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