--- name: worktree-merge-cleanup description: > On-demand home for the pre-merge worktree precondition and the unified post-merge cleanup sequence — how to run `gh pr merge --squash --delete-branch` from the base-branch worktree, the submodule `--force` caveat, the squash-ancestry stale-HEAD guard, and the no-`&&`-chain rule. when_to_use: > Triggers on "merge cleanup", "post-merge cleanup", "worktree cleanup", "delete-branch merge", "squash-ancestry", "pre-merge worktree", "머지 후 정리", "worktree 정리". Do NOT activate on "merge conflict resolution" or a plain feature-branch "git merge". verified-against-runtime: true runtime-verified-at: 2026-07-17 runtime-verified-note: "gh 2.93.0 + git 2.50.1 — `gh pr merge -s/--squash`, `-d/--delete-branch`, and `git worktree remove -f/--force` confirmed present via --help." --- # worktree-merge-cleanup ## Overview `gh pr merge --squash --delete-branch` does a local checkout of the base branch (main / dev / prod) in the post-squash stage, and it deletes the local head branch by reachability against the local base HEAD. Both facts make the command sensitive to *which worktree* it runs from and *whether the base is synced* — get either wrong and the merge fails at the git layer or silently skips the local-branch delete, forcing manual recovery. This skill is the on-demand home for that procedure. The always-loaded rule set keeps only a pointer here; the deterministic `--delete-branch` failure mode is additionally enforced structurally by the `gh-merge-worktree-precondition` PreToolUse hook (see [Relationship to enforcement](#relationship-to-enforcement)). **Core principle:** run the post-merge cleanup steps as *separate* Bash calls, never collapsed into one `&&`-chain — a git hard error mid-sequence short-circuits the chain and its trailing output is easily misread as success. ## When to Use - About to merge a PR with `gh pr merge --squash --delete-branch` from an issue-branch worktree. - The merge just failed with `fatal: '' is already used by worktree at ...` or `cannot delete branch 'X' used by worktree at ...`. - After a squash-merge the local feature branch was not deleted (`warning: deleting branch 'X' ... not yet merged to HEAD`). - Cleaning up a worktree that contains a submodule. ## Pre-call checklist `gh pr merge --squash --delete-branch` must be **called from the worktree that owns the base branch**. Calling from an issue-branch worktree triggers the git exclusive-ref guard: ```text fatal: 'main' is already used by worktree at /Users/.../ ``` 1. Confirm the worktree occupying the base branch with `git worktree list`. 2. **If `--delete-branch` is included**: remove the PR head branch's worktree first (`git worktree remove `). The post-merge local branch delete fails if the branch is still occupied — `cannot delete branch 'X' used by worktree at ...` — even when invoked from the base (main) worktree, because the failing branch is the *head*, not the base. After removal, re-run `git worktree list` to confirm. - ⚠️ **Submodule-containing worktree**: plain `git worktree remove` is rejected with `fatal: working trees containing submodules cannot be moved or removed`. Use `git worktree remove --force ` **only after confirming the worktree is clean** (`git -C status --porcelain` empty). `--force` destroys uncommitted changes in a dirty worktree, so never make it the default. 3. `cd ` or chain `cd && gh pr merge ...` in the same Bash call (avoid cwd-reset trap between Bash calls). 4. Even if the call fails on a worktree conflict, the PR may already be merged on remote (gh order: remote merge → local sync). Manual cleanup — run each as a **separate** Bash call, never chained (a failure in one step must not short-circuit the rest): `git worktree remove `, then `git branch -D `, then `git worktree prune` (snapshot and dry-run it first — see [prune is repository-wide](#prune-is-repository-wide--snapshot-before-running-it)), then `git pull origin `. 5. **Squash-ancestry stale HEAD guard (post-merge sync mandatory)**: immediately after `gh pr merge --squash --delete-branch`, run `git pull origin ` as a **separate step**. gh merges remote then deletes local branch by reachability against local HEAD — but does not fetch the local base, so if local base is still at pre-merge SHA the delete silently skips (`warning: deleting branch 'X' that has been merged to 'refs/remotes/origin/X', but not yet merged to HEAD`). Distinct from item 4's worktree case — only `git pull` fixes it, not `git worktree remove`. ## Unified post-merge cleanup sequence Covers both sub-cases (worktree conflict + squash-ancestry gap): ```bash cd git switch gh pr merge --squash --delete-branch git pull origin # resolve squash-ancestry gap git branch -d 2>/dev/null \ || git branch -D # fallback (may already be deleted) git worktree list --porcelain \ > "$(git rev-parse --path-format=absolute --git-common-dir)/wt-before-.txt" git worktree prune --dry-run -v # what would go — read it ``` **STOP here and read the dry-run output.** `--dry-run` reports candidates; it is not a confirmation barrier, so the two commands below must not be pasted together with the block above. If anything other than your own worktree appears, surface it to the user before continuing. Only then: ```bash git worktree prune git worktree list --porcelain | grep '^worktree ' \ | diff <(grep '^worktree ' \ "$(git rev-parse --path-format=absolute --git-common-dir)/wt-before-.txt") - ``` Even if stdout shows only "deleted remote branch" or is empty, verify local cleanup separately: confirm with `git branch | grep ` **and** diff the post-prune registry against the snapshot. Both sides must be filtered the same way — the plain `git worktree list` output is a different format from `--porcelain`, so comparing one against the other never matches. Your own worktree left the registry back at checklist item 2, so it is already absent from the snapshot: when the dry-run printed nothing, the `diff` must print nothing either — assuming no one added or removed a worktree in this repository while the sequence was running. Any entry it reports as removed belonged to someone else. ### `prune` is repository-wide — snapshot before running it `git worktree prune` takes no target. It drops the registration of **every** unlocked worktree whose directory is gone, including worktrees belonging to other issues that you never touched. Two bounds on that blast radius: it only removes registrations for directories that are already missing — it never deletes a live worktree — and a registration held by `git worktree lock` survives even when its directory is gone. Neither bound is visible after the fact unless you captured evidence first. - **Before**: `git worktree list --porcelain` records every registration and marks the already-missing, unlocked ones `prunable` (a locked entry shows `locked` instead and is not a prune target). Park it at `$(git rev-parse --path-format=absolute --git-common-dir)/wt-before-.txt`, substituting the PR number you are cleaning up for ``. Not a shell variable and not a bare `wt-before.txt`: the steps run as separate Bash calls so a `snap=$(mktemp)` variable is gone by the next call, while the common dir recomputes to the same path every time — and the `` suffix keeps two concurrent cleanups in the same repository from overwriting each other's baseline. `--path-format=absolute` is not optional: a bare `git rev-parse --git-common-dir` answers `.git` in the main worktree and `../.git` one directory down, so without it the snapshot and the diff can resolve to different files. This snapshot is the only thing that later answers "was that sibling worktree already gone, or did I break it?" — without it the question is unanswerable, which is worse than the removal itself. Write the **unfiltered** porcelain output: the `prunable` and `locked` lines are precisely the forensic evidence, and a snapshot filtered at write time cannot answer the question it exists for. - **Dry run**: `git worktree prune --dry-run -v` (`-n` / `-v`, confirmed via `git worktree prune -h`) prints what it would remove without touching anything. If anything other than your own worktree appears, stop and surface it to the user before running the real prune. - **After**: diff `git worktree list --porcelain` against the snapshot — the same format on both sides, or the comparison is meaningless. Filter both through `grep '^worktree '` **at compare time**: the question is only which *registrations* vanished, and an unfiltered diff also reports the `HEAD` / `branch` lines that any parallel worktree changes by committing or switching, which reads as a removal that never happened. The existing `git branch | grep` check only proves *your* branch is gone; a sibling registration can disappear and still pass it. Recovering a registration removed this way is out of scope here — see [Limitations](#limitations) (`git worktree add`, never `git clone`). ⚠️ Do NOT collapse the sequence into a single `&&`-chain. A git hard error mid-sequence (submodule worktree missing `--force`, divergent pull) short-circuits `&&`, silently skips remaining steps, and trailing output from an earlier step is easily misread as "cleanup done". Run steps separately; after a fragile step (worktree remove / pull) fails, re-verify primitive state with `git worktree list` / `git branch | grep`. ## Squash merge under bulk cleanup — the merged-ness oracle The unified sequence above cleans up **one** worktree right after its own PR merge, where `gh pr merge` itself is the merged-ness proof. Bulk cleanup of N already-merged local branches (no `gh pr merge` call in the loop) needs its own oracle, and under a squash-merge repo the obvious ones are wrong. - **`git branch --merged` is invalid.** It answers "is this branch's tip an ancestor of ``?" — true for a fast-forward or merge-commit, but a squash merge creates a brand-new commit on `` that is never an ancestor of the original branch tip. A genuinely merged squash branch shows up as **not** merged by this check. - **`git diff base...branch` (or `--stat`) is equally invalid**, for the mirror reason: 3-dot diff shows the branch's unique commits relative to their merge-base with ``, and squash does not rewrite or remove those commits from the branch ref — it only adds a new, unrelated commit to ``. A squash-merged branch still reports the same non-empty diff a never-merged branch would, so an empty-diff check can never fire true and a non-empty one can never rule the branch out. - **Valid oracle — tip SHA vs `headRefOid`, plus PR `state` and `baseRefName`.** Compare the branch's local tip against the PR's recorded head SHA, then check the PR closed as merged *into the base you are cleaning up*: ```bash git rev-parse gh pr list --search "head:" --state merged \ --json number,headRefOid,state,mergedAt,baseRefName ``` `--state merged` is **not optional**. `gh pr list` defaults to `--state open` (`gh pr list --help`), so without it a squash-merged branch — the only kind this procedure ever looks at — comes back as `[]` and the `state == MERGED` check can never be reached. The failure is silent and looks exactly like "no PR found", which routes every merged branch into the manual fallback path. (`gh pr view --json headRefOid,state,mergedAt,baseRefName` works the same way when the PR number is already known, and needs no `--state` because it addresses one PR directly.) The branch is safe to delete only when **all three** hold: 1. the local tip SHA equals `headRefOid`; 2. `state` is `MERGED` (not `OPEN`/`CLOSED`); 3. `baseRefName` equals the `` you are cleaning up. `state` alone is insufficient — a PR can be merged and the local branch since amended with unpushed commits, in which case the tip SHA no longer matches the merged head and the extra commits would be lost by deleting it. `baseRefName` matters because this skill covers `main`/`dev`/`prod` bases: a branch merged into `dev` but not yet into `prod` satisfies conditions 1 and 2 while cleaning up `prod`, and deleting it there discards work `prod` has never seen. - Do not write verdict language ("safe to delete", "confirmed merged") before this two-step check has actually run against live `gh` output — a plausible guess from branch name or commit message is not evidence. ### Bulk branch-delete procedure 1. **Enumerate candidates**: list local branches to review (e.g. `git branch --format='%(refname:short)'`, excluding the base branch). 2. **Look up each branch's PR** with `gh pr list --search "head:" --state merged --json number,headRefOid,state,mergedAt,baseRefName`. - `--state merged` is required; the default is `open`, which returns `[]` for exactly the branches this procedure exists to clean up. - **No PR found** (never pushed, or pushed to a fork/deleted remote): this branch is not tracked by the oracle above — fall back to a separate, manual path (inspect the branch's own commits / confirm with the user) rather than assuming it is safe or unsafe. If *every* branch reports "no PR found", suspect the query before suspecting the branches — a dropped `--state merged` produces precisely that pattern. 3. **Confirm `state == "MERGED"`.** `OPEN` or `CLOSED` (without `mergedAt`) means do not delete. 4. **Compare tip SHA**: `git rev-parse ` must equal the PR's `headRefOid`. A mismatch means the local branch has commits the merged PR never saw — do not delete without inspecting the difference first. 5. **Confirm `baseRefName == `** — the branch must have merged into the base you are cleaning up, not a sibling base. 6. Only after all three checks pass, `git branch -D ` (`-D` because the squash commit is not an ancestor of the branch tip, so `-d` refuses with "not fully merged" even though the PR is in fact merged). ## Relationship to enforcement | Layer | Home | Covers | | ------- | ------ | -------- | | Structural enforcement | `gh-merge-worktree-precondition` PreToolUse hook | Blocks `gh pr merge ... --delete-branch` when the head branch is still checked out in another worktree — one deterministic slice of checklist item 2 | | On-demand procedure (this skill) | `worktree-merge-cleanup` | The full manual sequence: base-worktree call site, submodule `--force`, squash-ancestry stale-HEAD guard, unified cleanup, no-`&&`-chain | | Always-loaded | user-level rule set | A pointer to this skill only | The hook is the automated guard for the single failure mode that can be checked before the command runs; this skill is the reference for everything the hook does not (and structurally cannot) enforce. ## Limitations - The sequence assumes the squash-merge workflow (`--squash`). A merge-commit or rebase-merge strategy has different local-branch reachability semantics and is out of scope here. - Worktree-recovery after an accidental removal is a separate concern (recover via `git worktree add`, never `git clone`). - The bulk branch-delete oracle covers branches with a discoverable PR only (`gh pr list --search "head:"` returns a result). A branch pushed to a fork, or whose PR/remote was deleted, has no `headRefOid` to compare against — that case is out of scope here and needs a separate, manual review path.