Skip to content

wt audit suggests a wt remove command that wt remove refuses, and mislabels it as "unsaved changes" #17

Description

@pkudinov

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:

  1. Detached HEAD — no branch, so never an upstream.
  2. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions