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)| 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/// Resolved worktree target (path + optional branch).
63#[derive(Debug, Clone)]
64pub struct Resolved {
65    pub input: String,
66    pub path: PathBuf,
67    pub branch: Option<String>,
68}
69
70/// A single entry in the batch execution plan.
71#[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
85/// Resolve a list of user inputs against the main repository.
86///
87/// Inputs may be branch names, worktree directory names, or filesystem paths.
88/// Anything that does not resolve becomes a `PlanEntry::Unresolved`.
89pub 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    // 1) filesystem path
106    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    // 2) branch lookup
118    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    // 3) worktree name lookup
129    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
143/// Annotate resolved entries with busy status. Unresolved entries pass through.
144pub 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
168/// Counters for summary output.
169struct 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
191/// Print the batch summary. Goes to stdout to match the convention used by
192/// `gw clean` (summary/progress → stdout, errors/prompts → stderr).
193pub 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
240/// Ask for a single y/N confirmation on the whole batch. Only invoked when
241/// planned.ready > 1 (or planned.ready >= 1 combined with skips worth
242/// surfacing). Returns true if the user confirmed.
243pub fn confirm_batch() -> bool {
244    if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) {
245        return true; // non-interactive: assume confirmed (scripted usage)
246    }
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/// Final outcome used to compute exit code and summary.
258///
259/// Fields retain label/reason/error for Debug output and future per-item
260/// reporting. The current summary only counts variants, so dead-code lint is
261/// silenced here.
262#[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
281/// Execute the plan sequentially. Best-effort: one failure does not abort.
282fn 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                // progress line → stdout
290                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                        // failure → stderr
300                        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                // Summary line → stdout.
312                println!("{} Skipped {} (busy)", style("~").yellow(), label);
313                // Error mirror → stderr. Required so non-TTY `gw delete`
314                // against a busy worktree emits a stderr hint matching the
315                // legacy single-target flow (see tests/busy_detection.rs).
316                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
368/// If cwd lives inside any Ready/Busy target path, chdir to the main repo
369/// root. Prevents the current `gw` process from being flagged as a busy holder
370/// of the worktree it is being asked to remove.
371///
372/// Canonicalize failures on either side are treated as "skip this comparison"
373/// rather than falling back to the raw path. On filesystems with symlinked
374/// tempdirs (e.g. `/var` -> `/private/var` on macOS) an asymmetric fallback
375/// could mis-classify and leave cwd in the target.
376fn 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
401/// Top-level orchestrator for `gw delete`.
402///
403/// `inputs` is empty for the legacy "current worktree" case and for the
404/// `-i` interactive case — the caller passes `interactive=true` to trigger the
405/// selector.
406pub 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    // 1) Decide the initial input set.
414    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        // Legacy path: delegate to the single-target shim and return its exit
427        // code. Keeps the "no-args inside a worktree deletes current" behavior
428        // and its busy prompt exactly as today.
429        return legacy_single_current(flags, lookup_mode);
430    } else {
431        inputs
432    };
433
434    // 2) Resolve all inputs against the repo.
435    let entries = resolve_all(&initial_inputs, lookup_mode)?;
436
437    // 2.5) If cwd is inside any resolved target, move to the main repo *before*
438    // busy detection so the running `gw` process doesn't register as a busy
439    // holder of a worktree it's trying to delete. Mirrors the legacy
440    // `delete_worktree` behavior.
441    move_cwd_out_of_targets(&entries);
442
443    // 3) Plan busy status.
444    let entries = plan_busy(entries, flags.allow_busy);
445
446    // 4) Print summary.
447    print_summary(&entries, dry_run);
448
449    // 5) Dry-run short-circuits before execution.
450    if dry_run {
451        return Ok(0);
452    }
453
454    // 6) Batch confirmation when the plan has more than one entry
455    //    (Ready + Busy + Unresolved combined). Single-entry explicit
456    //    positional goes straight to execute.
457    if entries.len() >= 2 && !confirm_batch() {
458        eprintln!("Cancelled.");
459        return Ok(1);
460    }
461
462    // 7) Execute.
463    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}