Skip to main content

git_worktree_manager/operations/
clean.rs

1/// Batch cleanup of worktrees.
2///
3use 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
13/// Batch cleanup of worktrees based on criteria.
14pub 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    // Must specify at least one criterion
25    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(); // (branch, path, reason)
34
35    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        // Check if merged
40        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        // Check age
63        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    // Interactive mode
83    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    // Skip worktrees that another session is actively using, unless --force.
120    // This prevents `gw clean --merged` from wiping a worktree held open by
121    // a Claude Code / shell / editor session. Users can pass --force to
122    // ignore the busy gate.
123    let mut busy_skipped: Vec<(String, Vec<crate::operations::busy::BusyInfo>)> = Vec::new();
124    if !force {
125        let mut kept: Vec<(String, String, String)> = Vec::with_capacity(to_delete.len());
126        for (branch, path, reason) in to_delete.into_iter() {
127            let busy = crate::operations::busy::detect_busy(std::path::Path::new(&path));
128            if busy.is_empty() {
129                kept.push((branch, path, reason));
130            } else {
131                busy_skipped.push((branch, busy));
132            }
133        }
134        to_delete = kept;
135    }
136
137    if !busy_skipped.is_empty() {
138        println!(
139            "{}",
140            style(format!(
141                "Skipping {} busy worktree(s) (use --force to override):",
142                busy_skipped.len()
143            ))
144            .yellow()
145        );
146        for (branch, infos) in &busy_skipped {
147            let detail = infos
148                .first()
149                .map(|b| format!("PID {} {}", b.pid, b.cmd))
150                .unwrap_or_default();
151            println!("  - {:<30} (busy: {})", branch, detail);
152        }
153        println!();
154    }
155
156    if to_delete.is_empty() {
157        println!(
158            "{} No worktrees match the cleanup criteria\n",
159            style("*").green().bold()
160        );
161        return Ok(());
162    }
163
164    // Show what will be deleted
165    let prefix = if dry_run { "DRY RUN: " } else { "" };
166    println!(
167        "\n{}\n",
168        style(format!("{}Worktrees to delete:", prefix))
169            .yellow()
170            .bold()
171    );
172    for (branch, path, reason) in &to_delete {
173        println!("  - {:<30} ({})", branch, reason);
174        println!("    Path: {}", path);
175    }
176    println!();
177
178    if dry_run {
179        println!(
180            "{} Would delete {} worktree(s)",
181            style("*").cyan().bold(),
182            to_delete.len()
183        );
184        println!("Run without --dry-run to actually delete them");
185        return Ok(());
186    }
187
188    // Delete worktrees
189    let mut deleted = 0u32;
190    for (branch, _, _) in &to_delete {
191        println!("{}", style(format!("Deleting {}...", branch)).yellow());
192        // clean already filtered out busy worktrees above (unless --force),
193        // so at this point we pass allow_busy=true to skip the redundant
194        // gate inside delete_worktree.
195        match super::worktree::delete_worktree(Some(branch), false, false, true, true, None) {
196            Ok(()) => {
197                println!("{} Deleted {}", style("*").green().bold(), branch);
198                deleted += 1;
199            }
200            Err(e) => {
201                println!(
202                    "{} Failed to delete {}: {}",
203                    style("x").red().bold(),
204                    branch,
205                    e
206                );
207            }
208        }
209    }
210
211    println!(
212        "\n{}\n",
213        style(messages::cleanup_complete(deleted)).green().bold()
214    );
215
216    // Prune stale metadata
217    println!("{}", style("Pruning stale worktree metadata...").dim());
218    let _ = git::git_command(&["worktree", "prune"], Some(&repo), false, false);
219    println!("{}\n", style("* Prune complete").dim());
220
221    Ok(())
222}