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