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