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