1use 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)] struct CleanOpts {
32 #[arg(short = 'n', long = "dry-run")]
34 dry_run: bool,
35 #[arg(short = 'f', long)]
37 force: bool,
38 #[arg(short = 'd')]
40 directories: bool,
41 #[arg(short = 'x', conflicts_with = "only_ignored")]
43 ignored_too: bool,
44 #[arg(short = 'X')]
46 only_ignored: bool,
47 paths: Vec<String>,
49}
50
51struct Victim {
53 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 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 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 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
142fn 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 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; 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 let is_dir = std::fs::symlink_metadata(&abs)?.is_dir();
194 let ignored = parent_ignored || ignore.is_ignored(&path, is_dir);
200
201 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 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 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; continue;
237 }
238 let (sub_full, sub) = collect_dir(root, &abs, &path, ignored, index, ignore, opts)?;
239 if sub_full && include {
240 victims.push(Victim {
242 display: format!("{path}/"),
243 abs,
244 is_dir: true,
245 });
246 } else {
247 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; }
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
273fn 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;