Skip to main content

sley_odb/
reachability.rs

1use flate2::Compression;
2use flate2::write::ZlibEncoder;
3use flate2::{Decompress, FlushDecompress};
4use sley_core::{
5    CancelFlag, GitError, MissingObjectContext, ObjectFormat, ObjectId, Result, StreamingDigest,
6    primitives::u32_be,
7};
8use sley_object::{Commit, EncodedObject, ObjectType, Tag, TreeEntries, tree_entry_object_type};
9use sley_pack::{
10    MultiPackIndex, PackBitmapIndex, PackBitmapWriter, PackFile, PackIndex, PackIndexEntry,
11    PackIndexViewData, PackInput, PackPlanningHints, PackWrite, PackWriteLimits, PackWriteOptions,
12    PackWriteSummary,
13};
14use std::collections::{HashMap, HashSet};
15use std::io::{self, Write};
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::{env, fs};
19
20use crate::{
21    ObjectReader, ObjectWriter, ReusablePackCandidate, grafted_parents, unique_temp_path,
22    with_missing_object_context,
23};
24
25use crate::install::{
26    PackInstallResult, REACHABLE_PACK_STREAMING_MIN_OBJECTS, RawPackInstallOptions,
27    RawPackInstallResult, RawPackInstaller, ReachablePackFile, ReachablePackWriteSummary,
28};
29use crate::loose::{LooseObjectStore, collect_loose_object_ids};
30use crate::pack::{FileObjectDatabase, PackData, load_pack_data};
31use crate::registry::{read_incremental_midx_chain, repository_objects_dir};
32
33pub fn collect_reachable_object_ids<R, I>(
34    reader: &R,
35    format: ObjectFormat,
36    starts: I,
37) -> Result<HashSet<ObjectId>>
38where
39    R: ObjectReader,
40    I: IntoIterator<Item = ObjectId>,
41{
42    walk_reachable_objects(reader, format, starts, &HashSet::new(), |_, _| {})
43}
44
45pub fn collect_reachable_object_ids_tolerating_promised_missing<R, I>(
46    reader: &R,
47    format: ObjectFormat,
48    starts: I,
49) -> Result<HashSet<ObjectId>>
50where
51    R: ObjectReader,
52    I: IntoIterator<Item = ObjectId>,
53{
54    collect_reachable_object_ids_excluding_promised_missing(reader, format, starts, &HashSet::new())
55}
56
57pub fn collect_reachable_object_ids_tolerating_missing<R, I>(
58    reader: &R,
59    format: ObjectFormat,
60    starts: I,
61) -> Result<HashSet<ObjectId>>
62where
63    R: ObjectReader,
64    I: IntoIterator<Item = ObjectId>,
65{
66    walk_reachable_objects_tolerating_missing(reader, format, starts)
67}
68
69/// [`collect_reachable_object_ids`] with a cut set: commits in `cut` are
70/// collected, but the walk does not continue to their parents — the view a
71/// shallow repository has of its own refs (`$GIT_DIR/shallow` of the *other*
72/// side, threaded explicitly because `reader` belongs to this side).
73pub fn collect_reachable_object_ids_with_cut<R, I>(
74    reader: &R,
75    format: ObjectFormat,
76    starts: I,
77    cut: &HashSet<ObjectId>,
78) -> Result<HashSet<ObjectId>>
79where
80    R: ObjectReader,
81    I: IntoIterator<Item = ObjectId>,
82{
83    walk_reachable_objects_with_cut(reader, format, starts, &HashSet::new(), cut, |_, _| {})
84}
85
86/// [`collect_reachable_object_ids`] with a stop set: objects in `excluded` are
87/// not visited and not expanded, so the walk never sees anything reachable only
88/// through them (used to truncate history at a shallow boundary).
89pub fn collect_reachable_object_ids_excluding<R, I>(
90    reader: &R,
91    format: ObjectFormat,
92    starts: I,
93    excluded: &HashSet<ObjectId>,
94) -> Result<HashSet<ObjectId>>
95where
96    R: ObjectReader,
97    I: IntoIterator<Item = ObjectId>,
98{
99    walk_reachable_objects(reader, format, starts, excluded, |_, _| {})
100}
101
102pub(crate) fn collect_reachable_object_ids_excluding_promised_missing<R, I>(
103    reader: &R,
104    format: ObjectFormat,
105    starts: I,
106    excluded: &HashSet<ObjectId>,
107) -> Result<HashSet<ObjectId>>
108where
109    R: ObjectReader,
110    I: IntoIterator<Item = ObjectId>,
111{
112    walk_reachable_objects_excluding_promised_missing(reader, format, starts, excluded, |_, _| {})
113}
114
115fn walk_reachable_objects_excluding_promised_missing<R, I, F>(
116    reader: &R,
117    format: ObjectFormat,
118    starts: I,
119    excluded: &HashSet<ObjectId>,
120    mut visit: F,
121) -> Result<HashSet<ObjectId>>
122where
123    R: ObjectReader,
124    I: IntoIterator<Item = ObjectId>,
125    F: FnMut(&ObjectId, &Arc<EncodedObject>),
126{
127    let mut seen = HashSet::new();
128    let mut pending: Vec<ObjectId> = starts.into_iter().collect();
129    while let Some(oid) = pending.pop() {
130        if excluded.contains(&oid) || !seen.insert(oid) {
131            continue;
132        }
133        let object = match reader
134            .read_object(&oid)
135            .map_err(|err| with_missing_object_context(err, oid, MissingObjectContext::Traversal))
136        {
137            Ok(object) => object,
138            Err(GitError::NotFound(_)) if reader.is_promised_object(&oid) => continue,
139            Err(err) => return Err(err),
140        };
141        match object.object_type {
142            ObjectType::Commit => {
143                let commit = Commit::parse_ref(format, &object.body)?;
144                visit(&oid, &object);
145                pending.extend(grafted_parents(reader, &oid, commit.parents));
146                pending.push(commit.tree);
147            }
148            ObjectType::Tree => {
149                visit(&oid, &object);
150                for entry in TreeEntries::new(format, &object.body) {
151                    let entry = entry?;
152                    if !entry.is_gitlink() {
153                        pending.push(entry.oid);
154                    }
155                }
156            }
157            ObjectType::Tag => {
158                let tag = Tag::parse_ref(format, &object.body)?;
159                visit(&oid, &object);
160                pending.push(tag.object);
161            }
162            ObjectType::Blob => visit(&oid, &object),
163        }
164    }
165    Ok(seen)
166}
167
168pub fn collect_reachable_objects<R, I>(
169    reader: &R,
170    format: ObjectFormat,
171    starts: I,
172    excluded: &HashSet<ObjectId>,
173) -> Result<Vec<Arc<EncodedObject>>>
174where
175    R: ObjectReader,
176    I: IntoIterator<Item = ObjectId>,
177{
178    let mut objects = Vec::new();
179    walk_reachable_objects(reader, format, starts, excluded, |_, object| {
180        objects.push(Arc::clone(object));
181    })?;
182    Ok(objects)
183}
184
185#[derive(Debug, Clone)]
186pub(crate) struct ReachablePackObject {
187    pub(crate) oid: ObjectId,
188    pub(crate) object: Arc<EncodedObject>,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub(crate) struct ReachablePackObjectMeta {
193    pub(crate) oid: ObjectId,
194    pub(crate) object_type: ObjectType,
195    pub(crate) size: u64,
196    pub(crate) name_hash: u32,
197}
198
199pub(crate) enum ReachablePackObjectsForWrite {
200    Buffered {
201        objects: Vec<ReachablePackObject>,
202        name_hashes: HashMap<ObjectId, u32>,
203    },
204    Streaming(Vec<ReachablePackObjectMeta>),
205}
206
207#[derive(Clone, Copy)]
208enum ReachablePackTraversal {
209    Natural,
210    RepackLegacy,
211}
212
213fn collect_reachable_pack_objects<R, I>(
214    reader: &R,
215    format: ObjectFormat,
216    starts: I,
217    excluded: &HashSet<ObjectId>,
218) -> Result<Vec<ReachablePackObject>>
219where
220    R: ObjectReader,
221    I: IntoIterator<Item = ObjectId>,
222{
223    let mut objects = Vec::new();
224    walk_reachable_objects(reader, format, starts, excluded, |oid, object| {
225        objects.push(ReachablePackObject {
226            oid: *oid,
227            object: Arc::clone(object),
228        });
229    })?;
230    Ok(objects)
231}
232
233fn collect_reachable_pack_objects_tolerating_promised_missing<R, I>(
234    reader: &R,
235    format: ObjectFormat,
236    starts: I,
237    excluded: &HashSet<ObjectId>,
238) -> Result<Vec<ReachablePackObject>>
239where
240    R: ObjectReader,
241    I: IntoIterator<Item = ObjectId>,
242{
243    let mut objects = Vec::new();
244    walk_reachable_objects_excluding_promised_missing(
245        reader,
246        format,
247        starts,
248        excluded,
249        |oid, object| {
250            objects.push(ReachablePackObject {
251                oid: *oid,
252                object: Arc::clone(object),
253            });
254        },
255    )?;
256    Ok(objects)
257}
258
259pub(crate) fn collect_reachable_pack_objects_for_write<R, I>(
260    reader: &R,
261    format: ObjectFormat,
262    starts: I,
263    excluded: &HashSet<ObjectId>,
264) -> Result<ReachablePackObjectsForWrite>
265where
266    R: ObjectReader,
267    I: IntoIterator<Item = ObjectId>,
268{
269    collect_reachable_pack_objects_for_write_with_order(
270        reader,
271        format,
272        starts,
273        excluded,
274        true,
275        ReachablePackTraversal::Natural,
276    )
277}
278
279fn collect_reachable_pack_objects_for_write_with_order<R, I>(
280    reader: &R,
281    format: ObjectFormat,
282    starts: I,
283    excluded: &HashSet<ObjectId>,
284    reorder: bool,
285    traversal: ReachablePackTraversal,
286) -> Result<ReachablePackObjectsForWrite>
287where
288    R: ObjectReader,
289    I: IntoIterator<Item = ObjectId>,
290{
291    let mut buffered = Some(Vec::new());
292    let mut metadata: Vec<ReachablePackObjectMeta> = Vec::new();
293    let mut metadata_positions: HashMap<ObjectId, usize> = HashMap::new();
294    let mut seen = HashSet::new();
295    let starts = starts.into_iter().collect::<Vec<_>>();
296    let mut pending = Vec::with_capacity(starts.len());
297    match traversal {
298        ReachablePackTraversal::Natural => {
299            pending.extend(starts.into_iter().rev().map(|oid| (oid, None::<Vec<u8>>)))
300        }
301        ReachablePackTraversal::RepackLegacy => {
302            pending.extend(starts.into_iter().map(|oid| (oid, None::<Vec<u8>>)));
303        }
304    }
305    while let Some((oid, path)) = pending.pop() {
306        if excluded.contains(&oid) {
307            continue;
308        }
309        let hash = path
310            .as_deref()
311            .filter(|path| !path.is_empty())
312            .map(pack_name_hash)
313            .unwrap_or(0);
314        if !seen.insert(oid) {
315            if hash != 0
316                && let Some(position) = metadata_positions.get(&oid).copied()
317                && metadata[position].name_hash == 0
318            {
319                metadata[position].name_hash = hash;
320            }
321            continue;
322        }
323        let object = reader.read_object(&oid).map_err(|err| {
324            with_missing_object_context(err, oid, MissingObjectContext::Traversal)
325        })?;
326        match object.object_type {
327            ObjectType::Commit => {
328                let commit = Commit::parse_ref(format, &object.body)?;
329                let parents = grafted_parents(reader, &oid, commit.parents);
330                match traversal {
331                    ReachablePackTraversal::Natural => {
332                        pending.extend(parents.into_iter().rev().map(|parent| (parent, None)))
333                    }
334                    ReachablePackTraversal::RepackLegacy => {
335                        pending.extend(parents.into_iter().map(|parent| (parent, None)));
336                    }
337                }
338                pending.push((commit.tree, Some(Vec::new())));
339            }
340            ObjectType::Tree => {
341                let prefix = path.as_deref().unwrap_or_default();
342                let mut children = Vec::new();
343                for entry in TreeEntries::new(format, &object.body) {
344                    let entry = entry?;
345                    if entry.is_gitlink() {
346                        continue;
347                    }
348                    let mut child_path = Vec::with_capacity(
349                        prefix.len() + usize::from(!prefix.is_empty()) + entry.name.len(),
350                    );
351                    child_path.extend_from_slice(prefix);
352                    if !prefix.is_empty() {
353                        child_path.push(b'/');
354                    }
355                    child_path.extend_from_slice(entry.name);
356                    children.push((entry.oid, Some(child_path)));
357                }
358                pending.extend(children.into_iter().rev());
359            }
360            ObjectType::Tag => {
361                let tag = Tag::parse_ref(format, &object.body)?;
362                pending.push((tag.object, None));
363            }
364            ObjectType::Blob => {}
365        }
366        metadata_positions.insert(oid, metadata.len());
367        metadata.push(ReachablePackObjectMeta {
368            oid,
369            object_type: object.object_type,
370            size: object.body.len() as u64,
371            name_hash: hash,
372        });
373        let should_stream = buffered
374            .as_ref()
375            .is_some_and(|objects| objects.len() + 1 >= REACHABLE_PACK_STREAMING_MIN_OBJECTS);
376        if should_stream {
377            buffered = None;
378        }
379        if let Some(objects) = buffered.as_mut() {
380            objects.push(ReachablePackObject {
381                oid,
382                object: Arc::clone(&object),
383            });
384        }
385    }
386
387    match buffered {
388        Some(objects) => Ok(ReachablePackObjectsForWrite::Buffered {
389            objects,
390            name_hashes: metadata
391                .into_iter()
392                .filter(|meta| meta.name_hash != 0)
393                .map(|meta| (meta.oid, meta.name_hash))
394                .collect(),
395        }),
396        None => {
397            if reorder {
398                sort_reachable_pack_metadata(&mut metadata);
399            }
400            Ok(ReachablePackObjectsForWrite::Streaming(metadata))
401        }
402    }
403}
404
405pub(crate) fn sort_reachable_pack_metadata(metadata: &mut [ReachablePackObjectMeta]) {
406    sley_pack::sort_by_pack_planning_order(metadata, |entry| {
407        (entry.oid, entry.object_type, entry.size, entry.name_hash)
408    });
409}
410
411pub(crate) fn pack_inputs(objects: &[ReachablePackObject]) -> Vec<PackInput<'_>> {
412    objects
413        .iter()
414        .map(|entry| PackInput {
415            oid: &entry.oid,
416            object: &entry.object,
417        })
418        .collect()
419}
420
421pub fn install_reachable_pack<I>(
422    source: &impl ObjectReader,
423    destination: &impl RawPackInstaller,
424    format: ObjectFormat,
425    starts: I,
426) -> Result<Option<RawPackInstallResult>>
427where
428    I: IntoIterator<Item = ObjectId>,
429{
430    install_reachable_pack_excluding(source, destination, format, starts, &HashSet::new())
431}
432
433pub fn install_reachable_pack_excluding<I>(
434    source: &impl ObjectReader,
435    destination: &impl RawPackInstaller,
436    format: ObjectFormat,
437    starts: I,
438    excluded: &HashSet<ObjectId>,
439) -> Result<Option<RawPackInstallResult>>
440where
441    I: IntoIterator<Item = ObjectId>,
442{
443    let pack = match build_reachable_pack(source, format, starts, excluded)? {
444        Some(pack) => pack,
445        None => return Ok(None),
446    };
447    let mut reader = pack.pack.as_slice();
448    destination
449        .install_raw_pack_from_reader(&mut reader)
450        .map(Some)
451}
452
453pub fn build_reachable_pack<R, I>(
454    reader: &R,
455    format: ObjectFormat,
456    starts: I,
457    excluded: &HashSet<ObjectId>,
458) -> Result<Option<PackWrite>>
459where
460    R: ObjectReader,
461    I: IntoIterator<Item = ObjectId>,
462{
463    build_reachable_pack_with_reuse_stats(reader, format, starts, excluded)
464        .map(|build| build.map(|build| build.pack))
465}
466
467/// Pack-reuse counters for one reachable-pack build.
468///
469/// `verbatim_entries` were copied from existing pack storage without delta
470/// search or recompression. `redeltified_entries` went through the ordinary
471/// pack writer. `whole_pack` means the output bytes are one self-contained
472/// source pack, including its original header and trailer.
473#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
474pub struct ReachablePackReuseStats {
475    pub verbatim_entries: u32,
476    pub redeltified_entries: u32,
477    pub whole_pack: bool,
478}
479
480/// A reachable pack together with evidence that its reuse path engaged.
481#[derive(Debug, Clone, PartialEq, Eq)]
482pub struct ReachablePackBuild {
483    pub pack: PackWrite,
484    pub reuse: ReachablePackReuseStats,
485}
486
487/// A reachable pack written directly to a caller-owned stream, together with
488/// its checksum/object-count evidence and retained-entry reuse counters.
489#[derive(Debug, Clone, PartialEq, Eq)]
490pub struct ReachablePackReuseWrite {
491    pub summary: ReachablePackWriteSummary,
492    pub reuse: ReachablePackReuseStats,
493}
494
495/// [`build_reachable_pack`] with explicit reuse counters.
496pub fn build_reachable_pack_with_reuse_stats<R, I>(
497    reader: &R,
498    format: ObjectFormat,
499    starts: I,
500    excluded: &HashSet<ObjectId>,
501) -> Result<Option<ReachablePackBuild>>
502where
503    R: ObjectReader,
504    I: IntoIterator<Item = ObjectId>,
505{
506    let objects = collect_reachable_pack_objects_for_write(reader, format, starts, excluded)?;
507    match &objects {
508        ReachablePackObjectsForWrite::Buffered { objects, .. } if objects.is_empty() => {
509            return Ok(None);
510        }
511        ReachablePackObjectsForWrite::Streaming(objects) if objects.is_empty() => return Ok(None),
512        ReachablePackObjectsForWrite::Buffered { .. }
513        | ReachablePackObjectsForWrite::Streaming(_) => {}
514    }
515    build_reachable_pack_objects_with_reuse(reader, format, objects).map(Some)
516}
517
518pub fn build_reachable_pack_file<R, I>(
519    reader: &R,
520    format: ObjectFormat,
521    starts: I,
522    excluded: &HashSet<ObjectId>,
523    pack_path: impl AsRef<Path>,
524) -> Result<Option<ReachablePackFile>>
525where
526    R: ObjectReader,
527    I: IntoIterator<Item = ObjectId>,
528{
529    let pack_path = pack_path.as_ref();
530    let parent = pack_path
531        .parent()
532        .ok_or_else(|| GitError::InvalidPath("prepared pack path has no parent".into()))?;
533    fs::create_dir_all(parent)?;
534    let temp_path = unique_temp_path(parent).with_extension("pack");
535    let result = (|| -> Result<Option<ReachablePackFile>> {
536        let mut file = fs::OpenOptions::new()
537            .write(true)
538            .create_new(true)
539            .open(&temp_path)?;
540        let summary = write_reachable_pack_to_writer(reader, format, starts, excluded, &mut file)?;
541        let Some(summary) = summary else {
542            return Ok(None);
543        };
544        file.sync_all()?;
545        drop(file);
546        fs::rename(&temp_path, pack_path)?;
547        sync_directory(parent)?;
548        Ok(Some(ReachablePackFile {
549            pack_path: pack_path.to_path_buf(),
550            pack_size: summary.pack_size,
551            checksum: summary.checksum,
552            object_count: summary.object_count,
553            delta_count: summary.delta_count,
554        }))
555    })();
556    if result.is_err() || matches!(result.as_ref(), Ok(None)) {
557        let _ = fs::remove_file(&temp_path);
558    }
559    result
560}
561
562fn sync_directory(path: &Path) -> Result<()> {
563    #[cfg(unix)]
564    fs::File::open(path)?.sync_all()?;
565    #[cfg(not(unix))]
566    let _ = path;
567    Ok(())
568}
569
570pub fn write_reachable_pack_to_writer<R, I, W>(
571    reader: &R,
572    format: ObjectFormat,
573    starts: I,
574    excluded: &HashSet<ObjectId>,
575    writer: &mut W,
576) -> Result<Option<ReachablePackWriteSummary>>
577where
578    R: ObjectReader,
579    I: IntoIterator<Item = ObjectId>,
580    W: Write,
581{
582    write_reachable_pack_to_writer_with_options_and_cancel(
583        reader,
584        format,
585        starts,
586        excluded,
587        &PackWriteOptions::new(),
588        writer,
589        CancelFlag::never(),
590    )
591}
592
593/// Like [`write_reachable_pack_to_writer`], polling `cancel` on the streaming
594/// path and once before a buffered known-ids write.
595pub fn write_reachable_pack_to_writer_with_cancel<R, I, W>(
596    reader: &R,
597    format: ObjectFormat,
598    starts: I,
599    excluded: &HashSet<ObjectId>,
600    writer: &mut W,
601    cancel: CancelFlag<'_>,
602) -> Result<Option<ReachablePackWriteSummary>>
603where
604    R: ObjectReader,
605    I: IntoIterator<Item = ObjectId>,
606    W: Write,
607{
608    write_reachable_pack_to_writer_with_options_and_cancel(
609        reader,
610        format,
611        starts,
612        excluded,
613        &PackWriteOptions::new(),
614        writer,
615        cancel,
616    )
617}
618
619/// Option-aware and cancel-aware reachable pack streaming.
620#[allow(clippy::too_many_arguments)]
621pub(crate) fn write_reachable_pack_to_writer_with_options_and_cancel<R, I, W>(
622    reader: &R,
623    format: ObjectFormat,
624    starts: I,
625    excluded: &HashSet<ObjectId>,
626    options: &PackWriteOptions,
627    writer: &mut W,
628    cancel: CancelFlag<'_>,
629) -> Result<Option<ReachablePackWriteSummary>>
630where
631    R: ObjectReader,
632    I: IntoIterator<Item = ObjectId>,
633    W: Write,
634{
635    write_reachable_pack_to_writer_with_traversal(
636        reader,
637        format,
638        starts,
639        excluded,
640        options,
641        writer,
642        cancel,
643        ReachablePackTraversal::Natural,
644    )
645}
646
647/// Reachable pack streaming with the traversal order historically used by
648/// repository repacks. Kept crate-private so transfer callers retain their
649/// natural root and parent order.
650pub(crate) fn write_repack_reachable_pack_to_writer_with_options<R, I, W>(
651    reader: &R,
652    format: ObjectFormat,
653    starts: I,
654    excluded: &HashSet<ObjectId>,
655    options: &PackWriteOptions,
656    writer: &mut W,
657) -> Result<Option<ReachablePackWriteSummary>>
658where
659    R: ObjectReader,
660    I: IntoIterator<Item = ObjectId>,
661    W: Write,
662{
663    write_reachable_pack_to_writer_with_traversal(
664        reader,
665        format,
666        starts,
667        excluded,
668        options,
669        writer,
670        CancelFlag::never(),
671        ReachablePackTraversal::RepackLegacy,
672    )
673}
674
675#[allow(clippy::too_many_arguments)]
676fn write_reachable_pack_to_writer_with_traversal<R, I, W>(
677    reader: &R,
678    format: ObjectFormat,
679    starts: I,
680    excluded: &HashSet<ObjectId>,
681    options: &PackWriteOptions,
682    writer: &mut W,
683    cancel: CancelFlag<'_>,
684    traversal: ReachablePackTraversal,
685) -> Result<Option<ReachablePackWriteSummary>>
686where
687    R: ObjectReader,
688    I: IntoIterator<Item = ObjectId>,
689    W: Write,
690{
691    match collect_reachable_pack_objects_for_write_with_order(
692        reader,
693        format,
694        starts,
695        excluded,
696        options.reorder,
697        traversal,
698    )? {
699        ReachablePackObjectsForWrite::Buffered {
700            objects,
701            name_hashes,
702        } => {
703            if objects.is_empty() {
704                return Ok(None);
705            }
706            cancel.check()?;
707            let inputs = pack_inputs(&objects);
708            let summary = PackFile::write_packed_with_known_ids_and_options_and_hints_to_writer(
709                &inputs,
710                format,
711                options,
712                PackPlanningHints::new().with_name_hashes(&name_hashes),
713                writer,
714            )?;
715            Ok(Some(reachable_pack_write_summary(summary)))
716        }
717        ReachablePackObjectsForWrite::Streaming(metadata) => {
718            if metadata.is_empty() {
719                return Ok(None);
720            }
721            let object_count = u32::try_from(metadata.len())
722                .map_err(|_| GitError::InvalidFormat("too many pack objects".into()))?;
723            let name_hashes = metadata
724                .iter()
725                .filter(|meta| meta.name_hash != 0)
726                .map(|meta| (meta.oid, meta.name_hash))
727                .collect::<HashMap<_, _>>();
728            let streaming_options = options.clone().with_reorder(false);
729            let summary = PackFile::write_packed_from_source_to_writer_with_hints_and_cancel(
730                metadata.iter().map(|meta| meta.oid),
731                object_count,
732                format,
733                &streaming_options,
734                PackPlanningHints::new().with_name_hashes(&name_hashes),
735                PackWriteLimits::default(),
736                |oid| reader.read_object(oid),
737                writer,
738                cancel,
739            )?;
740            Ok(Some(reachable_pack_write_summary(summary)))
741        }
742    }
743}
744
745pub fn write_object_id_pack_to_writer<R, I, W>(
746    reader: &R,
747    format: ObjectFormat,
748    selected_objects: I,
749    object_count: u32,
750    writer: &mut W,
751) -> Result<ReachablePackWriteSummary>
752where
753    R: ObjectReader,
754    I: IntoIterator<Item = ObjectId>,
755    W: Write,
756{
757    write_object_id_pack_to_writer_with_cancel(
758        reader,
759        format,
760        selected_objects,
761        object_count,
762        writer,
763        CancelFlag::never(),
764    )
765}
766
767/// Streaming object-id pack write that polls `cancel` between compression
768/// windows via [`PackFile::write_packed_from_source_to_writer_with_cancel`].
769pub fn write_object_id_pack_to_writer_with_cancel<R, I, W>(
770    reader: &R,
771    format: ObjectFormat,
772    selected_objects: I,
773    object_count: u32,
774    writer: &mut W,
775    cancel: CancelFlag<'_>,
776) -> Result<ReachablePackWriteSummary>
777where
778    R: ObjectReader,
779    I: IntoIterator<Item = ObjectId>,
780    W: Write,
781{
782    let summary = PackFile::write_packed_from_source_to_writer_with_cancel(
783        selected_objects,
784        object_count,
785        format,
786        &PackWriteOptions::new(),
787        PackWriteLimits::default(),
788        |oid| reader.read_object(oid),
789        writer,
790        cancel,
791    )?;
792    Ok(reachable_pack_write_summary(summary))
793}
794
795fn reachable_pack_write_summary(summary: PackWriteSummary) -> ReachablePackWriteSummary {
796    ReachablePackWriteSummary {
797        index: summary.index,
798        checksum: summary.checksum,
799        object_count: summary.entries.len(),
800        delta_count: summary.delta_count,
801        pack_size: summary.pack_size,
802    }
803}
804
805pub fn build_and_install_reachable_pack<R, I>(
806    source: &R,
807    destination: &FileObjectDatabase,
808    format: ObjectFormat,
809    starts: I,
810    excluded: &HashSet<ObjectId>,
811    options: RawPackInstallOptions,
812) -> Result<Option<PackInstallResult>>
813where
814    R: ObjectReader,
815    I: IntoIterator<Item = ObjectId>,
816{
817    build_and_install_reachable_pack_filtered(
818        source,
819        destination,
820        format,
821        starts,
822        excluded,
823        options,
824        None,
825        None,
826    )
827}
828
829/// A partial-clone object filter applied while building a transfer pack.
830///
831/// Mirrors the subset of upstream's `list-objects-filter` the in-process local
832/// server supports: directly-wanted tips are always packed; the filter only
833/// prunes objects reached *through* the traversal (upstream's
834/// `filter_blobs_none` runs on traversed blobs, never on wanted tips).
835#[derive(Debug, Clone, PartialEq, Eq)]
836pub enum PackObjectFilter {
837    /// `blob:none`: omit every blob reached through tree traversal.
838    BlobNone,
839    /// `blob:limit=<n>`: omit traversed blobs whose body is at least `n` bytes.
840    BlobLimit(u64),
841    /// `tree:<n>`: keep only trees shallower than `n`, and omit traversed blobs.
842    TreeDepth(u32),
843    /// `sparse:oid=<blob>`: keep only blobs whose repo path is listed.
844    SparsePathSet(Vec<String>),
845}
846
847/// How a transfer-pack walk treats objects promised by an incomplete source.
848#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
849pub enum ReachablePackMissingPolicy {
850    /// Missing reachable objects are transfer errors.
851    #[default]
852    RequireComplete,
853    /// Omit only objects proven to be promised by the source repository.
854    OmitPromised,
855}
856
857/// Optional client-owned objects that upload-pack may use as external delta
858/// bases while building a thin transfer pack.
859///
860/// The candidates are object ids from the negotiated have closure. They stay
861/// separate from `excluded`: exclusion controls which objects are sent, while
862/// this option controls which already-owned objects may participate in delta
863/// selection. Keeping those concerns explicit prevents a have from being sent
864/// merely so it can act as a base.
865#[derive(Debug, Clone, Copy, Default)]
866pub struct ReachablePackThinBaseCandidates<'a> {
867    pub object_ids: Option<&'a HashSet<ObjectId>>,
868}
869
870impl<'a> ReachablePackThinBaseCandidates<'a> {
871    pub fn from_object_ids(object_ids: &'a HashSet<ObjectId>) -> Self {
872        Self {
873            object_ids: Some(object_ids),
874        }
875    }
876}
877
878/// [`build_and_install_reachable_pack`] with an optional partial-clone
879/// `filter`. With `Some(BlobNone)`, blobs are dropped from the pack unless
880/// they are directly wanted (named in `starts`).
881#[allow(clippy::too_many_arguments)]
882/// Apply a partial-clone object `filter` to a collected reachable pack in place,
883/// dropping the objects the filter omits. Explicitly-`wanted` objects (the tips
884/// the client asked for) are always retained even when the filter would drop
885/// them. Shared by the in-process fetch pack builders and the upload-pack server.
886pub(crate) fn retain_filtered_pack_objects<R>(
887    objects: &mut Vec<ReachablePackObject>,
888    filter: Option<&PackObjectFilter>,
889    wanted: &HashSet<ObjectId>,
890    source: &R,
891    format: ObjectFormat,
892) -> Result<()>
893where
894    R: ObjectReader,
895{
896    match filter {
897        Some(PackObjectFilter::BlobNone) => {
898            objects.retain(|entry| {
899                entry.object.object_type != ObjectType::Blob || wanted.contains(&entry.oid)
900            });
901        }
902        Some(PackObjectFilter::BlobLimit(limit)) => {
903            let limit = *limit;
904            objects.retain(|entry| {
905                entry.object.object_type != ObjectType::Blob
906                    || wanted.contains(&entry.oid)
907                    || (entry.object.body.len() as u64) < limit
908            });
909        }
910        Some(PackObjectFilter::TreeDepth(depth)) => {
911            let depth = *depth;
912            let tree_depths = collect_tree_filter_depths(source, format, objects)?;
913            objects.retain(|entry| {
914                if wanted.contains(&entry.oid) {
915                    return true;
916                }
917                match entry.object.object_type {
918                    ObjectType::Blob => false,
919                    ObjectType::Tree => tree_depths
920                        .get(&entry.oid)
921                        .is_some_and(|tree_depth| *tree_depth < depth),
922                    _ => true,
923                }
924            });
925        }
926        Some(PackObjectFilter::SparsePathSet(paths)) => {
927            let allowed_blobs = collect_sparse_filter_blobs(source, format, objects, paths)?;
928            objects.retain(|entry| {
929                entry.object.object_type != ObjectType::Blob
930                    || wanted.contains(&entry.oid)
931                    || allowed_blobs.contains(&entry.oid)
932            });
933        }
934        None => {}
935    }
936    Ok(())
937}
938
939/// Build a self-contained pack of the objects reachable from `starts` (excluding
940/// `excluded`) with a partial-clone `filter` applied, returning the packed bytes
941/// without installing them. This is the upload-pack server's counterpart to
942/// [`build_reachable_pack`]: it honors `filter blob:none` / `blob:limit=<n>` /
943/// `tree:<depth>` requests so a filtered fetch omits the corresponding objects.
944pub fn build_reachable_pack_filtered<R, I>(
945    reader: &R,
946    format: ObjectFormat,
947    starts: I,
948    excluded: &HashSet<ObjectId>,
949    filter: Option<PackObjectFilter>,
950) -> Result<Option<PackWrite>>
951where
952    R: ObjectReader,
953    I: IntoIterator<Item = ObjectId>,
954{
955    build_reachable_pack_filtered_with_reuse_stats(reader, format, starts, excluded, filter)
956        .map(|build| build.map(|build| build.pack))
957}
958
959/// [`build_reachable_pack_filtered`] with explicit reuse counters.
960pub fn build_reachable_pack_filtered_with_reuse_stats<R, I>(
961    reader: &R,
962    format: ObjectFormat,
963    starts: I,
964    excluded: &HashSet<ObjectId>,
965    filter: Option<PackObjectFilter>,
966) -> Result<Option<ReachablePackBuild>>
967where
968    R: ObjectReader,
969    I: IntoIterator<Item = ObjectId>,
970{
971    let starts: Vec<ObjectId> = starts.into_iter().collect();
972    let wanted: HashSet<ObjectId> = starts.iter().copied().collect();
973    let mut objects = collect_reachable_pack_objects(reader, format, starts, excluded)?;
974    retain_filtered_pack_objects(&mut objects, filter.as_ref(), &wanted, reader, format)?;
975    if objects.is_empty() {
976        return Ok(None);
977    }
978    build_reachable_pack_objects_with_reuse(
979        reader,
980        format,
981        ReachablePackObjectsForWrite::Buffered {
982            objects,
983            name_hashes: HashMap::new(),
984        },
985    )
986    .map(Some)
987}
988
989#[derive(Debug)]
990struct ValidatedReusablePack {
991    pack: Arc<ReusablePackData>,
992    entries: Vec<ValidatedReusableEntry>,
993    oids: HashSet<ObjectId>,
994    checksum: ObjectId,
995    self_contained: bool,
996}
997
998#[derive(Debug, Clone)]
999struct ValidatedReusableEntry {
1000    oid: ObjectId,
1001    start: usize,
1002    end: usize,
1003    payload_start: usize,
1004    kind: u8,
1005    base_oid: Option<ObjectId>,
1006    crc32: u32,
1007}
1008
1009#[derive(Debug)]
1010struct SelectedReusableEntry {
1011    pack: Arc<ReusablePackData>,
1012    entry: ValidatedReusableEntry,
1013}
1014
1015#[derive(Debug)]
1016enum ReusablePackData {
1017    Bytes(Arc<[u8]>),
1018    File(Arc<PackData>),
1019}
1020
1021impl std::ops::Deref for ReusablePackData {
1022    type Target = [u8];
1023
1024    fn deref(&self) -> &[u8] {
1025        match self {
1026            Self::Bytes(bytes) => bytes,
1027            Self::File(data) => data,
1028        }
1029    }
1030}
1031
1032fn build_reachable_pack_objects_with_reuse<R: ObjectReader>(
1033    reader: &R,
1034    format: ObjectFormat,
1035    objects: ReachablePackObjectsForWrite,
1036) -> Result<ReachablePackBuild> {
1037    let selected: HashSet<ObjectId> = match &objects {
1038        ReachablePackObjectsForWrite::Buffered { objects, .. } => {
1039            objects.iter().map(|entry| entry.oid).collect()
1040        }
1041        ReachablePackObjectsForWrite::Streaming(objects) => {
1042            objects.iter().map(|entry| entry.oid).collect()
1043        }
1044    };
1045    let candidates = reader.reusable_pack_candidates(&selected)?;
1046    let mut validated = Vec::with_capacity(candidates.len());
1047    for candidate in candidates {
1048        if let Some(candidate) = validate_reusable_pack_candidate(format, candidate)? {
1049            validated.push(candidate);
1050        }
1051    }
1052
1053    // Degenerate pack-reuse case: the selected closure is exactly one
1054    // self-contained source pack. Preserve the complete byte stream, including
1055    // OFS_DELTA distances, header, and checksum.
1056    for candidate in &validated {
1057        if candidate.self_contained && candidate.oids == selected {
1058            let entries = candidate
1059                .entries
1060                .iter()
1061                .map(|entry| PackIndexEntry {
1062                    oid: entry.oid,
1063                    crc32: entry.crc32,
1064                    offset: entry.start as u64,
1065                })
1066                .collect::<Vec<_>>();
1067            let index = PackIndex::write_v2(format, &entries, &candidate.checksum)?;
1068            let delta_count = candidate
1069                .entries
1070                .iter()
1071                .filter(|entry| matches!(entry.kind, 6 | 7))
1072                .count() as u32;
1073            let reuse = ReachablePackReuseStats {
1074                verbatim_entries: entries.len() as u32,
1075                redeltified_entries: 0,
1076                whole_pack: true,
1077            };
1078            trace_reachable_pack_reuse(reuse);
1079            return Ok(ReachablePackBuild {
1080                pack: PackWrite {
1081                    pack: candidate.pack.to_vec(),
1082                    index,
1083                    checksum: candidate.checksum,
1084                    entries,
1085                    delta_count,
1086                },
1087                reuse,
1088            });
1089        }
1090    }
1091
1092    // General case: reuse every selected entry whose immediate delta base is
1093    // selected too. REF_DELTA entries and non-deltas stay byte-for-byte intact.
1094    // OFS_DELTA entries retain their size header, compressed delta program, and
1095    // chain, but their location-dependent base header is changed to REF_DELTA.
1096    let mut reused_oids = HashSet::new();
1097    let mut reused_entries = Vec::new();
1098    for candidate in validated {
1099        for entry in candidate.entries {
1100            if !selected.contains(&entry.oid)
1101                || entry
1102                    .base_oid
1103                    .is_some_and(|base_oid| !selected.contains(&base_oid))
1104                || !reused_oids.insert(entry.oid)
1105            {
1106                continue;
1107            }
1108            reused_entries.push(SelectedReusableEntry {
1109                pack: Arc::clone(&candidate.pack),
1110                entry,
1111            });
1112        }
1113    }
1114
1115    let fresh_oids = match &objects {
1116        ReachablePackObjectsForWrite::Buffered { objects, .. } => objects
1117            .iter()
1118            .filter_map(|entry| (!reused_oids.contains(&entry.oid)).then_some(entry.oid))
1119            .collect::<Vec<_>>(),
1120        ReachablePackObjectsForWrite::Streaming(objects) => objects
1121            .iter()
1122            .filter_map(|entry| (!reused_oids.contains(&entry.oid)).then_some(entry.oid))
1123            .collect::<Vec<_>>(),
1124    };
1125    let fresh_pack = if fresh_oids.is_empty() {
1126        None
1127    } else {
1128        match &objects {
1129            ReachablePackObjectsForWrite::Buffered {
1130                objects,
1131                name_hashes,
1132            } => {
1133                let inputs = objects
1134                    .iter()
1135                    .filter(|entry| !reused_oids.contains(&entry.oid))
1136                    .map(|entry| PackInput {
1137                        oid: &entry.oid,
1138                        object: &entry.object,
1139                    })
1140                    .collect::<Vec<_>>();
1141                Some(PackFile::write_packed_with_known_ids_and_options_and_hints(
1142                    &inputs,
1143                    format,
1144                    &PackWriteOptions::new(),
1145                    PackPlanningHints::new().with_name_hashes(name_hashes),
1146                )?)
1147            }
1148            ReachablePackObjectsForWrite::Streaming(_) => {
1149                let mut pack = Vec::new();
1150                let object_count = u32::try_from(fresh_oids.len())
1151                    .map_err(|_| GitError::InvalidFormat("too many pack objects".into()))?;
1152                let summary = PackFile::write_packed_from_source_to_writer(
1153                    fresh_oids.iter().copied(),
1154                    object_count,
1155                    format,
1156                    &PackWriteOptions::new(),
1157                    PackWriteLimits::default(),
1158                    |oid| reader.read_object(oid),
1159                    &mut pack,
1160                )?;
1161                Some(PackWrite {
1162                    pack,
1163                    index: summary.index,
1164                    checksum: summary.checksum,
1165                    entries: summary.entries,
1166                    delta_count: summary.delta_count,
1167                })
1168            }
1169        }
1170    };
1171    let pack = assemble_generated_and_reused_entries(format, fresh_pack, &reused_entries)?;
1172    let reuse = ReachablePackReuseStats {
1173        verbatim_entries: u32::try_from(reused_entries.len())
1174            .map_err(|_| GitError::InvalidFormat("too many reusable pack entries".into()))?,
1175        redeltified_entries: u32::try_from(fresh_oids.len())
1176            .map_err(|_| GitError::InvalidFormat("too many generated pack entries".into()))?,
1177        whole_pack: false,
1178    };
1179    trace_reachable_pack_reuse(reuse);
1180    Ok(ReachablePackBuild { pack, reuse })
1181}
1182
1183/// Write a reachable pack directly to `writer` while preserving retained-pack
1184/// entries. Large walks retain object metadata rather than every decoded body;
1185/// fresh objects are compressed through the pack writer's bounded window, and
1186/// reused entries are copied to the destination as they are selected.
1187pub fn write_reachable_pack_with_reuse_stats_to_writer<R, I, W>(
1188    reader: &R,
1189    format: ObjectFormat,
1190    starts: I,
1191    excluded: &HashSet<ObjectId>,
1192    writer: &mut W,
1193) -> Result<Option<ReachablePackReuseWrite>>
1194where
1195    R: ObjectReader,
1196    I: IntoIterator<Item = ObjectId>,
1197    W: Write,
1198{
1199    let objects = collect_reachable_pack_objects_for_write(reader, format, starts, excluded)?;
1200    let selected: HashSet<ObjectId> = match &objects {
1201        ReachablePackObjectsForWrite::Buffered { objects, .. } => {
1202            objects.iter().map(|entry| entry.oid).collect()
1203        }
1204        ReachablePackObjectsForWrite::Streaming(objects) => {
1205            objects.iter().map(|entry| entry.oid).collect()
1206        }
1207    };
1208    if selected.is_empty() {
1209        return Ok(None);
1210    }
1211
1212    let candidates = reader.reusable_pack_candidates(&selected)?;
1213    let mut validated = Vec::with_capacity(candidates.len());
1214    for candidate in candidates {
1215        if let Some(candidate) = validate_reusable_pack_candidate(format, candidate)? {
1216            validated.push(candidate);
1217        }
1218    }
1219
1220    for candidate in &validated {
1221        if candidate.self_contained && candidate.oids == selected {
1222            writer.write_all(&candidate.pack)?;
1223            let entries = candidate
1224                .entries
1225                .iter()
1226                .map(|entry| PackIndexEntry {
1227                    oid: entry.oid,
1228                    crc32: entry.crc32,
1229                    offset: entry.start as u64,
1230                })
1231                .collect::<Vec<_>>();
1232            let index = PackIndex::write_v2(format, &entries, &candidate.checksum)?;
1233            let delta_count = candidate
1234                .entries
1235                .iter()
1236                .filter(|entry| matches!(entry.kind, 6 | 7))
1237                .count() as u32;
1238            let reuse = ReachablePackReuseStats {
1239                verbatim_entries: entries.len() as u32,
1240                redeltified_entries: 0,
1241                whole_pack: true,
1242            };
1243            trace_reachable_pack_reuse(reuse);
1244            return Ok(Some(ReachablePackReuseWrite {
1245                summary: ReachablePackWriteSummary {
1246                    index,
1247                    checksum: candidate.checksum,
1248                    object_count: entries.len(),
1249                    delta_count,
1250                    pack_size: candidate.pack.len() as u64,
1251                },
1252                reuse,
1253            }));
1254        }
1255    }
1256
1257    let mut reused_oids = HashSet::new();
1258    let mut reused_entries = Vec::new();
1259    for candidate in validated {
1260        for entry in candidate.entries {
1261            if !selected.contains(&entry.oid)
1262                || entry
1263                    .base_oid
1264                    .is_some_and(|base_oid| !selected.contains(&base_oid))
1265                || !reused_oids.insert(entry.oid)
1266            {
1267                continue;
1268            }
1269            reused_entries.push(SelectedReusableEntry {
1270                pack: Arc::clone(&candidate.pack),
1271                entry,
1272            });
1273        }
1274    }
1275
1276    let fresh_oids = match &objects {
1277        ReachablePackObjectsForWrite::Buffered { objects, .. } => objects
1278            .iter()
1279            .filter_map(|entry| (!reused_oids.contains(&entry.oid)).then_some(entry.oid))
1280            .collect::<Vec<_>>(),
1281        ReachablePackObjectsForWrite::Streaming(objects) => objects
1282            .iter()
1283            .filter_map(|entry| (!reused_oids.contains(&entry.oid)).then_some(entry.oid))
1284            .collect::<Vec<_>>(),
1285    };
1286    let object_count = fresh_oids
1287        .len()
1288        .checked_add(reused_entries.len())
1289        .ok_or_else(|| GitError::InvalidFormat("too many pack objects".into()))?;
1290    let declared_count = u32::try_from(object_count)
1291        .map_err(|_| GitError::InvalidFormat("too many pack objects".into()))?;
1292
1293    let mut output = ReachablePackDigestWriter::new(writer, format);
1294    output.write_pack_bytes(b"PACK")?;
1295    output.write_pack_bytes(&2u32.to_be_bytes())?;
1296    output.write_pack_bytes(&declared_count.to_be_bytes())?;
1297
1298    let mut entries = Vec::with_capacity(object_count);
1299    let mut delta_count = 0u32;
1300    if !fresh_oids.is_empty() {
1301        let mut fresh_body = GeneratedPackBodyWriter::new(&mut output, format.raw_len());
1302        let fresh_count = u32::try_from(fresh_oids.len())
1303            .map_err(|_| GitError::InvalidFormat("too many pack objects".into()))?;
1304        let fresh = PackFile::write_packed_from_source_to_writer(
1305            fresh_oids.iter().copied(),
1306            fresh_count,
1307            format,
1308            &PackWriteOptions::new(),
1309            PackWriteLimits::default(),
1310            |oid| reader.read_object(oid),
1311            &mut fresh_body,
1312        )?;
1313        fresh_body.finish(&fresh.checksum, fresh_oids.len())?;
1314        delta_count = fresh.delta_count;
1315        entries.extend(fresh.entries);
1316    }
1317
1318    for reused in &reused_entries {
1319        let offset = output.position();
1320        let encoded = encode_selected_reusable_entry(reused)?;
1321        let crc32 = crc32fast::hash(&encoded);
1322        output.write_pack_bytes(&encoded)?;
1323        entries.push(PackIndexEntry {
1324            oid: reused.entry.oid,
1325            crc32,
1326            offset,
1327        });
1328        if matches!(reused.entry.kind, 6 | 7) {
1329            delta_count = delta_count
1330                .checked_add(1)
1331                .ok_or_else(|| GitError::InvalidFormat("too many pack deltas".into()))?;
1332        }
1333    }
1334
1335    let (checksum, pack_size) = output.finish()?;
1336    let index = PackIndex::write_v2(format, &entries, &checksum)?;
1337    let reuse = ReachablePackReuseStats {
1338        verbatim_entries: u32::try_from(reused_entries.len())
1339            .map_err(|_| GitError::InvalidFormat("too many reusable pack entries".into()))?,
1340        redeltified_entries: u32::try_from(fresh_oids.len())
1341            .map_err(|_| GitError::InvalidFormat("too many generated pack entries".into()))?,
1342        whole_pack: false,
1343    };
1344    trace_reachable_pack_reuse(reuse);
1345    Ok(Some(ReachablePackReuseWrite {
1346        summary: ReachablePackWriteSummary {
1347            index,
1348            checksum,
1349            object_count,
1350            delta_count,
1351            pack_size,
1352        },
1353        reuse,
1354    }))
1355}
1356
1357fn validate_reusable_pack_candidate(
1358    format: ObjectFormat,
1359    candidate: ReusablePackCandidate,
1360) -> Result<Option<ValidatedReusablePack>> {
1361    let pack = if let Some(path) = candidate.pack_path {
1362        let Ok(data) = load_pack_data(&path) else {
1363            return Ok(None);
1364        };
1365        Arc::new(ReusablePackData::File(Arc::new(data)))
1366    } else {
1367        Arc::new(ReusablePackData::Bytes(candidate.pack))
1368    };
1369    let hash_len = format.raw_len();
1370    if pack.len() < 12 + hash_len
1371        || &pack[..4] != b"PACK"
1372        || candidate.pack_checksum.format() != format
1373    {
1374        return Ok(None);
1375    }
1376    let version = u32::from_be_bytes([pack[4], pack[5], pack[6], pack[7]]);
1377    if !matches!(version, 2 | 3) {
1378        return Ok(None);
1379    }
1380    let declared_count = u32::from_be_bytes([pack[8], pack[9], pack[10], pack[11]]) as usize;
1381    if declared_count != candidate.entries.len() {
1382        return Ok(None);
1383    }
1384    let trailer_offset = pack.len() - hash_len;
1385    let trailer = ObjectId::from_raw(format, &pack[trailer_offset..])?;
1386    let actual = sley_core::digest_bytes(format, &pack[..trailer_offset])?;
1387    if actual != trailer || trailer != candidate.pack_checksum {
1388        return Ok(None);
1389    }
1390
1391    let mut by_offset = candidate.entries;
1392    by_offset.sort_by_key(|entry| entry.offset);
1393    let mut oids = HashSet::with_capacity(by_offset.len());
1394    let mut offset_oids = HashMap::with_capacity(by_offset.len());
1395    for entry in &by_offset {
1396        if entry.oid.format() != format
1397            || !oids.insert(entry.oid)
1398            || offset_oids.insert(entry.offset, entry.oid).is_some()
1399        {
1400            return Ok(None);
1401        }
1402    }
1403
1404    let mut entries = Vec::with_capacity(by_offset.len());
1405    for (idx, index_entry) in by_offset.iter().enumerate() {
1406        let Ok(start) = usize::try_from(index_entry.offset) else {
1407            return Ok(None);
1408        };
1409        let end = match by_offset.get(idx + 1) {
1410            Some(next) => match usize::try_from(next.offset) {
1411                Ok(offset) => offset,
1412                Err(_) => return Ok(None),
1413            },
1414            None => trailer_offset,
1415        };
1416        if start < 12 || start >= end || end > trailer_offset {
1417            return Ok(None);
1418        }
1419        let raw = &pack[start..end];
1420        let Some((kind, payload_start, base_oid)) =
1421            reusable_entry_header(format, raw, index_entry.offset, &offset_oids)?
1422        else {
1423            return Ok(None);
1424        };
1425        entries.push(ValidatedReusableEntry {
1426            oid: index_entry.oid,
1427            start,
1428            end,
1429            payload_start,
1430            kind,
1431            base_oid,
1432            crc32: crc32fast::hash(raw),
1433        });
1434    }
1435    let self_contained = entries.iter().all(|entry| {
1436        entry
1437            .base_oid
1438            .is_none_or(|base_oid| oids.contains(&base_oid))
1439    });
1440    Ok(Some(ValidatedReusablePack {
1441        pack,
1442        entries,
1443        oids,
1444        checksum: trailer,
1445        self_contained,
1446    }))
1447}
1448
1449fn reusable_entry_header(
1450    format: ObjectFormat,
1451    raw: &[u8],
1452    entry_offset: u64,
1453    offset_oids: &HashMap<u64, ObjectId>,
1454) -> Result<Option<(u8, usize, Option<ObjectId>)>> {
1455    let Some(first) = raw.first().copied() else {
1456        return Ok(None);
1457    };
1458    let kind = (first >> 4) & 0x07;
1459    if !matches!(kind, 1 | 2 | 3 | 4 | 6 | 7) {
1460        return Ok(None);
1461    }
1462    let mut cursor = 1usize;
1463    let mut byte = first;
1464    while byte & 0x80 != 0 {
1465        let Some(next) = raw.get(cursor).copied() else {
1466            return Ok(None);
1467        };
1468        cursor += 1;
1469        byte = next;
1470    }
1471    let base_oid = match kind {
1472        6 => {
1473            let Some(first_offset) = raw.get(cursor).copied() else {
1474                return Ok(None);
1475            };
1476            cursor += 1;
1477            let mut byte = first_offset;
1478            let mut relative = u64::from(byte & 0x7f);
1479            while byte & 0x80 != 0 {
1480                let Some(next) = raw.get(cursor).copied() else {
1481                    return Ok(None);
1482                };
1483                cursor += 1;
1484                byte = next;
1485                let Some(next_relative) = relative
1486                    .checked_add(1)
1487                    .and_then(|value| value.checked_shl(7))
1488                    .and_then(|value| value.checked_add(u64::from(byte & 0x7f)))
1489                else {
1490                    return Ok(None);
1491                };
1492                relative = next_relative;
1493            }
1494            let Some(base_offset) = entry_offset.checked_sub(relative) else {
1495                return Ok(None);
1496            };
1497            let Some(base_oid) = offset_oids.get(&base_offset).copied() else {
1498                return Ok(None);
1499            };
1500            Some(base_oid)
1501        }
1502        7 => {
1503            let end = match cursor.checked_add(format.raw_len()) {
1504                Some(end) if end <= raw.len() => end,
1505                _ => return Ok(None),
1506            };
1507            let base_oid = ObjectId::from_raw(format, &raw[cursor..end])?;
1508            cursor = end;
1509            Some(base_oid)
1510        }
1511        _ => None,
1512    };
1513    if cursor >= raw.len() {
1514        return Ok(None);
1515    }
1516    Ok(Some((kind, cursor, base_oid)))
1517}
1518
1519fn assemble_generated_and_reused_entries(
1520    format: ObjectFormat,
1521    fresh: Option<PackWrite>,
1522    reused: &[SelectedReusableEntry],
1523) -> Result<PackWrite> {
1524    let fresh_count = fresh.as_ref().map_or(0usize, |pack| pack.entries.len());
1525    let total = fresh_count
1526        .checked_add(reused.len())
1527        .and_then(|count| u32::try_from(count).ok())
1528        .ok_or_else(|| GitError::InvalidFormat("too many pack objects".into()))?;
1529    let mut out = Vec::new();
1530    out.extend_from_slice(b"PACK");
1531    out.extend_from_slice(&2u32.to_be_bytes());
1532    out.extend_from_slice(&total.to_be_bytes());
1533
1534    let mut entries = Vec::with_capacity(total as usize);
1535    let mut delta_count = 0u32;
1536    if let Some(fresh) = fresh {
1537        let hash_len = format.raw_len();
1538        if fresh.pack.len() < 12 + hash_len || &fresh.pack[..4] != b"PACK" {
1539            return Err(GitError::InvalidFormat(
1540                "generated pack is not reusable".into(),
1541            ));
1542        }
1543        out.extend_from_slice(&fresh.pack[12..fresh.pack.len() - hash_len]);
1544        delta_count = fresh.delta_count;
1545        entries.extend(fresh.entries);
1546    }
1547
1548    for reused in reused {
1549        let offset = out.len() as u64;
1550        let encoded = encode_selected_reusable_entry(reused)?;
1551        let crc32 = crc32fast::hash(&encoded);
1552        out.extend_from_slice(&encoded);
1553        entries.push(PackIndexEntry {
1554            oid: reused.entry.oid,
1555            crc32,
1556            offset,
1557        });
1558        if matches!(reused.entry.kind, 6 | 7) {
1559            delta_count = delta_count
1560                .checked_add(1)
1561                .ok_or_else(|| GitError::InvalidFormat("too many pack deltas".into()))?;
1562        }
1563    }
1564
1565    let checksum = sley_core::digest_bytes(format, &out)?;
1566    out.extend_from_slice(checksum.as_bytes());
1567    let index = PackIndex::write_v2(format, &entries, &checksum)?;
1568    Ok(PackWrite {
1569        pack: out,
1570        index,
1571        checksum,
1572        entries,
1573        delta_count,
1574    })
1575}
1576
1577fn encode_selected_reusable_entry(reused: &SelectedReusableEntry) -> Result<Vec<u8>> {
1578    let raw = &reused.pack[reused.entry.start..reused.entry.end];
1579    if reused.entry.kind != 6 {
1580        return Ok(raw.to_vec());
1581    }
1582    let base_oid = reused
1583        .entry
1584        .base_oid
1585        .ok_or_else(|| GitError::InvalidFormat("reused ofs-delta has no base oid".into()))?;
1586    let mut entry = raw[..reused.entry.payload_start].to_vec();
1587    let mut header_end = 1usize;
1588    while entry[header_end - 1] & 0x80 != 0 {
1589        header_end += 1;
1590    }
1591    entry.truncate(header_end);
1592    entry[0] = (entry[0] & 0x8f) | (7 << 4);
1593    entry.extend_from_slice(base_oid.as_bytes());
1594    entry.extend_from_slice(&raw[reused.entry.payload_start..]);
1595    Ok(entry)
1596}
1597
1598struct ReachablePackDigestWriter<'a, W> {
1599    writer: &'a mut W,
1600    digest: StreamingDigest,
1601    position: u64,
1602}
1603
1604impl<'a, W: Write> ReachablePackDigestWriter<'a, W> {
1605    fn new(writer: &'a mut W, format: ObjectFormat) -> Self {
1606        Self {
1607            writer,
1608            digest: StreamingDigest::new(format),
1609            position: 0,
1610        }
1611    }
1612
1613    fn position(&self) -> u64 {
1614        self.position
1615    }
1616
1617    fn write_pack_bytes(&mut self, bytes: &[u8]) -> Result<()> {
1618        self.writer.write_all(bytes)?;
1619        self.digest.update(bytes);
1620        self.position = self
1621            .position
1622            .checked_add(bytes.len() as u64)
1623            .ok_or_else(|| GitError::InvalidFormat("pack offset overflow".into()))?;
1624        Ok(())
1625    }
1626
1627    fn finish(mut self) -> Result<(ObjectId, u64)> {
1628        let checksum = self.digest.finalize()?;
1629        self.writer.write_all(checksum.as_bytes())?;
1630        self.position = self
1631            .position
1632            .checked_add(checksum.as_bytes().len() as u64)
1633            .ok_or_else(|| GitError::InvalidFormat("pack offset overflow".into()))?;
1634        Ok((checksum, self.position))
1635    }
1636}
1637
1638/// Strip a generated pack's header and trailer while forwarding its entry
1639/// bytes into the combined output digest. `pending_trailer` never exceeds one
1640/// object-id, so large compressed payload writes are not duplicated.
1641struct GeneratedPackBodyWriter<'a, 'b, W> {
1642    output: &'a mut ReachablePackDigestWriter<'b, W>,
1643    prefix: Vec<u8>,
1644    pending_trailer: Vec<u8>,
1645    hash_len: usize,
1646}
1647
1648impl<'a, 'b, W: Write> GeneratedPackBodyWriter<'a, 'b, W> {
1649    fn new(output: &'a mut ReachablePackDigestWriter<'b, W>, hash_len: usize) -> Self {
1650        Self {
1651            output,
1652            prefix: Vec::with_capacity(12),
1653            pending_trailer: Vec::with_capacity(hash_len),
1654            hash_len,
1655        }
1656    }
1657
1658    fn finish(self, checksum: &ObjectId, object_count: usize) -> Result<()> {
1659        if self.prefix.len() != 12 || &self.prefix[..4] != b"PACK" {
1660            return Err(GitError::InvalidFormat(
1661                "generated pack is missing its header".into(),
1662            ));
1663        }
1664        let declared_count = u32::from_be_bytes([
1665            self.prefix[8],
1666            self.prefix[9],
1667            self.prefix[10],
1668            self.prefix[11],
1669        ]);
1670        if declared_count as usize != object_count {
1671            return Err(GitError::InvalidFormat(format!(
1672                "generated pack declared {declared_count} objects, expected {object_count}"
1673            )));
1674        }
1675        if self.pending_trailer.as_slice() != checksum.as_bytes() {
1676            return Err(GitError::InvalidFormat(
1677                "generated pack checksum trailer mismatch".into(),
1678            ));
1679        }
1680        Ok(())
1681    }
1682
1683    fn forward_body(&mut self, mut bytes: &[u8]) -> Result<()> {
1684        if self.pending_trailer.len() < self.hash_len {
1685            let fill = (self.hash_len - self.pending_trailer.len()).min(bytes.len());
1686            self.pending_trailer.extend_from_slice(&bytes[..fill]);
1687            bytes = &bytes[fill..];
1688        }
1689        if bytes.is_empty() {
1690            return Ok(());
1691        }
1692
1693        if bytes.len() >= self.hash_len {
1694            self.output.write_pack_bytes(&self.pending_trailer)?;
1695            let body_len = bytes.len() - self.hash_len;
1696            self.output.write_pack_bytes(&bytes[..body_len])?;
1697            self.pending_trailer.clear();
1698            self.pending_trailer.extend_from_slice(&bytes[body_len..]);
1699            return Ok(());
1700        }
1701
1702        self.output
1703            .write_pack_bytes(&self.pending_trailer[..bytes.len()])?;
1704        self.pending_trailer.copy_within(bytes.len().., 0);
1705        let retained = self.hash_len - bytes.len();
1706        self.pending_trailer.truncate(retained);
1707        self.pending_trailer.extend_from_slice(bytes);
1708        Ok(())
1709    }
1710}
1711
1712impl<W: Write> Write for GeneratedPackBodyWriter<'_, '_, W> {
1713    fn write(&mut self, mut bytes: &[u8]) -> io::Result<usize> {
1714        let input_len = bytes.len();
1715        if self.prefix.len() < 12 {
1716            let fill = (12 - self.prefix.len()).min(bytes.len());
1717            self.prefix.extend_from_slice(&bytes[..fill]);
1718            bytes = &bytes[fill..];
1719        }
1720        self.forward_body(bytes)
1721            .map_err(|err| io::Error::other(err.to_string()))?;
1722        Ok(input_len)
1723    }
1724
1725    fn flush(&mut self) -> io::Result<()> {
1726        self.output
1727            .writer
1728            .flush()
1729            .map_err(|err| io::Error::other(err.to_string()))
1730    }
1731}
1732
1733fn trace_reachable_pack_reuse(stats: ReachablePackReuseStats) {
1734    sley_core::trace2::data(
1735        "pack-objects",
1736        "verbatim-entries",
1737        u64::from(stats.verbatim_entries),
1738    );
1739    sley_core::trace2::data(
1740        "pack-objects",
1741        "redeltified-entries",
1742        u64::from(stats.redeltified_entries),
1743    );
1744    sley_core::trace2::data(
1745        "pack-objects",
1746        "whole-pack-reused",
1747        u64::from(stats.whole_pack),
1748    );
1749}
1750
1751// Keep the public call shape aligned with the unfiltered installer while the
1752// two filter inputs remain independent policy knobs.
1753#[allow(clippy::too_many_arguments)]
1754pub fn build_and_install_reachable_pack_filtered<R, I>(
1755    source: &R,
1756    destination: &FileObjectDatabase,
1757    format: ObjectFormat,
1758    starts: I,
1759    excluded: &HashSet<ObjectId>,
1760    options: RawPackInstallOptions,
1761    filter: Option<PackObjectFilter>,
1762    unpack_limit: Option<usize>,
1763) -> Result<Option<PackInstallResult>>
1764where
1765    R: ObjectReader,
1766    I: IntoIterator<Item = ObjectId>,
1767{
1768    build_and_install_reachable_pack_filtered_with_missing_policy(
1769        source,
1770        destination,
1771        format,
1772        starts,
1773        excluded,
1774        options,
1775        filter,
1776        unpack_limit,
1777        ReachablePackMissingPolicy::RequireComplete,
1778    )
1779}
1780
1781/// Build and install a filtered transfer pack with an explicit promised-object
1782/// policy. The omission mode is reserved for a server whose client accepted a
1783/// protocol-v2 `promisor-remote` advertisement.
1784#[allow(clippy::too_many_arguments)]
1785pub fn build_and_install_reachable_pack_filtered_with_missing_policy<R, I>(
1786    source: &R,
1787    destination: &FileObjectDatabase,
1788    format: ObjectFormat,
1789    starts: I,
1790    excluded: &HashSet<ObjectId>,
1791    options: RawPackInstallOptions,
1792    filter: Option<PackObjectFilter>,
1793    unpack_limit: Option<usize>,
1794    missing_policy: ReachablePackMissingPolicy,
1795) -> Result<Option<PackInstallResult>>
1796where
1797    R: ObjectReader,
1798    I: IntoIterator<Item = ObjectId>,
1799{
1800    build_and_install_reachable_pack_filtered_with_thin_bases(
1801        source,
1802        destination,
1803        format,
1804        starts,
1805        excluded,
1806        options,
1807        filter,
1808        unpack_limit,
1809        missing_policy,
1810        ReachablePackThinBaseCandidates::default(),
1811    )
1812    .map(|outcome| outcome.install)
1813}
1814
1815/// Result of one reachable transfer installation. `object_count` describes
1816/// equal work independently of storage policy: it is retained when a small
1817/// transfer is unpacked into loose objects and `install` is therefore `None`.
1818#[derive(Debug, Clone, PartialEq, Eq)]
1819pub struct ReachablePackInstallOutcome {
1820    /// Installed pack metadata, or `None` when the transfer was empty or was
1821    /// exploded into loose objects by `unpack_limit`.
1822    pub install: Option<PackInstallResult>,
1823    /// Number of objects selected and written by this transfer.
1824    pub object_count: usize,
1825    /// Number of selected objects eligible for Git's delta-compression search.
1826    /// Git excludes objects smaller than 50 bytes before displaying its
1827    /// `Compressing objects` phase; the Sley writer applies the same threshold.
1828    pub compression_count: usize,
1829    /// Number of deltified objects written into the generated transfer pack.
1830    /// This remains available when the receiving side subsequently explodes a
1831    /// small transfer into loose objects.
1832    pub delta_count: u32,
1833}
1834
1835/// Build and install a filtered transfer pack, optionally considering objects
1836/// from the client's negotiated have closure as external thin-pack bases.
1837/// Candidate selection is bounded per object type and deterministic so a large
1838/// client repository cannot turn delta planning into an unbounded comparison.
1839#[allow(clippy::too_many_arguments)]
1840pub fn build_and_install_reachable_pack_filtered_with_thin_bases<R, I>(
1841    source: &R,
1842    destination: &FileObjectDatabase,
1843    format: ObjectFormat,
1844    starts: I,
1845    excluded: &HashSet<ObjectId>,
1846    options: RawPackInstallOptions,
1847    filter: Option<PackObjectFilter>,
1848    unpack_limit: Option<usize>,
1849    missing_policy: ReachablePackMissingPolicy,
1850    thin_base_candidates: ReachablePackThinBaseCandidates<'_>,
1851) -> Result<ReachablePackInstallOutcome>
1852where
1853    R: ObjectReader,
1854    I: IntoIterator<Item = ObjectId>,
1855{
1856    let starts: Vec<ObjectId> = starts.into_iter().collect();
1857    let wanted: HashSet<ObjectId> = starts.iter().copied().collect();
1858    let mut objects = match missing_policy {
1859        ReachablePackMissingPolicy::RequireComplete => {
1860            collect_reachable_pack_objects(source, format, starts, excluded)?
1861        }
1862        ReachablePackMissingPolicy::OmitPromised => {
1863            collect_reachable_pack_objects_tolerating_promised_missing(
1864                source, format, starts, excluded,
1865            )?
1866        }
1867    };
1868    retain_filtered_pack_objects(&mut objects, filter.as_ref(), &wanted, source, format)?;
1869    let object_count = objects.len();
1870    let compression_count = objects
1871        .iter()
1872        .filter(|entry| entry.object.body.len() >= 50)
1873        .count();
1874    if objects.is_empty() {
1875        return Ok(ReachablePackInstallOutcome {
1876            install: None,
1877            object_count,
1878            compression_count,
1879            delta_count: 0,
1880        });
1881    }
1882    // upload-pack always prepares the transfer pack. `unpack_limit` is a
1883    // client-side storage choice made after receiving its header; retain the
1884    // server's real delta/compression work and counts even when the destination
1885    // ultimately explodes the transfer into loose objects.
1886    let unpack_loose = unpack_limit.is_some_and(|limit| objects.len() < limit);
1887    let inputs = pack_inputs(&objects);
1888    let (thin_bases, preferred_thin_bases) =
1889        select_reachable_pack_thin_bases(source, &objects, thin_base_candidates.object_ids)?;
1890    let pack_dir = destination.objects_dir.join("pack");
1891    fs::create_dir_all(&pack_dir)?;
1892    let temp_pack_path = unique_temp_path(&pack_dir);
1893    let result = (|| -> Result<(Option<PackInstallResult>, u32)> {
1894        let mut file = fs::OpenOptions::new()
1895            .write(true)
1896            .create_new(true)
1897            .open(&temp_pack_path)?;
1898        let pack_options = PackWriteOptions::new()
1899            .with_thin_bases(thin_bases)
1900            .with_preferred_thin_bases(preferred_thin_bases);
1901        let summary = PackFile::write_packed_with_known_ids_to_writer(
1902            &inputs,
1903            format,
1904            &pack_options,
1905            &mut file,
1906        )?;
1907        file.flush()?;
1908        file.sync_all()?;
1909        drop(file);
1910        trace_packfile_path(&temp_pack_path)?;
1911        let delta_count = summary.delta_count;
1912        if unpack_loose {
1913            for entry in &objects {
1914                destination.loose().write_object((*entry.object).clone())?;
1915            }
1916            fs::remove_file(&temp_pack_path)?;
1917            return Ok((None, delta_count));
1918        }
1919        destination
1920            .install_pack_file_from_temp(
1921                &temp_pack_path,
1922                summary.checksum,
1923                &summary.index,
1924                summary.entries.iter().map(|entry| entry.oid).collect(),
1925                options,
1926            )
1927            .map(|install| (Some(install), delta_count))
1928    })();
1929    if result.is_err() {
1930        let _ = fs::remove_file(&temp_pack_path);
1931    }
1932    result.map(|(install, delta_count)| ReachablePackInstallOutcome {
1933        install,
1934        object_count,
1935        compression_count,
1936        delta_count,
1937    })
1938}
1939
1940/// Select only bases of deltas already stored by the source. This performs one
1941/// metadata lookup per object being sent and never decodes the complete client
1942/// have closure. Base bodies are decoded once per distinct reusable base.
1943fn select_reachable_pack_thin_bases<R: ObjectReader>(
1944    source: &R,
1945    objects: &[ReachablePackObject],
1946    candidate_ids: Option<&HashSet<ObjectId>>,
1947) -> Result<(
1948    HashMap<ObjectId, EncodedObject>,
1949    HashMap<ObjectId, ObjectId>,
1950)> {
1951    let Some(candidate_ids) = candidate_ids else {
1952        return Ok((HashMap::new(), HashMap::new()));
1953    };
1954    if candidate_ids.is_empty() || objects.is_empty() {
1955        return Ok((HashMap::new(), HashMap::new()));
1956    }
1957
1958    let mut bases = HashMap::new();
1959    let mut preferred = HashMap::new();
1960    for entry in objects {
1961        let Some(base_oid) = source.reusable_delta_base(&entry.oid)? else {
1962            continue;
1963        };
1964        if !candidate_ids.contains(&base_oid) {
1965            continue;
1966        }
1967        if let std::collections::hash_map::Entry::Vacant(slot) = bases.entry(base_oid) {
1968            slot.insert((*source.read_object(&base_oid)?).clone());
1969        }
1970        preferred.insert(entry.oid, base_oid);
1971    }
1972    Ok((bases, preferred))
1973}
1974
1975#[cfg(test)]
1976mod thin_base_tests {
1977    use super::*;
1978
1979    #[test]
1980    fn reachable_transfer_retains_count_when_unpacking_loose_objects() {
1981        let format = ObjectFormat::Sha1;
1982        let root = unique_temp_path(&env::temp_dir());
1983        let source = FileObjectDatabase::new(root.join("source/objects"), format);
1984        let destination = FileObjectDatabase::new(root.join("destination/objects"), format);
1985        let blob = EncodedObject::new(ObjectType::Blob, b"small transfer\n".to_vec());
1986        let oid = source.write_object(blob).expect("write source blob");
1987
1988        let outcome = build_and_install_reachable_pack_filtered_with_thin_bases(
1989            &source,
1990            &destination,
1991            format,
1992            [oid],
1993            &HashSet::new(),
1994            RawPackInstallOptions::default(),
1995            None,
1996            Some(100),
1997            ReachablePackMissingPolicy::RequireComplete,
1998            ReachablePackThinBaseCandidates::default(),
1999        )
2000        .expect("install loose transfer");
2001
2002        assert_eq!(outcome.object_count, 1);
2003        assert_eq!(outcome.compression_count, 0);
2004        assert_eq!(outcome.delta_count, 0);
2005        assert!(outcome.install.is_none());
2006        assert!(destination.contains(&oid).expect("read destination blob"));
2007        fs::remove_dir_all(root).expect("remove test repositories");
2008    }
2009
2010    #[test]
2011    fn reachable_transfer_reuses_stored_client_owned_blob_base() {
2012        for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] {
2013            let root = unique_temp_path(&env::temp_dir());
2014            let source = FileObjectDatabase::new(root.join("source/objects"), format);
2015            let destination = FileObjectDatabase::new(root.join("destination/objects"), format);
2016
2017            let base = EncodedObject::new(ObjectType::Blob, vec![b'a'; 16_384]);
2018            let mut target_body = base.body.clone();
2019            target_body[8_192..8_256].fill(b'b');
2020            let target = EncodedObject::new(ObjectType::Blob, target_body);
2021            let base_oid = base.object_id(format).expect("base oid");
2022            let target_oid = target.object_id(format).expect("target oid");
2023            let source_pack = PackFile::write_packed_with_options(
2024                &[base.clone(), target.clone()],
2025                format,
2026                &PackWriteOptions::new().with_reorder(false),
2027            )
2028            .expect("write source pack");
2029            source
2030                .install_pack(&source_pack)
2031                .expect("install source pack");
2032            assert_eq!(
2033                source
2034                    .reusable_delta_base(&target_oid)
2035                    .expect("read source delta metadata"),
2036                Some(base_oid)
2037            );
2038            destination
2039                .write_object(base)
2040                .expect("client already owns base");
2041
2042            let candidates = HashSet::from([base_oid]);
2043
2044            let result = build_and_install_reachable_pack_filtered_with_thin_bases(
2045                &source,
2046                &destination,
2047                format,
2048                [target_oid],
2049                &HashSet::new(),
2050                RawPackInstallOptions::default(),
2051                None,
2052                Some(1),
2053                ReachablePackMissingPolicy::RequireComplete,
2054                ReachablePackThinBaseCandidates::from_object_ids(&candidates),
2055            )
2056            .expect("build thin transfer")
2057            .install
2058            .expect("installed pack");
2059
2060            let index = PackIndex::parse(
2061                &fs::read(&result.index_path).expect("read transfer index"),
2062                format,
2063            )
2064            .expect("parse transfer index");
2065            let target_offset = index
2066                .entries
2067                .iter()
2068                .find(|entry| entry.oid == target_oid)
2069                .expect("target index entry")
2070                .offset as usize;
2071            let pack = fs::read(&result.pack_path).expect("read transfer pack");
2072            let first = pack[target_offset];
2073            assert_eq!((first >> 4) & 0x07, 7, "target must be a ref-delta");
2074            let mut base_offset = target_offset + 1;
2075            let mut header_byte = first;
2076            while header_byte & 0x80 != 0 {
2077                header_byte = pack[base_offset];
2078                base_offset += 1;
2079            }
2080            assert_eq!(
2081                &pack[base_offset..base_offset + format.raw_len()],
2082                base_oid.as_bytes(),
2083                "thin delta should use the buried client-owned base"
2084            );
2085
2086            destination.refresh_read_cache();
2087            assert_eq!(
2088                destination
2089                    .read_object(&target_oid)
2090                    .expect("read target through external pack base")
2091                    .as_ref(),
2092                &target
2093            );
2094            fs::remove_dir_all(root).expect("remove test repository");
2095        }
2096    }
2097}
2098
2099#[cfg(test)]
2100mod pack_reuse_tests {
2101    use super::*;
2102
2103    #[derive(Clone)]
2104    struct CandidateReader {
2105        objects: HashMap<ObjectId, Arc<EncodedObject>>,
2106        candidates: Vec<ReusablePackCandidate>,
2107    }
2108
2109    impl ObjectReader for CandidateReader {
2110        fn read_object(&self, oid: &ObjectId) -> Result<Arc<EncodedObject>> {
2111            self.objects
2112                .get(oid)
2113                .cloned()
2114                .ok_or_else(|| GitError::object_not_found_in(*oid, MissingObjectContext::Read))
2115        }
2116
2117        fn reusable_pack_candidates(
2118            &self,
2119            object_ids: &HashSet<ObjectId>,
2120        ) -> Result<Vec<ReusablePackCandidate>> {
2121            Ok(self
2122                .candidates
2123                .iter()
2124                .filter(|candidate| {
2125                    candidate
2126                        .entries
2127                        .iter()
2128                        .any(|entry| object_ids.contains(&entry.oid))
2129                })
2130                .cloned()
2131                .collect())
2132        }
2133    }
2134
2135    fn reusable_candidate(pack: &PackWrite) -> ReusablePackCandidate {
2136        ReusablePackCandidate {
2137            pack: Arc::from(pack.pack.clone()),
2138            pack_path: None,
2139            entries: pack.entries.clone(),
2140            pack_checksum: pack.checksum,
2141        }
2142    }
2143
2144    fn similar_blobs(format: ObjectFormat) -> (EncodedObject, ObjectId, EncodedObject, ObjectId) {
2145        let base = EncodedObject::new(ObjectType::Blob, vec![b'a'; 16_384]);
2146        let mut target_body = base.body.clone();
2147        target_body[8_192..8_256].fill(b'b');
2148        let target = EncodedObject::new(ObjectType::Blob, target_body);
2149        let base_oid = base.object_id(format).expect("base oid");
2150        let target_oid = target.object_id(format).expect("target oid");
2151        (base, base_oid, target, target_oid)
2152    }
2153
2154    #[test]
2155    fn exact_self_contained_pack_is_returned_verbatim() {
2156        for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] {
2157            let root = unique_temp_path(&env::temp_dir());
2158            let source = FileObjectDatabase::new(root.join("objects"), format);
2159            let (base, base_oid, target, target_oid) = similar_blobs(format);
2160            let stored = PackFile::write_packed_with_options(
2161                &[base, target],
2162                format,
2163                &PackWriteOptions::new().with_reorder(false),
2164            )
2165            .expect("write source pack");
2166            assert_eq!(stored.delta_count, 1);
2167            source.install_pack(&stored).expect("install source pack");
2168
2169            let build = build_reachable_pack_with_reuse_stats(
2170                &source,
2171                format,
2172                [base_oid, target_oid],
2173                &HashSet::new(),
2174            )
2175            .expect("build reachable pack")
2176            .expect("non-empty pack");
2177
2178            assert_eq!(build.pack.pack, stored.pack);
2179            assert_eq!(
2180                build.reuse,
2181                ReachablePackReuseStats {
2182                    verbatim_entries: 2,
2183                    redeltified_entries: 0,
2184                    whole_pack: true,
2185                }
2186            );
2187            assert_eq!(build.pack.delta_count, 1);
2188            PackFile::parse(&build.pack.pack, format).expect("verbatim pack is self-contained");
2189
2190            let mut streamed = Vec::new();
2191            let streamed_build = write_reachable_pack_with_reuse_stats_to_writer(
2192                &source,
2193                format,
2194                [base_oid, target_oid],
2195                &HashSet::new(),
2196                &mut streamed,
2197            )
2198            .expect("stream reachable pack")
2199            .expect("non-empty streamed pack");
2200            assert_eq!(streamed, stored.pack);
2201            assert_eq!(streamed_build.reuse, build.reuse);
2202            assert_eq!(streamed_build.summary.checksum, stored.checksum);
2203            assert_eq!(streamed_build.summary.object_count, 2);
2204            fs::remove_dir_all(root).expect("remove test repository");
2205        }
2206    }
2207
2208    #[test]
2209    fn partial_pack_reuses_existing_delta_chain_without_redeltifying() {
2210        let format = ObjectFormat::Sha1;
2211        let root = unique_temp_path(&env::temp_dir());
2212        let source = FileObjectDatabase::new(root.join("objects"), format);
2213        let (base, base_oid, target, target_oid) = similar_blobs(format);
2214        let unrelated = EncodedObject::new(ObjectType::Blob, b"not selected\n".to_vec());
2215        let stored = PackFile::write_packed_with_options(
2216            &[base.clone(), target.clone(), unrelated],
2217            format,
2218            &PackWriteOptions::new().with_reorder(false),
2219        )
2220        .expect("write source pack");
2221        assert_eq!(stored.delta_count, 1);
2222        source.install_pack(&stored).expect("install source pack");
2223
2224        let build = build_reachable_pack_with_reuse_stats(
2225            &source,
2226            format,
2227            [base_oid, target_oid],
2228            &HashSet::new(),
2229        )
2230        .expect("build partial reuse pack")
2231        .expect("non-empty pack");
2232
2233        assert_ne!(build.pack.pack, stored.pack);
2234        assert_eq!(
2235            build.reuse,
2236            ReachablePackReuseStats {
2237                verbatim_entries: 2,
2238                redeltified_entries: 0,
2239                whole_pack: false,
2240            }
2241        );
2242        assert_eq!(build.pack.delta_count, 1);
2243        let parsed =
2244            PackFile::parse(&build.pack.pack, format).expect("partial reuse is self-contained");
2245        let parsed = parsed
2246            .entries
2247            .into_iter()
2248            .map(|entry| (entry.entry.oid, entry.object))
2249            .collect::<HashMap<_, _>>();
2250        assert_eq!(parsed.get(&base_oid), Some(&base));
2251        assert_eq!(parsed.get(&target_oid), Some(&target));
2252        fs::remove_dir_all(root).expect("remove test repository");
2253    }
2254
2255    #[test]
2256    fn thin_whole_pack_candidate_is_refused_and_regenerated() {
2257        let format = ObjectFormat::Sha1;
2258        let (base, base_oid, target, target_oid) = similar_blobs(format);
2259        let thin = PackFile::write_packed_with_options(
2260            std::slice::from_ref(&target),
2261            format,
2262            &PackWriteOptions::new().with_thin_bases(HashMap::from([(base_oid, base)])),
2263        )
2264        .expect("write thin source pack");
2265        assert_eq!(thin.delta_count, 1, "test candidate must really be thin");
2266        let reader = CandidateReader {
2267            objects: HashMap::from([(target_oid, Arc::new(target.clone()))]),
2268            candidates: vec![reusable_candidate(&thin)],
2269        };
2270
2271        let build =
2272            build_reachable_pack_with_reuse_stats(&reader, format, [target_oid], &HashSet::new())
2273                .expect("refuse thin candidate and regenerate")
2274                .expect("non-empty pack");
2275
2276        assert_ne!(build.pack.pack, thin.pack);
2277        assert_eq!(
2278            build.reuse,
2279            ReachablePackReuseStats {
2280                verbatim_entries: 0,
2281                redeltified_entries: 1,
2282                whole_pack: false,
2283            }
2284        );
2285        let parsed =
2286            PackFile::parse(&build.pack.pack, format).expect("fallback pack is self-contained");
2287        assert_eq!(parsed.entries.len(), 1);
2288        assert_eq!(parsed.entries[0].object, target);
2289
2290        let mut streamed = Vec::new();
2291        let streamed_build = write_reachable_pack_with_reuse_stats_to_writer(
2292            &reader,
2293            format,
2294            [target_oid],
2295            &HashSet::new(),
2296            &mut streamed,
2297        )
2298        .expect("stream regenerated pack")
2299        .expect("non-empty streamed pack");
2300        assert_eq!(streamed, build.pack.pack);
2301        assert_eq!(streamed_build.reuse, build.reuse);
2302        assert_eq!(streamed_build.summary.object_count, 1);
2303        assert_eq!(streamed_build.summary.checksum, build.pack.checksum);
2304    }
2305
2306    #[test]
2307    fn selected_cross_pack_ref_delta_is_reused_safely() {
2308        let format = ObjectFormat::Sha1;
2309        let (base, base_oid, target, target_oid) = similar_blobs(format);
2310        let base_pack =
2311            PackFile::write_packed(std::slice::from_ref(&base), format).expect("write base pack");
2312        let target_pack = PackFile::write_packed_with_options(
2313            std::slice::from_ref(&target),
2314            format,
2315            &PackWriteOptions::new().with_thin_bases(HashMap::from([(base_oid, base.clone())])),
2316        )
2317        .expect("write cross-pack ref-delta");
2318        assert_eq!(target_pack.delta_count, 1);
2319        let reader = CandidateReader {
2320            objects: HashMap::from([
2321                (base_oid, Arc::new(base.clone())),
2322                (target_oid, Arc::new(target.clone())),
2323            ]),
2324            candidates: vec![
2325                reusable_candidate(&target_pack),
2326                reusable_candidate(&base_pack),
2327            ],
2328        };
2329
2330        let build = build_reachable_pack_with_reuse_stats(
2331            &reader,
2332            format,
2333            [base_oid, target_oid],
2334            &HashSet::new(),
2335        )
2336        .expect("reuse cross-pack delta")
2337        .expect("non-empty pack");
2338
2339        assert_eq!(
2340            build.reuse,
2341            ReachablePackReuseStats {
2342                verbatim_entries: 2,
2343                redeltified_entries: 0,
2344                whole_pack: false,
2345            }
2346        );
2347        assert_eq!(build.pack.delta_count, 1);
2348        let parsed =
2349            PackFile::parse(&build.pack.pack, format).expect("cross-pack reuse is self-contained");
2350        let parsed = parsed
2351            .entries
2352            .into_iter()
2353            .map(|entry| (entry.entry.oid, entry.object))
2354            .collect::<HashMap<_, _>>();
2355        assert_eq!(parsed.get(&base_oid), Some(&base));
2356        assert_eq!(parsed.get(&target_oid), Some(&target));
2357    }
2358
2359    #[test]
2360    fn closure_spanning_multiple_packs_never_serves_one_partial_pack() {
2361        let format = ObjectFormat::Sha1;
2362        let root = unique_temp_path(&env::temp_dir());
2363        let source = FileObjectDatabase::new(root.join("objects"), format);
2364
2365        let blob = EncodedObject::new(ObjectType::Blob, b"across packs\n".to_vec());
2366        let blob_oid = blob.object_id(format).expect("blob oid");
2367        let mut tree_body = b"100644 file\0".to_vec();
2368        tree_body.extend_from_slice(blob_oid.as_bytes());
2369        let tree = EncodedObject::new(ObjectType::Tree, tree_body);
2370        let tree_oid = tree.object_id(format).expect("tree oid");
2371        let commit = EncodedObject::new(
2372            ObjectType::Commit,
2373            format!(
2374                "tree {tree_oid}\nauthor A <a@example.com> 0 +0000\ncommitter A <a@example.com> 0 +0000\n\nsplit closure\n"
2375            )
2376            .into_bytes(),
2377        );
2378        let commit_oid = commit.object_id(format).expect("commit oid");
2379
2380        let data_pack = PackFile::write_packed(&[blob, tree], format).expect("write data pack");
2381        let commit_pack = PackFile::write_packed(std::slice::from_ref(&commit), format)
2382            .expect("write commit pack");
2383        source.install_pack(&data_pack).expect("install data pack");
2384        source
2385            .install_pack(&commit_pack)
2386            .expect("install commit pack");
2387
2388        let build =
2389            build_reachable_pack_with_reuse_stats(&source, format, [commit_oid], &HashSet::new())
2390                .expect("build multi-pack closure")
2391                .expect("non-empty pack");
2392
2393        assert!(!build.reuse.whole_pack);
2394        assert_eq!(build.reuse.verbatim_entries, 3);
2395        assert_eq!(build.reuse.redeltified_entries, 0);
2396        assert_ne!(build.pack.pack, data_pack.pack);
2397        assert_ne!(build.pack.pack, commit_pack.pack);
2398        let parsed =
2399            PackFile::parse(&build.pack.pack, format).expect("combined closure is self-contained");
2400        assert_eq!(parsed.entries.len(), 3);
2401        assert!(
2402            parsed
2403                .entries
2404                .iter()
2405                .any(|entry| entry.entry.oid == commit_oid && entry.object == commit)
2406        );
2407
2408        let mut streamed = Vec::new();
2409        let streamed_build = write_reachable_pack_with_reuse_stats_to_writer(
2410            &source,
2411            format,
2412            [commit_oid],
2413            &HashSet::new(),
2414            &mut streamed,
2415        )
2416        .expect("stream multi-pack closure")
2417        .expect("non-empty streamed pack");
2418        assert_eq!(streamed, build.pack.pack);
2419        assert_eq!(streamed_build.reuse, build.reuse);
2420        assert_eq!(streamed_build.summary.object_count, 3);
2421        assert_eq!(streamed_build.summary.checksum, build.pack.checksum);
2422        fs::remove_dir_all(root).expect("remove test repository");
2423    }
2424}
2425
2426fn trace_packfile_path(pack_path: &Path) -> Result<()> {
2427    let Some(path) = env::var_os("GIT_TRACE_PACKFILE").filter(|value| !value.is_empty()) else {
2428        return Ok(());
2429    };
2430    fs::copy(pack_path, path)?;
2431    Ok(())
2432}
2433
2434fn collect_tree_filter_depths<R>(
2435    reader: &R,
2436    format: ObjectFormat,
2437    objects: &[ReachablePackObject],
2438) -> Result<HashMap<ObjectId, u32>>
2439where
2440    R: ObjectReader,
2441{
2442    let available: HashSet<ObjectId> = objects.iter().map(|entry| entry.oid).collect();
2443    let mut depths = HashMap::new();
2444    let mut stack = Vec::new();
2445    for entry in objects {
2446        if entry.object.object_type != ObjectType::Commit {
2447            continue;
2448        }
2449        let commit = Commit::parse(format, &entry.object.body)?;
2450        if available.contains(&commit.tree) {
2451            stack.push((commit.tree, 0u32));
2452        }
2453    }
2454    while let Some((tree_oid, depth)) = stack.pop() {
2455        if depths
2456            .get(&tree_oid)
2457            .is_some_and(|old_depth| *old_depth <= depth)
2458        {
2459            continue;
2460        }
2461        depths.insert(tree_oid, depth);
2462        let tree = reader.read_object(&tree_oid)?;
2463        if tree.object_type != ObjectType::Tree {
2464            continue;
2465        }
2466        let child_depth = depth.saturating_add(1);
2467        for entry in TreeEntries::new(format, &tree.body) {
2468            let entry = entry?;
2469            if tree_entry_object_type(entry.mode) == ObjectType::Tree
2470                && available.contains(&entry.oid)
2471            {
2472                stack.push((entry.oid, child_depth));
2473            }
2474        }
2475    }
2476    Ok(depths)
2477}
2478
2479fn collect_sparse_filter_blobs<R>(
2480    reader: &R,
2481    format: ObjectFormat,
2482    objects: &[ReachablePackObject],
2483    paths: &[String],
2484) -> Result<HashSet<ObjectId>>
2485where
2486    R: ObjectReader,
2487{
2488    let wanted_paths: HashSet<&str> = paths.iter().map(String::as_str).collect();
2489    let mut allowed = HashSet::new();
2490    let mut seen_trees = HashSet::new();
2491    for entry in objects {
2492        if entry.object.object_type != ObjectType::Commit {
2493            continue;
2494        }
2495        let commit = Commit::parse(format, &entry.object.body)?;
2496        collect_sparse_tree_blobs(
2497            reader,
2498            format,
2499            &commit.tree,
2500            "",
2501            &wanted_paths,
2502            &mut seen_trees,
2503            &mut allowed,
2504        )?;
2505    }
2506    Ok(allowed)
2507}
2508
2509fn collect_sparse_tree_blobs<R>(
2510    reader: &R,
2511    format: ObjectFormat,
2512    tree_oid: &ObjectId,
2513    prefix: &str,
2514    wanted_paths: &HashSet<&str>,
2515    seen_trees: &mut HashSet<ObjectId>,
2516    allowed: &mut HashSet<ObjectId>,
2517) -> Result<()>
2518where
2519    R: ObjectReader,
2520{
2521    if !seen_trees.insert(*tree_oid) {
2522        return Ok(());
2523    }
2524    let tree = reader.read_object(tree_oid)?;
2525    if tree.object_type != ObjectType::Tree {
2526        return Ok(());
2527    }
2528    for entry in TreeEntries::new(format, &tree.body) {
2529        let entry = entry?;
2530        let name = String::from_utf8_lossy(entry.name);
2531        let path = if prefix.is_empty() {
2532            name.into_owned()
2533        } else {
2534            format!("{prefix}/{name}")
2535        };
2536        if tree_entry_object_type(entry.mode) == ObjectType::Tree {
2537            collect_sparse_tree_blobs(
2538                reader,
2539                format,
2540                &entry.oid,
2541                &path,
2542                wanted_paths,
2543                seen_trees,
2544                allowed,
2545            )?;
2546        } else if wanted_paths.contains(path.as_str()) {
2547            allowed.insert(entry.oid);
2548        }
2549    }
2550    Ok(())
2551}
2552
2553/// Assemble a pack stream that reuses an existing pack's object data verbatim
2554/// (upstream pack-objects' "pack reuse" fast path, full-pack case) and appends
2555/// `appended` as freshly encoded undeltified entries.
2556///
2557/// The reused pack's entry bytes are copied as-is between our own header and
2558/// trailer: a full-pack copy preserves every relative distance, so internal
2559/// `OFS_DELTA` bases stay valid. The header object count covers both the
2560/// reused and appended entries, and the trailing pack checksum is recomputed
2561/// over the assembled stream.
2562pub fn assemble_pack_with_verbatim_reuse(
2563    format: ObjectFormat,
2564    reused_pack_bytes: &[u8],
2565    appended: &[PackInput<'_>],
2566) -> Result<(Vec<u8>, u32)> {
2567    assemble_pack_with_verbatim_reuses(format, &[reused_pack_bytes], appended)
2568}
2569
2570/// Like [`assemble_pack_with_verbatim_reuse`], but concatenates multiple whole
2571/// packs before appending fresh entries.
2572pub fn assemble_pack_with_verbatim_reuses(
2573    format: ObjectFormat,
2574    reused_packs: &[&[u8]],
2575    appended: &[PackInput<'_>],
2576) -> Result<(Vec<u8>, u32)> {
2577    let hash_len = format.raw_len();
2578    let mut reused_count = 0u32;
2579    let mut capacity = 12 + hash_len + 64 * appended.len();
2580    for reused_pack_bytes in reused_packs {
2581        if reused_pack_bytes.len() < 12 + hash_len {
2582            return Err(GitError::InvalidFormat("reused pack too short".into()));
2583        }
2584        if &reused_pack_bytes[..4] != b"PACK" {
2585            return Err(GitError::InvalidFormat(
2586                "reused pack has no signature".into(),
2587            ));
2588        }
2589        let version = u32::from_be_bytes([
2590            reused_pack_bytes[4],
2591            reused_pack_bytes[5],
2592            reused_pack_bytes[6],
2593            reused_pack_bytes[7],
2594        ]);
2595        if version != 2 {
2596            return Err(GitError::Unsupported(format!(
2597                "reused pack version {version}"
2598            )));
2599        }
2600        let count = u32::from_be_bytes([
2601            reused_pack_bytes[8],
2602            reused_pack_bytes[9],
2603            reused_pack_bytes[10],
2604            reused_pack_bytes[11],
2605        ]);
2606        reused_count = reused_count
2607            .checked_add(count)
2608            .ok_or_else(|| GitError::InvalidFormat("too many pack objects".into()))?;
2609        capacity = capacity.saturating_add(reused_pack_bytes.len().saturating_sub(12 + hash_len));
2610    }
2611    let total = reused_count
2612        .checked_add(appended.len() as u32)
2613        .ok_or_else(|| GitError::InvalidFormat("too many pack objects".into()))?;
2614
2615    let mut out = Vec::with_capacity(capacity);
2616    out.extend_from_slice(b"PACK");
2617    out.extend_from_slice(&2u32.to_be_bytes());
2618    out.extend_from_slice(&total.to_be_bytes());
2619    for reused_pack_bytes in reused_packs {
2620        out.extend_from_slice(&reused_pack_bytes[12..reused_pack_bytes.len() - hash_len]);
2621    }
2622    for input in appended {
2623        write_undeltified_pack_entry(&mut out, input.object)?;
2624    }
2625    let checksum = sley_core::digest_bytes(format, &out)?;
2626    out.extend_from_slice(checksum.as_bytes());
2627    Ok((out, reused_count))
2628}
2629
2630/// Assemble a pack stream by copying already-encoded pack entries verbatim and
2631/// appending freshly encoded undeltified entries.
2632pub fn assemble_pack_with_verbatim_entries(
2633    format: ObjectFormat,
2634    reused_entries: &[&[u8]],
2635    appended: &[PackInput<'_>],
2636) -> Result<(Vec<u8>, u32)> {
2637    let reused_count = u32::try_from(reused_entries.len())
2638        .map_err(|_| GitError::InvalidFormat("too many pack objects".into()))?;
2639    let total = reused_count
2640        .checked_add(appended.len() as u32)
2641        .ok_or_else(|| GitError::InvalidFormat("too many pack objects".into()))?;
2642
2643    let mut capacity = 12 + format.raw_len() + 64 * appended.len();
2644    for entry in reused_entries {
2645        capacity = capacity.saturating_add(entry.len());
2646    }
2647    let mut out = Vec::with_capacity(capacity);
2648    out.extend_from_slice(b"PACK");
2649    out.extend_from_slice(&2u32.to_be_bytes());
2650    out.extend_from_slice(&total.to_be_bytes());
2651    for entry in reused_entries {
2652        out.extend_from_slice(entry);
2653    }
2654    for input in appended {
2655        write_undeltified_pack_entry(&mut out, input.object)?;
2656    }
2657    let checksum = sley_core::digest_bytes(format, &out)?;
2658    out.extend_from_slice(checksum.as_bytes());
2659    Ok((out, reused_count))
2660}
2661
2662/// Append one undeltified pack entry (type/size varint header + zlib body).
2663fn write_undeltified_pack_entry(out: &mut Vec<u8>, object: &EncodedObject) -> Result<()> {
2664    let type_bits: u8 = match object.object_type {
2665        ObjectType::Commit => 1,
2666        ObjectType::Tree => 2,
2667        ObjectType::Blob => 3,
2668        ObjectType::Tag => 4,
2669    };
2670    let mut size = object.body.len() as u64;
2671    let mut byte = (type_bits << 4) | (size & 0x0f) as u8;
2672    size >>= 4;
2673    while size > 0 {
2674        out.push(byte | 0x80);
2675        byte = (size & 0x7f) as u8;
2676        size >>= 7;
2677    }
2678    out.push(byte);
2679    let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
2680    encoder.write_all(&object.body)?;
2681    out.extend_from_slice(&encoder.finish()?);
2682    Ok(())
2683}
2684/// List loose objects under `git_dir` that are *not* reachable from `roots`,
2685/// optionally deleting them.
2686///
2687/// Reachability is computed with [`collect_reachable_object_ids`] over the
2688/// repository's object database, so trees, parents, and tag targets are all
2689/// followed. When `delete` is `false` the returned ids are merely reported;
2690/// when `true` each unreachable loose object file is removed (packed copies are
2691/// never touched). Deletion is therefore opt-in.
2692pub fn prune_unreachable_loose<I>(
2693    git_dir: &Path,
2694    format: ObjectFormat,
2695    roots: I,
2696    delete: bool,
2697) -> Result<Vec<ObjectId>>
2698where
2699    I: IntoIterator<Item = ObjectId>,
2700{
2701    prune_unreachable_loose_with_reachability(git_dir, format, roots, delete, false)
2702}
2703
2704/// Like [`prune_unreachable_loose`], but missing links encountered while walking
2705/// reachable roots are ignored. `git gc` uses this mode for pre-existing broken
2706/// unreachable commits/trees/tags: the broken object itself is kept when recent,
2707/// but its absent children do not make housekeeping fail.
2708pub fn prune_unreachable_loose_tolerating_missing<I>(
2709    git_dir: &Path,
2710    format: ObjectFormat,
2711    roots: I,
2712    delete: bool,
2713) -> Result<Vec<ObjectId>>
2714where
2715    I: IntoIterator<Item = ObjectId>,
2716{
2717    prune_unreachable_loose_with_reachability(git_dir, format, roots, delete, true)
2718}
2719
2720fn prune_unreachable_loose_with_reachability<I>(
2721    git_dir: &Path,
2722    format: ObjectFormat,
2723    roots: I,
2724    delete: bool,
2725    tolerate_missing: bool,
2726) -> Result<Vec<ObjectId>>
2727where
2728    I: IntoIterator<Item = ObjectId>,
2729{
2730    let objects_dir = repository_objects_dir(git_dir);
2731    let database = FileObjectDatabase::new(objects_dir.clone(), format);
2732    let reachable = if tolerate_missing {
2733        collect_reachable_object_ids_tolerating_missing(&database, format, roots)?
2734    } else {
2735        collect_reachable_object_ids(&database, format, roots)?
2736    };
2737
2738    let store = LooseObjectStore::new(objects_dir.clone(), format);
2739    let mut pruned: Vec<ObjectId> = loose_object_ids(&objects_dir, format)?
2740        .into_iter()
2741        .filter(|oid| !reachable.contains(oid))
2742        .collect();
2743    pruned.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes()));
2744
2745    if delete {
2746        for oid in &pruned {
2747            let path = store.object_path(oid)?;
2748            match fs::remove_file(&path) {
2749                Ok(()) => {}
2750                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
2751                Err(err) => return Err(GitError::from(err)),
2752            }
2753        }
2754    }
2755    Ok(pruned)
2756}
2757
2758/// Loose object ids under `objects_dir`, sorted by hex, with packed objects
2759/// excluded.
2760pub(crate) fn loose_object_ids(objects_dir: &Path, format: ObjectFormat) -> Result<Vec<ObjectId>> {
2761    let oids = loose_object_id_set(objects_dir, format)?;
2762    let mut oids = oids.into_iter().collect::<Vec<_>>();
2763    oids.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes()));
2764    Ok(oids)
2765}
2766
2767pub(crate) fn loose_object_id_set(
2768    objects_dir: &Path,
2769    format: ObjectFormat,
2770) -> Result<HashSet<ObjectId>> {
2771    let mut oids = HashSet::new();
2772    collect_loose_object_ids(objects_dir, format, &mut oids)?;
2773    Ok(oids)
2774}
2775
2776/// Absolute paths of every `*.pack` file directly inside `pack_dir`, sorted for
2777/// deterministic output.
2778pub(crate) fn existing_pack_files(pack_dir: &Path) -> Result<Vec<PathBuf>> {
2779    if !pack_dir.exists() {
2780        return Ok(Vec::new());
2781    }
2782    let mut packs = Vec::new();
2783    for entry in fs::read_dir(pack_dir)? {
2784        let path = entry?.path();
2785        if path.extension().and_then(|ext| ext.to_str()) == Some("pack") && path.is_file() {
2786            packs.push(path);
2787        }
2788    }
2789    packs.sort();
2790    Ok(packs)
2791}
2792
2793/// Remove pre-existing packs whose every object is contained in `present`,
2794/// skipping `keep` (the pack just written), `.keep` packs, and `.promisor` packs.
2795/// A stale multi-pack-index that references any removed pack is removed too.
2796pub(crate) fn prune_obsolete_pack_paths(
2797    objects_dir: &Path,
2798    format: ObjectFormat,
2799    packs: &[PathBuf],
2800    keep: &Path,
2801    retained_pack_stems: &[String],
2802    prune_promisor: bool,
2803) -> Result<()> {
2804    prune_pack_paths_matching(
2805        objects_dir,
2806        format,
2807        packs.iter(),
2808        keep,
2809        retained_pack_stems,
2810        prune_promisor,
2811        |_| Ok(true),
2812    )
2813}
2814
2815fn prune_pack_paths_matching<'a>(
2816    objects_dir: &Path,
2817    format: ObjectFormat,
2818    packs: impl IntoIterator<Item = &'a PathBuf>,
2819    keep: &Path,
2820    retained_pack_stems: &[String],
2821    prune_promisor: bool,
2822    mut should_prune: impl FnMut(&Path) -> Result<bool>,
2823) -> Result<()> {
2824    let pack_dir = objects_dir.join("pack");
2825    let keep_stem = keep.file_stem().map(|stem| stem.to_owned());
2826    let retained_pack_stems: HashSet<&str> =
2827        retained_pack_stems.iter().map(String::as_str).collect();
2828    let mut removed_stems: HashSet<String> = HashSet::new();
2829
2830    for pack_path in packs {
2831        if pack_path == keep {
2832            continue;
2833        }
2834        let Some(stem) = pack_path.file_stem() else {
2835            continue;
2836        };
2837        if Some(stem) == keep_stem.as_deref() {
2838            continue;
2839        }
2840        if let Some(stem) = stem.to_str()
2841            && retained_pack_stems.contains(stem)
2842        {
2843            continue;
2844        }
2845        if pack_path.with_extension("keep").exists() {
2846            continue;
2847        }
2848        if pack_path.with_extension("promisor").exists() && !prune_promisor {
2849            continue;
2850        }
2851        if !should_prune(pack_path)? {
2852            continue;
2853        }
2854        remove_file_if_exists(pack_path)?;
2855        remove_file_if_exists(&pack_path.with_extension("idx"))?;
2856        for ext in ["rev", "mtimes", "bitmap", "promisor"] {
2857            remove_file_if_exists(&pack_path.with_extension(ext))?;
2858        }
2859        removed_stems.insert(stem.to_string_lossy().into_owned());
2860    }
2861
2862    prune_stale_multi_pack_index(&pack_dir, format, &removed_stems)?;
2863    Ok(())
2864}
2865
2866/// Remove a `multi-pack-index` if it names *any* pack that was removed.
2867///
2868/// A MIDX that still references a deleted pack makes reads fail (the lookup
2869/// resolves to a pack that is gone) before any fallback. Removing the whole MIDX
2870/// when even one of its packs is pruned forces readers back to the individual pack
2871/// indexes, which are correct; `multi-pack-index write` can rebuild it later.
2872pub(crate) fn prune_stale_multi_pack_index(
2873    pack_dir: &Path,
2874    format: ObjectFormat,
2875    removed_stems: &HashSet<String>,
2876) -> Result<()> {
2877    if removed_stems.is_empty() {
2878        return Ok(());
2879    }
2880    let midx_path = pack_dir.join("multi-pack-index");
2881    if !midx_path.exists() {
2882        return Ok(());
2883    }
2884    let midx = MultiPackIndex::parse(&fs::read(&midx_path)?, format)?;
2885    let references_removed_pack = midx.pack_names.iter().any(|name| {
2886        let stem = name.strip_suffix(".idx").unwrap_or(name);
2887        removed_stems.contains(stem)
2888    });
2889    if references_removed_pack {
2890        // Mirror git's `clear_midx_file`: drop the MIDX and every sidecar
2891        // (`multi-pack-index-*.bitmap`, `.rev`, …).
2892        remove_file_if_exists(&midx_path)?;
2893        if let Ok(entries) = fs::read_dir(pack_dir) {
2894            for entry in entries.flatten() {
2895                let path = entry.path();
2896                let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
2897                    continue;
2898                };
2899                if name.starts_with("multi-pack-index") {
2900                    let _ = fs::remove_file(&path);
2901                }
2902            }
2903        }
2904    }
2905    Ok(())
2906}
2907
2908/// Remove each loose object in `candidates` whose id is in `present`, leaving
2909/// any object not actually packed untouched.
2910pub(crate) fn prune_loose_objects<'a, I>(
2911    objects_dir: &Path,
2912    format: ObjectFormat,
2913    candidates: I,
2914    present: &HashSet<ObjectId>,
2915) -> Result<()>
2916where
2917    I: IntoIterator<Item = &'a ObjectId>,
2918{
2919    let store = LooseObjectStore::new(objects_dir.to_path_buf(), format);
2920    for oid in candidates {
2921        if !present.contains(oid) {
2922            continue;
2923        }
2924        remove_file_if_exists(&store.object_path(oid)?)?;
2925    }
2926    Ok(())
2927}
2928
2929pub(crate) enum PackDeltaBase {
2930    Offset(u64),
2931    Ref(ObjectId),
2932}
2933
2934pub(crate) struct PackIndexOffsetInfo {
2935    pub(crate) end_offset: u64,
2936    pub(crate) delta_base_oid: Option<ObjectId>,
2937}
2938
2939pub(crate) fn scan_pack_index_offsets(
2940    index: &PackIndexViewData,
2941    target_offset: u64,
2942    trailer_offset: Option<u64>,
2943    delta_base_offset: Option<u64>,
2944) -> Result<PackIndexOffsetInfo> {
2945    let mut target_count = 0usize;
2946    let mut next_offset = None;
2947    let mut delta_base_oid = None;
2948
2949    for idx in 0..index.count {
2950        let Some(lookup) = index.lookup_at(idx) else {
2951            continue;
2952        };
2953        if lookup.offset == target_offset {
2954            target_count += 1;
2955        } else if lookup.offset > target_offset {
2956            match next_offset {
2957                Some(current) if current <= lookup.offset => {}
2958                _ => next_offset = Some(lookup.offset),
2959            }
2960        }
2961        if Some(lookup.offset) == delta_base_offset {
2962            delta_base_oid = Some(index.oid_at(idx)?);
2963        }
2964    }
2965
2966    if target_count == 0 {
2967        return Err(GitError::InvalidFormat(format!(
2968            "pack index offset {target_offset} not found"
2969        )));
2970    }
2971    if let Some(offset) = delta_base_offset
2972        && delta_base_oid.is_none()
2973    {
2974        return Err(GitError::InvalidFormat(format!(
2975            "ofs-delta base offset {offset} not found"
2976        )));
2977    }
2978
2979    Ok(PackIndexOffsetInfo {
2980        // Preserve the old sorted-vector behavior for malformed indexes with
2981        // duplicate offsets: the next sorted entry has the same offset.
2982        end_offset: if target_count > 1 {
2983            target_offset
2984        } else if let Some(offset) = next_offset {
2985            offset
2986        } else {
2987            trailer_offset.ok_or_else(|| {
2988                GitError::InvalidFormat("pack size unavailable for final indexed object".into())
2989            })?
2990        },
2991        delta_base_oid,
2992    })
2993}
2994
2995pub(crate) fn scan_pack_offsets_without_index(
2996    format: ObjectFormat,
2997    pack: &[u8],
2998    target_offset: u64,
2999) -> Result<Option<u64>> {
3000    let trailer_len = format.raw_len();
3001    if pack.len() < 12 + trailer_len {
3002        return Err(GitError::InvalidFormat("pack file too short".into()));
3003    }
3004    let trailer_offset = pack.len() - trailer_len;
3005    let checksum = sley_core::digest_bytes(format, &pack[..trailer_offset])?;
3006    let expected = ObjectId::from_raw(format, &pack[trailer_offset..])?;
3007    if checksum != expected {
3008        return Err(GitError::InvalidFormat(format!(
3009            "pack checksum mismatch: expected {expected}, got {checksum}"
3010        )));
3011    }
3012    if &pack[..4] != b"PACK" {
3013        return Err(GitError::InvalidFormat("missing PACK signature".into()));
3014    }
3015    let version = u32_be(&pack[4..8]);
3016    if version != 2 && version != 3 {
3017        return Err(GitError::Unsupported(format!("pack version {version}")));
3018    }
3019
3020    let count = u32_be(&pack[8..12]);
3021    let mut cursor = 12usize;
3022    for _ in 0..count {
3023        let entry_offset = cursor as u64;
3024        let first = pack_next_byte(pack, &mut cursor)?;
3025        let kind = (first >> 4) & 0x07;
3026        let mut byte = first;
3027        while byte & 0x80 != 0 {
3028            byte = pack_next_byte(pack, &mut cursor)?;
3029        }
3030        match kind {
3031            1..=4 => {}
3032            6 => {
3033                parse_ofs_delta_base_offset(pack, &mut cursor, entry_offset)?;
3034            }
3035            7 => {
3036                parse_ref_delta_base_oid(format, pack, &mut cursor)?;
3037            }
3038            _ => {
3039                return Err(GitError::InvalidFormat(format!(
3040                    "invalid pack object kind {kind}"
3041                )));
3042            }
3043        }
3044        if cursor > trailer_offset {
3045            return Err(GitError::InvalidFormat(
3046                "pack entry extends past checksum".into(),
3047            ));
3048        }
3049        let consumed = inflate_pack_member_len(&pack[cursor..trailer_offset])?;
3050        if consumed == 0 {
3051            return Err(GitError::InvalidFormat(
3052                "empty compressed pack entry".into(),
3053            ));
3054        }
3055        cursor = cursor
3056            .checked_add(consumed)
3057            .ok_or_else(|| GitError::InvalidFormat("pack offset overflow".into()))?;
3058        if cursor > trailer_offset {
3059            return Err(GitError::InvalidFormat(
3060                "pack entry extends past checksum".into(),
3061            ));
3062        }
3063        if entry_offset == target_offset {
3064            return Ok(Some(cursor as u64));
3065        }
3066    }
3067    if cursor != trailer_offset {
3068        return Err(GitError::InvalidFormat(format!(
3069            "pack has {} trailing bytes before checksum",
3070            trailer_offset - cursor
3071        )));
3072    }
3073    Ok(None)
3074}
3075
3076pub(crate) fn inflate_pack_member_len(compressed: &[u8]) -> Result<usize> {
3077    let mut decompress = Decompress::new(true);
3078    let mut input = compressed;
3079    let mut consumed_total = 0usize;
3080    let mut out = [0u8; 8192];
3081    loop {
3082        let before_in = decompress.total_in();
3083        let before_out = decompress.total_out();
3084        let status = decompress
3085            .decompress(input, &mut out, FlushDecompress::None)
3086            .map_err(|err| GitError::InvalidObject(format!("zlib inflate failed: {err}")))?;
3087        let consumed = (decompress.total_in() - before_in) as usize;
3088        let produced = decompress.total_out() - before_out;
3089        input = &input[consumed..];
3090        consumed_total += consumed;
3091        match status {
3092            flate2::Status::StreamEnd => return Ok(consumed_total),
3093            _ if consumed == 0 && produced == 0 => {
3094                return Err(GitError::InvalidObject("truncated zlib stream".into()));
3095            }
3096            _ => {}
3097        }
3098    }
3099}
3100
3101pub(crate) fn pack_entry_delta_base(
3102    format: ObjectFormat,
3103    pack: &[u8],
3104    entry_offset: u64,
3105) -> Result<Option<PackDeltaBase>> {
3106    let mut cursor = usize::try_from(entry_offset)
3107        .map_err(|_| GitError::InvalidFormat("pack entry offset overflows usize".into()))?;
3108    let first = pack_next_byte(pack, &mut cursor)?;
3109    let kind = (first >> 4) & 0x07;
3110    let mut byte = first;
3111    while byte & 0x80 != 0 {
3112        byte = pack_next_byte(pack, &mut cursor)?;
3113    }
3114    match kind {
3115        6 => Ok(Some(PackDeltaBase::Offset(parse_ofs_delta_base_offset(
3116            pack,
3117            &mut cursor,
3118            entry_offset,
3119        )?))),
3120        7 => Ok(Some(PackDeltaBase::Ref(parse_ref_delta_base_oid(
3121            format,
3122            pack,
3123            &mut cursor,
3124        )?))),
3125        _ => Ok(None),
3126    }
3127}
3128
3129fn parse_ref_delta_base_oid(
3130    format: ObjectFormat,
3131    pack: &[u8],
3132    cursor: &mut usize,
3133) -> Result<ObjectId> {
3134    let raw_len = format.raw_len();
3135    if *cursor + raw_len > pack.len() {
3136        return Err(GitError::InvalidFormat(
3137            "truncated ref-delta base object id".into(),
3138        ));
3139    }
3140    let oid = ObjectId::from_raw(format, &pack[*cursor..*cursor + raw_len])?;
3141    *cursor += raw_len;
3142    Ok(oid)
3143}
3144
3145fn parse_ofs_delta_base_offset(pack: &[u8], cursor: &mut usize, entry_offset: u64) -> Result<u64> {
3146    let relative = sley_core::primitives::read_biased_varint(pack, cursor).map_err(|err| {
3147        GitError::InvalidFormat(
3148            match err {
3149                sley_core::primitives::BiasedVarintError::Truncated => "truncated pack entry",
3150                sley_core::primitives::BiasedVarintError::Overflow => "ofs-delta offset overflow",
3151            }
3152            .into(),
3153        )
3154    })?;
3155    entry_offset
3156        .checked_sub(relative)
3157        .ok_or_else(|| GitError::InvalidFormat("ofs-delta points before pack start".into()))
3158}
3159
3160fn pack_next_byte(pack: &[u8], cursor: &mut usize) -> Result<u8> {
3161    let Some(byte) = pack.get(*cursor).copied() else {
3162        return Err(GitError::InvalidFormat("truncated pack entry".into()));
3163    };
3164    *cursor += 1;
3165    Ok(byte)
3166}
3167
3168/// Remove `path` if it exists, treating a missing file as success.
3169pub(crate) fn remove_file_if_exists(path: &Path) -> Result<()> {
3170    match fs::remove_file(path) {
3171        Ok(()) => Ok(()),
3172        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
3173        Err(err) => Err(GitError::from(err)),
3174    }
3175}
3176
3177fn walk_reachable_objects<R, I, F>(
3178    reader: &R,
3179    format: ObjectFormat,
3180    starts: I,
3181    excluded: &HashSet<ObjectId>,
3182    visit: F,
3183) -> Result<HashSet<ObjectId>>
3184where
3185    R: ObjectReader,
3186    I: IntoIterator<Item = ObjectId>,
3187    F: FnMut(&ObjectId, &Arc<EncodedObject>),
3188{
3189    walk_reachable_objects_with_cut(reader, format, starts, excluded, &HashSet::new(), visit)
3190}
3191
3192/// [`walk_reachable_objects`] with an additional `cut` set: commits in `cut`
3193/// are visited (their trees and blobs too) but their parents are not followed,
3194/// mirroring a shallow client's view of its own history during negotiation.
3195fn walk_reachable_objects_with_cut<R, I, F>(
3196    reader: &R,
3197    format: ObjectFormat,
3198    starts: I,
3199    excluded: &HashSet<ObjectId>,
3200    cut: &HashSet<ObjectId>,
3201    mut visit: F,
3202) -> Result<HashSet<ObjectId>>
3203where
3204    R: ObjectReader,
3205    I: IntoIterator<Item = ObjectId>,
3206    F: FnMut(&ObjectId, &Arc<EncodedObject>),
3207{
3208    let mut seen = HashSet::new();
3209    let mut pending = Vec::new();
3210    for start in starts {
3211        pending.push(start);
3212        while let Some(oid) = pending.pop() {
3213            if excluded.contains(&oid) {
3214                continue;
3215            }
3216            if !seen.insert(oid) {
3217                continue;
3218            }
3219            let object = reader.read_object(&oid).map_err(|err| {
3220                with_missing_object_context(err, oid, MissingObjectContext::Traversal)
3221            })?;
3222            match object.object_type {
3223                ObjectType::Commit => {
3224                    let (tree, parents) = {
3225                        let commit = Commit::parse_ref(format, &object.body)?;
3226                        (commit.tree, commit.parents)
3227                    };
3228                    visit(&oid, &object);
3229                    if !cut.contains(&oid) {
3230                        for parent in grafted_parents(reader, &oid, parents).into_iter().rev() {
3231                            pending.push(parent);
3232                        }
3233                    }
3234                    pending.push(tree);
3235                }
3236                ObjectType::Tree => {
3237                    let mut child_oids = Vec::new();
3238                    for entry in TreeEntries::new(format, &object.body) {
3239                        let entry = entry?;
3240                        if entry.is_gitlink() {
3241                            continue;
3242                        }
3243                        child_oids.push(entry.oid);
3244                    }
3245                    visit(&oid, &object);
3246                    pending.extend(child_oids.into_iter().rev());
3247                }
3248                ObjectType::Tag => {
3249                    let target = {
3250                        let tag = Tag::parse_ref(format, &object.body)?;
3251                        tag.object
3252                    };
3253                    visit(&oid, &object);
3254                    pending.push(target);
3255                }
3256                ObjectType::Blob => visit(&oid, &object),
3257            }
3258        }
3259    }
3260    Ok(seen)
3261}
3262
3263fn walk_reachable_objects_tolerating_missing<R, I>(
3264    reader: &R,
3265    format: ObjectFormat,
3266    starts: I,
3267) -> Result<HashSet<ObjectId>>
3268where
3269    R: ObjectReader,
3270    I: IntoIterator<Item = ObjectId>,
3271{
3272    let mut seen = HashSet::new();
3273    let mut pending: Vec<ObjectId> = starts.into_iter().collect();
3274    while let Some(oid) = pending.pop() {
3275        if !seen.insert(oid) {
3276            continue;
3277        }
3278        let object = match reader
3279            .read_object(&oid)
3280            .map_err(|err| with_missing_object_context(err, oid, MissingObjectContext::Traversal))
3281        {
3282            Ok(object) => object,
3283            Err(GitError::NotFound(_)) => continue,
3284            Err(err) => return Err(err),
3285        };
3286        match object.object_type {
3287            ObjectType::Commit => {
3288                let commit = Commit::parse_ref(format, &object.body)?;
3289                pending.extend(grafted_parents(reader, &oid, commit.parents));
3290                pending.push(commit.tree);
3291            }
3292            ObjectType::Tree => {
3293                for entry in TreeEntries::new(format, &object.body) {
3294                    let entry = entry?;
3295                    if !entry.is_gitlink() {
3296                        pending.push(entry.oid);
3297                    }
3298                }
3299            }
3300            ObjectType::Tag => {
3301                let tag = Tag::parse_ref(format, &object.body)?;
3302                pending.push(tag.object);
3303            }
3304            ObjectType::Blob => {}
3305        }
3306    }
3307    Ok(seen)
3308}
3309
3310// ===== reachability bitmaps (.bitmap write + consult) =====
3311
3312#[derive(Debug, Clone)]
3313pub struct BitmapPseudoMergeGroup {
3314    pub commits: Vec<ObjectId>,
3315    pub exclude_selected: bool,
3316    pub partition: Option<BitmapPseudoMergePartition>,
3317}
3318
3319#[derive(Debug, Clone)]
3320pub struct BitmapPseudoMergePartition {
3321    pub max_merges: usize,
3322    pub decay: f64,
3323    pub sample_rate: f64,
3324}
3325
3326/// Optional extensions for a reachability bitmap write. The hash cache is in
3327/// oid-sorted index order (pack index or MIDX OIDL order), independent of the
3328/// bitmap's pack-order bit numbering.
3329#[derive(Debug, Clone, Default)]
3330pub struct ReachabilityBitmapOptions {
3331    pub write_lookup_table: bool,
3332    pub name_hash_cache: Option<Vec<u32>>,
3333    /// Limit commit selection to the ancestry visible from `preferred_tips`.
3334    /// Used for `multi-pack-index --refs-snapshot`, where the snapshot replaces
3335    /// the live ref universe rather than merely influencing selection.
3336    pub restrict_to_tips: bool,
3337}
3338
3339/// Bit accessors over a `Vec<u64>` bitset using git's bitmap convention:
3340/// bit `i` lives in word `i / 64` at bit `i % 64` (LSB-first within a word).
3341fn bitset_get(words: &[u64], position: u32) -> bool {
3342    let word = (position / 64) as usize;
3343    word < words.len() && words[word] & (1u64 << (position % 64)) != 0
3344}
3345
3346fn bitset_set(words: &mut [u64], position: u32) {
3347    let word = (position / 64) as usize;
3348    if word < words.len() {
3349        words[word] |= 1u64 << (position % 64);
3350    }
3351}
3352
3353fn bitset_or(acc: &mut [u64], other: &[u64]) {
3354    for (dst, src) in acc.iter_mut().zip(other) {
3355        *dst |= *src;
3356    }
3357}
3358
3359fn bitset_is_subset(needles: &[u64], haystack: &[u64]) -> bool {
3360    needles
3361        .iter()
3362        .zip(haystack)
3363        .all(|(needle, hay)| needle & !hay == 0)
3364}
3365
3366/// Sorted set-bit positions of a bitset (the inverse of repeated [`bitset_set`]).
3367fn bitset_positions(words: &[u64]) -> Vec<u32> {
3368    let mut positions = Vec::new();
3369    for (word_index, word) in words.iter().enumerate() {
3370        let mut remaining = *word;
3371        while remaining != 0 {
3372            let bit = remaining.trailing_zeros();
3373            positions.push(word_index as u32 * 64 + bit);
3374            remaining &= remaining - 1;
3375        }
3376    }
3377    positions
3378}
3379
3380/// Committer timestamp (epoch seconds) of a commit identity line
3381/// (`Name <email> <timestamp> <tz>`); 0 when unparseable, matching git's
3382/// tolerance for bogus dates during bitmap commit selection.
3383fn commit_identity_timestamp(identity: &[u8]) -> i64 {
3384    let mut fields = identity.rsplitn(3, |byte| *byte == b' ');
3385    let _tz = fields.next();
3386    fields
3387        .next()
3388        .and_then(|raw| std::str::from_utf8(raw).ok())
3389        .and_then(|raw| raw.parse::<i64>().ok())
3390        .unwrap_or(0)
3391}
3392
3393/// Upstream `next_commit_index` (pack-bitmap-write.c): the spacing schedule for
3394/// bitmap commit selection over the date-descending commit list.
3395fn bitmap_next_commit_index(idx: u32) -> u32 {
3396    const MIN_COMMITS: u32 = 100;
3397    const MAX_COMMITS: u32 = 5000;
3398    const MUST_REGION: u32 = 100;
3399    const MIN_REGION: u32 = 20000;
3400
3401    if idx <= MUST_REGION {
3402        return 0;
3403    }
3404    if idx <= MIN_REGION {
3405        let offset = idx - MUST_REGION;
3406        return offset.min(MIN_COMMITS);
3407    }
3408    let offset = idx - MIN_REGION;
3409    offset.clamp(MIN_COMMITS, MAX_COMMITS)
3410}
3411
3412/// Builds a serialised `.bitmap` for the pack described by `index_entries` /
3413/// `pack_checksum`, mirroring upstream pack-bitmap-write.c:
3414///
3415/// * commit selection walks the pack's commits in committer-date-descending
3416///   order through `bitmap_next_commit_index`'s spacing schedule, preferring
3417///   `preferred_tips` (ref tips — upstream's `NEEDS_BITMAP`) and merge commits
3418///   inside each window;
3419/// * each selected commit stores its full reachability closure (commits, trees,
3420///   blobs) as pack-order bit positions (no XOR compression — `xor_offset` 0 is
3421///   valid on disk and what readers see after resolution anyway).
3422///
3423/// Returns `Ok(None)` — mirroring upstream's warn-and-skip — when the pack
3424/// lacks full closure (a reachable object is missing from it).
3425pub fn build_pack_bitmap(
3426    db: &FileObjectDatabase,
3427    format: ObjectFormat,
3428    index_entries: &[PackIndexEntry],
3429    pack_checksum: &ObjectId,
3430    preferred_tips: &HashSet<ObjectId>,
3431    pseudo_merge_groups: &[BitmapPseudoMergeGroup],
3432) -> Result<Option<Vec<u8>>> {
3433    // `index_entries` carries no ordering guarantee (writer provenance is in
3434    // pack-write order); bit numbering follows pack (offset) order.
3435    let mut by_offset: Vec<usize> = (0..index_entries.len()).collect();
3436    by_offset.sort_by_key(|&slot| index_entries[slot].offset);
3437    let bit_order: Vec<ObjectId> = by_offset
3438        .into_iter()
3439        .map(|slot| index_entries[slot].oid)
3440        .collect();
3441    build_reachability_bitmap(
3442        db,
3443        BitmapObjectTypes::Database(db),
3444        format,
3445        pack_checksum,
3446        &bit_order,
3447        preferred_tips,
3448        pseudo_merge_groups,
3449        &ReachabilityBitmapOptions::default(),
3450    )
3451}
3452
3453/// Compute the bitmap name-hash cache in pack-index (oid-sorted) order.
3454/// Commits and unnamed root trees retain Git's zero sentinel; named tree/blob
3455/// objects receive the v1 pack-name hash of the first path encountered while
3456/// walking packed commit trees.
3457pub(crate) fn build_pack_name_hash_cache<R: ObjectReader>(
3458    db: &R,
3459    format: ObjectFormat,
3460    index_entries: &[PackIndexEntry],
3461    known_object_types: &HashMap<ObjectId, ObjectType>,
3462) -> Result<Vec<u32>> {
3463    let packed_objects = index_entries
3464        .iter()
3465        .map(|entry| {
3466            known_object_types
3467                .get(&entry.oid)
3468                .copied()
3469                .map(|object_type| (entry.oid, object_type))
3470                .ok_or_else(|| {
3471                    GitError::InvalidFormat(format!(
3472                        "repack bitmap type cache is missing packed object {}",
3473                        entry.oid
3474                    ))
3475                })
3476        })
3477        .collect::<Result<Vec<_>>>()?;
3478    let by_oid = build_pack_name_hashes(db, format, &packed_objects)?;
3479    let mut sorted: Vec<&PackIndexEntry> = index_entries.iter().collect();
3480    sorted.sort_by(|left, right| left.oid.as_bytes().cmp(right.oid.as_bytes()));
3481    Ok(sorted
3482        .into_iter()
3483        .map(|entry| by_oid.get(&entry.oid).copied().unwrap_or(0))
3484        .collect())
3485}
3486
3487/// Compute Git's v1 pack-name hash for each named tree/blob object in a pack.
3488/// Commits, tags, and unnamed root trees retain the implicit zero hash.
3489pub(crate) fn build_pack_name_hashes<R: ObjectReader>(
3490    db: &R,
3491    format: ObjectFormat,
3492    packed_objects: &[(ObjectId, ObjectType)],
3493) -> Result<HashMap<ObjectId, u32>> {
3494    let packed: HashSet<ObjectId> = packed_objects.iter().map(|(oid, _)| *oid).collect();
3495    let mut by_oid = HashMap::new();
3496    let mut seen_trees = HashSet::new();
3497    for (oid, object_type) in packed_objects {
3498        if *object_type != ObjectType::Commit {
3499            continue;
3500        }
3501        let object = db.read_object(oid)?;
3502        let commit = Commit::parse_ref(format, &object.body)?;
3503        collect_tree_name_hashes(
3504            db,
3505            format,
3506            &commit.tree,
3507            &[],
3508            &packed,
3509            &mut seen_trees,
3510            &mut by_oid,
3511        )?;
3512    }
3513    Ok(by_oid)
3514}
3515
3516fn collect_tree_name_hashes<R: ObjectReader>(
3517    db: &R,
3518    format: ObjectFormat,
3519    tree: &ObjectId,
3520    prefix: &[u8],
3521    packed: &HashSet<ObjectId>,
3522    seen_trees: &mut HashSet<ObjectId>,
3523    by_oid: &mut HashMap<ObjectId, u32>,
3524) -> Result<()> {
3525    if !seen_trees.insert(*tree) {
3526        return Ok(());
3527    }
3528    let object = db.read_object(tree)?;
3529    for entry in TreeEntries::new(format, &object.body) {
3530        let entry = entry?;
3531        if entry.is_gitlink() {
3532            continue;
3533        }
3534        let mut path =
3535            Vec::with_capacity(prefix.len() + usize::from(!prefix.is_empty()) + entry.name.len());
3536        path.extend_from_slice(prefix);
3537        if !prefix.is_empty() {
3538            path.push(b'/');
3539        }
3540        path.extend_from_slice(entry.name);
3541        if packed.contains(&entry.oid) {
3542            by_oid
3543                .entry(entry.oid)
3544                .or_insert_with(|| pack_name_hash(&path));
3545        }
3546        if entry.is_tree() {
3547            collect_tree_name_hashes(db, format, &entry.oid, &path, packed, seen_trees, by_oid)?;
3548        }
3549    }
3550    Ok(())
3551}
3552
3553pub(crate) fn pack_name_hash(name: &[u8]) -> u32 {
3554    let mut hash = 0u32;
3555    for &byte in name {
3556        if byte.is_ascii_whitespace() {
3557            continue;
3558        }
3559        hash = (hash >> 2).wrapping_add(u32::from(byte) << 24);
3560    }
3561    hash
3562}
3563
3564/// [`build_pack_bitmap`]'s multi-pack sibling: builds the serialised
3565/// `multi-pack-index-<checksum>.bitmap` for `midx_entries`, with bits in
3566/// pseudo-pack order (preferred pack first, then pack id, then offset — the
3567/// same order [`MultiPackIndex::write_with_reverse_index`] records in `RIDX`)
3568/// and the midx checksum in the BITM checksum field.
3569pub fn build_midx_bitmap(
3570    db: &FileObjectDatabase,
3571    format: ObjectFormat,
3572    midx_entries: &[sley_pack::MultiPackIndexEntry],
3573    midx_checksum: &ObjectId,
3574    preferred_pack: u32,
3575    preferred_tips: &HashSet<ObjectId>,
3576    pseudo_merge_groups: &[BitmapPseudoMergeGroup],
3577) -> Result<Option<Vec<u8>>> {
3578    build_midx_bitmap_with_options(
3579        db,
3580        format,
3581        midx_entries,
3582        midx_checksum,
3583        preferred_pack,
3584        preferred_tips,
3585        pseudo_merge_groups,
3586        &ReachabilityBitmapOptions::default(),
3587    )
3588}
3589
3590#[allow(clippy::too_many_arguments)]
3591pub fn build_midx_bitmap_with_options(
3592    db: &FileObjectDatabase,
3593    format: ObjectFormat,
3594    midx_entries: &[sley_pack::MultiPackIndexEntry],
3595    midx_checksum: &ObjectId,
3596    preferred_pack: u32,
3597    preferred_tips: &HashSet<ObjectId>,
3598    pseudo_merge_groups: &[BitmapPseudoMergeGroup],
3599    options: &ReachabilityBitmapOptions,
3600) -> Result<Option<Vec<u8>>> {
3601    let mut pseudo: Vec<usize> = (0..midx_entries.len()).collect();
3602    pseudo.sort_by_key(|&slot| {
3603        let entry = &midx_entries[slot];
3604        (
3605            entry.pack_int_id != preferred_pack,
3606            entry.pack_int_id,
3607            entry.offset,
3608        )
3609    });
3610    let bit_order: Vec<ObjectId> = pseudo
3611        .into_iter()
3612        .map(|slot| midx_entries[slot].oid)
3613        .collect();
3614    build_reachability_bitmap(
3615        db,
3616        BitmapObjectTypes::Database(db),
3617        format,
3618        midx_checksum,
3619        &bit_order,
3620        preferred_tips,
3621        pseudo_merge_groups,
3622        options,
3623    )
3624}
3625
3626/// Repack fast path for bitmap construction. The repack walk has already read
3627/// every object which was fed to the pack writer, so retaining those shared
3628/// object handles until installation avoids reopening the new ODB and decoding
3629/// the same commits and trees a second time.
3630#[allow(clippy::too_many_arguments)]
3631pub(crate) fn build_pack_bitmap_with_cached_objects<R: ObjectReader>(
3632    db: &R,
3633    format: ObjectFormat,
3634    index_entries: &[PackIndexEntry],
3635    pack_checksum: &ObjectId,
3636    preferred_tips: &HashSet<ObjectId>,
3637    pseudo_merge_groups: &[BitmapPseudoMergeGroup],
3638    known_object_types: &HashMap<ObjectId, ObjectType>,
3639    options: &ReachabilityBitmapOptions,
3640) -> Result<Option<Vec<u8>>> {
3641    let mut by_offset: Vec<usize> = (0..index_entries.len()).collect();
3642    by_offset.sort_by_key(|&slot| index_entries[slot].offset);
3643    let bit_order: Vec<ObjectId> = by_offset
3644        .into_iter()
3645        .map(|slot| index_entries[slot].oid)
3646        .collect();
3647    build_reachability_bitmap(
3648        db,
3649        BitmapObjectTypes::Cached(known_object_types),
3650        format,
3651        pack_checksum,
3652        &bit_order,
3653        preferred_tips,
3654        pseudo_merge_groups,
3655        options,
3656    )
3657}
3658
3659enum BitmapObjectTypes<'a> {
3660    Database(&'a FileObjectDatabase),
3661    Cached(&'a HashMap<ObjectId, ObjectType>),
3662}
3663
3664/// Upstream `bitmap_builder_init`'s `num_maximal` counter (pack-bitmap-write.c):
3665/// walk the first-parent ancestry of the selected commits, children before
3666/// parents, propagating per-commit "which selected commits reach me" masks.
3667/// A commit counts as maximal when it is selected, or when distinct selected
3668/// lineages converge on it (its mask gains bits its last contributing child
3669/// did not carry). Only the count is needed (for the trace2 data event), so no
3670/// reverse-edge bookkeeping is kept.
3671fn bitmap_num_maximal_commits(
3672    db: &impl ObjectReader,
3673    format: ObjectFormat,
3674    selected: &[ObjectId],
3675) -> Result<usize> {
3676    // First-parent subgraph reachable from the selected commits.
3677    let mut first_parent: HashMap<ObjectId, Option<ObjectId>> = HashMap::new();
3678    let mut stack: Vec<ObjectId> = selected.to_vec();
3679    while let Some(oid) = stack.pop() {
3680        if first_parent.contains_key(&oid) {
3681            continue;
3682        }
3683        let object = db.read_object(&oid)?;
3684        let commit = Commit::parse_ref(format, &object.body)?;
3685        let parent = grafted_parents(db, &oid, commit.parents).first().copied();
3686        first_parent.insert(oid, parent);
3687        if let Some(parent) = parent {
3688            stack.push(parent);
3689        }
3690    }
3691    // Children-before-parents order (Kahn over the single first-parent edge).
3692    let mut pending_children: HashMap<ObjectId, usize> = HashMap::new();
3693    for parent in first_parent.values().flatten() {
3694        *pending_children.entry(*parent).or_default() += 1;
3695    }
3696    let word_count = selected.len().div_ceil(64);
3697    struct MaximalEnt {
3698        mask: Vec<u64>,
3699        maximal: bool,
3700    }
3701    let mut ents: HashMap<ObjectId, MaximalEnt> = HashMap::new();
3702    for (bit, oid) in selected.iter().enumerate() {
3703        let ent = ents.entry(*oid).or_insert_with(|| MaximalEnt {
3704            mask: vec![0u64; word_count],
3705            maximal: true,
3706        });
3707        ent.mask[bit / 64] |= 1u64 << (bit % 64);
3708        ent.maximal = true;
3709    }
3710    let mut queue: Vec<ObjectId> = first_parent
3711        .keys()
3712        .filter(|oid| pending_children.get(*oid).copied().unwrap_or(0) == 0)
3713        .copied()
3714        .collect();
3715    let mut num_maximal = 0usize;
3716    while let Some(oid) = queue.pop() {
3717        if let Some(ent) = ents.remove(&oid) {
3718            if ent.maximal {
3719                num_maximal += 1;
3720            }
3721            if let Some(Some(parent)) = first_parent.get(&oid) {
3722                match ents.entry(*parent) {
3723                    std::collections::hash_map::Entry::Vacant(vacant) => {
3724                        // Fresh parent mask: c_not_p, !p_not_c -> not maximal.
3725                        vacant.insert(MaximalEnt {
3726                            mask: ent.mask.clone(),
3727                            maximal: false,
3728                        });
3729                    }
3730                    std::collections::hash_map::Entry::Occupied(mut occupied) => {
3731                        let parent_ent = occupied.get_mut();
3732                        let c_not_p = ent
3733                            .mask
3734                            .iter()
3735                            .zip(&parent_ent.mask)
3736                            .any(|(child, parent)| child & !parent != 0);
3737                        if c_not_p {
3738                            let p_not_c = parent_ent
3739                                .mask
3740                                .iter()
3741                                .zip(&ent.mask)
3742                                .any(|(parent, child)| parent & !child != 0);
3743                            for (parent, child) in parent_ent.mask.iter_mut().zip(&ent.mask) {
3744                                *parent |= child;
3745                            }
3746                            parent_ent.maximal = p_not_c;
3747                        }
3748                    }
3749                }
3750            }
3751        }
3752        if let Some(Some(parent)) = first_parent.get(&oid)
3753            && let Some(remaining) = pending_children.get_mut(parent)
3754        {
3755            *remaining -= 1;
3756            if *remaining == 0 {
3757                queue.push(*parent);
3758            }
3759        }
3760    }
3761    Ok(num_maximal)
3762}
3763
3764/// Shared write half: `bit_order` lists every covered object's oid in bit
3765/// order (pack order for a single pack, pseudo-pack order for a midx);
3766/// `checksum` fills the BITM checksum field (pack checksum / midx checksum).
3767#[allow(clippy::too_many_arguments)]
3768fn build_reachability_bitmap<R: ObjectReader>(
3769    db: &R,
3770    object_types_source: BitmapObjectTypes<'_>,
3771    format: ObjectFormat,
3772    checksum: &ObjectId,
3773    bit_order: &[ObjectId],
3774    preferred_tips: &HashSet<ObjectId>,
3775    pseudo_merge_groups: &[BitmapPseudoMergeGroup],
3776    options: &ReachabilityBitmapOptions,
3777) -> Result<Option<Vec<u8>>> {
3778    if bit_order.is_empty() || bit_order.len() > u32::MAX as usize {
3779        return Ok(None);
3780    }
3781    let object_count = bit_order.len();
3782
3783    // The on-disk entry position space is the oid-sorted lookup order (.idx /
3784    // midx OIDL); derive each bit-order slot's rank there.
3785    let mut oid_sorted: Vec<u32> = (0..object_count as u32).collect();
3786    oid_sorted.sort_by(|&left, &right| {
3787        bit_order[left as usize]
3788            .as_bytes()
3789            .cmp(bit_order[right as usize].as_bytes())
3790    });
3791    let mut index_position = vec![0u32; object_count];
3792    for (position, &slot) in oid_sorted.iter().enumerate() {
3793        index_position[slot as usize] = position as u32;
3794    }
3795    let mut oid_to_pack = HashMap::with_capacity(object_count);
3796    for (pack_pos, oid) in bit_order.iter().enumerate() {
3797        oid_to_pack.insert(*oid, pack_pos as u32);
3798    }
3799    // Object types in bit order; commits also collect (date, parent count).
3800    let mut object_types = Vec::with_capacity(object_count);
3801    struct IndexedCommit {
3802        oid: ObjectId,
3803        pack_pos: u32,
3804        index_pos: u32,
3805        date: i64,
3806        parent_count: usize,
3807    }
3808    let mut indexed_commits = Vec::new();
3809    for (pack_pos, oid) in bit_order.iter().enumerate() {
3810        // Type via the header fast path: blobs (the bulk of most packs) never
3811        // need their bodies inflated here.
3812        let object_type = match &object_types_source {
3813            BitmapObjectTypes::Database(database) => match database.read_object_header(oid)? {
3814                Some((object_type, _)) => object_type,
3815                None => db.read_object(oid)?.object_type,
3816            },
3817            BitmapObjectTypes::Cached(object_types) => {
3818                object_types.get(oid).copied().ok_or_else(|| {
3819                    GitError::InvalidFormat(format!(
3820                        "repack bitmap cache is missing packed object {oid}"
3821                    ))
3822                })?
3823            }
3824        };
3825        object_types.push(object_type);
3826        if object_type == ObjectType::Commit {
3827            let object = db.read_object(oid)?;
3828            let commit = Commit::parse_ref(format, &object.body)?;
3829            indexed_commits.push(IndexedCommit {
3830                oid: *oid,
3831                pack_pos: pack_pos as u32,
3832                index_pos: index_position[pack_pos],
3833                date: commit_identity_timestamp(commit.committer),
3834                parent_count: grafted_parents(db, oid, commit.parents).len(),
3835            });
3836        }
3837    }
3838    if options.restrict_to_tips {
3839        let visible = bitmap_visible_commits(db, format, preferred_tips)?;
3840        indexed_commits.retain(|commit| visible.contains(&commit.oid));
3841    }
3842
3843    // Selection: date-descending, then the spacing schedule.
3844    indexed_commits.sort_by_key(|commit| std::cmp::Reverse(commit.date));
3845    let mut selected: Vec<&IndexedCommit> = Vec::new();
3846    let commit_count = indexed_commits.len() as u32;
3847    if commit_count < 100 {
3848        selected.extend(indexed_commits.iter());
3849    } else {
3850        let mut i = 0u32;
3851        loop {
3852            let next = bitmap_next_commit_index(i);
3853            if i + next >= commit_count {
3854                break;
3855            }
3856            let mut chosen = &indexed_commits[(i + next) as usize];
3857            if next > 0 {
3858                for j in 0..=next {
3859                    let candidate = &indexed_commits[(i + j) as usize];
3860                    if preferred_tips.contains(&candidate.oid) {
3861                        chosen = candidate;
3862                        break;
3863                    }
3864                    if candidate.parent_count >= 2 {
3865                        chosen = candidate;
3866                    }
3867                }
3868            }
3869            selected.push(chosen);
3870            i += next + 1;
3871        }
3872    }
3873
3874    // Trace2 selection counters (upstream bitmap_builder_init): emitted before
3875    // the closure walk, like upstream emits them before building the ewah
3876    // bitmaps. Computing num_maximal_commits needs its own first-parent walk,
3877    // so it only runs when the trace2 event target is active.
3878    if std::env::var_os("GIT_TRACE2_EVENT").is_some() {
3879        let selected_oids: Vec<ObjectId> = selected.iter().map(|commit| commit.oid).collect();
3880        let num_maximal = bitmap_num_maximal_commits(db, format, &selected_oids)?;
3881        sley_core::trace2::data("pack-bitmap-write", "num_selected_commits", selected.len());
3882        sley_core::trace2::data("pack-bitmap-write", "num_maximal_commits", num_maximal);
3883        let reusable_pseudo_merges = pseudo_merge_groups
3884            .iter()
3885            .filter(|group| !group.exclude_selected)
3886            .count();
3887        sley_core::trace2::data(
3888            "pack-bitmap-write",
3889            "building_bitmaps_pseudo_merge_reused",
3890            reusable_pseudo_merges,
3891        );
3892    }
3893
3894    // Reachability closures, oldest-first so newer walks stop at memoised
3895    // older selected commits.
3896    let word_count = object_count.div_ceil(64);
3897    let mut memo: HashMap<ObjectId, Arc<Vec<u64>>> = HashMap::new();
3898    for commit in selected.iter().rev() {
3899        let Some(acc) =
3900            bitmap_commit_closure(db, format, &[commit.oid], &oid_to_pack, word_count, &memo)?
3901        else {
3902            return Ok(None);
3903        };
3904        memo.insert(commit.oid, Arc::new(acc));
3905    }
3906
3907    let mut writer = PackBitmapWriter::new(format, *checksum, &object_types)?
3908        .with_lookup_table(options.write_lookup_table);
3909    if let Some(cache) = &options.name_hash_cache {
3910        writer = writer.with_name_hash_cache(cache.clone())?;
3911    }
3912    for commit in &selected {
3913        let words = match memo.get(&commit.oid) {
3914            Some(words) => words,
3915            None => continue,
3916        };
3917        writer.add_commit(commit.pack_pos, commit.index_pos, &bitset_positions(words))?;
3918    }
3919    if !pseudo_merge_groups.is_empty() {
3920        let selected_oids: HashSet<ObjectId> = selected.iter().map(|commit| commit.oid).collect();
3921        for group in pseudo_merge_groups {
3922            let mut commits = Vec::new();
3923            for oid in &group.commits {
3924                if group.exclude_selected && selected_oids.contains(oid) {
3925                    continue;
3926                }
3927                let Some(&pack_pos) = oid_to_pack.get(oid) else {
3928                    continue;
3929                };
3930                if object_types.get(pack_pos as usize) != Some(&ObjectType::Commit) {
3931                    continue;
3932                }
3933                commits.push((*oid, pack_pos));
3934            }
3935            if commits.is_empty() {
3936                continue;
3937            }
3938            if let Some(partition) = &group.partition {
3939                let mut start = 0usize;
3940                for merge_index in 0..partition.max_merges {
3941                    if start >= commits.len() {
3942                        break;
3943                    }
3944                    let size = bitmap_pseudo_merge_group_size(
3945                        partition.max_merges,
3946                        partition.decay,
3947                        commits.len(),
3948                        merge_index,
3949                    );
3950                    let end = if size < 8 {
3951                        commits.len()
3952                    } else {
3953                        start.saturating_add(size).min(commits.len())
3954                    };
3955                    let sample_stride = if partition.sample_rate <= 0.0 {
3956                        usize::MAX
3957                    } else {
3958                        ((1.0 / partition.sample_rate) as usize).max(1)
3959                    };
3960                    let sampled: Vec<(ObjectId, u32)> = commits[start..end]
3961                        .iter()
3962                        .enumerate()
3963                        .filter(|(offset, _candidate)| *offset % sample_stride == 0)
3964                        .map(|(_offset, candidate)| *candidate)
3965                        .collect();
3966                    if !sampled.is_empty()
3967                        && !bitmap_add_pseudo_merge(
3968                            &mut writer,
3969                            db,
3970                            format,
3971                            &sampled,
3972                            &oid_to_pack,
3973                            word_count,
3974                            &memo,
3975                        )?
3976                    {
3977                        return Ok(None);
3978                    }
3979                    start = end;
3980                    if end >= commits.len() {
3981                        break;
3982                    }
3983                }
3984            } else if !bitmap_add_pseudo_merge(
3985                &mut writer,
3986                db,
3987                format,
3988                &commits,
3989                &oid_to_pack,
3990                word_count,
3991                &memo,
3992            )? {
3993                return Ok(None);
3994            }
3995        }
3996    }
3997    writer.write().map(Some)
3998}
3999
4000fn bitmap_visible_commits(
4001    db: &impl ObjectReader,
4002    format: ObjectFormat,
4003    tips: &HashSet<ObjectId>,
4004) -> Result<HashSet<ObjectId>> {
4005    let mut visible = HashSet::new();
4006    let mut pending: Vec<ObjectId> = tips.iter().copied().collect();
4007    while let Some(oid) = pending.pop() {
4008        if !visible.insert(oid) {
4009            continue;
4010        }
4011        let object = db.read_object(&oid)?;
4012        let commit = Commit::parse_ref(format, &object.body)?;
4013        pending.extend(grafted_parents(db, &oid, commit.parents));
4014    }
4015    Ok(visible)
4016}
4017
4018fn bitmap_pseudo_merge_group_size(
4019    max_merges: usize,
4020    decay: f64,
4021    unstable_len: usize,
4022    index: usize,
4023) -> usize {
4024    let mut scale = 0.0;
4025    for n in 0..max_merges {
4026        scale += 1.0 / ((n + 1) as f64).powf(decay);
4027    }
4028    if scale == 0.0 {
4029        return 0;
4030    }
4031    ((unstable_len as f64 / scale) / ((index + 1) as f64).powf(decay) + 0.5) as usize
4032}
4033
4034fn bitmap_add_pseudo_merge(
4035    writer: &mut PackBitmapWriter,
4036    db: &impl ObjectReader,
4037    format: ObjectFormat,
4038    commits: &[(ObjectId, u32)],
4039    oid_to_pack: &HashMap<ObjectId, u32>,
4040    word_count: usize,
4041    memo: &HashMap<ObjectId, Arc<Vec<u64>>>,
4042) -> Result<bool> {
4043    let roots: Vec<ObjectId> = commits.iter().map(|(oid, _position)| *oid).collect();
4044    let Some(words) = bitmap_commit_closure(db, format, &roots, oid_to_pack, word_count, memo)?
4045    else {
4046        return Ok(false);
4047    };
4048    let commit_positions: Vec<u32> = commits.iter().map(|(_oid, position)| *position).collect();
4049    writer.add_pseudo_merge(&commit_positions, &bitset_positions(&words))?;
4050    Ok(true)
4051}
4052
4053fn bitmap_commit_closure(
4054    db: &impl ObjectReader,
4055    format: ObjectFormat,
4056    roots: &[ObjectId],
4057    oid_to_pack: &HashMap<ObjectId, u32>,
4058    word_count: usize,
4059    memo: &HashMap<ObjectId, Arc<Vec<u64>>>,
4060) -> Result<Option<Vec<u64>>> {
4061    let mut acc = vec![0u64; word_count];
4062    let mut pending = roots.to_vec();
4063    while let Some(oid) = pending.pop() {
4064        let Some(&pack_pos) = oid_to_pack.get(&oid) else {
4065            sley_core::diagnostic!(
4066                Stderr,
4067                true,
4068                "warning: Failed to write bitmap index. Packfile doesn't have full closure (object {oid} is missing)"
4069            );
4070            return Ok(None);
4071        };
4072        if bitset_get(&acc, pack_pos) {
4073            continue;
4074        }
4075        if let Some(stored) = memo.get(&oid) {
4076            bitset_or(&mut acc, stored);
4077            continue;
4078        }
4079        bitset_set(&mut acc, pack_pos);
4080        let object = db.read_object(&oid)?;
4081        let parsed = Commit::parse_ref(format, &object.body)?;
4082        pending.extend(grafted_parents(db, &oid, parsed.parents));
4083        if !bitmap_mark_tree(db, format, &parsed.tree, oid_to_pack, &mut acc)? {
4084            return Ok(None);
4085        }
4086    }
4087    Ok(Some(acc))
4088}
4089
4090/// Marks `tree` and everything below it (sub-trees, blobs) in `acc`, skipping
4091/// already-set bits (their closure is already covered). Returns `false` when an
4092/// object is missing from the pack (no full closure), after warning.
4093fn bitmap_mark_tree(
4094    db: &impl ObjectReader,
4095    format: ObjectFormat,
4096    tree: &ObjectId,
4097    oid_to_pack: &HashMap<ObjectId, u32>,
4098    acc: &mut [u64],
4099) -> Result<bool> {
4100    let Some(&pack_pos) = oid_to_pack.get(tree) else {
4101        sley_core::diagnostic!(
4102            Stderr,
4103            true,
4104            "warning: Failed to write bitmap index. Packfile doesn't have full closure (object {tree} is missing)"
4105        );
4106        return Ok(false);
4107    };
4108    if bitset_get(acc, pack_pos) {
4109        return Ok(true);
4110    }
4111    bitset_set(acc, pack_pos);
4112    let object = db.read_object(tree)?;
4113    for entry in TreeEntries::new(format, &object.body) {
4114        let entry = entry?;
4115        if entry.is_gitlink() {
4116            continue;
4117        }
4118        if entry.is_tree() {
4119            if !bitmap_mark_tree(db, format, &entry.oid, oid_to_pack, acc)? {
4120                return Ok(false);
4121            }
4122        } else {
4123            let Some(&blob_pos) = oid_to_pack.get(&entry.oid) else {
4124                sley_core::diagnostic!(
4125                    Stderr,
4126                    true,
4127                    "warning: Failed to write bitmap index. Packfile doesn't have full closure (object {} is missing)",
4128                    entry.oid
4129                );
4130                return Ok(false);
4131            };
4132            bitset_set(acc, blob_pos);
4133        }
4134    }
4135    Ok(true)
4136}
4137
4138/// A pack's `.bitmap` loaded for consultation: oid <-> pack-position mappings,
4139/// resolved (XOR-expanded) per-commit reachability bitsets, and the four object
4140/// type bitmaps. Bit numbering follows pack order throughout.
4141pub struct LoadedPackBitmap {
4142    object_count: u32,
4143    oid_to_pack: HashMap<ObjectId, u32>,
4144    pack_to_oid: Vec<ObjectId>,
4145    commit_words: HashMap<ObjectId, Arc<Vec<u64>>>,
4146    pseudo_merges: Vec<LoadedPseudoMerge>,
4147    commits: Vec<u64>,
4148    trees: Vec<u64>,
4149    blobs: Vec<u64>,
4150    tags: Vec<u64>,
4151}
4152
4153struct LoadedPseudoMerge {
4154    commits: Arc<Vec<u64>>,
4155    bitmap: Arc<Vec<u64>>,
4156}
4157
4158impl LoadedPackBitmap {
4159    pub fn object_count(&self) -> u32 {
4160        self.object_count
4161    }
4162
4163    /// Pack-order position of `oid`, when the object is in the bitmapped pack.
4164    pub fn pack_position(&self, oid: &ObjectId) -> Option<u32> {
4165        self.oid_to_pack.get(oid).copied()
4166    }
4167
4168    pub fn oid_at(&self, position: u32) -> Option<&ObjectId> {
4169        self.pack_to_oid.get(position as usize)
4170    }
4171
4172    /// The resolved reachability bitset stored for `oid`, when it was one of
4173    /// the writer's selected commits.
4174    pub fn bitmap_for_commit(&self, oid: &ObjectId) -> Option<&Arc<Vec<u64>>> {
4175        self.commit_words.get(oid)
4176    }
4177
4178    /// Oids of every commit with a stored bitmap entry (unordered).
4179    pub fn bitmapped_commits(&self) -> impl Iterator<Item = &ObjectId> {
4180        self.commit_words.keys()
4181    }
4182
4183    pub fn pseudo_merge_count(&self) -> usize {
4184        self.pseudo_merges.len()
4185    }
4186
4187    pub fn pseudo_merge_words(&self, index: usize) -> Option<(&[u64], &[u64])> {
4188        self.pseudo_merges
4189            .get(index)
4190            .map(|merge| (merge.commits.as_slice(), merge.bitmap.as_slice()))
4191    }
4192
4193    /// The type bitmap for `object_type` (bit per pack position).
4194    pub fn type_words(&self, object_type: ObjectType) -> &[u64] {
4195        match object_type {
4196            ObjectType::Commit => &self.commits,
4197            ObjectType::Tree => &self.trees,
4198            ObjectType::Blob => &self.blobs,
4199            ObjectType::Tag => &self.tags,
4200        }
4201    }
4202
4203    fn word_count(&self) -> usize {
4204        (self.object_count as usize).div_ceil(64)
4205    }
4206}
4207
4208/// Loads the single-pack `.bitmap` of `objects_dir/pack`, if a valid one
4209/// exists. Scans `pack-*.bitmap` files (sorted, first valid wins, like
4210/// upstream's "first bitmap" behaviour), requires the sibling `.idx`, and
4211/// verifies the recorded pack checksum. Any unreadable/corrupt bitmap yields
4212/// `Ok(None)` — consumers fall back to a regular object walk, mirroring
4213/// upstream's warn-and-ignore on bitmap load failure.
4214pub fn load_pack_bitmap(
4215    objects_dir: &Path,
4216    format: ObjectFormat,
4217) -> Result<Option<LoadedPackBitmap>> {
4218    let pack_dir = objects_dir.join("pack");
4219    if !pack_dir.exists() {
4220        return Ok(None);
4221    }
4222    // A multi-pack bitmap wins over single-pack bitmaps, like upstream's
4223    // open_bitmap trying the midx first.
4224    if let Some(bitmap) = load_incremental_midx_bitmap(&pack_dir, format)? {
4225        return Ok(Some(bitmap));
4226    }
4227    if let Some(bitmap) = load_midx_bitmap(&pack_dir, format)? {
4228        return Ok(Some(bitmap));
4229    }
4230    let mut bitmap_paths = Vec::new();
4231    for entry in fs::read_dir(&pack_dir)? {
4232        let path = entry?.path();
4233        if path.extension().and_then(|ext| ext.to_str()) == Some("bitmap")
4234            && path
4235                .file_name()
4236                .and_then(|name| name.to_str())
4237                .is_some_and(|name| name.starts_with("pack-"))
4238        {
4239            bitmap_paths.push(path);
4240        }
4241    }
4242    bitmap_paths.sort();
4243    let has_extra_bitmap = bitmap_paths.len() > 1;
4244    for bitmap_path in bitmap_paths {
4245        match load_pack_bitmap_file(&bitmap_path, format) {
4246            Ok(Some(bitmap)) => {
4247                sley_core::trace2::data("bitmap", "message", "opened bitmap");
4248                if has_extra_bitmap {
4249                    sley_core::trace2::data("bitmap", "message", "ignoring extra bitmap");
4250                }
4251                return Ok(Some(bitmap));
4252            }
4253            Ok(None) => continue,
4254            Err(err) => {
4255                let message = err.to_string();
4256                if message.contains("EWAH") {
4257                    sley_core::diagnostic!(Stderr, true, "error: corrupt ewah bitmap: {message}");
4258                } else {
4259                    sley_core::diagnostic!(
4260                        Stderr,
4261                        true,
4262                        "error: corrupted bitmap index: {message}"
4263                    );
4264                }
4265                continue;
4266            }
4267        }
4268    }
4269    Ok(None)
4270}
4271
4272fn load_incremental_midx_bitmap(
4273    pack_dir: &Path,
4274    format: ObjectFormat,
4275) -> Result<Option<LoadedPackBitmap>> {
4276    let chain = read_incremental_midx_chain(pack_dir)?;
4277    if chain.is_empty() {
4278        return Ok(None);
4279    }
4280    let midx_dir = pack_dir.join("multi-pack-index.d");
4281    if !chain.iter().any(|checksum| {
4282        midx_dir
4283            .join(format!("multi-pack-index-{checksum}.bitmap"))
4284            .exists()
4285    }) {
4286        return Ok(None);
4287    }
4288
4289    let mut pack_to_oid = Vec::new();
4290    for checksum in &chain {
4291        let path = midx_dir.join(format!("multi-pack-index-{checksum}.midx"));
4292        let Ok(bytes) = fs::read(path) else {
4293            return Ok(None);
4294        };
4295        let Ok(midx) = MultiPackIndex::parse(&bytes, format) else {
4296            return Ok(None);
4297        };
4298        let positions: Vec<usize> = match &midx.reverse_index {
4299            Some(reverse) => reverse.iter().map(|position| *position as usize).collect(),
4300            None => {
4301                let mut positions: Vec<usize> = (0..midx.objects.len()).collect();
4302                positions.sort_by_key(|&position| {
4303                    let entry = &midx.objects[position];
4304                    (entry.pack_int_id, entry.offset)
4305                });
4306                positions
4307            }
4308        };
4309        for position in positions {
4310            let Some(entry) = midx.objects.get(position) else {
4311                return Ok(None);
4312            };
4313            pack_to_oid.push(entry.oid);
4314        }
4315    }
4316
4317    let object_count = pack_to_oid.len();
4318    if object_count == 0 || object_count > u32::MAX as usize {
4319        return Ok(None);
4320    }
4321    let mut oid_to_pack = HashMap::with_capacity(object_count);
4322    for (position, oid) in pack_to_oid.iter().enumerate() {
4323        oid_to_pack.insert(*oid, position as u32);
4324    }
4325
4326    let Some(objects_dir) = pack_dir.parent() else {
4327        return Ok(None);
4328    };
4329    let db = FileObjectDatabase::new(objects_dir.to_path_buf(), format);
4330    let word_count = object_count.div_ceil(64);
4331    let mut commits = vec![0u64; word_count];
4332    let mut trees = vec![0u64; word_count];
4333    let mut blobs = vec![0u64; word_count];
4334    let mut tags = vec![0u64; word_count];
4335    let mut commit_oids = Vec::new();
4336    for (position, oid) in pack_to_oid.iter().enumerate() {
4337        let Ok(Some((object_type, _size))) = db.read_object_header(oid) else {
4338            return Ok(None);
4339        };
4340        let position = position as u32;
4341        match object_type {
4342            ObjectType::Commit => {
4343                bitset_set(&mut commits, position);
4344                commit_oids.push(*oid);
4345            }
4346            ObjectType::Tree => bitset_set(&mut trees, position),
4347            ObjectType::Blob => bitset_set(&mut blobs, position),
4348            ObjectType::Tag => bitset_set(&mut tags, position),
4349        }
4350    }
4351
4352    let mut loaded = LoadedPackBitmap {
4353        object_count: object_count as u32,
4354        oid_to_pack,
4355        pack_to_oid,
4356        commit_words: HashMap::new(),
4357        pseudo_merges: Vec::new(),
4358        commits,
4359        trees,
4360        blobs,
4361        tags,
4362    };
4363    for oid in commit_oids {
4364        let result = bitmap_reachable(&loaded, &db, format, &[oid], true)?;
4365        if result.extended.is_empty() {
4366            loaded.commit_words.insert(oid, Arc::new(result.words));
4367        }
4368    }
4369    Ok(Some(loaded))
4370}
4371
4372/// Loads `multi-pack-index-<checksum>.bitmap` when the pack directory has a
4373/// multi-pack-index with a `RIDX` chunk (the bit-order permutation) and a
4374/// matching bitmap file. Returns `Ok(None)` — never an error — on any missing
4375/// or unusable piece, so callers fall through to single-pack bitmaps.
4376fn load_midx_bitmap(pack_dir: &Path, format: ObjectFormat) -> Result<Option<LoadedPackBitmap>> {
4377    let midx_path = pack_dir.join("multi-pack-index");
4378    if !midx_path.exists() {
4379        return Ok(None);
4380    }
4381    let Ok(midx_bytes) = fs::read(&midx_path) else {
4382        return Ok(None);
4383    };
4384    if midx_has_bad_ridx_chunk(&midx_bytes, format) {
4385        sley_core::diagnostic!(
4386            Stderr,
4387            true,
4388            "error: multi-pack-index reverse-index chunk is the wrong size"
4389        );
4390        sley_core::diagnostic!(
4391            Stderr,
4392            true,
4393            "warning: multi-pack bitmap is missing required reverse index"
4394        );
4395        return Ok(None);
4396    }
4397    let midx = match MultiPackIndex::parse(&midx_bytes, format) {
4398        Ok(midx) => midx,
4399        Err(GitError::InvalidFormat(message))
4400            if message == "multi-pack-index reverse-index chunk is the wrong size" =>
4401        {
4402            sley_core::diagnostic!(Stderr, true, "error: {message}");
4403            sley_core::diagnostic!(
4404                Stderr,
4405                true,
4406                "warning: multi-pack bitmap is missing required reverse index"
4407            );
4408            return Ok(None);
4409        }
4410        Err(_) => return Ok(None),
4411    };
4412    let bitmap_path = pack_dir.join(format!(
4413        "multi-pack-index-{}.bitmap",
4414        midx.checksum.to_hex()
4415    ));
4416    if !bitmap_path.exists() {
4417        return Ok(None);
4418    }
4419    let object_count = midx.objects.len();
4420    // Upstream `load_midx_revindex`: prefer the midx's own RIDX chunk unless
4421    // GIT_TEST_MIDX_READ_RIDX=0 disables it, else fall back to the separate
4422    // `multi-pack-index-<checksum>.rev` file; a trace2 data event records
4423    // which source supplied the permutation.
4424    let read_ridx_chunk = env::var("GIT_TEST_MIDX_READ_RIDX")
4425        .map(|value| value != "0" && !value.eq_ignore_ascii_case("false"))
4426        .unwrap_or(true);
4427    let reverse_index: Vec<u32> = match (&midx.reverse_index, read_ridx_chunk) {
4428        (Some(chunk), true) => {
4429            sley_core::trace2::data("load_midx_revindex", "source", "midx");
4430            chunk.clone()
4431        }
4432        _ => {
4433            let rev_path =
4434                pack_dir.join(format!("multi-pack-index-{}.rev", midx.checksum.to_hex()));
4435            let Ok(rev_bytes) = fs::read(&rev_path) else {
4436                // Without the RIDX permutation the bit numbering is unknown.
4437                return Ok(None);
4438            };
4439            let Ok(parsed_rev) =
4440                sley_pack::PackReverseIndex::parse(&rev_bytes, format, object_count)
4441            else {
4442                return Ok(None);
4443            };
4444            sley_core::trace2::data("load_midx_revindex", "source", "rev");
4445            parsed_rev.positions
4446        }
4447    };
4448    let Ok(bitmap_bytes) = fs::read(&bitmap_path) else {
4449        return Ok(None);
4450    };
4451    let parsed = match PackBitmapIndex::parse(&bitmap_bytes, format, object_count) {
4452        Ok(parsed) => parsed,
4453        Err(_) => return Ok(None),
4454    };
4455    if parsed.pack_checksum != midx.checksum {
4456        return Ok(None);
4457    }
4458
4459    // midx.objects is in lookup (oid-sorted) order; RIDX maps bit positions
4460    // to lookup positions.
4461    let mut pack_to_oid = Vec::with_capacity(object_count);
4462    for &midx_pos in &reverse_index {
4463        let Some(entry) = midx.objects.get(midx_pos as usize) else {
4464            return Ok(None);
4465        };
4466        pack_to_oid.push(entry.oid);
4467    }
4468    let mut oid_to_pack = HashMap::with_capacity(object_count);
4469    for (pack_pos, oid) in pack_to_oid.iter().enumerate() {
4470        oid_to_pack.insert(*oid, pack_pos as u32);
4471    }
4472    match assemble_loaded_bitmap(parsed, object_count, pack_to_oid, oid_to_pack, |position| {
4473        midx.objects.get(position).map(|entry| entry.oid)
4474    }) {
4475        Ok(loaded) => Ok(Some(loaded)),
4476        Err(_) => Ok(None),
4477    }
4478}
4479
4480fn midx_has_bad_ridx_chunk(bytes: &[u8], format: ObjectFormat) -> bool {
4481    let hash_len = format.raw_len();
4482    if bytes.len() < 12 + 12 + hash_len || &bytes[..4] != b"MIDX" {
4483        return false;
4484    }
4485    let chunk_count = bytes[6] as usize;
4486    let table_len = match (chunk_count + 1).checked_mul(12) {
4487        Some(table_len) => table_len,
4488        None => return false,
4489    };
4490    let table_end = match 12usize.checked_add(table_len) {
4491        Some(table_end) if table_end <= bytes.len().saturating_sub(hash_len) => table_end,
4492        _ => return false,
4493    };
4494    let mut entries = Vec::with_capacity(chunk_count + 1);
4495    let mut cursor = 12usize;
4496    while cursor < table_end {
4497        let id = [
4498            bytes[cursor],
4499            bytes[cursor + 1],
4500            bytes[cursor + 2],
4501            bytes[cursor + 3],
4502        ];
4503        let mut raw_offset = [0u8; 8];
4504        raw_offset.copy_from_slice(&bytes[cursor + 4..cursor + 12]);
4505        entries.push((id, u64::from_be_bytes(raw_offset) as usize));
4506        cursor += 12;
4507    }
4508    let mut oidf = None;
4509    let mut ridx = None;
4510    for pair in entries.windows(2) {
4511        let start = pair[0].1;
4512        let end = pair[1].1;
4513        if end < start || end > bytes.len().saturating_sub(hash_len) {
4514            return false;
4515        }
4516        match &pair[0].0 {
4517            b"OIDF" => oidf = Some((start, end)),
4518            b"RIDX" => ridx = Some((start, end)),
4519            _ => {}
4520        }
4521    }
4522    let Some((oidf_start, oidf_end)) = oidf else {
4523        return false;
4524    };
4525    let Some((ridx_start, ridx_end)) = ridx else {
4526        return false;
4527    };
4528    if oidf_end.saturating_sub(oidf_start) != 256 * 4 {
4529        return false;
4530    }
4531    let object_count_start = oidf_end - 4;
4532    let object_count = u32::from_be_bytes([
4533        bytes[object_count_start],
4534        bytes[object_count_start + 1],
4535        bytes[object_count_start + 2],
4536        bytes[object_count_start + 3],
4537    ]) as usize;
4538    ridx_end.saturating_sub(ridx_start) != object_count.saturating_mul(4)
4539}
4540
4541fn load_pack_bitmap_file(
4542    bitmap_path: &Path,
4543    format: ObjectFormat,
4544) -> Result<Option<LoadedPackBitmap>> {
4545    let index_path = bitmap_path.with_extension("idx");
4546    if !index_path.exists() {
4547        return Ok(None);
4548    }
4549    let index = PackIndex::parse(&fs::read(&index_path)?, format)?;
4550    let object_count = index.entries.len();
4551    let parsed = PackBitmapIndex::parse(&fs::read(bitmap_path)?, format, object_count)?;
4552    if parsed.pack_checksum != index.pack_checksum {
4553        return Ok(None);
4554    }
4555
4556    let mut pack_order: Vec<u32> = (0..object_count as u32).collect();
4557    pack_order.sort_by_key(|index_pos| index.entries[*index_pos as usize].offset);
4558    let mut pack_to_oid = Vec::with_capacity(object_count);
4559    for index_pos in &pack_order {
4560        pack_to_oid.push(index.entries[*index_pos as usize].oid);
4561    }
4562    let mut oid_to_pack = HashMap::with_capacity(object_count);
4563    for (pack_pos, oid) in pack_to_oid.iter().enumerate() {
4564        oid_to_pack.insert(*oid, pack_pos as u32);
4565    }
4566
4567    assemble_loaded_bitmap(parsed, object_count, pack_to_oid, oid_to_pack, |position| {
4568        index.entries.get(position).map(|entry| entry.oid)
4569    })
4570    .map(Some)
4571}
4572
4573/// Shared tail of the bitmap loaders: expands the type bitmaps, resolves the
4574/// per-commit entries (XOR offsets reference earlier entries in file order),
4575/// and maps each entry's lookup-order position back to a commit oid via
4576/// `lookup_oid`.
4577fn assemble_loaded_bitmap(
4578    parsed: PackBitmapIndex,
4579    object_count: usize,
4580    pack_to_oid: Vec<ObjectId>,
4581    oid_to_pack: HashMap<ObjectId, u32>,
4582    lookup_oid: impl Fn(usize) -> Option<ObjectId>,
4583) -> Result<LoadedPackBitmap> {
4584    let word_count = object_count.div_ceil(64);
4585    let expand = |bitmap: &sley_pack::EwahBitmap| -> Result<Vec<u64>> {
4586        let mut words = bitmap.to_words()?;
4587        words.resize(word_count, 0);
4588        Ok(words)
4589    };
4590
4591    let mut resolved: Vec<Arc<Vec<u64>>> = Vec::with_capacity(parsed.entries.len());
4592    let mut commit_words = HashMap::with_capacity(parsed.entries.len());
4593    for (entry_index, entry) in parsed.entries.iter().enumerate() {
4594        let mut words = expand(&entry.bitmap)?;
4595        if entry.xor_offset > 0 {
4596            let base_index = entry_index - entry.xor_offset as usize;
4597            let base = &resolved[base_index];
4598            for (dst, src) in words.iter_mut().zip(base.iter()) {
4599                *dst ^= *src;
4600            }
4601        }
4602        let words = Arc::new(words);
4603        resolved.push(Arc::clone(&words));
4604        let commit_oid = lookup_oid(entry.object_position as usize)
4605            .ok_or_else(|| GitError::InvalidFormat("bitmap entry position out of range".into()))?;
4606        commit_words.insert(commit_oid, words);
4607    }
4608    let mut pseudo_merges = Vec::with_capacity(parsed.pseudo_merges.len());
4609    for merge in &parsed.pseudo_merges {
4610        pseudo_merges.push(LoadedPseudoMerge {
4611            commits: Arc::new(expand(&merge.commits)?),
4612            bitmap: Arc::new(expand(&merge.bitmap)?),
4613        });
4614    }
4615
4616    Ok(LoadedPackBitmap {
4617        object_count: object_count as u32,
4618        oid_to_pack,
4619        pack_to_oid,
4620        commit_words,
4621        pseudo_merges,
4622        commits: expand(&parsed.type_bitmaps.commits)?,
4623        trees: expand(&parsed.type_bitmaps.trees)?,
4624        blobs: expand(&parsed.type_bitmaps.blobs)?,
4625        tags: expand(&parsed.type_bitmaps.tags)?,
4626    })
4627}
4628
4629/// Result of a bitmap-assisted reachability walk: pack-position bits for
4630/// in-pack objects plus the "extended" objects encountered outside the
4631/// bitmapped pack (in first-seen order, like upstream's extended index).
4632pub struct BitmapWalkResult {
4633    pub words: Vec<u64>,
4634    pub extended: Vec<(ObjectId, ObjectType)>,
4635    pub pseudo_merges_satisfied: usize,
4636    pub pseudo_merges_cascades: usize,
4637}
4638
4639/// Strategy used to compute the bitmap closure of the receiver's `have` tips.
4640#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
4641pub enum BitmapHaveTraversal {
4642    /// Traditional depth-first fill-in walk.
4643    #[default]
4644    Classic,
4645    /// Find the first have-covered commits reached from wants, then fill only
4646    /// those boundary tips instead of expanding unrelated have-side objects.
4647    Boundary,
4648}
4649
4650impl BitmapHaveTraversal {
4651    fn trace_label(self) -> &'static str {
4652        match self {
4653            Self::Classic => "haves/classic",
4654            Self::Boundary => "haves/boundary",
4655        }
4656    }
4657}
4658
4659impl BitmapWalkResult {
4660    /// Removes everything reachable in `haves` from this result.
4661    pub fn subtract(&mut self, haves: &BitmapWalkResult) {
4662        for (dst, src) in self.words.iter_mut().zip(haves.words.iter()) {
4663            *dst &= !*src;
4664        }
4665        let have_ext: HashSet<ObjectId> = haves.extended.iter().map(|(oid, _)| *oid).collect();
4666        self.extended.retain(|(oid, _)| !have_ext.contains(oid));
4667    }
4668
4669    fn union(&mut self, other: &BitmapWalkResult) {
4670        for (dst, src) in self.words.iter_mut().zip(other.words.iter()) {
4671            *dst |= *src;
4672        }
4673        let mut seen = self
4674            .extended
4675            .iter()
4676            .map(|(oid, _)| *oid)
4677            .collect::<HashSet<_>>();
4678        self.extended.extend(
4679            other
4680                .extended
4681                .iter()
4682                .filter(|(oid, _)| seen.insert(*oid))
4683                .copied(),
4684        );
4685        self.pseudo_merges_satisfied += other.pseudo_merges_satisfied;
4686        self.pseudo_merges_cascades += other.pseudo_merges_cascades;
4687    }
4688}
4689
4690/// Computes the set of objects reachable from `roots` using stored bitmaps
4691/// where available and a fill-in object walk where not — the consult half of
4692/// the bitmap engine (upstream `find_objects` + `fill_in_bitmap`).
4693///
4694/// Roots may be any object type; tag chains are peeled with every tag object
4695/// itself included, like the pending-object handling in
4696/// `prepare_bitmap_walk`. When `include_objects` is false only commits are
4697/// walked (tree contents of fill-in commits are not marked) — callers that
4698/// only count/enumerate commits mask with the commit type bitmap, so the
4699/// extra non-commit bits OR-ed in from stored (closed) bitmaps are harmless.
4700pub fn bitmap_reachable(
4701    bitmap: &LoadedPackBitmap,
4702    db: &impl ObjectReader,
4703    format: ObjectFormat,
4704    roots: &[ObjectId],
4705    include_objects: bool,
4706) -> Result<BitmapWalkResult> {
4707    bitmap_reachable_fill(bitmap, db, format, roots, include_objects)
4708}
4709
4710/// Compute wants minus haves using the selected have-side bitmap traversal.
4711/// The trace2 region is emitted here, around real bitmap-engine work, so every
4712/// transport/CLI consumer observes the same strategy decision.
4713pub fn bitmap_reachable_excluding_haves(
4714    bitmap: &LoadedPackBitmap,
4715    db: &impl ObjectReader,
4716    format: ObjectFormat,
4717    want_roots: &[ObjectId],
4718    have_roots: &[ObjectId],
4719    include_objects: bool,
4720    have_traversal: BitmapHaveTraversal,
4721) -> Result<BitmapWalkResult> {
4722    let mut result = bitmap_reachable(bitmap, db, format, want_roots, include_objects)?;
4723    if have_roots.is_empty() {
4724        return Ok(result);
4725    }
4726    sley_core::trace2::region("bitmap", have_traversal.trace_label());
4727    let haves = match have_traversal {
4728        BitmapHaveTraversal::Classic => {
4729            bitmap_reachable(bitmap, db, format, have_roots, include_objects)?
4730        }
4731        BitmapHaveTraversal::Boundary => {
4732            bitmap_boundary_haves(bitmap, db, format, want_roots, have_roots, include_objects)?.0
4733        }
4734    };
4735    result.subtract(&haves);
4736    Ok(result)
4737}
4738
4739#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
4740struct BitmapBoundaryStats {
4741    have_commits_walked: usize,
4742    want_commits_walked: usize,
4743    boundary_tips_filled: usize,
4744}
4745
4746/// Upstream `find_boundary_objects` shape: seed coverage from have roots with
4747/// stored bitmaps, walk uncovered haves as commit metadata only, find the first
4748/// have-covered commits reached from wants, and perform object fill-in solely
4749/// from those boundary tips. Have-side branches that never intersect wants are
4750/// never expanded into trees/blobs.
4751fn bitmap_boundary_haves(
4752    bitmap: &LoadedPackBitmap,
4753    db: &impl ObjectReader,
4754    format: ObjectFormat,
4755    want_roots: &[ObjectId],
4756    have_roots: &[ObjectId],
4757    include_objects: bool,
4758) -> Result<(BitmapWalkResult, BitmapBoundaryStats)> {
4759    let mut base = BitmapWalkResult {
4760        words: vec![0; bitmap.word_count()],
4761        extended: Vec::new(),
4762        pseudo_merges_satisfied: 0,
4763        pseudo_merges_cascades: 0,
4764    };
4765    let mut stats = BitmapBoundaryStats::default();
4766    let mut have_commits = HashSet::new();
4767    let mut pending = have_roots.to_vec();
4768    while let Some(oid) = pending.pop() {
4769        if bitmap_result_contains(&base, bitmap, &oid) || !have_commits.insert(oid) {
4770            continue;
4771        }
4772        let object = db.read_object(&oid)?;
4773        if object.object_type != ObjectType::Commit {
4774            // The boundary optimization is commit-specific. Preserve exact
4775            // semantics for tag/tree/blob negative roots via classic fill.
4776            return Ok((
4777                bitmap_reachable(bitmap, db, format, have_roots, include_objects)?,
4778                stats,
4779            ));
4780        }
4781        if bitmap.bitmap_for_commit(&oid).is_some() {
4782            let covered = bitmap_reachable(bitmap, db, format, &[oid], include_objects)?;
4783            base.union(&covered);
4784            continue;
4785        }
4786        stats.have_commits_walked += 1;
4787        let commit = Commit::parse_ref(format, &object.body)?;
4788        pending.extend(grafted_parents(db, &oid, commit.parents));
4789    }
4790
4791    let mut boundary = Vec::new();
4792    let mut seen_wants = HashSet::new();
4793    let mut pending = want_roots.to_vec();
4794    while let Some(oid) = pending.pop() {
4795        if !seen_wants.insert(oid) {
4796            continue;
4797        }
4798        if have_commits.contains(&oid) || bitmap_result_contains(&base, bitmap, &oid) {
4799            boundary.push(oid);
4800            continue;
4801        }
4802        let object = db.read_object(&oid)?;
4803        if object.object_type != ObjectType::Commit {
4804            return Ok((
4805                bitmap_reachable(bitmap, db, format, have_roots, include_objects)?,
4806                stats,
4807            ));
4808        }
4809        stats.want_commits_walked += 1;
4810        let commit = Commit::parse_ref(format, &object.body)?;
4811        pending.extend(grafted_parents(db, &oid, commit.parents));
4812    }
4813
4814    boundary.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes()));
4815    boundary.dedup();
4816    boundary.retain(|oid| !bitmap_result_contains(&base, bitmap, oid));
4817    stats.boundary_tips_filled = boundary.len();
4818    if !boundary.is_empty() {
4819        let fill = bitmap_reachable(bitmap, db, format, &boundary, include_objects)?;
4820        base.union(&fill);
4821    }
4822    Ok((base, stats))
4823}
4824
4825fn bitmap_result_contains(
4826    result: &BitmapWalkResult,
4827    bitmap: &LoadedPackBitmap,
4828    oid: &ObjectId,
4829) -> bool {
4830    match bitmap.pack_position(oid) {
4831        Some(position) => bitset_get(&result.words, position),
4832        None => result
4833            .extended
4834            .iter()
4835            .any(|(candidate, _)| candidate == oid),
4836    }
4837}
4838
4839#[cfg(test)]
4840mod bitmap_boundary_tests {
4841    use super::*;
4842
4843    #[test]
4844    fn boundary_haves_match_classic_while_filling_only_intersection_tip() {
4845        for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] {
4846            let root = unique_temp_path(&env::temp_dir());
4847            let db = FileObjectDatabase::new(root.join("objects"), format);
4848            let tree = db
4849                .write_object(EncodedObject::new(ObjectType::Tree, Vec::new()))
4850                .expect("write tree");
4851            let base = write_commit(&db, format, tree, &[], b"base\n");
4852            let shared = write_commit(&db, format, tree, &[base], b"shared\n");
4853            let want = write_commit(&db, format, tree, &[shared], b"want\n");
4854            let unrelated_have = write_commit(&db, format, tree, &[shared], b"have\n");
4855            let bitmap = empty_loaded_bitmap();
4856
4857            let classic = bitmap_reachable_excluding_haves(
4858                &bitmap,
4859                &db,
4860                format,
4861                &[want],
4862                &[unrelated_have],
4863                true,
4864                BitmapHaveTraversal::Classic,
4865            )
4866            .expect("classic have traversal");
4867            let boundary = bitmap_reachable_excluding_haves(
4868                &bitmap,
4869                &db,
4870                format,
4871                &[want],
4872                &[unrelated_have],
4873                true,
4874                BitmapHaveTraversal::Boundary,
4875            )
4876            .expect("boundary have traversal");
4877            assert_eq!(object_set(&classic), object_set(&boundary));
4878            assert_eq!(object_set(&boundary), HashSet::from([want]));
4879
4880            let (boundary_haves, stats) =
4881                bitmap_boundary_haves(&bitmap, &db, format, &[want], &[unrelated_have], true)
4882                    .expect("boundary have set");
4883            assert_eq!(stats.have_commits_walked, 3);
4884            assert_eq!(stats.want_commits_walked, 1);
4885            assert_eq!(stats.boundary_tips_filled, 1);
4886            let covered = object_set(&boundary_haves);
4887            assert!(covered.contains(&shared));
4888            assert!(covered.contains(&base));
4889            assert!(!covered.contains(&unrelated_have));
4890            fs::remove_dir_all(root).expect("remove test repository");
4891        }
4892    }
4893
4894    fn write_commit(
4895        db: &FileObjectDatabase,
4896        format: ObjectFormat,
4897        tree: ObjectId,
4898        parents: &[ObjectId],
4899        message: &[u8],
4900    ) -> ObjectId {
4901        db.write_object(EncodedObject::new(
4902            ObjectType::Commit,
4903            Commit {
4904                tree,
4905                parents: parents.to_vec(),
4906                author: b"A <a@example.com> 1 +0000".to_vec(),
4907                committer: b"A <a@example.com> 1 +0000".to_vec(),
4908                encoding: None,
4909                message: message.to_vec(),
4910            }
4911            .write(),
4912        ))
4913        .unwrap_or_else(|error| panic!("write {format:?} commit: {error}"))
4914    }
4915
4916    fn empty_loaded_bitmap() -> LoadedPackBitmap {
4917        LoadedPackBitmap {
4918            object_count: 0,
4919            oid_to_pack: HashMap::new(),
4920            pack_to_oid: Vec::new(),
4921            commit_words: HashMap::new(),
4922            pseudo_merges: Vec::new(),
4923            commits: Vec::new(),
4924            trees: Vec::new(),
4925            blobs: Vec::new(),
4926            tags: Vec::new(),
4927        }
4928    }
4929
4930    fn object_set(result: &BitmapWalkResult) -> HashSet<ObjectId> {
4931        result.extended.iter().map(|(oid, _)| *oid).collect()
4932    }
4933}
4934
4935fn bitmap_reachable_fill(
4936    bitmap: &LoadedPackBitmap,
4937    db: &impl ObjectReader,
4938    format: ObjectFormat,
4939    roots: &[ObjectId],
4940    include_objects: bool,
4941) -> Result<BitmapWalkResult> {
4942    let mut walk = BitmapFillWalk {
4943        bitmap,
4944        words: vec![0u64; bitmap.word_count()],
4945        extended: Vec::new(),
4946        extended_seen: HashSet::new(),
4947    };
4948    let mut commit_stack = Vec::new();
4949
4950    for root in roots {
4951        let mut oid = *root;
4952        // Peel tag chains, marking each tag object on the way.
4953        loop {
4954            let object = db.read_object(&oid)?;
4955            match object.object_type {
4956                ObjectType::Tag => {
4957                    walk.mark(&oid, ObjectType::Tag);
4958                    let tag = Tag::parse_ref(format, &object.body)?;
4959                    oid = tag.object;
4960                }
4961                ObjectType::Commit => {
4962                    commit_stack.push(oid);
4963                    break;
4964                }
4965                ObjectType::Tree => {
4966                    walk.mark_tree_closure(db, format, &oid)?;
4967                    break;
4968                }
4969                ObjectType::Blob => {
4970                    walk.mark(&oid, ObjectType::Blob);
4971                    break;
4972                }
4973            }
4974        }
4975    }
4976
4977    while let Some(oid) = commit_stack.pop() {
4978        if let Some(position) = bitmap.pack_position(&oid) {
4979            if bitset_get(&walk.words, position) {
4980                continue;
4981            }
4982            if let Some(stored) = bitmap.bitmap_for_commit(&oid) {
4983                bitset_or(&mut walk.words, stored);
4984                continue;
4985            }
4986            bitset_set(&mut walk.words, position);
4987        } else {
4988            if walk.extended_seen.contains(&oid) {
4989                continue;
4990            }
4991            walk.extended_seen.insert(oid);
4992            walk.extended.push((oid, ObjectType::Commit));
4993        }
4994        let object = db.read_object(&oid)?;
4995        let commit = Commit::parse_ref(format, &object.body)?;
4996        commit_stack.extend(grafted_parents(db, &oid, commit.parents));
4997        if include_objects {
4998            walk.mark_tree_closure(db, format, &commit.tree)?;
4999        }
5000    }
5001
5002    let (pseudo_merges_satisfied, pseudo_merges_cascades) =
5003        bitmap_cascade_pseudo_merges(bitmap, &mut walk.words);
5004
5005    Ok(BitmapWalkResult {
5006        words: walk.words,
5007        extended: walk.extended,
5008        pseudo_merges_satisfied,
5009        pseudo_merges_cascades,
5010    })
5011}
5012
5013fn bitmap_cascade_pseudo_merges(bitmap: &LoadedPackBitmap, words: &mut [u64]) -> (usize, usize) {
5014    if bitmap.pseudo_merges.is_empty() {
5015        return (0, 0);
5016    }
5017    let mut satisfied = vec![false; bitmap.pseudo_merges.len()];
5018    let mut total = 0usize;
5019    loop {
5020        let mut any = false;
5021        for (index, merge) in bitmap.pseudo_merges.iter().enumerate() {
5022            if satisfied[index] || !bitset_is_subset(merge.commits.as_slice(), words) {
5023                continue;
5024            }
5025            bitset_or(words, merge.bitmap.as_slice());
5026            satisfied[index] = true;
5027            any = true;
5028            total += 1;
5029        }
5030        if !any {
5031            break;
5032        }
5033    }
5034    (total, usize::from(total > 0))
5035}
5036
5037struct BitmapFillWalk<'a> {
5038    bitmap: &'a LoadedPackBitmap,
5039    words: Vec<u64>,
5040    extended: Vec<(ObjectId, ObjectType)>,
5041    extended_seen: HashSet<ObjectId>,
5042}
5043
5044impl BitmapFillWalk<'_> {
5045    /// Marks one object; returns false when it was already marked.
5046    fn mark(&mut self, oid: &ObjectId, object_type: ObjectType) -> bool {
5047        if let Some(position) = self.bitmap.pack_position(oid) {
5048            if bitset_get(&self.words, position) {
5049                return false;
5050            }
5051            bitset_set(&mut self.words, position);
5052            true
5053        } else {
5054            if !self.extended_seen.insert(*oid) {
5055                return false;
5056            }
5057            self.extended.push((*oid, object_type));
5058            true
5059        }
5060    }
5061
5062    /// Marks `tree` and everything below it, skipping subtrees already marked
5063    /// (a set in-pack bit means its closure is covered: either it came from a
5064    /// stored — closed — bitmap, or this walk already expanded it).
5065    fn mark_tree_closure(
5066        &mut self,
5067        db: &impl ObjectReader,
5068        format: ObjectFormat,
5069        tree: &ObjectId,
5070    ) -> Result<()> {
5071        if !self.mark(tree, ObjectType::Tree) {
5072            return Ok(());
5073        }
5074        let object = db.read_object(tree)?;
5075        for entry in TreeEntries::new(format, &object.body) {
5076            let entry = entry?;
5077            if entry.is_gitlink() {
5078                continue;
5079            }
5080            if entry.is_tree() {
5081                self.mark_tree_closure(db, format, &entry.oid)?;
5082            } else {
5083                self.mark(&entry.oid, ObjectType::Blob);
5084            }
5085        }
5086        Ok(())
5087    }
5088}
5089
5090#[cfg(test)]
5091mod bitmap_name_hash_tests {
5092    use super::pack_name_hash;
5093
5094    #[test]
5095    fn v1_name_hash_matches_upstream_stability_vectors() {
5096        assert_eq!(pack_name_hash(b"first"), 2_582_249_472);
5097        assert_eq!(pack_name_hash(b"second"), 2_289_942_528);
5098        assert_eq!(pack_name_hash(b"third"), 2_300_837_888);
5099        assert_eq!(
5100            pack_name_hash(b"a/one-long-enough-for-collisions"),
5101            2_544_516_325
5102        );
5103    }
5104}
5105
5106#[cfg(test)]
5107mod reachable_pack_staging_tests {
5108    use super::*;
5109
5110    #[derive(Default)]
5111    struct MapReader {
5112        objects: HashMap<ObjectId, Arc<EncodedObject>>,
5113    }
5114
5115    impl ObjectReader for MapReader {
5116        fn read_object(&self, oid: &ObjectId) -> Result<Arc<EncodedObject>> {
5117            self.objects
5118                .get(oid)
5119                .cloned()
5120                .ok_or_else(|| GitError::object_not_found_in(*oid, MissingObjectContext::Read))
5121        }
5122    }
5123
5124    fn blob_reader(count: usize) -> (MapReader, Vec<ObjectId>) {
5125        let format = ObjectFormat::Sha1;
5126        let mut reader = MapReader::default();
5127        let mut starts = Vec::new();
5128        for index in 0..count {
5129            let object = Arc::new(EncodedObject::new(
5130                ObjectType::Blob,
5131                format!("blob-{index}").into_bytes(),
5132            ));
5133            let oid = object.object_id(format).expect("blob object id");
5134            reader.objects.insert(oid, object);
5135            starts.push(oid);
5136        }
5137        (reader, starts)
5138    }
5139
5140    fn insert_object(reader: &mut MapReader, object: EncodedObject) -> ObjectId {
5141        let object = Arc::new(object);
5142        let oid = object.object_id(ObjectFormat::Sha1).expect("object id");
5143        reader.objects.insert(oid, object);
5144        oid
5145    }
5146
5147    fn one_entry_tree(name: &[u8], oid: ObjectId) -> EncodedObject {
5148        let mut body = b"100644 ".to_vec();
5149        body.extend_from_slice(name);
5150        body.push(0);
5151        body.extend_from_slice(oid.as_bytes());
5152        EncodedObject::new(ObjectType::Tree, body)
5153    }
5154
5155    fn commit_for_tree(tree: ObjectId, message: &str) -> EncodedObject {
5156        EncodedObject::new(
5157            ObjectType::Commit,
5158            format!(
5159                "tree {}\nauthor A <a@example.com> 1 +0000\ncommitter A <a@example.com> 1 +0000\n\n{message}\n",
5160                tree.to_hex()
5161            )
5162            .into_bytes(),
5163        )
5164    }
5165
5166    #[test]
5167    fn natural_and_repack_traversal_select_their_established_first_path() {
5168        let mut reader = MapReader::default();
5169        let shared = insert_object(
5170            &mut reader,
5171            EncodedObject::new(ObjectType::Blob, b"shared".to_vec()),
5172        );
5173        let first_tree = insert_object(&mut reader, one_entry_tree(b"first", shared));
5174        let second_tree = insert_object(&mut reader, one_entry_tree(b"second", shared));
5175        let first = insert_object(&mut reader, commit_for_tree(first_tree, "first"));
5176        let second = insert_object(&mut reader, commit_for_tree(second_tree, "second"));
5177
5178        let natural = collect_reachable_pack_objects_for_write_with_order(
5179            &reader,
5180            ObjectFormat::Sha1,
5181            [first, second],
5182            &HashSet::new(),
5183            false,
5184            ReachablePackTraversal::Natural,
5185        )
5186        .expect("natural traversal");
5187        let legacy = collect_reachable_pack_objects_for_write_with_order(
5188            &reader,
5189            ObjectFormat::Sha1,
5190            [first, second],
5191            &HashSet::new(),
5192            false,
5193            ReachablePackTraversal::RepackLegacy,
5194        )
5195        .expect("legacy traversal");
5196
5197        let ReachablePackObjectsForWrite::Buffered {
5198            objects: natural_objects,
5199            name_hashes: natural_hashes,
5200        } = natural
5201        else {
5202            panic!("small natural traversal must stay buffered");
5203        };
5204        let ReachablePackObjectsForWrite::Buffered {
5205            objects: legacy_objects,
5206            name_hashes: legacy_hashes,
5207        } = legacy
5208        else {
5209            panic!("small legacy traversal must stay buffered");
5210        };
5211        assert_eq!(
5212            natural_objects
5213                .iter()
5214                .map(|entry| entry.oid)
5215                .collect::<Vec<_>>(),
5216            vec![first, first_tree, shared, second, second_tree]
5217        );
5218        assert_eq!(
5219            legacy_objects
5220                .iter()
5221                .map(|entry| entry.oid)
5222                .collect::<Vec<_>>(),
5223            vec![second, second_tree, shared, first, first_tree]
5224        );
5225        assert_eq!(natural_hashes[&shared], pack_name_hash(b"first"));
5226        assert_eq!(legacy_hashes[&shared], pack_name_hash(b"second"));
5227    }
5228
5229    #[test]
5230    fn reachable_pack_streaming_threshold_is_exact_and_canonical() {
5231        assert_eq!(REACHABLE_PACK_STREAMING_MIN_OBJECTS, 32);
5232        let (reader, starts) = blob_reader(REACHABLE_PACK_STREAMING_MIN_OBJECTS - 1);
5233        let buffered = collect_reachable_pack_objects_for_write(
5234            &reader,
5235            ObjectFormat::Sha1,
5236            starts,
5237            &HashSet::new(),
5238        )
5239        .expect("collect buffered objects");
5240        assert!(matches!(
5241            buffered,
5242            ReachablePackObjectsForWrite::Buffered { objects, .. }
5243                if objects.len() == REACHABLE_PACK_STREAMING_MIN_OBJECTS - 1
5244        ));
5245
5246        let (reader, starts) = blob_reader(REACHABLE_PACK_STREAMING_MIN_OBJECTS);
5247        let traversal_order = starts.clone();
5248        let streaming = collect_reachable_pack_objects_for_write(
5249            &reader,
5250            ObjectFormat::Sha1,
5251            starts.iter().copied(),
5252            &HashSet::new(),
5253        )
5254        .expect("collect streaming metadata");
5255        assert!(matches!(
5256            streaming,
5257            ReachablePackObjectsForWrite::Streaming(metadata)
5258                if metadata.len() == REACHABLE_PACK_STREAMING_MIN_OBJECTS
5259        ));
5260
5261        let mut pack = Vec::new();
5262        write_reachable_pack_to_writer_with_options_and_cancel(
5263            &reader,
5264            ObjectFormat::Sha1,
5265            starts,
5266            &HashSet::new(),
5267            &PackWriteOptions::new().with_reorder(false),
5268            &mut pack,
5269            CancelFlag::never(),
5270        )
5271        .expect("write traversal-ordered pack")
5272        .expect("non-empty traversal-ordered pack");
5273        let parsed = PackFile::parse(&pack, ObjectFormat::Sha1).expect("parse traversal pack");
5274        assert_eq!(
5275            parsed
5276                .entries
5277                .iter()
5278                .map(|entry| entry.entry.oid)
5279                .collect::<Vec<_>>(),
5280            traversal_order,
5281            "disabling reorder must preserve traversal order across the streaming threshold"
5282        );
5283
5284        let oid = |suffix: &str| {
5285            ObjectId::from_hex(
5286                ObjectFormat::Sha1,
5287                &format!("000000000000000000000000000000000000000{suffix}"),
5288            )
5289            .expect("planning object id")
5290        };
5291        let mut metadata = vec![
5292            ReachablePackObjectMeta {
5293                oid: oid("4"),
5294                object_type: ObjectType::Commit,
5295                size: 1,
5296                name_hash: 0,
5297            },
5298            ReachablePackObjectMeta {
5299                oid: oid("3"),
5300                object_type: ObjectType::Tree,
5301                size: 1,
5302                name_hash: 0,
5303            },
5304            ReachablePackObjectMeta {
5305                oid: oid("2"),
5306                object_type: ObjectType::Blob,
5307                size: 1,
5308                name_hash: 0,
5309            },
5310            ReachablePackObjectMeta {
5311                oid: oid("1"),
5312                object_type: ObjectType::Tag,
5313                size: 1,
5314                name_hash: 0,
5315            },
5316        ];
5317        sort_reachable_pack_metadata(&mut metadata);
5318        assert_eq!(
5319            metadata
5320                .iter()
5321                .map(|entry| entry.object_type)
5322                .collect::<Vec<_>>(),
5323            vec![
5324                ObjectType::Tag,
5325                ObjectType::Blob,
5326                ObjectType::Tree,
5327                ObjectType::Commit,
5328            ]
5329        );
5330    }
5331
5332    #[test]
5333    fn staged_reachable_pack_preserves_target_on_empty_and_failure() {
5334        let format = ObjectFormat::Sha1;
5335        let root = unique_temp_path(&env::temp_dir());
5336        let database = FileObjectDatabase::new(root.join("objects"), format);
5337        let pack_dir = root.join("prepared");
5338        fs::create_dir_all(&pack_dir).expect("create prepared directory");
5339        let target = pack_dir.join("target.pack");
5340        fs::write(&target, b"previous-pack").expect("write existing target");
5341
5342        let empty = build_reachable_pack_file(
5343            &database,
5344            format,
5345            std::iter::empty(),
5346            &HashSet::new(),
5347            &target,
5348        )
5349        .expect("empty walk");
5350        assert!(empty.is_none());
5351        assert_eq!(fs::read(&target).expect("read target"), b"previous-pack");
5352
5353        let missing_tree = ObjectId::from_hex(format, "ffffffffffffffffffffffffffffffffffffffff")
5354            .expect("missing tree id");
5355        let commit = database
5356            .write_object(EncodedObject::new(
5357                ObjectType::Commit,
5358                Commit {
5359                    tree: missing_tree,
5360                    parents: Vec::new(),
5361                    author: b"A <a@example.com> 1 +0000".to_vec(),
5362                    committer: b"A <a@example.com> 1 +0000".to_vec(),
5363                    encoding: None,
5364                    message: b"missing tree\n".to_vec(),
5365                }
5366                .write(),
5367            ))
5368            .expect("write commit");
5369        build_reachable_pack_file(&database, format, [commit], &HashSet::new(), &target)
5370            .expect_err("missing tree must abort staging");
5371        assert_eq!(fs::read(&target).expect("read target"), b"previous-pack");
5372        assert!(
5373            fs::read_dir(&pack_dir)
5374                .expect("list prepared directory")
5375                .filter_map(std::result::Result::ok)
5376                .all(|entry| !entry.file_name().to_string_lossy().starts_with("tmp_obj_")),
5377            "failed and empty builds must remove temporary packs"
5378        );
5379
5380        let blob = database
5381            .write_object(EncodedObject::new(
5382                ObjectType::Blob,
5383                b"replacement".to_vec(),
5384            ))
5385            .expect("write replacement blob");
5386        let prepared =
5387            build_reachable_pack_file(&database, format, [blob], &HashSet::new(), &target)
5388                .expect("stage replacement")
5389                .expect("non-empty replacement");
5390        let bytes = fs::read(&target).expect("read prepared pack");
5391        let parsed = PackFile::parse(&bytes, format).expect("parse prepared pack");
5392        assert_eq!(prepared.pack_size, bytes.len() as u64);
5393        assert_eq!(prepared.checksum, parsed.checksum);
5394        assert_eq!(prepared.object_count, 1);
5395        fs::remove_dir_all(root).expect("remove staging fixture");
5396    }
5397}