Skip to main content

apm/cmd/
clean.rs

1use anyhow::Result;
2use apm_core::{clean, git};
3use std::io::IsTerminal;
4use std::path::Path;
5use crate::ctx::CmdContext;
6
7pub fn run(
8    root: &Path,
9    dry_run: bool,
10    yes: bool,
11    force: bool,
12    branches: bool,
13    remote: bool,
14    older_than: Option<String>,
15    untracked: bool,
16    epics: bool,
17) -> Result<()> {
18    // Validate flag combinations.
19    if remote && older_than.is_none() {
20        anyhow::bail!("--remote requires --older-than <THRESHOLD>");
21    }
22
23    let config = CmdContext::load_config_only(root)?;
24    let (candidates, dirty, candidate_warnings) = clean::candidates(root, &config, force, untracked, dry_run)?;
25    for w in &candidate_warnings {
26        eprintln!("{w}");
27    }
28
29    // Refuse to remove any worktree that contains the current working directory.
30    let cwd = std::env::current_dir().unwrap_or_default();
31    let canonical_cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.clone());
32    for candidate in &candidates {
33        if let Some(ref wt_path) = candidate.worktree {
34            let canonical_wt = wt_path.canonicalize().unwrap_or_else(|_| wt_path.clone());
35            if canonical_cwd.starts_with(&canonical_wt) {
36                eprintln!(
37                    "refusing to remove worktree containing the current working directory: {}",
38                    wt_path.display()
39                );
40                anyhow::bail!(
41                    "refusing to remove worktree containing the current working directory: {}",
42                    wt_path.display()
43                );
44            }
45        }
46    }
47
48    if candidates.is_empty() && dirty.is_empty() && !remote && !epics {
49        println!("Nothing to clean.");
50        return Ok(());
51    }
52
53    // Warn about dirty worktrees that can't be auto-cleaned.
54    for dw in &dirty {
55        if !dw.modified_tracked.is_empty() {
56            for f in &dw.modified_tracked {
57                eprintln!("  M {}", f.display());
58            }
59            eprintln!(
60                "warning: {} has modified tracked files — manual cleanup required — skipping",
61                dw.branch
62            );
63        } else {
64            for f in &dw.other_untracked {
65                eprintln!("  ? {}", f.display());
66            }
67            eprintln!(
68                "warning: {} has untracked files — re-run with --untracked to remove — skipping",
69                dw.branch
70            );
71        }
72    }
73
74    for candidate in &candidates {
75        if dry_run {
76            if let Some(ref path) = candidate.worktree {
77                println!(
78                    "would remove worktree {} (ticket #{}, state: {})",
79                    path.display(),
80                    candidate.ticket_id,
81                    candidate.reason
82                );
83            }
84            if branches && candidate.local_branch_exists && (candidate.branch_merged || force) {
85                println!(
86                    "would remove branch {} (state: {})",
87                    candidate.branch, candidate.reason
88                );
89            } else if branches && candidate.local_branch_exists && !candidate.branch_merged {
90                println!(
91                    "would keep branch {} (not merged into main)",
92                    candidate.branch
93                );
94            }
95        } else if force {
96            eprintln!(
97                "warning: force-removing {} — branch may not be merged",
98                candidate.branch
99            );
100            if crate::util::prompt_yes_no(&format!("Force-remove {}? [y/N] ", candidate.branch))? {
101                if let Some(ref path) = candidate.worktree {
102                    println!("removed worktree {}", path.display());
103                }
104                if branches && candidate.local_branch_exists {
105                    println!("removed branch {}", candidate.branch);
106                }
107                let remove_out = clean::remove(root, candidate, true, branches)?;
108                for w in &remove_out.warnings {
109                    eprintln!("{w}");
110                }
111            } else {
112                eprintln!("skipping {}", candidate.branch);
113            }
114        } else {
115            if let Some(ref path) = candidate.worktree {
116                println!("removed worktree {}", path.display());
117            }
118            if branches && candidate.local_branch_exists && candidate.branch_merged {
119                println!("removed branch {}", candidate.branch);
120            } else if branches && candidate.local_branch_exists && !candidate.branch_merged {
121                println!("kept branch {} (not merged into main)", candidate.branch);
122            }
123            let remove_out = clean::remove(root, candidate, false, branches)?;
124            for w in &remove_out.warnings {
125                eprintln!("{w}");
126            }
127        }
128    }
129
130    // --remote --older-than path.
131    if remote {
132        let threshold_str = older_than.as_deref().unwrap();
133        let threshold = clean::parse_older_than(threshold_str)?;
134        let remote_candidates = clean::remote_candidates(root, &config, threshold)?;
135
136        if remote_candidates.is_empty() {
137            println!("No remote branches to clean.");
138        }
139
140        for rc in &remote_candidates {
141            if dry_run {
142                println!(
143                    "would delete remote branch {} (last commit: {})",
144                    rc.branch,
145                    rc.last_commit.format("%Y-%m-%d")
146                );
147                continue;
148            }
149            let should_delete = if yes {
150                true
151            } else if std::io::stdout().is_terminal() {
152                crate::util::prompt_yes_no(&format!(
153                    "Delete remote branch {} (last commit: {})? [y/N] ",
154                    rc.branch,
155                    rc.last_commit.format("%Y-%m-%d")
156                ))?
157            } else {
158                eprintln!(
159                    "skipping {} — non-interactive (use --yes to auto-confirm)",
160                    rc.branch
161                );
162                false
163            };
164            if should_delete {
165                git::delete_remote_branch(root, &rc.branch)?;
166                println!("deleted remote branch {}", rc.branch);
167            }
168        }
169    }
170
171    if epics || remote {
172        crate::cmd::epic::run_epic_clean(root, &config, dry_run, yes)?;
173    }
174
175    Ok(())
176}