Skip to content

Commit 248331c

Browse files
authored
Merge pull request #278 from PyAutoLabs/claude/repo-cleanup-82k6kh
feature(repo_cleanup): make branch deletion reachable from a cloud session
2 parents 5fd1d87 + e03547f commit 248331c

5 files changed

Lines changed: 533 additions & 13 deletions

File tree

.github/workflows/branch_sweep.yml

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
name: Branch Sweep
2+
3+
# Deletes feature branches whose content is already in `main`, and reports the
4+
# ones it will not touch.
5+
#
6+
# WHY A WORKFLOW AND NOT THE SESSION. A cloud Claude session — the phone, or
7+
# claude.ai/code — can audit branches perfectly well but cannot remove one:
8+
# `git push origin --delete` returns 403 for the session credential, and the
9+
# GitHub tool surface those sessions get has no delete-ref call at all. So
10+
# branch cleanup was a laptop-only chore, and it showed: 233 branches across
11+
# Mind and Brain by 2026-08-25, 188 of them provably spent, because nothing
12+
# deletes a merged head automatically.
13+
#
14+
# A workflow's GITHUB_TOKEN is a *different* credential, and this repo already
15+
# trusts it with `contents: write` (dashboard_refresh.yml commits to main with
16+
# it). Running the sweep here means any surface that can dispatch a workflow
17+
# can drive the cleanup — including a chat on a phone.
18+
#
19+
# THIS IS THE BACKSTOP, NOT THE FIX. The primary fix is the repo setting
20+
# Settings → General → "Automatically delete head branches", which removes each
21+
# PR head at merge and prevents the pile-up in the first place. This workflow
22+
# exists for the backlog that predates it, for branches pushed without a PR,
23+
# and for heads whose PR was closed unmerged.
24+
#
25+
# The safety gates (never `main`, never `archive/condemned/*` Gut transit refs,
26+
# never an open PR's head, never a branch git cannot prove is contained) live
27+
# in the script, not here — see PyAutoBrain/bin/branch_sweep.sh and
28+
# PyAutoBrain/skills/repo_cleanup/SKILL.md.
29+
30+
on:
31+
workflow_dispatch:
32+
inputs:
33+
mode:
34+
description: "audit = report only · delete = actually remove"
35+
type: choice
36+
options: [audit, delete]
37+
default: audit
38+
limit:
39+
description: "Max branches to delete (0 = no cap). Ignored in audit mode."
40+
type: string
41+
default: "0"
42+
schedule:
43+
# Weekly, audit-only: keeps the backlog visible without ever acting
44+
# unattended. Deletion always requires someone to dispatch it.
45+
- cron: "10 4 * * 0"
46+
47+
permissions:
48+
contents: write
49+
pull-requests: read
50+
51+
concurrency:
52+
group: branch-sweep-${{ github.repository }}
53+
cancel-in-progress: false
54+
55+
jobs:
56+
sweep:
57+
runs-on: ubuntu-latest
58+
steps:
59+
- name: Check out this repo (full history)
60+
uses: actions/checkout@v4
61+
with:
62+
# Containment is an ancestry question: on a shallow clone every
63+
# branch looks unmerged, so the sweep would protect everything and
64+
# quietly do nothing. The script re-checks and deepens if needed.
65+
fetch-depth: 0
66+
67+
- name: Check out PyAutoBrain (the sweep logic lives there)
68+
uses: actions/checkout@v4
69+
with:
70+
repository: PyAutoLabs/PyAutoBrain
71+
path: .brain
72+
fetch-depth: 1
73+
74+
- name: Sweep
75+
env:
76+
GH_TOKEN: ${{ github.token }}
77+
run: |
78+
set -o pipefail
79+
# A scheduled run never deletes, whatever anyone edits into the cron.
80+
mode='${{ inputs.mode }}'
81+
if [ '${{ github.event_name }}' != 'workflow_dispatch' ]; then
82+
mode=audit
83+
fi
84+
mode="${mode:-audit}"
85+
86+
.brain/bin/branch_sweep.sh \
87+
--repo "$GITHUB_WORKSPACE" \
88+
--owner '${{ github.repository_owner }}' \
89+
--name '${{ github.event.repository.name }}' \
90+
--mode "$mode" \
91+
--limit '${{ inputs.limit || 0 }}' 2>&1 | tee sweep.log
92+
93+
# The run summary is the readable surface on a phone.
94+
{
95+
echo "## Branch sweep — \`$mode\`"
96+
echo
97+
echo '```'
98+
cat sweep.log
99+
echo '```'
100+
} >> "$GITHUB_STEP_SUMMARY"

bin/branch_sweep.sh

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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

skills/repo_cleanup/SKILL.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,19 @@ and worktree roots under `$PYAUTO_WT_ROOT`.
6363
`euclid_strong_lens_modeling_pipeline`, `autolens_assistant`, and anything not
6464
listed above. Skip any entry that is missing, not a git repo, or a bare symlink.
6565

66+
## Where this runs
67+
68+
**Local session (laptop CLI):** the full sweep below — canonical checkouts,
69+
worktrees, stashes, the lot.
70+
71+
**Cloud session (phone, claude.ai/code):** there is no local tree, and the
72+
session credential **cannot delete a remote ref** (`git push origin --delete`
73+
returns 403; the GitHub tool surface has no delete-ref call). Do not attempt it
74+
and do not report the cleanup as blocked — **dispatch the repo's own
75+
`branch_sweep.yml`**, which runs the same gates under a `contents: write`
76+
`GITHUB_TOKEN`. Recipe: [`reference.md`](reference.md) → "Execution
77+
environments". Branch buckets only; stashes and worktrees stay laptop-only.
78+
6679
## Steps
6780

6881
### 1. Setup
@@ -108,5 +121,8 @@ Format: [`reference.md`](reference.md) → "Recap".
108121
- Skip missing / non-git / detached-HEAD repos with a one-line note.
109122
- Never skip hooks, never force-push — these are read/prune/delete operations only.
110123
- Suggest `/health worktrees` first if unsure whether tasks are in flight.
111-
- Execution-environment fallback (remote-audit-only when there's no local tree)
112-
is in [`reference.md`](reference.md).
124+
- A repo that still accumulates merged PR heads has **"Automatically delete head
125+
branches" off** (Settings → General). Say so once — that setting prevents the
126+
backlog this skill exists to clear, and no sweep substitutes for it.
127+
- Cloud-session routing (dispatch `branch_sweep.yml` rather than pushing
128+
deletes) is in [`reference.md`](reference.md) → "Execution environments".

0 commit comments

Comments
 (0)