Skip to main content

git_workty/commands/
clean.rs

1use crate::config::Config;
2use crate::git::GitRepo;
3use crate::status::{get_all_statuses, is_worktree_dirty};
4use crate::ui::{print_info, print_success, print_warning};
5use crate::worktree::{list_worktrees, Worktree};
6use anyhow::{bail, Context, Result};
7use dialoguer::Confirm;
8use is_terminal::IsTerminal;
9use std::process::Command;
10
11pub struct CleanOptions {
12    pub merged: bool,
13    pub gone: bool,
14    pub stale_days: Option<u32>,
15    pub dry_run: bool,
16    pub yes: bool,
17}
18
19pub fn execute(repo: &GitRepo, opts: CleanOptions) -> Result<()> {
20    let config = Config::load(repo)?;
21    let worktrees = list_worktrees(repo)?;
22    let current_path = std::env::current_dir().unwrap_or_default();
23
24    // Get statuses if we need them for --gone or --stale
25    let statuses = if opts.gone || opts.stale_days.is_some() {
26        Some(get_all_statuses(repo, &worktrees))
27    } else {
28        None
29    };
30
31    // Helper to find status for a worktree
32    let get_status = |wt: &Worktree| {
33        statuses.as_ref().and_then(|s| {
34            s.iter()
35                .find(|(w, _)| w.path == wt.path)
36                .map(|(_, status)| status)
37        })
38    };
39
40    let has_filter = opts.merged || opts.gone || opts.stale_days.is_some();
41
42    if !has_filter {
43        print_info("No filter specified. Use one of:");
44        println!("  --merged      Remove worktrees whose branches are merged into base");
45        println!("  --gone        Remove worktrees whose upstream branch was deleted");
46        println!("  --stale N     Remove worktrees not touched in N days");
47        println!("\nAdd --dry-run to preview what would be removed.");
48        return Ok(());
49    }
50
51    let candidates: Vec<&Worktree> = worktrees
52        .iter()
53        .filter(|wt| {
54            if wt.path == current_path {
55                return false;
56            }
57
58            if wt.is_main_worktree(repo) {
59                return false;
60            }
61
62            if wt.detached {
63                return false;
64            }
65
66            if let Some(branch) = &wt.branch_short {
67                if branch == &config.base {
68                    return false;
69                }
70            }
71
72            // Check --merged
73            if opts.merged {
74                if let Some(branch) = &wt.branch_short {
75                    if matches!(repo.is_merged(branch, &config.base), Ok(true)) {
76                        return true;
77                    }
78                }
79            }
80
81            // Check --gone (upstream branch deleted)
82            if opts.gone {
83                if let Some(status) = get_status(wt) {
84                    if status.upstream_gone {
85                        return true;
86                    }
87                }
88            }
89
90            // Check --stale (not touched in X days)
91            if let Some(days) = opts.stale_days {
92                if let Some(status) = get_status(wt) {
93                    if let Some(seconds) = status.last_commit_time {
94                        let stale_seconds = (days as i64) * 24 * 60 * 60;
95                        if seconds > stale_seconds {
96                            return true;
97                        }
98                    }
99                }
100            }
101
102            false
103        })
104        .collect();
105
106    if candidates.is_empty() {
107        print_info("No worktrees to clean up.");
108        return Ok(());
109    }
110
111    // Compute dirty status once per candidate to avoid redundant checks
112    let candidates_with_dirty: Vec<(&Worktree, bool)> = candidates
113        .into_iter()
114        .map(|wt| {
115            let is_dirty = is_worktree_dirty(wt);
116            (wt, is_dirty)
117        })
118        .collect();
119
120    println!("Worktrees to remove:");
121    for (wt, is_dirty) in &candidates_with_dirty {
122        let dirty_str = if *is_dirty { " (dirty)" } else { "" };
123        println!("  - {}{}", wt.name(), dirty_str);
124    }
125
126    if opts.dry_run {
127        print_info("Dry run - no worktrees removed.");
128        return Ok(());
129    }
130
131    let dirty_count = candidates_with_dirty.iter().filter(|(_, d)| *d).count();
132    if dirty_count > 0 {
133        print_warning(&format!(
134            "{} worktree(s) have uncommitted changes and will be skipped.",
135            dirty_count
136        ));
137    }
138
139    let clean_candidates: Vec<&Worktree> = candidates_with_dirty
140        .iter()
141        .filter(|(_, is_dirty)| !is_dirty)
142        .map(|(wt, _)| *wt)
143        .collect();
144
145    if clean_candidates.is_empty() {
146        print_info("All candidate worktrees have uncommitted changes. Nothing to remove.");
147        return Ok(());
148    }
149
150    if !opts.yes && std::io::stdin().is_terminal() {
151        let confirm = Confirm::new()
152            .with_prompt(format!("Remove {} worktree(s)?", clean_candidates.len()))
153            .default(false)
154            .interact()?;
155
156        if !confirm {
157            eprintln!("Aborted.");
158            return Ok(());
159        }
160    } else if !opts.yes {
161        bail!("Non-interactive mode requires --yes flag for destructive operations");
162    }
163
164    let mut removed = 0;
165    for wt in clean_candidates {
166        let path_str = wt
167            .path
168            .to_str()
169            .ok_or_else(|| anyhow::anyhow!("Path contains invalid UTF-8: {:?}", wt.path))?;
170
171        let output = Command::new("git")
172            .current_dir(&repo.root)
173            .args(["worktree", "remove", path_str])
174            .output()
175            .context("Failed to remove worktree")?;
176
177        if output.status.success() {
178            print_success(&format!("Removed worktree '{}'", wt.name()));
179            removed += 1;
180        } else {
181            let stderr = String::from_utf8_lossy(&output.stderr);
182            print_warning(&format!(
183                "Failed to remove '{}': {}",
184                wt.name(),
185                stderr.trim()
186            ));
187        }
188    }
189
190    print_info(&format!("Cleaned up {} worktree(s).", removed));
191
192    Ok(())
193}