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::{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    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 no filter specified, don't include anything
64            if !has_filter {
65                return false;
66            }
67
68            // Check --merged
69            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            // Check --gone (upstream branch deleted)
78            if opts.gone {
79                if let Some(status) = get_status(wt) {
80                    if status.upstream_gone {
81                        return true;
82                    }
83                }
84            }
85
86            // Check --stale (not touched in X days)
87            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}