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