This commit is contained in:
nuno maduro
2026-08-07 02:26:45 +01:00
parent b0d1fc589d
commit d0572ce138
6 changed files with 167 additions and 110 deletions
+5 -16
View File
@@ -33,13 +33,13 @@ use Pest\Plugins\Tia\Storage;
use Pest\Plugins\Tia\TableExtractor; use Pest\Plugins\Tia\TableExtractor;
use Pest\Plugins\Tia\WatchPatterns; use Pest\Plugins\Tia\WatchPatterns;
use Pest\Support\Container; use Pest\Support\Container;
use Pest\Support\Git;
use Pest\Support\View; use Pest\Support\View;
use Pest\TestCaseFilters\TiaTestCaseFilter; use Pest\TestCaseFilters\TiaTestCaseFilter;
use Pest\TestSuite; use Pest\TestSuite;
use PHPUnit\Framework\TestStatus\TestStatus; use PHPUnit\Framework\TestStatus\TestStatus;
use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade; use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
/** /**
* @internal * @internal
@@ -2136,16 +2136,7 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
*/ */
private function gitSubdirectoryPrefix(string $projectRoot): ?string private function gitSubdirectoryPrefix(string $projectRoot): ?string
{ {
$process = new Process(['git', 'rev-parse', '--show-prefix'], $projectRoot); return new Git($projectRoot)->subdirectoryPrefix();
$process->run();
if (! $process->isSuccessful()) {
return null;
}
$prefix = trim($process->getOutput());
return $prefix === '' ? null : rtrim(str_replace(DIRECTORY_SEPARATOR, '/', $prefix), '/');
} }
private function composerLockDelta(string $projectRoot, string $sha): string private function composerLockDelta(string $projectRoot, string $sha): string
@@ -2155,15 +2146,13 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
return ''; return '';
} }
$process = new Process(['git', 'show', $sha.':composer.lock'], $projectRoot); $baseline = new Git($projectRoot)->show($sha, 'composer.lock');
$process->setTimeout(5.0);
$process->run();
if (! $process->isSuccessful()) { if ($baseline === null) {
return ''; return '';
} }
$oldVersions = $this->lockVersions($process->getOutput()); $oldVersions = $this->lockVersions($baseline);
$newVersions = $this->lockVersions($current); $newVersions = $this->lockVersions($current);
if ($oldVersions === [] && $newVersions === []) { if ($oldVersions === [] && $newVersions === []) {
+40 -87
View File
@@ -5,14 +5,19 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
use Pest\Exceptions\MissingDependency; use Pest\Exceptions\MissingDependency;
use Symfony\Component\Process\Process; use Pest\Support\Git;
/** /**
* @internal * @internal
*/ */
final readonly class ChangedFiles 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<int, string> $files project-relative paths. * @param array<int, string> $files project-relative paths.
@@ -155,15 +160,7 @@ final readonly class ChangedFiles
private function contentAtSha(string $sha, string $path): ?string private function contentAtSha(string $sha, string $path): ?string
{ {
$process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot); return $this->git->show($sha, $path);
$process->setTimeout(5.0);
$process->run();
if (! $process->isSuccessful()) {
return null;
}
return $process->getOutput();
} }
/** /**
@@ -176,21 +173,17 @@ final readonly class ChangedFiles
return $candidates; return $candidates;
} }
$process = new Process( $result = $this->git->result(
['git', 'check-ignore', '--no-index', '-z', '--stdin'], ['check-ignore', '--no-index', '-z', '--stdin'],
$this->projectRoot, implode("\x00", array_keys($candidates)),
); );
$process->setTimeout(5.0);
$process->setInput(implode("\x00", array_keys($candidates)));
$process->run();
$exitCode = $process->getExitCode(); // `check-ignore` exits 1 when nothing matched — that is not a failure.
if ($result['exitCode'] !== 0 && $result['exitCode'] !== 1) {
if ($exitCode !== 0 && $exitCode !== 1) {
throw new MissingDependency('Tia mode', 'git'); throw new MissingDependency('Tia mode', 'git');
} }
$output = $process->getOutput(); $output = $result['output'];
if ($output === '') { if ($output === '') {
return $candidates; return $candidates;
@@ -207,21 +200,20 @@ final readonly class ChangedFiles
public function currentBranch(): ?string public function currentBranch(): ?string
{ {
$process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot); $output = $this->git->raw(['rev-parse', '--abbrev-ref', 'HEAD']);
$process->run();
if (! $process->isSuccessful()) { if ($output === null) {
throw new MissingDependency('Tia mode', 'git'); throw new MissingDependency('Tia mode', 'git');
} }
$branch = trim($process->getOutput()); $branch = trim($output);
return $branch === '' || $branch === 'HEAD' ? null : $branch; return $branch === '' || $branch === 'HEAD' ? null : $branch;
} }
public function defaultBranch(): ?string 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) { if ($head !== null) {
$branch = preg_replace('#^origin/#', '', $head); $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) { if ($configured === null) {
return null; return null;
} }
$exists = $this->gitOutput(['git', 'rev-parse', '--verify', '--quiet', 'refs/heads/'.$configured]) !== null $exists = $this->git->hasRef('refs/heads/'.$configured)
|| $this->gitOutput(['git', 'rev-parse', '--verify', '--quiet', 'refs/remotes/origin/'.$configured]) !== null; || $this->git->hasRef('refs/remotes/origin/'.$configured);
return $exists ? $configured : null; return $exists ? $configured : null;
} }
@@ -248,20 +240,15 @@ final readonly class ChangedFiles
*/ */
public function branchNames(): ?array public function branchNames(): ?array
{ {
$process = new Process( $output = $this->git->raw(['for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes']);
['git', 'for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes'],
$this->projectRoot,
);
$process->setTimeout(5.0);
$process->run();
if (! $process->isSuccessful()) { if ($output === null) {
return null; return null;
} }
$names = []; $names = [];
foreach ($this->splitLines($process->getOutput()) as $ref) { foreach ($this->splitLines($output) as $ref) {
if (str_starts_with($ref, 'refs/heads/')) { if (str_starts_with($ref, 'refs/heads/')) {
$names[substr($ref, strlen('refs/heads/'))] = true; $names[substr($ref, strlen('refs/heads/'))] = true;
@@ -291,54 +278,31 @@ final readonly class ChangedFiles
public function hasRemote(): bool public function hasRemote(): bool
{ {
return $this->gitOutput(['git', 'remote']) !== null; return $this->git->hasRemote();
} }
public function isRepository(): bool public function isRepository(): bool
{ {
$process = new Process(['git', 'rev-parse', '--git-dir'], $this->projectRoot); return $this->git->isRepository();
$process->setTimeout(5.0);
$process->run();
return $process->getExitCode() === 0;
} }
public function hasCommits(): bool public function hasCommits(): bool
{ {
$process = new Process(['git', 'rev-parse', '--verify', '--quiet', 'HEAD'], $this->projectRoot); return $this->git->hasCommits();
$process->setTimeout(5.0);
$process->run();
return $process->getExitCode() === 0;
} }
/** /**
* @param array<int, string> $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); return $this->git->withTimeout(60.0);
$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 private function shaIsReachable(string $sha): bool
{ {
$process = new Process( return $this->git->succeeds(['merge-base', '--is-ancestor', $sha, 'HEAD']);
['git', 'merge-base', '--is-ancestor', $sha, 'HEAD'],
$this->projectRoot,
);
$process->run();
return $process->getExitCode() === 0;
} }
/** /**
@@ -346,17 +310,13 @@ final readonly class ChangedFiles
*/ */
private function diffSinceSha(string $sha): array private function diffSinceSha(string $sha): array
{ {
$process = new Process( $output = $this->scan()->raw(['diff', '--name-only', '--no-renames', $sha.'..HEAD']);
['git', 'diff', '--name-only', '--no-renames', $sha.'..HEAD'],
$this->projectRoot,
);
$process->run();
if (! $process->isSuccessful()) { if ($output === null) {
throw new MissingDependency('Tia mode', 'git'); 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 private function workingTreeChanges(): array
{ {
$process = new Process( $output = $this->scan()->raw(['status', '--porcelain', '-z', '--untracked-files=all']);
['git', 'status', '--porcelain', '-z', '--untracked-files=all'],
$this->projectRoot,
);
$process->run();
if (! $process->isSuccessful()) { if ($output === null) {
throw new MissingDependency('Tia mode', 'git'); throw new MissingDependency('Tia mode', 'git');
} }
$output = $process->getOutput();
if ($output === '') { if ($output === '') {
return []; return [];
} }
@@ -413,14 +367,13 @@ final readonly class ChangedFiles
public function currentSha(): ?string public function currentSha(): ?string
{ {
$process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot); $output = $this->git->raw(['rev-parse', 'HEAD']);
$process->run();
if (! $process->isSuccessful()) { if ($output === null) {
throw new MissingDependency('Tia mode', 'git'); throw new MissingDependency('Tia mode', 'git');
} }
$sha = trim($process->getOutput()); $sha = trim($output);
return $sha === '' ? null : $sha; return $sha === '' ? null : $sha;
} }
+116
View File
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use Symfony\Component\Process\Process;
/**
* @internal
*/
final readonly class Git
{
private const float TIMEOUT = 5.0;
public function __construct(
private ?string $directory = null,
private float $timeout = self::TIMEOUT,
) {}
public function withTimeout(float $timeout): self
{
return new self($this->directory, $timeout);
}
/**
* @param array<int, string> $arguments
*/
public function raw(array $arguments): ?string
{
$result = $this->result($arguments);
return $result['exitCode'] === 0 ? $result['output'] : null;
}
/**
* @param array<int, string> $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<int, string> $arguments
*/
public function succeeds(array $arguments): bool
{
return $this->result($arguments)['exitCode'] === 0;
}
/**
* @param array<int, string> $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), '/');
}
}
@@ -8,8 +8,8 @@ use Pest\Contracts\TestCaseFilter;
use Pest\Exceptions\MissingDependency; use Pest\Exceptions\MissingDependency;
use Pest\Exceptions\NoDirtyTestsFound; use Pest\Exceptions\NoDirtyTestsFound;
use Pest\Panic; use Pest\Panic;
use Pest\Support\Git;
use Pest\TestSuite; use Pest\TestSuite;
use Symfony\Component\Process\Process;
final class GitDirtyTestCaseFilter implements TestCaseFilter final class GitDirtyTestCaseFilter implements TestCaseFilter
{ {
@@ -52,14 +52,13 @@ final class GitDirtyTestCaseFilter implements TestCaseFilter
*/ */
private function loadChangedFiles(): void private function loadChangedFiles(): void
{ {
$process = new Process(['git', 'status', '--short', '--', '*.php']); $status = new Git(timeout: 60.0)->raw(['status', '--short', '--', '*.php']);
$process->run();
if (! $process->isSuccessful()) { if ($status === null) {
throw new MissingDependency('Filter by dirty files', 'git'); 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)); assert(is_array($output));
$dirtyFiles = []; $dirtyFiles = [];
+1 -1
View File
@@ -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()) expect($tia->exitCode)->toBe(1, $tia->describe())
->and($tia->output)->toContain('Tia mode requires at least one commit') ->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(); ->and($project->graphExists())->toBeFalse();
$plain = $project->pest(); $plain = $project->pest();
@@ -175,7 +175,7 @@ test('tia still requires git', function (): void {
$result = $project->pest('--tia'); $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); ->and($result->exitCode)->not->toBe(0);
})->skipOnWindows(); })->skipOnWindows();