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