feat(prune): judge detached, locked, and content-merged work - #112
Open
justin13888 wants to merge 11 commits into
Open
justin13888 wants to merge 11 commits into
justin13888 wants to merge 11 commits into
Conversation
`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`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
In real use,
wt prune -amissed 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:--merged,--gone, and--pushedalike.git worktree remove, but only adebug!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 whengit 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_opchecks for rebase, merge, cherry-pick, revert,sequencer/, and bisect markers in a worktree's admin dir.in_progress_branchnames 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_removegains anunlockflag, which passes--force --forceas git requires for a locked worktree.src/worktree/service.rs:wt removepassesunlock: false, so its behavior is unchanged.src/git/mod.rs: registersmergedand re-exportsis_clean_for_removal.src/git/status.rs:is_clean_for_removalreads 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: theremove.untracked_blocksdoc now says prune always counts untracked files.src/commands/prune.rs→src/commands/prune/mod.rs, plus a newsrc/commands/prune/assess.rs:assess.rsclassifies each subject. Reasons:merged,merged by content,upstream gone,missing,pushed.mod.rsreports each verdict, confirms, and removes.src/cli.rs: new--lockedflag, 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 therun_best_effortre-export, which prune no longer uses.README.md: the prune section describes content merges (including themerge=unionoverride), detached and locked worktrees, the skip rules, the unreadable-worktree rule, reflog-only commits, the--jsonreason, and git ≥ 2.38.What
prunenow selects--merged: worktrees (detached ones included) and branches that are merged into the default branch (local ororigin/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.--force.What keeps an item
Prune prints
skipping <name>: <why>for each kept item, in dry runs too.--locked: a locked worktree.--forcedoes not override a lock.--force:remove.untracked_blocksor the repository'sstatus.showUntrackedFilessays. Ignored files do not.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:
A removal git refuses is reported, not counted, and keeps its branch.
Behavior changes to note
git worktree prune. This was not in the original plan: the review added it. Prune used to rungit worktree pruneon every invocation, including--mergedalone 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 withgit worktree remove --force, which git accepts for a missing path. A missing worktree that no mode selects stays registered. Rungit worktree pruneyourself for the old reconcile-everything behavior.would remove X: merged by content.skipping dirty worktree Xtoskipping X: uncommitted changes; use --force.skipping X: branch has commits…toskipping X (branch): has work on no remote and not in the default branch; use --force.--json:reasonfield (merged,merged by content,upstream gone,missing,pushed). Worktree rows otherwise keepschema_version1 and every existing field; their keys are now in alphabetical order.mergednow also counts content merges, whatever the mode.safe: falseappears only under--force.--jsonmode.Design decisions (settled with the maintainer before and during the work)
--locked.--forcekeeps its existing meaning (dirty worktrees and unsafe branches).--pushedand--all.sandt, then deleteds, withtsquash-merged, still counts as merged.ssurvives only in that branch's history.remove.untracked_blockssays. 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.--forceoverrides it. This also applies to worktrees merged by ancestry, which used to follow the config.merge.<name>.driverso it fails, and always overrides the built-inuniondriver withgit merge-file, a plain text merge (git looks up a configuredmerge.union.driverbefore its built-ins, and this override comes last, so it also replaces a user's own). It pinsmerge.default=text. A file a custom driver governs conflicts and reads as not merged. Amerge=unionfile 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.cannot read the rebase or bisect state of blind.--forcedoesn't override it. Previously the read error was dropped and only git's "used by worktree" refusal stood between that branch and deletion.--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.git cherrypatch-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
--lockedon a missing, locked worktree: removes only its registration.--lockedis the explicit opt-in. A detached HEAD there is still protected by the unsafe check, and a branch survives.origin/HEADunder--no-fetch: same exposure as the existing ancestry check against it. Accepted.0, consistent with the existing branch-delete failures. Accepted; left for a follow-up.MergeState: stays ancestry-only, which errs toward "not merged". Accepted; left for a follow-up.reasonon--jsonworktree rows without aschema_versionbump: accepted. The field is new and no existing field changes. Prune's worktree rows are emitted with keys in alphabetical order, unlikewt list --json; also accepted.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-checkpassed.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 coveragepassed at 93.19% line coverage overall.cargo test --libon its own.merge=uniondeletion case, themerge=unionchangelog case (fails under anexit 1override), both unreadable re-checks under the lock, and the unreadable-worktree bare-branch block.git merge-tree --write-treereturns the default branch's own tree for amerge=unionfile whose line the branch deleted and main edited; with thegit merge-fileoverride it conflicts.Earlier validation, at
f5d42acand before (not re-run at836e204):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.-y --lockedrun removed the locked items.c10c61b. Atf5d42ac, 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 amerge=ourschangelog branch is no longer deleted.--pushed/--all. That is the existing documented--pushedbehavior (its commits are on origin).Test coverage gaps
These behaviors have no test that reaches them:
remove_worktreeend to end. The re-checks are tested by callingchanged_since_assesseddirectly, 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).--dry-run/--json, and under--allfor a worktree's own branch.rebase-apply/head-name; a rebase in the primary worktree;in_progress_branchfailing and falling through (git's own "used by worktree" refusal is the only backstop).origin/HEADtarget; a detached worktree merged by content;--jsonomitting a blocked worktree.delete_merged_branchfailure logging.--ignore-submodules=noneis passed, but no test sets up a submodule.Risks
--force, even when its SHAs exist nowhere else. Intermediate states that the branch itself overwrote are not preserved.merge-treewrites loose tree objects (no refs, collected bygc). This is noticeable with hundreds of stale branches.git worktree removeon 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).merge=unionfiles are judged by a plain text merge. The override runsgit 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 rungit,merge-treereports a conflict, which also reads as not merged.MergeStatedoesn't yet know about content merges.