From ef3b208c1a75a71c1e30284cc24b8fd696279cf4 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 22 Sep 2026 20:01:23 -0400 Subject: [PATCH 1/3] feat(prune): add -a/--all to select merged and gone candidates `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. --- README.md | 7 +++-- src/cli.rs | 38 ++++++++++++++++++++++++ src/commands/prune.rs | 69 ++++++++++++++++++++++++++++++++++++++----- 3 files changed, 103 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index eaee1d1..8481677 100644 --- a/README.md +++ b/README.md @@ -222,9 +222,10 @@ These are the things worth knowing up front; the rest is discoverable from guard. - **Bulk-clean stale branches.** `wt prune --merged` removes worktrees whose branch is merged into the default branch, and `wt prune --gone` removes worktrees whose - upstream was deleted (plus any missing worktrees). Both also delete matching - **local branches that no longer have a worktree** — so a repo left with a pile of - merged feature branches gets cleaned up too. Preview with `--dry-run`. A `--gone` + upstream was deleted (plus any missing worktrees); `wt prune --all` (`-a`) does + both at once. Every mode also deletes matching **local branches that no longer + have a worktree** — so a repo left with a pile of merged feature branches gets + cleaned up too. Preview with `--dry-run`. A `--gone` branch that isn't also merged may hold unmerged commits, so it is skipped unless you pass `--force`. The current and default branches are never touched. diff --git a/src/cli.rs b/src/cli.rs index dc9e7f3..0d95d4e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -360,6 +360,9 @@ pub(crate) struct PruneArgs { /// Include worktrees whose upstream is gone, and missing worktrees. #[arg(long)] pub(crate) gone: bool, + /// Include both merged and gone candidates (same as `--merged --gone`). + #[arg(short = 'a', long)] + pub(crate) all: bool, /// Report candidates without removing anything. #[arg(long = "dry-run")] pub(crate) dry_run: bool, @@ -368,6 +371,18 @@ pub(crate) struct PruneArgs { pub(crate) force: bool, } +impl PruneArgs { + /// Whether merged candidates are selected (`--merged` or `--all`). + pub(crate) fn includes_merged(&self) -> bool { + self.merged || self.all + } + + /// Whether gone candidates are selected (`--gone` or `--all`). + pub(crate) fn includes_gone(&self) -> bool { + self.gone || self.all + } +} + /// Arguments for `wt pr`. #[derive(Debug, Args)] pub(crate) struct PrArgs { @@ -809,6 +824,29 @@ mod tests { assert!(parse(&["drop", "somequery"]).is_err()); } + #[test] + fn prune_all_parses_short_and_long() { + for flag in ["-a", "--all"] { + match parse(&["prune", flag]).unwrap().command { + Some(Command::Prune(a)) => { + assert!(a.all); + assert!(a.includes_merged() && a.includes_gone()); + } + _ => panic!("expected prune for {flag}"), + } + } + // `--all` alongside a mode flag is redundant, not an error. + assert!(parse(&["prune", "-a", "--merged"]).is_ok()); + // A single mode flag selects only that mode. + match parse(&["prune", "--gone"]).unwrap().command { + Some(Command::Prune(a)) => { + assert!(!a.includes_merged()); + assert!(a.includes_gone()); + } + _ => panic!("expected prune"), + } + } + #[test] fn pr_forms_parse_distinctly() { // `pr list` -> the list sub-form. diff --git a/src/commands/prune.rs b/src/commands/prune.rs index 3ea3cc2..823776f 100644 --- a/src/commands/prune.rs +++ b/src/commands/prune.rs @@ -30,8 +30,8 @@ enum Candidate { /// Selects and removes prune candidates after confirmation (spec §7/§12). pub(crate) fn run(cx: &mut Cx, args: &PruneArgs, json: bool) -> Result { - if !args.merged && !args.gone { - return Err(Error::usage("prune requires --merged and/or --gone")); + if !args.includes_merged() && !args.includes_gone() { + return Err(Error::usage("prune requires --merged, --gone, or --all")); } let git = cx.git.clone(); let git = git.as_ref(); @@ -138,9 +138,10 @@ pub(crate) fn run(cx: &mut Cx, args: &PruneArgs, json: bool) -> Result { } /// Selects local branches that have no worktree but qualify for pruning: merged -/// into the default branch (`--merged`) or with a gone upstream (`--gone`). The -/// default branch and the current branch are never selected, and branches that -/// already have a worktree are left to the worktree path. +/// into the default branch (`--merged`), with a gone upstream (`--gone`), or +/// either (`--all`). The default branch and the current branch are never +/// selected, and branches that already have a worktree are left to the worktree +/// path. fn branch_candidates( repo: &Repo, args: &PruneArgs, @@ -161,7 +162,7 @@ fn branch_candidates( .is_some_and(|d| is_ancestor(repo.gix(), &branch_ref(&branch), d)); let gone = upstream_of(repo.gix(), &branch).is_some_and(|u| u.is_gone); tracing::trace!(branch = %branch, merged, gone, "prune: branch classified"); - if (args.merged && merged) || (args.gone && gone) { + if (args.includes_merged() && merged) || (args.includes_gone() && gone) { out.push(Candidate::Branch { name: branch, merged, @@ -277,7 +278,7 @@ fn is_candidate( args: &PruneArgs, default: &Option, ) -> bool { - if args.merged + if args.includes_merged() && let Some(branch) = &worktree.branch && let Some(default) = default // The default branch is an ancestor of itself; never prune a worktree @@ -287,7 +288,7 @@ fn is_candidate( { return true; } - if args.gone && (worktree.is_missing || upstream_is_gone(repo, worktree)) { + if args.includes_gone() && (worktree.is_missing || upstream_is_gone(repo, worktree)) { return true; } false @@ -345,11 +346,20 @@ mod tests { PruneArgs { merged, gone, + all: false, dry_run, force, } } + /// `--all` alone (no `--merged`/`--gone`), as a dry run. + fn all_dry_run() -> PruneArgs { + PruneArgs { + all: true, + ..prune_args(false, false, true, false) + } + } + /// A branch at the current tip — an ancestor of the default branch (merged), /// with no worktree. fn bare_branch(repo: &TestRepo, name: &str) { @@ -563,6 +573,49 @@ mod tests { assert!(repo.git(&["branch", "--list", "wip"]).trim().is_empty()); } + #[test] + fn all_selects_merged_and_gone() { + let repo = TestRepo::init(); + bare_branch(&repo, "old"); // merged only + diverged_branch(&repo, "wip"); // gone only + give_gone_upstream(&repo, "wip"); + let report = |args: &PruneArgs| { + let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap()); + super::run(&mut t.cx, args, false).unwrap(); + t.out.contents() + }; + let all = report(&all_dry_run()); + assert!(all.contains("would remove old (branch)"), "{all}"); + assert!(all.contains("would remove wip (branch)"), "{all}"); + // Same selection as `--merged --gone`; each mode alone picks only one. + assert_eq!(all, report(&prune_args(true, true, true, false))); + assert!(!report(&prune_args(true, false, true, false)).contains("wip")); + assert!(!report(&prune_args(false, true, true, false)).contains("old")); + // Nothing was removed. + assert!(repo.git(&["branch", "--list", "old"]).contains("old")); + assert!(repo.git(&["branch", "--list", "wip"]).contains("wip")); + } + + #[test] + fn all_selects_merged_and_missing_worktrees() { + // `--all` covers both worktree paths: a merged worktree, and a missing + // one whose branch is unmerged (so only the `--gone` path selects it). + let repo = TestRepo::init(); + make_wt(&repo, "done"); + make_unmerged_wt(&repo, "lost"); + std::fs::remove_dir_all(wt_dir(&repo, "lost")).unwrap(); + let report = |args: &PruneArgs| { + let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap()); + super::run(&mut t.cx, args, false).unwrap(); + t.out.contents() + }; + let all = report(&all_dry_run()); + assert!(all.contains("would remove done\n"), "{all}"); + assert!(all.contains("would remove lost\n"), "{all}"); + assert!(!report(&prune_args(true, false, true, false)).contains("lost")); + assert!(!report(&prune_args(false, true, true, false)).contains("done")); + } + #[test] fn default_and_current_never_deleted() { let repo = TestRepo::init(); // only `main` (default + current) From 03ce7492d29e31b56bfe8db989457d38ba83a8a0 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 22 Sep 2026 21:16:02 -0400 Subject: [PATCH 2/3] feat(prune): make --all delete every branch recoverable from a remote `--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/`. - Branch deletion keys on recoverability (`rev-list --not --remotes ` 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. --- src/cli.rs | 47 +++- src/commands/prune.rs | 566 +++++++++++++++++++++++++++++++++++------ src/git/aheadbehind.rs | 72 +++++- src/git/mod.rs | 4 +- src/git/ops.rs | 15 ++ src/git/refs.rs | 14 + 6 files changed, 635 insertions(+), 83 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 0d95d4e..a3dcdd6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -354,15 +354,24 @@ pub(crate) struct DropArgs { /// Arguments for `wt prune`. #[derive(Debug, Args)] pub(crate) struct PruneArgs { - /// Include worktrees whose branch is merged into the default branch. + /// Include worktrees and branches merged into the default branch (local or + /// its `origin` tracking ref). #[arg(long)] pub(crate) merged: bool, - /// Include worktrees whose upstream is gone, and missing worktrees. + /// Include worktrees and branches whose upstream is gone, and missing worktrees. #[arg(long)] pub(crate) gone: bool, - /// Include both merged and gone candidates (same as `--merged --gone`). + /// Include branches (not worktrees) whose every commit is on a remote or the + /// default branch. + #[arg(long)] + pub(crate) pushed: bool, + /// Include everything deletable without losing a commit: merged and gone + /// worktrees, plus every merged, pushed, or gone branch whose commits survive. #[arg(short = 'a', long)] pub(crate) all: bool, + /// Trust the last fetch instead of running `git fetch --all --prune` first. + #[arg(long = "no-fetch")] + pub(crate) no_fetch: bool, /// Report candidates without removing anything. #[arg(long = "dry-run")] pub(crate) dry_run: bool, @@ -381,6 +390,17 @@ impl PruneArgs { pub(crate) fn includes_gone(&self) -> bool { self.gone || self.all } + + /// Whether pushed branch candidates are selected (`--pushed` or `--all`). + pub(crate) fn includes_pushed(&self) -> bool { + self.pushed || self.all + } + + /// Whether prune must fetch before selecting: a mode that reads remote state + /// is on (`--gone`, `--pushed`, or `--all`) and `--no-fetch` is not. + pub(crate) fn needs_fetch(&self) -> bool { + !self.no_fetch && (self.gone || self.pushed || self.all) + } } /// Arguments for `wt pr`. @@ -830,7 +850,8 @@ mod tests { match parse(&["prune", flag]).unwrap().command { Some(Command::Prune(a)) => { assert!(a.all); - assert!(a.includes_merged() && a.includes_gone()); + assert!(a.includes_merged() && a.includes_gone() && a.includes_pushed()); + assert!(a.needs_fetch()); } _ => panic!("expected prune for {flag}"), } @@ -842,11 +863,29 @@ mod tests { Some(Command::Prune(a)) => { assert!(!a.includes_merged()); assert!(a.includes_gone()); + assert!(!a.includes_pushed()); } _ => panic!("expected prune"), } } + #[test] + fn prune_pushed_and_no_fetch_parse() { + let prune = |args: &[&str]| match parse(args).unwrap().command { + Some(Command::Prune(a)) => a, + _ => panic!("expected prune for {args:?}"), + }; + let pushed = prune(&["prune", "--pushed"]); + assert!(pushed.includes_pushed()); + assert!(!pushed.includes_merged() && !pushed.includes_gone()); + // Remote-reading modes fetch unless told not to; `--merged` stays offline. + assert!(pushed.needs_fetch()); + assert!(prune(&["prune", "--gone"]).needs_fetch()); + assert!(!prune(&["prune", "--merged"]).needs_fetch()); + assert!(!prune(&["prune", "--all", "--no-fetch"]).needs_fetch()); + assert!(!prune(&["prune", "--pushed", "--no-fetch"]).needs_fetch()); + } + #[test] fn pr_forms_parse_distinctly() { // `pr list` -> the list sub-form. diff --git a/src/commands/prune.rs b/src/commands/prune.rs index 823776f..36f7ad5 100644 --- a/src/commands/prune.rs +++ b/src/commands/prune.rs @@ -9,10 +9,12 @@ use crate::commands::{Session, candidate_label, confirm, open_session, run_best_ use crate::config::wtconfig; use crate::cx::Cx; use crate::error::{Error, Result}; +use crate::git::aheadbehind::is_recoverable; use crate::git::cli::GitCli; use crate::git::discover::Repo; use crate::git::{ - branch_ref, current_branch, default_branch, is_ancestor, local_branches, ops, upstream_of, + branch_ref, current_branch, default_branch, default_tracking_ref, is_ancestor, local_branches, + ops, resolve_hex, upstream_of, }; use crate::model::Worktree; use crate::worktree::{build_worktrees, guard_status, lock_repo}; @@ -23,47 +25,115 @@ enum Candidate { /// An existing worktree, identified by its index in the worktree list. Worktree(usize), /// A local branch with no worktree. `merged` records whether it is an - /// ancestor of the default branch (a safe `git branch -d`); a gone-only - /// branch (`merged == false`) may hold unmerged commits and needs `--force`. - Branch { name: String, merged: bool }, + /// ancestor of the default branch; `safe` whether every commit on it is also + /// on a remote-tracking ref or the default branch, so deleting it loses + /// nothing. An unsafe branch (a gone branch with local-only commits) needs + /// `--force`. + Branch { + name: String, + merged: bool, + safe: bool, + }, +} + +/// The refs a branch counts as merged into: the local default branch and, when +/// `origin/HEAD` is set, its remote-tracking ref — so work merged on the remote +/// is seen even while the local default lags behind. +struct MergeTargets { + /// The default branch's short name (never itself a prune candidate). + default: Option, + /// Full refs to test ancestry against, each known to resolve. + refs: Vec, +} + +impl MergeTargets { + fn resolve(repo: &Repo) -> Self { + let default = default_branch(repo.gix()); + let refs = default + .as_deref() + .map(branch_ref) + .into_iter() + .chain(default_tracking_ref(repo.gix())) + .filter(|r| resolve_hex(repo.gix(), r).is_some()) + .collect(); + MergeTargets { default, refs } + } + + /// Whether `branch` is an ancestor of any target. The default branch itself + /// is never "merged" (it is trivially its own ancestor). + fn is_merged(&self, repo: &Repo, branch: &str) -> bool { + self.default.as_deref() != Some(branch) + && self + .refs + .iter() + .any(|target| is_ancestor(repo.gix(), &branch_ref(branch), target)) + } + + /// The local default branch ref, if it resolves — the one non-remote ref + /// whose commits count as kept by the recoverability check. + fn local_default_ref(&self) -> Option<&str> { + let local = branch_ref(self.default.as_deref()?); + self.refs.iter().find(|r| **r == local).map(String::as_str) + } } /// Selects and removes prune candidates after confirmation (spec §7/§12). pub(crate) fn run(cx: &mut Cx, args: &PruneArgs, json: bool) -> Result { - if !args.includes_merged() && !args.includes_gone() { - return Err(Error::usage("prune requires --merged, --gone, or --all")); + if !args.includes_merged() && !args.includes_gone() && !args.includes_pushed() { + return Err(Error::usage( + "prune requires --merged, --gone, --pushed, or --all", + )); } let git = cx.git.clone(); let git = git.as_ref(); let session = open_session(cx, git)?; let root = session.primary_root.clone(); + // "Gone" and "still on the remote" are only as true as the remote-tracking + // refs, so the modes that read them refresh them first (dry runs included, + // so a preview never promises something the real run would not do). + if args.needs_fetch() { + fetch_remotes(git, &session.repo, &root)?; + } let worktrees = build_worktrees(&session.repo, git)?; - let default = default_branch(session.repo.gix()); + let targets = MergeTargets::resolve(&session.repo); let current = current_branch(session.repo.gix()); - // Branches that already have a worktree (the primary checkout and any branch - // checked out elsewhere) are handled by the worktree path; the bare-branch - // path skips them so a branch is never counted — or deleted — twice. - let worktree_branches: HashSet = - worktrees.iter().filter_map(|w| w.branch.clone()).collect(); - let mut candidates: Vec = worktrees .iter() .enumerate() - .filter(|(_, w)| !w.is_main && is_candidate(&session.repo, w, args, &default)) + .filter(|(_, w)| !w.is_main && is_candidate(&session.repo, w, args, &targets)) .map(|(i, _)| Candidate::Worktree(i)) .collect(); + + // Branches that keep a worktree (the primary checkout and any branch checked + // out elsewhere) are left to the worktree path, so a branch is never counted + // — or deleted — twice. Under `--all` the branch of a worktree being removed + // is also judged as a bare branch, so one pass clears both. + let worktree_branches: HashSet = worktrees + .iter() + .enumerate() + .filter(|(i, _)| { + !(args.all + && candidates + .iter() + .any(|c| matches!(c, Candidate::Worktree(j) if j == i))) + }) + .filter_map(|(_, w)| w.branch.clone()) + .collect(); candidates.extend(branch_candidates( + git, + &root, &session.repo, args, - &default, + &targets, ¤t, &worktree_branches, )?); // The worktree/branch gap is the signal when a prune surprises someone. tracing::debug!( - default = ?default, + default = ?targets.default, + merge_targets = ?targets.refs, worktrees = worktrees.len(), candidates = candidates.len(), local_branches = local_branches(session.repo.gix()).map_or(0, |b| b.len()), @@ -115,13 +185,30 @@ pub(crate) fn run(cx: &mut Cx, args: &PruneArgs, json: bool) -> Result { // #99). `prune` runs no hooks, so nothing inside can re-enter `wt`. let lock = lock_repo(&root)?; let mut removed = 0_usize; + // Branches whose worktree was skipped (dirty) stay checked out, so their + // `--all` branch candidate is skipped too. Worktree candidates come first. + let mut kept: HashSet<&str> = HashSet::new(); + let mut reconciled = false; for candidate in &candidates { + // A missing worktree stays registered until Git's worktree metadata is + // reconciled, and Git refuses to delete a branch it still sees checked + // out — so reconcile once, after the worktrees and before the branches. + if !reconciled && matches!(candidate, Candidate::Branch { .. }) { + ops::worktree_prune(git, &root)?; + reconciled = true; + } let pruned = match candidate { Candidate::Worktree(index) => { - remove_worktree(cx, git, &session, &root, &worktrees[*index], args, &default)? + let worktree = &worktrees[*index]; + let pruned = remove_worktree(cx, git, &session, &root, worktree, args, &targets)?; + if !pruned && let Some(branch) = &worktree.branch { + kept.insert(branch); + } + pruned } - Candidate::Branch { name, merged } => { - remove_branch(cx, git, &root, name, *merged, args.force)? + Candidate::Branch { name, .. } if kept.contains(name.as_str()) => false, + Candidate::Branch { name, safe, .. } => { + remove_branch(cx, git, &root, name, *safe, args.force, &targets)? } }; if pruned { @@ -137,41 +224,76 @@ pub(crate) fn run(cx: &mut Cx, args: &PruneArgs, json: bool) -> Result { Ok(0) } +/// Runs `git fetch --all --prune` when the repository has any remote, so the +/// remote-tracking refs prune reads reflect the remote now. A failed fetch aborts +/// rather than letting a stale ref vouch for commits the remote no longer has. +fn fetch_remotes(git: &dyn GitCli, repo: &Repo, root: &Path) -> Result<()> { + if repo.gix().remote_names().is_empty() { + tracing::debug!("prune: no remotes; skipping fetch"); + return Ok(()); + } + tracing::debug!("prune: fetching remotes"); + ops::fetch_all_prune(git, root).map_err(|e| { + Error::operation(format!( + "cannot fetch remotes: {e}\n(pass --no-fetch to trust the last fetch)" + )) + })?; + Ok(()) +} + /// Selects local branches that have no worktree but qualify for pruning: merged -/// into the default branch (`--merged`), with a gone upstream (`--gone`), or -/// either (`--all`). The default branch and the current branch are never -/// selected, and branches that already have a worktree are left to the worktree -/// path. +/// into the default branch (`--merged`), with a gone upstream (`--gone`), with +/// every commit on a remote or the default branch (`--pushed`), or any of these +/// (`--all`). The default branch and the current branch are never selected, and +/// branches that keep a worktree are left to the worktree path. fn branch_candidates( + git: &dyn GitCli, + root: &Path, repo: &Repo, args: &PruneArgs, - default: &Option, + targets: &MergeTargets, current: &Option, worktree_branches: &HashSet, ) -> Result> { + let keep: Vec<&str> = targets.local_default_ref().into_iter().collect(); let mut out = Vec::new(); for branch in local_branches(repo.gix())? { if worktree_branches.contains(&branch) - || default.as_deref() == Some(branch.as_str()) + || targets.default.as_deref() == Some(branch.as_str()) || current.as_deref() == Some(branch.as_str()) { continue; } - let merged = default - .as_deref() - .is_some_and(|d| is_ancestor(repo.gix(), &branch_ref(&branch), d)); + let merged = targets.is_merged(repo, &branch); let gone = upstream_of(repo.gix(), &branch).is_some_and(|u| u.is_gone); - tracing::trace!(branch = %branch, merged, gone, "prune: branch classified"); - if (args.includes_merged() && merged) || (args.includes_gone() && gone) { + let by_mode = (args.includes_merged() && merged) || (args.includes_gone() && gone); + // The recoverability check costs a subprocess; skip it for a branch no + // selected mode could pick. + if !by_mode && !args.includes_pushed() { + continue; + } + let safe = branch_is_recoverable(git, root, &branch, &keep); + tracing::trace!(branch = %branch, merged, gone, safe, "prune: branch classified"); + if by_mode || (args.includes_pushed() && safe) { out.push(Candidate::Branch { name: branch, merged, + safe, }); } } Ok(out) } +/// [`is_recoverable`] for a local branch, treating a failed check as unsafe so a +/// git error can never be the reason a branch is deleted. +fn branch_is_recoverable(git: &dyn GitCli, root: &Path, branch: &str, keep: &[&str]) -> bool { + is_recoverable(git, root, &branch_ref(branch), keep).unwrap_or_else(|e| { + tracing::warn!(branch = %branch, error = %e, "prune: recoverability check failed"); + false + }) +} + /// Removes one worktree candidate, returning whether it was removed (a dirty /// worktree is skipped unless `--force`). This is the per-worktree body of the /// prune loop (spec §12). @@ -182,7 +304,7 @@ fn remove_worktree( root: &Path, worktree: &Worktree, args: &PruneArgs, - default: &Option, + targets: &MergeTargets, ) -> Result { // Dirty worktrees are skipped unless --force (spec §12). if !args.force && guard_status(worktree, session.config.remove_untracked_blocks).dirty { @@ -201,7 +323,7 @@ fn remove_worktree( "prune: worktree remove", ); } - delete_merged_branch(git, &session.repo, root, worktree, &session.config, default); + delete_merged_branch(git, &session.repo, root, worktree, &session.config, targets); if let Some(branch) = &worktree.branch { let _ = wtconfig::clear_meta(git, root, branch); } @@ -209,30 +331,38 @@ fn remove_worktree( Ok(true) } -/// Deletes one bare-branch candidate, returning whether it was deleted. A merged -/// branch is removed with `git branch -d`; a gone-only branch (not merged into -/// the default) may hold unmerged commits, so it is skipped unless `--force`, -/// which force-deletes it (`git branch -D`). A delete failure is reported and -/// skipped rather than aborting the whole prune. +/// Deletes one bare-branch candidate, returning whether it was deleted. A safe +/// branch — every commit also on a remote or the default branch — is re-checked +/// under the lock (it may have moved since selection) and then deleted with +/// `git branch -D`, since `-d` only knows about HEAD and the upstream. An unsafe +/// branch may hold commits found nowhere else, so it is skipped unless `--force`. +/// A branch already gone (deleted with its worktree) is skipped quietly, and a +/// delete failure is reported and skipped rather than aborting the whole prune. fn remove_branch( cx: &mut Cx, git: &dyn GitCli, root: &Path, name: &str, - merged: bool, + safe: bool, force: bool, + targets: &MergeTargets, ) -> Result { - if !merged && !force { + if !branch_exists(git, root, name) { + tracing::debug!(branch = %name, "prune: branch already deleted"); + return Ok(false); + } + let keep: Vec<&str> = targets.local_default_ref().into_iter().collect(); + if !force && !(safe && branch_is_recoverable(git, root, name, &keep)) { cx.err.line(&format!( - "skipping {name}: branch may have unmerged commits; use --force" + "skipping {name}: branch has commits on no remote or default branch; use --force" ))?; - tracing::debug!(branch = %name, "prune: skip protected gone branch"); + tracing::debug!(branch = %name, "prune: skip unrecoverable branch"); return Ok(false); } - match ops::delete_branch(git, root, name, !merged) { + match ops::delete_branch(git, root, name, true) { Ok(out) if out.success => { let _ = wtconfig::clear_meta(git, root, name); - tracing::debug!(branch = %name, merged, "prune: deleted branch"); + tracing::debug!(branch = %name, safe, "prune: deleted branch"); Ok(true) } Ok(out) => { @@ -249,6 +379,15 @@ fn remove_branch( } } +/// Whether the local branch `name` still exists. +fn branch_exists(git: &dyn GitCli, root: &Path, name: &str) -> bool { + git.run_raw( + root, + &["rev-parse", "--verify", "--quiet", &branch_ref(name)], + ) + .is_ok_and(|out| out.success) +} + /// A human label for a prune candidate (worktree or bare branch). fn candidate_text(worktrees: &[Worktree], candidate: &Candidate) -> String { match candidate { @@ -262,10 +401,11 @@ fn candidate_text(worktrees: &[Worktree], candidate: &Candidate) -> String { fn candidate_json(worktrees: &[Worktree], candidate: &Candidate) -> Result { match candidate { Candidate::Worktree(index) => worktrees[*index].to_json_line(), - Candidate::Branch { name, merged } => Ok(serde_json::json!({ + Candidate::Branch { name, merged, safe } => Ok(serde_json::json!({ "branch": name, "kind": "branch", "merged": merged, + "safe": safe, }) .to_string()), } @@ -276,15 +416,13 @@ fn is_candidate( repo: &crate::git::discover::Repo, worktree: &Worktree, args: &PruneArgs, - default: &Option, + targets: &MergeTargets, ) -> bool { + // `is_merged` never counts the default branch itself, so a worktree checked + // out on the default branch is never pruned as merged. if args.includes_merged() && let Some(branch) = &worktree.branch - && let Some(default) = default - // The default branch is an ancestor of itself; never prune a worktree - // that is checked out on the default branch. - && branch != default - && is_ancestor(repo.gix(), &branch_ref(branch), default) + && targets.is_merged(repo, branch) { return true; } @@ -310,7 +448,7 @@ fn delete_merged_branch( root: &Path, worktree: &Worktree, config: &crate::config::Config, - default: &Option, + targets: &MergeTargets, ) { let Some(branch) = &worktree.branch else { return; @@ -322,10 +460,7 @@ fn delete_merged_branch( if !meta.created_by_wt { return; } - let merged = default - .as_deref() - .is_some_and(|d| is_ancestor(repo.gix(), &branch_ref(branch), d)); - if merged { + if targets.is_merged(repo, branch) { run_best_effort( git, root, @@ -340,13 +475,15 @@ mod tests { use crate::cli::PruneArgs; use crate::error::Error; use crate::git::cli::RealGit; - use crate::testutil::{CannedInput, TestRepo, make_wt, wt_dir}; + use crate::testutil::{CannedInput, TestRepo, give_upstream, make_wt, wt_dir}; fn prune_args(merged: bool, gone: bool, dry_run: bool, force: bool) -> PruneArgs { PruneArgs { merged, gone, + pushed: false, all: false, + no_fetch: false, dry_run, force, } @@ -360,6 +497,51 @@ mod tests { } } + /// `--pushed` alone, as a dry run. + fn pushed_dry_run() -> PruneArgs { + PruneArgs { + pushed: true, + ..prune_args(false, false, true, false) + } + } + + /// `--all` for real, without `--force` (the confirmation is answered by `-y`). + fn all_run() -> PruneArgs { + PruneArgs { + all: true, + ..prune_args(false, false, false, false) + } + } + + /// Runs prune with `args` and returns stdout (the dry-run report). + fn report(repo: &TestRepo, args: &PruneArgs) -> String { + let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap()); + super::run(&mut t.cx, args, false).unwrap(); + t.out.contents() + } + + /// Runs prune with `args`, answering the prompt with `-y`, and returns stderr. + fn run_yes(repo: &TestRepo, args: &PruneArgs) -> String { + let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap()); + t.cx.assume_yes = true; + super::run(&mut t.cx, args, false).unwrap(); + t.err.contents() + } + + /// Records a remote-tracking ref `refs/remotes/origin/` at + /// `name`'s tip, as if the branch had been pushed there. + fn push_tip(repo: &TestRepo, name: &str, remote_name: &str) { + repo.git(&[ + "update-ref", + &format!("refs/remotes/origin/{remote_name}"), + &format!("refs/heads/{name}"), + ]); + } + + fn has_branch(repo: &TestRepo, name: &str) -> bool { + !repo.git(&["branch", "--list", name]).trim().is_empty() + } + /// A branch at the current tip — an ancestor of the default branch (merged), /// with no worktree. fn bare_branch(repo: &TestRepo, name: &str) { @@ -577,23 +759,24 @@ mod tests { fn all_selects_merged_and_gone() { let repo = TestRepo::init(); bare_branch(&repo, "old"); // merged only - diverged_branch(&repo, "wip"); // gone only + diverged_branch(&repo, "wip"); // gone only, commits nowhere else give_gone_upstream(&repo, "wip"); - let report = |args: &PruneArgs| { - let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap()); - super::run(&mut t.cx, args, false).unwrap(); - t.out.contents() - }; - let all = report(&all_dry_run()); + let all = report(&repo, &all_dry_run()); assert!(all.contains("would remove old (branch)"), "{all}"); assert!(all.contains("would remove wip (branch)"), "{all}"); - // Same selection as `--merged --gone`; each mode alone picks only one. - assert_eq!(all, report(&prune_args(true, true, true, false))); - assert!(!report(&prune_args(true, false, true, false)).contains("wip")); - assert!(!report(&prune_args(false, true, true, false)).contains("old")); + // Same selection as `--merged --gone` here; each mode alone picks one. + assert_eq!(all, report(&repo, &prune_args(true, true, true, false))); + assert!(!report(&repo, &prune_args(true, false, true, false)).contains("wip")); + assert!(!report(&repo, &prune_args(false, true, true, false)).contains("old")); // Nothing was removed. - assert!(repo.git(&["branch", "--list", "old"]).contains("old")); - assert!(repo.git(&["branch", "--list", "wip"]).contains("wip")); + assert!(has_branch(&repo, "old")); + assert!(has_branch(&repo, "wip")); + // A real `--all` deletes the merged branch but keeps the gone branch whose + // commits exist nowhere else. + let err = run_yes(&repo, &all_run()); + assert!(err.contains("skipping wip"), "{err}"); + assert!(!has_branch(&repo, "old")); + assert!(has_branch(&repo, "wip")); } #[test] @@ -604,16 +787,243 @@ mod tests { make_wt(&repo, "done"); make_unmerged_wt(&repo, "lost"); std::fs::remove_dir_all(wt_dir(&repo, "lost")).unwrap(); - let report = |args: &PruneArgs| { - let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap()); - super::run(&mut t.cx, args, false).unwrap(); - t.out.contents() - }; - let all = report(&all_dry_run()); + let all = report(&repo, &all_dry_run()); assert!(all.contains("would remove done\n"), "{all}"); assert!(all.contains("would remove lost\n"), "{all}"); - assert!(!report(&prune_args(true, false, true, false)).contains("lost")); - assert!(!report(&prune_args(false, true, true, false)).contains("done")); + assert!(!report(&repo, &prune_args(true, false, true, false)).contains("lost")); + assert!(!report(&repo, &prune_args(false, true, true, false)).contains("done")); + } + + #[test] + fn pushed_branch_is_selected_by_pushed_and_all_only() { + // Diverged from main but every commit is on origin: neither merged nor + // gone, yet deleting it loses nothing. + let repo = TestRepo::init(); + diverged_branch(&repo, "feat"); + give_upstream(&repo, "feat"); + diverged_branch(&repo, "local"); // commits exist only here + let pushed = report(&repo, &pushed_dry_run()); + assert!(pushed.contains("would remove feat (branch)"), "{pushed}"); + assert!(!pushed.contains("local"), "{pushed}"); + let all = report(&repo, &all_dry_run()); + assert!(all.contains("would remove feat (branch)"), "{all}"); + assert!(!all.contains("local"), "{all}"); + assert!(!report(&repo, &prune_args(true, true, true, false)).contains("feat")); + // Deleted without --force; the local-only branch is untouched. + let err = run_yes(&repo, &all_run()); + assert!(!err.contains("skipping"), "{err}"); + assert!(!has_branch(&repo, "feat")); + assert!(has_branch(&repo, "local")); + } + + #[test] + fn pushed_branch_without_upstream_is_selected() { + // Pushed without `-u`: no upstream, but origin holds the tip. + let repo = TestRepo::init(); + diverged_branch(&repo, "feat"); + push_tip(&repo, "feat", "feat"); + let pushed = report(&repo, &pushed_dry_run()); + assert!(pushed.contains("would remove feat (branch)"), "{pushed}"); + } + + #[test] + fn pushed_branch_that_moved_since_push_is_not_selected() { + let repo = TestRepo::init(); + diverged_branch(&repo, "feat"); + give_upstream(&repo, "feat"); + repo.git(&["checkout", "-q", "feat"]); + repo.write("more.txt", "x\n"); + repo.commit_all("unpushed"); + repo.git(&["checkout", "-q", "main"]); + assert!(!report(&repo, &pushed_dry_run()).contains("feat")); + } + + #[test] + fn merged_counts_the_remote_default_branch() { + // Merged on origin while local main lags behind: `--merged` sees it via + // origin/HEAD's target. + let repo = TestRepo::init(); + diverged_branch(&repo, "feat"); + repo.git(&["update-ref", "refs/remotes/origin/main", "refs/heads/feat"]); + repo.git(&[ + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/main", + ]); + let merged = report(&repo, &prune_args(true, false, true, false)); + assert!(merged.contains("would remove feat (branch)"), "{merged}"); + // Deleted for real without --force. + run_yes(&repo, &prune_args(true, false, false, false)); + assert!(!has_branch(&repo, "feat")); + assert!(has_branch(&repo, "main")); + } + + #[test] + fn gone_branch_whose_commits_survive_needs_no_force() { + // Upstream deleted, but the commits live on under another remote ref. + let repo = TestRepo::init(); + diverged_branch(&repo, "shipped"); + give_gone_upstream(&repo, "shipped"); + push_tip(&repo, "shipped", "release"); + let err = run_yes(&repo, &prune_args(false, true, false, false)); + assert!(!err.contains("skipping"), "{err}"); + assert!(!has_branch(&repo, "shipped")); + } + + #[test] + fn all_keeps_the_worktree_of_a_live_pushed_branch() { + // Pushed and live (upstream present): the branch is recoverable, but the + // checkout is active work, so `--all` touches neither. + let repo = TestRepo::init(); + make_unmerged_wt(&repo, "live"); + give_upstream(&repo, "live"); + let all = report(&repo, &all_dry_run()); + assert!(!all.contains("live"), "{all}"); + } + + #[test] + fn all_removes_a_gone_worktree_and_its_recoverable_branch_in_one_pass() { + let repo = TestRepo::init(); + make_unmerged_wt(&repo, "shipped"); + give_gone_upstream(&repo, "shipped"); + push_tip(&repo, "shipped", "release"); + let all = report(&repo, &all_dry_run()); + assert!(all.contains("would remove shipped\n"), "{all}"); + assert!(all.contains("would remove shipped (branch)"), "{all}"); + // `--gone` alone removes the worktree and keeps the (unmerged) branch. + assert!(!report(&repo, &prune_args(false, true, true, false)).contains("(branch)")); + let err = run_yes(&repo, &all_run()); + assert!(err.contains("pruned 2 item(s)"), "{err}"); + assert!(!repo.git(&["worktree", "list"]).contains("shipped")); + assert!(!has_branch(&repo, "shipped")); + } + + #[test] + fn all_removes_a_merged_worktree_and_branch_without_noise() { + // The merged branch goes with its worktree; the branch candidate then + // finds it already gone and skips quietly. + let repo = TestRepo::init(); + make_wt(&repo, "done"); + let err = run_yes(&repo, &all_run()); + assert!(!err.contains("could not delete"), "{err}"); + assert!(!repo.git(&["worktree", "list"]).contains("done")); + assert!(!has_branch(&repo, "done")); + } + + #[test] + fn all_deletes_the_branch_of_a_missing_worktree_in_one_pass() { + // The missing worktree is still registered until Git reconciles its + // metadata; the branch delete must not trip over that. + let repo = TestRepo::init(); + make_wt(&repo, "done"); + std::fs::remove_dir_all(wt_dir(&repo, "done")).unwrap(); + let err = run_yes(&repo, &all_run()); + assert!(!err.contains("could not delete"), "{err}"); + assert!(!repo.git(&["worktree", "list"]).contains("done")); + assert!(!has_branch(&repo, "done")); + } + + #[test] + fn pushed_alone_never_selects_worktrees() { + let repo = TestRepo::init(); + make_unmerged_wt(&repo, "live"); + give_upstream(&repo, "live"); + std::fs::remove_dir_all(wt_dir(&repo, "live")).unwrap(); // even a missing one + assert!(!report(&repo, &pushed_dry_run()).contains("live")); + } + + #[test] + fn all_keeps_the_branch_of_a_skipped_dirty_worktree() { + let repo = TestRepo::init(); + make_unmerged_wt(&repo, "busy"); + give_gone_upstream(&repo, "busy"); + push_tip(&repo, "busy", "release"); + std::fs::write(wt_dir(&repo, "busy").join("README.md"), "dirty\n").unwrap(); + let err = run_yes(&repo, &all_run()); + assert!(err.contains("skipping dirty worktree busy"), "{err}"); + assert!(!err.contains("could not delete"), "{err}"); + assert!(has_branch(&repo, "busy")); + } + + #[test] + fn a_failed_recoverability_check_counts_as_unsafe() { + let repo = TestRepo::init(); + // `rev-list` fails on a branch that does not exist; that must never read + // as "safe to delete". + assert!(!super::branch_is_recoverable( + &RealGit, + repo.root(), + "no-such-branch", + &[] + )); + } + + #[test] + fn pushed_alone_satisfies_the_mode_requirement() { + let repo = TestRepo::init(); + let err = run_yes(&repo, &pushed_dry_run()); + assert!(err.contains("nothing to prune"), "{err}"); + } + + /// A clone-like repo: `main` and `feat` pushed to a bare `origin`, with + /// `feat` tracking `origin/feat`. Returns (local, remote). + fn repo_with_remote() -> (TestRepo, TestRepo) { + let remote = TestRepo::init_bare(); + let repo = TestRepo::init(); + repo.git(&["remote", "add", "origin", remote.root().to_str().unwrap()]); + diverged_branch(&repo, "feat"); + repo.git(&["push", "-q", "-u", "origin", "main", "feat"]); + repo.git(&["remote", "set-head", "origin", "main"]); + (repo, remote) + } + + fn json_branch(repo: &TestRepo, args: &PruneArgs, name: &str) -> Option { + let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap()); + super::run(&mut t.cx, args, true).unwrap(); + t.out + .contents() + .lines() + .map(|l| serde_json::from_str::(l).unwrap()) + .find(|v| v["branch"] == serde_json::json!(name)) + } + + #[test] + fn fetch_refreshes_remote_refs_before_judging_safety() { + // The branch was deleted on origin since the last fetch. The stale + // tracking ref would vouch for its commits; the fetch shows they are + // gone, so the branch is no longer safe to delete. + let (repo, remote) = repo_with_remote(); + remote.git(&["branch", "-D", "feat"]); + let offline = PruneArgs { + no_fetch: true, + ..all_dry_run() + }; + let stale = json_branch(&repo, &offline, "feat").expect("selected offline"); + assert_eq!(stale["safe"], serde_json::json!(true)); + let fresh = json_branch(&repo, &all_dry_run(), "feat").expect("selected as gone"); + assert_eq!(fresh["safe"], serde_json::json!(false)); + assert!(has_branch(&repo, "feat")); + } + + #[test] + fn failed_fetch_aborts_unless_no_fetch() { + let repo = TestRepo::init(); + repo.git(&[ + "remote", + "add", + "origin", + "/nonexistent/wt-prune-remote.git", + ]); + let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap()); + let err = super::run(&mut t.cx, &all_dry_run(), false).unwrap_err(); + assert!(err.to_string().contains("--no-fetch"), "{err}"); + // `--merged` alone reads no remote state, so it never fetches. + report(&repo, &prune_args(true, false, true, false)); + let offline = PruneArgs { + no_fetch: true, + ..all_dry_run() + }; + report(&repo, &offline); } #[test] @@ -672,6 +1082,8 @@ mod tests { let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap(); assert_eq!(v["branch"], serde_json::json!("old")); assert_eq!(v["kind"], serde_json::json!("branch")); + assert_eq!(v["merged"], serde_json::json!(true)); + assert_eq!(v["safe"], serde_json::json!(true)); // --json implies dry-run: still present. assert!(repo.git(&["branch", "--list", "old"]).contains("old")); } diff --git a/src/git/aheadbehind.rs b/src/git/aheadbehind.rs index b33b7e0..93c94ff 100644 --- a/src/git/aheadbehind.rs +++ b/src/git/aheadbehind.rs @@ -1,6 +1,8 @@ //! Ahead/behind computation via `git rev-list --left-right --count`. Ahead/behind //! is async-loaded data (spec §10), so this subprocess is outside the synchronous -//! listing fast-path; `git rev-list` is used for its exact correctness. +//! listing fast-path; `git rev-list` is used for its exact correctness. The same +//! `rev-list` reachability backs [`is_recoverable`], the "deleting this branch +//! loses no commit" check `wt prune` keys on. use std::path::Path; @@ -20,6 +22,24 @@ pub(crate) fn ahead_behind( parse_left_right(&output) } +/// Whether every commit on `branch_ref` is also reachable from a remote-tracking +/// ref or from one of `keep` (e.g. the local default branch), run in `dir` — i.e. +/// deleting the branch loses no commit. True exactly when +/// `git rev-list -n1 --not --remotes ...` prints nothing. Only +/// as current as the remote-tracking refs, so callers fetch first. +#[cfg_attr(not(feature = "cli"), allow(dead_code))] +pub(crate) fn is_recoverable( + git: &dyn GitCli, + dir: &Path, + branch_ref: &str, + keep: &[&str], +) -> Result { + let mut args = vec!["rev-list", "-n1", branch_ref, "--not", "--remotes"]; + args.extend_from_slice(keep); + let output = git.run(dir, &args)?; + Ok(output.trim().is_empty()) +} + /// Parses the `\t` output of `rev-list --left-right --count` /// (left = commits only in the upstream = behind; right = only in branch = ahead). fn parse_left_right(text: &str) -> Result<(u32, u32)> { @@ -88,4 +108,54 @@ mod tests { .unwrap(); assert_eq!((ahead, behind), (0, 1)); } + + #[test] + fn recoverable_when_a_remote_ref_holds_every_commit() { + let repo = TestRepo::init(); + repo.git(&["checkout", "-q", "-b", "topic"]); + repo.write("t.txt", "1\n"); + repo.commit_all("t1"); + repo.git(&["checkout", "-q", "main"]); + let recoverable = |keep: &[&str]| { + is_recoverable(&RealGit, repo.root(), "refs/heads/topic", keep).unwrap() + }; + // Its commit exists nowhere else yet. + assert!(!recoverable(&[])); + assert!(!recoverable(&["refs/heads/main"])); + // Once a remote-tracking ref holds the tip, nothing would be lost. + repo.git(&[ + "update-ref", + "refs/remotes/origin/topic", + "refs/heads/topic", + ]); + assert!(recoverable(&[])); + // A new local commit on top is unique again. + repo.git(&["checkout", "-q", "topic"]); + repo.write("t.txt", "2\n"); + repo.commit_all("t2"); + repo.git(&["checkout", "-q", "main"]); + assert!(!recoverable(&[])); + } + + #[test] + fn recoverable_when_a_kept_ref_holds_every_commit() { + let repo = TestRepo::init(); + repo.git(&["branch", "old"]); // at main's tip, no remote refs at all + assert!(!is_recoverable(&RealGit, repo.root(), "refs/heads/old", &[]).unwrap()); + assert!( + is_recoverable( + &RealGit, + repo.root(), + "refs/heads/old", + &["refs/heads/main"] + ) + .unwrap() + ); + } + + #[test] + fn recoverable_errors_on_an_unknown_ref() { + let repo = TestRepo::init(); + assert!(is_recoverable(&RealGit, repo.root(), "refs/heads/nope", &[]).is_err()); + } } diff --git a/src/git/mod.rs b/src/git/mod.rs index 26977ec..93c29b5 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -30,7 +30,9 @@ pub(crate) use refs::{ // `default_base_ref` moved here from the TUI group when `wt issue` (a CLI // command) started resolving its base from `origin/HEAD`. #[cfg(feature = "cli")] -pub(crate) use refs::{all_branches, current_branch, default_base_ref, remote_branches}; +pub(crate) use refs::{ + all_branches, current_branch, default_base_ref, default_tracking_ref, remote_branches, +}; // Only the TUI reaches for this. #[cfg(feature = "tui")] pub(crate) use refs::origin_head_branch; diff --git a/src/git/ops.rs b/src/git/ops.rs index 83ed10c..ffd1e4c 100644 --- a/src/git/ops.rs +++ b/src/git/ops.rs @@ -130,6 +130,14 @@ pub(crate) fn fetch_refspec( git.run(dir, &["fetch", remote, refspec]) } +/// Fetches every remote and prunes remote-tracking refs whose branch was deleted +/// (`git fetch --all --prune`), so both "gone" and "still on the remote" read +/// the remote's current state. +#[cfg_attr(not(feature = "cli"), allow(dead_code))] +pub(crate) fn fetch_all_prune(git: &dyn GitCli, dir: &Path) -> Result { + git.run(dir, &["fetch", "--all", "--prune"]) +} + /// Fast-forwards the branch checked out at `dir` to `tracking_ref` /// (`git merge --ff-only`). Errors if the merge is not a fast-forward. #[cfg_attr(not(feature = "cli"), allow(dead_code))] @@ -305,6 +313,13 @@ mod tests { assert_eq!(git.last(), ["fetch", "origin", "pull/7/head"]); } + #[test] + fn fetch_all_prune_fetches_every_remote_with_prune() { + let git = RecordingGit::new(); + fetch_all_prune(&git, &root()).unwrap(); + assert_eq!(git.last(), ["fetch", "--all", "--prune"]); + } + #[test] fn merge_ff_only_builds_fast_forward_merge() { let git = RecordingGit::new(); diff --git a/src/git/refs.rs b/src/git/refs.rs index e5e1ba7..3fc5f5f 100644 --- a/src/git/refs.rs +++ b/src/git/refs.rs @@ -152,6 +152,15 @@ pub(crate) fn default_base_ref(repo: &gix::Repository) -> Option { origin_head_tracking(repo) } +/// The full remote-tracking ref of the default branch, e.g. +/// `refs/remotes/origin/main`, from the `origin/HEAD` target. Lets a merge check +/// see work merged on the remote even while the local default lags behind. +/// `None` when `origin/HEAD` is unset. +#[cfg_attr(not(feature = "cli"), allow(dead_code))] +pub(crate) fn default_tracking_ref(repo: &gix::Repository) -> Option { + origin_head_tracking(repo).map(|tracking| format!("refs/remotes/{tracking}")) +} + /// The current branch name, or `None` for a detached HEAD or unborn branch. pub(crate) fn current_branch(repo: &gix::Repository) -> Option { let head = repo.head().ok()?; @@ -330,6 +339,10 @@ mod tests { ]); let r = Repo::discover(repo.root()).unwrap(); assert_eq!(default_base_ref(r.gix()).as_deref(), Some("origin/main")); + assert_eq!( + default_tracking_ref(r.gix()).as_deref(), + Some("refs/remotes/origin/main") + ); } #[test] @@ -339,6 +352,7 @@ mod tests { let repo = TestRepo::init(); let r = Repo::discover(repo.root()).unwrap(); assert_eq!(default_base_ref(r.gix()), None); + assert_eq!(default_tracking_ref(r.gix()), None); } #[test] From 762f1e8153ef313ff9d2bd344f93bcd39e47aae6 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 22 Sep 2026 21:16:03 -0400 Subject: [PATCH 3/3] docs(readme): describe prune --pushed, --all, and the default fetch --- README.md | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8481677..68b923b 100644 --- a/README.md +++ b/README.md @@ -220,14 +220,25 @@ These are the things worth knowing up front; the rest is discoverable from current directory (from any depth), keeps the branch, and `cd`s you back to the main worktree. It refuses the primary worktree and honors the same `--force` guard. -- **Bulk-clean stale branches.** `wt prune --merged` removes worktrees whose branch - is merged into the default branch, and `wt prune --gone` removes worktrees whose - upstream was deleted (plus any missing worktrees); `wt prune --all` (`-a`) does - both at once. Every mode also deletes matching **local branches that no longer - have a worktree** — so a repo left with a pile of merged feature branches gets - cleaned up too. Preview with `--dry-run`. A `--gone` - branch that isn't also merged may hold unmerged commits, so it is skipped unless - you pass `--force`. The current and default branches are never touched. +- **Bulk-clean stale branches.** `wt prune --all` (`-a`) deletes every local branch + you can drop without losing a commit, meaning each of its commits is also on a + remote or on the default branch. It also removes worktrees whose work is + finished. You can pick the modes one at a time: + - `--merged`: worktrees and branches merged into the default branch, either the + local copy or `origin`'s. + - `--gone`: worktrees and branches whose upstream was deleted, plus any missing + worktrees. + - `--pushed`: branches (never worktrees) whose every commit is already on a + remote, such as an open PR's branch. + + Every mode deletes matching **local branches that have no worktree**, so a pile of + old feature branches gets cleaned up too. Under `--all`, the branch of a removed + worktree goes with it. A branch holding commits that exist nowhere else is + skipped unless you pass `--force`. This includes a `--gone` branch that was never + merged. The modes that read remote state (`--gone`, `--pushed`, `--all`) run + `git fetch --all --prune` first, so "still on the remote" means the remote now. + Pass `--no-fetch` to trust the last fetch. Preview with `--dry-run`. The current + and default branches are never touched. ## Using wt as a library