From 1f1451e9b6cb5eaddf30cc1784b8d44dbd957621 Mon Sep 17 00:00:00 2001 From: Ademola Emmanuel <19527203+ademola-emmanuel@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:40:07 +0100 Subject: [PATCH] fix(tia): fingerprint structural files inside linked git worktrees --- src/Plugins/Tia/Fingerprint.php | 18 ++-- tests/Unit/Plugins/Tia/Fingerprint.php | 109 +++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 tests/Unit/Plugins/Tia/Fingerprint.php diff --git a/src/Plugins/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php index 39e562399..062e852ea 100644 --- a/src/Plugins/Tia/Fingerprint.php +++ b/src/Plugins/Tia/Fingerprint.php @@ -5,7 +5,8 @@ namespace Pest\Plugins\Tia; use Pest\Plugins\Tia\Contracts\Lockfile; -use Symfony\Component\Finder\Finder; +use Symfony\Component\Process\Exception\ExceptionInterface as ProcessException; +use Symfony\Component\Process\Process; /** * @internal @@ -279,13 +280,16 @@ private static function isTrackedByGit(string $projectRoot, string $relativePath return $cache[$key] = true; } - $finder = (new Finder) - ->in($projectRoot) - ->depth('== 0') - ->name($relativePath) - ->ignoreVCSIgnored(true); + $process = new Process(['git', 'check-ignore', '-q', '--', $relativePath], $projectRoot); + $process->setTimeout(5.0); - return $cache[$key] = $finder->hasResults(); + try { + $process->run(); + } catch (ProcessException) { + return $cache[$key] = true; + } + + return $cache[$key] = $process->getExitCode() !== 0; } private static function contentHashOrNull(string $path): ?string diff --git a/tests/Unit/Plugins/Tia/Fingerprint.php b/tests/Unit/Plugins/Tia/Fingerprint.php new file mode 100644 index 000000000..734d7c44b --- /dev/null +++ b/tests/Unit/Plugins/Tia/Fingerprint.php @@ -0,0 +1,109 @@ +setTimeout(10.0); + $process->mustRun(); +} + +function fingerprintRepository(): string +{ + $root = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest_fingerprint_'.uniqid(); + mkdir($root, 0777, true); + + fingerprintGit($root, 'init', '-q'); + fingerprintGit($root, 'config', 'user.email', 'pest@example.com'); + fingerprintGit($root, 'config', 'user.name', 'Pest'); + + return $root; +} + +function fingerprintRemoveDirectory(string $path): void +{ + if (! is_dir($path)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $file) { + @chmod($file->getPathname(), 0777); + $file->isDir() ? @rmdir($file->getPathname()) : @unlink($file->getPathname()); + } + + @rmdir($path); +} + +describe('compute()', function (): void { + it('fingerprints tracked structural files in a regular clone', function (): void { + $clone = fingerprintRepository(); + + try { + file_put_contents($clone.'/composer.lock', '{"packages": []}'); + file_put_contents($clone.'/phpunit.xml', ''); + fingerprintGit($clone, 'add', '-A'); + fingerprintGit($clone, 'commit', '-q', '-m', 'init'); + + $fingerprint = Fingerprint::compute($clone); + + expect($fingerprint['structural']['composer_lock'])->not->toBeNull() + ->and($fingerprint['structural']['phpunit_xml'])->not->toBeNull(); + } finally { + fingerprintRemoveDirectory($clone); + } + }); + + it('fingerprints tracked structural files inside a linked git worktree', function (): void { + $clone = fingerprintRepository(); + + try { + file_put_contents($clone.'/composer.lock', '{"packages": []}'); + file_put_contents($clone.'/phpunit.xml', ''); + file_put_contents($clone.'/.gitignore', ".worktrees/\n"); + fingerprintGit($clone, 'add', '-A'); + fingerprintGit($clone, 'commit', '-q', '-m', 'init'); + fingerprintGit($clone, 'worktree', 'add', '-q', '.worktrees/feature'); + + $worktree = $clone.'/.worktrees/feature'; + + $cloneFingerprint = Fingerprint::compute($clone); + $worktreeFingerprint = Fingerprint::compute($worktree); + + expect($worktreeFingerprint['structural']['composer_lock'])->not->toBeNull() + ->and($worktreeFingerprint['structural']['phpunit_xml'])->not->toBeNull() + ->and($worktreeFingerprint['structural']['composer_lock'])->toBe($cloneFingerprint['structural']['composer_lock']) + ->and($worktreeFingerprint['structural']['phpunit_xml'])->toBe($cloneFingerprint['structural']['phpunit_xml']); + } finally { + fingerprintRemoveDirectory($clone); + } + }); + + it('excludes gitignored untracked files from the fingerprint', function (): void { + $clone = fingerprintRepository(); + + try { + file_put_contents($clone.'/composer.lock', '{"packages": []}'); + file_put_contents($clone.'/.gitignore', "phpunit.xml\n"); + fingerprintGit($clone, 'add', '-A'); + fingerprintGit($clone, 'commit', '-q', '-m', 'init'); + + file_put_contents($clone.'/phpunit.xml', ''); + + $fingerprint = Fingerprint::compute($clone); + + expect($fingerprint['structural']['phpunit_xml'])->toBeNull() + ->and($fingerprint['structural']['composer_lock'])->not->toBeNull(); + } finally { + fingerprintRemoveDirectory($clone); + } + }); +});