Skip to content

feat(prune): judge detached, locked, and content-merged work - #112

Open
justin13888 wants to merge 11 commits into
masterfrom
feat/prune-redesign
Open

justin13888 wants to merge 11 commits into
masterfrom
feat/prune-redesign

Conversation

@justin13888

@justin13888 justin13888 commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

In real use, wt prune -a missed most of the stale work it could safely remove (in the reported repo, most of 13G of agent worktrees). It also hid what it kept. This PR fixes four problems:

  1. It never considered detached-HEAD worktrees.
  2. It judged safety by commit SHA only, so squash merges, rebase merges, work split across several PRs, and merge-only integration branches never counted.
  3. A squash-merged branch with a live upstream fell through --merged, --gone, and --pushed alike.
  4. A locked worktree failed git worktree remove, but only a debug! log recorded the failure, and prune still counted it as pruned.

Selection now gives every worktree and branch a verdict: why it qualifies, and what, if anything, keeps it.

Changed paths

  • src/git/merged.rs (new): is_content_merged. A tip counts as merged when git merge-tree --write-tree <target> <tip> produces the target's own tree. A branch whose final tree equals its merge base's tree (empty commits only, or work it backed out itself) never counts. It fails safe: a conflict, unrelated histories, an error, or git < 2.38 all read as "not merged".
  • src/git/worktrees.rs:
    • in_progress_op checks for rebase, merge, cherry-pick, revert, sequencer/, and bisect markers in a worktree's admin dir.
    • in_progress_branch names the branch that a rebase or bisect will return to.
  • src/git/porcelain.rs: parses the lock reason (lock_reason).
  • src/git/ops.rs: worktree_remove gains an unlock flag, which passes --force --force as git requires for a locked worktree.
  • src/worktree/service.rs: wt remove passes unlock: false, so its behavior is unchanged.
  • src/git/mod.rs: registers merged and re-exports is_clean_for_removal.
  • src/git/status.rs: is_clean_for_removal reads status with --untracked-files=normal --ignore-submodules=none, so repository config cannot hide untracked files or dirty submodules from prune's guard.
  • src/config/schema.rs: the remove.untracked_blocks doc now says prune always counts untracked files.
  • src/commands/prune.rs → src/commands/prune/mod.rs, plus a new src/commands/prune/assess.rs:
    • assess.rs classifies each subject. Reasons: merged, merged by content, upstream gone, missing, pushed.
    • Blocks: current worktree, operation in progress, unreadable state, locked, dirty, unsafe.
    • mod.rs reports each verdict, confirms, and removes.
  • src/cli.rs: new --locked flag, with updated help for --merged, --pushed, --all, and --force (which now says it orphans the commits of detached worktrees whose work exists nowhere else).
  • src/commands/mod.rs, src/worktree/mod.rs: drop the run_best_effort re-export, which prune no longer uses.
  • README.md: the prune section describes content merges (including the merge=union override), detached and locked worktrees, the skip rules, the unreadable-worktree rule, reflog-only commits, the --json reason, and git ≥ 2.38.

What prune now selects

  • --merged: worktrees (detached ones included) and branches that are merged into the default branch (local or origin/HEAD) by ancestry or by content.
  • --gone: same selection as before, but a content-merged gone branch is now deleted without --force, and a missing worktree that is locked, or detached with the only copy of its HEAD, is now kept.
  • --pushed: branches without a worktree, as before, plus detached worktrees whose HEAD is on a remote. A worktree with a branch checked out is still left alone as active work.
  • Branch safety is now "recoverable or merged by content". A content-merged branch can go without --force.

What keeps an item

Prune prints skipping <name>: <why> for each kept item, in dry runs too.

  • Never removed, whatever the flags:
    • the current worktree
    • a worktree with an operation in progress
    • a branch being rebased or bisected
    • a worktree whose state can't be read
  • Needs --locked: a locked worktree. --force does not override a lock.
  • Needs --force:
    • a dirty worktree. Untracked files always count, whatever remove.untracked_blocks or the repository's status.showUntrackedFiles says. Ignored files do not.
    • a branch whose work is on no remote and not in the default branch
    • a detached worktree whose HEAD is on no remote and not in the default branch, including a missing one

Re-checked under the repo lock before each removal

The confirmation prompt can wait a while, so each worktree is checked again right before removal. Any of these keeps it:

  • new uncommitted edits or untracked files
  • a moved HEAD (a detached worktree that committed, or a branch worktree that detached)
  • an operation that started
  • a "missing" directory that has reappeared (for example, a remounted drive)

A removal git refuses is reported, not counted, and keeps its branch.

