---
title: Worktree Cleanup Skill (reap landed worktrees, safely and reversibly)
---

# Worktree Cleanup Skill

## What it is

A Claude Code skill (`/worktree-cleanup`) that bulk-cleans the git worktrees left behind by finished work — and, on request, the local branch refs that have no worktree. It fills the gap between "my session ended and the work shipped" and "that worktree is still sitting on disk eating space." It is the companion to `merge-all-ready`, which lands the branches this then reaps. These are the same worktrees an isolated session runs in — see [Session isolation](session-isolation.md).

It decides "has this landed?" by **content**, not by branch name and not by a plain ancestor check. It compares each commit's **patch-id** against the integration branch, so it also catches work that landed by **squash, rebase, cherry-pick, or an auto-lander replay** — landings a `merge-base --is-ancestor` test misses entirely. The built-in scheduled cleanup makes the same call: the vendored reaper classifies a worktree landed by ancestry, by squash-identical content (`merge-tree --write-tree`), or by every commit's patch already being in master (`cherry-clean`), and records `<branch>\t<sha>\t<path>\t<how>` to a recovery ledger plus a patch of any uncommitted changes before it deletes anything. What this skill adds is the **interactive** half — a human-approved plan, the squash-trap symbol check that needs judgment, and the optional deeper passes below — not a stronger detector.

It is **pure git**. GitHub PR status is used as one extra signal when `gh` and a GitHub remote happen to be there, and is never required — the skill works in any repo, with no remote and no `gh` auth.

## Where to find it

It is a skill, not a screen: in any Claude Code session working in the project you type `/worktree-cleanup` or say "clean up worktrees," and the skill does the work through git. Its unattended mode is what the Dev Pipeline's daily maintenance job invokes. The one in-app surface is the **Worktree Cleanup dashboard inside the Dev Pipeline panel**, which surfaces and controls the PowerShell cleanup scripts and carries the **Run cleanup now** button.

## How it behaves

### How to use it

1. In any Claude Code session working in the project, type `/worktree-cleanup` or say "clean up worktrees."
2. The skill discovers every worktree, applies the never-touch guards, classifies the rest by content, and presents a plan with three buckets:
   - **Landed** — provably in the integration branch. Safe to reap.
   - **Keep-viable** — real, unique, unlanded commits (or an open PR). Left alone.
   - **Ambiguous** — can't prove landed, can't prove viable. Kept and reported, never auto-removed.

   A machine auto-save tip is judged by the commit **under** it, not by itself. When a session is
   abandoned, the app saves its uncommitted work by committing
   `chore: auto-save WIP before worktree cleanup` onto the branch tip — and the lander refuses by
   design to land that snapshot, so it can never be an ancestor of the trunk. Asking "is this tip
   landed?" therefore answered **no forever** for a worktree whose real work was entirely on the
   integration branch, and the worktree was reported Keep-viable indefinitely (measured 2026-09-23:
   three worktrees, each reading as one unlanded commit). The floors now ask their own landed
   question about the authored commit the snapshot sits on, so such a worktree lands in **Landed**
   where it belongs. The snapshot itself is still preserved — the lander refuses to land it, and its
   content is archived under a durable ref before any branch is deleted.
3. Review the plan and tell the AI which to remove (all landed ones, specific picks, or none).
4. The skill saves any uncommitted work first, logs a recovery SHA for every branch it deletes, removes the approved worktrees, prunes stale git admin entries, and reports what it did and where the recovery files are.

It also runs **unattended** — see **Modes** below — which is what the Dev Pipeline's daily maintenance job uses.

### Modes

