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