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/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 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 new file mode 100644 index 00000000000..b952ead3e9b --- /dev/null +++ b/tests/Caching/Detector/ChangedFilesDetectorPortableCacheTest.php @@ -0,0 +1,286 @@ +changedFilesDetector = $this->make(ChangedFilesDetector::class); + + $workingDirectory = getcwd(); + Assert::string($workingDirectory); + $this->originalWorkingDirectory = $workingDirectory; + + // 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); + + // 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); + + SimpleParameterProvider::setParameter(Option::PATHS, $this->originalPaths); + + $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) + ); + } + } + + 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 + foreach (self::PROJECT_FILE_PATHS as $projectFilePath) { + FileSystem::write($directory . '/' . $projectFilePath, 'outsideFilePathFor($directory), 'configFilePathFor($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; + } +}