Skip to main content

apm/cmd/
clean.rs

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    // When --branches, also enumerate remote-only ticket branches (origin
24    // has them, no local head) whose ticket on the default branch is in a
25    // terminal state. These are common after an earlier clean removed
26    // local branches but the remote was still up.
27    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    // Apply --older-than filter (if set) by ticket frontmatter updated_at.
34    // Tickets with no updated_at are conservatively kept (we can't verify age).
35    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    // Refuse to remove any worktree that contains the current working directory.
44    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    // Warn about dirty worktrees that can't be auto-cleaned.
68    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}