diff --git a/bin/branch_sweep.sh b/bin/branch_sweep.sh index 3e2ba6b..351dfb8 100755 --- a/bin/branch_sweep.sh +++ b/bin/branch_sweep.sh @@ -219,17 +219,36 @@ if [[ "$MODE" == "audit" ]]; then fi # --- delete ------------------------------------------------------------------ -deleted=0 failed=0 n=0 +# Two things learned the hard way on the first org-wide delete run, where 99 +# pushes failed and the log recorded nothing but the word FAILED 99 times: +# +# * KEEP THE ERROR. `push >/dev/null 2>&1` throws away the one piece of +# information a failure carries. A run that cannot say why it failed is +# barely better than one that failed silently. +# * STOP EARLY. Delete permission is a property of the credential, not of +# the branch, so the first few failures already answer the question for +# all of them. Grinding through 99 doomed pushes just buries the reason +# and burns the API budget. +deleted=0 failed=0 n=0 streak=0 for entry in "${safe[@]}"; do b="${entry%% *}" if [[ "$LIMIT" -gt 0 && "$n" -ge "$LIMIT" ]]; then echo " (limit $LIMIT reached — $(( ${#safe[@]} - n )) left for the next run)"; break fi n=$((n + 1)) - if g push origin --delete "$b" >/dev/null 2>&1; then - echo " deleted $b"; deleted=$((deleted + 1)) + if err=$(g push origin --delete "$b" 2>&1); then + echo " deleted $b"; deleted=$((deleted + 1)); streak=0 else - echo " FAILED $b"; failed=$((failed + 1)) + reason=$(grep -iEm1 'error|fatal|denied|forbidden|protected' <<<"$err" | sed 's/^remote: *//;s/[[:space:]]*$//') + echo " FAILED $b — ${reason:-no error text returned}" + failed=$((failed + 1)); streak=$((streak + 1)) + if [[ "$streak" -ge 3 ]]; then + echo + echo " Stopping: $streak deletions in a row failed for the same credential." + echo " That is a permission property, not a per-branch one — the remaining" + echo " $(( ${#safe[@]} - n )) would fail identically. Nothing has been deleted." + break + fi fi done echo diff --git a/tests/test_branch_sweep.py b/tests/test_branch_sweep.py index 449955b..390673c 100644 --- a/tests/test_branch_sweep.py +++ b/tests/test_branch_sweep.py @@ -193,6 +193,58 @@ def test_a_legacy_trunk_is_protected_even_when_fully_merged(tmp_path): assert "master" in remaining, "a legacy trunk must never be swept" +def _readonly_origin(tmp_path: Path): + """A world whose origin refuses ref deletion, like a read-only credential.""" + origin = tmp_path / "origin" + origin.mkdir() + subprocess.run(["git", "init", "-q", "-b", "main", str(origin)], check=True) + _git(origin, "config", "user.email", "t@t") + _git(origin, "config", "user.name", "t") + _commit(origin, "f", "base", "base") + for i in range(5): + _git(origin, "branch", f"spent{i}") # all contained in main + # the closest local stand-in for "this credential may not delete refs" + _git(origin, "config", "receive.denyDeletes", "true") + + clone = tmp_path / "clone" + subprocess.run(["git", "clone", "-q", str(origin), str(clone)], check=True) + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + (bin_dir / "gh").write_text('#!/usr/bin/env bash\nexit 0\n') + (bin_dir / "gh").chmod(0o755) + return clone, origin, bin_dir + + +def test_a_failed_delete_reports_why(tmp_path): + """`FAILED ` with no reason is barely better than silence. + + The first org-wide delete run failed 99 times and recorded nothing but the + word FAILED, because the push was redirected to /dev/null. The error text + is the only thing a failure carries; keep it. + """ + clone, _, bin_dir = _readonly_origin(tmp_path) + out = _sweep(clone, bin_dir, mode="delete").stdout + assert "FAILED" in out + assert "no error text returned" not in out, "the push error was swallowed" + assert "denyDeletes" in out or "denied" in out.lower() or "error" in out.lower() + + +def test_repeated_delete_failures_stop_early(tmp_path): + """Delete permission belongs to the credential, not the branch. + + Once a few in a row fail the answer is known for all of them, so grinding + through the rest only buries the reason. + """ + clone, origin, bin_dir = _readonly_origin(tmp_path) + proc = _sweep(clone, bin_dir, mode="delete") + assert proc.returncode == 2, "a run where nothing could be deleted must not report success" + assert "Stopping:" in proc.stdout + # stopped after the streak rather than attempting all five + assert proc.stdout.count("FAILED") == 3 + remaining = set(_git(origin, "for-each-ref", "--format=%(refname:short)", "refs/heads").split()) + assert {f"spent{i}" for i in range(5)} <= remaining, "nothing should have been deleted" + + def test_refuses_to_run_without_gh(world): """Blind to open PRs means blind to in-flight work: refuse, do not guess.""" clone, _, _ = world