Skip to main content

mkit_cli/commands/
clean.rs

1//! `mkit clean` — remove untracked files from the worktree (like
2//! `git clean`).
3//!
4//! Safety: this is destructive, so — matching git's `clean.requireForce`
5//! default — it **refuses to delete anything** unless `-f`/`--force` is
6//! given; `-n`/`--dry-run` previews instead. Without `-d`, untracked
7//! *directories* are left alone (git semantics). Ignored files are kept
8//! unless `-x` (also remove ignored) or `-X` (remove *only* ignored).
9//!
10//! Ignore matching uses the shared path-aware matcher (`.gitignore` +
11//! `.mkitignore`, #256), so `-x`/`-X` honor anchored/`**`/multi-segment
12//! patterns and a file under an ignored directory counts as ignored.
13
14use std::io::Write;
15use std::path::{Path, PathBuf};
16
17use clap::Parser;
18use mkit_core::ignore::{self, IgnoreList};
19use mkit_core::index::Index;
20use mkit_core::store::ObjectStore;
21
22use crate::clap_shim;
23use crate::exit;
24
25#[derive(Debug, Parser)]
26#[command(
27    name = "mkit clean",
28    about = "Remove untracked files from the worktree."
29)]
30#[allow(clippy::struct_excessive_bools)] // clap option flags, not a state machine
31struct CleanOpts {
32    /// Dry run: list what would be removed without deleting anything.
33    #[arg(short = 'n', long = "dry-run")]
34    dry_run: bool,
35    /// Actually delete. Required (or `-n`) — clean refuses otherwise.
36    #[arg(short = 'f', long)]
37    force: bool,
38    /// Also remove untracked directories.
39    #[arg(short = 'd')]
40    directories: bool,
41    /// Also remove ignored files (not just untracked ones).
42    #[arg(short = 'x', conflicts_with = "only_ignored")]
43    ignored_too: bool,
44    /// Remove ONLY ignored files.
45    #[arg(short = 'X')]
46    only_ignored: bool,
47    /// Optional pathspecs limiting what is cleaned.
48    paths: Vec<String>,
49}
50
51/// One worktree entry slated for removal.
52struct Victim {
53    /// Display path (git appends `/` to directories).
54    display: String,
55    abs: PathBuf,
56    is_dir: bool,
57}
58
59#[must_use]
60pub fn run(args: &[String]) -> u8 {
61    let opts = match clap_shim::parse::<CleanOpts>("mkit clean", args) {
62        Ok(o) => o,
63        Err(code) => return code,
64    };
65    let cwd = match std::env::current_dir() {
66        Ok(p) => p,
67        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
68    };
69    let layout = match super::resolve_layout(&cwd) {
70        Ok(layout) => layout,
71        Err(code) => return code,
72    };
73    let store = match ObjectStore::open(&layout) {
74        Ok(s) => s,
75        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
76    };
77    // Safety: never delete without an explicit -f, mirroring git's
78    // `clean.requireForce`. `-n` previews without deleting.
79    if !opts.force && !opts.dry_run {
80        return emit_err(
81            "refusing to clean without -f (use -n to preview, -f to delete)",
82            exit::GENERAL_ERROR,
83        );
84    }
85    let _lock = match super::acquire_worktree_lock(&layout) {
86        Ok(l) => l,
87        Err(code) => return code,
88    };
89    let index = match super::read_or_seed_index_from_head(&layout, &store) {
90        Ok(i) => i,
91        Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
92    };
93    let ignore = match ignore::load(&cwd) {
94        Ok(i) => i,
95        Err(e) => return emit_err(&format!("read ignore file: {e}"), exit::GENERAL_ERROR),
96    };
97
98    let mut victims: Vec<Victim> = match collect_dir(&cwd, &cwd, "", false, &index, &ignore, &opts)
99    {
100        Ok((_root_fully_removable, v)) => v,
101        Err(e) => return emit_err(&format!("scan worktree: {e}"), exit::GENERAL_ERROR),
102    };
103
104    // Pathspec filter (repo-relative match-or-descend), if any. A `.` or
105    // empty pathspec means "everything under cwd" and is skipped.
106    let specs: Vec<String> = opts
107        .paths
108        .iter()
109        .map(|p| normalize_pathspec(p))
110        .filter(|s| !s.is_empty())
111        .collect();
112    let match_all = opts.paths.iter().any(|p| {
113        let n = normalize_pathspec(p);
114        n.is_empty()
115    });
116    if !specs.is_empty() && !match_all {
117        victims.retain(|v| {
118            let p = v.display.strip_suffix('/').unwrap_or(&v.display);
119            specs
120                .iter()
121                .any(|s| super::index_path_matches_or_descends(p, s))
122        });
123    }
124
125    // Deterministic, git-like ordering.
126    victims.sort_by(|a, b| a.display.cmp(&b.display));
127
128    let mut out = std::io::stdout().lock();
129    for v in &victims {
130        if opts.dry_run {
131            let _ = writeln!(out, "Would remove {}", v.display);
132            continue;
133        }
134        if let Err(e) = remove(&v.abs, v.is_dir) {
135            return emit_err(&format!("remove {}: {e}", v.display), exit::GENERAL_ERROR);
136        }
137        let _ = writeln!(out, "Removing {}", v.display);
138    }
139    exit::OK
140}
141
142/// Recursively gather removal candidates under `dir`. Returns
143/// `(fully_removable, victims)`: `fully_removable` is true when nothing
144/// inside the directory survives a clean, so a caller may collapse the
145/// whole subtree to a single `dir/` victim; otherwise `victims` are the
146/// individual removable entries within it.
147///
148/// Matches git: a **nested repository** (a subdirectory containing
149/// `.mkit`/`.git`) is left untouched — git only removes one with the
150/// double-force `-ff`, which mkit doesn't offer. **Ignored files are
151/// kept** (unless `-x`) and keep their parent directory alive. So a
152/// directory is removed wholesale only when every entry under it is itself
153/// removable.
154fn collect_dir(
155    root: &Path,
156    dir: &Path,
157    prefix: &str,
158    parent_ignored: bool,
159    index: &Index,
160    ignore: &IgnoreList,
161    opts: &CleanOpts,
162) -> std::io::Result<(bool, Vec<Victim>)> {
163    // Nested-repo protection. The repo root always has its own `.mkit`, so
164    // only guard SUBdirectories (prefix non-empty).
165    if !prefix.is_empty() && (dir.join(".mkit").exists() || dir.join(".git").exists()) {
166        return Ok((false, Vec::new()));
167    }
168    let read = match std::fs::read_dir(dir) {
169        Ok(r) => r,
170        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((true, Vec::new())),
171        Err(e) => return Err(e),
172    };
173    let mut victims: Vec<Victim> = Vec::new();
174    let mut fully_removable = true;
175    for entry in read {
176        let entry = entry?;
177        let name = entry.file_name();
178        let Some(name) = name.to_str() else {
179            fully_removable = false;
180            continue;
181        };
182        if name.eq_ignore_ascii_case(".mkit") || name.eq_ignore_ascii_case(".git") {
183            fully_removable = false; // repo metadata stays
184            continue;
185        }
186        let path = if prefix.is_empty() {
187            name.to_string()
188        } else {
189            format!("{prefix}/{name}")
190        };
191        let abs = root.join(&path);
192        // A symlink is treated as a file (never followed/recursed).
193        let is_dir = std::fs::symlink_metadata(&abs)?.is_dir();
194        // A path under an ignored directory is ignored too (git "can't
195        // re-include under an excluded dir"); OR in the inherited bit. This
196        // must be computed BEFORE the tracked check so a tracked-but-ignored
197        // directory (e.g. node_modules/ with a tracked file inside) still
198        // propagates the ignored bit to its untracked descendants.
199        let ignored = parent_ignored || ignore.is_ignored(&path, is_dir);
200
201        // A directory shadowing a path tracked as a *file* is not untracked
202        // content: git reports only the tracked-side deletion and suppresses
203        // the directory's contents (#288). Skip the whole subtree — this must
204        // precede the `index_tracks_path_or_descendant` branch below, which
205        // would otherwise treat `f` as a tracked-descendant and descend into
206        // `f/`, deleting `f/child`. The dir stays (shadows a tracked path), so
207        // clear `fully_removable`.
208        if is_dir && index.has_tracked_file_at(&path) {
209            fully_removable = false;
210            continue;
211        }
212
213        if super::index_tracks_path_or_descendant(index, &path) {
214            // Tracked content keeps the dir alive; descend into a tracked
215            // directory to clean any untracked files inside it, carrying the
216            // ignored bit so ignored untracked descendants are kept.
217            fully_removable = false;
218            if is_dir {
219                let (_full, sub) = collect_dir(root, &abs, &path, ignored, index, ignore, opts)?;
220                victims.extend(sub);
221            }
222            continue;
223        }
224
225        // Untracked. `-X` keeps only ignored entries; otherwise keep
226        // non-ignored entries and ignored ones only with `-x`.
227        let include = if opts.only_ignored {
228            ignored
229        } else {
230            !ignored || opts.ignored_too
231        };
232
233        if is_dir {
234            if !opts.directories {
235                fully_removable = false; // untracked dirs need -d
236                continue;
237            }
238            let (sub_full, sub) = collect_dir(root, &abs, &path, ignored, index, ignore, opts)?;
239            if sub_full && include {
240                // The whole subtree is removable → one `dir/` victim.
241                victims.push(Victim {
242                    display: format!("{path}/"),
243                    abs,
244                    is_dir: true,
245                });
246            } else {
247                // Some entries survive (ignored / nested repo) → keep the
248                // directory, remove only its removable contents.
249                fully_removable = false;
250                victims.extend(sub);
251            }
252        } else if include {
253            victims.push(Victim {
254                display: path,
255                abs,
256                is_dir: false,
257            });
258        } else {
259            fully_removable = false; // kept (ignored) file → dir survives
260        }
261    }
262    Ok((fully_removable, victims))
263}
264
265fn remove(abs: &Path, is_dir: bool) -> std::io::Result<()> {
266    if is_dir {
267        std::fs::remove_dir_all(abs)
268    } else {
269        std::fs::remove_file(abs)
270    }
271}
272
273/// Normalize a pathspec to the index path form: strip a leading `./`,
274/// collapse `\\` to `/`, drop a trailing `/`. The cwd itself (`.` or `./`)
275/// normalizes to the empty string, meaning "everything under cwd".
276fn normalize_pathspec(spec: &str) -> String {
277    let s = spec.replace('\\', "/");
278    let s = s.strip_prefix("./").unwrap_or(&s);
279    let s = s.strip_suffix('/').unwrap_or(s);
280    if s == "." {
281        String::new()
282    } else {
283        s.to_string()
284    }
285}
286
287use super::error as emit_err;