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/// OID rows from the active multi-pack-index, plus reverse-index order for pack-reuse bitmap bits.
2416///
2417/// Git assigns each object a **global bitmap bit** equal to its position in the MIDX reverse index
2418/// (`RIDX` chunk) traversal order — not its position in the pack `.idx` file. Helpers on this struct
2419/// map [`ObjectId`] → global bit the same way as `midx-write.c` (`midx_pack_order`).
2420#[derive(Debug, Clone)]
2421pub struct MidxReuseTables {
2422    /// OIDs in MIDX lexicographic order (same order as the OID lookup chunk).
2423    pub oids: Vec<ObjectId>,
2424    /// `(pack_int_id, in-pack offset)` parallel to `oids`.
2425    pub pack_and_offset: Vec<(u32, u64)>,
2426    /// `rid_order[rank]` is the OID-table index of the object at global bitmap rank `rank`.
2427    pub rid_order: Vec<u32>,
2428    /// Inverse map: global bitmap rank for each OID-table index.
2429    pub oid_idx_to_rank: Vec<u32>,
2430}
2431
2432/// Load OID / object-offset / reverse-index tables from the tip MIDX (root or chain tip).
2433///
2434/// Returns [`None`] when there is no MIDX or no `RIDX` chunk (no pseudo-bitmap ordering).
2435pub fn load_midx_reuse_tables(objects_dir: &Path) -> Result<Option<MidxReuseTables>> {
2436    let pack_dir = objects_dir.join("pack");
2437    let Some(path) = resolve_tip_midx_path(&pack_dir) else {
2438        return Ok(None);
2439    };
2440    load_midx_reuse_tables_from_path(&path)
2441}
2442
2443/// Load reuse tables from one on-disk MIDX layer (`multi-pack-index` or `multi-pack-index.d/…midx`).
2444pub fn load_midx_reuse_tables_from_path(midx_path: &Path) -> Result<Option<MidxReuseTables>> {
2445    let data = fs::read(midx_path).map_err(Error::Io)?;
2446    let hash_len = midx_hash_len(&data);
2447    let (_, hdr_end, _) = parse_midx_header(&data)?;
2448    let (oidl_off, oid_l_len) = find_chunk(&data, hdr_end, MIDX_CHUNKID_OIDLOOKUP)?;
2449    let (ooff_off, ooff_len) = find_chunk(&data, hdr_end, MIDX_CHUNKID_OBJECTOFFSETS)?;
2450    if oid_l_len % hash_len != 0 || ooff_len != oid_l_len / hash_len * 8 {
2451        return Err(Error::CorruptObject(
2452            "MIDX OID / offset chunk size mismatch".to_owned(),
2453        ));
2454    }
2455    let num_objects = oid_l_len / hash_len;
2456    if num_objects == 0 {
2457        return Ok(None);
2458    }
2459
2460    let rid_order =
2461        if let Ok((ridx_off, ridx_len)) = find_chunk(&data, hdr_end, MIDX_CHUNKID_REVINDEX) {
2462            if ridx_len != num_objects.saturating_mul(4) {
2463                return Err(Error::CorruptObject(
2464                    "MIDX reverse index length does not match object count".to_owned(),
2465                ));
2466            }
2467            let mut order = Vec::with_capacity(num_objects);
2468            for i in 0..num_objects {
2469                let base = ridx_off + i * 4;
2470                order.push(read_be_u32(&data, base)?);
2471            }
2472            order
2473        } else {
2474            let hash_hex = hex::encode(&data[data.len().saturating_sub(hash_len)..]);
2475            let rev_path = midx_path
2476                .parent()
2477                .map(|p| p.join(format!("multi-pack-index-{hash_hex}.rev")))
2478                .unwrap_or_else(|| PathBuf::from(format!("multi-pack-index-{hash_hex}.rev")));
2479            let rev_data = match fs::read(&rev_path) {
2480                Ok(d) => d,
2481                Err(_) => return Ok(None),
2482            };
2483            crate::pack_rev::try_rev_positions_in_pack_order(&rev_data, num_objects).ok_or_else(
2484                || {
2485                    Error::CorruptObject(
2486                        "MIDX standalone .rev sidecar is invalid or missing".to_owned(),
2487                    )
2488                },
2489            )?
2490        };
2491
2492    let mut oids = Vec::with_capacity(num_objects);
2493    for i in 0..num_objects {
2494        let base = oidl_off + i * hash_len;
2495        oids.push(ObjectId::from_bytes(&data[base..base + hash_len])?);
2496    }
2497
2498    let mut pack_and_offset = Vec::with_capacity(num_objects);
2499    for i in 0..num_objects {
2500        let ob = ooff_off + i * 8;
2501        let pack_id = read_be_u32(&data, ob)?;
2502        let off32 = read_be_u32(&data, ob + 4)?;
2503        pack_and_offset.push((pack_id, u64::from(off32)));
2504    }
2505
2506    let mut oid_idx_to_rank = vec![0u32; num_objects];
2507    for (rank, &oid_idx) in rid_order.iter().enumerate() {
2508        let idx = usize::try_from(oid_idx)
2509            .map_err(|_| Error::CorruptObject("bad MIDX reverse index entry".to_owned()))?;
2510        if idx >= num_objects {
2511            return Err(Error::CorruptObject(
2512                "MIDX reverse index out of range".to_owned(),
2513            ));
2514        }
2515        oid_idx_to_rank[idx] = u32::try_from(rank)
2516            .map_err(|_| Error::CorruptObject("too many MIDX objects".to_owned()))?;
2517    }
2518
2519    Ok(Some(MidxReuseTables {
2520        oids,
2521        pack_and_offset,
2522        rid_order,
2523        oid_idx_to_rank,
2524    }))
2525}
2526
2527/// Parse reuse tables from in-memory MIDX bytes and a known RIDX / `.rev` traversal order.
2528pub(crate) fn midx_reuse_tables_from_bytes(
2529    data: &[u8],
2530    rid_order: &[u32],
2531) -> Result<MidxReuseTables> {
2532    let hash_len = midx_hash_len(data);
2533    let (_, hdr_end, _) = parse_midx_header(data)?;
2534    let (oidl_off, oid_l_len) = find_chunk(data, hdr_end, MIDX_CHUNKID_OIDLOOKUP)?;
2535    let (ooff_off, ooff_len) = find_chunk(data, hdr_end, MIDX_CHUNKID_OBJECTOFFSETS)?;
2536    if oid_l_len % hash_len != 0 || ooff_len != oid_l_len / hash_len * 8 {
2537        return Err(Error::CorruptObject(
2538            "MIDX OID / offset chunk size mismatch".to_owned(),
2539        ));
2540    }
2541    let num_objects = oid_l_len / hash_len;
2542    if rid_order.len() != num_objects {
2543        return Err(Error::CorruptObject(
2544            "MIDX reverse index length does not match object count".to_owned(),
2545        ));
2546    }
2547
2548    let mut oids = Vec::with_capacity(num_objects);
2549    for i in 0..num_objects {
2550        let base = oidl_off + i * hash_len;
2551        oids.push(ObjectId::from_bytes(&data[base..base + hash_len])?);
2552    }
2553
2554    let mut pack_and_offset = Vec::with_capacity(num_objects);
2555    for i in 0..num_objects {
2556        let ob = ooff_off + i * 8;
2557        let pack_id = read_be_u32(data, ob)?;
2558        let off32 = read_be_u32(data, ob + 4)?;
2559        pack_and_offset.push((pack_id, u64::from(off32)));
2560    }
2561
2562    let mut oid_idx_to_rank = vec![0u32; num_objects];
2563    for (rank, &oid_idx) in rid_order.iter().enumerate() {
2564        let idx = usize::try_from(oid_idx)
2565            .map_err(|_| Error::CorruptObject("bad MIDX reverse index entry".to_owned()))?;
2566        if idx >= num_objects {
2567            return Err(Error::CorruptObject(
2568                "MIDX reverse index out of range".to_owned(),
2569            ));
2570        }
2571        oid_idx_to_rank[idx] = u32::try_from(rank)
2572            .map_err(|_| Error::CorruptObject("too many MIDX objects".to_owned()))?;
2573    }
2574
2575    Ok(MidxReuseTables {
2576        oids,
2577        pack_and_offset,
2578        rid_order: rid_order.to_vec(),
2579        oid_idx_to_rank,
2580    })
2581}
2582
2583impl MidxReuseTables {
2584    /// Global pseudo-bitmap index for `oid`, or [`None`] if the object is not in this MIDX.
2585    #[must_use]
2586    pub fn global_bitmap_bit(&self, oid: &ObjectId) -> Option<u32> {
2587        let oid_idx = self.oids.binary_search(oid).ok()?;
2588        Some(self.oid_idx_to_rank[oid_idx])
2589    }
2590
2591    /// MIDX-canonical pack id for `oid` (the single copy the MIDX selected after deduplication),
2592    /// or [`None`] if the object is not in this MIDX. Used to reject cross-pack delta reuse: a
2593    /// delta is only reusable verbatim when its base resolves to the *same* pack the delta lives
2594    /// in, mirroring Git's `midx_pair_to_pack_pos` check in `try_partial_reuse`.
2595    #[must_use]
2596    pub fn canonical_pack(&self, oid: &ObjectId) -> Option<u32> {
2597        let oid_idx = self.oids.binary_search(oid).ok()?;
2598        Some(self.pack_and_offset[oid_idx].0)
2599    }
2600}
2601
2602/// One pack's slice of the MIDX pseudo-bitmap namespace (`BTMP` chunk).
2603#[derive(Debug, Clone, Copy)]
2604pub struct MidxBtmpPackRange {
2605    /// Pack index in the MIDX pack-names list.
2606    pub pack_id: u32,
2607    /// First bit index assigned to this pack (cumulative object order).
2608    pub bitmap_pos: u32,
2609    /// Number of objects in this pack (same as `.idx` entry count).
2610    pub bitmap_nr: u32,
2611}
2612
2613/// Read per-pack `(bitmap_pos, bitmap_nr)` from the active MIDX `BTMP` chunk.
2614///
2615/// Returns an empty vector when the MIDX has no bitmapped-packs chunk.
2616pub fn read_midx_btmp_ranges(objects_dir: &Path) -> Result<Vec<MidxBtmpPackRange>> {
2617    let pack_dir = objects_dir.join("pack");
2618    let Some(path) = resolve_tip_midx_path(&pack_dir) else {
2619        return Ok(Vec::new());
2620    };
2621    let data = fs::read(&path).map_err(Error::Io)?;
2622    let (_, hdr_end, _) = parse_midx_header(&data)?;
2623    let Ok((btmp_off, btmp_len)) = find_chunk(&data, hdr_end, MIDX_CHUNKID_BITMAPPED_PACKS) else {
2624        return Ok(Vec::new());
2625    };
2626    if btmp_len == 0 || btmp_len % 8 != 0 {
2627        return Err(Error::CorruptObject(
2628            "invalid MIDX BTMP chunk length".to_owned(),
2629        ));
2630    }
2631    let num_packs = read_be_u32(&data, 8)?;
2632    let n_entries = btmp_len / 8;
2633    if u32::try_from(n_entries).ok() != Some(num_packs) {
2634        return Err(Error::CorruptObject(
2635            "MIDX BTMP entry count does not match num_packs".to_owned(),
2636        ));
2637    }
2638    let mut out = Vec::with_capacity(n_entries);
2639    for i in 0..n_entries {
2640        let base = btmp_off + i * 8;
2641        let bitmap_pos = read_be_u32(&data, base)?;
2642        let bitmap_nr = read_be_u32(&data, base + 4)?;
2643        out.push(MidxBtmpPackRange {
2644            pack_id: u32::try_from(i)
2645                .map_err(|_| Error::CorruptObject("too many packs in MIDX BTMP".to_owned()))?,
2646            bitmap_pos,
2647            bitmap_nr,
2648        });
2649    }
2650    Ok(out)
2651}
2652
2653/// Look up which pack and in-pack offset holds `oid` when listed in the active MIDX.
2654///
2655/// Returns [`None`] when no MIDX exists or `oid` is absent (no allocation on miss).
2656pub fn midx_lookup_pack_and_offset_opt(
2657    objects_dir: &Path,
2658    oid: &ObjectId,
2659) -> Result<Option<(u32, u64)>> {
2660    let Some(chain) = prepared_midx_chain(objects_dir)? else {
2661        return Ok(None);
2662    };
2663    Ok(chain.lookup_pack_and_offset(oid))
2664}
2665
2666/// Look up which pack and in-pack offset holds `oid` according to the active MIDX.
2667pub fn midx_lookup_pack_and_offset(objects_dir: &Path, oid: &ObjectId) -> Result<(u32, u64)> {
2668    let Some(chain) = prepared_midx_chain(objects_dir)? else {
2669        return Err(Error::CorruptObject("no multi-pack-index found".to_owned()));
2670    };
2671    chain.lookup_pack_and_offset(oid).ok_or_else(|| {
2672        Error::CorruptObject(format!("object {} not in multi-pack-index", oid.to_hex()))
2673    })
2674}
2675
2676/// Returns whether `oid` appears in the active MIDX OID table for `objects_dir`.
2677///
2678/// [`None`] means there is no MIDX at the pack tip. [`Some`] is the lookup result when a MIDX exists.
2679pub fn midx_oid_listed_in_tip(objects_dir: &Path, oid: &ObjectId) -> Result<Option<bool>> {
2680    let Some(chain) = prepared_midx_chain(objects_dir)? else {
2681        return Ok(None);
2682    };
2683    Ok(Some(chain.oid_listed_in_tip(oid)))
2684}
2685
2686/// Chunk offsets and metadata of a successfully loaded MIDX, ready for object reads.
2687struct MidxReadView {
2688    oidf_off: usize,
2689    oidl_off: usize,
2690    ooff_off: usize,
2691    loff: Option<(usize, usize)>,
2692    num_objects: usize,
2693    pack_names: Vec<String>,
2694}
2695
2696enum MidxLoadResult {
2697    Ok(MidxReadView),
2698    /// The MIDX is unusable but not fatal (Git returns NULL and falls back to packs).
2699    Skip,
2700}
2701
2702/// Validate and load a MIDX image for object reads, mirroring `load_multi_pack_index`
2703/// in git/midx.c. Fatal corruptions return [`Error::Midx`]; recoverable corruptions emit
2704/// [`Warning`] values through `diagnostics` and return [`MidxLoadResult::Skip`].
2705fn midx_load_for_read(
2706    data: &PackData,
2707    expected_hash_version: u8,
2708    diagnostics: &dyn DiagnosticSink,
2709) -> Result<MidxLoadResult> {
2710    if data.len() < MIDX_HEADER_SIZE + 20 {
2711        diagnostics.warn(Warning::MidxChunkTableError {
2712            detail: "multi-pack-index file is too small".to_owned(),
2713        });
2714        return Ok(MidxLoadResult::Skip);
2715    }
2716    let sig = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
2717    if sig != MIDX_SIGNATURE {
2718        return Err(MidxError::SignatureMismatch {
2719            found: sig,
2720            expected: MIDX_SIGNATURE,
2721        }
2722        .into());
2723    }
2724    let version = data[4];
2725    if version != MIDX_VERSION_V1 && version != MIDX_VERSION_V2 {
2726        return Err(MidxError::VersionNotRecognized { version }.into());
2727    }
2728    let hash_version = data[5];
2729    if hash_version != expected_hash_version {
2730        return Err(MidxError::HashVersionMismatch {
2731            found: hash_version,
2732            expected: expected_hash_version,
2733        }
2734        .into());
2735    }
2736    let hash_len = if hash_version == 2 { 32usize } else { 20usize };
2737    let num_packs = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
2738
2739    let mut toc_errors: Vec<String> = Vec::new();
2740    let chunks = match parse_midx_toc(data, hash_len, &mut toc_errors) {
2741        Ok(c) => c,
2742        Err(fatal) => {
2743            if !toc_errors.is_empty() {
2744                for e in toc_errors {
2745                    diagnostics.warn(Warning::MidxChunkTableError { detail: e });
2746                }
2747                return Ok(MidxLoadResult::Skip);
2748            }
2749            return Err(MidxError::from(fatal).into());
2750        }
2751    };
2752
2753    let Some((pn_off, pn_len)) = toc_chunk_range(&chunks, data.len(), MIDX_CHUNKID_PACKNAMES)
2754    else {
2755        return Err(MidxError::RequiredPackNameChunkMissing.into());
2756    };
2757
2758    let Some((oidf_off, oidf_len)) = toc_chunk_range(&chunks, data.len(), MIDX_CHUNKID_OIDFANOUT)
2759    else {
2760        return Err(MidxError::RequiredOidFanoutChunkMissing.into());
2761    };
2762    if oidf_len != 256 * 4 {
2763        return Err(MidxError::OidFanoutWrongSize.into());
2764    }
2765    let fanout = |i: usize| -> u32 {
2766        let b = oidf_off + i * 4;
2767        u32::from_be_bytes([data[b], data[b + 1], data[b + 2], data[b + 3]])
2768    };
2769    let num_objects = fanout(255) as usize;
2770
2771    let Some((oidl_off, oidl_len)) = toc_chunk_range(&chunks, data.len(), MIDX_CHUNKID_OIDLOOKUP)
2772    else {
2773        return Err(MidxError::RequiredOidLookupChunkMissing.into());
2774    };
2775    if oidl_len != hash_len * num_objects {
2776        return Err(MidxError::OidLookupWrongSize.into());
2777    }
2778
2779    let Some((ooff_off, ooff_len)) =
2780        toc_chunk_range(&chunks, data.len(), MIDX_CHUNKID_OBJECTOFFSETS)
2781    else {
2782        return Err(MidxError::RequiredObjectOffsetsChunkMissing.into());
2783    };
2784    if ooff_len != num_objects * 8 {
2785        return Err(MidxError::ObjectOffsetsWrongSize.into());
2786    }
2787
2788    let loff = toc_chunk_range(&chunks, data.len(), MIDX_CHUNKID_LARGEOFFSETS);
2789
2790    if let Some((_, rlen)) = toc_chunk_range(&chunks, data.len(), MIDX_CHUNKID_REVINDEX) {
2791        if rlen != num_objects * 4 {
2792            diagnostics.warn(Warning::MidxRevIndexWrongSize);
2793            diagnostics.warn(Warning::MidxBitmapMissingReverseIndex);
2794        }
2795    }
2796
2797    let mut pack_names: Vec<String> = Vec::with_capacity(num_packs);
2798    let blob = &data[pn_off..pn_off + pn_len];
2799    let mut start = 0usize;
2800    for _ in 0..num_packs {
2801        let Some(rel) = blob[start..].iter().position(|&b| b == 0) else {
2802            return Err(MidxError::PackNameChunkTooShort.into());
2803        };
2804        let name = match std::str::from_utf8(&blob[start..start + rel]) {
2805            Ok(s) => s.to_string(),
2806            Err(_) => return Err(MidxError::PackNameChunkTooShort.into()),
2807        };
2808        pack_names.push(name);
2809        start += rel + 1;
2810    }
2811
2812    let payload_end = data.len() - hash_len;
2813    let algo = if hash_version == HASH_VERSION_SHA256 {
2814        HashAlgo::Sha256
2815    } else {
2816        HashAlgo::Sha1
2817    };
2818    let budget = crate::pack_map::ResidentBudget::new(data, 2 * 1024 * 1024);
2819    let valid = budget.checksum(algo, &data[..payload_end]) == data[payload_end..];
2820    // Validation touches every page once. Keep only pages later lookups actually need.
2821    data.release_resident();
2822    if !valid {
2823        diagnostics.warn(Warning::MidxChecksumMismatch);
2824        return Ok(MidxLoadResult::Skip);
2825    }
2826
2827    Ok(MidxLoadResult::Ok(MidxReadView {
2828        oidf_off,
2829        oidl_off,
2830        ooff_off,
2831        loff,
2832        num_objects,
2833        pack_names,
2834    }))
2835}
2836
2837/// Validate existing pack indexes named by the active MIDX once per prepared chain view.
2838///
2839/// This advisory check also runs when an ODB read ultimately finds a loose object.
2840/// Strict and permissive views retain independent diagnostic state while sharing
2841/// validated MIDX backing within the active [`crate::pack_store::PackStore`].
2842/// Refresh or invalidation permits a new check. This variant discards warnings;
2843/// use [`validate_midx_referenced_packs_with_diagnostics`] to collect them.
2844pub fn validate_midx_referenced_packs(objects_dir: &Path) {
2845    validate_midx_referenced_packs_with_diagnostics(objects_dir, &NullDiagnostics);
2846}
2847
2848/// Like [`validate_midx_referenced_packs`], emitting warnings through `diagnostics`.
2849pub fn validate_midx_referenced_packs_with_diagnostics(
2850    objects_dir: &Path,
2851    diagnostics: &dyn DiagnosticSink,
2852) {
2853    if let Ok(Some(chain)) = midx_cache::prepared_chain(objects_dir, diagnostics) {
2854        chain.ensure_packs_validated(diagnostics);
2855    }
2856}
2857
2858/// When `core.multiPackIndex` is enabled, try to read `oid` from the active MIDX in `objects_dir`.
2859///
2860/// Returns [`None`] when no MIDX exists or `oid` is not listed. Returns `Some(Err(..))` when the
2861/// MIDX is present but malformed.
2862pub fn try_read_object_via_midx(
2863    objects_dir: &Path,
2864    oid: &ObjectId,
2865) -> Result<Option<crate::objects::Object>> {
2866    try_read_object_via_midx_with_diagnostics(objects_dir, oid, &NullDiagnostics)
2867}
2868
2869/// Like [`try_read_object_via_midx`], emitting recoverable MIDX warnings through `diagnostics`.
2870pub fn try_read_object_via_midx_with_diagnostics(
2871    objects_dir: &Path,
2872    oid: &ObjectId,
2873    diagnostics: &dyn DiagnosticSink,
2874) -> Result<Option<crate::objects::Object>> {
2875    let Some(chain) = midx_cache::prepared_chain(objects_dir, diagnostics)? else {
2876        return Ok(None);
2877    };
2878    chain.try_read_object(oid, diagnostics)
2879}
2880
2881/// Like [`try_read_object_via_midx`], but returns [`ObjectInfo`] without inflating object bodies.
2882pub fn try_read_info_via_midx(objects_dir: &Path, oid: &ObjectId) -> Result<Option<ObjectInfo>> {
2883    try_read_info_via_midx_with_diagnostics(objects_dir, oid, &NullDiagnostics)
2884}
2885
2886/// Like [`try_read_info_via_midx`], emitting recoverable MIDX warnings through `diagnostics`.
2887pub fn try_read_info_via_midx_with_diagnostics(
2888    objects_dir: &Path,
2889    oid: &ObjectId,
2890    diagnostics: &dyn DiagnosticSink,
2891) -> Result<Option<ObjectInfo>> {
2892    let Some(chain) = midx_cache::prepared_chain(objects_dir, diagnostics)? else {
2893        return Ok(None);
2894    };
2895    chain.try_read_info(oid, diagnostics)
2896}
2897
2898/// `read_midx_preferred_idx_name` function.
2899pub fn read_midx_preferred_idx_name(objects_dir: &Path) -> Result<String> {
2900    let pack_dir = objects_dir.join("pack");
2901    let path = resolve_tip_midx_path(&pack_dir)
2902        .ok_or_else(|| Error::CorruptObject("no multi-pack-index found".to_owned()))?;
2903    let data = fs::read(&path).map_err(Error::Io)?;
2904    let (_, hdr_end, _) = parse_midx_header(&data)?;
2905    let (pn_off, pn_len) = find_chunk(&data, hdr_end, MIDX_CHUNKID_PACKNAMES)?;
2906    let names = parse_pack_names_blob(&data[pn_off..pn_off + pn_len])?;
2907    let (ooff_off, ooff_len) = find_chunk(&data, hdr_end, MIDX_CHUNKID_OBJECTOFFSETS)?;
2908    // The preferred pack is recorded in the MIDX reverse index, which is only
2909    // present when the MIDX has a bitmap. Without it, the preferred pack is
2910    // unknowable (git/midx.c `midx_preferred_pack` returns -1). Prefer the
2911    // embedded RIDX chunk; otherwise fall back to a `multi-pack-index*.rev`
2912    // sidecar, matching `load_midx_revindex`.
2913    let (ridx_off, ridx_len) = match find_chunk(&data, hdr_end, MIDX_CHUNKID_REVINDEX) {
2914        Ok(r) => r,
2915        Err(_) => {
2916            return Err(Error::CorruptObject(
2917                "could not determine MIDX preferred pack".to_owned(),
2918            ));
2919        }
2920    };
2921
2922    if ridx_len < 4 || ooff_len < 8 {
2923        return Err(Error::CorruptObject("truncated MIDX RIDX/OOFF".to_owned()));
2924    }
2925    let first_oid_idx = read_be_u32(&data, ridx_off)? as usize;
2926    let entry_base = ooff_off + first_oid_idx * 8;
2927    if entry_base + 8 > data.len() || entry_base + 8 > ooff_off + ooff_len {
2928        return Err(Error::CorruptObject(
2929            "bad MIDX object-offsets index".to_owned(),
2930        ));
2931    }
2932    let pack_id = read_be_u32(&data, entry_base)?;
2933    let idx = usize::try_from(pack_id)
2934        .map_err(|_| Error::CorruptObject("pack id overflow in multi-pack-index".to_owned()))?;
2935    names
2936        .get(idx)
2937        .cloned()
2938        .ok_or_else(|| Error::CorruptObject("preferred pack id out of range".to_owned()))
2939}
2940
2941/// Build `objects/pack/multi-pack-index` for all pack indexes in `pack_dir`.
2942///
2943/// Returns an error if there are no `.idx` files, if an object offset does not
2944/// fit in 31 bits (no `LOFF` chunk yet), or if I/O fails.
2945/// Remove every multi-pack-index file under `pack_dir` (root file, sidecars, and
2946/// `multi-pack-index.d/`). Used by full `repack -a` so stale incremental chains do not survive.
2947pub fn clear_pack_midx_state(pack_dir: &Path) -> Result<()> {
2948    let _ = fs::remove_file(pack_dir.join("multi-pack-index"));
2949    scrub_root_midx_sidecars_except(pack_dir, None)?;
2950    let midx_d = midx_d_dir(pack_dir);
2951    if midx_d.exists() {
2952        let _ = fs::remove_dir_all(&midx_d);
2953    }
2954    midx_cache::evict_pack_dir(pack_dir);
2955    Ok(())
2956}
2957
2958/// `write_multi_pack_index` function.
2959pub fn write_multi_pack_index(pack_dir: &Path) -> Result<WriteMultiPackIndexResult> {
2960    write_multi_pack_index_with_options(pack_dir, &WriteMultiPackIndexOptions::default())
2961}
2962
2963fn repository_for_pack_dir(pack_dir: &Path) -> Result<Repository> {
2964    let objects = pack_dir.parent().ok_or_else(|| {
2965        Error::Io(std::io::Error::new(
2966            std::io::ErrorKind::NotFound,
2967            "pack directory has no parent",
2968        ))
2969    })?;
2970    let git_dir = objects.parent().ok_or_else(|| {
2971        Error::Io(std::io::Error::new(
2972            std::io::ErrorKind::NotFound,
2973            "objects directory has no parent",
2974        ))
2975    })?;
2976    Repository::open(git_dir, &RepositoryOptions::empty())
2977}
2978
2979fn midx_bitmap_sidecar_matches(path: &Path, midx_checksum: &[u8]) -> bool {
2980    let Ok(data) = fs::read(path) else {
2981        return false;
2982    };
2983    let header_end = 4 + 2 + 2 + 4;
2984    if data.len() < header_end + midx_checksum.len() {
2985        return false;
2986    }
2987    if &data[0..4] != b"BITM" {
2988        return false;
2989    }
2990    data[header_end..header_end + midx_checksum.len()] == *midx_checksum
2991}
2992
2993fn write_midx_reachability_bitmap(
2994    pack_dir: &Path,
2995    midx_bytes: &[u8],
2996    rid_order: &[u32],
2997    midx_checksum: &[u8],
2998    bitmap_path: &Path,
2999) -> Result<()> {
3000    write_midx_reachability_bitmap_with_options(
3001        pack_dir,
3002        midx_bytes,
3003        rid_order,
3004        midx_checksum,
3005        bitmap_path,
3006        &PackBitmapWriteOptions::default(),
3007    )
3008}
3009
3010fn write_midx_reachability_bitmap_with_options(
3011    pack_dir: &Path,
3012    midx_bytes: &[u8],
3013    rid_order: &[u32],
3014    midx_checksum: &[u8],
3015    bitmap_path: &Path,
3016    options: &PackBitmapWriteOptions,
3017) -> Result<()> {
3018    let repo = repository_for_pack_dir(pack_dir)?;
3019    MidxBitmapWriter::write_for_midx_layer(
3020        &repo,
3021        midx_bytes,
3022        rid_order,
3023        midx_checksum,
3024        bitmap_path,
3025        options,
3026    )
3027    .map_err(|e| MidxError::BitmapWriteFailed {
3028        detail: e.to_string(),
3029    })?;
3030    Ok(())
3031}
3032
3033/// Write `multi-pack-index` with optional preferred pack, bitmap sidecars, and incremental chain.
3034pub fn write_multi_pack_index_with_options(
3035    pack_dir: &Path,
3036    opts: &WriteMultiPackIndexOptions,
3037) -> Result<WriteMultiPackIndexResult> {
3038    let version = midx_write_version(opts.version)?;
3039
3040    // Git warns and ignores an existing MIDX whose checksum does not validate when
3041    // writing a fresh (non-stdin-packs) MIDX (git/midx-write.c `write_midx_internal`).
3042    if opts.pack_names_subset_ordered.is_none() {
3043        if let Some(existing) = resolve_tip_midx_path(pack_dir) {
3044            if let Ok(bytes) = fs::read(&existing) {
3045                if midx_checksum_is_valid(&bytes) {
3046                    // A fresh write copies the existing MIDX's packs. Loading a pack
3047                    // it references whose `.pack` is gone fails with `could not load
3048                    // pack N` (git/midx-write.c `fill_pack_from_midx`).
3049                    if let Ok((_, existing_names)) = oids_and_packs_from_midx_data(&bytes) {
3050                        for (i, name) in existing_names.iter().enumerate() {
3051                            let stem = name.strip_suffix(".idx").unwrap_or(name);
3052                            if !pack_dir.join(format!("{stem}.pack")).exists() {
3053                                return Err(
3054                                    MidxError::ReferencedPackMissing { pack_index: i }.into()
3055                                );
3056                            }
3057                        }
3058                    }
3059                } else {
3060                    midx_write_warn(opts, Warning::MidxIgnoringExistingChecksumMismatch);
3061                }
3062            }
3063        }
3064    }
3065
3066    // Git's MIDX covers every pack index in the directory regardless of its
3067    // basename (the `.git/objects/pack/test-*.idx` packs created by t7900's
3068    // incremental-repack test, for instance), so include any `*.idx` whose
3069    // companion `.pack` exists.
3070    let mut idx_names: Vec<String> = fs::read_dir(pack_dir)
3071        .map(|rd| {
3072            rd.filter_map(|e| e.ok())
3073                .filter_map(|e| {
3074                    let name = e.file_name().to_string_lossy().to_string();
3075                    let stem = name.strip_suffix(".idx")?;
3076                    if pack_dir.join(format!("{stem}.pack")).exists() {
3077                        Some(name)
3078                    } else {
3079                        None
3080                    }
3081                })
3082                .collect()
3083        })
3084        .unwrap_or_default();
3085    idx_names.sort();
3086
3087    let idx_names: Vec<String> = if let Some(sub) = &opts.pack_names_subset_ordered {
3088        let mut out = Vec::new();
3089        for line in sub {
3090            let want = normalize_pack_idx_basename(line)?;
3091            if let Some(found) = idx_names.iter().find(|n| **n == want).cloned() {
3092                if !out.contains(&found) {
3093                    out.push(found);
3094                }
3095            }
3096            // Unknown names on stdin are silently ignored (Git skips packs it
3097            // cannot find rather than failing the whole write).
3098        }
3099        // Readers reject a v1 MIDX whose pack names are not strictly increasing.
3100        if version == MIDX_VERSION_V1 {
3101            out.sort();
3102        }
3103        out
3104    } else {
3105        idx_names
3106    };
3107
3108    // Resolve / validate the preferred pack against the working pack set. Git emits a
3109    // (non-fatal) `warning: unknown preferred pack: '<name>'` when it cannot be matched.
3110    let mut preferred_warned = false;
3111    if let Some(raw) = opts.preferred_pack_name.as_deref() {
3112        if opts.preferred_pack_idx.is_none()
3113            && !idx_names
3114                .iter()
3115                .any(|n| cmp_idx_or_pack_name(raw, n).is_eq())
3116        {
3117            midx_write_warn(
3118                opts,
3119                Warning::MidxUnknownPreferredPack {
3120                    name: raw.to_owned(),
3121                },
3122            );
3123            preferred_warned = true;
3124        }
3125    }
3126
3127    if idx_names.is_empty() {
3128        return Err(MidxError::NoPacksToIndex.into());
3129    }
3130
3131    let (base_oids, base_pack_names) = if opts.incremental {
3132        collect_incremental_base(pack_dir)?
3133    } else {
3134        (HashSet::new(), HashSet::new())
3135    };
3136
3137    let layer_idx_names: Vec<String> = if opts.incremental {
3138        idx_names
3139            .iter()
3140            .filter(|n| {
3141                !base_pack_names
3142                    .iter()
3143                    .any(|bp| pack_names_match_layer(bp, n))
3144            })
3145            .cloned()
3146            .collect()
3147    } else {
3148        idx_names.clone()
3149    };
3150
3151    if opts.incremental && layer_idx_names.is_empty() {
3152        return Ok(WriteMultiPackIndexResult::default());
3153    }
3154
3155    let work_names = if opts.incremental {
3156        &layer_idx_names[..]
3157    } else {
3158        &idx_names[..]
3159    };
3160
3161    let mut preferred_idx = opts.preferred_pack_idx.map(|p| p as usize);
3162    if preferred_idx.is_none() && !preferred_warned {
3163        if let Some(raw) = opts.preferred_pack_name.as_deref() {
3164            // Already validated against `idx_names`; resolve against the working set.
3165            preferred_idx = work_names
3166                .iter()
3167                .position(|n| cmp_idx_or_pack_name(raw, n).is_eq());
3168        }
3169    }
3170    if preferred_idx.is_none() && opts.write_bitmap && !work_names.is_empty() {
3171        preferred_idx = preferred_pack_index_by_mtime(pack_dir, work_names)?;
3172    }
3173    if let Some(p) = preferred_idx {
3174        if p >= work_names.len() {
3175            return Err(Error::CorruptObject(
3176                "preferred pack index out of range".to_owned(),
3177            ));
3178        }
3179    }
3180
3181    let mut indexes: Vec<PackIndex> = Vec::with_capacity(work_names.len());
3182    for name in work_names {
3183        let path = pack_dir.join(name);
3184        // Do not re-verify the idx trailer here; Git reads the offset table
3185        // directly (t5319 forces a deliberately corrupt-but-valid 64-bit idx).
3186        indexes.push(crate::pack::read_pack_index_no_verify(&path)?);
3187    }
3188
3189    // Git refuses an explicitly preferred pack that has no objects.
3190    if let Some(p) = preferred_idx {
3191        if indexes.get(p).map(PackIndex::len).unwrap_or(0) == 0 {
3192            let name = work_names.get(p).cloned().unwrap_or_default();
3193            let pack_name = name.strip_suffix(".idx").unwrap_or(&name);
3194            return Err(MidxError::PreferredPackEmpty {
3195                pack: format!("{pack_name}.pack"),
3196            }
3197            .into());
3198        }
3199    }
3200
3201    let pack_mtimes_layer: Vec<std::time::SystemTime> =
3202        indexes.iter().map(pack_mtime_for_midx).collect();
3203    let preferred_u32 = preferred_idx.map(|p| p as u32);
3204    let select_hash_len = if repo_midx_hash_version(pack_dir) == 2 {
3205        32
3206    } else {
3207        20
3208    };
3209
3210    let mut best: HashMap<ObjectId, MidxEntry> = HashMap::new();
3211    for (pack_id, idx) in indexes.iter().enumerate() {
3212        let pack_id = u32::try_from(pack_id).map_err(|_| {
3213            Error::CorruptObject("too many pack files for multi-pack-index".to_owned())
3214        })?;
3215        let mtime = pack_mtimes_layer[pack_id as usize];
3216        for e in idx.iter() {
3217            if e.oid().len() != select_hash_len {
3218                continue;
3219            }
3220            let Ok(oid) = ObjectId::from_bytes(e.oid()) else {
3221                continue;
3222            };
3223            if opts.incremental && base_oids.contains(&oid) {
3224                continue;
3225            }
3226            let offset = e.offset()?;
3227            let cand = MidxEntry {
3228                oid,
3229                pack_id,
3230                offset,
3231                pack_mtime: mtime,
3232            };
3233            match best.get(&oid) {
3234                None => {
3235                    best.insert(oid, cand);
3236                }
3237                Some(cur) => {
3238                    if midx_pick_better_entry(cur, pack_id, offset, mtime, preferred_u32) {
3239                        best.insert(oid, cand);
3240                    }
3241                }
3242            }
3243        }
3244    }
3245
3246    let write_bitmap_chunks = opts.write_bitmap && (!opts.incremental || !best.is_empty());
3247
3248    let omit_embedded_ridx = opts.write_rev_sidecar;
3249    // An incremental layer must not repeat objects already provided by the base
3250    // chain even when the layer's own pack physically contains them (a fresh pack
3251    // built with `--revs` from a tag range, for instance). Filter by base OID.
3252    let exclude = if opts.incremental && !base_oids.is_empty() {
3253        Some(&base_oids)
3254    } else {
3255        None
3256    };
3257    let (out, bitmap_rid_order) = build_midx_bytes_filtered(
3258        work_names,
3259        &indexes,
3260        preferred_idx,
3261        write_bitmap_chunks,
3262        omit_embedded_ridx,
3263        version,
3264        repo_midx_hash_version(pack_dir),
3265        exclude,
3266    )?;
3267
3268    let repo_algo = repo_hash_algo_for_pack_dir(pack_dir);
3269    let hash_len = repo_algo.len();
3270    let hash = &out[out.len() - hash_len..];
3271    let hash_hex = hex::encode(hash);
3272    let hash_arr: Vec<u8> = hash.to_vec();
3273
3274    let bitmap_outcome = if opts.write_bitmap {
3275        if opts.incremental {
3276            MidxBitmapWriteOutcome::SkippedIncrementalLayer
3277        } else {
3278            MidxBitmapWriteOutcome::Written
3279        }
3280    } else {
3281        MidxBitmapWriteOutcome::NotRequested
3282    };
3283
3284    if opts.incremental {
3285        let root_midx = pack_dir.join("multi-pack-index");
3286        let chain_path = chain_file_path(pack_dir);
3287        let chain_existed = chain_path.exists();
3288
3289        let mut chain = if root_midx.exists() && !chain_existed {
3290            let root_hex = midx_checksum_hex_from_path(&root_midx)?;
3291            link_root_midx_into_chain(pack_dir, &root_hex)?;
3292            vec![root_hex]
3293        } else {
3294            read_chain_layer_hashes(pack_dir).unwrap_or_default()
3295        };
3296
3297        chain.push(hash_hex.clone());
3298
3299        let midx_d = midx_d_dir(pack_dir);
3300        fs::create_dir_all(&midx_d).map_err(Error::Io)?;
3301
3302        let layer_path = midx_d.join(format!("multi-pack-index-{hash_hex}.midx"));
3303        fs::write(&layer_path, &out).map_err(Error::Io)?;
3304
3305        let mut chain_data = String::new();
3306        for h in &chain {
3307            chain_data.push_str(h);
3308            chain_data.push('\n');
3309        }
3310        fs::write(chain_file_path(pack_dir), chain_data.as_bytes()).map_err(Error::Io)?;
3311
3312        clear_stale_split_layers(pack_dir, &chain)?;
3313
3314        let _ = fs::remove_file(pack_dir.join("multi-pack-index"));
3315        scrub_root_midx_sidecars(pack_dir)?;
3316        if write_bitmap_chunks && opts.write_rev_sidecar {
3317            let full = hex::encode(hash);
3318            let rev_path = midx_d.join(format!("multi-pack-index-{full}.rev"));
3319            if let Some(order) = bitmap_rid_order.as_ref() {
3320                write_midx_rev_sidecar(&rev_path, order, &hash_arr, repo_algo)?;
3321            }
3322        }
3323    } else {
3324        // A non-incremental write replaces any prior split layout. Git removes the
3325        // individual incremental layer files inside `multi-pack-index.d/` and
3326        // unlinks the chain file, but never `rmdir`s the directory itself, so an
3327        // empty `multi-pack-index.d/` is left behind (t5334 expects
3328        // `test_dir_is_empty $midxdir` after the conversion).
3329        let dest = pack_dir.join("multi-pack-index");
3330
3331        // Git's `midx_needs_update`: if the new MIDX is byte-identical to the one
3332        // already on disk and we are not (re)writing a bitmap, leave the file
3333        // untouched so its mtime is preserved (t5319 `test_midx_is_retained`).
3334        let bitmap_path = pack_dir.join(format!("multi-pack-index-{hash_hex}.bitmap"));
3335        let bitmap_ok = !opts.write_bitmap || midx_bitmap_sidecar_matches(&bitmap_path, hash);
3336        // Only short-circuit when there is no active incremental chain to collapse;
3337        // an empty leftover `multi-pack-index.d/` (from a prior conversion) must not
3338        // defeat the retention optimization, so key off the chain file, not the dir.
3339        if bitmap_ok && !chain_file_path(pack_dir).exists() {
3340            if let Ok(existing) = fs::read(&dest) {
3341                if existing == out {
3342                    if opts.write_rev_sidecar {
3343                        if let Some(order) = bitmap_rid_order.as_ref() {
3344                            let rev_path =
3345                                pack_dir.join(format!("multi-pack-index-{hash_hex}.rev"));
3346                            if !rev_path.exists() {
3347                                write_midx_rev_sidecar(&rev_path, order, &hash_arr, repo_algo)?;
3348                            }
3349                        }
3350                    }
3351                    return Ok(WriteMultiPackIndexResult {
3352                        bitmap: bitmap_outcome,
3353                    });
3354                }
3355            }
3356        }
3357
3358        clear_incremental_midx_files(pack_dir)?;
3359
3360        fs::write(&dest, &out).map_err(Error::Io)?;
3361
3362        scrub_root_midx_sidecars_except(pack_dir, Some(&hash_hex))?;
3363
3364        if opts.write_bitmap {
3365            let rid = bitmap_rid_order.as_ref().ok_or_else(|| {
3366                Error::CorruptObject("MIDX bitmap write missing reverse index order".to_owned())
3367            })?;
3368            write_midx_reachability_bitmap(pack_dir, &out, rid, hash, &bitmap_path)?;
3369        }
3370        if opts.write_rev_sidecar {
3371            let rev_path = pack_dir.join(format!("multi-pack-index-{hash_hex}.rev"));
3372            let order = bitmap_rid_order.as_ref().ok_or_else(|| {
3373                Error::CorruptObject(
3374                    "MIDX rev sidecar write missing reverse index order".to_owned(),
3375                )
3376            })?;
3377            write_midx_rev_sidecar(&rev_path, order, &hash_arr, repo_algo)?;
3378        }
3379    }
3380
3381    if opts.write_rev_sidecar {
3382        let hash_hex = hex::encode(hash);
3383        let rev_root = pack_dir.join(format!("multi-pack-index-{hash_hex}.rev"));
3384        let rev_layer = midx_d_dir(pack_dir).join(format!("multi-pack-index-{hash_hex}.rev"));
3385        if !rev_root.is_file() && !rev_layer.is_file() {
3386            return Err(Error::CorruptObject(format!(
3387                "MIDX rev sidecar missing after write ({hash_hex})"
3388            )));
3389        }
3390    }
3391
3392    midx_cache::evict_pack_dir(pack_dir);
3393    Ok(WriteMultiPackIndexResult {
3394        bitmap: bitmap_outcome,
3395    })
3396}
3397
3398fn pack_names_match_layer(base_name: &str, disk_idx: &str) -> bool {
3399    if base_name == disk_idx {
3400        return true;
3401    }
3402    cmp_idx_or_pack_name(disk_idx, base_name).is_eq()
3403}
3404
3405/// Failure modes of [`compact_multi_pack_index`], each mapping to one of git's
3406/// user-facing diagnostics in `cmd_multi_pack_index_compact`.
3407#[derive(Debug)]
3408pub enum CompactError {
3409    /// `--incremental` was requested but no chain exists yet.
3410    NoChain,
3411    /// One of the endpoint checksums does not name a layer in the chain. Carries the
3412    /// raw argument text so the message matches `could not find MIDX: <arg>`.
3413    MissingEndpoint(String),
3414    /// Both endpoints resolve to the same layer.
3415    IdenticalEndpoints,
3416    /// `from` (`argv[0]`) is newer than `to` (`argv[1]`); git requires `from` to be an
3417    /// ancestor of `to`. Carries `(from, to)` arg text for the diagnostic.
3418    NotAncestor(String, String),
3419    /// Compaction was requested with the v1 on-disk MIDX format.
3420    V1Format,
3421    /// Any underlying I/O or parse failure.
3422    Other(String),
3423}
3424
3425impl std::fmt::Display for CompactError {
3426    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3427        match self {
3428            CompactError::NoChain => write!(f, "no multi-pack-index chain to compact"),
3429            CompactError::MissingEndpoint(s) => write!(f, "could not find MIDX: {s}"),
3430            CompactError::IdenticalEndpoints => {
3431                write!(f, "MIDX compaction endpoints must be unique")
3432            }
3433            CompactError::NotAncestor(from, to) => {
3434                write!(f, "MIDX {from} must be an ancestor of {to}")
3435            }
3436            CompactError::V1Format => write!(f, "cannot perform MIDX compaction with v1 format"),
3437            CompactError::Other(s) => write!(f, "{s}"),
3438        }
3439    }
3440}
3441
3442impl From<Error> for CompactError {
3443    fn from(e: Error) -> Self {
3444        CompactError::Other(e.to_string())
3445    }
3446}
3447
3448/// Collect every OID provided by the chain layers in `hashes` (each layer file is
3449/// self-contained: it lists only its own incremental objects).
3450fn collect_layer_oids(pack_dir: &Path, hashes: &[String]) -> Result<HashSet<ObjectId>> {
3451    let mut oids = HashSet::new();
3452    for h in hashes {
3453        let p = midx_d_dir(pack_dir).join(format!("multi-pack-index-{h}.midx"));
3454        let data = load_midx_file(&p)?;
3455        let (layer_oids, _) = oids_and_packs_from_midx_data(&data)?;
3456        oids.extend(layer_oids);
3457    }
3458    Ok(oids)
3459}
3460
3461/// Pack idx basenames listed by a single chain layer, in the layer's stored order.
3462fn layer_pack_names(pack_dir: &Path, hash: &str) -> Result<Vec<String>> {
3463    let p = midx_d_dir(pack_dir).join(format!("multi-pack-index-{hash}.midx"));
3464    let data = load_midx_file(&p)?;
3465    let (_, hdr_end, _) = parse_midx_header(&data)?;
3466    let (pn_off, pn_len) = find_chunk(&data, hdr_end, MIDX_CHUNKID_PACKNAMES)?;
3467    parse_pack_names_blob(&data[pn_off..pn_off + pn_len])
3468}
3469
3470/// `git multi-pack-index compact <from> <to>`: merge the inclusive chain range
3471/// `[from..to]` (oldest→newest, matching git's `from`=`argv[0]` / `to`=`argv[1]`) into a
3472/// single new incremental layer, preserving pack order, and rewrite the chain as
3473/// `[layers before from] + [compacted layer] + [layers after to]`.
3474///
3475/// Mirrors `write_midx_file_compact` (git/midx-write.c). Because grit's chain layers
3476/// are self-contained (each lists only its own packs/objects), layers outside the
3477/// compacted range keep their existing files and checksums untouched.
3478///
3479/// `version` is the on-disk format of the compacted layer. `None` writes v2, which
3480/// compaction needs because the merged packs keep chain order rather than sorted order;
3481/// `Some(1)` fails with [`CompactError::V1Format`], and values other than `1` or `2` fail
3482/// with [`MidxError::UnknownWriteVersion`] wrapped in [`CompactError::Other`].
3483pub fn compact_multi_pack_index(
3484    pack_dir: &Path,
3485    from_arg: &str,
3486    to_arg: &str,
3487    write_bitmaps: bool,
3488    write_rev: bool,
3489    version: Option<u8>,
3490) -> std::result::Result<(), CompactError> {
3491    let version = resolve_write_version(version, MIDX_VERSION_V2)?;
3492    if version == MIDX_VERSION_V1 {
3493        return Err(CompactError::V1Format);
3494    }
3495
3496    let chain = read_chain_layer_hashes(pack_dir).map_err(|_| CompactError::NoChain)?;
3497    if chain.is_empty() {
3498        return Err(CompactError::NoChain);
3499    }
3500
3501    let from_hex = from_arg.to_ascii_lowercase();
3502    let to_hex = to_arg.to_ascii_lowercase();
3503
3504    let from_pos = chain.iter().position(|h| *h == from_hex);
3505    let to_pos = chain.iter().position(|h| *h == to_hex);
3506
3507    // Match git: report `from` first, then `to`, when an endpoint is missing.
3508    let Some(from_pos) = from_pos else {
3509        return Err(CompactError::MissingEndpoint(from_arg.to_string()));
3510    };
3511    let Some(to_pos) = to_pos else {
3512        return Err(CompactError::MissingEndpoint(to_arg.to_string()));
3513    };
3514
3515    if from_pos == to_pos {
3516        return Err(CompactError::IdenticalEndpoints);
3517    }
3518    // git walks `base_midx` from `from`; reaching `to` means `from` is an ancestor of
3519    // `to`, i.e. `from` is newer (higher chain index) than `to`. That is the reverse
3520    // of what compaction expects, so report the "must be an ancestor" error.
3521    if from_pos > to_pos {
3522        return Err(CompactError::NotAncestor(
3523            from_arg.to_string(),
3524            to_arg.to_string(),
3525        ));
3526    }
3527
3528    // Layers strictly before `from` form the base; their objects are excluded from
3529    // the compacted layer.
3530    let base_hashes = &chain[..from_pos];
3531    let merged_hashes = &chain[from_pos..=to_pos];
3532    let upper_hashes = &chain[to_pos + 1..];
3533
3534    let base_oids = collect_layer_oids(pack_dir, base_hashes)?;
3535
3536    // Gather the merged layers' pack idx names in chain order (oldest layer first),
3537    // preserving each layer's internal order (git's `fill_packs_from_midx_range`).
3538    let mut ordered_idx_names: Vec<String> = Vec::new();
3539    for h in merged_hashes {
3540        for name in layer_pack_names(pack_dir, h)? {
3541            if !ordered_idx_names.contains(&name) {
3542                ordered_idx_names.push(name);
3543            }
3544        }
3545    }
3546
3547    if ordered_idx_names.is_empty() {
3548        return Err(CompactError::Other(
3549            "no packs found in compaction range".to_owned(),
3550        ));
3551    }
3552
3553    // Load the pack indexes in the resolved order.
3554    let mut indexes: Vec<PackIndex> = Vec::with_capacity(ordered_idx_names.len());
3555    for name in &ordered_idx_names {
3556        let path = pack_dir.join(name);
3557        indexes.push(crate::pack::read_pack_index_no_verify(&path)?);
3558    }
3559
3560    // When writing a bitmap, git sets the preferred pack to the first (oldest) pack
3561    // of the compacted range so its objects win duplicate selection.
3562    let preferred_idx = if write_bitmaps { Some(0usize) } else { None };
3563
3564    let exclude = if base_oids.is_empty() {
3565        None
3566    } else {
3567        Some(&base_oids)
3568    };
3569
3570    let (out, bitmap_rid_order) = build_midx_bytes_filtered(
3571        &ordered_idx_names,
3572        &indexes,
3573        preferred_idx,
3574        write_bitmaps,
3575        write_rev,
3576        version,
3577        repo_midx_hash_version(pack_dir),
3578        exclude,
3579    )?;
3580
3581    let repo_algo = repo_hash_algo_for_pack_dir(pack_dir);
3582    let hash_len = repo_algo.len();
3583    let hash = &out[out.len() - hash_len..];
3584    let hash_hex = hex::encode(hash);
3585    let hash_arr: Vec<u8> = hash.to_vec();
3586
3587    let midx_d = midx_d_dir(pack_dir);
3588    fs::create_dir_all(&midx_d).map_err(Error::Io)?;
3589
3590    let layer_path = midx_d.join(format!("multi-pack-index-{hash_hex}.midx"));
3591    fs::write(&layer_path, &out).map_err(Error::Io)?;
3592
3593    // New chain: base layers, the compacted layer, then the untouched upper layers.
3594    let mut new_chain: Vec<String> = Vec::new();
3595    new_chain.extend(base_hashes.iter().cloned());
3596    new_chain.push(hash_hex.clone());
3597    new_chain.extend(upper_hashes.iter().cloned());
3598
3599    let mut chain_data = String::new();
3600    for h in &new_chain {
3601        chain_data.push_str(h);
3602        chain_data.push('\n');
3603    }
3604    fs::write(chain_file_path(pack_dir), chain_data.as_bytes()).map_err(Error::Io)?;
3605
3606    if write_bitmaps {
3607        if write_rev {
3608            let rev_path = midx_d.join(format!("multi-pack-index-{hash_hex}.rev"));
3609            if let Some(order) = bitmap_rid_order.as_ref() {
3610                write_midx_rev_sidecar(&rev_path, order, &hash_arr, repo_algo)?;
3611            }
3612        }
3613        let bitmap_path = midx_d.join(format!("multi-pack-index-{hash_hex}.bitmap"));
3614        let rid = bitmap_rid_order.as_ref().ok_or_else(|| {
3615            CompactError::Other("MIDX bitmap write missing reverse index order".to_owned())
3616        })?;
3617        write_midx_reachability_bitmap_with_options(
3618            pack_dir,
3619            &out,
3620            rid,
3621            hash,
3622            &bitmap_path,
3623            &PackBitmapWriteOptions {
3624                verify_reachability_closure: false,
3625                ..PackBitmapWriteOptions::default()
3626            },
3627        )
3628        .map_err(|e| CompactError::Other(e.to_string()))?;
3629    }
3630
3631    // Drop the now-removed range layers and their sidecars.
3632    clear_stale_split_layers(pack_dir, &new_chain)?;
3633
3634    midx_cache::evict_pack_dir(pack_dir);
3635    Ok(())
3636}
3637
3638fn scrub_root_midx_sidecars(pack_dir: &Path) -> Result<()> {
3639    scrub_root_midx_sidecars_except(pack_dir, None)
3640}
3641
3642fn scrub_root_midx_sidecars_except(pack_dir: &Path, keep_hex: Option<&str>) -> Result<()> {
3643    let Ok(rd) = fs::read_dir(pack_dir) else {
3644        return Ok(());
3645    };
3646    for ent in rd {
3647        let ent = ent.map_err(Error::Io)?;
3648        let name = ent.file_name().to_string_lossy().to_string();
3649        let Some(rest) = name.strip_prefix("multi-pack-index-") else {
3650            continue;
3651        };
3652        if !(rest.ends_with(".bitmap") || rest.ends_with(".rev")) {
3653            continue;
3654        }
3655        let hash_part = rest
3656            .strip_suffix(".bitmap")
3657            .or_else(|| rest.strip_suffix(".rev"))
3658            .unwrap_or(rest);
3659        // Git's `clear_midx_files_ext` removes any `multi-pack-index-<hash>.<ext>`
3660        // sidecar that does not belong to the current MIDX, regardless of the
3661        // hash's textual length (t5319 plants a `multi-pack-index-abc.rev`).
3662        if keep_hex.is_some_and(|k| k == hash_part) {
3663            continue;
3664        }
3665        let _ = fs::remove_file(ent.path());
3666    }
3667    Ok(())
3668}
3669
3670#[cfg(test)]
3671mod tests {
3672    use super::{
3673        clear_pack_midx_state, resolve_tip_midx_path, try_read_object_via_midx,
3674        write_multi_pack_index,
3675    };
3676
3677    use crate::error::{Error, MidxError};
3678    use crate::hot_path_test_metrics::HotPathMetricsScope;
3679    use crate::objects::ObjectId;
3680    use crate::odb::Odb;
3681    use crate::pack::{clear_pack_cache, read_object_from_packs};
3682    use std::fs;
3683    use std::path::Path;
3684    use std::process::Command;
3685    use std::sync::Arc;
3686
3687    fn git(dir: &Path, args: &[&str]) {
3688        let out = Command::new("git")
3689            .current_dir(dir)
3690            .args(args)
3691            .env("GIT_AUTHOR_NAME", "T")
3692            .env("GIT_AUTHOR_EMAIL", "t@example.com")
3693            .env("GIT_COMMITTER_NAME", "T")
3694            .env("GIT_COMMITTER_EMAIL", "t@example.com")
3695            .env("GIT_CONFIG_GLOBAL", "/dev/null")
3696            .env("GIT_CONFIG_SYSTEM", "/dev/null")
3697            .output()
3698            .expect("run git");
3699        assert!(
3700            out.status.success(),
3701            "git {:?} failed: {}",
3702            args,
3703            String::from_utf8_lossy(&out.stderr)
3704        );
3705    }
3706
3707    fn git_available() -> bool {
3708        Command::new("git")
3709            .arg("--version")
3710            .output()
3711            .map(|o| o.status.success())
3712            .unwrap_or(false)
3713    }
3714
3715    /// Pack fixture: one commit, all objects in a single pack (no MIDX yet).
3716    pub(super) fn midx_fixture(with_midx: bool) -> Option<(tempfile::TempDir, ObjectId)> {
3717        if !git_available() {
3718            return None;
3719        }
3720        let tmp = tempfile::tempdir().ok()?;
3721        let dir = tmp.path();
3722        git(dir, &["init", "-q", "-b", "main"]);
3723        std::fs::write(dir.join("f.txt"), b"hello midx cache").ok()?;
3724        git(dir, &["add", "f.txt"]);
3725        git(dir, &["commit", "-q", "-m", "c"]);
3726        git(dir, &["repack", "-adf"]);
3727        let pack_dir = dir.join(".git/objects/pack");
3728        if with_midx {
3729            write_multi_pack_index(&pack_dir).ok()?;
3730        }
3731        let hex = Command::new("git")
3732            .current_dir(dir)
3733            .args(["rev-parse", "HEAD"])
3734            .env("GIT_CONFIG_GLOBAL", "/dev/null")
3735            .env("GIT_CONFIG_SYSTEM", "/dev/null")
3736            .output()
3737            .ok()?;
3738        if !hex.status.success() {
3739            return None;
3740        }
3741        let oid = ObjectId::from_hex(std::str::from_utf8(&hex.stdout).ok()?.trim()).ok()?;
3742        Some((tmp, oid))
3743    }
3744
3745    #[cfg_attr(
3746        not(feature = "full-tests"),
3747        ignore = "starts system git or is heavy; run with --features full-tests"
3748    )]
3749    #[test]
3750    fn vanished_cached_tip_falls_back() {
3751        let Some((tmp, oid)) = midx_fixture(true) else {
3752            eprintln!("SKIP: git unavailable for MIDX fixture");
3753            return;
3754        };
3755        let git_dir = tmp.path().join(".git");
3756        let objects = git_dir.join("objects");
3757        let pack_dir = objects.join("pack");
3758        let midx_path = pack_dir.join("multi-pack-index");
3759        assert!(midx_path.is_file(), "expected root MIDX");
3760
3761        assert!(
3762            try_read_object_via_midx(&objects, &oid)
3763                .expect("prime midx read")
3764                .is_some(),
3765            "object should be reachable via MIDX"
3766        );
3767
3768        std::fs::remove_file(&midx_path).expect("simulate external MIDX removal");
3769
3770        // Prepared MIDX bytes stay in-process until evict; external unlink alone does not
3771        // invalidate the parsed layer (no stat on the hot path).
3772        assert!(
3773            try_read_object_via_midx(&objects, &oid)
3774                .expect("vanished tip must not fail reads")
3775                .is_some(),
3776            "cached prepared layer still serves until pack-dir evict"
3777        );
3778
3779        let odb = Odb::new(&objects).with_config_git_dir(git_dir);
3780        let via_pack = odb.read(&oid).expect("pack fallback after vanished MIDX");
3781        assert_eq!(via_pack.kind, crate::objects::ObjectKind::Commit);
3782    }
3783
3784    #[cfg_attr(
3785        not(feature = "full-tests"),
3786        ignore = "starts system git or is heavy; run with --features full-tests"
3787    )]
3788    #[test]
3789    fn writer_evicts_tip_cache() {
3790        let Some((tmp, oid)) = midx_fixture(true) else {
3791            eprintln!("SKIP: git unavailable for MIDX fixture");
3792            return;
3793        };
3794        let objects = tmp.path().join(".git/objects");
3795        let pack_dir = objects.join("pack");
3796
3797        assert!(try_read_object_via_midx(&objects, &oid)
3798            .expect("prime caches")
3799            .is_some());
3800        assert!(resolve_tip_midx_path(&pack_dir).is_some());
3801
3802        clear_pack_midx_state(&pack_dir).expect("clear midx");
3803        assert!(resolve_tip_midx_path(&pack_dir).is_none());
3804
3805        // Tip-path cache was evicted: listing must not resurrect the removed MIDX.
3806        assert!(try_read_object_via_midx(&objects, &oid)
3807            .expect("read after clear")
3808            .is_none());
3809
3810        write_multi_pack_index(&pack_dir).expect("rewrite midx");
3811        assert!(
3812            try_read_object_via_midx(&objects, &oid)
3813                .expect("read after rewrite")
3814                .is_some(),
3815            "fresh MIDX must be visible after in-process rewrite"
3816        );
3817    }
3818
3819    #[cfg_attr(
3820        not(feature = "full-tests"),
3821        ignore = "starts system git or is heavy; run with --features full-tests"
3822    )]
3823    #[test]
3824    fn midx_hot_path_does_not_stat_after_cache_warm() {
3825        let Some((tmp, oid)) = midx_fixture(true) else {
3826            eprintln!("SKIP: git unavailable for MIDX fixture");
3827            return;
3828        };
3829        let git_dir = tmp.path().join(".git");
3830        let objects = git_dir.join("objects");
3831        let odb = Odb::new(&objects).with_config_git_dir(git_dir);
3832        let metrics = odb.hot_path_metrics_arc();
3833        let _scope = HotPathMetricsScope::install(Arc::clone(&metrics));
3834        metrics.set_stamp_counting(true);
3835        let _ = odb.read(&oid).expect("prime");
3836        assert!(
3837            metrics.midx_stamp_stat_calls() > 0,
3838            "cold read must stat the MIDX layer"
3839        );
3840        metrics.reset_midx_stamp_stats();
3841        for _ in 0..200 {
3842            let _ = odb.read(&oid).expect("repeat");
3843        }
3844        metrics.set_stamp_counting(false);
3845        assert_eq!(metrics.midx_stamp_stat_calls(), 0);
3846    }
3847
3848    #[test]
3849    fn decode_high_bit_offset_is_raw_without_loff_chunk() {
3850        use super::midx_cache::decode_midx_object_offset_for_test;
3851        assert_eq!(
3852            decode_midx_object_offset_for_test(&[], 0x8000_0010, None),
3853            Some(0x8000_0010)
3854        );
3855    }
3856
3857    #[cfg_attr(
3858        not(feature = "full-tests"),
3859        ignore = "starts system git or is heavy; run with --features full-tests"
3860    )]
3861    #[test]
3862    fn lookup_high_bit_raw_offset_written_without_loff() {
3863        use super::{
3864            clear_pack_midx_state, find_chunk, midx_lookup_pack_and_offset_opt, parse_midx_header,
3865            MIDX_CHUNKID_OBJECTOFFSETS, MIDX_CHUNKID_OIDLOOKUP,
3866        };
3867        let Some((tmp, oid)) = midx_fixture(true) else {
3868            eprintln!("SKIP: git unavailable for MIDX fixture");
3869            return;
3870        };
3871        let objects = tmp.path().join(".git/objects");
3872        let pack_dir = objects.join("pack");
3873        let midx_path = pack_dir.join("multi-pack-index");
3874        let mut data = std::fs::read(&midx_path).expect("midx bytes");
3875        let (_, hdr_end, _) = parse_midx_header(&data).expect("header");
3876        let (oidl_off, oidl_len) =
3877            find_chunk(&data, hdr_end, MIDX_CHUNKID_OIDLOOKUP).expect("oidl");
3878        let (ooff_off, _) = find_chunk(&data, hdr_end, MIDX_CHUNKID_OBJECTOFFSETS).expect("ooff");
3879        let hash_len = oid.as_bytes().len();
3880        let num_objects = oidl_len / hash_len;
3881        let needle = oid.as_bytes();
3882        let mut patched = false;
3883        for i in 0..num_objects {
3884            let start = oidl_off + i * hash_len;
3885            if data[start..start + hash_len] == *needle {
3886                let ob = ooff_off + i * 8 + 4;
3887                data[ob..ob + 4].copy_from_slice(&0x8000_0010u32.to_be_bytes());
3888                patched = true;
3889                break;
3890            }
3891        }
3892        assert!(patched, "oid row in MIDX");
3893        let hash_len = if data[5] == 2 { 32 } else { 20 };
3894        data.truncate(data.len() - hash_len);
3895        crate::pack_rev::append_hashfile_checksum(&mut data, hash_len);
3896        clear_pack_midx_state(&pack_dir).expect("evict midx cache");
3897        std::fs::write(&midx_path, &data).expect("write midx");
3898        crate::pack::clear_pack_cache();
3899        let (_pack_id, off) = midx_lookup_pack_and_offset_opt(&objects, &oid)
3900            .expect("lookup")
3901            .expect("listed");
3902        assert_eq!(off, 0x8000_0010);
3903    }
3904
3905    #[cfg_attr(
3906        not(feature = "full-tests"),
3907        ignore = "starts system git or is heavy; run with --features full-tests"
3908    )]
3909    #[test]
3910    fn tiny_midx_stub_skipped_without_aborting_reads() {
3911        let Some((tmp, oid)) = midx_fixture(true) else {
3912            eprintln!("SKIP: git unavailable for MIDX fixture");
3913            return;
3914        };
3915        let objects = tmp.path().join(".git/objects");
3916        let pack_dir = objects.join("pack");
3917        std::fs::write(pack_dir.join("multi-pack-index"), [0u8; 8]).expect("tiny stub midx");
3918        super::midx_cache::evict_pack_dir(&pack_dir);
3919        clear_pack_cache();
3920
3921        assert!(super::prepared_midx_chain(&objects)
3922            .expect("tiny MIDX must not fail chain build")
3923            .is_none());
3924        assert!(try_read_object_via_midx(&objects, &oid)
3925            .expect("tiny MIDX must not fail lookup")
3926            .is_none());
3927        read_object_from_packs(&objects, &oid).expect("pack fallback still works");
3928    }
3929
3930    #[cfg_attr(
3931        not(feature = "full-tests"),
3932        ignore = "starts system git or is heavy; run with --features full-tests"
3933    )]
3934    #[test]
3935    fn flipped_midx_checksum_warns_and_reads_from_pack() {
3936        let Some((tmp, oid)) = midx_fixture(true) else {
3937            eprintln!("SKIP: git unavailable for MIDX fixture");
3938            return;
3939        };
3940        let objects = tmp.path().join(".git/objects");
3941        let pack_dir = objects.join("pack");
3942        let midx_path = pack_dir.join("multi-pack-index");
3943        let mut bytes = fs::read(&midx_path).expect("read midx");
3944        let last = bytes.len() - 1;
3945        bytes[last] ^= 0xff;
3946        fs::write(&midx_path, &bytes).expect("corrupt midx checksum");
3947        super::midx_cache::evict_pack_dir(&pack_dir);
3948        clear_pack_cache();
3949
3950        let errs = super::verify_midx(&objects).expect_err("verify catches bad checksum");
3951        assert!(
3952            errs.iter().any(|e| e.contains("incorrect checksum")),
3953            "expected verify checksum error, got {errs:?}"
3954        );
3955        let via_midx =
3956            try_read_object_via_midx(&objects, &oid).expect("checksum mismatch must not fatal");
3957        assert!(
3958            via_midx.is_none(),
3959            "invalid MIDX checksum must be skipped for direct lookup"
3960        );
3961
3962        let git_dir = tmp.path().join(".git");
3963        let odb = Odb::new(&objects).with_config_git_dir(git_dir);
3964        let grit_obj = odb.read(&oid).expect("pack fallback read");
3965        let via_pack = read_object_from_packs(&objects, &oid).expect("pack read");
3966        assert_eq!(via_pack.data, grit_obj.data);
3967    }
3968
3969    #[cfg_attr(
3970        not(feature = "full-tests"),
3971        ignore = "starts system git or is heavy; run with --features full-tests"
3972    )]
3973    #[test]
3974    fn midx_shorter_than_min_len_skips_without_fatal_error() {
3975        let Some((tmp, oid)) = midx_fixture(true) else {
3976            eprintln!("SKIP: git unavailable for MIDX fixture");
3977            return;
3978        };
3979        let objects = tmp.path().join(".git/objects");
3980        let pack_dir = objects.join("pack");
3981        let midx_path = pack_dir.join("multi-pack-index");
3982        let mut bytes = fs::read(&midx_path).expect("read");
3983        bytes.truncate(super::MIDX_HEADER_SIZE + 10);
3984        fs::write(&midx_path, &bytes).expect("truncate");
3985        super::midx_cache::evict_pack_dir(&pack_dir);
3986        clear_pack_cache();
3987        assert!(
3988            try_read_object_via_midx(&objects, &oid)
3989                .expect("read")
3990                .is_none(),
3991            "truncated MIDX must be skipped for direct lookup"
3992        );
3993        read_object_from_packs(&objects, &oid).expect("pack fallback still works");
3994    }
3995
3996    #[cfg_attr(
3997        not(feature = "full-tests"),
3998        ignore = "starts system git or is heavy; run with --features full-tests"
3999    )]
4000    #[test]
4001    fn large_git_midx_layer_uses_mapped_backing() {
4002        let Some((tmp, _oid)) = midx_fixture(false) else {
4003            eprintln!("SKIP: git unavailable for MIDX fixture");
4004            return;
4005        };
4006        for i in 0..120 {
4007            std::fs::write(tmp.path().join(format!("bulk{i}.txt")), format!("{i}\n")).unwrap();
4008            git(tmp.path(), &["add", &format!("bulk{i}.txt")]);
4009            git(tmp.path(), &["commit", "-q", "-m", &format!("bulk{i}")]);
4010        }
4011        git(tmp.path(), &["repack", "-adf"]);
4012        git(tmp.path(), &["multi-pack-index", "write"]);
4013        let midx_path = tmp.path().join(".git/objects/pack/multi-pack-index");
4014        let len = std::fs::metadata(&midx_path).expect("meta").len();
4015        assert!(len > 8192, "midx size {len}");
4016        let storage =
4017            crate::pack_map::PackData::open_with_min_len(&midx_path, super::MIDX_MIN_FILE_LEN)
4018                .expect("open");
4019        assert!(storage.is_mapped_backing());
4020    }
4021
4022    #[cfg_attr(
4023        not(feature = "full-tests"),
4024        ignore = "starts system git or is heavy; run with --features full-tests"
4025    )]
4026    #[test]
4027    fn wrong_midx_hash_version_returns_typed_error() {
4028        let Some((tmp, oid)) = midx_fixture(true) else {
4029            eprintln!("SKIP: git unavailable for MIDX fixture");
4030            return;
4031        };
4032        let objects = tmp.path().join(".git/objects");
4033        let midx_path = objects.join("pack/multi-pack-index");
4034        let mut bytes = fs::read(&midx_path).expect("read midx");
4035        bytes[5] = 2;
4036        fs::write(&midx_path, &bytes).expect("write wrong hash version");
4037        super::midx_cache::evict_pack_dir(&objects.join("pack"));
4038
4039        let err = try_read_object_via_midx(&objects, &oid).expect_err("hash mismatch");
4040        assert!(
4041            matches!(
4042                err,
4043                Error::Midx(MidxError::HashVersionMismatch {
4044                    found: 2,
4045                    expected: 1
4046                })
4047            ),
4048            "unexpected error: {err:?}"
4049        );
4050    }
4051
4052    #[test]
4053    fn midx_load_error_display_roundtrip() {
4054        use crate::midx_error::MidxError;
4055        let err = MidxError::VersionNotRecognized { version: 99 };
4056        assert!(err.to_string().contains("not recognized"));
4057    }
4058
4059    #[test]
4060    fn compact_error_from_library_error() {
4061        use crate::error::Error;
4062        let err: super::CompactError = Error::CorruptObject("test".into()).into();
4063        assert!(err.to_string().contains("test"));
4064    }
4065
4066    #[cfg_attr(
4067        not(feature = "full-tests"),
4068        ignore = "starts system git or is heavy; run with --features full-tests"
4069    )]
4070    #[test]
4071    fn verify_midx_reports_missing_pack_name_chunk() {
4072        let Some((tmp, _oid)) = midx_fixture(true) else {
4073            eprintln!("SKIP: git unavailable for MIDX fixture");
4074            return;
4075        };
4076        let objects = tmp.path().join(".git/objects");
4077        let pack_dir = objects.join("pack");
4078        let path = pack_dir.join("multi-pack-index");
4079        let mut data = std::fs::read(&path).expect("midx");
4080        data[6] = 0;
4081        data.truncate(super::MIDX_HEADER_SIZE + 24);
4082        std::fs::write(&path, &data).expect("write broken midx");
4083        super::evict_midx_read_cache_for_pack_dir(&pack_dir);
4084        let errs = super::verify_midx(&objects).expect_err("verify");
4085        assert!(
4086            errs.iter().any(|e| {
4087                e.contains("pack-name") || e.contains("pack name") || e.contains("chunk")
4088            }),
4089            "{errs:?}"
4090        );
4091    }
4092
4093    #[cfg_attr(
4094        not(feature = "full-tests"),
4095        ignore = "starts system git or is heavy; run with --features full-tests"
4096    )]
4097    #[test]
4098    fn find_chunk_reports_missing_id() {
4099        let Some((tmp, _oid)) = midx_fixture(true) else {
4100            eprintln!("SKIP: git unavailable for MIDX fixture");
4101            return;
4102        };
4103        let midx_path = tmp.path().join(".git/objects/pack/multi-pack-index");
4104        let data = std::fs::read(midx_path).expect("midx");
4105        let (_, hdr_end, _) = super::parse_midx_header(&data).expect("header");
4106        let err = super::find_chunk(&data, hdr_end, 0xdead_beef).expect_err("missing chunk");
4107        assert!(err.to_string().contains("not found"));
4108    }
4109
4110    #[cfg_attr(
4111        not(feature = "full-tests"),
4112        ignore = "starts system git or is heavy; run with --features full-tests"
4113    )]
4114    #[test]
4115    fn read_after_in_process_pack_swap() {
4116        let Some((tmp, oid)) = midx_fixture(false) else {
4117            eprintln!("SKIP: git unavailable for MIDX fixture");
4118            return;
4119        };
4120        let objects = tmp.path().join(".git/objects");
4121        let before = read_object_from_packs(&objects, &oid).expect("read from pack");
4122
4123        std::fs::write(tmp.path().join("f2.txt"), b"replacement pack").expect("write");
4124        git(tmp.path(), &["add", "f2.txt"]);
4125        git(tmp.path(), &["commit", "-q", "-m", "c2"]);
4126        git(tmp.path(), &["repack", "-adf"]);
4127        clear_pack_cache();
4128
4129        let after = read_object_from_packs(&objects, &oid).expect("historical commit still packed");
4130        assert_eq!(before.kind, after.kind);
4131        assert_eq!(before.data, after.data);
4132    }
4133
4134    #[cfg_attr(
4135        not(feature = "full-tests"),
4136        ignore = "starts system git or is heavy; run with --features full-tests"
4137    )]
4138    #[test]
4139    fn git_midx_mapped_bytes_match_owned_prepare() {
4140        use super::midx_cache::PreparedMidxLayer;
4141        use crate::diagnostics::NullDiagnostics;
4142        use crate::pack_map::PackData;
4143
4144        let Some((tmp, oid)) = midx_fixture(false) else {
4145            eprintln!("SKIP: git unavailable for MIDX fixture");
4146            return;
4147        };
4148        for i in 0..120 {
4149            std::fs::write(tmp.path().join(format!("bulk{i}.txt")), format!("{i}\n")).unwrap();
4150            git(tmp.path(), &["add", &format!("bulk{i}.txt")]);
4151            git(tmp.path(), &["commit", "-q", "-m", &format!("bulk{i}")]);
4152        }
4153        git(tmp.path(), &["repack", "-adf"]);
4154        git(tmp.path(), &["multi-pack-index", "write"]);
4155
4156        let objects = tmp.path().join(".git/objects");
4157        let pack_dir = objects.join("pack");
4158        let midx_path = pack_dir.join("multi-pack-index");
4159        let midx_len = std::fs::metadata(&midx_path).expect("meta").len();
4160        assert!(midx_len > 8192, "fixture midx size {midx_len}");
4161        let hash_version = super::repo_midx_hash_version_for_objects_dir(&objects);
4162        let mapped =
4163            PackData::open_with_min_len(&midx_path, super::MIDX_MIN_FILE_LEN).expect("mmap");
4164        assert!(
4165            mapped.is_mapped_backing(),
4166            "expected mmap backing for {midx_len}-byte MIDX"
4167        );
4168        let owned = PackData::from_owned(std::fs::read(&midx_path).expect("read"));
4169        assert!(
4170            !owned.is_mapped_backing(),
4171            "explicit owned buffer must not use mmap"
4172        );
4173        let view_mapped =
4174            match super::midx_load_for_read(mapped.as_ref(), hash_version, &NullDiagnostics)
4175                .expect("load mapped")
4176            {
4177                super::MidxLoadResult::Ok(v) => v,
4178                super::MidxLoadResult::Skip => panic!("mapped midx skipped"),
4179            };
4180        let view_owned =
4181            match super::midx_load_for_read(owned.as_ref(), hash_version, &NullDiagnostics)
4182                .expect("load owned")
4183            {
4184                super::MidxLoadResult::Ok(v) => v,
4185                super::MidxLoadResult::Skip => panic!("owned midx skipped"),
4186            };
4187        let layer_mapped = PreparedMidxLayer::prepare(mapped, view_mapped, &pack_dir);
4188        let layer_owned = PreparedMidxLayer::prepare(owned, view_owned, &pack_dir);
4189        let loc = layer_mapped
4190            .lookup_pack_and_offset(&oid)
4191            .expect("mapped lookup");
4192        assert_eq!(
4193            loc,
4194            layer_owned
4195                .lookup_pack_and_offset(&oid)
4196                .expect("owned lookup")
4197        );
4198        let mapped_obj = layer_mapped
4199            .read_at(loc.0, loc.1)
4200            .expect("read mapped")
4201            .expect("object mapped");
4202        let owned_obj = layer_owned
4203            .read_at(loc.0, loc.1)
4204            .expect("read owned")
4205            .expect("object owned");
4206        assert_eq!(mapped_obj.data, owned_obj.data);
4207    }
4208
4209    fn config_from(text: &str) -> crate::config::ConfigSet {
4210        let file = crate::config::ConfigFile::parse(
4211            Path::new("test"),
4212            text,
4213            crate::config::ConfigScope::Local,
4214        )
4215        .expect("parse config");
4216        let mut set = crate::config::ConfigSet::new();
4217        set.merge(&file);
4218        set
4219    }
4220
4221    #[test]
4222    fn write_version_defaults_and_validation() {
4223        assert_eq!(super::resolve_write_version(None, 1).expect("default"), 1);
4224        assert_eq!(super::resolve_write_version(None, 2).expect("default"), 2);
4225        assert_eq!(super::resolve_write_version(Some(1), 2).expect("v1"), 1);
4226        assert_eq!(super::resolve_write_version(Some(2), 1).expect("v2"), 2);
4227        for bad in [0u8, 3, 255] {
4228            assert!(matches!(
4229                super::resolve_write_version(Some(bad), 1),
4230                Err(Error::Midx(MidxError::UnknownWriteVersion { version })) if version == i64::from(bad)
4231            ));
4232        }
4233    }
4234
4235    #[test]
4236    fn midx_version_config_parsing() {
4237        use crate::error::ConfigError;
4238        let read = |text: &str| super::midx_version_from_config(&config_from(text));
4239        assert_eq!(read("[core]\n\tbare = false\n").expect("unset"), None);
4240        assert_eq!(read("[midx]\n\tversion = 1\n").expect("v1"), Some(1));
4241        assert_eq!(read("[midx]\n\tversion = 2\n").expect("v2"), Some(2));
4242        assert_eq!(
4243            read("[midx]\n\tversion = 2\n[midx]\n\tversion = 1\n").expect("last wins"),
4244            Some(1)
4245        );
4246        for (text, version) in [
4247            ("[midx]\n\tversion = 3\n", 3),
4248            ("[midx]\n\tversion = 0\n", 0),
4249            ("[midx]\n\tversion = -1\n", -1),
4250            ("[midx]\n\tversion = 1k\n", 1024),
4251        ] {
4252            assert!(
4253                matches!(
4254                    read(text),
4255                    Err(Error::Midx(MidxError::UnknownWriteVersion { version: v })) if v == version
4256                ),
4257                "{text:?}"
4258            );
4259        }
4260        assert!(matches!(
4261            read("[midx]\n\tversion = two\n"),
4262            Err(Error::Config(ConfigError::BadNumericValue { ref key, .. })) if key == "midx.version"
4263        ));
4264    }
4265
4266    #[cfg_attr(
4267        not(feature = "full-tests"),
4268        ignore = "starts system git or is heavy; run with --features full-tests"
4269    )]
4270    #[test]
4271    fn default_write_is_v1_and_unknown_version_is_rejected() {
4272        let Some((tmp, _oid)) = midx_fixture(false) else {
4273            eprintln!("SKIP: git unavailable for MIDX fixture");
4274            return;
4275        };
4276        let pack_dir = tmp.path().join(".git/objects/pack");
4277        let midx_path = pack_dir.join("multi-pack-index");
4278        write_multi_pack_index(&pack_dir).expect("default write");
4279        assert_eq!(fs::read(&midx_path).expect("read midx")[4], 1);
4280
4281        fs::remove_file(&midx_path).expect("remove midx");
4282        let err = super::write_multi_pack_index_with_options(
4283            &pack_dir,
4284            &super::WriteMultiPackIndexOptions {
4285                version: Some(3),
4286                ..Default::default()
4287            },
4288        )
4289        .expect_err("version 3");
4290        assert!(matches!(
4291            err,
4292            Error::Midx(MidxError::UnknownWriteVersion { version: 3 })
4293        ));
4294        assert!(!midx_path.exists(), "rejected write must not leave a MIDX");
4295    }
4296}