Skip to main content

mkit_cli/commands/
rm.rs

1//! `mkit rm <pathspec>...` — remove paths from the worktree and stage
2//! the deletion for the next commit.
3//!
4//! Mirrors `git rm`:
5//!
6//! - default — stage the deletion AND delete the worktree file(s);
7//! - `--cached` — stage the deletion only, leaving the worktree intact;
8//! - `-r/--recursive` — required to remove a directory's entries;
9//! - `-f/--force` — override the safety guard that refuses to destroy a
10//!   tracked file whose worktree content differs from the staged blob.
11//!
12//! Multiple pathspecs may be given. The safety guard reuses the same
13//! "don't clobber user work" spirit as the #176 restore guards: a
14//! tracked-but-modified file is not deleted without `--force`.
15
16use 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    /// Keep the worktree file(s); only stage the removal in the index.
35    /// This is the historical mkit behaviour.
36    #[arg(long)]
37    cached: bool,
38
39    /// Allow removing a directory and everything under it.
40    #[arg(short = 'r', long)]
41    recursive: bool,
42
43    /// Remove worktree files even when they differ from the staged
44    /// blob (otherwise modified files are refused to avoid data loss).
45    #[arg(short = 'f', long)]
46    force: bool,
47
48    /// Paths to remove. A directory path removes every entry at or
49    /// below it (requires `-r`).
50    #[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    // Resolve every pathspec up front and gather the set of tracked
78    // index paths each one matches. A pathspec matching more than one
79    // entry (or being itself a directory) requires `-r`.
80    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        // A pathspec that resolves to a strict descendant (i.e. it names
104        // a directory, not an exact tracked file) needs --recursive.
105        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    // Safety pass (unless --force): refuse to destroy a worktree file
119    // whose content diverges from the staged blob. Skipped for
120    // --cached, which never touches the worktree.
121    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    // Mutation pass: delete worktree files (unless --cached) then mark
132    // the index entries Removed.
133    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
157/// Return `Some(reason)` when the worktree file backing `entry` exists
158/// but differs from the staged blob — the case `git rm` refuses without
159/// `-f`. Returns `None` when the file is clean, absent, or a symlink
160/// whose hashing we treat the same as a regular blob.
161fn 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    // Compute the worktree object hash the same way `add` would.
165    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
186/// Hash a symlink target as a blob (matching `worktree`/`add` semantics)
187/// so the dirty-check compares like-for-like with the index entry.
188fn symlink_blob_hash(target: &str) -> Option<mkit_core::hash::Hash> {
189    // Pure content-addressing — change detection must not write to the
190    // store. Byte layout pinned to serialize() via blob_prologue.
191    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
197/// Delete every worktree file named by the matched index entries, then
198/// prune directories left empty by those deletions.
199fn 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            // Already gone — treat as success (idempotent).
209            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
220/// Remove now-empty directories, walking upward toward `root` but never
221/// removing `root` itself. Best-effort: non-empty dirs and errors stop
222/// the upward walk for that branch.
223fn prune_empty_dirs(root: &Path, mut dirs: Vec<PathBuf>) {
224    // Deepest paths first so children are pruned before parents.
225    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}