Skip to main content

apm_core/
clean.rs

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        // The branch state is authoritative — we already filtered to terminal
144        // states above. If the branch says the ticket is done, clean proceeds
145        // regardless of what main says (or whether the ticket exists on main).
146
147        let wt_path = worktree::find_worktree_for_branch(root, &branch);
148
149        // Check worktree cleanliness before the tip-divergence guard so that
150        // a clean worktree on a closed ticket is not blocked by stale refs.
151        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                        // Force mode: git worktree remove --force handles remaining files.
177                        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                        // Auto-remove: known_temp always; other_untracked if --untracked.
188                        // Skip actual file removal in dry-run mode.
189                        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        // Prune the remote tracking ref so sync_local_ticket_refs does not
244        // recreate the local branch on the next apm sync.
245        git_util::prune_remote_tracking(root, &candidate.branch);
246    }
247
248    Ok(RemoveOutput { warnings })
249}
250
251/// Parse an --older-than threshold into a UTC DateTime.
252/// Accepts "Nd" (N days ago) or "YYYY-MM-DD" (ISO date).
253pub 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
269/// Return remote ticket/* branches in terminal states older than `older_than`.
270pub 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        // Allow a few seconds of skew between the two Utc::now() calls.
303        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}