From 8d32b239499650f14a693f1208931ab08b80e9ad Mon Sep 17 00:00:00 2001 From: Caleb White Date: Mon, 10 Aug 2026 21:24:18 -0500 Subject: [PATCH 1/2] refactor: extract shared CI detection utility Consolidate duplicated CI environment detection logic from Snapshot and BaselineSync into a shared Pest\Support\Ci class. This reduces duplication and provides a single source of truth for CI detection, including comprehensive environment variable checks for all major CI providers. --- src/Plugins/Snapshot.php | 37 ++----------------------- src/Plugins/Tia/BaselineSync.php | 10 ++----- src/Support/Ci.php | 47 ++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 43 deletions(-) create mode 100644 src/Support/Ci.php diff --git a/src/Plugins/Snapshot.php b/src/Plugins/Snapshot.php index e210b4f70..863147ea7 100644 --- a/src/Plugins/Snapshot.php +++ b/src/Plugins/Snapshot.php @@ -5,6 +5,7 @@ namespace Pest\Plugins; use Pest\Contracts\Plugins\HandlesArguments; +use Pest\Support\Ci; use Pest\TestSuite; /** @@ -16,34 +17,9 @@ final class Snapshot implements HandlesArguments public static bool $updateSnapshots = false; - /** - * @var list - */ - private const array CI_ENVIRONMENT_VARIABLES = [ - 'CI', - 'GITHUB_ACTIONS', - 'GITLAB_CI', - 'CIRCLECI', - 'TRAVIS', - 'APPVEYOR', - 'BITBUCKET_BUILD_NUMBER', - 'BUILDKITE', - 'TEAMCITY_VERSION', - 'JENKINS_URL', - 'SYSTEM_COLLECTIONURI', - 'CI_NAME', - 'TASKCLUSTER_ROOT_URL', - 'DRONE', - 'WERCKER', - 'NEVERCODE', - 'SEMAPHORE', - 'NETLIFY', - 'NOW_BUILDER', - ]; - public static function shouldCreateMissingSnapshots(): bool { - return self::$updateSnapshots || ! self::runningOnCI(); + return self::$updateSnapshots || ! Ci::isRunning(); } /** @@ -149,13 +125,4 @@ private function isFullRun(array $arguments): bool return true; } - - private static function runningOnCI(): bool - { - if (Environment::name() === Environment::CI) { - return true; - } - - return array_any(self::CI_ENVIRONMENT_VARIABLES, fn (string $environmentVariable): bool => getenv($environmentVariable) !== false); - } } diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index 1f4d63cea..f34605836 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -8,6 +8,7 @@ use Pest\Panic; use Pest\Plugins\Tia; use Pest\Plugins\Tia\Contracts\State; +use Pest\Support\Ci; use Pest\Support\View; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\Process; @@ -164,7 +165,7 @@ private function formatDuration(int $seconds): string private function emitPublishInstructions(): void { - if ($this->isCi()) { + if (Ci::isRunning()) { $this->renderBadge('INFO', 'No baseline yet — this run will produce one.'); return; @@ -174,13 +175,6 @@ private function emitPublishInstructions(): void $this->renderChild('See https://pestphp.com/docs/tia for how to publish one from CI.'); } - private function isCi(): bool - { - return getenv('GITHUB_ACTIONS') === 'true' - || getenv('GITLAB_CI') === 'true' - || getenv('CIRCLECI') === 'true'; - } - private function detectGitHubRepo(string $projectRoot): ?string { $gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config'; diff --git a/src/Support/Ci.php b/src/Support/Ci.php new file mode 100644 index 000000000..3c3620b48 --- /dev/null +++ b/src/Support/Ci.php @@ -0,0 +1,47 @@ + + */ + private const array ENVIRONMENT_VARIABLES = [ + 'CI', + 'GITHUB_ACTIONS', + 'GITLAB_CI', + 'CIRCLECI', + 'TRAVIS', + 'APPVEYOR', + 'BITBUCKET_BUILD_NUMBER', + 'BUILDKITE', + 'TEAMCITY_VERSION', + 'JENKINS_URL', + 'SYSTEM_COLLECTIONURI', + 'CI_NAME', + 'TASKCLUSTER_ROOT_URL', + 'DRONE', + 'WERCKER', + 'NEVERCODE', + 'SEMAPHORE', + 'NETLIFY', + 'NOW_BUILDER', + ]; + + public static function isRunning(): bool + { + if (Environment::name() === Environment::CI) { + return true; + } + + return array_any(self::ENVIRONMENT_VARIABLES, fn (string $env): bool => getenv($env) !== false); + } +} From 0760b4e4a744a44916658947233eaea6b995705e Mon Sep 17 00:00:00 2001 From: Caleb White Date: Mon, 10 Aug 2026 21:26:15 -0500 Subject: [PATCH 2/2] fix(tia): persist state on detached HEAD Allow detached checkouts to write, delete, and rebuild TIA state so baseline publishers and local detached workflows produce a usable graph. Resolve the writable baseline from the Git branch, CI branch metadata, or a stable workspace key. Keep the default branch as the read fallback so local workspaces can use a published baseline without changing it. --- src/Plugins/Tia.php | 34 +++++++++---------- .../Tia/{CiDefaultBranch.php => CiBranch.php} | 17 ++++++++-- src/Plugins/Tia/Cis/GitHub.php | 5 +++ src/Plugins/Tia/Cis/GitLab.php | 5 +++ src/Plugins/Tia/Contracts/Ci.php | 2 ++ src/Plugins/Tia/Graph.php | 6 ++++ tests/Features/Tia/BranchShapes.php | 9 +++-- tests/Features/Tia/DefaultBranchWriteTier.php | 33 ++++++++++++++++-- tests/Features/Tia/StateReclamation.php | 12 +++---- tests/Fixtures/Tia/Project.php | 15 ++++---- 10 files changed, 101 insertions(+), 37 deletions(-) rename src/Plugins/Tia/{CiDefaultBranch.php => CiBranch.php} (58%) diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index be761fc7b..5c287113e 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -20,7 +20,7 @@ use Pest\Plugins\Concerns\HandleArguments; use Pest\Plugins\Tia\BaselineSync; use Pest\Plugins\Tia\ChangedFiles; -use Pest\Plugins\Tia\CiDefaultBranch; +use Pest\Plugins\Tia\CiBranch; use Pest\Plugins\Tia\Contracts\State; use Pest\Plugins\Tia\CoverageCollector; use Pest\Plugins\Tia\Fingerprint; @@ -196,8 +196,6 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument private bool $unreadableGraphReported = false; - private bool $detachedHead = false; - private bool $graphUnreachable = false; private bool $fullSuiteFallbackRan = false; @@ -283,19 +281,11 @@ private function discardUnreadableGraph(): void private function deleteState(string $key): bool { - if ($this->detachedHead) { - return false; - } - return $this->state->delete($key); } private function saveGraph(Graph $graph): bool { - if ($this->detachedHead) { - return true; - } - $json = $graph->encode(); if ($json === null) { @@ -851,7 +841,7 @@ private function handleParent(array $arguments, string $projectRoot, bool $force $fingerprint = Fingerprint::compute($projectRoot); $this->startFingerprint = $fingerprint; - if ($forceRebuild && ! $this->detachedHead) { + if ($forceRebuild) { Storage::purge($projectRoot); } @@ -1950,10 +1940,17 @@ private function resolveBranch(string $projectRoot): void Parallel::setGlobal(self::FALLBACK_BRANCH_GLOBAL, $this->fallbackBranch); - $currentBranch = $changedFiles->currentBranch(); + $this->branch = $changedFiles->currentBranch() + ?? CiBranch::detectCurrent() + ?? $this->workspaceBranch($projectRoot); + } - $this->detachedHead = $currentBranch === null; - $this->branch = $currentBranch ?? $this->fallbackBranch; + private function workspaceBranch(string $projectRoot): string + { + $realProjectRoot = realpath($projectRoot); + $hash = hash('sha256', $realProjectRoot === false ? $projectRoot : $realProjectRoot); + + return Graph::WORKSPACE_BRANCH_PREFIX.substr($hash, 0, 16); } private function resolveFallbackBranch(ChangedFiles $changedFiles): ?string @@ -1965,7 +1962,7 @@ private function resolveFallbackBranch(ChangedFiles $changedFiles): ?string } return $this->watchPatterns->defaultBranch() - ?? CiDefaultBranch::detect() + ?? CiBranch::detectDefault() ?? $changedFiles->defaultBranch() ?? $this->soleRecordedBranch(); } @@ -1978,7 +1975,10 @@ private function soleRecordedBranch(): ?string return null; } - $branches = Graph::branchesIn($json); + $branches = array_values(array_filter( + Graph::branchesIn($json), + fn (string $branch): bool => ! str_starts_with($branch, Graph::WORKSPACE_BRANCH_PREFIX), + )); return count($branches) === 1 ? $branches[0] : null; } diff --git a/src/Plugins/Tia/CiDefaultBranch.php b/src/Plugins/Tia/CiBranch.php similarity index 58% rename from src/Plugins/Tia/CiDefaultBranch.php rename to src/Plugins/Tia/CiBranch.php index 682a506e7..99a4dcb48 100644 --- a/src/Plugins/Tia/CiDefaultBranch.php +++ b/src/Plugins/Tia/CiBranch.php @@ -9,7 +9,7 @@ /** * @internal */ -final class CiDefaultBranch +final class CiBranch { /** * @var array> @@ -19,7 +19,20 @@ final class CiDefaultBranch Cis\GitHub::class, ]; - public static function detect(): ?string + public static function detectCurrent(): ?string + { + foreach (self::CIS as $class) { + $branch = (new $class)->currentBranch(); + + if ($branch !== null) { + return $branch; + } + } + + return null; + } + + public static function detectDefault(): ?string { foreach (self::CIS as $class) { $branch = (new $class)->defaultBranch(); diff --git a/src/Plugins/Tia/Cis/GitHub.php b/src/Plugins/Tia/Cis/GitHub.php index b0d5d7876..412759b7f 100644 --- a/src/Plugins/Tia/Cis/GitHub.php +++ b/src/Plugins/Tia/Cis/GitHub.php @@ -14,6 +14,11 @@ { use ReadsEnvironment; + public function currentBranch(): ?string + { + return $this->environment('GITHUB_REF_NAME'); + } + public function defaultBranch(): ?string { $path = $this->environment('GITHUB_EVENT_PATH'); diff --git a/src/Plugins/Tia/Cis/GitLab.php b/src/Plugins/Tia/Cis/GitLab.php index 06db0f1bd..4850738e3 100644 --- a/src/Plugins/Tia/Cis/GitLab.php +++ b/src/Plugins/Tia/Cis/GitLab.php @@ -14,6 +14,11 @@ { use ReadsEnvironment; + public function currentBranch(): ?string + { + return $this->environment('CI_COMMIT_BRANCH'); + } + public function defaultBranch(): ?string { return $this->environment('CI_DEFAULT_BRANCH'); diff --git a/src/Plugins/Tia/Contracts/Ci.php b/src/Plugins/Tia/Contracts/Ci.php index e5e720274..bbb6f82eb 100644 --- a/src/Plugins/Tia/Contracts/Ci.php +++ b/src/Plugins/Tia/Contracts/Ci.php @@ -9,5 +9,7 @@ */ interface Ci { + public function currentBranch(): ?string; + public function defaultBranch(): ?string; } diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 4f0d77f05..886799341 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -18,6 +18,8 @@ */ final class Graph { + public const string WORKSPACE_BRANCH_PREFIX = '@workspace:'; + /** * @var array */ @@ -1632,6 +1634,10 @@ public function pruneMissingBranches(array $keep): void $survivors = array_fill_keys($keep, true); foreach (array_keys($this->baselines) as $branch) { + if (str_starts_with($branch, self::WORKSPACE_BRANCH_PREFIX)) { + continue; + } + if (! isset($survivors[$branch])) { unset($this->baselines[$branch]); } diff --git a/tests/Features/Tia/BranchShapes.php b/tests/Features/Tia/BranchShapes.php index d0e5a095e..52c5aeb35 100644 --- a/tests/Features/Tia/BranchShapes.php +++ b/tests/Features/Tia/BranchShapes.php @@ -115,7 +115,7 @@ ->and($delta->isResultsOnly())->toBeTrue($delta->summary()); })->skipOnWindows(); -test('a detached HEAD does not reclaim anything either', function (): void { +test('a detached HEAD reclaims missing branches without changing the default baseline', function (): void { $project = Project::make('master'); $project->seed('master'); @@ -130,8 +130,11 @@ $project->pest('--tia'); $delta = $project->delta(); - expect($project->branchKeys())->toBe(['master', 'feature-x']) - ->and($delta->isHardSuppressed())->toBeTrue($delta->summary()); + expect($project->branchKeys())->toHaveCount(2) + ->and($project->branchKeys())->toContain('master') + ->and($project->branchKeys())->not->toContain('feature-x') + ->and($delta->baselineUntouched('master'))->toBeTrue($delta->summary()) + ->and($delta->structureMoved())->toBeTrue($delta->summary()); })->skipOnWindows(); test('the default branch baseline survives every branch that comes and goes', function (): void { diff --git a/tests/Features/Tia/DefaultBranchWriteTier.php b/tests/Features/Tia/DefaultBranchWriteTier.php index 6bc6a2cc4..1ef72613d 100644 --- a/tests/Features/Tia/DefaultBranchWriteTier.php +++ b/tests/Features/Tia/DefaultBranchWriteTier.php @@ -104,17 +104,20 @@ ->and($delta->isHardSuppressed())->toBeTrue($delta->summary()); })->skipOnWindows(); -test('a detached HEAD replays without minting a branch key', function (): void { +test('a detached HEAD replays through a workspace baseline', function (): void { $project = Project::make('master'); $project->seed('master'); $project->git()->detach(); $result = $project->pest('--tia'); + $hasWorkspaceBranch = array_any($project->branchKeys(), fn (string $branch): bool => str_starts_with($branch, '@workspace:')); expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe()) ->and($result->uncached())->toBe(0, $result->describe()) - ->and($project->branchKeys())->toBe(['master']); + ->and($project->branchKeys())->toHaveCount(2) + ->and($project->branchKeys())->toContain('master') + ->and($hasWorkspaceBranch)->toBeTrue(); })->skipOnWindows(); test('a detached HEAD does not write into the default branch baseline', function (array $arguments): void { @@ -125,9 +128,13 @@ $project->pest(...$arguments); $delta = $project->delta(); + $hasWorkspaceBranch = array_any($project->branchKeys(), fn (string $branch): bool => str_starts_with($branch, '@workspace:')); expect($delta->baselineUntouched('master'))->toBeTrue($delta->summary()) - ->and($project->branchKeys())->toBe(['master']); + ->and($project->branchKeys())->toHaveCount(2) + ->and($project->branchKeys())->toContain('master') + ->and($hasWorkspaceBranch)->toBeTrue() + ->and($delta->writtenCount())->toBe(0, $delta->summary()); })->with([ 'sequential' => [['--filter=adds two numbers']], 'parallel' => [['--parallel', '--processes=2', '--filter=adds two numbers']], @@ -147,6 +154,26 @@ ->and($delta->writtenCount())->toBe(0, $delta->summary()); })->skipOnWindows(); +test('a detached CI branch writes into its branch baseline', function (array $environment): void { + $project = Project::make('master'); + $project->seed('master'); + + $project->git()->detach(); + $project->pestWithEnvironment($project->path(), $environment, '--filter=adds two numbers'); + + expect($project->branchKeys())->toBe(['master', 'feature-x']) + ->and(array_any($project->branchKeys(), fn (string $branch): bool => str_starts_with($branch, '@workspace:')))->toBeFalse(); +})->with([ + 'GitLab' => [[ + 'GITLAB_CI' => 'true', + 'CI_COMMIT_BRANCH' => 'feature-x', + ]], + 'GitHub' => [[ + 'GITHUB_ACTIONS' => 'true', + 'GITHUB_REF_NAME' => 'feature-x', + ]], +])->skipOnWindows(); + test('the fallback reaches parallel workers', function (): void { $project = Project::make('master'); $project->seed('master'); diff --git a/tests/Features/Tia/StateReclamation.php b/tests/Features/Tia/StateReclamation.php index e43b82173..8687438ea 100644 --- a/tests/Features/Tia/StateReclamation.php +++ b/tests/Features/Tia/StateReclamation.php @@ -8,7 +8,7 @@ Project::destroyAll(); }); -test('a detached HEAD does not purge the graph on structural drift', function (array $arguments): void { +test('a detached HEAD can rebuild the graph on structural drift', function (array $arguments): void { $project = Project::make('master'); $project->seed('master'); @@ -25,10 +25,10 @@ expect($result->exitCode)->toBe(0, $result->describe()) ->and($project->graphExists())->toBeTrue('the detached run deleted graph.json') - ->and($delta->isHardSuppressed())->toBeTrue($delta->summary()); + ->and($delta->structureMoved())->toBeTrue($delta->summary()); })->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows(); -test('a detached HEAD does not purge the graph with --fresh either', function (array $arguments): void { +test('a detached HEAD can rebuild the graph with --fresh', function (array $arguments): void { $project = Project::make('master'); $project->seed('master'); @@ -39,10 +39,10 @@ expect($result->exitCode)->toBe(0, $result->describe()) ->and($project->graphExists())->toBeTrue('the detached --fresh run deleted graph.json') - ->and($delta->isHardSuppressed())->toBeTrue($delta->summary()); + ->and($delta->structureMoved())->toBeTrue($delta->summary()); })->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows(); -test('a detached HEAD leaves an unreadable graph for a checkout that can rebuild it', function (): void { +test('a detached HEAD can replace an unreadable graph for a checkout that can rebuild it', function (): void { $project = Project::make('master'); $project->seed('master'); @@ -54,7 +54,7 @@ expect($result->exitCode)->toBe(0, $result->describe()) ->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed') - ->and(file_get_contents($project->graphDir().'/graph.json'))->toBe('{not json'); + ->and(file_get_contents($project->graphDir().'/graph.json'))->not->toBe('{not json'); })->skipOnWindows(); test('a cached failure whose test file was deleted stops widening later runs', function (array $arguments): void { diff --git a/tests/Fixtures/Tia/Project.php b/tests/Fixtures/Tia/Project.php index bead90b35..a72d525e8 100644 --- a/tests/Fixtures/Tia/Project.php +++ b/tests/Fixtures/Tia/Project.php @@ -157,7 +157,7 @@ public function pestIn(string $directory, string ...$arguments): PestResult } /** - * @param array $environment + * @param array $environment */ public function pestWithEnvironment(string $directory, array $environment, string ...$arguments): PestResult { @@ -172,11 +172,14 @@ public function pestWithEnvironment(string $directory, array $environment, strin 'PAO_DISABLE' => '1', 'XDEBUG_MODE' => 'coverage', 'HOME' => $this->home(), - 'GITHUB_EVENT_PATH' => '', - 'CI_DEFAULT_BRANCH' => '', - 'GITHUB_ACTIONS' => '', - 'GITLAB_CI' => '', - 'CIRCLECI' => '', + 'CI' => false, + 'GITHUB_EVENT_PATH' => false, + 'CI_DEFAULT_BRANCH' => false, + 'CI_COMMIT_BRANCH' => false, + 'GITHUB_ACTIONS' => false, + 'GITHUB_REF_NAME' => false, + 'GITLAB_CI' => false, + 'CIRCLECI' => false, ...$environment, ], );