Skip to main content

grit_lib/
commit_graph_write.rs

1//! Serialize Git commit-graph v1 files with GDA2 + optional Bloom chunks (`commit-graph.c` compatible).
2
3use 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; // GDO2
24const 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
33/// `GENERATION_NUMBER_V2_OFFSET_MAX` from Git `commit.h`.
34const GENERATION_NUMBER_V2_OFFSET_MAX: u64 = (1u64 << 31) - 1;
35/// `CORRECTED_COMMIT_DATE_OFFSET_OVERFLOW` from Git `commit-graph.c`.
36const CORRECTED_COMMIT_DATE_OFFSET_OVERFLOW: u32 = 1u32 << 31;
37
38/// Per-commit data needed to write CDAT / Bloom.
39#[derive(Debug, Clone)]
40pub struct CommitGraphCommitInfo {
41    /// Tree for this item.
42    pub tree: ObjectId,
43    /// Parents for this item.
44    pub parents: Vec<ObjectId>,
45    /// Committer Unix timestamp (Git `timestamp_t`; may exceed `u32`).
46    pub commit_time: u64,
47}
48
49/// Hash a commit-graph/multi-pack-index file body with the repository algorithm.
50fn 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
63/// Load commit metadata from the ODB for graph writing.
64///
65/// # Errors
66///
67/// [`crate::error::Error::CorruptObject`] when `oid` is not a commit, plus any read or
68/// parse error from [`Odb::read_commit`].
69pub 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/// Counters emitted as `GIT_TRACE2_EVENT` for Bloom generation (`commit-graph.c`).
236#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
237pub struct BloomWriteStats {
238    /// Filter computed for this item.
239    pub filter_computed: u32,
240    /// Filter not computed for this item.
241    pub filter_not_computed: u32,
242    /// Filter trunc empty for this item.
243    pub filter_trunc_empty: u32,
244    /// Filter trunc large for this item.
245    pub filter_trunc_large: u32,
246    /// Filter upgraded for this item.
247    pub filter_upgraded: u32,
248}
249#[expect(clippy::too_many_arguments)]
250/// Build raw commit-graph bytes (without touching the filesystem).
251pub 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/// [`build_commit_graph_bytes`] over the crate's OID-keyed maps, so in-crate writers that
280/// already hold them (one entry and one bloom filter per commit) do not copy them.
281#[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            // Reuse a filter already present (in a compatible layer) for this commit
392            // instead of recomputing it. Git counts these as `filter_not_computed`.
393            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            // A filter present under a different (compatible) version that we can
401            // relabel without recomputation: the on-disk bytes are reused as-is
402            // and Git counts it as `filter-upgraded`.
403            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    // The commit-graph hash version and trailing checksum follow the repository
488    // hash algorithm (SHA-1 → version 1, SHA-256 → version 2).
489    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
536/// Collect reachable commit OIDs from ref tips via the repository [`RefStore`](crate::refs::store::RefStore).
537///
538/// Resolves `HEAD` through [`Repository::refs`](crate::repo::Repository::refs), lists refs under
539/// `refs/` (reftable, packed-refs, and linked-worktree rules),
540/// peels annotated tags while walking, and traverses commit parents. Missing targets
541/// are skipped without fetching from a promisor remote.
542///
543/// # Errors
544///
545/// Propagates ref listing or object read/parse failures.
546pub 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
563/// Collect reachable commit OIDs from on-disk ref tips under `git_dir`.
564///
565/// Prefer [`collect_reachable_commit_oids_for_repository`] when a [`crate::repo::Repository`] is
566/// available (reftable and linked worktrees require the ref store).
567pub 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
637/// Directory in the repository tree, not a symlink/junction entry.
638fn 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
647/// Replace `objects/info/commit-graph` atomically (lock + tempfile + rename).
648///
649/// Does not remove a split `commit-graphs/` chain; callers use
650/// [`remove_commit_graph_chain_dir`] after a successful monolithic write.
651///
652/// # Errors
653///
654/// Propagates I/O failures or when the lock file is already held.
655pub 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        // Staged commit-graph bytes (not a lock file); exclusive create avoids clobber.
684        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        // Same-directory rename over an existing file is atomic on POSIX; do not unlink first.
706        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
718/// Remove `objects/info/commit-graphs` when replacing a split chain with a single graph file.
719///
720/// Returns `true` when a chain directory was removed.
721pub 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
730/// Delete `objects/info/commit-graph` and any split `commit-graphs/` chain.
731///
732/// Returns `true` when a chain directory was removed (as [`remove_commit_graph_chain_dir`]).
733///
734/// # Errors
735///
736/// Propagates I/O failures removing the monolithic graph other than it being absent.
737pub(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
753/// Count unique commit OIDs that refs point to directly (peeling annotated tags), matching Git's
754/// `add_ref_to_set` accounting for the "Collecting referenced commits" progress meter. Unlike
755/// [`collect_reachable_commit_oids`], this does not walk commit parents.
756pub 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    // Peel each ref tip to the commit it ultimately references (an annotated tag points at a
803    // commit) and collect distinct commit OIDs. Non-commit tips (e.g. tags pointing at trees)
804    // are ignored, exactly like Git's OBJ_COMMIT check.
805    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    /// Clears injection on drop so a panicking test cannot leak into parallel tests.
831    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    /// `test_inject::FAIL_RENAME` is process-global; keep inject tests serial.
855    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}