1use 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 #[arg(short = 'f', long)]
52 force: bool,
53 #[arg(num_args = 2.., required = true)]
56 paths: Vec<String>,
57}
58
59struct PlannedMove {
61 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 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 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 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 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 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 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
174fn 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 let src_idx = idx
190 .entries
191 .iter()
192 .position(|e| e.path == src_rel && e.status != EntryStatus::Removed)
193 .ok_or_else(|| {
194 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 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 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
265fn 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
288fn 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
310fn path_present(p: &Path) -> bool {
313 p.symlink_metadata().is_ok()
314}
315
316fn 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
324fn 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}