Skip to content

fix(prune): protect the remote default branch, not just current and target - #913

Merged
laynepenney merged 1 commit into
devfrom
fix/prune-protects-default-branch
Aug 22, 2026
Merged

fix(prune): protect the remote default branch, not just current and target#913
laynepenney merged 1 commit into
devfrom
fix/prune-protects-default-branch

Conversation

@laynepenney

Copy link
Copy Markdown
Member

Ref #912 — closes at promotion

What this fixes

gr prune --execute deleted the local default branch on any repository whose manifest target is not
the default. The guard skipped two branches — the one checked out and repo.target_branch() — so
once 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 --remote path is git 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 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 set grows rather than shrinks — main and dev are
protected 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

Witness Kills it
target=dev, default present: both survive, a merged branch is still deleted Some(default) => {}
origin/HEAD deleted from a real clone: both survive AND the run says the default is undetermined None => {}

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:

  • The first witness asserts a genuinely merged branch is deleted. Without that positive control
    it would pass just as happily against a guard that protected everything.
  • The second makes resolution genuinely fail by deleting origin/HEAD from a real clone, with a
    control 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_default is left in place and does not cover any of this. Its fixture
has 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

grip is OSS: local workspace orchestration. No identity, org, or entitlement behaviour touched.

…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>
@laynepenney

Copy link
Copy Markdown
Member Author

Review record

Both verdicts are bound to head 0b7029aad95c11e420c57e10e6ed3402b0a7b7a9 — the exact bytes on this
branch, frozen before the push and re-derived independently by the reviewer.

Apollo (author) — RAN: full suite base vs head under one toolchain (cargo 1.97.1 / rustc 1.97.1); both mutations with restore verified by occurrence count; cargo fmt --all --check;
pre-push verification that dev had not moved.

Stromus (reviewer) — APPROVE. Re-derived all six artifact hashes rather than accepting them;
confirmed the delta from the previous revision is exactly one line by diff rather than by the
author's count; confirmed the referenced issue's published bytes are the reviewed ones.

Evidence

passed failed result lines
base dev 1151 0 56
head 1153 0 56

Delta +2, exactly the two witnesses added here. Zero failures on either side, so no pre-existing
failure can be attributed to this change, and the equal result-line count shows no test binary was
lost between runs. A control on the base run confirmed the new guard is genuinely absent there, so
"base" is the pre-fix state and not a mislabelled checkout.

Mutation results, each killing exactly one witness and leaving the other green:

Mutation target-is-not-default witness unresolvable-default witness
Some(default) => {} FAILED ok
None => {} ok FAILED

A pair where one mutation killed both would not discriminate.

Scope of this approval

The change, its two witnesses, and the published text. Not re-audited: the rest of the prune
command's behaviour beyond the protection set.

@laynepenney
laynepenney merged commit 6a33df5 into dev Aug 22, 2026
1 check passed
@laynepenney
laynepenney deleted the fix/prune-protects-default-branch branch August 22, 2026 15:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant