From e2347e0d4181a20464f939480925e210998b4c6b Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Sun, 18 Oct 2026 12:40:43 +0200 Subject: [PATCH] [perf] Cache realpath() of skip patterns in FileInfoMatcher PHP does not cache a failed realpath() lookup. A wildcard skip pattern like "**/vendor/*" fails that lookup once for every file. The matcher now resolves each pattern once per run, and keeps a false result too. --- src/Skipper/Matcher/FileInfoMatcher.php | 9 ++++++++- tests/Skipper/Matcher/FileInfoMatcherTest.php | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Skipper/Matcher/FileInfoMatcher.php b/src/Skipper/Matcher/FileInfoMatcher.php index 19eef3ee00d..5f384eb57a1 100644 --- a/src/Skipper/Matcher/FileInfoMatcher.php +++ b/src/Skipper/Matcher/FileInfoMatcher.php @@ -11,6 +11,13 @@ */ final class FileInfoMatcher { + /** + * PHP does not cache a failed realpath() lookup, and a wildcard pattern fails it for every file + * + * @var array + */ + private array $realpathByPattern = []; + /** * Returns the original (un-normalized) pattern that matched, so callers can report the exact * configured path. Returns null when no pattern matches. @@ -90,7 +97,7 @@ private function matchFnmatch(string $matchingPath, string $filePath): bool private function matchRealpath(string $matchingPath, string $filePath): bool { - $realPathMatchingPath = realpath($matchingPath); + $realPathMatchingPath = $this->realpathByPattern[$matchingPath] ??= realpath($matchingPath); if ($realPathMatchingPath === false) { return false; } diff --git a/tests/Skipper/Matcher/FileInfoMatcherTest.php b/tests/Skipper/Matcher/FileInfoMatcherTest.php index 72f92ee4dc7..97c3dd76d48 100644 --- a/tests/Skipper/Matcher/FileInfoMatcherTest.php +++ b/tests/Skipper/Matcher/FileInfoMatcherTest.php @@ -35,6 +35,22 @@ public function testMatchPatternReturnsNullWhenNoPatternMatches(): void $this->assertNull($matchedPattern); } + public function testRealPathOfEachPatternIsResolvedSeparatelyOnRepeatedCalls(): void + { + // the file paths are not real paths, so only the realpath() comparison can match them + $pathInPattern = __DIR__ . '/Fixture/path/in'; + $inPattern = __DIR__ . '/Fixture/in'; + $patterns = [$pathInPattern, $inPattern]; + + $pathInFile = __DIR__ . '/Fixture/path/with/../in/it/KeepThisFile.txt'; + $inFile = __DIR__ . '/Fixture/path/../in/it/KeepThisFile.txt'; + + for ($i = 0; $i < 2; ++$i) { + $this->assertSame($pathInPattern, $this->fileInfoMatcher->matchPattern($pathInFile, $patterns)); + $this->assertSame($inPattern, $this->fileInfoMatcher->matchPattern($inFile, $patterns)); + } + } + #[DataProvider('providePatterns')] public function testPatternNormalization(string $filePath, string $filePattern, bool $shouldMatch): void {