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 calls
5//! `mkit_core::ops::restore::restore_tree_to_worktree`, which respects
6//! `.mkitignore` and rejects symlinks that would escape the repo root.
7
8use std::io::Write;
9
10use clap::Parser;
11use mkit_core::hash::Hash;
12use mkit_core::index::EntryStatus;
13use mkit_core::layout::RepoLayout;
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    /// Create a new branch at the start-point and switch to it
47    /// (`git checkout -b <new>`). Refuses to clobber an existing branch.
48    #[arg(short = 'b', value_name = "NEW", conflicts_with = "create_force")]
49    create: Option<String>,
50    /// Create-or-reset a branch at the start-point and switch to it
51    /// (`git checkout -B <new>`).
52    #[arg(short = 'B', value_name = "NEW")]
53    create_force: Option<String>,
54    /// Discard local changes that would block the switch, like
55    /// `git checkout -f`: skip the dirty-tracked/staged safety gate and
56    /// overwrite locally-modified tracked paths with the target's version.
57    /// Untracked files are still preserved. Used by `bisect run` to
58    /// materialize each candidate over the test command's scribbles.
59    #[arg(short = 'f', long = "force")]
60    force: bool,
61    /// Branch name, tag, or 64-char commit hash. With `-b`/`-B` this is
62    /// the optional start-point (defaults to HEAD).
63    target: Option<String>,
64}
65
66#[must_use]
67#[allow(clippy::too_many_lines)] // linear flow: create-branch + switch + report
68pub fn run(args: &[String]) -> u8 {
69    let opts = match clap_shim::parse::<CheckoutOpts>("mkit checkout", args) {
70        Ok(o) => o,
71        Err(code) => return code,
72    };
73    let cwd = match std::env::current_dir() {
74        Ok(p) => p,
75        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
76    };
77    let layout = match super::resolve_layout(&cwd) {
78        Ok(layout) => layout,
79        Err(code) => return code,
80    };
81    let store = match ObjectStore::open(&layout) {
82        Ok(s) => s,
83        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
84    };
85    // Registry lock first (global order, SPEC-WORKTREE §4.3): the
86    // branch-checked-out-elsewhere guard below and the HEAD write must
87    // be one atomic step against sibling checkouts and `worktree add`,
88    // or two racing processes could land one branch on two trees.
89    let _registry_lock = match super::acquire_worktrees_registry_lock(&layout) {
90        Ok(l) => l,
91        Err(code) => return code,
92    };
93    let _lock = match super::acquire_worktree_lock(&layout) {
94        Ok(l) => l,
95        Err(code) => return code,
96    };
97
98    // `-b`/`-B`: plan a branch create (or reset, for `-B`) at the
99    // start-point (the optional positional, default HEAD). The ref is NOT
100    // written here — only AFTER the destructive-restore gate passes — so a
101    // refused switch creates nothing (git atomicity). `reset_existing`
102    // tracks whether `-B` is resetting a pre-existing branch (→ git's
103    // `Reset branch …` message rather than `Switched to a new branch …`).
104    let create_new = opts.create.as_deref().or(opts.create_force.as_deref());
105    let create_plan: Option<(String, Hash, refs::RefWriteCondition, bool)> =
106        if let Some(new) = create_new {
107            let start_spec = opts.target.as_deref().unwrap_or("HEAD");
108            let start = match super::revspec::resolve_revision(&store, &layout, start_spec) {
109                Ok(h) => h,
110                Err(e) => {
111                    return emit_err(
112                        &format!("invalid start point '{start_spec}': {e}"),
113                        exit::GENERAL_ERROR,
114                    );
115                }
116            };
117            let existed = matches!(refs::read_ref(&layout, new), Ok(Some(_)));
118            if existed && opts.create_force.is_none() {
119                return emit_err(&format!("branch '{new}' already exists"), exit::CANTCREAT);
120            }
121            let cond = if opts.create_force.is_some() {
122                refs::RefWriteCondition::Any
123            } else {
124                refs::RefWriteCondition::Missing
125            };
126            Some((
127                new.to_string(),
128                start,
129                cond,
130                existed && opts.create_force.is_some(),
131            ))
132        } else {
133            None
134        };
135    let created = create_plan.is_some();
136
137    let name_owned: String = match &create_plan {
138        Some((new, ..)) => new.clone(),
139        None => match opts.target.as_deref() {
140            Some(t) => t.to_string(),
141            None => {
142                return super::usage_error(
143                    "usage: mkit checkout [-b|-B <new>] <branch|tag|commit>",
144                );
145            }
146        },
147    };
148    let name = name_owned.as_str();
149
150    // Remember whether we were already on the requested branch so the
151    // final report can say `Already on '<name>'` for a no-op switch —
152    // WITHOUT short-circuiting the safety gate (a dirty same-branch
153    // checkout must still refuse, like mkit always has).
154    let already_on = matches!(
155        refs::read_head(&layout),
156        Ok(mkit_core::refs::Head::Branch(ref cur)) if cur == name
157    );
158
159    // Single-writer-per-branch across worktrees (#493): if this
160    // checkout would END on a branch (existing or being created),
161    // refuse when a sibling tree already has it checked out — branch
162    // moves flow through the history-MMR ref path, which assumes one
163    // writer per branch. Applies to `--force` too, like git.
164    let ends_on_branch = created || matches!(refs::read_ref(&layout, name), Ok(Some(_)));
165    if ends_on_branch {
166        match super::branch_checked_out_elsewhere(&layout, name) {
167            Ok(Some(at)) => {
168                return emit_err(
169                    &format!(
170                        "branch '{name}' is already checked out at '{}'",
171                        at.display()
172                    ),
173                    exit::DATAERR,
174                );
175            }
176            Ok(None) => {}
177            Err(e) => return emit_err(&e, exit::DATAERR),
178        }
179    }
180
181    // The target commit: for `-b`/`-B` it is the (resolved) start-point;
182    // otherwise resolve `<name>` via the shared revspec resolver.
183    let commit_hash: Hash = match &create_plan {
184        Some((_, start, ..)) => *start,
185        None => match super::revspec::resolve_revision(&store, &layout, name) {
186            Ok(h) => h,
187            Err(e) => {
188                return emit_err(
189                    &format!("no such branch, tag, or commit: {name} ({e})"),
190                    exit::GENERAL_ERROR,
191                );
192            }
193        },
194    };
195
196    // Resolve the commit's tree so we can materialise it.
197    let tree_hash = match store.read_object(&commit_hash) {
198        Ok(Object::Commit(c)) => c.tree_hash,
199        Ok(Object::Remix(r)) => r.tree_hash,
200        Ok(_) => {
201            return emit_err(
202                &format!(
203                    "{} does not resolve to a commit or remix",
204                    format::short_hash(&commit_hash, 8)
205                ),
206                exit::GENERAL_ERROR,
207            );
208        }
209        Err(e) => return emit_err(&format!("read commit: {e}"), exit::GENERAL_ERROR),
210    };
211
212    // If `--sparse` was supplied, drive a verifiable sparse-checkout:
213    // build a manifest from the commit's tree, re-verify the
214    // delivered subset, cache the bitmap, then materialise with the
215    // restore-side sparse patterns set. Empty `opts.sparse` falls
216    // through to the full-tree restore below.
217    //
218    // `clean = false` everywhere: like git, switching branches PRESERVES
219    // untracked files. Tracked paths the target drops are deleted
220    // explicitly below (same pattern as `reset --hard`), so the restore
221    // itself never sweeps the worktree.
222    #[cfg(feature = "sparse-checkout")]
223    let sparse_opts: RestoreOptions = if opts.sparse.is_empty() {
224        RestoreOptions {
225            clean: false,
226            sparse_patterns: None,
227        }
228    } else {
229        match prepare_sparse_restore(&layout, &store, tree_hash, &opts.sparse) {
230            Ok(o) => o,
231            Err((msg, code)) => return emit_err(&msg, code),
232        }
233    };
234    #[cfg(not(feature = "sparse-checkout"))]
235    let sparse_opts: RestoreOptions = RestoreOptions {
236        clean: false,
237        sparse_patterns: None,
238    };
239
240    // Run the destructive-restore safety gate (#176) BEFORE touching
241    // anything. This is read-only — it refuses the checkout if dirty
242    // tracked files, staged changes, or untracked-path collisions with
243    // the target tree would be clobbered. Untracked files that do NOT
244    // collide with the target are preserved (git branch-switch
245    // semantics), so they no longer block the checkout.
246    // `--force` (git checkout -f) skips the gate, discarding local edits.
247    if !opts.force
248        && let Err(e) =
249            super::ensure_restore_safe_with_options(&layout, &store, tree_hash, &sparse_opts)
250    {
251        return emit_err(&e, exit::GENERAL_ERROR);
252    }
253
254    // Tracked paths the target drops — removed explicitly after
255    // materialising (the `clean = false` restore never deletes). Refuses
256    // first if any of them carries local edits (unless `--force`).
257    let dropped = match dropped_paths_guarded(&layout, &store, tree_hash, &sparse_opts, opts.force)
258    {
259        Ok(d) => d,
260        Err(code) => return code,
261    };
262
263    // Safety gate passed — NOW create the `-b`/`-B` branch ref. Deferring
264    // it to here means a refused switch above leaves no orphan branch
265    // behind (git creates nothing when it refuses the operation).
266    if let Some((new, start, cond, _)) = &create_plan {
267        match super::write_ref_recording_history(&layout, new, *cond, start) {
268            Ok(()) => {}
269            Err(refs::RefError::Conflict(_)) => {
270                return emit_err(&format!("branch '{new}' already exists"), exit::CANTCREAT);
271            }
272            Err(e) => return emit_err(&format!("create branch {new}: {e}"), exit::CANTCREAT),
273        }
274    }
275
276    // Update HEAD FIRST, before mutating the worktree/index (#223). The
277    // failure modes are asymmetric: if we materialised the new tree and
278    // *then* HEAD failed to advance, the worktree would hold the new
279    // branch's files while HEAD still pointed at the old branch — a
280    // silent, hard-to-diagnose split. Writing HEAD first inverts the
281    // hazard: a subsequent worktree/index failure leaves HEAD on the new
282    // branch with a stale worktree, which `mkit status` surfaces as
283    // ordinary local changes and a re-run of `mkit checkout` repairs.
284    // The `ensure_restore_safe` gate above already guaranteed no real
285    // user work is at risk, so the stale-worktree window is benign.
286    let is_branch = matches!(refs::read_ref(&layout, name), Ok(Some(_)));
287    let head_err = if is_branch {
288        refs::write_head_branch(&layout, name)
289    } else {
290        refs::write_head_detached(&layout, &commit_hash)
291    };
292    if let Err(e) = head_err {
293        return emit_err(&format!("update HEAD: {e}"), exit::CANTCREAT);
294    }
295
296    // Materialise the tree with `clean = false`: tracked entries are
297    // written/overwritten, untracked files are preserved. Then delete
298    // the tracked paths the target drops (computed above) and prune any
299    // directories that became empty — git removes those on a branch
300    // switch; `fs::remove_dir` only succeeds on EMPTY dirs, so a dir
301    // still holding untracked files survives.
302    let report = match restore_tree_to_worktree(&store, &tree_hash, &cwd, &sparse_opts) {
303        Ok(r) => r,
304        Err(e) => return emit_err(&format!("restore: {e}"), exit::CANTCREAT),
305    };
306    if let Err(code) = remove_dropped(&cwd, &dropped) {
307        return code;
308    }
309    if let Err(e) = super::sync_index_to_tree(&layout, &store, tree_hash) {
310        return emit_err(&e, exit::CANTCREAT);
311    }
312
313    // git-shaped switch confirmation (drop mkit's non-git restored-count
314    // line). `report` is no longer printed; keep the binding consumed.
315    let _ = &report;
316    let reset_existing = matches!(&create_plan, Some((.., true)));
317    let mut stderr = std::io::stderr().lock();
318    if is_branch {
319        if reset_existing {
320            let _ = writeln!(stderr, "Reset branch '{name}'");
321        } else if created {
322            let _ = writeln!(stderr, "Switched to a new branch '{name}'");
323        } else if already_on {
324            let _ = writeln!(stderr, "Already on '{name}'");
325        } else {
326            let _ = writeln!(stderr, "Switched to branch '{name}'");
327        }
328    } else {
329        let _ = writeln!(
330            stderr,
331            "HEAD is now at {} {}",
332            format::short_hash(&commit_hash, format::SUMMARY_ABBREV),
333            super::commit_subject(&store, &commit_hash),
334        );
335    }
336    exit::OK
337}
338
339use super::error as emit_err;
340
341/// Tracked paths the target drops — present in the current index but
342/// absent from the target tree. The `clean = false` restore never
343/// deletes, so `run` removes them explicitly after materialising.
344/// Restricted to the sparse cone so `--sparse` keeps its old reach.
345///
346/// Direct per-dropped-path dirty check (mirrors `reset --hard`): a
347/// locally-edited tracked file the target drops must never be deleted
348/// silently, even when an ignore rule hides it from the shared guard's
349/// worktree snapshot — refuses (returning the exit code) when one is
350/// found.
351fn dropped_paths_guarded(
352    layout: &RepoLayout,
353    store: &ObjectStore,
354    tree_hash: Hash,
355    opts: &RestoreOptions,
356    force: bool,
357) -> Result<Vec<(String, EntryStatus, Hash)>, u8> {
358    let dropped: Vec<(String, EntryStatus, Hash)> =
359        match super::dropped_tracked_paths(layout, store, tree_hash) {
360            Ok(all) => all
361                .into_iter()
362                .filter(|(path, _, _)| super::restore_affects_path(opts, path))
363                .collect(),
364            Err(e) => return Err(emit_err(&e, exit::GENERAL_ERROR)),
365        };
366    // `--force` overwrites/removes dropped paths regardless of local edits.
367    if force {
368        return Ok(dropped);
369    }
370    match super::locally_modified_dropped_path(layout.worktree_root(), store, &dropped) {
371        Ok(Some(path)) => Err(emit_err(
372            &format!(
373                "restore would overwrite local changes; commit, stash, or reset '{path}' first"
374            ),
375            exit::GENERAL_ERROR,
376        )),
377        Ok(None) => Ok(dropped),
378        Err(e) => Err(emit_err(&e, exit::GENERAL_ERROR)),
379    }
380}
381
382/// Delete the dropped tracked paths from the worktree and prune any
383/// parent directories that became empty.
384fn remove_dropped(
385    cwd: &std::path::Path,
386    dropped: &[(String, EntryStatus, Hash)],
387) -> Result<(), u8> {
388    for (path, _, _) in dropped {
389        if let Err(e) = super::remove_dropped_path(&cwd.join(path)) {
390            return Err(emit_err(
391                &format!("restore: remove {path}: {e}"),
392                exit::CANTCREAT,
393            ));
394        }
395        prune_empty_parents(cwd, path);
396    }
397    Ok(())
398}
399
400/// After deleting the dropped tracked file at repo-relative `rel_path`,
401/// remove its parent directories bottom-up while they are empty.
402/// `fs::remove_dir` refuses non-empty directories, so a parent still
403/// holding untracked (or ignored) files is left untouched, and the walk
404/// stops at the first survivor. Errors are deliberately swallowed — a
405/// leftover empty directory is cosmetic, never data loss.
406fn prune_empty_parents(root: &std::path::Path, rel_path: &str) {
407    let mut dir = std::path::Path::new(rel_path).parent();
408    while let Some(d) = dir {
409        if d.as_os_str().is_empty() {
410            break;
411        }
412        if std::fs::remove_dir(root.join(d)).is_err() {
413            break;
414        }
415        dir = d.parent();
416    }
417}
418
419/// Drive the verifiable sparse-checkout pipeline for `tree_hash`
420/// against the supplied path-prefix patterns:
421///
422/// 1. Read the top-level tree from `store`.
423/// 2. Translate the CLI `--sparse <pattern>...` argv into both
424///    (a) a flat `Vec<PathBuf>` filter the sparse module understands,
425///    and
426///    (b) a `Vec<SparsePattern>` the restore code understands.
427/// 3. Call `build_sparse` → `verify_sparse` (the round-trip catches a
428///    self-inconsistency at the seam).
429/// 4. Persist the bitmap under `.mkit/sparse/<tree-hex>.bitmap`.
430/// 5. Return the `RestoreOptions` the caller hands to
431///    `restore_tree_to_worktree`.
432///
433/// On any failure, returns `(message, exit_code)` so the caller can
434/// thread it back through the existing `emit_err` plumbing.
435#[cfg(feature = "sparse-checkout")]
436fn prepare_sparse_restore(
437    layout: &RepoLayout,
438    store: &ObjectStore,
439    tree_hash: Hash,
440    patterns: &[String],
441) -> Result<RestoreOptions, (String, u8)> {
442    use crate::sparse_cache::{SparseBuildError, SparseOutcome, load_or_build};
443    use mkit_core::object::Object as CoreObject;
444    use mkit_core::ops::restore::parse_sparse_patterns;
445    use std::path::PathBuf;
446
447    let tree = match store.read_object(&tree_hash) {
448        Ok(CoreObject::Tree(t)) => t,
449        Ok(_) => {
450            return Err((
451                "checkout: HEAD does not resolve to a tree".to_string(),
452                exit::DATAERR,
453            ));
454        }
455        Err(e) => return Err((format!("read tree: {e}"), exit::GENERAL_ERROR)),
456    };
457
458    // The sparse module's filter is a flat list of `PathBuf` prefixes.
459    // The restore code's pattern grammar additionally supports `!`
460    // negation and `/`-anchored matches; we translate the CLI argv
461    // into both representations so the manifest's filter binding sees
462    // a stable canonical form while the restore code keeps its
463    // existing semantics. Negated patterns are excluded from the
464    // sparse-module filter (they're a worktree-side exclusion, not a
465    // server-side inclusion), but still flow through to the restore
466    // step so the user's intent survives.
467    let mut filter: Vec<PathBuf> = Vec::with_capacity(patterns.len());
468    for raw in patterns {
469        let trimmed = raw.trim_start_matches('/');
470        let trimmed = trimmed.trim_end_matches('/');
471        if trimmed.is_empty() || trimmed.starts_with('!') {
472            continue;
473        }
474        filter.push(PathBuf::from(trimmed));
475    }
476
477    // Cache-aware self-consistency round-trip: a cache hit for this
478    // exact (tree, filter) skips the expensive build_sparse +
479    // verify_sparse Merkle-bitmap reconstruction entirely
480    // (SPEC-SPARSE-CHECKOUT §8). A miss (including a stale filter or a
481    // corrupt cache entry) falls through to a fresh build — the local
482    // equivalent of "server delivers manifest, client checks it",
483    // catching a regression in either side without standing up a
484    // transport — and rewrites the cache.
485    match load_or_build(layout, &tree, &filter) {
486        Ok(SparseOutcome::CacheHit) => {}
487        Ok(SparseOutcome::Built { store_error }) => {
488            if let Some(e) = store_error {
489                let mut stderr = std::io::stderr().lock();
490                let _ = writeln!(stderr, "warning: sparse cache write failed: {e}");
491            }
492        }
493        Err(SparseBuildError::Build(e)) => {
494            return Err((format!("sparse build: {e}"), exit::GENERAL_ERROR));
495        }
496        Err(SparseBuildError::VerifyFailed) => {
497            return Err((
498                "sparse build produced a manifest that fails verify".to_string(),
499                exit::GENERAL_ERROR,
500            ));
501        }
502    }
503
504    // Translate the CLI patterns into the restore-side pattern grammar.
505    // `clean = false`: untracked files inside the sparse cone are
506    // preserved (same branch-switch semantics as the full-tree path);
507    // tracked paths the target drops are deleted explicitly by `run`.
508    let joined = patterns.join("\n");
509    let parsed = parse_sparse_patterns(&joined);
510    Ok(RestoreOptions {
511        clean: false,
512        sparse_patterns: Some(parsed),
513    })
514}