feat(prune): make --all delete every branch recoverable from a remote - #110
Merged
Merged
Conversation
`wt prune --all` (short `-a`) selects everything `--merged` and `--gone` would, so cleaning up every stale worktree and branch no longer needs both flags. Combining `--all` with either mode flag is accepted and redundant.
`--merged` proves safety only against the local default branch, and `--gone` is a staleness signal rather than a safety proof, so their union missed branches whose commits are all on origin: a live pushed branch, one pushed without an upstream, one merged into a lagging `origin/main`. - New `--pushed` selects bare branches whose every commit is on a remote-tracking ref or the default branch; it never selects worktrees. - `--all` now covers merged, pushed, and gone branches, and also judges the branch of each worktree it removes, so one pass clears both. Worktrees are still removed only for finished (merged or gone) work. - `--merged` also counts `origin/<default>`. - Branch deletion keys on recoverability (`rev-list <branch> --not --remotes <default>` is empty), re-checked under the repo lock: a safe branch is deleted (including a gone one) without `--force`; one with commits found nowhere else still needs `--force`. - `--gone`, `--pushed`, and `--all` run `git fetch --all --prune` first (dry runs too) and abort on a failed fetch; `--no-fetch` trusts the last fetch. - `--json` branch rows gain a `safe` field.
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
wt prune --allnow deletes every local branch that can be dropped without losing a commit. A branch qualifies when each of its commits is also on a remote-tracking ref or on the default branch. Worktrees are still removed only for finished work.Why the first version of this PR fell short. The first commit (
ef3b208) defined--allas--merged ∪ --gone, and those two modes answer different questions:--mergedproves a branch is safe, but only against the local default branch, which can lag behind.--gonemeans "the upstream ref disappeared". That hints the work is finished; it proves nothing about safety, which is why an unmerged gone branch needs--force. It also relied on afetch --prunethatwtnever ran.Branches whose commits are all on origin but that the union missed:
-u(no upstream) thatorigin/<b>still contains;origin/mainwhile localmainis behind;The check is now recoverability:
git rev-list -n1 refs/heads/<b> --not --remotes refs/heads/<default>prints nothing.--mergedorigin/<default>)--gone--force--pushed(new)-a/--all--forcehint), plus the branch of each worktree--allremovesThe current and default branches are never touched. Dirty worktrees are still skipped. Nothing unrecoverable is deleted without
--force.Changed paths
src/git/aheadbehind.rs: newis_recoverable, therev-listreachability check, with tests (via a remote ref, via a kept ref, error on an unknown ref).src/git/ops.rs: newfetch_all_prune(git fetch --all --prune) and an argv test.src/git/refs.rsandsrc/git/mod.rs: newdefault_tracking_ref(origin/HEADtarget asrefs/remotes/origin/<default>), re-exported for CLI builds and asserted in the existingdefault_base_reftests.src/cli.rs: newPruneArgsflags--pushedand--no-fetch; new accessorsincludes_pushed()andneeds_fetch();--merged/--gone/--allhelp rewritten; parse tests.src/commands/prune.rs:MergeTargets(local default plusorigin/<default>; the default is never "merged" into itself).fetch_remotesruns before selection when a remote-reading mode is on and a remote exists; a failed fetch aborts and points to--no-fetch.Candidate::Branchgainssafe, andbranch_candidatesselects on merged/pushed/gone. Therev-listcheck is skipped for branches no mode could pick, and a failed check counts as unsafe.remove_branchquietly skips branches already gone, re-checks recoverability under the repo lock, and deletes with-D, since-donly compares against HEAD and upstream. Unrecoverable branches still need--force.--all, the branch of each worktree being removed goes through the same bare-branch check. It is skipped when its worktree was kept (dirty). Git's worktree metadata is reconciled after the worktree removals and before the branch deletes, so a missing worktree's branch can be deleted in the same pass.--jsonbranch rows gain"safe".README.md: the "Bulk-clean stale branches" bullet describes the modes, the default fetch, and--no-fetch.Design decisions
These were settled with the requester before implementation, or during review:
--alldeletes the local branch but keeps the worktree. A clean checkout on a pushed, still-active branch is live work, and only finished (merged or gone) worktrees are removed.--gone,--pushed, and--allrungit fetch --all --prunefirst, dry runs included, so a preview matches the real run. The fetch is skipped when no remote is configured. If it fails, prune aborts, because a stale tracking ref could vouch for commits the remote has since dropped;--no-fetchtrusts the last fetch.--mergedalone stays offline, as before.--remotes), not just the branch's own upstream, which catches pushes without-uand commits spread over several refs. A remote withskipFetchAllis not refreshed byfetch --all, so its refs may be stale. This is accepted: they still exist locally and are only dropped by a later prune-fetch of that remote.--forcekeeps its existing double meaning (skip the confirmation and allow unrecoverable deletes). Splitting it is out of scope.--all.--mergedand--gonekeep their existing rule for a removed worktree's branch: delete it only if it is merged, was created bywt, andremove.delete_merged_branchis on. So--allis intentionally more than--merged --gone --pushed.--dry-run/--json(withsafe) preview it.Behavior changes (for release notes)
--gonenow fetches (network, credentials) and aborts if the fetch fails. Offline scripts should add--no-fetch. The fetch also prunes stale remote-tracking refs during--dry-run.--gonenow deletes a gone branch without--forcewhen its commits exist on another remote ref or on the default branch.--mergedalso countsorigin/<default>. So does the JSON"merged"field. As a result, a branch with no commits of its own, forked fromorigin/mainwhile localmainis behind (the TUI andwt issuefork fromorigin/main, New worktree should default base as the upstream default branch #70), is now "merged". Zero-commit branches forked from a current localmainwere already pruned as merged; this extends the same rule to a lagging localmain. Nothing is lost except the clean checkout; dirty worktrees are still skipped.skipping <b>: branch has commits on no remote or default branch; use --force.Validation
Run at
762f1e8:mise run test: passed (904 passed, 0 failed)mise run format-check: passedmise run lint(cargo clippy --all-targets -- -D warnings): passedmise run check-core(lean feature builds): passedmise run coverage: passed, 93.66% line coverage (commands/prune.rsabout 97%,git/aheadbehind.rs100%,git/ops.rs100%)mise run commit-check: passedmerged,pushed(live upstream),pushednou(pushed without-u),gonesafe(remote branch deleted, commits still onorigin/release),goneunsafe, andlocalonly:wt prune --all --dry-runlistedgonesafe,goneunsafe,merged,pushed, andpushednou.--merged --gone, listed onlygonesafe,goneunsafe, andmerged.wt -y prune --alldeleted 4 branches and skippedgoneunsafewith the--forcehint.goneunsafe,localonly, andmainremained.--all, a missing worktree's branch failed to delete because it was still registered. That is fixed and covered byall_deletes_the_branch_of_a_missing_worktree_in_one_pass. A re-review at762f1e8found the fix correct: the test fails when the fix is removed. It found no new defects. CI at762f1e8: Conventional commits, Coverage, and Format, Lint & Test all pass.Coverage gaps
These behaviors have no test:
git branch -Dfailure path ("could not delete").origin/<default>on the worktree path and insidedelete_merged_branch. Only the bare-branch path is tested.local_default_ref()when the local default branch doesn't exist.--all --forcedeleting an unrecoverable worktree branch.Risks and rollout
git(which ignoreswt's advisory lock) between the re-check andgit branch -Dwould be lost from the branch. It stays in the reflog/objects until gc. A compare-and-swapupdate-ref -d <b> <sha>would close this; deferred.git worktree prune, so under--allits branch delete printscould not delete …. The branch survives.--all, the merged branch of a missing worktree is still left behind. The behavior is the same as onmaster.rev-list … --not --remotessubprocess per local branch under--pushed/--all, and again at delete time. This may be slow in repos with thousands of branches or remote refs.