diff --git a/README.md b/README.md index eaee1d1..68b923b 100644 --- a/README.md +++ b/README.md @@ -220,13 +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). 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` - 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 diff --git a/src/cli.rs b/src/cli.rs index dc9e7f3..a3dcdd6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -354,12 +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 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, @@ -368,6 +380,29 @@ 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 + } + + /// 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`. #[derive(Debug, Args)] pub(crate) struct PrArgs { @@ -809,6 +844,48 @@ 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() && a.includes_pushed()); + assert!(a.needs_fetch()); + } + _ => 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()); + 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 3ea3cc2..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.merged && !args.gone { - return Err(Error::usage("prune requires --merged and/or --gone")); + 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,40 +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`) 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`), 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.merged && merged) || (args.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). @@ -181,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 { @@ -200,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); } @@ -208,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) => { @@ -248,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 { @@ -261,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()), } @@ -275,19 +416,17 @@ fn is_candidate( repo: &crate::git::discover::Repo, worktree: &Worktree, args: &PruneArgs, - default: &Option, + targets: &MergeTargets, ) -> bool { - if args.merged + // `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; } - 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 @@ -309,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; @@ -321,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, @@ -339,17 +475,73 @@ 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, } } + /// `--all` alone (no `--merged`/`--gone`), as a dry run. + fn all_dry_run() -> PruneArgs { + PruneArgs { + all: true, + ..prune_args(false, false, true, false) + } + } + + /// `--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) { @@ -563,6 +755,277 @@ 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, commits nowhere else + give_gone_upstream(&repo, "wip"); + 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` 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!(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] + 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 all = report(&repo, &all_dry_run()); + assert!(all.contains("would remove done\n"), "{all}"); + assert!(all.contains("would remove lost\n"), "{all}"); + 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] fn default_and_current_never_deleted() { let repo = TestRepo::init(); // only `main` (default + current) @@ -619,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]