ai-git-cleanup · diff
git:20260929.8cccd61 to git:20261004.3e3bd00
121 added, 182 removed. Audit A to A.
---
name: ai-git-cleanup
description: >-
- Action-first cleanup of finished local git branches and worktrees — one
- question lists every removal, the answer runs it, the report says what
- happened. Auto-invoked by "tidy up", "tidy up these branches", "tidy
- branches", "sync to main", "delete old branches", "start fresh", "limpieza
- de branches", "limpia branches", "sincroniza a main", or by naming
- ai-git-cleanup. Phase 0 migrates to the default branch by itself (auto-stash,
- switch, ff-only pull), the analysis is read-only, every delete is guarded and
- single-quoted, and nothing is ever pushed.
- license: LicenseRef-Attributed
+ Removes 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`.
+ 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: the pass never force-deletes and no name is ever asked. Not for
+ pushing, not for remote branches, not for opening a pull request.
+ license: Apache-2.0
---
- # ai-git-cleanup — remove finished local branches with proof
-
- Say the cleanup phrase and the run takes over: it gets the repository ready by
- itself, shows one list of what it will remove, asks you once, then removes it
- and tells you what happened to every branch. Four phases in order — migrate
- (automatic and reversible), analyze (read-only), ask (the only confirmation),
- execute and report. No deletion starts before that one answer, and none
- happens without it.
-
- ## What it produces
-
- One JSON document on stdout from [scripts/analyze.mjs](scripts/analyze.mjs)
- (never mutates the repository), with sections scoped by the mode flag:
-
- - `fetchStatus` (`ok`, or `failed: <first stderr line, trimmed>` after the
- best-effort `git fetch --prune origin` — a failed fetch must show on screen,
- never silent) and `defaultBranch` (from `origin/HEAD` with a main fallback,
- never from HEAD; an empty default fails every branch closed to `unanalyzed`
- with reason `NO_DEFAULT_BRANCH`).
- - `migration` (absent with `--branches`): `previousBranch` (the pre-migration
- branch, for the report) and the steps in execution order — `stash` and `pop`
- only when the working tree is dirty, `switch` when a default exists, `pull`
- only when the default is behind its live upstream — each step carrying its
- exact command and a WARN-and-continue annotation for its failure.
- - `batch` (absent with `--sync`): the ONE array the ask renders. Every entry
- carries `branch`, `category`, `action` (`-d` or `-D`), `evidence`, the exact
- single-quoted `command` (the `-d` command bundles the merge-base guard), an
- optional `worktreePath` back-link to the holding worktree, and an
- informational `cluster` when the name groups with a sibling.
- - `keep` (branch, reason, evidence, optional cluster — never a command) and
- `unanalyzed` (always present, even empty: a partial analysis must never read
- as a complete one).
- - `worktrees` (path, branch, dirty, dirtyFiles, `requiresAcknowledgment` on
- dirty ones, stale, and `command` only when a clean removal is queued) and
- `postCleanup` (`prune` only when a removal is queued).
- - `report` (absent with `--sync`): `previousBranch`, `stashState` (this run's
- auto-stash message, or `none`), and one row per branch — `branch`, `action`,
- `reason`, `upstream`, `ahead`, `behind` (counts report-only).
-
- ## Flags
-
- The mode flag mirrors the invocation. Arguments: a mode plus an optional repo
- path (default: the current directory).
+ # ai-git-cleanup — remove finished local work in one pass
- - `--all` — the default: everything, phases 0-3.
- - `--sync` — phase 0 only: migrate to the default branch, then stop. No
- analysis, no ask, no deletion.
- - `--branches` — phases 1-3: analyze, ask, execute, report — migration stays
- out, HEAD never moves.
+ 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`, and those
+ merged branches — 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.
- ```sh
- node scripts/analyze.mjs # --all: phases 0-3 (the default)
- node scripts/analyze.mjs --sync # phase 0 only: get ready, stop
- node scripts/analyze.mjs --branches <repo> # phases 1-3: no migration
- ```
+ ## The pass
- The phrase picks the flag: "sync to main" → `--sync`; "tidy up", "delete old
- branches" → the default `--all`; stay on the current checkout → `--branches`.
+ 1. **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: a `status.showUntrackedFiles=no` in the repository or the
+ global config makes the bare command print nothing at all, so the read must
+ force the listing itself; `normal` keeps a wholly ignored directory collapsed
+ to one `!! dir/` line, so the report stays bounded and matches the list's
+ `dir/` entries literally). Remove
+ it with `git worktree remove -- "<path>"` only when the HEAD check names a branch
+ that is already merged into the local `main`, 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 remove` exits 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 status` exits 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_Store` into 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/` covers `dist/out.js`, and
+ `node_modules/` covers everything under it). A lookalike is not the entry:
+ `packages/x/dist/out.js` is not `dist/`, and `reports-archive/` is not
+ `reports/`. 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_Store` matches `sub/.DS_Store` and `*.bun-build` matches
+ `app.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 inside `dist/` is
+ dropped with it, unlisted, and only the paths git prints are ever seen.
+ Never force a removal.
+ 2. **Prune the stale ones.** Run `git worktree prune` to drop registrations
+ whose directory is already gone. It removes no live worktree.
+ 3. **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 local `main` and 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 an `unknown option` error, 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.
+ 4. **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 with
+ `git 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 `-r` cannot be read as a flag. A
+ refusal is a keep, and it is never retried: `-d`
+ tests the branch against its own upstream, so a branch merged into `main`
+ whose upstream points elsewhere (a lagging `origin/main`, a
+ `refs/heads/behind`) is refused — report that branch kept and name the
+ upstream mismatch as the reason.
## Safety invariants
- - **One ask per delete batch is the hard floor — never zero.** Exactly one
- `ask` precedes any deletion; nothing destructive runs before it. Phase 0's
- stash/switch/pull already ran, because they are reversible — say so at the
- ask.
- - **PROTECTED names, the default branch and the checked-out branch are never
- deletable.** One guard sits at the single classification choke point every
- categorization path funnels through (a regex plus explicit names, before any
- triage) — no per-path exceptions. A recommendation that names one of them is
- a bug: stop and report it.
- - **Every batch entry carries named evidence** — its category plus the tip sha,
- empty diff or `[gone]` state behind it. A category without evidence is not a
- recommendation; it goes to keep.
- - **Every refname and path is single-quoted in every command you emit.** Git
- refname rules allow `$(...)`, backticks, quotes, `;`, `|` and `&`; double
- quotes still execute the first two. An apostrophe escapes by closing and
- reopening: `'has'\''quote'`.
- - **The guard runs immediately before every `git branch -d`** — the entry's
- command bundles `git merge-base --is-ancestor 'refs/heads/<branch>'
- '<default>'` ahead of the delete, naming refs and the default branch, never
- a reported sha. Guard failure skips the delete and reports it.
- - **The sync target is pinned:** the pull names the default branch as its
- destination and runs with HEAD already switched there — never a
- HEAD-ambiguous pull.
- - **A dirty worktree is never removed without the person acknowledging the
- data loss in their own words, per worktree.** Without that acknowledgment it
- is refused and its branch stays keep (`WORKTREE_HELD`); the refusal
- outranks any ordering.
- - **`unanalyzed` is always surfaced, and uncertainty fails closed.** A broken
- inventory, a missing default, a failed diff — each lands in keep or
- `unanalyzed` with its reason, visible in the report, never in the batch.
- - **Deletions run only in the main session.** Analysis subagents are strictly
- read-only and never ask the person anything.
- - **No push, no reflog GC, no `git gc`, no CI, no remote deletion, ever.**
- Ahead/behind counts are report-only.
-
- ## Phases
-
- ### Phase 0 — get ready (automatic, no ask)
-
- Runs with HEAD on the default branch — the invariant that keeps the pull's
- target unambiguous. From `migration`, in execution order:
-
- 1. The default is already detected (`defaultBranch`); record `previousBranch`
- — the branch to return to, and the report's "previous branch".
- 2. Dirty working tree → run `stash` (`git stash push -m
- cleanup-auto-stash-<ts>`). A failed stash push WARNs: secure the
- uncommitted work manually before anything moves the checkout.
- 3. Run `switch` (`git switch '<default>'`). A failure WARNs and leaves HEAD
- where it is: resolve the checkout before continuing.
- 4. `pull` appears in the plan only when the default is behind its upstream:
- run `git pull --ff-only '<remote>' '<default>'`, with HEAD already on the
- default so the destination is pinned by name. A failure WARNs: skip the
- pull and continue the cleanup against the local default — cleanup does not
- need a synced default.
- 5. Stashed? Run `pop` (`git stash pop`). A conflict WARNs: the stash is left
- in place (inspect with `git stash list`), continue the cleanup, and the
- report's `stashState` names it.
-
- None of this asks — it is all reversible. With `--sync`, phase 0 is the whole
- run: execute it, show what migrated, stop.
-
- ### Phase 1 — analyze (read-only)
-
- Run [scripts/analyze.mjs](scripts/analyze.mjs) with the mode flag that mirrors
- the invocation (see Flags). It prints the one JSON document described above and
- never mutates the repository.
-
- ### Phase 2 — the one ask (the only confirmation)
-
- Present, in execution order:
-
- - `fetchStatus` — `ok` or `failed: <reason>`; a failed fetch is visible, never
- silent.
- - The batch table: branch, category, evidence, exact command — with the
- worktree removals that precede deletions at the top (each batch entry's
- `worktreePath` names the worktree that must go first).
- - Dirty-worktree warnings: the `requiresAcknowledgment` worktrees and where
- their branches went — NOT in the batch, they sit in keep as
- `WORKTREE_HELD`. Each removal needs its own explicit data-loss
- acknowledgment; until then, refused.
- - `postCleanup` — `git worktree prune`, present only when a removal is queued.
-
- The report is deliberately not previewed here — it comes after execution
- (phase 3). Then ONE `ask`: delete all, pick, or stop. Nothing destructive has
- run before this answer; phase 0 already ran and was reversible (say so). Any
- other answer stops the run with the batch untouched.
-
- ### Phase 3 — execute in the main session, then report
-
- 1. Worktree removals first: every queued `git worktree remove '<path>'`.
- 2. Then the batch, entry by entry: run the exact command as emitted — for
- `-d` that means the guard, then the delete; a guard failure skips that
- branch and shows up in the report as kept, never as a forced delete. `-D`
- entries run as their own evidence-backed command.
- 3. `git worktree prune` only when a removal actually happened (`postCleanup`
- is in the document exactly then). Never a push — ahead/behind stay
- report-only.
- 4. Print the report from `report`: the default branch, the previous
- (pre-migration) branch, `stashState`, then one row per branch — action,
- reason, upstream, ahead/behind as report-only counts. Skipped guards appear
- as kept rows with their reason.
-
- Deletions run only in the main session; a subagent never executes a deletion
- and never asks the person anything.
-
- ## Out of scope
-
- - Remote branches: no `git push origin --delete`, ever — and no plain
- `git push` either: an ahead default branch is reported, never pushed.
- Local git only.
- - No reflog GC, no `git gc`, no CI, no auto-deletion: the one ask is the hard
- floor, never zero.
- - Not a branching model: this cleans up branches another process created.
+ - **Never force-delete anything.** No forced branch delete and no forced
+ worktree removal: a refusal is the correct outcome and is reported as kept,
+ never retried with force.
+ - **An unmerged branch is never deleted.** Both the delete-merged pass and the
+ fallback delete only what is already merged into `main`; a branch with work
+ not in `main` is left alone.
+ - **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 rm` is 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 `main` is 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 approving the cleanup
- Dies: after each run; the report is a snapshot of that moment
+ Read by: the person who asked for the cleanup
+ Dies: after each pass; the report is a snapshot of that moment
Next: none
-
- Source: adapted from the Trail of Bits git-cleanup plugin
- (https://github.com/trailofbits/skills/tree/main/plugins/git-cleanup), CC BY-SA 4.0.