wrapup
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 aft
SKILL.mdskills/wrapup/SKILL.md
GitHubWrap 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. NotMERGED→ 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 --porcelainempty) and no local commits ahead (git log --oneline origin/<branch>..HEADempty). Gate the second check ongit 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--forcethe removal.Both checks can pass on a worktree that still refuses to delete.
git worktree removederegisters first and deletes second, so a failure ("Directory not empty") strands a directory git no longer tracks. Two causes, neither visible tostatus:- 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 withlsof -a -d cwd -p <pid>. - Gitignored build artefacts (dependency dirs, caches):
git status --porcelainnever reports them;git status --porcelain --ignoreddoes.
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"thengit -C "$MAIN" worktree prune.- Live processes rooted in the worktree:
Sync trunk. If
$MAINhas 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
$WTmeans 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 ancestryDelete 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 setsidThe
pscheck 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): sweeppending-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 withgit 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
$WTper 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 pushedThe 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--forceon that literal line, and an abandoned worktree is dirty by definition, so withguards=passedevery removal attempt fails and the worktree strands while the report claims cleanup is under way. The two lines are mutually exclusive — this mode never writesguards=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.