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
45pub const RESTORE_CHUNK_BATCH: usize = 64;
55
56static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
57
58#[derive(Debug, thiserror::Error)]
60pub enum RestoreError {
61 #[error("requested object is not a tree")]
62 NotATree,
63 #[error("requested object is not a blob or chunked-blob")]
64 NotABlob,
65 #[error("symlink target '{0}' is invalid (absolute or contains '..')")]
66 InvalidSymlinkTarget(String),
67 #[error("path '{0}' is occupied by something other than a directory")]
68 NotADirectory(PathBuf),
69 #[error("path component is not valid UTF-8")]
70 InvalidUtf8,
71 #[error("tree nesting exceeds {} levels", MAX_TREE_DEPTH)]
72 TreeTooDeep,
73 #[error(transparent)]
74 Object(#[from] object::MkitError),
75 #[error(transparent)]
76 Store(#[from] crate::store::StoreError),
77 #[error(transparent)]
78 Io(#[from] io::Error),
79 #[error("read_chunks callback returned {actual} buffers for a {expected}-chunk batch")]
85 ChunkBatchLengthMismatch { expected: usize, actual: usize },
86}
87
88pub type RestoreResult<T> = Result<T, RestoreError>;
90
91#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct SparsePattern {
94 pub pattern: String,
95 pub negated: bool,
96 pub dir_only: bool,
97}
98
99#[derive(Debug, Clone)]
101pub struct RestoreOptions {
102 pub clean: bool,
105 pub sparse_patterns: Option<Vec<SparsePattern>>,
107}
108
109impl Default for RestoreOptions {
110 fn default() -> Self {
111 Self {
112 clean: true,
113 sparse_patterns: None,
114 }
115 }
116}
117
118#[must_use]
120pub fn parse_sparse_patterns(content: &str) -> Vec<SparsePattern> {
121 let mut out = Vec::new();
122 for raw in content.split('\n') {
123 let line = raw.trim_end_matches(['\r', ' ']);
124 if line.is_empty() || line.starts_with('#') {
125 continue;
126 }
127 let (negated, rest) = if let Some(stripped) = line.strip_prefix('!') {
128 (true, stripped)
129 } else {
130 (false, line)
131 };
132 let (dir_only, pat) = if let Some(stripped) = rest.strip_suffix('/') {
133 (true, stripped)
134 } else {
135 (false, rest)
136 };
137 if pat.is_empty() {
138 continue;
139 }
140 out.push(SparsePattern {
141 pattern: pat.to_string(),
142 negated,
143 dir_only,
144 });
145 }
146 out
147}
148
149#[must_use]
152pub fn matches_sparse(patterns: &[SparsePattern], path: &str, is_dir: bool) -> bool {
153 let mut matched = false;
154 for pat in patterns {
155 if path_matches_pattern(&pat.pattern, path) {
156 if pat.dir_only && !is_dir {
157 let pat_stripped = pat.pattern.strip_suffix('/').unwrap_or(&pat.pattern);
158 if pat_stripped == path {
159 continue;
160 }
161 }
162 matched = !pat.negated;
163 }
164 }
165 matched
166}
167
168#[must_use]
171pub fn could_match_descendant(patterns: &[SparsePattern], dir_prefix: &str) -> bool {
172 for pat in patterns {
173 if pat.negated {
174 continue;
175 }
176 if pat.pattern.starts_with(dir_prefix) {
177 return true;
178 }
179 if dir_prefix.starts_with(&pat.pattern) {
180 return true;
181 }
182 if !dir_prefix.is_empty()
183 && !dir_prefix.ends_with('/')
184 && pat.pattern.len() > dir_prefix.len()
185 && pat.pattern.starts_with(dir_prefix)
186 && pat.pattern.as_bytes()[dir_prefix.len()] == b'/'
187 {
188 return true;
189 }
190 }
191 false
192}
193
194pub fn load_sparse_checkout(layout: &RepoLayout) -> RestoreResult<Option<Vec<SparsePattern>>> {
200 let path = layout.sparse_checkout_file();
201 let meta = match fs::metadata(&path) {
202 Ok(m) => m,
203 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
204 Err(e) => return Err(RestoreError::Io(e)),
205 };
206 if meta.len() > MAX_SPARSE_BYTES {
207 return Err(RestoreError::Io(io::Error::other(
208 "sparse-checkout too large",
209 )));
210 }
211 let raw = fs::read_to_string(&path)?;
212 let patterns = parse_sparse_patterns(&raw);
213 if patterns.is_empty() {
214 Ok(None)
215 } else {
216 Ok(Some(patterns))
217 }
218}
219
220pub fn write_sparse_checkout(layout: &RepoLayout, lines: &[&str]) -> RestoreResult<()> {
226 let mkit_dir = layout.worktree_state_dir().to_path_buf();
227 fs::create_dir_all(&mkit_dir)?;
228 let mut buf = String::new();
229 for l in lines {
230 buf.push_str(l);
231 buf.push('\n');
232 }
233 let path = layout.sparse_checkout_file();
234 crate::atomic::write_atomic(&path, buf.as_bytes(), true)?;
235 Ok(())
236}
237
238fn path_matches_pattern(pattern: &str, path: &str) -> bool {
239 let pat = pattern.strip_suffix('/').unwrap_or(pattern);
240 if pat == path {
241 return true;
242 }
243 if path.len() > pat.len() && path.starts_with(pat) && path.as_bytes()[pat.len()] == b'/' {
244 return true;
245 }
246 if !pat.contains('/')
247 && let Some(last) = path.rfind('/')
248 {
249 let basename = &path[last + 1..];
250 if basename == pat {
251 return true;
252 }
253 }
254 false
255}
256
257#[derive(Debug, Default, Clone, PartialEq, Eq)]
262pub struct RestoreReport {
263 pub files_written: u32,
265 pub symlinks_written: u32,
267 pub directories_created: u32,
269}
270
271pub fn restore_tree_to_worktree(
295 store: &ObjectStore,
296 tree: &Hash,
297 root: &Path,
298 opts: &RestoreOptions,
299) -> RestoreResult<RestoreReport> {
300 restore_tree_to_worktree_impl(store, tree, root, opts, 1, &sequential_read_chunks)
307}
308
309pub fn restore_tree_to_worktree_with<F>(
356 store: &ObjectStore,
357 tree: &Hash,
358 root: &Path,
359 opts: &RestoreOptions,
360 read_chunks: &F,
361) -> RestoreResult<RestoreReport>
362where
363 F: Fn(&ObjectStore, &[Hash]) -> RestoreResult<Vec<Vec<u8>>> + Sync,
364{
365 restore_tree_to_worktree_impl(store, tree, root, opts, RESTORE_CHUNK_BATCH, read_chunks)
366}
367
368fn restore_tree_to_worktree_impl<F>(
374 store: &ObjectStore,
375 tree: &Hash,
376 root: &Path,
377 opts: &RestoreOptions,
378 batch_size: usize,
379 read_chunks: &F,
380) -> RestoreResult<RestoreReport>
381where
382 F: Fn(&ObjectStore, &[Hash]) -> RestoreResult<Vec<Vec<u8>>> + Sync,
383{
384 let ignore_list = match ignore::load(root) {
386 Ok(il) => il,
387 Err(_) => IgnoreList::new(),
388 };
389 fs::create_dir_all(root)?;
390 let mut report = RestoreReport::default();
391 restore_tree_to_worktree_inner(
392 store,
393 *tree,
394 root,
395 opts,
396 "",
397 &ignore_list,
398 &mut report,
399 0,
400 batch_size,
401 read_chunks,
402 )?;
403 Ok(report)
404}
405
406#[allow(clippy::too_many_arguments)]
407fn restore_tree_to_worktree_inner<F>(
408 store: &ObjectStore,
409 tree_hash: Hash,
410 target_dir: &Path,
411 options: &RestoreOptions,
412 path_prefix: &str,
413 ignore: &IgnoreList,
414 report: &mut RestoreReport,
415 depth: usize,
416 batch_size: usize,
417 read_chunks: &F,
418) -> RestoreResult<()>
419where
420 F: Fn(&ObjectStore, &[Hash]) -> RestoreResult<Vec<Vec<u8>>> + Sync,
421{
422 if depth > MAX_TREE_DEPTH {
423 return Err(RestoreError::TreeTooDeep);
424 }
425 let obj = store.read_object(&tree_hash)?;
426 let Object::Tree(tree) = obj else {
427 return Err(RestoreError::NotATree);
428 };
429
430 if options.clean {
431 clean_directory(
432 target_dir,
433 &tree.entries,
434 options.sparse_patterns.as_deref(),
435 path_prefix,
436 Some(ignore),
437 )?;
438 }
439
440 for entry in &tree.entries {
441 if !crate::object::TreeEntry::validate_name(&entry.name) {
442 continue;
443 }
444 let name = std::str::from_utf8(&entry.name).map_err(|_| RestoreError::InvalidUtf8)?;
445 let full_path = if path_prefix.is_empty() {
446 name.to_string()
447 } else {
448 format!("{path_prefix}/{name}")
449 };
450 match entry.mode {
456 EntryMode::Blob | EntryMode::Executable => {
457 if let Some(patterns) = options.sparse_patterns.as_deref()
458 && !matches_sparse(patterns, &full_path, false)
459 {
460 continue;
461 }
462 restore_blob_with(
463 store,
464 target_dir,
465 name,
466 entry.object_hash,
467 entry.mode == EntryMode::Executable,
468 batch_size,
469 read_chunks,
470 )?;
471 report.files_written += 1;
472 }
473 EntryMode::Tree => {
474 if let Some(patterns) = options.sparse_patterns.as_deref()
475 && !could_match_descendant(patterns, &full_path)
476 {
477 continue;
478 }
479 ensure_directory(target_dir, name)?;
480 report.directories_created += 1;
481 let dir_path = target_dir.join(name);
482 let dir_meta = fs::symlink_metadata(&dir_path)?;
483 if !dir_meta.is_dir() {
484 return Err(RestoreError::NotADirectory(dir_path));
485 }
486 restore_tree_to_worktree_inner(
487 store,
488 entry.object_hash,
489 &dir_path,
490 options,
491 &full_path,
492 ignore,
493 report,
494 depth + 1,
495 batch_size,
496 read_chunks,
497 )?;
498 }
499 EntryMode::Symlink => {
500 if let Some(patterns) = options.sparse_patterns.as_deref()
501 && !matches_sparse(patterns, &full_path, false)
502 {
503 continue;
504 }
505 restore_symlink(store, target_dir, name, entry.object_hash)?;
506 report.symlinks_written += 1;
507 }
508 }
509 }
510 Ok(())
511}
512
513pub fn restore_tree(
532 store: &ObjectStore,
533 tree_hash: Hash,
534 target_dir: &Path,
535 options: &RestoreOptions,
536) -> RestoreResult<()> {
537 fs::create_dir_all(target_dir)?;
538 restore_tree_inner(store, tree_hash, target_dir, options, "")
539}
540
541fn restore_tree_inner(
542 store: &ObjectStore,
543 tree_hash: Hash,
544 target_dir: &Path,
545 options: &RestoreOptions,
546 path_prefix: &str,
547) -> RestoreResult<()> {
548 let obj = store.read_object(&tree_hash)?;
549 let Object::Tree(tree) = obj else {
550 return Err(RestoreError::NotATree);
551 };
552
553 if options.clean {
554 clean_directory(
555 target_dir,
556 &tree.entries,
557 options.sparse_patterns.as_deref(),
558 path_prefix,
559 None,
560 )?;
561 }
562
563 for entry in &tree.entries {
564 if !crate::object::TreeEntry::validate_name(&entry.name) {
565 continue;
566 }
567 let name = std::str::from_utf8(&entry.name).map_err(|_| RestoreError::InvalidUtf8)?;
568 let full_path = if path_prefix.is_empty() {
569 name.to_string()
570 } else {
571 format!("{path_prefix}/{name}")
572 };
573 match entry.mode {
574 EntryMode::Blob | EntryMode::Executable => {
575 if let Some(patterns) = options.sparse_patterns.as_deref()
576 && !matches_sparse(patterns, &full_path, false)
577 {
578 continue;
579 }
580 restore_blob(
581 store,
582 target_dir,
583 name,
584 entry.object_hash,
585 entry.mode == EntryMode::Executable,
586 )?;
587 }
588 EntryMode::Tree => {
589 if let Some(patterns) = options.sparse_patterns.as_deref()
590 && !could_match_descendant(patterns, &full_path)
591 {
592 continue;
593 }
594 ensure_directory(target_dir, name)?;
595 let dir_path = target_dir.join(name);
596 let dir_meta = fs::symlink_metadata(&dir_path)?;
598 if !dir_meta.is_dir() {
599 return Err(RestoreError::NotADirectory(dir_path));
600 }
601 restore_tree_inner(store, entry.object_hash, &dir_path, options, &full_path)?;
602 }
603 EntryMode::Symlink => {
604 if let Some(patterns) = options.sparse_patterns.as_deref()
605 && !matches_sparse(patterns, &full_path, false)
606 {
607 continue;
608 }
609 restore_symlink(store, target_dir, name, entry.object_hash)?;
610 }
611 }
612 }
613 Ok(())
614}
615
616fn restore_blob(
629 store: &ObjectStore,
630 dir: &Path,
631 name: &str,
632 blob_hash: Hash,
633 executable: bool,
634) -> RestoreResult<()> {
635 let obj = store.read_object(&blob_hash)?;
636 match obj {
637 Object::Blob(b) => write_file_atomic(dir, name, &b.data, executable)?,
638 Object::ChunkedBlob(cb) => {
639 let (tmp_path, final_path, mut tmp) = create_tmp_for_write(dir, name)?;
640 let mut written: u64 = 0;
641 for ch in &cb.chunks {
642 let buf = read_chunk_bytes(store, ch)?;
643 tmp.write_all(&buf)?;
644 written += buf.len() as u64;
645 }
646 cb.check_reassembled_size(usize::try_from(written).unwrap_or(usize::MAX))?;
647 #[cfg(not(target_arch = "wasm32"))] drop(tmp);
649 finish_atomic_write(&tmp_path, &final_path, executable)?;
650 }
651 _ => return Err(RestoreError::NotABlob),
652 }
653 Ok(())
654}
655
656fn restore_blob_with<F>(
669 store: &ObjectStore,
670 dir: &Path,
671 name: &str,
672 blob_hash: Hash,
673 executable: bool,
674 batch_size: usize,
675 read_chunks: &F,
676) -> RestoreResult<()>
677where
678 F: Fn(&ObjectStore, &[Hash]) -> RestoreResult<Vec<Vec<u8>>> + Sync,
679{
680 let obj = store.read_object(&blob_hash)?;
681 match obj {
682 Object::Blob(b) => write_file_atomic(dir, name, &b.data, executable)?,
683 Object::ChunkedBlob(cb) => {
684 let (tmp_path, final_path, mut tmp) = create_tmp_for_write(dir, name)?;
693 let mut written: u64 = 0;
694 for batch in cb.chunks.chunks(batch_size) {
695 let bufs = read_chunks(store, batch)?;
696 if bufs.len() != batch.len() {
697 return Err(RestoreError::ChunkBatchLengthMismatch {
698 expected: batch.len(),
699 actual: bufs.len(),
700 });
701 }
702 for buf in &bufs {
703 tmp.write_all(buf)?;
704 written += buf.len() as u64;
705 }
706 }
707 cb.check_reassembled_size(usize::try_from(written).unwrap_or(usize::MAX))?;
708 #[cfg(not(target_arch = "wasm32"))] drop(tmp);
710 finish_atomic_write(&tmp_path, &final_path, executable)?;
711 }
712 _ => return Err(RestoreError::NotABlob),
713 }
714 Ok(())
715}
716
717fn sequential_read_chunks(store: &ObjectStore, hashes: &[Hash]) -> RestoreResult<Vec<Vec<u8>>> {
721 hashes.iter().map(|h| read_chunk_bytes(store, h)).collect()
722}
723
724fn read_chunk_bytes(store: &ObjectStore, h: &Hash) -> RestoreResult<Vec<u8>> {
725 match store.read_object(h)? {
726 Object::Blob(b) => Ok(b.data),
727 _ => Err(RestoreError::NotABlob),
728 }
729}
730
731fn restore_symlink(
732 store: &ObjectStore,
733 dir: &Path,
734 name: &str,
735 blob_hash: Hash,
736) -> RestoreResult<()> {
737 let obj = store.read_object(&blob_hash)?;
738 let Object::Blob(b) = obj else {
739 return Err(RestoreError::NotABlob);
740 };
741 let target = std::str::from_utf8(&b.data).map_err(|_| RestoreError::InvalidUtf8)?;
742 if !worktree::validate_symlink_target(target) {
743 return Err(RestoreError::InvalidSymlinkTarget(target.to_string()));
744 }
745 let tmp_name = make_tmp_sibling_name(name);
746 let tmp_path = dir.join(&tmp_name);
747 let final_path = dir.join(name);
748 let _ = fs::remove_file(&tmp_path);
749 create_symlink(target, &tmp_path)?;
750 prepare_path_for_rename(&final_path)?;
751 fs::rename(&tmp_path, &final_path)?;
752 Ok(())
753}
754
755#[cfg(unix)]
756fn create_symlink(target: &str, link: &Path) -> io::Result<()> {
757 std::os::unix::fs::symlink(target, link)
758}
759
760#[cfg(windows)]
761fn create_symlink(target: &str, link: &Path) -> io::Result<()> {
762 std::os::windows::fs::symlink_file(target, link)
766}
767
768#[cfg(not(any(unix, windows)))]
769fn create_symlink(_target: &str, _link: &Path) -> io::Result<()> {
770 Err(io::Error::new(
774 io::ErrorKind::Unsupported,
775 "symlink creation is not supported on this target",
776 ))
777}
778
779fn write_file_atomic(dir: &Path, name: &str, data: &[u8], executable: bool) -> io::Result<()> {
789 let (tmp_path, final_path, mut tmp) = create_tmp_for_write(dir, name)?;
790 tmp.write_all(data)?;
791 #[cfg(not(target_arch = "wasm32"))] drop(tmp);
793 finish_atomic_write(&tmp_path, &final_path, executable)
794}
795
796fn create_tmp_for_write(dir: &Path, name: &str) -> io::Result<(PathBuf, PathBuf, fs::File)> {
803 let tmp_name = make_tmp_sibling_name(name);
804 let tmp_path = dir.join(&tmp_name);
805 let final_path = dir.join(name);
806 let _ = fs::remove_file(&tmp_path);
807 let tmp = fs::File::create(&tmp_path)?;
808 Ok((tmp_path, final_path, tmp))
809}
810
811fn finish_atomic_write(tmp_path: &Path, final_path: &Path, executable: bool) -> io::Result<()> {
816 if executable {
817 apply_executable_bit(tmp_path)?;
818 }
819 prepare_path_for_rename(final_path)?;
820 fs::rename(tmp_path, final_path)?;
821 Ok(())
822}
823
824#[cfg(unix)]
825fn apply_executable_bit(path: &Path) -> io::Result<()> {
826 use std::os::unix::fs::PermissionsExt;
827 let mut perm = fs::metadata(path)?.permissions();
828 perm.set_mode(0o755);
829 fs::set_permissions(path, perm)
830}
831
832#[cfg(not(unix))]
833#[allow(clippy::unnecessary_wraps)]
834fn apply_executable_bit(_path: &Path) -> io::Result<()> {
835 Ok(())
836}
837
838fn ensure_directory(parent: &Path, name: &str) -> io::Result<()> {
839 let path = parent.join(name);
840 match fs::symlink_metadata(&path) {
841 Ok(meta) if meta.is_dir() => return Ok(()),
842 Ok(_) => fs::remove_file(&path)?,
843 Err(e) if e.kind() == io::ErrorKind::NotFound => {}
844 Err(e) => return Err(e),
845 }
846 match fs::create_dir_all(&path) {
847 Ok(()) => Ok(()),
848 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
849 let meta = fs::symlink_metadata(&path)?;
850 if meta.is_dir() {
851 Ok(())
852 } else {
853 Err(io::Error::new(
854 io::ErrorKind::AlreadyExists,
855 "expected directory",
856 ))
857 }
858 }
859 Err(e) => Err(e),
860 }
861}
862
863fn prepare_path_for_rename(final_path: &Path) -> io::Result<()> {
864 match fs::symlink_metadata(final_path) {
865 Ok(meta) if meta.is_dir() => fs::remove_dir_all(final_path),
866 Ok(_) => Ok(()),
867 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
868 Err(e) => Err(e),
869 }
870}
871
872fn make_tmp_sibling_name(name: &str) -> String {
873 let pid = process::id();
874 let counter = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
875 format!(".{name}.tmp.{pid}.{counter}")
876}
877
878fn clean_directory(
889 target_dir: &Path,
890 tree_entries: &[TreeEntry],
891 sparse_patterns: Option<&[SparsePattern]>,
892 path_prefix: &str,
893 ignore: Option<&IgnoreList>,
894) -> RestoreResult<()> {
895 struct CleanItem {
896 name: String,
897 is_dir: bool,
898 }
899 let mut to_delete: Vec<CleanItem> = Vec::new();
900
901 let read = match fs::read_dir(target_dir) {
902 Ok(r) => r,
903 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
904 Err(e) => return Err(RestoreError::Io(e)),
905 };
906 for entry in read {
907 let entry = entry?;
908 let file_name = entry.file_name();
909 let name_str = file_name
910 .to_str()
911 .ok_or(RestoreError::InvalidUtf8)?
912 .to_string();
913 if name_str.eq_ignore_ascii_case(".mkit") || name_str.eq_ignore_ascii_case(".git") {
914 continue;
915 }
916 if name_str == ".mkitignore" {
917 continue;
918 }
919 if ignore.is_some() && name_str == ".gitignore" {
921 continue;
922 }
923 let mut found = false;
924 for te in tree_entries {
925 if te.name.as_slice() == name_str.as_bytes() {
926 found = true;
927 break;
928 }
929 }
930 if found {
931 continue;
932 }
933 let meta = entry.metadata()?;
934 let is_dir = meta.is_dir();
935 let full_path = if path_prefix.is_empty() {
936 name_str.clone()
937 } else {
938 format!("{path_prefix}/{name_str}")
939 };
940 if let Some(ignore) = ignore
945 && ignore.is_ignored_with_ancestors(&full_path, is_dir)
946 {
947 continue;
948 }
949 if let Some(patterns) = sparse_patterns {
950 let allow = matches_sparse(patterns, &full_path, is_dir)
951 || (is_dir && could_match_descendant(patterns, &full_path));
952 if !allow {
953 continue;
954 }
955 }
956 to_delete.push(CleanItem {
957 name: name_str,
958 is_dir,
959 });
960 }
961
962 for item in to_delete {
963 let path = target_dir.join(&item.name);
964 if item.is_dir {
965 let _ = fs::remove_dir_all(&path);
966 } else {
967 let _ = fs::remove_file(&path);
968 }
969 }
970 Ok(())
971}
972
973#[cfg(test)]
974mod tests {
975 use super::*;
976 use crate::object::{Tree, TreeEntry};
977 use crate::serialize;
978 use tempfile::TempDir;
979
980 fn fresh_store() -> (TempDir, ObjectStore) {
981 let dir = TempDir::new().unwrap();
982 let store = ObjectStore::init(&RepoLayout::single(dir.path())).unwrap();
983 (dir, store)
984 }
985
986 fn put_blob(store: &ObjectStore, data: &[u8]) -> Hash {
987 let bytes = serialize::serialize(&Object::Blob(crate::object::Blob {
988 data: data.to_vec(),
989 }))
990 .unwrap();
991 store.write(&bytes).unwrap()
992 }
993
994 fn put_tree_with(store: &ObjectStore, entries: Vec<TreeEntry>) -> Hash {
995 let bytes = serialize::serialize(&Object::Tree(Tree { entries })).unwrap();
996 store.write(&bytes).unwrap()
997 }
998
999 #[test]
1000 fn parse_sparse_basic() {
1001 let content = "# comment line\nsrc\n!tests\ndocs/\n\nREADME.md\n";
1002 let p = parse_sparse_patterns(content);
1003 assert_eq!(p.len(), 4);
1004 assert_eq!(p[0].pattern, "src");
1005 assert!(!p[0].negated);
1006 assert!(!p[0].dir_only);
1007 assert_eq!(p[1].pattern, "tests");
1008 assert!(p[1].negated);
1009 assert_eq!(p[2].pattern, "docs");
1010 assert!(p[2].dir_only);
1011 assert_eq!(p[3].pattern, "README.md");
1012 }
1013
1014 #[test]
1015 fn matches_sparse_exact_and_prefix() {
1016 let p = vec![SparsePattern {
1017 pattern: "src".to_string(),
1018 negated: false,
1019 dir_only: false,
1020 }];
1021 assert!(matches_sparse(&p, "src/main.rs", false));
1022 assert!(matches_sparse(&p, "src/lib/util.rs", false));
1023 assert!(!matches_sparse(&p, "tests/foo", false));
1024 }
1025
1026 #[test]
1027 fn matches_sparse_negation() {
1028 let p = vec![
1029 SparsePattern {
1030 pattern: "src".to_string(),
1031 negated: false,
1032 dir_only: false,
1033 },
1034 SparsePattern {
1035 pattern: "src/secret".to_string(),
1036 negated: true,
1037 dir_only: false,
1038 },
1039 ];
1040 assert!(matches_sparse(&p, "src/main.rs", false));
1041 assert!(!matches_sparse(&p, "src/secret/key.pem", false));
1042 }
1043
1044 #[test]
1045 fn matches_sparse_dir_only() {
1046 let p = vec![SparsePattern {
1047 pattern: "build".to_string(),
1048 negated: false,
1049 dir_only: true,
1050 }];
1051 assert!(matches_sparse(&p, "build", true));
1052 assert!(!matches_sparse(&p, "build", false));
1053 }
1054
1055 #[test]
1056 fn matches_sparse_last_match_wins() {
1057 let p = vec![
1058 SparsePattern {
1059 pattern: "src".to_string(),
1060 negated: false,
1061 dir_only: false,
1062 },
1063 SparsePattern {
1064 pattern: "src".to_string(),
1065 negated: true,
1066 dir_only: false,
1067 },
1068 ];
1069 assert!(!matches_sparse(&p, "src/main.rs", false));
1070 }
1071
1072 #[test]
1073 fn matches_sparse_bare_basename() {
1074 let p = vec![SparsePattern {
1075 pattern: "Makefile".to_string(),
1076 negated: false,
1077 dir_only: false,
1078 }];
1079 assert!(matches_sparse(&p, "Makefile", false));
1080 assert!(matches_sparse(&p, "sub/Makefile", false));
1081 assert!(!matches_sparse(&p, "Makefile.bak", false));
1082 }
1083
1084 #[test]
1085 fn could_match_descendant_basic() {
1086 let p = vec![SparsePattern {
1087 pattern: "src/lib".to_string(),
1088 negated: false,
1089 dir_only: false,
1090 }];
1091 assert!(could_match_descendant(&p, "src"));
1092 assert!(could_match_descendant(&p, "src/lib"));
1093 assert!(!could_match_descendant(&p, "tests"));
1094 }
1095
1096 #[test]
1097 fn restore_empty_tree_creates_no_files() {
1098 let (_d, store) = fresh_store();
1099 let target = TempDir::new().unwrap();
1100 let tree_h = put_tree_with(&store, vec![]);
1101 restore_tree(&store, tree_h, target.path(), &RestoreOptions::default()).unwrap();
1102 let count = fs::read_dir(target.path()).unwrap().count();
1103 assert_eq!(count, 0);
1104 }
1105
1106 #[test]
1107 fn restore_single_file() {
1108 let (_d, store) = fresh_store();
1109 let target = TempDir::new().unwrap();
1110 let blob = put_blob(&store, b"hello");
1111 let tree = put_tree_with(
1112 &store,
1113 vec![TreeEntry {
1114 name: b"file.txt".to_vec(),
1115 mode: EntryMode::Blob,
1116 object_hash: blob,
1117 }],
1118 );
1119 restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap();
1120 let content = fs::read(target.path().join("file.txt")).unwrap();
1121 assert_eq!(content, b"hello");
1122 }
1123
1124 #[test]
1125 fn restore_nested_directories() {
1126 let (_d, store) = fresh_store();
1127 let target = TempDir::new().unwrap();
1128 let blob = put_blob(&store, b"const main = 0;");
1129 let inner = put_tree_with(
1130 &store,
1131 vec![TreeEntry {
1132 name: b"main.rs".to_vec(),
1133 mode: EntryMode::Blob,
1134 object_hash: blob,
1135 }],
1136 );
1137 let root = put_tree_with(
1138 &store,
1139 vec![TreeEntry {
1140 name: b"src".to_vec(),
1141 mode: EntryMode::Tree,
1142 object_hash: inner,
1143 }],
1144 );
1145 restore_tree(&store, root, target.path(), &RestoreOptions::default()).unwrap();
1146 let content = fs::read(target.path().join("src/main.rs")).unwrap();
1147 assert_eq!(content, b"const main = 0;");
1148 }
1149
1150 #[test]
1151 fn restore_overwrites_existing_files() {
1152 let (_d, store) = fresh_store();
1153 let target = TempDir::new().unwrap();
1154 fs::write(target.path().join("file.txt"), b"old").unwrap();
1155 let blob = put_blob(&store, b"new");
1156 let tree = put_tree_with(
1157 &store,
1158 vec![TreeEntry {
1159 name: b"file.txt".to_vec(),
1160 mode: EntryMode::Blob,
1161 object_hash: blob,
1162 }],
1163 );
1164 restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap();
1165 assert_eq!(fs::read(target.path().join("file.txt")).unwrap(), b"new");
1166 }
1167
1168 #[test]
1169 fn restore_removes_untracked_when_clean() {
1170 let (_d, store) = fresh_store();
1171 let target = TempDir::new().unwrap();
1172 fs::write(target.path().join("extra.txt"), b"gone").unwrap();
1173 let blob = put_blob(&store, b"keep");
1174 let tree = put_tree_with(
1175 &store,
1176 vec![TreeEntry {
1177 name: b"tracked.txt".to_vec(),
1178 mode: EntryMode::Blob,
1179 object_hash: blob,
1180 }],
1181 );
1182 restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap();
1183 assert!(!target.path().join("extra.txt").exists());
1184 assert_eq!(
1185 fs::read(target.path().join("tracked.txt")).unwrap(),
1186 b"keep"
1187 );
1188 }
1189
1190 #[test]
1191 fn restore_clean_false_keeps_untracked() {
1192 let (_d, store) = fresh_store();
1193 let target = TempDir::new().unwrap();
1194 fs::write(target.path().join("extra.txt"), b"survive").unwrap();
1195 let blob = put_blob(&store, b"keep");
1196 let tree = put_tree_with(
1197 &store,
1198 vec![TreeEntry {
1199 name: b"tracked.txt".to_vec(),
1200 mode: EntryMode::Blob,
1201 object_hash: blob,
1202 }],
1203 );
1204 restore_tree(
1205 &store,
1206 tree,
1207 target.path(),
1208 &RestoreOptions {
1209 clean: false,
1210 sparse_patterns: None,
1211 },
1212 )
1213 .unwrap();
1214 assert_eq!(
1215 fs::read(target.path().join("extra.txt")).unwrap(),
1216 b"survive"
1217 );
1218 }
1219
1220 #[test]
1221 fn restore_preserves_mkit_directory() {
1222 let (_d, store) = fresh_store();
1223 let target = TempDir::new().unwrap();
1224 fs::create_dir_all(target.path().join(".mkit")).unwrap();
1225 fs::write(target.path().join(".mkit/config"), b"important").unwrap();
1226 let tree = put_tree_with(&store, vec![]);
1227 restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap();
1228 assert_eq!(
1229 fs::read(target.path().join(".mkit/config")).unwrap(),
1230 b"important"
1231 );
1232 }
1233
1234 #[test]
1235 fn clean_directory_preserves_case_variant_mkit_and_git() {
1236 let target = TempDir::new().unwrap();
1241 fs::create_dir_all(target.path().join(".MKIT")).unwrap();
1242 fs::write(target.path().join(".MKIT/config"), b"meta").unwrap();
1243 fs::create_dir_all(target.path().join(".Git")).unwrap();
1244 fs::write(target.path().join(".Git/HEAD"), b"ref").unwrap();
1245 clean_directory(target.path(), &[], None, "", None).unwrap();
1248 assert!(
1249 target.path().join(".MKIT/config").exists(),
1250 ".MKIT swept by clean_directory (case-fold bypass)"
1251 );
1252 assert!(
1253 target.path().join(".Git/HEAD").exists(),
1254 ".Git swept by clean_directory (case-fold bypass)"
1255 );
1256 }
1257
1258 #[test]
1259 fn clean_directory_with_ignore_list_preserves_case_variant_mkit_and_git() {
1260 let target = TempDir::new().unwrap();
1261 fs::create_dir_all(target.path().join(".MKIT")).unwrap();
1262 fs::write(target.path().join(".MKIT/config"), b"meta").unwrap();
1263 fs::create_dir_all(target.path().join(".GIT")).unwrap();
1264 fs::write(target.path().join(".GIT/HEAD"), b"ref").unwrap();
1265 let ignore = crate::ignore::IgnoreList::new();
1266 clean_directory(target.path(), &[], None, "", Some(&ignore)).unwrap();
1267 assert!(
1268 target.path().join(".MKIT/config").exists(),
1269 ".MKIT swept by clean_directory (ignore-aware, case-fold bypass)"
1270 );
1271 assert!(
1272 target.path().join(".GIT/HEAD").exists(),
1273 ".GIT swept by clean_directory (ignore-aware, case-fold bypass)"
1274 );
1275 }
1276
1277 #[test]
1278 fn restore_chunked_blob_reassembled() {
1279 let (_d, store) = fresh_store();
1280 let target = TempDir::new().unwrap();
1281 let c0 = put_blob(&store, b"Hello, ");
1282 let c1 = put_blob(&store, b"chunked ");
1283 let c2 = put_blob(&store, b"world!");
1284 let cb = Object::ChunkedBlob(crate::object::ChunkedBlob {
1285 total_size: 7 + 8 + 6,
1286 chunk_size: 64 * 1024,
1287 chunks: vec![c0, c1, c2],
1288 });
1289 let cb_h = store.write(&serialize::serialize(&cb).unwrap()).unwrap();
1290 let tree = put_tree_with(
1291 &store,
1292 vec![TreeEntry {
1293 name: b"out.txt".to_vec(),
1294 mode: EntryMode::Blob,
1295 object_hash: cb_h,
1296 }],
1297 );
1298 restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap();
1299 let content = fs::read(target.path().join("out.txt")).unwrap();
1300 assert_eq!(content, b"Hello, chunked world!");
1301 }
1302
1303 #[test]
1310 fn restore_tree_to_worktree_with_rejects_miscounted_chunks() {
1311 let (_d, store) = fresh_store();
1312 let target = TempDir::new().unwrap();
1313 let c0 = put_blob(&store, b"Hello, ");
1314 let c1 = put_blob(&store, b"chunked ");
1315 let c2 = put_blob(&store, b"world!");
1316 let cb = Object::ChunkedBlob(crate::object::ChunkedBlob {
1317 total_size: 7 + 8 + 6,
1318 chunk_size: 0,
1319 chunks: vec![c0, c1, c2],
1320 });
1321 let cb_h = store.write(&serialize::serialize(&cb).unwrap()).unwrap();
1322 let tree = put_tree_with(
1323 &store,
1324 vec![TreeEntry {
1325 name: b"out.txt".to_vec(),
1326 mode: EntryMode::Blob,
1327 object_hash: cb_h,
1328 }],
1329 );
1330 let err = restore_tree_to_worktree_with(
1331 &store,
1332 &tree,
1333 target.path(),
1334 &RestoreOptions::default(),
1335 &|_store, batch| Ok(vec![Vec::new(); batch.len() - 1]),
1336 )
1337 .unwrap_err();
1338 assert!(
1339 matches!(
1340 err,
1341 RestoreError::ChunkBatchLengthMismatch {
1342 expected: 3,
1343 actual: 2
1344 }
1345 ),
1346 "unexpected error: {err:?}"
1347 );
1348 }
1349
1350 #[test]
1359 fn restore_tree_to_worktree_with_out_of_order_processing_matches_sequential() {
1360 let (_d, store) = fresh_store();
1361 let chunk_data: Vec<Vec<u8>> = (0..10)
1362 .map(|i| format!("chunk-{i:02}-").into_bytes())
1363 .collect();
1364 let total_size: u64 = chunk_data.iter().map(|c| c.len() as u64).sum();
1365 let chunks: Vec<Hash> = chunk_data.iter().map(|c| put_blob(&store, c)).collect();
1366 let cb = Object::ChunkedBlob(crate::object::ChunkedBlob {
1367 total_size,
1368 chunk_size: 0,
1369 chunks: chunks.clone(),
1370 });
1371 let cb_h = store.write(&serialize::serialize(&cb).unwrap()).unwrap();
1372 let tree = put_tree_with(
1373 &store,
1374 vec![TreeEntry {
1375 name: b"out.txt".to_vec(),
1376 mode: EntryMode::Blob,
1377 object_hash: cb_h,
1378 }],
1379 );
1380
1381 let sequential_target = TempDir::new().unwrap();
1382 restore_tree_to_worktree(
1383 &store,
1384 &tree,
1385 sequential_target.path(),
1386 &RestoreOptions::default(),
1387 )
1388 .unwrap();
1389 let sequential_content = fs::read(sequential_target.path().join("out.txt")).unwrap();
1390
1391 let fanout_target = TempDir::new().unwrap();
1392 restore_tree_to_worktree_with(
1393 &store,
1394 &tree,
1395 fanout_target.path(),
1396 &RestoreOptions::default(),
1397 &|store, batch| {
1398 let mut out: Vec<Vec<u8>> = batch
1402 .iter()
1403 .rev()
1404 .map(|h| read_chunk_bytes(store, h))
1405 .collect::<RestoreResult<_>>()?;
1406 out.reverse();
1407 Ok(out)
1408 },
1409 )
1410 .unwrap();
1411 let fanout_content = fs::read(fanout_target.path().join("out.txt")).unwrap();
1412
1413 assert_eq!(
1414 sequential_content, fanout_content,
1415 "an out-of-order-processing read_chunks callback must still match \
1416 the sequential default when it returns results in input order"
1417 );
1418 }
1419
1420 #[test]
1424 fn restore_rejects_chunked_total_size_mismatch() {
1425 let (_d, store) = fresh_store();
1426 let target = TempDir::new().unwrap();
1427 let c0 = put_blob(&store, b"Hello, ");
1428 let c1 = put_blob(&store, b"world!");
1429 let cb = Object::ChunkedBlob(crate::object::ChunkedBlob {
1430 total_size: 7 + 6 + 1,
1431 chunk_size: 0,
1432 chunks: vec![c0, c1],
1433 });
1434 let cb_h = store.write(&serialize::serialize(&cb).unwrap()).unwrap();
1435 let tree = put_tree_with(
1436 &store,
1437 vec![TreeEntry {
1438 name: b"out.txt".to_vec(),
1439 mode: EntryMode::Blob,
1440 object_hash: cb_h,
1441 }],
1442 );
1443 let err =
1444 restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap_err();
1445 assert!(
1446 matches!(
1447 err,
1448 RestoreError::Object(object::MkitError::ChunkedBlobSizeMismatch {
1449 expected: 14,
1450 actual: 13,
1451 })
1452 ),
1453 "expected ChunkedBlobSizeMismatch, got {err:?}"
1454 );
1455 assert!(!target.path().join("out.txt").exists());
1456 }
1457
1458 #[cfg(unix)]
1459 #[test]
1460 fn restore_with_symlink() {
1461 let (_d, store) = fresh_store();
1462 let target = TempDir::new().unwrap();
1463 let link_target = put_blob(&store, b"target.txt");
1464 let file = put_blob(&store, b"real");
1465 let tree = put_tree_with(
1466 &store,
1467 vec![
1468 TreeEntry {
1469 name: b"link".to_vec(),
1470 mode: EntryMode::Symlink,
1471 object_hash: link_target,
1472 },
1473 TreeEntry {
1474 name: b"target.txt".to_vec(),
1475 mode: EntryMode::Blob,
1476 object_hash: file,
1477 },
1478 ],
1479 );
1480 restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap();
1481 let read = fs::read_link(target.path().join("link")).unwrap();
1482 assert_eq!(read.to_str().unwrap(), "target.txt");
1483 assert_eq!(fs::read(target.path().join("target.txt")).unwrap(), b"real");
1484 }
1485
1486 #[cfg(unix)]
1487 #[test]
1488 fn restore_rejects_invalid_symlink_targets() {
1489 let (_d, store) = fresh_store();
1490 let target = TempDir::new().unwrap();
1491 let bad = put_blob(&store, b"/etc/passwd");
1492 let tree = put_tree_with(
1493 &store,
1494 vec![TreeEntry {
1495 name: b"link".to_vec(),
1496 mode: EntryMode::Symlink,
1497 object_hash: bad,
1498 }],
1499 );
1500 let err =
1501 restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap_err();
1502 assert!(matches!(err, RestoreError::InvalidSymlinkTarget(_)));
1503 }
1504
1505 #[test]
1506 fn sparse_restore_only_restores_matched() {
1507 let (_d, store) = fresh_store();
1508 let target = TempDir::new().unwrap();
1509 let main = put_blob(&store, b"pub fn main(){}");
1510 let test = put_blob(&store, b"test {}");
1511 let readme = put_blob(&store, b"# Project");
1512 let src = put_tree_with(
1513 &store,
1514 vec![TreeEntry {
1515 name: b"main.rs".to_vec(),
1516 mode: EntryMode::Blob,
1517 object_hash: main,
1518 }],
1519 );
1520 let tests = put_tree_with(
1521 &store,
1522 vec![TreeEntry {
1523 name: b"test.rs".to_vec(),
1524 mode: EntryMode::Blob,
1525 object_hash: test,
1526 }],
1527 );
1528 let root = put_tree_with(
1529 &store,
1530 vec![
1531 TreeEntry {
1532 name: b"README.md".to_vec(),
1533 mode: EntryMode::Blob,
1534 object_hash: readme,
1535 },
1536 TreeEntry {
1537 name: b"src".to_vec(),
1538 mode: EntryMode::Tree,
1539 object_hash: src,
1540 },
1541 TreeEntry {
1542 name: b"tests".to_vec(),
1543 mode: EntryMode::Tree,
1544 object_hash: tests,
1545 },
1546 ],
1547 );
1548 let opts = RestoreOptions {
1549 clean: true,
1550 sparse_patterns: Some(vec![SparsePattern {
1551 pattern: "src".to_string(),
1552 negated: false,
1553 dir_only: false,
1554 }]),
1555 };
1556 restore_tree(&store, root, target.path(), &opts).unwrap();
1557 assert!(target.path().join("src/main.rs").exists());
1558 assert!(!target.path().join("tests/test.rs").exists());
1559 assert!(!target.path().join("README.md").exists());
1560 }
1561
1562 #[test]
1563 fn sparse_restore_with_negation_excludes_subtree() {
1564 let (_d, store) = fresh_store();
1565 let target = TempDir::new().unwrap();
1566 let main = put_blob(&store, b"main");
1567 let key = put_blob(&store, b"secret");
1568 let secret_tree = put_tree_with(
1569 &store,
1570 vec![TreeEntry {
1571 name: b"key.pem".to_vec(),
1572 mode: EntryMode::Blob,
1573 object_hash: key,
1574 }],
1575 );
1576 let src = put_tree_with(
1577 &store,
1578 vec![
1579 TreeEntry {
1580 name: b"main.rs".to_vec(),
1581 mode: EntryMode::Blob,
1582 object_hash: main,
1583 },
1584 TreeEntry {
1585 name: b"secret".to_vec(),
1586 mode: EntryMode::Tree,
1587 object_hash: secret_tree,
1588 },
1589 ],
1590 );
1591 let root = put_tree_with(
1592 &store,
1593 vec![TreeEntry {
1594 name: b"src".to_vec(),
1595 mode: EntryMode::Tree,
1596 object_hash: src,
1597 }],
1598 );
1599 let opts = RestoreOptions {
1600 clean: true,
1601 sparse_patterns: Some(vec![
1602 SparsePattern {
1603 pattern: "src".to_string(),
1604 negated: false,
1605 dir_only: false,
1606 },
1607 SparsePattern {
1608 pattern: "src/secret".to_string(),
1609 negated: true,
1610 dir_only: false,
1611 },
1612 ]),
1613 };
1614 restore_tree(&store, root, target.path(), &opts).unwrap();
1615 assert!(target.path().join("src/main.rs").exists());
1616 assert!(!target.path().join("src/secret/key.pem").exists());
1617 }
1618
1619 #[test]
1620 fn sparse_checkout_roundtrip() {
1621 let target = TempDir::new().unwrap();
1622 let layout = RepoLayout::single(target.path());
1623 write_sparse_checkout(&layout, &["src", "!src/secret", "docs/"]).unwrap();
1624 let p = load_sparse_checkout(&layout).unwrap().unwrap();
1625 assert_eq!(p.len(), 3);
1626 assert_eq!(p[0].pattern, "src");
1627 assert!(p[1].negated);
1628 assert!(p[2].dir_only);
1629 }
1630
1631 #[test]
1632 fn load_sparse_checkout_returns_none_when_missing() {
1633 let target = TempDir::new().unwrap();
1634 fs::create_dir_all(target.path().join(".mkit")).unwrap();
1635 let p = load_sparse_checkout(&RepoLayout::single(target.path())).unwrap();
1636 assert!(p.is_none());
1637 }
1638
1639 #[test]
1644 fn worktree_restore_counts_files_and_dirs() {
1645 let (_d, store) = fresh_store();
1646 let target = TempDir::new().unwrap();
1647 let blob_a = put_blob(&store, b"a");
1648 let blob_b = put_blob(&store, b"b");
1649 let sub = put_tree_with(
1650 &store,
1651 vec![TreeEntry {
1652 name: b"b.txt".to_vec(),
1653 mode: EntryMode::Blob,
1654 object_hash: blob_b,
1655 }],
1656 );
1657 let root = put_tree_with(
1658 &store,
1659 vec![
1660 TreeEntry {
1661 name: b"a.txt".to_vec(),
1662 mode: EntryMode::Blob,
1663 object_hash: blob_a,
1664 },
1665 TreeEntry {
1666 name: b"sub".to_vec(),
1667 mode: EntryMode::Tree,
1668 object_hash: sub,
1669 },
1670 ],
1671 );
1672 let report =
1673 restore_tree_to_worktree(&store, &root, target.path(), &RestoreOptions::default())
1674 .unwrap();
1675 assert_eq!(report.files_written, 2);
1676 assert_eq!(report.directories_created, 1);
1677 assert!(target.path().join("a.txt").exists());
1678 assert!(target.path().join("sub/b.txt").exists());
1679 }
1680
1681 #[test]
1682 fn worktree_restore_writes_tracked_entries_and_keeps_untracked_ignored() {
1683 let (_d, store) = fresh_store();
1684 let target = TempDir::new().unwrap();
1685 fs::write(target.path().join(".mkitignore"), "*.tmp\nsecret.txt\n").unwrap();
1689 fs::write(target.path().join("scratch.tmp"), b"local-only").unwrap();
1690 fs::write(target.path().join("secret.txt"), b"OLD-LOCAL").unwrap();
1691 let secret_blob = put_blob(&store, b"COMMITTED-SECRET");
1692 let ok_blob = put_blob(&store, b"ok");
1693 let root = put_tree_with(
1694 &store,
1695 vec![
1696 TreeEntry {
1697 name: b"ok.txt".to_vec(),
1698 mode: EntryMode::Blob,
1699 object_hash: ok_blob,
1700 },
1701 TreeEntry {
1702 name: b"secret.txt".to_vec(),
1703 mode: EntryMode::Blob,
1704 object_hash: secret_blob,
1705 },
1706 ],
1707 );
1708 let report =
1709 restore_tree_to_worktree(&store, &root, target.path(), &RestoreOptions::default())
1710 .unwrap();
1711 assert_eq!(report.files_written, 2);
1713 assert_eq!(
1714 fs::read(target.path().join("secret.txt")).unwrap(),
1715 b"COMMITTED-SECRET",
1716 "a tracked tree entry is written even if it matches an ignore rule"
1717 );
1718 assert_eq!(fs::read(target.path().join("ok.txt")).unwrap(), b"ok");
1719 assert_eq!(
1721 fs::read(target.path().join("scratch.tmp")).unwrap(),
1722 b"local-only",
1723 "an untracked ignored file must survive the clean sweep"
1724 );
1725 }
1726
1727 #[test]
1728 fn worktree_restore_clean_keeps_untracked_under_ignored_dir() {
1729 let (_d, store) = fresh_store();
1733 let target = TempDir::new().unwrap();
1734 fs::write(target.path().join(".mkitignore"), "dist/\n").unwrap();
1735 fs::create_dir(target.path().join("dist")).unwrap();
1736 fs::write(target.path().join("dist/local.tmp"), b"local").unwrap();
1737 let app_blob = put_blob(&store, b"APP");
1739 let dist_tree = put_tree_with(
1740 &store,
1741 vec![TreeEntry {
1742 name: b"app.js".to_vec(),
1743 mode: EntryMode::Blob,
1744 object_hash: app_blob,
1745 }],
1746 );
1747 let root = put_tree_with(
1748 &store,
1749 vec![TreeEntry {
1750 name: b"dist".to_vec(),
1751 mode: EntryMode::Tree,
1752 object_hash: dist_tree,
1753 }],
1754 );
1755 restore_tree_to_worktree(&store, &root, target.path(), &RestoreOptions::default()).unwrap();
1756 assert_eq!(fs::read(target.path().join("dist/app.js")).unwrap(), b"APP");
1758 assert_eq!(
1760 fs::read(target.path().join("dist/local.tmp")).unwrap(),
1761 b"local",
1762 "an untracked file under an ignored dir must survive the clean sweep"
1763 );
1764 }
1765
1766 #[cfg(unix)]
1767 #[test]
1768 fn worktree_restore_rejects_escaping_symlink() {
1769 let (_d, store) = fresh_store();
1770 let target = TempDir::new().unwrap();
1771 let bad = put_blob(&store, b"../outside");
1772 let root = put_tree_with(
1773 &store,
1774 vec![TreeEntry {
1775 name: b"link".to_vec(),
1776 mode: EntryMode::Symlink,
1777 object_hash: bad,
1778 }],
1779 );
1780 let err =
1781 restore_tree_to_worktree(&store, &root, target.path(), &RestoreOptions::default())
1782 .unwrap_err();
1783 assert!(matches!(err, RestoreError::InvalidSymlinkTarget(_)));
1784 }
1785}