Behavior changes to note

  • No more blanket git worktree prune. This was not in the original plan: the review added it. Prune used to run git worktree prune on every invocation, including --merged alone and the "nothing to prune" case. That silently dropped the registration of every missing worktree, including a detached one whose HEAD was the only copy of its commits. Prune now removes each missing worktree it selected with git worktree remove --force, which git accepts for a missing path. A missing worktree that no mode selects stays registered. Run git worktree prune yourself for the old reconcile-everything behavior.
  • Text output:
    • Dry-run lines now carry the reason, e.g. would remove X: merged by content.
    • Skip lines changed from skipping dirty worktree X to skipping X: uncommitted changes; use --force.
    • Skip lines now also appear on stderr during dry runs.
    • The unsafe-branch skip line changed from skipping X: branch has commits… to skipping X (branch): has work on no remote and not in the default branch; use --force.
    • An unsafe branch is no longer listed among the dry-run candidates. It appears only as a skip line.
  • --json:
    • Lists only what would be removed. Blocked items are no longer emitted; for example, a dirty worktree used to appear.
    • Branch rows and worktree rows gain a reason field (merged, merged by content, upstream gone, missing, pushed). Worktree rows otherwise keep schema_version 1 and every existing field; their keys are now in alphabetical order.
    • merged now also counts content merges, whatever the mode.
    • Unsafe branches are no longer emitted, so safe: false appears only under --force.
    • No skip lines are printed in --json mode.

Design decisions (settled with the maintainer before and during the work)

  • Locked worktrees: skipped unless --locked. --force keeps its existing meaning (dirty worktrees and unsafe branches).
  • Dirty worktrees: the dirty guard stays. Uncommitted content is never compared against the default branch.
  • Clean detached worktrees: a clean detached worktree whose HEAD is recoverable qualifies under --pushed and --all.
  • Content-merge rule: judged by the branch's final state, plus a guard that a branch with an empty net diff is never merged by content. So a branch that added s and t, then deleted s, with t squash-merged, still counts as merged. s survives only in that branch's history.
  • Untracked files block removal: prune's guard counts untracked files as uncommitted changes, whatever remove.untracked_blocks says. It reads status with pinned flags so repository config can't hide them. Removal keeps passing --force, because worktrees with submodules and locked worktrees need it. So prune's guard is the only thing protecting untracked files. --force overrides it. This also applies to worktrees merged by ancestry, which used to follow the config.
  • Merge drivers can't fake a content merge: the content check overrides every configured merge.<name>.driver so it fails, and always overrides the built-in union driver with git merge-file, a plain text merge (git looks up a configured merge.union.driver before its built-ins, and this override comes last, so it also replaces a user's own). It pins merge.default=text. A file a custom driver governs conflicts and reads as not merged. A merge=union file merges as text: a real conflict (such as a deletion the default branch edited) reads as not merged, while a changelog whose entry was squash-merged and which the default branch edited elsewhere still reads as merged. Driver config that can't be read, or a driver name that can't be overridden, also reads as not merged.
  • An unreadable worktree keeps every bare branch: when a present worktree's git directory can't be resolved, prune can't tell which branch its rebase or bisect will return to, so no bare branch is deleted on that run. The skip line names the worktree, e.g. cannot read the rebase or bisect state of blind. --force doesn't override it. Previously the read error was dropped and only git's "used by worktree" refusal stood between that branch and deletion.
  • Reflog-only commits in a detached worktree are not protected: a detached worktree whose HEAD was moved back to a merged commit qualifies, and removing it deletes the HEAD reflog that was the only path to its earlier commits. Accepted; the README says so.
  • A detached worktree at the default branch's tip counts as merged: that is every freshly created agent worktree, and removing it is intended, provided it has no uncommitted changes or untracked files.
  • Content-merged branches are deleted without --force: this deliberately replaces the earlier README promise that prune drops only what it can lose "without losing a commit". Intermediate commits the branch itself overwrote are not preserved.
  • Dropped the planned git cherry patch-id fallback: it treats a change that the default branch later reverted as merged. Without it, a change the default branch took and later edited on the same lines conflicts and reads as not merged, which is the safe way to be wrong.

Questions raised in review, and their answers

  • --locked on a missing, locked worktree: removes only its registration. --locked is the explicit opt-in. A detached HEAD there is still protected by the unsafe check, and a branch survives.
  • Content check against a stale origin/HEAD under --no-fetch: same exposure as the existing ancestry check against it. Accepted.
  • Dirty worktrees still run the content check before being blocked: that costs time, but the result is the same. Accepted.
  • Exit code when a removal or delete fails: still 0, consistent with the existing branch-delete failures. Accepted; left for a follow-up.
  • TUI delete-safety MergeState: stays ancestry-only, which errs toward "not merged". Accepted; left for a follow-up.
  • Untested behaviour listed below: accepted as known gaps.
  • reason on --json worktree rows without a schema_version bump: accepted. The field is new and no existing field changes. Prune's worktree rows are emitted with keys in alphabetical order, unlike wt list --json; also accepted.
  • Without origin/HEAD, the default branch falls back to the current branch (git::refs::default_branch): this predates the change; the content check only widens its reach. Not changed here.

