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/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; + } +}