Repository navigation
v0.9.0 — a result's address carries its environment, and prune collects the mirror
A shared cache could serve a result recorded under a different PHP minor or a different OS, and every developer's local read-through mirror grew without bound because nothing ever collected it. Both are closed by the same change, which is the interesting part.
The environment goes into the address
A result's content key was built from the structural half of the fingerprint only, and RunPipeline::replayFromRemote() checks the key, the object's existence, and whether it holds a status that must be re-run — nothing else. So a result recorded under PHP 8.2 was findable and replayable by a machine on PHP 8.4. Local recordings were never at risk (environmental drift discards a machine's own results); that protection simply did not extend to what arrives from a remote.
$material = Fingerprint::canonicalStructural($fingerprint)
. Fingerprint::canonicalResultEnvironment($fingerprint) // new
. $testHash
. implode('', $parts);The new segment is deliberately not the environmental bucket verbatim:
| key | in the address | why |
|---|---|---|
php MAJOR.MINOR |
yes | a result can legitimately differ across a minor — which is what suites exist to catch |
os |
yes | separators, locale, filesystem behaviour |
driver (pcov/xdebug) |
no | it changes which lines are reported, not whether an assertion passed. This package's own scripts alternate them, so including it would halve a developer's hit rate daily for no correctness |
coverage format |
no | a remote object carries no coverage snapshot at all; snapshots are local and keyed by the test file's own content hash |
php and os stay in the environmental bucket as well, and that redundancy is load-bearing: an adopted graph still matches structurally so its edges are inherited — correctly, since edges are a property of the tree — while its results carry addresses this machine will never compute, and environmentalDrift() → clearResults() is the only thing that removes them.
This was chosen over scoping the remote object tree by a generation and environment path, which an earlier proposal recommended. The reason is the next section.
prune collects the local mirror, which nothing had ever collected
Everything read from a remote is mirrored at <stateDir>/remote/cache/objects/<k>.json, flat. --keep-months existed only on prune --remote, which collects the remote. Measured on one populated mirror after a single composer.lock change: 726 of 1,452 objects unreachable — exactly one dead generation, on every developer machine, with no way to remove it.
With the environment in the address, every object a machine can address is by construction from its own environment, so an object recorded elsewhere is provably unreachable rather than merely absent from today's graph. That is what a path level would not have given, and it is why the key won.
Eviction is a truncate, not an unlink, and needs no change to either code path that reads the mirror. The file does two jobs on different parts of itself — putObject() tests is_file() for its "already published" marker, object() decodes the contents — so a zero-byte file keeps the marker true, fails the decode, frees the blocks, re-publishes nothing, and refills on the next lookup.
prune --forget-published unlinks instead, reclaiming the inode at the cost of a redundant put (idempotent — objects are content-addressed and immutable). It stays behind a flag because on a client with remote_push: objects that re-publication is noise on a shared remote.
<stateDir>/coverage/ is collected too, by its own rule: snapshots are keyed by ContentHash::of() of the test file, never by a content key, because a content key needs a Graph that does not exist inside the PHPUnit child process.
status now reports it: mirror: 1452 objects · 726 reachable · 2.4 MB reclaimable.
Exercised end to end against a copy of a real state directory:
status: mirror: 1452 objects · 726 reachable · 2.4 MB reclaimable
prune: mirror prune: truncated 726 unreachable object(s) (2.4 MB reclaimed), kept 726 reachable
after: truncated 726 · intact 726 · 2,549,512 bytes of 5,099,022 remain
Upgrading
Remote objects recorded before this release become unaddressable. Graphs are not invalidated — edges and local results survive and keep replaying — but every content key moves, so a machine re-records what it needs and the first prune truncates the old generation out of the mirror.
No configuration key was added, renamed, or given a new default.
Documentation
docs/DECISIONS.md is back. It had been deleted wholesale — 216 lines, 43 entries — for one line naming a vendor namespace from an early spec draft, while seven places in src/ went on citing it at a path that did not exist. Two new entries record why the environment went into the address, and why the cache may live on an orphan branch of the project's own repository while a dedicated repository stays the recommendation.
docs/sharing-the-cache.md now leads with what to do instead of a six-option comparison table, and documents that orphan-branch shape — no repository to create, no deploy key, no secret — along with the measured reason it is not the default: a plain git clone of the project fetches every branch.
Full changelog: v0.8.1...v0.9.0