diff --git a/.github/wiki b/.github/wiki index 6feadca091..d3bec40285 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit 6feadca0911ba4de9a4a7e711d591df343c0a06e +Subproject commit d3bec40285be65e5502a5b522c7b395c87fae7a1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 97706a1b27..9af6c6449a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Render managed GrumPHP hook fallback paths relative to the consumer project so global `dev-tools:sync` installs keep local GrumPHP hook execution working (#305) + ## [1.24.4] - 2026-04-30 ### Fixed diff --git a/docs/getting-started/installation.rst b/docs/getting-started/installation.rst index 3b7a96a2b9..03c42f6de7 100644 --- a/docs/getting-started/installation.rst +++ b/docs/getting-started/installation.rst @@ -38,7 +38,8 @@ following steps: ``.editorconfig``, and ``.github/dependabot.yml``, and refreshes ``.gitignore``, ``.gitattributes``, the project license, and packaged Git hooks that prefer a project-local ``grumphp.yml`` override and otherwise - use the active packaged DevTools ``grumphp.yml`` path. + use a project-relative reference to the active packaged DevTools + ``grumphp.yml`` path. 6. If ``.github/wiki`` is missing, ``dev-tools:sync`` adds it as a Git submodule that points to the repository wiki. 7. ``dev-tools:sync`` runs ``gitignore`` to merge canonical ignore rules into diff --git a/docs/running/specialized-commands.rst b/docs/running/specialized-commands.rst index 7208ae3a63..fc4d83b277 100644 --- a/docs/running/specialized-commands.rst +++ b/docs/running/specialized-commands.rst @@ -390,8 +390,8 @@ Important details: .gitignore; - it calls ``gitattributes`` to manage export-ignore rules in .gitattributes; - it refreshes packaged Git hooks that prefer a local ``grumphp.yml`` - override and otherwise use the active packaged DevTools ``grumphp.yml`` - path resolved when sync installs them; + override and otherwise use a project-relative reference to the active + packaged DevTools ``grumphp.yml`` path resolved when sync installs them; - it calls ``skills`` so ``.agents/skills`` contains links to the packaged skill set; - it calls ``agents`` so ``.agents/agents`` contains links to the packaged diff --git a/docs/usage/syncing-consumer-projects.rst b/docs/usage/syncing-consumer-projects.rst index d2223fa79a..09196cd7e0 100644 --- a/docs/usage/syncing-consumer-projects.rst +++ b/docs/usage/syncing-consumer-projects.rst @@ -53,8 +53,8 @@ What the Command Changes - Only when missing. * - ``.git/hooks/*`` - Copies packaged hooks that prefer a local ``grumphp.yml`` override and - otherwise use the active packaged DevTools ``grumphp.yml`` path - resolved when sync installs them. + otherwise use a project-relative reference to the active packaged + DevTools ``grumphp.yml`` path resolved when sync installs them. - Replaced when drift is detected. When to Run It diff --git a/src/Console/Command/GitHooksCommand.php b/src/Console/Command/GitHooksCommand.php index ae169c290b..9b08e94ec5 100644 --- a/src/Console/Command/GitHooksCommand.php +++ b/src/Console/Command/GitHooksCommand.php @@ -130,6 +130,7 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { $sourcePath = $this->fileLocator->locate((string) $input->getOption('source')); + $projectPath = (string) $this->filesystem->getAbsolutePath('.'); $targetPath = (string) $this->filesystem->getAbsolutePath((string) $input->getOption('target')); $overwrite = ! $input->getOption('no-overwrite'); $dryRun = (bool) $input->getOption('dry-run'); @@ -147,7 +148,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int foreach ($files as $file) { $sourcePath = $file->getRealPath(); $sourceContents = $this->filesystem->readFile($sourcePath); - $renderedSourceContents = $this->hookContentRenderer->render($sourceContents); + $renderedSourceContents = $this->hookContentRenderer->render($sourceContents, $projectPath); $hookPath = Path::join($targetPath, $file->getRelativePathname()); if (! $overwrite && ! $dryRun && ! $check && ! $interactive && $this->filesystem->exists($hookPath)) { diff --git a/src/GitHooks/HookContentRenderer.php b/src/GitHooks/HookContentRenderer.php index 65e063a8db..3757b16e3e 100644 --- a/src/GitHooks/HookContentRenderer.php +++ b/src/GitHooks/HookContentRenderer.php @@ -27,7 +27,7 @@ final class HookContentRenderer { /** - * Placeholder replaced with the active packaged GrumPHP config path. + * Placeholder replaced with the packaged GrumPHP config path rendered relative to the project when possible. */ public const string MANAGED_GRUMPHP_CONFIG_PLACEHOLDER = '__DEV_TOOLS_GRUMPHP_CONFIG__'; @@ -35,14 +35,15 @@ final class HookContentRenderer * Renders the hook contents for the active DevTools runtime. * * @param string $contents the packaged hook contents + * @param string $projectPath the consumer project root that will own the synchronized hook * * @return string the rendered hook contents */ - public function render(string $contents): string + public function render(string $contents, string $projectPath = ''): string { return str_replace( self::MANAGED_GRUMPHP_CONFIG_PLACEHOLDER, - escapeshellarg(DevToolsPathResolver::getPackagePath('grumphp.yml')), + escapeshellarg(DevToolsPathResolver::getPackagePathRelativeToProject('grumphp.yml', $projectPath)), $contents, ); } diff --git a/src/Path/DevToolsPathResolver.php b/src/Path/DevToolsPathResolver.php index 021c280afa..29f9d34d44 100644 --- a/src/Path/DevToolsPathResolver.php +++ b/src/Path/DevToolsPathResolver.php @@ -49,13 +49,7 @@ final class DevToolsPathResolver */ public static function getPackagePath(string $path = ''): string { - $packageDirectory = \dirname(__DIR__, 2); - - if ('' !== $path && Path::isAbsolute($path)) { - throw new InvalidArgumentException('The DevTools package path MUST be relative to the package root.'); - } - - return Path::join($packageDirectory, $path); + return self::resolvePackageRelativePath($path); } /** @@ -88,6 +82,28 @@ public static function getResourcesPath(string $path = ''): string return self::getPackagePath(Path::join(self::RESOURCES, $path)); } + /** + * Returns a packaged path rendered relative to the active project root when possible. + * + * When the project root and package root do not share a filesystem root, + * the packaged absolute path MUST be returned unchanged so globally + * installed DevTools can still point hooks at the packaged fallback file. + * + * @param string $path the relative path under the package root + * @param string $projectPath an optional project root path; defaults to the working project root + * @param string $packagePath an optional package root path; defaults to the current package root + */ + public static function getPackagePathRelativeToProject( + string $path, + string $projectPath = '', + string $packagePath = '', + ): string { + return self::relativizePathFromProject( + self::resolvePackageRelativePath($path, $packagePath), + self::resolveProjectPath($projectPath), + ); + } + /** * Returns the active Composer autoload file for the current DevTools installation mode. * @@ -99,13 +115,7 @@ public static function getResourcesPath(string $path = ''): string */ public static function getRuntimeAutoloadPath(string $packagePath = ''): string { - $packagePath = Path::canonicalize('' === $packagePath ? self::getPackagePath() : $packagePath); - - if (self::isInstalledAsDependency($packagePath)) { - return Path::canonicalize(Path::join($packagePath, '..', '..', 'autoload.php')); - } - - return Path::join($packagePath, 'vendor', 'autoload.php'); + return Path::join(self::getRuntimeVendorRoot($packagePath), 'autoload.php'); } /** @@ -119,13 +129,7 @@ public static function getRuntimeAutoloadPath(string $packagePath = ''): string */ public static function getRuntimeToolBinaryPath(string $binary, string $packagePath = ''): string { - $packagePath = Path::canonicalize('' === $packagePath ? self::getPackagePath() : $packagePath); - - if (self::isInstalledAsDependency($packagePath)) { - return Path::canonicalize(Path::join($packagePath, '..', '..', 'bin', $binary)); - } - - return Path::join($packagePath, 'vendor', 'bin', $binary); + return self::getRuntimeVendorPath(Path::join('bin', $binary), $packagePath); } /** @@ -139,14 +143,7 @@ public static function getRuntimeToolBinaryPath(string $binary, string $packageP */ public static function getRuntimeVendorPath(string $path, string $packagePath = ''): string { - $packagePath = Path::canonicalize('' === $packagePath ? self::getPackagePath() : $packagePath); - $vendorPath = self::normalizeVendorRelativePath($path); - - if (self::isInstalledAsDependency($packagePath)) { - return Path::canonicalize(Path::join($packagePath, '..', '..', $vendorPath)); - } - - return Path::join($packagePath, 'vendor', $vendorPath); + return Path::join(self::getRuntimeVendorRoot($packagePath), self::normalizeVendorRelativePath($path)); } /** @@ -165,14 +162,10 @@ public static function getPreferredToolBinaryPath( string $projectPath = '', string $packagePath = '', ): string { - $projectPath = '' === $projectPath ? WorkingProjectPathResolver::getProjectPath() : $projectPath; - $projectBinaryPath = Path::join($projectPath, 'vendor', 'bin', $binary); - - if (file_exists($projectBinaryPath)) { - return $projectBinaryPath; - } - - return self::getRuntimeToolBinaryPath($binary, $packagePath); + return self::preferExistingPath( + self::getProjectVendorPath(Path::join('bin', $binary), $projectPath), + self::getRuntimeToolBinaryPath($binary, $packagePath), + ); } /** @@ -191,15 +184,10 @@ public static function getPreferredVendorPath( string $projectPath = '', string $packagePath = '', ): string { - $projectPath = '' === $projectPath ? WorkingProjectPathResolver::getProjectPath() : $projectPath; - $vendorPath = self::normalizeVendorRelativePath($path); - $projectVendorPath = Path::join($projectPath, 'vendor', $vendorPath); - - if (file_exists($projectVendorPath)) { - return $projectVendorPath; - } - - return self::getRuntimeVendorPath($vendorPath, $packagePath); + return self::preferExistingPath( + self::getProjectVendorPath($path, $projectPath), + self::getRuntimeVendorPath($path, $packagePath), + ); } /** @@ -209,9 +197,7 @@ public static function getPreferredVendorPath( */ public static function isInstalledAsDependency(string $packagePath = ''): bool { - $packagePath = Path::canonicalize('' === $packagePath ? self::getPackagePath() : $packagePath); - - return str_contains($packagePath, self::VENDOR_PACKAGE_PATH); + return str_contains(self::resolvePackageRoot($packagePath), self::VENDOR_PACKAGE_PATH); } /** @@ -239,4 +225,109 @@ private static function normalizeVendorRelativePath(string $path): string return $path; } + + /** + * Ensures packaged paths stay relative to the DevTools package root. + * + * @param string $path the package-relative path to validate + */ + private static function assertRelativePackagePath(string $path): void + { + if ('' !== $path && Path::isAbsolute($path)) { + throw new InvalidArgumentException('The DevTools package path MUST be relative to the package root.'); + } + } + + /** + * Returns a canonical path under the DevTools package root. + * + * @param string $path the package-relative path to resolve + * @param string $packagePath an optional package root path; defaults to the current package root + */ + private static function resolvePackageRelativePath(string $path = '', string $packagePath = ''): string + { + self::assertRelativePackagePath($path); + + return Path::canonicalize(Path::join(self::resolvePackageRoot($packagePath), $path)); + } + + /** + * Returns the canonical DevTools package root. + * + * @param string $packagePath an optional package root path; defaults to the current package root + */ + private static function resolvePackageRoot(string $packagePath = ''): string + { + return Path::canonicalize('' === $packagePath ? \dirname(__DIR__, 2) : $packagePath); + } + + /** + * Returns the canonical working project root. + * + * @param string $projectPath an optional project root path; defaults to the working project root + */ + private static function resolveProjectPath(string $projectPath = ''): string + { + return Path::canonicalize(WorkingProjectPathResolver::getProjectPath($projectPath)); + } + + /** + * Returns the active Composer vendor root for the current DevTools installation mode. + * + * @param string $packagePath an optional package root path; defaults to the current package root + */ + private static function getRuntimeVendorRoot(string $packagePath = ''): string + { + $packagePath = self::resolvePackageRoot($packagePath); + + if (self::isInstalledAsDependency($packagePath)) { + return Path::canonicalize(Path::join($packagePath, '..', '..')); + } + + return Path::join($packagePath, 'vendor'); + } + + /** + * Returns a vendor path under the active project root. + * + * @param string $path the vendor-relative path to resolve + * @param string $projectPath an optional project root path; defaults to the working project root + */ + private static function getProjectVendorPath(string $path, string $projectPath = ''): string + { + return Path::join(self::resolveProjectPath($projectPath), 'vendor', self::normalizeVendorRelativePath($path)); + } + + /** + * Returns the preferred path when a project-local candidate exists. + * + * @param string $preferredPath the project-local candidate path + * @param string $fallbackPath the runtime fallback path + */ + private static function preferExistingPath(string $preferredPath, string $fallbackPath): string + { + if (file_exists($preferredPath)) { + return $preferredPath; + } + + return $fallbackPath; + } + + /** + * Returns a path relative to the project root when possible. + * + * When paths do not share the same filesystem root, the original absolute + * path MUST be returned unchanged so callers still receive a usable path. + * + * @param string $path the absolute path to relativize + * @param string $projectPath the absolute project root used as base path + */ + private static function relativizePathFromProject(string $path, string $projectPath): string + { + try { + return Path::makeRelative($path, $projectPath); + } catch (InvalidArgumentException) { + return $path; + } + } } diff --git a/tests/Console/Command/GitHooksCommandTest.php b/tests/Console/Command/GitHooksCommandTest.php index 11afe1b407..17d014b5b9 100644 --- a/tests/Console/Command/GitHooksCommandTest.php +++ b/tests/Console/Command/GitHooksCommandTest.php @@ -25,6 +25,7 @@ use FastForward\DevTools\Filesystem\FilesystemInterface; use FastForward\DevTools\GitHooks\HookContentRenderer; use FastForward\DevTools\Path\DevToolsPathResolver; +use FastForward\DevTools\Path\WorkingProjectPathResolver; use FastForward\DevTools\Resource\FileDiff; use FastForward\DevTools\Resource\FileDiffer; use PHPUnit\Framework\Attributes\CoversClass; @@ -54,6 +55,7 @@ #[CoversClass(GitHooksCommand::class)] #[UsesClass(DevToolsPathResolver::class)] +#[UsesClass(WorkingProjectPathResolver::class)] #[UsesClass(FileDiff::class)] #[UsesClass(HookContentRenderer::class)] #[UsesTrait(LogsCommandResults::class)] @@ -114,6 +116,8 @@ protected function setUp(): void ->willReturn(false); $this->input->isInteractive() ->willReturn(false); + $this->filesystem->getAbsolutePath('.') + ->willReturn('/app'); $this->filesystem->readFile(Argument::containingString('/post-merge')) ->willReturn('#!/bin/sh'); @@ -206,7 +210,7 @@ public function executeWillRenderManagedGrumPhpConfigIntoPlaceholderHooks(): voi $this->input->getOption('source') ->willReturn('resources/git-hooks'); $this->input->getOption('target') - ->willReturn('.git/hooks'); + ->willReturn('.githooks'); $this->input->getOption('no-overwrite') ->willReturn(false); @@ -215,22 +219,22 @@ public function executeWillRenderManagedGrumPhpConfigIntoPlaceholderHooks(): voi $this->finderFactory->create() ->willReturn(new Finder()) ->shouldBeCalledOnce(); - $this->filesystem->getAbsolutePath('.git/hooks') - ->willReturn('/app/.git/hooks'); - $this->filesystem->exists('/app/.git/hooks/post-merge') + $this->filesystem->getAbsolutePath('.githooks') + ->willReturn('/app/.githooks'); + $this->filesystem->exists('/app/.githooks/post-merge') ->willReturn(false); - $this->filesystem->exists('/app/.git/hooks/pre-commit') + $this->filesystem->exists('/app/.githooks/pre-commit') ->willReturn(false); $this->filesystem->readFile(Argument::containingString('/pre-commit')) ->willReturn("DEVTOOLS_GRUMPHP_CONFIG=__DEV_TOOLS_GRUMPHP_CONFIG__\n"); - $this->filesystem->copy(Argument::containingString('/post-merge'), '/app/.git/hooks/post-merge', false) + $this->filesystem->copy(Argument::containingString('/post-merge'), '/app/.githooks/post-merge', false) ->shouldBeCalledOnce(); $this->filesystem->dumpFile( - '/app/.git/hooks/pre-commit', + '/app/.githooks/pre-commit', Argument::that( static fn(string $contents): bool => str_contains( $contents, - escapeshellarg(DevToolsPathResolver::getPackagePath('grumphp.yml')) + escapeshellarg(DevToolsPathResolver::getPackagePathRelativeToProject('grumphp.yml', '/app')) ) ), )->shouldBeCalledOnce(); diff --git a/tests/GitHooks/HookContentRendererTest.php b/tests/GitHooks/HookContentRendererTest.php index b58308d1b4..3155b8cc1d 100644 --- a/tests/GitHooks/HookContentRendererTest.php +++ b/tests/GitHooks/HookContentRendererTest.php @@ -21,6 +21,7 @@ use FastForward\DevTools\GitHooks\HookContentRenderer; use FastForward\DevTools\Path\DevToolsPathResolver; +use FastForward\DevTools\Path\WorkingProjectPathResolver; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -28,6 +29,7 @@ #[CoversClass(HookContentRenderer::class)] #[UsesClass(DevToolsPathResolver::class)] +#[UsesClass(WorkingProjectPathResolver::class)] final class HookContentRendererTest extends TestCase { /** @@ -39,8 +41,13 @@ public function renderWillReplaceTheManagedGrumPhpConfigPlaceholder(): void $renderer = new HookContentRenderer(); self::assertSame( - 'DEVTOOLS_GRUMPHP_CONFIG=' . escapeshellarg(DevToolsPathResolver::getPackagePath('grumphp.yml')), - $renderer->render('DEVTOOLS_GRUMPHP_CONFIG=' . HookContentRenderer::MANAGED_GRUMPHP_CONFIG_PLACEHOLDER), + 'DEVTOOLS_GRUMPHP_CONFIG=' . escapeshellarg( + DevToolsPathResolver::getPackagePathRelativeToProject('grumphp.yml', '/workspaces/project') + ), + $renderer->render( + 'DEVTOOLS_GRUMPHP_CONFIG=' . HookContentRenderer::MANAGED_GRUMPHP_CONFIG_PLACEHOLDER, + '/workspaces/project' + ), ); } } diff --git a/tests/Path/DevToolsPathResolverTest.php b/tests/Path/DevToolsPathResolverTest.php index c82ff3f9f2..5bd1fc77a1 100644 --- a/tests/Path/DevToolsPathResolverTest.php +++ b/tests/Path/DevToolsPathResolverTest.php @@ -46,6 +46,10 @@ public function itWillExposeCanonicalPackagePaths(): void self::assertSame(\dirname(__DIR__, 2), DevToolsPathResolver::getPackagePath()); self::assertSame(\dirname(__DIR__, 2) . '/bin/dev-tools', DevToolsPathResolver::getBinaryPath()); self::assertSame(\dirname(__DIR__, 2) . '/resources', DevToolsPathResolver::getResourcesPath()); + self::assertSame( + 'grumphp.yml', + DevToolsPathResolver::getPackagePathRelativeToProject('grumphp.yml', \dirname(__DIR__, 2)) + ); self::assertSame(\dirname(__DIR__, 2) . '/vendor/autoload.php', DevToolsPathResolver::getRuntimeAutoloadPath()); self::assertSame( \dirname(__DIR__, 2) . '/vendor/bin/ecs', @@ -114,6 +118,30 @@ public function itWillResolveRuntimeAutoloadAndVendorPathsForRepositoryAndDepend '/workspaces/project/vendor/fast-forward/dev-tools' ) ); + self::assertSame( + 'vendor/fast-forward/dev-tools/grumphp.yml', + DevToolsPathResolver::getPackagePathRelativeToProject( + 'grumphp.yml', + '/workspaces/project', + '/workspaces/project/vendor/fast-forward/dev-tools' + ) + ); + self::assertSame( + '../../Users/example/.composer/vendor/fast-forward/dev-tools/grumphp.yml', + DevToolsPathResolver::getPackagePathRelativeToProject( + 'grumphp.yml', + '/workspaces/project', + '/Users/example/.composer/vendor/fast-forward/dev-tools' + ) + ); + self::assertSame( + 'C:/Users/example/.composer/vendor/fast-forward/dev-tools/grumphp.yml', + DevToolsPathResolver::getPackagePathRelativeToProject( + 'grumphp.yml', + 'D:/workspaces/project', + 'C:/Users/example/.composer/vendor/fast-forward/dev-tools' + ) + ); } /**