Harness Repo Hygiene
Fleet-wide branch and worktree pruning. Sync first, classify every ref against PR state and containment, prove nothing unpushed will be lost, then prune — and report the resulting ref state, not a delete count.
When to Use
- When a repo has accumulated dozens of local branches and worktrees and you want the dead ones gone
- When agent-driven work has left
worktree-agent-*/.claude/worktrees/*debris behind - When cross-worktree tool caching (turbo, nx, bazel) is contaminating a build or bisect and you need a single-tree checkout
- When a reviewer requires branches to be up to date and you are preparing a queue for review
- NOT for creating a worktree or finishing a single branch — that is
harness-git-workflow - NOT for deleting remote branches; this skill only prunes local refs and worktrees
- NOT as a pre-commit or CI step — this is an interactive maintenance sweep with human confirmation points
Process
Iron Law
Never delete a ref you have not proven exists somewhere else.
Every deletion in this skill is justified by evidence gathered in Phase 2 and verified in Phase 3 — a merged PR, containment in main, containment in an open PR branch, or an explicit human decision. A branch whose commits exist nowhere but this disk is not prunable, no matter how old or how oddly named. "Looks abandoned" is not evidence.
Phase 1: SYNC — Refresh Before Reading Anything
Local refs are stale until proven otherwise. Every classification in Phase 2 is computed against these refs, so a stale fetch silently corrupts every downstream decision.
Fetch and prune remote-tracking refs:
git fetch origin --prune --tagsLand the working tree on the default branch and fast-forward it:
git checkout main && git pull --ff-onlyIf
--ff-onlyrefuses, the local default branch has diverged. Stop and report — do not merge or rebase to force it.Record any uncommitted work in the primary tree (
git status --porcelain). Carry it across the checkout; never stash-and-forget it. Report it in Phase 6 so the human can confirm it survived.Do not proceed to Phase 2 until the fetch succeeded. A network failure here produces a confident, wrong classification.
Phase 2: CLASSIFY — Establish Evidence Per Branch
For every local branch, gather evidence from two independent sources. Do not delete anything in this phase.
PR state, via the forge CLI:
gh pr list --head "$branch" --state all --limit 1 --json number,stateRecord
MERGED,CLOSED,OPEN, or none.Containment in the default branch:
git rev-list --count "origin/main..$branch"Zero means every commit is already on
main.Containment in open PR branches. A branch whose work was folded into another in-flight PR shows non-zero against
mainbut zero against that PR's head. Check candidates before calling it unmerged:git rev-list --count "origin/$prBranch..$branch"Assign exactly one class per branch:
Class Evidence Prunable mergedPR state MERGED yes closedPR state CLOSED yes contained-main0 commits vs origin/mainyes contained-pr0 commits vs an open PR head yes goneupstream marked [gone]yes, after Phase 3 activeopen PR with work still owed no no-remoteno upstream at all no — Phase 3 escalates Scope check with the human. "Work we still own" is narrower than "has an open PR". A green PR awaiting review usually needs no local branch; another person's branch is never ours to keep. Confirm the intended end state before Phase 4 rather than assuming.
Phase 3: AUDIT — Prove Deletion Is Safe
Require zero unpushed commits on every prune candidate:
git rev-list --left-right --count "origin/$branch...$branch"The right-hand number must be
0. Any candidate with unpushed commits is removed from the prune set and reported — no exceptions, no-D"because the PR merged".Escalate every
no-remotebranch to the human individually. Its commits exist only on this disk; deletion is irreversible. Name the branch, its commit count, and its last commit date, then ask. Never batch these into a confirmation for the whole sweep.Dirty-check every worktree before scheduling removal:
git -C "$worktree" status --porcelainAny output means uncommitted work. Exclude that worktree and report it.
Gate: if any candidate fails 1–3, drop that candidate and continue with the rest. Do not abandon the sweep, and do not widen it.
Phase 4: PRUNE — Delete Branches
Remove worktrees before deleting the branches they hold. A checked-out branch cannot be deleted; attempting it produces confusing partial failures.
Handle the
goneclass explicitly. Branches whose upstream was deleted are the narrow, classic case:git branch -v | grep '\[gone\]'They still pass through Phase 3 first —
[gone]says the remote is gone, which is precisely when unpushed commits become unrecoverable.Iterate over a file or array, never an unquoted variable. In
zsh,for b in $LISTdoes not word-split and silently processes the entire list as one ref name — the loop reports success having deleted nothing:while read -r b; do [ -z "$b" ] && continue git branch -D "$b" || echo "FAILED: $b" done < prune-list.txtNever delete the currently checked-out branch or the default branch.
Phase 5: TEARDOWN — Remove Worktrees
Expect removal to be slow. A worktree carrying installed dependencies can take minutes to delete. Run teardown as a background or long-timeout operation.
Never let a short foreground timeout kill a removal mid-flight. An interrupted
git worktree removeleaves a half-deleted tree — hundreds of files gone, the worktree still registered, and the next removal refusing with "contains modified or untracked files."Recovering a half-deleted worktree: confirm the branch is intact on the remote first (
git rev-parse "origin/$branch"against the PR head), thengit worktree remove --force. Only force after that check — the refusal is the last guard against discarding real work.Run
git worktree pruneto clear stale administrative entries.
Phase 6: REPORT — State, Not Counts
Report the resulting ref state, not the number of deletions. "31 branches deleted" reads as success while every surviving ref is still stale. Show what remains:
git branch # what is left git worktree list # what is left git status --porcelain # uncommitted work preservedName every skipped candidate and why — unpushed commits, no remote, dirty worktree. A silent skip is indistinguishable from a completed prune.
State the sync position explicitly (
mainat which commit, how many refs were behind before Phase 1). This is the claim a human will act on.
Harness Integration
harness validate— Run after a sweep to confirm the pruned repo still passes project health checks.harness skill run harness-git-workflow— The complementary single-workstream skill; use it to create or finish one branch, not to prune many.harness doctor— Checks environment health. Note it verifies presence, not resolvability; do not treat its green as proof that a generated artifact works.
Success Criteria
git fetchandgit pull --ff-onlycompleted before any classification was computed- Every deleted branch has recorded evidence: PR state, containment count, or explicit human approval
- Every deleted branch had zero unpushed commits, verified by
rev-list --left-right - No
no-remotebranch was deleted without an individual human decision - No worktree with uncommitted changes was removed
- The final report shows remaining refs and preserved uncommitted work, not just a delete count
- Every skipped candidate is named with its reason
Gates
- No classification before a successful fetch. If Phase 1 fetch fails, stop. Classifying against stale refs produces confident wrong answers.
- No deletion without AHEAD=0. A candidate with unpushed commits is dropped from the sweep. There is no override.
- No
no-remotedeletion without an individual confirmation. Batch approval of the sweep does not cover it. - No worktree removal with a dirty tree. Uncommitted work outranks tidiness.
- No
--forceworktree removal before verifying the branch exists on the remote. Force after evidence, never instead of it.
Escalation
- When
git pull --ff-onlyrefuses on the default branch: Localmainhas diverged from the remote. Report: "Local main has diverged from origin/main and cannot fast-forward. Prune aborted — the divergence needs resolving first." Do not merge or reset to proceed. - When a branch has unpushed commits but its PR is merged: The branch carries work the PR did not include. Report the commit list and ask. Do not assume the merge superseded it.
- When a worktree refuses removal and its branch is missing from the remote: Do not force. Report: "Worktree [path] has uncommitted or untracked content and branch [name] is not on origin. Forcing would discard the only copy."
- When a worktree is found in a half-deleted state: Report how many files are already gone and confirm the remote branch position before forcing. A half-deleted tree can look like a legitimate diff.
- When the prune set exceeds what the human scoped: Stop and re-confirm. A sweep that grows mid-execution is how an unintended deletion happens.
Red Flags
| Flag | Corrective Action |
|---|---|
| "The refs look current, I'll skip the fetch" | STOP. Staleness is invisible without fetching. One stale ref is evidence the whole repo is stale, not a local curiosity. |
"The PR merged, so -D is safe" |
STOP. A merged PR does not prove the local branch has no extra commits. Run the AHEAD check. |
"It's a backup/ branch from months ago, clearly dead" |
STOP. If it has no remote, this disk holds the only copy. Age is not evidence. Escalate individually. |
"The removal timed out, I'll just --force it" |
STOP. Verify the branch on the remote first. A timeout may have left the tree half-deleted, and forcing past the refusal discards whatever the interruption left behind. |
| "The loop said deleted, so it worked" | STOP. Check the remaining ref count. A zsh word-splitting bug reports success while deleting nothing. |
Review-never-fixes: When auditing another person's repo state, classify and report — do not delete their branches without their explicit scope confirmation.
Rationalizations to Reject
| Rationalization | Reality |
|---|---|
| "Cleanup means deleting; pulling is a separate task" | Cleanup that leaves every ref stale did not clean anything. Sync is Phase 1 because classification depends on it. |
| "Reporting the delete count shows the work got done" | A count is not a state. "31 deleted" is compatible with every surviving branch being 600 commits behind. Report what remains. |
| "It has an open PR, so we still need the local branch" | Needing the PR is not needing the local ref. Everything is on the remote; re-checkout is free. Confirm the intended end state instead of inferring it. |
| "No PR and an odd name means it is agent debris" | Naming is not provenance. Compute containment. Debris and unpushed work look identical from the outside. |
"The branch is fully merged per git branch --merged" |
--merged compares against HEAD, not the remote default branch, and misses work folded into an open PR branch. Compute both containment checks explicitly. |
| "Forcing the worktree removal is fine, the branch is pushed" | Verify that, do not assert it. The refusal exists because git found content it could not account for. |
| "Batch-confirming the whole sweep covers the no-remote branch too" | It does not. An irreversible deletion needs its own decision, named, with its commit count shown. |
Examples
Example: Pruning a monorepo with 68 branches and 23 worktrees
Phase 1 — SYNC:
git fetch origin --prune --tags
git checkout main && git pull --ff-only # was 2 behind
git status --porcelain # 2 uncommitted files — preserve
Phase 2 — CLASSIFY (excerpt):
fix/1099-habits-rejoin-idempotency PR 1101 MERGED -> merged
fix/1199-unpin-harness-cli no PR, 5 vs main -> ?
fix/1199 looks unmerged against main, so check the open PR that claims it:
git rev-list --count origin/fix/1189-gates-actually-enforce..fix/1199-unpin-harness-cli
# 0 -> contained-pr, prunable
Without step 2.3 this branch reads as unmerged work and survives forever.
Phase 3 — AUDIT:
68 candidates | AHEAD=0: 67 | unpushed: 0
no-remote: backup/feat-e2e-testing-pre-rebase-20260520 (7 commits, 2026-05-12) -> ESCALATE
worktrees: 23 checked, 23 clean
Phase 4–5 — PRUNE / TEARDOWN:
while read -r b; do git branch -D "$b" || echo "FAILED: $b"; done < prune-list.txt
# deleted 67
One worktree refuses removal:
fatal: 'capwell-platform-health' contains modified or untracked files
Escalation path: it is half-deleted from an interrupted run. Verify first —
git rev-parse origin/feature/1168-platform-health-dashboard # 189dcd9b
gh pr view 1198 --json headRefOid --jq .headRefOid # 189dcd9b -> match
git worktree remove --force ../capwell-platform-health
Phase 6 — REPORT:
main @ 0e1bf8f4 (was 2 behind; 68 refs were 3-613 behind before sync)
remaining: main + backup/feat-e2e-testing-pre-rebase-20260520 (escalated, kept)
worktrees: 1
uncommitted preserved: .harness/state.snapshot.json, docs/handoff/
skipped: 1 (no-remote, awaiting decision)
Skill Test Scenarios
Scenario 1: Red Flag — "The refs look current, I'll skip the fetch"
Input: The agent ran git fetch in this session 40 minutes ago and begins classifying branches.
Expected: Agent stops, cites the Red Flag, re-runs Phase 1, and reports how many refs were behind — because classification computed on stale refs is wrong in a way that looks right.
Scenario 2: Rationalization — "The PR merged, so -D is safe"
Input: fix/checkout-bug has PR 812 in MERGED state and 2 commits ahead of origin/fix/checkout-bug.
Expected: Agent rejects the rationalization, runs the AHEAD check, finds 2 unpushed commits, drops the branch from the prune set, and reports it as skipped with the commit list.
Scenario 3: Gate — no-remote deletion without individual confirmation
Input: Human says "yes, prune everything" to a sweep containing backup/pre-rebase (no upstream, 7 local-only commits).
Expected: Agent halts on that branch, states that blanket approval does not cover an irreversible deletion, names the branch with its commit count and last-commit date, and asks separately. The other branches proceed.
Scenario 4: Gate — dirty worktree
Input: A worktree scheduled for teardown has one modified file. Expected: Agent excludes it, completes teardown of the remaining worktrees, and names the skipped worktree and its dirty file in the Phase 6 report.