From 7d5416be34cce1d5078894cba5cee8ea8285ad0f Mon Sep 17 00:00:00 2001 From: jfatr Date: Tue, 15 Sep 2026 14:48:48 +0200 Subject: [PATCH] fix(tia): support a Pest project nested in a git repository Tia refused to run whenever the Pest project was not the git repository root, because git prints and addresses paths relative to the repository root while the dependency graph is keyed on project-relative paths. Translate at every git boundary instead of guarding against the mismatch. `Git::pathPrefix()` resolves the directory of the project inside the repository, and `ChangedFiles` strips that prefix off the paths git prints, drops the paths of sibling packages, and prepends it to `git show`. Both diffs read `-z` records, because `core.quotePath` C-quotes a path outside ASCII, and `--no-relative` holds the frame when a user sets `diff.relative`. Three places read `/.git` directly, which is absent in a nested project and in a worktree, and now ask git: `BaseRemote`, `Storage` and `Fingerprint::isTrackedByGit()`. `Storage` folds the prefix into the state key so two projects in one repository do not share a graph, and the fingerprint carries the prefix so a baseline published by a sibling project is rejected. A project at the repository root keeps the key and the fingerprint it had. `git check-ignore` runs without `--no-index`, so a tracked file that an ignore rule matches counts as part of the project on both sides of the boundary. Changes outside the project are dropped and reported by count in the run summary, so a nested project does not look idle when the repository around it moved. Co-Authored-By: Claude Opus 5 --- src/Exceptions/TiaRequiresRepositoryRoot.php | 44 ---- src/Plugins/Tia.php | 54 ++-- src/Plugins/Tia/Baselines/BaseRemote.php | 19 +- src/Plugins/Tia/ChangedFiles.php | 114 ++++++++- src/Plugins/Tia/Fingerprint.php | 43 +++- src/Plugins/Tia/Storage.php | 34 +-- src/Support/Git.php | 20 +- tests/Features/Tia/BranchShapes.php | 12 - tests/Features/Tia/Monorepo.php | 155 ++++++++++++ tests/Fixtures/Tia/Project.php | 10 +- tests/Unit/Plugins/Tia/ChangedFiles.php | 246 +++++++++++++++++++ tests/Unit/Plugins/Tia/Storage.php | 31 +++ 12 files changed, 642 insertions(+), 140 deletions(-) delete mode 100644 src/Exceptions/TiaRequiresRepositoryRoot.php create mode 100644 tests/Features/Tia/Monorepo.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 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 f334e9334..551a5d7d5 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; @@ -357,8 +356,6 @@ private static function applyWatchPatternMarks(array $arguments, WatchPatterns $ } /** - * Mirrors {@see HandleArguments::hasArgument()} for - * * @param array $arguments */ private static function argumentPresent(string $argument, array $arguments): bool @@ -440,17 +437,11 @@ public function getAssertionCount(string $testId): int return $this->cachedAssertionsByTestId[$testId] ?? 0; } - /** - * {@inheritDoc} - */ public function handleOriginalArguments(array $arguments): void { $this->originalArguments = $arguments; } - /** - * {@inheritDoc} - */ public function handleArguments(array $arguments): array { if ($this->hasArgument(self::BASELINE_PATH_OPTION, $arguments)) { @@ -816,12 +807,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) { @@ -1052,6 +1037,8 @@ private function enterReplayMode(Graph $graph, string $projectRoot, array $argum $branchSha = $graph->recordedAtSha($this->branch); $changed = $changedFiles->since($branchSha) ?? []; + $outsideProject = $changedFiles->outsideProject(); + $changed = $changedFiles->filterUnchangedSinceLastRun( $changed, $graph->lastRunTree($this->branch), @@ -1089,7 +1076,7 @@ private function enterReplayMode(Graph $graph, string $projectRoot, array $argum ...$rerunFromCache, ])); - $this->reportAffectedSummary($changed, $affectedFromChanges, $rerunFromCache, $affected); + $this->reportAffectedSummary($changed, $affectedFromChanges, $rerunFromCache, $affected, $outsideProject); $affectedSet = array_fill_keys($affected, true); $canRefreshReplayEdges = $affected !== [] && $coverageAvailable; @@ -1154,13 +1141,15 @@ private function enterReplayMode(Graph $graph, string $projectRoot, array $argum * @param array $affectedFromChanges * @param array $rerunFromCache * @param array $affected + * @param array $outsideProject */ - private function reportAffectedSummary(array $changedFiles, array $affectedFromChanges, array $rerunFromCache, array $affected): void + private function reportAffectedSummary(array $changedFiles, array $affectedFromChanges, array $rerunFromCache, array $affected, array $outsideProject = []): void { $this->output->writeln(''); if ($affected === []) { $this->renderChild('Experimental TIA mode enabled.'); + $this->reportChangesOutsideProject($outsideProject); return; } @@ -1223,6 +1212,27 @@ private function reportAffectedSummary(array $changedFiles, array $affectedFromC if ($remainder > 0) { $this->output->writeln(sprintf(' … +%d more', $remainder)); } + + $this->reportChangesOutsideProject($outsideProject); + } + + /** + * @param array $outsideProject Repository-relative paths. + */ + private function reportChangesOutsideProject(array $outsideProject): void + { + if ($outsideProject === []) { + return; + } + + $count = count($outsideProject); + + $this->renderChild(sprintf( + '%d changed file%s outside this project %s ignored — TIA only tracks files under the project root.', + $count, + $count === 1 ? '' : 's', + $count === 1 ? 'was' : 'were', + )); } /** @@ -2178,6 +2188,7 @@ private function formatStructuralDrift(array $drift): string 'js_config' => 'JS/TS config', 'pest_factory' => 'Pest internals', 'pest_method_factory' => 'Pest internals', + 'project_prefix' => 'project location in the repository', ]; $seen = []; @@ -2192,11 +2203,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'); @@ -2204,7 +2210,9 @@ private function composerLockDelta(string $projectRoot, string $sha): string return ''; } - $baseline = new Git($projectRoot)->show($sha, 'composer.lock'); + $git = new Git($projectRoot); + + $baseline = $git->show($sha, $git->pathPrefix().'composer.lock'); if ($baseline === null) { return ''; diff --git a/src/Plugins/Tia/Baselines/BaseRemote.php b/src/Plugins/Tia/Baselines/BaseRemote.php index 659d5f484..297eefbad 100644 --- a/src/Plugins/Tia/Baselines/BaseRemote.php +++ b/src/Plugins/Tia/Baselines/BaseRemote.php @@ -5,6 +5,7 @@ namespace Pest\Plugins\Tia\Baselines; use Pest\Plugins\Tia\WatchPatterns; +use Pest\Support\Git; use Symfony\Component\Process\Process; /** @@ -75,23 +76,7 @@ public function classifyError(string $output): array protected function readOriginUrl(string $projectRoot): ?string // @pest-arch-ignore-line { - $gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config'; - - if (! is_file($gitConfig)) { - return null; - } - - $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]; + return new Git($projectRoot)->originUrl(); } /** diff --git a/src/Plugins/Tia/ChangedFiles.php b/src/Plugins/Tia/ChangedFiles.php index 4e2d9d454..c9b5556dd 100644 --- a/src/Plugins/Tia/ChangedFiles.php +++ b/src/Plugins/Tia/ChangedFiles.php @@ -10,15 +10,66 @@ /** * @internal */ -final readonly class ChangedFiles +final class ChangedFiles { - private Git $git; + private readonly Git $git; - public function __construct(private string $projectRoot) + private ?string $gitPrefix = null; + + /** + * @var array + */ + private array $outsideProject = []; + + public function __construct(private readonly string $projectRoot) { $this->git = new Git($projectRoot); } + public function gitPrefix(): string + { + return $this->gitPrefix ??= $this->git->pathPrefix(); + } + + /** + * @param array $files repository-relative paths, as printed by git. + * @return array project-relative paths; paths 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)); + + continue; + } + + if ($file === '') { + continue; + } + + $this->outsideProject[$file] = true; + } + + return $projectFiles; + } + + /** + * @return array + */ + public function outsideProject(): array + { + return array_keys($this->outsideProject); + } + /** * @param array $files project-relative paths. * @param array $lastRunTree path → content hash from last run. @@ -95,6 +146,8 @@ public function snapshotTree(array $files): array */ public function since(?string $sha): ?array { + $this->outsideProject = []; + $files = []; if ($sha !== null && $sha !== '') { @@ -118,6 +171,8 @@ public function since(?string $sha): ?array $candidates = array_keys($this->filterIgnored($unique)); + $this->filterIgnoredOutsideProject(); + if ($sha !== null && $sha !== '') { return $this->filterBehaviourallyUnchanged($candidates, $sha); } @@ -160,7 +215,24 @@ private function filterBehaviourallyUnchanged(array $files, string $sha): array private function contentAtSha(string $sha, string $path): ?string { - return $this->git->show($sha, $path); + return $this->git->show($sha, $this->gitPrefix().$path); + } + + private function filterIgnoredOutsideProject(): void + { + if ($this->outsideProject === []) { + return; + } + + $repositoryRoot = $this->git->repositoryRoot(); + + if ($repositoryRoot === null) { + return; + } + + $git = new Git($repositoryRoot); + + $this->outsideProject = $this->filterIgnoredWith($git, $this->outsideProject); } /** @@ -168,13 +240,22 @@ private function contentAtSha(string $sha, string $path): ?string * @return array */ private function filterIgnored(array $candidates): array + { + return $this->filterIgnoredWith($this->git, $candidates); + } + + /** + * @param array $candidates + * @return array + */ + private function filterIgnoredWith(Git $git, array $candidates): array { if ($candidates === []) { return $candidates; } - $result = $this->git->result( - ['check-ignore', '--no-index', '-z', '--stdin'], + $result = $git->result( + ['check-ignore', '-z', '--stdin'], implode("\x00", array_keys($candidates)), ); @@ -305,13 +386,13 @@ 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', '--no-renames', '--no-relative', '-z', $sha.'..HEAD']); if ($output === null) { throw new MissingDependency('Tia mode', 'git'); } - return $this->splitLines($output); + return $this->toProjectRelative($this->splitRecords($output)); } /** @@ -357,7 +438,7 @@ private function workingTreeChanges(): array $files[] = $path; } - return $files; + return $this->toProjectRelative($files); } public function currentSha(): ?string @@ -373,6 +454,21 @@ public function currentSha(): ?string return $sha === '' ? null : $sha; } + /** + * @return array + */ + private function splitRecords(string $output): array + { + if ($output === '') { + return []; + } + + return array_values(array_filter( + explode("\x00", rtrim($output, "\x00")), + static fn (string $record): bool => $record !== '', + )); + } + /** * @return array */ diff --git a/src/Plugins/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php index 39e562399..382952eae 100644 --- a/src/Plugins/Tia/Fingerprint.php +++ b/src/Plugins/Tia/Fingerprint.php @@ -5,6 +5,7 @@ namespace Pest\Plugins\Tia; use Pest\Plugins\Tia\Contracts\Lockfile; +use Pest\Support\Git; use Symfony\Component\Finder\Finder; /** @@ -29,16 +30,24 @@ */ public static function compute(string $projectRoot): array { + $structural = [ + 'schema' => self::SCHEMA_VERSION, + 'composer_lock' => self::composerLockHash($projectRoot), + 'phpunit_xml' => self::trackedHash($projectRoot, 'phpunit.xml'), + 'phpunit_xml_dist' => self::trackedHash($projectRoot, 'phpunit.xml.dist'), + 'vite_config' => self::viteConfigHash($projectRoot), + 'package_lock' => self::packageLockHash($projectRoot), + 'js_config' => self::jsConfigHash($projectRoot), + ]; + + $prefix = self::projectPrefix($projectRoot); + + if ($prefix !== '') { + $structural['project_prefix'] = $prefix; + } + return [ - 'structural' => [ - 'schema' => self::SCHEMA_VERSION, - 'composer_lock' => self::composerLockHash($projectRoot), - 'phpunit_xml' => self::trackedHash($projectRoot, 'phpunit.xml'), - 'phpunit_xml_dist' => self::trackedHash($projectRoot, 'phpunit.xml.dist'), - 'vite_config' => self::viteConfigHash($projectRoot), - 'package_lock' => self::packageLockHash($projectRoot), - 'js_config' => self::jsConfigHash($projectRoot), - ], + 'structural' => $structural, 'environmental' => [ 'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION, @@ -275,7 +284,7 @@ private static function isTrackedByGit(string $projectRoot, string $relativePath return $cache[$key]; } - if (! is_dir($projectRoot.'/.git') && ! is_file($projectRoot.'/.git')) { + if (! self::isGitRepository($projectRoot)) { return $cache[$key] = true; } @@ -288,6 +297,20 @@ private static function isTrackedByGit(string $projectRoot, string $relativePath return $cache[$key] = $finder->hasResults(); } + private static function projectPrefix(string $projectRoot): string + { + static $cache = []; + + return $cache[$projectRoot] ??= new Git($projectRoot)->pathPrefix(); + } + + private static function isGitRepository(string $projectRoot): bool + { + static $cache = []; + + return $cache[$projectRoot] ??= new Git($projectRoot)->isRepository(); + } + private static function contentHashOrNull(string $path): ?string { if (! is_file($path)) { diff --git a/src/Plugins/Tia/Storage.php b/src/Plugins/Tia/Storage.php index e0674314f..7bdc635c9 100644 --- a/src/Plugins/Tia/Storage.php +++ b/src/Plugins/Tia/Storage.php @@ -4,6 +4,8 @@ namespace Pest\Plugins\Tia; +use Pest\Support\Git; + /** * @internal */ @@ -92,9 +94,6 @@ private static function homeDir(): ?string return null; } - /** - * `git@github.com:foo/bar.git`, `ssh://git@github.com/foo/bar` - */ private static function projectKey(string $projectRoot): string { $origin = self::originIdentity($projectRoot); @@ -102,6 +101,12 @@ private static function projectKey(string $projectRoot): string $realpath = @realpath($projectRoot); $input = $origin ?? ($realpath === false ? $projectRoot : $realpath); + $prefix = $origin === null ? '' : self::gitPrefix($projectRoot); + + if ($prefix !== '') { + $input .= '/'.$prefix; + } + $hash = substr(hash('sha256', $input), 0, 16); $slug = self::slug(basename($projectRoot)); @@ -116,12 +121,10 @@ private static function originIdentity(string $projectRoot): ?string return null; } - // git@host:org/repo(.git) if (preg_match('#^[\w.-]+@([\w.-]+):([\w./-]+?)(?:\.git)?/?$#', $url, $m) === 1) { return strtolower($m[1].'/'.$m[2]); } - // scheme://[user@]host[:port]/org/repo(.git) — https, ssh, git, file if (preg_match('#^[a-z]+://(?:[^@/]+@)?([^/:]+)(?::\d+)?/([\w./-]+?)(?:\.git)?/?$#i', $url, $m) === 1) { return strtolower($m[1].'/'.$m[2]); } @@ -131,23 +134,12 @@ private static function originIdentity(string $projectRoot): ?string private static function rawOriginUrl(string $projectRoot): ?string { - $config = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config'; - - if (! is_file($config)) { - return null; - } - - $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 new Git($projectRoot)->originUrl(); + } - return null; + private static function gitPrefix(string $projectRoot): string + { + return new Git($projectRoot)->pathPrefix(); } private static function slug(string $name): string diff --git a/src/Support/Git.php b/src/Support/Git.php index 03f52a005..49264393f 100644 --- a/src/Support/Git.php +++ b/src/Support/Git.php @@ -103,14 +103,28 @@ public function show(string $sha, string $path): ?string return $this->raw(['show', $sha.':'.$path]); } - public function subdirectoryPrefix(): ?string + public function repositoryRoot(): ?string + { + $root = $this->output(['rev-parse', '--show-toplevel']); + + return $root === null ? null : rtrim(str_replace(DIRECTORY_SEPARATOR, '/', $root), '/'); + } + + public function pathPrefix(): string { $prefix = $this->output(['rev-parse', '--show-prefix']); if ($prefix === null) { - return null; + return ''; } - return rtrim(str_replace(DIRECTORY_SEPARATOR, '/', $prefix), '/'); + $prefix = trim(str_replace(DIRECTORY_SEPARATOR, '/', $prefix), '/'); + + return $prefix === '' ? '' : $prefix.'/'; + } + + public function originUrl(): ?string + { + return $this->output(['config', '--get', 'remote.origin.url']); } } diff --git a/tests/Features/Tia/BranchShapes.php b/tests/Features/Tia/BranchShapes.php index 52c5aeb35..0c51a6165 100644 --- a/tests/Features/Tia/BranchShapes.php +++ b/tests/Features/Tia/BranchShapes.php @@ -158,18 +158,6 @@ ->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 { - $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(); -})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows(); - test('a repository with no commits says so, and leaves plain runs alone', function (): void { $project = Project::withoutGit(); $project->git()->run(['init', '--quiet']); diff --git a/tests/Features/Tia/Monorepo.php b/tests/Features/Tia/Monorepo.php new file mode 100644 index 000000000..e6d8cd1ff --- /dev/null +++ b/tests/Features/Tia/Monorepo.php @@ -0,0 +1,155 @@ +nested(); + + $project->write('nested/resources/views/greeting.blade.php', "

Hello

\n"); + $project->write('frontend/widget.php', "git()->commit('add the nested project and a sibling'); + + $project->seedFor($nested, 'master'); + + $project->mutateGraph(function (array $graph): array { + $id = count($graph['files']); + $graph['files'][$id] = 'resources/views/greeting.blade.php'; + $graph['edges']['tests/Unit/GreeterTest.php'][] = $id; + + return $graph; + }); + + return [$project, $nested]; +} + +test('a project in a subdirectory of the repository replays its baseline', function (array $arguments): void { + [$project, $nested] = tiaMonorepo(); + + $result = $project->pestIn($nested, '--tia', ...$arguments); + + expect($result->exitCode)->toBe(0, $result->describe()) + ->and($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe()) + ->and($result->affected())->toBe(0, $result->describe()); +})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows(); + +test('a working tree change inside the subdirectory selects the tests that depend on it', function (array $arguments): void { + [$project, $nested] = tiaMonorepo(); + + $project->write('nested/resources/views/greeting.blade.php', "

Hi

\n"); + $project->snapshot(); + + $result = $project->pestIn($nested, '--tia', ...$arguments); + + expect($result->exitCode)->toBe(0, $result->describe()) + ->and($result->affected())->toBe(2, $result->describe()) + ->and($result->replayed())->toBe(4, $result->describe()); +})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows(); + +test('a committed change inside the subdirectory selects the tests that depend on it', function (): void { + [$project, $nested] = tiaMonorepo(); + + $project->write('nested/resources/views/greeting.blade.php', "

Hi

\n"); + $project->git()->commit('reword the greeting'); + $project->snapshot(); + + $result = $project->pestIn($nested, '--tia'); + + expect($result->exitCode)->toBe(0, $result->describe()) + ->and($result->affected())->toBe(2, $result->describe()) + ->and($result->replayed())->toBe(4, $result->describe()); +})->skipOnWindows(); + +test('a change in a sibling package leaves the subdirectory project untouched', function (): void { + [$project, $nested] = tiaMonorepo(); + + $project->write('frontend/widget.php', "write('frontend/views/greeting.blade.php', "

Hi

\n"); + $project->git()->commit('rework the sibling'); + $project->snapshot(); + + $result = $project->pestIn($nested, '--tia'); + + expect($result->exitCode)->toBe(0, $result->describe()) + ->and($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe()) + ->and($result->affected())->toBe(0, $result->describe()); +})->skipOnWindows(); + +test('a committed change inside the subdirectory that is undone again selects nothing', function (): void { + [$project, $nested] = tiaMonorepo(); + + $project->write('nested/resources/views/greeting.blade.php', "

Hi

\n"); + $project->git()->commit('reword the greeting'); + + $project->write('nested/resources/views/greeting.blade.php', "

Hello

\n"); + $project->git()->commit('put the greeting back'); + $project->snapshot(); + + $result = $project->pestIn($nested, '--tia'); + + expect($result->exitCode)->toBe(0, $result->describe()) + ->and($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe()) + ->and($result->affected())->toBe(0, $result->describe()); +})->skipOnWindows(); + +test('two projects in the same repository keep their state apart', function (): void { + $project = Project::make('master'); + + $api = $project->nested('services/api'); + $admin = $project->nested('services/admin'); + + $project->git()->commit('add both projects'); + + expect($project->stateDirFor($api))->not->toBe($project->stateDirFor($admin)); +})->skipOnWindows(); + +test('a nested project fetches the baseline published for it', function (): void { + [$project, $nested] = tiaMonorepo(); + + $environment = $project->gh('ok', $project->detachGraph()); + + $result = $project->pestWithEnvironment($nested, $environment, '--tia', '--baselined'); + + expect($result->exitCode)->toBe(0, $result->describe()) + ->and($result->output)->toContain('Downloading TIA baseline') + ->and($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe()); +})->skipOnWindows(); + +test('a nested project refuses a baseline published by a sibling project', function (): void { + [$project, $nested] = tiaMonorepo(); + + /** @var array $graph */ + $graph = json_decode($project->detachGraph(), true); + $graph['fingerprint']['structural']['project_prefix'] = 'services/admin/'; + + $environment = $project->gh('ok', (string) json_encode($graph, JSON_UNESCAPED_SLASHES)); + + $result = $project->pestWithEnvironment($nested, $environment, '--tia', '--baselined'); + + expect($result->exitCode)->toBe(0, $result->describe()) + ->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed') + ->and($result->replayed())->toBe(0, $result->describe()); +})->skipOnWindows(); + +test('it says when changes outside the project were ignored', function (): void { + [$project, $nested] = tiaMonorepo(); + + $project->write('frontend/widget.php', "git()->commit('rework the sibling'); + $project->snapshot(); + + $result = $project->pestIn($nested, '--tia'); + + expect($result->exitCode)->toBe(0, $result->describe()) + ->and($result->output)->toContain('1 changed file outside this project was ignored'); +})->skipOnWindows(); diff --git a/tests/Fixtures/Tia/Project.php b/tests/Fixtures/Tia/Project.php index 04d9f4637..544900bec 100644 --- a/tests/Fixtures/Tia/Project.php +++ b/tests/Fixtures/Tia/Project.php @@ -130,6 +130,9 @@ public function nested(string $directory = 'nested'): string } self::copy(__DIR__.'/app', $path); + + $this->write($directory.DIRECTORY_SEPARATOR.'.gitignore', implode("\n", ['/vendor/', '/.phpunit.cache/', ''])); + $this->scaffoldVendor($path); return $path; @@ -394,7 +397,12 @@ public function branchKeys(): array public function graphDir(): string { - return $this->withHome(fn (): string => Storage::tempDir($this->graphRoot)); + return $this->stateDirFor($this->graphRoot); + } + + public function stateDirFor(string $root): string + { + return $this->withHome(fn (): string => Storage::tempDir($root)); } public function graphExists(): bool diff --git a/tests/Unit/Plugins/Tia/ChangedFiles.php b/tests/Unit/Plugins/Tia/ChangedFiles.php new file mode 100644 index 000000000..93f718765 --- /dev/null +++ b/tests/Unit/Plugins/Tia/ChangedFiles.php @@ -0,0 +1,246 @@ + + */ +function tiaMonorepoRoots(?string $created = null): array +{ + static $roots = []; + + if ($created !== null) { + $roots[] = $created; + + return $roots; + } + + $all = $roots; + $roots = []; + + return $all; +} + +/** + * @return array{root: string, project: string, repo: GitRepo, sha: string} + */ +function tiaMonorepoRepository(): array +{ + $root = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-monorepo-'.bin2hex(random_bytes(8)); + + mkdir($root.'/backend/app', 0755, true); + mkdir($root.'/frontend', 0755, true); + + file_put_contents($root.'/backend/app/Service.php', "init('master'); + + return ['root' => $root, 'project' => $root.'/backend', 'repo' => $repo, 'sha' => $repo->sha()]; +} + +afterEach(function (): void { + foreach (tiaMonorepoRoots() as $root) { + new Process(['rm', '-rf', $root])->run(); + } +}); + +it('resolves the project prefix inside the repository', function (): void { + $monorepo = tiaMonorepoRepository(); + + expect(new ChangedFiles($monorepo['project'])->gitPrefix())->toBe('backend/') + ->and(new ChangedFiles($monorepo['root'])->gitPrefix())->toBeEmpty(); +})->skipOnWindows(); + +it('reports working tree changes relative to the project, dropping siblings', function (): void { + $monorepo = tiaMonorepoRepository(); + + file_put_contents($monorepo['project'].'/app/Service.php', "since($monorepo['sha'])) + ->toBe(['app/Service.php']); +})->skipOnWindows(); + +it('reports committed changes relative to the project, dropping siblings', function (): void { + $monorepo = tiaMonorepoRepository(); + + file_put_contents($monorepo['project'].'/app/Service.php', "commit('Second commit'); + + expect(new ChangedFiles($monorepo['project'])->since($monorepo['sha'])) + ->toBe(['app/Service.php']); +})->skipOnWindows(); + +it('drops a file whose content came back to its baseline across the prefix boundary', function (): void { + $monorepo = tiaMonorepoRepository(); + + file_put_contents($monorepo['project'].'/app/Service.php', "commit('Change the service'); + + file_put_contents($monorepo['project'].'/app/Service.php', "commit('Put the service back'); + + expect(new ChangedFiles($monorepo['project'])->since($monorepo['sha']))->toBe([]); +})->skipOnWindows(); + +it('honours a gitignore inside the project', function (): void { + $monorepo = tiaMonorepoRepository(); + + file_put_contents($monorepo['project'].'/.gitignore', "/ignored.php\n"); + file_put_contents($monorepo['project'].'/ignored.php', "since($monorepo['sha'])) + ->toContain('app/Service.php') + ->not->toContain('ignored.php'); +})->skipOnWindows(); + +it('leaves a project at the repository root untouched', function (): void { + $monorepo = tiaMonorepoRepository(); + + file_put_contents($monorepo['root'].'/frontend/widget.php', "since($monorepo['sha'])) + ->toBe(['frontend/widget.php']); +})->skipOnWindows(); + +it('reports the changes it left outside the project', function (): void { + $monorepo = tiaMonorepoRepository(); + + file_put_contents($monorepo['project'].'/app/Service.php', "since($monorepo['sha']); + + expect($changedFiles->outsideProject())->toBe(['frontend/widget.php']); +})->skipOnWindows(); + +it('reports nothing outside the project for a project at the repository root', function (): void { + $monorepo = tiaMonorepoRepository(); + + file_put_contents($monorepo['root'].'/frontend/widget.php', "since($monorepo['sha']); + + expect($changedFiles->outsideProject())->toBeEmpty(); +})->skipOnWindows(); + +it('ties a fingerprint to the project location inside the repository', function (): void { + $monorepo = tiaMonorepoRepository(); + + mkdir($monorepo['root'].'/admin/app', 0755, true); + file_put_contents($monorepo['root'].'/admin/app/Service.php', "commit('add a second project'); + + $nested = Fingerprint::compute($monorepo['project']); + $sibling = Fingerprint::compute($monorepo['root'].'/admin'); + $root = Fingerprint::compute($monorepo['root']); + + expect($nested['structural']['project_prefix'])->toBe('backend/') + ->and($sibling['structural']['project_prefix'])->toBe('admin/') + ->and($root['structural'])->not->toHaveKey('project_prefix') + ->and(Fingerprint::structuralMatches($nested, $sibling))->toBeFalse() + ->and(Fingerprint::structuralMatches($nested, $root))->toBeFalse() + ->and(Fingerprint::structuralDrift($sibling, $nested))->toContain('project_prefix'); +})->skipOnWindows(); + +it('sees a committed non-ascii path that git would otherwise quote', function (): void { + $monorepo = tiaMonorepoRepository(); + + mkdir($monorepo['project'].'/resources/views', 0755, true); + file_put_contents($monorepo['project'].'/resources/views/\u{26a1}orders.blade.php', "
a
\n"); + $monorepo['repo']->commit('add a lightning view'); + + $sha = $monorepo['repo']->sha(); + + file_put_contents($monorepo['project'].'/resources/views/\u{26a1}orders.blade.php', "
b
\n"); + $monorepo['repo']->commit('reword the lightning view'); + + $changedFiles = new ChangedFiles($monorepo['project']); + + expect($changedFiles->since($sha))->toBe(['resources/views/\u{26a1}orders.blade.php']) + ->and($changedFiles->outsideProject())->toBeEmpty(); +})->skipOnWindows(); + +it('sees a committed non-ascii path at the repository root too', function (): void { + $monorepo = tiaMonorepoRepository(); + + file_put_contents($monorepo['root'].'/frontend/\u{26a1}widget.php', "commit('add a lightning widget'); + + $sha = $monorepo['repo']->sha(); + + file_put_contents($monorepo['root'].'/frontend/\u{26a1}widget.php', "commit('rework the lightning widget'); + + expect(new ChangedFiles($monorepo['root'])->since($sha)) + ->toBe(['frontend/\u{26a1}widget.php']); +})->skipOnWindows(); + +it('keeps a tracked outside change and drops an untracked ignored one', function (): void { + $monorepo = tiaMonorepoRepository(); + + file_put_contents($monorepo['root'].'/.gitignore', "dist/\n"); + mkdir($monorepo['root'].'/frontend/dist', 0755, true); + file_put_contents($monorepo['root'].'/frontend/dist/bundle.js', "var a = 1;\n"); + $monorepo['repo']->run(['add', '-f', 'frontend/dist/bundle.js']); + $monorepo['repo']->commit('track a built bundle that the ignore rules match'); + + $sha = $monorepo['repo']->sha(); + + file_put_contents($monorepo['root'].'/frontend/dist/bundle.js', "var a = 2;\n"); + file_put_contents($monorepo['root'].'/frontend/dist/untracked.js', "var b = 1;\n"); + $monorepo['repo']->commit('rebuild the bundle'); + + $changedFiles = new ChangedFiles($monorepo['project']); + $changedFiles->since($sha); + + expect($changedFiles->outsideProject())->toBe(['frontend/dist/bundle.js']); +})->skipOnWindows(); + +it('keeps a tracked project change that the ignore rules match', function (): void { + $monorepo = tiaMonorepoRepository(); + + file_put_contents($monorepo['root'].'/.gitignore', "build/\n"); + mkdir($monorepo['project'].'/build', 0755, true); + file_put_contents($monorepo['project'].'/build/Generated.php', "run(['add', '-f', 'backend/build/Generated.php']); + $monorepo['repo']->commit('track a generated file that the ignore rules match'); + + $sha = $monorepo['repo']->sha(); + + file_put_contents($monorepo['project'].'/build/Generated.php', "commit('regenerate the file'); + + expect(new ChangedFiles($monorepo['project'])->since($sha)) + ->toBe(['build/Generated.php']); +})->skipOnWindows(); + +it('reads committed paths from the repository root even when git is told to be relative', function (): void { + $monorepo = tiaMonorepoRepository(); + + $monorepo['repo']->run(['config', 'diff.relative', 'true']); + + file_put_contents($monorepo['project'].'/app/Service.php', "commit('rework both sides'); + + $changedFiles = new ChangedFiles($monorepo['project']); + + expect($changedFiles->since($monorepo['sha']))->toBe(['app/Service.php']) + ->and($changedFiles->outsideProject())->toBe(['frontend/widget.php']); +})->skipOnWindows(); diff --git a/tests/Unit/Plugins/Tia/Storage.php b/tests/Unit/Plugins/Tia/Storage.php index 0f164348c..a51997670 100644 --- a/tests/Unit/Plugins/Tia/Storage.php +++ b/tests/Unit/Plugins/Tia/Storage.php @@ -9,6 +9,8 @@ use Pest\Plugins\Tia\Storage; use Pest\Support\Container; use Pest\TestSuite; +use Symfony\Component\Process\Process; +use Tests\Fixtures\Tia\GitRepo; afterEach(function (): void { Storage::useDirectory(null); @@ -37,3 +39,32 @@ expect(Storage::tempDir('/project'))->toBe('/tmp/pest-tia'); }); + +it('keeps the state directory of a project at the repository root', function (): void { + $root = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-storage-'.bin2hex(random_bytes(8)); + + mkdir($root.'/backend', 0755, true); + + $repo = new GitRepo($root); + $repo->init('master'); + $repo->addOrigin('git@github.com:pestphp/tia-fixture.git'); + + $home = $root.DIRECTORY_SEPARATOR.'.home'; + mkdir($home, 0755, true); + + $original = getenv('HOME'); + putenv('HOME='.$home); + + try { + $rootDir = Storage::tempDir($root); + $nestedDir = Storage::tempDir($root.'/backend'); + } finally { + putenv($original === false ? 'HOME' : 'HOME='.$original); + new Process(['rm', '-rf', $root])->run(); + } + + $identity = substr(hash('sha256', 'github.com/pestphp/tia-fixture'), 0, 16); + + expect(basename($rootDir))->toEndWith('-'.$identity) + ->and(basename($nestedDir))->not->toEndWith('-'.$identity); +})->skipOnWindows();