Skip to main content

git_worktree_manager/operations/
clean.rs

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