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 CleanCandidate {
15 pub ticket_id: String,
16 pub ticket_title: String,
17 pub branch: String,
18 pub worktree: Option<PathBuf>,
19 pub reason: String,
20 pub local_branch_exists: bool,
21 pub branch_merged: bool,
22 pub remote_branch_exists: bool,
27 pub updated_at: Option<DateTime<Utc>>,
30}
31
32pub struct DirtyWorktree {
33 pub ticket_id: String,
34 pub ticket_title: String,
35 pub branch: String,
36 pub path: PathBuf,
37 pub local_branch_exists: bool,
38 pub known_temp: Vec<PathBuf>,
39 pub other_untracked: Vec<PathBuf>,
40 pub modified_tracked: Vec<PathBuf>,
41}
42
43pub fn diagnose_worktree(
44 path: &Path,
45 ticket_id: &str,
46 ticket_title: &str,
47 branch: &str,
48 local_branch_exists: bool,
49 agent_dirs: &[String],
50) -> Result<DirtyWorktree> {
51 let stdout = git_util::run(path, &["status", "--porcelain"])?;
52
53 let mut known_temp = Vec::new();
54 let mut other_untracked = Vec::new();
55 let mut modified_tracked = Vec::new();
56
57 for line in stdout.lines() {
58 if line.len() < 3 {
59 continue;
60 }
61 let xy = &line[..2];
62 let file = line[3..].trim();
63 let filename = std::path::Path::new(file)
64 .file_name()
65 .map(|n| n.to_string_lossy().into_owned())
66 .unwrap_or_default();
67 let top_dir = file.split('/').next().unwrap_or("");
68
69 if xy == "??" {
70 if KNOWN_TEMP_FILES.contains(&filename.as_str())
71 || agent_dirs.iter().any(|d| d.trim_end_matches('/') == top_dir)
72 {
73 known_temp.push(PathBuf::from(file));
74 } else {
75 other_untracked.push(PathBuf::from(file));
76 }
77 } else {
78 modified_tracked.push(PathBuf::from(file));
79 }
80 }
81
82 Ok(DirtyWorktree {
83 ticket_id: ticket_id.to_string(),
84 ticket_title: ticket_title.to_string(),
85 branch: branch.to_string(),
86 path: path.to_path_buf(),
87 local_branch_exists,
88 known_temp,
89 other_untracked,
90 modified_tracked,
91 })
92}
93
94pub fn remove_untracked(wt_path: &Path, files: &[PathBuf]) -> Result<()> {
95 for file in files {
96 let full_path = wt_path.join(file);
97 if full_path.is_dir() {
98 std::fs::remove_dir_all(&full_path)?;
99 } else if full_path.exists() {
100 std::fs::remove_file(&full_path)?;
101 }
102 }
103 Ok(())
104}
105
106pub struct RemoveOutput {
107 pub warnings: Vec<String>,
108}
109
110pub fn candidates(root: &Path, config: &Config, force: bool, untracked: bool, dry_run: bool) -> Result<(Vec<CleanCandidate>, Vec<DirtyWorktree>, Vec<String>)> {
111 let mut warnings: Vec<String> = Vec::new();
112 let terminal_states = config.terminal_state_ids();
113
114 let default_branch = &config.project.default_branch;
115 let tickets = ticket::load_all_from_git(root, &config.tickets.dir)?;
116 let merged = git::merged_into_main(root, default_branch)?;
117 let merged_set: std::collections::HashSet<&str> = merged.iter().map(|s| s.as_str()).collect();
118 let remote_branches = git_util::list_remote_ticket_branches(root);
119
120 let mut result = Vec::new();
121 let mut dirty_result = Vec::new();
122
123 for t in &tickets {
124 if !terminal_states.contains(t.frontmatter.state.as_str()) {
125 continue;
126 }
127
128 let branch = t
129 .frontmatter
130 .branch
131 .clone()
132 .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
133 .unwrap_or_else(|| format!("ticket/{}", t.frontmatter.id));
134
135 let id = t.frontmatter.id.clone();
136 let branch_state = &t.frontmatter.state;
137
138 let is_merged = merged_set.contains(branch.as_str());
139
140 let local_tip = git::branch_tip(root, &branch);
141 let is_ancestor = if let Some(ref tip) = local_tip {
142 git::is_ancestor(root, tip, default_branch)
143 } else {
144 true
145 };
146
147 let wt_path = worktree::find_worktree_for_branch(root, &branch);
152
153 let wt_clean = if let Some(ref path) = wt_path {
156 !git_util::is_worktree_dirty(path)
157 } else {
158 true
159 };
160
161 if !force {
162 let remote_tip = git::remote_branch_tip(root, &branch);
163 if let (Some(ref lt), Some(ref rt)) = (&local_tip, &remote_tip) {
164 if lt != rt && !wt_clean {
165 warnings.push(format!(
166 "warning: {branch} local tip differs from origin/{branch} — skipping"
167 ));
168 continue;
169 }
170 }
171 }
172
173 if let Some(ref path) = wt_path {
174 if !wt_clean {
175 let lbe = git_util::local_branch_exists(root, &branch);
176 let diagnosis =
177 diagnose_worktree(path, &id, &t.frontmatter.title, &branch, lbe, &config.worktrees.agent_dirs)?;
178 if diagnosis.modified_tracked.is_empty() {
179 if force {
180 result.push(CleanCandidate {
182 ticket_id: id,
183 ticket_title: t.frontmatter.title.clone(),
184 branch: branch.clone(),
185 worktree: wt_path,
186 reason: branch_state.clone(),
187 local_branch_exists: lbe,
188 branch_merged: is_merged && is_ancestor,
189 remote_branch_exists: remote_branches.contains(&branch),
190 updated_at: t.frontmatter.updated_at,
191 });
192 } else if untracked || diagnosis.other_untracked.is_empty() {
193 if !dry_run {
196 remove_untracked(path, &diagnosis.known_temp)?;
197 if untracked {
198 remove_untracked(path, &diagnosis.other_untracked)?;
199 }
200 }
201 result.push(CleanCandidate {
202 ticket_id: id,
203 ticket_title: t.frontmatter.title.clone(),
204 branch: branch.clone(),
205 worktree: wt_path,
206 reason: branch_state.clone(),
207 local_branch_exists: lbe,
208 branch_merged: is_merged && is_ancestor,
209 remote_branch_exists: remote_branches.contains(&branch),
210 updated_at: t.frontmatter.updated_at,
211 });
212 } else {
213 dirty_result.push(diagnosis);
214 }
215 } else {
216 dirty_result.push(diagnosis);
217 }
218 continue;
219 }
220 }
221
222 let local_branch_exists = git_util::local_branch_exists(root, &branch);
223
224 if wt_path.is_none() && !local_branch_exists {
225 continue;
226 }
227
228 result.push(CleanCandidate {
229 ticket_id: id,
230 ticket_title: t.frontmatter.title.clone(),
231 branch: branch.clone(),
232 worktree: wt_path,
233 reason: branch_state.clone(),
234 local_branch_exists,
235 branch_merged: is_merged && is_ancestor,
236 remote_branch_exists: remote_branches.contains(&branch),
237 updated_at: t.frontmatter.updated_at,
238 });
239 }
240
241 Ok((result, dirty_result, warnings))
242}
243
244pub fn remote_only_candidates(
253 root: &Path,
254 config: &Config,
255 skip: &std::collections::HashSet<String>,
256) -> Result<Vec<CleanCandidate>> {
257 let terminal_states = config.terminal_state_ids();
258 let default_branch = &config.project.default_branch;
259 let remote_branches = git_util::list_remote_ticket_branches(root);
260 let primary_dir = config.tickets.dir.to_string_lossy().to_string();
261 let archive_dir = config.tickets.archive_dir.as_ref().map(|p| p.to_string_lossy().to_string());
262
263 let mut result = Vec::new();
264 for branch in remote_branches {
265 if skip.contains(&branch) {
266 continue;
267 }
268 let suffix = branch.trim_start_matches("ticket/");
269 let primary_path = format!("{primary_dir}/{suffix}.md");
270 let (content, content_path) = match git::read_from_branch(root, default_branch, &primary_path) {
271 Ok(c) => (c, primary_path),
272 Err(_) => match &archive_dir {
273 Some(archive) => {
274 let archive_path = format!("{archive}/{suffix}.md");
275 match git::read_from_branch(root, default_branch, &archive_path) {
276 Ok(c) => (c, archive_path),
277 Err(_) => continue,
278 }
279 }
280 None => continue,
281 },
282 };
283 let dummy = root.join(&content_path);
284 let ticket = match ticket_fmt::Ticket::parse(&dummy, &content) {
285 Ok(t) => t,
286 Err(_) => continue,
287 };
288 if !terminal_states.contains(ticket.frontmatter.state.as_str()) {
289 continue;
290 }
291 result.push(CleanCandidate {
292 ticket_id: ticket.frontmatter.id.clone(),
293 ticket_title: ticket.frontmatter.title.clone(),
294 branch: branch.clone(),
295 worktree: None,
296 reason: ticket.frontmatter.state.clone(),
297 local_branch_exists: false,
298 branch_merged: true,
299 remote_branch_exists: true,
300 updated_at: ticket.frontmatter.updated_at,
301 });
302 }
303 Ok(result)
304}
305
306pub fn remove(root: &Path, candidate: &CleanCandidate, force: bool, remove_branches: bool) -> Result<RemoveOutput> {
307 let mut warnings: Vec<String> = Vec::new();
308
309 if let Some(ref path) = candidate.worktree {
310 if path.exists() {
311 worktree::remove_worktree(root, path, force)?;
312 } else {
313 crate::git_util::run(root, &["worktree", "prune", "--expire", "now"])?;
320 }
321 }
322
323 if remove_branches {
324 let _ = force; if candidate.local_branch_exists {
332 git_util::delete_local_branch(root, &candidate.branch, &mut warnings);
333 git_util::prune_remote_tracking(root, &candidate.branch);
336 }
337 if candidate.remote_branch_exists {
347 match git_util::delete_remote_branch(root, &candidate.branch) {
348 Ok(()) => git_util::prune_remote_tracking(root, &candidate.branch),
349 Err(e) => warnings.push(format!(
350 "warning: could not delete remote branch {}: {e}",
351 candidate.branch
352 )),
353 }
354 }
355 }
356
357 Ok(RemoveOutput { warnings })
358}
359
360pub fn parse_older_than(s: &str) -> anyhow::Result<DateTime<Utc>> {
363 if let Some(days_str) = s.strip_suffix('d') {
364 let days: i64 = days_str
365 .parse()
366 .map_err(|_| anyhow::anyhow!("--older-than: invalid days value {:?}", s))?;
367 return Ok(Utc::now() - chrono::Duration::days(days));
368 }
369 if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
370 return Ok(date.and_hms_opt(0, 0, 0).unwrap().and_utc());
371 }
372 anyhow::bail!(
373 "--older-than: unrecognised format {:?}; use \"30d\" or \"YYYY-MM-DD\"",
374 s
375 )
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
383 fn parse_older_than_days() {
384 let threshold = parse_older_than("30d").unwrap();
385 let expected = Utc::now() - chrono::Duration::days(30);
386 assert!((threshold - expected).num_seconds().abs() < 5);
388 }
389
390 #[test]
391 fn parse_older_than_iso_date() {
392 let threshold = parse_older_than("2026-01-01").unwrap();
393 assert_eq!(threshold.format("%Y-%m-%d").to_string(), "2026-01-01");
394 }
395
396 #[test]
397 fn parse_older_than_invalid_rejects() {
398 assert!(parse_older_than("notadate").is_err());
399 assert!(parse_older_than("30").is_err());
400 assert!(parse_older_than("").is_err());
401 }
402
403 #[test]
404 fn parse_older_than_zero_days() {
405 let threshold = parse_older_than("0d").unwrap();
406 let now = Utc::now();
407 assert!((threshold - now).num_seconds().abs() < 5);
408 }
409}