Wrap up a feature worktree
Two modes. Default: after the PR merges. --abandon: the feature is dead
and its work is being discarded on purpose. Every guard below exists to
avoid deleting work unintentionally.
Constant: $MAIN — the primary checkout the worktree is attached to.
Resolve it from git, never assume a path:
MAIN=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")
If the common dir is itself a bare repository (a repo.git hub with every
checkout as a worktree), use it directly as $MAIN and take step 4's fetch
path — there is no checkout to pull.
Forge commands here assume a GitHub remote and the gh CLI — translate
them for another forge. A cold session that doesn't know the PR number
recovers it with gh pr list --head <branch>.
Not this skill
| Situation |
Use instead |
| Session ending, feature still in flight |
Your session-close workflow |
| Unfinished work another session must resume |
The handoff skill |
| Mid-work branch or stack sync |
Your git tooling — no teardown involved |
A bare "wrap up" does not distinguish teardown from ending a session.
Route on evidence, not the phrase: no merged PR in the conversation and
no --abandon means this is not wrapup. When it stays ambiguous, ask —
the wrong guess here spends a teardown on a live feature.
Ownership guard (both modes, before anything else)
Not every worktree holds your own branch — picking up a colleague's
branch for a review or a stacked pass is normal. Resolve ownership from
the worktree and the forge, not from the conversation:
gh pr view <number> --json author,headRefName,baseRefName
gh api user --jq '.login'
A PR authored by someone else, a branch that does not follow your own
naming, or a marker your bootstrap workflow left recording that this
worktree tracks a colleague's branch means the remote side is someone
else's. Teardown is then limited to git worktree remove and the
local branch -D. Never gh pr close, never
git push origin --delete — those target a colleague's live PR and
their branch. Say in the report which parts were skipped and why.
--abandon is the dangerous combination there: work pushed to their
branch is already out of your control, so the only thing left to
discard is theirs. Confirm the mode before running it.
Process (default — merged)
Confirm the merge. gh pr view <number> --json state,mergedAt.
Not MERGED → report and stop; nothing below is safe. (Feature
dropped rather than merged → that's --abandon, not a bypass.)
Deploy check (when the merge ships a deployable service). Confirm
the deployed build actually contains the merge before declaring it
done — compare whatever build identifier the service exposes (a
version endpoint, release tag, or deploy dashboard) against the merge
commit, and treat "the PR merged" as evidence of nothing by itself.
Skip only when nothing deploys from this change.
Guard the worktree. Before removing, from the worktree: working
tree clean (git status --porcelain empty) and no local commits
ahead (git log --oneline origin/<branch>..HEAD empty). Gate the
second check on git rev-parse --verify --quiet origin/<branch> —
no origin ref (never pushed, or pruned after the merge) means that
range is meaningless; compare against the merge-base with the
default branch instead:
git log --oneline "$(git merge-base origin/<default-branch> HEAD)"..HEAD.
Either non-empty → surface what's there and stop; never --force
the removal.
Both checks can pass on a worktree that still refuses to delete.
git worktree remove deregisters first and deletes second, so a
failure ("Directory not empty") strands a directory git no longer
tracks. Two causes, neither visible to status:
- Live processes rooted in the worktree:
ps -eo pid,comm,args | grep -F "<worktree-path>" | grep -v grep.
Dev servers, file watchers, and language servers race the
recursive delete. Discount matches that merely mention the path
in their arguments — a global daemon that once took the path as an
argument is a standing false positive; confirm a real holder with
lsof -a -d cwd -p <pid>.
- Gitignored build artefacts (dependency dirs, caches):
git status --porcelain never reports them;
git status --porcelain --ignored does.
A manual wrapup invocation pre-authorises stopping this worktree's
own per-worktree dev services — the user invokes it on finished,
merged work. Stop them directly and name them in the report. That
authority is scoped to services rooted in the worktree being torn
down, on a manual invocation. Reached any other way (chained from
another skill, an autonomous loop), or for any process that is not
part of this worktree's services: report the holder, hand the user
the paste-ready line, and wait.
Holders this session spawned itself (helper binaries, children of a
finished sub-task) are yours to kill once their work is done.
Recover a half-failed removal with rm -rf "$WT" then
git -C "$MAIN" worktree prune.
Sync trunk. If $MAIN has the default branch checked out:
git -C "$MAIN" pull --ff-only origin <default-branch>. Anything
else checked out → update the ref without touching the checkout:
git -C "$MAIN" fetch origin <default-branch>:<default-branch>.
Always name the refspec — on a large remote a bare fetch pulls
every ref and can hang for minutes. Failure is a warning, not a
stop; teardown does not depend on it.
Remove — or arm the reaper if this session lives inside the
worktree. Resolve the path from git, not from convention —
worktrees live wherever they were created:
WT=$(git -C "$MAIN" worktree list --porcelain \
| awk -v b="branch refs/heads/<branch>" \
'/^worktree /{p=substr($0,10)} $0==b{print p}')
# substr, not $2: a worktree path containing spaces must survive intact —
# everything below hands $WT to remove/rm commands.
[ -n "$WT" ] || { echo "no worktree found for <branch>"; exit 1; }
An empty $WT means no worktree holds the branch — stop and report;
never feed an empty path to the removal commands below.
Session outside the worktree → remove directly:
git -C "$MAIN" worktree remove "$WT"
git -C "$MAIN" branch -D <branch> # -D: squash merges break ancestry
Delete the branch with plain git. If you use stacked-branch tooling,
keep its sync commands out of teardown — they typically restack
every tracked branch in the repo, far beyond this cleanup's scope.
Session inside the worktree (cwd under it) → removing your own cwd
breaks every later command, so write a marker and hand the deletion
to a detached reaper: it waits for this session's process to exit,
then removes worktree and branch on its own.
SESSION_PID=$PPID # the harness process — the tool's own shell is ephemeral
ps -p "$SESSION_PID" -o comm= # must name your agent-harness binary, not a shell
BRANCH=<branch>
MARKER_DIR="$(git -C "$MAIN" rev-parse --path-format=absolute --git-common-dir)/pending-cleanup"
mkdir -p "$MARKER_DIR"
MARKER="$MARKER_DIR/${BRANCH//\//-}-$(printf %s "$BRANCH" | shasum -a 256 | cut -c1-8)"
# hash suffix: slash-to-dash mangling alone lets a/b-c and a-b/c collide
MODE_LINE="guards=passed"
printf 'branch=%s\npid=%s\n%s\n' "$BRANCH" "$SESSION_PID" "$MODE_LINE" > "$MARKER"
(nohup bash -c '
PID=$1 MAIN=$2 WT=$3 BRANCH=$4 MARKER=$5
cd "$MAIN" || exit 1 # cwd is the worktree being deleted — leave it first
LOG="$MARKER.log"
kill -0 "$PID" 2>/dev/null || { echo "dead pid at spawn; never reap" >>"$LOG"; exit 1; }
while kill -0 "$PID" 2>/dev/null; do sleep 15; done
sleep 5
FORCE=""; grep -q "^mode=abandon$" "$MARKER" 2>/dev/null && FORCE="--force"
for attempt in 1 2 3; do
if git -C "$MAIN" worktree remove $FORCE "$WT" >>"$LOG" 2>&1; then
if git -C "$MAIN" branch -D "$BRANCH" >>"$LOG" 2>&1; then
rm -f "$MARKER" "$LOG"
else
echo "worktree removed but branch $BRANCH survived" >>"$LOG"
fi
exit 0
fi
sleep 5
done
echo "gave up after 3 attempts; worktree left in place" >>"$LOG"
' _ "$SESSION_PID" "$MAIN" "$WT" "$BRANCH" "$MARKER" \
>/dev/null 2>&1 &) # subshell double-fork — macOS has no setsid
The ps check is a precondition, not decoration: if it names
anything other than your harness binary the pid is wrong, and the
reaper would delete the worktree seconds from now instead of after
the session ends. Don't arm it — report manual cleanup instead. The
marker doubles as the breadcrumb if the reaper dies (reboot, crash):
sweep pending-cleanup/ at your next bootstrap, or clean up by hand.
Close the ledger. If you keep a workstream log or running memory
for this feature, write its final state — outcome, merged PR,
lessons worth keeping — and stop treating it as live.
Report the teardown as it actually happened: PR merged (+ deploy
status if checked), and "worktree removed, branch <name> deleted"
only if those commands ran and succeeded — otherwise "reaper armed —
cleanup runs itself when this session closes", or what still holds
the worktree and the line the user needs to run. Never report a
branch cleaned on the strength of having run a sync command.
Process (--abandon — feature killed)
Discards work by design, so the guards invert: instead of refusing on
dirty/ahead state, show it and make the user own the loss.
Show what's being discarded, all three, even when empty:
git status --porcelain (uncommitted),
git log --oneline origin/<branch>..HEAD (unpushed),
git diff origin/<default-branch>...HEAD --stat (the branch's whole
delta vs trunk).
Gate the unpushed check on
git rev-parse --verify --quiet origin/<branch>. No origin ref here
means the entire branch is unpushed and will be lost — say so, and
list it with
git log --oneline "$(git merge-base origin/<default-branch> HEAD)"..HEAD.
Explicit confirmation (hard stop). Present the summary and wait
for an unambiguous yes to "permanently discard this". Never proceed
on the original "--abandon" alone — the user asked before seeing the
inventory.
Close the PR if one is open and it is yours (§ Ownership
guard): gh pr close <number> --comment "Abandoned." — an open PR
for a deleted branch confuses reverts and incident response. Someone
else's PR is never closed here; report it as left open.
Tear down. Resolve $WT per default step 5.
Session outside the worktree:
git -C "$MAIN" worktree remove --force "$WT"
git -C "$MAIN" branch -D <branch>
git push origin --delete <branch> # only if the branch was pushed
The remote delete is the line the ownership guard forbids on a
colleague's branch. Drop it there; the first two still run.
Session inside it → set MODE_LINE="mode=abandon" and run default
step 5's block otherwise unchanged. The reaper gates --force on
that literal line, and an abandoned worktree is dirty by definition,
so with guards=passed every removal attempt fails and the worktree
strands while the report claims cleanup is under way. The two lines
are mutually exclusive — this mode never writes guards=passed.
Report: what was discarded (commit count + diffstat), PR closed
(if any), worktree and branches removed. If you keep a workstream
log, record why the feature died and whether that was knowable
earlier — the one lesson this mode reliably produces.
1---2name: wrapup3description: Close out a feature worktree once its PR has merged — confirm the merge, verify the deploy where one applies, remove the worktree, and clean up branches; with --abandon, tear down a killed feature after showing exactly what is being discarded. Requires a merged PR or an explicit --abandon — a session ending while work is still in flight is your session-close workflow, and unfinished work someone must resume is the handoff skill. Triggers on "/wrapup", "the PR merged, tidy up", "clean up this worktree", "abandon this feature".4---56# Wrap up a feature worktree78Two modes. Default: after the PR merges. `--abandon`: the feature is dead9and its work is being discarded on purpose. Every guard below exists to10avoid deleting work *unintentionally*.1112Constant: `$MAIN` — the primary checkout the worktree is attached to.13Resolve it from git, never assume a path:1415```bash16MAIN=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")17```1819If the common dir is itself a bare repository (a `repo.git` hub with every20checkout as a worktree), use it directly as `$MAIN` and take step 4's fetch21path — there is no checkout to pull.2223Forge commands here assume a GitHub remote and the `gh` CLI — translate24them for another forge. A cold session that doesn't know the PR number25recovers it with `gh pr list --head <branch>`.2627## Not this skill2829| Situation | Use instead |30| --- | --- |31| Session ending, feature still in flight | Your session-close workflow |32| Unfinished work another session must resume | The `handoff` skill |33| Mid-work branch or stack sync | Your git tooling — no teardown involved |3435A bare "wrap up" does not distinguish teardown from ending a session.36Route on evidence, not the phrase: no merged PR in the conversation and37no `--abandon` means this is not wrapup. When it stays ambiguous, ask —38the wrong guess here spends a teardown on a live feature.3940## Ownership guard (both modes, before anything else)4142Not every worktree holds your own branch — picking up a colleague's43branch for a review or a stacked pass is normal. Resolve ownership from44the worktree and the forge, not from the conversation:4546```bash47gh pr view <number> --json author,headRefName,baseRefName48gh api user --jq '.login'49```5051A PR authored by someone else, a branch that does not follow your own52naming, or a marker your bootstrap workflow left recording that this53worktree tracks a colleague's branch means **the remote side is someone54else's**. Teardown is then limited to `git worktree remove` and the55local `branch -D`. Never `gh pr close`, never56`git push origin --delete` — those target a colleague's live PR and57their branch. Say in the report which parts were skipped and why.5859`--abandon` is the dangerous combination there: work pushed to their60branch is already out of your control, so the only thing left to61discard is theirs. Confirm the mode before running it.6263## Process (default — merged)64651. **Confirm the merge.** `gh pr view <number> --json state,mergedAt`.66 Not `MERGED` → report and stop; nothing below is safe. (Feature67 dropped rather than merged → that's `--abandon`, not a bypass.)68692. **Deploy check (when the merge ships a deployable service).** Confirm70 the deployed build actually contains the merge before declaring it71 done — compare whatever build identifier the service exposes (a72 version endpoint, release tag, or deploy dashboard) against the merge73 commit, and treat "the PR merged" as evidence of nothing by itself.74 Skip only when nothing deploys from this change.75763. **Guard the worktree.** Before removing, from the worktree: working77 tree clean (`git status --porcelain` empty) and no local commits78 ahead (`git log --oneline origin/<branch>..HEAD` empty). Gate the79 second check on `git rev-parse --verify --quiet origin/<branch>` —80 no origin ref (never pushed, or pruned after the merge) means that81 range is meaningless; compare against the merge-base with the82 default branch instead:83 `git log --oneline "$(git merge-base origin/<default-branch> HEAD)"..HEAD`.84 Either non-empty → surface what's there and stop; never `--force`85 the removal.8687 Both checks can pass on a worktree that still refuses to delete.88 `git worktree remove` deregisters first and deletes second, so a89 failure ("Directory not empty") strands a directory git no longer90 tracks. Two causes, neither visible to `status`:9192 - **Live processes rooted in the worktree**:93 `ps -eo pid,comm,args | grep -F "<worktree-path>" | grep -v grep`.94 Dev servers, file watchers, and language servers race the95 recursive delete. Discount matches that merely *mention* the path96 in their arguments — a global daemon that once took the path as an97 argument is a standing false positive; confirm a real holder with98 `lsof -a -d cwd -p <pid>`.99 - **Gitignored build artefacts** (dependency dirs, caches):100 `git status --porcelain` never reports them;101 `git status --porcelain --ignored` does.102103 A manual wrapup invocation pre-authorises stopping this worktree's104 own per-worktree dev services — the user invokes it on finished,105 merged work. Stop them directly and name them in the report. That106 authority is scoped to services rooted in the worktree being torn107 down, on a manual invocation. Reached any other way (chained from108 another skill, an autonomous loop), or for any process that is not109 part of this worktree's services: report the holder, hand the user110 the paste-ready line, and wait.111112 Holders this session spawned itself (helper binaries, children of a113 finished sub-task) are yours to kill once their work is done.114 Recover a half-failed removal with `rm -rf "$WT"` then115 `git -C "$MAIN" worktree prune`.1161174. **Sync trunk.** If `$MAIN` has the default branch checked out:118 `git -C "$MAIN" pull --ff-only origin <default-branch>`. Anything119 else checked out → update the ref without touching the checkout:120 `git -C "$MAIN" fetch origin <default-branch>:<default-branch>`.121 Always name the refspec — on a large remote a bare fetch pulls122 every ref and can hang for minutes. Failure is a warning, not a123 stop; teardown does not depend on it.1241255. **Remove — or arm the reaper if this session lives inside the126 worktree.** Resolve the path from git, not from convention —127 worktrees live wherever they were created:128129 ```bash130 WT=$(git -C "$MAIN" worktree list --porcelain \131 | awk -v b="branch refs/heads/<branch>" \132 '/^worktree /{p=substr($0,10)} $0==b{print p}')133 # substr, not $2: a worktree path containing spaces must survive intact —134 # everything below hands $WT to remove/rm commands.135 [ -n "$WT" ] || { echo "no worktree found for <branch>"; exit 1; }136 ```137138 An empty `$WT` means no worktree holds the branch — stop and report;139 never feed an empty path to the removal commands below.140141 Session outside the worktree → remove directly:142143 ```bash144 git -C "$MAIN" worktree remove "$WT"145 git -C "$MAIN" branch -D <branch> # -D: squash merges break ancestry146 ```147148 Delete the branch with plain git. If you use stacked-branch tooling,149 keep its sync commands out of teardown — they typically restack150 every tracked branch in the repo, far beyond this cleanup's scope.151152 Session inside the worktree (cwd under it) → removing your own cwd153 breaks every later command, so write a marker and hand the deletion154 to a detached reaper: it waits for this session's process to exit,155 then removes worktree and branch on its own.156157 ```bash158 SESSION_PID=$PPID # the harness process — the tool's own shell is ephemeral159 ps -p "$SESSION_PID" -o comm= # must name your agent-harness binary, not a shell160 BRANCH=<branch>161 MARKER_DIR="$(git -C "$MAIN" rev-parse --path-format=absolute --git-common-dir)/pending-cleanup"162 mkdir -p "$MARKER_DIR"163 MARKER="$MARKER_DIR/${BRANCH//\//-}-$(printf %s "$BRANCH" | shasum -a 256 | cut -c1-8)"164 # hash suffix: slash-to-dash mangling alone lets a/b-c and a-b/c collide165 MODE_LINE="guards=passed"166 printf 'branch=%s\npid=%s\n%s\n' "$BRANCH" "$SESSION_PID" "$MODE_LINE" > "$MARKER"167 (nohup bash -c '168 PID=$1 MAIN=$2 WT=$3 BRANCH=$4 MARKER=$5169 cd "$MAIN" || exit 1 # cwd is the worktree being deleted — leave it first170 LOG="$MARKER.log"171 kill -0 "$PID" 2>/dev/null || { echo "dead pid at spawn; never reap" >>"$LOG"; exit 1; }172 while kill -0 "$PID" 2>/dev/null; do sleep 15; done173 sleep 5174 FORCE=""; grep -q "^mode=abandon$" "$MARKER" 2>/dev/null && FORCE="--force"175 for attempt in 1 2 3; do176 if git -C "$MAIN" worktree remove $FORCE "$WT" >>"$LOG" 2>&1; then177 if git -C "$MAIN" branch -D "$BRANCH" >>"$LOG" 2>&1; then178 rm -f "$MARKER" "$LOG"179 else180 echo "worktree removed but branch $BRANCH survived" >>"$LOG"181 fi182 exit 0183 fi184 sleep 5185 done186 echo "gave up after 3 attempts; worktree left in place" >>"$LOG"187 ' _ "$SESSION_PID" "$MAIN" "$WT" "$BRANCH" "$MARKER" \188 >/dev/null 2>&1 &) # subshell double-fork — macOS has no setsid189 ```190191 The `ps` check is a precondition, not decoration: if it names192 anything other than your harness binary the pid is wrong, and the193 reaper would delete the worktree seconds from now instead of after194 the session ends. Don't arm it — report manual cleanup instead. The195 marker doubles as the breadcrumb if the reaper dies (reboot, crash):196 sweep `pending-cleanup/` at your next bootstrap, or clean up by hand.1971986. **Close the ledger.** If you keep a workstream log or running memory199 for this feature, write its final state — outcome, merged PR,200 lessons worth keeping — and stop treating it as live.2012027. **Report** the teardown as it actually happened: PR merged (+ deploy203 status if checked), and "worktree removed, branch `<name>` deleted"204 only if those commands ran and succeeded — otherwise "reaper armed —205 cleanup runs itself when this session closes", or what still holds206 the worktree and the line the user needs to run. Never report a207 branch cleaned on the strength of having run a sync command.208209## Process (--abandon — feature killed)210211Discards work by design, so the guards invert: instead of refusing on212dirty/ahead state, show it and make the user own the loss.2132141. **Show what's being discarded**, all three, even when empty:215 `git status --porcelain` (uncommitted),216 `git log --oneline origin/<branch>..HEAD` (unpushed),217 `git diff origin/<default-branch>...HEAD --stat` (the branch's whole218 delta vs trunk).219220 Gate the unpushed check on221 `git rev-parse --verify --quiet origin/<branch>`. No origin ref here222 means the entire branch is unpushed and will be lost — say so, and223 list it with224 `git log --oneline "$(git merge-base origin/<default-branch> HEAD)"..HEAD`.2252262. **Explicit confirmation (hard stop).** Present the summary and wait227 for an unambiguous yes to "permanently discard this". Never proceed228 on the original "--abandon" alone — the user asked before seeing the229 inventory.2302313. **Close the PR** if one is open **and it is yours** (§ Ownership232 guard): `gh pr close <number> --comment "Abandoned."` — an open PR233 for a deleted branch confuses reverts and incident response. Someone234 else's PR is never closed here; report it as left open.2352364. **Tear down.** Resolve `$WT` per default step 5.237238 Session outside the worktree:239240 ```bash241 git -C "$MAIN" worktree remove --force "$WT"242 git -C "$MAIN" branch -D <branch>243 git push origin --delete <branch> # only if the branch was pushed244 ```245246 The remote delete is the line the ownership guard forbids on a247 colleague's branch. Drop it there; the first two still run.248249 Session inside it → set `MODE_LINE="mode=abandon"` and run default250 step 5's block otherwise unchanged. The reaper gates `--force` on251 that literal line, and an abandoned worktree is dirty by definition,252 so with `guards=passed` every removal attempt fails and the worktree253 strands while the report claims cleanup is under way. The two lines254 are mutually exclusive — this mode never writes `guards=passed`.2552565. **Report**: what was discarded (commit count + diffstat), PR closed257 (if any), worktree and branches removed. If you keep a workstream258 log, record why the feature died and whether that was knowable259 earlier — the one lesson this mode reliably produces.