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..a38cc35699 --- /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 4b 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 5338f11939..1412753081 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,20 +137,22 @@ 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 emit branch deletion for the user) → verify physical deletion: [context/cleanup.md](context/cleanup.md). `--dry-run` reports candidates and takes no action. +## Action: `cleanup [...] [--dry-run]` **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`. - **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 4a. -- **Emit `git branch -D` + self-worktree removal for the USER to run, never inline** (Step 4b). 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 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). --- @@ -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 432364e7ee..805b445cd1 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 4b 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 4b 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 @@ -52,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 @@ -84,9 +95,11 @@ rm -rf git worktree remove ``` +**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 4b, which removes it before deleting its branch. Both guards, the file-lock release, 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 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: @@ -300,21 +313,26 @@ 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 4b: 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 4b: 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 +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 ``` -**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. +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 both Step 4a 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 4a defers to this step; every other candidate was already removed in Step 4a, after its guards. Unlock first only for a row the Step 4a 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 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. -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 diff --git a/plugins/source-control/skills/worktree/evals/evals.json b/plugins/source-control/skills/worktree/evals/evals.json index 5d3c4e508f..37af0c887c 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" ] }, @@ -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"