1use std::collections::{HashMap, HashSet};
4use std::io::Write;
5
6use crate::bloom::{BloomBuildOutcome, BloomFilterSettings};
7use crate::commit_graph_file::CommitGraphChain;
8#[cfg(windows)]
9use crate::fs_readonly::clear_readonly_attribute;
10use crate::fs_readonly::clear_readonly_tree;
11use crate::objects::{
12 oid_hash_map_from, oid_hash_set, parse_commit, tag_object_line_oid, HashAlgo, ObjectId,
13 ObjectKind, OidHashMap, OidHashSet,
14};
15use crate::odb::Odb;
16
17const SIGNATURE: &[u8; 4] = b"CGPH";
18const VERSION: u8 = 1;
19const CHUNK_OID_FANOUT: u32 = 0x4f49_4446;
20const CHUNK_OID_LOOKUP: u32 = 0x4f49_444c;
21const CHUNK_COMMIT_DATA: u32 = 0x4344_4154;
22const CHUNK_GENERATION_DATA: u32 = 0x4744_4132;
23const CHUNK_GENERATION_DATA_OVERFLOW: u32 = 0x4744_4f32; const CHUNK_EXTRA_EDGES: u32 = 0x4544_4745;
25const CHUNK_BLOOM_INDEXES: u32 = 0x4249_4458;
26const CHUNK_BLOOM_DATA: u32 = 0x4244_4154;
27const CHUNK_BASE: u32 = 0x4241_5345;
28
29const PARENT_NONE: u32 = 0x7000_0000;
30const GRAPH_EXTRA_EDGES_NEEDED: u32 = 0x8000_0000;
31const GRAPH_LAST_EDGE: u32 = 0x8000_0000;
32
33const GENERATION_NUMBER_V2_OFFSET_MAX: u64 = (1u64 << 31) - 1;
35const CORRECTED_COMMIT_DATE_OFFSET_OVERFLOW: u32 = 1u32 << 31;
37
38#[derive(Debug, Clone)]
40pub struct CommitGraphCommitInfo {
41 pub tree: ObjectId,
43 pub parents: Vec<ObjectId>,
45 pub commit_time: u64,
47}
48
49fn hash_file_body(body: &[u8], algo: HashAlgo) -> Vec<u8> {
51 algo.checksum_digest(body)
52}
53
54fn parse_commit_time(committer: &str) -> u64 {
55 let parts: Vec<&str> = committer.rsplitn(3, ' ').collect();
56 if parts.len() >= 2 {
57 parts[1].parse::<u64>().unwrap_or(0)
58 } else {
59 0
60 }
61}
62
63pub fn load_commit_graph_commit_info(
70 odb: &Odb,
71 oid: ObjectId,
72) -> crate::error::Result<CommitGraphCommitInfo> {
73 let c = odb.read_commit(&oid).map_err(|err| match err {
74 crate::error::Error::UnexpectedObjectKind { .. } => {
75 crate::error::Error::CorruptObject(format!("object {oid} is not a commit"))
76 }
77 other => other,
78 })?;
79 Ok(CommitGraphCommitInfo {
80 tree: c.tree,
81 parents: c.parents.clone(),
82 commit_time: parse_commit_time(&c.committer),
83 })
84}
85
86fn compute_topo_generations(
87 sorted_oids: &[ObjectId],
88 infos: &OidHashMap<CommitGraphCommitInfo>,
89 oid_to_idx: &OidHashMap<u32>,
90) -> Vec<u32> {
91 let n = sorted_oids.len();
92 let mut gen = vec![0u32; n];
93 let mut computed = vec![false; n];
94 for i in 0..n {
95 if computed[i] {
96 continue;
97 }
98 let mut work_stack: Vec<(usize, bool)> = vec![(i, false)];
99 while let Some((idx, parents_done)) = work_stack.pop() {
100 if computed[idx] {
101 continue;
102 }
103 let oid = sorted_oids[idx];
104 let info = &infos[&oid];
105 if parents_done {
106 let mut max_parent_gen = 0u32;
107 for p in &info.parents {
108 if let Some(&pidx) = oid_to_idx.get(p) {
109 max_parent_gen = max_parent_gen.max(gen[pidx as usize]);
110 }
111 }
112 gen[idx] = max_parent_gen + 1;
113 computed[idx] = true;
114 } else {
115 let mut all_done = true;
116 for p in &info.parents {
117 if let Some(&pidx) = oid_to_idx.get(p) {
118 if !computed[pidx as usize] {
119 all_done = false;
120 }
121 }
122 }
123 if all_done {
124 let mut max_parent_gen = 0u32;
125 for p in &info.parents {
126 if let Some(&pidx) = oid_to_idx.get(p) {
127 max_parent_gen = max_parent_gen.max(gen[pidx as usize]);
128 }
129 }
130 gen[idx] = max_parent_gen + 1;
131 computed[idx] = true;
132 } else {
133 work_stack.push((idx, true));
134 for p in &info.parents {
135 if let Some(&pidx) = oid_to_idx.get(p) {
136 if !computed[pidx as usize] {
137 work_stack.push((pidx as usize, false));
138 }
139 }
140 }
141 }
142 }
143 }
144 }
145 gen
146}
147
148fn compute_corrected_generations(
149 sorted_oids: &[ObjectId],
150 infos: &OidHashMap<CommitGraphCommitInfo>,
151 oid_to_idx: &OidHashMap<u32>,
152 topo_gen: &[u32],
153) -> Vec<u64> {
154 let n = sorted_oids.len();
155 let mut gen_date = vec![0u64; n];
156 let mut computed = vec![false; n];
157 for i in 0..n {
158 if computed[i] {
159 continue;
160 }
161 let mut work_stack: Vec<(usize, bool)> = vec![(i, false)];
162 while let Some((idx, parents_done)) = work_stack.pop() {
163 if computed[idx] {
164 continue;
165 }
166 let oid = sorted_oids[idx];
167 let info = &infos[&oid];
168 let cdate = info.commit_time;
169 if parents_done {
170 let mut max_g = cdate;
171 for p in &info.parents {
172 if let Some(&pidx) = oid_to_idx.get(p) {
173 max_g = max_g.max(gen_date[pidx as usize]);
174 }
175 }
176 let topo = topo_gen[idx] as u64;
177 if max_g < topo {
178 max_g = topo;
179 }
180 gen_date[idx] = max_g + 1;
181 computed[idx] = true;
182 } else {
183 let mut all_done = true;
184 for p in &info.parents {
185 if let Some(&pidx) = oid_to_idx.get(p) {
186 if !computed[pidx as usize] {
187 all_done = false;
188 }
189 }
190 }
191 if all_done {
192 let mut max_g = cdate;
193 for p in &info.parents {
194 if let Some(&pidx) = oid_to_idx.get(p) {
195 max_g = max_g.max(gen_date[pidx as usize]);
196 }
197 }
198 let topo = topo_gen[idx] as u64;
199 if max_g < topo {
200 max_g = topo;
201 }
202 gen_date[idx] = max_g + 1;
203 computed[idx] = true;
204 } else {
205 work_stack.push((idx, true));
206 for p in &info.parents {
207 if let Some(&pidx) = oid_to_idx.get(p) {
208 if !computed[pidx as usize] {
209 work_stack.push((pidx as usize, false));
210 }
211 }
212 }
213 }
214 }
215 }
216 }
217 gen_date
218}
219
220fn resolve_parent_edge(
221 commit: ObjectId,
222 parent: ObjectId,
223 oid_to_idx: &OidHashMap<u32>,
224 base_count: u32,
225 chain: Option<&CommitGraphChain>,
226) -> crate::error::Result<u32> {
227 if let Some(&idx) = oid_to_idx.get(&parent) {
228 return Ok(idx + base_count);
229 }
230 chain
231 .and_then(|c| c.global_position(&parent))
232 .ok_or(crate::error::Error::CommitGraphParentMissing { commit, parent })
233}
234
235#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
237pub struct BloomWriteStats {
238 pub filter_computed: u32,
240 pub filter_not_computed: u32,
242 pub filter_trunc_empty: u32,
244 pub filter_trunc_large: u32,
246 pub filter_upgraded: u32,
248}
249#[expect(clippy::too_many_arguments)]
250pub fn build_commit_graph_bytes(
252 sorted_oids: &[ObjectId],
253 infos: &HashMap<ObjectId, CommitGraphCommitInfo>,
254 odb: &Odb,
255 changed_paths: bool,
256 bloom_settings: &BloomFilterSettings,
257 base_chain: Option<&CommitGraphChain>,
258 base_graph_hashes: &[&[u8]],
259 max_new_filters: Option<u32>,
260 existing_filters: &HashMap<ObjectId, Vec<u8>>,
261 upgraded_filters: &HashMap<ObjectId, Vec<u8>>,
262 write_generation_data: bool,
263) -> crate::error::Result<(Vec<u8>, BloomWriteStats)> {
264 build_commit_graph_bytes_from_oid_maps(
265 sorted_oids,
266 &oid_hash_map_from(infos),
267 odb,
268 changed_paths,
269 bloom_settings,
270 base_chain,
271 base_graph_hashes,
272 max_new_filters,
273 &oid_hash_map_from(existing_filters),
274 &oid_hash_map_from(upgraded_filters),
275 write_generation_data,
276 )
277}
278
279#[expect(clippy::too_many_arguments)]
282pub(crate) fn build_commit_graph_bytes_from_oid_maps(
283 sorted_oids: &[ObjectId],
284 infos: &OidHashMap<CommitGraphCommitInfo>,
285 odb: &Odb,
286 changed_paths: bool,
287 bloom_settings: &BloomFilterSettings,
288 base_chain: Option<&CommitGraphChain>,
289 base_graph_hashes: &[&[u8]],
290 max_new_filters: Option<u32>,
291 existing_filters: &OidHashMap<Vec<u8>>,
292 upgraded_filters: &OidHashMap<Vec<u8>>,
293 write_generation_data: bool,
294) -> crate::error::Result<(Vec<u8>, BloomWriteStats)> {
295 let base_count: u32 = base_chain.map(CommitGraphChain::total_commits).unwrap_or(0);
296 let algo = odb.hash_algo();
297 let hash_len = algo.len();
298
299 let oid_to_idx: OidHashMap<u32> = sorted_oids
300 .iter()
301 .enumerate()
302 .map(|(i, o)| (*o, i as u32))
303 .collect();
304
305 let topo = compute_topo_generations(sorted_oids, infos, &oid_to_idx);
306 let gen_date = compute_corrected_generations(sorted_oids, infos, &oid_to_idx, &topo);
307
308 let mut gda2: Vec<u8> = Vec::with_capacity(sorted_oids.len() * 4);
309 let mut generation_overflow: Vec<u8> = Vec::new();
310 let mut overflow_count: u32 = 0;
311 for (i, oid) in sorted_oids.iter().enumerate() {
312 let info = &infos[oid];
313 let offset_raw = gen_date[i].saturating_sub(info.commit_time);
314 if offset_raw > GENERATION_NUMBER_V2_OFFSET_MAX {
315 let marker = CORRECTED_COMMIT_DATE_OFFSET_OVERFLOW | overflow_count;
316 overflow_count = overflow_count.wrapping_add(1);
317 gda2.extend_from_slice(&marker.to_be_bytes());
318 generation_overflow.extend_from_slice(&((offset_raw >> 32) as u32).to_be_bytes());
319 generation_overflow.extend_from_slice(&((offset_raw as u32).to_be_bytes()));
320 } else {
321 gda2.extend_from_slice(&(offset_raw as u32).to_be_bytes());
322 }
323 }
324
325 let mut extra_edges: Vec<u8> = Vec::new();
326
327 let mut cdat: Vec<u8> = Vec::with_capacity(sorted_oids.len() * (hash_len + 16));
328 for (i, oid) in sorted_oids.iter().enumerate() {
329 let info = &infos[oid];
330 cdat.extend_from_slice(info.tree.as_bytes());
331
332 let edge = |parent: ObjectId| {
333 resolve_parent_edge(*oid, parent, &oid_to_idx, base_count, base_chain)
334 };
335 let p1 = info
336 .parents
337 .first()
338 .map(|p| edge(*p))
339 .transpose()?
340 .unwrap_or(PARENT_NONE);
341 cdat.extend_from_slice(&p1.to_be_bytes());
342
343 let p2 = if info.parents.len() <= 1 {
344 PARENT_NONE
345 } else if info.parents.len() == 2 {
346 edge(info.parents[1])?
347 } else {
348 let start_u32 = (extra_edges.len() / 4) as u32;
349 for (j, p) in info.parents.iter().enumerate().skip(1) {
350 let mut ev = edge(*p)?;
351 if j + 1 == info.parents.len() {
352 ev |= GRAPH_LAST_EDGE;
353 }
354 extra_edges.extend_from_slice(&ev.to_be_bytes());
355 }
356 GRAPH_EXTRA_EDGES_NEEDED | start_u32
357 };
358 cdat.extend_from_slice(&p2.to_be_bytes());
359
360 let topo = topo[i];
361 let date = info.commit_time;
362 let packed = (topo << 2) | (((date >> 32) & 0x3) as u32);
363 cdat.extend_from_slice(&packed.to_be_bytes());
364 cdat.extend_from_slice(&((date & 0xFFFF_FFFF) as u32).to_be_bytes());
365 }
366
367 let mut fanout = vec![0u8; 256 * 4];
368 let mut counts = [0u32; 256];
369 for oid in sorted_oids {
370 counts[oid.as_bytes()[0] as usize] += 1;
371 }
372 let mut cum = 0u32;
373 for i in 0..256 {
374 cum += counts[i];
375 fanout[i * 4..i * 4 + 4].copy_from_slice(&cum.to_be_bytes());
376 }
377
378 let mut oid_lookup = Vec::with_capacity(sorted_oids.len() * hash_len);
379 for oid in sorted_oids {
380 oid_lookup.extend_from_slice(oid.as_bytes());
381 }
382
383 let mut bloom_stats = BloomWriteStats::default();
384 let max_new = max_new_filters.unwrap_or(u32::MAX);
385 let (bidx, bdat, bloom_total_payload) = if changed_paths {
386 let mut indexes: Vec<u32> = Vec::with_capacity(sorted_oids.len());
387 let mut data_payload = Vec::new();
388 let mut cur = 0u32;
389 for oid in sorted_oids {
390 let info = &infos[oid];
391 if let Some(existing) = existing_filters.get(oid) {
394 bloom_stats.filter_not_computed += 1;
395 cur += existing.len() as u32;
396 indexes.push(cur);
397 data_payload.extend_from_slice(existing);
398 continue;
399 }
400 if let Some(upgraded) = upgraded_filters.get(oid) {
404 bloom_stats.filter_upgraded += 1;
405 cur += upgraded.len() as u32;
406 indexes.push(cur);
407 data_payload.extend_from_slice(upgraded);
408 continue;
409 }
410 let compute = bloom_stats.filter_computed < max_new;
411 let (bytes, outcome) = if compute {
412 crate::commit_graph_file::bloom_filter_for_commit_write(
413 odb,
414 &info.parents,
415 info.tree,
416 bloom_settings,
417 )?
418 } else {
419 (Vec::new(), BloomBuildOutcome::Normal)
420 };
421 if compute {
422 bloom_stats.filter_computed += 1;
423 match outcome {
424 BloomBuildOutcome::Normal => {}
425 BloomBuildOutcome::TruncatedLarge => bloom_stats.filter_trunc_large += 1,
426 BloomBuildOutcome::TruncatedEmpty => bloom_stats.filter_trunc_empty += 1,
427 }
428 } else {
429 bloom_stats.filter_not_computed += 1;
430 }
431 cur += bytes.len() as u32;
432 indexes.push(cur);
433 data_payload.extend_from_slice(&bytes);
434 }
435 let mut bdat_chunk = Vec::with_capacity(12 + data_payload.len());
436 bdat_chunk.extend_from_slice(&bloom_settings.hash_version.to_be_bytes());
437 bdat_chunk.extend_from_slice(&bloom_settings.num_hashes.to_be_bytes());
438 bdat_chunk.extend_from_slice(&bloom_settings.bits_per_entry.to_be_bytes());
439 bdat_chunk.extend_from_slice(&data_payload);
440 let mut bidx_bytes = Vec::with_capacity(indexes.len() * 4);
441 for v in indexes {
442 bidx_bytes.extend_from_slice(&v.to_be_bytes());
443 }
444 (bidx_bytes, bdat_chunk, data_payload.len())
445 } else {
446 (Vec::new(), Vec::new(), 0)
447 };
448
449 let _ = bloom_total_payload;
450
451 let mut chunks: Vec<(u32, Vec<u8>)> = Vec::new();
452 chunks.push((CHUNK_OID_FANOUT, fanout));
453 chunks.push((CHUNK_OID_LOOKUP, oid_lookup));
454 chunks.push((CHUNK_COMMIT_DATA, cdat));
455 if write_generation_data {
456 chunks.push((CHUNK_GENERATION_DATA, gda2));
457 if !generation_overflow.is_empty() {
458 chunks.push((CHUNK_GENERATION_DATA_OVERFLOW, generation_overflow));
459 }
460 }
461 if !extra_edges.is_empty() {
462 chunks.push((CHUNK_EXTRA_EDGES, extra_edges));
463 }
464 if changed_paths {
465 chunks.push((CHUNK_BLOOM_INDEXES, bidx));
466 chunks.push((CHUNK_BLOOM_DATA, bdat));
467 }
468 if !base_graph_hashes.is_empty() {
469 let mut base_chunk = Vec::new();
470 for h in base_graph_hashes {
471 base_chunk.extend_from_slice(h);
472 }
473 chunks.push((CHUNK_BASE, base_chunk));
474 }
475
476 let num_chunks = chunks.len() as u8;
477 let header_size = 8u64;
478 let toc_size = (num_chunks as u64 + 1) * 12;
479 let mut offsets = Vec::with_capacity(chunks.len());
480 let mut cur = header_size + toc_size;
481 for (_, data) in &chunks {
482 offsets.push(cur);
483 cur += data.len() as u64;
484 }
485 let end_offset = cur;
486
487 let mut out = Vec::with_capacity(end_offset as usize + algo.len());
490 out.write_all(SIGNATURE)?;
491 let base_layers = base_graph_hashes.len() as u8;
492 out.write_all(&[VERSION, algo.oid_version(), num_chunks, base_layers])?;
493 for i in 0..chunks.len() {
494 out.write_all(&chunks[i].0.to_be_bytes())?;
495 out.write_all(&offsets[i].to_be_bytes())?;
496 }
497 out.write_all(&[0u8; 4])?;
498 out.write_all(&end_offset.to_be_bytes())?;
499 for (_, data) in &chunks {
500 out.write_all(data)?;
501 }
502
503 let checksum = hash_file_body(&out, algo);
504 out.write_all(&checksum)?;
505 Ok((out, bloom_stats))
506}
507
508fn walk_reachable_commits_from_tip_stack(
509 odb: &Odb,
510 mut stack: Vec<ObjectId>,
511) -> crate::error::Result<OidHashSet> {
512 let mut commits: OidHashSet = oid_hash_set();
513 while let Some(oid) = stack.pop() {
514 if commits.contains(&oid) {
515 continue;
516 }
517 let obj = match odb.try_read_once(&oid) {
518 Ok(Some(o)) => o,
519 Ok(None) | Err(_) => continue,
520 };
521 if obj.kind != ObjectKind::Commit {
522 if obj.kind == ObjectKind::Tag {
523 stack.extend(tag_object_line_oid(&obj.data));
524 }
525 continue;
526 }
527 let commit = parse_commit(&obj.data)?;
528 for parent in &commit.parents {
529 stack.push(*parent);
530 }
531 commits.insert(oid);
532 }
533 Ok(commits)
534}
535
536pub fn collect_reachable_commit_oids_for_repository(
547 repo: &crate::repo::Repository,
548) -> crate::error::Result<HashSet<ObjectId>> {
549 use crate::refs;
550 let odb = repo.odb();
551 let mut stack: Vec<ObjectId> = Vec::new();
552 if let Ok(oid) = repo.refs().resolve("HEAD") {
553 stack.push(oid);
554 }
555 for (_, oid) in refs::list_refs_for_repository(repo, "refs/")? {
556 stack.push(oid);
557 }
558 Ok(walk_reachable_commits_from_tip_stack(odb, stack)?
559 .into_iter()
560 .collect())
561}
562
563pub fn collect_reachable_commit_oids(
568 git_dir: &std::path::Path,
569 odb: &Odb,
570) -> crate::error::Result<HashSet<ObjectId>> {
571 use std::fs;
572 let mut stack: Vec<ObjectId> = Vec::new();
573
574 fn collect_ref_tips(
575 dir: &std::path::Path,
576 stack: &mut Vec<ObjectId>,
577 ) -> crate::error::Result<()> {
578 if !dir.exists() {
579 return Ok(());
580 }
581 for entry in fs::read_dir(dir)? {
582 let entry = entry?;
583 let path = entry.path();
584 if path.is_dir() {
585 collect_ref_tips(&path, stack)?;
586 } else if let Ok(content) = fs::read_to_string(&path) {
587 if let Ok(oid) = ObjectId::from_hex(content.trim()) {
588 stack.push(oid);
589 }
590 }
591 }
592 Ok(())
593 }
594
595 let refs_dir = git_dir.join("refs");
596 collect_ref_tips(&refs_dir, &mut stack)?;
597
598 let packed_refs = crate::refs::store::paths::packed_refs_path(git_dir);
599 if packed_refs.exists() {
600 if let Ok(content) = fs::read_to_string(&packed_refs) {
601 for line in content.lines() {
602 if line.starts_with('#') || line.starts_with('^') {
603 continue;
604 }
605 if let Some(hex) = line.split_whitespace().next() {
606 if let Ok(oid) = ObjectId::from_hex(hex) {
607 stack.push(oid);
608 }
609 }
610 }
611 }
612 }
613
614 let head_path = git_dir.join("HEAD");
615 if head_path.exists() {
616 let head = fs::read_to_string(&head_path)?;
617 let head = head.trim();
618 if let Some(refpath) = head.strip_prefix("ref: ") {
619 let full = git_dir.join(refpath);
620 if full.exists() {
621 if let Ok(content) = fs::read_to_string(&full) {
622 if let Ok(oid) = ObjectId::from_hex(content.trim()) {
623 stack.push(oid);
624 }
625 }
626 }
627 } else if let Ok(oid) = ObjectId::from_hex(head) {
628 stack.push(oid);
629 }
630 }
631
632 Ok(walk_reachable_commits_from_tip_stack(odb, stack)?
633 .into_iter()
634 .collect())
635}
636
637fn is_repository_directory(path: &std::path::Path) -> bool {
639 use std::fs;
640 let Ok(meta) = fs::symlink_metadata(path) else {
641 return false;
642 };
643 let file_type = meta.file_type();
644 file_type.is_dir() && !file_type.is_symlink()
645}
646
647pub fn atomic_replace_commit_graph(
656 objects_dir: &std::path::Path,
657 bytes: &[u8],
658) -> crate::error::Result<std::path::PathBuf> {
659 use std::fs;
660 use std::io::Write as _;
661
662 let info = objects_dir.join("info");
663 fs::create_dir_all(&info).map_err(crate::error::Error::Io)?;
664 let graph_path = info.join("commit-graph");
665 let lock_path = info.join("commit-graph.lock");
666 use crate::lockfile::{LockError, LockFile, LockWait};
667 let git_dir = objects_dir.parent().unwrap_or(objects_dir);
668 let wait = LockWait::files_ref_lock_for_git_dir(git_dir);
669 LockFile::acquire(&lock_path, wait)
670 .map(|guard| guard.detach())
671 .map_err(|err| match err {
672 LockError::Held { .. } => {
673 crate::error::Error::from(crate::error::RefLockError::PackedRefsLockHeld {
674 lock_path: lock_path.display().to_string(),
675 })
676 }
677 LockError::Io { source, .. } => crate::error::Error::Io(source),
678 })?;
679
680 let tmp = info.join("commit-graph.new");
681 let mut created_tmp = false;
682 let write_result = (|| -> crate::error::Result<()> {
683 let mut file = fs::OpenOptions::new()
685 .write(true)
686 .create_new(true)
687 .open(&tmp)
688 .map_err(crate::error::Error::Io)?;
689 created_tmp = true;
690 file.write_all(bytes).map_err(crate::error::Error::Io)?;
691 drop(file);
692 #[cfg(test)]
693 if test_inject::should_fail_rename() {
694 return Err(crate::error::Error::Message(
695 "injected commit-graph rename failure".into(),
696 ));
697 }
698 #[cfg(windows)]
699 if fs::symlink_metadata(&graph_path)
700 .map(|m| m.file_type().is_file() && !m.file_type().is_symlink())
701 .unwrap_or(false)
702 {
703 clear_readonly_attribute(&graph_path, true)?;
704 }
705 fs::rename(&tmp, &graph_path).map_err(crate::error::Error::Io)?;
707 created_tmp = false;
708 Ok(())
709 })();
710 let _ = fs::remove_file(&lock_path);
711 if write_result.is_err() && created_tmp {
712 let _ = fs::remove_file(&tmp);
713 }
714 write_result?;
715 Ok(graph_path)
716}
717
718pub fn remove_commit_graph_chain_dir(objects_dir: &std::path::Path) -> bool {
722 let graphs = objects_dir.join("info").join("commit-graphs");
723 if !is_repository_directory(&graphs) {
724 return false;
725 }
726 let _ = clear_readonly_tree(&graphs);
727 std::fs::remove_dir_all(&graphs).is_ok()
728}
729
730pub(crate) fn remove_commit_graph_files(
738 objects_dir: &std::path::Path,
739) -> crate::error::Result<bool> {
740 let graph_path = objects_dir.join("info").join("commit-graph");
741 #[cfg(windows)]
742 if std::fs::symlink_metadata(&graph_path).is_ok_and(|m| m.file_type().is_file()) {
743 clear_readonly_attribute(&graph_path, true)?;
744 }
745 match std::fs::remove_file(&graph_path) {
746 Ok(()) => {}
747 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
748 Err(e) => return Err(crate::error::Error::Io(e)),
749 }
750 Ok(remove_commit_graph_chain_dir(objects_dir))
751}
752
753pub fn count_referenced_commit_tips(
757 git_dir: &std::path::Path,
758 odb: &Odb,
759) -> crate::error::Result<usize> {
760 use std::fs;
761 let mut tips: Vec<ObjectId> = Vec::new();
762
763 fn collect_ref_tips(
764 dir: &std::path::Path,
765 tips: &mut Vec<ObjectId>,
766 ) -> crate::error::Result<()> {
767 if !dir.exists() {
768 return Ok(());
769 }
770 for entry in fs::read_dir(dir)? {
771 let entry = entry?;
772 let path = entry.path();
773 if path.is_dir() {
774 collect_ref_tips(&path, tips)?;
775 } else if let Ok(content) = fs::read_to_string(&path) {
776 if let Ok(oid) = ObjectId::from_hex(content.trim()) {
777 tips.push(oid);
778 }
779 }
780 }
781 Ok(())
782 }
783
784 collect_ref_tips(&git_dir.join("refs"), &mut tips)?;
785
786 let packed_refs = crate::refs::store::paths::packed_refs_path(git_dir);
787 if packed_refs.exists() {
788 if let Ok(content) = fs::read_to_string(&packed_refs) {
789 for line in content.lines() {
790 if line.starts_with('#') || line.starts_with('^') {
791 continue;
792 }
793 if let Some(hex) = line.split_whitespace().next() {
794 if let Ok(oid) = ObjectId::from_hex(hex) {
795 tips.push(oid);
796 }
797 }
798 }
799 }
800 }
801
802 let mut commits: OidHashSet = oid_hash_set();
806 for tip in tips {
807 if let Ok(Some(commit_oid)) = odb.peel_to_commit(tip) {
808 commits.insert(commit_oid);
809 }
810 }
811 Ok(commits.len())
812}
813
814#[cfg(test)]
815mod test_inject {
816 use std::cell::Cell;
817
818 thread_local! {
819 static FAIL_RENAME: Cell<bool> = const { Cell::new(false) };
820 }
821
822 pub fn set_fail_rename(fail: bool) {
823 FAIL_RENAME.with(|flag| flag.set(fail));
824 }
825
826 pub(crate) fn should_fail_rename() -> bool {
827 FAIL_RENAME.with(|flag| flag.get())
828 }
829
830 pub struct FailRenameGuard(bool);
832
833 impl FailRenameGuard {
834 pub fn enabled() -> Self {
835 set_fail_rename(true);
836 Self(true)
837 }
838 }
839
840 impl Drop for FailRenameGuard {
841 fn drop(&mut self) {
842 if self.0 {
843 set_fail_rename(false);
844 }
845 }
846 }
847}
848
849#[cfg(test)]
850mod tests {
851 use std::collections::HashMap;
852 use std::sync::Mutex;
853
854 static ATOMIC_REPLACE_INJECT: Mutex<()> = Mutex::new(());
856
857 use crate::bloom::BloomFilterSettings;
858 use crate::index::{Index, IndexEntry, MODE_REGULAR};
859 use crate::objects::{oid_hash_map, serialize_commit, CommitData, ObjectId, ObjectKind};
860
861 fn commit_infos_std(
862 infos: &crate::objects::OidHashMap<super::CommitGraphCommitInfo>,
863 ) -> HashMap<ObjectId, super::CommitGraphCommitInfo> {
864 infos.iter().map(|(k, v)| (*k, v.clone())).collect()
865 }
866
867 fn empty_filter_map() -> HashMap<ObjectId, Vec<u8>> {
868 HashMap::new()
869 }
870 use crate::repo::{init_repository, Repository};
871 use crate::write_tree::write_tree_from_index;
872
873 fn empty_commit(repo: &Repository, parents: Vec<ObjectId>, committer: &str) -> ObjectId {
874 let raw = serialize_commit(&CommitData {
875 tree: repo.odb().write(ObjectKind::Tree, b"").expect("tree"),
876 parents,
877 author: committer.into(),
878 committer: committer.into(),
879 author_raw: Vec::new(),
880 committer_raw: Vec::new(),
881 encoding: None,
882 message: "m\n".into(),
883 raw_message: None,
884 extra_headers: Vec::new(),
885 });
886 repo.odb().write(ObjectKind::Commit, &raw).expect("commit")
887 }
888
889 #[test]
890 fn load_commit_graph_commit_info_rejects_non_commit() {
891 let dir = tempfile::tempdir().expect("tempdir");
892 let repo = init_repository(
893 dir.path(),
894 false,
895 "main",
896 None,
897 crate::RefStorageFormat::Files,
898 )
899 .expect("init");
900 let blob = repo.odb().write(ObjectKind::Blob, b"x").expect("blob");
901 let err = super::load_commit_graph_commit_info(&repo.odb(), blob).unwrap_err();
902 assert!(matches!(err, crate::error::Error::CorruptObject(_)));
903 }
904
905 #[test]
906 fn build_octopus_merge_includes_extra_edges_chunk() {
907 let dir = tempfile::tempdir().expect("tempdir");
908 let repo = init_repository(
909 dir.path(),
910 false,
911 "main",
912 None,
913 crate::RefStorageFormat::Files,
914 )
915 .expect("init");
916 let p1 = empty_commit(&repo, vec![], "T <t@example.com> 1 +0000");
917 let p2 = empty_commit(&repo, vec![], "T <t@example.com> 2 +0000");
918 let p3 = empty_commit(&repo, vec![], "T <t@example.com> 3 +0000");
919 let merge = empty_commit(&repo, vec![p1, p2, p3], "T <t@example.com> 4 +0000");
920 let mut sorted = vec![p1, p2, p3, merge];
921 sorted.sort();
922 let mut infos = oid_hash_map();
923 for oid in &sorted {
924 infos.insert(
925 *oid,
926 super::load_commit_graph_commit_info(&repo.odb(), *oid).expect("info"),
927 );
928 }
929 let bloom = BloomFilterSettings::default();
930 let empty = empty_filter_map();
931 let (bytes, _) = super::build_commit_graph_bytes(
932 &sorted,
933 &commit_infos_std(&infos),
934 &repo.odb(),
935 false,
936 &bloom,
937 None,
938 &[],
939 None,
940 &empty,
941 &empty,
942 true,
943 )
944 .expect("write");
945 assert!(bytes.windows(4).any(|w| w == b"EDGE"));
946 crate::commit_graph_file::CommitGraphLayer::try_parse(std::path::PathBuf::from("g"), bytes)
947 .expect("parse octopus graph");
948 }
949
950 #[test]
951 fn build_rejects_parent_outside_graph() {
952 let dir = tempfile::tempdir().expect("tempdir");
953 let repo = init_repository(
954 dir.path(),
955 false,
956 "main",
957 None,
958 crate::RefStorageFormat::Files,
959 )
960 .expect("init");
961 let root = empty_commit(&repo, vec![], "T <t@example.com> 1 +0000");
962 let side = empty_commit(&repo, vec![], "T <t@example.com> 2 +0000");
963 let merge = empty_commit(&repo, vec![root, side], "T <t@example.com> 3 +0000");
964 let sorted = vec![merge, side];
965 let mut infos = oid_hash_map();
966 for oid in &sorted {
967 infos.insert(
968 *oid,
969 super::load_commit_graph_commit_info(&repo.odb(), *oid).expect("info"),
970 );
971 }
972 let empty = empty_filter_map();
973 let err = super::build_commit_graph_bytes(
974 &sorted,
975 &commit_infos_std(&infos),
976 &repo.odb(),
977 false,
978 &BloomFilterSettings::default(),
979 None,
980 &[],
981 None,
982 &empty,
983 &empty,
984 true,
985 )
986 .expect_err("first parent is not in the graph");
987 assert!(matches!(
988 err,
989 crate::error::Error::CommitGraphParentMissing { commit, parent }
990 if commit == merge && parent == root
991 ));
992 }
993
994 #[test]
995 fn build_respects_max_new_filters_budget() {
996 let dir = tempfile::tempdir().expect("tempdir");
997 let repo = init_repository(
998 dir.path(),
999 false,
1000 "main",
1001 None,
1002 crate::RefStorageFormat::Files,
1003 )
1004 .expect("init");
1005 let mut index = Index::new();
1006 index.hash_algo = repo.odb().hash_algo();
1007 let blob = repo.odb().write(ObjectKind::Blob, b"a\n").expect("blob");
1008 index.add_or_replace(IndexEntry {
1009 ctime_sec: 0,
1010 ctime_nsec: 0,
1011 mtime_sec: 0,
1012 mtime_nsec: 0,
1013 dev: 0,
1014 ino: 0,
1015 mode: MODE_REGULAR,
1016 uid: 0,
1017 gid: 0,
1018 size: 2,
1019 oid: blob,
1020 flags: 4,
1021 flags_extended: None,
1022 path: b"a.txt".to_vec(),
1023 base_index_pos: 0,
1024 });
1025 let tree = write_tree_from_index(&repo.odb(), &index, "").expect("tree");
1026 let mut sorted = Vec::new();
1027 let mut infos = oid_hash_map();
1028 for t in [1i64, 2, 3] {
1029 let raw = serialize_commit(&CommitData {
1030 tree,
1031 parents: sorted.last().copied().map(|p| vec![p]).unwrap_or_default(),
1032 author: format!("T <t@example.com> {t} +0000"),
1033 committer: format!("T <t@example.com> {t} +0000"),
1034 author_raw: Vec::new(),
1035 committer_raw: Vec::new(),
1036 encoding: None,
1037 message: "m\n".into(),
1038 raw_message: None,
1039 extra_headers: Vec::new(),
1040 });
1041 let c = repo.odb().write(ObjectKind::Commit, &raw).expect("commit");
1042 infos.insert(
1043 c,
1044 super::load_commit_graph_commit_info(&repo.odb(), c).expect("info"),
1045 );
1046 sorted.push(c);
1047 }
1048 let bloom = BloomFilterSettings::default();
1049 let empty = empty_filter_map();
1050 let (_, stats) = super::build_commit_graph_bytes(
1051 &sorted,
1052 &commit_infos_std(&infos),
1053 &repo.odb(),
1054 true,
1055 &bloom,
1056 None,
1057 &[],
1058 Some(1),
1059 &empty,
1060 &empty,
1061 true,
1062 )
1063 .expect("write");
1064 assert!(stats.filter_computed <= 1);
1065 assert!(stats.filter_not_computed >= 1);
1066 }
1067
1068 #[test]
1069 fn atomic_replace_preserves_existing_graph_when_rename_fails() {
1070 let _inject = ATOMIC_REPLACE_INJECT
1071 .lock()
1072 .unwrap_or_else(|e| e.into_inner());
1073 let dir = tempfile::tempdir().expect("tempdir");
1074 let repo = init_repository(
1075 dir.path(),
1076 false,
1077 "main",
1078 None,
1079 crate::RefStorageFormat::Files,
1080 )
1081 .expect("init");
1082 let objects_dir = repo.odb().objects_dir();
1083 let prior = b"CGPH\x01\x00\x00\x00prior-graph-bytes";
1084 let graph_path = objects_dir.join("info").join("commit-graph");
1085 std::fs::create_dir_all(graph_path.parent().unwrap()).expect("info");
1086 std::fs::write(&graph_path, prior).expect("seed graph");
1087
1088 let _fail_rename = super::test_inject::FailRenameGuard::enabled();
1089 let err = super::atomic_replace_commit_graph(objects_dir, b"replacement")
1090 .expect_err("injected rename failure");
1091 assert!(err.to_string().contains("injected commit-graph rename"));
1092
1093 let on_disk = std::fs::read(&graph_path).expect("graph still present");
1094 assert_eq!(
1095 on_disk, prior,
1096 "existing commit-graph must survive failed replace"
1097 );
1098 assert!(
1099 !objects_dir.join("info").join("commit-graph.new").exists(),
1100 "temp file must be removed on failure"
1101 );
1102 }
1103
1104 #[test]
1105 fn atomic_replace_renames_over_existing_file() {
1106 let _inject = ATOMIC_REPLACE_INJECT
1107 .lock()
1108 .unwrap_or_else(|e| e.into_inner());
1109 let dir = tempfile::tempdir().expect("tempdir");
1110 let repo = init_repository(
1111 dir.path(),
1112 false,
1113 "main",
1114 None,
1115 crate::RefStorageFormat::Files,
1116 )
1117 .expect("init");
1118 let objects_dir = repo.odb().objects_dir();
1119 let graph_path = objects_dir.join("info").join("commit-graph");
1120 std::fs::create_dir_all(graph_path.parent().unwrap()).expect("info");
1121 std::fs::write(&graph_path, b"old").expect("seed");
1122
1123 let replacement = b"CGPH\x01\x00\x00\x00new-graph";
1124 super::atomic_replace_commit_graph(objects_dir, replacement).expect("replace");
1125 let on_disk = std::fs::read(&graph_path).expect("graph");
1126 assert_eq!(on_disk, replacement);
1127 }
1128
1129 #[test]
1130 fn atomic_replace_over_readonly_existing() {
1131 let dir = tempfile::tempdir().expect("tempdir");
1132 let repo = init_repository(
1133 dir.path(),
1134 false,
1135 "main",
1136 None,
1137 crate::RefStorageFormat::Files,
1138 )
1139 .expect("init");
1140 let objects_dir = repo.odb().objects_dir();
1141 let graph_path = objects_dir.join("info").join("commit-graph");
1142 std::fs::create_dir_all(graph_path.parent().unwrap()).expect("info");
1143 std::fs::write(&graph_path, b"old").expect("seed");
1144 let mut perms = std::fs::metadata(&graph_path).expect("meta").permissions();
1145 perms.set_readonly(true);
1146 std::fs::set_permissions(&graph_path, perms).expect("readonly");
1147
1148 let replacement = b"CGPH\x01\x00\x00\x00new-graph";
1149 super::atomic_replace_commit_graph(objects_dir, replacement).expect("replace");
1150 let on_disk = std::fs::read(&graph_path).expect("graph");
1151 assert_eq!(on_disk, replacement);
1152 }
1153
1154 #[test]
1155 fn remove_commit_graph_chain_dir_clears_readonly_layers() {
1156 let dir = tempfile::tempdir().expect("tempdir");
1157 let objects_dir = dir.path().join("objects");
1158 let chain_root = objects_dir.join("info").join("commit-graphs");
1159 let layer = chain_root.join("graph-chain-abc123");
1160 std::fs::create_dir_all(&layer).expect("layer dir");
1161 std::fs::write(layer.join("graph"), b"layer-bytes").expect("layer file");
1162 for path in [&chain_root, &layer, &layer.join("graph")] {
1163 let mut perms = std::fs::metadata(path).expect("meta").permissions();
1164 perms.set_readonly(true);
1165 std::fs::set_permissions(path, perms).expect("readonly");
1166 }
1167
1168 assert!(super::remove_commit_graph_chain_dir(&objects_dir));
1169 assert!(!chain_root.exists());
1170 }
1171
1172 #[test]
1173 fn remove_commit_graph_files_tolerates_missing_graph() {
1174 let dir = tempfile::tempdir().expect("tempdir");
1175 let objects_dir = dir.path().join("objects");
1176 std::fs::create_dir_all(objects_dir.join("info")).expect("info");
1177 assert!(!super::remove_commit_graph_files(&objects_dir).expect("nothing to remove"));
1178
1179 let graph_path = objects_dir.join("info").join("commit-graph");
1180 std::fs::write(&graph_path, b"graph").expect("seed graph");
1181 let mut perms = std::fs::metadata(&graph_path).expect("meta").permissions();
1182 perms.set_readonly(true);
1183 std::fs::set_permissions(&graph_path, perms).expect("readonly");
1184 std::fs::create_dir_all(objects_dir.join("info").join("commit-graphs")).expect("chain");
1185 assert!(super::remove_commit_graph_files(&objects_dir).expect("remove"));
1186 assert!(!graph_path.exists());
1187 }
1188
1189 #[cfg(unix)]
1190 #[test]
1191 fn remove_commit_graph_chain_symlink_does_not_chmod_outside() {
1192 use std::os::unix::fs::PermissionsExt;
1193
1194 let dir = tempfile::tempdir().expect("tempdir");
1195 let outside = tempfile::tempdir().expect("outside");
1196 let victim = outside.path().join("victim");
1197 std::fs::write(&victim, b"outside").expect("victim");
1198 let mut perms = std::fs::metadata(&victim).expect("meta").permissions();
1199 perms.set_mode(0o444);
1200 std::fs::set_permissions(&victim, perms).expect("readonly victim");
1201
1202 let objects_dir = dir.path().join("objects");
1203 let info = objects_dir.join("info");
1204 std::fs::create_dir_all(&info).expect("info");
1205 std::os::unix::fs::symlink(outside.path(), info.join("commit-graphs")).expect("symlink");
1206
1207 assert!(
1208 !super::remove_commit_graph_chain_dir(&objects_dir),
1209 "symlinked commit-graphs must not be treated as an in-repo chain directory"
1210 );
1211 let mode_after = std::fs::metadata(&victim)
1212 .expect("victim meta")
1213 .permissions()
1214 .mode();
1215 assert_eq!(
1216 mode_after & 0o777,
1217 0o444,
1218 "must not chmod files outside the repository via symlinked commit-graphs"
1219 );
1220 assert!(
1221 info.join("commit-graphs").symlink_metadata().is_ok(),
1222 "symlink entry must remain when not removed as a directory"
1223 );
1224 }
1225
1226 #[test]
1227 fn collect_reachable_follows_annotated_tag_to_commit() {
1228 let dir = tempfile::tempdir().expect("tempdir");
1229 let repo = init_repository(
1230 dir.path(),
1231 false,
1232 "main",
1233 None,
1234 crate::RefStorageFormat::Files,
1235 )
1236 .expect("init");
1237 let commit = empty_commit(&repo, vec![], "T <t@example.com> 1 +0000");
1238 let tag_body = format!(
1239 "object {}\ntype commit\ntag v1\ntagger T <t@example.com> 1 +0000\n\n",
1240 commit
1241 );
1242 let tag = repo
1243 .odb()
1244 .write(ObjectKind::Tag, tag_body.as_bytes())
1245 .expect("tag");
1246 crate::refs::write_ref(&repo.git_dir(), "refs/tags/v1", &tag).expect("ref");
1247 let set = super::collect_reachable_commit_oids(&repo.git_dir(), &repo.odb()).expect("walk");
1248 assert!(set.contains(&commit));
1249 let tips = super::count_referenced_commit_tips(&repo.git_dir(), &repo.odb()).expect("tips");
1250 assert_eq!(tips, 1);
1251 }
1252}