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    // Check both clean candidates and dirty candidates (dirty worktrees are skipped later,
45    // but we must still refuse if the caller is inside one of them).
46    let cwd = std::env::current_dir().unwrap_or_default();
47    let canonical_cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.clone());
48    for candidate in &candidates {
49        if let Some(ref wt_path) = candidate.worktree {
50            let canonical_wt = wt_path.canonicalize().unwrap_or_else(|_| wt_path.clone());
51            if canonical_cwd.starts_with(&canonical_wt) {
52                eprintln!(
53                    "refusing to remove worktree containing the current working directory: {}",
54                    wt_path.display()
55                );
56                anyhow::bail!(
57                    "refusing to remove worktree containing the current working directory: {}",
58                    wt_path.display()
59                );
60            }
61        }
62    }
63    for dw in &dirty {
64        let canonical_wt = dw.path.canonicalize().unwrap_or_else(|_| dw.path.clone());
65        if canonical_cwd.starts_with(&canonical_wt) {
66            eprintln!(
67                "refusing to remove worktree containing the current working directory: {}",
68                dw.path.display()
69            );
70            anyhow::bail!(
71                "refusing to remove worktree containing the current working directory: {}",
72                dw.path.display()
73            );
74        }
75    }
76
77    if candidates.is_empty() && dirty.is_empty() && !epics {
78        println!("Nothing to clean.");
79        return Ok(());
80    }
81
82    // Warn about dirty worktrees that can't be auto-cleaned.
83    for dw in &dirty {
84        if !dw.modified_tracked.is_empty() {
85            for f in &dw.modified_tracked {
86                eprintln!("  M {}", f.display());
87            }
88            eprintln!(
89                "warning: {} has modified tracked files — manual cleanup required — skipping",
90                dw.branch
91            );
92        } else {
93            for f in &dw.other_untracked {
94                eprintln!("  ? {}", f.display());
95            }
96            eprintln!(
97                "warning: {} has untracked files — re-run with --untracked to remove — skipping",
98                dw.branch
99            );
100        }
101    }
102
103    for candidate in &candidates {
104        let scope = match (candidate.local_branch_exists, candidate.remote_branch_exists) {
105            (true, true) => "local + remote",
106            (true, false) => "local",
107            (false, true) => "remote",
108            (false, false) => "registry only",
109        };
110        if dry_run {
111            if let Some(ref path) = candidate.worktree {
112                println!(
113                    "would remove worktree {} (ticket #{}, state: {})",
114                    path.display(),
115                    candidate.ticket_id,
116                    candidate.reason
117                );
118            }
119            if branches {
120                println!(
121                    "would remove branch {} ({}, state: {})",
122                    candidate.branch, scope, candidate.reason
123                );
124            }
125        } else if force {
126            if crate::util::prompt_yes_no(&format!("Remove {}? [y/N] ", candidate.branch))? {
127                if let Some(ref path) = candidate.worktree {
128                    println!("removed worktree {}", path.display());
129                }
130                if branches {
131                    println!("removed branch {} ({})", candidate.branch, scope);
132                }
133                let remove_out = clean::remove(root, candidate, true, branches)?;
134                for w in &remove_out.warnings {
135                    eprintln!("{w}");
136                }
137            } else {
138                eprintln!("skipping {}", candidate.branch);
139            }
140        } else {
141            if let Some(ref path) = candidate.worktree {
142                println!("removed worktree {}", path.display());
143            }
144            if branches {
145                println!("removed branch {} ({})", candidate.branch, scope);
146            }
147            let remove_out = clean::remove(root, candidate, false, branches)?;
148            for w in &remove_out.warnings {
149                eprintln!("{w}");
150            }
151        }
152    }
153
154    if epics {
155        crate::cmd::epic::run_epic_clean(root, &config, dry_run, _yes)?;
156    }
157
158    Ok(())
159}