1use crate::{config::Config, git, git_util, ticket, ticket_fmt, worktree};
2use anyhow::Result;
3use chrono::{DateTime, NaiveDate, Utc};
4use std::path::{Path, PathBuf};
5
6const KNOWN_TEMP_FILES: &[&str] = &[
7 "pr-body.md",
8 "body.md",
9 "ac.txt",
10 ".apm-worker.pid",
11 ".apm-worker.log",
12];
13
14pub struct RemoteCandidate {
15 pub branch: String,
16 pub last_commit: DateTime<Utc>,
17}
18
19pub struct CleanCandidate {
20 pub ticket_id: String,
21 pub ticket_title: String,
22 pub branch: String,
23 pub worktree: Option<PathBuf>,
24 pub reason: String,
25 pub local_branch_exists: bool,
26 pub branch_merged: bool,
27}
28
29pub struct DirtyWorktree {
30 pub ticket_id: String,
31 pub ticket_title: String,
32 pub branch: String,
33 pub path: PathBuf,
34 pub local_branch_exists: bool,
35 pub known_temp: Vec<PathBuf>,
36 pub other_untracked: Vec<PathBuf>,
37 pub modified_tracked: Vec<PathBuf>,
38}
39
40pub fn diagnose_worktree(
41 path: &Path,
42 ticket_id: &str,
43 ticket_title: &str,
44 branch: &str,
45 local_branch_exists: bool,
46 agent_dirs: &[String],
47) -> Result<DirtyWorktree> {
48 let stdout = git_util::run(path, &["status", "--porcelain"])?;
49
50 let mut known_temp = Vec::new();
51 let mut other_untracked = Vec::new();
52 let mut modified_tracked = Vec::new();
53
54 for line in stdout.lines() {
55 if line.len() < 3 {
56 continue;
57 }
58 let xy = &line[..2];
59 let file = line[3..].trim();
60 let filename = std::path::Path::new(file)
61 .file_name()
62 .map(|n| n.to_string_lossy().into_owned())
63 .unwrap_or_default();
64 let top_dir = file.split('/').next().unwrap_or("");
65
66 if xy == "??" {
67 if KNOWN_TEMP_FILES.contains(&filename.as_str())
68 || agent_dirs.iter().any(|d| d.trim_end_matches('/') == top_dir)
69 {
70 known_temp.push(PathBuf::from(file));
71 } else {
72 other_untracked.push(PathBuf::from(file));
73 }
74 } else {
75 modified_tracked.push(PathBuf::from(file));
76 }
77 }
78
79 Ok(DirtyWorktree {
80 ticket_id: ticket_id.to_string(),
81 ticket_title: ticket_title.to_string(),
82 branch: branch.to_string(),
83 path: path.to_path_buf(),
84 local_branch_exists,
85 known_temp,
86 other_untracked,
87 modified_tracked,
88 })
89}
90
91pub fn remove_untracked(wt_path: &Path, files: &[PathBuf]) -> Result<()> {
92 for file in files {
93 let full_path = wt_path.join(file);
94 if full_path.is_dir() {
95 std::fs::remove_dir_all(&full_path)?;
96 } else if full_path.exists() {
97 std::fs::remove_file(&full_path)?;
98 }
99 }
100 Ok(())
101}
102
103pub struct RemoveOutput {
104 pub warnings: Vec<String>,
105}
106
107pub fn candidates(root: &Path, config: &Config, force: bool, untracked: bool, dry_run: bool) -> Result<(Vec<CleanCandidate>, Vec<DirtyWorktree>, Vec<String>)> {
108 let mut warnings: Vec<String> = Vec::new();
109 let terminal_states = config.terminal_state_ids();
110
111 let default_branch = &config.project.default_branch;
112 let tickets = ticket::load_all_from_git(root, &config.tickets.dir)?;
113 let merged = git::merged_into_main(root, default_branch)?;
114 let merged_set: std::collections::HashSet<&str> = merged.iter().map(|s| s.as_str()).collect();
115
116 let mut result = Vec::new();
117 let mut dirty_result = Vec::new();
118
119 for t in &tickets {
120 if !terminal_states.contains(t.frontmatter.state.as_str()) {
121 continue;
122 }
123
124 let branch = t
125 .frontmatter
126 .branch
127 .clone()
128 .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
129 .unwrap_or_else(|| format!("ticket/{}", t.frontmatter.id));
130
131 let id = t.frontmatter.id.clone();
132 let branch_state = &t.frontmatter.state;
133
134 let is_merged = merged_set.contains(branch.as_str());
135
136 let local_tip = git::branch_tip(root, &branch);
137 let is_ancestor = if let Some(ref tip) = local_tip {
138 git::is_ancestor(root, tip, default_branch)
139 } else {
140 true
141 };
142
143 let wt_path = worktree::find_worktree_for_branch(root, &branch);
148
149 let wt_clean = if let Some(ref path) = wt_path {
152 !git_util::is_worktree_dirty(path)
153 } else {
154 true
155 };
156
157 if !force {
158 let remote_tip = git::remote_branch_tip(root, &branch);
159 if let (Some(ref lt), Some(ref rt)) = (&local_tip, &remote_tip) {
160 if lt != rt && !wt_clean {
161 warnings.push(format!(
162 "warning: {branch} local tip differs from origin/{branch} — skipping"
163 ));
164 continue;
165 }
166 }
167 }
168
169 if let Some(ref path) = wt_path {
170 if !wt_clean {
171 let lbe = git_util::local_branch_exists(root, &branch);
172 let diagnosis =
173 diagnose_worktree(path, &id, &t.frontmatter.title, &branch, lbe, &config.worktrees.agent_dirs)?;
174 if diagnosis.modified_tracked.is_empty() {
175 if force {
176 result.push(CleanCandidate {
178 ticket_id: id,
179 ticket_title: t.frontmatter.title.clone(),
180 branch: branch.clone(),
181 worktree: wt_path,
182 reason: branch_state.clone(),
183 local_branch_exists: lbe,
184 branch_merged: is_merged && is_ancestor,
185 });
186 } else if untracked || diagnosis.other_untracked.is_empty() {
187 if !dry_run {
190 remove_untracked(path, &diagnosis.known_temp)?;
191 if untracked {
192 remove_untracked(path, &diagnosis.other_untracked)?;
193 }
194 }
195 result.push(CleanCandidate {
196 ticket_id: id,
197 ticket_title: t.frontmatter.title.clone(),
198 branch: branch.clone(),
199 worktree: wt_path,
200 reason: branch_state.clone(),
201 local_branch_exists: lbe,
202 branch_merged: is_merged && is_ancestor,
203 });
204 } else {
205 dirty_result.push(diagnosis);
206 }
207 } else {
208 dirty_result.push(diagnosis);
209 }
210 continue;
211 }
212 }
213
214 let local_branch_exists = git_util::local_branch_exists(root, &branch);
215
216 if wt_path.is_none() && !local_branch_exists {
217 continue;
218 }
219
220 result.push(CleanCandidate {
221 ticket_id: id,
222 ticket_title: t.frontmatter.title.clone(),
223 branch: branch.clone(),
224 worktree: wt_path,
225 reason: branch_state.clone(),
226 local_branch_exists,
227 branch_merged: is_merged && is_ancestor,
228 });
229 }
230
231 Ok((result, dirty_result, warnings))
232}
233
234pub fn remove(root: &Path, candidate: &CleanCandidate, force: bool, remove_branches: bool) -> Result<RemoveOutput> {
235 let mut warnings: Vec<String> = Vec::new();
236
237 if let Some(ref path) = candidate.worktree {
238 worktree::remove_worktree(root, path, force)?;
239 }
240
241 if remove_branches && candidate.local_branch_exists && (candidate.branch_merged || force) {
242 git_util::delete_local_branch(root, &candidate.branch, &mut warnings);
243 git_util::prune_remote_tracking(root, &candidate.branch);
246 }
247
248 Ok(RemoveOutput { warnings })
249}
250
251pub fn parse_older_than(s: &str) -> anyhow::Result<DateTime<Utc>> {
254 if let Some(days_str) = s.strip_suffix('d') {
255 let days: i64 = days_str
256 .parse()
257 .map_err(|_| anyhow::anyhow!("--older-than: invalid days value {:?}", s))?;
258 return Ok(Utc::now() - chrono::Duration::days(days));
259 }
260 if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
261 return Ok(date.and_hms_opt(0, 0, 0).unwrap().and_utc());
262 }
263 anyhow::bail!(
264 "--older-than: unrecognised format {:?}; use \"30d\" or \"YYYY-MM-DD\"",
265 s
266 )
267}
268
269pub fn remote_candidates(
271 root: &Path,
272 config: &Config,
273 older_than: DateTime<Utc>,
274) -> Result<Vec<RemoteCandidate>> {
275 let terminal_states = config.terminal_state_ids();
276 let default_branch = &config.project.default_branch;
277 let branches = git::remote_ticket_branches_with_dates(root)?;
278 let mut result = Vec::new();
279 for (branch, last_commit) in branches {
280 if last_commit >= older_than {
281 continue;
282 }
283 let suffix = branch.trim_start_matches("ticket/");
284 let rel_path = format!("{}/{suffix}.md", config.tickets.dir.to_string_lossy());
285 if let Some(state) = ticket::state_from_branch(root, default_branch, &rel_path) {
286 if terminal_states.contains(&state) {
287 result.push(RemoteCandidate { branch, last_commit });
288 }
289 }
290 }
291 Ok(result)
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
299 fn parse_older_than_days() {
300 let threshold = parse_older_than("30d").unwrap();
301 let expected = Utc::now() - chrono::Duration::days(30);
302 assert!((threshold - expected).num_seconds().abs() < 5);
304 }
305
306 #[test]
307 fn parse_older_than_iso_date() {
308 let threshold = parse_older_than("2026-01-01").unwrap();
309 assert_eq!(threshold.format("%Y-%m-%d").to_string(), "2026-01-01");
310 }
311
312 #[test]
313 fn parse_older_than_invalid_rejects() {
314 assert!(parse_older_than("notadate").is_err());
315 assert!(parse_older_than("30").is_err());
316 assert!(parse_older_than("").is_err());
317 }
318
319 #[test]
320 fn parse_older_than_zero_days() {
321 let threshold = parse_older_than("0d").unwrap();
322 let now = Utc::now();
323 assert!((threshold - now).num_seconds().abs() < 5);
324 }
325}