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