ExitWorktree refuses to remove clean squash-merged worktrees, forcing a model turn to dismiss from Agent View

Status Open
Maintainer reply None cached
Activity 3 comments · opened Jul 17, 2026

Summary

Dismissing a clean worktree-backed session from the Agent View costs a model turn — and for squash-merge workflows, a refuse-then-retry double turn — because Ctrl+X×2 routes deletion through the ExitWorktree tool, which refuses when the branch's commits aren't reachable from the original branch. Squash-merge guarantees that condition. There is no keybinding or setting to force-remove without invoking the model.

Why this happens

ExitWorktree(action: "remove") refuses when the worktree has "uncommitted files or commits not on the original branch," unless discard_changes: true is passed. It appears to test whether the branch's commits are reachable from the base branch (ancestry).

Squash-merge lands the work as a single new-SHA commit on the base branch. The original per-commit SHAs are never reachable from the base afterward, so the ancestry test fails even though:

  • the working tree is clean (no uncommitted/untracked files), and
  • the PR is merged and the work is fully preserved on the base branch.

Result: the tool refuses, surfaces Re-invoke with discard_changes: true, and the model must take another turn to re-invoke. From the Agent View, this whole exchange is triggered by a Ctrl+X×2 that the user intends as a trivial dismiss.

Repro

  1. EnterWorktree from main, do work, push, open a PR.
  2. Squash-merge the PR on GitHub (branch auto-deleted).
  3. Working tree is clean; PR is merged.
  4. In the Agent View, Ctrl+X×2 to delete the session, or call ExitWorktree(action: "remove").
  5. Tool refuses: "Could not verify worktree state … Re-invoke with discard_changes: true."

Impact

For anyone whose merge strategy is squash (a very common default), every worktree dismissal pays this tax. The user perceives a UI-trivial action (dismiss a finished agent) as a latency + token cost, repeated many times a day.

Why existing knobs don't solve it

  • No keybindings action to bind a force-remove — the keybindings reference has no agent:forceRemove / worktree:discard / session:forceDelete; the delete path always goes through ExitWorktree.
  • cleanupPeriodDays — the periodic sweep also requires "no unpushed commits," which the squash branch fails identically. And it's periodic, not immediate.
  • No setting disables or relaxes the ExitWorktree verification; discard_changes: true is the only bypass, and passing it is itself a model turn.

Proposed fixes (any one would help)

  1. Detect squash-merge as clean. When the branch's tree content matches the base tip (or the PR is known-merged), treat the worktree as safely removable without discard_changes. Comparing tree hashes / git cherry / merged-PR state instead of raw commit ancestry would cover squash and rebase merges.
  2. A bindable non-model-turn force-remove. An Agent-View action (e.g. Ctrl+Shift+X, or a agent:forceRemove keybinding) that removes the worktree directly via git worktree remove --force without dispatching a model turn.
  3. Respect a registered WorktreeRemove hook's verdict. Where a project has a WorktreeRemove hook, let it decide removability (it can check upstream/merged state) instead of the built-in ancestry gate refusing before the hook runs.

Related

  • #58966 — dismiss/archive an agent from Agent View without a destructive model-turn round-trip (closed, not planned)
  • #55435 — auto-prune .claude/worktrees/agent-* on session end (closed, not planned)
  • #74719 — native worktree GC deletes dirty/unpushed worktrees (the over-deletion counterpart)

Environment

  • Platform: WSL2 (Ubuntu 24.04)
  • Merge strategy: squash-merge (GitHub, auto-delete branch on merge)

View original on GitHub ↗

3 Comments

hanslemm · 27 days ago

Confirming this on a different platform and a different entry path — so it isn't specific to WSL2 or to the Agent View dismiss flow.

  • Platform: macOS (Darwin 25.5.0), Claude Code 2.1.220
  • Trigger: a plain ExitWorktree(action: "remove") call from a CLI session, not Ctrl+X×2
  • Merge strategy: squash-merge via a merge-queue script (branch auto-deleted on merge)

Same refusal, on a worktree whose PR had just merged cleanly and whose working tree was empty:

Worktree has 5 commits on <branch>. Removing will discard this work permanently.
Confirm with the user, then re-invoke with discard_changes: true

One thing worth separating from the latency cost: the message is not merely early, it is factually wrong in this state. Nothing was going to be discarded — the work was already on main as the squash commit. Before passing discard_changes: true I verified the PR was merged from that exact branch (gh pr list --state merged --json number,headRefName) and that the changed files and a specific edited line were present on origin/main. Only then was it safe, and the tool gave no signal distinguishing that from the genuinely-unmerged case.

