Skip to main content

git_worktree_manager/operations/
clean.rs

1/// Batch cleanup of worktrees.
2///
3use 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
14/// Determine whether `branch` is merged, using the same two-step logic as
15/// `gw list`:
16///   1. PrCache (primary) — squash-merge aware, checks GitHub PR state.
17///   2. `git branch --merged` (fallback) — only catches traditional merge
18///      commits, but still useful when `gh` is not available.
19///
20/// Returns `Some(base_branch_name)` if merged, `None` otherwise. Returning
21/// the resolved base lets the caller render an accurate "merged into <base>"
22/// reason without re-deriving the base separately (which would risk silent
23/// drift if the resolution logic ever changed in only one place).
24///
25/// The base branch is read from `branch.<name>.worktreeBase` git config; if
26/// absent, `git::detect_default_branch` is used as the fallback.  The old
27/// code silently skipped the merged check when the config key was missing,
28/// which caused `gw clean --merged` to miss every squash-merged branch (the
29/// live bug: `gw list` showed "merged" while `gw clean --merged` said "No
30/// worktrees match").
31pub(super) fn branch_is_merged(
32    branch_name: &str,
33    repo: &Path,
34    pr_cache: &PrCache,
35) -> Option<String> {
36    // Determine base branch: git config first, repo default second.
37    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    // Primary: cached GitHub PR state (squash-merge aware).
42    if matches!(pr_cache.state(branch_name), Some(PrState::Merged)) {
43        return Some(base_branch);
44    }
45
46    // Fallback: git branch --merged (traditional merge commits only).
47    if git::is_branch_merged(branch_name, &base_branch, Some(repo)) {
48        return Some(base_branch);
49    }
50
51    None
52}
53
54/// Batch cleanup of worktrees based on criteria.
55pub 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    // Must specify at least one criterion
66    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    // Load the PR cache once at the top so the merged-check and the interactive
75    // listing both share the same instance (no double fetch).
76    let pr_cache = PrCache::load_or_fetch(&repo, no_cache);
77
78    let mut to_delete: Vec<(String, String, String)> = Vec::new(); // (branch, path, reason)
79
80    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        // Check if merged — mirrors `gw list`'s merge-detection strategy:
85        // PrCache first (squash-merge aware), git fallback second.
86        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        // Check age
94        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    // Interactive mode
114    if interactive && to_delete.is_empty() {
115        println!("{}\n", style("Available worktrees:").cyan().bold());
116        let mut all_wt = Vec::new();
117        // Reuse the already-loaded pr_cache instance (no second fetch).
118        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    // Skip worktrees that another session is actively using, unless --force.
151    // This prevents `gw clean --merged` from wiping a worktree held open by
152    // a Claude Code / shell / editor session. Users can pass --force to
153    // ignore the busy gate.
154    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    // Show what will be deleted
200    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    // Delete worktrees
224    let mut deleted = 0u32;
225    for (branch, _, _) in &to_delete {
226        println!("{}", style(format!("Deleting {}...", branch)).yellow());
227        // clean already filtered out busy worktrees above (unless --force),
228        // so at this point we pass allow_busy=true to skip the redundant
229        // gate inside delete_worktree.
230        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    // Prune stale metadata
252    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    // The env-var lock and guard are defined in `super::super::test_env` so
263    // this module and `pr_cache` share a single mutex. Without sharing, each
264    // module's tests would hold a different lock and race on the same global
265    // env vars (`GW_TEST_GH_JSON`, `GW_TEST_GH_FAIL`, `GW_TEST_CACHE_DIR`).
266    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    // ──────────────────────────────────────────────────────────────────────
284    // Case A: squash-merged branch detected via PrCache, worktreeBase MISSING.
285    //
286    // This is the live bug: `gw list` shows "merged", but the old
287    // `gw clean --merged` skipped the check entirely when worktreeBase
288    // was absent from git config.
289    // ──────────────────────────────────────────────────────────────────────
290    #[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        // Inject a MERGED PR into the PrCache via the test env hook.
296        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        // Sanity: ensure the cache has the MERGED state before calling the predicate.
305        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        // Use a real tempdir as "repo" — worktreeBase is intentionally absent.
312        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    // ──────────────────────────────────────────────────────────────────────
325    // Case C: branch with no PR, not reachable from base, no worktreeBase.
326    //         Predicate must return None (no false-positive).
327    // ──────────────────────────────────────────────────────────────────────
328    #[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        // Empty cache — no PRs at all.
334        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        // Initial commit on main
344        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        // Unmerged feature branch
357        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    // ──────────────────────────────────────────────────────────────────────
384    // The reason string returned to the user must agree with the resolved
385    // base branch — i.e., the helper returns the SAME base it used for the
386    // git fallback. Pins single-source-of-truth so the "merged into <base>"
387    // reason cannot silently disagree with the predicate's actual base.
388    // ──────────────────────────────────────────────────────────────────────
389    #[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        // Set worktreeBase to a non-default value so we can tell the helper
407        // honored it (instead of silently falling back to detect_default_branch).
408        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    // ──────────────────────────────────────────────────────────────────────
425    // PrCache OPEN must not mark a branch merged.
426    // ──────────────────────────────────────────────────────────────────────
427    #[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}