git_workty/commands/
clean.rs1use crate::config::Config;
2use crate::git::{is_ancestor, GitRepo};
3use crate::status::is_worktree_dirty;
4use crate::ui::{print_info, print_success, print_warning};
5use crate::worktree::{list_worktrees, Worktree};
6use anyhow::{Context, Result};
7use dialoguer::Confirm;
8use is_terminal::IsTerminal;
9use std::process::Command;
10
11pub struct CleanOptions {
12 pub merged: bool,
13 pub dry_run: bool,
14 pub yes: bool,
15}
16
17pub fn execute(repo: &GitRepo, opts: CleanOptions) -> Result<()> {
18 let config = Config::load(repo)?;
19 let worktrees = list_worktrees(repo)?;
20 let current_path = std::env::current_dir().unwrap_or_default();
21
22 let candidates: Vec<&Worktree> = worktrees
23 .iter()
24 .filter(|wt| {
25 if wt.path == current_path {
26 return false;
27 }
28
29 if wt.is_main_worktree(repo) {
30 return false;
31 }
32
33 if wt.detached {
34 return false;
35 }
36
37 if let Some(branch) = &wt.branch_short {
38 if branch == &config.base {
39 return false;
40 }
41 }
42
43 if opts.merged {
44 if let Some(branch) = &wt.branch_short {
45 matches!(is_ancestor(repo, branch, &config.base), Ok(true))
46 } else {
47 false
48 }
49 } else {
50 true
51 }
52 })
53 .collect();
54
55 if candidates.is_empty() {
56 print_info("No worktrees to clean up.");
57 return Ok(());
58 }
59
60 println!("Worktrees to remove:");
61 for wt in &candidates {
62 let dirty = if is_worktree_dirty(wt) { " (dirty)" } else { "" };
63 println!(" - {}{}", wt.name(), dirty);
64 }
65
66 if opts.dry_run {
67 print_info("Dry run - no worktrees removed.");
68 return Ok(());
69 }
70
71 let dirty_count = candidates.iter().filter(|wt| is_worktree_dirty(wt)).count();
72 if dirty_count > 0 {
73 print_warning(&format!(
74 "{} worktree(s) have uncommitted changes and will be skipped.",
75 dirty_count
76 ));
77 }
78
79 let clean_candidates: Vec<&&Worktree> = candidates
80 .iter()
81 .filter(|wt| !is_worktree_dirty(wt))
82 .collect();
83
84 if clean_candidates.is_empty() {
85 print_info("All candidate worktrees have uncommitted changes. Nothing to remove.");
86 return Ok(());
87 }
88
89 if !opts.yes && std::io::stdin().is_terminal() {
90 let confirm = Confirm::new()
91 .with_prompt(format!(
92 "Remove {} worktree(s)?",
93 clean_candidates.len()
94 ))
95 .default(false)
96 .interact()?;
97
98 if !confirm {
99 eprintln!("Aborted.");
100 return Ok(());
101 }
102 } else if !opts.yes {
103 print_warning("Non-interactive mode requires --yes flag for destructive operations.");
104 std::process::exit(1);
105 }
106
107 let mut removed = 0;
108 for wt in clean_candidates {
109 let output = Command::new("git")
110 .current_dir(&repo.root)
111 .args(["worktree", "remove", wt.path.to_str().unwrap()])
112 .output()
113 .context("Failed to remove worktree")?;
114
115 if output.status.success() {
116 print_success(&format!("Removed worktree '{}'", wt.name()));
117 removed += 1;
118 } else {
119 let stderr = String::from_utf8_lossy(&output.stderr);
120 print_warning(&format!(
121 "Failed to remove '{}': {}",
122 wt.name(),
123 stderr.trim()
124 ));
125 }
126 }
127
128 print_info(&format!("Cleaned up {} worktree(s).", removed));
129
130 Ok(())
131}