From e90e4f70fcc4f5afce03dd1050f715dd8c218586 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Wed, 5 Aug 2026 20:20:35 +0100 Subject: [PATCH] wip --- src/Exceptions/TiaRequiresDefaultBranch.php | 52 ++++++++ src/Plugins/Tia.php | 71 +++++++++-- src/Plugins/Tia/ChangedFiles.php | 15 ++- src/Plugins/Tia/CiDefaultBranch.php | 77 ++++++++++++ src/Plugins/Tia/Graph.php | 29 +++++ .../Features/Tia/DefaultBranchResolution.php | 114 +++++++++++++++++- tests/Fixtures/Tia/GitRepo.php | 11 ++ tests/Fixtures/Tia/Project.php | 7 ++ 8 files changed, 360 insertions(+), 16 deletions(-) create mode 100644 src/Exceptions/TiaRequiresDefaultBranch.php create mode 100644 src/Plugins/Tia/CiDefaultBranch.php diff --git a/src/Exceptions/TiaRequiresDefaultBranch.php b/src/Exceptions/TiaRequiresDefaultBranch.php new file mode 100644 index 00000000..d914d26b --- /dev/null +++ b/src/Exceptions/TiaRequiresDefaultBranch.php @@ -0,0 +1,52 @@ +writeln([ + '', + ' ERROR Tia mode could not determine the default branch.', + '', + ' It is the branch whose baseline every other branch falls back to reading, and', + ' nothing in this checkout names it: the repository has a remote, but no', + ' origin/HEAD, and no CI provider stated it either. Guessing would re-run the', + ' whole suite on every new branch while reporting it as a cache hit.', + '', + ' Name the branch in tests/Pest.php:', + '', + ' pest()->tia()->defaultBranch(\'master\');', + '', + ' Or let git answer, once per clone:', + '', + ' git remote set-head origin --auto', + '', + ]); + } + + public function exitCode(): int + { + return 1; + } +} diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 97e5e791..3032be6c 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -12,12 +12,14 @@ use Pest\Contracts\Plugins\Terminable; use Pest\Exceptions\InvalidOption; use Pest\Exceptions\MissingDependency; use Pest\Exceptions\NoAffectedTestsFound; +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; use Pest\Plugins\Tia\ChangedFiles; +use Pest\Plugins\Tia\CiDefaultBranch; use Pest\Plugins\Tia\Contracts\State; use Pest\Plugins\Tia\CoverageCollector; use Pest\Plugins\Tia\Fingerprint; @@ -244,6 +246,16 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument */ private string $fallbackBranch = self::DEFAULT_BRANCH; + /** + * Whether anything actually named the branch above. + * + * When nothing did, the value is a guess, and a guess is what the TIA path + * refuses to run on: an unresolved fallback reads no baseline at all, which + * looks exactly like a hit in the output. Runs that never asked for TIA + * still have to write somewhere, so the guess stands for them. + */ + private bool $fallbackBranchResolved = false; + private bool $branchResolved = false; /** @var array */ @@ -889,11 +901,15 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument $this->resolveBranch($projectRoot); // After resolveBranch(), so a directory that is no repository at all - // still reports the missing git dependency rather than a missing remote. - // Skipped once the default branch is configured by hand: there is then - // nothing left for a remote to answer. - if ($this->watchPatterns->defaultBranch() === null && ! new ChangedFiles($projectRoot)->hasRemote()) { - Panic::with(new TiaRequiresRemote); + // still reports the missing git dependency rather than an unresolved + // default branch. Nothing named the branch every other baseline reads + // through, so every new branch would re-run the whole suite while the + // output called it a hit. A repository with no remote is the likeliest + // reason and gets said out loud. + if (! $this->fallbackBranchResolved) { + Panic::with(new ChangedFiles($projectRoot)->hasRemote() + ? new TiaRequiresDefaultBranch + : new TiaRequiresRemote); } $fingerprint = Fingerprint::compute($projectRoot); @@ -2001,7 +2017,10 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument // Resolved before the current branch, which throws where git is // missing: the fallback is advisory, so a run that cannot name its // branch at all should still carry the best answer available. - $this->fallbackBranch = $this->resolveFallbackBranch($changedFiles); + $resolved = $this->resolveFallbackBranch($changedFiles); + + $this->fallbackBranchResolved = $resolved !== null; + $this->fallbackBranch = $resolved ?? self::DEFAULT_BRANCH; Parallel::setGlobal(self::FALLBACK_BRANCH_GLOBAL, $this->fallbackBranch); @@ -2012,7 +2031,18 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument $this->branch = $changedFiles->currentBranch() ?? $this->fallbackBranch; } - private function resolveFallbackBranch(ChangedFiles $changedFiles): string + /** + * The branch every other baseline falls back to reading, or null when + * nothing in the checkout can name it. + * + * Ordered by how much the source actually knows. Configuration first: it is + * the escape hatch for a repository whose git-side answers disagree with its + * branches. Then the CI provider, which states the answer outright where git + * is at its least informed. Then git itself. Then the recorded graph, whose + * single baseline can only have come from the branch this repository + * integrates on. + */ + private function resolveFallbackBranch(ChangedFiles $changedFiles): ?string { $inherited = Parallel::getGlobal(self::FALLBACK_BRANCH_GLOBAL); @@ -2020,12 +2050,31 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument return $inherited; } - // Configuration wins over autodetection: it is the escape hatch for a - // repository whose `origin/HEAD` is unset and whose `init.defaultBranch` - // says something else than its branches do. return $this->watchPatterns->defaultBranch() + ?? CiDefaultBranch::detect() ?? $changedFiles->defaultBranch() - ?? self::DEFAULT_BRANCH; + ?? $this->soleRecordedBranch(); + } + + /** + * The one branch a recorded graph holds a baseline for. + * + * Last in the chain and deliberately narrow: with a single baseline on disk + * there is only one branch whose results can be read at all, so naming it is + * strictly better than resolving to a branch that holds nothing. Two or more + * baselines carry no such implication and are left alone. + */ + private function soleRecordedBranch(): ?string + { + $json = $this->state->read(self::KEY_GRAPH); + + if ($json === null) { + return null; + } + + $branches = Graph::branchesIn($json); + + return count($branches) === 1 ? $branches[0] : null; } /** diff --git a/src/Plugins/Tia/ChangedFiles.php b/src/Plugins/Tia/ChangedFiles.php index 979eba48..9207bd03 100644 --- a/src/Plugins/Tia/ChangedFiles.php +++ b/src/Plugins/Tia/ChangedFiles.php @@ -241,7 +241,20 @@ final readonly class ChangedFiles } } - return $this->gitOutput(['git', 'config', '--get', 'init.defaultBranch']); + // `init.defaultBranch` is a setting of the machine, not of the + // repository — it names what `git init` would have called the first + // branch here, which is worth nothing once the repository disagrees. + // Taken only when a branch by that name actually exists. + $configured = $this->gitOutput(['git', 'config', '--get', 'init.defaultBranch']); + + if ($configured === null) { + return null; + } + + $exists = $this->gitOutput(['git', 'rev-parse', '--verify', '--quiet', 'refs/heads/'.$configured]) !== null + || $this->gitOutput(['git', 'rev-parse', '--verify', '--quiet', 'refs/remotes/origin/'.$configured]) !== null; + + return $exists ? $configured : null; } /** diff --git a/src/Plugins/Tia/CiDefaultBranch.php b/src/Plugins/Tia/CiDefaultBranch.php new file mode 100644 index 00000000..07450ae3 --- /dev/null +++ b/src/Plugins/Tia/CiDefaultBranch.php @@ -0,0 +1,77 @@ + + */ + public static function branchesIn(string $json): array + { + $data = json_decode($json, true); + + if (! is_array($data) || ! is_array($data['baselines'] ?? null)) { + return []; + } + + $branches = []; + + foreach (array_keys($data['baselines']) as $branch) { + if (is_string($branch) && $branch !== '') { + $branches[] = $branch; + } + } + + return $branches; + } + public static function decode(string $json, string $projectRoot): ?self { $data = json_decode($json, true); diff --git a/tests/Features/Tia/DefaultBranchResolution.php b/tests/Features/Tia/DefaultBranchResolution.php index e80fd29d..a79a350f 100644 --- a/tests/Features/Tia/DefaultBranchResolution.php +++ b/tests/Features/Tia/DefaultBranchResolution.php @@ -42,9 +42,117 @@ test('a declared default branch that does not exist degrades to a full run', fun ->and($project->branchKeys())->toBe(['master', 'feature-x']); })->skipOnWindows(); +test('the CI provider names the default branch where the checkout cannot', function (): void { + // A CI checkout: `actions/checkout` fetches a single ref instead of cloning, + // so there is no `origin/HEAD` for git to read the default branch from. The + // event payload GitHub hands the job says it outright. + $project = Project::make('master'); + $project->git()->unsetOriginHead(); + $project->seed('master'); + + $project->git()->switchTo('feature-x', new: true); + + $project->write('.home/event.json', (string) json_encode([ + 'repository' => ['default_branch' => 'master'], + ])); + + $result = $project->pestWithEnvironment($project->path(), [ + 'GITHUB_EVENT_PATH' => $project->path('.home/event.json'), + ], '--tia'); + + expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe()) + ->and($result->uncached())->toBe(0, $result->describe()); +})->skipOnWindows(); + +test('GitLab names the default branch through its own variable', function (): void { + $project = Project::make('master'); + $project->git()->unsetOriginHead(); + $project->seed('master'); + + $project->git()->switchTo('feature-x', new: true); + + $result = $project->pestWithEnvironment($project->path(), [ + 'CI_DEFAULT_BRANCH' => 'master', + ], '--tia'); + + expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe()) + ->and($result->uncached())->toBe(0, $result->describe()); +})->skipOnWindows(); + +test('a lone recorded baseline names the default branch', function (): void { + // Nothing left to ask: no `origin/HEAD`, no CI provider, and an + // `init.defaultBranch` that names a branch this repository does not have. + // The graph holds exactly one baseline, and it is the only one any branch + // could read — so it is the answer. + $project = Project::make('master'); + $project->git()->unsetOriginHead(); + $project->git()->config('init.defaultBranch', 'main'); + $project->seed('master'); + + $project->git()->switchTo('feature-x', new: true); + + $result = $project->pest('--tia'); + + expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe()) + ->and($result->uncached())->toBe(0, $result->describe()); +})->skipOnWindows(); + +test('a default branch nothing can name is refused rather than guessed', function (): void { + // Same checkout as above, without the graph that answered it. Guessing here + // is what made this bug expensive: the guess reads no baseline at all, and + // the output calls that a hit. + $project = Project::make('master'); + $project->git()->unsetOriginHead(); + $project->git()->config('init.defaultBranch', 'main'); + + $project->git()->switchTo('feature-x', new: true); + + $result = $project->pest('--tia'); + + expect($result->output)->toContain('Tia mode could not determine the default branch.') + ->and($result->output)->toContain('git remote set-head origin --auto') + ->and($result->exitCode)->toBe(1, $result->describe()) + ->and($project->graphExists())->toBeFalse(); +})->skipOnWindows(); + +test('an init.defaultBranch naming a branch that exists is still trusted', function (): void { + // The setting is the machine's, not the repository's — worth taking only + // where the repository has a branch by that name. It does here, and with no + // graph on disk it is the only source left, so the run must not be refused. + $project = Project::make('master'); + $project->git()->unsetOriginHead(); + $project->git()->config('init.defaultBranch', 'master'); + + $project->git()->switchTo('feature-x', new: true); + + $result = $project->pest('--tia'); + + expect($result->exitCode)->toBe(0, $result->describe()) + ->and($result->output)->not->toContain('could not determine the default branch') + ->and($project->branchKeys())->toBe(['feature-x']); +})->skipOnWindows(); + test('a repository with no remote is refused rather than silently re-run', function (): void { $project = Project::make('master'); + $project->git()->removeOrigin(); + + $project->git()->switchTo('feature-x', new: true); + + $result = $project->pest('--tia'); + + // Nothing can name the default branch, so every new branch would re-run the + // whole suite with no explanation. Saying so beats doing that quietly. The + // missing remote is the likeliest reason and gets named as such. + expect($result->output)->toContain('Tia mode requires a repository with a remote.') + ->and($result->exitCode)->toBe(1, $result->describe()); +})->skipOnWindows(); + +test('a remote-less repository holding one baseline is not refused', function (): void { + // The refusal above exists to stop a guess, not to demand a remote for its + // own sake. With a baseline on disk there is nothing left to guess at. + $project = Project::make('master'); + $project->git()->removeOrigin(); $project->seed('master'); @@ -52,10 +160,8 @@ test('a repository with no remote is refused rather than silently re-run', funct $result = $project->pest('--tia'); - // Nothing can name the default branch, so every new branch would re-run the - // whole suite with no explanation. Saying so beats doing that quietly. - expect($result->output)->toContain('Tia mode requires a repository with a remote.') - ->and($result->exitCode)->toBe(1, $result->describe()); + expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe()) + ->and($result->uncached())->toBe(0, $result->describe()); })->skipOnWindows(); test('a declared default branch stands in for a missing remote', function (): void { diff --git a/tests/Fixtures/Tia/GitRepo.php b/tests/Fixtures/Tia/GitRepo.php index 18c4689e..e828fd04 100644 --- a/tests/Fixtures/Tia/GitRepo.php +++ b/tests/Fixtures/Tia/GitRepo.php @@ -96,6 +96,17 @@ final readonly class GitRepo $this->run(['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/'.$branch]); } + /** + * Drops `refs/remotes/origin/HEAD` while keeping the remote-tracking branch + * — what a CI checkout looks like. `actions/checkout` builds its working + * copy with `git init` plus a single-ref `fetch` rather than a `clone`, and + * only a `clone` writes that symbolic ref. + */ + public function unsetOriginHead(): void + { + $this->run(['symbolic-ref', '--delete', 'refs/remotes/origin/HEAD']); + } + public function config(string $key, string $value): void { $this->run(['config', '--local', $key, $value]); diff --git a/tests/Fixtures/Tia/Project.php b/tests/Fixtures/Tia/Project.php index 67443f43..ee6b42ff 100644 --- a/tests/Fixtures/Tia/Project.php +++ b/tests/Fixtures/Tia/Project.php @@ -222,6 +222,13 @@ final class Project 'PARATEST' => '0', 'PAO_DISABLE' => '1', 'HOME' => $this->home(), + // Blanked for the same reason `GitRepo::ENV` blanks git's own + // config: the default branch a CI provider reports is the one + // *its* build is for. Pest's suite runs on GitHub Actions, so + // without this every scenario would autodetect Pest's default + // branch instead of the fixture's. + 'GITHUB_EVENT_PATH' => '', + 'CI_DEFAULT_BRANCH' => '', ...$environment, ], );