Skip to main content

grit_lib/
index.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::collections::BTreeSet;
19use std::fs;
20use std::io::{self, Write};
21use std::path::Path;
22
23use sha1::{Digest, Sha1};
24
25use crate::config::ConfigSet;
26use crate::error::{Error, Result};
27use crate::objects::{parse_tree, ObjectId, ObjectKind, TreeEntry};
28use crate::odb::Odb;
29use crate::repo::Repository;
30use crate::rev_parse;
31
32/// File mode for a regular (non-executable) file.
33pub const MODE_REGULAR: u32 = 0o100644;
34/// File mode for an executable file.
35pub const MODE_EXECUTABLE: u32 = 0o100755;
36/// File mode for a symbolic link.
37pub const MODE_SYMLINK: u32 = 0o120000;
38/// File mode for a gitlink (submodule).
39pub const MODE_GITLINK: u32 = 0o160000;
40/// File mode for a directory (tree) entry — only used in tree objects, not index.
41pub const MODE_TREE: u32 = 0o040000;
42
43/// Git index extension signature `sdir` (sparse directory entries present).
44const INDEX_EXT_SPARSE_DIRECTORIES: u32 = u32::from_be_bytes(*b"sdir");
45
46/// A single entry in the Git index.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct IndexEntry {
49    /// Time the file metadata last changed (seconds since epoch).
50    pub ctime_sec: u32,
51    /// Nanosecond fraction of `ctime_sec`.
52    pub ctime_nsec: u32,
53    /// Time the file data last changed (seconds since epoch).
54    pub mtime_sec: u32,
55    /// Nanosecond fraction of `mtime_sec`.
56    pub mtime_nsec: u32,
57    /// Device number.
58    pub dev: u32,
59    /// Inode number.
60    pub ino: u32,
61    /// Unix file mode (`MODE_REGULAR`, `MODE_EXECUTABLE`, `MODE_SYMLINK`, …).
62    pub mode: u32,
63    /// Owner UID.
64    pub uid: u32,
65    /// Owner GID.
66    pub gid: u32,
67    /// File size in bytes (truncated to 32 bits).
68    pub size: u32,
69    /// SHA-1 of the blob object.
70    pub oid: ObjectId,
71    /// Entry flags (stage, assume-valid, extended, …).
72    pub flags: u16,
73    /// Extended flags (v3+ only).
74    pub flags_extended: Option<u16>,
75    /// Path relative to the repository root.  May contain `/` separators.
76    pub path: Vec<u8>,
77}
78
79impl IndexEntry {
80    /// Merge stage (0 = normal, 1–3 = conflict stages).
81    #[must_use]
82    pub fn stage(&self) -> u8 {
83        ((self.flags >> 12) & 0x3) as u8
84    }
85
86    pub(crate) fn set_stage(&mut self, stage: u8) {
87        self.flags = (self.flags & 0x0FFF) | ((stage as u16 & 0x3) << 12);
88    }
89
90    /// Whether the assume-unchanged bit is set.
91    #[must_use]
92    pub fn assume_unchanged(&self) -> bool {
93        self.flags & 0x8000 != 0
94    }
95
96    /// Whether the skip-worktree bit is set (extended flags, v3+).
97    #[must_use]
98    pub fn skip_worktree(&self) -> bool {
99        self.flags_extended
100            .map(|f| f & 0x4000 != 0)
101            .unwrap_or(false)
102    }
103
104    /// Set the assume-unchanged bit.
105    pub fn set_assume_unchanged(&mut self, value: bool) {
106        if value {
107            self.flags |= 0x8000;
108        } else {
109            self.flags &= !0x8000;
110        }
111    }
112
113    /// Set the skip-worktree bit (promotes entry to v3).
114    pub fn set_skip_worktree(&mut self, value: bool) {
115        let fe = self.flags_extended.get_or_insert(0);
116        if value {
117            *fe |= 0x4000;
118        } else {
119            *fe &= !0x4000;
120            if *fe == 0 {
121                self.flags_extended = None;
122            }
123        }
124    }
125
126    /// Whether the intent-to-add bit is set (extended flags, v3+).
127    #[must_use]
128    pub fn intent_to_add(&self) -> bool {
129        self.flags_extended
130            .map(|f| f & 0x2000 != 0)
131            .unwrap_or(false)
132    }
133
134    /// Set the intent-to-add bit (promotes entry to v3).
135    pub fn set_intent_to_add(&mut self, value: bool) {
136        let fe = self.flags_extended.get_or_insert(0);
137        if value {
138            *fe |= 0x2000;
139        } else {
140            *fe &= !0x2000;
141            if *fe == 0 {
142                self.flags_extended = None;
143            }
144        }
145    }
146
147    /// Sparse-index placeholder: tree mode, stage 0, and `SKIP_WORKTREE` set.
148    #[must_use]
149    pub fn is_sparse_directory_placeholder(&self) -> bool {
150        self.mode == MODE_TREE && self.stage() == 0 && self.skip_worktree()
151    }
152
153    /// In-memory only: `ls-files --with-tree` hides stage-1 overlay rows that duplicate stage 0.
154    const FLAG_EXT_OVERLAY_TREE_SKIP: u16 = 0x8000;
155
156    #[must_use]
157    pub fn overlay_tree_skip_output(&self) -> bool {
158        self.flags_extended
159            .is_some_and(|fe| fe & Self::FLAG_EXT_OVERLAY_TREE_SKIP != 0)
160    }
161
162    fn set_overlay_tree_skip_output(&mut self, value: bool) {
163        let fe = self.flags_extended.get_or_insert(0);
164        if value {
165            *fe |= Self::FLAG_EXT_OVERLAY_TREE_SKIP;
166        } else {
167            *fe &= !Self::FLAG_EXT_OVERLAY_TREE_SKIP;
168            if *fe == 0 {
169                self.flags_extended = None;
170            }
171        }
172    }
173}
174
175/// The in-memory representation of the Git index file.
176#[derive(Debug, Clone, Default)]
177pub struct Index {
178    /// Index format version (2 or 3).
179    pub version: u32,
180    /// Index entries, sorted by (path, stage).
181    pub entries: Vec<IndexEntry>,
182    /// When true, the on-disk index includes the `sdir` extension (sparse index).
183    pub sparse_directories: bool,
184}
185
186/// Options for loading an index from disk.
187#[derive(Debug, Clone, Copy)]
188pub struct IndexLoadOptions {
189    /// If the index contains sparse directory placeholders, expand them to full file entries.
190    pub expand_sparse_directories: bool,
191}
192
193impl Default for IndexLoadOptions {
194    fn default() -> Self {
195        Self {
196            expand_sparse_directories: true,
197        }
198    }
199}
200
201/// Version used after an invalid `GIT_INDEX_VERSION` value (matches Git stderr: "Using version 3").
202const INDEX_ENV_INVALID_FALLBACK: u32 = 3;
203/// Version used after an invalid `index.version` config value (same message as env).
204const INDEX_CONFIG_INVALID_FALLBACK: u32 = 3;
205/// Minimum supported index version.
206const INDEX_FORMAT_LB: u32 = 2;
207/// Maximum supported index version (version 4 requests are accepted and
208/// downgraded on write).
209const INDEX_FORMAT_UB: u32 = 4;
210
211/// Read `GIT_INDEX_VERSION` and return the requested version.
212///
213/// If the environment variable is unset, returns `None`.
214/// If it is set but invalid (non-numeric or out of range 2..=4), prints a
215/// warning to stderr and returns the default version.
216pub fn get_index_format_from_env() -> Option<u32> {
217    let val = std::env::var("GIT_INDEX_VERSION").ok()?;
218    if val.is_empty() {
219        return None;
220    }
221    match val.parse::<u32>() {
222        Ok(v) if (INDEX_FORMAT_LB..=INDEX_FORMAT_UB).contains(&v) => Some(v),
223        _ => {
224            eprintln!(
225                "warning: GIT_INDEX_VERSION set, but the value is invalid.\n\
226                 Using version {INDEX_ENV_INVALID_FALLBACK}"
227            );
228            Some(INDEX_ENV_INVALID_FALLBACK)
229        }
230    }
231}
232
233impl Index {
234    /// Create a new, empty index.
235    ///
236    /// Respects `GIT_INDEX_VERSION` if set, otherwise defaults to version 2.
237    #[must_use]
238    pub fn new() -> Self {
239        let version = get_index_format_from_env().unwrap_or(2);
240        Self {
241            version,
242            entries: Vec::new(),
243            sparse_directories: false,
244        }
245    }
246
247    /// Create a new empty index, respecting config values for version.
248    ///
249    /// Priority matches Git's `prepare_repo_settings`: `GIT_INDEX_VERSION` env, then
250    /// `feature.manyFiles` (implies version 4), then `index.version` (overrides version).
251    pub fn new_with_config(
252        config_index_version: Option<&str>,
253        config_many_files: Option<&str>,
254    ) -> Self {
255        if let Some(v) = get_index_format_from_env() {
256            return Self {
257                version: v,
258                entries: Vec::new(),
259                sparse_directories: false,
260            };
261        }
262
263        let many_files = config_truthy(config_many_files);
264        let mut version = if many_files { 4 } else { 2 };
265
266        if let Some(val) = config_index_version {
267            let trimmed = val.trim();
268            if !trimmed.is_empty() {
269                match trimmed.parse::<u32>() {
270                    Ok(v) if (INDEX_FORMAT_LB..=INDEX_FORMAT_UB).contains(&v) => {
271                        version = v;
272                    }
273                    _ => {
274                        eprintln!(
275                            "warning: index.version set, but the value is invalid.\n\
276                             Using version {INDEX_CONFIG_INVALID_FALLBACK}"
277                        );
278                        version = INDEX_CONFIG_INVALID_FALLBACK;
279                    }
280                }
281            }
282        }
283
284        Self {
285            version,
286            entries: Vec::new(),
287            sparse_directories: false,
288        }
289    }
290
291    /// New empty index using a loaded [`ConfigSet`] (includes `-c` / `GIT_CONFIG_PARAMETERS`).
292    ///
293    /// Same precedence as [`Self::new_with_config`], but reads `feature.manyFiles` and
294    /// `index.version` from `config`.
295    #[must_use]
296    pub fn new_from_config(config: &ConfigSet) -> Self {
297        if let Some(v) = get_index_format_from_env() {
298            return Self {
299                version: v,
300                entries: Vec::new(),
301                sparse_directories: false,
302            };
303        }
304
305        let many_files = config
306            .get_bool("feature.manyFiles")
307            .and_then(|r| r.ok())
308            .unwrap_or(false);
309        let mut version = if many_files { 4 } else { 2 };
310
311        if let Some(val) = config.get("index.version") {
312            let trimmed = val.trim();
313            if !trimmed.is_empty() {
314                match trimmed.parse::<u32>() {
315                    Ok(v) if (INDEX_FORMAT_LB..=INDEX_FORMAT_UB).contains(&v) => {
316                        version = v;
317                    }
318                    _ => {
319                        eprintln!(
320                            "warning: index.version set, but the value is invalid.\n\
321                             Using version {INDEX_CONFIG_INVALID_FALLBACK}"
322                        );
323                        version = INDEX_CONFIG_INVALID_FALLBACK;
324                    }
325                }
326            }
327        }
328
329        Self {
330            version,
331            entries: Vec::new(),
332            sparse_directories: false,
333        }
334    }
335
336    /// Load an index from the given file path without expanding sparse-directory placeholders.
337    ///
338    /// Returns an empty index if the file does not exist.
339    ///
340    /// # Errors
341    ///
342    /// Returns [`Error::IndexError`] if the file is present but corrupt.
343    pub fn load(path: &Path) -> Result<Self> {
344        match fs::read(path) {
345            Ok(data) => Self::parse(&data),
346            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self {
347                sparse_directories: false,
348                ..Self::new()
349            }),
350            Err(e) => Err(Error::Io(e)),
351        }
352    }
353
354    /// Load an index and expand sparse-directory placeholders using the object database.
355    ///
356    /// After a successful return, [`Index::sparse_directories`] is cleared and every
357    /// placeholder is replaced by the blob entries from the referenced tree.
358    pub fn load_expand_sparse(path: &Path, odb: &Odb) -> Result<Self> {
359        let mut idx = Self::load(path)?;
360        idx.expand_sparse_directory_placeholders(odb)?;
361        Ok(idx)
362    }
363
364    /// Like [`Index::load_expand_sparse`], but treats a missing index or Git's
365    /// `"file too short"` placeholder as an empty index.
366    pub fn load_expand_sparse_optional(path: &Path, odb: &Odb) -> Result<Self> {
367        let mut idx = match fs::read(path) {
368            Ok(data) => Self::parse(&data).or_else(|e| match e {
369                Error::IndexError(msg) if msg == "file too short" => Ok(Self::new()),
370                other => Err(other),
371            })?,
372            Err(e) if e.kind() == io::ErrorKind::NotFound => Self::new(),
373            Err(e) => return Err(Error::Io(e)),
374        };
375        idx.expand_sparse_directory_placeholders(odb)?;
376        Ok(idx)
377    }
378
379    /// Returns true if the index contains sparse-index tree placeholders (`MODE_TREE` + skip-worktree).
380    #[must_use]
381    pub fn has_sparse_directory_placeholders(&self) -> bool {
382        self.entries
383            .iter()
384            .any(IndexEntry::is_sparse_directory_placeholder)
385    }
386
387    /// Replace sparse-directory placeholder entries with all blob paths from their trees.
388    ///
389    /// Each placeholder must reference a tree object. New entries are marked skip-worktree like Git's
390    /// expanded index, except we keep `sparse_directories` false in memory after expansion.
391    pub fn expand_sparse_directory_placeholders(&mut self, odb: &Odb) -> Result<()> {
392        if !self.has_sparse_directory_placeholders() {
393            return Ok(());
394        }
395        let mut out: Vec<IndexEntry> = Vec::with_capacity(self.entries.len());
396        for entry in self.entries.drain(..) {
397            if entry.is_sparse_directory_placeholder() {
398                let prefix = trim_trailing_slash_bytes(&entry.path);
399                let blobs = flatten_tree_blobs(odb, &entry.oid, prefix)?;
400                out.extend(blobs);
401            } else {
402                out.push(entry);
403            }
404        }
405        self.entries = out;
406        self.sparse_directories = false;
407        self.sort();
408        Ok(())
409    }
410
411    /// Collapse consecutive skip-worktree subtrees into sparse-directory placeholders when
412    /// `cone_mode` is true and each directory is outside the sparse cone.
413    ///
414    /// `head_tree` is the tree OID at `HEAD`. When `enable_sparse_index` is false, clears
415    /// [`Index::sparse_directories`] and returns without collapsing.
416    pub fn try_collapse_sparse_directories(
417        &mut self,
418        odb: &Odb,
419        head_tree: &ObjectId,
420        patterns: &[String],
421        cone_mode: bool,
422        enable_sparse_index: bool,
423    ) -> Result<()> {
424        if !enable_sparse_index || !cone_mode {
425            self.sparse_directories = false;
426            return Ok(());
427        }
428
429        let mut prefixes = BTreeSet::<Vec<u8>>::new();
430        for e in &self.entries {
431            if e.stage() != 0 || e.mode == MODE_TREE || !e.skip_worktree() {
432                continue;
433            }
434            collect_directory_prefixes(&e.path, &mut prefixes);
435        }
436
437        let mut collapsed_any = false;
438        // Deepest prefixes first so nested dirs collapse before parents.
439        let mut ordered: Vec<Vec<u8>> = prefixes.into_iter().collect();
440        ordered.sort_by_key(|p| std::cmp::Reverse(p.len()));
441
442        for pref in ordered {
443            let pref_str = String::from_utf8_lossy(&pref);
444            if directory_in_cone(&pref_str, patterns, cone_mode) {
445                continue;
446            }
447            let Some(subtree_oid) = tree_oid_for_prefix(odb, head_tree, &pref)? else {
448                continue;
449            };
450            let expected = collect_sparse_aware_expected_blobs(
451                odb,
452                &subtree_oid,
453                &pref,
454                patterns,
455                cone_mode,
456                &self.entries,
457            )?;
458            if expected.is_empty() {
459                continue;
460            }
461            let mut matched = Vec::new();
462            for e in &self.entries {
463                if e.stage() != 0 {
464                    continue;
465                }
466                if path_under_prefix(&e.path, &pref) && e.mode != MODE_TREE {
467                    matched.push(e.clone());
468                }
469            }
470            if matched.len() != expected.len() {
471                continue;
472            }
473            matched.sort_by(|a, b| a.path.cmp(&b.path));
474            let mut exp_sorted = expected;
475            exp_sorted.sort_by(|a, b| a.path.cmp(&b.path));
476            if !matched
477                .iter()
478                .zip(exp_sorted.iter())
479                .all(|(a, b)| a.path == b.path && a.oid == b.oid && a.mode == b.mode)
480            {
481                continue;
482            }
483            if !matched.iter().all(|e| e.skip_worktree()) {
484                continue;
485            }
486
487            let mut path_with_slash = pref.clone();
488            if !path_with_slash.ends_with(b"/") {
489                path_with_slash.push(b'/');
490            }
491            self.entries
492                .retain(|e| e.stage() != 0 || !path_under_prefix(&e.path, &pref));
493            let mut placeholder = IndexEntry {
494                ctime_sec: 0,
495                ctime_nsec: 0,
496                mtime_sec: 0,
497                mtime_nsec: 0,
498                dev: 0,
499                ino: 0,
500                mode: MODE_TREE,
501                uid: 0,
502                gid: 0,
503                size: 0,
504                oid: subtree_oid,
505                flags: path_with_slash.len().min(0xFFF) as u16,
506                flags_extended: Some(0),
507                path: path_with_slash,
508            };
509            placeholder.set_skip_worktree(true);
510            self.add_or_replace(placeholder);
511            collapsed_any = true;
512        }
513
514        if collapsed_any {
515            self.sort();
516            self.sparse_directories = true;
517        } else {
518            self.sparse_directories = false;
519        }
520        Ok(())
521    }
522
523    /// Parse index bytes (the whole file including trailing SHA-1).
524    ///
525    /// # Errors
526    ///
527    /// Returns [`Error::IndexError`] on structural problems.
528    pub fn parse(data: &[u8]) -> Result<Self> {
529        if data.len() < 12 {
530            return Err(Error::IndexError("file too short".to_owned()));
531        }
532
533        // Trailing SHA-1: normal index is a hash of the body; Git may write all zeros when
534        // `index.skipHash` / `feature.manyFiles` skips computing the checksum.
535        let (body, checksum) = data.split_at(data.len() - 20);
536        if !checksum.iter().all(|&b| b == 0) {
537            let mut hasher = Sha1::new();
538            hasher.update(body);
539            let computed = hasher.finalize();
540            if computed.as_slice() != checksum {
541                return Err(Error::IndexError("SHA-1 checksum mismatch".to_owned()));
542            }
543        }
544
545        // Header
546        let magic = &body[..4];
547        if magic != b"DIRC" {
548            return Err(Error::IndexError("bad magic: expected DIRC".to_owned()));
549        }
550        let version = u32::from_be_bytes(
551            body[4..8]
552                .try_into()
553                .map_err(|_| Error::IndexError("cannot read version".to_owned()))?,
554        );
555        if version != 2 && version != 3 && version != 4 {
556            return Err(Error::IndexError(format!(
557                "unsupported index version {version}"
558            )));
559        }
560        let count = u32::from_be_bytes(
561            body[8..12]
562                .try_into()
563                .map_err(|_| Error::IndexError("cannot read entry count".to_owned()))?,
564        );
565
566        let mut pos = 12usize;
567        let mut entries = Vec::with_capacity(count as usize);
568
569        let mut prev_path: Vec<u8> = Vec::new();
570        for _ in 0..count {
571            let (entry, consumed) = parse_entry(&body[pos..], version, &prev_path)?;
572            prev_path = entry.path.clone();
573            entries.push(entry);
574            pos += consumed;
575        }
576
577        let mut sparse_directories = false;
578        while pos + 8 <= body.len() {
579            let sig = u32::from_be_bytes(
580                body[pos..pos + 4]
581                    .try_into()
582                    .map_err(|_| Error::IndexError("truncated extension sig".to_owned()))?,
583            );
584            let ext_sz = u32::from_be_bytes(
585                body[pos + 4..pos + 8]
586                    .try_into()
587                    .map_err(|_| Error::IndexError("truncated extension size".to_owned()))?,
588            ) as usize;
589            pos += 8;
590            if pos + ext_sz > body.len() {
591                return Err(Error::IndexError(
592                    "extension overruns index body".to_owned(),
593                ));
594            }
595            if sig == INDEX_EXT_SPARSE_DIRECTORIES {
596                sparse_directories = true;
597            }
598            pos += ext_sz;
599        }
600        if pos != body.len() {
601            return Err(Error::IndexError("junk after index extensions".to_owned()));
602        }
603
604        Ok(Self {
605            version,
606            entries,
607            sparse_directories,
608        })
609    }
610
611    /// Write the index to a file, computing and appending the trailing SHA-1.
612    ///
613    /// # Errors
614    ///
615    /// Returns [`Error::Io`] on filesystem errors.
616    pub fn write(&self, path: &Path) -> Result<()> {
617        let mut sorted = self.clone();
618        sorted.sort();
619
620        let mut body = Vec::new();
621        sorted.serialize_into(&mut body)?;
622
623        let git_dir = path.parent();
624        let config = git_dir.and_then(|d| ConfigSet::load(Some(d), true).ok());
625        let skip_hash = index_skip_hash_for_write(config.as_ref());
626        let checksum: [u8; 20] = if skip_hash {
627            [0u8; 20]
628        } else {
629            let mut hasher = Sha1::new();
630            hasher.update(&body);
631            hasher.finalize().into()
632        };
633
634        let tmp_path = path.with_extension("lock");
635        let pid_path = pid_path_for_lock(&tmp_path);
636        let lockfile_pid_enabled = lockfile_pid_enabled(path);
637
638        let mut lock_file = match fs::OpenOptions::new()
639            .write(true)
640            .create_new(true)
641            .open(&tmp_path)
642        {
643            Ok(file) => file,
644            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
645                let message = build_lock_exists_message(&tmp_path, &pid_path, &e);
646                return Err(Error::Io(io::Error::new(
647                    io::ErrorKind::AlreadyExists,
648                    message,
649                )));
650            }
651            Err(e) => return Err(Error::Io(e)),
652        };
653
654        let mut wrote_pid_file = false;
655        if lockfile_pid_enabled {
656            if let Err(e) = write_lock_pid_file(&pid_path) {
657                let _ = fs::remove_file(&tmp_path);
658                return Err(Error::Io(e));
659            }
660            wrote_pid_file = true;
661        }
662
663        if let Err(e) = (|| -> io::Result<()> {
664            lock_file.write_all(&body)?;
665            lock_file.write_all(&checksum)?;
666            Ok(())
667        })() {
668            let _ = fs::remove_file(&tmp_path);
669            if wrote_pid_file {
670                let _ = fs::remove_file(&pid_path);
671            }
672            return Err(Error::Io(e));
673        }
674        drop(lock_file);
675
676        if let Err(e) = fs::rename(&tmp_path, path) {
677            let _ = fs::remove_file(&tmp_path);
678            if wrote_pid_file {
679                let _ = fs::remove_file(&pid_path);
680            }
681            return Err(Error::Io(e));
682        }
683        {
684            if wrote_pid_file {
685                let _ = fs::remove_file(&pid_path);
686            }
687        }
688        Ok(())
689    }
690
691    /// Serialise the index body (without trailing checksum) into `out`.
692    ///
693    /// Callers must have sorted entries when using format 4 (path compression depends on order).
694    fn serialize_into(&self, out: &mut Vec<u8>) -> Result<()> {
695        let has_extended_flags = self.entries.iter().any(|e| e.flags_extended.is_some());
696        let write_version = if self.version >= 4 {
697            4
698        } else if has_extended_flags {
699            3
700        } else if self.version >= 3 {
701            2
702        } else {
703            self.version
704        };
705        // Header
706        out.extend_from_slice(b"DIRC");
707        out.extend_from_slice(&write_version.to_be_bytes());
708        out.extend_from_slice(&(self.entries.len() as u32).to_be_bytes());
709
710        if write_version == 4 {
711            let mut previous_path: Vec<u8> = Vec::new();
712            for entry in &self.entries {
713                serialize_entry_v4(entry, &mut previous_path, out);
714            }
715        } else {
716            for entry in &self.entries {
717                serialize_entry(entry, write_version, out);
718            }
719        }
720        if self.sparse_directories {
721            out.extend_from_slice(&INDEX_EXT_SPARSE_DIRECTORIES.to_be_bytes());
722            out.extend_from_slice(&0u32.to_be_bytes());
723        }
724        Ok(())
725    }
726
727    /// Add or replace an entry (matched by path + stage).
728    pub fn add_or_replace(&mut self, entry: IndexEntry) {
729        let path = &entry.path;
730        let stage = entry.stage();
731        // Binary search for the insertion point by (path, stage)
732        let result = self.entries.binary_search_by(|e| {
733            e.path
734                .as_slice()
735                .cmp(path.as_slice())
736                .then_with(|| e.stage().cmp(&stage))
737        });
738        match result {
739            Ok(pos) => {
740                // Exact match — replace in place
741                self.entries[pos] = entry;
742            }
743            Err(pos) => {
744                // Not found — insert at sorted position
745                self.entries.insert(pos, entry);
746            }
747        }
748    }
749
750    /// Stage a file at stage 0, removing any conflict stage entries (1, 2, 3)
751    /// for the same path. This is the correct behavior for `git add` on a
752    /// conflicted file during merge/cherry-pick resolution.
753    pub fn stage_file(&mut self, entry: IndexEntry) {
754        let path = entry.path.clone();
755        // Remove conflict stages first
756        self.entries.retain(|e| e.path != path || e.stage() == 0);
757        // Then add/replace stage-0 entry
758        self.add_or_replace(entry);
759    }
760
761    /// Remove all entries matching the given path (all stages).
762    ///
763    /// Returns `true` if at least one entry was removed.
764    pub fn remove(&mut self, path: &[u8]) -> bool {
765        let before = self.entries.len();
766        self.entries.retain(|e| e.path != path);
767        self.entries.len() < before
768    }
769
770    /// Remove every index entry whose path lies strictly under `path` (all stages).
771    ///
772    /// Used when staging a file at `path` that replaces a former directory: Git removes
773    /// tracked paths like `path/child` from the index so they do not remain alongside
774    /// the new blob entry.
775    pub fn remove_descendants_under_path(&mut self, path: &str) {
776        let prefix = path.as_bytes();
777        if prefix.is_empty() {
778            return;
779        }
780        let plen = prefix.len();
781        self.entries.retain(|e| {
782            let ep = e.path.as_slice();
783            if ep.len() <= plen {
784                return true;
785            }
786            if !ep.starts_with(prefix) {
787                return true;
788            }
789            // Drop paths strictly under `prefix/` (keep same-length prefix matches like "d-other").
790            ep[plen] != b'/'
791        });
792    }
793
794    /// Sort entries in Git's canonical order: by path, then by stage.
795    pub fn sort(&mut self) {
796        self.entries
797            .sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.stage().cmp(&b.stage())));
798    }
799
800    /// Find an entry by path and stage (0 for normal entries).
801    #[must_use]
802    pub fn get(&self, path: &[u8], stage: u8) -> Option<&IndexEntry> {
803        self.entries
804            .iter()
805            .find(|e| e.path == path && e.stage() == stage)
806    }
807
808    /// Find a mutable entry by path and stage.
809    pub fn get_mut(&mut self, path: &[u8], stage: u8) -> Option<&mut IndexEntry> {
810        self.entries
811            .iter_mut()
812            .find(|e| e.path == path && e.stage() == stage)
813    }
814
815    /// Merge tree contents from `treeish` into this index as virtual stage-1 entries, matching
816    /// Git's `overlay_tree_on_index` used by `git ls-files --with-tree`.
817    ///
818    /// Existing unmerged entries (stages 1–3) are shifted to stage 3 so stage 1 is free for the
819    /// overlay. Stage-1 paths that already exist at stage 0 are marked so `ls-files` can skip
820    /// them (Git's `CE_UPDATE` on the stage-1 entry).
821    ///
822    /// # Parameters
823    ///
824    /// - `repo` — repository whose object database is used to read the tree.
825    /// - `treeish` — revision or tree OID string (`HEAD`, `HEAD~1`, full SHA, etc.).
826    /// - `prefix` — optional path prefix (bytes, no trailing slash except empty); only paths under
827    ///   this prefix are considered from the tree. Pass empty slice for the full tree.
828    ///
829    /// # Errors
830    ///
831    /// Returns [`Error`] if `treeish` cannot be resolved, the tree cannot be read, or an object is
832    /// missing from the ODB.
833    pub fn overlay_tree_on_index(
834        &mut self,
835        repo: &Repository,
836        treeish: &str,
837        prefix: &[u8],
838    ) -> Result<()> {
839        let oid = rev_parse::resolve_revision(repo, treeish)?;
840        let tree_oid = peel_to_tree_oid(repo, oid)?;
841        for e in self.entries.iter_mut() {
842            if e.stage() != 0 {
843                e.set_stage(3);
844            }
845        }
846        self.sort();
847        let has_stage1 = self.entries.iter().any(|e| e.stage() == 1);
848        let mut appended: Vec<IndexEntry> = Vec::new();
849        read_tree_into_overlay(repo, &tree_oid, prefix, &[], has_stage1, &mut appended)?;
850        for e in appended {
851            self.add_or_replace(e);
852        }
853        if !has_stage1 {
854            self.sort();
855        }
856        let mut last_stage0: Option<&[u8]> = None;
857        for e in &mut self.entries {
858            match e.stage() {
859                0 => {
860                    last_stage0 = Some(e.path.as_slice());
861                }
862                1 => {
863                    if last_stage0.is_some_and(|p| p == e.path.as_slice()) {
864                        e.set_overlay_tree_skip_output(true);
865                    }
866                }
867                _ => {}
868            }
869        }
870        Ok(())
871    }
872}
873
874fn peel_to_tree_oid(repo: &Repository, oid: ObjectId) -> Result<ObjectId> {
875    let obj = repo.odb.read(&oid)?;
876    match obj.kind {
877        ObjectKind::Tree => Ok(oid),
878        ObjectKind::Commit => {
879            let commit = crate::objects::parse_commit(&obj.data)?;
880            Ok(commit.tree)
881        }
882        ObjectKind::Tag => {
883            let tag = crate::objects::parse_tag(&obj.data)?;
884            peel_to_tree_oid(repo, tag.object)
885        }
886        _ => Err(Error::ObjectNotFound(format!(
887            "cannot peel {oid} to tree for --with-tree"
888        ))),
889    }
890}
891
892fn read_tree_into_overlay(
893    repo: &Repository,
894    tree_oid: &ObjectId,
895    prefix: &[u8],
896    rel_base: &[u8],
897    use_replace_path: bool,
898    out: &mut Vec<IndexEntry>,
899) -> Result<()> {
900    let obj = repo.odb.read(tree_oid)?;
901    if obj.kind != ObjectKind::Tree {
902        return Err(Error::ObjectNotFound(format!(
903            "object {tree_oid} is not a tree"
904        )));
905    }
906    let entries = parse_tree(&obj.data)?;
907    for TreeEntry { mode, name, oid } in entries {
908        if mode == MODE_TREE {
909            let mut path = rel_base.to_vec();
910            if !path.is_empty() {
911                path.push(b'/');
912            }
913            path.extend_from_slice(&name);
914            if !prefix_under_or_equal(prefix, &path) {
915                continue;
916            }
917            read_tree_into_overlay(repo, &oid, prefix, &path, use_replace_path, out)?;
918            continue;
919        }
920        if mode == MODE_GITLINK {
921            continue;
922        }
923        let mut path = rel_base.to_vec();
924        if !path.is_empty() {
925            path.push(b'/');
926        }
927        path.extend_from_slice(&name);
928        if !prefix_under_or_equal(prefix, &path) {
929            continue;
930        }
931        let entry = synthetic_stage1_index_entry(mode, &path, oid);
932        if use_replace_path {
933            if let Some(pos) = out.iter().position(|e| e.path == path && e.stage() == 1) {
934                out[pos] = entry;
935            } else {
936                out.push(entry);
937            }
938        } else {
939            out.push(entry);
940        }
941    }
942    Ok(())
943}
944
945fn prefix_under_or_equal(prefix: &[u8], path: &[u8]) -> bool {
946    if prefix.is_empty() {
947        return true;
948    }
949    if path == prefix {
950        return true;
951    }
952    path.len() > prefix.len() && path.starts_with(prefix) && path[prefix.len()] == b'/'
953}
954
955fn synthetic_stage1_index_entry(mode: u32, path: &[u8], oid: ObjectId) -> IndexEntry {
956    let path_len = path.len().min(0xFFF) as u16;
957    let flags = (1u16 << 12) | path_len;
958    IndexEntry {
959        ctime_sec: 0,
960        ctime_nsec: 0,
961        mtime_sec: 0,
962        mtime_nsec: 0,
963        dev: 0,
964        ino: 0,
965        mode,
966        uid: 0,
967        gid: 0,
968        size: 0,
969        oid,
970        flags,
971        flags_extended: None,
972        path: path.to_vec(),
973    }
974}
975
976fn config_truthy(raw: Option<&str>) -> bool {
977    let Some(val) = raw else {
978        return false;
979    };
980    let lowered = val.trim().to_lowercase();
981    matches!(lowered.as_str(), "true" | "yes" | "1" | "on")
982}
983
984/// Whether to write 20 zero bytes instead of the SHA-1 of the index body.
985///
986/// Mirrors Git `prepare_repo_settings`: `feature.manyFiles` enables skip-hash unless
987/// `index.skipHash` / `index.skiphash` is explicitly false; otherwise honor true `index.skipHash`.
988fn index_skip_hash_for_write(config: Option<&ConfigSet>) -> bool {
989    let Some(config) = config else {
990        return false;
991    };
992    let many_files = config
993        .get_bool("feature.manyFiles")
994        .and_then(|r| r.ok())
995        .unwrap_or(false);
996    if many_files {
997        if let Some(Ok(false)) = config.get_bool("index.skipHash") {
998            return false;
999        }
1000        if let Some(Ok(false)) = config.get_bool("index.skiphash") {
1001            return false;
1002        }
1003        return true;
1004    }
1005    for key in ["index.skipHash", "index.skiphash"] {
1006        if let Some(Ok(true)) = config.get_bool(key) {
1007            return true;
1008        }
1009    }
1010    false
1011}
1012
1013fn trim_trailing_slash_bytes(path: &[u8]) -> &[u8] {
1014    path.strip_suffix(b"/").unwrap_or(path)
1015}
1016
1017fn path_under_prefix(path: &[u8], prefix: &[u8]) -> bool {
1018    if path == prefix {
1019        return true;
1020    }
1021    if prefix.is_empty() {
1022        return true;
1023    }
1024    path.len() > prefix.len() && path.starts_with(prefix) && path[prefix.len()] == b'/'
1025}
1026
1027fn directory_in_cone(dir_path: &str, patterns: &[String], cone_mode: bool) -> bool {
1028    crate::sparse_checkout::path_matches_sparse_patterns(dir_path, patterns, cone_mode)
1029}
1030
1031fn collect_directory_prefixes(path: &[u8], out: &mut BTreeSet<Vec<u8>>) {
1032    for (i, &b) in path.iter().enumerate() {
1033        if b == b'/' {
1034            out.insert(path[..i].to_vec());
1035        }
1036    }
1037}
1038
1039fn tree_oid_for_prefix(odb: &Odb, root_tree: &ObjectId, prefix: &[u8]) -> Result<Option<ObjectId>> {
1040    if prefix.is_empty() {
1041        return Ok(Some(*root_tree));
1042    }
1043    let pref_str = String::from_utf8_lossy(prefix);
1044    let components: Vec<&str> = pref_str.split('/').filter(|c| !c.is_empty()).collect();
1045    let mut current = *root_tree;
1046    for comp in components {
1047        let obj = odb.read(&current)?;
1048        if obj.kind != ObjectKind::Tree {
1049            return Ok(None);
1050        }
1051        let entries = parse_tree(&obj.data)?;
1052        let mut next = None;
1053        for e in entries {
1054            if e.name == comp.as_bytes() {
1055                if e.mode == MODE_TREE {
1056                    next = Some(e.oid);
1057                }
1058                break;
1059            }
1060        }
1061        current = match next {
1062            Some(o) => o,
1063            None => return Ok(None),
1064        };
1065    }
1066    Ok(Some(current))
1067}
1068
1069/// Build the list of blob index entries under `prefix` that match `HEAD` at `tree_oid`,
1070/// treating existing sparse-directory placeholders in `entries` as opaque subtrees (like Git).
1071fn collect_sparse_aware_expected_blobs(
1072    odb: &Odb,
1073    tree_oid: &ObjectId,
1074    prefix: &[u8],
1075    patterns: &[String],
1076    cone_mode: bool,
1077    entries: &[IndexEntry],
1078) -> Result<Vec<IndexEntry>> {
1079    let mut out = Vec::new();
1080    walk_sparse_aware(
1081        odb, tree_oid, prefix, patterns, cone_mode, entries, &mut out,
1082    )?;
1083    Ok(out)
1084}
1085
1086fn walk_sparse_aware(
1087    odb: &Odb,
1088    tree_oid: &ObjectId,
1089    prefix: &[u8],
1090    patterns: &[String],
1091    cone_mode: bool,
1092    entries: &[IndexEntry],
1093    out: &mut Vec<IndexEntry>,
1094) -> Result<()> {
1095    let obj = odb.read(tree_oid)?;
1096    if obj.kind != ObjectKind::Tree {
1097        return Err(Error::IndexError(format!("expected tree at {}", tree_oid)));
1098    }
1099    let tree_entries = parse_tree(&obj.data)?;
1100    for te in tree_entries {
1101        let path = if prefix.is_empty() {
1102            te.name.clone()
1103        } else {
1104            let mut p = prefix.to_vec();
1105            p.push(b'/');
1106            p.extend_from_slice(&te.name);
1107            p
1108        };
1109        if te.mode == MODE_TREE {
1110            let path_slash = {
1111                let mut p = path.clone();
1112                p.push(b'/');
1113                p
1114            };
1115            if entries.iter().any(|e| {
1116                e.stage() == 0
1117                    && e.is_sparse_directory_placeholder()
1118                    && e.path == path_slash
1119                    && e.oid == te.oid
1120            }) {
1121                continue;
1122            }
1123            walk_sparse_aware(odb, &te.oid, &path, patterns, cone_mode, entries, out)?;
1124        } else {
1125            let path_len = path.len().min(0xFFF) as u16;
1126            let path_str = String::from_utf8_lossy(&path);
1127            if crate::sparse_checkout::path_matches_sparse_patterns(&path_str, patterns, cone_mode)
1128            {
1129                continue;
1130            }
1131            let mut e = IndexEntry {
1132                ctime_sec: 0,
1133                ctime_nsec: 0,
1134                mtime_sec: 0,
1135                mtime_nsec: 0,
1136                dev: 0,
1137                ino: 0,
1138                mode: te.mode,
1139                uid: 0,
1140                gid: 0,
1141                size: 0,
1142                oid: te.oid,
1143                flags: path_len,
1144                flags_extended: Some(0),
1145                path,
1146            };
1147            e.set_skip_worktree(true);
1148            out.push(e);
1149        }
1150    }
1151    Ok(())
1152}
1153
1154fn flatten_tree_blobs(odb: &Odb, tree_oid: &ObjectId, prefix: &[u8]) -> Result<Vec<IndexEntry>> {
1155    let obj = odb.read(tree_oid)?;
1156    if obj.kind != ObjectKind::Tree {
1157        return Err(Error::IndexError(format!("expected tree at {}", tree_oid)));
1158    }
1159    let entries = parse_tree(&obj.data)?;
1160    let mut out = Vec::new();
1161    for te in entries {
1162        let path = if prefix.is_empty() {
1163            te.name.clone()
1164        } else {
1165            let mut p = prefix.to_vec();
1166            p.push(b'/');
1167            p.extend_from_slice(&te.name);
1168            p
1169        };
1170        if te.mode == MODE_TREE {
1171            let sub = flatten_tree_blobs(odb, &te.oid, &path)?;
1172            out.extend(sub);
1173        } else {
1174            let path_len = path.len().min(0xFFF) as u16;
1175            let mut e = IndexEntry {
1176                ctime_sec: 0,
1177                ctime_nsec: 0,
1178                mtime_sec: 0,
1179                mtime_nsec: 0,
1180                dev: 0,
1181                ino: 0,
1182                mode: te.mode,
1183                uid: 0,
1184                gid: 0,
1185                size: 0,
1186                oid: te.oid,
1187                flags: path_len,
1188                flags_extended: Some(0),
1189                path,
1190            };
1191            e.set_skip_worktree(true);
1192            out.push(e);
1193        }
1194    }
1195    Ok(out)
1196}
1197
1198fn lockfile_pid_enabled(index_path: &Path) -> bool {
1199    let git_dir = match index_path.parent() {
1200        Some(dir) => dir,
1201        None => return false,
1202    };
1203
1204    ConfigSet::load(Some(git_dir), true)
1205        .ok()
1206        .and_then(|cfg| cfg.get_bool("core.lockfilepid"))
1207        .and_then(|res| res.ok())
1208        .unwrap_or(false)
1209}
1210
1211fn pid_path_for_lock(lock_path: &Path) -> std::path::PathBuf {
1212    let file_name = lock_path
1213        .file_name()
1214        .map(|s| s.to_string_lossy().to_string())
1215        .unwrap_or_else(|| "index.lock".to_owned());
1216    let pid_name = if let Some(base) = file_name.strip_suffix(".lock") {
1217        format!("{base}~pid.lock")
1218    } else {
1219        format!("{file_name}~pid.lock")
1220    };
1221    lock_path.with_file_name(pid_name)
1222}
1223
1224fn write_lock_pid_file(pid_path: &Path) -> io::Result<()> {
1225    use std::io::Write as _;
1226    let mut file = fs::OpenOptions::new()
1227        .write(true)
1228        .create(true)
1229        .truncate(true)
1230        .open(pid_path)?;
1231    writeln!(file, "pid {}", std::process::id())?;
1232    Ok(())
1233}
1234
1235fn build_lock_exists_message(lock_path: &Path, pid_path: &Path, err: &io::Error) -> String {
1236    let mut msg = format!("Unable to create '{}': {}.\n\n", lock_path.display(), err);
1237
1238    if let Some(pid) = read_lock_pid(pid_path) {
1239        if is_process_running(pid) {
1240            msg.push_str(&format!(
1241                "Lock is held by process {pid}; if no git process is running, the lock file may be stale (PIDs can be reused)"
1242            ));
1243        } else {
1244            msg.push_str(&format!(
1245                "Lock was held by process {pid}, which is no longer running; the lock file appears to be stale"
1246            ));
1247        }
1248    } else {
1249        msg.push_str(
1250            "Another git process seems to be running in this repository, or the lock file may be stale",
1251        );
1252    }
1253
1254    msg
1255}
1256
1257fn read_lock_pid(pid_path: &Path) -> Option<u64> {
1258    let raw = fs::read_to_string(pid_path).ok()?;
1259    let trimmed = raw.trim();
1260    if let Some(v) = trimmed.strip_prefix("pid ") {
1261        return v.trim().parse::<u64>().ok();
1262    }
1263    trimmed.parse::<u64>().ok()
1264}
1265
1266fn is_process_running(pid: u64) -> bool {
1267    #[cfg(target_os = "linux")]
1268    {
1269        let proc_path = std::path::PathBuf::from(format!("/proc/{pid}"));
1270        proc_path.exists()
1271    }
1272
1273    #[cfg(not(target_os = "linux"))]
1274    {
1275        let status = std::process::Command::new("kill")
1276            .arg("-0")
1277            .arg(pid.to_string())
1278            .status();
1279        status.map(|s| s.success()).unwrap_or(false)
1280    }
1281}
1282
1283/// Parse a single index entry from `data`, returning `(entry, bytes_consumed)`.
1284fn parse_entry(data: &[u8], version: u32, prev_path: &[u8]) -> Result<(IndexEntry, usize)> {
1285    if data.len() < 62 {
1286        return Err(Error::IndexError("entry too short".to_owned()));
1287    }
1288
1289    let mut pos = 0;
1290
1291    macro_rules! read_u32 {
1292        () => {{
1293            let v = u32::from_be_bytes(
1294                data[pos..pos + 4]
1295                    .try_into()
1296                    .map_err(|_| Error::IndexError("truncated u32".to_owned()))?,
1297            );
1298            pos += 4;
1299            v
1300        }};
1301    }
1302
1303    let ctime_sec = read_u32!();
1304    let ctime_nsec = read_u32!();
1305    let mtime_sec = read_u32!();
1306    let mtime_nsec = read_u32!();
1307    let dev = read_u32!();
1308    let ino = read_u32!();
1309    let mode = read_u32!();
1310    let uid = read_u32!();
1311    let gid = read_u32!();
1312    let size = read_u32!();
1313
1314    let oid = ObjectId::from_bytes(&data[pos..pos + 20])?;
1315    pos += 20;
1316
1317    let flags = u16::from_be_bytes(
1318        data[pos..pos + 2]
1319            .try_into()
1320            .map_err(|_| Error::IndexError("truncated flags".to_owned()))?,
1321    );
1322    pos += 2;
1323
1324    let flags_extended = if version >= 3 && flags & 0x4000 != 0 {
1325        let fe = u16::from_be_bytes(
1326            data[pos..pos + 2]
1327                .try_into()
1328                .map_err(|_| Error::IndexError("truncated extended flags".to_owned()))?,
1329        );
1330        pos += 2;
1331        Some(fe)
1332    } else {
1333        None
1334    };
1335
1336    let path;
1337    if version == 4 {
1338        // V4: prefix-compressed path
1339        let (strip_len, varint_bytes) = read_varint(&data[pos..]);
1340        pos += varint_bytes;
1341        let nul = data[pos..]
1342            .iter()
1343            .position(|&b| b == 0)
1344            .ok_or_else(|| Error::IndexError("v4 entry path missing NUL".to_owned()))?;
1345        let suffix = &data[pos..pos + nul];
1346        pos += nul + 1;
1347        let keep = prev_path.len().saturating_sub(strip_len);
1348        let mut full_path = prev_path[..keep].to_vec();
1349        full_path.extend_from_slice(suffix);
1350        path = full_path;
1351    } else {
1352        // V2/V3: NUL-terminated full path + padding
1353        let nul = data[pos..]
1354            .iter()
1355            .position(|&b| b == 0)
1356            .ok_or_else(|| Error::IndexError("entry path missing NUL terminator".to_owned()))?;
1357        path = data[pos..pos + nul].to_vec();
1358        pos += nul + 1;
1359        let entry_start = 0usize;
1360        let entry_len = pos - entry_start;
1361        let padded = (entry_len + 7) & !7;
1362        let padding = padded.saturating_sub(entry_len);
1363        pos += padding;
1364    }
1365
1366    Ok((
1367        IndexEntry {
1368            ctime_sec,
1369            ctime_nsec,
1370            mtime_sec,
1371            mtime_nsec,
1372            dev,
1373            ino,
1374            mode,
1375            uid,
1376            gid,
1377            size,
1378            oid,
1379            flags,
1380            flags_extended,
1381            path,
1382        },
1383        pos,
1384    ))
1385}
1386
1387/// Serialise a single index entry into `out`.
1388/// Read a variable-length integer (git's index v4 varint encoding).
1389/// Returns (value, bytes_consumed).
1390fn write_varint(out: &mut Vec<u8>, mut value: usize) {
1391    loop {
1392        let mut b = (value & 0x7F) as u8;
1393        value >>= 7;
1394        if value != 0 {
1395            b |= 0x80;
1396        }
1397        out.push(b);
1398        if value == 0 {
1399            break;
1400        }
1401    }
1402}
1403
1404fn read_varint(data: &[u8]) -> (usize, usize) {
1405    let mut value: usize = 0;
1406    let mut shift = 0usize;
1407    let mut pos = 0;
1408    loop {
1409        if pos >= data.len() {
1410            break;
1411        }
1412        let byte = data[pos] as usize;
1413        pos += 1;
1414        value |= (byte & 0x7F) << shift;
1415        if byte & 0x80 == 0 {
1416            break;
1417        }
1418        shift += 7;
1419        // Prevent infinite loops on malformed data
1420        if shift > 28 {
1421            break;
1422        }
1423    }
1424    (value, pos)
1425}
1426
1427fn serialize_entry_v4(entry: &IndexEntry, previous_path: &mut Vec<u8>, out: &mut Vec<u8>) {
1428    let write_u32 = |out: &mut Vec<u8>, v: u32| out.extend_from_slice(&v.to_be_bytes());
1429
1430    write_u32(out, entry.ctime_sec);
1431    write_u32(out, entry.ctime_nsec);
1432    write_u32(out, entry.mtime_sec);
1433    write_u32(out, entry.mtime_nsec);
1434    write_u32(out, entry.dev);
1435    write_u32(out, entry.ino);
1436    write_u32(out, entry.mode);
1437    write_u32(out, entry.uid);
1438    write_u32(out, entry.gid);
1439    write_u32(out, entry.size);
1440    out.extend_from_slice(entry.oid.as_bytes());
1441
1442    let mut flags = entry.flags;
1443    if entry.flags_extended.is_some() {
1444        flags |= 0x4000;
1445    } else {
1446        flags &= !0x4000;
1447    }
1448    let path_len = entry.path.len().min(0xFFF) as u16;
1449    flags = (flags & 0xF000) | path_len;
1450    out.extend_from_slice(&flags.to_be_bytes());
1451
1452    if let Some(fe) = entry.flags_extended {
1453        out.extend_from_slice(&fe.to_be_bytes());
1454    }
1455
1456    let common = previous_path
1457        .iter()
1458        .zip(entry.path.iter())
1459        .take_while(|(a, b)| a == b)
1460        .count();
1461    let to_remove = previous_path.len().saturating_sub(common);
1462    write_varint(out, to_remove);
1463    out.extend_from_slice(&entry.path[common..]);
1464    out.push(0);
1465
1466    previous_path.clear();
1467    previous_path.extend_from_slice(&entry.path);
1468}
1469
1470fn serialize_entry(entry: &IndexEntry, version: u32, out: &mut Vec<u8>) {
1471    let start = out.len();
1472
1473    let write_u32 = |out: &mut Vec<u8>, v: u32| out.extend_from_slice(&v.to_be_bytes());
1474
1475    write_u32(out, entry.ctime_sec);
1476    write_u32(out, entry.ctime_nsec);
1477    write_u32(out, entry.mtime_sec);
1478    write_u32(out, entry.mtime_nsec);
1479    write_u32(out, entry.dev);
1480    write_u32(out, entry.ino);
1481    write_u32(out, entry.mode);
1482    write_u32(out, entry.uid);
1483    write_u32(out, entry.gid);
1484    write_u32(out, entry.size);
1485    out.extend_from_slice(entry.oid.as_bytes());
1486
1487    // Set or clear the extended-flags bit in flags
1488    let mut flags = entry.flags;
1489    if version >= 3 && entry.flags_extended.is_some() {
1490        flags |= 0x4000;
1491    } else {
1492        flags &= !0x4000;
1493    }
1494    // Overwrite path length bits (bottom 12)
1495    let path_len = entry.path.len().min(0xFFF) as u16;
1496    flags = (flags & 0xF000) | path_len;
1497    out.extend_from_slice(&flags.to_be_bytes());
1498
1499    if version >= 3 {
1500        if let Some(fe) = entry.flags_extended {
1501            out.extend_from_slice(&fe.to_be_bytes());
1502        }
1503    }
1504
1505    out.extend_from_slice(&entry.path);
1506    out.push(0);
1507
1508    // Pad to 8-byte boundary
1509    let entry_len = out.len() - start;
1510    let padded = (entry_len + 7) & !7;
1511    let padding = padded - entry_len;
1512    for _ in 0..padding {
1513        out.push(0);
1514    }
1515}
1516
1517/// Build an [`IndexEntry`] by stat-ing a file on disk.
1518///
1519/// # Parameters
1520///
1521/// - `path` — absolute path to the file.
1522/// - `rel_path` — path relative to the repo root (stored in the index).
1523/// - `oid` — the object ID of the file's blob.
1524/// - `mode` — file mode (use [`MODE_REGULAR`], [`MODE_EXECUTABLE`], etc.).
1525///
1526/// # Errors
1527///
1528/// Returns [`Error::Io`] if `stat` fails.
1529pub fn entry_from_stat(
1530    path: &Path,
1531    rel_path: &[u8],
1532    oid: ObjectId,
1533    mode: u32,
1534) -> Result<IndexEntry> {
1535    let meta = fs::symlink_metadata(path)?;
1536    Ok(entry_from_metadata(&meta, rel_path, oid, mode))
1537}
1538
1539/// Build an [`IndexEntry`] from already-obtained metadata.
1540///
1541/// This avoids a redundant `stat()` call when the caller already has
1542/// filesystem metadata (e.g. from `symlink_metadata`).
1543#[must_use]
1544pub fn entry_from_metadata(
1545    meta: &fs::Metadata,
1546    rel_path: &[u8],
1547    oid: ObjectId,
1548    mode: u32,
1549) -> IndexEntry {
1550    use std::os::unix::fs::MetadataExt;
1551    IndexEntry {
1552        ctime_sec: meta.ctime() as u32,
1553        ctime_nsec: meta.ctime_nsec() as u32,
1554        mtime_sec: meta.mtime() as u32,
1555        mtime_nsec: meta.mtime_nsec() as u32,
1556        dev: meta.dev() as u32,
1557        ino: meta.ino() as u32,
1558        mode,
1559        uid: meta.uid(),
1560        gid: meta.gid(),
1561        size: meta.size() as u32,
1562        oid,
1563        flags: rel_path.len().min(0xFFF) as u16,
1564        flags_extended: None,
1565        path: rel_path.to_vec(),
1566    }
1567}
1568
1569/// Convert a `stat` mode to the Git index mode, normalised to one of the
1570/// known constants ([`MODE_REGULAR`], [`MODE_EXECUTABLE`], [`MODE_SYMLINK`]).
1571///
1572/// Only the `S_IFMT` and execute bits are inspected; all other permission bits
1573/// are discarded (Git stores only 644 or 755 for regular files).
1574///
1575/// # Parameters
1576///
1577/// - `raw_mode` — the raw `st_mode` value from `stat(2)`.
1578#[must_use]
1579pub fn normalize_mode(raw_mode: u32) -> u32 {
1580    const S_IFMT: u32 = 0o170000;
1581    const S_IFLNK: u32 = 0o120000;
1582    const S_IFREG: u32 = 0o100000;
1583
1584    let fmt = raw_mode & S_IFMT;
1585    if fmt == S_IFLNK {
1586        return MODE_SYMLINK;
1587    }
1588    if fmt == S_IFREG {
1589        // Executable if any execute bit is set
1590        if raw_mode & 0o111 != 0 {
1591            return MODE_EXECUTABLE;
1592        }
1593        return MODE_REGULAR;
1594    }
1595    // Fallback for everything else (devices, etc.) — treat as regular
1596    MODE_REGULAR
1597}
1598
1599#[cfg(test)]
1600mod tests {
1601    #![allow(clippy::expect_used, clippy::unwrap_used)]
1602
1603    use super::*;
1604    use tempfile::TempDir;
1605
1606    fn dummy_oid() -> ObjectId {
1607        ObjectId::from_bytes(&[0u8; 20]).unwrap()
1608    }
1609
1610    fn make_entry(path: &str) -> IndexEntry {
1611        IndexEntry {
1612            ctime_sec: 0,
1613            ctime_nsec: 0,
1614            mtime_sec: 0,
1615            mtime_nsec: 0,
1616            dev: 0,
1617            ino: 0,
1618            mode: MODE_REGULAR,
1619            uid: 0,
1620            gid: 0,
1621            size: 0,
1622            oid: dummy_oid(),
1623            flags: path.len().min(0xFFF) as u16,
1624            flags_extended: None,
1625            path: path.as_bytes().to_vec(),
1626        }
1627    }
1628
1629    #[test]
1630    fn round_trip_empty_index() {
1631        let dir = TempDir::new().unwrap();
1632        let path = dir.path().join("index");
1633
1634        let idx = Index::new();
1635        idx.write(&path).unwrap();
1636
1637        let loaded = Index::load(&path).unwrap();
1638        assert_eq!(loaded.entries.len(), 0);
1639    }
1640
1641    #[test]
1642    fn round_trip_with_entries() {
1643        let dir = TempDir::new().unwrap();
1644        let path = dir.path().join("index");
1645
1646        let mut idx = Index::new();
1647        idx.add_or_replace(make_entry("foo.txt"));
1648        idx.add_or_replace(make_entry("bar/baz.txt"));
1649        idx.write(&path).unwrap();
1650
1651        let loaded = Index::load(&path).unwrap();
1652        assert_eq!(loaded.entries.len(), 2);
1653        assert_eq!(loaded.entries[0].path, b"bar/baz.txt");
1654        assert_eq!(loaded.entries[1].path, b"foo.txt");
1655    }
1656
1657    #[test]
1658    fn remove_descendants_under_path_drops_nested_only() {
1659        let mut idx = Index::new();
1660        idx.add_or_replace(make_entry("d/e"));
1661        idx.add_or_replace(make_entry("d-other"));
1662        idx.add_or_replace(make_entry("prefix/d"));
1663        idx.remove_descendants_under_path("d");
1664        let paths: Vec<_> = idx.entries.iter().map(|e| e.path.as_slice()).collect();
1665        assert_eq!(paths, vec![b"d-other".as_slice(), b"prefix/d".as_slice()]);
1666    }
1667
1668    #[test]
1669    fn requested_v4_writes_v4_on_disk() {
1670        let dir = TempDir::new().unwrap();
1671        let path = dir.path().join("index");
1672
1673        let mut idx = Index {
1674            version: 4,
1675            ..Index::default()
1676        };
1677        idx.add_or_replace(make_entry("one"));
1678        idx.add_or_replace(make_entry("two/one"));
1679        idx.write(&path).unwrap();
1680
1681        let data = fs::read(&path).unwrap();
1682        assert_eq!(&data[4..8], &4u32.to_be_bytes());
1683
1684        let loaded = Index::load(&path).unwrap();
1685        assert_eq!(loaded.version, 4);
1686        assert_eq!(loaded.entries[0].path, b"one");
1687        assert_eq!(loaded.entries[1].path, b"two/one");
1688    }
1689}