git_worktree_manager/operations/
clean.rs1use console::style;
4
5use crate::constants::{format_config_key, path_age_days, CONFIG_KEY_BASE_BRANCH};
6use crate::error::Result;
7use crate::git;
8use crate::messages;
9
10use super::display::get_worktree_status;
11
12pub fn clean_worktrees(
14 merged: bool,
15 older_than: Option<u64>,
16 interactive: bool,
17 dry_run: bool,
18 force: bool,
19) -> Result<()> {
20 let repo = git::get_repo_root(None)?;
21
22 if !merged && older_than.is_none() && !interactive {
24 eprintln!(
25 "Error: Please specify at least one cleanup criterion:\n \
26 --merged, --older-than, or -i/--interactive"
27 );
28 return Ok(());
29 }
30
31 let mut to_delete: Vec<(String, String, String)> = Vec::new(); for (branch_name, path) in git::get_feature_worktrees(Some(&repo))? {
34 let mut should_delete = false;
35 let mut reasons = Vec::new();
36
37 if merged {
39 let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, &branch_name);
40 if let Some(base_branch) = git::get_config(&base_key, Some(&repo)) {
41 if let Ok(r) = git::git_command(
42 &[
43 "branch",
44 "--merged",
45 &base_branch,
46 "--format=%(refname:short)",
47 ],
48 Some(&repo),
49 false,
50 true,
51 ) {
52 if r.returncode == 0 && r.stdout.lines().any(|l| l.trim() == branch_name) {
53 should_delete = true;
54 reasons.push(format!("merged into {}", base_branch));
55 }
56 }
57 }
58 }
59
60 if let Some(days) = older_than {
62 if let Some(age) = path_age_days(&path) {
63 let age_days = age as u64;
64 if age_days >= days {
65 should_delete = true;
66 reasons.push(format!("older than {} days ({} days)", days, age_days));
67 }
68 }
69 }
70
71 if should_delete {
72 to_delete.push((
73 branch_name.clone(),
74 path.to_string_lossy().to_string(),
75 reasons.join(", "),
76 ));
77 }
78 }
79
80 if interactive && to_delete.is_empty() {
82 println!("{}\n", style("Available worktrees:").cyan().bold());
83 let mut all_wt = Vec::new();
84 for (branch_name, path) in git::get_feature_worktrees(Some(&repo))? {
85 let status = get_worktree_status(&path, &repo, Some(branch_name.as_str()));
86 println!(" [{:8}] {:<30} {}", status, branch_name, path.display());
87 all_wt.push((branch_name, path.to_string_lossy().to_string()));
88 }
89 println!();
90 println!("Enter branch names to delete (space-separated), or 'all' for all:");
91
92 let mut input = String::new();
93 std::io::stdin().read_line(&mut input)?;
94 let input = input.trim();
95
96 if input.eq_ignore_ascii_case("all") {
97 to_delete = all_wt
98 .into_iter()
99 .map(|(b, p)| (b, p, "user selected".to_string()))
100 .collect();
101 } else {
102 let selected: Vec<&str> = input.split_whitespace().collect();
103 to_delete = all_wt
104 .into_iter()
105 .filter(|(b, _)| selected.contains(&b.as_str()))
106 .map(|(b, p)| (b, p, "user selected".to_string()))
107 .collect();
108 }
109
110 if to_delete.is_empty() {
111 println!("{}", style("No worktrees selected for deletion").yellow());
112 return Ok(());
113 }
114 }
115
116 let mut busy_skipped: Vec<(String, Vec<crate::operations::busy::BusyInfo>)> = Vec::new();
121 if !force {
122 let mut kept: Vec<(String, String, String)> = Vec::with_capacity(to_delete.len());
123 for (branch, path, reason) in to_delete.into_iter() {
124 let busy = crate::operations::busy::detect_busy(std::path::Path::new(&path));
125 if busy.is_empty() {
126 kept.push((branch, path, reason));
127 } else {
128 busy_skipped.push((branch, busy));
129 }
130 }
131 to_delete = kept;
132 }
133
134 if !busy_skipped.is_empty() {
135 println!(
136 "{}",
137 style(format!(
138 "Skipping {} busy worktree(s) (use --force to override):",
139 busy_skipped.len()
140 ))
141 .yellow()
142 );
143 for (branch, infos) in &busy_skipped {
144 let detail = infos
145 .first()
146 .map(|b| format!("PID {} {}", b.pid, b.cmd))
147 .unwrap_or_default();
148 println!(" - {:<30} (busy: {})", branch, detail);
149 }
150 println!();
151 }
152
153 if to_delete.is_empty() {
154 println!(
155 "{} No worktrees match the cleanup criteria\n",
156 style("*").green().bold()
157 );
158 return Ok(());
159 }
160
161 let prefix = if dry_run { "DRY RUN: " } else { "" };
163 println!(
164 "\n{}\n",
165 style(format!("{}Worktrees to delete:", prefix))
166 .yellow()
167 .bold()
168 );
169 for (branch, path, reason) in &to_delete {
170 println!(" - {:<30} ({})", branch, reason);
171 println!(" Path: {}", path);
172 }
173 println!();
174
175 if dry_run {
176 println!(
177 "{} Would delete {} worktree(s)",
178 style("*").cyan().bold(),
179 to_delete.len()
180 );
181 println!("Run without --dry-run to actually delete them");
182 return Ok(());
183 }
184
185 let mut deleted = 0u32;
187 for (branch, _, _) in &to_delete {
188 println!("{}", style(format!("Deleting {}...", branch)).yellow());
189 match super::worktree::delete_worktree(Some(branch), false, false, true, true, None) {
193 Ok(()) => {
194 println!("{} Deleted {}", style("*").green().bold(), branch);
195 deleted += 1;
196 }
197 Err(e) => {
198 println!(
199 "{} Failed to delete {}: {}",
200 style("x").red().bold(),
201 branch,
202 e
203 );
204 }
205 }
206 }
207
208 println!(
209 "\n{}\n",
210 style(messages::cleanup_complete(deleted)).green().bold()
211 );
212
213 println!("{}", style("Pruning stale worktree metadata...").dim());
215 let _ = git::git_command(&["worktree", "prune"], Some(&repo), false, false);
216 println!("{}\n", style("* Prune complete").dim());
217
218 Ok(())
219}