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 mv;
42#[cfg(feature = "pack-shards")]
43pub mod pack_shard;
44pub mod pull;
45pub mod push;
46pub mod rebase;
47pub mod reflog;
48pub mod remote;
49pub mod reset;
50pub mod restore;
51pub mod rev_parse;
52pub mod revert;
53pub mod revspec;
54pub mod rm;
55pub mod serve;
56pub mod show;
57pub mod show_ref;
58pub mod sparse_checkout;
59pub mod stash;
60pub mod status;
61pub mod symbolic_ref;
62pub mod tag;
63pub mod tree;
64pub mod update_ref;
65pub mod verify;
66pub mod verify_attest;
67
68use crate::exit;
69use mkit_core::hash::Hash;
70use mkit_core::index::{EntryStatus, Index};
71use mkit_core::object::Object;
72use mkit_core::ops::diff::{DiffKind, diff_trees};
73use mkit_core::ops::recovery::{self, RecoveryEntry};
74use mkit_core::ops::restore::{RestoreOptions, matches_sparse, restore_tree_to_worktree};
75use mkit_core::refs::{self, Head, RefError, RefWriteCondition};
76use mkit_core::store::ObjectStore;
77use mkit_core::worktree;
78use std::fs;
79use std::io::Write;
80use std::path::Path;
81
82/// Open the object store for a mutating command, honoring the repo's
83/// configured durability schedule (`durability.objects`, see
84/// [`crate::config::Config::object_sync_policy`]). Falls back to the
85/// batched default when the config cannot be read — a broken config
86/// must not change write semantics silently, and Batch is the default
87/// contract.
88pub fn open_store_configured(root: &Path) -> Result<ObjectStore, mkit_core::store::StoreError> {
89    let mut store = ObjectStore::open(root)?;
90    if let Ok(cfg) = crate::config::read_or_default(root) {
91        store.set_sync_policy(cfg.object_sync_policy());
92    }
93    Ok(store)
94}
95
96/// Shared helper: emit a "not yet wired" notice and return the
97/// tempfail exit code. Commands whose backing state-machines haven't
98/// been wired into the CLI yet say so honestly rather than pretending
99/// to work.
100#[must_use]
101pub fn not_yet_ported(cmd: &str) -> u8 {
102    let mut stderr = std::io::stderr().lock();
103    let _ = writeln!(stderr, "error: `mkit {cmd}` is not yet wired");
104    exit::TEMPFAIL
105}
106
107/// Shared helper: print a usage error and return the USAGE exit code.
108#[must_use]
109pub fn usage_error(msg: &str) -> u8 {
110    let mut stderr = std::io::stderr().lock();
111    let _ = writeln!(stderr, "error: {msg}");
112    exit::USAGE
113}
114
115/// Basename of the repo-level lock that serialises worktree/index
116/// read-modify-write commands (`add`, `rm`, `commit`, `merge`,
117/// `checkout`, `rebase`, `cherry-pick`, `stash`, `sparse-checkout`).
118///
119/// Ref-only mutations (`branch`/`tag`) and config-only mutations do not
120/// take this lock — they rely on ref-CAS / atomic-config writes instead.
121pub const WORKTREE_LOCK: &str = "worktree.lock";
122
123/// Acquire the shared worktree/index lock for the repo rooted at `root`.
124///
125/// Hold the returned guard across the whole read-modify-write so a
126/// second mutating `mkit` blocks (then times out) instead of racing on
127/// the worktree + `.mkit/index`. On failure, the lock message has
128/// already been printed to stderr and the returned [`u8`] is the exit
129/// code to propagate.
130///
131/// Mirrors the pattern already used in `sparse_checkout` and
132/// `remote_dispatch`; new mutating commands should reuse this helper
133/// rather than calling `repo_lock::acquire_default` directly.
134///
135/// # Errors
136/// Returns [`exit::TEMPFAIL`] when the lock cannot be taken within the
137/// default timeout (another `mkit` holds it, or a stale lockfile is
138/// present).
139pub fn acquire_worktree_lock(root: &Path) -> Result<mkit_core::repo_lock::RepoLock, u8> {
140    let mkit_dir = root.join(mkit_core::MKIT_DIR);
141    mkit_core::repo_lock::acquire_default(&mkit_dir, WORKTREE_LOCK).map_err(|e| {
142        let mut stderr = std::io::stderr().lock();
143        let _ = writeln!(stderr, "error: repo lock: {e}");
144        exit::TEMPFAIL
145    })
146}
147
148/// C-style-quote `path` the way Git does for porcelain / `--name-*`
149/// output when a path contains bytes that need escaping. Returns `None`
150/// when the path is "plain" (all printable ASCII except `"`/`\`) and can
151/// be emitted as-is. Shared by `status` and `diff --name-only/-status`.
152///
153/// Quoting rule (matches Git's `quote_c_style` with the default
154/// `core.quotePath=true`): quote if any byte is a control char (`< 0x20`),
155/// `"`, `\`, or non-printable / non-ASCII (`>= 0x7f`). Inside the quotes,
156/// the common control chars use their `\a\b\t\n\v\f\r` escapes, `"` and
157/// `\` are backslash-escaped, printable ASCII is literal, and everything
158/// else is a 3-digit `\NNN` octal escape (per UTF-8 byte).
159pub(crate) fn c_quote_path(path: &str) -> Option<String> {
160    let bytes = path.as_bytes();
161    let needs = bytes
162        .iter()
163        .any(|&b| b < 0x20 || b == b'"' || b == b'\\' || b >= 0x7f);
164    if !needs {
165        return None;
166    }
167    let mut out = String::with_capacity(bytes.len() + 2);
168    out.push('"');
169    for &b in bytes {
170        match b {
171            0x07 => out.push_str("\\a"),
172            0x08 => out.push_str("\\b"),
173            0x09 => out.push_str("\\t"),
174            0x0a => out.push_str("\\n"),
175            0x0b => out.push_str("\\v"),
176            0x0c => out.push_str("\\f"),
177            0x0d => out.push_str("\\r"),
178            b'"' => out.push_str("\\\""),
179            b'\\' => out.push_str("\\\\"),
180            0x20..=0x7e => out.push(b as char),
181            other => {
182                use std::fmt::Write as _;
183                let _ = write!(out, "\\{other:03o}");
184            }
185        }
186    }
187    out.push('"');
188    Some(out)
189}
190
191/// Resolve a CLI path argument to a repo-relative, `/`-separated index
192/// path, validating it. Shared by `rm` and `mv` so both resolve and
193/// validate pathspecs identically (absolute args are mapped under the
194/// repo root, `.`/`..` are normalized, and the result is checked against
195/// [`mkit_core::index::validate_index_path`]).
196pub(crate) fn index_path_for_arg(root: &Path, arg: &Path) -> Result<String, String> {
197    use std::path::Component;
198    let rel = if arg.is_absolute() {
199        absolute_arg_to_repo_relative(root, arg)?
200    } else {
201        arg.to_path_buf()
202    };
203
204    let mut parts: Vec<String> = Vec::new();
205    for component in rel.as_path().components() {
206        match component {
207            Component::Normal(part) => {
208                let part = part
209                    .to_str()
210                    .ok_or_else(|| "path is not valid UTF-8".to_string())?;
211                parts.push(part.to_string());
212            }
213            Component::CurDir => {}
214            Component::ParentDir => {
215                if parts.pop().is_none() {
216                    return Err(format!("invalid path: {}", arg.display()));
217                }
218            }
219            Component::Prefix(_) | Component::RootDir => {
220                return Err(format!("invalid path: {}", arg.display()));
221            }
222        }
223    }
224
225    let path = parts.join("/");
226    if !mkit_core::index::validate_index_path(&path) {
227        return Err(format!("invalid path: {path}"));
228    }
229    Ok(path)
230}
231
232/// Map an absolute path argument to a path relative to the repo `root`,
233/// erroring if it escapes the repository. Handles not-yet-existing tail
234/// components (the leaf may not exist yet, e.g. an `mv` destination).
235pub(crate) fn absolute_arg_to_repo_relative(
236    root: &Path,
237    arg: &Path,
238) -> Result<std::path::PathBuf, String> {
239    use std::ffi::OsString;
240    let root = root.canonicalize().map_err(|e| format!("repo root: {e}"))?;
241
242    if let Ok(rel) = arg.strip_prefix(&root) {
243        return Ok(rel.to_path_buf());
244    }
245
246    let mut suffix: Vec<OsString> = vec![
247        arg.file_name()
248            .ok_or_else(|| format!("invalid path: {}", arg.display()))?
249            .to_os_string(),
250    ];
251    let mut ancestor = arg
252        .parent()
253        .ok_or_else(|| format!("invalid path: {}", arg.display()))?;
254    while ancestor.symlink_metadata().is_err() {
255        let name = ancestor
256            .file_name()
257            .ok_or_else(|| format!("path is outside repository: {}", arg.display()))?;
258        suffix.push(name.to_os_string());
259        ancestor = ancestor
260            .parent()
261            .ok_or_else(|| format!("path is outside repository: {}", arg.display()))?;
262    }
263
264    let mut normalized = ancestor
265        .canonicalize()
266        .map_err(|e| format!("path {}: {e}", ancestor.display()))?;
267    for component in suffix.iter().rev() {
268        normalized.push(component);
269    }
270
271    normalized
272        .strip_prefix(&root)
273        .map(Path::to_path_buf)
274        .map_err(|_| format!("path is outside repository: {}", arg.display()))
275}
276
277/// The worktree's current staged representation `(status, hash)` for
278/// `path`: a regular file (with its exec bit), a symlink (blob of its
279/// target), or `None` when the path is missing or not a stageable type
280/// (e.g. a directory). Mirrors how `add` stages one entry, so a caller can
281/// compare a worktree path to an index entry by **content AND mode/type** —
282/// catching symlink-target and chmod-only changes that a content-only hash
283/// would miss.
284pub(crate) fn worktree_entry_state(
285    root: &Path,
286    store: &ObjectStore,
287    path: &str,
288) -> Result<Option<(EntryStatus, Hash)>, String> {
289    let abs = root.join(path);
290    let meta = match abs.symlink_metadata() {
291        Ok(m) => m,
292        Err(e)
293            if matches!(
294                e.kind(),
295                std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
296            ) =>
297        {
298            return Ok(None);
299        }
300        Err(e) => return Err(format!("metadata {}: {e}", abs.display())),
301    };
302    if meta.file_type().is_file() {
303        let (opened_meta, bytes) = worktree::read_regular_file_bounded(&abs)
304            .map_err(|e| format!("read {}: {e}", abs.display()))?;
305        let h = worktree::store_file_object(store, &bytes).map_err(|e| format!("store: {e}"))?;
306        Ok(Some((file_exec_status(&opened_meta), h)))
307    } else if meta.file_type().is_symlink() {
308        let target =
309            fs::read_link(&abs).map_err(|e| format!("read link {}: {e}", abs.display()))?;
310        let target_str = target
311            .to_str()
312            .ok_or_else(|| "symlink target is not valid UTF-8".to_string())?;
313        if !worktree::validate_symlink_target(target_str) {
314            return Err(format!("invalid symlink target: {target_str}"));
315        }
316        let blob = Object::Blob(mkit_core::object::Blob {
317            data: target_str.as_bytes().to_vec(),
318        });
319        let ser = mkit_core::serialize::serialize(&blob).map_err(|e| format!("serialize: {e}"))?;
320        let h = store.write(&ser).map_err(|e| format!("store: {e}"))?;
321        Ok(Some((EntryStatus::Symlink, h)))
322    } else {
323        Ok(None)
324    }
325}
326
327#[cfg(unix)]
328fn file_exec_status(meta: &fs::Metadata) -> EntryStatus {
329    use std::os::unix::fs::PermissionsExt;
330    if meta.permissions().mode() & 0o111 != 0 {
331        EntryStatus::Executable
332    } else {
333        EntryStatus::Blob
334    }
335}
336
337#[cfg(not(unix))]
338fn file_exec_status(_meta: &fs::Metadata) -> EntryStatus {
339    EntryStatus::Blob
340}
341
342pub(crate) fn index_path_matches_or_descends(path: &str, base: &str) -> bool {
343    path == base || index_path_descends_from(path, base)
344}
345
346pub(crate) fn index_path_descends_from(path: &str, base: &str) -> bool {
347    path.len() > base.len()
348        && path.starts_with(base)
349        && path.as_bytes().get(base.len()) == Some(&b'/')
350}
351
352// ---------------------------------------------------------------------------
353// History-MMR ref-write helper (feature: history-mmr)
354// ---------------------------------------------------------------------------
355//
356// Phase 2 of issue #157. Every CLI subcommand that advances a branch ref
357// MUST route the write through this helper instead of calling
358// `refs::write_ref` / `refs::update_ref` directly. Default builds
359// (no `history-mmr` feature) keep the old direct semantics; the
360// feature-gated path opens a per-branch journaled `CommitHistory`, takes
361// a single repo-level lock around (ref-write + MMR-append), and syncs
362// the journal to disk before returning.
363//
364// The executor is a **process-global** `Arc<TokioExecutor>` — we
365// construct exactly one per process via `OnceLock` so multiple branch
366// advances share one tokio runtime. Threading the executor through
367// every CLI helper would force `history-mmr` into the signature of
368// every subcommand entry point, so we keep it local to this module.
369
370/// Construct (lazily) and share the process-wide `TokioExecutor` used
371/// by every history-MMR-coupled ref write in the CLI.
372///
373/// One executor per process: each `TokioExecutor` owns a multi-thread
374/// tokio runtime, and re-constructing it per ref-write would burn a
375/// fresh runtime for every commit. The `OnceLock` is initialised on the
376/// first call; subsequent calls reuse the same `Arc` clone.
377#[cfg(feature = "history-mmr")]
378pub(crate) fn history_executor() -> std::sync::Arc<mkit_core::history::TokioExecutor> {
379    use std::sync::{Arc, OnceLock};
380    static EXECUTOR: OnceLock<Arc<mkit_core::history::TokioExecutor>> = OnceLock::new();
381    EXECUTOR
382        .get_or_init(|| {
383            let exec = mkit_core::history::TokioExecutor::new()
384                .expect("history-mmr tokio runtime must initialise");
385            Arc::new(exec)
386        })
387        .clone()
388}
389
390/// CLI-side ref-write helper that records every advance in the
391/// branch's history MMR when `history-mmr` is enabled.
392///
393/// Behaviour matrix:
394///
395/// - **Default build (no `history-mmr`)** — exactly equivalent to
396///   `refs::update_ref(mkit_dir, branch, condition, new_hash)`.
397/// - **`--features history-mmr`** — opens a journaled
398///   `CommitHistory` for `branch` under `<mkit_dir>/history/`, takes
399///   the `refs-history.lock` repo lock, performs the CAS ref-write,
400///   appends `new_hash` to the MMR, and `sync()`s the journal before
401///   returning. The journal survives `SIGKILL` immediately after the
402///   call returns. See `mkit-core::refs::update_ref_with_history` and
403///   SPEC-HISTORY-PROOF §4 for the contract.
404///
405/// All CLI subcommands that move a branch ref MUST funnel through this
406/// helper rather than calling `refs::write_ref` or `refs::update_ref`
407/// directly. Detached-HEAD writes (`refs::write_head_detached`) are
408/// not history-tracked: the per-branch journal is keyed on the branch
409/// name, and detached HEADs have none.
410pub fn write_ref_recording_history(
411    mkit_dir: &Path,
412    branch: &str,
413    condition: RefWriteCondition,
414    new_hash: &Hash,
415) -> Result<(), RefError> {
416    #[cfg(feature = "history-mmr")]
417    {
418        let exec = history_executor();
419        let mut history = mkit_core::history::CommitHistory::open_at(exec, mkit_dir, branch)
420            .map_err(|e| RefError::InvalidRef(format!("{branch}: open history journal: {e}")))?;
421        refs::update_ref_with_history(mkit_dir, branch, condition, new_hash, &mut history)
422    }
423    #[cfg(not(feature = "history-mmr"))]
424    {
425        refs::update_ref(mkit_dir, branch, condition, new_hash)
426    }
427}
428
429/// Current branch name for recovery logging — empty for a detached HEAD
430/// or an unreadable/symbolic-only HEAD.
431#[must_use]
432pub fn head_branch_name(mkit_dir: &Path) -> String {
433    match refs::read_head(mkit_dir) {
434        Ok(Head::Branch(name)) => name,
435        _ => String::new(),
436    }
437}
438
439/// Record `superseded` (the old branch tip a history-rewriting op is
440/// about to replace) in the recovery log so `mkit gc` keeps it
441/// recoverable.
442///
443/// Call this **before** moving the ref and while holding the worktree
444/// lock (every caller does both): recording first guarantees that a
445/// persisted ref move always has a persisted recovery entry, and the
446/// lock keeps a concurrent `recovery::expire` from clobbering the append.
447/// On failure the caller MUST abort the rewrite (propagate the returned
448/// error) rather than orphan an unrecoverable commit. The zero hash is a
449/// no-op inside [`recovery::record`].
450pub fn record_superseded(
451    mkit_dir: &Path,
452    op: &str,
453    branch: &str,
454    superseded: Hash,
455) -> Result<(), (String, u8)> {
456    let timestamp = std::time::SystemTime::now()
457        .duration_since(std::time::UNIX_EPOCH)
458        .map_or(0, |d| d.as_secs());
459    let entry = RecoveryEntry {
460        timestamp,
461        op: op.to_owned(),
462        superseded,
463        branch: branch.to_owned(),
464    };
465    recovery::record(mkit_dir, &entry).map_err(|e| (format!("recovery log: {e}"), exit::CANTCREAT))
466}
467
468/// Rewrite `.mkit/index` so it exactly mirrors `tree_hash`.
469///
470/// `mkit commit` now signs the index, so commands that move HEAD and
471/// materialize a committed tree must keep the index aligned with that
472/// snapshot.
473pub fn sync_index_to_tree(root: &Path, store: &ObjectStore, tree_hash: Hash) -> Result<(), String> {
474    let mut idx =
475        mkit_core::index::from_tree(store, tree_hash).map_err(|e| format!("index: {e}"))?;
476    // Tree-derived entries carry no stat cache. Carry it over from the
477    // outgoing index wherever path AND object hash agree: a later stat
478    // match against the old observation still proves the same bytes,
479    // so commit/checkout don't wipe the O(stat) fast path.
480    if let Ok(old) = mkit_core::index::read_index(root) {
481        // O(1) lookups: find_entry is a linear scan and this loop runs
482        // once per tree entry (was O(n²) per commit/checkout).
483        let by_path: std::collections::HashMap<&str, &mkit_core::index::IndexEntry> =
484            old.entries.iter().map(|o| (o.path.as_str(), o)).collect();
485        for e in &mut idx.entries {
486            if let Some(o) = by_path.get(e.path.as_str())
487                && o.object_hash == e.object_hash
488                && o.status == e.status
489            {
490                e.mtime_ns = o.mtime_ns;
491                e.size = o.size;
492                e.ino = o.ino;
493                e.ctime_ns = o.ctime_ns;
494            }
495        }
496    }
497    mkit_core::index::write_index(root, &idx).map_err(|e| format!("write index: {e}"))
498}
499
500/// Materialise `tree_hash` and align the index while preserving `.mkitignore` entries.
501pub fn restore_worktree_and_index(
502    root: &Path,
503    store: &ObjectStore,
504    tree_hash: Hash,
505) -> Result<(), String> {
506    restore_tree_to_worktree(store, &tree_hash, root, &RestoreOptions::default())
507        .map_err(|e| format!("restore worktree: {e}"))?;
508    sync_index_to_tree(root, store, tree_hash)
509}
510
511/// Refuse a destructive restore when the index/worktree contains user work.
512pub fn ensure_restore_safe(
513    root: &Path,
514    store: &ObjectStore,
515    target_tree: Hash,
516) -> Result<(), String> {
517    ensure_restore_safe_with_options(root, store, target_tree, &RestoreOptions::default())
518}
519
520/// Refuse a destructive restore when affected index/worktree paths contain user work.
521pub fn ensure_restore_safe_with_options(
522    root: &Path,
523    store: &ObjectStore,
524    target_tree: Hash,
525    options: &RestoreOptions,
526) -> Result<(), String> {
527    let current_tree = current_head_tree(root, store)?;
528    let idx = read_or_seed_index_from_head(root, store)?;
529    // Safety-check snapshot trees are ephemeral — in-memory overlay,
530    // no durability cost, no garbage objects in the store.
531    let snapshot = mkit_core::store::EphemeralSink::new(store);
532    let index_tree = worktree::build_tree_from_index_with(store, &snapshot, &idx, false)
533        .map_err(|e| format!("check index state: {e}"))?;
534
535    let staged = diff_trees(&snapshot, current_tree, Some(index_tree))
536        .map_err(|e| format!("check staged changes: {e}"))?;
537    if let Some(entry) = staged
538        .entries
539        .iter()
540        .find(|entry| restore_affects_path(options, &entry.path))
541    {
542        return Err(format!(
543            "restore would overwrite staged changes; commit, stash, or reset '{}' first",
544            entry.path
545        ));
546    }
547
548    let worktree_tree = worktree::build_tree_filtered(&snapshot, root, Some(&idx))
549        .map_err(|e| format!("check working tree changes: {e}"))?;
550    let unstaged = diff_trees(&snapshot, Some(index_tree), Some(worktree_tree))
551        .map_err(|e| format!("check working tree changes: {e}"))?;
552    if let Some(entry) = unstaged
553        .entries
554        .iter()
555        .find(|entry| entry.kind != DiffKind::Added && restore_affects_path(options, &entry.path))
556    {
557        return Err(format!(
558            "restore would overwrite local changes; commit, stash, or reset '{}' first",
559            entry.path
560        ));
561    }
562
563    let target_writes = diff_trees(&snapshot, Some(index_tree), Some(target_tree))
564        .map_err(|e| format!("check restore target: {e}"))?
565        .entries
566        .into_iter()
567        .filter(|entry| entry.kind != DiffKind::Removed)
568        .filter(|entry| restore_affects_path(options, &entry.path))
569        .map(|entry| entry.path)
570        .collect::<Vec<_>>();
571    if target_writes.is_empty() && !options.clean {
572        return Ok(());
573    }
574
575    let ignore = mkit_core::ignore::load(root).map_err(|e| format!("read ignore file: {e}"))?;
576    let mut worktree_paths = Vec::new();
577    collect_worktree_paths(root, root, "", &mut worktree_paths)
578        .map_err(|e| format!("check untracked paths: {e}"))?;
579    if let Some(path) = worktree_paths.iter().find(|path| {
580        !index_tracks_path_or_descendant(&idx, path)
581            && target_writes
582                .iter()
583                .any(|target| paths_overlap(path, target))
584    }) {
585        return Err(format!(
586            "restore would overwrite untracked path '{path}'; move or remove it first"
587        ));
588    }
589
590    if options.clean
591        && let Some(path) = worktree_paths.iter().find(|path| {
592            !index_tracks_path_or_descendant(&idx, path)
593                && restore_affects_path(options, path)
594                && *path != ".mkitignore"
595                && *path != ".gitignore"
596                && !is_ignored_worktree_path(root, &ignore, path)
597        })
598    {
599        return Err(format!(
600            "restore would remove untracked path '{path}'; move or remove it first"
601        ));
602    }
603
604    Ok(())
605}
606
607pub(crate) fn restore_affects_path(options: &RestoreOptions, path: &str) -> bool {
608    options
609        .sparse_patterns
610        .as_deref()
611        .is_none_or(|patterns| matches_sparse(patterns, path, false))
612}
613
614/// Tracked paths present in the current index but absent from the target
615/// tree, each paired with its index entry's `(status, hash)` — for
616/// destructive worktree moves (`reset --hard`, `checkout`) these files
617/// are deleted explicitly (`restore_tree_to_worktree` with `clean =
618/// false` writes/overwrites but never deletes). The `(status, hash)`
619/// lets the caller detect local edits by content AND mode/type.
620pub(crate) fn dropped_tracked_paths(
621    cwd: &Path,
622    store: &ObjectStore,
623    target_tree: Hash,
624) -> Result<Vec<(String, EntryStatus, Hash)>, String> {
625    let idx = read_or_seed_index_from_head(cwd, store)?;
626    let snapshot = mkit_core::store::EphemeralSink::new(store);
627    let index_tree = worktree::build_tree_from_index_with(store, &snapshot, &idx, false)
628        .map_err(|e| format!("index tree: {e}"))?;
629    let mut out = Vec::new();
630    for e in diff_trees(&snapshot, Some(index_tree), Some(target_tree))
631        .map_err(|e| format!("diff index vs target: {e}"))?
632        .entries
633        .into_iter()
634        .filter(|e| e.kind == DiffKind::Removed)
635    {
636        if let Some(entry) = idx
637            .entries
638            .iter()
639            .find(|ie| ie.path == e.path && ie.status != EntryStatus::Removed)
640        {
641            out.push((e.path, entry.status, entry.object_hash));
642        }
643    }
644    Ok(out)
645}
646
647/// The first dropped path whose worktree entry differs from its indexed
648/// `(status, hash)` — a local edit to content, mode (exec bit), or symlink
649/// target. `None` if every dropped path is unmodified, missing, or a
650/// directory (no file to lose). This is a direct per-dropped-path check, so
651/// destructive moves never silently discard a local edit — independent of
652/// how the shared worktree-snapshot guard treats ignored files.
653pub(crate) fn locally_modified_dropped_path(
654    cwd: &Path,
655    store: &ObjectStore,
656    dropped: &[(String, EntryStatus, Hash)],
657) -> Result<Option<String>, String> {
658    for (path, idx_status, idx_hash) in dropped {
659        if let Some((wt_status, wt_hash)) = worktree_entry_state(cwd, store, path)?
660            && (wt_status != *idx_status || wt_hash != *idx_hash)
661        {
662            return Ok(Some(path.clone()));
663        }
664    }
665    Ok(None)
666}
667
668/// Delete a dropped tracked path from the worktree. A regular file or
669/// symlink is removed; a directory (untracked content that replaced the
670/// tracked file) is LEFT in place rather than recursively deleted, and a
671/// missing path is a no-op — so this never crashes on `IsADirectory` and
672/// never nukes untracked directories.
673pub(crate) fn remove_dropped_path(abs: &Path) -> std::io::Result<()> {
674    match fs::symlink_metadata(abs) {
675        Ok(meta) if meta.is_dir() => Ok(()),
676        Ok(_) => fs::remove_file(abs),
677        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
678        Err(e) => Err(e),
679    }
680}
681
682fn is_ignored_worktree_path(
683    root: &Path,
684    ignore: &mkit_core::ignore::IgnoreList,
685    path: &str,
686) -> bool {
687    let full_path = root.join(path);
688    let Ok(meta) = fs::symlink_metadata(&full_path) else {
689        return false;
690    };
691    // Match on the repo-relative path, and treat a path under an ignored
692    // directory as ignored too (no top-down walk here to carry that bit).
693    ignore.is_ignored_with_ancestors(path, meta.is_dir())
694}
695
696pub(crate) fn current_head_tree(root: &Path, store: &ObjectStore) -> Result<Option<Hash>, String> {
697    let mkit_dir = root.join(mkit_core::MKIT_DIR);
698    let Some(head_hash) =
699        refs::resolve_head(&mkit_dir).map_err(|e| format!("resolve HEAD: {e}"))?
700    else {
701        return Ok(None);
702    };
703    match store
704        .read_object(&head_hash)
705        .map_err(|e| format!("read HEAD: {e}"))?
706    {
707        Object::Commit(c) => Ok(Some(c.tree_hash)),
708        Object::Remix(r) => Ok(Some(r.tree_hash)),
709        _ => Err("HEAD does not resolve to a commit or remix".to_string()),
710    }
711}
712
713fn collect_worktree_paths(
714    root: &Path,
715    dir: &Path,
716    prefix: &str,
717    out: &mut Vec<String>,
718) -> std::io::Result<()> {
719    let read = match fs::read_dir(dir) {
720        Ok(read) => read,
721        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
722        Err(e) => return Err(e),
723    };
724    for entry in read {
725        let entry = entry?;
726        let name = entry.file_name();
727        let Some(name) = name.to_str() else {
728            continue;
729        };
730        if name.eq_ignore_ascii_case(".mkit") || name.eq_ignore_ascii_case(".git") {
731            continue;
732        }
733        let path = if prefix.is_empty() {
734            name.to_string()
735        } else {
736            format!("{prefix}/{name}")
737        };
738        out.push(path.clone());
739        let full_path = root.join(&path);
740        let meta = fs::symlink_metadata(&full_path)?;
741        if meta.is_dir() {
742            collect_worktree_paths(root, &full_path, &path, out)?;
743        }
744    }
745    Ok(())
746}
747
748pub(crate) fn index_tracks_path_or_descendant(index: &Index, path: &str) -> bool {
749    index.entries.iter().any(|entry| {
750        entry.status != EntryStatus::Removed
751            && (entry.path == path || index_path_descends_from(&entry.path, path))
752    })
753}
754
755fn paths_overlap(left: &str, right: &str) -> bool {
756    index_path_matches_or_descends(left, right) || index_path_descends_from(right, left)
757}
758
759/// Read the index, seeding an absent/empty one from HEAD when possible.
760///
761/// This lets old repositories or manually removed indexes keep the
762/// expected staging invariant: adding/removing one path starts from the
763/// current commit snapshot instead of making the next commit forget all
764/// unchanged tracked files.
765pub fn read_or_seed_index_from_head(
766    root: &Path,
767    store: &ObjectStore,
768) -> Result<mkit_core::index::Index, String> {
769    let idx = mkit_core::index::read_index(root).map_err(|e| format!("read index: {e}"))?;
770    if !idx.entries.is_empty() {
771        return Ok(idx);
772    }
773
774    let mkit_dir = root.join(mkit_core::MKIT_DIR);
775    let Some(head_hash) =
776        mkit_core::refs::resolve_head(&mkit_dir).map_err(|e| format!("resolve HEAD: {e}"))?
777    else {
778        return Ok(idx);
779    };
780    match store
781        .read_object(&head_hash)
782        .map_err(|e| format!("read HEAD: {e}"))?
783    {
784        Object::Commit(c) => mkit_core::index::from_tree(store, c.tree_hash)
785            .map_err(|e| format!("index from HEAD: {e}")),
786        Object::Remix(r) => mkit_core::index::from_tree(store, r.tree_hash)
787            .map_err(|e| format!("index from HEAD: {e}")),
788        _ => Err("HEAD does not resolve to a commit or remix".to_string()),
789    }
790}
791
792#[cfg(test)]
793mod tests {
794    use super::c_quote_path;
795
796    #[test]
797    fn c_quote_leaves_plain_paths_alone() {
798        assert_eq!(c_quote_path("a.txt"), None);
799        assert_eq!(c_quote_path("dir/with space.txt"), None); // space is plain
800        assert_eq!(c_quote_path("weird-but-ascii_!@#$%.rs"), None);
801    }
802
803    #[test]
804    fn c_quote_escapes_special_bytes() {
805        assert_eq!(c_quote_path("a\tb.txt").as_deref(), Some(r#""a\tb.txt""#));
806        assert_eq!(
807            c_quote_path("line\nfeed").as_deref(),
808            Some(r#""line\nfeed""#)
809        );
810        assert_eq!(c_quote_path("q\"x").as_deref(), Some(r#""q\"x""#));
811        assert_eq!(
812            c_quote_path("back\\slash").as_deref(),
813            Some(r#""back\\slash""#)
814        );
815    }
816
817    #[test]
818    fn c_quote_octal_escapes_non_ascii() {
819        // "é" is UTF-8 0xC3 0xA9 → \303\251 (matches git core.quotePath).
820        assert_eq!(c_quote_path("é").as_deref(), Some(r#""\303\251""#));
821        // Combined with ASCII: only the non-ASCII bytes are octal-escaped.
822        assert_eq!(c_quote_path("x-é").as_deref(), Some(r#""x-\303\251""#));
823    }
824}