Skip to main content

grit_lib/
commit_graph_file.rs

1//! Parsing Git commit-graph files and Bloom filter lookup (`commit-graph.c` / `bloom.c` compatible).
2
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex};
5
6use crate::bloom::{
7    bloom_filter_contains, bloom_keyvec_for_path, BloomBuildOutcome, BloomFilterSettings,
8};
9use crate::error::Error;
10use crate::hash::verify_trailer;
11use crate::objects::{HashAlgo, ObjectId};
12use crate::odb::{hash_algo_for_objects_dir, Odb};
13use crate::pack_map::PackData;
14
15fn warn_once_for_disabled_bloom_layer(
16    caches: Option<&crate::repo_caches::RepoCaches>,
17    id: &str,
18) -> bool {
19    let key = format!("bloom-disabled:{id}");
20    caches
21        .map(|c| c.should_warn_commit_graph_once(&key))
22        .unwrap_or(true)
23}
24
25fn warn_once_for_base_chunk_too_small(
26    caches: Option<&crate::repo_caches::RepoCaches>,
27    id: &str,
28) -> bool {
29    let key = format!("base-chunk:{id}");
30    caches
31        .map(|c| c.should_warn_commit_graph_once(&key))
32        .unwrap_or(true)
33}
34
35const SIGNATURE: &[u8; 4] = b"CGPH";
36const GRAPH_VERSION: u8 = 1;
37const COMMIT_GRAPH_MIN_FILE_LEN: u64 = 28;
38
39const CHUNK_OID_FANOUT: u32 = 0x4f49_4446; // OIDF
40const CHUNK_OID_LOOKUP: u32 = 0x4f49_444c; // OIDL
41const CHUNK_COMMIT_DATA: u32 = 0x4344_4154; // CDAT
42const CHUNK_GENERATION_DATA: u32 = 0x4744_4132; // GDA2
43const CHUNK_GENERATION_DATA_OVERFLOW: u32 = 0x4744_4f32; // GDO2
44const CHUNK_EXTRA_EDGES: u32 = 0x4544_4745; // EDGE
45
46/// CDAT parent word meaning "no parent in this slot" (Git `GRAPH_PARENT_NONE`).
47const GRAPH_PARENT_NONE: u32 = 0x7000_0000;
48/// CDAT parent-2 high bit meaning "remaining parents live in the EXTRA_EDGES
49/// chunk" (octopus merge; Git `GRAPH_EXTRA_EDGES_NEEDED`).
50const GRAPH_EXTRA_EDGES_NEEDED: u32 = 0x8000_0000;
51const GRAPH_LAST_EDGE: u32 = 0x8000_0000;
52const CHUNK_BLOOM_INDEXES: u32 = 0x4249_4458; // BIDX
53const CHUNK_BLOOM_DATA: u32 = 0x4244_4154; // BDAT
54const CHUNK_BASE_GRAPHS: u32 = 0x4241_5345; // BASE
55
56const BLOOM_HEADER: usize = crate::bloom::BLOOMDATA_HEADER_LEN;
57
58/// `CORRECTED_COMMIT_DATE_OFFSET_OVERFLOW` — high bit marks GDA2 entries that index GDO2.
59const CORRECTED_COMMIT_DATE_OFFSET_OVERFLOW: u32 = 1u32 << 31;
60
61fn warn_path_for_graph_file(path: &Path) -> String {
62    let s = path.to_string_lossy();
63    if let Some(idx) = s.find(".git/") {
64        return s[idx..].replace('\\', "/");
65    }
66    s.replace('\\', "/")
67}
68
69/// One layer from `.git/objects/info/commit-graph` or `commit-graphs/<hash>.graph`.
70#[derive(Debug, Clone)]
71pub struct CommitGraphLayer {
72    /// Repository-relative or filesystem path related to the failure.
73    pub path: PathBuf,
74    file: Arc<PackData>,
75    body_len: usize,
76    num_commits: u32,
77    oid_lookup_off: usize,
78    chunk_commit_data_off: usize,
79    chunk_generation_data: Option<usize>,
80    chunk_generation_overflow: Option<usize>,
81    read_generation_data: bool,
82    chunk_bloom_indexes: Option<usize>,
83    chunk_bloom_data: Option<(usize, usize)>,
84    bloom_settings: Option<BloomFilterSettings>,
85    bloom_disabled: bool,
86    /// Size in bytes of the BASE chunk (0 if absent). Used to bounds-check the
87    /// base-graph list from the commit-graph header (Git `add_graph_to_chain`).
88    base_chunk_size: usize,
89    /// OID width in bytes implied by the header hash version (20 for SHA-1, 32 for SHA-256).
90    hash_len: usize,
91    extra_edges_off: Option<usize>,
92    extra_edges_end: Option<usize>,
93}
94
95/// OID width for a commit-graph header hash-version byte (`body[5]`).
96fn commit_graph_hash_len(hash_version: u8) -> Option<usize> {
97    HashAlgo::try_from(hash_version).ok().map(|a| a.len())
98}
99
100/// True when `h` is a lowercase/uppercase hex chain entry for `algo` (40 or 64 chars).
101fn commit_graph_chain_hash_is_valid(h: &str, algo: HashAlgo) -> bool {
102    h.len() == algo.hex_len() && h.chars().all(|c| c.is_ascii_hexdigit())
103}
104
105const OIDF_BUCKET_BYTES: usize = 256 * 4;
106
107fn commit_graph_chunk_oob() -> Error {
108    Error::CorruptObject("commit-graph chunk extends past end of file".to_owned())
109}
110
111fn commit_graph_offset_out_of_range() -> Error {
112    Error::CorruptObject("commit-graph chunk offset out of range".to_owned())
113}
114
115fn checked_add_end(start: usize, len: usize, bound: usize) -> Result<usize, Error> {
116    start
117        .checked_add(len)
118        .filter(|&end| end <= bound)
119        .ok_or_else(commit_graph_chunk_oob)
120}
121
122fn body_slice(body: &[u8], start: usize, len: usize) -> Result<&[u8], Error> {
123    let end = checked_add_end(start, len, body.len())?;
124    body.get(start..end).ok_or_else(commit_graph_chunk_oob)
125}
126
127fn parse_toc_offset(body_len: usize, raw: [u8; 8]) -> Result<usize, Error> {
128    let off = u64::from_be_bytes(raw);
129    if off > body_len as u64 {
130        return Err(commit_graph_offset_out_of_range());
131    }
132    usize::try_from(off).map_err(|_| commit_graph_offset_out_of_range())
133}
134
135fn fanout_num_commits(body: &[u8], fanout_off: usize) -> Result<u32, Error> {
136    let fanout = body_slice(body, fanout_off, OIDF_BUCKET_BYTES)?;
137    let word = fanout[255 * 4..256 * 4]
138        .try_into()
139        .map_err(|_| Error::CorruptObject("commit-graph fanout corrupt".to_owned()))?;
140    Ok(u32::from_be_bytes(word))
141}
142
143#[allow(clippy::too_many_arguments)]
144fn validate_single_layer_parent_indices(
145    body: &[u8],
146    num_commits: u32,
147    commit_data_off: usize,
148    graph_data_width: usize,
149    hash_len: usize,
150    extra_edges_off: Option<usize>,
151    toc_entries: &[(u32, usize)],
152    file_end: usize,
153) -> Result<(), Error> {
154    fn chunk_byte_range(
155        start: usize,
156        toc_entries: &[(u32, usize)],
157        file_end: usize,
158    ) -> Result<usize, Error> {
159        let mut ends: Vec<usize> = toc_entries
160            .iter()
161            .map(|&(_, o)| o)
162            .filter(|&o| o > start)
163            .collect();
164        ends.sort_unstable();
165        Ok(ends.first().copied().unwrap_or(file_end))
166    }
167
168    for lex in 0..num_commits {
169        let base = commit_data_off + lex as usize * graph_data_width;
170        let p1_off = base + hash_len;
171        let p1 =
172            u32::from_be_bytes(body[p1_off..p1_off + 4].try_into().map_err(|_| {
173                Error::CorruptObject("commit-graph commit data corrupt".to_owned())
174            })?);
175        let p2 =
176            u32::from_be_bytes(body[p1_off + 4..p1_off + 8].try_into().map_err(|_| {
177                Error::CorruptObject("commit-graph commit data corrupt".to_owned())
178            })?);
179        if p1 != GRAPH_PARENT_NONE
180            && p1 & GRAPH_EXTRA_EDGES_NEEDED == 0
181            && p1 as usize >= num_commits as usize
182        {
183            return Err(Error::CorruptObject(
184                "commit-graph invalid parent index".to_owned(),
185            ));
186        }
187        if p2 != GRAPH_PARENT_NONE {
188            if p2 & GRAPH_EXTRA_EDGES_NEEDED != 0 {
189                let start = (p2 & !GRAPH_EXTRA_EDGES_NEEDED) as usize;
190                if let Some(edge_off) = extra_edges_off {
191                    let edge_end = chunk_byte_range(edge_off, toc_entries, file_end)?;
192                    let edge_len = edge_end.saturating_sub(edge_off);
193                    if start * 4 >= edge_len {
194                        return Err(Error::CorruptObject(
195                            "commit-graph extra edge list out of range".to_owned(),
196                        ));
197                    }
198                    let mut idx = start;
199                    loop {
200                        if idx * 4 + 4 > edge_len {
201                            return Err(Error::CorruptObject(
202                                "commit-graph extra edge list corrupt".to_owned(),
203                            ));
204                        }
205                        let ev = u32::from_be_bytes(
206                            body[edge_off + idx * 4..edge_off + idx * 4 + 4]
207                                .try_into()
208                                .map_err(|_| {
209                                    Error::CorruptObject(
210                                        "commit-graph extra edge list corrupt".to_owned(),
211                                    )
212                                })?,
213                        );
214                        let parent = ev & !GRAPH_LAST_EDGE;
215                        if parent != GRAPH_PARENT_NONE
216                            && parent & GRAPH_EXTRA_EDGES_NEEDED == 0
217                            && parent as usize >= num_commits as usize
218                        {
219                            return Err(Error::CorruptObject(
220                                "commit-graph invalid parent index".to_owned(),
221                            ));
222                        }
223                        if ev & GRAPH_LAST_EDGE != 0 {
224                            break;
225                        }
226                        idx += 1;
227                    }
228                }
229            } else if p2 as usize >= num_commits as usize {
230                return Err(Error::CorruptObject(
231                    "commit-graph invalid parent index".to_owned(),
232                ));
233            }
234        }
235    }
236    Ok(())
237}
238
239#[derive(Clone, Copy, PartialEq, Eq)]
240enum CommitGraphParseMode {
241    /// Normal read path: header, chunk table, and chunk bounds only (no checksum/OID scan).
242    Open,
243    /// Full on-disk verification (checksum, ordering, generation/parent consistency).
244    Verify,
245}
246
247impl CommitGraphLayer {
248    fn body(&self) -> &[u8] {
249        &self.file[..self.body_len]
250    }
251
252    /// Map or read a commit-graph layer from disk (hot path; borrows mapped bytes).
253    fn try_open(path: PathBuf) -> Result<Self, Error> {
254        let storage = PackData::open_with_min_len(&path, COMMIT_GRAPH_MIN_FILE_LEN)?;
255        Self::try_parse_storage(path, storage, CommitGraphParseMode::Open)
256    }
257
258    /// Parse a commit-graph layer; fails if generation overflow chunk is inconsistent with GDA2.
259    pub fn try_parse(path: PathBuf, raw: Vec<u8>) -> Result<Self, Error> {
260        Self::try_parse_storage(
261            path,
262            PackData::from_owned(raw),
263            CommitGraphParseMode::Verify,
264        )
265    }
266
267    fn try_parse_storage(
268        path: PathBuf,
269        storage: Arc<PackData>,
270        mode: CommitGraphParseMode,
271    ) -> Result<Self, Error> {
272        let raw = storage.as_ref();
273        if raw.len() < 28 {
274            return Err(Error::CorruptObject(
275                "commit-graph file too small".to_owned(),
276            ));
277        }
278        // The hash version (byte 5) selects the OID width and trailing-checksum
279        // width: 1 → SHA-1 (20 bytes), 2 → SHA-256 (32 bytes).
280        let hash_len = match commit_graph_hash_len(raw[5]) {
281            Some(n) => n,
282            None => {
283                return Err(Error::CorruptObject(format!(
284                    "commit-graph version/hash not supported (version {} hash {})",
285                    raw[4], raw[5]
286                )))
287            }
288        };
289        let algo = HashAlgo::try_from(raw[5]).map_err(|_| {
290            Error::CorruptObject(format!(
291                "commit-graph version/hash not supported (version {} hash {})",
292                raw[4], raw[5]
293            ))
294        })?;
295        if mode == CommitGraphParseMode::Verify {
296            verify_trailer(algo, raw)
297                .map_err(|_| Error::CorruptObject("commit-graph incorrect checksum".to_owned()))?;
298        }
299        let body_len = raw.len().saturating_sub(hash_len);
300        let body = &raw[..body_len];
301        if body.len() < 8 || &body[0..4] != SIGNATURE {
302            return Err(Error::CorruptObject(
303                "commit-graph has bad signature".to_owned(),
304            ));
305        }
306        if body[4] != GRAPH_VERSION {
307            return Err(Error::CorruptObject(format!(
308                "commit-graph version/hash not supported (version {} hash {})",
309                body[4], body[5]
310            )));
311        }
312        let num_chunks = body[6] as usize;
313        let toc_start: usize = 8;
314        let toc_end = num_chunks
315            .checked_add(1)
316            .and_then(|n| n.checked_mul(12))
317            .and_then(|n| toc_start.checked_add(n))
318            .ok_or_else(|| {
319                Error::CorruptObject("commit-graph truncated at chunk table".to_owned())
320            })?;
321        if body.len() < toc_end {
322            return Err(Error::CorruptObject(
323                "commit-graph truncated at chunk table".to_owned(),
324            ));
325        }
326
327        let mut fanout_off = None;
328        let mut oid_lookup_off = None;
329        let mut commit_data_off = None;
330        let mut generation_off = None;
331        let mut generation_overflow_off = None;
332        let mut bloom_idx_off = None;
333        let mut bloom_data_range = None;
334        let mut base_graphs_off = None;
335        let mut extra_edges_off = None;
336        let mut extra_edges_end = None;
337        let mut chunk_offsets: Vec<usize> = Vec::new();
338        let mut toc_entries: Vec<(u32, usize)> = Vec::with_capacity(num_chunks);
339
340        for i in 0..num_chunks {
341            let e = toc_start + i * 12;
342            let id = u32::from_be_bytes(
343                body[e..e + 4]
344                    .try_into()
345                    .map_err(|_| Error::CorruptObject("commit-graph bad TOC".to_owned()))?,
346            );
347            let off = parse_toc_offset(
348                body.len(),
349                body[e + 4..e + 12]
350                    .try_into()
351                    .map_err(|_| Error::CorruptObject("commit-graph bad TOC".to_owned()))?,
352            )?;
353            toc_entries.push((id, off));
354            chunk_offsets.push(off);
355            match id {
356                CHUNK_OID_FANOUT => fanout_off = Some(off),
357                CHUNK_OID_LOOKUP => oid_lookup_off = Some(off),
358                CHUNK_COMMIT_DATA => commit_data_off = Some(off),
359                CHUNK_GENERATION_DATA => generation_off = Some(off),
360                CHUNK_GENERATION_DATA_OVERFLOW => generation_overflow_off = Some(off),
361                CHUNK_BLOOM_INDEXES => bloom_idx_off = Some(off),
362                CHUNK_EXTRA_EDGES => {
363                    extra_edges_off = Some(off);
364                    extra_edges_end = Some(if i + 1 < num_chunks {
365                        let e2 = toc_start
366                            .checked_add((i + 1).checked_mul(12).ok_or_else(|| {
367                                Error::CorruptObject("commit-graph bad TOC".to_owned())
368                            })?)
369                            .ok_or_else(|| {
370                                Error::CorruptObject("commit-graph bad TOC".to_owned())
371                            })?;
372                        parse_toc_offset(
373                            body.len(),
374                            body[e2 + 4..e2 + 12].try_into().map_err(|_| {
375                                Error::CorruptObject("commit-graph bad TOC".to_owned())
376                            })?,
377                        )?
378                    } else {
379                        let term = toc_start
380                            .checked_add(num_chunks.checked_mul(12).ok_or_else(|| {
381                                Error::CorruptObject("commit-graph bad TOC".to_owned())
382                            })?)
383                            .ok_or_else(|| {
384                                Error::CorruptObject("commit-graph bad TOC".to_owned())
385                            })?;
386                        parse_toc_offset(
387                            body.len(),
388                            body[term + 4..term + 12].try_into().map_err(|_| {
389                                Error::CorruptObject("commit-graph bad TOC".to_owned())
390                            })?,
391                        )?
392                    });
393                }
394                CHUNK_BASE_GRAPHS => base_graphs_off = Some(off),
395                CHUNK_BLOOM_DATA => {
396                    let end = if i + 1 < num_chunks {
397                        let e2 = toc_start
398                            .checked_add((i + 1).checked_mul(12).ok_or_else(|| {
399                                Error::CorruptObject("commit-graph bad TOC".to_owned())
400                            })?)
401                            .ok_or_else(|| {
402                                Error::CorruptObject("commit-graph bad TOC".to_owned())
403                            })?;
404                        parse_toc_offset(
405                            body.len(),
406                            body[e2 + 4..e2 + 12].try_into().map_err(|_| {
407                                Error::CorruptObject("commit-graph bad TOC".to_owned())
408                            })?,
409                        )?
410                    } else {
411                        let term = toc_start
412                            .checked_add(num_chunks.checked_mul(12).ok_or_else(|| {
413                                Error::CorruptObject("commit-graph bad TOC".to_owned())
414                            })?)
415                            .ok_or_else(|| {
416                                Error::CorruptObject("commit-graph bad TOC".to_owned())
417                            })?;
418                        parse_toc_offset(
419                            body.len(),
420                            body[term + 4..term + 12].try_into().map_err(|_| {
421                                Error::CorruptObject("commit-graph bad TOC".to_owned())
422                            })?,
423                        )?
424                    };
425                    let len = end.checked_sub(off).ok_or_else(|| {
426                        Error::CorruptObject("commit-graph chunk layout invalid".to_owned())
427                    })?;
428                    bloom_data_range = Some((off, len));
429                }
430                _ => {}
431            }
432        }
433        let file_end_pos = toc_start
434            .checked_add(
435                num_chunks
436                    .checked_mul(12)
437                    .ok_or_else(|| Error::CorruptObject("commit-graph bad file end".to_owned()))?,
438            )
439            .and_then(|t| t.checked_add(4))
440            .ok_or_else(|| Error::CorruptObject("commit-graph bad file end".to_owned()))?;
441        let file_end = parse_toc_offset(
442            body.len(),
443            body[file_end_pos..file_end_pos + 8]
444                .try_into()
445                .map_err(|_| Error::CorruptObject("commit-graph bad file end".to_owned()))?,
446        )?;
447        if file_end > body.len() {
448            return Err(commit_graph_offset_out_of_range());
449        }
450        chunk_offsets.push(file_end);
451        chunk_offsets.sort_unstable();
452        chunk_offsets.dedup();
453
454        fn chunk_byte_range(
455            start: usize,
456            toc_entries: &[(u32, usize)],
457            file_end: usize,
458        ) -> Result<usize, Error> {
459            let mut ends: Vec<usize> = toc_entries
460                .iter()
461                .map(|&(_, o)| o)
462                .filter(|&o| o > start)
463                .collect();
464            ends.sort_unstable();
465            let end = ends.first().copied().unwrap_or(file_end);
466            if end < start {
467                return Err(Error::CorruptObject(
468                    "commit-graph chunk layout invalid".to_owned(),
469                ));
470            }
471            Ok(end)
472        }
473
474        if mode == CommitGraphParseMode::Verify {
475            if let Some(gda) = generation_off {
476                let gda_end = chunk_byte_range(gda, &toc_entries, file_end)?;
477                let gda_len = gda_end.saturating_sub(gda);
478                let fo = fanout_off.ok_or_else(|| {
479                    Error::CorruptObject("commit-graph missing fanout".to_owned())
480                })?;
481                let num_commits_probe = fanout_num_commits(body, fo)?;
482                let expected = num_commits_probe as usize * 4;
483                if gda_len < expected {
484                    return Err(Error::CorruptObject(
485                        "commit-graph generation data chunk is too small".to_owned(),
486                    ));
487                }
488                let gda_slice = body.get(gda..gda + expected).ok_or_else(|| {
489                    Error::CorruptObject("commit-graph generation data OOB".to_owned())
490                })?;
491                let mut max_overflow_idx: Option<u32> = None;
492                for w in 0..num_commits_probe as usize {
493                    let v = u32::from_be_bytes(gda_slice[w * 4..w * 4 + 4].try_into().map_err(
494                        |_| Error::CorruptObject("commit-graph GDA2 corrupt".to_owned()),
495                    )?);
496                    if v & CORRECTED_COMMIT_DATE_OFFSET_OVERFLOW != 0 {
497                        let pos = v ^ CORRECTED_COMMIT_DATE_OFFSET_OVERFLOW;
498                        max_overflow_idx = Some(match max_overflow_idx {
499                            None => pos,
500                            Some(m) => m.max(pos),
501                        });
502                    }
503                }
504                if let Some(pos) = max_overflow_idx {
505                    let Some(gdo_start) = generation_overflow_off else {
506                        return Err(Error::CorruptObject(
507                            "commit-graph requires overflow generation data but has none"
508                                .to_owned(),
509                        ));
510                    };
511                    let gdo_end = chunk_byte_range(gdo_start, &toc_entries, file_end)?;
512                    let overflow_bytes = gdo_end.saturating_sub(gdo_start);
513                    let n_slots = overflow_bytes / 8;
514                    if n_slots <= pos as usize {
515                        return Err(Error::CorruptObject(
516                            "commit-graph overflow generation data is too small".to_owned(),
517                        ));
518                    }
519                }
520            }
521        }
522        let bidx_len = bloom_idx_off.and_then(|b| {
523            chunk_offsets
524                .iter()
525                .find(|&&o| o > b)
526                .map(|&next| next.saturating_sub(b))
527        });
528
529        let fanout_off = fanout_off.ok_or_else(|| {
530            Error::CorruptObject("commit-graph missing OID fanout chunk".to_owned())
531        })?;
532        let oid_lookup_off = oid_lookup_off.ok_or_else(|| {
533            Error::CorruptObject("commit-graph missing OID lookup chunk".to_owned())
534        })?;
535        let commit_data_off = commit_data_off.ok_or_else(|| {
536            Error::CorruptObject("commit-graph missing commit data chunk".to_owned())
537        })?;
538        checked_add_end(oid_lookup_off, 4, body.len())?;
539        let num_commits = fanout_num_commits(body, fanout_off)?;
540        let oid_lookup_bytes = (num_commits as usize)
541            .checked_mul(hash_len)
542            .ok_or_else(commit_graph_chunk_oob)?;
543        checked_add_end(oid_lookup_off, oid_lookup_bytes, body.len())?;
544        let graph_data_width = hash_len
545            .checked_add(16)
546            .ok_or_else(commit_graph_chunk_oob)?;
547        let cdat_bytes = (num_commits as usize)
548            .checked_mul(graph_data_width)
549            .ok_or_else(commit_graph_chunk_oob)?;
550        checked_add_end(commit_data_off, cdat_bytes, body.len())?;
551
552        if mode == CommitGraphParseMode::Verify {
553            for bucket in 0usize..255 {
554                let a_off = fanout_off
555                    .checked_add(bucket.checked_mul(4).ok_or_else(commit_graph_chunk_oob)?)
556                    .ok_or_else(commit_graph_chunk_oob)?;
557                let a =
558                    u32::from_be_bytes(body_slice(body, a_off, 4)?.try_into().map_err(|_| {
559                        Error::CorruptObject("commit-graph fanout corrupt".to_owned())
560                    })?);
561                let b_off = fanout_off
562                    .checked_add(
563                        (bucket + 1)
564                            .checked_mul(4)
565                            .ok_or_else(commit_graph_chunk_oob)?,
566                    )
567                    .ok_or_else(commit_graph_chunk_oob)?;
568                let b =
569                    u32::from_be_bytes(body_slice(body, b_off, 4)?.try_into().map_err(|_| {
570                        Error::CorruptObject("commit-graph fanout corrupt".to_owned())
571                    })?);
572                if a > b {
573                    return Err(Error::CorruptObject(
574                        "commit-graph fanout value non-monotonic".to_owned(),
575                    ));
576                }
577            }
578
579            for lex in 1..num_commits {
580                let off_a = oid_lookup_off
581                    .checked_add(
582                        (lex as usize - 1)
583                            .checked_mul(hash_len)
584                            .ok_or_else(commit_graph_chunk_oob)?,
585                    )
586                    .ok_or_else(commit_graph_chunk_oob)?;
587                let off_b = oid_lookup_off
588                    .checked_add(
589                        (lex as usize)
590                            .checked_mul(hash_len)
591                            .ok_or_else(commit_graph_chunk_oob)?,
592                    )
593                    .ok_or_else(commit_graph_chunk_oob)?;
594                let slice_a = body_slice(body, off_a, hash_len)?;
595                let slice_b = body_slice(body, off_b, hash_len)?;
596                if slice_a >= slice_b {
597                    return Err(Error::CorruptObject(
598                        "commit-graph incorrect OID order".to_owned(),
599                    ));
600                }
601            }
602
603            if base_graphs_off.is_none() {
604                validate_single_layer_parent_indices(
605                    body,
606                    num_commits,
607                    commit_data_off,
608                    graph_data_width,
609                    hash_len,
610                    extra_edges_off,
611                    &toc_entries,
612                    file_end,
613                )?;
614            }
615        }
616
617        let read_generation_data = generation_off.is_some();
618        let mut bloom_settings = None;
619        let mut chunk_bloom_data = None;
620        if let (Some(_bidx), Some((bdat_off, bdat_len))) = (bloom_idx_off, bloom_data_range) {
621            if bdat_len < BLOOM_HEADER {
622            } else if bdat_off + bdat_len <= body.len() {
623                let hdr = &body[bdat_off..bdat_off + BLOOM_HEADER];
624                let hash_version: [u8; 4] = hdr[0..4]
625                    .try_into()
626                    .map_err(|_| Error::CorruptObject("Bloom header corrupt".to_owned()))?;
627                let num_hashes: [u8; 4] = hdr[4..8]
628                    .try_into()
629                    .map_err(|_| Error::CorruptObject("Bloom header corrupt".to_owned()))?;
630                let bits_per_entry: [u8; 4] = hdr[8..12]
631                    .try_into()
632                    .map_err(|_| Error::CorruptObject("Bloom header corrupt".to_owned()))?;
633                bloom_settings = Some(BloomFilterSettings {
634                    hash_version: u32::from_be_bytes(hash_version),
635                    num_hashes: u32::from_be_bytes(num_hashes),
636                    bits_per_entry: u32::from_be_bytes(bits_per_entry),
637                    max_changed_paths: 512,
638                });
639                chunk_bloom_data = Some((bdat_off, bdat_len));
640            }
641        }
642
643        let bloom_indexes_ok = if let (Some(bidx), Some(bsize)) = (bloom_idx_off, bidx_len) {
644            !(bsize / 4 != num_commits as usize || bidx + bsize > body.len())
645        } else {
646            false
647        };
648        let bloom_pair_ok = bloom_settings.is_some()
649            && chunk_bloom_data.is_some()
650            && bloom_indexes_ok
651            && chunk_bloom_data.is_some_and(|(_, len)| len >= BLOOM_HEADER);
652
653        let (chunk_bloom_indexes, bloom_settings) = if bloom_pair_ok {
654            (bloom_idx_off, bloom_settings)
655        } else {
656            (None, None)
657        };
658        let chunk_bloom_data = if bloom_pair_ok {
659            chunk_bloom_data
660        } else {
661            None
662        };
663
664        let base_chunk_size = match base_graphs_off {
665            Some(off) => {
666                let end = chunk_byte_range(off, &toc_entries, file_end)?;
667                end.saturating_sub(off)
668            }
669            None => 0,
670        };
671
672        Ok(Self {
673            path,
674            file: storage,
675            body_len,
676            num_commits,
677            oid_lookup_off,
678            chunk_commit_data_off: commit_data_off,
679            chunk_generation_data: generation_off,
680            chunk_generation_overflow: generation_overflow_off,
681            read_generation_data,
682            chunk_bloom_indexes,
683            chunk_bloom_data,
684            bloom_settings,
685            bloom_disabled: false,
686            base_chunk_size,
687            hash_len,
688            extra_edges_off,
689            extra_edges_end,
690        })
691    }
692
693    fn parse(path: PathBuf, raw: Vec<u8>) -> Option<Self> {
694        Self::try_parse(path, raw).ok()
695    }
696
697    fn oid_at_lex(&self, lex_index: u32) -> Option<ObjectId> {
698        if lex_index >= self.num_commits {
699            return None;
700        }
701        let off = self.oid_lookup_off + lex_index as usize * self.hash_len;
702        ObjectId::from_bytes(self.body().get(off..off + self.hash_len)?).ok()
703    }
704
705    /// Decode the CDAT (commit-data) record at `lex_index` into
706    /// `(parent1_word, parent2_word, committer_time)`. The parent words are raw
707    /// graph-position encodings (see [`GRAPH_PARENT_NONE`] /
708    /// [`GRAPH_EXTRA_EDGES_NEEDED`]); the time is the low 34 bits of the trailing
709    /// generation/commit-time field. Each record is `hash_len + 16` bytes:
710    /// root-tree OID, two 4-byte parent words, then an 8-byte gen/time field.
711    fn parent_global_positions(&self, lex_index: u32) -> Option<Vec<u32>> {
712        let (p1, p2, _) = self.cdat_record(lex_index)?;
713        let mut out = Vec::new();
714        if p1 == GRAPH_PARENT_NONE {
715            return Some(out);
716        }
717        if p1 & GRAPH_EXTRA_EDGES_NEEDED != 0 {
718            return None;
719        }
720        out.push(p1);
721        if p2 == GRAPH_PARENT_NONE {
722            return Some(out);
723        }
724        if p2 & GRAPH_EXTRA_EDGES_NEEDED != 0 {
725            let start = (p2 & !GRAPH_EXTRA_EDGES_NEEDED) as usize;
726            let edge_off = self.extra_edges_off?;
727            let edge_end = self.extra_edges_end?;
728            let edge_len = edge_end.saturating_sub(edge_off);
729            if start.saturating_mul(4) >= edge_len {
730                return None;
731            }
732            let mut idx = start;
733            loop {
734                if idx.saturating_mul(4).saturating_add(4) > edge_len {
735                    return None;
736                }
737                let ev = u32::from_be_bytes(
738                    self.body()[edge_off + idx * 4..edge_off + idx * 4 + 4]
739                        .try_into()
740                        .ok()?,
741                );
742                let parent = ev & !GRAPH_LAST_EDGE;
743                if parent != GRAPH_PARENT_NONE && parent & GRAPH_EXTRA_EDGES_NEEDED == 0 {
744                    out.push(parent);
745                }
746                if ev & GRAPH_LAST_EDGE != 0 {
747                    break;
748                }
749                idx += 1;
750            }
751            return Some(out);
752        }
753        out.push(p2);
754        Some(out)
755    }
756
757    /// Root tree OID stored in the CDAT record for `lex_index`.
758    fn cdat_root_tree(&self, lex_index: u32) -> Option<ObjectId> {
759        if lex_index >= self.num_commits {
760            return None;
761        }
762        let base = self.chunk_commit_data_off + lex_index as usize * (self.hash_len + 16);
763        ObjectId::from_bytes(self.body().get(base..base + self.hash_len)?).ok()
764    }
765
766    fn cdat_record(&self, lex_index: u32) -> Option<(u32, u32, i64)> {
767        if lex_index >= self.num_commits {
768            return None;
769        }
770        let base = self.chunk_commit_data_off + lex_index as usize * (self.hash_len + 16);
771        let p1_off = base + self.hash_len;
772        let p1 = u32::from_be_bytes(self.body().get(p1_off..p1_off + 4)?.try_into().ok()?);
773        let p2 = u32::from_be_bytes(self.body().get(p1_off + 4..p1_off + 8)?.try_into().ok()?);
774        let gen_time =
775            u64::from_be_bytes(self.body().get(p1_off + 8..p1_off + 16)?.try_into().ok()?);
776        let commit_time = (gen_time & 0x3_FFFF_FFFF) as i64;
777        Some((p1, p2, commit_time))
778    }
779
780    /// Corrected commit date (generation v2) from GDA2/GDO2, when the layer stores generation data.
781    fn corrected_commit_generation(&self, lex_index: u32) -> Option<u64> {
782        if !self.read_generation_data {
783            return None;
784        }
785        let (_, _, commit_time) = self.cdat_record(lex_index)?;
786        let gda_off = self.chunk_generation_data?;
787        if lex_index >= self.num_commits {
788            return None;
789        }
790        let start = gda_off + lex_index as usize * 4;
791        let word = u32::from_be_bytes(self.body().get(start..start + 4)?.try_into().ok()?);
792        let offset = if word & CORRECTED_COMMIT_DATE_OFFSET_OVERFLOW != 0 {
793            let slot = (word ^ CORRECTED_COMMIT_DATE_OFFSET_OVERFLOW) as usize;
794            let gdo_off = self.chunk_generation_overflow?;
795            let off = gdo_off + slot * 8;
796            let hi = u32::from_be_bytes(self.body().get(off..off + 4)?.try_into().ok()?);
797            let lo = u32::from_be_bytes(self.body().get(off + 4..off + 8)?.try_into().ok()?);
798            (u64::from(hi) << 32) | u64::from(lo)
799        } else {
800            u64::from(word)
801        };
802        Some((commit_time as u64).saturating_add(offset))
803    }
804
805    fn bsearch_oid(&self, oid: &ObjectId) -> Option<u32> {
806        let mut lo = 0u32;
807        let mut hi = self.num_commits;
808        let bytes = oid.as_bytes();
809        while lo < hi {
810            let mid = (lo + hi) / 2;
811            let off = self.oid_lookup_off + mid as usize * self.hash_len;
812            let slice = &self.body()[off..off + self.hash_len];
813            match slice.cmp(bytes) {
814                std::cmp::Ordering::Less => lo = mid + 1,
815                std::cmp::Ordering::Greater => hi = mid,
816                std::cmp::Ordering::Equal => return Some(mid),
817            }
818        }
819        None
820    }
821
822    fn disable_bloom(&mut self) {
823        self.chunk_bloom_indexes = None;
824        self.chunk_bloom_data = None;
825        self.bloom_settings = None;
826        self.bloom_disabled = true;
827    }
828
829    fn layer_display_id(&self) -> String {
830        self.path
831            .file_stem()
832            .and_then(|s| s.to_str())
833            .map(|s| s.strip_prefix("graph-").unwrap_or(s).to_string())
834            .unwrap_or_else(|| "commit-graph".to_string())
835    }
836
837    fn bloom_filter_slice(&self, lex_index: u32) -> Option<&[u8]> {
838        let _settings = self.bloom_settings.as_ref()?;
839        let bidx_base = self.chunk_bloom_indexes?;
840        let (bdat_off, bdat_total) = self.chunk_bloom_data?;
841        let _graph_warn = warn_path_for_graph_file(self.path.as_path());
842        if lex_index >= self.num_commits {
843            return None;
844        }
845        let payload_len = bdat_total.saturating_sub(BLOOM_HEADER);
846        let end_rel = u32::from_be_bytes(
847            self.body()[bidx_base + lex_index as usize * 4..bidx_base + lex_index as usize * 4 + 4]
848                .try_into()
849                .ok()?,
850        ) as usize;
851        let start_rel = if lex_index == 0 {
852            0usize
853        } else {
854            u32::from_be_bytes(
855                self.body()[bidx_base + (lex_index as usize - 1) * 4
856                    ..bidx_base + (lex_index as usize - 1) * 4 + 4]
857                    .try_into()
858                    .ok()?,
859            ) as usize
860        };
861        // Git checks both offsets for being out of range *before* comparing them
862        // (`load_bloom_filter_from_graph`: two `check_bloom_offset` calls joined by
863        // `||`, then the decreasing-offset check). The end offset (at this position)
864        // is checked first, short-circuiting the start offset (reported one position
865        // back) when it fails.
866        let max_payload = payload_len;
867        if end_rel > max_payload {
868            return None;
869        }
870        if start_rel > max_payload {
871            return None;
872        }
873        if end_rel < start_rel {
874            return None;
875        }
876        let data_base = bdat_off + BLOOM_HEADER;
877        let abs_start = data_base + start_rel;
878        let abs_end = data_base + end_rel;
879        if abs_end > bdat_off + bdat_total || abs_start > abs_end {
880            return None;
881        }
882        Some(&self.body()[abs_start..abs_end])
883    }
884}
885
886/// Result of consulting Bloom filters before running a tree diff (matches `revision.c`).
887#[derive(Debug, Clone, Copy, PartialEq, Eq)]
888pub enum BloomPrecheck {
889    /// No commit-graph, pathspecs disallow Bloom, or wrong parent count — no statistics.
890    Inapplicable,
891    /// Commit not in graph or generation unavailable — skip Bloom (`-1` without `filter_not_present`).
892    NotInGraph,
893    /// Bloom filter missing or unusable (`filter_not_present` in Git).
894    FilterNotPresent,
895    /// Bloom says path cannot be in this commit (`definitely_not`).
896    DefinitelyNot,
897    /// Bloom says maybe — caller must run diff and may count `false_positive`.
898    Maybe,
899}
900
901/// Counters for `GIT_TRACE2_PERF` Bloom statistics (`revision.c` `trace2_bloom_filter_statistics_atexit`).
902#[derive(Debug, Default, Clone)]
903pub struct BloomWalkStats {
904    /// Filter not present for this item.
905    pub filter_not_present: u32,
906    /// Maybe for this item.
907    pub maybe: u32,
908    /// Definitely not for this item.
909    pub definitely_not: u32,
910    /// False positive for this item.
911    pub false_positive: u32,
912}
913
914impl BloomWalkStats {
915    /// Record precheck.
916    pub fn record_precheck(&mut self, pre: BloomPrecheck) {
917        match pre {
918            BloomPrecheck::Inapplicable | BloomPrecheck::NotInGraph => {}
919            BloomPrecheck::FilterNotPresent => self.filter_not_present += 1,
920            BloomPrecheck::DefinitelyNot => self.definitely_not += 1,
921            BloomPrecheck::Maybe => self.maybe += 1,
922        }
923    }
924
925    /// Record false positive.
926    pub fn record_false_positive(&mut self) {
927        self.false_positive += 1;
928    }
929}
930
931/// Shared stats handle for [`crate::rev_list::RevListOptions::bloom_stats`].
932pub type BloomWalkStatsHandle = Arc<Mutex<BloomWalkStats>>;
933
934/// Loaded commit-graph chain (newest layer first, matching `commit-graph-chain` file order).
935#[derive(Debug, Clone)]
936pub struct CommitGraphChain {
937    layers: Vec<CommitGraphLayer>,
938}
939
940/// Test-only constructor (tip-first layer order).
941#[doc(hidden)]
942pub fn commit_graph_chain_from_layers_for_test(layers: Vec<CommitGraphLayer>) -> CommitGraphChain {
943    CommitGraphChain { layers }
944}
945
946impl CommitGraphChain {
947    /// Bloom settings from the newest layer, if that layer carries Bloom data.
948    #[must_use]
949    pub fn top_layer_bloom_settings(&self) -> Option<BloomFilterSettings> {
950        self.layers.first()?.bloom_settings
951    }
952
953    /// Total commits across all layers (Git `num_commits_in_base` offset for new layers).
954    #[must_use]
955    pub fn total_commits(&self) -> u32 {
956        self.layers.iter().map(|l| l.num_commits).sum()
957    }
958
959    /// Layer file paths from oldest base to newest (reverse of chain file order).
960    #[must_use]
961    pub fn layer_paths_oldest_first(&self) -> Vec<PathBuf> {
962        self.layers.iter().rev().map(|l| l.path.clone()).collect()
963    }
964
965    /// Number of layers in the chain.
966    #[must_use]
967    pub fn num_layers(&self) -> usize {
968        self.layers.len()
969    }
970
971    /// Commit counts per layer, tip-first (layer 0 is the newest tip).
972    #[must_use]
973    pub fn layer_commit_counts_tip_first(&self) -> Vec<u32> {
974        self.layers.iter().map(|l| l.num_commits).collect()
975    }
976
977    /// Whether each layer carries a generation-data (GDA2) chunk, tip-first.
978    #[must_use]
979    pub fn layer_has_generation_data_tip_first(&self) -> Vec<bool> {
980        self.layers
981            .iter()
982            .map(|l| l.chunk_generation_data.is_some())
983            .collect()
984    }
985
986    /// Layer hex hashes (from `graph-<hash>.graph` file stem), tip-first.
987    #[must_use]
988    pub fn layer_hashes_tip_first(&self) -> Vec<String> {
989        self.layers.iter().map(|l| l.layer_display_id()).collect()
990    }
991
992    /// Source object directory each layer was loaded from (its `.git/objects`),
993    /// tip-first. Derived from the layer path: `<objdir>/info/commit-graphs/graph-*.graph`
994    /// or `<objdir>/info/commit-graph`.
995    #[must_use]
996    pub fn layer_object_dirs_tip_first(&self) -> Vec<PathBuf> {
997        self.layers
998            .iter()
999            .map(|l| {
1000                // .../objects/info/commit-graphs/graph-X.graph  -> .../objects
1001                // .../objects/info/commit-graph                 -> .../objects
1002                let p = l.path.as_path();
1003                let info = if p
1004                    .parent()
1005                    .and_then(|d| d.file_name())
1006                    .map(|n| n == "commit-graphs")
1007                    .unwrap_or(false)
1008                {
1009                    p.parent().and_then(|d| d.parent())
1010                } else {
1011                    p.parent()
1012                };
1013                info.and_then(|d| d.parent())
1014                    .map(Path::to_path_buf)
1015                    .unwrap_or_else(|| PathBuf::from("."))
1016            })
1017            .collect()
1018    }
1019
1020    /// A sub-chain made of the layers at tip-first indices `start..end`
1021    /// (so `start` becomes the new tip). Used by the writer when only some base
1022    /// layers are kept after a split merge.
1023    #[must_use]
1024    pub fn sub_chain_tip_first(&self, start: usize, end: usize) -> Option<Self> {
1025        let end = end.min(self.layers.len());
1026        if start >= end {
1027            return None;
1028        }
1029        Some(Self {
1030            layers: self.layers[start..end].to_vec(),
1031        })
1032    }
1033
1034    /// All commit OIDs in one layer (by tip-first index), in lexicographic order.
1035    #[must_use]
1036    pub fn layer_oids(&self, tip_first_idx: usize) -> Vec<ObjectId> {
1037        let Some(layer) = self.layers.get(tip_first_idx) else {
1038            return Vec::new();
1039        };
1040        let mut out = Vec::with_capacity(layer.num_commits as usize);
1041        for i in 0..layer.num_commits {
1042            if let Some(oid) = layer.oid_at_lex(i) {
1043                out.push(oid);
1044            }
1045        }
1046        out
1047    }
1048
1049    /// Load from `objects/info/commit-graph` or `objects/info/commit-graphs/commit-graph-chain`.
1050    ///
1051    /// Returns `Ok(None)` when no commit-graph exists. Corrupt graphs (including invalid GDO2)
1052    /// return [`Err`].
1053    pub fn try_load(objects_dir: &Path) -> Result<Option<Self>, Error> {
1054        Self::try_load_with_caches(objects_dir, None)
1055    }
1056
1057    /// Load a commit-graph chain, deduplicating warnings through `caches` when provided.
1058    pub fn try_load_with_caches(
1059        objects_dir: &Path,
1060        caches: Option<&crate::repo_caches::RepoCaches>,
1061    ) -> Result<Option<Self>, Error> {
1062        let info = objects_dir.join("info");
1063        let chain_path = info.join("commit-graphs").join("commit-graph-chain");
1064        if chain_path.is_file() {
1065            let algo =
1066                hash_algo_for_objects_dir(&crate::environment::Environment::empty(), objects_dir);
1067            let content = std::fs::read_to_string(&chain_path).map_err(Error::from)?;
1068            let mut layers = Vec::new();
1069            for line in content.lines() {
1070                let h = line.trim();
1071                if !commit_graph_chain_hash_is_valid(h, algo) {
1072                    continue;
1073                }
1074                let graph_path = info.join("commit-graphs").join(format!("graph-{h}.graph"));
1075                let layer = CommitGraphLayer::try_open(graph_path)?;
1076                // `add_graph_to_chain`: a layer that declares N base graphs must
1077                // carry a BASE chunk large enough to hold N hashes. If it is too
1078                // small, Git warns and refuses to add this layer (and anything
1079                // above it) to the chain, falling back to the object database for
1080                // those commits. `layers.len()` here is the number of base layers
1081                // already loaded below this one.
1082                let n = layers.len();
1083                if n > 0 && layer.base_chunk_size / layer.hash_len < n {
1084                    warn_once_for_base_chunk_too_small(caches, &layer.layer_display_id());
1085                    break;
1086                }
1087                layers.push(layer);
1088            }
1089            if layers.is_empty() {
1090                return Ok(None);
1091            }
1092            // The on-disk chain file lists layers base-first (Git order: line 1
1093            // is the base graph, the last line is the tip). Grit's internal
1094            // representation is tip-first, so reverse after reading.
1095            layers.reverse();
1096            let mut chain = Self { layers };
1097            chain.validate_bloom_compatibility(caches);
1098            return Ok(Some(chain));
1099        }
1100        let single = info.join("commit-graph");
1101        if single.is_file() {
1102            let layer = CommitGraphLayer::try_open(single.clone())?;
1103            let mut chain = Self {
1104                layers: vec![layer],
1105            };
1106            chain.validate_bloom_compatibility(caches);
1107            return Ok(Some(chain));
1108        }
1109        Ok(None)
1110    }
1111
1112    /// Like [`Self::try_load`] but ignores parse errors (returns `None`).
1113    pub fn load(objects_dir: &Path) -> Option<Self> {
1114        Self::try_load(objects_dir).ok().flatten()
1115    }
1116
1117    /// Full commit-graph verification (checksum, OID order, generation/parent checks).
1118    ///
1119    /// Mirrors `git commit-graph verify` — used by fsck, not the hot read path.
1120    ///
1121    /// # Errors
1122    ///
1123    /// Returns [`Error::CorruptObject`] when any layer fails verification.
1124    pub fn verify_on_disk(objects_dir: &Path) -> Result<(), Error> {
1125        let Some(chain) = Self::try_load(objects_dir)? else {
1126            return Ok(());
1127        };
1128        for path in chain.layer_paths_oldest_first() {
1129            let raw = std::fs::read(&path).map_err(Error::from)?;
1130            CommitGraphLayer::try_parse(path, raw)?;
1131        }
1132        Ok(())
1133    }
1134
1135    /// Load a split commit-graph chain that may live in (and reference layers across) an
1136    /// alternate object directory.
1137    ///
1138    /// Git's commit-graph chain is owned by exactly one object directory, but its layer
1139    /// `graph-<hash>.graph` files may be split between that directory and the alternate(s) it
1140    /// inherits from. This is the situation created by a local clone whose `info/alternates`
1141    /// points at a source repository that already has a split chain: a new `commit-graph write
1142    /// --split` produces a chain file (in the local object dir) that lists the alternate's base
1143    /// layer hashes followed by the locally-written tip layer.
1144    ///
1145    /// The chain file itself is taken from `objects_dir` if present, otherwise from the first
1146    /// `alt_dirs` entry that has one. Each referenced `graph-<hash>.graph` layer is then resolved
1147    /// by searching `objects_dir` first and each alternate in turn.
1148    ///
1149    /// # Parameters
1150    /// - `objects_dir`: the primary object directory (e.g. the local `.git/objects`).
1151    /// - `alt_dirs`: alternate object directories, in search order.
1152    ///
1153    /// # Returns
1154    /// `Ok(None)` when no chain/single graph is found in any directory; `Ok(Some(_))` on success.
1155    ///
1156    /// # Errors
1157    /// Returns an error if a referenced layer file is missing/unreadable or fails to parse.
1158    pub fn try_load_across(
1159        objects_dir: &Path,
1160        alt_dirs: &[PathBuf],
1161    ) -> Result<Option<Self>, Error> {
1162        Self::try_load_across_with_caches(objects_dir, alt_dirs, None)
1163    }
1164
1165    /// Like [`Self::try_load_across`] but reuses parsed commit-graph caches when provided.
1166    ///
1167    /// # Errors
1168    ///
1169    /// Returns an error if a referenced layer file is missing, unreadable, or fails to parse.
1170    pub fn try_load_across_with_caches(
1171        objects_dir: &Path,
1172        alt_dirs: &[PathBuf],
1173        caches: Option<&crate::repo_caches::RepoCaches>,
1174    ) -> Result<Option<Self>, Error> {
1175        let layer_dir = |dir: &Path| dir.join("info").join("commit-graphs");
1176        let resolve_layer = |hash: &str| -> Option<PathBuf> {
1177            let name = format!("graph-{hash}.graph");
1178            let local = layer_dir(objects_dir).join(&name);
1179            if local.is_file() {
1180                return Some(local);
1181            }
1182            alt_dirs
1183                .iter()
1184                .map(|d| layer_dir(d).join(&name))
1185                .find(|p| p.is_file())
1186        };
1187
1188        // A split write only chains on an existing *split chain*. Precedence for the chain owner:
1189        //   1. the local dir's chain file (its layers may live across the alternate),
1190        //   2. the local dir's single (non-split) `info/commit-graph` (Git migrates it),
1191        //   3. a *chain file* in an alternate.
1192        // An alternate's single (non-split) graph file is never used as a base — Git refuses to
1193        // base a chain on a plain commit-graph file (t5324 "fork and fail to base a chain on a
1194        // commit-graph file").
1195        let local_chain = layer_dir(objects_dir).join("commit-graph-chain");
1196        let local_single = objects_dir.join("info").join("commit-graph");
1197        let chain_path = if local_chain.is_file() {
1198            local_chain
1199        } else if local_single.is_file() {
1200            return Self::try_load_with_caches(objects_dir, caches);
1201        } else {
1202            match alt_dirs
1203                .iter()
1204                .map(PathBuf::as_path)
1205                .find(|d| layer_dir(d).join("commit-graph-chain").is_file())
1206            {
1207                Some(owner) => layer_dir(owner).join("commit-graph-chain"),
1208                None => return Ok(None),
1209            }
1210        };
1211
1212        let algo =
1213            hash_algo_for_objects_dir(&crate::environment::Environment::empty(), objects_dir);
1214        let content = std::fs::read_to_string(&chain_path).map_err(Error::from)?;
1215        let mut layers = Vec::new();
1216        for line in content.lines() {
1217            let h = line.trim();
1218            if !commit_graph_chain_hash_is_valid(h, algo) {
1219                continue;
1220            }
1221            let graph_path = resolve_layer(h).ok_or_else(|| {
1222                Error::from(std::io::Error::new(
1223                    std::io::ErrorKind::NotFound,
1224                    format!("commit-graph layer graph-{h}.graph not found"),
1225                ))
1226            })?;
1227            let layer = CommitGraphLayer::try_open(graph_path)?;
1228            let n = layers.len();
1229            if n > 0 && layer.base_chunk_size / layer.hash_len < n {
1230                warn_once_for_base_chunk_too_small(caches, &layer.layer_display_id());
1231                break;
1232            }
1233            layers.push(layer);
1234        }
1235        if layers.is_empty() {
1236            return Ok(None);
1237        }
1238        layers.reverse();
1239        let mut chain = Self { layers };
1240        chain.validate_bloom_compatibility(caches);
1241        Ok(Some(chain))
1242    }
1243
1244    fn validate_bloom_compatibility(&mut self, caches: Option<&crate::repo_caches::RepoCaches>) {
1245        // Git walks the chain from the tip down to the base (`for (; g; g =
1246        // g->base_graph)` in validate_mixed_bloom_settings), so the *topmost*
1247        // (tip) layer's Bloom settings become the reference and any
1248        // incompatible *lower* (base) layer is disabled. `self.layers` is
1249        // stored tip-first internally, so iterate forward to match.
1250        let mut ref_settings: Option<BloomFilterSettings> = None;
1251        for layer in &mut self.layers {
1252            let Some(bs) = layer.bloom_settings else {
1253                continue;
1254            };
1255            match ref_settings {
1256                None => ref_settings = Some(bs),
1257                Some(r) => {
1258                    if r.hash_version != bs.hash_version
1259                        || r.num_hashes != bs.num_hashes
1260                        || r.bits_per_entry != bs.bits_per_entry
1261                    {
1262                        let id = layer.layer_display_id();
1263                        // Git loads the commit-graph chain once per process and caches
1264                        // it, so the "disabling Bloom filters" warning is emitted at
1265                        // most once per layer. Grit re-reads the chain from disk several
1266                        // times within a single command (settings probe, commit set,
1267                        // filter reuse), so dedupe the warning per layer id to match.
1268                        warn_once_for_disabled_bloom_layer(caches, &id);
1269                        layer.disable_bloom();
1270                    }
1271                }
1272            }
1273        }
1274    }
1275
1276    /// Existing changed-path Bloom filter bytes for `oid`, if present in any layer
1277    /// whose Bloom settings are compatible with `want`. Returns `Some(bytes)` (possibly
1278    /// empty for an empty/no-change filter) when the filter can be reused verbatim, or
1279    /// `None` when no compatible filter exists. Used by the writer to backfill / reuse
1280    /// already-computed filters (Git counts these as `filter_not_computed`).
1281    pub fn existing_filter_bytes(
1282        &self,
1283        oid: &ObjectId,
1284        want: &BloomFilterSettings,
1285    ) -> Option<Vec<u8>> {
1286        let (layer_idx, lex) = self.find_commit(oid)?;
1287        let layer = &self.layers[layer_idx];
1288        let settings = layer.bloom_settings.as_ref()?;
1289        if settings.hash_version != want.hash_version
1290            || settings.num_hashes != want.num_hashes
1291            || settings.bits_per_entry != want.bits_per_entry
1292        {
1293            return None;
1294        }
1295        // Git only reuses a loaded filter when its on-disk length is non-zero
1296        // (`get_or_compute_bloom_filter`: `if (filter->data && filter->len)`).
1297        // A zero-length entry means the filter was skipped (over the
1298        // `--max-new-filters` budget) and must be (re)computed. Empty-diff
1299        // filters are stored with length 1 (a single zero byte) and so are
1300        // reused. Truncated-large filters are length 1 (0xff) and reused too.
1301        match layer.bloom_filter_slice(lex) {
1302            Some(s) if !s.is_empty() => Some(s.to_vec()),
1303            _ => None,
1304        }
1305    }
1306
1307    /// Existing non-empty filter bytes for `oid` whose stored `hash_version`
1308    /// differs from `want.hash_version` but is otherwise compatible (same
1309    /// `num_hashes`/`bits_per_entry`). Used to detect filters that may be
1310    /// *upgraded* (relabeled to the new version without recomputation) when the
1311    /// changed paths contain no high-bit bytes.
1312    pub fn upgradable_filter_bytes(
1313        &self,
1314        oid: &ObjectId,
1315        want: &BloomFilterSettings,
1316    ) -> Option<Vec<u8>> {
1317        let (layer_idx, lex) = self.find_commit(oid)?;
1318        let layer = &self.layers[layer_idx];
1319        let settings = layer.bloom_settings.as_ref()?;
1320        if settings.hash_version == want.hash_version {
1321            return None;
1322        }
1323        if settings.num_hashes != want.num_hashes || settings.bits_per_entry != want.bits_per_entry
1324        {
1325            return None;
1326        }
1327        match layer.bloom_filter_slice(lex) {
1328            Some(s) if !s.is_empty() => Some(s.to_vec()),
1329            _ => None,
1330        }
1331    }
1332
1333    /// Bloom filter settings stored for `oid`'s commit-graph layer, if any.
1334    pub(crate) fn bloom_settings_for_commit(&self, oid: &ObjectId) -> Option<BloomFilterSettings> {
1335        let (layer_idx, _) = self.find_commit(oid)?;
1336        self.layers[layer_idx].bloom_settings
1337    }
1338
1339    /// Lexicographic position in the full chain, or `None` if not in any layer.
1340    pub fn find_commit(&self, oid: &ObjectId) -> Option<(usize, u32)> {
1341        for (i, layer) in self.layers.iter().enumerate() {
1342            if let Some(lex) = layer.bsearch_oid(oid) {
1343                return Some((i, lex));
1344            }
1345        }
1346        None
1347    }
1348
1349    /// Global commit-graph position (Git `graph_pos`): base layers first, then newer layers.
1350    pub fn global_position(&self, oid: &ObjectId) -> Option<u32> {
1351        let (layer_idx, lex) = self.find_commit(oid)?;
1352        let below: u32 = self.layers[layer_idx + 1..]
1353            .iter()
1354            .map(|l| l.num_commits)
1355            .sum();
1356        Some(below + lex)
1357    }
1358
1359    /// Inverse of [`Self::global_position`]: the OID at a base-first global
1360    /// position (used to resolve CDAT parent words to OIDs).
1361    fn oid_at_global_position(&self, pos: u32) -> Option<ObjectId> {
1362        // `layers` is tip-first; global positions count base layers first.
1363        let mut remaining = pos;
1364        for layer in self.layers.iter().rev() {
1365            if remaining < layer.num_commits {
1366                return layer.oid_at_lex(remaining);
1367            }
1368            remaining -= layer.num_commits;
1369        }
1370        None
1371    }
1372
1373    /// Commit parents and committer time read straight from the commit-graph file
1374    /// (no object decompression). Returns `None` when `oid` is not in the graph.
1375    pub fn graph_commit(&self, oid: &ObjectId) -> Option<(Vec<ObjectId>, i64)> {
1376        let (layer_idx, lex) = self.find_commit(oid)?;
1377        self.graph_commit_at(layer_idx, lex)
1378    }
1379
1380    fn graph_commit_at(&self, layer_idx: usize, lex: u32) -> Option<(Vec<ObjectId>, i64)> {
1381        let layer = &self.layers[layer_idx];
1382        let (_, _, commit_time) = layer.cdat_record(lex)?;
1383        let oids = layer
1384            .parent_global_positions(lex)?
1385            .into_iter()
1386            .filter_map(|pos| self.oid_at_global_position(pos))
1387            .collect();
1388        Some((oids, commit_time))
1389    }
1390
1391    /// Root tree OID for `oid` from commit-graph CDAT (no object read).
1392    pub(crate) fn graph_commit_tree(&self, oid: &ObjectId) -> Option<ObjectId> {
1393        let (layer_idx, lex) = self.find_commit(oid)?;
1394        self.layers[layer_idx].cdat_root_tree(lex)
1395    }
1396
1397    /// Tree and parent OIDs for blame when `oid` is indexed in the chain.
1398    pub(crate) fn graph_commit_meta(&self, oid: &ObjectId) -> Option<(ObjectId, Vec<ObjectId>)> {
1399        let (layer_idx, lex) = self.find_commit(oid)?;
1400        let tree = self.layers[layer_idx].cdat_root_tree(lex)?;
1401        let (parents, _) = self.graph_commit_at(layer_idx, lex)?;
1402        Some((tree, parents))
1403    }
1404
1405    /// Generation number for ordering merge-base walks (`2^63-1` when unknown).
1406    #[must_use]
1407    pub fn commit_generation_for_walk(&self, oid: &ObjectId) -> u64 {
1408        const UNRANKED: u64 = (1u64 << 63) - 1;
1409        let Some((layer_idx, lex)) = self.find_commit(oid) else {
1410            return UNRANKED;
1411        };
1412        self.layers[layer_idx]
1413            .corrected_commit_generation(lex)
1414            .unwrap_or(UNRANKED)
1415    }
1416
1417    /// Parent commit positions in global graph order (CDAT + EXTRA_EDGES), or `None` if `oid`
1418    /// is not in the chain.
1419    #[must_use]
1420    pub fn parent_global_positions(&self, oid: &ObjectId) -> Option<Vec<u32>> {
1421        let (layer_idx, lex) = self.find_commit(oid)?;
1422        self.layers[layer_idx].parent_global_positions(lex)
1423    }
1424
1425    /// Count commits reachable from `tips` minus those reachable from `hide`, using only
1426    /// commit-graph parent links (no object reads). Returns `None` when any tip is missing
1427    /// from the graph or parent data cannot be decoded from the file.
1428    #[must_use]
1429    pub fn count_reachable_commits(
1430        &self,
1431        tips: &[ObjectId],
1432        hide: &[ObjectId],
1433        first_parent_only: bool,
1434        shallow: &std::collections::HashSet<ObjectId>,
1435    ) -> Option<usize> {
1436        if self.total_commits() == 0 {
1437            return None;
1438        }
1439        let mut seen = self.reachable_positions(tips, first_parent_only, shallow)?;
1440        if hide.is_empty() {
1441            return Some(seen.len());
1442        }
1443        let hidden = self.reachable_positions(hide, first_parent_only, shallow)?;
1444        seen.retain(|pos| !hidden.contains(pos));
1445        Some(seen.len())
1446    }
1447
1448    fn reachable_positions(
1449        &self,
1450        tips: &[ObjectId],
1451        first_parent_only: bool,
1452        shallow: &std::collections::HashSet<ObjectId>,
1453    ) -> Option<std::collections::HashSet<u32>> {
1454        let total = self.total_commits();
1455        let mut stack: Vec<u32> = Vec::new();
1456        for tip in tips {
1457            stack.push(self.global_position(tip)?);
1458        }
1459        let mut seen = std::collections::HashSet::new();
1460        while let Some(pos) = stack.pop() {
1461            if pos >= total || !seen.insert(pos) {
1462                continue;
1463            }
1464            let oid = self.oid_at_global_position(pos)?;
1465            let parents = if shallow.contains(&oid) {
1466                Vec::new()
1467            } else {
1468                self.parent_global_positions(&oid)?
1469            };
1470            let parents = if first_parent_only {
1471                parents.into_iter().take(1).collect::<Vec<_>>()
1472            } else {
1473                parents
1474            };
1475            for p in parents {
1476                if p >= total {
1477                    return None;
1478                }
1479                stack.push(p);
1480            }
1481        }
1482        Some(seen)
1483    }
1484
1485    /// All commit OIDs in the chain (oldest base first, then newer layers).
1486    pub fn all_oids_in_order(&self) -> Vec<ObjectId> {
1487        let mut out = Vec::new();
1488        for layer in self.layers.iter().rev() {
1489            for i in 0..layer.num_commits {
1490                if let Some(oid) = layer.oid_at_lex(i) {
1491                    out.push(oid);
1492                }
1493            }
1494        }
1495        out
1496    }
1497
1498    /// Consult Bloom filters for a single-parent commit before diffing trees.
1499    pub fn bloom_precheck_for_paths(
1500        &self,
1501        _odb: &Odb,
1502        oid: ObjectId,
1503        pathspecs: &[String],
1504        bloom_cwd: Option<&str>,
1505        requested_hash_version: i32,
1506        read_changed_paths: bool,
1507    ) -> std::result::Result<BloomPrecheck, crate::error::Error> {
1508        if !read_changed_paths {
1509            return Ok(BloomPrecheck::Inapplicable);
1510        }
1511        let Some((layer_idx, lex)) = self.find_commit(&oid) else {
1512            return Ok(BloomPrecheck::NotInGraph);
1513        };
1514        let layer = &self.layers[layer_idx];
1515        let Some(settings) = layer.bloom_settings.as_ref() else {
1516            return Ok(BloomPrecheck::FilterNotPresent);
1517        };
1518        let effective_version = if requested_hash_version < 0 {
1519            settings.hash_version as i32
1520        } else {
1521            requested_hash_version
1522        };
1523        if effective_version != settings.hash_version as i32 {
1524            return Ok(BloomPrecheck::FilterNotPresent);
1525        }
1526
1527        // Git computes the changed-path Bloom filter for every commit (including merges)
1528        // relative to its first parent, and `rev_compare_tree` consults it for the
1529        // first-parent comparison only (`nth_parent == 0`). The caller is responsible for
1530        // restricting the precheck to the first parent, so merge commits are handled here
1531        // exactly like single-parent commits.
1532        let filter = match layer.bloom_filter_slice(lex) {
1533            Some(s) => s,
1534            None => return Ok(BloomPrecheck::FilterNotPresent),
1535        };
1536        if filter.is_empty() {
1537            return Ok(BloomPrecheck::FilterNotPresent);
1538        }
1539
1540        // Git `bloom_filter_contains_vec`: within one pathspec, every prefix key must match;
1541        // multiple pathspecs are ORed (`revision.c` loop over `bloom_keyvecs_nr`).
1542        let mut any_pathspec_maybe = false;
1543        let mut checked_any_keys = false;
1544        for spec in pathspecs {
1545            if spec.is_empty() || crate::pathspec::pathspec_is_exclude(spec) {
1546                continue;
1547            }
1548            let Some(norm) = crate::pathspec::bloom_lookup_prefix_with_cwd(spec, bloom_cwd) else {
1549                continue;
1550            };
1551            let keys = bloom_keyvec_for_path(norm.as_str(), settings);
1552            if keys.is_empty() {
1553                continue;
1554            }
1555            checked_any_keys = true;
1556            let mut all_keys_maybe = true;
1557            for key in &keys {
1558                match bloom_filter_contains(key, filter, settings) {
1559                    Ok(true) => {}
1560                    Ok(false) => {
1561                        all_keys_maybe = false;
1562                        break;
1563                    }
1564                    Err(crate::bloom::BloomFilterInvalid) => {
1565                        all_keys_maybe = true;
1566                        break;
1567                    }
1568                }
1569            }
1570            if all_keys_maybe {
1571                any_pathspec_maybe = true;
1572                break;
1573            }
1574        }
1575        if checked_any_keys && !any_pathspec_maybe {
1576            return Ok(BloomPrecheck::DefinitelyNot);
1577        }
1578        Ok(BloomPrecheck::Maybe)
1579    }
1580
1581    /// Like [`Self::bloom_precheck_for_paths`], but reuses precomputed Bloom keys for one path.
1582    pub(crate) fn bloom_definitely_not_for_keys(
1583        &self,
1584        oid: ObjectId,
1585        path_keys: &[Vec<u32>],
1586    ) -> bool {
1587        if path_keys.is_empty() {
1588            return false;
1589        }
1590        let Some((layer_idx, lex)) = self.find_commit(&oid) else {
1591            return false;
1592        };
1593        let layer = &self.layers[layer_idx];
1594        let Some(settings) = layer.bloom_settings.as_ref() else {
1595            return false;
1596        };
1597        let Some(filter) = layer.bloom_filter_slice(lex) else {
1598            return false;
1599        };
1600        if filter.is_empty() {
1601            return false;
1602        }
1603        bloom_definitely_not_from_filter(filter, settings, path_keys)
1604    }
1605}
1606
1607/// Git `bloom_filter_contains_vec` precheck for one path's prefix keys against a filter slice.
1608pub(crate) fn bloom_definitely_not_from_filter(
1609    filter: &[u8],
1610    settings: &crate::bloom::BloomFilterSettings,
1611    path_keys: &[Vec<u32>],
1612) -> bool {
1613    use crate::bloom::bloom_filter_contains;
1614
1615    for key in path_keys {
1616        match bloom_filter_contains(key, filter, settings) {
1617            Ok(false) => return true,
1618            Ok(true) => {}
1619            Err(crate::bloom::BloomFilterInvalid) => return false,
1620        }
1621    }
1622    false
1623}
1624
1625/// Compute changed paths between parent and commit trees (recursive diff, no rename detection).
1626pub fn diff_changed_paths_for_bloom(
1627    odb: &Odb,
1628    parent_tree: Option<ObjectId>,
1629    commit_tree: ObjectId,
1630) -> crate::error::Result<(Vec<String>, usize)> {
1631    use crate::diff::diff_trees;
1632    let entries = diff_trees(odb, parent_tree.as_ref(), Some(&commit_tree), "")?;
1633    let raw_len = entries.len();
1634    let mut paths = Vec::new();
1635    for e in entries {
1636        let p = e.path().to_string();
1637        if !p.is_empty() {
1638            paths.push(p);
1639        }
1640    }
1641    Ok((paths, raw_len))
1642}
1643
1644/// Re-export for `commit-graph` write.
1645pub use crate::bloom::collect_changed_paths_for_bloom;
1646
1647/// Build Bloom filter bytes for one commit; returns cumulative size contribution for BIDX.
1648pub fn bloom_filter_for_commit_write(
1649    odb: &Odb,
1650    parents: &[ObjectId],
1651    tree_oid: ObjectId,
1652    settings: &BloomFilterSettings,
1653) -> crate::error::Result<(Vec<u8>, BloomBuildOutcome)> {
1654    // Git computes the changed-path filter against the *first* parent only,
1655    // regardless of how many parents a commit has (see bloom.c:
1656    // `diff_tree_oid(&c->parents->item->object.oid, ...)`).
1657    let (changed_paths_vec, raw_count) = if let Some(first_parent) = parents.first() {
1658        let p = odb.commit_tree(first_parent)?;
1659        diff_changed_paths_for_bloom(odb, Some(p), tree_oid)?
1660    } else {
1661        diff_changed_paths_for_bloom(odb, None, tree_oid)?
1662    };
1663    let set = collect_changed_paths_for_bloom(&changed_paths_vec);
1664    Ok(crate::bloom::build_bloom_filter_data(
1665        &set, raw_count, settings,
1666    ))
1667}
1668
1669/// Whether any path component in `tree` (recursively) contains a byte with the
1670/// high bit set (`& 0x80`). Mirrors `bloom.c:has_entries_with_high_bit`, used to
1671/// decide whether a v1 changed-path Bloom filter can be relabeled as v2 without
1672/// recomputation (v1/v2 hashing only differs for bytes >= 0x80).
1673fn tree_has_high_bit_paths(odb: &Odb, tree_oid: ObjectId) -> bool {
1674    let Ok(obj) = odb.read(&tree_oid) else {
1675        // Git treats an unreadable tree conservatively (no upgrade).
1676        return true;
1677    };
1678    let Ok(entries) = crate::objects::parse_tree(&obj.data) else {
1679        return true;
1680    };
1681    for e in &entries {
1682        if e.name.iter().any(|&b| b & 0x80 != 0) {
1683            return true;
1684        }
1685        if e.mode == 0o040000 && tree_has_high_bit_paths(odb, e.oid) {
1686            return true;
1687        }
1688    }
1689    false
1690}
1691
1692/// Whether a commit's tree (recursively) has any high-bit path bytes
1693/// (`bloom.c:commit_tree_has_high_bit_paths`).
1694pub fn commit_tree_has_high_bit_paths(odb: &Odb, commit_oid: ObjectId) -> bool {
1695    match odb.commit_tree(&commit_oid) {
1696        Ok(tree) => tree_has_high_bit_paths(odb, tree),
1697        Err(_) => true,
1698    }
1699}
1700
1701/// Parse all chunks for `test-tool read-graph` / debugging.
1702pub fn parse_graph_file(path: &Path) -> Option<ParsedGraphDump> {
1703    let raw = std::fs::read(path).ok()?;
1704    if raw.len() < 28 {
1705        return None;
1706    }
1707    let hash_len = commit_graph_hash_len(raw[5])?;
1708    let body = &raw[..raw.len() - hash_len];
1709    if body.len() < 8 || &body[0..4] != SIGNATURE {
1710        return None;
1711    }
1712    let header_word = u32::from_be_bytes(body[0..4].try_into().ok()?);
1713    let num_chunks = body[6] as usize;
1714    let toc_start = 8;
1715    let mut present: std::collections::HashSet<u32> = std::collections::HashSet::new();
1716    for i in 0..num_chunks {
1717        let e = toc_start + i * 12;
1718        let id = u32::from_be_bytes(body[e..e + 4].try_into().ok()?);
1719        present.insert(id);
1720    }
1721    // `git/t/helper/test-read-graph.c` prints a fixed set of recognized chunks in
1722    // a fixed order, omitting the BASE chunk and any unknown chunk.
1723    let mut chunk_names: Vec<String> = Vec::new();
1724    for (id, label) in [
1725        (CHUNK_OID_FANOUT, "oid_fanout"),
1726        (CHUNK_OID_LOOKUP, "oid_lookup"),
1727        (CHUNK_COMMIT_DATA, "commit_metadata"),
1728        (CHUNK_GENERATION_DATA, "generation_data"),
1729        (CHUNK_GENERATION_DATA_OVERFLOW, "generation_data_overflow"),
1730        (CHUNK_EXTRA_EDGES, "extra_edges"),
1731        (CHUNK_BLOOM_INDEXES, "bloom_indexes"),
1732        (CHUNK_BLOOM_DATA, "bloom_data"),
1733    ] {
1734        if present.contains(&id) {
1735            chunk_names.push(label.to_string());
1736        }
1737    }
1738    let layer = CommitGraphLayer::parse(path.to_path_buf(), raw.clone())?;
1739    let bloom_opt = layer.bloom_settings.map(|s| {
1740        format!(
1741            " bloom({},{},{})",
1742            s.hash_version, s.bits_per_entry, s.num_hashes
1743        )
1744    });
1745    let mut options = String::new();
1746    if let Some(b) = bloom_opt {
1747        options.push_str(&b);
1748    }
1749    if layer.read_generation_data {
1750        options.push_str(" read_generation_data");
1751    }
1752    Some(ParsedGraphDump {
1753        header_word,
1754        version: body[4],
1755        hash_ver: body[5],
1756        num_chunks: body[6],
1757        reserved: body[7],
1758        num_commits: layer.num_commits,
1759        chunks: chunk_names.join(" "),
1760        options,
1761    })
1762}
1763
1764/// Parsed commit-graph header fields for debugging and `--dump` style output.
1765pub struct ParsedGraphDump {
1766    /// Commit-graph file signature word (`0x43475048`).
1767    pub header_word: u32,
1768    /// Commit-graph format version byte.
1769    pub version: u8,
1770    /// Object hash version used in the graph (`1` = SHA-1, `2` = SHA-256).
1771    pub hash_ver: u8,
1772    /// Number of chunk types present in the file.
1773    pub num_chunks: u8,
1774    /// Reserved header byte (must be zero in current formats).
1775    pub reserved: u8,
1776    /// Number of commits encoded in the graph.
1777    pub num_commits: u32,
1778    /// Space-separated chunk id names in file order.
1779    pub chunks: String,
1780    /// Human-readable graph option flags from the header.
1781    pub options: String,
1782}
1783
1784/// Dump hex lines of Bloom filters (one per commit, empty line for empty filter).
1785pub fn dump_bloom_filters(path: &Path) -> Option<Vec<String>> {
1786    let raw = std::fs::read(path).ok()?;
1787    let layer = CommitGraphLayer::parse(path.to_path_buf(), raw)?;
1788    let mut out = Vec::new();
1789    for i in 0..layer.num_commits {
1790        let slice = layer.bloom_filter_slice(i).unwrap_or(&[]);
1791        if slice.is_empty() {
1792            out.push(String::new());
1793        } else {
1794            let hex: String = slice.iter().map(|b| format!("{b:02x}")).collect();
1795            out.push(hex);
1796        }
1797    }
1798    Some(out)
1799}
1800
1801#[cfg(test)]
1802mod tests {
1803    use std::collections::HashMap;
1804
1805    use crate::bloom::BloomFilterSettings;
1806    use crate::commit_graph_write::{build_commit_graph_bytes, load_commit_graph_commit_info};
1807    use crate::index::{Index, IndexEntry, MODE_REGULAR};
1808    use crate::objects::{serialize_commit, CommitData, ObjectId, ObjectKind};
1809    use crate::repo::{init_repository, Repository};
1810    use crate::write_tree::write_tree_from_index;
1811
1812    use crate::commit_graph_write::CommitGraphCommitInfo;
1813    use crate::objects::OidHashMap;
1814
1815    fn infos_std(
1816        infos: &OidHashMap<CommitGraphCommitInfo>,
1817    ) -> HashMap<ObjectId, CommitGraphCommitInfo> {
1818        infos.iter().map(|(k, v)| (*k, v.clone())).collect()
1819    }
1820
1821    fn empty_byte_filters() -> HashMap<ObjectId, Vec<u8>> {
1822        HashMap::new()
1823    }
1824
1825    fn one_commit_repo() -> (tempfile::TempDir, Repository, ObjectId) {
1826        let dir = tempfile::tempdir().expect("tempdir");
1827        let repo = init_repository(
1828            dir.path(),
1829            false,
1830            "main",
1831            None,
1832            crate::RefStorageFormat::Files,
1833        )
1834        .expect("init");
1835        let mut index = Index::new();
1836        index.hash_algo = repo.odb().hash_algo();
1837        let blob = repo.odb().write(ObjectKind::Blob, b"x\n").expect("blob");
1838        index.add_or_replace(IndexEntry {
1839            ctime_sec: 0,
1840            ctime_nsec: 0,
1841            mtime_sec: 0,
1842            mtime_nsec: 0,
1843            dev: 0,
1844            ino: 0,
1845            mode: MODE_REGULAR,
1846            uid: 0,
1847            gid: 0,
1848            size: 2,
1849            oid: blob,
1850            flags: 4,
1851            flags_extended: None,
1852            path: b"f.txt".to_vec(),
1853            base_index_pos: 0,
1854        });
1855        let tree = write_tree_from_index(&repo.odb(), &index, "").expect("tree");
1856        let raw = serialize_commit(&CommitData {
1857            tree,
1858            parents: vec![],
1859            author: "T <t@example.com> 100 +0000".into(),
1860            committer: "T <t@example.com> 100 +0000".into(),
1861            author_raw: Vec::new(),
1862            committer_raw: Vec::new(),
1863            encoding: None,
1864            message: "m\n".into(),
1865            raw_message: None,
1866            extra_headers: Vec::new(),
1867        });
1868        let commit = repo.odb().write(ObjectKind::Commit, &raw).expect("commit");
1869        crate::refs::write_ref(&repo.git_dir(), "refs/heads/main", &commit).expect("ref");
1870        (dir, repo, commit)
1871    }
1872
1873    fn reseal_graph(bytes: &mut Vec<u8>, algo: crate::objects::HashAlgo) {
1874        let hash_len = algo.len();
1875        let body_len = bytes.len().saturating_sub(hash_len);
1876        let digest = algo.checksum_digest(&bytes[..body_len]);
1877        bytes.truncate(body_len);
1878        bytes.extend_from_slice(&digest);
1879    }
1880
1881    #[test]
1882    fn try_parse_rejects_bad_signature_and_truncated_toc() {
1883        let (_dir, repo, commit) = one_commit_repo();
1884        let info = load_commit_graph_commit_info(&repo.odb(), commit).expect("info");
1885        let mut infos = crate::objects::oid_hash_map();
1886        infos.insert(commit, info);
1887        let bloom = BloomFilterSettings::default();
1888        let (mut bytes, _) = build_commit_graph_bytes(
1889            &[commit],
1890            &infos_std(&infos),
1891            &repo.odb(),
1892            false,
1893            &bloom,
1894            None,
1895            &[],
1896            None,
1897            &empty_byte_filters(),
1898            &empty_byte_filters(),
1899            true,
1900        )
1901        .expect("write");
1902        let algo = repo.odb().hash_algo();
1903        bytes[0] = 0;
1904        reseal_graph(&mut bytes, algo);
1905        let err = super::CommitGraphLayer::try_parse(std::path::PathBuf::from("g"), bytes.clone())
1906            .unwrap_err();
1907        assert!(matches!(err, crate::error::Error::CorruptObject(_)));
1908        bytes.truncate(40);
1909        let err =
1910            super::CommitGraphLayer::try_parse(std::path::PathBuf::from("g"), bytes).unwrap_err();
1911        assert!(matches!(err, crate::error::Error::CorruptObject(_)));
1912        let (mut good, _) = build_commit_graph_bytes(
1913            &[commit],
1914            &infos_std(&infos),
1915            &repo.odb(),
1916            false,
1917            &bloom,
1918            None,
1919            &[],
1920            None,
1921            &empty_byte_filters(),
1922            &empty_byte_filters(),
1923            true,
1924        )
1925        .expect("write");
1926        good[4] = 2;
1927        reseal_graph(&mut good, algo);
1928        let err =
1929            super::CommitGraphLayer::try_parse(std::path::PathBuf::from("g"), good).unwrap_err();
1930        assert!(matches!(err, crate::error::Error::CorruptObject(_)));
1931        let (mut checksum_bad, _) = build_commit_graph_bytes(
1932            &[commit],
1933            &infos_std(&infos),
1934            &repo.odb(),
1935            false,
1936            &bloom,
1937            None,
1938            &[],
1939            None,
1940            &empty_byte_filters(),
1941            &empty_byte_filters(),
1942            true,
1943        )
1944        .expect("write");
1945        let last = checksum_bad.len() - 1;
1946        checksum_bad[last] ^= 0xff;
1947        let err = super::CommitGraphLayer::try_parse(std::path::PathBuf::from("g"), checksum_bad)
1948            .unwrap_err();
1949        assert!(matches!(err, crate::error::Error::CorruptObject(_)));
1950    }
1951
1952    #[test]
1953    fn parse_graph_file_returns_none_for_invalid_inputs() {
1954        let dir = tempfile::tempdir().expect("tempdir");
1955        let missing = dir.path().join("nope.graph");
1956        assert!(super::parse_graph_file(&missing).is_none());
1957        std::fs::write(dir.path().join("tiny.graph"), b"CGPH").expect("write");
1958        assert!(super::parse_graph_file(&dir.path().join("tiny.graph")).is_none());
1959        assert!(super::dump_bloom_filters(&missing).is_none());
1960    }
1961
1962    #[test]
1963    fn try_load_missing_graph_is_none() {
1964        let dir = tempfile::tempdir().expect("tempdir");
1965        let objects = dir.path().join("objects");
1966        std::fs::create_dir_all(objects.join("info")).expect("info");
1967        assert!(super::CommitGraphChain::try_load(&objects)
1968            .expect("ok")
1969            .is_none());
1970    }
1971
1972    #[test]
1973    fn try_load_across_finds_single_graph() {
1974        let (_dir, repo, _commit) = one_commit_repo();
1975        let objects = repo.odb().objects_dir();
1976        let sorted = vec![_commit];
1977        let info = load_commit_graph_commit_info(&repo.odb(), _commit).expect("info");
1978        let mut infos = crate::objects::oid_hash_map();
1979        infos.insert(_commit, info);
1980        let bloom = BloomFilterSettings::default();
1981        let (bytes, _) = build_commit_graph_bytes(
1982            &sorted,
1983            &infos_std(&infos),
1984            &repo.odb(),
1985            false,
1986            &bloom,
1987            None,
1988            &[],
1989            None,
1990            &empty_byte_filters(),
1991            &empty_byte_filters(),
1992            true,
1993        )
1994        .expect("write");
1995        std::fs::write(objects.join("info/commit-graph"), bytes).expect("write");
1996        let chain = super::CommitGraphChain::try_load_across(objects, &[])
1997            .expect("across")
1998            .expect("some");
1999        assert_eq!(chain.total_commits(), 1);
2000    }
2001
2002    #[test]
2003    fn try_parse_rejects_oidf_toc_offset_out_of_range() {
2004        let (_dir, repo, commit) = one_commit_repo();
2005        let info = load_commit_graph_commit_info(&repo.odb(), commit).expect("info");
2006        let mut infos = crate::objects::oid_hash_map();
2007        infos.insert(commit, info);
2008        let bloom = BloomFilterSettings::default();
2009        let (mut bytes, _) = build_commit_graph_bytes(
2010            &[commit],
2011            &infos_std(&infos),
2012            &repo.odb(),
2013            false,
2014            &bloom,
2015            None,
2016            &[],
2017            None,
2018            &empty_byte_filters(),
2019            &empty_byte_filters(),
2020            true,
2021        )
2022        .expect("write");
2023        let algo = repo.odb().hash_algo();
2024        let num_chunks = bytes[6] as usize;
2025        for i in 0..num_chunks {
2026            let e = 8 + i * 12;
2027            let id = u32::from_be_bytes(bytes[e..e + 4].try_into().unwrap());
2028            if id == 0x4f49_4446 {
2029                bytes[e + 4..e + 12].copy_from_slice(&u64::MAX.to_be_bytes());
2030                break;
2031            }
2032        }
2033        reseal_graph(&mut bytes, algo);
2034        let err =
2035            super::CommitGraphLayer::try_parse(std::path::PathBuf::from("g"), bytes).unwrap_err();
2036        assert!(matches!(err, crate::error::Error::CorruptObject(_)));
2037    }
2038
2039    #[test]
2040    fn generation_overflow_without_gdo2_fails_parse() {
2041        let (_dir, repo, commit) = one_commit_repo();
2042        let info = load_commit_graph_commit_info(&repo.odb(), commit).expect("info");
2043        let mut infos = crate::objects::oid_hash_map();
2044        infos.insert(commit, info);
2045        let bloom = BloomFilterSettings::default();
2046        let (mut bytes, _) = build_commit_graph_bytes(
2047            &[commit],
2048            &infos_std(&infos),
2049            &repo.odb(),
2050            false,
2051            &bloom,
2052            None,
2053            &[],
2054            None,
2055            &empty_byte_filters(),
2056            &empty_byte_filters(),
2057            true,
2058        )
2059        .expect("write");
2060        if bytes.len() > 100 {
2061            for i in 80..120 {
2062                if i + 4 <= bytes.len() - 20 {
2063                    bytes[i] = 0x80;
2064                    bytes[i + 1] = 0x00;
2065                    bytes[i + 2] = 0x00;
2066                    bytes[i + 3] = 0x00;
2067                    break;
2068                }
2069            }
2070        }
2071        let err =
2072            super::CommitGraphLayer::try_parse(std::path::PathBuf::from("g"), bytes).unwrap_err();
2073        assert!(matches!(err, crate::error::Error::CorruptObject(_)));
2074    }
2075
2076    #[test]
2077    fn bloom_precheck_not_in_graph_for_missing_oid() {
2078        let (_dir, repo, commit) = one_commit_repo();
2079        let info = load_commit_graph_commit_info(&repo.odb(), commit).expect("info");
2080        let mut infos = crate::objects::oid_hash_map();
2081        infos.insert(commit, info);
2082        let bloom = BloomFilterSettings::default();
2083        let (bytes, _) = build_commit_graph_bytes(
2084            &[commit],
2085            &infos_std(&infos),
2086            &repo.odb(),
2087            true,
2088            &bloom,
2089            None,
2090            &[],
2091            None,
2092            &empty_byte_filters(),
2093            &empty_byte_filters(),
2094            true,
2095        )
2096        .expect("write");
2097        std::fs::write(repo.odb().objects_dir().join("info/commit-graph"), bytes).expect("write");
2098        let chain = super::CommitGraphChain::load(repo.odb().objects_dir()).expect("chain");
2099        let missing = ObjectId::from_hex("1234567890123456789012345678901234567890").expect("oid");
2100        let pre = chain
2101            .bloom_precheck_for_paths(&repo.odb(), missing, &[], None, -1, true)
2102            .expect("precheck");
2103        assert_eq!(pre, super::BloomPrecheck::NotInGraph);
2104    }
2105
2106    #[test]
2107    fn bloom_precheck_respects_read_changed_paths_flag() {
2108        let (_dir, repo, commit) = one_commit_repo();
2109        let mut sorted = vec![commit];
2110        let info = load_commit_graph_commit_info(&repo.odb(), commit).expect("info");
2111        let mut infos = crate::objects::oid_hash_map();
2112        infos.insert(commit, info);
2113        let bloom = BloomFilterSettings::default();
2114        let (bytes, _) = build_commit_graph_bytes(
2115            &sorted,
2116            &infos_std(&infos),
2117            &repo.odb(),
2118            true,
2119            &bloom,
2120            None,
2121            &[],
2122            None,
2123            &empty_byte_filters(),
2124            &empty_byte_filters(),
2125            true,
2126        )
2127        .expect("write");
2128        std::fs::write(repo.odb().objects_dir().join("info/commit-graph"), bytes).expect("write");
2129        let chain = super::CommitGraphChain::load(repo.odb().objects_dir()).expect("chain");
2130        let pre = chain
2131            .bloom_precheck_for_paths(&repo.odb(), commit, &["f.txt".to_owned()], None, -1, false)
2132            .expect("precheck");
2133        assert_eq!(pre, super::BloomPrecheck::Inapplicable);
2134        sorted.clear();
2135    }
2136
2137    #[test]
2138    fn bloom_skips_most_steps_when_tracked_path_unchanged_in_linear_history() {
2139        use crate::bloom::bloom_keyvec_for_path;
2140        use crate::commit_graph_write::{build_commit_graph_bytes, load_commit_graph_commit_info};
2141        use crate::index::{Index, IndexEntry, MODE_REGULAR};
2142        use crate::objects::{serialize_commit, CommitData, ObjectKind};
2143        use crate::pathspec::bloom_lookup_prefix_with_cwd;
2144        use crate::repo::init_repository;
2145        use crate::write_tree::write_tree_from_index;
2146        use crate::RefStorageFormat;
2147        use std::fs;
2148
2149        let dir = tempfile::tempdir().expect("tempdir");
2150        let repo = init_repository(dir.path(), false, "main", None, RefStorageFormat::Files)
2151            .expect("init");
2152        let path = "tracked.txt";
2153        let stable = b"stable payload\n";
2154        let blob = repo.odb().write(ObjectKind::Blob, stable).expect("blob");
2155        let mut parent: Option<ObjectId> = None;
2156        let mut oids = Vec::new();
2157        for i in 0..24usize {
2158            let mut index = Index::new();
2159            index.hash_algo = repo.odb().hash_algo();
2160            index.add_or_replace(IndexEntry {
2161                ctime_sec: 0,
2162                ctime_nsec: 0,
2163                mtime_sec: 0,
2164                mtime_nsec: 0,
2165                dev: 0,
2166                ino: 0,
2167                mode: MODE_REGULAR,
2168                uid: 0,
2169                gid: 0,
2170                size: stable.len() as u32,
2171                oid: blob,
2172                flags: 4,
2173                flags_extended: None,
2174                path: path.as_bytes().to_vec(),
2175                base_index_pos: 0,
2176            });
2177            if i % 5 == 3 {
2178                let noise = repo
2179                    .odb()
2180                    .write(ObjectKind::Blob, format!("noise {i}\n").as_bytes())
2181                    .expect("noise blob");
2182                index.add_or_replace(IndexEntry {
2183                    ctime_sec: 0,
2184                    ctime_nsec: 0,
2185                    mtime_sec: 0,
2186                    mtime_nsec: 0,
2187                    dev: 0,
2188                    ino: 0,
2189                    mode: MODE_REGULAR,
2190                    uid: 0,
2191                    gid: 0,
2192                    size: 8,
2193                    oid: noise,
2194                    flags: 4,
2195                    flags_extended: None,
2196                    path: format!("other{i}.txt").into_bytes(),
2197                    base_index_pos: 0,
2198                });
2199            }
2200            let tree = write_tree_from_index(&repo.odb(), &index, "").expect("tree");
2201            let mut parents = Vec::new();
2202            if let Some(p) = parent {
2203                parents.push(p);
2204            }
2205            let raw = serialize_commit(&CommitData {
2206                tree,
2207                parents,
2208                author: "T <t@example.com> 100 +0000".into(),
2209                committer: "T <t@example.com> 100 +0000".into(),
2210                author_raw: Vec::new(),
2211                committer_raw: Vec::new(),
2212                encoding: None,
2213                message: format!("c{i}\n"),
2214                raw_message: None,
2215                extra_headers: Vec::new(),
2216            });
2217            let commit = repo.odb().write(ObjectKind::Commit, &raw).expect("commit");
2218            oids.push(commit);
2219            parent = Some(commit);
2220        }
2221        let head = *oids.last().expect("head");
2222        let mut infos = crate::objects::oid_hash_map();
2223        for oid in &oids {
2224            infos.insert(
2225                *oid,
2226                load_commit_graph_commit_info(&repo.odb(), *oid).expect("info"),
2227            );
2228        }
2229        let bloom = BloomFilterSettings::default();
2230        let (bytes, _) = build_commit_graph_bytes(
2231            &oids,
2232            &infos_std(&infos),
2233            &repo.odb(),
2234            true,
2235            &bloom,
2236            None,
2237            &[],
2238            None,
2239            &empty_byte_filters(),
2240            &empty_byte_filters(),
2241            true,
2242        )
2243        .expect("graph bytes");
2244        fs::write(repo.odb().objects_dir().join("info/commit-graph"), bytes).expect("write graph");
2245        let chain = super::CommitGraphChain::load(repo.odb().objects_dir()).expect("chain");
2246        let settings = chain.top_layer_bloom_settings().expect("bloom");
2247        let norm = bloom_lookup_prefix_with_cwd(path, None).expect("prefix");
2248        let keys = bloom_keyvec_for_path(norm.as_str(), &settings);
2249        let mut current = head;
2250        let mut bloom_yes = 0usize;
2251        let mut bloom_no = 0usize;
2252        while bloom_yes + bloom_no < 10_000 {
2253            let Some((parents, _)) = chain.graph_commit(&current) else {
2254                break;
2255            };
2256            let Some(parent) = parents.first().copied() else {
2257                break;
2258            };
2259            if chain.bloom_definitely_not_for_keys(current, keys.as_slice()) {
2260                bloom_yes += 1;
2261            } else {
2262                bloom_no += 1;
2263            }
2264            current = parent;
2265        }
2266        assert!(
2267            bloom_yes > bloom_no,
2268            "expected bloom to skip unchanged tracked path (yes={bloom_yes} no={bloom_no})"
2269        );
2270    }
2271
2272    #[test]
2273    #[ignore = "local git.git worktree; GRIT_BLAME_BENCH_REPO"]
2274    fn first_parent_makefile_bloom_skip_rate_on_git_dot_git() {
2275        use crate::bloom::bloom_keyvec_for_path;
2276        use crate::environment::RepositoryOptions;
2277        use crate::pathspec::bloom_lookup_prefix_with_cwd;
2278        use crate::repo::Repository;
2279
2280        let repo_path = std::env::var("GRIT_BLAME_BENCH_REPO")
2281            .unwrap_or_else(|_| "/tmp/grit-bench/git-dot-git-worktree".to_string());
2282        if !std::path::Path::new(&repo_path).join(".git").exists() {
2283            return;
2284        }
2285        let git_dir = std::path::Path::new(&repo_path).join(".git");
2286        let repo =
2287            Repository::open(git_dir.as_path(), &RepositoryOptions::default()).expect("open");
2288        let chain = repo
2289            .caches()
2290            .commit_graph_chain(repo.odb().objects_dir())
2291            .expect("graph");
2292        let settings = chain.top_layer_bloom_settings().expect("bloom");
2293        let norm = bloom_lookup_prefix_with_cwd("Makefile", None).expect("prefix");
2294        let keys = bloom_keyvec_for_path(norm.as_str(), &settings);
2295        let head = *crate::state::resolve_head(repo.git_dir())
2296            .expect("head")
2297            .oid()
2298            .expect("commit");
2299        let mut current = head;
2300        let mut bloom_yes = 0usize;
2301        let mut bloom_no = 0usize;
2302        for _ in 0..10_000 {
2303            let Some((parents, _)) = chain.graph_commit(&current) else {
2304                break;
2305            };
2306            let Some(parent) = parents.first().copied() else {
2307                break;
2308            };
2309            if chain.bloom_definitely_not_for_keys(current, keys.as_slice()) {
2310                bloom_yes += 1;
2311            } else {
2312                bloom_no += 1;
2313            }
2314            current = parent;
2315        }
2316        assert!(
2317            bloom_yes > bloom_no,
2318            "expected mostly bloom skips, yes={bloom_yes} no={bloom_no}"
2319        );
2320    }
2321
2322    #[test]
2323    fn bloom_definitely_not_when_directory_key_present_but_not_full_path() {
2324        use crate::bloom::{
2325            bloom_filter_contains, bloom_keyvec_for_path, build_bloom_filter_data,
2326            collect_changed_paths_for_bloom,
2327        };
2328
2329        let bloom = BloomFilterSettings::default();
2330        let changed = collect_changed_paths_for_bloom(&["builtin/sibling.c".to_owned()]);
2331        let (filter, _) = build_bloom_filter_data(&changed, 1, &bloom);
2332        let path_keys = bloom_keyvec_for_path("builtin/commit.c", &bloom);
2333        assert!(path_keys.len() >= 2);
2334        assert_eq!(
2335            bloom_filter_contains(&path_keys[0], &filter, &bloom),
2336            Ok(false),
2337            "full path key must be absent when only a sibling changed"
2338        );
2339        assert_eq!(
2340            bloom_filter_contains(&path_keys[1], &filter, &bloom),
2341            Ok(true),
2342            "directory prefix key should be present"
2343        );
2344        assert!(super::bloom_definitely_not_from_filter(
2345            &filter, &bloom, &path_keys
2346        ));
2347    }
2348
2349    #[test]
2350    fn large_git_split_chain_layers_use_mapped_backing() {
2351        use std::process::Command;
2352
2353        let git_ok = Command::new("git")
2354            .arg("--version")
2355            .output()
2356            .map(|o| o.status.success())
2357            .unwrap_or(false);
2358        if !git_ok {
2359            eprintln!("SKIP: git unavailable");
2360            return;
2361        }
2362        let dir = tempfile::tempdir().expect("tempdir");
2363        let run = |args: &[&str]| {
2364            let out = Command::new("git")
2365                .current_dir(dir.path())
2366                .args(args)
2367                .env("GIT_CONFIG_GLOBAL", "/dev/null")
2368                .env("GIT_CONFIG_SYSTEM", "/dev/null")
2369                .env("GIT_AUTHOR_NAME", "T")
2370                .env("GIT_AUTHOR_EMAIL", "t@example.com")
2371                .env("GIT_COMMITTER_NAME", "T")
2372                .env("GIT_COMMITTER_EMAIL", "t@example.com")
2373                .output()
2374                .expect("git");
2375            assert!(out.status.success(), "git {args:?}");
2376        };
2377        run(&["init", "-q", "-b", "main"]);
2378        run(&["commit", "--allow-empty", "-q", "-m", "bootstrap"]);
2379        for i in 0..150 {
2380            run(&[
2381                "commit",
2382                "--allow-empty",
2383                "-q",
2384                "-m",
2385                &format!("base-fill-{i}"),
2386            ]);
2387        }
2388        run(&[
2389            "commit-graph",
2390            "write",
2391            "--reachable",
2392            "--split",
2393            "--max-commits",
2394            "40",
2395        ]);
2396        for i in 150..190 {
2397            run(&[
2398                "commit",
2399                "--allow-empty",
2400                "-q",
2401                "-m",
2402                &format!("tip-fill-{i}"),
2403            ]);
2404        }
2405        run(&[
2406            "commit-graph",
2407            "write",
2408            "--reachable",
2409            "--split",
2410            "--max-commits",
2411            "40",
2412        ]);
2413        let objects = dir.path().join(".git/objects");
2414        let chain = super::CommitGraphChain::try_load(&objects)
2415            .expect("load")
2416            .expect("chain");
2417        assert!(
2418            chain.num_layers() >= 2,
2419            "expected multi-layer chain, got {}",
2420            chain.num_layers()
2421        );
2422        let mut saw_mapped_large_layer = false;
2423        for path in chain.layer_paths_oldest_first() {
2424            let len = std::fs::metadata(&path).expect("meta").len();
2425            if len <= 8192 {
2426                continue;
2427            }
2428            let storage = crate::pack_map::PackData::open_with_min_len(
2429                &path,
2430                super::COMMIT_GRAPH_MIN_FILE_LEN,
2431            )
2432            .expect("open layer");
2433            assert!(
2434                storage.is_mapped_backing(),
2435                "expected mmap backing for {} ({} bytes)",
2436                path.display(),
2437                len
2438            );
2439            saw_mapped_large_layer = true;
2440        }
2441        assert!(
2442            saw_mapped_large_layer,
2443            "expected at least one chain layer larger than 8192 bytes to use mmap"
2444        );
2445    }
2446}