Skip to main content

grit_lib/
midx.rs

1//! Multi-pack-index (MIDX) file writing and minimal reading.
2//!
3//! Writes a Git-compatible `multi-pack-index` file (version 1, SHA-1) covering
4//! selected `pack-*.idx` files. Objects that appear in multiple packs keep the
5//! preferred pack's copy when `preferred_pack_idx` is set (matching Git's
6//! geometric repack tests).
7//!
8//! Incremental writes follow Git's split layout: layers live under
9//! `pack/multi-pack-index.d/multi-pack-index-<sha1>.midx` with ordering in
10//! `multi-pack-index-chain` (oldest hash first, newest last).
11
12use std::collections::{HashMap, HashSet};
13use std::fs;
14use std::io::{BufRead, BufReader, Write};
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18use crate::environment::RepositoryOptions;
19use crate::hash::verify_trailer;
20
21use crate::config::ConfigSet;
22use crate::diagnostics::{DiagnosticSink, NullDiagnostics, Warning};
23use crate::error::{Error, MidxError, Result};
24use crate::fs_publish::hard_link_or_copy;
25use crate::objects::{HashAlgo, ObjectId, ObjectInfo};
26use crate::pack::{read_pack_index_no_verify, PackIndex};
27use crate::pack_bitmap::{MidxBitmapWriter, PackBitmapWriteOptions};
28use crate::pack_map::PackData;
29use crate::pack_rev::append_hashfile_checksum;
30use crate::repo::Repository;
31
32const MIDX_SIGNATURE: u32 = 0x4d49_4458;
33const MIDX_VERSION_V1: u8 = 1;
34const MIDX_VERSION_V2: u8 = 2;
35const HASH_VERSION_SHA1: u8 = 1;
36const HASH_VERSION_SHA256: u8 = 2;
37const MIDX_HEADER_SIZE: usize = 12;
38pub(crate) const MIDX_MIN_FILE_LEN: u64 = (MIDX_HEADER_SIZE + 20) as u64;
39const CHUNK_TOC_ENTRY_SIZE: usize = 12;
40const MIDX_CHUNKID_PACKNAMES: u32 = 0x504e_414d;
41const MIDX_CHUNKID_OIDFANOUT: u32 = 0x4f49_4446;
42const MIDX_CHUNKID_OIDLOOKUP: u32 = 0x4f49_444c;
43const MIDX_CHUNKID_OBJECTOFFSETS: u32 = 0x4f4f_4646;
44const MIDX_CHUNKID_LARGEOFFSETS: u32 = 0x4c4f_4646;
45const MIDX_CHUNKID_REVINDEX: u32 = 0x5249_4458;
46const MIDX_CHUNKID_BITMAPPED_PACKS: u32 = 0x4254_4d50;
47
48// Git `pack-revindex.h` / `pack-write.c` (standalone `.rev` next to MIDX).
49const RIDX_SIGNATURE: u32 = 0x5249_4458;
50const RIDX_VERSION: u32 = 1;
51const RIDX_HEADER_SIZE: usize = 12;
52const MIDX_CHUNK_ALIGNMENT: usize = 4;
53
54// `git midx.h` (MIDX_LARGE_OFFSET_NEEDED).
55const MIDX_LARGE_OFFSET_NEEDED: u32 = 0x8000_0000;
56
57struct MidxEntry {
58    oid: ObjectId,
59    pack_id: u32,
60    offset: u64,
61    pack_mtime: std::time::SystemTime,
62}
63
64/// Options for writing a multi-pack index (extension of the simple writer).
65#[derive(Clone, Default)]
66pub struct WriteMultiPackIndexOptions {
67    /// When set, objects also present in other packs are taken from this pack
68    /// (`pack_names` index in the sorted name list).
69    pub preferred_pack_idx: Option<u32>,
70    /// Basename of the preferred pack (e.g. `pack-abc.idx` or `pack-abc.pack`); resolved against
71    /// the working pack name list after optional subset filtering.
72    pub preferred_pack_name: Option<String>,
73    /// If set, only these `pack-*.idx` basenames are included, in this order (Git `--stdin-packs`).
74    pub pack_names_subset_ordered: Option<Vec<String>>,
75    /// When true, append RIDX + BTMP chunks and write a reachability `.bitmap` sidecar
76    /// (non-incremental root MIDX and compaction only; incremental layers skip the bitmap).
77    pub write_bitmap: bool,
78    /// When true, write a new layer in `multi-pack-index.d/` and extend the chain file
79    /// instead of replacing `pack/multi-pack-index`.
80    pub incremental: bool,
81    /// When true with [`Self::write_bitmap`], emit a standalone `.rev` sidecar and omit the
82    /// embedded RIDX chunk (Git `GIT_TEST_MIDX_WRITE_REV` compatibility).
83    pub write_rev_sidecar: bool,
84    /// On-disk MIDX format version to write (`1` or `2`). `None` writes v1, which every Git
85    /// with multi-pack-index support can read; v2 (readable only by newer Git) is written
86    /// only when requested. [`crate::maintenance::write_midx`] fills this from `midx.version`.
87    /// Any other value fails with [`MidxError::UnknownWriteVersion`].
88    pub version: Option<u8>,
89    /// When set, non-fatal MIDX write warnings are reported here instead of being discarded.
90    pub diagnostics: Option<crate::diagnostics::DiagnosticsHandle>,
91}
92
93/// Validate a requested on-disk MIDX version, using `default` when none was requested.
94fn resolve_write_version(requested: Option<u8>, default: u8) -> Result<u8> {
95    match requested {
96        None => Ok(default),
97        Some(v @ (MIDX_VERSION_V1 | MIDX_VERSION_V2)) => Ok(v),
98        Some(v) => Err(MidxError::UnknownWriteVersion {
99            version: i64::from(v),
100        }
101        .into()),
102    }
103}
104
105/// Version a non-compaction write uses for `requested` (v1 when unset).
106///
107/// # Errors
108///
109/// [`MidxError::UnknownWriteVersion`] when `requested` is neither `1` nor `2`.
110pub(crate) fn midx_write_version(requested: Option<u8>) -> Result<u8> {
111    resolve_write_version(requested, MIDX_VERSION_V1)
112}
113
114/// Read `midx.version` from `config`.
115///
116/// Returns `Ok(None)` when the key is unset, so the writer's own default applies.
117///
118/// # Errors
119///
120/// [`Error::Config`] when the value is not an integer, and
121/// [`MidxError::UnknownWriteVersion`] when it is an integer other than `1` or `2`.
122pub(crate) fn midx_version_from_config(config: &ConfigSet) -> Result<Option<u8>> {
123    let Some(version) = config.get_i64_strict("midx.version")? else {
124        return Ok(None);
125    };
126    match u8::try_from(version) {
127        Ok(v @ (MIDX_VERSION_V1 | MIDX_VERSION_V2)) => Ok(Some(v)),
128        _ => Err(MidxError::UnknownWriteVersion { version }.into()),
129    }
130}
131
132/// Outcome of an optional MIDX reachability bitmap write.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
134#[non_exhaustive]
135pub enum MidxBitmapWriteOutcome {
136    /// No bitmap was requested for this write.
137    #[default]
138    NotRequested,
139    /// A `.bitmap` sidecar was written next to the MIDX.
140    Written,
141    /// Incremental chain layers do not carry bitmap sidecars (Git-compatible).
142    SkippedIncrementalLayer,
143}
144
145/// Result of [`write_multi_pack_index_with_options`].
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
147pub struct WriteMultiPackIndexResult {
148    /// Whether and how a reachability bitmap was handled.
149    pub bitmap: MidxBitmapWriteOutcome,
150}
151
152fn normalize_pack_idx_basename(raw: &str) -> Result<String> {
153    let t = raw.trim();
154    let t = std::path::Path::new(t)
155        .file_name()
156        .and_then(|s| s.to_str())
157        .unwrap_or(t);
158    let t = t.strip_prefix("./").unwrap_or(t);
159    if t.ends_with(".idx") {
160        Ok(t.to_string())
161    } else if t.ends_with(".pack") {
162        Ok(format!("{}.idx", t.strip_suffix(".pack").unwrap_or(t)))
163    } else {
164        Ok(format!("{t}.idx"))
165    }
166}
167
168/// Read a big-endian `u32` from `data` at byte offset `off`.
169///
170/// Returns [`Error::CorruptObject`] if `data` does not contain 4 bytes at `off`,
171/// replacing the previous fixed-width-slice `.try_into().unwrap()` with real
172/// bounds handling (the success-path value is unchanged).
173fn read_be_u32(data: &[u8], off: usize) -> Result<u32> {
174    let end = off.checked_add(4).filter(|&e| e <= data.len());
175    let Some(end) = end else {
176        return Err(Error::CorruptObject(
177            "truncated MIDX data reading u32".to_owned(),
178        ));
179    };
180    let bytes: [u8; 4] = data[off..end]
181        .try_into()
182        .map_err(|_| Error::CorruptObject("truncated MIDX data reading u32".to_owned()))?;
183    Ok(u32::from_be_bytes(bytes))
184}
185
186/// Read a big-endian `u64` from `data` at byte offset `off`.
187///
188/// Returns [`Error::CorruptObject`] if `data` does not contain 8 bytes at `off`,
189/// replacing the previous fixed-width-slice `.try_into().unwrap()` with real
190/// bounds handling (the success-path value is unchanged).
191fn read_be_u64(data: &[u8], off: usize) -> Result<u64> {
192    let end = off.checked_add(8).filter(|&e| e <= data.len());
193    let Some(end) = end else {
194        return Err(Error::CorruptObject(
195            "truncated MIDX data reading u64".to_owned(),
196        ));
197    };
198    let bytes: [u8; 8] = data[off..end]
199        .try_into()
200        .map_err(|_| Error::CorruptObject("truncated MIDX data reading u64".to_owned()))?;
201    Ok(u64::from_be_bytes(bytes))
202}
203
204struct MidxFileHeader {
205    num_chunks: u8,
206}
207
208fn parse_midx_header(data: &[u8]) -> Result<(MidxFileHeader, usize, u8)> {
209    if data.len() < MIDX_HEADER_SIZE + 20 {
210        return Err(Error::CorruptObject("midx file too small".to_owned()));
211    }
212    let sig = read_be_u32(data, 0)?;
213    if sig != MIDX_SIGNATURE {
214        return Err(Error::CorruptObject("bad MIDX signature".to_owned()));
215    }
216    let version = data[4];
217    if version != MIDX_VERSION_V1 && version != MIDX_VERSION_V2 {
218        return Err(Error::CorruptObject(format!(
219            "multi-pack-index version {version} not recognized"
220        )));
221    }
222    let object_hash_bytes = data[5];
223    let num_chunks = data[6];
224    let _num_packs = read_be_u32(data, 8)?;
225    Ok((
226        MidxFileHeader { num_chunks },
227        MIDX_HEADER_SIZE,
228        object_hash_bytes,
229    ))
230}
231
232fn parse_pack_names_blob(pn: &[u8]) -> Result<Vec<String>> {
233    let mut names = Vec::new();
234    let mut start = 0usize;
235    for (i, &b) in pn.iter().enumerate() {
236        if b == 0 && i >= start {
237            if i > start {
238                let s = std::str::from_utf8(&pn[start..i])
239                    .map_err(|_| Error::CorruptObject("non-utf8 pack name in MIDX".to_owned()))?;
240                names.push(s.to_string());
241            }
242            start = i + 1;
243        }
244    }
245    Ok(names)
246}
247
248/// Compare a pack basename that may use `.pack` or `.idx` with an MIDX pack name (`.idx`).
249fn cmp_idx_or_pack_name(idx_or_pack_name: &str, idx_name: &str) -> std::cmp::Ordering {
250    let a = idx_or_pack_name.as_bytes();
251    let b = idx_name.as_bytes();
252    let mut i = 0usize;
253    let min = a.len().min(b.len());
254    while i < min && a[i] == b[i] {
255        i += 1;
256    }
257    let suf_a = &a[i..];
258    let suf_b = &b[i..];
259    if suf_b == b"idx" && suf_a == b"pack" {
260        return std::cmp::Ordering::Equal;
261    }
262    suf_a.cmp(suf_b)
263}
264
265fn preferred_pack_index_by_mtime(pack_dir: &Path, names: &[String]) -> Result<Option<usize>> {
266    let mut best: Option<(usize, std::time::SystemTime)> = None;
267    for (i, n) in names.iter().enumerate() {
268        let meta = fs::metadata(pack_dir.join(n)).map_err(Error::Io)?;
269        let mtime = meta.modified().map_err(Error::Io)?;
270        match best {
271            None => best = Some((i, mtime)),
272            Some((_, t)) if mtime < t => best = Some((i, mtime)),
273            _ => {}
274        }
275    }
276    Ok(best.map(|(i, _)| i))
277}
278
279fn midx_d_dir(pack_dir: &Path) -> std::path::PathBuf {
280    pack_dir.join("multi-pack-index.d")
281}
282
283fn chain_file_path(pack_dir: &Path) -> std::path::PathBuf {
284    midx_d_dir(pack_dir).join("multi-pack-index-chain")
285}
286
287fn midx_chain_hash_hex_len(pack_dir: &Path) -> usize {
288    let Some(objects_dir) = pack_dir.parent() else {
289        return HashAlgo::Sha1.hex_len();
290    };
291    if repo_midx_hash_version_for_objects_dir(objects_dir) == HASH_VERSION_SHA256 {
292        HashAlgo::Sha256.hex_len()
293    } else {
294        HashAlgo::Sha1.hex_len()
295    }
296}
297
298fn is_valid_midx_chain_hash_line(t: &str, hex_len: usize) -> bool {
299    t.len() == hex_len && t.chars().all(|c| c.is_ascii_hexdigit())
300}
301
302fn read_chain_layer_hashes(pack_dir: &Path) -> Result<Vec<String>> {
303    let hex_len = midx_chain_hash_hex_len(pack_dir);
304    let path = chain_file_path(pack_dir);
305    let f = fs::File::open(&path).map_err(Error::Io)?;
306    let mut out = Vec::new();
307    for line in BufReader::new(f).lines() {
308        let line = line.map_err(Error::Io)?;
309        let t = line.trim();
310        if t.is_empty() {
311            continue;
312        }
313        if !is_valid_midx_chain_hash_line(t, hex_len) {
314            return Err(Error::CorruptObject(format!(
315                "invalid multi-pack-index chain line: {t}"
316            )));
317        }
318        out.push(t.to_ascii_lowercase());
319    }
320    Ok(out)
321}
322
323/// Resolve the path to the newest MIDX layer (root `multi-pack-index` or last chain entry).
324/// Return the MIDX hash-version byte expected for the repository owning `pack_dir`,
325/// mirroring git's `oid_version(r->hash_algo)` (SHA-1 → 1, SHA-256 → 2).
326///
327/// `pack_dir` is `<gitdir>/objects/pack`; the object format lives in the gitdir's
328/// `config` under `extensions.objectformat`. When the config cannot be read or the
329/// extension is absent, the default SHA-1 version (1) is returned.
330fn repo_midx_hash_version(pack_dir: &Path) -> u8 {
331    // pack_dir = <gitdir>/objects/pack -> gitdir = pack_dir/../..
332    let Some(objects_dir) = pack_dir.parent() else {
333        return HASH_VERSION_SHA1;
334    };
335    repo_midx_hash_version_for_objects_dir(objects_dir)
336}
337
338/// Open a MIDX layer for read (mmap when large enough, owned buffer when small or undersized).
339fn open_midx_layer_bytes(path: &Path) -> Result<Arc<PackData>> {
340    match PackData::open_with_min_len(path, MIDX_MIN_FILE_LEN) {
341        Ok(b) => Ok(b),
342        Err(Error::CorruptObject(_)) => Ok(PackData::from_owned(
343            std::fs::read(path).map_err(Error::Io)?,
344        )),
345        Err(err) => Err(err),
346    }
347}
348
349// Repository-scoped MIDX read cache: one [`PreparedMidxChain`] per `objects/pack` directory,
350// built once (no stat/read/parse on lookup hits). In-process writers call [`evict_pack_dir`].
351pub(crate) mod midx_cache {
352    use super::{
353        midx_chain_layer_paths_newest_first, midx_load_for_read, open_midx_layer_bytes,
354        repo_midx_hash_version_for_objects_dir, MidxLoadResult, MidxReadView,
355        MIDX_LARGE_OFFSET_NEEDED, MIDX_MIN_FILE_LEN,
356    };
357    use crate::diagnostics::{CollectingDiagnostics, DiagnosticSink, Warning};
358    use crate::error::{Error, Result};
359    use crate::objects::{ObjectId, ObjectIdPrefix, ObjectInfo};
360    use crate::pack::{read_object_at, read_pack_index_cached, PackIndex};
361    use crate::pack_map::PackData;
362    use std::collections::HashMap;
363    use std::fs;
364    use std::ops::ControlFlow;
365    use std::path::{Path, PathBuf};
366    use std::sync::atomic::{AtomicBool, Ordering};
367    use std::sync::{Arc, Mutex, OnceLock};
368
369    /// Parsed MIDX layer: fanout + binary search; pack indexes opened on first read per pack id.
370    pub struct PreparedMidxLayer {
371        bytes: Arc<PackData>,
372        fanout: [u32; 256],
373        oidl_off: usize,
374        num_objects: usize,
375        hash_len: usize,
376        ooff_off: usize,
377        loff: Option<(usize, usize)>,
378        pack_dir: PathBuf,
379        pack_names: Vec<String>,
380        pack_indexes: Vec<OnceLock<Option<Arc<PackIndex>>>>,
381    }
382
383    impl PreparedMidxLayer {
384        pub(super) fn prepare(bytes: Arc<PackData>, view: MidxReadView, pack_dir: &Path) -> Self {
385            let data = bytes.as_ref();
386            let hash_len = if data.get(5) == Some(&2) { 32 } else { 20 };
387            let mut fanout = [0u32; 256];
388            for (i, slot) in fanout.iter_mut().enumerate() {
389                *slot = read_be_u32(data, view.oidf_off + i * 4).unwrap_or(0);
390            }
391            Self {
392                fanout,
393                oidl_off: view.oidl_off,
394                ooff_off: view.ooff_off,
395                loff: view.loff,
396                num_objects: view.num_objects,
397                hash_len,
398                pack_dir: pack_dir.to_path_buf(),
399                pack_names: view.pack_names.clone(),
400                pack_indexes: view.pack_names.iter().map(|_| OnceLock::new()).collect(),
401                bytes,
402            }
403        }
404
405        fn open_pack_index(&self, pack_id: u32) -> Option<Arc<PackIndex>> {
406            let i = pack_id as usize;
407            let slot = self.pack_indexes.get(i)?;
408            slot.get_or_init(|| {
409                let idx_path = self.pack_dir.join(self.pack_names.get(i)?);
410                if !idx_path.is_file() {
411                    return None;
412                }
413                read_pack_index_cached(&idx_path).ok()
414            })
415            .clone()
416        }
417
418        fn oid_at(&self, pos: usize) -> &[u8] {
419            let base = self.oidl_off + pos * self.hash_len;
420            &self.bytes[base..base + self.hash_len]
421        }
422
423        #[inline(always)]
424        fn find_position(&self, oid: &ObjectId) -> Option<usize> {
425            let needle = oid.as_bytes();
426            if needle.len() != self.hash_len {
427                return None;
428            }
429            let first = needle[0] as usize;
430            let lo = if first == 0 {
431                0
432            } else {
433                self.fanout[first - 1] as usize
434            };
435            let hi = self.fanout[first] as usize;
436            if lo >= hi || hi > self.num_objects {
437                return None;
438            }
439            if hi - lo >= 64 {
440                if let Some(pos) = self.interpolation_search(lo, hi, needle) {
441                    return Some(pos);
442                }
443            }
444            self.binary_search(lo, hi, needle)
445        }
446
447        fn binary_search(&self, mut lo: usize, mut hi: usize, needle: &[u8]) -> Option<usize> {
448            while lo < hi {
449                let mid = lo + (hi - lo) / 2;
450                match self.oid_at(mid).cmp(needle) {
451                    std::cmp::Ordering::Less => lo = mid + 1,
452                    std::cmp::Ordering::Greater => hi = mid,
453                    std::cmp::Ordering::Equal => return Some(mid),
454                }
455            }
456            None
457        }
458
459        fn interpolation_search(&self, lo: usize, hi: usize, needle: &[u8]) -> Option<usize> {
460            use crate::pack_index::oid_interp_prefix_u64;
461            let mut lo = lo;
462            let mut hi = hi;
463            while lo < hi && needle >= self.oid_at(lo) && needle <= self.oid_at(hi - 1) {
464                let lo_oid = self.oid_at(lo);
465                let hi_oid = self.oid_at(hi - 1);
466                if lo_oid == hi_oid {
467                    return if lo_oid == needle { Some(lo) } else { None };
468                }
469                let span = hi - lo - 1;
470                let num = oid_interp_prefix_u64(needle);
471                let lo_val = oid_interp_prefix_u64(lo_oid);
472                let hi_val = oid_interp_prefix_u64(hi_oid);
473                let guess = if hi_val <= lo_val {
474                    lo + span / 2
475                } else {
476                    let num = u128::from(num.saturating_sub(lo_val));
477                    let den = u128::from(hi_val - lo_val);
478                    lo.saturating_add(((num * u128::from(span as u64)) / den) as usize)
479                };
480                let pos = guess.min(hi - 1);
481                match self.oid_at(pos).cmp(needle) {
482                    std::cmp::Ordering::Equal => return Some(pos),
483                    std::cmp::Ordering::Less => lo = pos + 1,
484                    std::cmp::Ordering::Greater => hi = pos,
485                }
486            }
487            None
488        }
489
490        fn validate_packs(&self, diagnostics: &dyn DiagnosticSink) {
491            for idx_name in &self.pack_names {
492                let idx_path = self.pack_dir.join(idx_name);
493                if !idx_path.is_file() {
494                    continue;
495                }
496                if crate::pack::read_pack_index_no_verify(&idx_path).is_err() {
497                    let mut pack_path = idx_path.clone();
498                    pack_path.set_extension("pack");
499                    diagnostics.warn(Warning::MidxPackIndexUnavailable {
500                        pack: pack_path.display().to_string(),
501                    });
502                }
503            }
504        }
505
506        /// Fanout bucket + binary search over OID lookup rows; returns in-pack offset.
507        #[inline(always)]
508        pub fn lookup_pack_and_offset(&self, oid: &ObjectId) -> Option<(u32, u64)> {
509            let pos = self.find_position(oid)?;
510            let ob = self.ooff_off + pos * 8;
511            let data = self.bytes.as_ref();
512            let pack_id = read_be_u32(data, ob).ok()?;
513            let raw_off = read_be_u32(data, ob + 4).ok()?;
514            decode_midx_object_offset(data, raw_off, self.loff).map(|offset| (pack_id, offset))
515        }
516
517        #[inline]
518        pub fn read_at(&self, pack_id: u32, offset: u64) -> Result<Option<crate::objects::Object>> {
519            let Some(idx) = self.open_pack_index(pack_id) else {
520                return Ok(None);
521            };
522            match read_object_at(idx.as_ref(), offset) {
523                Ok(obj) => Ok(Some(obj)),
524                Err(Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
525                Err(err) => Err(err),
526            }
527        }
528
529        #[cfg(test)]
530        fn synthetic_test_layer(n: usize) -> Self {
531            let hash_len = 20;
532            let first = 0x42u8;
533            let mut fanout = [0u32; 256];
534            fanout[0x42] = u32::try_from(n).expect("n fits u32");
535            let mut bytes = Vec::with_capacity(n * hash_len);
536            for i in 0..n {
537                let mut h = [0u8; 20];
538                h[0] = first;
539                h[19] = i as u8;
540                bytes.extend_from_slice(&h);
541            }
542            Self {
543                bytes: PackData::from_owned(bytes),
544                fanout,
545                oidl_off: 0,
546                ooff_off: 0,
547                loff: None,
548                num_objects: n,
549                hash_len,
550                pack_dir: PathBuf::from("/nonexistent"),
551                pack_names: vec!["test-pack.idx".into()],
552                pack_indexes: vec![OnceLock::new()],
553            }
554        }
555
556        #[cfg(test)]
557        fn test_find_position(&self, oid: &ObjectId) -> Option<usize> {
558            self.find_position(oid)
559        }
560
561        fn for_each_listed_object(
562            &self,
563            f: &mut dyn FnMut(&ObjectId) -> ControlFlow<()>,
564        ) -> Result<()> {
565            for i in 0..self.num_objects {
566                let oid = ObjectId::from_bytes(self.oid_at(i))
567                    .map_err(|e| Error::CorruptObject(e.to_string()))?;
568                if f(&oid).is_break() {
569                    break;
570                }
571            }
572            Ok(())
573        }
574
575        fn lookup_prefix(
576            &self,
577            prefix: &ObjectIdPrefix,
578            limit: usize,
579            count: &mut usize,
580            out: &mut Vec<ObjectId>,
581            seen: &mut std::collections::HashSet<ObjectId>,
582        ) -> Result<()> {
583            let lo = self.lower_bound_oid(prefix.lowest().as_bytes());
584            let hi = self.upper_bound_oid(prefix.highest().as_bytes());
585            for pos in lo..hi {
586                let digest = self.oid_at(pos);
587                if !prefix.matches_digest(digest) {
588                    continue;
589                }
590                let oid = ObjectId::from_bytes(digest)
591                    .map_err(|e| Error::CorruptObject(e.to_string()))?;
592                if !seen.insert(oid) {
593                    continue;
594                }
595                out.push(oid);
596                *count += 1;
597                if limit != 0 && *count >= limit {
598                    break;
599                }
600            }
601            Ok(())
602        }
603
604        fn lower_bound_oid(&self, needle: &[u8]) -> usize {
605            let mut lo = 0usize;
606            let mut hi = self.num_objects;
607            while lo < hi {
608                let mid = lo + (hi - lo) / 2;
609                if self.oid_at(mid) < needle {
610                    lo = mid + 1;
611                } else {
612                    hi = mid;
613                }
614            }
615            lo
616        }
617
618        fn upper_bound_oid(&self, needle: &[u8]) -> usize {
619            let mut lo = 0usize;
620            let mut hi = self.num_objects;
621            while lo < hi {
622                let mid = lo + (hi - lo) / 2;
623                if self.oid_at(mid) <= needle {
624                    lo = mid + 1;
625                } else {
626                    hi = mid;
627                }
628            }
629            lo
630        }
631    }
632
633    #[cfg(test)]
634    mod layer_lookup_tests {
635        use super::{read_be_u32, PreparedMidxLayer};
636        use crate::objects::ObjectId;
637
638        #[test]
639        fn interpolation_search_finds_oid_in_wide_fanout_bucket() {
640            let layer = PreparedMidxLayer::synthetic_test_layer(70);
641            let mut h = [0u8; 20];
642            h[0] = 0x42;
643            h[19] = 35;
644            let oid = ObjectId::from_bytes(&h).expect("oid");
645            assert_eq!(layer.test_find_position(&oid), Some(35));
646        }
647
648        #[cfg_attr(
649            not(feature = "full-tests"),
650            ignore = "starts system git or is heavy; run with --features full-tests"
651        )]
652        #[test]
653        fn strict_and_permissive_views_share_validated_backing_until_invalidation() {
654            use crate::diagnostics::NullDiagnostics;
655            use crate::pack_store::PackStore;
656            use std::sync::Arc;
657            let (root, _) = crate::midx::tests::midx_fixture(true).expect("fixture");
658            let objects = root.path().join(".git/objects");
659            let store = Arc::new(PackStore::new(objects.clone()));
660            let (permissive, strict) = PackStore::with_context(Arc::clone(&store), || {
661                (
662                    super::prepared_chain(&objects, &NullDiagnostics)
663                        .expect("permissive")
664                        .expect("chain"),
665                    super::prepared_chain_strict(&objects, &NullDiagnostics)
666                        .expect("strict")
667                        .expect("chain"),
668                )
669            });
670            assert!(Arc::ptr_eq(
671                &permissive.layers[0].bytes,
672                &strict.layers[0].bytes
673            ));
674            store.invalidate_midx();
675            let refreshed = PackStore::with_context(Arc::clone(&store), || {
676                super::prepared_chain_strict(&objects, &NullDiagnostics)
677                    .expect("refresh")
678                    .expect("chain")
679            });
680            assert!(!Arc::ptr_eq(
681                &refreshed.layers[0].bytes,
682                &strict.layers[0].bytes
683            ));
684        }
685
686        #[cfg_attr(
687            not(feature = "full-tests"),
688            ignore = "starts system git or is heavy; run with --features full-tests"
689        )]
690        #[test]
691        fn shared_layer_replays_load_warnings_for_the_other_view() {
692            use crate::diagnostics::{CollectingDiagnostics, NullDiagnostics, Warning};
693            use crate::midx::{write_multi_pack_index_with_options, WriteMultiPackIndexOptions};
694            use crate::objects::HashAlgo;
695            use crate::pack_store::PackStore;
696            use std::sync::Arc;
697            let (root, _) = crate::midx::tests::midx_fixture(false).expect("fixture");
698            let objects = root.path().join(".git/objects");
699            let pack_dir = objects.join("pack");
700            write_multi_pack_index_with_options(
701                &pack_dir,
702                &WriteMultiPackIndexOptions {
703                    preferred_pack_idx: Some(0),
704                    ..Default::default()
705                },
706            )
707            .expect("write RIDX");
708            let path = pack_dir.join("multi-pack-index");
709            let mut data = std::fs::read(&path).expect("read");
710            let (off, len) = crate::midx::find_chunk(&data, 12, crate::midx::MIDX_CHUNKID_REVINDEX)
711                .expect("RIDX");
712            assert!(len >= 4);
713            data.drain(off..off + 4);
714            let sentinel = 12 + data[6] as usize * 12;
715            let end = crate::midx::read_be_u64(&data, sentinel + 4).expect("end") - 4;
716            data[sentinel + 4..sentinel + 12].copy_from_slice(&end.to_be_bytes());
717            let payload = data.len() - 20;
718            let hash = HashAlgo::Sha1.checksum_digest(&data[..payload]);
719            data[payload..].copy_from_slice(&hash);
720            std::fs::write(&path, data).expect("write warning fixture");
721            let store = Arc::new(PackStore::new(objects.clone()));
722            PackStore::with_context(store, || {
723                let strict = super::prepared_chain_strict(&objects, &NullDiagnostics)
724                    .expect("strict")
725                    .expect("chain");
726                let sink = CollectingDiagnostics::new();
727                let permissive = super::prepared_chain(&objects, &sink)
728                    .expect("permissive")
729                    .expect("chain");
730                assert_eq!(
731                    sink.warnings(),
732                    vec![
733                        Warning::MidxRevIndexWrongSize,
734                        Warning::MidxBitmapMissingReverseIndex
735                    ]
736                );
737                assert!(Arc::ptr_eq(
738                    &strict.layers[0].bytes,
739                    &permissive.layers[0].bytes
740                ));
741            });
742        }
743
744        #[cfg_attr(
745            not(feature = "full-tests"),
746            ignore = "starts system git or is heavy; run with --features full-tests"
747        )]
748        #[test]
749        fn concurrent_views_share_one_layer_backing() {
750            use crate::diagnostics::NullDiagnostics;
751            use crate::pack_store::PackStore;
752            use std::sync::{Arc, Barrier};
753            let (root, _) = crate::midx::tests::midx_fixture(true).expect("fixture");
754            let objects = root.path().join(".git/objects");
755            let store = Arc::new(PackStore::new(objects.clone()));
756            let start = Arc::new(Barrier::new(4));
757            let handles: Vec<_> = (0..4)
758                .map(|i| {
759                    let store = Arc::clone(&store);
760                    let start = Arc::clone(&start);
761                    let objects = objects.clone();
762                    std::thread::spawn(move || {
763                        start.wait();
764                        PackStore::with_context(store, || {
765                            if i % 2 == 0 {
766                                super::prepared_chain(&objects, &NullDiagnostics)
767                            } else {
768                                super::prepared_chain_strict(&objects, &NullDiagnostics)
769                            }
770                        })
771                        .expect("prepare")
772                        .expect("chain")
773                    })
774                })
775                .collect();
776            let chains: Vec<_> = handles
777                .into_iter()
778                .map(|h| h.join().expect("thread"))
779                .collect();
780            assert!(chains
781                .iter()
782                .all(|chain| Arc::ptr_eq(&chain.layers[0].bytes, &chains[0].layers[0].bytes)));
783        }
784
785        #[test]
786        fn midx_cache_read_be_u32_truncated() {
787            assert!(read_be_u32(&[0, 1, 2], 0).is_err());
788        }
789    }
790
791    /// All incremental MIDX layers for one `objects/pack` directory (newest first).
792    pub struct PreparedMidxChain {
793        layers: Vec<Arc<PreparedMidxLayer>>,
794        packs_validated: AtomicBool,
795    }
796
797    impl PreparedMidxChain {
798        #[inline(always)]
799        pub fn lookup_pack_and_offset(&self, oid: &ObjectId) -> Option<(u32, u64)> {
800            for layer in &self.layers {
801                if let Some(found) = layer.lookup_pack_and_offset(oid) {
802                    return Some(found);
803                }
804            }
805            None
806        }
807
808        pub fn oid_listed_in_tip(&self, oid: &ObjectId) -> bool {
809            self.lookup_pack_and_offset(oid).is_some()
810        }
811
812        pub fn try_read_object(
813            &self,
814            oid: &ObjectId,
815            diagnostics: &dyn DiagnosticSink,
816        ) -> Result<Option<crate::objects::Object>> {
817            for layer in &self.layers {
818                let Some((pack_id, offset)) = layer.lookup_pack_and_offset(oid) else {
819                    continue;
820                };
821                self.ensure_packs_validated(diagnostics);
822                match layer.read_at(pack_id, offset)? {
823                    Some(obj) => return Ok(Some(obj)),
824                    None => return Ok(None),
825                }
826            }
827            Ok(None)
828        }
829
830        pub fn try_read_info(
831            &self,
832            oid: &ObjectId,
833            diagnostics: &dyn DiagnosticSink,
834        ) -> Result<Option<ObjectInfo>> {
835            for layer in &self.layers {
836                let Some((pack_id, _offset)) = layer.lookup_pack_and_offset(oid) else {
837                    continue;
838                };
839                self.ensure_packs_validated(diagnostics);
840                let Some(idx) = layer.open_pack_index(pack_id) else {
841                    return Ok(None);
842                };
843                return crate::pack::read_object_info_from_pack(idx.as_ref(), oid).map(Some);
844            }
845            Ok(None)
846        }
847
848        pub fn ensure_packs_validated(&self, diagnostics: &dyn DiagnosticSink) {
849            if self.packs_validated.swap(true, Ordering::Relaxed) {
850                return;
851            }
852            for layer in &self.layers {
853                layer.validate_packs(diagnostics);
854            }
855        }
856
857        /// Pack index basenames referenced by any layer in this chain.
858        pub(crate) fn covered_pack_basenames(&self) -> std::collections::HashSet<String> {
859            let mut out = std::collections::HashSet::new();
860            for layer in &self.layers {
861                for name in &layer.pack_names {
862                    out.insert(name.clone());
863                }
864            }
865            out
866        }
867
868        /// Invoke `f` for each OID listed in the chain (newest layer wins deduplication).
869        pub(crate) fn for_each_listed_object(
870            &self,
871            f: &mut dyn FnMut(&ObjectId) -> ControlFlow<()>,
872        ) -> Result<()> {
873            use std::collections::HashSet;
874            let mut seen = HashSet::new();
875            for layer in &self.layers {
876                let mut stop = false;
877                layer.for_each_listed_object(&mut |oid| {
878                    if !seen.insert(*oid) {
879                        return ControlFlow::Continue(());
880                    }
881                    if f(oid).is_break() {
882                        stop = true;
883                        return ControlFlow::Break(());
884                    }
885                    ControlFlow::Continue(())
886                })?;
887                if stop {
888                    break;
889                }
890            }
891            Ok(())
892        }
893
894        /// Collect OIDs that start with `prefix`, up to `limit` (0 means no limit).
895        pub(crate) fn lookup_prefix(
896            &self,
897            prefix: &ObjectIdPrefix,
898            limit: usize,
899            out: &mut Vec<ObjectId>,
900        ) -> Result<()> {
901            use std::collections::HashSet;
902            let mut seen = HashSet::new();
903            let mut count = 0usize;
904            for layer in &self.layers {
905                layer.lookup_prefix(prefix, limit, &mut count, out, &mut seen)?;
906                if limit != 0 && count >= limit {
907                    break;
908                }
909            }
910            Ok(())
911        }
912    }
913
914    fn read_be_u32(data: &[u8], off: usize) -> Result<u32> {
915        if off + 4 > data.len() {
916            return Err(Error::CorruptObject("truncated MIDX u32".to_owned()));
917        }
918        Ok(u32::from_be_bytes([
919            data[off],
920            data[off + 1],
921            data[off + 2],
922            data[off + 3],
923        ]))
924    }
925
926    fn read_be_u64(data: &[u8], off: usize) -> Result<u64> {
927        if off + 8 > data.len() {
928            return Err(Error::CorruptObject("truncated MIDX u64".to_owned()));
929        }
930        Ok(u64::from_be_bytes([
931            data[off],
932            data[off + 1],
933            data[off + 2],
934            data[off + 3],
935            data[off + 4],
936            data[off + 5],
937            data[off + 6],
938            data[off + 7],
939        ]))
940    }
941
942    fn decode_midx_object_offset(
943        data: &[u8],
944        raw_off: u32,
945        loff: Option<(usize, usize)>,
946    ) -> Option<u64> {
947        if loff.is_none() {
948            return Some(u64::from(raw_off));
949        }
950        if (raw_off & MIDX_LARGE_OFFSET_NEEDED) != 0 {
951            let idx = (raw_off & !MIDX_LARGE_OFFSET_NEEDED) as usize;
952            let need = (idx + 1) * 8;
953            match loff {
954                Some((loff_off, loff_len)) if loff_len >= need => {
955                    read_be_u64(data, loff_off + idx * 8).ok()
956                }
957                _ => None,
958            }
959        } else {
960            Some(u64::from(raw_off))
961        }
962    }
963
964    #[cfg(test)]
965    pub(super) fn decode_midx_object_offset_for_test(
966        data: &[u8],
967        raw_off: u32,
968        loff: Option<(usize, usize)>,
969    ) -> Option<u64> {
970        decode_midx_object_offset(data, raw_off, loff)
971    }
972
973    struct CachedLayer {
974        stamp: (Option<std::time::SystemTime>, u64),
975        prepared: Arc<PreparedMidxLayer>,
976        warnings: Vec<Warning>,
977    }
978
979    #[derive(Default)]
980    pub(crate) struct State {
981        layers: HashMap<PathBuf, CachedLayer>,
982        chains: HashMap<PathBuf, Arc<PreparedMidxChain>>,
983        strict_chains: HashMap<PathBuf, Arc<PreparedMidxChain>>,
984        hot_chain: Option<(PathBuf, Arc<PreparedMidxChain>)>,
985    }
986
987    fn build_chain_impl(
988        state: &mut State,
989        objects_dir: &Path,
990        diagnostics: &dyn DiagnosticSink,
991        require_every_layer: bool,
992    ) -> Result<Option<Arc<PreparedMidxChain>>> {
993        let pack_dir = objects_dir.join("pack");
994        let paths = midx_chain_layer_paths_newest_first(&pack_dir);
995        if paths.is_empty() {
996            return Ok(None);
997        }
998        let path_count = paths.len();
999        let hash_version = repo_midx_hash_version_for_objects_dir(objects_dir);
1000        let mut layers = Vec::with_capacity(path_count);
1001        for path in paths {
1002            let metadata = match fs::metadata(&path) {
1003                Ok(meta) => meta,
1004                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1005                    if require_every_layer {
1006                        return Ok(None);
1007                    }
1008                    continue;
1009                }
1010                Err(err) => return Err(Error::Io(err)),
1011            };
1012            if metadata.len() < MIDX_MIN_FILE_LEN {
1013                if require_every_layer {
1014                    return Ok(None);
1015                }
1016                continue;
1017            }
1018            let stamp = (metadata.modified().ok(), metadata.len());
1019            if let Some(cached) = state.layers.get(&path).filter(|cached| {
1020                stamp.0.is_some()
1021                    && cached.stamp == stamp
1022                    && cached.prepared.hash_len == if hash_version == 2 { 32 } else { 20 }
1023            }) {
1024                for warning in &cached.warnings {
1025                    diagnostics.warn(warning.clone());
1026                }
1027                layers.push(Arc::clone(&cached.prepared));
1028                continue;
1029            }
1030            let bytes = match open_midx_layer_bytes(&path) {
1031                Ok(b) => b,
1032                Err(Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {
1033                    if require_every_layer {
1034                        return Ok(None);
1035                    }
1036                    continue;
1037                }
1038                Err(Error::CorruptObject(_)) => {
1039                    if require_every_layer {
1040                        return Ok(None);
1041                    }
1042                    continue;
1043                }
1044                Err(err) => return Err(err),
1045            };
1046            // Retain load warnings so preparing the other view still reports them even
1047            // when the first view was opened with NullDiagnostics.
1048            let collected = CollectingDiagnostics::new();
1049            let loaded = midx_load_for_read(bytes.as_ref(), hash_version, &collected);
1050            let warnings = collected.warnings();
1051            for warning in &warnings {
1052                diagnostics.warn(warning.clone());
1053            }
1054            let view = match loaded {
1055                Ok(MidxLoadResult::Ok(v)) => v,
1056                Ok(MidxLoadResult::Skip) => {
1057                    if require_every_layer {
1058                        return Ok(None);
1059                    }
1060                    continue;
1061                }
1062                Err(Error::Midx(_)) if require_every_layer => return Ok(None),
1063                Err(err) => return Err(err),
1064            };
1065            let prepared = Arc::new(PreparedMidxLayer::prepare(bytes, view, &pack_dir));
1066            state.layers.insert(
1067                path,
1068                CachedLayer {
1069                    stamp,
1070                    prepared: Arc::clone(&prepared),
1071                    warnings,
1072                },
1073            );
1074            layers.push(prepared);
1075        }
1076        if layers.is_empty() {
1077            return Ok(None);
1078        }
1079        if require_every_layer && layers.len() != path_count {
1080            return Ok(None);
1081        }
1082        Ok(Some(Arc::new(PreparedMidxChain {
1083            layers,
1084            packs_validated: AtomicBool::new(false),
1085        })))
1086    }
1087
1088    /// Cached permissive chain: usable layers survive missing or skipped layers.
1089    pub fn prepared_chain(
1090        objects_dir: &Path,
1091        diagnostics: &dyn DiagnosticSink,
1092    ) -> Result<Option<Arc<PreparedMidxChain>>> {
1093        let (result, warnings) =
1094            crate::pack_store::PackStore::with_current_or_implicit_midx(|state| {
1095                if let Some((path, chain)) = &state.hot_chain {
1096                    if path == objects_dir {
1097                        return (Ok(Some(Arc::clone(chain))), Vec::new());
1098                    }
1099                }
1100                let pack_dir = objects_dir.join("pack");
1101                if let Some(chain) = state.chains.get(&pack_dir) {
1102                    let chain = Arc::clone(chain);
1103                    state.hot_chain = Some((objects_dir.to_path_buf(), Arc::clone(&chain)));
1104                    return (Ok(Some(chain)), Vec::new());
1105                }
1106                let collected = CollectingDiagnostics::new();
1107                let result = build_chain_impl(state, objects_dir, &collected, false);
1108                if let Ok(Some(chain)) = &result {
1109                    state.chains.insert(pack_dir, Arc::clone(chain));
1110                    state.hot_chain = Some((objects_dir.to_path_buf(), Arc::clone(chain)));
1111                }
1112                (result, collected.warnings())
1113            });
1114        // Caller-provided sinks may reenter the ODB; never call them under the cache lock.
1115        for warning in warnings {
1116            diagnostics.warn(warning);
1117        }
1118        result
1119    }
1120
1121    /// Every referenced layer must load cleanly; valid layers share backing with
1122    /// the permissive view, but a partial permissive chain never satisfies this view.
1123    pub fn prepared_chain_strict(
1124        objects_dir: &Path,
1125        diagnostics: &dyn DiagnosticSink,
1126    ) -> Result<Option<Arc<PreparedMidxChain>>> {
1127        let (result, warnings) =
1128            crate::pack_store::PackStore::with_current_or_implicit_midx(|state| {
1129                let pack_dir = objects_dir.join("pack");
1130                if let Some(chain) = state.strict_chains.get(&pack_dir) {
1131                    return (Ok(Some(Arc::clone(chain))), Vec::new());
1132                }
1133                let collected = CollectingDiagnostics::new();
1134                let result = build_chain_impl(state, objects_dir, &collected, true);
1135                if let Ok(Some(chain)) = &result {
1136                    state.strict_chains.insert(pack_dir, Arc::clone(chain));
1137                }
1138                (result, collected.warnings())
1139            });
1140        for warning in warnings {
1141            diagnostics.warn(warning);
1142        }
1143        result
1144    }
1145
1146    pub(super) fn cached_pack_names(path: &Path, metadata: &fs::Metadata) -> Option<Vec<String>> {
1147        let stamp = (metadata.modified().ok(), metadata.len());
1148        crate::pack_store::PackStore::with_current_or_implicit_midx(|state| {
1149            state
1150                .layers
1151                .get(path)
1152                .filter(|cached| stamp.0.is_some() && cached.stamp == stamp)
1153                .map(|cached| cached.prepared.pack_names.clone())
1154        })
1155    }
1156
1157    pub fn evict_pack_dir(pack_dir: &Path) {
1158        crate::pack_store::PackStore::with_current_or_implicit_midx(|state| {
1159            evict_pack_dir_in_state(state, pack_dir);
1160        });
1161    }
1162
1163    pub(crate) fn evict_pack_dir_on(state: &Mutex<State>, pack_dir: &Path) {
1164        let mut state = state.lock().unwrap_or_else(|poison| poison.into_inner());
1165        evict_pack_dir_in_state(&mut state, pack_dir);
1166    }
1167
1168    fn evict_pack_dir_in_state(state: &mut State, pack_dir: &Path) {
1169        if state.hot_chain.as_ref().is_some_and(|(objects_dir, _)| {
1170            objects_dir.join("pack") == pack_dir || pack_dir.starts_with(objects_dir)
1171        }) {
1172            state.hot_chain = None;
1173        }
1174        state.layers.retain(|path, _| !path.starts_with(pack_dir));
1175        state.chains.retain(|path, _| !path.starts_with(pack_dir));
1176        state
1177            .strict_chains
1178            .retain(|path, _| !path.starts_with(pack_dir));
1179    }
1180}
1181
1182/// Cached MIDX chain for object reads (see [`midx_cache::prepared_chain`]).
1183pub(crate) use midx_cache::PreparedMidxChain;
1184
1185pub(crate) fn prepared_midx_chain(objects_dir: &Path) -> Result<Option<Arc<PreparedMidxChain>>> {
1186    midx_cache::prepared_chain(objects_dir, &NullDiagnostics)
1187}
1188
1189/// Prepared chain requiring every on-disk layer file to parse (for [`crate::odb::store::MidxObjects`]).
1190pub(crate) fn prepared_midx_chain_strict(
1191    objects_dir: &Path,
1192) -> Result<Option<Arc<PreparedMidxChain>>> {
1193    midx_cache::prepared_chain_strict(objects_dir, &NullDiagnostics)
1194}
1195
1196/// MIDX layer files to search on object reads, newest chain layer first.
1197fn midx_chain_layer_paths_newest_first(pack_dir: &Path) -> Vec<PathBuf> {
1198    let root = pack_dir.join("multi-pack-index");
1199    if root.is_file() {
1200        return vec![root];
1201    }
1202    let Ok(hashes) = read_chain_layer_hashes(pack_dir) else {
1203        return Vec::new();
1204    };
1205    if hashes.is_empty() {
1206        return Vec::new();
1207    }
1208    let midx_d = midx_d_dir(pack_dir);
1209    hashes
1210        .iter()
1211        .rev()
1212        .map(|h| midx_d.join(format!("multi-pack-index-{h}.midx")))
1213        .collect()
1214}
1215
1216/// Like [`repo_midx_hash_version`] but starting from the `objects` directory.
1217fn repo_midx_hash_version_for_objects_dir(objects_dir: &Path) -> u8 {
1218    let Some(gitdir) = objects_dir.parent() else {
1219        return HASH_VERSION_SHA1;
1220    };
1221    sniff_objectformat_hash_version(&gitdir.join("config"))
1222}
1223
1224/// Cached `[extensions] objectformat` scan of one config file (reuses process-local config text cache).
1225fn sniff_objectformat_hash_version(config_path: &Path) -> u8 {
1226    sniff_objectformat_hash_version_uncached(config_path)
1227}
1228
1229/// Uncached `[extensions] objectformat` scan of one config file.
1230fn sniff_objectformat_hash_version_uncached(config_path: &Path) -> u8 {
1231    let text = if let Some(caches) =
1232        crate::pack_store::PackStore::repo_caches_for_config_path(config_path)
1233    {
1234        if let Some(text) = caches.cached_local_config_text_for_path(config_path) {
1235            text
1236        } else {
1237            let Ok(text) = crate::repo_caches::read_local_config_file(config_path) else {
1238                return HASH_VERSION_SHA1;
1239            };
1240            text
1241        }
1242    } else {
1243        let Ok(text) = crate::repo_caches::read_local_config_file(config_path) else {
1244            return HASH_VERSION_SHA1;
1245        };
1246        text
1247    };
1248    // Minimal scan for `[extensions]` ... `objectformat = sha256`. Section and key
1249    // names are case-insensitive in git config; values are case-sensitive but git
1250    // only accepts the literals "sha1"/"sha256".
1251    let mut in_extensions = false;
1252    for raw in text.lines() {
1253        let line = raw.trim();
1254        if line.starts_with('[') {
1255            let section = line.trim_start_matches('[').trim_end_matches(']');
1256            let name = section.split_whitespace().next().unwrap_or("");
1257            in_extensions = name.eq_ignore_ascii_case("extensions");
1258            continue;
1259        }
1260        if !in_extensions {
1261            continue;
1262        }
1263        if let Some((key, value)) = line.split_once('=') {
1264            if key.trim().eq_ignore_ascii_case("objectformat")
1265                && value.trim().eq_ignore_ascii_case("sha256")
1266            {
1267                return HASH_VERSION_SHA256;
1268            }
1269        }
1270    }
1271    HASH_VERSION_SHA1
1272}
1273
1274/// Whether a tip MIDX file exists under `pack_dir` (live filesystem probe).
1275#[must_use]
1276pub fn cached_tip_midx_path(pack_dir: &Path) -> Option<std::path::PathBuf> {
1277    resolve_tip_midx_path(pack_dir)
1278}
1279
1280/// Drop cached MIDX bytes and tip resolution after pack-directory changes.
1281pub fn evict_midx_read_cache_for_pack_dir(pack_dir: &Path) {
1282    midx_cache::evict_pack_dir(pack_dir);
1283}
1284
1285/// `resolve_tip_midx_path` function.
1286pub fn resolve_tip_midx_path(pack_dir: &Path) -> Option<std::path::PathBuf> {
1287    let root = pack_dir.join("multi-pack-index");
1288    if root.exists() {
1289        return Some(root);
1290    }
1291    let hashes = read_chain_layer_hashes(pack_dir).ok()?;
1292    let last = hashes.last()?;
1293    Some(midx_d_dir(pack_dir).join(format!("multi-pack-index-{last}.midx")))
1294}
1295
1296/// Resolve a specific MIDX layer file by its lowercase hex checksum. Searches the
1297/// incremental chain (`multi-pack-index.d/multi-pack-index-<hash>.midx`) and the
1298/// single-file root MIDX. Returns `None` when no layer matches that checksum.
1299/// Sidecar path `multi-pack-index-<hash>.<ext>` in the same directory as `midx_path`.
1300pub fn midx_sidecar_path_for_midx_file(midx_path: &Path, ext: &str) -> Result<PathBuf> {
1301    if let Some(name) = midx_path.file_name().and_then(|s| s.to_str()) {
1302        if let Some(hash_part) = name
1303            .strip_prefix("multi-pack-index-")
1304            .and_then(|r| r.strip_suffix(".midx"))
1305        {
1306            let parent = midx_path.parent().ok_or_else(|| {
1307                Error::CorruptObject("MIDX path has no parent directory".to_owned())
1308            })?;
1309            return Ok(parent.join(format!("multi-pack-index-{hash_part}.{ext}")));
1310        }
1311    }
1312    let hash_hex = midx_checksum_hex_from_path(midx_path)?;
1313    let parent = midx_path
1314        .parent()
1315        .ok_or_else(|| Error::CorruptObject("MIDX path has no parent directory".to_owned()))?;
1316    Ok(parent.join(format!("multi-pack-index-{hash_hex}.{ext}")))
1317}
1318
1319/// Newest chain layer (or root MIDX) that has a non-empty `.bitmap` sidecar beside its MIDX file.
1320///
1321/// When the tip incremental layer has no bitmap, an older compacted layer's sidecar under
1322/// `multi-pack-index.d/` is returned so readers match Git's split-layout layout.
1323#[must_use]
1324pub fn resolve_midx_bitmap_layer(pack_dir: &Path) -> Option<(PathBuf, PathBuf)> {
1325    let is_real_bitmap =
1326        |path: &Path| path.is_file() && path.metadata().ok().is_some_and(|m| m.len() > 32);
1327
1328    if let Ok(chain) = read_chain_layer_hashes(pack_dir) {
1329        if !chain.is_empty() {
1330            let midx_d = midx_d_dir(pack_dir);
1331            for hash in chain.iter().rev() {
1332                let midx_path = midx_d.join(format!("multi-pack-index-{hash}.midx"));
1333                if !midx_path.is_file() {
1334                    continue;
1335                }
1336                let bitmap_path = midx_d.join(format!("multi-pack-index-{hash}.bitmap"));
1337                if is_real_bitmap(&bitmap_path) {
1338                    return Some((bitmap_path, midx_path));
1339                }
1340            }
1341        }
1342    }
1343
1344    let root = pack_dir.join("multi-pack-index");
1345    if root.is_file() {
1346        let hash_hex = midx_checksum_hex_from_path(&root).ok()?;
1347        let bitmap_path = pack_dir.join(format!("multi-pack-index-{hash_hex}.bitmap"));
1348        if is_real_bitmap(&bitmap_path) {
1349            return Some((bitmap_path, root));
1350        }
1351    }
1352    None
1353}
1354
1355/// `resolve_midx_layer_path` function.
1356pub fn resolve_midx_layer_path(pack_dir: &Path, checksum: &str) -> Option<std::path::PathBuf> {
1357    let checksum = checksum.to_ascii_lowercase();
1358    if let Ok(hashes) = read_chain_layer_hashes(pack_dir) {
1359        if hashes.contains(&checksum) {
1360            return Some(midx_d_dir(pack_dir).join(format!("multi-pack-index-{checksum}.midx")));
1361        }
1362    }
1363    let root = pack_dir.join("multi-pack-index");
1364    if root.exists() {
1365        if let Ok(hex) = midx_checksum_hex_from_path(&root) {
1366            if hex == checksum {
1367                return Some(root);
1368            }
1369        }
1370    }
1371    None
1372}
1373
1374fn load_midx_file(path: &Path) -> Result<Vec<u8>> {
1375    let data = fs::read(path).map_err(Error::Io)?;
1376    let _ = parse_midx_header(&data)?;
1377    Ok(data)
1378}
1379
1380/// OID width implied by a MIDX file's header hash-version byte (`data[5]`):
1381/// 2 → SHA-256 (32 bytes), anything else → SHA-1 (20 bytes).
1382fn midx_hash_len(data: &[u8]) -> usize {
1383    if data.len() > 5 && data[5] == 2 {
1384        32
1385    } else {
1386        20
1387    }
1388}
1389
1390fn oids_and_packs_from_midx_data(data: &[u8]) -> Result<(HashSet<ObjectId>, Vec<String>)> {
1391    let hash_len = midx_hash_len(data);
1392    let (_, hdr_end, _) = parse_midx_header(data)?;
1393    let (pn_off, pn_len) = find_chunk(data, hdr_end, MIDX_CHUNKID_PACKNAMES)?;
1394    let pack_names = parse_pack_names_blob(&data[pn_off..pn_off + pn_len])?;
1395    let (_ooff_off, ooff_len) = find_chunk(data, hdr_end, MIDX_CHUNKID_OBJECTOFFSETS)?;
1396    let (oidl_off, oidl_len) = find_chunk(data, hdr_end, MIDX_CHUNKID_OIDLOOKUP)?;
1397    let num_objects = ooff_len / 8;
1398    if oidl_len != num_objects * hash_len {
1399        return Err(Error::CorruptObject(
1400            "MIDX oid-lookup size mismatch".to_owned(),
1401        ));
1402    }
1403    let mut oids = HashSet::with_capacity(num_objects);
1404    for i in 0..num_objects {
1405        let start = oidl_off + i * hash_len;
1406        let oid = ObjectId::from_bytes(&data[start..start + hash_len])?;
1407        oids.insert(oid);
1408    }
1409    Ok((oids, pack_names))
1410}
1411
1412fn collect_incremental_base(pack_dir: &Path) -> Result<(HashSet<ObjectId>, HashSet<String>)> {
1413    let mut oids = HashSet::new();
1414    let mut packs = HashSet::new();
1415    let root = pack_dir.join("multi-pack-index");
1416    let chain_path = chain_file_path(pack_dir);
1417    if chain_path.exists() {
1418        for h in read_chain_layer_hashes(pack_dir)? {
1419            let p = midx_d_dir(pack_dir).join(format!("multi-pack-index-{h}.midx"));
1420            let data = load_midx_file(&p)?;
1421            let (layer_oids, names) = oids_and_packs_from_midx_data(&data)?;
1422            oids.extend(layer_oids);
1423            for n in names {
1424                packs.insert(n);
1425            }
1426        }
1427        return Ok((oids, packs));
1428    }
1429    if root.exists() {
1430        let data = load_midx_file(&root)?;
1431        let (o, names) = oids_and_packs_from_midx_data(&data)?;
1432        oids = o;
1433        for n in names {
1434            packs.insert(n);
1435        }
1436    }
1437    Ok((oids, packs))
1438}
1439
1440fn midx_trailing_hash_len(data: &[u8]) -> Result<usize> {
1441    if data.len() < MIDX_HEADER_SIZE {
1442        return Err(Error::CorruptObject(
1443            "midx file too small for header".to_owned(),
1444        ));
1445    }
1446    HashAlgo::try_from(data[5]).map(|a| a.len()).map_err(|e| {
1447        Error::CorruptObject(format!(
1448            "multi-pack-index hash version {} not recognized",
1449            e.0
1450        ))
1451    })
1452}
1453
1454fn midx_checksum_hex_from_path(path: &Path) -> Result<String> {
1455    let data = fs::read(path).map_err(Error::Io)?;
1456    let hash_len = midx_trailing_hash_len(&data)?;
1457    if data.len() < hash_len {
1458        return Err(Error::CorruptObject(
1459            "midx too small for checksum".to_owned(),
1460        ));
1461    }
1462    let hash = &data[data.len() - hash_len..];
1463    Ok(hex::encode(hash))
1464}
1465
1466fn link_root_midx_into_chain(pack_dir: &Path, root_checksum_hex: &str) -> Result<()> {
1467    let midx_d = midx_d_dir(pack_dir);
1468    fs::create_dir_all(&midx_d).map_err(Error::Io)?;
1469    let dst_midx = midx_d.join(format!("multi-pack-index-{root_checksum_hex}.midx"));
1470    hard_link_or_copy(&pack_dir.join("multi-pack-index"), &dst_midx)?;
1471    let exts = ["bitmap", "rev"];
1472    for ext in exts {
1473        let src = pack_dir.join(format!("multi-pack-index-{root_checksum_hex}.{ext}"));
1474        if src.exists() {
1475            let dst = midx_d.join(format!("multi-pack-index-{root_checksum_hex}.{ext}"));
1476            hard_link_or_copy(&src, &dst)?;
1477        }
1478    }
1479    Ok(())
1480}
1481
1482fn clear_stale_split_layers(pack_dir: &Path, keep: &[String]) -> Result<()> {
1483    let midx_d = midx_d_dir(pack_dir);
1484    if !midx_d.exists() {
1485        return Ok(());
1486    }
1487    let keep: HashSet<&str> = keep.iter().map(|s| s.as_str()).collect();
1488    for ent in fs::read_dir(&midx_d).map_err(Error::Io)? {
1489        let ent = ent.map_err(Error::Io)?;
1490        let name = ent.file_name().to_string_lossy().to_string();
1491        let Some(rest) = name.strip_prefix("multi-pack-index-") else {
1492            continue;
1493        };
1494        let Some((hash_part, _ext)) = rest.split_once('.') else {
1495            continue;
1496        };
1497        if (hash_part.len() == 40 || hash_part.len() == 64) && !keep.contains(hash_part) {
1498            let _ = fs::remove_file(ent.path());
1499        }
1500    }
1501    Ok(())
1502}
1503
1504/// Remove every incremental MIDX layer file (`multi-pack-index-<hash>.midx`,
1505/// `.bitmap`, `.rev`) from `multi-pack-index.d/` and unlink the chain file, but
1506/// leave the (now empty) directory in place.
1507///
1508/// This mirrors git's `clear_incremental_midx_files_ext` plus the chain unlink in
1509/// `clear_midx_files` for a non-incremental write: git iterates the directory and
1510/// `unlink`s the matching files individually and never `rmdir`s the directory, so
1511/// a single-file MIDX write leaves an empty `multi-pack-index.d/` behind rather
1512/// than removing it (see t5334 "convert incremental to non-incremental").
1513fn clear_incremental_midx_files(pack_dir: &Path) -> Result<()> {
1514    let midx_d = midx_d_dir(pack_dir);
1515    // Unlink the chain file regardless of whether other entries remain.
1516    let _ = fs::remove_file(chain_file_path(pack_dir));
1517    if !midx_d.exists() {
1518        return Ok(());
1519    }
1520    for ent in fs::read_dir(&midx_d).map_err(Error::Io)? {
1521        let ent = ent.map_err(Error::Io)?;
1522        let name = ent.file_name().to_string_lossy().to_string();
1523        if name.starts_with("multi-pack-index-")
1524            && (name.ends_with(".midx") || name.ends_with(".bitmap") || name.ends_with(".rev"))
1525        {
1526            let _ = fs::remove_file(ent.path());
1527        }
1528    }
1529    Ok(())
1530}
1531
1532fn pack_mtime_for_midx(idx: &PackIndex) -> std::time::SystemTime {
1533    fs::metadata(&idx.pack_path)
1534        .and_then(|m| m.modified())
1535        .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
1536}
1537
1538fn midx_pick_better_entry(
1539    cur: &MidxEntry,
1540    cand_pack: u32,
1541    cand_offset: u64,
1542    cand_mtime: std::time::SystemTime,
1543    preferred_pack: Option<u32>,
1544) -> bool {
1545    let cur_pref = preferred_pack == Some(cur.pack_id);
1546    let new_pref = preferred_pack == Some(cand_pack);
1547    if new_pref && !cur_pref {
1548        return true;
1549    }
1550    if cur_pref && !new_pref {
1551        return false;
1552    }
1553    match cand_mtime.cmp(&cur.pack_mtime) {
1554        std::cmp::Ordering::Greater => true,
1555        std::cmp::Ordering::Less => false,
1556        std::cmp::Ordering::Equal => {
1557            if cand_pack != cur.pack_id {
1558                cand_pack < cur.pack_id
1559            } else {
1560                cand_offset < cur.offset
1561            }
1562        }
1563    }
1564}
1565
1566/// Build a MIDX layer's bytes, omitting objects whose OID is present in
1567/// `exclude_oids` (the base chain for incremental layers and compaction, where
1568/// objects already provided by a lower layer must not be repeated). Pass `None`
1569/// for a full (non-incremental) MIDX.
1570#[allow(clippy::too_many_arguments)]
1571fn build_midx_bytes_filtered(
1572    idx_names: &[String],
1573    indexes: &[PackIndex],
1574    preferred_idx: Option<usize>,
1575    write_bitmap: bool,
1576    omit_embedded_ridx_chunk: bool,
1577    version: u8,
1578    hash_version: u8,
1579    exclude_oids: Option<&HashSet<ObjectId>>,
1580) -> Result<(Vec<u8>, Option<Vec<u32>>)> {
1581    // OID width implied by the MIDX hash version (1 → SHA-1/20, 2 → SHA-256/32).
1582    let hash_len = if hash_version == 2 { 32 } else { 20 };
1583    let preferred_pack_idx = preferred_idx.map(|p| p as u32);
1584    let pack_mtimes: Vec<std::time::SystemTime> = indexes.iter().map(pack_mtime_for_midx).collect();
1585
1586    let mut best: HashMap<ObjectId, MidxEntry> = HashMap::new();
1587    for (pack_id, idx) in indexes.iter().enumerate() {
1588        let pack_id = u32::try_from(pack_id).map_err(|_| {
1589            Error::CorruptObject("too many pack files for multi-pack-index".to_owned())
1590        })?;
1591        let mtime = pack_mtimes[pack_id as usize];
1592        for e in idx.iter() {
1593            if e.oid().len() != hash_len {
1594                continue;
1595            }
1596            let Ok(oid) = ObjectId::from_bytes(e.oid()) else {
1597                continue;
1598            };
1599            if let Some(ex) = exclude_oids {
1600                if ex.contains(&oid) {
1601                    continue;
1602                }
1603            }
1604            let offset = e.offset()?;
1605            let cand = MidxEntry {
1606                oid,
1607                pack_id,
1608                offset,
1609                pack_mtime: mtime,
1610            };
1611            match best.get(&oid) {
1612                None => {
1613                    best.insert(oid, cand);
1614                }
1615                Some(cur) => {
1616                    if midx_pick_better_entry(cur, pack_id, offset, mtime, preferred_pack_idx) {
1617                        best.insert(oid, cand);
1618                    }
1619                }
1620            }
1621        }
1622    }
1623
1624    let mut entries: Vec<MidxEntry> = best.into_values().collect();
1625    entries.sort_by_key(|a| a.oid);
1626
1627    // Decide how object offsets are encoded, mirroring git/midx-write.c.
1628    // `large_offsets_needed` becomes true only when some offset cannot fit in a
1629    // 32-bit field (> 0xffffffff); in that mode every offset that does not fit in
1630    // 31 bits (> 0x7fffffff) is stored in the 64-bit large-offset (LOFF) chunk and
1631    // its 32-bit slot is `MIDX_LARGE_OFFSET_NEEDED | slot`. When no offset exceeds
1632    // 32 bits, offsets in [2^31, 2^32) are written directly as raw 32-bit values
1633    // and no LOFF chunk is emitted.
1634    let large_offsets_needed = entries.iter().any(|e| e.offset > u64::from(u32::MAX));
1635
1636    let num_packs = indexes.len() as u32;
1637
1638    let mut pack_names_blob = Vec::new();
1639    for name in idx_names {
1640        pack_names_blob.extend_from_slice(name.as_bytes());
1641        pack_names_blob.push(0);
1642    }
1643    let pad = (MIDX_CHUNK_ALIGNMENT - (pack_names_blob.len() % MIDX_CHUNK_ALIGNMENT))
1644        % MIDX_CHUNK_ALIGNMENT;
1645    pack_names_blob.extend(std::iter::repeat_n(0u8, pad));
1646    let chunk_pnam = pack_names_blob;
1647
1648    let mut chunk_oidf = vec![0u8; 256 * 4];
1649    let mut j = 0usize;
1650    for i in 0..256 {
1651        while j < entries.len() && entries[j].oid.as_bytes()[0] <= i as u8 {
1652            j += 1;
1653        }
1654        chunk_oidf[i * 4..(i + 1) * 4].copy_from_slice(&(j as u32).to_be_bytes());
1655    }
1656
1657    let mut chunk_oidl = Vec::with_capacity(entries.len() * 20);
1658    for e in &entries {
1659        chunk_oidl.extend_from_slice(e.oid.as_bytes());
1660    }
1661
1662    let mut large_offsets: Vec<u64> = Vec::new();
1663    let mut chunk_ooff = Vec::with_capacity(entries.len() * 8);
1664    for e in &entries {
1665        chunk_ooff.extend_from_slice(&e.pack_id.to_be_bytes());
1666        let encoded = if large_offsets_needed && e.offset >> 31 != 0 {
1667            let slot = u32::try_from(large_offsets.len()).map_err(|_| {
1668                Error::CorruptObject("too many large offsets in multi-pack-index".to_owned())
1669            })?;
1670            large_offsets.push(e.offset);
1671            MIDX_LARGE_OFFSET_NEEDED | slot
1672        } else {
1673            // When large offsets are not needed, an offset in [2^31, 2^32) is
1674            // written verbatim (truncation via `as u32` is exact here because the
1675            // value fits in 32 bits).
1676            e.offset as u32
1677        };
1678        chunk_ooff.extend_from_slice(&encoded.to_be_bytes());
1679    }
1680
1681    let chunk_loff: Vec<u8> = if large_offsets.is_empty() {
1682        Vec::new()
1683    } else {
1684        let mut v = Vec::with_capacity(large_offsets.len() * 8);
1685        for off in &large_offsets {
1686            v.extend_from_slice(&off.to_be_bytes());
1687        }
1688        v
1689    };
1690
1691    let pref = preferred_pack_idx;
1692    let mut order: Vec<u32> = (0..entries.len() as u32).collect();
1693    order.sort_by(|&ai, &bi| {
1694        let a = &entries[ai as usize];
1695        let b = &entries[bi as usize];
1696        let a_pref = pref == Some(a.pack_id);
1697        let b_pref = pref == Some(b.pack_id);
1698        b_pref
1699            .cmp(&a_pref)
1700            .then_with(|| a.pack_id.cmp(&b.pack_id))
1701            .then_with(|| a.offset.cmp(&b.offset))
1702            .then_with(|| ai.cmp(&bi))
1703    });
1704
1705    let mut chunk_ridx = Vec::with_capacity(entries.len() * 4);
1706    for oid_idx in &order {
1707        chunk_ridx.extend_from_slice(&oid_idx.to_be_bytes());
1708    }
1709
1710    // BTMP: per-pack (bitmap_pos, bitmap_nr) in the pseudo-bitmap namespace, matching Git's
1711    // `write_midx_bitmapped_packs` (cumulative start + object count per pack).
1712    let bitmap_rid_order = if write_bitmap {
1713        Some(order.clone())
1714    } else {
1715        None
1716    };
1717    let chunk_btmp: Vec<u8> = if write_bitmap {
1718        // Per-pack `(bitmap_pos, bitmap_nr)`: position of the pack's first object in
1719        // the MIDX pack-order traversal and the number of (deduplicated) MIDX objects
1720        // selected from that pack — matching `write_midx_bitmapped_packs` in
1721        // git/midx-write.c (counts MIDX entries per pack, not raw idx entry counts).
1722        let num_packs_usize = indexes.len();
1723        let mut bitmap_pos = vec![u32::MAX; num_packs_usize];
1724        let mut bitmap_nr = vec![0u32; num_packs_usize];
1725        for (rank, &oid_idx) in order.iter().enumerate() {
1726            let pack = entries[oid_idx as usize].pack_id as usize;
1727            if let Some(p) = bitmap_pos.get_mut(pack) {
1728                if *p == u32::MAX {
1729                    *p = rank as u32;
1730                }
1731            }
1732            if let Some(n) = bitmap_nr.get_mut(pack) {
1733                *n += 1;
1734            }
1735        }
1736        let mut v = Vec::new();
1737        for pack in 0..num_packs_usize {
1738            let pos = if bitmap_pos[pack] == u32::MAX {
1739                0
1740            } else {
1741                bitmap_pos[pack]
1742            };
1743            v.extend_from_slice(&pos.to_be_bytes());
1744            v.extend_from_slice(&bitmap_nr[pack].to_be_bytes());
1745        }
1746        let pad = (MIDX_CHUNK_ALIGNMENT - (v.len() % MIDX_CHUNK_ALIGNMENT)) % MIDX_CHUNK_ALIGNMENT;
1747        v.extend(std::iter::repeat_n(0u8, pad));
1748        v
1749    } else {
1750        Vec::new()
1751    };
1752
1753    let mut chunks: Vec<(u32, Vec<u8>)> = vec![
1754        (MIDX_CHUNKID_PACKNAMES, chunk_pnam),
1755        (MIDX_CHUNKID_OIDFANOUT, chunk_oidf),
1756        (MIDX_CHUNKID_OIDLOOKUP, chunk_oidl),
1757        (MIDX_CHUNKID_OBJECTOFFSETS, chunk_ooff),
1758    ];
1759    if !chunk_loff.is_empty() {
1760        chunks.push((MIDX_CHUNKID_LARGEOFFSETS, chunk_loff));
1761    }
1762    if (pref.is_some() || write_bitmap) && !omit_embedded_ridx_chunk {
1763        chunks.push((MIDX_CHUNKID_REVINDEX, chunk_ridx));
1764    }
1765    if write_bitmap {
1766        chunks.push((MIDX_CHUNKID_BITMAPPED_PACKS, chunk_btmp));
1767    }
1768
1769    let num_chunks: u8 = chunks
1770        .len()
1771        .try_into()
1772        .map_err(|_| Error::CorruptObject("too many MIDX chunks".to_owned()))?;
1773
1774    let mut body = Vec::new();
1775    let mut cur_offset =
1776        MIDX_HEADER_SIZE as u64 + ((chunks.len() + 1) * CHUNK_TOC_ENTRY_SIZE) as u64;
1777
1778    for (id, data) in &chunks {
1779        body.extend_from_slice(&id.to_be_bytes());
1780        body.extend_from_slice(&cur_offset.to_be_bytes());
1781        cur_offset += data.len() as u64;
1782    }
1783    body.extend_from_slice(&0u32.to_be_bytes());
1784    body.extend_from_slice(&cur_offset.to_be_bytes());
1785
1786    for (_, data) in &chunks {
1787        body.extend_from_slice(data);
1788    }
1789
1790    let mut out = Vec::with_capacity(MIDX_HEADER_SIZE + body.len() + 20);
1791    out.extend_from_slice(&MIDX_SIGNATURE.to_be_bytes());
1792    out.push(if version == MIDX_VERSION_V1 {
1793        MIDX_VERSION_V1
1794    } else {
1795        MIDX_VERSION_V2
1796    });
1797    out.push(hash_version);
1798    out.push(num_chunks);
1799    out.push(0);
1800    out.extend_from_slice(&num_packs.to_be_bytes());
1801    out.extend_from_slice(&body);
1802
1803    // Trailing checksum matches the MIDX hash version (SHA-1 for 1, SHA-256 for 2).
1804    let algo = if hash_version == 2 {
1805        HashAlgo::Sha256
1806    } else {
1807        HashAlgo::Sha1
1808    };
1809    out.extend_from_slice(&algo.checksum_digest(&out));
1810
1811    Ok((out, bitmap_rid_order))
1812}
1813
1814/// Standalone MIDX `.rev` file (Git `write_rev_file_order` / `RIDX_SIGNATURE`).
1815///
1816/// `midx_file_hash` is the MIDX's own trailing checksum (20 bytes for SHA-1, 32
1817/// for SHA-256); its width selects the RIDX hash-id (1 or 2).
1818fn write_midx_rev_sidecar(
1819    path: &Path,
1820    pack_order: &[u32],
1821    midx_file_hash: &[u8],
1822    algo: HashAlgo,
1823) -> Result<()> {
1824    let hash_len = algo.len();
1825    let hash_id = u32::from(u8::from(algo));
1826    let body_len = RIDX_HEADER_SIZE + pack_order.len() * 4 + hash_len;
1827    let mut out = Vec::with_capacity(body_len + hash_len);
1828    out.extend_from_slice(&RIDX_SIGNATURE.to_be_bytes());
1829    out.extend_from_slice(&RIDX_VERSION.to_be_bytes());
1830    out.extend_from_slice(&hash_id.to_be_bytes());
1831    for idx in pack_order {
1832        out.extend_from_slice(&idx.to_be_bytes());
1833    }
1834    if midx_file_hash.len() >= hash_len {
1835        out.extend_from_slice(&midx_file_hash[..hash_len]);
1836    } else {
1837        return Err(Error::CorruptObject(format!(
1838            "MIDX checksum length {} does not match hash algorithm width {hash_len}",
1839            midx_file_hash.len()
1840        )));
1841    }
1842    append_hashfile_checksum(&mut out, hash_len);
1843    let mut file = fs::File::create(path).map_err(Error::Io)?;
1844    file.write_all(&out).map_err(Error::Io)?;
1845    file.sync_all().map_err(Error::Io)?;
1846    if !path.is_file() {
1847        return Err(Error::CorruptObject(format!(
1848            "MIDX rev sidecar not present after write ({})",
1849            path.display()
1850        )));
1851    }
1852    Ok(())
1853}
1854
1855fn repo_hash_algo_for_pack_dir(pack_dir: &Path) -> HashAlgo {
1856    pack_dir
1857        .parent()
1858        .map(|d| {
1859            crate::odb::hash_algo_for_objects_dir(&crate::environment::Environment::empty(), d)
1860        })
1861        .unwrap_or(HashAlgo::Sha1)
1862}
1863
1864fn find_chunk(data: &[u8], header_end: usize, chunk_id: u32) -> Result<(usize, usize)> {
1865    let (hdr, _, _) = parse_midx_header(data)?;
1866    let n = hdr.num_chunks as usize;
1867    let pos = header_end;
1868    let toc_end = pos + (n + 1) * CHUNK_TOC_ENTRY_SIZE;
1869    if data.len() < toc_end + 20 {
1870        return Err(Error::CorruptObject(
1871            "truncated MIDX chunk table".to_owned(),
1872        ));
1873    }
1874    for i in 0..n {
1875        let base = pos + i * CHUNK_TOC_ENTRY_SIZE;
1876        let id = read_be_u32(data, base)?;
1877        let off = read_be_u64(data, base + 4)? as usize;
1878        if id == chunk_id {
1879            let next_off = if i + 1 < n {
1880                let nb = pos + (i + 1) * CHUNK_TOC_ENTRY_SIZE;
1881                read_be_u64(data, nb + 4)? as usize
1882            } else {
1883                let term = pos + n * CHUNK_TOC_ENTRY_SIZE;
1884                read_be_u64(data, term + 4)? as usize
1885            };
1886            return Ok((off, next_off.saturating_sub(off)));
1887        }
1888    }
1889    Err(Error::CorruptObject(format!(
1890        "MIDX chunk {chunk_id:08x} not found"
1891    )))
1892}
1893
1894/// Fatal chunk-table failures from [`parse_midx_toc`] (Git `die()` in `load_multi_pack_index`).
1895#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1896enum MidxChunkTableFatal {
1897    FileTooSmall,
1898    TableTruncated,
1899    RequiredPackNameChunkMissing,
1900}
1901
1902impl From<MidxChunkTableFatal> for MidxError {
1903    fn from(value: MidxChunkTableFatal) -> Self {
1904        match value {
1905            MidxChunkTableFatal::FileTooSmall => MidxError::InvalidChunkTable {
1906                detail: "multi-pack-index file too small".to_owned(),
1907            },
1908            MidxChunkTableFatal::TableTruncated => MidxError::InvalidChunkTable {
1909                detail: "multi-pack-index chunk table is truncated".to_owned(),
1910            },
1911            MidxChunkTableFatal::RequiredPackNameChunkMissing => {
1912                MidxError::RequiredPackNameChunkMissing
1913            }
1914        }
1915    }
1916}
1917
1918fn midx_write_warn(opts: &WriteMultiPackIndexOptions, warning: Warning) {
1919    if let Some(handle) = opts.diagnostics.as_ref() {
1920        handle.warn(warning);
1921    } else {
1922        NullDiagnostics.warn(warning);
1923    }
1924}
1925
1926/// Parsed table-of-contents entry: `(chunk_id, file_offset)`.
1927struct TocEntry {
1928    id: u32,
1929    offset: usize,
1930}
1931
1932/// Walk the MIDX chunk table of contents, mirroring `read_table_of_contents`
1933/// in `git/chunk-format.c`. Returns the chunk list plus any reported errors,
1934/// or a fatal [`MidxChunkTableFatal`] for the conditions Git treats as `die()`-worthy.
1935fn parse_midx_toc(
1936    data: &[u8],
1937    hash_len: usize,
1938    errors: &mut Vec<String>,
1939) -> std::result::Result<Vec<TocEntry>, MidxChunkTableFatal> {
1940    if data.len() < MIDX_HEADER_SIZE + hash_len {
1941        return Err(MidxChunkTableFatal::FileTooSmall);
1942    }
1943    let num_chunks = data[6] as usize;
1944    let toc_off = MIDX_HEADER_SIZE;
1945    let needed = toc_off + (num_chunks + 1) * CHUNK_TOC_ENTRY_SIZE;
1946    if data.len() < needed {
1947        return Err(MidxChunkTableFatal::TableTruncated);
1948    }
1949    let file_size = data.len();
1950    let mut chunks: Vec<TocEntry> = Vec::with_capacity(num_chunks);
1951
1952    let read_be64 = |off: usize| -> u64 {
1953        let mut b = [0u8; 8];
1954        b.copy_from_slice(&data[off..off + 8]);
1955        u64::from_be_bytes(b)
1956    };
1957    let read_be32 = |off: usize| -> u32 {
1958        let mut b = [0u8; 4];
1959        b.copy_from_slice(&data[off..off + 4]);
1960        u32::from_be_bytes(b)
1961    };
1962
1963    for i in 0..num_chunks {
1964        let entry = toc_off + i * CHUNK_TOC_ENTRY_SIZE;
1965        let chunk_id = read_be32(entry);
1966        let chunk_offset = read_be64(entry + 4);
1967
1968        if chunk_id == 0 {
1969            errors.push("terminating chunk id appears earlier than expected".to_owned());
1970            return Err(MidxChunkTableFatal::RequiredPackNameChunkMissing);
1971        }
1972        if !(chunk_offset as usize).is_multiple_of(MIDX_CHUNK_ALIGNMENT) {
1973            errors.push(format!(
1974                "chunk id {chunk_id:x} not {MIDX_CHUNK_ALIGNMENT}-byte aligned"
1975            ));
1976            return Err(MidxChunkTableFatal::RequiredPackNameChunkMissing);
1977        }
1978
1979        let next_entry = toc_off + (i + 1) * CHUNK_TOC_ENTRY_SIZE;
1980        let next_chunk_offset = read_be64(next_entry + 4);
1981
1982        if next_chunk_offset < chunk_offset
1983            || next_chunk_offset > (file_size as u64).saturating_sub(hash_len as u64)
1984        {
1985            errors.push(format!(
1986                "improper chunk offset(s) {chunk_offset:x} and {next_chunk_offset:x}"
1987            ));
1988            return Err(MidxChunkTableFatal::RequiredPackNameChunkMissing);
1989        }
1990
1991        if chunks.iter().any(|c| c.id == chunk_id) {
1992            errors.push(format!("duplicate chunk ID {chunk_id:x} found"));
1993            return Err(MidxChunkTableFatal::RequiredPackNameChunkMissing);
1994        }
1995
1996        chunks.push(TocEntry {
1997            id: chunk_id,
1998            offset: chunk_offset as usize,
1999        });
2000    }
2001
2002    // Terminating TOC entry must have chunk id 0.
2003    let term_entry = toc_off + num_chunks * CHUNK_TOC_ENTRY_SIZE;
2004    let final_id = read_be32(term_entry);
2005    if final_id != 0 {
2006        errors.push(format!("final chunk has non-zero id {final_id:x}"));
2007        return Err(MidxChunkTableFatal::RequiredPackNameChunkMissing);
2008    }
2009
2010    // Record the terminator offset as a sentinel (id 0) so the final real chunk's
2011    // length is taken from the table — not a hash-width-dependent file-size guess.
2012    let term_offset = read_be64(term_entry + 4) as usize;
2013    chunks.push(TocEntry {
2014        id: 0,
2015        offset: term_offset,
2016    });
2017
2018    Ok(chunks)
2019}
2020
2021/// Look up `(start, len)` of a chunk in a parsed TOC.
2022fn toc_chunk_range(chunks: &[TocEntry], data_len: usize, id: u32) -> Option<(usize, usize)> {
2023    for (i, c) in chunks.iter().enumerate() {
2024        if c.id == id {
2025            let next = if i + 1 < chunks.len() {
2026                chunks[i + 1].offset
2027            } else {
2028                data_len.saturating_sub(20)
2029            };
2030            return Some((c.offset, next.saturating_sub(c.offset)));
2031        }
2032    }
2033    None
2034}
2035
2036/// Full multi-pack-index verification, mirroring `verify_midx_file` in `git/midx.c`
2037/// plus the `die()`/`error()` conditions in `load_multi_pack_index`. On any problem
2038/// returns the list of error lines (without `error:`/`fatal:` prefixes) in the order
2039/// Git emits them; an empty list means the MIDX is valid.
2040///
2041/// `objects_dir` is the object database (e.g. `.git/objects`).
2042pub fn verify_midx(objects_dir: &Path) -> std::result::Result<(), Vec<String>> {
2043    let pack_dir = objects_dir.join("pack");
2044    let path = match resolve_tip_midx_path(&pack_dir) {
2045        Some(p) => p,
2046        None => return Ok(()),
2047    };
2048    let data = match open_midx_layer_bytes(&path) {
2049        Ok(bytes) => bytes,
2050        Err(Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
2051        Err(_) => return Ok(()),
2052    };
2053    let data = data.as_ref();
2054
2055    let mut fatal: Vec<String> = Vec::new();
2056    let mut errors: Vec<String> = Vec::new();
2057
2058    // --- header checks (load_multi_pack_index) ---
2059    if data.len() < MIDX_HEADER_SIZE + 20 {
2060        return Err(vec!["multi-pack-index file is too small".to_owned()]);
2061    }
2062    let sig = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
2063    if sig != MIDX_SIGNATURE {
2064        return Err(vec![format!(
2065            "multi-pack-index signature 0x{sig:08x} does not match signature 0x{MIDX_SIGNATURE:08x}"
2066        )]);
2067    }
2068    let version = data[4];
2069    if version != MIDX_VERSION_V1 && version != MIDX_VERSION_V2 {
2070        return Err(vec![format!(
2071            "multi-pack-index version {version} not recognized"
2072        )]);
2073    }
2074    let hash_version = data[5];
2075    let expected_hash_version = repo_midx_hash_version_for_objects_dir(objects_dir);
2076    if hash_version != expected_hash_version {
2077        return Err(vec![format!(
2078            "multi-pack-index hash version {hash_version} does not match version {expected_hash_version}"
2079        )]);
2080    }
2081    let hash_len = if hash_version == 2 { 32usize } else { 20usize };
2082    let num_packs = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
2083
2084    // --- table of contents ---
2085    let chunks = match parse_midx_toc(data, hash_len, &mut errors) {
2086        Ok(c) => c,
2087        Err(fatal) => {
2088            if !errors.is_empty() {
2089                return Err(errors);
2090            }
2091            errors.push(MidxError::from(fatal).to_string());
2092            return Err(errors);
2093        }
2094    };
2095
2096    // required pack-names chunk
2097    let Some((pn_off, pn_len)) = toc_chunk_range(&chunks, data.len(), MIDX_CHUNKID_PACKNAMES)
2098    else {
2099        errors.push("multi-pack-index required pack-name chunk missing or corrupted".to_owned());
2100        return Err(errors);
2101    };
2102
2103    // oid-fanout chunk + ordering check
2104    let Some((fan_off, fan_len)) = toc_chunk_range(&chunks, data.len(), MIDX_CHUNKID_OIDFANOUT)
2105    else {
2106        errors.push("multi-pack-index required OID fanout chunk missing or corrupted".to_owned());
2107        return Err(errors);
2108    };
2109    if fan_len != 256 * 4 {
2110        errors.push("multi-pack-index OID fanout is of the wrong size".to_owned());
2111        errors.push("multi-pack-index required OID fanout chunk missing or corrupted".to_owned());
2112        return Err(errors);
2113    }
2114    let fanout = |i: usize| -> u32 {
2115        let b = fan_off + i * 4;
2116        u32::from_be_bytes([data[b], data[b + 1], data[b + 2], data[b + 3]])
2117    };
2118    for i in 0..255 {
2119        let f1 = fanout(i);
2120        let f2 = fanout(i + 1);
2121        if f1 > f2 {
2122            errors.push(format!(
2123                "oid fanout out of order: fanout[{i}] = {f1:x} > {f2:x} = fanout[{}]",
2124                i + 1
2125            ));
2126            errors
2127                .push("multi-pack-index required OID fanout chunk missing or corrupted".to_owned());
2128            return Err(errors);
2129        }
2130    }
2131    let num_objects = fanout(255) as usize;
2132
2133    // oid-lookup chunk (size depends on num_objects)
2134    let Some((oidl_off, oidl_len)) = toc_chunk_range(&chunks, data.len(), MIDX_CHUNKID_OIDLOOKUP)
2135    else {
2136        errors.push("multi-pack-index required OID lookup chunk missing or corrupted".to_owned());
2137        return Err(errors);
2138    };
2139    if oidl_len != hash_len * num_objects {
2140        errors.push("multi-pack-index OID lookup chunk is the wrong size".to_owned());
2141        errors.push("multi-pack-index required OID lookup chunk missing or corrupted".to_owned());
2142        return Err(errors);
2143    }
2144
2145    // object-offsets chunk
2146    let Some((ooff_off, ooff_len)) =
2147        toc_chunk_range(&chunks, data.len(), MIDX_CHUNKID_OBJECTOFFSETS)
2148    else {
2149        errors
2150            .push("multi-pack-index required object offsets chunk missing or corrupted".to_owned());
2151        return Err(errors);
2152    };
2153    if ooff_len != num_objects * 8 {
2154        errors.push("multi-pack-index object offset chunk is the wrong size".to_owned());
2155        errors
2156            .push("multi-pack-index required object offsets chunk missing or corrupted".to_owned());
2157        return Err(errors);
2158    }
2159
2160    let large_off = toc_chunk_range(&chunks, data.len(), MIDX_CHUNKID_LARGEOFFSETS);
2161
2162    // pack names: parse and (for V1) verify ordering.
2163    let names = match parse_pack_names_blob(&data[pn_off..pn_off + pn_len]) {
2164        Ok(n) => n,
2165        Err(_) => {
2166            errors.push("multi-pack-index pack-name chunk is too short".to_owned());
2167            return Err(errors);
2168        }
2169    };
2170    if version == MIDX_VERSION_V1 {
2171        for i in 1..names.len() {
2172            if names[i] <= names[i - 1] {
2173                fatal.push(format!(
2174                    "multi-pack-index pack names out of order: '{}' before '{}'",
2175                    names[i - 1],
2176                    names[i]
2177                ));
2178                // Git die()s here while loading; surface immediately.
2179                errors.extend(fatal);
2180                return Err(errors);
2181            }
2182        }
2183    }
2184
2185    // --- checksum ---
2186    if !midx_checksum_is_valid(data) {
2187        errors.push("incorrect checksum".to_owned());
2188    }
2189
2190    // --- load each referenced pack (failed to load pack) ---
2191    let mut pack_indexes: Vec<Option<PackIndex>> = Vec::with_capacity(num_packs);
2192    for i in 0..num_packs {
2193        // Load the pack idx without verifying its trailing checksum: `git
2194        // multi-pack-index verify` uses `open_pack_index`, which only parses the
2195        // index header/tables. The 64-bit-offset tests deliberately corrupt a
2196        // pack `.idx` (invalidating its checksum) and still expect the MIDX
2197        // verify to read recorded offsets out of that idx for comparison.
2198        let loaded = match names.get(i) {
2199            Some(name) => read_pack_index_no_verify(&pack_dir.join(name)).ok(),
2200            None => None,
2201        };
2202        if loaded.is_none() {
2203            errors.push(format!("failed to load pack in position {i}"));
2204        }
2205        pack_indexes.push(loaded);
2206    }
2207
2208    if num_objects == 0 {
2209        errors.push("the midx contains no oid".to_owned());
2210        if errors.is_empty() {
2211            return Ok(());
2212        }
2213        return Err(errors);
2214    }
2215
2216    // --- OID lookup order ---
2217    let oid_at =
2218        |i: usize| -> &[u8] { &data[oidl_off + i * hash_len..oidl_off + (i + 1) * hash_len] };
2219    for i in 0..num_objects.saturating_sub(1) {
2220        let a = oid_at(i);
2221        let b = oid_at(i + 1);
2222        if a >= b {
2223            errors.push(format!(
2224                "oid lookup out of order: oid[{i}] = {} >= {} = oid[{}]",
2225                hex::encode(a),
2226                hex::encode(b),
2227                i + 1
2228            ));
2229        }
2230    }
2231
2232    // --- object offsets vs pack index ---
2233    for i in 0..num_objects {
2234        let ob = ooff_off + i * 8;
2235        let pack_int_id = u32::from_be_bytes([data[ob], data[ob + 1], data[ob + 2], data[ob + 3]]);
2236        let off_raw = u32::from_be_bytes([data[ob + 4], data[ob + 5], data[ob + 6], data[ob + 7]]);
2237        let oid_hex = hex::encode(oid_at(i));
2238
2239        if pack_int_id as usize >= num_packs {
2240            errors.push(format!(
2241                "bad pack-int-id: {pack_int_id} ({num_packs} total packs)"
2242            ));
2243            errors.push(format!(
2244                "failed to load pack entry for oid[{i}] = {oid_hex}"
2245            ));
2246            continue;
2247        }
2248
2249        // resolve MIDX-recorded offset (handle large offsets)
2250        let m_offset: u64 = if off_raw & MIDX_LARGE_OFFSET_NEEDED != 0 {
2251            let slot = (off_raw & !MIDX_LARGE_OFFSET_NEEDED) as usize;
2252            match large_off {
2253                Some((lo_off, lo_len)) if (slot + 1) * 8 <= lo_len => {
2254                    let b = lo_off + slot * 8;
2255                    let mut arr = [0u8; 8];
2256                    arr.copy_from_slice(&data[b..b + 8]);
2257                    u64::from_be_bytes(arr)
2258                }
2259                _ => {
2260                    errors.push("multi-pack-index large offset out of bounds".to_owned());
2261                    continue;
2262                }
2263            }
2264        } else {
2265            u64::from(off_raw)
2266        };
2267
2268        let Some(Some(idx)) = pack_indexes.get(pack_int_id as usize) else {
2269            errors.push(format!(
2270                "failed to load pack entry for oid[{i}] = {oid_hex}"
2271            ));
2272            continue;
2273        };
2274        let Ok(oid) = ObjectId::from_bytes(oid_at(i)) else {
2275            errors.push(format!(
2276                "failed to load pack entry for oid[{i}] = {oid_hex}"
2277            ));
2278            continue;
2279        };
2280        match idx.find_offset(&oid) {
2281            Ok(Some(p_offset)) => {
2282                if m_offset != p_offset {
2283                    errors.push(format!(
2284                        "incorrect object offset for oid[{i}] = {oid_hex}: {m_offset:x} != {p_offset:x}"
2285                    ));
2286                }
2287            }
2288            Ok(None) => {
2289                errors.push(format!(
2290                    "failed to load pack entry for oid[{i}] = {oid_hex}"
2291                ));
2292            }
2293            Err(err) => {
2294                errors.push(format!(
2295                    "failed to load pack entry for oid[{i}] = {oid_hex}: {err}"
2296                ));
2297            }
2298        }
2299    }
2300
2301    if errors.is_empty() {
2302        Ok(())
2303    } else {
2304        Err(errors)
2305    }
2306}
2307
2308/// Validate the trailing checksum of an in-memory MIDX image, using the
2309/// algorithm implied by the header hash version (SHA-1 or SHA-256).
2310fn midx_checksum_is_valid(data: &[u8]) -> bool {
2311    let hash_len = midx_hash_len(data);
2312    if data.len() < hash_len {
2313        return false;
2314    }
2315    HashAlgo::from_len(hash_len).is_some_and(|algo| verify_trailer(algo, data).is_ok())
2316}
2317
2318/// Pack index basenames stored in the active tip MIDX's pack-names chunk.
2319///
2320/// `objects_dir` is the repository object database (for example `.git/objects`).
2321/// For incremental chains this returns only the tip layer's names, not the union
2322/// of all layers. Unchanged prepared metadata is reused; otherwise only header,
2323/// chunk-table and pack-name pages are read, without allocating the object tables.
2324///
2325/// # Errors
2326///
2327/// Returns I/O errors or [`Error::CorruptObject`] for missing or malformed metadata.
2328pub fn read_midx_pack_idx_names(objects_dir: &Path) -> Result<Vec<String>> {
2329    let pack_dir = objects_dir.join("pack");
2330    let path = resolve_tip_midx_path(&pack_dir)
2331        .ok_or_else(|| Error::CorruptObject("no multi-pack-index found".to_owned()))?;
2332    let metadata = fs::metadata(&path).map_err(Error::Io)?;
2333    read_midx_pack_idx_names_from_path(&path, &metadata)
2334}
2335
2336/// Read names using an already resolved tip path and its current metadata.
2337/// Errors have the same meaning as [`read_midx_pack_idx_names`].
2338pub(crate) fn read_midx_pack_idx_names_from_path(
2339    path: &Path,
2340    metadata: &fs::Metadata,
2341) -> Result<Vec<String>> {
2342    if let Some(names) = midx_cache::cached_pack_names(path, metadata) {
2343        return Ok(names);
2344    }
2345    // Mapping only faults the header, TOC and PNAM pages; reading names must not
2346    // allocate or scan the object tables of a large MIDX.
2347    let data = open_midx_layer_bytes(path)?;
2348    let (_, hdr_end, _) = parse_midx_header(&data)?;
2349    let (pn_off, pn_len) = find_chunk(&data, hdr_end, MIDX_CHUNKID_PACKNAMES)?;
2350    let blob = pn_off
2351        .checked_add(pn_len)
2352        .and_then(|end| data.get(pn_off..end))
2353        .ok_or_else(|| Error::CorruptObject("MIDX pack names are outside the file".to_owned()))?;
2354    parse_pack_names_blob(blob)
2355}
2356
2357/// A single MIDX-referenced object together with the pack it is attributed to.
2358pub struct MidxObjectRef {
2359    /// `oid` field.
2360    pub oid: ObjectId,
2361    /// Index into the pack-names list returned alongside this.
2362    pub pack_int_id: usize,
2363}
2364
2365/// Read the tip MIDX and return `(pack_names, objects)`, where each object names
2366/// the pack it is attributed to (`pack_int_id`). Mirrors the per-object
2367/// `nth_midxed_pack_int_id` iteration in Git used by expire/repack.
2368pub fn read_midx_objects(objects_dir: &Path) -> Result<(Vec<String>, Vec<MidxObjectRef>)> {
2369    let pack_dir = objects_dir.join("pack");
2370    let path = resolve_tip_midx_path(&pack_dir)
2371        .ok_or_else(|| Error::CorruptObject("no multi-pack-index found".to_owned()))?;
2372    let data = fs::read(&path).map_err(Error::Io)?;
2373    let (_, hdr_end, _) = parse_midx_header(&data)?;
2374    let (pn_off, pn_len) = find_chunk(&data, hdr_end, MIDX_CHUNKID_PACKNAMES)?;
2375    let names = parse_pack_names_blob(&data[pn_off..pn_off + pn_len])?;
2376    let hash_len = midx_hash_len(&data);
2377    let (oidl_off, oidl_len) = find_chunk(&data, hdr_end, MIDX_CHUNKID_OIDLOOKUP)?;
2378    let (ooff_off, ooff_len) = find_chunk(&data, hdr_end, MIDX_CHUNKID_OBJECTOFFSETS)?;
2379    if oidl_len % hash_len != 0 || ooff_len % 8 != 0 {
2380        return Err(Error::CorruptObject(
2381            "bad MIDX oid-lookup / object-offsets size".to_owned(),
2382        ));
2383    }
2384    let num = oidl_len / hash_len;
2385    if num * 8 != ooff_len {
2386        return Err(Error::CorruptObject(
2387            "MIDX oid count does not match object-offsets".to_owned(),
2388        ));
2389    }
2390    let mut objects = Vec::with_capacity(num);
2391    for i in 0..num {
2392        let oid =
2393            ObjectId::from_bytes(&data[oidl_off + i * hash_len..oidl_off + (i + 1) * hash_len])
2394                .map_err(|e| Error::CorruptObject(e.to_string()))?;
2395        let base = ooff_off + i * 8;
2396        let pack_id = read_be_u32(&data, base)? as usize;
2397        objects.push(MidxObjectRef {
2398            oid,
2399            pack_int_id: pack_id,
2400        });
2401    }
2402    Ok((names, objects))
2403}
2404
2405/// Trailing 40-character SHA-1 hex of the active MIDX (root or chain tip).
2406pub fn midx_checksum_hex(objects_dir: &Path) -> Result<String> {
2407    let pack_dir = objects_dir.join("pack");
2408    let path = resolve_tip_midx_path(&pack_dir)
2409        .ok_or_else(|| Error::CorruptObject("no multi-pack-index found".to_owned()))?;
2410    midx_checksum_hex_from_path(&path)
2411}
2412
2413/// Resolve the MIDX file to read for `test-tool read-midx`: a specific layer when
2414/// `checksum` is `Some`, otherwise the chain tip / root MIDX. A checksum that does
2415/// not name any layer yields a `could not find MIDX with checksum` error matching
2416/// git's `test-read-midx.c`.
2417fn resolve_read_midx_path(pack_dir: &Path, checksum: Option<&str>) -> Result<std::path::PathBuf> {
2418    match checksum {
2419        Some(cs) => resolve_midx_layer_path(pack_dir, cs)
2420            .ok_or_else(|| Error::CorruptObject(format!("could not find MIDX with checksum {cs}"))),
2421        None => resolve_tip_midx_path(pack_dir)
2422            .ok_or_else(|| Error::CorruptObject("no multi-pack-index found".to_owned())),
2423    }
2424}
2425
2426/// Human-readable dump of the MIDX (matches `test-tool read-midx` layout closely enough for grep-based tests).
2427/// Emit one line per MIDX object: `{oid} {offset}\t{pack-idx-name}` (matches Git `test-read-midx.c`).
2428pub fn format_midx_show_objects(objects_dir: &Path) -> Result<String> {
2429    format_midx_show_objects_layer(objects_dir, None)
2430}
2431
2432/// Like [`format_midx_show_objects`] but reads a specific layer by checksum.
2433pub fn format_midx_show_objects_layer(
2434    objects_dir: &Path,
2435    checksum: Option<&str>,
2436) -> Result<String> {
2437    let mut out = format_midx_dump_layer(objects_dir, checksum)?;
2438    let pack_dir = objects_dir.join("pack");
2439    let path = resolve_read_midx_path(&pack_dir, checksum)?;
2440    let data = fs::read(&path).map_err(Error::Io)?;
2441    let (_, hdr_end, _) = parse_midx_header(&data)?;
2442    let (pn_off, pn_len) = find_chunk(&data, hdr_end, MIDX_CHUNKID_PACKNAMES)?;
2443    let names = parse_pack_names_blob(&data[pn_off..pn_off + pn_len])?;
2444    let hash_len = midx_hash_len(&data);
2445    let (oidl_off, oidl_len) = find_chunk(&data, hdr_end, MIDX_CHUNKID_OIDLOOKUP)?;
2446    let (ooff_off, ooff_len) = find_chunk(&data, hdr_end, MIDX_CHUNKID_OBJECTOFFSETS)?;
2447    if oidl_len % hash_len != 0 || ooff_len % 8 != 0 {
2448        return Err(Error::CorruptObject(
2449            "bad MIDX oid-lookup / object-offsets size".to_owned(),
2450        ));
2451    }
2452    let num = oidl_len / hash_len;
2453    if num * 8 != ooff_len {
2454        return Err(Error::CorruptObject(
2455            "MIDX oid count does not match object-offsets".to_owned(),
2456        ));
2457    }
2458    for i in 0..num {
2459        let oid =
2460            ObjectId::from_bytes(&data[oidl_off + i * hash_len..oidl_off + (i + 1) * hash_len])
2461                .map_err(|e| Error::CorruptObject(e.to_string()))?;
2462        let base = ooff_off + i * 8;
2463        let pack_id = read_be_u32(&data, base)? as usize;
2464        let offset = u64::from(read_be_u32(&data, base + 4)?);
2465        let idx_name = names
2466            .get(pack_id)
2467            .ok_or_else(|| Error::CorruptObject("pack id out of range in MIDX".to_owned()))?;
2468        // Match `test-read-midx.c`, which prints `e.p->pack_name`: the full pack
2469        // path `<object-dir>/pack/<stem>.pack`. A relative object dir gets a `./`
2470        // prefix (Git `relative_path`).
2471        let stem = idx_name.strip_suffix(".idx").unwrap_or(idx_name);
2472        let dir_disp = objects_dir.display().to_string();
2473        let dir_disp = if objects_dir.is_absolute() || dir_disp.starts_with("./") {
2474            dir_disp
2475        } else {
2476            format!("./{dir_disp}")
2477        };
2478        out.push_str(&format!(
2479            "{} {}\t{}/pack/{}.pack\n",
2480            oid.to_hex(),
2481            offset,
2482            dir_disp,
2483            stem
2484        ));
2485    }
2486    Ok(out)
2487}
2488
2489/// `format_midx_dump` function.
2490pub fn format_midx_dump(objects_dir: &Path) -> Result<String> {
2491    format_midx_dump_layer(objects_dir, None)
2492}
2493
2494/// Like [`format_midx_dump`] but reads a specific layer by checksum (chain layer or
2495/// root MIDX). Used by `test-tool read-midx <object-dir> <checksum>`.
2496pub fn format_midx_dump_layer(objects_dir: &Path, checksum: Option<&str>) -> Result<String> {
2497    let pack_dir = objects_dir.join("pack");
2498    let path = resolve_read_midx_path(&pack_dir, checksum)?;
2499    let data = fs::read(&path).map_err(Error::Io)?;
2500    let (hdr, hdr_end, _) = parse_midx_header(&data)?;
2501    let sig = read_be_u32(&data, 0)?;
2502    let version = data[4];
2503    // The C `read-midx` test tool prints `m->hash_len`, the raw hash length
2504    // (20 for SHA-1, 32 for SHA-256), not the on-disk hash-version byte.
2505    let hash_len: u8 = match data[5] {
2506        1 => 20,
2507        2 => 32,
2508        other => other,
2509    };
2510    let num_chunks = hdr.num_chunks;
2511    let num_packs = read_be_u32(&data, 8)?;
2512
2513    let mut chunk_tags: Vec<&'static str> = Vec::new();
2514    let n = num_chunks as usize;
2515    let pos = hdr_end;
2516    let toc_end = pos + (n + 1) * CHUNK_TOC_ENTRY_SIZE;
2517    if data.len() < toc_end + 20 {
2518        return Err(Error::CorruptObject(
2519            "truncated MIDX chunk table".to_owned(),
2520        ));
2521    }
2522    for i in 0..n {
2523        let base = pos + i * CHUNK_TOC_ENTRY_SIZE;
2524        let id = read_be_u32(&data, base)?;
2525        let tag = match id {
2526            x if x == MIDX_CHUNKID_PACKNAMES => "pack-names",
2527            x if x == MIDX_CHUNKID_OIDFANOUT => "oid-fanout",
2528            x if x == MIDX_CHUNKID_OIDLOOKUP => "oid-lookup",
2529            x if x == MIDX_CHUNKID_OBJECTOFFSETS => "object-offsets",
2530            x if x == MIDX_CHUNKID_LARGEOFFSETS => "large-offsets",
2531            x if x == MIDX_CHUNKID_REVINDEX => "revindex",
2532            x if x == MIDX_CHUNKID_BITMAPPED_PACKS => "bitmapped-packs",
2533            _ => "unknown",
2534        };
2535        chunk_tags.push(tag);
2536    }
2537
2538    let (_ooff_off, ooff_len) = find_chunk(&data, hdr_end, MIDX_CHUNKID_OBJECTOFFSETS)?;
2539    let num_objects = ooff_len / 8;
2540
2541    let (pn_off, pn_len) = find_chunk(&data, hdr_end, MIDX_CHUNKID_PACKNAMES)?;
2542    let pack_names = parse_pack_names_blob(&data[pn_off..pn_off + pn_len])?;
2543
2544    let mut out = String::new();
2545    out.push_str(&format!(
2546        "header: {:08x} {} {} {} {}\n",
2547        sig, version, hash_len, num_chunks, num_packs
2548    ));
2549    out.push_str("chunks:");
2550    for t in &chunk_tags {
2551        out.push(' ');
2552        out.push_str(t);
2553    }
2554    out.push('\n');
2555    out.push_str(&format!("num_objects: {num_objects}\n"));
2556    out.push_str("packs:\n");
2557    for n in &pack_names {
2558        out.push_str(n);
2559        out.push('\n');
2560    }
2561    out.push_str(&format!("object-dir: {}\n", objects_dir.display()));
2562    Ok(out)
2563}
2564
2565/// OID rows from the active multi-pack-index, plus reverse-index order for pack-reuse bitmap bits.
2566///
2567/// Git assigns each object a **global bitmap bit** equal to its position in the MIDX reverse index
2568/// (`RIDX` chunk) traversal order — not its position in the pack `.idx` file. Helpers on this struct
2569/// map [`ObjectId`] → global bit the same way as `midx-write.c` (`midx_pack_order`).
2570#[derive(Debug, Clone)]
2571pub struct MidxReuseTables {
2572    /// OIDs in MIDX lexicographic order (same order as the OID lookup chunk).
2573    pub oids: Vec<ObjectId>,
2574    /// `(pack_int_id, in-pack offset)` parallel to `oids`.
2575    pub pack_and_offset: Vec<(u32, u64)>,
2576    /// `rid_order[rank]` is the OID-table index of the object at global bitmap rank `rank`.
2577    pub rid_order: Vec<u32>,
2578    /// Inverse map: global bitmap rank for each OID-table index.
2579    pub oid_idx_to_rank: Vec<u32>,
2580}
2581
2582/// Load OID / object-offset / reverse-index tables from the tip MIDX (root or chain tip).
2583///
2584/// Returns [`None`] when there is no MIDX or no `RIDX` chunk (no pseudo-bitmap ordering).
2585pub fn load_midx_reuse_tables(objects_dir: &Path) -> Result<Option<MidxReuseTables>> {
2586    let pack_dir = objects_dir.join("pack");
2587    let Some(path) = resolve_tip_midx_path(&pack_dir) else {
2588        return Ok(None);
2589    };
2590    load_midx_reuse_tables_from_path(&path)
2591}
2592
2593/// Load reuse tables from one on-disk MIDX layer (`multi-pack-index` or `multi-pack-index.d/…midx`).
2594pub fn load_midx_reuse_tables_from_path(midx_path: &Path) -> Result<Option<MidxReuseTables>> {
2595    let data = fs::read(midx_path).map_err(Error::Io)?;
2596    let hash_len = midx_hash_len(&data);
2597    let (_, hdr_end, _) = parse_midx_header(&data)?;
2598    let (oidl_off, oid_l_len) = find_chunk(&data, hdr_end, MIDX_CHUNKID_OIDLOOKUP)?;
2599    let (ooff_off, ooff_len) = find_chunk(&data, hdr_end, MIDX_CHUNKID_OBJECTOFFSETS)?;
2600    if oid_l_len % hash_len != 0 || ooff_len != oid_l_len / hash_len * 8 {
2601        return Err(Error::CorruptObject(
2602            "MIDX OID / offset chunk size mismatch".to_owned(),
2603        ));
2604    }
2605    let num_objects = oid_l_len / hash_len;
2606    if num_objects == 0 {
2607        return Ok(None);
2608    }
2609
2610    let rid_order =
2611        if let Ok((ridx_off, ridx_len)) = find_chunk(&data, hdr_end, MIDX_CHUNKID_REVINDEX) {
2612            if ridx_len != num_objects.saturating_mul(4) {
2613                return Err(Error::CorruptObject(
2614                    "MIDX reverse index length does not match object count".to_owned(),
2615                ));
2616            }
2617            let mut order = Vec::with_capacity(num_objects);
2618            for i in 0..num_objects {
2619                let base = ridx_off + i * 4;
2620                order.push(read_be_u32(&data, base)?);
2621            }
2622            order
2623        } else {
2624            let hash_hex = hex::encode(&data[data.len().saturating_sub(hash_len)..]);
2625            let rev_path = midx_path
2626                .parent()
2627                .map(|p| p.join(format!("multi-pack-index-{hash_hex}.rev")))
2628                .unwrap_or_else(|| PathBuf::from(format!("multi-pack-index-{hash_hex}.rev")));
2629            let rev_data = match fs::read(&rev_path) {
2630                Ok(d) => d,
2631                Err(_) => return Ok(None),
2632            };
2633            crate::pack_rev::try_rev_positions_in_pack_order(&rev_data, num_objects).ok_or_else(
2634                || {
2635                    Error::CorruptObject(
2636                        "MIDX standalone .rev sidecar is invalid or missing".to_owned(),
2637                    )
2638                },
2639            )?
2640        };
2641
2642    let mut oids = Vec::with_capacity(num_objects);
2643    for i in 0..num_objects {
2644        let base = oidl_off + i * hash_len;
2645        oids.push(ObjectId::from_bytes(&data[base..base + hash_len])?);
2646    }
2647
2648    let mut pack_and_offset = Vec::with_capacity(num_objects);
2649    for i in 0..num_objects {
2650        let ob = ooff_off + i * 8;
2651        let pack_id = read_be_u32(&data, ob)?;
2652        let off32 = read_be_u32(&data, ob + 4)?;
2653        pack_and_offset.push((pack_id, u64::from(off32)));
2654    }
2655
2656    let mut oid_idx_to_rank = vec![0u32; num_objects];
2657    for (rank, &oid_idx) in rid_order.iter().enumerate() {
2658        let idx = usize::try_from(oid_idx)
2659            .map_err(|_| Error::CorruptObject("bad MIDX reverse index entry".to_owned()))?;
2660        if idx >= num_objects {
2661            return Err(Error::CorruptObject(
2662                "MIDX reverse index out of range".to_owned(),
2663            ));
2664        }
2665        oid_idx_to_rank[idx] = u32::try_from(rank)
2666            .map_err(|_| Error::CorruptObject("too many MIDX objects".to_owned()))?;
2667    }
2668
2669    Ok(Some(MidxReuseTables {
2670        oids,
2671        pack_and_offset,
2672        rid_order,
2673        oid_idx_to_rank,
2674    }))
2675}
2676
2677/// Parse reuse tables from in-memory MIDX bytes and a known RIDX / `.rev` traversal order.
2678pub(crate) fn midx_reuse_tables_from_bytes(
2679    data: &[u8],
2680    rid_order: &[u32],
2681) -> Result<MidxReuseTables> {
2682    let hash_len = midx_hash_len(data);
2683    let (_, hdr_end, _) = parse_midx_header(data)?;
2684    let (oidl_off, oid_l_len) = find_chunk(data, hdr_end, MIDX_CHUNKID_OIDLOOKUP)?;
2685    let (ooff_off, ooff_len) = find_chunk(data, hdr_end, MIDX_CHUNKID_OBJECTOFFSETS)?;
2686    if oid_l_len % hash_len != 0 || ooff_len != oid_l_len / hash_len * 8 {
2687        return Err(Error::CorruptObject(
2688            "MIDX OID / offset chunk size mismatch".to_owned(),
2689        ));
2690    }
2691    let num_objects = oid_l_len / hash_len;
2692    if rid_order.len() != num_objects {
2693        return Err(Error::CorruptObject(
2694            "MIDX reverse index length does not match object count".to_owned(),
2695        ));
2696    }
2697
2698    let mut oids = Vec::with_capacity(num_objects);
2699    for i in 0..num_objects {
2700        let base = oidl_off + i * hash_len;
2701        oids.push(ObjectId::from_bytes(&data[base..base + hash_len])?);
2702    }
2703
2704    let mut pack_and_offset = Vec::with_capacity(num_objects);
2705    for i in 0..num_objects {
2706        let ob = ooff_off + i * 8;
2707        let pack_id = read_be_u32(data, ob)?;
2708        let off32 = read_be_u32(data, ob + 4)?;
2709        pack_and_offset.push((pack_id, u64::from(off32)));
2710    }
2711
2712    let mut oid_idx_to_rank = vec![0u32; num_objects];
2713    for (rank, &oid_idx) in rid_order.iter().enumerate() {
2714        let idx = usize::try_from(oid_idx)
2715            .map_err(|_| Error::CorruptObject("bad MIDX reverse index entry".to_owned()))?;
2716        if idx >= num_objects {
2717            return Err(Error::CorruptObject(
2718                "MIDX reverse index out of range".to_owned(),
2719            ));
2720        }
2721        oid_idx_to_rank[idx] = u32::try_from(rank)
2722            .map_err(|_| Error::CorruptObject("too many MIDX objects".to_owned()))?;
2723    }
2724
2725    Ok(MidxReuseTables {
2726        oids,
2727        pack_and_offset,
2728        rid_order: rid_order.to_vec(),
2729        oid_idx_to_rank,
2730    })
2731}
2732
2733impl MidxReuseTables {
2734    /// Global pseudo-bitmap index for `oid`, or [`None`] if the object is not in this MIDX.
2735    #[must_use]
2736    pub fn global_bitmap_bit(&self, oid: &ObjectId) -> Option<u32> {
2737        let oid_idx = self.oids.binary_search(oid).ok()?;
2738        Some(self.oid_idx_to_rank[oid_idx])
2739    }
2740
2741    /// MIDX-canonical pack id for `oid` (the single copy the MIDX selected after deduplication),
2742    /// or [`None`] if the object is not in this MIDX. Used to reject cross-pack delta reuse: a
2743    /// delta is only reusable verbatim when its base resolves to the *same* pack the delta lives
2744    /// in, mirroring Git's `midx_pair_to_pack_pos` check in `try_partial_reuse`.
2745    #[must_use]
2746    pub fn canonical_pack(&self, oid: &ObjectId) -> Option<u32> {
2747        let oid_idx = self.oids.binary_search(oid).ok()?;
2748        Some(self.pack_and_offset[oid_idx].0)
2749    }
2750}
2751
2752/// One pack's slice of the MIDX pseudo-bitmap namespace (`BTMP` chunk).
2753#[derive(Debug, Clone, Copy)]
2754pub struct MidxBtmpPackRange {
2755    /// Pack index in the MIDX pack-names list.
2756    pub pack_id: u32,
2757    /// First bit index assigned to this pack (cumulative object order).
2758    pub bitmap_pos: u32,
2759    /// Number of objects in this pack (same as `.idx` entry count).
2760    pub bitmap_nr: u32,
2761}
2762
2763/// Read per-pack `(bitmap_pos, bitmap_nr)` from the active MIDX `BTMP` chunk.
2764///
2765/// Returns an empty vector when the MIDX has no bitmapped-packs chunk.
2766pub fn read_midx_btmp_ranges(objects_dir: &Path) -> Result<Vec<MidxBtmpPackRange>> {
2767    let pack_dir = objects_dir.join("pack");
2768    let Some(path) = resolve_tip_midx_path(&pack_dir) else {
2769        return Ok(Vec::new());
2770    };
2771    let data = fs::read(&path).map_err(Error::Io)?;
2772    let (_, hdr_end, _) = parse_midx_header(&data)?;
2773    let Ok((btmp_off, btmp_len)) = find_chunk(&data, hdr_end, MIDX_CHUNKID_BITMAPPED_PACKS) else {
2774        return Ok(Vec::new());
2775    };
2776    if btmp_len == 0 || btmp_len % 8 != 0 {
2777        return Err(Error::CorruptObject(
2778            "invalid MIDX BTMP chunk length".to_owned(),
2779        ));
2780    }
2781    let num_packs = read_be_u32(&data, 8)?;
2782    let n_entries = btmp_len / 8;
2783    if u32::try_from(n_entries).ok() != Some(num_packs) {
2784        return Err(Error::CorruptObject(
2785            "MIDX BTMP entry count does not match num_packs".to_owned(),
2786        ));
2787    }
2788    let mut out = Vec::with_capacity(n_entries);
2789    for i in 0..n_entries {
2790        let base = btmp_off + i * 8;
2791        let bitmap_pos = read_be_u32(&data, base)?;
2792        let bitmap_nr = read_be_u32(&data, base + 4)?;
2793        out.push(MidxBtmpPackRange {
2794            pack_id: u32::try_from(i)
2795                .map_err(|_| Error::CorruptObject("too many packs in MIDX BTMP".to_owned()))?,
2796            bitmap_pos,
2797            bitmap_nr,
2798        });
2799    }
2800    Ok(out)
2801}
2802
2803/// Format `test-tool read-midx --bitmap` output for the active MIDX: per pack, a
2804/// line with `<pack>.pack`, then `  bitmap_pos:` and `  bitmap_nr:`. Returns an
2805/// error whose message is `MIDX does not contain the BTMP chunk` when the MIDX has
2806/// no `BTMP` chunk (mirrors `nth_bitmapped_pack` in git/midx.c).
2807pub fn format_midx_bitmapped_packs(objects_dir: &Path) -> Result<String> {
2808    let pack_dir = objects_dir.join("pack");
2809    let path = resolve_tip_midx_path(&pack_dir)
2810        .ok_or_else(|| Error::CorruptObject("no multi-pack-index found".to_owned()))?;
2811    let data = fs::read(&path).map_err(Error::Io)?;
2812    let (_, hdr_end, _) = parse_midx_header(&data)?;
2813    let (pn_off, pn_len) = find_chunk(&data, hdr_end, MIDX_CHUNKID_PACKNAMES)?;
2814    let names = parse_pack_names_blob(&data[pn_off..pn_off + pn_len])?;
2815    let Ok((btmp_off, btmp_len)) = find_chunk(&data, hdr_end, MIDX_CHUNKID_BITMAPPED_PACKS) else {
2816        return Err(Error::CorruptObject(
2817            "MIDX does not contain the BTMP chunk".to_owned(),
2818        ));
2819    };
2820    let n_entries = btmp_len / 8;
2821    let mut out = String::new();
2822    for i in 0..n_entries {
2823        let base = btmp_off + i * 8;
2824        let bitmap_pos = read_be_u32(&data, base)?;
2825        let bitmap_nr = read_be_u32(&data, base + 4)?;
2826        let idx_name = names.get(i).ok_or_else(|| {
2827            Error::CorruptObject("BTMP entry has no corresponding pack name".to_owned())
2828        })?;
2829        let stem = idx_name.strip_suffix(".idx").unwrap_or(idx_name);
2830        out.push_str(&format!("{stem}.pack\n"));
2831        out.push_str(&format!("  bitmap_pos: {bitmap_pos}\n"));
2832        out.push_str(&format!("  bitmap_nr: {bitmap_nr}\n"));
2833    }
2834    Ok(out)
2835}
2836
2837/// Look up which pack and in-pack offset holds `oid` when listed in the active MIDX.
2838///
2839/// Returns [`None`] when no MIDX exists or `oid` is absent (no allocation on miss).
2840pub fn midx_lookup_pack_and_offset_opt(
2841    objects_dir: &Path,
2842    oid: &ObjectId,
2843) -> Result<Option<(u32, u64)>> {
2844    let Some(chain) = prepared_midx_chain(objects_dir)? else {
2845        return Ok(None);
2846    };
2847    Ok(chain.lookup_pack_and_offset(oid))
2848}
2849
2850/// Look up which pack and in-pack offset holds `oid` according to the active MIDX.
2851pub fn midx_lookup_pack_and_offset(objects_dir: &Path, oid: &ObjectId) -> Result<(u32, u64)> {
2852    let Some(chain) = prepared_midx_chain(objects_dir)? else {
2853        return Err(Error::CorruptObject("no multi-pack-index found".to_owned()));
2854    };
2855    chain.lookup_pack_and_offset(oid).ok_or_else(|| {
2856        Error::CorruptObject(format!("object {} not in multi-pack-index", oid.to_hex()))
2857    })
2858}
2859
2860/// Returns whether `oid` appears in the active MIDX OID table for `objects_dir`.
2861///
2862/// [`None`] means there is no MIDX at the pack tip. [`Some`] is the lookup result when a MIDX exists.
2863pub fn midx_oid_listed_in_tip(objects_dir: &Path, oid: &ObjectId) -> Result<Option<bool>> {
2864    let Some(chain) = prepared_midx_chain(objects_dir)? else {
2865        return Ok(None);
2866    };
2867    Ok(Some(chain.oid_listed_in_tip(oid)))
2868}
2869
2870/// Chunk offsets and metadata of a successfully loaded MIDX, ready for object reads.
2871struct MidxReadView {
2872    oidf_off: usize,
2873    oidl_off: usize,
2874    ooff_off: usize,
2875    loff: Option<(usize, usize)>,
2876    num_objects: usize,
2877    pack_names: Vec<String>,
2878}
2879
2880enum MidxLoadResult {
2881    Ok(MidxReadView),
2882    /// The MIDX is unusable but not fatal (Git returns NULL and falls back to packs).
2883    Skip,
2884}
2885
2886/// Validate and load a MIDX image for object reads, mirroring `load_multi_pack_index`
2887/// in git/midx.c. Fatal corruptions return [`Error::Midx`]; recoverable corruptions emit
2888/// [`Warning`] values through `diagnostics` and return [`MidxLoadResult::Skip`].
2889fn midx_load_for_read(
2890    data: &PackData,
2891    expected_hash_version: u8,
2892    diagnostics: &dyn DiagnosticSink,
2893) -> Result<MidxLoadResult> {
2894    if data.len() < MIDX_HEADER_SIZE + 20 {
2895        diagnostics.warn(Warning::MidxChunkTableError {
2896            detail: "multi-pack-index file is too small".to_owned(),
2897        });
2898        return Ok(MidxLoadResult::Skip);
2899    }
2900    let sig = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
2901    if sig != MIDX_SIGNATURE {
2902        return Err(MidxError::SignatureMismatch {
2903            found: sig,
2904            expected: MIDX_SIGNATURE,
2905        }
2906        .into());
2907    }
2908    let version = data[4];
2909    if version != MIDX_VERSION_V1 && version != MIDX_VERSION_V2 {
2910        return Err(MidxError::VersionNotRecognized { version }.into());
2911    }
2912    let hash_version = data[5];
2913    if hash_version != expected_hash_version {
2914        return Err(MidxError::HashVersionMismatch {
2915            found: hash_version,
2916            expected: expected_hash_version,
2917        }
2918        .into());
2919    }
2920    let hash_len = if hash_version == 2 { 32usize } else { 20usize };
2921    let num_packs = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
2922
2923    let mut toc_errors: Vec<String> = Vec::new();
2924    let chunks = match parse_midx_toc(data, hash_len, &mut toc_errors) {
2925        Ok(c) => c,
2926        Err(fatal) => {
2927            if !toc_errors.is_empty() {
2928                for e in toc_errors {
2929                    diagnostics.warn(Warning::MidxChunkTableError { detail: e });
2930                }
2931                return Ok(MidxLoadResult::Skip);
2932            }
2933            return Err(MidxError::from(fatal).into());
2934        }
2935    };
2936
2937    let Some((pn_off, pn_len)) = toc_chunk_range(&chunks, data.len(), MIDX_CHUNKID_PACKNAMES)
2938    else {
2939        return Err(MidxError::RequiredPackNameChunkMissing.into());
2940    };
2941
2942    let Some((oidf_off, oidf_len)) = toc_chunk_range(&chunks, data.len(), MIDX_CHUNKID_OIDFANOUT)
2943    else {
2944        return Err(MidxError::RequiredOidFanoutChunkMissing.into());
2945    };
2946    if oidf_len != 256 * 4 {
2947        return Err(MidxError::OidFanoutWrongSize.into());
2948    }
2949    let fanout = |i: usize| -> u32 {
2950        let b = oidf_off + i * 4;
2951        u32::from_be_bytes([data[b], data[b + 1], data[b + 2], data[b + 3]])
2952    };
2953    let num_objects = fanout(255) as usize;
2954
2955    let Some((oidl_off, oidl_len)) = toc_chunk_range(&chunks, data.len(), MIDX_CHUNKID_OIDLOOKUP)
2956    else {
2957        return Err(MidxError::RequiredOidLookupChunkMissing.into());
2958    };
2959    if oidl_len != hash_len * num_objects {
2960        return Err(MidxError::OidLookupWrongSize.into());
2961    }
2962
2963    let Some((ooff_off, ooff_len)) =
2964        toc_chunk_range(&chunks, data.len(), MIDX_CHUNKID_OBJECTOFFSETS)
2965    else {
2966        return Err(MidxError::RequiredObjectOffsetsChunkMissing.into());
2967    };
2968    if ooff_len != num_objects * 8 {
2969        return Err(MidxError::ObjectOffsetsWrongSize.into());
2970    }
2971
2972    let loff = toc_chunk_range(&chunks, data.len(), MIDX_CHUNKID_LARGEOFFSETS);
2973
2974    if let Some((_, rlen)) = toc_chunk_range(&chunks, data.len(), MIDX_CHUNKID_REVINDEX) {
2975        if rlen != num_objects * 4 {
2976            diagnostics.warn(Warning::MidxRevIndexWrongSize);
2977            diagnostics.warn(Warning::MidxBitmapMissingReverseIndex);
2978        }
2979    }
2980
2981    let mut pack_names: Vec<String> = Vec::with_capacity(num_packs);
2982    let blob = &data[pn_off..pn_off + pn_len];
2983    let mut start = 0usize;
2984    for _ in 0..num_packs {
2985        let Some(rel) = blob[start..].iter().position(|&b| b == 0) else {
2986            return Err(MidxError::PackNameChunkTooShort.into());
2987        };
2988        let name = match std::str::from_utf8(&blob[start..start + rel]) {
2989            Ok(s) => s.to_string(),
2990            Err(_) => return Err(MidxError::PackNameChunkTooShort.into()),
2991        };
2992        pack_names.push(name);
2993        start += rel + 1;
2994    }
2995
2996    let payload_end = data.len() - hash_len;
2997    let algo = if hash_version == HASH_VERSION_SHA256 {
2998        HashAlgo::Sha256
2999    } else {
3000        HashAlgo::Sha1
3001    };
3002    let budget = crate::pack_map::ResidentBudget::new(data, 2 * 1024 * 1024);
3003    let valid = budget.checksum(algo, &data[..payload_end]) == data[payload_end..];
3004    // Validation touches every page once. Keep only pages later lookups actually need.
3005    data.release_resident();
3006    if !valid {
3007        diagnostics.warn(Warning::MidxChecksumMismatch);
3008        return Ok(MidxLoadResult::Skip);
3009    }
3010
3011    Ok(MidxLoadResult::Ok(MidxReadView {
3012        oidf_off,
3013        oidl_off,
3014        ooff_off,
3015        loff,
3016        num_objects,
3017        pack_names,
3018    }))
3019}
3020
3021/// Validate existing pack indexes named by the active MIDX once per prepared chain view.
3022///
3023/// This advisory check also runs when an ODB read ultimately finds a loose object.
3024/// Strict and permissive views retain independent diagnostic state while sharing
3025/// validated MIDX backing within the active [`crate::pack_store::PackStore`].
3026/// Refresh or invalidation permits a new check. This variant discards warnings;
3027/// use [`validate_midx_referenced_packs_with_diagnostics`] to collect them.
3028pub fn validate_midx_referenced_packs(objects_dir: &Path) {
3029    validate_midx_referenced_packs_with_diagnostics(objects_dir, &NullDiagnostics);
3030}
3031
3032/// Like [`validate_midx_referenced_packs`], emitting warnings through `diagnostics`.
3033pub fn validate_midx_referenced_packs_with_diagnostics(
3034    objects_dir: &Path,
3035    diagnostics: &dyn DiagnosticSink,
3036) {
3037    if let Ok(Some(chain)) = midx_cache::prepared_chain(objects_dir, diagnostics) {
3038        chain.ensure_packs_validated(diagnostics);
3039    }
3040}
3041
3042/// When `core.multiPackIndex` is enabled, try to read `oid` from the active MIDX in `objects_dir`.
3043///
3044/// Returns [`None`] when no MIDX exists or `oid` is not listed. Returns `Some(Err(..))` when the
3045/// MIDX is present but malformed.
3046pub fn try_read_object_via_midx(
3047    objects_dir: &Path,
3048    oid: &ObjectId,
3049) -> Result<Option<crate::objects::Object>> {
3050    try_read_object_via_midx_with_diagnostics(objects_dir, oid, &NullDiagnostics)
3051}
3052
3053/// Like [`try_read_object_via_midx`], emitting recoverable MIDX warnings through `diagnostics`.
3054pub fn try_read_object_via_midx_with_diagnostics(
3055    objects_dir: &Path,
3056    oid: &ObjectId,
3057    diagnostics: &dyn DiagnosticSink,
3058) -> Result<Option<crate::objects::Object>> {
3059    let Some(chain) = midx_cache::prepared_chain(objects_dir, diagnostics)? else {
3060        return Ok(None);
3061    };
3062    chain.try_read_object(oid, diagnostics)
3063}
3064
3065/// Like [`try_read_object_via_midx`], but returns [`ObjectInfo`] without inflating object bodies.
3066pub fn try_read_info_via_midx(objects_dir: &Path, oid: &ObjectId) -> Result<Option<ObjectInfo>> {
3067    try_read_info_via_midx_with_diagnostics(objects_dir, oid, &NullDiagnostics)
3068}
3069
3070/// Like [`try_read_info_via_midx`], emitting recoverable MIDX warnings through `diagnostics`.
3071pub fn try_read_info_via_midx_with_diagnostics(
3072    objects_dir: &Path,
3073    oid: &ObjectId,
3074    diagnostics: &dyn DiagnosticSink,
3075) -> Result<Option<ObjectInfo>> {
3076    let Some(chain) = midx_cache::prepared_chain(objects_dir, diagnostics)? else {
3077        return Ok(None);
3078    };
3079    chain.try_read_info(oid, diagnostics)
3080}
3081
3082/// `read_midx_preferred_idx_name` function.
3083pub fn read_midx_preferred_idx_name(objects_dir: &Path) -> Result<String> {
3084    let pack_dir = objects_dir.join("pack");
3085    let path = resolve_tip_midx_path(&pack_dir)
3086        .ok_or_else(|| Error::CorruptObject("no multi-pack-index found".to_owned()))?;
3087    let data = fs::read(&path).map_err(Error::Io)?;
3088    let (_, hdr_end, _) = parse_midx_header(&data)?;
3089    let (pn_off, pn_len) = find_chunk(&data, hdr_end, MIDX_CHUNKID_PACKNAMES)?;
3090    let names = parse_pack_names_blob(&data[pn_off..pn_off + pn_len])?;
3091    let (ooff_off, ooff_len) = find_chunk(&data, hdr_end, MIDX_CHUNKID_OBJECTOFFSETS)?;
3092    // The preferred pack is recorded in the MIDX reverse index, which is only
3093    // present when the MIDX has a bitmap. Without it, the preferred pack is
3094    // unknowable (git/midx.c `midx_preferred_pack` returns -1). Prefer the
3095    // embedded RIDX chunk; otherwise fall back to a `multi-pack-index*.rev`
3096    // sidecar, matching `load_midx_revindex`.
3097    let (ridx_off, ridx_len) = match find_chunk(&data, hdr_end, MIDX_CHUNKID_REVINDEX) {
3098        Ok(r) => r,
3099        Err(_) => {
3100            return Err(Error::CorruptObject(
3101                "could not determine MIDX preferred pack".to_owned(),
3102            ));
3103        }
3104    };
3105
3106    if ridx_len < 4 || ooff_len < 8 {
3107        return Err(Error::CorruptObject("truncated MIDX RIDX/OOFF".to_owned()));
3108    }
3109    let first_oid_idx = read_be_u32(&data, ridx_off)? as usize;
3110    let entry_base = ooff_off + first_oid_idx * 8;
3111    if entry_base + 8 > data.len() || entry_base + 8 > ooff_off + ooff_len {
3112        return Err(Error::CorruptObject(
3113            "bad MIDX object-offsets index".to_owned(),
3114        ));
3115    }
3116    let pack_id = read_be_u32(&data, entry_base)?;
3117    let idx = usize::try_from(pack_id)
3118        .map_err(|_| Error::CorruptObject("pack id overflow in multi-pack-index".to_owned()))?;
3119    names
3120        .get(idx)
3121        .cloned()
3122        .ok_or_else(|| Error::CorruptObject("preferred pack id out of range".to_owned()))
3123}
3124
3125/// Build `objects/pack/multi-pack-index` for all pack indexes in `pack_dir`.
3126///
3127/// Returns an error if there are no `.idx` files, if an object offset does not
3128/// fit in 31 bits (no `LOFF` chunk yet), or if I/O fails.
3129/// Remove every multi-pack-index file under `pack_dir` (root file, sidecars, and
3130/// `multi-pack-index.d/`). Used by full `repack -a` so stale incremental chains do not survive.
3131pub fn clear_pack_midx_state(pack_dir: &Path) -> Result<()> {
3132    let _ = fs::remove_file(pack_dir.join("multi-pack-index"));
3133    scrub_root_midx_sidecars_except(pack_dir, None)?;
3134    let midx_d = midx_d_dir(pack_dir);
3135    if midx_d.exists() {
3136        let _ = fs::remove_dir_all(&midx_d);
3137    }
3138    midx_cache::evict_pack_dir(pack_dir);
3139    Ok(())
3140}
3141
3142/// `write_multi_pack_index` function.
3143pub fn write_multi_pack_index(pack_dir: &Path) -> Result<WriteMultiPackIndexResult> {
3144    write_multi_pack_index_with_options(pack_dir, &WriteMultiPackIndexOptions::default())
3145}
3146
3147fn repository_for_pack_dir(pack_dir: &Path) -> Result<Repository> {
3148    let objects = pack_dir.parent().ok_or_else(|| {
3149        Error::Io(std::io::Error::new(
3150            std::io::ErrorKind::NotFound,
3151            "pack directory has no parent",
3152        ))
3153    })?;
3154    let git_dir = objects.parent().ok_or_else(|| {
3155        Error::Io(std::io::Error::new(
3156            std::io::ErrorKind::NotFound,
3157            "objects directory has no parent",
3158        ))
3159    })?;
3160    Repository::open(git_dir, &RepositoryOptions::empty())
3161}
3162
3163fn midx_bitmap_sidecar_matches(path: &Path, midx_checksum: &[u8]) -> bool {
3164    let Ok(data) = fs::read(path) else {
3165        return false;
3166    };
3167    let header_end = 4 + 2 + 2 + 4;
3168    if data.len() < header_end + midx_checksum.len() {
3169        return false;
3170    }
3171    if &data[0..4] != b"BITM" {
3172        return false;
3173    }
3174    data[header_end..header_end + midx_checksum.len()] == *midx_checksum
3175}
3176
3177fn write_midx_reachability_bitmap(
3178    pack_dir: &Path,
3179    midx_bytes: &[u8],
3180    rid_order: &[u32],
3181    midx_checksum: &[u8],
3182    bitmap_path: &Path,
3183) -> Result<()> {
3184    write_midx_reachability_bitmap_with_options(
3185        pack_dir,
3186        midx_bytes,
3187        rid_order,
3188        midx_checksum,
3189        bitmap_path,
3190        &PackBitmapWriteOptions::default(),
3191    )
3192}
3193
3194fn write_midx_reachability_bitmap_with_options(
3195    pack_dir: &Path,
3196    midx_bytes: &[u8],
3197    rid_order: &[u32],
3198    midx_checksum: &[u8],
3199    bitmap_path: &Path,
3200    options: &PackBitmapWriteOptions,
3201) -> Result<()> {
3202    let repo = repository_for_pack_dir(pack_dir)?;
3203    MidxBitmapWriter::write_for_midx_layer(
3204        &repo,
3205        midx_bytes,
3206        rid_order,
3207        midx_checksum,
3208        bitmap_path,
3209        options,
3210    )
3211    .map_err(|e| MidxError::BitmapWriteFailed {
3212        detail: e.to_string(),
3213    })?;
3214    Ok(())
3215}
3216
3217/// Write `multi-pack-index` with optional preferred pack, bitmap sidecars, and incremental chain.
3218pub fn write_multi_pack_index_with_options(
3219    pack_dir: &Path,
3220    opts: &WriteMultiPackIndexOptions,
3221) -> Result<WriteMultiPackIndexResult> {
3222    let version = midx_write_version(opts.version)?;
3223
3224    // Git warns and ignores an existing MIDX whose checksum does not validate when
3225    // writing a fresh (non-stdin-packs) MIDX (git/midx-write.c `write_midx_internal`).
3226    if opts.pack_names_subset_ordered.is_none() {
3227        if let Some(existing) = resolve_tip_midx_path(pack_dir) {
3228            if let Ok(bytes) = fs::read(&existing) {
3229                if midx_checksum_is_valid(&bytes) {
3230                    // A fresh write copies the existing MIDX's packs. Loading a pack
3231                    // it references whose `.pack` is gone fails with `could not load
3232                    // pack N` (git/midx-write.c `fill_pack_from_midx`).
3233                    if let Ok((_, existing_names)) = oids_and_packs_from_midx_data(&bytes) {
3234                        for (i, name) in existing_names.iter().enumerate() {
3235                            let stem = name.strip_suffix(".idx").unwrap_or(name);
3236                            if !pack_dir.join(format!("{stem}.pack")).exists() {
3237                                return Err(
3238                                    MidxError::ReferencedPackMissing { pack_index: i }.into()
3239                                );
3240                            }
3241                        }
3242                    }
3243                } else {
3244                    midx_write_warn(opts, Warning::MidxIgnoringExistingChecksumMismatch);
3245                }
3246            }
3247        }
3248    }
3249
3250    // Git's MIDX covers every pack index in the directory regardless of its
3251    // basename (the `.git/objects/pack/test-*.idx` packs created by t7900's
3252    // incremental-repack test, for instance), so include any `*.idx` whose
3253    // companion `.pack` exists.
3254    let mut idx_names: Vec<String> = fs::read_dir(pack_dir)
3255        .map(|rd| {
3256            rd.filter_map(|e| e.ok())
3257                .filter_map(|e| {
3258                    let name = e.file_name().to_string_lossy().to_string();
3259                    let stem = name.strip_suffix(".idx")?;
3260                    if pack_dir.join(format!("{stem}.pack")).exists() {
3261                        Some(name)
3262                    } else {
3263                        None
3264                    }
3265                })
3266                .collect()
3267        })
3268        .unwrap_or_default();
3269    idx_names.sort();
3270
3271    let idx_names: Vec<String> = if let Some(sub) = &opts.pack_names_subset_ordered {
3272        let mut out = Vec::new();
3273        for line in sub {
3274            let want = normalize_pack_idx_basename(line)?;
3275            if let Some(found) = idx_names.iter().find(|n| **n == want).cloned() {
3276                if !out.contains(&found) {
3277                    out.push(found);
3278                }
3279            }
3280            // Unknown names on stdin are silently ignored (Git skips packs it
3281            // cannot find rather than failing the whole write).
3282        }
3283        // Readers reject a v1 MIDX whose pack names are not strictly increasing.
3284        if version == MIDX_VERSION_V1 {
3285            out.sort();
3286        }
3287        out
3288    } else {
3289        idx_names
3290    };
3291
3292    // Resolve / validate the preferred pack against the working pack set. Git emits a
3293    // (non-fatal) `warning: unknown preferred pack: '<name>'` when it cannot be matched.
3294    let mut preferred_warned = false;
3295    if let Some(raw) = opts.preferred_pack_name.as_deref() {
3296        if opts.preferred_pack_idx.is_none()
3297            && !idx_names
3298                .iter()
3299                .any(|n| cmp_idx_or_pack_name(raw, n).is_eq())
3300        {
3301            midx_write_warn(
3302                opts,
3303                Warning::MidxUnknownPreferredPack {
3304                    name: raw.to_owned(),
3305                },
3306            );
3307            preferred_warned = true;
3308        }
3309    }
3310
3311    if idx_names.is_empty() {
3312        return Err(MidxError::NoPacksToIndex.into());
3313    }
3314
3315    let (base_oids, base_pack_names) = if opts.incremental {
3316        collect_incremental_base(pack_dir)?
3317    } else {
3318        (HashSet::new(), HashSet::new())
3319    };
3320
3321    let layer_idx_names: Vec<String> = if opts.incremental {
3322        idx_names
3323            .iter()
3324            .filter(|n| {
3325                !base_pack_names
3326                    .iter()
3327                    .any(|bp| pack_names_match_layer(bp, n))
3328            })
3329            .cloned()
3330            .collect()
3331    } else {
3332        idx_names.clone()
3333    };
3334
3335    if opts.incremental && layer_idx_names.is_empty() {
3336        return Ok(WriteMultiPackIndexResult::default());
3337    }
3338
3339    let work_names = if opts.incremental {
3340        &layer_idx_names[..]
3341    } else {
3342        &idx_names[..]
3343    };
3344
3345    let mut preferred_idx = opts.preferred_pack_idx.map(|p| p as usize);
3346    if preferred_idx.is_none() && !preferred_warned {
3347        if let Some(raw) = opts.preferred_pack_name.as_deref() {
3348            // Already validated against `idx_names`; resolve against the working set.
3349            preferred_idx = work_names
3350                .iter()
3351                .position(|n| cmp_idx_or_pack_name(raw, n).is_eq());
3352        }
3353    }
3354    if preferred_idx.is_none() && opts.write_bitmap && !work_names.is_empty() {
3355        preferred_idx = preferred_pack_index_by_mtime(pack_dir, work_names)?;
3356    }
3357    if let Some(p) = preferred_idx {
3358        if p >= work_names.len() {
3359            return Err(Error::CorruptObject(
3360                "preferred pack index out of range".to_owned(),
3361            ));
3362        }
3363    }
3364
3365    let mut indexes: Vec<PackIndex> = Vec::with_capacity(work_names.len());
3366    for name in work_names {
3367        let path = pack_dir.join(name);
3368        // Do not re-verify the idx trailer here; Git reads the offset table
3369        // directly (t5319 forces a deliberately corrupt-but-valid 64-bit idx).
3370        indexes.push(crate::pack::read_pack_index_no_verify(&path)?);
3371    }
3372
3373    // Git refuses an explicitly preferred pack that has no objects.
3374    if let Some(p) = preferred_idx {
3375        if indexes.get(p).map(PackIndex::len).unwrap_or(0) == 0 {
3376            let name = work_names.get(p).cloned().unwrap_or_default();
3377            let pack_name = name.strip_suffix(".idx").unwrap_or(&name);
3378            return Err(MidxError::PreferredPackEmpty {
3379                pack: format!("{pack_name}.pack"),
3380            }
3381            .into());
3382        }
3383    }
3384
3385    let pack_mtimes_layer: Vec<std::time::SystemTime> =
3386        indexes.iter().map(pack_mtime_for_midx).collect();
3387    let preferred_u32 = preferred_idx.map(|p| p as u32);
3388    let select_hash_len = if repo_midx_hash_version(pack_dir) == 2 {
3389        32
3390    } else {
3391        20
3392    };
3393
3394    let mut best: HashMap<ObjectId, MidxEntry> = HashMap::new();
3395    for (pack_id, idx) in indexes.iter().enumerate() {
3396        let pack_id = u32::try_from(pack_id).map_err(|_| {
3397            Error::CorruptObject("too many pack files for multi-pack-index".to_owned())
3398        })?;
3399        let mtime = pack_mtimes_layer[pack_id as usize];
3400        for e in idx.iter() {
3401            if e.oid().len() != select_hash_len {
3402                continue;
3403            }
3404            let Ok(oid) = ObjectId::from_bytes(e.oid()) else {
3405                continue;
3406            };
3407            if opts.incremental && base_oids.contains(&oid) {
3408                continue;
3409            }
3410            let offset = e.offset()?;
3411            let cand = MidxEntry {
3412                oid,
3413                pack_id,
3414                offset,
3415                pack_mtime: mtime,
3416            };
3417            match best.get(&oid) {
3418                None => {
3419                    best.insert(oid, cand);
3420                }
3421                Some(cur) => {
3422                    if midx_pick_better_entry(cur, pack_id, offset, mtime, preferred_u32) {
3423                        best.insert(oid, cand);
3424                    }
3425                }
3426            }
3427        }
3428    }
3429
3430    let write_bitmap_chunks = opts.write_bitmap && (!opts.incremental || !best.is_empty());
3431
3432    let omit_embedded_ridx = opts.write_rev_sidecar;
3433    // An incremental layer must not repeat objects already provided by the base
3434    // chain even when the layer's own pack physically contains them (a fresh pack
3435    // built with `--revs` from a tag range, for instance). Filter by base OID.
3436    let exclude = if opts.incremental && !base_oids.is_empty() {
3437        Some(&base_oids)
3438    } else {
3439        None
3440    };
3441    let (out, bitmap_rid_order) = build_midx_bytes_filtered(
3442        work_names,
3443        &indexes,
3444        preferred_idx,
3445        write_bitmap_chunks,
3446        omit_embedded_ridx,
3447        version,
3448        repo_midx_hash_version(pack_dir),
3449        exclude,
3450    )?;
3451
3452    let repo_algo = repo_hash_algo_for_pack_dir(pack_dir);
3453    let hash_len = repo_algo.len();
3454    let hash = &out[out.len() - hash_len..];
3455    let hash_hex = hex::encode(hash);
3456    let hash_arr: Vec<u8> = hash.to_vec();
3457
3458    let bitmap_outcome = if opts.write_bitmap {
3459        if opts.incremental {
3460            MidxBitmapWriteOutcome::SkippedIncrementalLayer
3461        } else {
3462            MidxBitmapWriteOutcome::Written
3463        }
3464    } else {
3465        MidxBitmapWriteOutcome::NotRequested
3466    };
3467
3468    if opts.incremental {
3469        let root_midx = pack_dir.join("multi-pack-index");
3470        let chain_path = chain_file_path(pack_dir);
3471        let chain_existed = chain_path.exists();
3472
3473        let mut chain = if root_midx.exists() && !chain_existed {
3474            let root_hex = midx_checksum_hex_from_path(&root_midx)?;
3475            link_root_midx_into_chain(pack_dir, &root_hex)?;
3476            vec![root_hex]
3477        } else {
3478            read_chain_layer_hashes(pack_dir).unwrap_or_default()
3479        };
3480
3481        chain.push(hash_hex.clone());
3482
3483        let midx_d = midx_d_dir(pack_dir);
3484        fs::create_dir_all(&midx_d).map_err(Error::Io)?;
3485
3486        let layer_path = midx_d.join(format!("multi-pack-index-{hash_hex}.midx"));
3487        fs::write(&layer_path, &out).map_err(Error::Io)?;
3488
3489        let mut chain_data = String::new();
3490        for h in &chain {
3491            chain_data.push_str(h);
3492            chain_data.push('\n');
3493        }
3494        fs::write(chain_file_path(pack_dir), chain_data.as_bytes()).map_err(Error::Io)?;
3495
3496        clear_stale_split_layers(pack_dir, &chain)?;
3497
3498        let _ = fs::remove_file(pack_dir.join("multi-pack-index"));
3499        scrub_root_midx_sidecars(pack_dir)?;
3500        if write_bitmap_chunks && opts.write_rev_sidecar {
3501            let full = hex::encode(hash);
3502            let rev_path = midx_d.join(format!("multi-pack-index-{full}.rev"));
3503            if let Some(order) = bitmap_rid_order.as_ref() {
3504                write_midx_rev_sidecar(&rev_path, order, &hash_arr, repo_algo)?;
3505            }
3506        }
3507    } else {
3508        // A non-incremental write replaces any prior split layout. Git removes the
3509        // individual incremental layer files inside `multi-pack-index.d/` and
3510        // unlinks the chain file, but never `rmdir`s the directory itself, so an
3511        // empty `multi-pack-index.d/` is left behind (t5334 expects
3512        // `test_dir_is_empty $midxdir` after the conversion).
3513        let dest = pack_dir.join("multi-pack-index");
3514
3515        // Git's `midx_needs_update`: if the new MIDX is byte-identical to the one
3516        // already on disk and we are not (re)writing a bitmap, leave the file
3517        // untouched so its mtime is preserved (t5319 `test_midx_is_retained`).
3518        let bitmap_path = pack_dir.join(format!("multi-pack-index-{hash_hex}.bitmap"));
3519        let bitmap_ok = !opts.write_bitmap || midx_bitmap_sidecar_matches(&bitmap_path, hash);
3520        // Only short-circuit when there is no active incremental chain to collapse;
3521        // an empty leftover `multi-pack-index.d/` (from a prior conversion) must not
3522        // defeat the retention optimization, so key off the chain file, not the dir.
3523        if bitmap_ok && !chain_file_path(pack_dir).exists() {
3524            if let Ok(existing) = fs::read(&dest) {
3525                if existing == out {
3526                    if opts.write_rev_sidecar {
3527                        if let Some(order) = bitmap_rid_order.as_ref() {
3528                            let rev_path =
3529                                pack_dir.join(format!("multi-pack-index-{hash_hex}.rev"));
3530                            if !rev_path.exists() {
3531                                write_midx_rev_sidecar(&rev_path, order, &hash_arr, repo_algo)?;
3532                            }
3533                        }
3534                    }
3535                    return Ok(WriteMultiPackIndexResult {
3536                        bitmap: bitmap_outcome,
3537                    });
3538                }
3539            }
3540        }
3541
3542        clear_incremental_midx_files(pack_dir)?;
3543
3544        fs::write(&dest, &out).map_err(Error::Io)?;
3545
3546        scrub_root_midx_sidecars_except(pack_dir, Some(&hash_hex))?;
3547
3548        if opts.write_bitmap {
3549            let rid = bitmap_rid_order.as_ref().ok_or_else(|| {
3550                Error::CorruptObject("MIDX bitmap write missing reverse index order".to_owned())
3551            })?;
3552            write_midx_reachability_bitmap(pack_dir, &out, rid, hash, &bitmap_path)?;
3553        }
3554        if opts.write_rev_sidecar {
3555            let rev_path = pack_dir.join(format!("multi-pack-index-{hash_hex}.rev"));
3556            let order = bitmap_rid_order.as_ref().ok_or_else(|| {
3557                Error::CorruptObject(
3558                    "MIDX rev sidecar write missing reverse index order".to_owned(),
3559                )
3560            })?;
3561            write_midx_rev_sidecar(&rev_path, order, &hash_arr, repo_algo)?;
3562        }
3563    }
3564
3565    if opts.write_rev_sidecar {
3566        let hash_hex = hex::encode(hash);
3567        let rev_root = pack_dir.join(format!("multi-pack-index-{hash_hex}.rev"));
3568        let rev_layer = midx_d_dir(pack_dir).join(format!("multi-pack-index-{hash_hex}.rev"));
3569        if !rev_root.is_file() && !rev_layer.is_file() {
3570            return Err(Error::CorruptObject(format!(
3571                "MIDX rev sidecar missing after write ({hash_hex})"
3572            )));
3573        }
3574    }
3575
3576    midx_cache::evict_pack_dir(pack_dir);
3577    Ok(WriteMultiPackIndexResult {
3578        bitmap: bitmap_outcome,
3579    })
3580}
3581
3582fn pack_names_match_layer(base_name: &str, disk_idx: &str) -> bool {
3583    if base_name == disk_idx {
3584        return true;
3585    }
3586    cmp_idx_or_pack_name(disk_idx, base_name).is_eq()
3587}
3588
3589/// Failure modes of [`compact_multi_pack_index`], each mapping to one of git's
3590/// user-facing diagnostics in `cmd_multi_pack_index_compact`.
3591#[derive(Debug)]
3592pub enum CompactError {
3593    /// `--incremental` was requested but no chain exists yet.
3594    NoChain,
3595    /// One of the endpoint checksums does not name a layer in the chain. Carries the
3596    /// raw argument text so the message matches `could not find MIDX: <arg>`.
3597    MissingEndpoint(String),
3598    /// Both endpoints resolve to the same layer.
3599    IdenticalEndpoints,
3600    /// `from` (`argv[0]`) is newer than `to` (`argv[1]`); git requires `from` to be an
3601    /// ancestor of `to`. Carries `(from, to)` arg text for the diagnostic.
3602    NotAncestor(String, String),
3603    /// Compaction was requested with the v1 on-disk MIDX format.
3604    V1Format,
3605    /// Any underlying I/O or parse failure.
3606    Other(String),
3607}
3608
3609impl std::fmt::Display for CompactError {
3610    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3611        match self {
3612            CompactError::NoChain => write!(f, "no multi-pack-index chain to compact"),
3613            CompactError::MissingEndpoint(s) => write!(f, "could not find MIDX: {s}"),
3614            CompactError::IdenticalEndpoints => {
3615                write!(f, "MIDX compaction endpoints must be unique")
3616            }
3617            CompactError::NotAncestor(from, to) => {
3618                write!(f, "MIDX {from} must be an ancestor of {to}")
3619            }
3620            CompactError::V1Format => write!(f, "cannot perform MIDX compaction with v1 format"),
3621            CompactError::Other(s) => write!(f, "{s}"),
3622        }
3623    }
3624}
3625
3626impl From<Error> for CompactError {
3627    fn from(e: Error) -> Self {
3628        CompactError::Other(e.to_string())
3629    }
3630}
3631
3632/// Collect every OID provided by the chain layers in `hashes` (each layer file is
3633/// self-contained: it lists only its own incremental objects).
3634fn collect_layer_oids(pack_dir: &Path, hashes: &[String]) -> Result<HashSet<ObjectId>> {
3635    let mut oids = HashSet::new();
3636    for h in hashes {
3637        let p = midx_d_dir(pack_dir).join(format!("multi-pack-index-{h}.midx"));
3638        let data = load_midx_file(&p)?;
3639        let (layer_oids, _) = oids_and_packs_from_midx_data(&data)?;
3640        oids.extend(layer_oids);
3641    }
3642    Ok(oids)
3643}
3644
3645/// Pack idx basenames listed by a single chain layer, in the layer's stored order.
3646fn layer_pack_names(pack_dir: &Path, hash: &str) -> Result<Vec<String>> {
3647    let p = midx_d_dir(pack_dir).join(format!("multi-pack-index-{hash}.midx"));
3648    let data = load_midx_file(&p)?;
3649    let (_, hdr_end, _) = parse_midx_header(&data)?;
3650    let (pn_off, pn_len) = find_chunk(&data, hdr_end, MIDX_CHUNKID_PACKNAMES)?;
3651    parse_pack_names_blob(&data[pn_off..pn_off + pn_len])
3652}
3653
3654/// `git multi-pack-index compact <from> <to>`: merge the inclusive chain range
3655/// `[from..to]` (oldest→newest, matching git's `from`=`argv[0]` / `to`=`argv[1]`) into a
3656/// single new incremental layer, preserving pack order, and rewrite the chain as
3657/// `[layers before from] + [compacted layer] + [layers after to]`.
3658///
3659/// Mirrors `write_midx_file_compact` (git/midx-write.c). Because grit's chain layers
3660/// are self-contained (each lists only its own packs/objects), layers outside the
3661/// compacted range keep their existing files and checksums untouched.
3662///
3663/// `version` is the on-disk format of the compacted layer. `None` writes v2, which
3664/// compaction needs because the merged packs keep chain order rather than sorted order;
3665/// `Some(1)` fails with [`CompactError::V1Format`], and values other than `1` or `2` fail
3666/// with [`MidxError::UnknownWriteVersion`] wrapped in [`CompactError::Other`].
3667pub fn compact_multi_pack_index(
3668    pack_dir: &Path,
3669    from_arg: &str,
3670    to_arg: &str,
3671    write_bitmaps: bool,
3672    write_rev: bool,
3673    version: Option<u8>,
3674) -> std::result::Result<(), CompactError> {
3675    let version = resolve_write_version(version, MIDX_VERSION_V2)?;
3676    if version == MIDX_VERSION_V1 {
3677        return Err(CompactError::V1Format);
3678    }
3679
3680    let chain = read_chain_layer_hashes(pack_dir).map_err(|_| CompactError::NoChain)?;
3681    if chain.is_empty() {
3682        return Err(CompactError::NoChain);
3683    }
3684
3685    let from_hex = from_arg.to_ascii_lowercase();
3686    let to_hex = to_arg.to_ascii_lowercase();
3687
3688    let from_pos = chain.iter().position(|h| *h == from_hex);
3689    let to_pos = chain.iter().position(|h| *h == to_hex);
3690
3691    // Match git: report `from` first, then `to`, when an endpoint is missing.
3692    let Some(from_pos) = from_pos else {
3693        return Err(CompactError::MissingEndpoint(from_arg.to_string()));
3694    };
3695    let Some(to_pos) = to_pos else {
3696        return Err(CompactError::MissingEndpoint(to_arg.to_string()));
3697    };
3698
3699    if from_pos == to_pos {
3700        return Err(CompactError::IdenticalEndpoints);
3701    }
3702    // git walks `base_midx` from `from`; reaching `to` means `from` is an ancestor of
3703    // `to`, i.e. `from` is newer (higher chain index) than `to`. That is the reverse
3704    // of what compaction expects, so report the "must be an ancestor" error.
3705    if from_pos > to_pos {
3706        return Err(CompactError::NotAncestor(
3707            from_arg.to_string(),
3708            to_arg.to_string(),
3709        ));
3710    }
3711
3712    // Layers strictly before `from` form the base; their objects are excluded from
3713    // the compacted layer.
3714    let base_hashes = &chain[..from_pos];
3715    let merged_hashes = &chain[from_pos..=to_pos];
3716    let upper_hashes = &chain[to_pos + 1..];
3717
3718    let base_oids = collect_layer_oids(pack_dir, base_hashes)?;
3719
3720    // Gather the merged layers' pack idx names in chain order (oldest layer first),
3721    // preserving each layer's internal order (git's `fill_packs_from_midx_range`).
3722    let mut ordered_idx_names: Vec<String> = Vec::new();
3723    for h in merged_hashes {
3724        for name in layer_pack_names(pack_dir, h)? {
3725            if !ordered_idx_names.contains(&name) {
3726                ordered_idx_names.push(name);
3727            }
3728        }
3729    }
3730
3731    if ordered_idx_names.is_empty() {
3732        return Err(CompactError::Other(
3733            "no packs found in compaction range".to_owned(),
3734        ));
3735    }
3736
3737    // Load the pack indexes in the resolved order.
3738    let mut indexes: Vec<PackIndex> = Vec::with_capacity(ordered_idx_names.len());
3739    for name in &ordered_idx_names {
3740        let path = pack_dir.join(name);
3741        indexes.push(crate::pack::read_pack_index_no_verify(&path)?);
3742    }
3743
3744    // When writing a bitmap, git sets the preferred pack to the first (oldest) pack
3745    // of the compacted range so its objects win duplicate selection.
3746    let preferred_idx = if write_bitmaps { Some(0usize) } else { None };
3747
3748    let exclude = if base_oids.is_empty() {
3749        None
3750    } else {
3751        Some(&base_oids)
3752    };
3753
3754    let (out, bitmap_rid_order) = build_midx_bytes_filtered(
3755        &ordered_idx_names,
3756        &indexes,
3757        preferred_idx,
3758        write_bitmaps,
3759        write_rev,
3760        version,
3761        repo_midx_hash_version(pack_dir),
3762        exclude,
3763    )?;
3764
3765    let repo_algo = repo_hash_algo_for_pack_dir(pack_dir);
3766    let hash_len = repo_algo.len();
3767    let hash = &out[out.len() - hash_len..];
3768    let hash_hex = hex::encode(hash);
3769    let hash_arr: Vec<u8> = hash.to_vec();
3770
3771    let midx_d = midx_d_dir(pack_dir);
3772    fs::create_dir_all(&midx_d).map_err(Error::Io)?;
3773
3774    let layer_path = midx_d.join(format!("multi-pack-index-{hash_hex}.midx"));
3775    fs::write(&layer_path, &out).map_err(Error::Io)?;
3776
3777    // New chain: base layers, the compacted layer, then the untouched upper layers.
3778    let mut new_chain: Vec<String> = Vec::new();
3779    new_chain.extend(base_hashes.iter().cloned());
3780    new_chain.push(hash_hex.clone());
3781    new_chain.extend(upper_hashes.iter().cloned());
3782
3783    let mut chain_data = String::new();
3784    for h in &new_chain {
3785        chain_data.push_str(h);
3786        chain_data.push('\n');
3787    }
3788    fs::write(chain_file_path(pack_dir), chain_data.as_bytes()).map_err(Error::Io)?;
3789
3790    if write_bitmaps {
3791        if write_rev {
3792            let rev_path = midx_d.join(format!("multi-pack-index-{hash_hex}.rev"));
3793            if let Some(order) = bitmap_rid_order.as_ref() {
3794                write_midx_rev_sidecar(&rev_path, order, &hash_arr, repo_algo)?;
3795            }
3796        }
3797        let bitmap_path = midx_d.join(format!("multi-pack-index-{hash_hex}.bitmap"));
3798        let rid = bitmap_rid_order.as_ref().ok_or_else(|| {
3799            CompactError::Other("MIDX bitmap write missing reverse index order".to_owned())
3800        })?;
3801        write_midx_reachability_bitmap_with_options(
3802            pack_dir,
3803            &out,
3804            rid,
3805            hash,
3806            &bitmap_path,
3807            &PackBitmapWriteOptions {
3808                verify_reachability_closure: false,
3809                ..PackBitmapWriteOptions::default()
3810            },
3811        )
3812        .map_err(|e| CompactError::Other(e.to_string()))?;
3813    }
3814
3815    // Drop the now-removed range layers and their sidecars.
3816    clear_stale_split_layers(pack_dir, &new_chain)?;
3817
3818    midx_cache::evict_pack_dir(pack_dir);
3819    Ok(())
3820}
3821
3822fn scrub_root_midx_sidecars(pack_dir: &Path) -> Result<()> {
3823    scrub_root_midx_sidecars_except(pack_dir, None)
3824}
3825
3826fn scrub_root_midx_sidecars_except(pack_dir: &Path, keep_hex: Option<&str>) -> Result<()> {
3827    let Ok(rd) = fs::read_dir(pack_dir) else {
3828        return Ok(());
3829    };
3830    for ent in rd {
3831        let ent = ent.map_err(Error::Io)?;
3832        let name = ent.file_name().to_string_lossy().to_string();
3833        let Some(rest) = name.strip_prefix("multi-pack-index-") else {
3834            continue;
3835        };
3836        if !(rest.ends_with(".bitmap") || rest.ends_with(".rev")) {
3837            continue;
3838        }
3839        let hash_part = rest
3840            .strip_suffix(".bitmap")
3841            .or_else(|| rest.strip_suffix(".rev"))
3842            .unwrap_or(rest);
3843        // Git's `clear_midx_files_ext` removes any `multi-pack-index-<hash>.<ext>`
3844        // sidecar that does not belong to the current MIDX, regardless of the
3845        // hash's textual length (t5319 plants a `multi-pack-index-abc.rev`).
3846        if keep_hex.is_some_and(|k| k == hash_part) {
3847            continue;
3848        }
3849        let _ = fs::remove_file(ent.path());
3850    }
3851    Ok(())
3852}
3853
3854#[cfg(test)]
3855mod tests {
3856    use super::{
3857        clear_pack_midx_state, resolve_tip_midx_path, try_read_object_via_midx,
3858        write_multi_pack_index,
3859    };
3860
3861    use crate::error::{Error, MidxError};
3862    use crate::hot_path_test_metrics::HotPathMetricsScope;
3863    use crate::objects::ObjectId;
3864    use crate::odb::Odb;
3865    use crate::pack::{clear_pack_cache, read_object_from_packs};
3866    use std::fs;
3867    use std::path::Path;
3868    use std::process::Command;
3869    use std::sync::Arc;
3870
3871    fn git(dir: &Path, args: &[&str]) {
3872        let out = Command::new("git")
3873            .current_dir(dir)
3874            .args(args)
3875            .env("GIT_AUTHOR_NAME", "T")
3876            .env("GIT_AUTHOR_EMAIL", "t@example.com")
3877            .env("GIT_COMMITTER_NAME", "T")
3878            .env("GIT_COMMITTER_EMAIL", "t@example.com")
3879            .env("GIT_CONFIG_GLOBAL", "/dev/null")
3880            .env("GIT_CONFIG_SYSTEM", "/dev/null")
3881            .output()
3882            .expect("run git");
3883        assert!(
3884            out.status.success(),
3885            "git {:?} failed: {}",
3886            args,
3887            String::from_utf8_lossy(&out.stderr)
3888        );
3889    }
3890
3891    fn git_available() -> bool {
3892        Command::new("git")
3893            .arg("--version")
3894            .output()
3895            .map(|o| o.status.success())
3896            .unwrap_or(false)
3897    }
3898
3899    /// Pack fixture: one commit, all objects in a single pack (no MIDX yet).
3900    pub(super) fn midx_fixture(with_midx: bool) -> Option<(tempfile::TempDir, ObjectId)> {
3901        if !git_available() {
3902            return None;
3903        }
3904        let tmp = tempfile::tempdir().ok()?;
3905        let dir = tmp.path();
3906        git(dir, &["init", "-q", "-b", "main"]);
3907        std::fs::write(dir.join("f.txt"), b"hello midx cache").ok()?;
3908        git(dir, &["add", "f.txt"]);
3909        git(dir, &["commit", "-q", "-m", "c"]);
3910        git(dir, &["repack", "-adf"]);
3911        let pack_dir = dir.join(".git/objects/pack");
3912        if with_midx {
3913            write_multi_pack_index(&pack_dir).ok()?;
3914        }
3915        let hex = Command::new("git")
3916            .current_dir(dir)
3917            .args(["rev-parse", "HEAD"])
3918            .env("GIT_CONFIG_GLOBAL", "/dev/null")
3919            .env("GIT_CONFIG_SYSTEM", "/dev/null")
3920            .output()
3921            .ok()?;
3922        if !hex.status.success() {
3923            return None;
3924        }
3925        let oid = ObjectId::from_hex(std::str::from_utf8(&hex.stdout).ok()?.trim()).ok()?;
3926        Some((tmp, oid))
3927    }
3928
3929    #[cfg_attr(
3930        not(feature = "full-tests"),
3931        ignore = "starts system git or is heavy; run with --features full-tests"
3932    )]
3933    #[test]
3934    fn vanished_cached_tip_falls_back() {
3935        let Some((tmp, oid)) = midx_fixture(true) else {
3936            eprintln!("SKIP: git unavailable for MIDX fixture");
3937            return;
3938        };
3939        let git_dir = tmp.path().join(".git");
3940        let objects = git_dir.join("objects");
3941        let pack_dir = objects.join("pack");
3942        let midx_path = pack_dir.join("multi-pack-index");
3943        assert!(midx_path.is_file(), "expected root MIDX");
3944
3945        assert!(
3946            try_read_object_via_midx(&objects, &oid)
3947                .expect("prime midx read")
3948                .is_some(),
3949            "object should be reachable via MIDX"
3950        );
3951
3952        std::fs::remove_file(&midx_path).expect("simulate external MIDX removal");
3953
3954        // Prepared MIDX bytes stay in-process until evict; external unlink alone does not
3955        // invalidate the parsed layer (no stat on the hot path).
3956        assert!(
3957            try_read_object_via_midx(&objects, &oid)
3958                .expect("vanished tip must not fail reads")
3959                .is_some(),
3960            "cached prepared layer still serves until pack-dir evict"
3961        );
3962
3963        let odb = Odb::new(&objects).with_config_git_dir(git_dir);
3964        let via_pack = odb.read(&oid).expect("pack fallback after vanished MIDX");
3965        assert_eq!(via_pack.kind, crate::objects::ObjectKind::Commit);
3966    }
3967
3968    #[cfg_attr(
3969        not(feature = "full-tests"),
3970        ignore = "starts system git or is heavy; run with --features full-tests"
3971    )]
3972    #[test]
3973    fn writer_evicts_tip_cache() {
3974        let Some((tmp, oid)) = midx_fixture(true) else {
3975            eprintln!("SKIP: git unavailable for MIDX fixture");
3976            return;
3977        };
3978        let objects = tmp.path().join(".git/objects");
3979        let pack_dir = objects.join("pack");
3980
3981        assert!(try_read_object_via_midx(&objects, &oid)
3982            .expect("prime caches")
3983            .is_some());
3984        assert!(resolve_tip_midx_path(&pack_dir).is_some());
3985
3986        clear_pack_midx_state(&pack_dir).expect("clear midx");
3987        assert!(resolve_tip_midx_path(&pack_dir).is_none());
3988
3989        // Tip-path cache was evicted: listing must not resurrect the removed MIDX.
3990        assert!(try_read_object_via_midx(&objects, &oid)
3991            .expect("read after clear")
3992            .is_none());
3993
3994        write_multi_pack_index(&pack_dir).expect("rewrite midx");
3995        assert!(
3996            try_read_object_via_midx(&objects, &oid)
3997                .expect("read after rewrite")
3998                .is_some(),
3999            "fresh MIDX must be visible after in-process rewrite"
4000        );
4001    }
4002
4003    #[cfg_attr(
4004        not(feature = "full-tests"),
4005        ignore = "starts system git or is heavy; run with --features full-tests"
4006    )]
4007    #[test]
4008    fn midx_hot_path_does_not_stat_after_cache_warm() {
4009        let Some((tmp, oid)) = midx_fixture(true) else {
4010            eprintln!("SKIP: git unavailable for MIDX fixture");
4011            return;
4012        };
4013        let git_dir = tmp.path().join(".git");
4014        let objects = git_dir.join("objects");
4015        let odb = Odb::new(&objects).with_config_git_dir(git_dir);
4016        let metrics = odb.hot_path_metrics_arc();
4017        let _scope = HotPathMetricsScope::install(Arc::clone(&metrics));
4018        metrics.set_stamp_counting(true);
4019        let _ = odb.read(&oid).expect("prime");
4020        metrics.reset_midx_stamp_stats();
4021        for _ in 0..200 {
4022            let _ = odb.read(&oid).expect("repeat");
4023        }
4024        metrics.set_stamp_counting(false);
4025        assert_eq!(metrics.midx_stamp_stat_calls(), 0);
4026    }
4027
4028    #[test]
4029    fn decode_high_bit_offset_is_raw_without_loff_chunk() {
4030        use super::midx_cache::decode_midx_object_offset_for_test;
4031        assert_eq!(
4032            decode_midx_object_offset_for_test(&[], 0x8000_0010, None),
4033            Some(0x8000_0010)
4034        );
4035    }
4036
4037    #[cfg_attr(
4038        not(feature = "full-tests"),
4039        ignore = "starts system git or is heavy; run with --features full-tests"
4040    )]
4041    #[test]
4042    fn lookup_high_bit_raw_offset_written_without_loff() {
4043        use super::{
4044            clear_pack_midx_state, find_chunk, midx_lookup_pack_and_offset_opt, parse_midx_header,
4045            MIDX_CHUNKID_OBJECTOFFSETS, MIDX_CHUNKID_OIDLOOKUP,
4046        };
4047        let Some((tmp, oid)) = midx_fixture(true) else {
4048            eprintln!("SKIP: git unavailable for MIDX fixture");
4049            return;
4050        };
4051        let objects = tmp.path().join(".git/objects");
4052        let pack_dir = objects.join("pack");
4053        let midx_path = pack_dir.join("multi-pack-index");
4054        let mut data = std::fs::read(&midx_path).expect("midx bytes");
4055        let (_, hdr_end, _) = parse_midx_header(&data).expect("header");
4056        let (oidl_off, oidl_len) =
4057            find_chunk(&data, hdr_end, MIDX_CHUNKID_OIDLOOKUP).expect("oidl");
4058        let (ooff_off, _) = find_chunk(&data, hdr_end, MIDX_CHUNKID_OBJECTOFFSETS).expect("ooff");
4059        let hash_len = oid.as_bytes().len();
4060        let num_objects = oidl_len / hash_len;
4061        let needle = oid.as_bytes();
4062        let mut patched = false;
4063        for i in 0..num_objects {
4064            let start = oidl_off + i * hash_len;
4065            if data[start..start + hash_len] == *needle {
4066                let ob = ooff_off + i * 8 + 4;
4067                data[ob..ob + 4].copy_from_slice(&0x8000_0010u32.to_be_bytes());
4068                patched = true;
4069                break;
4070            }
4071        }
4072        assert!(patched, "oid row in MIDX");
4073        let hash_len = if data[5] == 2 { 32 } else { 20 };
4074        data.truncate(data.len() - hash_len);
4075        crate::pack_rev::append_hashfile_checksum(&mut data, hash_len);
4076        clear_pack_midx_state(&pack_dir).expect("evict midx cache");
4077        std::fs::write(&midx_path, &data).expect("write midx");
4078        crate::pack::clear_pack_cache();
4079        let (_pack_id, off) = midx_lookup_pack_and_offset_opt(&objects, &oid)
4080            .expect("lookup")
4081            .expect("listed");
4082        assert_eq!(off, 0x8000_0010);
4083    }
4084
4085    #[cfg_attr(
4086        not(feature = "full-tests"),
4087        ignore = "starts system git or is heavy; run with --features full-tests"
4088    )]
4089    #[test]
4090    fn tiny_midx_stub_skipped_without_aborting_reads() {
4091        let Some((tmp, oid)) = midx_fixture(true) else {
4092            eprintln!("SKIP: git unavailable for MIDX fixture");
4093            return;
4094        };
4095        let objects = tmp.path().join(".git/objects");
4096        let pack_dir = objects.join("pack");
4097        std::fs::write(pack_dir.join("multi-pack-index"), [0u8; 8]).expect("tiny stub midx");
4098        super::midx_cache::evict_pack_dir(&pack_dir);
4099        clear_pack_cache();
4100
4101        assert!(super::prepared_midx_chain(&objects)
4102            .expect("tiny MIDX must not fail chain build")
4103            .is_none());
4104        assert!(try_read_object_via_midx(&objects, &oid)
4105            .expect("tiny MIDX must not fail lookup")
4106            .is_none());
4107        read_object_from_packs(&objects, &oid).expect("pack fallback still works");
4108    }
4109
4110    #[cfg_attr(
4111        not(feature = "full-tests"),
4112        ignore = "starts system git or is heavy; run with --features full-tests"
4113    )]
4114    #[test]
4115    fn flipped_midx_checksum_warns_and_reads_from_pack() {
4116        let Some((tmp, oid)) = midx_fixture(true) else {
4117            eprintln!("SKIP: git unavailable for MIDX fixture");
4118            return;
4119        };
4120        let objects = tmp.path().join(".git/objects");
4121        let pack_dir = objects.join("pack");
4122        let midx_path = pack_dir.join("multi-pack-index");
4123        let mut bytes = fs::read(&midx_path).expect("read midx");
4124        let last = bytes.len() - 1;
4125        bytes[last] ^= 0xff;
4126        fs::write(&midx_path, &bytes).expect("corrupt midx checksum");
4127        super::midx_cache::evict_pack_dir(&pack_dir);
4128        clear_pack_cache();
4129
4130        let errs = super::verify_midx(&objects).expect_err("verify catches bad checksum");
4131        assert!(
4132            errs.iter().any(|e| e.contains("incorrect checksum")),
4133            "expected verify checksum error, got {errs:?}"
4134        );
4135        let via_midx =
4136            try_read_object_via_midx(&objects, &oid).expect("checksum mismatch must not fatal");
4137        assert!(
4138            via_midx.is_none(),
4139            "invalid MIDX checksum must be skipped for direct lookup"
4140        );
4141
4142        let git_dir = tmp.path().join(".git");
4143        let odb = Odb::new(&objects).with_config_git_dir(git_dir);
4144        let grit_obj = odb.read(&oid).expect("pack fallback read");
4145        let via_pack = read_object_from_packs(&objects, &oid).expect("pack read");
4146        assert_eq!(via_pack.data, grit_obj.data);
4147    }
4148
4149    #[cfg_attr(
4150        not(feature = "full-tests"),
4151        ignore = "starts system git or is heavy; run with --features full-tests"
4152    )]
4153    #[test]
4154    fn midx_shorter_than_min_len_skips_without_fatal_error() {
4155        let Some((tmp, oid)) = midx_fixture(true) else {
4156            eprintln!("SKIP: git unavailable for MIDX fixture");
4157            return;
4158        };
4159        let objects = tmp.path().join(".git/objects");
4160        let pack_dir = objects.join("pack");
4161        let midx_path = pack_dir.join("multi-pack-index");
4162        let mut bytes = fs::read(&midx_path).expect("read");
4163        bytes.truncate(super::MIDX_HEADER_SIZE + 10);
4164        fs::write(&midx_path, &bytes).expect("truncate");
4165        super::midx_cache::evict_pack_dir(&pack_dir);
4166        clear_pack_cache();
4167        assert!(
4168            try_read_object_via_midx(&objects, &oid)
4169                .expect("read")
4170                .is_none(),
4171            "truncated MIDX must be skipped for direct lookup"
4172        );
4173        read_object_from_packs(&objects, &oid).expect("pack fallback still works");
4174    }
4175
4176    #[cfg_attr(
4177        not(feature = "full-tests"),
4178        ignore = "starts system git or is heavy; run with --features full-tests"
4179    )]
4180    #[test]
4181    fn large_git_midx_layer_uses_mapped_backing() {
4182        let Some((tmp, _oid)) = midx_fixture(false) else {
4183            eprintln!("SKIP: git unavailable for MIDX fixture");
4184            return;
4185        };
4186        for i in 0..120 {
4187            std::fs::write(tmp.path().join(format!("bulk{i}.txt")), format!("{i}\n")).unwrap();
4188            git(tmp.path(), &["add", &format!("bulk{i}.txt")]);
4189            git(tmp.path(), &["commit", "-q", "-m", &format!("bulk{i}")]);
4190        }
4191        git(tmp.path(), &["repack", "-adf"]);
4192        git(tmp.path(), &["multi-pack-index", "write"]);
4193        let midx_path = tmp.path().join(".git/objects/pack/multi-pack-index");
4194        let len = std::fs::metadata(&midx_path).expect("meta").len();
4195        assert!(len > 8192, "midx size {len}");
4196        let storage =
4197            crate::pack_map::PackData::open_with_min_len(&midx_path, super::MIDX_MIN_FILE_LEN)
4198                .expect("open");
4199        assert!(storage.is_mapped_backing());
4200    }
4201
4202    #[cfg_attr(
4203        not(feature = "full-tests"),
4204        ignore = "starts system git or is heavy; run with --features full-tests"
4205    )]
4206    #[test]
4207    fn wrong_midx_hash_version_returns_typed_error() {
4208        let Some((tmp, oid)) = midx_fixture(true) else {
4209            eprintln!("SKIP: git unavailable for MIDX fixture");
4210            return;
4211        };
4212        let objects = tmp.path().join(".git/objects");
4213        let midx_path = objects.join("pack/multi-pack-index");
4214        let mut bytes = fs::read(&midx_path).expect("read midx");
4215        bytes[5] = 2;
4216        fs::write(&midx_path, &bytes).expect("write wrong hash version");
4217        super::midx_cache::evict_pack_dir(&objects.join("pack"));
4218
4219        let err = try_read_object_via_midx(&objects, &oid).expect_err("hash mismatch");
4220        assert!(
4221            matches!(
4222                err,
4223                Error::Midx(MidxError::HashVersionMismatch {
4224                    found: 2,
4225                    expected: 1
4226                })
4227            ),
4228            "unexpected error: {err:?}"
4229        );
4230    }
4231
4232    #[test]
4233    fn midx_load_error_display_roundtrip() {
4234        use crate::midx_error::MidxError;
4235        let err = MidxError::VersionNotRecognized { version: 99 };
4236        assert!(err.to_string().contains("not recognized"));
4237    }
4238
4239    #[test]
4240    fn compact_error_from_library_error() {
4241        use crate::error::Error;
4242        let err: super::CompactError = Error::CorruptObject("test".into()).into();
4243        assert!(err.to_string().contains("test"));
4244    }
4245
4246    #[cfg_attr(
4247        not(feature = "full-tests"),
4248        ignore = "starts system git or is heavy; run with --features full-tests"
4249    )]
4250    #[test]
4251    fn verify_midx_reports_missing_pack_name_chunk() {
4252        let Some((tmp, _oid)) = midx_fixture(true) else {
4253            eprintln!("SKIP: git unavailable for MIDX fixture");
4254            return;
4255        };
4256        let objects = tmp.path().join(".git/objects");
4257        let pack_dir = objects.join("pack");
4258        let path = pack_dir.join("multi-pack-index");
4259        let mut data = std::fs::read(&path).expect("midx");
4260        data[6] = 0;
4261        data.truncate(super::MIDX_HEADER_SIZE + 24);
4262        std::fs::write(&path, &data).expect("write broken midx");
4263        super::evict_midx_read_cache_for_pack_dir(&pack_dir);
4264        let errs = super::verify_midx(&objects).expect_err("verify");
4265        assert!(
4266            errs.iter().any(|e| {
4267                e.contains("pack-name") || e.contains("pack name") || e.contains("chunk")
4268            }),
4269            "{errs:?}"
4270        );
4271    }
4272
4273    #[cfg_attr(
4274        not(feature = "full-tests"),
4275        ignore = "starts system git or is heavy; run with --features full-tests"
4276    )]
4277    #[test]
4278    fn find_chunk_reports_missing_id() {
4279        let Some((tmp, _oid)) = midx_fixture(true) else {
4280            eprintln!("SKIP: git unavailable for MIDX fixture");
4281            return;
4282        };
4283        let midx_path = tmp.path().join(".git/objects/pack/multi-pack-index");
4284        let data = std::fs::read(midx_path).expect("midx");
4285        let (_, hdr_end, _) = super::parse_midx_header(&data).expect("header");
4286        let err = super::find_chunk(&data, hdr_end, 0xdead_beef).expect_err("missing chunk");
4287        assert!(err.to_string().contains("not found"));
4288    }
4289
4290    #[cfg_attr(
4291        not(feature = "full-tests"),
4292        ignore = "starts system git or is heavy; run with --features full-tests"
4293    )]
4294    #[test]
4295    fn read_after_in_process_pack_swap() {
4296        let Some((tmp, oid)) = midx_fixture(false) else {
4297            eprintln!("SKIP: git unavailable for MIDX fixture");
4298            return;
4299        };
4300        let objects = tmp.path().join(".git/objects");
4301        let before = read_object_from_packs(&objects, &oid).expect("read from pack");
4302
4303        std::fs::write(tmp.path().join("f2.txt"), b"replacement pack").expect("write");
4304        git(tmp.path(), &["add", "f2.txt"]);
4305        git(tmp.path(), &["commit", "-q", "-m", "c2"]);
4306        git(tmp.path(), &["repack", "-adf"]);
4307        clear_pack_cache();
4308
4309        let after = read_object_from_packs(&objects, &oid).expect("historical commit still packed");
4310        assert_eq!(before.kind, after.kind);
4311        assert_eq!(before.data, after.data);
4312    }
4313
4314    #[cfg_attr(
4315        not(feature = "full-tests"),
4316        ignore = "starts system git or is heavy; run with --features full-tests"
4317    )]
4318    #[test]
4319    fn git_midx_mapped_bytes_match_owned_prepare() {
4320        use super::midx_cache::PreparedMidxLayer;
4321        use crate::diagnostics::NullDiagnostics;
4322        use crate::pack_map::PackData;
4323
4324        let Some((tmp, oid)) = midx_fixture(false) else {
4325            eprintln!("SKIP: git unavailable for MIDX fixture");
4326            return;
4327        };
4328        for i in 0..120 {
4329            std::fs::write(tmp.path().join(format!("bulk{i}.txt")), format!("{i}\n")).unwrap();
4330            git(tmp.path(), &["add", &format!("bulk{i}.txt")]);
4331            git(tmp.path(), &["commit", "-q", "-m", &format!("bulk{i}")]);
4332        }
4333        git(tmp.path(), &["repack", "-adf"]);
4334        git(tmp.path(), &["multi-pack-index", "write"]);
4335
4336        let objects = tmp.path().join(".git/objects");
4337        let pack_dir = objects.join("pack");
4338        let midx_path = pack_dir.join("multi-pack-index");
4339        let midx_len = std::fs::metadata(&midx_path).expect("meta").len();
4340        assert!(midx_len > 8192, "fixture midx size {midx_len}");
4341        let hash_version = super::repo_midx_hash_version_for_objects_dir(&objects);
4342        let mapped =
4343            PackData::open_with_min_len(&midx_path, super::MIDX_MIN_FILE_LEN).expect("mmap");
4344        assert!(
4345            mapped.is_mapped_backing(),
4346            "expected mmap backing for {midx_len}-byte MIDX"
4347        );
4348        let owned = PackData::from_owned(std::fs::read(&midx_path).expect("read"));
4349        assert!(
4350            !owned.is_mapped_backing(),
4351            "explicit owned buffer must not use mmap"
4352        );
4353        let view_mapped =
4354            match super::midx_load_for_read(mapped.as_ref(), hash_version, &NullDiagnostics)
4355                .expect("load mapped")
4356            {
4357                super::MidxLoadResult::Ok(v) => v,
4358                super::MidxLoadResult::Skip => panic!("mapped midx skipped"),
4359            };
4360        let view_owned =
4361            match super::midx_load_for_read(owned.as_ref(), hash_version, &NullDiagnostics)
4362                .expect("load owned")
4363            {
4364                super::MidxLoadResult::Ok(v) => v,
4365                super::MidxLoadResult::Skip => panic!("owned midx skipped"),
4366            };
4367        let layer_mapped = PreparedMidxLayer::prepare(mapped, view_mapped, &pack_dir);
4368        let layer_owned = PreparedMidxLayer::prepare(owned, view_owned, &pack_dir);
4369        let loc = layer_mapped
4370            .lookup_pack_and_offset(&oid)
4371            .expect("mapped lookup");
4372        assert_eq!(
4373            loc,
4374            layer_owned
4375                .lookup_pack_and_offset(&oid)
4376                .expect("owned lookup")
4377        );
4378        let mapped_obj = layer_mapped
4379            .read_at(loc.0, loc.1)
4380            .expect("read mapped")
4381            .expect("object mapped");
4382        let owned_obj = layer_owned
4383            .read_at(loc.0, loc.1)
4384            .expect("read owned")
4385            .expect("object owned");
4386        assert_eq!(mapped_obj.data, owned_obj.data);
4387    }
4388
4389    fn config_from(text: &str) -> crate::config::ConfigSet {
4390        let file = crate::config::ConfigFile::parse(
4391            Path::new("test"),
4392            text,
4393            crate::config::ConfigScope::Local,
4394        )
4395        .expect("parse config");
4396        let mut set = crate::config::ConfigSet::new();
4397        set.merge(&file);
4398        set
4399    }
4400
4401    #[test]
4402    fn write_version_defaults_and_validation() {
4403        assert_eq!(super::resolve_write_version(None, 1).expect("default"), 1);
4404        assert_eq!(super::resolve_write_version(None, 2).expect("default"), 2);
4405        assert_eq!(super::resolve_write_version(Some(1), 2).expect("v1"), 1);
4406        assert_eq!(super::resolve_write_version(Some(2), 1).expect("v2"), 2);
4407        for bad in [0u8, 3, 255] {
4408            assert!(matches!(
4409                super::resolve_write_version(Some(bad), 1),
4410                Err(Error::Midx(MidxError::UnknownWriteVersion { version })) if version == i64::from(bad)
4411            ));
4412        }
4413    }
4414
4415    #[test]
4416    fn midx_version_config_parsing() {
4417        use crate::error::ConfigError;
4418        let read = |text: &str| super::midx_version_from_config(&config_from(text));
4419        assert_eq!(read("[core]\n\tbare = false\n").expect("unset"), None);
4420        assert_eq!(read("[midx]\n\tversion = 1\n").expect("v1"), Some(1));
4421        assert_eq!(read("[midx]\n\tversion = 2\n").expect("v2"), Some(2));
4422        assert_eq!(
4423            read("[midx]\n\tversion = 2\n[midx]\n\tversion = 1\n").expect("last wins"),
4424            Some(1)
4425        );
4426        for (text, version) in [
4427            ("[midx]\n\tversion = 3\n", 3),
4428            ("[midx]\n\tversion = 0\n", 0),
4429            ("[midx]\n\tversion = -1\n", -1),
4430            ("[midx]\n\tversion = 1k\n", 1024),
4431        ] {
4432            assert!(
4433                matches!(
4434                    read(text),
4435                    Err(Error::Midx(MidxError::UnknownWriteVersion { version: v })) if v == version
4436                ),
4437                "{text:?}"
4438            );
4439        }
4440        assert!(matches!(
4441            read("[midx]\n\tversion = two\n"),
4442            Err(Error::Config(ConfigError::BadNumericValue { ref key, .. })) if key == "midx.version"
4443        ));
4444    }
4445
4446    #[cfg_attr(
4447        not(feature = "full-tests"),
4448        ignore = "starts system git or is heavy; run with --features full-tests"
4449    )]
4450    #[test]
4451    fn default_write_is_v1_and_unknown_version_is_rejected() {
4452        let Some((tmp, _oid)) = midx_fixture(false) else {
4453            eprintln!("SKIP: git unavailable for MIDX fixture");
4454            return;
4455        };
4456        let pack_dir = tmp.path().join(".git/objects/pack");
4457        let midx_path = pack_dir.join("multi-pack-index");
4458        write_multi_pack_index(&pack_dir).expect("default write");
4459        assert_eq!(fs::read(&midx_path).expect("read midx")[4], 1);
4460
4461        fs::remove_file(&midx_path).expect("remove midx");
4462        let err = super::write_multi_pack_index_with_options(
4463            &pack_dir,
4464            &super::WriteMultiPackIndexOptions {
4465                version: Some(3),
4466                ..Default::default()
4467            },
4468        )
4469        .expect_err("version 3");
4470        assert!(matches!(
4471            err,
4472            Error::Midx(MidxError::UnknownWriteVersion { version: 3 })
4473        ));
4474        assert!(!midx_path.exists(), "rejected write must not leave a MIDX");
4475    }
4476}