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 {