Validation

Run at 836e204, git 2.55.0:

  • mise run format-check passed.
  • mise run lint (cargo clippy --all-targets -- -D warnings) passed.
  • mise run test: 957 passed, 0 failed.
  • mise run check-core (lean {} and {cli} builds; clippy + tests) passed.
  • mise run coverage passed at 93.19% line coverage overall.
  • Each of the seven review-follow-up commits passed clippy and cargo test --lib on its own.
  • Each new test was confirmed to fail with its fix reverted: the merge=union deletion case, the merge=union changelog case (fails under an exit 1 override), both unreadable re-checks under the lock, and the unreadable-worktree bare-branch block.
  • On git 2.55, without an override git merge-tree --write-tree returns the default branch's own tree for a merge=union file whose line the branch deleted and main edited; with the git merge-file override it conflicts.

Earlier validation, at f5d42ac and before (not re-run at 836e204):

  • End to end, target/debug/wt prune -a --dry-run, then -y, then -y --locked, against a scratch repo shaped like the reported one: squash-merged gone branches (one locked); a squash-merged branch split across two PRs with a live upstream and a worktree; a dirty integration branch made only of merges; local-only work; an open-PR branch and worktree; and detached worktrees that are pushed, local-only, dirty, locked-and-merged, and mid-bisect.
    • Every verdict matched the third-party analysis, with the one exception below.
    • The -y --locked run removed the locked items.
    • Nothing marked "keep" was touched.
    • The end-to-end run was at c10c61b. At f5d42ac, two scratch-repo reproductions were re-run with the built binary: untracked files in a detached worktree and in a squash-merged worktree are now kept, and a merge=ours changelog branch is no longer deleted.
    • The exception: a pushed branch with no worktree and an open PR is removed by --pushed/--all. That is the existing documented --pushed behavior (its commits are on origin).

Test coverage gaps

