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) -> Result<()> {
19    let repo = git::get_repo_root(None)?;
20
21    // Must specify at least one criterion
22    if !merged && older_than.is_none() && !interactive {
23        eprintln!(
24            "Error: Please specify at least one cleanup criterion:\n  \
25             --merged, --older-than, or -i/--interactive"
26        );
27        return Ok(());
28    }
29
30    let mut to_delete: Vec<(String, String, String)> = Vec::new(); // (branch, path, reason)
31
32    for (branch_name, path) in git::get_feature_worktrees(Some(&repo))? {
33        let mut should_delete = false;
34        let mut reasons = Vec::new();
35
36        // Check if merged
37        if merged {
38            let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, &branch_name);
39            if let Some(base_branch) = git::get_config(&base_key, Some(&repo)) {
40                if let Ok(r) = git::git_command(
41                    &[
42                        "branch",
43                        "--merged",
44                        &base_branch,
45                        "--format=%(refname:short)",
46                    ],
47                    Some(&repo),
48                    false,
49                    true,
50                ) {
51                    if r.returncode == 0 && r.stdout.lines().any(|l| l.trim() == branch_name) {
52                        should_delete = true;
53                        reasons.push(format!("merged into {}", base_branch));
54                    }
55                }
56            }
57        }
58
59        // Check age
60        if let Some(days) = older_than {
61            if let Some(age) = path_age_days(&path) {
62                let age_days = age as u64;
63                if age_days > days {
64                    should_delete = true;
65                    reasons.push(format!("older than {} days ({} days)", days, age_days));
66                }
67            }
68        }
69
70        if should_delete {
71            to_delete.push((
72                branch_name.clone(),
73                path.to_string_lossy().to_string(),
74                reasons.join(", "),
75            ));
76        }
77    }
78
79    // Interactive mode
80    if interactive && to_delete.is_empty() {
81        println!("{}\n", style("Available worktrees:").cyan().bold());
82        let mut all_wt = Vec::new();
83        for (branch_name, path) in git::get_feature_worktrees(Some(&repo))? {
84            let status = get_worktree_status(&path, &repo);
85            println!("  [{:8}] {:<30} {}", status, branch_name, path.display());
86            all_wt.push((branch_name, path.to_string_lossy().to_string()));
87        }
88        println!();
89        println!("Enter branch names to delete (space-separated), or 'all' for all:");
90
91        let mut input = String::new();
92        std::io::stdin().read_line(&mut input)?;
93        let input = input.trim();
94
95        if input.eq_ignore_ascii_case("all") {
96            to_delete = all_wt
97                .into_iter()
98                .map(|(b, p)| (b, p, "user selected".to_string()))
99                .collect();
100        } else {
101            let selected: Vec<&str> = input.split_whitespace().collect();
102            to_delete = all_wt
103                .into_iter()
104                .filter(|(b, _)| selected.contains(&b.as_str()))
105                .map(|(b, p)| (b, p, "user selected".to_string()))
106                .collect();
107        }
108
109        if to_delete.is_empty() {
110            println!("{}", style("No worktrees selected for deletion").yellow());
111            return Ok(());
112        }
113    }
114
115    if to_delete.is_empty() {
116        println!(
117            "{} No worktrees match the cleanup criteria\n",
118            style("*").green().bold()
119        );
120        return Ok(());
121    }
122
123    // Show what will be deleted
124    let prefix = if dry_run { "DRY RUN: " } else { "" };
125    println!(
126        "\n{}\n",
127        style(format!("{}Worktrees to delete:", prefix))
128            .yellow()
129            .bold()
130    );
131    for (branch, path, reason) in &to_delete {
132        println!("  - {:<30} ({})", branch, reason);
133        println!("    Path: {}", path);
134    }
135    println!();
136
137    if dry_run {
138        println!(
139            "{} Would delete {} worktree(s)",
140            style("*").cyan().bold(),
141            to_delete.len()
142        );
143        println!("Run without --dry-run to actually delete them");
144        return Ok(());
145    }
146
147    // Delete worktrees
148    let mut deleted = 0u32;
149    for (branch, _, _) in &to_delete {
150        println!("{}", style(format!("Deleting {}...", branch)).yellow());
151        match super::worktree::delete_worktree(Some(branch), false, false, true, None) {
152            Ok(()) => {
153                println!("{} Deleted {}", style("*").green().bold(), branch);
154                deleted += 1;
155            }
156            Err(e) => {
157                println!(
158                    "{} Failed to delete {}: {}",
159                    style("x").red().bold(),
160                    branch,
161                    e
162                );
163            }
164        }
165    }
166
167    println!(
168        "\n{}\n",
169        style(messages::cleanup_complete(deleted)).green().bold()
170    );
171
172    // Prune stale metadata
173    println!("{}", style("Pruning stale worktree metadata...").dim());
174    let _ = git::git_command(&["worktree", "prune"], Some(&repo), false, false);
175    println!("{}\n", style("* Prune complete").dim());
176
177    Ok(())
178}