Git Worktrees
Git worktrees as the default isolation for parallel sub-agent delegation on one machine, with a safe janitor that enforces teardown so worktrees never silently fill the disk.
Tags
README
git-worktrees
Git worktrees as the default isolation for parallel sub-agent delegation on one machine, with enforced teardown so worktrees never silently fill the disk.
What This Module Does
A worktree is a second working tree from the same .git, with its own index and HEAD. That independence is what makes parallel agents safe: each builds, tests, and commits on its own branch without touching the others. When a delegator (you, /etp, /mawf, /xplan, or the Agent/Workflow isolation: "worktree" option) fans work out to parallel implementers, each gets its own worktree — created per unit of work and destroyed when that unit merges — instead of a permanent extra clone.
Key capabilities:
- Default parallel-delegation isolation: one ephemeral worktree per unit of work, sharing the parent
.git. /worktree-start: create a worktree hands-on, with gitignore verification and project-setup auto-detection./worktree-finish: finish one worktree via a four-option gate (merge locally / push + PR / keep / discard)./worktree-sweep: repo-wide safe janitor — remove clean worktrees, preserve anything with unsaved work, prune stale metadata, and delete each removed worktree's branch once the default branch provably contains its work. The enforced-teardown backstop, and the only permitted way to delete a squash-merged branch.
Why It Exists
On 2026-07-13, delegated work using isolation: "worktree" left 33 stale worktrees consuming ~237 GB on one repo. The harness auto-removes a worktree only if it is unchanged; a built-in worktree lingers forever, and nothing mandated cleaning them up. This module makes worktrees the default (ephemeral, shared .git) and makes teardown load-bearing (mandatory per-unit removal + /worktree-sweep orphan backstop). See rules/git-worktrees.md for the full lifecycle and removal-safety rule.
Worktrees vs Multi-Agent Clones
| Situation | Use |
|---|---|
| Parallel sub-agent delegation on one machine (the default) | Worktree — one per unit, removed on merge |
| Single agent trying an idea / comparing two branches | Worktree |
| Multiple long-lived independent agents owning a repo for days | Clone (multi-agent module) |
Per-branch dev-server ports (worktrees share .env) |
Clone |
Hook-driven per-branch tracking.csv |
Clone |
| Cross-machine / cloud dispatch | Clone / remote isolation |
Worktrees share .git objects and external caches, but each still builds its own .build — the disk win over clones is ephemerality and the shared .git, not a smaller build. That win only materializes if teardown actually happens, which is why /worktree-sweep exists.
Files
| File | Type | Description |
|---|---|---|
rules/git-worktrees.md |
rule | Default-isolation framing, delegation lifecycle, honest economics, removal-safety rule, pitfalls |
commands/worktree-start.md |
command | /worktree-start {branch-name} |
commands/worktree-finish.md |
command | /worktree-finish — four-option gate for one worktree |
commands/worktree-sweep.md |
command | /worktree-sweep — safe repo-wide orphan janitor |
lib/worktree-sweep.sh |
lib | Deterministic sweep implementation (classify → non-force remove → prune → verified branch delete → report) |
Dependencies
None. The module is self-contained.
Manual Installation
# Rule
mkdir -p ~/.claude/rules
cp rules/git-worktrees.md ~/.claude/rules/git-worktrees.md
# Commands
mkdir -p ~/.claude/commands
cp commands/worktree-start.md ~/.claude/commands/worktree-start.md
cp commands/worktree-finish.md ~/.claude/commands/worktree-finish.md
cp commands/worktree-sweep.md ~/.claude/commands/worktree-sweep.md
# Lib
mkdir -p ~/.claude/lib
cp lib/worktree-sweep.sh ~/.claude/lib/worktree-sweep.sh
chmod +x ~/.claude/lib/worktree-sweep.sh
Will install
| Path | Action | Target | Type |
|---|---|---|---|
rules/git-worktrees.md | → | rules/git-worktrees.md | rule |
commands/worktree-start.md | → | commands/worktree-start.md | command |
commands/worktree-finish.md | → | commands/worktree-finish.md | command |
commands/worktree-sweep.md | → | commands/worktree-sweep.md | command |
lib/worktree-sweep.sh | → | lib/worktree-sweep.sh | lib |
Dependencies
No dependencies.
Required by
No other module depends on this one.
Included in presets
Install this module
Agent prompt
Recommended for agent users -- hands the whole install off to your assistant.
Fetch https://cd23a9be.ccgm-site.pages.dev/modules/git-worktrees.md and install this module into my Claude Code setup.
Native plugin marketplace
One command via the native plugin marketplace -- additive, does not merge settings.json.
claude plugin install git-worktrees@ccgm
The marketplace path is additive, not a replacement: it installs commands, agents, and skills as native plugin components, but it does not perform the bash installer's deep settings.json merge, and it does not write the always-loaded global CLAUDE.md context. Rules are only injected via an opt-in SessionStart hook rather than being auto-loaded. Use the bash installer when those pieces matter to you.
Files
rule (1)
rules/git-worktrees.md
# Git Worktrees (Default Parallel-Delegation Isolation) A worktree is a second working tree checked out from the **same** `.git`. Each worktree has its **own index, HEAD, and working directory**, so two agents can build, test, and commit on different branches at the same time without touching each other's files. That independent-index-and-HEAD property is exactly what makes worktrees safe for parallel work. **Worktrees are the default isolation for parallel sub-agent delegation on a single machine.** When a delegator (you, or a command like `/etp`, `/mawf`, `/xplan`) fans out work to parallel implementer agents, give each agent its own worktree — not its own permanent clone. Worktrees are created per unit of work and destroyed when that unit is done, so disk is reclaimed instead of accumulating. > This corrects earlier guidance that said worktrees are "solo-agent only, not for parallel." The real caveats are narrow (shared `.git/hooks` + `.git/config`, and tolerated ref-lock contention — see Pitfalls); they do **not** make parallel worktrees unsafe. The harness's own `isolation: "worktree"` runs parallel worktrees successfully every day. ## The Incident This Exists To Prevent On 2026-07-13, delegated work on the `evoglyph` repo using the Agent/Workflow `isolation: "worktree"` feature left **33 stale worktrees** across two clones' `.claude/worktrees/` directories, consuming **~237 GB** — each worktree carried its own 9–13 GB `.build`. Free disk fell to 63 GB (99% full). Root cause: the harness's `isolation: "worktree"` auto-removes a worktree **only if it is unchanged**. A worktree that got *built* in is "changed" and lingers forever, and nothing mandated removing a worktree after its PR merged or sweeping orphaned ones. The multi-clone alternative is worse: each clone is a permanent 4 GB fresh / 13 GB built that never gets reclaimed at all. The fix has two halves, and both are load-bearing: **(1)** worktrees are the default (ephemeral, shared `.git`), and **(2)** teardown is mandatory, not best-effort. ## Worktrees vs Clones — When Each Is Right | Situation | Use | |-----------|-----| | **Parallel sub-agent delegation on one machine** (the default) | **Worktree** — one per unit of work, removed when the unit merges | | Single agent trying an idea on a branch without stashing / switching in place | **Worktree** | | Comparing two branches side by side (run both, diff outputs) | **Worktree** | | **Multiple long-lived independent agents** each owning a repo for days | **Clone** | | Per-branch **dev-server ports** (worktrees share `.env`; clones have per-clone `.env.clone` with pre-computed `FRONTEND_PORT`/`BACKEND_PORT`) | **Clone** | | Hook-driven **per-branch `tracking.csv`** issue tracking | **Clone** | | **Cross-machine / cloud dispatch** (a worktree cannot span machines) | **Clone** (or remote isolation) | Reach for the `multi-agent` module's clones only for those specific cases. For ordinary "fan out N independent units across N agents on this machine," worktrees are the answer — see `multi-agent/rules/multi-agent.md` and `subagent-patterns/rules/subagent-patterns.md`, which both now default to worktrees. ## Honest Economics (Do Not Overclaim) Worktrees share the parent repo's `.git` object store and any **external** caches (downloaded models, package caches, `~/.cache`), so they avoid the re-fetch and re-download cost of a fresh clone. **But each worktree still builds its own `.build` / `target/` / `node_modules`-of-record.** Two worktrees compiling the same project produce two full build trees. So the disk win over clones is **ephemerality** (a worktree is created for one unit and destroyed on completion, whereas a clone persists) plus the **shared `.git`** — **not** a smaller build. A worktree you build in and never remove costs the same disk as a clone you built in and never removed. The savings are entirely in the lifecycle. If teardown does not happen, worktrees are no cheaper than clones — they are the incident. ## The Delegation Lifecycle (Every Worktree Has an Owner) A worktree is created for **exactly one unit of work** and has one owner — the delegator that created it. The lifecycle: 1. **Create** — the delegator makes one worktree per unit (via the Agent/Workflow `isolation: "worktree"`, or `/worktree-start` for hands-on work). One branch, one unit. 2. **Implement / test / PR** — the sub-agent does the work inside its worktree, on its feature branch, and opens a PR. 3. **Merge** — the PR is reviewed and merged. 4. **Remove** — **the delegator tears that unit down as soon as its PR merges** (or the unit is abandoned). One command does the whole step — remove the worktree, prune the metadata, and delete the branch if the merge already absorbed it: ```bash bash ~/.claude/lib/worktree-sweep.sh --worktree .claude/worktrees/<name> ``` This step is mandatory, not "when I get around to it." See Cleanup below. 5. **Sweep orphans** — as a backstop, run `/worktree-sweep` to remove any clean worktree that leaked past step 4 (an early exit, a crash, a `isolation:"worktree"` worktree the harness could not auto-remove because it was built in). Steps 4 and 5 are the half of the fix that the incident was missing. A delegator that spawns worktrees and does not tear them down has not finished the job. ## Worktree Location Going forward, **new worktrees live under `<repo>/.claude/worktrees/`** — the same directory the harness's `isolation: "worktree"` already uses. Standardizing there means: - Delegated (`isolation:"worktree"`) and hands-on (`/worktree-start`) worktrees share one tree. - One sweep target covers everything. - `.claude/` is conventionally gitignored by the harness, so worktrees never risk being committed. `<repo>/.worktrees/` is the module's **legacy** location. `/worktree-sweep` still recognizes and cleans it, but do not create new worktrees there. Whichever location is used, its parent directory **must be gitignored**. Committing a worktree directory is catastrophic — it nests an entire working tree inside the repo. `/worktree-start` verifies this before creating anything. ## Creating a Worktree **Delegated (the default for parallel work):** pass `isolation: "worktree"` to the Agent or Workflow tool. The harness creates `.claude/worktrees/agent-<hash>/`, runs the agent there, and auto-removes it **only if the agent left it unchanged**. Because implementers build and commit, their worktrees are "changed" and will **not** auto-remove — the delegator must remove them (lifecycle step 4) and `/worktree-sweep` catches the rest. **Hands-on:** use `/worktree-start <branch-name> [base-branch]`, which does pre-flight checks (gitignore, uniqueness, clean tree), creates the worktree under `.claude/worktrees/<branch-name>/`, runs project setup, and confirms a green baseline. Manually, the underlying commands are: ```bash git fetch origin main git worktree add -b <branch-name> .claude/worktrees/<branch-name> origin/main # new branch git worktree add .claude/worktrees/<branch-name> <existing-branch> # existing branch ``` ## Cleanup Is Mandatory, Not Best-Effort This is the whole lesson of the incident: **cleanup must not depend on the happy path.** - **On the happy path**, the delegator removes each worktree the moment its PR merges (lifecycle step 4). - **On any abnormal exit** (early stop, gate rejection, crash, a `isolation:"worktree"` worktree the harness could not reclaim), the worktree leaks. So a **standalone safe sweep must be runnable at any time** to reclaim orphans: `/worktree-sweep`. - **For unattended runs**, schedule the sweep as a backstop so cleanup happens even if no one remembers (see "Scheduled backstop" below). This mirrors the principle that *safety must not depend on a report being read*: the disk is reclaimed whether or not the operator notices. Tearing down a single worktree — **use the janitor, not hand-rolled git**: ```bash bash ~/.claude/lib/worktree-sweep.sh --worktree .claude/worktrees/<branch-name> ``` It removes the worktree (non-force, so git still refuses on unsaved work), prunes the metadata, and deletes the branch **only if** the default branch already contains its work. Add `--dry-run` first if you want to see the classification before anything changes. The raw git equivalent is two commands, and both are on the allow list: ```bash git worktree remove .claude/worktrees/<branch-name> # non-force; refuses if there is unsaved work git worktree prune # drop administrative state for deleted worktrees ``` Never `rm -rf` a worktree directory — it leaves dangling `.git/worktrees/<name>/` metadata. Always use `git worktree remove` (then `git worktree prune`). ### Do Not Chain `git branch -D` Onto Teardown `git branch -D` is **denied**, and a chain is only as safe as its most dangerous link: a single `git branch -D` segment kills the whole command, including the `git worktree remove` in front of it. The denial names the entire command, so it reads as if worktree removal were blocked. It is not — both worktree commands are permitted; only the branch delete is at fault. This dead-end is why worktrees kept getting left on disk (issue #907): agents chained the branch delete onto teardown, got the whole chain denied, concluded teardown was impossible, and moved on. Measured across session transcripts: ~39 such denials in 26 sessions, spread over two months. The branch delete is denied because it can discard commits that exist nowhere else — and `git branch -d`, the safe form, is no way out either. **This repo family squash-merges**, and a squash merge rewrites the branch's commits, so the branch is not an ancestor of main and `git branch -d` refuses it with "not fully merged." `worktree-sweep.sh` is the way through. It verifies absorption two ways — the branch is an ancestor of the default branch (a normal merge), **or** replaying the branch's tree on the merge base is patch-equivalent to something already upstream (a squash merge) — and only then force-deletes. A branch that passes neither test is kept and reported, so nothing unmerged is ever discarded. | Situation | Command | |-----------|---------| | Worktree still present, PR merged | `bash ~/.claude/lib/worktree-sweep.sh --worktree <path>` | | Worktree already removed, branch left behind | `bash ~/.claude/lib/worktree-sweep.sh --merged-branches` | | Many worktrees to reclaim | `/worktree-sweep` | | Branch genuinely unmerged, discarding it on purpose | `ALLOW_BRANCH_FORCE_DELETE=1 git branch -D <branch>` | The last row is a deliberate statement that you are throwing commits away — not a way around the friction. A PreToolUse hook hard-blocks every force-delete spelling (`-D`, `-d -f`, `--delete --force`, `-Df`) and names the offending segment plus the command to run instead, so a chain that hits it tells you how to fix itself. ## Removal Safety (Codified From the Incident) The procedure that worked in the incident, stated exactly: 1. **Prefer non-force `git worktree remove`.** It is itself a safety gate: git **refuses** (`fatal: '<path>' contains modified or untracked files, use --force to delete it`) whenever the worktree has uncommitted tracked changes **or** untracked non-ignored files. A gitignored build artifact (`.build/`, `target/`, `dist/`) does **not** block a non-force remove — verified on git 2.50.1: a clean worktree with a multi-GB gitignored `.build` removes cleanly without `--force`. 2. **PRESERVE** — never remove — any worktree that has: - **uncommitted tracked changes** (work not yet committed), or - **untracked non-ignored files** (new files not yet added), or - an **in-progress rebase / merge / cherry-pick / revert / bisect** (a paused operation, whether or not it currently shows a conflict), or - a **detached HEAD carrying commits reachable from no ref** (removing it would orphan those commits — a detached HEAD has no branch ref to survive on; see the KEY FACT). 3. **Force-remove only the verified-safe ones**, and only if a non-force remove unexpectedly refuses a worktree you have already classified clean. A sweep should default to *never* forcing — let git's refusal protect unsaved work. 4. **Then `git worktree prune`** to clear metadata for worktrees whose directories are already gone. 5. **Always report what was preserved and why**, so a preserved worktree is a visible decision, not a silent skip. One accepted footgun: a non-force remove also discards a clean worktree's **gitignored** local files — that is what makes it useful (the multi-GB `.build` goes with the checkout), but it also means a worktree-local `.env` or secret that is gitignored is removed too. Those are normally copied from the main checkout (`/worktree-start` does this), so losing them is recoverable; do not keep unique un-committed secrets only inside a worktree. ### KEY FACT: removing a clean worktree never loses committed work Removing a worktree does **not** delete its branch or any committed work. For a worktree **on a branch**, the branch ref stays in the parent `.git`; only the working-tree checkout (and its `.build`) is removed. You can always re-materialize the checkout later: ```bash git worktree add .claude/worktrees/<branch-name> <branch-name> ``` **So a clean worktree on a branch is ALWAYS safe to remove** — its commits survive on the branch ref even though the checkout is gone. This is why a clean worktree on an unmerged feature branch is still safe to reclaim: you lose a re-creatable checkout, never the commits. The one exception is a **detached-HEAD** worktree. It has no branch ref, so commits made on top of a detached checkout (e.g. `git worktree add <path> <sha>` then a fixup commit) are reachable from *nothing* once the checkout is gone — removing it orphans them (gc-eligible). So a clean detached worktree is safe to remove **only if** its HEAD is already reachable from some other ref (the common "parked at an existing commit" case); if it carries ref-orphan commits, preserve it. The sweep's branch cleanup does not weaken this. It deletes a branch only when the default branch already contains that branch's work, so every commit on the deleted ref is still reachable from the default branch. A branch with work of its own is kept, and the report prints the `git worktree add` line that restores its checkout. ### The four cases, decided | Worktree state | Outcome | Why | |----------------|---------|-----| | Clean worktree on a feature branch | **Remove** | Branch ref (and all its commits) preserved in `.git`; only the checkout goes | | Uncommitted tracked edits present | **Preserve** | The edits are not committed anywhere — removal would lose them | | Mid-rebase with an unresolved conflict | **Preserve** | An in-progress operation; removal discards the resolution-in-progress | | Clean detached HEAD, reachable from a ref | **Remove** | The commit survives on that other ref; only the checkout goes | | Clean detached HEAD, commits on no ref | **Preserve** | No ref survives removal — the commits would be orphaned | Branch cleanup adds two more, decided the same way — by whether the work survives elsewhere: | Branch state after its worktree is removed | Outcome | Why | |--------------------------------------------|---------|-----| | Merged or squash-merged into the default branch | **Delete** | Every commit is already reachable from the default branch | | Anything else, including unverifiable | **Keep** | The work exists only on this ref; the report says so and prints the restore command | `/worktree-sweep` implements exactly this classification. ## Scheduled backstop (opt-in) For machines that run unattended delegation, schedule the sweep so orphaned worktrees are reclaimed without anyone remembering. A launchd/cron entry that runs the sweep's non-interactive remove-clean pass per active repo is enough; keep it read-mostly (it only ever removes *clean* worktrees, never forces). This is opt-in because auto-removing worktrees on a timer is a standing action the operator should choose, not a default daemon. ## Pitfalls ### Worktree Lock `git worktree add` can mark a worktree "locked" (`.git/worktrees/<name>/locked`). A locked worktree cannot be removed until `git worktree unlock .claude/worktrees/<name>`. Do not lock worktrees unless you have a specific reason; `/worktree-sweep` reports locked worktrees rather than fighting the lock. ### Moving a Worktree `mv` / `cp -r` breaks Git's internal pointers — `.git/worktrees/<name>/` still refers to the old path. Use `git worktree move <from> <to>`, or remove + re-add. Never move a worktree by hand. ### Two Worktrees, Same Branch Git refuses to check out the same branch in two worktrees at once (`fatal: '<branch>' is already checked out at ...`). This is a feature — it prevents divergent commits on one ref. Reuse the existing worktree or pick a different branch. ### Shared Hooks and Config Worktrees share `.git/hooks/` and `.git/config` with the main checkout and every sibling worktree. A hook installed in one affects all of them. Treat hook and config changes as global, not per-branch. (This — not the index — is the real reason worktrees are not a full substitute for clones in some setups.) ### Ref-Lock Contention Parallel worktrees each have their own index and HEAD, so builds and commits do not collide. They do share the ref database, so simultaneous ref updates (two agents pushing/branching at the exact same instant) can briefly contend on a ref lock. Git retries transparently; this is tolerated, not a correctness problem. ### Stale `.env` Files Worktrees do NOT inherit `.env` or other gitignored local config from the main checkout. Copy or symlink it in if the worktree needs local secrets. Never commit `.env` from a worktree. ## Integration With Existing Git Rules Worktree work follows every existing git rule — worktrees change **where** the working tree lives, not how branches, commits, or PRs are managed: - **Branch-guard is satisfied automatically.** A worktree is always created on a feature branch (never the default branch), so the branch-guard hook never fires inside it. Create the worktree's branch off `origin/main`, then work. - **No AI attribution** in commits or PR bodies. - **Never `git stash`** — commit WIP instead (a worktree's whole point is that you never need to stash to switch context). - **Rebase by default** when pulling `origin/main` into a feature branch; sync before any history-altering command. - **Follow the repo's PR template** if one exists.
command (3)
commands/worktree-start.md
--- description: Create a new git worktree for feature work with gitignore verification and project setup allowed-tools: Bash, Read, Edit, Write --- # /worktree-start - Start a New Worktree Creates an isolated git worktree so feature work does not disturb the main checkout. This is the **hands-on** single-worktree creator (you, working a branch directly). For **parallel sub-agent delegation**, you usually do not run this by hand — the delegating command (`/etp`, `/mawf`, `/xplan`) or the Agent/Workflow `isolation: "worktree"` option creates one worktree per unit automatically, and removes it when the unit merges. Worktrees are the default isolation for that; see `git-worktrees.md`. Reach for a permanent clone only for long-lived independent agents, per-branch dev-server ports, per-branch `tracking.csv`, or cross-machine dispatch. Whichever path creates the worktree, its lifecycle ends the same way: removed when its PR merges, with `/worktree-sweep` as the orphan backstop. ## Usage ``` /worktree-start <branch-name> [base-branch] ``` - `branch-name` (required): the new branch to create in the worktree - `base-branch` (optional, default: `origin/main`): branch to fork from ## Workflow ### Phase 1: Pre-Flight Checks 1. **Confirm repo root**: run `git rev-parse --show-toplevel`. If this fails, stop and report: "not inside a git repository." 2. **Fetch latest**: `git fetch origin`. Do this every time - never assume the local `origin/main` ref is current. 3. **Check for existing worktree on this branch**: `git worktree list --porcelain | grep -A2 "branch refs/heads/<branch-name>"`. If one exists, show its path and ask the user whether to reuse it, pick a different name, or remove the existing worktree first. 4. **Check branch uniqueness**: `git branch --list <branch-name>`. If the branch already exists and the user did not pass `[existing-branch]` as the base, ask whether they meant to check out the existing branch in a worktree. 5. **Verify the worktree parent is gitignored**: the canonical location is `.claude/worktrees/`. Check `git check-ignore .claude/worktrees` — if it exits 0, the path is already ignored (the harness usually gitignores `.claude/`) and there is nothing to do. If not, add it: ```bash echo ".claude/worktrees/" >> .gitignore git add .gitignore git commit -m "chore: ignore .claude/worktrees directory" ``` Do this BEFORE creating the worktree. Creating a worktree under a non-gitignored path risks committing the entire worktree back into the repo on a careless `git add .`. ### Phase 2: Create the Worktree Choose the directory: - Canonical: `<repo-root>/.claude/worktrees/<branch-name>/` — the same tree the harness's `isolation: "worktree"` uses, so `/worktree-sweep` reclaims everything from one place. - Fallback if `.claude/worktrees/` gitignore cannot be added: `~/code/worktrees/<repo-name>-<branch-name>/` (create this directory first, mkdir -p). Create the worktree: ```bash # New branch off base-branch (default: origin/main) git worktree add -b <branch-name> .claude/worktrees/<branch-name> <base-branch> ``` If the branch already exists and the user confirmed reuse: ```bash git worktree add .claude/worktrees/<branch-name> <branch-name> ``` ### Phase 3: Project Setup (Auto-Detect) Inside the new worktree, detect project type and run the appropriate install / build command. Check for these files in order and run the matching command: | File present | Command | |--------------|---------| | `pnpm-lock.yaml` | `pnpm install` | | `yarn.lock` | `yarn install` | | `package-lock.json` | `npm install` | | `package.json` (no lockfile) | `npm install` | | `Cargo.toml` | `cargo build` | | `requirements.txt` | `pip install -r requirements.txt` | | `Gemfile` | `bundle install` | | `go.mod` | `go mod download` | Run only the FIRST match. If the project uses a lockfile, respect it. If the install fails, stop and report the error - do not proceed to baseline checks. ### Phase 4: Copy Non-Tracked Local Config Worktrees do not inherit `.env` or other gitignored local config. Offer to copy them from the main checkout. Locate the main checkout root robustly (do not hard-code `../../` — the depth depends on the worktree path): ```bash ROOT="$(git rev-parse --git-common-dir)/.." # main checkout root, from anywhere inside the worktree cp "$ROOT/.env" .env 2>/dev/null || true cp "$ROOT/.env.local" .env.local 2>/dev/null || true ``` Only copy files that are gitignored. Never copy a file that could be committed back. ### Phase 5: Baseline Test Run Run the project's test command once to confirm the baseline is green before feature work starts. Auto-detect: | File present | Test command | |--------------|--------------| | `package.json` with `"test"` script | `pnpm test` / `npm test` (match the install command) | | `Cargo.toml` | `cargo test` | | `pytest.ini` / `pyproject.toml` with pytest | `pytest` | | `Gemfile` with rspec | `bundle exec rspec` | | `go.mod` | `go test ./...` | If the baseline test run fails: - Do NOT start feature work - Report the failure to the user - Ask whether to proceed anyway (accepting that later failures will be hard to attribute) or investigate If the project has no test command, skip this phase and note it in the report. ### Phase 6: Report Report to the user: - Worktree path: absolute path - Branch name - Base branch (ref and SHA) - Install / build status - Baseline test status (pass / fail / skipped) - `cd` command to enter the worktree Do not claim success if any phase failed. ## Safety Notes - Never run `rm -rf` on a worktree directory directly - use `git worktree remove` - Never `mv` a worktree - use `git worktree move` - Never create worktrees inside other worktrees - they cannot share a parent - A locked worktree (`.git/worktrees/<name>/locked` present) requires `git worktree unlock` before removal
commands/worktree-finish.md
---
description: Finish a git worktree feature branch with a four-option gate (merge locally / push+PR / keep / discard)
allowed-tools: Bash, Read
---
# /worktree-finish - Finish a Worktree
Ends feature work in a worktree with an explicit four-option gate. Never silently merges, pushes, or discards - the user picks the outcome, and destructive options require typed confirmation.
## Usage
```
/worktree-finish [worktree-path]
```
- `worktree-path` (optional): path to the worktree. Defaults to the current directory if it is a worktree, otherwise asks.
## Workflow
### Phase 1: Detect Context
1. If `worktree-path` was passed, use it. Otherwise, run `git rev-parse --show-toplevel` to find the repo root and check `git worktree list` - if the current directory is a worktree, use it.
2. Verify this IS a worktree, not the main checkout:
```bash
git rev-parse --git-common-dir # .git of the main repo
git rev-parse --git-dir # .git-worktrees/<name> of this worktree
```
If they are the same path, this is the main checkout - stop and report: "this is the main checkout, not a worktree; /worktree-finish is only for worktrees."
3. Gather state:
- Current branch: `git rev-parse --abbrev-ref HEAD`
- Upstream: `git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null`
- Uncommitted changes: `git status --porcelain`
- Unpushed commits: `git log @{u}..HEAD --oneline 2>/dev/null`
- Merge base with `origin/main`: `git merge-base HEAD origin/main`
- Commits ahead of main: `git log origin/main..HEAD --oneline`
### Phase 2: Preflight Warnings
Before showing options, surface any state the user should know about:
- Uncommitted changes: list them. Warn that options 1, 2, and 4 below require a clean tree or an intentional WIP commit.
- Unpushed commits: list count and titles.
- Branch not based on latest main: if `git merge-base HEAD origin/main` is not the tip of `origin/main`, suggest a rebase before merging or opening a PR.
### Phase 3: Present Four Options
Print this prompt to the user VERBATIM and wait for a numeric reply:
```
Finishing worktree: <path>
Branch: <branch>
Commits ahead of main: <N>
Uncommitted changes: <yes/no>
Choose an action:
1) Merge locally into main (squash, then remove worktree)
2) Push branch and open a PR (worktree stays until PR is merged)
3) Keep the worktree as is (no action, just exit)
4) Discard the worktree and branch (DESTRUCTIVE, requires typed confirmation)
Enter 1, 2, 3, or 4:
```
Do NOT pick an option on the user's behalf. If the reply is not `1`, `2`, `3`, or `4`, re-prompt.
### Phase 4: Execute the Chosen Option
#### Option 1: Merge Locally
1. Require a clean tree. If `git status --porcelain` is non-empty, stop and ask the user to commit or discard the changes first.
2. `cd` to the main checkout: `cd $(git rev-parse --git-common-dir)/..`
3. `git checkout main && git pull --ff-only origin main`
4. `git merge --squash <branch>`
5. Show the staged diff. Ask the user to confirm the squash commit message.
6. `git commit -m "<message>"`
7. Push: ask first. If confirmed, `git push origin main`.
8. Tear down the worktree and its branch in one step: `bash ~/.claude/lib/worktree-sweep.sh --worktree <path>`. This removes the worktree, prunes the metadata, and deletes the branch after verifying the squash merge absorbed it. Do **not** hand-roll `git branch -D` — it is denied and hard-blocked, and chaining it onto `git worktree remove` kills the removal too (issue #907).
#### Option 2: Push and Open PR
1. Require a clean tree (same check as option 1).
2. Check that `origin/main` is current: `git fetch origin`. If behind, offer to rebase first.
3. Push: `git push -u origin <branch>`
4. Create PR: `gh pr create` with title and body. Use the repo's PR template if one exists (check `.github/pull_request_template.md`, `.github/PULL_REQUEST_TEMPLATE.md`, and repo root). Do not add AI attribution footers.
5. Report the PR URL.
6. Leave the worktree in place until the PR merges — then remove it. Per the worktree lifecycle (`git-worktrees.md`), teardown is mandatory, not optional: run `/worktree-finish` again after the merge, or `git worktree remove <path>`. `/worktree-sweep` is the backstop that reclaims this worktree if it is ever forgotten.
#### Option 3: Keep
No-op. Report:
```
Keeping worktree: <path>
Branch: <branch>
(Run /worktree-finish again when you are ready to merge, push, or discard.)
```
#### Option 4: Discard (Typed Confirmation Required)
1. Show what will be lost:
- Branch and SHA
- List of commits that exist only on this branch (`git log origin/main..HEAD --oneline`)
- List of uncommitted changes (`git status --porcelain`)
2. Ask the user to type the EXACT branch name to confirm. Example:
```
To confirm discard, type the branch name: <branch>
```
3. If the typed reply does not match the branch name exactly, abort and report: "Discard cancelled - typed name did not match."
4. If it matches:
- `git worktree remove --force <path>`
- `ALLOW_BRANCH_FORCE_DELETE=1 git branch -D <branch>` — discard is the one case where throwing commits away is the point, so it uses the escape hatch rather than the sweep (which by design refuses to delete an unmerged branch). The typed confirmation in step 2 is what authorizes it.
- If the branch was pushed, ask separately whether to delete the remote: `git push origin --delete <branch>`. This is a second typed confirmation.
5. Report: worktree and branch discarded.
### Phase 5: Final Report
Always report:
- Option chosen
- Actions taken (commits, pushes, removals)
- Any warnings the user should follow up on (e.g., unpushed commits when keeping, stale remote branches)
## Safety Notes
- Option 4 is the only destructive action and requires typed confirmation. Do not shortcut it.
- Never force-push to `main` from any option.
- Never `rm -rf` a worktree - always use `git worktree remove` (with `--force` only for option 4).
- If any phase fails, stop and report. Do not fall through to a different option silently.
`/worktree-finish` handles **one** worktree at a time, interactively. To clean up **many** leaked worktrees at once (the orphan-sweep backstop in the lifecycle), use `/worktree-sweep` — it never forces and only removes verified-clean worktrees.
commands/worktree-sweep.md
---
description: Repo-wide safe worktree janitor - remove clean worktrees, preserve anything with unsaved work, prune stale metadata
allowed-tools: Bash, Read
---
# /worktree-sweep - Sweep Orphaned Worktrees
The backstop that keeps worktree isolation from silently filling the disk. It enumerates every worktree of the current repo, removes the **clean** ones with a non-force `git worktree remove`, preserves anything with unsaved work, prunes already-gone entries, and reports what it did. It is the orphan-sweep step of the worktree lifecycle (`git-worktrees.md`) — run it after a delegation run, or any time `git worktree list` looks crowded.
It exists because the harness's `isolation: "worktree"` auto-removes a worktree **only if it is unchanged** — a worktree an agent built in is "changed" and lingers forever. On 2026-07-13 that left 33 stale worktrees consuming ~237 GB on one repo. This command reclaims them safely.
## Usage
```
/worktree-sweep [--dry-run] [--conservative] [--all]
[--worktree <path>] [--keep-branches] [--merged-branches]
```
- **(no flags)** — report + remove every clean worktree in the managed locations, and delete each removed worktree's branch if the default branch already contains its work. Safe by construction: a clean worktree's commits survive on its branch ref after removal, and a branch is only deleted once those commits are reachable from the default branch anyway.
- **`--dry-run`** — classify and show what *would* happen; change nothing. Run this first if you want a preview.
- **`--conservative`** — additionally preserve clean worktrees whose branch has commits not yet on the origin default branch (keep in-progress-but-committed checkouts around). Removes only fully-merged, detached, or behind worktrees.
- **`--all`** — also sweep clean worktrees outside the two managed locations (`.claude/worktrees/`, `.worktrees/`). Off by default so a deliberately-placed worktree elsewhere is never touched.
- **`--worktree <path>`** — scope the whole sweep to one worktree. This is the per-unit teardown of lifecycle step 4: remove it, prune, delete its branch if absorbed. Refuses a path that is not a worktree of this repo, and refuses the main checkout.
- **`--keep-branches`** — never delete branches; only remove worktrees.
- **`--merged-branches`** — also delete local branches that have no worktree at all and whose work the default branch already contains. This is the recovery path when a worktree was removed by hand earlier and left its branch behind.
## Branch Cleanup
The sweep deletes a branch only after verifying the default branch already contains its work, two ways:
- the branch is an **ancestor** of the default branch (a normal merge), or
- replaying the branch's **tree** on the merge base is patch-equivalent to something already upstream (a **squash merge**).
The second test is what makes this useful. This repo family squash-merges, and a squash merge rewrites the branch's commits — so `git branch -d` refuses the branch as "not fully merged", and `git branch -D` is denied. Without this verification step there is no permitted way to finish a teardown after a squash merge, which is how worktrees kept getting stranded on disk (issue #907).
A branch that passes neither test is **kept**, and the report says so and prints the `git worktree add` line that restores its checkout. Nothing unmerged is ever discarded.
## What It Does
Run the installed janitor and present its report to the user:
```bash
bash ~/.claude/lib/worktree-sweep.sh $ARGUMENTS
```
(When running from a CCGM checkout instead of an install, the script is at `modules/git-worktrees/lib/worktree-sweep.sh`.)
The script classifies each worktree (never the main checkout, never the one you are standing in):
| Classification | Action |
|----------------|--------|
| Uncommitted tracked changes, or untracked non-ignored files | **PRESERVE** |
| In-progress rebase / merge / cherry-pick / revert / bisect | **PRESERVE** |
| Detached HEAD carrying commits reachable from no ref | **PRESERVE** (removal would orphan them) |
| Locked | **PRESERVE** (report; run `git worktree unlock` if intended) |
| Clean, in a managed location | **REMOVE** (non-force) |
| Clean, outside managed locations | **SKIP** (unless `--all`) |
| Directory already gone (prunable) | **PRUNE** metadata, then clean up its leftover branch |
It **never uses `--force`.** A non-force `git worktree remove` is itself a safety gate — git refuses on any modified-or-untracked worktree — so even if the classification missed something, git will not let the sweep destroy unsaved work. A gitignored build artifact (`.build/`, `target/`) does not block a clean removal.
## After the Sweep
Report the summary the script prints: how many worktrees were removed (and disk reclaimed), how many were preserved and why, how many branches were deleted, and how many prunable entries were cleaned. For any removed worktree whose branch had commits not on the default branch, the report includes the exact `git worktree add ...` command to restore the checkout — that branch and its commits were never deleted.
## Related
- `git-worktrees.md` — the worktree lifecycle, removal-safety rule, and honest economics.
- `/worktree-finish` — finish **one** worktree interactively (merge / PR / keep / discard). Use `/worktree-sweep` for the many-at-once orphan cleanup.
lib (1)
lib/worktree-sweep.sh
#!/usr/bin/env bash
# worktree-sweep.sh - repo-wide safe worktree janitor.
#
# Enumerates every worktree of the current repo, classifies each as CLEAN
# (safe to remove) or PRESERVE (unsaved work / in-progress operation / locked),
# removes ONLY the clean ones with a NON-FORCE `git worktree remove`, prunes
# stale metadata, and prints a report. It NEVER forces, so git's own refusal on
# a modified-or-untracked worktree is a second safety gate on top of the
# classification. See modules/git-worktrees/rules/git-worktrees.md.
#
# KEY SAFETY FACT: removing a clean ON-BRANCH worktree never loses committed
# work - the branch ref stays in the parent .git; only the working-tree checkout
# (and its build artifacts) are removed. Re-create it later with
# `git worktree add <path> <branch>`. A DETACHED worktree has no branch ref, so
# if it carries commits reachable from no other ref, removal would orphan them -
# those are preserved, not removed.
#
# By default it only touches worktrees under the two managed locations,
# `.claude/worktrees/` (harness `isolation:"worktree"` default) and `.worktrees/`
# (legacy module location), plus prunable entries whose directory is already
# gone. Worktrees elsewhere are reported but left alone unless --all is given.
#
# BRANCH CLEANUP: after removing a worktree, its branch is deleted IFF the default
# branch already contains the branch's work - either a normal merge (the branch is
# an ancestor) or a SQUASH merge (the branch's tree, replayed on the merge base,
# is patch-equivalent to something already upstream). A branch that fails both
# tests is kept and reported, so no unmerged commit is ever discarded. This is the
# permitted path that `git branch -D` is not: agents cannot run `git branch -D`
# (it is denied, and hard-blocked by auto-approve-bash.py), and `git branch -d`
# refuses squash-merged branches, so without this there is no way to finish a
# teardown after a squash merge (GitHub issue #907).
#
# Usage: worktree-sweep.sh [--dry-run|-n] [--conservative] [--all]
# [--worktree <path>] [--keep-branches] [--merged-branches]
# [-h|--help]
# --dry-run Report the classification and planned actions; change nothing.
# --conservative Also PRESERVE clean worktrees whose branch has commits not on
# the origin default branch (keep in-progress-but-committed
# checkouts around). Default removes clean worktrees regardless,
# since their commits survive on the branch ref.
# --all Also sweep clean worktrees outside the two managed locations.
# --worktree <path> Scope the sweep to ONE worktree - the per-unit teardown for
# lifecycle step 4. Implies --all for that path.
# --keep-branches Never delete branches; only remove worktrees (pre-#907 behavior).
# --merged-branches Also delete local branches with no worktree at all whose work
# the default branch already contains. Recovers the leftover
# branch when a worktree was removed by hand earlier.
# -h, --help Print this header.
set -u
MODE="apply"
CONSERVATIVE=0
ALL=0
KEEP_BRANCHES=0
MERGED_BRANCHES=0
ONLY_WORKTREE=""
while [ $# -gt 0 ]; do
case "$1" in
--dry-run|-n) MODE="dry-run" ;;
--conservative) CONSERVATIVE=1 ;;
--all) ALL=1 ;;
--keep-branches) KEEP_BRANCHES=1 ;;
--merged-branches) MERGED_BRANCHES=1 ;;
--worktree)
shift
[ $# -gt 0 ] || { echo "worktree-sweep: --worktree needs a path" >&2; exit 2; }
ONLY_WORKTREE="$1"; ALL=1 ;;
--worktree=*)
ONLY_WORKTREE="${1#--worktree=}"; ALL=1 ;;
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "worktree-sweep: unknown arg: $1" >&2; exit 2 ;;
esac
shift
done
if ! git rev-parse --show-toplevel >/dev/null 2>&1; then
echo "worktree-sweep: not inside a git repository" >&2
exit 2
fi
CWD_WT="$(git rev-parse --show-toplevel)"
# Best-effort default-branch ref, for the unmerged-commit note in the report.
DEFAULT_REF=""
if git show-ref --verify --quiet refs/remotes/origin/HEAD 2>/dev/null; then
DEFAULT_REF="$(git rev-parse --abbrev-ref origin/HEAD 2>/dev/null)"
fi
if [ -z "$DEFAULT_REF" ] && git show-ref --verify --quiet refs/remotes/origin/main 2>/dev/null; then
DEFAULT_REF="origin/main"
fi
if [ -z "$DEFAULT_REF" ] && git show-ref --verify --quiet refs/remotes/origin/master 2>/dev/null; then
DEFAULT_REF="origin/master"
fi
# Local fallback for a repo with no origin, so --conservative still works there.
if [ -z "$DEFAULT_REF" ] && git show-ref --verify --quiet refs/heads/main 2>/dev/null; then
DEFAULT_REF="main"
fi
if [ -z "$DEFAULT_REF" ] && git show-ref --verify --quiet refs/heads/master 2>/dev/null; then
DEFAULT_REF="master"
fi
# The main checkout (parent of the shared .git dir) must never be swept, no
# matter which worktree we run from. Empty for a bare repo (it has no checkout).
MAIN_CHECKOUT=""
_common_dir="$(git rev-parse --git-common-dir 2>/dev/null)"
if [ -n "$_common_dir" ] && [ "$(git rev-parse --is-bare-repository 2>/dev/null)" != "true" ]; then
# pwd -P resolves symlinks so this matches git's canonical porcelain paths
# (e.g. macOS /var -> /private/var), so the comparison in process_record holds.
MAIN_CHECKOUT="$(cd "$_common_dir/.." 2>/dev/null && pwd -P)"
fi
# Short name of the default branch, so it is never a deletion candidate.
DEFAULT_BRANCH="${DEFAULT_REF#origin/}"
# Resolve --worktree to the same canonical form git reports, so the comparison
# in process_record holds (pwd -P collapses macOS /var -> /private/var).
if [ -n "$ONLY_WORKTREE" ]; then
_resolved="$(cd "$ONLY_WORKTREE" 2>/dev/null && pwd -P)"
if [ -z "$_resolved" ]; then
echo "worktree-sweep: --worktree path does not exist: $ONLY_WORKTREE" >&2
exit 2
fi
ONLY_WORKTREE="$_resolved"
if ! git worktree list --porcelain | grep -qxF "worktree $ONLY_WORKTREE"; then
echo "worktree-sweep: not a worktree of this repository: $ONLY_WORKTREE" >&2
exit 2
fi
# The main checkout is listed like any other worktree but can never be torn
# down. Refuse loudly rather than exiting 0 having done nothing.
if [ -n "$MAIN_CHECKOUT" ] && [ "$ONLY_WORKTREE" = "$MAIN_CHECKOUT" ]; then
echo "worktree-sweep: refusing to sweep the main checkout: $ONLY_WORKTREE" >&2
exit 2
fi
fi
is_managed() {
# True if the path lives under a managed worktree dir.
case "$1" in
*/.claude/worktrees/*|*/.worktrees/*) return 0 ;;
*) return 1 ;;
esac
}
branch_is_absorbed() {
# True if the default branch already contains this branch's work, by EITHER
# a normal merge or a squash merge. False on any error, so an unverifiable
# branch is always kept.
#
# Squash detection: a squash merge rewrites the branch's commits into one new
# commit, so the originals are not ancestors and their patch-ids do not match.
# Replaying the branch's TREE as a single synthetic commit on the merge base
# reproduces exactly what the squash produced, and `git cherry` then reports
# it as already upstream ("-"). GIT_* identity is set explicitly because
# `git commit-tree` fails with "empty ident name not allowed" on a machine or
# CI runner with no configured git identity.
local b="$1" base tree syn
[ -n "$DEFAULT_REF" ] || return 1
[ "$b" != "$DEFAULT_BRANCH" ] || return 1
git show-ref --verify --quiet "refs/heads/$b" 2>/dev/null || return 1
git merge-base --is-ancestor "refs/heads/$b" "$DEFAULT_REF" 2>/dev/null && return 0
base="$(git merge-base "$DEFAULT_REF" "refs/heads/$b" 2>/dev/null)" || return 1
[ -n "$base" ] || return 1
tree="$(git rev-parse "refs/heads/$b^{tree}" 2>/dev/null)" || return 1
syn="$(GIT_AUTHOR_NAME=worktree-sweep GIT_AUTHOR_EMAIL=worktree-sweep@localhost \
GIT_COMMITTER_NAME=worktree-sweep GIT_COMMITTER_EMAIL=worktree-sweep@localhost \
git commit-tree "$tree" -p "$base" -m _ 2>/dev/null)" || return 1
[ -n "$syn" ] || return 1
case "$(git cherry "$DEFAULT_REF" "$syn" 2>/dev/null)" in
"-"*) return 0 ;;
esac
return 1
}
BRANCHES_DELETED=0
BRANCH_NOTE=""
BRANCH_LINES=""
# Snapshot the worktree table BEFORE pruning, so already-gone entries are still
# in it and their leftover branches can be cleaned. Then drop the stale metadata:
# git refuses to delete a branch a registered worktree still claims, so the claim
# has to go first. Pruning only ever touches entries whose directory is gone.
WT_LIST="$(git worktree list --porcelain)"
[ "$MODE" = "apply" ] && git worktree prune 2>/dev/null
cleanup_branch() {
# Delete $1 if absorbed, else keep it. Sets BRANCH_NOTE to a report suffix.
# Deliberately assigns a global instead of printing: a $(...) call site would
# run this in a subshell and lose the BRANCHES_DELETED increment.
local b="$1"
BRANCH_NOTE=""
[ -n "$b" ] || return 0
if [ "$KEEP_BRANCHES" = "1" ]; then
BRANCH_NOTE=" (branch $b kept: --keep-branches)"; return 0
fi
if ! branch_is_absorbed "$b"; then
BRANCH_NOTE=" (branch $b KEPT: work not on ${DEFAULT_REF:-<unknown default>} - nothing discarded)"
return 0
fi
if [ "$MODE" = "dry-run" ]; then
BRANCH_NOTE=" (branch $b WOULD BE DELETED: already on $DEFAULT_REF)"
BRANCHES_DELETED=$((BRANCHES_DELETED+1)); return 0
fi
if git branch -D "$b" >/dev/null 2>&1; then
BRANCH_NOTE=" (branch $b deleted: already on $DEFAULT_REF)"
BRANCHES_DELETED=$((BRANCHES_DELETED+1))
else
BRANCH_NOTE=" (branch $b kept: git refused the delete)"
fi
}
in_progress_op() {
# True if the worktree has a paused rebase/merge/cherry-pick/revert/bisect.
wt="$1"
for m in rebase-merge rebase-apply MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD BISECT_LOG; do
mp="$(git -C "$wt" rev-parse --git-path "$m" 2>/dev/null)"
if [ -n "$mp" ] && [ -e "$mp" ]; then return 0; fi
done
return 1
}
REMOVED=0
RECLAIMED_KB=0
declare_preserved() { PRESERVED_LINES="${PRESERVED_LINES}$1"$'\n'; PRESERVED=$((PRESERVED+1)); }
PRESERVED=0
PRESERVED_LINES=""
REMOVED_LINES=""
SKIPPED=0
SKIPPED_LINES=""
PRUNABLE=0
# Walk `git worktree list --porcelain`. Records are blank-line separated.
CUR_PATH=""
CUR_BRANCH=""
CUR_DETACHED=0
CUR_LOCKED=0
CUR_PRUNABLE=0
CUR_BARE=0
process_record() {
[ -z "$CUR_PATH" ] && return 0
[ "$CUR_BARE" = "1" ] && return 0 # the bare repo itself is not a checkout
if [ -n "$MAIN_CHECKOUT" ] && [ "$CUR_PATH" = "$MAIN_CHECKOUT" ]; then return 0; fi # main checkout
# --worktree scopes the whole sweep to one unit of work.
if [ -n "$ONLY_WORKTREE" ] && [ "$CUR_PATH" != "$ONLY_WORKTREE" ]; then return 0; fi
local path="$CUR_PATH"
local label="$path"
if [ "$CUR_DETACHED" = "1" ]; then label="$label (detached)"; else label="$label [$CUR_BRANCH]"; fi
# Already-gone directory: git prunable will drop the metadata. Its branch is
# still a teardown leftover, so it gets the same verified cleanup.
if [ "$CUR_PRUNABLE" = "1" ] || [ ! -d "$path" ]; then
PRUNABLE=$((PRUNABLE+1))
if [ "$CUR_DETACHED" != "1" ] && [ -n "$CUR_BRANCH" ]; then
cleanup_branch "$CUR_BRANCH"
[ -n "$BRANCH_NOTE" ] && BRANCH_LINES="${BRANCH_LINES} - ${CUR_BRANCH} (worktree already gone)${BRANCH_NOTE}"$'\n'
fi
return 0
fi
# Never touch the worktree we are standing in.
if [ "$path" = "$CWD_WT" ]; then
SKIPPED_LINES="${SKIPPED_LINES} - $label -> current worktree (skipped)"$'\n'; SKIPPED=$((SKIPPED+1)); return 0
fi
# Preserve: locked.
if [ "$CUR_LOCKED" = "1" ]; then
declare_preserved " - $label -> PRESERVE (locked; run 'git worktree unlock' if intended)"; return 0
fi
# Preserve: in-progress operation.
if in_progress_op "$path"; then
declare_preserved " - $label -> PRESERVE (in-progress rebase/merge/cherry-pick)"; return 0
fi
# Preserve: uncommitted tracked changes or untracked non-ignored files.
if [ -n "$(git -C "$path" status --porcelain 2>/dev/null)" ]; then
declare_preserved " - $label -> PRESERVE (uncommitted or untracked changes)"; return 0
fi
# Preserve: a detached-HEAD worktree whose commit is reachable from NO ref.
# A branch worktree's commits survive removal on the branch ref, but a detached
# HEAD has no ref - removing it orphans any commits made on top (gc-eligible).
# If some branch/tag/remote already contains the tip (the common "parked at an
# existing commit" case), removal is safe and we fall through.
if [ "$CUR_DETACHED" = "1" ]; then
local head_sha reached
head_sha="$(git -C "$path" rev-parse HEAD 2>/dev/null)"
reached="$(git -C "$path" for-each-ref --contains "$head_sha" --format='%(refname)' 2>/dev/null | head -1)"
if [ -z "$reached" ]; then
declare_preserved " - $label -> PRESERVE (detached HEAD; commits reachable from no ref would be orphaned)"; return 0
fi
fi
# From here the worktree is CLEAN and its committed work survives removal.
local unmerged=""
if [ -n "$DEFAULT_REF" ] && [ "$CUR_DETACHED" != "1" ]; then
local n
n="$(git -C "$path" rev-list --count "${DEFAULT_REF}..HEAD" 2>/dev/null || echo 0)"
[ "$n" -gt 0 ] 2>/dev/null && unmerged="$n"
fi
# Conservative mode preserves clean-but-unmerged branch checkouts.
if [ "$CONSERVATIVE" = "1" ] && [ -n "$unmerged" ]; then
declare_preserved " - $label -> PRESERVE (--conservative: $unmerged commit(s) not on $DEFAULT_REF)"; return 0
fi
# Only sweep managed locations unless --all.
if [ "$ALL" != "1" ] && ! is_managed "$path"; then
SKIPPED_LINES="${SKIPPED_LINES} - $label -> clean, outside managed dirs (skipped; --all to include)"$'\n'; SKIPPED=$((SKIPPED+1)); return 0
fi
local kb; kb="$(du -sk "$path" 2>/dev/null | awk '{print $1}')"; [ -z "$kb" ] && kb=0
if [ "$MODE" = "dry-run" ]; then
branch_note_for "$path"
REMOVED_LINES="${REMOVED_LINES} - $label -> WOULD REMOVE, ~$((kb/1024)) MB${BRANCH_NOTE}"$'\n'
REMOVED=$((REMOVED+1)); RECLAIMED_KB=$((RECLAIMED_KB+kb)); return 0
fi
if git worktree remove "$path" 2>/dev/null; then
# Branch cleanup runs only AFTER the checkout is gone: git refuses to delete
# a branch that a live worktree has checked out.
branch_note_for "$path"
REMOVED_LINES="${REMOVED_LINES} - $label -> removed, ~$((kb/1024)) MB${BRANCH_NOTE}"$'\n'
REMOVED=$((REMOVED+1)); RECLAIMED_KB=$((RECLAIMED_KB+kb))
else
# Non-force refused: git found something unsafe the checks missed. Never force.
declare_preserved " - $label -> PRESERVE (git refused non-force removal)"
fi
}
branch_note_for() {
# Resolve the branch decision for the record being processed, appending the
# restore hint whenever the branch survives. $1 is the worktree path (for the
# hint only).
BRANCH_NOTE=""
[ "$CUR_DETACHED" = "1" ] && return 0 # detached: no branch to clean up
[ -n "$CUR_BRANCH" ] || return 0
cleanup_branch "$CUR_BRANCH"
case "$BRANCH_NOTE" in
*"deleted:"*|*"WOULD BE DELETED"*) ;;
"") ;;
*) BRANCH_NOTE="${BRANCH_NOTE%)}; restore with 'git worktree add $1 $CUR_BRANCH')" ;;
esac
}
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
"worktree "*) process_record; CUR_PATH="${line#worktree }"; CUR_BRANCH=""; CUR_DETACHED=0; CUR_LOCKED=0; CUR_PRUNABLE=0; CUR_BARE=0 ;;
"branch "*) CUR_BRANCH="${line#branch refs/heads/}" ;;
"detached") CUR_DETACHED=1 ;;
"locked"*) CUR_LOCKED=1 ;;
"prunable"*) CUR_PRUNABLE=1 ;;
"bare") CUR_BARE=1 ;; # the bare repo entry has no checkout; skip it
esac
done <<EOF
${WT_LIST}
EOF
process_record # flush the final record
# --merged-branches: leftover branches with no worktree at all. This is the
# recovery path when a worktree was removed by hand earlier, leaving a branch
# `git branch -d` will not delete because the PR was squash-merged. Only
# branches the default branch already contains are touched, and never one that
# is checked out anywhere.
if [ "$MERGED_BRANCHES" = "1" ] && [ "$KEEP_BRANCHES" != "1" ] && [ -n "$DEFAULT_REF" ]; then
CHECKED_OUT="$(git worktree list --porcelain | sed -n 's/^branch refs\/heads\///p')"
while IFS= read -r b; do
[ -n "$b" ] || continue
[ "$b" = "$DEFAULT_BRANCH" ] && continue
printf '%s\n' "$CHECKED_OUT" | grep -qxF "$b" && continue
branch_is_absorbed "$b" || continue
if [ "$MODE" = "dry-run" ]; then
BRANCH_LINES="${BRANCH_LINES} - $b (no worktree) WOULD BE DELETED: already on $DEFAULT_REF"$'\n'
BRANCHES_DELETED=$((BRANCHES_DELETED+1))
elif git branch -D "$b" >/dev/null 2>&1; then
BRANCH_LINES="${BRANCH_LINES} - $b (no worktree) deleted: already on $DEFAULT_REF"$'\n'
BRANCHES_DELETED=$((BRANCHES_DELETED+1))
fi
done <<EOF
$(git for-each-ref --format='%(refname:short)' refs/heads)
EOF
fi
# Drop administrative state for worktrees whose directories are gone or removed.
if [ "$MODE" = "dry-run" ]; then
echo "=== worktree-sweep (DRY RUN - nothing removed) ==="
else
git worktree prune 2>/dev/null
echo "=== worktree-sweep ==="
fi
[ -n "$DEFAULT_REF" ] && echo "default branch: $DEFAULT_REF"
echo ""
if [ "$REMOVED" -gt 0 ]; then
echo "REMOVED ($REMOVED, ~$((RECLAIMED_KB/1024)) MB reclaimed):"
printf "%s" "$REMOVED_LINES"
echo ""
fi
if [ "$PRESERVED" -gt 0 ]; then
echo "PRESERVED ($PRESERVED, unsaved work / in-progress / locked):"
printf "%s" "$PRESERVED_LINES"
echo ""
fi
if [ "$SKIPPED" -gt 0 ]; then
echo "SKIPPED ($SKIPPED):"
printf "%s" "$SKIPPED_LINES"
echo ""
fi
if [ -n "$BRANCH_LINES" ]; then
echo "BRANCHES (no live worktree):"
printf "%s" "$BRANCH_LINES"
echo ""
fi
[ "$PRUNABLE" -gt 0 ] && echo "PRUNED $PRUNABLE worktree(s) whose directory was already gone."
echo "Done. $REMOVED removed, $PRESERVED preserved, $SKIPPED skipped, $BRANCHES_DELETED branch(es) deleted."