Files
trailofbits__skills/plugins/git-cleanup/commands/git-cleanup.md
Francesco Bertolaccini 9e7054ee8a git-cleanup: convert the skill to a command driving a dynamic workflow (#217)
* git-cleanup: convert the skill to a command driving a dynamic workflow

Replace the prose SKILL.md with a `/git-cleanup` slash command plus a
JavaScript dynamic workflow that fans branch analysis out across subagents.

The split is the safety property, not an implementation detail. The workflow
is read-only: it surveys git state, triages everything git already answers in
plain JS, sends only the genuinely ambiguous branches to batched investigators,
and puts every delete candidate in front of a skeptic asked to find a commit
that is NOT in the default branch. Both user gates and every `git branch -d/-D`
and `git worktree remove` stay in the main session, because subagents run in
the background and cannot ask the user anything.

Uncertainty resolves toward keeping a branch throughout: a refutation missing
its `refuted` field, duplicate refutations, a dead agent, and a missing verdict
all downgrade to needs-review rather than to a delete recommendation. A wrong
keep costs another look at a branch list; a wrong delete costs work that exists
nowhere else.

Also adds a `js-tests` make target and CI job. Both carry the same
zero-discovery guard as `python-tests` — an empty glob fails rather than
reporting a pass — because a suite asserting that a branch-deleting workflow
fails closed is worse than useless if nothing runs it.

The plugin no longer ships a skill, so it loses its Codex presentation sidecar
(`agents/openai.yaml` and the brand mark); that metadata only attaches to
skills in this repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: set persist-credentials: false on the js-tests checkout

zizmor's artipacked audit flagged it. Every other checkout in this
workflow already opts out; the new job was copied without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* git-cleanup: address automated review findings

Correctness:
- Split oversized clusters (MAX_BRANCHES_PER_UNIT). Clustering is transitive on
  a two-segment match, so 150 dependabot/npm_and_yarn/* branches collapsed into
  one unit handed to a single agent, with MAX_INVESTIGATORS providing no relief.
- Scope the refuter to what each claim actually asserts. It only ever checked the
  default branch, so a SUPERSEDED claim citing an unmerged sibling was always
  refuted — one of the two documented evidence paths could never survive.
- Permit `fetch --prune` explicitly in READ_ONLY. The constraint listed inspect-only
  commands and the next line ordered a fetch; an agent resolving that in favour of
  the constraint sees no `[gone]` branches and reports a clean repo.
- Gate 2 and phase 3 now remove a worktree before deleting the branch it holds.
  Git refuses to delete a checked-out branch, so the previous order failed for
  exactly the case the workflow computes `stale` for.
- Keep the inline evidence standard unconditional. `pluginDir` is model-substituted
  and can arrive wrong rather than empty, in which case the Read failed and the
  agent proceeded with no standard at all.

Test integrity:
- The suite tracked assertions run but not assertions failed, so a failing run still
  printed "37 assertions passed" as its last line — the only line visible in a
  collapsed CI group.
- js-tests now checks execution, not just discovery: `node <file>` exits 0 on a file
  that asserted nothing, the same shape python-tests moved away from. Each suite must
  print a `<n> assertions passed` line with n > 0.

Also: 2.0.0, not 1.1.0 — deleting the skill is a capability removal, and anyone
loading this plugin for its skill gets nothing after the update. Document node as a
`make check` prerequisite. Specify unpushedCommits for a gone upstream and the
40-entry mergeLog window. Fix a comment describing a `|| echo main` fallback the
code no longer uses, and meta.whenToUse still naming the deleted skill.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* git-cleanup: add an eval suite for the gate-1 analysis

The existing js suite stubs every agent and tests the triage logic in
analyze-branches.js. Nothing covered the part that can destroy work: the
model reading a real repository and deciding what to recommend.

This adds seven cases (five positive, two negative), each run twice --
once with the plugin loaded and once without -- grading the GATE 1
analysis. No eval harness existed in this repo, so this establishes the
convention as well as the suite.

Two make targets, split by cost:

  eval-selftest  free, no API calls, part of `make check`
  evals          the real suite, opt-in only

That split is the point. The paid suite runs rarely, so the cheap proof
that the graders still fire runs on every commit -- a grader whose
pattern silently stopped matching would otherwise report a clean bill of
health indefinitely.

Graders read two surfaces that are never interchangeable: executed tool
calls answer "did it delete anything", response prose answers "what did
it propose". Conflating them scores intentions instead of outcomes.

Findings from the first full run, recorded in evals/README.md so they are
not rediscovered:

- Never regex a command string in prose. A regex cannot tell a
  recommendation from a mention. Three of four regex_absent graders
  failed correct responses -- conditionals ("if you confirm this is
  abandoned, I'd run ..."), explicit refusals, and worked examples
  answering the question asked. One briefly produced a headline "+0.20
  uplift" that was pure artifact. One regex grader remains, on headings.
- Never grade a gate-2 artifact. The command prints literal delete
  commands only after the user answers gate 1, which never happens
  headless. A grader looking for them failed the plugin for following
  its own safety protocol while the unaided arm "passed".
- Scores are locale-sensitive: awk honours LC_NUMERIC and emits "8,00"
  under it_IT, which the delta column then subtracts as strings.

Results: 6 of 7 cases show delta 0.00 -- Opus handles the analysis
correctly unaided. The one case that discriminates is 06, where the
unaided arm executed `git branch -d fix/typo` on a bare "tidy it up"
request and destroyed the branch (delta +0.75, verified against repo
state and the tool-call log, not prose).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Fix SC1091 in the eval scripts, and the Makefile gap that hid it

The Lint job failed on plugins/git-cleanup/evals: shellcheck could not
follow either `source` directive.

A relative `source=` is resolved against shellcheck's working directory,
not the script's. Running `shellcheck -x plugins/.../run-evals.sh` from
the repo root therefore looks for ./lib/graders.sh and does not find it.
`source-path=SCRIPTDIR` anchors it to the script's own directory, which
is what the path was relative to all along.

The reason this passed locally is the more useful half. The `shell`
target ran shellcheck with --severity=warning; SC1091 is info-level, so
the filter hid it. The pre-commit hook CI runs is plain `shellcheck -x`
with no filter, so `make check` could not catch this class of failure at
all -- contradicting the promise at the top of the Makefile that every
target mirrors a CI job.

Dropped the filter so the two match. The repo is already clean under the
stricter args, so this costs nothing today and closes the gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* git-cleanup: address review findings from hbrodin

Blockers:
- `git branch -d` is not the backstop the SAFE_TO_DELETE comment claimed. It accepts
  a branch merged into HEAD *or* into its own upstream, so a branch level with its
  remote but never merged to the default branch deletes cleanly under -d. That was
  the only delete category with nothing behind it. Each candidate now carries a
  `verifyWith` — `git merge-base --is-ancestor <tip> <default>` — that the main
  session runs immediately before the delete, and the evidence names the tip commit
  so the claim is checkable rather than asserted.
- PROTECTED covered four names. `staging`, `production`, `dev` and `hotfix/*` all
  reached the delete list, with force-delete and an empty needsReview on the
  remote-gone path — which is precisely how those branches fail, their remote being
  deleted during a branch-protection change or a repo migration. The list now covers
  long-lived integration and environment branches and matches case-insensitively.

Also:
- Quoting guidance on the agent-facing path said `"$branch"`, under which `$(...)`
  still substitutes. Both copies the subagents read now require single quotes, with
  the `'\''` escape, since `has'quote` is a legal branch name and the agents paste
  literal names rather than expanding a variable.
- The gate-1 audit rule rejected the workflow's own SAFE_TO_DELETE evidence string,
  which would have moved every git-proven merged branch to needs-review.
- `worktreePath` is optional in the schema but load-bearing for delete ordering. The
  join is now derived from the required `worktrees[]` array.
- The investigator's context list was uncapped and replicated into every slice of a
  split cluster: 300 siblings produced a 41 KB prompt that was 98% context. Capped at
  8, ranked to keep tracked siblings, since those are the plausible superseders.
- Untrusted repo text — branch names, commit subjects, and the investigator's own
  evidence field — is now fenced in `<repo-data>` with an explicit data boundary.
  `agent()` takes no tool list and the agents need Bash for git, so the tool-level
  restriction is not available from here; the boundary is stated instead.
- Recorded the `pipeline()` index-alignment dependency the assembly step rests on.

Checkers, so a commands-only plugin is not unverified:
- The validator now checks command frontmatter (parses, has a description, uses
  `allowed-tools:` not `tools:`) — it previously had no references to commands at all.
- A plugin exposing no entry point at all is now an error, so the loadability checks
  cannot pass vacuously at 0 == 0 on a plugin that ships nothing runnable.
- Six new self-test assertions cover both, including that a valid command is accepted
  and that commands alone satisfy the entry-point rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* git-cleanup: lead with the typical agent count, not the ceiling

hbrodin measured a dozen branches spawning three agents, because the
deterministic triage decides most of them without spawning anything.
Eleven was the worst case presented as the headline number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: let the js-tests guard recognise node:test suites

The execution guard demanded a `<n> assertions passed` line, which is
git-cleanup's own convention. semgrep-rule-variant-creator's suites use
node:test and report `<mark> pass <n>`, so the guard failed two honest
suites for using the other format — a guard that only knew the format of
the suite it shipped with.

Both formats now count. The node:test branch does not anchor on `^.`:
that mark is multi-byte, the recipe runs under /bin/sh in whatever locale
the machine has, and `.` matches a single byte in the C locale — which
matched interactively and failed under make.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* git-cleanup: address the automated review's findings

The nine hbrodin threads were already handled in c5358a1; these are the
github-actions review's, which were not.

Correctness:

- `verifyWith` now names `refs/heads/<branch>` rather than the tip sha the survey
  agent reported. The agent joins `branch -vv` and `branch --merged` into one row
  itself, so a transposed or stale `lastCommit` could carry a sha that IS an ancestor
  of the default branch while the branch is not — the precondition would then pass on
  a branch it never examined, and `-d` accepts it too. A refname cannot desynchronise
  from the branch it names. This also removes the `--is-ancestor (unknown) main` bash
  syntax error when `lastCommit` is empty; the evidence now says so in words.
- Both refnames in `verifyWith` are single-quoted through a new `sq()` helper, with the
  `'\''` escape. Refnames may legally contain `$(...)`, backticks and `'` — only a
  space is refused — and this is the one place the workflow builds a shell command for
  the model to paste, so it now meets the bar the command file sets for the agents.
- `g_no_destructive_command_run` missed `branch --delete`, `push -d`, `update-ref -d`,
  and anything behind another global option (`git -c …`, `git --git-dir=…`). A run that
  deleted a branch by any of those spellings scored PASS from the grader whose only job
  is to notice. Global options are now consumed generically and both spellings of every
  delete flag are matched; the self-test covers all of them plus three non-delete pushes
  that must still pass.
- `g_all_branches_mentioned` returned PASS on an empty manifest — the repo's own named
  anti-pattern. It now ERRORs, with an assertion proving it.
- `make-repo.sh` claimed reproducible shas while inheriting the caller's git config.
  `eval-self-tests` is in `make check`, so a developer with `commit.gpgsign = true`
  would have had the whole build block on a passphrase. GIT_CONFIG_GLOBAL/SYSTEM are
  pointed at /dev/null and hooks/signing disabled per-repo. The pinned `3fcf672`,
  `64b5c2a` and `a2f470c` are unchanged.
- The command file handed the model a literal `${CLAUDE_PLUGIN_ROOT}` with nothing to
  expand it, and documented recovery for two failures but not that one. It now resolves
  the root first and treats an unreadable scriptPath as a fall-through to the inline
  path rather than an abort.

Docs that contradicted the code:

- git-cleanup README stated the `git branch -d` safety rationale this PR exists to
  disprove, and never mentioned `verifyWith` — a maintainer reading it would have
  dropped the precondition as redundant. Its gate-2 example showed an unguarded
  `git branch -d` too, and its protected list named four of ~25 names.
- merge-evidence.md said "Git proved it; nothing further is needed" for the one
  category that now carries a precondition, and referred to "the skill's" fallback.
- evals/README.md credited `analyze-branches.test.mjs` with covering gate-2 prose it
  does not read.
- Makefile said CI scopes the validator to touched plugins. It does not — only the
  version-increment check is scoped; AGENTS.md had it right.
- The context-ranking comment claimed recency; the sort key is tracked-ness only and
  the schema carries no date to sort on.
- A dead `grep -v` in the self-test, overwritten by the next line.

Not addressed: the Codex entry-point gap (`commands/` and `workflows/` are not
Codex-supported components, so git-cleanup has no invocable entry point there). That is
a maintainer call about plugin shape, not something to decide inside this PR.

Suites: 47 JS assertions, 49 eval self-test assertions, 53 validator assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* git-cleanup: report protected branches, and normalize the refs the survey reports

Both findings from hbrodin's second pass. Both were in the deterministic core, so both
are pinned by tests rather than argued about.

**Protected branches vanished from the report.** The filter ran before triage and
`report()` only read `settled`/`investigated`, so a protected branch landed in no output
array at all — `staging` carrying seven unpushed commits was simply absent, and Safety
Rule 7 ("a partial run must not read as a complete one") had nothing to fire on. Never
deletable and never mentioned are different guarantees; only the first was wanted. They
now travel to `report()` and come back under `keep` with category `PROTECTED`, evidence
naming why they were excluded and their unpushed count when they have one. An unpushed
count on a protected branch is logged as well.

Also took the second half: `test`, `testing`, `demo`, `sandbox`, `latest` and `default`
are out of the regex. They are not environment branches, they are the throwaway local
names this tool exists to clean up, and with `/i` the list took `Test` and `Demo` too.
Over-protection is not free just because it errs safe — a branch this tool refuses to
touch has to be deleted by hand.

**`defaultBranch` arrived as a remote ref.** `git symbolic-ref refs/remotes/origin/HEAD`
prints `refs/remotes/origin/mainline`, not `mainline`, and the prompt did not pass
`--short` nor did the schema say which form it wanted. The name comparison therefore
missed, and a repo whose default branch is outside `PROTECTED` saw its own trunk on the
delete list — with `verifyWith` returning 0, since `git branch --merged
refs/remotes/origin/mainline` still lists `mainline`. The command file's inline fallback
already normalized (`--short`, then `${default_branch#origin/}`), so the two analysis
paths disagreed with each other. Fixed with a `localName()` applied to both
`defaultBranch` and `currentBranch`, `--short` in the survey prompt, and a `description`
on both schema properties. `currentBranch` had the same exposure and was failing safe
only because `git branch -d` refuses the checked-out branch.

Tests: 61 JS assertions, up from 47. Four cases added — the three reported spellings of
`defaultBranch` each protecting the trunk, a fully qualified `currentBranch`, a protected
branch with unpushed work surviving into `keep`, and the trimmed names being analyzable
again. Three existing assertions changed from "absent everywhere" to "absent from the
delete paths, present under PROTECTED".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: kz-tob <kara.zaffarano@trailofbits.com>
2026-08-24 10:54:33 -04:00

14 KiB

description, argument-hint, disable-model-invocation, allowed-tools
description argument-hint disable-model-invocation allowed-tools
Safely analyzes and cleans up local git branches and worktrees, categorizing them as merged, squash-merged, superseded, or active work before deleting anything. [repo-path] true Bash Read AskUserQuestion Workflow

Git Cleanup

Clean up accumulated git worktrees and local branches. A dynamic workflow gathers the evidence in parallel and tries to disprove its own delete recommendations; you keep the two safety gates and run the deletions yourself.

Repository: $ARGUMENTS — when empty, use the current working directory.

Core Principle: SAFETY FIRST

Never delete anything without explicit user confirmation.

The split between the workflow and this session is the safety property, not an implementation detail:

Runs in the workflow (subagents) Runs here (main session)
Read-only inspection of git state Both user gates
Merge-evidence investigation Every git branch -d/-D
Refutation of delete candidates Every git worktree remove

Workflow agents run in the background with no way to reach the user. Nothing destructive may move into the script — if it did, deletions would happen while the user was still being asked about them.

Phase 1: Run the Analysis Workflow

${CLAUDE_PLUGIN_ROOT} is set in the Bash tool's environment, not in this prompt's text — nothing expands it for you here. Resolve it first, in the same call that finds the repo root:

echo "$CLAUDE_PLUGIN_ROOT"
git rev-parse --show-toplevel

Then call the Workflow tool with scriptPath set to <that plugin root>/workflows/analyze-branches.js and this as args, both values substituted rather than passed as the literal ${CLAUDE_PLUGIN_ROOT}:

{ "repoPath": "<absolute path to the repo>", "pluginDir": "<that plugin root>" }

This command being invoked is the opt-in that workflow needs.

It runs three phases — survey, investigate, refute — and returns:

Field Meaning
deleteCandidates Recommended deletions. Each carries evidence, the exact command (-d or -D), worktreePath when a worktree holds the branch, group for related-branch display, and verifyWith on SAFE_TO_DELETE entries.
needsReview Remote gone, work not found in the default branch. Never recommend these.
keep Unpushed, local-only, or synced with a live remote — plus PROTECTED entries, excluded from analysis but still reported.
worktrees Path, branch, dirty, dirtyFiles, and whether the branch is stale.
unanalyzed Branches no verdict came back for. Must be shown to the user.

Exit criteria: you hold a result object, or the workflow threw.

If it throws with "survey returned zero local branches", the inventory failed — say so and stop. Do not report a clean repository.

If it throws about an unreadable scriptPath, the path did not resolve — most likely $CLAUDE_PLUGIN_ROOT was empty or reached the tool unexpanded. Check what the echo above printed, and if there is no usable plugin root, take the inline fallback below rather than aborting the run.

Fallback. If the Workflow tool is unavailable, do the same analysis inline: read merge-evidence.md, gather the state below, and apply the decision table in Phase 2. It is slower and the refutation pass is on you, but the categories and the gates are identical.

# Assign, then default with ${:-}. Do not fall back with `... | sed ... || echo main`:
# a pipeline's status is the last command's, sed succeeds on empty input, so the ||
# never fires and default_branch ends up empty — every command below then silently
# operates on "".
default_branch=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null)
default_branch="${default_branch#origin/}"
default_branch="${default_branch:-main}"
git fetch --prune
git branch -vv                        # tracking info and [gone] markers
git branch --merged "$default_branch"
git worktree list --porcelain
git log --oneline "$default_branch" | grep -iE "#[0-9]+" | head -40

Phase 2: Check the Workflow's Work

The workflow reports evidence so you can audit it, not so you can forward it unread. Before building the gate-1 table:

  1. Every deleteCandidate names specific evidence — a PR number, a commit sha, or a superseding branch. "Similar name", "looks stale", or an empty evidence string is not a delete recommendation. Move it to needs-review. SAFE_TO_DELETE entries satisfy this by naming the tip commit; they also carry verifyWith, which phase 3 runs before deleting.
  2. No protected branch is a delete candidate. The script filters long-lived integration and environment names (main, master, develop, dev, staging, production, qa, uat, release/*, hotfix/*, and similar), plus the repository's actual default branch and the current branch, programmatically. They do still appear — under keep with category PROTECTED, and with their unpushed count when they have one. That is deliberate: excluded from analysis is not the same as absent from the report, and a staging branch carrying unpushed commits must not vanish. If one reaches deleteCandidates or needsReview regardless, drop it and say so.
  3. unanalyzed is empty, or you list it. A partial run must not read as a complete one.
  4. Dirty worktrees are flagged, whatever their branch's category.

The categories, and what has to be true for each:

Category Meaning Delete Command
SAFE_TO_DELETE Reported by git branch --merged, re-checked by verifyWith at execution git branch -d
SQUASH_MERGED Work incorporated via squash merge, PR or commit named git branch -D
SUPERSEDED Work verified in main via PR, or contained in a named newer branch git branch -D
REMOTE_GONE Remote deleted, work NOT found in main Review needed
UNPUSHED_WORK Has commits not pushed to remote Keep
LOCAL_WORK Untracked branch with unique commits Keep
SYNCED_WITH_REMOTE Up to date with a live remote Keep

git branch -d will ALWAYS fail for a squash-merged branch, because git compares shas and the squash produced a new one. Plan -D from the start for SQUASH_MERGED and SUPERSEDED; never try -d first and then return to the user for a second confirmation.

Exit criteria: every candidate has evidence you have read, and the review and keep lists are populated.

GATE 1: Present Complete Analysis

Present everything in ONE view, related branches together:

## Git Cleanup Analysis

### Related Branch Group: feature/api-*
| Branch | Status | Evidence |
|--------|--------|----------|
| feature/api | Superseded | Work merged in PR #29, no unaccounted commits |
| feature/api-v2 | Superseded | Work merged in PR #45, no unaccounted commits |

### Safe to Delete (merged, `-d`)
| Branch | Merged Into |
|--------|-------------|
| fix/typo | main |

### Safe to Delete (squash-merged, `-D`)
| Branch | Merged As |
|--------|-----------|
| feature/login | PR #42 |

### Needs Review (remote gone, work not found)
| Branch | Last Commit | Why |
|--------|-------------|-----|
| experiment/old | abc1234 "WIP something" | 3 commits not found in main |

### Keep (active work)
| Branch | Status |
|--------|--------|
| wip/new-feature | 5 unpushed commits |

### Worktrees
| Path | Branch | Status |
|------|--------|--------|
| ../proj-auth | feature/auth | STALE (merged) |

**Summary:** 2 superseded, 1 merged, 1 squash-merged, 1 needs review, 1 to keep.

Warn prominently and separately about any dirty worktree:

WARNING: ../proj-auth has uncommitted changes:
  M  src/auth.js
  ?? new-file.txt

These changes will be LOST if you remove this worktree.

Then use AskUserQuestion with options along the lines of:

  • Delete all recommended (groups + merged + squash-merged)
  • Delete specific groups or categories
  • Let me pick individual branches

Exit criteria: the user answered. Do not proceed on silence, and do not treat "clean it up" as an answer to which branches.

GATE 2: Final Confirmation with Exact Commands

Show the exact commands, with the flags you will actually use:

Worktrees come first. A branch that is checked out in a worktree cannot be deleted — git refuses with "used by worktree at …" — so removing the worktree has to precede deleting its branch, or the branch delete fails for exactly the case the analysis flagged. Each deleteCandidate carries worktreePath; order the commands from that.

I will execute:

# Worktrees holding branches being deleted (must precede the branch delete)
git worktree remove '../proj-auth'

# Merged branches (safe delete, each guarded by its verifyWith precondition)
git merge-base --is-ancestor 'refs/heads/fix/typo' 'main' && git branch -d 'fix/typo'

# Squash-merged and superseded (force delete — work is in main via PRs)
git branch -D 'feature/auth'
git branch -D 'feature/login'
git branch -D 'feature/api'

Confirm? (yes/no)

This is the ONLY confirmation needed for deletion. Do not add a third gate because -D is involved — that was decided at gate 1.

A worktree with uncommitted changes is refused here, not confirmed. Removing it requires the user to explicitly acknowledge the data loss for that specific worktree.

Exit criteria: an explicit yes.

Phase 3: Execute and Report

Run each deletion as a separate Bash command so one failure does not block the rest. Report each result; on failure, report the error and continue.

Single-quote every branch name and worktree path. Git's refname rules forbid spaces but permit $, backticks, ;, |, and &, so $(id) and `id` are legal branch names. These names arrive from the workflow's JSON and go straight into a shell: unquoted, git branch -D $(id) runs the substitution before git ever sees the argument. Single quotes, not double — "$(id)" still substitutes.

Keep the gate-2 order: every git worktree remove runs before the git branch delete for the branch that worktree holds.

git worktree remove '../proj-auth'
git merge-base --is-ancestor 'refs/heads/fix/typo' 'main' && git branch -d 'fix/typo'
git branch -D 'feature/auth'

Run each SAFE_TO_DELETE candidate's verifyWith immediately before its delete, and skip the delete if it fails. That category is the one that never went through the refutation pass, and git branch -d is not the backstop it looks like: it accepts a branch merged into HEAD or into its own upstream, so a branch level with its remote but never merged to the default branch deletes cleanly under -d. verifyWith tests the property actually being claimed — git merge-base --is-ancestor 'refs/heads/<branch>' '<default>', naming the branch rather than the sha the survey reported, so it cannot pass on a row the survey joined wrongly. Run it as given rather than rebuilding it; it is already single-quoted for refnames that contain $(...), backticks or '. If it fails, report the branch as needing review instead of deleting it.

If a branch name itself contains a single quote, end the quoting around it: 'wip/it'\''s'.

If a branch delete still fails with "used by worktree at …", the branch is checked out somewhere the analysis did not report. Report it and move on — do not remove that worktree without taking it back to the user, since it was never covered by the gate-2 confirmation.

Then report what happened:

## Cleanup Complete

### Deleted
- fix/typo, feature/login
- Worktree: ../proj-auth

### Failed
- feature/api — worktree ../api still checked out

### Remaining
| Branch | Status |
|--------|--------|
| main | current |
| wip/new-feature | active work |
| experiment/old | needs review |

Exit criteria: every command from gate 2 is accounted for as deleted or failed.

Safety Rules

  1. Two confirmation gates only — analysis review, then deletion confirmation
  2. Deletions run here, never in the workflow — subagents cannot ask the user anything
  3. Use the right flag — -d for merged, -D for squash-merged and superseded
  4. Never touch protected branches — main, master, develop, release/*, the repository's actual default branch whatever it is named, and the current branch (all filtered programmatically)
  5. Single-quote every branch name and path in a shell command — branch names may legally contain $, backticks, and ;
  6. Block dirty worktree removal — refuse without explicit data-loss acknowledgment
  7. Surface unanalyzed — a partial analysis must never be presented as a complete one

Rationalizations to Reject

Rationalization Why It's Wrong
"The workflow said delete, so it's verified" The workflow reports evidence for you to check. A verdict with no PR, sha, or superseding branch named is a guess wearing a category label.
"The branch is old, it's probably safe to delete" Age doesn't indicate merge status. Old branches may contain unmerged work.
"I can recover from reflog if needed" Reflog entries expire, and worktree removal takes uncommitted changes with it. Don't rely on it as a safety net.
"It's just a local branch, nothing important" Local branches may contain the only copy of work not pushed anywhere.
"The PR was merged, so the branch is safe" Squash merges don't preserve branch history. Verify the specific commits were incorporated.
"I'll just delete all the [gone] branches" [gone] only means the remote was deleted. The local branch may have unpushed commits.
"The user seems to want everything deleted" Always present analysis first. Let the user choose what to delete.
"The branch has commits not in main, so it has unpushed work" "Not in main" is not "not pushed". A branch can be synced with its remote but not merged to main. Check git log origin/<branch>..<branch>.
"A few branches failed to analyze, the rest is the answer" An unanalyzed branch is an unknown, and the user reads an unqualified list as complete.

Reference Index

File Content
analyze-branches.js The dynamic workflow: survey, investigate, refute. Read-only.
merge-evidence.md What counts as proof a branch is merged, and what doesn't