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