diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 5b527360..8157a5a9 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -98,6 +98,21 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument private const string PIGGYBACK_COVERAGE_GLOBAL = 'TIA_PIGGYBACK_COVERAGE'; + /** + * The parent's resolved fallback branch, handed to the workers. + * + * A worker cannot resolve it for itself: the restarters run before + * `tests/Pest.php` is loaded, so a `defaultBranch()` declared there is + * invisible to it — and autodetecting again would spend a git call per + * worker to reach the answer the parent already has. + */ + private const string FALLBACK_BRANCH_GLOBAL = 'TIA_FALLBACK_BRANCH'; + + /** + * The branch assumed when a repository cannot name its own default. + */ + private const string DEFAULT_BRANCH = 'main'; + /** * PHPUnit/Pest CLI flags whose subsequent argument is a value, not a path. * @@ -197,13 +212,22 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument /** * The baseline this run reads from and writes to. * - * `main` is only the fallback for a repository whose branch cannot be read - * — it is also the branch every other baseline falls back to reading, so - * writing there by accident corrupts the shared baseline. Resolved through - * resolveBranch() rather than at every use site, because the git call it - * needs is not free. + * The repository's default branch is only the fallback for a checkout whose + * branch cannot be read — a detached HEAD. It is also the branch every + * other baseline falls back to reading, so writing there by accident + * corrupts the shared baseline. Resolved through resolveBranch() rather + * than at every use site, because the git call it needs is not free. */ - private string $branch = 'main'; + private string $branch = self::DEFAULT_BRANCH; + + /** + * The baseline branches with none of their own read from. + * + * Read-only, and the whole point of the exercise: without it the first run + * on every new branch re-runs a suite whose results the default branch + * already holds. + */ + private string $fallbackBranch = self::DEFAULT_BRANCH; private bool $branchResolved = false; @@ -289,7 +313,13 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument return null; } - return Graph::decode($json, $projectRoot); + $graph = Graph::decode($json, $projectRoot); + + // Every read of a baseline goes through a graph loaded here, so this is + // the one place the resolved fallback has to reach. + $graph?->setFallbackBranch($this->fallbackBranch); + + return $graph; } private function saveGraph(Graph $graph): bool @@ -1724,6 +1754,11 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument // fallback baseline is the lesser of the two evils. } + // The graph above was loaded before the branch was known — this path + // only writes, but a graph carrying an unresolved fallback is the exact + // bug this whole change is about. + $graph->setFallbackBranch($this->fallbackBranch); + $touchedFiles = []; // Whether this run is the one that records the edges its results will be @@ -1892,7 +1927,7 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument } /** - * Resolves the baseline this run reads from and writes to, once. + * Resolves the baselines this run reads from and writes to, once. * * Results are written on runs where TIA itself took no part, and those * never reach handleParent(). Without this the default would stand and @@ -1907,7 +1942,36 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument $this->branchResolved = true; - $this->branch = new ChangedFiles($projectRoot)->currentBranch() ?? $this->branch; + $changedFiles = new ChangedFiles($projectRoot); + + // 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); + + Parallel::setGlobal(self::FALLBACK_BRANCH_GLOBAL, $this->fallbackBranch); + + // A detached HEAD has no branch of its own to write to. The default + // branch is the honest key there — it is the commit the checkout most + // likely sits on, and it keeps a phantom baseline from being minted + // under a branch name the repository never had. + $this->branch = $changedFiles->currentBranch() ?? $this->fallbackBranch; + } + + private function resolveFallbackBranch(ChangedFiles $changedFiles): string + { + $inherited = Parallel::getGlobal(self::FALLBACK_BRANCH_GLOBAL); + + if (is_string($inherited) && $inherited !== '') { + 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() + ?? $changedFiles->defaultBranch() + ?? self::DEFAULT_BRANCH; } /** diff --git a/src/Plugins/Tia/ChangedFiles.php b/src/Plugins/Tia/ChangedFiles.php index 9ffb680e..d39da1d8 100644 --- a/src/Plugins/Tia/ChangedFiles.php +++ b/src/Plugins/Tia/ChangedFiles.php @@ -219,6 +219,49 @@ final readonly class ChangedFiles return $branch === '' || $branch === 'HEAD' ? null : $branch; } + /** + * The repository's default branch — the one every other branch's baseline + * falls back to reading. + * + * Advisory, unlike {@see self::currentBranch()}: a repository that cannot + * answer the question is not a broken repository. A remote-less checkout + * has no `origin/HEAD`, and plenty of CI checkouts never run + * `git remote set-head`, so every step here fails soft and the caller is + * left to pick its own default. + */ + public function defaultBranch(): ?string + { + $head = $this->gitOutput(['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD']); + + if ($head !== null) { + $branch = preg_replace('#^origin/#', '', $head); + + if (is_string($branch) && $branch !== '') { + return $branch; + } + } + + return $this->gitOutput(['git', 'config', '--get', 'init.defaultBranch']); + } + + /** + * @param array $command + */ + private function gitOutput(array $command): ?string + { + $process = new Process($command, $this->projectRoot); + $process->setTimeout(5.0); + $process->run(); + + if (! $process->isSuccessful()) { + return null; + } + + $output = trim($process->getOutput()); + + return $output === '' ? null : $output; + } + private function shaIsReachable(string $sha): bool { $process = new Process( diff --git a/src/Plugins/Tia/Configuration.php b/src/Plugins/Tia/Configuration.php index 61789801..0850b2e6 100644 --- a/src/Plugins/Tia/Configuration.php +++ b/src/Plugins/Tia/Configuration.php @@ -60,6 +60,24 @@ final class Configuration return $this; } + /** + * The branch whose baseline every other branch falls back to reading. + * + * Autodetected from the repository when left unset; declare it here when + * the repository cannot answer for itself — no `origin/HEAD`, or an + * `init.defaultBranch` that disagrees with reality. + * + * @return $this + */ + public function defaultBranch(string $branch): self + { + /** @var WatchPatterns $watchPatterns */ + $watchPatterns = Container::getInstance()->get(WatchPatterns::class); + $watchPatterns->setDefaultBranch($branch); + + return $this; + } + /** * @param array $patterns glob → project-relative test dir * @return $this diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 14d077d8..83806b53 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -48,6 +48,17 @@ final class Graph */ private array $baselines = []; + /** + * The baseline a branch with none of its own reads from. + * + * Only ever read from: a branch writes to its own key, so a fallback that + * leaked into the write path would corrupt the baseline every other branch + * depends on. Resolved once per run by the plugin — see + * {@see self::setFallbackBranch()} — because the git calls it takes are not + * free and the read path runs per test. + */ + private string $fallbackBranch = 'main'; + private readonly string $projectRoot; /** @var array|null */ @@ -576,7 +587,12 @@ final class Graph return $this->fingerprint; } - public function recordedAtSha(string $branch, string $fallbackBranch = 'main'): ?string + public function setFallbackBranch(string $branch): void + { + $this->fallbackBranch = $branch; + } + + public function recordedAtSha(string $branch, ?string $fallbackBranch = null): ?string { $baseline = $this->baselineFor($branch, $fallbackBranch); @@ -611,7 +627,7 @@ final class Graph $this->baselines[$branch]['results'][$testId] = $entry; } - public function getAssertions(string $branch, string $testId, string $fallbackBranch = 'main'): ?int + public function getAssertions(string $branch, string $testId, ?string $fallbackBranch = null): ?int { $baseline = $this->baselineFor($branch, $fallbackBranch); @@ -622,7 +638,7 @@ final class Graph return $baseline['results'][$testId]['assertions']; } - public function getTime(string $branch, string $testId, string $fallbackBranch = 'main'): ?float + public function getTime(string $branch, string $testId, ?string $fallbackBranch = null): ?float { $baseline = $this->baselineFor($branch, $fallbackBranch); @@ -633,7 +649,7 @@ final class Graph return $baseline['results'][$testId]['time']; } - public function getResult(string $branch, string $testId, string $fallbackBranch = 'main'): ?TestStatus + public function getResult(string $branch, string $testId, ?string $fallbackBranch = null): ?TestStatus { $baseline = $this->baselineFor($branch, $fallbackBranch); @@ -660,7 +676,7 @@ final class Graph /** * @return array */ - public function testFilesToRerun(string $branch, string $fallbackBranch = 'main'): array + public function testFilesToRerun(string $branch, ?string $fallbackBranch = null): array { $baseline = $this->baselineFor($branch, $fallbackBranch); $files = []; @@ -697,7 +713,7 @@ final class Graph * collects no tests, so the run reports green without ever re-running the * failure — and does so again on every subsequent invocation. */ - public function hasUnlocatedTestsToRerun(string $branch, string $fallbackBranch = 'main'): bool + public function hasUnlocatedTestsToRerun(string $branch, ?string $fallbackBranch = null): bool { $baseline = $this->baselineFor($branch, $fallbackBranch); @@ -808,7 +824,7 @@ final class Graph /** * @return array */ - public function lastRunTree(string $branch, string $fallbackBranch = 'main'): array + public function lastRunTree(string $branch, ?string $fallbackBranch = null): array { return $this->baselineFor($branch, $fallbackBranch)['tree']; } @@ -816,8 +832,10 @@ final class Graph /** * @return array{sha: ?string, tree: array, results: array} */ - private function baselineFor(string $branch, string $fallbackBranch): array + private function baselineFor(string $branch, ?string $fallbackBranch): array { + $fallbackBranch ??= $this->fallbackBranch; + if (isset($this->baselines[$branch])) { return $this->baselines[$branch]; } diff --git a/src/Plugins/Tia/WatchPatterns.php b/src/Plugins/Tia/WatchPatterns.php index 33f91f0f..8d1e9837 100644 --- a/src/Plugins/Tia/WatchPatterns.php +++ b/src/Plugins/Tia/WatchPatterns.php @@ -44,6 +44,8 @@ final class WatchPatterns private bool $baselined = false; + private ?string $defaultBranch = null; + public function useDefaults(string $projectRoot): void { $testPath = TestSuite::getInstance()->testPath; @@ -177,6 +179,16 @@ final class WatchPatterns return $this->baselined; } + public function setDefaultBranch(string $branch): void + { + $this->defaultBranch = $branch; + } + + public function defaultBranch(): ?string + { + return $this->defaultBranch; + } + public function reset(): void { $this->patterns = []; @@ -185,6 +197,7 @@ final class WatchPatterns $this->locally = false; $this->filtered = false; $this->baselined = false; + $this->defaultBranch = null; } private function keyMatches(string $key, string $file): bool