ai-git-cleanup — remove finished local work in one pass
SkillDev toolsRemoves finished local git work in one automatic pass, with no question and no name to type: dead worktrees first (`git worktree remove`, then `git worktree prune`), then branches already merged into the local `main`, then the branches a squash-merged pull request left behind (tree identical to `origin/main`, tagged before deletion). Trigger for "tidy up", "tidy up these branches", "tidy branches", "delete old branches", "start fresh", "limpieza de branches", "limpia branches", or by naming ai-git-cleanup. A branch that is not merged into `main` is left alone, unless its tree is byte-identical to `origin/main`, which is what a squash-merged pull request looks like afterward; the pass deletes those with the proof in hand, tags the tip, and never force-deletes anything else. Not for pushing, not for remote branches, not for opening a pull request.
Use ai-git-cleanup — remove finished local work in one pass in Claude, ChatGPT or Ahel Desktop
Free. Sign in, add ai-git-cleanup — remove finished local work in one pass and connect your AI. About a minute.
Also: Claude Code · Cursor · Codex
Then ask your AI: use the ai-git-cleanup skill
Details
Instructions available. Your AI can read the instructions. Execution depends on the setup they require.
Account requirements not reviewed. Check the skill instructions before use; ahel provides instructions and does not run this skill.
No other account needed.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
What this skill tells your AI
The instructions your AI receives, as published by arcasilesgroup/ai-engineering in skills/ai-git-cleanup/SKILL.md and read by ahel’s review.
Say a cleanup phrase and the pass runs itself. It deletes only work that is
finished — worktrees whose branch is merged into the local main, those merged
branches, and the branches a squash-merged pull request left behind (their
content provably already in origin/main) — and it never asks a question and
never takes a name. Anything unfinished, unprovable, or detached is left exactly
where it is. Run the steps in order, from the primary checkout, and report what
each one did.
The pass
-
Remove the dead worktrees. The loop walks the session worktrees only — the primary checkout is never a candidate, since
git worktree remove <repo>refuses it as a main working tree (and the pass leaves the tree you are standing in alone). For each worktree, read two facts before touching it: whether its HEAD is on a branch (git -C "<path>" symbolic-ref -q HEAD) and what uncommitted content it holds, ignored files included (git -C "<path>" status --porcelain --ignored --untracked-files=normal— the option is required: astatus.showUntrackedFiles=noin the repository or the global config makes the bare command print nothing at all, so the read must force the listing itself;normalkeeps a wholly ignored directory collapsed to one!! dir/line, so the report stays bounded and matches the list'sdir/entries literally). Remove it withgit worktree remove -- "<path>"only when the HEAD check names a branch that is already merged into the localmain, the status read printed no warning, and every status entry is either absent or an ignored path on the regenerable list below. Otherwise skip it and report it kept with the reason:- No branch (detached HEAD).
git worktree removeexits 0 on a clean detached worktree and the commits it holds become unreachable from every ref, so the pass never removes one: report it kept as "detached, no branch to prove". - Uncommitted content that is not regenerable. Tracked and untracked
content always keeps the worktree, and so does any ignored path outside the
list below — the removal is never forced, and git destroys ignored content
silently (a
.env, ignored screenshots). Report it kept and say what the status listed. - Unreadable status.
git statusexits 0 while warning on stderr when it cannot read a subdirectory or a path, so a read that warned is not a clean worktree: keep it and report the warning. The doubt is resolved by keeping, never by removing what the read did not see. - Ignored content that is all regenerable. Two groups, and nothing else:
the paths this framework rebuilds —
node_modules/,dist/,coverage/,.stryker-tmp/,reports/,.ai-engineering/receipts/,.ai-engineering/cache/,.ai-engineering/workflow/playwright/— plus the system and editor noise this repository already ignores in.gitignore:*.bun-build,.claude/reviews/, and*.DS_Store(macOS writes a.DS_Storeinto every directory a Finder window or an app touches, so a merged worktree whose only ignored content is one must still be removed). They are build, runtime, and machine output, never data, so they do not keep a worktree: remove it and name every ignored path dropped. The ceiling is that list — it is exactly what this framework regenerates and what the repository ignores as noise, so anything else ignored is treated as data and keeps the worktree. A match is anchored to the worktree root, never a substring or a bare name: the printed path must be the entry itself, or sit inside a listed directory, so a listed directory is taken whole (dist/coversdist/out.js, andnode_modules/covers everything under it). A lookalike is not the entry:packages/x/dist/out.jsis notdist/, andreports-archive/is notreports/. The two*-prefixed entries are the exception to the whole-path rule — they are suffix patterns matched against the path's last component at any depth, so*.DS_Storematchessub/.DS_Storeand*.bun-buildmatchesapp.bun-build. The read collapses a wholly ignored directory to one!! dir/line, so the report names the directories it drops; the ceiling is that a listed directory goes whole — a hand-written file insidedist/is dropped with it, unlisted, and only the paths git prints are ever seen. Never force a removal.
- No branch (detached HEAD).
-
Prune the stale ones. Run
git worktree pruneto drop registrations whose directory is already gone. It removes no live worktree. -
Delete the merged branches (needs git ≥ 2.56.0). Run
git branch --delete-merged refs/heads/main— it deletes only branches whose upstream is the localmainand that are fully merged into it, and never the branch checked out here. The option exists only from git 2.56.0: on any older git it fails with anunknown optionerror, and that is not a pass failure — steps 1 and 2 have already run — so report it and continue to step 4 for every merged branch. -
Fall back for a merged branch without that upstream. List the candidates with
git branch --merged main, then drop the current branch and any branch checked out in another worktree (the lines decorated*or+) and read each remaining name without its decoration. Delete each withgit branch -d -- "<branch>". The name goes in as one quoted argument that is data, never re-typed into a shell line: a branch name may legally carry metacharacters ($(…),;,|, spaces), and an unquoted interpolation executes them.--ends the options, so a name like-rcannot be read as a flag. A refusal is a keep, and it is never retried:-dtests the branch against its own upstream, so a branch merged intomainwhose upstream points elsewhere (a laggingorigin/main, arefs/heads/behind) is refused — report that branch kept and name the upstream mismatch as the reason. -
Reconcile branches a squash landed.
git branch --merged mainmisses the commonest end state of a pull request: GitHub squash-merges it into one commit whose ancestry contains nothing from the branch, sogit branch --merged mainnever lists it and the branch looks "unmerged" forever. Detect it instead of guessing: a branch (never the current one, never one checked out in a worktree) whose tipgit diff --quiet <branch> origin/mainreports byte-identical toorigin/main's tree has landed — nothing the branch holds is missing from the remote. Tag the tip first (git tag archive/<branch> <branch>) — the tag is the receipt that the history stayed reachable — then delete withgit branch -D -- "<branch>". This is the pass's only force-delete, it is earned by the whole-tree comparison, and it never applies to a branch whose tree differs fromorigin/main: that one still carries something the remote does not have — leave it alone and report it as parked (never merge it, never re-open a pull request for it: that decision is the human's, on their ask). Also refuse the whole step whenorigin/maincannot be fetched or the remote is not configured: without a fetchedorigin/mainthere is no proof, and nothing deletable. After any deletion, check whether the localmaincan still be fast-forwarded: if a squash landed, the localmain's history was bypassed and only a human's reviewedgit reset --hard origin/mainlevels it — report that state, never reset on the pass's own initiative.
Safety invariants
- Never force-delete anything without a proof in hand. Worktree removals
are never forced: a refusal is the correct outcome and is reported as kept,
never retried with force. The one force-delete the pass may run is step 5's
git branch -Don a branch whose tree is byte-identical toorigin/main, tagged first — without that proof,-Ddoes not exist for this pass. - An unmerged branch is never deleted — by ancestry. Steps 3–4 delete only
branches whose commits are in
main's ancestry; a branch with work not inmainis left alone. The one exception is step 5's whole-tree proof: a branch whose tree is byte-identical toorigin/mainholds nothing the remote lacks, however unmerged its ancestry looks. - A worktree with no branch is never removed. A detached HEAD has no branch to prove merged, so the pass keeps that worktree and reports it as detached: its commits never lose their only ref.
- Uncommitted content keeps its worktree — except regenerable output and ignored noise. Tracked, untracked, and ignored-but-not-regenerable content all keep it; only the named build/runtime paths and the system/editor noise the repository already ignores may be dropped, and every dropped path is reported.
ai-eng worktree rmis the explicit human path. A person typed it, or the close ran it after the merge landed, so it removes what this pass keeps — a worktree holding ignored content is deleted by the verb, not by this pass — and it prints the ignored paths it drops. The pass is the automatic path and errs on keeping; the verb answers a named worktree its caller asked to remove.- No name is ever asked, and there is no interactive question. The pass decides from git state alone; it never prompts for a branch or worktree name.
- The local
mainis the only destination that matters. No remote is read for the merge decision and nothing is ever pushed. - No deletion starts before the worktrees holding a merged branch are gone. A branch checked out in a worktree cannot be deleted; removing that worktree first is what lets the pass finish.
Lifecycle
Lane: any Writes: nothing Read by: the person who asked for the cleanup Dies: after each pass; the report is a snapshot of that moment Next: none
Signals
- GitHub stars
- 60
- Forks
- 3
- Last commit
- Oct 2026
ahel review
K4blow
destructive-scoped
Automated review, not a security audit. Ruleset v1+k2.
Advanced
- Item type
- skill
- Key
ai-git-cleanup- Source
- github.com/arcasilesgroup/ai-engineering
github.com/arcasilesgroup/ai-engineering