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 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    /// Authoritative: is the branch present on origin right now? Computed
23    /// via a single `git ls-remote` at the start of candidate enumeration.
24    /// Used so remote deletion only attempts pushes that can succeed,
25    /// avoiding noisy "remote ref does not exist" errors.
26    pub remote_branch_exists: bool,
27    /// Ticket's `updated_at` from frontmatter; None if absent. Used by
28    /// `--older-than` to filter candidates by age.
29    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        // The branch state is authoritative — we already filtered to terminal
148        // states above. If the branch says the ticket is done, clean proceeds
149        // regardless of what main says (or whether the ticket exists on main).
150
151        let wt_path = worktree::find_worktree_for_branch(root, &branch);
152
153        // Check worktree cleanliness before the tip-divergence guard so that
154        // a clean worktree on a closed ticket is not blocked by stale refs.
155        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                        // Force mode: git worktree remove --force handles remaining files.
181                        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                        // Auto-remove: known_temp always; other_untracked if --untracked.
194                        // Skip actual file removal in dry-run mode.
195                        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
244/// Find ticket branches that exist on origin but have no local head.
245/// For each, look up the ticket file on the default branch (in
246/// `tickets/` first, then `archive_dir/`) and accept it as a candidate
247/// if its state is terminal. These are pure remote-deletion candidates:
248/// no worktree, no local branch.
249///
250/// `skip` is the set of branches already returned by `candidates()` so
251/// we don't double-process branches that have a local head.
252pub 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            // Path no longer on disk — prune dangling registry entry only.
314            // `--expire now` overrides git's default grace period (a few
315            // months) and prunes every stale entry immediately, not just
316            // this candidate's. Acceptable because APM expects to own the
317            // worktrees directory; unrelated stale entries from manual
318            // workflows will also be cleaned.
319            crate::git_util::run(root, &["worktree", "prune", "--expire", "now"])?;
320        }
321    }
322
323    if remove_branches {
324        // All candidates are already filtered to terminal states (the
325        // supervisor's verdict that the work is done), so we don't gate
326        // branch deletion on merge-into-main. The branch's commits live
327        // either in main, in an epic branch (preserved separately), or
328        // in the reflog if neither — the supervisor's `closed` decision
329        // is the authority.
330        let _ = force; // accepted for API stability; no longer gates deletion
331        if candidate.local_branch_exists {
332            git_util::delete_local_branch(root, &candidate.branch, &mut warnings);
333            // Prune the remote tracking ref so sync_local_ticket_refs does not
334            // recreate the local branch on the next apm sync.
335            git_util::prune_remote_tracking(root, &candidate.branch);
336        }
337        // Remote deletion: only attempt when origin actually has the
338        // branch (authoritative ls-remote check at candidate-collection
339        // time). Avoids noisy "remote ref does not exist" failures that
340        // happen routinely when GitHub auto-deletes branches on merge.
341        //
342        // On success, also prune the local remote-tracking ref. `git push
343        // origin --delete` updates the remote but leaves the local
344        // refs/remotes/origin/<branch> ref behind; without this prune the
345        // stale ref would keep surfacing the ticket in `apm list`.
346        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
360/// Parse an --older-than threshold into a UTC DateTime.
361/// Accepts "Nd" (N days ago) or "YYYY-MM-DD" (ISO date).
362pub 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        // Allow a few seconds of skew between the two Utc::now() calls.
387        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}