1use std::ffi::OsString;
29use std::path::{Component, Path, PathBuf};
30
31use clap::Parser;
32use mkit_core::hash::Hash;
33use mkit_core::index::{self, EntryStatus, Index, IndexEntry};
34use mkit_core::layout::RepoLayout;
35use mkit_core::object::Object;
36use mkit_core::ops::restore::{RestoreOptions, SparsePattern, restore_tree_to_worktree};
37use mkit_core::store::ObjectStore;
38use mkit_core::worktree;
39
40use crate::clap_shim;
41use crate::exit;
42
43#[derive(Debug, Parser)]
44#[command(
45 name = "mkit restore",
46 about = "Restore worktree files (discard local changes) or unstage them."
47)]
48struct RestoreOpts {
49 #[arg(short = 'S', long)]
53 staged: bool,
54
55 #[arg(short = 'W', long)]
59 worktree: bool,
60
61 #[arg(long, value_name = "REV")]
66 source: Option<String>,
67
68 #[arg(short = 'f', long)]
71 force: bool,
72
73 #[arg(required = true)]
76 paths: Vec<String>,
77}
78
79#[must_use]
80pub fn run(args: &[String]) -> u8 {
81 let opts = match clap_shim::parse::<RestoreOpts>("mkit restore", args) {
82 Ok(o) => o,
83 Err(code) => return code,
84 };
85 let cwd = match std::env::current_dir() {
86 Ok(p) => p,
87 Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
88 };
89 let layout = match super::resolve_layout(&cwd) {
90 Ok(layout) => layout,
91 Err(code) => return code,
92 };
93 let store = match ObjectStore::open(&layout) {
94 Ok(s) => s,
95 Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
96 };
97 let _lock = match super::acquire_worktree_lock(&layout) {
98 Ok(l) => l,
99 Err(code) => return code,
100 };
101
102 let do_staged = opts.staged;
106 let do_worktree = opts.worktree || !opts.staged;
107
108 let mut idx = match super::read_or_seed_index_from_head(&layout, &store) {
109 Ok(i) => i,
110 Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
111 };
112
113 let head_tree = match super::current_head_tree(&layout, &store) {
116 Ok(t) => t,
117 Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
118 };
119
120 let source_tree: Option<Hash> = match &opts.source {
123 Some(spec) => match resolve_source_tree(&store, &layout, spec) {
124 Ok(t) => Some(t),
125 Err((msg, code)) => return emit_err(&msg, code),
126 },
127 None => None,
128 };
129
130 let restore_index: Option<Index> = match resolve_restore_index(&store, source_tree, head_tree) {
135 Ok(i) => i,
136 Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
137 };
138
139 let mut rels: Vec<String> = Vec::with_capacity(opts.paths.len());
141 for raw in &opts.paths {
142 match index_path_for_arg(&cwd, Path::new(raw)) {
143 Ok(p) => rels.push(p),
144 Err(e) => return emit_err(&e, exit::DATAERR),
145 }
146 }
147
148 if do_staged && let Err(code) = restore_staged(&layout, &mut idx, restore_index.as_ref(), &rels)
149 {
150 return code;
151 }
152
153 if do_worktree
154 && let Err(code) = restore_worktree(
155 &cwd,
156 &store,
157 &idx,
158 restore_index.as_ref(),
159 &rels,
160 source_tree.is_some(),
161 opts.force,
162 )
163 {
164 return code;
165 }
166
167 exit::OK
168}
169
170fn resolve_source_tree(
172 store: &ObjectStore,
173 layout: &RepoLayout,
174 spec: &str,
175) -> Result<Hash, (String, u8)> {
176 let commit = super::revspec::resolve_revision(store, layout, spec)
177 .map_err(|e| (format!("bad --source '{spec}': {e}"), exit::GENERAL_ERROR))?;
178 match store.read_object(&commit) {
179 Ok(Object::Commit(c)) => Ok(c.tree_hash),
180 Ok(Object::Remix(r)) => Ok(r.tree_hash),
181 Ok(Object::Tree(_)) => Ok(commit),
182 Ok(_) => Err((
183 format!("--source '{spec}' does not resolve to a commit or tree"),
184 exit::GENERAL_ERROR,
185 )),
186 Err(e) => Err((format!("read --source object: {e}"), exit::GENERAL_ERROR)),
187 }
188}
189
190fn resolve_restore_index(
195 store: &ObjectStore,
196 source_tree: Option<Hash>,
197 head_tree: Option<Hash>,
198) -> Result<Option<Index>, String> {
199 let tree = source_tree.or(head_tree);
200 match tree {
201 Some(t) => index::from_tree(store, t)
202 .map(Some)
203 .map_err(|e| format!("read source tree: {e}")),
204 None => Ok(None),
205 }
206}
207
208fn restore_staged(
213 layout: &RepoLayout,
214 idx: &mut Index,
215 restore_index: Option<&Index>,
216 rels: &[String],
217) -> Result<(), u8> {
218 let mut matched_any = false;
219 for rel in rels {
220 let in_index = entry_matches(idx, rel);
221 let in_source = restore_index
222 .map(|src| entry_matches(src, rel))
223 .unwrap_or_default();
224 if in_index.is_empty() && in_source.is_empty() {
225 return Err(emit_err(
226 &format!("pathspec '{rel}' did not match any tracked or staged files"),
227 exit::GENERAL_ERROR,
228 ));
229 }
230 matched_any = true;
231
232 let mut affected: Vec<String> = in_index
234 .iter()
235 .chain(in_source.iter())
236 .map(|e| e.path.clone())
237 .collect();
238 affected.sort_unstable();
239 affected.dedup();
240
241 for path in affected {
242 let source_entry =
243 restore_index.and_then(|src| src.find_entry(&path).map(|i| src.entries[i].clone()));
244 apply_index_restore(idx, &path, source_entry);
245 }
246 }
247
248 if !matched_any {
249 return Ok(());
250 }
251 index::write_index(layout, idx)
252 .map_err(|e| emit_err(&format!("write index: {e}"), exit::CANTCREAT))
253}
254
255fn apply_index_restore(idx: &mut Index, path: &str, source: Option<IndexEntry>) {
257 match source {
258 Some(src) => idx.upsert_entry(src),
259 None => {
260 idx.remove_path(path);
263 }
264 }
265}
266
267fn restore_worktree(
272 cwd: &Path,
273 store: &ObjectStore,
274 idx: &Index,
275 restore_index: Option<&Index>,
276 rels: &[String],
277 explicit_source: bool,
278 force: bool,
279) -> Result<(), u8> {
280 let source = if explicit_source {
283 restore_index.unwrap_or(idx)
284 } else {
285 idx
286 };
287
288 let mut to_write: Vec<IndexEntry> = Vec::new();
290 for rel in rels {
291 let matches = entry_matches(source, rel);
292 if matches.is_empty() {
293 return Err(emit_err(
294 &format!("pathspec '{rel}' did not match any tracked files"),
295 exit::GENERAL_ERROR,
296 ));
297 }
298 to_write.extend(matches);
299 }
300 to_write.sort_by(|a, b| a.path.cmp(&b.path));
301 to_write.dedup_by(|a, b| a.path == b.path);
302
303 if !force {
308 for entry in &to_write {
309 if let Some(reason) = dirty_reason(cwd, store, idx, &entry.path) {
310 return Err(emit_err(&reason, exit::GENERAL_ERROR));
311 }
312 }
313 }
314
315 let source_tree = match worktree::build_tree_from_index(store, source) {
321 Ok(t) => t,
322 Err(e) => {
323 return Err(emit_err(
324 &format!("build source tree: {e}"),
325 exit::GENERAL_ERROR,
326 ));
327 }
328 };
329 let patterns: Vec<SparsePattern> = to_write
330 .iter()
331 .map(|e| SparsePattern {
332 pattern: e.path.clone(),
333 negated: false,
334 dir_only: false,
335 })
336 .collect();
337 let restore_opts = RestoreOptions {
338 clean: false,
339 sparse_patterns: Some(patterns),
340 };
341 if let Err(e) = restore_tree_to_worktree(store, &source_tree, cwd, &restore_opts) {
342 return Err(emit_err(&format!("restore worktree: {e}"), exit::CANTCREAT));
343 }
344 Ok(())
345}
346
347fn entry_matches(idx: &Index, rel: &str) -> Vec<IndexEntry> {
349 idx.entries
350 .iter()
351 .filter(|e| {
352 e.status != EntryStatus::Removed && super::index_path_matches_or_descends(&e.path, rel)
353 })
354 .cloned()
355 .collect()
356}
357
358fn dirty_reason(root: &Path, _store: &ObjectStore, idx: &Index, path: &str) -> Option<String> {
362 let staged = idx
363 .entries
364 .iter()
365 .find(|e| e.path == path && e.status != EntryStatus::Removed)?;
366 let abs = root.join(path);
367 let meta = abs.symlink_metadata().ok()?;
368 let work_hash = if meta.file_type().is_symlink() {
369 let target = std::fs::read_link(&abs).ok()?;
370 let target_str = target.to_str()?;
371 symlink_blob_hash(target_str)?
372 } else if meta.file_type().is_file() {
373 worktree::read_regular_file_bounded(&abs)
374 .ok()
375 .and_then(|(_, data)| worktree::hash_file_object(&data).ok())?
376 } else {
377 return None;
379 };
380 if work_hash == staged.object_hash {
381 None
382 } else {
383 Some(format!(
384 "'{path}' has unstaged changes; use --force to discard them"
385 ))
386 }
387}
388
389fn symlink_blob_hash(target: &str) -> Option<Hash> {
391 let prologue = mkit_core::serialize::blob_prologue(target.len()).ok()?;
394 let mut hasher = mkit_core::hash::Hasher::new();
395 hasher.update(&prologue).update(target.as_bytes());
396 Some(hasher.finalize())
397}
398
399fn index_path_for_arg(root: &Path, arg: &Path) -> Result<String, String> {
403 let rel = if arg.is_absolute() {
404 absolute_arg_to_repo_relative(root, arg)?
405 } else {
406 arg.to_path_buf()
407 };
408
409 let mut parts: Vec<String> = Vec::new();
410 for component in rel.as_path().components() {
411 match component {
412 Component::Normal(part) => {
413 let part = part
414 .to_str()
415 .ok_or_else(|| "path is not valid UTF-8".to_string())?;
416 parts.push(part.to_string());
417 }
418 Component::CurDir => {}
419 Component::ParentDir => {
420 if parts.pop().is_none() {
421 return Err(format!("invalid path: {}", arg.display()));
422 }
423 }
424 Component::Prefix(_) | Component::RootDir => {
425 return Err(format!("invalid path: {}", arg.display()));
426 }
427 }
428 }
429
430 let path = parts.join("/");
431 if !index::validate_index_path(&path) {
432 return Err(format!("invalid path: {path}"));
433 }
434 Ok(path)
435}
436
437fn absolute_arg_to_repo_relative(root: &Path, arg: &Path) -> Result<PathBuf, String> {
438 let root = root.canonicalize().map_err(|e| format!("repo root: {e}"))?;
439
440 if let Ok(rel) = arg.strip_prefix(&root) {
441 return Ok(rel.to_path_buf());
442 }
443
444 let mut suffix: Vec<OsString> = vec![
445 arg.file_name()
446 .ok_or_else(|| format!("invalid path: {}", arg.display()))?
447 .to_os_string(),
448 ];
449 let mut ancestor = arg
450 .parent()
451 .ok_or_else(|| format!("invalid path: {}", arg.display()))?;
452 while ancestor.symlink_metadata().is_err() {
453 let name = ancestor
454 .file_name()
455 .ok_or_else(|| format!("path is outside repository: {}", arg.display()))?;
456 suffix.push(name.to_os_string());
457 ancestor = ancestor
458 .parent()
459 .ok_or_else(|| format!("path is outside repository: {}", arg.display()))?;
460 }
461
462 let mut normalized = ancestor
463 .canonicalize()
464 .map_err(|e| format!("path {}: {e}", ancestor.display()))?;
465 for component in suffix.iter().rev() {
466 normalized.push(component);
467 }
468
469 normalized
470 .strip_prefix(&root)
471 .map(Path::to_path_buf)
472 .map_err(|_| format!("path is outside repository: {}", arg.display()))
473}
474
475use super::error as emit_err;