Skip to main content

grit_lib/index/
core.rs

1//! Git index (staging area) reading and writing.
2//!
3//! The index file (`.git/index`) stores the current state of the staging area.
4//! It uses a binary format with a 12-byte header, fixed-size index entries,
5//! and optional extensions, followed by a trailing SHA-1 over the whole file.
6//!
7//! # Format version
8//!
9//! This implementation supports index versions 2 and 3. Requests for version 4
10//! currently fall back to a non-compressed index on write because path
11//! compression is not yet implemented.
12//!
13//! # References
14//!
15//! See `Documentation/technical/index-format.txt` in the Git source tree for
16//! the authoritative format specification.
17
18use std::borrow::Cow;
19use std::collections::{BTreeMap, BTreeSet, HashMap};
20use std::fs;
21use std::io::{self, Write};
22use std::path::{Path, PathBuf};
23
24use crate::hash::verify_trailer;
25
26use crate::config::ConfigSet;
27use crate::error::{Error, Result};
28use crate::lockfile::pid_path_for_lock;
29use crate::objects::{parse_tree, HashAlgo, ObjectId, ObjectKind, TreeEntry};
30use crate::odb::Odb;
31use crate::repo::Repository;
32use crate::resolve_undo::{self, write_resolve_undo_payload, ResolveUndoRecord};
33use crate::rev_parse;
34use crate::untracked_cache;
35
36fn gitattributes_index_path(path: &[u8]) -> bool {
37    path == b".gitattributes" || path.ends_with(b"/.gitattributes")
38}
39
40/// File mode for a regular (non-executable) file.
41pub const MODE_REGULAR: u32 = 0o100644;
42/// File mode for an executable file.
43pub const MODE_EXECUTABLE: u32 = 0o100755;
44/// File mode for a symbolic link.
45pub const MODE_SYMLINK: u32 = 0o120000;
46/// File mode for a gitlink (submodule).
47pub const MODE_GITLINK: u32 = 0o160000;
48/// File mode for a directory (tree) entry — only used in tree objects, not index.
49pub const MODE_TREE: u32 = 0o040000;
50
51/// Git `match_stat_data` policy (`core.trustctime`, `core.checkstat`, `core.usenanosec`).
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct StatMatchPolicy {
54    /// When true, compare ctime fields when `check_stat` is enabled.
55    pub trust_ctime: bool,
56    /// When true, compare owner, inode, device, and nanoseconds (if `use_nanosec`).
57    pub check_stat: bool,
58    /// When true with `check_stat`, compare sub-second mtime/ctime fractions.
59    pub use_nanosec: bool,
60}
61
62impl Default for StatMatchPolicy {
63    fn default() -> Self {
64        Self {
65            trust_ctime: true,
66            check_stat: true,
67            use_nanosec: false,
68        }
69    }
70}
71
72impl StatMatchPolicy {
73    /// Load policy from repository config, using Git's defaults for unset keys.
74    #[must_use]
75    pub fn from_config(config: Option<&ConfigSet>) -> Self {
76        let mut policy = Self::default();
77        let Some(cfg) = config else {
78            return policy;
79        };
80        if let Some(trust) = cfg.get_bool("core.trustctime").and_then(|r| r.ok()) {
81            policy.trust_ctime = trust;
82        }
83        if let Some(raw) = cfg.get("core.checkstat") {
84            policy.check_stat = match raw.to_ascii_lowercase().as_str() {
85                "minimal" => false,
86                "default" => true,
87                other => cfg
88                    .get_bool("core.checkstat")
89                    .and_then(|r| r.ok())
90                    .unwrap_or(!other.eq_ignore_ascii_case("minimal")),
91            };
92        }
93        if let Some(use_ns) = cfg.get_bool("core.usenanosec").and_then(|r| r.ok()) {
94            policy.use_nanosec = use_ns;
95        }
96        policy
97    }
98}
99
100/// Git-compatible `(ctime, mtime)` seconds and nanoseconds from worktree metadata.
101///
102/// On Windows uses `filetime` (last write + creation times) so index stat comparison
103/// matches Git's `mingw_stat` / `match_stat_data` rather than `Metadata::modified()` alone.
104#[must_use]
105pub(crate) fn metadata_times(meta: &fs::Metadata) -> ((u32, u32), (u32, u32)) {
106    #[cfg(unix)]
107    {
108        use std::os::unix::fs::MetadataExt;
109        (
110            (meta.ctime() as u32, meta.ctime_nsec() as u32),
111            (meta.mtime() as u32, meta.mtime_nsec() as u32),
112        )
113    }
114    #[cfg(not(unix))]
115    {
116        use filetime::FileTime;
117        let mtime_ft = FileTime::from_last_modification_time(meta);
118        let ctime_ft = FileTime::from_creation_time(meta).unwrap_or(mtime_ft);
119        (
120            filetime_to_index_pair(ctime_ft),
121            filetime_to_index_pair(mtime_ft),
122        )
123    }
124}
125
126#[cfg(not(unix))]
127#[must_use]
128fn filetime_to_index_pair(ft: filetime::FileTime) -> (u32, u32) {
129    let secs = ft.unix_seconds();
130    if secs.is_negative() {
131        (0, 0)
132    } else {
133        (secs as u32, ft.nanoseconds())
134    }
135}
136
137/// Map `st_size` into the 32-bit size stored in the index (Git `munge_st_size`).
138#[must_use]
139pub fn munge_index_size(st_size: u64) -> u32 {
140    let sd_size = st_size as u32;
141    if sd_size == 0 && st_size != 0 {
142        0x8000_0000
143    } else {
144        sd_size
145    }
146}
147
148/// Whether two index entries carry the same stat fields under `policy`.
149#[must_use]
150pub fn index_entries_stat_equivalent(
151    a: &IndexEntry,
152    b: &IndexEntry,
153    policy: StatMatchPolicy,
154) -> bool {
155    IndexStatFields::of_entry(a).equivalent_to(&IndexStatFields::of_entry(b), policy)
156}
157
158/// Git index extension signature `sdir` (sparse directory entries present).
159pub(crate) const INDEX_EXT_SPARSE_DIRECTORIES: u32 = u32::from_be_bytes(*b"sdir");
160/// Git index extension signature `UNTR` (untracked cache).
161pub(crate) const INDEX_EXT_UNTRACKED: u32 = u32::from_be_bytes(*b"UNTR");
162/// Git index extension signature `FSMN` (fsmonitor).
163pub(crate) const INDEX_EXT_FSMONITOR: u32 = u32::from_be_bytes(*b"FSMN");
164/// Fsmonitor index extension header version 1 (u64 timestamp token).
165/// Index files at least this large verify their trailing checksum on a second thread while
166/// the entries parse; below it, spawning the thread costs more than the hash.
167const PARALLEL_TRAILER_VERIFY_MIN_BYTES: usize = 256 * 1024;
168
169const FSMONITOR_EXT_VERSION1: u32 = 1;
170/// Fsmonitor index extension header version 2 (NUL-terminated string token).
171const FSMONITOR_EXT_VERSION2: u32 = 2;
172/// Git index extension signature `REUC` (resolve undo).
173pub(crate) const INDEX_EXT_RESOLVE_UNDO: u32 = u32::from_be_bytes(*b"REUC");
174/// Git index extension signature `link` (split index).
175pub(crate) const INDEX_EXT_LINK: u32 = u32::from_be_bytes(*b"link");
176
177/// A single entry in the Git index.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct IndexEntry {
180    /// Time the file metadata last changed (seconds since epoch).
181    pub ctime_sec: u32,
182    /// Nanosecond fraction of `ctime_sec`.
183    pub ctime_nsec: u32,
184    /// Time the file data last changed (seconds since epoch).
185    pub mtime_sec: u32,
186    /// Nanosecond fraction of `mtime_sec`.
187    pub mtime_nsec: u32,
188    /// Device number.
189    pub dev: u32,
190    /// Inode number.
191    pub ino: u32,
192    /// Unix file mode (`MODE_REGULAR`, `MODE_EXECUTABLE`, `MODE_SYMLINK`, …).
193    pub mode: u32,
194    /// Owner UID.
195    pub uid: u32,
196    /// Owner GID.
197    pub gid: u32,
198    /// File size in bytes (truncated to 32 bits).
199    pub size: u32,
200    /// SHA-1 of the blob object.
201    pub oid: ObjectId,
202    /// Entry flags (stage, assume-valid, extended, …).
203    pub flags: u16,
204    /// Extended flags (v3+ only).
205    pub flags_extended: Option<u16>,
206    /// Path relative to the repository root.  May contain `/` separators.
207    pub path: Vec<u8>,
208    /// Split index: position in shared base (1-based), or 0 if not from shared index.
209    pub base_index_pos: u32,
210}
211
212impl IndexEntry {
213    /// Merge stage (0 = normal, 1–3 = conflict stages).
214    #[must_use]
215    pub fn stage(&self) -> u8 {
216        ((self.flags >> 12) & 0x3) as u8
217    }
218
219    /// Whether cached stat fields were never populated (e.g. index built from a tree).
220    #[must_use]
221    pub fn stat_cache_unset(&self) -> bool {
222        self.size == 0 && self.mtime_sec == 0 && self.mtime_nsec == 0
223    }
224
225    pub(crate) fn set_stage(&mut self, stage: u8) {
226        self.flags = (self.flags & 0x0FFF) | ((stage as u16 & 0x3) << 12);
227    }
228
229    /// Whether the assume-unchanged bit is set.
230    #[must_use]
231    pub fn assume_unchanged(&self) -> bool {
232        self.flags & 0x8000 != 0
233    }
234
235    /// Whether the skip-worktree bit is set (extended flags, v3+).
236    #[must_use]
237    pub fn skip_worktree(&self) -> bool {
238        self.flags_extended
239            .map(|f| f & 0x4000 != 0)
240            .unwrap_or(false)
241    }
242
243    /// Set the assume-unchanged bit.
244    pub fn set_assume_unchanged(&mut self, value: bool) {
245        if value {
246            self.flags |= 0x8000;
247        } else {
248            self.flags &= !0x8000;
249        }
250    }
251
252    /// Set the skip-worktree bit (promotes entry to v3).
253    pub fn set_skip_worktree(&mut self, value: bool) {
254        let fe = self.flags_extended.get_or_insert(0);
255        if value {
256            *fe |= 0x4000;
257        } else {
258            *fe &= !0x4000;
259            if *fe == 0 {
260                self.flags_extended = None;
261            }
262        }
263    }
264
265    /// Whether the intent-to-add bit is set (extended flags, v3+).
266    #[must_use]
267    pub fn intent_to_add(&self) -> bool {
268        self.flags_extended
269            .map(|f| f & 0x2000 != 0)
270            .unwrap_or(false)
271    }
272
273    /// Set the intent-to-add bit (promotes entry to v3).
274    pub fn set_intent_to_add(&mut self, value: bool) {
275        let fe = self.flags_extended.get_or_insert(0);
276        if value {
277            *fe |= 0x2000;
278        } else {
279            *fe &= !0x2000;
280            if *fe == 0 {
281                self.flags_extended = None;
282            }
283        }
284    }
285
286    /// Sparse-index placeholder: tree mode, stage 0, and `SKIP_WORKTREE` set.
287    #[must_use]
288    pub fn is_sparse_directory_placeholder(&self) -> bool {
289        self.mode == MODE_TREE && self.stage() == 0 && self.skip_worktree()
290    }
291
292    /// In-memory only: `ls-files --with-tree` hides stage-1 overlay rows that duplicate stage 0.
293    const FLAG_EXT_OVERLAY_TREE_SKIP: u16 = 0x8000;
294    /// In-memory and on-disk compatibility bit for fsmonitor validity (`git ls-files -f`).
295    const FLAG_EXT_FSMONITOR_VALID: u16 = 0x1000;
296    /// Extended flags Git persists in the on-disk index (`CE_EXTENDED_FLAGS` in `read-cache-ll.h`).
297    /// `CE_FSMONITOR_VALID` is in-memory only; fsmonitor state lives in the `FSMN` extension.
298    const FLAG_EXT_ON_DISK: u16 = 0x2000 | 0x4000;
299
300    /// Extended flag bits safe to write to a Git-compatible on-disk index.
301    fn disk_flags_extended(fe: u16) -> u16 {
302        fe & Self::FLAG_EXT_ON_DISK
303    }
304
305    /// Whether extended index flags request skipping overlay tree output.
306    #[must_use]
307    pub fn overlay_tree_skip_output(&self) -> bool {
308        self.flags_extended
309            .is_some_and(|fe| fe & Self::FLAG_EXT_OVERLAY_TREE_SKIP != 0)
310    }
311
312    fn set_overlay_tree_skip_output(&mut self, value: bool) {
313        let fe = self.flags_extended.get_or_insert(0);
314        if value {
315            *fe |= Self::FLAG_EXT_OVERLAY_TREE_SKIP;
316        } else {
317            *fe &= !Self::FLAG_EXT_OVERLAY_TREE_SKIP;
318            if *fe == 0 {
319                self.flags_extended = None;
320            }
321        }
322    }
323
324    /// Whether the fsmonitor-valid bit is set.
325    #[must_use]
326    pub fn fsmonitor_valid(&self) -> bool {
327        self.flags_extended
328            .is_some_and(|fe| fe & Self::FLAG_EXT_FSMONITOR_VALID != 0)
329    }
330
331    /// Set or clear the fsmonitor-valid bit.
332    pub fn set_fsmonitor_valid(&mut self, value: bool) {
333        let fe = self.flags_extended.get_or_insert(0);
334        if value {
335            *fe |= Self::FLAG_EXT_FSMONITOR_VALID;
336        } else {
337            *fe &= !Self::FLAG_EXT_FSMONITOR_VALID;
338            if *fe == 0 {
339                self.flags_extended = None;
340            }
341        }
342    }
343}
344
345/// The in-memory representation of the Git index file (staging area).
346///
347/// # Examples
348///
349/// ```
350/// use grit_lib::repo::{InitOptions, Repository};
351///
352/// # fn main() -> grit_lib::error::Result<()> {
353/// let dir = tempfile::tempdir()?;
354/// let repo = Repository::init(dir.path(), &InitOptions::default())?;
355/// let index = repo.index_handle().load()?;
356/// assert!(index.entries().is_empty());
357/// # Ok(())
358/// # }
359/// ```
360#[derive(Debug, Clone)]
361pub struct Index {
362    /// Index format version (2 or 3).
363    pub version: u32,
364    /// Index entries, sorted by (path, stage) when [`Self::entries_sorted`] is true.
365    pub(crate) entries: Vec<IndexEntry>,
366    /// When `true`, `entries` are in Git `(path, stage)` order and O(log n) lookups apply.
367    pub(crate) entries_sorted: bool,
368    /// When true, the on-disk index includes the `sdir` extension (sparse index).
369    pub sparse_directories: bool,
370    /// Optional untracked-cache extension (`UNTR`), matching Git's `istate->untracked`.
371    pub untracked_cache: Option<untracked_cache::UntrackedCache>,
372    /// Optional fsmonitor token extension (`FSMN`).
373    pub fsmonitor_last_update: Option<String>,
374    /// Parsed fsmonitor dirty bitmap from the `FSMN` extension, when present.
375    pub(crate) fsmonitor_dirty: Option<crate::ewah_bitmap::EwahBitmap>,
376    /// On-disk `FSMN` extension payload for byte-for-byte round-trip when entries are unchanged.
377    pub(crate) fsmonitor_raw_extension: Option<Vec<u8>>,
378    /// Optional `REUC` resolve-undo extension (paths that were unmerged before a resolution).
379    pub resolve_undo: Option<BTreeMap<Vec<u8>, ResolveUndoRecord>>,
380    /// Split index `link` extension (bitmaps cleared after load merge).
381    pub(crate) split_link: Option<crate::split_index::SplitIndexLink>,
382    /// Root tree OID from a valid `TREE` index extension (`cache_tree`), when present.
383    pub cache_tree_root: Option<ObjectId>,
384    /// Parsed `TREE` index extension (`cache-tree`) preserving invalid and subtree nodes.
385    pub cache_tree: Option<CacheTreeNode>,
386    /// The repository's object hash algorithm. Determines the width of entry
387    /// object IDs and the trailing checksum on disk. Set authoritatively from
388    /// the object database when loaded via the repository; defaults to SHA-1.
389    pub hash_algo: HashAlgo,
390    /// On-disk index mtime when this index was last read (Git `istate->timestamp`).
391    pub source_mtime: Option<(u32, u32)>,
392    /// When set, [`Self::invalidate_untracked_cache_for_path`] is a no-op (bulk staging defers invalidation).
393    pub(crate) defer_untracked_cache_invalidation: bool,
394    /// Skip the pre-write racy smudge walk (checkout just aligned worktree and index entries).
395    pub(crate) skip_racy_smudge_on_write: bool,
396    /// In-memory only (Git `CE_UPTODATE`): stage-0 paths whose worktree content this process
397    /// hashed to the recorded object id after taking the stat data now in the entry. The
398    /// pre-write racy smudge skips an entry that still holds that id instead of reading the
399    /// file again; the next index write consumes the map.
400    pub(crate) content_verified: HashMap<Vec<u8>, ObjectId>,
401    /// In-memory only: Git `istate->fsmonitor_has_run_once`.
402    pub(crate) fsmonitor_refresh_ran: bool,
403    /// Skip racy smudge and UNTR shell setup on the next index write (checkout fast path).
404    pub(crate) suppress_pre_write_index_housekeeping: bool,
405}
406
407impl Default for Index {
408    fn default() -> Self {
409        Self::new()
410    }
411}
412
413/// One node from Git's `TREE` index extension.
414#[derive(Debug, Clone, PartialEq, Eq)]
415pub struct CacheTreeNode {
416    /// Path component for this node. The root node stores an empty name.
417    pub name: Vec<u8>,
418    /// Number of index entries covered by this node, or `-1` when invalid.
419    pub entry_count: i32,
420    /// Tree object ID for valid nodes. Invalid nodes do not store an object ID.
421    pub oid: Option<ObjectId>,
422    /// Immediate child cache-tree nodes.
423    pub children: Vec<CacheTreeNode>,
424}
425
426impl CacheTreeNode {
427    /// Create a valid cache-tree node.
428    #[must_use]
429    pub fn valid(
430        name: Vec<u8>,
431        entry_count: i32,
432        oid: ObjectId,
433        children: Vec<CacheTreeNode>,
434    ) -> Self {
435        Self {
436            name,
437            entry_count,
438            oid: Some(oid),
439            children,
440        }
441    }
442
443    /// Mark this node as invalid while preserving its children.
444    pub fn invalidate(&mut self) {
445        self.entry_count = -1;
446        self.oid = None;
447    }
448
449    /// Returns whether this node has a valid cached tree object ID.
450    #[must_use]
451    pub fn is_valid(&self) -> bool {
452        self.entry_count >= 0 && self.oid.is_some()
453    }
454}
455
456pub use crate::index_eoie_ieot::IndexParseContext;
457
458/// Options for loading an index from disk.
459#[derive(Debug, Clone, Copy)]
460pub struct IndexLoadOptions {
461    /// If the index contains sparse directory placeholders, expand them to full file entries.
462    pub expand_sparse_directories: bool,
463    /// Thread policy for parallel index entry load (EOIE/IEOT).
464    pub parse_context: IndexParseContext,
465}
466
467impl Default for IndexLoadOptions {
468    fn default() -> Self {
469        Self {
470            expand_sparse_directories: true,
471            parse_context: IndexParseContext::default(),
472        }
473    }
474}
475
476/// Version used after an invalid `GIT_INDEX_VERSION` value (matches Git stderr: "Using version 3").
477const INDEX_ENV_INVALID_FALLBACK: u32 = 3;
478/// Version used after an invalid `index.version` config value (same message as env).
479const INDEX_CONFIG_INVALID_FALLBACK: u32 = 3;
480/// Minimum supported index version.
481const INDEX_FORMAT_LB: u32 = 2;
482/// Maximum supported index version (version 4 requests are accepted and
483/// downgraded on write).
484const INDEX_FORMAT_UB: u32 = 4;
485/// Index extension signature `TREE` (cache-tree).
486pub(crate) const INDEX_EXT_CACHE_TREE: u32 = 0x5452_4545;
487
488/// Best-effort read of Git's `TREE` index extension (`cache_tree_read`).
489pub(crate) fn parse_cache_tree(data: &[u8], hash_len: usize) -> Option<CacheTreeNode> {
490    let (node, pos) = parse_cache_tree_node(data, 0, hash_len)?;
491    if pos == data.len() {
492        Some(node)
493    } else {
494        None
495    }
496}
497
498fn parse_cache_tree_node(
499    data: &[u8],
500    mut pos: usize,
501    hash_len: usize,
502) -> Option<(CacheTreeNode, usize)> {
503    let name_end = data.get(pos..)?.iter().position(|&b| b == 0)? + pos;
504    let name = data[pos..name_end].to_vec();
505    pos = name_end + 1;
506
507    let (entry_count, consumed) = parse_signed_int_prefix(&data[pos..])?;
508    pos += consumed;
509    if data.get(pos) != Some(&b' ') {
510        return None;
511    }
512    pos += 1;
513    let (subtree_count, consumed) = parse_signed_int_prefix(&data[pos..])?;
514    if subtree_count < 0 {
515        return None;
516    }
517    pos += consumed;
518    if data.get(pos) != Some(&b'\n') {
519        return None;
520    }
521    pos += 1;
522
523    let oid = if entry_count >= 0 {
524        if data.len().saturating_sub(pos) < hash_len {
525            return None;
526        }
527        let oid = ObjectId::from_bytes(&data[pos..pos + hash_len]).ok()?;
528        pos += hash_len;
529        Some(oid)
530    } else {
531        None
532    };
533
534    let mut children = Vec::with_capacity(subtree_count as usize);
535    for _ in 0..subtree_count {
536        let (child, next) = parse_cache_tree_node(data, pos, hash_len)?;
537        children.push(child);
538        pos = next;
539    }
540
541    Some((
542        CacheTreeNode {
543            name,
544            entry_count,
545            oid,
546            children,
547        },
548        pos,
549    ))
550}
551
552fn parse_signed_int_prefix(data: &[u8]) -> Option<(i32, usize)> {
553    let mut j = 0usize;
554    while j < data.len() && data[j] == b' ' {
555        j += 1;
556    }
557    let start = j;
558    if j < data.len() && data[j] == b'-' {
559        j += 1;
560    }
561    let digit_start = j;
562    while j < data.len() && data[j].is_ascii_digit() {
563        j += 1;
564    }
565    if j == digit_start {
566        return None;
567    }
568    let s = std::str::from_utf8(&data[start..j]).ok()?;
569    let v: i32 = s.parse().ok()?;
570    Some((v, j))
571}
572
573fn serialize_cache_tree_node(node: &CacheTreeNode, out: &mut Vec<u8>) {
574    out.extend_from_slice(&node.name);
575    out.push(0);
576    out.extend_from_slice(node.entry_count.to_string().as_bytes());
577    out.push(b' ');
578    out.extend_from_slice(node.children.len().to_string().as_bytes());
579    out.push(b'\n');
580    if node.entry_count >= 0 {
581        if let Some(oid) = node.oid {
582            out.extend_from_slice(oid.as_bytes());
583        }
584    }
585    for child in &node.children {
586        serialize_cache_tree_node(child, out);
587    }
588}
589
590fn format_cache_tree_node(node: &CacheTreeNode, parent_path: &str, out: &mut String) {
591    let path = if node.name.is_empty() {
592        String::new()
593    } else {
594        let name = String::from_utf8_lossy(&node.name);
595        format!("{parent_path}{name}/")
596    };
597    if node.is_valid() {
598        if let Some(oid) = node.oid {
599            out.push_str(&format!(
600                "{} {} ({} entries, {} subtrees)\n",
601                oid,
602                path,
603                node.entry_count,
604                node.children.len()
605            ));
606        }
607    } else {
608        out.push_str(&format!(
609            "{:<40} {} ({} subtrees)\n",
610            "invalid",
611            path,
612            node.children.len()
613        ));
614    }
615    for child in &node.children {
616        format_cache_tree_node(child, &path, out);
617    }
618}
619
620/// Emit a single cache-tree node line in `test-tool dump-cache-tree` format.
621fn dump_one_line(node: &CacheTreeNode, pfx: &str, out: &mut String) {
622    if node.entry_count < 0 {
623        out.push_str(&format!(
624            "{:<40} {} ({} subtrees)\n",
625            "invalid",
626            pfx,
627            node.children.len()
628        ));
629    } else {
630        let oid = node.oid.unwrap_or_else(ObjectId::zero);
631        out.push_str(&format!(
632            "{} {} ({} entries, {} subtrees)\n",
633            oid,
634            pfx,
635            node.entry_count,
636            node.children.len()
637        ));
638    }
639}
640
641/// Walk the stored cache-tree (`it`) against a freshly built reference (`ref`),
642/// emitting only the nodes that exist in both — matching Git's `dump_cache_tree`.
643fn dump_cache_tree_pair(
644    it: &CacheTreeNode,
645    reference: &CacheTreeNode,
646    pfx: &str,
647    out: &mut String,
648) {
649    dump_one_line(it, pfx, out);
650
651    for child in &it.children {
652        let Some(ref_child) = reference.children.iter().find(|c| c.name == child.name) else {
653            continue;
654        };
655        let name = String::from_utf8_lossy(&child.name);
656        let child_pfx = format!("{pfx}{name}/");
657        dump_cache_tree_pair(child, ref_child, &child_pfx, out);
658    }
659}
660
661/// Read `GIT_INDEX_VERSION` and return the requested version.
662///
663/// If the environment variable is unset, returns `None`.
664/// If it is set but invalid (non-numeric or out of range 2..=4), prints a
665/// warning to stderr and returns the default version.
666pub fn get_index_format_from_env() -> Option<u32> {
667    let val = crate::environment::Environment::empty().var("GIT_INDEX_VERSION")?;
668    if val.is_empty() {
669        return None;
670    }
671    match val.parse::<u32>() {
672        Ok(v) if (INDEX_FORMAT_LB..=INDEX_FORMAT_UB).contains(&v) => Some(v),
673        _ => Some(INDEX_ENV_INVALID_FALLBACK),
674    }
675}
676
677/// Parse Git's `FSMN` index extension payload (`read_fsmonitor_extension`).
678pub(crate) fn parse_fsmonitor_extension(
679    ext_data: &[u8],
680) -> Result<(String, crate::ewah_bitmap::EwahBitmap)> {
681    if ext_data.len() < 4 + 1 + 4 {
682        return Err(Error::IndexFsmonitorExtensionTruncated);
683    }
684    let hdr_version = u32::from_be_bytes(
685        ext_data[0..4]
686            .try_into()
687            .map_err(|_| Error::IndexFsmonitorExtensionTruncated)?,
688    );
689    let mut pos = 4usize;
690    let token = match hdr_version {
691        FSMONITOR_EXT_VERSION1 => {
692            if ext_data.len() < pos + 8 + 4 {
693                return Err(Error::IndexFsmonitorExtensionTruncated);
694            }
695            let ts = u64::from_be_bytes(
696                ext_data[pos..pos + 8]
697                    .try_into()
698                    .map_err(|_| Error::IndexFsmonitorExtensionTruncated)?,
699            );
700            pos += 8;
701            ts.to_string()
702        }
703        FSMONITOR_EXT_VERSION2 => {
704            let nul = ext_data[pos..]
705                .iter()
706                .position(|&b| b == 0)
707                .ok_or(Error::IndexFsmonitorExtensionTruncated)?;
708            let token = String::from_utf8_lossy(&ext_data[pos..pos + nul]).into_owned();
709            pos += nul + 1;
710            token
711        }
712        v => return Err(Error::IndexFsmonitorExtensionBadVersion(v)),
713    };
714    if ext_data.len() < pos + 4 {
715        return Err(Error::IndexFsmonitorExtensionTruncated);
716    }
717    let ewah_size = u32::from_be_bytes(
718        ext_data[pos..pos + 4]
719            .try_into()
720            .map_err(|_| Error::IndexFsmonitorExtensionTruncated)?,
721    ) as usize;
722    pos += 4;
723    if ext_data.len() < pos + ewah_size {
724        return Err(Error::IndexFsmonitorExtensionTruncated);
725    }
726    let ewah_slice = &ext_data[pos..pos + ewah_size];
727    let (dirty, consumed) = crate::ewah_bitmap::EwahBitmap::deserialize_prefix(ewah_slice)
728        .ok_or(Error::IndexFsmonitorExtensionEwahInvalid)?;
729    if consumed != ewah_size {
730        return Err(Error::IndexFsmonitorExtensionEwahInvalid);
731    }
732    Ok((token, dirty))
733}
734
735/// Apply `FSMN` dirty bits to in-memory `CE_FSMONITOR_VALID` (`fsmonitor_ewah_callback`).
736pub(crate) fn apply_fsmonitor_dirty_to_entries(
737    entries: &mut [IndexEntry],
738    dirty: &crate::ewah_bitmap::EwahBitmap,
739) {
740    for entry in entries.iter_mut() {
741        entry.set_fsmonitor_valid(true);
742    }
743    dirty.each_set_bit(|pos| {
744        if let Some(entry) = entries.get_mut(pos) {
745            entry.set_fsmonitor_valid(false);
746        }
747    });
748}
749
750impl Index {
751    /// Create a new, empty index.
752    ///
753    /// Respects `GIT_INDEX_VERSION` if set, otherwise defaults to version 2.
754    #[must_use]
755    pub fn new() -> Self {
756        let version = get_index_format_from_env().unwrap_or(2);
757        Self {
758            version,
759            entries: Vec::new(),
760            entries_sorted: true,
761            sparse_directories: false,
762            untracked_cache: None,
763            fsmonitor_last_update: None,
764            fsmonitor_dirty: None,
765            fsmonitor_raw_extension: None,
766            resolve_undo: None,
767            split_link: None,
768            cache_tree_root: None,
769            cache_tree: None,
770            hash_algo: HashAlgo::Sha1,
771            source_mtime: None,
772            defer_untracked_cache_invalidation: false,
773            skip_racy_smudge_on_write: false,
774            content_verified: HashMap::new(),
775            fsmonitor_refresh_ran: false,
776            suppress_pre_write_index_housekeeping: false,
777        }
778    }
779
780    /// Index entries used for `.gitattributes` fallback when the worktree copy is missing.
781    ///
782    /// Contains only stage-0 paths named `.gitattributes` or `*/.gitattributes`, so callers
783    /// can pass attribute context without cloning unrelated tracked files.
784    #[must_use]
785    pub fn gitattributes_snapshot(&self) -> Self {
786        let mut snap = Self::new();
787        snap.version = self.version;
788        snap.hash_algo = self.hash_algo;
789        for entry in &self.entries {
790            if entry.stage() != 0 {
791                continue;
792            }
793            if gitattributes_index_path(&entry.path) {
794                snap.add_or_replace(entry.clone());
795            }
796        }
797        snap
798    }
799
800    /// Create a new, empty index at a fixed version without consulting
801    /// `GIT_INDEX_VERSION` or config.
802    ///
803    /// Unlike [`Self::new`], this never reads the environment and therefore never
804    /// emits the "GIT_INDEX_VERSION set, but the value is invalid" warning. Use it
805    /// for throwaway baseline indexes (e.g. sparse-index diff references) where the
806    /// version is irrelevant and Git resolves the format only once per operation.
807    #[must_use]
808    pub fn empty(version: u32) -> Self {
809        Self {
810            version,
811            entries: Vec::new(),
812            entries_sorted: true,
813            sparse_directories: false,
814            untracked_cache: None,
815            fsmonitor_last_update: None,
816            fsmonitor_dirty: None,
817            fsmonitor_raw_extension: None,
818            resolve_undo: None,
819            split_link: None,
820            cache_tree_root: None,
821            cache_tree: None,
822            hash_algo: HashAlgo::Sha1,
823            source_mtime: None,
824            defer_untracked_cache_invalidation: false,
825            skip_racy_smudge_on_write: false,
826            content_verified: HashMap::new(),
827            fsmonitor_refresh_ran: false,
828            suppress_pre_write_index_housekeeping: false,
829        }
830    }
831
832    /// Create a new empty index, respecting config values for version.
833    ///
834    /// Priority matches Git's `prepare_repo_settings`: `GIT_INDEX_VERSION` env, then
835    /// `feature.manyFiles` (implies version 4), then `index.version` (overrides version).
836    pub fn new_with_config(
837        config_index_version: Option<&str>,
838        config_many_files: Option<&str>,
839    ) -> Self {
840        if let Some(v) = get_index_format_from_env() {
841            return Self {
842                version: v,
843                entries: Vec::new(),
844                entries_sorted: true,
845                sparse_directories: false,
846                untracked_cache: None,
847                fsmonitor_last_update: None,
848                fsmonitor_dirty: None,
849                fsmonitor_raw_extension: None,
850                resolve_undo: None,
851                split_link: None,
852                cache_tree_root: None,
853                cache_tree: None,
854                hash_algo: HashAlgo::default(),
855                source_mtime: None,
856                defer_untracked_cache_invalidation: false,
857                skip_racy_smudge_on_write: false,
858                content_verified: HashMap::new(),
859                fsmonitor_refresh_ran: false,
860                suppress_pre_write_index_housekeeping: false,
861            };
862        }
863
864        let many_files = config_truthy(config_many_files);
865        let mut version = if many_files { 4 } else { 2 };
866
867        if let Some(val) = config_index_version {
868            let trimmed = val.trim();
869            if !trimmed.is_empty() {
870                match trimmed.parse::<u32>() {
871                    Ok(v) if (INDEX_FORMAT_LB..=INDEX_FORMAT_UB).contains(&v) => {
872                        version = v;
873                    }
874                    _ => {
875                        version = INDEX_CONFIG_INVALID_FALLBACK;
876                    }
877                }
878            }
879        }
880
881        Self {
882            version,
883            entries: Vec::new(),
884            entries_sorted: true,
885            sparse_directories: false,
886            untracked_cache: None,
887            fsmonitor_last_update: None,
888            fsmonitor_dirty: None,
889            fsmonitor_raw_extension: None,
890            resolve_undo: None,
891            split_link: None,
892            cache_tree_root: None,
893            cache_tree: None,
894            hash_algo: HashAlgo::default(),
895            source_mtime: None,
896            defer_untracked_cache_invalidation: false,
897            skip_racy_smudge_on_write: false,
898            content_verified: HashMap::new(),
899            fsmonitor_refresh_ran: false,
900            suppress_pre_write_index_housekeeping: false,
901        }
902    }
903
904    /// New empty index using a loaded [`ConfigSet`] (includes `-c` / `GIT_CONFIG_PARAMETERS`).
905    ///
906    /// Same precedence as [`Self::new_with_config`], but reads `feature.manyFiles` and
907    /// `index.version` from `config`.
908    #[must_use]
909    pub fn new_from_config(config: &ConfigSet) -> Self {
910        if let Some(v) = get_index_format_from_env() {
911            return Self {
912                version: v,
913                entries: Vec::new(),
914                entries_sorted: true,
915                sparse_directories: false,
916                untracked_cache: None,
917                fsmonitor_last_update: None,
918                fsmonitor_dirty: None,
919                fsmonitor_raw_extension: None,
920                resolve_undo: None,
921                split_link: None,
922                cache_tree_root: None,
923                cache_tree: None,
924                hash_algo: HashAlgo::default(),
925                source_mtime: None,
926                defer_untracked_cache_invalidation: false,
927                skip_racy_smudge_on_write: false,
928                content_verified: HashMap::new(),
929                fsmonitor_refresh_ran: false,
930                suppress_pre_write_index_housekeeping: false,
931            };
932        }
933
934        let many_files = config
935            .get_bool("feature.manyFiles")
936            .and_then(|r| r.ok())
937            .unwrap_or(false);
938        let mut version = if many_files { 4 } else { 2 };
939
940        if let Some(val) = config.get("index.version") {
941            let trimmed = val.trim();
942            if !trimmed.is_empty() {
943                match trimmed.parse::<u32>() {
944                    Ok(v) if (INDEX_FORMAT_LB..=INDEX_FORMAT_UB).contains(&v) => {
945                        version = v;
946                    }
947                    _ => {
948                        version = INDEX_CONFIG_INVALID_FALLBACK;
949                    }
950                }
951            }
952        }
953
954        Self {
955            version,
956            entries: Vec::new(),
957            entries_sorted: true,
958            sparse_directories: false,
959            untracked_cache: None,
960            fsmonitor_last_update: None,
961            fsmonitor_dirty: None,
962            fsmonitor_raw_extension: None,
963            resolve_undo: None,
964            split_link: None,
965            cache_tree_root: None,
966            cache_tree: None,
967            hash_algo: HashAlgo::default(),
968            source_mtime: None,
969            defer_untracked_cache_invalidation: false,
970            skip_racy_smudge_on_write: false,
971            content_verified: HashMap::new(),
972            fsmonitor_refresh_ran: false,
973            suppress_pre_write_index_housekeeping: false,
974        }
975    }
976
977    /// Load an index from the given file path without expanding sparse-directory placeholders.
978    ///
979    /// Returns an empty index if the file does not exist.
980    ///
981    /// # Errors
982    ///
983    /// Returns [`Error::IndexError`] if the file is present but corrupt.
984    pub fn load(path: &Path) -> Result<Self> {
985        match read_index_file(path) {
986            Ok((data, mtime)) => {
987                let mut idx = Self::parse(&data)?;
988                idx.source_mtime = Some(mtime);
989                Ok(idx)
990            }
991            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self {
992                sparse_directories: false,
993                ..Self::new()
994            }),
995            Err(e) => Err(Error::Io(e)),
996        }
997    }
998
999    /// Load an index and expand sparse-directory placeholders using the object database.
1000    ///
1001    /// After a successful return, [`Index::sparse_directories`] is cleared and every
1002    /// placeholder is replaced by the blob entries from the referenced tree.
1003    pub fn load_expand_sparse(path: &Path, odb: &Odb) -> Result<Self> {
1004        let mut idx = match read_index_file(path) {
1005            Ok((data, mtime)) => Self {
1006                source_mtime: Some(mtime),
1007                ..Self::parse_with_algo(&data, odb.hash_algo())?
1008            },
1009            Err(e) if e.kind() == io::ErrorKind::NotFound => Self {
1010                sparse_directories: false,
1011                hash_algo: odb.hash_algo(),
1012                ..Self::new()
1013            },
1014            Err(e) => return Err(Error::Io(e)),
1015        };
1016        idx.expand_sparse_directory_placeholders(
1017            odb,
1018            crate::path_validate::ProtectOptions::default(),
1019        )?;
1020        Ok(idx)
1021    }
1022
1023    /// Like [`Self::load_expand_sparse`], but applies path protection from `config` when expanding sparse directories.
1024    pub fn load_expand_sparse_with_config(
1025        path: &Path,
1026        odb: &Odb,
1027        config: &ConfigSet,
1028    ) -> Result<Self> {
1029        Self::load_expand_sparse_optional_with_context(
1030            path,
1031            odb,
1032            Some(config),
1033            IndexParseContext::default(),
1034        )
1035    }
1036
1037    /// Like [`Index::load_expand_sparse`], but treats a missing index as an empty index.
1038    ///
1039    /// An index file too short to hold a header and checksum is an error, as in Git.
1040    pub fn load_expand_sparse_optional(path: &Path, odb: &Odb) -> Result<Self> {
1041        Self::load_expand_sparse_optional_with_context(
1042            path,
1043            odb,
1044            None,
1045            IndexParseContext::default(),
1046        )
1047    }
1048
1049    /// Like [`Self::load_expand_sparse_optional`], but passes config and thread policy for EOIE/IEOT load.
1050    pub fn load_expand_sparse_optional_with_context(
1051        path: &Path,
1052        odb: &Odb,
1053        config: Option<&ConfigSet>,
1054        parse_context: IndexParseContext,
1055    ) -> Result<Self> {
1056        let algo = odb.hash_algo();
1057        let mut idx = match read_index_file(path) {
1058            Ok((data, mtime)) => Self {
1059                source_mtime: Some(mtime),
1060                ..Self::parse_with_algo_and_config(&data, algo, config, parse_context)?
1061            },
1062            Err(e) if e.kind() == io::ErrorKind::NotFound => Self {
1063                hash_algo: algo,
1064                ..Self::new()
1065            },
1066            Err(e) => return Err(Error::Io(e)),
1067        };
1068        let protect = config
1069            .map(crate::path_validate::ProtectOptions::from_config)
1070            .unwrap_or_default();
1071        idx.expand_sparse_directory_placeholders(odb, protect)?;
1072        Ok(idx)
1073    }
1074
1075    /// Returns true if the index contains sparse-index tree placeholders (`MODE_TREE` + skip-worktree).
1076    #[must_use]
1077    pub fn has_sparse_directory_placeholders(&self) -> bool {
1078        self.entries
1079            .iter()
1080            .any(IndexEntry::is_sparse_directory_placeholder)
1081    }
1082
1083    /// Replace sparse-directory placeholder entries with all blob paths from their trees.
1084    ///
1085    /// Each placeholder must reference a tree object. New entries are marked skip-worktree like Git's
1086    /// expanded index, except we keep `sparse_directories` false in memory after expansion.
1087    pub fn expand_sparse_directory_placeholders(
1088        &mut self,
1089        odb: &Odb,
1090        protect: crate::path_validate::ProtectOptions,
1091    ) -> Result<()> {
1092        if !self.has_sparse_directory_placeholders() {
1093            return Ok(());
1094        }
1095        let mut out: Vec<IndexEntry> = Vec::with_capacity(self.entries.len());
1096        for entry in self.entries.drain(..) {
1097            if entry.is_sparse_directory_placeholder() {
1098                let prefix = trim_trailing_slash_bytes(&entry.path);
1099                let blobs = flatten_tree_blobs(odb, &entry.oid, prefix, protect)?;
1100                out.extend(blobs);
1101            } else {
1102                out.push(entry);
1103            }
1104        }
1105        self.entries = out;
1106        self.sparse_directories = false;
1107        self.sort();
1108        Ok(())
1109    }
1110
1111    /// Collapse consecutive skip-worktree subtrees into sparse-directory placeholders when
1112    /// `cone_mode` is true and each directory is outside the sparse cone.
1113    ///
1114    /// `head_tree` is the tree OID at `HEAD`. When `enable_sparse_index` is false, clears
1115    /// [`Index::sparse_directories`] and returns without collapsing.
1116    pub fn try_collapse_sparse_directories(
1117        &mut self,
1118        odb: &Odb,
1119        head_tree: &ObjectId,
1120        patterns: &[String],
1121        cone_mode: bool,
1122        enable_sparse_index: bool,
1123        protect: crate::path_validate::ProtectOptions,
1124    ) -> Result<()> {
1125        if !enable_sparse_index || !cone_mode {
1126            self.sparse_directories = false;
1127            return Ok(());
1128        }
1129
1130        let mut prefixes = BTreeSet::<Vec<u8>>::new();
1131        for e in &self.entries {
1132            if e.stage() != 0 || e.mode == MODE_TREE || !e.skip_worktree() {
1133                continue;
1134            }
1135            collect_directory_prefixes(&e.path, &mut prefixes);
1136        }
1137
1138        let mut collapsed_any = false;
1139        // Deepest prefixes first so nested dirs collapse before parents.
1140        let mut ordered: Vec<Vec<u8>> = prefixes.into_iter().collect();
1141        ordered.sort_by_key(|p| std::cmp::Reverse(p.len()));
1142
1143        for pref in ordered {
1144            let pref_str = String::from_utf8_lossy(&pref);
1145            if directory_in_cone(&pref_str, patterns, cone_mode) {
1146                continue;
1147            }
1148            let Some(subtree_oid) = tree_oid_for_prefix(odb, head_tree, &pref)? else {
1149                continue;
1150            };
1151            let expected = collect_sparse_aware_expected_blobs(
1152                odb,
1153                &subtree_oid,
1154                &pref,
1155                patterns,
1156                cone_mode,
1157                &self.entries,
1158                protect,
1159            )?;
1160            if expected.is_empty() {
1161                continue;
1162            }
1163            let mut matched = Vec::new();
1164            for e in &self.entries {
1165                if e.stage() != 0 {
1166                    continue;
1167                }
1168                if path_under_prefix(&e.path, &pref) && e.mode != MODE_TREE {
1169                    matched.push(e.clone());
1170                }
1171            }
1172            if matched.len() != expected.len() {
1173                continue;
1174            }
1175            matched.sort_by(|a, b| a.path.cmp(&b.path));
1176            let mut exp_sorted = expected;
1177            exp_sorted.sort_by(|a, b| a.path.cmp(&b.path));
1178            if !matched
1179                .iter()
1180                .zip(exp_sorted.iter())
1181                .all(|(a, b)| a.path == b.path && a.oid == b.oid && a.mode == b.mode)
1182            {
1183                continue;
1184            }
1185            if !matched.iter().all(|e| e.skip_worktree()) {
1186                continue;
1187            }
1188            // Git's convert_to_sparse_rec refuses to collapse a directory that contains a
1189            // submodule (gitlink); the sparse-directory entry could not faithfully represent
1190            // the gitlink's committed OID. Leave such directories expanded.
1191            if matched.iter().any(|e| e.mode == MODE_GITLINK)
1192                || exp_sorted.iter().any(|e| e.mode == MODE_GITLINK)
1193            {
1194                continue;
1195            }
1196
1197            let mut path_with_slash = pref.clone();
1198            if !path_with_slash.ends_with(b"/") {
1199                path_with_slash.push(b'/');
1200            }
1201            self.entries
1202                .retain(|e| e.stage() != 0 || !path_under_prefix(&e.path, &pref));
1203            let mut placeholder = IndexEntry {
1204                ctime_sec: 0,
1205                ctime_nsec: 0,
1206                mtime_sec: 0,
1207                mtime_nsec: 0,
1208                dev: 0,
1209                ino: 0,
1210                mode: MODE_TREE,
1211                uid: 0,
1212                gid: 0,
1213                size: 0,
1214                oid: subtree_oid,
1215                flags: path_with_slash.len().min(0xFFF) as u16,
1216                flags_extended: Some(0),
1217                path: path_with_slash,
1218                base_index_pos: 0,
1219            };
1220            placeholder.set_skip_worktree(true);
1221            self.add_or_replace(placeholder);
1222            collapsed_any = true;
1223        }
1224
1225        if collapsed_any {
1226            self.sort();
1227            self.sparse_directories = true;
1228        } else {
1229            self.sparse_directories = false;
1230        }
1231        Ok(())
1232    }
1233
1234    /// Parse index bytes (the whole file including trailing SHA-1).
1235    ///
1236    /// # Errors
1237    ///
1238    /// Returns [`Error::IndexError`] on structural problems.
1239    pub fn parse(data: &[u8]) -> Result<Self> {
1240        // The index file does not record its hash width; it is fixed by the
1241        // repository. Detect it from the trailing checksum (callers with a
1242        // repository should prefer [`Self::parse_with_algo`]).
1243        Self::parse_with_algo(data, detect_index_hash_algo(data))
1244    }
1245
1246    /// Parse an index using a known hash algorithm (e.g. from the repository's
1247    /// `extensions.objectformat`), which determines the entry OID width and the
1248    /// trailing checksum width.
1249    pub fn parse_with_algo(data: &[u8], hash_algo: HashAlgo) -> Result<Self> {
1250        Self::parse_with_algo_and_config(data, hash_algo, None, IndexParseContext::default())
1251    }
1252
1253    /// Parse an index with optional repository config and thread policy for EOIE/IEOT load.
1254    pub fn parse_with_algo_and_config(
1255        data: &[u8],
1256        hash_algo: HashAlgo,
1257        config: Option<&ConfigSet>,
1258        parse_context: IndexParseContext,
1259    ) -> Result<Self> {
1260        const INDEX_HEADER_LEN: usize = 12;
1261
1262        let hash_len = hash_algo.len();
1263        if data.len() < INDEX_HEADER_LEN + hash_len {
1264            return Err(Error::IndexError(
1265                "file too short for index header and checksum".to_owned(),
1266            ));
1267        }
1268        let (body, checksum) = data.split_at(data.len() - hash_len);
1269        if body.len() < INDEX_HEADER_LEN {
1270            return Err(Error::IndexError(
1271                "file too short for index header and checksum".to_owned(),
1272            ));
1273        }
1274        let trailer_error = |e: crate::hash::TrailerMismatch| {
1275            Error::IndexError(format!(
1276                "{} checksum mismatch: {e}",
1277                hash_algo.name().to_uppercase()
1278            ))
1279        };
1280        if checksum.iter().all(|&b| b == 0) {
1281            return Self::parse_body(body, hash_algo, config, parse_context);
1282        }
1283        let verify_in_parallel = data.len() >= PARALLEL_TRAILER_VERIFY_MIN_BYTES
1284            && parse_context.thread_policy.threads_override != Some(1);
1285        if !verify_in_parallel {
1286            verify_trailer(hash_algo, data).map_err(trailer_error)?;
1287            return Self::parse_body(body, hash_algo, config, parse_context);
1288        }
1289        // Hashing a large index costs about as much as parsing it; overlap the two and
1290        // report a checksum mismatch ahead of any structural error, as the serial path does.
1291        std::thread::scope(|scope| {
1292            let verifier = scope.spawn(|| verify_trailer(hash_algo, data));
1293            let parsed = Self::parse_body(body, hash_algo, config, parse_context);
1294            verifier
1295                .join()
1296                .map_err(|_| Error::Message("index checksum worker thread panicked".to_owned()))?
1297                .map_err(trailer_error)?;
1298            parsed
1299        })
1300    }
1301
1302    /// Parse the index body (header, entries and extensions) without the trailing checksum.
1303    fn parse_body(
1304        body: &[u8],
1305        hash_algo: HashAlgo,
1306        config: Option<&ConfigSet>,
1307        parse_context: IndexParseContext,
1308    ) -> Result<Self> {
1309        use crate::index_eoie_ieot::{
1310            index_load_worker_count, parallel_index_load_allowed, parse_entries_parallel,
1311            parse_entries_serial, parse_index_extensions, read_eoie_extension, read_ieot_extension,
1312            ParsedIndexExtensions,
1313        };
1314
1315        let hash_len = hash_algo.len();
1316        let magic = &body[..4];
1317        if magic != b"DIRC" {
1318            return Err(Error::IndexError("bad magic: expected DIRC".to_owned()));
1319        }
1320        let version = u32::from_be_bytes(
1321            body[4..8]
1322                .try_into()
1323                .map_err(|_| Error::IndexError("cannot read version".to_owned()))?,
1324        );
1325        if version != 2 && version != 3 && version != 4 {
1326            return Err(Error::IndexError(format!(
1327                "unsupported index version {version}"
1328            )));
1329        }
1330        let count = u32::from_be_bytes(
1331            body[8..12]
1332                .try_into()
1333                .map_err(|_| Error::IndexError("cannot read entry count".to_owned()))?,
1334        );
1335
1336        let workers = index_load_worker_count(parse_context.thread_policy, config, count as usize);
1337        let extension_offset = if workers > 1 {
1338            read_eoie_extension(body, hash_algo)
1339        } else {
1340            None
1341        };
1342
1343        let eoie_payload = 4 + hash_len;
1344        let eoie_with_header = 8 + eoie_payload;
1345        let extensions_end = body
1346            .len()
1347            .checked_sub(eoie_with_header)
1348            .filter(|_| extension_offset.is_some())
1349            .unwrap_or(body.len());
1350
1351        let try_parallel = extension_offset.is_some()
1352            && workers > 1
1353            && parallel_index_load_allowed(parse_context.thread_policy, config, count as usize);
1354
1355        let (entries, ext_fields): (Vec<IndexEntry>, ParsedIndexExtensions) = if try_parallel {
1356            let Some(ext_off) = extension_offset else {
1357                return Err(Error::IndexError(
1358                    "parallel index load missing EOIE offset".to_owned(),
1359                ));
1360            };
1361            let ieot = read_ieot_extension(body, ext_off, extensions_end);
1362            let mut loaded_entries = if let Some(ref ieot) = ieot {
1363                parse_entries_parallel(body, version, count, hash_len, ieot, workers)?
1364            } else {
1365                parse_entries_serial(body, version, count, hash_len, 12)?.0
1366            };
1367            let ext_fields =
1368                parse_index_extensions(body, ext_off, extensions_end, hash_algo, None)?;
1369            if let Some(dirty) = ext_fields.fsmonitor_dirty.as_ref() {
1370                apply_fsmonitor_dirty_to_entries(&mut loaded_entries, dirty);
1371            }
1372            (loaded_entries, ext_fields)
1373        } else {
1374            let (mut entries, pos) = parse_entries_serial(body, version, count, hash_len, 12)?;
1375            let ext_fields =
1376                parse_index_extensions(body, pos, body.len(), hash_algo, Some(&mut entries))?;
1377            (entries, ext_fields)
1378        };
1379
1380        Ok(Self {
1381            version,
1382            entries,
1383            entries_sorted: true,
1384            sparse_directories: ext_fields.sparse_directories,
1385            untracked_cache: ext_fields.untracked_cache,
1386            fsmonitor_last_update: ext_fields.fsmonitor_last_update,
1387            fsmonitor_dirty: ext_fields.fsmonitor_dirty,
1388            fsmonitor_raw_extension: ext_fields.fsmonitor_raw_extension,
1389            resolve_undo: ext_fields.resolve_undo,
1390            split_link: ext_fields.split_link,
1391            cache_tree_root: ext_fields.cache_tree_root,
1392            cache_tree: ext_fields.cache_tree,
1393            hash_algo,
1394            source_mtime: None,
1395            defer_untracked_cache_invalidation: false,
1396            skip_racy_smudge_on_write: false,
1397            content_verified: HashMap::new(),
1398            fsmonitor_refresh_ran: false,
1399            suppress_pre_write_index_housekeeping: false,
1400        })
1401    }
1402
1403    /// Write the index to a file, computing and appending the trailing SHA-1.
1404    ///
1405    /// # Errors
1406    ///
1407    /// Returns [`Error::Io`] on filesystem errors.
1408    pub fn write(&mut self, path: &Path) -> Result<()> {
1409        let git_dir = path.parent();
1410        let config = git_dir.and_then(|d| {
1411            ConfigSet::load(&crate::environment::Environment::empty(), Some(d), true).ok()
1412        });
1413        let skip_hash = index_skip_hash_for_write(config.as_ref());
1414        self.write_to_path(path, skip_hash)
1415    }
1416
1417    /// Write the index using an already-loaded config snapshot (avoids re-loading the cascade).
1418    ///
1419    /// # Errors
1420    ///
1421    /// Same as [`Self::write`].
1422    pub fn write_with_config(&mut self, path: &Path, config: &ConfigSet) -> Result<()> {
1423        self.write_with_config_and_policy(path, config, crate::parallel::ThreadPolicy::default())
1424    }
1425
1426    /// Write the index using config and an explicit thread policy (EOIE/IEOT emission).
1427    pub fn write_with_config_and_policy(
1428        &mut self,
1429        path: &Path,
1430        config: &ConfigSet,
1431        policy: crate::parallel::ThreadPolicy,
1432    ) -> Result<()> {
1433        let skip_hash = index_skip_hash_for_write(Some(config));
1434        self.write_to_path_with_config_and_policy(path, skip_hash, Some(config), policy)
1435    }
1436
1437    /// Write this index to `path` with an explicit trailing-checksum policy.
1438    ///
1439    /// When `skip_hash` is true, the trailing SHA-1 is written as all zeros (Git `index.skipHash`).
1440    pub fn write_to_path(&mut self, path: &Path, skip_hash: bool) -> Result<()> {
1441        self.write_to_path_with_config(path, skip_hash, None)
1442    }
1443
1444    /// Like [`Self::write_to_path`], but uses `config` for `core.lockfilepid` instead of reloading.
1445    pub(crate) fn write_to_path_with_config(
1446        &mut self,
1447        path: &Path,
1448        skip_hash: bool,
1449        config: Option<&ConfigSet>,
1450    ) -> Result<()> {
1451        self.write_to_path_with_config_and_policy(
1452            path,
1453            skip_hash,
1454            config,
1455            crate::parallel::ThreadPolicy::default(),
1456        )
1457    }
1458
1459    pub(crate) fn write_to_path_with_config_and_policy(
1460        &mut self,
1461        path: &Path,
1462        skip_hash: bool,
1463        config: Option<&ConfigSet>,
1464        policy: crate::parallel::ThreadPolicy,
1465    ) -> Result<()> {
1466        self.write_to_path_with_config_and_lock_policy(path, skip_hash, config, None, policy)
1467    }
1468
1469    pub(crate) fn write_to_path_with_config_and_lock_policy(
1470        &mut self,
1471        path: &Path,
1472        skip_hash: bool,
1473        config: Option<&ConfigSet>,
1474        existing_lock: Option<&mut IndexLock>,
1475        policy: crate::parallel::ThreadPolicy,
1476    ) -> Result<()> {
1477        self.write_to_path_with_config_and_lock_mode_policy(
1478            path,
1479            skip_hash,
1480            config,
1481            existing_lock,
1482            true,
1483            policy,
1484        )
1485    }
1486
1487    pub(crate) fn write_to_path_with_config_and_lock_mode_policy(
1488        &mut self,
1489        path: &Path,
1490        skip_hash: bool,
1491        config: Option<&ConfigSet>,
1492        existing_lock: Option<&mut IndexLock>,
1493        commit: bool,
1494        policy: crate::parallel::ThreadPolicy,
1495    ) -> Result<()> {
1496        let body = self.serialize_index_body(config, policy)?;
1497
1498        if let Some(lock) = existing_lock {
1499            if lock.index_path != path {
1500                return Err(Error::Message(
1501                    "index lock path does not match write target".into(),
1502                ));
1503            }
1504            let checksum: Vec<u8> = if skip_hash {
1505                vec![0u8; self.hash_algo.len()]
1506            } else {
1507                hash_index_body(self.hash_algo, &body)
1508            };
1509            lock.write_index_contents(&body, &checksum)?;
1510            if commit {
1511                lock.commit_replace_index()?;
1512            }
1513            return Ok(());
1514        }
1515
1516        let tmp_path = path.with_extension("lock");
1517        let pid_path = pid_path_for_lock(&tmp_path);
1518        let lockfile_pid_enabled = lockfile_pid_enabled(path, config);
1519
1520        use crate::lockfile::{LockFile, LockWait};
1521        let wait = LockWait::files_ref_lock(config);
1522        let (tmp_path, mut lock_file) =
1523            LockFile::acquire(&tmp_path, wait).and_then(LockFile::into_open_file)?;
1524
1525        let mut wrote_pid_file = false;
1526        if lockfile_pid_enabled {
1527            if let Err(e) = write_lock_pid_file(&pid_path) {
1528                let _ = fs::remove_file(&tmp_path);
1529                return Err(Error::Io(e));
1530            }
1531            wrote_pid_file = true;
1532        }
1533
1534        if let Err(e) =
1535            write_index_body_and_trailer(&mut lock_file, self.hash_algo, skip_hash, &body)
1536        {
1537            let _ = fs::remove_file(&tmp_path);
1538            if wrote_pid_file {
1539                let _ = fs::remove_file(&pid_path);
1540            }
1541            return Err(Error::Io(e));
1542        }
1543        drop(lock_file);
1544
1545        if let Err(e) = fs::rename(&tmp_path, path) {
1546            let _ = fs::remove_file(&tmp_path);
1547            if wrote_pid_file {
1548                let _ = fs::remove_file(&pid_path);
1549            }
1550            return Err(Error::Io(e));
1551        }
1552        if wrote_pid_file {
1553            let _ = fs::remove_file(&pid_path);
1554        }
1555        Ok(())
1556    }
1557
1558    fn serialize_index_body(
1559        &mut self,
1560        config: Option<&ConfigSet>,
1561        policy: crate::parallel::ThreadPolicy,
1562    ) -> Result<Vec<u8>> {
1563        self.ensure_canonical_order();
1564        let mut body = Vec::new();
1565        self.serialize_into_with_options(&mut body, config, policy)?;
1566        Ok(body)
1567    }
1568
1569    #[cfg(test)]
1570    /// Serialise the index body (without trailing checksum) into `out`.
1571    ///
1572    /// Callers must have sorted entries when using format 4 (path compression depends on order).
1573    pub(crate) fn serialize_into(&self, out: &mut Vec<u8>) -> Result<()> {
1574        self.serialize_into_with_options(out, None, crate::parallel::ThreadPolicy::default())
1575    }
1576
1577    pub(crate) fn serialize_into_with_options(
1578        &self,
1579        out: &mut Vec<u8>,
1580        config: Option<&ConfigSet>,
1581        policy: crate::parallel::ThreadPolicy,
1582    ) -> Result<()> {
1583        use crate::index_eoie_ieot::{
1584            entries_per_ieot_block, ieot_block_count_for_write, record_end_of_index_entries,
1585            write_ieot_payload, IeotEntry, IeotTable, INDEX_EXT_EOIE, INDEX_EXT_IEOT,
1586        };
1587        let has_extended_flags = self.entries.iter().any(|e| e.flags_extended.is_some());
1588        let write_version = if self.version >= 4 {
1589            4
1590        } else if has_extended_flags {
1591            3
1592        } else if self.version >= 3 {
1593            2
1594        } else {
1595            self.version
1596        };
1597        out.reserve(self.estimate_body_capacity(write_version, config));
1598        // Header
1599        out.extend_from_slice(b"DIRC");
1600        out.extend_from_slice(&write_version.to_be_bytes());
1601        out.extend_from_slice(&(self.entries.len() as u32).to_be_bytes());
1602
1603        let ieot_blocks = ieot_block_count_for_write(policy, config, self.entries.len());
1604        let ieot_stride = ieot_blocks.map(|b| entries_per_ieot_block(self.entries.len(), b));
1605        let mut ieot_table = IeotTable {
1606            entries: Vec::new(),
1607        };
1608        let mut nr_in_block = 0u32;
1609        let mut block_start_offset = out.len();
1610
1611        if write_version == 4 {
1612            let mut previous_path: Vec<u8> = Vec::new();
1613            for (i, entry) in self.entries.iter().enumerate() {
1614                if let Some(stride) = ieot_stride {
1615                    if i > 0 && i % stride == 0 {
1616                        ieot_table.entries.push(IeotEntry {
1617                            offset: block_start_offset as u32,
1618                            nr: nr_in_block,
1619                        });
1620                        nr_in_block = 0;
1621                        // Git `do_write_index`: invalidate first byte so the next entry
1622                        // encodes with no common prefix, without shrinking `previous_path`.
1623                        if !previous_path.is_empty() {
1624                            previous_path[0] = 0;
1625                        }
1626                        block_start_offset = out.len();
1627                    }
1628                }
1629                serialize_entry_v4(entry, &mut previous_path, out);
1630                nr_in_block += 1;
1631            }
1632        } else {
1633            for (i, entry) in self.entries.iter().enumerate() {
1634                if let Some(stride) = ieot_stride {
1635                    if i > 0 && i % stride == 0 {
1636                        ieot_table.entries.push(IeotEntry {
1637                            offset: block_start_offset as u32,
1638                            nr: nr_in_block,
1639                        });
1640                        nr_in_block = 0;
1641                        block_start_offset = out.len();
1642                    }
1643                }
1644                serialize_entry(entry, write_version, out);
1645                nr_in_block += 1;
1646            }
1647        }
1648        if ieot_blocks.is_some() && nr_in_block > 0 {
1649            ieot_table.entries.push(IeotEntry {
1650                offset: block_start_offset as u32,
1651                nr: nr_in_block,
1652            });
1653        }
1654
1655        let extension_offset = out.len();
1656        let write_eoie = record_end_of_index_entries(config);
1657        let mut eoie_header_bytes: Option<Vec<u8>> = write_eoie.then(Vec::new);
1658
1659        if !ieot_table.entries.is_empty() {
1660            let mut payload = Vec::new();
1661            write_ieot_payload(&ieot_table, &mut payload);
1662            append_index_extension(out, &mut eoie_header_bytes, INDEX_EXT_IEOT, &payload);
1663        }
1664        if self.sparse_directories {
1665            append_index_extension(
1666                out,
1667                &mut eoie_header_bytes,
1668                INDEX_EXT_SPARSE_DIRECTORIES,
1669                &[],
1670            );
1671        }
1672        if let Some(uc) = &self.untracked_cache {
1673            let payload = untracked_cache::write_untracked_extension(uc);
1674            append_index_extension(out, &mut eoie_header_bytes, INDEX_EXT_UNTRACKED, &payload);
1675        }
1676        self.append_fsmonitor_extension(out, &mut eoie_header_bytes)?;
1677        if let Some(ru) = &self.resolve_undo {
1678            let payload = write_resolve_undo_payload(ru);
1679            if !payload.is_empty() {
1680                append_index_extension(
1681                    out,
1682                    &mut eoie_header_bytes,
1683                    INDEX_EXT_RESOLVE_UNDO,
1684                    &payload,
1685                );
1686            }
1687        }
1688        if let Some(sl) = &self.split_link {
1689            use crate::ewah_bitmap::EwahBitmap;
1690            let del = sl
1691                .delete_bitmap
1692                .as_ref()
1693                .cloned()
1694                .unwrap_or_else(EwahBitmap::new);
1695            let rep = sl
1696                .replace_bitmap
1697                .as_ref()
1698                .cloned()
1699                .unwrap_or_else(EwahBitmap::new);
1700            let payload =
1701                crate::split_index::serialize_link_extension_payload(&sl.base_oid, &del, &rep);
1702            append_index_extension(out, &mut eoie_header_bytes, INDEX_EXT_LINK, &payload);
1703        }
1704        if let Some(cache_tree) = &self.cache_tree {
1705            let mut payload = Vec::new();
1706            serialize_cache_tree_node(cache_tree, &mut payload);
1707            append_index_extension(out, &mut eoie_header_bytes, INDEX_EXT_CACHE_TREE, &payload);
1708        }
1709        if write_eoie {
1710            let mut eoie_payload = Vec::new();
1711            eoie_payload.extend_from_slice(&(extension_offset as u32).to_be_bytes());
1712            let headers = eoie_header_bytes.as_deref().unwrap_or(&[]);
1713            eoie_payload.extend_from_slice(&self.hash_algo.checksum_digest(headers));
1714            append_index_extension(out, &mut None, INDEX_EXT_EOIE, &eoie_payload);
1715        }
1716        Ok(())
1717    }
1718
1719    fn append_fsmonitor_extension(
1720        &self,
1721        out: &mut Vec<u8>,
1722        eoie_headers: &mut Option<Vec<u8>>,
1723    ) -> Result<()> {
1724        if let Some(raw) = &self.fsmonitor_raw_extension {
1725            if self.fsmonitor_raw_still_valid() {
1726                append_index_extension(out, eoie_headers, INDEX_EXT_FSMONITOR, raw);
1727                return Ok(());
1728            }
1729        }
1730        let Some(token) = &self.fsmonitor_last_update else {
1731            return Ok(());
1732        };
1733        let dirty = self
1734            .fsmonitor_dirty
1735            .as_ref()
1736            .cloned()
1737            .unwrap_or_else(|| self.build_fsmonitor_dirty_from_entries());
1738        if dirty.bit_size > self.fsmonitor_active_entry_count() {
1739            return Ok(());
1740        }
1741        let mut payload = Vec::new();
1742        payload.extend_from_slice(&FSMONITOR_EXT_VERSION2.to_be_bytes());
1743        payload.extend_from_slice(token.as_bytes());
1744        payload.push(0);
1745        let ewah_size_fixup = payload.len();
1746        payload.extend_from_slice(&0u32.to_be_bytes());
1747        let ewah_start = payload.len();
1748        dirty.serialize(&mut payload);
1749        let ewah_size = (payload.len() - ewah_start) as u32;
1750        payload[ewah_size_fixup..ewah_size_fixup + 4].copy_from_slice(&ewah_size.to_be_bytes());
1751        append_index_extension(out, eoie_headers, INDEX_EXT_FSMONITOR, &payload);
1752        Ok(())
1753    }
1754
1755    /// Number of index rows counted by the fsmonitor dirty bitmap (Git `cache_nr` minus `CE_REMOVE`).
1756    fn fsmonitor_active_entry_count(&self) -> usize {
1757        self.entries.len()
1758    }
1759
1760    /// Build the fsmonitor dirty EWAH from in-memory `CE_FSMONITOR_VALID` (`fill_fsmonitor_bitmap`).
1761    fn build_fsmonitor_dirty_from_entries(&self) -> crate::ewah_bitmap::EwahBitmap {
1762        use crate::ewah_bitmap::EwahBitmap;
1763        let mut dirty = EwahBitmap::new();
1764        if self.fsmonitor_last_update.is_none() {
1765            return dirty;
1766        }
1767        #[cfg(test)]
1768        tests::fsmonitor_rebuild_counter::note_build();
1769        for (i, entry) in self.entries.iter().enumerate() {
1770            if !entry.fsmonitor_valid() {
1771                dirty.set_bit_extend(i);
1772            }
1773        }
1774        dirty
1775    }
1776
1777    /// Whether the on-disk `FSMN` payload still matches token and fsmonitor-valid state.
1778    fn fsmonitor_raw_still_valid(&self) -> bool {
1779        use crate::ewah_bitmap::ewah_bitmaps_equal;
1780        let Some(raw) = self.fsmonitor_raw_extension.as_ref() else {
1781            return false;
1782        };
1783        let Some(token) = self.fsmonitor_last_update.as_deref() else {
1784            return false;
1785        };
1786        let Ok((raw_token, raw_dirty)) = parse_fsmonitor_extension(raw) else {
1787            return false;
1788        };
1789        if raw_token != token {
1790            return false;
1791        }
1792        let current = self.build_fsmonitor_dirty_from_entries();
1793        if !ewah_bitmaps_equal(&raw_dirty, &current) {
1794            return false;
1795        }
1796        current.bit_size <= self.fsmonitor_active_entry_count()
1797    }
1798
1799    /// Rebuild the in-memory fsmonitor dirty bitmap from `CE_FSMONITOR_VALID` (`fill_fsmonitor_bitmap`).
1800    fn rebuild_fsmonitor_dirty_from_entries(&mut self) {
1801        if self.fsmonitor_last_update.is_none() {
1802            self.fsmonitor_dirty = None;
1803            return;
1804        }
1805        self.fsmonitor_dirty = Some(self.build_fsmonitor_dirty_from_entries());
1806    }
1807
1808    /// Drop cached and on-disk fsmonitor snapshots; rebuild on the next read or write.
1809    fn invalidate_fsmonitor_bitmap(&mut self) {
1810        if self.fsmonitor_last_update.is_none() {
1811            return;
1812        }
1813        self.fsmonitor_raw_extension = None;
1814        self.fsmonitor_dirty = None;
1815    }
1816
1817    /// Update the fsmonitor token; clears any stale on-disk snapshot when the token changes.
1818    pub fn set_fsmonitor_last_update(&mut self, token: Option<String>) {
1819        if self.fsmonitor_last_update.as_deref() != token.as_deref() {
1820            self.fsmonitor_raw_extension = None;
1821        }
1822        self.fsmonitor_last_update = token;
1823        self.rebuild_fsmonitor_dirty_from_entries();
1824    }
1825
1826    /// Remove fsmonitor metadata from the index (extension and in-memory valid bits are cleared on write).
1827    pub fn clear_fsmonitor_extension(&mut self) {
1828        self.fsmonitor_last_update = None;
1829        self.fsmonitor_dirty = None;
1830        self.fsmonitor_raw_extension = None;
1831    }
1832
1833    /// Refresh the in-memory fsmonitor EWAH from entry valid bits (does not invalidate raw).
1834    pub fn sync_fsmonitor_dirty_bitmap(&mut self) {
1835        self.rebuild_fsmonitor_dirty_from_entries();
1836    }
1837
1838    /// Call after mutating entry fsmonitor-valid bits without changing index membership.
1839    pub fn fsmonitor_validity_changed(&mut self) {
1840        self.invalidate_fsmonitor_bitmap();
1841    }
1842
1843    /// Clear fsmonitor-valid on stage-0 entries without disturbing sort order.
1844    pub(crate) fn invalidate_all_fsmonitor_valid(&mut self) {
1845        for entry in &mut self.entries {
1846            if entry.stage() == 0 {
1847                entry.set_fsmonitor_valid(false);
1848            }
1849        }
1850        self.fsmonitor_validity_changed();
1851    }
1852
1853    /// Invalidate fsmonitor-valid bits for one hook-reported path (Git `fsmonitor_refresh_callback`).
1854    pub(crate) fn invalidate_fsmonitor_for_path(&mut self, path: &str, ignorecase: bool) {
1855        if let Some(uc) = self.untracked_cache.as_mut() {
1856            crate::untracked_cache::invalidate_path(uc, path);
1857        }
1858        let mut invalidated = if path.ends_with('/') {
1859            self.invalidate_fsmonitor_directory_cone(path.trim_end_matches('/'), ignorecase)
1860        } else {
1861            self.invalidate_fsmonitor_unqualified_path(path, ignorecase)
1862        };
1863        if !invalidated && ignorecase {
1864            invalidated = self.invalidate_fsmonitor_icase_fallback(path);
1865        }
1866        if invalidated {
1867            self.fsmonitor_validity_changed();
1868        }
1869    }
1870
1871    fn invalidate_fsmonitor_unqualified_path(&mut self, path: &str, ignorecase: bool) -> bool {
1872        if let Some(entry) = self.get_mut(path.as_bytes(), 0) {
1873            entry.set_fsmonitor_valid(false);
1874            return true;
1875        }
1876        if self.has_stage0_descendant_under(path) {
1877            return self.invalidate_fsmonitor_directory_cone(path, ignorecase);
1878        }
1879        false
1880    }
1881
1882    fn invalidate_fsmonitor_directory_cone(&mut self, prefix: &str, ignorecase: bool) -> bool {
1883        if prefix.is_empty() {
1884            self.invalidate_all_fsmonitor_valid();
1885            return true;
1886        }
1887        let prefix_slash = format!("{prefix}/");
1888        let mut any = false;
1889        for entry in &mut self.entries {
1890            if entry.stage() != 0 {
1891                continue;
1892            }
1893            let Some(rel) = std::str::from_utf8(&entry.path).ok() else {
1894                continue;
1895            };
1896            if Self::fsmonitor_path_under_prefix(rel, prefix, &prefix_slash, ignorecase) {
1897                entry.set_fsmonitor_valid(false);
1898                any = true;
1899            }
1900        }
1901        any
1902    }
1903
1904    fn invalidate_fsmonitor_icase_fallback(&mut self, path: &str) -> bool {
1905        let is_dir = path.ends_with('/');
1906        let prefix = path.trim_end_matches('/');
1907        let prefix_slash = format!("{prefix}/");
1908        let mut any = false;
1909        for entry in &mut self.entries {
1910            if entry.stage() != 0 {
1911                continue;
1912            }
1913            let Some(rel) = std::str::from_utf8(&entry.path).ok() else {
1914                continue;
1915            };
1916            let matches = if is_dir {
1917                Self::fsmonitor_path_under_prefix(rel, prefix, &prefix_slash, true)
1918            } else {
1919                rel.eq_ignore_ascii_case(prefix)
1920            };
1921            if matches {
1922                entry.set_fsmonitor_valid(false);
1923                any = true;
1924            }
1925        }
1926        any
1927    }
1928
1929    fn fsmonitor_path_under_prefix(
1930        rel: &str,
1931        prefix: &str,
1932        prefix_slash: &str,
1933        ignorecase: bool,
1934    ) -> bool {
1935        if ignorecase {
1936            rel.eq_ignore_ascii_case(prefix)
1937                || (rel.len() > prefix.len()
1938                    && rel.as_bytes().get(prefix.len()) == Some(&b'/')
1939                    && rel[..prefix.len()].eq_ignore_ascii_case(prefix))
1940        } else {
1941            rel == prefix || rel.starts_with(prefix_slash)
1942        }
1943    }
1944
1945    /// Drop byte-for-byte `FSMN` preservation after index entries change.
1946    fn touch_fsmonitor_after_entry_change(&mut self) {
1947        self.invalidate_fsmonitor_bitmap();
1948    }
1949
1950    /// Read-only view of index entries.
1951    #[must_use]
1952    pub fn entries(&self) -> &[IndexEntry] {
1953        &self.entries
1954    }
1955
1956    /// Mutable entry list; marks the index unsorted until [`Self::sort`].
1957    pub fn entries_mut(&mut self) -> &mut Vec<IndexEntry> {
1958        self.entries_sorted = false;
1959        &mut self.entries
1960    }
1961
1962    /// Mark `entries` as not in canonical `(path, stage)` order after direct vector mutation.
1963    pub fn mark_entries_unsorted(&mut self) {
1964        self.entries_sorted = false;
1965    }
1966
1967    /// Append an entry without maintaining sort order (used while building a merge index).
1968    pub(crate) fn push_entry_unsorted(&mut self, entry: IndexEntry) {
1969        self.entries.push(entry);
1970        self.entries_sorted = false;
1971    }
1972
1973    /// Whether [`Self::entries`] are in Git canonical `(path, stage)` order (full scan).
1974    #[must_use]
1975    pub fn entries_are_sorted(&self) -> bool {
1976        self.entries
1977            .windows(2)
1978            .all(|pair| (&pair[0].path, pair[0].stage()) <= (&pair[1].path, pair[1].stage()))
1979    }
1980
1981    /// Compare two index paths (counts comparisons in unit tests).
1982    fn cmp_paths(a: &[u8], b: &[u8]) -> std::cmp::Ordering {
1983        record_path_cmp_for_tests();
1984        a.cmp(b)
1985    }
1986
1987    fn entry_matches(entry: &IndexEntry, path: &[u8], stage: u8) -> bool {
1988        entry.path == path && entry.stage() == stage
1989    }
1990
1991    /// Index of `(path, stage)`; binary search when sorted, linear scan when not.
1992    pub(crate) fn find_entry_pos(&self, path: &[u8], stage: u8) -> Option<usize> {
1993        if self.entries_sorted {
1994            return match self.entries.binary_search_by(|e| {
1995                Self::cmp_paths(e.path.as_slice(), path).then_with(|| e.stage().cmp(&stage))
1996            }) {
1997                Ok(pos) if Self::entry_matches(&self.entries[pos], path, stage) => Some(pos),
1998                _ => None,
1999            };
2000        }
2001        self.entries
2002            .iter()
2003            .position(|e| Self::entry_matches(e, path, stage))
2004    }
2005
2006    /// Inclusive start and exclusive end of all entries sharing `path` (sorted indexes only).
2007    fn path_run_range_sorted(&self, path: &[u8]) -> (usize, usize) {
2008        debug_assert!(self.entries_sorted);
2009        let start = self
2010            .entries
2011            .partition_point(|e| Self::cmp_paths(e.path.as_slice(), path).is_lt());
2012        let end = start
2013            + self.entries[start..]
2014                .iter()
2015                .take_while(|e| e.path == path)
2016                .count();
2017        (start, end)
2018    }
2019
2020    /// Whether any stage-0 entry lies strictly under `rel_dir/` (or any stage-0 entry exists when
2021    /// `rel_dir` is empty).
2022    #[must_use]
2023    pub fn has_stage0_descendant_under(&self, rel_dir: &str) -> bool {
2024        if rel_dir.is_empty() {
2025            return self.entries.iter().any(|e| e.stage() == 0);
2026        }
2027        if self.entries_sorted {
2028            let (start, end) = self.descendant_run_range_sorted(rel_dir);
2029            return self.entries[start..end].iter().any(|e| e.stage() == 0);
2030        }
2031        let prefix = format!("{rel_dir}/");
2032        self.entries
2033            .iter()
2034            .any(|e| e.stage() == 0 && e.path.starts_with(prefix.as_bytes()))
2035    }
2036
2037    /// Inclusive start and exclusive end of entries strictly under `path/` (sorted indexes only).
2038    fn descendant_run_range_sorted(&self, path: &str) -> (usize, usize) {
2039        debug_assert!(self.entries_sorted);
2040        let prefix = path.as_bytes();
2041        let plen = prefix.len();
2042        let mut prefix_slash = Vec::with_capacity(plen + 1);
2043        prefix_slash.extend_from_slice(prefix);
2044        prefix_slash.push(b'/');
2045        let start = self.entries.partition_point(|e| {
2046            Self::cmp_paths(e.path.as_slice(), prefix_slash.as_slice()).is_lt()
2047        });
2048        let mut end = start;
2049        while end < self.entries.len() {
2050            let ep = self.entries[end].path.as_slice();
2051            if ep.len() > plen && ep.starts_with(prefix) && ep[plen] == b'/' {
2052                end += 1;
2053            } else {
2054                break;
2055            }
2056        }
2057        (start, end)
2058    }
2059
2060    fn record_resolve_undo_for_path(&mut self, path: &[u8]) {
2061        for e in &self.entries {
2062            if e.path == path && e.stage() != 0 {
2063                resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, e);
2064            }
2065        }
2066    }
2067
2068    fn record_resolve_undo_for_run(&mut self, start: usize, end: usize) {
2069        for e in &self.entries[start..end] {
2070            if e.stage() != 0 {
2071                resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, e);
2072            }
2073        }
2074    }
2075
2076    /// Drop derived `TREE` / untracked-cache extensions before a large checkout rewrite.
2077    pub(crate) fn drop_derived_index_extensions(&mut self) {
2078        self.cache_tree = None;
2079        self.cache_tree_root = None;
2080        self.untracked_cache = None;
2081    }
2082
2083    /// Partially invalidate cache-tree nodes for a bulk checkout without dropping the whole extension.
2084    pub(crate) fn invalidate_cache_tree_for_bulk_checkout<'a>(
2085        &mut self,
2086        dir_prefixes: &BTreeSet<String>,
2087        paths: impl IntoIterator<Item = &'a [u8]>,
2088        replacements: &[IndexEntry],
2089    ) {
2090        if self.cache_tree.is_none() {
2091            return;
2092        }
2093        for dir in dir_prefixes {
2094            self.invalidate_cache_tree_for_path(dir.as_bytes());
2095        }
2096        for path in paths {
2097            self.invalidate_cache_tree_for_path(path);
2098        }
2099        for entry in replacements {
2100            self.invalidate_cache_tree_for_path(&entry.path);
2101        }
2102    }
2103
2104    fn invalidate_after_path_removal(&mut self, path: &[u8]) {
2105        if let Ok(p) = std::str::from_utf8(path) {
2106            self.invalidate_untracked_cache_for_path(p);
2107        }
2108        self.invalidate_cache_tree_for_path(path);
2109    }
2110
2111    /// One index compaction pass: drop entries under `dir_prefixes`, drop exact `paths`, merge `replacements`.
2112    pub(crate) fn remove_prefixes_paths_and_insert<'a>(
2113        &mut self,
2114        dir_prefixes: &BTreeSet<String>,
2115        paths: impl IntoIterator<Item = &'a [u8]>,
2116        replacements: impl IntoIterator<Item = IndexEntry>,
2117    ) {
2118        let mut replacements: Vec<IndexEntry> = replacements.into_iter().collect();
2119        if !replacements.is_empty() {
2120            replacements
2121                .sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.stage().cmp(&b.stage())));
2122            dedup_sorted_replacement_entries(&mut replacements);
2123        }
2124
2125        let mut paths: Vec<&[u8]> = paths.into_iter().collect();
2126        paths.sort_unstable_by(|a, b| Self::cmp_paths(a, b));
2127        paths.dedup_by(|a, b| a == b);
2128
2129        let mut prefix_bytes: Vec<&[u8]> = dir_prefixes.iter().map(|d| d.as_bytes()).collect();
2130        prefix_bytes.sort_unstable_by(|a, b| Self::cmp_paths(a, b));
2131        prefix_bytes.dedup_by(|a, b| a == b);
2132
2133        if dir_prefixes.is_empty() && paths.is_empty() {
2134            self.merge_replacements_into_sorted_index(replacements);
2135            return;
2136        }
2137
2138        let mut invalidated_paths: Vec<Vec<u8>> = Vec::new();
2139        let mut kept = Vec::with_capacity(self.entries.len());
2140        let skip_per_path_extension_inval = self.defer_untracked_cache_invalidation;
2141
2142        if self.entries_sorted {
2143            let mut path_idx = 0usize;
2144            for entry in self.entries.drain(..) {
2145                let ep = entry.path.as_slice();
2146                while path_idx < paths.len() && Self::cmp_paths(paths[path_idx], ep).is_lt() {
2147                    path_idx += 1;
2148                }
2149                let remove = (path_idx < paths.len()
2150                    && Self::cmp_paths(paths[path_idx], ep).is_eq())
2151                    || Self::entry_under_any_directory_prefix(ep, &prefix_bytes);
2152                if remove {
2153                    if entry.stage() != 0 {
2154                        resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, &entry);
2155                    }
2156                    if !skip_per_path_extension_inval
2157                        && invalidated_paths.last().is_none_or(|p| p.as_slice() != ep)
2158                    {
2159                        invalidated_paths.push(entry.path.clone());
2160                    }
2161                } else {
2162                    kept.push(entry);
2163                }
2164            }
2165            let repl_for_inval: Vec<Vec<u8>> = if skip_per_path_extension_inval {
2166                Vec::new()
2167            } else {
2168                replacements
2169                    .iter()
2170                    .filter(|e| e.stage() == 0)
2171                    .map(|e| e.path.clone())
2172                    .collect()
2173            };
2174            self.entries = merge_sorted_index_entries(kept, replacements);
2175            self.entries_sorted = true;
2176            if skip_per_path_extension_inval {
2177                self.untracked_cache = None;
2178                self.touch_fsmonitor_after_entry_change();
2179                return;
2180            }
2181            const BULK_INDEX_EXTENSION_INVALIDATE_MIN: usize = 64;
2182            let mut invalidations = dir_prefixes.len();
2183            for path in &invalidated_paths {
2184                if dir_prefixes
2185                    .iter()
2186                    .all(|d| !Self::entry_under_directory_prefix(path.as_slice(), d.as_bytes()))
2187                {
2188                    invalidations += 1;
2189                }
2190            }
2191            invalidations += repl_for_inval.len();
2192            if invalidations >= BULK_INDEX_EXTENSION_INVALIDATE_MIN {
2193                self.drop_derived_index_extensions();
2194            } else {
2195                for dir in dir_prefixes {
2196                    self.invalidate_after_path_removal(dir.as_bytes());
2197                }
2198                for path in invalidated_paths {
2199                    if dir_prefixes
2200                        .iter()
2201                        .all(|d| !Self::entry_under_directory_prefix(path.as_slice(), d.as_bytes()))
2202                    {
2203                        self.invalidate_after_path_removal(&path);
2204                    }
2205                }
2206                for path in repl_for_inval {
2207                    if let Ok(p) = std::str::from_utf8(&path) {
2208                        self.invalidate_untracked_cache_for_path(p);
2209                    }
2210                    self.invalidate_cache_tree_for_path(&path);
2211                }
2212            }
2213            self.touch_fsmonitor_after_entry_change();
2214            return;
2215        }
2216        if !dir_prefixes.is_empty() {
2217            self.remove_directory_prefixes(dir_prefixes);
2218        }
2219        self.remove_paths_and_insert(paths, replacements);
2220    }
2221
2222    /// Remove every entry whose path equals `dir` or lies under `dir/` for each `dir` in `dirs`.
2223    pub(crate) fn remove_directory_prefixes(&mut self, dirs: &BTreeSet<String>) {
2224        if dirs.is_empty() {
2225            return;
2226        }
2227        let mut prefixes: Vec<&[u8]> = dirs.iter().map(|d| d.as_bytes()).collect();
2228        prefixes.sort_unstable_by(|a, b| Self::cmp_paths(a, b));
2229        prefixes.dedup_by(|a, b| a == b);
2230
2231        let mut invalidated_paths: Vec<Vec<u8>> = Vec::new();
2232        let mut kept = Vec::with_capacity(self.entries.len());
2233
2234        if self.entries_sorted {
2235            for entry in self.entries.drain(..) {
2236                let ep = entry.path.as_slice();
2237                let remove = Self::entry_under_any_directory_prefix(ep, &prefixes);
2238                if remove {
2239                    if entry.stage() != 0 {
2240                        resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, &entry);
2241                    }
2242                    if invalidated_paths.last().is_none_or(|p| p.as_slice() != ep) {
2243                        invalidated_paths.push(entry.path.clone());
2244                    }
2245                } else {
2246                    kept.push(entry);
2247                }
2248            }
2249            self.entries = kept;
2250        } else {
2251            for entry in self.entries.drain(..) {
2252                let remove =
2253                    Self::entry_under_any_directory_prefix(entry.path.as_slice(), &prefixes);
2254                if remove {
2255                    if entry.stage() != 0 {
2256                        resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, &entry);
2257                    }
2258                    if invalidated_paths
2259                        .last()
2260                        .is_none_or(|p| p.as_slice() != entry.path)
2261                    {
2262                        invalidated_paths.push(entry.path.clone());
2263                    }
2264                } else {
2265                    kept.push(entry);
2266                }
2267            }
2268            self.entries = kept;
2269        }
2270        if invalidated_paths.is_empty() {
2271            return;
2272        }
2273        for dir in dirs {
2274            self.invalidate_after_path_removal(dir.as_bytes());
2275        }
2276        self.touch_fsmonitor_after_entry_change();
2277    }
2278
2279    fn entry_under_directory_prefix(path: &[u8], prefix: &[u8]) -> bool {
2280        if path.len() < prefix.len() {
2281            return false;
2282        }
2283        if path[..prefix.len()] != *prefix {
2284            return false;
2285        }
2286        path.len() == prefix.len() || path[prefix.len()] == b'/'
2287    }
2288
2289    fn entry_under_any_directory_prefix(path: &[u8], prefixes: &[&[u8]]) -> bool {
2290        prefixes
2291            .iter()
2292            .any(|prefix| Self::entry_under_directory_prefix(path, prefix))
2293    }
2294
2295    /// Remove many paths (all merge stages per path) in one `O(N + M log M)` compaction pass.
2296    pub fn remove_paths<'a>(&mut self, paths: impl IntoIterator<Item = &'a [u8]>) {
2297        use std::collections::BTreeSet;
2298
2299        let mut paths: Vec<&[u8]> = paths.into_iter().collect();
2300        if paths.is_empty() {
2301            return;
2302        }
2303        paths.sort_unstable_by(|a, b| Self::cmp_paths(a, b));
2304        paths.dedup_by(|a, b| a == b);
2305
2306        let mut invalidated_paths: Vec<Vec<u8>> = Vec::new();
2307        let mut kept = Vec::with_capacity(self.entries.len());
2308
2309        if self.entries_sorted {
2310            let mut path_idx = 0usize;
2311            for entry in self.entries.drain(..) {
2312                let ep = entry.path.as_slice();
2313                while path_idx < paths.len() && Self::cmp_paths(paths[path_idx], ep).is_lt() {
2314                    path_idx += 1;
2315                }
2316                let remove = path_idx < paths.len() && Self::cmp_paths(paths[path_idx], ep).is_eq();
2317                if remove {
2318                    if entry.stage() != 0 {
2319                        resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, &entry);
2320                    }
2321                    if invalidated_paths.last().is_none_or(|p| p.as_slice() != ep) {
2322                        invalidated_paths.push(entry.path.clone());
2323                    }
2324                } else {
2325                    kept.push(entry);
2326                }
2327            }
2328            self.entries = kept;
2329        } else {
2330            let remove_set: BTreeSet<&[u8]> = paths.into_iter().collect();
2331            for entry in self.entries.drain(..) {
2332                if remove_set.contains(entry.path.as_slice()) {
2333                    if entry.stage() != 0 {
2334                        resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, &entry);
2335                    }
2336                    if invalidated_paths
2337                        .last()
2338                        .is_none_or(|p| p.as_slice() != entry.path.as_slice())
2339                    {
2340                        invalidated_paths.push(entry.path.clone());
2341                    }
2342                } else {
2343                    kept.push(entry);
2344                }
2345            }
2346            self.entries = kept;
2347        }
2348
2349        for path in invalidated_paths {
2350            self.invalidate_after_path_removal(&path);
2351        }
2352        self.touch_fsmonitor_after_entry_change();
2353    }
2354
2355    /// Replace many stage-0 entries in one sorted merge (bulk `git add` restage).
2356    pub(crate) fn replace_stage0_entries_batch(&mut self, replacements: Vec<IndexEntry>) {
2357        self.merge_replacements_into_sorted_index(replacements);
2358    }
2359
2360    /// Record that the worktree file at `path` was just hashed to `oid` (see
2361    /// [`Self::content_verified`]), so the next write does not re-read it for the racy check.
2362    pub(crate) fn mark_content_verified(&mut self, path: &[u8], oid: ObjectId) {
2363        self.content_verified.insert(path.to_vec(), oid);
2364    }
2365
2366    /// Overwrite the stage-0 rows that already hold each replacement's path, in place.
2367    ///
2368    /// Same result and cache invalidation as [`Self::replace_stage0_entries_batch`], without
2369    /// growing `entries` to merge: restaging only changes rows, never their order. Replacements
2370    /// with no stage-0 row at their path (or not at stage 0) go through the sorted merge.
2371    pub(crate) fn overwrite_stage0_entries(&mut self, replacements: Vec<IndexEntry>) {
2372        const BULK_INVALIDATE_MIN: usize = 64;
2373        if replacements.is_empty() {
2374            return;
2375        }
2376        let defer_untracked = self.defer_untracked_cache_invalidation;
2377        let drop_caches = !defer_untracked && replacements.len() >= BULK_INVALIDATE_MIN;
2378        let mut unmatched = Vec::new();
2379        for mut entry in replacements {
2380            let pos = if entry.stage() == 0 {
2381                self.find_entry_pos(&entry.path, 0)
2382            } else {
2383                None
2384            };
2385            let Some(pos) = pos else {
2386                unmatched.push(entry);
2387                continue;
2388            };
2389            if !drop_caches {
2390                if !defer_untracked {
2391                    if let Ok(p) = std::str::from_utf8(&entry.path) {
2392                        self.invalidate_untracked_cache_for_path(p);
2393                    }
2394                }
2395                self.invalidate_cache_tree_for_path(&entry.path);
2396            }
2397            entry.base_index_pos = self.entries[pos].base_index_pos;
2398            self.entries[pos] = entry;
2399        }
2400        if drop_caches {
2401            self.cache_tree = None;
2402            self.cache_tree_root = None;
2403            self.untracked_cache = None;
2404        }
2405        self.touch_fsmonitor_after_entry_change();
2406        if !unmatched.is_empty() {
2407            self.merge_replacements_into_sorted_index(unmatched);
2408        }
2409    }
2410
2411    /// Append sorted `replacements` to a sorted index and merge in one linear pass.
2412    fn merge_sorted_replacements_into_entries(&mut self, mut replacements: Vec<IndexEntry>) {
2413        if replacements.is_empty() {
2414            return;
2415        }
2416        self.entries.reserve(replacements.len());
2417        let mid = self.entries.len();
2418        self.entries.append(&mut replacements);
2419        merge_sorted_index_adjacent_halves(&mut self.entries, mid);
2420    }
2421
2422    /// Merge sorted `replacements` into a sorted index in one linear pass.
2423    fn merge_replacements_into_sorted_index(&mut self, replacements: Vec<IndexEntry>) {
2424        if replacements.is_empty() {
2425            return;
2426        }
2427        if !self.entries_sorted {
2428            for entry in replacements {
2429                self.add_or_replace(entry);
2430            }
2431            return;
2432        }
2433        const BULK_MERGE_MIN: usize = 64;
2434        if self.defer_untracked_cache_invalidation {
2435            // Campaign-scale tracked restage: merge in bulk but keep UNTR and invalidate only
2436            // touched cache-tree paths (issue #1184).
2437            for entry in replacements.iter().filter(|e| e.stage() == 0) {
2438                self.invalidate_cache_tree_for_path(&entry.path);
2439            }
2440            self.merge_sorted_replacements_into_entries(replacements);
2441            self.touch_fsmonitor_after_entry_change();
2442            return;
2443        }
2444        let repl_for_inval: Vec<Vec<u8>> = replacements
2445            .iter()
2446            .filter(|e| e.stage() == 0)
2447            .map(|e| e.path.clone())
2448            .collect();
2449        self.merge_sorted_replacements_into_entries(replacements);
2450        if repl_for_inval.len() >= BULK_MERGE_MIN {
2451            self.cache_tree = None;
2452            self.cache_tree_root = None;
2453            self.untracked_cache = None;
2454            self.touch_fsmonitor_after_entry_change();
2455            return;
2456        }
2457        for path in repl_for_inval {
2458            if let Ok(p) = std::str::from_utf8(&path) {
2459                self.invalidate_untracked_cache_for_path(p);
2460            }
2461            self.invalidate_cache_tree_for_path(&path);
2462        }
2463        self.touch_fsmonitor_after_entry_change();
2464    }
2465
2466    /// Remove every entry at `paths` (all stages), then merge `replacements` in sorted order.
2467    ///
2468    /// When the index is already sorted, this is one removal scan plus an `O(N + M)` merge,
2469    /// avoiding per-path `Vec::drain` shifts and repeated `insert`/`sort` work.
2470    pub fn remove_paths_and_insert<'a>(
2471        &mut self,
2472        paths: impl IntoIterator<Item = &'a [u8]>,
2473        replacements: impl IntoIterator<Item = IndexEntry>,
2474    ) {
2475        let mut replacements: Vec<IndexEntry> = replacements.into_iter().collect();
2476        if replacements.is_empty() {
2477            self.remove_paths(paths);
2478            return;
2479        }
2480        replacements.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.stage().cmp(&b.stage())));
2481        dedup_sorted_replacement_entries(&mut replacements);
2482
2483        let mut paths: Vec<&[u8]> = paths.into_iter().collect();
2484        if paths.is_empty() {
2485            self.merge_replacements_into_sorted_index(replacements);
2486            return;
2487        }
2488        paths.sort_unstable_by(|a, b| Self::cmp_paths(a, b));
2489        paths.dedup_by(|a, b| a == b);
2490
2491        let mut invalidated_paths: Vec<Vec<u8>> = Vec::new();
2492        let mut kept = Vec::with_capacity(self.entries.len());
2493
2494        if self.entries_sorted {
2495            let mut path_idx = 0usize;
2496            for entry in self.entries.drain(..) {
2497                let ep = entry.path.as_slice();
2498                while path_idx < paths.len() && Self::cmp_paths(paths[path_idx], ep).is_lt() {
2499                    path_idx += 1;
2500                }
2501                let remove = path_idx < paths.len() && Self::cmp_paths(paths[path_idx], ep).is_eq();
2502                if remove {
2503                    if entry.stage() != 0 {
2504                        resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, &entry);
2505                    }
2506                    if invalidated_paths.last().is_none_or(|p| p.as_slice() != ep) {
2507                        invalidated_paths.push(entry.path.clone());
2508                    }
2509                } else {
2510                    kept.push(entry);
2511                }
2512            }
2513            let repl_for_inval = replacements
2514                .iter()
2515                .filter(|e| e.stage() == 0)
2516                .map(|e| e.path.clone())
2517                .collect::<Vec<_>>();
2518            self.entries = merge_sorted_index_entries(kept, replacements);
2519            self.entries_sorted = true;
2520            for path in invalidated_paths {
2521                self.invalidate_after_path_removal(&path);
2522            }
2523            for path in repl_for_inval {
2524                if let Ok(p) = std::str::from_utf8(&path) {
2525                    self.invalidate_untracked_cache_for_path(p);
2526                }
2527                self.invalidate_cache_tree_for_path(&path);
2528            }
2529            self.touch_fsmonitor_after_entry_change();
2530            return;
2531        }
2532        self.remove_paths(paths);
2533        for entry in replacements {
2534            self.add_or_replace(entry);
2535        }
2536    }
2537
2538    /// Add or replace an entry (matched by path + stage).
2539    pub fn add_or_replace(&mut self, entry: IndexEntry) {
2540        let path = entry.path.clone();
2541        let stage = entry.stage();
2542        let mut inserted_stage0 = false;
2543        if self.entries_sorted {
2544            let result = self.entries.binary_search_by(|e| {
2545                e.path
2546                    .as_slice()
2547                    .cmp(path.as_slice())
2548                    .then_with(|| e.stage().cmp(&stage))
2549            });
2550            match result {
2551                Ok(pos) => {
2552                    let mut e = entry;
2553                    e.base_index_pos = self.entries[pos].base_index_pos;
2554                    self.entries[pos] = e;
2555                }
2556                Err(pos) => {
2557                    self.entries.insert(pos, entry);
2558                    inserted_stage0 = stage == 0;
2559                }
2560            }
2561        } else if let Some(pos) = self
2562            .entries
2563            .iter()
2564            .position(|e| e.path == path && e.stage() == stage)
2565        {
2566            let mut e = entry;
2567            e.base_index_pos = self.entries[pos].base_index_pos;
2568            self.entries[pos] = e;
2569        } else {
2570            self.entries.push(entry);
2571            inserted_stage0 = stage == 0;
2572        }
2573        if inserted_stage0 {
2574            if let Ok(p) = std::str::from_utf8(&path) {
2575                self.invalidate_untracked_cache_for_path(p);
2576            }
2577        }
2578        if stage == 0 {
2579            self.invalidate_cache_tree_for_path(&path);
2580        }
2581        self.touch_fsmonitor_after_entry_change();
2582    }
2583
2584    /// Returns whether the index contains any unmerged (stage 1/2/3) entries.
2585    #[must_use]
2586    pub fn has_unmerged_entries(&self) -> bool {
2587        self.entries.iter().any(|e| e.stage() != 0)
2588    }
2589
2590    /// Stage a file at stage 0, removing any conflict stage entries (1, 2, 3)
2591    /// for the same path. This is the correct behavior for `git add` on a
2592    /// conflicted file during merge/cherry-pick resolution.
2593    pub fn stage_file(&mut self, entry: IndexEntry) {
2594        let path = entry.path.clone();
2595        if self.entries_sorted {
2596            let (start, end) = self.path_run_range_sorted(&path);
2597            for i in (start..end).rev() {
2598                if self.entries[i].stage() != 0 {
2599                    resolve_undo::record_resolve_undo_for_entry(
2600                        &mut self.resolve_undo,
2601                        &self.entries[i],
2602                    );
2603                    self.entries.remove(i);
2604                }
2605            }
2606        } else {
2607            for e in &self.entries {
2608                if e.path == path && e.stage() != 0 {
2609                    resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, e);
2610                }
2611            }
2612            self.entries.retain(|e| e.path != path || e.stage() == 0);
2613        }
2614        self.add_or_replace(entry);
2615    }
2616
2617    /// Drop all resolve-undo records (matches Git `resolve_undo_clear_index`).
2618    pub fn clear_resolve_undo(&mut self) {
2619        self.resolve_undo = None;
2620    }
2621
2622    /// Remove and return the resolve-undo record for `path`, if any.
2623    pub fn take_resolve_undo_record(&mut self, path: &[u8]) -> Option<ResolveUndoRecord> {
2624        let map = self.resolve_undo.as_mut()?;
2625        let ru = map.remove(path)?;
2626        if map.is_empty() {
2627            self.resolve_undo = None;
2628        }
2629        Some(ru)
2630    }
2631
2632    /// Replace all index entries for `path` with unmerged stages from `record`.
2633    pub fn install_unmerged_from_resolve_undo(&mut self, path: &[u8], record: &ResolveUndoRecord) {
2634        if self.entries_sorted {
2635            let (start, end) = self.path_run_range_sorted(path);
2636            if start < end {
2637                self.entries.drain(start..end);
2638            }
2639        } else {
2640            self.entries.retain(|e| e.path != path);
2641        }
2642        for stage in 1u8..=3u8 {
2643            let i = (stage - 1) as usize;
2644            if record.modes[i] == 0 {
2645                continue;
2646            }
2647            let entry = IndexEntry {
2648                ctime_sec: 0,
2649                ctime_nsec: 0,
2650                mtime_sec: 0,
2651                mtime_nsec: 0,
2652                dev: 0,
2653                ino: 0,
2654                mode: record.modes[i],
2655                uid: 0,
2656                gid: 0,
2657                size: 0,
2658                oid: record.oids[i],
2659                flags: path.len().min(0xFFF) as u16 | ((stage as u16) << 12),
2660                flags_extended: None,
2661                path: path.to_vec(),
2662                base_index_pos: 0,
2663            };
2664            self.add_or_replace(entry);
2665        }
2666    }
2667
2668    /// Re-create unmerged index entries for `path` from the resolve-undo extension.
2669    ///
2670    /// Returns `true` when a resolve-undo record existed and was consumed (Git `unmerge_one`).
2671    pub fn unmerge_path_from_resolve_undo(&mut self, path: &[u8]) -> bool {
2672        let Some(record) = self.take_resolve_undo_record(path) else {
2673            return false;
2674        };
2675        self.install_unmerged_from_resolve_undo(path, &record);
2676        // Git's `unmerge_index_entry` swaps the resolved stage-0 entry for unmerged
2677        // stages 1/2/3 via `remove_index_entry_at` + `add_index_entry`, both of which
2678        // call `cache_tree_invalidate_path`. After committing the resolution the index
2679        // carries a valid TREE extension whose stage-0 entry for `path` no longer
2680        // matches; without invalidating it, the next write-tree trips
2681        // `verify_cache_tree` ("corrupted cache-tree has entries not present in index").
2682        if let Ok(p) = std::str::from_utf8(path) {
2683            self.invalidate_untracked_cache_for_path(p);
2684        }
2685        self.invalidate_cache_tree_for_path(path);
2686        true
2687    }
2688
2689    /// Remove all entries matching the given path (all stages).
2690    ///
2691    /// Returns `true` if at least one entry was removed.
2692    pub fn remove(&mut self, path: &[u8]) -> bool {
2693        if self.entries_sorted {
2694            let (start, end) = self.path_run_range_sorted(path);
2695            if start < end {
2696                self.record_resolve_undo_for_run(start, end);
2697                self.entries.drain(start..end);
2698                self.invalidate_after_path_removal(path);
2699                self.touch_fsmonitor_after_entry_change();
2700                return true;
2701            }
2702            if !self.entries.iter().any(|e| e.path == path) {
2703                return false;
2704            }
2705            self.entries_sorted = false;
2706        }
2707        self.record_resolve_undo_for_path(path);
2708        let before = self.entries.len();
2709        self.entries.retain(|e| e.path != path);
2710        if self.entries.len() == before {
2711            return false;
2712        }
2713        self.invalidate_after_path_removal(path);
2714        self.touch_fsmonitor_after_entry_change();
2715        true
2716    }
2717
2718    /// Remove every index entry for `path` (all merge stages), like `remove_file_from_index`.
2719    ///
2720    /// Returns whether any entry was removed.
2721    pub fn remove_path_all_stages(&mut self, path: &[u8]) -> bool {
2722        self.remove(path)
2723    }
2724
2725    /// Invalidate UNTR nodes affected by an index change (Git `untracked_cache_*_index`).
2726    pub fn invalidate_untracked_cache_for_path(&mut self, path: &str) {
2727        if self.defer_untracked_cache_invalidation {
2728            return;
2729        }
2730        if let Some(uc) = self.untracked_cache.as_mut() {
2731            untracked_cache::invalidate_path(uc, path);
2732        }
2733    }
2734
2735    /// Remove every index entry whose path lies strictly under `path` (all stages).
2736    ///
2737    /// Used when staging a file at `path` that replaces a former directory: Git removes
2738    /// tracked paths like `path/child` from the index so they do not remain alongside
2739    /// the new blob entry.
2740    pub fn remove_descendants_under_path(&mut self, path: &str) {
2741        let prefix = path.as_bytes();
2742        if prefix.is_empty() {
2743            return;
2744        }
2745        let plen = prefix.len();
2746        if self.entries_sorted {
2747            let (start, end) = self.descendant_run_range_sorted(path);
2748            if start < end {
2749                self.record_resolve_undo_for_run(start, end);
2750                self.entries.drain(start..end);
2751                self.invalidate_untracked_cache_for_path(path);
2752                self.invalidate_cache_tree_for_path(path.as_bytes());
2753                return;
2754            }
2755            let any = self.entries.iter().any(|e| {
2756                let ep = e.path.as_slice();
2757                ep.len() > plen && ep.starts_with(prefix) && ep[plen] == b'/'
2758            });
2759            if !any {
2760                return;
2761            }
2762            self.entries_sorted = false;
2763        }
2764        {
2765            let had_descendant = self.entries.iter().any(|e| {
2766                let ep = e.path.as_slice();
2767                ep.len() > plen && ep.starts_with(prefix) && ep[plen] == b'/'
2768            });
2769            for e in self.entries.iter() {
2770                let ep = e.path.as_slice();
2771                if ep.len() > plen && ep.starts_with(prefix) && ep[plen] == b'/' && e.stage() != 0 {
2772                    resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, e);
2773                }
2774            }
2775            self.entries.retain(|e| {
2776                let ep = e.path.as_slice();
2777                if ep.len() <= plen {
2778                    return true;
2779                }
2780                if !ep.starts_with(prefix) {
2781                    return true;
2782                }
2783                ep[plen] != b'/'
2784            });
2785            if !had_descendant {
2786                return;
2787            }
2788        }
2789        self.invalidate_untracked_cache_for_path(path);
2790        self.invalidate_cache_tree_for_path(path.as_bytes());
2791    }
2792
2793    /// Replace the cache-tree extension with a valid tree.
2794    pub fn set_cache_tree(&mut self, cache_tree: CacheTreeNode) {
2795        self.cache_tree_root = cache_tree.oid.filter(|_| cache_tree.entry_count >= 0);
2796        self.cache_tree = Some(cache_tree);
2797    }
2798
2799    /// Remove the cache-tree extension.
2800    pub fn clear_cache_tree(&mut self) {
2801        self.cache_tree_root = None;
2802        self.cache_tree = None;
2803    }
2804
2805    /// Mark cache-tree nodes affected by an index path change as invalid.
2806    ///
2807    /// Mirrors Git's `do_invalidate_path` (`cache-tree.c`): each ancestor node along `path`
2808    /// is invalidated (`entry_count = -1`), and when the final path component names an existing
2809    /// **subtree** node, that subtree is removed entirely. The removal is essential for the
2810    /// directory→file transition (e.g. tracked dir `a/b/` replaced by file `a/b`): without it,
2811    /// stale descendant nodes (`a/b/c`, …) keep their positive `entry_count` and later trip
2812    /// [`crate::write_tree::verify_cache_tree`] with "corrupted cache-tree has entries not present
2813    /// in index".
2814    pub fn invalidate_cache_tree_for_path(&mut self, path: &[u8]) {
2815        let Some(root) = self.cache_tree.as_mut() else {
2816            self.cache_tree_root = None;
2817            return;
2818        };
2819        self.cache_tree_root = None;
2820        Self::do_invalidate_cache_tree_path(root, path);
2821    }
2822
2823    /// Recursive worker for [`Self::invalidate_cache_tree_for_path`], mirroring
2824    /// Git's `do_invalidate_path`.
2825    fn do_invalidate_cache_tree_path(node: &mut CacheTreeNode, path: &[u8]) {
2826        node.invalidate();
2827        let slash = path.iter().position(|&b| b == b'/');
2828        match slash {
2829            // Final component: drop the matching subtree node, if any.
2830            None => {
2831                if !path.is_empty() {
2832                    node.children.retain(|child| child.name != path);
2833                }
2834            }
2835            // Interior component: descend into the named subtree and recurse on the rest.
2836            Some(idx) => {
2837                let (component, rest) = (&path[..idx], &path[idx + 1..]);
2838                if let Some(child) = node
2839                    .children
2840                    .iter_mut()
2841                    .find(|child| child.name == component)
2842                {
2843                    Self::do_invalidate_cache_tree_path(child, rest);
2844                }
2845            }
2846        }
2847    }
2848
2849    /// Format the parsed cache-tree extension like Git's `test-tool dump-cache-tree`.
2850    #[must_use]
2851    pub fn format_cache_tree_dump(&self) -> String {
2852        let Some(root) = self.cache_tree.as_ref() else {
2853            return String::new();
2854        };
2855        let mut out = String::new();
2856        format_cache_tree_node(root, "", &mut out);
2857        out
2858    }
2859
2860    /// Produce `test-tool dump-cache-tree` output for this index.
2861    ///
2862    /// Mirrors Git's `cmd__dump_cache_tree`: the stored cache-tree (`it`) is
2863    /// compared against a freshly computed reference (`ref`) built from the
2864    /// current index entries with `WRITE_TREE_DRY_RUN`. Only nodes present in
2865    /// both trees are dumped; the `#(ref)` divergence lines that Git would emit
2866    /// are filtered out by the harness, so they are intentionally omitted here.
2867    ///
2868    /// If the index has no stored cache-tree, output is empty (Git dumps nothing
2869    /// because `it` is NULL).
2870    ///
2871    /// # Errors
2872    ///
2873    /// Returns an error if the reference cache-tree cannot be built (for example,
2874    /// if a tree object cannot be written to the object database).
2875    pub fn dump_cache_tree(&self, odb: &Odb) -> Result<String> {
2876        let Some(it_root) = self.cache_tree.as_ref() else {
2877            return Ok(String::new());
2878        };
2879        let ref_root = crate::write_tree::build_cache_tree_from_index(odb, self)?;
2880        let mut out = String::new();
2881        dump_cache_tree_pair(it_root, &ref_root, "", &mut out);
2882        Ok(out)
2883    }
2884
2885    /// Sort entries in Git's canonical order: by path, then by stage.
2886    pub fn sort(&mut self) {
2887        self.ensure_canonical_order();
2888    }
2889
2890    /// Restore Git canonical `(path, stage)` order before writing the index.
2891    ///
2892    /// Unlike [`Self::sort`], this re-sorts when the sorted flag is stale (for example after
2893    /// appending entries without using [`Self::add_or_replace`]).
2894    pub fn ensure_canonical_order(&mut self) {
2895        if self.entries_sorted && self.entries_are_sorted() {
2896            return;
2897        }
2898        self.sort_entries_canonical();
2899    }
2900
2901    fn sort_entries_canonical(&mut self) {
2902        self.entries
2903            .sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.stage().cmp(&b.stage())));
2904        self.entries_sorted = true;
2905    }
2906
2907    /// Collapse duplicate `(path, stage)` entries, keeping the **last** one in current order.
2908    ///
2909    /// A valid Git index never holds two entries with the same path and stage; `add_index_entry`
2910    /// replaces an existing same-name entry in place. When grit builds an index by flattening a
2911    /// tree that has duplicate path entries (see `t4058-diff-duplicates`), the naive flatten yields
2912    /// several identical-path entries. This restores Git's invariant by keeping the last entry for
2913    /// each `(path, stage)` — matching `add_index_entry`'s replace-on-collision semantics where the
2914    /// final tree entry for a path wins.
2915    pub fn dedup_paths_keep_last(&mut self) {
2916        let mut seen: std::collections::HashSet<(Vec<u8>, u8)> = std::collections::HashSet::new();
2917        let mut kept: Vec<IndexEntry> = Vec::with_capacity(self.entries.len());
2918        // Walk in reverse so the *last* occurrence of each (path, stage) is the one retained.
2919        for entry in self.entries.iter().rev() {
2920            if seen.insert((entry.path.clone(), entry.stage())) {
2921                kept.push(entry.clone());
2922            }
2923        }
2924        kept.reverse();
2925        self.entries = kept;
2926        self.entries_sorted = false;
2927    }
2928
2929    /// OID of the shared index when this index uses split-index mode (`link` extension).
2930    #[must_use]
2931    pub fn split_index_base_oid(&self) -> Option<ObjectId> {
2932        self.split_link.as_ref().map(|l| l.base_oid)
2933    }
2934
2935    /// Find an entry by path and stage (0 for normal entries).
2936    #[must_use]
2937    pub fn get(&self, path: &[u8], stage: u8) -> Option<&IndexEntry> {
2938        self.find_entry_pos(path, stage)
2939            .map(|pos| &self.entries[pos])
2940    }
2941
2942    /// Find a mutable entry by path and stage.
2943    pub fn get_mut(&mut self, path: &[u8], stage: u8) -> Option<&mut IndexEntry> {
2944        let pos = self.find_entry_pos(path, stage)?;
2945        Some(&mut self.entries[pos])
2946    }
2947
2948    /// Index positions of stage-0 entries, keyed like [`index_path_outcome_key`].
2949    ///
2950    /// Used when ordering [`crate::porcelain::add::StageOutcome`] path lists without repeated
2951    /// linear scans. Valid UTF-8 paths borrow from the index entry; invalid UTF-8 uses lossy keys.
2952    pub(crate) fn stage0_path_positions<'a>(
2953        &'a self,
2954    ) -> std::collections::HashMap<Cow<'a, str>, usize> {
2955        let stage0_count = self.entries.iter().filter(|e| e.stage() == 0).count();
2956        let mut positions = std::collections::HashMap::with_capacity(stage0_count);
2957        for (pos, entry) in self.entries.iter().enumerate() {
2958            if entry.stage() == 0 {
2959                let key = index_path_outcome_key(&entry.path);
2960                positions.entry(key).or_insert(pos);
2961            }
2962        }
2963        positions
2964    }
2965
2966    /// Merge tree contents from `treeish` into this index as virtual stage-1 entries, matching
2967    /// Git's `overlay_tree_on_index` used by `git ls-files --with-tree`.
2968    ///
2969    /// Existing unmerged entries (stages 1–3) are shifted to stage 3 so stage 1 is free for the
2970    /// overlay. Stage-1 paths that already exist at stage 0 are marked so `ls-files` can skip
2971    /// them (Git's `CE_UPDATE` on the stage-1 entry).
2972    ///
2973    /// # Parameters
2974    ///
2975    /// - `repo` — repository whose object database is used to read the tree.
2976    /// - `treeish` — revision or tree OID string (`HEAD`, `HEAD~1`, full SHA, etc.).
2977    /// - `prefix` — optional path prefix (bytes, no trailing slash except empty); only paths under
2978    ///   this prefix are considered from the tree. Pass empty slice for the full tree.
2979    ///
2980    /// # Errors
2981    ///
2982    /// Returns [`Error`] if `treeish` cannot be resolved, the tree cannot be read, or an object is
2983    /// missing from the ODB.
2984    pub fn overlay_tree_on_index(
2985        &mut self,
2986        repo: &Repository,
2987        treeish: &str,
2988        prefix: &[u8],
2989    ) -> Result<()> {
2990        let oid = rev_parse::resolve_revision(repo, treeish)?;
2991        let tree_oid = repo.odb().peel_to_tree(oid)?.ok_or_else(|| {
2992            Error::ObjectNotFound(format!("cannot peel {oid} to tree for --with-tree"))
2993        })?;
2994        for e in self.entries.iter_mut() {
2995            if e.stage() != 0 {
2996                e.set_stage(3);
2997            }
2998        }
2999        self.sort();
3000        let has_stage1 = self.entries.iter().any(|e| e.stage() == 1);
3001        let mut appended: Vec<IndexEntry> = Vec::new();
3002        let protect = crate::path_validate::ProtectOptions::from_config(&*repo.config()?);
3003        read_tree_into_overlay(
3004            repo,
3005            &tree_oid,
3006            prefix,
3007            &[],
3008            has_stage1,
3009            protect,
3010            &mut appended,
3011        )?;
3012        for e in appended {
3013            self.add_or_replace(e);
3014        }
3015        if !has_stage1 {
3016            self.sort();
3017        }
3018        let mut last_stage0: Option<&[u8]> = None;
3019        for e in &mut self.entries {
3020            match e.stage() {
3021                0 => {
3022                    last_stage0 = Some(e.path.as_slice());
3023                }
3024                1 if last_stage0.is_some_and(|p| p == e.path.as_slice()) => {
3025                    e.set_overlay_tree_skip_output(true);
3026                }
3027                _ => {}
3028            }
3029        }
3030        Ok(())
3031    }
3032}
3033
3034#[allow(clippy::too_many_arguments)]
3035fn read_tree_into_overlay(
3036    repo: &Repository,
3037    tree_oid: &ObjectId,
3038    prefix: &[u8],
3039    rel_base: &[u8],
3040    use_replace_path: bool,
3041    protect: crate::path_validate::ProtectOptions,
3042    out: &mut Vec<IndexEntry>,
3043) -> Result<()> {
3044    let obj = repo.odb().read(tree_oid)?;
3045    if obj.kind != ObjectKind::Tree {
3046        return Err(Error::ObjectNotFound(format!(
3047            "object {tree_oid} is not a tree"
3048        )));
3049    }
3050    let entries = parse_tree(&obj.data)?;
3051    for TreeEntry { mode, name, oid } in entries {
3052        let mut path = rel_base.to_vec();
3053        if !path.is_empty() {
3054            path.push(b'/');
3055        }
3056        path.extend_from_slice(&name);
3057        if !prefix_under_or_equal(prefix, &path) {
3058            continue;
3059        }
3060        crate::path_population::validate_tree_walk_entry(rel_base, &name, mode, protect)?;
3061        if mode == MODE_TREE {
3062            read_tree_into_overlay(repo, &oid, prefix, &path, use_replace_path, protect, out)?;
3063            continue;
3064        }
3065        if mode == MODE_GITLINK {
3066            continue;
3067        }
3068        let entry = synthetic_stage1_index_entry(mode, &path, oid);
3069        if use_replace_path {
3070            if let Some(pos) = out.iter().position(|e| e.path == path && e.stage() == 1) {
3071                out[pos] = entry;
3072            } else {
3073                out.push(entry);
3074            }
3075        } else {
3076            out.push(entry);
3077        }
3078    }
3079    Ok(())
3080}
3081
3082/// Collapse consecutive sorted rows with the same `(path, stage)`; the last row wins.
3083fn dedup_sorted_replacement_entries(replacements: &mut Vec<IndexEntry>) {
3084    if replacements.len() <= 1 {
3085        return;
3086    }
3087    let mut out: Vec<IndexEntry> = Vec::with_capacity(replacements.len());
3088    for entry in replacements.drain(..) {
3089        match out.last_mut() {
3090            Some(last) if last.path == entry.path && last.stage() == entry.stage() => {
3091                *last = entry;
3092            }
3093            _ => out.push(entry),
3094        }
3095    }
3096    *replacements = out;
3097}
3098
3099fn index_entry_path_stage_cmp(a: &IndexEntry, b: &IndexEntry) -> std::cmp::Ordering {
3100    a.path.cmp(&b.path).then_with(|| a.stage().cmp(&b.stage()))
3101}
3102
3103/// Merge sorted `insert` into sorted `kept` without holding a third full-length copy at peak.
3104///
3105/// `insert` is appended to `kept`, then the two adjacent sorted halves are merged in place.
3106fn merge_sorted_index_entries(
3107    mut kept: Vec<IndexEntry>,
3108    mut insert: Vec<IndexEntry>,
3109) -> Vec<IndexEntry> {
3110    if insert.is_empty() {
3111        return kept;
3112    }
3113    if kept.is_empty() {
3114        return insert;
3115    }
3116    kept.reserve(insert.len());
3117    let mid = kept.len();
3118    kept.append(&mut insert);
3119    merge_sorted_index_adjacent_halves(&mut kept, mid);
3120    kept
3121}
3122
3123/// Merge sorted `v[0..mid)` with `v[mid..]` in place; auxiliary storage holds only the shorter half.
3124fn merge_sorted_index_adjacent_halves(v: &mut Vec<IndexEntry>, mid: usize) {
3125    let n = v.len();
3126    if mid == 0 || mid >= n {
3127        return;
3128    }
3129    let right_len = n - mid;
3130    let smaller_is_right = right_len <= mid;
3131    let mut aux = if smaller_is_right {
3132        v.drain(mid..).collect::<Vec<_>>()
3133    } else {
3134        v.drain(..mid).collect::<Vec<_>>()
3135    };
3136    let left_len = if smaller_is_right { v.len() } else { aux.len() };
3137    let pad = aux.len();
3138    v.reserve(pad);
3139    for _ in 0..pad {
3140        v.push(merge_padding_index_entry());
3141    }
3142    let total = v.len();
3143    merge_backward_into_place(v, left_len, total, &mut aux, smaller_is_right);
3144}
3145
3146/// Placeholder slot for backward merge scratch space (replaced before any read).
3147fn merge_padding_index_entry() -> IndexEntry {
3148    IndexEntry {
3149        ctime_sec: 0,
3150        ctime_nsec: 0,
3151        mtime_sec: 0,
3152        mtime_nsec: 0,
3153        dev: 0,
3154        ino: 0,
3155        mode: 0,
3156        uid: 0,
3157        gid: 0,
3158        size: 0,
3159        oid: ObjectId::zero(),
3160        flags: 0,
3161        flags_extended: None,
3162        path: Vec::new(),
3163        base_index_pos: 0,
3164    }
3165}
3166
3167/// Backward merge into `v` using `aux` (copy of the shorter sorted run).
3168fn merge_backward_into_place(
3169    v: &mut Vec<IndexEntry>,
3170    left_len: usize,
3171    total: usize,
3172    aux: &mut Vec<IndexEntry>,
3173    smaller_is_right: bool,
3174) {
3175    let pad = aux.len();
3176    let mut li = left_len;
3177    let mut ri = if smaller_is_right {
3178        aux.len()
3179    } else {
3180        total.saturating_sub(pad)
3181    };
3182    let mut k = total;
3183    while li > 0 && ri > 0 {
3184        k -= 1;
3185        let ord = if smaller_is_right {
3186            index_entry_path_stage_cmp(&v[li - 1], &aux[ri - 1])
3187        } else {
3188            index_entry_path_stage_cmp(&aux[li - 1], &v[ri - 1])
3189        };
3190        match ord {
3191            std::cmp::Ordering::Greater => {
3192                if smaller_is_right {
3193                    let entry = std::mem::replace(&mut v[li - 1], merge_padding_index_entry());
3194                    drop(std::mem::replace(&mut v[k], entry));
3195                    li -= 1;
3196                } else {
3197                    let entry = std::mem::replace(&mut aux[li - 1], merge_padding_index_entry());
3198                    li -= 1;
3199                    drop(std::mem::replace(&mut v[k], entry));
3200                }
3201            }
3202            std::cmp::Ordering::Equal => {
3203                let base = if smaller_is_right {
3204                    v[li - 1].base_index_pos
3205                } else {
3206                    aux[li - 1].base_index_pos
3207                };
3208                let mut entry = if smaller_is_right {
3209                    let Some(e) = aux.pop() else {
3210                        break;
3211                    };
3212                    ri -= 1;
3213                    e
3214                } else {
3215                    ri -= 1;
3216                    std::mem::replace(&mut v[ri], merge_padding_index_entry())
3217                };
3218                entry.base_index_pos = base;
3219                if smaller_is_right {
3220                    drop(std::mem::replace(
3221                        &mut v[li - 1],
3222                        merge_padding_index_entry(),
3223                    ));
3224                    li -= 1;
3225                } else {
3226                    drop(std::mem::replace(
3227                        &mut aux[li - 1],
3228                        merge_padding_index_entry(),
3229                    ));
3230                    li -= 1;
3231                }
3232                drop(std::mem::replace(&mut v[k], entry));
3233            }
3234            std::cmp::Ordering::Less => {
3235                if smaller_is_right {
3236                    let Some(entry) = aux.pop() else {
3237                        break;
3238                    };
3239                    ri -= 1;
3240                    drop(std::mem::replace(&mut v[k], entry));
3241                } else {
3242                    ri -= 1;
3243                    let entry = std::mem::replace(&mut v[ri], merge_padding_index_entry());
3244                    drop(std::mem::replace(&mut v[k], entry));
3245                }
3246            }
3247        }
3248    }
3249    while ri > 0 {
3250        k -= 1;
3251        if smaller_is_right {
3252            let Some(entry) = aux.pop() else {
3253                break;
3254            };
3255            ri -= 1;
3256            drop(std::mem::replace(&mut v[k], entry));
3257        } else {
3258            ri -= 1;
3259            let entry = std::mem::replace(&mut v[ri], merge_padding_index_entry());
3260            drop(std::mem::replace(&mut v[k], entry));
3261        }
3262    }
3263    if !smaller_is_right && li > 0 {
3264        for i in 0..li {
3265            let entry = std::mem::replace(&mut aux[i], merge_padding_index_entry());
3266            drop(std::mem::replace(&mut v[i], entry));
3267        }
3268    }
3269    let tail_len = total - k;
3270    if li < k && tail_len > 0 {
3271        for offset in 0..tail_len {
3272            let entry = std::mem::replace(&mut v[k + offset], merge_padding_index_entry());
3273            drop(std::mem::replace(&mut v[li + offset], entry));
3274        }
3275    }
3276    v.truncate(li + tail_len);
3277}
3278
3279fn prefix_under_or_equal(prefix: &[u8], path: &[u8]) -> bool {
3280    if prefix.is_empty() {
3281        return true;
3282    }
3283    if path == prefix {
3284        return true;
3285    }
3286    path.len() > prefix.len() && path.starts_with(prefix) && path[prefix.len()] == b'/'
3287}
3288
3289fn synthetic_stage1_index_entry(mode: u32, path: &[u8], oid: ObjectId) -> IndexEntry {
3290    let path_len = path.len().min(0xFFF) as u16;
3291    let flags = (1u16 << 12) | path_len;
3292    IndexEntry {
3293        ctime_sec: 0,
3294        ctime_nsec: 0,
3295        mtime_sec: 0,
3296        mtime_nsec: 0,
3297        dev: 0,
3298        ino: 0,
3299        mode,
3300        uid: 0,
3301        gid: 0,
3302        size: 0,
3303        oid,
3304        flags,
3305        flags_extended: None,
3306        path: path.to_vec(),
3307        base_index_pos: 0,
3308    }
3309}
3310
3311fn config_truthy(raw: Option<&str>) -> bool {
3312    let Some(val) = raw else {
3313        return false;
3314    };
3315    let lowered = val.trim().to_lowercase();
3316    matches!(lowered.as_str(), "true" | "yes" | "1" | "on")
3317}
3318
3319/// Whether to write 20 zero bytes instead of the SHA-1 of the index body.
3320///
3321/// Mirrors Git `prepare_repo_settings`: `feature.manyFiles` enables skip-hash unless
3322/// `index.skipHash` / `index.skiphash` is explicitly false; otherwise honor true `index.skipHash`.
3323pub(crate) fn index_skip_hash_for_write(config: Option<&ConfigSet>) -> bool {
3324    let Some(config) = config else {
3325        return false;
3326    };
3327    let many_files = config
3328        .get_bool("feature.manyFiles")
3329        .and_then(|r| r.ok())
3330        .unwrap_or(false);
3331    if many_files {
3332        if let Some(Ok(false)) = config.get_bool("index.skipHash") {
3333            return false;
3334        }
3335        if let Some(Ok(false)) = config.get_bool("index.skiphash") {
3336            return false;
3337        }
3338        return true;
3339    }
3340    for key in ["index.skipHash", "index.skiphash"] {
3341        if let Some(Ok(true)) = config.get_bool(key) {
3342            return true;
3343        }
3344    }
3345    false
3346}
3347
3348fn trim_trailing_slash_bytes(path: &[u8]) -> &[u8] {
3349    path.strip_suffix(b"/").unwrap_or(path)
3350}
3351
3352fn path_under_prefix(path: &[u8], prefix: &[u8]) -> bool {
3353    if path == prefix {
3354        return true;
3355    }
3356    if prefix.is_empty() {
3357        return true;
3358    }
3359    path.len() > prefix.len() && path.starts_with(prefix) && path[prefix.len()] == b'/'
3360}
3361
3362fn directory_in_cone(dir_path: &str, patterns: &[String], cone_mode: bool) -> bool {
3363    // Match Git `path_in_cone_mode_sparse_checkout`: a *directory* is in the cone when its
3364    // contents are recursively included. `path_matches_sparse_patterns` distinguishes a
3365    // directory from a file by a trailing slash (expanded-cone matching uses dtype the way
3366    // Git does), so pass the directory with a trailing slash. Collapse prefixes arrive
3367    // without one (e.g. `before`, `folder1`), so append it to avoid a top-level directory
3368    // being mistaken for an always-in-cone top-level file. The root (empty) directory is
3369    // always in the cone (`/*` + `!/*/` excludes every top-level directory, so e.g. `deep`
3370    // collapses to a single placeholder instead of leaving `deep/deeper1/` etc.).
3371    let dir = dir_path.trim_end_matches('/');
3372    if dir.is_empty() {
3373        return true;
3374    }
3375    let with_slash = format!("{dir}/");
3376    crate::sparse_checkout::path_matches_sparse_patterns(&with_slash, patterns, cone_mode)
3377}
3378
3379fn collect_directory_prefixes(path: &[u8], out: &mut BTreeSet<Vec<u8>>) {
3380    for (i, &b) in path.iter().enumerate() {
3381        if b == b'/' {
3382            out.insert(path[..i].to_vec());
3383        }
3384    }
3385}
3386
3387fn tree_oid_for_prefix(odb: &Odb, root_tree: &ObjectId, prefix: &[u8]) -> Result<Option<ObjectId>> {
3388    if prefix.is_empty() {
3389        return Ok(Some(*root_tree));
3390    }
3391    let pref_str = String::from_utf8_lossy(prefix);
3392    let components: Vec<&str> = pref_str.split('/').filter(|c| !c.is_empty()).collect();
3393    let mut current = *root_tree;
3394    for comp in components {
3395        let obj = odb.read(&current)?;
3396        if obj.kind != ObjectKind::Tree {
3397            return Ok(None);
3398        }
3399        let entries = parse_tree(&obj.data)?;
3400        let mut next = None;
3401        for e in entries {
3402            if e.name == comp.as_bytes() {
3403                if e.mode == MODE_TREE {
3404                    next = Some(e.oid);
3405                }
3406                break;
3407            }
3408        }
3409        current = match next {
3410            Some(o) => o,
3411            None => return Ok(None),
3412        };
3413    }
3414    Ok(Some(current))
3415}
3416
3417/// Build the list of blob index entries under `prefix` that match `HEAD` at `tree_oid`,
3418/// treating existing sparse-directory placeholders in `entries` as opaque subtrees (like Git).
3419fn collect_sparse_aware_expected_blobs(
3420    odb: &Odb,
3421    tree_oid: &ObjectId,
3422    prefix: &[u8],
3423    patterns: &[String],
3424    cone_mode: bool,
3425    entries: &[IndexEntry],
3426    protect: crate::path_validate::ProtectOptions,
3427) -> Result<Vec<IndexEntry>> {
3428    let mut out = Vec::new();
3429    walk_sparse_aware(
3430        odb, tree_oid, prefix, patterns, cone_mode, entries, protect, &mut out,
3431    )?;
3432    Ok(out)
3433}
3434
3435#[allow(clippy::too_many_arguments)]
3436fn walk_sparse_aware(
3437    odb: &Odb,
3438    tree_oid: &ObjectId,
3439    prefix: &[u8],
3440    patterns: &[String],
3441    cone_mode: bool,
3442    entries: &[IndexEntry],
3443    protect: crate::path_validate::ProtectOptions,
3444    out: &mut Vec<IndexEntry>,
3445) -> Result<()> {
3446    let obj = odb.read(tree_oid)?;
3447    if obj.kind != ObjectKind::Tree {
3448        return Err(Error::IndexError(format!("expected tree at {}", tree_oid)));
3449    }
3450    let tree_entries = parse_tree(&obj.data)?;
3451    for te in tree_entries {
3452        let path = if prefix.is_empty() {
3453            te.name.clone()
3454        } else {
3455            let mut p = prefix.to_vec();
3456            p.push(b'/');
3457            p.extend_from_slice(&te.name);
3458            p
3459        };
3460        crate::path_population::validate_tree_walk_entry(&path, &te.name, te.mode, protect)?;
3461        if te.mode == MODE_TREE {
3462            let path_slash = {
3463                let mut p = path.clone();
3464                p.push(b'/');
3465                p
3466            };
3467            if entries.iter().any(|e| {
3468                e.stage() == 0
3469                    && e.is_sparse_directory_placeholder()
3470                    && e.path == path_slash
3471                    && e.oid == te.oid
3472            }) {
3473                continue;
3474            }
3475            walk_sparse_aware(
3476                odb, &te.oid, &path, patterns, cone_mode, entries, protect, out,
3477            )?;
3478        } else {
3479            let path_len = path.len().min(0xFFF) as u16;
3480            let path_str = String::from_utf8_lossy(&path);
3481            if crate::sparse_checkout::path_matches_sparse_patterns(&path_str, patterns, cone_mode)
3482            {
3483                continue;
3484            }
3485            let mut e = IndexEntry {
3486                ctime_sec: 0,
3487                ctime_nsec: 0,
3488                mtime_sec: 0,
3489                mtime_nsec: 0,
3490                dev: 0,
3491                ino: 0,
3492                mode: te.mode,
3493                uid: 0,
3494                gid: 0,
3495                size: 0,
3496                oid: te.oid,
3497                flags: path_len,
3498                flags_extended: Some(0),
3499                path,
3500                base_index_pos: 0,
3501            };
3502            e.set_skip_worktree(true);
3503            out.push(e);
3504        }
3505    }
3506    Ok(())
3507}
3508
3509fn flatten_tree_blobs(
3510    odb: &Odb,
3511    tree_oid: &ObjectId,
3512    prefix: &[u8],
3513    protect: crate::path_validate::ProtectOptions,
3514) -> Result<Vec<IndexEntry>> {
3515    let obj = odb.read(tree_oid)?;
3516    if obj.kind != ObjectKind::Tree {
3517        return Err(Error::IndexError(format!("expected tree at {}", tree_oid)));
3518    }
3519    let entries = parse_tree(&obj.data)?;
3520    let mut out = Vec::new();
3521    for te in entries {
3522        let path = if prefix.is_empty() {
3523            te.name.clone()
3524        } else {
3525            let mut p = prefix.to_vec();
3526            p.push(b'/');
3527            p.extend_from_slice(&te.name);
3528            p
3529        };
3530        crate::path_population::validate_tree_walk_entry(prefix, &te.name, te.mode, protect)?;
3531        if te.mode == MODE_TREE {
3532            let sub = flatten_tree_blobs(odb, &te.oid, &path, protect)?;
3533            out.extend(sub);
3534        } else {
3535            let path_len = path.len().min(0xFFF) as u16;
3536            let mut e = IndexEntry {
3537                ctime_sec: 0,
3538                ctime_nsec: 0,
3539                mtime_sec: 0,
3540                mtime_nsec: 0,
3541                dev: 0,
3542                ino: 0,
3543                mode: te.mode,
3544                uid: 0,
3545                gid: 0,
3546                size: 0,
3547                oid: te.oid,
3548                flags: path_len,
3549                flags_extended: Some(0),
3550                path,
3551                base_index_pos: 0,
3552            };
3553            e.set_skip_worktree(true);
3554            out.push(e);
3555        }
3556    }
3557    Ok(out)
3558}
3559
3560fn lockfile_pid_enabled(index_path: &Path, config: Option<&ConfigSet>) -> bool {
3561    let git_dir = match index_path.parent() {
3562        Some(dir) => dir,
3563        None => return false,
3564    };
3565
3566    let cfg = config.cloned().unwrap_or_else(|| {
3567        ConfigSet::load(
3568            &crate::environment::Environment::empty(),
3569            Some(git_dir),
3570            true,
3571        )
3572        .unwrap_or_default()
3573    });
3574    cfg.get_bool("core.lockfilepid")
3575        .and_then(|res| res.ok())
3576        .unwrap_or(false)
3577}
3578
3579fn write_lock_pid_file(pid_path: &Path) -> io::Result<()> {
3580    use std::io::Write as _;
3581    let mut file = fs::OpenOptions::new()
3582        .write(true)
3583        .create(true)
3584        .truncate(true)
3585        .open(pid_path)?;
3586    writeln!(file, "pid {}", std::process::id())?;
3587    Ok(())
3588}
3589
3590/// Exclusive `.git/index.lock` held for a mutating porcelain operation.
3591///
3592/// Acquire with [`Self::acquire`] before changing the working tree; persist the index with
3593/// [`IndexHandle::commit_update`](crate::index::IndexHandle::commit_update) or
3594/// [`IndexHandle::write`](crate::index::IndexHandle::write), then release on drop when
3595/// uncommitted.
3596pub struct IndexLock {
3597    pub(crate) index_path: PathBuf,
3598    pub(crate) lock_path: PathBuf,
3599    pub(crate) pid_path: PathBuf,
3600    pub(crate) file: fs::File,
3601    pub(crate) wrote_pid: bool,
3602    pub(crate) committed: bool,
3603}
3604
3605impl IndexLock {
3606    /// Write serialized index bytes into the held lock file without replacing `index`.
3607    pub(crate) fn write_index_contents(&mut self, body: &[u8], checksum: &[u8]) -> Result<()> {
3608        self.file.write_all(body).map_err(Error::Io)?;
3609        self.file.write_all(checksum).map_err(Error::Io)?;
3610        drop(std::mem::replace(
3611            &mut self.file,
3612            fs::OpenOptions::new()
3613                .write(true)
3614                .open(&self.lock_path)
3615                .map_err(Error::Io)?,
3616        ));
3617        Ok(())
3618    }
3619
3620    /// Rename the staged lock file over the on-disk index.
3621    pub(crate) fn commit_replace_index(&mut self) -> Result<()> {
3622        fs::rename(&self.lock_path, &self.index_path).map_err(Error::Io)?;
3623        self.committed = true;
3624        if self.wrote_pid {
3625            let _ = fs::remove_file(&self.pid_path);
3626        }
3627        Ok(())
3628    }
3629
3630    /// Create `.git/index.lock` exclusively, failing when another process holds it.
3631    ///
3632    /// # Errors
3633    ///
3634    /// Returns [`Error::LockHeld`] (with the pid sidecar's [`LockHolder`](crate::lockfile::LockHolder)
3635    /// when one was written) when `index.lock` already exists after the
3636    /// `core.filesRefLockTimeout` budget, or [`Error::Io`] for other filesystem failures.
3637    pub fn acquire(index_path: &Path, config: Option<&ConfigSet>) -> Result<Self> {
3638        let lock_path = index_path.with_extension("lock");
3639        let pid_path = pid_path_for_lock(&lock_path);
3640        let lockfile_pid_enabled = lockfile_pid_enabled(index_path, config);
3641
3642        use crate::lockfile::{LockFile, LockWait};
3643        let wait = LockWait::files_ref_lock(config);
3644        let (lock_path, file) =
3645            LockFile::acquire(&lock_path, wait).and_then(LockFile::into_open_file)?;
3646
3647        let mut wrote_pid = false;
3648        if lockfile_pid_enabled {
3649            write_lock_pid_file(&pid_path).map_err(|e| {
3650                let _ = fs::remove_file(&lock_path);
3651                Error::Io(e)
3652            })?;
3653            wrote_pid = true;
3654        }
3655
3656        Ok(Self {
3657            index_path: index_path.to_path_buf(),
3658            lock_path,
3659            pid_path,
3660            file,
3661            wrote_pid,
3662            committed: false,
3663        })
3664    }
3665}
3666
3667impl Drop for IndexLock {
3668    fn drop(&mut self) {
3669        if self.committed {
3670            return;
3671        }
3672        let _ = fs::remove_file(&self.lock_path);
3673        if self.wrote_pid {
3674            let _ = fs::remove_file(&self.pid_path);
3675        }
3676    }
3677}
3678
3679/// Hash an index body with the given algorithm, returning the raw checksum.
3680fn hash_index_body(algo: HashAlgo, body: &[u8]) -> Vec<u8> {
3681    algo.checksum_digest(body)
3682}
3683
3684/// Write index bytes and trailing checksum in one buffered pass, updating the digest while writing.
3685fn write_index_body_and_trailer(
3686    file: &mut fs::File,
3687    algo: HashAlgo,
3688    skip_hash: bool,
3689    body: &[u8],
3690) -> io::Result<()> {
3691    use std::io::BufWriter;
3692    let mut out = BufWriter::with_capacity(256 * 1024, file);
3693    if skip_hash {
3694        out.write_all(body)?;
3695        out.write_all(&vec![0u8; algo.len()])?;
3696        return out.flush();
3697    }
3698    let mut hasher = algo.checksum_hasher();
3699    for chunk in body.chunks(256 * 1024) {
3700        hasher.update(chunk);
3701        out.write_all(chunk)?;
3702    }
3703    out.write_all(&hasher.finalize())?;
3704    out.flush()
3705}
3706
3707impl Index {
3708    /// Conservative pre-size for serializing the index body (header, entries, extensions).
3709    fn estimate_body_capacity(&self, write_version: u32, config: Option<&ConfigSet>) -> usize {
3710        use crate::index_eoie_ieot::record_end_of_index_entries;
3711        let hash_len = self.hash_algo.len();
3712        let mut entries_bytes = 0usize;
3713        for entry in &self.entries {
3714            let mut n = 62usize
3715                .saturating_add(hash_len)
3716                .saturating_add(entry.path.len());
3717            if write_version >= 3
3718                && entry
3719                    .flags_extended
3720                    .map(IndexEntry::disk_flags_extended)
3721                    .filter(|fe| *fe != 0)
3722                    .is_some()
3723            {
3724                n += 2;
3725            }
3726            if write_version == 4 {
3727                n += 12;
3728            } else {
3729                n = (n + 7) & !7;
3730            }
3731            entries_bytes = entries_bytes.saturating_add(n);
3732        }
3733        let mut extra = 256usize;
3734        if self.cache_tree.is_some() {
3735            extra += 4096;
3736        }
3737        if self.untracked_cache.is_some() {
3738            extra += 4096;
3739        }
3740        if self.fsmonitor_last_update.is_some() {
3741            extra += 512;
3742        }
3743        if record_end_of_index_entries(config) {
3744            extra += 64;
3745        }
3746        12 + entries_bytes + extra
3747    }
3748}
3749
3750fn append_index_extension(
3751    out: &mut Vec<u8>,
3752    eoie_headers: &mut Option<Vec<u8>>,
3753    signature: u32,
3754    payload: &[u8],
3755) {
3756    let size = payload.len() as u32;
3757    out.extend_from_slice(&signature.to_be_bytes());
3758    out.extend_from_slice(&size.to_be_bytes());
3759    if let Some(headers) = eoie_headers {
3760        headers.extend_from_slice(&signature.to_be_bytes());
3761        headers.extend_from_slice(&size.to_be_bytes());
3762    }
3763    out.extend_from_slice(payload);
3764}
3765
3766/// Best-effort detection of an index file's hash algorithm from its trailing
3767/// checksum. Used by [`Index::parse`] when no repository algorithm is known.
3768/// Callers with a repository should use [`Index::parse_with_algo`] instead,
3769/// which is authoritative even when the checksum is skipped (all zeros).
3770fn detect_index_hash_algo(data: &[u8]) -> HashAlgo {
3771    // A SHA-256 index ends with a 32-byte checksum over the body. If that
3772    // verifies, the file is SHA-256; otherwise assume SHA-1.
3773    let sha256_len = HashAlgo::Sha256.len();
3774    if data.len() > sha256_len + 12 {
3775        let (body, checksum) = data.split_at(data.len() - sha256_len);
3776        if checksum.iter().any(|&b| b != 0) && hash_index_body(HashAlgo::Sha256, body) == checksum {
3777            return HashAlgo::Sha256;
3778        }
3779    }
3780    HashAlgo::Sha1
3781}
3782
3783/// Parse a single index entry from `data`, returning `(entry, bytes_consumed)`.
3784///
3785/// `hash_len` is the raw object-id width (20 for SHA-1, 32 for SHA-256).
3786pub(crate) fn parse_entry(
3787    data: &[u8],
3788    version: u32,
3789    prev_path: &[u8],
3790    hash_len: usize,
3791) -> Result<(IndexEntry, usize)> {
3792    // 40 bytes of stat fields + the OID + 2 flag bytes.
3793    if data.len() < 40 + hash_len + 2 {
3794        return Err(Error::IndexError("entry too short".to_owned()));
3795    }
3796
3797    let mut pos = 0;
3798
3799    macro_rules! read_u32 {
3800        () => {{
3801            let v = u32::from_be_bytes(
3802                data[pos..pos + 4]
3803                    .try_into()
3804                    .map_err(|_| Error::IndexError("truncated u32".to_owned()))?,
3805            );
3806            pos += 4;
3807            v
3808        }};
3809    }
3810
3811    let ctime_sec = read_u32!();
3812    let ctime_nsec = read_u32!();
3813    let mtime_sec = read_u32!();
3814    let mtime_nsec = read_u32!();
3815    let dev = read_u32!();
3816    let ino = read_u32!();
3817    let mode = read_u32!();
3818    let uid = read_u32!();
3819    let gid = read_u32!();
3820    let size = read_u32!();
3821
3822    let oid = ObjectId::from_bytes(&data[pos..pos + hash_len])?;
3823    pos += hash_len;
3824
3825    let flags = u16::from_be_bytes(
3826        data[pos..pos + 2]
3827            .try_into()
3828            .map_err(|_| Error::IndexError("truncated flags".to_owned()))?,
3829    );
3830    pos += 2;
3831
3832    let flags_extended = if version >= 3 && flags & 0x4000 != 0 {
3833        let fe = u16::from_be_bytes(
3834            data[pos..pos + 2]
3835                .try_into()
3836                .map_err(|_| Error::IndexError("truncated extended flags".to_owned()))?,
3837        );
3838        pos += 2;
3839        Some(fe)
3840    } else {
3841        None
3842    };
3843
3844    let path;
3845    if version == 4 {
3846        // V4: prefix-compressed path
3847        let (strip_len, varint_bytes) = read_varint(&data[pos..]);
3848        pos += varint_bytes;
3849        let nul = data[pos..]
3850            .iter()
3851            .position(|&b| b == 0)
3852            .ok_or_else(|| Error::IndexError("v4 entry path missing NUL".to_owned()))?;
3853        let suffix = &data[pos..pos + nul];
3854        pos += nul + 1;
3855        let keep = prev_path.len().saturating_sub(strip_len);
3856        let mut full_path = Vec::with_capacity(keep + suffix.len());
3857        full_path.extend_from_slice(&prev_path[..keep]);
3858        full_path.extend_from_slice(suffix);
3859        path = full_path;
3860    } else {
3861        // V2/V3: NUL-terminated full path + padding
3862        let nul = data[pos..]
3863            .iter()
3864            .position(|&b| b == 0)
3865            .ok_or_else(|| Error::IndexError("entry path missing NUL terminator".to_owned()))?;
3866        path = data[pos..pos + nul].to_vec();
3867        pos += nul + 1;
3868        let entry_start = 0usize;
3869        let entry_len = pos - entry_start;
3870        let padded = (entry_len + 7) & !7;
3871        let padding = padded.saturating_sub(entry_len);
3872        pos += padding;
3873    }
3874
3875    Ok((
3876        IndexEntry {
3877            ctime_sec,
3878            ctime_nsec,
3879            mtime_sec,
3880            mtime_nsec,
3881            dev,
3882            ino,
3883            mode,
3884            uid,
3885            gid,
3886            size,
3887            oid,
3888            flags,
3889            flags_extended,
3890            path,
3891            base_index_pos: 0,
3892        },
3893        pos,
3894    ))
3895}
3896
3897/// Serialise a single index entry into `out`.
3898/// Read a variable-length integer (git's index v4 varint encoding).
3899/// Returns (value, bytes_consumed).
3900fn write_varint(out: &mut Vec<u8>, mut value: usize) {
3901    loop {
3902        let mut b = (value & 0x7F) as u8;
3903        value >>= 7;
3904        if value != 0 {
3905            b |= 0x80;
3906        }
3907        out.push(b);
3908        if value == 0 {
3909            break;
3910        }
3911    }
3912}
3913
3914fn read_varint(data: &[u8]) -> (usize, usize) {
3915    let mut value: usize = 0;
3916    let mut shift = 0usize;
3917    let mut pos = 0;
3918    loop {
3919        if pos >= data.len() {
3920            break;
3921        }
3922        let byte = data[pos] as usize;
3923        pos += 1;
3924        value |= (byte & 0x7F) << shift;
3925        if byte & 0x80 == 0 {
3926            break;
3927        }
3928        shift += 7;
3929        // Prevent infinite loops on malformed data
3930        if shift > 28 {
3931            break;
3932        }
3933    }
3934    (value, pos)
3935}
3936
3937fn serialize_entry_v4(entry: &IndexEntry, previous_path: &mut Vec<u8>, out: &mut Vec<u8>) {
3938    let write_u32 = |out: &mut Vec<u8>, v: u32| out.extend_from_slice(&v.to_be_bytes());
3939
3940    write_u32(out, entry.ctime_sec);
3941    write_u32(out, entry.ctime_nsec);
3942    write_u32(out, entry.mtime_sec);
3943    write_u32(out, entry.mtime_nsec);
3944    write_u32(out, entry.dev);
3945    write_u32(out, entry.ino);
3946    write_u32(out, entry.mode);
3947    write_u32(out, entry.uid);
3948    write_u32(out, entry.gid);
3949    write_u32(out, entry.size);
3950    out.extend_from_slice(entry.oid.as_bytes());
3951
3952    let mut flags = entry.flags;
3953    let disk_ext = entry
3954        .flags_extended
3955        .map(IndexEntry::disk_flags_extended)
3956        .filter(|fe| *fe != 0);
3957    if disk_ext.is_some() {
3958        flags |= 0x4000;
3959    } else {
3960        flags &= !0x4000;
3961    }
3962    let path_len = entry.path.len().min(0xFFF) as u16;
3963    flags = (flags & 0xF000) | path_len;
3964    out.extend_from_slice(&flags.to_be_bytes());
3965
3966    if let Some(fe) = disk_ext {
3967        out.extend_from_slice(&fe.to_be_bytes());
3968    }
3969
3970    let common = previous_path
3971        .iter()
3972        .zip(entry.path.iter())
3973        .take_while(|(a, b)| a == b)
3974        .count();
3975    let to_remove = previous_path.len().saturating_sub(common);
3976    write_varint(out, to_remove);
3977    out.extend_from_slice(&entry.path[common..]);
3978    out.push(0);
3979
3980    previous_path.clear();
3981    previous_path.extend_from_slice(&entry.path);
3982}
3983
3984fn serialize_entry(entry: &IndexEntry, version: u32, out: &mut Vec<u8>) {
3985    let start = out.len();
3986
3987    let write_u32 = |out: &mut Vec<u8>, v: u32| out.extend_from_slice(&v.to_be_bytes());
3988
3989    write_u32(out, entry.ctime_sec);
3990    write_u32(out, entry.ctime_nsec);
3991    write_u32(out, entry.mtime_sec);
3992    write_u32(out, entry.mtime_nsec);
3993    write_u32(out, entry.dev);
3994    write_u32(out, entry.ino);
3995    write_u32(out, entry.mode);
3996    write_u32(out, entry.uid);
3997    write_u32(out, entry.gid);
3998    write_u32(out, entry.size);
3999    out.extend_from_slice(entry.oid.as_bytes());
4000
4001    // Set or clear the extended-flags bit in flags
4002    let mut flags = entry.flags;
4003    let disk_ext = entry
4004        .flags_extended
4005        .map(IndexEntry::disk_flags_extended)
4006        .filter(|fe| *fe != 0);
4007    if version >= 3 && disk_ext.is_some() {
4008        flags |= 0x4000;
4009    } else {
4010        flags &= !0x4000;
4011    }
4012    // Overwrite path length bits (bottom 12)
4013    let path_len = entry.path.len().min(0xFFF) as u16;
4014    flags = (flags & 0xF000) | path_len;
4015    out.extend_from_slice(&flags.to_be_bytes());
4016
4017    if version >= 3 {
4018        if let Some(fe) = disk_ext {
4019            out.extend_from_slice(&fe.to_be_bytes());
4020        }
4021    }
4022
4023    out.extend_from_slice(&entry.path);
4024    out.push(0);
4025
4026    // Pad to 8-byte boundary
4027    let entry_len = out.len() - start;
4028    let padded = (entry_len + 7) & !7;
4029    let padding = padded - entry_len;
4030    for _ in 0..padding {
4031        out.push(0);
4032    }
4033}
4034
4035/// Build an [`IndexEntry`] by stat-ing a file on disk.
4036///
4037/// # Parameters
4038///
4039/// - `path` — absolute path to the file.
4040/// - `rel_path` — path relative to the repo root (stored in the index).
4041/// - `oid` — the object ID of the file's blob.
4042/// - `mode` — file mode (use [`MODE_REGULAR`], [`MODE_EXECUTABLE`], etc.).
4043///
4044/// # Errors
4045///
4046/// Returns [`Error::Io`] if `stat` fails.
4047pub fn entry_from_stat(
4048    path: &Path,
4049    rel_path: &[u8],
4050    oid: ObjectId,
4051    mode: u32,
4052) -> Result<IndexEntry> {
4053    let meta = fs::symlink_metadata(path)?;
4054    Ok(entry_from_metadata(&meta, rel_path, oid, mode))
4055}
4056
4057/// Returns `(mtime_sec, mtime_nsec)` for an on-disk index file, or `None` if it does not exist.
4058///
4059/// Uses the `filetime` crate so index mtime reads behave the same on Unix and Windows.
4060#[must_use]
4061pub fn index_file_mtime(path: &Path) -> Option<(u32, u32)> {
4062    let meta = fs::metadata(path).ok()?;
4063    Some(index_mtime_from_metadata(&meta))
4064}
4065
4066fn index_mtime_from_metadata(meta: &fs::Metadata) -> (u32, u32) {
4067    let ft = filetime::FileTime::from_last_modification_time(meta);
4068    let secs = ft.unix_seconds();
4069    if secs.is_negative() {
4070        return (0, 0);
4071    }
4072    (secs as u32, ft.nanoseconds())
4073}
4074
4075/// Index files at least this large are memory-mapped instead of copied into a buffer.
4076const INDEX_MMAP_MIN_BYTES: u64 = 64 * 1024;
4077
4078/// Raw bytes of an on-disk index file.
4079enum IndexFileBytes {
4080    Owned(Vec<u8>),
4081    Mapped(memmap2::Mmap),
4082}
4083
4084impl std::ops::Deref for IndexFileBytes {
4085    type Target = [u8];
4086
4087    fn deref(&self) -> &[u8] {
4088        match self {
4089            Self::Owned(bytes) => bytes,
4090            Self::Mapped(map) => map,
4091        }
4092    }
4093}
4094
4095/// Read an index file along with the mtime of the same open file.
4096///
4097/// Large files are mapped read-only: index writers replace the file by renaming a lock file
4098/// over it, so a mapped inode is never truncated underneath the reader.
4099fn read_index_file(path: &Path) -> io::Result<(IndexFileBytes, (u32, u32))> {
4100    use std::io::Read;
4101
4102    let mut file = fs::File::open(path)?;
4103    let meta = file.metadata()?;
4104    let mtime = index_mtime_from_metadata(&meta);
4105    let len = meta.len();
4106    if meta.is_file() && len >= INDEX_MMAP_MIN_BYTES {
4107        if let Ok(len) = usize::try_from(len) {
4108            // SAFETY: the map is only read through `&[u8]`, its length comes from fstat on this
4109            // same file, and index writers swap files with rename instead of rewriting in place.
4110            if let Ok(map) = unsafe { memmap2::MmapOptions::new().len(len).map(&file) } {
4111                return Ok((IndexFileBytes::Mapped(map), mtime));
4112            }
4113        }
4114    }
4115    let mut bytes = Vec::with_capacity(usize::try_from(len).unwrap_or(0));
4116    file.read_to_end(&mut bytes)?;
4117    Ok((IndexFileBytes::Owned(bytes), mtime))
4118}
4119
4120fn max_stage0_entry_mtime(entries: &[IndexEntry]) -> (u32, u32) {
4121    let mut max_sec = 0u32;
4122    let mut max_nsec = 0u32;
4123    for ie in entries {
4124        if ie.stage() != 0 {
4125            continue;
4126        }
4127        if ie.mtime_sec > max_sec || (ie.mtime_sec == max_sec && ie.mtime_nsec > max_nsec) {
4128            max_sec = ie.mtime_sec;
4129            max_nsec = ie.mtime_nsec;
4130        }
4131    }
4132    (max_sec, max_nsec)
4133}
4134
4135/// Maximum stage-0 mtime across two `(sec, nsec)` pairs.
4136#[must_use]
4137pub(crate) fn max_stage0_mtime_pair(a: (u32, u32), b: (u32, u32)) -> (u32, u32) {
4138    if a.0 > b.0 || (a.0 == b.0 && a.1 > b.1) {
4139        a
4140    } else {
4141        b
4142    }
4143}
4144
4145/// Stage-0 mtime maximum in `entries`.
4146pub(crate) fn stage0_mtime_max(entries: &[IndexEntry]) -> (u32, u32) {
4147    max_stage0_entry_mtime(entries)
4148}
4149
4150/// Advance the on-disk index mtime to be strictly newer than every stage-0 entry mtime.
4151///
4152/// This only acts when the index write lands on the same timestamp tick as the newest entry
4153/// (or earlier), as coarse filesystem clocks allow, and moves it the smallest step past that
4154/// entry. That step clears raciness only under `core.usenanosec`. Under the default
4155/// whole-second rule, entries in the index's own second stay racy, and the next status
4156/// verifies them by content once and then rewrites the index. Never move the mtime into a
4157/// later second: stat matching ignores nanoseconds by default, so that would hide an edit
4158/// made within the entry's second.
4159///
4160/// When `touched_only_max` is set, it must be the true maximum stage-0 mtime in the index after
4161/// checkout (not just the touched entries), or later switch prepare may treat paths as racy.
4162///
4163/// # Errors
4164///
4165/// Returns [`Error::Io`] when the index timestamp cannot be updated.
4166pub(crate) fn advance_index_file_mtime_past_entries(
4167    index: &Index,
4168    index_path: &Path,
4169    touched_only_max: Option<(u32, u32)>,
4170) -> Result<()> {
4171    use filetime::FileTime;
4172    let (max_sec, max_nsec) = match touched_only_max {
4173        Some(pair) => pair,
4174        None => max_stage0_entry_mtime(&index.entries),
4175    };
4176    if max_sec == 0 && max_nsec == 0 {
4177        return Ok(());
4178    }
4179    let needs_bump = index_file_mtime(index_path)
4180        .is_none_or(|(sec, nsec)| sec < max_sec || (sec == max_sec && nsec <= max_nsec));
4181    if !needs_bump {
4182        return Ok(());
4183    }
4184    let ft = if max_nsec < 999_999_999 {
4185        FileTime::from_unix_time(i64::from(max_sec), max_nsec + 1)
4186    } else {
4187        FileTime::from_unix_time(i64::from(max_sec) + 1, 0)
4188    };
4189    filetime::set_file_mtime(index_path, ft).map_err(Error::Io)
4190}
4191
4192/// String form of an index path as surfaced in porcelain outcomes (UTF-8 or lossy).
4193#[must_use]
4194pub(crate) fn index_path_outcome_key(path: &[u8]) -> Cow<'_, str> {
4195    match std::str::from_utf8(path) {
4196        Ok(s) => Cow::Borrowed(s),
4197        Err(_) => Cow::Owned(String::from_utf8_lossy(path).into_owned()),
4198    }
4199}
4200
4201/// Join a repository work tree root with an index-relative path stored as raw bytes.
4202///
4203/// Index paths may be non-UTF-8 on Unix; this preserves bytes when building the absolute path.
4204#[must_use]
4205pub fn worktree_path_from_index_rel(work_tree: &Path, rel: &[u8]) -> PathBuf {
4206    #[cfg(unix)]
4207    {
4208        use std::ffi::OsStr;
4209        use std::os::unix::ffi::OsStrExt;
4210        work_tree.join(OsStr::from_bytes(rel))
4211    }
4212    #[cfg(not(unix))]
4213    {
4214        let rel = std::str::from_utf8(rel)
4215            .map(Cow::Borrowed)
4216            .unwrap_or_else(|_| Cow::Owned(String::from_utf8_lossy(rel).into_owned()));
4217        work_tree.join(rel.as_ref())
4218    }
4219}
4220
4221/// Stat fields copied from a worktree `Metadata` probe during index staging.
4222#[derive(Debug, Clone, Copy)]
4223pub struct IndexStatFields {
4224    ctime_sec: u32,
4225    ctime_nsec: u32,
4226    mtime_sec: u32,
4227    mtime_nsec: u32,
4228    dev: u32,
4229    ino: u32,
4230    uid: u32,
4231    gid: u32,
4232    size: u32,
4233}
4234
4235impl IndexStatFields {
4236    /// Capture the same index fields as `from_metadata` from a Linux fstatat result.
4237    #[cfg(all(target_os = "linux", target_pointer_width = "64"))]
4238    pub(crate) fn from_linux_stat(stat: &libc::stat) -> Self {
4239        Self {
4240            ctime_sec: stat.st_ctime as u32,
4241            ctime_nsec: stat.st_ctime_nsec as u32,
4242            mtime_sec: stat.st_mtime as u32,
4243            mtime_nsec: stat.st_mtime_nsec as u32,
4244            dev: stat.st_dev as u32,
4245            ino: stat.st_ino as u32,
4246            uid: stat.st_uid,
4247            gid: stat.st_gid,
4248            size: munge_index_size(stat.st_size as u64),
4249        }
4250    }
4251
4252    /// Munged size field stored in the index (see [`munge_index_size`]).
4253    #[must_use]
4254    pub(crate) fn index_size(&self) -> u32 {
4255        self.size
4256    }
4257
4258    /// Capture stat fields from filesystem metadata.
4259    #[must_use]
4260    pub fn from_metadata(meta: &fs::Metadata) -> Self {
4261        #[cfg(unix)]
4262        {
4263            use std::os::unix::fs::MetadataExt;
4264            let ((ctime_sec, ctime_nsec), (mtime_sec, mtime_nsec)) = metadata_times(meta);
4265            Self {
4266                ctime_sec,
4267                ctime_nsec,
4268                mtime_sec,
4269                mtime_nsec,
4270                dev: meta.dev() as u32,
4271                ino: meta.ino() as u32,
4272                uid: meta.uid(),
4273                gid: meta.gid(),
4274                size: munge_index_size(meta.size()),
4275            }
4276        }
4277        #[cfg(not(unix))]
4278        {
4279            let ((ctime_sec, ctime_nsec), (mtime_sec, mtime_nsec)) = metadata_times(meta);
4280            Self {
4281                ctime_sec,
4282                ctime_nsec,
4283                mtime_sec,
4284                mtime_nsec,
4285                dev: 0,
4286                ino: 0,
4287                uid: 0,
4288                gid: 0,
4289                size: munge_index_size(meta.len()),
4290            }
4291        }
4292    }
4293
4294    /// Stat fields currently cached on `ie`.
4295    #[must_use]
4296    pub(crate) fn of_entry(ie: &IndexEntry) -> Self {
4297        Self {
4298            ctime_sec: ie.ctime_sec,
4299            ctime_nsec: ie.ctime_nsec,
4300            mtime_sec: ie.mtime_sec,
4301            mtime_nsec: ie.mtime_nsec,
4302            dev: ie.dev,
4303            ino: ie.ino,
4304            uid: ie.uid,
4305            gid: ie.gid,
4306            size: ie.size,
4307        }
4308    }
4309
4310    /// Whether `self` and `other` carry the same stat data under `policy`.
4311    #[must_use]
4312    pub(crate) fn equivalent_to(&self, other: &Self, policy: StatMatchPolicy) -> bool {
4313        if self.size != other.size || self.mtime_sec != other.mtime_sec {
4314            return false;
4315        }
4316        if policy.use_nanosec && policy.check_stat && self.mtime_nsec != other.mtime_nsec {
4317            return false;
4318        }
4319        if policy.trust_ctime && policy.check_stat {
4320            if self.ctime_sec != other.ctime_sec {
4321                return false;
4322            }
4323            if policy.use_nanosec && self.ctime_nsec != other.ctime_nsec {
4324                return false;
4325            }
4326        }
4327        !policy.check_stat
4328            || (self.dev == other.dev
4329                && self.ino == other.ino
4330                && self.uid == other.uid
4331                && self.gid == other.gid)
4332    }
4333
4334    /// Replace the stat data cached on `ie` with these fields (OID, mode and path are kept).
4335    pub(crate) fn store_in(&self, ie: &mut IndexEntry) {
4336        ie.ctime_sec = self.ctime_sec;
4337        ie.ctime_nsec = self.ctime_nsec;
4338        ie.mtime_sec = self.mtime_sec;
4339        ie.mtime_nsec = self.mtime_nsec;
4340        ie.dev = self.dev;
4341        ie.ino = self.ino;
4342        ie.uid = self.uid;
4343        ie.gid = self.gid;
4344        ie.size = self.size;
4345    }
4346
4347    /// Build a stage-0 index entry using cached stat fields.
4348    #[must_use]
4349    pub fn index_entry(self, rel_path: &[u8], oid: ObjectId, mode: u32) -> IndexEntry {
4350        IndexEntry {
4351            ctime_sec: self.ctime_sec,
4352            ctime_nsec: self.ctime_nsec,
4353            mtime_sec: self.mtime_sec,
4354            mtime_nsec: self.mtime_nsec,
4355            dev: self.dev,
4356            ino: self.ino,
4357            mode,
4358            uid: self.uid,
4359            gid: self.gid,
4360            size: self.size,
4361            oid,
4362            flags: rel_path.len().min(0xFFF) as u16,
4363            flags_extended: None,
4364            path: rel_path.to_vec(),
4365            base_index_pos: 0,
4366        }
4367    }
4368}
4369
4370impl From<&fs::Metadata> for IndexStatFields {
4371    fn from(meta: &fs::Metadata) -> Self {
4372        Self::from_metadata(meta)
4373    }
4374}
4375
4376impl IndexStatFields {
4377    /// Whether munged size and whole-second mtime match the index row (Git quick stat check).
4378    #[must_use]
4379    #[cfg_attr(not(windows), allow(dead_code))]
4380    pub(crate) fn mtime_and_size_match_index(&self, ie: &IndexEntry) -> bool {
4381        self.size == ie.size && self.mtime_sec == ie.mtime_sec
4382    }
4383
4384    /// Whether cached stat fields match a stage-0 index row (Git `ie_match_stat`).
4385    #[must_use]
4386    pub(crate) fn matches_index_entry(&self, ie: &IndexEntry, policy: StatMatchPolicy) -> bool {
4387        if self.size != ie.size {
4388            return false;
4389        }
4390        if self.mtime_sec != ie.mtime_sec {
4391            return false;
4392        }
4393        if policy.use_nanosec && policy.check_stat && self.mtime_nsec != ie.mtime_nsec {
4394            return false;
4395        }
4396        if policy.trust_ctime && policy.check_stat {
4397            if self.ctime_sec != ie.ctime_sec {
4398                return false;
4399            }
4400            if policy.use_nanosec && self.ctime_nsec != ie.ctime_nsec {
4401                return false;
4402            }
4403        }
4404        if policy.check_stat {
4405            // Directory scans on Windows populate identity fields with zero (Git `mingw_stat`
4406            // also stores dev/ino/uid/gid as zero). Do not treat zeroed scan fields as a mismatch
4407            // against index rows written by Git or grit (#1518, #1702).
4408            #[cfg(windows)]
4409            {
4410                if self.ino != ie.ino && self.ino != 0 {
4411                    return false;
4412                }
4413                if self.dev != ie.dev && self.dev != 0 {
4414                    return false;
4415                }
4416                if self.uid != ie.uid && self.uid != 0 {
4417                    return false;
4418                }
4419                if self.gid != ie.gid && self.gid != 0 {
4420                    return false;
4421                }
4422            }
4423            #[cfg(not(windows))]
4424            if self.ino != ie.ino {
4425                return false;
4426            }
4427        }
4428        true
4429    }
4430}
4431
4432/// Build an [`IndexEntry`] from already-obtained metadata.
4433///
4434/// This avoids a redundant `stat()` call when the caller already has
4435/// filesystem metadata (e.g. from `symlink_metadata`).
4436#[must_use]
4437pub fn entry_from_metadata(
4438    meta: &fs::Metadata,
4439    rel_path: &[u8],
4440    oid: ObjectId,
4441    mode: u32,
4442) -> IndexEntry {
4443    #[cfg(unix)]
4444    {
4445        use std::os::unix::fs::MetadataExt;
4446        let ((ctime_sec, ctime_nsec), (mtime_sec, mtime_nsec)) = metadata_times(meta);
4447        IndexEntry {
4448            ctime_sec,
4449            ctime_nsec,
4450            mtime_sec,
4451            mtime_nsec,
4452            dev: meta.dev() as u32,
4453            ino: meta.ino() as u32,
4454            mode,
4455            uid: meta.uid(),
4456            gid: meta.gid(),
4457            size: munge_index_size(meta.size()),
4458            oid,
4459            flags: rel_path.len().min(0xFFF) as u16,
4460            flags_extended: None,
4461            path: rel_path.to_vec(),
4462            base_index_pos: 0,
4463        }
4464    }
4465    #[cfg(not(unix))]
4466    {
4467        let ((ctime_sec, ctime_nsec), (mtime_sec, mtime_nsec)) = metadata_times(meta);
4468        IndexEntry {
4469            ctime_sec,
4470            ctime_nsec,
4471            mtime_sec,
4472            mtime_nsec,
4473            dev: 0,
4474            ino: 0,
4475            mode,
4476            uid: 0,
4477            gid: 0,
4478            size: munge_index_size(meta.len()),
4479            oid,
4480            flags: rel_path.len().min(0xFFF) as u16,
4481            flags_extended: None,
4482            path: rel_path.to_vec(),
4483            base_index_pos: 0,
4484        }
4485    }
4486}
4487
4488/// Convert a `stat` mode to the Git index mode, normalised to one of the
4489/// known constants ([`MODE_REGULAR`], [`MODE_EXECUTABLE`], [`MODE_SYMLINK`]).
4490///
4491/// Only the `S_IFMT` and execute bits are inspected; all other permission bits
4492/// are discarded (Git stores only 644 or 755 for regular files).
4493///
4494/// # Parameters
4495///
4496/// - `raw_mode` — the raw `st_mode` value from `stat(2)`.
4497#[must_use]
4498pub fn normalize_mode(raw_mode: u32) -> u32 {
4499    const S_IFMT: u32 = 0o170000;
4500    const S_IFLNK: u32 = 0o120000;
4501    const S_IFREG: u32 = 0o100000;
4502
4503    let fmt = raw_mode & S_IFMT;
4504    if fmt == S_IFLNK {
4505        return MODE_SYMLINK;
4506    }
4507    if fmt == S_IFREG {
4508        // Executable if any execute bit is set
4509        if raw_mode & 0o111 != 0 {
4510            return MODE_EXECUTABLE;
4511        }
4512        return MODE_REGULAR;
4513    }
4514    // Fallback for everything else (devices, etc.) — treat as regular
4515    MODE_REGULAR
4516}
4517
4518#[cfg(test)]
4519pub(crate) mod index_path_cmp {
4520    use std::cell::Cell;
4521
4522    thread_local! { // hygiene: index path comparison test counter (cfg(test) only)
4523        static PATH_CMP_COUNT: Cell<usize> = const { Cell::new(0) };
4524    }
4525
4526    pub fn reset() {
4527        PATH_CMP_COUNT.with(|c| c.set(0));
4528    }
4529
4530    pub fn take() -> usize {
4531        PATH_CMP_COUNT.with(|c| {
4532            let n = c.get();
4533            c.set(0);
4534            n
4535        })
4536    }
4537
4538    pub fn bump() {
4539        PATH_CMP_COUNT.with(|c| c.set(c.get() + 1));
4540    }
4541}
4542
4543fn record_path_cmp_for_tests() {
4544    #[cfg(test)]
4545    index_path_cmp::bump();
4546}
4547
4548#[cfg(test)]
4549mod tests {
4550    #![allow(clippy::expect_used, clippy::unwrap_used)]
4551
4552    use super::*;
4553    use tempfile::TempDir;
4554
4555    /// Per-thread count of fsmonitor EWAH rebuilds. Thread-local so concurrently running tests
4556    /// that also write fsmonitor indexes do not disturb each other's counts.
4557    pub(super) mod fsmonitor_rebuild_counter {
4558        use std::cell::Cell;
4559
4560        thread_local! { // hygiene: fsmonitor rebuild counter for cfg(test) integration tests
4561            static COUNT: Cell<usize> = const { Cell::new(0) };
4562        }
4563
4564        pub fn reset() {
4565            COUNT.with(|c| c.set(0));
4566        }
4567
4568        pub fn count() -> usize {
4569            COUNT.with(Cell::get)
4570        }
4571
4572        pub fn note_build() {
4573            COUNT.with(|c| c.set(c.get() + 1));
4574        }
4575    }
4576
4577    fn dummy_oid() -> ObjectId {
4578        ObjectId::from_bytes(&[0u8; 20]).unwrap()
4579    }
4580
4581    fn make_entry(path: &str) -> IndexEntry {
4582        IndexEntry {
4583            ctime_sec: 0,
4584            ctime_nsec: 0,
4585            mtime_sec: 0,
4586            mtime_nsec: 0,
4587            dev: 0,
4588            ino: 0,
4589            mode: MODE_REGULAR,
4590            uid: 0,
4591            gid: 0,
4592            size: 0,
4593            oid: dummy_oid(),
4594            flags: path.len().min(0xFFF) as u16,
4595            flags_extended: None,
4596            path: path.as_bytes().to_vec(),
4597            base_index_pos: 0,
4598        }
4599    }
4600
4601    #[test]
4602    fn round_trip_empty_index() {
4603        let dir = TempDir::new().unwrap();
4604        let path = dir.path().join("index");
4605
4606        let mut idx = Index::new();
4607        idx.write(&path).unwrap();
4608
4609        let loaded = Index::load(&path).unwrap();
4610        assert_eq!(loaded.entries.len(), 0);
4611    }
4612
4613    #[test]
4614    fn read_index_file_maps_only_large_files() {
4615        let dir = TempDir::new().unwrap();
4616        let threshold = usize::try_from(INDEX_MMAP_MIN_BYTES).unwrap();
4617        for (len, mapped) in [(0, false), (threshold - 1, false), (threshold, true)] {
4618            let path = dir.path().join(format!("index-{len}"));
4619            let contents: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
4620            fs::write(&path, &contents).unwrap();
4621            let (bytes, mtime) = read_index_file(&path).unwrap();
4622            assert_eq!(matches!(bytes, IndexFileBytes::Mapped(_)), mapped, "{len}");
4623            assert_eq!(&*bytes, contents.as_slice());
4624            assert_eq!(Some(mtime), index_file_mtime(&path));
4625        }
4626        let missing = read_index_file(&dir.path().join("missing")).err().unwrap();
4627        assert_eq!(missing.kind(), io::ErrorKind::NotFound);
4628    }
4629
4630    #[test]
4631    fn round_trip_with_entries() {
4632        let dir = TempDir::new().unwrap();
4633        let path = dir.path().join("index");
4634
4635        let mut idx = Index::new();
4636        idx.add_or_replace(make_entry("foo.txt"));
4637        idx.add_or_replace(make_entry("bar/baz.txt"));
4638        idx.write(&path).unwrap(); // idx is mut in each test scope
4639
4640        let loaded = Index::load(&path).unwrap();
4641        assert_eq!(loaded.entries.len(), 2);
4642        assert_eq!(loaded.entries[0].path, b"bar/baz.txt");
4643        assert_eq!(loaded.entries[1].path, b"foo.txt");
4644    }
4645
4646    fn overwrite_fixture() -> Index {
4647        let mut idx = Index::new();
4648        for i in 0..200 {
4649            let mut e = make_entry(&format!("d{}/f{i:03}.txt", i % 4));
4650            e.base_index_pos = i + 1;
4651            idx.add_or_replace(e);
4652        }
4653        for stage in 1..=3u16 {
4654            let mut e = make_entry("d0/conflict.txt");
4655            e.flags |= stage << 12;
4656            idx.add_or_replace(e);
4657        }
4658        let leaf =
4659            |name: &str| CacheTreeNode::valid(name.as_bytes().to_vec(), 50, dummy_oid(), vec![]);
4660        idx.cache_tree_root = Some(dummy_oid());
4661        idx.cache_tree = Some(CacheTreeNode::valid(
4662            Vec::new(),
4663            203,
4664            dummy_oid(),
4665            vec![leaf("d0"), leaf("d1"), leaf("d2"), leaf("d3")],
4666        ));
4667        assert!(idx.entries_sorted);
4668        idx
4669    }
4670
4671    fn overwrite_replacements(count: usize) -> Vec<IndexEntry> {
4672        let changed = ObjectId::from_bytes(&[7u8; 20]).unwrap();
4673        let mut out: Vec<IndexEntry> = (0..count)
4674            .map(|i| {
4675                let mut e = make_entry(&format!("d{}/f{:03}.txt", (i * 3) % 4, i * 3));
4676                e.oid = changed;
4677                e.size = 9;
4678                e
4679            })
4680            .collect();
4681        out.sort_by(|a, b| a.path.cmp(&b.path));
4682        out
4683    }
4684
4685    #[test]
4686    fn overwrite_stage0_entries_matches_sorted_merge() {
4687        for count in [3, 64] {
4688            let mut merged = overwrite_fixture();
4689            merged.replace_stage0_entries_batch(overwrite_replacements(count));
4690            let mut overwritten = overwrite_fixture();
4691            overwritten.overwrite_stage0_entries(overwrite_replacements(count));
4692            assert_eq!(overwritten.entries, merged.entries, "count {count}");
4693            assert_eq!(overwritten.cache_tree, merged.cache_tree, "count {count}");
4694            assert_eq!(overwritten.cache_tree_root, merged.cache_tree_root);
4695            assert_eq!(overwritten.entries.len(), 203);
4696        }
4697    }
4698
4699    #[test]
4700    fn overwrite_stage0_entries_keeps_split_positions_and_invalidates_touched_dirs() {
4701        let mut idx = overwrite_fixture();
4702        let target = b"d1/f001.txt".to_vec();
4703        let pos = idx.find_entry_pos(&target, 0).unwrap();
4704        let before = idx.entries[pos].base_index_pos;
4705        let mut e = make_entry("d1/f001.txt");
4706        e.size = 42;
4707        idx.overwrite_stage0_entries(vec![e]);
4708        assert_eq!(idx.entries[pos].size, 42);
4709        assert_eq!(idx.entries[pos].base_index_pos, before);
4710        let tree = idx.cache_tree.as_ref().unwrap();
4711        assert!(!tree.is_valid());
4712        let child = |name: &[u8]| tree.children.iter().find(|c| c.name == name).unwrap();
4713        assert!(!child(b"d1").is_valid());
4714        assert!(child(b"d0").is_valid());
4715    }
4716
4717    #[test]
4718    fn overwrite_stage0_entries_merges_paths_without_a_stage0_row() {
4719        let mut idx = overwrite_fixture();
4720        let mut new_path = make_entry("d2/new.txt");
4721        new_path.size = 1;
4722        idx.overwrite_stage0_entries(vec![make_entry("d0/f000.txt"), new_path]);
4723        assert_eq!(idx.entries.len(), 204);
4724        assert!(idx.get(b"d2/new.txt", 0).is_some());
4725        assert_eq!(
4726            idx.entries
4727                .iter()
4728                .filter(|e| e.path == b"d0/conflict.txt")
4729                .count(),
4730            3
4731        );
4732        assert!(idx.entries.windows(2).all(|w| {
4733            Index::cmp_paths(&w[0].path, &w[1].path)
4734                .then_with(|| w[0].stage().cmp(&w[1].stage()))
4735                .is_lt()
4736        }));
4737    }
4738
4739    #[test]
4740    fn remove_descendants_under_path_drops_nested_only() {
4741        let mut idx = Index::new();
4742        idx.add_or_replace(make_entry("d/e"));
4743        idx.add_or_replace(make_entry("d-other"));
4744        idx.add_or_replace(make_entry("prefix/d"));
4745        idx.remove_descendants_under_path("d");
4746        let paths: Vec<_> = idx.entries.iter().map(|e| e.path.as_slice()).collect();
4747        assert_eq!(paths, vec![b"d-other".as_slice(), b"prefix/d".as_slice()]);
4748    }
4749
4750    #[test]
4751    fn fsmonitor_extension_round_trip_matches_git_layout() {
4752        // Payload from `git -c core.fsmonitor=true status` on a one-file repo (v2 + builtin:fake + EWAH).
4753        let fsmn_payload: [u8; 41] = [
4754            0, 0, 0, 2, b'b', b'u', b'i', b'l', b't', b'i', b'n', b':', b'f', b'a', b'k', b'e', 0,
4755            0, 0, 0, 20, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
4756        ];
4757        let (token, dirty) = parse_fsmonitor_extension(&fsmn_payload).unwrap();
4758        assert_eq!(token, "builtin:fake");
4759        assert_eq!(dirty.bit_size, 0);
4760
4761        let dir = TempDir::new().unwrap();
4762        let path = dir.path().join("index");
4763        let mut idx = Index::new();
4764        idx.add_or_replace(make_entry("a"));
4765        if let Some(e) = idx.get_mut(b"a", 0) {
4766            e.set_fsmonitor_valid(true);
4767        }
4768        idx.fsmonitor_last_update = Some(token);
4769        idx.fsmonitor_dirty = Some(dirty);
4770        idx.fsmonitor_raw_extension = Some(fsmn_payload.to_vec());
4771        idx.write(&path).unwrap(); // idx is mut in each test scope
4772
4773        let loaded = Index::load(&path).unwrap();
4774        assert_eq!(
4775            loaded.fsmonitor_last_update.as_deref(),
4776            Some("builtin:fake")
4777        );
4778        assert_eq!(loaded.fsmonitor_dirty.as_ref().map(|b| b.bit_size), Some(0));
4779        assert_eq!(
4780            loaded.fsmonitor_raw_extension.as_deref(),
4781            Some(fsmn_payload.as_slice())
4782        );
4783
4784        let mut body = Vec::new();
4785        loaded.serialize_into(&mut body).unwrap();
4786        assert!(
4787            body.windows(4).any(|w| w == b"FSMN"),
4788            "serialized index must include FSMN"
4789        );
4790        let off = body.windows(4).position(|w| w == b"FSMN").unwrap();
4791        let ext_sz = u32::from_be_bytes(body[off + 4..off + 8].try_into().unwrap()) as usize;
4792        let written = &body[off + 8..off + 8 + ext_sz];
4793        assert_eq!(written, fsmn_payload);
4794    }
4795
4796    #[test]
4797    fn fsmonitor_extension_written_from_token_only_state() {
4798        let dir = TempDir::new().unwrap();
4799        let path = dir.path().join("index");
4800        let mut idx = Index::new();
4801        idx.add_or_replace(make_entry("a"));
4802        idx.set_fsmonitor_last_update(Some("builtin:fake".to_owned()));
4803        idx.write(&path).unwrap(); // idx is mut in each test scope
4804
4805        let data = fs::read(&path).unwrap();
4806        assert!(
4807            data.windows(4).any(|w| w == b"FSMN"),
4808            "token-only enable must emit a full FSMN extension"
4809        );
4810        let loaded = Index::load(&path).unwrap();
4811        assert_eq!(
4812            loaded.fsmonitor_last_update.as_deref(),
4813            Some("builtin:fake")
4814        );
4815    }
4816
4817    #[test]
4818    fn fsmonitor_raw_not_reused_after_token_change() {
4819        let fsmn_payload: [u8; 41] = [
4820            0, 0, 0, 2, b'b', b'u', b'i', b'l', b't', b'i', b'n', b':', b'f', b'a', b'k', b'e', 0,
4821            0, 0, 0, 20, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
4822        ];
4823        let mut idx = Index::new();
4824        idx.add_or_replace(make_entry("a"));
4825        idx.fsmonitor_last_update = Some("builtin:fake".to_owned());
4826        idx.fsmonitor_dirty = Some(parse_fsmonitor_extension(&fsmn_payload).unwrap().1);
4827        idx.fsmonitor_raw_extension = Some(fsmn_payload.to_vec());
4828
4829        idx.set_fsmonitor_last_update(Some("last_update_token".to_owned()));
4830        let mut body = Vec::new();
4831        idx.serialize_into(&mut body).unwrap();
4832        let off = body.windows(4).position(|w| w == b"FSMN").unwrap();
4833        let ext_sz = u32::from_be_bytes(body[off + 4..off + 8].try_into().unwrap()) as usize;
4834        let written = &body[off + 8..off + 8 + ext_sz];
4835        let (token, _) = parse_fsmonitor_extension(written).unwrap();
4836        assert_eq!(token, "last_update_token");
4837        assert_ne!(written, fsmn_payload);
4838    }
4839
4840    #[test]
4841    fn fsmonitor_raw_not_reused_after_validity_change() {
4842        let fsmn_payload: [u8; 41] = [
4843            0, 0, 0, 2, b'b', b'u', b'i', b'l', b't', b'i', b'n', b':', b'f', b'a', b'k', b'e', 0,
4844            0, 0, 0, 20, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
4845        ];
4846        let mut idx = Index::new();
4847        idx.add_or_replace(make_entry("a"));
4848        idx.fsmonitor_last_update = Some("builtin:fake".to_owned());
4849        idx.fsmonitor_dirty = Some(parse_fsmonitor_extension(&fsmn_payload).unwrap().1);
4850        idx.fsmonitor_raw_extension = Some(fsmn_payload.to_vec());
4851        if let Some(e) = idx.get_mut(b"a", 0) {
4852            e.set_fsmonitor_valid(false);
4853        }
4854        idx.fsmonitor_validity_changed();
4855
4856        let mut body = Vec::new();
4857        idx.serialize_into(&mut body).unwrap();
4858        let off = body.windows(4).position(|w| w == b"FSMN").unwrap();
4859        let ext_sz = u32::from_be_bytes(body[off + 4..off + 8].try_into().unwrap()) as usize;
4860        let written = &body[off + 8..off + 8 + ext_sz];
4861        assert_ne!(written, fsmn_payload);
4862        let (_, dirty) = parse_fsmonitor_extension(written).unwrap();
4863        assert!(dirty.bit_size >= 1);
4864    }
4865
4866    fn make_staged_entry(path: &str, stage: u8) -> IndexEntry {
4867        let mut e = make_entry(path);
4868        e.set_stage(stage);
4869        e
4870    }
4871
4872    /// Naive O(n) index model mirroring pre–binary-search semantics.
4873    struct LinearIndex {
4874        entries: Vec<IndexEntry>,
4875        resolve_undo: Option<BTreeMap<Vec<u8>, ResolveUndoRecord>>,
4876    }
4877
4878    impl LinearIndex {
4879        fn from_index(idx: &Index) -> Self {
4880            Self {
4881                entries: idx.entries.clone(),
4882                resolve_undo: idx.resolve_undo.clone(),
4883            }
4884        }
4885
4886        fn assert_matches(&self, idx: &Index) {
4887            let mut actual = idx.entries.clone();
4888            actual.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.stage().cmp(&b.stage())));
4889            let mut expect = self.entries.clone();
4890            expect.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.stage().cmp(&b.stage())));
4891            assert_eq!(actual, expect);
4892            assert_eq!(idx.resolve_undo, self.resolve_undo);
4893        }
4894
4895        fn add_or_replace(&mut self, entry: IndexEntry) {
4896            if let Some(pos) = self
4897                .entries
4898                .iter()
4899                .position(|e| e.path == entry.path && e.stage() == entry.stage())
4900            {
4901                self.entries[pos] = entry;
4902            } else {
4903                self.entries.push(entry);
4904            }
4905            self.entries
4906                .sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.stage().cmp(&b.stage())));
4907        }
4908
4909        fn remove(&mut self, path: &[u8]) -> bool {
4910            let mut removed_any = false;
4911            for e in &self.entries {
4912                if e.path == path {
4913                    if e.stage() != 0 {
4914                        resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, e);
4915                    }
4916                    removed_any = true;
4917                }
4918            }
4919            if !removed_any {
4920                return false;
4921            }
4922            self.entries.retain(|e| e.path != path);
4923            removed_any
4924        }
4925
4926        fn stage_file(&mut self, entry: IndexEntry) {
4927            let path = entry.path.clone();
4928            for e in &self.entries {
4929                if e.path == path && e.stage() != 0 {
4930                    resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, e);
4931                }
4932            }
4933            self.entries.retain(|e| e.path != path || e.stage() == 0);
4934            self.add_or_replace(entry);
4935        }
4936
4937        fn remove_descendants_under_path(&mut self, path: &str) {
4938            let prefix = path.as_bytes();
4939            if prefix.is_empty() {
4940                return;
4941            }
4942            let plen = prefix.len();
4943            for e in self.entries.iter() {
4944                let ep = e.path.as_slice();
4945                if ep.len() > plen && ep.starts_with(prefix) && ep[plen] == b'/' && e.stage() != 0 {
4946                    resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, e);
4947                }
4948            }
4949            self.entries.retain(|e| {
4950                let ep = e.path.as_slice();
4951                if ep.len() <= plen {
4952                    return true;
4953                }
4954                if !ep.starts_with(prefix) {
4955                    return true;
4956                }
4957                ep[plen] != b'/'
4958            });
4959        }
4960    }
4961
4962    #[test]
4963    fn sorted_ops_match_linear_oracle() {
4964        let paths: Vec<Vec<u8>> = [
4965            "a", "a-b", "a/b", "a/b/c", "aa", "b", "dir/x", "dir/x/y", "dir-xy",
4966        ]
4967        .into_iter()
4968        .map(|p| p.as_bytes().to_vec())
4969        .collect();
4970
4971        let mut idx = Index::new();
4972        let mut oracle = LinearIndex::from_index(&idx);
4973        let mut rng_state: u64 = 376;
4974
4975        fn next_u64(state: &mut u64) -> u64 {
4976            *state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
4977            *state
4978        }
4979
4980        for step in 0..400 {
4981            let op = next_u64(&mut rng_state) % 4;
4982            match op {
4983                0 => {
4984                    let p = &paths[(next_u64(&mut rng_state) as usize) % paths.len()];
4985                    let stage = (next_u64(&mut rng_state) % 4) as u8;
4986                    let mut e = make_staged_entry(std::str::from_utf8(p).unwrap(), stage);
4987                    e.oid = ObjectId::from_bytes(&[(step % 256) as u8; 20]).unwrap();
4988                    idx.add_or_replace(e.clone());
4989                    oracle.add_or_replace(e);
4990                }
4991                1 => {
4992                    let p = &paths[(next_u64(&mut rng_state) as usize) % paths.len()];
4993                    idx.remove(p);
4994                    oracle.remove(p);
4995                }
4996                2 => {
4997                    let p = &paths[(next_u64(&mut rng_state) as usize) % paths.len()];
4998                    let e = make_entry(std::str::from_utf8(p).unwrap());
4999                    idx.stage_file(e.clone());
5000                    oracle.stage_file(e);
5001                }
5002                _ => {
5003                    let p = &paths[(next_u64(&mut rng_state) as usize) % paths.len()];
5004                    let ps = std::str::from_utf8(p).unwrap();
5005                    idx.remove_descendants_under_path(ps);
5006                    oracle.remove_descendants_under_path(ps);
5007                }
5008            }
5009            oracle.assert_matches(&idx);
5010        }
5011    }
5012
5013    #[test]
5014    fn stale_sorted_flag_miss_until_resorted() {
5015        let mut idx = Index::new();
5016        idx.entries.push(make_entry("z"));
5017        idx.entries.push(make_entry("a"));
5018        idx.entries.push(make_entry("b"));
5019        idx.entries_sorted = true;
5020        assert!(!idx.entries_are_sorted());
5021        assert!(idx.get(b"z", 0).is_none());
5022        idx.sort();
5023        assert!(idx.get(b"z", 0).is_some());
5024        assert!(idx.remove(b"z"));
5025        assert!(idx.get(b"z", 0).is_none());
5026    }
5027
5028    #[test]
5029    fn write_restores_canonical_order_when_sorted_flag_is_stale() {
5030        let mut idx = Index::new();
5031        idx.entries.push(make_entry("zzz.txt"));
5032        idx.entries.push(make_entry("aaa.txt"));
5033        idx.entries_sorted = true;
5034        assert!(!idx.entries_are_sorted());
5035        let dir = tempfile::tempdir().expect("tempdir");
5036        let path = dir.path().join("index");
5037        idx.write(&path).expect("write index");
5038        let parsed = Index::load(&path).expect("load index");
5039        assert!(parsed.entries_are_sorted());
5040        assert_eq!(parsed.entries[0].path, b"aaa.txt".as_slice());
5041    }
5042
5043    #[test]
5044    fn find_entry_pos_sorted_miss_is_constant() {
5045        const N: usize = 100_000;
5046        const M: usize = 1_000;
5047        let mut idx = Index::new();
5048        for i in 0..N {
5049            idx.add_or_replace(make_entry(&format!("file/{i:06}.txt")));
5050        }
5051        assert!(idx.entries_sorted);
5052        let misses: Vec<Vec<u8>> = (0..M)
5053            .map(|i| format!("missing/{i:06}.txt").into_bytes())
5054            .collect();
5055        index_path_cmp::reset();
5056        for path in &misses {
5057            assert!(idx.get(path, 0).is_none());
5058        }
5059        let cmps = index_path_cmp::take();
5060        let log_n = (N as f64).log2().ceil() as usize;
5061        let bound = 100 * M * log_n;
5062        assert!(
5063            cmps < bound,
5064            "sorted miss path comparisons {cmps} should stay under {bound} (N={N}, M={M})"
5065        );
5066    }
5067
5068    #[test]
5069    fn unsorted_remove_drops_every_matching_stage_globally() {
5070        let mut idx = Index::new();
5071        idx.mark_entries_unsorted();
5072        idx.entries.push(make_staged_entry("a", 1));
5073        idx.entries.push(make_entry("z"));
5074        idx.entries.push(make_staged_entry("a", 2));
5075        assert!(idx.remove(b"a"));
5076        assert!(idx.get(b"a", 1).is_none());
5077        assert!(idx.get(b"a", 2).is_none());
5078        assert!(idx.get(b"z", 0).is_some());
5079    }
5080
5081    #[test]
5082    fn unsorted_remove_descendants_drops_noncontiguous_matches() {
5083        let mut idx = Index::new();
5084        idx.mark_entries_unsorted();
5085        idx.entries.push(make_entry("a/x"));
5086        idx.entries.push(make_entry("z"));
5087        idx.entries.push(make_entry("a/y"));
5088        idx.remove_descendants_under_path("a");
5089        let paths: Vec<_> = idx.entries.iter().map(|e| e.path.as_slice()).collect();
5090        assert_eq!(paths, vec![b"z".as_slice()]);
5091    }
5092
5093    #[test]
5094    fn sorted_ops_match_linear_oracle_unsorted_noncontiguous() {
5095        let mut idx = Index::new();
5096        idx.mark_entries_unsorted();
5097        idx.entries.push(make_entry("a/x"));
5098        idx.entries.push(make_entry("z"));
5099        idx.entries.push(make_staged_entry("a", 1));
5100        idx.remove(b"a");
5101        idx.entries.push(make_entry("a/y"));
5102        idx.sort();
5103        let mut oracle = LinearIndex::from_index(&idx);
5104        oracle.entries = idx.entries.clone();
5105        oracle.assert_matches(&idx);
5106        idx.remove_descendants_under_path("a");
5107        oracle.remove_descendants_under_path("a");
5108        oracle.assert_matches(&idx);
5109    }
5110
5111    #[test]
5112    fn merge_replacements_preserves_base_index_pos() {
5113        let mut idx = Index::new();
5114        let mut kept = make_entry("path/a.txt");
5115        kept.base_index_pos = 42;
5116        idx.add_or_replace(kept);
5117        let mut replacement = make_entry("path/a.txt");
5118        replacement.oid = dummy_oid();
5119        idx.remove_paths_and_insert([], [replacement]);
5120        assert_eq!(idx.entries[0].base_index_pos, 42);
5121    }
5122
5123    #[test]
5124    fn remove_paths_and_insert_replaces_equal_path_without_duplicates() {
5125        let mut idx = Index::new();
5126        idx.add_or_replace(make_entry("path/a.txt"));
5127        idx.add_or_replace(make_entry("path/b.txt"));
5128        let mut replacement = make_entry("path/a.txt");
5129        replacement.oid = dummy_oid();
5130        idx.remove_paths_and_insert(["ghost.txt".as_bytes()], [replacement.clone()]);
5131        assert_eq!(idx.entries.len(), 2);
5132        assert_eq!(idx.entries[0].path, b"path/a.txt");
5133        assert_eq!(idx.entries[0].oid, replacement.oid);
5134        assert_eq!(idx.entries[1].path, b"path/b.txt");
5135    }
5136
5137    #[test]
5138    fn remove_directory_prefixes_drops_nested_paths() {
5139        let mut idx = Index::new();
5140        idx.add_or_replace(make_entry("d005/f0.txt"));
5141        idx.add_or_replace(make_entry("d005/f1.txt"));
5142        idx.add_or_replace(make_entry("d006/f0.txt"));
5143        idx.add_or_replace(make_entry("keep.txt"));
5144        idx.remove_directory_prefixes(&BTreeSet::from(["d005".to_owned()]));
5145        assert!(idx.get(b"d005/f0.txt", 0).is_none());
5146        assert!(idx.get(b"d005/f1.txt", 0).is_none());
5147        assert!(idx.get(b"d006/f0.txt", 0).is_some());
5148        assert!(idx.get(b"keep.txt", 0).is_some());
5149    }
5150
5151    #[test]
5152    fn remove_prefixes_paths_and_insert_combines_prefix_exact_and_replacements() {
5153        let mut idx = Index::new();
5154        idx.add_or_replace(make_entry("d005/f0.txt"));
5155        idx.add_or_replace(make_entry("d005/f1.txt"));
5156        idx.add_or_replace(make_entry("d006/f0.txt"));
5157        idx.add_or_replace(make_entry("solo.txt"));
5158        let mut replacement = make_entry("solo.txt");
5159        replacement.oid = dummy_oid();
5160        idx.remove_prefixes_paths_and_insert(
5161            &BTreeSet::from(["d005".to_owned()]),
5162            [b"d006/f0.txt".as_slice()],
5163            [replacement.clone()],
5164        );
5165        assert!(idx.get(b"d005/f0.txt", 0).is_none());
5166        assert!(idx.get(b"d005/f1.txt", 0).is_none());
5167        assert!(idx.get(b"d006/f0.txt", 0).is_none());
5168        assert_eq!(idx.get(b"solo.txt", 0).unwrap().oid, replacement.oid);
5169    }
5170
5171    #[test]
5172    fn remove_directory_prefixes_does_not_skip_lexically_later_prefix_children() {
5173        let mut idx = Index::new();
5174        idx.add_or_replace(make_entry("d005/f0.txt"));
5175        idx.add_or_replace(make_entry("d005/f1.txt"));
5176        idx.remove_directory_prefixes(&BTreeSet::from(["d005".to_owned(), "d0050".to_owned()]));
5177        assert!(idx.get(b"d005/f0.txt", 0).is_none());
5178        assert!(idx.get(b"d005/f1.txt", 0).is_none());
5179    }
5180
5181    #[test]
5182    fn remove_is_sublinear() {
5183        const N: usize = 100_000;
5184        const M: usize = 1_000;
5185        let mut idx = Index::new();
5186        for i in 0..N {
5187            idx.add_or_replace(make_entry(&format!("file/{i:06}.txt")));
5188        }
5189        let paths: Vec<Vec<u8>> = (0..M)
5190            .map(|i| format!("file/{i:06}.txt").into_bytes())
5191            .collect();
5192        index_path_cmp::reset();
5193        idx.remove_paths(paths.iter().map(|p| p.as_slice()));
5194        let cmps = index_path_cmp::take();
5195        let log_n = (N as f64).log2().ceil() as usize;
5196        let bound = 100 * M * log_n;
5197        assert!(
5198            cmps < bound,
5199            "path comparisons {cmps} should stay under {bound} (N={N}, M={M})"
5200        );
5201        assert_eq!(idx.entries.len(), N - M);
5202    }
5203
5204    #[test]
5205    fn fsmonitor_rebuild_once_per_batch() {
5206        let fsmn_payload: [u8; 41] = [
5207            0, 0, 0, 2, b'b', b'u', b'i', b'l', b't', b'i', b'n', b':', b'f', b'a', b'k', b'e', 0,
5208            0, 0, 0, 20, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
5209        ];
5210        let (token, dirty) = parse_fsmonitor_extension(&fsmn_payload).unwrap();
5211        let dir = TempDir::new().unwrap();
5212        let path = dir.path().join("index");
5213
5214        let mut idx = Index::new();
5215        for i in 0..1000 {
5216            idx.add_or_replace(make_entry(&format!("path-{i:04}.txt")));
5217        }
5218        idx.sort();
5219        for e in &mut idx.entries {
5220            e.set_fsmonitor_valid(true);
5221        }
5222        idx.fsmonitor_last_update = Some(token);
5223        idx.fsmonitor_dirty = Some(dirty);
5224        idx.fsmonitor_raw_extension = Some(fsmn_payload.to_vec());
5225
5226        fsmonitor_rebuild_counter::reset();
5227        for i in 0..1000 {
5228            idx.add_or_replace(make_entry(&format!("mut-{i:04}.txt")));
5229        }
5230        assert_eq!(
5231            fsmonitor_rebuild_counter::count(),
5232            0,
5233            "entry mutations must not scan entries for the EWAH"
5234        );
5235        idx.write(&path).unwrap(); // idx is mut in each test scope
5236        assert_eq!(
5237            fsmonitor_rebuild_counter::count(),
5238            1,
5239            "write must build the EWAH once for the batch"
5240        );
5241    }
5242
5243    #[test]
5244    fn requested_v4_writes_v4_on_disk() {
5245        let dir = TempDir::new().unwrap();
5246        let path = dir.path().join("index");
5247
5248        let mut idx = Index {
5249            version: 4,
5250            ..Index::default()
5251        };
5252        idx.add_or_replace(make_entry("one"));
5253        idx.add_or_replace(make_entry("two/one"));
5254        idx.write(&path).unwrap(); // idx is mut in each test scope
5255
5256        let data = fs::read(&path).unwrap();
5257        assert_eq!(&data[4..8], &4u32.to_be_bytes());
5258
5259        let loaded = Index::load(&path).unwrap();
5260        assert_eq!(loaded.version, 4);
5261        assert_eq!(loaded.entries[0].path, b"one");
5262        assert_eq!(loaded.entries[1].path, b"two/one");
5263    }
5264
5265    #[test]
5266    fn stat_match_policy_from_repo_config() {
5267        let dir = TempDir::new().unwrap();
5268        let git_dir = dir.path().join(".git");
5269        fs::create_dir_all(&git_dir).unwrap();
5270        fs::write(
5271            git_dir.join("config"),
5272            "[core]\n\ttrustctime = false\n\tcheckstat = minimal\n\tusenanosec = true\n",
5273        )
5274        .unwrap();
5275        let cfg = crate::config::ConfigSet::load(
5276            &crate::environment::Environment::empty(),
5277            Some(&git_dir),
5278            true,
5279        )
5280        .unwrap();
5281        let policy = StatMatchPolicy::from_config(Some(&cfg));
5282        assert!(!policy.trust_ctime);
5283        assert!(!policy.check_stat);
5284        assert!(policy.use_nanosec);
5285    }
5286
5287    #[test]
5288    fn index_entry_extended_flags_and_placeholders() {
5289        let mut e = make_entry("p");
5290        e.set_intent_to_add(true);
5291        assert!(e.intent_to_add());
5292        e.set_assume_unchanged(true);
5293        assert!(e.assume_unchanged());
5294        e.set_skip_worktree(true);
5295        assert!(e.skip_worktree());
5296        e.mode = MODE_TREE;
5297        e.set_skip_worktree(true);
5298        assert!(e.is_sparse_directory_placeholder());
5299        e.set_overlay_tree_skip_output(true);
5300        assert!(e.overlay_tree_skip_output());
5301    }
5302
5303    #[test]
5304    fn cache_tree_formatting_and_invalidation_helpers() {
5305        let mut idx = Index::new();
5306        idx.add_or_replace(make_entry("a/b"));
5307        let child = CacheTreeNode::valid(b"a".to_vec(), 1, dummy_oid(), vec![]);
5308        let root = CacheTreeNode::valid(b"".to_vec(), 1, dummy_oid(), vec![child]);
5309        idx.set_cache_tree(root);
5310        let dump = idx.format_cache_tree_dump();
5311        assert!(dump.contains("a/") || dump.contains("invalid"));
5312        idx.invalidate_cache_tree_for_path(b"a/b");
5313        idx.clear_cache_tree();
5314        assert!(idx.cache_tree.is_none());
5315    }
5316
5317    #[test]
5318    fn dedup_paths_and_stage0_descendant_queries() {
5319        let mut idx = Index::new();
5320        idx.add_or_replace(make_entry("dir/x"));
5321        idx.add_or_replace(make_entry("dir/x"));
5322        idx.dedup_paths_keep_last();
5323        assert_eq!(idx.entries.len(), 1);
5324        assert!(idx.has_stage0_descendant_under("dir"));
5325        assert!(!idx.has_stage0_descendant_under("missing"));
5326    }
5327
5328    #[test]
5329    fn load_missing_index_is_empty() {
5330        let dir = TempDir::new().unwrap();
5331        let path = dir.path().join("no-index");
5332        let idx = Index::load(&path).unwrap();
5333        assert!(idx.entries.is_empty());
5334    }
5335
5336    #[test]
5337    fn munge_and_stat_equivalence_helpers() {
5338        assert_eq!(munge_index_size(0x1_0000_0000), 0x8000_0000);
5339        let a = make_entry("x");
5340        let b = make_entry("x");
5341        assert!(index_entries_stat_equivalent(
5342            &a,
5343            &b,
5344            StatMatchPolicy::default()
5345        ));
5346    }
5347
5348    #[test]
5349    fn new_from_config_invalid_index_version_falls_back() {
5350        let dir = TempDir::new().unwrap();
5351        let git_dir = dir.path().join(".git");
5352        fs::create_dir_all(&git_dir).unwrap();
5353        fs::write(
5354            git_dir.join("config"),
5355            "[index]\n\tversion = not-a-number\n",
5356        )
5357        .unwrap();
5358        let cfg = crate::config::ConfigSet::load(
5359            &crate::environment::Environment::empty(),
5360            Some(&git_dir),
5361            true,
5362        )
5363        .unwrap();
5364        let idx = Index::new_from_config(&cfg);
5365        assert_eq!(idx.version, INDEX_CONFIG_INVALID_FALLBACK);
5366    }
5367
5368    #[test]
5369    fn fsmonitor_invalidate_directory_prefix_without_trailing_slash() {
5370        let mut idx = Index::new();
5371        idx.add_or_replace(make_entry("dir/a.txt"));
5372        idx.add_or_replace(make_entry("dir/b.txt"));
5373        idx.set_fsmonitor_last_update(Some("tok".into()));
5374        for entry in idx.entries_mut() {
5375            entry.set_fsmonitor_valid(true);
5376        }
5377        idx.invalidate_fsmonitor_for_path("dir", false);
5378        assert!(!idx.get(b"dir/a.txt", 0).unwrap().fsmonitor_valid());
5379        assert!(!idx.get(b"dir/b.txt", 0).unwrap().fsmonitor_valid());
5380    }
5381
5382    #[test]
5383    fn parse_fsmonitor_extension_rejects_truncated_ewah() {
5384        let mut payload = vec![0, 0, 0, 2, b't', 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0];
5385        let err = parse_fsmonitor_extension(&payload).unwrap_err();
5386        assert!(matches!(
5387            err,
5388            Error::IndexFsmonitorExtensionTruncated | Error::IndexFsmonitorExtensionEwahInvalid
5389        ));
5390        payload[12..16].copy_from_slice(&2u32.to_be_bytes());
5391        let err2 = parse_fsmonitor_extension(&payload).unwrap_err();
5392        assert!(matches!(
5393            err2,
5394            Error::IndexFsmonitorExtensionTruncated | Error::IndexFsmonitorExtensionEwahInvalid
5395        ));
5396    }
5397
5398    #[test]
5399    fn unmerge_from_resolve_undo_installs_conflict_stages() {
5400        use crate::resolve_undo::ResolveUndoRecord;
5401        use std::collections::BTreeMap;
5402
5403        let mut idx = Index::new();
5404        idx.add_or_replace(make_entry("f.txt"));
5405        let oid1 = dummy_oid();
5406        let oid2 = ObjectId::from_bytes(&[1u8; 20]).unwrap();
5407        idx.resolve_undo = Some(BTreeMap::from([(
5408            b"f.txt".to_vec(),
5409            ResolveUndoRecord {
5410                modes: [MODE_REGULAR, MODE_REGULAR, 0],
5411                oids: [oid1, oid2, ObjectId::zero()],
5412            },
5413        )]));
5414        assert!(idx.unmerge_path_from_resolve_undo(b"f.txt"));
5415        assert!(idx.has_unmerged_entries());
5416    }
5417
5418    #[test]
5419    fn parse_rejects_truncated_headers() {
5420        for n in 0..12 {
5421            assert!(Index::parse(&vec![0u8; n]).is_err());
5422        }
5423    }
5424
5425    #[test]
5426    fn entry_from_stat_populates_index_metadata() {
5427        let dir = TempDir::new().unwrap();
5428        fs::write(dir.path().join("f.txt"), b"payload").unwrap();
5429        let oid = dummy_oid();
5430        let entry = entry_from_stat(dir.path(), b"f.txt", oid, MODE_REGULAR).unwrap();
5431        assert_eq!(entry.path, b"f.txt");
5432        assert!(entry.size > 0);
5433    }
5434
5435    #[test]
5436    fn write_to_path_honors_skip_hash_flag() {
5437        let dir = TempDir::new().unwrap();
5438        let path = dir.path().join("index");
5439        let mut idx = Index::new();
5440        idx.add_or_replace(make_entry("x"));
5441        idx.write_to_path(&path, true).unwrap();
5442        let bytes = fs::read(&path).unwrap();
5443        assert!(bytes.ends_with(&[0u8; 20]));
5444    }
5445
5446    #[test]
5447    fn index_lock_acquire_blocks_second_holder() {
5448        let dir = TempDir::new().unwrap();
5449        let path = dir.path().join("index");
5450        let mut idx = Index::new();
5451        idx.add_or_replace(make_entry("tracked.txt"));
5452        let lock = IndexLock::acquire(&path, None).unwrap();
5453        assert!(matches!(
5454            IndexLock::acquire(&path, None),
5455            Err(Error::LockHeld { .. })
5456        ));
5457        drop(lock);
5458        idx.write(&path).unwrap(); // idx is mut in each test scope
5459        assert_eq!(Index::load(&path).unwrap().entries.len(), 1);
5460    }
5461
5462    #[test]
5463    fn held_index_lock_is_a_typed_error_naming_the_holder() {
5464        let dir = TempDir::new().unwrap();
5465        let path = dir.path().join("index");
5466        let lock_path = dir.path().join("index.lock");
5467        fs::write(&lock_path, b"foreign").unwrap();
5468        fs::write(
5469            dir.path().join("index~pid.lock"),
5470            format!("pid {}\n", std::process::id()),
5471        )
5472        .unwrap();
5473
5474        let Err(acquire_err) = IndexLock::acquire(&path, None) else {
5475            panic!("lock must be refused");
5476        };
5477        let write_err = Index::new().write(&path).unwrap_err();
5478        for err in [acquire_err, write_err] {
5479            match &err {
5480                Error::LockHeld { path: p, holder } => {
5481                    assert_eq!(p, &lock_path);
5482                    assert_eq!(
5483                        *holder,
5484                        Some(crate::lockfile::LockHolder {
5485                            pid: u64::from(std::process::id()),
5486                            running: true,
5487                        })
5488                    );
5489                }
5490                other => panic!("expected LockHeld, got {other:?}"),
5491            }
5492            assert!(
5493                err.to_string().contains(&lock_path.display().to_string()),
5494                "{err}"
5495            );
5496        }
5497        assert_eq!(fs::read(&lock_path).unwrap(), b"foreign");
5498        assert!(!path.exists());
5499    }
5500
5501    #[test]
5502    fn write_with_config_and_parallel_policy() {
5503        let dir = TempDir::new().unwrap();
5504        let path = dir.path().join("index");
5505        let mut idx = Index::new();
5506        idx.add_or_replace(make_entry("p"));
5507        let cfg = crate::config::ConfigSet::new();
5508        idx.write_with_config_and_policy(&path, &cfg, crate::parallel::ThreadPolicy::default())
5509            .unwrap();
5510        assert!(path.is_file());
5511    }
5512
5513    #[test]
5514    fn fsmonitor_sync_and_clear_helpers() {
5515        let mut idx = Index::new();
5516        idx.add_or_replace(make_entry("a"));
5517        idx.set_fsmonitor_last_update(Some("token".to_owned()));
5518        idx.sync_fsmonitor_dirty_bitmap();
5519        if let Some(e) = idx.get_mut(b"a", 0) {
5520            e.set_fsmonitor_valid(false);
5521        }
5522        idx.fsmonitor_validity_changed();
5523        idx.clear_fsmonitor_extension();
5524        assert!(idx.fsmonitor_last_update.is_none());
5525    }
5526
5527    #[test]
5528    fn intent_to_add_entry_forces_empty_blob_on_write_tree_path() {
5529        let dir = TempDir::new().unwrap();
5530        let odb = crate::odb::Odb::new(dir.path());
5531        let mut idx = Index::new();
5532        let mut e = make_entry("ita.txt");
5533        e.set_intent_to_add(true);
5534        idx.add_or_replace(e);
5535        crate::write_tree::write_tree_from_index(&odb, &idx, "").unwrap();
5536    }
5537
5538    #[test]
5539    fn optional_load_is_empty_only_when_the_index_is_missing() {
5540        let dir = TempDir::new().unwrap();
5541        let odb = crate::odb::Odb::new(dir.path());
5542        let path = dir.path().join("index");
5543        let missing = Index::load_expand_sparse_optional(&path, &odb).unwrap();
5544        assert!(missing.entries.is_empty());
5545
5546        for len in [0, 12, 31] {
5547            std::fs::write(&path, b"DIRC\0\0\0\x02\0\0\0\0".repeat(3)[..len].to_vec()).unwrap();
5548            assert!(
5549                matches!(
5550                    Index::load_expand_sparse_optional(&path, &odb),
5551                    Err(Error::IndexError(_))
5552                ),
5553                "len={len}"
5554            );
5555        }
5556    }
5557
5558    fn naive_merge_sorted(kept: &[IndexEntry], insert: &[IndexEntry]) -> Vec<IndexEntry> {
5559        let mut out: Vec<IndexEntry> = kept.iter().chain(insert.iter()).cloned().collect();
5560        out.sort_by(index_entry_path_stage_cmp);
5561        let mut deduped = Vec::with_capacity(out.len());
5562        for entry in out {
5563            if deduped
5564                .last()
5565                .is_some_and(|last| index_entry_path_stage_cmp(last, &entry).is_eq())
5566            {
5567                *deduped.last_mut().unwrap() = entry;
5568            } else {
5569                deduped.push(entry);
5570            }
5571        }
5572        deduped
5573    }
5574
5575    #[test]
5576    fn merge_sorted_index_halves_matches_naive_merge() {
5577        let paths = [
5578            ".gitattributes",
5579            "a.txt",
5580            "b.txt",
5581            "f.txt",
5582            "z.txt",
5583            "dir/nested.txt",
5584        ];
5585        let mut kept = Vec::new();
5586        let mut insert = Vec::new();
5587        for (i, path) in paths.iter().enumerate() {
5588            let mut e = make_entry(path);
5589            e.oid = ObjectId::from_bytes(&[i as u8; 20]).unwrap();
5590            kept.push(e);
5591        }
5592        for (i, path) in paths.iter().enumerate() {
5593            if i % 2 == 0 {
5594                continue;
5595            }
5596            let mut e = make_entry(path);
5597            e.oid = ObjectId::from_bytes(&[(i + 100) as u8; 20]).unwrap();
5598            insert.push(e);
5599        }
5600        kept.sort_by(index_entry_path_stage_cmp);
5601        insert.sort_by(index_entry_path_stage_cmp);
5602
5603        let expected = naive_merge_sorted(&kept, &insert);
5604        let merged = merge_sorted_index_entries(kept, insert);
5605        assert_eq!(merged, expected, "fixed scenario merge mismatch");
5606
5607        for seed in 0u64..200 {
5608            let mut kept = Vec::new();
5609            let mut insert = Vec::new();
5610            for n in 0..((seed % 7) + 1) {
5611                let path = format!("p/{seed}/{n}.txt");
5612                let mut e = make_entry(&path);
5613                e.oid = ObjectId::from_bytes(&[(seed as u8).wrapping_add(n as u8); 20]).unwrap();
5614                kept.push(e);
5615            }
5616            for n in 0..((seed % 5) + 1) {
5617                let path = format!("p/{seed}/{n}.txt");
5618                let mut e = make_entry(&path);
5619                e.oid = ObjectId::from_bytes(
5620                    &[(seed as u8).wrapping_add(n as u8).wrapping_add(50); 20],
5621                )
5622                .unwrap();
5623                insert.push(e);
5624            }
5625            kept.sort_by(index_entry_path_stage_cmp);
5626            insert.sort_by(index_entry_path_stage_cmp);
5627            let expected = naive_merge_sorted(&kept, &insert);
5628            let merged = merge_sorted_index_entries(kept, insert);
5629            assert_eq!(merged, expected, "seed {seed} merge mismatch");
5630        }
5631    }
5632}