Skip to main content

git_worktree_manager/operations/
clean.rs

1/// Batch cleanup of worktrees.
2///
3use 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
15/// Determine whether `branch` is merged, using the same two-step logic as
16/// `gw list`:
17///   1. PrCache (primary) — squash-merge aware, checks GitHub PR state.
18///   2. `git branch --merged` (fallback) — only catches traditional merge
19///      commits, but still useful when `gh` is not available.
20///
21/// Returns `Some(base_branch_name)` if merged, `None` otherwise. Returning
22/// the resolved base lets the caller render an accurate "merged into <base>"
23/// reason without re-deriving the base separately (which would risk silent
24/// drift if the resolution logic ever changed in only one place).
25///
26/// The base branch is read from `branch.<name>.worktreeBase` git config; if
27/// absent, `git::detect_default_branch` is used as the fallback.  The old
28/// code silently skipped the merged check when the config key was missing,
29/// which caused `gw clean --merged` to miss every squash-merged branch (the
30/// live bug: `gw list` showed "merged" while `gw clean --merged` said "No
31/// worktrees match").
32pub(super) fn branch_is_merged(
33    branch_name: &str,
34    repo: &Path,
35    pr_cache: &PrCache,
36) -> Option<String> {
37    // Determine base branch: git config first, repo default second.
38    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    // Primary: cached GitHub PR state (squash-merge aware).
43    if matches!(pr_cache.state(branch_name), Some(PrState::Merged)) {
44        return Some(base_branch);
45    }
46
47    // Fallback: git branch --merged (traditional merge commits only).
48    if git::is_branch_merged(branch_name, &base_branch, Some(repo)) {
49        return Some(base_branch);
50    }
51
52    None
53}
54
55/// Top-level `gw clean` entry point.
56///
57/// Returns an exit code (0/1/2) matching the convention `gw delete` uses:
58/// - `0`: every selected worktree was deleted (or filters matched nothing).
59/// - `1`: user cancelled at the batch-confirmation prompt.
60/// - `2`: misuse (no filter flags / removed `-i` flag), or any worktree was
61///   skipped (busy) or failed to delete.
62pub 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    // Hand the selection to the shared deletion engine. The `DeleteFlags`
118    // here mirror the legacy `clean` semantics:
119    // - keep_branch=false / delete_remote=false: clean removes the local
120    //   branch metadata only.
121    // - git_force=true: matches the historical `git worktree remove --force`
122    //   behavior — the worktree may have local-only state.
123    // - allow_busy=force: only `--force` lets clean tear through a busy
124    //   worktree (one held by `gw shell` / claude / editor).
125    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, /*interactive=*/ false, dry_run, flags, /*lookup_mode=*/ None,
134    )?;
135
136    // Prune stale metadata on any non-cancelled path (matches legacy UX).
137    // If the user cancelled at the batch prompt (code == 1) nothing was
138    // deleted, so there's no metadata to prune.
139    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    // On partial failure `delete_batch` already printed a Summary line, so
146    // only print the success banner when every selection succeeded.
147    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
157/// Pure selection helper. Takes a list of `(branch, path)` candidates and
158/// the active filter predicates; returns branch names matching at least one
159/// active filter (UNION semantics — a worktree matching either `--merged` OR
160/// `--older-than` qualifies, mirroring the legacy behavior). Generic over
161/// the predicates so unit tests can inject deterministic ones without a
162/// real git repo or PrCache.
163pub(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    // The env-var lock and guard are defined in `super::super::test_env` so
188    // this module and `pr_cache` share a single mutex. Without sharing, each
189    // module's tests would hold a different lock and race on the same global
190    // env vars (`GW_TEST_GH_JSON`, `GW_TEST_GH_FAIL`, `GW_TEST_CACHE_DIR`).
191    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    // ──────────────────────────────────────────────────────────────────────
209    // Case A: squash-merged branch detected via PrCache, worktreeBase MISSING.
210    //
211    // This is the live bug: `gw list` shows "merged", but the old
212    // `gw clean --merged` skipped the check entirely when worktreeBase
213    // was absent from git config.
214    // ──────────────────────────────────────────────────────────────────────
215    #[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        // Inject a MERGED PR into the PrCache via the test env hook.
221        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        // Sanity: ensure the cache has the MERGED state before calling the predicate.
230        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        // Use a real tempdir as "repo" — worktreeBase is intentionally absent.
237        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    // ──────────────────────────────────────────────────────────────────────
250    // Case C: branch with no PR, not reachable from base, no worktreeBase.
251    //         Predicate must return None (no false-positive).
252    // ──────────────────────────────────────────────────────────────────────
253    #[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        // Empty cache — no PRs at all.
259        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        // Initial commit on main
269        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        // Unmerged feature branch
282        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    // ──────────────────────────────────────────────────────────────────────
309    // The reason string returned to the user must agree with the resolved
310    // base branch — i.e., the helper returns the SAME base it used for the
311    // git fallback. Pins single-source-of-truth so the "merged into <base>"
312    // reason cannot silently disagree with the predicate's actual base.
313    // ──────────────────────────────────────────────────────────────────────
314    #[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        // Set worktreeBase to a non-default value so we can tell the helper
332        // honored it (instead of silently falling back to detect_default_branch).
333        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    // ──────────────────────────────────────────────────────────────────────
350    // PrCache OPEN must not mark a branch merged.
351    // ──────────────────────────────────────────────────────────────────────
352    #[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            /*merged=*/ true,
392            /*older_than_days=*/ 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            /*merged=*/ false,
415            /*older_than_days=*/ None,
416            &is_merged,
417            &is_old,
418        );
419        assert!(selected.is_empty());
420    }
421}