1use std::io::{IsTerminal, Write};
8use std::path::{Path, PathBuf};
9
10use console::style;
11
12use crate::error::{CwError, Result};
13use crate::git;
14use crate::operations::busy::{self, BusyInfo};
15use crate::operations::busy_messages;
16use crate::operations::worktree::{self, DeleteFlags};
17
18enum InteractiveOutcome {
25 Selected(Vec<String>),
26 Nothing,
27 Cancelled,
28}
29
30fn interactive_select(main_repo: &Path) -> Result<InteractiveOutcome> {
34 let feature_worktrees = git::get_feature_worktrees(Some(main_repo))?;
35 if feature_worktrees.is_empty() {
36 eprintln!("No feature worktrees to delete.");
37 return Ok(InteractiveOutcome::Nothing);
38 }
39 let labels: Vec<String> = feature_worktrees
40 .iter()
41 .map(|(branch, path)| format!("{:<30} {}", branch, path.display()))
42 .collect();
43 match crate::tui::multi_select::multi_select(&labels, "Select worktrees to delete:") {
44 Some(indices) if indices.is_empty() => {
45 eprintln!("Nothing selected.");
46 Ok(InteractiveOutcome::Nothing)
47 }
48 Some(indices) => {
49 let selected: Vec<String> = indices
50 .into_iter()
51 .map(|i| feature_worktrees[i].0.clone())
52 .collect();
53 Ok(InteractiveOutcome::Selected(selected))
54 }
55 None => {
56 eprintln!("Cancelled.");
57 Ok(InteractiveOutcome::Cancelled)
58 }
59 }
60}
61
62#[derive(Debug, Clone)]
64pub struct Resolved {
65 pub input: String,
66 pub path: PathBuf,
67 pub branch: Option<String>,
68}
69
70#[derive(Debug)]
72pub enum PlanEntry {
73 Ready(Resolved),
74 Busy {
75 resolved: Resolved,
76 hard: Vec<BusyInfo>,
77 soft: Vec<BusyInfo>,
78 },
79 Unresolved {
80 input: String,
81 reason: String,
82 },
83}
84
85pub fn resolve_all(inputs: &[String], lookup_mode: Option<&str>) -> Result<Vec<PlanEntry>> {
90 let main_repo = git::get_main_repo_root(None)?;
91 let mut out = Vec::with_capacity(inputs.len());
92 for input in inputs {
93 match resolve_one(input, &main_repo, lookup_mode) {
94 Some(resolved) => out.push(PlanEntry::Ready(resolved)),
95 None => out.push(PlanEntry::Unresolved {
96 input: input.clone(),
97 reason: "not found".into(),
98 }),
99 }
100 }
101 Ok(out)
102}
103
104fn resolve_one(input: &str, main_repo: &Path, lookup_mode: Option<&str>) -> Option<Resolved> {
105 let p = PathBuf::from(input);
107 if p.exists() {
108 let resolved = p.canonicalize().unwrap_or(p);
109 let branch = crate::operations::helpers::get_branch_for_worktree(main_repo, &resolved);
110 return Some(Resolved {
111 input: input.to_string(),
112 path: resolved,
113 branch,
114 });
115 }
116
117 if lookup_mode != Some("worktree") {
119 if let Ok(Some(path)) = git::find_worktree_by_intended_branch(main_repo, input) {
120 return Some(Resolved {
121 input: input.to_string(),
122 path,
123 branch: Some(input.to_string()),
124 });
125 }
126 }
127
128 if lookup_mode != Some("branch") {
130 if let Ok(Some(path)) = git::find_worktree_by_name(main_repo, input) {
131 let branch = crate::operations::helpers::get_branch_for_worktree(main_repo, &path);
132 return Some(Resolved {
133 input: input.to_string(),
134 path,
135 branch,
136 });
137 }
138 }
139
140 None
141}
142
143pub fn plan_busy(entries: Vec<PlanEntry>, allow_busy: bool) -> Vec<PlanEntry> {
145 if allow_busy {
146 return entries;
147 }
148 entries
149 .into_iter()
150 .map(|entry| match entry {
151 PlanEntry::Ready(r) => {
152 let (hard, soft) = busy::detect_busy_tiered(&r.path);
153 if hard.is_empty() && soft.is_empty() {
154 PlanEntry::Ready(r)
155 } else {
156 PlanEntry::Busy {
157 resolved: r,
158 hard,
159 soft,
160 }
161 }
162 }
163 other => other,
164 })
165 .collect()
166}
167
168struct PlanCounts {
170 ready: usize,
171 busy: usize,
172 unresolved: usize,
173}
174
175fn count(entries: &[PlanEntry]) -> PlanCounts {
176 let mut c = PlanCounts {
177 ready: 0,
178 busy: 0,
179 unresolved: 0,
180 };
181 for e in entries {
182 match e {
183 PlanEntry::Ready(_) => c.ready += 1,
184 PlanEntry::Busy { .. } => c.busy += 1,
185 PlanEntry::Unresolved { .. } => c.unresolved += 1,
186 }
187 }
188 c
189}
190
191pub fn print_summary(entries: &[PlanEntry], dry_run: bool) {
194 let counts = count(entries);
195 let header = if dry_run {
196 format!("Would delete {} worktree(s):", counts.ready)
197 } else {
198 let busy_note = if counts.busy > 0 {
199 format!(" ({} busy, will skip without --force)", counts.busy)
200 } else {
201 String::new()
202 };
203 format!("Deleting {} worktree(s){}:", counts.ready, busy_note)
204 };
205 println!("\n{}", style(header).yellow().bold());
206 for e in entries {
207 match e {
208 PlanEntry::Ready(r) => {
209 let label = r.branch.as_deref().unwrap_or(&r.input);
210 println!(" {:<30} {}", label, r.path.display());
211 }
212 PlanEntry::Busy {
213 resolved,
214 hard,
215 soft,
216 } => {
217 let label = resolved.branch.as_deref().unwrap_or(&resolved.input);
218 let detail = hard
219 .first()
220 .or_else(|| soft.first())
221 .map(|b| format!("PID {} {}", b.pid, b.cmd))
222 .unwrap_or_default();
223 println!(" {:<30} (busy: {}) [skip]", label, detail);
224 }
225 PlanEntry::Unresolved { input, reason } => {
226 println!(" {:<30} [{}] [skip]", input, reason);
227 }
228 }
229 }
230 println!(
231 "Total: {} planned, {} not found, {} busy",
232 counts.ready, counts.unresolved, counts.busy
233 );
234 if dry_run {
235 println!("(dry-run; nothing deleted)");
236 }
237 println!();
238}
239
240pub fn confirm_batch() -> bool {
244 if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) {
245 return true; }
247 eprint!("Proceed? (y/N): ");
248 let _ = std::io::stderr().flush();
249 let mut buf = String::new();
250 if std::io::stdin().read_line(&mut buf).is_err() {
251 return false;
252 }
253 let ans = buf.trim().to_lowercase();
254 ans == "y" || ans == "yes"
255}
256
257#[derive(Debug)]
263#[allow(dead_code)]
264enum ItemResult {
265 Deleted(String),
266 Skipped { label: String, reason: String },
267 Failed { label: String, error: CwError },
268}
269
270fn label_of(entry: &PlanEntry) -> String {
271 match entry {
272 PlanEntry::Ready(r) => r.branch.clone().unwrap_or_else(|| r.input.clone()),
273 PlanEntry::Busy { resolved, .. } => resolved
274 .branch
275 .clone()
276 .unwrap_or_else(|| resolved.input.clone()),
277 PlanEntry::Unresolved { input, .. } => input.clone(),
278 }
279}
280
281fn execute_all(entries: Vec<PlanEntry>, flags: DeleteFlags) -> Result<Vec<ItemResult>> {
283 let main_repo = git::get_main_repo_root(None)?;
284 let mut results = Vec::with_capacity(entries.len());
285 for entry in entries {
286 let label = label_of(&entry);
287 match entry {
288 PlanEntry::Ready(r) => {
289 println!("{} Deleting {}", style("•").cyan().bold(), label);
291 match worktree::delete_one(&r.path, r.branch.as_deref(), &main_repo, flags) {
292 worktree::DeletionOutcome::Deleted { .. } => {
293 results.push(ItemResult::Deleted(label));
294 }
295 worktree::DeletionOutcome::Skipped { reason } => {
296 results.push(ItemResult::Skipped { label, reason });
297 }
298 worktree::DeletionOutcome::Failed { error } => {
299 eprintln!(
301 "{} Failed to delete {}: {}",
302 style("x").red().bold(),
303 label,
304 error
305 );
306 results.push(ItemResult::Failed { label, error });
307 }
308 }
309 }
310 PlanEntry::Busy { hard, soft, .. } => {
311 println!("{} Skipped {} (busy)", style("~").yellow(), label);
313 eprint!(
317 "{} {}",
318 style("error:").red().bold(),
319 busy_messages::render_refusal(&label, &hard, &soft)
320 );
321 results.push(ItemResult::Skipped {
322 label,
323 reason: "busy".into(),
324 });
325 }
326 PlanEntry::Unresolved { input, reason } => {
327 println!("{} Skipped {} ({})", style("~").yellow(), input, reason);
328 results.push(ItemResult::Skipped {
329 label: input,
330 reason,
331 });
332 }
333 }
334 }
335 Ok(results)
336}
337
338fn print_results(results: &[ItemResult]) {
339 let deleted = results
340 .iter()
341 .filter(|r| matches!(r, ItemResult::Deleted(_)))
342 .count();
343 let skipped = results
344 .iter()
345 .filter(|r| matches!(r, ItemResult::Skipped { .. }))
346 .count();
347 let failed = results
348 .iter()
349 .filter(|r| matches!(r, ItemResult::Failed { .. }))
350 .count();
351 println!(
352 "\nSummary: {} deleted, {} skipped, {} failed",
353 deleted, skipped, failed
354 );
355}
356
357fn exit_code_from(results: &[ItemResult]) -> i32 {
358 let any_bad = results
359 .iter()
360 .any(|r| matches!(r, ItemResult::Failed { .. } | ItemResult::Skipped { .. }));
361 if any_bad {
362 2
363 } else {
364 0
365 }
366}
367
368fn move_cwd_out_of_targets(entries: &[PlanEntry]) {
377 let Ok(cwd) = std::env::current_dir() else {
378 return;
379 };
380 let Ok(cwd_canon) = cwd.canonicalize() else {
381 return;
382 };
383 for e in entries {
384 let path = match e {
385 PlanEntry::Ready(r) => &r.path,
386 PlanEntry::Busy { resolved, .. } => &resolved.path,
387 PlanEntry::Unresolved { .. } => continue,
388 };
389 let Ok(wt_canon) = path.canonicalize() else {
390 continue;
391 };
392 if cwd_canon.starts_with(&wt_canon) {
393 if let Ok(main_repo) = git::get_main_repo_root(None) {
394 let _ = std::env::set_current_dir(&main_repo);
395 }
396 return;
397 }
398 }
399}
400
401pub fn delete_worktrees(
407 inputs: Vec<String>,
408 interactive: bool,
409 dry_run: bool,
410 flags: DeleteFlags,
411 lookup_mode: Option<&str>,
412) -> Result<i32> {
413 let initial_inputs: Vec<String> = if interactive {
415 debug_assert!(
416 inputs.is_empty(),
417 "clap should have rejected -i with positionals"
418 );
419 let main_repo = git::get_main_repo_root(None)?;
420 match interactive_select(&main_repo)? {
421 InteractiveOutcome::Selected(v) => v,
422 InteractiveOutcome::Nothing => return Ok(0),
423 InteractiveOutcome::Cancelled => return Ok(1),
424 }
425 } else if inputs.is_empty() {
426 return legacy_single_current(flags, lookup_mode);
430 } else {
431 inputs
432 };
433
434 let entries = resolve_all(&initial_inputs, lookup_mode)?;
436
437 move_cwd_out_of_targets(&entries);
442
443 let entries = plan_busy(entries, flags.allow_busy);
445
446 print_summary(&entries, dry_run);
448
449 if dry_run {
451 return Ok(0);
452 }
453
454 if entries.len() >= 2 && !confirm_batch() {
458 eprintln!("Cancelled.");
459 return Ok(1);
460 }
461
462 let results = execute_all(entries, flags)?;
464 print_results(&results);
465 Ok(exit_code_from(&results))
466}
467
468fn legacy_single_current(flags: DeleteFlags, lookup_mode: Option<&str>) -> Result<i32> {
469 match worktree::delete_worktree(
470 None,
471 flags.keep_branch,
472 flags.delete_remote,
473 flags.git_force,
474 flags.allow_busy,
475 lookup_mode,
476 ) {
477 Ok(()) => Ok(0),
478 Err(e) => {
479 eprintln!("{} {}", style("error:").red().bold(), e);
480 Ok(2)
481 }
482 }
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488
489 #[test]
490 fn plan_busy_passthrough_when_allowed() {
491 let entries = vec![PlanEntry::Unresolved {
492 input: "x".into(),
493 reason: "not found".into(),
494 }];
495 let out = plan_busy(entries, true);
496 assert_eq!(out.len(), 1);
497 assert!(matches!(out[0], PlanEntry::Unresolved { .. }));
498 }
499
500 #[test]
501 fn plan_busy_passes_unresolved_through_when_not_allowed() {
502 let entries = vec![PlanEntry::Unresolved {
503 input: "x".into(),
504 reason: "not found".into(),
505 }];
506 let out = plan_busy(entries, false);
507 assert_eq!(out.len(), 1);
508 assert!(matches!(out[0], PlanEntry::Unresolved { .. }));
509 }
510
511 #[test]
512 fn count_buckets_entries_correctly() {
513 let entries = vec![
514 PlanEntry::Ready(Resolved {
515 input: "a".into(),
516 path: PathBuf::from("/tmp/a"),
517 branch: Some("a".into()),
518 }),
519 PlanEntry::Busy {
520 resolved: Resolved {
521 input: "b".into(),
522 path: PathBuf::from("/tmp/b"),
523 branch: Some("b".into()),
524 },
525 hard: vec![],
526 soft: vec![],
527 },
528 PlanEntry::Unresolved {
529 input: "c".into(),
530 reason: "not found".into(),
531 },
532 ];
533 let c = count(&entries);
534 assert_eq!(c.ready, 1);
535 assert_eq!(c.busy, 1);
536 assert_eq!(c.unresolved, 1);
537 }
538}