Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,38 @@ jobs:
echo "$OUTPUT"
../bashunit -a contains 'Composer metadata changed but no package versions changed; keeping the result cache.' "$OUTPUT"
../bashunit -a contains 'Result cache restored. 1 file will be reanalysed.' "$OUTPUT"
- script: |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need a separate e2e test showing result cache can be re-used after a git worktree is created

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in e5a2d61. It warms the cache in one checkout, creates a git worktree at a different absolute path with its own vendor (so %rootDir% points at the worktree), carries the warm cache in, and asserts the worktree run reuses it with 0 files reanalysed. So it proves the stored paths re-absolutize against the worktree's location.

One note on scope: the test copies the warm cache into the worktree rather than having PHPStan discover the main checkout's cache. Auto-discovery (reaching into the main checkout for the warm cache) is the separate, harder piece @ondrejmirtes flagged and it is not in this PR. This test covers what the PR actually implements, that a cache made available in the worktree is portable to it.

cd e2e/result-cache-relative-path
# Cold run: paths are stored relative to the phpstan install (the anchor), so the cache
# no longer embeds the absolute checkout path.
../../bin/phpstan analyse
../bashunit -a contains "'e2e/result-cache-relative-path/src/HelloWorld.php'" "$(cat tmp/resultCache.php)"
# the analysed file must NOT be stored under its absolute checkout path
if grep -q "'$(pwd)/src/HelloWorld.php'" tmp/resultCache.php; then echo 'cache still holds an absolute analysed path'; exit 1; fi
# Warm run: the relative cache re-absolutizes against the current anchor and is fully reused.
OUTPUT=$(../bashunit -a exit_code "0" "../../bin/phpstan analyse -vv")
echo "$OUTPUT"
../bashunit -a contains 'Result cache restored. 0 files will be reanalysed.' "$OUTPUT"
- script: |
cd e2e/result-cache-relative-path
# Warm the cache in this checkout; with the toggle on, paths are stored relative to the
# phpstan install, so the cache is portable to another checkout with the same layout.
../../bin/phpstan analyse
# A git worktree is a second checkout of the same repo at a different absolute path. Give it
# its own phpstan install (vendor) so %rootDir% points at the worktree, and carry the warm
# cache across (a real setup would CoW-clone the checkout or share the tmpDir).
WORKTREE="$(mktemp -d)/phpstan"
git -C ../.. worktree add --detach "$WORKTREE" HEAD
cp -al ../../vendor "$WORKTREE/vendor"
cp -R tmp "$WORKTREE/e2e/result-cache-relative-path/tmp"
rm -rf "$WORKTREE/e2e/result-cache-relative-path/tmp/cache"
# Running in the worktree (a different absolute prefix) must re-absolutize the relative cache
# against the worktree and reuse it, with 0 files reanalysed.
cd "$WORKTREE/e2e/result-cache-relative-path"
# -vv progress (incl. "Result cache restored") goes to stderr, so capture both streams
OUTPUT=$(../../bin/phpstan analyse -vv 2>&1)
echo "$OUTPUT"
echo "$OUTPUT" | grep -q 'Result cache restored. 0 files will be reanalysed.' || { echo 'result cache was not reused in the git worktree'; exit 1; }
- script: |
cd e2e/result-cache-package-update
composer install
Expand Down
1 change: 1 addition & 0 deletions e2e/result-cache-relative-path/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/tmp
5 changes: 5 additions & 0 deletions e2e/result-cache-relative-path/phpstan.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
parameters:
level: 8
tmpDir: tmp
paths:
- src
13 changes: 13 additions & 0 deletions e2e/result-cache-relative-path/src/HelloWorld.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php declare(strict_types = 1);

namespace RelativePathResultCache;

class HelloWorld
{

public function sayHello(string $name): string
{
return sprintf('Hello, %s', $name);
}

}
46 changes: 46 additions & 0 deletions src/Analyser/Error.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use Nette\Utils\Strings;
use Override;
use PhpParser\Node;
use PHPStan\File\FileHelper;
use PHPStan\File\RelativePathHelper;
use PHPStan\ShouldNotHappenException;
use ReturnTypeWillChange;
use Throwable;
Expand Down Expand Up @@ -133,6 +135,50 @@ public function getTraitFilePath(): ?string
return $this->traitFilePath;
}

