ExitWorktree refuses to remove clean squash-merged worktrees, forcing a model turn to dismiss from Agent View
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
EnterWorktreefrommain, do work, push, open a PR.- Squash-merge the PR on GitHub (branch auto-deleted).
- Working tree is clean; PR is merged.
- In the Agent View,
Ctrl+X×2 to delete the session, or callExitWorktree(action: "remove"). - 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 throughExitWorktree. 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
ExitWorktreeverification;discard_changes: trueis the only bypass, and passing it is itself a model turn.
Proposed fixes (any one would help)
- 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. - A bindable non-model-turn force-remove. An Agent-View action (e.g.
Ctrl+Shift+X, or aagent:forceRemovekeybinding) that removes the worktree directly viagit worktree remove --forcewithout dispatching a model turn. - Respect a registered
WorktreeRemovehook's verdict. Where a project has aWorktreeRemovehook, 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)
3 Comments
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.
ExitWorktree(action: "remove")call from a CLI session, notCtrl+X×2Same refusal, on a worktree whose PR had just merged cleanly and whose working tree was empty:
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
mainas the squash commit. Before passingdiscard_changes: trueI 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 onorigin/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: truereflexively, 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 passdiscard_changes: true.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")withoutdiscard_changesruns two git commands in the worktree and refuses if either count is nonzero:originalHeadCommitis 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 storesoriginalBranchtoo, 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 saysdiscard_changesis 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:
Fast-forward repro (2.1.233, macOS) — consistent with the reading above, the refusal fires even when ancestry holds:
EnterWorktree(name: "x")frommain; commit once in the worktree.git merge worktree-x(fast-forward).git merge-base --is-ancestor worktree-x mainnow exits 0 — the commit is fully reachable frommain.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 thingmaindoesn'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
originalBranchat 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.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)
EnterWorktreewith any nameExitWorktree(action: "remove")— immediately, without touching anythingReproduced 3/3 on distinct fresh worktrees. Working tree clean (
git status --porcelainempty).The branch in this state has no upstream configured —
git rev-parse --abbrev-ref --symbolic-full-name @{upstream}returnsfatal: no upstream configured for branch '<name>'— since the tool branched it fromorigin/<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-mergeones.
Workaround
discard_changes: truedoes not work around it. Here it exits the session but still does not remove.Raw
git worktree removethen 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