diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 73b6ad66..d59213ff 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -33,13 +33,13 @@ use Pest\Plugins\Tia\Storage; use Pest\Plugins\Tia\TableExtractor; use Pest\Plugins\Tia\WatchPatterns; use Pest\Support\Container; +use Pest\Support\Git; use Pest\Support\View; use Pest\TestCaseFilters\TiaTestCaseFilter; use Pest\TestSuite; use PHPUnit\Framework\TestStatus\TestStatus; use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Process\Process; /** * @internal @@ -2136,16 +2136,7 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument */ private function gitSubdirectoryPrefix(string $projectRoot): ?string { - $process = new Process(['git', 'rev-parse', '--show-prefix'], $projectRoot); - $process->run(); - - if (! $process->isSuccessful()) { - return null; - } - - $prefix = trim($process->getOutput()); - - return $prefix === '' ? null : rtrim(str_replace(DIRECTORY_SEPARATOR, '/', $prefix), '/'); + return new Git($projectRoot)->subdirectoryPrefix(); } private function composerLockDelta(string $projectRoot, string $sha): string @@ -2155,15 +2146,13 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument return ''; } - $process = new Process(['git', 'show', $sha.':composer.lock'], $projectRoot); - $process->setTimeout(5.0); - $process->run(); + $baseline = new Git($projectRoot)->show($sha, 'composer.lock'); - if (! $process->isSuccessful()) { + if ($baseline === null) { return ''; } - $oldVersions = $this->lockVersions($process->getOutput()); + $oldVersions = $this->lockVersions($baseline); $newVersions = $this->lockVersions($current); if ($oldVersions === [] && $newVersions === []) { diff --git a/src/Plugins/Tia/ChangedFiles.php b/src/Plugins/Tia/ChangedFiles.php index 805400f4..02e101fa 100644 --- a/src/Plugins/Tia/ChangedFiles.php +++ b/src/Plugins/Tia/ChangedFiles.php @@ -5,14 +5,19 @@ declare(strict_types=1); namespace Pest\Plugins\Tia; use Pest\Exceptions\MissingDependency; -use Symfony\Component\Process\Process; +use Pest\Support\Git; /** * @internal */ final readonly class ChangedFiles { - public function __construct(private string $projectRoot) {} + private Git $git; + + public function __construct(private string $projectRoot) + { + $this->git = new Git($projectRoot); + } /** * @param array $files project-relative paths. @@ -155,15 +160,7 @@ final readonly class ChangedFiles private function contentAtSha(string $sha, string $path): ?string { - $process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot); - $process->setTimeout(5.0); - $process->run(); - - if (! $process->isSuccessful()) { - return null; - } - - return $process->getOutput(); + return $this->git->show($sha, $path); } /** @@ -176,21 +173,17 @@ final readonly class ChangedFiles return $candidates; } - $process = new Process( - ['git', 'check-ignore', '--no-index', '-z', '--stdin'], - $this->projectRoot, + $result = $this->git->result( + ['check-ignore', '--no-index', '-z', '--stdin'], + implode("\x00", array_keys($candidates)), ); - $process->setTimeout(5.0); - $process->setInput(implode("\x00", array_keys($candidates))); - $process->run(); - $exitCode = $process->getExitCode(); - - if ($exitCode !== 0 && $exitCode !== 1) { + // `check-ignore` exits 1 when nothing matched — that is not a failure. + if ($result['exitCode'] !== 0 && $result['exitCode'] !== 1) { throw new MissingDependency('Tia mode', 'git'); } - $output = $process->getOutput(); + $output = $result['output']; if ($output === '') { return $candidates; @@ -207,21 +200,20 @@ final readonly class ChangedFiles public function currentBranch(): ?string { - $process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot); - $process->run(); + $output = $this->git->raw(['rev-parse', '--abbrev-ref', 'HEAD']); - if (! $process->isSuccessful()) { + if ($output === null) { throw new MissingDependency('Tia mode', 'git'); } - $branch = trim($process->getOutput()); + $branch = trim($output); return $branch === '' || $branch === 'HEAD' ? null : $branch; } public function defaultBranch(): ?string { - $head = $this->gitOutput(['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD']); + $head = $this->git->output(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']); if ($head !== null) { $branch = preg_replace('#^origin/#', '', $head); @@ -231,14 +223,14 @@ final readonly class ChangedFiles } } - $configured = $this->gitOutput(['git', 'config', '--get', 'init.defaultBranch']); + $configured = $this->git->output(['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; + $exists = $this->git->hasRef('refs/heads/'.$configured) + || $this->git->hasRef('refs/remotes/origin/'.$configured); return $exists ? $configured : null; } @@ -248,20 +240,15 @@ final readonly class ChangedFiles */ public function branchNames(): ?array { - $process = new Process( - ['git', 'for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes'], - $this->projectRoot, - ); - $process->setTimeout(5.0); - $process->run(); + $output = $this->git->raw(['for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes']); - if (! $process->isSuccessful()) { + if ($output === null) { return null; } $names = []; - foreach ($this->splitLines($process->getOutput()) as $ref) { + foreach ($this->splitLines($output) as $ref) { if (str_starts_with($ref, 'refs/heads/')) { $names[substr($ref, strlen('refs/heads/'))] = true; @@ -291,54 +278,31 @@ final readonly class ChangedFiles public function hasRemote(): bool { - return $this->gitOutput(['git', 'remote']) !== null; + return $this->git->hasRemote(); } public function isRepository(): bool { - $process = new Process(['git', 'rev-parse', '--git-dir'], $this->projectRoot); - $process->setTimeout(5.0); - $process->run(); - - return $process->getExitCode() === 0; + return $this->git->isRepository(); } public function hasCommits(): bool { - $process = new Process(['git', 'rev-parse', '--verify', '--quiet', 'HEAD'], $this->projectRoot); - $process->setTimeout(5.0); - $process->run(); - - return $process->getExitCode() === 0; + return $this->git->hasCommits(); } /** - * @param array $command + * Working-tree scans get a longer leash than metadata queries — on a large + * repository with a cold cache, `status` and `diff` are not instant. */ - private function gitOutput(array $command): ?string + private function scan(): Git { - $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; + return $this->git->withTimeout(60.0); } private function shaIsReachable(string $sha): bool { - $process = new Process( - ['git', 'merge-base', '--is-ancestor', $sha, 'HEAD'], - $this->projectRoot, - ); - $process->run(); - - return $process->getExitCode() === 0; + return $this->git->succeeds(['merge-base', '--is-ancestor', $sha, 'HEAD']); } /** @@ -346,17 +310,13 @@ final readonly class ChangedFiles */ private function diffSinceSha(string $sha): array { - $process = new Process( - ['git', 'diff', '--name-only', '--no-renames', $sha.'..HEAD'], - $this->projectRoot, - ); - $process->run(); + $output = $this->scan()->raw(['diff', '--name-only', '--no-renames', $sha.'..HEAD']); - if (! $process->isSuccessful()) { + if ($output === null) { throw new MissingDependency('Tia mode', 'git'); } - return $this->splitLines($process->getOutput()); + return $this->splitLines($output); } /** @@ -364,18 +324,12 @@ final readonly class ChangedFiles */ private function workingTreeChanges(): array { - $process = new Process( - ['git', 'status', '--porcelain', '-z', '--untracked-files=all'], - $this->projectRoot, - ); - $process->run(); + $output = $this->scan()->raw(['status', '--porcelain', '-z', '--untracked-files=all']); - if (! $process->isSuccessful()) { + if ($output === null) { throw new MissingDependency('Tia mode', 'git'); } - $output = $process->getOutput(); - if ($output === '') { return []; } @@ -413,14 +367,13 @@ final readonly class ChangedFiles public function currentSha(): ?string { - $process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot); - $process->run(); + $output = $this->git->raw(['rev-parse', 'HEAD']); - if (! $process->isSuccessful()) { + if ($output === null) { throw new MissingDependency('Tia mode', 'git'); } - $sha = trim($process->getOutput()); + $sha = trim($output); return $sha === '' ? null : $sha; } diff --git a/src/Support/Git.php b/src/Support/Git.php new file mode 100644 index 00000000..03f52a00 --- /dev/null +++ b/src/Support/Git.php @@ -0,0 +1,116 @@ +directory, $timeout); + } + + /** + * @param array $arguments + */ + public function raw(array $arguments): ?string + { + $result = $this->result($arguments); + + return $result['exitCode'] === 0 ? $result['output'] : null; + } + + /** + * @param array $arguments + */ + public function output(array $arguments): ?string + { + $output = $this->raw($arguments); + + if ($output === null) { + return null; + } + + $output = trim($output); + + return $output === '' ? null : $output; + } + + /** + * @param array $arguments + */ + public function succeeds(array $arguments): bool + { + return $this->result($arguments)['exitCode'] === 0; + } + + /** + * @param array $arguments + * @return array{exitCode: int, output: string} + */ + public function result(array $arguments, ?string $input = null): array + { + $process = new Process(['git', ...$arguments], $this->directory); + $process->setTimeout($this->timeout); + + if ($input !== null) { + $process->setInput($input); + } + + $process->run(); + + return [ + 'exitCode' => $process->getExitCode() ?? 1, + 'output' => $process->getOutput(), + ]; + } + + public function isRepository(): bool + { + return $this->succeeds(['rev-parse', '--git-dir']); + } + + public function hasCommits(): bool + { + return $this->succeeds(['rev-parse', '--verify', '--quiet', 'HEAD']); + } + + public function hasRemote(): bool + { + return $this->output(['remote']) !== null; + } + + public function hasRef(string $ref): bool + { + return $this->output(['rev-parse', '--verify', '--quiet', $ref]) !== null; + } + + public function show(string $sha, string $path): ?string + { + return $this->raw(['show', $sha.':'.$path]); + } + + public function subdirectoryPrefix(): ?string + { + $prefix = $this->output(['rev-parse', '--show-prefix']); + + if ($prefix === null) { + return null; + } + + return rtrim(str_replace(DIRECTORY_SEPARATOR, '/', $prefix), '/'); + } +} diff --git a/src/TestCaseFilters/GitDirtyTestCaseFilter.php b/src/TestCaseFilters/GitDirtyTestCaseFilter.php index 98d06973..70447b50 100644 --- a/src/TestCaseFilters/GitDirtyTestCaseFilter.php +++ b/src/TestCaseFilters/GitDirtyTestCaseFilter.php @@ -8,8 +8,8 @@ use Pest\Contracts\TestCaseFilter; use Pest\Exceptions\MissingDependency; use Pest\Exceptions\NoDirtyTestsFound; use Pest\Panic; +use Pest\Support\Git; use Pest\TestSuite; -use Symfony\Component\Process\Process; final class GitDirtyTestCaseFilter implements TestCaseFilter { @@ -52,14 +52,13 @@ final class GitDirtyTestCaseFilter implements TestCaseFilter */ private function loadChangedFiles(): void { - $process = new Process(['git', 'status', '--short', '--', '*.php']); - $process->run(); + $status = new Git(timeout: 60.0)->raw(['status', '--short', '--', '*.php']); - if (! $process->isSuccessful()) { + if ($status === null) { throw new MissingDependency('Filter by dirty files', 'git'); } - $output = preg_split('/\R+/', $process->getOutput(), flags: PREG_SPLIT_NO_EMPTY); + $output = preg_split('/\R+/', $status, flags: PREG_SPLIT_NO_EMPTY); assert(is_array($output)); $dirtyFiles = []; diff --git a/tests/Features/Tia/BranchShapes.php b/tests/Features/Tia/BranchShapes.php index 47471c8a..388a0052 100644 --- a/tests/Features/Tia/BranchShapes.php +++ b/tests/Features/Tia/BranchShapes.php @@ -177,7 +177,7 @@ test('a repository with no commits says so, and leaves plain runs alone', functi expect($tia->exitCode)->toBe(1, $tia->describe()) ->and($tia->output)->toContain('Tia mode requires at least one commit') - ->and($tia->output)->not->toContain('requires "git"') + ->and($tia->output)->not->toContain('requires [git]') ->and($project->graphExists())->toBeFalse(); $plain = $project->pest(); diff --git a/tests/Features/Tia/DefaultBranchResolution.php b/tests/Features/Tia/DefaultBranchResolution.php index 640dd8fd..424f708b 100644 --- a/tests/Features/Tia/DefaultBranchResolution.php +++ b/tests/Features/Tia/DefaultBranchResolution.php @@ -175,7 +175,7 @@ test('tia still requires git', function (): void { $result = $project->pest('--tia'); - expect($result->output)->toContain('The feature "Tia mode" requires "git".') + expect($result->output)->toContain('The feature [Tia mode[ requires [git].') ->and($result->exitCode)->not->toBe(0); })->skipOnWindows();