Skip to main content

mkit_cli/commands/
mod.rs

1//! Subcommand implementations. Each top-level command is its own
2//! module.
3//!
4//! Dispatch lives in `main.rs`; business logic lives in library
5//! crates; this module is the thin presentation shim.
6
7pub mod add;
8pub mod attest;
9pub mod attest_factory;
10pub mod bisect;
11pub mod blame;
12pub mod branch;
13pub mod cat;
14pub mod cat_file;
15pub mod checkout;
16pub mod cherry_pick;
17pub mod clean;
18pub mod clone;
19pub mod commit;
20pub mod config_cmd;
21pub mod conflict;
22pub mod diff;
23pub mod fetch;
24pub mod for_each_ref;
25pub mod gc;
26#[cfg(feature = "git-bridge")]
27pub mod git;
28#[cfg(feature = "git-bridge")]
29pub mod git_import;
30#[cfg(feature = "git-bridge")]
31pub mod git_tools;
32pub mod hash_cmd;
33pub mod init;
34pub mod key;
35pub mod keygen;
36pub mod log;
37pub mod ls_files;
38pub mod ls_tree;
39pub mod mcp;
40pub mod merge;
41pub mod merge_base;
42pub mod mv;
43#[cfg(feature = "pack-shards")]
44pub mod pack_shard;
45pub mod pull;
46pub mod push;
47pub mod rebase;
48pub mod ref_cmd;
49pub mod reflog;
50pub mod remote;
51pub mod reset;
52pub mod restore;
53pub mod rev_list;
54pub mod rev_parse;
55pub mod revert;
56pub mod revspec;
57pub mod rm;
58pub mod self_update;
59pub mod serve;
60pub mod show;
61pub mod show_ref;
62pub mod sparse_checkout;
63pub mod stash;
64pub mod status;
65pub mod summary;
66pub mod switch;
67pub mod symbolic_ref;
68pub mod tag;
69pub mod tree;
70pub mod trust;
71pub mod trust_roots;
72pub mod update_ref;
73pub mod verify;
74pub mod verify_attest;
75pub mod worktree;
76
77use crate::exit;
78use mkit_core::hash::Hash;
79use mkit_core::index::{EntryStatus, Index};
80use mkit_core::layout::RepoLayout;
81use mkit_core::object::Object;
82use mkit_core::ops::diff::{DiffKind, diff_trees};
83use mkit_core::ops::recovery::{self, RecoveryEntry};
84use mkit_core::ops::restore::{RestoreOptions, matches_sparse, restore_tree_to_worktree};
85use mkit_core::refs::{self, Head, RefError, RefWriteCondition};
86use mkit_core::store::ObjectStore;
87use mkit_core::worktree as core_worktree;
88use std::fs;
89use std::io::Write;
90use std::path::Path;
91
92/// Open the object store for a mutating command, honoring the repo's
93/// configured durability schedule (`durability.objects`, see
94/// [`crate::config::Config::object_sync_policy`]). Falls back to the
95/// First line of a commit/remix message (empty string on any read
96/// failure). Shared by `checkout`'s detached-HEAD report and `blame`'s
97/// porcelain `summary` field so the "subject" extraction can't drift.
98pub(crate) fn commit_subject(store: &ObjectStore, commit: &Hash) -> String {
99    let msg = match store.read_object(commit) {
100        Ok(Object::Commit(c)) => c.message,
101        _ => return String::new(),
102    };
103    String::from_utf8_lossy(&msg)
104        .lines()
105        .next()
106        .unwrap_or("")
107        .to_owned()
108}
109
110/// batched default when the config cannot be read — a broken config
111/// must not change write semantics silently, and Batch is the default
112/// contract.
113pub fn open_store_configured(
114    layout: &RepoLayout,
115) -> Result<ObjectStore, mkit_core::store::StoreError> {
116    let mut store = ObjectStore::open(layout)?;
117    if let Ok(cfg) = crate::config::read_or_default(layout) {
118        store.set_sync_policy(cfg.object_sync_policy());
119    }
120    Ok(store)
121}
122
123/// Read an object's serialised bytes from `store`, mapping a failure to
124/// the `(message, exit-code)` shape commands return. Shared by `attest`,
125/// `git`'s `publish_attestations`, and `git_import`'s `mint_attestations`
126/// — each needs a commit's raw bytes (not just its hash) to compute the
127/// attestation subject's paired `sha256` digest (SPEC-ATTESTATIONS
128/// §4.2), and previously duplicated this read-and-format-error shape
129/// independently.
130pub(crate) fn read_object_bytes(store: &ObjectStore, hash: &Hash) -> Result<Vec<u8>, (String, u8)> {
131    store.read(hash).map_err(|e| {
132        (
133            format!("read {}: {e}", mkit_core::hash::to_hex(hash)),
134            exit::GENERAL_ERROR,
135        )
136    })
137}
138
139/// Resolve the [`RepoLayout`] a command operates on (#493 Phase 1):
140/// pointer-following discovery. A `.mkit` DIRECTORY (or none at all)
141/// resolves to the classic single-worktree layout exactly as before; a
142/// `.mkit` pointer FILE resolves to the linked tree's split layout. On
143/// a broken pointer the error has already been printed and the
144/// returned code is the exit status to propagate — a broken linked
145/// tree must never silently operate on the wrong directory. Command
146/// code must obtain its layout HERE and never construct one ad hoc.
147pub fn resolve_layout(cwd: &Path) -> Result<RepoLayout, u8> {
148    mkit_core::layout::discover(cwd)
149        .map_err(|e| error(&format!("worktree discovery: {e}"), exit::DATAERR))
150}
151
152/// Shared helper: emit a "not yet wired" notice and return the
153/// tempfail exit code. Commands whose backing state-machines haven't
154/// been wired into the CLI yet say so honestly rather than pretending
155/// to work.
156#[must_use]
157pub fn not_yet_ported(cmd: &str) -> u8 {
158    let mut stderr = std::io::stderr().lock();
159    let _ = writeln!(stderr, "error: `mkit {cmd}` is not yet wired");
160    exit::TEMPFAIL
161}
162
163/// Shared helper: print a usage error and return the USAGE exit code.
164#[must_use]
165pub fn usage_error(msg: &str) -> u8 {
166    let mut stderr = std::io::stderr().lock();
167    let _ = writeln!(stderr, "error: {msg}");
168    exit::USAGE
169}
170
171/// Shared helper: print `error: <msg>` to stderr and return `code`.
172///
173/// This is the single source of truth for the `error: …`-prefixed
174/// stderr channel used by every subcommand. It generalises
175/// [`usage_error`] (which hardcodes [`exit::USAGE`]) to an arbitrary
176/// exit code so command modules don't each carry their own copy.
177#[must_use]
178pub(crate) fn error(msg: &str, code: u8) -> u8 {
179    let mut stderr = std::io::stderr().lock();
180    let _ = writeln!(stderr, "error: {msg}");
181    code
182}
183
184/// Load the tree hash of a commit object, surfacing a CLI error code.
185///
186/// Shared by the `cherry-pick`/`revert`/`merge` replay+rollback paths,
187/// which all need the tree of a resolved commit before restoring it.
188///
189/// # Errors
190/// Returns [`exit::DATAERR`] if the object is not a commit, or
191/// [`exit::GENERAL_ERROR`] if it cannot be read.
192pub(crate) fn load_tree_hash(store: &ObjectStore, commit_hash: Hash) -> Result<Hash, u8> {
193    match store.read_object(&commit_hash) {
194        Ok(Object::Commit(c)) => Ok(c.tree_hash),
195        Ok(_) => Err(error("object is not a commit", exit::DATAERR)),
196        Err(e) => Err(error(&format!("read commit: {e}"), exit::GENERAL_ERROR)),
197    }
198}
199
200/// Point the current branch (or detached HEAD) at `new_head`, routing a
201/// branch advance through the history-MMR helper.
202///
203/// Shared by `cherry-pick`/`revert`/`merge`. Unlike the historical
204/// per-command copies, a failure to read HEAD is propagated as an error
205/// rather than silently fabricating `Head::Branch("main")` and writing
206/// the commit pointer to the wrong (or a non-existent) `main` ref.
207///
208/// # Errors
209/// Returns a human-readable message if HEAD cannot be read or the ref
210/// write fails.
211pub(crate) fn advance_head(layout: &RepoLayout, new_head: &Hash) -> Result<(), String> {
212    let head = refs::read_head(layout).map_err(|e| format!("read HEAD: {e}"))?;
213    match head {
214        Head::Branch(name) => {
215            write_ref_recording_history(layout, &name, RefWriteCondition::Any, new_head)
216                .map_err(|e| format!("write ref: {e}"))
217        }
218        Head::Detached(_) => {
219            refs::write_head_detached(layout, new_head).map_err(|e| format!("update HEAD: {e}"))
220        }
221    }
222}
223
224/// Restore the current branch (or detached HEAD) to `target` as the
225/// final step of a conflict `--abort`/rollback.
226///
227/// Shared by `cherry-pick`/`revert`/`merge` `restore_to`. As with
228/// [`advance_head`], an unreadable HEAD is reported as an error instead
229/// of defaulting to `main` — a corrupted HEAD during `--abort` must not
230/// silently clobber/create a `main` branch.
231///
232/// # Errors
233/// Returns a CLI exit code (already printed via [`error`]) on failure.
234pub(crate) fn restore_head_ref(layout: &RepoLayout, target: &Hash) -> Result<(), u8> {
235    let head =
236        refs::read_head(layout).map_err(|e| error(&format!("read HEAD: {e}"), exit::DATAERR))?;
237    match head {
238        Head::Branch(name) => {
239            write_ref_recording_history(layout, &name, RefWriteCondition::Any, target)
240                .map_err(|e| error(&format!("restore ref: {e}"), exit::CANTCREAT))
241        }
242        Head::Detached(_) => refs::write_head_detached(layout, target)
243            .map_err(|e| error(&format!("restore HEAD: {e}"), exit::CANTCREAT)),
244    }
245}
246
247/// Basename of the repo-level lock that serialises worktree/index
248/// read-modify-write commands (`add`, `rm`, `commit`, `merge`,
249/// `checkout`, `rebase`, `cherry-pick`, `stash`, `sparse-checkout`).
250///
251/// Ref-only mutations (`branch`/`tag`) and config-only mutations do not
252/// take this lock — they rely on ref-CAS / atomic-config writes instead.
253pub const WORKTREE_LOCK: &str = "worktree.lock";
254
255/// Acquire the shared worktree/index lock for this worktree.
256///
257/// Hold the returned guard across the whole read-modify-write so a
258/// second mutating `mkit` blocks (then times out) instead of racing on
259/// the worktree + `.mkit/index`. On failure, the lock message has
260/// already been printed to stderr and the returned [`u8`] is the exit
261/// code to propagate.
262///
263/// Mirrors the pattern already used in `sparse_checkout` and
264/// `remote_dispatch`; new mutating commands should reuse this helper
265/// rather than calling `repo_lock::acquire_default` directly.
266///
267/// # Errors
268/// Returns [`exit::TEMPFAIL`] when the lock cannot be taken within the
269/// default timeout (another `mkit` holds it, or a stale lockfile is
270/// present).
271pub fn acquire_worktree_lock(layout: &RepoLayout) -> Result<mkit_core::repo_lock::RepoLock, u8> {
272    // Per-worktree state: the lock serialises THIS tree's worktree/
273    // index mutations (#493 Phase 3 adds a separate shared lock for
274    // store/refs/gc mutation).
275    mkit_core::repo_lock::acquire_default(layout.worktree_state_dir(), WORKTREE_LOCK).map_err(|e| {
276        let mut stderr = std::io::stderr().lock();
277        let _ = writeln!(stderr, "error: repo lock: {e}");
278        exit::TEMPFAIL
279    })
280}
281
282/// Basename of the common-dir lock serialising linked-worktree
283/// registry mutations (`worktree add`/`remove`/`prune`), the
284/// branch-checkout guard + HEAD-write critical sections
285/// (`checkout`/`switch`, `branch -d`/`-m`), and gc's freeze of the
286/// worktree set. Distinct from [`WORKTREE_LOCK`], which guards ONE
287/// tree's worktree/index state.
288///
289/// GLOBAL LOCK ORDER (SPEC-WORKTREE §4.3): a process that takes more
290/// than one of these MUST acquire in this order —
291/// `worktrees.lock` ≺ per-tree `worktree.lock`(s) ≺
292/// `refs-history.lock` — or two multi-lock takers can stall each
293/// other until the 5s timeout.
294pub const WORKTREES_REGISTRY_LOCK: &str = "worktrees.lock";
295
296/// Acquire the shared worktree-registry lock (common dir).
297///
298/// # Errors
299/// [`exit::TEMPFAIL`] when the lock cannot be taken (message already
300/// printed), mirroring [`acquire_worktree_lock`].
301pub fn acquire_worktrees_registry_lock(
302    layout: &RepoLayout,
303) -> Result<mkit_core::repo_lock::RepoLock, u8> {
304    mkit_core::repo_lock::acquire_default(layout.common_dir(), WORKTREES_REGISTRY_LOCK).map_err(
305        |e| {
306            let mut stderr = std::io::stderr().lock();
307            let _ = writeln!(stderr, "error: worktree registry lock: {e}");
308            exit::TEMPFAIL
309        },
310    )
311}
312
313/// Every worktree of `layout`'s repository as `(tree root, layout)`
314/// pairs: the main tree first, then each healthy linked tree from the
315/// registry. Broken (prunable) registry entries are skipped — they
316/// have no live HEAD to consult; `worktree prune` reaps them.
317///
318/// # Errors
319/// A human-readable message when the registry cannot be enumerated
320/// (fail closed: a caller consulting sibling HEADs must not treat an
321/// unreadable registry as "no siblings").
322pub(crate) fn all_worktree_layouts(
323    layout: &RepoLayout,
324) -> Result<Vec<(std::path::PathBuf, RepoLayout)>, String> {
325    let mut out = Vec::new();
326    if let Some(main_root) = layout.common_dir().parent() {
327        out.push((main_root.to_path_buf(), RepoLayout::single(main_root)));
328    }
329    for wt in mkit_core::layout::worktrees(layout).map_err(|e| format!("worktree registry: {e}"))? {
330        if wt.prunable.is_some() {
331            continue;
332        }
333        let Some(tree_root) = wt.tree_root else {
334            continue;
335        };
336        out.push((
337            tree_root.clone(),
338            RepoLayout::linked(tree_root, wt.state_dir, layout.common_dir()),
339        ));
340    }
341    Ok(out)
342}
343
344/// The tree (other than the invoking one) that has `branch` checked
345/// out, if any. Branch moves are single-writer-per-branch (the
346/// history-MMR journal assumes it), so `checkout`/`switch`/`worktree
347/// add` refuse to put one branch on two trees, and `branch -d`/`-m`
348/// refuse to pull a branch out from under a sibling tree.
349///
350/// # Errors
351/// Propagates registry/HEAD read failures as a message — fail closed.
352pub(crate) fn branch_checked_out_elsewhere(
353    layout: &RepoLayout,
354    branch: &str,
355) -> Result<Option<std::path::PathBuf>, String> {
356    let self_state = layout
357        .worktree_state_dir()
358        .canonicalize()
359        .unwrap_or_else(|_| layout.worktree_state_dir().to_path_buf());
360    for (tree_root, candidate) in all_worktree_layouts(layout)? {
361        let candidate_state = candidate
362            .worktree_state_dir()
363            .canonicalize()
364            .unwrap_or_else(|_| candidate.worktree_state_dir().to_path_buf());
365        if candidate_state == self_state {
366            continue; // the invoking tree itself
367        }
368        match refs::read_head(&candidate) {
369            Ok(Head::Branch(name)) if name == branch => return Ok(Some(tree_root)),
370            // A sibling with no HEAD yet (mid-add) holds no branch.
371            Ok(_) | Err(RefError::NoHead) => {}
372            Err(e) => {
373                return Err(format!(
374                    "read HEAD of worktree at {}: {e}",
375                    tree_root.display()
376                ));
377            }
378        }
379    }
380    Ok(None)
381}
382
383/// C-style-quote `path` the way Git does for porcelain / `--name-*`
384/// output when a path contains bytes that need escaping. Returns `None`
385/// when the path is "plain" (all printable ASCII except `"`/`\`) and can
386/// be emitted as-is. Shared by `status` and `diff --name-only/-status`.
387///
388/// Quoting rule (matches Git's `quote_c_style` with the default
389/// `core.quotePath=true`): quote if any byte is a control char (`< 0x20`),
390/// `"`, `\`, or non-printable / non-ASCII (`>= 0x7f`). Inside the quotes,
391/// the common control chars use their `\a\b\t\n\v\f\r` escapes, `"` and
392/// `\` are backslash-escaped, printable ASCII is literal, and everything
393/// else is a 3-digit `\NNN` octal escape (per UTF-8 byte).
394pub(crate) fn c_quote_path(path: &str) -> Option<String> {
395    let bytes = path.as_bytes();
396    let needs = bytes
397        .iter()
398        .any(|&b| b < 0x20 || b == b'"' || b == b'\\' || b >= 0x7f);
399    if !needs {
400        return None;
401    }
402    let mut out = String::with_capacity(bytes.len() + 2);
403    out.push('"');
404    for &b in bytes {
405        match b {
406            0x07 => out.push_str("\\a"),
407            0x08 => out.push_str("\\b"),
408            0x09 => out.push_str("\\t"),
409            0x0a => out.push_str("\\n"),
410            0x0b => out.push_str("\\v"),
411            0x0c => out.push_str("\\f"),
412            0x0d => out.push_str("\\r"),
413            b'"' => out.push_str("\\\""),
414            b'\\' => out.push_str("\\\\"),
415            0x20..=0x7e => out.push(b as char),
416            other => {
417                use std::fmt::Write as _;
418                let _ = write!(out, "\\{other:03o}");
419            }
420        }
421    }
422    out.push('"');
423    Some(out)
424}
425
426/// Resolve a CLI path argument to a repo-relative, `/`-separated index
427/// path, validating it. Shared by `rm` and `mv` so both resolve and
428/// validate pathspecs identically (absolute args are mapped under the
429/// repo root, `.`/`..` are normalized, and the result is checked against
430/// [`mkit_core::index::validate_index_path`]).
431pub(crate) fn index_path_for_arg(root: &Path, arg: &Path) -> Result<String, String> {
432    use std::path::Component;
433    let rel = if arg.is_absolute() {
434        absolute_arg_to_repo_relative(root, arg)?
435    } else {
436        arg.to_path_buf()
437    };
438
439    let mut parts: Vec<String> = Vec::new();
440    for component in rel.as_path().components() {
441        match component {
442            Component::Normal(part) => {
443                let part = part
444                    .to_str()
445                    .ok_or_else(|| "path is not valid UTF-8".to_string())?;
446                parts.push(part.to_string());
447            }
448            Component::CurDir => {}
449            Component::ParentDir => {
450                if parts.pop().is_none() {
451                    return Err(format!("invalid path: {}", arg.display()));
452                }
453            }
454            Component::Prefix(_) | Component::RootDir => {
455                return Err(format!("invalid path: {}", arg.display()));
456            }
457        }
458    }
459
460    let path = parts.join("/");
461    if !mkit_core::index::validate_index_path(&path) {
462        return Err(format!("invalid path: {path}"));
463    }
464    Ok(path)
465}
466
467/// Map an absolute path argument to a path relative to the repo `root`,
468/// erroring if it escapes the repository. Handles not-yet-existing tail
469/// components (the leaf may not exist yet, e.g. an `mv` destination).
470pub(crate) fn absolute_arg_to_repo_relative(
471    root: &Path,
472    arg: &Path,
473) -> Result<std::path::PathBuf, String> {
474    use std::ffi::OsString;
475    let root = root.canonicalize().map_err(|e| format!("repo root: {e}"))?;
476
477    if let Ok(rel) = arg.strip_prefix(&root) {
478        return Ok(rel.to_path_buf());
479    }
480
481    let mut suffix: Vec<OsString> = vec![
482        arg.file_name()
483            .ok_or_else(|| format!("invalid path: {}", arg.display()))?
484            .to_os_string(),
485    ];
486    let mut ancestor = arg
487        .parent()
488        .ok_or_else(|| format!("invalid path: {}", arg.display()))?;
489    while ancestor.symlink_metadata().is_err() {
490        let name = ancestor
491            .file_name()
492            .ok_or_else(|| format!("path is outside repository: {}", arg.display()))?;
493        suffix.push(name.to_os_string());
494        ancestor = ancestor
495            .parent()
496            .ok_or_else(|| format!("path is outside repository: {}", arg.display()))?;
497    }
498
499    let mut normalized = ancestor
500        .canonicalize()
501        .map_err(|e| format!("path {}: {e}", ancestor.display()))?;
502    for component in suffix.iter().rev() {
503        normalized.push(component);
504    }
505
506    normalized
507        .strip_prefix(&root)
508        .map(Path::to_path_buf)
509        .map_err(|_| format!("path is outside repository: {}", arg.display()))
510}
511
512/// The worktree's current staged representation `(status, hash)` for
513/// `path`: a regular file (with its exec bit), a symlink (blob of its
514/// target), or `None` when the path is missing or not a stageable type
515/// (e.g. a directory). Mirrors how `add` stages one entry, so a caller can
516/// compare a worktree path to an index entry by **content AND mode/type** —
517/// catching symlink-target and chmod-only changes that a content-only hash
518/// would miss.
519pub(crate) fn worktree_entry_state(
520    root: &Path,
521    store: &ObjectStore,
522    path: &str,
523) -> Result<Option<(EntryStatus, Hash)>, String> {
524    let abs = root.join(path);
525    let meta = match abs.symlink_metadata() {
526        Ok(m) => m,
527        Err(e)
528            if matches!(
529                e.kind(),
530                std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
531            ) =>
532        {
533            return Ok(None);
534        }
535        Err(e) => return Err(format!("metadata {}: {e}", abs.display())),
536    };
537    if meta.file_type().is_file() {
538        let (opened_meta, bytes) = core_worktree::read_regular_file_bounded(&abs)
539            .map_err(|e| format!("read {}: {e}", abs.display()))?;
540        let h =
541            core_worktree::store_file_object(store, &bytes).map_err(|e| format!("store: {e}"))?;
542        Ok(Some((file_exec_status(&opened_meta), h)))
543    } else if meta.file_type().is_symlink() {
544        let target =
545            fs::read_link(&abs).map_err(|e| format!("read link {}: {e}", abs.display()))?;
546        let target_str = target
547            .to_str()
548            .ok_or_else(|| "symlink target is not valid UTF-8".to_string())?;
549        if !core_worktree::validate_symlink_target(target_str) {
550            return Err(format!("invalid symlink target: {target_str}"));
551        }
552        let blob = Object::Blob(mkit_core::object::Blob {
553            data: target_str.as_bytes().to_vec(),
554        });
555        let ser = mkit_core::serialize::serialize(&blob).map_err(|e| format!("serialize: {e}"))?;
556        let h = store.write(&ser).map_err(|e| format!("store: {e}"))?;
557        Ok(Some((EntryStatus::Symlink, h)))
558    } else {
559        Ok(None)
560    }
561}
562
563#[cfg(unix)]
564fn file_exec_status(meta: &fs::Metadata) -> EntryStatus {
565    use std::os::unix::fs::PermissionsExt;
566    if meta.permissions().mode() & 0o111 != 0 {
567        EntryStatus::Executable
568    } else {
569        EntryStatus::Blob
570    }
571}
572
573#[cfg(not(unix))]
574fn file_exec_status(_meta: &fs::Metadata) -> EntryStatus {
575    EntryStatus::Blob
576}
577
578pub(crate) fn index_path_matches_or_descends(path: &str, base: &str) -> bool {
579    path == base || index_path_descends_from(path, base)
580}
581
582pub(crate) fn index_path_descends_from(path: &str, base: &str) -> bool {
583    path.len() > base.len()
584        && path.starts_with(base)
585        && path.as_bytes().get(base.len()) == Some(&b'/')
586}
587
588// ---------------------------------------------------------------------------
589// History-MMR ref-write helper (feature: history-mmr)
590// ---------------------------------------------------------------------------
591//
592// Branch-ref history journaling (issue #157). Every CLI subcommand that
593// advances a branch ref MUST route the write through this helper instead of calling
594// `refs::write_ref` / `refs::update_ref` directly. Default builds
595// (no `history-mmr` feature) keep the old direct semantics; the
596// feature-gated path opens a per-branch journaled `CommitHistory`, takes
597// a single repo-level lock around (ref-write + MMR-append), and syncs
598// the journal to disk before returning.
599//
600// The executor is a **process-global** `Arc<TokioExecutor>` — we
601// construct exactly one per process via `OnceLock` so multiple branch
602// advances share one tokio runtime. Threading the executor through
603// every CLI helper would force `history-mmr` into the signature of
604// every subcommand entry point, so we keep it local to this module.
605
606/// Construct (lazily) and share the process-wide `TokioExecutor` used
607/// by every history-MMR-coupled ref write in the CLI.
608///
609/// One executor per process: each `TokioExecutor` owns a multi-thread
610/// tokio runtime, and re-constructing it per ref-write would burn a
611/// fresh runtime for every commit. The `OnceLock` is initialised on the
612/// first call; subsequent calls reuse the same `Arc` clone.
613#[cfg(feature = "history-mmr")]
614pub(crate) fn history_executor() -> std::sync::Arc<mkit_core::history::TokioExecutor> {
615    use std::sync::{Arc, OnceLock};
616    static EXECUTOR: OnceLock<Arc<mkit_core::history::TokioExecutor>> = OnceLock::new();
617    EXECUTOR
618        .get_or_init(|| {
619            let exec = mkit_core::history::TokioExecutor::new()
620                .expect("history-mmr tokio runtime must initialise");
621            Arc::new(exec)
622        })
623        .clone()
624}
625
626/// CLI-side ref-write helper that records every advance in the
627/// branch's history MMR when `history-mmr` is enabled.
628///
629/// Behaviour matrix:
630///
631/// - **Default build (no `history-mmr`)** — exactly equivalent to
632///   `refs::update_ref(mkit_dir, branch, condition, new_hash)`.
633/// - **`--features history-mmr`** — takes the `refs-history.lock`
634///   repo lock, THEN opens a journaled `CommitHistory` for `branch`
635///   under `<mkit_dir>/history/` (lock-then-open, not the reverse —
636///   see `mkit_core::refs::open_and_update_ref_with_history_and_backfill`'s
637///   doc comment for why), performs the CAS ref-write, appends
638///   `new_hash` to the MMR, and `sync()`s the journal before
639///   returning. The journal survives `SIGKILL` immediately after the
640///   call returns. See
641///   `mkit-core::refs::open_and_update_ref_with_history_and_backfill`
642///   and SPEC-HISTORY-PROOF §4 for the contract.
643///
644/// If the journal is empty but `branch` already has a ref value on
645/// disk (a v0.1.x-era repo enabling `history-mmr` for the first time,
646/// or a crash on the branch's very first tracked write), this backfills
647/// the full known chain via [`mkit_core::history::rebuild_from_chain`]
648/// before proceeding — SPEC-HISTORY-PROOF §4.5. The empty-journal check
649/// AND the backfill loop run inside
650/// [`mkit_core::refs::update_ref_with_history_and_backfill`]'s
651/// `refs-history.lock` critical section (issue #638 / INV-18): running
652/// them before the lock (as this used to) let two ref-only writers on
653/// the same never-before-journaled branch — e.g. two concurrent
654/// `update-ref` calls, which deliberately skip the worktree lock — both
655/// observe an empty journal and both independently backfill, corrupting
656/// the journal's leaf positions.
657///
658/// All CLI subcommands that move a branch ref MUST funnel through this
659/// helper rather than calling `refs::write_ref` or `refs::update_ref`
660/// directly. Detached-HEAD writes (`refs::write_head_detached`) are
661/// not history-tracked: the per-branch journal is keyed on the branch
662/// name, and detached HEADs have none.
663pub fn write_ref_recording_history(
664    layout: &RepoLayout,
665    branch: &str,
666    condition: RefWriteCondition,
667    new_hash: &Hash,
668) -> Result<(), RefError> {
669    #[cfg(feature = "history-mmr")]
670    {
671        let exec = history_executor();
672
673        // Opening the object store is read-only and touches none of the
674        // history-journal state that's actually racy here, so it's fine
675        // to do before the lock.
676        let store = ObjectStore::open(layout)
677            .map_err(|e| RefError::InvalidRef(format!("{branch}: open object store: {e}")))?;
678
679        // `open_and_update_ref_with_history_and_backfill` (not the
680        // open-then-call shape this used to have) acquires the
681        // per-branch lock BEFORE opening the journal, closing a race
682        // two concurrent first-writers on a never-before-journaled
683        // branch could hit: `CommitHistory::open_at` reads the on-disk
684        // metadata blob, and reading it while the OTHER thread is mid
685        // -`sync` (writing that same blob under its own lock hold) can
686        // observe a torn/zeroed blob and fail as "corrupt" even though
687        // nothing is actually wrong once the write finishes. See
688        // `mkit_core::refs::update_ref_with_history_critical_section`'s
689        // doc comment for the full mechanism.
690        refs::open_and_update_ref_with_history_and_backfill(
691            layout,
692            branch,
693            condition,
694            new_hash,
695            exec,
696            |h| match store.read_object(h) {
697                Ok(Object::Commit(c)) => Ok(c.parents.first().copied()),
698                Ok(Object::Remix(r)) => Ok(r.parents.first().copied()),
699                Ok(_) => Err(format!(
700                    "{}: object is not a commit or remix",
701                    mkit_core::hash::to_hex(h)
702                )),
703                Err(e) => Err(e.to_string()),
704            },
705        )
706    }
707    #[cfg(not(feature = "history-mmr"))]
708    {
709        refs::update_ref(layout, branch, condition, new_hash)
710    }
711}
712
713/// `mkit branch -d`/`-D` helper: deletes a branch ref and, on
714/// `--features history-mmr` builds, also destroys its history-MMR
715/// journal partition (issue #648). Refuses the checked-out branch, same
716/// as plain `refs::delete_ref_safe`.
717///
718/// Without this, a branch recreated under a previously-deleted name
719/// would reopen the dead incarnation's non-empty journal (the
720/// commonware partition is keyed on the sanitized branch name, not any
721/// per-incarnation identifier) and resume appending on top of its old
722/// leaves — the new branch's MMR root would then span two unrelated
723/// incarnations, and the deleted incarnation's stale leaves would keep
724/// producing valid-looking inclusion proofs "on this branch". See
725/// [`mkit_core::refs::delete_ref_safe_with_history`] for the full
726/// crash-ordering contract.
727///
728/// - **Default build (no `history-mmr`)** — exactly
729///   `refs::delete_ref_safe(layout, branch)`.
730/// - **`--features history-mmr`** — routes through
731///   [`mkit_core::refs::delete_ref_safe_with_history`], sharing the same
732///   process-global executor as [`write_ref_recording_history`].
733pub fn delete_ref_recording_history(layout: &RepoLayout, branch: &str) -> Result<(), RefError> {
734    #[cfg(feature = "history-mmr")]
735    {
736        refs::delete_ref_safe_with_history(layout, branch, history_executor())
737    }
738    #[cfg(not(feature = "history-mmr"))]
739    {
740        refs::delete_ref_safe(layout, branch)
741    }
742}
743
744/// `mkit branch -m` helper: deletes the OLD name's ref after a rename
745/// and, on `--features history-mmr` builds, also destroys its history-MMR
746/// journal partition (issue #648).
747///
748/// Unlike [`delete_ref_recording_history`], this does NOT refuse the
749/// checked-out branch — `branch -m` legitimately renames the current
750/// branch and moves HEAD to the new name immediately after this call.
751/// The NEW name's ref is created first by the caller (via
752/// [`write_ref_recording_history`], which seeds it with a fresh
753/// journal), so by the time this runs the old and new incarnations are
754/// already disjoint; this just makes sure the OLD name's journal is not
755/// left behind to be inherited by a future branch of the same name.
756///
757/// - **Default build (no `history-mmr`)** — exactly
758///   `refs::delete_ref(layout, branch)`.
759/// - **`--features history-mmr`** — routes through
760///   [`mkit_core::refs::delete_ref_with_history`].
761pub fn delete_ref_dropping_history(layout: &RepoLayout, branch: &str) -> Result<(), RefError> {
762    #[cfg(feature = "history-mmr")]
763    {
764        refs::delete_ref_with_history(layout, branch, history_executor())
765    }
766    #[cfg(not(feature = "history-mmr"))]
767    {
768        refs::delete_ref(layout, branch)
769    }
770}
771
772/// CAS-guarded sibling of [`delete_ref_dropping_history`] (issue #658):
773/// only deletes `branch` (and, on `--features history-mmr` builds,
774/// destroys its journal) if its current value is exactly `expected`.
775///
776/// `mkit branch -m` uses this — not the unconditional version — for
777/// BOTH the source-branch drop and, on a lost race, the rollback delete
778/// of the just-created destination: an unconditional delete here can't
779/// tell "the branch tip I read is still current" from "a concurrent
780/// `commit` just advanced it out from under me", so it would silently
781/// destroy the concurrently-landed commit's only ref. See
782/// [`mkit_core::refs::delete_ref_if_matches`] for the full race
783/// analysis.
784///
785/// - **Default build (no `history-mmr`)** — exactly
786///   `refs::delete_ref_if_matches(layout, branch, expected)`.
787/// - **`--features history-mmr`** — routes through
788///   [`mkit_core::refs::delete_ref_with_history_if_matches`], sharing
789///   the same process-global executor as [`write_ref_recording_history`].
790pub fn delete_ref_dropping_history_if_matches(
791    layout: &RepoLayout,
792    branch: &str,
793    expected: Hash,
794) -> Result<(), RefError> {
795    #[cfg(feature = "history-mmr")]
796    {
797        refs::delete_ref_with_history_if_matches(layout, branch, expected, history_executor())
798    }
799    #[cfg(not(feature = "history-mmr"))]
800    {
801        refs::delete_ref_if_matches(layout, branch, expected)
802    }
803}
804
805/// Current branch name for recovery logging — empty for a detached HEAD
806/// or an unreadable/symbolic-only HEAD.
807#[must_use]
808pub fn head_branch_name(layout: &RepoLayout) -> String {
809    match refs::read_head(layout) {
810        Ok(Head::Branch(name)) => name,
811        _ => String::new(),
812    }
813}
814
815/// Record `superseded` (the old branch tip a history-rewriting op is
816/// about to replace) in the recovery log so `mkit gc` keeps it
817/// recoverable.
818///
819/// Call this **before** moving the ref and while holding the worktree
820/// lock (every caller does both): recording first guarantees that a
821/// persisted ref move always has a persisted recovery entry, and the
822/// lock keeps a concurrent `recovery::expire` from clobbering the append.
823/// On failure the caller MUST abort the rewrite (propagate the returned
824/// error) rather than orphan an unrecoverable commit. The zero hash is a
825/// no-op inside [`recovery::record`].
826pub fn record_superseded(
827    layout: &RepoLayout,
828    op: &str,
829    branch: &str,
830    superseded: Hash,
831) -> Result<(), (String, u8)> {
832    let timestamp = std::time::SystemTime::now()
833        .duration_since(std::time::UNIX_EPOCH)
834        .map_or(0, |d| d.as_secs());
835    let entry = RecoveryEntry {
836        timestamp,
837        op: op.to_owned(),
838        superseded,
839        branch: branch.to_owned(),
840    };
841    recovery::record(layout, &entry).map_err(|e| (format!("recovery log: {e}"), exit::CANTCREAT))
842}
843
844/// Rewrite `.mkit/index` so it exactly mirrors `tree_hash`.
845///
846/// `mkit commit` now signs the index, so commands that move HEAD and
847/// materialize a committed tree must keep the index aligned with that
848/// snapshot.
849pub fn sync_index_to_tree(
850    layout: &RepoLayout,
851    store: &ObjectStore,
852    tree_hash: Hash,
853) -> Result<(), String> {
854    let mut idx =
855        mkit_core::index::from_tree(store, tree_hash).map_err(|e| format!("index: {e}"))?;
856    // Tree-derived entries carry no stat cache. Carry it over from the
857    // outgoing index wherever path AND object hash agree: a later stat
858    // match against the old observation still proves the same bytes,
859    // so commit/checkout don't wipe the O(stat) fast path.
860    if let Ok(old) = mkit_core::index::read_index(layout) {
861        // O(1) lookups: find_entry is a linear scan and this loop runs
862        // once per tree entry (was O(n²) per commit/checkout).
863        let by_path: std::collections::HashMap<&str, &mkit_core::index::IndexEntry> =
864            old.entries.iter().map(|o| (o.path.as_str(), o)).collect();
865        for e in &mut idx.entries {
866            if let Some(o) = by_path.get(e.path.as_str())
867                && o.object_hash == e.object_hash
868                && o.status == e.status
869            {
870                e.mtime_ns = o.mtime_ns;
871                e.size = o.size;
872                e.ino = o.ino;
873                e.ctime_ns = o.ctime_ns;
874            }
875        }
876    }
877    mkit_core::index::write_index(layout, &idx).map_err(|e| format!("write index: {e}"))
878}
879
880/// After staging a `result_tree` (which, being a tree, omits removed paths),
881/// add `Removed` tombstones to the index for every path present in
882/// `base_tree` but absent from `result_tree`.
883///
884/// `sync_index_to_tree`/`restore_worktree_and_index` set the index from a
885/// tree, so a staged DELETION is silently dropped. Callers that stage a
886/// computed result without committing (e.g. `cherry-pick -n` / `revert -n`)
887/// use this so the deletion stays staged — otherwise an all-deletions result
888/// leaves an empty index and `mkit commit` rejects it as "nothing staged".
889pub fn stage_removed_tombstones(
890    layout: &RepoLayout,
891    store: &ObjectStore,
892    base_tree: Option<Hash>,
893    result_tree: Hash,
894) -> Result<(), String> {
895    let diff = diff_trees(store, base_tree, Some(result_tree))
896        .map_err(|e| format!("diff for staged deletions: {e}"))?;
897    let removed: Vec<String> = diff
898        .entries
899        .iter()
900        .filter(|e| e.kind == DiffKind::Removed)
901        .map(|e| e.path.clone())
902        .collect();
903    if removed.is_empty() {
904        return Ok(());
905    }
906    let mut idx = mkit_core::index::read_index(layout).map_err(|e| format!("read index: {e}"))?;
907    for path in removed {
908        match idx.find_entry(&path) {
909            Some(j) => {
910                idx.entries[j].status = EntryStatus::Removed;
911                idx.entries[j].object_hash = mkit_core::hash::ZERO;
912            }
913            None => idx.upsert_entry(mkit_core::index::IndexEntry {
914                path,
915                status: EntryStatus::Removed,
916                object_hash: mkit_core::hash::ZERO,
917                mtime_ns: 0,
918                size: 0,
919                ino: 0,
920                ctime_ns: 0,
921            }),
922        }
923    }
924    mkit_core::index::write_index(layout, &idx).map_err(|e| format!("write index: {e}"))
925}
926
927/// Materialise `tree_hash` and align the index while preserving `.mkitignore` entries.
928pub fn restore_worktree_and_index(
929    layout: &RepoLayout,
930    store: &ObjectStore,
931    tree_hash: Hash,
932) -> Result<(), String> {
933    restore_tree_to_worktree(
934        store,
935        &tree_hash,
936        layout.worktree_root(),
937        &RestoreOptions::default(),
938    )
939    .map_err(|e| format!("restore worktree: {e}"))?;
940    sync_index_to_tree(layout, store, tree_hash)
941}
942
943/// Refuse a destructive restore when the index/worktree contains user work.
944pub fn ensure_restore_safe(
945    layout: &RepoLayout,
946    store: &ObjectStore,
947    target_tree: Hash,
948) -> Result<(), String> {
949    ensure_restore_safe_with_options(layout, store, target_tree, &RestoreOptions::default())
950}
951
952/// Refuse a destructive restore when affected index/worktree paths contain user work.
953pub fn ensure_restore_safe_with_options(
954    layout: &RepoLayout,
955    store: &ObjectStore,
956    target_tree: Hash,
957    options: &RestoreOptions,
958) -> Result<(), String> {
959    let root = layout.worktree_root();
960    let current_tree = current_head_tree(layout, store)?;
961    let idx = read_or_seed_index_from_head(layout, store)?;
962    // Safety-check snapshot trees are ephemeral — in-memory overlay,
963    // no durability cost, no garbage objects in the store.
964    let snapshot = mkit_core::store::EphemeralSink::new(store);
965    let index_tree = core_worktree::build_tree_from_index_with(store, &snapshot, &idx, false)
966        .map_err(|e| format!("check index state: {e}"))?;
967
968    let staged = diff_trees(&snapshot, current_tree, Some(index_tree))
969        .map_err(|e| format!("check staged changes: {e}"))?;
970    if let Some(entry) = staged
971        .entries
972        .iter()
973        .find(|entry| restore_affects_path(options, &entry.path))
974    {
975        return Err(format!(
976            "restore would overwrite staged changes; commit, stash, or reset '{}' first",
977            entry.path
978        ));
979    }
980
981    let worktree_tree = core_worktree::build_tree_filtered(&snapshot, root, Some(&idx))
982        .map_err(|e| format!("check working tree changes: {e}"))?;
983    let unstaged = diff_trees(&snapshot, Some(index_tree), Some(worktree_tree))
984        .map_err(|e| format!("check working tree changes: {e}"))?;
985    if let Some(entry) = unstaged
986        .entries
987        .iter()
988        .find(|entry| entry.kind != DiffKind::Added && restore_affects_path(options, &entry.path))
989    {
990        return Err(format!(
991            "restore would overwrite local changes; commit, stash, or reset '{}' first",
992            entry.path
993        ));
994    }
995
996    let target_writes = diff_trees(&snapshot, Some(index_tree), Some(target_tree))
997        .map_err(|e| format!("check restore target: {e}"))?
998        .entries
999        .into_iter()
1000        .filter(|entry| entry.kind != DiffKind::Removed)
1001        .filter(|entry| restore_affects_path(options, &entry.path))
1002        .map(|entry| entry.path)
1003        .collect::<Vec<_>>();
1004    if target_writes.is_empty() && !options.clean {
1005        return Ok(());
1006    }
1007
1008    let ignore = mkit_core::ignore::load(root).map_err(|e| format!("read ignore file: {e}"))?;
1009    let mut worktree_paths = Vec::new();
1010    collect_worktree_paths(root, root, "", &mut worktree_paths)
1011        .map_err(|e| format!("check untracked paths: {e}"))?;
1012    if let Some(path) = worktree_paths.iter().find(|path| {
1013        !index_tracks_path_or_descendant(&idx, path)
1014            && target_writes
1015                .iter()
1016                .any(|target| paths_overlap(path, target))
1017    }) {
1018        return Err(format!(
1019            "restore would overwrite untracked path '{path}'; move or remove it first"
1020        ));
1021    }
1022
1023    if options.clean
1024        && let Some(path) = worktree_paths.iter().find(|path| {
1025            !index_tracks_path_or_descendant(&idx, path)
1026                && restore_affects_path(options, path)
1027                && *path != ".mkitignore"
1028                && *path != ".gitignore"
1029                && !is_ignored_worktree_path(root, &ignore, path)
1030        })
1031    {
1032        return Err(format!(
1033            "restore would remove untracked path '{path}'; move or remove it first"
1034        ));
1035    }
1036
1037    Ok(())
1038}
1039
1040pub(crate) fn restore_affects_path(options: &RestoreOptions, path: &str) -> bool {
1041    options
1042        .sparse_patterns
1043        .as_deref()
1044        .is_none_or(|patterns| matches_sparse(patterns, path, false))
1045}
1046
1047/// Tracked paths present in the current index but absent from the target
1048/// tree, each paired with its index entry's `(status, hash)` — for
1049/// destructive worktree moves (`reset --hard`, `checkout`) these files
1050/// are deleted explicitly (`restore_tree_to_worktree` with `clean =
1051/// false` writes/overwrites but never deletes). The `(status, hash)`
1052/// lets the caller detect local edits by content AND mode/type.
1053pub(crate) fn dropped_tracked_paths(
1054    layout: &RepoLayout,
1055    store: &ObjectStore,
1056    target_tree: Hash,
1057) -> Result<Vec<(String, EntryStatus, Hash)>, String> {
1058    let idx = read_or_seed_index_from_head(layout, store)?;
1059    let snapshot = mkit_core::store::EphemeralSink::new(store);
1060    let index_tree = core_worktree::build_tree_from_index_with(store, &snapshot, &idx, false)
1061        .map_err(|e| format!("index tree: {e}"))?;
1062    let mut out = Vec::new();
1063    for e in diff_trees(&snapshot, Some(index_tree), Some(target_tree))
1064        .map_err(|e| format!("diff index vs target: {e}"))?
1065        .entries
1066        .into_iter()
1067        .filter(|e| e.kind == DiffKind::Removed)
1068    {
1069        if let Some(entry) = idx
1070            .entries
1071            .iter()
1072            .find(|ie| ie.path == e.path && ie.status != EntryStatus::Removed)
1073        {
1074            out.push((e.path, entry.status, entry.object_hash));
1075        }
1076    }
1077    Ok(out)
1078}
1079
1080/// The first dropped path whose worktree entry differs from its indexed
1081/// `(status, hash)` — a local edit to content, mode (exec bit), or symlink
1082/// target. `None` if every dropped path is unmodified, missing, or a
1083/// directory (no file to lose). This is a direct per-dropped-path check, so
1084/// destructive moves never silently discard a local edit — independent of
1085/// how the shared worktree-snapshot guard treats ignored files.
1086pub(crate) fn locally_modified_dropped_path(
1087    cwd: &Path,
1088    store: &ObjectStore,
1089    dropped: &[(String, EntryStatus, Hash)],
1090) -> Result<Option<String>, String> {
1091    for (path, idx_status, idx_hash) in dropped {
1092        if let Some((wt_status, wt_hash)) = worktree_entry_state(cwd, store, path)?
1093            && (wt_status != *idx_status || wt_hash != *idx_hash)
1094        {
1095            return Ok(Some(path.clone()));
1096        }
1097    }
1098    Ok(None)
1099}
1100
1101/// Delete a dropped tracked path from the worktree. A regular file or
1102/// symlink is removed; a directory (untracked content that replaced the
1103/// tracked file) is LEFT in place rather than recursively deleted, and a
1104/// missing path is a no-op — so this never crashes on `IsADirectory` and
1105/// never nukes untracked directories.
1106pub(crate) fn remove_dropped_path(abs: &Path) -> std::io::Result<()> {
1107    match fs::symlink_metadata(abs) {
1108        Ok(meta) if meta.is_dir() => Ok(()),
1109        Ok(_) => fs::remove_file(abs),
1110        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1111        Err(e) => Err(e),
1112    }
1113}
1114
1115fn is_ignored_worktree_path(
1116    root: &Path,
1117    ignore: &mkit_core::ignore::IgnoreList,
1118    path: &str,
1119) -> bool {
1120    let full_path = root.join(path);
1121    let Ok(meta) = fs::symlink_metadata(&full_path) else {
1122        return false;
1123    };
1124    // Match on the repo-relative path, and treat a path under an ignored
1125    // directory as ignored too (no top-down walk here to carry that bit).
1126    ignore.is_ignored_with_ancestors(path, meta.is_dir())
1127}
1128
1129pub(crate) fn current_head_tree(
1130    layout: &RepoLayout,
1131    store: &ObjectStore,
1132) -> Result<Option<Hash>, String> {
1133    let Some(head_hash) = refs::resolve_head(layout).map_err(|e| format!("resolve HEAD: {e}"))?
1134    else {
1135        return Ok(None);
1136    };
1137    match store
1138        .read_object(&head_hash)
1139        .map_err(|e| format!("read HEAD: {e}"))?
1140    {
1141        Object::Commit(c) => Ok(Some(c.tree_hash)),
1142        Object::Remix(r) => Ok(Some(r.tree_hash)),
1143        _ => Err("HEAD does not resolve to a commit or remix".to_string()),
1144    }
1145}
1146
1147pub(crate) fn collect_worktree_paths(
1148    root: &Path,
1149    dir: &Path,
1150    prefix: &str,
1151    out: &mut Vec<String>,
1152) -> std::io::Result<()> {
1153    let read = match fs::read_dir(dir) {
1154        Ok(read) => read,
1155        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1156        Err(e) => return Err(e),
1157    };
1158    for entry in read {
1159        let entry = entry?;
1160        let name = entry.file_name();
1161        let Some(name) = name.to_str() else {
1162            continue;
1163        };
1164        if name.eq_ignore_ascii_case(".mkit") || name.eq_ignore_ascii_case(".git") {
1165            continue;
1166        }
1167        let path = if prefix.is_empty() {
1168            name.to_string()
1169        } else {
1170            format!("{prefix}/{name}")
1171        };
1172        out.push(path.clone());
1173        let full_path = root.join(&path);
1174        let meta = fs::symlink_metadata(&full_path)?;
1175        if meta.is_dir() {
1176            collect_worktree_paths(root, &full_path, &path, out)?;
1177        }
1178    }
1179    Ok(())
1180}
1181
1182pub(crate) fn index_tracks_path_or_descendant(index: &Index, path: &str) -> bool {
1183    // Delegates to `Index::tracks_path_or_descendant`, which answers via
1184    // the maintained `path -> position` map in `O(log n + k)` instead of
1185    // this function's old `O(n)` full scan (issue #708) — `add_tree` calls
1186    // this once per directory/file it walks.
1187    index.tracks_path_or_descendant(path)
1188}
1189
1190fn paths_overlap(left: &str, right: &str) -> bool {
1191    index_path_matches_or_descends(left, right) || index_path_descends_from(right, left)
1192}
1193
1194/// Read the index, seeding an absent/empty one from HEAD when possible.
1195///
1196/// This lets old repositories or manually removed indexes keep the
1197/// expected staging invariant: adding/removing one path starts from the
1198/// current commit snapshot instead of making the next commit forget all
1199/// unchanged tracked files.
1200pub fn read_or_seed_index_from_head(
1201    layout: &RepoLayout,
1202    store: &ObjectStore,
1203) -> Result<mkit_core::index::Index, String> {
1204    let idx = mkit_core::index::read_index(layout).map_err(|e| format!("read index: {e}"))?;
1205    if !idx.entries.is_empty() {
1206        return Ok(idx);
1207    }
1208
1209    let Some(head_hash) =
1210        mkit_core::refs::resolve_head(layout).map_err(|e| format!("resolve HEAD: {e}"))?
1211    else {
1212        return Ok(idx);
1213    };
1214    match store
1215        .read_object(&head_hash)
1216        .map_err(|e| format!("read HEAD: {e}"))?
1217    {
1218        Object::Commit(c) => mkit_core::index::from_tree(store, c.tree_hash)
1219            .map_err(|e| format!("index from HEAD: {e}")),
1220        Object::Remix(r) => mkit_core::index::from_tree(store, r.tree_hash)
1221            .map_err(|e| format!("index from HEAD: {e}")),
1222        _ => Err("HEAD does not resolve to a commit or remix".to_string()),
1223    }
1224}
1225
1226#[cfg(test)]
1227mod tests {
1228    use super::{advance_head, c_quote_path, restore_head_ref};
1229    use mkit_core::hash::Hash;
1230
1231    #[cfg(feature = "history-mmr")]
1232    fn write_commit(store: &mkit_core::store::ObjectStore, parents: Vec<Hash>, seed: u8) -> Hash {
1233        use mkit_core::object::{Commit, Identity, Object};
1234
1235        let commit = Commit::new_unannotated(
1236            [seed; 32],
1237            parents,
1238            Identity::ed25519([seed; 32]),
1239            [seed; 32],
1240            b"msg".to_vec(),
1241            0,
1242            [0u8; 64],
1243        );
1244        let bytes = mkit_core::serialize::serialize(&Object::Commit(commit)).unwrap();
1245        store.write(&bytes).unwrap()
1246    }
1247
1248    #[cfg(feature = "history-mmr")]
1249    #[test]
1250    fn write_ref_recording_history_backfills_v01x_style_repo_from_object_store() {
1251        use super::write_ref_recording_history;
1252        use mkit_core::history::{CommitHistory, Position, TokioExecutor, verify_inclusion};
1253        use mkit_core::refs::{self, RefWriteCondition};
1254        use mkit_core::store::ObjectStore;
1255        use std::sync::Arc;
1256
1257        let td = tempfile::tempdir().unwrap();
1258        let repo_root = td.path();
1259        let layout = mkit_core::layout::RepoLayout::single(repo_root);
1260        let store = ObjectStore::init(&layout).unwrap();
1261
1262        // Build a 3-commit chain entirely via the object store and point
1263        // `refs/heads/main` at the tip directly — simulating a repo
1264        // whose commits predate `history-mmr`: the ref exists, but
1265        // `<mkit_dir>/history/` has never been touched.
1266        let c0 = write_commit(&store, vec![], 1);
1267        let c1 = write_commit(&store, vec![c0], 2);
1268        let c2 = write_commit(&store, vec![c1], 3);
1269        refs::write_ref(&layout, "main", &c2).unwrap();
1270
1271        // The first history-mmr-enabled write for this branch: a new
1272        // commit c3 on top of the pre-existing tip c2.
1273        let c3 = write_commit(&store, vec![c2], 4);
1274        write_ref_recording_history(&layout, "main", RefWriteCondition::Match(c2), &c3).unwrap();
1275
1276        assert_eq!(refs::read_ref(&layout, "main").unwrap(), Some(c3));
1277
1278        // The journal must now hold the full backfilled chain (c0, c1,
1279        // c2) PLUS the new c3 — not just c3 alone.
1280        let exec = Arc::new(TokioExecutor::new().unwrap());
1281        let hist = CommitHistory::open_at(exec, &layout, "main").unwrap();
1282        assert_eq!(hist.len(), 4);
1283        let root = hist.root();
1284        for (i, c) in [c0, c1, c2, c3].into_iter().enumerate() {
1285            let pos = Position(i as u64);
1286            let proof = hist.prove(pos).unwrap();
1287            assert!(
1288                verify_inclusion(&c, pos, &proof, &root),
1289                "commit at position {i} failed inclusion proof after backfill"
1290            );
1291        }
1292    }
1293
1294    #[cfg(feature = "history-mmr")]
1295    #[test]
1296    fn write_ref_recording_history_does_not_backfill_a_genuinely_fresh_branch() {
1297        use super::write_ref_recording_history;
1298        use mkit_core::history::{CommitHistory, TokioExecutor};
1299        use mkit_core::refs::RefWriteCondition;
1300        use mkit_core::store::ObjectStore;
1301        use std::sync::Arc;
1302
1303        let td = tempfile::tempdir().unwrap();
1304        let repo_root = td.path();
1305        let layout = mkit_core::layout::RepoLayout::single(repo_root);
1306        let store = ObjectStore::init(&layout).unwrap();
1307
1308        // No pre-existing ref: this is a brand new branch's first ever
1309        // commit, not a v0.1.x migration. There is nothing to backfill.
1310        let c0 = write_commit(&store, vec![], 1);
1311        write_ref_recording_history(&layout, "main", RefWriteCondition::Missing, &c0).unwrap();
1312
1313        let exec = Arc::new(TokioExecutor::new().unwrap());
1314        let hist = CommitHistory::open_at(exec, &layout, "main").unwrap();
1315        assert_eq!(
1316            hist.len(),
1317            1,
1318            "only the one real write, no phantom backfill entries"
1319        );
1320    }
1321
1322    /// A long v0.1.x-style chain (ref exists on disk, journal never
1323    /// touched) — simulates an existing repo enabling `history-mmr` for
1324    /// the first time.
1325    #[cfg(feature = "history-mmr")]
1326    const CONCURRENT_BACKFILL_CHAIN_LEN: usize = 500;
1327
1328    /// INV-18 regression (issue #638): the empty-journal check and the
1329    /// entire backfill-from-object-store loop must run *inside*
1330    /// `refs-history.lock`, not before it. `update-ref`/`branch` calls
1331    /// deliberately skip the worktree lock, so two ref-only writers on
1332    /// the same never-before-journaled branch can both call this
1333    /// function concurrently. Pre-fix, both threads independently
1334    /// observe an empty journal (the check happens before any lock is
1335    /// taken) and both independently backfill the whole chain, landing
1336    /// duplicate leaves. Post-fix, only one of them may see the empty
1337    /// journal and perform the backfill; the other must see a
1338    /// non-empty journal once it acquires the lock and skip straight to
1339    /// its own append.
1340    ///
1341    /// The chain is long enough (500 commits) that the pre-fix unlocked
1342    /// backfill loop — which, before the fsync-batching fix also lands,
1343    /// syncs once per commit — takes long enough in wall-clock terms
1344    /// for both threads (released simultaneously via a barrier) to
1345    /// almost certainly overlap.
1346    #[cfg(feature = "history-mmr")]
1347    #[test]
1348    fn write_ref_recording_history_concurrent_backfill_does_not_duplicate_journal_leaves() {
1349        use super::write_ref_recording_history;
1350        use mkit_core::history::{CommitHistory, TokioExecutor};
1351        use mkit_core::refs::{self, RefWriteCondition};
1352        use mkit_core::store::ObjectStore;
1353        use std::sync::{Arc, Barrier};
1354
1355        let td = tempfile::tempdir().unwrap();
1356        let repo_root = td.path();
1357        let layout = Arc::new(mkit_core::layout::RepoLayout::single(repo_root));
1358        let store = ObjectStore::init(&layout).unwrap();
1359
1360        let mut tip: Option<Hash> = None;
1361        for seed in 0..CONCURRENT_BACKFILL_CHAIN_LEN {
1362            let seed = u8::try_from(seed % 256).expect("seed % 256 fits in u8");
1363            tip = Some(write_commit(&store, tip.into_iter().collect(), seed));
1364        }
1365        let tip = tip.unwrap();
1366        refs::write_ref(&layout, "main", &tip).unwrap();
1367
1368        // Two independent new commits, each racing to be the first
1369        // history-mmr-enabled write for this branch.
1370        let c_a = write_commit(&store, vec![tip], 250);
1371        let c_b = write_commit(&store, vec![tip], 251);
1372
1373        let barrier = Arc::new(Barrier::new(2));
1374
1375        let (layout_a, barrier_a) = (Arc::clone(&layout), Arc::clone(&barrier));
1376        let t_a = std::thread::spawn(move || {
1377            barrier_a.wait();
1378            write_ref_recording_history(&layout_a, "main", RefWriteCondition::Any, &c_a)
1379        });
1380        let (layout_b, barrier_b) = (Arc::clone(&layout), Arc::clone(&barrier));
1381        let t_b = std::thread::spawn(move || {
1382            barrier_b.wait();
1383            write_ref_recording_history(&layout_b, "main", RefWriteCondition::Any, &c_b)
1384        });
1385
1386        let res_a = t_a.join().expect("thread a must not panic");
1387        let res_b = t_b.join().expect("thread b must not panic");
1388        res_a.expect("writer a must succeed");
1389        res_b.expect("writer b must succeed");
1390
1391        let exec = Arc::new(TokioExecutor::new().unwrap());
1392        let hist = CommitHistory::open_at(exec, &layout, "main").unwrap();
1393        assert_eq!(
1394            hist.len(),
1395            CONCURRENT_BACKFILL_CHAIN_LEN as u64 + 2,
1396            "two concurrent first-writers on a never-journaled branch \
1397             must backfill the shared chain exactly once between them \
1398             (plus their own two real appends) — a leaf count above \
1399             this means the backfill ran twice and duplicated leaves"
1400        );
1401    }
1402
1403    #[test]
1404    fn c_quote_leaves_plain_paths_alone() {
1405        assert_eq!(c_quote_path("a.txt"), None);
1406        assert_eq!(c_quote_path("dir/with space.txt"), None); // space is plain
1407        assert_eq!(c_quote_path("weird-but-ascii_!@#$%.rs"), None);
1408    }
1409
1410    #[test]
1411    fn c_quote_escapes_special_bytes() {
1412        assert_eq!(c_quote_path("a\tb.txt").as_deref(), Some(r#""a\tb.txt""#));
1413        assert_eq!(
1414            c_quote_path("line\nfeed").as_deref(),
1415            Some(r#""line\nfeed""#)
1416        );
1417        assert_eq!(c_quote_path("q\"x").as_deref(), Some(r#""q\"x""#));
1418        assert_eq!(
1419            c_quote_path("back\\slash").as_deref(),
1420            Some(r#""back\\slash""#)
1421        );
1422    }
1423
1424    #[test]
1425    fn c_quote_octal_escapes_non_ascii() {
1426        // "é" is UTF-8 0xC3 0xA9 → \303\251 (matches git core.quotePath).
1427        assert_eq!(c_quote_path("é").as_deref(), Some(r#""\303\251""#));
1428        // Combined with ASCII: only the non-ASCII bytes are octal-escaped.
1429        assert_eq!(c_quote_path("x-é").as_deref(), Some(r#""x-\303\251""#));
1430    }
1431
1432    // Regression: the shared replay helpers must NOT fabricate
1433    // `Head::Branch("main")` when HEAD is unreadable/missing. A missing
1434    // HEAD previously caused cherry-pick/revert/merge (and especially the
1435    // `--abort` recovery path) to silently write the commit pointer to
1436    // `refs/heads/main`, clobbering or creating a `main` branch the user
1437    // never had. Both helpers must surface the read error instead.
1438
1439    #[test]
1440    fn advance_head_errors_when_head_missing_instead_of_writing_main() {
1441        let td = tempfile::tempdir().unwrap();
1442        let layout = mkit_core::layout::RepoLayout::single(td.path());
1443        // No HEAD file exists → refs::read_head returns NoHead.
1444        let new_head: Hash = [0x11; 32];
1445        let err = advance_head(&layout, &new_head).expect_err("missing HEAD must error");
1446        assert!(err.contains("read HEAD"), "unexpected error: {err}");
1447        // Crucially, no `main` ref was fabricated.
1448        assert!(
1449            !layout.heads_dir().join("main").exists(),
1450            "advance_head must not write refs/heads/main when HEAD is unreadable"
1451        );
1452    }
1453
1454    #[test]
1455    fn restore_head_ref_errors_when_head_missing_instead_of_writing_main() {
1456        let td = tempfile::tempdir().unwrap();
1457        let layout = mkit_core::layout::RepoLayout::single(td.path());
1458        let target: Hash = [0x22; 32];
1459        let code = restore_head_ref(&layout, &target).expect_err("missing HEAD must error");
1460        assert_eq!(code, crate::exit::DATAERR);
1461        assert!(
1462            !layout.heads_dir().join("main").exists(),
1463            "restore_head_ref must not write refs/heads/main when HEAD is unreadable"
1464        );
1465    }
1466}