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}