git_workty/commands/
clean.rs

1use crate::config::Config;
2use crate::git::{is_ancestor, 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!(is_ancestor(repo, 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) { " (dirty)" } else { "" };
63        println!("  - {}{}", wt.name(), dirty);
64    }
65
66    if opts.dry_run {
67        print_info("Dry run - no worktrees removed.");
68        return Ok(());
69    }
70
71    let dirty_count = candidates.iter().filter(|wt| is_worktree_dirty(wt)).count();
72    if dirty_count > 0 {
73        print_warning(&format!(
74            "{} worktree(s) have uncommitted changes and will be skipped.",
75            dirty_count
76        ));
77    }
78
79    let clean_candidates: Vec<&&Worktree> = candidates
80        .iter()
81        .filter(|wt| !is_worktree_dirty(wt))
82        .collect();
83
84    if clean_candidates.is_empty() {
85        print_info("All candidate worktrees have uncommitted changes. Nothing to remove.");
86        return Ok(());
87    }
88
89    if !opts.yes && std::io::stdin().is_terminal() {
90        let confirm = Confirm::new()
91            .with_prompt(format!(
92                "Remove {} worktree(s)?",
93                clean_candidates.len()
94            ))
95            .default(false)
96            .interact()?;
97
98        if !confirm {
99            eprintln!("Aborted.");
100            return Ok(());
101        }
102    } else if !opts.yes {
103        print_warning("Non-interactive mode requires --yes flag for destructive operations.");
104        std::process::exit(1);
105    }
106
107    let mut removed = 0;
108    for wt in clean_candidates {
109        let output = Command::new("git")
110            .current_dir(&repo.root)
111            .args(["worktree", "remove", wt.path.to_str().unwrap()])
112            .output()
113            .context("Failed to remove worktree")?;
114
115        if output.status.success() {
116            print_success(&format!("Removed worktree '{}'", wt.name()));
117            removed += 1;
118        } else {
119            let stderr = String::from_utf8_lossy(&output.stderr);
120            print_warning(&format!(
121                "Failed to remove '{}': {}",
122                wt.name(),
123                stderr.trim()
124            ));
125        }
126    }
127
128    print_info(&format!("Cleaned up {} worktree(s).", removed));
129
130    Ok(())
131}