From 15afba6fb7f7b07b6291df2d0fd5293e9b7e39e9 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 17 Jul 2026 15:44:09 +0200 Subject: [PATCH] Read files from the worktree git dir instead of the shared repository Inside a linked git worktree, GrumPHP listed and diffed files against the main repository's branch instead of the worktree's own. A task could then fail on a file that exists on main but not in the worktree: yamllint: SplFileInfo::openFile(config/services/commands.yaml): Failed to open stream: No such file or directory A worktree's .git is a file pointing at
/.git/worktrees/, and that directory's commondir points back to the shared
/.git. GrumPHP collapsed the worktree straight to that shared directory and used it for everything, including the git client that lists files. So "git --git-dir=
/.git ls-files" returned main's index. The gitonomy client passes --git-dir on the command line, which overrides GIT_DIR, so correcting the environment around GrumPHP could not reach it. Collapsing to the shared directory is correct for hooks: a worktree runs the main repository's hooks. So the two concerns are now separate. Hooks still resolve to the shared .git; the git client that lists files and reads diffs points at the worktree's own git dir. A normal checkout and a submodule resolve as before. GRUMPHP_GIT_REPOSITORY_DIR still overrides the resolved directory and now drives both the hooks dir and the file-listing dir, which matches how it behaved before the split. --- doc/installation/exotic.md | 2 + src/Configuration/GuessedPaths.php | 14 +++++- .../EnrichedGuessedPathsFromDotEnvLocator.php | 7 ++- src/Locator/GitRepositoryDirLocator.php | 24 ++++++++- src/Locator/GitRepositoryLocator.php | 2 +- src/Locator/GuessedPathsLocator.php | 9 +++- src/Util/Paths.php | 9 ++++ test/E2E/AbstractE2ETestCase.php | 19 +++++++ test/E2E/SpecialGitStructuresTest.php | 30 +++++++++++ ...ichedGuessedPathsFromDotEnvLocatorTest.php | 20 ++++++++ .../Locator/GitRepositoryDirLocatorTest.php | 50 +++++++++++++++++++ test/Unit/Locator/GuessedPathsLocatorTest.php | 3 ++ test/Unit/Util/PathsTest.php | 29 +++++++++++ 13 files changed, 212 insertions(+), 6 deletions(-) diff --git a/doc/installation/exotic.md b/doc/installation/exotic.md index 86848475c..8403c7188 100644 --- a/doc/installation/exotic.md +++ b/doc/installation/exotic.md @@ -94,6 +94,8 @@ The path to the root directory of your git project. The path to the directory in which git stores it objects etc. Most of the time this is the .git folder. When using GIT submodules, this is the location of the submodule .git folder. +Inside a linked git worktree, this points at the shared repository root, since worktrees run the hooks +of the main repository. File listing and diffs still use the worktree's own branch. **GRUMPHP_COMPOSER_DIR** diff --git a/src/Configuration/GuessedPaths.php b/src/Configuration/GuessedPaths.php index b77b1077a..cfdd02be7 100644 --- a/src/Configuration/GuessedPaths.php +++ b/src/Configuration/GuessedPaths.php @@ -43,6 +43,11 @@ class GuessedPaths */ private $configFile; + /** + * @var string + */ + private $gitWorktreeDir; + public function __construct( string $gitWorkingDir, string $gitRepositoryDir, @@ -50,7 +55,8 @@ public function __construct( string $projectDir, string $binDir, ComposerFile $composerFile, - string $configFile + string $configFile, + ?string $gitWorktreeDir = null ) { $this->gitWorkingDir = $gitWorkingDir; $this->gitRepositoryDir = $gitRepositoryDir; @@ -59,6 +65,7 @@ public function __construct( $this->binDir = $binDir; $this->composerFile = $composerFile; $this->configFile = $configFile; + $this->gitWorktreeDir = $gitWorktreeDir ?? $gitRepositoryDir; } public function getGitWorkingDir(): string @@ -71,6 +78,11 @@ public function getGitRepositoryDir(): string return $this->gitRepositoryDir; } + public function getGitWorktreeDir(): string + { + return $this->gitWorktreeDir; + } + public function getWorkingDir(): string { return $this->workingDir; diff --git a/src/Locator/EnrichedGuessedPathsFromDotEnvLocator.php b/src/Locator/EnrichedGuessedPathsFromDotEnvLocator.php index 3bb3ecff8..43d01bf71 100644 --- a/src/Locator/EnrichedGuessedPathsFromDotEnvLocator.php +++ b/src/Locator/EnrichedGuessedPathsFromDotEnvLocator.php @@ -37,6 +37,10 @@ public function locate(GuessedPaths $guessedPaths): GuessedPaths (string) ($_SERVER['GRUMPHP_GIT_REPOSITORY_DIR'] ?? $guessedPaths->getGitRepositoryDir()), $workingDir ); + $gitWorktreeDir = $this->filesystem->makePathAbsolute( + (string) ($_SERVER['GRUMPHP_GIT_REPOSITORY_DIR'] ?? $guessedPaths->getGitWorktreeDir()), + $workingDir + ); $binDir = $this->filesystem->makePathAbsolute( (string) ($_SERVER['GRUMPHP_BIN_DIR'] ?? $guessedPaths->getBinDir()), $workingDir @@ -49,7 +53,8 @@ public function locate(GuessedPaths $guessedPaths): GuessedPaths $projectDir, $binDir, $guessedPaths->getComposerFile(), - $guessedPaths->getConfigFile() + $guessedPaths->getConfigFile(), + $gitWorktreeDir ); } } diff --git a/src/Locator/GitRepositoryDirLocator.php b/src/Locator/GitRepositoryDirLocator.php index 32ea7641b..169a64810 100644 --- a/src/Locator/GitRepositoryDirLocator.php +++ b/src/Locator/GitRepositoryDirLocator.php @@ -20,9 +20,27 @@ public function __construct(Filesystem $filesystem) /** * Resolves the path to the git repository directory (aka as .git). - * For submodules, it parses the .git file and resolves to the .git/modules/[submodules] directory + * For submodules, it parses the .git file and resolves to the .git/modules/[submodules] directory. + * For worktrees, it resolves to the common repository root, since worktrees share the main + * repository's hooks. Use locateWorktreeGitDir() when the worktree's own git dir is required. */ public function locate(string $gitDir): string + { + return $this->resolveGitDir($gitDir, true); + } + + /** + * Resolves the git directory used to read files and diffs. + * For worktrees this returns the worktree's own git dir (.git/worktrees/[id]) instead of collapsing + * to the common repository root, so file listing and diffs reflect the worktree's branch. + * For submodules and normal checkouts this is identical to locate(). + */ + public function locateWorktreeGitDir(string $gitDir): string + { + return $this->resolveGitDir($gitDir, false); + } + + private function resolveGitDir(string $gitDir, bool $collapseWorktreeToRoot): string { if (!$this->filesystem->isFile($gitDir)) { return $gitDir; @@ -36,7 +54,9 @@ public function locate(string $gitDir): string $gitRepositoryDir = $matches[1]; if ($this->isWorktree($gitRepositoryDir)) { - return $this->locateWorktreeRoot($gitRepositoryDir); + return $collapseWorktreeToRoot + ? $this->locateWorktreeRoot($gitRepositoryDir) + : $gitRepositoryDir; } return $this->filesystem->buildPath( diff --git a/src/Locator/GitRepositoryLocator.php b/src/Locator/GitRepositoryLocator.php index 967278fe0..1662632eb 100644 --- a/src/Locator/GitRepositoryLocator.php +++ b/src/Locator/GitRepositoryLocator.php @@ -22,7 +22,7 @@ public function __construct(Paths $paths) public function locate(array $options): Repository { return new Repository( - $this->paths->getGitRepositoryDir(), + $this->paths->getGitWorktreeDir(), array_merge( [ 'working_dir' => $this->paths->getGitWorkingDir(), diff --git a/src/Locator/GuessedPathsLocator.php b/src/Locator/GuessedPathsLocator.php index a9ef4946b..90d803d83 100644 --- a/src/Locator/GuessedPathsLocator.php +++ b/src/Locator/GuessedPathsLocator.php @@ -56,6 +56,12 @@ public function locate(?string $cliConfigFile): GuessedPaths )), $workingDir ); + $gitWorktreeDir = $this->filesystem->makePathAbsolute( + (string) ($_SERVER['GRUMPHP_GIT_REPOSITORY_DIR'] ?? $this->gitRepositoryDirLocator->locateWorktreeGitDir( + $this->filesystem->buildPath($gitWorkingDir, '.git') + )), + $workingDir + ); $composerFilePathname = $this->filesystem->guessFile( [ @@ -125,7 +131,8 @@ public function locate(?string $cliConfigFile): GuessedPaths $projectDir, $binDir, $composerFile, - $defaultConfigFile + $defaultConfigFile, + $gitWorktreeDir ); } diff --git a/src/Util/Paths.php b/src/Util/Paths.php index badfe8445..773c530eb 100644 --- a/src/Util/Paths.php +++ b/src/Util/Paths.php @@ -69,6 +69,15 @@ public function getGitRepositoryDir(): string return $this->guessedPaths->getGitRepositoryDir(); } + /** + * The git dir to read files and diffs from. Inside a linked worktree this is the worktree's own + * git dir; for normal checkouts and submodules it equals getGitRepositoryDir(). + */ + public function getGitWorktreeDir(): string + { + return $this->guessedPaths->getGitWorktreeDir(); + } + public function getBinDir(): string { return $this->guessedPaths->getBinDir(); diff --git a/test/E2E/AbstractE2ETestCase.php b/test/E2E/AbstractE2ETestCase.php index dfa624a71..6f093604c 100644 --- a/test/E2E/AbstractE2ETestCase.php +++ b/test/E2E/AbstractE2ETestCase.php @@ -87,6 +87,25 @@ protected function initializeGitSubModule(string $gitPath, string $submodulePath return $this->filesystem->buildPath($gitPath, basename($submodulePath)); } + protected function addGitWorktree(string $fromGitPath, string $worktreeName, string $branch): string + { + $this->changeGitPermissions(); + $worktreePath = $this->relativeRootPath($worktreeName); + $this->runCommand('add git worktree', new Process( + [$this->executableFinder->find('git'), 'worktree', 'add', '-b', $branch, $worktreePath], + $fromGitPath + )); + + return $worktreePath; + } + + protected function commitAllWithoutHook(string $gitPath) + { + $git = $this->executableFinder->find('git'); + $this->gitAddPath($gitPath); + $this->runCommand('commit without hook', new Process([$git, 'commit', '--no-verify', '-mtest'], $gitPath)); + } + protected function appendToGitignore(string $gitPath, array $paths = ['vendor']) { $gitignore = $this->filesystem->buildPath($gitPath, '.gitignore'); diff --git a/test/E2E/SpecialGitStructuresTest.php b/test/E2E/SpecialGitStructuresTest.php index ec767d7ea..8b2bf8935 100644 --- a/test/E2E/SpecialGitStructuresTest.php +++ b/test/E2E/SpecialGitStructuresTest.php @@ -29,6 +29,36 @@ function it_runs_inside_a_submodule() $this->runGrumphp($submoduleInMain); } + #[Test] + function it_runs_inside_a_worktree() + { + $main = $this->mkdir('main'); + + $this->initializeGit($main); + $this->appendToGitignore($main); + $this->initializeComposer($main); + $grumphpFile = $this->initializeGrumphpConfig($main); + $this->installComposer($main); + $this->ensureHooksExist($main); + + // A file that lives on the main branch but not on the worktree's branch: + $this->dumpFile($this->filesystem->buildPath($main, 'only-on-main.txt'), 'main'); + $this->enableValidatePathsTask($grumphpFile, $main); + $this->commitAll($main); + + // A linked worktree on a diverging branch that drops the main-only file: + $worktree = $this->addGitWorktree($main, 'worktree', 'feature'); + $this->filesystem->remove($this->filesystem->buildPath($worktree, 'only-on-main.txt')); + $this->commitAllWithoutHook($worktree); + + $this->installComposer($worktree); + $worktreeGrumphpFile = $this->initializeGrumphpConfig($worktree); + $this->enableValidatePathsTask($worktreeGrumphpFile, $worktree); + + // GrumPHP must list the worktree's branch, so only-on-main.txt stays out of the file list: + $this->runGrumphp($worktree); + } + #[Test] function it_handles_partial_commits() { diff --git a/test/Unit/Locator/EnrichedGuessedPathsFromDotEnvLocatorTest.php b/test/Unit/Locator/EnrichedGuessedPathsFromDotEnvLocatorTest.php index 5dbd17dac..ba06095a1 100644 --- a/test/Unit/Locator/EnrichedGuessedPathsFromDotEnvLocatorTest.php +++ b/test/Unit/Locator/EnrichedGuessedPathsFromDotEnvLocatorTest.php @@ -83,6 +83,26 @@ function (Filesystem $filesystem, string $workspace) use ($configure) { ]; + yield 'keep-distinct-worktree-dir' => [ + function (Filesystem $filesystem, string $workspace) use ($configure) { + \Closure::bind($configure, $this)($workspace); + }, + $inputWithWorktree = function (string $workspace) { + return new GuessedPaths( + $workspace, + $this->path('.git'), + $workspace, + $workspace, + $this->path('vendor/bin'), + new ComposerFile($this->path('composer.json'), []), + $this->path('grumphp.yml'), + $this->path('.git/worktrees/wt1') + ); + }, + $inputWithWorktree + ]; + + yield 'overwritten-config' => [ function (Filesystem $filesystem, string $workspace) use ($configure) { \Closure::bind($configure, $this)($workspace); diff --git a/test/Unit/Locator/GitRepositoryDirLocatorTest.php b/test/Unit/Locator/GitRepositoryDirLocatorTest.php index 4cd1532db..9375cf274 100644 --- a/test/Unit/Locator/GitRepositoryDirLocatorTest.php +++ b/test/Unit/Locator/GitRepositoryDirLocatorTest.php @@ -69,4 +69,54 @@ public function it_can_locate_git_dir_in_workspaces(): void $this->filesystem->dumpFile($ourWorktreeProject.'/.git', 'gitdir: '.$this->gitDir.'/worktrees/worktree1'); $this->assertEquals($this->gitDir, $this->locator->locate($this->gitDir)); } + + #[Test] + public function it_collapses_a_worktree_to_the_common_root_for_hooks(): void + { + [$worktreeGitFile, ] = $this->setupWorktree(); + + $this->assertEquals($this->gitDir, $this->locator->locate($worktreeGitFile)); + } + + #[Test] + public function it_locates_the_worktree_own_git_dir_for_file_listing(): void + { + [$worktreeGitFile, $worktreeGitDir] = $this->setupWorktree(); + + $this->assertEquals($worktreeGitDir, $this->locator->locateWorktreeGitDir($worktreeGitFile)); + } + + #[Test] + public function it_locates_worktree_git_dir_identically_to_locate_for_normal_checkout(): void + { + $this->filesystem->mkdir($this->gitDir); + $this->assertEquals($this->gitDir, $this->locator->locateWorktreeGitDir($this->gitDir)); + } + + #[Test] + public function it_locates_worktree_git_dir_identically_to_locate_for_submodule(): void + { + $this->filesystem->dumpFile($this->gitDir, 'gitdir: ../dev/null'); + $this->assertEquals( + $this->workspace . DIRECTORY_SEPARATOR . '../dev/null', + $this->locator->locateWorktreeGitDir($this->gitDir) + ); + } + + /** + * @return array{0: string, 1: string} [worktree .git file, worktree own git dir] + */ + private function setupWorktree(): array + { + $worktreeGitDir = $this->gitDir.DIRECTORY_SEPARATOR.'worktrees'.DIRECTORY_SEPARATOR.'worktree1'; + $this->filesystem->mkdir($worktreeGitDir); + $this->filesystem->dumpFile($worktreeGitDir.DIRECTORY_SEPARATOR.'commondir', '../..'); + + $worktreeProject = $this->workspace.DIRECTORY_SEPARATOR.'worktree-project'; + $this->filesystem->mkdir($worktreeProject); + $worktreeGitFile = $worktreeProject.DIRECTORY_SEPARATOR.'.git'; + $this->filesystem->dumpFile($worktreeGitFile, 'gitdir: '.$worktreeGitDir); + + return [$worktreeGitFile, $worktreeGitDir]; + } } diff --git a/test/Unit/Locator/GuessedPathsLocatorTest.php b/test/Unit/Locator/GuessedPathsLocatorTest.php index 8301167b0..53dd61208 100644 --- a/test/Unit/Locator/GuessedPathsLocatorTest.php +++ b/test/Unit/Locator/GuessedPathsLocatorTest.php @@ -60,6 +60,9 @@ protected function setUp(): void $this->gitRepositoryDirLocator->locate(Argument::any())->will(function (array $arguments) { return $arguments[0]; }); + $this->gitRepositoryDirLocator->locateWorktreeGitDir(Argument::any())->will(function (array $arguments) { + return $arguments[0]; + }); $this->guesser = new GuessedPathsLocator( $this->filesystem, diff --git a/test/Unit/Util/PathsTest.php b/test/Unit/Util/PathsTest.php index 286361746..ab757fc0e 100644 --- a/test/Unit/Util/PathsTest.php +++ b/test/Unit/Util/PathsTest.php @@ -119,6 +119,35 @@ public function it_knows_the_git_hooks_directory(): void ); } + #[Test] + public function it_knows_the_git_worktree_directory(): void + { + $this->assertSame( + $this->guessedPaths->getGitWorktreeDir(), + $this->paths->getGitWorktreeDir() + ); + } + + #[Test] + public function it_separates_the_worktree_dir_from_the_hooks_dir_inside_a_worktree(): void + { + $guessedPaths = new GuessedPaths( + $this->workspace, + $common = $this->buildPath($this->workspace, '.git'), + $this->workspace, + $this->workspace, + $this->buildPath($this->workspace, 'vendor/bin'), + new ComposerFile($this->buildPath($this->workspace, 'composer.json'), []), + $this->buildPath($this->workspace, 'grumphp.json'), + $worktree = $this->buildPath($this->workspace, '.git/worktrees/wt1') + ); + $paths = new Paths($this->filesystem, $guessedPaths); + + $this->assertSame($worktree, $paths->getGitWorktreeDir()); + $this->assertSame($common, $paths->getGitRepositoryDir()); + $this->assertSame($this->buildPath($common, 'hooks'), $paths->getGitHooksDir()); + } + #[Test] public function it_knows_the_project_directory(): void {