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.read(&oid) {
511 Ok(o) => o,
512 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(
539 repo: &crate::repo::Repository,
540) -> crate::error::Result<HashSet<ObjectId>> {
541 use crate::refs;
542 let odb = repo.odb();
543 let mut stack: Vec<ObjectId> = Vec::new();
544 if let Ok(oid) = repo.refs().resolve("HEAD") {
545 stack.push(oid);
546 }
547 for (_, oid) in refs::list_refs_for_repository(repo, "refs/")? {
548 stack.push(oid);
549 }
550 Ok(walk_reachable_commits_from_tip_stack(odb, stack)?
551 .into_iter()
552 .collect())
553}
554
555pub fn collect_reachable_commit_oids(
560 git_dir: &std::path::Path,
561 odb: &Odb,
562) -> crate::error::Result<HashSet<ObjectId>> {
563 use std::fs;
564 let mut stack: Vec<ObjectId> = Vec::new();
565
566 fn collect_ref_tips(
567 dir: &std::path::Path,
568 stack: &mut Vec<ObjectId>,
569 ) -> crate::error::Result<()> {
570 if !dir.exists() {
571 return Ok(());
572 }
573 for entry in fs::read_dir(dir)? {
574 let entry = entry?;
575 let path = entry.path();
576 if path.is_dir() {
577 collect_ref_tips(&path, stack)?;
578 } else if let Ok(content) = fs::read_to_string(&path) {
579 if let Ok(oid) = ObjectId::from_hex(content.trim()) {
580 stack.push(oid);
581 }
582 }
583 }
584 Ok(())
585 }
586
587 let refs_dir = git_dir.join("refs");
588 collect_ref_tips(&refs_dir, &mut stack)?;
589
590 let packed_refs = crate::refs::store::paths::packed_refs_path(git_dir);
591 if packed_refs.exists() {
592 if let Ok(content) = fs::read_to_string(&packed_refs) {
593 for line in content.lines() {
594 if line.starts_with('#') || line.starts_with('^') {
595 continue;
596 }
597 if let Some(hex) = line.split_whitespace().next() {
598 if let Ok(oid) = ObjectId::from_hex(hex) {
599 stack.push(oid);
600 }
601 }
602 }
603 }
604 }
605
606 let head_path = git_dir.join("HEAD");
607 if head_path.exists() {
608 let head = fs::read_to_string(&head_path)?;
609 let head = head.trim();
610 if let Some(refpath) = head.strip_prefix("ref: ") {
611 let full = git_dir.join(refpath);
612 if full.exists() {
613 if let Ok(content) = fs::read_to_string(&full) {
614 if let Ok(oid) = ObjectId::from_hex(content.trim()) {
615 stack.push(oid);
616 }
617 }
618 }
619 } else if let Ok(oid) = ObjectId::from_hex(head) {
620 stack.push(oid);
621 }
622 }
623
624 Ok(walk_reachable_commits_from_tip_stack(odb, stack)?
625 .into_iter()
626 .collect())
627}
628
629fn clear_readonly_attribute(path: &std::path::Path, required: bool) -> crate::error::Result<()> {
634 use std::fs;
635 let meta = match fs::symlink_metadata(path) {
636 Ok(m) => m,
637 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
638 Err(e) => return Err(crate::error::Error::Io(e)),
639 };
640 let mut perms = meta.permissions();
641 if !perms.readonly() {
642 return Ok(());
643 }
644 #[cfg(windows)]
645 {
646 perms.set_readonly(false);
647 fs::set_permissions(path, perms).map_err(crate::error::Error::Io)?;
648 }
649 #[cfg(unix)]
650 {
651 use std::os::unix::fs::PermissionsExt;
652 let mut mode = perms.mode();
653 mode |= 0o200;
654 perms.set_mode(mode);
655 if let Err(e) = fs::set_permissions(path, perms) {
656 if required {
657 return Err(crate::error::Error::Io(e));
658 }
659 }
660 }
661 #[cfg(windows)]
662 let _ = required;
663 Ok(())
664}
665
666fn is_repository_directory(path: &std::path::Path) -> bool {
668 use std::fs;
669 let Ok(meta) = fs::symlink_metadata(path) else {
670 return false;
671 };
672 let file_type = meta.file_type();
673 file_type.is_dir() && !file_type.is_symlink()
674}
675
676fn clear_readonly_tree(path: &std::path::Path) -> crate::error::Result<()> {
677 use std::fs;
678 let meta = fs::symlink_metadata(path).map_err(crate::error::Error::Io)?;
679 let file_type = meta.file_type();
680 if file_type.is_symlink() {
681 return Ok(());
682 }
683 if file_type.is_dir() {
684 for entry in fs::read_dir(path).map_err(crate::error::Error::Io)? {
685 let entry = entry.map_err(crate::error::Error::Io)?;
686 let child_type = entry.file_type().map_err(crate::error::Error::Io)?;
687 if child_type.is_symlink() {
688 continue;
689 }
690 clear_readonly_tree(&entry.path())?;
691 }
692 }
693 clear_readonly_attribute(path, cfg!(windows))
694}
695
696pub fn atomic_replace_commit_graph(
705 objects_dir: &std::path::Path,
706 bytes: &[u8],
707) -> crate::error::Result<std::path::PathBuf> {
708 use std::fs;
709 use std::io::Write as _;
710
711 let info = objects_dir.join("info");
712 fs::create_dir_all(&info).map_err(crate::error::Error::Io)?;
713 let graph_path = info.join("commit-graph");
714 let lock_path = info.join("commit-graph.lock");
715 use crate::lockfile::{LockError, LockFile, LockWait};
716 let git_dir = objects_dir.parent().unwrap_or(objects_dir);
717 let wait = LockWait::files_ref_lock_for_git_dir(git_dir);
718 LockFile::acquire(&lock_path, wait)
719 .map(|guard| guard.detach())
720 .map_err(|err| match err {
721 LockError::Held { .. } => {
722 crate::error::Error::from(crate::error::RefLockError::PackedRefsLockHeld {
723 lock_path: lock_path.display().to_string(),
724 })
725 }
726 LockError::Io { source, .. } => crate::error::Error::Io(source),
727 })?;
728
729 let tmp = info.join("commit-graph.new");
730 let mut created_tmp = false;
731 let write_result = (|| -> crate::error::Result<()> {
732 let mut file = fs::OpenOptions::new()
734 .write(true)
735 .create_new(true)
736 .open(&tmp)
737 .map_err(crate::error::Error::Io)?;
738 created_tmp = true;
739 file.write_all(bytes).map_err(crate::error::Error::Io)?;
740 drop(file);
741 #[cfg(test)]
742 if test_inject::should_fail_rename() {
743 return Err(crate::error::Error::Message(
744 "injected commit-graph rename failure".into(),
745 ));
746 }
747 #[cfg(windows)]
748 if fs::symlink_metadata(&graph_path)
749 .map(|m| m.file_type().is_file() && !m.file_type().is_symlink())
750 .unwrap_or(false)
751 {
752 clear_readonly_attribute(&graph_path, true)?;
753 }
754 fs::rename(&tmp, &graph_path).map_err(crate::error::Error::Io)?;
756 created_tmp = false;
757 Ok(())
758 })();
759 let _ = fs::remove_file(&lock_path);
760 if write_result.is_err() && created_tmp {
761 let _ = fs::remove_file(&tmp);
762 }
763 write_result?;
764 Ok(graph_path)
765}
766
767pub fn remove_commit_graph_chain_dir(objects_dir: &std::path::Path) -> bool {
771 let graphs = objects_dir.join("info").join("commit-graphs");
772 if !is_repository_directory(&graphs) {
773 return false;
774 }
775 let _ = clear_readonly_tree(&graphs);
776 std::fs::remove_dir_all(&graphs).is_ok()
777}
778
779pub(crate) fn remove_commit_graph_files(
787 objects_dir: &std::path::Path,
788) -> crate::error::Result<bool> {
789 let graph_path = objects_dir.join("info").join("commit-graph");
790 #[cfg(windows)]
791 if std::fs::symlink_metadata(&graph_path).is_ok_and(|m| m.file_type().is_file()) {
792 clear_readonly_attribute(&graph_path, true)?;
793 }
794 match std::fs::remove_file(&graph_path) {
795 Ok(()) => {}
796 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
797 Err(e) => return Err(crate::error::Error::Io(e)),
798 }
799 Ok(remove_commit_graph_chain_dir(objects_dir))
800}
801
802pub fn count_referenced_commit_tips(
806 git_dir: &std::path::Path,
807 odb: &Odb,
808) -> crate::error::Result<usize> {
809 use std::fs;
810 let mut tips: Vec<ObjectId> = Vec::new();
811
812 fn collect_ref_tips(
813 dir: &std::path::Path,
814 tips: &mut Vec<ObjectId>,
815 ) -> crate::error::Result<()> {
816 if !dir.exists() {
817 return Ok(());
818 }
819 for entry in fs::read_dir(dir)? {
820 let entry = entry?;
821 let path = entry.path();
822 if path.is_dir() {
823 collect_ref_tips(&path, tips)?;
824 } else if let Ok(content) = fs::read_to_string(&path) {
825 if let Ok(oid) = ObjectId::from_hex(content.trim()) {
826 tips.push(oid);
827 }
828 }
829 }
830 Ok(())
831 }
832
833 collect_ref_tips(&git_dir.join("refs"), &mut tips)?;
834
835 let packed_refs = crate::refs::store::paths::packed_refs_path(git_dir);
836 if packed_refs.exists() {
837 if let Ok(content) = fs::read_to_string(&packed_refs) {
838 for line in content.lines() {
839 if line.starts_with('#') || line.starts_with('^') {
840 continue;
841 }
842 if let Some(hex) = line.split_whitespace().next() {
843 if let Ok(oid) = ObjectId::from_hex(hex) {
844 tips.push(oid);
845 }
846 }
847 }
848 }
849 }
850
851 let mut commits: OidHashSet = oid_hash_set();
855 for tip in tips {
856 if let Ok(Some(commit_oid)) = odb.peel_to_commit(tip) {
857 commits.insert(commit_oid);
858 }
859 }
860 Ok(commits.len())
861}
862
863#[cfg(test)]
864mod test_inject {
865 use std::cell::Cell;
866
867 thread_local! {
868 static FAIL_RENAME: Cell<bool> = const { Cell::new(false) };
869 }
870
871 pub fn set_fail_rename(fail: bool) {
872 FAIL_RENAME.with(|flag| flag.set(fail));
873 }
874
875 pub(crate) fn should_fail_rename() -> bool {
876 FAIL_RENAME.with(|flag| flag.get())
877 }
878
879 pub struct FailRenameGuard(bool);
881
882 impl FailRenameGuard {
883 pub fn enabled() -> Self {
884 set_fail_rename(true);
885 Self(true)
886 }
887 }
888
889 impl Drop for FailRenameGuard {
890 fn drop(&mut self) {
891 if self.0 {
892 set_fail_rename(false);
893 }
894 }
895 }
896}
897
898#[cfg(test)]
899mod tests {
900 use std::collections::HashMap;
901 use std::sync::Mutex;
902
903 static ATOMIC_REPLACE_INJECT: Mutex<()> = Mutex::new(());
905
906 use crate::bloom::BloomFilterSettings;
907 use crate::index::{Index, IndexEntry, MODE_REGULAR};
908 use crate::objects::{oid_hash_map, serialize_commit, CommitData, ObjectId, ObjectKind};
909
910 fn commit_infos_std(
911 infos: &crate::objects::OidHashMap<super::CommitGraphCommitInfo>,
912 ) -> HashMap<ObjectId, super::CommitGraphCommitInfo> {
913 infos.iter().map(|(k, v)| (*k, v.clone())).collect()
914 }
915
916 fn empty_filter_map() -> HashMap<ObjectId, Vec<u8>> {
917 HashMap::new()
918 }
919 use crate::repo::{init_repository, Repository};
920 use crate::write_tree::write_tree_from_index;
921
922 fn empty_commit(repo: &Repository, parents: Vec<ObjectId>, committer: &str) -> ObjectId {
923 let raw = serialize_commit(&CommitData {
924 tree: repo.odb().write(ObjectKind::Tree, b"").expect("tree"),
925 parents,
926 author: committer.into(),
927 committer: committer.into(),
928 author_raw: Vec::new(),
929 committer_raw: Vec::new(),
930 encoding: None,
931 message: "m\n".into(),
932 raw_message: None,
933 extra_headers: Vec::new(),
934 });
935 repo.odb().write(ObjectKind::Commit, &raw).expect("commit")
936 }
937
938 #[test]
939 fn load_commit_graph_commit_info_rejects_non_commit() {
940 let dir = tempfile::tempdir().expect("tempdir");
941 let repo = init_repository(
942 dir.path(),
943 false,
944 "main",
945 None,
946 crate::RefStorageFormat::Files,
947 )
948 .expect("init");
949 let blob = repo.odb().write(ObjectKind::Blob, b"x").expect("blob");
950 let err = super::load_commit_graph_commit_info(&repo.odb(), blob).unwrap_err();
951 assert!(matches!(err, crate::error::Error::CorruptObject(_)));
952 }
953
954 #[test]
955 fn build_octopus_merge_includes_extra_edges_chunk() {
956 let dir = tempfile::tempdir().expect("tempdir");
957 let repo = init_repository(
958 dir.path(),
959 false,
960 "main",
961 None,
962 crate::RefStorageFormat::Files,
963 )
964 .expect("init");
965 let p1 = empty_commit(&repo, vec![], "T <t@example.com> 1 +0000");
966 let p2 = empty_commit(&repo, vec![], "T <t@example.com> 2 +0000");
967 let p3 = empty_commit(&repo, vec![], "T <t@example.com> 3 +0000");
968 let merge = empty_commit(&repo, vec![p1, p2, p3], "T <t@example.com> 4 +0000");
969 let mut sorted = vec![p1, p2, p3, merge];
970 sorted.sort();
971 let mut infos = oid_hash_map();
972 for oid in &sorted {
973 infos.insert(
974 *oid,
975 super::load_commit_graph_commit_info(&repo.odb(), *oid).expect("info"),
976 );
977 }
978 let bloom = BloomFilterSettings::default();
979 let empty = empty_filter_map();
980 let (bytes, _) = super::build_commit_graph_bytes(
981 &sorted,
982 &commit_infos_std(&infos),
983 &repo.odb(),
984 false,
985 &bloom,
986 None,
987 &[],
988 None,
989 &empty,
990 &empty,
991 true,
992 )
993 .expect("write");
994 assert!(bytes.windows(4).any(|w| w == b"EDGE"));
995 crate::commit_graph_file::CommitGraphLayer::try_parse(std::path::PathBuf::from("g"), bytes)
996 .expect("parse octopus graph");
997 }
998
999 #[test]
1000 fn build_rejects_parent_outside_graph() {
1001 let dir = tempfile::tempdir().expect("tempdir");
1002 let repo = init_repository(
1003 dir.path(),
1004 false,
1005 "main",
1006 None,
1007 crate::RefStorageFormat::Files,
1008 )
1009 .expect("init");
1010 let root = empty_commit(&repo, vec![], "T <t@example.com> 1 +0000");
1011 let side = empty_commit(&repo, vec![], "T <t@example.com> 2 +0000");
1012 let merge = empty_commit(&repo, vec![root, side], "T <t@example.com> 3 +0000");
1013 let sorted = vec![merge, side];
1014 let mut infos = oid_hash_map();
1015 for oid in &sorted {
1016 infos.insert(
1017 *oid,
1018 super::load_commit_graph_commit_info(&repo.odb(), *oid).expect("info"),
1019 );
1020 }
1021 let empty = empty_filter_map();
1022 let err = super::build_commit_graph_bytes(
1023 &sorted,
1024 &commit_infos_std(&infos),
1025 &repo.odb(),
1026 false,
1027 &BloomFilterSettings::default(),
1028 None,
1029 &[],
1030 None,
1031 &empty,
1032 &empty,
1033 true,
1034 )
1035 .expect_err("first parent is not in the graph");
1036 assert!(matches!(
1037 err,
1038 crate::error::Error::CommitGraphParentMissing { commit, parent }
1039 if commit == merge && parent == root
1040 ));
1041 }
1042
1043 #[test]
1044 fn build_respects_max_new_filters_budget() {
1045 let dir = tempfile::tempdir().expect("tempdir");
1046 let repo = init_repository(
1047 dir.path(),
1048 false,
1049 "main",
1050 None,
1051 crate::RefStorageFormat::Files,
1052 )
1053 .expect("init");
1054 let mut index = Index::new();
1055 index.hash_algo = repo.odb().hash_algo();
1056 let blob = repo.odb().write(ObjectKind::Blob, b"a\n").expect("blob");
1057 index.add_or_replace(IndexEntry {
1058 ctime_sec: 0,
1059 ctime_nsec: 0,
1060 mtime_sec: 0,
1061 mtime_nsec: 0,
1062 dev: 0,
1063 ino: 0,
1064 mode: MODE_REGULAR,
1065 uid: 0,
1066 gid: 0,
1067 size: 2,
1068 oid: blob,
1069 flags: 4,
1070 flags_extended: None,
1071 path: b"a.txt".to_vec(),
1072 base_index_pos: 0,
1073 });
1074 let tree = write_tree_from_index(&repo.odb(), &index, "").expect("tree");
1075 let mut sorted = Vec::new();
1076 let mut infos = oid_hash_map();
1077 for t in [1i64, 2, 3] {
1078 let raw = serialize_commit(&CommitData {
1079 tree,
1080 parents: sorted.last().copied().map(|p| vec![p]).unwrap_or_default(),
1081 author: format!("T <t@example.com> {t} +0000"),
1082 committer: format!("T <t@example.com> {t} +0000"),
1083 author_raw: Vec::new(),
1084 committer_raw: Vec::new(),
1085 encoding: None,
1086 message: "m\n".into(),
1087 raw_message: None,
1088 extra_headers: Vec::new(),
1089 });
1090 let c = repo.odb().write(ObjectKind::Commit, &raw).expect("commit");
1091 infos.insert(
1092 c,
1093 super::load_commit_graph_commit_info(&repo.odb(), c).expect("info"),
1094 );
1095 sorted.push(c);
1096 }
1097 let bloom = BloomFilterSettings::default();
1098 let empty = empty_filter_map();
1099 let (_, stats) = super::build_commit_graph_bytes(
1100 &sorted,
1101 &commit_infos_std(&infos),
1102 &repo.odb(),
1103 true,
1104 &bloom,
1105 None,
1106 &[],
1107 Some(1),
1108 &empty,
1109 &empty,
1110 true,
1111 )
1112 .expect("write");
1113 assert!(stats.filter_computed <= 1);
1114 assert!(stats.filter_not_computed >= 1);
1115 }
1116
1117 #[test]
1118 fn atomic_replace_preserves_existing_graph_when_rename_fails() {
1119 let _inject = ATOMIC_REPLACE_INJECT
1120 .lock()
1121 .unwrap_or_else(|e| e.into_inner());
1122 let dir = tempfile::tempdir().expect("tempdir");
1123 let repo = init_repository(
1124 dir.path(),
1125 false,
1126 "main",
1127 None,
1128 crate::RefStorageFormat::Files,
1129 )
1130 .expect("init");
1131 let objects_dir = repo.odb().objects_dir();
1132 let prior = b"CGPH\x01\x00\x00\x00prior-graph-bytes";
1133 let graph_path = objects_dir.join("info").join("commit-graph");
1134 std::fs::create_dir_all(graph_path.parent().unwrap()).expect("info");
1135 std::fs::write(&graph_path, prior).expect("seed graph");
1136
1137 let _fail_rename = super::test_inject::FailRenameGuard::enabled();
1138 let err = super::atomic_replace_commit_graph(objects_dir, b"replacement")
1139 .expect_err("injected rename failure");
1140 assert!(err.to_string().contains("injected commit-graph rename"));
1141
1142 let on_disk = std::fs::read(&graph_path).expect("graph still present");
1143 assert_eq!(
1144 on_disk, prior,
1145 "existing commit-graph must survive failed replace"
1146 );
1147 assert!(
1148 !objects_dir.join("info").join("commit-graph.new").exists(),
1149 "temp file must be removed on failure"
1150 );
1151 }
1152
1153 #[test]
1154 fn atomic_replace_renames_over_existing_file() {
1155 let _inject = ATOMIC_REPLACE_INJECT
1156 .lock()
1157 .unwrap_or_else(|e| e.into_inner());
1158 let dir = tempfile::tempdir().expect("tempdir");
1159 let repo = init_repository(
1160 dir.path(),
1161 false,
1162 "main",
1163 None,
1164 crate::RefStorageFormat::Files,
1165 )
1166 .expect("init");
1167 let objects_dir = repo.odb().objects_dir();
1168 let graph_path = objects_dir.join("info").join("commit-graph");
1169 std::fs::create_dir_all(graph_path.parent().unwrap()).expect("info");
1170 std::fs::write(&graph_path, b"old").expect("seed");
1171
1172 let replacement = b"CGPH\x01\x00\x00\x00new-graph";
1173 super::atomic_replace_commit_graph(objects_dir, replacement).expect("replace");
1174 let on_disk = std::fs::read(&graph_path).expect("graph");
1175 assert_eq!(on_disk, replacement);
1176 }
1177
1178 #[test]
1179 fn atomic_replace_over_readonly_existing() {
1180 let dir = tempfile::tempdir().expect("tempdir");
1181 let repo = init_repository(
1182 dir.path(),
1183 false,
1184 "main",
1185 None,
1186 crate::RefStorageFormat::Files,
1187 )
1188 .expect("init");
1189 let objects_dir = repo.odb().objects_dir();
1190 let graph_path = objects_dir.join("info").join("commit-graph");
1191 std::fs::create_dir_all(graph_path.parent().unwrap()).expect("info");
1192 std::fs::write(&graph_path, b"old").expect("seed");
1193 let mut perms = std::fs::metadata(&graph_path).expect("meta").permissions();
1194 perms.set_readonly(true);
1195 std::fs::set_permissions(&graph_path, perms).expect("readonly");
1196
1197 let replacement = b"CGPH\x01\x00\x00\x00new-graph";
1198 super::atomic_replace_commit_graph(objects_dir, replacement).expect("replace");
1199 let on_disk = std::fs::read(&graph_path).expect("graph");
1200 assert_eq!(on_disk, replacement);
1201 }
1202
1203 #[test]
1204 fn remove_commit_graph_chain_dir_clears_readonly_layers() {
1205 let dir = tempfile::tempdir().expect("tempdir");
1206 let objects_dir = dir.path().join("objects");
1207 let chain_root = objects_dir.join("info").join("commit-graphs");
1208 let layer = chain_root.join("graph-chain-abc123");
1209 std::fs::create_dir_all(&layer).expect("layer dir");
1210 std::fs::write(layer.join("graph"), b"layer-bytes").expect("layer file");
1211 for path in [&chain_root, &layer, &layer.join("graph")] {
1212 let mut perms = std::fs::metadata(path).expect("meta").permissions();
1213 perms.set_readonly(true);
1214 std::fs::set_permissions(path, perms).expect("readonly");
1215 }
1216
1217 assert!(super::remove_commit_graph_chain_dir(&objects_dir));
1218 assert!(!chain_root.exists());
1219 }
1220
1221 #[test]
1222 fn remove_commit_graph_files_tolerates_missing_graph() {
1223 let dir = tempfile::tempdir().expect("tempdir");
1224 let objects_dir = dir.path().join("objects");
1225 std::fs::create_dir_all(objects_dir.join("info")).expect("info");
1226 assert!(!super::remove_commit_graph_files(&objects_dir).expect("nothing to remove"));
1227
1228 let graph_path = objects_dir.join("info").join("commit-graph");
1229 std::fs::write(&graph_path, b"graph").expect("seed graph");
1230 let mut perms = std::fs::metadata(&graph_path).expect("meta").permissions();
1231 perms.set_readonly(true);
1232 std::fs::set_permissions(&graph_path, perms).expect("readonly");
1233 std::fs::create_dir_all(objects_dir.join("info").join("commit-graphs")).expect("chain");
1234 assert!(super::remove_commit_graph_files(&objects_dir).expect("remove"));
1235 assert!(!graph_path.exists());
1236 }
1237
1238 #[cfg(unix)]
1239 #[test]
1240 fn remove_commit_graph_chain_symlink_does_not_chmod_outside() {
1241 use std::os::unix::fs::PermissionsExt;
1242
1243 let dir = tempfile::tempdir().expect("tempdir");
1244 let outside = tempfile::tempdir().expect("outside");
1245 let victim = outside.path().join("victim");
1246 std::fs::write(&victim, b"outside").expect("victim");
1247 let mut perms = std::fs::metadata(&victim).expect("meta").permissions();
1248 perms.set_mode(0o444);
1249 std::fs::set_permissions(&victim, perms).expect("readonly victim");
1250
1251 let objects_dir = dir.path().join("objects");
1252 let info = objects_dir.join("info");
1253 std::fs::create_dir_all(&info).expect("info");
1254 std::os::unix::fs::symlink(outside.path(), info.join("commit-graphs")).expect("symlink");
1255
1256 assert!(
1257 !super::remove_commit_graph_chain_dir(&objects_dir),
1258 "symlinked commit-graphs must not be treated as an in-repo chain directory"
1259 );
1260 let mode_after = std::fs::metadata(&victim)
1261 .expect("victim meta")
1262 .permissions()
1263 .mode();
1264 assert_eq!(
1265 mode_after & 0o777,
1266 0o444,
1267 "must not chmod files outside the repository via symlinked commit-graphs"
1268 );
1269 assert!(
1270 info.join("commit-graphs").symlink_metadata().is_ok(),
1271 "symlink entry must remain when not removed as a directory"
1272 );
1273 }
1274
1275 #[test]
1276 fn collect_reachable_follows_annotated_tag_to_commit() {
1277 let dir = tempfile::tempdir().expect("tempdir");
1278 let repo = init_repository(
1279 dir.path(),
1280 false,
1281 "main",
1282 None,
1283 crate::RefStorageFormat::Files,
1284 )
1285 .expect("init");
1286 let commit = empty_commit(&repo, vec![], "T <t@example.com> 1 +0000");
1287 let tag_body = format!(
1288 "object {}\ntype commit\ntag v1\ntagger T <t@example.com> 1 +0000\n\n",
1289 commit
1290 );
1291 let tag = repo
1292 .odb()
1293 .write(ObjectKind::Tag, tag_body.as_bytes())
1294 .expect("tag");
1295 crate::refs::write_ref(&repo.git_dir(), "refs/tags/v1", &tag).expect("ref");
1296 let set = super::collect_reachable_commit_oids(&repo.git_dir(), &repo.odb()).expect("walk");
1297 assert!(set.contains(&commit));
1298 let tips = super::count_referenced_commit_tips(&repo.git_dir(), &repo.odb()).expect("tips");
1299 assert_eq!(tips, 1);
1300 }
1301}