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