Skip to main content

mkit_cli/commands/
checkout.rs

1//! `mkit checkout <branch>` — switch HEAD to a branch and materialise
2//! the branch tip's tree into the working directory.
3//!
4//! The file-restoration half was previously a Phase 10 follow-up; this
5//! wire-up calls `mkit_core::ops::restore::restore_tree_to_worktree`
6//! which respects `.mkitignore` and rejects symlinks that would escape
7//! the repo root.
8
9use std::io::Write;
10
11use clap::Parser;
12use mkit_core::hash::Hash;
13use mkit_core::index::EntryStatus;
14use mkit_core::object::Object;
15use mkit_core::ops::restore::{RestoreOptions, restore_tree_to_worktree};
16use mkit_core::refs;
17use mkit_core::store::ObjectStore;
18
19use crate::clap_shim;
20use crate::exit;
21use crate::format;
22
23#[derive(Debug, Parser)]
24#[command(
25    name = "mkit checkout",
26    about = "Switch HEAD to a branch (or tag / commit hash) and restore files."
27)]
28struct CheckoutOpts {
29    /// One or more path-prefix patterns selecting a subset of the
30    /// commit's tree. Each pattern is interpreted the same way the
31    /// `mkit sparse-checkout` config patterns are — a leading `/` is
32    /// stripped, a trailing `/` marks a directory-only match, and `!`
33    /// negates. Repeat the flag to add more patterns.
34    ///
35    /// When supplied, `mkit checkout` builds a verifiable sparse
36    /// manifest from the commit's top-level tree (via
37    /// `mkit_core::sparse::build_sparse`), re-runs the verifier on the
38    /// delivered subset, caches the bitmap under
39    /// `.mkit/sparse/<tree-hex>.bitmap`, and materialises only the
40    /// matching files. The patterns are NOT persisted to
41    /// `.mkit/sparse-checkout` — use `mkit sparse-checkout set` for
42    /// that.
43    #[cfg(feature = "sparse-checkout")]
44    #[arg(long = "sparse", value_name = "PATTERN", num_args = 1..)]
45    sparse: Vec<String>,
46    /// Branch name, tag, or 64-char commit hash.
47    target: String,
48}
49
50#[must_use]
51pub fn run(args: &[String]) -> u8 {
52    let opts = match clap_shim::parse::<CheckoutOpts>("mkit checkout", args) {
53        Ok(o) => o,
54        Err(code) => return code,
55    };
56    let name = &opts.target;
57    let cwd = match std::env::current_dir() {
58        Ok(p) => p,
59        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
60    };
61    let mkit_dir = cwd.join(mkit_core::MKIT_DIR);
62    let store = match ObjectStore::open(&cwd) {
63        Ok(s) => s,
64        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
65    };
66    let _lock = match super::acquire_worktree_lock(&cwd) {
67        Ok(l) => l,
68        Err(code) => return code,
69    };
70
71    // Resolve <name> through the shared revspec resolver (branch / tag /
72    // HEAD / full+short hash / `~n`/`^` navigation).
73    let commit_hash: Hash = match super::revspec::resolve_revision(&store, &mkit_dir, name) {
74        Ok(h) => h,
75        Err(e) => {
76            return emit_err(
77                &format!("no such branch, tag, or commit: {name} ({e})"),
78                exit::GENERAL_ERROR,
79            );
80        }
81    };
82
83    // Resolve the commit's tree so we can materialise it.
84    let tree_hash = match store.read_object(&commit_hash) {
85        Ok(Object::Commit(c)) => c.tree_hash,
86        Ok(Object::Remix(r)) => r.tree_hash,
87        Ok(_) => {
88            return emit_err(
89                &format!(
90                    "{} does not resolve to a commit or remix",
91                    format::short_hash(&commit_hash, 8)
92                ),
93                exit::GENERAL_ERROR,
94            );
95        }
96        Err(e) => return emit_err(&format!("read commit: {e}"), exit::GENERAL_ERROR),
97    };
98
99    // If `--sparse` was supplied, drive a verifiable sparse-checkout:
100    // build a manifest from the commit's tree, re-verify the
101    // delivered subset, cache the bitmap, then materialise with the
102    // restore-side sparse patterns set. Empty `opts.sparse` falls
103    // through to the full-tree restore below.
104    //
105    // `clean = false` everywhere: like git, switching branches PRESERVES
106    // untracked files. Tracked paths the target drops are deleted
107    // explicitly below (same pattern as `reset --hard`), so the restore
108    // itself never sweeps the worktree.
109    #[cfg(feature = "sparse-checkout")]
110    let sparse_opts: RestoreOptions = if opts.sparse.is_empty() {
111        RestoreOptions {
112            clean: false,
113            sparse_patterns: None,
114        }
115    } else {
116        match prepare_sparse_restore(&cwd, &store, tree_hash, &opts.sparse) {
117            Ok(o) => o,
118            Err((msg, code)) => return emit_err(&msg, code),
119        }
120    };
121    #[cfg(not(feature = "sparse-checkout"))]
122    let sparse_opts: RestoreOptions = RestoreOptions {
123        clean: false,
124        sparse_patterns: None,
125    };
126
127    // Run the destructive-restore safety gate (#176) BEFORE touching
128    // anything. This is read-only — it refuses the checkout if dirty
129    // tracked files, staged changes, or untracked-path collisions with
130    // the target tree would be clobbered. Untracked files that do NOT
131    // collide with the target are preserved (git branch-switch
132    // semantics), so they no longer block the checkout.
133    if let Err(e) = super::ensure_restore_safe_with_options(&cwd, &store, tree_hash, &sparse_opts) {
134        return emit_err(&e, exit::GENERAL_ERROR);
135    }
136
137    // Tracked paths the target drops — removed explicitly after
138    // materialising (the `clean = false` restore never deletes). Refuses
139    // first if any of them carries local edits.
140    let dropped = match dropped_paths_guarded(&cwd, &store, tree_hash, &sparse_opts) {
141        Ok(d) => d,
142        Err(code) => return code,
143    };
144
145    // Update HEAD FIRST, before mutating the worktree/index (#223). The
146    // failure modes are asymmetric: if we materialised the new tree and
147    // *then* HEAD failed to advance, the worktree would hold the new
148    // branch's files while HEAD still pointed at the old branch — a
149    // silent, hard-to-diagnose split. Writing HEAD first inverts the
150    // hazard: a subsequent worktree/index failure leaves HEAD on the new
151    // branch with a stale worktree, which `mkit status` surfaces as
152    // ordinary local changes and a re-run of `mkit checkout` repairs.
153    // The `ensure_restore_safe` gate above already guaranteed no real
154    // user work is at risk, so the stale-worktree window is benign.
155    let is_branch = matches!(refs::read_ref(&mkit_dir, name), Ok(Some(_)));
156    let head_err = if is_branch {
157        refs::write_head_branch(&mkit_dir, name)
158    } else {
159        refs::write_head_detached(&mkit_dir, &commit_hash)
160    };
161    if let Err(e) = head_err {
162        return emit_err(&format!("update HEAD: {e}"), exit::CANTCREAT);
163    }
164
165    // Materialise the tree with `clean = false`: tracked entries are
166    // written/overwritten, untracked files are preserved. Then delete
167    // the tracked paths the target drops (computed above) and prune any
168    // directories that became empty — git removes those on a branch
169    // switch; `fs::remove_dir` only succeeds on EMPTY dirs, so a dir
170    // still holding untracked files survives.
171    let report = match restore_tree_to_worktree(&store, &tree_hash, &cwd, &sparse_opts) {
172        Ok(r) => r,
173        Err(e) => return emit_err(&format!("restore: {e}"), exit::CANTCREAT),
174    };
175    if let Err(code) = remove_dropped(&cwd, &dropped) {
176        return code;
177    }
178    if let Err(e) = super::sync_index_to_tree(&cwd, &store, tree_hash) {
179        return emit_err(&e, exit::CANTCREAT);
180    }
181
182    let mut stderr = std::io::stderr().lock();
183    if is_branch {
184        let _ = writeln!(stderr, "switched to branch {name}");
185    } else {
186        let _ = writeln!(
187            stderr,
188            "switched to detached {}",
189            format::short_hash(&commit_hash, 8)
190        );
191    }
192    let _ = writeln!(
193        stderr,
194        "  {} file(s), {} dir(s), {} symlink(s) restored",
195        report.files_written, report.directories_created, report.symlinks_written,
196    );
197    exit::OK
198}
199
200fn emit_err(msg: &str, code: u8) -> u8 {
201    let mut stderr = std::io::stderr().lock();
202    let _ = writeln!(stderr, "error: {msg}");
203    code
204}
205
206/// Tracked paths the target drops — present in the current index but
207/// absent from the target tree. The `clean = false` restore never
208/// deletes, so `run` removes them explicitly after materialising.
209/// Restricted to the sparse cone so `--sparse` keeps its old reach.
210///
211/// Direct per-dropped-path dirty check (mirrors `reset --hard`): a
212/// locally-edited tracked file the target drops must never be deleted
213/// silently, even when an ignore rule hides it from the shared guard's
214/// worktree snapshot — refuses (returning the exit code) when one is
215/// found.
216fn dropped_paths_guarded(
217    cwd: &std::path::Path,
218    store: &ObjectStore,
219    tree_hash: Hash,
220    opts: &RestoreOptions,
221) -> Result<Vec<(String, EntryStatus, Hash)>, u8> {
222    let dropped: Vec<(String, EntryStatus, Hash)> =
223        match super::dropped_tracked_paths(cwd, store, tree_hash) {
224            Ok(all) => all
225                .into_iter()
226                .filter(|(path, _, _)| super::restore_affects_path(opts, path))
227                .collect(),
228            Err(e) => return Err(emit_err(&e, exit::GENERAL_ERROR)),
229        };
230    match super::locally_modified_dropped_path(cwd, store, &dropped) {
231        Ok(Some(path)) => Err(emit_err(
232            &format!(
233                "restore would overwrite local changes; commit, stash, or reset '{path}' first"
234            ),
235            exit::GENERAL_ERROR,
236        )),
237        Ok(None) => Ok(dropped),
238        Err(e) => Err(emit_err(&e, exit::GENERAL_ERROR)),
239    }
240}
241
242/// Delete the dropped tracked paths from the worktree and prune any
243/// parent directories that became empty.
244fn remove_dropped(
245    cwd: &std::path::Path,
246    dropped: &[(String, EntryStatus, Hash)],
247) -> Result<(), u8> {
248    for (path, _, _) in dropped {
249        if let Err(e) = super::remove_dropped_path(&cwd.join(path)) {
250            return Err(emit_err(
251                &format!("restore: remove {path}: {e}"),
252                exit::CANTCREAT,
253            ));
254        }
255        prune_empty_parents(cwd, path);
256    }
257    Ok(())
258}
259
260/// After deleting the dropped tracked file at repo-relative `rel_path`,
261/// remove its parent directories bottom-up while they are empty.
262/// `fs::remove_dir` refuses non-empty directories, so a parent still
263/// holding untracked (or ignored) files is left untouched, and the walk
264/// stops at the first survivor. Errors are deliberately swallowed — a
265/// leftover empty directory is cosmetic, never data loss.
266fn prune_empty_parents(root: &std::path::Path, rel_path: &str) {
267    let mut dir = std::path::Path::new(rel_path).parent();
268    while let Some(d) = dir {
269        if d.as_os_str().is_empty() {
270            break;
271        }
272        if std::fs::remove_dir(root.join(d)).is_err() {
273            break;
274        }
275        dir = d.parent();
276    }
277}
278
279/// Drive the verifiable sparse-checkout pipeline for `tree_hash`
280/// against the supplied path-prefix patterns:
281///
282/// 1. Read the top-level tree from `store`.
283/// 2. Translate the CLI `--sparse <pattern>...` argv into both
284///    (a) a flat `Vec<PathBuf>` filter the sparse module understands,
285///    and
286///    (b) a `Vec<SparsePattern>` the restore code understands.
287/// 3. Call `build_sparse` → `verify_sparse` (the round-trip catches a
288///    self-inconsistency at the seam).
289/// 4. Persist the bitmap under `.mkit/sparse/<tree-hex>.bitmap`.
290/// 5. Return the `RestoreOptions` the caller hands to
291///    `restore_tree_to_worktree`.
292///
293/// On any failure, returns `(message, exit_code)` so the caller can
294/// thread it back through the existing `emit_err` plumbing.
295#[cfg(feature = "sparse-checkout")]
296fn prepare_sparse_restore(
297    cwd: &std::path::Path,
298    store: &ObjectStore,
299    tree_hash: Hash,
300    patterns: &[String],
301) -> Result<RestoreOptions, (String, u8)> {
302    use mkit_core::object::Object as CoreObject;
303    use mkit_core::ops::restore::parse_sparse_patterns;
304    use mkit_core::sparse::{build_sparse, verify_sparse};
305    use std::path::PathBuf;
306
307    let tree = match store.read_object(&tree_hash) {
308        Ok(CoreObject::Tree(t)) => t,
309        Ok(_) => {
310            return Err((
311                "checkout: HEAD does not resolve to a tree".to_string(),
312                exit::DATAERR,
313            ));
314        }
315        Err(e) => return Err((format!("read tree: {e}"), exit::GENERAL_ERROR)),
316    };
317
318    // The sparse module's filter is a flat list of `PathBuf` prefixes.
319    // The restore code's pattern grammar additionally supports `!`
320    // negation and `/`-anchored matches; we translate the CLI argv
321    // into both representations so the manifest's filter binding sees
322    // a stable canonical form while the restore code keeps its
323    // existing semantics. Negated patterns are excluded from the
324    // sparse-module filter (they're a worktree-side exclusion, not a
325    // server-side inclusion), but still flow through to the restore
326    // step so the user's intent survives.
327    let mut filter: Vec<PathBuf> = Vec::with_capacity(patterns.len());
328    for raw in patterns {
329        let trimmed = raw.trim_start_matches('/');
330        let trimmed = trimmed.trim_end_matches('/');
331        if trimmed.is_empty() || trimmed.starts_with('!') {
332            continue;
333        }
334        filter.push(PathBuf::from(trimmed));
335    }
336
337    // Self-consistency round-trip: build the manifest, then verify
338    // the delivered subset against it. This is the local equivalent
339    // of "server delivers manifest, client checks it" — it catches a
340    // regression in either side without standing up a transport.
341    let (delivered, manifest, proof) = build_sparse(&tree, &filter)
342        .map_err(|e| (format!("sparse build: {e}"), exit::GENERAL_ERROR))?;
343    if !verify_sparse(&manifest, &delivered, &filter, &proof) {
344        return Err((
345            "sparse build produced a manifest that fails verify".to_string(),
346            exit::GENERAL_ERROR,
347        ));
348    }
349
350    // Persist to the on-disk cache. Errors are non-fatal — a missing
351    // cache just means the next checkout re-runs the verifier — but
352    // we surface them on stderr so the user knows the persistent
353    // optimisation is broken.
354    if let Err(e) = crate::sparse_cache::store(cwd, &manifest.tree_hash, &manifest, &proof) {
355        let mut stderr = std::io::stderr().lock();
356        let _ = writeln!(stderr, "warning: sparse cache write failed: {e}");
357    }
358
359    // Translate the CLI patterns into the restore-side pattern grammar.
360    // `clean = false`: untracked files inside the sparse cone are
361    // preserved (same branch-switch semantics as the full-tree path);
362    // tracked paths the target drops are deleted explicitly by `run`.
363    let joined = patterns.join("\n");
364    let parsed = parse_sparse_patterns(&joined);
365    Ok(RestoreOptions {
366        clean: false,
367        sparse_patterns: Some(parsed),
368    })
369}