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;
11
12/// Batch cleanup of worktrees based on criteria.
13pub fn clean_worktrees(
14    merged: bool,
15    older_than: Option<u64>,
16    interactive: bool,
17    dry_run: bool,
18    force: bool,
19) -> Result<()> {
20    let repo = git::get_repo_root(None)?;
21
22    // Must specify at least one criterion
23    if !merged && older_than.is_none() && !interactive {
24        eprintln!(
25            "Error: Please specify at least one cleanup criterion:\n  \
26             --merged, --older-than, or -i/--interactive"
27        );
28        return Ok(());
29    }
30
31    let mut to_delete: Vec<(String, String, String)> = Vec::new(); // (branch, path, reason)
32
33    for (branch_name, path) in git::get_feature_worktrees(Some(&repo))? {
34        let mut should_delete = false;
35        let mut reasons = Vec::new();
36
37        // Check if merged
38        if merged {
39            let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, &branch_name);
40            if let Some(base_branch) = git::get_config(&base_key, Some(&repo)) {
41                if let Ok(r) = git::git_command(
42                    &[
43                        "branch",
44                        "--merged",
45                        &base_branch,
46                        "--format=%(refname:short)",
47                    ],
48                    Some(&repo),
49                    false,
50                    true,
51                ) {
52                    if r.returncode == 0 && r.stdout.lines().any(|l| l.trim() == branch_name) {
53                        should_delete = true;
54                        reasons.push(format!("merged into {}", base_branch));
55                    }
56                }
57            }
58        }
59
60        // Check age
61        if let Some(days) = older_than {
62            if let Some(age) = path_age_days(&path) {
63                let age_days = age as u64;
64                if age_days >= days {
65                    should_delete = true;
66                    reasons.push(format!("older than {} days ({} days)", days, age_days));
67                }
68            }
69        }
70
71        if should_delete {
72            to_delete.push((
73                branch_name.clone(),
74                path.to_string_lossy().to_string(),
75                reasons.join(", "),
76            ));
77        }
78    }
79
80    // Interactive mode
81    if interactive && to_delete.is_empty() {
82        println!("{}\n", style("Available worktrees:").cyan().bold());
83        let mut all_wt = Vec::new();
84        for (branch_name, path) in git::get_feature_worktrees(Some(&repo))? {
85            let status = get_worktree_status(&path, &repo, Some(branch_name.as_str()));
86            println!("  [{:8}] {:<30} {}", status, branch_name, path.display());
87            all_wt.push((branch_name, path.to_string_lossy().to_string()));
88        }
89        println!();
90        println!("Enter branch names to delete (space-separated), or 'all' for all:");
91
92        let mut input = String::new();
93        std::io::stdin().read_line(&mut input)?;
94        let input = input.trim();
95
96        if input.eq_ignore_ascii_case("all") {
97            to_delete = all_wt
98                .into_iter()
99                .map(|(b, p)| (b, p, "user selected".to_string()))
100                .collect();
101        } else {
102            let selected: Vec<&str> = input.split_whitespace().collect();
103            to_delete = all_wt
104                .into_iter()
105                .filter(|(b, _)| selected.contains(&b.as_str()))
106                .map(|(b, p)| (b, p, "user selected".to_string()))
107                .collect();
108        }
109
110        if to_delete.is_empty() {
111            println!("{}", style("No worktrees selected for deletion").yellow());
112            return Ok(());
113        }
114    }
115
116    // Skip worktrees that another session is actively using, unless --force.
117    // This prevents `gw clean --merged` from wiping a worktree held open by
118    // a Claude Code / shell / editor session. Users can pass --force to
119    // ignore the busy gate.
120    let mut busy_skipped: Vec<(String, Vec<crate::operations::busy::BusyInfo>)> = Vec::new();
121    if !force {
122        let mut kept: Vec<(String, String, String)> = Vec::with_capacity(to_delete.len());
123        for (branch, path, reason) in to_delete.into_iter() {
124            let busy = crate::operations::busy::detect_busy(std::path::Path::new(&path));
125            if busy.is_empty() {
126                kept.push((branch, path, reason));
127            } else {
128                busy_skipped.push((branch, busy));
129            }
130        }
131        to_delete = kept;
132    }
133
134    if !busy_skipped.is_empty() {
135        println!(
136            "{}",
137            style(format!(
138                "Skipping {} busy worktree(s) (use --force to override):",
139                busy_skipped.len()
140            ))
141            .yellow()
142        );
143        for (branch, infos) in &busy_skipped {
144            let detail = infos
145                .first()
146                .map(|b| format!("PID {} {}", b.pid, b.cmd))
147                .unwrap_or_default();
148            println!("  - {:<30} (busy: {})", branch, detail);
149        }
150        println!();
151    }
152
153    if to_delete.is_empty() {
154        println!(
155            "{} No worktrees match the cleanup criteria\n",
156            style("*").green().bold()
157        );
158        return Ok(());
159    }
160
161    // Show what will be deleted
162    let prefix = if dry_run { "DRY RUN: " } else { "" };
163    println!(
164        "\n{}\n",
165        style(format!("{}Worktrees to delete:", prefix))
166            .yellow()
167            .bold()
168    );
169    for (branch, path, reason) in &to_delete {
170        println!("  - {:<30} ({})", branch, reason);
171        println!("    Path: {}", path);
172    }
173    println!();
174
175    if dry_run {
176        println!(
177            "{} Would delete {} worktree(s)",
178            style("*").cyan().bold(),
179            to_delete.len()
180        );
181        println!("Run without --dry-run to actually delete them");
182        return Ok(());
183    }
184
185    // Delete worktrees
186    let mut deleted = 0u32;
187    for (branch, _, _) in &to_delete {
188        println!("{}", style(format!("Deleting {}...", branch)).yellow());
189        // clean already filtered out busy worktrees above (unless --force),
190        // so at this point we pass allow_busy=true to skip the redundant
191        // gate inside delete_worktree.
192        match super::worktree::delete_worktree(Some(branch), false, false, true, true, None) {
193            Ok(()) => {
194                println!("{} Deleted {}", style("*").green().bold(), branch);
195                deleted += 1;
196            }
197            Err(e) => {
198                println!(
199                    "{} Failed to delete {}: {}",
200                    style("x").red().bold(),
201                    branch,
202                    e
203                );
204            }
205        }
206    }
207
208    println!(
209        "\n{}\n",
210        style(messages::cleanup_complete(deleted)).green().bold()
211    );
212
213    // Prune stale metadata
214    println!("{}", style("Pruning stale worktree metadata...").dim());
215    let _ = git::git_command(&["worktree", "prune"], Some(&repo), false, false);
216    println!("{}\n", style("* Prune complete").dim());
217
218    Ok(())
219}