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