1use console::style;
4use std::path::Path;
5
6use crate::constants::{format_config_key, path_age_days, CONFIG_KEY_BASE_BRANCH};
7use crate::error::Result;
8use crate::git;
9use crate::messages;
10
11use super::display::get_worktree_status;
12use super::pr_cache::{PrCache, PrState};
13
14pub(super) fn branch_is_merged(
32 branch_name: &str,
33 repo: &Path,
34 pr_cache: &PrCache,
35) -> Option<String> {
36 let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch_name);
38 let base_branch = git::get_config(&base_key, Some(repo))
39 .unwrap_or_else(|| git::detect_default_branch(Some(repo)));
40
41 if matches!(pr_cache.state(branch_name), Some(PrState::Merged)) {
43 return Some(base_branch);
44 }
45
46 if git::is_branch_merged(branch_name, &base_branch, Some(repo)) {
48 return Some(base_branch);
49 }
50
51 None
52}
53
54pub fn clean_worktrees(
56 no_cache: bool,
57 merged: bool,
58 older_than: Option<u64>,
59 interactive: bool,
60 dry_run: bool,
61 force: bool,
62) -> Result<()> {
63 let repo = git::get_repo_root(None)?;
64
65 if !merged && older_than.is_none() && !interactive {
67 eprintln!(
68 "Error: Please specify at least one cleanup criterion:\n \
69 --merged, --older-than, or -i/--interactive"
70 );
71 return Ok(());
72 }
73
74 let pr_cache = PrCache::load_or_fetch(&repo, no_cache);
77
78 let mut to_delete: Vec<(String, String, String)> = Vec::new(); for (branch_name, path) in git::get_feature_worktrees(Some(&repo))? {
81 let mut should_delete = false;
82 let mut reasons = Vec::new();
83
84 if merged {
87 if let Some(base_branch) = branch_is_merged(&branch_name, &repo, &pr_cache) {
88 should_delete = true;
89 reasons.push(format!("merged into {}", base_branch));
90 }
91 }
92
93 if let Some(days) = older_than {
95 if let Some(age) = path_age_days(&path) {
96 let age_days = age as u64;
97 if age_days >= days {
98 should_delete = true;
99 reasons.push(format!("older than {} days ({} days)", days, age_days));
100 }
101 }
102 }
103
104 if should_delete {
105 to_delete.push((
106 branch_name.clone(),
107 path.to_string_lossy().to_string(),
108 reasons.join(", "),
109 ));
110 }
111 }
112
113 if interactive && to_delete.is_empty() {
115 println!("{}\n", style("Available worktrees:").cyan().bold());
116 let mut all_wt = Vec::new();
117 for (branch_name, path) in git::get_feature_worktrees(Some(&repo))? {
119 let status = get_worktree_status(&path, &repo, Some(branch_name.as_str()), &pr_cache);
120 println!(" [{:8}] {:<30} {}", status, branch_name, path.display());
121 all_wt.push((branch_name, path.to_string_lossy().to_string()));
122 }
123 println!();
124 println!("Enter branch names to delete (space-separated), or 'all' for all:");
125
126 let mut input = String::new();
127 std::io::stdin().read_line(&mut input)?;
128 let input = input.trim();
129
130 if input.eq_ignore_ascii_case("all") {
131 to_delete = all_wt
132 .into_iter()
133 .map(|(b, p)| (b, p, "user selected".to_string()))
134 .collect();
135 } else {
136 let selected: Vec<&str> = input.split_whitespace().collect();
137 to_delete = all_wt
138 .into_iter()
139 .filter(|(b, _)| selected.contains(&b.as_str()))
140 .map(|(b, p)| (b, p, "user selected".to_string()))
141 .collect();
142 }
143
144 if to_delete.is_empty() {
145 println!("{}", style("No worktrees selected for deletion").yellow());
146 return Ok(());
147 }
148 }
149
150 let mut busy_skipped: Vec<(
155 String,
156 Vec<crate::operations::busy::BusyInfo>,
157 Vec<crate::operations::busy::BusyInfo>,
158 )> = Vec::new();
159 if !force {
160 let mut kept: Vec<(String, String, String)> = Vec::with_capacity(to_delete.len());
161 for (branch, path, reason) in to_delete.into_iter() {
162 let (hard, soft) =
163 crate::operations::busy::detect_busy_tiered(std::path::Path::new(&path));
164 if hard.is_empty() && soft.is_empty() {
165 kept.push((branch, path, reason));
166 } else {
167 busy_skipped.push((branch, hard, soft));
168 }
169 }
170 to_delete = kept;
171 }
172
173 if !busy_skipped.is_empty() {
174 println!(
175 "{}",
176 style(format!(
177 "Skipping {} busy worktree(s) (use --force to override):",
178 busy_skipped.len()
179 ))
180 .yellow()
181 );
182 for (branch, hard, soft) in &busy_skipped {
183 eprint!(
184 "{}",
185 crate::operations::busy_messages::render_refusal(branch, hard, soft)
186 );
187 }
188 println!();
189 }
190
191 if to_delete.is_empty() {
192 println!(
193 "{} No worktrees match the cleanup criteria\n",
194 style("*").green().bold()
195 );
196 return Ok(());
197 }
198
199 let prefix = if dry_run { "DRY RUN: " } else { "" };
201 println!(
202 "\n{}\n",
203 style(format!("{}Worktrees to delete:", prefix))
204 .yellow()
205 .bold()
206 );
207 for (branch, path, reason) in &to_delete {
208 println!(" - {:<30} ({})", branch, reason);
209 println!(" Path: {}", path);
210 }
211 println!();
212
213 if dry_run {
214 println!(
215 "{} Would delete {} worktree(s)",
216 style("*").cyan().bold(),
217 to_delete.len()
218 );
219 println!("Run without --dry-run to actually delete them");
220 return Ok(());
221 }
222
223 let mut deleted = 0u32;
225 for (branch, _, _) in &to_delete {
226 println!("{}", style(format!("Deleting {}...", branch)).yellow());
227 match super::worktree::delete_worktree(Some(branch), false, false, true, true, None) {
231 Ok(()) => {
232 println!("{} Deleted {}", style("*").green().bold(), branch);
233 deleted += 1;
234 }
235 Err(e) => {
236 println!(
237 "{} Failed to delete {}: {}",
238 style("x").red().bold(),
239 branch,
240 e
241 );
242 }
243 }
244 }
245
246 println!(
247 "\n{}\n",
248 style(messages::cleanup_complete(deleted)).green().bold()
249 );
250
251 println!("{}", style("Pruning stale worktree metadata...").dim());
253 let _ = git::git_command(&["worktree", "prune"], Some(&repo), false, false);
254 println!("{}\n", style("* Prune complete").dim());
255
256 Ok(())
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 use super::super::test_env::{env_lock, EnvGuard};
267
268 fn init_git_repo(path: &std::path::Path) {
269 for args in &[
270 vec!["init", "-b", "main"],
271 vec!["config", "user.name", "Test"],
272 vec!["config", "user.email", "test@test.com"],
273 vec!["config", "commit.gpgsign", "false"],
274 ] {
275 std::process::Command::new("git")
276 .args(args)
277 .current_dir(path)
278 .output()
279 .unwrap();
280 }
281 }
282
283 #[test]
291 fn case_a_squash_merged_pr_cache_no_worktree_base() {
292 let _g = env_lock();
293 let _env = EnvGuard::capture(&["GW_TEST_GH_JSON", "GW_TEST_GH_FAIL", "GW_TEST_CACHE_DIR"]);
294
295 std::env::set_var(
297 "GW_TEST_GH_JSON",
298 r#"[{"headRefName":"fix-squash-branch","state":"MERGED"}]"#,
299 );
300 let tmp_repo =
301 std::path::PathBuf::from(format!("/tmp/gw-test-unit-a-{}", std::process::id()));
302 let cache = PrCache::load_or_fetch(&tmp_repo, true);
303
304 assert_eq!(
306 cache.state("fix-squash-branch"),
307 Some(&super::super::pr_cache::PrState::Merged),
308 "PrCache must report Merged for the test to be meaningful"
309 );
310
311 let repo_dir = tempfile::tempdir().unwrap();
313 let repo = repo_dir.path();
314 init_git_repo(repo);
315
316 let result = branch_is_merged("fix-squash-branch", repo, &cache);
317 assert!(
318 result.is_some(),
319 "branch_is_merged must return Some(base) when PrCache reports MERGED, \
320 even without a worktreeBase git config entry (the live bug)"
321 );
322 }
323
324 #[test]
329 fn case_c_no_pr_not_merged_no_worktree_base() {
330 let _g = env_lock();
331 let _env = EnvGuard::capture(&["GW_TEST_GH_JSON", "GW_TEST_GH_FAIL", "GW_TEST_CACHE_DIR"]);
332
333 std::env::set_var("GW_TEST_GH_FAIL", "1");
335 let tmp_repo =
336 std::path::PathBuf::from(format!("/tmp/gw-test-unit-c-{}", std::process::id()));
337 let cache = PrCache::load_or_fetch(&tmp_repo, true);
338
339 let repo_dir = tempfile::tempdir().unwrap();
340 let repo = repo_dir.path();
341 init_git_repo(repo);
342
343 std::fs::write(repo.join("README.md"), "hi").unwrap();
345 for args in &[vec!["add", "."], vec!["commit", "-m", "init"]] {
346 std::process::Command::new("git")
347 .args(args)
348 .current_dir(repo)
349 .env("GIT_AUTHOR_NAME", "Test")
350 .env("GIT_AUTHOR_EMAIL", "test@test.com")
351 .env("GIT_COMMITTER_NAME", "Test")
352 .env("GIT_COMMITTER_EMAIL", "test@test.com")
353 .output()
354 .unwrap();
355 }
356 std::process::Command::new("git")
358 .args(["checkout", "-b", "feat-unmerged"])
359 .current_dir(repo)
360 .output()
361 .unwrap();
362 std::fs::write(repo.join("feat.txt"), "work").unwrap();
363 for args in &[vec!["add", "."], vec!["commit", "-m", "feat work"]] {
364 std::process::Command::new("git")
365 .args(args)
366 .current_dir(repo)
367 .env("GIT_AUTHOR_NAME", "Test")
368 .env("GIT_AUTHOR_EMAIL", "test@test.com")
369 .env("GIT_COMMITTER_NAME", "Test")
370 .env("GIT_COMMITTER_EMAIL", "test@test.com")
371 .output()
372 .unwrap();
373 }
374
375 let result = branch_is_merged("feat-unmerged", repo, &cache);
376 assert!(
377 result.is_none(),
378 "branch_is_merged must return None for an unmerged branch with no PR \
379 and no worktreeBase config"
380 );
381 }
382
383 #[test]
390 fn reason_base_matches_resolved_worktree_base_config() {
391 let _g = env_lock();
392 let _env = EnvGuard::capture(&["GW_TEST_GH_JSON", "GW_TEST_GH_FAIL", "GW_TEST_CACHE_DIR"]);
393
394 std::env::set_var(
395 "GW_TEST_GH_JSON",
396 r#"[{"headRefName":"some-feature","state":"MERGED"}]"#,
397 );
398 let tmp_repo =
399 std::path::PathBuf::from(format!("/tmp/gw-test-unit-reason-{}", std::process::id()));
400 let cache = PrCache::load_or_fetch(&tmp_repo, true);
401
402 let repo_dir = tempfile::tempdir().unwrap();
403 let repo = repo_dir.path();
404 init_git_repo(repo);
405
406 std::process::Command::new("git")
409 .args(["config", "branch.some-feature.worktreeBase", "develop"])
410 .current_dir(repo)
411 .output()
412 .unwrap();
413
414 let result = branch_is_merged("some-feature", repo, &cache);
415 assert_eq!(
416 result.as_deref(),
417 Some("develop"),
418 "branch_is_merged must return the worktreeBase config value as the \
419 resolved base, so the user-facing 'merged into <base>' reason \
420 cannot drift from what the predicate actually checked"
421 );
422 }
423
424 #[test]
428 fn pr_open_is_not_merged() {
429 let _g = env_lock();
430 let _env = EnvGuard::capture(&["GW_TEST_GH_JSON", "GW_TEST_GH_FAIL", "GW_TEST_CACHE_DIR"]);
431
432 std::env::set_var(
433 "GW_TEST_GH_JSON",
434 r#"[{"headRefName":"feat-open","state":"OPEN"}]"#,
435 );
436 let tmp_repo =
437 std::path::PathBuf::from(format!("/tmp/gw-test-unit-open-{}", std::process::id()));
438 let cache = PrCache::load_or_fetch(&tmp_repo, true);
439
440 let repo_dir = tempfile::tempdir().unwrap();
441 let repo = repo_dir.path();
442 init_git_repo(repo);
443
444 let result = branch_is_merged("feat-open", repo, &cache);
445 assert!(result.is_none(), "An OPEN PR must not be considered merged");
446 }
447}