1use anyhow::Result;
2use apm_core::clean;
3use std::path::Path;
4use crate::ctx::CmdContext;
5
6#[allow(clippy::too_many_arguments)]
7pub fn run(
8 root: &Path,
9 dry_run: bool,
10 _yes: bool,
11 force: bool,
12 branches: bool,
13 older_than: Option<String>,
14 untracked: bool,
15 epics: bool,
16) -> Result<()> {
17 let config = CmdContext::load_config_only(root)?;
18 let (mut candidates, dirty, candidate_warnings) = clean::candidates(root, &config, force, untracked, dry_run)?;
19 for w in &candidate_warnings {
20 eprintln!("{w}");
21 }
22
23 if branches {
28 let local_branch_set: std::collections::HashSet<String> =
29 candidates.iter().map(|c| c.branch.clone()).collect();
30 candidates.extend(clean::remote_only_candidates(root, &config, &local_branch_set)?);
31 }
32
33 if let Some(threshold_str) = older_than.as_deref() {
36 let threshold = clean::parse_older_than(threshold_str)?;
37 candidates.retain(|c| match c.updated_at {
38 Some(ts) => ts < threshold,
39 None => false,
40 });
41 }
42
43 let cwd = std::env::current_dir().unwrap_or_default();
45 let canonical_cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.clone());
46 for candidate in &candidates {
47 if let Some(ref wt_path) = candidate.worktree {
48 let canonical_wt = wt_path.canonicalize().unwrap_or_else(|_| wt_path.clone());
49 if canonical_cwd.starts_with(&canonical_wt) {
50 eprintln!(
51 "refusing to remove worktree containing the current working directory: {}",
52 wt_path.display()
53 );
54 anyhow::bail!(
55 "refusing to remove worktree containing the current working directory: {}",
56 wt_path.display()
57 );
58 }
59 }
60 }
61
62 if candidates.is_empty() && dirty.is_empty() && !epics {
63 println!("Nothing to clean.");
64 return Ok(());
65 }
66
67 for dw in &dirty {
69 if !dw.modified_tracked.is_empty() {
70 for f in &dw.modified_tracked {
71 eprintln!(" M {}", f.display());
72 }
73 eprintln!(
74 "warning: {} has modified tracked files — manual cleanup required — skipping",
75 dw.branch
76 );
77 } else {
78 for f in &dw.other_untracked {
79 eprintln!(" ? {}", f.display());
80 }
81 eprintln!(
82 "warning: {} has untracked files — re-run with --untracked to remove — skipping",
83 dw.branch
84 );
85 }
86 }
87
88 for candidate in &candidates {
89 let scope = match (candidate.local_branch_exists, candidate.remote_branch_exists) {
90 (true, true) => "local + remote",
91 (true, false) => "local",
92 (false, true) => "remote",
93 (false, false) => "registry only",
94 };
95 if dry_run {
96 if let Some(ref path) = candidate.worktree {
97 println!(
98 "would remove worktree {} (ticket #{}, state: {})",
99 path.display(),
100 candidate.ticket_id,
101 candidate.reason
102 );
103 }
104 if branches {
105 println!(
106 "would remove branch {} ({}, state: {})",
107 candidate.branch, scope, candidate.reason
108 );
109 }
110 } else if force {
111 if crate::util::prompt_yes_no(&format!("Remove {}? [y/N] ", candidate.branch))? {
112 if let Some(ref path) = candidate.worktree {
113 println!("removed worktree {}", path.display());
114 }
115 if branches {
116 println!("removed branch {} ({})", candidate.branch, scope);
117 }
118 let remove_out = clean::remove(root, candidate, true, branches)?;
119 for w in &remove_out.warnings {
120 eprintln!("{w}");
121 }
122 } else {
123 eprintln!("skipping {}", candidate.branch);
124 }
125 } else {
126 if let Some(ref path) = candidate.worktree {
127 println!("removed worktree {}", path.display());
128 }
129 if branches {
130 println!("removed branch {} ({})", candidate.branch, scope);
131 }
132 let remove_out = clean::remove(root, candidate, false, branches)?;
133 for w in &remove_out.warnings {
134 eprintln!("{w}");
135 }
136 }
137 }
138
139 if epics {
140 crate::cmd::epic::run_epic_clean(root, &config, dry_run, _yes)?;
141 }
142
143 Ok(())
144}