1use std::fs;
31use std::io::{self, Write};
32use std::path::{Path, PathBuf};
33use std::process;
34use std::sync::atomic::{AtomicU64, Ordering};
35
36use crate::hash::Hash;
37use crate::ignore::{self, IgnoreList};
38use crate::layout::RepoLayout;
39use crate::object::{self, EntryMode, Object, TreeEntry};
40use crate::store::{MAX_TREE_DEPTH, ObjectStore};
41use crate::worktree;
42
43const MAX_SPARSE_BYTES: u64 = 1024 * 1024;
44
45static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
46
47#[derive(Debug, thiserror::Error)]
49pub enum RestoreError {
50 #[error("requested object is not a tree")]
51 NotATree,
52 #[error("requested object is not a blob or chunked-blob")]
53 NotABlob,
54 #[error("symlink target '{0}' is invalid (absolute or contains '..')")]
55 InvalidSymlinkTarget(String),
56 #[error("path '{0}' is occupied by something other than a directory")]
57 NotADirectory(PathBuf),
58 #[error("path component is not valid UTF-8")]
59 InvalidUtf8,
60 #[error("tree nesting exceeds {} levels", MAX_TREE_DEPTH)]
61 TreeTooDeep,
62 #[error(transparent)]
63 Object(#[from] object::MkitError),
64 #[error(transparent)]
65 Store(#[from] crate::store::StoreError),
66 #[error(transparent)]
67 Io(#[from] io::Error),
68}
69
70pub type RestoreResult<T> = Result<T, RestoreError>;
72
73#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct SparsePattern {
76 pub pattern: String,
77 pub negated: bool,
78 pub dir_only: bool,
79}
80
81#[derive(Debug, Clone)]
83pub struct RestoreOptions {
84 pub clean: bool,
87 pub sparse_patterns: Option<Vec<SparsePattern>>,
89}
90
91impl Default for RestoreOptions {
92 fn default() -> Self {
93 Self {
94 clean: true,
95 sparse_patterns: None,
96 }
97 }
98}
99
100#[must_use]
102pub fn parse_sparse_patterns(content: &str) -> Vec<SparsePattern> {
103 let mut out = Vec::new();
104 for raw in content.split('\n') {
105 let line = raw.trim_end_matches(['\r', ' ']);
106 if line.is_empty() || line.starts_with('#') {
107 continue;
108 }
109 let (negated, rest) = if let Some(stripped) = line.strip_prefix('!') {
110 (true, stripped)
111 } else {
112 (false, line)
113 };
114 let (dir_only, pat) = if let Some(stripped) = rest.strip_suffix('/') {
115 (true, stripped)
116 } else {
117 (false, rest)
118 };
119 if pat.is_empty() {
120 continue;
121 }
122 out.push(SparsePattern {
123 pattern: pat.to_string(),
124 negated,
125 dir_only,
126 });
127 }
128 out
129}
130
131#[must_use]
134pub fn matches_sparse(patterns: &[SparsePattern], path: &str, is_dir: bool) -> bool {
135 let mut matched = false;
136 for pat in patterns {
137 if path_matches_pattern(&pat.pattern, path) {
138 if pat.dir_only && !is_dir {
139 let pat_stripped = pat.pattern.strip_suffix('/').unwrap_or(&pat.pattern);
140 if pat_stripped == path {
141 continue;
142 }
143 }
144 matched = !pat.negated;
145 }
146 }
147 matched
148}
149
150#[must_use]
153pub fn could_match_descendant(patterns: &[SparsePattern], dir_prefix: &str) -> bool {
154 for pat in patterns {
155 if pat.negated {
156 continue;
157 }
158 if pat.pattern.starts_with(dir_prefix) {
159 return true;
160 }
161 if dir_prefix.starts_with(&pat.pattern) {
162 return true;
163 }
164 if !dir_prefix.is_empty()
165 && !dir_prefix.ends_with('/')
166 && pat.pattern.len() > dir_prefix.len()
167 && pat.pattern.starts_with(dir_prefix)
168 && pat.pattern.as_bytes()[dir_prefix.len()] == b'/'
169 {
170 return true;
171 }
172 }
173 false
174}
175
176pub fn load_sparse_checkout(layout: &RepoLayout) -> RestoreResult<Option<Vec<SparsePattern>>> {
182 let path = layout.sparse_checkout_file();
183 let meta = match fs::metadata(&path) {
184 Ok(m) => m,
185 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
186 Err(e) => return Err(RestoreError::Io(e)),
187 };
188 if meta.len() > MAX_SPARSE_BYTES {
189 return Err(RestoreError::Io(io::Error::other(
190 "sparse-checkout too large",
191 )));
192 }
193 let raw = fs::read_to_string(&path)?;
194 let patterns = parse_sparse_patterns(&raw);
195 if patterns.is_empty() {
196 Ok(None)
197 } else {
198 Ok(Some(patterns))
199 }
200}
201
202pub fn write_sparse_checkout(layout: &RepoLayout, lines: &[&str]) -> RestoreResult<()> {
208 let mkit_dir = layout.worktree_state_dir().to_path_buf();
209 fs::create_dir_all(&mkit_dir)?;
210 let mut buf = String::new();
211 for l in lines {
212 buf.push_str(l);
213 buf.push('\n');
214 }
215 let path = layout.sparse_checkout_file();
216 crate::atomic::write_atomic(&path, buf.as_bytes(), true)?;
217 Ok(())
218}
219
220fn path_matches_pattern(pattern: &str, path: &str) -> bool {
221 let pat = pattern.strip_suffix('/').unwrap_or(pattern);
222 if pat == path {
223 return true;
224 }
225 if path.len() > pat.len() && path.starts_with(pat) && path.as_bytes()[pat.len()] == b'/' {
226 return true;
227 }
228 if !pat.contains('/')
229 && let Some(last) = path.rfind('/')
230 {
231 let basename = &path[last + 1..];
232 if basename == pat {
233 return true;
234 }
235 }
236 false
237}
238
239#[derive(Debug, Default, Clone, PartialEq, Eq)]
244pub struct RestoreReport {
245 pub files_written: u32,
247 pub symlinks_written: u32,
249 pub directories_created: u32,
251}
252
253pub fn restore_tree_to_worktree(
277 store: &ObjectStore,
278 tree: &Hash,
279 root: &Path,
280 opts: &RestoreOptions,
281) -> RestoreResult<RestoreReport> {
282 let ignore_list = match ignore::load(root) {
284 Ok(il) => il,
285 Err(_) => IgnoreList::new(),
286 };
287 fs::create_dir_all(root)?;
288 let mut report = RestoreReport::default();
289 restore_tree_to_worktree_inner(store, *tree, root, opts, "", &ignore_list, &mut report, 0)?;
290 Ok(report)
291}
292
293#[allow(clippy::too_many_arguments)]
294fn restore_tree_to_worktree_inner(
295 store: &ObjectStore,
296 tree_hash: Hash,
297 target_dir: &Path,
298 options: &RestoreOptions,
299 path_prefix: &str,
300 ignore: &IgnoreList,
301 report: &mut RestoreReport,
302 depth: usize,
303) -> RestoreResult<()> {
304 if depth > MAX_TREE_DEPTH {
305 return Err(RestoreError::TreeTooDeep);
306 }
307 let obj = store.read_object(&tree_hash)?;
308 let Object::Tree(tree) = obj else {
309 return Err(RestoreError::NotATree);
310 };
311
312 if options.clean {
313 clean_directory(
314 target_dir,
315 &tree.entries,
316 options.sparse_patterns.as_deref(),
317 path_prefix,
318 Some(ignore),
319 )?;
320 }
321
322 for entry in &tree.entries {
323 if !crate::object::TreeEntry::validate_name(&entry.name) {
324 continue;
325 }
326 let name = std::str::from_utf8(&entry.name).map_err(|_| RestoreError::InvalidUtf8)?;
327 let full_path = if path_prefix.is_empty() {
328 name.to_string()
329 } else {
330 format!("{path_prefix}/{name}")
331 };
332 match entry.mode {
338 EntryMode::Blob | EntryMode::Executable => {
339 if let Some(patterns) = options.sparse_patterns.as_deref()
340 && !matches_sparse(patterns, &full_path, false)
341 {
342 continue;
343 }
344 restore_blob(
345 store,
346 target_dir,
347 name,
348 entry.object_hash,
349 entry.mode == EntryMode::Executable,
350 )?;
351 report.files_written += 1;
352 }
353 EntryMode::Tree => {
354 if let Some(patterns) = options.sparse_patterns.as_deref()
355 && !could_match_descendant(patterns, &full_path)
356 {
357 continue;
358 }
359 ensure_directory(target_dir, name)?;
360 report.directories_created += 1;
361 let dir_path = target_dir.join(name);
362 let dir_meta = fs::symlink_metadata(&dir_path)?;
363 if !dir_meta.is_dir() {
364 return Err(RestoreError::NotADirectory(dir_path));
365 }
366 restore_tree_to_worktree_inner(
367 store,
368 entry.object_hash,
369 &dir_path,
370 options,
371 &full_path,
372 ignore,
373 report,
374 depth + 1,
375 )?;
376 }
377 EntryMode::Symlink => {
378 if let Some(patterns) = options.sparse_patterns.as_deref()
379 && !matches_sparse(patterns, &full_path, false)
380 {
381 continue;
382 }
383 restore_symlink(store, target_dir, name, entry.object_hash)?;
384 report.symlinks_written += 1;
385 }
386 }
387 }
388 Ok(())
389}
390
391pub fn restore_tree(
399 store: &ObjectStore,
400 tree_hash: Hash,
401 target_dir: &Path,
402 options: &RestoreOptions,
403) -> RestoreResult<()> {
404 fs::create_dir_all(target_dir)?;
405 restore_tree_inner(store, tree_hash, target_dir, options, "")
406}
407
408fn restore_tree_inner(
409 store: &ObjectStore,
410 tree_hash: Hash,
411 target_dir: &Path,
412 options: &RestoreOptions,
413 path_prefix: &str,
414) -> RestoreResult<()> {
415 let obj = store.read_object(&tree_hash)?;
416 let Object::Tree(tree) = obj else {
417 return Err(RestoreError::NotATree);
418 };
419
420 if options.clean {
421 clean_directory(
422 target_dir,
423 &tree.entries,
424 options.sparse_patterns.as_deref(),
425 path_prefix,
426 None,
427 )?;
428 }
429
430 for entry in &tree.entries {
431 if !crate::object::TreeEntry::validate_name(&entry.name) {
432 continue;
433 }
434 let name = std::str::from_utf8(&entry.name).map_err(|_| RestoreError::InvalidUtf8)?;
435 let full_path = if path_prefix.is_empty() {
436 name.to_string()
437 } else {
438 format!("{path_prefix}/{name}")
439 };
440 match entry.mode {
441 EntryMode::Blob | EntryMode::Executable => {
442 if let Some(patterns) = options.sparse_patterns.as_deref()
443 && !matches_sparse(patterns, &full_path, false)
444 {
445 continue;
446 }
447 restore_blob(
448 store,
449 target_dir,
450 name,
451 entry.object_hash,
452 entry.mode == EntryMode::Executable,
453 )?;
454 }
455 EntryMode::Tree => {
456 if let Some(patterns) = options.sparse_patterns.as_deref()
457 && !could_match_descendant(patterns, &full_path)
458 {
459 continue;
460 }
461 ensure_directory(target_dir, name)?;
462 let dir_path = target_dir.join(name);
463 let dir_meta = fs::symlink_metadata(&dir_path)?;
465 if !dir_meta.is_dir() {
466 return Err(RestoreError::NotADirectory(dir_path));
467 }
468 restore_tree_inner(store, entry.object_hash, &dir_path, options, &full_path)?;
469 }
470 EntryMode::Symlink => {
471 if let Some(patterns) = options.sparse_patterns.as_deref()
472 && !matches_sparse(patterns, &full_path, false)
473 {
474 continue;
475 }
476 restore_symlink(store, target_dir, name, entry.object_hash)?;
477 }
478 }
479 }
480 Ok(())
481}
482
483fn restore_blob(
484 store: &ObjectStore,
485 dir: &Path,
486 name: &str,
487 blob_hash: Hash,
488 executable: bool,
489) -> RestoreResult<()> {
490 let obj = store.read_object(&blob_hash)?;
491 match obj {
492 Object::Blob(b) => write_file_atomic(dir, name, &b.data, executable)?,
493 Object::ChunkedBlob(cb) => {
494 let (tmp_path, final_path, mut tmp) = create_tmp_for_write(dir, name)?;
499 let mut written: u64 = 0;
500 for ch in &cb.chunks {
501 let chunk_obj = store.read_object(ch)?;
502 let Object::Blob(b) = chunk_obj else {
503 return Err(RestoreError::NotABlob);
504 };
505 tmp.write_all(&b.data)?;
506 written += b.data.len() as u64;
507 }
508 cb.check_reassembled_size(usize::try_from(written).unwrap_or(usize::MAX))?;
509 drop(tmp);
510 finish_atomic_write(&tmp_path, &final_path, executable)?;
511 }
512 _ => return Err(RestoreError::NotABlob),
513 }
514 Ok(())
515}
516
517fn restore_symlink(
518 store: &ObjectStore,
519 dir: &Path,
520 name: &str,
521 blob_hash: Hash,
522) -> RestoreResult<()> {
523 let obj = store.read_object(&blob_hash)?;
524 let Object::Blob(b) = obj else {
525 return Err(RestoreError::NotABlob);
526 };
527 let target = std::str::from_utf8(&b.data).map_err(|_| RestoreError::InvalidUtf8)?;
528 if !worktree::validate_symlink_target(target) {
529 return Err(RestoreError::InvalidSymlinkTarget(target.to_string()));
530 }
531 let tmp_name = make_tmp_sibling_name(name);
532 let tmp_path = dir.join(&tmp_name);
533 let final_path = dir.join(name);
534 let _ = fs::remove_file(&tmp_path);
535 create_symlink(target, &tmp_path)?;
536 prepare_path_for_rename(&final_path)?;
537 fs::rename(&tmp_path, &final_path)?;
538 Ok(())
539}
540
541#[cfg(unix)]
542fn create_symlink(target: &str, link: &Path) -> io::Result<()> {
543 std::os::unix::fs::symlink(target, link)
544}
545
546#[cfg(windows)]
547fn create_symlink(target: &str, link: &Path) -> io::Result<()> {
548 std::os::windows::fs::symlink_file(target, link)
552}
553
554#[cfg(not(any(unix, windows)))]
555fn create_symlink(_target: &str, _link: &Path) -> io::Result<()> {
556 Err(io::Error::new(
560 io::ErrorKind::Unsupported,
561 "symlink creation is not supported on this target",
562 ))
563}
564
565fn write_file_atomic(dir: &Path, name: &str, data: &[u8], executable: bool) -> io::Result<()> {
575 let (tmp_path, final_path, mut tmp) = create_tmp_for_write(dir, name)?;
576 tmp.write_all(data)?;
577 drop(tmp);
578 finish_atomic_write(&tmp_path, &final_path, executable)
579}
580
581fn create_tmp_for_write(dir: &Path, name: &str) -> io::Result<(PathBuf, PathBuf, fs::File)> {
588 let tmp_name = make_tmp_sibling_name(name);
589 let tmp_path = dir.join(&tmp_name);
590 let final_path = dir.join(name);
591 let _ = fs::remove_file(&tmp_path);
592 let tmp = fs::File::create(&tmp_path)?;
593 Ok((tmp_path, final_path, tmp))
594}
595
596fn finish_atomic_write(tmp_path: &Path, final_path: &Path, executable: bool) -> io::Result<()> {
601 if executable {
602 apply_executable_bit(tmp_path)?;
603 }
604 prepare_path_for_rename(final_path)?;
605 fs::rename(tmp_path, final_path)?;
606 Ok(())
607}
608
609#[cfg(unix)]
610fn apply_executable_bit(path: &Path) -> io::Result<()> {
611 use std::os::unix::fs::PermissionsExt;
612 let mut perm = fs::metadata(path)?.permissions();
613 perm.set_mode(0o755);
614 fs::set_permissions(path, perm)
615}
616
617#[cfg(not(unix))]
618#[allow(clippy::unnecessary_wraps)]
619fn apply_executable_bit(_path: &Path) -> io::Result<()> {
620 Ok(())
621}
622
623fn ensure_directory(parent: &Path, name: &str) -> io::Result<()> {
624 let path = parent.join(name);
625 match fs::symlink_metadata(&path) {
626 Ok(meta) if meta.is_dir() => return Ok(()),
627 Ok(_) => fs::remove_file(&path)?,
628 Err(e) if e.kind() == io::ErrorKind::NotFound => {}
629 Err(e) => return Err(e),
630 }
631 match fs::create_dir_all(&path) {
632 Ok(()) => Ok(()),
633 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
634 let meta = fs::symlink_metadata(&path)?;
635 if meta.is_dir() {
636 Ok(())
637 } else {
638 Err(io::Error::new(
639 io::ErrorKind::AlreadyExists,
640 "expected directory",
641 ))
642 }
643 }
644 Err(e) => Err(e),
645 }
646}
647
648fn prepare_path_for_rename(final_path: &Path) -> io::Result<()> {
649 match fs::symlink_metadata(final_path) {
650 Ok(meta) if meta.is_dir() => fs::remove_dir_all(final_path),
651 Ok(_) => Ok(()),
652 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
653 Err(e) => Err(e),
654 }
655}
656
657fn make_tmp_sibling_name(name: &str) -> String {
658 let pid = process::id();
659 let counter = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
660 format!(".{name}.tmp.{pid}.{counter}")
661}
662
663fn clean_directory(
674 target_dir: &Path,
675 tree_entries: &[TreeEntry],
676 sparse_patterns: Option<&[SparsePattern]>,
677 path_prefix: &str,
678 ignore: Option<&IgnoreList>,
679) -> RestoreResult<()> {
680 struct CleanItem {
681 name: String,
682 is_dir: bool,
683 }
684 let mut to_delete: Vec<CleanItem> = Vec::new();
685
686 let read = match fs::read_dir(target_dir) {
687 Ok(r) => r,
688 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
689 Err(e) => return Err(RestoreError::Io(e)),
690 };
691 for entry in read {
692 let entry = entry?;
693 let file_name = entry.file_name();
694 let name_str = file_name
695 .to_str()
696 .ok_or(RestoreError::InvalidUtf8)?
697 .to_string();
698 if name_str.eq_ignore_ascii_case(".mkit") || name_str.eq_ignore_ascii_case(".git") {
699 continue;
700 }
701 if name_str == ".mkitignore" {
702 continue;
703 }
704 if ignore.is_some() && name_str == ".gitignore" {
706 continue;
707 }
708 let mut found = false;
709 for te in tree_entries {
710 if te.name.as_slice() == name_str.as_bytes() {
711 found = true;
712 break;
713 }
714 }
715 if found {
716 continue;
717 }
718 let meta = entry.metadata()?;
719 let is_dir = meta.is_dir();
720 let full_path = if path_prefix.is_empty() {
721 name_str.clone()
722 } else {
723 format!("{path_prefix}/{name_str}")
724 };
725 if let Some(ignore) = ignore
730 && ignore.is_ignored_with_ancestors(&full_path, is_dir)
731 {
732 continue;
733 }
734 if let Some(patterns) = sparse_patterns {
735 let allow = matches_sparse(patterns, &full_path, is_dir)
736 || (is_dir && could_match_descendant(patterns, &full_path));
737 if !allow {
738 continue;
739 }
740 }
741 to_delete.push(CleanItem {
742 name: name_str,
743 is_dir,
744 });
745 }
746
747 for item in to_delete {
748 let path = target_dir.join(&item.name);
749 if item.is_dir {
750 let _ = fs::remove_dir_all(&path);
751 } else {
752 let _ = fs::remove_file(&path);
753 }
754 }
755 Ok(())
756}
757
758#[cfg(test)]
759mod tests {
760 use super::*;
761 use crate::object::{Tree, TreeEntry};
762 use crate::serialize;
763 use tempfile::TempDir;
764
765 fn fresh_store() -> (TempDir, ObjectStore) {
766 let dir = TempDir::new().unwrap();
767 let store = ObjectStore::init(&RepoLayout::single(dir.path())).unwrap();
768 (dir, store)
769 }
770
771 fn put_blob(store: &ObjectStore, data: &[u8]) -> Hash {
772 let bytes = serialize::serialize(&Object::Blob(crate::object::Blob {
773 data: data.to_vec(),
774 }))
775 .unwrap();
776 store.write(&bytes).unwrap()
777 }
778
779 fn put_tree_with(store: &ObjectStore, entries: Vec<TreeEntry>) -> Hash {
780 let bytes = serialize::serialize(&Object::Tree(Tree { entries })).unwrap();
781 store.write(&bytes).unwrap()
782 }
783
784 #[test]
785 fn parse_sparse_basic() {
786 let content = "# comment line\nsrc\n!tests\ndocs/\n\nREADME.md\n";
787 let p = parse_sparse_patterns(content);
788 assert_eq!(p.len(), 4);
789 assert_eq!(p[0].pattern, "src");
790 assert!(!p[0].negated);
791 assert!(!p[0].dir_only);
792 assert_eq!(p[1].pattern, "tests");
793 assert!(p[1].negated);
794 assert_eq!(p[2].pattern, "docs");
795 assert!(p[2].dir_only);
796 assert_eq!(p[3].pattern, "README.md");
797 }
798
799 #[test]
800 fn matches_sparse_exact_and_prefix() {
801 let p = vec![SparsePattern {
802 pattern: "src".to_string(),
803 negated: false,
804 dir_only: false,
805 }];
806 assert!(matches_sparse(&p, "src/main.rs", false));
807 assert!(matches_sparse(&p, "src/lib/util.rs", false));
808 assert!(!matches_sparse(&p, "tests/foo", false));
809 }
810
811 #[test]
812 fn matches_sparse_negation() {
813 let p = vec![
814 SparsePattern {
815 pattern: "src".to_string(),
816 negated: false,
817 dir_only: false,
818 },
819 SparsePattern {
820 pattern: "src/secret".to_string(),
821 negated: true,
822 dir_only: false,
823 },
824 ];
825 assert!(matches_sparse(&p, "src/main.rs", false));
826 assert!(!matches_sparse(&p, "src/secret/key.pem", false));
827 }
828
829 #[test]
830 fn matches_sparse_dir_only() {
831 let p = vec![SparsePattern {
832 pattern: "build".to_string(),
833 negated: false,
834 dir_only: true,
835 }];
836 assert!(matches_sparse(&p, "build", true));
837 assert!(!matches_sparse(&p, "build", false));
838 }
839
840 #[test]
841 fn matches_sparse_last_match_wins() {
842 let p = vec![
843 SparsePattern {
844 pattern: "src".to_string(),
845 negated: false,
846 dir_only: false,
847 },
848 SparsePattern {
849 pattern: "src".to_string(),
850 negated: true,
851 dir_only: false,
852 },
853 ];
854 assert!(!matches_sparse(&p, "src/main.rs", false));
855 }
856
857 #[test]
858 fn matches_sparse_bare_basename() {
859 let p = vec![SparsePattern {
860 pattern: "Makefile".to_string(),
861 negated: false,
862 dir_only: false,
863 }];
864 assert!(matches_sparse(&p, "Makefile", false));
865 assert!(matches_sparse(&p, "sub/Makefile", false));
866 assert!(!matches_sparse(&p, "Makefile.bak", false));
867 }
868
869 #[test]
870 fn could_match_descendant_basic() {
871 let p = vec![SparsePattern {
872 pattern: "src/lib".to_string(),
873 negated: false,
874 dir_only: false,
875 }];
876 assert!(could_match_descendant(&p, "src"));
877 assert!(could_match_descendant(&p, "src/lib"));
878 assert!(!could_match_descendant(&p, "tests"));
879 }
880
881 #[test]
882 fn restore_empty_tree_creates_no_files() {
883 let (_d, store) = fresh_store();
884 let target = TempDir::new().unwrap();
885 let tree_h = put_tree_with(&store, vec![]);
886 restore_tree(&store, tree_h, target.path(), &RestoreOptions::default()).unwrap();
887 let count = fs::read_dir(target.path()).unwrap().count();
888 assert_eq!(count, 0);
889 }
890
891 #[test]
892 fn restore_single_file() {
893 let (_d, store) = fresh_store();
894 let target = TempDir::new().unwrap();
895 let blob = put_blob(&store, b"hello");
896 let tree = put_tree_with(
897 &store,
898 vec![TreeEntry {
899 name: b"file.txt".to_vec(),
900 mode: EntryMode::Blob,
901 object_hash: blob,
902 }],
903 );
904 restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap();
905 let content = fs::read(target.path().join("file.txt")).unwrap();
906 assert_eq!(content, b"hello");
907 }
908
909 #[test]
910 fn restore_nested_directories() {
911 let (_d, store) = fresh_store();
912 let target = TempDir::new().unwrap();
913 let blob = put_blob(&store, b"const main = 0;");
914 let inner = put_tree_with(
915 &store,
916 vec![TreeEntry {
917 name: b"main.rs".to_vec(),
918 mode: EntryMode::Blob,
919 object_hash: blob,
920 }],
921 );
922 let root = put_tree_with(
923 &store,
924 vec![TreeEntry {
925 name: b"src".to_vec(),
926 mode: EntryMode::Tree,
927 object_hash: inner,
928 }],
929 );
930 restore_tree(&store, root, target.path(), &RestoreOptions::default()).unwrap();
931 let content = fs::read(target.path().join("src/main.rs")).unwrap();
932 assert_eq!(content, b"const main = 0;");
933 }
934
935 #[test]
936 fn restore_overwrites_existing_files() {
937 let (_d, store) = fresh_store();
938 let target = TempDir::new().unwrap();
939 fs::write(target.path().join("file.txt"), b"old").unwrap();
940 let blob = put_blob(&store, b"new");
941 let tree = put_tree_with(
942 &store,
943 vec![TreeEntry {
944 name: b"file.txt".to_vec(),
945 mode: EntryMode::Blob,
946 object_hash: blob,
947 }],
948 );
949 restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap();
950 assert_eq!(fs::read(target.path().join("file.txt")).unwrap(), b"new");
951 }
952
953 #[test]
954 fn restore_removes_untracked_when_clean() {
955 let (_d, store) = fresh_store();
956 let target = TempDir::new().unwrap();
957 fs::write(target.path().join("extra.txt"), b"gone").unwrap();
958 let blob = put_blob(&store, b"keep");
959 let tree = put_tree_with(
960 &store,
961 vec![TreeEntry {
962 name: b"tracked.txt".to_vec(),
963 mode: EntryMode::Blob,
964 object_hash: blob,
965 }],
966 );
967 restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap();
968 assert!(!target.path().join("extra.txt").exists());
969 assert_eq!(
970 fs::read(target.path().join("tracked.txt")).unwrap(),
971 b"keep"
972 );
973 }
974
975 #[test]
976 fn restore_clean_false_keeps_untracked() {
977 let (_d, store) = fresh_store();
978 let target = TempDir::new().unwrap();
979 fs::write(target.path().join("extra.txt"), b"survive").unwrap();
980 let blob = put_blob(&store, b"keep");
981 let tree = put_tree_with(
982 &store,
983 vec![TreeEntry {
984 name: b"tracked.txt".to_vec(),
985 mode: EntryMode::Blob,
986 object_hash: blob,
987 }],
988 );
989 restore_tree(
990 &store,
991 tree,
992 target.path(),
993 &RestoreOptions {
994 clean: false,
995 sparse_patterns: None,
996 },
997 )
998 .unwrap();
999 assert_eq!(
1000 fs::read(target.path().join("extra.txt")).unwrap(),
1001 b"survive"
1002 );
1003 }
1004
1005 #[test]
1006 fn restore_preserves_mkit_directory() {
1007 let (_d, store) = fresh_store();
1008 let target = TempDir::new().unwrap();
1009 fs::create_dir_all(target.path().join(".mkit")).unwrap();
1010 fs::write(target.path().join(".mkit/config"), b"important").unwrap();
1011 let tree = put_tree_with(&store, vec![]);
1012 restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap();
1013 assert_eq!(
1014 fs::read(target.path().join(".mkit/config")).unwrap(),
1015 b"important"
1016 );
1017 }
1018
1019 #[test]
1020 fn clean_directory_preserves_case_variant_mkit_and_git() {
1021 let target = TempDir::new().unwrap();
1026 fs::create_dir_all(target.path().join(".MKIT")).unwrap();
1027 fs::write(target.path().join(".MKIT/config"), b"meta").unwrap();
1028 fs::create_dir_all(target.path().join(".Git")).unwrap();
1029 fs::write(target.path().join(".Git/HEAD"), b"ref").unwrap();
1030 clean_directory(target.path(), &[], None, "", None).unwrap();
1033 assert!(
1034 target.path().join(".MKIT/config").exists(),
1035 ".MKIT swept by clean_directory (case-fold bypass)"
1036 );
1037 assert!(
1038 target.path().join(".Git/HEAD").exists(),
1039 ".Git swept by clean_directory (case-fold bypass)"
1040 );
1041 }
1042
1043 #[test]
1044 fn clean_directory_with_ignore_list_preserves_case_variant_mkit_and_git() {
1045 let target = TempDir::new().unwrap();
1046 fs::create_dir_all(target.path().join(".MKIT")).unwrap();
1047 fs::write(target.path().join(".MKIT/config"), b"meta").unwrap();
1048 fs::create_dir_all(target.path().join(".GIT")).unwrap();
1049 fs::write(target.path().join(".GIT/HEAD"), b"ref").unwrap();
1050 let ignore = crate::ignore::IgnoreList::new();
1051 clean_directory(target.path(), &[], None, "", Some(&ignore)).unwrap();
1052 assert!(
1053 target.path().join(".MKIT/config").exists(),
1054 ".MKIT swept by clean_directory (ignore-aware, case-fold bypass)"
1055 );
1056 assert!(
1057 target.path().join(".GIT/HEAD").exists(),
1058 ".GIT swept by clean_directory (ignore-aware, case-fold bypass)"
1059 );
1060 }
1061
1062 #[test]
1063 fn restore_chunked_blob_reassembled() {
1064 let (_d, store) = fresh_store();
1065 let target = TempDir::new().unwrap();
1066 let c0 = put_blob(&store, b"Hello, ");
1067 let c1 = put_blob(&store, b"chunked ");
1068 let c2 = put_blob(&store, b"world!");
1069 let cb = Object::ChunkedBlob(crate::object::ChunkedBlob {
1070 total_size: 7 + 8 + 6,
1071 chunk_size: 64 * 1024,
1072 chunks: vec![c0, c1, c2],
1073 });
1074 let cb_h = store.write(&serialize::serialize(&cb).unwrap()).unwrap();
1075 let tree = put_tree_with(
1076 &store,
1077 vec![TreeEntry {
1078 name: b"out.txt".to_vec(),
1079 mode: EntryMode::Blob,
1080 object_hash: cb_h,
1081 }],
1082 );
1083 restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap();
1084 let content = fs::read(target.path().join("out.txt")).unwrap();
1085 assert_eq!(content, b"Hello, chunked world!");
1086 }
1087
1088 #[test]
1092 fn restore_rejects_chunked_total_size_mismatch() {
1093 let (_d, store) = fresh_store();
1094 let target = TempDir::new().unwrap();
1095 let c0 = put_blob(&store, b"Hello, ");
1096 let c1 = put_blob(&store, b"world!");
1097 let cb = Object::ChunkedBlob(crate::object::ChunkedBlob {
1098 total_size: 7 + 6 + 1,
1099 chunk_size: 0,
1100 chunks: vec![c0, c1],
1101 });
1102 let cb_h = store.write(&serialize::serialize(&cb).unwrap()).unwrap();
1103 let tree = put_tree_with(
1104 &store,
1105 vec![TreeEntry {
1106 name: b"out.txt".to_vec(),
1107 mode: EntryMode::Blob,
1108 object_hash: cb_h,
1109 }],
1110 );
1111 let err =
1112 restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap_err();
1113 assert!(
1114 matches!(
1115 err,
1116 RestoreError::Object(object::MkitError::ChunkedBlobSizeMismatch {
1117 expected: 14,
1118 actual: 13,
1119 })
1120 ),
1121 "expected ChunkedBlobSizeMismatch, got {err:?}"
1122 );
1123 assert!(!target.path().join("out.txt").exists());
1124 }
1125
1126 #[cfg(unix)]
1127 #[test]
1128 fn restore_with_symlink() {
1129 let (_d, store) = fresh_store();
1130 let target = TempDir::new().unwrap();
1131 let link_target = put_blob(&store, b"target.txt");
1132 let file = put_blob(&store, b"real");
1133 let tree = put_tree_with(
1134 &store,
1135 vec![
1136 TreeEntry {
1137 name: b"link".to_vec(),
1138 mode: EntryMode::Symlink,
1139 object_hash: link_target,
1140 },
1141 TreeEntry {
1142 name: b"target.txt".to_vec(),
1143 mode: EntryMode::Blob,
1144 object_hash: file,
1145 },
1146 ],
1147 );
1148 restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap();
1149 let read = fs::read_link(target.path().join("link")).unwrap();
1150 assert_eq!(read.to_str().unwrap(), "target.txt");
1151 assert_eq!(fs::read(target.path().join("target.txt")).unwrap(), b"real");
1152 }
1153
1154 #[cfg(unix)]
1155 #[test]
1156 fn restore_rejects_invalid_symlink_targets() {
1157 let (_d, store) = fresh_store();
1158 let target = TempDir::new().unwrap();
1159 let bad = put_blob(&store, b"/etc/passwd");
1160 let tree = put_tree_with(
1161 &store,
1162 vec![TreeEntry {
1163 name: b"link".to_vec(),
1164 mode: EntryMode::Symlink,
1165 object_hash: bad,
1166 }],
1167 );
1168 let err =
1169 restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap_err();
1170 assert!(matches!(err, RestoreError::InvalidSymlinkTarget(_)));
1171 }
1172
1173 #[test]
1174 fn sparse_restore_only_restores_matched() {
1175 let (_d, store) = fresh_store();
1176 let target = TempDir::new().unwrap();
1177 let main = put_blob(&store, b"pub fn main(){}");
1178 let test = put_blob(&store, b"test {}");
1179 let readme = put_blob(&store, b"# Project");
1180 let src = put_tree_with(
1181 &store,
1182 vec![TreeEntry {
1183 name: b"main.rs".to_vec(),
1184 mode: EntryMode::Blob,
1185 object_hash: main,
1186 }],
1187 );
1188 let tests = put_tree_with(
1189 &store,
1190 vec![TreeEntry {
1191 name: b"test.rs".to_vec(),
1192 mode: EntryMode::Blob,
1193 object_hash: test,
1194 }],
1195 );
1196 let root = put_tree_with(
1197 &store,
1198 vec![
1199 TreeEntry {
1200 name: b"README.md".to_vec(),
1201 mode: EntryMode::Blob,
1202 object_hash: readme,
1203 },
1204 TreeEntry {
1205 name: b"src".to_vec(),
1206 mode: EntryMode::Tree,
1207 object_hash: src,
1208 },
1209 TreeEntry {
1210 name: b"tests".to_vec(),
1211 mode: EntryMode::Tree,
1212 object_hash: tests,
1213 },
1214 ],
1215 );
1216 let opts = RestoreOptions {
1217 clean: true,
1218 sparse_patterns: Some(vec![SparsePattern {
1219 pattern: "src".to_string(),
1220 negated: false,
1221 dir_only: false,
1222 }]),
1223 };
1224 restore_tree(&store, root, target.path(), &opts).unwrap();
1225 assert!(target.path().join("src/main.rs").exists());
1226 assert!(!target.path().join("tests/test.rs").exists());
1227 assert!(!target.path().join("README.md").exists());
1228 }
1229
1230 #[test]
1231 fn sparse_restore_with_negation_excludes_subtree() {
1232 let (_d, store) = fresh_store();
1233 let target = TempDir::new().unwrap();
1234 let main = put_blob(&store, b"main");
1235 let key = put_blob(&store, b"secret");
1236 let secret_tree = put_tree_with(
1237 &store,
1238 vec![TreeEntry {
1239 name: b"key.pem".to_vec(),
1240 mode: EntryMode::Blob,
1241 object_hash: key,
1242 }],
1243 );
1244 let src = put_tree_with(
1245 &store,
1246 vec![
1247 TreeEntry {
1248 name: b"main.rs".to_vec(),
1249 mode: EntryMode::Blob,
1250 object_hash: main,
1251 },
1252 TreeEntry {
1253 name: b"secret".to_vec(),
1254 mode: EntryMode::Tree,
1255 object_hash: secret_tree,
1256 },
1257 ],
1258 );
1259 let root = put_tree_with(
1260 &store,
1261 vec![TreeEntry {
1262 name: b"src".to_vec(),
1263 mode: EntryMode::Tree,
1264 object_hash: src,
1265 }],
1266 );
1267 let opts = RestoreOptions {
1268 clean: true,
1269 sparse_patterns: Some(vec![
1270 SparsePattern {
1271 pattern: "src".to_string(),
1272 negated: false,
1273 dir_only: false,
1274 },
1275 SparsePattern {
1276 pattern: "src/secret".to_string(),
1277 negated: true,
1278 dir_only: false,
1279 },
1280 ]),
1281 };
1282 restore_tree(&store, root, target.path(), &opts).unwrap();
1283 assert!(target.path().join("src/main.rs").exists());
1284 assert!(!target.path().join("src/secret/key.pem").exists());
1285 }
1286
1287 #[test]
1288 fn sparse_checkout_roundtrip() {
1289 let target = TempDir::new().unwrap();
1290 let layout = RepoLayout::single(target.path());
1291 write_sparse_checkout(&layout, &["src", "!src/secret", "docs/"]).unwrap();
1292 let p = load_sparse_checkout(&layout).unwrap().unwrap();
1293 assert_eq!(p.len(), 3);
1294 assert_eq!(p[0].pattern, "src");
1295 assert!(p[1].negated);
1296 assert!(p[2].dir_only);
1297 }
1298
1299 #[test]
1300 fn load_sparse_checkout_returns_none_when_missing() {
1301 let target = TempDir::new().unwrap();
1302 fs::create_dir_all(target.path().join(".mkit")).unwrap();
1303 let p = load_sparse_checkout(&RepoLayout::single(target.path())).unwrap();
1304 assert!(p.is_none());
1305 }
1306
1307 #[test]
1312 fn worktree_restore_counts_files_and_dirs() {
1313 let (_d, store) = fresh_store();
1314 let target = TempDir::new().unwrap();
1315 let blob_a = put_blob(&store, b"a");
1316 let blob_b = put_blob(&store, b"b");
1317 let sub = put_tree_with(
1318 &store,
1319 vec![TreeEntry {
1320 name: b"b.txt".to_vec(),
1321 mode: EntryMode::Blob,
1322 object_hash: blob_b,
1323 }],
1324 );
1325 let root = put_tree_with(
1326 &store,
1327 vec![
1328 TreeEntry {
1329 name: b"a.txt".to_vec(),
1330 mode: EntryMode::Blob,
1331 object_hash: blob_a,
1332 },
1333 TreeEntry {
1334 name: b"sub".to_vec(),
1335 mode: EntryMode::Tree,
1336 object_hash: sub,
1337 },
1338 ],
1339 );
1340 let report =
1341 restore_tree_to_worktree(&store, &root, target.path(), &RestoreOptions::default())
1342 .unwrap();
1343 assert_eq!(report.files_written, 2);
1344 assert_eq!(report.directories_created, 1);
1345 assert!(target.path().join("a.txt").exists());
1346 assert!(target.path().join("sub/b.txt").exists());
1347 }
1348
1349 #[test]
1350 fn worktree_restore_writes_tracked_entries_and_keeps_untracked_ignored() {
1351 let (_d, store) = fresh_store();
1352 let target = TempDir::new().unwrap();
1353 fs::write(target.path().join(".mkitignore"), "*.tmp\nsecret.txt\n").unwrap();
1357 fs::write(target.path().join("scratch.tmp"), b"local-only").unwrap();
1358 fs::write(target.path().join("secret.txt"), b"OLD-LOCAL").unwrap();
1359 let secret_blob = put_blob(&store, b"COMMITTED-SECRET");
1360 let ok_blob = put_blob(&store, b"ok");
1361 let root = put_tree_with(
1362 &store,
1363 vec![
1364 TreeEntry {
1365 name: b"ok.txt".to_vec(),
1366 mode: EntryMode::Blob,
1367 object_hash: ok_blob,
1368 },
1369 TreeEntry {
1370 name: b"secret.txt".to_vec(),
1371 mode: EntryMode::Blob,
1372 object_hash: secret_blob,
1373 },
1374 ],
1375 );
1376 let report =
1377 restore_tree_to_worktree(&store, &root, target.path(), &RestoreOptions::default())
1378 .unwrap();
1379 assert_eq!(report.files_written, 2);
1381 assert_eq!(
1382 fs::read(target.path().join("secret.txt")).unwrap(),
1383 b"COMMITTED-SECRET",
1384 "a tracked tree entry is written even if it matches an ignore rule"
1385 );
1386 assert_eq!(fs::read(target.path().join("ok.txt")).unwrap(), b"ok");
1387 assert_eq!(
1389 fs::read(target.path().join("scratch.tmp")).unwrap(),
1390 b"local-only",
1391 "an untracked ignored file must survive the clean sweep"
1392 );
1393 }
1394
1395 #[test]
1396 fn worktree_restore_clean_keeps_untracked_under_ignored_dir() {
1397 let (_d, store) = fresh_store();
1401 let target = TempDir::new().unwrap();
1402 fs::write(target.path().join(".mkitignore"), "dist/\n").unwrap();
1403 fs::create_dir(target.path().join("dist")).unwrap();
1404 fs::write(target.path().join("dist/local.tmp"), b"local").unwrap();
1405 let app_blob = put_blob(&store, b"APP");
1407 let dist_tree = put_tree_with(
1408 &store,
1409 vec![TreeEntry {
1410 name: b"app.js".to_vec(),
1411 mode: EntryMode::Blob,
1412 object_hash: app_blob,
1413 }],
1414 );
1415 let root = put_tree_with(
1416 &store,
1417 vec![TreeEntry {
1418 name: b"dist".to_vec(),
1419 mode: EntryMode::Tree,
1420 object_hash: dist_tree,
1421 }],
1422 );
1423 restore_tree_to_worktree(&store, &root, target.path(), &RestoreOptions::default()).unwrap();
1424 assert_eq!(fs::read(target.path().join("dist/app.js")).unwrap(), b"APP");
1426 assert_eq!(
1428 fs::read(target.path().join("dist/local.tmp")).unwrap(),
1429 b"local",
1430 "an untracked file under an ignored dir must survive the clean sweep"
1431 );
1432 }
1433
1434 #[cfg(unix)]
1435 #[test]
1436 fn worktree_restore_rejects_escaping_symlink() {
1437 let (_d, store) = fresh_store();
1438 let target = TempDir::new().unwrap();
1439 let bad = put_blob(&store, b"../outside");
1440 let root = put_tree_with(
1441 &store,
1442 vec![TreeEntry {
1443 name: b"link".to_vec(),
1444 mode: EntryMode::Symlink,
1445 object_hash: bad,
1446 }],
1447 );
1448 let err =
1449 restore_tree_to_worktree(&store, &root, target.path(), &RestoreOptions::default())
1450 .unwrap_err();
1451 assert!(matches!(err, RestoreError::InvalidSymlinkTarget(_)));
1452 }
1453}