mod assess;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use crate::cli::PruneArgs;
use crate::commands::{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::merged::ContentCheck;
use crate::git::porcelain::RawWorktree;
use crate::git::worktrees::{HeldBranch, in_progress_branch_in, in_progress_op};
use crate::git::{
abbrev_len, branch_ref, current_branch, enumerate, is_clean_for_removal, local_branches, ops,
};
use crate::model::Worktree;
use crate::worktree::{enrich_worktree, enumerate_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 mut worktrees = enumerate_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::new(git, &root, &session.repo, args, &targets);
let branch_refs: Vec<String> = local_branches(session.repo.gix())?
.iter()
.map(|name| branch_ref(name))
.collect();
assessor.prefetch_recoverable(
branch_refs
.iter()
.map(String::as_str)
.chain(raws.iter().filter_map(|raw| raw.head.as_deref())),
);
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(&assessor, &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 json {
let abbrev = abbrev_len(session.repo.gix());
for verdict in &candidates {
if let Subject::Worktree { index, .. } = verdict.subject {
enrich_worktree(&session.repo, git, abbrev, &mut worktrees[index]);
}
}
}
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 removal = Removal {
assessor: &assessor,
content: ContentCheck::new(git, &root),
config: &session.config,
meta: wtconfig::meta_branches(git, &root)
.inspect_err(|error| tracing::debug!(%error, "prune: cannot list wt metadata"))
.ok(),
};
let mut removed = 0_usize;
let mut kept: HashSet<&str> = HashSet::new();
let mut branches: Vec<&str> = Vec::new();
for verdict in &candidates {
match &verdict.subject {
Subject::Worktree {
index,
locked,
head,
} => {
let worktree = &worktrees[*index];
let pruned = remove_worktree(
cx,
&removal,
worktree,
head.as_deref(),
*locked && args.locked,
)?;
if pruned {
removed += 1;
} else if let Some(branch) = &worktree.branch {
kept.insert(branch);
}
}
Subject::Branch { name, .. } if kept.contains(name.as_str()) => {}
Subject::Branch { name, .. } => branches.push(name),
}
}
removed += remove_branches(cx, &removal, &branches)?;
drop(lock);
tracing::debug!(removed, "prune: done");
cx.err.line(&format!("pruned {removed} item(s)"))?;
Ok(0)
}
fn keep_held_branches(assessor: &Assessor<'_>, 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 assessor
.admin_dir(w)
.and_then(|dir| in_progress_branch_in(&dir))
{
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)
}
struct Removal<'r, 'a> {
assessor: &'r Assessor<'a>,
content: ContentCheck<'a>,
config: &'r crate::config::Config,
meta: Option<HashSet<String>>,
}
impl Removal<'_, '_> {
fn clear_meta(&self, branch: &str) {
if self.meta.as_ref().is_none_or(|meta| meta.contains(branch)) {
let assessor = self.assessor;
let _ = wtconfig::clear_meta(assessor.git, assessor.root, branch);
}
}
}
fn remove_worktree(
cx: &mut Cx,
removal: &Removal<'_, '_>,
worktree: &Worktree,
head: Option<&str>,
unlock: bool,
) -> Result<bool> {
let assessor = removal.assessor;
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(removal, worktree);
if let Some(branch) = &worktree.branch {
removal.clear_meta(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
}
const BRANCHES_PER_DELETE: usize = 256;
#[derive(Debug, PartialEq, Eq)]
enum Fate {
Delete,
Skip(Block),
Gone,
}
fn remove_branches(cx: &mut Cx, removal: &Removal<'_, '_>, names: &[&str]) -> Result<usize> {
if names.is_empty() {
return Ok(0);
}
let assessor = removal.assessor;
let (git, root) = (assessor.git, assessor.root);
let tips = match branch_tips(git, root) {
Ok(tips) => tips,
Err(error) => {
tracing::warn!(%error, "prune: cannot read branch tips");
for name in names {
cx.err.line(&format!(
"skipping {name} (branch): {}",
Block::Unreadable.message()
))?;
}
return Ok(0);
}
};
let present: Vec<(&str, Option<&str>)> = names
.iter()
.filter_map(|name| match tips.get(*name) {
Some(id) => Some((*name, id.as_deref())),
None => {
tracing::debug!(branch = %name, "prune: branch already deleted");
None
}
})
.collect();
let force = assessor.args.force;
let recoverable = if force {
HashSet::new()
} else {
let ids: Vec<String> = present
.iter()
.filter_map(|(_, id)| id.map(str::to_string))
.collect();
assessor.recoverable_now(&ids)
};
let mut outcomes: Vec<(&str, Fate)> = present
.iter()
.map(|&(name, id)| {
let safe = force
|| id.is_some_and(|id| {
recoverable.contains(id)
|| assessor
.targets
.is_merged(&removal.content, assessor.repo, id)
});
let fate = if safe {
Fate::Delete
} else {
Fate::Skip(Block::Unsafe)
};
(name, fate)
})
.collect();
if !force && outcomes.iter().any(|(_, fate)| *fate == Fate::Delete) {
let checked: HashMap<&str, Option<&str>> = present.iter().copied().collect();
let now = branch_tips(git, root);
for (name, fate) in &mut outcomes {
if *fate != Fate::Delete {
continue;
}
*fate = match &now {
Ok(now) => match now.get(*name) {
None => Fate::Gone,
Some(id) if Some(id.as_deref()) != checked.get(name).copied() => {
Fate::Skip(Block::Moved)
}
Some(_) => Fate::Delete,
},
Err(_) => Fate::Skip(Block::Unreadable),
};
}
}
let doomed: Vec<&str> = outcomes
.iter()
.filter(|(_, fate)| *fate == Fate::Delete)
.map(|(name, _)| *name)
.collect();
for chunk in doomed.chunks(BRANCHES_PER_DELETE) {
match ops::delete_branches(git, root, chunk) {
Ok(out) if !out.success => {
tracing::debug!(stderr = %out.stderr.trim(), "prune: batch delete left some branches");
}
Err(error) => tracing::debug!(%error, "prune: batch delete could not run"),
Ok(_) => {}
}
}
let left = if doomed.is_empty() {
None
} else {
branch_tips(git, root)
.inspect_err(|error| tracing::debug!(%error, "prune: cannot re-read branch tips"))
.ok()
};
let mut deleted = 0_usize;
for (name, fate) in outcomes {
match fate {
Fate::Skip(block) => {
cx.err
.line(&format!("skipping {name} (branch): {}", block.message()))?;
tracing::debug!(branch = %name, ?block, "prune: skip branch");
continue;
}
Fate::Gone => {
tracing::debug!(branch = %name, "prune: branch already deleted");
continue;
}
Fate::Delete => {}
}
let gone = match &left {
Some(left) => !left.contains_key(name),
None => branch_is_gone(git, root, name),
};
if gone || delete_branch(cx, git, root, name)? {
removal.clear_meta(name);
tracing::debug!(branch = %name, "prune: deleted branch");
deleted += 1;
}
}
Ok(deleted)
}
fn delete_branch(cx: &mut Cx, git: &dyn GitCli, root: &Path, name: &str) -> Result<bool> {
match ops::delete_branch(git, root, name, true) {
Ok(out) if out.success => 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_tips(git: &dyn GitCli, root: &Path) -> Result<HashMap<String, Option<String>>> {
let out = git.run(
root,
&[
"for-each-ref",
"--format=%(objectname) %(objecttype) %(refname)",
"refs/heads/",
],
)?;
let mut tips = HashMap::new();
for line in out.lines() {
let mut fields = line.splitn(3, ' ');
let (Some(id), Some(kind), Some(name)) = (fields.next(), fields.next(), fields.next())
else {
continue;
};
let Some(name) = name.strip_prefix("refs/heads/") else {
continue;
};
let id = if kind == "commit" {
Some(id.to_string())
} else {
git.run(
root,
&[
"rev-parse",
"--verify",
"--quiet",
&format!("{id}^{{commit}}"),
],
)
.ok()
.map(|peeled| peeled.trim().to_string())
};
tips.insert(name.to_string(), id);
}
Ok(tips)
}
fn branch_is_gone(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 && out.stderr.trim().is_empty())
}
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(removal: &Removal<'_, '_>, worktree: &Worktree) {
let assessor = removal.assessor;
let Some(branch) = &worktree.branch else {
return;
};
if !removal.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(&removal.content, 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_eq!(v["dirty"], serde_json::json!(false));
assert_eq!(v["has_untracked"], serde_json::json!(false));
assert!(v["commit"]["hash"].is_string(), "{v}");
assert!(repo.git(&["worktree", "list"]).contains("merged-wt"));
}
#[test]
fn a_remote_default_at_the_local_commit_is_one_target() {
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",
]);
let r = crate::git::discover::Repo::discover(repo.root()).unwrap();
assert_eq!(super::MergeTargets::resolve(&r).refs, ["refs/heads/main"]);
repo.write("new.txt", "x\n");
repo.commit_all("local only");
let r = crate::git::discover::Repo::discover(repo.root()).unwrap();
assert_eq!(
super::MergeTargets::resolve(&r).refs,
["refs/heads/main", "refs/remotes/origin/main"]
);
}
#[test]
fn a_branch_pointing_at_a_tag_of_unpushed_work_is_unsafe() {
let repo = TestRepo::init();
diverged_branch(&repo, "work");
repo.git(&["tag", "-a", "-m", "note", "v-work", "work"]);
let tag = repo.git(&["rev-parse", "v-work"]).trim().to_string();
std::fs::write(
repo.root().join(".git/refs/heads/tagged"),
format!("{tag}\n"),
)
.unwrap();
repo.git(&["branch", "-D", "work"]);
repo.git(&["tag", "-d", "v-work"]);
let pushed = PruneArgs {
pushed: true,
..prune_args(false, false, false, false)
};
let err = run_yes(&repo, &pushed);
assert!(!err.contains("tagged"), "{err}");
assert!(has_branch(&repo, "tagged"));
}
#[test]
fn branch_skips_and_failures_are_reported_in_candidate_order() {
let repo = TestRepo::init();
bare_branch(&repo, "a-stuck");
bare_branch(&repo, "b-unsafe");
bare_branch(&repo, "c-stuck");
diverged_branch(&repo, "wip"); let mut t = crate::testutil::test_cx_with_git(
&[],
repo.root().to_str().unwrap(),
std::sync::Arc::new(Refuses(|args| {
(args.first() == Some(&"branch") && args.iter().any(|a| a.ends_with("-stuck")))
.then_some("fatal: no")
})),
);
t.cx.input = Box::new(git_then_confirm(
&repo,
&["branch", "-f", "b-unsafe", "wip"],
));
super::run(&mut t.cx, &all_run(), false).unwrap();
let err = t.err.contents();
let at = |needle: &str| {
err.find(needle)
.unwrap_or_else(|| panic!("{needle}: {err}"))
};
assert!(at("could not delete a-stuck") < at("skipping b-unsafe (branch)"));
assert!(at("skipping b-unsafe (branch)") < at("could not delete c-stuck"));
}
#[test]
fn a_branch_that_moves_before_its_delete_is_kept() {
let repo = TestRepo::init();
bare_branch(&repo, "old");
let dir = repo.root().to_path_buf();
let err = run_yes_on_tips_read(&repo, move |read| {
if read == RE_READ {
let tree = repo_git(&dir, &["rev-parse", "old^{tree}"]);
let commit = repo_git(&dir, &["commit-tree", &tree, "-p", "old", "-m", "late"]);
repo_git(&dir, &["update-ref", "refs/heads/old", &commit]);
}
false
});
assert!(
err.contains("skipping old (branch): moved since it was checked"),
"{err}"
);
assert!(has_branch(&repo, "old"));
}
#[test]
fn a_branch_deleted_elsewhere_before_its_delete_is_skipped_quietly() {
let repo = TestRepo::init();
bare_branch(&repo, "old");
let dir = repo.root().to_path_buf();
let err = run_yes_on_tips_read(&repo, move |read| {
if read == RE_READ {
repo_git(&dir, &["branch", "-D", "old"]);
}
false
});
assert!(!err.contains("skipping old"), "{err}");
assert!(!err.contains("could not delete"), "{err}");
assert!(err.contains("pruned 0 item(s)"), "{err}");
}
#[test]
fn an_unreadable_re_read_before_the_delete_deletes_nothing() {
let repo = TestRepo::init();
bare_branch(&repo, "old");
let err = run_yes_on_tips_read(&repo, |read| read == RE_READ);
assert!(
err.contains("skipping old (branch): cannot read its git state"),
"{err}"
);
assert!(err.contains("pruned 0 item(s)"), "{err}");
assert!(has_branch(&repo, "old"));
}
#[test]
fn a_branch_moved_onto_a_tag_of_a_tree_while_the_prompt_waits_is_kept() {
let repo = TestRepo::init();
bare_branch(&repo, "old");
repo.git(&["tag", "-a", "-m", "a tree", "v-tree", "HEAD^{tree}"]);
let tag = repo.git(&["rev-parse", "v-tree"]).trim().to_string();
let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
let root = repo.root().to_path_buf();
t.cx.input = Box::new(ActsThenConfirms(Box::new(move || {
std::fs::write(root.join(".git/refs/heads/old"), format!("{tag}\n")).unwrap();
})));
super::run(&mut t.cx, &prune_args(true, false, false, false), false).unwrap();
let err = t.err.contents();
assert!(
err.contains("skipping old (branch): has work on no remote"),
"{err}"
);
assert!(has_branch(&repo, "old"));
}
#[test]
fn an_unreadable_read_back_asks_git_branch_by_branch() {
let repo = TestRepo::init();
bare_branch(&repo, "old");
let err = run_yes_on_tips_read(&repo, |read| read == READ_BACK);
assert!(err.contains("pruned 1 item(s)"), "{err}");
assert!(!err.contains("could not delete"), "{err}");
assert!(!has_branch(&repo, "old"));
}
const RE_READ: usize = 1;
const READ_BACK: usize = 2;
fn repo_git(dir: &std::path::Path, args: &[&str]) -> String {
crate::testutil::run_git(dir, args).trim().to_string()
}
fn run_yes_on_tips_read(
repo: &TestRepo,
on_read: impl Fn(usize) -> bool + Send + Sync + 'static,
) -> String {
let git = std::sync::Arc::new(OnTipsRead {
reads: std::sync::atomic::AtomicUsize::new(0),
on_read: Box::new(on_read),
});
let mut t = crate::testutil::test_cx_with_git(&[], repo.root().to_str().unwrap(), git);
t.cx.assume_yes = true;
super::run(&mut t.cx, &all_run(), false).unwrap();
t.err.contents()
}
struct OnTipsRead {
reads: std::sync::atomic::AtomicUsize,
on_read: Box<dyn Fn(usize) -> bool + Send + Sync>,
}
impl crate::git::cli::GitCli for OnTipsRead {
fn run_raw(
&self,
repo: &std::path::Path,
args: &[&str],
) -> crate::error::Result<crate::git::cli::GitOutput> {
use std::sync::atomic::Ordering;
if args.first() == Some(&"for-each-ref") {
let read = self.reads.fetch_add(1, Ordering::SeqCst);
if (self.on_read)(read) {
return Ok(crate::git::cli::GitOutput {
success: false,
stdout: String::new(),
stderr: "fatal: refs".into(),
});
}
}
RealGit.run_raw(repo, args)
}
}
#[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 and could not confirm its \
changes are in the default branch; use --force"
),
"{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"));
let main = repo.git(&["rev-parse", "main"]).trim().to_string();
let unknown = "0123456789abcdef0123456789abcdef01234567".to_string();
assert_eq!(
assessor.recoverable_now(&[main.clone(), unknown]),
std::collections::HashSet::from([main])
);
});
}
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::new(git, repo.root(), &r, args, &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"));
}
struct ActsThenConfirms(Box<dyn FnMut() + Send>);
impl crate::cx::Input for ActsThenConfirms {
fn read_line(&mut self) -> crate::error::Result<String> {
(self.0)();
Ok("y\n".into())
}
}
fn git_then_confirm(repo: &TestRepo, args: &'static [&'static str]) -> ActsThenConfirms {
let dir = repo.root().to_path_buf();
ActsThenConfirms(Box::new(move || {
repo_git(&dir, args);
}))
}
#[test]
fn a_branch_moved_onto_a_nested_tag_while_the_prompt_waits_is_unsafe() {
let repo = TestRepo::init();
bare_branch(&repo, "old");
diverged_branch(&repo, "work");
repo.git(&["tag", "-a", "-m", "inner", "v-inner", "work"]);
repo.git(&["tag", "-a", "-m", "outer", "v-outer", "v-inner"]);
let outer = repo.git(&["rev-parse", "v-outer"]).trim().to_string();
let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
let root = repo.root().to_path_buf();
t.cx.input = Box::new(ActsThenConfirms(Box::new(move || {
std::fs::write(root.join(".git/refs/heads/old"), format!("{outer}\n")).unwrap();
repo_git(&root, &["branch", "-D", "work"]);
repo_git(&root, &["tag", "-d", "v-inner", "v-outer"]);
})));
super::run(&mut t.cx, &prune_args(true, false, false, false), false).unwrap();
let err = t.err.contents();
assert!(
err.contains("skipping old (branch): has work on no remote"),
"{err}"
);
assert!(has_branch(&repo, "old"));
}
#[test]
fn unreadable_metadata_is_cleared_for_every_deleted_branch() {
let repo = TestRepo::init();
bare_branch(&repo, "old");
crate::config::wtconfig::mark_created_by_wt(&RealGit, repo.root(), "old").unwrap();
let err = run_yes_with(
&repo,
Refuses(|args| {
(args.first() == Some(&"config") && args.contains(&"--get-regexp"))
.then_some("fatal: config")
}),
);
assert!(err.contains("pruned 1 item(s)"), "{err}");
let r = crate::git::discover::Repo::discover(repo.root()).unwrap();
assert!(!crate::config::wtconfig::read_meta(r.gix(), "old").created_by_wt);
}
#[test]
fn a_squash_merge_dropped_while_the_prompt_waits_keeps_the_branch() {
let repo = TestRepo::init();
diverged_branch(&repo, "feat");
squash_into_main(&repo, "feat");
let merged = report(&repo, &prune_args(true, false, true, false));
assert!(
merged.contains("feat (branch): merged by content"),
"{merged}"
);
let mut t = crate::testutil::test_cx(&[], repo.root().to_str().unwrap());
t.cx.input = Box::new(git_then_confirm(
&repo,
&["reset", "-q", "--hard", "HEAD~1"],
));
super::run(&mut t.cx, &prune_args(true, false, false, false), false).unwrap();
let err = t.err.contents();
assert!(
err.contains("skipping feat (branch): has work on no remote"),
"{err}"
);
assert!(has_branch(&repo, "feat"));
}
#[test]
fn a_gone_squash_merged_branch_main_edited_since_is_pruned() {
let repo = TestRepo::init();
diverged_branch(&repo, "feat");
give_gone_upstream(&repo, "feat");
squash_into_main(&repo, "feat");
repo.write("feat.txt", "edited\n");
repo.commit_all("main edits feat.txt");
let all = report(&repo, &all_dry_run());
assert!(
all.contains("would remove feat (branch): merged by content"),
"{all}"
);
}
#[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)
}
}
#[derive(Default)]
struct Counting(std::sync::Mutex<Vec<String>>);
impl crate::git::cli::GitCli for Counting {
fn run_raw(
&self,
repo: &std::path::Path,
args: &[&str],
) -> crate::error::Result<crate::git::cli::GitOutput> {
let verb = args
.iter()
.find(|a| !a.starts_with('-') && !a.contains('='))
.unwrap_or(&"");
self.0.lock().unwrap().push(verb.to_string());
RealGit.run_raw(repo, args)
}
}
fn spawns(branches: usize, args: &PruneArgs) -> std::collections::BTreeMap<String, usize> {
let repo = TestRepo::init();
make_wt(&repo, "done");
for i in 0..branches {
bare_branch(&repo, &format!("old{i}"));
}
let git = std::sync::Arc::new(Counting::default());
let mut t =
crate::testutil::test_cx_with_git(&[], repo.root().to_str().unwrap(), git.clone());
t.cx.assume_yes = true;
super::run(&mut t.cx, args, false).unwrap();
let err = t.err.contents();
if !args.dry_run {
assert!(
err.contains(&format!("pruned {} item(s)", branches + 1)),
"{err}"
);
}
let mut counts = std::collections::BTreeMap::new();
for verb in git.0.lock().unwrap().iter() {
*counts.entry(verb.clone()).or_insert(0) += 1;
}
counts
}
#[test]
fn git_spawns_do_not_grow_with_the_branch_count() {
let few = spawns(2, &all_dry_run());
assert_eq!(few, spawns(12, &all_dry_run()));
assert_eq!(few.get("rev-list"), Some(&1), "{few:?}");
assert_eq!(few.get("status"), Some(&1), "{few:?}");
let few = spawns(2, &all_run());
assert_eq!(few, spawns(12, &all_run()));
assert_eq!(few.get("branch"), Some(&2), "{few:?}");
assert_eq!(few.get("config").copied().unwrap_or(0), 2, "{few:?}");
}
struct Refuses(fn(&[&str]) -> Option<&'static str>);
impl crate::git::cli::GitCli for Refuses {
fn run_raw(
&self,
repo: &std::path::Path,
args: &[&str],
) -> crate::error::Result<crate::git::cli::GitOutput> {
if let Some(stderr) = (self.0)(args) {
return Ok(crate::git::cli::GitOutput {
success: false,
stdout: String::new(),
stderr: stderr.into(),
});
}
RealGit.run_raw(repo, args)
}
}
fn run_yes_with(repo: &TestRepo, git: Refuses) -> String {
let mut t = crate::testutil::test_cx_with_git(
&[],
repo.root().to_str().unwrap(),
std::sync::Arc::new(git),
);
t.cx.assume_yes = true;
super::run(&mut t.cx, &all_run(), false).unwrap();
t.err.contents()
}
#[test]
fn a_branch_the_batch_delete_leaves_is_retried_alone() {
let repo = TestRepo::init();
bare_branch(&repo, "one");
bare_branch(&repo, "two");
let err = run_yes_with(
&repo,
Refuses(|args| {
(args.first() == Some(&"branch") && args.len() > 3).then_some("fatal: batch")
}),
);
assert!(err.contains("pruned 2 item(s)"), "{err}");
assert!(!err.contains("could not delete"), "{err}");
assert!(!has_branch(&repo, "one"));
assert!(!has_branch(&repo, "two"));
}
#[test]
fn a_branch_git_will_not_delete_is_reported_on_its_own() {
let repo = TestRepo::init();
bare_branch(&repo, "stuck");
bare_branch(&repo, "fine");
let err = run_yes_with(
&repo,
Refuses(|args| {
(args.first() == Some(&"branch") && args.contains(&"stuck")).then_some("fatal: no")
}),
);
assert!(err.contains("could not delete stuck: fatal: no"), "{err}");
assert!(!err.contains("could not delete fine"), "{err}");
assert!(err.contains("pruned 1 item(s)"), "{err}");
assert!(has_branch(&repo, "stuck"));
assert!(!has_branch(&repo, "fine"));
}
#[test]
fn unreadable_branch_tips_under_the_lock_delete_no_branch() {
let repo = TestRepo::init();
bare_branch(&repo, "old");
let err = run_yes_with(
&repo,
Refuses(|args| (args.first() == Some(&"for-each-ref")).then_some("fatal: refs")),
);
assert!(
err.contains("skipping old (branch): cannot read its git state"),
"{err}"
);
assert!(err.contains("pruned 0 item(s)"), "{err}");
assert!(has_branch(&repo, "old"));
}
#[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));
}
}