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        if let Some(lock) = existing_lock {
1676            if lock.index_path != path {
1677                return Err(Error::Message(
1678                    "index lock path does not match write target".into(),
1679                ));
1680            }
1681            self.stream_index_file(&mut lock.file, skip_hash, config, policy)?;
1682            lock.reopen_after_write()?;
1683            if commit {
1684                lock.commit_replace_index()?;
1685            }
1686            return Ok(());
1687        }
1688
1689        let tmp_path = path.with_extension("lock");
1690        let pid_path = pid_path_for_lock(&tmp_path);
1691        let lockfile_pid_enabled = grit_lockfile_pid_enabled(config, path.parent());
1692
1693        use crate::lockfile::{LockFile, LockWait};
1694        let wait = LockWait::files_ref_lock(config);
1695        let mut lock_file = LockFile::acquire(&tmp_path, wait).map_err(Error::from)?;
1696        if lockfile_pid_enabled {
1697            if let Err(e) = lock_file
1698                .write_holder_pid()
1699                .map_err(crate::error::Error::from)
1700            {
1701                lock_file.rollback();
1702                return Err(e);
1703            }
1704        }
1705        let wrote_pid_file = lockfile_pid_enabled;
1706        let (tmp_path, mut lock_file) = lock_file.into_open_file()?;
1707
1708        if let Err(e) = self.stream_index_file(&mut lock_file, skip_hash, config, policy) {
1709            drop(lock_file);
1710            let _ = fs::remove_file(&tmp_path);
1711            if wrote_pid_file {
1712                let _ = fs::remove_file(&pid_path);
1713            }
1714            return Err(e);
1715        }
1716        drop(lock_file);
1717
1718        if let Err(e) = crate::atomic_replace::replace_file(&tmp_path, path) {
1719            let _ = fs::remove_file(&tmp_path);
1720            if wrote_pid_file {
1721                let _ = fs::remove_file(&pid_path);
1722            }
1723            return Err(Error::Io(e));
1724        }
1725        if wrote_pid_file {
1726            let _ = fs::remove_file(&pid_path);
1727        }
1728        Ok(())
1729    }
1730
1731    /// Serialize the index into `file` followed by its trailing checksum (zeros when `skip_hash`).
1732    ///
1733    /// The body is streamed through a buffer of at most [`INDEX_WRITE_CHUNK`] bytes (plus one
1734    /// entry or extension), so writing a 100k-entry index does not hold a second full copy of it
1735    /// in memory next to the parsed entries.
1736    fn stream_index_file(
1737        &mut self,
1738        file: &mut fs::File,
1739        skip_hash: bool,
1740        config: Option<&ConfigSet>,
1741        policy: crate::parallel::ThreadPolicy,
1742    ) -> Result<()> {
1743        self.ensure_canonical_order();
1744        let mut hasher = (!skip_hash).then(|| self.hash_algo.checksum_hasher());
1745        let mut sink = |chunk: &[u8]| -> io::Result<()> {
1746            if let Some(hasher) = hasher.as_mut() {
1747                hasher.update(chunk);
1748            }
1749            file.write_all(chunk)
1750        };
1751        let mut body = IndexBodyOut {
1752            buf: Vec::new(),
1753            drained: 0,
1754            sink: Some(&mut sink),
1755        };
1756        self.serialize_body(&mut body, config, policy)?;
1757        let checksum = match hasher {
1758            Some(hasher) => hasher.finalize(),
1759            None => vec![0u8; self.hash_algo.len()],
1760        };
1761        file.write_all(&checksum)?;
1762        Ok(())
1763    }
1764
1765    #[cfg(test)]
1766    /// Serialise the index body (without trailing checksum) into `out`.
1767    ///
1768    /// Callers must have sorted entries when using format 4 (path compression depends on order).
1769    pub(crate) fn serialize_into(&self, out: &mut Vec<u8>) -> Result<()> {
1770        let mut body = IndexBodyOut {
1771            buf: std::mem::take(out),
1772            drained: 0,
1773            sink: None,
1774        };
1775        let result = self.serialize_body(&mut body, None, crate::parallel::ThreadPolicy::default());
1776        *out = body.buf;
1777        result
1778    }
1779
1780    /// Serialise the index body (header, entries, extensions; no trailing checksum) into `out`.
1781    fn serialize_body(
1782        &self,
1783        out: &mut IndexBodyOut<'_>,
1784        config: Option<&ConfigSet>,
1785        policy: crate::parallel::ThreadPolicy,
1786    ) -> Result<()> {
1787        use crate::index_eoie_ieot::{
1788            entries_per_ieot_block, ieot_block_count_for_write, record_end_of_index_entries,
1789            write_ieot_payload, IeotEntry, IeotTable, INDEX_EXT_EOIE, INDEX_EXT_IEOT,
1790        };
1791        let has_extended_flags = self.entries.iter().any(|e| {
1792            e.flags_extended
1793                .is_some_and(|fe| IndexEntry::disk_flags_extended(fe) != 0)
1794        });
1795        let write_version = if self.version >= 4 {
1796            4
1797        } else if has_extended_flags {
1798            3
1799        } else if self.version >= 3 {
1800            2
1801        } else {
1802            self.version
1803        };
1804        let estimate = self.estimate_body_capacity(write_version, config);
1805        out.buf.reserve(if out.sink.is_some() {
1806            estimate.min(INDEX_WRITE_CHUNK + INDEX_WRITE_CHUNK_SLACK)
1807        } else {
1808            estimate
1809        });
1810        // Header
1811        out.buf.extend_from_slice(b"DIRC");
1812        out.buf.extend_from_slice(&write_version.to_be_bytes());
1813        out.buf
1814            .extend_from_slice(&(self.entries.len() as u32).to_be_bytes());
1815
1816        let ieot_blocks = ieot_block_count_for_write(policy, config, self.entries.len());
1817        let ieot_stride = ieot_blocks.map(|b| entries_per_ieot_block(self.entries.len(), b));
1818        let mut ieot_table = IeotTable {
1819            entries: Vec::new(),
1820        };
1821        let mut nr_in_block = 0u32;
1822        let mut block_start_offset = out.position();
1823
1824        if write_version == 4 {
1825            let mut previous_path: Vec<u8> = Vec::new();
1826            for (i, entry) in self.entries.iter().enumerate() {
1827                if let Some(stride) = ieot_stride {
1828                    if i > 0 && i % stride == 0 {
1829                        ieot_table.entries.push(IeotEntry {
1830                            offset: block_start_offset as u32,
1831                            nr: nr_in_block,
1832                        });
1833                        nr_in_block = 0;
1834                        // Git `do_write_index`: invalidate first byte so the next entry
1835                        // encodes with no common prefix, without shrinking `previous_path`.
1836                        if !previous_path.is_empty() {
1837                            previous_path[0] = 0;
1838                        }
1839                        block_start_offset = out.position();
1840                    }
1841                }
1842                serialize_entry_v4(entry, &mut previous_path, &mut out.buf);
1843                nr_in_block += 1;
1844                out.drain_if_full()?;
1845            }
1846        } else {
1847            for (i, entry) in self.entries.iter().enumerate() {
1848                if let Some(stride) = ieot_stride {
1849                    if i > 0 && i % stride == 0 {
1850                        ieot_table.entries.push(IeotEntry {
1851                            offset: block_start_offset as u32,
1852                            nr: nr_in_block,
1853                        });
1854                        nr_in_block = 0;
1855                        block_start_offset = out.position();
1856                    }
1857                }
1858                serialize_entry(entry, write_version, &mut out.buf);
1859                nr_in_block += 1;
1860                out.drain_if_full()?;
1861            }
1862        }
1863        if ieot_blocks.is_some() && nr_in_block > 0 {
1864            ieot_table.entries.push(IeotEntry {
1865                offset: block_start_offset as u32,
1866                nr: nr_in_block,
1867            });
1868        }
1869
1870        let extension_offset = out.position();
1871        let write_eoie = record_end_of_index_entries(config);
1872        let mut eoie_header_bytes: Option<Vec<u8>> = write_eoie.then(Vec::new);
1873
1874        if !ieot_table.entries.is_empty() {
1875            let mut payload = Vec::new();
1876            write_ieot_payload(&ieot_table, &mut payload);
1877            append_index_extension(
1878                &mut out.buf,
1879                &mut eoie_header_bytes,
1880                INDEX_EXT_IEOT,
1881                &payload,
1882            );
1883        }
1884        if self.sparse_directories {
1885            append_index_extension(
1886                &mut out.buf,
1887                &mut eoie_header_bytes,
1888                INDEX_EXT_SPARSE_DIRECTORIES,
1889                &[],
1890            );
1891        }
1892        if let Some(uc) = &self.untracked_cache {
1893            let payload = untracked_cache::write_untracked_extension(uc);
1894            append_index_extension(
1895                &mut out.buf,
1896                &mut eoie_header_bytes,
1897                INDEX_EXT_UNTRACKED,
1898                &payload,
1899            );
1900        }
1901        self.append_fsmonitor_extension(&mut out.buf, &mut eoie_header_bytes)?;
1902        if let Some(ru) = &self.resolve_undo {
1903            let payload = write_resolve_undo_payload(ru);
1904            if !payload.is_empty() {
1905                append_index_extension(
1906                    &mut out.buf,
1907                    &mut eoie_header_bytes,
1908                    INDEX_EXT_RESOLVE_UNDO,
1909                    &payload,
1910                );
1911            }
1912        }
1913        if let Some(sl) = &self.split_link {
1914            use crate::ewah_bitmap::EwahBitmap;
1915            let del = sl
1916                .delete_bitmap
1917                .as_ref()
1918                .cloned()
1919                .unwrap_or_else(EwahBitmap::new);
1920            let rep = sl
1921                .replace_bitmap
1922                .as_ref()
1923                .cloned()
1924                .unwrap_or_else(EwahBitmap::new);
1925            let payload =
1926                crate::split_index::serialize_link_extension_payload(&sl.base_oid, &del, &rep);
1927            append_index_extension(
1928                &mut out.buf,
1929                &mut eoie_header_bytes,
1930                INDEX_EXT_LINK,
1931                &payload,
1932            );
1933        }
1934        if let Some(cache_tree) = &self.cache_tree {
1935            let mut payload = Vec::new();
1936            serialize_cache_tree_node(cache_tree, &mut payload);
1937            append_index_extension(
1938                &mut out.buf,
1939                &mut eoie_header_bytes,
1940                INDEX_EXT_CACHE_TREE,
1941                &payload,
1942            );
1943        }
1944        if write_eoie {
1945            let mut eoie_payload = Vec::new();
1946            eoie_payload.extend_from_slice(&(extension_offset as u32).to_be_bytes());
1947            let headers = eoie_header_bytes.as_deref().unwrap_or(&[]);
1948            eoie_payload.extend_from_slice(&self.hash_algo.checksum_digest(headers));
1949            append_index_extension(&mut out.buf, &mut None, INDEX_EXT_EOIE, &eoie_payload);
1950        }
1951        out.drain()?;
1952        Ok(())
1953    }
1954
1955    fn append_fsmonitor_extension(
1956        &self,
1957        out: &mut Vec<u8>,
1958        eoie_headers: &mut Option<Vec<u8>>,
1959    ) -> Result<()> {
1960        if let Some(raw) = &self.fsmonitor_raw_extension {
1961            if self.fsmonitor_raw_still_valid() {
1962                append_index_extension(out, eoie_headers, INDEX_EXT_FSMONITOR, raw);
1963                return Ok(());
1964            }
1965        }
1966        let Some(token) = &self.fsmonitor_last_update else {
1967            return Ok(());
1968        };
1969        let dirty = self
1970            .fsmonitor_dirty
1971            .as_ref()
1972            .cloned()
1973            .unwrap_or_else(|| self.build_fsmonitor_dirty_from_entries());
1974        if dirty.bit_size > self.fsmonitor_active_entry_count() {
1975            return Ok(());
1976        }
1977        let mut payload = Vec::new();
1978        payload.extend_from_slice(&FSMONITOR_EXT_VERSION2.to_be_bytes());
1979        payload.extend_from_slice(token.as_bytes());
1980        payload.push(0);
1981        let ewah_size_fixup = payload.len();
1982        payload.extend_from_slice(&0u32.to_be_bytes());
1983        let ewah_start = payload.len();
1984        dirty.serialize(&mut payload);
1985        let ewah_size = (payload.len() - ewah_start) as u32;
1986        payload[ewah_size_fixup..ewah_size_fixup + 4].copy_from_slice(&ewah_size.to_be_bytes());
1987        append_index_extension(out, eoie_headers, INDEX_EXT_FSMONITOR, &payload);
1988        Ok(())
1989    }
1990
1991    /// Number of index rows counted by the fsmonitor dirty bitmap (Git `cache_nr` minus `CE_REMOVE`).
1992    fn fsmonitor_active_entry_count(&self) -> usize {
1993        self.entries.len()
1994    }
1995
1996    /// Build the fsmonitor dirty EWAH from in-memory `CE_FSMONITOR_VALID` (`fill_fsmonitor_bitmap`).
1997    fn build_fsmonitor_dirty_from_entries(&self) -> crate::ewah_bitmap::EwahBitmap {
1998        use crate::ewah_bitmap::EwahBitmap;
1999        let mut dirty = EwahBitmap::new();
2000        if self.fsmonitor_last_update.is_none() {
2001            return dirty;
2002        }
2003        #[cfg(test)]
2004        tests::fsmonitor_rebuild_counter::note_build();
2005        for (i, entry) in self.entries.iter().enumerate() {
2006            if !entry.fsmonitor_valid() {
2007                dirty.set_bit_extend(i);
2008            }
2009        }
2010        dirty
2011    }
2012
2013    /// Whether the on-disk `FSMN` payload still matches token and fsmonitor-valid state.
2014    fn fsmonitor_raw_still_valid(&self) -> bool {
2015        use crate::ewah_bitmap::ewah_bitmaps_equal;
2016        let Some(raw) = self.fsmonitor_raw_extension.as_ref() else {
2017            return false;
2018        };
2019        let Some(token) = self.fsmonitor_last_update.as_deref() else {
2020            return false;
2021        };
2022        let Ok((raw_token, raw_dirty)) = parse_fsmonitor_extension(raw) else {
2023            return false;
2024        };
2025        if raw_token != token {
2026            return false;
2027        }
2028        let current = self.build_fsmonitor_dirty_from_entries();
2029        if !ewah_bitmaps_equal(&raw_dirty, &current) {
2030            return false;
2031        }
2032        current.bit_size <= self.fsmonitor_active_entry_count()
2033    }
2034
2035    /// Rebuild the in-memory fsmonitor dirty bitmap from `CE_FSMONITOR_VALID` (`fill_fsmonitor_bitmap`).
2036    fn rebuild_fsmonitor_dirty_from_entries(&mut self) {
2037        if self.fsmonitor_last_update.is_none() {
2038            self.fsmonitor_dirty = None;
2039            return;
2040        }
2041        self.fsmonitor_dirty = Some(self.build_fsmonitor_dirty_from_entries());
2042    }
2043
2044    /// Drop cached and on-disk fsmonitor snapshots; rebuild on the next read or write.
2045    fn invalidate_fsmonitor_bitmap(&mut self) {
2046        if self.fsmonitor_last_update.is_none() {
2047            return;
2048        }
2049        self.fsmonitor_raw_extension = None;
2050        self.fsmonitor_dirty = None;
2051    }
2052
2053    /// Update the fsmonitor token; clears any stale on-disk snapshot when the token changes.
2054    pub fn set_fsmonitor_last_update(&mut self, token: Option<String>) {
2055        if self.fsmonitor_last_update.as_deref() != token.as_deref() {
2056            self.fsmonitor_raw_extension = None;
2057        }
2058        self.fsmonitor_last_update = token;
2059        self.rebuild_fsmonitor_dirty_from_entries();
2060    }
2061
2062    /// Remove fsmonitor metadata from the index (extension and in-memory valid bits are cleared on write).
2063    pub fn clear_fsmonitor_extension(&mut self) {
2064        self.fsmonitor_last_update = None;
2065        self.fsmonitor_dirty = None;
2066        self.fsmonitor_raw_extension = None;
2067    }
2068
2069    /// Refresh the in-memory fsmonitor EWAH from entry valid bits (does not invalidate raw).
2070    pub fn sync_fsmonitor_dirty_bitmap(&mut self) {
2071        self.rebuild_fsmonitor_dirty_from_entries();
2072    }
2073
2074    /// Call after mutating entry fsmonitor-valid bits without changing index membership.
2075    pub fn fsmonitor_validity_changed(&mut self) {
2076        self.invalidate_fsmonitor_bitmap();
2077    }
2078
2079    /// Clear fsmonitor-valid on stage-0 entries without disturbing sort order.
2080    pub(crate) fn invalidate_all_fsmonitor_valid(&mut self) {
2081        for entry in &mut self.entries {
2082            if entry.stage() == 0 {
2083                entry.set_fsmonitor_valid(false);
2084            }
2085        }
2086        self.fsmonitor_validity_changed();
2087    }
2088
2089    /// Invalidate fsmonitor-valid bits for one hook-reported path (Git `fsmonitor_refresh_callback`).
2090    pub(crate) fn invalidate_fsmonitor_for_path(&mut self, path: &str, ignorecase: bool) {
2091        if let Some(uc) = self.untracked_cache.as_mut() {
2092            crate::untracked_cache::invalidate_path(uc, path);
2093        }
2094        let mut invalidated = if path.ends_with('/') {
2095            self.invalidate_fsmonitor_directory_cone(path.trim_end_matches('/'), ignorecase)
2096        } else {
2097            self.invalidate_fsmonitor_unqualified_path(path, ignorecase)
2098        };
2099        if !invalidated && ignorecase {
2100            invalidated = self.invalidate_fsmonitor_icase_fallback(path);
2101        }
2102        if invalidated {
2103            self.fsmonitor_validity_changed();
2104        }
2105    }
2106
2107    fn invalidate_fsmonitor_unqualified_path(&mut self, path: &str, ignorecase: bool) -> bool {
2108        if let Some(entry) = self.get_mut(path.as_bytes(), 0) {
2109            entry.set_fsmonitor_valid(false);
2110            return true;
2111        }
2112        if self.has_stage0_descendant_under(path) {
2113            return self.invalidate_fsmonitor_directory_cone(path, ignorecase);
2114        }
2115        false
2116    }
2117
2118    fn invalidate_fsmonitor_directory_cone(&mut self, prefix: &str, ignorecase: bool) -> bool {
2119        if prefix.is_empty() {
2120            self.invalidate_all_fsmonitor_valid();
2121            return true;
2122        }
2123        let prefix_slash = format!("{prefix}/");
2124        let mut any = false;
2125        for entry in &mut self.entries {
2126            if entry.stage() != 0 {
2127                continue;
2128            }
2129            let Some(rel) = std::str::from_utf8(&entry.path).ok() else {
2130                continue;
2131            };
2132            if Self::fsmonitor_path_under_prefix(rel, prefix, &prefix_slash, ignorecase) {
2133                entry.set_fsmonitor_valid(false);
2134                any = true;
2135            }
2136        }
2137        any
2138    }
2139
2140    fn invalidate_fsmonitor_icase_fallback(&mut self, path: &str) -> bool {
2141        let is_dir = path.ends_with('/');
2142        let prefix = path.trim_end_matches('/');
2143        let prefix_slash = format!("{prefix}/");
2144        let mut any = false;
2145        for entry in &mut self.entries {
2146            if entry.stage() != 0 {
2147                continue;
2148            }
2149            let Some(rel) = std::str::from_utf8(&entry.path).ok() else {
2150                continue;
2151            };
2152            let matches = if is_dir {
2153                Self::fsmonitor_path_under_prefix(rel, prefix, &prefix_slash, true)
2154            } else {
2155                rel.eq_ignore_ascii_case(prefix)
2156            };
2157            if matches {
2158                entry.set_fsmonitor_valid(false);
2159                any = true;
2160            }
2161        }
2162        any
2163    }
2164
2165    fn fsmonitor_path_under_prefix(
2166        rel: &str,
2167        prefix: &str,
2168        prefix_slash: &str,
2169        ignorecase: bool,
2170    ) -> bool {
2171        if ignorecase {
2172            rel.eq_ignore_ascii_case(prefix)
2173                || (rel.len() > prefix.len()
2174                    && rel.as_bytes().get(prefix.len()) == Some(&b'/')
2175                    && rel[..prefix.len()].eq_ignore_ascii_case(prefix))
2176        } else {
2177            rel == prefix || rel.starts_with(prefix_slash)
2178        }
2179    }
2180
2181    /// Drop byte-for-byte `FSMN` preservation after index entries change.
2182    fn touch_fsmonitor_after_entry_change(&mut self) {
2183        self.invalidate_fsmonitor_bitmap();
2184    }
2185
2186    /// Read-only view of index entries.
2187    #[must_use]
2188    pub fn entries(&self) -> &[IndexEntry] {
2189        &self.entries
2190    }
2191
2192    /// Mutable entry list; marks the index unsorted until [`Self::sort`].
2193    pub fn entries_mut(&mut self) -> &mut Vec<IndexEntry> {
2194        self.entries_sorted = false;
2195        &mut self.entries
2196    }
2197
2198    /// Mark `entries` as not in canonical `(path, stage)` order after direct vector mutation.
2199    pub fn mark_entries_unsorted(&mut self) {
2200        self.entries_sorted = false;
2201    }
2202
2203    /// Append an entry without maintaining sort order (used while building a merge index).
2204    pub(crate) fn push_entry_unsorted(&mut self, entry: IndexEntry) {
2205        self.entries.push(entry);
2206        self.entries_sorted = false;
2207    }
2208
2209    /// Whether [`Self::entries`] are in Git canonical `(path, stage)` order (full scan).
2210    #[must_use]
2211    pub fn entries_are_sorted(&self) -> bool {
2212        self.entries
2213            .windows(2)
2214            .all(|pair| (&pair[0].path, pair[0].stage()) <= (&pair[1].path, pair[1].stage()))
2215    }
2216
2217    /// Compare two index paths (counts comparisons in unit tests).
2218    fn cmp_paths(a: &[u8], b: &[u8]) -> std::cmp::Ordering {
2219        record_path_cmp_for_tests();
2220        a.cmp(b)
2221    }
2222
2223    fn entry_matches(entry: &IndexEntry, path: &[u8], stage: u8) -> bool {
2224        entry.path == path && entry.stage() == stage
2225    }
2226
2227    /// Index of `(path, stage)`; binary search when sorted, linear scan when not.
2228    pub(crate) fn find_entry_pos(&self, path: &[u8], stage: u8) -> Option<usize> {
2229        if self.entries_sorted {
2230            return match self.entries.binary_search_by(|e| {
2231                Self::cmp_paths(e.path.as_slice(), path).then_with(|| e.stage().cmp(&stage))
2232            }) {
2233                Ok(pos) if Self::entry_matches(&self.entries[pos], path, stage) => Some(pos),
2234                _ => None,
2235            };
2236        }
2237        self.entries
2238            .iter()
2239            .position(|e| Self::entry_matches(e, path, stage))
2240    }
2241
2242    /// Inclusive start and exclusive end of all entries sharing `path` (sorted indexes only).
2243    fn path_run_range_sorted(&self, path: &[u8]) -> (usize, usize) {
2244        debug_assert!(self.entries_sorted);
2245        let start = self
2246            .entries
2247            .partition_point(|e| Self::cmp_paths(e.path.as_slice(), path).is_lt());
2248        let end = start
2249            + self.entries[start..]
2250                .iter()
2251                .take_while(|e| e.path == path)
2252                .count();
2253        (start, end)
2254    }
2255
2256    /// Whether any stage-0 entry lies strictly under `rel_dir/` (or any stage-0 entry exists when
2257    /// `rel_dir` is empty).
2258    #[must_use]
2259    pub fn has_stage0_descendant_under(&self, rel_dir: &str) -> bool {
2260        if rel_dir.is_empty() {
2261            return self.entries.iter().any(|e| e.stage() == 0);
2262        }
2263        if self.entries_sorted {
2264            let prefix = rel_dir.as_bytes();
2265            let start = self.descendant_run_start_sorted(prefix);
2266            return self.entries[start..]
2267                .iter()
2268                .take_while(|e| path_is_under_dir(&e.path, prefix))
2269                .any(|e| e.stage() == 0);
2270        }
2271        let prefix = format!("{rel_dir}/");
2272        self.entries
2273            .iter()
2274            .any(|e| e.stage() == 0 && e.path.starts_with(prefix.as_bytes()))
2275    }
2276
2277    /// Entries strictly under `dir/` (raw path bytes; every entry when `dir` is empty), in
2278    /// index order.
2279    ///
2280    /// Returns `None` when the entries are not known to be sorted.
2281    pub(crate) fn sorted_entries_under(&self, dir: &[u8]) -> Option<&[IndexEntry]> {
2282        if !self.entries_sorted {
2283            return None;
2284        }
2285        if dir.is_empty() {
2286            return Some(&self.entries);
2287        }
2288        let (start, end) = self.descendant_run_range_sorted(dir);
2289        Some(&self.entries[start..end])
2290    }
2291
2292    /// Whether some entry (at any stage) has exactly `path`; `None` when the entries are not
2293    /// known to be sorted.
2294    pub(crate) fn sorted_contains_path(&self, path: &[u8]) -> Option<bool> {
2295        self.entries_sorted.then(|| {
2296            self.entries
2297                .binary_search_by(|e| Self::cmp_paths(e.path.as_slice(), path))
2298                .is_ok()
2299        })
2300    }
2301
2302    /// Inclusive start and exclusive end of entries strictly under `path/` (sorted indexes only).
2303    fn descendant_run_range_sorted(&self, path: &[u8]) -> (usize, usize) {
2304        let start = self.descendant_run_start_sorted(path);
2305        let len = self.entries[start..]
2306            .iter()
2307            .take_while(|e| path_is_under_dir(&e.path, path))
2308            .count();
2309        (start, start + len)
2310    }
2311
2312    /// Position of the first entry that sorts at or after `path/` (sorted indexes only).
2313    fn descendant_run_start_sorted(&self, path: &[u8]) -> usize {
2314        debug_assert!(self.entries_sorted);
2315        let mut prefix_slash = Vec::with_capacity(path.len() + 1);
2316        prefix_slash.extend_from_slice(path);
2317        prefix_slash.push(b'/');
2318        self.entries.partition_point(|e| {
2319            Self::cmp_paths(e.path.as_slice(), prefix_slash.as_slice()).is_lt()
2320        })
2321    }
2322
2323    fn record_resolve_undo_for_path(&mut self, path: &[u8]) {
2324        for e in &self.entries {
2325            if e.path == path && e.stage() != 0 {
2326                resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, e);
2327            }
2328        }
2329    }
2330
2331    fn record_resolve_undo_for_run(&mut self, start: usize, end: usize) {
2332        for e in &self.entries[start..end] {
2333            if e.stage() != 0 {
2334                resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, e);
2335            }
2336        }
2337    }
2338
2339    /// Drop derived `TREE` / untracked-cache extensions before a large checkout rewrite.
2340    pub(crate) fn drop_derived_index_extensions(&mut self) {
2341        self.cache_tree = None;
2342        self.cache_tree_root = None;
2343        self.untracked_cache = None;
2344    }
2345
2346    /// Partially invalidate cache-tree nodes for a bulk checkout without dropping the whole extension.
2347    pub(crate) fn invalidate_cache_tree_for_bulk_checkout<'a>(
2348        &mut self,
2349        dir_prefixes: &BTreeSet<String>,
2350        paths: impl IntoIterator<Item = &'a [u8]>,
2351        replacements: &[IndexEntry],
2352    ) {
2353        if self.cache_tree.is_none() {
2354            return;
2355        }
2356        for dir in dir_prefixes {
2357            self.invalidate_cache_tree_for_path(dir.as_bytes());
2358        }
2359        for path in paths {
2360            self.invalidate_cache_tree_for_path(path);
2361        }
2362        for entry in replacements {
2363            self.invalidate_cache_tree_for_path(&entry.path);
2364        }
2365    }
2366
2367    fn invalidate_after_path_removal(&mut self, path: &[u8]) {
2368        if let Ok(p) = std::str::from_utf8(path) {
2369            self.invalidate_untracked_cache_for_path(p);
2370        }
2371        self.invalidate_cache_tree_for_path(path);
2372    }
2373
2374    /// One index compaction pass: drop entries under `dir_prefixes`, drop exact `paths`, merge `replacements`.
2375    pub(crate) fn remove_prefixes_paths_and_insert<'a>(
2376        &mut self,
2377        dir_prefixes: &BTreeSet<String>,
2378        paths: impl IntoIterator<Item = &'a [u8]>,
2379        replacements: impl IntoIterator<Item = IndexEntry>,
2380    ) {
2381        let mut replacements: Vec<IndexEntry> = replacements.into_iter().collect();
2382        if !replacements.is_empty() {
2383            replacements
2384                .sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.stage().cmp(&b.stage())));
2385            dedup_sorted_replacement_entries(&mut replacements);
2386        }
2387
2388        let mut paths: Vec<&[u8]> = paths.into_iter().collect();
2389        paths.sort_unstable_by(|a, b| Self::cmp_paths(a, b));
2390        paths.dedup_by(|a, b| a == b);
2391
2392        let mut prefix_bytes: Vec<&[u8]> = dir_prefixes.iter().map(|d| d.as_bytes()).collect();
2393        prefix_bytes.sort_unstable_by(|a, b| Self::cmp_paths(a, b));
2394        prefix_bytes.dedup_by(|a, b| a == b);
2395
2396        if dir_prefixes.is_empty() && paths.is_empty() {
2397            self.merge_replacements_into_sorted_index(replacements);
2398            return;
2399        }
2400
2401        let mut invalidated_paths: Vec<Vec<u8>> = Vec::new();
2402        let mut kept = Vec::with_capacity(self.entries.len());
2403        let skip_per_path_extension_inval = self.defer_untracked_cache_invalidation;
2404
2405        if self.entries_sorted {
2406            let mut path_idx = 0usize;
2407            for entry in self.entries.drain(..) {
2408                let ep = entry.path.as_slice();
2409                while path_idx < paths.len() && Self::cmp_paths(paths[path_idx], ep).is_lt() {
2410                    path_idx += 1;
2411                }
2412                let remove = (path_idx < paths.len()
2413                    && Self::cmp_paths(paths[path_idx], ep).is_eq())
2414                    || Self::entry_under_any_directory_prefix(ep, &prefix_bytes);
2415                if remove {
2416                    if entry.stage() != 0 {
2417                        resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, &entry);
2418                    }
2419                    if !skip_per_path_extension_inval
2420                        && invalidated_paths.last().is_none_or(|p| p.as_slice() != ep)
2421                    {
2422                        invalidated_paths.push(entry.path.clone());
2423                    }
2424                } else {
2425                    kept.push(entry);
2426                }
2427            }
2428            let repl_for_inval: Vec<Vec<u8>> = if skip_per_path_extension_inval {
2429                Vec::new()
2430            } else {
2431                replacements
2432                    .iter()
2433                    .filter(|e| e.stage() == 0)
2434                    .map(|e| e.path.clone())
2435                    .collect()
2436            };
2437            self.entries = merge_sorted_index_entries(kept, replacements);
2438            self.entries_sorted = true;
2439            if skip_per_path_extension_inval {
2440                self.untracked_cache = None;
2441                self.touch_fsmonitor_after_entry_change();
2442                return;
2443            }
2444            const BULK_INDEX_EXTENSION_INVALIDATE_MIN: usize = 64;
2445            let mut invalidations = dir_prefixes.len();
2446            for path in &invalidated_paths {
2447                if dir_prefixes
2448                    .iter()
2449                    .all(|d| !Self::entry_under_directory_prefix(path.as_slice(), d.as_bytes()))
2450                {
2451                    invalidations += 1;
2452                }
2453            }
2454            invalidations += repl_for_inval.len();
2455            if invalidations >= BULK_INDEX_EXTENSION_INVALIDATE_MIN {
2456                self.drop_derived_index_extensions();
2457            } else {
2458                for dir in dir_prefixes {
2459                    self.invalidate_after_path_removal(dir.as_bytes());
2460                }
2461                for path in invalidated_paths {
2462                    if dir_prefixes
2463                        .iter()
2464                        .all(|d| !Self::entry_under_directory_prefix(path.as_slice(), d.as_bytes()))
2465                    {
2466                        self.invalidate_after_path_removal(&path);
2467                    }
2468                }
2469                for path in repl_for_inval {
2470                    if let Ok(p) = std::str::from_utf8(&path) {
2471                        self.invalidate_untracked_cache_for_path(p);
2472                    }
2473                    self.invalidate_cache_tree_for_path(&path);
2474                }
2475            }
2476            self.touch_fsmonitor_after_entry_change();
2477            return;
2478        }
2479        if !dir_prefixes.is_empty() {
2480            self.remove_directory_prefixes(dir_prefixes);
2481        }
2482        self.remove_paths_and_insert(paths, replacements);
2483    }
2484
2485    /// Remove every entry whose path equals `dir` or lies under `dir/` for each `dir` in `dirs`.
2486    pub(crate) fn remove_directory_prefixes(&mut self, dirs: &BTreeSet<String>) {
2487        if dirs.is_empty() {
2488            return;
2489        }
2490        let mut prefixes: Vec<&[u8]> = dirs.iter().map(|d| d.as_bytes()).collect();
2491        prefixes.sort_unstable_by(|a, b| Self::cmp_paths(a, b));
2492        prefixes.dedup_by(|a, b| a == b);
2493
2494        let mut invalidated_paths: Vec<Vec<u8>> = Vec::new();
2495        let mut kept = Vec::with_capacity(self.entries.len());
2496
2497        if self.entries_sorted {
2498            for entry in self.entries.drain(..) {
2499                let ep = entry.path.as_slice();
2500                let remove = Self::entry_under_any_directory_prefix(ep, &prefixes);
2501                if remove {
2502                    if entry.stage() != 0 {
2503                        resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, &entry);
2504                    }
2505                    if invalidated_paths.last().is_none_or(|p| p.as_slice() != ep) {
2506                        invalidated_paths.push(entry.path.clone());
2507                    }
2508                } else {
2509                    kept.push(entry);
2510                }
2511            }
2512            self.entries = kept;
2513        } else {
2514            for entry in self.entries.drain(..) {
2515                let remove =
2516                    Self::entry_under_any_directory_prefix(entry.path.as_slice(), &prefixes);
2517                if remove {
2518                    if entry.stage() != 0 {
2519                        resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, &entry);
2520                    }
2521                    if invalidated_paths
2522                        .last()
2523                        .is_none_or(|p| p.as_slice() != entry.path)
2524                    {
2525                        invalidated_paths.push(entry.path.clone());
2526                    }
2527                } else {
2528                    kept.push(entry);
2529                }
2530            }
2531            self.entries = kept;
2532        }
2533        if invalidated_paths.is_empty() {
2534            return;
2535        }
2536        for dir in dirs {
2537            self.invalidate_after_path_removal(dir.as_bytes());
2538        }
2539        self.touch_fsmonitor_after_entry_change();
2540    }
2541
2542    fn entry_under_directory_prefix(path: &[u8], prefix: &[u8]) -> bool {
2543        if path.len() < prefix.len() {
2544            return false;
2545        }
2546        if path[..prefix.len()] != *prefix {
2547            return false;
2548        }
2549        path.len() == prefix.len() || path[prefix.len()] == b'/'
2550    }
2551
2552    fn entry_under_any_directory_prefix(path: &[u8], prefixes: &[&[u8]]) -> bool {
2553        prefixes
2554            .iter()
2555            .any(|prefix| Self::entry_under_directory_prefix(path, prefix))
2556    }
2557
2558    /// Remove many paths (all merge stages per path) in one `O(N + M log M)` compaction pass.
2559    pub fn remove_paths<'a>(&mut self, paths: impl IntoIterator<Item = &'a [u8]>) {
2560        use std::collections::BTreeSet;
2561
2562        let mut paths: Vec<&[u8]> = paths.into_iter().collect();
2563        if paths.is_empty() {
2564            return;
2565        }
2566        paths.sort_unstable_by(|a, b| Self::cmp_paths(a, b));
2567        paths.dedup_by(|a, b| a == b);
2568
2569        let mut invalidated_paths: Vec<Vec<u8>> = Vec::new();
2570        let mut kept = Vec::with_capacity(self.entries.len());
2571
2572        if self.entries_sorted {
2573            let mut path_idx = 0usize;
2574            for entry in self.entries.drain(..) {
2575                let ep = entry.path.as_slice();
2576                while path_idx < paths.len() && Self::cmp_paths(paths[path_idx], ep).is_lt() {
2577                    path_idx += 1;
2578                }
2579                let remove = path_idx < paths.len() && Self::cmp_paths(paths[path_idx], ep).is_eq();
2580                if remove {
2581                    if entry.stage() != 0 {
2582                        resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, &entry);
2583                    }
2584                    if invalidated_paths.last().is_none_or(|p| p.as_slice() != ep) {
2585                        invalidated_paths.push(entry.path.clone());
2586                    }
2587                } else {
2588                    kept.push(entry);
2589                }
2590            }
2591            self.entries = kept;
2592        } else {
2593            let remove_set: BTreeSet<&[u8]> = paths.into_iter().collect();
2594            for entry in self.entries.drain(..) {
2595                if remove_set.contains(entry.path.as_slice()) {
2596                    if entry.stage() != 0 {
2597                        resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, &entry);
2598                    }
2599                    if invalidated_paths
2600                        .last()
2601                        .is_none_or(|p| p.as_slice() != entry.path.as_slice())
2602                    {
2603                        invalidated_paths.push(entry.path.clone());
2604                    }
2605                } else {
2606                    kept.push(entry);
2607                }
2608            }
2609            self.entries = kept;
2610        }
2611
2612        for path in invalidated_paths {
2613            self.invalidate_after_path_removal(&path);
2614        }
2615        self.touch_fsmonitor_after_entry_change();
2616    }
2617
2618    /// Replace many stage-0 entries in one sorted merge (bulk `git add` restage).
2619    pub(crate) fn replace_stage0_entries_batch(&mut self, replacements: Vec<IndexEntry>) {
2620        self.merge_replacements_into_sorted_index(replacements);
2621    }
2622
2623    /// Record that the worktree file at `path` was just hashed to `oid` (see
2624    /// [`Self::content_verified`]), so the next write does not re-read it for the racy check.
2625    pub(crate) fn mark_content_verified(&mut self, path: &[u8], oid: ObjectId) {
2626        self.content_verified.insert(path.to_vec(), oid);
2627    }
2628
2629    /// Drop every just-hashed mark: the [`Self::content_verified`] map and the per-entry bits.
2630    pub(crate) fn clear_content_verified(&mut self) {
2631        self.content_verified.clear();
2632        for entry in &mut self.entries {
2633            entry.take_content_verified();
2634        }
2635    }
2636
2637    /// Overwrite the stage-0 rows that already hold each replacement's path, in place.
2638    ///
2639    /// Same result and cache invalidation as [`Self::replace_stage0_entries_batch`], without
2640    /// growing `entries` to merge: restaging only changes rows, never their order. Replacements
2641    /// with no stage-0 row at their path (or not at stage 0) go through the sorted merge.
2642    pub(crate) fn overwrite_stage0_entries(&mut self, replacements: Vec<IndexEntry>) {
2643        const BULK_INVALIDATE_MIN: usize = 64;
2644        if replacements.is_empty() {
2645            return;
2646        }
2647        let defer_untracked = self.defer_untracked_cache_invalidation;
2648        let drop_caches = !defer_untracked && replacements.len() >= BULK_INVALIDATE_MIN;
2649        let mut unmatched = Vec::new();
2650        for mut entry in replacements {
2651            let pos = if entry.stage() == 0 {
2652                self.find_entry_pos(&entry.path, 0)
2653            } else {
2654                None
2655            };
2656            let Some(pos) = pos else {
2657                unmatched.push(entry);
2658                continue;
2659            };
2660            if !drop_caches {
2661                if !defer_untracked {
2662                    if let Ok(p) = std::str::from_utf8(&entry.path) {
2663                        self.invalidate_untracked_cache_for_path(p);
2664                    }
2665                }
2666                self.invalidate_cache_tree_for_path(&entry.path);
2667            }
2668            entry.base_index_pos = self.entries[pos].base_index_pos;
2669            self.entries[pos] = entry;
2670        }
2671        if drop_caches {
2672            self.cache_tree = None;
2673            self.cache_tree_root = None;
2674            self.untracked_cache = None;
2675        }
2676        self.touch_fsmonitor_after_entry_change();
2677        if !unmatched.is_empty() {
2678            self.merge_replacements_into_sorted_index(unmatched);
2679        }
2680    }
2681
2682    /// Append sorted `replacements` to a sorted index and merge in one linear pass.
2683    fn merge_sorted_replacements_into_entries(&mut self, mut replacements: Vec<IndexEntry>) {
2684        if replacements.is_empty() {
2685            return;
2686        }
2687        if self.entries.is_empty() {
2688            self.entries = replacements;
2689            return;
2690        }
2691        self.entries.reserve(replacements.len());
2692        let mid = self.entries.len();
2693        self.entries.append(&mut replacements);
2694        merge_sorted_index_adjacent_halves(&mut self.entries, mid);
2695    }
2696
2697    /// Merge sorted `replacements` into a sorted index in one linear pass.
2698    fn merge_replacements_into_sorted_index(&mut self, replacements: Vec<IndexEntry>) {
2699        if replacements.is_empty() {
2700            return;
2701        }
2702        if !self.entries_sorted {
2703            for entry in replacements {
2704                self.add_or_replace(entry);
2705            }
2706            return;
2707        }
2708        const BULK_MERGE_MIN: usize = 64;
2709        if self.defer_untracked_cache_invalidation {
2710            // Campaign-scale tracked restage: merge in bulk but keep UNTR and invalidate only
2711            // touched cache-tree paths (issue #1184).
2712            for entry in replacements.iter().filter(|e| e.stage() == 0) {
2713                self.invalidate_cache_tree_for_path(&entry.path);
2714            }
2715            self.merge_sorted_replacements_into_entries(replacements);
2716            self.touch_fsmonitor_after_entry_change();
2717            return;
2718        }
2719        let repl_for_inval: Vec<Vec<u8>> = replacements
2720            .iter()
2721            .filter(|e| e.stage() == 0)
2722            .map(|e| e.path.clone())
2723            .collect();
2724        self.merge_sorted_replacements_into_entries(replacements);
2725        if repl_for_inval.len() >= BULK_MERGE_MIN {
2726            self.cache_tree = None;
2727            self.cache_tree_root = None;
2728            self.untracked_cache = None;
2729            self.touch_fsmonitor_after_entry_change();
2730            return;
2731        }
2732        for path in repl_for_inval {
2733            if let Ok(p) = std::str::from_utf8(&path) {
2734                self.invalidate_untracked_cache_for_path(p);
2735            }
2736            self.invalidate_cache_tree_for_path(&path);
2737        }
2738        self.touch_fsmonitor_after_entry_change();
2739    }
2740
2741    /// Remove every entry at `paths` (all stages), then merge `replacements` in sorted order.
2742    ///
2743    /// When the index is already sorted, this is one removal scan plus an `O(N + M)` merge,
2744    /// avoiding per-path `Vec::drain` shifts and repeated `insert`/`sort` work.
2745    pub fn remove_paths_and_insert<'a>(
2746        &mut self,
2747        paths: impl IntoIterator<Item = &'a [u8]>,
2748        replacements: impl IntoIterator<Item = IndexEntry>,
2749    ) {
2750        let mut replacements: Vec<IndexEntry> = replacements.into_iter().collect();
2751        if replacements.is_empty() {
2752            self.remove_paths(paths);
2753            return;
2754        }
2755        replacements.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.stage().cmp(&b.stage())));
2756        dedup_sorted_replacement_entries(&mut replacements);
2757
2758        let mut paths: Vec<&[u8]> = paths.into_iter().collect();
2759        if paths.is_empty() {
2760            self.merge_replacements_into_sorted_index(replacements);
2761            return;
2762        }
2763        paths.sort_unstable_by(|a, b| Self::cmp_paths(a, b));
2764        paths.dedup_by(|a, b| a == b);
2765
2766        let mut invalidated_paths: Vec<Vec<u8>> = Vec::new();
2767        let mut kept = Vec::with_capacity(self.entries.len());
2768
2769        if self.entries_sorted {
2770            let mut path_idx = 0usize;
2771            for entry in self.entries.drain(..) {
2772                let ep = entry.path.as_slice();
2773                while path_idx < paths.len() && Self::cmp_paths(paths[path_idx], ep).is_lt() {
2774                    path_idx += 1;
2775                }
2776                let remove = path_idx < paths.len() && Self::cmp_paths(paths[path_idx], ep).is_eq();
2777                if remove {
2778                    if entry.stage() != 0 {
2779                        resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, &entry);
2780                    }
2781                    if invalidated_paths.last().is_none_or(|p| p.as_slice() != ep) {
2782                        invalidated_paths.push(entry.path.clone());
2783                    }
2784                } else {
2785                    kept.push(entry);
2786                }
2787            }
2788            let repl_for_inval = replacements
2789                .iter()
2790                .filter(|e| e.stage() == 0)
2791                .map(|e| e.path.clone())
2792                .collect::<Vec<_>>();
2793            self.entries = merge_sorted_index_entries(kept, replacements);
2794            self.entries_sorted = true;
2795            const BULK_INDEX_EXTENSION_INVALIDATE_MIN: usize = 64;
2796            let invalidations = invalidated_paths.len() + repl_for_inval.len();
2797            if invalidations >= BULK_INDEX_EXTENSION_INVALIDATE_MIN {
2798                self.drop_derived_index_extensions();
2799            } else {
2800                for path in invalidated_paths {
2801                    self.invalidate_after_path_removal(&path);
2802                }
2803                for path in repl_for_inval {
2804                    if let Ok(p) = std::str::from_utf8(&path) {
2805                        self.invalidate_untracked_cache_for_path(p);
2806                    }
2807                    self.invalidate_cache_tree_for_path(&path);
2808                }
2809            }
2810            self.touch_fsmonitor_after_entry_change();
2811            return;
2812        }
2813        self.remove_paths(paths);
2814        for entry in replacements {
2815            self.add_or_replace(entry);
2816        }
2817    }
2818
2819    /// Add or replace an entry (matched by path + stage).
2820    pub fn add_or_replace(&mut self, entry: IndexEntry) {
2821        let path = entry.path.clone();
2822        let stage = entry.stage();
2823        let mut inserted_stage0 = false;
2824        if self.entries_sorted {
2825            let result = self.entries.binary_search_by(|e| {
2826                e.path
2827                    .as_slice()
2828                    .cmp(path.as_slice())
2829                    .then_with(|| e.stage().cmp(&stage))
2830            });
2831            match result {
2832                Ok(pos) => {
2833                    let mut e = entry;
2834                    e.base_index_pos = self.entries[pos].base_index_pos;
2835                    self.entries[pos] = e;
2836                }
2837                Err(pos) => {
2838                    self.entries.insert(pos, entry);
2839                    inserted_stage0 = stage == 0;
2840                }
2841            }
2842        } else if let Some(pos) = self
2843            .entries
2844            .iter()
2845            .position(|e| e.path == path && e.stage() == stage)
2846        {
2847            let mut e = entry;
2848            e.base_index_pos = self.entries[pos].base_index_pos;
2849            self.entries[pos] = e;
2850        } else {
2851            self.entries.push(entry);
2852            inserted_stage0 = stage == 0;
2853        }
2854        if inserted_stage0 {
2855            if let Ok(p) = std::str::from_utf8(&path) {
2856                self.invalidate_untracked_cache_for_path(p);
2857            }
2858        }
2859        if stage == 0 {
2860            self.invalidate_cache_tree_for_path(&path);
2861        }
2862        self.touch_fsmonitor_after_entry_change();
2863    }
2864
2865    /// Returns whether the index contains any unmerged (stage 1/2/3) entries.
2866    #[must_use]
2867    pub fn has_unmerged_entries(&self) -> bool {
2868        self.entries.iter().any(|e| e.stage() != 0)
2869    }
2870
2871    /// Stage a file at stage 0, removing any conflict stage entries (1, 2, 3)
2872    /// for the same path. This is the correct behavior for `git add` on a
2873    /// conflicted file during merge/cherry-pick resolution.
2874    pub fn stage_file(&mut self, entry: IndexEntry) {
2875        let path = entry.path.clone();
2876        if self.entries_sorted {
2877            let (start, end) = self.path_run_range_sorted(&path);
2878            for i in (start..end).rev() {
2879                if self.entries[i].stage() != 0 {
2880                    resolve_undo::record_resolve_undo_for_entry(
2881                        &mut self.resolve_undo,
2882                        &self.entries[i],
2883                    );
2884                    self.entries.remove(i);
2885                }
2886            }
2887        } else {
2888            for e in &self.entries {
2889                if e.path == path && e.stage() != 0 {
2890                    resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, e);
2891                }
2892            }
2893            self.entries.retain(|e| e.path != path || e.stage() == 0);
2894        }
2895        self.add_or_replace(entry);
2896    }
2897
2898    /// Drop all resolve-undo records (matches Git `resolve_undo_clear_index`).
2899    pub fn clear_resolve_undo(&mut self) {
2900        self.resolve_undo = None;
2901    }
2902
2903    /// Remove and return the resolve-undo record for `path`, if any.
2904    pub fn take_resolve_undo_record(&mut self, path: &[u8]) -> Option<ResolveUndoRecord> {
2905        let map = self.resolve_undo.as_mut()?;
2906        let ru = map.remove(path)?;
2907        if map.is_empty() {
2908            self.resolve_undo = None;
2909        }
2910        Some(ru)
2911    }
2912
2913    /// Replace all index entries for `path` with unmerged stages from `record`.
2914    pub fn install_unmerged_from_resolve_undo(&mut self, path: &[u8], record: &ResolveUndoRecord) {
2915        if self.entries_sorted {
2916            let (start, end) = self.path_run_range_sorted(path);
2917            if start < end {
2918                self.entries.drain(start..end);
2919            }
2920        } else {
2921            self.entries.retain(|e| e.path != path);
2922        }
2923        for stage in 1u8..=3u8 {
2924            let i = (stage - 1) as usize;
2925            if record.modes[i] == 0 {
2926                continue;
2927            }
2928            let entry = IndexEntry {
2929                ctime_sec: 0,
2930                ctime_nsec: 0,
2931                mtime_sec: 0,
2932                mtime_nsec: 0,
2933                dev: 0,
2934                ino: 0,
2935                mode: record.modes[i],
2936                uid: 0,
2937                gid: 0,
2938                size: 0,
2939                oid: record.oids[i],
2940                flags: path.len().min(0xFFF) as u16 | ((stage as u16) << 12),
2941                flags_extended: None,
2942                path: path.to_vec(),
2943                base_index_pos: 0,
2944            };
2945            self.add_or_replace(entry);
2946        }
2947    }
2948
2949    /// Re-create unmerged index entries for `path` from the resolve-undo extension.
2950    ///
2951    /// Returns `true` when a resolve-undo record existed and was consumed (Git `unmerge_one`).
2952    pub fn unmerge_path_from_resolve_undo(&mut self, path: &[u8]) -> bool {
2953        let Some(record) = self.take_resolve_undo_record(path) else {
2954            return false;
2955        };
2956        self.install_unmerged_from_resolve_undo(path, &record);
2957        // Git's `unmerge_index_entry` swaps the resolved stage-0 entry for unmerged
2958        // stages 1/2/3 via `remove_index_entry_at` + `add_index_entry`, both of which
2959        // call `cache_tree_invalidate_path`. After committing the resolution the index
2960        // carries a valid TREE extension whose stage-0 entry for `path` no longer
2961        // matches; without invalidating it, the next write-tree trips
2962        // `verify_cache_tree` ("corrupted cache-tree has entries not present in index").
2963        if let Ok(p) = std::str::from_utf8(path) {
2964            self.invalidate_untracked_cache_for_path(p);
2965        }
2966        self.invalidate_cache_tree_for_path(path);
2967        true
2968    }
2969
2970    /// Remove all entries matching the given path (all stages).
2971    ///
2972    /// Returns `true` if at least one entry was removed.
2973    pub fn remove(&mut self, path: &[u8]) -> bool {
2974        if self.entries_sorted {
2975            let (start, end) = self.path_run_range_sorted(path);
2976            if start < end {
2977                self.record_resolve_undo_for_run(start, end);
2978                self.entries.drain(start..end);
2979                self.invalidate_after_path_removal(path);
2980                self.touch_fsmonitor_after_entry_change();
2981                return true;
2982            }
2983            if !self.entries.iter().any(|e| e.path == path) {
2984                return false;
2985            }
2986            self.entries_sorted = false;
2987        }
2988        self.record_resolve_undo_for_path(path);
2989        let before = self.entries.len();
2990        self.entries.retain(|e| e.path != path);
2991        if self.entries.len() == before {
2992            return false;
2993        }
2994        self.invalidate_after_path_removal(path);
2995        self.touch_fsmonitor_after_entry_change();
2996        true
2997    }
2998
2999    /// Remove every index entry for `path` (all merge stages), like `remove_file_from_index`.
3000    ///
3001    /// Returns whether any entry was removed.
3002    pub fn remove_path_all_stages(&mut self, path: &[u8]) -> bool {
3003        self.remove(path)
3004    }
3005
3006    /// Invalidate UNTR nodes affected by an index change (Git `untracked_cache_*_index`).
3007    pub fn invalidate_untracked_cache_for_path(&mut self, path: &str) {
3008        if self.defer_untracked_cache_invalidation {
3009            return;
3010        }
3011        if let Some(uc) = self.untracked_cache.as_mut() {
3012            untracked_cache::invalidate_path(uc, path);
3013        }
3014    }
3015
3016    /// Remove every index entry whose path lies strictly under `path` (all stages).
3017    ///
3018    /// Used when staging a file at `path` that replaces a former directory: Git removes
3019    /// tracked paths like `path/child` from the index so they do not remain alongside
3020    /// the new blob entry.
3021    pub fn remove_descendants_under_path(&mut self, path: &str) {
3022        let prefix = path.as_bytes();
3023        if prefix.is_empty() {
3024            return;
3025        }
3026        let plen = prefix.len();
3027        if self.entries_sorted {
3028            let (start, end) = self.descendant_run_range_sorted(prefix);
3029            if start < end {
3030                self.record_resolve_undo_for_run(start, end);
3031                self.entries.drain(start..end);
3032                self.invalidate_untracked_cache_for_path(path);
3033                self.invalidate_cache_tree_for_path(path.as_bytes());
3034                return;
3035            }
3036            let any = self.entries.iter().any(|e| {
3037                let ep = e.path.as_slice();
3038                ep.len() > plen && ep.starts_with(prefix) && ep[plen] == b'/'
3039            });
3040            if !any {
3041                return;
3042            }
3043            self.entries_sorted = false;
3044        }
3045        {
3046            let had_descendant = self.entries.iter().any(|e| {
3047                let ep = e.path.as_slice();
3048                ep.len() > plen && ep.starts_with(prefix) && ep[plen] == b'/'
3049            });
3050            for e in self.entries.iter() {
3051                let ep = e.path.as_slice();
3052                if ep.len() > plen && ep.starts_with(prefix) && ep[plen] == b'/' && e.stage() != 0 {
3053                    resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, e);
3054                }
3055            }
3056            self.entries.retain(|e| {
3057                let ep = e.path.as_slice();
3058                if ep.len() <= plen {
3059                    return true;
3060                }
3061                if !ep.starts_with(prefix) {
3062                    return true;
3063                }
3064                ep[plen] != b'/'
3065            });
3066            if !had_descendant {
3067                return;
3068            }
3069        }
3070        self.invalidate_untracked_cache_for_path(path);
3071        self.invalidate_cache_tree_for_path(path.as_bytes());
3072    }
3073
3074    /// Replace the cache-tree extension with a valid tree.
3075    pub fn set_cache_tree(&mut self, cache_tree: CacheTreeNode) {
3076        self.cache_tree_root = cache_tree.oid.filter(|_| cache_tree.entry_count >= 0);
3077        self.cache_tree = Some(cache_tree);
3078    }
3079
3080    /// Remove the cache-tree extension.
3081    pub fn clear_cache_tree(&mut self) {
3082        self.cache_tree_root = None;
3083        self.cache_tree = None;
3084    }
3085
3086    /// Mark cache-tree nodes affected by an index path change as invalid.
3087    ///
3088    /// Mirrors Git's `do_invalidate_path` (`cache-tree.c`): each ancestor node along `path`
3089    /// is invalidated (`entry_count = -1`), and when the final path component names an existing
3090    /// **subtree** node, that subtree is removed entirely. The removal is essential for the
3091    /// directory→file transition (e.g. tracked dir `a/b/` replaced by file `a/b`): without it,
3092    /// stale descendant nodes (`a/b/c`, …) keep their positive `entry_count` and later trip
3093    /// [`crate::write_tree::verify_cache_tree`] with "corrupted cache-tree has entries not present
3094    /// in index".
3095    pub fn invalidate_cache_tree_for_path(&mut self, path: &[u8]) {
3096        let Some(root) = self.cache_tree.as_mut() else {
3097            self.cache_tree_root = None;
3098            return;
3099        };
3100        self.cache_tree_root = None;
3101        Self::do_invalidate_cache_tree_path(root, path);
3102    }
3103
3104    /// Recursive worker for [`Self::invalidate_cache_tree_for_path`], mirroring
3105    /// Git's `do_invalidate_path`.
3106    fn do_invalidate_cache_tree_path(node: &mut CacheTreeNode, path: &[u8]) {
3107        node.invalidate();
3108        let slash = path.iter().position(|&b| b == b'/');
3109        match slash {
3110            // Final component: drop the matching subtree node, if any.
3111            None => {
3112                if !path.is_empty() {
3113                    node.children.retain(|child| child.name != path);
3114                }
3115            }
3116            // Interior component: descend into the named subtree and recurse on the rest.
3117            Some(idx) => {
3118                let (component, rest) = (&path[..idx], &path[idx + 1..]);
3119                if let Some(child) = node
3120                    .children
3121                    .iter_mut()
3122                    .find(|child| child.name == component)
3123                {
3124                    Self::do_invalidate_cache_tree_path(child, rest);
3125                }
3126            }
3127        }
3128    }
3129
3130    /// Format the parsed cache-tree extension like Git's `test-tool dump-cache-tree`.
3131    #[must_use]
3132    pub fn format_cache_tree_dump(&self) -> String {
3133        let Some(root) = self.cache_tree.as_ref() else {
3134            return String::new();
3135        };
3136        let mut out = String::new();
3137        format_cache_tree_node(root, "", &mut out);
3138        out
3139    }
3140
3141    /// Produce `test-tool dump-cache-tree` output for this index.
3142    ///
3143    /// Mirrors Git's `cmd__dump_cache_tree`: the stored cache-tree (`it`) is
3144    /// compared against a freshly computed reference (`ref`) built from the
3145    /// current index entries with `WRITE_TREE_DRY_RUN`. Only nodes present in
3146    /// both trees are dumped; the `#(ref)` divergence lines that Git would emit
3147    /// are filtered out by the harness, so they are intentionally omitted here.
3148    ///
3149    /// If the index has no stored cache-tree, output is empty (Git dumps nothing
3150    /// because `it` is NULL).
3151    ///
3152    /// # Errors
3153    ///
3154    /// Returns an error if the reference cache-tree cannot be built (for example,
3155    /// if a tree object cannot be written to the object database).
3156    pub fn dump_cache_tree(&self, odb: &Odb) -> Result<String> {
3157        let Some(it_root) = self.cache_tree.as_ref() else {
3158            return Ok(String::new());
3159        };
3160        let ref_root = crate::write_tree::build_cache_tree_from_index(odb, self)?;
3161        let mut out = String::new();
3162        dump_cache_tree_pair(it_root, &ref_root, "", &mut out);
3163        Ok(out)
3164    }
3165
3166    /// Sort entries in Git's canonical order: by path, then by stage.
3167    pub fn sort(&mut self) {
3168        self.ensure_canonical_order();
3169    }
3170
3171    /// Restore Git canonical `(path, stage)` order before writing the index.
3172    ///
3173    /// Unlike [`Self::sort`], this re-sorts when the sorted flag is stale (for example after
3174    /// appending entries without using [`Self::add_or_replace`]).
3175    pub fn ensure_canonical_order(&mut self) {
3176        if self.entries_sorted && self.entries_are_sorted() {
3177            return;
3178        }
3179        self.sort_entries_canonical();
3180    }
3181
3182    fn sort_entries_canonical(&mut self) {
3183        self.entries
3184            .sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.stage().cmp(&b.stage())));
3185        self.entries_sorted = true;
3186    }
3187
3188    /// Collapse duplicate `(path, stage)` entries, keeping the **last** one in current order.
3189    ///
3190    /// A valid Git index never holds two entries with the same path and stage; `add_index_entry`
3191    /// replaces an existing same-name entry in place. When grit builds an index by flattening a
3192    /// tree that has duplicate path entries (see `t4058-diff-duplicates`), the naive flatten yields
3193    /// several identical-path entries. This restores Git's invariant by keeping the last entry for
3194    /// each `(path, stage)` — matching `add_index_entry`'s replace-on-collision semantics where the
3195    /// final tree entry for a path wins.
3196    pub fn dedup_paths_keep_last(&mut self) {
3197        let mut seen: std::collections::HashSet<(Vec<u8>, u8)> = std::collections::HashSet::new();
3198        let mut kept: Vec<IndexEntry> = Vec::with_capacity(self.entries.len());
3199        // Walk in reverse so the *last* occurrence of each (path, stage) is the one retained.
3200        for entry in self.entries.iter().rev() {
3201            if seen.insert((entry.path.clone(), entry.stage())) {
3202                kept.push(entry.clone());
3203            }
3204        }
3205        kept.reverse();
3206        self.entries = kept;
3207        self.entries_sorted = false;
3208    }
3209
3210    /// OID of the shared index when this index uses split-index mode (`link` extension).
3211    #[must_use]
3212    pub fn split_index_base_oid(&self) -> Option<ObjectId> {
3213        self.split_link.as_ref().map(|l| l.base_oid)
3214    }
3215
3216    /// Find an entry by path and stage (0 for normal entries).
3217    #[must_use]
3218    pub fn get(&self, path: &[u8], stage: u8) -> Option<&IndexEntry> {
3219        self.find_entry_pos(path, stage)
3220            .map(|pos| &self.entries[pos])
3221    }
3222
3223    /// Find a mutable entry by path and stage.
3224    pub fn get_mut(&mut self, path: &[u8], stage: u8) -> Option<&mut IndexEntry> {
3225        let pos = self.find_entry_pos(path, stage)?;
3226        let entry = &mut self.entries[pos];
3227        // The caller may change `oid` or stat data, which voids a just-hashed mark.
3228        entry.take_content_verified();
3229        Some(entry)
3230    }
3231
3232    /// Index positions of stage-0 entries, keyed like [`index_path_outcome_key`].
3233    ///
3234    /// Used when ordering [`crate::porcelain::add::StageOutcome`] path lists without repeated
3235    /// linear scans. Valid UTF-8 paths borrow from the index entry; invalid UTF-8 uses lossy keys.
3236    pub(crate) fn stage0_path_positions<'a>(
3237        &'a self,
3238    ) -> std::collections::HashMap<Cow<'a, str>, usize> {
3239        let stage0_count = self.entries.iter().filter(|e| e.stage() == 0).count();
3240        let mut positions = std::collections::HashMap::with_capacity(stage0_count);
3241        for (pos, entry) in self.entries.iter().enumerate() {
3242            if entry.stage() == 0 {
3243                let key = index_path_outcome_key(&entry.path);
3244                positions.entry(key).or_insert(pos);
3245            }
3246        }
3247        positions
3248    }
3249
3250    /// Merge tree contents from `treeish` into this index as virtual stage-1 entries, matching
3251    /// Git's `overlay_tree_on_index` used by `git ls-files --with-tree`.
3252    ///
3253    /// Existing unmerged entries (stages 1–3) are shifted to stage 3 so stage 1 is free for the
3254    /// overlay. Stage-1 paths that already exist at stage 0 are marked so `ls-files` can skip
3255    /// them (Git's `CE_UPDATE` on the stage-1 entry).
3256    ///
3257    /// # Parameters
3258    ///
3259    /// - `repo` — repository whose object database is used to read the tree.
3260    /// - `treeish` — revision or tree OID string (`HEAD`, `HEAD~1`, full SHA, etc.).
3261    /// - `prefix` — optional path prefix (bytes, no trailing slash except empty); only paths under
3262    ///   this prefix are considered from the tree. Pass empty slice for the full tree.
3263    ///
3264    /// # Errors
3265    ///
3266    /// Returns [`Error`] if `treeish` cannot be resolved, the tree cannot be read, or an object is
3267    /// missing from the ODB.
3268    pub fn overlay_tree_on_index(
3269        &mut self,
3270        repo: &Repository,
3271        treeish: &str,
3272        prefix: &[u8],
3273    ) -> Result<()> {
3274        let oid = rev_parse::resolve_revision(repo, treeish)?;
3275        let tree_oid = repo.odb().peel_to_tree(oid)?.ok_or_else(|| {
3276            Error::ObjectNotFound(format!("cannot peel {oid} to tree for --with-tree"))
3277        })?;
3278        for e in self.entries.iter_mut() {
3279            if e.stage() != 0 {
3280                e.set_stage(3);
3281            }
3282        }
3283        self.sort();
3284        let has_stage1 = self.entries.iter().any(|e| e.stage() == 1);
3285        let mut appended: Vec<IndexEntry> = Vec::new();
3286        let protect = crate::path_validate::ProtectOptions::from_config(&*repo.config()?);
3287        read_tree_into_overlay(
3288            repo,
3289            &tree_oid,
3290            prefix,
3291            &[],
3292            has_stage1,
3293            protect,
3294            &mut appended,
3295        )?;
3296        for e in appended {
3297            self.add_or_replace(e);
3298        }
3299        if !has_stage1 {
3300            self.sort();
3301        }
3302        let mut last_stage0: Option<&[u8]> = None;
3303        for e in &mut self.entries {
3304            match e.stage() {
3305                0 => {
3306                    last_stage0 = Some(e.path.as_slice());
3307                }
3308                1 if last_stage0.is_some_and(|p| p == e.path.as_slice()) => {
3309                    e.set_overlay_tree_skip_output(true);
3310                }
3311                _ => {}
3312            }
3313        }
3314        Ok(())
3315    }
3316}
3317
3318#[allow(clippy::too_many_arguments)]
3319fn read_tree_into_overlay(
3320    repo: &Repository,
3321    tree_oid: &ObjectId,
3322    prefix: &[u8],
3323    rel_base: &[u8],
3324    use_replace_path: bool,
3325    protect: crate::path_validate::ProtectOptions,
3326    out: &mut Vec<IndexEntry>,
3327) -> Result<()> {
3328    let obj = repo.odb().read(tree_oid)?;
3329    if obj.kind != ObjectKind::Tree {
3330        return Err(Error::ObjectNotFound(format!(
3331            "object {tree_oid} is not a tree"
3332        )));
3333    }
3334    let entries = parse_tree(&obj.data)?;
3335    for TreeEntry { mode, name, oid } in entries {
3336        let mut path = rel_base.to_vec();
3337        if !path.is_empty() {
3338            path.push(b'/');
3339        }
3340        path.extend_from_slice(&name);
3341        if !prefix_under_or_equal(prefix, &path) {
3342            continue;
3343        }
3344        crate::path_population::validate_tree_walk_entry(rel_base, &name, mode, protect)?;
3345        if mode == MODE_TREE {
3346            read_tree_into_overlay(repo, &oid, prefix, &path, use_replace_path, protect, out)?;
3347            continue;
3348        }
3349        if mode == MODE_GITLINK {
3350            continue;
3351        }
3352        let entry = synthetic_stage1_index_entry(mode, &path, oid);
3353        if use_replace_path {
3354            if let Some(pos) = out.iter().position(|e| e.path == path && e.stage() == 1) {
3355                out[pos] = entry;
3356            } else {
3357                out.push(entry);
3358            }
3359        } else {
3360            out.push(entry);
3361        }
3362    }
3363    Ok(())
3364}
3365
3366/// Collapse consecutive sorted rows with the same `(path, stage)`; the last row wins.
3367fn dedup_sorted_replacement_entries(replacements: &mut Vec<IndexEntry>) {
3368    if replacements.len() <= 1 {
3369        return;
3370    }
3371    let mut out: Vec<IndexEntry> = Vec::with_capacity(replacements.len());
3372    for entry in replacements.drain(..) {
3373        match out.last_mut() {
3374            Some(last) if last.path == entry.path && last.stage() == entry.stage() => {
3375                *last = entry;
3376            }
3377            _ => out.push(entry),
3378        }
3379    }
3380    *replacements = out;
3381}
3382
3383fn index_entry_path_stage_cmp(a: &IndexEntry, b: &IndexEntry) -> std::cmp::Ordering {
3384    a.path.cmp(&b.path).then_with(|| a.stage().cmp(&b.stage()))
3385}
3386
3387/// Merge sorted `insert` into sorted `kept` without holding a third full-length copy at peak.
3388///
3389/// `insert` is appended to `kept`, then the two adjacent sorted halves are merged in place.
3390fn merge_sorted_index_entries(
3391    mut kept: Vec<IndexEntry>,
3392    mut insert: Vec<IndexEntry>,
3393) -> Vec<IndexEntry> {
3394    if insert.is_empty() {
3395        return kept;
3396    }
3397    if kept.is_empty() {
3398        return insert;
3399    }
3400    kept.reserve(insert.len());
3401    let mid = kept.len();
3402    kept.append(&mut insert);
3403    merge_sorted_index_adjacent_halves(&mut kept, mid);
3404    kept
3405}
3406
3407/// Merge sorted `v[0..mid)` with `v[mid..]` in place; auxiliary storage holds only the shorter half.
3408fn merge_sorted_index_adjacent_halves(v: &mut Vec<IndexEntry>, mid: usize) {
3409    let n = v.len();
3410    if mid == 0 || mid >= n {
3411        return;
3412    }
3413    let right_len = n - mid;
3414    let smaller_is_right = right_len <= mid;
3415    let mut aux = if smaller_is_right {
3416        v.drain(mid..).collect::<Vec<_>>()
3417    } else {
3418        v.drain(..mid).collect::<Vec<_>>()
3419    };
3420    let left_len = if smaller_is_right { v.len() } else { aux.len() };
3421    let pad = aux.len();
3422    v.reserve(pad);
3423    for _ in 0..pad {
3424        v.push(merge_padding_index_entry());
3425    }
3426    let total = v.len();
3427    merge_backward_into_place(v, left_len, total, &mut aux, smaller_is_right);
3428}
3429
3430/// Placeholder slot for backward merge scratch space (replaced before any read).
3431fn merge_padding_index_entry() -> IndexEntry {
3432    IndexEntry {
3433        ctime_sec: 0,
3434        ctime_nsec: 0,
3435        mtime_sec: 0,
3436        mtime_nsec: 0,
3437        dev: 0,
3438        ino: 0,
3439        mode: 0,
3440        uid: 0,
3441        gid: 0,
3442        size: 0,
3443        oid: ObjectId::zero(),
3444        flags: 0,
3445        flags_extended: None,
3446        path: Vec::new(),
3447        base_index_pos: 0,
3448    }
3449}
3450
3451/// Backward merge into `v` using `aux` (copy of the shorter sorted run).
3452fn merge_backward_into_place(
3453    v: &mut Vec<IndexEntry>,
3454    left_len: usize,
3455    total: usize,
3456    aux: &mut Vec<IndexEntry>,
3457    smaller_is_right: bool,
3458) {
3459    let pad = aux.len();
3460    let mut li = left_len;
3461    let mut ri = if smaller_is_right {
3462        aux.len()
3463    } else {
3464        total.saturating_sub(pad)
3465    };
3466    let mut k = total;
3467    while li > 0 && ri > 0 {
3468        k -= 1;
3469        let ord = if smaller_is_right {
3470            index_entry_path_stage_cmp(&v[li - 1], &aux[ri - 1])
3471        } else {
3472            index_entry_path_stage_cmp(&aux[li - 1], &v[ri - 1])
3473        };
3474        match ord {
3475            std::cmp::Ordering::Greater => {
3476                if smaller_is_right {
3477                    let entry = std::mem::replace(&mut v[li - 1], merge_padding_index_entry());
3478                    drop(std::mem::replace(&mut v[k], entry));
3479                    li -= 1;
3480                } else {
3481                    let entry = std::mem::replace(&mut aux[li - 1], merge_padding_index_entry());
3482                    li -= 1;
3483                    drop(std::mem::replace(&mut v[k], entry));
3484                }
3485            }
3486            std::cmp::Ordering::Equal => {
3487                let base = if smaller_is_right {
3488                    v[li - 1].base_index_pos
3489                } else {
3490                    aux[li - 1].base_index_pos
3491                };
3492                let mut entry = if smaller_is_right {
3493                    let Some(e) = aux.pop() else {
3494                        break;
3495                    };
3496                    ri -= 1;
3497                    e
3498                } else {
3499                    ri -= 1;
3500                    std::mem::replace(&mut v[ri], merge_padding_index_entry())
3501                };
3502                entry.base_index_pos = base;
3503                if smaller_is_right {
3504                    drop(std::mem::replace(
3505                        &mut v[li - 1],
3506                        merge_padding_index_entry(),
3507                    ));
3508                    li -= 1;
3509                } else {
3510                    drop(std::mem::replace(
3511                        &mut aux[li - 1],
3512                        merge_padding_index_entry(),
3513                    ));
3514                    li -= 1;
3515                }
3516                drop(std::mem::replace(&mut v[k], entry));
3517            }
3518            std::cmp::Ordering::Less => {
3519                if smaller_is_right {
3520                    let Some(entry) = aux.pop() else {
3521                        break;
3522                    };
3523                    ri -= 1;
3524                    drop(std::mem::replace(&mut v[k], entry));
3525                } else {
3526                    ri -= 1;
3527                    let entry = std::mem::replace(&mut v[ri], merge_padding_index_entry());
3528                    drop(std::mem::replace(&mut v[k], entry));
3529                }
3530            }
3531        }
3532    }
3533    while ri > 0 {
3534        k -= 1;
3535        if smaller_is_right {
3536            let Some(entry) = aux.pop() else {
3537                break;
3538            };
3539            ri -= 1;
3540            drop(std::mem::replace(&mut v[k], entry));
3541        } else {
3542            ri -= 1;
3543            let entry = std::mem::replace(&mut v[ri], merge_padding_index_entry());
3544            drop(std::mem::replace(&mut v[k], entry));
3545        }
3546    }
3547    if !smaller_is_right && li > 0 {
3548        for i in 0..li {
3549            let entry = std::mem::replace(&mut aux[i], merge_padding_index_entry());
3550            drop(std::mem::replace(&mut v[i], entry));
3551        }
3552    }
3553    let tail_len = total - k;
3554    if li < k && tail_len > 0 {
3555        for offset in 0..tail_len {
3556            let entry = std::mem::replace(&mut v[k + offset], merge_padding_index_entry());
3557            drop(std::mem::replace(&mut v[li + offset], entry));
3558        }
3559    }
3560    v.truncate(li + tail_len);
3561}
3562
3563fn prefix_under_or_equal(prefix: &[u8], path: &[u8]) -> bool {
3564    if prefix.is_empty() {
3565        return true;
3566    }
3567    if path == prefix {
3568        return true;
3569    }
3570    path.len() > prefix.len() && path.starts_with(prefix) && path[prefix.len()] == b'/'
3571}
3572
3573fn synthetic_stage1_index_entry(mode: u32, path: &[u8], oid: ObjectId) -> IndexEntry {
3574    let path_len = path.len().min(0xFFF) as u16;
3575    let flags = (1u16 << 12) | path_len;
3576    IndexEntry {
3577        ctime_sec: 0,
3578        ctime_nsec: 0,
3579        mtime_sec: 0,
3580        mtime_nsec: 0,
3581        dev: 0,
3582        ino: 0,
3583        mode,
3584        uid: 0,
3585        gid: 0,
3586        size: 0,
3587        oid,
3588        flags,
3589        flags_extended: None,
3590        path: path.to_vec(),
3591        base_index_pos: 0,
3592    }
3593}
3594
3595fn config_truthy(raw: Option<&str>) -> bool {
3596    let Some(val) = raw else {
3597        return false;
3598    };
3599    let lowered = val.trim().to_lowercase();
3600    matches!(lowered.as_str(), "true" | "yes" | "1" | "on")
3601}
3602
3603/// Whether to write 20 zero bytes instead of the SHA-1 of the index body.
3604///
3605/// Mirrors Git `prepare_repo_settings`: `feature.manyFiles` enables skip-hash unless
3606/// `index.skipHash` / `index.skiphash` is explicitly false; otherwise honor true `index.skipHash`.
3607pub(crate) fn index_skip_hash_for_write(config: Option<&ConfigSet>) -> bool {
3608    let Some(config) = config else {
3609        return false;
3610    };
3611    let many_files = config
3612        .get_bool("feature.manyFiles")
3613        .and_then(|r| r.ok())
3614        .unwrap_or(false);
3615    if many_files {
3616        if let Some(Ok(false)) = config.get_bool("index.skipHash") {
3617            return false;
3618        }
3619        if let Some(Ok(false)) = config.get_bool("index.skiphash") {
3620            return false;
3621        }
3622        return true;
3623    }
3624    for key in ["index.skipHash", "index.skiphash"] {
3625        if let Some(Ok(true)) = config.get_bool(key) {
3626            return true;
3627        }
3628    }
3629    false
3630}
3631
3632fn trim_trailing_slash_bytes(path: &[u8]) -> &[u8] {
3633    path.strip_suffix(b"/").unwrap_or(path)
3634}
3635
3636fn directory_in_cone(dir_path: &str, patterns: &[String], cone_mode: bool) -> bool {
3637    // Match Git `path_in_cone_mode_sparse_checkout`: a *directory* is in the cone when its
3638    // contents are recursively included. `path_matches_sparse_patterns` distinguishes a
3639    // directory from a file by a trailing slash (expanded-cone matching uses dtype the way
3640    // Git does), so pass the directory with a trailing slash. Collapse prefixes arrive
3641    // without one (e.g. `before`, `folder1`), so append it to avoid a top-level directory
3642    // being mistaken for an always-in-cone top-level file. The root (empty) directory is
3643    // always in the cone (`/*` + `!/*/` excludes every top-level directory, so e.g. `deep`
3644    // collapses to a single placeholder instead of leaving `deep/deeper1/` etc.).
3645    let dir = dir_path.trim_end_matches('/');
3646    if dir.is_empty() {
3647        return true;
3648    }
3649    let with_slash = format!("{dir}/");
3650    crate::sparse_checkout::path_matches_sparse_patterns(&with_slash, patterns, cone_mode)
3651}
3652
3653fn collect_directory_prefixes(path: &[u8], out: &mut BTreeSet<Vec<u8>>) {
3654    for (i, &b) in path.iter().enumerate() {
3655        if b == b'/' && !out.contains(&path[..i]) {
3656            out.insert(path[..i].to_vec());
3657        }
3658    }
3659}
3660
3661/// Directories chosen for collapse so far, keyed by their path with a trailing slash.
3662///
3663/// No key is nested under another: collapsing a directory drops the descendants it subsumes.
3664#[derive(Default)]
3665struct CollapsedDirectories {
3666    dirs: BTreeMap<Vec<u8>, ObjectId>,
3667}
3668
3669impl CollapsedDirectories {
3670    fn is_empty(&self) -> bool {
3671        self.dirs.is_empty()
3672    }
3673
3674    /// Record `directory` (with trailing slash) as collapsed to `oid`.
3675    fn insert(&mut self, directory: Vec<u8>, oid: ObjectId) {
3676        let nested: Vec<Vec<u8>> = self
3677            .dirs
3678            .range::<[u8], _>((
3679                std::ops::Bound::Excluded(directory.as_slice()),
3680                std::ops::Bound::Unbounded,
3681            ))
3682            .map(|(k, _)| k)
3683            .take_while(|k| k.starts_with(&directory))
3684            .cloned()
3685            .collect();
3686        for key in nested {
3687            self.dirs.remove(&key);
3688        }
3689        self.dirs.insert(directory, oid);
3690    }
3691
3692    fn get(&self, directory: &[u8]) -> Option<&ObjectId> {
3693        self.dirs.get(directory)
3694    }
3695
3696    /// Whether a collapsed directory lies strictly under `directory` (with trailing slash).
3697    fn has_descendants(&self, directory: &[u8]) -> bool {
3698        self.dirs
3699            .range::<[u8], _>((
3700                std::ops::Bound::Excluded(directory),
3701                std::ops::Bound::Unbounded,
3702            ))
3703            .next()
3704            .is_some_and(|(k, _)| k.starts_with(directory))
3705    }
3706
3707    /// For sorted `entries`, which rows [`Self::covers`] holds for, found by binary search per
3708    /// collapsed directory instead of a lookup per row.
3709    fn covered_rows(&self, entries: &[IndexEntry]) -> Vec<bool> {
3710        let mut covered = vec![false; entries.len()];
3711        let mut mark = |start: usize, matches: &dyn Fn(&[u8]) -> bool| {
3712            for (row, entry) in covered[start..].iter_mut().zip(&entries[start..]) {
3713                if !matches(&entry.path) {
3714                    break;
3715                }
3716                *row = true;
3717            }
3718        };
3719        for directory in self.dirs.keys() {
3720            let name = trim_trailing_slash_bytes(directory);
3721            let start = entries.partition_point(|e| e.path.as_slice() < name);
3722            mark(start, &|path| path == name);
3723            let start = entries.partition_point(|e| e.path.as_slice() < directory.as_slice());
3724            mark(start, &|path| path.starts_with(directory));
3725        }
3726        covered
3727    }
3728
3729    /// Whether a row at `path` is replaced by a collapsed placeholder: it lies under a
3730    /// collapsed directory, or names the directory itself. `scratch` is reused between calls.
3731    fn covers(&self, path: &[u8], scratch: &mut Vec<u8>) -> bool {
3732        if self.dirs.is_empty() {
3733            return false;
3734        }
3735        // Keys are never nested, so the only key that can prefix `path` is the greatest key
3736        // not after it.
3737        let under = self
3738            .dirs
3739            .range::<[u8], _>((std::ops::Bound::Unbounded, std::ops::Bound::Included(path)))
3740            .next_back()
3741            .is_some_and(|(k, _)| path.starts_with(k));
3742        if under {
3743            return true;
3744        }
3745        scratch.clear();
3746        scratch.extend_from_slice(path);
3747        scratch.push(b'/');
3748        self.dirs.contains_key(scratch.as_slice())
3749    }
3750
3751    fn into_placeholders(self) -> impl Iterator<Item = IndexEntry> {
3752        self.dirs.into_iter().map(|(path, oid)| {
3753            let mut placeholder = IndexEntry {
3754                ctime_sec: 0,
3755                ctime_nsec: 0,
3756                mtime_sec: 0,
3757                mtime_nsec: 0,
3758                dev: 0,
3759                ino: 0,
3760                mode: MODE_TREE,
3761                uid: 0,
3762                gid: 0,
3763                size: 0,
3764                oid,
3765                flags: path.len().min(0xFFF) as u16,
3766                flags_extended: Some(0),
3767                path,
3768                base_index_pos: 0,
3769            };
3770            placeholder.set_skip_worktree(true);
3771            placeholder
3772        })
3773    }
3774}
3775
3776/// The index as [`Index::try_collapse_sparse_directories`] sees it mid-run: sorted `entries`
3777/// with the directories in `collapsed` replaced by placeholders.
3778struct SparseCollapseView<'a> {
3779    entries: &'a [IndexEntry],
3780    collapsed: &'a CollapsedDirectories,
3781}
3782
3783impl SparseCollapseView<'_> {
3784    /// Whether a stage-0 sparse-directory placeholder for `path_slash` points at `oid`.
3785    fn has_placeholder(&self, path_slash: &[u8], oid: &ObjectId, scratch: &mut Vec<u8>) -> bool {
3786        if let Some(collapsed_oid) = self.collapsed.get(path_slash) {
3787            return collapsed_oid == oid;
3788        }
3789        if self.collapsed.covers(path_slash, scratch) {
3790            return false;
3791        }
3792        let start = self
3793            .entries
3794            .partition_point(|e| e.path.as_slice() < path_slash);
3795        self.entries[start..]
3796            .iter()
3797            .take_while(|e| e.path == path_slash)
3798            .any(|e| e.stage() == 0 && e.is_sparse_directory_placeholder() && e.oid == *oid)
3799    }
3800}
3801
3802/// Resolves directory prefixes to their tree OIDs under one root tree, reading and parsing
3803/// each tree on the way at most once.
3804struct PrefixTreeResolver {
3805    root: ObjectId,
3806    resolved: HashMap<Vec<u8>, Option<ObjectId>>,
3807    parsed: HashMap<ObjectId, Option<Vec<TreeEntry>>>,
3808}
3809
3810impl PrefixTreeResolver {
3811    fn new(root: ObjectId) -> Self {
3812        Self {
3813            root,
3814            resolved: HashMap::new(),
3815            parsed: HashMap::new(),
3816        }
3817    }
3818
3819    /// The tree at `prefix` (no trailing slash), or `None` when a component is missing or
3820    /// is not a tree.
3821    fn resolve(&mut self, odb: &Odb, prefix: &[u8]) -> Result<Option<ObjectId>> {
3822        if prefix.is_empty() {
3823            return Ok(Some(self.root));
3824        }
3825        if let Some(known) = self.resolved.get(prefix) {
3826            return Ok(*known);
3827        }
3828        let (parent, name) = match prefix.iter().rposition(|&b| b == b'/') {
3829            Some(i) => (&prefix[..i], &prefix[i + 1..]),
3830            None => (&prefix[..0], prefix),
3831        };
3832        let found = match self.resolve(odb, parent)? {
3833            Some(parent_oid) if !name.is_empty() => {
3834                let entries = match self.parsed.entry(parent_oid) {
3835                    std::collections::hash_map::Entry::Occupied(known) => known.into_mut(),
3836                    std::collections::hash_map::Entry::Vacant(slot) => {
3837                        let obj = odb.read(&parent_oid)?;
3838                        slot.insert(if obj.kind == ObjectKind::Tree {
3839                            Some(parse_tree(&obj.data)?)
3840                        } else {
3841                            None
3842                        })
3843                    }
3844                };
3845                entries
3846                    .as_ref()
3847                    .and_then(|entries| entries.iter().find(|e| e.name == name))
3848                    .filter(|e| e.mode == MODE_TREE)
3849                    .map(|e| e.oid)
3850            }
3851            // An empty component (`a//b`) is skipped, as when splitting on `/`.
3852            Some(parent_oid) => Some(parent_oid),
3853            None => None,
3854        };
3855        self.resolved.insert(prefix.to_vec(), found);
3856        Ok(found)
3857    }
3858}
3859
3860/// When every stage-0 row under `directory` is a sparse-directory placeholder whose OID
3861/// matches the corresponding `HEAD` tree entry, the directory can collapse to one placeholder.
3862fn try_collapse_directory_of_sparse_placeholders(
3863    odb: &Odb,
3864    directory: &[u8],
3865    rows: &[IndexEntry],
3866    head_subtree: &ObjectId,
3867) -> Result<bool> {
3868    if rows.is_empty() || rows.iter().any(|e| e.stage() != 0) {
3869        return Ok(false);
3870    }
3871    if !rows.iter().all(|e| e.is_sparse_directory_placeholder()) {
3872        return Ok(false);
3873    }
3874    let obj = odb.read(head_subtree)?;
3875    if obj.kind != crate::objects::ObjectKind::Tree {
3876        return Ok(false);
3877    }
3878    let tree_entries = crate::objects::parse_tree(&obj.data)?;
3879    if tree_entries.len() != rows.len() {
3880        return Ok(false);
3881    }
3882    for te in &tree_entries {
3883        if te.mode != MODE_TREE {
3884            return Ok(false);
3885        }
3886        let mut path = directory.to_vec();
3887        path.extend_from_slice(&te.name);
3888        path.push(b'/');
3889        let Some(row) = rows.iter().find(|e| e.path == path) else {
3890            return Ok(false);
3891        };
3892        if row.oid != te.oid || !row.skip_worktree() {
3893            return Ok(false);
3894        }
3895    }
3896    Ok(true)
3897}
3898
3899/// Build the list of blob index entries under `prefix` that match `HEAD` at `tree_oid`,
3900/// treating existing sparse-directory placeholders in `view` as opaque subtrees (like Git).
3901fn collect_sparse_aware_expected_blobs(
3902    odb: &Odb,
3903    tree_oid: &ObjectId,
3904    prefix: &[u8],
3905    patterns: &[String],
3906    cone_mode: bool,
3907    view: &SparseCollapseView<'_>,
3908    protect: crate::path_validate::ProtectOptions,
3909) -> Result<Vec<ExpectedBlob>> {
3910    let mut out = Vec::new();
3911    let mut scratch = Vec::new();
3912    walk_sparse_aware(
3913        odb,
3914        tree_oid,
3915        prefix,
3916        patterns,
3917        cone_mode,
3918        view,
3919        protect,
3920        &mut scratch,
3921        &mut out,
3922    )?;
3923    Ok(out)
3924}
3925
3926#[allow(clippy::too_many_arguments)]
3927fn walk_sparse_aware(
3928    odb: &Odb,
3929    tree_oid: &ObjectId,
3930    prefix: &[u8],
3931    patterns: &[String],
3932    cone_mode: bool,
3933    view: &SparseCollapseView<'_>,
3934    protect: crate::path_validate::ProtectOptions,
3935    scratch: &mut Vec<u8>,
3936    out: &mut Vec<ExpectedBlob>,
3937) -> Result<()> {
3938    // Switch and checkout have usually just diffed these trees, so share the handle's cache.
3939    let tree = odb.cached_tree_for_walk(tree_oid)?;
3940    let mut visit = |name: &[u8], mode: u32, oid: ObjectId| -> Result<()> {
3941        let path = if prefix.is_empty() {
3942            name.to_vec()
3943        } else {
3944            let mut p = Vec::with_capacity(prefix.len() + 1 + name.len());
3945            p.extend_from_slice(prefix);
3946            p.push(b'/');
3947            p.extend_from_slice(name);
3948            p
3949        };
3950        crate::path_population::validate_tree_walk_entry(&path, name, mode, protect)?;
3951        if mode == MODE_TREE {
3952            let mut path_slash = path.clone();
3953            path_slash.push(b'/');
3954            if view.has_placeholder(&path_slash, &oid, scratch) {
3955                return Ok(());
3956            }
3957            return walk_sparse_aware(
3958                odb, &oid, &path, patterns, cone_mode, view, protect, scratch, out,
3959            );
3960        }
3961        let path_str = String::from_utf8_lossy(&path);
3962        if crate::sparse_checkout::path_matches_sparse_patterns(&path_str, patterns, cone_mode) {
3963            return Ok(());
3964        }
3965        out.push(ExpectedBlob { path, mode, oid });
3966        Ok(())
3967    };
3968    match &tree {
3969        crate::odb::SessionTree::Parsed(entries) => {
3970            for te in entries.iter() {
3971                visit(&te.name, te.mode, te.oid)?;
3972            }
3973        }
3974        crate::odb::SessionTree::Raw(data) => {
3975            for te in crate::objects::TreeRefIter::with_oid_len(data, odb.hash_algo().len()) {
3976                let te = te?;
3977                visit(te.name, te.mode, te.oid)?;
3978            }
3979        }
3980    }
3981    Ok(())
3982}
3983
3984/// A blob a collapsible sparse directory must hold, from the `HEAD` tree.
3985struct ExpectedBlob {
3986    path: Vec<u8>,
3987    mode: u32,
3988    oid: ObjectId,
3989}
3990
3991fn flatten_tree_blobs(
3992    odb: &Odb,
3993    tree_oid: &ObjectId,
3994    prefix: &[u8],
3995    protect: crate::path_validate::ProtectOptions,
3996) -> Result<Vec<IndexEntry>> {
3997    let obj = odb.read(tree_oid)?;
3998    if obj.kind != ObjectKind::Tree {
3999        return Err(Error::IndexError(format!("expected tree at {}", tree_oid)));
4000    }
4001    let entries = parse_tree(&obj.data)?;
4002    let mut out = Vec::new();
4003    for te in entries {
4004        let path = if prefix.is_empty() {
4005            te.name.clone()
4006        } else {
4007            let mut p = prefix.to_vec();
4008            p.push(b'/');
4009            p.extend_from_slice(&te.name);
4010            p
4011        };
4012        crate::path_population::validate_tree_walk_entry(prefix, &te.name, te.mode, protect)?;
4013        if is_tree_mode(te.mode) {
4014            let sub = flatten_tree_blobs(odb, &te.oid, &path, protect)?;
4015            out.extend(sub);
4016        } else {
4017            let path_len = path.len().min(0xFFF) as u16;
4018            let mut e = IndexEntry {
4019                ctime_sec: 0,
4020                ctime_nsec: 0,
4021                mtime_sec: 0,
4022                mtime_nsec: 0,
4023                dev: 0,
4024                ino: 0,
4025                mode: canon_mode(te.mode),
4026                uid: 0,
4027                gid: 0,
4028                size: 0,
4029                oid: te.oid,
4030                flags: path_len,
4031                flags_extended: Some(0),
4032                path,
4033                base_index_pos: 0,
4034            };
4035            e.set_skip_worktree(true);
4036            out.push(e);
4037        }
4038    }
4039    Ok(out)
4040}
4041
4042/// Exclusive `.git/index.lock` held for a mutating porcelain operation.
4043///
4044/// Acquire with [`Self::acquire`] before changing the working tree; persist the index with
4045/// [`IndexHandle::commit_update`](crate::index::IndexHandle::commit_update) or
4046/// [`IndexHandle::write`](crate::index::IndexHandle::write), then release on drop when
4047/// uncommitted.
4048pub struct IndexLock {
4049    pub(crate) index_path: PathBuf,
4050    pub(crate) lock_path: PathBuf,
4051    pub(crate) pid_path: PathBuf,
4052    pub(crate) file: fs::File,
4053    pub(crate) wrote_pid: bool,
4054    pub(crate) committed: bool,
4055}
4056
4057impl IndexLock {
4058    /// Close the handle the index was just written through and hold the lock with a fresh one.
4059    pub(crate) fn reopen_after_write(&mut self) -> Result<()> {
4060        drop(std::mem::replace(
4061            &mut self.file,
4062            fs::OpenOptions::new()
4063                .write(true)
4064                .open(&self.lock_path)
4065                .map_err(Error::Io)?,
4066        ));
4067        Ok(())
4068    }
4069
4070    /// Rename the staged lock file over the on-disk index.
4071    pub(crate) fn commit_replace_index(&mut self) -> Result<()> {
4072        crate::atomic_replace::detach_open_handle(&mut self.file).map_err(Error::Io)?;
4073        crate::atomic_replace::replace_file(&self.lock_path, &self.index_path)
4074            .map_err(Error::Io)?;
4075        self.committed = true;
4076        if self.wrote_pid {
4077            let _ = fs::remove_file(&self.pid_path);
4078        }
4079        Ok(())
4080    }
4081
4082    /// Create `.git/index.lock` exclusively, failing when another process holds it.
4083    ///
4084    /// # Errors
4085    ///
4086    /// Returns [`Error::LockHeld`] (with the pid sidecar's [`LockHolder`](crate::lockfile::LockHolder)
4087    /// when one was written) when `index.lock` already exists after the
4088    /// `core.filesRefLockTimeout` budget, or [`Error::Io`] for other filesystem failures.
4089    pub fn acquire(index_path: &Path, config: Option<&ConfigSet>) -> Result<Self> {
4090        let lock_path = index_path.with_extension("lock");
4091        let pid_path = pid_path_for_lock(&lock_path);
4092        let lockfile_pid_enabled = grit_lockfile_pid_enabled(config, index_path.parent());
4093
4094        use crate::lockfile::{LockFile, LockWait};
4095        let wait = LockWait::files_ref_lock(config);
4096        let mut lock_file = LockFile::acquire(&lock_path, wait).map_err(Error::from)?;
4097        if lockfile_pid_enabled {
4098            if let Err(e) = lock_file
4099                .write_holder_pid()
4100                .map_err(crate::error::Error::from)
4101            {
4102                lock_file.rollback();
4103                return Err(e);
4104            }
4105        }
4106        let wrote_pid = lockfile_pid_enabled;
4107        let (lock_path, file) = lock_file.into_open_file()?;
4108
4109        Ok(Self {
4110            index_path: index_path.to_path_buf(),
4111            lock_path,
4112            pid_path,
4113            file,
4114            wrote_pid,
4115            committed: false,
4116        })
4117    }
4118}
4119
4120impl Drop for IndexLock {
4121    fn drop(&mut self) {
4122        if self.committed {
4123            return;
4124        }
4125        let _ = fs::remove_file(&self.lock_path);
4126        if self.wrote_pid {
4127            let _ = fs::remove_file(&self.pid_path);
4128        }
4129    }
4130}
4131
4132/// Whether `path` lies strictly under directory `dir` (`dir/…`).
4133fn path_is_under_dir(path: &[u8], dir: &[u8]) -> bool {
4134    path.len() > dir.len() && path[dir.len()] == b'/' && path.starts_with(dir)
4135}
4136
4137/// Hash an index body with the given algorithm, returning the raw checksum.
4138fn hash_index_body(algo: HashAlgo, body: &[u8]) -> Vec<u8> {
4139    algo.checksum_digest(body)
4140}
4141
4142/// Bytes [`IndexBodyOut`] buffers before handing them to its sink when streaming an index write.
4143const INDEX_WRITE_CHUNK: usize = 128 * 1024;
4144
4145/// Headroom reserved past [`INDEX_WRITE_CHUNK`] so the entry that crosses it rarely reallocates.
4146const INDEX_WRITE_CHUNK_SLACK: usize = 8 * 1024;
4147
4148/// Receives each drained chunk of a streamed index body.
4149type IndexChunkSink<'a> = &'a mut dyn FnMut(&[u8]) -> io::Result<()>;
4150
4151/// Serialized index body under construction.
4152///
4153/// Without a sink the whole body accumulates in `buf`. With a sink, `buf` is handed over and
4154/// cleared each time it reaches [`INDEX_WRITE_CHUNK`] between entries, so memory stays bounded
4155/// however many entries the index has. [`Self::position`] is the absolute body offset either way,
4156/// which the IEOT and EOIE extensions record.
4157struct IndexBodyOut<'a> {
4158    buf: Vec<u8>,
4159    drained: usize,
4160    sink: Option<IndexChunkSink<'a>>,
4161}
4162
4163impl IndexBodyOut<'_> {
4164    /// Offset of the next byte from the start of the body.
4165    fn position(&self) -> usize {
4166        self.drained + self.buf.len()
4167    }
4168
4169    /// Hand `buf` to the sink once it holds at least [`INDEX_WRITE_CHUNK`] bytes.
4170    fn drain_if_full(&mut self) -> io::Result<()> {
4171        if self.buf.len() < INDEX_WRITE_CHUNK {
4172            return Ok(());
4173        }
4174        self.drain()
4175    }
4176
4177    /// Hand everything buffered to the sink; a no-op for in-memory serialization.
4178    fn drain(&mut self) -> io::Result<()> {
4179        let Some(sink) = self.sink.as_mut() else {
4180            return Ok(());
4181        };
4182        if self.buf.is_empty() {
4183            return Ok(());
4184        }
4185        sink(&self.buf)?;
4186        self.drained += self.buf.len();
4187        self.buf.clear();
4188        Ok(())
4189    }
4190}
4191
4192impl Index {
4193    /// Conservative pre-size for serializing the index body (header, entries, extensions).
4194    fn estimate_body_capacity(&self, write_version: u32, config: Option<&ConfigSet>) -> usize {
4195        use crate::index_eoie_ieot::record_end_of_index_entries;
4196        let hash_len = self.hash_algo.len();
4197        let mut entries_bytes = 0usize;
4198        for entry in &self.entries {
4199            let mut n = 62usize
4200                .saturating_add(hash_len)
4201                .saturating_add(entry.path.len());
4202            if write_version >= 3
4203                && entry
4204                    .flags_extended
4205                    .map(IndexEntry::disk_flags_extended)
4206                    .filter(|fe| *fe != 0)
4207                    .is_some()
4208            {
4209                n += 2;
4210            }
4211            if write_version == 4 {
4212                n += 12;
4213            } else {
4214                n = (n + 7) & !7;
4215            }
4216            entries_bytes = entries_bytes.saturating_add(n);
4217        }
4218        let mut extra = 256usize;
4219        if self.cache_tree.is_some() {
4220            extra += 4096;
4221        }
4222        if self.untracked_cache.is_some() {
4223            extra += 4096;
4224        }
4225        if self.fsmonitor_last_update.is_some() {
4226            extra += 512;
4227        }
4228        if record_end_of_index_entries(config) {
4229            extra += 64;
4230        }
4231        12 + entries_bytes + extra
4232    }
4233}
4234
4235fn append_index_extension(
4236    out: &mut Vec<u8>,
4237    eoie_headers: &mut Option<Vec<u8>>,
4238    signature: u32,
4239    payload: &[u8],
4240) {
4241    let size = payload.len() as u32;
4242    out.extend_from_slice(&signature.to_be_bytes());
4243    out.extend_from_slice(&size.to_be_bytes());
4244    if let Some(headers) = eoie_headers {
4245        headers.extend_from_slice(&signature.to_be_bytes());
4246        headers.extend_from_slice(&size.to_be_bytes());
4247    }
4248    out.extend_from_slice(payload);
4249}
4250
4251/// Best-effort detection of an index file's hash algorithm from its trailing
4252/// checksum. Used by [`Index::parse`] when no repository algorithm is known.
4253/// Callers with a repository should use [`Index::parse_with_algo`] instead,
4254/// which is authoritative even when the checksum is skipped (all zeros).
4255fn detect_index_hash_algo(data: &[u8]) -> HashAlgo {
4256    // A SHA-256 index ends with a 32-byte checksum over the body. If that
4257    // verifies, the file is SHA-256; otherwise assume SHA-1.
4258    let sha256_len = HashAlgo::Sha256.len();
4259    if data.len() > sha256_len + 12 {
4260        let (body, checksum) = data.split_at(data.len() - sha256_len);
4261        if checksum.iter().any(|&b| b != 0) && hash_index_body(HashAlgo::Sha256, body) == checksum {
4262            return HashAlgo::Sha256;
4263        }
4264    }
4265    HashAlgo::Sha1
4266}
4267
4268/// Parse a single index entry from `data`, returning `(entry, bytes_consumed)`.
4269///
4270/// `hash_len` is the raw object-id width (20 for SHA-1, 32 for SHA-256).
4271pub(crate) fn parse_entry(
4272    data: &[u8],
4273    version: u32,
4274    prev_path: &[u8],
4275    hash_len: usize,
4276) -> Result<(IndexEntry, usize)> {
4277    // 40 bytes of stat fields + the OID + 2 flag bytes.
4278    if data.len() < 40 + hash_len + 2 {
4279        return Err(Error::IndexError("entry too short".to_owned()));
4280    }
4281
4282    let mut pos = 0;
4283
4284    macro_rules! read_u32 {
4285        () => {{
4286            let v = u32::from_be_bytes(
4287                data[pos..pos + 4]
4288                    .try_into()
4289                    .map_err(|_| Error::IndexError("truncated u32".to_owned()))?,
4290            );
4291            pos += 4;
4292            v
4293        }};
4294    }
4295
4296    let ctime_sec = read_u32!();
4297    let ctime_nsec = read_u32!();
4298    let mtime_sec = read_u32!();
4299    let mtime_nsec = read_u32!();
4300    let dev = read_u32!();
4301    let ino = read_u32!();
4302    let mode = read_u32!();
4303    let uid = read_u32!();
4304    let gid = read_u32!();
4305    let size = read_u32!();
4306
4307    let oid = ObjectId::from_bytes(&data[pos..pos + hash_len])?;
4308    pos += hash_len;
4309
4310    let flags = u16::from_be_bytes(
4311        data[pos..pos + 2]
4312            .try_into()
4313            .map_err(|_| Error::IndexError("truncated flags".to_owned()))?,
4314    );
4315    pos += 2;
4316
4317    let flags_extended = if version >= 3 && flags & 0x4000 != 0 {
4318        let fe = u16::from_be_bytes(
4319            data[pos..pos + 2]
4320                .try_into()
4321                .map_err(|_| Error::IndexError("truncated extended flags".to_owned()))?,
4322        );
4323        pos += 2;
4324        Some(fe)
4325    } else {
4326        None
4327    };
4328
4329    let path;
4330    if version == 4 {
4331        // V4: prefix-compressed path
4332        let (strip_len, varint_bytes) = read_varint(&data[pos..]);
4333        pos += varint_bytes;
4334        let nul = data[pos..]
4335            .iter()
4336            .position(|&b| b == 0)
4337            .ok_or_else(|| Error::IndexError("v4 entry path missing NUL".to_owned()))?;
4338        let suffix = &data[pos..pos + nul];
4339        pos += nul + 1;
4340        let keep = prev_path.len().saturating_sub(strip_len);
4341        let mut full_path = Vec::with_capacity(keep + suffix.len());
4342        full_path.extend_from_slice(&prev_path[..keep]);
4343        full_path.extend_from_slice(suffix);
4344        path = full_path;
4345    } else {
4346        // V2/V3: NUL-terminated full path + padding
4347        let nul = data[pos..]
4348            .iter()
4349            .position(|&b| b == 0)
4350            .ok_or_else(|| Error::IndexError("entry path missing NUL terminator".to_owned()))?;
4351        path = data[pos..pos + nul].to_vec();
4352        pos += nul + 1;
4353        let entry_start = 0usize;
4354        let entry_len = pos - entry_start;
4355        let padded = (entry_len + 7) & !7;
4356        let padding = padded.saturating_sub(entry_len);
4357        pos += padding;
4358    }
4359
4360    Ok((
4361        IndexEntry {
4362            ctime_sec,
4363            ctime_nsec,
4364            mtime_sec,
4365            mtime_nsec,
4366            dev,
4367            ino,
4368            mode,
4369            uid,
4370            gid,
4371            size,
4372            oid,
4373            flags,
4374            flags_extended,
4375            path,
4376            base_index_pos: 0,
4377        },
4378        pos,
4379    ))
4380}
4381
4382/// Serialise a single index entry into `out`.
4383/// Read a variable-length integer (git's index v4 varint encoding).
4384/// Returns (value, bytes_consumed).
4385fn write_varint(out: &mut Vec<u8>, mut value: usize) {
4386    loop {
4387        let mut b = (value & 0x7F) as u8;
4388        value >>= 7;
4389        if value != 0 {
4390            b |= 0x80;
4391        }
4392        out.push(b);
4393        if value == 0 {
4394            break;
4395        }
4396    }
4397}
4398
4399fn read_varint(data: &[u8]) -> (usize, usize) {
4400    let mut value: usize = 0;
4401    let mut shift = 0usize;
4402    let mut pos = 0;
4403    loop {
4404        if pos >= data.len() {
4405            break;
4406        }
4407        let byte = data[pos] as usize;
4408        pos += 1;
4409        value |= (byte & 0x7F) << shift;
4410        if byte & 0x80 == 0 {
4411            break;
4412        }
4413        shift += 7;
4414        // Prevent infinite loops on malformed data
4415        if shift > 28 {
4416            break;
4417        }
4418    }
4419    (value, pos)
4420}
4421
4422fn serialize_entry_v4(entry: &IndexEntry, previous_path: &mut Vec<u8>, out: &mut Vec<u8>) {
4423    let write_u32 = |out: &mut Vec<u8>, v: u32| out.extend_from_slice(&v.to_be_bytes());
4424
4425    write_u32(out, entry.ctime_sec);
4426    write_u32(out, entry.ctime_nsec);
4427    write_u32(out, entry.mtime_sec);
4428    write_u32(out, entry.mtime_nsec);
4429    write_u32(out, entry.dev);
4430    write_u32(out, entry.ino);
4431    write_u32(out, entry.mode);
4432    write_u32(out, entry.uid);
4433    write_u32(out, entry.gid);
4434    write_u32(out, entry.size);
4435    out.extend_from_slice(entry.oid.as_bytes());
4436
4437    let mut flags = entry.flags;
4438    let disk_ext = entry
4439        .flags_extended
4440        .map(IndexEntry::disk_flags_extended)
4441        .filter(|fe| *fe != 0);
4442    if disk_ext.is_some() {
4443        flags |= 0x4000;
4444    } else {
4445        flags &= !0x4000;
4446    }
4447    let path_len = entry.path.len().min(0xFFF) as u16;
4448    flags = (flags & 0xF000) | path_len;
4449    out.extend_from_slice(&flags.to_be_bytes());
4450
4451    if let Some(fe) = disk_ext {
4452        out.extend_from_slice(&fe.to_be_bytes());
4453    }
4454
4455    let common = previous_path
4456        .iter()
4457        .zip(entry.path.iter())
4458        .take_while(|(a, b)| a == b)
4459        .count();
4460    let to_remove = previous_path.len().saturating_sub(common);
4461    write_varint(out, to_remove);
4462    out.extend_from_slice(&entry.path[common..]);
4463    out.push(0);
4464
4465    previous_path.clear();
4466    previous_path.extend_from_slice(&entry.path);
4467}
4468
4469fn serialize_entry(entry: &IndexEntry, version: u32, out: &mut Vec<u8>) {
4470    let start = out.len();
4471
4472    let write_u32 = |out: &mut Vec<u8>, v: u32| out.extend_from_slice(&v.to_be_bytes());
4473
4474    write_u32(out, entry.ctime_sec);
4475    write_u32(out, entry.ctime_nsec);
4476    write_u32(out, entry.mtime_sec);
4477    write_u32(out, entry.mtime_nsec);
4478    write_u32(out, entry.dev);
4479    write_u32(out, entry.ino);
4480    write_u32(out, entry.mode);
4481    write_u32(out, entry.uid);
4482    write_u32(out, entry.gid);
4483    write_u32(out, entry.size);
4484    out.extend_from_slice(entry.oid.as_bytes());
4485
4486    // Set or clear the extended-flags bit in flags
4487    let mut flags = entry.flags;
4488    let disk_ext = entry
4489        .flags_extended
4490        .map(IndexEntry::disk_flags_extended)
4491        .filter(|fe| *fe != 0);
4492    if version >= 3 && disk_ext.is_some() {
4493        flags |= 0x4000;
4494    } else {
4495        flags &= !0x4000;
4496    }
4497    // Overwrite path length bits (bottom 12)
4498    let path_len = entry.path.len().min(0xFFF) as u16;
4499    flags = (flags & 0xF000) | path_len;
4500    out.extend_from_slice(&flags.to_be_bytes());
4501
4502    if version >= 3 {
4503        if let Some(fe) = disk_ext {
4504            out.extend_from_slice(&fe.to_be_bytes());
4505        }
4506    }
4507
4508    out.extend_from_slice(&entry.path);
4509    out.push(0);
4510
4511    // Pad to 8-byte boundary
4512    let entry_len = out.len() - start;
4513    let padded = (entry_len + 7) & !7;
4514    let padding = padded - entry_len;
4515    for _ in 0..padding {
4516        out.push(0);
4517    }
4518}
4519
4520/// Build an [`IndexEntry`] by stat-ing a file on disk.
4521///
4522/// # Parameters
4523///
4524/// - `path` — absolute path to the file.
4525/// - `rel_path` — path relative to the repo root (stored in the index).
4526/// - `oid` — the object ID of the file's blob.
4527/// - `mode` — file mode (use [`MODE_REGULAR`], [`MODE_EXECUTABLE`], etc.).
4528///
4529/// # Errors
4530///
4531/// Returns [`Error::Io`] if `stat` fails.
4532pub fn entry_from_stat(
4533    path: &Path,
4534    rel_path: &[u8],
4535    oid: ObjectId,
4536    mode: u32,
4537) -> Result<IndexEntry> {
4538    let meta = fs::symlink_metadata(path)?;
4539    Ok(entry_from_metadata(&meta, rel_path, oid, mode))
4540}
4541
4542/// Returns `(mtime_sec, mtime_nsec)` for an on-disk index file, or `None` if it does not exist.
4543///
4544/// Uses the `filetime` crate so index mtime reads behave the same on Unix and Windows.
4545#[must_use]
4546pub fn index_file_mtime(path: &Path) -> Option<(u32, u32)> {
4547    let meta = fs::metadata(path).ok()?;
4548    Some(index_mtime_from_metadata(&meta))
4549}
4550
4551fn index_mtime_from_metadata(meta: &fs::Metadata) -> (u32, u32) {
4552    let ft = filetime::FileTime::from_last_modification_time(meta);
4553    let secs = ft.unix_seconds();
4554    if secs.is_negative() {
4555        return (0, 0);
4556    }
4557    (secs as u32, ft.nanoseconds())
4558}
4559
4560/// Index files at least this large are memory-mapped instead of copied into a buffer.
4561const INDEX_MMAP_MIN_BYTES: u64 = 64 * 1024;
4562
4563/// Raw bytes of an on-disk index file.
4564enum IndexFileBytes {
4565    Owned(Vec<u8>),
4566    Mapped(memmap2::Mmap),
4567}
4568
4569impl std::ops::Deref for IndexFileBytes {
4570    type Target = [u8];
4571
4572    fn deref(&self) -> &[u8] {
4573        match self {
4574            Self::Owned(bytes) => bytes,
4575            Self::Mapped(map) => map,
4576        }
4577    }
4578}
4579
4580/// Trailing checksum bytes from a full on-disk index file (including the trailer).
4581#[must_use]
4582pub(crate) fn index_file_trailer(data: &[u8], hash_algo: HashAlgo) -> Option<Vec<u8>> {
4583    let hash_len = hash_algo.len();
4584    (data.len() >= hash_len).then(|| data[data.len() - hash_len..].to_vec())
4585}
4586
4587fn record_loaded_index_identity(index: &mut Index, data: &[u8], mtime: (u32, u32)) {
4588    index.source_mtime = Some(mtime);
4589    index.source_file_len = Some(u64::try_from(data.len()).unwrap_or(u64::MAX));
4590    index.source_checksum = index_file_trailer(data, index.hash_algo);
4591}
4592
4593/// On-disk identity of `.git/index` for opportunistic writers.
4594#[derive(Debug, Clone, PartialEq, Eq)]
4595pub(crate) struct IndexOnDiskIdentity {
4596    pub checksum: Option<Vec<u8>>,
4597    pub mtime: Option<(u32, u32)>,
4598    pub len: u64,
4599}
4600
4601/// Read checksum, mtime, and length for an existing index file.
4602pub(crate) fn read_index_on_disk_identity(
4603    path: &Path,
4604    hash_algo: HashAlgo,
4605) -> io::Result<IndexOnDiskIdentity> {
4606    match read_index_file(path) {
4607        Ok((data, mtime)) => {
4608            let bytes = data.deref();
4609            Ok(IndexOnDiskIdentity {
4610                checksum: index_file_trailer(bytes, hash_algo),
4611                mtime: Some(mtime),
4612                len: u64::try_from(bytes.len()).unwrap_or(u64::MAX),
4613            })
4614        }
4615        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(IndexOnDiskIdentity {
4616            checksum: None,
4617            mtime: None,
4618            len: 0,
4619        }),
4620        Err(e) => Err(e),
4621    }
4622}
4623
4624/// Whether an opportunistic index write may still replace the on-disk file.
4625///
4626/// Mandatory writers (`write_index`, commit, staging) do not call this.
4627#[must_use]
4628pub(crate) fn opportunistic_index_write_still_current(
4629    loaded: &Index,
4630    on_disk: &IndexOnDiskIdentity,
4631) -> bool {
4632    let had_disk_index = loaded.source_checksum.is_some()
4633        || loaded.source_mtime.is_some()
4634        || loaded.source_file_len.is_some();
4635    if !had_disk_index {
4636        return on_disk.len == 0;
4637    }
4638    if on_disk.len == 0 {
4639        return false;
4640    }
4641    if let (Some(loaded_cs), Some(disk_cs)) = (&loaded.source_checksum, &on_disk.checksum) {
4642        if loaded_cs != disk_cs {
4643            return false;
4644        }
4645        if loaded_cs.iter().all(|&b| b == 0) {
4646            return loaded.source_mtime == on_disk.mtime
4647                && loaded.source_file_len == Some(on_disk.len);
4648        }
4649    }
4650    true
4651}
4652
4653/// Read an index file along with the mtime of the same open file.
4654///
4655/// Large files are mapped read-only: index writers replace the file by renaming a lock file
4656/// over it, so a mapped inode is never truncated underneath the reader.
4657fn read_index_file(path: &Path) -> io::Result<(IndexFileBytes, (u32, u32))> {
4658    use std::io::Read;
4659
4660    let mut file = fs::File::open(path)?;
4661    let meta = file.metadata()?;
4662    let mtime = index_mtime_from_metadata(&meta);
4663    let len = meta.len();
4664    if meta.is_file() && len >= INDEX_MMAP_MIN_BYTES {
4665        if let Ok(len) = usize::try_from(len) {
4666            // SAFETY: the map is only read through `&[u8]`, its length comes from fstat on this
4667            // same file, and index writers swap files with rename instead of rewriting in place.
4668            if let Ok(map) = unsafe { memmap2::MmapOptions::new().len(len).map(&file) } {
4669                return Ok((IndexFileBytes::Mapped(map), mtime));
4670            }
4671        }
4672    }
4673    let mut bytes = Vec::with_capacity(usize::try_from(len).unwrap_or(0));
4674    file.read_to_end(&mut bytes)?;
4675    Ok((IndexFileBytes::Owned(bytes), mtime))
4676}
4677
4678fn max_stage0_entry_mtime(entries: &[IndexEntry]) -> (u32, u32) {
4679    let mut max_sec = 0u32;
4680    let mut max_nsec = 0u32;
4681    for ie in entries {
4682        if ie.stage() != 0 {
4683            continue;
4684        }
4685        if ie.mtime_sec > max_sec || (ie.mtime_sec == max_sec && ie.mtime_nsec > max_nsec) {
4686            max_sec = ie.mtime_sec;
4687            max_nsec = ie.mtime_nsec;
4688        }
4689    }
4690    (max_sec, max_nsec)
4691}
4692
4693/// Maximum stage-0 mtime across two `(sec, nsec)` pairs.
4694#[must_use]
4695pub(crate) fn max_stage0_mtime_pair(a: (u32, u32), b: (u32, u32)) -> (u32, u32) {
4696    if a.0 > b.0 || (a.0 == b.0 && a.1 > b.1) {
4697        a
4698    } else {
4699        b
4700    }
4701}
4702
4703/// Stage-0 mtime maximum in `entries`.
4704pub(crate) fn stage0_mtime_max(entries: &[IndexEntry]) -> (u32, u32) {
4705    max_stage0_entry_mtime(entries)
4706}
4707
4708/// Advance the on-disk index mtime to be strictly newer than every stage-0 entry mtime.
4709///
4710/// This only acts when the index write lands on the same timestamp tick as the newest entry
4711/// (or earlier), as coarse filesystem clocks allow, and moves it the smallest step past that
4712/// entry: one tick of the filesystem clock, which is 100 ns on NTFS (a 1 ns step would be
4713/// truncated back onto the entry's own tick there). That step clears raciness only under
4714/// `core.usenanosec` (the default on Windows, see [`StatMatchPolicy::DEFAULT_USE_NANOSEC`]).
4715/// Under the whole-second rule, entries in the index's own second stay racy, and the next
4716/// status verifies them by content once and then rewrites the index. Never move the mtime
4717/// into a later second: whole-second stat matching would then hide an edit made within the
4718/// entry's second. When the newest entry sits on the last tick of its second, the index is
4719/// left as it is and those entries stay racy.
4720///
4721/// When `touched_only_max` is set, it must be the true maximum stage-0 mtime in the index after
4722/// checkout (not just the touched entries), or later switch prepare may treat paths as racy.
4723///
4724/// # Errors
4725///
4726/// Returns [`Error::Io`] when the index timestamp cannot be updated.
4727pub(crate) fn advance_index_file_mtime_past_entries(
4728    index: &Index,
4729    index_path: &Path,
4730    touched_only_max: Option<(u32, u32)>,
4731) -> Result<()> {
4732    use filetime::FileTime;
4733    let (max_sec, max_nsec) = match touched_only_max {
4734        Some(pair) => pair,
4735        None => max_stage0_entry_mtime(&index.entries),
4736    };
4737    if max_sec == 0 && max_nsec == 0 {
4738        return Ok(());
4739    }
4740    let needs_bump = index_file_mtime(index_path)
4741        .is_none_or(|(sec, nsec)| sec < max_sec || (sec == max_sec && nsec <= max_nsec));
4742    if !needs_bump {
4743        return Ok(());
4744    }
4745    const TICK_NSEC: u32 = if cfg!(windows) { 100 } else { 1 };
4746    if max_nsec >= 1_000_000_000 - TICK_NSEC {
4747        return Ok(());
4748    }
4749    let ft = FileTime::from_unix_time(i64::from(max_sec), max_nsec + TICK_NSEC);
4750    filetime::set_file_mtime(index_path, ft).map_err(Error::Io)
4751}
4752
4753/// String form of an index path as surfaced in porcelain outcomes (UTF-8 or lossy).
4754#[must_use]
4755pub(crate) fn index_path_outcome_key(path: &[u8]) -> Cow<'_, str> {
4756    match std::str::from_utf8(path) {
4757        Ok(s) => Cow::Borrowed(s),
4758        Err(_) => Cow::Owned(String::from_utf8_lossy(path).into_owned()),
4759    }
4760}
4761
4762/// Join a repository work tree root with an index-relative path stored as raw bytes.
4763///
4764/// Index paths may be non-UTF-8 on Unix; this preserves bytes when building the absolute path.
4765#[must_use]
4766pub fn worktree_path_from_index_rel(work_tree: &Path, rel: &[u8]) -> PathBuf {
4767    #[cfg(unix)]
4768    {
4769        use std::ffi::OsStr;
4770        use std::os::unix::ffi::OsStrExt;
4771        work_tree.join(OsStr::from_bytes(rel))
4772    }
4773    #[cfg(not(unix))]
4774    {
4775        let rel = std::str::from_utf8(rel)
4776            .map(Cow::Borrowed)
4777            .unwrap_or_else(|_| Cow::Owned(String::from_utf8_lossy(rel).into_owned()));
4778        work_tree.join(rel.as_ref())
4779    }
4780}
4781
4782/// Stat fields copied from a worktree `Metadata` probe during index staging.
4783#[derive(Debug, Clone, Copy)]
4784pub struct IndexStatFields {
4785    ctime_sec: u32,
4786    ctime_nsec: u32,
4787    mtime_sec: u32,
4788    mtime_nsec: u32,
4789    dev: u32,
4790    ino: u32,
4791    uid: u32,
4792    gid: u32,
4793    size: u32,
4794}
4795
4796impl IndexStatFields {
4797    /// Capture the same index fields as `from_metadata` from a Linux fstatat result.
4798    #[cfg(all(target_os = "linux", target_pointer_width = "64"))]
4799    pub(crate) fn from_linux_stat(stat: &libc::stat) -> Self {
4800        Self {
4801            ctime_sec: stat.st_ctime as u32,
4802            ctime_nsec: stat.st_ctime_nsec as u32,
4803            mtime_sec: stat.st_mtime as u32,
4804            mtime_nsec: stat.st_mtime_nsec as u32,
4805            dev: stat.st_dev as u32,
4806            ino: stat.st_ino as u32,
4807            uid: stat.st_uid,
4808            gid: stat.st_gid,
4809            size: munge_index_size(stat.st_size as u64),
4810        }
4811    }
4812
4813    /// Munged size field stored in the index (see [`munge_index_size`]).
4814    #[must_use]
4815    pub(crate) fn index_size(&self) -> u32 {
4816        self.size
4817    }
4818
4819    /// Capture stat fields from filesystem metadata.
4820    #[must_use]
4821    pub fn from_metadata(meta: &fs::Metadata) -> Self {
4822        #[cfg(unix)]
4823        {
4824            use std::os::unix::fs::MetadataExt;
4825            let ((ctime_sec, ctime_nsec), (mtime_sec, mtime_nsec)) = metadata_times(meta);
4826            Self {
4827                ctime_sec,
4828                ctime_nsec,
4829                mtime_sec,
4830                mtime_nsec,
4831                dev: meta.dev() as u32,
4832                ino: meta.ino() as u32,
4833                uid: meta.uid(),
4834                gid: meta.gid(),
4835                size: munge_index_size(meta.size()),
4836            }
4837        }
4838        #[cfg(not(unix))]
4839        {
4840            let ((ctime_sec, ctime_nsec), (mtime_sec, mtime_nsec)) = metadata_times(meta);
4841            Self {
4842                ctime_sec,
4843                ctime_nsec,
4844                mtime_sec,
4845                mtime_nsec,
4846                dev: 0,
4847                ino: 0,
4848                uid: 0,
4849                gid: 0,
4850                size: munge_index_size(meta.len()),
4851            }
4852        }
4853    }
4854
4855    /// Stat fields currently cached on `ie`.
4856    #[must_use]
4857    pub(crate) fn of_entry(ie: &IndexEntry) -> Self {
4858        Self {
4859            ctime_sec: ie.ctime_sec,
4860            ctime_nsec: ie.ctime_nsec,
4861            mtime_sec: ie.mtime_sec,
4862            mtime_nsec: ie.mtime_nsec,
4863            dev: ie.dev,
4864            ino: ie.ino,
4865            uid: ie.uid,
4866            gid: ie.gid,
4867            size: ie.size,
4868        }
4869    }
4870
4871    /// Whether `self` and `other` carry the same stat data under `policy`.
4872    #[must_use]
4873    pub(crate) fn equivalent_to(&self, other: &Self, policy: StatMatchPolicy) -> bool {
4874        if self.size != other.size || self.mtime_sec != other.mtime_sec {
4875            return false;
4876        }
4877        if policy.use_nanosec && policy.check_stat && self.mtime_nsec != other.mtime_nsec {
4878            return false;
4879        }
4880        if policy.trust_ctime && policy.check_stat {
4881            if self.ctime_sec != other.ctime_sec {
4882                return false;
4883            }
4884            if policy.use_nanosec && self.ctime_nsec != other.ctime_nsec {
4885                return false;
4886            }
4887        }
4888        !policy.check_stat
4889            || (self.dev == other.dev
4890                && self.ino == other.ino
4891                && self.uid == other.uid
4892                && self.gid == other.gid)
4893    }
4894
4895    /// Replace the stat data cached on `ie` with these fields (OID, mode and path are kept).
4896    pub(crate) fn store_in(&self, ie: &mut IndexEntry) {
4897        ie.ctime_sec = self.ctime_sec;
4898        ie.ctime_nsec = self.ctime_nsec;
4899        ie.mtime_sec = self.mtime_sec;
4900        ie.mtime_nsec = self.mtime_nsec;
4901        ie.dev = self.dev;
4902        ie.ino = self.ino;
4903        ie.uid = self.uid;
4904        ie.gid = self.gid;
4905        ie.size = self.size;
4906    }
4907
4908    /// Build a stage-0 index entry using cached stat fields.
4909    #[must_use]
4910    pub fn index_entry(self, rel_path: &[u8], oid: ObjectId, mode: u32) -> IndexEntry {
4911        IndexEntry {
4912            ctime_sec: self.ctime_sec,
4913            ctime_nsec: self.ctime_nsec,
4914            mtime_sec: self.mtime_sec,
4915            mtime_nsec: self.mtime_nsec,
4916            dev: self.dev,
4917            ino: self.ino,
4918            mode,
4919            uid: self.uid,
4920            gid: self.gid,
4921            size: self.size,
4922            oid,
4923            flags: rel_path.len().min(0xFFF) as u16,
4924            flags_extended: None,
4925            path: rel_path.to_vec(),
4926            base_index_pos: 0,
4927        }
4928    }
4929}
4930
4931impl From<&fs::Metadata> for IndexStatFields {
4932    fn from(meta: &fs::Metadata) -> Self {
4933        Self::from_metadata(meta)
4934    }
4935}
4936
4937impl IndexStatFields {
4938    /// Whether munged size and mtime match the index row, plus the sub-second mtime/ctime
4939    /// fractions that `policy` compares; ctime seconds and identity fields are ignored.
4940    #[must_use]
4941    #[cfg_attr(not(windows), allow(dead_code))]
4942    pub(crate) fn size_and_times_match_index(
4943        &self,
4944        ie: &IndexEntry,
4945        policy: StatMatchPolicy,
4946    ) -> bool {
4947        if self.size != ie.size || self.mtime_sec != ie.mtime_sec {
4948            return false;
4949        }
4950        if !(policy.use_nanosec && policy.check_stat) {
4951            return true;
4952        }
4953        self.mtime_nsec == ie.mtime_nsec
4954            && (!policy.trust_ctime || self.ctime_nsec == ie.ctime_nsec)
4955    }
4956
4957    /// Whether cached stat fields match a stage-0 index row (Git `ie_match_stat`).
4958    #[must_use]
4959    pub(crate) fn matches_index_entry(&self, ie: &IndexEntry, policy: StatMatchPolicy) -> bool {
4960        if self.size != ie.size {
4961            return false;
4962        }
4963        if self.mtime_sec != ie.mtime_sec {
4964            return false;
4965        }
4966        if policy.use_nanosec && policy.check_stat && self.mtime_nsec != ie.mtime_nsec {
4967            return false;
4968        }
4969        if policy.trust_ctime && policy.check_stat {
4970            if self.ctime_sec != ie.ctime_sec {
4971                return false;
4972            }
4973            if policy.use_nanosec && self.ctime_nsec != ie.ctime_nsec {
4974                return false;
4975            }
4976        }
4977        if policy.check_stat {
4978            // Directory scans on Windows populate identity fields with zero (Git `mingw_stat`
4979            // also stores dev/ino/uid/gid as zero). Do not treat zeroed scan fields as a mismatch
4980            // against index rows written by Git or grit (#1518, #1702).
4981            #[cfg(windows)]
4982            {
4983                if self.ino != ie.ino && self.ino != 0 {
4984                    return false;
4985                }
4986                if self.dev != ie.dev && self.dev != 0 {
4987                    return false;
4988                }
4989                if self.uid != ie.uid && self.uid != 0 {
4990                    return false;
4991                }
4992                if self.gid != ie.gid && self.gid != 0 {
4993                    return false;
4994                }
4995            }
4996            #[cfg(not(windows))]
4997            if self.ino != ie.ino {
4998                return false;
4999            }
5000        }
5001        true
5002    }
5003}
5004
5005/// Build an [`IndexEntry`] from already-obtained metadata.
5006///
5007/// This avoids a redundant `stat()` call when the caller already has
5008/// filesystem metadata (e.g. from `symlink_metadata`).
5009#[must_use]
5010pub fn entry_from_metadata(
5011    meta: &fs::Metadata,
5012    rel_path: &[u8],
5013    oid: ObjectId,
5014    mode: u32,
5015) -> IndexEntry {
5016    #[cfg(unix)]
5017    {
5018        use std::os::unix::fs::MetadataExt;
5019        let ((ctime_sec, ctime_nsec), (mtime_sec, mtime_nsec)) = metadata_times(meta);
5020        IndexEntry {
5021            ctime_sec,
5022            ctime_nsec,
5023            mtime_sec,
5024            mtime_nsec,
5025            dev: meta.dev() as u32,
5026            ino: meta.ino() as u32,
5027            mode,
5028            uid: meta.uid(),
5029            gid: meta.gid(),
5030            size: munge_index_size(meta.size()),
5031            oid,
5032            flags: rel_path.len().min(0xFFF) as u16,
5033            flags_extended: None,
5034            path: rel_path.to_vec(),
5035            base_index_pos: 0,
5036        }
5037    }
5038    #[cfg(not(unix))]
5039    {
5040        let ((ctime_sec, ctime_nsec), (mtime_sec, mtime_nsec)) = metadata_times(meta);
5041        IndexEntry {
5042            ctime_sec,
5043            ctime_nsec,
5044            mtime_sec,
5045            mtime_nsec,
5046            dev: 0,
5047            ino: 0,
5048            mode,
5049            uid: 0,
5050            gid: 0,
5051            size: munge_index_size(meta.len()),
5052            oid,
5053            flags: rel_path.len().min(0xFFF) as u16,
5054            flags_extended: None,
5055            path: rel_path.to_vec(),
5056            base_index_pos: 0,
5057        }
5058    }
5059}
5060
5061/// Convert a `stat` mode to the Git index mode, normalised to one of the
5062/// known constants ([`MODE_REGULAR`], [`MODE_EXECUTABLE`], [`MODE_SYMLINK`]).
5063///
5064/// Only the `S_IFMT` and execute bits are inspected; all other permission bits
5065/// are discarded (Git stores only 644 or 755 for regular files).
5066///
5067/// # Parameters
5068///
5069/// - `raw_mode` — the raw `st_mode` value from `stat(2)`.
5070#[must_use]
5071pub fn normalize_mode(raw_mode: u32) -> u32 {
5072    const S_IFMT: u32 = 0o170000;
5073    const S_IFLNK: u32 = 0o120000;
5074    const S_IFREG: u32 = 0o100000;
5075
5076    let fmt = raw_mode & S_IFMT;
5077    if fmt == S_IFLNK {
5078        return MODE_SYMLINK;
5079    }
5080    if fmt == S_IFREG {
5081        // Executable if any execute bit is set
5082        if raw_mode & 0o111 != 0 {
5083            return MODE_EXECUTABLE;
5084        }
5085        return MODE_REGULAR;
5086    }
5087    // Fallback for everything else (devices, etc.) — treat as regular
5088    MODE_REGULAR
5089}
5090
5091#[cfg(test)]
5092pub(crate) mod index_path_cmp {
5093    use std::cell::Cell;
5094
5095    thread_local! { // hygiene: index path comparison test counter (cfg(test) only)
5096        static PATH_CMP_COUNT: Cell<usize> = const { Cell::new(0) };
5097    }
5098
5099    pub fn reset() {
5100        PATH_CMP_COUNT.with(|c| c.set(0));
5101    }
5102
5103    pub fn take() -> usize {
5104        PATH_CMP_COUNT.with(|c| {
5105            let n = c.get();
5106            c.set(0);
5107            n
5108        })
5109    }
5110
5111    pub fn bump() {
5112        PATH_CMP_COUNT.with(|c| c.set(c.get() + 1));
5113    }
5114}
5115
5116fn record_path_cmp_for_tests() {
5117    #[cfg(test)]
5118    index_path_cmp::bump();
5119}
5120
5121#[cfg(test)]
5122mod tests {
5123    #![allow(clippy::expect_used, clippy::unwrap_used)]
5124
5125    use super::*;
5126    use tempfile::TempDir;
5127
5128    /// Per-thread count of fsmonitor EWAH rebuilds. Thread-local so concurrently running tests
5129    /// that also write fsmonitor indexes do not disturb each other's counts.
5130    pub(super) mod fsmonitor_rebuild_counter {
5131        use std::cell::Cell;
5132
5133        thread_local! { // hygiene: fsmonitor rebuild counter for cfg(test) integration tests
5134            static COUNT: Cell<usize> = const { Cell::new(0) };
5135        }
5136
5137        pub fn reset() {
5138            COUNT.with(|c| c.set(0));
5139        }
5140
5141        pub fn count() -> usize {
5142            COUNT.with(Cell::get)
5143        }
5144
5145        pub fn note_build() {
5146            COUNT.with(|c| c.set(c.get() + 1));
5147        }
5148    }
5149
5150    fn dummy_oid() -> ObjectId {
5151        ObjectId::from_bytes(&[0u8; 20]).unwrap()
5152    }
5153
5154    fn make_entry(path: &str) -> IndexEntry {
5155        IndexEntry {
5156            ctime_sec: 0,
5157            ctime_nsec: 0,
5158            mtime_sec: 0,
5159            mtime_nsec: 0,
5160            dev: 0,
5161            ino: 0,
5162            mode: MODE_REGULAR,
5163            uid: 0,
5164            gid: 0,
5165            size: 0,
5166            oid: dummy_oid(),
5167            flags: path.len().min(0xFFF) as u16,
5168            flags_extended: None,
5169            path: path.as_bytes().to_vec(),
5170            base_index_pos: 0,
5171        }
5172    }
5173
5174    #[test]
5175    fn content_verified_bit_is_in_memory_only() {
5176        let mut index = Index::new();
5177        let mut plain = make_entry("a.txt");
5178        plain.mark_content_verified();
5179        let mut skipped = make_entry("b.txt");
5180        skipped.set_skip_worktree(true);
5181        skipped.mark_content_verified();
5182        index.add_or_replace(plain);
5183        index.add_or_replace(skipped);
5184
5185        let mut only_marked = index.clone();
5186        only_marked.remove(b"b.txt");
5187        let mut out = Vec::new();
5188        only_marked.serialize_into(&mut out).unwrap();
5189        assert_eq!(&out[4..8], &2u32.to_be_bytes(), "the bit alone keeps v2");
5190
5191        out.clear();
5192        index.serialize_into(&mut out).unwrap();
5193        let checksum = hash_index_body(index.hash_algo, &out);
5194        out.extend_from_slice(&checksum);
5195        let reread = Index::parse(&out).unwrap();
5196        assert_eq!(reread.version, 3);
5197        assert!(reread
5198            .entries
5199            .iter()
5200            .all(|e| !e.clone().take_content_verified()));
5201        assert!(reread.get(b"b.txt", 0).unwrap().skip_worktree());
5202
5203        assert!(index.get_mut(b"a.txt", 0).is_some());
5204        assert_eq!(
5205            index.entries[0].flags_extended, None,
5206            "get_mut voids the mark"
5207        );
5208        index.clear_content_verified();
5209        assert!(index.entries[1].skip_worktree());
5210        assert!(!index.entries[1].clone().take_content_verified());
5211    }
5212
5213    #[test]
5214    fn round_trip_empty_index() {
5215        let dir = TempDir::new().unwrap();
5216        let path = dir.path().join("index");
5217
5218        let mut idx = Index::new();
5219        idx.write(&path).unwrap();
5220
5221        let loaded = Index::load(&path).unwrap();
5222        assert_eq!(loaded.entries.len(), 0);
5223    }
5224
5225    #[test]
5226    fn opportunistic_write_still_current_logic() {
5227        use super::{opportunistic_index_write_still_current, IndexOnDiskIdentity};
5228
5229        let mut loaded = Index::new();
5230        loaded.source_checksum = Some(vec![1; 20]);
5231        loaded.source_mtime = Some((1, 0));
5232        loaded.source_file_len = Some(100);
5233        let same = IndexOnDiskIdentity {
5234            checksum: Some(vec![1; 20]),
5235            mtime: Some((1, 0)),
5236            len: 100,
5237        };
5238        assert!(opportunistic_index_write_still_current(&loaded, &same));
5239
5240        let changed = IndexOnDiskIdentity {
5241            checksum: Some(vec![2; 20]),
5242            mtime: Some((1, 0)),
5243            len: 100,
5244        };
5245        assert!(!opportunistic_index_write_still_current(&loaded, &changed));
5246
5247        loaded.source_checksum = Some(vec![0; 20]);
5248        assert!(!opportunistic_index_write_still_current(
5249            &loaded,
5250            &IndexOnDiskIdentity {
5251                checksum: Some(vec![0; 20]),
5252                mtime: Some((2, 0)),
5253                len: 100,
5254            }
5255        ));
5256
5257        let empty_loaded = Index::new();
5258        assert!(opportunistic_index_write_still_current(
5259            &empty_loaded,
5260            &IndexOnDiskIdentity {
5261                checksum: None,
5262                mtime: None,
5263                len: 0,
5264            }
5265        ));
5266        assert!(!opportunistic_index_write_still_current(
5267            &empty_loaded,
5268            &IndexOnDiskIdentity {
5269                checksum: Some(vec![1; 20]),
5270                mtime: Some((1, 0)),
5271                len: 50,
5272            }
5273        ));
5274    }
5275
5276    #[test]
5277    fn read_index_file_maps_only_large_files() {
5278        let dir = TempDir::new().unwrap();
5279        let threshold = usize::try_from(INDEX_MMAP_MIN_BYTES).unwrap();
5280        for (len, mapped) in [(0, false), (threshold - 1, false), (threshold, true)] {
5281            let path = dir.path().join(format!("index-{len}"));
5282            let contents: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
5283            fs::write(&path, &contents).unwrap();
5284            let (bytes, mtime) = read_index_file(&path).unwrap();
5285            assert_eq!(matches!(bytes, IndexFileBytes::Mapped(_)), mapped, "{len}");
5286            assert_eq!(&*bytes, contents.as_slice());
5287            assert_eq!(Some(mtime), index_file_mtime(&path));
5288        }
5289        let missing = read_index_file(&dir.path().join("missing")).err().unwrap();
5290        assert_eq!(missing.kind(), io::ErrorKind::NotFound);
5291    }
5292
5293    #[test]
5294    fn round_trip_with_entries() {
5295        let dir = TempDir::new().unwrap();
5296        let path = dir.path().join("index");
5297
5298        let mut idx = Index::new();
5299        idx.add_or_replace(make_entry("foo.txt"));
5300        idx.add_or_replace(make_entry("bar/baz.txt"));
5301        idx.write(&path).unwrap(); // idx is mut in each test scope
5302
5303        let loaded = Index::load(&path).unwrap();
5304        assert_eq!(loaded.entries.len(), 2);
5305        assert_eq!(loaded.entries[0].path, b"bar/baz.txt");
5306        assert_eq!(loaded.entries[1].path, b"foo.txt");
5307    }
5308
5309    fn overwrite_fixture() -> Index {
5310        let mut idx = Index::new();
5311        for i in 0..200 {
5312            let mut e = make_entry(&format!("d{}/f{i:03}.txt", i % 4));
5313            e.base_index_pos = i + 1;
5314            idx.add_or_replace(e);
5315        }
5316        for stage in 1..=3u16 {
5317            let mut e = make_entry("d0/conflict.txt");
5318            e.flags |= stage << 12;
5319            idx.add_or_replace(e);
5320        }
5321        let leaf =
5322            |name: &str| CacheTreeNode::valid(name.as_bytes().to_vec(), 50, dummy_oid(), vec![]);
5323        idx.cache_tree_root = Some(dummy_oid());
5324        idx.cache_tree = Some(CacheTreeNode::valid(
5325            Vec::new(),
5326            203,
5327            dummy_oid(),
5328            vec![leaf("d0"), leaf("d1"), leaf("d2"), leaf("d3")],
5329        ));
5330        assert!(idx.entries_sorted);
5331        idx
5332    }
5333
5334    fn overwrite_replacements(count: usize) -> Vec<IndexEntry> {
5335        let changed = ObjectId::from_bytes(&[7u8; 20]).unwrap();
5336        let mut out: Vec<IndexEntry> = (0..count)
5337            .map(|i| {
5338                let mut e = make_entry(&format!("d{}/f{:03}.txt", (i * 3) % 4, i * 3));
5339                e.oid = changed;
5340                e.size = 9;
5341                e
5342            })
5343            .collect();
5344        out.sort_by(|a, b| a.path.cmp(&b.path));
5345        out
5346    }
5347
5348    #[test]
5349    fn overwrite_stage0_entries_matches_sorted_merge() {
5350        for count in [3, 64] {
5351            let mut merged = overwrite_fixture();
5352            merged.replace_stage0_entries_batch(overwrite_replacements(count));
5353            let mut overwritten = overwrite_fixture();
5354            overwritten.overwrite_stage0_entries(overwrite_replacements(count));
5355            assert_eq!(overwritten.entries, merged.entries, "count {count}");
5356            assert_eq!(overwritten.cache_tree, merged.cache_tree, "count {count}");
5357            assert_eq!(overwritten.cache_tree_root, merged.cache_tree_root);
5358            assert_eq!(overwritten.entries.len(), 203);
5359        }
5360    }
5361
5362    #[test]
5363    fn overwrite_stage0_entries_keeps_split_positions_and_invalidates_touched_dirs() {
5364        let mut idx = overwrite_fixture();
5365        let target = b"d1/f001.txt".to_vec();
5366        let pos = idx.find_entry_pos(&target, 0).unwrap();
5367        let before = idx.entries[pos].base_index_pos;
5368        let mut e = make_entry("d1/f001.txt");
5369        e.size = 42;
5370        idx.overwrite_stage0_entries(vec![e]);
5371        assert_eq!(idx.entries[pos].size, 42);
5372        assert_eq!(idx.entries[pos].base_index_pos, before);
5373        let tree = idx.cache_tree.as_ref().unwrap();
5374        assert!(!tree.is_valid());
5375        let child = |name: &[u8]| tree.children.iter().find(|c| c.name == name).unwrap();
5376        assert!(!child(b"d1").is_valid());
5377        assert!(child(b"d0").is_valid());
5378    }
5379
5380    #[test]
5381    fn overwrite_stage0_entries_merges_paths_without_a_stage0_row() {
5382        let mut idx = overwrite_fixture();
5383        let mut new_path = make_entry("d2/new.txt");
5384        new_path.size = 1;
5385        idx.overwrite_stage0_entries(vec![make_entry("d0/f000.txt"), new_path]);
5386        assert_eq!(idx.entries.len(), 204);
5387        assert!(idx.get(b"d2/new.txt", 0).is_some());
5388        assert_eq!(
5389            idx.entries
5390                .iter()
5391                .filter(|e| e.path == b"d0/conflict.txt")
5392                .count(),
5393            3
5394        );
5395        assert!(idx.entries.windows(2).all(|w| {
5396            Index::cmp_paths(&w[0].path, &w[1].path)
5397                .then_with(|| w[0].stage().cmp(&w[1].stage()))
5398                .is_lt()
5399        }));
5400    }
5401
5402    #[test]
5403    fn remove_descendants_under_path_drops_nested_only() {
5404        let mut idx = Index::new();
5405        idx.add_or_replace(make_entry("d/e"));
5406        idx.add_or_replace(make_entry("d-other"));
5407        idx.add_or_replace(make_entry("prefix/d"));
5408        idx.remove_descendants_under_path("d");
5409        let paths: Vec<_> = idx.entries.iter().map(|e| e.path.as_slice()).collect();
5410        assert_eq!(paths, vec![b"d-other".as_slice(), b"prefix/d".as_slice()]);
5411    }
5412
5413    #[test]
5414    fn fsmonitor_extension_round_trip_matches_git_layout() {
5415        // Payload from `git -c core.fsmonitor=true status` on a one-file repo (v2 + builtin:fake + EWAH).
5416        let fsmn_payload: [u8; 41] = [
5417            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,
5418            0, 0, 0, 20, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
5419        ];
5420        let (token, dirty) = parse_fsmonitor_extension(&fsmn_payload).unwrap();
5421        assert_eq!(token, "builtin:fake");
5422        assert_eq!(dirty.bit_size, 0);
5423
5424        let dir = TempDir::new().unwrap();
5425        let path = dir.path().join("index");
5426        let mut idx = Index::new();
5427        idx.add_or_replace(make_entry("a"));
5428        if let Some(e) = idx.get_mut(b"a", 0) {
5429            e.set_fsmonitor_valid(true);
5430        }
5431        idx.fsmonitor_last_update = Some(token);
5432        idx.fsmonitor_dirty = Some(dirty);
5433        idx.fsmonitor_raw_extension = Some(fsmn_payload.to_vec());
5434        idx.write(&path).unwrap(); // idx is mut in each test scope
5435
5436        let loaded = Index::load(&path).unwrap();
5437        assert_eq!(
5438            loaded.fsmonitor_last_update.as_deref(),
5439            Some("builtin:fake")
5440        );
5441        assert_eq!(loaded.fsmonitor_dirty.as_ref().map(|b| b.bit_size), Some(0));
5442        assert_eq!(
5443            loaded.fsmonitor_raw_extension.as_deref(),
5444            Some(fsmn_payload.as_slice())
5445        );
5446
5447        let mut body = Vec::new();
5448        loaded.serialize_into(&mut body).unwrap();
5449        assert!(
5450            body.windows(4).any(|w| w == b"FSMN"),
5451            "serialized index must include FSMN"
5452        );
5453        let off = body.windows(4).position(|w| w == b"FSMN").unwrap();
5454        let ext_sz = u32::from_be_bytes(body[off + 4..off + 8].try_into().unwrap()) as usize;
5455        let written = &body[off + 8..off + 8 + ext_sz];
5456        assert_eq!(written, fsmn_payload);
5457    }
5458
5459    #[test]
5460    fn fsmonitor_extension_written_from_token_only_state() {
5461        let dir = TempDir::new().unwrap();
5462        let path = dir.path().join("index");
5463        let mut idx = Index::new();
5464        idx.add_or_replace(make_entry("a"));
5465        idx.set_fsmonitor_last_update(Some("builtin:fake".to_owned()));
5466        idx.write(&path).unwrap(); // idx is mut in each test scope
5467
5468        let data = fs::read(&path).unwrap();
5469        assert!(
5470            data.windows(4).any(|w| w == b"FSMN"),
5471            "token-only enable must emit a full FSMN extension"
5472        );
5473        let loaded = Index::load(&path).unwrap();
5474        assert_eq!(
5475            loaded.fsmonitor_last_update.as_deref(),
5476            Some("builtin:fake")
5477        );
5478    }
5479
5480    #[test]
5481    fn fsmonitor_raw_not_reused_after_token_change() {
5482        let fsmn_payload: [u8; 41] = [
5483            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,
5484            0, 0, 0, 20, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
5485        ];
5486        let mut idx = Index::new();
5487        idx.add_or_replace(make_entry("a"));
5488        idx.fsmonitor_last_update = Some("builtin:fake".to_owned());
5489        idx.fsmonitor_dirty = Some(parse_fsmonitor_extension(&fsmn_payload).unwrap().1);
5490        idx.fsmonitor_raw_extension = Some(fsmn_payload.to_vec());
5491
5492        idx.set_fsmonitor_last_update(Some("last_update_token".to_owned()));
5493        let mut body = Vec::new();
5494        idx.serialize_into(&mut body).unwrap();
5495        let off = body.windows(4).position(|w| w == b"FSMN").unwrap();
5496        let ext_sz = u32::from_be_bytes(body[off + 4..off + 8].try_into().unwrap()) as usize;
5497        let written = &body[off + 8..off + 8 + ext_sz];
5498        let (token, _) = parse_fsmonitor_extension(written).unwrap();
5499        assert_eq!(token, "last_update_token");
5500        assert_ne!(written, fsmn_payload);
5501    }
5502
5503    #[test]
5504    fn fsmonitor_raw_not_reused_after_validity_change() {
5505        let fsmn_payload: [u8; 41] = [
5506            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,
5507            0, 0, 0, 20, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
5508        ];
5509        let mut idx = Index::new();
5510        idx.add_or_replace(make_entry("a"));
5511        idx.fsmonitor_last_update = Some("builtin:fake".to_owned());
5512        idx.fsmonitor_dirty = Some(parse_fsmonitor_extension(&fsmn_payload).unwrap().1);
5513        idx.fsmonitor_raw_extension = Some(fsmn_payload.to_vec());
5514        if let Some(e) = idx.get_mut(b"a", 0) {
5515            e.set_fsmonitor_valid(false);
5516        }
5517        idx.fsmonitor_validity_changed();
5518
5519        let mut body = Vec::new();
5520        idx.serialize_into(&mut body).unwrap();
5521        let off = body.windows(4).position(|w| w == b"FSMN").unwrap();
5522        let ext_sz = u32::from_be_bytes(body[off + 4..off + 8].try_into().unwrap()) as usize;
5523        let written = &body[off + 8..off + 8 + ext_sz];
5524        assert_ne!(written, fsmn_payload);
5525        let (_, dirty) = parse_fsmonitor_extension(written).unwrap();
5526        assert!(dirty.bit_size >= 1);
5527    }
5528
5529    fn make_staged_entry(path: &str, stage: u8) -> IndexEntry {
5530        let mut e = make_entry(path);
5531        e.set_stage(stage);
5532        e
5533    }
5534
5535    /// Naive O(n) index model mirroring pre–binary-search semantics.
5536    struct LinearIndex {
5537        entries: Vec<IndexEntry>,
5538        resolve_undo: Option<BTreeMap<Vec<u8>, ResolveUndoRecord>>,
5539    }
5540
5541    impl LinearIndex {
5542        fn from_index(idx: &Index) -> Self {
5543            Self {
5544                entries: idx.entries.clone(),
5545                resolve_undo: idx.resolve_undo.clone(),
5546            }
5547        }
5548
5549        fn assert_matches(&self, idx: &Index) {
5550            let mut actual = idx.entries.clone();
5551            actual.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.stage().cmp(&b.stage())));
5552            let mut expect = self.entries.clone();
5553            expect.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.stage().cmp(&b.stage())));
5554            assert_eq!(actual, expect);
5555            assert_eq!(idx.resolve_undo, self.resolve_undo);
5556        }
5557
5558        fn add_or_replace(&mut self, entry: IndexEntry) {
5559            if let Some(pos) = self
5560                .entries
5561                .iter()
5562                .position(|e| e.path == entry.path && e.stage() == entry.stage())
5563            {
5564                self.entries[pos] = entry;
5565            } else {
5566                self.entries.push(entry);
5567            }
5568            self.entries
5569                .sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.stage().cmp(&b.stage())));
5570        }
5571
5572        fn remove(&mut self, path: &[u8]) -> bool {
5573            let mut removed_any = false;
5574            for e in &self.entries {
5575                if e.path == path {
5576                    if e.stage() != 0 {
5577                        resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, e);
5578                    }
5579                    removed_any = true;
5580                }
5581            }
5582            if !removed_any {
5583                return false;
5584            }
5585            self.entries.retain(|e| e.path != path);
5586            removed_any
5587        }
5588
5589        fn stage_file(&mut self, entry: IndexEntry) {
5590            let path = entry.path.clone();
5591            for e in &self.entries {
5592                if e.path == path && e.stage() != 0 {
5593                    resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, e);
5594                }
5595            }
5596            self.entries.retain(|e| e.path != path || e.stage() == 0);
5597            self.add_or_replace(entry);
5598        }
5599
5600        fn remove_descendants_under_path(&mut self, path: &str) {
5601            let prefix = path.as_bytes();
5602            if prefix.is_empty() {
5603                return;
5604            }
5605            let plen = prefix.len();
5606            for e in self.entries.iter() {
5607                let ep = e.path.as_slice();
5608                if ep.len() > plen && ep.starts_with(prefix) && ep[plen] == b'/' && e.stage() != 0 {
5609                    resolve_undo::record_resolve_undo_for_entry(&mut self.resolve_undo, e);
5610                }
5611            }
5612            self.entries.retain(|e| {
5613                let ep = e.path.as_slice();
5614                if ep.len() <= plen {
5615                    return true;
5616                }
5617                if !ep.starts_with(prefix) {
5618                    return true;
5619                }
5620                ep[plen] != b'/'
5621            });
5622        }
5623    }
5624
5625    #[test]
5626    fn sorted_ops_match_linear_oracle() {
5627        let paths: Vec<Vec<u8>> = [
5628            "a", "a-b", "a/b", "a/b/c", "aa", "b", "dir/x", "dir/x/y", "dir-xy",
5629        ]
5630        .into_iter()
5631        .map(|p| p.as_bytes().to_vec())
5632        .collect();
5633
5634        let mut idx = Index::new();
5635        let mut oracle = LinearIndex::from_index(&idx);
5636        let mut rng_state: u64 = 376;
5637
5638        fn next_u64(state: &mut u64) -> u64 {
5639            *state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
5640            *state
5641        }
5642
5643        for step in 0..400 {
5644            let op = next_u64(&mut rng_state) % 4;
5645            match op {
5646                0 => {
5647                    let p = &paths[(next_u64(&mut rng_state) as usize) % paths.len()];
5648                    let stage = (next_u64(&mut rng_state) % 4) as u8;
5649                    let mut e = make_staged_entry(std::str::from_utf8(p).unwrap(), stage);
5650                    e.oid = ObjectId::from_bytes(&[(step % 256) as u8; 20]).unwrap();
5651                    idx.add_or_replace(e.clone());
5652                    oracle.add_or_replace(e);
5653                }
5654                1 => {
5655                    let p = &paths[(next_u64(&mut rng_state) as usize) % paths.len()];
5656                    idx.remove(p);
5657                    oracle.remove(p);
5658                }
5659                2 => {
5660                    let p = &paths[(next_u64(&mut rng_state) as usize) % paths.len()];
5661                    let e = make_entry(std::str::from_utf8(p).unwrap());
5662                    idx.stage_file(e.clone());
5663                    oracle.stage_file(e);
5664                }
5665                _ => {
5666                    let p = &paths[(next_u64(&mut rng_state) as usize) % paths.len()];
5667                    let ps = std::str::from_utf8(p).unwrap();
5668                    idx.remove_descendants_under_path(ps);
5669                    oracle.remove_descendants_under_path(ps);
5670                }
5671            }
5672            oracle.assert_matches(&idx);
5673        }
5674    }
5675
5676    #[test]
5677    fn stale_sorted_flag_miss_until_resorted() {
5678        let mut idx = Index::new();
5679        idx.entries.push(make_entry("z"));
5680        idx.entries.push(make_entry("a"));
5681        idx.entries.push(make_entry("b"));
5682        idx.entries_sorted = true;
5683        assert!(!idx.entries_are_sorted());
5684        assert!(idx.get(b"z", 0).is_none());
5685        idx.sort();
5686        assert!(idx.get(b"z", 0).is_some());
5687        assert!(idx.remove(b"z"));
5688        assert!(idx.get(b"z", 0).is_none());
5689    }
5690
5691    #[test]
5692    fn write_restores_canonical_order_when_sorted_flag_is_stale() {
5693        let mut idx = Index::new();
5694        idx.entries.push(make_entry("zzz.txt"));
5695        idx.entries.push(make_entry("aaa.txt"));
5696        idx.entries_sorted = true;
5697        assert!(!idx.entries_are_sorted());
5698        let dir = tempfile::tempdir().expect("tempdir");
5699        let path = dir.path().join("index");
5700        idx.write(&path).expect("write index");
5701        let parsed = Index::load(&path).expect("load index");
5702        assert!(parsed.entries_are_sorted());
5703        assert_eq!(parsed.entries[0].path, b"aaa.txt".as_slice());
5704    }
5705
5706    #[test]
5707    fn find_entry_pos_sorted_miss_is_constant() {
5708        const N: usize = 100_000;
5709        const M: usize = 1_000;
5710        let mut idx = Index::new();
5711        for i in 0..N {
5712            idx.add_or_replace(make_entry(&format!("file/{i:06}.txt")));
5713        }
5714        assert!(idx.entries_sorted);
5715        let misses: Vec<Vec<u8>> = (0..M)
5716            .map(|i| format!("missing/{i:06}.txt").into_bytes())
5717            .collect();
5718        index_path_cmp::reset();
5719        for path in &misses {
5720            assert!(idx.get(path, 0).is_none());
5721        }
5722        let cmps = index_path_cmp::take();
5723        let log_n = (N as f64).log2().ceil() as usize;
5724        let bound = 100 * M * log_n;
5725        assert!(
5726            cmps < bound,
5727            "sorted miss path comparisons {cmps} should stay under {bound} (N={N}, M={M})"
5728        );
5729    }
5730
5731    fn sorted_index(entries: Vec<IndexEntry>) -> Index {
5732        let mut idx = Index::new();
5733        idx.entries = entries;
5734        idx.sort();
5735        idx
5736    }
5737
5738    fn paths_of(entries: &[IndexEntry]) -> Vec<&str> {
5739        entries
5740            .iter()
5741            .map(|e| std::str::from_utf8(&e.path).unwrap())
5742            .collect()
5743    }
5744
5745    #[test]
5746    fn sorted_entries_under_returns_the_directory_run_at_every_stage() {
5747        let idx = sorted_index(vec![
5748            make_entry("a-b"),
5749            make_entry("a.txt"),
5750            make_entry("a/x"),
5751            make_staged_entry("a/y", 2),
5752            make_staged_entry("a/y", 3),
5753            make_entry("a/z/deep"),
5754            make_entry("a0"),
5755            make_entry("ab/c"),
5756        ]);
5757        let run = idx.sorted_entries_under(b"a").unwrap();
5758        assert_eq!(paths_of(run), vec!["a/x", "a/y", "a/y", "a/z/deep"]);
5759        assert_eq!(
5760            paths_of(idx.sorted_entries_under(b"a/z").unwrap()),
5761            vec!["a/z/deep"]
5762        );
5763        assert!(idx.sorted_entries_under(b"a.txt").unwrap().is_empty());
5764        assert!(idx.sorted_entries_under(b"missing").unwrap().is_empty());
5765        assert_eq!(idx.sorted_entries_under(b"").unwrap().len(), 8);
5766    }
5767
5768    #[test]
5769    fn sorted_lookups_decline_unsorted_indexes() {
5770        let mut idx = sorted_index(vec![make_entry("b"), make_entry("a/x")]);
5771        assert_eq!(idx.sorted_contains_path(b"a/x"), Some(true));
5772        assert_eq!(idx.sorted_contains_path(b"a"), Some(false));
5773        idx.mark_entries_unsorted();
5774        assert!(idx.sorted_entries_under(b"a").is_none());
5775        assert_eq!(idx.sorted_contains_path(b"a/x"), None);
5776    }
5777
5778    #[test]
5779    fn sorted_contains_path_matches_conflict_stages() {
5780        let idx = sorted_index(vec![
5781            make_staged_entry("both.txt", 2),
5782            make_staged_entry("both.txt", 3),
5783            make_entry("plain.txt"),
5784        ]);
5785        assert_eq!(idx.sorted_contains_path(b"both.txt"), Some(true));
5786        assert_eq!(idx.sorted_contains_path(b"plain.txt"), Some(true));
5787        assert_eq!(idx.sorted_contains_path(b"both"), Some(false));
5788    }
5789
5790    #[test]
5791    fn has_stage0_descendant_under_skips_conflict_only_directories() {
5792        let idx = sorted_index(vec![
5793            make_staged_entry("conf/f", 1),
5794            make_staged_entry("conf/f", 2),
5795            make_entry("conf.txt"),
5796            make_entry("mixed/a"),
5797            make_staged_entry("mixed/b", 3),
5798        ]);
5799        assert!(!idx.has_stage0_descendant_under("conf"));
5800        assert!(idx.has_stage0_descendant_under("mixed"));
5801        assert!(!idx.has_stage0_descendant_under("conf.txt"));
5802        assert!(!idx.has_stage0_descendant_under("mix"));
5803    }
5804
5805    #[test]
5806    fn unsorted_remove_drops_every_matching_stage_globally() {
5807        let mut idx = Index::new();
5808        idx.mark_entries_unsorted();
5809        idx.entries.push(make_staged_entry("a", 1));
5810        idx.entries.push(make_entry("z"));
5811        idx.entries.push(make_staged_entry("a", 2));
5812        assert!(idx.remove(b"a"));
5813        assert!(idx.get(b"a", 1).is_none());
5814        assert!(idx.get(b"a", 2).is_none());
5815        assert!(idx.get(b"z", 0).is_some());
5816    }
5817
5818    #[test]
5819    fn unsorted_remove_descendants_drops_noncontiguous_matches() {
5820        let mut idx = Index::new();
5821        idx.mark_entries_unsorted();
5822        idx.entries.push(make_entry("a/x"));
5823        idx.entries.push(make_entry("z"));
5824        idx.entries.push(make_entry("a/y"));
5825        idx.remove_descendants_under_path("a");
5826        let paths: Vec<_> = idx.entries.iter().map(|e| e.path.as_slice()).collect();
5827        assert_eq!(paths, vec![b"z".as_slice()]);
5828    }
5829
5830    #[test]
5831    fn sorted_ops_match_linear_oracle_unsorted_noncontiguous() {
5832        let mut idx = Index::new();
5833        idx.mark_entries_unsorted();
5834        idx.entries.push(make_entry("a/x"));
5835        idx.entries.push(make_entry("z"));
5836        idx.entries.push(make_staged_entry("a", 1));
5837        idx.remove(b"a");
5838        idx.entries.push(make_entry("a/y"));
5839        idx.sort();
5840        let mut oracle = LinearIndex::from_index(&idx);
5841        oracle.entries = idx.entries.clone();
5842        oracle.assert_matches(&idx);
5843        idx.remove_descendants_under_path("a");
5844        oracle.remove_descendants_under_path("a");
5845        oracle.assert_matches(&idx);
5846    }
5847
5848    #[test]
5849    fn merge_replacements_preserves_base_index_pos() {
5850        let mut idx = Index::new();
5851        let mut kept = make_entry("path/a.txt");
5852        kept.base_index_pos = 42;
5853        idx.add_or_replace(kept);
5854        let mut replacement = make_entry("path/a.txt");
5855        replacement.oid = dummy_oid();
5856        idx.remove_paths_and_insert([], [replacement]);
5857        assert_eq!(idx.entries[0].base_index_pos, 42);
5858    }
5859
5860    #[test]
5861    fn remove_paths_and_insert_replaces_equal_path_without_duplicates() {
5862        let mut idx = Index::new();
5863        idx.add_or_replace(make_entry("path/a.txt"));
5864        idx.add_or_replace(make_entry("path/b.txt"));
5865        let mut replacement = make_entry("path/a.txt");
5866        replacement.oid = dummy_oid();
5867        idx.remove_paths_and_insert(["ghost.txt".as_bytes()], [replacement.clone()]);
5868        assert_eq!(idx.entries.len(), 2);
5869        assert_eq!(idx.entries[0].path, b"path/a.txt");
5870        assert_eq!(idx.entries[0].oid, replacement.oid);
5871        assert_eq!(idx.entries[1].path, b"path/b.txt");
5872    }
5873
5874    #[test]
5875    fn remove_directory_prefixes_drops_nested_paths() {
5876        let mut idx = Index::new();
5877        idx.add_or_replace(make_entry("d005/f0.txt"));
5878        idx.add_or_replace(make_entry("d005/f1.txt"));
5879        idx.add_or_replace(make_entry("d006/f0.txt"));
5880        idx.add_or_replace(make_entry("keep.txt"));
5881        idx.remove_directory_prefixes(&BTreeSet::from(["d005".to_owned()]));
5882        assert!(idx.get(b"d005/f0.txt", 0).is_none());
5883        assert!(idx.get(b"d005/f1.txt", 0).is_none());
5884        assert!(idx.get(b"d006/f0.txt", 0).is_some());
5885        assert!(idx.get(b"keep.txt", 0).is_some());
5886    }
5887
5888    #[test]
5889    fn remove_prefixes_paths_and_insert_combines_prefix_exact_and_replacements() {
5890        let mut idx = Index::new();
5891        idx.add_or_replace(make_entry("d005/f0.txt"));
5892        idx.add_or_replace(make_entry("d005/f1.txt"));
5893        idx.add_or_replace(make_entry("d006/f0.txt"));
5894        idx.add_or_replace(make_entry("solo.txt"));
5895        let mut replacement = make_entry("solo.txt");
5896        replacement.oid = dummy_oid();
5897        idx.remove_prefixes_paths_and_insert(
5898            &BTreeSet::from(["d005".to_owned()]),
5899            [b"d006/f0.txt".as_slice()],
5900            [replacement.clone()],
5901        );
5902        assert!(idx.get(b"d005/f0.txt", 0).is_none());
5903        assert!(idx.get(b"d005/f1.txt", 0).is_none());
5904        assert!(idx.get(b"d006/f0.txt", 0).is_none());
5905        assert_eq!(idx.get(b"solo.txt", 0).unwrap().oid, replacement.oid);
5906    }
5907
5908    #[test]
5909    fn remove_directory_prefixes_does_not_skip_lexically_later_prefix_children() {
5910        let mut idx = Index::new();
5911        idx.add_or_replace(make_entry("d005/f0.txt"));
5912        idx.add_or_replace(make_entry("d005/f1.txt"));
5913        idx.remove_directory_prefixes(&BTreeSet::from(["d005".to_owned(), "d0050".to_owned()]));
5914        assert!(idx.get(b"d005/f0.txt", 0).is_none());
5915        assert!(idx.get(b"d005/f1.txt", 0).is_none());
5916    }
5917
5918    #[test]
5919    fn remove_is_sublinear() {
5920        const N: usize = 100_000;
5921        const M: usize = 1_000;
5922        let mut idx = Index::new();
5923        for i in 0..N {
5924            idx.add_or_replace(make_entry(&format!("file/{i:06}.txt")));
5925        }
5926        let paths: Vec<Vec<u8>> = (0..M)
5927            .map(|i| format!("file/{i:06}.txt").into_bytes())
5928            .collect();
5929        index_path_cmp::reset();
5930        idx.remove_paths(paths.iter().map(|p| p.as_slice()));
5931        let cmps = index_path_cmp::take();
5932        let log_n = (N as f64).log2().ceil() as usize;
5933        let bound = 100 * M * log_n;
5934        assert!(
5935            cmps < bound,
5936            "path comparisons {cmps} should stay under {bound} (N={N}, M={M})"
5937        );
5938        assert_eq!(idx.entries.len(), N - M);
5939    }
5940
5941    #[test]
5942    fn fsmonitor_rebuild_once_per_batch() {
5943        let fsmn_payload: [u8; 41] = [
5944            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,
5945            0, 0, 0, 20, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
5946        ];
5947        let (token, dirty) = parse_fsmonitor_extension(&fsmn_payload).unwrap();
5948        let dir = TempDir::new().unwrap();
5949        let path = dir.path().join("index");
5950
5951        let mut idx = Index::new();
5952        for i in 0..1000 {
5953            idx.add_or_replace(make_entry(&format!("path-{i:04}.txt")));
5954        }
5955        idx.sort();
5956        for e in &mut idx.entries {
5957            e.set_fsmonitor_valid(true);
5958        }
5959        idx.fsmonitor_last_update = Some(token);
5960        idx.fsmonitor_dirty = Some(dirty);
5961        idx.fsmonitor_raw_extension = Some(fsmn_payload.to_vec());
5962
5963        fsmonitor_rebuild_counter::reset();
5964        for i in 0..1000 {
5965            idx.add_or_replace(make_entry(&format!("mut-{i:04}.txt")));
5966        }
5967        assert_eq!(
5968            fsmonitor_rebuild_counter::count(),
5969            0,
5970            "entry mutations must not scan entries for the EWAH"
5971        );
5972        idx.write(&path).unwrap(); // idx is mut in each test scope
5973        assert_eq!(
5974            fsmonitor_rebuild_counter::count(),
5975            1,
5976            "write must build the EWAH once for the batch"
5977        );
5978    }
5979
5980    #[test]
5981    fn requested_v4_writes_v4_on_disk() {
5982        let dir = TempDir::new().unwrap();
5983        let path = dir.path().join("index");
5984
5985        let mut idx = Index {
5986            version: 4,
5987            ..Index::default()
5988        };
5989        idx.add_or_replace(make_entry("one"));
5990        idx.add_or_replace(make_entry("two/one"));
5991        idx.write(&path).unwrap(); // idx is mut in each test scope
5992
5993        let data = fs::read(&path).unwrap();
5994        assert_eq!(&data[4..8], &4u32.to_be_bytes());
5995
5996        let loaded = Index::load(&path).unwrap();
5997        assert_eq!(loaded.version, 4);
5998        assert_eq!(loaded.entries[0].path, b"one");
5999        assert_eq!(loaded.entries[1].path, b"two/one");
6000    }
6001
6002    #[test]
6003    fn stat_match_policy_from_repo_config() {
6004        let dir = TempDir::new().unwrap();
6005        let git_dir = dir.path().join(".git");
6006        fs::create_dir_all(&git_dir).unwrap();
6007        fs::write(
6008            git_dir.join("config"),
6009            "[core]\n\ttrustctime = false\n\tcheckstat = minimal\n\tusenanosec = true\n",
6010        )
6011        .unwrap();
6012        let cfg = crate::config::ConfigSet::load(
6013            &crate::environment::Environment::empty(),
6014            Some(&git_dir),
6015            true,
6016        )
6017        .unwrap();
6018        let policy = StatMatchPolicy::from_config(Some(&cfg));
6019        assert!(!policy.trust_ctime);
6020        assert!(!policy.check_stat);
6021        assert!(policy.use_nanosec);
6022    }
6023
6024    fn policy_from_core_config(body: &str) -> StatMatchPolicy {
6025        let dir = TempDir::new().unwrap();
6026        let git_dir = dir.path().join(".git");
6027        fs::create_dir_all(&git_dir).unwrap();
6028        fs::write(git_dir.join("config"), format!("[core]\n{body}")).unwrap();
6029        let cfg = crate::config::ConfigSet::load(
6030            &crate::environment::Environment::empty(),
6031            Some(&git_dir),
6032            true,
6033        )
6034        .unwrap();
6035        StatMatchPolicy::from_config(Some(&cfg))
6036    }
6037
6038    #[test]
6039    fn stat_match_policy_uses_nanoseconds_by_default_on_windows_only() {
6040        assert_eq!(StatMatchPolicy::DEFAULT_USE_NANOSEC, cfg!(windows));
6041        assert_eq!(StatMatchPolicy::default().use_nanosec, cfg!(windows));
6042        assert_eq!(
6043            StatMatchPolicy::from_config(None),
6044            StatMatchPolicy::default()
6045        );
6046        assert_eq!(
6047            policy_from_core_config("\tbare = false\n"),
6048            StatMatchPolicy::default()
6049        );
6050        assert!(!policy_from_core_config("\tusenanosec = false\n").use_nanosec);
6051        assert!(policy_from_core_config("\tusenanosec = true\n").use_nanosec);
6052    }
6053
6054    const NSEC_POLICY: StatMatchPolicy = StatMatchPolicy {
6055        trust_ctime: true,
6056        check_stat: true,
6057        use_nanosec: true,
6058    };
6059    const WHOLE_SECOND_POLICY: StatMatchPolicy = StatMatchPolicy {
6060        use_nanosec: false,
6061        ..NSEC_POLICY
6062    };
6063
6064    fn entry_with_mtime(sec: u32, nsec: u32) -> IndexEntry {
6065        IndexEntry {
6066            mtime_sec: sec,
6067            mtime_nsec: nsec,
6068            ctime_sec: sec,
6069            ctime_nsec: nsec,
6070            size: 42,
6071            ..make_entry("f")
6072        }
6073    }
6074
6075    #[test]
6076    fn nanosecond_policy_trusts_same_second_entries_but_sees_newer_fractions() {
6077        let ie = entry_with_mtime(1_700_000_000, 123_456_700);
6078        let index_mtime = Some((ie.mtime_sec, ie.mtime_nsec + 1));
6079        assert!(!crate::diff::entry_is_racy_with_policy(
6080            &ie,
6081            index_mtime,
6082            NSEC_POLICY
6083        ));
6084        assert!(crate::diff::entry_is_racy_with_policy(
6085            &ie,
6086            Some((ie.mtime_sec, ie.mtime_nsec)),
6087            NSEC_POLICY
6088        ));
6089        assert!(
6090            crate::diff::entry_is_racy_with_policy(&ie, index_mtime, WHOLE_SECOND_POLICY),
6091            "the whole-second rule keeps the index's own second racy"
6092        );
6093
6094        // A same-second, same-size edit is only visible in the fraction.
6095        let mut edited = IndexStatFields::of_entry(&ie);
6096        edited.mtime_nsec += 1;
6097        assert!(IndexStatFields::of_entry(&ie).matches_index_entry(&ie, NSEC_POLICY));
6098        assert!(!edited.matches_index_entry(&ie, NSEC_POLICY));
6099        assert!(edited.matches_index_entry(&ie, WHOLE_SECOND_POLICY));
6100    }
6101
6102    #[test]
6103    fn minimal_checkstat_keeps_whole_second_racy_rule_under_nanosec() {
6104        let minimal = StatMatchPolicy {
6105            check_stat: false,
6106            ..NSEC_POLICY
6107        };
6108        let ie = entry_with_mtime(1_700_000_000, 100_000_000);
6109        let index_mtime = Some((ie.mtime_sec, 500_000_000));
6110        assert!(
6111            crate::diff::entry_is_racy_with_policy(&ie, index_mtime, minimal),
6112            "without fraction equality, a same-second entry must stay racy"
6113        );
6114        let mut edited = IndexStatFields::of_entry(&ie);
6115        edited.mtime_nsec = 200_000_000;
6116        assert!(edited.matches_index_entry(&ie, minimal));
6117        assert!(!crate::diff::entry_is_racy_with_policy(
6118            &ie,
6119            Some((ie.mtime_sec + 1, 0)),
6120            minimal
6121        ));
6122    }
6123
6124    #[test]
6125    fn advancing_index_mtime_never_leaves_the_last_tick_of_a_second() {
6126        let dir = TempDir::new().unwrap();
6127        let index_path = dir.path().join("index");
6128        let sec = 1_700_000_000u32;
6129        let last_tick = if cfg!(windows) {
6130            999_999_900
6131        } else {
6132            999_999_999
6133        };
6134        let mut index = Index::new();
6135        index.add_or_replace(entry_with_mtime(sec, last_tick));
6136        index.write(&index_path).unwrap();
6137        let pinned = filetime::FileTime::from_unix_time(i64::from(sec), last_tick);
6138        filetime::set_file_mtime(&index_path, pinned).unwrap();
6139
6140        advance_index_file_mtime_past_entries(&index, &index_path, None).unwrap();
6141        assert_eq!(index_file_mtime(&index_path), Some((sec, last_tick)));
6142        for policy in [NSEC_POLICY, WHOLE_SECOND_POLICY] {
6143            assert!(crate::diff::entry_is_racy_with_policy(
6144                &index.entries[0],
6145                index_file_mtime(&index_path),
6146                policy
6147            ));
6148        }
6149    }
6150
6151    #[test]
6152    fn size_and_times_match_index_compares_fractions_only_under_nanosec_policy() {
6153        let ie = entry_with_mtime(1_700_000_000, 123_456_700);
6154        let mut scan = IndexStatFields::of_entry(&ie);
6155        scan.ctime_sec += 5;
6156        scan.ino = 99;
6157        assert!(scan.size_and_times_match_index(&ie, NSEC_POLICY));
6158
6159        let mut mtime_frac = IndexStatFields::of_entry(&ie);
6160        mtime_frac.mtime_nsec += 100;
6161        assert!(!mtime_frac.size_and_times_match_index(&ie, NSEC_POLICY));
6162        assert!(mtime_frac.size_and_times_match_index(&ie, WHOLE_SECOND_POLICY));
6163
6164        let mut ctime_frac = IndexStatFields::of_entry(&ie);
6165        ctime_frac.ctime_nsec += 100;
6166        assert!(!ctime_frac.size_and_times_match_index(&ie, NSEC_POLICY));
6167        let no_ctime = StatMatchPolicy {
6168            trust_ctime: false,
6169            ..NSEC_POLICY
6170        };
6171        assert!(ctime_frac.size_and_times_match_index(&ie, no_ctime));
6172
6173        let mut resized = IndexStatFields::of_entry(&ie);
6174        resized.size += 1;
6175        assert!(!resized.size_and_times_match_index(&ie, WHOLE_SECOND_POLICY));
6176    }
6177
6178    #[test]
6179    fn advancing_index_mtime_clears_same_tick_entries_within_their_second() {
6180        let dir = TempDir::new().unwrap();
6181        let index_path = dir.path().join("index");
6182        let (sec, nsec) = (1_700_000_000u32, 123_456_700u32);
6183        let mut index = Index::new();
6184        index.add_or_replace(entry_with_mtime(sec, nsec));
6185        index.write(&index_path).unwrap();
6186        filetime::set_file_mtime(
6187            &index_path,
6188            filetime::FileTime::from_unix_time(i64::from(sec), nsec),
6189        )
6190        .unwrap();
6191        let ie = &index.entries[0];
6192        assert!(crate::diff::entry_is_racy_with_policy(
6193            ie,
6194            index_file_mtime(&index_path),
6195            NSEC_POLICY
6196        ));
6197
6198        advance_index_file_mtime_past_entries(&index, &index_path, None).unwrap();
6199        let bumped = index_file_mtime(&index_path);
6200        assert_eq!(
6201            bumped.map(|(s, _)| s),
6202            Some(sec),
6203            "never leave the entry's second"
6204        );
6205        assert!(
6206            !crate::diff::entry_is_racy_with_policy(ie, bumped, NSEC_POLICY),
6207            "bumped index mtime {bumped:?} must be past the entry's filesystem tick"
6208        );
6209        assert!(crate::diff::entry_is_racy_with_policy(
6210            ie,
6211            bumped,
6212            WHOLE_SECOND_POLICY
6213        ));
6214    }
6215
6216    #[test]
6217    fn index_entry_extended_flags_and_placeholders() {
6218        let mut e = make_entry("p");
6219        e.set_intent_to_add(true);
6220        assert!(e.intent_to_add());
6221        e.set_assume_unchanged(true);
6222        assert!(e.assume_unchanged());
6223        e.set_skip_worktree(true);
6224        assert!(e.skip_worktree());
6225        e.mode = MODE_TREE;
6226        e.set_skip_worktree(true);
6227        assert!(e.is_sparse_directory_placeholder());
6228        e.set_overlay_tree_skip_output(true);
6229        assert!(e.overlay_tree_skip_output());
6230    }
6231
6232    #[test]
6233    fn cache_tree_formatting_and_invalidation_helpers() {
6234        let mut idx = Index::new();
6235        idx.add_or_replace(make_entry("a/b"));
6236        let child = CacheTreeNode::valid(b"a".to_vec(), 1, dummy_oid(), vec![]);
6237        let root = CacheTreeNode::valid(b"".to_vec(), 1, dummy_oid(), vec![child]);
6238        idx.set_cache_tree(root);
6239        let dump = idx.format_cache_tree_dump();
6240        assert!(dump.contains("a/") || dump.contains("invalid"));
6241        idx.invalidate_cache_tree_for_path(b"a/b");
6242        idx.clear_cache_tree();
6243        assert!(idx.cache_tree.is_none());
6244    }
6245
6246    #[test]
6247    fn dedup_paths_and_stage0_descendant_queries() {
6248        let mut idx = Index::new();
6249        idx.add_or_replace(make_entry("dir/x"));
6250        idx.add_or_replace(make_entry("dir/x"));
6251        idx.dedup_paths_keep_last();
6252        assert_eq!(idx.entries.len(), 1);
6253        assert!(idx.has_stage0_descendant_under("dir"));
6254        assert!(!idx.has_stage0_descendant_under("missing"));
6255    }
6256
6257    #[test]
6258    fn load_missing_index_is_empty() {
6259        let dir = TempDir::new().unwrap();
6260        let path = dir.path().join("no-index");
6261        let idx = Index::load(&path).unwrap();
6262        assert!(idx.entries.is_empty());
6263    }
6264
6265    #[test]
6266    fn munge_and_stat_equivalence_helpers() {
6267        assert_eq!(munge_index_size(0x1_0000_0000), 0x8000_0000);
6268        let a = make_entry("x");
6269        let b = make_entry("x");
6270        assert!(index_entries_stat_equivalent(
6271            &a,
6272            &b,
6273            StatMatchPolicy::default()
6274        ));
6275    }
6276
6277    #[test]
6278    fn new_from_config_invalid_index_version_falls_back() {
6279        let dir = TempDir::new().unwrap();
6280        let git_dir = dir.path().join(".git");
6281        fs::create_dir_all(&git_dir).unwrap();
6282        fs::write(
6283            git_dir.join("config"),
6284            "[index]\n\tversion = not-a-number\n",
6285        )
6286        .unwrap();
6287        let cfg = crate::config::ConfigSet::load(
6288            &crate::environment::Environment::empty(),
6289            Some(&git_dir),
6290            true,
6291        )
6292        .unwrap();
6293        let idx = Index::new_from_config(&cfg);
6294        assert_eq!(idx.version, INDEX_CONFIG_INVALID_FALLBACK);
6295    }
6296
6297    #[test]
6298    fn fsmonitor_invalidate_directory_prefix_without_trailing_slash() {
6299        let mut idx = Index::new();
6300        idx.add_or_replace(make_entry("dir/a.txt"));
6301        idx.add_or_replace(make_entry("dir/b.txt"));
6302        idx.set_fsmonitor_last_update(Some("tok".into()));
6303        for entry in idx.entries_mut() {
6304            entry.set_fsmonitor_valid(true);
6305        }
6306        idx.invalidate_fsmonitor_for_path("dir", false);
6307        assert!(!idx.get(b"dir/a.txt", 0).unwrap().fsmonitor_valid());
6308        assert!(!idx.get(b"dir/b.txt", 0).unwrap().fsmonitor_valid());
6309    }
6310
6311    #[test]
6312    fn parse_fsmonitor_extension_rejects_truncated_ewah() {
6313        let mut payload = vec![0, 0, 0, 2, b't', 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0];
6314        let err = parse_fsmonitor_extension(&payload).unwrap_err();
6315        assert!(matches!(
6316            err,
6317            Error::IndexFsmonitorExtensionTruncated | Error::IndexFsmonitorExtensionEwahInvalid
6318        ));
6319        payload[12..16].copy_from_slice(&2u32.to_be_bytes());
6320        let err2 = parse_fsmonitor_extension(&payload).unwrap_err();
6321        assert!(matches!(
6322            err2,
6323            Error::IndexFsmonitorExtensionTruncated | Error::IndexFsmonitorExtensionEwahInvalid
6324        ));
6325    }
6326
6327    #[test]
6328    fn unmerge_from_resolve_undo_installs_conflict_stages() {
6329        use crate::resolve_undo::ResolveUndoRecord;
6330        use std::collections::BTreeMap;
6331
6332        let mut idx = Index::new();
6333        idx.add_or_replace(make_entry("f.txt"));
6334        let oid1 = dummy_oid();
6335        let oid2 = ObjectId::from_bytes(&[1u8; 20]).unwrap();
6336        idx.resolve_undo = Some(BTreeMap::from([(
6337            b"f.txt".to_vec(),
6338            ResolveUndoRecord {
6339                modes: [MODE_REGULAR, MODE_REGULAR, 0],
6340                oids: [oid1, oid2, ObjectId::zero()],
6341            },
6342        )]));
6343        assert!(idx.unmerge_path_from_resolve_undo(b"f.txt"));
6344        assert!(idx.has_unmerged_entries());
6345    }
6346
6347    #[test]
6348    fn parse_rejects_truncated_headers() {
6349        for n in 0..12 {
6350            assert!(Index::parse(&vec![0u8; n]).is_err());
6351        }
6352    }
6353
6354    #[test]
6355    fn entry_from_stat_populates_index_metadata() {
6356        let dir = TempDir::new().unwrap();
6357        fs::write(dir.path().join("f.txt"), b"payload").unwrap();
6358        let oid = dummy_oid();
6359        let entry = entry_from_stat(dir.path(), b"f.txt", oid, MODE_REGULAR).unwrap();
6360        assert_eq!(entry.path, b"f.txt");
6361        assert!(entry.size > 0);
6362    }
6363
6364    #[test]
6365    fn write_to_path_honors_skip_hash_flag() {
6366        let dir = TempDir::new().unwrap();
6367        let path = dir.path().join("index");
6368        let mut idx = Index::new();
6369        idx.add_or_replace(make_entry("x"));
6370        idx.write_to_path(&path, true).unwrap();
6371        let bytes = fs::read(&path).unwrap();
6372        assert!(bytes.ends_with(&[0u8; 20]));
6373    }
6374
6375    #[test]
6376    fn index_lock_acquire_blocks_second_holder() {
6377        let dir = TempDir::new().unwrap();
6378        let path = dir.path().join("index");
6379        let mut idx = Index::new();
6380        idx.add_or_replace(make_entry("tracked.txt"));
6381        let lock = IndexLock::acquire(&path, None).unwrap();
6382        assert!(matches!(
6383            IndexLock::acquire(&path, None),
6384            Err(Error::LockHeld { .. })
6385        ));
6386        drop(lock);
6387        idx.write(&path).unwrap(); // idx is mut in each test scope
6388        assert_eq!(Index::load(&path).unwrap().entries.len(), 1);
6389    }
6390
6391    #[test]
6392    fn held_index_lock_is_a_typed_error_naming_the_holder() {
6393        let dir = TempDir::new().unwrap();
6394        let path = dir.path().join("index");
6395        let lock_path = dir.path().join("index.lock");
6396        fs::write(&lock_path, b"foreign").unwrap();
6397        fs::write(
6398            dir.path().join("index~pid.lock"),
6399            format!("pid {}\n", std::process::id()),
6400        )
6401        .unwrap();
6402
6403        let Err(acquire_err) = IndexLock::acquire(&path, None) else {
6404            panic!("lock must be refused");
6405        };
6406        let write_err = Index::new().write(&path).unwrap_err();
6407        for err in [acquire_err, write_err] {
6408            match &err {
6409                Error::LockHeld { path: p, holder } => {
6410                    assert_eq!(p, &lock_path);
6411                    assert_eq!(
6412                        *holder,
6413                        Some(crate::lockfile::LockHolder {
6414                            pid: u64::from(std::process::id()),
6415                            running: true,
6416                        })
6417                    );
6418                }
6419                other => panic!("expected LockHeld, got {other:?}"),
6420            }
6421            assert!(
6422                err.to_string().contains(&lock_path.display().to_string()),
6423                "{err}"
6424            );
6425        }
6426        assert_eq!(fs::read(&lock_path).unwrap(), b"foreign");
6427        assert!(!path.exists());
6428    }
6429
6430    fn streaming_fixture(version: u32) -> Index {
6431        let mut idx = Index::new();
6432        idx.version = version;
6433        for i in 0..6000 {
6434            idx.add_or_replace(make_entry(&format!("dir{:02}/sub/file-{i:05}.txt", i % 37)));
6435        }
6436        idx
6437    }
6438
6439    fn streaming_config() -> crate::config::ConfigSet {
6440        use crate::config::{ConfigFile, ConfigScope};
6441        let file = ConfigFile::parse(
6442            Path::new("config"),
6443            "[index]\n\tthreads = 4\n",
6444            ConfigScope::Local,
6445        )
6446        .unwrap();
6447        let mut cfg = crate::config::ConfigSet::new();
6448        cfg.merge(&file);
6449        cfg
6450    }
6451
6452    fn in_memory_index_file(
6453        idx: &Index,
6454        cfg: &crate::config::ConfigSet,
6455        policy: crate::parallel::ThreadPolicy,
6456        skip_hash: bool,
6457    ) -> Vec<u8> {
6458        let mut body = IndexBodyOut {
6459            buf: Vec::new(),
6460            drained: 0,
6461            sink: None,
6462        };
6463        idx.serialize_body(&mut body, Some(cfg), policy).unwrap();
6464        let mut file = body.buf;
6465        let checksum = if skip_hash {
6466            vec![0u8; idx.hash_algo.len()]
6467        } else {
6468            idx.hash_algo.checksum_digest(&file)
6469        };
6470        file.extend_from_slice(&checksum);
6471        file
6472    }
6473
6474    #[test]
6475    fn streamed_write_matches_in_memory_serialization_across_chunks() {
6476        let policy = crate::parallel::ThreadPolicy::with_threads_override(Some(4));
6477        let cfg = streaming_config();
6478        for version in [2, 4] {
6479            for skip_hash in [false, true] {
6480                let dir = TempDir::new().unwrap();
6481                let path = dir.path().join("index");
6482                let mut idx = streaming_fixture(version);
6483                let expected = in_memory_index_file(&idx, &cfg, policy, skip_hash);
6484                assert!(
6485                    expected.len() > 3 * INDEX_WRITE_CHUNK,
6486                    "fixture must span several chunks"
6487                );
6488                idx.write_to_path_with_config_and_policy(&path, skip_hash, Some(&cfg), policy)
6489                    .unwrap();
6490                let written = fs::read(&path).unwrap();
6491                assert!(
6492                    written == expected,
6493                    "v{version} skip_hash={skip_hash}: streamed index differs from in-memory body"
6494                );
6495                assert!(!path.with_extension("lock").exists());
6496
6497                let ctx = crate::index::IndexParseContext::from_policy(policy);
6498                let loaded =
6499                    Index::parse_with_algo_and_config(&written, idx.hash_algo, Some(&cfg), ctx)
6500                        .unwrap();
6501                assert_eq!(loaded.entries.len(), idx.entries.len());
6502                assert!(loaded
6503                    .entries
6504                    .iter()
6505                    .zip(&idx.entries)
6506                    .all(|(a, b)| a.path == b.path && a.oid == b.oid));
6507            }
6508        }
6509    }
6510
6511    #[test]
6512    fn streamed_write_through_held_lock_matches_in_memory_serialization() {
6513        let policy = crate::parallel::ThreadPolicy::with_threads_override(Some(4));
6514        let cfg = streaming_config();
6515        let dir = TempDir::new().unwrap();
6516        let path = dir.path().join("index");
6517        let mut idx = streaming_fixture(2);
6518        let expected = in_memory_index_file(&idx, &cfg, policy, false);
6519
6520        let mut lock = IndexLock::acquire(&path, Some(&cfg)).unwrap();
6521        idx.write_to_path_with_config_and_lock_mode_policy(
6522            &path,
6523            false,
6524            Some(&cfg),
6525            Some(&mut lock),
6526            false,
6527            policy,
6528        )
6529        .unwrap();
6530        assert!(
6531            !path.exists(),
6532            "uncommitted lock must not replace the index"
6533        );
6534        assert!(fs::read(path.with_extension("lock")).unwrap() == expected);
6535        lock.commit_replace_index().unwrap();
6536        assert!(fs::read(&path).unwrap() == expected);
6537        assert!(!path.with_extension("lock").exists());
6538    }
6539
6540    #[test]
6541    fn index_body_out_drains_only_when_full_and_tracks_offsets() {
6542        let mut chunks: Vec<usize> = Vec::new();
6543        let mut sink = |chunk: &[u8]| -> io::Result<()> {
6544            chunks.push(chunk.len());
6545            Ok(())
6546        };
6547        let mut out = IndexBodyOut {
6548            buf: Vec::new(),
6549            drained: 0,
6550            sink: Some(&mut sink),
6551        };
6552        out.buf.resize(INDEX_WRITE_CHUNK - 1, 0);
6553        out.drain_if_full().unwrap();
6554        assert_eq!(out.position(), INDEX_WRITE_CHUNK - 1);
6555        out.buf.extend_from_slice(&[1, 2]);
6556        out.drain_if_full().unwrap();
6557        assert!(out.buf.is_empty());
6558        assert_eq!(out.position(), INDEX_WRITE_CHUNK + 1);
6559        out.buf.push(3);
6560        out.drain().unwrap();
6561        out.drain().unwrap();
6562        assert_eq!(out.position(), INDEX_WRITE_CHUNK + 2);
6563        assert_eq!(chunks, vec![INDEX_WRITE_CHUNK + 1, 1]);
6564
6565        let mut in_memory = IndexBodyOut {
6566            buf: vec![0; INDEX_WRITE_CHUNK * 2],
6567            drained: 0,
6568            sink: None,
6569        };
6570        in_memory.drain_if_full().unwrap();
6571        in_memory.drain().unwrap();
6572        assert_eq!(in_memory.buf.len(), INDEX_WRITE_CHUNK * 2);
6573        assert_eq!(in_memory.position(), INDEX_WRITE_CHUNK * 2);
6574    }
6575
6576    #[test]
6577    fn write_with_config_and_parallel_policy() {
6578        let dir = TempDir::new().unwrap();
6579        let path = dir.path().join("index");
6580        let mut idx = Index::new();
6581        idx.add_or_replace(make_entry("p"));
6582        let cfg = crate::config::ConfigSet::new();
6583        idx.write_with_config_and_policy(&path, &cfg, crate::parallel::ThreadPolicy::default())
6584            .unwrap();
6585        assert!(path.is_file());
6586    }
6587
6588    #[test]
6589    fn fsmonitor_sync_and_clear_helpers() {
6590        let mut idx = Index::new();
6591        idx.add_or_replace(make_entry("a"));
6592        idx.set_fsmonitor_last_update(Some("token".to_owned()));
6593        idx.sync_fsmonitor_dirty_bitmap();
6594        if let Some(e) = idx.get_mut(b"a", 0) {
6595            e.set_fsmonitor_valid(false);
6596        }
6597        idx.fsmonitor_validity_changed();
6598        idx.clear_fsmonitor_extension();
6599        assert!(idx.fsmonitor_last_update.is_none());
6600    }
6601
6602    #[test]
6603    fn intent_to_add_entry_forces_empty_blob_on_write_tree_path() {
6604        let dir = TempDir::new().unwrap();
6605        let odb = crate::odb::Odb::new(dir.path());
6606        let mut idx = Index::new();
6607        let mut e = make_entry("ita.txt");
6608        e.set_intent_to_add(true);
6609        idx.add_or_replace(e);
6610        crate::write_tree::write_tree_from_index(&odb, &idx, "").unwrap();
6611    }
6612
6613    #[test]
6614    fn optional_load_is_empty_only_when_the_index_is_missing() {
6615        let dir = TempDir::new().unwrap();
6616        let odb = crate::odb::Odb::new(dir.path());
6617        let path = dir.path().join("index");
6618        let missing = Index::load_expand_sparse_optional(&path, &odb).unwrap();
6619        assert!(missing.entries.is_empty());
6620
6621        for len in [0, 12, 31] {
6622            std::fs::write(&path, b"DIRC\0\0\0\x02\0\0\0\0".repeat(3)[..len].to_vec()).unwrap();
6623            assert!(
6624                matches!(
6625                    Index::load_expand_sparse_optional(&path, &odb),
6626                    Err(Error::IndexError(_))
6627                ),
6628                "len={len}"
6629            );
6630        }
6631    }
6632
6633    fn naive_merge_sorted(kept: &[IndexEntry], insert: &[IndexEntry]) -> Vec<IndexEntry> {
6634        let mut out: Vec<IndexEntry> = kept.iter().chain(insert.iter()).cloned().collect();
6635        out.sort_by(index_entry_path_stage_cmp);
6636        let mut deduped = Vec::with_capacity(out.len());
6637        for entry in out {
6638            if deduped
6639                .last()
6640                .is_some_and(|last| index_entry_path_stage_cmp(last, &entry).is_eq())
6641            {
6642                *deduped.last_mut().unwrap() = entry;
6643            } else {
6644                deduped.push(entry);
6645            }
6646        }
6647        deduped
6648    }
6649
6650    #[test]
6651    fn merge_sorted_index_halves_matches_naive_merge() {
6652        let paths = [
6653            ".gitattributes",
6654            "a.txt",
6655            "b.txt",
6656            "f.txt",
6657            "z.txt",
6658            "dir/nested.txt",
6659        ];
6660        let mut kept = Vec::new();
6661        let mut insert = Vec::new();
6662        for (i, path) in paths.iter().enumerate() {
6663            let mut e = make_entry(path);
6664            e.oid = ObjectId::from_bytes(&[i as u8; 20]).unwrap();
6665            kept.push(e);
6666        }
6667        for (i, path) in paths.iter().enumerate() {
6668            if i % 2 == 0 {
6669                continue;
6670            }
6671            let mut e = make_entry(path);
6672            e.oid = ObjectId::from_bytes(&[(i + 100) as u8; 20]).unwrap();
6673            insert.push(e);
6674        }
6675        kept.sort_by(index_entry_path_stage_cmp);
6676        insert.sort_by(index_entry_path_stage_cmp);
6677
6678        let expected = naive_merge_sorted(&kept, &insert);
6679        let merged = merge_sorted_index_entries(kept, insert);
6680        assert_eq!(merged, expected, "fixed scenario merge mismatch");
6681
6682        for seed in 0u64..200 {
6683            let mut kept = Vec::new();
6684            let mut insert = Vec::new();
6685            for n in 0..((seed % 7) + 1) {
6686                let path = format!("p/{seed}/{n}.txt");
6687                let mut e = make_entry(&path);
6688                e.oid = ObjectId::from_bytes(&[(seed as u8).wrapping_add(n as u8); 20]).unwrap();
6689                kept.push(e);
6690            }
6691            for n in 0..((seed % 5) + 1) {
6692                let path = format!("p/{seed}/{n}.txt");
6693                let mut e = make_entry(&path);
6694                e.oid = ObjectId::from_bytes(
6695                    &[(seed as u8).wrapping_add(n as u8).wrapping_add(50); 20],
6696                )
6697                .unwrap();
6698                insert.push(e);
6699            }
6700            kept.sort_by(index_entry_path_stage_cmp);
6701            insert.sort_by(index_entry_path_stage_cmp);
6702            let expected = naive_merge_sorted(&kept, &insert);
6703            let merged = merge_sorted_index_entries(kept, insert);
6704            assert_eq!(merged, expected, "seed {seed} merge mismatch");
6705        }
6706    }
6707
6708    mod sparse_collapse {
6709        use super::*;
6710
6711        const PATTERNS: [&str; 3] = ["/*", "!/*/", "/a/"];
6712
6713        fn patterns() -> Vec<String> {
6714            PATTERNS.iter().map(|p| (*p).to_owned()).collect()
6715        }
6716
6717        /// Write a tree holding `files` (`path`, `mode`, blob body) and return its OID.
6718        fn write_tree(odb: &Odb, files: &[(&str, u32, &str)]) -> ObjectId {
6719            let mut here: BTreeMap<Vec<u8>, (u32, ObjectId)> = BTreeMap::new();
6720            let mut subdirs: BTreeMap<&str, Vec<(&str, u32, &str)>> = BTreeMap::new();
6721            for (path, mode, body) in files {
6722                match path.split_once('/') {
6723                    Some((dir, rest)) => subdirs.entry(dir).or_default().push((rest, *mode, body)),
6724                    None => {
6725                        let oid = if *mode == MODE_GITLINK {
6726                            ObjectId::from_bytes(&[7u8; 20]).unwrap()
6727                        } else {
6728                            odb.write(ObjectKind::Blob, body.as_bytes()).unwrap()
6729                        };
6730                        here.insert(path.as_bytes().to_vec(), (*mode, oid));
6731                    }
6732                }
6733            }
6734            for (dir, children) in subdirs {
6735                here.insert(
6736                    dir.as_bytes().to_vec(),
6737                    (MODE_TREE, write_tree(odb, &children)),
6738                );
6739            }
6740            let mut entries: Vec<TreeEntry> = here
6741                .into_iter()
6742                .map(|(name, (mode, oid))| TreeEntry { mode, name, oid })
6743                .collect();
6744            entries.sort_by(|a, b| {
6745                crate::objects::tree_entry_cmp(
6746                    &a.name,
6747                    a.mode == MODE_TREE,
6748                    &b.name,
6749                    b.mode == MODE_TREE,
6750                )
6751            });
6752            odb.write(ObjectKind::Tree, &crate::objects::serialize_tree(&entries))
6753                .unwrap()
6754        }
6755
6756        fn subtree(odb: &Odb, root: &ObjectId, prefix: &str) -> ObjectId {
6757            PrefixTreeResolver::new(*root)
6758                .resolve(odb, prefix.as_bytes())
6759                .unwrap()
6760                .unwrap()
6761        }
6762
6763        /// The fully expanded index for `root`, with everything outside `a/` and the top
6764        /// level marked skip-worktree.
6765        fn expanded_index(odb: &Odb, root: &ObjectId) -> Index {
6766            let mut index = Index::new();
6767            let rows = flatten_tree_blobs(
6768                odb,
6769                root,
6770                b"",
6771                crate::path_validate::ProtectOptions::default(),
6772            )
6773            .unwrap();
6774            for mut row in rows {
6775                let in_cone = !row.path.contains(&b'/') || row.path.starts_with(b"a/");
6776                row.set_skip_worktree(!in_cone);
6777                index.entries_mut().push(row);
6778            }
6779            index.sort();
6780            index
6781        }
6782
6783        fn collapse(index: &mut Index, odb: &Odb, root: &ObjectId) {
6784            index
6785                .try_collapse_sparse_directories(
6786                    odb,
6787                    root,
6788                    &patterns(),
6789                    true,
6790                    true,
6791                    crate::path_validate::ProtectOptions::default(),
6792                )
6793                .unwrap();
6794        }
6795
6796        fn rows(index: &Index) -> Vec<(String, u32, ObjectId)> {
6797            index
6798                .entries()
6799                .iter()
6800                .map(|e| (String::from_utf8_lossy(&e.path).into_owned(), e.mode, e.oid))
6801                .collect()
6802        }
6803
6804        fn placeholder_paths(index: &Index) -> Vec<String> {
6805            index
6806                .entries()
6807                .iter()
6808                .filter(|e| e.is_sparse_directory_placeholder())
6809                .map(|e| String::from_utf8_lossy(&e.path).into_owned())
6810                .collect()
6811        }
6812
6813        const FILES: [(&str, u32, &str); 8] = [
6814            ("a/in", 0o100644, "in\n"),
6815            ("top", 0o100644, "top\n"),
6816            ("w/f4", 0o100644, "f4\n"),
6817            ("w/f5", 0o100755, "f5\n"),
6818            ("x/f1", 0o100644, "f1\n"),
6819            ("x/y/f2", 0o100644, "f2\n"),
6820            ("x/y/z/f3", 0o100644, "f3\n"),
6821            ("x/y-z", 0o100644, "dash\n"),
6822        ];
6823
6824        #[test]
6825        fn unchanged_excluded_directories_collapse_to_their_outermost_placeholder() {
6826            let dir = TempDir::new().unwrap();
6827            let odb = Odb::new(dir.path());
6828            let root = write_tree(&odb, &FILES);
6829            let mut index = expanded_index(&odb, &root);
6830            collapse(&mut index, &odb, &root);
6831
6832            assert_eq!(
6833                rows(&index),
6834                vec![
6835                    ("a/in".to_owned(), 0o100644, index.entries()[0].oid),
6836                    ("top".to_owned(), 0o100644, index.entries()[1].oid),
6837                    ("w/".to_owned(), MODE_TREE, subtree(&odb, &root, "w")),
6838                    ("x/".to_owned(), MODE_TREE, subtree(&odb, &root, "x")),
6839                ]
6840            );
6841            assert!(index.sparse_directories);
6842            assert!(index.entries_are_sorted());
6843        }
6844
6845        #[test]
6846        fn a_changed_row_keeps_its_ancestors_expanded_but_not_its_siblings() {
6847            let dir = TempDir::new().unwrap();
6848            let odb = Odb::new(dir.path());
6849            let root = write_tree(&odb, &FILES);
6850            let mut index = expanded_index(&odb, &root);
6851            let edited = odb.write(ObjectKind::Blob, b"edited\n").unwrap();
6852            let pos = index.find_entry_pos(b"x/f1", 0).unwrap();
6853            index.entries_mut()[pos].oid = edited;
6854            index.sort();
6855            collapse(&mut index, &odb, &root);
6856
6857            assert_eq!(
6858                placeholder_paths(&index),
6859                vec!["w/".to_owned(), "x/y/".to_owned()]
6860            );
6861            let kept: Vec<String> = rows(&index).into_iter().map(|(p, _, _)| p).collect();
6862            assert_eq!(kept, ["a/in", "top", "w/", "x/f1", "x/y-z", "x/y/"]);
6863            let f1 = &index.entries()[index.find_entry_pos(b"x/f1", 0).unwrap()];
6864            assert_eq!(f1.oid, edited);
6865            let y = &index.entries()[index.find_entry_pos(b"x/y/", 0).unwrap()];
6866            assert_eq!(y.oid, subtree(&odb, &root, "x/y"));
6867        }
6868
6869        #[test]
6870        fn directories_with_worktree_rows_gitlinks_or_conflicts_stay_expanded() {
6871            let dir = TempDir::new().unwrap();
6872            let odb = Odb::new(dir.path());
6873            let mut files = FILES.to_vec();
6874            files.push(("v/sub", MODE_GITLINK, ""));
6875            files.push(("u/f6", 0o100644, "f6\n"));
6876            let root = write_tree(&odb, &files);
6877            let mut index = expanded_index(&odb, &root);
6878            let pos = index.find_entry_pos(b"w/f4", 0).unwrap();
6879            index.entries_mut()[pos].set_skip_worktree(false);
6880            let pos = index.find_entry_pos(b"u/f6", 0).unwrap();
6881            let mut theirs = index.entries()[pos].clone();
6882            index.entries_mut()[pos].set_stage(2);
6883            theirs.set_stage(3);
6884            index.entries_mut().push(theirs);
6885            index.sort();
6886            collapse(&mut index, &odb, &root);
6887
6888            assert_eq!(placeholder_paths(&index), vec!["x/".to_owned()]);
6889            for path in ["u/f6", "v/sub", "w/f4", "w/f5"] {
6890                assert!(
6891                    index.entries().iter().any(|e| e.path == path.as_bytes()),
6892                    "{path} must stay expanded"
6893                );
6894            }
6895        }
6896
6897        #[test]
6898        fn existing_placeholders_count_only_when_they_match_head() {
6899            let dir = TempDir::new().unwrap();
6900            let odb = Odb::new(dir.path());
6901            let root = write_tree(&odb, &FILES);
6902            let y = subtree(&odb, &root, "x/y");
6903
6904            let with_y_placeholder = |oid: ObjectId| {
6905                let mut index = expanded_index(&odb, &root);
6906                index.entries_mut().retain(|e| !e.path.starts_with(b"x/y/"));
6907                let mut placeholder = index.entries()[0].clone();
6908                placeholder.path = b"x/y/".to_vec();
6909                placeholder.mode = MODE_TREE;
6910                placeholder.oid = oid;
6911                placeholder.flags = 4;
6912                placeholder.set_skip_worktree(true);
6913                index.entries_mut().push(placeholder);
6914                index.sort();
6915                index
6916            };
6917
6918            let mut matching = with_y_placeholder(y);
6919            collapse(&mut matching, &odb, &root);
6920            assert_eq!(
6921                placeholder_paths(&matching),
6922                vec!["w/".to_owned(), "x/".to_owned()]
6923            );
6924
6925            let stale = odb.write(ObjectKind::Tree, b"").unwrap();
6926            let mut outdated = with_y_placeholder(stale);
6927            collapse(&mut outdated, &odb, &root);
6928            assert_eq!(
6929                placeholder_paths(&outdated),
6930                vec!["w/".to_owned(), "x/y/".to_owned()]
6931            );
6932            let y_row = &outdated.entries()[outdated.find_entry_pos(b"x/y/", 0).unwrap()];
6933            assert_eq!(y_row.oid, stale, "an outdated placeholder is left alone");
6934            assert!(outdated.entries().iter().any(|e| e.path == b"x/f1"));
6935        }
6936
6937        #[test]
6938        fn collapsed_directories_subsume_nested_ones_and_cover_their_rows() {
6939            let oid = ObjectId::from_bytes(&[1u8; 20]).unwrap();
6940            let mut collapsed = CollapsedDirectories::default();
6941            collapsed.insert(b"a/b/".to_vec(), oid);
6942            collapsed.insert(b"a/c/".to_vec(), oid);
6943            assert!(collapsed.has_descendants(b"a/"));
6944            assert!(!collapsed.has_descendants(b"a/b/"));
6945            collapsed.insert(b"a/".to_vec(), oid);
6946            assert!(!collapsed.has_descendants(b"a/"));
6947            assert_eq!(collapsed.dirs.len(), 1);
6948
6949            let mut scratch = Vec::new();
6950            for (path, covered) in [
6951                (&b"a/x"[..], true),
6952                (b"a/b/c", true),
6953                (b"a", true),
6954                (b"a-b", false),
6955                (b"ab/c", false),
6956                (b"0", false),
6957            ] {
6958                assert_eq!(
6959                    collapsed.covers(path, &mut scratch),
6960                    covered,
6961                    "{}",
6962                    String::from_utf8_lossy(path)
6963                );
6964            }
6965
6966            let entry = |path: &str| IndexEntry {
6967                path: path.as_bytes().to_vec(),
6968                ..make_entry(path)
6969            };
6970            let entries = vec![
6971                entry("0"),
6972                entry("a"),
6973                entry("a-b"),
6974                entry("a/b/c"),
6975                entry("ab"),
6976            ];
6977            assert_eq!(
6978                collapsed.covered_rows(&entries),
6979                vec![false, true, false, true, false]
6980            );
6981        }
6982    }
6983}