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