fix(prune): protect the remote default branch, not just current and target - #913
Conversation
…arget `gr prune --execute` deleted the local `main` on any repo whose manifest target is not `main`. The guard skipped exactly two branches -- the one checked out and `repo.target_branch()` -- so once the integration target moved to `dev`, `main` was neither and fell out of protection. Nothing was edited to cause this: `main` had been protected only by coincidence, because target and default used to be the same value. Scope of the defect, measured rather than assumed: LOCAL ONLY. The `--remote` path is `git fetch --prune`, which prunes stale remote-tracking refs and does not delete remote branches, so the worst outcome was a local branch recreated from the remote. The fix protects a SET of three: current, target, and the remote's default branch read from `refs/remotes/<remote>/HEAD`. The default is the only one of the three that asserts "permanent" rather than "currently interesting", which is the property a cleanup rule needs. Resolution is local -- a cleanup verb should not acquire a network failure mode. When the default cannot be resolved, the protected set GROWS rather than shrinks: `main` and `dev` are protected by name and the run says so. A resolution failure that silently dropped a branch would reproduce this exact defect inside its own fix, and would do it with nothing going red. Two witnesses, each killed by exactly one mutation: - target=dev with `main` present: `main` and `dev` survive while a genuinely merged branch is still deleted. That last assertion is a positive control; without it the test would pass against a guard that protected everything. - `origin/HEAD` deleted from a real clone, so resolution genuinely fails rather than being stubbed: both branches survive AND the output states the default could not be determined. A protected-more that is silent still reads as "the default resolved fine" to the next reader. The pre-existing `test_prune_skips_current_and_default` is left in place but does not cover any of this: its fixture has current == target == `main`, so `main` is protected twice over and the test passes with EITHER clause of the old guard removed. It kills no mutant while carrying the name of the guarantee it fails to check. Co-Authored-By: Claude <noreply@anthropic.com>
Review recordBoth verdicts are bound to head Apollo (author) — RAN: full suite base vs head under one toolchain ( Stromus (reviewer) — APPROVE. Re-derived all six artifact hashes rather than accepting them; Evidence
Delta +2, exactly the two witnesses added here. Zero failures on either side, so no pre-existing Mutation results, each killing exactly one witness and leaving the other green:
A pair where one mutation killed both would not discriminate. Scope of this approvalThe change, its two witnesses, and the published text. Not re-audited: the rest of the prune |
Ref #912 — closes at promotion
What this fixes
gr prune --executedeleted the local default branch on any repository whose manifest target is notthe default. The guard skipped two branches — the one checked out and
repo.target_branch()— soonce the integration target moved off the default, the default was neither and fell out of
protection. Nothing was edited to cause it; the branch had been protected only by coincidence,
because target and default used to be the same value.
Severity, measured rather than assumed: local only. The
--remotepath isgit fetch --prune,which prunes stale remote-tracking refs and does not delete remote branches. The worst outcome was a
local branch recreated from the remote.
The change
Protect a set of three — current, target, and the remote's default branch from
refs/remotes/<remote>/HEAD. The default is the only member that asserts permanent rather thancurrently interesting, which is the property a cleanup rule needs. Resolution is local: a cleanup
verb should not acquire a network failure mode.
When the default cannot be resolved the set grows rather than shrinks —
mainanddevareprotected by name, and the run says so on stdout. A resolution failure that silently dropped a
branch would reproduce this defect inside its own fix, with nothing going red.
Witnesses, and the mutation that kills each
Some(default) => {}origin/HEADdeleted from a real clone: both survive AND the run says the default is undeterminedNone => {}Each mutation kills exactly one witness and leaves the other green — run both ways, recorded. A
pair where one mutation killed both would not be discriminating.
Two details that are load-bearing rather than decorative:
it would pass just as happily against a guard that protected everything.
origin/HEADfrom a real clone, with acontrol asserting the ref existed first. A stub would test the code path against a fixture rather
than against the condition, leaving the real case — a clone never given an
origin/HEAD—unexercised.
On the test that was already there
test_prune_skips_current_and_defaultis left in place and does not cover any of this. Its fixturehas current == target == default, so the branch is protected twice over; it passes with either
clause of the old guard removed. It is green, it kills no mutant, and it carries the name of the
guarantee it fails to check.
Premium boundary
gripis OSS: local workspace orchestration. No identity, org, or entitlement behaviour touched.