git_workty/commands/
clean.rs1use 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 let statuses = if opts.gone || opts.stale_days.is_some() {
26 Some(get_all_statuses(repo, &worktrees))
27 } else {
28 None
29 };
30
31 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 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 if opts.gone {
83 if let Some(status) = get_status(wt) {
84 if status.upstream_gone {
85 return true;
86 }
87 }
88 }
89
90 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 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}