Skip to content

v0.10.0 — remote:init, and a rejected push no longer eats its deletions

Choose a tag to compare

@manuglopez manuglopez released this 10 Sep 23:49
· 12 commits to main since this release
0a96cef

Sharing the cache stops being a document you read

Every remote shape worked, and every one of them needed a manual setup a user had to find, read and translate. That is where adoption dies — not in the number of options, but in the absence of a command.

$ vendor/bin/phpunit-replay remote:init

origin:      git@github.com:acme/widget.git
cache:       git@github.com:acme/widget-replay-cache.git
private:     always — the cache stores test names, file paths and failure messages

create:      created acme/widget-replay-cache with `gh repo create --private`

probe:       published objects/2026-09/replay-probe-f7cbc5e8ab5f8bad.json
             read it back from a separate clone — the round trip works
             probe deleted

config:      wrote phpunit-replay.php

The probe is the point. "Configured" and "works" are different claims, and only the second is worth printing. The command builds the real cache backend through the factory, publishes an object, reads it back through a separate clone, compares, and deletes it.

What it does by not doing it

Never touches a credential gh already owns yours. A test-tooling package that asks for a personal access token is a package nobody installs. Without gh it prints the exact manual steps and continues rather than failing
Never writes a secret into your repository CI publishing needs a deploy key in the cache repo plus a secret in your own — two writes to real infrastructure. It prints the steps and a workflow snippet and stops
Never creates a public repository and there is no --public
Never edits an existing config an existing phpunit-replay.php is left byte-identical and the lines to add are printed. A config with baseline_branches already tuned must not be clobbered by a setup command

--dry-run prints every action and takes none. --same-repo configures the other supported shape — an orphan branch of the repository you already have, nothing to create and no access to grant — and prints the cost that keeps it from being the default: a plain git clone of your project fetches every branch, so the cache lands in everyone's clone.

Known gap, pinned rather than closed: --name value as two tokens is not recognised, only --name=value. The argv pre-splitter recognises a value-taking option only with its value attached by =, the same as run --log-junit and prune --keep-months already; closing it changes shared behaviour and belongs in its own change.

A rejected push no longer eats its deletions

prune --remote silently discarded every deletion it made whenever the push was rejected once, and then reported success — printing a count of objects it had not removed.

doEnd() commits the deletion; a concurrent client rejects the push; adoptFetchedHistory() does git reset --hard FETCH_HEAD, which discards the local commit and restores the deleted file exactly as upstream still holds it. The helper then replayed the write buffer, which knew nothing about deletions, so nothing was staged and the retried push succeeded with no error.

Root cause in one sentence: the reconciliation helper could replay a run's writes but had no representation of its deletions.

Fixed with a tombstone set beside the write buffer. The cancellations in both directions are the part a naive fix gets wrong — delete() also drops any buffered put() of that key, put() also drops any tombstone for it. A delete() of a key absent from the local mirror tombstones too, deliberately: the mirror is only as fresh as its last refresh, so a key can be absent here and present upstream, and gating on local presence would reintroduce the same bug through staleness instead of push timing.

Nothing was ever corrupted — this is garbage collection and a later run retried — but it was silent, which is what the retry loop exists to prevent. Four of the six new tests fail against unfixed code.

It was found because remote:init needed to delete its own probe object and could not. A command written to prove the round trip works proved something else first.

Upgrading

Nothing to do. No configuration key was added, renamed or given a new default, and no cache is invalidated.

Full changelog: v0.9.0...v0.10.0