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