1use 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::objects::ObjectId;
11use crate::odb::Odb;
12
13fn warn_once_for_disabled_bloom_layer(id: &str) -> bool {
17 use std::collections::HashSet;
18 use std::sync::OnceLock;
19 static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
20 let set = SEEN.get_or_init(|| Mutex::new(HashSet::new()));
21 match set.lock() {
22 Ok(mut guard) => guard.insert(id.to_string()),
23 Err(_) => true,
24 }
25}
26
27fn warn_once_for_base_chunk_too_small(id: &str) -> bool {
30 use std::collections::HashSet;
31 use std::sync::OnceLock;
32 static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
33 let set = SEEN.get_or_init(|| Mutex::new(HashSet::new()));
34 match set.lock() {
35 Ok(mut guard) => guard.insert(id.to_string()),
36 Err(_) => true,
37 }
38}
39
40const SIGNATURE: &[u8; 4] = b"CGPH";
41const GRAPH_VERSION: u8 = 1;
42
43const CHUNK_OID_FANOUT: u32 = 0x4f49_4446; const CHUNK_OID_LOOKUP: u32 = 0x4f49_444c; const CHUNK_COMMIT_DATA: u32 = 0x4344_4154; const CHUNK_GENERATION_DATA: u32 = 0x4744_4132; const CHUNK_GENERATION_DATA_OVERFLOW: u32 = 0x4744_4f32; const CHUNK_EXTRA_EDGES: u32 = 0x4544_4745; const CHUNK_BLOOM_INDEXES: u32 = 0x4249_4458; const CHUNK_BLOOM_DATA: u32 = 0x4244_4154; const CHUNK_BASE_GRAPHS: u32 = 0x4241_5345; const BLOOM_HEADER: usize = crate::bloom::BLOOMDATA_HEADER_LEN;
54
55const CORRECTED_COMMIT_DATE_OFFSET_OVERFLOW: u32 = 1u32 << 31;
57
58fn warn_path_for_graph_file(path: &Path) -> String {
59 let s = path.to_string_lossy();
60 if let Some(idx) = s.find(".git/") {
61 return s[idx..].replace('\\', "/");
62 }
63 s.replace('\\', "/")
64}
65
66#[derive(Debug, Clone)]
68pub struct CommitGraphLayer {
69 pub path: PathBuf,
70 body: Vec<u8>,
71 num_commits: u32,
72 oid_lookup_off: usize,
73 #[allow(dead_code)]
74 chunk_commit_data_off: usize,
75 #[allow(dead_code)]
76 chunk_generation_data: Option<usize>,
77 read_generation_data: bool,
78 chunk_bloom_indexes: Option<usize>,
79 chunk_bloom_data: Option<(usize, usize)>,
80 bloom_settings: Option<BloomFilterSettings>,
81 bloom_disabled: bool,
82 base_chunk_size: usize,
85 hash_len: usize,
87}
88
89const fn commit_graph_hash_len(hash_version: u8) -> Option<usize> {
91 match hash_version {
92 1 => Some(20),
93 2 => Some(32),
94 _ => None,
95 }
96}
97
98impl CommitGraphLayer {
99 pub fn try_parse(path: PathBuf, raw: Vec<u8>) -> Result<Self, Error> {
101 if raw.len() < 28 {
102 return Err(Error::CorruptObject(
103 "commit-graph file too small".to_owned(),
104 ));
105 }
106 let hash_len = match commit_graph_hash_len(raw[5]) {
109 Some(n) => n,
110 None => {
111 return Err(Error::CorruptObject(format!(
112 "commit-graph version/hash not supported (version {} hash {})",
113 raw[4], raw[5]
114 )))
115 }
116 };
117 let body = raw[..raw.len() - hash_len].to_vec();
118 if body.len() < 8 || &body[0..4] != SIGNATURE {
119 return Err(Error::CorruptObject(
120 "commit-graph has bad signature".to_owned(),
121 ));
122 }
123 if body[4] != GRAPH_VERSION {
124 return Err(Error::CorruptObject(format!(
125 "commit-graph version/hash not supported (version {} hash {})",
126 body[4], body[5]
127 )));
128 }
129 let num_chunks = body[6] as usize;
130 let toc_start = 8;
131 let toc_end = toc_start + (num_chunks + 1) * 12;
132 if body.len() < toc_end {
133 return Err(Error::CorruptObject(
134 "commit-graph truncated at chunk table".to_owned(),
135 ));
136 }
137
138 let mut fanout_off = None;
139 let mut oid_lookup_off = None;
140 let mut commit_data_off = None;
141 let mut generation_off = None;
142 let mut generation_overflow_off = None;
143 let mut bloom_idx_off = None;
144 let mut bloom_data_range = None;
145 let mut base_graphs_off = None;
146 let mut chunk_offsets: Vec<usize> = Vec::new();
147 let mut toc_entries: Vec<(u32, usize)> = Vec::with_capacity(num_chunks);
148
149 for i in 0..num_chunks {
150 let e = toc_start + i * 12;
151 let id = u32::from_be_bytes(
152 body[e..e + 4]
153 .try_into()
154 .map_err(|_| Error::CorruptObject("commit-graph bad TOC".to_owned()))?,
155 );
156 let off = u64::from_be_bytes(
157 body[e + 4..e + 12]
158 .try_into()
159 .map_err(|_| Error::CorruptObject("commit-graph bad TOC".to_owned()))?,
160 ) as usize;
161 toc_entries.push((id, off));
162 chunk_offsets.push(off);
163 match id {
164 CHUNK_OID_FANOUT => fanout_off = Some(off),
165 CHUNK_OID_LOOKUP => oid_lookup_off = Some(off),
166 CHUNK_COMMIT_DATA => commit_data_off = Some(off),
167 CHUNK_GENERATION_DATA => generation_off = Some(off),
168 CHUNK_GENERATION_DATA_OVERFLOW => generation_overflow_off = Some(off),
169 CHUNK_BLOOM_INDEXES => bloom_idx_off = Some(off),
170 CHUNK_BASE_GRAPHS => base_graphs_off = Some(off),
171 CHUNK_BLOOM_DATA => {
172 let end = if i + 1 < num_chunks {
173 let e2 = toc_start + (i + 1) * 12;
174 u64::from_be_bytes(body[e2 + 4..e2 + 12].try_into().unwrap_or([0u8; 8]))
175 as usize
176 } else {
177 let term = toc_start + num_chunks * 12;
178 u64::from_be_bytes(body[term + 4..term + 12].try_into().unwrap_or([0u8; 8]))
179 as usize
180 };
181 bloom_data_range = Some((off, end.saturating_sub(off)));
182 }
183 _ => {}
184 }
185 }
186 let file_end = u64::from_be_bytes(
187 body[toc_start + num_chunks * 12 + 4..toc_start + num_chunks * 12 + 12]
188 .try_into()
189 .map_err(|_| Error::CorruptObject("commit-graph bad file end".to_owned()))?,
190 ) as usize;
191 chunk_offsets.push(file_end);
192 chunk_offsets.sort_unstable();
193 chunk_offsets.dedup();
194
195 fn chunk_byte_range(
196 start: usize,
197 toc_entries: &[(u32, usize)],
198 file_end: usize,
199 ) -> Result<usize, Error> {
200 let mut ends: Vec<usize> = toc_entries
201 .iter()
202 .map(|&(_, o)| o)
203 .filter(|&o| o > start)
204 .collect();
205 ends.sort_unstable();
206 let end = ends.first().copied().unwrap_or(file_end);
207 if end < start {
208 return Err(Error::CorruptObject(
209 "commit-graph chunk layout invalid".to_owned(),
210 ));
211 }
212 Ok(end)
213 }
214
215 if let Some(gda) = generation_off {
216 let gda_end = chunk_byte_range(gda, &toc_entries, file_end)?;
217 let gda_len = gda_end.saturating_sub(gda);
218 let num_commits = fanout_off
219 .and_then(|fo| {
220 let slice = body.get(fo + 255 * 4..fo + 256 * 4)?;
221 Some(u32::from_be_bytes(slice.try_into().ok()?))
222 })
223 .ok_or_else(|| Error::CorruptObject("commit-graph missing fanout".to_owned()))?;
224 let expected = num_commits as usize * 4;
225 if gda_len < expected {
226 return Err(Error::CorruptObject(
227 "commit-graph generation data chunk is too small".to_owned(),
228 ));
229 }
230 let gda_slice = body.get(gda..gda + expected).ok_or_else(|| {
231 Error::CorruptObject("commit-graph generation data OOB".to_owned())
232 })?;
233 let mut max_overflow_idx: Option<u32> = None;
234 for w in 0..num_commits as usize {
235 let v =
236 u32::from_be_bytes(gda_slice[w * 4..w * 4 + 4].try_into().map_err(|_| {
237 Error::CorruptObject("commit-graph GDA2 corrupt".to_owned())
238 })?);
239 if v & CORRECTED_COMMIT_DATE_OFFSET_OVERFLOW != 0 {
240 let pos = v ^ CORRECTED_COMMIT_DATE_OFFSET_OVERFLOW;
241 max_overflow_idx = Some(match max_overflow_idx {
242 None => pos,
243 Some(m) => m.max(pos),
244 });
245 }
246 }
247 if let Some(pos) = max_overflow_idx {
248 let Some(gdo_start) = generation_overflow_off else {
249 return Err(Error::CorruptObject(
250 "commit-graph requires overflow generation data but has none".to_owned(),
251 ));
252 };
253 let gdo_end = chunk_byte_range(gdo_start, &toc_entries, file_end)?;
254 let overflow_bytes = gdo_end.saturating_sub(gdo_start);
255 let n_slots = overflow_bytes / 8;
256 if n_slots <= pos as usize {
257 return Err(Error::CorruptObject(
258 "commit-graph overflow generation data is too small".to_owned(),
259 ));
260 }
261 }
262 }
263 let bidx_len = bloom_idx_off.and_then(|b| {
264 chunk_offsets
265 .iter()
266 .find(|&&o| o > b)
267 .map(|&next| next.saturating_sub(b))
268 });
269
270 let fanout_off = fanout_off.ok_or_else(|| {
271 Error::CorruptObject("commit-graph missing OID fanout chunk".to_owned())
272 })?;
273 let oid_lookup_off = oid_lookup_off.ok_or_else(|| {
274 Error::CorruptObject("commit-graph missing OID lookup chunk".to_owned())
275 })?;
276 let commit_data_off = commit_data_off.ok_or_else(|| {
277 Error::CorruptObject("commit-graph missing commit data chunk".to_owned())
278 })?;
279 if fanout_off + 256 * 4 > body.len() || oid_lookup_off + 4 > body.len() {
280 return Err(Error::CorruptObject(
281 "commit-graph chunk extends past end of file".to_owned(),
282 ));
283 }
284 let num_commits = u32::from_be_bytes(
285 body[fanout_off + 255 * 4..fanout_off + 256 * 4]
286 .try_into()
287 .map_err(|_| Error::CorruptObject("commit-graph fanout corrupt".to_owned()))?,
288 );
289 if oid_lookup_off + num_commits as usize * hash_len > body.len() {
290 return Err(Error::CorruptObject(
291 "commit-graph OID lookup extends past end of file".to_owned(),
292 ));
293 }
294 let graph_data_width = hash_len + 16;
295 if commit_data_off + num_commits as usize * graph_data_width > body.len() {
296 return Err(Error::CorruptObject(
297 "commit-graph commit data extends past end of file".to_owned(),
298 ));
299 }
300
301 let read_generation_data = generation_off.is_some();
302 let mut bloom_settings = None;
303 let mut chunk_bloom_data = None;
304 if let (Some(_bidx), Some((bdat_off, bdat_len))) = (bloom_idx_off, bloom_data_range) {
305 if bdat_len < BLOOM_HEADER {
306 eprintln!(
307 "warning: ignoring too-small changed-path chunk ({} < {}) in commit-graph file",
308 bdat_len, BLOOM_HEADER
309 );
310 } else if bdat_off + bdat_len <= body.len() {
311 let hdr = &body[bdat_off..bdat_off + BLOOM_HEADER];
312 let hash_version: [u8; 4] = hdr[0..4]
313 .try_into()
314 .map_err(|_| Error::CorruptObject("Bloom header corrupt".to_owned()))?;
315 let num_hashes: [u8; 4] = hdr[4..8]
316 .try_into()
317 .map_err(|_| Error::CorruptObject("Bloom header corrupt".to_owned()))?;
318 let bits_per_entry: [u8; 4] = hdr[8..12]
319 .try_into()
320 .map_err(|_| Error::CorruptObject("Bloom header corrupt".to_owned()))?;
321 bloom_settings = Some(BloomFilterSettings {
322 hash_version: u32::from_be_bytes(hash_version),
323 num_hashes: u32::from_be_bytes(num_hashes),
324 bits_per_entry: u32::from_be_bytes(bits_per_entry),
325 max_changed_paths: 512,
326 });
327 chunk_bloom_data = Some((bdat_off, bdat_len));
328 }
329 }
330
331 let bloom_indexes_ok = if let (Some(bidx), Some(bsize)) = (bloom_idx_off, bidx_len) {
332 if bsize / 4 != num_commits as usize {
333 eprintln!("warning: commit-graph changed-path index chunk is too small");
334 false
335 } else if bidx + bsize > body.len() {
336 eprintln!("warning: commit-graph changed-path index chunk is too small");
337 false
338 } else {
339 true
340 }
341 } else {
342 false
343 };
344 let bloom_pair_ok = bloom_settings.is_some()
345 && chunk_bloom_data.is_some()
346 && bloom_indexes_ok
347 && chunk_bloom_data.is_some_and(|(_, len)| len >= BLOOM_HEADER);
348
349 let (chunk_bloom_indexes, bloom_settings) = if bloom_pair_ok {
350 (bloom_idx_off, bloom_settings)
351 } else {
352 (None, None)
353 };
354 let chunk_bloom_data = if bloom_pair_ok {
355 chunk_bloom_data
356 } else {
357 None
358 };
359
360 let base_chunk_size = match base_graphs_off {
361 Some(off) => {
362 let end = chunk_byte_range(off, &toc_entries, file_end)?;
363 end.saturating_sub(off)
364 }
365 None => 0,
366 };
367
368 Ok(Self {
369 path,
370 body,
371 num_commits,
372 oid_lookup_off,
373 chunk_commit_data_off: commit_data_off,
374 chunk_generation_data: generation_off,
375 read_generation_data,
376 chunk_bloom_indexes,
377 chunk_bloom_data,
378 bloom_settings,
379 bloom_disabled: false,
380 base_chunk_size,
381 hash_len,
382 })
383 }
384
385 fn parse(path: PathBuf, raw: Vec<u8>) -> Option<Self> {
386 Self::try_parse(path, raw).ok()
387 }
388
389 fn oid_at_lex(&self, lex_index: u32) -> Option<ObjectId> {
390 if lex_index >= self.num_commits {
391 return None;
392 }
393 let off = self.oid_lookup_off + lex_index as usize * self.hash_len;
394 ObjectId::from_bytes(self.body.get(off..off + self.hash_len)?).ok()
395 }
396
397 fn bsearch_oid(&self, oid: &ObjectId) -> Option<u32> {
398 let mut lo = 0u32;
399 let mut hi = self.num_commits;
400 let bytes = oid.as_bytes();
401 while lo < hi {
402 let mid = (lo + hi) / 2;
403 let off = self.oid_lookup_off + mid as usize * self.hash_len;
404 let slice = &self.body[off..off + self.hash_len];
405 match slice.cmp(bytes) {
406 std::cmp::Ordering::Less => lo = mid + 1,
407 std::cmp::Ordering::Greater => hi = mid,
408 std::cmp::Ordering::Equal => return Some(mid),
409 }
410 }
411 None
412 }
413
414 fn disable_bloom(&mut self) {
415 self.chunk_bloom_indexes = None;
416 self.chunk_bloom_data = None;
417 self.bloom_settings = None;
418 self.bloom_disabled = true;
419 }
420
421 fn layer_display_id(&self) -> String {
422 self.path
423 .file_stem()
424 .and_then(|s| s.to_str())
425 .map(|s| s.strip_prefix("graph-").unwrap_or(s).to_string())
426 .unwrap_or_else(|| "commit-graph".to_string())
427 }
428
429 fn bloom_filter_slice(&self, lex_index: u32) -> Option<&[u8]> {
430 let _settings = self.bloom_settings.as_ref()?;
431 let bidx_base = self.chunk_bloom_indexes?;
432 let (bdat_off, bdat_total) = self.chunk_bloom_data?;
433 let graph_warn = warn_path_for_graph_file(self.path.as_path());
434 if lex_index >= self.num_commits {
435 return None;
436 }
437 let payload_len = bdat_total.saturating_sub(BLOOM_HEADER);
438 let end_rel = u32::from_be_bytes(
439 self.body[bidx_base + lex_index as usize * 4..bidx_base + lex_index as usize * 4 + 4]
440 .try_into()
441 .ok()?,
442 ) as usize;
443 let start_rel = if lex_index == 0 {
444 0usize
445 } else {
446 u32::from_be_bytes(
447 self.body[bidx_base + (lex_index as usize - 1) * 4
448 ..bidx_base + (lex_index as usize - 1) * 4 + 4]
449 .try_into()
450 .ok()?,
451 ) as usize
452 };
453 let max_payload = payload_len;
459 if end_rel > max_payload {
460 eprintln!(
461 "warning: ignoring out-of-range offset ({end_rel}) for changed-path filter at pos {} of {} (chunk size: {bdat_total})",
462 lex_index,
463 graph_warn,
464 bdat_total = bdat_total
465 );
466 return None;
467 }
468 if start_rel > max_payload {
469 eprintln!(
470 "warning: ignoring out-of-range offset ({start_rel}) for changed-path filter at pos {} of {} (chunk size: {bdat_total})",
471 lex_index.saturating_sub(1),
472 graph_warn,
473 bdat_total = bdat_total
474 );
475 return None;
476 }
477 if end_rel < start_rel {
478 eprintln!(
479 "warning: ignoring decreasing changed-path index offsets ({start_rel} > {end_rel}) for positions {} and {} of {}",
480 lex_index.saturating_sub(1),
481 lex_index,
482 graph_warn
483 );
484 return None;
485 }
486 let data_base = bdat_off + BLOOM_HEADER;
487 let abs_start = data_base + start_rel;
488 let abs_end = data_base + end_rel;
489 if abs_end > bdat_off + bdat_total || abs_start > abs_end {
490 return None;
491 }
492 Some(&self.body[abs_start..abs_end])
493 }
494}
495
496#[derive(Debug, Clone, Copy, PartialEq, Eq)]
498pub enum BloomPrecheck {
499 Inapplicable,
501 NotInGraph,
503 FilterNotPresent,
505 DefinitelyNot,
507 Maybe,
509}
510
511#[derive(Debug, Default, Clone)]
513pub struct BloomWalkStats {
514 pub filter_not_present: u32,
515 pub maybe: u32,
516 pub definitely_not: u32,
517 pub false_positive: u32,
518}
519
520impl BloomWalkStats {
521 pub fn record_precheck(&mut self, pre: BloomPrecheck) {
522 match pre {
523 BloomPrecheck::Inapplicable | BloomPrecheck::NotInGraph => {}
524 BloomPrecheck::FilterNotPresent => self.filter_not_present += 1,
525 BloomPrecheck::DefinitelyNot => self.definitely_not += 1,
526 BloomPrecheck::Maybe => self.maybe += 1,
527 }
528 }
529
530 pub fn record_false_positive(&mut self) {
531 self.false_positive += 1;
532 }
533}
534
535pub type BloomWalkStatsHandle = Arc<Mutex<BloomWalkStats>>;
537
538#[derive(Debug, Clone)]
540pub struct CommitGraphChain {
541 layers: Vec<CommitGraphLayer>,
542}
543
544impl CommitGraphChain {
545 #[must_use]
547 pub fn top_layer_bloom_settings(&self) -> Option<BloomFilterSettings> {
548 self.layers.first()?.bloom_settings
549 }
550
551 #[must_use]
553 pub fn total_commits(&self) -> u32 {
554 self.layers.iter().map(|l| l.num_commits).sum()
555 }
556
557 #[must_use]
559 pub fn layer_paths_oldest_first(&self) -> Vec<PathBuf> {
560 self.layers.iter().rev().map(|l| l.path.clone()).collect()
561 }
562
563 #[must_use]
565 pub fn num_layers(&self) -> usize {
566 self.layers.len()
567 }
568
569 #[must_use]
571 pub fn layer_commit_counts_tip_first(&self) -> Vec<u32> {
572 self.layers.iter().map(|l| l.num_commits).collect()
573 }
574
575 #[must_use]
577 pub fn layer_has_generation_data_tip_first(&self) -> Vec<bool> {
578 self.layers
579 .iter()
580 .map(|l| l.chunk_generation_data.is_some())
581 .collect()
582 }
583
584 #[must_use]
586 pub fn layer_hashes_tip_first(&self) -> Vec<String> {
587 self.layers.iter().map(|l| l.layer_display_id()).collect()
588 }
589
590 #[must_use]
594 pub fn layer_object_dirs_tip_first(&self) -> Vec<PathBuf> {
595 self.layers
596 .iter()
597 .map(|l| {
598 let p = l.path.as_path();
601 let info = if p
602 .parent()
603 .and_then(|d| d.file_name())
604 .map(|n| n == "commit-graphs")
605 .unwrap_or(false)
606 {
607 p.parent().and_then(|d| d.parent())
608 } else {
609 p.parent()
610 };
611 info.and_then(|d| d.parent())
612 .map(Path::to_path_buf)
613 .unwrap_or_else(|| PathBuf::from("."))
614 })
615 .collect()
616 }
617
618 #[must_use]
622 pub fn sub_chain_tip_first(&self, start: usize, end: usize) -> Option<Self> {
623 let end = end.min(self.layers.len());
624 if start >= end {
625 return None;
626 }
627 Some(Self {
628 layers: self.layers[start..end].to_vec(),
629 })
630 }
631
632 #[must_use]
634 pub fn layer_oids(&self, tip_first_idx: usize) -> Vec<ObjectId> {
635 let Some(layer) = self.layers.get(tip_first_idx) else {
636 return Vec::new();
637 };
638 let mut out = Vec::with_capacity(layer.num_commits as usize);
639 for i in 0..layer.num_commits {
640 if let Some(oid) = layer.oid_at_lex(i) {
641 out.push(oid);
642 }
643 }
644 out
645 }
646
647 pub fn try_load(objects_dir: &Path) -> Result<Option<Self>, Error> {
652 let info = objects_dir.join("info");
653 let chain_path = info.join("commit-graphs").join("commit-graph-chain");
654 if chain_path.is_file() {
655 let content = std::fs::read_to_string(&chain_path).map_err(Error::from)?;
656 let mut layers = Vec::new();
657 for line in content.lines() {
658 let h = line.trim();
659 if h.len() != 40 {
660 continue;
661 }
662 let graph_path = info.join("commit-graphs").join(format!("graph-{h}.graph"));
663 let raw = std::fs::read(&graph_path).map_err(Error::from)?;
664 let layer = CommitGraphLayer::try_parse(graph_path, raw)?;
665 let n = layers.len();
672 if n > 0 && layer.base_chunk_size / layer.hash_len < n {
673 if warn_once_for_base_chunk_too_small(&layer.layer_display_id()) {
674 eprintln!("warning: commit-graph base graphs chunk is too small");
675 }
676 break;
677 }
678 layers.push(layer);
679 }
680 if layers.is_empty() {
681 return Ok(None);
682 }
683 layers.reverse();
687 let mut chain = Self { layers };
688 chain.validate_bloom_compatibility();
689 return Ok(Some(chain));
690 }
691 let single = info.join("commit-graph");
692 if single.is_file() {
693 let raw = std::fs::read(&single).map_err(Error::from)?;
694 let layer = CommitGraphLayer::try_parse(single.clone(), raw)?;
695 let mut chain = Self {
696 layers: vec![layer],
697 };
698 chain.validate_bloom_compatibility();
699 return Ok(Some(chain));
700 }
701 Ok(None)
702 }
703
704 pub fn load(objects_dir: &Path) -> Option<Self> {
706 Self::try_load(objects_dir).ok().flatten()
707 }
708
709 pub fn try_load_across(
733 objects_dir: &Path,
734 alt_dirs: &[PathBuf],
735 ) -> Result<Option<Self>, Error> {
736 let layer_dir = |dir: &Path| dir.join("info").join("commit-graphs");
737 let resolve_layer = |hash: &str| -> Option<PathBuf> {
738 let name = format!("graph-{hash}.graph");
739 let local = layer_dir(objects_dir).join(&name);
740 if local.is_file() {
741 return Some(local);
742 }
743 alt_dirs
744 .iter()
745 .map(|d| layer_dir(d).join(&name))
746 .find(|p| p.is_file())
747 };
748
749 let local_chain = layer_dir(objects_dir).join("commit-graph-chain");
757 let local_single = objects_dir.join("info").join("commit-graph");
758 let chain_path = if local_chain.is_file() {
759 local_chain
760 } else if local_single.is_file() {
761 return Self::try_load(objects_dir);
762 } else {
763 match alt_dirs
764 .iter()
765 .map(PathBuf::as_path)
766 .find(|d| layer_dir(d).join("commit-graph-chain").is_file())
767 {
768 Some(owner) => layer_dir(owner).join("commit-graph-chain"),
769 None => return Ok(None),
770 }
771 };
772
773 let content = std::fs::read_to_string(&chain_path).map_err(Error::from)?;
774 let mut layers = Vec::new();
775 for line in content.lines() {
776 let h = line.trim();
777 if h.len() != 40 {
778 continue;
779 }
780 let graph_path = resolve_layer(h).ok_or_else(|| {
781 Error::from(std::io::Error::new(
782 std::io::ErrorKind::NotFound,
783 format!("commit-graph layer graph-{h}.graph not found"),
784 ))
785 })?;
786 let raw = std::fs::read(&graph_path).map_err(Error::from)?;
787 let layer = CommitGraphLayer::try_parse(graph_path, raw)?;
788 let n = layers.len();
789 if n > 0 && layer.base_chunk_size / layer.hash_len < n {
790 if warn_once_for_base_chunk_too_small(&layer.layer_display_id()) {
791 eprintln!("warning: commit-graph base graphs chunk is too small");
792 }
793 break;
794 }
795 layers.push(layer);
796 }
797 if layers.is_empty() {
798 return Ok(None);
799 }
800 layers.reverse();
801 let mut chain = Self { layers };
802 chain.validate_bloom_compatibility();
803 Ok(Some(chain))
804 }
805
806 fn validate_bloom_compatibility(&mut self) {
807 let mut ref_settings: Option<BloomFilterSettings> = None;
813 for layer in &mut self.layers {
814 let Some(bs) = layer.bloom_settings else {
815 continue;
816 };
817 match ref_settings {
818 None => ref_settings = Some(bs),
819 Some(r) => {
820 if r.hash_version != bs.hash_version
821 || r.num_hashes != bs.num_hashes
822 || r.bits_per_entry != bs.bits_per_entry
823 {
824 let id = layer.layer_display_id();
825 if warn_once_for_disabled_bloom_layer(&id) {
831 eprintln!(
832 "warning: disabling Bloom filters for commit-graph layer '{id}' due to incompatible settings"
833 );
834 }
835 layer.disable_bloom();
836 }
837 }
838 }
839 }
840 }
841
842 pub fn existing_filter_bytes(
848 &self,
849 oid: &ObjectId,
850 want: &BloomFilterSettings,
851 ) -> Option<Vec<u8>> {
852 let (layer_idx, lex) = self.find_commit(oid)?;
853 let layer = &self.layers[layer_idx];
854 let settings = layer.bloom_settings.as_ref()?;
855 if settings.hash_version != want.hash_version
856 || settings.num_hashes != want.num_hashes
857 || settings.bits_per_entry != want.bits_per_entry
858 {
859 return None;
860 }
861 match layer.bloom_filter_slice(lex) {
868 Some(s) if !s.is_empty() => Some(s.to_vec()),
869 _ => None,
870 }
871 }
872
873 pub fn upgradable_filter_bytes(
879 &self,
880 oid: &ObjectId,
881 want: &BloomFilterSettings,
882 ) -> Option<Vec<u8>> {
883 let (layer_idx, lex) = self.find_commit(oid)?;
884 let layer = &self.layers[layer_idx];
885 let settings = layer.bloom_settings.as_ref()?;
886 if settings.hash_version == want.hash_version {
887 return None;
888 }
889 if settings.num_hashes != want.num_hashes || settings.bits_per_entry != want.bits_per_entry
890 {
891 return None;
892 }
893 match layer.bloom_filter_slice(lex) {
894 Some(s) if !s.is_empty() => Some(s.to_vec()),
895 _ => None,
896 }
897 }
898
899 pub fn find_commit(&self, oid: &ObjectId) -> Option<(usize, u32)> {
901 for (i, layer) in self.layers.iter().enumerate() {
902 if let Some(lex) = layer.bsearch_oid(oid) {
903 return Some((i, lex));
904 }
905 }
906 None
907 }
908
909 pub fn global_position(&self, oid: &ObjectId) -> Option<u32> {
911 let (layer_idx, lex) = self.find_commit(oid)?;
912 let below: u32 = self.layers[layer_idx + 1..]
913 .iter()
914 .map(|l| l.num_commits)
915 .sum();
916 Some(below + lex)
917 }
918
919 pub fn all_oids_in_order(&self) -> Vec<ObjectId> {
921 let mut out = Vec::new();
922 for layer in self.layers.iter().rev() {
923 for i in 0..layer.num_commits {
924 if let Some(oid) = layer.oid_at_lex(i) {
925 out.push(oid);
926 }
927 }
928 }
929 out
930 }
931
932 pub fn bloom_precheck_for_paths(
934 &self,
935 _odb: &Odb,
936 oid: ObjectId,
937 pathspecs: &[String],
938 bloom_cwd: Option<&str>,
939 requested_hash_version: i32,
940 read_changed_paths: bool,
941 ) -> std::result::Result<BloomPrecheck, crate::error::Error> {
942 if !read_changed_paths {
943 return Ok(BloomPrecheck::Inapplicable);
944 }
945 let Some((layer_idx, lex)) = self.find_commit(&oid) else {
946 return Ok(BloomPrecheck::NotInGraph);
947 };
948 let layer = &self.layers[layer_idx];
949 let Some(settings) = layer.bloom_settings.as_ref() else {
950 return Ok(BloomPrecheck::FilterNotPresent);
951 };
952 let effective_version = if requested_hash_version < 0 {
953 settings.hash_version as i32
954 } else {
955 requested_hash_version
956 };
957 if effective_version != settings.hash_version as i32 {
958 return Ok(BloomPrecheck::FilterNotPresent);
959 }
960
961 let filter = match layer.bloom_filter_slice(lex) {
967 Some(s) => s,
968 None => return Ok(BloomPrecheck::FilterNotPresent),
969 };
970 if filter.is_empty() {
971 return Ok(BloomPrecheck::FilterNotPresent);
972 }
973
974 let mut any_pathspec_maybe = false;
977 let mut checked_any_keys = false;
978 for spec in pathspecs {
979 if spec.is_empty() || crate::pathspec::pathspec_is_exclude(spec) {
980 continue;
981 }
982 let Some(norm) = crate::pathspec::bloom_lookup_prefix_with_cwd(spec, bloom_cwd) else {
983 continue;
984 };
985 let keys = bloom_keyvec_for_path(norm.as_str(), settings);
986 if keys.is_empty() {
987 continue;
988 }
989 checked_any_keys = true;
990 let mut all_keys_maybe = true;
991 for key in &keys {
992 match bloom_filter_contains(key, filter, settings) {
993 Ok(true) => {}
994 Ok(false) => {
995 all_keys_maybe = false;
996 break;
997 }
998 Err(()) => {
999 all_keys_maybe = true;
1000 break;
1001 }
1002 }
1003 }
1004 if all_keys_maybe {
1005 any_pathspec_maybe = true;
1006 break;
1007 }
1008 }
1009 if checked_any_keys && !any_pathspec_maybe {
1010 return Ok(BloomPrecheck::DefinitelyNot);
1011 }
1012 Ok(BloomPrecheck::Maybe)
1013 }
1014}
1015
1016pub fn diff_changed_paths_for_bloom(
1018 odb: &Odb,
1019 parent_tree: Option<ObjectId>,
1020 commit_tree: ObjectId,
1021) -> crate::error::Result<(Vec<String>, usize)> {
1022 use crate::diff::diff_trees;
1023 let entries = diff_trees(odb, parent_tree.as_ref(), Some(&commit_tree), "")?;
1024 let raw_len = entries.len();
1025 let mut paths = Vec::new();
1026 for e in entries {
1027 let p = e.path().to_string();
1028 if !p.is_empty() {
1029 paths.push(p);
1030 }
1031 }
1032 Ok((paths, raw_len))
1033}
1034
1035pub use crate::bloom::collect_changed_paths_for_bloom;
1037
1038pub fn bloom_filter_for_commit_write(
1040 odb: &Odb,
1041 parents: &[ObjectId],
1042 tree_oid: ObjectId,
1043 settings: &BloomFilterSettings,
1044) -> crate::error::Result<(Vec<u8>, BloomBuildOutcome)> {
1045 let (changed_paths_vec, raw_count) = if let Some(first_parent) = parents.first() {
1049 let p = load_commit_tree(odb, *first_parent)?;
1050 diff_changed_paths_for_bloom(odb, Some(p), tree_oid)?
1051 } else {
1052 diff_changed_paths_for_bloom(odb, None, tree_oid)?
1053 };
1054 let set = collect_changed_paths_for_bloom(&changed_paths_vec);
1055 Ok(crate::bloom::build_bloom_filter_data(
1056 &set, raw_count, settings,
1057 ))
1058}
1059
1060fn load_commit_tree(odb: &Odb, commit_oid: ObjectId) -> crate::error::Result<ObjectId> {
1061 let obj = odb.read(&commit_oid)?;
1062 let c = crate::objects::parse_commit(&obj.data)?;
1063 Ok(c.tree)
1064}
1065
1066fn tree_has_high_bit_paths(odb: &Odb, tree_oid: ObjectId) -> bool {
1071 let Ok(obj) = odb.read(&tree_oid) else {
1072 return true;
1074 };
1075 let Ok(entries) = crate::objects::parse_tree(&obj.data) else {
1076 return true;
1077 };
1078 for e in &entries {
1079 if e.name.iter().any(|&b| b & 0x80 != 0) {
1080 return true;
1081 }
1082 if e.mode == 0o040000 && tree_has_high_bit_paths(odb, e.oid) {
1083 return true;
1084 }
1085 }
1086 false
1087}
1088
1089pub fn commit_tree_has_high_bit_paths(odb: &Odb, commit_oid: ObjectId) -> bool {
1092 match load_commit_tree(odb, commit_oid) {
1093 Ok(tree) => tree_has_high_bit_paths(odb, tree),
1094 Err(_) => true,
1095 }
1096}
1097
1098pub fn parse_graph_file(path: &Path) -> Option<ParsedGraphDump> {
1100 let raw = std::fs::read(path).ok()?;
1101 if raw.len() < 28 {
1102 return None;
1103 }
1104 let hash_len = commit_graph_hash_len(raw[5])?;
1105 let body = &raw[..raw.len() - hash_len];
1106 if body.len() < 8 || &body[0..4] != SIGNATURE {
1107 return None;
1108 }
1109 let header_word = u32::from_be_bytes(body[0..4].try_into().ok()?);
1110 let num_chunks = body[6] as usize;
1111 let toc_start = 8;
1112 let mut present: std::collections::HashSet<u32> = std::collections::HashSet::new();
1113 for i in 0..num_chunks {
1114 let e = toc_start + i * 12;
1115 let id = u32::from_be_bytes(body[e..e + 4].try_into().ok()?);
1116 present.insert(id);
1117 }
1118 let mut chunk_names: Vec<String> = Vec::new();
1121 for (id, label) in [
1122 (CHUNK_OID_FANOUT, "oid_fanout"),
1123 (CHUNK_OID_LOOKUP, "oid_lookup"),
1124 (CHUNK_COMMIT_DATA, "commit_metadata"),
1125 (CHUNK_GENERATION_DATA, "generation_data"),
1126 (CHUNK_GENERATION_DATA_OVERFLOW, "generation_data_overflow"),
1127 (CHUNK_EXTRA_EDGES, "extra_edges"),
1128 (CHUNK_BLOOM_INDEXES, "bloom_indexes"),
1129 (CHUNK_BLOOM_DATA, "bloom_data"),
1130 ] {
1131 if present.contains(&id) {
1132 chunk_names.push(label.to_string());
1133 }
1134 }
1135 let layer = CommitGraphLayer::parse(path.to_path_buf(), raw.clone())?;
1136 let bloom_opt = layer.bloom_settings.map(|s| {
1137 format!(
1138 " bloom({},{},{})",
1139 s.hash_version, s.bits_per_entry, s.num_hashes
1140 )
1141 });
1142 let mut options = String::new();
1143 if let Some(b) = bloom_opt {
1144 options.push_str(&b);
1145 }
1146 if layer.read_generation_data {
1147 options.push_str(" read_generation_data");
1148 }
1149 Some(ParsedGraphDump {
1150 header_word,
1151 version: body[4],
1152 hash_ver: body[5],
1153 num_chunks: body[6],
1154 reserved: body[7],
1155 num_commits: layer.num_commits,
1156 chunks: chunk_names.join(" "),
1157 options,
1158 })
1159}
1160
1161pub struct ParsedGraphDump {
1162 pub header_word: u32,
1163 pub version: u8,
1164 pub hash_ver: u8,
1165 pub num_chunks: u8,
1166 pub reserved: u8,
1167 pub num_commits: u32,
1168 pub chunks: String,
1169 pub options: String,
1170}
1171
1172pub fn dump_bloom_filters(path: &Path) -> Option<Vec<String>> {
1174 let raw = std::fs::read(path).ok()?;
1175 let layer = CommitGraphLayer::parse(path.to_path_buf(), raw)?;
1176 let mut out = Vec::new();
1177 for i in 0..layer.num_commits {
1178 let slice = layer.bloom_filter_slice(i).unwrap_or(&[]);
1179 if slice.is_empty() {
1180 out.push(String::new());
1181 } else {
1182 let hex: String = slice.iter().map(|b| format!("{b:02x}")).collect();
1183 out.push(hex);
1184 }
1185 }
1186 Some(out)
1187}