Skip to main content

apm/cmd/
clean.rs

1use anyhow::Result;
2use apm_core::{clean, git};
3use std::io::IsTerminal;
4use std::path::Path;
5use crate::ctx::CmdContext;
6
7pub fn run(
8    root: &Path,
9    dry_run: bool,
10    yes: bool,
11    force: bool,
12    branches: bool,
13    remote: bool,
14    older_than: Option<String>,
15    untracked: bool,
16    epics: bool,
17) -> Result<()> {
18    // Validate flag combinations.
19    if remote && older_than.is_none() {
20        anyhow::bail!("--remote requires --older-than <THRESHOLD>");
21    }
22
23    let config = CmdContext::load_config_only(root)?;
24    let (candidates, dirty, candidate_warnings) = clean::candidates(root, &config, force, untracked, dry_run)?;
25    for w in &candidate_warnings {
26        eprintln!("{w}");
27    }
28
29    if candidates.is_empty() && dirty.is_empty() && !remote && !epics {
30        println!("Nothing to clean.");
31        return Ok(());
32    }
33
34    // Warn about dirty worktrees that can't be auto-cleaned.
35    for dw in &dirty {
36        if !dw.modified_tracked.is_empty() {
37            for f in &dw.modified_tracked {
38                eprintln!("  M {}", f.display());
39            }
40            eprintln!(
41                "warning: {} has modified tracked files — manual cleanup required — skipping",
42                dw.branch
43            );
44        } else {
45            for f in &dw.other_untracked {
46                eprintln!("  ? {}", f.display());
47            }
48            eprintln!(
49                "warning: {} has untracked files — re-run with --untracked to remove — skipping",
50                dw.branch
51            );
52        }
53    }
54
55    for candidate in &candidates {
56        if dry_run {
57            if let Some(ref path) = candidate.worktree {
58                println!(
59                    "would remove worktree {} (ticket #{}, state: {})",
60                    path.display(),
61                    candidate.ticket_id,
62                    candidate.reason
63                );
64            }
65            if branches && candidate.local_branch_exists && (candidate.branch_merged || force) {
66                println!(
67                    "would remove branch {} (state: {})",
68                    candidate.branch, candidate.reason
69                );
70            } else if branches && candidate.local_branch_exists && !candidate.branch_merged {
71                println!(
72                    "would keep branch {} (not merged into main)",
73                    candidate.branch
74                );
75            }
76        } else if force {
77            eprintln!(
78                "warning: force-removing {} — branch may not be merged",
79                candidate.branch
80            );
81            if crate::util::prompt_yes_no(&format!("Force-remove {}? [y/N] ", candidate.branch))? {
82                if let Some(ref path) = candidate.worktree {
83                    println!("removed worktree {}", path.display());
84                }
85                if branches && candidate.local_branch_exists {
86                    println!("removed branch {}", candidate.branch);
87                }
88                let remove_out = clean::remove(root, candidate, true, branches)?;
89                for w in &remove_out.warnings {
90                    eprintln!("{w}");
91                }
92            } else {
93                eprintln!("skipping {}", candidate.branch);
94            }
95        } else {
96            if let Some(ref path) = candidate.worktree {
97                println!("removed worktree {}", path.display());
98            }
99            if branches && candidate.local_branch_exists && candidate.branch_merged {
100                println!("removed branch {}", candidate.branch);
101            } else if branches && candidate.local_branch_exists && !candidate.branch_merged {
102                println!("kept branch {} (not merged into main)", candidate.branch);
103            }
104            let remove_out = clean::remove(root, candidate, false, branches)?;
105            for w in &remove_out.warnings {
106                eprintln!("{w}");
107            }
108        }
109    }
110
111    // --remote --older-than path.
112    if remote {
113        let threshold_str = older_than.as_deref().unwrap();
114        let threshold = clean::parse_older_than(threshold_str)?;
115        let remote_candidates = clean::remote_candidates(root, &config, threshold)?;
116
117        if remote_candidates.is_empty() {
118            println!("No remote branches to clean.");
119        }
120
121        for rc in &remote_candidates {
122            if dry_run {
123                println!(
124                    "would delete remote branch {} (last commit: {})",
125                    rc.branch,
126                    rc.last_commit.format("%Y-%m-%d")
127                );
128                continue;
129            }
130            let should_delete = if yes {
131                true
132            } else if std::io::stdout().is_terminal() {
133                crate::util::prompt_yes_no(&format!(
134                    "Delete remote branch {} (last commit: {})? [y/N] ",
135                    rc.branch,
136                    rc.last_commit.format("%Y-%m-%d")
137                ))?
138            } else {
139                eprintln!(
140                    "skipping {} — non-interactive (use --yes to auto-confirm)",
141                    rc.branch
142                );
143                false
144            };
145            if should_delete {
146                git::delete_remote_branch(root, &rc.branch)?;
147                println!("deleted remote branch {}", rc.branch);
148            }
149        }
150    }
151
152    if epics || remote {
153        crate::cmd::epic::run_epic_clean(root, &config, dry_run, yes)?;
154    }
155
156    Ok(())
157}