Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changes/source-control/worktree-cleanup-scope-7332-3f9a1c7e.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
bump: minor
---

### Added

- `/source-control:worktree cleanup [<path>...] [--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.
18 changes: 10 additions & 8 deletions plugins/source-control/skills/worktree/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 [<path>...] [--dry-run], /worktree audit

## Repository context. Gather first

Expand Down Expand Up @@ -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 [<path>...] [--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 |

---
Expand Down Expand Up @@ -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 [<path>...] [--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 <path> 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 <path> 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 <path>` **from inside** the candidate, after the stranded-work and carried-file guards clear and, for a locked candidate, `worktree-claim.sh stale <path>` 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 `<common>/worktrees/<name>` whose admin dir is gone while `<common>` 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 `<common>` 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 <path>` 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).

---

Expand Down Expand Up @@ -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: `<type>/<kebab-description>` with a Conventional Commits type prefix).

Expand Down
Loading
Loading