git_worktree_manager/operations/
clean.rs1use console::style;
4use std::path::{Path, PathBuf};
5
6use crate::constants::{format_config_key, path_age_days, CONFIG_KEY_BASE_BRANCH};
7use crate::error::Result;
8use crate::git;
9use crate::messages;
10use crate::operations::delete_batch;
11use crate::operations::worktree::DeleteFlags;
12
13use super::pr_cache::{PrCache, PrState};
14
15pub(super) fn branch_is_merged(
33 branch_name: &str,
34 repo: &Path,
35 pr_cache: &PrCache,
36) -> Option<String> {
37 let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch_name);
39 let base_branch = git::get_config(&base_key, Some(repo))
40 .unwrap_or_else(|| git::detect_default_branch(Some(repo)));
41
42 if matches!(pr_cache.state(branch_name), Some(PrState::Merged)) {
44 return Some(base_branch);
45 }
46
47 if git::is_branch_merged(branch_name, &base_branch, Some(repo)) {
49 return Some(base_branch);
50 }
51
52 None
53}
54
55pub fn clean_worktrees(
63 merged: bool,
64 older_than: Option<u64>,
65 dry_run: bool,
66 force: bool,
67 interactive: bool,
68) -> Result<i32> {
69 if interactive {
70 eprintln!(
71 "Error: `gw clean -i` has been removed.\n\
72 For interactive selection, use `gw delete -i` instead."
73 );
74 return Ok(2);
75 }
76
77 if !merged && older_than.is_none() {
78 eprintln!(
79 "Error: `gw clean` requires at least one filter:\n \
80 --merged, --older-than <DURATION>.\n\
81 For interactive selection, use `gw delete -i`."
82 );
83 return Ok(2);
84 }
85
86 let main_repo = git::get_repo_root(None)?;
87 let candidates = git::get_feature_worktrees(Some(&main_repo))?;
88
89 let pr_cache = PrCache::load_or_fetch(&main_repo, false);
90
91 let is_merged_pred =
92 |branch: &str| -> bool { branch_is_merged(branch, &main_repo, &pr_cache).is_some() };
93 let is_old_pred = |path: &Path| -> bool {
94 match older_than {
95 Some(days) => path_age_days(path)
96 .map(|age| age as u64 >= days)
97 .unwrap_or(false),
98 None => false,
99 }
100 };
101 let selected = filter_worktrees_pure(
102 &candidates,
103 merged,
104 older_than,
105 &is_merged_pred,
106 &is_old_pred,
107 );
108
109 if selected.is_empty() {
110 println!(
111 "{} No worktrees match the cleanup criteria",
112 style("*").green().bold()
113 );
114 return Ok(0);
115 }
116
117 let flags = DeleteFlags {
126 keep_branch: false,
127 delete_remote: false,
128 git_force: true,
129 allow_busy: force,
130 };
131 let count = selected.len() as u32;
132 let code = delete_batch::delete_worktrees(
133 selected, false, dry_run, flags, None,
134 )?;
135
136 if code != 1 {
140 println!("{}", style("Pruning stale worktree metadata...").dim());
141 let _ = git::git_command(&["worktree", "prune"], Some(&main_repo), false, false);
142 println!("{}", style("* Prune complete").dim());
143 }
144
145 if code == 0 && !dry_run {
148 println!(
149 "\n{}",
150 style(messages::cleanup_complete(count)).green().bold()
151 );
152 }
153
154 Ok(code)
155}
156
157pub(crate) fn filter_worktrees_pure(
164 candidates: &[(String, PathBuf)],
165 merged: bool,
166 older_than_days: Option<u64>,
167 is_merged: &dyn Fn(&str) -> bool,
168 is_old: &dyn Fn(&Path) -> bool,
169) -> Vec<String> {
170 if !merged && older_than_days.is_none() {
171 return Vec::new();
172 }
173 candidates
174 .iter()
175 .filter(|(branch, path)| {
176 let m = merged && is_merged(branch);
177 let o = older_than_days.is_some() && is_old(path);
178 m || o
179 })
180 .map(|(branch, _)| branch.clone())
181 .collect()
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use super::super::test_env::{env_lock, EnvGuard};
192
193 fn init_git_repo(path: &std::path::Path) {
194 for args in &[
195 vec!["init", "-b", "main"],
196 vec!["config", "user.name", "Test"],
197 vec!["config", "user.email", "test@test.com"],
198 vec!["config", "commit.gpgsign", "false"],
199 ] {
200 std::process::Command::new("git")
201 .args(args)
202 .current_dir(path)
203 .output()
204 .unwrap();
205 }
206 }
207
208 #[test]
216 fn case_a_squash_merged_pr_cache_no_worktree_base() {
217 let _g = env_lock();
218 let _env = EnvGuard::capture(&["GW_TEST_GH_JSON", "GW_TEST_GH_FAIL", "GW_TEST_CACHE_DIR"]);
219
220 std::env::set_var(
222 "GW_TEST_GH_JSON",
223 r#"[{"headRefName":"fix-squash-branch","state":"MERGED"}]"#,
224 );
225 let tmp_repo =
226 std::path::PathBuf::from(format!("/tmp/gw-test-unit-a-{}", std::process::id()));
227 let cache = PrCache::load_or_fetch(&tmp_repo, true);
228
229 assert_eq!(
231 cache.state("fix-squash-branch"),
232 Some(&super::super::pr_cache::PrState::Merged),
233 "PrCache must report Merged for the test to be meaningful"
234 );
235
236 let repo_dir = tempfile::tempdir().unwrap();
238 let repo = repo_dir.path();
239 init_git_repo(repo);
240
241 let result = branch_is_merged("fix-squash-branch", repo, &cache);
242 assert!(
243 result.is_some(),
244 "branch_is_merged must return Some(base) when PrCache reports MERGED, \
245 even without a worktreeBase git config entry (the live bug)"
246 );
247 }
248
249 #[test]
254 fn case_c_no_pr_not_merged_no_worktree_base() {
255 let _g = env_lock();
256 let _env = EnvGuard::capture(&["GW_TEST_GH_JSON", "GW_TEST_GH_FAIL", "GW_TEST_CACHE_DIR"]);
257
258 std::env::set_var("GW_TEST_GH_FAIL", "1");
260 let tmp_repo =
261 std::path::PathBuf::from(format!("/tmp/gw-test-unit-c-{}", std::process::id()));
262 let cache = PrCache::load_or_fetch(&tmp_repo, true);
263
264 let repo_dir = tempfile::tempdir().unwrap();
265 let repo = repo_dir.path();
266 init_git_repo(repo);
267
268 std::fs::write(repo.join("README.md"), "hi").unwrap();
270 for args in &[vec!["add", "."], vec!["commit", "-m", "init"]] {
271 std::process::Command::new("git")
272 .args(args)
273 .current_dir(repo)
274 .env("GIT_AUTHOR_NAME", "Test")
275 .env("GIT_AUTHOR_EMAIL", "test@test.com")
276 .env("GIT_COMMITTER_NAME", "Test")
277 .env("GIT_COMMITTER_EMAIL", "test@test.com")
278 .output()
279 .unwrap();
280 }
281 std::process::Command::new("git")
283 .args(["checkout", "-b", "feat-unmerged"])
284 .current_dir(repo)
285 .output()
286 .unwrap();
287 std::fs::write(repo.join("feat.txt"), "work").unwrap();
288 for args in &[vec!["add", "."], vec!["commit", "-m", "feat work"]] {
289 std::process::Command::new("git")
290 .args(args)
291 .current_dir(repo)
292 .env("GIT_AUTHOR_NAME", "Test")
293 .env("GIT_AUTHOR_EMAIL", "test@test.com")
294 .env("GIT_COMMITTER_NAME", "Test")
295 .env("GIT_COMMITTER_EMAIL", "test@test.com")
296 .output()
297 .unwrap();
298 }
299
300 let result = branch_is_merged("feat-unmerged", repo, &cache);
301 assert!(
302 result.is_none(),
303 "branch_is_merged must return None for an unmerged branch with no PR \
304 and no worktreeBase config"
305 );
306 }
307
308 #[test]
315 fn reason_base_matches_resolved_worktree_base_config() {
316 let _g = env_lock();
317 let _env = EnvGuard::capture(&["GW_TEST_GH_JSON", "GW_TEST_GH_FAIL", "GW_TEST_CACHE_DIR"]);
318
319 std::env::set_var(
320 "GW_TEST_GH_JSON",
321 r#"[{"headRefName":"some-feature","state":"MERGED"}]"#,
322 );
323 let tmp_repo =
324 std::path::PathBuf::from(format!("/tmp/gw-test-unit-reason-{}", std::process::id()));
325 let cache = PrCache::load_or_fetch(&tmp_repo, true);
326
327 let repo_dir = tempfile::tempdir().unwrap();
328 let repo = repo_dir.path();
329 init_git_repo(repo);
330
331 std::process::Command::new("git")
334 .args(["config", "branch.some-feature.worktreeBase", "develop"])
335 .current_dir(repo)
336 .output()
337 .unwrap();
338
339 let result = branch_is_merged("some-feature", repo, &cache);
340 assert_eq!(
341 result.as_deref(),
342 Some("develop"),
343 "branch_is_merged must return the worktreeBase config value as the \
344 resolved base, so the user-facing 'merged into <base>' reason \
345 cannot drift from what the predicate actually checked"
346 );
347 }
348
349 #[test]
353 fn pr_open_is_not_merged() {
354 let _g = env_lock();
355 let _env = EnvGuard::capture(&["GW_TEST_GH_JSON", "GW_TEST_GH_FAIL", "GW_TEST_CACHE_DIR"]);
356
357 std::env::set_var(
358 "GW_TEST_GH_JSON",
359 r#"[{"headRefName":"feat-open","state":"OPEN"}]"#,
360 );
361 let tmp_repo =
362 std::path::PathBuf::from(format!("/tmp/gw-test-unit-open-{}", std::process::id()));
363 let cache = PrCache::load_or_fetch(&tmp_repo, true);
364
365 let repo_dir = tempfile::tempdir().unwrap();
366 let repo = repo_dir.path();
367 init_git_repo(repo);
368
369 let result = branch_is_merged("feat-open", repo, &cache);
370 assert!(result.is_none(), "An OPEN PR must not be considered merged");
371 }
372
373 #[test]
374 fn filter_worktrees_pure_selects_union_of_matching_rules() {
375 let candidates: Vec<(String, std::path::PathBuf)> = vec![
376 (
377 "feat/merged-only".into(),
378 std::path::PathBuf::from("/tmp/a"),
379 ),
380 ("feat/old-only".into(), std::path::PathBuf::from("/tmp/b")),
381 ("feat/both".into(), std::path::PathBuf::from("/tmp/c")),
382 ("feat/neither".into(), std::path::PathBuf::from("/tmp/d")),
383 ];
384 let is_merged =
385 |branch: &str| -> bool { matches!(branch, "feat/merged-only" | "feat/both") };
386 let is_old = |path: &std::path::Path| -> bool {
387 matches!(path.to_str().unwrap_or(""), "/tmp/b" | "/tmp/c")
388 };
389 let selected = super::filter_worktrees_pure(
390 &candidates,
391 true,
392 Some(30),
393 &is_merged,
394 &is_old,
395 );
396 assert_eq!(
397 selected,
398 vec![
399 "feat/merged-only".to_string(),
400 "feat/old-only".to_string(),
401 "feat/both".to_string(),
402 ]
403 );
404 }
405
406 #[test]
407 fn filter_worktrees_pure_empty_when_no_filters_active() {
408 let candidates: Vec<(String, std::path::PathBuf)> =
409 vec![("x".into(), std::path::PathBuf::from("/tmp/x"))];
410 let is_merged = |_: &str| true;
411 let is_old = |_: &std::path::Path| true;
412 let selected = super::filter_worktrees_pure(
413 &candidates,
414 false,
415 None,
416 &is_merged,
417 &is_old,
418 );
419 assert!(selected.is_empty());
420 }
421}