Skip to main content

mkit_cli/commands/
restore.rs

1//! `mkit restore [--staged] [--worktree] [--source <rev>] [-f] <path>...`
2//! — discard worktree changes for path(s), or unstage them.
3//!
4//! Mirrors the everyday halves of `git restore`:
5//!
6//! - **default (no `--staged`)** — restore the worktree file(s) for each
7//!   path from the index (the staged content), discarding uncommitted
8//!   worktree edits. Reuses the #176 "don't clobber user work" spirit: a
9//!   worktree file whose content diverges from the staged blob is refused
10//!   without `-f/--force`, so an accidental `restore` never silently eats
11//!   an un-staged edit.
12//! - **`--staged`** — restore the index entry for each path from
13//!   `HEAD` (i.e. unstage it), leaving the worktree file exactly as it
14//!   is. This touches only `.mkit/index`, never the worktree, so it
15//!   needs no dirty guard and no `--force`.
16//! - **`--staged --worktree`** (both) — unstage AND discard the worktree
17//!   edit in one step (worktree restored from `HEAD` since the staged
18//!   entry is being reset to `HEAD` too); the dirty guard still applies
19//!   to the worktree half.
20//! - **`--source <rev>`** — take the restored content from `<rev>`'s tree
21//!   (resolved via the shared revspec resolver) instead of the index/HEAD
22//!   default.
23//!
24//! A path that names a directory restores every tracked entry at or
25//! below it. Restoring the worktree never *removes* extra files; it only
26//! rewrites the named tracked paths from the source tree.
27
28use 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    /// Restore the index entry (unstage) instead of, or in addition to,
50    /// the worktree file. When given alone the worktree is left
51    /// untouched; combine with `--worktree` to do both.
52    #[arg(short = 'S', long)]
53    staged: bool,
54
55    /// Restore the worktree file. This is the implicit default unless
56    /// `--staged` is given; pass it explicitly to restore both the index
57    /// and the worktree (`--staged --worktree`).
58    #[arg(short = 'W', long)]
59    worktree: bool,
60
61    /// Take the restored content from this revision's tree instead of
62    /// the default source (the index for `--worktree`, `HEAD` for
63    /// `--staged`). Accepts the shared revspec grammar (branch, tag,
64    /// `HEAD`, full/short hash, `~n`/`^`).
65    #[arg(long, value_name = "REV")]
66    source: Option<String>,
67
68    /// Overwrite worktree files even when they differ from the source
69    /// (otherwise locally-modified files are refused to avoid data loss).
70    #[arg(short = 'f', long)]
71    force: bool,
72
73    /// Paths to restore. A directory path restores every tracked entry
74    /// at or below it.
75    #[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    // Default target selection: with no flag (or only `--worktree`) we
103    // restore the worktree; `--staged` alone restores the index only;
104    // `--staged --worktree` does both.
105    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    // HEAD's tree (None on an unborn branch) — the source for `--staged`
114    // unstaging, and the worktree source when a path is not in the index.
115    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    // Resolve an explicit `--source <rev>` to its tree once. When set it
121    // overrides both the index (worktree restore) and HEAD (unstage).
122    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    // Build the per-path index snapshot we restore the index from /
131    // against. For unstaging this is `--source` (when given) or HEAD; for
132    // the worktree-source fallback it is the same. Computing it once keeps
133    // the path lookups O(1) per arg.
134    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    // Resolve every pathspec to a repo-relative path.
140    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
170/// Resolve `--source <rev>` to a tree hash via the shared resolver.
171fn 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
190/// The index snapshot the restore sources from: `--source`'s tree when
191/// supplied, else HEAD's tree. `None` means "no source" (unborn HEAD and
192/// no `--source`), in which case unstaging removes the entry and the
193/// worktree-source fallback finds nothing.
194fn 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
208/// `--staged`: reset each matching index entry to the source snapshot
209/// (HEAD/`--source`). A path present in the source becomes its source
210/// entry; a path absent from the source is dropped from the index
211/// (it was newly staged, so unstaging removes it entirely).
212fn 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        // Every path that exists in either side and is at-or-below `rel`.
233        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
255/// Overwrite (or remove) the index entry for `path` from `source`.
256fn apply_index_restore(idx: &mut Index, path: &str, source: Option<IndexEntry>) {
257    match source {
258        Some(src) => idx.upsert_entry(src),
259        None => {
260            // Not present in the source: unstaging removes it from the
261            // index entirely (it was a freshly-staged add).
262            idx.remove_path(path);
263        }
264    }
265}
266
267/// Worktree restore: for each matching tracked path, rewrite the worktree
268/// file from the source tree. Refuses (unless `force`) to clobber a
269/// worktree file whose content diverges from the *index* entry, mirroring
270/// the #176 destructive-restore guards.
271fn 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    // The source for the worktree content: an explicit `--source`/HEAD
281    // snapshot when one is set, otherwise the live index.
282    let source = if explicit_source {
283        restore_index.unwrap_or(idx)
284    } else {
285        idx
286    };
287
288    // Gather the tracked source entries each pathspec selects.
289    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    // Dirty guard (unless --force): refuse to overwrite a worktree file
304    // that diverges from its *index* entry — that is an un-staged edit
305    // the user would lose. A path absent from the index, or whose
306    // worktree content matches the index, is safe to rewrite.
307    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    // Materialise each selected path from the source tree using the
316    // existing restore machinery (chunked-blob reassembly, exec-bit and
317    // symlink handling, escape checks). We restore from the source tree
318    // with `clean: false` (never delete neighbours) and a sparse pattern
319    // anchored to exactly the selected paths.
320    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
347/// Tracked entries (status != Removed) at or below `rel`.
348fn 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
358/// Return `Some(reason)` when the worktree file for `path` exists but
359/// diverges from its staged (index) blob — the un-staged edit a worktree
360/// restore would silently discard. Mirrors `rm`'s dirty check.
361fn 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        // No worktree file (or a non-file): nothing to clobber.
378        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
389/// Hash a symlink target as a blob (matching `worktree`/`add` semantics).
390fn symlink_blob_hash(target: &str) -> Option<Hash> {
391    // Pure content-addressing — change detection must not write to the
392    // store. Byte layout pinned to serialize() via blob_prologue.
393    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
399/// Normalise a CLI path argument into a repo-relative index path.
400/// Mirrors `rm`'s resolver: absolute args are made relative to the repo
401/// root, `.`/`..` are folded, and the result is validated.
402fn 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;