/**
* Rewrites the absolute paths this error carries to paths relative to the helper's base,
* for portable storage in the result cache. Inverse of absolutizePaths().
*/
public function relativizePaths(RelativePathHelper $relativePathHelper): self
{
return new self(
$this->message,
$relativePathHelper->getRelativePath($this->file),
$this->line,
$this->canBeIgnored,
$this->filePath === null ? null : $relativePathHelper->getRelativePath($this->filePath),
$this->traitFilePath === null ? null : $relativePathHelper->getRelativePath($this->traitFilePath),
$this->tip,
$this->nodeLine,
$this->nodeType,
$this->identifier,
$this->metadata,
$this->fixedErrorDiff,
);
}

/**
* Rewrites the relative paths stored by relativizePaths() back to absolute paths against
* the helper's base. Inverse of relativizePaths().
*/
public function absolutizePaths(FileHelper $fileHelper): self
{
return new self(
$this->message,
$fileHelper->normalizePath($fileHelper->absolutizePath($this->file)),
$this->line,
$this->canBeIgnored,
$this->filePath === null ? null : $fileHelper->normalizePath($fileHelper->absolutizePath($this->filePath)),
$this->traitFilePath === null ? null : $fileHelper->normalizePath($fileHelper->absolutizePath($this->traitFilePath)),
$this->tip,
$this->nodeLine,
$this->nodeType,
$this->identifier,
$this->metadata,
$this->fixedErrorDiff,
);
}