- **Interactive** (the default, a human is present). Classifies everything, presents the plan, and acts only on your approval. This mode gets the full detection — including the squash-trap check, which needs judgment — and it is the only mode that can run the optional deeper passes below.
- **Unattended** (`--auto`). **The AI does not run the reap procedure in this mode — it delegates and stops.** `POST /worktree-cleanup/run-now` (empty body) on the local control server, poll `GET /worktree-cleanup/status` until `lastRunAt` advances, then read `GET /worktree-cleanup/runs` (the rows are under `data.items`) — that IS the report. The route drives the same hardened, vendored PowerShell reaper as the in-app supervisor and the nightly task, with every floor, the reversible retire-to-trash and the recovery SHA. **Read the status rather than assuming 200:** a started pass answers `202` (the route does not wait for it), and a `200` carrying `deferred` means the app is HOLDING the reap because the machine is under load — report "held for load" and stop. A `409` means a pass is already running, `422` means this host cannot run one, `503` means the tool is missing or the spawn failed. Never pass `force` (that override belongs to a person) and never poll the route in a loop.

  **Never fall back to `git worktree remove`, `git branch -D`, or a hand-rolled reap.** That fallback IS the hazard this mode exists to remove: `--auto` is judgment-free by definition — reap only provably-landed, skip everything ambiguous, never wait — so there is nothing for a model to decide and everything for it to get wrong. On 2026-08-26 an improvised unattended reaper deleted 4+ LIVE dev-pipeline worktrees. The procedure further down this page is the **interactive path only**, where a human approves each removal.

  Two reporting rules that go with it: state the count you actually REMOVED, never the count you were going to remove (on 2026-09-02 a run reported 12 on a night it removed 1, because it counted the eligible list), and always state the backlog — how many worktrees are still open and how many are held for uncommitted work, unsaved files or unlanded commits. "Nothing was eligible" on its own hides an unbounded pile.

  The Dev Pipeline's daily maintenance run drives cleanup through exactly those three route calls — its prompt tells it not to open this skill and not to run git — and it never lands anything. See [dev-pipeline-maintenance.md](dev-pipeline-maintenance.md).

An optional `--idle <duration>` (e.g. `--idle 12h`) treats anything touched more recently than the threshold as possibly-live and leaves it alone — unless it is provably landed. The default is no threshold: the skill guards on **state**, not age.

### The safety floor (never violated, in either mode)

- **Never lose committed work.** Before deleting any branch, `<branch>` + its tip SHA are appended to a recovery log, so it can be restored with `git branch <name> <sha>`. A branch is deleted only when its work is provably in the integration branch.
- **Never lose uncommitted work.** Before removing any worktree with uncommitted changes, they are saved to a patch (`git -C <wt> diff HEAD > <recovery>/<slug>.patch`) plus a copy of its non-junk untracked files. Phantom dirt (CRLF / stat-cache noise, `.env`, `node_modules`, build output, a lockfile with no manifest change) is recognised as junk; real source / test / doc changes are always rescued.
- **Never touch live work.** A locked, mid-operation, actively-marked, or main/bare worktree is skipped. When unsure, it keeps.
- **Never touch a worktree a run is parked in.** A worktree whose `.claude/pipeline/state.md` says an unfinished run is live is kept, because a run waiting at a gate is clean and commitless — the dirty check cannot see it, so this file is its only protection. Two things end that hold, and both are things the file says about itself: a finished status, or a finished **phase** (`PHASE_6_GITPREP` / `COMPLETE`, which the pipeline writes before it tags). A file that is **not** a state file at all — an agent's own notes left at that path — is not held; that is the 2026-09-21 fix for a worktree that could otherwise never be cleaned up. A half-written file is still held, always.
- **Reversible by construction.** Worktree removal keeps the branch unless the branch is provably landed; branch deletion is SHA-logged; dirty state is patched first.

`<recovery>` is a directory **outside** the repo — your OS temp dir is fine (`$TMPDIR/worktree-cleanup` on macOS/Linux, `%TEMP%\worktree-cleanup` on Windows). It is created once at the start of a run and its location is named in the final report.

### How it decides what has landed

Strongest signal first, per candidate branch `B` against the integration branch (`main` if it exists, else `master`, else the remote's default; the `origin/<INT>` ref is preferred when a remote exists):

1. **Ancestor** — `git merge-base --is-ancestor B <INT>` exits 0 ⇒ every commit is already on the integration branch. **Landed.** This is the fast-forward / merge-commit case.
2. **Patch-identical** — else `git cherry <INT> B`: if every line is prefixed `-`, each commit's patch-id already exists on the integration branch. **Landed.** This is what catches cherry-pick, rebase, and **replay** landings (an auto-lander that replays a branch's commits under new SHAs). Patch-id equality is strong enough to be trusted unattended.
3. **Squash-trap** (interactive only — needs judgment) — a squash rewrites patch-ids, so `git cherry` shows `+` for work that IS on the integration branch. For a `+` commit the skill pulls a distinctive added symbol / filename / string and looks for it on the integration branch (`git grep -F <token> <INT>` / `git cat-file -e <INT>:<path>`). Present ⇒ **squash-landed**. Genuinely absent and the change is real ⇒ **not landed, keep**. This guess is **never run unattended** — a `+` that isn't proven present stays "not landed."
4. **GitHub PR** (optional extra signal) — if `gh` and a GitHub `origin` exist: a `MERGED` PR ⇒ landed; a `CLOSED` PR with an automated "already on the integration branch" comment ⇒ landed; `OPEN` ⇒ keep. No `gh` or no remote ⇒ this step is skipped. That is expected, not an error.

**The automatic reaper decides with a longer ladder of its own.** The four signals above describe the **interactive** path. The PowerShell reaper that `--auto`, the hourly supervisor and the nightly task all delegate to runs its own ordered checks instead — and its **first** one is not on that list at all:

1. **`landed-as` — on by default.** If the branch description carries a `landed-as:<sha>` marker (provenance the auto-lander itself wrote), the reaper asks only whether that sha is an ancestor of the integration branch, and stops there — every content signal below is skipped. Kill switch: `AMC_DISABLE_LANDED_AS_CHECK=1`. This is auto-lander provenance rather than a branch-name guess, but it is not one of the four content signals above.
2. **Ancestor**, then **cherry-clean** — the ancestry test, then every commit's patch already being on the integration branch.
3. **`squash-clean`** — a squash-identical result, checked with `git merge-tree --write-tree`.
4. **`subject-landed` — opt-in (`-TrustSubjectMatch`).** Declares a branch landed when every commit _subject_ is present on the integration branch. That is exactly the "subject alone" test this page opens by ruling out, which is why it is opt-in and never the default.
5. **Two caller-supplied bypasses.** `-TrustLanded` skips the landed test altogether for worktrees the caller has already content-verified; `-KeepBranch` retires the folder but preserves the branch. Neither is set by the normal flow.

**Guards come before all of this.** A worktree is skipped outright — never removed, in either mode — if it is locked, has an in-progress git op (`MERGE_HEAD` / a rebase or cherry-pick in flight / a live `index.lock`), carries an active status marker in its branch description, is dirty with real uncommitted work, is the main or bare checkout, or was touched more recently than an `--idle` threshold you supplied. The one exception to the idle threshold: provably-landed work is safe to reap regardless of age.

**A lock is not an absolute promise — read this before you rely on one.** The guards above are what _this_ tool honours, but a lock is also cleared by a separate background job that runs on its own (**The stale-lock reaper**, below). So a lock protects a worktree while its owning session is alive — it does not protect it forever after that session ends. **If you want a worktree kept regardless of any staleness rule, pin it.** The pin is the product's own keep marker, and it outranks every staleness rule in this subsystem; nothing described on this page clears it.

### The stale-lock reaper — the background job that clears locks

A lock is the strongest runtime guard the cleanup honours, so the product ships a second, separate job whose only purpose is to clear the ones that have gone stale and are now blocking that guard — and `git worktree prune` along with it. It runs **every 30 minutes**, it is **on by default**, it has no user setting, and it raises no inbox card (the notice it writes is one of the self-heal FYIs, silenced unless you turn on **show self-heal notices**). Its only switch is the `AMC_DISABLE_WORKTREE_LOCK_REAPER` environment variable.

It clears a lock in exactly these cases, each with its own floor:

- a leftover **`initializing`** lock — the provisioning lock a killed or restarted dependency setup never released — once it is **over an hour** old;
- a **`dev-pipeline active run`** lock whose owning session is provably finished, idle for **over six hours**;
- a lock of **any** reason whose worktree is provably owned by a session that has ended, idle **over six hours**;
- a lock whose owner the worktree ledger still records but the session list no longer contains at all, with no claim on the path and nothing uncommitted in the tree, idle **over six hours**.

It never touches a **pin**, the auto-lander's own recovery lock, a lock whose owner is still alive, a lock with no recorded owner and no claiming session, or any tree holding uncommitted work.

### If something went wrong — how to recover

Nothing either path deletes is gone — but **where the undo lives depends on which reaper ran**, so settle that first.

**The automatic reaper** (`--auto`, the in-app supervisor, the nightly task) is the hardened PowerShell scrubber, and it writes its recovery into a directory of its own: `%LOCALAPPDATA%\AMC\wt-cleanup`, falling back to `C:\tmp\wt-cleanup` when there is no `LOCALAPPDATA`. Its ledger of deleted branches is `wt-deleted-recovery.tsv` in that directory. **This is the one that ran if a worktree vanished overnight with nobody in the loop** — look there before concluding the work is gone. `<recovery>` below is _not_ that directory.

**The interactive path** (a human approving each removal) writes its two undo files into `<recovery>` — the temp directory named under _The safety floor_ above:

| What you lost                            | Where it is                                                                         | How to restore                                                                                                                                                      |
| ---------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A deleted branch                         | `<recovery>/reaped-branches.tsv` — one `<branch>\t<tip-SHA>\t<reason>` row per reap | `git branch <name> <sha>` (`git reflog` also still has it)                                                                                                          |
| Uncommitted work from a removed worktree | `<recovery>/<slug>.patch`, plus copies of the non-junk untracked files              | `git apply <recovery>/<slug>.patch` in a fresh worktree on that branch                                                                                              |
| An archived (but not deleted) branch     | still on the branch — only the worktree went                                        | `POST /worktrees/create` with the archived branch as **`baseBranch`** (never as `branch`) — full recipe in [orphan-worktree-archive.md](orphan-worktree-archive.md) |

### Optional deeper passes (interactive, opt-in)

None of these run unless you ask for them:

- **Archive** — reclaim disk from idle **unmerged** worktrees without losing the work: rescue any uncommitted changes, remove the worktree, and **keep the branch**. Restore it any time. **Inside Omniscio that is one call** — `POST http://127.0.0.1:19519/worktrees/create { repo, baseBranch: "<the archived branch>", branchPrefix: "recover", taskTitle: "<what you are doing>" }`, which makes a new folder on a **new branch forked from** the archived one and registers an owner, then provisions its dependencies in the **background** — read `readinessWarning` on the reply before working in the new folder, because a `200` does not mean usable and a `202 status: 'creating'` means it is still being built ([orphan-worktree-archive.md](orphan-worktree-archive.md)); raw `git worktree add <path> <branch>` is the repo-agnostic fallback and leaves the worktree owned by nobody. **The archived branch goes in `baseBranch`, never in `branch`** — `branch` names the branch to _create_, so passing the archived name there is refused `400 branch-exists`, and following that message would put you on a brand-new empty branch and leave the preserved commits behind. The skill verifies each branch survived and reports N / N preserved.
- **Bare branches** — apply the same landed detection to local branch refs that have **no** worktree, and delete the ones that are provably landed (SHA-logged first). Branches checked out by a worktree, and protected ones (`main` / `master` / `release*` / a deliberate keep), are off-limits. A `+` commit that isn't proven present stays a keep.
- **Clear stale guards** — a guard can itself be stale: a status marker from a session that died days ago, or a lock left by a crashed `git worktree add`. For a protected worktree idle well past the threshold, this pass content-verifies it is landed (or is pure scratch), _then_ clears the guard and reaps. It never clears a guard on a worktree with real unmerged work, uncommitted WIP, or an explicit do-not-delete.

