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