diff --git a/src/Plugins/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php index 0f4823a23..c3db69c19 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 @@ -273,6 +274,9 @@ private static function trackedHash(string $projectRoot, string $relativePath): * Gitignored lockfiles (e.g. `package-lock.json` excluded from the repo) * regenerate per-machine with OS-specific optional deps, which would * otherwise force a fingerprint mismatch on every fetched baseline. + * + * Git itself answers the ignore question so that linked worktrees, whose + * `.git` is a pointer file, resolve against the right repository. */ private static function isTrackedByGit(string $projectRoot, string $relativePath): bool { @@ -292,13 +296,18 @@ 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); + + try { + $process->run(); + } catch (ProcessException) { + return $cache[$key] = true; + } - return $cache[$key] = $finder->hasResults(); + // 0: ignored. 1: not ignored. Anything else: git unavailable or not + // a repository, in which case the file is kept like the no-git path. + 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); + } + }); +});