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 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 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 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 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}