agentsclimarketplace

Cleanup

Skill mikestankavich/claude-ship-workflow/skills/cleanup

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.From its SKILL.md

Install
npx -y skills add mikestankavich/claude-ship-workflow --skill cleanup

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 3 stars3 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

16.4 KB, as published. Nobody here has run it

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:

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:

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 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:

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:

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:

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:

    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:

    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:

git push origin --delete "<branch from Step 1>"

Step 4: Sweep for everything else

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:

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:

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:

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:

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.

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

ThoughtReality
"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 ticket is clearly done, I'll close it"Always ask. Every time.
"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.

Keep looking

Skills are one crate of 627,801. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.