These behaviors have no test that reaches them:

  • The skip path inside remove_worktree end to end. The re-checks are tested by calling changed_since_assessed directly, including both unreadable results under the lock (a path lookup that fails with something other than not-found, and an admin directory that can't be resolved).
  • The unreadable-worktree block under --dry-run/--json, and under --all for a worktree's own branch.
  • The bisect-branch exclusion at the prune level; rebase-apply/head-name; a rebase in the primary worktree; in_progress_branch failing and falling through (git's own "used by worktree" refusal is the only backstop).
  • A content merge judged specifically against the origin/HEAD target; a detached worktree merged by content; --json omitting a blocked worktree.
  • A branch that moves between selection and its safety re-check under the lock.
  • delete_merged_branch failure logging.
  • A dirty submodule or an untracked file inside a submodule, at the prune level. --ignore-submodules=none is passed, but no test sets up a submodule.
  • The git < 2.38 fallback. Only git 2.55 was available here.
  • A missing worktree with a lock and no porcelain record, end to end (tested at the assessor level only).

Risks

  • Deletion semantics change on purpose: a content-merged branch is deleted without --force, even when its SHAs exist nowhere else. Intermediate states that the branch itself overwrote are not preserved.
  • Performance: each non-ancestor candidate costs up to five git subprocesses per target, and merge-tree writes loose tree objects (no refs, collected by gc). This is noticeable with hundreds of stale branches.
  • Race windows: there is still a window between the final existence check and git worktree remove on a missing path. If a missing path is now a broken symlink, it reports "reappeared" on every run until you deal with it (nothing is removed).
  • Missing worktree mid-rebase: its rebase state (admin dir) is dropped if it's removed. Its commits stay protected by the detached-HEAD check.
  • merge=union files are judged by a plain text merge. The override runs git merge-file, which exits non-zero on any conflict or on a binary file, so either reads as not merged. If the driver's shell cannot run git, merge-tree reports a conflict, which also reads as not merged.
  • Not updated: the TUI's delete-safety MergeState doesn't yet know about content merges.

`wt prune --all` kept stale work it could safely drop and hid what it kept.
It never looked at detached-HEAD worktrees, and it judged safety by commit
SHA alone. So squash merges, rebase merges, work split across PRs, and
merge-only branches never counted. It also removed worktrees best-effort, so
a locked worktree failed silently and still counted as pruned.

Selection now gives every worktree and branch a verdict: a reason it
qualifies, and anything that keeps it.

- Content merges: a branch or HEAD counts as merged when merging it into the
  default branch (local or `origin/HEAD`) changes nothing, checked with
  `git merge-tree --write-tree` (git >= 2.38; older git falls back to ancestry
  only). A branch whose net diff is empty never counts. `--merged` and safety
  both use this, so a squash-merged branch with a live upstream, or a
  merge-only branch that was never pushed, can go without `--force`.
- Detached worktrees: selected by `--merged` when their HEAD is merged, and
  by `--pushed` when every commit is on a remote. One whose HEAD exists
  nowhere else, even a missing one, needs `--force`.
- Blocks: the current worktree, and one with a rebase, merge, cherry-pick,
  revert, or bisect in progress, are never removed, and neither is a branch
  being rebased or bisected. Locked worktrees are skipped unless `--locked`
  is passed (`git worktree remove --force --force`); `--force` does not
  override a lock. Dirty worktrees and unsafe branches still need `--force`.
- Removal re-checks under the repo lock: new edits, a moved HEAD, a new
  operation, or a missing directory that reappeared all keep the worktree.
  A refused removal is reported, not counted, and keeps its branch.
- Only listed items are removed. The blanket `git worktree prune` is gone,
  so a missing worktree no mode selects stays registered.
- Output: dry-run lines now say why (`would remove X: merged by content`).
  Anything kept is printed as `skipping X: <why>`, in dry runs too.
  `--json` lists only what would be removed, and branch rows gain a
  `reason` field.
Prune removes with `git worktree remove --force` (worktrees with submodules
and locked worktrees need it), so git's own untracked-file check never runs.
With `remove.untracked_blocks` off, the default, nothing else stopped it. A
detached worktree freshly cut from main, or a squash-merged worktree, lost
its untracked files silently.

Prune's guard now reads status with `--untracked-files=normal
--ignore-submodules=none`, so neither `remove.untracked_blocks` nor the
repository's `status.showUntrackedFiles` or submodule ignore config can hide
untracked files or dirty submodules. It applies at selection and in the
re-check under the repo lock. `--force` still overrides. Ignored files are
still removed.
`git merge-tree` honours `.gitattributes` and `merge.<name>.driver`, so a
driver like the common `merge=ours` could resolve a branch's own edit to the
target's side. The merge then came out as a no-op, and prune deleted the
branch as merged by content even though the work was nowhere in the target.
`merge.default=union` could do the same to a deletion.

Every configured custom driver is now overridden to fail for the content
check, so a file one governs conflicts and reads as not merged, and
`merge.default` is pinned to `text`. A driver name that cannot be
overridden, or configuration that cannot be read, fails the check the same
way (logged at debug).
A `merge=union` attribute selects git's built-in union driver, which has no
`merge.<name>.driver` key for the override scan to find. It keeps both sides
of a conflict, so a branch that deleted lines main had since edited merged
into main's own tree and was deleted as merged by content.

git looks up a configured `merge.union.driver` before its built-ins, so the
content check now always overrides it to fail. A file it governs conflicts
and reads as not merged, like one governed by a custom driver.
Both fail-safe arms of the re-check before removal were unreached: a
missing worktree whose path cannot be checked (a lookup error other than
not-found), and a worktree whose in-progress state can no longer be read.
Each must keep the worktree rather than force-remove it.
A branch being rebased or bisected is detached from its worktree, so prune
reads the branch each worktree will return to and leaves it alone. A failed
read was dropped, leaving only git's own "used by worktree" refusal between
that branch and deletion.

When any present worktree's git directory cannot be resolved, the held
branch is unknown, so every bare branch is now kept for that run with
"a worktree's rebase or bisect state cannot be read". `--force` does not
override it.
Branch rows already carried why they qualify; worktree rows now do too,
with the same labels.
The `--force` help now says it also removes detached worktrees whose work
exists nowhere else, orphaning those commits. The README notes that a
detached worktree whose HEAD was moved back to a merged commit qualifies,
and removing it deletes the reflog that was the only path to its commits.
Overriding the built-in union driver to fail made every `merge=union` file
both sides touched read as not merged. That includes the usual case: a
changelog whose entry was squash-merged, with the default branch adding
another entry elsewhere since.

The override now runs `git merge-file`, git's own text merge. A deletion
the default branch edited still conflicts and reads as not merged, while
edits that merge cleanly as text count as merged again.
The skip line for a bare branch kept because a worktree's rebase or bisect
state cannot be read now names that worktree (or several), e.g.
`cannot read the rebase or bisect state of blind`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant