Summary
wt audit prints a copy-pasteable wt remove ... command for the worktrees it classifies as safe, but wt remove then refuses most of them — and reports the refusal as Worktree has unsaved changes when the working trees are provably clean.
The two commands use incompatible safety models:
wt audit proves safety by content: is the branch an ancestor of the base ref, or was it squash-merged into it? Either way the commits exist in origin/main.
wt remove uses a proxy for lost work: changes || unpushedCommits || noUpstream (src/commands/remove.ts:192). And getUnsyncedStatus sets noUpstream: true whenever git log @{upstream}..HEAD throws (src/core/git.ts:146-148).
git log @{upstream}..HEAD throws in exactly the two cases the audit is most confident about:
- Detached HEAD — no branch, so never an upstream.
- A branch whose remote was deleted after a squash-merge — the normal end state of a merged PR.
So the merged, clean, disposable worktrees are the ones wt remove blocks. wt audit's advertised workflow cannot be completed as printed.
Reproduction
wt audit on a repo with 32 worktrees classified 13 as DELETE (merged), all with DIRTY = -, and printed:
# Safe to remove (in base ref, or folded into a retained branch; no uncommitted work):
wt remove 3 4 7 8 9 17 19 24 25 29 30 31 32
Running exactly that command:
$ wt remove 3 4 7 8 9 17 19 24 25 29 30 31 32
Failed to remove 13 target(s):
4: Worktree has unsaved changes. Use --force to override.
Branch has no upstream tracking branch.
17: Worktree has unsaved changes. Use --force to override.
Branch has no upstream tracking branch.
24: Worktree has unsaved changes. Use --force to override.
Branch has no upstream tracking branch.
25: Worktree has unsaved changes. Use --force to override.
Branch has no upstream tracking branch.
29: Worktree has unsaved changes. Use --force to override.
Branch has no upstream tracking branch.
30: Worktree has unsaved changes. Use --force to override.
Branch has no upstream tracking branch.
32: Worktree has unsaved changes. Use --force to override.
Branch has no upstream tracking branch.
exit 1
Note that Branch has no upstream tracking branch. is the only reason listed for each — no Uncommitted changes: block and no Unpushed commits: block. Verified independently, every one of those working trees is clean:
$ for p in <the 7 worktree paths>; do git -C "$p" status --porcelain | wc -l; done
0
0
0
0
0
0
0
Slots 4, 24, 29, 30 and 32 are detached-HEAD worktrees (created by an external agent harness); slots 17 and 25 are branches whose PRs were squash-merged and whose remote branches were then deleted afterwards. Audit correctly reported squash / ancestor and DELETE (merged) for all seven.
Why this matters
- The headline message is wrong.
Worktree has unsaved changes is printed when changes.length === 0. It sends you looking for uncommitted work that does not exist, and it makes --force look like it is overriding data loss when there is nothing to lose.
- The refusal is total, not partial. The gate at
src/commands/remove.ts:188-214 runs before the teardown try block, so a noUpstream worktree never reaches the database drop, Docker teardown, or git worktree remove. Its Postgres DB, Redis/Electric containers and port slot keep leaking.
- It pushes users to blanket
--force. The only way to act on the audit is wt remove <all of them> --force, which simultaneously disables the genuine uncommitted-changes check. That is strictly less safe than what audit already proved.
- Detached HEAD can never be removed without
--force, regardless of how thoroughly merged it is. Worktrees created by agent harnesses (Codex, Claude worktree isolation) are usually detached, so they accumulate.
Suggested fix
Make remove reuse the audit classifier instead of the @{upstream} proxy:
- If the worktree's
HEAD is provably contained in the base ref — ancestor, or squash-merge detected, i.e. the same determination wt audit already makes — then noUpstream and unpushedCommits are not risks, and only getUncommittedChanges should gate removal.
- Keep the current strict behaviour when containment cannot be proven (audit's
REVIEW (diverged) / keep (WIP) cases). That is where "no upstream" genuinely signals commits that exist nowhere else.
Smaller fixes worth doing regardless:
- Fix the message. Derive the headline from the reasons actually found, e.g.
Worktree has unpushed work. / Branch has no upstream tracking branch (cannot verify commits are pushed). Never claim "unsaved changes" when changes is empty.
- Distinguish detached HEAD from a branch without an upstream. They are different situations with different risk;
getUnsyncedStatus currently collapses both into noUpstream by catching the exec failure.
- Make audit and remove agree by construction. Either
wt audit should emit a command that actually works, or wt remove should accept audit's verdict (e.g. an --audited / --merged mode that removes exactly what audit classified as safe). Right now the printed suggestion is a trap.
Environment
wt 0.6.0
- macOS (Darwin 27.0.0), git worktrees spanning
.worktrees/, .claude/worktrees/, ~/.codex/worktrees/
- 32 registered worktrees, base ref
origin/main
Summary
wt auditprints a copy-pasteablewt remove ...command for the worktrees it classifies as safe, butwt removethen refuses most of them — and reports the refusal asWorktree has unsaved changeswhen the working trees are provably clean.The two commands use incompatible safety models:
wt auditproves safety by content: is the branch an ancestor of the base ref, or was it squash-merged into it? Either way the commits exist inorigin/main.wt removeuses a proxy for lost work:changes || unpushedCommits || noUpstream(src/commands/remove.ts:192). AndgetUnsyncedStatussetsnoUpstream: truewhenevergit log @{upstream}..HEADthrows (src/core/git.ts:146-148).git log @{upstream}..HEADthrows in exactly the two cases the audit is most confident about:So the merged, clean, disposable worktrees are the ones
wt removeblocks.wt audit's advertised workflow cannot be completed as printed.Reproduction
wt auditon a repo with 32 worktrees classified 13 asDELETE (merged), all withDIRTY=-, and printed:Running exactly that command:
Note that
Branch has no upstream tracking branch.is the only reason listed for each — noUncommitted changes:block and noUnpushed commits:block. Verified independently, every one of those working trees is clean:Slots 4, 24, 29, 30 and 32 are detached-HEAD worktrees (created by an external agent harness); slots 17 and 25 are branches whose PRs were squash-merged and whose remote branches were then deleted afterwards. Audit correctly reported
squash/ancestorandDELETE (merged)for all seven.Why this matters
Worktree has unsaved changesis printed whenchanges.length === 0. It sends you looking for uncommitted work that does not exist, and it makes--forcelook like it is overriding data loss when there is nothing to lose.src/commands/remove.ts:188-214runs before the teardowntryblock, so anoUpstreamworktree never reaches the database drop, Docker teardown, orgit worktree remove. Its Postgres DB, Redis/Electric containers and port slot keep leaking.--force. The only way to act on the audit iswt remove <all of them> --force, which simultaneously disables the genuine uncommitted-changes check. That is strictly less safe than what audit already proved.--force, regardless of how thoroughly merged it is. Worktrees created by agent harnesses (Codex, Claude worktree isolation) are usually detached, so they accumulate.Suggested fix
Make
removereuse the audit classifier instead of the@{upstream}proxy:HEADis provably contained in the base ref — ancestor, or squash-merge detected, i.e. the same determinationwt auditalready makes — thennoUpstreamandunpushedCommitsare not risks, and onlygetUncommittedChangesshould gate removal.REVIEW (diverged)/keep (WIP)cases). That is where "no upstream" genuinely signals commits that exist nowhere else.Smaller fixes worth doing regardless:
Worktree has unpushed work./Branch has no upstream tracking branch (cannot verify commits are pushed).Never claim "unsaved changes" whenchangesis empty.getUnsyncedStatuscurrently collapses both intonoUpstreamby catching the exec failure.wt auditshould emit a command that actually works, orwt removeshould accept audit's verdict (e.g. an--audited/--mergedmode that removes exactly what audit classified as safe). Right now the printed suggestion is a trap.Environment
wt0.6.0.worktrees/,.claude/worktrees/,~/.codex/worktrees/origin/main