Skip to main content

mkit_core/ops/
restore.rs

1//! Restore.
2//!
3//! Materialises a stored [`Tree`](crate::object::Tree) into a target
4//! directory: writes blobs as files, recurses into subtrees as
5//! directories, creates symlinks for symlink entries, and (when
6//! `clean=true`) deletes anything in the target dir that is not in the
7//! tree (preserving `.mkit/` and `.git/`).
8//!
9//! ### Symlink invariant
10//!
11//! Like `worktree::build_tree`, this module **never follows external
12//! symlinks**. Symlinks created here always have a relative,
13//! `..`-free target — checked by [`worktree::validate_symlink_target`]
14//! before the symlink is materialised.
15//!
16//! ### Sparse checkout
17//!
18//! When `RestoreOptions.sparse_patterns` is set, only files whose
19//! computed full path (relative to the root) matches the pattern set
20//! are restored. Pattern grammar:
21//!
22//! - Lines beginning with `#` are comments. Empty lines are skipped.
23//! - Leading `!` negates the pattern.
24//! - Trailing `/` makes the pattern dir-only.
25//! - Patterns are evaluated in order; **last match wins**. No match =
26//!   excluded.
27//! - Bare names without a `/` also match against the basename of
28//!   nested files.
29
30use 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/// Errors raised by this module.
48#[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
70/// Result alias.
71pub type RestoreResult<T> = Result<T, RestoreError>;
72
73/// One sparse-checkout pattern.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct SparsePattern {
76    pub pattern: String,
77    pub negated: bool,
78    pub dir_only: bool,
79}
80
81/// Options for [`restore_tree`].
82#[derive(Debug, Clone)]
83pub struct RestoreOptions {
84    /// If `true`, delete anything in the target dir that is not in the
85    /// tree (preserving `.mkit/` and `.git/`). Default `true`.
86    pub clean: bool,
87    /// If `Some`, only restore entries whose path matches the patterns.
88    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/// Parse the contents of a `.mkit/sparse-checkout` file into patterns.
101#[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/// True iff `path` matches at least one (non-negated, last-match-wins)
132/// pattern in `patterns`.
133#[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/// True iff a directory at `dir_prefix` could contain matched
151/// descendants. Used to short-circuit recursion under sparse mode.
152#[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
176/// Load patterns from `<repo_root>/.mkit/sparse-checkout`. Returns
177/// `Ok(None)` if the file does not exist or has zero patterns.
178///
179/// # Errors
180/// - [`RestoreError::Io`] for filesystem failures other than "not found".
181pub 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
202/// Write a sparse-checkout file (one pattern per line). Atomic on the
203/// destination.
204///
205/// # Errors
206/// - [`RestoreError::Io`] for filesystem failures.
207pub 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/// Summary of a [`restore_tree_to_worktree`] call. The counts are no
240/// longer printed by any caller (`mkit checkout` emits a git-shaped
241/// switch confirmation instead); they are retained for programmatic
242/// callers and asserted by this module's own unit tests.
243#[derive(Debug, Default, Clone, PartialEq, Eq)]
244pub struct RestoreReport {
245    /// Number of regular / executable files materialised.
246    pub files_written: u32,
247    /// Number of symlinks materialised.
248    pub symlinks_written: u32,
249    /// Number of directories created (or that already existed as dirs).
250    pub directories_created: u32,
251}
252
253/// Materialise `tree_hash` into `root` as a working tree.
254///
255/// Thin wrapper around [`restore_tree`] that additionally:
256/// 1. Loads `<root>/.gitignore` + `<root>/.mkitignore`. Ignore rules do NOT
257///    gate which tree entries are materialised — tracked content is always
258///    written (git parity) — they only protect *untracked* worktree files
259///    (editor swapfiles, local-only build artefacts, …) from the
260///    `clean=true` sweep.
261/// 2. Returns a [`RestoreReport`] with counts of what was materialised
262///    (consumed by programmatic callers and this module's tests; not
263///    printed by the CLI).
264///
265/// Symlink safety is inherited from [`restore_tree`] /
266/// [`worktree::validate_symlink_target`]: targets are validated BEFORE
267/// the symlink is created, and any target that is absolute or contains
268/// `..` is rejected with [`RestoreError::InvalidSymlinkTarget`]. The
269/// net effect is that no symlink produced by this function can point
270/// outside `root`.
271///
272/// # Errors
273///
274/// Same variants as [`restore_tree`]. [`RestoreError::InvalidSymlinkTarget`]
275/// on a rejected target is the load-bearing "outside-of-root" check.
276pub fn restore_tree_to_worktree(
277    store: &ObjectStore,
278    tree: &Hash,
279    root: &Path,
280    opts: &RestoreOptions,
281) -> RestoreResult<RestoreReport> {
282    // Load the root-level ignore list. Missing = empty list.
283    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        // NOTE: ignore rules do NOT gate materialization. Tree entries are
333        // tracked content and must always be written (git parity — skipping
334        // them would desync the index from the worktree). Ignore rules only
335        // protect *untracked* worktree files during the ignore-aware
336        // `clean_directory` sweep below.
337        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
391/// Materialise `tree_hash` into `target_dir`. See module docs for the
392/// invariants.
393///
394/// # Errors
395/// - [`RestoreError::NotATree`] if `tree_hash` is not a tree object.
396/// - [`RestoreError::InvalidSymlinkTarget`] if a symlink entry's target
397///   is absolute or contains `..`.
398pub 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                // Refuse to follow a symlink that took the place of the dir.
464                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            // Stream each chunk straight to the open tmp file instead of
495            // concatenating the whole reassembled file into memory first
496            // (issue #828): peak memory is one chunk (≤256 KiB), not the
497            // file's total size.
498            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    // Symlinks on Windows require either Developer Mode or admin
549    // privileges. We pick `symlink_file` because every blob is a file
550    // when materialised through this code path.
551    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    // Targets without a filesystem (notably `wasm32-unknown-unknown`)
557    // cannot materialise symlinks. The demo wasm crate does not exercise
558    // the restore path; this stub exists so the crate still compiles.
559    Err(io::Error::new(
560        io::ErrorKind::Unsupported,
561        "symlink creation is not supported on this target",
562    ))
563}
564
565/// Write a restored worktree file via tmp + rename so concurrent
566/// readers never observe a torn file.
567///
568/// Deliberately NOT flushed: worktree contents are not part of the
569/// store's durability invariant (SPEC-OBJECTS §10.1) — the object
570/// store is the source of truth and checkout is re-runnable after a
571/// crash. Flushing every restored file made checkout O(files) full
572/// flushes (`F_FULLFSYNC` each on macOS) for no recoverable state; git
573/// likewise does not flush checked-out files.
574fn 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
581/// Open a fresh tmp file beside `name`, removing any stale tmp file a
582/// prior aborted attempt at the same name left behind. Returns the tmp
583/// path, final path, and the open handle so a caller can write
584/// incrementally (see [`restore_blob`]'s `ChunkedBlob` arm) instead of
585/// buffering the whole file first — pair with [`finish_atomic_write`]
586/// to apply the executable bit and atomically rename into place.
587fn 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
596/// Apply the executable bit (if requested) and atomically rename the
597/// tmp file from [`create_tmp_for_write`] into its final path. The tmp
598/// file's handle must already be closed/dropped before calling this
599/// (renaming an open handle is unreliable on some platforms).
600fn 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
663/// Sweep `target_dir` of untracked entries before re-materialising a
664/// tree. Entries present in `tree_entries`, the repo-metadata dirs
665/// (`.mkit`/`.git`, ASCII case-insensitive so case-insensitive
666/// filesystems can't smuggle a `.MKIT`/`.Git` entry past the sweep — Git
667/// CVE-2021-21300 family), and `.mkitignore` are always preserved.
668///
669/// When `ignore` is `Some`, this is the worktree-checkout path: entries
670/// matching the ignore list are additionally preserved (ancestor-aware,
671/// so an untracked file *under* an ignored directory survives too), and
672/// `.gitignore` is preserved as well.
673fn 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        // The ignore-aware checkout path also preserves `.gitignore`.
705        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        // Respect ignore rules — don't touch locally-ignored files. Use
726        // ancestor-aware matching so an untracked file *under* an ignored
727        // directory is preserved too (the safety gate exempts it, so the
728        // sweep must not delete it).
729        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        // Regression for Git CVE-2021-21300 family: on case-insensitive
1022        // filesystems a worktree directory named `.MKIT` or `.Git` must
1023        // never be swept by `clean_directory`, otherwise a hostile tree
1024        // entry could trick restore into deleting repo metadata.
1025        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        // Empty tree — without the case-insensitive guard, everything
1031        // unknown is removed.
1032        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    /// SPEC-OBJECTS §7: "The concatenated length MUST equal `total_size`."
1089    /// A manifest whose forged `total_size` disagrees with its (valid)
1090    /// chunks must fail restore instead of writing wrong-length content.
1091    #[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    // =================================================================
1308    // restore_tree_to_worktree — checkout-facing wrapper.
1309    // =================================================================
1310
1311    #[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        // Pre-seed an ignore file, an UNTRACKED ignored file (must survive the
1354        // clean sweep), and a tracked path that happens to match the ignore
1355        // pattern but IS in the target tree (must be written — git parity).
1356        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        // Both tracked entries materialise — ignore rules never gate writes.
1380        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        // The UNTRACKED ignored file is preserved by the clean sweep.
1388        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        // The clean sweep must not delete an untracked file that lives *under*
1398        // an ignored directory, even when that directory is part of the
1399        // target tree (so it gets recursed into).
1400        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        // Target tree: dist/ (tree) holding a tracked app.js.
1406        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        // Tracked content materialised...
1425        assert_eq!(fs::read(target.path().join("dist/app.js")).unwrap(), b"APP");
1426        // ...and the untracked file under the ignored dir is preserved.
1427        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}