cleanup-loose-branches
TL;DR — Survey every loose-work location read-only → fan out DeepSeek subagents to classify each as land / cherry-pick / delete / keep → write a consolidation plan → execute it (useful work onto a branch, everything else staged ready-to-delete). Nothing is deleted without your explicit per-item OK.
The four phases — do this, in order
Run these four phases in order. Lean on DeepSeek subagents in every phase — they are the cheap default for reading wide, understanding nuances, and untangling supersession, not a heavyweight escalation. Flip "should a subagent do this?" into "is there any reason it can't?" Start the survey immediately — don't ask "want me to look around?" or "should I make recommendations?"
- Survey (read-only). Sweep every place loose work hides — current working tree, worktrees, branches, stashes, remotes/PRs, interrupted ops, submodules, detached HEADs, sibling repos, other clones, the megaplan-cloud machine, codespaces. Produce the raw map. Subagents: fan the survey sections across DeepSeek agents so the main thread stays lean — one agent per area, read-only.
- Investigate & classify (DeepSeek fan-out → go/no-go). For every item with real ambiguity (supersession, "is this useful?", conflict surface, cloud-only work), fan out read-only DeepSeek briefs — one decision area per agent — to convert uncertainty into a verdict. Collapse the whole survey into a decisive table: land / cherry-pick / delete / keep. The goal of investigation is a go/no-go decision, not more notes. Subagents: the most subagent-heavy phase.
- Strategy (write the merge plan). Write a durable consolidation plan: where every valuable piece lands, the merge order, how to handle complex/overlapping merges (shared bases, divergent test contracts), and what's junk-ready vs. keep. The plan doc — not the chat — is the source of truth. Subagents: use DeepSeek to map supersession and conflict surfaces, and to draft the plan.
- Execute (run the merges). Work the plan, lowest blast-radius first. Land every useful piece onto the consolidation branch (merge/cherry-pick/PR until tests pass). Everything else is staged ready-to-delete — not deleted: deletion waits for explicit per-item approval. Subagents: hand the heavy merge/conflict/test loops to agents in parallel where the work is independent.
Stop and ask the user at two points only: the Phase 2 → Phase 3 handoff (before you commit to a consolidation strategy), and before any destructive action in Phase 4.
Goal: land every loose piece of work on main or drop it. Never delete without explicit per-item approval.
The bias is STRONGLY toward landing on main. The target end-state is: everything valuable is on main, and the only things not on main have a specific, stated, good reason (genuinely abandoned, superseded by better work, or a deliberate not-yet-ready effort the user named). "It would take a lot of work to consolidate" is not a good reason — it is the expected cost of this skill. Be happy to spend lots of time refactoring, untangling, resolving conflicts, rewriting tests, and reconciling divergent work to get it onto main. A clean main that absorbs the loose work is worth hours of careful consolidation. Parking work on a branch "for later" is the lazy outcome and usually the wrong one — prefer to do the integration now. Reach for subagents (subagent-launcher) to parallelize the heavy investigation so the effort is cheap, but do not let the size of a consolidation talk you out of it.
Dirty work is not "preserved" as the outcome — it is merged or dropped. When this skill says to preserve uncommitted work, that means make a temporary safety checkpoint before risky integration, not "park it on a WIP branch and call the cleanup done." The decision you owe the user is: how will this dirty payload land on main, or what exact parts are junk to delete? For every dirty checkout/worktree, classify paths into mergeable units, identify junk with positive evidence, plan the integration order, and run the needed merge/test/fix loop. A checkpoint branch, patch, or scratch worktree is only a seatbelt while merging; it is not a final recommendation.
What "loose work" means — read this first. Loose work is anything that exists in only one place. The name says "branches" but branches are the least of it. Rank by how easily it is lost, highest first:
- Uncommitted changes in the current checkout's working tree — committed nowhere, pushed nowhere. One
git checkout .away from gone. THE most exposed work in any repo, and the one a "branch survey" skips by construction. Always survey this. It is item zero, not out of scope. - Untracked files (
git ls-files --others) — same exposure, plus invisible togit diff. - Uncommitted work in other worktrees — same as #1 but easier to forget because it's not your current directory.
- Committed but unpushed — survives local mistakes, dies with the disk.
- Pushed but only on a branch / single clone / single cloud volume — the actual "loose branch."
A branch named main with a dirty tree is not outside this skill's scope. Neither is your own current checkout. If you ever find yourself thinking "that's just the main checkout's dirty state, not my problem," stop — that is the exact failure this skill exists to prevent. Account for every tier above before you claim the survey is done.
Done when: every row in the survey has been acted on or explicitly parked, the "Cleaned up / Kept / Still to decide" report has been printed, AND every tier of loose work above (including the current checkout's own uncommitted/untracked/unpushed work) has an explicit verdict. No silent drops, no items left in uncertain, no working-tree work left unaccounted for.
keep is only for: open/draft PR, protected branch, or current active work the user has not said is ready. A dirty worktree is risk, not a recommendation. If the user says "everything except X is in scope", dirty worktrees outside X still need a landing/deletion recommendation: preserve artifacts, port useful work, then delete after approval. Everything else with unique commits gets a landing rec (merge-then-delete / PR-then-merge / cherry-pick-then-delete) or delete. "Recent branch with work on it" is not a reason to keep — pick a landing route.
"Is there other work too?" / "have you been thorough?" / "what about all the other branches?" — these mean you scoped too narrowly. Don't re-assert the table; re-run the survey on the leftovers and classify them. The two pools most often missed: (a) the current checkout's own uncommitted/untracked/unpushed work (item zero), and (b) non-branch loose state — .megaplan/tickets/ (deferred intent), stashes, other on-disk clones, and the megaplan-cloud machine (every per-chain workspace on the Hetzner box, not just the one in cloud.yaml). Treat dirty parked worktrees as in-scope unless the user explicitly named them active; name what you missed and why ("a branch listing can't show an uncommitted diff"). If you weren't thorough, say so and fix it — don't defend a partial sweep.
uncertain is a last resort, not a default. The final survey must turn every row into land / cherry-pick / delete / keep — "inspect" and "maybe" are failures, not recs. Reserve uncertain for the rare row you genuinely can't classify, and name the blocker plus the exact next investigation that would resolve it.
Phase 1 — survey (read-only; lean on DeepSeek to read wide)
Fan the sections below across read-only DeepSeek subagents — one agent per area (worktrees, branches, stashes, cloud machine, …) — so the heavy reading happens off the main thread and only the findings come back. Resolve main, fetch, and build the worktree map first:
MAIN=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@')
MAIN=${MAIN:-main}
git fetch --all --prune --quiet 2>/dev/null
The current checkout's own working tree (ITEM ZERO — most easily missed, highest exposure)
Do this before worktrees. The current checkout's uncommitted and untracked work is loose work that lives in exactly one place; a branch listing never shows it.
git status --short --branch # ahead/behind + dirty + untracked at a glance
git diff --stat HEAD # tracked uncommitted changes
git ls-files --others --exclude-standard # untracked files (NOT shown by git diff)
git log --oneline @{u}..HEAD 2>/dev/null # committed-but-unpushed commits
Then isolate what is genuinely unique to this checkout — work that exists on no branch, no other worktree, and no remote. The trap: a checkout's dirty diff often overlaps with worktrees forked from it, so most of it may be preserved elsewhere while a few files are unique and at risk:
# For each modified file, is its content reachable on any branch/worktree, or only here?
git diff HEAD --name-only | while read f; do
git log --all --oneline -1 -- "$f" >/dev/null 2>&1 || true
done
# Most reliable: compare against any worktree forked from this checkout (see worktree section),
# and against $MAIN's tip: `git diff $MAIN -- <file>`. Files unique here = highest-risk row.
Surface this as explicit survey rows, not a footnote: unique uncommitted work (rec: merge into $MAIN via a consolidation branch/PR, or delete specific junk with evidence), untracked keepers vs. junk (logs/caches/.megaplan-agentic = junk; new source/tests/briefs = keepers), and unpushed commits (rec: push/merge, or note why held). Never recommend deleting or force-checking-out the current tree. If integration is risky, take a safety checkpoint first, then continue to the merge/drop decision.
FOOTGUN — checkpointing the current checkout's dirty tree without disturbing it. git switch -c wip && git add -u && commit && git switch back does not leave the dirty work behind — it moves it onto the new branch, and switching back leaves the original checkout clean (changes reverted, untracked keepers that got committed are removed). If the user is actively working in this checkout, that silently wipes their working state. Two safe patterns: (a) record a fingerprint first (git diff HEAD | git hash-object --stdin + the untracked list), do the snapshot-branch + push, then restore: git checkout <wip-branch> -- . && git reset -q HEAD, and verify the fingerprint matches; or (b) snapshot via a scratch worktree (git worktree add /tmp/wip -b checkpoint/wip $MAIN) and copy the dirty files in there, never touching the live checkout. Always verify the original tree is byte-identical afterward. Then use that checkpoint as an integration source; do not treat it as the cleanup result.
Worktrees (FIRST among other worktrees — pinned branches can't be -d'd)
git worktree list --porcelain
ls -d "$PWD/.megaplan-worktrees"/* "$PWD/../"*"/.megaplan-worktrees"/* 2>/dev/null
find "$HOME/Documents/.megaplan-worktrees" "$HOME/.megaplan-worktrees" \
-maxdepth 1 -mindepth 1 -type d 2>/dev/null
find "$PWD" -maxdepth 3 -type d \( -name '.worktrees' -o -name 'worktrees' \) 2>/dev/null
find "$PWD/.megaplan/bakeoffs" -maxdepth 3 -type d -name 'worktrees' 2>/dev/null
ls -d ~/.claude/projects/*/worktrees/* 2>/dev/null
Do not trust git worktree list as complete. It only shows worktrees registered
to the current repo's .git/worktrees. Megaplan and agent runs can leave standalone
or chained checkouts under a shared directory such as
~/Documents/.megaplan-worktrees/<topic> whose .git belongs to a different clone
or whose origin points to another local checkout. These are still loose work and
must be mini-surveyed. For every candidate directory above, accept both .git
directories and .git files:
for wt in "$HOME/Documents/.megaplan-worktrees"/* "$HOME/.megaplan-worktrees"/*; do
git -C "$wt" rev-parse --git-dir >/dev/null 2>&1 || continue
echo "== $wt =="
git -C "$wt" remote -v
git -C "$wt" rev-parse --show-toplevel
git -C "$wt" branch --show-current
git -C "$wt" log -1 --oneline
git -C "$wt" status --porcelain
git -C "$wt" ls-files --others --exclude-standard | head -80
done
If remote get-url origin is this repo, a local path to this repo, or a local path
to another checkout that ultimately points at this repo, classify it in this cleanup.
If it is a different repo, list it separately as "other repo loose work" and do not
merge it into the current repo. A directory can contain source/tests entirely as
untracked files; that is highest-risk loose work even when branch tables are clean.
Per registered or unregistered worktree: branch, origin chain, ahead/behind vs $MAIN
when it belongs to this repo, git -C <path> status --porcelain, and untracked file
summary. Dirty/untracked source = highest risk, flag red. Also note prunable entries
(git worktree prune fixes them).
Build a branch → worktree path map now — later sections check it to flag pinned branches (which refuse git branch -d).
Local branches
git for-each-ref \
--format='%(refname:short)|%(committerdate:iso8601)|%(committerdate:relative)|%(upstream:short)|%(upstream:track)|%(objectname:short)|%(contents:subject)' \
refs/heads/
Per branch (skip $MAIN):
- ahead/behind:
git rev-list --left-right --count $MAIN...<br> - merged ancestor:
git merge-base --is-ancestor <br> $MAIN cherry +N(load-bearing — catches squash-merges and post-merge fixups):git cherry $MAIN <br> | grep -c '^+'. Zero+= every patch already on main. Always compute before applying any merged-PR rule.- conflicts:
git merge-tree $(git merge-base $MAIN <br>) $MAIN <br> | grep -c '<<<<<<<' - diff shape:
git diff --stat $MAIN...<br> - pinned: from the worktree map
- upstream
[gone]: remote was deleted (PR auto-delete-on-merge)
Stashes
git stash list --format='%gd|%cr|%s'
for s in $(git stash list --format='%gd'); do git stash show --stat "$s"; done
Cross-reference each stash's base branch against the branch table. A stash on a flagged-for-delete branch is the highest-risk row in the survey — surface the linkage; stash approval is always separate from branch approval.
Remote branches + PR state
git branch -r --format='%(refname:short)|%(committerdate:iso8601)|%(committerdate:relative)'
gh pr list --state all --limit 200 \
--json number,state,headRefName,baseRefName,title,updatedAt,isDraft,mergedAt,author > /tmp/prs.json
Join PRs by headRefName; most recent wins. If many [gone] upstreams, mention GitHub's auto-delete-head-branches setting.
Interrupted operations (carry real work, not visible elsewhere)
ls .git/MERGE_HEAD .git/CHERRY_PICK_HEAD .git/REVERT_HEAD .git/REBASE_HEAD 2>/dev/null
ls -d .git/rebase-apply .git/rebase-merge .git/sequencer 2>/dev/null
Any hit → flag keep until user resolves the in-progress op. Show git status so the user can see what's mid-flight.
Submodules
git submodule foreach --recursive \
'echo "== $name =="; git status --porcelain; git stash list; git log --oneline @{u}.. 2>/dev/null'
Each submodule with dirty state, stashes, or unpushed commits gets its own row in the survey.
Detached HEAD orphans
git fsck --unreachable --no-reflogs 2>/dev/null | grep -c '^unreachable commit'
Surface the count only; offer to dig in if the user's lost work.
Untracked / patch / merge-tool leftovers
git ls-files --others --exclude-standard
find "$PWD" -maxdepth 3 -type f \
\( -name '*.patch' -o -name '*.diff' \
-o -name '*.orig' -o -name '*.BACKUP.*' -o -name '*.LOCAL.*' -o -name '*.REMOTE.*' -o -name '*.BASE.*' \) 2>/dev/null
*.orig / *.BACKUP.* / *.LOCAL.* / *.REMOTE.* / *.BASE.* are merge-tool leftovers — often partial work the user never finished resolving.
Agent-history storage side effect
Loose-branch and worktree cleanup often involves many omp/Codex sessions and wide tool output. If the machine is under disk pressure, check omp session state before starting a long cleanup:
du -sh "$HOME/.omp/agent" 2>/dev/null
On this machine, the legacy hermes ~/.hermes/state.db grew to 7.9G from
CLI/tool history plus FTS5 indexes before the hermes runtime was removed; the
omp runtime keeps its session/agent state under ~/.omp/agent. If you do not
care about omp resume/search history, stop omp and remove the session files
under that directory.
This is not branch cleanup and should not be mixed into destructive branch actions;
it is a disk-pressure guardrail to run only with explicit user approval.
Tags, odd refs, fork PR refs
git for-each-ref \
--format='%(refname:short)|%(objectname:short)|%(committerdate:relative)|%(contents:subject)' \
refs/notes refs/replace refs/original 2>/dev/null
git ls-remote origin 'refs/pull/*/head' | head -20
gh pr list --state open --limit 100 \
--json number,headRepositoryOwner,headRefName,baseRefName,title,isDraft,url
Tags, refs/notes, refs/replace, refs/original: surface as context only — never delete candidates. External-head PRs (forks): PR work to keep/review, not branch cleanup.
Non-origin remotes
git remote -v
for r in $(git remote); do [ "$r" = origin ] || git branch -r --list "$r/*"; done
Treat upstream, personal forks, and leftover _variant remotes the same as origin: join PR state where applicable, classify per the same rules.
Sibling Repo variants (megaplan-2, repo.bak, etc.)
REPO=$(basename "$PWD"); PARENT=$(dirname "$PWD")
find "$PARENT" -maxdepth 1 -mindepth 1 -type d \
\( -iname "${REPO}-*" -o -iname "${REPO}_*" -o -iname "${REPO}.*" \
-o -iname "${REPO} *" -o -iname "old-${REPO}" -o -iname "old_${REPO}" \
-o -iname "*-${REPO}" -o -iname "*_${REPO}" \) \
-not -path "$PWD" 2>/dev/null
For each candidate that's a git repo (git -C <path> rev-parse --git-dir) and shares this repo's origin, run this mini-survey (read-only, no cd):
git -C <path> remote get-url origin
git -C <path> rev-parse --abbrev-ref HEAD
git -C <path> log -1 --format='%cr %h %s'
git -C <path> status --porcelain
git -C <path> stash list
git -C <path> for-each-ref --format='%(refname:short)|%(upstream:track)|%(contents:subject)' refs/heads/
git -C <path> ls-files --others --exclude-standard | head -30
Each variant is its own section, not a row in the branch table.
Other clones of this repo elsewhere on disk
ORIGIN=$(git remote get-url origin 2>/dev/null)
[ -n "$ORIGIN" ] && find ~/dev ~/Documents ~/src ~/code /tmp /Volumes 2>/dev/null \
-maxdepth 6 -name config -path '*/.git/config' \
| xargs grep -l "$ORIGIN" 2>/dev/null \
| grep -v "^$PWD/"
For each .git/config hit, the clone is dirname dirname <path>. Run the same mini-survey as sibling variants.
Megaplan cloud machine (Hetzner agentbox) + cloud workspaces
The cloud "machine" is a long-lived box (in this environment, a Hetzner VM via
provider: ssh) running the megaplan-cloud-agent container over a persistent
/workspace volume. That volume holds many per-chain workspaces
(/workspace/<unique-per-chain>), one per concurrent chain — each its own git
clone that can carry uncommitted work, untracked files, unpushed commits, and
cloud-only branches. Surveying only the single repo.workspace named in the
local cloud.yaml misses everything else on the box, so survey the whole
machine, not one workspace. The box outlasts any one cloud.yaml and may hold
loose work even when the current repo has none — so detect the box directly,
not just the config file.
Detect the config and the box (either can exist without the other):
ls "$PWD/cloud.yaml" "$PWD/.megaplan/cloud.yaml" "$PWD/.megaplan/cloud-"*.log 2>/dev/null
Resolve the ssh target from cloud.yaml (defaults match the working setup):
HOST=$(yq '.ssh.host' cloud.yaml 2>/dev/null)
SSH_USER=$(yq '.ssh.user // "root"' cloud.yaml 2>/dev/null)
PORT=$(yq '.ssh.port // 22' cloud.yaml 2>/dev/null)
IDENT=$(yq '.ssh.identity_file // ""' cloud.yaml 2>/dev/null)
CONTAINER=$(yq '.ssh.container // "megaplan-cloud-agent"' cloud.yaml 2>/dev/null)
# /workspace inside the container == ssh.workspace_dir on the host (default /opt/megaplan-cloud/workspace)
BOX="ssh -p ${PORT} ${IDENT:+-i $IDENT} ${SSH_USER}@${HOST} docker exec ${CONTAINER} bash -lc"
Reachability — try the orchestrator, then a direct ssh ping (the box can be up
even when the local megaplan cloud wiring isn't):
megaplan cloud status 2>&1 | head -40
$BOX 'echo ok' 2>&1 | head -5
Box reachable + container up → survey the entire machine read-only. Enumerate every workspace (every dir under
/workspace, not just the one in cloud.yaml), then mini-survey each git repo there exactly like a sibling variant, plus list the tmux sessions (each chain is its own loose-work candidate and may be mid-run):$BOX 'ls -1 /workspace 2>/dev/null' # every per-chain workspace on the box $BOX 'for ws in /workspace/*/; do git -C "$ws" rev-parse --git-dir >/dev/null 2>&1 || continue echo "== $ws ==" git -C "$ws" remote -v git -C "$ws" rev-parse --abbrev-ref HEAD git -C "$ws" status --porcelain git -C "$ws" ls-files --others --exclude-standard | head -40 git -C "$ws" stash list git -C "$ws" for-each-ref --format="%(refname:short)|%(upstream:track)|%(contents:subject)" refs/heads/ git -C "$ws" log --oneline "@{u}.." 2>/dev/null | head -20 # committed-but-unpushed done' $BOX 'tmux ls 2>/dev/null' # live chain sessions (each may be mid-run) $BOX 'tail -40 /workspace/*/.megaplan/cloud-chain*.log 2>/dev/null' # what is churning / interrupted(
megaplan cloud exec "<cmd>"runs the same command through the orchestrator if it is wired; direct ssh is more reliable — see themegaplan-cloudskill.)Five risk buckets, one per workspace / chain: dirty volume (uncommitted or untracked source — highest risk), committed-but-unpushed (push never landed), branch ahead of origin (chain interrupted mid-push), cloud-only branches (never pushed at all), and dormant chain workspaces (no live tmux session, nothing pushed — pure loose volume). A workspace with a live tmux session and recent log lines is active work →
keep; flag it, do not touch it.Box up, container down → the volume still holds work; start the container (
docker start "$CONTAINER") to inspect, or snapshot the volume — but do not destroy until every workspace is ported or declared junk.Box not deployed / unreachable → surface "dormant cloud — boot/deploy to inspect, or destroy if you're sure it's done." Do not boot just to survey without asking; booting is reversible,
destroyis not.
Each cloud workspace is its own section. Recs: port-and-down /
port-and-destroy / down / destroy / keep. Port by pushing from inside the
container ($BOX 'git -C /workspace/<ws> push origin <branch>') before any
down/destroy. destroy drops the provider volume — non-recoverable, same
blast radius as rm -rf on a sibling variant.
Also reconcile the other direction when a cloud workspace is done: if the
latest intended code now lives locally or on GitHub, make sure the Hetzner box is
not left running a stale checkout. First push the finished branch from the source
of truth, then update each inactive cloud workspace that should keep existing by
fetching and checking out that pushed tip inside the container. Do this only for
dormant/finished workspaces; active tmux sessions stay keep until they finish.
git push origin <finished-branch>
$BOX 'git -C /workspace/<ws> fetch origin --prune &&
git -C /workspace/<ws> checkout <finished-branch> &&
git -C /workspace/<ws> pull --ff-only origin <finished-branch>'
Arnold-specific branch replacement check. If the repo is Arnold, verify that
cloud workspaces no longer depend on the old editable-install branch once that
work has landed or been superseded. Treat any /workspace/* Arnold checkout still
on editable-install, or with editable-install as its configured branch, as a
cloud cleanup row: replace it with the intended branch (main or the new
consolidation branch), push/fetch the replacement tip, and only then mark the old
branch ready to delete. In the survey output, state explicitly: "editable-install
replaced on Hetzner: yes/no; remaining workspaces: ."
GitHub Codespaces
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null)
gh codespace list --json name,repository,gitStatus,lastUsedAt 2>/dev/null \
| jq --arg r "$REPO" 'map(select(.repository == $r))'
Each Codespace's gitStatus shows unpushed commits / dirty state — same shape as cloud workspaces. Recs: port-then-delete-codespace / delete-codespace / keep.
Phase 2 — investigate & classify (DeepSeek fan-out → go/no-go)
The survey gives you raw facts; this phase turns them into decisions. For any row where the first-pass call is "inspect" / "maybe" / "probably", fan the question out to read-only DeepSeek subagents and collapse the uncertainty into a go/no-go verdict. The goal is a decisive table — land / cherry-pick / delete / keep — not more notes.
Deploy a subagent (subagent-launcher or Agent tool) for any medium-or-higher ambiguity. Concrete triggers include: 5+ unique commits, conflicts, no PR but load-bearing-looking commits, stale-but-possibly-valuable work, dirty worktrees whose payload might overlap, branch name diverging from PR headRefName, unclear base branch, uncertain supersession, or any row where your first-pass recommendation would be "inspect" / "maybe" / "probably." Brief: branch/item, unique commits, key files, known facts, "is each commit already on main or the true base as a different SHA?", "what would be lost if dropped?", and "give a go/no-go recommendation." Surface its per-commit or per-payload verdict in the walk-through. If there is no meaningful ambiguity because direct evidence already proves delete, merge, PR, or keep, do not spawn an agent just to rubber-stamp it.
Deep-investigation upgrades (multi-epic repos, heavy ambiguity)
The base survey assumes loose refs are mostly residue. In a repo mid-flight on one or more megaplan epics, that assumption inverts: the branch list is largely residual refs from completed/squash-merged epics, while the real risk is unprotected uncommitted work sitting in worktrees — sometimes the most advanced work in the repo, on no branch and no remote. A single survey pass will misread this. The discipline below was distilled from a real cleanup where the headline finding wasn't "stale branches" but "an entire epic (M0–M5) existed only as a dirty worktree, triplicated across three checkouts." Use it whenever the basic survey leaves you hedging.
Epic awareness. Before classifying, read the intent record:
docs/megaplan/epics/*/chain.yaml,EPIC.md, milestone*.md,briefs/,wakeup-note.md,how-to-follow-along.md, and.megaplan/tickets/(open tickets often are the list of deferred work). Note that executed plans often ran on a cloud box, so.megaplan/plans/may be empty locally — say so rather than assuming no plan existed. For each epic, build an intended-scope-vs-done map (which milestones merged, which are incomplete, which were explicitly deferred). A "loose branch" whose epic is done is residue; a clean-looking epic may still owe unshipped milestones.Base-branch ≠ main. Epics frequently use a long-lived integration branch as the PR base, not
main. Computecherry/ahead-behind/merged against each branch's true PR base (gh pr ... --json baseRefName), not just$MAIN. A milestone squash-merged into the integration branch showscherry +Nvsmainyet is fully landed — deleting it is safe only if the integration branch is preserved. Always state that dependency.Worktree payload fingerprinting (duplicate/subset detection). Don't stop at a dirty-file count. Fingerprint each worktree's uncommitted diff and compare across worktrees that share a tip or epic:
git -C <wt> diff HEAD | git hash-object --stdin # identical hash ⇒ byte-identical payload git -C <wt> ls-files --others --exclude-standard | sort # compare untracked sets too comm -23 <(sort A_untracked) <(sort B_untracked) # empty ⇒ A ⊆ B (subset, not unique)This catches the trap where three worktrees look like "parallel epic work" but two are exact duplicates / subsets of a third. Classify each worktree's payload as keeper / duplicate / subset / unrelated-churn.
Phase-1 completion gate. Run every survey section before emitting the table — including the ones that come back empty (codespaces, other clones, tags, cloud). Presenting a table off a partial sweep is a failure mode; you don't know what you missed until each check has actually returned.
Preserve-before-delete, no middle ground. The goal is: land everything valuable, then delete the rest with positive evidence. Uncommitted worktree work is the highest-risk item and must be committed or ported before any deletion touches it. Never leave the repo in a half-cleaned state.
Resolving ambiguity with a read-only DeepSeek fan-out (subagent-launcher)
When classification has medium-or-higher ambiguity — is effort A superseded by effort B?,
is this epic actually complete?, what's the merge conflict surface?, do these tests pass?,
is this dirty worktree payload unique or duplicated elsewhere? — fan the questions out to parallel
DeepSeek V4 Pro agents (the subagent-launcher skill: fan.py for ≥5 agents,
launch_omp_agent.py for 1–4). One independent decision area per agent, run in parallel, no cross-talk.
The point is to cheaply convert uncertainty into a go/no-go cleanup recommendation; do not feel bad about
using agents when a direct survey leaves anything important unclear.
Best practices that earned their place:
- Split by cleanup decision, not by command family. Good briefs are scoped to one judgement area:
an integration branch vs
main, a large port branch, a stale docs-only branch, an open PR with a pinned worktree, or non-branch loose state. The goal is a verdict (PR-then-merge,delete,keep until X lands,do not cherry-pick as-is), not a transcript of every git command. A five-way fan-out that worked well:01-integration-to-main,02-large-port-branch,03-stale-branch-family,04-open-pr-pinned-worktree,05-nonbranch-loose-state. - Bake a hard read-only guardrail into every brief. Spell out the allowed commands
(
git status/diff/log/show/cherry/merge-base/merge-tree/ls-tree,cat/ls/grep/find, andgit stash show -pwhich is read-only) and the forbidden ones (anycommit/checkout/switch/merge/rebase/reset/stash apply-pop-drop/ cherry-pick/branch -d/push/rm/mv/clean,write_file/patch). These agents getterminalaccess with no sandbox — the guardrail is your only seatbelt. "Describe the mutation in your report instead of doing it." - Preload known facts and ask them to verify. Include the first survey's branch/worktree facts, PR numbers,
ahead/behind counts, commit subjects, and the specific uncertainty. This avoids wasting agent time on broad
rediscovery and makes corrections more meaningful. Example question shapes:
- "Is
<branch>fully consumed by its true base branch, not just bymain?" - "Is this docs-only residue safe to delete, or should any commit be rewritten as a fresh archival addendum?"
- "Does the detached worktree preserve local-only work, or is it redundant with a remote ref?"
- "Is
- Use
--toolsets="terminal,file"so they can actually run git across worktrees (thefiletoolset alone can't rungit diff). For test-running briefs, scope tightly: name the exact test files, exclude GPU/network/ agentic suites, and tell them to skip anything that hangs. - Hand them the facts you already established so they verify rather than re-derive — and still
cross-check their claims with a quick direct command. Agents are occasionally wrong in load-bearing ways
(in the reference run, one called untracked files "unique" when a 30-second
commproved they were a subset). - Demand decisiveness: "ground every claim in command output; take a position; do not hedge; cap at N words."
- Liveness-check 30–60s after launch (
tailthe fan stderr for[tool]/[done]heartbeats); set--task-timeout. See thesubagent-launcherskill's "Detecting hangs" section. - Iterate. A first fan-out (e.g. lineage / integration / stashes / branch-ledger) usually leaves one hard unknown — dispatch a focused follow-up agent for it (e.g. a supersession verdict) rather than guessing.
- Know the ceiling. Read-only agents produce a high-confidence risk map and task list; they cannot
prove an integration compiles or merges. That requires actually doing it in a throwaway worktree
(Codex or a Claude
Agentwith write access), which is implementation, not survey. State this limit plainly.
Concrete fan-out skeleton:
RUN_DIR=/tmp/loose-branches-deepseek-$(date +%Y%m%d-%H%M%S)
mkdir -p "$RUN_DIR/briefs" "$RUN_DIR/results"
# Write one self-contained markdown brief per judgement area under "$RUN_DIR/briefs".
PYENV_VERSION=3.11.11 python ~/.claude/skills/subagent-launcher/fan.py \
--briefs-dir="$RUN_DIR/briefs" \
--output-dir="$RUN_DIR/results" \
--max-workers=5 \
--model="deepseek:deepseek-v4-pro" \
--toolsets="terminal,file" \
--max-tokens=32768 \
--task-timeout=900 \
--project-dir="$PWD"
Minimal brief template:
You are a read-only DeepSeek investigation agent for branch cleanup in <repo>.
Goal: decide <one cleanup decision>. Return a go/no-go recommendation: land, delete, cherry-pick,
keep-until-X, or explicitly park. Do not end at "needs inspection."
Known facts from the first survey:
- <branch/worktree/PR facts>
- <ahead/behind, cherry +N, conflict count, commit subjects>
READ-ONLY GUARDRAIL:
Allowed commands only: git status, diff, log, show, cherry, merge-base, merge-tree, rev-list,
branch/list, worktree list/prune --dry-run, stash list/show, ls-tree, cat/sed/grep/rg/find,
gh pr/codespace list/view, python one-liners that only read files.
Forbidden: commit, checkout/switch, merge, rebase, reset, stash apply/pop/drop, cherry-pick,
branch -d/-D, push, git worktree prune without --dry-run, rm/mv/write_file/patch, editing files.
If a mutation would be useful, describe it instead.
Questions to answer:
1. <the load-bearing decision>
2. <what would be lost if dropped>
3. <what positive evidence makes delete/keep/merge safe>
Output: <=800 words. Lead with verdict. Ground every claim in command output. Take a position.
After the fan-out finishes, do not paste the raw reports as the answer. Read them, then cross-check the claims that would change a recommendation:
while read -r base br; do
mb=$(git merge-base "$base" "$br")
echo "## $base <- $br"
git rev-list --left-right --count "$base...$br"
git cherry "$base" "$br" | grep -c '^+' || true
git merge-tree "$mb" "$base" "$br" | grep -c '<<<<<<<' || true
done <<'EOF'
main feature/foo
integration spent-child
EOF
Then synthesize a short updated verdict table. Name any recommendation that changed because of the fan-out and why. If the fan-out finds a correction to the first survey (for example a missed third-party submodule, a detached worktree that is redundant with a remote ref, or a docs-only branch that is stale enough not to cherry-pick), update the durable plan doc (Phase 3) before asking for destructive approval.
Classification rules
First match wins. Use the reason template verbatim, filling <...>:
| Signal | Rec | Reason template |
|---|---|---|
| Worktree has uncommitted changes | keep | "uncommitted work in worktree at <path>" |
| Open PR (not draft) | keep | "open PR #N: <title>" |
| Draft PR | keep | "draft PR #N — still in progress" |
| Interrupted rebase/merge/cherry-pick | keep | "in-progress <op>; resolve before cleanup" |
Merged PR + cherry +0 |
delete | "PR #N merged <date>, no unique commits" |
Merged PR + cherry +N, some commits valuable |
cherry-pick-then-delete | "PR #N merged, <M>/<N> commits still matter — cherry-pick those" |
Merged PR + cherry +N, all superseded |
delete | "PR #N merged; remaining <N> commits superseded by main" |
Merged PR + cherry +N, coherent new work |
PR-then-merge | "PR #N merged, <N> coherent commits remain post-merge" |
Closed PR + cherry +0, >30d |
delete | "PR #N closed unmerged, no unique commits" |
Closed PR + cherry +N, valuable |
PR-then-merge | "PR #N closed unmerged; <N> unique commits still should land" |
Closed PR + cherry +N, superseded |
delete | "PR #N closed unmerged; unique commits superseded" |
cherry +0 (no PR) |
delete | "squash-merged into $MAIN, no unique commits" |
--merged ancestor of $MAIN |
delete | "fully merged into $MAIN" |
[gone] upstream + 0 ahead local |
delete | "remote deleted, nothing local to lose" |
| Protected/release-like name | keep | "looks like a protected/release branch" |
| Pinned by clean worktree, valuable | PR-then-merge | "checked out at <path>; clean, land via PR" |
| <14d, ahead, small/clean diff | merge-then-delete | "recent valuable work, <X> ahead, merge-tree clean" |
| <14d, ahead, larger/riskier diff | PR-then-merge | "recent valuable work, <X> ahead; needs CI/review" |
| >90d, ahead, no PR, superseded | delete | "stale (<N>d), <X> ahead, no PR, superseded by main" |
| >90d, ahead, no PR, valuable | PR-then-merge | "stale (<N>d), but <X> unique commits still should land" |
| Anything else valuable | PR-then-merge | "<X> ahead / <Y> behind, last commit <N>d ago; land via PR" |
| Anything else superseded | delete | "<X> ahead / <Y> behind; superseded by main" |
When two rules could apply, prefer the more cautious landing route (PR-then-merge over direct merge, cherry-pick-then-delete over whole-branch merge). delete requires positive evidence.
Local-vs-own-remote skew: --merged ancestor + [ahead N] upstream usually means N commits were squash-merged and the remote branch is residual. cherry +0 confirms. Reason: "ancestor of $MAIN; also N ahead of own remote (squash-merged), remote is residual."
Survey + classification output
One table, sorted: delete → cherry-pick-then-delete → merge-then-delete → PR-then-merge → keep → uncertain, then age desc.
REC WHERE BRANCH AHEAD/BEHIND AGE PR
delete local+remote fix/typo 0/0 merged #412 ✓
Change: README typo.
Why: PR merged, no unique commits.
merge-then-delete local+remote fix/cli-null 1/0 2d none
Change: small CLI null guard.
Why: low-risk, merge-tree clean.
PR-then-merge local feat/payments 23/4 3d none
Change: payment workflow + persistence.
Why: larger work, needs CI/review before landing.
Symbols: ✓ merged, ✗ closed, ◐ open, ◌ draft.
Below the table:
- Current checkout's working tree (LIST FIRS
…(truncated)