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;