diff --git a/src/Caching/Config/FileHashComputer.php b/src/Caching/Config/FileHashComputer.php index 37fe42a7d33..687844ab5b4 100644 --- a/src/Caching/Config/FileHashComputer.php +++ b/src/Caching/Config/FileHashComputer.php @@ -7,18 +7,28 @@ 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); + + // 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 ensureIsPhp(string $filePath): void diff --git a/src/Caching/Detector/ChangedFilesDetector.php b/src/Caching/Detector/ChangedFilesDetector.php index 385dbffb98c..dcc897dc09a 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); } @@ -105,24 +107,20 @@ public function setFirstResolvedConfigFileInfo(string $filePath): void $this->storeConfigurationDataHash($filePath, $configurationSnapshot); } - private function resolvePath(string $filePath): string + private function getFilePathCacheKey(string $filePath): string { - $realPath = realpath($filePath); - if ($realPath === false) { - return $filePath; - } - - return $realPath; + return $this->fileHasher->hash($this->cacheKeyPath($filePath) . $this->scopeSuffix); } - private function getFilePathCacheKey(string $filePath): string + // 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->fileHasher->hash($this->resolvePath($filePath) . $this->scopeSuffix); + 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/Configuration/Parameter/SimpleParameterProvider.php b/src/Configuration/Parameter/SimpleParameterProvider.php index 4548b61296d..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,6 +166,18 @@ 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($strictParameters)); @@ -166,13 +189,61 @@ 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 + ), ]; } + /** + * 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[] + */ + 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/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)) { 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; + } +}