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