From df59099698f107ba0986184db630c33bd779bb92 Mon Sep 17 00:00:00 2001 From: webard Date: Sat, 12 Sep 2026 14:20:50 +0200 Subject: [PATCH 1/5] relative path cache --- src/Caching/Detector/ChangedFilesDetector.php | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/Caching/Detector/ChangedFilesDetector.php b/src/Caching/Detector/ChangedFilesDetector.php index 385dbffb98c..2698ad39411 100644 --- a/src/Caching/Detector/ChangedFilesDetector.php +++ b/src/Caching/Detector/ChangedFilesDetector.php @@ -8,6 +8,7 @@ use Rector\Caching\Config\FileHashComputer; use Rector\Caching\Enum\CacheKey; use Rector\Configuration\Parameter\SimpleParameterProvider; +use Rector\FileSystem\FilePathHelper; use Rector\Util\FileHasher; /** @@ -28,7 +29,8 @@ final class ChangedFilesDetector public function __construct( private readonly FileHashComputer $fileHashComputer, private readonly Cache $cache, - private readonly FileHasher $fileHasher + private readonly FileHasher $fileHasher, + private readonly FilePathHelper $filePathHelper ) { } @@ -70,7 +72,7 @@ public function hasFileChanged(string $filePath): bool // a scoped (--only) run reuses the full-run cache: a file left clean by all rules stays // clean under a single rule too, and the content is still compared below if ($cachedValue === null && $this->scopeSuffix !== '') { - $unscopedCacheKey = $this->fileHasher->hash($this->resolvePath($filePath)); + $unscopedCacheKey = $this->fileHasher->hash($this->cacheKeyPath($filePath)); $cachedValue = $this->cache->load($unscopedCacheKey, CacheKey::FILE_HASH_KEY); } @@ -117,7 +119,20 @@ private function resolvePath(string $filePath): string private function getFilePathCacheKey(string $filePath): string { - return $this->fileHasher->hash($this->resolvePath($filePath) . $this->scopeSuffix); + return $this->fileHasher->hash($this->cacheKeyPath($filePath) . $this->scopeSuffix); + } + + /** + * The path a cache key is built from: relative to the project, never absolute. + * + * An absolute path ties the whole cache to one location on disk, so the same project + * checked out twice - a git worktree, a CI checkout, a container mount - shares nothing. + * Relative keys let a cache travel with the project. Paths outside the project keep + * their `../` prefix and stay just as stable, because the anchor does not move either. + */ + private function cacheKeyPath(string $filePath): string + { + return $this->filePathHelper->relativePath($this->resolvePath($filePath)); } private function hashFile(string $filePath): string From f6eb136d0cdac736d22dfc8f937ae8af27868aa2 Mon Sep 17 00:00:00 2001 From: webard Date: Mon, 14 Sep 2026 13:09:47 +0200 Subject: [PATCH 2/5] changed files detector tests --- .../ChangedFilesDetectorPortableCacheTest.php | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 tests/Caching/Detector/ChangedFilesDetectorPortableCacheTest.php diff --git a/tests/Caching/Detector/ChangedFilesDetectorPortableCacheTest.php b/tests/Caching/Detector/ChangedFilesDetectorPortableCacheTest.php new file mode 100644 index 00000000000..ff437c427ea --- /dev/null +++ b/tests/Caching/Detector/ChangedFilesDetectorPortableCacheTest.php @@ -0,0 +1,217 @@ +changedFilesDetector = $this->make(ChangedFilesDetector::class); + + $workingDirectory = getcwd(); + Assert::string($workingDirectory); + $this->originalWorkingDirectory = $workingDirectory; + + $this->rootDirectory = sys_get_temp_dir() . '/' . uniqid('rector_portable_cache_'); + $this->firstCheckoutDirectory = $this->rootDirectory . '/first/project'; + $this->secondCheckoutDirectory = $this->rootDirectory . '/second/project'; + + $this->createCheckout($this->firstCheckoutDirectory); + $this->createCheckout($this->secondCheckoutDirectory); + + // the scope is instance state, so pin it rather than inherit whatever ran before + $this->changedFilesDetector->setActiveScope([], null); + + // start from an empty cache, so entries can be counted rather than compared to a baseline + $this->changedFilesDetector->clear(); + } + + protected function tearDown(): void + { + chdir($this->originalWorkingDirectory); + + FileSystem::delete($this->rootDirectory); + + $this->changedFilesDetector->setActiveScope([], null); + $this->changedFilesDetector->clear(); + } + + public function testCacheBuiltInOneCheckoutIsReusedInAnother(): void + { + $this->cacheProjectFilesIn($this->firstCheckoutDirectory); + + chdir($this->secondCheckoutDirectory); + + foreach (self::PROJECT_FILE_PATHS as $projectFilePath) { + $this->assertFalse( + $this->changedFilesDetector->hasFileChanged( + $this->secondCheckoutDirectory . '/' . $projectFilePath + ), + sprintf('"%s" was re-analysed in the second checkout', $projectFilePath) + ); + } + } + + public function testSecondCheckoutWritesNoFurtherCacheEntries(): void + { + $this->cacheProjectFilesIn($this->firstCheckoutDirectory); + $entryCountAfterFirstCheckout = $this->countCacheEntries(); + + $this->assertSame(count(self::PROJECT_FILE_PATHS), $entryCountAfterFirstCheckout); + + // a full run in the second checkout: every file is offered to the cache again + $this->cacheProjectFilesIn($this->secondCheckoutDirectory); + + $this->assertSame( + $entryCountAfterFirstCheckout, + $this->countCacheEntries(), + 'the second checkout wrote its own set of entries, so it shares no keys with the first' + ); + } + + public function testCacheCoversFilesOutsideTheProjectRoot(): void + { + $outsideFilePath = $this->outsideFilePathFor($this->firstCheckoutDirectory); + + chdir($this->firstCheckoutDirectory); + $this->changedFilesDetector->addCacheableFile($outsideFilePath); + $this->changedFilesDetector->cacheFile($outsideFilePath); + + // sanity: the file is cached as clean before the checkout under test changes + $this->assertFalse($this->changedFilesDetector->hasFileChanged($outsideFilePath)); + + chdir($this->secondCheckoutDirectory); + + $this->assertFalse( + $this->changedFilesDetector->hasFileChanged( + $this->outsideFilePathFor($this->secondCheckoutDirectory) + ), + 'a file outside the project root was re-analysed in the second checkout' + ); + } + + public function testScopedRunReusesItsOwnCacheAcrossCheckouts(): void + { + // an --only run keys on the relative path PLUS the scope, so the scope must not + // smuggle an absolute path back into the key + $this->changedFilesDetector->setActiveScope(['Rector\\SomeRule'], null); + + $this->cacheProjectFilesIn($this->firstCheckoutDirectory); + + chdir($this->secondCheckoutDirectory); + + foreach (self::PROJECT_FILE_PATHS as $projectFilePath) { + $this->assertFalse( + $this->changedFilesDetector->hasFileChanged( + $this->secondCheckoutDirectory . '/' . $projectFilePath + ), + sprintf('"%s" was re-analysed by a scoped run in the second checkout', $projectFilePath) + ); + } + } + + public function testScopedRunReusesFullRunCacheAcrossCheckouts(): void + { + // a full run fills the cache in the first checkout + $this->cacheProjectFilesIn($this->firstCheckoutDirectory); + + // an --only run in the second checkout finds no scoped entry and falls back to the + // full-run key, which is computed separately and has to be just as portable + $this->changedFilesDetector->setActiveScope(['Rector\\SomeRule'], null); + + chdir($this->secondCheckoutDirectory); + + foreach (self::PROJECT_FILE_PATHS as $projectFilePath) { + $this->assertFalse( + $this->changedFilesDetector->hasFileChanged( + $this->secondCheckoutDirectory . '/' . $projectFilePath + ), + sprintf('"%s" did not reach the full-run cache from the second checkout', $projectFilePath) + ); + } + } + + private function createCheckout(string $directory): void + { + // identical contents in both checkouts, as two checkouts of one commit are + foreach (self::PROJECT_FILE_PATHS as $projectFilePath) { + FileSystem::write($directory . '/' . $projectFilePath, 'outsideFilePathFor($directory), 'changedFilesDetector->addCacheableFile($filePath); + $this->changedFilesDetector->cacheFile($filePath); + } + } + + private function countCacheEntries(): int + { + $cacheDirectory = SimpleParameterProvider::provideStringParameter(Option::CACHE_DIR); + if (! is_dir($cacheDirectory)) { + return 0; + } + + $recursiveDirectoryIterator = new RecursiveDirectoryIterator( + $cacheDirectory, + FilesystemIterator::SKIP_DOTS + ); + + $entryCount = 0; + foreach (new RecursiveIteratorIterator($recursiveDirectoryIterator) as $fileInfo) { + if ($fileInfo instanceof SplFileInfo && $fileInfo->getExtension() === 'php') { + ++$entryCount; + } + } + + return $entryCount; + } +} From 774eb01d2ecb04dc54c4910e312acce0ce1abaf2 Mon Sep 17 00:00:00 2001 From: webard Date: Mon, 14 Sep 2026 15:07:18 +0200 Subject: [PATCH 3/5] cache identity absolute paths --- src/Caching/Config/FileHashComputer.php | 24 ++++++- .../Parameter/SimpleParameterProvider.php | 60 ++++++++++++++-- .../AutoloadFileParameterResolverTest.php | 4 +- .../ChangedFilesDetectorPortableCacheTest.php | 71 ++++++++++++++++++- 4 files changed, 152 insertions(+), 7 deletions(-) diff --git a/src/Caching/Config/FileHashComputer.php b/src/Caching/Config/FileHashComputer.php index 37fe42a7d33..2d0b42c4fa0 100644 --- a/src/Caching/Config/FileHashComputer.php +++ b/src/Caching/Config/FileHashComputer.php @@ -7,18 +7,40 @@ use Rector\Application\VersionResolver; use Rector\Configuration\Parameter\SimpleParameterProvider; use Rector\Exception\ShouldNotHappenException; +use Rector\FileSystem\FilePathHelper; /** * Inspired by https://github.com/symplify/easy-coding-standard/blob/e598ab54686e416788f28fcfe007fd08e0f371d9/packages/changed-files-detector/src/FileHashComputer.php */ final readonly class FileHashComputer { + public function __construct( + private FilePathHelper $filePathHelper + ) { + } + public function compute(string $filePath): string { $this->ensureIsPhp($filePath); $parametersHash = SimpleParameterProvider::hashForCacheInvalidation(); - return sha1($filePath . $parametersHash . VersionResolver::PACKAGE_VERSION); + + // the config path is relative to the project: an absolute one ties the hash, and with it + // the whole cache, to a single directory on a single machine. Resolved first, because the + // path arrives straight from `--config` and two spellings of one file must hash alike. + $relativeFilePath = $this->filePathHelper->relativePath($this->resolvePath($filePath)); + + return sha1($relativeFilePath . $parametersHash . VersionResolver::PACKAGE_VERSION); + } + + private function resolvePath(string $filePath): string + { + $realPath = realpath($filePath); + if ($realPath === false) { + return $filePath; + } + + return $realPath; } private function ensureIsPhp(string $filePath): void diff --git a/src/Configuration/Parameter/SimpleParameterProvider.php b/src/Configuration/Parameter/SimpleParameterProvider.php index 4548b61296d..634dc993d20 100644 --- a/src/Configuration/Parameter/SimpleParameterProvider.php +++ b/src/Configuration/Parameter/SimpleParameterProvider.php @@ -157,7 +157,7 @@ public static function hashForCacheInvalidation(): string ksort($strictParameters); - return sha1(serialize($strictParameters)); + return sha1(serialize(self::relativizeProjectPaths($strictParameters, self::projectPathPrefix()))); } /** @@ -166,13 +166,65 @@ public static function hashForCacheInvalidation(): string */ public static function provideCacheDirectionalParameters(): array { + $projectPathPrefix = self::projectPathPrefix(); + return [ - 'rules' => self::$parameters[Option::REGISTERED_RECTOR_RULES] ?? [], - 'sets' => self::$parameters[Option::REGISTERED_RECTOR_SETS] ?? [], - 'skip' => self::$parameters[Option::SKIP] ?? [], + 'rules' => self::relativizeProjectPaths( + (array) (self::$parameters[Option::REGISTERED_RECTOR_RULES] ?? []), + $projectPathPrefix + ), + 'sets' => self::relativizeProjectPaths( + (array) (self::$parameters[Option::REGISTERED_RECTOR_SETS] ?? []), + $projectPathPrefix + ), + 'skip' => self::relativizeProjectPaths( + (array) (self::$parameters[Option::SKIP] ?? []), + $projectPathPrefix + ), ]; } + /** + * Paths declared in the configuration - analysed paths, autoload and bootstrap files, set + * files - are absolute, so they carry the location of the project into the cache identity. + * Hashed as they are, the cache is bound to one directory: a git worktree, a second + * checkout or a CI cache restored under a different workspace name looks like a changed + * configuration and drops every entry on its first run. Anchored to the project instead, + * they describe the same configuration wherever it is checked out. + * + * @param mixed[] $parameters + * @return mixed[] + */ + private static function relativizeProjectPaths(array $parameters, string $projectPathPrefix): array + { + foreach ($parameters as $key => $value) { + if (is_array($value)) { + $parameters[$key] = self::relativizeProjectPaths($value, $projectPathPrefix); + continue; + } + + if (is_string($value) && str_starts_with($value, $projectPathPrefix)) { + $parameters[$key] = substr($value, strlen($projectPathPrefix)); + } + } + + return $parameters; + } + + /** + * Empty when the working directory cannot be resolved, which makes the relativizing above a + * no-op rather than a wrong answer. + */ + private static function projectPathPrefix(): string + { + $currentDirectory = getcwd(); + if ($currentDirectory === false) { + return ''; + } + + return rtrim($currentDirectory, '/') . '/'; + } + /** * @param Option::* $name */ diff --git a/tests/Bootstrap/AutoloadFileParameterResolverTest.php b/tests/Bootstrap/AutoloadFileParameterResolverTest.php index 6068d1e5834..bd4620b4262 100644 --- a/tests/Bootstrap/AutoloadFileParameterResolverTest.php +++ b/tests/Bootstrap/AutoloadFileParameterResolverTest.php @@ -10,6 +10,8 @@ use Rector\Caching\Config\FileHashComputer; use Rector\Configuration\Option; use Rector\Configuration\Parameter\SimpleParameterProvider; +use Rector\FileSystem\FilePathHelper; +use Symfony\Component\Filesystem\Filesystem; final class AutoloadFileParameterResolverTest extends TestCase { @@ -58,7 +60,7 @@ public function testWithoutOptionParameterStaysUntouched(): void public function testResolvedAutoloadFileChangesConfigurationHash(): void { - $fileHashComputer = new FileHashComputer(); + $fileHashComputer = new FileHashComputer(new FilePathHelper(new Filesystem())); $configFilePath = __DIR__ . '/config/some_config.php'; $hashWithout = $fileHashComputer->compute($configFilePath); diff --git a/tests/Caching/Detector/ChangedFilesDetectorPortableCacheTest.php b/tests/Caching/Detector/ChangedFilesDetectorPortableCacheTest.php index ff437c427ea..b952ead3e9b 100644 --- a/tests/Caching/Detector/ChangedFilesDetectorPortableCacheTest.php +++ b/tests/Caching/Detector/ChangedFilesDetectorPortableCacheTest.php @@ -27,8 +27,18 @@ final class ChangedFilesDetectorPortableCacheTest extends AbstractLazyTestCase */ private const string OUTSIDE_FILE_PATH = 'shared/outside.php'; + /** + * Each checkout carries its own copy, so the two configs differ only by absolute path. + */ + private const string CONFIG_FILE_PATH = 'rector.php'; + private ChangedFilesDetector $changedFilesDetector; + /** + * @var mixed[] + */ + private array $originalPaths = []; + private string $originalWorkingDirectory; private string $rootDirectory; @@ -47,13 +57,20 @@ protected function setUp(): void Assert::string($workingDirectory); $this->originalWorkingDirectory = $workingDirectory; - $this->rootDirectory = sys_get_temp_dir() . '/' . uniqid('rector_portable_cache_'); + // canonical, so that paths built here match what getcwd() reports after chdir() + $temporaryDirectory = realpath(sys_get_temp_dir()); + Assert::string($temporaryDirectory); + + $this->rootDirectory = $temporaryDirectory . '/' . uniqid('rector_portable_cache_'); $this->firstCheckoutDirectory = $this->rootDirectory . '/first/project'; $this->secondCheckoutDirectory = $this->rootDirectory . '/second/project'; $this->createCheckout($this->firstCheckoutDirectory); $this->createCheckout($this->secondCheckoutDirectory); + // the parameter bag is a global static shared across the whole test process + $this->originalPaths = SimpleParameterProvider::provideArrayParameter(Option::PATHS); + // the scope is instance state, so pin it rather than inherit whatever ran before $this->changedFilesDetector->setActiveScope([], null); @@ -67,6 +84,8 @@ protected function tearDown(): void FileSystem::delete($this->rootDirectory); + SimpleParameterProvider::setParameter(Option::PATHS, $this->originalPaths); + $this->changedFilesDetector->setActiveScope([], null); $this->changedFilesDetector->clear(); } @@ -166,6 +185,50 @@ public function testScopedRunReusesFullRunCacheAcrossCheckouts(): void } } + public function testConfigurationSnapshotSurvivesChangeOfCheckout(): void + { + // a full run in the first checkout, recording its configuration alongside the entries + chdir($this->firstCheckoutDirectory); + $this->changedFilesDetector->setFirstResolvedConfigFileInfo( + $this->configFilePathFor($this->firstCheckoutDirectory) + ); + $this->cacheProjectFilesIn($this->firstCheckoutDirectory); + + // the second checkout holds the same configuration at a different absolute path, which + // must not read as a changed configuration - that clears every entry, not just one + chdir($this->secondCheckoutDirectory); + $this->changedFilesDetector->setFirstResolvedConfigFileInfo( + $this->configFilePathFor($this->secondCheckoutDirectory) + ); + + foreach (self::PROJECT_FILE_PATHS as $projectFilePath) { + $this->assertFalse( + $this->changedFilesDetector->hasFileChanged( + $this->secondCheckoutDirectory . '/' . $projectFilePath + ), + sprintf('the cache was cleared in the second checkout, so "%s" is gone', $projectFilePath) + ); + } + } + + public function testConfiguredPathsDoNotTieTheCacheToOneDirectory(): void + { + // `withPaths()` records absolute paths, and they reach the cache-invalidation hash. Two + // checkouts declare the same paths under different roots, which must hash alike. + chdir($this->firstCheckoutDirectory); + SimpleParameterProvider::setParameter(Option::PATHS, [$this->firstCheckoutDirectory . '/src']); + $firstCheckoutHash = SimpleParameterProvider::hashForCacheInvalidation(); + + chdir($this->secondCheckoutDirectory); + SimpleParameterProvider::setParameter(Option::PATHS, [$this->secondCheckoutDirectory . '/src']); + + $this->assertSame( + $firstCheckoutHash, + SimpleParameterProvider::hashForCacheInvalidation(), + 'the configured paths made the same configuration hash differently in another checkout' + ); + } + private function createCheckout(string $directory): void { // identical contents in both checkouts, as two checkouts of one commit are @@ -174,6 +237,12 @@ private function createCheckout(string $directory): void } FileSystem::write($this->outsideFilePathFor($directory), 'configFilePathFor($directory), ' Date: Wed, 30 Sep 2026 14:17:35 +0200 Subject: [PATCH 4/5] [Caching] Reuse a single resolveRealPath() on FilePathHelper, drop duplicated helpers Both FileHashComputer and ChangedFilesDetector carried an identical private resolvePath(); move it to FilePathHelper and reuse it. Also shorten the long cache-key docblocks to a single line, per repo style. --- src/Caching/Config/FileHashComputer.php | 16 ++----------- src/Caching/Detector/ChangedFilesDetector.php | 23 +++---------------- src/FileSystem/FilePathHelper.php | 11 +++++++++ 3 files changed, 16 insertions(+), 34 deletions(-) diff --git a/src/Caching/Config/FileHashComputer.php b/src/Caching/Config/FileHashComputer.php index 2d0b42c4fa0..687844ab5b4 100644 --- a/src/Caching/Config/FileHashComputer.php +++ b/src/Caching/Config/FileHashComputer.php @@ -25,24 +25,12 @@ public function compute(string $filePath): string $parametersHash = SimpleParameterProvider::hashForCacheInvalidation(); - // the config path is relative to the project: an absolute one ties the hash, and with it - // the whole cache, to a single directory on a single machine. Resolved first, because the - // path arrives straight from `--config` and two spellings of one file must hash alike. - $relativeFilePath = $this->filePathHelper->relativePath($this->resolvePath($filePath)); + // relative config path, so the hash (and the whole cache) is not tied to one directory + $relativeFilePath = $this->filePathHelper->relativePath($this->filePathHelper->resolveRealPath($filePath)); return sha1($relativeFilePath . $parametersHash . VersionResolver::PACKAGE_VERSION); } - private function resolvePath(string $filePath): string - { - $realPath = realpath($filePath); - if ($realPath === false) { - return $filePath; - } - - return $realPath; - } - private function ensureIsPhp(string $filePath): void { $fileExtension = pathinfo($filePath, PATHINFO_EXTENSION); diff --git a/src/Caching/Detector/ChangedFilesDetector.php b/src/Caching/Detector/ChangedFilesDetector.php index 2698ad39411..dcc897dc09a 100644 --- a/src/Caching/Detector/ChangedFilesDetector.php +++ b/src/Caching/Detector/ChangedFilesDetector.php @@ -107,37 +107,20 @@ public function setFirstResolvedConfigFileInfo(string $filePath): void $this->storeConfigurationDataHash($filePath, $configurationSnapshot); } - private function resolvePath(string $filePath): string - { - $realPath = realpath($filePath); - if ($realPath === false) { - return $filePath; - } - - return $realPath; - } - private function getFilePathCacheKey(string $filePath): string { return $this->fileHasher->hash($this->cacheKeyPath($filePath) . $this->scopeSuffix); } - /** - * The path a cache key is built from: relative to the project, never absolute. - * - * An absolute path ties the whole cache to one location on disk, so the same project - * checked out twice - a git worktree, a CI checkout, a container mount - shares nothing. - * Relative keys let a cache travel with the project. Paths outside the project keep - * their `../` prefix and stay just as stable, because the anchor does not move either. - */ + // relative to the project, so a cache built in one checkout is reused in another (worktree, CI, container mount) private function cacheKeyPath(string $filePath): string { - return $this->filePathHelper->relativePath($this->resolvePath($filePath)); + return $this->filePathHelper->relativePath($this->filePathHelper->resolveRealPath($filePath)); } private function hashFile(string $filePath): string { - return $this->fileHasher->hashFiles([$this->resolvePath($filePath)]); + return $this->fileHasher->hashFiles([$this->filePathHelper->resolveRealPath($filePath)]); } /** diff --git a/src/FileSystem/FilePathHelper.php b/src/FileSystem/FilePathHelper.php index d4d01ce3a5a..fc96074d1c2 100644 --- a/src/FileSystem/FilePathHelper.php +++ b/src/FileSystem/FilePathHelper.php @@ -31,6 +31,17 @@ public function __construct( ) { } + // real path when it resolves, the original path otherwise (vfs, phar, not-yet-created) + public function resolveRealPath(string $filePath): string + { + $realPath = realpath($filePath); + if ($realPath === false) { + return $filePath; + } + + return $realPath; + } + public function relativePath(string $fileRealPath): string { if (! $this->filesystem->isAbsolutePath($fileRealPath)) { From d18364683beedcecb0dd851792264e5b31f732ed Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Wed, 30 Sep 2026 14:17:35 +0200 Subject: [PATCH 5/5] [Caching] Relativize only project-path options in the cache hash, not every parameter hashForCacheInvalidation() walked all strict parameters and stripped the cwd prefix from any string that happened to start with it. Scope it to the path-bearing options (paths, autoload paths, bootstrap files) via CACHE_PROJECT_PATH_PARAMETER_NAMES, so relativizing is intentional rather than incidental. --- .../Parameter/SimpleParameterProvider.php | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/src/Configuration/Parameter/SimpleParameterProvider.php b/src/Configuration/Parameter/SimpleParameterProvider.php index 634dc993d20..a1269060034 100644 --- a/src/Configuration/Parameter/SimpleParameterProvider.php +++ b/src/Configuration/Parameter/SimpleParameterProvider.php @@ -62,6 +62,17 @@ final class SimpleParameterProvider Option::SKIP, ]; + /** + * Absolute-path options anchored to the project before hashing, so a checkout at a different + * path is not read as a changed configuration. + * @var array + */ + private const array CACHE_PROJECT_PATH_PARAMETER_NAMES = [ + Option::PATHS, + Option::AUTOLOAD_PATHS, + Option::BOOTSTRAP_FILES, + ]; + /** * @var array */ @@ -155,9 +166,21 @@ public static function hashForCacheInvalidation(): string unset($strictParameters[$ignoredName]); } + $projectPathPrefix = self::projectPathPrefix(); + foreach (self::CACHE_PROJECT_PATH_PARAMETER_NAMES as $pathParameterName) { + if (! isset($strictParameters[$pathParameterName]) || ! is_array($strictParameters[$pathParameterName])) { + continue; + } + + $strictParameters[$pathParameterName] = self::relativizeProjectPaths( + $strictParameters[$pathParameterName], + $projectPathPrefix + ); + } + ksort($strictParameters); - return sha1(serialize(self::relativizeProjectPaths($strictParameters, self::projectPathPrefix()))); + return sha1(serialize($strictParameters)); } /** @@ -185,12 +208,8 @@ public static function provideCacheDirectionalParameters(): array } /** - * Paths declared in the configuration - analysed paths, autoload and bootstrap files, set - * files - are absolute, so they carry the location of the project into the cache identity. - * Hashed as they are, the cache is bound to one directory: a git worktree, a second - * checkout or a CI cache restored under a different workspace name looks like a changed - * configuration and drops every entry on its first run. Anchored to the project instead, - * they describe the same configuration wherever it is checked out. + * Strips the project prefix off absolute config paths, so the same configuration hashes alike + * across checkouts instead of tying the cache to one directory. * * @param mixed[] $parameters * @return mixed[]