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<(
124        String,
125        Vec<crate::operations::busy::BusyInfo>,
126        Vec<crate::operations::busy::BusyInfo>,
127    )> = Vec::new();
128    if !force {
129        let mut kept: Vec<(String, String, String)> = Vec::with_capacity(to_delete.len());
130        for (branch, path, reason) in to_delete.into_iter() {
131            let (hard, soft) =
132                crate::operations::busy::detect_busy_tiered(std::path::Path::new(&path));
133            if hard.is_empty() && soft.is_empty() {
134                kept.push((branch, path, reason));
135            } else {
136                busy_skipped.push((branch, hard, soft));
137            }
138        }
139        to_delete = kept;
140    }
141
142    if !busy_skipped.is_empty() {
143        println!(
144            "{}",
145            style(format!(
146                "Skipping {} busy worktree(s) (use --force to override):",
147                busy_skipped.len()
148            ))
149            .yellow()
150        );
151        for (branch, hard, soft) in &busy_skipped {
152            eprint!(
153                "{}",
154                crate::operations::busy_messages::render_refusal(branch, hard, soft)
155            );
156        }
157        println!();
158    }
159
160    if to_delete.is_empty() {
161        println!(
162            "{} No worktrees match the cleanup criteria\n",
163            style("*").green().bold()
164        );
165        return Ok(());
166    }
167
168    // Show what will be deleted
169    let prefix = if dry_run { "DRY RUN: " } else { "" };
170    println!(
171        "\n{}\n",
172        style(format!("{}Worktrees to delete:", prefix))
173            .yellow()
174            .bold()
175    );
176    for (branch, path, reason) in &to_delete {
177        println!("  - {:<30} ({})", branch, reason);
178        println!("    Path: {}", path);
179    }
180    println!();
181
182    if dry_run {
183        println!(
184            "{} Would delete {} worktree(s)",
185            style("*").cyan().bold(),
186            to_delete.len()
187        );
188        println!("Run without --dry-run to actually delete them");
189        return Ok(());
190    }
191
192    // Delete worktrees
193    let mut deleted = 0u32;
194    for (branch, _, _) in &to_delete {
195        println!("{}", style(format!("Deleting {}...", branch)).yellow());
196        // clean already filtered out busy worktrees above (unless --force),
197        // so at this point we pass allow_busy=true to skip the redundant
198        // gate inside delete_worktree.
199        match super::worktree::delete_worktree(Some(branch), false, false, true, true, None) {
200            Ok(()) => {
201                println!("{} Deleted {}", style("*").green().bold(), branch);
202                deleted += 1;
203            }
204            Err(e) => {
205                println!(
206                    "{} Failed to delete {}: {}",
207                    style("x").red().bold(),
208                    branch,
209                    e
210                );
211            }
212        }
213    }
214
215    println!(
216        "\n{}\n",
217        style(messages::cleanup_complete(deleted)).green().bold()
218    );
219
220    // Prune stale metadata
221    println!("{}", style("Pruning stale worktree metadata...").dim());
222    let _ = git::git_command(&["worktree", "prune"], Some(&repo), false, false);
223    println!("{}\n", style("* Prune complete").dim());
224
225    Ok(())
226}