Skip to main content

sley_worktree/
checkout.rs

1//! checkout/restore/reset materialization: branch/detached/path checkout, tree materialization, and the symlink-safe blob writer.
2//!
3//! Split out of `lib.rs` in the wave-47 mechanical refactor: a pure code move
4//! (no function body changed); all items are re-exported from `lib.rs`.
5use super::*;
6use crate::attributes::*;
7use crate::filter::*;
8use crate::index::*;
9use crate::index_io::*;
10use crate::status::*;
11use crate::types_admin::*;
12
13pub fn deleted_index_entries(
14    worktree_root: impl AsRef<Path>,
15    git_dir: impl AsRef<Path>,
16    format: ObjectFormat,
17) -> Result<Vec<IndexEntry>> {
18    let worktree_root = worktree_root.as_ref();
19    let git_dir = git_dir.as_ref();
20    let index_path = repository_index_path(git_dir);
21    if !index_path.exists() {
22        return Ok(Vec::new());
23    }
24    let index = Index::parse(&fs::read(index_path)?, format)?;
25    let mut deleted = Vec::new();
26    for entry in index.entries {
27        if !worktree_path(worktree_root, entry.path.as_bytes())?.exists()
28            && !index_entry_skip_worktree(&entry)
29        {
30            deleted.push(entry);
31        }
32    }
33    Ok(deleted)
34}
35
36pub fn modified_index_entries(
37    worktree_root: impl AsRef<Path>,
38    git_dir: impl AsRef<Path>,
39    format: ObjectFormat,
40) -> Result<Vec<IndexEntry>> {
41    let worktree_root = worktree_root.as_ref();
42    let git_dir = git_dir.as_ref();
43    let index_path = repository_index_path(git_dir);
44    if !index_path.exists() {
45        return Ok(Vec::new());
46    }
47    let mut index = Index::parse(&fs::read(&index_path)?, format)?;
48    if index.entries.iter().any(IndexEntry::is_sparse_dir) {
49        let db = FileObjectDatabase::from_git_dir(git_dir, format);
50        expand_sparse_index(&mut index, &db, format)?;
51    }
52    // Reuse the same racy-git stat shortcut here: build the cache from the index
53    // we just parsed (no second parse) so the worktree walk can skip re-hashing
54    // unchanged files. A cached oid is only trusted on a non-racy stat match, so
55    // genuinely modified files still fall through to a hash and are reported.
56    let stat_cache = IndexStatCache::from_index(&index, &index_path);
57    let mut modified = Vec::new();
58    for entry in index.entries {
59        let worktree_entry = worktree_entry_for_git_path(
60            worktree_root,
61            git_dir,
62            format,
63            entry.path.as_bytes(),
64            &entry.oid,
65            entry.mode,
66            Some(&stat_cache),
67        )?;
68        let Some(worktree_entry) = worktree_entry else {
69            if !index_entry_skip_worktree(&entry) {
70                modified.push(entry);
71            }
72            continue;
73        };
74        if worktree_entry.mode != entry.mode || worktree_entry.oid != entry.oid {
75            modified.push(entry);
76        }
77    }
78    Ok(modified)
79}
80
81pub fn checkout_branch(
82    worktree_root: impl AsRef<Path>,
83    git_dir: impl AsRef<Path>,
84    format: ObjectFormat,
85    branch: &str,
86    committer: Vec<u8>,
87) -> Result<CheckoutResult> {
88    let worktree_root = worktree_root.as_ref();
89    let git_dir = git_dir.as_ref();
90    let branch_ref = branch_ref_name(branch)?;
91    let refs = FileRefStore::new(git_dir, format);
92    let target = match sley_refs::resolve_ref_peeled(&refs, &branch_ref)? {
93        Some(oid) => oid,
94        None => {
95            checkout_switch_head_symbolic(&refs, branch_ref, committer, branch, None, None)?;
96            return Ok(CheckoutResult {
97                branch: branch.into(),
98                oid: ObjectId::null(format),
99                files: 0,
100            });
101        }
102    };
103    let current_head = resolve_head_commit_oid(git_dir, format)?;
104    let files = if current_head == Some(target) {
105        0
106    } else {
107        checkout_commit_to_index_and_worktree(worktree_root, git_dir, format, &target)?
108    };
109    checkout_switch_head_symbolic(
110        &refs,
111        branch_ref,
112        committer,
113        branch,
114        Some(target),
115        Some(target),
116    )?;
117    Ok(CheckoutResult {
118        branch: branch.into(),
119        oid: target,
120        files,
121    })
122}
123
124pub fn checkout_detached(
125    worktree_root: impl AsRef<Path>,
126    git_dir: impl AsRef<Path>,
127    format: ObjectFormat,
128    target: &ObjectId,
129    committer: Vec<u8>,
130    message: Vec<u8>,
131) -> Result<CheckoutResult> {
132    let worktree_root = worktree_root.as_ref();
133    let git_dir = git_dir.as_ref();
134    let files = checkout_commit_to_index_and_worktree(worktree_root, git_dir, format, target)?;
135    let refs = FileRefStore::new(git_dir, format);
136    let zero = ObjectId::null(format);
137    let mut tx = refs.transaction();
138    tx.update(RefUpdate {
139        name: "HEAD".into(),
140        expected: None,
141        new: RefTarget::Direct(*target),
142        reflog: Some(ReflogEntry {
143            old_oid: zero,
144            new_oid: *target,
145            committer,
146            message,
147        }),
148    });
149    tx.commit()?;
150    Ok(CheckoutResult {
151        branch: target.to_string(),
152        oid: *target,
153        files,
154    })
155}
156
157/// Like [`checkout_branch`], but runs the smudge-side content filters
158/// (`core.autocrlf`/`text`/`eol` EOL conversion and `filter.<name>.smudge`
159/// drivers) on each blob as it is written to the worktree. `config` is the
160/// repository config used to resolve the filters.
161pub fn checkout_branch_filtered(
162    worktree_root: impl AsRef<Path>,
163    git_dir: impl AsRef<Path>,
164    format: ObjectFormat,
165    branch: &str,
166    committer: Vec<u8>,
167    config: &GitConfig,
168) -> Result<CheckoutResult> {
169    let worktree_root = worktree_root.as_ref();
170    let git_dir = git_dir.as_ref();
171    let branch_ref = branch_ref_name(branch)?;
172    let refs = FileRefStore::new(git_dir, format);
173    let target = match sley_refs::resolve_ref_peeled(&refs, &branch_ref)? {
174        Some(oid) => oid,
175        None => {
176            checkout_switch_head_symbolic(&refs, branch_ref, committer, branch, None, None)?;
177            return Ok(CheckoutResult {
178                branch: branch.into(),
179                oid: ObjectId::null(format),
180                files: 0,
181            });
182        }
183    };
184    let current_head = resolve_head_commit_oid(git_dir, format)?;
185    let files = if current_head == Some(target) {
186        0
187    } else {
188        checkout_commit_to_index_and_worktree_filtered(
189            worktree_root,
190            git_dir,
191            format,
192            &target,
193            Some(config),
194            Some(vec![
195                ("ref".to_string(), branch_ref.clone()),
196                ("treeish".to_string(), target.to_hex()),
197            ]),
198        )?
199    };
200    checkout_switch_head_symbolic(
201        &refs,
202        branch_ref,
203        committer,
204        branch,
205        Some(target),
206        Some(target),
207    )?;
208    Ok(CheckoutResult {
209        branch: branch.into(),
210        oid: target,
211        files,
212    })
213}
214
215/// Like [`checkout_detached`], but runs the smudge-side content filters (see
216/// [`checkout_branch_filtered`]).
217pub fn checkout_detached_filtered(
218    worktree_root: impl AsRef<Path>,
219    git_dir: impl AsRef<Path>,
220    format: ObjectFormat,
221    target: &ObjectId,
222    committer: Vec<u8>,
223    message: Vec<u8>,
224    config: &GitConfig,
225) -> Result<CheckoutResult> {
226    let worktree_root = worktree_root.as_ref();
227    let git_dir = git_dir.as_ref();
228    let files = checkout_commit_to_index_and_worktree_filtered(
229        worktree_root,
230        git_dir,
231        format,
232        target,
233        Some(config),
234        Some(vec![("treeish".to_string(), target.to_hex())]),
235    )?;
236    let refs = FileRefStore::new(git_dir, format);
237    let zero = ObjectId::null(format);
238    let mut tx = refs.transaction();
239    tx.update(RefUpdate {
240        name: "HEAD".into(),
241        expected: None,
242        new: RefTarget::Direct(*target),
243        reflog: Some(ReflogEntry {
244            old_oid: zero,
245            new_oid: *target,
246            committer,
247            message,
248        }),
249    });
250    tx.commit()?;
251    Ok(CheckoutResult {
252        branch: target.to_string(),
253        oid: *target,
254        files,
255    })
256}
257
258pub(crate) fn checkout_commit_to_index_and_worktree(
259    worktree_root: &Path,
260    git_dir: &Path,
261    format: ObjectFormat,
262    target: &ObjectId,
263) -> Result<usize> {
264    checkout_commit_to_index_and_worktree_filtered(
265        worktree_root,
266        git_dir,
267        format,
268        target,
269        None,
270        None,
271    )
272}
273
274/// Like [`checkout_commit_to_index_and_worktree`] but optionally runs the
275/// smudge-side content filters (see [`apply_smudge_filter`]) on each blob before
276/// it is written to the worktree. Attribute lookups use the `.gitattributes`
277/// recorded in the *target tree* so the rules of the checked-out commit apply.
278pub(crate) fn checkout_commit_to_index_and_worktree_filtered(
279    worktree_root: &Path,
280    git_dir: &Path,
281    format: ObjectFormat,
282    target: &ObjectId,
283    smudge_config: Option<&GitConfig>,
284    process_metadata: Option<Vec<(String, String)>>,
285) -> Result<usize> {
286    if let Some((sparse, mode)) = active_sparse_checkout(git_dir)? {
287        return checkout_commit_to_index_and_worktree_sparse(
288            worktree_root,
289            git_dir,
290            format,
291            target,
292            Some((&sparse, mode)),
293            smudge_config,
294            process_metadata,
295        );
296    }
297    let _process_filter_metadata = set_process_filter_metadata(process_metadata);
298    let mut dirty = false;
299    if smudge_config.is_some() {
300        dirty = !modified_index_entries(worktree_root, git_dir, format)?.is_empty();
301    } else {
302        stream_short_status(worktree_root, git_dir, format, |entry| {
303            if !status_row_is_untracked_or_ignored(entry) {
304                dirty = true;
305                return Ok(StreamControl::Stop);
306            }
307            Ok(StreamControl::Continue)
308        })?;
309    }
310    if dirty {
311        return Err(GitError::Transaction(
312            "checkout requires a clean working tree".into(),
313        ));
314    }
315    let db = FileObjectDatabase::from_git_dir(git_dir, format);
316    let commit = read_commit(&db, format, target)?;
317    let mut target_entries = BTreeMap::new();
318    collect_tree_entries(&db, format, &commit.tree, &mut target_entries)?;
319    refuse_if_current_working_directory_becomes_file(worktree_root, &target_entries)?;
320
321    let attributes = smudge_config
322        .map(|_| build_tree_attribute_matcher(worktree_root, &db, format, &commit.tree))
323        .transpose()?;
324
325    for path in read_index_entries(git_dir, format)?.keys() {
326        if !target_entries.contains_key(path) {
327            remove_worktree_file(worktree_root, path)?;
328        }
329    }
330
331    let mut index_entries = Vec::new();
332    for (path, entry) in &target_entries {
333        // Single type-by-mode materializer: gitlinks become a directory (mkdir,
334        // no blob read), symlinks (mode 120000) a real symlink to the raw blob
335        // bytes, and regular files the smudge-filtered content. Inlining the blob
336        // write here previously dropped the symlink arm and wrote the link target
337        // as a regular file — the whole symlink-checkout class.
338        index_entries.push(materialize_tree_entry_with_optional_smudge(
339            &db,
340            format,
341            worktree_root,
342            path,
343            entry,
344            smudge_config,
345            attributes.as_ref(),
346        )?);
347    }
348    index_entries.sort_by(|left, right| left.path.cmp(&right.path));
349    let extensions = preserved_index_extensions(git_dir, format)?;
350    fs::write(
351        repository_index_path(git_dir),
352        Index {
353            version: 2,
354            entries: index_entries,
355            extensions,
356            checksum: None,
357        }
358        .write(format)?,
359    )?;
360    Ok(target_entries.len())
361}
362
363/// Build an [`AttributeMatcher`] from the `.gitattributes` files contained in a
364/// tree, plus the repo-level (`core.attributesFile`, `.git/info/attributes`)
365/// sources, mirroring [`standard_attributes_for_path_from_tree`].
366pub(crate) fn build_tree_attribute_matcher(
367    worktree_root: &Path,
368    db: &FileObjectDatabase,
369    format: ObjectFormat,
370    tree_oid: &ObjectId,
371) -> Result<AttributeMatcher> {
372    let mut matcher = AttributeMatcher::default();
373    let git_dir = worktree_root.join(".git");
374    matcher.configure_case_sensitivity(&git_dir);
375    if !matcher.read_configured_attributes(worktree_root, &git_dir) {
376        matcher.read_default_global_attributes();
377    }
378    collect_attribute_patterns_from_tree(db, format, tree_oid, Vec::new(), &mut matcher)?;
379    read_attribute_patterns(
380        worktree_root.join(".git").join("info").join("attributes"),
381        &mut matcher,
382        &[],
383        b".git/info/attributes",
384        false,
385    );
386    Ok(matcher)
387}
388
389pub(crate) fn materialize_tree_entry_with_optional_smudge(
390    db: &FileObjectDatabase,
391    format: ObjectFormat,
392    worktree_root: &Path,
393    path: &[u8],
394    entry: &TrackedEntry,
395    smudge_config: Option<&GitConfig>,
396    attributes: Option<&AttributeMatcher>,
397) -> Result<IndexEntry> {
398    // A symlink (mode 120000) is written as a *symlink* whose target is the raw,
399    // unfiltered blob bytes — git treats symlink content as an opaque path, so no
400    // smudge/EOL filter ever applies. Route it through the type-aware
401    // `materialize_tree_entry` (→ `write_worktree_blob_entry`) so it is never
402    // materialized as a regular file holding the target string. A gitlink (mkdir,
403    // no blob read) and the no-smudge case go through the same shared path.
404    if smudge_config.is_none()
405        || sley_index::is_gitlink(entry.mode)
406        || (entry.mode & 0o170000) == 0o120000
407    {
408        return materialize_tree_entry(db, worktree_root, path, entry);
409    }
410    let config = smudge_config.expect("checked above");
411    let matcher = attributes.expect("attributes are built when smudge_config is set");
412    let object = read_expected_object(db, &entry.oid, ObjectType::Blob)?;
413    let checks = matcher.attributes_for_path(path, &filter_attribute_names(), false);
414    let body = apply_smudge_filter_with_attributes_cow_format(
415        config,
416        &checks,
417        path,
418        &object.body,
419        format,
420    )?;
421    let file_path = worktree_path(worktree_root, path)?;
422    prepare_blob_parent_dirs(worktree_root, &file_path)?;
423    remove_existing_worktree_path(&file_path)?;
424    fs::write(&file_path, &body)?;
425    set_worktree_file_mode(&file_path, entry.mode)?;
426    let metadata = fs::metadata(&file_path)?;
427    let mut index_entry = index_entry_from_metadata(path.to_vec(), entry.oid, &metadata);
428    index_entry.mode = entry.mode;
429    Ok(index_entry)
430}
431
432/// Sparse- and skip-worktree-aware variant of
433/// [`checkout_commit_to_index_and_worktree`].
434///
435/// When `sparse` is `None` this behaves like the plain checkout except that it
436/// preserves any pre-existing skip-worktree bits (so an already-sparse worktree
437/// is not silently re-expanded). When `sparse` is `Some`, every target path is
438/// additionally classified against the patterns: in-cone paths are written and
439/// have their skip-worktree bit cleared, while out-of-cone paths are left out
440/// of the worktree, get their skip-worktree bit set, and have any stale file
441/// removed.
442pub(crate) fn checkout_commit_to_index_and_worktree_sparse(
443    worktree_root: &Path,
444    git_dir: &Path,
445    format: ObjectFormat,
446    target: &ObjectId,
447    sparse: Option<(&SparseCheckout, SparseCheckoutMode)>,
448    smudge_config: Option<&GitConfig>,
449    process_metadata: Option<Vec<(String, String)>>,
450) -> Result<usize> {
451    let _process_filter_metadata = set_process_filter_metadata(process_metadata);
452    let previously_skipped = skip_worktree_paths(git_dir, format)?;
453    let db = FileObjectDatabase::from_git_dir(git_dir, format);
454    let commit = read_commit(&db, format, target)?;
455    let mut target_entries = BTreeMap::new();
456    collect_tree_entries(&db, format, &commit.tree, &mut target_entries)?;
457
458    // Honor skip-worktree: a path whose worktree file is intentionally absent
459    // must not be treated as a dirty (deleted) change blocking the checkout.
460    let mut dirty = false;
461    stream_short_status(worktree_root, git_dir, format, |entry| {
462        if previously_skipped.contains(entry.path) {
463            return Ok(StreamControl::Continue);
464        }
465        // Submodule state never blocks a checkout: upstream unpack-trees
466        // treats gitlinks as always up-to-date (ie_match_stat refuses to pay
467        // for a submodule dirtiness probe), so new commits / dirty content in
468        // a submodule must not fail the branch switch.
469        if entry.index_mode.is_some_and(sley_index::is_gitlink)
470            || entry.worktree_mode.is_some_and(sley_index::is_gitlink)
471        {
472            return Ok(StreamControl::Continue);
473        }
474        // An untracked embedded repository where the target tree records a
475        // gitlink is reused as-is (upstream entry.c write_entry: mkdir with
476        // EEXIST is success), so it does not block the checkout either.
477        if entry.index == b'?' && entry.worktree == b'?' {
478            let path = entry.path.strip_suffix(b"/").unwrap_or(entry.path);
479            if target_entries
480                .get(path)
481                .is_some_and(|target| sley_index::is_gitlink(target.mode))
482            {
483                return Ok(StreamControl::Continue);
484            }
485        }
486        dirty = true;
487        Ok(StreamControl::Stop)
488    })?;
489    if dirty {
490        return Err(GitError::Transaction(
491            "checkout requires a clean working tree".into(),
492        ));
493    }
494
495    let matcher = sparse.map(|(spec, mode)| SparseMatcher::new(spec, mode));
496    let attributes = smudge_config
497        .map(|_| build_tree_attribute_matcher(worktree_root, &db, format, &commit.tree))
498        .transpose()?;
499
500    for path in read_index_entries(git_dir, format)?.keys() {
501        if target_entries.contains_key(path) {
502            continue;
503        }
504        // Do not disturb the worktree state of an intentionally skipped path.
505        if previously_skipped.contains(path) {
506            continue;
507        }
508        remove_worktree_file(worktree_root, path)?;
509    }
510
511    let mut index_entries = Vec::new();
512    for (path, entry) in &target_entries {
513        let in_cone = matcher.as_ref().map_or_else(
514            || !previously_skipped.contains(path),
515            |matcher| matcher.includes_file(path),
516        );
517        let index_entry = if in_cone {
518            materialize_tree_entry_with_optional_smudge(
519                &db,
520                format,
521                worktree_root,
522                path,
523                entry,
524                smudge_config,
525                attributes.as_ref(),
526            )?
527        } else {
528            // Out of cone: ensure no stale worktree file remains and synthesize
529            // an index entry straight from the tree (no worktree metadata),
530            // then mark it skip-worktree.
531            remove_worktree_file(worktree_root, path)?;
532            let mut index_entry = restored_head_index_entry(worktree_root, &db, path, entry)?;
533            set_skip_worktree(&mut index_entry);
534            index_entry
535        };
536        index_entries.push(index_entry);
537    }
538    index_entries.sort_by(|left, right| left.path.cmp(&right.path));
539    let mut index = Index {
540        version: 2,
541        entries: index_entries,
542        extensions: preserved_index_extensions(git_dir, format)?,
543        checksum: None,
544    };
545    normalize_index_version_for_extended_flags(&mut index);
546    write_repository_index_ref(git_dir, format, &index)?;
547    Ok(target_entries.len())
548}
549
550pub(crate) fn skip_worktree_paths(
551    git_dir: &Path,
552    format: ObjectFormat,
553) -> Result<BTreeSet<Vec<u8>>> {
554    let index_path = repository_index_path(git_dir);
555    if !index_path.exists() {
556        return Ok(BTreeSet::new());
557    }
558    let index = Index::parse(&fs::read(index_path)?, format)?;
559    Ok(index
560        .entries
561        .into_iter()
562        .filter(index_entry_skip_worktree)
563        .map(|entry| entry.path.into_bytes())
564        .collect())
565}
566
567pub fn restore_worktree_paths(
568    worktree_root: impl AsRef<Path>,
569    git_dir: impl AsRef<Path>,
570    format: ObjectFormat,
571    paths: &[PathBuf],
572) -> Result<RestoreResult> {
573    restore_worktree_paths_inner(
574        worktree_root.as_ref(),
575        git_dir.as_ref(),
576        format,
577        paths,
578        None,
579    )
580}
581
582/// Like [`restore_worktree_paths`], applying the smudge-side content filters
583/// (CRLF / ident / filter drivers) the way a checkout writes blobs.
584pub fn restore_worktree_paths_filtered(
585    worktree_root: impl AsRef<Path>,
586    git_dir: impl AsRef<Path>,
587    format: ObjectFormat,
588    paths: &[PathBuf],
589    config: &GitConfig,
590) -> Result<RestoreResult> {
591    restore_worktree_paths_inner(
592        worktree_root.as_ref(),
593        git_dir.as_ref(),
594        format,
595        paths,
596        Some(config),
597    )
598}
599
600pub(crate) fn restore_worktree_paths_inner(
601    worktree_root: &Path,
602    git_dir: &Path,
603    format: ObjectFormat,
604    paths: &[PathBuf],
605    smudge_config: Option<&GitConfig>,
606) -> Result<RestoreResult> {
607    let index_path = repository_index_path(git_dir);
608    if !index_path.exists() {
609        return Err(GitError::Exit(1));
610    }
611    let mut index = Index::parse(&fs::read(&index_path)?, format)?;
612    let stat_cache = IndexStatCache::from_index(&index, &index_path);
613    let db = FileObjectDatabase::from_git_dir(git_dir, format);
614    let mut restored = BTreeSet::new();
615    for path in paths {
616        let absolute = if path.is_absolute() {
617            path.clone()
618        } else {
619            worktree_root.join(path)
620        };
621        let absolute = normalize_absolute_path_lexically(&absolute);
622        let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
623            GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
624        })?;
625        let git_path = git_path_bytes(relative)?;
626        let recursive = path == Path::new(".")
627            || path.to_string_lossy().ends_with('/')
628            || absolute.is_dir()
629            || index_has_entry_under(&index.entries, &git_path);
630        let mut matched = false;
631        let matched_positions = index
632            .entries
633            .iter()
634            .enumerate()
635            .filter_map(|(position, entry)| {
636                (entry.path.as_bytes() == git_path.as_slice()
637                    || (recursive && index_entry_is_under_path(entry.path.as_bytes(), &git_path)))
638                .then_some(position)
639            })
640            .collect::<Vec<_>>();
641        for position in matched_positions {
642            let refreshed = restore_index_entry(
643                worktree_root,
644                git_dir,
645                format,
646                &db,
647                &index.entries[position],
648                smudge_config,
649                Some(&stat_cache),
650            )?;
651            restored.insert(index.entries[position].path.clone());
652            matched = true;
653            if let Some(refreshed) = refreshed {
654                index.entries[position] = refreshed;
655            }
656        }
657        if !matched {
658            eprintln!(
659                "error: pathspec '{}' did not match any file(s) known to git",
660                path.display()
661            );
662            return Err(GitError::Exit(1));
663        }
664    }
665    write_repository_index_ref(git_dir, format, &index)?;
666    Ok(RestoreResult {
667        restored: restored.len(),
668    })
669}
670
671pub fn checkout_index_paths(
672    worktree_root: impl AsRef<Path>,
673    git_dir: impl AsRef<Path>,
674    format: ObjectFormat,
675    paths: &[PathBuf],
676    options: CheckoutIndexPathOptions<'_>,
677) -> Result<RestoreResult> {
678    let worktree_root = worktree_root.as_ref();
679    let git_dir = git_dir.as_ref();
680    let index_path = repository_index_path(git_dir);
681    if !index_path.exists() {
682        return Err(GitError::Exit(1));
683    }
684    let mut index = Index::parse(&fs::read(&index_path)?, format)?;
685    if options.merge {
686        checkout_unmerge_resolve_undo_paths(worktree_root, &mut index, format, paths)?;
687    }
688    let stat_cache = IndexStatCache::from_index(&index, &index_path);
689    let db = FileObjectDatabase::from_git_dir(git_dir, format);
690    let selected = checkout_selected_index_paths(worktree_root, &index, paths)?;
691
692    if options.stage.is_none() && !options.merge && !options.force {
693        for path in &selected {
694            if checkout_path_is_unmerged(&index, path) {
695                eprintln!(
696                    "error: path '{}' is unmerged",
697                    String::from_utf8_lossy(path)
698                );
699                return Err(GitError::Exit(1));
700            }
701        }
702    }
703
704    let mut refreshed = BTreeMap::new();
705    let mut restored = BTreeSet::new();
706    for path in selected {
707        let positions = index
708            .entries
709            .iter()
710            .enumerate()
711            .filter_map(|(position, entry)| (entry.path.as_bytes() == path).then_some(position))
712            .collect::<Vec<_>>();
713        let stage0 = positions
714            .iter()
715            .copied()
716            .find(|position| index.entries[*position].stage() == Stage::Normal);
717        let is_unmerged = positions
718            .iter()
719            .any(|position| index.entries[*position].stage() != Stage::Normal);
720
721        if is_unmerged {
722            if let Some(stage) = options.stage {
723                let wanted = match stage {
724                    CheckoutStage::Ours => Stage::Ours,
725                    CheckoutStage::Theirs => Stage::Theirs,
726                };
727                let Some(position) = positions
728                    .iter()
729                    .copied()
730                    .find(|position| index.entries[*position].stage() == wanted)
731                else {
732                    eprintln!(
733                        "error: path '{}' does not have {} version",
734                        String::from_utf8_lossy(&path),
735                        match stage {
736                            CheckoutStage::Ours => "our",
737                            CheckoutStage::Theirs => "their",
738                        }
739                    );
740                    return Err(GitError::Exit(1));
741                };
742                checkout_write_index_entry_to_worktree(
743                    worktree_root,
744                    git_dir,
745                    format,
746                    &db,
747                    &index.entries[position],
748                    options.smudge_config,
749                    Some(&stat_cache),
750                )?;
751                restored.insert(path);
752                continue;
753            }
754            if options.merge {
755                checkout_merge_unmerged_path(
756                    worktree_root,
757                    &db,
758                    &index,
759                    &positions,
760                    options.conflict_style,
761                )?;
762                restored.insert(path);
763                continue;
764            }
765            if options.force {
766                continue;
767            }
768        }
769
770        if let Some(position) = stage0 {
771            if let Some(updated) = checkout_write_index_entry_to_worktree(
772                worktree_root,
773                git_dir,
774                format,
775                &db,
776                &index.entries[position],
777                options.smudge_config,
778                Some(&stat_cache),
779            )? {
780                refreshed.insert(position, updated);
781            }
782            restored.insert(path);
783        }
784    }
785
786    for (position, entry) in refreshed {
787        index.entries[position] = entry;
788    }
789    if !index.entries.is_empty() {
790        write_repository_index_ref(git_dir, format, &index)?;
791    }
792    Ok(RestoreResult {
793        restored: restored.len(),
794    })
795}
796
797pub fn unresolve_index_paths(
798    worktree_root: impl AsRef<Path>,
799    git_dir: impl AsRef<Path>,
800    format: ObjectFormat,
801    paths: &[PathBuf],
802) -> Result<()> {
803    let worktree_root = worktree_root.as_ref();
804    let git_dir = git_dir.as_ref();
805    let index_path = repository_index_path(git_dir);
806    if !index_path.exists() {
807        return Ok(());
808    }
809    let mut index = Index::parse(&fs::read(&index_path)?, format)?;
810    checkout_unmerge_resolve_undo_paths(worktree_root, &mut index, format, paths)?;
811    write_repository_index_ref(git_dir, format, &index)
812}
813
814pub(crate) fn checkout_selected_index_paths(
815    worktree_root: &Path,
816    index: &Index,
817    paths: &[PathBuf],
818) -> Result<BTreeSet<Vec<u8>>> {
819    let index_paths = index
820        .entries
821        .iter()
822        .map(|entry| entry.path.as_bytes().to_vec())
823        .collect::<BTreeSet<_>>();
824    let mut selected = BTreeSet::new();
825    for path in paths {
826        let absolute = if path.is_absolute() {
827            path.clone()
828        } else {
829            worktree_root.join(path)
830        };
831        let absolute = normalize_absolute_path_lexically(&absolute);
832        let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
833            GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
834        })?;
835        let git_path = git_path_bytes(relative)?;
836        let recursive = path == Path::new(".")
837            || path.to_string_lossy().ends_with('/')
838            || absolute.is_dir()
839            || index_paths
840                .iter()
841                .any(|entry| index_entry_is_under_path(entry, &git_path));
842        let matched = index_paths
843            .iter()
844            .filter(|entry| {
845                entry.as_slice() == git_path.as_slice()
846                    || (recursive && index_entry_is_under_path(entry, &git_path))
847            })
848            .cloned()
849            .collect::<Vec<_>>();
850        if matched.is_empty() {
851            eprintln!(
852                "error: pathspec '{}' did not match any file(s) known to git",
853                path.display()
854            );
855            return Err(GitError::Exit(1));
856        }
857        selected.extend(matched);
858    }
859    Ok(selected)
860}
861
862pub(crate) fn checkout_unmerge_resolve_undo_paths(
863    worktree_root: &Path,
864    index: &mut Index,
865    format: ObjectFormat,
866    paths: &[PathBuf],
867) -> Result<()> {
868    let records = parse_resolve_undo_records(index.extension(b"REUC")?, format)?;
869    if records.is_empty() {
870        return Ok(());
871    }
872    let mut remaining = Vec::new();
873    let mut unmerged_any = false;
874    for record in records {
875        if checkout_pathspecs_match_git_path(worktree_root, paths, &record.path)? {
876            remove_index_entries_with_path(&mut index.entries, &record.path);
877            for (idx, stage) in record.stages.into_iter().enumerate() {
878                let Some((mode, oid)) = stage else {
879                    continue;
880                };
881                index.entries.push(resolve_undo_index_entry(
882                    record.path.clone(),
883                    mode,
884                    oid,
885                    (idx + 1) as u16,
886                ));
887            }
888            unmerged_any = true;
889        } else {
890            remaining.push(record);
891        }
892    }
893    if unmerged_any {
894        index.entries.sort_by(compare_index_key);
895        normalize_index_version_for_extended_flags(index);
896        set_resolve_undo_extension(index, &remaining)?;
897    }
898    Ok(())
899}
900
901pub(crate) fn checkout_pathspecs_match_git_path(
902    worktree_root: &Path,
903    paths: &[PathBuf],
904    candidate: &[u8],
905) -> Result<bool> {
906    for path in paths {
907        let absolute = if path.is_absolute() {
908            path.clone()
909        } else {
910            worktree_root.join(path)
911        };
912        let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
913            GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
914        })?;
915        let git_path = git_path_bytes(relative)?;
916        let recursive = path == Path::new(".")
917            || path.to_string_lossy().ends_with('/')
918            || absolute.is_dir()
919            || index_entry_is_under_path(candidate, &git_path);
920        if candidate == git_path.as_slice()
921            || (recursive && index_entry_is_under_path(candidate, &git_path))
922        {
923            return Ok(true);
924        }
925    }
926    Ok(false)
927}
928
929pub(crate) fn resolve_undo_index_entry(
930    path: Vec<u8>,
931    mode: u32,
932    oid: ObjectId,
933    stage: u16,
934) -> IndexEntry {
935    let name_len = (path
936        .len()
937        .min(sley_index::INDEX_FLAG_NAME_LENGTH_MASK as usize)) as u16;
938    IndexEntry {
939        ctime_seconds: 0,
940        ctime_nanoseconds: 0,
941        mtime_seconds: 0,
942        mtime_nanoseconds: 0,
943        dev: 0,
944        ino: 0,
945        mode,
946        uid: 0,
947        gid: 0,
948        size: 0,
949        oid,
950        flags: name_len | (stage << 12),
951        flags_extended: 0,
952        path: path.into(),
953    }
954}
955
956pub(crate) fn checkout_path_is_unmerged(index: &Index, path: &[u8]) -> bool {
957    index
958        .entries
959        .iter()
960        .any(|entry| entry.path.as_bytes() == path && entry.stage() != Stage::Normal)
961}
962
963pub(crate) fn checkout_write_index_entry_to_worktree(
964    worktree_root: &Path,
965    git_dir: &Path,
966    format: ObjectFormat,
967    db: &FileObjectDatabase,
968    entry: &IndexEntry,
969    smudge_config: Option<&GitConfig>,
970    stat_cache: Option<&IndexStatCache>,
971) -> Result<Option<IndexEntry>> {
972    restore_index_entry(
973        worktree_root,
974        git_dir,
975        format,
976        db,
977        entry,
978        smudge_config,
979        stat_cache,
980    )
981}
982
983pub(crate) fn checkout_merge_unmerged_path(
984    worktree_root: &Path,
985    db: &FileObjectDatabase,
986    index: &Index,
987    positions: &[usize],
988    style: CheckoutConflictStyle,
989) -> Result<()> {
990    let mut base = None;
991    let mut ours = None;
992    let mut theirs = None;
993    for position in positions {
994        let entry = &index.entries[*position];
995        match entry.stage() {
996            Stage::Base => base = Some(entry),
997            Stage::Ours => ours = Some(entry),
998            Stage::Theirs => theirs = Some(entry),
999            Stage::Normal => {}
1000        }
1001    }
1002    let Some(ours) = ours else {
1003        return Ok(());
1004    };
1005    let Some(theirs) = theirs else {
1006        return Ok(());
1007    };
1008    let base_body = match base {
1009        Some(entry) => read_expected_object(db, &entry.oid, ObjectType::Blob)?
1010            .body
1011            .clone(),
1012        None => Vec::new(),
1013    };
1014    let ours_body = read_expected_object(db, &ours.oid, ObjectType::Blob)?
1015        .body
1016        .clone();
1017    let theirs_body = read_expected_object(db, &theirs.oid, ObjectType::Blob)?
1018        .body
1019        .clone();
1020    let result = sley_diff_merge::merge_blobs(
1021        &base_body,
1022        &ours_body,
1023        &theirs_body,
1024        &sley_diff_merge::MergeBlobOptions {
1025            ours_label: "ours",
1026            theirs_label: "theirs",
1027            base_label: "base",
1028            style: match style {
1029                CheckoutConflictStyle::Merge => sley_diff_merge::ConflictStyle::Merge,
1030                CheckoutConflictStyle::Diff3 => sley_diff_merge::ConflictStyle::Diff3,
1031            },
1032            favor: sley_diff_merge::MergeFavor::None,
1033            ws_ignore: sley_diff_merge::WsIgnore::EMPTY,
1034        },
1035    );
1036    let file_path = worktree_path(worktree_root, ours.path.as_bytes())?;
1037    prepare_blob_parent_dirs(worktree_root, &file_path)?;
1038    remove_existing_worktree_path(&file_path)?;
1039    fs::write(&file_path, result.content)?;
1040    set_worktree_file_mode(&file_path, ours.mode)?;
1041    Ok(())
1042}
1043
1044pub fn restore_index_paths_from_head(
1045    worktree_root: impl AsRef<Path>,
1046    git_dir: impl AsRef<Path>,
1047    format: ObjectFormat,
1048    paths: &[PathBuf],
1049) -> Result<RestoreResult> {
1050    let worktree_root = worktree_root.as_ref();
1051    let git_dir = git_dir.as_ref();
1052    let index_path = repository_index_path(git_dir);
1053    let index = if index_path.exists() {
1054        Index::parse(&fs::read(&index_path)?, format)?
1055    } else {
1056        Index {
1057            version: 2,
1058            entries: Vec::new(),
1059            extensions: Vec::new(),
1060            checksum: None,
1061        }
1062    };
1063    let db = FileObjectDatabase::from_git_dir(git_dir, format);
1064    let head_entries = head_tree_entries(git_dir, format, &db)?;
1065    restore_index_paths_from_entries(
1066        worktree_root,
1067        git_dir,
1068        format,
1069        &db,
1070        index,
1071        &head_entries,
1072        paths,
1073        false,
1074    )
1075}
1076
1077pub fn restore_index_paths_from_tree(
1078    worktree_root: impl AsRef<Path>,
1079    git_dir: impl AsRef<Path>,
1080    format: ObjectFormat,
1081    tree_oid: &ObjectId,
1082    paths: &[PathBuf],
1083) -> Result<RestoreResult> {
1084    let worktree_root = worktree_root.as_ref();
1085    let git_dir = git_dir.as_ref();
1086    let index_path = repository_index_path(git_dir);
1087    let index = if index_path.exists() {
1088        Index::parse(&fs::read(&index_path)?, format)?
1089    } else {
1090        Index {
1091            version: 2,
1092            entries: Vec::new(),
1093            extensions: Vec::new(),
1094            checksum: None,
1095        }
1096    };
1097    let db = FileObjectDatabase::from_git_dir(git_dir, format);
1098    let source_entries = tree_entries(&db, format, tree_oid)?;
1099    restore_index_paths_from_entries(
1100        worktree_root,
1101        git_dir,
1102        format,
1103        &db,
1104        index,
1105        &source_entries,
1106        paths,
1107        false,
1108    )
1109}
1110
1111pub fn restore_index_paths_from_tree_allow_unmatched(
1112    worktree_root: impl AsRef<Path>,
1113    git_dir: impl AsRef<Path>,
1114    format: ObjectFormat,
1115    tree_oid: &ObjectId,
1116    paths: &[PathBuf],
1117) -> Result<RestoreResult> {
1118    let worktree_root = worktree_root.as_ref();
1119    let git_dir = git_dir.as_ref();
1120    let index_path = repository_index_path(git_dir);
1121    let index = if index_path.exists() {
1122        Index::parse(&fs::read(&index_path)?, format)?
1123    } else {
1124        Index {
1125            version: 2,
1126            entries: Vec::new(),
1127            extensions: Vec::new(),
1128            checksum: None,
1129        }
1130    };
1131    let db = FileObjectDatabase::from_git_dir(git_dir, format);
1132    let source_entries = tree_entries(&db, format, tree_oid)?;
1133    restore_index_paths_from_entries(
1134        worktree_root,
1135        git_dir,
1136        format,
1137        &db,
1138        index,
1139        &source_entries,
1140        paths,
1141        true,
1142    )
1143}
1144
1145pub(crate) fn restore_index_paths_from_entries(
1146    worktree_root: &Path,
1147    git_dir: &Path,
1148    format: ObjectFormat,
1149    db: &FileObjectDatabase,
1150    mut index: Index,
1151    source_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1152    paths: &[PathBuf],
1153    allow_unmatched: bool,
1154) -> Result<RestoreResult> {
1155    let sparse = active_sparse_checkout(git_dir)?;
1156    if index.is_sparse() {
1157        expand_sparse_index(&mut index, db, format)?;
1158    }
1159    let index_version = index.version;
1160    let extensions = index_extensions_without_cache_tree(&index.extensions);
1161    let mut index_entries = index
1162        .entries
1163        .into_iter()
1164        .map(|entry| (entry.path.as_bytes().to_vec(), entry))
1165        .collect::<BTreeMap<_, _>>();
1166    let prior_skip_worktree = index_entries
1167        .iter()
1168        .filter(|(_, entry)| entry.is_skip_worktree())
1169        .map(|(path, _)| path.clone())
1170        .collect::<BTreeSet<_>>();
1171    let mut restored = BTreeSet::new();
1172    for path in paths {
1173        let absolute = if path.is_absolute() {
1174            path.clone()
1175        } else {
1176            worktree_root.join(path)
1177        };
1178        let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
1179            GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
1180        })?;
1181        let git_path = git_path_bytes(relative)?;
1182        let recursive = path == Path::new(".")
1183            || path.to_string_lossy().ends_with('/')
1184            || absolute.is_dir()
1185            || index_entries
1186                .keys()
1187                .any(|entry| index_entry_is_under_path(entry, &git_path))
1188            || source_entries
1189                .keys()
1190                .any(|entry| index_entry_is_under_path(entry, &git_path));
1191        let mut matched_paths = BTreeSet::new();
1192        for path in index_entries.keys().chain(source_entries.keys()) {
1193            if path.as_slice() == git_path.as_slice()
1194                || (recursive && index_entry_is_under_path(path, &git_path))
1195            {
1196                matched_paths.insert(path.clone());
1197            }
1198        }
1199        if matched_paths.is_empty() {
1200            if allow_unmatched {
1201                continue;
1202            }
1203            eprintln!(
1204                "error: pathspec '{}' did not match any file(s) known to git",
1205                path.display()
1206            );
1207            return Err(GitError::Exit(1));
1208        }
1209        for path in matched_paths {
1210            if let Some(entry) = source_entries.get(&path) {
1211                // git's pathspec reset (`reset_index` → diff against the source
1212                // tree) only rewrites entries that actually CHANGE: an entry whose
1213                // oid and mode already equal the source is left untouched, so its
1214                // cached stat is preserved and `git diff-files` stays clean (t7102
1215                // "resetting an unmodified path is a no-op"). Only when the entry
1216                // genuinely changes does git write a fresh, stat-zeroed entry.
1217                let unchanged = index_entries.get(&path).is_some_and(|existing| {
1218                    existing.oid == entry.oid
1219                        && existing.mode == entry.mode
1220                        && !existing.is_intent_to_add()
1221                });
1222                if !unchanged {
1223                    let mut restored = restored_head_index_entry(worktree_root, db, &path, entry)?;
1224                    if prior_skip_worktree.contains(&path) {
1225                        restored.set_skip_worktree(true);
1226                    }
1227                    index_entries.insert(path.clone(), restored);
1228                }
1229            } else {
1230                index_entries.remove(&path);
1231            }
1232            restored.insert(path);
1233        }
1234    }
1235    let mut entries = index_entries.into_values().collect::<Vec<_>>();
1236    entries.sort_by(|left, right| left.path.cmp(&right.path));
1237    let restored_paths = restored.iter().cloned().collect::<Vec<_>>();
1238    let mut index = Index {
1239        version: index_version,
1240        entries,
1241        extensions,
1242        checksum: None,
1243    };
1244    invalidate_untracked_cache_for_git_paths(&mut index, format, &restored_paths)?;
1245    if let Some((sparse, mode)) = sparse
1246        && sparse.sparse_index
1247    {
1248        let matcher = SparseMatcher::new(&sparse, mode);
1249        collapse_to_sparse_index(&mut index, &matcher, db, format)?;
1250    }
1251    write_repository_index_ref(git_dir, format, &index)?;
1252    Ok(RestoreResult {
1253        restored: restored.len(),
1254    })
1255}
1256
1257pub fn restore_index_and_worktree_paths_from_head(
1258    worktree_root: impl AsRef<Path>,
1259    git_dir: impl AsRef<Path>,
1260    format: ObjectFormat,
1261    paths: &[PathBuf],
1262    overlay: bool,
1263) -> Result<RestoreResult> {
1264    let worktree_root = worktree_root.as_ref();
1265    let git_dir = git_dir.as_ref();
1266    let index_path = repository_index_path(git_dir);
1267    let index = if index_path.exists() {
1268        Index::parse(&fs::read(&index_path)?, format)?
1269    } else {
1270        Index {
1271            version: 2,
1272            entries: Vec::new(),
1273            extensions: Vec::new(),
1274            checksum: None,
1275        }
1276    };
1277    let db = FileObjectDatabase::from_git_dir(git_dir, format);
1278    let head_entries = head_tree_entries(git_dir, format, &db)?;
1279    restore_index_and_worktree_paths_from_entries(
1280        worktree_root,
1281        git_dir,
1282        format,
1283        &db,
1284        index,
1285        &head_entries,
1286        paths,
1287        overlay,
1288    )
1289}
1290
1291pub fn restore_index_and_worktree_paths_from_tree(
1292    worktree_root: impl AsRef<Path>,
1293    git_dir: impl AsRef<Path>,
1294    format: ObjectFormat,
1295    tree_oid: &ObjectId,
1296    paths: &[PathBuf],
1297    overlay: bool,
1298) -> Result<RestoreResult> {
1299    let worktree_root = worktree_root.as_ref();
1300    let git_dir = git_dir.as_ref();
1301    let index_path = repository_index_path(git_dir);
1302    let index = if index_path.exists() {
1303        Index::parse(&fs::read(&index_path)?, format)?
1304    } else {
1305        Index {
1306            version: 2,
1307            entries: Vec::new(),
1308            extensions: Vec::new(),
1309            checksum: None,
1310        }
1311    };
1312    let db = FileObjectDatabase::from_git_dir(git_dir, format);
1313    let source_entries = tree_entries(&db, format, tree_oid)?;
1314    restore_index_and_worktree_paths_from_entries(
1315        worktree_root,
1316        git_dir,
1317        format,
1318        &db,
1319        index,
1320        &source_entries,
1321        paths,
1322        overlay,
1323    )
1324}
1325
1326pub(crate) fn restore_index_and_worktree_paths_from_entries(
1327    worktree_root: &Path,
1328    git_dir: &Path,
1329    format: ObjectFormat,
1330    db: &FileObjectDatabase,
1331    index: Index,
1332    source_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1333    paths: &[PathBuf],
1334    overlay: bool,
1335) -> Result<RestoreResult> {
1336    let index_version = index.version;
1337    let extensions = index_extensions_without_cache_tree(&index.extensions);
1338    let mut index_entries = index
1339        .entries
1340        .into_iter()
1341        .map(|entry| (entry.path.as_bytes().to_vec(), entry))
1342        .collect::<BTreeMap<_, _>>();
1343    let mut restored = BTreeSet::new();
1344    for path in paths {
1345        let absolute = if path.is_absolute() {
1346            path.clone()
1347        } else {
1348            worktree_root.join(path)
1349        };
1350        let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
1351            GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
1352        })?;
1353        let git_path = git_path_bytes(relative)?;
1354        let recursive = path == Path::new(".")
1355            || path.to_string_lossy().ends_with('/')
1356            || absolute.is_dir()
1357            || index_entries
1358                .keys()
1359                .any(|entry| index_entry_is_under_path(entry, &git_path))
1360            || source_entries
1361                .keys()
1362                .any(|entry| index_entry_is_under_path(entry, &git_path));
1363        let mut matched_paths = BTreeSet::new();
1364        for path in index_entries.keys().chain(source_entries.keys()) {
1365            if path.as_slice() == git_path.as_slice()
1366                || (recursive && index_entry_is_under_path(path, &git_path))
1367            {
1368                matched_paths.insert(path.clone());
1369            }
1370        }
1371        if matched_paths.is_empty() {
1372            eprintln!(
1373                "error: pathspec '{}' did not match any file(s) known to git",
1374                path.display()
1375            );
1376            return Err(GitError::Exit(1));
1377        }
1378        for path in matched_paths {
1379            if let Some(entry) = source_entries.get(&path) {
1380                index_entries.insert(
1381                    path.clone(),
1382                    restore_head_entry_to_worktree_and_index(worktree_root, db, &path, entry)?,
1383                );
1384            } else if overlay {
1385                // Overlay mode (git checkout default): a path that matches the
1386                // pathspec but is absent from the source tree is left untouched
1387                // in both the index and the working tree.
1388                continue;
1389            } else {
1390                // No-overlay mode (git restore default, checkout --no-overlay):
1391                // drop the path from the index and the working tree.
1392                index_entries.remove(&path);
1393                remove_worktree_file(worktree_root, &path)?;
1394            }
1395            restored.insert(path);
1396        }
1397    }
1398    let mut entries = index_entries.into_values().collect::<Vec<_>>();
1399    entries.sort_by(|left, right| left.path.cmp(&right.path));
1400    let restored_paths = restored.iter().cloned().collect::<Vec<_>>();
1401    let mut index = Index {
1402        version: index_version,
1403        entries,
1404        extensions,
1405        checksum: None,
1406    };
1407    invalidate_untracked_cache_for_git_paths(&mut index, format, &restored_paths)?;
1408    write_repository_index_ref(git_dir, format, &index)?;
1409    Ok(RestoreResult {
1410        restored: restored.len(),
1411    })
1412}
1413
1414pub fn reset_index_and_worktree_to_commit(
1415    worktree_root: impl AsRef<Path>,
1416    git_dir: impl AsRef<Path>,
1417    format: ObjectFormat,
1418    commit_oid: &ObjectId,
1419) -> Result<RestoreResult> {
1420    let worktree_root = worktree_root.as_ref();
1421    let git_dir = git_dir.as_ref();
1422    let db = FileObjectDatabase::from_git_dir(git_dir, format);
1423    let commit = read_commit(&db, format, commit_oid)?;
1424    let mut target_entries = BTreeMap::new();
1425    collect_tree_entries(&db, format, &commit.tree, &mut target_entries)?;
1426    refuse_if_current_working_directory_becomes_file(worktree_root, &target_entries)?;
1427    let config = sley_config::read_repo_config(git_dir, None).unwrap_or_default();
1428    let attributes = build_tree_attribute_matcher(worktree_root, &db, format, &commit.tree)?;
1429
1430    // git's `reset --hard` runs a one-way merge through unpack-trees: EVERY path
1431    // present in the current index (at ANY stage) that the target tree does not
1432    // track is removed from the worktree. A conflicted D/F merge can leave a
1433    // path like `dir~HEAD` at stage 2 only — those entries are dropped by the
1434    // stage-0-only `read_index_entries`, so iterate the RAW index paths here
1435    // (deduped across stages) to match git and delete the moved-aside file.
1436    for path in current_index_paths(git_dir, format, &db)? {
1437        if !target_entries.contains_key(&path) {
1438            remove_worktree_file(worktree_root, &path)?;
1439        }
1440    }
1441
1442    let mut index_entries = Vec::new();
1443    for (path, entry) in &target_entries {
1444        index_entries.push(materialize_tree_entry_filtered(
1445            &db,
1446            format,
1447            worktree_root,
1448            path,
1449            entry,
1450            &config,
1451            &attributes,
1452        )?);
1453    }
1454    index_entries.sort_by(|left, right| left.path.cmp(&right.path));
1455    let extensions = preserved_index_extensions(git_dir, format)?;
1456    fs::write(
1457        repository_index_path(git_dir),
1458        Index {
1459            version: 2,
1460            entries: index_entries,
1461            extensions,
1462            checksum: None,
1463        }
1464        .write(format)?,
1465    )?;
1466    Ok(RestoreResult {
1467        restored: target_entries.len(),
1468    })
1469}
1470
1471/// All paths the current index references, deduped across stages (a conflicted
1472/// path appears at stages 1–3; we want it listed once). Unlike
1473/// `read_index_entries`, which filters to stage 0, this keeps conflicted paths
1474/// so a `reset --hard` worktree sweep removes moved-aside files (`dir~HEAD`) the
1475/// target tree doesn't track — matching git's one-way unpack-trees behavior.
1476pub(crate) fn current_index_paths(
1477    git_dir: &Path,
1478    format: ObjectFormat,
1479    db: &FileObjectDatabase,
1480) -> Result<BTreeSet<Vec<u8>>> {
1481    let (index, _stat_cache, _head_matches) = read_index_with_stat_cache(git_dir, format, db)?;
1482    Ok(index
1483        .entries
1484        .into_iter()
1485        .map(|entry| entry.path.into_bytes())
1486        .collect())
1487}
1488
1489/// Write one target tree entry into the worktree and return its index entry —
1490/// the shared materialization step for every checkout/reset worktree rebuild.
1491///
1492/// Gitlinks (mode 160000) never touch the object database: their oid names a
1493/// commit in the *submodule's* repository, not an object here. Upstream
1494/// (entry.c `write_entry` S_IFGITLINK) just mkdirs the path — an
1495/// already-populated submodule is left untouched (EEXIST is success) — and
1496/// records the oid in the index with a zeroed stat so status re-evaluates the
1497/// gitlink against the embedded repository's HEAD.
1498pub(crate) fn materialize_tree_entry(
1499    db: &FileObjectDatabase,
1500    worktree_root: &Path,
1501    path: &[u8],
1502    entry: &TrackedEntry,
1503) -> Result<IndexEntry> {
1504    if sley_index::is_gitlink(entry.mode) {
1505        let dir_path = worktree_path(worktree_root, path)?;
1506        materialize_gitlink_dir(worktree_root, &dir_path)?;
1507        return Ok(IndexEntry {
1508            ctime_seconds: 0,
1509            ctime_nanoseconds: 0,
1510            mtime_seconds: 0,
1511            mtime_nanoseconds: 0,
1512            dev: 0,
1513            ino: 0,
1514            mode: entry.mode,
1515            uid: 0,
1516            gid: 0,
1517            size: 0,
1518            oid: entry.oid,
1519            flags: path.len().min(0x0fff) as u16,
1520            flags_extended: 0,
1521            path: BString::from(path),
1522        });
1523    }
1524    let file_path = write_worktree_blob_entry(db, worktree_root, path, entry)?;
1525    let metadata = fs::symlink_metadata(&file_path)?;
1526    let mut index_entry = index_entry_from_metadata(path.to_vec(), entry.oid, &metadata);
1527    index_entry.mode = entry.mode;
1528    Ok(index_entry)
1529}
1530
1531pub(crate) fn materialize_gitlink_dir(worktree_root: &Path, dir_path: &Path) -> Result<()> {
1532    prepare_blob_parent_dirs(worktree_root, dir_path)?;
1533    if fs::symlink_metadata(dir_path).is_ok_and(|metadata| !metadata.is_dir()) {
1534        remove_existing_worktree_path(dir_path)?;
1535    }
1536    fs::create_dir_all(dir_path)?;
1537    Ok(())
1538}
1539
1540pub(crate) fn materialize_tree_entry_filtered(
1541    db: &FileObjectDatabase,
1542    format: ObjectFormat,
1543    worktree_root: &Path,
1544    path: &[u8],
1545    entry: &TrackedEntry,
1546    config: &GitConfig,
1547    attributes: &AttributeMatcher,
1548) -> Result<IndexEntry> {
1549    if sley_index::is_gitlink(entry.mode) || (entry.mode & 0o170000) == 0o120000 {
1550        return materialize_tree_entry(db, worktree_root, path, entry);
1551    }
1552    let object = read_expected_object(db, &entry.oid, ObjectType::Blob)?;
1553    let checks = attributes.attributes_for_path(path, &filter_attribute_names(), false);
1554    let body = apply_smudge_filter_with_attributes_cow_format(
1555        config,
1556        &checks,
1557        path,
1558        &object.body,
1559        format,
1560    )?;
1561    let file_path = worktree_path(worktree_root, path)?;
1562    prepare_blob_parent_dirs(worktree_root, &file_path)?;
1563    remove_existing_worktree_path(&file_path)?;
1564    fs::write(&file_path, &body)?;
1565    set_worktree_file_mode(&file_path, entry.mode)?;
1566    let metadata = fs::symlink_metadata(&file_path)?;
1567    let mut index_entry = index_entry_from_metadata(path.to_vec(), entry.oid, &metadata);
1568    index_entry.mode = entry.mode;
1569    Ok(index_entry)
1570}
1571
1572/// Materialize a blob (or symlink) tree entry into the worktree at `path`,
1573/// returning the absolute path written. Shared by every checkout/reset worktree
1574/// rebuild so the type-change handling is identical everywhere.
1575///
1576/// Mirrors git's entry.c `write_entry`: it unlinks whatever currently occupies
1577/// the path before creating the new object, so a type transition (regular file ⇄
1578/// symlink, or a stale symlink/directory in the way) is overwritten rather than
1579/// left in place or failing with EEXIST. A plain `fs::write` follows an existing
1580/// symlink and would write *through* it (leaving the link), so the unlink is
1581/// load-bearing for the symlink-stash / reset-hard type-change cases.
1582pub(crate) fn write_worktree_blob_entry(
1583    db: &FileObjectDatabase,
1584    worktree_root: &Path,
1585    path: &[u8],
1586    entry: &TrackedEntry,
1587) -> Result<PathBuf> {
1588    let object = read_expected_object(db, &entry.oid, ObjectType::Blob)?;
1589    let file_path = worktree_path(worktree_root, path)?;
1590    // Clear any non-directory blocking an ancestor component (prior tree had
1591    // `dir` as a FILE, target wants `dir/<child>`), creating the parent dirs.
1592    prepare_blob_parent_dirs(worktree_root, &file_path)?;
1593    // Clear whatever sits at the leaf — including a directory where the target
1594    // wants a plain file (reverse D/F) — before writing.
1595    remove_existing_worktree_path(&file_path)?;
1596    write_blob_body_or_symlink(&file_path, entry.mode, &object.body, &object.body)?;
1597    Ok(file_path)
1598}
1599
1600/// Write the materialized worktree object at `file_path` as the right *type* for
1601/// `mode` — git's `entry.c` `write_entry` type-by-mode switch, factored into a
1602/// single primitive so no checkout/reset/restore materializer can silently write
1603/// a symlink blob as a regular file (the symlink-checkout bug class).
1604///
1605/// The caller is responsible for the pre-write steps (leading directories +
1606/// removing any blocker at the leaf). Type by `mode`:
1607/// * `0o120000` (symlink) → a real symlink whose target is `link_target`, the
1608///   **raw** blob bytes. git treats symlink content as an opaque path, so the
1609///   smudge/EOL filter never applies — pass the unfiltered blob here even when
1610///   `body` is the smudged content for the regular-file arm.
1611/// * everything else → a regular file holding `body`, with the user-execute bit
1612///   set iff `mode` has it (`set_worktree_file_mode`).
1613///
1614/// Exposed crate-publicly so out-of-crate worktree materializers (e.g.
1615/// `sley-cli`'s `stash -u` untracked-tree restore) route through the same
1616/// type-by-mode primitive instead of re-deriving an `fs::write` that drops the
1617/// symlink arm.
1618pub fn write_blob_body_or_symlink(
1619    file_path: &Path,
1620    mode: u32,
1621    body: &[u8],
1622    link_target: &[u8],
1623) -> Result<()> {
1624    if (mode & 0o170000) == 0o120000 {
1625        #[cfg(unix)]
1626        {
1627            use std::os::unix::ffi::OsStringExt;
1628            let target =
1629                std::path::PathBuf::from(std::ffi::OsString::from_vec(link_target.to_vec()));
1630            std::os::unix::fs::symlink(&target, file_path)?;
1631        }
1632        #[cfg(not(unix))]
1633        {
1634            let _ = link_target;
1635            fs::write(file_path, body)?;
1636        }
1637    } else {
1638        fs::write(file_path, body)?;
1639        set_worktree_file_mode(file_path, mode)?;
1640    }
1641    Ok(())
1642}
1643
1644/// Create the ancestor directories of a worktree blob path, removing any
1645/// regular file or symlink that occupies an ancestor *component* first.
1646///
1647/// Mirrors git's `entry.c` `create_directories`: it walks each path component
1648/// between `worktree_root` and the leaf and, for each, if a non-directory (a
1649/// regular file or symlink left by a prior tree where `dir` was a FILE) blocks
1650/// it, unlinks the blocker before `mkdir`. A plain `fs::create_dir_all` fails
1651/// with `ENOTDIR`/`EEXIST` on such a D/F transition; this is the directory-side
1652/// of git's force-checkout D/F clearing.
1653///
1654/// `worktree_root` itself is never touched. Only components strictly between the
1655/// root and the leaf are cleared, matching `create_directories`' `base_dir_len`
1656/// boundary.
1657pub(crate) fn prepare_blob_parent_dirs(worktree_root: &Path, file_path: &Path) -> Result<()> {
1658    let parent = match file_path.parent() {
1659        Some(parent) => parent,
1660        None => return Ok(()),
1661    };
1662    // Fast path: parent already a directory (the overwhelmingly common case).
1663    if parent.is_dir() {
1664        return Ok(());
1665    }
1666    // Collect the ancestor chain from worktree_root (exclusive) down to `parent`
1667    // (inclusive). We can't `create_dir_all` blindly because a non-directory may
1668    // sit on one of these components; walk them and clear blockers as git does.
1669    let mut components: Vec<&Path> = Vec::new();
1670    let mut cursor = Some(parent);
1671    while let Some(dir) = cursor {
1672        if dir == worktree_root {
1673            break;
1674        }
1675        components.push(dir);
1676        cursor = dir.parent();
1677        if cursor.is_none() {
1678            break;
1679        }
1680    }
1681    // Walk root → leaf so each parent exists before its child.
1682    for dir in components.iter().rev() {
1683        match fs::symlink_metadata(dir) {
1684            Ok(metadata) if metadata.is_dir() => {}
1685            Ok(_) => {
1686                // A regular file or symlink occupies this component (the prior
1687                // tree had `dir` as a FILE). Unlink it, then create the dir.
1688                fs::remove_file(dir)?;
1689                fs::create_dir(dir)?;
1690            }
1691            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1692                fs::create_dir(dir)?;
1693            }
1694            Err(err) => return Err(err.into()),
1695        }
1696    }
1697    Ok(())
1698}
1699
1700/// Remove whatever currently occupies a worktree path before writing a new
1701/// object there — a symlink (even a dangling one, which `Path::exists` misses),
1702/// a regular file, or a directory subtree. Uses `symlink_metadata` (lstat) so a
1703/// symlink is removed as the link, never followed.
1704pub(crate) fn remove_existing_worktree_path(file_path: &Path) -> Result<()> {
1705    let metadata = match fs::symlink_metadata(file_path) {
1706        Ok(metadata) => metadata,
1707        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1708        Err(err) => return Err(err.into()),
1709    };
1710    if metadata.is_dir() {
1711        if path_is_original_cwd(file_path) {
1712            return refuse_remove_current_working_directory(file_path);
1713        }
1714        // A directory in the way of a file (D/F transition) or a populated
1715        // gitlink: remove the subtree so the file can be created.
1716        match fs::remove_dir_all(file_path) {
1717            Ok(()) => {}
1718            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1719            Err(err) => return Err(err.into()),
1720        }
1721    } else {
1722        fs::remove_file(file_path)?;
1723    }
1724    Ok(())
1725}
1726
1727/// chmod a freshly-materialized worktree blob to match its tree/index entry mode.
1728///
1729/// `fs::write` truncates an existing file *in place*, preserving its prior
1730/// permission bits. For a mode-only diff (identical oid, 100644 vs 100755) that
1731/// leaves the wrong exec bit on disk — which is exactly the `reset --hard` /
1732/// checkout bug this guards against. git's checkout path unlinks+recreates the
1733/// file precisely to "get the new one with the right permissions" (entry.c
1734/// `write_entry`); we instead chmod the just-written file.
1735///
1736/// Mirrors the observable result of git's `create_file` (entry.c):
1737/// `(mode & 0100) ? 0777 : 0666` masked by the standard umask (0022), i.e. 0755
1738/// for an executable entry and 0644 otherwise. Only regular-file entries (100644
1739/// / 100755) are chmod'd; gitlinks and symlinks have no meaningful exec bit.
1740///
1741/// We set the perms directly (rather than relying on a fresh `open(2)` to apply
1742/// the umask) because `fs::write` truncates an existing file in place, leaving its
1743/// old permission bits — the very thing that breaks a mode-only checkout/reset.
1744/// Matching git's default-umask output keeps the worktree byte-for-byte aligned
1745/// with the oracle, which is what the parity suite asserts.
1746#[cfg(unix)]
1747pub(crate) fn set_worktree_file_mode(file_path: &Path, entry_mode: u32) -> Result<()> {
1748    use std::os::unix::fs::PermissionsExt;
1749    let perms = match entry_mode {
1750        0o100755 => 0o755,
1751        0o100644 => 0o644,
1752        _ => return Ok(()),
1753    };
1754    fs::set_permissions(file_path, fs::Permissions::from_mode(perms))?;
1755    Ok(())
1756}
1757
1758#[cfg(not(unix))]
1759pub(crate) fn set_worktree_file_mode(_file_path: &Path, _entry_mode: u32) -> Result<()> {
1760    Ok(())
1761}
1762
1763/// Materialize a tree object into the index and worktree.
1764pub fn checkout_tree_to_index_and_worktree(
1765    worktree_root: impl AsRef<Path>,
1766    git_dir: impl AsRef<Path>,
1767    format: ObjectFormat,
1768    tree_oid: &ObjectId,
1769) -> Result<RestoreResult> {
1770    let worktree_root = worktree_root.as_ref();
1771    let git_dir = git_dir.as_ref();
1772    let db = FileObjectDatabase::from_git_dir(git_dir, format);
1773    let mut target_entries = BTreeMap::new();
1774    collect_tree_entries(&db, format, tree_oid, &mut target_entries)?;
1775
1776    for path in read_index_entries(git_dir, format)?.keys() {
1777        if !target_entries.contains_key(path) {
1778            remove_worktree_file(worktree_root, path)?;
1779        }
1780    }
1781
1782    let mut index_entries = Vec::new();
1783    for (path, entry) in &target_entries {
1784        index_entries.push(materialize_tree_entry(&db, worktree_root, path, entry)?);
1785    }
1786    index_entries.sort_by(|left, right| left.path.cmp(&right.path));
1787    let extensions = preserved_index_extensions(git_dir, format)?;
1788    fs::write(
1789        repository_index_path(git_dir),
1790        Index {
1791            version: 2,
1792            entries: index_entries,
1793            extensions,
1794            checksum: None,
1795        }
1796        .write(format)?,
1797    )?;
1798    Ok(RestoreResult {
1799        restored: target_entries.len(),
1800    })
1801}
1802
1803pub fn reset_index_to_commit(
1804    worktree_root: impl AsRef<Path>,
1805    git_dir: impl AsRef<Path>,
1806    format: ObjectFormat,
1807    commit_oid: &ObjectId,
1808) -> Result<RestoreResult> {
1809    let worktree_root = worktree_root.as_ref();
1810    let git_dir = git_dir.as_ref();
1811    let db = FileObjectDatabase::from_git_dir(git_dir, format);
1812    let commit = read_commit(&db, format, commit_oid)?;
1813    let mut target_entries = BTreeMap::new();
1814    collect_tree_entries(&db, format, &commit.tree, &mut target_entries)?;
1815    // git's `reset --mixed` preserves the skip-worktree bit on entries that survive
1816    // the reset (t7102 "--mixed preserves skip-worktree"): carry it forward from the
1817    // pre-reset index keyed by path, so reconstructed entries keep CE_SKIP_WORKTREE.
1818    let index_path = repository_index_path(git_dir);
1819    let prior_skip_worktree: BTreeSet<Vec<u8>> = match fs::read(&index_path) {
1820        Ok(bytes) => Index::parse(&bytes, format)?
1821            .entries
1822            .iter()
1823            .filter(|entry| entry.is_skip_worktree())
1824            .map(|entry| entry.path.as_bytes().to_vec())
1825            .collect(),
1826        Err(err) if err.kind() == std::io::ErrorKind::NotFound => BTreeSet::new(),
1827        Err(err) => return Err(err.into()),
1828    };
1829    let mut index_entries = Vec::new();
1830    for (path, entry) in &target_entries {
1831        let mut restored = restored_head_index_entry(worktree_root, &db, path, entry)?;
1832        if prior_skip_worktree.contains(path) {
1833            restored.set_skip_worktree(true);
1834        }
1835        index_entries.push(restored);
1836    }
1837    index_entries.sort_by(|left, right| left.path.cmp(&right.path));
1838    let mut index = Index {
1839        version: 2,
1840        entries: index_entries,
1841        extensions: preserved_index_extensions(git_dir, format)?,
1842        checksum: None,
1843    };
1844    index.upgrade_version_for_flags();
1845    write_repository_index_ref(git_dir, format, &index)?;
1846    Ok(RestoreResult {
1847        restored: target_entries.len(),
1848    })
1849}
1850
1851/// Build a fresh in-memory index that mirrors the tree `tree_oid`, the way
1852/// `git read-tree <tree>` does: every blob, symlink, and gitlink leaf (found by
1853/// recursing subtrees) becomes a stage-0 entry carrying the tree mode and oid,
1854/// with a fully zeroed stat (so nothing is treated as stat-clean) and size 0.
1855/// Entries are sorted by path; the index is version 2 with no extensions.
1856///
1857/// This does not touch the worktree or write anything to disk — serialize the
1858/// result with [`Index::write`] (and persist it) when you want to replace
1859/// `.git/index`.
1860pub fn index_from_tree(
1861    db: &FileObjectDatabase,
1862    format: ObjectFormat,
1863    tree_oid: &ObjectId,
1864) -> Result<Index> {
1865    let mut entries: Vec<IndexEntry> = Vec::new();
1866    if *tree_oid != ObjectId::empty_tree(format) {
1867        let mut tree_entries = BTreeMap::new();
1868        collect_tree_entries(db, format, tree_oid, &mut tree_entries)?;
1869        entries.reserve(tree_entries.len());
1870        for (path, entry) in tree_entries {
1871            let name_len = (path.len().min(0x0fff)) as u16;
1872            entries.push(IndexEntry {
1873                ctime_seconds: 0,
1874                ctime_nanoseconds: 0,
1875                mtime_seconds: 0,
1876                mtime_nanoseconds: 0,
1877                dev: 0,
1878                ino: 0,
1879                mode: entry.mode,
1880                uid: 0,
1881                gid: 0,
1882                size: 0,
1883                oid: entry.oid,
1884                flags: name_len,
1885                flags_extended: 0,
1886                path: path.into(),
1887            });
1888        }
1889    }
1890    // git orders index entries by path bytes; BTreeMap already yields that, but
1891    // sort explicitly so the contract holds regardless of how entries arrive.
1892    entries.sort_by(|left, right| left.path.cmp(&right.path));
1893    Ok(Index {
1894        version: 2,
1895        entries,
1896        extensions: Vec::new(),
1897        checksum: None,
1898    })
1899}
1900
1901/// Enforces a [`SparseCheckout`] against the current index and worktree.
1902///
1903/// Every stage-0 index entry is classified with the sparse patterns (see
1904/// [`SparseCheckoutMode`] for the matching semantics):
1905///
1906/// * **In cone**: the skip-worktree bit is cleared and, if the worktree file is
1907///   missing, it is re-materialized from the entry's blob in the object
1908///   database. Existing worktree files are left untouched so local content is
1909///   preserved.
1910/// * **Out of cone**: the skip-worktree bit is set and any existing worktree
1911///   file is removed (empty parent directories are pruned).
1912///
1913/// Returns `true` when `path` is inside the sparse-checkout described by
1914/// `sparse` under the given matching `mode`. This is the engine behind
1915/// `git sparse-checkout check-rules`: a path is "in" the sparse-checkout when
1916/// the compiled matcher would keep its worktree file. Cone and full (gitignore)
1917/// grammars are both handled, exactly as the apply engine interprets them, so
1918/// `check-rules` and `set`/`reapply` agree by construction.
1919pub fn path_in_sparse_checkout(
1920    path: &[u8],
1921    sparse: &SparseCheckout,
1922    mode: SparseCheckoutMode,
1923) -> bool {
1924    SparseMatcher::new(sparse, mode).includes_file(path)
1925}
1926
1927pub(crate) fn active_sparse_checkout(
1928    git_dir: &Path,
1929) -> Result<Option<(SparseCheckout, SparseCheckoutMode)>> {
1930    let worktree_config = GitConfig::read(git_dir.join("config.worktree")).unwrap_or_default();
1931    let repo_config = GitConfig::read(git_dir.join("config")).unwrap_or_default();
1932    let sparse_enabled = worktree_config
1933        .get_bool("core", None, "sparseCheckout")
1934        .or_else(|| repo_config.get_bool("core", None, "sparseCheckout"))
1935        .unwrap_or(false);
1936    if !sparse_enabled {
1937        return Ok(None);
1938    }
1939    let sparse_file = git_dir.join("info").join("sparse-checkout");
1940    if !sparse_file.exists() {
1941        return Ok(None);
1942    }
1943    let cone = worktree_config
1944        .get_bool("core", None, "sparseCheckoutCone")
1945        .or_else(|| repo_config.get_bool("core", None, "sparseCheckoutCone"))
1946        .unwrap_or(false);
1947    let sparse_index = cone
1948        && worktree_config
1949            .get_bool("index", None, "sparse")
1950            .or_else(|| repo_config.get_bool("index", None, "sparse"))
1951            .unwrap_or(false);
1952    let bytes = fs::read(sparse_file)?;
1953    let mut patterns = bytes
1954        .split(|byte| *byte == b'\n')
1955        .map(<[u8]>::to_vec)
1956        .collect::<Vec<_>>();
1957    if patterns.last().map(Vec::is_empty) == Some(true) {
1958        patterns.pop();
1959    }
1960    let mode = if cone {
1961        SparseCheckoutMode::Cone
1962    } else {
1963        SparseCheckoutMode::Full
1964    };
1965    Ok(Some((
1966        SparseCheckout {
1967            patterns,
1968            sparse_index,
1969        },
1970        mode,
1971    )))
1972}
1973
1974/// Conflicted entries (stage != 0) are never given the skip-worktree bit and
1975/// are left alone, matching upstream Git. The index is rewritten in place.
1976pub fn apply_sparse_checkout(
1977    worktree_root: impl AsRef<Path>,
1978    git_dir: impl AsRef<Path>,
1979    format: ObjectFormat,
1980    sparse: &SparseCheckout,
1981) -> Result<ApplySparseResult> {
1982    apply_sparse_checkout_with_mode(
1983        worktree_root,
1984        git_dir,
1985        format,
1986        sparse,
1987        SparseCheckoutMode::Auto,
1988    )
1989}
1990
1991/// Like [`apply_sparse_checkout`] but lets the caller force the pattern
1992/// interpretation instead of auto-detecting it.
1993pub fn apply_sparse_checkout_with_mode(
1994    worktree_root: impl AsRef<Path>,
1995    git_dir: impl AsRef<Path>,
1996    format: ObjectFormat,
1997    sparse: &SparseCheckout,
1998    mode: SparseCheckoutMode,
1999) -> Result<ApplySparseResult> {
2000    let worktree_root = worktree_root.as_ref();
2001    let git_dir = git_dir.as_ref();
2002    let index_path = repository_index_path(git_dir);
2003    let mut index = if index_path.exists() {
2004        Index::parse(&fs::read(&index_path)?, format)?
2005    } else {
2006        return Ok(ApplySparseResult {
2007            materialized: Vec::new(),
2008            skipped: Vec::new(),
2009            not_up_to_date: Vec::new(),
2010        });
2011    };
2012    let matcher = SparseMatcher::new(sparse, mode);
2013    let db = FileObjectDatabase::from_git_dir(git_dir, format);
2014    // Expand any collapsed sparse-directory entries to a full index before we
2015    // reconcile per-path: the apply loop reasons about individual blob paths, so
2016    // it must never see a sparse-dir entry. (Re-collapse happens at the end when
2017    // a sparse index is requested.)
2018    if index.entries.iter().any(IndexEntry::is_sparse_dir) {
2019        expand_sparse_index(&mut index, &db, format)?;
2020    }
2021    let mut materialized = Vec::new();
2022    let mut skipped = Vec::new();
2023    let mut not_up_to_date = Vec::new();
2024    for entry in &mut index.entries {
2025        // Never touch conflicted entries.
2026        if index_entry_stage(entry) != 0 {
2027            continue;
2028        }
2029        if matcher.includes_file(entry.path.as_bytes()) {
2030            clear_skip_worktree(entry);
2031            let file_path = worktree_path(worktree_root, entry.path.as_bytes())?;
2032            if !file_path.exists() {
2033                materialize_index_entry_file(&db, worktree_root, &file_path, entry)?;
2034                let metadata = fs::symlink_metadata(&file_path)?;
2035                *entry = index_entry_with_refreshed_stat(entry, &metadata);
2036            }
2037            materialized.push(entry.path.as_bytes().to_vec());
2038        } else {
2039            // The path is out of cone, so its worktree file should be removed and
2040            // the entry marked skip-worktree. But git refuses to delete a file
2041            // that is *not up to date* with the index (e.g. one that reappeared in
2042            // the worktree after the path was already sparse): it leaves the file,
2043            // leaves the skip-worktree bit clear, and reports the path in its "not
2044            // up to date" warning. Mirror that to avoid silent data loss.
2045            let file_path = worktree_path(worktree_root, entry.path.as_bytes())?;
2046            match fs::symlink_metadata(&file_path) {
2047                Ok(metadata) if !worktree_entry_is_uptodate(entry, &metadata) => {
2048                    clear_skip_worktree(entry);
2049                    not_up_to_date.push(entry.path.as_bytes().to_vec());
2050                }
2051                _ => {
2052                    set_skip_worktree(entry);
2053                    remove_worktree_file(worktree_root, entry.path.as_bytes())?;
2054                    skipped.push(entry.path.as_bytes().to_vec());
2055                }
2056            }
2057        }
2058    }
2059    not_up_to_date.sort();
2060    normalize_index_version_for_extended_flags(&mut index);
2061    // When a sparse index was requested (cone mode + index.sparse), collapse the
2062    // fully-out-of-cone directories into single sparse-directory entries and
2063    // mark the index with the `sdir` extension. Otherwise ensure the index is
2064    // written full (and any prior `sdir` marker is cleared).
2065    if sparse.sparse_index {
2066        collapse_to_sparse_index(&mut index, &matcher, &db, format)?;
2067    } else {
2068        index.clear_sparse_extension()?;
2069    }
2070    write_repository_index_ref(git_dir, format, &index)?;
2071    Ok(ApplySparseResult {
2072        materialized,
2073        skipped,
2074        not_up_to_date,
2075    })
2076}
2077
2078/// Expands every sparse-directory entry in `index` back into the full set of
2079/// blob (and nested-directory) entries it collapses, reading each directory's
2080/// tree from `db`. After this the index contains no sparse-directory entries and
2081/// carries no `sdir` marker — it is a full index that any per-path command can
2082/// operate on without sparse-index awareness.
2083///
2084/// This is the **close-the-class** primitive: a command never needs to special-
2085/// case a sparse index, because the moment it loads the index it expands to the
2086/// full form. The collapsed shape is purely an on-disk storage optimization.
2087pub fn expand_sparse_index(
2088    index: &mut Index,
2089    db: &FileObjectDatabase,
2090    format: ObjectFormat,
2091) -> Result<bool> {
2092    if !index.entries.iter().any(IndexEntry::is_sparse_dir) {
2093        // Still strip a stray `sdir` marker so the written index is recorded full.
2094        let had_marker = index.is_sparse();
2095        index.clear_sparse_extension()?;
2096        if had_marker {
2097            sley_core::trace2::region("index", "ensure_full_index");
2098        }
2099        return Ok(had_marker);
2100    }
2101    let mut expanded: Vec<IndexEntry> = Vec::with_capacity(index.entries.len());
2102    for entry in std::mem::take(&mut index.entries) {
2103        if !entry.is_sparse_dir() {
2104            expanded.push(entry);
2105            continue;
2106        }
2107        // The sparse-dir path ends in `/`; its OID is the directory's tree.
2108        let dir = entry.path.as_bytes();
2109        let dir_prefix = dir; // includes the trailing slash
2110        for (rel, (mode, oid)) in sley_diff_merge::flatten_tree(db, format, &entry.oid)? {
2111            let mut full_path = dir_prefix.to_vec();
2112            full_path.extend_from_slice(&rel);
2113            let mut blob = blank_sparse_blob_entry(format, &full_path, mode, oid);
2114            // Re-collapsed entries are skip-worktree (they live outside the cone).
2115            blob.set_skip_worktree(true);
2116            expanded.push(blob);
2117        }
2118    }
2119    expanded.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
2120    index.entries = expanded;
2121    index.clear_sparse_extension()?;
2122    normalize_index_version_for_extended_flags(index);
2123    sley_core::trace2::region("index", "ensure_full_index");
2124    Ok(true)
2125}
2126
2127pub(crate) fn index_sparse_dir_contains_path(index: &Index, git_path: &[u8]) -> bool {
2128    index.entries.iter().any(|entry| {
2129        entry.is_sparse_dir()
2130            && git_path.starts_with(entry.path.as_bytes())
2131            && git_path.len() > entry.path.len()
2132    })
2133}
2134
2135/// Builds a minimal index entry for an expanded sparse blob: zeroed stat fields
2136/// (the file is not in the worktree), the given mode/oid, and a fresh name
2137/// length. Stat fields are zero because a skip-worktree file has no on-disk
2138/// presence to record.
2139pub(crate) fn blank_sparse_blob_entry(
2140    format: ObjectFormat,
2141    path: &[u8],
2142    mode: u32,
2143    oid: ObjectId,
2144) -> IndexEntry {
2145    let _ = format;
2146    let mut entry = IndexEntry {
2147        ctime_seconds: 0,
2148        ctime_nanoseconds: 0,
2149        mtime_seconds: 0,
2150        mtime_nanoseconds: 0,
2151        dev: 0,
2152        ino: 0,
2153        mode,
2154        uid: 0,
2155        gid: 0,
2156        size: 0,
2157        oid,
2158        flags: 0,
2159        flags_extended: 0,
2160        path: path.into(),
2161    };
2162    entry.refresh_name_length();
2163    entry
2164}
2165
2166/// Collapses fully-out-of-cone directories in `index` into single sparse-
2167/// directory entries (mode `040000`, skip-worktree, the directory tree's OID),
2168/// then marks the index with the `sdir` extension. A directory is collapsible
2169/// when *every* entry under it is skip-worktree and stage 0 — i.e. nothing in it
2170/// is in the cone or conflicted. The shallowest such directory subsumes deeper
2171/// ones, matching git's `convert_to_sparse` cache-tree walk.
2172pub(crate) fn collapse_to_sparse_index(
2173    index: &mut Index,
2174    matcher: &SparseMatcher,
2175    db: &FileObjectDatabase,
2176    format: ObjectFormat,
2177) -> Result<()> {
2178    // First expand any pre-existing sparse-dir entries so the collapse decision
2179    // sees a uniform full index (idempotent re-collapse).
2180    if index.entries.iter().any(IndexEntry::is_sparse_dir) {
2181        expand_sparse_index(index, db, format)?;
2182    }
2183
2184    // Any unmerged (stage != 0) entry forbids a sparse index entirely (the cache
2185    // tree cannot be built), so stay full — matching git's bail.
2186    if index.entries.iter().any(|e| index_entry_stage(e) != 0) {
2187        index.clear_sparse_extension()?;
2188        return Ok(());
2189    }
2190
2191    index
2192        .entries
2193        .sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
2194
2195    // Determine, for every directory prefix, whether it contains any in-cone
2196    // path. A directory with no in-cone descendant is collapsible.
2197    use std::collections::BTreeMap;
2198    let mut dir_has_in_cone: BTreeMap<Vec<u8>, bool> = BTreeMap::new();
2199    for entry in &index.entries {
2200        let path = entry.path.as_bytes();
2201        let in_cone = matcher.includes_file(path);
2202        let mut start = 0usize;
2203        while let Some(rel) = path
2204            .get(start..)
2205            .and_then(|s| s.iter().position(|b| *b == b'/'))
2206        {
2207            let end = start + rel;
2208            let dir = path[..end].to_vec();
2209            let flag = dir_has_in_cone.entry(dir).or_insert(false);
2210            *flag = *flag || in_cone;
2211            start = end + 1;
2212        }
2213    }
2214
2215    // The collapsible directories are those with no in-cone descendant; keep only
2216    // the shallowest (a directory whose ancestor is also collapsible is subsumed).
2217    let collapsible: Vec<Vec<u8>> = {
2218        let all: Vec<Vec<u8>> = dir_has_in_cone
2219            .iter()
2220            .filter(|(_, has)| !**has)
2221            .map(|(dir, _)| dir.clone())
2222            .collect();
2223        all.iter()
2224            .filter(|dir| {
2225                !all.iter().any(|other| {
2226                    other != *dir
2227                        && dir
2228                            .strip_prefix(other.as_slice())
2229                            .is_some_and(|rest| rest.first() == Some(&b'/'))
2230                })
2231            })
2232            .cloned()
2233            .collect()
2234    };
2235    if collapsible.is_empty() {
2236        index.clear_sparse_extension()?;
2237        return Ok(());
2238    }
2239
2240    let mut checker = db.presence_checker();
2241    let mut new_entries: Vec<IndexEntry> = Vec::with_capacity(index.entries.len());
2242    let mut consumed: std::collections::HashSet<Vec<u8>> = std::collections::HashSet::new();
2243    for dir in &collapsible {
2244        // Gather the entries that live strictly under this directory.
2245        let mut subtree: Vec<&IndexEntry> = index
2246            .entries
2247            .iter()
2248            .filter(|e| {
2249                e.path
2250                    .as_bytes()
2251                    .strip_prefix(dir.as_slice())
2252                    .is_some_and(|rest| rest.first() == Some(&b'/'))
2253            })
2254            .collect();
2255        if subtree.is_empty() {
2256            continue;
2257        }
2258        subtree.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
2259        // Build the subtree object and capture its OID.
2260        let mut prefix = dir.clone();
2261        prefix.push(b'/');
2262        let tree_entries: Vec<WriteTreeEntry<'_>> = subtree
2263            .iter()
2264            .map(|e| WriteTreeEntry {
2265                path: e.path.as_bytes(),
2266                mode: e.mode,
2267                oid: e.oid.clone(),
2268            })
2269            .collect();
2270        let tree_oid =
2271            write_tree_entries_stream(&tree_entries, &prefix, None, db, &mut checker, false)?;
2272        // Mark every consumed path so the second pass drops them.
2273        for e in &subtree {
2274            consumed.insert(e.path.as_bytes().to_vec());
2275        }
2276        // The sparse-dir entry's name is the directory path WITH a trailing slash.
2277        let mut sparse_path = dir.clone();
2278        sparse_path.push(b'/');
2279        let mut sparse_entry =
2280            blank_sparse_blob_entry(format, &sparse_path, SPARSE_DIR_MODE, tree_oid);
2281        sparse_entry.set_skip_worktree(true);
2282        new_entries.push(sparse_entry);
2283    }
2284    // Carry forward every entry that was not collapsed.
2285    for entry in &index.entries {
2286        if consumed.contains(entry.path.as_bytes()) {
2287            continue;
2288        }
2289        new_entries.push(entry.clone());
2290    }
2291    new_entries.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
2292    index.entries = new_entries;
2293    index.set_sparse_extension();
2294    normalize_index_version_for_extended_flags(index);
2295    sley_core::trace2::region("index", "convert_to_sparse");
2296    Ok(())
2297}
2298
2299/// Whether the worktree file described by `metadata` is up to date with `entry`'s
2300/// cached index stat, using the size + mtime heuristic at the core of git's
2301/// `ie_match_stat`. A freshly-checked-out (clean) file matches; a file that was
2302/// deleted and later recreated — as happens when an out-of-cone path reappears in
2303/// the worktree — gets a fresh mtime and so reads as modified, which is exactly
2304/// the state git declines to overwrite during a sparse update.
2305pub(crate) fn worktree_entry_is_uptodate(entry: &IndexEntry, metadata: &fs::Metadata) -> bool {
2306    if u64::from(entry.size) != metadata.len() {
2307        return false;
2308    }
2309    let Some((mtime_seconds, mtime_nanoseconds)) = file_mtime_parts(metadata) else {
2310        // Without a usable mtime we cannot prove the file is clean; treat it as
2311        // not up to date so a present file is never silently discarded.
2312        return false;
2313    };
2314    u64::from(entry.mtime_seconds) == mtime_seconds
2315        && u64::from(entry.mtime_nanoseconds) == mtime_nanoseconds
2316}
2317
2318pub(crate) fn worktree_entry_ref_is_uptodate(
2319    entry: &IndexEntryRef<'_>,
2320    metadata: &fs::Metadata,
2321) -> bool {
2322    if u64::from(entry.size) != metadata.len() {
2323        return false;
2324    }
2325    let Some((mtime_seconds, mtime_nanoseconds)) = file_mtime_parts(metadata) else {
2326        return false;
2327    };
2328    u64::from(entry.mtime_seconds) == mtime_seconds
2329        && u64::from(entry.mtime_nanoseconds) == mtime_nanoseconds
2330}
2331
2332/// The file's modification time split into whole seconds and the sub-second
2333/// nanosecond remainder, matching how git stores `mtime` in the index.
2334pub(crate) fn file_mtime_parts(metadata: &fs::Metadata) -> Option<(u64, u64)> {
2335    let modified = metadata.modified().ok()?;
2336    let duration = modified.duration_since(UNIX_EPOCH).ok()?;
2337    Some((duration.as_secs(), u64::from(duration.subsec_nanos())))
2338}
2339
2340/// Write a git metadata file through a sibling `.lock` file and atomic rename.
2341///
2342/// This helper is intended for small repository/worktree metadata files such as
2343/// `HEAD`, `config.worktree`, or state files under `.git/`. It deliberately does
2344/// not try to replace object or pack writers, which have their own durability
2345/// and naming rules.
2346pub fn write_metadata_file_atomic(
2347    path: impl AsRef<Path>,
2348    bytes: &[u8],
2349    options: AtomicMetadataWriteOptions,
2350) -> Result<AtomicMetadataWriteResult> {
2351    let path = path.as_ref();
2352    let parent = path.parent().ok_or_else(|| {
2353        GitError::InvalidPath(format!("metadata path has no parent: {}", path.display()))
2354    })?;
2355    if !parent.as_os_str().is_empty() {
2356        fs::create_dir_all(parent)?;
2357    }
2358    let lock_path = metadata_lock_path(path)?;
2359    let mut lock = match fs::OpenOptions::new()
2360        .write(true)
2361        .create_new(true)
2362        .open(&lock_path)
2363    {
2364        Ok(lock) => lock,
2365        Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
2366            return Err(GitError::Transaction(format!(
2367                "metadata lock already exists: {}",
2368                lock_path.display()
2369            )));
2370        }
2371        Err(err) => return Err(err.into()),
2372    };
2373    if let Err(err) = lock.write_all(bytes) {
2374        let _ = fs::remove_file(&lock_path);
2375        return Err(err.into());
2376    }
2377    if options.fsync_file
2378        && let Err(err) = lock.sync_all()
2379    {
2380        let _ = fs::remove_file(&lock_path);
2381        return Err(err.into());
2382    }
2383    drop(lock);
2384    if let Err(err) = fs::rename(&lock_path, path) {
2385        let _ = fs::remove_file(&lock_path);
2386        return Err(err.into());
2387    }
2388    if options.fsync_dir
2389        && let Ok(dir) = fs::File::open(parent)
2390    {
2391        dir.sync_all()?;
2392    }
2393    let metadata = fs::metadata(path)?;
2394    Ok(AtomicMetadataWriteResult {
2395        path: path.to_path_buf(),
2396        len: metadata.len(),
2397        mtime: file_mtime_parts(&metadata),
2398    })
2399}
2400
2401pub(crate) fn metadata_lock_path(path: &Path) -> Result<PathBuf> {
2402    let file_name = path.file_name().ok_or_else(|| {
2403        GitError::InvalidPath(format!("metadata path has no filename: {}", path.display()))
2404    })?;
2405    let mut lock_name = file_name.to_os_string();
2406    lock_name.push(".lock");
2407    Ok(path.with_file_name(lock_name))
2408}
2409
2410/// Checks out `target` like [`checkout_detached`], but materializes the
2411/// worktree through the supplied [`SparseCheckout`]: out-of-cone paths are not
2412/// written, get their skip-worktree bit set, and have any stale worktree file
2413/// removed. Existing public checkout entry points are unchanged; this is an
2414/// additive sparse-aware variant.
2415///
2416/// The pattern interpretation is auto-detected ([`SparseCheckoutMode::Auto`]);
2417/// to reconcile an existing checkout under an explicit mode use
2418/// [`apply_sparse_checkout_with_mode`].
2419pub fn checkout_detached_sparse(
2420    worktree_root: impl AsRef<Path>,
2421    git_dir: impl AsRef<Path>,
2422    format: ObjectFormat,
2423    target: &ObjectId,
2424    committer: Vec<u8>,
2425    message: Vec<u8>,
2426    sparse: &SparseCheckout,
2427) -> Result<CheckoutResult> {
2428    let worktree_root = worktree_root.as_ref();
2429    let git_dir = git_dir.as_ref();
2430    let files = checkout_commit_to_index_and_worktree_sparse(
2431        worktree_root,
2432        git_dir,
2433        format,
2434        target,
2435        Some((sparse, SparseCheckoutMode::Auto)),
2436        None,
2437        None,
2438    )?;
2439    let refs = FileRefStore::new(git_dir, format);
2440    let zero = ObjectId::null(format);
2441    let mut tx = refs.transaction();
2442    tx.update(RefUpdate {
2443        name: "HEAD".into(),
2444        expected: None,
2445        new: RefTarget::Direct(*target),
2446        reflog: Some(ReflogEntry {
2447            old_oid: zero,
2448            new_oid: *target,
2449            committer,
2450            message,
2451        }),
2452    });
2453    tx.commit()?;
2454    Ok(CheckoutResult {
2455        branch: target.to_string(),
2456        oid: *target,
2457        files,
2458    })
2459}
2460
2461pub(crate) fn materialize_index_entry_file(
2462    db: &FileObjectDatabase,
2463    worktree_root: &Path,
2464    file_path: &Path,
2465    entry: &IndexEntry,
2466) -> Result<()> {
2467    // A gitlink (mode 160000) has no blob in this object store and materializes
2468    // as a directory (git's `write_entry` S_IFGITLINK arm: mkdir, never read an
2469    // object). Single gitlink rule via `sley_index::is_gitlink`; without it a
2470    // sparse re-materialization of a submodule path would fail with "not found:
2471    // blob object <commit-oid>".
2472    if sley_index::is_gitlink(entry.mode) {
2473        materialize_gitlink_dir(worktree_root, file_path)?;
2474        return Ok(());
2475    }
2476    let object = read_expected_object(db, &entry.oid, ObjectType::Blob)?;
2477    prepare_blob_parent_dirs(worktree_root, file_path)?;
2478    remove_existing_worktree_path(file_path)?;
2479    write_blob_body_or_symlink(file_path, entry.mode, &object.body, &object.body)?;
2480    Ok(())
2481}
2482
2483pub(crate) fn set_skip_worktree(entry: &mut IndexEntry) {
2484    entry.flags |= INDEX_FLAG_EXTENDED;
2485    entry.flags_extended |= INDEX_EXTENDED_FLAG_SKIP_WORKTREE;
2486}
2487
2488pub(crate) fn clear_skip_worktree(entry: &mut IndexEntry) {
2489    entry.flags_extended &= !INDEX_EXTENDED_FLAG_SKIP_WORKTREE;
2490    if entry.flags_extended == 0 {
2491        entry.flags &= !INDEX_FLAG_EXTENDED;
2492    }
2493}
2494
2495pub fn restore_worktree_paths_from_head(
2496    worktree_root: impl AsRef<Path>,
2497    git_dir: impl AsRef<Path>,
2498    format: ObjectFormat,
2499    paths: &[PathBuf],
2500) -> Result<RestoreResult> {
2501    let worktree_root = worktree_root.as_ref();
2502    let git_dir = git_dir.as_ref();
2503    let index_path = repository_index_path(git_dir);
2504    let index = if index_path.exists() {
2505        Index::parse(&fs::read(&index_path)?, format)?
2506    } else {
2507        Index {
2508            version: 2,
2509            entries: Vec::new(),
2510            extensions: Vec::new(),
2511            checksum: None,
2512        }
2513    };
2514    let db = FileObjectDatabase::from_git_dir(git_dir, format);
2515    let head_entries = head_tree_entries(git_dir, format, &db)?;
2516    restore_worktree_paths_from_entries(worktree_root, &db, index, &head_entries, paths)
2517}
2518
2519pub fn restore_worktree_paths_from_tree(
2520    worktree_root: impl AsRef<Path>,
2521    git_dir: impl AsRef<Path>,
2522    format: ObjectFormat,
2523    tree_oid: &ObjectId,
2524    paths: &[PathBuf],
2525) -> Result<RestoreResult> {
2526    let worktree_root = worktree_root.as_ref();
2527    let git_dir = git_dir.as_ref();
2528    let index_path = repository_index_path(git_dir);
2529    let index = if index_path.exists() {
2530        Index::parse(&fs::read(&index_path)?, format)?
2531    } else {
2532        Index {
2533            version: 2,
2534            entries: Vec::new(),
2535            extensions: Vec::new(),
2536            checksum: None,
2537        }
2538    };
2539    let db = FileObjectDatabase::from_git_dir(git_dir, format);
2540    let source_entries = tree_entries(&db, format, tree_oid)?;
2541    restore_worktree_paths_from_entries(worktree_root, &db, index, &source_entries, paths)
2542}
2543
2544pub(crate) fn restore_worktree_paths_from_entries(
2545    worktree_root: &Path,
2546    db: &FileObjectDatabase,
2547    index: Index,
2548    source_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
2549    paths: &[PathBuf],
2550) -> Result<RestoreResult> {
2551    let index_entries = index
2552        .entries
2553        .into_iter()
2554        .map(|entry| entry.path.into_bytes())
2555        .collect::<BTreeSet<_>>();
2556    let mut restored = BTreeSet::new();
2557    for path in paths {
2558        let absolute = if path.is_absolute() {
2559            path.clone()
2560        } else {
2561            worktree_root.join(path)
2562        };
2563        let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
2564            GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
2565        })?;
2566        let git_path = git_path_bytes(relative)?;
2567        let recursive = path == Path::new(".")
2568            || path.to_string_lossy().ends_with('/')
2569            || absolute.is_dir()
2570            || index_entries
2571                .iter()
2572                .any(|entry| index_entry_is_under_path(entry, &git_path))
2573            || source_entries
2574                .keys()
2575                .any(|entry| index_entry_is_under_path(entry, &git_path));
2576        let mut matched_paths = BTreeSet::new();
2577        for path in index_entries.iter().chain(source_entries.keys()) {
2578            if path.as_slice() == git_path.as_slice()
2579                || (recursive && index_entry_is_under_path(path, &git_path))
2580            {
2581                matched_paths.insert(path.clone());
2582            }
2583        }
2584        if matched_paths.is_empty() {
2585            eprintln!(
2586                "error: pathspec '{}' did not match any file(s) known to git",
2587                path.display()
2588            );
2589            return Err(GitError::Exit(1));
2590        }
2591        for path in matched_paths {
2592            if let Some(entry) = source_entries.get(&path) {
2593                restore_head_entry_to_worktree(worktree_root, db, &path, entry)?;
2594            } else {
2595                remove_worktree_file(worktree_root, &path)?;
2596            }
2597            restored.insert(path);
2598        }
2599    }
2600    Ok(RestoreResult {
2601        restored: restored.len(),
2602    })
2603}