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/// Generation reported for a commit the chain does not rank (absent, or no generation data).
935/// It sorts above every real generation, so history walks expand such commits first.
936pub(crate) const UNRANKED_GENERATION: u64 = (1u64 << 63) - 1;
937
938/// A commit as read from the chain by [`CommitGraphChain::graph_walk_entry`].
939#[derive(Debug, Clone)]
940pub(crate) struct GraphWalkEntry {
941    /// Root tree.
942    pub(crate) tree: ObjectId,
943    /// Parents, in commit order.
944    pub(crate) parents: Vec<ObjectId>,
945    /// Committer timestamp.
946    pub(crate) time: i64,
947    /// Corrected commit date, or [`UNRANKED_GENERATION`] when the chain has none.
948    pub(crate) generation: u64,
949}
950
951/// Loaded commit-graph chain (newest layer first, matching `commit-graph-chain` file order).
952#[derive(Debug, Clone)]
953pub struct CommitGraphChain {
954    layers: Vec<CommitGraphLayer>,
955}
956
957/// Test-only constructor (tip-first layer order).
958#[doc(hidden)]
959pub fn commit_graph_chain_from_layers_for_test(layers: Vec<CommitGraphLayer>) -> CommitGraphChain {
960    CommitGraphChain { layers }
961}
962
963impl CommitGraphChain {
964    /// Bloom settings from the newest layer, if that layer carries Bloom data.
965    #[must_use]
966    pub fn top_layer_bloom_settings(&self) -> Option<BloomFilterSettings> {
967        self.layers.first()?.bloom_settings
968    }
969
970    /// Total commits across all layers (Git `num_commits_in_base` offset for new layers).
971    #[must_use]
972    pub fn total_commits(&self) -> u32 {
973        self.layers.iter().map(|l| l.num_commits).sum()
974    }
975
976    /// Layer file paths from oldest base to newest (reverse of chain file order).
977    #[must_use]
978    pub fn layer_paths_oldest_first(&self) -> Vec<PathBuf> {
979        self.layers.iter().rev().map(|l| l.path.clone()).collect()
980    }
981
982    /// Number of layers in the chain.
983    #[must_use]
984    pub fn num_layers(&self) -> usize {
985        self.layers.len()
986    }
987
988    /// Commit counts per layer, tip-first (layer 0 is the newest tip).
989    #[must_use]
990    pub fn layer_commit_counts_tip_first(&self) -> Vec<u32> {
991        self.layers.iter().map(|l| l.num_commits).collect()
992    }
993
994    /// Whether each layer carries a generation-data (GDA2) chunk, tip-first.
995    #[must_use]
996    pub fn layer_has_generation_data_tip_first(&self) -> Vec<bool> {
997        self.layers
998            .iter()
999            .map(|l| l.chunk_generation_data.is_some())
1000            .collect()
1001    }
1002
1003    /// Layer hex hashes (from `graph-<hash>.graph` file stem), tip-first.
1004    #[must_use]
1005    pub fn layer_hashes_tip_first(&self) -> Vec<String> {
1006        self.layers.iter().map(|l| l.layer_display_id()).collect()
1007    }
1008
1009    /// Source object directory each layer was loaded from (its `.git/objects`),
1010    /// tip-first. Derived from the layer path: `<objdir>/info/commit-graphs/graph-*.graph`
1011    /// or `<objdir>/info/commit-graph`.
1012    #[must_use]
1013    pub fn layer_object_dirs_tip_first(&self) -> Vec<PathBuf> {
1014        self.layers
1015            .iter()
1016            .map(|l| {
1017                // .../objects/info/commit-graphs/graph-X.graph  -> .../objects
1018                // .../objects/info/commit-graph                 -> .../objects
1019                let p = l.path.as_path();
1020                let info = if p
1021                    .parent()
1022                    .and_then(|d| d.file_name())
1023                    .map(|n| n == "commit-graphs")
1024                    .unwrap_or(false)
1025                {
1026                    p.parent().and_then(|d| d.parent())
1027                } else {
1028                    p.parent()
1029                };
1030                info.and_then(|d| d.parent())
1031                    .map(Path::to_path_buf)
1032                    .unwrap_or_else(|| PathBuf::from("."))
1033            })
1034            .collect()
1035    }
1036
1037    /// A sub-chain made of the layers at tip-first indices `start..end`
1038    /// (so `start` becomes the new tip). Used by the writer when only some base
1039    /// layers are kept after a split merge.
1040    #[must_use]
1041    pub fn sub_chain_tip_first(&self, start: usize, end: usize) -> Option<Self> {
1042        let end = end.min(self.layers.len());
1043        if start >= end {
1044            return None;
1045        }
1046        Some(Self {
1047            layers: self.layers[start..end].to_vec(),
1048        })
1049    }
1050
1051    /// All commit OIDs in one layer (by tip-first index), in lexicographic order.
1052    #[must_use]
1053    pub fn layer_oids(&self, tip_first_idx: usize) -> Vec<ObjectId> {
1054        let Some(layer) = self.layers.get(tip_first_idx) else {
1055            return Vec::new();
1056        };
1057        let mut out = Vec::with_capacity(layer.num_commits as usize);
1058        for i in 0..layer.num_commits {
1059            if let Some(oid) = layer.oid_at_lex(i) {
1060                out.push(oid);
1061            }
1062        }
1063        out
1064    }
1065
1066    /// Load from `objects/info/commit-graph` or `objects/info/commit-graphs/commit-graph-chain`.
1067    ///
1068    /// Returns `Ok(None)` when no commit-graph exists. Corrupt graphs (including invalid GDO2)
1069    /// return [`Err`].
1070    pub fn try_load(objects_dir: &Path) -> Result<Option<Self>, Error> {
1071        Self::try_load_with_caches(objects_dir, None)
1072    }
1073
1074    /// Load a commit-graph chain, deduplicating warnings through `caches` when provided.
1075    pub fn try_load_with_caches(
1076        objects_dir: &Path,
1077        caches: Option<&crate::repo_caches::RepoCaches>,
1078    ) -> Result<Option<Self>, Error> {
1079        let info = objects_dir.join("info");
1080        let chain_path = info.join("commit-graphs").join("commit-graph-chain");
1081        if chain_path.is_file() {
1082            let algo =
1083                hash_algo_for_objects_dir(&crate::environment::Environment::empty(), objects_dir);
1084            let content = std::fs::read_to_string(&chain_path).map_err(Error::from)?;
1085            let mut layers = Vec::new();
1086            for line in content.lines() {
1087                let h = line.trim();
1088                if !commit_graph_chain_hash_is_valid(h, algo) {
1089                    continue;
1090                }
1091                let graph_path = info.join("commit-graphs").join(format!("graph-{h}.graph"));
1092                let layer = CommitGraphLayer::try_open(graph_path)?;
1093                // `add_graph_to_chain`: a layer that declares N base graphs must
1094                // carry a BASE chunk large enough to hold N hashes. If it is too
1095                // small, Git warns and refuses to add this layer (and anything
1096                // above it) to the chain, falling back to the object database for
1097                // those commits. `layers.len()` here is the number of base layers
1098                // already loaded below this one.
1099                let n = layers.len();
1100                if n > 0 && layer.base_chunk_size / layer.hash_len < n {
1101                    warn_once_for_base_chunk_too_small(caches, &layer.layer_display_id());
1102                    break;
1103                }
1104                layers.push(layer);
1105            }
1106            if layers.is_empty() {
1107                return Ok(None);
1108            }
1109            // The on-disk chain file lists layers base-first (Git order: line 1
1110            // is the base graph, the last line is the tip). Grit's internal
1111            // representation is tip-first, so reverse after reading.
1112            layers.reverse();
1113            let mut chain = Self { layers };
1114            chain.validate_bloom_compatibility(caches);
1115            return Ok(Some(chain));
1116        }
1117        let single = info.join("commit-graph");
1118        if single.is_file() {
1119            let layer = CommitGraphLayer::try_open(single.clone())?;
1120            let mut chain = Self {
1121                layers: vec![layer],
1122            };
1123            chain.validate_bloom_compatibility(caches);
1124            return Ok(Some(chain));
1125        }
1126        Ok(None)
1127    }
1128
1129    /// Like [`Self::try_load`] but ignores parse errors (returns `None`).
1130    pub fn load(objects_dir: &Path) -> Option<Self> {
1131        Self::try_load(objects_dir).ok().flatten()
1132    }
1133
1134    /// Full commit-graph verification (checksum, OID order, generation/parent checks).
1135    ///
1136    /// Mirrors `git commit-graph verify` — used by fsck, not the hot read path.
1137    ///
1138    /// # Errors
1139    ///
1140    /// Returns [`Error::CorruptObject`] when any layer fails verification.
1141    pub fn verify_on_disk(objects_dir: &Path) -> Result<(), Error> {
1142        let Some(chain) = Self::try_load(objects_dir)? else {
1143            return Ok(());
1144        };
1145        for path in chain.layer_paths_oldest_first() {
1146            let raw = std::fs::read(&path).map_err(Error::from)?;
1147            CommitGraphLayer::try_parse(path, raw)?;
1148        }
1149        Ok(())
1150    }
1151
1152    /// Load a split commit-graph chain that may live in (and reference layers across) an
1153    /// alternate object directory.
1154    ///
1155    /// Git's commit-graph chain is owned by exactly one object directory, but its layer
1156    /// `graph-<hash>.graph` files may be split between that directory and the alternate(s) it
1157    /// inherits from. This is the situation created by a local clone whose `info/alternates`
1158    /// points at a source repository that already has a split chain: a new `commit-graph write
1159    /// --split` produces a chain file (in the local object dir) that lists the alternate's base
1160    /// layer hashes followed by the locally-written tip layer.
1161    ///
1162    /// The chain file itself is taken from `objects_dir` if present, otherwise from the first
1163    /// `alt_dirs` entry that has one. Each referenced `graph-<hash>.graph` layer is then resolved
1164    /// by searching `objects_dir` first and each alternate in turn.
1165    ///
1166    /// # Parameters
1167    /// - `objects_dir`: the primary object directory (e.g. the local `.git/objects`).
1168    /// - `alt_dirs`: alternate object directories, in search order.
1169    ///
1170    /// # Returns
1171    /// `Ok(None)` when no chain/single graph is found in any directory; `Ok(Some(_))` on success.
1172    ///
1173    /// # Errors
1174    /// Returns an error if a referenced layer file is missing/unreadable or fails to parse.
1175    pub fn try_load_across(
1176        objects_dir: &Path,
1177        alt_dirs: &[PathBuf],
1178    ) -> Result<Option<Self>, Error> {
1179        Self::try_load_across_with_caches(objects_dir, alt_dirs, None)
1180    }
1181
1182    /// Like [`Self::try_load_across`] but reuses parsed commit-graph caches when provided.
1183    ///
1184    /// # Errors
1185    ///
1186    /// Returns an error if a referenced layer file is missing, unreadable, or fails to parse.
1187    pub fn try_load_across_with_caches(
1188        objects_dir: &Path,
1189        alt_dirs: &[PathBuf],
1190        caches: Option<&crate::repo_caches::RepoCaches>,
1191    ) -> Result<Option<Self>, Error> {
1192        let layer_dir = |dir: &Path| dir.join("info").join("commit-graphs");
1193        let resolve_layer = |hash: &str| -> Option<PathBuf> {
1194            let name = format!("graph-{hash}.graph");
1195            let local = layer_dir(objects_dir).join(&name);
1196            if local.is_file() {
1197                return Some(local);
1198            }
1199            alt_dirs
1200                .iter()
1201                .map(|d| layer_dir(d).join(&name))
1202                .find(|p| p.is_file())
1203        };
1204
1205        // A split write only chains on an existing *split chain*. Precedence for the chain owner:
1206        //   1. the local dir's chain file (its layers may live across the alternate),
1207        //   2. the local dir's single (non-split) `info/commit-graph` (Git migrates it),
1208        //   3. a *chain file* in an alternate.
1209        // An alternate's single (non-split) graph file is never used as a base — Git refuses to
1210        // base a chain on a plain commit-graph file (t5324 "fork and fail to base a chain on a
1211        // commit-graph file").
1212        let local_chain = layer_dir(objects_dir).join("commit-graph-chain");
1213        let local_single = objects_dir.join("info").join("commit-graph");
1214        let chain_path = if local_chain.is_file() {
1215            local_chain
1216        } else if local_single.is_file() {
1217            return Self::try_load_with_caches(objects_dir, caches);
1218        } else {
1219            match alt_dirs
1220                .iter()
1221                .map(PathBuf::as_path)
1222                .find(|d| layer_dir(d).join("commit-graph-chain").is_file())
1223            {
1224                Some(owner) => layer_dir(owner).join("commit-graph-chain"),
1225                None => return Ok(None),
1226            }
1227        };
1228
1229        let algo =
1230            hash_algo_for_objects_dir(&crate::environment::Environment::empty(), objects_dir);
1231        let content = std::fs::read_to_string(&chain_path).map_err(Error::from)?;
1232        let mut layers = Vec::new();
1233        for line in content.lines() {
1234            let h = line.trim();
1235            if !commit_graph_chain_hash_is_valid(h, algo) {
1236                continue;
1237            }
1238            let graph_path = resolve_layer(h).ok_or_else(|| {
1239                Error::from(std::io::Error::new(
1240                    std::io::ErrorKind::NotFound,
1241                    format!("commit-graph layer graph-{h}.graph not found"),
1242                ))
1243            })?;
1244            let layer = CommitGraphLayer::try_open(graph_path)?;
1245            let n = layers.len();
1246            if n > 0 && layer.base_chunk_size / layer.hash_len < n {
1247                warn_once_for_base_chunk_too_small(caches, &layer.layer_display_id());
1248                break;
1249            }
1250            layers.push(layer);
1251        }
1252        if layers.is_empty() {
1253            return Ok(None);
1254        }
1255        layers.reverse();
1256        let mut chain = Self { layers };
1257        chain.validate_bloom_compatibility(caches);
1258        Ok(Some(chain))
1259    }
1260
1261    fn validate_bloom_compatibility(&mut self, caches: Option<&crate::repo_caches::RepoCaches>) {
1262        // Git walks the chain from the tip down to the base (`for (; g; g =
1263        // g->base_graph)` in validate_mixed_bloom_settings), so the *topmost*
1264        // (tip) layer's Bloom settings become the reference and any
1265        // incompatible *lower* (base) layer is disabled. `self.layers` is
1266        // stored tip-first internally, so iterate forward to match.
1267        let mut ref_settings: Option<BloomFilterSettings> = None;
1268        for layer in &mut self.layers {
1269            let Some(bs) = layer.bloom_settings else {
1270                continue;
1271            };
1272            match ref_settings {
1273                None => ref_settings = Some(bs),
1274                Some(r) => {
1275                    if r.hash_version != bs.hash_version
1276                        || r.num_hashes != bs.num_hashes
1277                        || r.bits_per_entry != bs.bits_per_entry
1278                    {
1279                        let id = layer.layer_display_id();
1280                        // Git loads the commit-graph chain once per process and caches
1281                        // it, so the "disabling Bloom filters" warning is emitted at
1282                        // most once per layer. Grit re-reads the chain from disk several
1283                        // times within a single command (settings probe, commit set,
1284                        // filter reuse), so dedupe the warning per layer id to match.
1285                        warn_once_for_disabled_bloom_layer(caches, &id);
1286                        layer.disable_bloom();
1287                    }
1288                }
1289            }
1290        }
1291    }
1292
1293    /// Existing changed-path Bloom filter bytes for `oid`, if present in any layer
1294    /// whose Bloom settings are compatible with `want`. Returns `Some(bytes)` (possibly
1295    /// empty for an empty/no-change filter) when the filter can be reused verbatim, or
1296    /// `None` when no compatible filter exists. Used by the writer to backfill / reuse
1297    /// already-computed filters (Git counts these as `filter_not_computed`).
1298    pub fn existing_filter_bytes(
1299        &self,
1300        oid: &ObjectId,
1301        want: &BloomFilterSettings,
1302    ) -> Option<Vec<u8>> {
1303        let (layer_idx, lex) = self.find_commit(oid)?;
1304        let layer = &self.layers[layer_idx];
1305        let settings = layer.bloom_settings.as_ref()?;
1306        if settings.hash_version != want.hash_version
1307            || settings.num_hashes != want.num_hashes
1308            || settings.bits_per_entry != want.bits_per_entry
1309        {
1310            return None;
1311        }
1312        // Git only reuses a loaded filter when its on-disk length is non-zero
1313        // (`get_or_compute_bloom_filter`: `if (filter->data && filter->len)`).
1314        // A zero-length entry means the filter was skipped (over the
1315        // `--max-new-filters` budget) and must be (re)computed. Empty-diff
1316        // filters are stored with length 1 (a single zero byte) and so are
1317        // reused. Truncated-large filters are length 1 (0xff) and reused too.
1318        match layer.bloom_filter_slice(lex) {
1319            Some(s) if !s.is_empty() => Some(s.to_vec()),
1320            _ => None,
1321        }
1322    }
1323
1324    /// Existing non-empty filter bytes for `oid` whose stored `hash_version`
1325    /// differs from `want.hash_version` but is otherwise compatible (same
1326    /// `num_hashes`/`bits_per_entry`). Used to detect filters that may be
1327    /// *upgraded* (relabeled to the new version without recomputation) when the
1328    /// changed paths contain no high-bit bytes.
1329    pub fn upgradable_filter_bytes(
1330        &self,
1331        oid: &ObjectId,
1332        want: &BloomFilterSettings,
1333    ) -> Option<Vec<u8>> {
1334        let (layer_idx, lex) = self.find_commit(oid)?;
1335        let layer = &self.layers[layer_idx];
1336        let settings = layer.bloom_settings.as_ref()?;
1337        if settings.hash_version == want.hash_version {
1338            return None;
1339        }
1340        if settings.num_hashes != want.num_hashes || settings.bits_per_entry != want.bits_per_entry
1341        {
1342            return None;
1343        }
1344        match layer.bloom_filter_slice(lex) {
1345            Some(s) if !s.is_empty() => Some(s.to_vec()),
1346            _ => None,
1347        }
1348    }
1349
1350    /// Bloom filter settings stored for `oid`'s commit-graph layer, if any.
1351    pub(crate) fn bloom_settings_for_commit(&self, oid: &ObjectId) -> Option<BloomFilterSettings> {
1352        let (layer_idx, _) = self.find_commit(oid)?;
1353        self.layers[layer_idx].bloom_settings
1354    }
1355
1356    /// Lexicographic position in the full chain, or `None` if not in any layer.
1357    pub fn find_commit(&self, oid: &ObjectId) -> Option<(usize, u32)> {
1358        for (i, layer) in self.layers.iter().enumerate() {
1359            if let Some(lex) = layer.bsearch_oid(oid) {
1360                return Some((i, lex));
1361            }
1362        }
1363        None
1364    }
1365
1366    /// Global commit-graph position (Git `graph_pos`): base layers first, then newer layers.
1367    pub fn global_position(&self, oid: &ObjectId) -> Option<u32> {
1368        let (layer_idx, lex) = self.find_commit(oid)?;
1369        let below: u32 = self.layers[layer_idx + 1..]
1370            .iter()
1371            .map(|l| l.num_commits)
1372            .sum();
1373        Some(below + lex)
1374    }
1375
1376    /// Inverse of [`Self::global_position`]: the OID at a base-first global
1377    /// position (used to resolve CDAT parent words to OIDs).
1378    fn oid_at_global_position(&self, pos: u32) -> Option<ObjectId> {
1379        // `layers` is tip-first; global positions count base layers first.
1380        let mut remaining = pos;
1381        for layer in self.layers.iter().rev() {
1382            if remaining < layer.num_commits {
1383                return layer.oid_at_lex(remaining);
1384            }
1385            remaining -= layer.num_commits;
1386        }
1387        None
1388    }
1389
1390    /// Commit parents and committer time read straight from the commit-graph file
1391    /// (no object decompression). Returns `None` when `oid` is not in the graph.
1392    pub fn graph_commit(&self, oid: &ObjectId) -> Option<(Vec<ObjectId>, i64)> {
1393        let (layer_idx, lex) = self.find_commit(oid)?;
1394        self.graph_commit_at(layer_idx, lex)
1395    }
1396
1397    fn graph_commit_at(&self, layer_idx: usize, lex: u32) -> Option<(Vec<ObjectId>, i64)> {
1398        let layer = &self.layers[layer_idx];
1399        let (_, _, commit_time) = layer.cdat_record(lex)?;
1400        let oids = layer
1401            .parent_global_positions(lex)?
1402            .into_iter()
1403            .filter_map(|pos| self.oid_at_global_position(pos))
1404            .collect();
1405        Some((oids, commit_time))
1406    }
1407
1408    /// Root tree OID for `oid` from commit-graph CDAT (no object read).
1409    pub(crate) fn graph_commit_tree(&self, oid: &ObjectId) -> Option<ObjectId> {
1410        let (layer_idx, lex) = self.find_commit(oid)?;
1411        self.layers[layer_idx].cdat_root_tree(lex)
1412    }
1413
1414    /// Tree and parent OIDs for blame when `oid` is indexed in the chain.
1415    pub(crate) fn graph_commit_meta(&self, oid: &ObjectId) -> Option<(ObjectId, Vec<ObjectId>)> {
1416        let (layer_idx, lex) = self.find_commit(oid)?;
1417        let tree = self.layers[layer_idx].cdat_root_tree(lex)?;
1418        let (parents, _) = self.graph_commit_at(layer_idx, lex)?;
1419        Some((tree, parents))
1420    }
1421
1422    /// Everything a date- or generation-ordered history walk needs for `oid`, from a single
1423    /// lookup and without decompressing the commit. `None` when `oid` is not in the chain.
1424    pub(crate) fn graph_walk_entry(&self, oid: &ObjectId) -> Option<GraphWalkEntry> {
1425        let (layer_idx, lex) = self.find_commit(oid)?;
1426        let layer = &self.layers[layer_idx];
1427        let tree = layer.cdat_root_tree(lex)?;
1428        let (parents, time) = self.graph_commit_at(layer_idx, lex)?;
1429        Some(GraphWalkEntry {
1430            tree,
1431            parents,
1432            time,
1433            generation: layer
1434                .corrected_commit_generation(lex)
1435                .unwrap_or(UNRANKED_GENERATION),
1436        })
1437    }
1438
1439    /// Generation number for ordering merge-base walks (`2^63-1` when unknown).
1440    #[must_use]
1441    pub fn commit_generation_for_walk(&self, oid: &ObjectId) -> u64 {
1442        let Some((layer_idx, lex)) = self.find_commit(oid) else {
1443            return UNRANKED_GENERATION;
1444        };
1445        self.layers[layer_idx]
1446            .corrected_commit_generation(lex)
1447            .unwrap_or(UNRANKED_GENERATION)
1448    }
1449
1450    /// Parent commit positions in global graph order (CDAT + EXTRA_EDGES), or `None` if `oid`
1451    /// is not in the chain.
1452    #[must_use]
1453    pub fn parent_global_positions(&self, oid: &ObjectId) -> Option<Vec<u32>> {
1454        let (layer_idx, lex) = self.find_commit(oid)?;
1455        self.layers[layer_idx].parent_global_positions(lex)
1456    }
1457
1458    /// Count commits reachable from `tips` minus those reachable from `hide`, using only
1459    /// commit-graph parent links (no object reads). Returns `None` when any tip is missing
1460    /// from the graph or parent data cannot be decoded from the file.
1461    #[must_use]
1462    pub fn count_reachable_commits(
1463        &self,
1464        tips: &[ObjectId],
1465        hide: &[ObjectId],
1466        first_parent_only: bool,
1467        shallow: &std::collections::HashSet<ObjectId>,
1468    ) -> Option<usize> {
1469        if self.total_commits() == 0 {
1470            return None;
1471        }
1472        let mut seen = self.reachable_positions(tips, first_parent_only, shallow)?;
1473        if hide.is_empty() {
1474            return Some(seen.len());
1475        }
1476        let hidden = self.reachable_positions(hide, first_parent_only, shallow)?;
1477        seen.retain(|pos| !hidden.contains(pos));
1478        Some(seen.len())
1479    }
1480
1481    fn reachable_positions(
1482        &self,
1483        tips: &[ObjectId],
1484        first_parent_only: bool,
1485        shallow: &std::collections::HashSet<ObjectId>,
1486    ) -> Option<std::collections::HashSet<u32>> {
1487        let total = self.total_commits();
1488        let mut stack: Vec<u32> = Vec::new();
1489        for tip in tips {
1490            stack.push(self.global_position(tip)?);
1491        }
1492        let mut seen = std::collections::HashSet::new();
1493        while let Some(pos) = stack.pop() {
1494            if pos >= total || !seen.insert(pos) {
1495                continue;
1496            }
1497            #[cfg(test)]
1498            crate::merge_base::intersect_walk_test_hooks::record_commit_parent_lookup();
1499            let oid = self.oid_at_global_position(pos)?;
1500            let parents = if shallow.contains(&oid) {
1501                Vec::new()
1502            } else {
1503                self.parent_global_positions(&oid)?
1504            };
1505            let parents = if first_parent_only {
1506                parents.into_iter().take(1).collect::<Vec<_>>()
1507            } else {
1508                parents
1509            };
1510            for p in parents {
1511                if p >= total {
1512                    return None;
1513                }
1514                stack.push(p);
1515            }
1516        }
1517        Some(seen)
1518    }
1519
1520    /// All commit OIDs in the chain (oldest base first, then newer layers).
1521    pub fn all_oids_in_order(&self) -> Vec<ObjectId> {
1522        let mut out = Vec::new();
1523        for layer in self.layers.iter().rev() {
1524            for i in 0..layer.num_commits {
1525                if let Some(oid) = layer.oid_at_lex(i) {
1526                    out.push(oid);
1527                }
1528            }
1529        }
1530        out
1531    }
1532
1533    /// Consult Bloom filters for a single-parent commit before diffing trees.
1534    pub fn bloom_precheck_for_paths(
1535        &self,
1536        _odb: &Odb,
1537        oid: ObjectId,
1538        pathspecs: &[String],
1539        bloom_cwd: Option<&str>,
1540        requested_hash_version: i32,
1541        read_changed_paths: bool,
1542    ) -> std::result::Result<BloomPrecheck, crate::error::Error> {
1543        if !read_changed_paths {
1544            return Ok(BloomPrecheck::Inapplicable);
1545        }
1546        let Some((layer_idx, lex)) = self.find_commit(&oid) else {
1547            return Ok(BloomPrecheck::NotInGraph);
1548        };
1549        let layer = &self.layers[layer_idx];
1550        let Some(settings) = layer.bloom_settings.as_ref() else {
1551            return Ok(BloomPrecheck::FilterNotPresent);
1552        };
1553        let effective_version = if requested_hash_version < 0 {
1554            settings.hash_version as i32
1555        } else {
1556            requested_hash_version
1557        };
1558        if effective_version != settings.hash_version as i32 {
1559            return Ok(BloomPrecheck::FilterNotPresent);
1560        }
1561
1562        // Git computes the changed-path Bloom filter for every commit (including merges)
1563        // relative to its first parent, and `rev_compare_tree` consults it for the
1564        // first-parent comparison only (`nth_parent == 0`). The caller is responsible for
1565        // restricting the precheck to the first parent, so merge commits are handled here
1566        // exactly like single-parent commits.
1567        let filter = match layer.bloom_filter_slice(lex) {
1568            Some(s) => s,
1569            None => return Ok(BloomPrecheck::FilterNotPresent),
1570        };
1571        if filter.is_empty() {
1572            return Ok(BloomPrecheck::FilterNotPresent);
1573        }
1574
1575        // Git `bloom_filter_contains_vec`: within one pathspec, every prefix key must match;
1576        // multiple pathspecs are ORed (`revision.c` loop over `bloom_keyvecs_nr`).
1577        let mut any_pathspec_maybe = false;
1578        let mut checked_any_keys = false;
1579        for spec in pathspecs {
1580            if spec.is_empty() || crate::pathspec::pathspec_is_exclude(spec) {
1581                continue;
1582            }
1583            let Some(norm) = crate::pathspec::bloom_lookup_prefix_with_cwd(spec, bloom_cwd) else {
1584                continue;
1585            };
1586            let keys = bloom_keyvec_for_path(norm.as_str(), settings);
1587            if keys.is_empty() {
1588                continue;
1589            }
1590            checked_any_keys = true;
1591            let mut all_keys_maybe = true;
1592            for key in &keys {
1593                match bloom_filter_contains(key, filter, settings) {
1594                    Ok(true) => {}
1595                    Ok(false) => {
1596                        all_keys_maybe = false;
1597                        break;
1598                    }
1599                    Err(crate::bloom::BloomFilterInvalid) => {
1600                        all_keys_maybe = true;
1601                        break;
1602                    }
1603                }
1604            }
1605            if all_keys_maybe {
1606                any_pathspec_maybe = true;
1607                break;
1608            }
1609        }
1610        if checked_any_keys && !any_pathspec_maybe {
1611            return Ok(BloomPrecheck::DefinitelyNot);
1612        }
1613        Ok(BloomPrecheck::Maybe)
1614    }
1615
1616    /// Like [`Self::bloom_precheck_for_paths`], but reuses precomputed Bloom keys for one path.
1617    pub(crate) fn bloom_definitely_not_for_keys(
1618        &self,
1619        oid: ObjectId,
1620        path_keys: &[Vec<u32>],
1621    ) -> bool {
1622        if path_keys.is_empty() {
1623            return false;
1624        }
1625        let Some((layer_idx, lex)) = self.find_commit(&oid) else {
1626            return false;
1627        };
1628        let layer = &self.layers[layer_idx];
1629        let Some(settings) = layer.bloom_settings.as_ref() else {
1630            return false;
1631        };
1632        let Some(filter) = layer.bloom_filter_slice(lex) else {
1633            return false;
1634        };
1635        if filter.is_empty() {
1636            return false;
1637        }
1638        bloom_definitely_not_from_filter(filter, settings, path_keys)
1639    }
1640}
1641
1642/// Git `bloom_filter_contains_vec` precheck for one path's prefix keys against a filter slice.
1643pub(crate) fn bloom_definitely_not_from_filter(
1644    filter: &[u8],
1645    settings: &crate::bloom::BloomFilterSettings,
1646    path_keys: &[Vec<u32>],
1647) -> bool {
1648    use crate::bloom::bloom_filter_contains;
1649
1650    for key in path_keys {
1651        match bloom_filter_contains(key, filter, settings) {
1652            Ok(false) => return true,
1653            Ok(true) => {}
1654            Err(crate::bloom::BloomFilterInvalid) => return false,
1655        }
1656    }
1657    false
1658}
1659
1660/// Compute changed paths between parent and commit trees (recursive diff, no rename detection).
1661pub fn diff_changed_paths_for_bloom(
1662    odb: &Odb,
1663    parent_tree: Option<ObjectId>,
1664    commit_tree: ObjectId,
1665) -> crate::error::Result<(Vec<String>, usize)> {
1666    use crate::diff::diff_trees;
1667    let entries = diff_trees(odb, parent_tree.as_ref(), Some(&commit_tree), "")?;
1668    let raw_len = entries.len();
1669    let mut paths = Vec::new();
1670    for e in entries {
1671        let p = e.path().to_string();
1672        if !p.is_empty() {
1673            paths.push(p);
1674        }
1675    }
1676    Ok((paths, raw_len))
1677}
1678
1679/// Re-export for `commit-graph` write.
1680pub use crate::bloom::collect_changed_paths_for_bloom;
1681
1682/// Build Bloom filter bytes for one commit; returns cumulative size contribution for BIDX.
1683pub fn bloom_filter_for_commit_write(
1684    odb: &Odb,
1685    parents: &[ObjectId],
1686    tree_oid: ObjectId,
1687    settings: &BloomFilterSettings,
1688) -> crate::error::Result<(Vec<u8>, BloomBuildOutcome)> {
1689    // Git computes the changed-path filter against the *first* parent only,
1690    // regardless of how many parents a commit has (see bloom.c:
1691    // `diff_tree_oid(&c->parents->item->object.oid, ...)`).
1692    let (changed_paths_vec, raw_count) = if let Some(first_parent) = parents.first() {
1693        let p = odb.commit_tree(first_parent)?;
1694        diff_changed_paths_for_bloom(odb, Some(p), tree_oid)?
1695    } else {
1696        diff_changed_paths_for_bloom(odb, None, tree_oid)?
1697    };
1698    let set = collect_changed_paths_for_bloom(&changed_paths_vec);
1699    Ok(crate::bloom::build_bloom_filter_data(
1700        &set, raw_count, settings,
1701    ))
1702}
1703
1704/// Whether any path component in `tree` (recursively) contains a byte with the
1705/// high bit set (`& 0x80`). Mirrors `bloom.c:has_entries_with_high_bit`, used to
1706/// decide whether a v1 changed-path Bloom filter can be relabeled as v2 without
1707/// recomputation (v1/v2 hashing only differs for bytes >= 0x80).
1708fn tree_has_high_bit_paths(odb: &Odb, tree_oid: ObjectId) -> bool {
1709    let Ok(obj) = odb.read(&tree_oid) else {
1710        // Git treats an unreadable tree conservatively (no upgrade).
1711        return true;
1712    };
1713    let Ok(entries) = crate::objects::parse_tree(&obj.data) else {
1714        return true;
1715    };
1716    for e in &entries {
1717        if e.name.iter().any(|&b| b & 0x80 != 0) {
1718            return true;
1719        }
1720        if e.mode == 0o040000 && tree_has_high_bit_paths(odb, e.oid) {
1721            return true;
1722        }
1723    }
1724    false
1725}
1726
1727/// Whether a commit's tree (recursively) has any high-bit path bytes
1728/// (`bloom.c:commit_tree_has_high_bit_paths`).
1729pub fn commit_tree_has_high_bit_paths(odb: &Odb, commit_oid: ObjectId) -> bool {
1730    match odb.commit_tree(&commit_oid) {
1731        Ok(tree) => tree_has_high_bit_paths(odb, tree),
1732        Err(_) => true,
1733    }
1734}
1735
1736/// Parse all chunks for `test-tool read-graph` / debugging.
1737pub fn parse_graph_file(path: &Path) -> Option<ParsedGraphDump> {
1738    let raw = std::fs::read(path).ok()?;
1739    if raw.len() < 28 {
1740        return None;
1741    }
1742    let hash_len = commit_graph_hash_len(raw[5])?;
1743    let body = &raw[..raw.len() - hash_len];
1744    if body.len() < 8 || &body[0..4] != SIGNATURE {
1745        return None;
1746    }
1747    let header_word = u32::from_be_bytes(body[0..4].try_into().ok()?);
1748    let num_chunks = body[6] as usize;
1749    let toc_start = 8;
1750    let mut present: std::collections::HashSet<u32> = std::collections::HashSet::new();
1751    for i in 0..num_chunks {
1752        let e = toc_start + i * 12;
1753        let id = u32::from_be_bytes(body[e..e + 4].try_into().ok()?);
1754        present.insert(id);
1755    }
1756    // `git/t/helper/test-read-graph.c` prints a fixed set of recognized chunks in
1757    // a fixed order, omitting the BASE chunk and any unknown chunk.
1758    let mut chunk_names: Vec<String> = Vec::new();
1759    for (id, label) in [
1760        (CHUNK_OID_FANOUT, "oid_fanout"),
1761        (CHUNK_OID_LOOKUP, "oid_lookup"),
1762        (CHUNK_COMMIT_DATA, "commit_metadata"),
1763        (CHUNK_GENERATION_DATA, "generation_data"),
1764        (CHUNK_GENERATION_DATA_OVERFLOW, "generation_data_overflow"),
1765        (CHUNK_EXTRA_EDGES, "extra_edges"),
1766        (CHUNK_BLOOM_INDEXES, "bloom_indexes"),
1767        (CHUNK_BLOOM_DATA, "bloom_data"),
1768    ] {
1769        if present.contains(&id) {
1770            chunk_names.push(label.to_string());
1771        }
1772    }
1773    let layer = CommitGraphLayer::parse(path.to_path_buf(), raw.clone())?;
1774    let bloom_opt = layer.bloom_settings.map(|s| {
1775        format!(
1776            " bloom({},{},{})",
1777            s.hash_version, s.bits_per_entry, s.num_hashes
1778        )
1779    });
1780    let mut options = String::new();
1781    if let Some(b) = bloom_opt {
1782        options.push_str(&b);
1783    }
1784    if layer.read_generation_data {
1785        options.push_str(" read_generation_data");
1786    }
1787    Some(ParsedGraphDump {
1788        header_word,
1789        version: body[4],
1790        hash_ver: body[5],
1791        num_chunks: body[6],
1792        reserved: body[7],
1793        num_commits: layer.num_commits,
1794        chunks: chunk_names.join(" "),
1795        options,
1796    })
1797}
1798
1799/// Parsed commit-graph header fields for debugging and `--dump` style output.
1800pub struct ParsedGraphDump {
1801    /// Commit-graph file signature word (`0x43475048`).
1802    pub header_word: u32,
1803    /// Commit-graph format version byte.
1804    pub version: u8,
1805    /// Object hash version used in the graph (`1` = SHA-1, `2` = SHA-256).
1806    pub hash_ver: u8,
1807    /// Number of chunk types present in the file.
1808    pub num_chunks: u8,
1809    /// Reserved header byte (must be zero in current formats).
1810    pub reserved: u8,
1811    /// Number of commits encoded in the graph.
1812    pub num_commits: u32,
1813    /// Space-separated chunk id names in file order.
1814    pub chunks: String,
1815    /// Human-readable graph option flags from the header.
1816    pub options: String,
1817}
1818
1819/// Dump hex lines of Bloom filters (one per commit, empty line for empty filter).
1820pub fn dump_bloom_filters(path: &Path) -> Option<Vec<String>> {
1821    let raw = std::fs::read(path).ok()?;
1822    let layer = CommitGraphLayer::parse(path.to_path_buf(), raw)?;
1823    let mut out = Vec::new();
1824    for i in 0..layer.num_commits {
1825        let slice = layer.bloom_filter_slice(i).unwrap_or(&[]);
1826        if slice.is_empty() {
1827            out.push(String::new());
1828        } else {
1829            let hex: String = slice.iter().map(|b| format!("{b:02x}")).collect();
1830            out.push(hex);
1831        }
1832    }
1833    Some(out)
1834}
1835
1836#[cfg(test)]
1837mod tests {
1838    use std::collections::HashMap;
1839
1840    use crate::bloom::BloomFilterSettings;
1841    use crate::commit_graph_write::{build_commit_graph_bytes, load_commit_graph_commit_info};
1842    use crate::index::{Index, IndexEntry, MODE_REGULAR};
1843    use crate::objects::{serialize_commit, CommitData, ObjectId, ObjectKind};
1844    use crate::repo::{init_repository, Repository};
1845    use crate::write_tree::write_tree_from_index;
1846
1847    use crate::commit_graph_write::CommitGraphCommitInfo;
1848    use crate::objects::OidHashMap;
1849
1850    fn infos_std(
1851        infos: &OidHashMap<CommitGraphCommitInfo>,
1852    ) -> HashMap<ObjectId, CommitGraphCommitInfo> {
1853        infos.iter().map(|(k, v)| (*k, v.clone())).collect()
1854    }
1855
1856    fn empty_byte_filters() -> HashMap<ObjectId, Vec<u8>> {
1857        HashMap::new()
1858    }
1859
1860    fn one_commit_repo() -> (tempfile::TempDir, Repository, ObjectId) {
1861        let dir = tempfile::tempdir().expect("tempdir");
1862        let repo = init_repository(
1863            dir.path(),
1864            false,
1865            "main",
1866            None,
1867            crate::RefStorageFormat::Files,
1868        )
1869        .expect("init");
1870        let mut index = Index::new();
1871        index.hash_algo = repo.odb().hash_algo();
1872        let blob = repo.odb().write(ObjectKind::Blob, b"x\n").expect("blob");
1873        index.add_or_replace(IndexEntry {
1874            ctime_sec: 0,
1875            ctime_nsec: 0,
1876            mtime_sec: 0,
1877            mtime_nsec: 0,
1878            dev: 0,
1879            ino: 0,
1880            mode: MODE_REGULAR,
1881            uid: 0,
1882            gid: 0,
1883            size: 2,
1884            oid: blob,
1885            flags: 4,
1886            flags_extended: None,
1887            path: b"f.txt".to_vec(),
1888            base_index_pos: 0,
1889        });
1890        let tree = write_tree_from_index(&repo.odb(), &index, "").expect("tree");
1891        let raw = serialize_commit(&CommitData {
1892            tree,
1893            parents: vec![],
1894            author: "T <t@example.com> 100 +0000".into(),
1895            committer: "T <t@example.com> 100 +0000".into(),
1896            author_raw: Vec::new(),
1897            committer_raw: Vec::new(),
1898            encoding: None,
1899            message: "m\n".into(),
1900            raw_message: None,
1901            extra_headers: Vec::new(),
1902        });
1903        let commit = repo.odb().write(ObjectKind::Commit, &raw).expect("commit");
1904        crate::refs::write_ref(&repo.git_dir(), "refs/heads/main", &commit).expect("ref");
1905        (dir, repo, commit)
1906    }
1907
1908    fn reseal_graph(bytes: &mut Vec<u8>, algo: crate::objects::HashAlgo) {
1909        let hash_len = algo.len();
1910        let body_len = bytes.len().saturating_sub(hash_len);
1911        let digest = algo.checksum_digest(&bytes[..body_len]);
1912        bytes.truncate(body_len);
1913        bytes.extend_from_slice(&digest);
1914    }
1915
1916    #[test]
1917    fn try_parse_rejects_bad_signature_and_truncated_toc() {
1918        let (_dir, repo, commit) = one_commit_repo();
1919        let info = load_commit_graph_commit_info(&repo.odb(), commit).expect("info");
1920        let mut infos = crate::objects::oid_hash_map();
1921        infos.insert(commit, info);
1922        let bloom = BloomFilterSettings::default();
1923        let (mut bytes, _) = build_commit_graph_bytes(
1924            &[commit],
1925            &infos_std(&infos),
1926            &repo.odb(),
1927            false,
1928            &bloom,
1929            None,
1930            &[],
1931            None,
1932            &empty_byte_filters(),
1933            &empty_byte_filters(),
1934            true,
1935        )
1936        .expect("write");
1937        let algo = repo.odb().hash_algo();
1938        bytes[0] = 0;
1939        reseal_graph(&mut bytes, algo);
1940        let err = super::CommitGraphLayer::try_parse(std::path::PathBuf::from("g"), bytes.clone())
1941            .unwrap_err();
1942        assert!(matches!(err, crate::error::Error::CorruptObject(_)));
1943        bytes.truncate(40);
1944        let err =
1945            super::CommitGraphLayer::try_parse(std::path::PathBuf::from("g"), bytes).unwrap_err();
1946        assert!(matches!(err, crate::error::Error::CorruptObject(_)));
1947        let (mut good, _) = build_commit_graph_bytes(
1948            &[commit],
1949            &infos_std(&infos),
1950            &repo.odb(),
1951            false,
1952            &bloom,
1953            None,
1954            &[],
1955            None,
1956            &empty_byte_filters(),
1957            &empty_byte_filters(),
1958            true,
1959        )
1960        .expect("write");
1961        good[4] = 2;
1962        reseal_graph(&mut good, algo);
1963        let err =
1964            super::CommitGraphLayer::try_parse(std::path::PathBuf::from("g"), good).unwrap_err();
1965        assert!(matches!(err, crate::error::Error::CorruptObject(_)));
1966        let (mut checksum_bad, _) = build_commit_graph_bytes(
1967            &[commit],
1968            &infos_std(&infos),
1969            &repo.odb(),
1970            false,
1971            &bloom,
1972            None,
1973            &[],
1974            None,
1975            &empty_byte_filters(),
1976            &empty_byte_filters(),
1977            true,
1978        )
1979        .expect("write");
1980        let last = checksum_bad.len() - 1;
1981        checksum_bad[last] ^= 0xff;
1982        let err = super::CommitGraphLayer::try_parse(std::path::PathBuf::from("g"), checksum_bad)
1983            .unwrap_err();
1984        assert!(matches!(err, crate::error::Error::CorruptObject(_)));
1985    }
1986
1987    #[test]
1988    fn parse_graph_file_returns_none_for_invalid_inputs() {
1989        let dir = tempfile::tempdir().expect("tempdir");
1990        let missing = dir.path().join("nope.graph");
1991        assert!(super::parse_graph_file(&missing).is_none());
1992        std::fs::write(dir.path().join("tiny.graph"), b"CGPH").expect("write");
1993        assert!(super::parse_graph_file(&dir.path().join("tiny.graph")).is_none());
1994        assert!(super::dump_bloom_filters(&missing).is_none());
1995    }
1996
1997    #[test]
1998    fn try_load_missing_graph_is_none() {
1999        let dir = tempfile::tempdir().expect("tempdir");
2000        let objects = dir.path().join("objects");
2001        std::fs::create_dir_all(objects.join("info")).expect("info");
2002        assert!(super::CommitGraphChain::try_load(&objects)
2003            .expect("ok")
2004            .is_none());
2005    }
2006
2007    #[test]
2008    fn try_load_across_finds_single_graph() {
2009        let (_dir, repo, _commit) = one_commit_repo();
2010        let objects = repo.odb().objects_dir();
2011        let sorted = vec![_commit];
2012        let info = load_commit_graph_commit_info(&repo.odb(), _commit).expect("info");
2013        let mut infos = crate::objects::oid_hash_map();
2014        infos.insert(_commit, info);
2015        let bloom = BloomFilterSettings::default();
2016        let (bytes, _) = build_commit_graph_bytes(
2017            &sorted,
2018            &infos_std(&infos),
2019            &repo.odb(),
2020            false,
2021            &bloom,
2022            None,
2023            &[],
2024            None,
2025            &empty_byte_filters(),
2026            &empty_byte_filters(),
2027            true,
2028        )
2029        .expect("write");
2030        std::fs::write(objects.join("info/commit-graph"), bytes).expect("write");
2031        let chain = super::CommitGraphChain::try_load_across(objects, &[])
2032            .expect("across")
2033            .expect("some");
2034        assert_eq!(chain.total_commits(), 1);
2035    }
2036
2037    #[test]
2038    fn try_parse_rejects_oidf_toc_offset_out_of_range() {
2039        let (_dir, repo, commit) = one_commit_repo();
2040        let info = load_commit_graph_commit_info(&repo.odb(), commit).expect("info");
2041        let mut infos = crate::objects::oid_hash_map();
2042        infos.insert(commit, info);
2043        let bloom = BloomFilterSettings::default();
2044        let (mut bytes, _) = build_commit_graph_bytes(
2045            &[commit],
2046            &infos_std(&infos),
2047            &repo.odb(),
2048            false,
2049            &bloom,
2050            None,
2051            &[],
2052            None,
2053            &empty_byte_filters(),
2054            &empty_byte_filters(),
2055            true,
2056        )
2057        .expect("write");
2058        let algo = repo.odb().hash_algo();
2059        let num_chunks = bytes[6] as usize;
2060        for i in 0..num_chunks {
2061            let e = 8 + i * 12;
2062            let id = u32::from_be_bytes(bytes[e..e + 4].try_into().unwrap());
2063            if id == 0x4f49_4446 {
2064                bytes[e + 4..e + 12].copy_from_slice(&u64::MAX.to_be_bytes());
2065                break;
2066            }
2067        }
2068        reseal_graph(&mut bytes, algo);
2069        let err =
2070            super::CommitGraphLayer::try_parse(std::path::PathBuf::from("g"), bytes).unwrap_err();
2071        assert!(matches!(err, crate::error::Error::CorruptObject(_)));
2072    }
2073
2074    #[test]
2075    fn generation_overflow_without_gdo2_fails_parse() {
2076        let (_dir, repo, commit) = one_commit_repo();
2077        let info = load_commit_graph_commit_info(&repo.odb(), commit).expect("info");
2078        let mut infos = crate::objects::oid_hash_map();
2079        infos.insert(commit, info);
2080        let bloom = BloomFilterSettings::default();
2081        let (mut bytes, _) = build_commit_graph_bytes(
2082            &[commit],
2083            &infos_std(&infos),
2084            &repo.odb(),
2085            false,
2086            &bloom,
2087            None,
2088            &[],
2089            None,
2090            &empty_byte_filters(),
2091            &empty_byte_filters(),
2092            true,
2093        )
2094        .expect("write");
2095        if bytes.len() > 100 {
2096            for i in 80..120 {
2097                if i + 4 <= bytes.len() - 20 {
2098                    bytes[i] = 0x80;
2099                    bytes[i + 1] = 0x00;
2100                    bytes[i + 2] = 0x00;
2101                    bytes[i + 3] = 0x00;
2102                    break;
2103                }
2104            }
2105        }
2106        let err =
2107            super::CommitGraphLayer::try_parse(std::path::PathBuf::from("g"), bytes).unwrap_err();
2108        assert!(matches!(err, crate::error::Error::CorruptObject(_)));
2109    }
2110
2111    #[test]
2112    fn bloom_precheck_not_in_graph_for_missing_oid() {
2113        let (_dir, repo, commit) = one_commit_repo();
2114        let info = load_commit_graph_commit_info(&repo.odb(), commit).expect("info");
2115        let mut infos = crate::objects::oid_hash_map();
2116        infos.insert(commit, info);
2117        let bloom = BloomFilterSettings::default();
2118        let (bytes, _) = build_commit_graph_bytes(
2119            &[commit],
2120            &infos_std(&infos),
2121            &repo.odb(),
2122            true,
2123            &bloom,
2124            None,
2125            &[],
2126            None,
2127            &empty_byte_filters(),
2128            &empty_byte_filters(),
2129            true,
2130        )
2131        .expect("write");
2132        std::fs::write(repo.odb().objects_dir().join("info/commit-graph"), bytes).expect("write");
2133        let chain = super::CommitGraphChain::load(repo.odb().objects_dir()).expect("chain");
2134        let missing = ObjectId::from_hex("1234567890123456789012345678901234567890").expect("oid");
2135        let pre = chain
2136            .bloom_precheck_for_paths(&repo.odb(), missing, &[], None, -1, true)
2137            .expect("precheck");
2138        assert_eq!(pre, super::BloomPrecheck::NotInGraph);
2139    }
2140
2141    #[test]
2142    fn bloom_precheck_respects_read_changed_paths_flag() {
2143        let (_dir, repo, commit) = one_commit_repo();
2144        let mut sorted = vec![commit];
2145        let info = load_commit_graph_commit_info(&repo.odb(), commit).expect("info");
2146        let mut infos = crate::objects::oid_hash_map();
2147        infos.insert(commit, info);
2148        let bloom = BloomFilterSettings::default();
2149        let (bytes, _) = build_commit_graph_bytes(
2150            &sorted,
2151            &infos_std(&infos),
2152            &repo.odb(),
2153            true,
2154            &bloom,
2155            None,
2156            &[],
2157            None,
2158            &empty_byte_filters(),
2159            &empty_byte_filters(),
2160            true,
2161        )
2162        .expect("write");
2163        std::fs::write(repo.odb().objects_dir().join("info/commit-graph"), bytes).expect("write");
2164        let chain = super::CommitGraphChain::load(repo.odb().objects_dir()).expect("chain");
2165        let pre = chain
2166            .bloom_precheck_for_paths(&repo.odb(), commit, &["f.txt".to_owned()], None, -1, false)
2167            .expect("precheck");
2168        assert_eq!(pre, super::BloomPrecheck::Inapplicable);
2169        sorted.clear();
2170    }
2171
2172    #[test]
2173    fn bloom_skips_most_steps_when_tracked_path_unchanged_in_linear_history() {
2174        use crate::bloom::bloom_keyvec_for_path;
2175        use crate::commit_graph_write::{build_commit_graph_bytes, load_commit_graph_commit_info};
2176        use crate::index::{Index, IndexEntry, MODE_REGULAR};
2177        use crate::objects::{serialize_commit, CommitData, ObjectKind};
2178        use crate::pathspec::bloom_lookup_prefix_with_cwd;
2179        use crate::repo::init_repository;
2180        use crate::write_tree::write_tree_from_index;
2181        use crate::RefStorageFormat;
2182        use std::fs;
2183
2184        let dir = tempfile::tempdir().expect("tempdir");
2185        let repo = init_repository(dir.path(), false, "main", None, RefStorageFormat::Files)
2186            .expect("init");
2187        let path = "tracked.txt";
2188        let stable = b"stable payload\n";
2189        let blob = repo.odb().write(ObjectKind::Blob, stable).expect("blob");
2190        let mut parent: Option<ObjectId> = None;
2191        let mut oids = Vec::new();
2192        for i in 0..24usize {
2193            let mut index = Index::new();
2194            index.hash_algo = repo.odb().hash_algo();
2195            index.add_or_replace(IndexEntry {
2196                ctime_sec: 0,
2197                ctime_nsec: 0,
2198                mtime_sec: 0,
2199                mtime_nsec: 0,
2200                dev: 0,
2201                ino: 0,
2202                mode: MODE_REGULAR,
2203                uid: 0,
2204                gid: 0,
2205                size: stable.len() as u32,
2206                oid: blob,
2207                flags: 4,
2208                flags_extended: None,
2209                path: path.as_bytes().to_vec(),
2210                base_index_pos: 0,
2211            });
2212            if i % 5 == 3 {
2213                let noise = repo
2214                    .odb()
2215                    .write(ObjectKind::Blob, format!("noise {i}\n").as_bytes())
2216                    .expect("noise blob");
2217                index.add_or_replace(IndexEntry {
2218                    ctime_sec: 0,
2219                    ctime_nsec: 0,
2220                    mtime_sec: 0,
2221                    mtime_nsec: 0,
2222                    dev: 0,
2223                    ino: 0,
2224                    mode: MODE_REGULAR,
2225                    uid: 0,
2226                    gid: 0,
2227                    size: 8,
2228                    oid: noise,
2229                    flags: 4,
2230                    flags_extended: None,
2231                    path: format!("other{i}.txt").into_bytes(),
2232                    base_index_pos: 0,
2233                });
2234            }
2235            let tree = write_tree_from_index(&repo.odb(), &index, "").expect("tree");
2236            let mut parents = Vec::new();
2237            if let Some(p) = parent {
2238                parents.push(p);
2239            }
2240            let raw = serialize_commit(&CommitData {
2241                tree,
2242                parents,
2243                author: "T <t@example.com> 100 +0000".into(),
2244                committer: "T <t@example.com> 100 +0000".into(),
2245                author_raw: Vec::new(),
2246                committer_raw: Vec::new(),
2247                encoding: None,
2248                message: format!("c{i}\n"),
2249                raw_message: None,
2250                extra_headers: Vec::new(),
2251            });
2252            let commit = repo.odb().write(ObjectKind::Commit, &raw).expect("commit");
2253            oids.push(commit);
2254            parent = Some(commit);
2255        }
2256        let head = *oids.last().expect("head");
2257        let mut infos = crate::objects::oid_hash_map();
2258        for oid in &oids {
2259            infos.insert(
2260                *oid,
2261                load_commit_graph_commit_info(&repo.odb(), *oid).expect("info"),
2262            );
2263        }
2264        let bloom = BloomFilterSettings::default();
2265        let (bytes, _) = build_commit_graph_bytes(
2266            &oids,
2267            &infos_std(&infos),
2268            &repo.odb(),
2269            true,
2270            &bloom,
2271            None,
2272            &[],
2273            None,
2274            &empty_byte_filters(),
2275            &empty_byte_filters(),
2276            true,
2277        )
2278        .expect("graph bytes");
2279        fs::write(repo.odb().objects_dir().join("info/commit-graph"), bytes).expect("write graph");
2280        let chain = super::CommitGraphChain::load(repo.odb().objects_dir()).expect("chain");
2281        let settings = chain.top_layer_bloom_settings().expect("bloom");
2282        let norm = bloom_lookup_prefix_with_cwd(path, None).expect("prefix");
2283        let keys = bloom_keyvec_for_path(norm.as_str(), &settings);
2284        let mut current = head;
2285        let mut bloom_yes = 0usize;
2286        let mut bloom_no = 0usize;
2287        while bloom_yes + bloom_no < 10_000 {
2288            let Some((parents, _)) = chain.graph_commit(&current) else {
2289                break;
2290            };
2291            let Some(parent) = parents.first().copied() else {
2292                break;
2293            };
2294            if chain.bloom_definitely_not_for_keys(current, keys.as_slice()) {
2295                bloom_yes += 1;
2296            } else {
2297                bloom_no += 1;
2298            }
2299            current = parent;
2300        }
2301        assert!(
2302            bloom_yes > bloom_no,
2303            "expected bloom to skip unchanged tracked path (yes={bloom_yes} no={bloom_no})"
2304        );
2305    }
2306
2307    #[test]
2308    #[ignore = "local git.git worktree; GRIT_BLAME_BENCH_REPO"]
2309    fn first_parent_makefile_bloom_skip_rate_on_git_dot_git() {
2310        use crate::bloom::bloom_keyvec_for_path;
2311        use crate::environment::RepositoryOptions;
2312        use crate::pathspec::bloom_lookup_prefix_with_cwd;
2313        use crate::repo::Repository;
2314
2315        let repo_path = std::env::var("GRIT_BLAME_BENCH_REPO")
2316            .unwrap_or_else(|_| "/tmp/grit-bench/git-dot-git-worktree".to_string());
2317        if !std::path::Path::new(&repo_path).join(".git").exists() {
2318            return;
2319        }
2320        let git_dir = std::path::Path::new(&repo_path).join(".git");
2321        let repo =
2322            Repository::open(git_dir.as_path(), &RepositoryOptions::default()).expect("open");
2323        let chain = repo
2324            .caches()
2325            .commit_graph_chain(repo.odb().objects_dir())
2326            .expect("graph");
2327        let settings = chain.top_layer_bloom_settings().expect("bloom");
2328        let norm = bloom_lookup_prefix_with_cwd("Makefile", None).expect("prefix");
2329        let keys = bloom_keyvec_for_path(norm.as_str(), &settings);
2330        let head = *crate::state::resolve_head(repo.git_dir())
2331            .expect("head")
2332            .oid()
2333            .expect("commit");
2334        let mut current = head;
2335        let mut bloom_yes = 0usize;
2336        let mut bloom_no = 0usize;
2337        for _ in 0..10_000 {
2338            let Some((parents, _)) = chain.graph_commit(&current) else {
2339                break;
2340            };
2341            let Some(parent) = parents.first().copied() else {
2342                break;
2343            };
2344            if chain.bloom_definitely_not_for_keys(current, keys.as_slice()) {
2345                bloom_yes += 1;
2346            } else {
2347                bloom_no += 1;
2348            }
2349            current = parent;
2350        }
2351        assert!(
2352            bloom_yes > bloom_no,
2353            "expected mostly bloom skips, yes={bloom_yes} no={bloom_no}"
2354        );
2355    }
2356
2357    #[test]
2358    fn bloom_definitely_not_when_directory_key_present_but_not_full_path() {
2359        use crate::bloom::{
2360            bloom_filter_contains, bloom_keyvec_for_path, build_bloom_filter_data,
2361            collect_changed_paths_for_bloom,
2362        };
2363
2364        let bloom = BloomFilterSettings::default();
2365        let changed = collect_changed_paths_for_bloom(&["builtin/sibling.c".to_owned()]);
2366        let (filter, _) = build_bloom_filter_data(&changed, 1, &bloom);
2367        let path_keys = bloom_keyvec_for_path("builtin/commit.c", &bloom);
2368        assert!(path_keys.len() >= 2);
2369        assert_eq!(
2370            bloom_filter_contains(&path_keys[0], &filter, &bloom),
2371            Ok(false),
2372            "full path key must be absent when only a sibling changed"
2373        );
2374        assert_eq!(
2375            bloom_filter_contains(&path_keys[1], &filter, &bloom),
2376            Ok(true),
2377            "directory prefix key should be present"
2378        );
2379        assert!(super::bloom_definitely_not_from_filter(
2380            &filter, &bloom, &path_keys
2381        ));
2382    }
2383
2384    #[cfg_attr(
2385        not(feature = "full-tests"),
2386        ignore = "starts system git or is heavy; run with --features full-tests"
2387    )]
2388    #[test]
2389    fn large_git_split_chain_layers_use_mapped_backing() {
2390        use std::process::Command;
2391
2392        let git_ok = Command::new("git")
2393            .arg("--version")
2394            .output()
2395            .map(|o| o.status.success())
2396            .unwrap_or(false);
2397        if !git_ok {
2398            eprintln!("SKIP: git unavailable");
2399            return;
2400        }
2401        let dir = tempfile::tempdir().expect("tempdir");
2402        let run = |args: &[&str]| {
2403            let out = Command::new("git")
2404                .current_dir(dir.path())
2405                .args(args)
2406                .env("GIT_CONFIG_GLOBAL", "/dev/null")
2407                .env("GIT_CONFIG_SYSTEM", "/dev/null")
2408                .env("GIT_AUTHOR_NAME", "T")
2409                .env("GIT_AUTHOR_EMAIL", "t@example.com")
2410                .env("GIT_COMMITTER_NAME", "T")
2411                .env("GIT_COMMITTER_EMAIL", "t@example.com")
2412                .output()
2413                .expect("git");
2414            assert!(out.status.success(), "git {args:?}");
2415        };
2416        run(&["init", "-q", "-b", "main"]);
2417        run(&["commit", "--allow-empty", "-q", "-m", "bootstrap"]);
2418        for i in 0..150 {
2419            run(&[
2420                "commit",
2421                "--allow-empty",
2422                "-q",
2423                "-m",
2424                &format!("base-fill-{i}"),
2425            ]);
2426        }
2427        run(&[
2428            "commit-graph",
2429            "write",
2430            "--reachable",
2431            "--split",
2432            "--max-commits",
2433            "40",
2434        ]);
2435        for i in 150..190 {
2436            run(&[
2437                "commit",
2438                "--allow-empty",
2439                "-q",
2440                "-m",
2441                &format!("tip-fill-{i}"),
2442            ]);
2443        }
2444        run(&[
2445            "commit-graph",
2446            "write",
2447            "--reachable",
2448            "--split",
2449            "--max-commits",
2450            "40",
2451        ]);
2452        let objects = dir.path().join(".git/objects");
2453        let chain = super::CommitGraphChain::try_load(&objects)
2454            .expect("load")
2455            .expect("chain");
2456        assert!(
2457            chain.num_layers() >= 2,
2458            "expected multi-layer chain, got {}",
2459            chain.num_layers()
2460        );
2461        let mut saw_mapped_large_layer = false;
2462        for path in chain.layer_paths_oldest_first() {
2463            let len = std::fs::metadata(&path).expect("meta").len();
2464            if len <= 8192 {
2465                continue;
2466            }
2467            let storage = crate::pack_map::PackData::open_with_min_len(
2468                &path,
2469                super::COMMIT_GRAPH_MIN_FILE_LEN,
2470            )
2471            .expect("open layer");
2472            assert!(
2473                storage.is_mapped_backing(),
2474                "expected mmap backing for {} ({} bytes)",
2475                path.display(),
2476                len
2477            );
2478            saw_mapped_large_layer = true;
2479        }
2480        assert!(
2481            saw_mapped_large_layer,
2482            "expected at least one chain layer larger than 8192 bytes to use mmap"
2483        );
2484    }
2485}