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<(
124 String,
125 Vec<crate::operations::busy::BusyInfo>,
126 Vec<crate::operations::busy::BusyInfo>,
127 )> = Vec::new();
128 if !force {
129 let mut kept: Vec<(String, String, String)> = Vec::with_capacity(to_delete.len());
130 for (branch, path, reason) in to_delete.into_iter() {
131 let (hard, soft) =
132 crate::operations::busy::detect_busy_tiered(std::path::Path::new(&path));
133 if hard.is_empty() && soft.is_empty() {
134 kept.push((branch, path, reason));
135 } else {
136 busy_skipped.push((branch, hard, soft));
137 }
138 }
139 to_delete = kept;
140 }
141
142 if !busy_skipped.is_empty() {
143 println!(
144 "{}",
145 style(format!(
146 "Skipping {} busy worktree(s) (use --force to override):",
147 busy_skipped.len()
148 ))
149 .yellow()
150 );
151 for (branch, hard, soft) in &busy_skipped {
152 eprint!(
153 "{}",
154 crate::operations::busy_messages::render_refusal(branch, hard, soft)
155 );
156 }
157 println!();
158 }
159
160 if to_delete.is_empty() {
161 println!(
162 "{} No worktrees match the cleanup criteria\n",
163 style("*").green().bold()
164 );
165 return Ok(());
166 }
167
168 let prefix = if dry_run { "DRY RUN: " } else { "" };
170 println!(
171 "\n{}\n",
172 style(format!("{}Worktrees to delete:", prefix))
173 .yellow()
174 .bold()
175 );
176 for (branch, path, reason) in &to_delete {
177 println!(" - {:<30} ({})", branch, reason);
178 println!(" Path: {}", path);
179 }
180 println!();
181
182 if dry_run {
183 println!(
184 "{} Would delete {} worktree(s)",
185 style("*").cyan().bold(),
186 to_delete.len()
187 );
188 println!("Run without --dry-run to actually delete them");
189 return Ok(());
190 }
191
192 let mut deleted = 0u32;
194 for (branch, _, _) in &to_delete {
195 println!("{}", style(format!("Deleting {}...", branch)).yellow());
196 match super::worktree::delete_worktree(Some(branch), false, false, true, true, None) {
200 Ok(()) => {
201 println!("{} Deleted {}", style("*").green().bold(), branch);
202 deleted += 1;
203 }
204 Err(e) => {
205 println!(
206 "{} Failed to delete {}: {}",
207 style("x").red().bold(),
208 branch,
209 e
210 );
211 }
212 }
213 }
214
215 println!(
216 "\n{}\n",
217 style(messages::cleanup_complete(deleted)).green().bold()
218 );
219
220 println!("{}", style("Pruning stale worktree metadata...").dim());
222 let _ = git::git_command(&["worktree", "prune"], Some(&repo), false, false);
223 println!("{}\n", style("* Prune complete").dim());
224
225 Ok(())
226}