cleanup · diff
git:20260907.b1a8d57 to git:20260907.bdb897a
17 added, 6 removed. Audit A to A.
---
name: cleanup
description: Clean up after a merged pull request — return to the base branch, remove the worktree, delete the branch, sweep other stale branches and worktrees, and report tracker state. Use after a merge or whenever asked what is left over.
when_to_use: "clean up the worktree and merged branches", "any remaining worktrees?", "delete merged branches", after csw:merge lands a PR
---
# Clean up after a merged pull request
**Announce at start:** "Using csw:cleanup to close out <ticket>."
Once the merge is confirmed, branch and worktree cleanup happens without asking. Closing a
ticket always asks. Those two rules are not symmetric and the asymmetry is deliberate.
Confirming the merge is itself the one precondition cleanup checks before it removes
anything — that check is not optional, even though most of the time it passes instantly.
## Step 1: Confirm the merge, then note where you are
Before anything is removed, establish that the PR for this branch is actually merged:
```bash
gh pr view --json state,mergedAt
```
If this run was chained straight from **csw:merge**, the merge is already confirmed there —
say so, and this check simply passes; the chained path stays frictionless.
The only thing that authorises deletion is `gh pr view` reporting `state: MERGED`. Anything
else is a stop-and-ask: a non-merged state, no PR for this branch, or
**the command failing for any reason** — no auth, no configured remote, a network error, or
anything else. Command failure is not "no signal, treat it as merged" — it is exactly the same
stop as an explicit non-merged state. No substitute may be used to establish the merge
instead: not `git log --merges`, not `git branch --merged`, not reading the PR page. Only
`gh pr view` reporting `state: MERGED` counts.
If the check does not clear, **stop**. Name the branch and what you found — including the raw
error if the command itself failed — and ask whether to clean up anyway. This is the one case
where branch and worktree cleanup asks: `git branch -d` refuses an unmerged branch on its own,
but nothing stops `git worktree remove` from deleting the checkout that holds someone's
unlanded work, and the worktree carries no such protection of its own.
Once the merge is confirmed:
```bash
git rev-parse --show-toplevel # this worktree's path
git branch --show-current # the branch about to be deleted
csw-config get baseBranch
```
Capture all three now. Step 2 changes directory and Step 3 needs these values.
## Step 1a: Stop what is running from this worktree
```bash
csw-services stop "<path from Step 1>"
```
**This never asks**, for exactly the reason branch cleanup never asks. Removing the worktree is
bookkeeping, and so is stopping the things that lived in it. Every argument for deleting the
worktree unprompted applies unchanged to the processes inside it — and asking is how it gets
forgotten, while a forgotten one goes on answering `:5432` for the next ten hours.
**Cleanup is what manufactures these orphans.** It does not merely fail to tidy up: removing the
worktree is the moment those processes stop having a valid home and become indistinguishable
from the live session's. The cost is not the wasted process — it is a test run going green
against a backend, frontend or **database** belonging to a different branch, with every
precondition check passing. And a healthy orphan does not get ignored, it gets **adopted**: the
next stack finds it present, healthy and on the expected port, attaches rather than starting its
own, and then correctly disclaims something it did not start. Every subsequent session reaches
the same conclusion, which is what makes an orphan permanent.
**It runs before the worktree is removed, and that ordering is load-bearing three times over.**
A graceful teardown often has to run from inside the directory; once the directory is gone the
compose file goes with it; and a container that bind-mounts the worktree can leave root-owned
files behind that a later non-root removal cannot delete. Step 2 is where `ExitWorktree` removes
the directory, so this is the last moment any of that is still possible.
- `csw-services` names every process group and compose project **before** it signals anything, so
- an unprompted destructive step is still reviewable. **Pass that on, including the empty case** —
- `nothing running from <path>` is an answer, and silence is indistinguishable from not having
- checked. If it names something that looks like a test harness mid-run, say so in the report:
- losing one costs a measurement, and the human should learn that from you rather than from a
- result that never arrives.
+ `csw-services` names everything **before** it signals anything, so an unprompted destructive step
+ is still reviewable. **Pass that on, including the empty case** — `nothing running from <path>`
+ is an answer, and silence is indistinguishable from not having checked. If it names something
+ that looks like a test harness mid-run, say so in the report: losing one costs a measurement, and
+ the human should learn that from you rather than from a result that never arrives.
+ **Report the `holding files open` section separately, and never treat it as done.** Those
+ processes are *not* stopped, deliberately — an editor, an LSP or a `tail -f` from another
+ terminal did not come from this worktree, and killing one is a blast radius this must not have.
+ But removal still **breaks** them, and silently: the descriptor goes on pointing at a deleted
+ inode, and an inotify watch on a deleted directory never fires again, so the editor holds a
+ buffer it can no longer save and the watcher stops rebuilding with no error anywhere. That is a
+ different failure from an orphan — an orphan survives and gets wrongly adopted, a holder
+ survives and stops working. Name them and let the human decide; do not go looking for them to
+ kill.
+
Its scope is one path. It stops what came from *this* worktree and nothing else.
**It is not a zombie reaper**, and there is no machine-wide mode to reach for. That boundary makes
acting without asking safe: what it might touch is answerable in one sentence. A supervised
`systemctl --user` unit, a buildkit container, an editor's MCP server and a database started
from the main checkout are all out of scope for the same reason rather than four different ones.
If `csw-services` itself fails — not "found nothing", but exited non-zero — report that and
carry on with the cleanup. Removal is still correct; what is unknown is whether anything
survived it, and saying so beats either stalling or implying a clean teardown.
Orphans whose worktree is *already* gone are not reachable from here, because no run owns them.
Step 4's sweep reports those.
## Step 2: Leave the worktree
Use the native **ExitWorktree** tool if one exists — it owns removal for worktrees it
created. Otherwise move to the main worktree root by hand:
```bash
cd "$(git -C "$(git rev-parse --git-common-dir)/.." rev-parse --show-toplevel)"
```
Worktree removal must run from outside the worktree being removed.
### When ExitWorktree warns it will discard commits
`ExitWorktree` refuses to remove a worktree whose branch it believes carries unmerged
commits, and asks to be re-invoked with `discard_changes: true`:
```
Worktree has 2 commits on worktree-fix+76-csw-sweep-misses-branches-merged-upstrea.
Removing will discard this work permanently. Confirm with the user, then re-invoke
with discard_changes: true — or use action: "keep" to preserve the worktree.
```
**Expect this on every single cleanup.** The tool compares the branch against the worktree's
**creation point** — the commit the base branch was on when the worktree was cut — and cleanup
by definition runs after commits have been added since. So the warning **cannot be suppressed
by anything cleanup does**. Pulling or fast-forwarding the base first does not help: that was
measured directly, by moving the local base *ahead* of the branch head before exiting and
watching the warning fire anyway. Do not re-run that experiment.
Because it always fires, it carries no information on its own, and both reflexes it invites
are wrong: stalling on a warning you have not checked, and passing `discard_changes: true`
because the PR is merged so the warning "must" be spurious. The second one eventually discards
work that really was unmerged.
Prove it instead. The refused exit leaves you still inside the worktree, so this runs from
right there — capture the branch head **before** exiting, because afterwards the branch may
be gone:
```bash
head_sha=$(git rev-parse HEAD)
git fetch -q origin
git merge-base --is-ancestor "$head_sha" "origin/<baseBranch>"
```
Exit 0 proves every commit on this branch is already in `origin/<baseBranch>`. Nothing is
lost; re-invoke `ExitWorktree` with `discard_changes: true` and carry on.
**Non-zero means the warning is real** — there are commits here that did not ship. Stop,
report the SHAs (`git log --oneline "origin/<baseBranch>..HEAD"`), and do not discard.
This proves safety directly rather than inferring it from the tool's warning, so it holds no
matter what the tool is comparing against.
Two things this is **not**:
- **Not the branch rename.** The name in the warning is the pre-rename `worktree-<slug>` that
`csw:work` Step 4 renamed away, which makes the rename look causal. It is not: the commit
*count* is correct, and a branch name that no longer exists could not produce a correct
count. The stale name is display only. Renaming back will not suppress the warning, and the
rename is what ties the branch to its ticket — do not abandon it.
- **Not a reason to skip Step 1.** A merged PR is not by itself proof that *this* branch head
is in the base. Verify the SHA.
### Land on the base branch
Once the worktree is gone, **on either path**, land on the base branch and take the remote's
latest:
```bash
git checkout "<baseBranch>"
git pull --prune
```
`git pull` is not optional and is not only for the manual path. The merge you just landed
is on the remote, not in this checkout — skip the pull and the local base branch sits one
commit behind from the moment cleanup finishes, which then silently backdates the next
worktree branched from it. If the pull fails, say so and stop before removing anything.
`--prune` is not cosmetic either — it is what makes Step 4's sweep able to see anything at
all. `csw-sweep` finds upstream-deleted branches through `%(upstream:track)` reporting
`[gone]`, and that says `[gone]` only once `refs/remotes/origin/<name>` is missing **locally**.
Merging with `--delete-branch` deletes the branch on the forge; **only a prune** removes the
remote-tracking ref that mirrors it, and a plain `git pull` does not prune. Drop the flag and
that half of the sweep silently never fires — every leftover it reports comes from the
merged-into-base half instead, which is exactly the half that cannot see a branch that shipped
without becoming an ancestor of the base. The sweep cannot do this for itself: it must never
fetch, and pruning mutates refs.
## Step 3: Remove this worktree and branch
No confirmation here — Step 1 already confirmed the merge. This is bookkeeping, and it **should never require a separate instruction**.
What happens next depends on what Step 2 actually did:
- **Step 2 used the native ExitWorktree tool.** It already owns removal, so the worktree is
already gone. Running `git worktree remove` again on a path it already removed fails with
`fatal: '<path>' is not a working tree` (exit 128) — that is "already gone," which is success,
not a problem to report. Skip straight to pruning and the branch delete:
```bash
git worktree prune
git branch -d "<branch from Step 1>"
```
- **Step 2 fell back to the manual `cd`/`checkout` path** because no native tool existed. The
worktree is still there, so remove it first:
```bash
git worktree remove "<path from Step 1>"
git worktree prune
git branch -d "<branch from Step 1>"
```
If `git worktree remove` refuses because of uncommitted changes, stop and show them. Work
that never made it into the merged PR is not clutter — report it and let the human decide.
Only use `--force` if they say so. This uncommitted-changes stop is specific to the manual
path — it cannot happen on the ExitWorktree path, since that tool has already removed the
worktree by the time this step runs.
If `git branch -d` refuses, the branch is not merged into the base. Say so and stop; do not
reach for `-D`.
The remote branch is already gone if the merge used `--delete-branch`. If it is still there:
```bash
git push origin --delete "<branch from Step 1>"
```
## Step 4: Sweep for everything else
```bash
csw-sweep
```
Check its exit code before trusting the output. Sweep results are never an error — finding
nothing is exit 0, and `nothing to sweep` is itself a normal, successful report. A **non-zero
exit always means the sweep did not run** — a bare repo, a broken config lookup — never that
it ran and found nothing. When that happens, report that the sweep itself failed, show its
message, and say plainly that stale branches and worktrees are **unknown, not absent**. Do
not substitute "nothing to sweep" for a sweep that never ran.
This is the part that turns *"any remaining worktrees or merged branches?"* from a question
someone has to remember into something reported unprompted. Report what it found even when
it found nothing.
The sweep may lead with a `note:` line — the local base being behind its upstream, or
upstream-gone detection being only as fresh as the last prune. Those are caveats on how far
the answer reaches, so pass them on with the findings rather than trimming them off. On the
chained path Step 2's `git pull --prune` has already refreshed the prune half; a prune note
surviving that means some branch still resolves an upstream this run has not pruned since, and
it is honest reporting, not a failure.
Then **ask before touching any of it**. These are other people's leftovers as far as this
session is concerned — list them, propose removing them, and wait. A vague "sure, clean it
up" is not approval for a list: either they name what goes, or ask again.
A stale worktree that `csw-sweep` lists but `git worktree remove` refuses to delete is the
known rough edge at the worktree-plus-shipped intersection. Report it plainly rather than
forcing it.
### The branch you are standing on
The sweep reports a merged branch even when it is the one currently checked out, marking it
`(current branch — check out <base> first, then delete)`. On the normal chained path this
never fires: Steps 2–3 already landed on the base branch and deleted the branch this run was
about. It fires when cleanup is invoked in a plain checkout that is simply sitting on work
that already shipped.
`git branch -d` refuses the checked-out branch, so — once it is approved along with the rest
of the sweep — land first, then delete:
```bash
git checkout "<baseBranch>"
git pull --prune
git branch -d "<the current branch the sweep flagged>"
```
That is the same land-then-delete Step 2 and Step 3 do, applied to a branch this run did not
open. The approval rule above is unchanged: it is still swept work, so it is still proposed
and waited on, never deleted on sight.
## Step 4a: End on the base branch
Cleanup finishes on the base branch, current with its remote, with nothing merged left lying
around. That is the whole point of the exercise — the next piece of work starts from a clean
checkout in a fresh session, with no leftovers to trip over. Before moving to the tracker:
```bash
git branch --show-current # must be <baseBranch>
git status -sb # must be clean, and not behind
```
If either says otherwise, say so explicitly rather than letting the session end somewhere
unexpected. Being left on a deleted branch's detached HEAD, or on a base branch three commits
behind its remote, is exactly the state that silently backdates the next branch cut from it.
## Step 5: The tracker, last
Report the ticket's current state. If the PR body said `Closes <TICKET>`, the tracker may
have moved it already — check rather than assume.
Then look for siblings before declaring it done. **Scope the search to this repo's owner.**
`gh search prs` with no scope searches all of GitHub, which is almost never what this step
wants:
```bash
owner=$(gh repo view --json owner --jq .owner.login)
gh search prs "<TICKET>" --owner "$owner" --state open --json repository,number,title,url
```
With `tracker: linear` the ticket is a distinctive string like `ENG-123`, and even that query
wants the scope. With `tracker: github` it is a **bare number**, which matches every PR whose
title happens to contain those digits — version bumps, unrelated issue numbers, strangers'
repos. Run unscoped for `81` and you get thirty PRs from thirty projects, none of them
siblings.
Scoping is the floor, not the whole answer. For a numeric ticket, also search what the work
was actually *about*, and search the `#<TICKET>` form against titles and bodies rather than the
bare digits:
```bash
gh search prs "<a distinctive phrase from the ticket title>" --owner "$owner" --state open --json repository,number,title,url
gh search prs "#<TICKET>" --owner "$owner" --match title,body --state open --json repository,number,title,url
```
**A long result list is a symptom, not a finding.** A couple of candidates is an answer worth
reading; thirty means the query was too broad. Narrow it and run it again — do not paste the
list into the report. This step exists so that one genuine cross-repo sibling gets noticed, and
burying it in thirty false positives is indistinguishable from missing it.
A platform ticket is not done while its docs counterpart is still open. Report the siblings you
find — and when the scoped search comes back empty, report that too. `[]` is the answer this
step is usually looking for, and saying so is what shows it ran.
### Before proposing closure, check coverage
Read the ticket's `**CSW scope**` comment and account for every item in its `## Acceptance`
list against the merged PRs. A merged PR and a clean sweep answer *did this work land*; neither
answers *is it all there*, and proposing closure on that evidence is how a ticket closes
claiming everything shipped with an item half-built.
- **Every item covered** — say so, name what covers each, and go on to propose closure.
- **Any item not covered** — **do not propose closure.** Say which items are uncovered and stop
there. The ticket stays open, and it is in exactly the right shape to re-dispatch.
- **No ledger on the ticket** — say that too. A ticket with no acceptance list cannot be
checked, so closure would rest on the human's recollection rather than on evidence, and they
should know which of the two they are being asked for.
**An item on the ledger never becomes a new ticket.** An uncovered item is unfinished work on
*this* ticket. Spinning it out converts one incomplete ticket into two tickets and a closure
that was not earned, which is the accounting this ledger exists to prevent.
**Always ask before closing a ticket.** Even when everything is merged, even when the sweep
is clean, even when it is obvious. Propose it, name what you would set it to, and wait.
## Red flags
| Thought | Reality |
|---|---|
| "The worktree is right here, so it must be safe to remove" | Confirm the merge first. A worktree existing says nothing about whether its branch shipped. |
| "`gh pr view` failed, but I can check `git log` instead" | No substitute counts. Command failure is a stop, the same as an explicit non-merged state. |
| "I'll ask before deleting the worktree" | Once the merge is confirmed, do not. Removing its worktree from there is bookkeeping. Asking again is how it gets forgotten. |
| "The worktree is going away, so whatever was in it goes with it" | It does not. The directory goes; the dev server, the watcher and the database container are **still running**, and the next stack adopts them on the expected port. |
| "I'll ask before stopping the dev server" | Do not. It is the same bookkeeping as deleting the branch, and asking is how it gets forgotten. |
| "I'll tear the services down after the worktree is removed" | Too late. The compose file went with the directory, and a graceful stop often has to run from inside it. |
| "Nothing was running, so there's nothing to report" | Report the empty case. Silence reads as "not checked". |
| "That container looks stale too, I'll take it down while I'm here" | Not yours. Cleanup is responsible for the mess it made — this worktree — and nothing else on the machine. |
+ | "Something is holding a file open in there, I'll kill it so removal is clean" | Do not. It did not come from this worktree. Report it — removal will break it, and that is the human's call, not yours. |
+ | "The holders section is empty-ish noise, I'll trim it from the report" | It names what is about to break silently. A stale fd and a dead inotify watch raise no error anywhere; the report is the only warning. |
| "The ticket is clearly done, I'll close it" | Always ask. Every time. |
| "Everything merged and the sweep is clean, so it's done" | Merged answers whether the work landed, never whether it is all there. Read the ledger first. |
| "One item is short — I'll file it as a follow-up" | An item on the ledger never becomes a new ticket. The ticket is not done. |
| "The sweep is empty, nothing to report" | Report the empty sweep. Silence reads as "not checked". |
| "`csw-sweep` printed nothing, so there's nothing stale" | Check the exit code. Non-zero means the sweep did not run — unknown is not the same as absent. |
| "`--prune` on the pull is tidiness, I'll use plain `git pull`" | It is load-bearing. Without it `[gone]` never fires and half the sweep silently stops working. |
| "The sweep's `note:` line is chatter, I'll report the findings" | The note says how far the answer reaches. Report it with the findings. |
| "The PR is merged, so ExitWorktree's discard warning is obviously spurious" | Prove it. `git merge-base --is-ancestor "$head_sha" origin/<base>` exiting 0, or stop. Reflexive `discard_changes: true` is how real work gets deleted. |
| "The warning names a branch that no longer exists, so it's stale nonsense" | The name is display only; the commit count is correct. Run the `merge-base` check — do not dismiss it, and do not stall on it either. |
| "The sweep flagged the branch I'm on, so it must be confused" | It is not. Land on the base branch, then delete it. Standing on shipped work is the ordinary case, not an anomaly. |
| "`git branch -d` refused the current branch, so I'll leave it" | Refusing the *checked-out* branch is not the unmerged-commits refusal. Check out the base first, then delete. |
| "Cleanup is done, wherever the checkout happens to be" | It ends on the base branch, current with its remote. Anything else backdates the next branch cut from it. |
| "That other worktree is obviously stale too" | List it, ask, then act. |
| "`git branch -d` refused, I'll use -D" | Refusal means unmerged commits. Investigate. |
| "Uncommitted changes in the worktree are just scratch" | Show them first. That call is not yours. |
| "One repo's PR is merged, so the ticket is done" | Check for siblings in other repos. |
| "The sibling search returned thirty open PRs" | That is an unscoped query, not thirty siblings. Add `--owner` and run it again. |
| "The ticket number is enough of a query on its own" | Not for a `github` ticket. Scope it with `--owner`, then search the title too. |