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//! Because mkit is content-addressed, the moved blob keeps the same object
18//! id at its new path, so `mkit status` / `mkit diff` detect the move as an
19//! exact rename and report git's `R` (`renamed: old -> new`) by default —
20//! no similarity heuristic needed. `--no-renames` opts back into the
21//! delete-plus-add view.
22//!
23//! Directory sources (`mv dir newdir`, or `mv dir existing-dir/`) are also
24//! supported: the directory is renamed on disk in one filesystem operation
25//! — so untracked files inside it come along, exactly like `git mv` — and
26//! every tracked file beneath it is restaged at its new path (each then
27//! surfaces as an exact rename, per the note above). The same up-front
28//! validation, clobber guard (`-f`), and repo-escape guard apply, plus a
29//! refusal to move a directory into itself.
30//!
31//! Safety divergences:
32//! - refuses to overwrite an existing destination without `-f` (matching
33//!   git's `mv` clobber guard), and detects a dangling symlink at the
34//!   destination as "exists" (git refuses that too);
35//! - refuses a destination that escapes the repository through a
36//!   symlinked parent directory (git would silently follow it) — mkit
37//!   keeps writes inside the repo.
38
39use std::path::{Path, PathBuf};
40
41use clap::Parser;
42use mkit_core::hash::{Hash, ZERO};
43use mkit_core::index::{self, EntryStatus, IndexEntry};
44use mkit_core::store::ObjectStore;
45
46use crate::clap_shim;
47use crate::exit;
48
49#[derive(Debug, Parser)]
50#[command(
51    name = "mkit mv",
52    about = "Move or rename tracked paths, staging the change."
53)]
54struct MvOpts {
55    /// Overwrite the destination if it already exists.
56    #[arg(short = 'f', long)]
57    force: bool,
58    /// `<source>... <dest>`. With more than one source, `<dest>` must be
59    /// an existing directory.
60    #[arg(num_args = 2.., required = true)]
61    paths: Vec<String>,
62}
63
64/// A validated, ready-to-execute single-file move.
65struct PlannedMove {
66    /// Index slot of the source entry (still valid through execution: we
67    /// only flip statuses and append, never remove from the vec).
68    src_idx: usize,
69    src_rel: String,
70    src_abs: PathBuf,
71    target_rel: String,
72    target_abs: PathBuf,
73    status: EntryStatus,
74    hash: Hash,
75}
76
77/// One tracked file carried by a directory move: its current index path and
78/// the path it lands at, with the blob/mode reused (content is unchanged).
79struct DirFileMove {
80    src_rel: String,
81    target_rel: String,
82    status: EntryStatus,
83    hash: Hash,
84}
85
86/// A validated, ready-to-execute directory move. The worktree directory is
87/// renamed in one `fs::rename` (so untracked files travel with it), and each
88/// tracked file beneath it is restaged via [`DirFileMove`].
89struct PlannedDirMove {
90    src_dir_rel: String,
91    src_dir_abs: PathBuf,
92    dest_dir_rel: String,
93    dest_dir_abs: PathBuf,
94    files: Vec<DirFileMove>,
95}
96
97/// A planned move of either kind. Single-file moves keep their original
98/// path exactly; directory moves are the new case.
99enum Planned {
100    File(PlannedMove),
101    Dir(PlannedDirMove),
102}
103
104impl Planned {
105    /// Every final destination path this move stages, for the
106    /// collision check across the whole batch.
107    fn target_paths(&self) -> Vec<&str> {
108        match self {
109            Self::File(m) => vec![m.target_rel.as_str()],
110            Self::Dir(m) => m.files.iter().map(|f| f.target_rel.as_str()).collect(),
111        }
112    }
113
114    /// The directory landing ROOT for a directory move (`dst/dir`), or `None`
115    /// for a file move. `target_paths` only exposes per-file landings, so two
116    /// directory moves onto the SAME root are invisible there — they're caught
117    /// by a dedicated pre-flight using this.
118    fn dest_dir_root(&self) -> Option<&str> {
119        match self {
120            Self::Dir(m) => Some(m.dest_dir_rel.as_str()),
121            Self::File(_) => None,
122        }
123    }
124
125    /// The repo-relative source path this move consumes.
126    fn source_rel(&self) -> &str {
127        match self {
128            Self::File(m) => &m.src_rel,
129            Self::Dir(m) => &m.src_dir_rel,
130        }
131    }
132}
133
134#[must_use]
135#[allow(clippy::too_many_lines)]
136pub fn run(args: &[String]) -> u8 {
137    let opts = match clap_shim::parse::<MvOpts>("mkit mv", args) {
138        Ok(o) => o,
139        Err(code) => return code,
140    };
141    let cwd = match std::env::current_dir() {
142        Ok(p) => p,
143        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
144    };
145    let layout = match super::resolve_layout(&cwd) {
146        Ok(layout) => layout,
147        Err(code) => return code,
148    };
149    let store = match ObjectStore::open(&layout) {
150        Ok(s) => s,
151        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
152    };
153    let _lock = match super::acquire_worktree_lock(&layout) {
154        Ok(l) => l,
155        Err(code) => return code,
156    };
157    // Seed from HEAD when the index is absent/empty, like `rm` and
158    // `status`, so a HEAD-tracked source is recognized as version-controlled.
159    let mut idx = match super::read_or_seed_index_from_head(&layout, &store) {
160        Ok(i) => i,
161        Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
162    };
163    let root_canon = match cwd.canonicalize() {
164        Ok(p) => p,
165        Err(e) => return emit_err(&format!("repo root: {e}"), exit::GENERAL_ERROR),
166    };
167
168    // Split `<source>... <dest>` (clap guarantees >= 2 args).
169    let Some((dest_raw, sources)) = opts.paths.split_last() else {
170        return super::usage_error("usage: mkit mv <source>... <dest>");
171    };
172    if sources.is_empty() {
173        return super::usage_error("usage: mkit mv <source>... <dest>");
174    }
175
176    let dest_rel = match super::index_path_for_arg(&cwd, Path::new(dest_raw)) {
177        Ok(p) => p,
178        Err(e) => return emit_err(&e, exit::USAGE),
179    };
180    let dest_abs = cwd.join(&dest_rel);
181    // Multiple sources require an existing destination directory; a single
182    // source moves into the destination when it is an existing directory,
183    // otherwise it is a plain rename.
184    if sources.len() > 1 && !dest_abs.is_dir() {
185        return emit_err(
186            &format!("destination directory does not exist: {dest_raw}"),
187            exit::USAGE,
188        );
189    }
190    let into_dir = sources.len() > 1 || dest_abs.is_dir();
191
192    // Pass 1 — validate and plan every move before touching anything. A
193    // source that is an exact tracked entry is a file move; one that is the
194    // prefix of tracked entries is a directory move; anything else is not
195    // under version control.
196    let mut plan: Vec<Planned> = Vec::new();
197    for source in sources {
198        let src_rel = match super::index_path_for_arg(&cwd, Path::new(source)) {
199            Ok(p) => p,
200            Err(e) => return emit_err(&e, exit::USAGE),
201        };
202        let is_file = idx
203            .entries
204            .iter()
205            .any(|e| e.path == src_rel && e.status != EntryStatus::Removed);
206        let dir_prefix = format!("{src_rel}/");
207        let is_dir = idx
208            .entries
209            .iter()
210            .any(|e| e.status != EntryStatus::Removed && e.path.starts_with(&dir_prefix));
211
212        let planned = if is_file {
213            plan_move(
214                &cwd,
215                &root_canon,
216                &idx,
217                source,
218                &dest_rel,
219                into_dir,
220                opts.force,
221            )
222            .map(Planned::File)
223        } else if is_dir {
224            plan_dir_move(
225                &cwd,
226                &root_canon,
227                &idx,
228                source,
229                &src_rel,
230                &dest_rel,
231                into_dir,
232                opts.force,
233            )
234            .map(Planned::Dir)
235        } else {
236            Err(emit_err(
237                &format!("not under version control: {source}"),
238                exit::GENERAL_ERROR,
239            ))
240        };
241        match planned {
242            Ok(p) => plan.push(p),
243            Err(code) => return code,
244        }
245    }
246    // Reject overlapping sources (e.g. `mv dir dir/file <dest>`): moving one
247    // invalidates the other, which would otherwise leave a partial result on
248    // disk and in the index. Git rejects this up front too.
249    for i in 0..plan.len() {
250        for j in 0..plan.len() {
251            if i == j {
252                continue;
253            }
254            let (a, b) = (plan[i].source_rel(), plan[j].source_rel());
255            if a == b {
256                return emit_err(&format!("duplicate source: {a}"), exit::USAGE);
257            }
258            if b.starts_with(&format!("{a}/")) {
259                return emit_err(
260                    &format!("overlapping sources: '{b}' is inside '{a}'"),
261                    exit::USAGE,
262                );
263            }
264        }
265    }
266    // Reject a batch where two staged destinations collide (across both file
267    // and directory moves) — either the SAME path, or one nested UNDER the
268    // other (e.g. a file at `dst/x` and a directory at `dst/x/...`), which
269    // would create a file/directory conflict on disk and in the index.
270    let all_targets: Vec<&str> = plan.iter().flat_map(Planned::target_paths).collect();
271    for i in 0..all_targets.len() {
272        for j in 0..all_targets.len() {
273            if i == j {
274                continue;
275            }
276            let (a, b) = (all_targets[i], all_targets[j]);
277            if a == b {
278                return emit_err(
279                    &format!("multiple sources map to the same destination: {a}"),
280                    exit::USAGE,
281                );
282            }
283            if b.starts_with(&format!("{a}/")) {
284                return emit_err(
285                    &format!("conflicting destinations: '{a}' and '{b}' (one is inside the other)"),
286                    exit::USAGE,
287                );
288            }
289        }
290    }
291    // Two DIRECTORY moves must not land at the same (or a nested) destination
292    // root: `target_paths` only exposes per-file landings (`dst/dir/a` vs
293    // `dst/dir/b` — non-colliding), so the check above misses two directories
294    // renamed onto the same `dest_dir_rel` (e.g. `mv x/dir y/dir dst` → both →
295    // `dst/dir`). `execute_dir_move` does a bare `fs::rename` onto the
296    // now-existing dest, failing mid-batch and leaving a partial result —
297    // violating the all-or-nothing invariant. Reject it up front.
298    let dir_roots: Vec<&str> = plan.iter().filter_map(Planned::dest_dir_root).collect();
299    for i in 0..dir_roots.len() {
300        for j in (i + 1)..dir_roots.len() {
301            let (a, b) = (dir_roots[i], dir_roots[j]);
302            if a == b || b.starts_with(&format!("{a}/")) || a.starts_with(&format!("{b}/")) {
303                return emit_err(
304                    &format!("multiple directory sources map to the same destination: {a}"),
305                    exit::USAGE,
306                );
307            }
308        }
309    }
310
311    // Pass 2 — execute. On a filesystem error mid-batch, persist the
312    // index for the moves already done so it stays consistent with disk.
313    for (done, p) in plan.iter().enumerate() {
314        let exec = match p {
315            Planned::File(m) => execute_move(m, opts.force),
316            Planned::Dir(m) => execute_dir_move(m),
317        };
318        if let Err(code) = exec {
319            if done > 0 {
320                let _ = index::write_index(&layout, &idx);
321            }
322            return code;
323        }
324        match p {
325            Planned::File(m) => apply_to_index(&mut idx, m),
326            Planned::Dir(m) => apply_dir_to_index(&mut idx, m),
327        }
328    }
329
330    match index::write_index(&layout, &idx) {
331        Ok(()) => exit::OK,
332        Err(e) => emit_err(&format!("write index: {e}"), exit::GENERAL_ERROR),
333    }
334}
335
336/// Validate one `source` and return the planned move, or the exit code to
337/// propagate. Performs no filesystem or index mutation.
338#[allow(clippy::too_many_lines)] // a flat sequence of independent safety guards
339fn plan_move(
340    cwd: &Path,
341    root_canon: &Path,
342    idx: &index::Index,
343    source: &str,
344    dest_rel: &str,
345    into_dir: bool,
346    force: bool,
347) -> Result<PlannedMove, u8> {
348    let src_rel =
349        super::index_path_for_arg(cwd, Path::new(source)).map_err(|e| emit_err(&e, exit::USAGE))?;
350
351    // The source must be a tracked, not-yet-removed index entry. The
352    // caller (`run`) only invokes `plan_move` after proving exactly this
353    // with the same predicate against the same index and `src_rel` (the
354    // `is_file` branch), so a match is guaranteed to exist here. The
355    // untracked and tracked-directory cases are handled by the caller's
356    // `else if is_dir` / `else` arms before we ever get here.
357    let src_idx = idx
358        .entries
359        .iter()
360        .position(|e| e.path == src_rel && e.status != EntryStatus::Removed)
361        .ok_or_else(|| {
362            // Unreachable given the caller's guarantee above, but surface a
363            // clean error rather than panicking if that invariant ever breaks.
364            emit_err(
365                &format!("internal: source is not a tracked file: {source}"),
366                exit::GENERAL_ERROR,
367            )
368        })?;
369    let status = idx.entries[src_idx].status;
370    let hash = idx.entries[src_idx].object_hash;
371
372    let target_rel = if into_dir {
373        let base = src_rel.rsplit('/').next().unwrap_or(&src_rel);
374        format!("{dest_rel}/{base}")
375    } else {
376        dest_rel.to_string()
377    };
378    if target_rel == src_rel {
379        return Err(emit_err(
380            &format!("source and destination are the same: {source}"),
381            exit::USAGE,
382        ));
383    }
384
385    let src_abs = cwd.join(&src_rel);
386    let target_abs = cwd.join(&target_rel);
387
388    if !path_present(&src_abs) {
389        return Err(emit_err(
390            &format!("bad source: {source}"),
391            exit::GENERAL_ERROR,
392        ));
393    }
394    // Safety: a file source tracked in the index must still be a file/symlink
395    // on disk. If it was replaced by a directory, this "file move" would
396    // rename the directory while staging the destination with the OLD file
397    // blob — a worktree/index divergence. Git refuses this too.
398    if std::fs::symlink_metadata(&src_abs).is_ok_and(|m| m.is_dir()) {
399        return Err(emit_err(
400            &format!("bad source: {source} (tracked as a file but is now a directory)"),
401            exit::GENERAL_ERROR,
402        ));
403    }
404    // Safety: refuse a source reached through a symlinked ancestor — it could
405    // point outside the repo (see `has_symlinked_ancestor`).
406    if has_symlinked_ancestor(cwd, &src_rel) {
407        return Err(emit_err(
408            &format!("bad source: {source} (path traverses a symlink)"),
409            exit::GENERAL_ERROR,
410        ));
411    }
412    // Safety: keep writes inside the repo — refuse a destination whose real
413    // parent (resolving symlinks) escapes the repository root.
414    if !target_within_repo(root_canon, &target_abs) {
415        return Err(emit_err(
416            &format!("destination escapes the repository: {target_rel}"),
417            exit::GENERAL_ERROR,
418        ));
419    }
420    // Safety: refuse a destination reached through a symlinked ancestor, even
421    // when the link resolves INSIDE the repo. `fs::rename` follows the link
422    // and writes to the real location while the index is staged at the literal
423    // lexical `target_rel` — an immediate worktree/index divergence.
424    // `target_within_repo` only proves containment; it accepts an in-repo
425    // symlink, so this guard (symmetric with the source check) is still needed.
426    if has_symlinked_ancestor(cwd, &target_rel) {
427        return Err(emit_err(
428            &format!("destination path traverses a symlink: {target_rel}"),
429            exit::GENERAL_ERROR,
430        ));
431    }
432    // Safety: never clobber an existing destination without -f. Use a
433    // symlink-aware check so a dangling symlink still counts as "exists".
434    // Skip entirely ONLY for a genuine case-only rename (the "destination" IS
435    // the source on a case-insensitive filesystem) so `mv Foo foo` is a plain
436    // rename. An untracked symlink pointing AT the source must NOT bypass this.
437    if path_present(&target_abs)
438        && !is_case_only_rename(&src_rel, &target_rel, &src_abs, &target_abs)
439    {
440        // A file source can never replace a DIRECTORY destination — even with
441        // -f. `-f` removes the destination first, and removing a directory
442        // would recursively delete its (tracked + untracked) contents and
443        // leave the index in a file/dir conflict. Git refuses this too.
444        if std::fs::symlink_metadata(&target_abs).is_ok_and(|m| m.is_dir()) {
445            return Err(emit_err(
446                &format!(
447                    "destination is a directory: {target_rel} (mv cannot replace a directory with a file)"
448                ),
449                exit::GENERAL_ERROR,
450            ));
451        }
452        if !force {
453            return Err(emit_err(
454                &format!("destination exists (use -f to overwrite): {target_rel}"),
455                exit::GENERAL_ERROR,
456            ));
457        }
458    }
459    // A tracked file BENEATH the destination (e.g. tracked `dst/child` with
460    // `dst` deleted from disk, then `mv src dst`) would leave both `dst` (a
461    // file) and `dst/child` in the index — a file/dir conflict.
462    if let Some(desc) = idx
463        .entries
464        .iter()
465        .find(|e| e.status != EntryStatus::Removed && e.path.starts_with(&format!("{target_rel}/")))
466    {
467        return Err(emit_err(
468            &format!(
469                "destination has tracked descendants (e.g. '{}'); a file cannot replace it",
470                desc.path
471            ),
472            exit::GENERAL_ERROR,
473        ));
474    }
475    // A tracked file at an ANCESTOR of the destination would leave the index
476    // with both `<ancestor>` (a file) and `<ancestor>/…/target` — a file/dir
477    // conflict. This is missed by the on-disk checks when that ancestor was
478    // deleted from the worktree (e.g. tracked file `dst`, removed from disk,
479    // a directory `dst/` recreated, then `mv src dst`).
480    if let Some(anc) = idx.entries.iter().find(|e| {
481        e.status != EntryStatus::Removed && target_rel.starts_with(&format!("{}/", e.path))
482    }) {
483        return Err(emit_err(
484            &format!(
485                "destination nests under tracked file '{}'; move or remove it first",
486                anc.path
487            ),
488            exit::GENERAL_ERROR,
489        ));
490    }
491
492    Ok(PlannedMove {
493        src_idx,
494        src_rel,
495        src_abs,
496        target_rel,
497        target_abs,
498        status,
499        hash,
500    })
501}
502
503/// Move the worktree file for one planned move. Creates parent dirs and,
504/// under `-f`, removes an existing destination first so the rename is
505/// cross-platform.
506fn execute_move(m: &PlannedMove, force: bool) -> Result<(), u8> {
507    if let Some(parent) = m.target_abs.parent() {
508        std::fs::create_dir_all(parent).map_err(|e| {
509            emit_err(
510                &format!("create {}: {e}", parent.display()),
511                exit::CANTCREAT,
512            )
513        })?;
514    }
515    // Never clear the destination when it IS the source (a genuine case-only
516    // rename on a case-insensitive filesystem, e.g. `Foo` -> `foo`): removing
517    // it would delete the source and the rename would then fail with both
518    // gone. A symlink merely pointing at the source is NOT a case-only rename.
519    if force
520        && path_present(&m.target_abs)
521        && !is_case_only_rename(&m.src_rel, &m.target_rel, &m.src_abs, &m.target_abs)
522    {
523        let _ = remove_path(&m.target_abs);
524    }
525    std::fs::rename(&m.src_abs, &m.target_abs).map_err(|e| {
526        emit_err(
527            &format!("move {} -> {}: {e}", m.src_rel, m.target_rel),
528            exit::GENERAL_ERROR,
529        )
530    })
531}
532
533/// Apply a completed move to the index: source removed, destination added
534/// with the source's blob (content unchanged → object reused) and mode.
535fn apply_to_index(idx: &mut index::Index, m: &PlannedMove) {
536    idx.entries[m.src_idx].status = EntryStatus::Removed;
537    idx.entries[m.src_idx].object_hash = ZERO;
538    match idx.find_entry(&m.target_rel) {
539        Some(j) => {
540            idx.entries[j].status = m.status;
541            idx.entries[j].object_hash = m.hash;
542        }
543        None => idx.upsert_entry(IndexEntry {
544            path: m.target_rel.clone(),
545            status: m.status,
546            object_hash: m.hash,
547            mtime_ns: 0,
548            size: 0,
549            ino: 0,
550            ctime_ns: 0,
551        }),
552    }
553}
554
555/// Validate a directory `source` and plan its move. Performs no filesystem
556/// or index mutation. `src_rel` is the source's repo-relative path (already
557/// resolved by the caller).
558#[allow(clippy::too_many_arguments)]
559#[allow(clippy::too_many_lines)] // a flat sequence of independent safety guards
560fn plan_dir_move(
561    cwd: &Path,
562    root_canon: &Path,
563    idx: &index::Index,
564    source: &str,
565    src_rel: &str,
566    dest_rel: &str,
567    into_dir: bool,
568    force: bool,
569) -> Result<PlannedDirMove, u8> {
570    let src_dir_abs = cwd.join(src_rel);
571    // The worktree path must be a REAL directory. `Path::is_dir()` follows
572    // symlinks, so a tracked directory replaced by a symlink-to-a-directory
573    // would otherwise be "moved" as a symlink — and its tracked contents
574    // restaged under a path that escapes the repo. Require the on-disk type
575    // (not the link target) to be a directory.
576    if !std::fs::symlink_metadata(&src_dir_abs).is_ok_and(|m| m.is_dir()) {
577        return Err(emit_err(
578            &format!("bad source: {source} (not a directory, or a symlink standing in for one)"),
579            exit::GENERAL_ERROR,
580        ));
581    }
582    // Safety: also refuse if an ANCESTOR component is a symlink — the leaf
583    // check above only proves the final component is a real directory, but a
584    // symlinked ancestor could place that directory outside the repo.
585    if has_symlinked_ancestor(cwd, src_rel) {
586        return Err(emit_err(
587            &format!("bad source: {source} (path traverses a symlink)"),
588            exit::GENERAL_ERROR,
589        ));
590    }
591
592    // Where the directory lands: a plain rename to `dest_rel`, or — when the
593    // destination is an existing directory (or there are multiple sources) —
594    // *into* it as `dest_rel/<basename>`, matching git.
595    let dest_dir_rel = if into_dir {
596        let base = src_rel.rsplit('/').next().unwrap_or(src_rel);
597        format!("{dest_rel}/{base}")
598    } else {
599        dest_rel.to_string()
600    };
601    if dest_dir_rel == src_rel {
602        return Err(emit_err(
603            &format!("source and destination are the same: {source}"),
604            exit::USAGE,
605        ));
606    }
607    // Refuse moving a directory into a descendant of itself (the on-disk
608    // rename would otherwise be nonsensical / lose data). The exact
609    // same-path case is already handled above with a clearer message.
610    if dest_dir_rel.starts_with(&format!("{src_rel}/")) {
611        return Err(emit_err(
612            &format!("cannot move '{source}' into itself"),
613            exit::USAGE,
614        ));
615    }
616
617    let dest_dir_abs = cwd.join(&dest_dir_rel);
618    // Safety: keep writes inside the repo.
619    if !target_within_repo(root_canon, &dest_dir_abs) {
620        return Err(emit_err(
621            &format!("destination escapes the repository: {dest_dir_rel}"),
622            exit::GENERAL_ERROR,
623        ));
624    }
625    // Safety: refuse a destination reached through a symlinked ancestor, even
626    // when the link resolves INSIDE the repo (symmetric with the file-move
627    // guard in `plan_move`). `fs::rename` follows the link and moves the
628    // subtree to the real location while the index is staged at the literal
629    // lexical `dest_dir_rel` — an immediate worktree/index divergence.
630    // `target_within_repo` only proves containment; it accepts an in-repo
631    // symlink target.
632    if has_symlinked_ancestor(cwd, &dest_dir_rel) {
633        return Err(emit_err(
634            &format!("destination path traverses a symlink: {dest_dir_rel}"),
635            exit::GENERAL_ERROR,
636        ));
637    }
638    // Safety: a directory move never overwrites an existing destination —
639    // even with -f. Recursively removing the destination would silently
640    // delete its tracked files (leaving them dangling in the index) and any
641    // untracked files under it. Git likewise refuses to clobber a directory
642    // with `mv`, so this is a refusal, not a force-overridable guard.
643    if path_present(&dest_dir_abs) {
644        return Err(emit_err(
645            &format!(
646                "destination already exists: {dest_dir_rel} \
647                 (refusing to overwrite it — -f does not clobber a directory)"
648            ),
649            exit::GENERAL_ERROR,
650        ));
651    }
652    // Also refuse when the destination collides with a tracked INDEX path
653    // even though it is absent on disk (e.g. a tracked file deleted from the
654    // worktree, or an already-tracked subtree). Otherwise the move would
655    // leave the index with both `<dest>` (a file) and `<dest>/<child>`
656    // entries — a path conflict that breaks `status`/`commit`.
657    let dest_prefix = format!("{dest_dir_rel}/");
658    if idx.entries.iter().any(|e| {
659        e.status != EntryStatus::Removed
660            && (e.path == dest_dir_rel                         // dest tracked as a file
661                || e.path.starts_with(&dest_prefix)            // dest subtree tracked
662                || dest_dir_rel.starts_with(&format!("{}/", e.path))) // tracked ANCESTOR file
663    }) {
664        return Err(emit_err(
665            &format!("destination conflicts with a tracked path: {dest_dir_rel}"),
666            exit::GENERAL_ERROR,
667        ));
668    }
669    let _ = force;
670
671    // Restage every tracked file beneath the directory at its new path.
672    let prefix = format!("{src_rel}/");
673    let mut files = Vec::new();
674    for e in &idx.entries {
675        if e.status != EntryStatus::Removed && e.path.starts_with(&prefix) {
676            let child_abs = cwd.join(&e.path);
677            // The tracked child must still exist in the worktree. Otherwise the
678            // directory rename moves nothing for it, yet we'd restage its old
679            // blob at the destination — resurrecting a file that was deleted
680            // from disk. Git refuses a move whose source is gone.
681            let Ok(meta) = std::fs::symlink_metadata(&child_abs) else {
682                return Err(emit_err(
683                    &format!(
684                        "bad source: {} (tracked file missing from the worktree)",
685                        e.path
686                    ),
687                    exit::GENERAL_ERROR,
688                ));
689            };
690            // …and it must still be a file/symlink, not a directory: a child
691            // replaced by a directory would restage the stale blob at the
692            // destination while the worktree keeps the directory.
693            if meta.is_dir() {
694                return Err(emit_err(
695                    &format!(
696                        "bad source: {} (tracked as a file but is now a directory)",
697                        e.path
698                    ),
699                    exit::GENERAL_ERROR,
700                ));
701            }
702            // …and its path must not be reached through a symlinked ancestor
703            // inside the moved subtree (which the single directory rename would
704            // carry along, leaving a symlink that escapes the repo).
705            if has_symlinked_ancestor(cwd, &e.path) {
706                return Err(emit_err(
707                    &format!("bad source: {} (path traverses a symlink)", e.path),
708                    exit::GENERAL_ERROR,
709                ));
710            }
711            let sub = &e.path[prefix.len()..];
712            files.push(DirFileMove {
713                src_rel: e.path.clone(),
714                target_rel: format!("{dest_dir_rel}/{sub}"),
715                status: e.status,
716                hash: e.object_hash,
717            });
718        }
719    }
720    if files.is_empty() {
721        // The caller only routes here when at least one tracked entry has the
722        // `src_rel/` prefix, so this is defensive.
723        return Err(emit_err(
724            &format!("not under version control: {source}"),
725            exit::GENERAL_ERROR,
726        ));
727    }
728
729    Ok(PlannedDirMove {
730        src_dir_rel: src_rel.to_string(),
731        src_dir_abs,
732        dest_dir_rel,
733        dest_dir_abs,
734        files,
735    })
736}
737
738/// Rename the worktree directory for one planned directory move. A single
739/// `fs::rename` moves the whole subtree (tracked and untracked alike),
740/// matching `git mv`. The destination is guaranteed absent (plan-time
741/// guard), so this never removes an existing path.
742fn execute_dir_move(m: &PlannedDirMove) -> Result<(), u8> {
743    if let Some(parent) = m.dest_dir_abs.parent() {
744        std::fs::create_dir_all(parent).map_err(|e| {
745            emit_err(
746                &format!("create {}: {e}", parent.display()),
747                exit::CANTCREAT,
748            )
749        })?;
750    }
751    std::fs::rename(&m.src_dir_abs, &m.dest_dir_abs).map_err(|e| {
752        emit_err(
753            &format!("move {} -> {}: {e}", m.src_dir_rel, m.dest_dir_rel),
754            exit::GENERAL_ERROR,
755        )
756    })
757}
758
759/// Apply a completed directory move to the index: each source file is staged
760/// as removed and its destination staged with the reused blob/mode. Only
761/// flips statuses and appends — never removes from the vec — so any
762/// `src_idx` captured by a sibling file move stays valid.
763fn apply_dir_to_index(idx: &mut index::Index, m: &PlannedDirMove) {
764    for f in &m.files {
765        if let Some(i) = idx
766            .find_entry(&f.src_rel)
767            .filter(|&i| idx.entries[i].status != EntryStatus::Removed)
768        {
769            idx.entries[i].status = EntryStatus::Removed;
770            idx.entries[i].object_hash = ZERO;
771        }
772        match idx.find_entry(&f.target_rel) {
773            Some(j) => {
774                idx.entries[j].status = f.status;
775                idx.entries[j].object_hash = f.hash;
776            }
777            None => idx.upsert_entry(IndexEntry {
778                path: f.target_rel.clone(),
779                status: f.status,
780                object_hash: f.hash,
781                mtime_ns: 0,
782                size: 0,
783                ino: 0,
784                ctime_ns: 0,
785            }),
786        }
787    }
788}
789
790/// Symlink-aware existence: true even for a dangling symlink (unlike
791/// [`Path::exists`], which follows the link and reports `false`).
792fn path_present(p: &Path) -> bool {
793    p.symlink_metadata().is_ok()
794}
795
796/// Do `a` and `b` resolve to the same filesystem object? Used to detect a
797/// case-only rename on a case-insensitive filesystem (`Foo` vs `foo`), where
798/// the move destination IS the source — so it must not be cleared. Both paths
799/// must exist; a failed canonicalization is treated as "different".
800fn same_file(a: &Path, b: &Path) -> bool {
801    match (a.canonicalize(), b.canonicalize()) {
802        (Ok(ca), Ok(cb)) => ca == cb,
803        _ => false,
804    }
805}
806
807/// Is this move a genuine CASE-ONLY rename (`Foo` -> `foo`, including non-ASCII
808/// folds like `Ä` -> `ä`) on a case-insensitive filesystem? Such a move's
809/// destination resolves to the source itself, so the clobber guard /
810/// force-remove must be skipped. We require distinct lexical paths that resolve
811/// to the SAME object where the destination is a REGULAR FILE (the source
812/// under a different case spelling). The regular-file requirement is what
813/// excludes an untracked SYMLINK pointing at the source: it also resolves to
814/// the source via `same_file`, but it is a distinct name the user must `-f` to
815/// overwrite (matching git). A hardlink has a distinct canonical path, so
816/// `same_file` already excludes it.
817fn is_case_only_rename(src_rel: &str, target_rel: &str, src_abs: &Path, target_abs: &Path) -> bool {
818    src_rel != target_rel
819        && same_file(src_abs, target_abs)
820        && std::fs::symlink_metadata(target_abs).is_ok_and(|m| !m.file_type().is_symlink())
821}
822
823/// Does any ANCESTOR component of repo-relative `rel` (under `root`) resolve
824/// through a symlink? `Path::symlink_metadata` only declines to follow the
825/// *final* component, so a tracked path like `link/dir/file` whose `link`
826/// component is replaced by a symlink to an external directory would let `mv`
827/// operate on content outside the repo. The leaf is excluded — a file move
828/// may legitimately move a tracked symlink, and directory sources validate
829/// the leaf separately.
830fn has_symlinked_ancestor(root: &Path, rel: &str) -> bool {
831    let comps: Vec<&str> = rel.split('/').filter(|c| !c.is_empty()).collect();
832    let mut p = root.to_path_buf();
833    for comp in comps.iter().take(comps.len().saturating_sub(1)) {
834        p.push(comp);
835        if std::fs::symlink_metadata(&p).is_ok_and(|m| m.file_type().is_symlink()) {
836            return true;
837        }
838    }
839    false
840}
841
842/// Remove a file or symlink at `p` (used to clear a destination under -f).
843fn remove_path(p: &Path) -> std::io::Result<()> {
844    match p.symlink_metadata() {
845        Ok(meta) if meta.is_dir() => std::fs::remove_dir_all(p),
846        _ => std::fs::remove_file(p),
847    }
848}
849
850/// Does `target_abs` stay within the repo once symlinks are resolved? We
851/// canonicalize its nearest existing ancestor (the leaf may not exist yet)
852/// and require it to live under the canonical repo root, so a symlinked
853/// parent pointing outside the repo is rejected.
854fn target_within_repo(root_canon: &Path, target_abs: &Path) -> bool {
855    let mut ancestor = target_abs.parent();
856    while let Some(a) = ancestor {
857        match a.canonicalize() {
858            Ok(real) => return real.starts_with(root_canon),
859            Err(_) => ancestor = a.parent(),
860        }
861    }
862    false
863}
864
865use super::error as emit_err;