From fd218c3c9df8d0ae20d3de12d6b81a6ffec3310e Mon Sep 17 00:00:00 2001 From: John Bacon Date: Fri, 31 Jul 2026 11:00:26 -0400 Subject: [PATCH] fix: support Tia when the Pest project is a subdirectory of the git repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git prints and addresses paths relative to the repository root, while Tia's dependency graph is keyed on project-relative paths. When the two roots coincide the notions are identical; in a monorepo they are not, which previously made change detection map every change to zero tests — so the repository-root guard refused to run Tia at all. ChangedFiles now resolves the project's prefix inside the repository once (git rev-parse --show-prefix) and translates at every git boundary: output paths from diff/status are stripped to project-relative (dropping sibling packages), and input paths to git show are prefixed. The guard and its exception are removed. Co-Authored-By: Claude Fable 5 --- src/Exceptions/TiaRequiresRepositoryRoot.php | 44 ------ src/Plugins/Tia.php | 30 +--- src/Plugins/Tia/ChangedFiles.php | 59 ++++++- tests/Unit/Plugins/Tia/ChangedFiles.php | 155 +++++++++++++++++++ 4 files changed, 210 insertions(+), 78 deletions(-) delete mode 100644 src/Exceptions/TiaRequiresRepositoryRoot.php create mode 100644 tests/Unit/Plugins/Tia/ChangedFiles.php diff --git a/src/Exceptions/TiaRequiresRepositoryRoot.php b/src/Exceptions/TiaRequiresRepositoryRoot.php deleted file mode 100644 index 5a122197f..000000000 --- a/src/Exceptions/TiaRequiresRepositoryRoot.php +++ /dev/null @@ -1,44 +0,0 @@ -subdirectoryPrefix, - )); - } - - public function render(OutputInterface $output): void - { - $output->writeln([ - '', - ' ERROR Tia mode requires the git repository root.', - '', - sprintf(' This project sits in a subdirectory of a larger repo %s.', $this->subdirectoryPrefix), - '', - ' Give the project its own git repository to use Tia.', - '', - ]); - } - - public function exitCode(): int - { - return 1; - } -} diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 401d6f0ce..bf5d5b7a0 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -9,7 +9,6 @@ use Pest\Contracts\Plugins\HandlesArguments; use Pest\Contracts\Plugins\Terminable; use Pest\Exceptions\NoAffectedTestsFound; -use Pest\Exceptions\TiaRequiresRepositoryRoot; use Pest\Panic; use Pest\Plugins\Concerns\HandleArguments; use Pest\Plugins\Tia\BaselineSync; @@ -629,12 +628,6 @@ private function handleParent(array $arguments, string $projectRoot, bool $force { $this->watchPatterns->useDefaults($projectRoot); - $subdirectoryPrefix = $this->gitSubdirectoryPrefix($projectRoot); - - if ($subdirectoryPrefix !== null) { - Panic::with(new TiaRequiresRepositoryRoot($subdirectoryPrefix)); - } - $this->branch = new ChangedFiles($projectRoot)->currentBranch() ?? 'main'; $fingerprint = Fingerprint::compute($projectRoot); @@ -1692,27 +1685,6 @@ private function formatStructuralDrift(array $drift): string return implode(', ', array_keys($seen)); } - /** - * The path from the git repository root down to $projectRoot (e.g. - * `laravel-app`) when the project is nested inside a larger repo, or `null` - * when the project root is itself the repo root (or git is unavailable). - * TIA requires the two to coincide: git reports and addresses paths - * relative to the repo root, while the dependency graph is project-relative. - */ - private function gitSubdirectoryPrefix(string $projectRoot): ?string - { - $process = new Process(['git', 'rev-parse', '--show-prefix'], $projectRoot); - $process->run(); - - if (! $process->isSuccessful()) { - return null; - } - - $prefix = trim($process->getOutput()); - - return $prefix === '' ? null : rtrim(str_replace(DIRECTORY_SEPARATOR, '/', $prefix), '/'); - } - private function composerLockDelta(string $projectRoot, string $sha): string { $current = @file_get_contents($projectRoot.'/composer.lock'); @@ -1720,7 +1692,7 @@ private function composerLockDelta(string $projectRoot, string $sha): string return ''; } - $process = new Process(['git', 'show', $sha.':composer.lock'], $projectRoot); + $process = new Process(['git', 'show', $sha.':'.new ChangedFiles($projectRoot)->gitPrefix().'composer.lock'], $projectRoot); $process->setTimeout(5.0); $process->run(); diff --git a/src/Plugins/Tia/ChangedFiles.php b/src/Plugins/Tia/ChangedFiles.php index 9ffb680ea..7ca987861 100644 --- a/src/Plugins/Tia/ChangedFiles.php +++ b/src/Plugins/Tia/ChangedFiles.php @@ -10,9 +10,58 @@ /** * @internal */ -final readonly class ChangedFiles +final class ChangedFiles { - public function __construct(private string $projectRoot) {} + private ?string $gitPrefix = null; + + public function __construct(private readonly string $projectRoot) {} + + /** + * The project root's path prefix inside the git repository, with a + * trailing slash (e.g. `backend/`), or an empty string when the project + * root is the repository root itself or git is unavailable. Git prints + * and addresses paths relative to the repository root, while the + * dependency graph is keyed on project-relative paths — the git + * boundaries below translate between the two using this prefix. + */ + public function gitPrefix(): string + { + if ($this->gitPrefix !== null) { + return $this->gitPrefix; + } + + $process = new Process(['git', 'rev-parse', '--show-prefix'], $this->projectRoot); + $process->run(); + + if (! $process->isSuccessful()) { + return $this->gitPrefix = ''; + } + + return $this->gitPrefix = str_replace(DIRECTORY_SEPARATOR, '/', trim($process->getOutput())); + } + + /** + * @param array $files repository-relative paths as printed by git. + * @return array project-relative paths; files outside the project are dropped. + */ + private function toProjectRelative(array $files): array + { + $prefix = $this->gitPrefix(); + + if ($prefix === '') { + return $files; + } + + $projectFiles = []; + + foreach ($files as $file) { + if (str_starts_with($file, $prefix)) { + $projectFiles[] = substr($file, strlen($prefix)); + } + } + + return $projectFiles; + } /** * @param array $files project-relative paths. @@ -155,7 +204,7 @@ private function filterBehaviourallyUnchanged(array $files, string $sha): array private function contentAtSha(string $sha, string $path): ?string { - $process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot); + $process = new Process(['git', 'show', $sha.':'.$this->gitPrefix().$path], $this->projectRoot); $process->setTimeout(5.0); $process->run(); @@ -245,7 +294,7 @@ private function diffSinceSha(string $sha): array throw new MissingDependency('Tia mode', 'git'); } - return $this->splitLines($process->getOutput()); + return $this->toProjectRelative($this->splitLines($process->getOutput())); } /** @@ -297,7 +346,7 @@ private function workingTreeChanges(): array $files[] = $path; } - return $files; + return $this->toProjectRelative($files); } public function currentSha(): ?string diff --git a/tests/Unit/Plugins/Tia/ChangedFiles.php b/tests/Unit/Plugins/Tia/ChangedFiles.php new file mode 100644 index 000000000..cca1541e1 --- /dev/null +++ b/tests/Unit/Plugins/Tia/ChangedFiles.php @@ -0,0 +1,155 @@ +mustRun(); +} + +/** + * Creates a git repository containing a `backend/` Pest project and a + * `frontend/` sibling package, with one initial commit. + * + * @return array{root: string, sha: string} + */ +function tia_changed_files_monorepo(): array +{ + $root = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest_tia_monorepo_'.uniqid(); + + mkdir($root.'/backend/app', 0o777, true); + mkdir($root.'/frontend', 0o777, true); + + file_put_contents($root.'/backend/app/Service.php', "mustRun(); + + return ['root' => $root, 'sha' => trim($process->getOutput())]; +} + +function tia_changed_files_rm(string $directory): void +{ + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + + rmdir($directory); +} + +describe('gitPrefix()', function (): void { + it('is empty at the repository root', function (): void { + $fixture = tia_changed_files_monorepo(); + + try { + expect(new ChangedFiles($fixture['root'])->gitPrefix())->toBeEmpty(); + } finally { + tia_changed_files_rm($fixture['root']); + } + }); + + it('is the slash-terminated subdirectory path inside a larger repository', function (): void { + $fixture = tia_changed_files_monorepo(); + + try { + expect(new ChangedFiles($fixture['root'].'/backend')->gitPrefix())->toBe('backend/'); + } finally { + tia_changed_files_rm($fixture['root']); + } + }); + + it('is empty outside any git repository', function (): void { + $directory = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest_tia_no_repo_'.uniqid(); + mkdir($directory); + + try { + expect(new ChangedFiles($directory)->gitPrefix())->toBeEmpty(); + } finally { + tia_changed_files_rm($directory); + } + }); +}); + +describe('a project in a repository subdirectory', function (): void { + it('reports working-tree changes as project-relative paths and drops sibling packages', function (): void { + $fixture = tia_changed_files_monorepo(); + + try { + file_put_contents($fixture['root'].'/backend/app/Service.php', "since(null); + + sort($changed); + + expect($changed)->toBe(['app/Service.php', 'app/Untracked.php']); + } finally { + tia_changed_files_rm($fixture['root']); + } + }); + + it('reports committed changes since a sha as project-relative paths', function (): void { + $fixture = tia_changed_files_monorepo(); + + try { + file_put_contents($fixture['root'].'/backend/app/Service.php', "since($fixture['sha'])) + ->toBe(['app/Service.php']); + } finally { + tia_changed_files_rm($fixture['root']); + } + }); + + it('filters files whose content is behaviourally unchanged against the baseline sha', function (): void { + $fixture = tia_changed_files_monorepo(); + + try { + file_put_contents($fixture['root'].'/backend/app/Service.php', "since($fixture['sha']))->toBe([]); + } finally { + tia_changed_files_rm($fixture['root']); + } + }); +}); + +describe('a project at the repository root', function (): void { + it('still reports repository-relative paths untouched', function (): void { + $fixture = tia_changed_files_monorepo(); + + try { + file_put_contents($fixture['root'].'/backend/app/Service.php', "since(null))->toBe(['backend/app/Service.php']); + } finally { + tia_changed_files_rm($fixture['root']); + } + }); +});