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 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
82pub 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#[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#[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
115pub const WORKTREE_LOCK: &str = "worktree.lock";
122
123pub 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
148pub(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
191pub(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
232pub(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
277pub(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#[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
390pub 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#[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
439pub 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
468pub 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 if let Ok(old) = mkit_core::index::read_index(root) {
481 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
500pub 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
511pub 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
520pub 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 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
614pub(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
647pub(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
668pub(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 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
759pub 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); 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 assert_eq!(c_quote_path("é").as_deref(), Some(r#""\303\251""#));
821 assert_eq!(c_quote_path("x-é").as_deref(), Some(r#""x-\303\251""#));
823 }
824}