Skip to main content

sley_worktree/
index_io.rs

1//! Index<->worktree scanning internals: stat-cache reads, head-vs-index comparison, worktree-entry collection, and path/mode helpers.
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::checkout::*;
8use crate::filter::*;
9use crate::ignore::*;
10use crate::index::*;
11use crate::types_admin::*;
12
13pub(crate) fn restore_index_entry(
14    worktree_root: &Path,
15    git_dir: &Path,
16    format: ObjectFormat,
17    db: &FileObjectDatabase,
18    entry: &IndexEntry,
19    smudge_config: Option<&GitConfig>,
20    stat_cache: Option<&IndexStatCache>,
21) -> Result<Option<IndexEntry>> {
22    restore_index_entry_maybe_delayed(
23        worktree_root,
24        git_dir,
25        format,
26        db,
27        entry,
28        smudge_config,
29        stat_cache,
30        None,
31    )
32}
33
34pub(crate) fn restore_index_entry_maybe_delayed(
35    worktree_root: &Path,
36    git_dir: &Path,
37    format: ObjectFormat,
38    db: &FileObjectDatabase,
39    entry: &IndexEntry,
40    smudge_config: Option<&GitConfig>,
41    stat_cache: Option<&IndexStatCache>,
42    mut delayed: Option<&mut DelayedCheckoutQueue>,
43) -> Result<Option<IndexEntry>> {
44    // A gitlink (mode 160000) names a commit in the submodule's repository, not
45    // a blob here — reading it as a blob fails ("not found: blob object"). git's
46    // `checkout_entry` S_IFGITLINK arm just ensures the submodule directory
47    // exists and never touches an object; the submodule's content is `submodule
48    // update` territory. Single gitlink rule via `sley_index::is_gitlink`.
49    if sley_index::is_gitlink(entry.mode) {
50        let dir_path = worktree_path(worktree_root, entry.path.as_bytes())?;
51        materialize_gitlink_dir(worktree_root, &dir_path)?;
52        return Ok(None);
53    }
54    let file_path = worktree_path(worktree_root, entry.path.as_bytes())?;
55    if let Some(stat_cache) = stat_cache {
56        if let Ok(metadata) = fs::symlink_metadata(&file_path) {
57            if stat_cache
58                .reuse_index_entry_for_checkout(entry, &metadata)
59                .is_some()
60            {
61                return Ok(None);
62            }
63        }
64    }
65    let object = read_expected_object(db, &entry.oid, ObjectType::Blob)?;
66    let body: Cow<'_, [u8]> = match smudge_config {
67        Some(config) => {
68            let checks = smudge_attribute_checks_from_index(
69                worktree_root,
70                git_dir,
71                format,
72                entry.path.as_bytes(),
73            )?;
74            match apply_smudge_filter_with_attributes_maybe_delayed(
75                config,
76                &checks,
77                entry.path.as_bytes(),
78                &object.body,
79                format,
80                delayed.is_some() && (entry.mode & 0o170000) != 0o120000,
81            )? {
82                SmudgeFilterResult::Content(body) => body,
83                SmudgeFilterResult::Delayed { process } => {
84                    let queue = delayed
85                        .as_deref_mut()
86                        .expect("delay is only reported when a queue is available");
87                    queue.enqueue(
88                        process,
89                        entry.path.as_bytes(),
90                        &TrackedEntry {
91                            mode: entry.mode,
92                            oid: entry.oid,
93                        },
94                    );
95                    return Ok(Some(unmaterialized_index_entry_from_index(entry)));
96                }
97            }
98        }
99        None => Cow::Borrowed(&object.body),
100    };
101    prepare_blob_parent_dirs(worktree_root, &file_path)?;
102    remove_existing_worktree_path(&file_path)?;
103    write_blob_body_or_symlink(&file_path, entry.mode, &body, &object.body)?;
104    let metadata = fs::symlink_metadata(&file_path)?;
105    Ok(Some(index_entry_with_refreshed_stat(entry, &metadata)))
106}
107
108pub(crate) fn index_entry_with_refreshed_stat(
109    entry: &IndexEntry,
110    metadata: &fs::Metadata,
111) -> IndexEntry {
112    let mut refreshed = index_entry_from_metadata(entry.path.clone(), entry.oid, metadata);
113    refreshed.mode = entry.mode;
114    refreshed.flags = entry.flags;
115    refreshed.flags_extended = entry.flags_extended;
116    refreshed
117}
118
119pub(crate) fn restored_head_index_entry(
120    _worktree_root: &Path,
121    _db: &FileObjectDatabase,
122    path: &[u8],
123    entry: &TrackedEntry,
124) -> Result<IndexEntry> {
125    // This restores the index from a tree (reset --mixed / stash / sparse) WITHOUT
126    // rewriting the worktree file, so the file on disk may hold different content
127    // than `entry.oid`. Crucially we must NOT copy the worktree file's stat onto
128    // this entry: that would make the cached stat match a file whose real content
129    // hashes to a DIFFERENT oid, breaking git's "stat-match implies oid-match"
130    // invariant that the status stat-cache relies on. Leave the whole stat tuple
131    // zeroed, including size, so `reset --mixed --no-refresh` remains stat-dirty
132    // until an explicit/default refresh validates it (t7102 cell 28).
133    Ok(IndexEntry {
134        ctime_seconds: 0,
135        ctime_nanoseconds: 0,
136        mtime_seconds: 0,
137        mtime_nanoseconds: 0,
138        dev: 0,
139        ino: 0,
140        mode: entry.mode,
141        uid: 0,
142        gid: 0,
143        size: 0,
144        oid: entry.oid,
145        flags: path.len().min(0x0fff) as u16,
146        flags_extended: 0,
147        path: BString::from(path),
148    })
149}
150
151pub(crate) fn index_entry_is_under_path(entry_path: &[u8], directory: &[u8]) -> bool {
152    if directory.is_empty() {
153        return true;
154    }
155    entry_path
156        .strip_prefix(directory)
157        .and_then(|rest| rest.strip_prefix(b"/"))
158        .is_some()
159}
160
161pub(crate) fn index_entry_from_metadata(
162    path: impl Into<BString>,
163    oid: ObjectId,
164    metadata: &fs::Metadata,
165) -> IndexEntry {
166    let modified = metadata.modified().ok();
167    let duration = modified
168        .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
169        .unwrap_or_default();
170    let mode = file_mode(metadata);
171    let path = path.into();
172    let flags = path.len().min(0x0fff) as u16;
173    let mut entry = IndexEntry {
174        ctime_seconds: duration.as_secs().min(u32::MAX as u64) as u32,
175        ctime_nanoseconds: duration.subsec_nanos(),
176        mtime_seconds: duration.as_secs().min(u32::MAX as u64) as u32,
177        mtime_nanoseconds: duration.subsec_nanos(),
178        dev: 0,
179        ino: 0,
180        mode,
181        uid: 0,
182        gid: 0,
183        size: index_size_from_metadata(metadata),
184        oid,
185        flags,
186        flags_extended: 0,
187        path,
188    };
189    apply_unix_metadata_to_index_entry(&mut entry, metadata);
190    entry
191}
192
193pub(crate) fn index_entry_from_metadata_with_filemode(
194    path: impl Into<BString>,
195    oid: ObjectId,
196    metadata: &fs::Metadata,
197    trust_filemode: bool,
198) -> IndexEntry {
199    let mut entry = index_entry_from_metadata(path, oid, metadata);
200    entry.mode = file_mode_with_trust(metadata, trust_filemode);
201    entry
202}
203
204pub(crate) fn trust_executable_bit_from_git_dir(
205    git_dir: &Path,
206    config_parameters_env: Option<&str>,
207) -> bool {
208    sley_config::read_repo_config(git_dir, config_parameters_env)
209        .ok()
210        .as_ref()
211        .map(trust_executable_bit)
212        .unwrap_or(true)
213}
214
215pub(crate) fn trust_executable_bit(config: &GitConfig) -> bool {
216    config.get_bool("core", None, "filemode").unwrap_or(true)
217}
218
219pub(crate) fn trust_symlinks_from_git_dir(
220    git_dir: &Path,
221    config_parameters_env: Option<&str>,
222) -> bool {
223    sley_config::read_repo_config(git_dir, config_parameters_env)
224        .ok()
225        .as_ref()
226        .map(trust_symlinks)
227        .unwrap_or(true)
228}
229
230pub(crate) fn trust_symlinks(config: &GitConfig) -> bool {
231    config.get_bool("core", None, "symlinks").unwrap_or(true)
232}
233
234pub(crate) fn preferred_unmerged_mode_for_untrusted_worktree(
235    entries: &[IndexEntry],
236    trust_filemode: bool,
237    trust_symlinks: bool,
238) -> Option<u32> {
239    if trust_filemode && trust_symlinks {
240        return None;
241    }
242    let preferred = entries
243        .iter()
244        .find(|entry| entry.stage() == Stage::Ours)
245        .or_else(|| entries.iter().find(|entry| entry.stage() == Stage::Base))?;
246    if (!trust_symlinks && preferred.mode == 0o120000)
247        || (!trust_filemode && matches!(preferred.mode, 0o100644 | 0o100755))
248    {
249        Some(preferred.mode)
250    } else {
251        None
252    }
253}
254
255pub(crate) fn file_mode_with_trust(metadata: &fs::Metadata, trust_filemode: bool) -> u32 {
256    if trust_filemode {
257        file_mode(metadata)
258    } else {
259        0o100644
260    }
261}
262
263#[cfg(unix)]
264pub(crate) fn apply_unix_metadata_to_index_entry(entry: &mut IndexEntry, metadata: &fs::Metadata) {
265    use std::os::unix::fs::MetadataExt;
266
267    entry.ctime_seconds = metadata.ctime().min(u32::MAX as i64).max(0) as u32;
268    entry.ctime_nanoseconds = metadata.ctime_nsec().min(u32::MAX as i64).max(0) as u32;
269    entry.dev = metadata.dev() as u32;
270    entry.ino = metadata.ino() as u32;
271    entry.uid = metadata.uid();
272    entry.gid = metadata.gid();
273}
274
275#[cfg(not(unix))]
276pub(crate) fn apply_unix_metadata_to_index_entry(
277    _entry: &mut IndexEntry,
278    _metadata: &fs::Metadata,
279) {
280}
281
282pub(crate) fn index_size_from_metadata(metadata: &fs::Metadata) -> u32 {
283    metadata.len().min(u32::MAX as u64) as u32
284}
285
286pub(crate) fn read_expected_object(
287    db: &FileObjectDatabase,
288    oid: &ObjectId,
289    expected: ObjectType,
290) -> Result<std::sync::Arc<EncodedObject>> {
291    let object = db
292        .read_object(oid)
293        .map_err(|err| expect_missing_object_kind(err, *oid, missing_kind_for_type(expected)))?;
294    if object.object_type != expected {
295        return Err(GitError::InvalidObject(format!(
296            "expected {} {}, found {}",
297            expected.as_str(),
298            oid,
299            object.object_type.as_str()
300        )));
301    }
302    Ok(object)
303}
304
305pub(crate) fn expect_missing_object_kind(
306    err: GitError,
307    oid: ObjectId,
308    expected: MissingObjectKind,
309) -> GitError {
310    match err.not_found_kind() {
311        Some(sley_core::NotFoundKind::Object { .. }) => GitError::object_kind_not_found_in(
312            oid,
313            expected,
314            MissingObjectContext::WorktreeMaterialize,
315        ),
316        _ => err,
317    }
318}
319
320pub(crate) fn missing_kind_for_type(object_type: ObjectType) -> MissingObjectKind {
321    match object_type {
322        ObjectType::Blob => MissingObjectKind::Blob,
323        ObjectType::Tree => MissingObjectKind::Tree,
324        ObjectType::Commit => MissingObjectKind::Commit,
325        ObjectType::Tag => MissingObjectKind::Tag,
326    }
327}
328
329pub(crate) fn read_commit(
330    db: &FileObjectDatabase,
331    format: ObjectFormat,
332    oid: &ObjectId,
333) -> Result<Commit> {
334    let object = read_expected_object(db, oid, ObjectType::Commit)?;
335    Commit::parse(format, &object.body)
336}
337
338#[derive(Debug, Clone, PartialEq, Eq)]
339pub(crate) struct TrackedEntry {
340    pub(crate) mode: u32,
341    pub(crate) oid: ObjectId,
342}
343
344/// The short-status change byte for a pair of differing tracked entries: `T`
345/// (typechange) when the file-type bits (`S_IFMT`) of the two modes differ —
346/// regular↔symlink, regular↔gitlink — otherwise `M` (modified). git's diff
347/// machinery sets `DIFF_STATUS_TYPE_CHANGED` for the former
348/// (`diffcore.h`'s `DIFF_PAIR_TYPE_CHANGED`); `wt-status.c` renders it
349/// `typechange:` / `T`. An exec-bit-only change (100644↔100755) stays `M`.
350pub(crate) fn status_change_code(old_mode: u32, new_mode: u32) -> u8 {
351    if sley_diff_merge::is_type_change(old_mode, new_mode) {
352        b'T'
353    } else {
354        b'M'
355    }
356}
357
358/// git's racy-git stat cache: the stage-0 index entries keyed by path (so the
359/// worktree walk can reuse a cached oid when a file's stat shows it is unchanged
360/// since it was staged) plus the index *file's* own mtime, which git uses as the
361/// racy-clean reference timestamp.
362///
363/// SAFETY INVARIANT: trusting a cached oid by stat alone is only sound because
364/// every code path that stamps a worktree stat onto an index entry also hashed
365/// that exact file content (see `index_entry_from_metadata`), while tree-sourced
366/// restores (reset --mixed / stash / sparse) leave the stat zeroed
367/// (`restored_head_index_entry`). So a non-zero, non-racy stat match implies the
368/// cached oid is the file's true content. When that does not hold we fall through
369/// to a full read+filter+hash, so a modified file is never reported clean.
370#[derive(Debug, Clone, Default)]
371pub(crate) struct IndexStatCache {
372    pub(crate) entries: HashMap<Vec<u8>, IndexEntry>,
373    /// The index file's modification time as `(seconds, nanoseconds)`, or `None`
374    /// when it could not be determined. Used as git's racy-clean reference.
375    pub(crate) index_mtime: Option<(u64, u64)>,
376}
377
378impl IndexStatCache {
379    /// Builds the cache from an already-parsed index plus the path of the index
380    /// file on disk (whose mtime becomes the racy-clean reference). Only stage-0
381    /// entries are retained; higher merge stages never describe a worktree file.
382    pub(crate) fn from_index(index: &Index, index_path: &Path) -> Self {
383        let index_mtime = fs::metadata(index_path)
384            .ok()
385            .and_then(|metadata| file_mtime_parts(&metadata));
386        Self::from_index_mtime(index, index_mtime)
387    }
388
389    pub(crate) fn from_index_mtime(index: &Index, index_mtime: Option<(u64, u64)>) -> Self {
390        IndexStatCache {
391            entries: stage0_index_entries(index),
392            index_mtime,
393        }
394    }
395
396    pub(crate) fn from_index_mtime_only(index_mtime: Option<(u64, u64)>) -> Self {
397        IndexStatCache {
398            entries: HashMap::new(),
399            index_mtime,
400        }
401    }
402
403    /// Whether `entry` is "racily clean" in git's sense: its cached mtime is not
404    /// strictly older than the index file's mtime, so a same-timestamp write
405    /// could have changed the content without moving the stat. Such entries must
406    /// always be re-hashed.
407    ///
408    /// Conservative by construction: if the index mtime is unknown, or either
409    /// side's mtime is zero (e.g. a tree-sourced entry whose stat was left
410    /// zeroed), this returns `true` so the caller re-hashes rather than trusting
411    /// a stat we cannot prove safe.
412    fn is_racily_clean(&self, entry: &IndexEntry) -> bool {
413        let Some(index_mtime) = self.index_mtime else {
414            return true;
415        };
416        if index_mtime == (0, 0) {
417            return true;
418        }
419        let entry_mtime = (
420            u64::from(entry.mtime_seconds),
421            u64::from(entry.mtime_nanoseconds),
422        );
423        if entry_mtime == (0, 0) {
424            return true;
425        }
426        // Racy unless the index was written strictly after the entry's mtime.
427        index_mtime <= entry_mtime
428    }
429
430    fn is_racily_clean_ref(&self, entry: &IndexEntryRef<'_>) -> bool {
431        let Some(index_mtime) = self.index_mtime else {
432            return true;
433        };
434        if index_mtime == (0, 0) {
435            return true;
436        }
437        let entry_mtime = (
438            u64::from(entry.mtime_seconds),
439            u64::from(entry.mtime_nanoseconds),
440        );
441        if entry_mtime == (0, 0) {
442            return true;
443        }
444        index_mtime <= entry_mtime
445    }
446
447    /// Whether the index has a stage-0 entry for `git_path` (i.e. the path is
448    /// tracked). Used to skip hashing untracked worktree files.
449    fn contains(&self, git_path: &[u8]) -> bool {
450        self.entries.contains_key(git_path)
451    }
452
453    fn tracked_entry(&self, git_path: &[u8]) -> Option<TrackedEntry> {
454        self.entries.get(git_path).map(|entry| TrackedEntry {
455            mode: entry.mode,
456            oid: entry.oid,
457        })
458    }
459
460    pub(crate) fn index_entry(&self, git_path: &[u8]) -> Option<&IndexEntry> {
461        self.entries.get(git_path)
462    }
463
464    /// Returns the cached [`TrackedEntry`] for `git_path` (reusing its stored
465    /// oid, so the caller can SKIP reading, filtering, and hashing the file) only
466    /// when the worktree file is provably unchanged since it was staged: a
467    /// stage-0 entry exists, its recorded mode matches the file's current mode
468    /// (catching pure `chmod`s that do not move mtime), the size+mtime stat
469    /// check passes, and the entry is not racily clean. Otherwise returns `None`
470    /// and the caller hashes the file as usual.
471    pub(crate) fn reuse_tracked_entry(
472        &self,
473        git_path: &[u8],
474        worktree_metadata: &fs::Metadata,
475    ) -> Option<TrackedEntry> {
476        let entry = self.entries.get(git_path)?;
477        self.reuse_index_entry(entry, worktree_metadata)
478    }
479
480    pub(crate) fn reuse_index_entry(
481        &self,
482        entry: &IndexEntry,
483        worktree_metadata: &fs::Metadata,
484    ) -> Option<TrackedEntry> {
485        // Gitlink: reusable as-is whenever the worktree path is a directory (a
486        // submodule is never re-hashed; its cached stat is ignored). Routes
487        // through the single `sley_index::gitlink_stat_verdict` rule so the
488        // gitlink-vs-040000 mode mismatch never spuriously rejects it.
489        if sley_index::is_gitlink(entry.mode) {
490            return match sley_index::gitlink_stat_verdict(worktree_metadata) {
491                sley_index::GitlinkStatVerdict::Populated => Some(TrackedEntry {
492                    mode: entry.mode,
493                    oid: entry.oid,
494                }),
495                sley_index::GitlinkStatVerdict::TypeChanged => None,
496            };
497        }
498        if entry.mode != worktree_entry_mode(worktree_metadata) {
499            return None;
500        }
501        if !worktree_entry_is_uptodate(entry, worktree_metadata) {
502            return None;
503        }
504        if self.is_racily_clean(entry) {
505            return None;
506        }
507        Some(TrackedEntry {
508            mode: entry.mode,
509            oid: entry.oid,
510        })
511    }
512
513    fn reuse_index_entry_for_checkout(
514        &self,
515        entry: &IndexEntry,
516        worktree_metadata: &fs::Metadata,
517    ) -> Option<TrackedEntry> {
518        if let Some(tracked) = self.reuse_index_entry(entry, worktree_metadata) {
519            return Some(tracked);
520        }
521        if u64::from(entry.size) != 0 || worktree_metadata.len() == 0 {
522            return None;
523        }
524        if entry.mode != worktree_entry_mode(worktree_metadata) {
525            return None;
526        }
527        let (mtime_seconds, mtime_nanoseconds) = file_mtime_parts(worktree_metadata)?;
528        if u64::from(entry.mtime_seconds) != mtime_seconds
529            || u64::from(entry.mtime_nanoseconds) != mtime_nanoseconds
530        {
531            return None;
532        }
533        if self.is_racily_clean(entry) {
534            return None;
535        }
536        Some(TrackedEntry {
537            mode: entry.mode,
538            oid: entry.oid,
539        })
540    }
541
542    pub(crate) fn reuse_index_entry_ref(
543        &self,
544        entry: &IndexEntryRef<'_>,
545        worktree_metadata: &fs::Metadata,
546    ) -> Option<TrackedEntry> {
547        if sley_index::is_gitlink(entry.mode) {
548            return match sley_index::gitlink_stat_verdict(worktree_metadata) {
549                sley_index::GitlinkStatVerdict::Populated => Some(TrackedEntry {
550                    mode: entry.mode,
551                    oid: entry.oid,
552                }),
553                sley_index::GitlinkStatVerdict::TypeChanged => None,
554            };
555        }
556        if entry.mode != worktree_entry_mode(worktree_metadata) {
557            return None;
558        }
559        if !worktree_entry_ref_is_uptodate(entry, worktree_metadata) {
560            return None;
561        }
562        if self.is_racily_clean_ref(entry) {
563            return None;
564        }
565        Some(TrackedEntry {
566            mode: entry.mode,
567            oid: entry.oid,
568        })
569    }
570
571    /// The stage-0 gitlink (mode 160000) index entry at `git_path`, if any.
572    fn gitlink_entry(&self, git_path: &[u8]) -> Option<&IndexEntry> {
573        self.entries
574            .get(git_path)
575            .filter(|entry| sley_index::is_gitlink(entry.mode))
576    }
577}
578
579pub(crate) fn read_index_entries(
580    git_dir: &Path,
581    format: ObjectFormat,
582) -> Result<BTreeMap<Vec<u8>, TrackedEntry>> {
583    let db = FileObjectDatabase::from_git_dir(git_dir, format);
584    Ok(read_index_entries_with_stat_cache(git_dir, format, &db)?.0)
585}
586
587pub(crate) fn read_all_index_paths(
588    git_dir: &Path,
589    format: ObjectFormat,
590) -> Result<BTreeSet<Vec<u8>>> {
591    let index_path = repository_index_path(git_dir);
592    let bytes = match fs::read(index_path) {
593        Ok(bytes) => bytes,
594        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeSet::new()),
595        Err(err) => return Err(err.into()),
596    };
597    let index = Index::parse(&bytes, format)?;
598    Ok(index
599        .entries
600        .into_iter()
601        .map(|entry| entry.path.into_bytes())
602        .collect())
603}
604
605pub(crate) fn resolve_head_tree_oid(
606    git_dir: &Path,
607    format: ObjectFormat,
608    db: &FileObjectDatabase,
609) -> Result<Option<ObjectId>> {
610    let Some(commit_oid) = resolve_head_commit_oid(git_dir, format)? else {
611        return Ok(None);
612    };
613    if let Some(tree_oid) = sley_rev::commit_graph_tree_oid(git_dir, format, &commit_oid)? {
614        return Ok(Some(tree_oid));
615    }
616    let object = read_expected_object(db, &commit_oid, ObjectType::Commit)?;
617    let commit = Commit::parse_ref(format, &object.body)?;
618    Ok(Some(commit.tree))
619}
620
621pub(crate) fn resolve_head_commit_oid(
622    git_dir: &Path,
623    format: ObjectFormat,
624) -> Result<Option<ObjectId>> {
625    let refs = FileRefStore::new(git_dir, format);
626    sley_refs::resolve_ref_peeled(&refs, "HEAD")
627}
628
629pub(crate) fn status_row_is_untracked_or_ignored(entry: ShortStatusRow<'_>) -> bool {
630    matches!((entry.index, entry.worktree), (b'?', b'?') | (b'!', b'!'))
631}
632
633pub(crate) fn checkout_switch_head_symbolic(
634    refs: &FileRefStore,
635    branch_ref: String,
636    committer: Vec<u8>,
637    branch: &str,
638    old_oid: Option<ObjectId>,
639    new_oid: Option<ObjectId>,
640) -> Result<()> {
641    // Reflog "from" side: the previous branch's short name, or the commit id
642    // when HEAD was detached (git's `checkout: moving from X to Y` shape,
643    // which `@{-N}` resolution parses).
644    let from = match refs.read_ref("HEAD") {
645        Ok(Some(RefTarget::Symbolic(name))) => name
646            .strip_prefix("refs/heads/")
647            .unwrap_or(&name)
648            .to_string(),
649        Ok(Some(RefTarget::Direct(oid))) => oid.to_hex(),
650        _ => "HEAD".to_string(),
651    };
652    let mut tx = refs.transaction();
653    let reflog = match (old_oid, new_oid) {
654        (Some(old_oid), Some(new_oid)) => Some(ReflogEntry {
655            old_oid,
656            new_oid,
657            committer,
658            message: format!("checkout: moving from {from} to {branch}").into_bytes(),
659        }),
660        _ => None,
661    };
662    tx.update(RefUpdate {
663        name: "HEAD".into(),
664        expected: None,
665        new: RefTarget::Symbolic(branch_ref),
666        reflog,
667    });
668    tx.commit()
669}
670
671pub(crate) fn head_matches_index_from_entries(
672    index: &Index,
673    format: ObjectFormat,
674    head_tree_oid: &ObjectId,
675) -> Result<bool> {
676    if index
677        .entries
678        .iter()
679        .any(|entry| entry.stage() != Stage::Normal)
680    {
681        return Ok(false);
682    }
683    let index_tree = index_entry_tree_oid_without_cache(index, format)?;
684    Ok(&index_tree == head_tree_oid)
685}
686
687pub(crate) fn head_matches_borrowed_index_from_entries(
688    index: &BorrowedIndex<'_>,
689    format: ObjectFormat,
690    head_tree_oid: &ObjectId,
691) -> Result<bool> {
692    if index
693        .entries
694        .iter()
695        .any(|entry| entry.stage() != Stage::Normal)
696    {
697        return Ok(false);
698    }
699    let index_tree = borrowed_index_entry_tree_oid_without_cache(index, format)?;
700    Ok(&index_tree == head_tree_oid)
701}
702
703/// Parses the index a single time and returns both the path -> [`TrackedEntry`]
704/// map used for status comparisons AND the [`IndexStatCache`] used to short-cut
705/// the worktree walk, avoiding a second parse of the same file.
706pub(crate) fn read_index_entries_with_stat_cache(
707    git_dir: &Path,
708    format: ObjectFormat,
709    db: &FileObjectDatabase,
710) -> Result<(BTreeMap<Vec<u8>, TrackedEntry>, IndexStatCache, bool)> {
711    let (index, stat_cache, head_matches_index) = read_index_with_stat_cache(git_dir, format, db)?;
712    let tracked = index_entries_from_index(index);
713    Ok((tracked, stat_cache, head_matches_index))
714}
715
716pub(crate) fn index_entries_from_index(index: Index) -> BTreeMap<Vec<u8>, TrackedEntry> {
717    index
718        .entries
719        .into_iter()
720        .filter(|entry| entry.stage() == Stage::Normal)
721        .map(|entry| {
722            (
723                entry.path.into_bytes(),
724                TrackedEntry {
725                    mode: entry.mode,
726                    oid: entry.oid,
727                },
728            )
729        })
730        .collect()
731}
732
733pub(crate) fn read_index_with_stat_cache(
734    git_dir: &Path,
735    format: ObjectFormat,
736    db: &FileObjectDatabase,
737) -> Result<(Index, IndexStatCache, bool)> {
738    read_index_with_stat_cache_entries(git_dir, format, db, true)
739}
740
741pub(crate) fn read_index_with_stat_cache_entries(
742    git_dir: &Path,
743    format: ObjectFormat,
744    db: &FileObjectDatabase,
745    include_entries: bool,
746) -> Result<(Index, IndexStatCache, bool)> {
747    let index_path = repository_index_path(git_dir);
748    let index_metadata = match fs::metadata(&index_path) {
749        Ok(metadata) => metadata,
750        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
751            return Ok((
752                Index {
753                    version: 2,
754                    entries: Vec::new(),
755                    extensions: Vec::new(),
756                    checksum: None,
757                },
758                IndexStatCache::default(),
759                false,
760            ));
761        }
762        Err(err) => return Err(err.into()),
763    };
764    let index = sley_index::read_repository_index(git_dir, format)?;
765    let index_mtime = file_mtime_parts(&index_metadata);
766    let stat_cache = if include_entries {
767        IndexStatCache::from_index_mtime(&index, index_mtime)
768    } else {
769        IndexStatCache::from_index_mtime_only(index_mtime)
770    };
771    let head_matches_index = match resolve_head_tree_oid(git_dir, format, db)? {
772        Some(head_tree_oid) => head_matches_index_from_entries(&index, format, &head_tree_oid)?,
773        None => false,
774    };
775    Ok((index, stat_cache, head_matches_index))
776}
777
778pub(crate) fn head_tree_entries(
779    git_dir: &Path,
780    format: ObjectFormat,
781    db: &FileObjectDatabase,
782) -> Result<BTreeMap<Vec<u8>, TrackedEntry>> {
783    let refs = FileRefStore::new(git_dir, format);
784    let Some(head) = refs.read_ref("HEAD")? else {
785        return Ok(BTreeMap::new());
786    };
787    let commit_oid = match head {
788        RefTarget::Direct(oid) => Some(oid),
789        RefTarget::Symbolic(name) => match refs.read_ref(&name)? {
790            Some(RefTarget::Direct(oid)) => Some(oid),
791            _ => None,
792        },
793    };
794    let Some(commit_oid) = commit_oid else {
795        return Ok(BTreeMap::new());
796    };
797    let object = read_expected_object(db, &commit_oid, ObjectType::Commit)?;
798    let commit = Commit::parse_ref(format, &object.body)?;
799    let mut entries = BTreeMap::new();
800    collect_tree_entries(db, format, &commit.tree, &mut entries)?;
801    Ok(entries)
802}
803
804pub(crate) fn tree_entries(
805    db: &FileObjectDatabase,
806    format: ObjectFormat,
807    tree_oid: &ObjectId,
808) -> Result<BTreeMap<Vec<u8>, TrackedEntry>> {
809    let mut entries = BTreeMap::new();
810    collect_tree_entries(db, format, tree_oid, &mut entries)?;
811    Ok(entries)
812}
813
814/// Flatten a tree's blob leaves into `entries`, keyed by full path.
815///
816/// Delegates to the canonical [`sley_diff_merge::flatten_tree`] (the local
817/// recursive flattener was a byte-identical copy) and adapts its
818/// `(mode, oid)` tuples into this module's [`TrackedEntry`]. Entries already
819/// present in `entries` are overwritten, matching the previous insert-based
820/// behaviour.
821pub(crate) fn collect_tree_entries(
822    db: &FileObjectDatabase,
823    format: ObjectFormat,
824    tree_oid: &ObjectId,
825    entries: &mut BTreeMap<Vec<u8>, TrackedEntry>,
826) -> Result<()> {
827    for (path, (mode, oid)) in sley_diff_merge::flatten_tree(db, format, tree_oid)? {
828        entries.insert(path, TrackedEntry { mode, oid });
829    }
830    Ok(())
831}
832
833/// Like a full worktree walk, but accepts the index's [`IndexStatCache`] so the
834/// walk can reuse a cached oid for files that are provably unchanged since they
835/// were staged, skipping the read+filter+hash for those paths. Passing `None`
836/// hashes every file when no stat cache is supplied.
837pub(crate) fn worktree_entries_with_stat_cache(
838    worktree_root: &Path,
839    git_dir: &Path,
840    format: ObjectFormat,
841    stat_cache: Option<&IndexStatCache>,
842    tracked_paths: Option<&BTreeSet<Vec<u8>>>,
843    ignores: Option<&mut IgnoreMatcher>,
844) -> Result<BTreeMap<Vec<u8>, TrackedEntry>> {
845    Ok(worktree_entries_with_submodule_dirt(
846        worktree_root,
847        git_dir,
848        format,
849        stat_cache,
850        tracked_paths,
851        ignores,
852    )?
853    .0)
854}
855
856/// Tracked worktree entries keyed by repo path, plus the dirt mask
857/// ([`DIRTY_SUBMODULE_MODIFIED`] / [`DIRTY_SUBMODULE_UNTRACKED`]) for every
858/// tracked gitlink path whose submodule working tree is dirty.
859pub(crate) type WorktreeEntriesWithDirt = (BTreeMap<Vec<u8>, TrackedEntry>, BTreeMap<Vec<u8>, u8>);
860
861/// Status worktree snapshot: tracked/untracked entries, gitlink dirt masks, and
862/// tracked paths observed in the worktree.
863pub(crate) type StatusWorktreeSnapshot = (
864    BTreeMap<Vec<u8>, TrackedEntry>,
865    BTreeMap<Vec<u8>, u8>,
866    HashSet<Vec<u8>>,
867);
868
869/// Like [`worktree_entries_with_stat_cache`], but also reports, for every
870/// tracked gitlink path whose submodule working tree is dirty, the dirt mask
871/// ([`DIRTY_SUBMODULE_MODIFIED`] / [`DIRTY_SUBMODULE_UNTRACKED`]).
872pub(crate) fn worktree_entries_with_submodule_dirt(
873    worktree_root: &Path,
874    git_dir: &Path,
875    format: ObjectFormat,
876    stat_cache: Option<&IndexStatCache>,
877    tracked_paths: Option<&BTreeSet<Vec<u8>>>,
878    ignores: Option<&mut IgnoreMatcher>,
879) -> Result<WorktreeEntriesWithDirt> {
880    let mut entries = BTreeMap::new();
881    let mut submodule_dirt_map = BTreeMap::new();
882    let mut tracked_presence = HashSet::new();
883    // Worktree blobs are compared to the index by OID, so they must be passed
884    // through the clean filter (core.autocrlf / .gitattributes) first -- exactly
885    // as `git add` would store them. With no filter configured this is an exact
886    // passthrough, so unfiltered repositories see identical OIDs.
887    let config = sley_config::read_repo_config(git_dir, None).unwrap_or_default();
888    // Seed the matcher with the repo-wide sources only; each directory's
889    // `.gitattributes` is folded in by `collect_worktree_entries` as it descends,
890    // so the worktree is read exactly once (a separate full-tree attribute pass was
891    // a second traversal of every directory).
892    let mut attr_matcher = AttributeMatcher::from_worktree_base(worktree_root);
893    let attr_requested = filter_attribute_names();
894    let mut context = WorktreeEntriesWalk {
895        git_dir,
896        format,
897        config: &config,
898        matcher: &mut attr_matcher,
899        requested: &attr_requested,
900        stat_cache,
901        known_tracked_paths: tracked_paths,
902        tracked_paths,
903        ignores,
904        entries: &mut entries,
905        submodule_dirt: &mut submodule_dirt_map,
906        tracked_presence: &mut tracked_presence,
907        record_clean_tracked: true,
908    };
909    collect_worktree_entries(&mut context, worktree_root, &[])?;
910    Ok((entries, submodule_dirt_map))
911}
912
913pub(crate) fn status_worktree_entries_with_submodule_dirt(
914    worktree_root: &Path,
915    git_dir: &Path,
916    format: ObjectFormat,
917    stat_cache: &IndexStatCache,
918    known_tracked_paths: Option<&BTreeSet<Vec<u8>>>,
919    tracked_paths: Option<&BTreeSet<Vec<u8>>>,
920    ignores: Option<&mut IgnoreMatcher>,
921) -> Result<StatusWorktreeSnapshot> {
922    let mut entries = BTreeMap::new();
923    let mut submodule_dirt_map = BTreeMap::new();
924    let mut tracked_presence = HashSet::new();
925    let config = sley_config::read_repo_config(git_dir, None).unwrap_or_default();
926    let mut attr_matcher = AttributeMatcher::from_worktree_base(worktree_root);
927    let attr_requested = filter_attribute_names();
928    let mut context = WorktreeEntriesWalk {
929        git_dir,
930        format,
931        config: &config,
932        matcher: &mut attr_matcher,
933        requested: &attr_requested,
934        stat_cache: Some(stat_cache),
935        known_tracked_paths,
936        tracked_paths,
937        ignores,
938        entries: &mut entries,
939        submodule_dirt: &mut submodule_dirt_map,
940        tracked_presence: &mut tracked_presence,
941        record_clean_tracked: false,
942    };
943    collect_worktree_entries(&mut context, worktree_root, &[])?;
944    Ok((entries, submodule_dirt_map, tracked_presence))
945}
946
947pub(crate) fn worktree_entry_for_git_path(
948    worktree_root: &Path,
949    git_dir: &Path,
950    format: ObjectFormat,
951    git_path: &[u8],
952    expected_oid: &ObjectId,
953    expected_mode: u32,
954    stat_cache: Option<&IndexStatCache>,
955) -> Result<Option<TrackedEntry>> {
956    if git_path_has_symlink_parent(worktree_root, git_path)? {
957        return Ok(None);
958    }
959    let absolute = worktree_root.join(repo_path_to_os_path(git_path)?);
960    let metadata = match fs::symlink_metadata(&absolute) {
961        Ok(metadata) => metadata,
962        Err(err)
963            if matches!(
964                err.kind(),
965                std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
966            ) =>
967        {
968            return Ok(None);
969        }
970        Err(err) => return Err(err.into()),
971    };
972
973    if sley_index::is_gitlink(expected_mode) {
974        if !metadata.is_dir() {
975            return Ok(Some(TrackedEntry {
976                mode: worktree_entry_mode(&metadata),
977                oid: ObjectId::null(format),
978            }));
979        }
980        let oid = sley_diff_merge::gitlink_head_oid(&absolute, format).unwrap_or(*expected_oid);
981        return Ok(Some(TrackedEntry {
982            mode: sley_index::GITLINK_MODE,
983            oid,
984        }));
985    }
986
987    if metadata.is_dir() {
988        return Ok(Some(TrackedEntry {
989            mode: worktree_entry_mode(&metadata),
990            oid: ObjectId::null(format),
991        }));
992    }
993
994    if !(metadata.is_file() || metadata.file_type().is_symlink()) {
995        return Ok(Some(TrackedEntry {
996            mode: worktree_entry_mode(&metadata),
997            oid: ObjectId::null(format),
998        }));
999    }
1000
1001    if let Some(tracked) =
1002        stat_cache.and_then(|cache| cache.reuse_tracked_entry(git_path, &metadata))
1003    {
1004        return Ok(Some(tracked));
1005    }
1006
1007    let mode = worktree_entry_mode(&metadata);
1008    let body = if metadata.file_type().is_symlink() {
1009        symlink_target_bytes(&absolute)?
1010    } else {
1011        let config = sley_config::read_repo_config(git_dir, None).unwrap_or_default();
1012        let body = fs::read(&absolute)?;
1013        let clean = apply_clean_filter(worktree_root, git_dir, &config, git_path, &body)?;
1014        let oid = match stat_cache.and_then(|cache| cache.index_entry(git_path)) {
1015            Some(index_entry) => clean_filtered_oid_for_status(
1016                format,
1017                &body,
1018                clean,
1019                index_entry.oid,
1020                index_entry.size,
1021                &metadata,
1022            )?,
1023            None => EncodedObject::new(ObjectType::Blob, clean).object_id(format)?,
1024        };
1025        return Ok(Some(TrackedEntry { mode, oid }));
1026    };
1027    let oid = EncodedObject::new(ObjectType::Blob, body).object_id(format)?;
1028    Ok(Some(TrackedEntry { mode, oid }))
1029}
1030
1031pub(crate) fn worktree_entry_for_index_entry_with_attributes(
1032    worktree_root: &Path,
1033    git_dir: &Path,
1034    format: ObjectFormat,
1035    index_entry: &IndexEntry,
1036    stat_cache: &IndexStatCache,
1037    clean_filter: &mut Option<TrackedOnlyCleanFilter>,
1038) -> Result<Option<TrackedEntry>> {
1039    let git_path = index_entry.path.as_bytes();
1040    let expected_mode = index_entry.mode;
1041    if git_path_has_symlink_parent(worktree_root, git_path)? {
1042        return Ok(None);
1043    }
1044    let absolute = worktree_root.join(repo_path_to_os_path(git_path)?);
1045    let metadata = match fs::symlink_metadata(&absolute) {
1046        Ok(metadata) => metadata,
1047        Err(err)
1048            if matches!(
1049                err.kind(),
1050                std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
1051            ) =>
1052        {
1053            return Ok(None);
1054        }
1055        Err(err) => return Err(err.into()),
1056    };
1057    let file_type = metadata.file_type();
1058
1059    if sley_index::is_gitlink(expected_mode) {
1060        if !file_type.is_dir() {
1061            return Ok(Some(TrackedEntry {
1062                mode: worktree_entry_mode(&metadata),
1063                oid: ObjectId::null(format),
1064            }));
1065        }
1066        let oid = sley_diff_merge::gitlink_head_oid(&absolute, format).unwrap_or(index_entry.oid);
1067        return Ok(Some(TrackedEntry {
1068            mode: sley_index::GITLINK_MODE,
1069            oid,
1070        }));
1071    }
1072
1073    if file_type.is_dir() {
1074        if expected_mode != 0o040000 {
1075            return Ok(None);
1076        }
1077        return Ok(Some(TrackedEntry {
1078            mode: worktree_entry_mode(&metadata),
1079            oid: ObjectId::null(format),
1080        }));
1081    }
1082
1083    if !(file_type.is_file() || file_type.is_symlink()) {
1084        return Ok(Some(TrackedEntry {
1085            mode: worktree_entry_mode(&metadata),
1086            oid: ObjectId::null(format),
1087        }));
1088    }
1089
1090    if let Some(tracked) = stat_cache.reuse_index_entry(index_entry, &metadata) {
1091        return Ok(Some(tracked));
1092    }
1093
1094    let mode = worktree_entry_mode(&metadata);
1095    let body = if file_type.is_symlink() {
1096        symlink_target_bytes(&absolute)?
1097    } else {
1098        let body = fs::read(&absolute)?;
1099        let clean_filter = tracked_only_clean_filter(clean_filter, worktree_root, git_dir);
1100        clean_filter.read_attributes_for_path(worktree_root, git_path)?;
1101        let checks =
1102            clean_filter
1103                .matcher
1104                .attributes_for_path(git_path, &clean_filter.requested, false);
1105        let clean =
1106            apply_clean_filter_with_attributes(&clean_filter.config, &checks, git_path, &body)?;
1107        let oid = clean_filtered_oid_for_status(
1108            format,
1109            &body,
1110            clean,
1111            index_entry.oid,
1112            index_entry.size,
1113            &metadata,
1114        )?;
1115        return Ok(Some(TrackedEntry { mode, oid }));
1116    };
1117    let oid = EncodedObject::new(ObjectType::Blob, body).object_id(format)?;
1118    Ok(Some(TrackedEntry { mode, oid }))
1119}
1120
1121pub(crate) fn worktree_entry_for_index_entry_ref_with_attributes(
1122    worktree_root: &Path,
1123    git_dir: &Path,
1124    format: ObjectFormat,
1125    index_entry: &IndexEntryRef<'_>,
1126    stat_cache: &IndexStatCache,
1127    clean_filter: &mut Option<TrackedOnlyCleanFilter>,
1128) -> Result<Option<TrackedEntry>> {
1129    let git_path = index_entry.path;
1130    let expected_mode = index_entry.mode;
1131    if git_path_has_symlink_parent(worktree_root, git_path)? {
1132        return Ok(None);
1133    }
1134    let absolute = worktree_root.join(repo_path_to_os_path(git_path)?);
1135    let metadata = match fs::symlink_metadata(&absolute) {
1136        Ok(metadata) => metadata,
1137        Err(err)
1138            if matches!(
1139                err.kind(),
1140                std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
1141            ) =>
1142        {
1143            return Ok(None);
1144        }
1145        Err(err) => return Err(err.into()),
1146    };
1147    let file_type = metadata.file_type();
1148
1149    if sley_index::is_gitlink(expected_mode) {
1150        if !file_type.is_dir() {
1151            return Ok(Some(TrackedEntry {
1152                mode: worktree_entry_mode(&metadata),
1153                oid: ObjectId::null(format),
1154            }));
1155        }
1156        let oid = sley_diff_merge::gitlink_head_oid(&absolute, format).unwrap_or(index_entry.oid);
1157        return Ok(Some(TrackedEntry {
1158            mode: sley_index::GITLINK_MODE,
1159            oid,
1160        }));
1161    }
1162
1163    if file_type.is_dir() {
1164        if expected_mode != 0o040000 {
1165            return Ok(None);
1166        }
1167        return Ok(Some(TrackedEntry {
1168            mode: worktree_entry_mode(&metadata),
1169            oid: ObjectId::null(format),
1170        }));
1171    }
1172
1173    if !(file_type.is_file() || file_type.is_symlink()) {
1174        return Ok(Some(TrackedEntry {
1175            mode: worktree_entry_mode(&metadata),
1176            oid: ObjectId::null(format),
1177        }));
1178    }
1179
1180    if let Some(tracked) = stat_cache.reuse_index_entry_ref(index_entry, &metadata) {
1181        return Ok(Some(tracked));
1182    }
1183
1184    let mode = worktree_entry_mode(&metadata);
1185    let body = if file_type.is_symlink() {
1186        symlink_target_bytes(&absolute)?
1187    } else {
1188        let body = fs::read(&absolute)?;
1189        let clean_filter = tracked_only_clean_filter(clean_filter, worktree_root, git_dir);
1190        clean_filter.read_attributes_for_path(worktree_root, git_path)?;
1191        let checks =
1192            clean_filter
1193                .matcher
1194                .attributes_for_path(git_path, &clean_filter.requested, false);
1195        let clean =
1196            apply_clean_filter_with_attributes(&clean_filter.config, &checks, git_path, &body)?;
1197        let oid = clean_filtered_oid_for_status(
1198            format,
1199            &body,
1200            clean,
1201            index_entry.oid,
1202            index_entry.size,
1203            &metadata,
1204        )?;
1205        return Ok(Some(TrackedEntry { mode, oid }));
1206    };
1207    let oid = EncodedObject::new(ObjectType::Blob, body).object_id(format)?;
1208    Ok(Some(TrackedEntry { mode, oid }))
1209}
1210
1211pub(crate) fn clean_filtered_oid_for_status(
1212    format: ObjectFormat,
1213    raw_body: &[u8],
1214    clean_body: Vec<u8>,
1215    index_oid: ObjectId,
1216    index_size: u32,
1217    metadata: &fs::Metadata,
1218) -> Result<ObjectId> {
1219    let clean_oid = EncodedObject::new(ObjectType::Blob, clean_body).object_id(format)?;
1220    let metadata_size = index_size_from_metadata(metadata);
1221    if clean_oid == index_oid && index_size != 0 && index_size != metadata_size {
1222        return EncodedObject::new(ObjectType::Blob, raw_body.to_vec()).object_id(format);
1223    }
1224    Ok(clean_oid)
1225}
1226
1227#[derive(Default)]
1228pub struct StatCleanFilterValidator {
1229    clean_filter: Option<TrackedOnlyCleanFilter>,
1230}
1231
1232impl StatCleanFilterValidator {
1233    pub fn new() -> Self {
1234        Self::default()
1235    }
1236
1237    pub fn validate_path(
1238        &mut self,
1239        worktree_root: &Path,
1240        git_dir: &Path,
1241        format: ObjectFormat,
1242        _index_mode: u32,
1243        index_oid: ObjectId,
1244        _index_size: u32,
1245        git_path: &[u8],
1246        absolute_path: &Path,
1247        metadata: &fs::Metadata,
1248    ) -> Result<Option<sley_diff_merge::IndexWorktreeValidatedEntry>> {
1249        if !metadata.is_file() {
1250            return Ok(None);
1251        }
1252        let clean_filter =
1253            tracked_only_clean_filter(&mut self.clean_filter, worktree_root, git_dir);
1254        clean_filter.read_attributes_for_path(worktree_root, git_path)?;
1255        let checks =
1256            clean_filter
1257                .matcher
1258                .attributes_for_path(git_path, &clean_filter.requested, false);
1259        let body = fs::read(absolute_path)?;
1260        if !clean_encoding_needs_stat_match_validation(&clean_filter.config, &checks) {
1261            let clean =
1262                apply_clean_filter_with_attributes(&clean_filter.config, &checks, git_path, &body)?;
1263            let oid = EncodedObject::new(ObjectType::Blob, clean).object_id(format)?;
1264            let oid = clean_or_raw_oid_for_index(format, &body, oid, index_oid)?;
1265            return Ok(Some(sley_diff_merge::IndexWorktreeValidatedEntry {
1266                mode: worktree_entry_mode(metadata),
1267                oid,
1268            }));
1269        }
1270        validate_clean_encoding_for_stat_match(&clean_filter.config, &checks, git_path, &body)?;
1271        let clean =
1272            apply_clean_filter_with_attributes(&clean_filter.config, &checks, git_path, &body)?;
1273        let oid = EncodedObject::new(ObjectType::Blob, clean).object_id(format)?;
1274        let oid = clean_or_raw_oid_for_index(format, &body, oid, index_oid)?;
1275        Ok(Some(sley_diff_merge::IndexWorktreeValidatedEntry {
1276            mode: worktree_entry_mode(metadata),
1277            oid,
1278        }))
1279    }
1280}
1281
1282fn clean_or_raw_oid_for_index(
1283    format: ObjectFormat,
1284    raw_body: &[u8],
1285    clean_oid: ObjectId,
1286    index_oid: ObjectId,
1287) -> Result<ObjectId> {
1288    if clean_oid == index_oid {
1289        return Ok(clean_oid);
1290    }
1291    let raw_oid = EncodedObject::new(ObjectType::Blob, raw_body.to_vec()).object_id(format)?;
1292    if raw_oid == index_oid {
1293        Ok(raw_oid)
1294    } else {
1295        Ok(clean_oid)
1296    }
1297}
1298
1299pub(crate) struct TrackedOnlyCleanFilter {
1300    pub(crate) config: GitConfig,
1301    pub(crate) matcher: AttributeMatcher,
1302    pub(crate) requested: Vec<Vec<u8>>,
1303    pub(crate) attribute_dirs: BTreeSet<Vec<u8>>,
1304}
1305
1306impl TrackedOnlyCleanFilter {
1307    pub(crate) fn read_attributes_for_path(
1308        &mut self,
1309        worktree_root: &Path,
1310        git_path: &[u8],
1311    ) -> Result<()> {
1312        self.read_attribute_dir(worktree_root, &[])?;
1313        let mut prefix = Vec::new();
1314        let mut parts = git_path.split(|byte| *byte == b'/').peekable();
1315        while let Some(part) = parts.next() {
1316            if parts.peek().is_none() {
1317                break;
1318            }
1319            if !prefix.is_empty() {
1320                prefix.push(b'/');
1321            }
1322            prefix.extend_from_slice(part);
1323            self.read_attribute_dir(worktree_root, &prefix)?;
1324        }
1325        Ok(())
1326    }
1327
1328    fn read_attribute_dir(&mut self, worktree_root: &Path, git_path: &[u8]) -> Result<()> {
1329        if !self.attribute_dirs.insert(git_path.to_vec()) {
1330            return Ok(());
1331        }
1332        let dir = if git_path.is_empty() {
1333            worktree_root.to_path_buf()
1334        } else {
1335            worktree_root.join(repo_path_to_os_path(git_path)?)
1336        };
1337        read_dir_attribute_patterns(worktree_root, &dir, &mut self.matcher)
1338    }
1339}
1340
1341pub(crate) fn tracked_only_clean_filter<'a>(
1342    clean_filter: &'a mut Option<TrackedOnlyCleanFilter>,
1343    worktree_root: &Path,
1344    git_dir: &Path,
1345) -> &'a mut TrackedOnlyCleanFilter {
1346    if clean_filter.is_none() {
1347        *clean_filter = Some(TrackedOnlyCleanFilter {
1348            config: sley_config::read_repo_config(git_dir, None).unwrap_or_default(),
1349            matcher: AttributeMatcher::from_worktree_base(worktree_root),
1350            requested: filter_attribute_names(),
1351            attribute_dirs: BTreeSet::new(),
1352        });
1353    }
1354    clean_filter
1355        .as_mut()
1356        .expect("tracked-only clean filter initialized")
1357}
1358
1359pub(crate) fn tracked_only_clean_filter_with_config<'a>(
1360    clean_filter: &'a mut Option<TrackedOnlyCleanFilter>,
1361    worktree_root: &Path,
1362    config: &GitConfig,
1363) -> &'a mut TrackedOnlyCleanFilter {
1364    if clean_filter.is_none() {
1365        *clean_filter = Some(TrackedOnlyCleanFilter {
1366            config: config.clone(),
1367            matcher: AttributeMatcher::from_worktree_base(worktree_root),
1368            requested: filter_attribute_names(),
1369            attribute_dirs: BTreeSet::new(),
1370        });
1371    }
1372    clean_filter
1373        .as_mut()
1374        .expect("tracked-only clean filter initialized")
1375}
1376
1377pub(crate) struct WorktreeEntriesWalk<'a> {
1378    git_dir: &'a Path,
1379    format: ObjectFormat,
1380    config: &'a GitConfig,
1381    matcher: &'a mut AttributeMatcher,
1382    requested: &'a [Vec<u8>],
1383    stat_cache: Option<&'a IndexStatCache>,
1384    known_tracked_paths: Option<&'a BTreeSet<Vec<u8>>>,
1385    tracked_paths: Option<&'a BTreeSet<Vec<u8>>>,
1386    ignores: Option<&'a mut IgnoreMatcher>,
1387    entries: &'a mut BTreeMap<Vec<u8>, TrackedEntry>,
1388    /// Dirt masks for tracked gitlink paths whose submodule worktree is dirty.
1389    submodule_dirt: &'a mut BTreeMap<Vec<u8>, u8>,
1390    tracked_presence: &'a mut HashSet<Vec<u8>>,
1391    record_clean_tracked: bool,
1392}
1393
1394impl WorktreeEntriesWalk<'_> {
1395    fn mark_tracked_present(&mut self, git_path: &[u8]) {
1396        self.tracked_presence.insert(git_path.to_vec());
1397    }
1398
1399    fn tracked_entry_for(&self, git_path: &[u8]) -> Option<TrackedEntry> {
1400        self.stat_cache
1401            .and_then(|cache| cache.tracked_entry(git_path))
1402    }
1403
1404    fn should_record_tracked_entry(&self, git_path: &[u8], entry: &TrackedEntry) -> bool {
1405        self.record_clean_tracked
1406            || self.is_intent_to_add(git_path)
1407            || self
1408                .tracked_entry_for(git_path)
1409                .is_none_or(|tracked| tracked != *entry)
1410    }
1411
1412    /// An intent-to-add (`git add -N`) entry must always be recorded into the
1413    /// worktree snapshot: even when its bytes match the empty-blob placeholder,
1414    /// status reports it as worktree-added (`.A`), so it can never be folded
1415    /// away as a clean match.
1416    fn is_intent_to_add(&self, git_path: &[u8]) -> bool {
1417        self.stat_cache
1418            .and_then(|cache| cache.index_entry(git_path))
1419            .is_some_and(IndexEntry::is_intent_to_add)
1420    }
1421}
1422
1423pub(crate) fn git_path_append_component(parent: &[u8], component: &std::ffi::OsStr) -> Vec<u8> {
1424    let component = os_str_component_bytes(component);
1425    let separator = usize::from(!parent.is_empty());
1426    let mut path = Vec::with_capacity(parent.len() + separator + component.len());
1427    if !parent.is_empty() {
1428        path.extend_from_slice(parent);
1429        path.push(b'/');
1430    }
1431    path.extend_from_slice(component.as_ref());
1432    path
1433}
1434
1435pub(crate) fn git_path_push_component(path: &mut Vec<u8>, component: &std::ffi::OsStr) -> usize {
1436    let original_len = path.len();
1437    let component = os_str_component_bytes(component);
1438    if !path.is_empty() {
1439        path.push(b'/');
1440    }
1441    path.extend_from_slice(component.as_ref());
1442    original_len
1443}
1444
1445#[cfg(unix)]
1446pub(crate) fn os_str_component_bytes(component: &std::ffi::OsStr) -> Cow<'_, [u8]> {
1447    use std::os::unix::ffi::OsStrExt;
1448
1449    Cow::Borrowed(component.as_bytes())
1450}
1451
1452#[cfg(not(unix))]
1453pub(crate) fn os_str_component_bytes(component: &std::ffi::OsStr) -> Cow<'_, [u8]> {
1454    Cow::Owned(component.to_string_lossy().into_owned().into_bytes())
1455}
1456
1457pub(crate) fn collect_worktree_entries(
1458    context: &mut WorktreeEntriesWalk<'_>,
1459    dir: &Path,
1460    dir_git_path: &[u8],
1461) -> Result<()> {
1462    if is_same_path(dir, context.git_dir) {
1463        return Ok(());
1464    }
1465    // Fold this directory's `.gitattributes` into the matcher before processing its
1466    // files, so lookups for files here (and below) see it. This is what lets the
1467    // walk read the tree once instead of doing a separate full-tree attribute pass.
1468    read_dir_attribute_patterns_for_base(dir, dir_git_path, context.matcher)?;
1469    if let Some(ignores) = context.ignores.as_deref_mut() {
1470        read_dir_ignore_patterns_for_base(dir, dir_git_path, ignores)?;
1471    }
1472    let mut dir_entries = fs::read_dir(dir)?.collect::<std::result::Result<Vec<_>, _>>()?;
1473    dir_entries.sort_by_key(|entry| entry.file_name());
1474    for entry in dir_entries {
1475        let file_name = entry.file_name();
1476        let path = entry.path();
1477        if is_dot_git_entry(&path) {
1478            continue;
1479        }
1480        if is_same_path(&path, context.git_dir) {
1481            continue;
1482        }
1483        let metadata = entry.metadata()?;
1484        let git_path = git_path_append_component(dir_git_path, &file_name);
1485        if context
1486            .ignores
1487            .as_ref()
1488            .is_some_and(|ignores| ignores.is_ignored(&git_path, metadata.is_dir()))
1489        {
1490            let tracked = context.known_tracked_paths.is_some_and(|tracked_paths| {
1491                if metadata.is_dir() {
1492                    tracked_paths_may_contain(tracked_paths, &git_path)
1493                } else {
1494                    tracked_paths.contains(&git_path)
1495                }
1496            });
1497            if !tracked {
1498                continue;
1499            }
1500            if metadata.is_dir() {
1501                collect_worktree_entries(context, &path, &git_path)?;
1502                continue;
1503            }
1504        }
1505        if metadata.is_dir() {
1506            // A directory staged as a gitlink (mode 160000) is opaque: the walk
1507            // never descends into it. Its worktree "content" is the commit the
1508            // embedded repository has checked out (upstream ce_compare_gitlink):
1509            // a populated submodule reports its HEAD (plus a dirt mask when its
1510            // own tree has modified/untracked content); an unpopulated
1511            // directory — no repository, or no commit checked out — always
1512            // matches the staged oid.
1513            if let Some(index_entry) = context
1514                .stat_cache
1515                .and_then(|cache| cache.gitlink_entry(&git_path))
1516            {
1517                context.mark_tracked_present(&git_path);
1518                let oid = sley_diff_merge::gitlink_head_oid(&path, context.format)
1519                    .unwrap_or(index_entry.oid);
1520                let dirt = submodule_dirt_checked(&path)?;
1521                if dirt != 0 {
1522                    context.submodule_dirt.insert(git_path.clone(), dirt);
1523                }
1524                let tracked = TrackedEntry {
1525                    mode: sley_index::GITLINK_MODE,
1526                    oid,
1527                };
1528                if dirt != 0 || context.should_record_tracked_entry(&git_path, &tracked) {
1529                    context.entries.insert(git_path, tracked);
1530                }
1531                continue;
1532            }
1533            if is_nested_repository_boundary(&path, context.git_dir) {
1534                if let Some(tracked_paths) = context.tracked_paths
1535                    && !tracked_paths_may_contain(tracked_paths, &git_path)
1536                {
1537                    continue;
1538                }
1539                context.entries.insert(
1540                    git_path,
1541                    TrackedEntry {
1542                        mode: 0o040000,
1543                        oid: ObjectId::null(context.format),
1544                    },
1545                );
1546                continue;
1547            }
1548            if let Some(tracked_paths) = context.tracked_paths
1549                && !tracked_paths_may_contain(tracked_paths, &git_path)
1550            {
1551                continue;
1552            }
1553            collect_worktree_entries(context, &path, &git_path)?;
1554        } else if metadata.is_file() || metadata.file_type().is_symlink() {
1555            if let Some(tracked_paths) = context.tracked_paths
1556                && !tracked_paths.contains(&git_path)
1557            {
1558                continue;
1559            }
1560            let entry_mode = worktree_entry_mode(&metadata);
1561            // git's racy-git stat shortcut: when the index's cached stat proves
1562            // this file is unchanged since it was staged, reuse the staged oid
1563            // and skip the read+filter+hash entirely. `reuse_tracked_entry`
1564            // returns `Some` ONLY for a non-racy size+mtime+mode match, so a
1565            // modified file always falls through to the full hash below and is
1566            // never silently reported clean.
1567            if let Some(tracked) = context
1568                .stat_cache
1569                .and_then(|cache| cache.reuse_tracked_entry(&git_path, &metadata))
1570            {
1571                context.mark_tracked_present(&git_path);
1572                if context.record_clean_tracked || context.is_intent_to_add(&git_path) {
1573                    context.entries.insert(git_path, tracked);
1574                }
1575                continue;
1576            }
1577            // A file absent from the index is untracked: status and the
1578            // index-vs-worktree diff report it by *presence* (`??` / nothing), never
1579            // by content, so computing its oid is wasted work — git never hashes
1580            // untracked files. Record presence with a null oid and skip the
1581            // read+filter+hash. Without a stat cache we cannot tell tracked from
1582            // untracked, so fall through and hash as before.
1583            if context
1584                .stat_cache
1585                .is_some_and(|cache| !cache.contains(&git_path))
1586            {
1587                context.entries.insert(
1588                    git_path,
1589                    TrackedEntry {
1590                        mode: entry_mode,
1591                        oid: ObjectId::null(context.format),
1592                    },
1593                );
1594                continue;
1595            }
1596            let body = if metadata.file_type().is_symlink() {
1597                // The blob for a symlink is the raw link target; clean filters
1598                // never apply because git treats symlink content as opaque.
1599                symlink_target_bytes(&path)?
1600            } else {
1601                let body = fs::read(&path)?;
1602                // Resolve this path's attributes against the prebuilt matcher (a cheap
1603                // pattern match) and apply the clean filter -- no per-file matcher
1604                // rebuild. With no attributes/autocrlf configured this is an exact
1605                // passthrough, so the stored OID is unchanged.
1606                let checks =
1607                    context
1608                        .matcher
1609                        .attributes_for_path(&git_path, context.requested, false);
1610                let clean =
1611                    apply_clean_filter_with_attributes(context.config, &checks, &git_path, &body)?;
1612                let oid = match context
1613                    .stat_cache
1614                    .and_then(|cache| cache.index_entry(&git_path))
1615                {
1616                    Some(index_entry) => clean_filtered_oid_for_status(
1617                        context.format,
1618                        &body,
1619                        clean,
1620                        index_entry.oid,
1621                        index_entry.size,
1622                        &metadata,
1623                    )?,
1624                    None => {
1625                        EncodedObject::new(ObjectType::Blob, clean).object_id(context.format)?
1626                    }
1627                };
1628                let tracked = TrackedEntry {
1629                    mode: entry_mode,
1630                    oid,
1631                };
1632                if context
1633                    .stat_cache
1634                    .is_some_and(|cache| cache.contains(&git_path))
1635                {
1636                    context.mark_tracked_present(&git_path);
1637                    if context.should_record_tracked_entry(&git_path, &tracked) {
1638                        context.entries.insert(git_path, tracked);
1639                    }
1640                } else {
1641                    context.entries.insert(git_path, tracked);
1642                }
1643                continue;
1644            };
1645            let oid = EncodedObject::new(ObjectType::Blob, body).object_id(context.format)?;
1646            let tracked = TrackedEntry {
1647                mode: entry_mode,
1648                oid,
1649            };
1650            if context
1651                .stat_cache
1652                .is_some_and(|cache| cache.contains(&git_path))
1653            {
1654                context.mark_tracked_present(&git_path);
1655                if context.should_record_tracked_entry(&git_path, &tracked) {
1656                    context.entries.insert(git_path, tracked);
1657                }
1658            } else {
1659                context.entries.insert(git_path, tracked);
1660            }
1661        }
1662    }
1663    Ok(())
1664}
1665
1666pub(crate) fn tracked_paths_may_contain(
1667    tracked_paths: &BTreeSet<Vec<u8>>,
1668    directory: &[u8],
1669) -> bool {
1670    if tracked_paths.contains(directory) {
1671        return true;
1672    }
1673    let mut prefix = Vec::with_capacity(directory.len() + 1);
1674    prefix.extend_from_slice(directory);
1675    prefix.push(b'/');
1676    tracked_paths
1677        .range::<[u8], _>((
1678            std::ops::Bound::Included(prefix.as_slice()),
1679            std::ops::Bound::Unbounded,
1680        ))
1681        .next()
1682        .is_some_and(|path| path.starts_with(&prefix))
1683}
1684
1685pub(crate) fn is_same_path(left: &Path, right: &Path) -> bool {
1686    left == right
1687}
1688
1689/// Whether `path`'s final component is `.git`. Git never lists a `.git` entry at
1690/// any depth (a repository's own `.git`, a submodule gitlink file, or an embedded
1691/// repository's `.git` directory) as untracked content.
1692pub(crate) fn is_dot_git_entry(path: &Path) -> bool {
1693    path.file_name() == Some(std::ffi::OsStr::new(".git"))
1694}
1695
1696/// Whether `path` is a directory containing an embedded repository's `.git`
1697/// *directory*, or a `.git` file whose `gitdir:` pointer resolves to an
1698/// existing directory (a submodule worktree). Git treats both as a repository
1699/// boundary (listing the directory as `dir/`); an *invalid* `.git` file (no
1700/// resolvable `gitdir:` target) is not a boundary — Git descends into the
1701/// directory and lists its other untracked contents normally.
1702pub(crate) fn is_nested_repository_boundary(path: &Path, git_dir: &Path) -> bool {
1703    let dot_git = path.join(".git");
1704    if dot_git.is_dir() {
1705        if is_same_path(&dot_git, git_dir) {
1706            return false;
1707        }
1708        return true;
1709    }
1710    sley_diff_merge::gitlink_git_dir(path).is_some_and(|embedded| !is_same_path(&embedded, git_dir))
1711}
1712
1713pub(crate) fn active_repository_worktree_dir(path: &Path, git_dir: &Path) -> bool {
1714    sley_diff_merge::gitlink_git_dir(path).is_some_and(|embedded| is_same_path(&embedded, git_dir))
1715}
1716
1717/// Whether `path` is an embedded repository's `.git` directory or a path inside it.
1718pub(crate) fn is_embedded_git_internals(root: &Path, path: &Path) -> bool {
1719    let Ok(relative) = path.strip_prefix(root) else {
1720        return false;
1721    };
1722    let mut current = root.to_path_buf();
1723    for component in relative.components() {
1724        if matches!(component, std::path::Component::Normal(name) if name == ".git")
1725            && current != root
1726            && current.join(".git").is_dir()
1727        {
1728            return true;
1729        }
1730        current.push(component);
1731    }
1732    false
1733}
1734
1735pub(crate) fn git_path_has_symlink_parent(worktree_root: &Path, git_path: &[u8]) -> Result<bool> {
1736    let text =
1737        std::str::from_utf8(git_path).map_err(|err| GitError::InvalidPath(err.to_string()))?;
1738    let mut current = worktree_root.to_path_buf();
1739    let mut components = text.split('/').peekable();
1740    while let Some(component) = components.next() {
1741        if components.peek().is_none() {
1742            break;
1743        }
1744        current.push(component);
1745        let metadata = match fs::symlink_metadata(&current) {
1746            Ok(metadata) => metadata,
1747            Err(err)
1748                if matches!(
1749                    err.kind(),
1750                    std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
1751                ) =>
1752            {
1753                return Ok(false);
1754            }
1755            Err(err) => return Err(err.into()),
1756        };
1757        if metadata.file_type().is_symlink() {
1758            return Ok(true);
1759        }
1760    }
1761    Ok(false)
1762}
1763
1764pub(crate) fn worktree_entry_mode(metadata: &fs::Metadata) -> u32 {
1765    if metadata.file_type().is_symlink() {
1766        0o120000
1767    } else if metadata.is_dir() {
1768        0o040000
1769    } else {
1770        file_mode(metadata)
1771    }
1772}
1773
1774pub(crate) fn worktree_path(root: &Path, path: &[u8]) -> Result<PathBuf> {
1775    let text = std::str::from_utf8(path).map_err(|err| GitError::InvalidPath(err.to_string()))?;
1776    let relative = PathBuf::from(text);
1777    if relative.is_absolute()
1778        || relative.components().any(|component| {
1779            matches!(
1780                component,
1781                std::path::Component::ParentDir | std::path::Component::Prefix(_)
1782            )
1783        })
1784    {
1785        return Err(GitError::InvalidPath(format!(
1786            "invalid worktree path {text}"
1787        )));
1788    }
1789    Ok(root.join(relative))
1790}
1791
1792pub(crate) fn remove_worktree_file(root: &Path, path: &[u8]) -> Result<()> {
1793    let file = worktree_path(root, path)?;
1794    if !file.exists() {
1795        return Ok(());
1796    }
1797    if file.is_dir() {
1798        // A tracked path that is a directory on disk is a gitlink: upstream
1799        // checkout/reset never recurses into a submodule's working tree. It
1800        // rmdirs the path when empty (remove_scheduled_dirs) and leaves a
1801        // populated submodule in place.
1802        match fs::remove_dir(&file) {
1803            Ok(()) => prune_empty_parents(root, file.parent())?,
1804            Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => {}
1805            Err(err) => return Err(err.into()),
1806        }
1807        return Ok(());
1808    }
1809    fs::remove_file(&file)?;
1810    prune_empty_parents(root, file.parent())?;
1811    Ok(())
1812}
1813
1814pub(crate) fn prune_empty_parents(root: &Path, mut dir: Option<&Path>) -> Result<()> {
1815    while let Some(path) = dir {
1816        if path == root || path_is_original_cwd(path) {
1817            break;
1818        }
1819        match fs::remove_dir(path) {
1820            Ok(()) => dir = path.parent(),
1821            Err(err) if err.kind() == std::io::ErrorKind::NotFound => dir = path.parent(),
1822            Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => break,
1823            Err(err) => return Err(err.into()),
1824        }
1825    }
1826    Ok(())
1827}
1828
1829pub(crate) fn original_cwd_absolute() -> Option<PathBuf> {
1830    let cwd = sley_core::original_cwd().or_else(|| env::current_dir().ok())?;
1831    Some(fs::canonicalize(&cwd).unwrap_or(cwd))
1832}
1833
1834pub(crate) fn path_is_original_cwd(path: &Path) -> bool {
1835    let Some(cwd) = original_cwd_absolute() else {
1836        return false;
1837    };
1838    let path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1839    path == cwd
1840}
1841
1842pub(crate) fn original_cwd_is_inside(path: &Path) -> bool {
1843    let Some(cwd) = original_cwd_absolute() else {
1844        return false;
1845    };
1846    let path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1847    cwd == path || cwd.starts_with(&path)
1848}
1849
1850pub(crate) fn refuse_if_current_working_directory_becomes_file(
1851    worktree_root: &Path,
1852    target_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1853) -> Result<()> {
1854    for (path, entry) in target_entries {
1855        if sley_index::is_gitlink(entry.mode) || (entry.mode & 0o170000) == 0o040000 {
1856            continue;
1857        }
1858        let path = worktree_path(worktree_root, path)?;
1859        if path_is_original_cwd(&path)
1860            && fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.is_dir())
1861        {
1862            return refuse_remove_current_working_directory(&path);
1863        }
1864    }
1865    Ok(())
1866}
1867
1868pub(crate) fn refuse_remove_current_working_directory(path: &Path) -> Result<()> {
1869    eprintln!(
1870        "error: Refusing to remove the current working directory:\n{}",
1871        path.display()
1872    );
1873    Err(GitError::Exit(128))
1874}
1875
1876pub(crate) fn git_tree_entry_cmp(
1877    left_name: &[u8],
1878    left_mode: u32,
1879    right_name: &[u8],
1880    right_mode: u32,
1881) -> Ordering {
1882    let shared = left_name.len().min(right_name.len());
1883    let name_order = left_name[..shared].cmp(&right_name[..shared]);
1884    if name_order != Ordering::Equal {
1885        return name_order;
1886    }
1887    let left_end = left_name.len() == shared;
1888    let right_end = right_name.len() == shared;
1889    match (left_end, right_end) {
1890        (true, true) => Ordering::Equal,
1891        (true, false) => tree_name_terminator(left_mode).cmp(&right_name[shared]),
1892        (false, true) => left_name[shared].cmp(&tree_name_terminator(right_mode)),
1893        (false, false) => Ordering::Equal,
1894    }
1895}
1896
1897pub(crate) fn tree_name_terminator(mode: u32) -> u8 {
1898    if mode == 0o040000 { b'/' } else { 0 }
1899}
1900
1901#[cfg(unix)]
1902pub(crate) fn file_mode(metadata: &fs::Metadata) -> u32 {
1903    use std::os::unix::fs::PermissionsExt;
1904    if metadata.permissions().mode() & 0o111 != 0 {
1905        0o100755
1906    } else {
1907        0o100644
1908    }
1909}
1910
1911#[cfg(not(unix))]
1912pub(crate) fn file_mode(_metadata: &fs::Metadata) -> u32 {
1913    0o100644
1914}
1915
1916/// The blob content git stores for a symlink: the raw bytes of the link target
1917/// exactly as `readlink(2)` returns them. On Unix the target is an opaque byte
1918/// string, so we take the `OsStr` bytes verbatim (no UTF-8 round-trip, no path
1919/// re-componentization that could rewrite separators).
1920#[cfg(unix)]
1921pub(crate) fn symlink_target_bytes(path: &Path) -> Result<Vec<u8>> {
1922    use std::os::unix::ffi::OsStrExt;
1923    let target = fs::read_link(path)?;
1924    Ok(target.as_os_str().as_bytes().to_vec())
1925}
1926
1927#[cfg(not(unix))]
1928pub(crate) fn symlink_target_bytes(path: &Path) -> Result<Vec<u8>> {
1929    let target = fs::read_link(path)?;
1930    // git normalizes symlink targets to forward slashes on platforms whose
1931    // native separator is `\`.
1932    Ok(target.to_string_lossy().replace('\\', "/").into_bytes())
1933}
1934
1935pub(crate) fn git_path_bytes(path: &Path) -> Result<Vec<u8>> {
1936    if path.components().any(|component| {
1937        matches!(
1938            component,
1939            std::path::Component::ParentDir | std::path::Component::Prefix(_)
1940        )
1941    }) {
1942        return Err(GitError::InvalidPath(format!(
1943            "invalid index path {}",
1944            path.display()
1945        )));
1946    }
1947    Ok(path
1948        .components()
1949        .filter_map(|component| match component {
1950            std::path::Component::Normal(value) => Some(value.to_string_lossy().into_owned()),
1951            _ => None,
1952        })
1953        .collect::<Vec<_>>()
1954        .join("/")
1955        .into_bytes())
1956}
1957
1958pub(crate) fn normalize_absolute_path_lexically(path: &Path) -> PathBuf {
1959    let mut normalized = PathBuf::new();
1960    for component in path.components() {
1961        match component {
1962            std::path::Component::CurDir => {}
1963            std::path::Component::ParentDir => {
1964                normalized.pop();
1965            }
1966            std::path::Component::Normal(_)
1967            | std::path::Component::RootDir
1968            | std::path::Component::Prefix(_) => normalized.push(component.as_os_str()),
1969        }
1970    }
1971    normalized
1972}
1973
1974pub(crate) fn absolute_path_lexically(path: &Path, cwd: &Path) -> PathBuf {
1975    if path.is_absolute() {
1976        normalize_absolute_path_lexically(path)
1977    } else {
1978        normalize_absolute_path_lexically(&cwd.join(path))
1979    }
1980}
1981
1982pub(crate) fn repo_path_to_os_path(path: &[u8]) -> Result<PathBuf> {
1983    #[cfg(unix)]
1984    {
1985        use std::os::unix::ffi::OsStrExt;
1986
1987        Ok(PathBuf::from(std::ffi::OsStr::from_bytes(path)))
1988    }
1989
1990    #[cfg(not(unix))]
1991    {
1992        let path = std::str::from_utf8(path)
1993            .map_err(|_| GitError::InvalidPath("index path is not utf8".into()))?;
1994        Ok(path.split('/').collect())
1995    }
1996}
1997
1998pub(crate) fn git_path_to_relative_path(path: &[u8]) -> Result<PathBuf> {
1999    let path = std::str::from_utf8(path)
2000        .map_err(|err| GitError::InvalidPath(format!("invalid utf-8 index path: {err}")))?;
2001    Ok(path.split('/').collect())
2002}
2003
2004pub(crate) fn path_has_trailing_separator(path: &Path) -> bool {
2005    path.as_os_str()
2006        .to_string_lossy()
2007        .ends_with(std::path::MAIN_SEPARATOR)
2008}