/cleanup-feature $ARGUMENTS
You are running the cleanup phase. The work for $ARGUMENTS is finished and in develop;
this skill removes what is left behind — the worktree, the local branches, the remote branches.
This skill deletes things, and remote deletion affects the whole team. Two rules hold throughout:
- Verification comes before deletion. If the work is not in
develop, you report and abort. You never delete to "clean up" something that might still be needed. - Nothing is deleted before the user says so. Read-only inspection runs freely. Every destructive command waits for the plan in Step 5 to be approved.
Step 1 — Resolve the target
Refresh remote state first, so a branch someone already deleted upstream is not treated as live:
git fetch origin --prune
Find every branch and worktree carrying the ID:
git branch --list "*$ARGUMENTS*" -vv
git branch -r --list "*$ARGUMENTS*"
git worktree list
Classify what you found by branch prefix:
epic/…→ epic. Go through the recursive path (Step 4).feature/…,fix/…,hotfix/…→ standalone branch. Skip Step 4.
Then resolve ambiguity before going further:
- No branches and no worktree → say so and stop. There is nothing to clean up.
- Several branches share the ID → list them with their last commit date and ahead/behind
counts, and ask which are in scope. An ID often spans a base epic plus sub-epics (
-D1,-D3) plus afix/branch, and they are rarely all finished at the same time. - A worktree directory matches the ID but holds an unrelated branch → point this out explicitly and ask before touching it. Worktree directories get reused for other work, and the directory name stops being the truth when they do.
Step 2 — Verify the work is in develop
This is the gate. Run it for the target branch, and later for every branch Step 4 discovers.
git fetch origin develop
git merge-base --is-ancestor <branch> origin/develop && echo MERGED || echo NOT-ANCESTOR
If the ancestry check fails, fall back to a content check before concluding anything — a squash-merged branch is fully delivered even though none of its commits are ancestors:
git diff --stat origin/develop...<branch>
git log --oneline origin/develop..<branch>
Read the result as:
| Ancestry | Content diff | Verdict |
|---|---|---|
| ancestor | — | Merged. Proceed. |
| not ancestor | no differing files | Squash-merged. Say which check passed, then proceed. |
| not ancestor | files differ | Not merged. Abort — go to Step 3. |
Step 3 — Abort report (only when something is unmerged)
Do not delete anything, and do not offer a way to force past this. Report:
- The branch, and how many commits it holds that
developdoes not - Those commits, from
git log --oneline origin/develop..<branch> - The files that differ, from
git diff --stat origin/develop...<branch> - Whether the branch exists on origin, so the user knows if the work is backed up anywhere
Then stop and tell the user their options in plain terms: merge it first, or confirm they want it abandoned and re-run naming the branch explicitly. Abandoning unmerged work is their call to make, never yours.
Step 4 — Discover an epic's feature branches (epics only)
The epic plan document decides which branches belong to the epic. Git does not. Read the plan first, every time. Git ancestry is only a cross-check, and Step 4b explains why it cannot be trusted on its own.
Step 4a — Read the epic plan
An epic has a plan document under docs/agomez/plans/, named for its ID:
ls docs/agomez/plans/ | grep <epic-id>
Usually <epic-id>-<short-name>.md, sometimes <epic-id>-plan-progress.md. An epic may also have
more than one file — a hotfix plan alongside the main one. Read the main plan.
That document lists every feature task of the epic, each with its own Jira number, in tables. Pull the IDs out:
grep -oE 'MLID-[0-9]{3,}' docs/agomez/plans/<epic-plan>.md | sort -u
Use {3,}, not + — a bare MLID-[0-9]+ also matches prose fragments such as MLID-28 and
reports them as tasks. Then read the lines around each ID. An ID in a task table is a sub-task of
this epic; an ID mentioned in passing ("this reuses the query builder from MLID-XXXX") is not.
The rule: if MLID-YYYY is listed as a task in the plan for epic MLID-XXXX, then the branch for
MLID-YYYY belongs to that epic and is cleaned up with it.
Match each plan ID to its branches, local and remote — a sub-task may have several (a feature/
branch plus a later fix/ or hotfix/ branch), and some exist only on origin:
git branch -a --list "*MLID-YYYY*"
Step 4b — Cross-check with git, and expect it to disagree
git branch --merged <epic-branch> | grep -E "feature/|fix/|hotfix/|epic/"
git log --merges --oneline <epic-branch> --grep="into <epic-branch>"
Use this to catch branches the plan does not mention, never as the list itself. Ancestry is wrong in
both directions, and a real run of this skill on MLID-2806 showed both at once:
- It over-matches.
develophad been merged into that epic 10 times, so everything already in develop counted as "merged into the epic". Discovery returned 20 branches; 7 were unrelated work (a turbopack migration, a login fix, a chat refresh loop) and 4 of those were still live on origin. - It under-matches. Three branches the plan listed as epic tasks were missed entirely, because
they were never merged into the epic branch — they went straight to
develop, or were made after the epic closed.
When the plan and git disagree, the plan wins for deciding membership, and Step 2 still decides whether each branch is safe to delete.
Recurse. A branch found this way may itself be an epic (epic/…-D1, epic/…-D3) with its own
plan and its own sub-tasks. Repeat Step 4a for it. Depth is usually two.
Every discovered branch goes through Step 2 on its own. Being listed in the plan, or merged into
the epic, is not the same as being in develop. If any one fails, drop that branch from the plan,
keep the rest, and say clearly in Step 5 which were held back and why.
No plan document? Say so plainly and do not fall back to ancestry as though it were equivalent. Present what git suggests, labelled as a guess, and confirm the branches one at a time.
The degenerate case — read this before trusting the list
When an epic has been fast-forwarded onto develop, its tip and develop are the same commit. Then
git branch --merged <epic> means git branch --merged develop, which is every merged branch in
the repository, and the first-parent history is develop's entire history. Discovery silently stops
being about this epic at all.
Check for it explicitly:
test "$(git rev-parse <epic-branch>)" = "$(git rev-parse develop)" && echo DEGENERATE
If it prints DEGENERATE, or if discovery returns more than about ten branches, stop trusting the
automatic list. Tell the user plainly that discovery is unreliable here and why, then work from
the epic's merge commits and confirm the branches one at a time. Do not present a long
machine-generated list as though it were a finding.
Step 5 — Present the plan and wait
Put everything in one report, and wait for an explicit go-ahead. Do not start deleting because the earlier steps looked clean.
## Cleanup plan for $ARGUMENTS
**Verified in develop:** <how — ancestry, or squash-merge content check>
### Worktree
| Path | Branch it holds | Working tree | Unpushed | Action |
|---|---|---|---|---|
| ~/Dev/worktrees/MLID-XXXX | feature/… | clean | none | remove + delete directory |
### Branches
| Branch | Local | Remote | In develop | Action |
|---|---|---|---|---|
| epic/MLID-XXXX-name | yes | yes | ancestor | delete both |
| feature/MLID-YYYY-name | yes | yes | ancestor | delete both |
### Held back
| Branch | Why |
|---|---|
| feature/MLID-ZZZZ | 3 commits not in develop |
State the totals in words as well — how many branches locally, how many on origin — so the scale is clear before approval, not after.
Step 6 — Remove the worktree
Check the worktree before removing it. A clean tree is not enough on its own; commits that exist nowhere else are the thing that cannot be recovered:
cd <worktree-path>
git status --short --branch
git log --oneline @{u}..HEAD
If it has uncommitted changes or unpushed commits, stop and show the user. That is new information which arrived after the plan was approved, so it needs a fresh decision.
git worktree remove <worktree-path>
git worktree list
ls <worktree-path> # confirm the directory is actually gone
git worktree remove deregisters the worktree, but it can leave the directory behind — usually
gitignored leftovers such as empty node_modules/ or apps/ trees. Always check. If a directory
remains, list what is inside it before removing it:
find <worktree-path> -type f | head -40
If that lists no files, only empty directories, delete it with rm -rf. If any file is left,
show the user what it is and ask. Gitignored files such as .env.local are not in git and are gone
for good once deleted.
Step 7 — Delete the branches
Local first, then remote. Local deletion is trivially reversible; remote deletion is the one that reaches other people.
Every branch you delete locally is also deleted on origin if it exists there. Local and remote do not get different treatment — a branch is either being cleaned up or it is not. Check each one rather than assuming, because within a single epic some sub-task branches exist only locally while others were pushed:
git show-ref --verify --quiet "refs/remotes/origin/<branch>" && echo "on origin" || echo "local only"
# Local — -d refuses anything not merged, which is the safety net. Never use -D here.
git branch -d <branch>
# Remote — for every branch above that exists on origin
git push origin --delete <branch>
A branch that exists only on origin still counts. It has no local copy to delete, but it is part of the cleanup and is deleted there.
Order matters for an epic: delete the feature branches first, deepest first, then the epic itself. Deleting the epic first strands its children with nothing pointing at them.
If git branch -d refuses a branch, do not reach for -D. The refusal contradicts Step 2, which
means one of the two is wrong — stop, show the user both results, and let them resolve it.
A branch checked out in any worktree cannot be deleted. Step 6 must remove the worktree first.
Step 8 — Report
Confirm what is gone and what survived:
git branch --list "*$ARGUMENTS*"
git branch -r --list "*$ARGUMENTS*"
git worktree list
State it plainly: branches deleted locally, branches deleted on origin, worktree and directory removed, and anything deliberately kept. If a branch was held back, repeat why — the user is deciding whether to act on it, and the reason should not need scrolling to find.
Important Rules
- Verify before deleting. Step 2 gates everything. An unmerged branch aborts the run.
- The epic plan document decides membership, not git ancestry. Read
docs/agomez/plans/<epic-id>-*.mdand take the sub-task IDs from it. Ancestry over-matches and under-matches, and has done both on a real run. - Local and remote are cleaned together. Any branch deleted locally is deleted on origin too if it exists there, and a branch that exists only on origin is still deleted.
- Never delete unmerged work, even when asked to during the run. Report it and let the user decide in a separate step.
- Never delete
developormain, whatever the discovery returns. - Never
git branch -D. Only-d. A refusal is information, not an obstacle. - Never force-delete a remote branch to get past an error.
- Confirmation is per run, not per session. Approving one cleanup does not approve the next.
- New information voids the approval. Unpushed commits or a dirty tree found in Step 6 means going back to the user, not pressing on.
- Do not touch Jira. No transitions, no comments — cleanup is a git operation.
- Do not delete plan or PR documents under
docs/agomez/. They are the record of the work and outlive the branch. - The user's own branch list wins. If they name the branches to clean up, use their list and still run Step 2 on each.