From 803ef7327afe854c2c3c5fd33a8f6976f22d7dd4 Mon Sep 17 00:00:00 2001 From: Damilola Ogunmoye Date: Mon, 3 Aug 2026 15:35:34 +0100 Subject: [PATCH 1/2] fix: resolve git origin in linked worktrees for Tia storage key and baseline fetch In a linked git worktree, .git is a file pointing at the real git dir, so reading $projectRoot/.git/config directly fails. Both Storage::rawOriginUrl() and BaselineSync::detectGitHubRepo() bail out silently as a result: - the storage key falls back to a realpath hash, so worktrees of the same repository do not share the documented origin-derived project key, and - --tia --baselined silently skips the CI baseline fetch in every worktree. Keep the fast .git/config file read for regular checkouts and fall back to `git config --get remote.origin.url` (run inside the project root) when .git is not a directory. --- src/Plugins/Tia/BaselineSync.php | 58 ++++++++++++++++++++++++-------- src/Plugins/Tia/Storage.php | 43 +++++++++++++++++++---- 2 files changed, 81 insertions(+), 20 deletions(-) diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index 5b147883a..03d0bbcbd 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -177,24 +177,12 @@ private function isCi(): bool private function detectGitHubRepo(string $projectRoot): ?string { - $gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config'; - - if (! is_file($gitConfig)) { - return null; - } - - $content = @file_get_contents($gitConfig); + $url = $this->originUrl($projectRoot); - if ($content === false) { + if ($url === null) { return null; } - if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) { - return null; - } - - $url = $match[1]; - if (preg_match('#^git@github\.com:([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $m) === 1) { return $m[1]; } @@ -210,6 +198,48 @@ private function detectGitHubRepo(string $projectRoot): ?string return null; } + private function originUrl(string $projectRoot): ?string + { + $gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config'; + + if (is_file($gitConfig)) { + $content = @file_get_contents($gitConfig); + + if ($content === false) { + return null; + } + + if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) { + return null; + } + + return $match[1]; + } + + // Linked worktree: `.git` is a file pointing at the real git dir, so + // the config cannot be read directly — ask git itself instead. + if (! file_exists($projectRoot.DIRECTORY_SEPARATOR.'.git')) { + return null; + } + + $process = new Process(['git', 'config', '--get', 'remote.origin.url'], $projectRoot); + $process->setTimeout(5.0); + + try { + $process->run(); + } catch (\Throwable) { + return null; + } + + if (! $process->isSuccessful()) { + return null; + } + + $url = trim($process->getOutput()); + + return $url === '' ? null : $url; + } + /** * @return array{payload: array{graph: string, coverage: ?string, sizeOnDisk: int}|null, failureKind: ?string} */ diff --git a/src/Plugins/Tia/Storage.php b/src/Plugins/Tia/Storage.php index e7deeb6f7..28f0ce49c 100644 --- a/src/Plugins/Tia/Storage.php +++ b/src/Plugins/Tia/Storage.php @@ -119,21 +119,52 @@ private static function rawOriginUrl(string $projectRoot): ?string { $config = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config'; - if (! is_file($config)) { + if (is_file($config)) { + $raw = @file_get_contents($config); + + if ($raw === false) { + return null; + } + + if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $raw, $match) === 1) { + return trim($match[1]); + } + + return null; + } + + return self::originUrlFromGit($projectRoot); + } + + /** + * In a linked worktree, `.git` is a file pointing at the real git dir, so + * the config cannot be read directly — ask git itself instead. + */ + private static function originUrlFromGit(string $projectRoot): ?string + { + if (! file_exists($projectRoot.DIRECTORY_SEPARATOR.'.git')) { return null; } - $raw = @file_get_contents($config); + $process = new \Symfony\Component\Process\Process( + ['git', 'config', '--get', 'remote.origin.url'], + $projectRoot, + ); + $process->setTimeout(5.0); - if ($raw === false) { + try { + $process->run(); + } catch (\Throwable) { return null; } - if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $raw, $match) === 1) { - return trim($match[1]); + if (! $process->isSuccessful()) { + return null; } - return null; + $url = trim($process->getOutput()); + + return $url === '' ? null : $url; } private static function slug(string $name): string From 00c5ea8777a6d29276a2e41f16ee647e773013ba Mon Sep 17 00:00:00 2001 From: Damilola Ogunmoye Date: Tue, 4 Aug 2026 10:11:45 +0100 Subject: [PATCH 2/2] test: cover origin resolution in linked git worktrees Claude-Session: https://claude.ai/code/session_01HrxKKJs8vJ2jWShgp94Dz6 --- tests/Unit/Plugins/Tia/BaselineSync.php | 120 ++++++++++++++++++++++++ tests/Unit/Plugins/Tia/Storage.php | 98 +++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 tests/Unit/Plugins/Tia/BaselineSync.php create mode 100644 tests/Unit/Plugins/Tia/Storage.php diff --git a/tests/Unit/Plugins/Tia/BaselineSync.php b/tests/Unit/Plugins/Tia/BaselineSync.php new file mode 100644 index 000000000..5fcc79bd9 --- /dev/null +++ b/tests/Unit/Plugins/Tia/BaselineSync.php @@ -0,0 +1,120 @@ +setTimeout(10.0); + $process->mustRun(); +} + +function baselineSyncRepository(?string $origin): string +{ + $root = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest_baseline_sync_'.uniqid(); + mkdir($root, 0777, true); + + baselineSyncGit($root, 'init', '-q'); + baselineSyncGit($root, 'config', 'user.email', 'pest@example.com'); + baselineSyncGit($root, 'config', 'user.name', 'Pest'); + + if ($origin !== null) { + baselineSyncGit($root, 'remote', 'add', 'origin', $origin); + } + + return $root; +} + +function baselineSyncRemoveDirectory(string $path): void +{ + if (! is_dir($path)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $file) { + @chmod($file->getPathname(), 0777); + $file->isDir() ? @rmdir($file->getPathname()) : @unlink($file->getPathname()); + } + + @rmdir($path); +} + +function baselineSyncDetect(string $projectRoot): ?string +{ + $sync = new BaselineSync(new FileState($projectRoot.DIRECTORY_SEPARATOR.'.pest-state'), new NullOutput); + + /** @var ?string $repo */ + $repo = Reflection::call($sync, 'detectGitHubRepo', [$projectRoot]); + + return $repo; +} + +describe('detectGitHubRepo()', function (): void { + it('detects the repository from an ssh origin in a regular clone', function (): void { + $clone = baselineSyncRepository('git@github.com:foo/bar.git'); + + try { + expect(baselineSyncDetect($clone))->toBe('foo/bar'); + } finally { + baselineSyncRemoveDirectory($clone); + } + }); + + it('detects the repository from an https origin in a regular clone', function (): void { + $clone = baselineSyncRepository('https://github.com/foo/bar.git'); + + try { + expect(baselineSyncDetect($clone))->toBe('foo/bar'); + } finally { + baselineSyncRemoveDirectory($clone); + } + }); + + it('detects the repository inside a linked git worktree', function (): void { + $clone = baselineSyncRepository('git@github.com:foo/bar.git'); + $worktree = $clone.'-wt'; + + try { + file_put_contents($clone.'/README.md', 'pest'); + baselineSyncGit($clone, 'add', '-A'); + baselineSyncGit($clone, 'commit', '-q', '-m', 'init'); + baselineSyncGit($clone, 'worktree', 'add', '-q', $worktree); + + expect(baselineSyncDetect($worktree))->toBe('foo/bar'); + } finally { + baselineSyncRemoveDirectory($worktree); + baselineSyncRemoveDirectory($clone); + } + }); + + it('returns null for a non-github origin', function (): void { + $clone = baselineSyncRepository('git@gitlab.com:foo/bar.git'); + + try { + expect(baselineSyncDetect($clone))->toBeNull(); + } finally { + baselineSyncRemoveDirectory($clone); + } + }); + + it('returns null when there is no origin remote', function (): void { + $clone = baselineSyncRepository(null); + + try { + expect(baselineSyncDetect($clone))->toBeNull(); + } finally { + baselineSyncRemoveDirectory($clone); + } + }); +}); diff --git a/tests/Unit/Plugins/Tia/Storage.php b/tests/Unit/Plugins/Tia/Storage.php new file mode 100644 index 000000000..9403acc2b --- /dev/null +++ b/tests/Unit/Plugins/Tia/Storage.php @@ -0,0 +1,98 @@ +setTimeout(10.0); + $process->mustRun(); +} + +function storageRepository(?string $origin): string +{ + $root = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest_storage_'.uniqid(); + mkdir($root, 0777, true); + + storageGit($root, 'init', '-q'); + storageGit($root, 'config', 'user.email', 'pest@example.com'); + storageGit($root, 'config', 'user.name', 'Pest'); + + if ($origin !== null) { + storageGit($root, 'remote', 'add', 'origin', $origin); + } + + return $root; +} + +function storageRemoveDirectory(string $path): void +{ + if (! is_dir($path)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $file) { + @chmod($file->getPathname(), 0777); + $file->isDir() ? @rmdir($file->getPathname()) : @unlink($file->getPathname()); + } + + @rmdir($path); +} + +function storageKeyHash(string $projectRoot): string +{ + preg_match('/([a-f0-9]{16})$/', basename(Storage::tempDir($projectRoot)), $match); + + return $match[1] ?? ''; +} + +describe('tempDir()', function (): void { + it('derives the storage key from the origin remote in a regular clone', function (): void { + $clone = storageRepository('git@github.com:foo/bar.git'); + + try { + expect(storageKeyHash($clone))->toBe(substr(hash('sha256', 'github.com/foo/bar'), 0, 16)); + } finally { + storageRemoveDirectory($clone); + } + }); + + it('derives the same origin key inside a linked git worktree', function (): void { + $clone = storageRepository('git@github.com:foo/bar.git'); + $worktree = $clone.'-wt'; + + try { + file_put_contents($clone.'/README.md', 'pest'); + storageGit($clone, 'add', '-A'); + storageGit($clone, 'commit', '-q', '-m', 'init'); + storageGit($clone, 'worktree', 'add', '-q', $worktree); + + expect(storageKeyHash($worktree))->toBe(storageKeyHash($clone)) + ->and(storageKeyHash($worktree))->toBe(substr(hash('sha256', 'github.com/foo/bar'), 0, 16)); + } finally { + storageRemoveDirectory($worktree); + storageRemoveDirectory($clone); + } + }); + + it('falls back to a path-derived key when there is no origin remote', function (): void { + $clone = storageRepository(null); + + try { + $realpath = realpath($clone); + + expect($realpath)->not->toBeFalse() + ->and(storageKeyHash($clone))->toBe(substr(hash('sha256', (string) $realpath), 0, 16)); + } finally { + storageRemoveDirectory($clone); + } + }); +});