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 107adf902..5e90b13d1 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\ExecutableFinder; use Symfony\Component\Process\Process; @@ -73,23 +74,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 4c2e446b7..5cad8f10a 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)), ); @@ -321,13 +402,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)); } /** @@ -373,7 +454,7 @@ private function workingTreeChanges(): array $files[] = $path; } - return $files; + return $this->toProjectRelative($files); } public function currentSha(): ?string @@ -389,6 +470,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 fe0e59410..8a585a099 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();