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