use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use console::style;
use crate::error::{CwError, Result};
use crate::git;
use crate::operations::busy::{self, BusyInfo};
use crate::operations::busy_messages;
use crate::operations::worktree::{self, DeleteFlags};
enum InteractiveOutcome {
Selected(Vec<String>),
Nothing,
Cancelled,
}
fn interactive_select(main_repo: &Path) -> Result<InteractiveOutcome> {
let feature_worktrees = git::get_feature_worktrees(Some(main_repo))?;
if feature_worktrees.is_empty() {
eprintln!("No feature worktrees to delete.");
return Ok(InteractiveOutcome::Nothing);
}
let labels: Vec<String> = feature_worktrees
.iter()
.map(|(branch, path)| format!("{:<30} {}", branch, path.display()))
.collect();
match crate::tui::multi_select::multi_select(&labels, "Select worktrees to delete:") {
Some(indices) if indices.is_empty() => {
eprintln!("Nothing selected.");
Ok(InteractiveOutcome::Nothing)
}
Some(indices) => {
let selected: Vec<String> = indices
.into_iter()
.map(|i| feature_worktrees[i].0.clone())
.collect();
Ok(InteractiveOutcome::Selected(selected))
}
None => {
eprintln!("Cancelled.");
Ok(InteractiveOutcome::Cancelled)
}
}
}
#[derive(Debug, Clone)]
pub struct Resolved {
pub input: String,
pub path: PathBuf,
pub branch: Option<String>,
}
#[derive(Debug)]
pub enum PlanEntry {
Ready(Resolved),
Busy {
resolved: Resolved,
hard: Vec<BusyInfo>,
soft: Vec<BusyInfo>,
},
Unresolved {
input: String,
reason: String,
},
}
pub fn resolve_all(inputs: &[String], lookup_mode: Option<&str>) -> Result<Vec<PlanEntry>> {
let main_repo = git::get_main_repo_root(None)?;
let mut out = Vec::with_capacity(inputs.len());
for input in inputs {
match resolve_one(input, &main_repo, lookup_mode) {
Some(resolved) => out.push(PlanEntry::Ready(resolved)),
None => out.push(PlanEntry::Unresolved {
input: input.clone(),
reason: "not found".into(),
}),
}
}
Ok(out)
}
fn resolve_one(input: &str, main_repo: &Path, lookup_mode: Option<&str>) -> Option<Resolved> {
let p = PathBuf::from(input);
if p.exists() {
let resolved = p.canonicalize().unwrap_or(p);
let branch = crate::operations::helpers::get_branch_for_worktree(main_repo, &resolved);
return Some(Resolved {
input: input.to_string(),
path: resolved,
branch,
});
}
if lookup_mode != Some("worktree") {
if let Ok(Some(path)) = git::find_worktree_by_intended_branch(main_repo, input) {
return Some(Resolved {
input: input.to_string(),
path,
branch: Some(input.to_string()),
});
}
}
if lookup_mode != Some("branch") {
if let Ok(Some(path)) = git::find_worktree_by_name(main_repo, input) {
let branch = crate::operations::helpers::get_branch_for_worktree(main_repo, &path);
return Some(Resolved {
input: input.to_string(),
path,
branch,
});
}
}
None
}
pub fn plan_busy(entries: Vec<PlanEntry>, allow_busy: bool) -> Vec<PlanEntry> {
if allow_busy {
return entries;
}
entries
.into_iter()
.map(|entry| match entry {
PlanEntry::Ready(r) => {
let (hard, soft) = busy::detect_busy_tiered(&r.path);
if hard.is_empty() && soft.is_empty() {
PlanEntry::Ready(r)
} else {
PlanEntry::Busy {
resolved: r,
hard,
soft,
}
}
}
other => other,
})
.collect()
}
struct PlanCounts {
ready: usize,
busy: usize,
unresolved: usize,
}
fn count(entries: &[PlanEntry]) -> PlanCounts {
let mut c = PlanCounts {
ready: 0,
busy: 0,
unresolved: 0,
};
for e in entries {
match e {
PlanEntry::Ready(_) => c.ready += 1,
PlanEntry::Busy { .. } => c.busy += 1,
PlanEntry::Unresolved { .. } => c.unresolved += 1,
}
}
c
}
pub fn print_summary(entries: &[PlanEntry], dry_run: bool) {
let counts = count(entries);
let header = if dry_run {
format!("Would delete {} worktree(s):", counts.ready)
} else {
let busy_note = if counts.busy > 0 {
format!(" ({} busy, will skip without --force)", counts.busy)
} else {
String::new()
};
format!("Deleting {} worktree(s){}:", counts.ready, busy_note)
};
println!("\n{}", style(header).yellow().bold());
for e in entries {
match e {
PlanEntry::Ready(r) => {
let label = r.branch.as_deref().unwrap_or(&r.input);
println!(" {:<30} {}", label, r.path.display());
}
PlanEntry::Busy {
resolved,
hard,
soft,
} => {
let label = resolved.branch.as_deref().unwrap_or(&resolved.input);
let detail = hard
.first()
.or_else(|| soft.first())
.map(|b| format!("PID {} {}", b.pid, b.cmd))
.unwrap_or_default();
println!(" {:<30} (busy: {}) [skip]", label, detail);
}
PlanEntry::Unresolved { input, reason } => {
println!(" {:<30} [{}] [skip]", input, reason);
}
}
}
println!(
"Total: {} planned, {} not found, {} busy",
counts.ready, counts.unresolved, counts.busy
);
if dry_run {
println!("(dry-run; nothing deleted)");
}
println!();
}
pub fn confirm_batch() -> bool {
if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) {
return true; }
eprint!("Proceed? (y/N): ");
let _ = std::io::stderr().flush();
let mut buf = String::new();
if std::io::stdin().read_line(&mut buf).is_err() {
return false;
}
let ans = buf.trim().to_lowercase();
ans == "y" || ans == "yes"
}
#[derive(Debug)]
#[allow(dead_code)]
enum ItemResult {
Deleted(String),
Skipped { label: String, reason: String },
Failed { label: String, error: CwError },
}
fn label_of(entry: &PlanEntry) -> String {
match entry {
PlanEntry::Ready(r) => r.branch.clone().unwrap_or_else(|| r.input.clone()),
PlanEntry::Busy { resolved, .. } => resolved
.branch
.clone()
.unwrap_or_else(|| resolved.input.clone()),
PlanEntry::Unresolved { input, .. } => input.clone(),
}
}
fn execute_all(entries: Vec<PlanEntry>, flags: DeleteFlags) -> Result<Vec<ItemResult>> {
let main_repo = git::get_main_repo_root(None)?;
let mut results = Vec::with_capacity(entries.len());
for entry in entries {
let label = label_of(&entry);
match entry {
PlanEntry::Ready(r) => {
println!("{} Deleting {}", style("•").cyan().bold(), label);
match worktree::delete_one(&r.path, r.branch.as_deref(), &main_repo, flags) {
worktree::DeletionOutcome::Deleted { .. } => {
results.push(ItemResult::Deleted(label));
}
worktree::DeletionOutcome::Skipped { reason } => {
results.push(ItemResult::Skipped { label, reason });
}
worktree::DeletionOutcome::Failed { error } => {
eprintln!(
"{} Failed to delete {}: {}",
style("x").red().bold(),
label,
error
);
results.push(ItemResult::Failed { label, error });
}
}
}
PlanEntry::Busy { hard, soft, .. } => {
println!("{} Skipped {} (busy)", style("~").yellow(), label);
eprint!(
"{} {}",
style("error:").red().bold(),
busy_messages::render_refusal(&label, &hard, &soft)
);
results.push(ItemResult::Skipped {
label,
reason: "busy".into(),
});
}
PlanEntry::Unresolved { input, reason } => {
println!("{} Skipped {} ({})", style("~").yellow(), input, reason);
results.push(ItemResult::Skipped {
label: input,
reason,
});
}
}
}
Ok(results)
}
fn print_results(results: &[ItemResult]) {
let deleted = results
.iter()
.filter(|r| matches!(r, ItemResult::Deleted(_)))
.count();
let skipped = results
.iter()
.filter(|r| matches!(r, ItemResult::Skipped { .. }))
.count();
let failed = results
.iter()
.filter(|r| matches!(r, ItemResult::Failed { .. }))
.count();
println!(
"\nSummary: {} deleted, {} skipped, {} failed",
deleted, skipped, failed
);
}
fn exit_code_from(results: &[ItemResult]) -> i32 {
let any_bad = results
.iter()
.any(|r| matches!(r, ItemResult::Failed { .. } | ItemResult::Skipped { .. }));
if any_bad {
2
} else {
0
}
}
fn move_cwd_out_of_targets(entries: &[PlanEntry]) {
let Ok(cwd) = std::env::current_dir() else {
return;
};
let Ok(cwd_canon) = cwd.canonicalize() else {
return;
};
for e in entries {
let path = match e {
PlanEntry::Ready(r) => &r.path,
PlanEntry::Busy { resolved, .. } => &resolved.path,
PlanEntry::Unresolved { .. } => continue,
};
let Ok(wt_canon) = path.canonicalize() else {
continue;
};
if cwd_canon.starts_with(&wt_canon) {
if let Ok(main_repo) = git::get_main_repo_root(None) {
let _ = std::env::set_current_dir(&main_repo);
}
return;
}
}
}
pub fn delete_worktrees(
inputs: Vec<String>,
interactive: bool,
dry_run: bool,
flags: DeleteFlags,
lookup_mode: Option<&str>,
) -> Result<i32> {
let initial_inputs: Vec<String> = if interactive {
debug_assert!(
inputs.is_empty(),
"clap should have rejected -i with positionals"
);
let main_repo = git::get_main_repo_root(None)?;
match interactive_select(&main_repo)? {
InteractiveOutcome::Selected(v) => v,
InteractiveOutcome::Nothing => return Ok(0),
InteractiveOutcome::Cancelled => return Ok(1),
}
} else if inputs.is_empty() {
return legacy_single_current(flags, lookup_mode);
} else {
inputs
};
let entries = resolve_all(&initial_inputs, lookup_mode)?;
move_cwd_out_of_targets(&entries);
let entries = plan_busy(entries, flags.allow_busy);
print_summary(&entries, dry_run);
if dry_run {
return Ok(0);
}
if entries.len() >= 2 && !confirm_batch() {
eprintln!("Cancelled.");
return Ok(1);
}
let results = execute_all(entries, flags)?;
print_results(&results);
Ok(exit_code_from(&results))
}
fn legacy_single_current(flags: DeleteFlags, lookup_mode: Option<&str>) -> Result<i32> {
match worktree::delete_worktree(
None,
flags.keep_branch,
flags.delete_remote,
flags.git_force,
flags.allow_busy,
lookup_mode,
) {
Ok(()) => Ok(0),
Err(e) => {
eprintln!("{} {}", style("error:").red().bold(), e);
Ok(2)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plan_busy_passthrough_when_allowed() {
let entries = vec![PlanEntry::Unresolved {
input: "x".into(),
reason: "not found".into(),
}];
let out = plan_busy(entries, true);
assert_eq!(out.len(), 1);
assert!(matches!(out[0], PlanEntry::Unresolved { .. }));
}
#[test]
fn plan_busy_passes_unresolved_through_when_not_allowed() {
let entries = vec![PlanEntry::Unresolved {
input: "x".into(),
reason: "not found".into(),
}];
let out = plan_busy(entries, false);
assert_eq!(out.len(), 1);
assert!(matches!(out[0], PlanEntry::Unresolved { .. }));
}
#[test]
fn count_buckets_entries_correctly() {
let entries = vec![
PlanEntry::Ready(Resolved {
input: "a".into(),
path: PathBuf::from("/tmp/a"),
branch: Some("a".into()),
}),
PlanEntry::Busy {
resolved: Resolved {
input: "b".into(),
path: PathBuf::from("/tmp/b"),
branch: Some("b".into()),
},
hard: vec![],
soft: vec![],
},
PlanEntry::Unresolved {
input: "c".into(),
reason: "not found".into(),
},
];
let c = count(&entries);
assert_eq!(c.ready, 1);
assert_eq!(c.busy, 1);
assert_eq!(c.unresolved, 1);
}
}