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::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    /// Keep the worktree file(s); only stage the removal in the index.
34    /// This is the historical mkit behaviour.
35    #[arg(long)]
36    cached: bool,
37
38    /// Allow removing a directory and everything under it.
39    #[arg(short = 'r', long)]
40    recursive: bool,
41
42    /// Remove worktree files even when they differ from the staged
43    /// blob (otherwise modified files are refused to avoid data loss).
44    #[arg(short = 'f', long)]
45    force: bool,
46
47    /// Paths to remove. A directory path removes every entry at or
48    /// below it (requires `-r`).
49    #[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    // Resolve every pathspec up front and gather the set of tracked
81    // index paths each one matches. A pathspec matching more than one
82    // entry (or being itself a directory) requires `-r`.
83    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        // A pathspec that resolves to a strict descendant (i.e. it names
107        // a directory, not an exact tracked file) needs --recursive.
108        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    // Safety pass (unless --force): refuse to destroy a worktree file
122    // whose content diverges from the staged blob. Skipped for
123    // --cached, which never touches the worktree.
124    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    // Mutation pass: delete worktree files (unless --cached) then mark
135    // the index entries Removed.
136    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
160/// Return `Some(reason)` when the worktree file backing `entry` exists
161/// but differs from the staged blob — the case `git rm` refuses without
162/// `-f`. Returns `None` when the file is clean, absent, or a symlink
163/// whose hashing we treat the same as a regular blob.
164fn 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    // Compute the worktree object hash the same way `add` would.
168    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        let clean = worktree::read_regular_file_bounded(&abs)
174            .ok()
175            .and_then(|(opened, data)| {
176                if super::file_exec_status(&opened) != entry.status {
177                    return Some(false);
178                }
179                worktree::content_eq_bytes(store, &entry.object_hash, &data).ok()
180            })
181            .unwrap_or(false);
182        return if clean {
183            None
184        } else {
185            Some(format!(
186                "'{}' has local modifications or unreadable staged content; use --cached to keep it, or --force to discard them",
187                entry.path
188            ))
189        };
190    } else {
191        return None;
192    };
193    if entry.status == EntryStatus::Symlink && work_hash == entry.object_hash {
194        None
195    } else {
196        Some(format!(
197            "'{}' has local modifications; use --cached to keep it, or --force to discard them",
198            entry.path
199        ))
200    }
201}
202
203/// Hash a symlink target as a blob (matching `worktree`/`add` semantics)
204/// so the dirty-check compares like-for-like with the index entry.
205fn symlink_blob_hash(target: &str) -> Option<mkit_core::hash::Hash> {
206    // Pure content-addressing — change detection must not write to the
207    // store. Byte layout pinned to serialize() via blob_prologue.
208    let prologue = mkit_core::serialize::blob_prologue(target.len()).ok()?;
209    let mut hasher = mkit_core::hash::Hasher::new();
210    hasher.update(&prologue).update(target.as_bytes());
211    Some(hasher.finalize())
212}
213
214/// Delete every worktree file named by the matched index entries, then
215/// prune directories left empty by those deletions.
216fn remove_worktree_paths(root: &Path, idx: &Index, matches: &[usize]) -> Result<(), String> {
217    let mut dirs_to_prune: Vec<PathBuf> = Vec::new();
218    for &i in matches {
219        let rel = &idx.entries[i].path;
220        let abs = root.join(rel);
221        match std::fs::symlink_metadata(&abs) {
222            Ok(_) => {
223                std::fs::remove_file(&abs).map_err(|e| format!("remove {}: {e}", abs.display()))?;
224            }
225            // Already gone — treat as success (idempotent).
226            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
227            Err(e) => return Err(format!("remove {}: {e}", abs.display())),
228        }
229        if let Some(parent) = abs.parent() {
230            dirs_to_prune.push(parent.to_path_buf());
231        }
232    }
233    prune_empty_dirs(root, dirs_to_prune);
234    Ok(())
235}
236
237/// Remove now-empty directories, walking upward toward `root` but never
238/// removing `root` itself. Best-effort: non-empty dirs and errors stop
239/// the upward walk for that branch.
240fn prune_empty_dirs(root: &Path, mut dirs: Vec<PathBuf>) {
241    // Deepest paths first so children are pruned before parents.
242    dirs.sort_by_key(|d| std::cmp::Reverse(d.components().count()));
243    dirs.dedup();
244    for dir in dirs {
245        let mut cur = dir;
246        while cur != root && cur.starts_with(root) {
247            let is_empty = match std::fs::read_dir(&cur) {
248                Ok(mut rd) => rd.next().is_none(),
249                Err(_) => break,
250            };
251            if !is_empty || std::fs::remove_dir(&cur).is_err() {
252                break;
253            }
254            match cur.parent() {
255                Some(p) => cur = p.to_path_buf(),
256                None => break,
257            }
258        }
259    }
260}
261
262use super::error as emit_err;