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;
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; // GDO2
21const 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
30/// `GENERATION_NUMBER_V2_OFFSET_MAX` from Git `commit.h`.
31const GENERATION_NUMBER_V2_OFFSET_MAX: u64 = (1u64 << 31) - 1;
32/// `CORRECTED_COMMIT_DATE_OFFSET_OVERFLOW` from Git `commit-graph.c`.
33const CORRECTED_COMMIT_DATE_OFFSET_OVERFLOW: u32 = 1u32 << 31;
34
35/// Per-commit data needed to write CDAT / Bloom.
36#[derive(Debug, Clone)]
37pub struct CommitGraphCommitInfo {
38    /// Tree for this item.
39    pub tree: ObjectId,
40    /// Parents for this item.
41    pub parents: Vec<ObjectId>,
42    /// Committer Unix timestamp (Git `timestamp_t`; may exceed `u32`).
43    pub commit_time: u64,
44}
45
46/// Hash a commit-graph/multi-pack-index file body with the repository algorithm.
47fn 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
60/// Load commit metadata from the ODB for graph writing.
61pub 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/// Counters emitted as `GIT_TRACE2_EVENT` for Bloom generation (`commit-graph.c`).
229#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
230pub struct BloomWriteStats {
231    /// Filter computed for this item.
232    pub filter_computed: u32,
233    /// Filter not computed for this item.
234    pub filter_not_computed: u32,
235    /// Filter trunc empty for this item.
236    pub filter_trunc_empty: u32,
237    /// Filter trunc large for this item.
238    pub filter_trunc_large: u32,
239    /// Filter upgraded for this item.
240    pub filter_upgraded: u32,
241}
242#[expect(clippy::too_many_arguments)]
243/// Build raw commit-graph bytes (without touching the filesystem).
244pub 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/// [`build_commit_graph_bytes`] over the crate's OID-keyed maps, so in-crate writers that
273/// already hold them (one entry and one bloom filter per commit) do not copy them.
274#[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            // Reuse a filter already present (in a compatible layer) for this commit
385            // instead of recomputing it. Git counts these as `filter_not_computed`.
386            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            // A filter present under a different (compatible) version that we can
394            // relabel without recomputation: the on-disk bytes are reused as-is
395            // and Git counts it as `filter-upgraded`.
396            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    // The commit-graph hash version and trailing checksum follow the repository
481    // hash algorithm (SHA-1 → version 1, SHA-256 → version 2).
482    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
529/// Collect reachable commit OIDs from ref tips via the repository [`RefStore`](crate::refs::store::RefStore).
530///
531/// Resolves `HEAD` through [`Repository::refs`](crate::repo::Repository::refs), lists refs under
532/// `refs/` (reftable, packed-refs, and linked-worktree rules),
533/// peels annotated tags while walking, and traverses commit parents. Missing targets
534/// are skipped without fetching from a promisor remote.
535///
536/// # Errors
537///
538/// Propagates ref listing or object read/parse failures.
539pub 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
556/// Collect reachable commit OIDs from on-disk ref tips under `git_dir`.
557///
558/// Prefer [`collect_reachable_commit_oids_for_repository`] when a [`crate::repo::Repository`] is
559/// available (reftable and linked worktrees require the ref store).
560pub 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
630/// Clear the read-only attribute when present so replace/remove can succeed.
631///
632/// Git marks commit-graph (and pack) files read-only on Windows; same-directory
633/// rename over an existing read-only file fails with access denied until cleared.
634fn 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
667/// Directory in the repository tree, not a symlink/junction entry.
668fn 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
697/// Replace `objects/info/commit-graph` atomically (lock + tempfile + rename).
698///
699/// Does not remove a split `commit-graphs/` chain; callers use
700/// [`remove_commit_graph_chain_dir`] after a successful monolithic write.
701///
702/// # Errors
703///
704/// Propagates I/O failures or when the lock file is already held.
705pub 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        // Staged commit-graph bytes (not a lock file); exclusive create avoids clobber.
734        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        // Same-directory rename over an existing file is atomic on POSIX; do not unlink first.
756        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
768/// Remove `objects/info/commit-graphs` when replacing a split chain with a single graph file.
769///
770/// Returns `true` when a chain directory was removed.
771pub 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
780/// Delete `objects/info/commit-graph` and any split `commit-graphs/` chain.
781///
782/// Returns `true` when a chain directory was removed (as [`remove_commit_graph_chain_dir`]).
783///
784/// # Errors
785///
786/// Propagates I/O failures removing the monolithic graph other than it being absent.
787pub(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
803/// Count unique commit OIDs that refs point to directly (peeling annotated tags), matching Git's
804/// `add_ref_to_set` accounting for the "Collecting referenced commits" progress meter. Unlike
805/// [`collect_reachable_commit_oids`], this does not walk commit parents.
806pub 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    // Peel each ref tip to the commit it ultimately references (an annotated tag points at a
853    // commit) and collect distinct commit OIDs. Non-commit tips (e.g. tags pointing at trees)
854    // are ignored, exactly like Git's OBJ_COMMIT check.
855    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    /// Clears injection on drop so a panicking test cannot leak into parallel tests.
881    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    /// `test_inject::FAIL_RENAME` is process-global; keep inject tests serial.
905    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}