|
| 1 | +#!/usr/bin/env bash |
| 2 | +# branch_sweep.sh — the executable half of the repo_cleanup sweep, in a form |
| 3 | +# that runs where the *session* cannot. |
| 4 | +# |
| 5 | +# WHY THIS EXISTS. A cloud/web Claude session (phone, claude.ai/code) can read |
| 6 | +# every repo but cannot delete a remote ref: `git push origin --delete` comes |
| 7 | +# back 403 from GitHub, and the GitHub tool surface exposed to those sessions |
| 8 | +# has no delete-ref call at all. The proxy is not the blocker (it logs no relay |
| 9 | +# failure) — the session credential simply is not allowed to remove refs. That |
| 10 | +# left branch cleanup a laptop-only task, which is why 233 branches had piled |
| 11 | +# up across Mind and Brain by 2026-08-25. |
| 12 | +# |
| 13 | +# A workflow's GITHUB_TOKEN is a different credential with `contents: write` — |
| 14 | +# the same class that already self-heals dashboard.md straight onto main. So |
| 15 | +# the sweep runs *inside the repo* on Actions, and any surface that can |
| 16 | +# dispatch a workflow (mobile chat included) can drive it. |
| 17 | +# |
| 18 | +# WHAT IT WILL NOT DO. Deletion is irreversible from the branch's point of |
| 19 | +# view, so the safety gates from skills/repo_cleanup/SKILL.md are enforced here |
| 20 | +# rather than assumed of the caller: |
| 21 | +# |
| 22 | +# * `main` / the default branch — never a candidate |
| 23 | +# * `archive/condemned/*` — PyAutoGut transit refs; |
| 24 | +# voiding these before their sweep-after date destroys the recovery path |
| 25 | +# the Gut exists to provide. The Gut voids them, not us. |
| 26 | +# * any branch that is the head of an OPEN pull request |
| 27 | +# * any branch git cannot prove is already contained in the base |
| 28 | +# |
| 29 | +# Containment is decided by branch_contribution.sh — the blessed tool, never a |
| 30 | +# hand-rolled ahead-count (see docs/agent_failure_modes.md D1/D2: that question |
| 31 | +# was got wrong three times in one day). MERGED and ABSORBED are certain. A |
| 32 | +# CONTRIBUTES branch is deleted ONLY if it clears the squash check below. |
| 33 | +# |
| 34 | +# THE SQUASH CHECK. git 2.34 cannot see through a squash-merge: the branch |
| 35 | +# reads CONTRIBUTES even though main holds every line of it. Rather than trust |
| 36 | +# that, we prove it — find the first-parent commit on the base whose subject |
| 37 | +# ends `(#N)` for a PR N whose head SHA is this branch's tip, and require its |
| 38 | +# patch-id to equal the branch's own diff. Same content, arrived by a different |
| 39 | +# route. Anything short of an exact match stays. |
| 40 | +# |
| 41 | +# Usage: |
| 42 | +# branch_sweep.sh --repo <path> --owner <o> --name <n> [--mode audit|delete] |
| 43 | +# [--base origin/main] [--limit N] |
| 44 | +# |
| 45 | +# Exit: 0 clean · 1 usage/setup error · 2 one or more deletions failed. |
| 46 | + |
| 47 | +set -uo pipefail |
| 48 | + |
| 49 | +REPO="" OWNER="" NAME="" MODE="audit" BASE="origin/main" LIMIT=0 |
| 50 | +while [[ $# -gt 0 ]]; do |
| 51 | + case "$1" in |
| 52 | + --repo) REPO="$2"; shift 2 ;; |
| 53 | + --owner) OWNER="$2"; shift 2 ;; |
| 54 | + --name) NAME="$2"; shift 2 ;; |
| 55 | + --mode) MODE="$2"; shift 2 ;; |
| 56 | + --base) BASE="$2"; shift 2 ;; |
| 57 | + --limit) LIMIT="$2"; shift 2 ;; |
| 58 | + *) echo "branch_sweep: unknown argument '$1'" >&2; exit 1 ;; |
| 59 | + esac |
| 60 | +done |
| 61 | +[[ -n "$REPO" && -n "$OWNER" && -n "$NAME" ]] || { |
| 62 | + echo "usage: branch_sweep.sh --repo <path> --owner <o> --name <n> [--mode audit|delete]" >&2 |
| 63 | + exit 1 |
| 64 | +} |
| 65 | +[[ "$MODE" == "audit" || "$MODE" == "delete" ]] || { |
| 66 | + echo "branch_sweep: --mode must be 'audit' or 'delete' (got '$MODE')" >&2; exit 1 |
| 67 | +} |
| 68 | + |
| 69 | +g() { git -C "$REPO" "$@"; } |
| 70 | + |
| 71 | +# --- prerequisites, before anything is touched ------------------------------- |
| 72 | +# Checked up front, and deliberately before the fetches below: without gh we |
| 73 | +# cannot see open PRs, so we would refuse to sweep anyway — and refusing after |
| 74 | +# rewriting the caller's clone (an unshallow is not free and not undoable) |
| 75 | +# would be a rude way to say no. |
| 76 | +command -v gh >/dev/null 2>&1 || { |
| 77 | + echo "branch_sweep: gh not found — cannot rule out open PRs, refusing to sweep" >&2 |
| 78 | + exit 1 |
| 79 | +} |
| 80 | +open_prs=$(gh pr list --repo "$OWNER/$NAME" --state open --limit 500 \ |
| 81 | + --json headRefName --jq '.[].headRefName' 2>/dev/null) |
| 82 | +if [[ -z "$open_prs" ]] && ! gh auth status >/dev/null 2>&1; then |
| 83 | + echo "branch_sweep: gh is not authenticated — cannot rule out open PRs" >&2 |
| 84 | + exit 1 |
| 85 | +fi |
| 86 | + |
| 87 | +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
| 88 | +# shellcheck source=branch_contribution.sh |
| 89 | +source "$here/branch_contribution.sh" |
| 90 | + |
| 91 | +# --- history the verdicts depend on ----------------------------------------- |
| 92 | +# A shallow clone makes every ancestry question wrong in the same direction: |
| 93 | +# nothing looks contained, so a MERGED branch reports CONTRIBUTES and the sweep |
| 94 | +# silently protects everything. Deepen before asking anything. |
| 95 | +# |
| 96 | +# Ask git the question directly. Testing for a `shallow` file under |
| 97 | +# `rev-parse --git-dir` does NOT work: with `-C` that path comes back relative |
| 98 | +# to *our* cwd, not the repo's, so the answer is really "is the process's own |
| 99 | +# directory a shallow clone?" — which on an Actions runner (whose checkout is |
| 100 | +# shallow by default) is a confident yes about entirely the wrong repository. |
| 101 | +if [[ "$(g rev-parse --is-shallow-repository 2>/dev/null)" == "true" ]]; then |
| 102 | + echo "→ unshallowing (verdicts are meaningless on a truncated history)" |
| 103 | + g fetch --unshallow --quiet origin || { echo "branch_sweep: unshallow failed" >&2; exit 1; } |
| 104 | +fi |
| 105 | +g fetch --prune --quiet origin || { echo "branch_sweep: fetch failed" >&2; exit 1; } |
| 106 | +# PR head refs let us match a branch tip to the PR that carried it. |
| 107 | +g fetch --quiet origin '+refs/pull/*/head:refs/remotes/pr/*' 2>/dev/null || true |
| 108 | +g rev-parse --verify --quiet "$BASE^{commit}" >/dev/null || { |
| 109 | + echo "branch_sweep: base '$BASE' not found" >&2; exit 1; } |
| 110 | + |
| 111 | +# --- PR tip index: branch tip SHA -> PR numbers ------------------------------ |
| 112 | +declare -A PR_FOR_SHA |
| 113 | +while read -r sha ref; do |
| 114 | + PR_FOR_SHA[$sha]="${PR_FOR_SHA[$sha]:-} ${ref#refs/remotes/pr/}" |
| 115 | +done < <(g for-each-ref --format='%(objectname) %(refname)' refs/remotes/pr) |
| 116 | + |
| 117 | +# Squash-merge commits land on the base as first-parent subjects ending `(#N)`. |
| 118 | +mapfile -t landed < <(g log --first-parent --format='%s' "$BASE" \ |
| 119 | + | sed -nE 's|.*\(#([0-9]+)\)$|\1|p' | sort -u) |
| 120 | +landed_set=" ${landed[*]} " |
| 121 | + |
| 122 | +# The authoritative answer, when the runner can reach the API: ask GitHub |
| 123 | +# whether a MERGED pull request carried exactly this branch at exactly this |
| 124 | +# tip. Nothing local can beat this — the patch-id proof below exists only for |
| 125 | +# the offline case, and it is strictly more conservative (on the 2026-08-25 |
| 126 | +# sweep it cleared 13 of PyAutoMind's 19 squash-merges; this cleared all 19). |
| 127 | +merged_pr_for() { |
| 128 | + local branch="$1" tip |
| 129 | + tip=$(g rev-parse "$branch") |
| 130 | + gh api "repos/$OWNER/$NAME/commits/$tip/pulls" \ |
| 131 | + --jq ".[] | select(.merged_at != null and .head.ref == \"${branch#origin/}\") | .number" \ |
| 132 | + 2>/dev/null | head -1 |
| 133 | +} |
| 134 | + |
| 135 | +# Returns 0 and echoes the proving commit when `branch` is a confirmed squash |
| 136 | +# of a PR already on the base; returns 1 otherwise. |
| 137 | +squash_proof() { |
| 138 | + local branch="$1" tip commit base_of pid_branch pid_commit n |
| 139 | + tip=$(g rev-parse "$branch") |
| 140 | + for n in ${PR_FOR_SHA[$tip]:-}; do |
| 141 | + [[ "$landed_set" == *" $n "* ]] || continue |
| 142 | + commit=$(g log --first-parent --format='%H %s' "$BASE" \ |
| 143 | + | awk -v n="$n" '$0 ~ "\\(#"n"\\)$" {print $1; exit}') |
| 144 | + [[ -n "$commit" ]] || continue |
| 145 | + base_of=$(g merge-base "$commit^" "$tip") || continue |
| 146 | + pid_branch=$(g diff "$base_of" "$tip" | git patch-id --stable | cut -d' ' -f1) |
| 147 | + pid_commit=$(g diff "$commit^" "$commit" | git patch-id --stable | cut -d' ' -f1) |
| 148 | + if [[ -n "$pid_branch" && "$pid_branch" == "$pid_commit" ]]; then |
| 149 | + echo "$n:${commit:0:8}"; return 0 |
| 150 | + fi |
| 151 | + done |
| 152 | + return 1 |
| 153 | +} |
| 154 | + |
| 155 | +default_branch=$(g symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null) |
| 156 | +default_branch="${default_branch#origin/}" |
| 157 | +default_branch="${default_branch:-main}" |
| 158 | + |
| 159 | +# --- classify ---------------------------------------------------------------- |
| 160 | +safe=() keep=() protected=() |
| 161 | +while read -r b; do |
| 162 | + [[ -n "$b" && "$b" != "HEAD" && "$b" != "$default_branch" ]] || continue |
| 163 | + case "$b" in |
| 164 | + archive/condemned/*) protected+=("$b gut-transit-ref"); continue ;; |
| 165 | + esac |
| 166 | + if [[ -n "$open_prs" ]] && grep -qxF "$b" <<<"$open_prs"; then |
| 167 | + protected+=("$b open-pr"); continue |
| 168 | + fi |
| 169 | + verdict=$(branch_contribution "$REPO" "origin/$b" "$BASE"); word=${verdict%% *} |
| 170 | + case "$word" in |
| 171 | + MERGED|ABSORBED) safe+=("$b $word") ;; |
| 172 | + CONTRIBUTES) |
| 173 | + # git says this has unique content. It may still be a squash-merge |
| 174 | + # git 2.34 cannot see through — so ask GitHub, then fall back to |
| 175 | + # proving it locally. Unproven means kept, never deleted. |
| 176 | + if pr=$(merged_pr_for "origin/$b") && [[ -n "$pr" ]]; then |
| 177 | + safe+=("$b MERGED-PR#$pr") |
| 178 | + elif proof=$(squash_proof "origin/$b"); then |
| 179 | + safe+=("$b SQUASHED(PR#${proof%%:*})") |
| 180 | + else |
| 181 | + keep+=("$b unmerged") |
| 182 | + fi ;; |
| 183 | + *) keep+=("$b $word") ;; # UNKNOWN is never safe |
| 184 | + esac |
| 185 | +done < <(g for-each-ref --format='%(refname:short)' refs/remotes/origin | sed 's|^origin/||') |
| 186 | + |
| 187 | +echo |
| 188 | +echo "Branch sweep — $OWNER/$NAME (mode: $MODE, base: $BASE)" |
| 189 | +echo " ${#safe[@]} contained · ${#keep[@]} unmerged · ${#protected[@]} protected" |
| 190 | +echo |
| 191 | +[[ ${#protected[@]} -gt 0 ]] && { echo "PROTECTED (never swept)"; printf ' %s\n' "${protected[@]}"; echo; } |
| 192 | +[[ ${#keep[@]} -gt 0 ]] && { echo "KEEP (unique content)"; printf ' %s\n' "${keep[@]}"; echo; } |
| 193 | +[[ ${#safe[@]} -eq 0 ]] && { echo "Nothing to sweep."; exit 0; } |
| 194 | + |
| 195 | +echo "CONTAINED IN $BASE" |
| 196 | +printf ' %s\n' "${safe[@]}" |
| 197 | +echo |
| 198 | + |
| 199 | +if [[ "$MODE" == "audit" ]]; then |
| 200 | + echo "audit mode — nothing deleted. Re-dispatch with mode=delete to act." |
| 201 | + exit 0 |
| 202 | +fi |
| 203 | + |
| 204 | +# --- delete ------------------------------------------------------------------ |
| 205 | +deleted=0 failed=0 n=0 |
| 206 | +for entry in "${safe[@]}"; do |
| 207 | + b="${entry%% *}" |
| 208 | + if [[ "$LIMIT" -gt 0 && "$n" -ge "$LIMIT" ]]; then |
| 209 | + echo " (limit $LIMIT reached — $(( ${#safe[@]} - n )) left for the next run)"; break |
| 210 | + fi |
| 211 | + n=$((n + 1)) |
| 212 | + if g push origin --delete "$b" >/dev/null 2>&1; then |
| 213 | + echo " deleted $b"; deleted=$((deleted + 1)) |
| 214 | + else |
| 215 | + echo " FAILED $b"; failed=$((failed + 1)) |
| 216 | + fi |
| 217 | +done |
| 218 | +echo |
| 219 | +echo "→ $deleted deleted, $failed failed, ${#keep[@]} kept, ${#protected[@]} protected" |
| 220 | +[[ "$failed" -eq 0 ]] || exit 2 |
0 commit comments