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