Skip to main content

git_worktree_manager/operations/
rm_batch.rs

1//! Batch removal orchestration for `gw rm`.
2//!
3//! Multi-target removal pipeline: resolve-all → plan (busy) → summary →
4//! confirm → execute → exit code. Reuses `worktree::delete_one` for per-target
5//! execution.
6
7use 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
19/// Result of the interactive multi-select flow.
20///
21/// - `Selected(v)` — user confirmed with at least one pick; `v` is non-empty.
22/// - `Nothing` — no feature worktrees, or user confirmed with zero selections.
23///   Nothing-to-do is not an error; the orchestrator exits 0.
24/// - `Cancelled` — user pressed Esc / q / Ctrl-C. Orchestrator exits 1.
25enum InteractiveOutcome {
26    Selected(Vec<String>),
27    Nothing,
28    Cancelled,
29}
30
31/// Open the multi-select TUI to let the user choose which feature worktrees
32/// to remove. Distinguishes Selected / Nothing / Cancelled so the caller can
33/// map each to the exit code the spec requires.
34fn 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/// Resolved worktree target (path + optional branch).
76#[derive(Debug, Clone)]
77pub struct Resolved {
78    pub input: String,
79    pub path: PathBuf,
80    pub branch: Option<String>,
81}
82
83/// A single entry in the batch execution plan.
84#[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
98/// Resolve a list of user inputs against the main repository.
99///
100/// Each input is resolved via strict ordered resolution:
101/// exact worktree name → exact branch name → exact path.
102/// Anything that does not match becomes a `PlanEntry::Unresolved`.
103pub 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
129/// Annotate resolved entries with busy status. Unresolved entries pass through.
130pub 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
154/// Counters for summary output.
155struct 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
177/// Print the batch summary. Summary/progress goes to stdout; errors and
178/// prompts go to stderr.
179pub 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
226/// Ask for a single y/N confirmation on the whole batch. Only invoked when
227/// planned.ready > 1 (or planned.ready >= 1 combined with skips worth
228/// surfacing). Returns true if the user confirmed.
229pub fn confirm_batch() -> bool {
230    if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) {
231        return true; // non-interactive: assume confirmed (scripted usage)
232    }
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/// Final outcome used to compute exit code and summary.
244///
245/// Fields retain label/reason/error for Debug output and future per-item
246/// reporting. The current summary only counts variants, so dead-code lint is
247/// silenced here.
248#[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
267/// Execute the plan sequentially. Best-effort: one failure does not abort.
268fn 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                // progress line → stdout
276                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                        // failure → stderr
286                        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                // Summary line → stdout.
298                println!("{} Skipped {} (busy)", style("~").yellow(), label);
299                // Error mirror → stderr. Required so non-TTY `gw rm`
300                // against a busy worktree emits a stderr hint matching the
301                // legacy single-target flow (see tests/busy_detection.rs).
302                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
354/// If cwd lives inside any Ready/Busy target path, chdir to the main repo
355/// root. Prevents the current `gw` process from being flagged as a busy holder
356/// of the worktree it is being asked to remove.
357///
358/// Canonicalize failures on either side are treated as "skip this comparison"
359/// rather than falling back to the raw path. On filesystems with symlinked
360/// tempdirs (e.g. `/var` -> `/private/var` on macOS) an asymmetric fallback
361/// could mis-classify and leave cwd in the target.
362fn 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
387/// Top-level orchestrator for `gw rm`.
388///
389/// `inputs` is empty for the legacy "current worktree" case and for the
390/// `-i` interactive case — the caller passes `interactive=true` to trigger the
391/// selector.
392pub fn rm_worktrees(
393    inputs: Vec<String>,
394    interactive: bool,
395    dry_run: bool,
396    flags: RmFlags,
397) -> Result<i32> {
398    // 1) Decide the initial input set.
399    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        // Legacy path: delegate to the single-target shim and return its exit
412        // code. Keeps the "no-args inside a worktree deletes current" behavior
413        // and its busy prompt exactly as today.
414        return legacy_single_current(flags);
415    } else {
416        inputs
417    };
418
419    // 2) Resolve all inputs against the repo.
420    let entries = resolve_all(&initial_inputs)?;
421
422    // 2.5) If cwd is inside any resolved target, move to the main repo *before*
423    // busy detection so the running `gw` process doesn't register as a busy
424    // holder of a worktree it's trying to delete. Mirrors the legacy
425    // `delete_worktree` behavior.
426    move_cwd_out_of_targets(&entries);
427
428    // 3) Plan busy status.
429    let entries = plan_busy(entries, flags.allow_busy);
430
431    // 4) Print summary.
432    print_summary(&entries, dry_run);
433
434    // 5) Dry-run short-circuits before execution.
435    if dry_run {
436        return Ok(0);
437    }
438
439    // 6) Batch confirmation when the plan has more than one entry
440    //    (Ready + Busy + Unresolved combined). Single-entry explicit
441    //    positional goes straight to execute.
442    if entries.len() >= 2 && !confirm_batch() {
443        eprintln!("Cancelled.");
444        return Ok(1);
445    }
446
447    // 7) Execute.
448    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}