diff --git a/.github/workflows/branch_sweep.yml b/.github/workflows/branch_sweep.yml new file mode 100644 index 0000000..b8cc6bf --- /dev/null +++ b/.github/workflows/branch_sweep.yml @@ -0,0 +1,100 @@ +name: Branch Sweep + +# Deletes feature branches whose content is already in `main`, and reports the +# ones it will not touch. +# +# WHY A WORKFLOW AND NOT THE SESSION. A cloud Claude session — the phone, or +# claude.ai/code — can audit branches perfectly well but cannot remove one: +# `git push origin --delete` returns 403 for the session credential, and the +# GitHub tool surface those sessions get has no delete-ref call at all. So +# branch cleanup was a laptop-only chore, and it showed: 233 branches across +# Mind and Brain by 2026-08-25, 188 of them provably spent, because nothing +# deletes a merged head automatically. +# +# A workflow's GITHUB_TOKEN is a *different* credential, and this repo already +# trusts it with `contents: write` (dashboard_refresh.yml commits to main with +# it). Running the sweep here means any surface that can dispatch a workflow +# can drive the cleanup — including a chat on a phone. +# +# THIS IS THE BACKSTOP, NOT THE FIX. The primary fix is the repo setting +# Settings → General → "Automatically delete head branches", which removes each +# PR head at merge and prevents the pile-up in the first place. This workflow +# exists for the backlog that predates it, for branches pushed without a PR, +# and for heads whose PR was closed unmerged. +# +# The safety gates (never `main`, never `archive/condemned/*` Gut transit refs, +# never an open PR's head, never a branch git cannot prove is contained) live +# in the script, not here — see PyAutoBrain/bin/branch_sweep.sh and +# PyAutoBrain/skills/repo_cleanup/SKILL.md. + +on: + workflow_dispatch: + inputs: + mode: + description: "audit = report only · delete = actually remove" + type: choice + options: [audit, delete] + default: audit + limit: + description: "Max branches to delete (0 = no cap). Ignored in audit mode." + type: string + default: "0" + schedule: + # Weekly, audit-only: keeps the backlog visible without ever acting + # unattended. Deletion always requires someone to dispatch it. + - cron: "10 4 * * 0" + +permissions: + contents: write + pull-requests: read + +concurrency: + group: branch-sweep-${{ github.repository }} + cancel-in-progress: false + +jobs: + sweep: + runs-on: ubuntu-latest + steps: + - name: Check out this repo (full history) + uses: actions/checkout@v4 + with: + # Containment is an ancestry question: on a shallow clone every + # branch looks unmerged, so the sweep would protect everything and + # quietly do nothing. The script re-checks and deepens if needed. + fetch-depth: 0 + + - name: Check out PyAutoBrain (the sweep logic lives there) + uses: actions/checkout@v4 + with: + repository: PyAutoLabs/PyAutoBrain + path: .brain + fetch-depth: 1 + + - name: Sweep + env: + GH_TOKEN: ${{ github.token }} + run: | + set -o pipefail + # A scheduled run never deletes, whatever anyone edits into the cron. + mode='${{ inputs.mode }}' + if [ '${{ github.event_name }}' != 'workflow_dispatch' ]; then + mode=audit + fi + mode="${mode:-audit}" + + .brain/bin/branch_sweep.sh \ + --repo "$GITHUB_WORKSPACE" \ + --owner '${{ github.repository_owner }}' \ + --name '${{ github.event.repository.name }}' \ + --mode "$mode" \ + --limit '${{ inputs.limit || 0 }}' 2>&1 | tee sweep.log + + # The run summary is the readable surface on a phone. + { + echo "## Branch sweep — \`$mode\`" + echo + echo '```' + cat sweep.log + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/bin/branch_sweep.sh b/bin/branch_sweep.sh new file mode 100755 index 0000000..dcd9aed --- /dev/null +++ b/bin/branch_sweep.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +# branch_sweep.sh — the executable half of the repo_cleanup sweep, in a form +# that runs where the *session* cannot. +# +# WHY THIS EXISTS. A cloud/web Claude session (phone, claude.ai/code) can read +# every repo but cannot delete a remote ref: `git push origin --delete` comes +# back 403 from GitHub, and the GitHub tool surface exposed to those sessions +# has no delete-ref call at all. The proxy is not the blocker (it logs no relay +# failure) — the session credential simply is not allowed to remove refs. That +# left branch cleanup a laptop-only task, which is why 233 branches had piled +# up across Mind and Brain by 2026-08-25. +# +# A workflow's GITHUB_TOKEN is a different credential with `contents: write` — +# the same class that already self-heals dashboard.md straight onto main. So +# the sweep runs *inside the repo* on Actions, and any surface that can +# dispatch a workflow (mobile chat included) can drive it. +# +# WHAT IT WILL NOT DO. Deletion is irreversible from the branch's point of +# view, so the safety gates from skills/repo_cleanup/SKILL.md are enforced here +# rather than assumed of the caller: +# +# * `main` / the default branch — never a candidate +# * `archive/condemned/*` — PyAutoGut transit refs; +# voiding these before their sweep-after date destroys the recovery path +# the Gut exists to provide. The Gut voids them, not us. +# * any branch that is the head of an OPEN pull request +# * any branch git cannot prove is already contained in the base +# +# Containment is decided by branch_contribution.sh — the blessed tool, never a +# hand-rolled ahead-count (see docs/agent_failure_modes.md D1/D2: that question +# was got wrong three times in one day). MERGED and ABSORBED are certain. A +# CONTRIBUTES branch is deleted ONLY if it clears the squash check below. +# +# THE SQUASH CHECK. git 2.34 cannot see through a squash-merge: the branch +# reads CONTRIBUTES even though main holds every line of it. Rather than trust +# that, we prove it — find the first-parent commit on the base whose subject +# ends `(#N)` for a PR N whose head SHA is this branch's tip, and require its +# patch-id to equal the branch's own diff. Same content, arrived by a different +# route. Anything short of an exact match stays. +# +# Usage: +# branch_sweep.sh --repo --owner --name [--mode audit|delete] +# [--base origin/main] [--limit N] +# +# Exit: 0 clean · 1 usage/setup error · 2 one or more deletions failed. + +set -uo pipefail + +REPO="" OWNER="" NAME="" MODE="audit" BASE="origin/main" LIMIT=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --repo) REPO="$2"; shift 2 ;; + --owner) OWNER="$2"; shift 2 ;; + --name) NAME="$2"; shift 2 ;; + --mode) MODE="$2"; shift 2 ;; + --base) BASE="$2"; shift 2 ;; + --limit) LIMIT="$2"; shift 2 ;; + *) echo "branch_sweep: unknown argument '$1'" >&2; exit 1 ;; + esac +done +[[ -n "$REPO" && -n "$OWNER" && -n "$NAME" ]] || { + echo "usage: branch_sweep.sh --repo --owner --name [--mode audit|delete]" >&2 + exit 1 +} +[[ "$MODE" == "audit" || "$MODE" == "delete" ]] || { + echo "branch_sweep: --mode must be 'audit' or 'delete' (got '$MODE')" >&2; exit 1 +} + +g() { git -C "$REPO" "$@"; } + +# --- prerequisites, before anything is touched ------------------------------- +# Checked up front, and deliberately before the fetches below: without gh we +# cannot see open PRs, so we would refuse to sweep anyway — and refusing after +# rewriting the caller's clone (an unshallow is not free and not undoable) +# would be a rude way to say no. +command -v gh >/dev/null 2>&1 || { + echo "branch_sweep: gh not found — cannot rule out open PRs, refusing to sweep" >&2 + exit 1 +} +open_prs=$(gh pr list --repo "$OWNER/$NAME" --state open --limit 500 \ + --json headRefName --jq '.[].headRefName' 2>/dev/null) +if [[ -z "$open_prs" ]] && ! gh auth status >/dev/null 2>&1; then + echo "branch_sweep: gh is not authenticated — cannot rule out open PRs" >&2 + exit 1 +fi + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=branch_contribution.sh +source "$here/branch_contribution.sh" + +# --- history the verdicts depend on ----------------------------------------- +# A shallow clone makes every ancestry question wrong in the same direction: +# nothing looks contained, so a MERGED branch reports CONTRIBUTES and the sweep +# silently protects everything. Deepen before asking anything. +# +# Ask git the question directly. Testing for a `shallow` file under +# `rev-parse --git-dir` does NOT work: with `-C` that path comes back relative +# to *our* cwd, not the repo's, so the answer is really "is the process's own +# directory a shallow clone?" — which on an Actions runner (whose checkout is +# shallow by default) is a confident yes about entirely the wrong repository. +if [[ "$(g rev-parse --is-shallow-repository 2>/dev/null)" == "true" ]]; then + echo "→ unshallowing (verdicts are meaningless on a truncated history)" + g fetch --unshallow --quiet origin || { echo "branch_sweep: unshallow failed" >&2; exit 1; } +fi +g fetch --prune --quiet origin || { echo "branch_sweep: fetch failed" >&2; exit 1; } +# PR head refs let us match a branch tip to the PR that carried it. +g fetch --quiet origin '+refs/pull/*/head:refs/remotes/pr/*' 2>/dev/null || true +g rev-parse --verify --quiet "$BASE^{commit}" >/dev/null || { + echo "branch_sweep: base '$BASE' not found" >&2; exit 1; } + +# --- PR tip index: branch tip SHA -> PR numbers ------------------------------ +declare -A PR_FOR_SHA +while read -r sha ref; do + PR_FOR_SHA[$sha]="${PR_FOR_SHA[$sha]:-} ${ref#refs/remotes/pr/}" +done < <(g for-each-ref --format='%(objectname) %(refname)' refs/remotes/pr) + +# Squash-merge commits land on the base as first-parent subjects ending `(#N)`. +mapfile -t landed < <(g log --first-parent --format='%s' "$BASE" \ + | sed -nE 's|.*\(#([0-9]+)\)$|\1|p' | sort -u) +landed_set=" ${landed[*]} " + +# The authoritative answer, when the runner can reach the API: ask GitHub +# whether a MERGED pull request carried exactly this branch at exactly this +# tip. Nothing local can beat this — the patch-id proof below exists only for +# the offline case, and it is strictly more conservative (on the 2026-08-25 +# sweep it cleared 13 of PyAutoMind's 19 squash-merges; this cleared all 19). +merged_pr_for() { + local branch="$1" tip + tip=$(g rev-parse "$branch") + gh api "repos/$OWNER/$NAME/commits/$tip/pulls" \ + --jq ".[] | select(.merged_at != null and .head.ref == \"${branch#origin/}\") | .number" \ + 2>/dev/null | head -1 +} + +# Returns 0 and echoes the proving commit when `branch` is a confirmed squash +# of a PR already on the base; returns 1 otherwise. +squash_proof() { + local branch="$1" tip commit base_of pid_branch pid_commit n + tip=$(g rev-parse "$branch") + for n in ${PR_FOR_SHA[$tip]:-}; do + [[ "$landed_set" == *" $n "* ]] || continue + commit=$(g log --first-parent --format='%H %s' "$BASE" \ + | awk -v n="$n" '$0 ~ "\\(#"n"\\)$" {print $1; exit}') + [[ -n "$commit" ]] || continue + base_of=$(g merge-base "$commit^" "$tip") || continue + pid_branch=$(g diff "$base_of" "$tip" | git patch-id --stable | cut -d' ' -f1) + pid_commit=$(g diff "$commit^" "$commit" | git patch-id --stable | cut -d' ' -f1) + if [[ -n "$pid_branch" && "$pid_branch" == "$pid_commit" ]]; then + echo "$n:${commit:0:8}"; return 0 + fi + done + return 1 +} + +default_branch=$(g symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null) +default_branch="${default_branch#origin/}" +default_branch="${default_branch:-main}" + +# --- classify ---------------------------------------------------------------- +safe=() keep=() protected=() +while read -r b; do + [[ -n "$b" && "$b" != "HEAD" && "$b" != "$default_branch" ]] || continue + case "$b" in + archive/condemned/*) protected+=("$b gut-transit-ref"); continue ;; + esac + if [[ -n "$open_prs" ]] && grep -qxF "$b" <<<"$open_prs"; then + protected+=("$b open-pr"); continue + fi + verdict=$(branch_contribution "$REPO" "origin/$b" "$BASE"); word=${verdict%% *} + case "$word" in + MERGED|ABSORBED) safe+=("$b $word") ;; + CONTRIBUTES) + # git says this has unique content. It may still be a squash-merge + # git 2.34 cannot see through — so ask GitHub, then fall back to + # proving it locally. Unproven means kept, never deleted. + if pr=$(merged_pr_for "origin/$b") && [[ -n "$pr" ]]; then + safe+=("$b MERGED-PR#$pr") + elif proof=$(squash_proof "origin/$b"); then + safe+=("$b SQUASHED(PR#${proof%%:*})") + else + keep+=("$b unmerged") + fi ;; + *) keep+=("$b $word") ;; # UNKNOWN is never safe + esac +done < <(g for-each-ref --format='%(refname:short)' refs/remotes/origin | sed 's|^origin/||') + +echo +echo "Branch sweep — $OWNER/$NAME (mode: $MODE, base: $BASE)" +echo " ${#safe[@]} contained · ${#keep[@]} unmerged · ${#protected[@]} protected" +echo +[[ ${#protected[@]} -gt 0 ]] && { echo "PROTECTED (never swept)"; printf ' %s\n' "${protected[@]}"; echo; } +[[ ${#keep[@]} -gt 0 ]] && { echo "KEEP (unique content)"; printf ' %s\n' "${keep[@]}"; echo; } +[[ ${#safe[@]} -eq 0 ]] && { echo "Nothing to sweep."; exit 0; } + +echo "CONTAINED IN $BASE" +printf ' %s\n' "${safe[@]}" +echo + +if [[ "$MODE" == "audit" ]]; then + echo "audit mode — nothing deleted. Re-dispatch with mode=delete to act." + exit 0 +fi + +# --- delete ------------------------------------------------------------------ +deleted=0 failed=0 n=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)) + else + echo " FAILED $b"; failed=$((failed + 1)) + fi +done +echo +echo "→ $deleted deleted, $failed failed, ${#keep[@]} kept, ${#protected[@]} protected" +[[ "$failed" -eq 0 ]] || exit 2 diff --git a/skills/repo_cleanup/SKILL.md b/skills/repo_cleanup/SKILL.md index 66a0a44..7eb983a 100644 --- a/skills/repo_cleanup/SKILL.md +++ b/skills/repo_cleanup/SKILL.md @@ -63,6 +63,19 @@ and worktree roots under `$PYAUTO_WT_ROOT`. `euclid_strong_lens_modeling_pipeline`, `autolens_assistant`, and anything not listed above. Skip any entry that is missing, not a git repo, or a bare symlink. +## Where this runs + +**Local session (laptop CLI):** the full sweep below — canonical checkouts, +worktrees, stashes, the lot. + +**Cloud session (phone, claude.ai/code):** there is no local tree, and the +session credential **cannot delete a remote ref** (`git push origin --delete` +returns 403; the GitHub tool surface has no delete-ref call). Do not attempt it +and do not report the cleanup as blocked — **dispatch the repo's own +`branch_sweep.yml`**, which runs the same gates under a `contents: write` +`GITHUB_TOKEN`. Recipe: [`reference.md`](reference.md) → "Execution +environments". Branch buckets only; stashes and worktrees stay laptop-only. + ## Steps ### 1. Setup @@ -108,5 +121,8 @@ Format: [`reference.md`](reference.md) → "Recap". - Skip missing / non-git / detached-HEAD repos with a one-line note. - Never skip hooks, never force-push — these are read/prune/delete operations only. - Suggest `/health worktrees` first if unsure whether tasks are in flight. -- Execution-environment fallback (remote-audit-only when there's no local tree) - is in [`reference.md`](reference.md). +- A repo that still accumulates merged PR heads has **"Automatically delete head + branches" off** (Settings → General). Say so once — that setting prevents the + backlog this skill exists to clear, and no sweep substitutes for it. +- Cloud-session routing (dispatch `branch_sweep.yml` rather than pushing + deletes) is in [`reference.md`](reference.md) → "Execution environments". diff --git a/skills/repo_cleanup/reference.md b/skills/repo_cleanup/reference.md index 05049eb..87ce11c 100644 --- a/skills/repo_cleanup/reference.md +++ b/skills/repo_cleanup/reference.md @@ -116,17 +116,39 @@ List anything the user opted to keep so the next sweep resumes from there. ## Execution environments -In a web-github / analysis-only session (no local checkout) destructive git ops -are unavailable — degrade to a **remote audit only**: skip all `git -C`; for each -in-scope repo enumerate remote branches -(`gh api repos///branches --jq '.[].name' --paginate`), cross-check -open PRs (`gh pr list --head --state open`), and report branches with no -open PR that are merged to `main` -(`gh api repos///compare/main...` → `ahead_by == 0`) as -**candidates** only. Do not delete remotely; recommend running on a local-dev -checkout to complete the sweep (which re-audits against local state). Skip -stash/dirty sections. Resolve each repo's `/` from the `github:` +A cloud session (phone, claude.ai/code) has no local tree **and cannot delete a +remote ref**: `git push origin --delete` returns 403 for the session credential +— the proxy is not the blocker, it logs no relay failure — and the GitHub tool +surface exposed to those sessions has no delete-ref call. Try it once and you +have learned nothing the next session does not already know; do not retry, and +do not route around it. + +Instead **dispatch the repo's own sweep**, which runs the same gates under a +workflow `GITHUB_TOKEN` that does have `contents: write`: + +1. `mcp__github__actions_run_trigger` → `run_workflow`, `workflow_id: + branch_sweep.yml`, `ref: main`, `inputs: {mode: audit}`. +2. Poll `mcp__github__actions_list` → `list_workflow_runs` for that workflow; + read the finished run's summary (its step summary carries the full report). +3. Show the user the contained / unmerged / protected split and get approval + exactly as for Bucket B locally. +4. Re-dispatch with `mode: delete` (add `limit` for a first cautious batch). + +`branch_sweep.yml` currently exists in **PyAutoMind** and **PyAutoBrain**. For a +repo without it, say so and offer to add it rather than falling back to +hand-deletion — the file is repo-independent (it reads `github.repository`), so +adding it is a copy. + +**The setting that makes this mostly unnecessary.** A repo whose merged PR heads +survive has *Settings → General → "Automatically delete head branches"* off. +That single toggle removes each head at merge and stops the backlog forming; +this sweep is the backstop for what predates it, for branches pushed without a +PR, and for heads whose PR closed unmerged. Claude cannot set it (no +repo-settings tool) — flag it for the human once, then move on. + +Whichever environment: resolve each repo's `/` from the `github:` field in `PyAutoMind/repos.yaml` (the body map is the single source of repo identity) — do not assume a default owner. Nearly every repo is under `PyAutoLabs/`; the pre-migration `rhayes777/` and `Jammy2211/` homes are gone -bar the two the body map still records. +bar the two the body map still records. Stash and dirty-checkout buckets have no +cloud equivalent — skip them and say so. diff --git a/tests/test_branch_sweep.py b/tests/test_branch_sweep.py new file mode 100644 index 0000000..0f2a52c --- /dev/null +++ b/tests/test_branch_sweep.py @@ -0,0 +1,162 @@ +"""tests/test_branch_sweep.py — what the sweep REFUSES to delete. + +branch_sweep.sh is the one PyAuto script whose job is irreversible from the +branch's point of view, and it runs unattended on Actions where nobody sees the +dashboard before it acts. Its value is therefore not the deletions — those are +one `git push --delete` — but the four gates that keep a branch out of the +delete set. Each gate gets a known-answer repo here, in the spirit of +test_branch_contribution.py: a gate that silently stopped working would look +exactly like a clean sweep. + +The gates, and what breaking each one would cost: + + main / default branch the repo + archive/condemned/* PyAutoGut's recovery path — these refs ARE the + backup for condemned work, so voiding one early + destroys the only copy + open PR heads someone's in-flight review + unproven CONTRIBUTES unmerged work, gone + +`gh` is stubbed: the script refuses to run without it (it cannot rule out open +PRs blind), and these tests pin that refusal too. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +TOOL = Path(__file__).resolve().parents[1] / "bin" / "branch_sweep.sh" + + +def _git(repo: Path, *args: str) -> str: + return subprocess.run( + ["git", "-C", str(repo), *args], check=True, capture_output=True, text=True + ).stdout + + +def _commit(repo: Path, name: str, body: str, message: str) -> None: + (repo / name).write_text(body) + _git(repo, "add", name) + _git(repo, "commit", "-qm", message) + + +@pytest.fixture +def world(tmp_path: Path): + """An origin with one branch per verdict, and a clone that sweeps it.""" + 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") + _git(origin, "config", "receive.denyDeleteCurrent", "ignore") + _commit(origin, "f", "base", "base") + + # merged: folded into main, so main contains its tip + _git(origin, "checkout", "-q", "-b", "merged") + _commit(origin, "m", "m", "merged work") + _git(origin, "checkout", "-q", "main") + _git(origin, "merge", "-q", "--no-ff", "merged", "-m", "Merge merged") + + # unmerged: unique content main has never seen + _git(origin, "checkout", "-q", "-b", "unmerged", "main") + _commit(origin, "u", "u", "unmerged work") + + # an open PR's head, and a Gut transit ref — both fully merged, so ONLY + # their protection can keep them out of the delete set + for name in ("open-pr-head", "archive/condemned/something"): + _git(origin, "checkout", "-q", "-b", name, "main") + _git(origin, "checkout", "-q", "main") + + clone = tmp_path / "clone" + subprocess.run( + ["git", "clone", "-q", str(origin), str(clone)], check=True + ) + _git(clone, "config", "user.email", "t@t") + _git(clone, "config", "user.name", "t") + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + (bin_dir / "gh").write_text( + "#!/usr/bin/env bash\n" + 'if [[ "$1" == "auth" ]]; then exit 0; fi\n' + 'if [[ "$1" == "api" ]]; then exit 0; fi\n' + 'echo "open-pr-head"\n' + "exit 0\n" + ) + (bin_dir / "gh").chmod(0o755) + return clone, origin, bin_dir + + +def _gh_free_path(tmp_path: Path) -> str: + """A PATH with everything the script needs except `gh`. + + Emptying PATH would remove bash too and prove nothing, so link in the tools + the script actually calls and leave `gh` out. + """ + lean = tmp_path / "nogh" + lean.mkdir(exist_ok=True) + for tool in ("bash", "git", "sed", "awk", "grep", "sort", "head", "cut", "tr", + "dirname", "basename", "cat", "wc", "env", "uniq"): + found = shutil.which(tool) + if found and not (lean / tool).exists(): + (lean / tool).symlink_to(found) + return str(lean) + + +def _sweep(clone: Path, bin_dir: Path | None, mode: str = "audit") -> subprocess.CompletedProcess: + env = dict(os.environ) + if bin_dir is not None: + env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}" + else: + env["PATH"] = _gh_free_path(clone.parent) + return subprocess.run( + ["bash", str(TOOL), "--repo", str(clone), "--owner", "o", "--name", "n", + "--mode", mode], + capture_output=True, text=True, env=env, timeout=120, + ) + + +def test_audit_classifies_every_branch(world): + clone, _, bin_dir = world + out = _sweep(clone, bin_dir).stdout + assert "merged\tMERGED" in out + assert "unmerged\tunmerged" in out + assert "open-pr-head\topen-pr" in out + assert "archive/condemned/something\tgut-transit-ref" in out + + +def test_audit_never_deletes(world): + clone, origin, bin_dir = world + before = _git(origin, "for-each-ref", "--format=%(refname)", "refs/heads") + assert _sweep(clone, bin_dir, mode="audit").returncode == 0 + assert _git(origin, "for-each-ref", "--format=%(refname)", "refs/heads") == before + + +def test_delete_removes_only_the_contained_branch(world): + clone, origin, bin_dir = world + assert _sweep(clone, bin_dir, mode="delete").returncode == 0 + remaining = _git(origin, "for-each-ref", "--format=%(refname:short)", "refs/heads") + remaining = set(remaining.split()) + assert "merged" not in remaining, "a contained branch should have been swept" + # every gate held + assert {"main", "unmerged", "open-pr-head", "archive/condemned/something"} <= remaining + + +def test_refuses_to_run_without_gh(world): + """Blind to open PRs means blind to in-flight work: refuse, do not guess.""" + clone, _, _ = world + proc = _sweep(clone, None) + assert proc.returncode == 1 + assert "gh not found" in proc.stderr + + +def test_rejects_unknown_mode(world): + clone, _, bin_dir = world + proc = _sweep(clone, bin_dir, mode="purge") + assert proc.returncode == 1 + assert "must be 'audit' or 'delete'" in proc.stderr