1use std::path::{Path, PathBuf};
17
18use clap::Parser;
19use mkit_core::hash::ZERO;
20use mkit_core::index::{self, EntryStatus, Index, IndexEntry};
21use mkit_core::store::ObjectStore;
22use mkit_core::worktree;
23
24use crate::clap_shim;
25use crate::exit;
26
27#[derive(Debug, Parser)]
28#[command(
29 name = "mkit rm",
30 about = "Remove paths from the worktree and stage their deletion."
31)]
32struct RmOpts {
33 #[arg(long)]
36 cached: bool,
37
38 #[arg(short = 'r', long)]
40 recursive: bool,
41
42 #[arg(short = 'f', long)]
45 force: bool,
46
47 #[arg(required = true)]
50 paths: Vec<String>,
51}
52
53#[must_use]
54pub fn run(args: &[String]) -> u8 {
55 let opts = match clap_shim::parse::<RmOpts>("mkit rm", args) {
56 Ok(o) => o,
57 Err(code) => return code,
58 };
59 let cwd = match std::env::current_dir() {
60 Ok(p) => p,
61 Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
62 };
63 let layout = match super::resolve_layout(&cwd) {
64 Ok(layout) => layout,
65 Err(code) => return code,
66 };
67 let store = match ObjectStore::open(&layout) {
68 Ok(s) => s,
69 Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
70 };
71 let _lock = match super::acquire_worktree_lock(&layout) {
72 Ok(l) => l,
73 Err(code) => return code,
74 };
75 let mut idx = match super::read_or_seed_index_from_head(&layout, &store) {
76 Ok(i) => i,
77 Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
78 };
79
80 let mut targets: Vec<(String, Vec<usize>)> = Vec::new();
84 for raw in &opts.paths {
85 let rel = match super::index_path_for_arg(&cwd, Path::new(raw)) {
86 Ok(p) => p,
87 Err(e) => return emit_err(&e, exit::DATAERR),
88 };
89 let matches: Vec<usize> = idx
90 .entries
91 .iter()
92 .enumerate()
93 .filter(|(_, e)| {
94 e.status != EntryStatus::Removed
95 && super::index_path_matches_or_descends(&e.path, &rel)
96 })
97 .map(|(i, _)| i)
98 .collect();
99
100 if matches.is_empty() {
101 return emit_err(
102 &format!("pathspec '{raw}' did not match any tracked files"),
103 exit::GENERAL_ERROR,
104 );
105 }
106 let names_dir = !idx
109 .entries
110 .iter()
111 .any(|e| e.status != EntryStatus::Removed && e.path == rel);
112 if names_dir && !opts.recursive {
113 return emit_err(
114 &format!("not removing '{raw}' recursively without -r"),
115 exit::GENERAL_ERROR,
116 );
117 }
118 targets.push((rel, matches));
119 }
120
121 if !opts.force && !opts.cached {
125 for (_, matches) in &targets {
126 for &i in matches {
127 if let Some(reason) = dirty_reason(&cwd, &store, &idx.entries[i]) {
128 return emit_err(&reason, exit::GENERAL_ERROR);
129 }
130 }
131 }
132 }
133
134 let mut all_matches: Vec<usize> = targets
137 .iter()
138 .flat_map(|(_, m)| m.iter().copied())
139 .collect();
140 all_matches.sort_unstable();
141 all_matches.dedup();
142
143 if !opts.cached
144 && let Err(e) = remove_worktree_paths(&cwd, &idx, &all_matches)
145 {
146 return emit_err(&e, exit::GENERAL_ERROR);
147 }
148
149 for &i in &all_matches {
150 idx.entries[i].status = EntryStatus::Removed;
151 idx.entries[i].object_hash = ZERO;
152 }
153
154 match index::write_index(&layout, &idx) {
155 Ok(()) => exit::OK,
156 Err(e) => emit_err(&format!("write index: {e}"), exit::CANTCREAT),
157 }
158}
159
160fn dirty_reason(root: &Path, _store: &ObjectStore, entry: &IndexEntry) -> Option<String> {
165 let abs = root.join(&entry.path);
166 let meta = abs.symlink_metadata().ok()?;
167 let work_hash = if meta.file_type().is_symlink() {
169 let target = std::fs::read_link(&abs).ok()?;
170 let target_str = target.to_str()?;
171 symlink_blob_hash(target_str)?
172 } else if meta.file_type().is_file() {
173 worktree::read_regular_file_bounded(&abs)
174 .ok()
175 .and_then(|(_, data)| worktree::hash_file_object(&data).ok())?
176 } else {
177 return None;
178 };
179 if work_hash == entry.object_hash {
180 None
181 } else {
182 Some(format!(
183 "'{}' has local modifications; use --cached to keep it, or --force to discard them",
184 entry.path
185 ))
186 }
187}
188
189fn symlink_blob_hash(target: &str) -> Option<mkit_core::hash::Hash> {
192 let prologue = mkit_core::serialize::blob_prologue(target.len()).ok()?;
195 let mut hasher = mkit_core::hash::Hasher::new();
196 hasher.update(&prologue).update(target.as_bytes());
197 Some(hasher.finalize())
198}
199
200fn remove_worktree_paths(root: &Path, idx: &Index, matches: &[usize]) -> Result<(), String> {
203 let mut dirs_to_prune: Vec<PathBuf> = Vec::new();
204 for &i in matches {
205 let rel = &idx.entries[i].path;
206 let abs = root.join(rel);
207 match std::fs::symlink_metadata(&abs) {
208 Ok(_) => {
209 std::fs::remove_file(&abs).map_err(|e| format!("remove {}: {e}", abs.display()))?;
210 }
211 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
213 Err(e) => return Err(format!("remove {}: {e}", abs.display())),
214 }
215 if let Some(parent) = abs.parent() {
216 dirs_to_prune.push(parent.to_path_buf());
217 }
218 }
219 prune_empty_dirs(root, dirs_to_prune);
220 Ok(())
221}
222
223fn prune_empty_dirs(root: &Path, mut dirs: Vec<PathBuf>) {
227 dirs.sort_by_key(|d| std::cmp::Reverse(d.components().count()));
229 dirs.dedup();
230 for dir in dirs {
231 let mut cur = dir;
232 while cur != root && cur.starts_with(root) {
233 let is_empty = match std::fs::read_dir(&cur) {
234 Ok(mut rd) => rd.next().is_none(),
235 Err(_) => break,
236 };
237 if !is_empty || std::fs::remove_dir(&cur).is_err() {
238 break;
239 }
240 match cur.parent() {
241 Some(p) => cur = p.to_path_buf(),
242 None => break,
243 }
244 }
245 }
246}
247
248use super::error as emit_err;