Skip to content

feat(prune): make --all delete every branch recoverable from a remote - #110

Merged
justin13888 merged 3 commits into
masterfrom
feat/prune-all
Sep 23, 2026
Merged

justin13888 merged 3 commits into
masterfrom
feat/prune-all

Conversation

@justin13888

@justin13888 justin13888 commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

wt prune --all now 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 --all as --merged ∪ --gone, and those two modes answer different questions:

  • --merged proves a branch is safe, but only against the local default branch, which can lag behind.
  • --gone means "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 a fetch --prune that wt never ran.

Branches whose commits are all on origin but that the union missed:

  1. a pushed branch with a live upstream (an open PR, or a squash-merged PR whose remote branch was kept);
  2. a branch pushed without -u (no upstream) that origin/<b> still contains;
  3. a branch merged into origin/main while local main is behind;
  4. a branch whose commits are spread across several remote refs.

The check is now recoverability: git rev-list -n1 refs/heads/<b> --not --remotes refs/heads/<default> prints nothing.

Mode Worktrees removed Bare branches deleted
--merged branch merged into the default (local or origin/<default>) same
--gone upstream gone, or worktree missing (unchanged) upstream gone: recoverable ones deleted, others skipped unless --force
--pushed (new) none every commit on a remote or the default branch
-a/--all merged ∪ gone merged ∪ pushed ∪ gone (gone and unrecoverable: skipped with the --force hint), plus the branch of each worktree --all removes

The current and default branches are never touched. Dirty worktrees are still skipped. Nothing unrecoverable is deleted without --force.

Changed paths

  • src/git/aheadbehind.rs: new is_recoverable, the rev-list reachability check, with tests (via a remote ref, via a kept ref, error on an unknown ref).
  • src/git/ops.rs: new fetch_all_prune (git fetch --all --prune) and an argv test.
  • src/git/refs.rs and src/git/mod.rs: new default_tracking_ref (origin/HEAD target as refs/remotes/origin/<default>), re-exported for CLI builds and asserted in the existing default_base_ref tests.
  • src/cli.rs: new PruneArgs flags --pushed and --no-fetch; new accessors includes_pushed() and needs_fetch(); --merged/--gone/--all help rewritten; parse tests.
  • src/commands/prune.rs:
    • New MergeTargets (local default plus origin/<default>; the default is never "merged" into itself).
    • fetch_remotes runs before selection when a remote-reading mode is on and a remote exists; a failed fetch aborts and points to --no-fetch.
    • Candidate::Branch gains safe, and branch_candidates selects on merged/pushed/gone. The rev-list check is skipped for branches no mode could pick, and a failed check counts as unsafe.
    • remove_branch quietly skips branches already gone, re-checks recoverability under the repo lock, and deletes with -D, since -d only compares against HEAD and upstream. Unrecoverable branches still need --force.
    • Under --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.
    • --json branch rows gain "safe".
    • 17 new or rewritten tests, including one against a real bare remote.
  • 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:

  • Live pushed branches: --all deletes 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.
  • Fetch by default: --gone, --pushed, and --all run git fetch --all --prune first, 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-fetch trusts the last fetch. --merged alone stays offline, as before.
  • "On a remote" means any remote-tracking ref (--remotes), not just the branch's own upstream, which catches pushes without -u and commits spread over several refs. A remote with skipFetchAll is not refreshed by fetch --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.
  • --force keeps its existing double meaning (skip the confirmation and allow unrecoverable deletes). Splitting it is out of scope.
  • Folding the worktree's branch into the same pass is limited to --all. --merged and --gone keep their existing rule for a removed worktree's branch: delete it only if it is merged, was created by wt, and remove.delete_merged_branch is on. So --all is intentionally more than --merged --gone --pushed.
  • Confirmation: no extra prompt for live-upstream branches. The existing prompt lists every candidate, and --dry-run/--json (with safe) preview it.

Behavior changes (for release notes)

  • --gone now 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.
  • --gone now deletes a gone branch without --force when its commits exist on another remote ref or on the default branch.
  • --merged also counts origin/<default>. So does the JSON "merged" field. As a result, a branch with no commits of its own, forked from origin/main while local main is behind (the TUI and wt issue fork from origin/main, New worktree should default base as the upstream default branch #70), is now "merged". Zero-commit branches forked from a current local main were already pruned as merged; this extends the same rule to a lagging local main. Nothing is lost except the clean checkout; dirty worktrees are still skipped.
  • The skip message is now 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: passed
  • mise run lint (cargo clippy --all-targets -- -D warnings): passed
  • mise run check-core (lean feature builds): passed
  • mise run coverage: passed, 93.66% line coverage (commands/prune.rs about 97%, git/aheadbehind.rs 100%, git/ops.rs 100%)
  • mise run commit-check: passed
  • Manual run of the built binary against a real bare remote with branches merged, pushed (live upstream), pushednou (pushed without -u), gonesafe (remote branch deleted, commits still on origin/release), goneunsafe, and localonly:
    • wt prune --all --dry-run listed gonesafe, goneunsafe, merged, pushed, and pushednou.
    • The old union, --merged --gone, listed only gonesafe, goneunsafe, and merged.
    • wt -y prune --all deleted 4 branches and skipped goneunsafe with the --force hint. goneunsafe, localonly, and main remained.
  • An independent review found no Critical or High defects. It reproduced one Medium bug: under --all, a missing worktree's branch failed to delete because it was still registered. That is fixed and covered by all_deletes_the_branch_of_a_missing_worktree_in_one_pass. A re-review at 762f1e8 found the fix correct: the test fails when the fix is removed. It found no new defects. CI at 762f1e8: Conventional commits, Coverage, and Format, Lint & Test all pass.

Coverage gaps

These behaviors have no test:

  • Re-checking recoverability under the lock when a branch gains commits between selection and deletion.
  • The git branch -D failure path ("could not delete").
  • Merged via origin/<default> on the worktree path and inside delete_merged_branch. Only the bare-branch path is tested.
  • The default branch never counting as "merged" when it is not also the current branch.
  • local_default_ref() when the local default branch doesn't exist.
  • --all --force deleting an unrecoverable worktree branch.
  • Skipping the fetch when no remote exists. Every test without a remote exercises it implicitly, but none asserts it.

Risks and rollout

  • Race window: a commit made with plain git (which ignores wt's advisory lock) between the re-check and git branch -D would be lost from the branch. It stays in the reflog/objects until gc. A compare-and-swap update-ref -d <b> <sha> would close this; deferred.
  • Missing worktrees, two known limits (both Low, found in re-review):
    • A locked missing worktree is skipped by git worktree prune, so under --all its branch delete prints could not delete …. The branch survives.
    • Without --all, the merged branch of a missing worktree is still left behind. The behavior is the same as on master.
  • Performance: one rev-list … --not --remotes subprocess per local branch under --pushed/--all, and again at delete time. This may be slow in repos with thousands of branches or remote refs.

`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.
@justin13888 justin13888 changed the title feat(prune): add -a/--all to select merged and gone candidates feat(prune): make --all delete every branch recoverable from a remote Sep 23, 2026
@justin13888
justin13888 merged commit 2f79184 into master Sep 23, 2026
3 checks passed
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