That matters because of the interaction with #79899 (exit-time cleanup deleting unpushed commits as "no pending changes"). In a repo that squash-merges exclusively, this warning is wrong on 100% of successful lands. A warning that is always wrong on the happy path trains people — and models — to pass discard_changes: true reflexively, which is precisely the habit that makes the over-deletion case expensive. The false positive and the false negative compound.

Supporting proposed fix #1, with a concrete cheap form: git diff <base> <branch> being empty means the branch's content is fully represented in the base, regardless of how it merged. That is a pure-git check needing no GitHub API or PR state, it passes silently for both squash and rebase merges, and it still refuses on genuinely unmerged work. Comparing content rather than commit ancestry is the distinction the current gate is missing.

For anyone hitting this meanwhile, the safe manual sequence is: confirm the PR merged from that branch name, confirm the merge commit is on origin/main, spot-check that an actual edit from the branch is present in the base, and only then pass discard_changes: true.

santoli-massimo · 13 days ago

ExitWorktree's remove check may not be ancestry-based

Reading the bundled code, the check appears to count commits against a base frozen at creation time, not against the original branch — which is not what the tool's own documentation describes. In 2.1.233, ExitWorktree(action: "remove") without discard_changes runs two git commands in the worktree and refuses if either count is nonzero:

git status --porcelain
git rev-list --count <originalHeadCommit>..HEAD

originalHeadCommit is the SHA that HEAD pointed to when EnterWorktree created the worktree, stored in session state and never updated afterwards. The original branch is not consulted (the state stores originalBranch too, but the check doesn't use it), so no later merge — of any kind — can bring the second count back to zero. The documentation, however, describes reachability semantics: the schema says discard_changes is required when the worktree has "uncommitted files or unmerged commits", and the tool prompt says "commits not on the original branch". Code and docs answer two different questions — "has this worktree made any commits since creation?" versus "would any commits be lost?".

This would explain the behavior in this issue, and also in some earlier reports describing the same refusal:

  • #40137 — after a squash-merge (closed for inactivity)
  • #71135 — after a fast-forward merge, about the "Discarded N commits" message (auto-closed as dup of #40137)
  • #62724 — feature request for merge verification before removal (closed for inactivity)
  • #84856 — after a squash-merge (closed as duplicate of this issue)

Fast-forward repro (2.1.233, macOS) — consistent with the reading above, the refusal fires even when ancestry holds:

  1. EnterWorktree(name: "x") from main; commit once in the worktree.
  2. From the main checkout, git merge worktree-x (fast-forward). git merge-base --is-ancestor worktree-x main now exits 0 — the commit is fully reachable from main.
  3. ExitWorktree(action: "remove") → refused: "Worktree has 1 commit on worktree-x. Removing will discard this work permanently." At this point the branch name is the only thing main doesn't already have.

(empty worktree → removes cleanly · unmerged commit → refuses · ff-merged commit → refuses identically)

Possible ways to close the gap: either align the docs and the message with the frozen-base semantics, or resolve originalBranch at check time (git rev-list --count <originalBranch>..HEAD) — which matches the documented wording, covers fast-forward/true merges, and composes with the tree-equality already proposed here for squash/rebase.

aharrow-dv · 5 days ago

Confirming on 2.1.245, and adding a case: this happens with no squash-merge involved — including on a worktree created seconds earlier with zero commits.

Repro (fresh worktree, no commits)

  1. EnterWorktree with any name
  2. ExitWorktree(action: "remove") — immediately, without touching anything
Could not verify worktree state at <path>. Refusing to remove without explicit confirmation. Re-invoke with discard_changes: true to proceed — or use action: "keep" to preserve the worktree.

Reproduced 3/3 on distinct fresh worktrees. Working tree clean (git status --porcelain empty).

The branch in this state has no upstream configuredgit rev-parse --abbrev-ref --symbolic-full-name @{upstream} returns fatal: no upstream configured for branch '<name>' — since the tool branched it from origin/<default>. If the ancestry check needs a comparison ref, it may be failing to resolve one rather than finding commits unreachable. That would explain a brand-new worktree failing when there is nothing to be unreachable, and means every new worktree here hits this, not just post-squash-merge
ones.

Workaround

discard_changes: true does not work around it. Here it exits the session but still does not remove.

Exited worktree but could not remove it — kept at <path>.

Raw git worktree remove then succeeds without --force, so git considers the worktree clean and removable. (Differs from #69802 / #47386, which report a false success while orphaning; here the failure is explicit.)

Environment

  • Claude Code 2.1.245
  • macOS 26.5.2