Add a post-merge-cleanup skill to git-agent that removes merged branches and their worktrees only after inspecting each worktree for uncommitted work, detects squash-merged branches that commit-ancestry cannot see, and reports unregistered leftover directories instead of ignoring them.
Merged branches pile up with their worktrees, and the existing tool for clearing them force-removes whatever uncommitted work is sitting inside. This adds a skill that looks first and deletes second, so nothing gets destroyed without someone saying yes. We will know it worked when a worktree holding an uncommitted file survives a cleanup run untouched.
Read and implement all steps in the plan at docs/plans/add-post-merge-cleanup-skill.md — Look inside the worktree before deleting it. Verify against the plan's Tests, Verification, and Acceptance Criteria before reporting done. If everything passed, mark completion in docs/plans/add-post-merge-cleanup-skill.md — tick each step's [x] marker and each criterion's - [x], set status: completed — and re-render the HTML from the spec. If any check failed, leave status: in-progress and say which.
More ways to run this plan — goal & workflow prompts, file path
Achieve this goal: Look inside the worktree before deleting it. The plan at docs/plans/add-post-merge-cleanup-skill.md describes one approach — use it as reference, but optimize for the outcome. Fan out across parallel subagents where that serves the outcome. Verify against the plan's Tests, Verification, and Acceptance Criteria before reporting done. If everything passed, mark completion in docs/plans/add-post-merge-cleanup-skill.md — tick each step's [x] marker and each criterion's - [x], set status: completed — and re-render the HTML from the spec. If any check failed, leave status: in-progress and say which.
Run a workflow to implement the plan at docs/plans/add-post-merge-cleanup-skill.md — Look inside the worktree before deleting it. Brief subagents with the plan file at docs/plans/add-post-merge-cleanup-skill.md. Reserve a final verification phase for the lead agent, not a subagent. Verify against the plan's Tests, Verification, and Acceptance Criteria before reporting done. If everything passed, mark completion in docs/plans/add-post-merge-cleanup-skill.md — tick each step's [x] marker and each criterion's - [x], set status: completed — and re-render the HTML from the spec. If any check failed, leave status: in-progress and say which.
add-post-merge-cleanup-skill.html
docs/plans/add-post-merge-cleanup-skill.html
docs/plans/add-post-merge-cleanup-skill.md
Context
The story behind this plan — what prompted the work and why it matters now.
kit/plugins/git-agent/skills/merge/SKILL.md:183 deliberately declines branch
deletion — "Never pass --delete-branch. Branch deletion is a separate
destructive action that needs its own explicit yes." Nothing has ever filled
that seam, so the job falls to commit-commands:clean_gone, an external command
that runs git worktree remove --force plus git branch -D in one unattended
loop with no user gate. Measured against this repo on 2026-08-15, that would
destroy 16 untracked files across 9 of 19 worktrees, because --force exists
precisely to defeat the dirty-tree refusal git worktree remove performs by
default.
That default refusal is the design hinge: because an unforcedgit worktree remove already fails on a dirty tree, checking for uncommitted
work first lets this skill delegate its central safety property to git rather
than reimplementing it.
Selection cannot rely on commit ancestry. git-agent squash-merges, and a
squash merge replays a branch's changes as one new commit with a different SHA,
so the branch's own commits never become ancestors of main.git branch --merged origin/main therefore cannot see squash-merged branches at
all. Measured here across 395 local branches: 84 are ancestry-merged, while
318 have a merged pull request — 296 of those invisible to the ancestry
test. The union is 380 cleanable branches, so selecting on ancestry alone
would find 84 of 380 and miss 78% of the real backlog. git branch -d would
also refuse those 296 even when deleting is correct, because -d performs the
same ancestry check. Selection therefore takes either signal, and -D is
permitted only where a merged PR supplies positive evidence.
The grounded proposal is at docs/prompts/proposal-add-post-merge-cleanup.md.
Note that its "51 branches that never landed" claim was disproved during this
plan's interview and has been corrected there.
Risks carried into this plan:
- Recursive delete inside a skill. Step 6 gives the skill an rm -rf for
unregistered directories, which have no git registration to remove them by,
and whose dangling .git files mean git status cannot vouch for their
contents. Mitigated by printing size, file count, and recently modified files,
requiring a per-directory confirmation, checking containment against the
worktrees root, and refusing any path detection did not itself produce.
- -D is now reachable. Squash-merged branches require it. Mitigated by
gating -D behind a confirmed merged PR; without that evidence the skill uses-d and accepts git's refusal.
- Self-deletion. The skill can run inside the worktree it is asked to
remove. Mitigated by an explicit cwd check in Step 3.
- Version guard. Any edit under kit/plugins/git-agent/ fails CI without amarketplace.json version bump. Step 7 does it; nothing else may.
Files that change
Every file this plan touches, and what happens to each one.
`kit/plugins/git-agent/skills/post-merge-cleanup/SKILL.md`new activation, the safety contract, and the default single-branch flow- `kit/plugins/git-agent/skills/post-merge-cleanup/references/
detection.md`new dual-signal selection, the degraded mode, and the read-only inventorysweep.md`new the repo-wide sweep report and its gatesstale-directories.md`new unregistered-directory evidence rules and removal rails
`tests/plugins/test-post-merge-cleanup.sh`new fixture-repo objective test plus contract greps`.github/workflows/check-plugin-versions.yml`modified run the new test in CI- `kit/plugins/git-agent/
README.md`modified add the skill to the Skills tableCHANGELOG.md`modified v4.19.0 entry
`.claude-plugin/marketplace.json`modified bump git-agent 4.18.0 to 4.19.0`README.md`modified Plugin Reference Table, regenerated via the canonical generator, never hand-edited
Steps
The step-by-step work, in order — each step says what to do, why it matters, and how to check it worked.
kit/plugins/git-agent/skills/post-merge-cleanup/SKILL.md with frontmatter (name, description, allowed-tools) and a Safety Contract section stating the four absolutes: never git worktree remove --force, never remove a worktree whose git status --porcelain is non-empty, never git branch -D without a confirmed merged PR, and never rm a path outside the worktrees root.
python3 tests/plugins/measure_description_budget.py reports the description at 200 characters or fewer with a first sentence of 80 or fewer, and find kit/plugins/git-agent/skills/post-merge-cleanup -maxdepth 1 -name '*.md' | wc -l returns 1.references/detection.md defining dual-signal selection: a branch is cleanable when git branch --merged origin/<default> lists it or gh pr list --head <branch> --state merged returns a PR. Resolve the default branch via git symbolic-ref refs/remotes/origin/HEAD, falling back to gh repo view --json defaultBranchRef. When gh is absent, unauthenticated, or the remote is not GitHub, degrade to the ancestry test alone and state in the report that squash-merged branches cannot be detected in this mode, so the list is known-incomplete.
full here while a gh-less environment reports degraded.SKILL.md: confirm the branch is cleanable by either signal, refuse when the current working directory is inside the target worktree, inspect with git status --porcelain and stop with the file list when output is non-empty for any reason — untracked, staged, or unstaged — then on approval run git worktree remove <path> from outside the worktree, followed by git branch -d, escalating to -D only when a merged PR was the qualifying signal.
references/sweep.md defining the repo-wide sweep behind an explicit flag: emit one table of branch, qualifying signal, worktree path, and dirty-file count before any action; list dirty worktrees as blocked alongside their file lists; require per-item approval, with batch approval as a separate deliberate answer rather than a default.
references/stale-directories.md: a directory is unregistered only when it is absent from git worktree list, has no admin directory under .git/worktrees/<name>, and its .git file is dangling or missing. Because a dangling .git means git status cannot inspect it, removal requires printing the directory's size, file count, and most recently modified files, then a per-directory confirmation, a check that the resolved path is inside the worktrees root, and refusal of any path not produced by detection.
tests/plugins/test-post-merge-cleanup.sh following the shape of tests/plugins/test-scope-guard.sh: build a throwaway fixture repo under mktemp -d, create both an ancestry-merged and a squash-merged branch each with a worktree, leave one worktree holding an uncommitted file, assert the documented flow leaves that worktree and its file intact, assert the clean worktree is removed, assert the squash-merged branch is detected as cleanable, assert paths outside the worktrees root are rejected, assert no forbidden flag appears inside a fenced code block in the skill sources (a prose prohibition naming the flag is correct and must not fail), and remove the temp directory on exit via trap.
bash tests/plugins/test-post-merge-cleanup.sh exits 0, and re-running it leaves no directory behind under $TMPDIR..github/workflows/check-plugin-versions.yml as its own step matching the existing run: bash tests/plugins/test-<name>.sh entries, then update kit/plugins/git-agent/README.md's Skills table and add a ## v4.19.0 CHANGELOG entry, and bump git-agent from 4.18.0 to 4.19.0 in .claude-plugin/marketplace.json.
git fetch origin && BASE_REF=main node scripts/check-plugin-versions.mjs exits 0, and grep -c test-post-merge-cleanup .github/workflows/check-plugin-versions.yml returns 1 or more.README.md Plugin Reference Table with node scripts/build-readme-table.mjs.
node scripts/build-readme-table.mjs && git diff --exit-code README.md exits 0.Tests
The tests that prove the change does what it promises.
tests/plugins/test-post-merge-cleanup.sh; Type: smoke; Asserts: after running the documented flow against a fixture repo whose worktree contains one uncommitted file, the worktree directory still exists, the file is byte-identical, and the branch is still present; Run: bash tests/plugins/test-post-merge-cleanup.shtests/plugins/test-post-merge-cleanup.sh; Targets: the selection rule in references/detection.md; Key cases: an ancestry-merged branch qualifies; a squash-merged branch with a merged PR qualifies despite failing the ancestry test; a branch with neither signal does not qualify; with gh unavailable the run degrades to ancestry only and emits the incompleteness warningtests/plugins/test-post-merge-cleanup.sh; Targets: the Step 3 flow; Key cases: untracked-only blocks; staged-only blocks; unstaged-only blocks; a genuinely clean worktree is removed, proving the gate blocks dirty trees specifically rather than blocking everythingtests/plugins/test-post-merge-cleanup.sh; Targets: the path checks in references/stale-directories.md; Key cases: a path inside the worktrees root is accepted; a sibling path outside it is rejected; a path still present in git worktree list is rejected; a path whose .git/worktrees/<name> admin directory still exists is rejectedtests/plugins/test-post-merge-cleanup.sh; Targets: fenced code blocks under skills/post-merge-cleanup/; Key cases: no GNU-only find -newermt '<relative>' form, which BSD find rejects and bfs errors on; the documented find -mtime -90 form actually executes on the host. Added after the GNU-only form failed during end-to-end verification.tests/plugins/test-post-merge-cleanup.sh; Targets: fenced code blocks in every .md under kit/plugins/git-agent/skills/post-merge-cleanup/; Key cases: no worktree remove --force or -f inside a fenced code block; no bare branch -D inside a fenced code block; the safety contract's prose prohibitions naming those flags do not fail the test, since a rule that forbids a flag must be able to name itDefinition of done
The plan counts as done when every statement below is true — check each one off as you verify it.
Final check
One last pass to confirm the whole change works end to end.
Build a throwaway repo under mktemp -d with a main branch and three feature
branches: one merged with a merge commit, one squash-merged, one never merged.
Give each a worktree. Leave an uncommitted file in the first worktree and leave
the second clean. Run the documented flow from the repo root.
The first worktree must be reported as blocked, its file named in the report,
and left on disk unchanged with its branch undeleted. The second must be removed
and its branch deleted. The squash-merged branch must be recognized as cleanable
even though git branch --merged does not list it, and its deletion must use-D only after its merged PR is confirmed. The never-merged branch must not
appear as cleanable at all. Re-run with gh unavailable and confirm the run
completes, covers only the ancestry-merged branch, and says the list is
incomplete.
Then create a directory under the worktrees root with no git registration and
confirm the skill reports its size, file count, and recent files and takes no
action without a per-directory yes. Finally, cd into a worktree and invoke the
flow against that same worktree — it must refuse rather than delete the
directory it is running in.
Remove the temp repo afterward. Then confirm the packaging half:BASE_REF=main node scripts/check-plugin-versions.mjs exits 0, andnode scripts/build-readme-table.mjs && git diff --exit-code README.md exits 0.
Wrapping up
Three gates that must all pass before this plan is marked completed.
Completion Report
No items to report — all requirements met.