### Why a cleanup request can be held

Since 2026-09-06 the "run cleanup now" request that agents send through the control server
(`POST /worktree-cleanup/run-now`) obeys the **same load gate as the hourly cleanup**: while the
computer is busy — the user-harm verdict (or the CPU reading while that verdict is blind), a
saturated worktree drive — the request is **held**, not run. The reply says so plainly:
`200 { started: false, deferred: { reason, gate } }`, where `gate` names what held it (`harm`,
`cpu`, or `volume`), and nothing is spawned. A calm computer answers exactly as before.

- **Your own click always runs.** The **Run cleanup now** button in the Dev Pipeline panel (desktop
  or phone) is a person asking, so it is never held.
- **Only a person can force it from the command line.** `force: true` is honoured only for the
  operator's own token sent from a plain terminal; an agent session that passes `force` gets a
  `403` and nothing runs. An agent that is held should report "held for load" and stop — never
  retry in a loop, never force.
- **Nothing is queued for later.** The hourly cleanup fires on its own once the box is calm, so a
  held manual request needs no follow-up.

### Why a create can be deferred

Cleanup has a twin on the other end of the lifecycle. Since 2026-09-04 a **create-admission
authority** sits in front of every new working folder, and it can hold one back. This is what you
are seeing if a session or an agent reports that a working folder is "waiting its turn".

**Your own new sessions are never held by it.** A create you started — a new session in the app, or
your own shell calling the create API without a session id — skips the budget and the cap entirely.
The single exception is a drive that is physically below its floor, and that message names the
drive, because no amount of waiting fixes a full disk.

**What can be held, and why:**

- **The budget.** Working folders may not be created faster than the box reclaims them, plus a
  small allowance (20/hour by default). This is the limit that did not exist on 2026-09-04, when
  six unattended jobs created ~90 an hour between them against a cleanup path retiring ~50-60, and
  the overflow filled the drive holding the repository itself.
- **The cap.** Live working folders stay under `max(50, 1.5 × running sessions)`, so the ceiling
  rises with a busy fleet instead of throttling it.
- **A drive under its floor.** The only condition that holds _everyone_.

**It tries to fix the problem before it reports it.** When a create is over budget or a drive is
low, the authority asks the landed-retire drain for one bounded batch of cleanup — up to 60
seconds — and re-decides once. Very often that is enough and the create simply proceeds. It never
loops and never waits indefinitely.

**How to see it.** A defer or refuse is never silent:

- the caller gets the reason back, with how long to wait — not a generic error;
- one row lands in `~/.amc/worktree-placement-refusals.jsonl` recording who asked, why, on which
  drive, and what the cleanup pass managed to free;
- `node scripts/ops/fleet-status.mjs` prints a `wt admission` line whenever anything was deferred
  in the last hour (silent otherwise);
- if deferrals run unbroken for ten minutes, one inbox card says so and names the kinds of work
  most affected.

**Turning it off.** `AMC_DISABLE_WORKTREE_CREATE_BUDGET=1` admits everything. The thresholds are
`AMC_WORKTREE_CREATE_BUDGET_HEADROOM`, `AMC_WORKTREE_LIVE_CAP_FLOOR` and
`AMC_WORKTREE_LIVE_CAP_MULTIPLIER`. It also fails open on its own: if it cannot read the disk or
the ledger, it admits the create rather than becoming an outage of its own.

#### When there is nowhere to put it at all

