git_workty/commands/
clean.rs

1use crate::config::Config;
2use crate::git::GitRepo;
3use crate::status::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 dry_run: bool,
14    pub yes: bool,
15}
16
17pub fn execute(repo: &GitRepo, opts: CleanOptions) -> Result<()> {
18    let config = Config::load(repo)?;
19    let worktrees = list_worktrees(repo)?;
20    let current_path = std::env::current_dir().unwrap_or_default();
21
22    let candidates: Vec<&Worktree> = worktrees
23        .iter()
24        .filter(|wt| {
25            if wt.path == current_path {
26                return false;
27            }
28
29            if wt.is_main_worktree(repo) {
30                return false;
31            }
32
33            if wt.detached {
34                return false;
35            }
36
37            if let Some(branch) = &wt.branch_short {
38                if branch == &config.base {
39                    return false;
40                }
41            }
42
43            if opts.merged {
44                if let Some(branch) = &wt.branch_short {
45                    matches!(repo.is_merged(branch, &config.base), Ok(true))
46                } else {
47                    false
48                }
49            } else {
50                true
51            }
52        })
53        .collect();
54
55    if candidates.is_empty() {
56        print_info("No worktrees to clean up.");
57        return Ok(());
58    }
59
60    println!("Worktrees to remove:");
61    for wt in &candidates {
62        let dirty = if is_worktree_dirty(wt) {
63            " (dirty)"
64        } else {
65            ""
66        };
67        println!("  - {}{}", wt.name(), dirty);
68    }
69
70    if opts.dry_run {
71        print_info("Dry run - no worktrees removed.");
72        return Ok(());
73    }
74
75    let dirty_count = candidates.iter().filter(|wt| is_worktree_dirty(wt)).count();
76    if dirty_count > 0 {
77        print_warning(&format!(
78            "{} worktree(s) have uncommitted changes and will be skipped.",
79            dirty_count
80        ));
81    }
82
83    let clean_candidates: Vec<&&Worktree> = candidates
84        .iter()
85        .filter(|wt| !is_worktree_dirty(wt))
86        .collect();
87
88    if clean_candidates.is_empty() {
89        print_info("All candidate worktrees have uncommitted changes. Nothing to remove.");
90        return Ok(());
91    }
92
93    if !opts.yes && std::io::stdin().is_terminal() {
94        let confirm = Confirm::new()
95            .with_prompt(format!("Remove {} worktree(s)?", clean_candidates.len()))
96            .default(false)
97            .interact()?;
98
99        if !confirm {
100            eprintln!("Aborted.");
101            return Ok(());
102        }
103    } else if !opts.yes {
104        print_warning("Non-interactive mode requires --yes flag for destructive operations.");
105        std::process::exit(1);
106    }
107
108    let mut removed = 0;
109    for wt in clean_candidates {
110        let output = Command::new("git")
111            .current_dir(&repo.root)
112            .args(["worktree", "remove", wt.path.to_str().unwrap()])
113            .output()
114            .context("Failed to remove worktree")?;
115
116        if output.status.success() {
117            print_success(&format!("Removed worktree '{}'", wt.name()));
118            removed += 1;
119        } else {
120            let stderr = String::from_utf8_lossy(&output.stderr);
121            print_warning(&format!(
122                "Failed to remove '{}': {}",
123                wt.name(),
124                stderr.trim()
125            ));
126        }
127    }
128
129    print_info(&format!("Cleaned up {} worktree(s).", removed));
130
131    Ok(())
132}