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::{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 let candidates: Vec<&Worktree> = worktrees
43 .iter()
44 .filter(|wt| {
45 if wt.path == current_path {
46 return false;
47 }
48
49 if wt.is_main_worktree(repo) {
50 return false;
51 }
52
53 if wt.detached {
54 return false;
55 }
56
57 if let Some(branch) = &wt.branch_short {
58 if branch == &config.base {
59 return false;
60 }
61 }
62
63 if !has_filter {
65 return false;
66 }
67
68 if opts.merged {
70 if let Some(branch) = &wt.branch_short {
71 if matches!(repo.is_merged(branch, &config.base), Ok(true)) {
72 return true;
73 }
74 }
75 }
76
77 if opts.gone {
79 if let Some(status) = get_status(wt) {
80 if status.upstream_gone {
81 return true;
82 }
83 }
84 }
85
86 if let Some(days) = opts.stale_days {
88 if let Some(status) = get_status(wt) {
89 if let Some(seconds) = status.last_commit_time {
90 let stale_seconds = (days as i64) * 24 * 60 * 60;
91 if seconds > stale_seconds {
92 return true;
93 }
94 }
95 }
96 }
97
98 false
99 })
100 .collect();
101
102 if candidates.is_empty() {
103 print_info("No worktrees to clean up.");
104 return Ok(());
105 }
106
107 println!("Worktrees to remove:");
108 for wt in &candidates {
109 let dirty = if is_worktree_dirty(wt) {
110 " (dirty)"
111 } else {
112 ""
113 };
114 println!(" - {}{}", wt.name(), dirty);
115 }
116
117 if opts.dry_run {
118 print_info("Dry run - no worktrees removed.");
119 return Ok(());
120 }
121
122 let dirty_count = candidates.iter().filter(|wt| is_worktree_dirty(wt)).count();
123 if dirty_count > 0 {
124 print_warning(&format!(
125 "{} worktree(s) have uncommitted changes and will be skipped.",
126 dirty_count
127 ));
128 }
129
130 let clean_candidates: Vec<&&Worktree> = candidates
131 .iter()
132 .filter(|wt| !is_worktree_dirty(wt))
133 .collect();
134
135 if clean_candidates.is_empty() {
136 print_info("All candidate worktrees have uncommitted changes. Nothing to remove.");
137 return Ok(());
138 }
139
140 if !opts.yes && std::io::stdin().is_terminal() {
141 let confirm = Confirm::new()
142 .with_prompt(format!("Remove {} worktree(s)?", clean_candidates.len()))
143 .default(false)
144 .interact()?;
145
146 if !confirm {
147 eprintln!("Aborted.");
148 return Ok(());
149 }
150 } else if !opts.yes {
151 print_warning("Non-interactive mode requires --yes flag for destructive operations.");
152 std::process::exit(1);
153 }
154
155 let mut removed = 0;
156 for wt in clean_candidates {
157 let output = Command::new("git")
158 .current_dir(&repo.root)
159 .args(["worktree", "remove", wt.path.to_str().unwrap()])
160 .output()
161 .context("Failed to remove worktree")?;
162
163 if output.status.success() {
164 print_success(&format!("Removed worktree '{}'", wt.name()));
165 removed += 1;
166 } else {
167 let stderr = String::from_utf8_lossy(&output.stderr);
168 print_warning(&format!(
169 "Failed to remove '{}': {}",
170 wt.name(),
171 stderr.trim()
172 ));
173 }
174 }
175
176 print_info(&format!("Cleaned up {} worktree(s).", removed));
177
178 Ok(())
179}