A stronger version of the same hold. If the drive your working folders are configured to live on is
full, Omniscio will **not** quietly put them on the drive holding your code instead. That drive is
where Git keeps the project itself, and filling it stops everything — it is the failure this rule
exists to prevent, and it happened twice before the rule existed.

So when no working-folder drive has room, Omniscio:

1. **cleans up first** — it retires finished working folders on the configured drive and looks
   again. Most of the time that is enough, because finished folders pile up faster than anyone
   notices.
2. **refuses clearly if that did not help** — you get a specific "no drive has room" answer rather
   than a mysterious failure, plus one notice in your inbox with a button that shows you which
   folders are holding the space.

That notice appears only after the cleanup has already run and come up empty, so it means something
real: nothing on this machine can fix it by itself, and freeing space is the next step.

## For agents

### How it works (for AI agents / contributors) — the INTERACTIVE path

The skill is a pure instruction file at [.claude/skills/worktree-cleanup/SKILL.md](../../.claude/skills/worktree-cleanup/SKILL.md). It contains no application code — it's a set of steps the AI follows with `git` (and `gh` only when it happens to be available), **with a human approving each removal**. Every git call is `git -C "<repo-root>"`, so it runs from anywhere. **Unattended `--auto` never executes this sequence** — it delegates to `POST /worktree-cleanup/run-now` and stops (see Modes above).

- **Discovery:** `git worktree list --porcelain` to enumerate all worktrees, then `git worktree prune` to clear admin entries whose directory is already gone.
- **Guards:** skip anything locked, mid-git-op, actively marked, dirty with real WIP, the main/bare checkout, or newer than an `--idle` threshold.
- **Landed check:** `git merge-base --is-ancestor <branch> <INT>`, then `git cherry <INT> <branch>` for patch-id equality, then (interactive only) a token lookup on the integration branch for the squash case.
- **GitHub (optional):** `gh pr list` / `gh pr view` as one more signal. Absent `gh` or remote ⇒ skipped, not an error.
- **Rescue BEFORE any removal:** `git -C <wt> diff HEAD > "<recovery>/<slug>.patch"` plus a copy of the non-junk untracked files (`git -C <wt> ls-files --others --exclude-standard`), then append `<branch>\t<tip-SHA>\t<reason>` to `<recovery>/reaped-branches.tsv`.
- **Cleanup:** `git worktree remove "<path>"` — **`--force` only after that rescue has run; never over un-rescued real WIP** — then `git branch -D "<branch>"` _only_ when the branch is provably landed, then `git worktree prune` after the batch.

> **Do not shortcut the removal step.** An unconditional `git worktree remove --force` destroys uncommitted work irreversibly, and it is exactly what the skill's safety floor forbids. Rescue first, then remove; force only over dirt you have already saved.

This skill is complementary to the in-app **Worktree Cleanup dashboard** (in the Dev Pipeline panel), which surfaces and controls the PowerShell cleanup scripts. **Whether anything reaps on a timer at all depends on the `worktreeCleanupEnabled` setting, which ships OFF, and on a default install the answer is nothing — no in-app schedule and no OS task.** With the toggle off, the app schedules no reap, and it only ever _removes_ the legacy "AMC Worktree Cleanup" Windows Scheduled Task — it never creates one, so no such task exists on a default install to be "the runner." Switch the toggle on and the app schedules the reap itself (hourly, plus a startup catch-up) and best-effort deletes that legacy task if it happens to be there. Those scripts already do the content-based landed detection and the reversible rescue/recovery (see the top of this page); the skill adds the human-in-the-loop plan and the optional deeper passes.

## Related

Its neighbours in the worktree lifecycle are:

- [dev-pipeline-maintenance.md](dev-pipeline-maintenance.md) — the daily automatic run that invokes this skill unattended, alongside `merge-all-ready`
- [dev-pipeline-panel.md](dev-pipeline-panel.md) — the Dev Pipeline panel, which includes the worktree cleanup dashboard
- [use-skills.md](use-skills.md) — how to browse, install, and manage skills in Omniscio
