Skip to main content

mkit_cli/commands/
mv.rs

1//! `mkit mv <source>... <dest>` — move or rename tracked paths, staging
2//! the change (like `git mv`).
3//!
4//! Forms:
5//! - `mv <src> <dst>` — rename `src` to `dst`, or move it into `dst` when
6//!   `dst` is an existing directory.
7//! - `mv <src>... <dir>` — move every source into the existing directory
8//!   `<dir>`.
9//!
10//! For each source the worktree file is moved and the index updated: the
11//! source path is staged as removed and the destination staged with the
12//! source's blob (content is unchanged, so the existing object is reused)
13//! and mode. All sources are **validated up front**; nothing on disk or in
14//! the index is touched until every move is known to be legal, so a bad
15//! source in a batch can't leave the worktree half-moved.
16//!
17//! mkit has no rename detection, so `mkit status` reports the move as a
18//! deletion plus an addition rather than git's `R` — a documented
19//! divergence; the staged result (`mkit commit`) is equivalent.
20//!
21//! Scope: moves a single tracked file per source. Moving a tracked
22//! **directory** (`mv dir newdir`) is not yet supported and is refused
23//! with a clear error (follow-up).
24//!
25//! Safety divergences:
26//! - refuses to overwrite an existing destination without `-f` (matching
27//!   git's `mv` clobber guard), and detects a dangling symlink at the
28//!   destination as "exists" (git refuses that too);
29//! - refuses a destination that escapes the repository through a
30//!   symlinked parent directory (git would silently follow it) — mkit
31//!   keeps writes inside the repo.
32
33use std::io::Write;
34use std::path::{Path, PathBuf};
35
36use clap::Parser;
37use mkit_core::hash::{Hash, ZERO};
38use mkit_core::index::{self, EntryStatus, IndexEntry};
39use mkit_core::store::ObjectStore;
40
41use crate::clap_shim;
42use crate::exit;
43
44#[derive(Debug, Parser)]
45#[command(
46    name = "mkit mv",
47    about = "Move or rename tracked paths, staging the change."
48)]
49struct MvOpts {
50    /// Overwrite the destination if it already exists.
51    #[arg(short = 'f', long)]
52    force: bool,
53    /// `<source>... <dest>`. With more than one source, `<dest>` must be
54    /// an existing directory.
55    #[arg(num_args = 2.., required = true)]
56    paths: Vec<String>,
57}
58
59/// A validated, ready-to-execute single-file move.
60struct PlannedMove {
61    /// Index slot of the source entry (still valid through execution: we
62    /// only flip statuses and append, never remove from the vec).
63    src_idx: usize,
64    src_rel: String,
65    src_abs: PathBuf,
66    target_rel: String,
67    target_abs: PathBuf,
68    status: EntryStatus,
69    hash: Hash,
70}
71
72#[must_use]
73pub fn run(args: &[String]) -> u8 {
74    let opts = match clap_shim::parse::<MvOpts>("mkit mv", args) {
75        Ok(o) => o,
76        Err(code) => return code,
77    };
78    let cwd = match std::env::current_dir() {
79        Ok(p) => p,
80        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
81    };
82    let store = match ObjectStore::open(&cwd) {
83        Ok(s) => s,
84        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
85    };
86    let _lock = match super::acquire_worktree_lock(&cwd) {
87        Ok(l) => l,
88        Err(code) => return code,
89    };
90    // Seed from HEAD when the index is absent/empty, like `rm` and
91    // `status`, so a HEAD-tracked source is recognized as version-controlled.
92    let mut idx = match super::read_or_seed_index_from_head(&cwd, &store) {
93        Ok(i) => i,
94        Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
95    };
96    let root_canon = match cwd.canonicalize() {
97        Ok(p) => p,
98        Err(e) => return emit_err(&format!("repo root: {e}"), exit::GENERAL_ERROR),
99    };
100
101    // Split `<source>... <dest>` (clap guarantees >= 2 args).
102    let Some((dest_raw, sources)) = opts.paths.split_last() else {
103        return super::usage_error("usage: mkit mv <source>... <dest>");
104    };
105    if sources.is_empty() {
106        return super::usage_error("usage: mkit mv <source>... <dest>");
107    }
108
109    let dest_rel = match super::index_path_for_arg(&cwd, Path::new(dest_raw)) {
110        Ok(p) => p,
111        Err(e) => return emit_err(&e, exit::USAGE),
112    };
113    let dest_abs = cwd.join(&dest_rel);
114    // Multiple sources require an existing destination directory; a single
115    // source moves into the destination when it is an existing directory,
116    // otherwise it is a plain rename.
117    if sources.len() > 1 && !dest_abs.is_dir() {
118        return emit_err(
119            &format!("destination directory does not exist: {dest_raw}"),
120            exit::USAGE,
121        );
122    }
123    let into_dir = sources.len() > 1 || dest_abs.is_dir();
124
125    // Phase 1 — validate and plan every move before touching anything.
126    let mut plan: Vec<PlannedMove> = Vec::new();
127    for source in sources {
128        match plan_move(
129            &cwd,
130            &root_canon,
131            &idx,
132            source,
133            &dest_rel,
134            into_dir,
135            opts.force,
136        ) {
137            Ok(m) => plan.push(m),
138            Err(code) => return code,
139        }
140    }
141    // Reject a batch that would move two sources onto the same path.
142    for i in 0..plan.len() {
143        for j in (i + 1)..plan.len() {
144            if plan[i].target_rel == plan[j].target_rel {
145                return emit_err(
146                    &format!(
147                        "multiple sources map to the same destination: {}",
148                        plan[i].target_rel
149                    ),
150                    exit::USAGE,
151                );
152            }
153        }
154    }
155
156    // Phase 2 — execute. On a filesystem error mid-batch, persist the
157    // index for the moves already done so it stays consistent with disk.
158    for (done, m) in plan.iter().enumerate() {
159        if let Err(code) = execute_move(m, opts.force) {
160            if done > 0 {
161                let _ = index::write_index(&cwd, &idx);
162            }
163            return code;
164        }
165        apply_to_index(&mut idx, m);
166    }
167
168    match index::write_index(&cwd, &idx) {
169        Ok(()) => exit::OK,
170        Err(e) => emit_err(&format!("write index: {e}"), exit::GENERAL_ERROR),
171    }
172}
173
174/// Validate one `source` and return the planned move, or the exit code to
175/// propagate. Performs no filesystem or index mutation.
176fn plan_move(
177    cwd: &Path,
178    root_canon: &Path,
179    idx: &index::Index,
180    source: &str,
181    dest_rel: &str,
182    into_dir: bool,
183    force: bool,
184) -> Result<PlannedMove, u8> {
185    let src_rel =
186        super::index_path_for_arg(cwd, Path::new(source)).map_err(|e| emit_err(&e, exit::USAGE))?;
187
188    // The source must be a tracked, not-yet-removed index entry.
189    let src_idx = idx
190        .entries
191        .iter()
192        .position(|e| e.path == src_rel && e.status != EntryStatus::Removed)
193        .ok_or_else(|| {
194            // Distinguish "tracked directory" (unsupported) from "untracked".
195            let dir_prefix = format!("{src_rel}/");
196            let is_tracked_dir = idx
197                .entries
198                .iter()
199                .any(|e| e.status != EntryStatus::Removed && e.path.starts_with(&dir_prefix));
200            if is_tracked_dir {
201                emit_err(
202                    &format!("moving directories is not yet supported: {source}"),
203                    exit::GENERAL_ERROR,
204                )
205            } else {
206                emit_err(
207                    &format!("not under version control: {source}"),
208                    exit::GENERAL_ERROR,
209                )
210            }
211        })?;
212    let status = idx.entries[src_idx].status;
213    let hash = idx.entries[src_idx].object_hash;
214
215    let target_rel = if into_dir {
216        let base = src_rel.rsplit('/').next().unwrap_or(&src_rel);
217        format!("{dest_rel}/{base}")
218    } else {
219        dest_rel.to_string()
220    };
221    if target_rel == src_rel {
222        return Err(emit_err(
223            &format!("source and destination are the same: {source}"),
224            exit::USAGE,
225        ));
226    }
227
228    let src_abs = cwd.join(&src_rel);
229    let target_abs = cwd.join(&target_rel);
230
231    if !path_present(&src_abs) {
232        return Err(emit_err(
233            &format!("bad source: {source}"),
234            exit::GENERAL_ERROR,
235        ));
236    }
237    // Safety: keep writes inside the repo — refuse a destination whose real
238    // parent (resolving symlinks) escapes the repository root.
239    if !target_within_repo(root_canon, &target_abs) {
240        return Err(emit_err(
241            &format!("destination escapes the repository: {target_rel}"),
242            exit::GENERAL_ERROR,
243        ));
244    }
245    // Safety: never clobber an existing destination without -f. Use a
246    // symlink-aware check so a dangling symlink still counts as "exists".
247    if path_present(&target_abs) && !force {
248        return Err(emit_err(
249            &format!("destination exists (use -f to overwrite): {target_rel}"),
250            exit::GENERAL_ERROR,
251        ));
252    }
253
254    Ok(PlannedMove {
255        src_idx,
256        src_rel,
257        src_abs,
258        target_rel,
259        target_abs,
260        status,
261        hash,
262    })
263}
264
265/// Move the worktree file for one planned move. Creates parent dirs and,
266/// under `-f`, removes an existing destination first so the rename is
267/// cross-platform.
268fn execute_move(m: &PlannedMove, force: bool) -> Result<(), u8> {
269    if let Some(parent) = m.target_abs.parent() {
270        std::fs::create_dir_all(parent).map_err(|e| {
271            emit_err(
272                &format!("create {}: {e}", parent.display()),
273                exit::CANTCREAT,
274            )
275        })?;
276    }
277    if force && path_present(&m.target_abs) {
278        let _ = remove_path(&m.target_abs);
279    }
280    std::fs::rename(&m.src_abs, &m.target_abs).map_err(|e| {
281        emit_err(
282            &format!("move {} -> {}: {e}", m.src_rel, m.target_rel),
283            exit::GENERAL_ERROR,
284        )
285    })
286}
287
288/// Apply a completed move to the index: source removed, destination added
289/// with the source's blob (content unchanged → object reused) and mode.
290fn apply_to_index(idx: &mut index::Index, m: &PlannedMove) {
291    idx.entries[m.src_idx].status = EntryStatus::Removed;
292    idx.entries[m.src_idx].object_hash = ZERO;
293    match idx.entries.iter().position(|e| e.path == m.target_rel) {
294        Some(j) => {
295            idx.entries[j].status = m.status;
296            idx.entries[j].object_hash = m.hash;
297        }
298        None => idx.entries.push(IndexEntry {
299            path: m.target_rel.clone(),
300            status: m.status,
301            object_hash: m.hash,
302            mtime_ns: 0,
303            size: 0,
304            ino: 0,
305            ctime_ns: 0,
306        }),
307    }
308}
309
310/// Symlink-aware existence: true even for a dangling symlink (unlike
311/// [`Path::exists`], which follows the link and reports `false`).
312fn path_present(p: &Path) -> bool {
313    p.symlink_metadata().is_ok()
314}
315
316/// Remove a file or symlink at `p` (used to clear a destination under -f).
317fn remove_path(p: &Path) -> std::io::Result<()> {
318    match p.symlink_metadata() {
319        Ok(meta) if meta.is_dir() => std::fs::remove_dir_all(p),
320        _ => std::fs::remove_file(p),
321    }
322}
323
324/// Does `target_abs` stay within the repo once symlinks are resolved? We
325/// canonicalize its nearest existing ancestor (the leaf may not exist yet)
326/// and require it to live under the canonical repo root, so a symlinked
327/// parent pointing outside the repo is rejected.
328fn target_within_repo(root_canon: &Path, target_abs: &Path) -> bool {
329    let mut ancestor = target_abs.parent();
330    while let Some(a) = ancestor {
331        match a.canonicalize() {
332            Ok(real) => return real.starts_with(root_canon),
333            Err(_) => ancestor = a.parent(),
334        }
335    }
336    false
337}
338
339fn emit_err(msg: &str, code: u8) -> u8 {
340    let mut stderr = std::io::stderr().lock();
341    let _ = writeln!(stderr, "error: {msg}");
342    code
343}