use std::path::{Path, PathBuf};
use clap::Parser;
use mkit_core::hash::{Hash, ZERO};
use mkit_core::index::{self, EntryStatus, IndexEntry};
use mkit_core::store::ObjectStore;
use crate::clap_shim;
use crate::exit;
#[derive(Debug, Parser)]
#[command(
name = "mkit mv",
about = "Move or rename tracked paths, staging the change."
)]
struct MvOpts {
#[arg(short = 'f', long)]
force: bool,
#[arg(num_args = 2.., required = true)]
paths: Vec<String>,
}
struct PlannedMove {
src_idx: usize,
src_rel: String,
src_abs: PathBuf,
target_rel: String,
target_abs: PathBuf,
status: EntryStatus,
hash: Hash,
}
struct DirFileMove {
src_rel: String,
target_rel: String,
status: EntryStatus,
hash: Hash,
}
struct PlannedDirMove {
src_dir_rel: String,
src_dir_abs: PathBuf,
dest_dir_rel: String,
dest_dir_abs: PathBuf,
files: Vec<DirFileMove>,
}
enum Planned {
File(PlannedMove),
Dir(PlannedDirMove),
}
impl Planned {
fn target_paths(&self) -> Vec<&str> {
match self {
Self::File(m) => vec![m.target_rel.as_str()],
Self::Dir(m) => m.files.iter().map(|f| f.target_rel.as_str()).collect(),
}
}
fn dest_dir_root(&self) -> Option<&str> {
match self {
Self::Dir(m) => Some(m.dest_dir_rel.as_str()),
Self::File(_) => None,
}
}
fn source_rel(&self) -> &str {
match self {
Self::File(m) => &m.src_rel,
Self::Dir(m) => &m.src_dir_rel,
}
}
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn run(args: &[String]) -> u8 {
let opts = match clap_shim::parse::<MvOpts>("mkit mv", args) {
Ok(o) => o,
Err(code) => return code,
};
let cwd = match std::env::current_dir() {
Ok(p) => p,
Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
};
let layout = match super::resolve_layout(&cwd) {
Ok(layout) => layout,
Err(code) => return code,
};
let store = match ObjectStore::open(&layout) {
Ok(s) => s,
Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
};
let _lock = match super::acquire_worktree_lock(&layout) {
Ok(l) => l,
Err(code) => return code,
};
let mut idx = match super::read_or_seed_index_from_head(&layout, &store) {
Ok(i) => i,
Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
};
let root_canon = match cwd.canonicalize() {
Ok(p) => p,
Err(e) => return emit_err(&format!("repo root: {e}"), exit::GENERAL_ERROR),
};
let Some((dest_raw, sources)) = opts.paths.split_last() else {
return super::usage_error("usage: mkit mv <source>... <dest>");
};
if sources.is_empty() {
return super::usage_error("usage: mkit mv <source>... <dest>");
}
let dest_rel = match super::index_path_for_arg(&cwd, Path::new(dest_raw)) {
Ok(p) => p,
Err(e) => return emit_err(&e, exit::USAGE),
};
let dest_abs = cwd.join(&dest_rel);
if sources.len() > 1 && !dest_abs.is_dir() {
return emit_err(
&format!("destination directory does not exist: {dest_raw}"),
exit::USAGE,
);
}
let into_dir = sources.len() > 1 || dest_abs.is_dir();
let mut plan: Vec<Planned> = Vec::new();
for source in sources {
let src_rel = match super::index_path_for_arg(&cwd, Path::new(source)) {
Ok(p) => p,
Err(e) => return emit_err(&e, exit::USAGE),
};
let is_file = idx
.entries
.iter()
.any(|e| e.path == src_rel && e.status != EntryStatus::Removed);
let dir_prefix = format!("{src_rel}/");
let is_dir = idx
.entries
.iter()
.any(|e| e.status != EntryStatus::Removed && e.path.starts_with(&dir_prefix));
let planned = if is_file {
plan_move(
&cwd,
&root_canon,
&idx,
source,
&dest_rel,
into_dir,
opts.force,
)
.map(Planned::File)
} else if is_dir {
plan_dir_move(
&cwd,
&root_canon,
&idx,
source,
&src_rel,
&dest_rel,
into_dir,
opts.force,
)
.map(Planned::Dir)
} else {
Err(emit_err(
&format!("not under version control: {source}"),
exit::GENERAL_ERROR,
))
};
match planned {
Ok(p) => plan.push(p),
Err(code) => return code,
}
}
for i in 0..plan.len() {
for j in 0..plan.len() {
if i == j {
continue;
}
let (a, b) = (plan[i].source_rel(), plan[j].source_rel());
if a == b {
return emit_err(&format!("duplicate source: {a}"), exit::USAGE);
}
if b.starts_with(&format!("{a}/")) {
return emit_err(
&format!("overlapping sources: '{b}' is inside '{a}'"),
exit::USAGE,
);
}
}
}
let all_targets: Vec<&str> = plan.iter().flat_map(Planned::target_paths).collect();
for i in 0..all_targets.len() {
for j in 0..all_targets.len() {
if i == j {
continue;
}
let (a, b) = (all_targets[i], all_targets[j]);
if a == b {
return emit_err(
&format!("multiple sources map to the same destination: {a}"),
exit::USAGE,
);
}
if b.starts_with(&format!("{a}/")) {
return emit_err(
&format!("conflicting destinations: '{a}' and '{b}' (one is inside the other)"),
exit::USAGE,
);
}
}
}
let dir_roots: Vec<&str> = plan.iter().filter_map(Planned::dest_dir_root).collect();
for i in 0..dir_roots.len() {
for j in (i + 1)..dir_roots.len() {
let (a, b) = (dir_roots[i], dir_roots[j]);
if a == b || b.starts_with(&format!("{a}/")) || a.starts_with(&format!("{b}/")) {
return emit_err(
&format!("multiple directory sources map to the same destination: {a}"),
exit::USAGE,
);
}
}
}
for (done, p) in plan.iter().enumerate() {
let exec = match p {
Planned::File(m) => execute_move(m, opts.force),
Planned::Dir(m) => execute_dir_move(m),
};
if let Err(code) = exec {
if done > 0 {
let _ = index::write_index(&layout, &idx);
}
return code;
}
match p {
Planned::File(m) => apply_to_index(&mut idx, m),
Planned::Dir(m) => apply_dir_to_index(&mut idx, m),
}
}
match index::write_index(&layout, &idx) {
Ok(()) => exit::OK,
Err(e) => emit_err(&format!("write index: {e}"), exit::GENERAL_ERROR),
}
}
#[allow(clippy::too_many_lines)] fn plan_move(
cwd: &Path,
root_canon: &Path,
idx: &index::Index,
source: &str,
dest_rel: &str,
into_dir: bool,
force: bool,
) -> Result<PlannedMove, u8> {
let src_rel =
super::index_path_for_arg(cwd, Path::new(source)).map_err(|e| emit_err(&e, exit::USAGE))?;
let src_idx = idx
.entries
.iter()
.position(|e| e.path == src_rel && e.status != EntryStatus::Removed)
.ok_or_else(|| {
emit_err(
&format!("internal: source is not a tracked file: {source}"),
exit::GENERAL_ERROR,
)
})?;
let status = idx.entries[src_idx].status;
let hash = idx.entries[src_idx].object_hash;
let target_rel = if into_dir {
let base = src_rel.rsplit('/').next().unwrap_or(&src_rel);
format!("{dest_rel}/{base}")
} else {
dest_rel.to_string()
};
if target_rel == src_rel {
return Err(emit_err(
&format!("source and destination are the same: {source}"),
exit::USAGE,
));
}
let src_abs = cwd.join(&src_rel);
let target_abs = cwd.join(&target_rel);
if !path_present(&src_abs) {
return Err(emit_err(
&format!("bad source: {source}"),
exit::GENERAL_ERROR,
));
}
if std::fs::symlink_metadata(&src_abs).is_ok_and(|m| m.is_dir()) {
return Err(emit_err(
&format!("bad source: {source} (tracked as a file but is now a directory)"),
exit::GENERAL_ERROR,
));
}
if has_symlinked_ancestor(cwd, &src_rel) {
return Err(emit_err(
&format!("bad source: {source} (path traverses a symlink)"),
exit::GENERAL_ERROR,
));
}
if !target_within_repo(root_canon, &target_abs) {
return Err(emit_err(
&format!("destination escapes the repository: {target_rel}"),
exit::GENERAL_ERROR,
));
}
if has_symlinked_ancestor(cwd, &target_rel) {
return Err(emit_err(
&format!("destination path traverses a symlink: {target_rel}"),
exit::GENERAL_ERROR,
));
}
if path_present(&target_abs)
&& !is_case_only_rename(&src_rel, &target_rel, &src_abs, &target_abs)
{
if std::fs::symlink_metadata(&target_abs).is_ok_and(|m| m.is_dir()) {
return Err(emit_err(
&format!(
"destination is a directory: {target_rel} (mv cannot replace a directory with a file)"
),
exit::GENERAL_ERROR,
));
}
if !force {
return Err(emit_err(
&format!("destination exists (use -f to overwrite): {target_rel}"),
exit::GENERAL_ERROR,
));
}
}
if let Some(desc) = idx
.entries
.iter()
.find(|e| e.status != EntryStatus::Removed && e.path.starts_with(&format!("{target_rel}/")))
{
return Err(emit_err(
&format!(
"destination has tracked descendants (e.g. '{}'); a file cannot replace it",
desc.path
),
exit::GENERAL_ERROR,
));
}
if let Some(anc) = idx.entries.iter().find(|e| {
e.status != EntryStatus::Removed && target_rel.starts_with(&format!("{}/", e.path))
}) {
return Err(emit_err(
&format!(
"destination nests under tracked file '{}'; move or remove it first",
anc.path
),
exit::GENERAL_ERROR,
));
}
Ok(PlannedMove {
src_idx,
src_rel,
src_abs,
target_rel,
target_abs,
status,
hash,
})
}
fn execute_move(m: &PlannedMove, force: bool) -> Result<(), u8> {
if let Some(parent) = m.target_abs.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
emit_err(
&format!("create {}: {e}", parent.display()),
exit::CANTCREAT,
)
})?;
}
if force
&& path_present(&m.target_abs)
&& !is_case_only_rename(&m.src_rel, &m.target_rel, &m.src_abs, &m.target_abs)
{
let _ = remove_path(&m.target_abs);
}
std::fs::rename(&m.src_abs, &m.target_abs).map_err(|e| {
emit_err(
&format!("move {} -> {}: {e}", m.src_rel, m.target_rel),
exit::GENERAL_ERROR,
)
})
}
fn apply_to_index(idx: &mut index::Index, m: &PlannedMove) {
idx.entries[m.src_idx].status = EntryStatus::Removed;
idx.entries[m.src_idx].object_hash = ZERO;
match idx.find_entry(&m.target_rel) {
Some(j) => {
idx.entries[j].status = m.status;
idx.entries[j].object_hash = m.hash;
}
None => idx.upsert_entry(IndexEntry {
path: m.target_rel.clone(),
status: m.status,
object_hash: m.hash,
mtime_ns: 0,
size: 0,
ino: 0,
ctime_ns: 0,
}),
}
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_lines)] fn plan_dir_move(
cwd: &Path,
root_canon: &Path,
idx: &index::Index,
source: &str,
src_rel: &str,
dest_rel: &str,
into_dir: bool,
force: bool,
) -> Result<PlannedDirMove, u8> {
let src_dir_abs = cwd.join(src_rel);
if !std::fs::symlink_metadata(&src_dir_abs).is_ok_and(|m| m.is_dir()) {
return Err(emit_err(
&format!("bad source: {source} (not a directory, or a symlink standing in for one)"),
exit::GENERAL_ERROR,
));
}
if has_symlinked_ancestor(cwd, src_rel) {
return Err(emit_err(
&format!("bad source: {source} (path traverses a symlink)"),
exit::GENERAL_ERROR,
));
}
let dest_dir_rel = if into_dir {
let base = src_rel.rsplit('/').next().unwrap_or(src_rel);
format!("{dest_rel}/{base}")
} else {
dest_rel.to_string()
};
if dest_dir_rel == src_rel {
return Err(emit_err(
&format!("source and destination are the same: {source}"),
exit::USAGE,
));
}
if dest_dir_rel.starts_with(&format!("{src_rel}/")) {
return Err(emit_err(
&format!("cannot move '{source}' into itself"),
exit::USAGE,
));
}
let dest_dir_abs = cwd.join(&dest_dir_rel);
if !target_within_repo(root_canon, &dest_dir_abs) {
return Err(emit_err(
&format!("destination escapes the repository: {dest_dir_rel}"),
exit::GENERAL_ERROR,
));
}
if has_symlinked_ancestor(cwd, &dest_dir_rel) {
return Err(emit_err(
&format!("destination path traverses a symlink: {dest_dir_rel}"),
exit::GENERAL_ERROR,
));
}
if path_present(&dest_dir_abs) {
return Err(emit_err(
&format!(
"destination already exists: {dest_dir_rel} \
(refusing to overwrite it — -f does not clobber a directory)"
),
exit::GENERAL_ERROR,
));
}
let dest_prefix = format!("{dest_dir_rel}/");
if idx.entries.iter().any(|e| {
e.status != EntryStatus::Removed
&& (e.path == dest_dir_rel || e.path.starts_with(&dest_prefix) || dest_dir_rel.starts_with(&format!("{}/", e.path))) }) {
return Err(emit_err(
&format!("destination conflicts with a tracked path: {dest_dir_rel}"),
exit::GENERAL_ERROR,
));
}
let _ = force;
let prefix = format!("{src_rel}/");
let mut files = Vec::new();
for e in &idx.entries {
if e.status != EntryStatus::Removed && e.path.starts_with(&prefix) {
let child_abs = cwd.join(&e.path);
let Ok(meta) = std::fs::symlink_metadata(&child_abs) else {
return Err(emit_err(
&format!(
"bad source: {} (tracked file missing from the worktree)",
e.path
),
exit::GENERAL_ERROR,
));
};
if meta.is_dir() {
return Err(emit_err(
&format!(
"bad source: {} (tracked as a file but is now a directory)",
e.path
),
exit::GENERAL_ERROR,
));
}
if has_symlinked_ancestor(cwd, &e.path) {
return Err(emit_err(
&format!("bad source: {} (path traverses a symlink)", e.path),
exit::GENERAL_ERROR,
));
}
let sub = &e.path[prefix.len()..];
files.push(DirFileMove {
src_rel: e.path.clone(),
target_rel: format!("{dest_dir_rel}/{sub}"),
status: e.status,
hash: e.object_hash,
});
}
}
if files.is_empty() {
return Err(emit_err(
&format!("not under version control: {source}"),
exit::GENERAL_ERROR,
));
}
Ok(PlannedDirMove {
src_dir_rel: src_rel.to_string(),
src_dir_abs,
dest_dir_rel,
dest_dir_abs,
files,
})
}
fn execute_dir_move(m: &PlannedDirMove) -> Result<(), u8> {
if let Some(parent) = m.dest_dir_abs.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
emit_err(
&format!("create {}: {e}", parent.display()),
exit::CANTCREAT,
)
})?;
}
std::fs::rename(&m.src_dir_abs, &m.dest_dir_abs).map_err(|e| {
emit_err(
&format!("move {} -> {}: {e}", m.src_dir_rel, m.dest_dir_rel),
exit::GENERAL_ERROR,
)
})
}
fn apply_dir_to_index(idx: &mut index::Index, m: &PlannedDirMove) {
for f in &m.files {
if let Some(i) = idx
.find_entry(&f.src_rel)
.filter(|&i| idx.entries[i].status != EntryStatus::Removed)
{
idx.entries[i].status = EntryStatus::Removed;
idx.entries[i].object_hash = ZERO;
}
match idx.find_entry(&f.target_rel) {
Some(j) => {
idx.entries[j].status = f.status;
idx.entries[j].object_hash = f.hash;
}
None => idx.upsert_entry(IndexEntry {
path: f.target_rel.clone(),
status: f.status,
object_hash: f.hash,
mtime_ns: 0,
size: 0,
ino: 0,
ctime_ns: 0,
}),
}
}
}
fn path_present(p: &Path) -> bool {
p.symlink_metadata().is_ok()
}
fn same_file(a: &Path, b: &Path) -> bool {
match (a.canonicalize(), b.canonicalize()) {
(Ok(ca), Ok(cb)) => ca == cb,
_ => false,
}
}
fn is_case_only_rename(src_rel: &str, target_rel: &str, src_abs: &Path, target_abs: &Path) -> bool {
src_rel != target_rel
&& same_file(src_abs, target_abs)
&& std::fs::symlink_metadata(target_abs).is_ok_and(|m| !m.file_type().is_symlink())
}
fn has_symlinked_ancestor(root: &Path, rel: &str) -> bool {
let comps: Vec<&str> = rel.split('/').filter(|c| !c.is_empty()).collect();
let mut p = root.to_path_buf();
for comp in comps.iter().take(comps.len().saturating_sub(1)) {
p.push(comp);
if std::fs::symlink_metadata(&p).is_ok_and(|m| m.file_type().is_symlink()) {
return true;
}
}
false
}
fn remove_path(p: &Path) -> std::io::Result<()> {
match p.symlink_metadata() {
Ok(meta) if meta.is_dir() => std::fs::remove_dir_all(p),
_ => std::fs::remove_file(p),
}
}
fn target_within_repo(root_canon: &Path, target_abs: &Path) -> bool {
let mut ancestor = target_abs.parent();
while let Some(a) = ancestor {
match a.canonicalize() {
Ok(real) => return real.starts_with(root_canon),
Err(_) => ancestor = a.parent(),
}
}
false
}
use super::error as emit_err;