Skip to content

Commit e54781f

Browse files
committed
fix(branch_sweep): protect legacy trunks by name, not by containment
The first org-wide audit found a repo carrying a `master` branch alongside `main`. It landed in KEEP, but only because it happened to hold unique content — the sweep protected the *default* branch and nothing else, so a `master` fully folded into `main` after a rename is indistinguishable from spent work and would have been deleted. That is not an ordinary branch. Deleting it breaks every stale clone, bookmark, CI reference and doc link still pointing at the old trunk, and nothing about the branch's content says so — which is exactly why the skill's own recipe has always excluded both names (`grep -vE '^(main|master)$'`). Protecting only the default silently implemented half of a rule that was written whole. So `main` and `master` are now protected on NAME, whether or not either is this repo's default, alongside whatever the default actually is. Found by running the audit across 25 repos before deleting anything in any of them — the value of audit-first is that it surfaces this class of thing while it is still hypothetical. The test fails without the guard and passes with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KwqicJpMqmcT5RVbyNwdKq
1 parent b72fc1f commit e54781f

2 files changed

Lines changed: 43 additions & 2 deletions

File tree

bin/branch_sweep.sh

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
# view, so the safety gates from skills/repo_cleanup/SKILL.md are enforced here
2020
# rather than assumed of the caller:
2121
#
22-
# * `main` / the default branch — never a candidate
22+
# * `main`, `master`, and the default branch — never a candidate
2323
# * `archive/condemned/*` — PyAutoGut transit refs;
2424
# voiding these before their sweep-after date destroys the recovery path
2525
# the Gut exists to provide. The Gut voids them, not us.
@@ -156,10 +156,21 @@ default_branch=$(g symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/
156156
default_branch="${default_branch#origin/}"
157157
default_branch="${default_branch:-main}"
158158

159+
# Trunk names are protected whether or not they are THIS repo's default.
160+
# A repo migrated main<-master keeps the old trunk as an ordinary branch, and
161+
# once it is fully contained in main the sweep would happily delete it —
162+
# breaking every stale clone, bookmark and doc that still points at it. The
163+
# skill's own recipe has always excluded both names (`grep -vE
164+
# '^(main|master)$'`); protecting only the default silently dropped half of
165+
# that. Found by the first org-wide audit, where one repo's `master` survived
166+
# on unique content alone.
167+
is_trunk() { [[ "$1" == "$default_branch" || "$1" == "main" || "$1" == "master" ]]; }
168+
159169
# --- classify ----------------------------------------------------------------
160170
safe=() keep=() protected=()
161171
while read -r b; do
162-
[[ -n "$b" && "$b" != "HEAD" && "$b" != "$default_branch" ]] || continue
172+
[[ -n "$b" && "$b" != "HEAD" ]] || continue
173+
is_trunk "$b" && continue
163174
case "$b" in
164175
archive/condemned/*) protected+=("$b gut-transit-ref"); continue ;;
165176
esac

tests/test_branch_sweep.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,36 @@ def test_delete_removes_only_the_contained_branch(world):
163163
assert {"main", "unmerged", "open-pr-head", "archive/condemned/something"} <= remaining
164164

165165

166+
def test_a_legacy_trunk_is_protected_even_when_fully_merged(tmp_path):
167+
"""`master` after a main<-master migration is not an ordinary branch.
168+
169+
It is protected on NAME, not on containment: once folded into main it looks
170+
exactly like spent work, and deleting it breaks every stale clone, bookmark
171+
and doc still pointing at it. The first org-wide audit found a repo whose
172+
`master` survived only because it happened to carry unique content — luck,
173+
not a gate.
174+
"""
175+
origin = tmp_path / "origin"
176+
origin.mkdir()
177+
subprocess.run(["git", "init", "-q", "-b", "main", str(origin)], check=True)
178+
_git(origin, "config", "user.email", "t@t")
179+
_git(origin, "config", "user.name", "t")
180+
_commit(origin, "f", "base", "base")
181+
# a legacy trunk fully contained in main — indistinguishable from spent work
182+
_git(origin, "branch", "master")
183+
184+
clone = tmp_path / "clone"
185+
subprocess.run(["git", "clone", "-q", str(origin), str(clone)], check=True)
186+
bin_dir = tmp_path / "bin"
187+
bin_dir.mkdir()
188+
(bin_dir / "gh").write_text('#!/usr/bin/env bash\nexit 0\n')
189+
(bin_dir / "gh").chmod(0o755)
190+
191+
assert _sweep(clone, bin_dir, mode="delete").returncode == 0
192+
remaining = set(_git(origin, "for-each-ref", "--format=%(refname:short)", "refs/heads").split())
193+
assert "master" in remaining, "a legacy trunk must never be swept"
194+
195+
166196
def test_refuses_to_run_without_gh(world):
167197
"""Blind to open PRs means blind to in-flight work: refuse, do not guess."""
168198
clone, _, _ = world

0 commit comments

Comments
 (0)