Skip to main content

sley_pack/
write.rs

1//! Pack generation: options, deltified/undeltified writes, compression, and bitmap output.
2//!
3//! Split out of `lib.rs` in the W21 mechanical refactor: a pure code move
4//! (no function body changed); all items are re-exported from `lib.rs`.
5use super::*;
6
7/// Default sliding-window size used by [`PackFile::write_packed`].
8///
9/// Each object is compared against up to this many previously emitted
10/// candidates of the same type when searching for a small delta. Matches git's
11/// default `pack.window`.
12pub const DEFAULT_PACK_WINDOW: usize = 10;
13
14/// Default maximum delta chain depth used by [`PackFile::write_packed`].
15///
16/// A delta may reference a base that is itself a delta; this bounds how long
17/// such chains may grow so that reconstructing any object stays cheap and the
18/// reader's recursion stays shallow. Matches git's default `pack.depth`.
19pub const DEFAULT_PACK_DEPTH: usize = 50;
20
21/// Object-count threshold before pack payload compression is fanned out across
22/// worker threads. Below this, thread setup and extra buffering cost more than
23/// they save.
24pub(crate) const PACK_PARALLEL_COMPRESSION_MIN_OBJECTS: usize = 64;
25
26/// Keep parallel compression bounded. Git gets much of its wall-clock win from
27/// using several cores, but unbounded threads can steal cache from delta
28/// planning and inflate peak memory on large packs.
29pub(crate) const PACK_PARALLEL_COMPRESSION_MAX_THREADS: usize = 4;
30
31/// Streaming pack writes pre-compress only this many ordered entries at a time.
32/// This restores CPU parallelism without holding every compressed payload for a
33/// large pack in memory at once.
34pub(crate) const PACK_STREAM_COMPRESSION_WINDOW_OBJECTS: usize = 256;
35
36/// Options controlling sliding-window delta selection during pack generation.
37///
38/// Construct with [`PackWriteOptions::new`] (sensible defaults) and adjust with
39/// the builder-style setters, or build one directly. Used by
40/// [`PackFile::write_packed_with_options`] and [`PackFile::write_thin`].
41#[derive(Debug, Clone)]
42pub struct PackWriteOptions {
43    /// Number of previous same-type candidates each object is deltified
44    /// against. Larger windows find better deltas at higher cost.
45    pub window: usize,
46    /// Maximum delta chain depth. A value of `0` disables deltification.
47    pub depth: usize,
48    /// When `true`, in-pack deltas are encoded as ofs-deltas (the default and
49    /// git's preference). When `false`, in-pack deltas use ref-deltas. Deltas
50    /// against external thin-pack bases always use ref-deltas regardless.
51    pub prefer_ofs_delta: bool,
52    /// External base objects, keyed by object id, that are *not* written into
53    /// the pack but may be used as delta bases. Supplying any entries here
54    /// produces a thin pack (see [`PackFile::write_thin`]). Empty by default,
55    /// yielding a self-contained pack.
56    pub thin_bases: HashMap<ObjectId, EncodedObject>,
57    /// Preferred external base for a specific target object. Upload-pack uses
58    /// this to preserve an existing on-disk delta when its base belongs to the
59    /// client. Preferred pairs avoid comparing every target with every thin
60    /// base and are used when the recomputed delta remains worthwhile.
61    pub preferred_thin_bases: HashMap<ObjectId, ObjectId>,
62    /// When `true` (the default), objects are reordered by type and size for
63    /// better delta locality. When `false`, the input order is preserved (the
64    /// emitted pack lists objects in the order supplied); deltas then only
65    /// reference earlier input objects. Reordering is always skipped when
66    /// deltification is disabled (`depth == 0`), since it has no effect there.
67    pub reorder: bool,
68    /// Zlib compression level for pack entry payloads.
69    pub compression_level: u32,
70}
71
72impl Default for PackWriteOptions {
73    fn default() -> Self {
74        Self::new()
75    }
76}
77
78impl PackWriteOptions {
79    /// Options with git-compatible defaults: window
80    /// [`DEFAULT_PACK_WINDOW`], depth [`DEFAULT_PACK_DEPTH`], ofs-deltas, and
81    /// no external thin bases.
82    pub fn new() -> Self {
83        Self {
84            window: DEFAULT_PACK_WINDOW,
85            depth: DEFAULT_PACK_DEPTH,
86            prefer_ofs_delta: true,
87            thin_bases: HashMap::new(),
88            preferred_thin_bases: HashMap::new(),
89            reorder: true,
90            compression_level: 6,
91        }
92    }
93
94    /// Set the sliding-window size.
95    pub fn with_window(mut self, window: usize) -> Self {
96        self.window = window;
97        self
98    }
99
100    /// Set the maximum delta chain depth (`0` disables deltas).
101    pub fn with_depth(mut self, depth: usize) -> Self {
102        self.depth = depth;
103        self
104    }
105
106    /// Choose whether in-pack deltas use ofs-delta (`true`) or ref-delta
107    /// (`false`) base references.
108    pub fn with_prefer_ofs_delta(mut self, prefer_ofs_delta: bool) -> Self {
109        self.prefer_ofs_delta = prefer_ofs_delta;
110        self
111    }
112
113    /// Provide the set of external base objects permitted for a thin pack.
114    pub fn with_thin_bases(mut self, thin_bases: HashMap<ObjectId, EncodedObject>) -> Self {
115        self.thin_bases = thin_bases;
116        self
117    }
118
119    /// Prefer a particular external base for each target object id.
120    pub fn with_preferred_thin_bases(
121        mut self,
122        preferred_thin_bases: HashMap<ObjectId, ObjectId>,
123    ) -> Self {
124        self.preferred_thin_bases = preferred_thin_bases;
125        self
126    }
127
128    /// Choose whether objects may be reordered for delta locality (`true`) or
129    /// emitted in input order (`false`).
130    pub fn with_reorder(mut self, reorder: bool) -> Self {
131        self.reorder = reorder;
132        self
133    }
134
135    /// Set the zlib compression level used for pack entry payloads.
136    pub fn with_compression_level(mut self, level: u32) -> Self {
137        self.compression_level = level.min(9);
138        self
139    }
140}
141
142impl PackFile {
143    pub fn write_undeltified_sha1<T>(objects: &[T]) -> Result<PackWrite>
144    where
145        T: Borrow<EncodedObject>,
146    {
147        Self::write_undeltified(objects, ObjectFormat::Sha1)
148    }
149
150    /// Write a pack with every object stored undeltified (no delta entries).
151    ///
152    /// This is the simple, self-contained encoding; objects appear in the given
153    /// order. For smaller output that exploits similarity between objects, use
154    /// [`PackFile::write_packed`].
155    pub fn write_undeltified<T>(objects: &[T], format: ObjectFormat) -> Result<PackWrite>
156    where
157        T: Borrow<EncodedObject>,
158    {
159        let options = PackWriteOptions::new().with_depth(0).with_reorder(false);
160        Self::write_packed_impl(objects, format, &options)
161    }
162
163    /// Write a pack using sliding-window delta selection with git-compatible
164    /// defaults (window [`DEFAULT_PACK_WINDOW`], depth [`DEFAULT_PACK_DEPTH`],
165    /// ofs-deltas, self-contained).
166    ///
167    /// Objects are grouped by type and ordered for good deltas, then each is
168    /// compared against a window of previously emitted candidates; the smallest
169    /// acceptable delta is kept, otherwise the object is stored undeltified. The
170    /// result round-trips through [`PackFile::parse`].
171    pub fn write_packed<T>(objects: &[T], format: ObjectFormat) -> Result<PackWrite>
172    where
173        T: Borrow<EncodedObject>,
174    {
175        Self::write_packed_with_options(objects, format, &PackWriteOptions::new())
176    }
177
178    /// Like [`PackFile::write_packed`] but with caller-supplied
179    /// [`PackWriteOptions`] (window, depth, base-reference style, and optional
180    /// external thin bases).
181    pub fn write_packed_with_options<T>(
182        objects: &[T],
183        format: ObjectFormat,
184        options: &PackWriteOptions,
185    ) -> Result<PackWrite>
186    where
187        T: Borrow<EncodedObject>,
188    {
189        Self::write_packed_impl(objects, format, options)
190    }
191
192    /// Like [`PackFile::write_packed`], but uses caller-supplied object ids
193    /// instead of re-hashing each object before pack planning.
194    ///
195    /// This is intended for object-database paths that reached each object by
196    /// its id and already trust that id/object mapping. The function validates
197    /// id formats and duplicate ids, but it does not re-hash object bodies; use
198    /// [`PackFile::write_packed`] when the ids are not already known to be
199    /// canonical.
200    pub fn write_packed_with_known_ids(
201        inputs: &[PackInput<'_>],
202        format: ObjectFormat,
203    ) -> Result<PackWrite> {
204        Self::write_packed_with_known_ids_and_options(inputs, format, &PackWriteOptions::new())
205    }
206
207    /// Like [`PackFile::write_packed_with_known_ids`] but with caller-supplied
208    /// [`PackWriteOptions`].
209    pub fn write_packed_with_known_ids_and_options(
210        inputs: &[PackInput<'_>],
211        format: ObjectFormat,
212        options: &PackWriteOptions,
213    ) -> Result<PackWrite> {
214        if inputs.len() > u32::MAX as usize {
215            return Err(GitError::InvalidFormat("too many pack objects".into()));
216        }
217        let mut objects = Vec::with_capacity(inputs.len());
218        let mut object_ids = Vec::with_capacity(inputs.len());
219        for input in inputs {
220            if input.oid.format() != format {
221                return Err(GitError::InvalidObjectId(format!(
222                    "pack object id {} uses {}, pack uses {}",
223                    input.oid,
224                    input.oid.format().name(),
225                    format.name()
226                )));
227            }
228            objects.push(input.object);
229            object_ids.push(*input.oid);
230        }
231        Self::write_packed_from_parts(objects, object_ids, format, options)
232    }
233
234    pub fn write_packed_with_known_ids_to_writer<W>(
235        inputs: &[PackInput<'_>],
236        format: ObjectFormat,
237        options: &PackWriteOptions,
238        writer: &mut W,
239    ) -> Result<PackWriteSummary>
240    where
241        W: Write,
242    {
243        if inputs.len() > u32::MAX as usize {
244            return Err(GitError::InvalidFormat("too many pack objects".into()));
245        }
246        let mut objects = Vec::with_capacity(inputs.len());
247        let mut object_ids = Vec::with_capacity(inputs.len());
248        for input in inputs {
249            if input.oid.format() != format {
250                return Err(GitError::InvalidObjectId(format!(
251                    "pack object id {} uses {}, pack uses {}",
252                    input.oid,
253                    input.oid.format().name(),
254                    format.name()
255                )));
256            }
257            objects.push(input.object);
258            object_ids.push(*input.oid);
259        }
260        Self::write_packed_from_parts_to_writer(objects, object_ids, format, options, writer)
261    }
262
263    /// Write a thin pack: objects may be deltified against `external_bases`
264    /// that are *not* included in the pack, referenced by ref-delta to their
265    /// object id.
266    ///
267    /// The receiver must already have (or otherwise obtain) those base objects
268    /// and resolve the pack with [`PackFile::parse_thin`]. Window and depth use
269    /// the defaults; pass options via [`PackFile::write_packed_with_options`]
270    /// with [`PackWriteOptions::with_thin_bases`] for finer control.
271    pub fn write_thin<T>(
272        objects: &[T],
273        format: ObjectFormat,
274        external_bases: HashMap<ObjectId, EncodedObject>,
275    ) -> Result<PackWrite>
276    where
277        T: Borrow<EncodedObject>,
278    {
279        let options = PackWriteOptions::new().with_thin_bases(external_bases);
280        Self::write_packed_impl(objects, format, &options)
281    }
282
283    pub(crate) fn write_packed_impl<T>(
284        objects: &[T],
285        format: ObjectFormat,
286        options: &PackWriteOptions,
287    ) -> Result<PackWrite>
288    where
289        T: Borrow<EncodedObject>,
290    {
291        if objects.len() > u32::MAX as usize {
292            return Err(GitError::InvalidFormat("too many pack objects".into()));
293        }
294        let objects: Vec<&EncodedObject> = objects.iter().map(Borrow::borrow).collect();
295
296        // Compute object ids up front; they are needed both for the index and,
297        // for ref-deltas, inside the pack entries themselves.
298        let mut object_ids: Vec<ObjectId> = Vec::with_capacity(objects.len());
299        for object in &objects {
300            object_ids.push(object.object_id(format)?);
301        }
302        Self::write_packed_from_parts(objects, object_ids, format, options)
303    }
304
305    pub(crate) fn write_packed_from_parts(
306        objects: Vec<&EncodedObject>,
307        object_ids: Vec<ObjectId>,
308        format: ObjectFormat,
309        options: &PackWriteOptions,
310    ) -> Result<PackWrite> {
311        let mut seen = HashSet::with_capacity(object_ids.len());
312        for oid in &object_ids {
313            if !seen.insert(oid) {
314                return Err(GitError::InvalidFormat(format!(
315                    "pack contains duplicate object id {oid}"
316                )));
317            }
318        }
319
320        // Validate external thin bases share the pack's hash format.
321        for oid in options.thin_bases.keys() {
322            if oid.format() != format {
323                return Err(GitError::InvalidObjectId(
324                    "thin pack base object id format does not match pack format".into(),
325                ));
326            }
327        }
328
329        // Decide, for each object, whether it is stored undeltified or as a
330        // delta against another object (in-pack or an external thin base), and
331        // obtain the emit order. In-pack deltas only ever reference candidates
332        // that appear earlier in `order`, so emitting in `order` guarantees a
333        // base is always written before any object that deltas against it.
334        let (plan, order) = plan_pack_deltas(&objects, &object_ids, options)?;
335
336        let mut pack = Vec::new();
337        pack.extend_from_slice(b"PACK");
338        pack.extend_from_slice(&2u32.to_be_bytes());
339        pack.extend_from_slice(&(objects.len() as u32).to_be_bytes());
340
341        let mut index_entries = Vec::with_capacity(objects.len());
342        let mut delta_count = 0u32;
343        // Pack offset at which each original object index was written, or
344        // `None` until it has been emitted.
345        let mut written_offsets: Vec<Option<u64>> = vec![None; objects.len()];
346
347        let compressed_payloads =
348            compress_planned_payloads(&objects, &plan, &order, options.compression_level)?;
349
350        for (order_pos, &idx) in order.iter().enumerate() {
351            let offset = pack.len() as u64;
352            let mut entry_bytes = Vec::new();
353            match &plan[idx].base {
354                PlannedBase::None => {
355                    write_entry_header(
356                        &mut entry_bytes,
357                        objects[idx].object_type,
358                        objects[idx].body.len() as u64,
359                    );
360                }
361                PlannedBase::InPack { base_idx, delta } => {
362                    delta_count += 1;
363                    let base_offset = written_offsets[*base_idx].ok_or_else(|| {
364                        GitError::InvalidFormat(
365                            "in-pack delta base emitted after dependent object".into(),
366                        )
367                    })?;
368                    if options.prefer_ofs_delta {
369                        write_pack_entry_header_kind(&mut entry_bytes, 6, delta.len() as u64);
370                        let relative = offset.checked_sub(base_offset).ok_or_else(|| {
371                            GitError::InvalidFormat("ofs-delta base offset is after delta".into())
372                        })?;
373                        write_ofs_delta_offset(&mut entry_bytes, relative)?;
374                    } else {
375                        write_pack_entry_header_kind(&mut entry_bytes, 7, delta.len() as u64);
376                        entry_bytes.extend_from_slice(object_ids[*base_idx].as_bytes());
377                    }
378                }
379                PlannedBase::External { base_oid, delta } => {
380                    delta_count += 1;
381                    write_pack_entry_header_kind(&mut entry_bytes, 7, delta.len() as u64);
382                    entry_bytes.extend_from_slice(base_oid.as_bytes());
383                }
384            }
385            entry_bytes.extend_from_slice(&compressed_payloads[order_pos]);
386            let crc32 = crc32fast::hash(&entry_bytes);
387            pack.extend_from_slice(&entry_bytes);
388            written_offsets[idx] = Some(offset);
389            index_entries.push(PackIndexEntry {
390                oid: object_ids[idx].clone(),
391                crc32,
392                offset,
393            });
394        }
395
396        let checksum = sley_core::digest_bytes(format, &pack)?;
397        pack.extend_from_slice(checksum.as_bytes());
398        let index = PackIndex::write_v2(format, &index_entries, &checksum)?;
399        Ok(PackWrite {
400            pack,
401            index,
402            checksum,
403            entries: index_entries,
404            delta_count,
405        })
406    }
407
408    pub(crate) fn write_packed_from_parts_to_writer<W>(
409        objects: Vec<&EncodedObject>,
410        object_ids: Vec<ObjectId>,
411        format: ObjectFormat,
412        options: &PackWriteOptions,
413        writer: &mut W,
414    ) -> Result<PackWriteSummary>
415    where
416        W: Write,
417    {
418        let mut seen = HashSet::with_capacity(object_ids.len());
419        for oid in &object_ids {
420            if !seen.insert(oid) {
421                return Err(GitError::InvalidFormat(format!(
422                    "pack contains duplicate object id {oid}"
423                )));
424            }
425        }
426
427        for oid in options.thin_bases.keys() {
428            if oid.format() != format {
429                return Err(GitError::InvalidObjectId(
430                    "thin pack base object id format does not match pack format".into(),
431                ));
432            }
433        }
434
435        let (plan, order) = plan_pack_deltas(&objects, &object_ids, options)?;
436        let mut output = PackDigestWriter::new(writer, format);
437        output.write_pack_bytes(b"PACK")?;
438        output.write_pack_bytes(&2u32.to_be_bytes())?;
439        output.write_pack_bytes(&(objects.len() as u32).to_be_bytes())?;
440
441        let mut index_entries = Vec::with_capacity(objects.len());
442        let mut delta_count = 0u32;
443        let mut written_offsets: Vec<Option<u64>> = vec![None; objects.len()];
444
445        for order_window in order.chunks(PACK_STREAM_COMPRESSION_WINDOW_OBJECTS) {
446            let compressed_payloads = compress_planned_payloads(
447                &objects,
448                &plan,
449                order_window,
450                options.compression_level,
451            )?;
452            for (&idx, compressed_payload) in order_window.iter().zip(&compressed_payloads) {
453                let offset = output.position();
454                let mut entry_header = Vec::new();
455                match &plan[idx].base {
456                    PlannedBase::None => {
457                        write_entry_header(
458                            &mut entry_header,
459                            objects[idx].object_type,
460                            objects[idx].body.len() as u64,
461                        );
462                    }
463                    PlannedBase::InPack { base_idx, delta } => {
464                        delta_count += 1;
465                        let base_offset = written_offsets[*base_idx].ok_or_else(|| {
466                            GitError::InvalidFormat(
467                                "in-pack delta base emitted after dependent object".into(),
468                            )
469                        })?;
470                        if options.prefer_ofs_delta {
471                            write_pack_entry_header_kind(&mut entry_header, 6, delta.len() as u64);
472                            let relative = offset.checked_sub(base_offset).ok_or_else(|| {
473                                GitError::InvalidFormat(
474                                    "ofs-delta base offset is after delta".into(),
475                                )
476                            })?;
477                            write_ofs_delta_offset(&mut entry_header, relative)?;
478                        } else {
479                            write_pack_entry_header_kind(&mut entry_header, 7, delta.len() as u64);
480                            entry_header.extend_from_slice(object_ids[*base_idx].as_bytes());
481                        }
482                    }
483                    PlannedBase::External { base_oid, delta } => {
484                        delta_count += 1;
485                        write_pack_entry_header_kind(&mut entry_header, 7, delta.len() as u64);
486                        entry_header.extend_from_slice(base_oid.as_bytes());
487                    }
488                }
489                let mut crc32 = crc32fast::Hasher::new();
490                crc32.update(&entry_header);
491                crc32.update(compressed_payload);
492                output.write_pack_bytes(&entry_header)?;
493                output.write_pack_bytes(compressed_payload)?;
494                written_offsets[idx] = Some(offset);
495                index_entries.push(PackIndexEntry {
496                    oid: object_ids[idx],
497                    crc32: crc32.finalize(),
498                    offset,
499                });
500            }
501        }
502
503        let (checksum, pack_size) = output.finish()?;
504        let index = PackIndex::write_v2(format, &index_entries, &checksum)?;
505        Ok(PackWriteSummary {
506            index,
507            checksum,
508            entries: index_entries,
509            delta_count,
510            pack_size,
511        })
512    }
513
514    pub fn write_undeltified_from_source_to_writer<W, F>(
515        object_ids: &[ObjectId],
516        format: ObjectFormat,
517        options: &PackWriteOptions,
518        read_object: F,
519        writer: &mut W,
520    ) -> Result<PackWriteSummary>
521    where
522        W: Write,
523        F: FnMut(&ObjectId) -> Result<Arc<EncodedObject>>,
524    {
525        Self::write_undeltified_from_source_to_writer_with_cancel(
526            object_ids,
527            format,
528            options,
529            read_object,
530            writer,
531            CancelFlag::never(),
532        )
533    }
534
535    /// Undeltified pack write that polls `cancel` between compression windows.
536    pub fn write_undeltified_from_source_to_writer_with_cancel<W, F>(
537        object_ids: &[ObjectId],
538        format: ObjectFormat,
539        options: &PackWriteOptions,
540        mut read_object: F,
541        writer: &mut W,
542        cancel: CancelFlag<'_>,
543    ) -> Result<PackWriteSummary>
544    where
545        W: Write,
546        F: FnMut(&ObjectId) -> Result<Arc<EncodedObject>>,
547    {
548        let mut seen = HashSet::with_capacity(object_ids.len());
549        for oid in object_ids {
550            if oid.format() != format {
551                return Err(GitError::InvalidObjectId(
552                    "pack object id format does not match pack format".into(),
553                ));
554            }
555            if !seen.insert(oid) {
556                return Err(GitError::InvalidFormat(format!(
557                    "pack contains duplicate object id {oid}"
558                )));
559            }
560        }
561
562        let mut output = PackDigestWriter::new(writer, format);
563        output.write_pack_bytes(b"PACK")?;
564        output.write_pack_bytes(&2u32.to_be_bytes())?;
565        output.write_pack_bytes(&(object_ids.len() as u32).to_be_bytes())?;
566
567        let mut index_entries = Vec::with_capacity(object_ids.len());
568        for oid_window in object_ids.chunks(PACK_STREAM_COMPRESSION_WINDOW_OBJECTS) {
569            cancel.check()?;
570            let mut objects = Vec::with_capacity(oid_window.len());
571            for oid in oid_window {
572                objects.push(read_object(oid)?);
573            }
574            let compressed_payloads =
575                compress_undeltified_payloads(&objects, options.compression_level)?;
576            for ((oid, object), compressed_payload) in
577                oid_window.iter().zip(&objects).zip(&compressed_payloads)
578            {
579                let offset = output.position();
580                let mut entry_header = Vec::new();
581                write_entry_header(
582                    &mut entry_header,
583                    object.object_type,
584                    object.body.len() as u64,
585                );
586                let mut crc32 = crc32fast::Hasher::new();
587                crc32.update(&entry_header);
588                crc32.update(compressed_payload);
589                output.write_pack_bytes(&entry_header)?;
590                output.write_pack_bytes(compressed_payload)?;
591                index_entries.push(PackIndexEntry {
592                    oid: *oid,
593                    crc32: crc32.finalize(),
594                    offset,
595                });
596            }
597        }
598
599        let (checksum, pack_size) = output.finish()?;
600        let index = PackIndex::write_v2(format, &index_entries, &checksum)?;
601        Ok(PackWriteSummary {
602            index,
603            checksum,
604            entries: index_entries,
605            delta_count: 0,
606            pack_size,
607        })
608    }
609
610    pub fn write_packed_from_source_to_writer<W, F>(
611        object_ids: &[ObjectId],
612        format: ObjectFormat,
613        options: &PackWriteOptions,
614        read_object: F,
615        writer: &mut W,
616    ) -> Result<PackWriteSummary>
617    where
618        W: Write,
619        F: FnMut(&ObjectId) -> Result<Arc<EncodedObject>>,
620    {
621        Self::write_packed_from_source_to_writer_with_cancel(
622            object_ids,
623            format,
624            options,
625            read_object,
626            writer,
627            CancelFlag::never(),
628        )
629    }
630
631    /// Streaming deltified pack write that polls `cancel` between compression
632    /// windows. Returns [`GitError::Cancelled`] when the flag trips.
633    pub fn write_packed_from_source_to_writer_with_cancel<W, F>(
634        object_ids: &[ObjectId],
635        format: ObjectFormat,
636        options: &PackWriteOptions,
637        mut read_object: F,
638        writer: &mut W,
639        cancel: CancelFlag<'_>,
640    ) -> Result<PackWriteSummary>
641    where
642        W: Write,
643        F: FnMut(&ObjectId) -> Result<Arc<EncodedObject>>,
644    {
645        if object_ids.len() > u32::MAX as usize {
646            return Err(GitError::InvalidFormat("too many pack objects".into()));
647        }
648
649        let mut seen = HashSet::with_capacity(object_ids.len());
650        for oid in object_ids {
651            if oid.format() != format {
652                return Err(GitError::InvalidObjectId(
653                    "pack object id format does not match pack format".into(),
654                ));
655            }
656            if !seen.insert(*oid) {
657                return Err(GitError::InvalidFormat(format!(
658                    "pack contains duplicate object id {oid}"
659                )));
660            }
661        }
662
663        for oid in options.thin_bases.keys() {
664            if oid.format() != format {
665                return Err(GitError::InvalidObjectId(
666                    "thin pack base object id format does not match pack format".into(),
667                ));
668            }
669        }
670
671        let mut output = PackDigestWriter::new(writer, format);
672        output.write_pack_bytes(b"PACK")?;
673        output.write_pack_bytes(&2u32.to_be_bytes())?;
674        output.write_pack_bytes(&(object_ids.len() as u32).to_be_bytes())?;
675
676        let mut index_entries = Vec::with_capacity(object_ids.len());
677        let mut delta_count = 0u32;
678        let mut base_horizon: VecDeque<StreamingDeltaBase> = VecDeque::new();
679
680        for oid_window in object_ids.chunks(PACK_STREAM_COMPRESSION_WINDOW_OBJECTS) {
681            cancel.check()?;
682            let mut objects = Vec::with_capacity(oid_window.len());
683            for oid in oid_window {
684                objects.push(read_object(oid)?);
685            }
686
687            let (plan, order) =
688                plan_streaming_window_deltas(&objects, oid_window, &base_horizon, options);
689            let compressed_payloads = compress_streaming_planned_payloads(
690                &objects,
691                &plan,
692                &order,
693                options.compression_level,
694            )?;
695            let mut written_offsets: Vec<Option<u64>> = vec![None; objects.len()];
696
697            for (&idx, compressed_payload) in order.iter().zip(&compressed_payloads) {
698                let offset = output.position();
699                let mut entry_header = Vec::new();
700                match &plan[idx].base {
701                    StreamingPlannedBase::None => {
702                        write_entry_header(
703                            &mut entry_header,
704                            objects[idx].object_type,
705                            objects[idx].body.len() as u64,
706                        );
707                    }
708                    StreamingPlannedBase::Current { base_idx, delta } => {
709                        delta_count += 1;
710                        let base_offset = written_offsets[*base_idx].ok_or_else(|| {
711                            GitError::InvalidFormat(
712                                "in-pack delta base emitted after dependent object".into(),
713                            )
714                        })?;
715                        if options.prefer_ofs_delta {
716                            write_pack_entry_header_kind(&mut entry_header, 6, delta.len() as u64);
717                            let relative = offset.checked_sub(base_offset).ok_or_else(|| {
718                                GitError::InvalidFormat(
719                                    "ofs-delta base offset is after delta".into(),
720                                )
721                            })?;
722                            write_ofs_delta_offset(&mut entry_header, relative)?;
723                        } else {
724                            write_pack_entry_header_kind(&mut entry_header, 7, delta.len() as u64);
725                            entry_header.extend_from_slice(oid_window[*base_idx].as_bytes());
726                        }
727                    }
728                    StreamingPlannedBase::Previous {
729                        base_oid,
730                        base_offset,
731                        delta,
732                    } => {
733                        delta_count += 1;
734                        if options.prefer_ofs_delta {
735                            write_pack_entry_header_kind(&mut entry_header, 6, delta.len() as u64);
736                            let relative = offset.checked_sub(*base_offset).ok_or_else(|| {
737                                GitError::InvalidFormat(
738                                    "ofs-delta base offset is after delta".into(),
739                                )
740                            })?;
741                            write_ofs_delta_offset(&mut entry_header, relative)?;
742                        } else {
743                            write_pack_entry_header_kind(&mut entry_header, 7, delta.len() as u64);
744                            entry_header.extend_from_slice(base_oid.as_bytes());
745                        }
746                    }
747                    StreamingPlannedBase::External { base_oid, delta } => {
748                        delta_count += 1;
749                        write_pack_entry_header_kind(&mut entry_header, 7, delta.len() as u64);
750                        entry_header.extend_from_slice(base_oid.as_bytes());
751                    }
752                }
753
754                let mut crc32 = crc32fast::Hasher::new();
755                crc32.update(&entry_header);
756                crc32.update(compressed_payload);
757                output.write_pack_bytes(&entry_header)?;
758                output.write_pack_bytes(compressed_payload)?;
759                written_offsets[idx] = Some(offset);
760                index_entries.push(PackIndexEntry {
761                    oid: oid_window[idx],
762                    crc32: crc32.finalize(),
763                    offset,
764                });
765
766                if options.depth > 0 && options.window > 0 {
767                    base_horizon.push_back(StreamingDeltaBase {
768                        oid: oid_window[idx],
769                        object: Arc::clone(&objects[idx]),
770                        offset,
771                        depth: plan[idx].depth,
772                    });
773                    while base_horizon.len() > options.window {
774                        base_horizon.pop_front();
775                    }
776                }
777            }
778        }
779
780        let (checksum, pack_size) = output.finish()?;
781        let index = PackIndex::write_v2(format, &index_entries, &checksum)?;
782        Ok(PackWriteSummary {
783            index,
784            checksum,
785            entries: index_entries,
786            delta_count,
787            pack_size,
788        })
789    }
790}
791
792pub(crate) struct PackDigestWriter<'a, W> {
793    writer: &'a mut W,
794    digest: StreamingDigest,
795    position: u64,
796}
797
798impl<'a, W> PackDigestWriter<'a, W>
799where
800    W: Write,
801{
802    pub(crate) fn new(writer: &'a mut W, format: ObjectFormat) -> Self {
803        Self {
804            writer,
805            digest: StreamingDigest::new(format),
806            position: 0,
807        }
808    }
809
810    pub(crate) fn position(&self) -> u64 {
811        self.position
812    }
813
814    pub(crate) fn write_pack_bytes(&mut self, bytes: &[u8]) -> Result<()> {
815        self.writer.write_all(bytes)?;
816        self.digest.update(bytes);
817        self.position = self
818            .position
819            .checked_add(bytes.len() as u64)
820            .ok_or_else(|| GitError::InvalidFormat("pack offset overflow".into()))?;
821        Ok(())
822    }
823
824    pub(crate) fn finish(mut self) -> Result<(ObjectId, u64)> {
825        let checksum = self.digest.finalize()?;
826        self.writer.write_all(checksum.as_bytes())?;
827        self.position = self
828            .position
829            .checked_add(checksum.as_bytes().len() as u64)
830            .ok_or_else(|| GitError::InvalidFormat("pack offset overflow".into()))?;
831        Ok((checksum, self.position))
832    }
833}
834pub(crate) fn compress_planned_payloads(
835    objects: &[&EncodedObject],
836    plan: &[PlannedEntry],
837    order: &[usize],
838    compression_level: u32,
839) -> Result<Vec<Vec<u8>>> {
840    if order.is_empty() {
841        return Ok(Vec::new());
842    }
843
844    let worker_count = std::thread::available_parallelism()
845        .map(|threads| threads.get())
846        .unwrap_or(1)
847        .min(PACK_PARALLEL_COMPRESSION_MAX_THREADS)
848        .min(order.len());
849    if worker_count <= 1 || order.len() < PACK_PARALLEL_COMPRESSION_MIN_OBJECTS {
850        let mut payloads = Vec::with_capacity(order.len());
851        for &idx in order {
852            payloads.push(compressed_payload(
853                planned_payload(objects, plan, idx),
854                compression_level,
855            )?);
856        }
857        return Ok(payloads);
858    }
859
860    let chunk_len = order.len().div_ceil(worker_count);
861    let mut payloads: Vec<Vec<u8>> = std::iter::repeat_with(Vec::new).take(order.len()).collect();
862    std::thread::scope(|scope| {
863        let mut handles = Vec::new();
864        for (chunk_idx, chunk) in order.chunks(chunk_len).enumerate() {
865            let chunk_start = chunk_idx * chunk_len;
866            handles.push(scope.spawn(move || -> Result<Vec<(usize, Vec<u8>)>> {
867                let mut chunk_payloads = Vec::with_capacity(chunk.len());
868                for (offset, &idx) in chunk.iter().enumerate() {
869                    chunk_payloads.push((
870                        chunk_start + offset,
871                        compressed_payload(planned_payload(objects, plan, idx), compression_level)?,
872                    ));
873                }
874                Ok(chunk_payloads)
875            }));
876        }
877
878        let mut first_error = None;
879        for handle in handles {
880            match handle.join() {
881                Ok(Ok(chunk_payloads)) => {
882                    if first_error.is_none() {
883                        for (pos, payload) in chunk_payloads {
884                            payloads[pos] = payload;
885                        }
886                    }
887                }
888                Ok(Err(err)) => {
889                    first_error.get_or_insert(err);
890                }
891                Err(_) => {
892                    first_error.get_or_insert_with(|| {
893                        GitError::InvalidObject("pack compression worker panicked".into())
894                    });
895                }
896            }
897        }
898
899        match first_error {
900            Some(err) => Err(err),
901            None => Ok(()),
902        }
903    })?;
904    Ok(payloads)
905}
906
907pub(crate) fn compress_streaming_planned_payloads(
908    objects: &[Arc<EncodedObject>],
909    plan: &[StreamingPlannedEntry],
910    order: &[usize],
911    compression_level: u32,
912) -> Result<Vec<Vec<u8>>> {
913    if order.is_empty() {
914        return Ok(Vec::new());
915    }
916
917    let worker_count = std::thread::available_parallelism()
918        .map(|threads| threads.get())
919        .unwrap_or(1)
920        .min(PACK_PARALLEL_COMPRESSION_MAX_THREADS)
921        .min(order.len());
922    if worker_count <= 1 || order.len() < PACK_PARALLEL_COMPRESSION_MIN_OBJECTS {
923        let mut payloads = Vec::with_capacity(order.len());
924        for &idx in order {
925            payloads.push(compressed_payload(
926                streaming_planned_payload(objects, plan, idx),
927                compression_level,
928            )?);
929        }
930        return Ok(payloads);
931    }
932
933    let chunk_len = order.len().div_ceil(worker_count);
934    let mut payloads: Vec<Vec<u8>> = std::iter::repeat_with(Vec::new).take(order.len()).collect();
935    std::thread::scope(|scope| {
936        let mut handles = Vec::new();
937        for (chunk_idx, chunk) in order.chunks(chunk_len).enumerate() {
938            let chunk_start = chunk_idx * chunk_len;
939            handles.push(scope.spawn(move || -> Result<Vec<(usize, Vec<u8>)>> {
940                let mut chunk_payloads = Vec::with_capacity(chunk.len());
941                for (offset, &idx) in chunk.iter().enumerate() {
942                    chunk_payloads.push((
943                        chunk_start + offset,
944                        compressed_payload(
945                            streaming_planned_payload(objects, plan, idx),
946                            compression_level,
947                        )?,
948                    ));
949                }
950                Ok(chunk_payloads)
951            }));
952        }
953
954        let mut first_error = None;
955        for handle in handles {
956            match handle.join() {
957                Ok(Ok(chunk_payloads)) => {
958                    if first_error.is_none() {
959                        for (pos, payload) in chunk_payloads {
960                            payloads[pos] = payload;
961                        }
962                    }
963                }
964                Ok(Err(err)) => {
965                    first_error.get_or_insert(err);
966                }
967                Err(_) => {
968                    first_error.get_or_insert_with(|| {
969                        GitError::InvalidObject("pack compression worker panicked".into())
970                    });
971                }
972            }
973        }
974
975        match first_error {
976            Some(err) => Err(err),
977            None => Ok(()),
978        }
979    })?;
980    Ok(payloads)
981}
982
983pub(crate) fn compress_undeltified_payloads(
984    objects: &[Arc<EncodedObject>],
985    compression_level: u32,
986) -> Result<Vec<Vec<u8>>> {
987    if objects.is_empty() {
988        return Ok(Vec::new());
989    }
990
991    let worker_count = std::thread::available_parallelism()
992        .map(|threads| threads.get())
993        .unwrap_or(1)
994        .min(PACK_PARALLEL_COMPRESSION_MAX_THREADS)
995        .min(objects.len());
996    if worker_count <= 1 || objects.len() < PACK_PARALLEL_COMPRESSION_MIN_OBJECTS {
997        let mut payloads = Vec::with_capacity(objects.len());
998        for object in objects {
999            payloads.push(compressed_payload(&object.body, compression_level)?);
1000        }
1001        return Ok(payloads);
1002    }
1003
1004    let chunk_len = objects.len().div_ceil(worker_count);
1005    let mut payloads: Vec<Vec<u8>> = std::iter::repeat_with(Vec::new)
1006        .take(objects.len())
1007        .collect();
1008    std::thread::scope(|scope| {
1009        let mut handles = Vec::new();
1010        for (chunk_idx, chunk) in objects.chunks(chunk_len).enumerate() {
1011            let chunk_start = chunk_idx * chunk_len;
1012            handles.push(scope.spawn(move || -> Result<Vec<(usize, Vec<u8>)>> {
1013                let mut chunk_payloads = Vec::with_capacity(chunk.len());
1014                for (offset, object) in chunk.iter().enumerate() {
1015                    chunk_payloads.push((
1016                        chunk_start + offset,
1017                        compressed_payload(&object.body, compression_level)?,
1018                    ));
1019                }
1020                Ok(chunk_payloads)
1021            }));
1022        }
1023
1024        let mut first_error = None;
1025        for handle in handles {
1026            match handle.join() {
1027                Ok(Ok(chunk_payloads)) => {
1028                    if first_error.is_none() {
1029                        for (pos, payload) in chunk_payloads {
1030                            payloads[pos] = payload;
1031                        }
1032                    }
1033                }
1034                Ok(Err(err)) => {
1035                    first_error.get_or_insert(err);
1036                }
1037                Err(_) => {
1038                    first_error.get_or_insert_with(|| {
1039                        GitError::InvalidObject("pack compression worker panicked".into())
1040                    });
1041                }
1042            }
1043        }
1044
1045        match first_error {
1046            Some(err) => Err(err),
1047            None => Ok(()),
1048        }
1049    })?;
1050    Ok(payloads)
1051}
1052
1053pub(crate) fn streaming_planned_payload<'a>(
1054    objects: &'a [Arc<EncodedObject>],
1055    plan: &'a [StreamingPlannedEntry],
1056    idx: usize,
1057) -> &'a [u8] {
1058    match &plan[idx].base {
1059        StreamingPlannedBase::None => &objects[idx].body,
1060        StreamingPlannedBase::Current { delta, .. }
1061        | StreamingPlannedBase::Previous { delta, .. }
1062        | StreamingPlannedBase::External { delta, .. } => delta,
1063    }
1064}
1065
1066pub(crate) fn planned_payload<'a>(
1067    objects: &'a [&'a EncodedObject],
1068    plan: &'a [PlannedEntry],
1069    idx: usize,
1070) -> &'a [u8] {
1071    match &plan[idx].base {
1072        PlannedBase::None => &objects[idx].body,
1073        PlannedBase::InPack { delta, .. } | PlannedBase::External { delta, .. } => delta,
1074    }
1075}
1076
1077pub(crate) fn compressed_payload(body: &[u8], compression_level: u32) -> Result<Vec<u8>> {
1078    let mut out = Vec::new();
1079    write_compressed_payload(&mut out, body, compression_level)?;
1080    Ok(out)
1081}
1082pub(crate) fn write_compressed_payload(
1083    out: &mut Vec<u8>,
1084    body: &[u8],
1085    compression_level: u32,
1086) -> Result<()> {
1087    let mut compressor = Compress::new(Compression::new(compression_level.min(9)), true);
1088    out.reserve(zlib_compress_bound(body.len()));
1089    let status = compressor
1090        .compress_vec(body, out, FlushCompress::Finish)
1091        .map_err(|err| GitError::InvalidObject(format!("zlib compression failed: {err}")))?;
1092    if status != Status::StreamEnd || compressor.total_in() != body.len() as u64 {
1093        return Err(GitError::InvalidObject(
1094            "zlib compression did not finish pack entry".into(),
1095        ));
1096    }
1097    Ok(())
1098}
1099
1100pub(crate) fn zlib_compress_bound(len: usize) -> usize {
1101    len.saturating_add(len >> 12)
1102        .saturating_add(len >> 14)
1103        .saturating_add(len >> 25)
1104        .saturating_add(13)
1105}
1106
1107pub(crate) fn write_entry_header(out: &mut Vec<u8>, object_type: ObjectType, size: u64) {
1108    let type_code = match object_type {
1109        ObjectType::Commit => 1,
1110        ObjectType::Tree => 2,
1111        ObjectType::Blob => 3,
1112        ObjectType::Tag => 4,
1113    };
1114    write_pack_entry_header_kind(out, type_code, size);
1115}
1116
1117pub(crate) fn write_pack_entry_header_kind(out: &mut Vec<u8>, type_code: u8, mut size: u64) {
1118    let mut byte = (type_code << 4) | ((size as u8) & 0x0f);
1119    size >>= 4;
1120    if size != 0 {
1121        byte |= 0x80;
1122    }
1123    out.push(byte);
1124    while size != 0 {
1125        let mut byte = (size as u8) & 0x7f;
1126        size >>= 7;
1127        if size != 0 {
1128            byte |= 0x80;
1129        }
1130        out.push(byte);
1131    }
1132}
1133
1134pub(crate) fn write_ofs_delta_offset(out: &mut Vec<u8>, relative: u64) -> Result<()> {
1135    if relative == 0 {
1136        return Err(GitError::InvalidFormat(
1137            "ofs-delta relative offset cannot be zero".into(),
1138        ));
1139    }
1140    let mut value = relative;
1141    let mut bytes = vec![(value & 0x7f) as u8];
1142    value >>= 7;
1143    while value != 0 {
1144        value -= 1;
1145        bytes.push(((value & 0x7f) as u8) | 0x80);
1146        value >>= 7;
1147    }
1148    bytes.reverse();
1149    out.extend_from_slice(&bytes);
1150    Ok(())
1151}
1152/// Builder that assembles a reachability bitmap (`.bitmap`) for a pack.
1153///
1154/// The writer is constructed from the object layout of a pack (one
1155/// [`ObjectType`] per object, in pack order) and the pack's trailing checksum.
1156/// Callers then register one selected commit per [`add_commit`] call, supplying
1157/// the set of pack positions reachable from that commit. [`build`]/[`write`]
1158/// produce a [`PackBitmapIndex`] / serialised `.bitmap` bytes matching git's
1159/// on-disk format (signature `BITM`, version 1).
1160///
1161/// [`add_commit`]: PackBitmapWriter::add_commit
1162/// [`build`]: PackBitmapWriter::build
1163/// [`write`]: PackBitmapWriter::write
1164#[derive(Debug, Clone)]
1165pub struct PackBitmapWriter {
1166    format: ObjectFormat,
1167    pack_checksum: ObjectId,
1168    object_count: u32,
1169    commit_positions: Vec<u32>,
1170    tree_positions: Vec<u32>,
1171    blob_positions: Vec<u32>,
1172    tag_positions: Vec<u32>,
1173    name_hash_cache: Option<Vec<u32>>,
1174    write_lookup_table: bool,
1175    selected: Vec<SelectedCommit>,
1176    pseudo_merges: Vec<PackBitmapPseudoMerge>,
1177}
1178
1179#[derive(Debug, Clone)]
1180pub(crate) struct SelectedCommit {
1181    /// Oid-sorted `.idx` position (what the on-disk entry records). The
1182    /// commit's pack-order position lives in `reachable` with the rest of the
1183    /// bits.
1184    commit_index_position: u32,
1185    flags: u8,
1186    reachable: Vec<u32>,
1187}
1188
1189impl PackBitmapWriter {
1190    /// `OBJ_NONE` selection flag: this commit's bitmap is stored in full (no XOR
1191    /// compression against a previously selected commit). This is the only flag
1192    /// value this writer emits.
1193    pub const FLAG_NONE: u8 = 0;
1194
1195    /// Creates a writer for a pack whose objects (in pack order) have the given
1196    /// [`ObjectType`]s and whose trailing checksum is `pack_checksum`.
1197    ///
1198    /// Returns an error if the pack contains more than `u32::MAX` objects, if
1199    /// `pack_checksum`'s format does not match `format`, or if any object type
1200    /// is not one of the four reachable git object kinds.
1201    pub fn new(
1202        format: ObjectFormat,
1203        pack_checksum: ObjectId,
1204        object_types: &[ObjectType],
1205    ) -> Result<Self> {
1206        if object_types.len() > u32::MAX as usize {
1207            return Err(GitError::InvalidFormat(
1208                "too many objects for a pack bitmap".into(),
1209            ));
1210        }
1211        if pack_checksum.format() != format {
1212            return Err(GitError::InvalidObjectId(
1213                "pack checksum format does not match bitmap format".into(),
1214            ));
1215        }
1216        let object_count = object_types.len() as u32;
1217        let mut commit_positions = Vec::new();
1218        let mut tree_positions = Vec::new();
1219        let mut blob_positions = Vec::new();
1220        let mut tag_positions = Vec::new();
1221        for (index, object_type) in object_types.iter().enumerate() {
1222            let position = index as u32;
1223            match object_type {
1224                ObjectType::Commit => commit_positions.push(position),
1225                ObjectType::Tree => tree_positions.push(position),
1226                ObjectType::Blob => blob_positions.push(position),
1227                ObjectType::Tag => tag_positions.push(position),
1228            }
1229        }
1230        Ok(Self {
1231            format,
1232            pack_checksum,
1233            object_count,
1234            commit_positions,
1235            tree_positions,
1236            blob_positions,
1237            tag_positions,
1238            name_hash_cache: None,
1239            write_lookup_table: false,
1240            selected: Vec::new(),
1241            pseudo_merges: Vec::new(),
1242        })
1243    }
1244
1245    /// Attaches a name-hash cache (one `u32` per object, in pack order). When
1246    /// set, the written bitmap advertises [`PackBitmapIndex::OPTION_HASH_CACHE`]
1247    /// and appends the cache after the bitmap entries, exactly as git does.
1248    ///
1249    /// Returns an error if the cache length does not equal the object count.
1250    pub fn with_name_hash_cache(mut self, cache: Vec<u32>) -> Result<Self> {
1251        if cache.len() != self.object_count as usize {
1252            return Err(GitError::InvalidFormat(format!(
1253                "name hash cache has {} entries but pack has {} objects",
1254                cache.len(),
1255                self.object_count
1256            )));
1257        }
1258        self.name_hash_cache = Some(cache);
1259        Ok(self)
1260    }
1261
1262    /// Enable the commit lookup-table extension. Each row is derived from the
1263    /// selected commit entries when the bitmap is serialised.
1264    pub fn with_lookup_table(mut self, enabled: bool) -> Self {
1265        self.write_lookup_table = enabled;
1266        self
1267    }
1268
1269    /// Registers a selected commit and the pack positions reachable from it.
1270    ///
1271    /// `commit_position` is the *pack-order* position of the commit itself (the
1272    /// bit-number space); it must reference a commit object and is implicitly
1273    /// part of the reachable set. `commit_index_position` is the commit's
1274    /// position in the *oid-sorted* pack index — this is what the on-disk entry
1275    /// records (upstream `oid_pos`); bits and entry positions live in different
1276    /// spaces. `reachable` lists the pack-order positions of every object
1277    /// reachable from the commit (it may include or omit `commit_position`;
1278    /// duplicates are fine). All positions must be in range. The commit's full
1279    /// (non-XORed) bitmap is stored.
1280    pub fn add_commit(
1281        &mut self,
1282        commit_position: u32,
1283        commit_index_position: u32,
1284        reachable: &[u32],
1285    ) -> Result<()> {
1286        if commit_position >= self.object_count {
1287            return Err(GitError::InvalidFormat(format!(
1288                "commit position {commit_position} out of range for {} objects",
1289                self.object_count
1290            )));
1291        }
1292        if commit_index_position >= self.object_count {
1293            return Err(GitError::InvalidFormat(format!(
1294                "commit index position {commit_index_position} out of range for {} objects",
1295                self.object_count
1296            )));
1297        }
1298        if !self.commit_positions.contains(&commit_position) {
1299            return Err(GitError::InvalidFormat(format!(
1300                "bitmap commit position {commit_position} is not a commit object"
1301            )));
1302        }
1303        for &position in reachable {
1304            if position >= self.object_count {
1305                return Err(GitError::InvalidFormat(format!(
1306                    "reachable position {position} out of range for {} objects",
1307                    self.object_count
1308                )));
1309            }
1310        }
1311        let mut reachable = reachable.to_vec();
1312        reachable.push(commit_position);
1313        self.selected.push(SelectedCommit {
1314            commit_index_position,
1315            flags: Self::FLAG_NONE,
1316            reachable,
1317        });
1318        Ok(())
1319    }
1320
1321    /// Registers a pseudo-merge bitmap. Both `commits` and `reachable` are
1322    /// positions in the bitmap's bit-numbering order (pack order for a single
1323    /// pack, pseudo-pack order for a MIDX). Every commit position must refer to
1324    /// a commit object; every reachable position must be in range.
1325    pub fn add_pseudo_merge(&mut self, commits: &[u32], reachable: &[u32]) -> Result<()> {
1326        if commits.is_empty() {
1327            return Err(GitError::InvalidFormat(
1328                "pseudo-merge must contain at least one commit".into(),
1329            ));
1330        }
1331        for &position in commits {
1332            if position >= self.object_count {
1333                return Err(GitError::InvalidFormat(format!(
1334                    "pseudo-merge commit position {position} out of range for {} objects",
1335                    self.object_count
1336                )));
1337            }
1338            if !self.commit_positions.contains(&position) {
1339                return Err(GitError::InvalidFormat(format!(
1340                    "pseudo-merge commit position {position} is not a commit object"
1341                )));
1342            }
1343        }
1344        for &position in reachable {
1345            if position >= self.object_count {
1346                return Err(GitError::InvalidFormat(format!(
1347                    "pseudo-merge reachable position {position} out of range for {} objects",
1348                    self.object_count
1349                )));
1350            }
1351        }
1352        self.pseudo_merges.push(PackBitmapPseudoMerge {
1353            commits: EwahBitmap::from_positions(self.object_count, commits)?,
1354            bitmap: EwahBitmap::from_positions(self.object_count, reachable)?,
1355        });
1356        Ok(())
1357    }
1358
1359    /// Builds the in-memory [`PackBitmapIndex`] without serialising it.
1360    ///
1361    /// The resulting index always advertises
1362    /// [`PackBitmapIndex::OPTION_FULL_DAG`] (the four type bitmaps fully cover
1363    /// the pack) and, when a name-hash cache was attached,
1364    /// [`PackBitmapIndex::OPTION_HASH_CACHE`].
1365    pub fn build(&self) -> Result<PackBitmapIndex> {
1366        let commits = EwahBitmap::from_positions(self.object_count, &self.commit_positions)?;
1367        let trees = EwahBitmap::from_positions(self.object_count, &self.tree_positions)?;
1368        let blobs = EwahBitmap::from_positions(self.object_count, &self.blob_positions)?;
1369        let tags = EwahBitmap::from_positions(self.object_count, &self.tag_positions)?;
1370
1371        let mut entries = Vec::with_capacity(self.selected.len());
1372        for selected in &self.selected {
1373            let bitmap = EwahBitmap::from_positions(self.object_count, &selected.reachable)?;
1374            entries.push(PackBitmapEntry {
1375                object_position: selected.commit_index_position,
1376                xor_offset: 0,
1377                flags: selected.flags,
1378                bitmap,
1379            });
1380        }
1381
1382        let mut options = PackBitmapIndex::OPTION_FULL_DAG;
1383        if self.name_hash_cache.is_some() {
1384            options |= PackBitmapIndex::OPTION_HASH_CACHE;
1385        }
1386        if !self.pseudo_merges.is_empty() {
1387            options |= PackBitmapIndex::OPTION_PSEUDO_MERGES;
1388        }
1389        if self.write_lookup_table {
1390            options |= PackBitmapIndex::OPTION_LOOKUP_TABLE;
1391        }
1392
1393        // The index checksum is only known once the body is serialised; the
1394        // dedicated `write` path fills it in. `build` reports a placeholder of
1395        // the correct format so the struct is self-consistent for callers that
1396        // only need the decoded bitmaps.
1397        let placeholder_checksum = ObjectId::null(self.format);
1398        Ok(PackBitmapIndex {
1399            version: 1,
1400            format: self.format,
1401            options,
1402            pack_checksum: self.pack_checksum.clone(),
1403            index_checksum: placeholder_checksum,
1404            type_bitmaps: PackBitmapTypeBitmaps {
1405                commits,
1406                trees,
1407                blobs,
1408                tags,
1409            },
1410            entries,
1411            pseudo_merges: self.pseudo_merges.clone(),
1412            lookup_table: self.write_lookup_table,
1413            name_hash_cache: self.name_hash_cache.clone(),
1414        })
1415    }
1416
1417    /// Builds and serialises the `.bitmap` file, returning the on-disk bytes
1418    /// (including the trailing index checksum).
1419    pub fn write(&self) -> Result<Vec<u8>> {
1420        self.build()?.write()
1421    }
1422}
1423
1424impl PackBitmapIndex {
1425    /// Serialises this index into git's on-disk `.bitmap` byte layout.
1426    ///
1427    /// This is the exact inverse of [`PackBitmapIndex::parse`]: signature
1428    /// `BITM`, version (u16 BE), options (u16 BE), entry count (u32 BE), the
1429    /// pack checksum, the four type bitmaps (commits, trees, blobs, tags), each
1430    /// commit entry (object position, XOR offset, flags, EWAH bitmap), the
1431    /// optional pseudo-merge extension, the optional name-hash cache, and
1432    /// finally the trailing index checksum over everything written so far.
1433    ///
1434    /// The `index_checksum` field of `self` is ignored and recomputed from the
1435    /// serialised body. Returns an error for unsupported versions, mismatched
1436    /// object-id formats, an oversized entry table, or an inconsistent name-hash
1437    /// cache.
1438    pub fn write(&self) -> Result<Vec<u8>> {
1439        if self.version != 1 {
1440            return Err(GitError::Unsupported(format!(
1441                "bitmap index version {}",
1442                self.version
1443            )));
1444        }
1445        let mut options = self.options;
1446        if !self.pseudo_merges.is_empty() {
1447            options |= Self::OPTION_PSEUDO_MERGES;
1448        }
1449        if self.lookup_table {
1450            options |= Self::OPTION_LOOKUP_TABLE;
1451        }
1452        let known_options = Self::OPTION_FULL_DAG
1453            | Self::OPTION_HASH_CACHE
1454            | Self::OPTION_LOOKUP_TABLE
1455            | Self::OPTION_PSEUDO_MERGES;
1456        if options & !known_options != 0 {
1457            return Err(GitError::Unsupported(format!(
1458                "bitmap index options {:#06x}",
1459                options & !known_options
1460            )));
1461        }
1462        if self.pack_checksum.format() != self.format {
1463            return Err(GitError::InvalidObjectId(
1464                "bitmap pack checksum format does not match index format".into(),
1465            ));
1466        }
1467        if self.entries.len() > u32::MAX as usize {
1468            return Err(GitError::InvalidFormat(
1469                "too many bitmap index entries".into(),
1470            ));
1471        }
1472        if options & Self::OPTION_PSEUDO_MERGES != 0 && self.pseudo_merges.is_empty() {
1473            return Err(GitError::InvalidFormat(
1474                "OPTION_PSEUDO_MERGES set without pseudo-merge records".into(),
1475            ));
1476        }
1477        let want_cache = options & Self::OPTION_HASH_CACHE != 0;
1478        match (&self.name_hash_cache, want_cache) {
1479            (Some(_), false) => {
1480                return Err(GitError::InvalidFormat(
1481                    "name hash cache present without OPTION_HASH_CACHE".into(),
1482                ));
1483            }
1484            (None, true) => {
1485                return Err(GitError::InvalidFormat(
1486                    "OPTION_HASH_CACHE set without a name hash cache".into(),
1487                ));
1488            }
1489            _ => {}
1490        }
1491
1492        let mut out = Vec::new();
1493        out.extend_from_slice(b"BITM");
1494        out.extend_from_slice(&self.version.to_be_bytes());
1495        out.extend_from_slice(&options.to_be_bytes());
1496        out.extend_from_slice(&(self.entries.len() as u32).to_be_bytes());
1497        out.extend_from_slice(self.pack_checksum.as_bytes());
1498
1499        self.type_bitmaps.commits.append_bytes(&mut out);
1500        self.type_bitmaps.trees.append_bytes(&mut out);
1501        self.type_bitmaps.blobs.append_bytes(&mut out);
1502        self.type_bitmaps.tags.append_bytes(&mut out);
1503
1504        let mut entry_offsets = Vec::with_capacity(self.entries.len());
1505        for (idx, entry) in self.entries.iter().enumerate() {
1506            if entry.xor_offset as usize > idx {
1507                return Err(GitError::InvalidFormat(
1508                    "bitmap index entry has invalid XOR offset".into(),
1509                ));
1510            }
1511            entry_offsets.push(out.len() as u64);
1512            out.extend_from_slice(&entry.object_position.to_be_bytes());
1513            out.push(entry.xor_offset);
1514            out.push(entry.flags);
1515            entry.bitmap.append_bytes(&mut out);
1516        }
1517
1518        if !self.pseudo_merges.is_empty() {
1519            append_bitmap_pseudo_merges(&mut out, &self.pseudo_merges)?;
1520        }
1521
1522        if self.lookup_table {
1523            append_bitmap_lookup_table(&mut out, &self.entries, &entry_offsets)?;
1524        }
1525
1526        if let Some(cache) = &self.name_hash_cache {
1527            for value in cache {
1528                out.extend_from_slice(&value.to_be_bytes());
1529            }
1530        }
1531
1532        let checksum = sley_core::digest_bytes(self.format, &out)?;
1533        out.extend_from_slice(checksum.as_bytes());
1534        Ok(out)
1535    }
1536}
1537
1538fn append_bitmap_lookup_table(
1539    out: &mut Vec<u8>,
1540    entries: &[PackBitmapEntry],
1541    entry_offsets: &[u64],
1542) -> Result<()> {
1543    if entries.len() != entry_offsets.len() {
1544        return Err(GitError::InvalidFormat(
1545            "bitmap lookup table offset count mismatch".into(),
1546        ));
1547    }
1548    let mut table: Vec<usize> = (0..entries.len()).collect();
1549    table.sort_by_key(|&index| entries[index].object_position);
1550    let mut inverse = vec![0u32; entries.len()];
1551    for (row, &entry_index) in table.iter().enumerate() {
1552        inverse[entry_index] = row as u32;
1553    }
1554    for &entry_index in &table {
1555        let entry = &entries[entry_index];
1556        let xor_row = if entry.xor_offset == 0 {
1557            u32::MAX
1558        } else {
1559            let base = entry_index
1560                .checked_sub(entry.xor_offset as usize)
1561                .ok_or_else(|| {
1562                    GitError::InvalidFormat("bitmap lookup table XOR base underflow".into())
1563                })?;
1564            inverse[base]
1565        };
1566        out.extend_from_slice(&entry.object_position.to_be_bytes());
1567        out.extend_from_slice(&entry_offsets[entry_index].to_be_bytes());
1568        out.extend_from_slice(&xor_row.to_be_bytes());
1569    }
1570    Ok(())
1571}
1572
1573pub(crate) fn append_bitmap_pseudo_merges(
1574    out: &mut Vec<u8>,
1575    pseudo_merges: &[PackBitmapPseudoMerge],
1576) -> Result<()> {
1577    if pseudo_merges.len() > u32::MAX as usize {
1578        return Err(GitError::InvalidFormat(
1579            "too many pseudo-merge bitmap records".into(),
1580        ));
1581    }
1582    let start = out.len();
1583    let mut pseudo_offsets = Vec::with_capacity(pseudo_merges.len());
1584    let mut commit_to_offsets: BTreeMap<u32, Vec<u64>> = BTreeMap::new();
1585    for merge in pseudo_merges {
1586        let offset = u64::try_from(out.len())
1587            .map_err(|_| GitError::InvalidFormat("bitmap file offset overflow".into()))?;
1588        pseudo_offsets.push(offset);
1589        for commit_pos in merge.commits.to_positions()? {
1590            commit_to_offsets
1591                .entry(commit_pos)
1592                .or_default()
1593                .push(offset);
1594        }
1595        merge.commits.append_bytes(out);
1596        merge.bitmap.append_bytes(out);
1597    }
1598    if commit_to_offsets.len() > u32::MAX as usize {
1599        return Err(GitError::InvalidFormat(
1600            "too many pseudo-merge commits".into(),
1601        ));
1602    }
1603
1604    let lookup_start = out.len();
1605    let lookup_len = commit_to_offsets
1606        .len()
1607        .checked_mul(12)
1608        .ok_or_else(|| GitError::InvalidFormat("pseudo-merge lookup overflow".into()))?;
1609    let mut next_extended = u64::try_from(
1610        lookup_start
1611            .checked_add(lookup_len)
1612            .ok_or_else(|| GitError::InvalidFormat("pseudo-merge lookup overflow".into()))?,
1613    )
1614    .map_err(|_| GitError::InvalidFormat("bitmap file offset overflow".into()))?;
1615    let mut rows = Vec::with_capacity(commit_to_offsets.len());
1616    for (commit_pos, offsets) in commit_to_offsets {
1617        let extended_offset = if offsets.len() > 1 {
1618            if next_extended & (1u64 << 63) != 0 {
1619                return Err(GitError::InvalidFormat(
1620                    "pseudo-merge extended offset overflow".into(),
1621                ));
1622            }
1623            let offset = next_extended;
1624            let ext_len = offsets
1625                .len()
1626                .checked_mul(8)
1627                .and_then(|len| len.checked_add(4))
1628                .ok_or_else(|| {
1629                    GitError::InvalidFormat("pseudo-merge extended lookup overflow".into())
1630                })?;
1631            next_extended = next_extended.checked_add(ext_len as u64).ok_or_else(|| {
1632                GitError::InvalidFormat("pseudo-merge extended lookup overflow".into())
1633            })?;
1634            Some(offset)
1635        } else {
1636            None
1637        };
1638        rows.push((commit_pos, offsets, extended_offset));
1639    }
1640
1641    for (commit_pos, offsets, extended_offset) in &rows {
1642        out.extend_from_slice(&commit_pos.to_be_bytes());
1643        match extended_offset {
1644            Some(offset) => out.extend_from_slice(&(offset | (1u64 << 63)).to_be_bytes()),
1645            None => out.extend_from_slice(&offsets[0].to_be_bytes()),
1646        }
1647    }
1648
1649    for (_commit_pos, offsets, extended_offset) in &rows {
1650        if extended_offset.is_none() {
1651            continue;
1652        }
1653        let count = u32::try_from(offsets.len())
1654            .map_err(|_| GitError::InvalidFormat("pseudo-merge extended lookup overflow".into()))?;
1655        out.extend_from_slice(&count.to_be_bytes());
1656        for offset in offsets {
1657            out.extend_from_slice(&offset.to_be_bytes());
1658        }
1659    }
1660
1661    for offset in &pseudo_offsets {
1662        out.extend_from_slice(&offset.to_be_bytes());
1663    }
1664    out.extend_from_slice(&(pseudo_merges.len() as u32).to_be_bytes());
1665    out.extend_from_slice(&(rows.len() as u32).to_be_bytes());
1666    let lookup_relative = lookup_start
1667        .checked_sub(start)
1668        .ok_or_else(|| GitError::InvalidFormat("pseudo-merge lookup underflow".into()))?;
1669    out.extend_from_slice(&(lookup_relative as u64).to_be_bytes());
1670    let extension_size = out
1671        .len()
1672        .checked_sub(start)
1673        .and_then(|len| len.checked_add(8))
1674        .ok_or_else(|| GitError::InvalidFormat("pseudo-merge extension overflow".into()))?;
1675    out.extend_from_slice(&(extension_size as u64).to_be_bytes());
1676    Ok(())
1677}
1678
1679/// Convenience wrapper that builds a `.bitmap` file in one call.
1680///
1681/// `object_types` lists the [`ObjectType`] of every pack object in pack order,
1682/// `pack_checksum` is the pack's trailing checksum, and `commits` carries, per
1683/// selected commit, `(pack_position, index_position, reachable_pack_positions)`
1684/// (see [`PackBitmapWriter::add_commit`] for the two position spaces). An
1685/// optional `name_hash_cache` (one entry per object) may be supplied to emit
1686/// the hash-cache extension.
1687pub fn write_bitmap(
1688    format: ObjectFormat,
1689    pack_checksum: ObjectId,
1690    object_types: &[ObjectType],
1691    commits: &[(u32, u32, Vec<u32>)],
1692    name_hash_cache: Option<Vec<u32>>,
1693) -> Result<Vec<u8>> {
1694    let mut writer = PackBitmapWriter::new(format, pack_checksum, object_types)?;
1695    if let Some(cache) = name_hash_cache {
1696        writer = writer.with_name_hash_cache(cache)?;
1697    }
1698    for (commit_position, commit_index_position, reachable) in commits {
1699        writer.add_commit(*commit_position, *commit_index_position, reachable)?;
1700    }
1701    writer.write()
1702}