From b81a96bee85106120a557424bfcddf4fc6d9a899 Mon Sep 17 00:00:00 2001 From: "melodic-automation-lanes[bot]" <337595936+melodic-automation-lanes[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 07:13:12 +0000 Subject: [PATCH 1/7] feat(source-control): scope worktree cleanup to paths and run Step 4c inline on POSIX Co-Authored-By: Claude Sonnet 5.5 --- .../worktree-cleanup-scope-7332-3f9a1c7e.md | 12 +++++++++ .../source-control/skills/worktree/SKILL.md | 14 +++++----- .../skills/worktree/context/cleanup.md | 26 ++++++++++++++----- 3 files changed, 39 insertions(+), 13 deletions(-) create mode 100644 .changes/source-control/worktree-cleanup-scope-7332-3f9a1c7e.md diff --git a/.changes/source-control/worktree-cleanup-scope-7332-3f9a1c7e.md b/.changes/source-control/worktree-cleanup-scope-7332-3f9a1c7e.md new file mode 100644 index 0000000000..08ff453416 --- /dev/null +++ b/.changes/source-control/worktree-cleanup-scope-7332-3f9a1c7e.md @@ -0,0 +1,12 @@ +--- +bump: minor +--- + +### Added + +- `/source-control:worktree cleanup [...] [--dry-run]` takes explicit worktree paths and runs the same classification and every Step 4 guard on exactly those worktrees, skipping the repository-wide inventory and root scan. +- A bare `cleanup` in a session that created, entered or merged a PR from one worktree asks whether to clean that worktree or sweep the whole repository before inventorying, and says so up front when the scoped worktree is the session's own. + +### Changed + +- `cleanup` Step 4c runs the landed-branch deletion and the current-worktree removal itself on Linux and macOS after the Step 4 confirmation and a passed stranded-work guard. It still emits them for the user on Windows or when a hook blocks the command. diff --git a/plugins/source-control/skills/worktree/SKILL.md b/plugins/source-control/skills/worktree/SKILL.md index 06d4ea1e1f..921ee17d26 100644 --- a/plugins/source-control/skills/worktree/SKILL.md +++ b/plugins/source-control/skills/worktree/SKILL.md @@ -8,7 +8,7 @@ metadata: summary: Create, inspect, and clean git worktrees for parallel sessions --- -**Arguments.** `[create|status|cleanup|audit] [args]`. e.g., /worktree create feat/my-feature, /worktree status, /worktree cleanup, /worktree audit +**Arguments.** `[create|status|cleanup|audit] [args]`. e.g., /worktree create feat/my-feature, /worktree status, /worktree cleanup [...] [--dry-run], /worktree audit ## Repository context. Gather first @@ -72,7 +72,7 @@ This skill is self-contained, every action runs on plain `git`, plus the forge C | *(empty)* | Smart default | Detect current state, suggest appropriate action | | `create [name]` | Create worktree | Validate name, explain setup, call EnterWorktree | | `status` | Inventory | List all worktrees with PR status and staleness | -| `cleanup [--dry-run]` | Remove stale | Prune orphans, detect merged PRs and stale locks, remove with confirmation | +| `cleanup [...] [--dry-run]` | Remove stale | Prune orphans, detect merged PRs and stale locks, remove with confirmation; named paths limit it to exactly those worktrees | | `audit` | Health check | Run status + verify configuration health | --- @@ -137,9 +137,11 @@ The **Work** axis answers a question age and PR state cannot: whether removing a --- -## Action: `cleanup [--dry-run]` +## Action: `cleanup [...] [--dry-run]` -Remove stale worktrees, orphaned metadata, branches from merged PRs, and the project-scope plugin install records the worktree leaves behind. Full 5-step procedure. Prune orphaned metadata → identify candidates (detection reasons: orphaned dir / prunable / PR-merged / stale / stale lock / reap age, the last proposing an already-safe worktree older than `${user_config.worktree_reap_after_hours}` hours, 48 by default) → present → execute (4a release file locks, 4b guards → reap records → remove, 4c emit branch deletion for the user) → verify physical deletion: [context/cleanup.md](context/cleanup.md). `--dry-run` reports candidates and takes no action. +Remove stale worktrees, orphaned metadata, branches from merged PRs, and the project-scope plugin install records the worktree leaves behind. Full 5-step procedure. Prune orphaned metadata → identify candidates (detection reasons: orphaned dir / prunable / PR-merged / stale / stale lock / reap age, the last proposing an already-safe worktree older than `${user_config.worktree_reap_after_hours}` hours, 48 by default) → present → execute (4a release file locks, 4b guards → reap records → remove, 4c branch deletion and current-worktree removal) → verify physical deletion: [context/cleanup.md](context/cleanup.md). `--dry-run` reports candidates and takes no action. + +**Scope.** Paths given after `cleanup` limit the run to exactly those worktrees: the same classification and every Step 4 guard apply to each, and the repository-wide inventory and root scan are skipped. A bare `cleanup` in a session that created, entered or merged a PR from one worktree asks whether to target that worktree or the whole repository before inventorying; the whole-repository sweep behaves as before when chosen. See [context/cleanup.md § Scope](context/cleanup.md#scope-explicit-paths-or-the-whole-repository). **Safety invariants cleanup MUST honor** (full detail in context/cleanup.md): @@ -150,7 +152,7 @@ Remove stale worktrees, orphaned metadata, branches from merged PRs, and the pro - **A reap's non-zero exit is a no-op to report, never an escalation.** The CLI's failure text for an id with no project-scope record here suggests `--scope user`, which would uninstall the plugin fleet-wide. Never run `-s user`, never `--prune`, and never hand-edit `installed_plugins.json` (Claude Code's internal state, not a published contract). - **Never swallow removal stderr** (`2>/dev/null`), a failed removal must surface so Step 5 reports husks honestly rather than counting one as removed. - **Run the Workspace environment `down` before removing a worktree**, after the guards clear and before the reap, read from the fetched default branch and run through the Bash tool; a failed `down` is reported and the removal continues under the guards. Procedure in [context/cleanup.md](context/cleanup.md) Step 4b. -- **Emit `git branch -D` + self-worktree removal for the USER to run, never inline** (Step 4c). Deleting a branch is destructive (and the consuming project's hooks may block it mid-session); a worktree can't delete itself (the running Claude Code session holds its handle). `-D` (not `-d`) is needed because squash-merge changes the SHA. +- **Run `git branch -D` + self-worktree removal inline on Linux and macOS, only after the Step 4 confirmation and a passed stranded-work guard; emit them for the USER to run on Windows or when a hook blocks the command** (Step 4c). On Windows the running Claude Code session holds the directory handle, so a worktree can't delete itself; on POSIX a working directory can be removed while a process runs. A hook block is reported, never worked around. `-D` (not `-d`) is needed because squash-merge changes the SHA. --- @@ -225,7 +227,7 @@ session, never that it is present. The four-part records live in - **Does not push, create, merge, or close PRs**, `/source-control:pull-request` owns the back-half (prep, create, monitor, merge). - **Does not commit or stage code**, staging and committing stay user-controlled; `/source-control:commit` owns the commit mechanic. - **Does not run CI, build, test, or lint**, use your project's build/test/lint tooling or skills. -- **Does not manage remote branches**, the forge's delete-branch-on-merge setting handles remote cleanup on merge (when enabled); local `git branch -D` is emitted for the user, never run inline. +- **Does not manage remote branches**, the forge's delete-branch-on-merge setting handles remote cleanup on merge (when enabled); local `git branch -D` runs only for a confirmed, landed candidate (inline on Linux and macOS, emitted for the user on Windows or on a hook block). - **Does not uninstall plugins, and does not touch user or local scope.** `cleanup`'s reap removes only the *project-scope install records keyed to the worktree it is tearing down*, through `claude plugin uninstall -s project`. It never runs `-s user` or `-s local`, never passes `--prune`, and never edits `installed_plugins.json`. Fleet-wide plugin state is a plugin-management concern, not a worktree one. - **Does not enforce branch naming**, the consuming project's hooks and CI are the gates. This skill only surfaces the project's convention (read it from the project's `CLAUDE.md` / rules; default suggestion: `/` with a Conventional Commits type prefix). diff --git a/plugins/source-control/skills/worktree/context/cleanup.md b/plugins/source-control/skills/worktree/context/cleanup.md index 7b04a2fd9e..f34a239484 100644 --- a/plugins/source-control/skills/worktree/context/cleanup.md +++ b/plugins/source-control/skills/worktree/context/cleanup.md @@ -2,10 +2,19 @@ `` is the scripts directory resolved in SKILL.md. This file is read as raw bytes, so substitute that resolved absolute path for `` before a command reaches Bash. -Full detail for the `/source-control:worktree cleanup [--dry-run]` action. SKILL.md carries the headline plus the safety invariants; this file carries the complete step-by-step (prune → identify → present → execute → verify), including the Windows file-lock handling and the user-emitted branch deletion. +Full detail for the `/source-control:worktree cleanup [...] [--dry-run]` action. SKILL.md carries the headline plus the safety invariants; this file carries the complete step-by-step (prune → identify → present → execute → verify), including the Windows file-lock handling and the Step 4c branch deletion. Remove stale worktrees, orphaned metadata, and branches from merged PRs. +## Scope: explicit paths or the whole repository + +Settle the scope before Step 1. Strip `--dry-run` from the arguments; what remains are worktree paths. + +- **Paths given.** Scope is exactly those worktrees. Resolve each against `bash "/lib/worktree-facts.sh" list ` (a path that is not a registered linked worktree of this repository, or is the primary checkout, is reported and dropped; never fall through to the sweep for it). Run the Step 2 classification, the stranded-work record and every Step 4 guard on those rows only. **Skip** the repository-wide inventory, the Step 2 detection scan for other candidates, and the `worktree-root-scan.sh` root scan. Every named worktree is presented in Step 3 with its classification even when it matches no Step 2 reason (for example a young worktree with an open PR): naming it is the request, so the user confirms or declines, and the guards still decide whether removal may proceed. Step 1's `git worktree prune` still runs, as it touches only records of directories that are already gone. +- **Bare `cleanup`, session worked one worktree.** When this session created a worktree, entered one with `EnterWorktree`, or merged a PR from one, and that worktree is a linked worktree of this repository, ask before inventorying: "Clean up only `` (branch ``), or sweep the whole repository?" Offer the single worktree as the default and the sweep as the alternative. If the session worked more than one worktree, list them and ask which, with the sweep as the last option. On the single-worktree answer, continue as for named paths; on the sweep answer, continue as below. Unattended runs with no one to answer take the single worktree, never the sweep. +- **Bare `cleanup`, no worktree worked.** The whole-repository behavior below, unchanged. It is also the behavior when the sweep is chosen. +- **The scoped worktree is the one the session runs in.** Compare the scoped path to `git rev-parse --show-toplevel`. When they match, say so before anything else: "`` is this session's own worktree." Classification and guards run as usual, with no repo-wide inventory, and Step 4c decides how its removal runs. + **Outside a repository, cleanup reports and removes nothing.** Its guards (stranded work, claims, the reap, `git worktree prune`) are per repository, so it runs only from inside the repository that owns a candidate. Collect with [status.md § Outside a repository](status.md#outside-a-repository-cross-repository-mode), identify candidates per Step 2 for each repository, and add the Step 2c root scan rows of [audit.md](audit.md). Present them as Step 3 does, grouped under a heading per owning repository (scan rows under their root), each group ending with "Run `/source-control:worktree cleanup` from `` to act on these." Take no Step 1, 4 or 5 action, `--dry-run` or not. ## Step 1: Prune orphaned metadata @@ -301,21 +310,24 @@ silently answers a question it was not asked. Do NOT swallow stderr with `2>/dev/null`. A failed removal must surface so Step 5 can report it honestly. -### Step 4c: Emit branch + current-worktree deletion for the user (do not run inline) - -Branch deletion is destructive (and the consuming project's hooks may block `git branch -D` mid-session), and the worktree a session runs in cannot delete itself, because the running Claude Code process holds its directory handle. Surface these for the user to run from a main-repo terminal (or via the `!` prompt prefix) rather than executing them inline: +### Step 4c: Branch + current-worktree deletion (run inline on POSIX, emit on Windows or a hook block) ```bash -# Run from main repo / another terminal: git branch -D # -D needed (squash-merge changes SHA) git worktree remove # only if the active worktree was itself a candidate ``` -**The stranded-work precondition applies here too, and this is where it bites hardest.** Removal left the branch ref intact; this line is what actually destroys the commits. Emit `git branch -D ` only for a branch whose row was `landed` or `ok`. For `STRANDED`, `UNKNOWN`, or `superseded`, emit nothing and say why. A suggested command in a code block reads as vetted, and a user pasting it has no way to know the guard upstream was never applied to it. Emit `git -C push -u origin HEAD` instead. +**Where it runs.** The Step 4 confirmation is the user's go-ahead. With it given and the stranded-work guard passed for the row: + +- **Linux and macOS (`uname -s` is not `MINGW*`, `MSYS*` or `CYGWIN*`): run both commands yourself.** A process's working directory can be removed on these systems while it runs, so the session's own worktree is removable, and the session carries on from a directory that still exists (run later commands with `git -C` on the main checkout, or by absolute path). Run the branch deletion from the main checkout (`git -C branch -D `), and the removal after Steps 4a and 4b's guards for that worktree. Unlock first only for a row the Step 4b lock rules allow. +- **Windows: emit them for the user.** The running Claude Code process holds the session worktree's directory handle there, so the worktree cannot delete itself. Surface the commands to run from a main-repo terminal (or via the `!` prompt prefix) instead of executing them. +- **A hook blocks a command, on any host: stop, report the block and the command, and emit it for the user.** Try the command and report a block; never route around a hook, and never retry the same command. + +**The stranded-work precondition applies here too, and this is where it bites hardest.** Removal left the branch ref intact; this line is what actually destroys the commits. Run or emit `git branch -D ` only for a branch whose row was `landed` or `ok`. For `STRANDED`, `UNKNOWN`, or `superseded`, run and emit nothing and say why. A suggested command in a code block reads as vetted, and a user pasting it has no way to know the guard upstream was never applied to it. Emit `git -C push -u origin HEAD` instead. The branch this deletes may also be checked out by another worktree; `git branch -D` refuses in that case, which is git protecting the peer rather than an error to work around. -Remote branch cleanup is not needed when the repo has `delete_branch_on_merge` enabled (GitHub deletes the remote branch on merge). Read the repository's `delete_branch_on_merge` setting ([GitHub](../../../reference/providers/github/README.md#branch-rules)); otherwise also emit `git push origin --delete --end-of-options ''`, only for a name that matches `^[A-Za-z0-9._/-]+$`, does not start with `-` and holds no `..`; any other name is reported, never emitted. +Remote branch cleanup is not needed when the repo has `delete_branch_on_merge` enabled (GitHub deletes the remote branch on merge). Read the repository's `delete_branch_on_merge` setting ([GitHub](../../../reference/providers/github/README.md#branch-rules)); otherwise also emit (remote deletion is always emitted, never run inline) `git push origin --delete --end-of-options ''`, only for a name that matches `^[A-Za-z0-9._/-]+$`, does not start with `-` and holds no `..`; any other name is reported, never emitted. ## Step 5: Verify physical deletion, prune, and report From bc2655e2d5c3b5102110e99aab44533c32f00a1f Mon Sep 17 00:00:00 2001 From: "melodic-automation-lanes[bot]" <337595936+melodic-automation-lanes[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 07:13:49 +0000 Subject: [PATCH 2/7] fix(source-control): align worktree cleanup eval and Step 4c wording Co-Authored-By: Claude Sonnet 5.5 --- .../source-control/skills/worktree/context/cleanup.md | 2 +- .../source-control/skills/worktree/evals/evals.json | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/source-control/skills/worktree/context/cleanup.md b/plugins/source-control/skills/worktree/context/cleanup.md index f34a239484..ca5f8cca2f 100644 --- a/plugins/source-control/skills/worktree/context/cleanup.md +++ b/plugins/source-control/skills/worktree/context/cleanup.md @@ -319,7 +319,7 @@ git worktree remove # only if the active worktree was **Where it runs.** The Step 4 confirmation is the user's go-ahead. With it given and the stranded-work guard passed for the row: -- **Linux and macOS (`uname -s` is not `MINGW*`, `MSYS*` or `CYGWIN*`): run both commands yourself.** A process's working directory can be removed on these systems while it runs, so the session's own worktree is removable, and the session carries on from a directory that still exists (run later commands with `git -C` on the main checkout, or by absolute path). Run the branch deletion from the main checkout (`git -C branch -D `), and the removal after Steps 4a and 4b's guards for that worktree. Unlock first only for a row the Step 4b lock rules allow. +- **Linux and macOS (`uname -s` is not `MINGW*`, `MSYS*` or `CYGWIN*`): run both commands yourself.** A process's working directory can be removed on these systems while it runs, so the session's own worktree is removable, and the session carries on from a directory that still exists (run later commands with `git -C` on the main checkout, or by absolute path). Run the branch deletion from the main checkout (`git -C branch -D `), and the `git worktree remove` line for the session's own worktree, which is the one Step 4b leaves for this step; every other candidate was already removed in Step 4b, after 4a and the 4b guards. Unlock first only for a row the Step 4b lock rules allow. - **Windows: emit them for the user.** The running Claude Code process holds the session worktree's directory handle there, so the worktree cannot delete itself. Surface the commands to run from a main-repo terminal (or via the `!` prompt prefix) instead of executing them. - **A hook blocks a command, on any host: stop, report the block and the command, and emit it for the user.** Try the command and report a block; never route around a hook, and never retry the same command. diff --git a/plugins/source-control/skills/worktree/evals/evals.json b/plugins/source-control/skills/worktree/evals/evals.json index 5d3c4e508f..4e07a42996 100644 --- a/plugins/source-control/skills/worktree/evals/evals.json +++ b/plugins/source-control/skills/worktree/evals/evals.json @@ -16,13 +16,13 @@ }, { "id": 2, - "name": "cleanup-emits-branch-delete-not-inline", - "prompt": "/worktree cleanup — remove the stale worktrees and delete their branches.", - "expected_output": "Cleanup removes stale/orphaned worktrees but does NOT run the branch deletion inline: it emits the git branch -D commands for the USER to run — branch deletion is destructive and the project's hooks may block it mid-session. If the active worktree is itself a cleanup candidate, its removal is emitted too (a worktree can't delete itself), but not when the current checkout is not a target. -D (not -d) is used because squash-merge changes the SHA.", + "name": "cleanup-branch-delete-after-confirmation", + "prompt": "/worktree cleanup — remove the stale worktrees and delete their branches. (Windows host, or a hook blocks git branch -D.)", + "expected_output": "Cleanup removes stale/orphaned worktrees after the confirmation, but on Windows or when a hook blocks the command it emits the git branch -D commands for the USER to run instead of running them inline (on Linux/macOS it runs them itself once the user confirmed and the stranded-work guard passed). If the active worktree is itself a cleanup candidate, its removal is emitted too on Windows (the session holds its directory handle), but not when the current checkout is not a target. -D (not -d) is used because squash-merge changes the SHA.", "files": [], "expectations": [ - "The skill does NOT run git branch -D inline — it emits the branch-deletion command(s) for the user to run", - "If the active worktree is itself one of the cleanup candidates, its removal is emitted for the user to run (a worktree cannot delete itself) rather than executed inline; self-removal is not emitted when the current checkout is not a target", + "On Windows or after a hook block, the skill does NOT run git branch -D inline — it emits the branch-deletion command(s) for the user to run, and never before the Step 4 confirmation", + "If the active worktree is itself one of the cleanup candidates on Windows, its removal is emitted for the user to run rather than executed inline; self-removal is not emitted when the current checkout is not a target", "Branch deletion uses -D (not -d), and the reasoning cites squash-merge changing the SHA / destructiveness / hooks" ] }, From 001be618243f4a4db9fc3a45a821a231cf5fcabb Mon Sep 17 00:00:00 2001 From: "melodic-automation-lanes[bot]" <337595936+melodic-automation-lanes[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 07:18:25 +0000 Subject: [PATCH 3/7] fix(source-control): harden Step 4c branch deletion and defer session worktree from 4b Co-Authored-By: Claude Sonnet 5.5 --- .../skills/worktree/context/cleanup.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/plugins/source-control/skills/worktree/context/cleanup.md b/plugins/source-control/skills/worktree/context/cleanup.md index ca5f8cca2f..20ef7f05d6 100644 --- a/plugins/source-control/skills/worktree/context/cleanup.md +++ b/plugins/source-control/skills/worktree/context/cleanup.md @@ -102,6 +102,8 @@ rm -rf git worktree remove ``` +**The session's own worktree is deferred.** When the candidate path equals `git rev-parse --show-toplevel`, skip its removal here and leave it for Step 4c, which removes it before deleting its branch. + **Two guards, the workspace `down` and one reap run before ANY removal, plain or forced, in this order: guard 1 → guard 2 → `down` → reap → removal.** `down` comes after the guards so a worktree they keep keeps its services. The stranded-work guard first, because it can abort the removal outright. Running the carried-file comparison ahead of it spends work reconciling files for a worktree that is not going to be removed, and an aborted removal loses nothing that needed syncing. The reap runs last of the three for the same reason inverted: it is the only one of the three that is *not* undoable, so it must not fire for a worktree the guards are about to save. **1. Stranded-work guard:** removal itself is recoverable, since `git worktree remove` unregisters the directory and leaves the branch ref intact, but a detached-HEAD worktree has no branch ref holding its commits, and for every other candidate the `git branch -D` emitted in Step 4c finishes the job one step later. Both are covered here, at the point where the candidate is still on disk. @@ -313,17 +315,19 @@ Do NOT swallow stderr with `2>/dev/null`. A failed removal must surface so Step ### Step 4c: Branch + current-worktree deletion (run inline on POSIX, emit on Windows or a hook block) ```bash -git branch -D # -D needed (squash-merge changes SHA) -git worktree remove # only if the active worktree was itself a candidate +git -C '' worktree remove '' # first, only if the active worktree was itself a candidate +git -C '' branch -D --end-of-options '' # second: -D needed (squash-merge changes SHA); git refuses while the branch is still checked out in a worktree ``` +Substitute ``, `` and `` only when each is safe to quote: a branch name matches `^[A-Za-z0-9._/-]+$`, does not start with `-` and holds no `..`; a path holds no single quote. Keep the single quotes. Any other value reaches no command: report it as data, because untrusted input can shape git ref names and paths. + **Where it runs.** The Step 4 confirmation is the user's go-ahead. With it given and the stranded-work guard passed for the row: -- **Linux and macOS (`uname -s` is not `MINGW*`, `MSYS*` or `CYGWIN*`): run both commands yourself.** A process's working directory can be removed on these systems while it runs, so the session's own worktree is removable, and the session carries on from a directory that still exists (run later commands with `git -C` on the main checkout, or by absolute path). Run the branch deletion from the main checkout (`git -C branch -D `), and the `git worktree remove` line for the session's own worktree, which is the one Step 4b leaves for this step; every other candidate was already removed in Step 4b, after 4a and the 4b guards. Unlock first only for a row the Step 4b lock rules allow. +- **Linux and macOS (`uname -s` is not `MINGW*`, `MSYS*` or `CYGWIN*`): run both commands yourself.** A process's working directory can be removed on these systems while it runs, so the session's own worktree is removable, and the session carries on from a directory that still exists (run later commands with `git -C` on the main checkout, or by absolute path). Run the worktree removal first and the branch deletion after it, both through `git -C ''` as in the block above (the branch stays checked out, and so undeletable, while its worktree exists). The removal is for the session's own worktree, which is the one Step 4b defers to this step; every other candidate was already removed in Step 4b, after 4a and the 4b guards. Unlock first only for a row the Step 4b lock rules allow. - **Windows: emit them for the user.** The running Claude Code process holds the session worktree's directory handle there, so the worktree cannot delete itself. Surface the commands to run from a main-repo terminal (or via the `!` prompt prefix) instead of executing them. - **A hook blocks a command, on any host: stop, report the block and the command, and emit it for the user.** Try the command and report a block; never route around a hook, and never retry the same command. -**The stranded-work precondition applies here too, and this is where it bites hardest.** Removal left the branch ref intact; this line is what actually destroys the commits. Run or emit `git branch -D ` only for a branch whose row was `landed` or `ok`. For `STRANDED`, `UNKNOWN`, or `superseded`, run and emit nothing and say why. A suggested command in a code block reads as vetted, and a user pasting it has no way to know the guard upstream was never applied to it. Emit `git -C push -u origin HEAD` instead. +**The stranded-work precondition applies here too, and this is where it bites hardest.** Removal left the branch ref intact; this line is what actually destroys the commits. Run or emit the `git branch -D` line only for a branch whose row was `landed` or `ok`. For `STRANDED`, `UNKNOWN`, or `superseded`, run and emit nothing and say why. A suggested command in a code block reads as vetted, and a user pasting it has no way to know the guard upstream was never applied to it. Emit `git -C push -u origin HEAD` instead. The branch this deletes may also be checked out by another worktree; `git branch -D` refuses in that case, which is git protecting the peer rather than an error to work around. From d85604c14ec963898631910801574ec22c72cfd7 Mon Sep 17 00:00:00 2001 From: "melodic-automation-lanes[bot]" <337595936+melodic-automation-lanes[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 07:22:42 +0000 Subject: [PATCH 4/7] fix(source-control): run Step 4b guards, down and reap before deferred session-worktree removal Co-Authored-By: Claude Sonnet 5.5 --- plugins/source-control/skills/worktree/context/cleanup.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/source-control/skills/worktree/context/cleanup.md b/plugins/source-control/skills/worktree/context/cleanup.md index 20ef7f05d6..719a43c576 100644 --- a/plugins/source-control/skills/worktree/context/cleanup.md +++ b/plugins/source-control/skills/worktree/context/cleanup.md @@ -102,7 +102,7 @@ rm -rf git worktree remove ``` -**The session's own worktree is deferred.** When the candidate path equals `git rev-parse --show-toplevel`, skip its removal here and leave it for Step 4c, which removes it before deleting its branch. +**The session's own worktree is deferred.** When the candidate path equals `git rev-parse --show-toplevel`, skip only the `git worktree remove` line here and leave it for Step 4c, which removes it before deleting its branch. Both guards, the workspace `down` and the reap still run in this step, in the order below, while the directory is on disk: the reap does `cd ` into the candidate, so it cannot follow the removal, and plain removal loses gitignored files with exit 0. **Two guards, the workspace `down` and one reap run before ANY removal, plain or forced, in this order: guard 1 → guard 2 → `down` → reap → removal.** `down` comes after the guards so a worktree they keep keeps its services. The stranded-work guard first, because it can abort the removal outright. Running the carried-file comparison ahead of it spends work reconciling files for a worktree that is not going to be removed, and an aborted removal loses nothing that needed syncing. The reap runs last of the three for the same reason inverted: it is the only one of the three that is *not* undoable, so it must not fire for a worktree the guards are about to save. @@ -321,7 +321,7 @@ git -C '' branch -D --end-of-options '' # second: -D n Substitute ``, `` and `` only when each is safe to quote: a branch name matches `^[A-Za-z0-9._/-]+$`, does not start with `-` and holds no `..`; a path holds no single quote. Keep the single quotes. Any other value reaches no command: report it as data, because untrusted input can shape git ref names and paths. -**Where it runs.** The Step 4 confirmation is the user's go-ahead. With it given and the stranded-work guard passed for the row: +**Where it runs.** The Step 4 confirmation is the user's go-ahead. With it given and both Step 4b guards (stranded-work and carried-ignored-file) passed for the row, and the workspace `down` and the reap already run there: - **Linux and macOS (`uname -s` is not `MINGW*`, `MSYS*` or `CYGWIN*`): run both commands yourself.** A process's working directory can be removed on these systems while it runs, so the session's own worktree is removable, and the session carries on from a directory that still exists (run later commands with `git -C` on the main checkout, or by absolute path). Run the worktree removal first and the branch deletion after it, both through `git -C ''` as in the block above (the branch stays checked out, and so undeletable, while its worktree exists). The removal is for the session's own worktree, which is the one Step 4b defers to this step; every other candidate was already removed in Step 4b, after 4a and the 4b guards. Unlock first only for a row the Step 4b lock rules allow. - **Windows: emit them for the user.** The running Claude Code process holds the session worktree's directory handle there, so the worktree cannot delete itself. Surface the commands to run from a main-repo terminal (or via the `!` prompt prefix) instead of executing them. From a904343799e222a5e345a857c968276b8f793115 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:15:48 -0400 Subject: [PATCH 5/7] docs(source-control): keep the stranded-work rule inside the re-attach slice Move the cleanup action's safety invariants directly under its heading, ahead of the procedure summary and the Scope paragraph, so the stranded-work rule sits within the first 20000 bytes that reattach-slice.test.sh requires. Pure reorder; no wording changes. Co-Authored-By: Claude Opus 5.5 --- plugins/source-control/skills/worktree/SKILL.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/source-control/skills/worktree/SKILL.md b/plugins/source-control/skills/worktree/SKILL.md index 96b7d05c19..e04c26ece8 100644 --- a/plugins/source-control/skills/worktree/SKILL.md +++ b/plugins/source-control/skills/worktree/SKILL.md @@ -139,10 +139,6 @@ The **Work** axis answers a question age and PR state cannot: whether removing a ## Action: `cleanup [...] [--dry-run]` -Remove stale worktrees, orphaned metadata, branches from merged PRs, and the project-scope plugin install records the worktree leaves behind. Full 5-step procedure. Prune orphaned metadata → identify candidates (detection reasons: orphaned dir / prunable / PR-merged / stale / stale lock / reap age, the last proposing an already-safe worktree older than `${user_config.worktree_reap_after_hours}` hours, 48 by default) → present → execute (4a guards → release file locks → `down` → reap records → remove, 4b branch deletion and current-worktree removal) → verify physical deletion: [context/cleanup.md](context/cleanup.md). `--dry-run` reports candidates and takes no action. - -**Scope.** Paths given after `cleanup` limit the run to exactly those worktrees: the same classification and every Step 4 guard apply to each, and the repository-wide inventory and root scan are skipped. A bare `cleanup` in a session that created, entered or merged a PR from one worktree asks whether to target that worktree or the whole repository before inventorying; the whole-repository sweep behaves as before when chosen. See [context/cleanup.md § Scope](context/cleanup.md#scope-explicit-paths-or-the-whole-repository). - **Safety invariants cleanup MUST honor** (full detail in context/cleanup.md): - **Never remove a worktree, and never emit its `git branch -D`, while its work is stranded or unproven**, a worktree whose unpushed commits are not already on the base loses them, and `unknown` is treated exactly as `stranded`. Both sites are guarded because removal itself is recoverable (the branch ref survives) while the branch deletion one step later is not; a detached-HEAD worktree is the exception where removal alone is already terminal. The override is `--acknowledge-stranded`, per worktree, never a bare `--force`, which answers git's dirty-tree check, a different question. Offer `git -C push -u origin HEAD` first: it makes the commits durable without anyone judging whether the work matters. @@ -154,6 +150,10 @@ Remove stale worktrees, orphaned metadata, branches from merged PRs, and the pro - **Run the Workspace environment `down` before removing a worktree**, after the guards clear and before the reap, read from the fetched default branch and run through the Bash tool; a failed `down` is reported and the removal continues under the guards. Procedure in [context/cleanup.md](context/cleanup.md) Step 4a. - **Run `git branch -D` + self-worktree removal inline on Linux and macOS, only after the Step 4 confirmation and a passed stranded-work guard; emit them for the USER to run on Windows or when a hook blocks the command** (Step 4b). On Windows the running Claude Code session holds the directory handle, so a worktree can't delete itself; on POSIX a working directory can be removed while a process runs. A hook block is reported, never worked around. `-D` (not `-d`) is needed because squash-merge changes the SHA. +Remove stale worktrees, orphaned metadata, branches from merged PRs, and the project-scope plugin install records the worktree leaves behind. Full 5-step procedure. Prune orphaned metadata → identify candidates (detection reasons: orphaned dir / prunable / PR-merged / stale / stale lock / reap age, the last proposing an already-safe worktree older than `${user_config.worktree_reap_after_hours}` hours, 48 by default) → present → execute (4a guards → release file locks → `down` → reap records → remove, 4b branch deletion and current-worktree removal) → verify physical deletion: [context/cleanup.md](context/cleanup.md). `--dry-run` reports candidates and takes no action. + +**Scope.** Paths given after `cleanup` limit the run to exactly those worktrees: the same classification and every Step 4 guard apply to each, and the repository-wide inventory and root scan are skipped. A bare `cleanup` in a session that created, entered or merged a PR from one worktree asks whether to target that worktree or the whole repository before inventorying; the whole-repository sweep behaves as before when chosen. See [context/cleanup.md § Scope](context/cleanup.md#scope-explicit-paths-or-the-whole-repository). + --- ## Action: `audit` From 186ca266e42f6ee368818fca4d43994ceb84cf28 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:25:39 -0400 Subject: [PATCH 6/7] docs(source-control): name what the cleanup yes authorizes and where branch -D runs The Step 3 prompt now names the local branch deletion and the session's own worktree removal that Step 4b performs on Linux and macOS, and Step 4 points at that prompt. The stranded-work guard states that Step 4b runs `git branch -D` on Linux and macOS and emits it on Windows or a hook block. Co-Authored-By: Claude Opus 5.5 --- plugins/source-control/skills/worktree/context/cleanup.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plugins/source-control/skills/worktree/context/cleanup.md b/plugins/source-control/skills/worktree/context/cleanup.md index 7354cb7802..805b445cd1 100644 --- a/plugins/source-control/skills/worktree/context/cleanup.md +++ b/plugins/source-control/skills/worktree/context/cleanup.md @@ -61,13 +61,15 @@ Collect the stranded-work record in the same pass, per `status.md`'s data-collec | 3 | (orphaned metadata) | none | Directory no longer exists | | 4 | /old-lane | feat/old-lane | Reap age (nothing unpushed, last commit 3d ago); locked: `lane active on session since `; stale: session on has no transcript change in 120 minutes | -**Action:** Remove these 4 items? (yes/no/select) +**Action:** Remove these 4 items and delete their local branches? (yes/no/select) ``` +That yes is the confirmation Step 4b relies on, so the prompt names what it authorizes. On Linux and macOS: deleting each removed worktree's local branch with `git branch -D`, and, when this session's own worktree is listed, removing it (name its path). On Windows, where Step 4b emits those commands for the user instead, ask only "Remove these N items?". + ## Step 4: Execute or report - **`--dry-run`**: Report candidates only, take no action. Exit. -- **Otherwise**: Ask for confirmation. On "yes", record the free space first (below), then run each candidate through phases 4a → 4b. Phase 4a runs the guards first, then the file-lock release, the Workspace `down`, the reap and the removal; a row any guard keeps has no side effects: no process is stopped and nothing is reaped. +- **Otherwise**: Ask for confirmation with the Step 3 prompt. On "yes", record the free space first (below), then run each candidate through phases 4a → 4b. Phase 4a runs the guards first, then the file-lock release, the Workspace `down`, the reap and the removal; a row any guard keeps has no side effects: no process is stopped and nothing is reaped. **Free space before removal.** Step 5 reports how much disk the run gave back, so take the first reading before any removal on a directory the removal leaves in place: the worktree root that @@ -97,7 +99,7 @@ git worktree remove **Two guards, the file-lock release, the workspace `down` and one reap run before ANY removal, plain or forced, in this order: guard 1 → guard 2 → file-lock release → `down` → reap → removal.** For a locked candidate, the `worktree-claim.sh stale` proof (exit 0) is part of guard 2: it is a read-only check, and a row where it fails is kept before the release, `down` and the reap. The guards only read state, so a row any guard keeps has no side effects: no process is stopped, no `down` runs and nothing is reaped. The file-lock release and `down` come after the guards so a worktree they keep keeps its processes and services. The stranded-work guard first, because it can abort the removal outright. Running the carried-file comparison ahead of it spends work reconciling files for a worktree that is not going to be removed, and an aborted removal loses nothing that needed syncing. The reap runs last before the removal for the same reason inverted: the process stop and the reap are the steps that are *not* undoable, so neither may fire for a worktree the guards are about to save. -**1. Stranded-work guard:** removal itself is recoverable, since `git worktree remove` unregisters the directory and leaves the branch ref intact, but a detached-HEAD worktree has no branch ref holding its commits, and for every other candidate the `git branch -D` emitted in Step 4b finishes the job one step later. Both are covered here, at the point where the candidate is still on disk. +**1. Stranded-work guard:** removal itself is recoverable, since `git worktree remove` unregisters the directory and leaves the branch ref intact, but a detached-HEAD worktree has no branch ref holding its commits, and for every other candidate the `git branch -D` that Step 4b runs (Linux and macOS) or emits for the user (Windows, or when a hook blocks it) finishes the job one step later. Both are covered here, at the point where the candidate is still on disk. Read the candidate's row from the record collected in Step 2: From 68a527f046aa562fd6ec171dd68ecd4857c4b5e5 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:31:30 -0400 Subject: [PATCH 7/7] docs(source-control): say the stranded-work rule covers a run or emitted branch -D This PR runs `git branch -D` inline on Linux and macOS and emits it on Windows, so the safety rule, the reattach-slice test that pins its wording, and the eval expectation now say "run or emit". The rule stays inside the first 20000 bytes. Co-Authored-By: Claude Opus 5.5 --- plugins/source-control/skills/worktree/SKILL.md | 2 +- plugins/source-control/skills/worktree/evals/evals.json | 2 +- plugins/source-control/skills/worktree/reattach-slice.test.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/source-control/skills/worktree/SKILL.md b/plugins/source-control/skills/worktree/SKILL.md index e04c26ece8..1412753081 100644 --- a/plugins/source-control/skills/worktree/SKILL.md +++ b/plugins/source-control/skills/worktree/SKILL.md @@ -141,7 +141,7 @@ The **Work** axis answers a question age and PR state cannot: whether removing a **Safety invariants cleanup MUST honor** (full detail in context/cleanup.md): -- **Never remove a worktree, and never emit its `git branch -D`, while its work is stranded or unproven**, a worktree whose unpushed commits are not already on the base loses them, and `unknown` is treated exactly as `stranded`. Both sites are guarded because removal itself is recoverable (the branch ref survives) while the branch deletion one step later is not; a detached-HEAD worktree is the exception where removal alone is already terminal. The override is `--acknowledge-stranded`, per worktree, never a bare `--force`, which answers git's dirty-tree check, a different question. Offer `git -C push -u origin HEAD` first: it makes the commits durable without anyone judging whether the work matters. +- **Never remove a worktree, and never run or emit its `git branch -D`, while its work is stranded or unproven**, a worktree whose unpushed commits are not already on the base loses them, and `unknown` is treated exactly as `stranded`. Both sites are guarded because removal itself is recoverable (the branch ref survives) while the branch deletion one step later is not; a detached-HEAD worktree is the exception where removal alone is already terminal. The override is `--acknowledge-stranded`, per worktree, never a bare `--force`, which answers git's dirty-tree check, a different question. Offer `git -C push -u origin HEAD` first: it makes the commits durable without anyone judging whether the work matters. - **Release OS file locks BEFORE `git worktree remove --force`, but only after the keep/remove guards clear** (Step 4a), so a worktree a guard keeps loses no process. On Windows `--force` unregisters the worktree from git but leaves a husk on disk if a process holds a file handle. Stop build servers (`dotnet build-server shutdown`, Gradle `--stop`, or your stack's equivalent) and worktree-rooted daemons/MCP servers first; stop ONLY those (never another live worktree's processes). - **Reap the worktree's project-scope plugin install records BEFORE removing its directory, and only ever on a teardown this action is performing** (Step 4a). Claude Code keys a project-scope install to a literal `projectPath` and never reaps it when that path goes away, so a torn-down worktree leaves one record per installed plugin behind forever. Run `${CLAUDE_PLUGIN_ROOT}/scripts/reap-project-plugin-records.sh --worktree-path ` **from inside** the candidate, after the stranded-work and carried-file guards clear and, for a locked candidate, `worktree-claim.sh stale ` exits 0: `claude plugin uninstall -s project` has no path flag and resolves strictly against the current directory. **Never trigger a reap on bare path non-resolution.** A record for a live repository on an unmounted share or a detached volume looks identical to a dead worktree, and destroying it is unrecoverable. An **orphaned-directory** candidate is the strictest case, because it is the only class with no stranded-work row to read: it must pass every test for its class. An `empty` row needs *not a symlink*, *not a work tree*, *no `.git` entry*, and *empty*. A `husk` row needs *not a symlink*, *not a work tree*, a `.git` file naming `/worktrees/` whose admin dir is gone while `` is still a repository, and *no entry but that file*. The `.git` test is the one that matters and the first does not imply it. A live worktree whose main clone was moved, deleted, or unmounted still carries its `.git` file while `rev-parse` fails, and has no `` to show, and the external root is shared across repositories, so such a directory is another lane's live work: it is `unknown`, never a husk. Pre-existing orphans are `audit`'s to report, not this action's to remove. - **A worktree's lock is its owner's claim, and reap age never overrides it.** Every helper-created worktree is locked when created. A locked worktree past the reap age is proposed only when `worktree-claim.sh stale ` exits 0, the same proof a **Stale lock** needs; otherwise it stays locked and is reported. Unlock and removal wait for the Step 4 confirmation, never `--force --force`. diff --git a/plugins/source-control/skills/worktree/evals/evals.json b/plugins/source-control/skills/worktree/evals/evals.json index 4e07a42996..37af0c887c 100644 --- a/plugins/source-control/skills/worktree/evals/evals.json +++ b/plugins/source-control/skills/worktree/evals/evals.json @@ -130,7 +130,7 @@ "id": 11, "name": "branch-delete-carries-the-same-precondition", "prompt": "/worktree cleanup — the worktree directory is already gone, just give me the branch cleanup commands to paste.", - "expected_output": "Removal leaves the branch ref intact, so the emitted `git branch -D` is where the commits actually die. The skill emits it only for a branch whose work is landed or ok; for STRANDED, UNKNOWN, or superseded it emits nothing destructive and says why, offering `git -C push -u origin HEAD` instead. A suggested command in a code block reads as vetted, so an unguarded one must not appear at all.", + "expected_output": "Removal leaves the branch ref intact, so the `git branch -D`, run or emitted, is where the commits actually die. The skill emits it only for a branch whose work is landed or ok; for STRANDED, UNKNOWN, or superseded it emits nothing destructive and says why, offering `git -C push -u origin HEAD` instead. A suggested command in a code block reads as vetted, so an unguarded one must not appear at all.", "files": [], "expectations": [ "The stranded-work precondition is applied to the emitted git branch -D, not only to the worktree removal", diff --git a/plugins/source-control/skills/worktree/reattach-slice.test.sh b/plugins/source-control/skills/worktree/reattach-slice.test.sh index a1effeb6bc..36def551c0 100755 --- a/plugins/source-control/skills/worktree/reattach-slice.test.sh +++ b/plugins/source-control/skills/worktree/reattach-slice.test.sh @@ -32,7 +32,7 @@ assert_gate() { assert_gate 'Pass this session'"'"'s id on every direct helper invocation.' # shellcheck disable=SC2016 # intentional literal phrase with backticks -assert_gate 'Never remove a worktree, and never emit its `git branch -D`, while its work is stranded' +assert_gate 'Never remove a worktree, and never run or emit its `git branch -D`, while its work is stranded' assert_gate 'never nested inside any repository'"'"'s tree' echo "PASS=$PASS FAIL=$FAIL"