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 commit;
20pub mod config_cmd;
21pub mod conflict;
22pub mod diff;
23pub mod fetch;
24pub mod for_each_ref;
25pub mod gc;
26#[cfg(feature = "git-bridge")]
27pub mod git;
28#[cfg(feature = "git-bridge")]
29pub mod git_import;
30#[cfg(feature = "git-bridge")]
31pub mod git_tools;
32pub mod hash_cmd;
33pub mod init;
34pub mod key;
35pub mod keygen;
36pub mod log;
37pub mod ls_files;
38pub mod ls_tree;
39pub mod mcp;
40pub mod merge;
41pub mod merge_base;
42pub mod mv;
43#[cfg(feature = "pack-shards")]
44pub mod pack_shard;
45pub mod pull;
46pub mod push;
47pub mod rebase;
48pub mod ref_cmd;
49pub mod reflog;
50pub mod remote;
51pub mod reset;
52pub mod restore;
53pub mod rev_list;
54pub mod rev_parse;
55pub mod revert;
56pub mod revspec;
57pub mod rm;
58pub mod self_update;
59pub mod serve;
60pub mod show;
61pub mod show_ref;
62pub mod sparse_checkout;
63pub mod stash;
64pub mod status;
65pub mod summary;
66pub mod switch;
67pub mod symbolic_ref;
68pub mod tag;
69pub mod tree;
70pub mod trust;
71pub mod trust_roots;
72pub mod update_ref;
73pub mod verify;
74pub mod verify_attest;
75pub mod worktree;
76
77use crate::exit;
78use mkit_core::hash::Hash;
79use mkit_core::index::{EntryStatus, Index};
80use mkit_core::layout::RepoLayout;
81use mkit_core::object::Object;
82use mkit_core::ops::diff::{DiffKind, diff_trees};
83use mkit_core::ops::recovery::{self, RecoveryEntry};
84use mkit_core::ops::restore::{RestoreOptions, matches_sparse, restore_tree_to_worktree};
85use mkit_core::refs::{self, Head, RefError, RefWriteCondition};
86use mkit_core::store::ObjectStore;
87use mkit_core::worktree as core_worktree;
88use std::fs;
89use std::io::Write;
90use std::path::Path;
91
92pub(crate) fn commit_subject(store: &ObjectStore, commit: &Hash) -> String {
99 let msg = match store.read_object(commit) {
100 Ok(Object::Commit(c)) => c.message,
101 _ => return String::new(),
102 };
103 String::from_utf8_lossy(&msg)
104 .lines()
105 .next()
106 .unwrap_or("")
107 .to_owned()
108}
109
110pub fn open_store_configured(
114 layout: &RepoLayout,
115) -> Result<ObjectStore, mkit_core::store::StoreError> {
116 let mut store = ObjectStore::open(layout)?;
117 if let Ok(cfg) = crate::config::read_or_default(layout) {
118 store.set_sync_policy(cfg.object_sync_policy());
119 }
120 Ok(store)
121}
122
123pub(crate) fn read_object_bytes(store: &ObjectStore, hash: &Hash) -> Result<Vec<u8>, (String, u8)> {
131 store.read(hash).map_err(|e| {
132 (
133 format!("read {}: {e}", mkit_core::hash::to_hex(hash)),
134 exit::GENERAL_ERROR,
135 )
136 })
137}
138
139pub fn resolve_layout(cwd: &Path) -> Result<RepoLayout, u8> {
148 mkit_core::layout::discover(cwd)
149 .map_err(|e| error(&format!("worktree discovery: {e}"), exit::DATAERR))
150}
151
152#[must_use]
157pub fn not_yet_ported(cmd: &str) -> u8 {
158 let mut stderr = std::io::stderr().lock();
159 let _ = writeln!(stderr, "error: `mkit {cmd}` is not yet wired");
160 exit::TEMPFAIL
161}
162
163#[must_use]
165pub fn usage_error(msg: &str) -> u8 {
166 let mut stderr = std::io::stderr().lock();
167 let _ = writeln!(stderr, "error: {msg}");
168 exit::USAGE
169}
170
171#[must_use]
178pub(crate) fn error(msg: &str, code: u8) -> u8 {
179 let mut stderr = std::io::stderr().lock();
180 let _ = writeln!(stderr, "error: {msg}");
181 code
182}
183
184pub(crate) fn load_tree_hash(store: &ObjectStore, commit_hash: Hash) -> Result<Hash, u8> {
193 match store.read_object(&commit_hash) {
194 Ok(Object::Commit(c)) => Ok(c.tree_hash),
195 Ok(_) => Err(error("object is not a commit", exit::DATAERR)),
196 Err(e) => Err(error(&format!("read commit: {e}"), exit::GENERAL_ERROR)),
197 }
198}
199
200pub(crate) fn advance_head(layout: &RepoLayout, new_head: &Hash) -> Result<(), String> {
212 let head = refs::read_head(layout).map_err(|e| format!("read HEAD: {e}"))?;
213 match head {
214 Head::Branch(name) => {
215 write_ref_recording_history(layout, &name, RefWriteCondition::Any, new_head)
216 .map_err(|e| format!("write ref: {e}"))
217 }
218 Head::Detached(_) => {
219 refs::write_head_detached(layout, new_head).map_err(|e| format!("update HEAD: {e}"))
220 }
221 }
222}
223
224pub(crate) fn restore_head_ref(layout: &RepoLayout, target: &Hash) -> Result<(), u8> {
235 let head =
236 refs::read_head(layout).map_err(|e| error(&format!("read HEAD: {e}"), exit::DATAERR))?;
237 match head {
238 Head::Branch(name) => {
239 write_ref_recording_history(layout, &name, RefWriteCondition::Any, target)
240 .map_err(|e| error(&format!("restore ref: {e}"), exit::CANTCREAT))
241 }
242 Head::Detached(_) => refs::write_head_detached(layout, target)
243 .map_err(|e| error(&format!("restore HEAD: {e}"), exit::CANTCREAT)),
244 }
245}
246
247pub const WORKTREE_LOCK: &str = "worktree.lock";
254
255pub fn acquire_worktree_lock(layout: &RepoLayout) -> Result<mkit_core::repo_lock::RepoLock, u8> {
272 mkit_core::repo_lock::acquire_default(layout.worktree_state_dir(), WORKTREE_LOCK).map_err(|e| {
276 let mut stderr = std::io::stderr().lock();
277 let _ = writeln!(stderr, "error: repo lock: {e}");
278 exit::TEMPFAIL
279 })
280}
281
282pub const WORKTREES_REGISTRY_LOCK: &str = "worktrees.lock";
295
296pub fn acquire_worktrees_registry_lock(
302 layout: &RepoLayout,
303) -> Result<mkit_core::repo_lock::RepoLock, u8> {
304 mkit_core::repo_lock::acquire_default(layout.common_dir(), WORKTREES_REGISTRY_LOCK).map_err(
305 |e| {
306 let mut stderr = std::io::stderr().lock();
307 let _ = writeln!(stderr, "error: worktree registry lock: {e}");
308 exit::TEMPFAIL
309 },
310 )
311}
312
313pub(crate) fn all_worktree_layouts(
323 layout: &RepoLayout,
324) -> Result<Vec<(std::path::PathBuf, RepoLayout)>, String> {
325 let mut out = Vec::new();
326 if let Some(main_root) = layout.common_dir().parent() {
327 out.push((main_root.to_path_buf(), RepoLayout::single(main_root)));
328 }
329 for wt in mkit_core::layout::worktrees(layout).map_err(|e| format!("worktree registry: {e}"))? {
330 if wt.prunable.is_some() {
331 continue;
332 }
333 let Some(tree_root) = wt.tree_root else {
334 continue;
335 };
336 out.push((
337 tree_root.clone(),
338 RepoLayout::linked(tree_root, wt.state_dir, layout.common_dir()),
339 ));
340 }
341 Ok(out)
342}
343
344pub(crate) fn branch_checked_out_elsewhere(
353 layout: &RepoLayout,
354 branch: &str,
355) -> Result<Option<std::path::PathBuf>, String> {
356 let self_state = layout
357 .worktree_state_dir()
358 .canonicalize()
359 .unwrap_or_else(|_| layout.worktree_state_dir().to_path_buf());
360 for (tree_root, candidate) in all_worktree_layouts(layout)? {
361 let candidate_state = candidate
362 .worktree_state_dir()
363 .canonicalize()
364 .unwrap_or_else(|_| candidate.worktree_state_dir().to_path_buf());
365 if candidate_state == self_state {
366 continue; }
368 match refs::read_head(&candidate) {
369 Ok(Head::Branch(name)) if name == branch => return Ok(Some(tree_root)),
370 Ok(_) | Err(RefError::NoHead) => {}
372 Err(e) => {
373 return Err(format!(
374 "read HEAD of worktree at {}: {e}",
375 tree_root.display()
376 ));
377 }
378 }
379 }
380 Ok(None)
381}
382
383pub(crate) fn c_quote_path(path: &str) -> Option<String> {
395 let bytes = path.as_bytes();
396 let needs = bytes
397 .iter()
398 .any(|&b| b < 0x20 || b == b'"' || b == b'\\' || b >= 0x7f);
399 if !needs {
400 return None;
401 }
402 let mut out = String::with_capacity(bytes.len() + 2);
403 out.push('"');
404 for &b in bytes {
405 match b {
406 0x07 => out.push_str("\\a"),
407 0x08 => out.push_str("\\b"),
408 0x09 => out.push_str("\\t"),
409 0x0a => out.push_str("\\n"),
410 0x0b => out.push_str("\\v"),
411 0x0c => out.push_str("\\f"),
412 0x0d => out.push_str("\\r"),
413 b'"' => out.push_str("\\\""),
414 b'\\' => out.push_str("\\\\"),
415 0x20..=0x7e => out.push(b as char),
416 other => {
417 use std::fmt::Write as _;
418 let _ = write!(out, "\\{other:03o}");
419 }
420 }
421 }
422 out.push('"');
423 Some(out)
424}
425
426pub(crate) fn index_path_for_arg(root: &Path, arg: &Path) -> Result<String, String> {
432 use std::path::Component;
433 let rel = if arg.is_absolute() {
434 absolute_arg_to_repo_relative(root, arg)?
435 } else {
436 arg.to_path_buf()
437 };
438
439 let mut parts: Vec<String> = Vec::new();
440 for component in rel.as_path().components() {
441 match component {
442 Component::Normal(part) => {
443 let part = part
444 .to_str()
445 .ok_or_else(|| "path is not valid UTF-8".to_string())?;
446 parts.push(part.to_string());
447 }
448 Component::CurDir => {}
449 Component::ParentDir => {
450 if parts.pop().is_none() {
451 return Err(format!("invalid path: {}", arg.display()));
452 }
453 }
454 Component::Prefix(_) | Component::RootDir => {
455 return Err(format!("invalid path: {}", arg.display()));
456 }
457 }
458 }
459
460 let path = parts.join("/");
461 if !mkit_core::index::validate_index_path(&path) {
462 return Err(format!("invalid path: {path}"));
463 }
464 Ok(path)
465}
466
467pub(crate) fn absolute_arg_to_repo_relative(
471 root: &Path,
472 arg: &Path,
473) -> Result<std::path::PathBuf, String> {
474 use std::ffi::OsString;
475 let root = root.canonicalize().map_err(|e| format!("repo root: {e}"))?;
476
477 if let Ok(rel) = arg.strip_prefix(&root) {
478 return Ok(rel.to_path_buf());
479 }
480
481 let mut suffix: Vec<OsString> = vec![
482 arg.file_name()
483 .ok_or_else(|| format!("invalid path: {}", arg.display()))?
484 .to_os_string(),
485 ];
486 let mut ancestor = arg
487 .parent()
488 .ok_or_else(|| format!("invalid path: {}", arg.display()))?;
489 while ancestor.symlink_metadata().is_err() {
490 let name = ancestor
491 .file_name()
492 .ok_or_else(|| format!("path is outside repository: {}", arg.display()))?;
493 suffix.push(name.to_os_string());
494 ancestor = ancestor
495 .parent()
496 .ok_or_else(|| format!("path is outside repository: {}", arg.display()))?;
497 }
498
499 let mut normalized = ancestor
500 .canonicalize()
501 .map_err(|e| format!("path {}: {e}", ancestor.display()))?;
502 for component in suffix.iter().rev() {
503 normalized.push(component);
504 }
505
506 normalized
507 .strip_prefix(&root)
508 .map(Path::to_path_buf)
509 .map_err(|_| format!("path is outside repository: {}", arg.display()))
510}
511
512pub(crate) fn worktree_entry_state(
520 root: &Path,
521 store: &ObjectStore,
522 path: &str,
523) -> Result<Option<(EntryStatus, Hash)>, String> {
524 let abs = root.join(path);
525 let meta = match abs.symlink_metadata() {
526 Ok(m) => m,
527 Err(e)
528 if matches!(
529 e.kind(),
530 std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
531 ) =>
532 {
533 return Ok(None);
534 }
535 Err(e) => return Err(format!("metadata {}: {e}", abs.display())),
536 };
537 if meta.file_type().is_file() {
538 let (opened_meta, bytes) = core_worktree::read_regular_file_bounded(&abs)
539 .map_err(|e| format!("read {}: {e}", abs.display()))?;
540 let h =
541 core_worktree::store_file_object(store, &bytes).map_err(|e| format!("store: {e}"))?;
542 Ok(Some((file_exec_status(&opened_meta), h)))
543 } else if meta.file_type().is_symlink() {
544 let target =
545 fs::read_link(&abs).map_err(|e| format!("read link {}: {e}", abs.display()))?;
546 let target_str = target
547 .to_str()
548 .ok_or_else(|| "symlink target is not valid UTF-8".to_string())?;
549 if !core_worktree::validate_symlink_target(target_str) {
550 return Err(format!("invalid symlink target: {target_str}"));
551 }
552 let blob = Object::Blob(mkit_core::object::Blob {
553 data: target_str.as_bytes().to_vec(),
554 });
555 let ser = mkit_core::serialize::serialize(&blob).map_err(|e| format!("serialize: {e}"))?;
556 let h = store.write(&ser).map_err(|e| format!("store: {e}"))?;
557 Ok(Some((EntryStatus::Symlink, h)))
558 } else {
559 Ok(None)
560 }
561}
562
563#[cfg(unix)]
564fn file_exec_status(meta: &fs::Metadata) -> EntryStatus {
565 use std::os::unix::fs::PermissionsExt;
566 if meta.permissions().mode() & 0o111 != 0 {
567 EntryStatus::Executable
568 } else {
569 EntryStatus::Blob
570 }
571}
572
573#[cfg(not(unix))]
574fn file_exec_status(_meta: &fs::Metadata) -> EntryStatus {
575 EntryStatus::Blob
576}
577
578pub(crate) fn index_path_matches_or_descends(path: &str, base: &str) -> bool {
579 path == base || index_path_descends_from(path, base)
580}
581
582pub(crate) fn index_path_descends_from(path: &str, base: &str) -> bool {
583 path.len() > base.len()
584 && path.starts_with(base)
585 && path.as_bytes().get(base.len()) == Some(&b'/')
586}
587
588#[cfg(feature = "history-mmr")]
614pub(crate) fn history_executor() -> std::sync::Arc<mkit_core::history::TokioExecutor> {
615 use std::sync::{Arc, OnceLock};
616 static EXECUTOR: OnceLock<Arc<mkit_core::history::TokioExecutor>> = OnceLock::new();
617 EXECUTOR
618 .get_or_init(|| {
619 let exec = mkit_core::history::TokioExecutor::new()
620 .expect("history-mmr tokio runtime must initialise");
621 Arc::new(exec)
622 })
623 .clone()
624}
625
626pub fn write_ref_recording_history(
661 layout: &RepoLayout,
662 branch: &str,
663 condition: RefWriteCondition,
664 new_hash: &Hash,
665) -> Result<(), RefError> {
666 #[cfg(feature = "history-mmr")]
667 {
668 let exec = history_executor();
669 let mut history = mkit_core::history::CommitHistory::open_at(exec, layout, branch)
670 .map_err(|e| RefError::InvalidRef(format!("{branch}: open history journal: {e}")))?;
671
672 let store = ObjectStore::open(layout)
678 .map_err(|e| RefError::InvalidRef(format!("{branch}: open object store: {e}")))?;
679
680 refs::update_ref_with_history_and_backfill(
681 layout,
682 branch,
683 condition,
684 new_hash,
685 &mut history,
686 |h| match store.read_object(h) {
687 Ok(Object::Commit(c)) => Ok(c.parents.first().copied()),
688 Ok(Object::Remix(r)) => Ok(r.parents.first().copied()),
689 Ok(_) => Err(format!(
690 "{}: object is not a commit or remix",
691 mkit_core::hash::to_hex(h)
692 )),
693 Err(e) => Err(e.to_string()),
694 },
695 )
696 }
697 #[cfg(not(feature = "history-mmr"))]
698 {
699 refs::update_ref(layout, branch, condition, new_hash)
700 }
701}
702
703pub fn delete_ref_recording_history(layout: &RepoLayout, branch: &str) -> Result<(), RefError> {
724 #[cfg(feature = "history-mmr")]
725 {
726 refs::delete_ref_safe_with_history(layout, branch, history_executor())
727 }
728 #[cfg(not(feature = "history-mmr"))]
729 {
730 refs::delete_ref_safe(layout, branch)
731 }
732}
733
734pub fn delete_ref_dropping_history(layout: &RepoLayout, branch: &str) -> Result<(), RefError> {
752 #[cfg(feature = "history-mmr")]
753 {
754 refs::delete_ref_with_history(layout, branch, history_executor())
755 }
756 #[cfg(not(feature = "history-mmr"))]
757 {
758 refs::delete_ref(layout, branch)
759 }
760}
761
762pub fn delete_ref_dropping_history_if_matches(
781 layout: &RepoLayout,
782 branch: &str,
783 expected: Hash,
784) -> Result<(), RefError> {
785 #[cfg(feature = "history-mmr")]
786 {
787 refs::delete_ref_with_history_if_matches(layout, branch, expected, history_executor())
788 }
789 #[cfg(not(feature = "history-mmr"))]
790 {
791 refs::delete_ref_if_matches(layout, branch, expected)
792 }
793}
794
795#[must_use]
798pub fn head_branch_name(layout: &RepoLayout) -> String {
799 match refs::read_head(layout) {
800 Ok(Head::Branch(name)) => name,
801 _ => String::new(),
802 }
803}
804
805pub fn record_superseded(
817 layout: &RepoLayout,
818 op: &str,
819 branch: &str,
820 superseded: Hash,
821) -> Result<(), (String, u8)> {
822 let timestamp = std::time::SystemTime::now()
823 .duration_since(std::time::UNIX_EPOCH)
824 .map_or(0, |d| d.as_secs());
825 let entry = RecoveryEntry {
826 timestamp,
827 op: op.to_owned(),
828 superseded,
829 branch: branch.to_owned(),
830 };
831 recovery::record(layout, &entry).map_err(|e| (format!("recovery log: {e}"), exit::CANTCREAT))
832}
833
834pub fn sync_index_to_tree(
840 layout: &RepoLayout,
841 store: &ObjectStore,
842 tree_hash: Hash,
843) -> Result<(), String> {
844 let mut idx =
845 mkit_core::index::from_tree(store, tree_hash).map_err(|e| format!("index: {e}"))?;
846 if let Ok(old) = mkit_core::index::read_index(layout) {
851 let by_path: std::collections::HashMap<&str, &mkit_core::index::IndexEntry> =
854 old.entries.iter().map(|o| (o.path.as_str(), o)).collect();
855 for e in &mut idx.entries {
856 if let Some(o) = by_path.get(e.path.as_str())
857 && o.object_hash == e.object_hash
858 && o.status == e.status
859 {
860 e.mtime_ns = o.mtime_ns;
861 e.size = o.size;
862 e.ino = o.ino;
863 e.ctime_ns = o.ctime_ns;
864 }
865 }
866 }
867 mkit_core::index::write_index(layout, &idx).map_err(|e| format!("write index: {e}"))
868}
869
870pub fn stage_removed_tombstones(
880 layout: &RepoLayout,
881 store: &ObjectStore,
882 base_tree: Option<Hash>,
883 result_tree: Hash,
884) -> Result<(), String> {
885 let diff = diff_trees(store, base_tree, Some(result_tree))
886 .map_err(|e| format!("diff for staged deletions: {e}"))?;
887 let removed: Vec<String> = diff
888 .entries
889 .iter()
890 .filter(|e| e.kind == DiffKind::Removed)
891 .map(|e| e.path.clone())
892 .collect();
893 if removed.is_empty() {
894 return Ok(());
895 }
896 let mut idx = mkit_core::index::read_index(layout).map_err(|e| format!("read index: {e}"))?;
897 for path in removed {
898 match idx.find_entry(&path) {
899 Some(j) => {
900 idx.entries[j].status = EntryStatus::Removed;
901 idx.entries[j].object_hash = mkit_core::hash::ZERO;
902 }
903 None => idx.upsert_entry(mkit_core::index::IndexEntry {
904 path,
905 status: EntryStatus::Removed,
906 object_hash: mkit_core::hash::ZERO,
907 mtime_ns: 0,
908 size: 0,
909 ino: 0,
910 ctime_ns: 0,
911 }),
912 }
913 }
914 mkit_core::index::write_index(layout, &idx).map_err(|e| format!("write index: {e}"))
915}
916
917pub fn restore_worktree_and_index(
919 layout: &RepoLayout,
920 store: &ObjectStore,
921 tree_hash: Hash,
922) -> Result<(), String> {
923 restore_tree_to_worktree(
924 store,
925 &tree_hash,
926 layout.worktree_root(),
927 &RestoreOptions::default(),
928 )
929 .map_err(|e| format!("restore worktree: {e}"))?;
930 sync_index_to_tree(layout, store, tree_hash)
931}
932
933pub fn ensure_restore_safe(
935 layout: &RepoLayout,
936 store: &ObjectStore,
937 target_tree: Hash,
938) -> Result<(), String> {
939 ensure_restore_safe_with_options(layout, store, target_tree, &RestoreOptions::default())
940}
941
942pub fn ensure_restore_safe_with_options(
944 layout: &RepoLayout,
945 store: &ObjectStore,
946 target_tree: Hash,
947 options: &RestoreOptions,
948) -> Result<(), String> {
949 let root = layout.worktree_root();
950 let current_tree = current_head_tree(layout, store)?;
951 let idx = read_or_seed_index_from_head(layout, store)?;
952 let snapshot = mkit_core::store::EphemeralSink::new(store);
955 let index_tree = core_worktree::build_tree_from_index_with(store, &snapshot, &idx, false)
956 .map_err(|e| format!("check index state: {e}"))?;
957
958 let staged = diff_trees(&snapshot, current_tree, Some(index_tree))
959 .map_err(|e| format!("check staged changes: {e}"))?;
960 if let Some(entry) = staged
961 .entries
962 .iter()
963 .find(|entry| restore_affects_path(options, &entry.path))
964 {
965 return Err(format!(
966 "restore would overwrite staged changes; commit, stash, or reset '{}' first",
967 entry.path
968 ));
969 }
970
971 let worktree_tree = core_worktree::build_tree_filtered(&snapshot, root, Some(&idx))
972 .map_err(|e| format!("check working tree changes: {e}"))?;
973 let unstaged = diff_trees(&snapshot, Some(index_tree), Some(worktree_tree))
974 .map_err(|e| format!("check working tree changes: {e}"))?;
975 if let Some(entry) = unstaged
976 .entries
977 .iter()
978 .find(|entry| entry.kind != DiffKind::Added && restore_affects_path(options, &entry.path))
979 {
980 return Err(format!(
981 "restore would overwrite local changes; commit, stash, or reset '{}' first",
982 entry.path
983 ));
984 }
985
986 let target_writes = diff_trees(&snapshot, Some(index_tree), Some(target_tree))
987 .map_err(|e| format!("check restore target: {e}"))?
988 .entries
989 .into_iter()
990 .filter(|entry| entry.kind != DiffKind::Removed)
991 .filter(|entry| restore_affects_path(options, &entry.path))
992 .map(|entry| entry.path)
993 .collect::<Vec<_>>();
994 if target_writes.is_empty() && !options.clean {
995 return Ok(());
996 }
997
998 let ignore = mkit_core::ignore::load(root).map_err(|e| format!("read ignore file: {e}"))?;
999 let mut worktree_paths = Vec::new();
1000 collect_worktree_paths(root, root, "", &mut worktree_paths)
1001 .map_err(|e| format!("check untracked paths: {e}"))?;
1002 if let Some(path) = worktree_paths.iter().find(|path| {
1003 !index_tracks_path_or_descendant(&idx, path)
1004 && target_writes
1005 .iter()
1006 .any(|target| paths_overlap(path, target))
1007 }) {
1008 return Err(format!(
1009 "restore would overwrite untracked path '{path}'; move or remove it first"
1010 ));
1011 }
1012
1013 if options.clean
1014 && let Some(path) = worktree_paths.iter().find(|path| {
1015 !index_tracks_path_or_descendant(&idx, path)
1016 && restore_affects_path(options, path)
1017 && *path != ".mkitignore"
1018 && *path != ".gitignore"
1019 && !is_ignored_worktree_path(root, &ignore, path)
1020 })
1021 {
1022 return Err(format!(
1023 "restore would remove untracked path '{path}'; move or remove it first"
1024 ));
1025 }
1026
1027 Ok(())
1028}
1029
1030pub(crate) fn restore_affects_path(options: &RestoreOptions, path: &str) -> bool {
1031 options
1032 .sparse_patterns
1033 .as_deref()
1034 .is_none_or(|patterns| matches_sparse(patterns, path, false))
1035}
1036
1037pub(crate) fn dropped_tracked_paths(
1044 layout: &RepoLayout,
1045 store: &ObjectStore,
1046 target_tree: Hash,
1047) -> Result<Vec<(String, EntryStatus, Hash)>, String> {
1048 let idx = read_or_seed_index_from_head(layout, store)?;
1049 let snapshot = mkit_core::store::EphemeralSink::new(store);
1050 let index_tree = core_worktree::build_tree_from_index_with(store, &snapshot, &idx, false)
1051 .map_err(|e| format!("index tree: {e}"))?;
1052 let mut out = Vec::new();
1053 for e in diff_trees(&snapshot, Some(index_tree), Some(target_tree))
1054 .map_err(|e| format!("diff index vs target: {e}"))?
1055 .entries
1056 .into_iter()
1057 .filter(|e| e.kind == DiffKind::Removed)
1058 {
1059 if let Some(entry) = idx
1060 .entries
1061 .iter()
1062 .find(|ie| ie.path == e.path && ie.status != EntryStatus::Removed)
1063 {
1064 out.push((e.path, entry.status, entry.object_hash));
1065 }
1066 }
1067 Ok(out)
1068}
1069
1070pub(crate) fn locally_modified_dropped_path(
1077 cwd: &Path,
1078 store: &ObjectStore,
1079 dropped: &[(String, EntryStatus, Hash)],
1080) -> Result<Option<String>, String> {
1081 for (path, idx_status, idx_hash) in dropped {
1082 if let Some((wt_status, wt_hash)) = worktree_entry_state(cwd, store, path)?
1083 && (wt_status != *idx_status || wt_hash != *idx_hash)
1084 {
1085 return Ok(Some(path.clone()));
1086 }
1087 }
1088 Ok(None)
1089}
1090
1091pub(crate) fn remove_dropped_path(abs: &Path) -> std::io::Result<()> {
1097 match fs::symlink_metadata(abs) {
1098 Ok(meta) if meta.is_dir() => Ok(()),
1099 Ok(_) => fs::remove_file(abs),
1100 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1101 Err(e) => Err(e),
1102 }
1103}
1104
1105fn is_ignored_worktree_path(
1106 root: &Path,
1107 ignore: &mkit_core::ignore::IgnoreList,
1108 path: &str,
1109) -> bool {
1110 let full_path = root.join(path);
1111 let Ok(meta) = fs::symlink_metadata(&full_path) else {
1112 return false;
1113 };
1114 ignore.is_ignored_with_ancestors(path, meta.is_dir())
1117}
1118
1119pub(crate) fn current_head_tree(
1120 layout: &RepoLayout,
1121 store: &ObjectStore,
1122) -> Result<Option<Hash>, String> {
1123 let Some(head_hash) = refs::resolve_head(layout).map_err(|e| format!("resolve HEAD: {e}"))?
1124 else {
1125 return Ok(None);
1126 };
1127 match store
1128 .read_object(&head_hash)
1129 .map_err(|e| format!("read HEAD: {e}"))?
1130 {
1131 Object::Commit(c) => Ok(Some(c.tree_hash)),
1132 Object::Remix(r) => Ok(Some(r.tree_hash)),
1133 _ => Err("HEAD does not resolve to a commit or remix".to_string()),
1134 }
1135}
1136
1137pub(crate) fn collect_worktree_paths(
1138 root: &Path,
1139 dir: &Path,
1140 prefix: &str,
1141 out: &mut Vec<String>,
1142) -> std::io::Result<()> {
1143 let read = match fs::read_dir(dir) {
1144 Ok(read) => read,
1145 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1146 Err(e) => return Err(e),
1147 };
1148 for entry in read {
1149 let entry = entry?;
1150 let name = entry.file_name();
1151 let Some(name) = name.to_str() else {
1152 continue;
1153 };
1154 if name.eq_ignore_ascii_case(".mkit") || name.eq_ignore_ascii_case(".git") {
1155 continue;
1156 }
1157 let path = if prefix.is_empty() {
1158 name.to_string()
1159 } else {
1160 format!("{prefix}/{name}")
1161 };
1162 out.push(path.clone());
1163 let full_path = root.join(&path);
1164 let meta = fs::symlink_metadata(&full_path)?;
1165 if meta.is_dir() {
1166 collect_worktree_paths(root, &full_path, &path, out)?;
1167 }
1168 }
1169 Ok(())
1170}
1171
1172pub(crate) fn index_tracks_path_or_descendant(index: &Index, path: &str) -> bool {
1173 index.tracks_path_or_descendant(path)
1178}
1179
1180fn paths_overlap(left: &str, right: &str) -> bool {
1181 index_path_matches_or_descends(left, right) || index_path_descends_from(right, left)
1182}
1183
1184pub fn read_or_seed_index_from_head(
1191 layout: &RepoLayout,
1192 store: &ObjectStore,
1193) -> Result<mkit_core::index::Index, String> {
1194 let idx = mkit_core::index::read_index(layout).map_err(|e| format!("read index: {e}"))?;
1195 if !idx.entries.is_empty() {
1196 return Ok(idx);
1197 }
1198
1199 let Some(head_hash) =
1200 mkit_core::refs::resolve_head(layout).map_err(|e| format!("resolve HEAD: {e}"))?
1201 else {
1202 return Ok(idx);
1203 };
1204 match store
1205 .read_object(&head_hash)
1206 .map_err(|e| format!("read HEAD: {e}"))?
1207 {
1208 Object::Commit(c) => mkit_core::index::from_tree(store, c.tree_hash)
1209 .map_err(|e| format!("index from HEAD: {e}")),
1210 Object::Remix(r) => mkit_core::index::from_tree(store, r.tree_hash)
1211 .map_err(|e| format!("index from HEAD: {e}")),
1212 _ => Err("HEAD does not resolve to a commit or remix".to_string()),
1213 }
1214}
1215
1216#[cfg(test)]
1217mod tests {
1218 use super::{advance_head, c_quote_path, restore_head_ref};
1219 use mkit_core::hash::Hash;
1220
1221 #[cfg(feature = "history-mmr")]
1222 fn write_commit(store: &mkit_core::store::ObjectStore, parents: Vec<Hash>, seed: u8) -> Hash {
1223 use mkit_core::object::{Commit, Identity, Object};
1224
1225 let commit = Commit::new_unannotated(
1226 [seed; 32],
1227 parents,
1228 Identity::ed25519([seed; 32]),
1229 [seed; 32],
1230 b"msg".to_vec(),
1231 0,
1232 [0u8; 64],
1233 );
1234 let bytes = mkit_core::serialize::serialize(&Object::Commit(commit)).unwrap();
1235 store.write(&bytes).unwrap()
1236 }
1237
1238 #[cfg(feature = "history-mmr")]
1239 #[test]
1240 fn write_ref_recording_history_backfills_v01x_style_repo_from_object_store() {
1241 use super::write_ref_recording_history;
1242 use mkit_core::history::{CommitHistory, Position, TokioExecutor, verify_inclusion};
1243 use mkit_core::refs::{self, RefWriteCondition};
1244 use mkit_core::store::ObjectStore;
1245 use std::sync::Arc;
1246
1247 let td = tempfile::tempdir().unwrap();
1248 let repo_root = td.path();
1249 let layout = mkit_core::layout::RepoLayout::single(repo_root);
1250 let store = ObjectStore::init(&layout).unwrap();
1251
1252 let c0 = write_commit(&store, vec![], 1);
1257 let c1 = write_commit(&store, vec![c0], 2);
1258 let c2 = write_commit(&store, vec![c1], 3);
1259 refs::write_ref(&layout, "main", &c2).unwrap();
1260
1261 let c3 = write_commit(&store, vec![c2], 4);
1264 write_ref_recording_history(&layout, "main", RefWriteCondition::Match(c2), &c3).unwrap();
1265
1266 assert_eq!(refs::read_ref(&layout, "main").unwrap(), Some(c3));
1267
1268 let exec = Arc::new(TokioExecutor::new().unwrap());
1271 let hist = CommitHistory::open_at(exec, &layout, "main").unwrap();
1272 assert_eq!(hist.len(), 4);
1273 let root = hist.root();
1274 for (i, c) in [c0, c1, c2, c3].into_iter().enumerate() {
1275 let pos = Position(i as u64);
1276 let proof = hist.prove(pos).unwrap();
1277 assert!(
1278 verify_inclusion(&c, pos, &proof, &root),
1279 "commit at position {i} failed inclusion proof after backfill"
1280 );
1281 }
1282 }
1283
1284 #[cfg(feature = "history-mmr")]
1285 #[test]
1286 fn write_ref_recording_history_does_not_backfill_a_genuinely_fresh_branch() {
1287 use super::write_ref_recording_history;
1288 use mkit_core::history::{CommitHistory, TokioExecutor};
1289 use mkit_core::refs::RefWriteCondition;
1290 use mkit_core::store::ObjectStore;
1291 use std::sync::Arc;
1292
1293 let td = tempfile::tempdir().unwrap();
1294 let repo_root = td.path();
1295 let layout = mkit_core::layout::RepoLayout::single(repo_root);
1296 let store = ObjectStore::init(&layout).unwrap();
1297
1298 let c0 = write_commit(&store, vec![], 1);
1301 write_ref_recording_history(&layout, "main", RefWriteCondition::Missing, &c0).unwrap();
1302
1303 let exec = Arc::new(TokioExecutor::new().unwrap());
1304 let hist = CommitHistory::open_at(exec, &layout, "main").unwrap();
1305 assert_eq!(
1306 hist.len(),
1307 1,
1308 "only the one real write, no phantom backfill entries"
1309 );
1310 }
1311
1312 #[cfg(feature = "history-mmr")]
1316 const CONCURRENT_BACKFILL_CHAIN_LEN: usize = 500;
1317
1318 #[cfg(feature = "history-mmr")]
1337 #[test]
1338 fn write_ref_recording_history_concurrent_backfill_does_not_duplicate_journal_leaves() {
1339 use super::write_ref_recording_history;
1340 use mkit_core::history::{CommitHistory, TokioExecutor};
1341 use mkit_core::refs::{self, RefWriteCondition};
1342 use mkit_core::store::ObjectStore;
1343 use std::sync::{Arc, Barrier};
1344
1345 let td = tempfile::tempdir().unwrap();
1346 let repo_root = td.path();
1347 let layout = Arc::new(mkit_core::layout::RepoLayout::single(repo_root));
1348 let store = ObjectStore::init(&layout).unwrap();
1349
1350 let mut tip: Option<Hash> = None;
1351 for seed in 0..CONCURRENT_BACKFILL_CHAIN_LEN {
1352 let seed = u8::try_from(seed % 256).expect("seed % 256 fits in u8");
1353 tip = Some(write_commit(&store, tip.into_iter().collect(), seed));
1354 }
1355 let tip = tip.unwrap();
1356 refs::write_ref(&layout, "main", &tip).unwrap();
1357
1358 let c_a = write_commit(&store, vec![tip], 250);
1361 let c_b = write_commit(&store, vec![tip], 251);
1362
1363 let barrier = Arc::new(Barrier::new(2));
1364
1365 let (layout_a, barrier_a) = (Arc::clone(&layout), Arc::clone(&barrier));
1366 let t_a = std::thread::spawn(move || {
1367 barrier_a.wait();
1368 write_ref_recording_history(&layout_a, "main", RefWriteCondition::Any, &c_a)
1369 });
1370 let (layout_b, barrier_b) = (Arc::clone(&layout), Arc::clone(&barrier));
1371 let t_b = std::thread::spawn(move || {
1372 barrier_b.wait();
1373 write_ref_recording_history(&layout_b, "main", RefWriteCondition::Any, &c_b)
1374 });
1375
1376 let res_a = t_a.join().expect("thread a must not panic");
1377 let res_b = t_b.join().expect("thread b must not panic");
1378 res_a.expect("writer a must succeed");
1379 res_b.expect("writer b must succeed");
1380
1381 let exec = Arc::new(TokioExecutor::new().unwrap());
1382 let hist = CommitHistory::open_at(exec, &layout, "main").unwrap();
1383 assert_eq!(
1384 hist.len(),
1385 CONCURRENT_BACKFILL_CHAIN_LEN as u64 + 2,
1386 "two concurrent first-writers on a never-journaled branch \
1387 must backfill the shared chain exactly once between them \
1388 (plus their own two real appends) — a leaf count above \
1389 this means the backfill ran twice and duplicated leaves"
1390 );
1391 }
1392
1393 #[test]
1394 fn c_quote_leaves_plain_paths_alone() {
1395 assert_eq!(c_quote_path("a.txt"), None);
1396 assert_eq!(c_quote_path("dir/with space.txt"), None); assert_eq!(c_quote_path("weird-but-ascii_!@#$%.rs"), None);
1398 }
1399
1400 #[test]
1401 fn c_quote_escapes_special_bytes() {
1402 assert_eq!(c_quote_path("a\tb.txt").as_deref(), Some(r#""a\tb.txt""#));
1403 assert_eq!(
1404 c_quote_path("line\nfeed").as_deref(),
1405 Some(r#""line\nfeed""#)
1406 );
1407 assert_eq!(c_quote_path("q\"x").as_deref(), Some(r#""q\"x""#));
1408 assert_eq!(
1409 c_quote_path("back\\slash").as_deref(),
1410 Some(r#""back\\slash""#)
1411 );
1412 }
1413
1414 #[test]
1415 fn c_quote_octal_escapes_non_ascii() {
1416 assert_eq!(c_quote_path("é").as_deref(), Some(r#""\303\251""#));
1418 assert_eq!(c_quote_path("x-é").as_deref(), Some(r#""x-\303\251""#));
1420 }
1421
1422 #[test]
1430 fn advance_head_errors_when_head_missing_instead_of_writing_main() {
1431 let td = tempfile::tempdir().unwrap();
1432 let layout = mkit_core::layout::RepoLayout::single(td.path());
1433 let new_head: Hash = [0x11; 32];
1435 let err = advance_head(&layout, &new_head).expect_err("missing HEAD must error");
1436 assert!(err.contains("read HEAD"), "unexpected error: {err}");
1437 assert!(
1439 !layout.heads_dir().join("main").exists(),
1440 "advance_head must not write refs/heads/main when HEAD is unreadable"
1441 );
1442 }
1443
1444 #[test]
1445 fn restore_head_ref_errors_when_head_missing_instead_of_writing_main() {
1446 let td = tempfile::tempdir().unwrap();
1447 let layout = mkit_core::layout::RepoLayout::single(td.path());
1448 let target: Hash = [0x22; 32];
1449 let code = restore_head_ref(&layout, &target).expect_err("missing HEAD must error");
1450 assert_eq!(code, crate::exit::DATAERR);
1451 assert!(
1452 !layout.heads_dir().join("main").exists(),
1453 "restore_head_ref must not write refs/heads/main when HEAD is unreadable"
1454 );
1455 }
1456}