Finish an already-merged branch. Proves the branch is contained in the fetched default, classifies leftover artifacts as unique / redundant / superseded, returns to a clean --ff-only default checkout, and deletes the merged local branch — every discard confirmed per item. Routed to by {{CMD:cleanup}}.
Scanned 9/2/2026
Install to Claude Code
npx -y skills add arbiterForge/codeArbiter --skill post-merge-cleanup --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Post Merge Cleanup?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/arbiterforge-post-merge-cleanup)More formats (shields.io, HTML) on the badges page.
---
name: post-merge-cleanup
description: Finish an already-merged branch. Proves the branch is contained in the fetched default, classifies leftover artifacts as unique / redundant / superseded, returns to a clean --ff-only default checkout, and deletes the merged local branch — every discard confirmed per item. Routed to by {{CMD:cleanup}}.
---
# post-merge-cleanup
The walk back from a merged PR. Routed to by `{{CMD:cleanup}}`.
The work has landed on the default branch. What is left is local: the branch you
are standing on, and whatever the run left in the tree. This skill returns you to
a clean, fast-forwarded default checkout without losing anything you have not
explicitly agreed to lose.
**It is ordinary lifecycle work and never needs `{{CMD:override}}`.** When it
cannot proceed, it names the gate that stopped it. Issue #308 recorded the
alternative: with no owner for this transition, the routing loop reached for
`{{CMD:chore}}`, then for `{{CMD:override}}` — a bypass manufactured to cover a
coverage hole.
## Pre-flight
Read this, or STOP and surface the gap — never guess the default-branch name:
- `{{PROJECT_DIR}}/.codearbiter/CONTEXT.md` — the default-branch name.
If HEAD is already the default branch, there is no transition to make. Report the
dirty state read-only and exit; branch pruning across *other* branches belongs to
`{{CMD:standup}}`.
## Phase 1 — Prove the merge · gate: BLOCK
A branch that looks merged is not merged. Establish it against the network, not
against a stale local ref. This repo squash-merges by default, so SHA-ancestry
alone is the wrong instrument for most landings: it holds for a fast-forward or
a merge-commit landing, but a squash merge writes a new commit with no SHA
lineage back to the branch, and `--is-ancestor` will report non-zero for a
branch that landed cleanly. The gate is **content-containment**, proven by
whichever instrument fits the landing, always reported as a fact:
1. `git fetch` the remote holding the default branch. A fetch that fails STOPs —
an unfetched comparison proves nothing.
2. Prove containment with two primary instruments plus a fallback, tried in
order:
1. **Ancestry** — `git merge-base --is-ancestor HEAD origin/<default>`. Holds
for a fast-forward or merge-commit landing. If it holds, that is the
proof: report it and move on.
2. **Squash-merge proof via the PR record** — if ancestry fails, run
`gh pr list --head "$(git branch --show-current)" --state merged --json number,state,headRefOid,mergeCommit`.
A `MERGED` PR whose `headRefOid` equals local `git rev-parse HEAD` proves
every commit on this branch rode that PR's squash into the default
branch. Report the PR number, that `headRefOid == HEAD`, the merge
commit (`mergeCommit.oid` in that JSON), **and** the ancestry check's
negative result — all as facts, not as a failure. Corroborate with
`git diff --quiet origin/<default> HEAD` when it happens to hold; a
**non-empty** diff alongside a valid PR proof is normal (the default
branch advanced since the merge landed) and is reported as a fact, never
treated as a failure. The PR-record identity — `MERGED` plus
`headRefOid == HEAD` — is the load-bearing proof; the diff is
corroboration only when it happens to be fresh, never a requirement.
3. **Fallback when `gh` is unavailable or no PR record exists** —
`git diff --quiet origin/<default> HEAD` (the tree is byte-identical to
the fetched default) is an acceptable fallback proof. Report it as such.
3. Report whichever instrument held as a fact — the default branch, the fetched
SHA, and which of the three proofs established containment.
If none of the three hold, STOP exactly as today. Name the un-landed commits
and route to `{{CMD:pr}}`; nothing is deleted here.
> Deliberate deviation from issue #586's suggested contract: that suggestion
> required the squash-merge proof's diff to be empty. That requirement
> re-breaks this gate the moment any later PR merges to the default branch —
> the common state, not an edge case. The PR-record identity is what proves
> containment; the diff is corroboration, demoted from requirement to fact.
Gate: the remote is fetched and HEAD is proven contained in the fetched default
branch — by ancestry, by the PR-record squash proof, or by the byte-identical
fallback — or the skill has stopped.
## Phase 2 — Classify the residue · gate: BLOCK
List every dirty tracked change, every untracked file, and every stash reachable
from this branch. Classify each into exactly one of three, and say *why* for each:
- **Redundant** — byte-identical to content already on the default branch, or a
regenerated build artifact whose generator is committed and rerunnable. Safe to
remove because removing it loses no information.
- **Superseded** — an earlier form of something the merged PR already landed in a
better shape. Name what supersedes it.
- **Unique** — anything else. This is the default: an artifact that cannot be
proven redundant or superseded IS unique. Uncertainty classifies as unique, not
as redundant.
Present the classification before touching anything. A file whose class you
cannot establish is reported as unique with the reason you could not classify it.
Gate: every dirty, untracked, and stashed artifact carries a class and a stated
reason, with unclassifiable items counted as unique.
## Phase 3 — Resolve the residue · gate: STOP
Per item, in the order Phase 2 listed them. Never batch:
- **Unique** — offer to keep it: `{{CMD:commit}}` it on this branch before the
transition, move it aside, or leave it in place and stop the cleanup. It is
discarded only if the user explicitly confirms *that item by name*, with the
Phase 2 reasoning in view.
- **Redundant / superseded** — offer removal, one confirmation each, stating what
it is and why it is safe. Declining leaves it exactly where it is.
A stash is never dropped here. Stashes are reported with `git stash show` as the
suggested next step, the same report-and-route contract `{{CMD:standup}}` holds.
If anything the user chose to keep would block the checkout, STOP and say so
rather than removing it anyway — but distinguish the two causes before naming
the artifact as the blocker:
- **The kept artifact genuinely conflicts** — its content collides with what
checking out the default branch would need to change or remove. This is the
user's to resolve, as today.
- **The local default ref lags the fetched one** — git refuses a checkout when
a locally-modified tracked file differs between HEAD and the target ref, and
a stale local `<default>` makes that difference larger than reality: content
the kept artifact never actually touches can still collide with what a
*current* default branch would carry. That gap is not the user's problem and
not the kept artifact's fault. It is resolved by Phase 4's fast-forward-first
step, not by discarding anything here. Issue #586 recorded the observed
failure mode: a stale local `main` — 513 lines behind on
`.codearbiter/gate-events.log` alone — made this STOP name a correctly-kept
38-line artifact as the blocker, which invites exactly the silent discard
this phase exists to prevent.
Gate: every item is resolved by an explicit per-item decision, and the working
tree is clean enough to check out the default branch — or the skill has stopped
with the blocker named, correctly attributed to a genuine conflict or a stale
local ref.
## Phase 4 — Transition · gate: BLOCK
Only after Phase 3 leaves the tree safe:
1. **Fast-forward the local default ref BEFORE checking it out:**
`git fetch origin <default>:<default>`. Git refuses a non-fast-forward
update of a ref that is not currently checked out, so the `--ff-only`
guarantee is preserved by the instrument itself — this cannot silently
rewrite the local default ref, only advance it or refuse. This is what
keeps a stale local default from blocking (or worse, misattributing) the
checkout below. This step can itself be refused for the same reason a
checkout can be — `<default>` checked out in another worktree — in which
case report that as the (correctly attributed) blocker, per Phase 3, and
stop; do not treat the refusal as license to skip ahead.
2. Check out the default branch, then **verify the checkout actually happened**
(`git branch --show-current`). A checkout silently fails when another worktree
holds the branch, and every step after this one would otherwise run against
the wrong branch.
3. Fast-forward with `--ff-only` (belt over the braces of step 1 — a no-op when
the pre-checkout fetch already brought the local ref current). A divergence
means the default branch moved in a way this skill will not reconcile:
report it and stop. Never a merge commit, never a rebase, never a reset.
Gate: the local default ref was fast-forwarded before the checkout — or the
fast-forward's refusal was itself reported as a fact and the skill stopped —
HEAD is confirmed on the default branch by re-read, and the belt-and-braces
`--ff-only` pull either succeeded (typically a no-op) or was reported as a
refused divergence.
## Phase 5 — Delete the merged local branch · gate: STOP
Offer deletion of the now-merged local branch, with the Phase 1 containment
proof restated. One confirmation, naming the branch.
- `git branch -d` first, always. Never reach for `-D` on the assumption that a
refusal means `-d` can't handle a squash merge — it often can. `git branch
-d`'s safety check accepts a branch merged into its **upstream**, not only
into HEAD: with an upstream still configured and equal to the tip, `-d`
succeeds (with a warning) even for a squash merge that `--is-ancestor` alone
would call unmerged. Do not conclude a refusal here means `-D` is required —
check the reason first.
- **The sanctioned `-D` path.** `-d` typically refuses once the remote branch
has been auto-deleted and pruned, because there is then no upstream left for
`-d` to test reachability against. When that happens, `git branch -D` is
permitted, but ONLY when both hold:
1. Phase 1's proof, established this run, was the PR-record squash proof
(`MERGED` and `headRefOid == HEAD`) — not a bare ancestry pass, not a
stale or assumed proof.
2. The confirmation restates that Phase-1 proof and explicitly names the
branch.
Frame this precisely: `-d`'s safety check tests SHA-reachability, and the
squash proof has already shown that instrument is the wrong one for this
repo's merge mode. The PR-record proof plus a named confirmation **replaces**
the check that `-d` can no longer run — it does not bypass it. Everywhere
else, `-D` stays forbidden exactly as before: if `-d` refuses and Phase 1's
proof was anything other than the PR-record squash proof, report both and
stop.
- The **remote** branch is never touched. If the user wants it gone, that is
theirs to do or the platform's auto-delete-on-merge to do.
Declining leaves the branch in place. That is a normal outcome, not a failure.
Gate: the branch is deleted only after an explicit confirmation naming it, via
`-d`, or via `-D` restricted to the sanctioned path above with the proof
restated, with the remote untouched.
## Phase 6 — Receipt
One short summary: what landed, what was removed, what was kept, and where HEAD
is now. State declines as declines — a cleanup the user stopped halfway is a
correct outcome reported plainly, not an error.
## Hard rules
- MUST fetch and prove `HEAD` is **contained** in the fetched default branch
before any deletion — via ancestry, via the PR-record squash proof (`MERGED`
and `headRefOid == HEAD`), or, without `gh`, via a byte-identical tree diff.
MUST NOT infer merge state from a `: gone]` upstream alone.
- MUST classify every artifact, and MUST treat anything not provably redundant or
superseded as unique.
- MUST NOT discard a unique or unclassifiable artifact without an explicit
confirmation naming that item.
- MUST confirm every removal and the branch deletion individually — no batched or
implied yes.
- MUST fast-forward the local default ref (`git fetch origin <default>:<default>`)
before checking it out, or report the fast-forward's own refusal (e.g.
`<default>` checked out in another worktree) as the blocker and stop. MUST
re-read the current branch after checkout before acting on it.
- MUST use `--ff-only`, and MUST NOT merge, rebase, or reset to reach the default
branch.
- MUST use `git branch -d`, and MAY use `-D` ONLY when the Phase-1 squash-merge
proof held this run and `-d` refused, with that proof restated and the branch
named in the confirmation. Everywhere else `-D` is forbidden. MUST NOT delete a
remote branch, force-push, or write to the default branch.
- MUST NOT drop a stash — report and route, as `{{CMD:standup}}` does.
- MUST NOT route to `{{CMD:override}}` when blocked. Name the gate instead.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!