public function getLine(): ?int
{
return $this->line;
Expand Down
56 changes: 54 additions & 2 deletions src/Analyser/ResultCache/ResultCacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,13 @@
final class ResultCacheManager
{

private const CACHE_VERSION = 'v13-packageDependencies';
private const CACHE_VERSION = 'v14-relativePaths';

/** @var array<string, string> */
private array $fileHashes = [];

private ?ResultCachePathTransformer $pathTransformer = null;

/** @var array<string, true> */
private array $alreadyProcessed = [];

Expand Down Expand Up @@ -123,10 +125,17 @@ public function __construct(
private array $parametersNotInvalidatingCache,
#[AutowiredParameter(ref: '%resultCacheSkipIfOlderThanDays%')]
private int $skipResultCacheIfOlderThanDays,
#[AutowiredParameter(ref: '%rootDir%')]
private string $anchorDirectory,
)
{
}

private function getPathTransformer(): ResultCachePathTransformer
{
return $this->pathTransformer ??= new ResultCachePathTransformer($this->anchorDirectory);
}

/**
* @param string[] $allAnalysedFiles
* @param mixed[]|null $projectConfigArray
Expand Down Expand Up @@ -263,8 +272,32 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ?
);
}

// The cache stores paths relative to the anchor directory. Re-absolutize them against the current
// anchor before anything reads them, so a moved project (a fresh CI checkout dir, a git worktree)
// resolves to its new location. projectConfig stays a relative Neon string here;
// isMetaDifferent()/getMetaKeyDifferences() relativize the current side to compare. Absolutizing an
// already-absolute path is a no-op, so a cache from an older format is left untouched (and then
// discarded by the cacheVersion check below).
$transformer = $this->getPathTransformer();
$data['meta'] = $transformer->absolutizeMeta($data['meta']);
$data['projectExtensionFiles'] = $transformer->absolutizeFileKeyed($data['projectExtensionFiles']);
$data['linesToIgnore'] = $transformer->absolutizeCompoundKeyed($data['linesToIgnore']);
$data['unmatchedLineIgnores'] = $transformer->absolutizeCompoundKeyed($data['unmatchedLineIgnores']);
$data['dependencies'] = $transformer->absolutizeDependencies($data['dependencies']);
$data['packageDependencies'] = $transformer->absolutizeFileKeyed($data['packageDependencies'] ?? []);

$errorsCallback = $data['errorsCallback'];
$data['errorsCallback'] = static fn (): array => $transformer->absolutizeErrors($errorsCallback());
$locallyIgnoredErrorsCallback = $data['locallyIgnoredErrorsCallback'];
$data['locallyIgnoredErrorsCallback'] = static fn (): array => $transformer->absolutizeErrors($locallyIgnoredErrorsCallback());
$collectedDataCallback = $data['collectedDataCallback'];
$data['collectedDataCallback'] = static fn (): array => $transformer->absolutizeFileKeyed($collectedDataCallback());
$exportedNodesCallback = $data['exportedNodesCallback'];
$data['exportedNodesCallback'] = static fn (): array => $transformer->absolutizeFileKeyed($exportedNodesCallback());

$meta = $this->getMeta($allAnalysedFiles, $projectConfigArray);
$packageDependencies = $data['packageDependencies'] ?? [];
// absolutized above, so it is always present here
$packageDependencies = $data['packageDependencies'];
$packageSeededFiles = [];
if ($this->isMetaDifferent($data['meta'], $meta)) {
$diffs = $this->getMetaKeyDifferences($data['meta'], $meta);
Expand Down Expand Up @@ -636,6 +669,7 @@ private function isMetaDifferent(array $cachedMeta, array $currentMeta): bool
if ($projectConfig !== null) {
ksort($currentMeta['projectConfig']);

$currentMeta['projectConfig'] = $this->getPathTransformer()->relativizeProjectConfig($currentMeta['projectConfig']);
$currentMeta['projectConfig'] = Neon::encode($currentMeta['projectConfig']);
}

Expand All @@ -657,6 +691,7 @@ private function getMetaKeyDifferences(array $cachedMeta, array $currentMeta): a
if ($projectConfig !== null) {
ksort($currentMeta['projectConfig']);

$currentMeta['projectConfig'] = $this->getPathTransformer()->relativizeProjectConfig($currentMeta['projectConfig']);
$currentMeta['projectConfig'] = Neon::encode($currentMeta['projectConfig']);
}

Expand Down Expand Up @@ -740,6 +775,7 @@ public function process(AnalyserResult $analyserResult, ResultCache $resultCache
$meta = $resultCache->getMeta();
$projectConfigArray = $meta['projectConfig'];
if ($projectConfigArray !== null) {
$projectConfigArray = $this->getPathTransformer()->relativizeProjectConfig($projectConfigArray);
$meta['projectConfig'] = Neon::encode($projectConfigArray);
}
$doSave = function (array $errorsByFile, $locallyIgnoredErrorsByFile, $linesToIgnore, $unmatchedLineIgnores, $collectedDataByFile, ?array $dependencies, ?array $usedTraitDependencies, ?array $packageDependencies, array $exportedNodes, array $projectExtensionFiles) use ($internalErrors, $resultCache, $output, $onlyFiles, $meta): bool {
Expand Down Expand Up @@ -1210,6 +1246,22 @@ private function save(

ksort($exportedNodes);

// Store paths relative to the anchor so the cache survives a change of the project's absolute
// path prefix (a fresh CI checkout dir, a git worktree). projectConfig inside $meta is already a
// Neon-encoded string here (encoded in process()), so it is relativized at the array level before
// that encode; only the other meta paths remain.
$transformer = $this->getPathTransformer();
$meta = $transformer->relativizeMeta($meta);
$errors = $transformer->relativizeErrors($errors);
$locallyIgnoredErrors = $transformer->relativizeErrors($locallyIgnoredErrors);
$linesToIgnore = $transformer->relativizeCompoundKeyed($linesToIgnore);
$unmatchedLineIgnores = $transformer->relativizeCompoundKeyed($unmatchedLineIgnores);
$collectedData = $transformer->relativizeFileKeyed($collectedData);
$invertedDependencies = $transformer->relativizeDependencies($invertedDependencies);
$packageDependencies = $transformer->relativizeFileKeyed($packageDependencies);
$exportedNodes = $transformer->relativizeFileKeyed($exportedNodes);
$projectExtensionFiles = $transformer->relativizeFileKeyed($projectExtensionFiles);

$file = $this->cacheFilePath;

// streamed to the file section by section - building the whole
Expand Down
Loading