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::fs;
19use std::io::{self, Write};
20use std::path::Path;
21
22use sha1::{Digest, Sha1};
23
24use crate::config::ConfigSet;
25use crate::error::{Error, Result};
26use crate::objects::ObjectId;
27
28/// File mode for a regular (non-executable) file.
29pub const MODE_REGULAR: u32 = 0o100644;
30/// File mode for an executable file.
31pub const MODE_EXECUTABLE: u32 = 0o100755;
32/// File mode for a symbolic link.
33pub const MODE_SYMLINK: u32 = 0o120000;
34/// File mode for a gitlink (submodule).
35pub const MODE_GITLINK: u32 = 0o160000;
36/// File mode for a directory (tree) entry — only used in tree objects, not index.
37pub const MODE_TREE: u32 = 0o040000;
38
39/// A single entry in the Git index.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct IndexEntry {
42    /// Time the file metadata last changed (seconds since epoch).
43    pub ctime_sec: u32,
44    /// Nanosecond fraction of `ctime_sec`.
45    pub ctime_nsec: u32,
46    /// Time the file data last changed (seconds since epoch).
47    pub mtime_sec: u32,
48    /// Nanosecond fraction of `mtime_sec`.
49    pub mtime_nsec: u32,
50    /// Device number.
51    pub dev: u32,
52    /// Inode number.
53    pub ino: u32,
54    /// Unix file mode (`MODE_REGULAR`, `MODE_EXECUTABLE`, `MODE_SYMLINK`, …).
55    pub mode: u32,
56    /// Owner UID.
57    pub uid: u32,
58    /// Owner GID.
59    pub gid: u32,
60    /// File size in bytes (truncated to 32 bits).
61    pub size: u32,
62    /// SHA-1 of the blob object.
63    pub oid: ObjectId,
64    /// Entry flags (stage, assume-valid, extended, …).
65    pub flags: u16,
66    /// Extended flags (v3+ only).
67    pub flags_extended: Option<u16>,
68    /// Path relative to the repository root.  May contain `/` separators.
69    pub path: Vec<u8>,
70}
71
72impl IndexEntry {
73    /// Merge stage (0 = normal, 1–3 = conflict stages).
74    #[must_use]
75    pub fn stage(&self) -> u8 {
76        ((self.flags >> 12) & 0x3) as u8
77    }
78
79    /// Whether the assume-unchanged bit is set.
80    #[must_use]
81    pub fn assume_unchanged(&self) -> bool {
82        self.flags & 0x8000 != 0
83    }
84
85    /// Whether the skip-worktree bit is set (extended flags, v3+).
86    #[must_use]
87    pub fn skip_worktree(&self) -> bool {
88        self.flags_extended
89            .map(|f| f & 0x4000 != 0)
90            .unwrap_or(false)
91    }
92
93    /// Set the assume-unchanged bit.
94    pub fn set_assume_unchanged(&mut self, value: bool) {
95        if value {
96            self.flags |= 0x8000;
97        } else {
98            self.flags &= !0x8000;
99        }
100    }
101
102    /// Set the skip-worktree bit (promotes entry to v3).
103    pub fn set_skip_worktree(&mut self, value: bool) {
104        let fe = self.flags_extended.get_or_insert(0);
105        if value {
106            *fe |= 0x4000;
107        } else {
108            *fe &= !0x4000;
109            if *fe == 0 {
110                self.flags_extended = None;
111            }
112        }
113    }
114
115    /// Whether the intent-to-add bit is set (extended flags, v3+).
116    #[must_use]
117    pub fn intent_to_add(&self) -> bool {
118        self.flags_extended
119            .map(|f| f & 0x2000 != 0)
120            .unwrap_or(false)
121    }
122
123    /// Set the intent-to-add bit (promotes entry to v3).
124    pub fn set_intent_to_add(&mut self, value: bool) {
125        let fe = self.flags_extended.get_or_insert(0);
126        if value {
127            *fe |= 0x2000;
128        } else {
129            *fe &= !0x2000;
130            if *fe == 0 {
131                self.flags_extended = None;
132            }
133        }
134    }
135}
136
137/// The in-memory representation of the Git index file.
138#[derive(Debug, Clone, Default)]
139pub struct Index {
140    /// Index format version (2 or 3).
141    pub version: u32,
142    /// Index entries, sorted by (path, stage).
143    pub entries: Vec<IndexEntry>,
144}
145
146/// Default index version when `GIT_INDEX_VERSION` is unset or invalid.
147const INDEX_FORMAT_DEFAULT: u32 = 3;
148/// Minimum supported index version.
149const INDEX_FORMAT_LB: u32 = 2;
150/// Maximum supported index version (version 4 requests are accepted and
151/// downgraded on write).
152const INDEX_FORMAT_UB: u32 = 4;
153
154/// Read `GIT_INDEX_VERSION` and return the requested version.
155///
156/// If the environment variable is unset, returns `None`.
157/// If it is set but invalid (non-numeric or out of range 2..=4), prints a
158/// warning to stderr and returns the default version.
159pub fn get_index_format_from_env() -> Option<u32> {
160    let val = std::env::var("GIT_INDEX_VERSION").ok()?;
161    if val.is_empty() {
162        return None;
163    }
164    match val.parse::<u32>() {
165        Ok(v) if (INDEX_FORMAT_LB..=INDEX_FORMAT_UB).contains(&v) => Some(v),
166        _ => {
167            eprintln!(
168                "warning: GIT_INDEX_VERSION set, but the value is invalid.\n\
169                 Using version {INDEX_FORMAT_DEFAULT}"
170            );
171            Some(INDEX_FORMAT_DEFAULT)
172        }
173    }
174}
175
176impl Index {
177    /// Create a new, empty index.
178    ///
179    /// Respects `GIT_INDEX_VERSION` if set, otherwise defaults to version 2.
180    #[must_use]
181    pub fn new() -> Self {
182        let version = get_index_format_from_env().unwrap_or(2);
183        Self {
184            version,
185            entries: Vec::new(),
186        }
187    }
188
189    /// Create a new empty index, respecting config values for version.
190    ///
191    /// Priority: GIT_INDEX_VERSION env > index.version config > feature.manyFiles config > default (2).
192    pub fn new_with_config(
193        config_index_version: Option<&str>,
194        config_many_files: Option<&str>,
195    ) -> Self {
196        // Env var takes highest priority
197        if let Some(v) = get_index_format_from_env() {
198            return Self {
199                version: v,
200                entries: Vec::new(),
201            };
202        }
203        // Config index.version
204        if let Some(val) = config_index_version {
205            if let Ok(v) = val.parse::<u32>() {
206                if (INDEX_FORMAT_LB..=INDEX_FORMAT_UB).contains(&v) {
207                    return Self {
208                        version: v,
209                        entries: Vec::new(),
210                    };
211                }
212            }
213            // Invalid config value
214            eprintln!(
215                "warning: index.version set, but the value is invalid.\n\
216                 Using version {INDEX_FORMAT_DEFAULT}"
217            );
218            return Self {
219                version: INDEX_FORMAT_DEFAULT,
220                entries: Vec::new(),
221            };
222        }
223        // feature.manyFiles implies version 4
224        if let Some(val) = config_many_files {
225            let lowered = val.to_lowercase();
226            let enabled = matches!(lowered.as_str(), "true" | "yes" | "1" | "on");
227            if enabled {
228                return Self {
229                    version: 4,
230                    entries: Vec::new(),
231                };
232            }
233        }
234        Self {
235            version: 2,
236            entries: Vec::new(),
237        }
238    }
239
240    /// Load an index from the given file path.
241    ///
242    /// Returns an empty index if the file does not exist.
243    ///
244    /// # Errors
245    ///
246    /// Returns [`Error::IndexError`] if the file is present but corrupt.
247    pub fn load(path: &Path) -> Result<Self> {
248        match fs::read(path) {
249            Ok(data) => Self::parse(&data),
250            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::new()),
251            Err(e) => Err(Error::Io(e)),
252        }
253    }
254
255    /// Parse index bytes (the whole file including trailing SHA-1).
256    ///
257    /// # Errors
258    ///
259    /// Returns [`Error::IndexError`] on structural problems.
260    pub fn parse(data: &[u8]) -> Result<Self> {
261        if data.len() < 12 {
262            return Err(Error::IndexError("file too short".to_owned()));
263        }
264
265        // Verify trailing SHA-1 checksum
266        let (body, checksum) = data.split_at(data.len() - 20);
267        let mut hasher = Sha1::new();
268        hasher.update(body);
269        let computed = hasher.finalize();
270        if computed.as_slice() != checksum {
271            return Err(Error::IndexError("SHA-1 checksum mismatch".to_owned()));
272        }
273
274        // Header
275        let magic = &body[..4];
276        if magic != b"DIRC" {
277            return Err(Error::IndexError("bad magic: expected DIRC".to_owned()));
278        }
279        let version = u32::from_be_bytes(
280            body[4..8]
281                .try_into()
282                .map_err(|_| Error::IndexError("cannot read version".to_owned()))?,
283        );
284        if version != 2 && version != 3 && version != 4 {
285            return Err(Error::IndexError(format!(
286                "unsupported index version {version}"
287            )));
288        }
289        let count = u32::from_be_bytes(
290            body[8..12]
291                .try_into()
292                .map_err(|_| Error::IndexError("cannot read entry count".to_owned()))?,
293        );
294
295        let mut pos = 12usize;
296        let mut entries = Vec::with_capacity(count as usize);
297
298        let mut prev_path: Vec<u8> = Vec::new();
299        for _ in 0..count {
300            let (entry, consumed) = parse_entry(&body[pos..], version, &prev_path)?;
301            prev_path = entry.path.clone();
302            entries.push(entry);
303            pos += consumed;
304        }
305
306        Ok(Self { version, entries })
307    }
308
309    /// Write the index to a file, computing and appending the trailing SHA-1.
310    ///
311    /// # Errors
312    ///
313    /// Returns [`Error::Io`] on filesystem errors.
314    pub fn write(&self, path: &Path) -> Result<()> {
315        let mut body = Vec::new();
316        self.serialize_into(&mut body)?;
317
318        let mut hasher = Sha1::new();
319        hasher.update(&body);
320        let checksum = hasher.finalize();
321
322        let tmp_path = path.with_extension("lock");
323        let pid_path = pid_path_for_lock(&tmp_path);
324        let lockfile_pid_enabled = lockfile_pid_enabled(path);
325
326        let mut lock_file = match fs::OpenOptions::new()
327            .write(true)
328            .create_new(true)
329            .open(&tmp_path)
330        {
331            Ok(file) => file,
332            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
333                let message = build_lock_exists_message(&tmp_path, &pid_path, &e);
334                return Err(Error::Io(io::Error::new(
335                    io::ErrorKind::AlreadyExists,
336                    message,
337                )));
338            }
339            Err(e) => return Err(Error::Io(e)),
340        };
341
342        let mut wrote_pid_file = false;
343        if lockfile_pid_enabled {
344            if let Err(e) = write_lock_pid_file(&pid_path) {
345                let _ = fs::remove_file(&tmp_path);
346                return Err(Error::Io(e));
347            }
348            wrote_pid_file = true;
349        }
350
351        if let Err(e) = (|| -> io::Result<()> {
352            lock_file.write_all(&body)?;
353            lock_file.write_all(&checksum)?;
354            Ok(())
355        })() {
356            let _ = fs::remove_file(&tmp_path);
357            if wrote_pid_file {
358                let _ = fs::remove_file(&pid_path);
359            }
360            return Err(Error::Io(e));
361        }
362        drop(lock_file);
363
364        if let Err(e) = fs::rename(&tmp_path, path) {
365            let _ = fs::remove_file(&tmp_path);
366            if wrote_pid_file {
367                let _ = fs::remove_file(&pid_path);
368            }
369            return Err(Error::Io(e));
370        }
371        {
372            if wrote_pid_file {
373                let _ = fs::remove_file(&pid_path);
374            }
375        }
376        Ok(())
377    }
378
379    /// Serialise the index body (without trailing checksum) into `out`.
380    fn serialize_into(&self, out: &mut Vec<u8>) -> Result<()> {
381        // Determine which version to write.
382        // Version 4 requires path compression, which we do not implement yet.
383        // Downgrade to the newest format we can serialize correctly.
384        let has_extended_flags = self.entries.iter().any(|e| e.flags_extended.is_some());
385        let write_version = if has_extended_flags {
386            3
387        } else if self.version >= 3 {
388            2
389        } else {
390            self.version
391        };
392        // Header
393        out.extend_from_slice(b"DIRC");
394        out.extend_from_slice(&write_version.to_be_bytes());
395        out.extend_from_slice(&(self.entries.len() as u32).to_be_bytes());
396
397        for entry in &self.entries {
398            serialize_entry(entry, write_version, out);
399        }
400        Ok(())
401    }
402
403    /// Add or replace an entry (matched by path + stage).
404    pub fn add_or_replace(&mut self, entry: IndexEntry) {
405        let path = &entry.path;
406        let stage = entry.stage();
407        // Binary search for the insertion point by (path, stage)
408        let result = self.entries.binary_search_by(|e| {
409            e.path
410                .as_slice()
411                .cmp(path.as_slice())
412                .then_with(|| e.stage().cmp(&stage))
413        });
414        match result {
415            Ok(pos) => {
416                // Exact match — replace in place
417                self.entries[pos] = entry;
418            }
419            Err(pos) => {
420                // Not found — insert at sorted position
421                self.entries.insert(pos, entry);
422            }
423        }
424    }
425
426    /// Stage a file at stage 0, removing any conflict stage entries (1, 2, 3)
427    /// for the same path. This is the correct behavior for `git add` on a
428    /// conflicted file during merge/cherry-pick resolution.
429    pub fn stage_file(&mut self, entry: IndexEntry) {
430        let path = entry.path.clone();
431        // Remove conflict stages first
432        self.entries.retain(|e| e.path != path || e.stage() == 0);
433        // Then add/replace stage-0 entry
434        self.add_or_replace(entry);
435    }
436
437    /// Remove all entries matching the given path (all stages).
438    ///
439    /// Returns `true` if at least one entry was removed.
440    pub fn remove(&mut self, path: &[u8]) -> bool {
441        let before = self.entries.len();
442        self.entries.retain(|e| e.path != path);
443        self.entries.len() < before
444    }
445
446    /// Sort entries in Git's canonical order: by path, then by stage.
447    pub fn sort(&mut self) {
448        self.entries
449            .sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.stage().cmp(&b.stage())));
450    }
451
452    /// Find an entry by path and stage (0 for normal entries).
453    #[must_use]
454    pub fn get(&self, path: &[u8], stage: u8) -> Option<&IndexEntry> {
455        self.entries
456            .iter()
457            .find(|e| e.path == path && e.stage() == stage)
458    }
459
460    /// Find a mutable entry by path and stage.
461    pub fn get_mut(&mut self, path: &[u8], stage: u8) -> Option<&mut IndexEntry> {
462        self.entries
463            .iter_mut()
464            .find(|e| e.path == path && e.stage() == stage)
465    }
466}
467
468fn lockfile_pid_enabled(index_path: &Path) -> bool {
469    let git_dir = match index_path.parent() {
470        Some(dir) => dir,
471        None => return false,
472    };
473
474    ConfigSet::load(Some(git_dir), true)
475        .ok()
476        .and_then(|cfg| cfg.get_bool("core.lockfilepid"))
477        .and_then(|res| res.ok())
478        .unwrap_or(false)
479}
480
481fn pid_path_for_lock(lock_path: &Path) -> std::path::PathBuf {
482    let file_name = lock_path
483        .file_name()
484        .map(|s| s.to_string_lossy().to_string())
485        .unwrap_or_else(|| "index.lock".to_owned());
486    let pid_name = if let Some(base) = file_name.strip_suffix(".lock") {
487        format!("{base}~pid.lock")
488    } else {
489        format!("{file_name}~pid.lock")
490    };
491    lock_path.with_file_name(pid_name)
492}
493
494fn write_lock_pid_file(pid_path: &Path) -> io::Result<()> {
495    use std::io::Write as _;
496    let mut file = fs::OpenOptions::new()
497        .write(true)
498        .create(true)
499        .truncate(true)
500        .open(pid_path)?;
501    writeln!(file, "pid {}", std::process::id())?;
502    Ok(())
503}
504
505fn build_lock_exists_message(lock_path: &Path, pid_path: &Path, err: &io::Error) -> String {
506    let mut msg = format!("Unable to create '{}': {}.\n\n", lock_path.display(), err);
507
508    if let Some(pid) = read_lock_pid(pid_path) {
509        if is_process_running(pid) {
510            msg.push_str(&format!(
511                "Lock is held by process {pid}; if no git process is running, the lock file may be stale (PIDs can be reused)"
512            ));
513        } else {
514            msg.push_str(&format!(
515                "Lock was held by process {pid}, which is no longer running; the lock file appears to be stale"
516            ));
517        }
518    } else {
519        msg.push_str(
520            "Another git process seems to be running in this repository, or the lock file may be stale",
521        );
522    }
523
524    msg
525}
526
527fn read_lock_pid(pid_path: &Path) -> Option<u64> {
528    let raw = fs::read_to_string(pid_path).ok()?;
529    let trimmed = raw.trim();
530    if let Some(v) = trimmed.strip_prefix("pid ") {
531        return v.trim().parse::<u64>().ok();
532    }
533    trimmed.parse::<u64>().ok()
534}
535
536fn is_process_running(pid: u64) -> bool {
537    #[cfg(target_os = "linux")]
538    {
539        let proc_path = std::path::PathBuf::from(format!("/proc/{pid}"));
540        proc_path.exists()
541    }
542
543    #[cfg(not(target_os = "linux"))]
544    {
545        let status = std::process::Command::new("kill")
546            .arg("-0")
547            .arg(pid.to_string())
548            .status();
549        status.map(|s| s.success()).unwrap_or(false)
550    }
551}
552
553/// Parse a single index entry from `data`, returning `(entry, bytes_consumed)`.
554fn parse_entry(data: &[u8], version: u32, prev_path: &[u8]) -> Result<(IndexEntry, usize)> {
555    if data.len() < 62 {
556        return Err(Error::IndexError("entry too short".to_owned()));
557    }
558
559    let mut pos = 0;
560
561    macro_rules! read_u32 {
562        () => {{
563            let v = u32::from_be_bytes(
564                data[pos..pos + 4]
565                    .try_into()
566                    .map_err(|_| Error::IndexError("truncated u32".to_owned()))?,
567            );
568            pos += 4;
569            v
570        }};
571    }
572
573    let ctime_sec = read_u32!();
574    let ctime_nsec = read_u32!();
575    let mtime_sec = read_u32!();
576    let mtime_nsec = read_u32!();
577    let dev = read_u32!();
578    let ino = read_u32!();
579    let mode = read_u32!();
580    let uid = read_u32!();
581    let gid = read_u32!();
582    let size = read_u32!();
583
584    let oid = ObjectId::from_bytes(&data[pos..pos + 20])?;
585    pos += 20;
586
587    let flags = u16::from_be_bytes(
588        data[pos..pos + 2]
589            .try_into()
590            .map_err(|_| Error::IndexError("truncated flags".to_owned()))?,
591    );
592    pos += 2;
593
594    let flags_extended = if version >= 3 && flags & 0x4000 != 0 {
595        let fe = u16::from_be_bytes(
596            data[pos..pos + 2]
597                .try_into()
598                .map_err(|_| Error::IndexError("truncated extended flags".to_owned()))?,
599        );
600        pos += 2;
601        Some(fe)
602    } else {
603        None
604    };
605
606    let path;
607    if version == 4 {
608        // V4: prefix-compressed path
609        let (strip_len, varint_bytes) = read_varint(&data[pos..]);
610        pos += varint_bytes;
611        let nul = data[pos..]
612            .iter()
613            .position(|&b| b == 0)
614            .ok_or_else(|| Error::IndexError("v4 entry path missing NUL".to_owned()))?;
615        let suffix = &data[pos..pos + nul];
616        pos += nul + 1;
617        let keep = prev_path.len().saturating_sub(strip_len);
618        let mut full_path = prev_path[..keep].to_vec();
619        full_path.extend_from_slice(suffix);
620        path = full_path;
621    } else {
622        // V2/V3: NUL-terminated full path + padding
623        let nul = data[pos..]
624            .iter()
625            .position(|&b| b == 0)
626            .ok_or_else(|| Error::IndexError("entry path missing NUL terminator".to_owned()))?;
627        path = data[pos..pos + nul].to_vec();
628        pos += nul + 1;
629        let entry_start = 0usize;
630        let entry_len = pos - entry_start;
631        let padded = (entry_len + 7) & !7;
632        let padding = padded.saturating_sub(entry_len);
633        pos += padding;
634    }
635
636    Ok((
637        IndexEntry {
638            ctime_sec,
639            ctime_nsec,
640            mtime_sec,
641            mtime_nsec,
642            dev,
643            ino,
644            mode,
645            uid,
646            gid,
647            size,
648            oid,
649            flags,
650            flags_extended,
651            path,
652        },
653        pos,
654    ))
655}
656
657/// Serialise a single index entry into `out`.
658/// Read a variable-length integer (git's index v4 varint encoding).
659/// Returns (value, bytes_consumed).
660fn read_varint(data: &[u8]) -> (usize, usize) {
661    let mut value: usize = 0;
662    let mut shift = 0usize;
663    let mut pos = 0;
664    loop {
665        if pos >= data.len() {
666            break;
667        }
668        let byte = data[pos] as usize;
669        pos += 1;
670        value |= (byte & 0x7F) << shift;
671        if byte & 0x80 == 0 {
672            break;
673        }
674        shift += 7;
675        // Prevent infinite loops on malformed data
676        if shift > 28 {
677            break;
678        }
679    }
680    (value, pos)
681}
682
683fn serialize_entry(entry: &IndexEntry, version: u32, out: &mut Vec<u8>) {
684    let start = out.len();
685
686    let write_u32 = |out: &mut Vec<u8>, v: u32| out.extend_from_slice(&v.to_be_bytes());
687
688    write_u32(out, entry.ctime_sec);
689    write_u32(out, entry.ctime_nsec);
690    write_u32(out, entry.mtime_sec);
691    write_u32(out, entry.mtime_nsec);
692    write_u32(out, entry.dev);
693    write_u32(out, entry.ino);
694    write_u32(out, entry.mode);
695    write_u32(out, entry.uid);
696    write_u32(out, entry.gid);
697    write_u32(out, entry.size);
698    out.extend_from_slice(entry.oid.as_bytes());
699
700    // Set or clear the extended-flags bit in flags
701    let mut flags = entry.flags;
702    if version >= 3 && entry.flags_extended.is_some() {
703        flags |= 0x4000;
704    } else {
705        flags &= !0x4000;
706    }
707    // Overwrite path length bits (bottom 12)
708    let path_len = entry.path.len().min(0xFFF) as u16;
709    flags = (flags & 0xF000) | path_len;
710    out.extend_from_slice(&flags.to_be_bytes());
711
712    if version >= 3 {
713        if let Some(fe) = entry.flags_extended {
714            out.extend_from_slice(&fe.to_be_bytes());
715        }
716    }
717
718    out.extend_from_slice(&entry.path);
719    out.push(0);
720
721    // Pad to 8-byte boundary
722    let entry_len = out.len() - start;
723    let padded = (entry_len + 7) & !7;
724    let padding = padded - entry_len;
725    for _ in 0..padding {
726        out.push(0);
727    }
728}
729
730/// Build an [`IndexEntry`] by stat-ing a file on disk.
731///
732/// # Parameters
733///
734/// - `path` — absolute path to the file.
735/// - `rel_path` — path relative to the repo root (stored in the index).
736/// - `oid` — the object ID of the file's blob.
737/// - `mode` — file mode (use [`MODE_REGULAR`], [`MODE_EXECUTABLE`], etc.).
738///
739/// # Errors
740///
741/// Returns [`Error::Io`] if `stat` fails.
742pub fn entry_from_stat(
743    path: &Path,
744    rel_path: &[u8],
745    oid: ObjectId,
746    mode: u32,
747) -> Result<IndexEntry> {
748    let meta = fs::symlink_metadata(path)?;
749    Ok(entry_from_metadata(&meta, rel_path, oid, mode))
750}
751
752/// Build an [`IndexEntry`] from already-obtained metadata.
753///
754/// This avoids a redundant `stat()` call when the caller already has
755/// filesystem metadata (e.g. from `symlink_metadata`).
756#[must_use]
757pub fn entry_from_metadata(
758    meta: &fs::Metadata,
759    rel_path: &[u8],
760    oid: ObjectId,
761    mode: u32,
762) -> IndexEntry {
763    use std::os::unix::fs::MetadataExt;
764    IndexEntry {
765        ctime_sec: meta.ctime() as u32,
766        ctime_nsec: meta.ctime_nsec() as u32,
767        mtime_sec: meta.mtime() as u32,
768        mtime_nsec: meta.mtime_nsec() as u32,
769        dev: meta.dev() as u32,
770        ino: meta.ino() as u32,
771        mode,
772        uid: meta.uid(),
773        gid: meta.gid(),
774        size: meta.size() as u32,
775        oid,
776        flags: rel_path.len().min(0xFFF) as u16,
777        flags_extended: None,
778        path: rel_path.to_vec(),
779    }
780}
781
782/// Convert a `stat` mode to the Git index mode, normalised to one of the
783/// known constants ([`MODE_REGULAR`], [`MODE_EXECUTABLE`], [`MODE_SYMLINK`]).
784///
785/// Only the `S_IFMT` and execute bits are inspected; all other permission bits
786/// are discarded (Git stores only 644 or 755 for regular files).
787///
788/// # Parameters
789///
790/// - `raw_mode` — the raw `st_mode` value from `stat(2)`.
791#[must_use]
792pub fn normalize_mode(raw_mode: u32) -> u32 {
793    const S_IFMT: u32 = 0o170000;
794    const S_IFLNK: u32 = 0o120000;
795    const S_IFREG: u32 = 0o100000;
796
797    let fmt = raw_mode & S_IFMT;
798    if fmt == S_IFLNK {
799        return MODE_SYMLINK;
800    }
801    if fmt == S_IFREG {
802        // Executable if any execute bit is set
803        if raw_mode & 0o111 != 0 {
804            return MODE_EXECUTABLE;
805        }
806        return MODE_REGULAR;
807    }
808    // Fallback for everything else (devices, etc.) — treat as regular
809    MODE_REGULAR
810}
811
812#[cfg(test)]
813mod tests {
814    #![allow(clippy::expect_used, clippy::unwrap_used)]
815
816    use super::*;
817    use tempfile::TempDir;
818
819    fn dummy_oid() -> ObjectId {
820        ObjectId::from_bytes(&[0u8; 20]).unwrap()
821    }
822
823    fn make_entry(path: &str) -> IndexEntry {
824        IndexEntry {
825            ctime_sec: 0,
826            ctime_nsec: 0,
827            mtime_sec: 0,
828            mtime_nsec: 0,
829            dev: 0,
830            ino: 0,
831            mode: MODE_REGULAR,
832            uid: 0,
833            gid: 0,
834            size: 0,
835            oid: dummy_oid(),
836            flags: path.len().min(0xFFF) as u16,
837            flags_extended: None,
838            path: path.as_bytes().to_vec(),
839        }
840    }
841
842    #[test]
843    fn round_trip_empty_index() {
844        let dir = TempDir::new().unwrap();
845        let path = dir.path().join("index");
846
847        let idx = Index::new();
848        idx.write(&path).unwrap();
849
850        let loaded = Index::load(&path).unwrap();
851        assert_eq!(loaded.entries.len(), 0);
852    }
853
854    #[test]
855    fn round_trip_with_entries() {
856        let dir = TempDir::new().unwrap();
857        let path = dir.path().join("index");
858
859        let mut idx = Index::new();
860        idx.add_or_replace(make_entry("foo.txt"));
861        idx.add_or_replace(make_entry("bar/baz.txt"));
862        idx.write(&path).unwrap();
863
864        let loaded = Index::load(&path).unwrap();
865        assert_eq!(loaded.entries.len(), 2);
866        assert_eq!(loaded.entries[0].path, b"bar/baz.txt");
867        assert_eq!(loaded.entries[1].path, b"foo.txt");
868    }
869
870    #[test]
871    fn requested_v4_writes_a_compatible_index_format() {
872        let dir = TempDir::new().unwrap();
873        let path = dir.path().join("index");
874
875        let mut idx = Index {
876            version: 4,
877            ..Index::default()
878        };
879        idx.add_or_replace(make_entry("one"));
880        idx.add_or_replace(make_entry("two/one"));
881        idx.write(&path).unwrap();
882
883        let data = fs::read(&path).unwrap();
884        assert_eq!(&data[4..8], &2u32.to_be_bytes());
885
886        let loaded = Index::load(&path).unwrap();
887        assert_eq!(loaded.entries[0].path, b"one");
888        assert_eq!(loaded.entries[1].path, b"two/one");
889    }
890}