diff --git a/src/Exceptions/TiaRequiresRepositoryRoot.php b/src/Exceptions/TiaRequiresRepositoryRoot.php deleted file mode 100644 index 8ded003fd..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 ff1f5b066..8b0d0388b 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -15,7 +15,6 @@ use Pest\Exceptions\TiaRequiresCommit; use Pest\Exceptions\TiaRequiresDefaultBranch; use Pest\Exceptions\TiaRequiresRemote; -use Pest\Exceptions\TiaRequiresRepositoryRoot; use Pest\Panic; use Pest\Plugins\Concerns\HandleArguments; use Pest\Plugins\Tia\BaselineSync; @@ -33,7 +32,6 @@ use Pest\Plugins\Tia\TableExtractor; use Pest\Plugins\Tia\WatchPatterns; use Pest\Support\Container; -use Pest\Support\Git; use Pest\Support\View; use Pest\TestCaseFilters\TiaTestCaseFilter; use Pest\TestSuite; @@ -822,12 +820,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)); - } - try { $this->resolveBranch($projectRoot); } catch (MissingDependency $missingGit) { @@ -2154,11 +2146,6 @@ private function formatStructuralDrift(array $drift): string return implode(', ', array_keys($seen)); } - private function gitSubdirectoryPrefix(string $projectRoot): ?string - { - return new Git($projectRoot)->subdirectoryPrefix(); - } - private function composerLockDelta(string $projectRoot, string $sha): string { $current = @file_get_contents($projectRoot.'/composer.lock'); @@ -2166,7 +2153,7 @@ private function composerLockDelta(string $projectRoot, string $sha): string return ''; } - $baseline = new Git($projectRoot)->show($sha, 'composer.lock'); + $baseline = new ChangedFiles($projectRoot)->contentAtSha($sha, 'composer.lock'); if ($baseline === null) { return ''; diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index 1f4d63cea..52ef9ba52 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -183,9 +183,9 @@ private function isCi(): bool private function detectGitHubRepo(string $projectRoot): ?string { - $gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config'; + $gitConfig = GitRepository::configPath($projectRoot); - if (! is_file($gitConfig)) { + if ($gitConfig === null) { return null; } diff --git a/src/Plugins/Tia/ChangedFiles.php b/src/Plugins/Tia/ChangedFiles.php index 4e2d9d454..85ed4c955 100644 --- a/src/Plugins/Tia/ChangedFiles.php +++ b/src/Plugins/Tia/ChangedFiles.php @@ -14,9 +14,17 @@ { private Git $git; + private string $repoPrefix; + public function __construct(private string $projectRoot) { $this->git = new Git($projectRoot); + $this->repoPrefix = $this->detectRepoPrefix(); + } + + public function repoPrefix(): string + { + return $this->repoPrefix; } /** @@ -158,9 +166,9 @@ private function filterBehaviourallyUnchanged(array $files, string $sha): array return $remaining; } - private function contentAtSha(string $sha, string $path): ?string + public function contentAtSha(string $sha, string $path): ?string { - return $this->git->show($sha, $path); + return $this->git->show($sha, $this->repoPrefix.$path); } /** @@ -305,13 +313,15 @@ private function shaIsReachable(string $sha): bool */ private function diffSinceSha(string $sha): array { - $output = $this->scan()->raw(['diff', '--name-only', '--no-renames', $sha.'..HEAD']); + $output = $this->scan()->raw(['diff', '--name-only', '-z', '--no-renames', $sha.'..HEAD']); if ($output === null) { throw new MissingDependency('Tia mode', 'git'); } - return $this->splitLines($output); + $paths = explode("\x00", rtrim($output, "\x00")); + + return $this->toProjectRelative(array_values(array_filter($paths, static fn (string $path): bool => $path !== ''))); } /** @@ -357,7 +367,7 @@ private function workingTreeChanges(): array $files[] = $path; } - return $files; + return $this->toProjectRelative($files); } public function currentSha(): ?string @@ -373,6 +383,44 @@ public function currentSha(): ?string return $sha === '' ? null : $sha; } + /** + * @param array $repoRelativePaths + * @return array + */ + private function toProjectRelative(array $repoRelativePaths): array + { + if ($this->repoPrefix === '') { + return $repoRelativePaths; + } + + $projectRelative = []; + + foreach ($repoRelativePaths as $path) { + if (str_starts_with($path, $this->repoPrefix)) { + $projectRelative[] = substr($path, strlen($this->repoPrefix)); + } + } + + return $projectRelative; + } + + private function detectRepoPrefix(): string + { + static $cache = []; + + if (isset($cache[$this->projectRoot])) { + return $cache[$this->projectRoot]; + } + + $prefix = $this->git->subdirectoryPrefix(); + + if ($prefix === null || $prefix === '') { + return $cache[$this->projectRoot] = ''; + } + + return $cache[$this->projectRoot] = $prefix.'/'; + } + /** * @return array */ diff --git a/src/Plugins/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php index e8ef26cb3..31639012b 100644 --- a/src/Plugins/Tia/Fingerprint.php +++ b/src/Plugins/Tia/Fingerprint.php @@ -275,7 +275,7 @@ private static function isTrackedByGit(string $projectRoot, string $relativePath return $cache[$key]; } - if (! is_dir($projectRoot.'/.git') && ! is_file($projectRoot.'/.git')) { + if (GitRepository::locate($projectRoot) === null) { return $cache[$key] = true; } diff --git a/src/Plugins/Tia/GitRepository.php b/src/Plugins/Tia/GitRepository.php new file mode 100644 index 000000000..3acbdfaf1 --- /dev/null +++ b/src/Plugins/Tia/GitRepository.php @@ -0,0 +1,85 @@ +and($project->graph()['baselines']['master']['results'])->toHaveCount(Project::TOTAL_TESTS); })->skipOnWindows(); -test('a project below the git repository root refuses to run and writes nothing', function (array $arguments): void { +test('a project below the git repository root runs from the subdirectory', function (array $arguments): void { $project = Project::make('master'); $nested = $project->nested(); $result = $project->pestIn($nested, '--tia', ...$arguments); - expect($result->exitCode)->toBe(1, $result->describe()) - ->and($result->output)->toContain('Tia mode requires the git repository root') - ->and($project->path('.home/.pest'))->not->toBeDirectory() - ->and($nested.DIRECTORY_SEPARATOR.'.pest')->not->toBeDirectory(); + expect($result->exitCode)->toBe(0, $result->describe()) + ->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed'); })->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows(); test('a repository with no commits says so, and leaves plain runs alone', function (): void { diff --git a/tests/Unit/Plugins/Tia/ChangedFiles.php b/tests/Unit/Plugins/Tia/ChangedFiles.php new file mode 100644 index 000000000..462a0ffc8 --- /dev/null +++ b/tests/Unit/Plugins/Tia/ChangedFiles.php @@ -0,0 +1,167 @@ +repoRoot = sys_get_temp_dir().'/pest-tia-changed-files-'.bin2hex(random_bytes(4)); + mkdir($this->repoRoot.'/apps/api/app', 0755, true); + mkdir($this->repoRoot.'/apps/web', 0755, true); + + $this->git = function (string ...$command): string { + $process = new Process(['git', ...$command], $this->repoRoot); + $process->mustRun(); + + return trim($process->getOutput()); + }; + + ($this->git)('init', '-q', '-b', 'main'); + ($this->git)('config', 'user.email', 'pest@example.com'); + ($this->git)('config', 'user.name', 'Pest'); + ($this->git)('config', 'commit.gpgsign', 'false'); + + file_put_contents($this->repoRoot.'/README.md', 'root'); + file_put_contents($this->repoRoot.'/apps/api/composer.lock', 'lock-v1'); + file_put_contents($this->repoRoot.'/apps/api/app/Service.php', "repoRoot.'/apps/web/page.tsx', 'web-v1'); + + ($this->git)('add', '-A'); + ($this->git)('commit', '-q', '-m', 'baseline'); + + $this->baselineSha = ($this->git)('rev-parse', 'HEAD'); +}); + +afterEach(function (): void { + $remove = function (string $dir) use (&$remove): void { + foreach (array_diff((array) scandir($dir), ['.', '..']) as $entry) { + $path = $dir.DIRECTORY_SEPARATOR.$entry; + + is_dir($path) && ! is_link($path) ? $remove($path) : @unlink($path); + } + + @rmdir($dir); + }; + + $remove($this->repoRoot); +}); + +describe('repoPrefix()', function (): void { + it('is empty at the repository root', function (): void { + expect(new ChangedFiles($this->repoRoot)->repoPrefix())->toBeEmpty(); + }); + + it('is the slash-terminated subdirectory path for a monorepo project', function (): void { + expect(new ChangedFiles($this->repoRoot.'/apps/api')->repoPrefix())->toBe('apps/api/'); + }); + + it('is empty outside a git repository', function (): void { + $outside = sys_get_temp_dir().'/pest-tia-no-repo-'.bin2hex(random_bytes(4)); + mkdir($outside); + + try { + expect(new ChangedFiles($outside)->repoPrefix())->toBeEmpty(); + } finally { + @rmdir($outside); + } + }); +}); + +describe('since() in a monorepo subdirectory', function (): void { + it('reports project files as project-relative paths', function (): void { + file_put_contents($this->repoRoot.'/apps/api/app/Service.php', "repoRoot.'/apps/api')->since($this->baselineSha); + + expect($changed)->toBe(['app/Service.php']); + }); + + it('includes untracked project files', function (): void { + file_put_contents($this->repoRoot.'/apps/api/app/Fresh.php', "repoRoot.'/apps/api')->since($this->baselineSha); + + expect($changed)->toBe(['app/Fresh.php']); + }); + + it('ignores changes in sibling projects and at the repository root', function (): void { + file_put_contents($this->repoRoot.'/README.md', 'root-changed'); + file_put_contents($this->repoRoot.'/apps/web/page.tsx', 'web-v2'); + + $changed = new ChangedFiles($this->repoRoot.'/apps/api')->since($this->baselineSha); + + expect($changed)->toBe([]); + }); + + it('detects committed changes to files git would C-quote (non-ASCII names)', function (): void { + file_put_contents($this->repoRoot.'/apps/api/app/Ærlig.php', "git)('add', '-A'); + ($this->git)('commit', '-q', '-m', 'add utf-8 named file'); + file_put_contents($this->repoRoot.'/apps/api/app/Ærlig.php', "git)('add', '-A'); + ($this->git)('commit', '-q', '-m', 'change utf-8 named file'); + + $changed = new ChangedFiles($this->repoRoot.'/apps/api')->since($this->baselineSha); + + expect($changed)->toBe(['app/Ærlig.php']); + }); + + it('sees committed changes since the baseline sha', function (): void { + file_put_contents($this->repoRoot.'/apps/api/app/Service.php', "git)('add', '-A'); + ($this->git)('commit', '-q', '-m', 'change service'); + + $changed = new ChangedFiles($this->repoRoot.'/apps/api')->since($this->baselineSha); + + expect($changed)->toBe(['app/Service.php']); + }); + + it('reports a file renamed OUT of the project subtree as a change to the old path', function (): void { + ($this->git)('mv', 'apps/api/app/Service.php', 'apps/web/Service.php'); + ($this->git)('commit', '-q', '-m', 'move service out of the api project'); + + $changed = new ChangedFiles($this->repoRoot.'/apps/api')->since($this->baselineSha); + + expect($changed)->toBe(['app/Service.php']); + }); + + it('drops cosmetic-only changes, comparing against the baseline blob through the prefix', function (): void { + file_put_contents($this->repoRoot.'/apps/api/app/Service.php', "git)('add', '-A'); + ($this->git)('commit', '-q', '-m', 'cosmetic'); + + $changed = new ChangedFiles($this->repoRoot.'/apps/api')->since($this->baselineSha); + + expect($changed)->toBe([]); + }); +}); + +describe('since() at the repository root', function (): void { + it('keeps the existing root-level behaviour', function (): void { + file_put_contents($this->repoRoot.'/README.md', 'root-changed'); + file_put_contents($this->repoRoot.'/apps/api/app/Service.php', "repoRoot)->since($this->baselineSha); + + sort($changed); + + expect($changed)->toBe(['README.md', 'apps/api/app/Service.php']); + }); +}); + +describe('contentAtSha()', function (): void { + it('resolves project-relative paths through the subdirectory prefix', function (): void { + $content = new ChangedFiles($this->repoRoot.'/apps/api') + ->contentAtSha($this->baselineSha, 'composer.lock'); + + expect($content)->toBe('lock-v1'); + }); + + it('returns null for paths missing from the commit', function (): void { + $content = new ChangedFiles($this->repoRoot.'/apps/api') + ->contentAtSha($this->baselineSha, 'nope.txt'); + + expect($content)->toBeNull(); + }); +}); diff --git a/tests/Unit/Plugins/Tia/GitRepository.php b/tests/Unit/Plugins/Tia/GitRepository.php new file mode 100644 index 000000000..19e35f7bd --- /dev/null +++ b/tests/Unit/Plugins/Tia/GitRepository.php @@ -0,0 +1,85 @@ +root = sys_get_temp_dir().'/pest-tia-git-repository-'.bin2hex(random_bytes(4)); + mkdir($this->root.'/apps/api', 0755, true); + mkdir($this->root.'/services/api', 0755, true); + mkdir($this->root.'/.git', 0755); + file_put_contents($this->root.'/.git/config', "[remote \"origin\"]\n\turl = git@github.com:acme/mono.git\n"); +}); + +afterEach(function (): void { + $remove = function (string $dir) use (&$remove): void { + foreach (array_diff((array) scandir($dir), ['.', '..']) as $entry) { + $path = $dir.DIRECTORY_SEPARATOR.$entry; + + is_dir($path) && ! is_link($path) ? $remove($path) : @unlink($path); + } + + @rmdir($dir); + }; + + $remove($this->root); +}); + +describe('locate()', function (): void { + it('finds .git in the path itself', function (): void { + expect(GitRepository::locate($this->root))->toBe($this->root.DIRECTORY_SEPARATOR.'.git'); + }); + + it('walks up to an ancestor .git for subdirectory projects', function (): void { + expect(GitRepository::locate($this->root.'/apps/api')) + ->toBe($this->root.DIRECTORY_SEPARATOR.'.git'); + }); + + it('stops at a nested repository boundary', function (): void { + mkdir($this->root.'/apps/api/.git'); + + expect(GitRepository::locate($this->root.'/apps/api')) + ->toBe($this->root.'/apps/api'.DIRECTORY_SEPARATOR.'.git'); + }); + + it('treats a .git FILE (worktree, submodule) as the governing entry', function (): void { + file_put_contents($this->root.'/apps/api/.git', "gitdir: /elsewhere\n"); + + expect(GitRepository::locate($this->root.'/apps/api')) + ->toBe($this->root.'/apps/api'.DIRECTORY_SEPARATOR.'.git'); + }); +}); + +describe('configPath()', function (): void { + it('resolves the governing repository config for subdirectory projects', function (): void { + expect(GitRepository::configPath($this->root.'/apps/api')) + ->toBe($this->root.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config'); + }); + + it('returns null for a .git FILE, matching the previous degradation', function (): void { + file_put_contents($this->root.'/apps/api/.git', "gitdir: /elsewhere\n"); + + expect(GitRepository::configPath($this->root.'/apps/api'))->toBeNull(); + }); +}); + +describe('subdirectoryPrefix()', function (): void { + it('is empty at the repository root', function (): void { + expect(GitRepository::subdirectoryPrefix($this->root))->toBeEmpty(); + }); + + it('is the slash-terminated project location inside the repository', function (): void { + expect(GitRepository::subdirectoryPrefix($this->root.'/apps/api'))->toBe('apps/api/'); + }); +}); + +describe('storage keys', function (): void { + it('gives same-basename sibling projects distinct stores', function (): void { + $api = Storage::tempDir($this->root.'/apps/api'); + $siblingApi = Storage::tempDir($this->root.'/services/api'); + + expect($api)->not->toBe($siblingApi); + }); +});