1pub 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
100const LIST_REFS_FANOUT_ENTRIES_PER_THREAD: usize = 2;
107
108fn 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
121pub(crate) fn list_refs_parallel(layout: &RepoLayout) -> Result<Vec<refs::Ref>, RefError> {
130 refs::list_refs_with(layout, fanout_read_batch)
131}
132
133pub(crate) fn list_tags_parallel(layout: &RepoLayout) -> Result<Vec<refs::Ref>, RefError> {
138 refs::list_tags_with(layout, fanout_read_batch)
139}
140
141pub(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
150pub(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
168pub 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
181pub(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
197pub 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#[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#[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#[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
242pub(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
258pub(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
282pub(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
305pub const WORKTREE_LOCK: &str = "worktree.lock";
312
313pub fn acquire_worktree_lock(layout: &RepoLayout) -> Result<mkit_core::repo_lock::RepoLock, u8> {
330 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
343pub const SERVE_LOCK: &str = "serve.lock";
351
352pub(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
390pub const WORKTREES_REGISTRY_LOCK: &str = "worktrees.lock";
403
404pub 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
422pub(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
453pub(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; }
477 match refs::read_head(&candidate) {
478 Ok(Head::Branch(name)) if name == branch => return Ok(Some(tree_root)),
479 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
492pub(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
535pub(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
576pub(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
621pub(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
697pub 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
727pub 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
745pub 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
760pub 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#[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
789pub 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
818pub 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 if let Ok(old) = mkit_core::index::read_index(layout) {
835 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
854pub 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
901pub 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
918pub 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
927pub 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 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
1028pub(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
1061pub(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
1085pub(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 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 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
1178pub 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 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 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 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 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 #[cfg(feature = "history-mmr")]
1306 const CONCURRENT_BACKFILL_CHAIN_LEN: usize = 500;
1307
1308 #[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 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); 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 assert_eq!(c_quote_path("é").as_deref(), Some(r#""\303\251""#));
1389 assert_eq!(c_quote_path("x-é").as_deref(), Some(r#""x-\303\251""#));
1391 }
1392
1393 #[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 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 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}