mod assess;
use std::collections::HashSet;
use std::path::Path;
use crate::cli::PruneArgs;
use crate::commands::{Session, candidate_label, confirm, open_session};
use crate::config::wtconfig;
use crate::cx::Cx;
use crate::error::{Error, Result};
use crate::git::cli::GitCli;
use crate::git::discover::Repo;
use crate::git::porcelain::RawWorktree;
use crate::git::worktrees::{HeldBranch, in_progress_branch, in_progress_op};
use crate::git::{
branch_ref, current_branch, enumerate, is_clean_for_removal, local_branches, ops,
};
use crate::model::Worktree;
use crate::worktree::{build_worktrees, lock_repo};
use assess::{Assessor, Block, MergeTargets, Subject, Verdict};
pub(crate) fn run(cx: &mut Cx, args: &PruneArgs, json: bool) -> Result<u8> {
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();
if args.needs_fetch() {
fetch_remotes(git, &session.repo, &root)?;
}
let worktrees = build_worktrees(&session.repo, git)?;
let raws = enumerate(git, &root)?;
let targets = MergeTargets::resolve(&session.repo);
let current = current_branch(session.repo.gix());
let assessor = Assessor {
git,
root: &root,
repo: &session.repo,
args,
targets: &targets,
};
let mut verdicts: Vec<Verdict> = worktrees
.iter()
.enumerate()
.filter_map(|(i, w)| assessor.worktree(i, w, raw_for(&raws, w)))
.collect();
let removing: HashSet<usize> = verdicts
.iter()
.filter(|v| v.block.is_none())
.filter_map(|v| match v.subject {
Subject::Worktree { index, .. } => Some(index),
Subject::Branch { .. } => None,
})
.collect();
let worktree_branches: HashSet<String> = worktrees
.iter()
.enumerate()
.filter(|(i, _)| !(args.all && removing.contains(i)))
.filter_map(|(_, w)| w.branch.clone())
.collect();
let mut branch_verdicts = assessor.branches(current.as_deref(), &worktree_branches)?;
keep_held_branches(git, &root, &worktrees, &mut branch_verdicts);
verdicts.extend(branch_verdicts);
let (candidates, skipped): (Vec<Verdict>, Vec<Verdict>) =
verdicts.into_iter().partition(|v| v.block.is_none());
tracing::debug!(
default = ?targets.default,
merge_targets = ?targets.refs,
worktrees = worktrees.len(),
candidates = candidates.len(),
skipped = skipped.len(),
local_branches = local_branches(session.repo.gix()).map_or(0, |b| b.len()),
"prune: candidate selection",
);
if !json {
for verdict in &skipped {
if let Some(block) = &verdict.block {
cx.err.line(&format!(
"skipping {}: {}",
verdict_label(&worktrees, verdict),
block.message()
))?;
}
}
}
if args.dry_run || json {
if candidates.is_empty() && !json {
cx.err.line("nothing to prune")?;
}
for verdict in &candidates {
if json {
cx.out.line(&verdict_json(&worktrees, verdict)?)?;
} else {
cx.out.line(&format!(
"would remove {}",
verdict_text(&worktrees, verdict)
))?;
}
}
return Ok(0);
}
if candidates.is_empty() {
cx.err.line("nothing to prune")?;
return Ok(0);
}
if !args.force {
cx.err.line("to remove:")?;
for verdict in &candidates {
cx.err
.line(&format!(" {}", verdict_text(&worktrees, verdict)))?;
}
if !confirm(cx, "Proceed? [y/N] ")? {
cx.err.line("aborted")?;
return Ok(0);
}
}
let lock = lock_repo(&root)?;
let mut removed = 0_usize;
let mut kept: HashSet<&str> = HashSet::new();
for verdict in &candidates {
let pruned = match &verdict.subject {
Subject::Worktree {
index,
locked,
head,
} => {
let worktree = &worktrees[*index];
let pruned = remove_worktree(
cx,
&assessor,
&session,
worktree,
head.as_deref(),
*locked && args.locked,
)?;
if !pruned && let Some(branch) = &worktree.branch {
kept.insert(branch);
}
pruned
}
Subject::Branch { name, .. } if kept.contains(name.as_str()) => false,
Subject::Branch { name, .. } => remove_branch(cx, &assessor, name)?,
};
if pruned {
removed += 1;
}
}
drop(lock);
tracing::debug!(removed, "prune: done");
cx.err.line(&format!("pruned {removed} item(s)"))?;
Ok(0)
}
fn keep_held_branches(
git: &dyn GitCli,
root: &Path,
worktrees: &[Worktree],
verdicts: &mut [Verdict],
) {
let mut unreadable_holders: Vec<String> = Vec::new();
let mut held: Vec<(HeldBranch, String)> = Vec::new();
for w in worktrees {
match in_progress_branch(git, root, &w.path, w.is_missing) {
Ok(Some(branch)) => held.push((branch, candidate_label(w))),
Ok(None) => {}
Err(error) => {
tracing::warn!(target_wt = %w.path.display(), %error, "prune: cannot read in-progress branch");
unreadable_holders.push(candidate_label(w));
}
}
}
let holders = unreadable_holders.join(", ");
for verdict in verdicts {
let Subject::Branch { name, .. } = &verdict.subject else {
continue;
};
if let Some((branch, worktree)) = held.iter().find(|(b, _)| b.name == *name) {
verdict.block = Some(Block::Held {
activity: branch.activity,
worktree: worktree.clone(),
});
} else if !unreadable_holders.is_empty() {
verdict.block = Some(Block::HolderUnreadable(holders.clone()));
}
}
}
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(())
}
fn raw_for<'a>(raws: &'a [RawWorktree], worktree: &Worktree) -> Option<&'a RawWorktree> {
raws.iter().find(|raw| raw.path == worktree.path)
}
fn remove_worktree(
cx: &mut Cx,
assessor: &Assessor<'_>,
session: &Session,
worktree: &Worktree,
head: Option<&str>,
unlock: bool,
) -> Result<bool> {
let (git, root) = (assessor.git, assessor.root);
let label = candidate_label(worktree);
if let Some(why) = changed_since_assessed(assessor, worktree, head) {
cx.err.line(&format!("skipping {label}: {why}"))?;
return Ok(false);
}
let path = worktree.path.to_string_lossy();
if let Err(error) = ops::worktree_remove(git, root, &path, true, unlock) {
cx.err.line(&format!("could not remove {label}: {error}"))?;
tracing::warn!(target_wt = %label, "prune: worktree remove failed");
return Ok(false);
}
delete_merged_branch(assessor, worktree, &session.config);
if let Some(branch) = &worktree.branch {
let _ = wtconfig::clear_meta(git, root, branch);
}
tracing::debug!(target_wt = %label, "prune: removed worktree");
Ok(true)
}
fn changed_since_assessed(
assessor: &Assessor<'_>,
worktree: &Worktree,
head: Option<&str>,
) -> Option<String> {
if worktree.is_missing {
match std::fs::symlink_metadata(&worktree.path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Ok(_) => return Some("reappeared since it was assessed; run prune again".into()),
Err(_) => return Some(Block::Unreadable.message()),
}
}
match in_progress_op(
assessor.git,
assessor.root,
&worktree.path,
worktree.is_missing,
) {
Ok(None) => {}
Ok(Some(op)) => return Some(Block::InProgress(op).message()),
Err(_) => return Some(Block::Unreadable.message()),
}
if worktree.is_missing {
return None;
}
let moved = "HEAD moved since it was assessed; run prune again";
if let Some(head) = head {
let now = assessor.git.run(
&worktree.path,
&["rev-parse", "--verify", "--quiet", "HEAD"],
);
if now.map(|h| h.trim().to_string()).ok().as_deref() != Some(head) {
return Some(moved.into());
}
}
if let Some(branch) = &worktree.branch {
let now = assessor
.git
.run(&worktree.path, &["symbolic-ref", "--quiet", "HEAD"]);
if now.map(|r| r.trim().to_string()).ok() != Some(branch_ref(branch)) {
return Some(moved.into());
}
}
if !assessor.args.force && !is_clean_for_removal(assessor.git, &worktree.path) {
return Some(Block::Dirty.message());
}
None
}
fn remove_branch(cx: &mut Cx, assessor: &Assessor<'_>, name: &str) -> Result<bool> {
let (git, root) = (assessor.git, assessor.root);
if !branch_exists(git, root, name) {
tracing::debug!(branch = %name, "prune: branch already deleted");
return Ok(false);
}
if !assessor.args.force && !assessor.branch_is_safe(name) {
cx.err.line(&format!(
"skipping {name} (branch): {}",
Block::Unsafe.message()
))?;
tracing::debug!(branch = %name, "prune: skip unsafe branch");
return Ok(false);
}
match ops::delete_branch(git, root, name, true) {
Ok(out) if out.success => {
let _ = wtconfig::clear_meta(git, root, name);
tracing::debug!(branch = %name, "prune: deleted branch");
Ok(true)
}
Ok(out) => {
cx.err
.line(&format!("could not delete {name}: {}", out.stderr.trim()))?;
tracing::warn!(branch = %name, "prune: branch delete failed");
Ok(false)
}
Err(error) => {
cx.err.line(&format!("could not delete {name}: {error}"))?;
tracing::warn!(branch = %name, "prune: branch delete errored");
Ok(false)
}
}
}
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)
}
fn verdict_label(worktrees: &[Worktree], verdict: &Verdict) -> String {
match &verdict.subject {
Subject::Worktree { index, .. } => candidate_label(&worktrees[*index]),
Subject::Branch { name, .. } => format!("{name} (branch)"),
}
}
fn verdict_text(worktrees: &[Worktree], verdict: &Verdict) -> String {
format!(
"{}: {}",
verdict_label(worktrees, verdict),
verdict.reason.label()
)
}
fn verdict_json(worktrees: &[Worktree], verdict: &Verdict) -> Result<String> {
match &verdict.subject {
Subject::Worktree { index, .. } => {
let mut row = serde_json::to_value(&worktrees[*index])?;
if let Some(fields) = row.as_object_mut() {
fields.insert("reason".into(), verdict.reason.label().into());
}
Ok(row.to_string())
}
Subject::Branch { name, merged, safe } => Ok(serde_json::json!({
"branch": name,
"kind": "branch",
"merged": merged,
"safe": safe,
"reason": verdict.reason.label(),
})
.to_string()),
}
}
fn delete_merged_branch(
assessor: &Assessor<'_>,
worktree: &Worktree,
config: &crate::config::Config,
) {
let Some(branch) = &worktree.branch else {
return;
};
if !config.remove_delete_merged_branch || assessor.targets.is_default(branch) {
return;
}
let meta = wtconfig::read_meta(assessor.repo.gix(), branch);
if !meta.created_by_wt {
return;
}
if !assessor.targets.is_merged(
assessor.git,
assessor.root,
assessor.repo,
&branch_ref(branch),
) {
return;
}
match ops::delete_branch(assessor.git, assessor.root, branch, true) {
Ok(out) if !out.success => {
tracing::debug!(branch = %branch, stderr = %out.stderr.trim(), "prune: delete merged branch failed");
}
Err(error) => {
tracing::debug!(branch = %branch, %error, "prune: delete merged branch could not run");
}
Ok(_) => {}
}
}
#[cfg(test)]
mod tests {
use crate::cli::PruneArgs;
use crate::error::Error;
use crate::git::cli::RealGit;
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,
locked: false,
no_fetch: false,
dry_run,
force,
}
}
fn all_dry_run() -> PruneArgs {
PruneArgs {
all: true,
..prune_args(false, false, true, false)
}
}
fn pushed_dry_run() -> PruneArgs {
PruneArgs {
pushed: true,
..prune_args(false, false, true, false)
}
}
fn all_run() -> PruneArgs {
PruneArgs {
all: true,
..prune_args(false, false, false, false)
}
}
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()
}
fn report_both(repo: &TestRepo, args: &PruneArgs) -> (String, String) {
let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
super::run(&mut t.cx, args, false).unwrap();
(t.out.contents(), t.err.contents())
}
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()
}
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()
}
fn bare_branch(repo: &TestRepo, name: &str) {
repo.git(&["branch", name]);
}
fn diverged_branch(repo: &TestRepo, name: &str) {
repo.git(&["checkout", "-q", "-b", name]);
repo.write(&format!("{name}.txt"), "x\n");
repo.commit_all("diverge");
repo.git(&["checkout", "-q", "main"]);
}
fn give_gone_upstream(repo: &TestRepo, name: &str) {
repo.git(&["config", &format!("branch.{name}.remote"), "origin"]);
repo.git(&[
"config",
&format!("branch.{name}.merge"),
&format!("refs/heads/{name}"),
]);
}
fn make_unmerged_wt(repo: &TestRepo, branch: &str) {
make_wt(repo, branch);
let wt = wt_dir(repo, branch);
std::fs::write(wt.join("change.txt"), "x\n").unwrap();
let dir = wt.to_string_lossy().into_owned();
repo.git(&["-C", &dir, "add", "-A"]);
repo.git(&["-C", &dir, "commit", "-q", "-m", "unmerged change"]);
}
#[test]
fn requires_a_mode_flag() {
let repo = TestRepo::init();
let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
let err =
super::run(&mut t.cx, &prune_args(false, false, false, false), false).unwrap_err();
assert_eq!(err.exit_code(), 2);
}
#[test]
fn dry_run_reports_merged_candidates_without_removing() {
let repo = TestRepo::init();
make_wt(&repo, "merged-wt"); let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
super::run(&mut t.cx, &prune_args(true, false, true, false), false).unwrap();
assert!(t.out.contents().contains("would remove merged-wt"));
assert!(repo.git(&["worktree", "list"]).contains("merged-wt"));
}
#[test]
fn dry_run_reports_nothing_when_no_candidates() {
let repo = TestRepo::init();
let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
super::run(&mut t.cx, &prune_args(true, false, true, false), false).unwrap();
assert!(t.err.contents().contains("nothing to prune"));
assert!(t.out.contents().is_empty());
}
#[test]
fn force_prunes_merged_worktree_and_branch() {
let repo = TestRepo::init();
make_wt(&repo, "merged-wt");
let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
super::run(&mut t.cx, &prune_args(true, false, false, true), false).unwrap();
assert!(!repo.git(&["worktree", "list"]).contains("merged-wt"));
assert!(
repo.git(&["branch", "--list", "merged-wt"])
.trim()
.is_empty()
);
}
#[test]
fn prune_is_excluded_by_a_concurrent_lock_holder() {
let repo = TestRepo::init();
make_wt(&repo, "merged-wt");
let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
let ws = crate::worktree::Workspace::discover(repo.root(), &t.cx.env, &RealGit).unwrap();
let held = ws.lock().unwrap();
let err = super::run(&mut t.cx, &prune_args(true, false, false, true), false).unwrap_err();
drop(held);
assert!(matches!(err, Error::LockUnavailable { .. }), "{err:?}");
assert!(repo.git(&["worktree", "list"]).contains("merged-wt"));
}
#[test]
fn confirmation_yes_prunes() {
let repo = TestRepo::init();
make_wt(&repo, "merged-wt");
let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
t.cx.input = Box::new(CannedInput::new(&["y"]));
super::run(&mut t.cx, &prune_args(true, false, false, false), false).unwrap();
assert!(t.err.contents().contains("Proceed?"));
assert!(!repo.git(&["worktree", "list"]).contains("merged-wt"));
}
#[test]
fn assume_yes_skips_prompt_but_not_the_dirty_guard() {
let repo = TestRepo::init();
make_wt(&repo, "clean-wt");
make_wt(&repo, "dirty-wt");
std::fs::write(wt_dir(&repo, "dirty-wt").join("README.md"), "dirty\n").unwrap();
let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
t.cx.assume_yes = true;
super::run(&mut t.cx, &prune_args(true, false, false, false), false).unwrap();
assert!(t.err.contents().contains("Proceed? [y/N] y (--yes)"));
assert!(
t.err
.contents()
.contains("skipping dirty-wt: uncommitted changes; use --force")
);
let list = repo.git(&["worktree", "list"]);
assert!(!list.contains("clean-wt"), "{list}");
assert!(list.contains("dirty-wt"), "{list}");
}
#[test]
fn confirmation_no_aborts() {
let repo = TestRepo::init();
make_wt(&repo, "merged-wt");
let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
t.cx.input = Box::new(CannedInput::new(&["n"]));
super::run(&mut t.cx, &prune_args(true, false, false, false), false).unwrap();
assert!(t.err.contents().contains("aborted"));
assert!(repo.git(&["worktree", "list"]).contains("merged-wt"));
}
#[test]
fn gone_prunes_missing_worktrees() {
let repo = TestRepo::init();
make_wt(&repo, "goner");
let repo_name = repo.root().file_name().unwrap().to_string_lossy();
let wt_path = repo
.root()
.parent()
.unwrap()
.join(format!("{repo_name}.worktrees/{repo_name}-goner"));
std::fs::remove_dir_all(&wt_path).unwrap();
let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
super::run(&mut t.cx, &prune_args(false, true, false, true), false).unwrap();
assert!(!repo.git(&["worktree", "list"]).contains("goner"));
}
#[test]
fn json_lists_candidates_without_removing() {
let repo = TestRepo::init();
make_wt(&repo, "merged-wt");
let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
super::run(&mut t.cx, &prune_args(true, false, false, false), true).unwrap();
let out = t.out.contents();
assert_eq!(out.lines().count(), 1);
let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
assert_eq!(v["branch"], serde_json::json!("merged-wt"));
assert_eq!(v["reason"], serde_json::json!("merged"));
assert_eq!(v["schema_version"], serde_json::json!(1));
assert!(repo.git(&["worktree", "list"]).contains("merged-wt"));
}
#[test]
fn merged_bare_branch_is_pruned() {
let repo = TestRepo::init();
bare_branch(&repo, "old"); let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
super::run(&mut t.cx, &prune_args(true, false, false, true), false).unwrap();
assert!(repo.git(&["branch", "--list", "old"]).trim().is_empty());
assert!(repo.git(&["branch", "--list", "main"]).contains("main"));
}
#[test]
fn gone_merged_bare_branch_is_pruned() {
let repo = TestRepo::init();
bare_branch(&repo, "old");
give_gone_upstream(&repo, "old"); let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
super::run(&mut t.cx, &prune_args(false, true, false, true), false).unwrap();
assert!(repo.git(&["branch", "--list", "old"]).trim().is_empty());
}
#[test]
fn gone_unmerged_branch_needs_force() {
let repo = TestRepo::init();
diverged_branch(&repo, "wip"); give_gone_upstream(&repo, "wip");
let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
t.cx.input = Box::new(CannedInput::new(&["y"]));
super::run(&mut t.cx, &prune_args(false, true, false, false), false).unwrap();
assert!(t.err.contents().contains("use --force"));
assert!(repo.git(&["branch", "--list", "wip"]).contains("wip"));
let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
super::run(&mut t.cx, &prune_args(false, true, false, true), false).unwrap();
assert!(repo.git(&["branch", "--list", "wip"]).trim().is_empty());
}
#[test]
fn all_selects_merged_and_gone() {
let repo = TestRepo::init();
bare_branch(&repo, "old"); diverged_branch(&repo, "wip"); give_gone_upstream(&repo, "wip");
let (all, skips) = report_both(&repo, &all_dry_run());
assert!(all.contains("would remove old (branch): merged"), "{all}");
assert!(!all.contains("wip"), "{all}");
assert!(
skips.contains("skipping wip (branch): has work on no remote"),
"{skips}"
);
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"));
assert!(has_branch(&repo, "old"));
assert!(has_branch(&repo, "wip"));
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() {
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: merged\n"), "{all}");
assert!(all.contains("would remove lost: missing\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() {
let repo = TestRepo::init();
diverged_branch(&repo, "feat");
give_upstream(&repo, "feat");
diverged_branch(&repo, "local"); 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"));
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() {
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() {
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}");
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() {
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() {
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: upstream gone\n"),
"{all}"
);
assert!(
all.contains("would remove shipped (branch): upstream gone"),
"{all}"
);
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() {
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() {
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(); 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 busy: uncommitted changes"), "{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();
let args = all_dry_run();
with_assessor(&repo, &args, |assessor| {
assert!(!assessor.recoverable("refs/heads/no-such-branch"));
assert!(!assessor.branch_is_safe("no-such-branch"));
});
}
fn with_assessor(repo: &TestRepo, args: &PruneArgs, f: impl FnOnce(&super::Assessor<'_>)) {
with_assessor_git(repo, args, &RealGit, f);
}
fn with_assessor_git(
repo: &TestRepo,
args: &PruneArgs,
git: &dyn crate::git::cli::GitCli,
f: impl FnOnce(&super::Assessor<'_>),
) {
let r = crate::git::discover::Repo::discover(repo.root()).unwrap();
let targets = super::MergeTargets::resolve(&r);
let assessor = super::Assessor {
git,
root: repo.root(),
repo: &r,
args,
targets: &targets,
};
f(&assessor);
}
#[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}");
}
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<serde_json::Value> {
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::<serde_json::Value>(l).unwrap())
.find(|v| v["branch"] == serde_json::json!(name))
}
#[test]
fn fetch_refreshes_remote_refs_before_judging_safety() {
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));
assert_eq!(stale["reason"], serde_json::json!("pushed"));
assert!(json_branch(&repo, &all_dry_run(), "feat").is_none());
let (_, skips) = report_both(&repo, &all_dry_run());
assert!(
skips.contains("skipping feat (branch): has work on no remote"),
"{skips}"
);
let forced = PruneArgs {
force: true,
..all_dry_run()
};
let fresh = json_branch(&repo, &forced, "feat").expect("selected as gone");
assert_eq!(fresh["safe"], serde_json::json!(false));
assert_eq!(fresh["reason"], serde_json::json!("upstream gone"));
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}");
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(); let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
super::run(&mut t.cx, &prune_args(true, true, false, true), false).unwrap();
assert!(t.err.contents().contains("nothing to prune"));
assert!(repo.git(&["branch", "--list", "main"]).contains("main"));
}
#[test]
fn merged_only_skips_unmerged_worktree() {
let repo = TestRepo::init();
make_unmerged_wt(&repo, "wip");
let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
super::run(&mut t.cx, &prune_args(true, false, true, false), false).unwrap();
assert!(t.err.contents().contains("nothing to prune"));
}
#[test]
fn dry_run_lists_bare_branch_without_deleting() {
let repo = TestRepo::init();
bare_branch(&repo, "old");
let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
super::run(&mut t.cx, &prune_args(true, false, true, false), false).unwrap();
assert!(t.out.contents().contains("would remove old (branch)"));
assert!(repo.git(&["branch", "--list", "old"]).contains("old"));
}
#[test]
fn branch_with_worktree_uses_worktree_path() {
let repo = TestRepo::init();
make_wt(&repo, "merged-wt"); let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
super::run(&mut t.cx, &prune_args(true, false, false, true), false).unwrap();
assert!(!repo.git(&["worktree", "list"]).contains("merged-wt"));
assert!(
repo.git(&["branch", "--list", "merged-wt"])
.trim()
.is_empty()
);
assert!(t.err.contents().contains("pruned 1 item(s)"));
}
#[test]
fn json_lists_bare_branch() {
let repo = TestRepo::init();
bare_branch(&repo, "old");
let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
super::run(&mut t.cx, &prune_args(true, false, false, false), true).unwrap();
let out = t.out.contents();
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));
assert_eq!(v["reason"], serde_json::json!("merged"));
assert!(repo.git(&["branch", "--list", "old"]).contains("old"));
}
fn detached_wt(repo: &TestRepo, name: &str, rev: &str) -> std::path::PathBuf {
let path = repo
.root()
.parent()
.unwrap()
.join(format!("detached-{name}"));
repo.git(&[
"worktree",
"add",
"-q",
"--detach",
path.to_str().unwrap(),
rev,
]);
path
}
fn commit_in(repo: &TestRepo, path: &std::path::Path, file: &str) -> String {
std::fs::write(path.join(file), "x\n").unwrap();
let dir = path.to_string_lossy().into_owned();
repo.git(&["-C", &dir, "add", "-A"]);
repo.git(&["-C", &dir, "commit", "-q", "-m", file]);
repo.git(&["-C", &dir, "rev-parse", "HEAD"])
.trim()
.to_string()
}
fn worktree_listed(repo: &TestRepo, needle: &str) -> bool {
repo.git(&["worktree", "list"]).contains(needle)
}
fn squash_into_main(repo: &TestRepo, branch: &str) {
repo.git(&["merge", "-q", "--squash", branch]);
repo.git(&["commit", "-q", "-m", &format!("squash {branch}")]);
}
#[test]
fn a_detached_worktree_on_merged_work_is_pruned_as_merged() {
let repo = TestRepo::init();
detached_wt(&repo, "merged", "main");
let merged = report(&repo, &prune_args(true, false, true, false));
assert!(merged.contains("detached-merged: merged\n"), "{merged}");
run_yes(&repo, &prune_args(true, false, false, false));
assert!(!worktree_listed(&repo, "detached-merged"));
}
#[test]
fn a_detached_worktree_is_pushed_only_when_its_head_is_on_a_remote() {
let repo = TestRepo::init();
let pushed = detached_wt(&repo, "pushed", "main");
let head = commit_in(&repo, &pushed, "pr.txt");
repo.git(&["update-ref", "refs/remotes/origin/pr-head", &head]);
let local = detached_wt(&repo, "local", "main");
commit_in(&repo, &local, "wip.txt");
let report_pushed = report(&repo, &pushed_dry_run());
assert!(
report_pushed.contains("detached-pushed: pushed\n"),
"{report_pushed}"
);
assert!(!report_pushed.contains("detached-local"), "{report_pushed}");
assert!(!report(&repo, &prune_args(true, false, true, false)).contains("detached-"));
let err = run_yes(&repo, &all_run());
assert!(err.contains("pruned 1 item(s)"), "{err}");
assert!(!worktree_listed(&repo, "detached-pushed"));
assert!(worktree_listed(&repo, "detached-local"));
}
#[test]
fn a_dirty_detached_worktree_is_skipped_and_says_so() {
let repo = TestRepo::init();
let path = detached_wt(&repo, "dirty", "main");
std::fs::write(path.join("README.md"), "edited\n").unwrap();
let (out, err) = report_both(&repo, &all_dry_run());
assert!(!out.contains("detached-dirty"), "{out}");
assert!(err.contains("detached-dirty: uncommitted changes"), "{err}");
run_yes(&repo, &all_run());
assert!(worktree_listed(&repo, "detached-dirty"));
}
#[test]
fn a_worktree_mid_rebase_is_kept_even_with_force() {
let repo = TestRepo::init();
let path = detached_wt(&repo, "rebasing", "main");
let admin = repo.git(&[
"-C",
path.to_str().unwrap(),
"rev-parse",
"--absolute-git-dir",
]);
std::fs::create_dir(std::path::Path::new(admin.trim()).join("rebase-merge")).unwrap();
let forced = PruneArgs {
all: true,
..prune_args(false, false, false, true)
};
let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
super::run(&mut t.cx, &forced, false).unwrap();
let err = t.err.contents();
assert!(
err.contains("detached-rebasing: rebase in progress"),
"{err}"
);
assert!(worktree_listed(&repo, "detached-rebasing"));
}
#[test]
fn a_squash_merged_worktree_with_a_live_upstream_is_merged_by_content() {
let repo = TestRepo::init();
make_unmerged_wt(&repo, "chore");
give_upstream(&repo, "chore");
squash_into_main(&repo, "chore");
let merged = report(&repo, &prune_args(true, false, true, false));
assert!(
merged.contains("would remove chore: merged by content"),
"{merged}"
);
assert!(!report(&repo, &pushed_dry_run()).contains("chore"));
let err = run_yes(&repo, &all_run());
assert!(!err.contains("skipping"), "{err}");
assert!(!err.contains("could not"), "{err}");
assert!(!worktree_listed(&repo, "chore"));
assert!(!has_branch(&repo, "chore"));
}
#[test]
fn merged_alone_removes_a_squash_merged_worktree_and_its_branch() {
let repo = TestRepo::init();
make_unmerged_wt(&repo, "squashed");
squash_into_main(&repo, "squashed");
let err = run_yes(&repo, &prune_args(true, false, false, false));
assert!(err.contains("squashed: merged by content"), "{err}");
assert!(err.contains("pruned 1 item(s)"), "{err}");
assert!(!worktree_listed(&repo, "squashed"));
assert!(!has_branch(&repo, "squashed"));
}
#[test]
fn a_merge_only_branch_without_upstream_is_merged_by_content() {
let repo = TestRepo::init();
diverged_branch(&repo, "x");
diverged_branch(&repo, "y");
repo.git(&["checkout", "-q", "-b", "integration"]);
repo.git(&["merge", "-q", "--no-ff", "-m", "merge x", "x"]);
repo.git(&["merge", "-q", "--no-ff", "-m", "merge y", "y"]);
repo.git(&["checkout", "-q", "main"]);
assert!(!report(&repo, &all_dry_run()).contains("integration"));
squash_into_main(&repo, "x");
squash_into_main(&repo, "y");
let all = report(&repo, &all_dry_run());
assert!(
all.contains("would remove integration (branch): merged by content"),
"{all}"
);
let err = run_yes(&repo, &all_run());
assert!(!err.contains("skipping"), "{err}");
assert!(!has_branch(&repo, "integration"));
}
#[test]
fn a_gone_branch_whose_changes_landed_needs_no_force() {
let repo = TestRepo::init();
diverged_branch(&repo, "shipped");
give_gone_upstream(&repo, "shipped");
squash_into_main(&repo, "shipped");
let err = run_yes(&repo, &prune_args(false, true, false, false));
assert!(!err.contains("skipping"), "{err}");
assert!(!has_branch(&repo, "shipped"));
}
#[test]
fn the_current_worktree_is_never_pruned() {
let repo = TestRepo::init();
repo.git(&["update-ref", "refs/remotes/origin/main", "refs/heads/main"]);
repo.git(&[
"symbolic-ref",
"refs/remotes/origin/HEAD",
"refs/remotes/origin/main",
]);
make_wt(&repo, "here");
let inside = wt_dir(&repo, "here");
let mut t = crate::testutil::test_cx(&[], inside.to_str().unwrap());
super::run(&mut t.cx, &prune_args(true, false, false, true), false).unwrap();
let err = t.err.contents();
assert!(
err.contains("skipping here: it is the current worktree"),
"{err}"
);
assert!(worktree_listed(&repo, "here"));
assert!(has_branch(&repo, "here"));
}
#[test]
fn a_locked_worktree_is_skipped_unless_locked_is_passed() {
let repo = TestRepo::init();
make_wt(&repo, "agent");
let path = wt_dir(&repo, "agent");
repo.git(&[
"worktree",
"lock",
"--reason",
"claimed by agent",
path.to_str().unwrap(),
]);
let (out, err) = report_both(&repo, &all_dry_run());
assert!(!out.contains("agent"), "{out}");
assert!(
err.contains("skipping agent: locked (claimed by agent); use --locked"),
"{err}"
);
let forced = PruneArgs {
all: true,
..prune_args(false, false, false, true)
};
run_yes(&repo, &forced);
assert!(worktree_listed(&repo, "agent"));
assert!(has_branch(&repo, "agent"));
let unlocked = PruneArgs {
locked: true,
..all_run()
};
let err = run_yes(&repo, &unlocked);
assert!(!err.contains("could not"), "{err}");
assert!(!worktree_listed(&repo, "agent"));
assert!(!has_branch(&repo, "agent"));
}
#[test]
fn a_missing_locked_worktree_is_reconciled_with_locked() {
let repo = TestRepo::init();
make_wt(&repo, "ghost");
let path = wt_dir(&repo, "ghost");
repo.git(&["worktree", "lock", path.to_str().unwrap()]);
std::fs::remove_dir_all(&path).unwrap();
let err = run_yes(&repo, &all_run());
assert!(
err.contains("skipping ghost: locked; use --locked"),
"{err}"
);
assert!(worktree_listed(&repo, "ghost"));
let unlocked = PruneArgs {
locked: true,
..all_run()
};
run_yes(&repo, &unlocked);
assert!(!worktree_listed(&repo, "ghost"));
assert!(!has_branch(&repo, "ghost"));
}
struct RefusesRemove;
impl crate::git::cli::GitCli for RefusesRemove {
fn run_raw(
&self,
repo: &std::path::Path,
args: &[&str],
) -> crate::error::Result<crate::git::cli::GitOutput> {
if args.starts_with(&["worktree", "remove"]) {
return Ok(crate::git::cli::GitOutput {
success: false,
stdout: String::new(),
stderr: "fatal: refused".into(),
});
}
RealGit.run_raw(repo, args)
}
}
#[test]
fn a_refused_worktree_removal_is_reported_and_not_counted() {
let repo = TestRepo::init();
make_wt(&repo, "stuck");
let mut t = crate::testutil::test_cx_with_git(
&[],
repo.root().to_str().unwrap(),
std::sync::Arc::new(RefusesRemove),
);
t.cx.assume_yes = true;
super::run(&mut t.cx, &all_run(), false).unwrap();
let err = t.err.contents();
assert!(err.contains("could not remove stuck: "), "{err}");
assert!(err.contains("fatal: refused"), "{err}");
assert!(err.contains("pruned 0 item(s)"), "{err}");
assert!(!err.contains("could not delete"), "{err}");
assert!(worktree_listed(&repo, "stuck"));
assert!(has_branch(&repo, "stuck"));
}
#[test]
fn a_worktree_dirtied_after_the_prompt_is_kept() {
let repo = TestRepo::init();
make_wt(&repo, "late");
let path = wt_dir(&repo, "late");
let row = row_for(&repo, "late");
let args = all_run();
with_assessor(&repo, &args, |assessor| {
assert_eq!(super::changed_since_assessed(assessor, &row, None), None);
});
std::fs::write(path.join("README.md"), "late edit\n").unwrap();
with_assessor(&repo, &args, |assessor| {
let why = super::changed_since_assessed(assessor, &row, None).unwrap();
assert!(why.contains("uncommitted changes"), "{why}");
});
}
#[test]
fn an_untracked_file_added_after_the_prompt_keeps_the_worktree() {
let repo = TestRepo::init();
make_wt(&repo, "late-new");
let path = wt_dir(&repo, "late-new");
let row = row_for(&repo, "late-new");
let args = all_run();
std::fs::write(path.join("new.rs"), "fn main() {}\n").unwrap();
with_assessor(&repo, &args, |assessor| {
let why = super::changed_since_assessed(assessor, &row, None).unwrap();
assert!(why.contains("uncommitted changes"), "{why}");
});
}
#[test]
fn untracked_files_keep_a_selected_worktree_whatever_the_config() {
let repo = TestRepo::init();
repo.git(&["config", "status.showUntrackedFiles", "no"]);
let detached = detached_wt(&repo, "fresh", "main");
std::fs::write(detached.join("newfile.rs"), "wip\n").unwrap();
make_unmerged_wt(&repo, "squashed");
give_upstream(&repo, "squashed");
squash_into_main(&repo, "squashed");
std::fs::write(wt_dir(&repo, "squashed").join("notes.md"), "wip\n").unwrap();
make_wt(&repo, "held");
let held = wt_dir(&repo, "held");
std::fs::write(held.join("scratch.txt"), "wip\n").unwrap();
repo.git(&["worktree", "lock", held.to_str().unwrap()]);
let (out, err) = report_both(
&repo,
&PruneArgs {
locked: true,
..all_dry_run()
},
);
for name in ["detached-fresh", "squashed", "held"] {
assert!(!out.contains(name), "{out}");
assert!(
err.contains(&format!("{name}: uncommitted changes")),
"{err}"
);
}
let unlocked = PruneArgs {
locked: true,
..all_run()
};
run_yes(&repo, &unlocked);
assert!(detached.join("newfile.rs").exists());
assert!(wt_dir(&repo, "squashed").join("notes.md").exists());
assert!(held.join("scratch.txt").exists());
let forced = PruneArgs {
all: true,
locked: true,
..prune_args(false, false, false, true)
};
run_yes(&repo, &forced);
assert!(!worktree_listed(&repo, "detached-fresh"));
assert!(!worktree_listed(&repo, "squashed"));
assert!(!worktree_listed(&repo, "held"));
}
fn row_for(repo: &TestRepo, suffix: &str) -> crate::model::Worktree {
crate::worktree::build_worktrees(
&crate::git::discover::Repo::discover(repo.root()).unwrap(),
&RealGit,
)
.unwrap()
.into_iter()
.find(|w| w.path.to_string_lossy().ends_with(suffix))
.unwrap()
}
#[test]
fn a_missing_worktree_that_reappeared_after_the_prompt_is_kept() {
let repo = TestRepo::init();
make_wt(&repo, "remounted");
let path = wt_dir(&repo, "remounted");
let mut row = row_for(&repo, "remounted");
row.is_missing = true;
let args = all_run();
with_assessor(&repo, &args, |assessor| {
let why = super::changed_since_assessed(assessor, &row, None).unwrap();
assert!(why.contains("reappeared"), "{why}");
});
std::fs::remove_dir_all(&path).unwrap();
with_assessor(&repo, &args, |assessor| {
assert_eq!(super::changed_since_assessed(assessor, &row, None), None);
});
}
#[test]
fn a_branch_worktree_that_detached_after_the_prompt_is_kept() {
let repo = TestRepo::init();
make_wt(&repo, "hopper");
let path = wt_dir(&repo, "hopper");
let row = row_for(&repo, "hopper");
let dir = path.to_string_lossy().into_owned();
repo.git(&["-C", &dir, "checkout", "-q", "--detach"]);
commit_in(&repo, &path, "stray.txt");
let args = all_run();
with_assessor(&repo, &args, |assessor| {
let why = super::changed_since_assessed(assessor, &row, None).unwrap();
assert!(why.contains("HEAD moved"), "{why}");
});
}
#[test]
fn a_detached_worktree_that_committed_after_the_prompt_is_kept() {
let repo = TestRepo::init();
let path = detached_wt(&repo, "busy-agent", "main");
let assessed = repo.git(&["rev-parse", "main"]).trim().to_string();
let row = row_for(&repo, "detached-busy-agent");
let args = all_run();
with_assessor(&repo, &args, |assessor| {
assert_eq!(
super::changed_since_assessed(assessor, &row, Some(&assessed)),
None
);
});
commit_in(&repo, &path, "late.txt");
with_assessor(&repo, &args, |assessor| {
let why = super::changed_since_assessed(assessor, &row, Some(&assessed)).unwrap();
assert!(why.contains("HEAD moved"), "{why}");
});
}
#[test]
fn a_worktree_that_started_a_rebase_after_the_prompt_is_kept() {
let repo = TestRepo::init();
make_wt(&repo, "rebasing");
let row = row_for(&repo, "rebasing");
let admin = repo.git(&[
"-C",
wt_dir(&repo, "rebasing").to_str().unwrap(),
"rev-parse",
"--absolute-git-dir",
]);
std::fs::create_dir(std::path::Path::new(admin.trim()).join("rebase-merge")).unwrap();
let args = all_run();
with_assessor(&repo, &args, |assessor| {
let why = super::changed_since_assessed(assessor, &row, None).unwrap();
assert_eq!(why, "rebase in progress");
});
}
#[test]
fn a_missing_detached_worktree_holding_the_only_copy_needs_force() {
let repo = TestRepo::init();
let path = detached_wt(&repo, "orphan", "main");
commit_in(&repo, &path, "only-here.txt");
std::fs::remove_dir_all(&path).unwrap();
let err = run_yes(&repo, &all_run());
assert!(
err.contains("detached-orphan: has work on no remote"),
"{err}"
);
assert!(worktree_listed(&repo, "detached-orphan"));
let forced = PruneArgs {
all: true,
..prune_args(false, false, false, true)
};
run_yes(&repo, &forced);
assert!(!worktree_listed(&repo, "detached-orphan"));
}
#[test]
fn a_missing_worktree_no_mode_selected_is_left_registered() {
let repo = TestRepo::init();
make_unmerged_wt(&repo, "offline");
std::fs::remove_dir_all(wt_dir(&repo, "offline")).unwrap();
make_wt(&repo, "done"); let err = run_yes(&repo, &prune_args(true, false, false, false));
assert!(err.contains("pruned 1 item(s)"), "{err}");
assert!(worktree_listed(&repo, "offline"));
run_yes(&repo, &prune_args(true, false, false, false));
assert!(worktree_listed(&repo, "offline"));
}
#[test]
fn a_missing_detached_worktree_whose_head_survives_is_pruned() {
let repo = TestRepo::init();
let path = detached_wt(&repo, "spent", "main");
std::fs::remove_dir_all(&path).unwrap();
let err = run_yes(&repo, &all_run());
assert!(!err.contains("skipping"), "{err}");
assert!(!worktree_listed(&repo, "detached-spent"));
}
struct BlindAdminDir;
impl crate::git::cli::GitCli for BlindAdminDir {
fn run_raw(
&self,
repo: &std::path::Path,
args: &[&str],
) -> crate::error::Result<crate::git::cli::GitOutput> {
if args == ["rev-parse", "--absolute-git-dir"]
&& repo.to_string_lossy().ends_with("blind")
{
return Ok(crate::git::cli::GitOutput {
success: false,
stdout: String::new(),
stderr: "fatal: unreadable".into(),
});
}
RealGit.run_raw(repo, args)
}
}
#[test]
fn a_worktree_whose_state_turns_unreadable_after_the_prompt_is_kept() {
let repo = TestRepo::init();
make_wt(&repo, "blind");
let row = row_for(&repo, "blind");
let args = all_run();
with_assessor(&repo, &args, |assessor| {
assert_eq!(super::changed_since_assessed(assessor, &row, None), None);
});
with_assessor_git(&repo, &args, &BlindAdminDir, |assessor| {
let why = super::changed_since_assessed(assessor, &row, None);
assert_eq!(why, Some(super::Block::Unreadable.message()));
});
}
#[test]
fn a_missing_worktree_whose_path_cannot_be_checked_is_kept() {
let repo = TestRepo::init();
let scratch = tempfile::tempdir().unwrap();
let path = scratch.path().join("mount").join("offline");
repo.git(&[
"worktree",
"add",
"-q",
"-b",
"offline",
path.to_str().unwrap(),
]);
let mut row = row_for(&repo, "offline");
let parent = row.path.parent().unwrap().to_path_buf();
std::fs::remove_dir_all(&parent).unwrap();
row.is_missing = true;
let args = all_run();
with_assessor(&repo, &args, |assessor| {
assert_eq!(super::changed_since_assessed(assessor, &row, None), None);
});
std::fs::write(&parent, "not a directory\n").unwrap();
with_assessor(&repo, &args, |assessor| {
assert_eq!(
super::changed_since_assessed(assessor, &row, None),
Some(super::Block::Unreadable.message())
);
});
}
#[test]
fn an_unreadable_worktree_keeps_every_bare_branch() {
let repo = TestRepo::init();
make_wt(&repo, "blind");
repo.git(&["branch", "done"]);
let mut t = crate::testutil::test_cx_with_git(
&[],
repo.root().to_str().unwrap(),
std::sync::Arc::new(BlindAdminDir),
);
t.cx.assume_yes = true;
super::run(&mut t.cx, &all_run(), false).unwrap();
let err = t.err.contents();
assert!(
err.contains("skipping done (branch): cannot read the rebase or bisect state of blind"),
"{err}"
);
assert!(has_branch(&repo, "done"));
assert!(worktree_listed(&repo, "blind"));
}
#[test]
fn a_worktree_without_a_porcelain_record_is_unreadable() {
let repo = TestRepo::init();
make_wt(&repo, "unlisted");
let row = row_for(&repo, "unlisted");
let args = all_dry_run();
with_assessor(&repo, &args, |assessor| {
let verdict = assessor.worktree(1, &row, None).expect("merged");
assert_eq!(verdict.block, Some(super::Block::Unreadable));
});
}
#[test]
fn a_branch_being_rebased_is_kept_and_says_so() {
let repo = TestRepo::init();
rebase_stopped_wt(&repo, "feat");
give_upstream(&repo, "feat"); let holder = wt_dir(&repo, "feat");
let skip = format!(
"skipping feat (branch): being rebased in {}",
holder.display()
);
let (all, err) = report_both(&repo, &all_dry_run());
assert!(!all.contains("feat (branch)"), "{all}");
assert!(err.lines().any(|l| l == skip), "{err}");
assert!(json_branch(&repo, &all_dry_run(), "feat").is_none());
let forced = PruneArgs {
all: true,
..prune_args(false, false, false, true)
};
let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
super::run(&mut t.cx, &forced, false).unwrap();
assert!(t.err.contents().lines().any(|l| l == skip));
assert!(has_branch(&repo, "feat"));
}
#[test]
fn a_branch_being_bisected_is_kept_and_says_so() {
let repo = TestRepo::init();
make_wt(&repo, "hunt"); let holder = wt_dir(&repo, "hunt");
for n in 1..=3 {
repo.write("a.txt", &format!("{n}\n"));
repo.commit_all(&format!("c{n}"));
}
let dir = holder.to_string_lossy().into_owned();
repo.git(&["-C", &dir, "bisect", "start", "main", "main~3"]);
let skip = format!(
"skipping hunt (branch): being bisected in {}",
holder.display()
);
let merged_dry_run = prune_args(true, false, true, false);
let (out, err) = report_both(&repo, &merged_dry_run);
assert!(!out.contains("hunt (branch)"), "{out}");
assert!(err.lines().any(|l| l == skip), "{err}");
let err = run_yes(&repo, &prune_args(true, false, false, true));
assert!(err.lines().any(|l| l == skip), "{err}");
assert!(has_branch(&repo, "hunt"));
}
#[test]
fn a_held_branch_no_mode_selects_is_not_mentioned() {
let repo = TestRepo::init();
rebase_stopped_wt(&repo, "feat");
let (_, err) = report_both(&repo, &prune_args(true, false, true, false));
assert!(!err.contains("feat (branch)"), "{err}");
}
fn rebase_stopped_wt(repo: &TestRepo, branch: &str) {
make_unmerged_wt(repo, branch);
repo.write("change.txt", "main\n");
repo.commit_all("main conflicts");
let out =
crate::git::cli::GitCli::run_raw(&RealGit, &wt_dir(repo, branch), &["rebase", "main"])
.unwrap();
assert!(!out.success, "the rebase should stop on the conflict");
}
#[test]
fn a_missing_worktree_mid_rebase_is_kept_with_its_branch() {
let repo = TestRepo::init();
rebase_stopped_wt(&repo, "stalled");
std::fs::remove_dir_all(wt_dir(&repo, "stalled")).unwrap();
make_wt(&repo, "lost");
std::fs::remove_dir_all(wt_dir(&repo, "lost")).unwrap();
let forced = PruneArgs {
all: true,
..prune_args(false, false, false, true)
};
let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
super::run(&mut t.cx, &forced, false).unwrap();
let err = t.err.contents();
assert!(
err.lines().any(|l| l.starts_with("skipping")
&& l.contains("stalled")
&& l.ends_with(": rebase in progress")),
"{err}"
);
assert!(worktree_listed(&repo, "stalled"));
assert!(has_branch(&repo, "stalled"));
assert!(!worktree_listed(&repo, "lost"), "{err}");
assert!(!has_branch(&repo, "lost"));
}
#[test]
fn merged_is_reported_the_same_whatever_the_mode() {
let repo = TestRepo::init();
diverged_branch(&repo, "feat");
give_upstream(&repo, "feat");
squash_into_main(&repo, "feat");
let pushed = json_branch(&repo, &pushed_dry_run(), "feat").expect("pushed");
assert_eq!(pushed["reason"], serde_json::json!("pushed"));
assert_eq!(pushed["merged"], serde_json::json!(true));
let all = json_branch(&repo, &all_dry_run(), "feat").expect("merged");
assert_eq!(all["merged"], serde_json::json!(true));
}
}