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