Skip to main content

git_worktree_manager/operations/
delete_batch.rs

1//! Batch deletion orchestration for `gw delete`.
2//!
3//! Multi-target deletion 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::worktree::{self, DeleteFlags};
17
18/// Result of the interactive multi-select flow.
19///
20/// - `Selected(v)` — user confirmed with at least one pick; `v` is non-empty.
21/// - `Nothing` — no feature worktrees, or user confirmed with zero selections.
22///   Nothing-to-do is not an error; the orchestrator exits 0.
23/// - `Cancelled` — user pressed Esc / q / Ctrl-C. Orchestrator exits 1.
24enum InteractiveOutcome {
25    Selected(Vec<String>),
26    Nothing,
27    Cancelled,
28}
29
30/// Open the multi-select TUI to let the user choose which feature worktrees
31/// to delete. Distinguishes Selected / Nothing / Cancelled so the caller can
32/// map each to the exit code the spec requires.
33fn 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/// Resolved worktree target (path + optional branch).
75#[derive(Debug, Clone)]
76pub struct Resolved {
77    pub input: String,
78    pub path: PathBuf,
79    pub branch: Option<String>,
80}
81
82/// A single entry in the batch execution plan.
83#[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
97/// Resolve a list of user inputs against the main repository.
98///
99/// Inputs may be branch names, worktree directory names, or filesystem paths.
100/// Anything that does not resolve becomes a `PlanEntry::Unresolved`.
101pub 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    // 1) filesystem path
118    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    // 2) branch lookup
130    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    // 3) worktree name lookup
141    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
155/// Annotate resolved entries with busy status. Unresolved entries pass through.
156pub 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
180/// Counters for summary output.
181struct 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
203/// Print the batch summary. Goes to stdout to match the convention used by
204/// `gw clean` (summary/progress → stdout, errors/prompts → stderr).
205pub 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
252/// Ask for a single y/N confirmation on the whole batch. Only invoked when
253/// planned.ready > 1 (or planned.ready >= 1 combined with skips worth
254/// surfacing). Returns true if the user confirmed.
255pub fn confirm_batch() -> bool {
256    if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) {
257        return true; // non-interactive: assume confirmed (scripted usage)
258    }
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/// Final outcome used to compute exit code and summary.
270///
271/// Fields retain label/reason/error for Debug output and future per-item
272/// reporting. The current summary only counts variants, so dead-code lint is
273/// silenced here.
274#[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
293/// Execute the plan sequentially. Best-effort: one failure does not abort.
294fn 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                // progress line → stdout
302                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                        // failure → stderr
312                        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                // Summary line → stdout.
324                println!("{} Skipped {} (busy)", style("~").yellow(), label);
325                // Error mirror → stderr. Required so non-TTY `gw delete`
326                // against a busy worktree emits a stderr hint matching the
327                // legacy single-target flow (see tests/busy_detection.rs).
328                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
380/// If cwd lives inside any Ready/Busy target path, chdir to the main repo
381/// root. Prevents the current `gw` process from being flagged as a busy holder
382/// of the worktree it is being asked to remove.
383///
384/// Canonicalize failures on either side are treated as "skip this comparison"
385/// rather than falling back to the raw path. On filesystems with symlinked
386/// tempdirs (e.g. `/var` -> `/private/var` on macOS) an asymmetric fallback
387/// could mis-classify and leave cwd in the target.
388fn 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
413/// Top-level orchestrator for `gw delete`.
414///
415/// `inputs` is empty for the legacy "current worktree" case and for the
416/// `-i` interactive case — the caller passes `interactive=true` to trigger the
417/// selector.
418pub 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    // 1) Decide the initial input set.
426    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        // Legacy path: delegate to the single-target shim and return its exit
439        // code. Keeps the "no-args inside a worktree deletes current" behavior
440        // and its busy prompt exactly as today.
441        return legacy_single_current(flags, lookup_mode);
442    } else {
443        inputs
444    };
445
446    // 2) Resolve all inputs against the repo.
447    let entries = resolve_all(&initial_inputs, lookup_mode)?;
448
449    // 2.5) If cwd is inside any resolved target, move to the main repo *before*
450    // busy detection so the running `gw` process doesn't register as a busy
451    // holder of a worktree it's trying to delete. Mirrors the legacy
452    // `delete_worktree` behavior.
453    move_cwd_out_of_targets(&entries);
454
455    // 3) Plan busy status.
456    let entries = plan_busy(entries, flags.allow_busy);
457
458    // 4) Print summary.
459    print_summary(&entries, dry_run);
460
461    // 5) Dry-run short-circuits before execution.
462    if dry_run {
463        return Ok(0);
464    }
465
466    // 6) Batch confirmation when the plan has more than one entry
467    //    (Ready + Busy + Unresolved combined). Single-entry explicit
468    //    positional goes straight to execute.
469    if entries.len() >= 2 && !confirm_batch() {
470        eprintln!("Cancelled.");
471        return Ok(1);
472    }
473
474    // 7) Execute.
475    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}