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        mut read_object: F,
519        writer: &mut W,
520    ) -> Result<PackWriteSummary>
521    where
522        W: Write,
523        F: FnMut(&ObjectId) -> Result<Arc<EncodedObject>>,
524    {
525        let mut seen = HashSet::with_capacity(object_ids.len());
526        for oid in object_ids {
527            if oid.format() != format {
528                return Err(GitError::InvalidObjectId(
529                    "pack object id format does not match pack format".into(),
530                ));
531            }
532            if !seen.insert(oid) {
533                return Err(GitError::InvalidFormat(format!(
534                    "pack contains duplicate object id {oid}"
535                )));
536            }
537        }
538
539        let mut output = PackDigestWriter::new(writer, format);
540        output.write_pack_bytes(b"PACK")?;
541        output.write_pack_bytes(&2u32.to_be_bytes())?;
542        output.write_pack_bytes(&(object_ids.len() as u32).to_be_bytes())?;
543
544        let mut index_entries = Vec::with_capacity(object_ids.len());
545        for oid_window in object_ids.chunks(PACK_STREAM_COMPRESSION_WINDOW_OBJECTS) {
546            let mut objects = Vec::with_capacity(oid_window.len());
547            for oid in oid_window {
548                objects.push(read_object(oid)?);
549            }
550            let compressed_payloads =
551                compress_undeltified_payloads(&objects, options.compression_level)?;
552            for ((oid, object), compressed_payload) in
553                oid_window.iter().zip(&objects).zip(&compressed_payloads)
554            {
555                let offset = output.position();
556                let mut entry_header = Vec::new();
557                write_entry_header(
558                    &mut entry_header,
559                    object.object_type,
560                    object.body.len() as u64,
561                );
562                let mut crc32 = crc32fast::Hasher::new();
563                crc32.update(&entry_header);
564                crc32.update(compressed_payload);
565                output.write_pack_bytes(&entry_header)?;
566                output.write_pack_bytes(compressed_payload)?;
567                index_entries.push(PackIndexEntry {
568                    oid: *oid,
569                    crc32: crc32.finalize(),
570                    offset,
571                });
572            }
573        }
574
575        let (checksum, pack_size) = output.finish()?;
576        let index = PackIndex::write_v2(format, &index_entries, &checksum)?;
577        Ok(PackWriteSummary {
578            index,
579            checksum,
580            entries: index_entries,
581            delta_count: 0,
582            pack_size,
583        })
584    }
585
586    pub fn write_packed_from_source_to_writer<W, F>(
587        object_ids: &[ObjectId],
588        format: ObjectFormat,
589        options: &PackWriteOptions,
590        mut read_object: F,
591        writer: &mut W,
592    ) -> Result<PackWriteSummary>
593    where
594        W: Write,
595        F: FnMut(&ObjectId) -> Result<Arc<EncodedObject>>,
596    {
597        if object_ids.len() > u32::MAX as usize {
598            return Err(GitError::InvalidFormat("too many pack objects".into()));
599        }
600
601        let mut seen = HashSet::with_capacity(object_ids.len());
602        for oid in object_ids {
603            if oid.format() != format {
604                return Err(GitError::InvalidObjectId(
605                    "pack object id format does not match pack format".into(),
606                ));
607            }
608            if !seen.insert(*oid) {
609                return Err(GitError::InvalidFormat(format!(
610                    "pack contains duplicate object id {oid}"
611                )));
612            }
613        }
614
615        for oid in options.thin_bases.keys() {
616            if oid.format() != format {
617                return Err(GitError::InvalidObjectId(
618                    "thin pack base object id format does not match pack format".into(),
619                ));
620            }
621        }
622
623        let mut output = PackDigestWriter::new(writer, format);
624        output.write_pack_bytes(b"PACK")?;
625        output.write_pack_bytes(&2u32.to_be_bytes())?;
626        output.write_pack_bytes(&(object_ids.len() as u32).to_be_bytes())?;
627
628        let mut index_entries = Vec::with_capacity(object_ids.len());
629        let mut delta_count = 0u32;
630        let mut base_horizon: VecDeque<StreamingDeltaBase> = VecDeque::new();
631
632        for oid_window in object_ids.chunks(PACK_STREAM_COMPRESSION_WINDOW_OBJECTS) {
633            let mut objects = Vec::with_capacity(oid_window.len());
634            for oid in oid_window {
635                objects.push(read_object(oid)?);
636            }
637
638            let (plan, order) =
639                plan_streaming_window_deltas(&objects, oid_window, &base_horizon, options);
640            let compressed_payloads = compress_streaming_planned_payloads(
641                &objects,
642                &plan,
643                &order,
644                options.compression_level,
645            )?;
646            let mut written_offsets: Vec<Option<u64>> = vec![None; objects.len()];
647
648            for (&idx, compressed_payload) in order.iter().zip(&compressed_payloads) {
649                let offset = output.position();
650                let mut entry_header = Vec::new();
651                match &plan[idx].base {
652                    StreamingPlannedBase::None => {
653                        write_entry_header(
654                            &mut entry_header,
655                            objects[idx].object_type,
656                            objects[idx].body.len() as u64,
657                        );
658                    }
659                    StreamingPlannedBase::Current { base_idx, delta } => {
660                        delta_count += 1;
661                        let base_offset = written_offsets[*base_idx].ok_or_else(|| {
662                            GitError::InvalidFormat(
663                                "in-pack delta base emitted after dependent object".into(),
664                            )
665                        })?;
666                        if options.prefer_ofs_delta {
667                            write_pack_entry_header_kind(&mut entry_header, 6, delta.len() as u64);
668                            let relative = offset.checked_sub(base_offset).ok_or_else(|| {
669                                GitError::InvalidFormat(
670                                    "ofs-delta base offset is after delta".into(),
671                                )
672                            })?;
673                            write_ofs_delta_offset(&mut entry_header, relative)?;
674                        } else {
675                            write_pack_entry_header_kind(&mut entry_header, 7, delta.len() as u64);
676                            entry_header.extend_from_slice(oid_window[*base_idx].as_bytes());
677                        }
678                    }
679                    StreamingPlannedBase::Previous {
680                        base_oid,
681                        base_offset,
682                        delta,
683                    } => {
684                        delta_count += 1;
685                        if options.prefer_ofs_delta {
686                            write_pack_entry_header_kind(&mut entry_header, 6, delta.len() as u64);
687                            let relative = offset.checked_sub(*base_offset).ok_or_else(|| {
688                                GitError::InvalidFormat(
689                                    "ofs-delta base offset is after delta".into(),
690                                )
691                            })?;
692                            write_ofs_delta_offset(&mut entry_header, relative)?;
693                        } else {
694                            write_pack_entry_header_kind(&mut entry_header, 7, delta.len() as u64);
695                            entry_header.extend_from_slice(base_oid.as_bytes());
696                        }
697                    }
698                    StreamingPlannedBase::External { base_oid, delta } => {
699                        delta_count += 1;
700                        write_pack_entry_header_kind(&mut entry_header, 7, delta.len() as u64);
701                        entry_header.extend_from_slice(base_oid.as_bytes());
702                    }
703                }
704
705                let mut crc32 = crc32fast::Hasher::new();
706                crc32.update(&entry_header);
707                crc32.update(compressed_payload);
708                output.write_pack_bytes(&entry_header)?;
709                output.write_pack_bytes(compressed_payload)?;
710                written_offsets[idx] = Some(offset);
711                index_entries.push(PackIndexEntry {
712                    oid: oid_window[idx],
713                    crc32: crc32.finalize(),
714                    offset,
715                });
716
717                if options.depth > 0 && options.window > 0 {
718                    base_horizon.push_back(StreamingDeltaBase {
719                        oid: oid_window[idx],
720                        object: Arc::clone(&objects[idx]),
721                        offset,
722                        depth: plan[idx].depth,
723                    });
724                    while base_horizon.len() > options.window {
725                        base_horizon.pop_front();
726                    }
727                }
728            }
729        }
730
731        let (checksum, pack_size) = output.finish()?;
732        let index = PackIndex::write_v2(format, &index_entries, &checksum)?;
733        Ok(PackWriteSummary {
734            index,
735            checksum,
736            entries: index_entries,
737            delta_count,
738            pack_size,
739        })
740    }
741}
742
743pub(crate) struct PackDigestWriter<'a, W> {
744    writer: &'a mut W,
745    digest: StreamingDigest,
746    position: u64,
747}
748
749impl<'a, W> PackDigestWriter<'a, W>
750where
751    W: Write,
752{
753    pub(crate) fn new(writer: &'a mut W, format: ObjectFormat) -> Self {
754        Self {
755            writer,
756            digest: StreamingDigest::new(format),
757            position: 0,
758        }
759    }
760
761    pub(crate) fn position(&self) -> u64 {
762        self.position
763    }
764
765    pub(crate) fn write_pack_bytes(&mut self, bytes: &[u8]) -> Result<()> {
766        self.writer.write_all(bytes)?;
767        self.digest.update(bytes);
768        self.position = self
769            .position
770            .checked_add(bytes.len() as u64)
771            .ok_or_else(|| GitError::InvalidFormat("pack offset overflow".into()))?;
772        Ok(())
773    }
774
775    pub(crate) fn finish(mut self) -> Result<(ObjectId, u64)> {
776        let checksum = self.digest.finalize()?;
777        self.writer.write_all(checksum.as_bytes())?;
778        self.position = self
779            .position
780            .checked_add(checksum.as_bytes().len() as u64)
781            .ok_or_else(|| GitError::InvalidFormat("pack offset overflow".into()))?;
782        Ok((checksum, self.position))
783    }
784}
785pub(crate) fn compress_planned_payloads(
786    objects: &[&EncodedObject],
787    plan: &[PlannedEntry],
788    order: &[usize],
789    compression_level: u32,
790) -> Result<Vec<Vec<u8>>> {
791    if order.is_empty() {
792        return Ok(Vec::new());
793    }
794
795    let worker_count = std::thread::available_parallelism()
796        .map(|threads| threads.get())
797        .unwrap_or(1)
798        .min(PACK_PARALLEL_COMPRESSION_MAX_THREADS)
799        .min(order.len());
800    if worker_count <= 1 || order.len() < PACK_PARALLEL_COMPRESSION_MIN_OBJECTS {
801        let mut payloads = Vec::with_capacity(order.len());
802        for &idx in order {
803            payloads.push(compressed_payload(
804                planned_payload(objects, plan, idx),
805                compression_level,
806            )?);
807        }
808        return Ok(payloads);
809    }
810
811    let chunk_len = order.len().div_ceil(worker_count);
812    let mut payloads: Vec<Vec<u8>> = std::iter::repeat_with(Vec::new).take(order.len()).collect();
813    std::thread::scope(|scope| {
814        let mut handles = Vec::new();
815        for (chunk_idx, chunk) in order.chunks(chunk_len).enumerate() {
816            let chunk_start = chunk_idx * chunk_len;
817            handles.push(scope.spawn(move || -> Result<Vec<(usize, Vec<u8>)>> {
818                let mut chunk_payloads = Vec::with_capacity(chunk.len());
819                for (offset, &idx) in chunk.iter().enumerate() {
820                    chunk_payloads.push((
821                        chunk_start + offset,
822                        compressed_payload(planned_payload(objects, plan, idx), compression_level)?,
823                    ));
824                }
825                Ok(chunk_payloads)
826            }));
827        }
828
829        let mut first_error = None;
830        for handle in handles {
831            match handle.join() {
832                Ok(Ok(chunk_payloads)) => {
833                    if first_error.is_none() {
834                        for (pos, payload) in chunk_payloads {
835                            payloads[pos] = payload;
836                        }
837                    }
838                }
839                Ok(Err(err)) => {
840                    first_error.get_or_insert(err);
841                }
842                Err(_) => {
843                    first_error.get_or_insert_with(|| {
844                        GitError::InvalidObject("pack compression worker panicked".into())
845                    });
846                }
847            }
848        }
849
850        match first_error {
851            Some(err) => Err(err),
852            None => Ok(()),
853        }
854    })?;
855    Ok(payloads)
856}
857
858pub(crate) fn compress_streaming_planned_payloads(
859    objects: &[Arc<EncodedObject>],
860    plan: &[StreamingPlannedEntry],
861    order: &[usize],
862    compression_level: u32,
863) -> Result<Vec<Vec<u8>>> {
864    if order.is_empty() {
865        return Ok(Vec::new());
866    }
867
868    let worker_count = std::thread::available_parallelism()
869        .map(|threads| threads.get())
870        .unwrap_or(1)
871        .min(PACK_PARALLEL_COMPRESSION_MAX_THREADS)
872        .min(order.len());
873    if worker_count <= 1 || order.len() < PACK_PARALLEL_COMPRESSION_MIN_OBJECTS {
874        let mut payloads = Vec::with_capacity(order.len());
875        for &idx in order {
876            payloads.push(compressed_payload(
877                streaming_planned_payload(objects, plan, idx),
878                compression_level,
879            )?);
880        }
881        return Ok(payloads);
882    }
883
884    let chunk_len = order.len().div_ceil(worker_count);
885    let mut payloads: Vec<Vec<u8>> = std::iter::repeat_with(Vec::new).take(order.len()).collect();
886    std::thread::scope(|scope| {
887        let mut handles = Vec::new();
888        for (chunk_idx, chunk) in order.chunks(chunk_len).enumerate() {
889            let chunk_start = chunk_idx * chunk_len;
890            handles.push(scope.spawn(move || -> Result<Vec<(usize, Vec<u8>)>> {
891                let mut chunk_payloads = Vec::with_capacity(chunk.len());
892                for (offset, &idx) in chunk.iter().enumerate() {
893                    chunk_payloads.push((
894                        chunk_start + offset,
895                        compressed_payload(
896                            streaming_planned_payload(objects, plan, idx),
897                            compression_level,
898                        )?,
899                    ));
900                }
901                Ok(chunk_payloads)
902            }));
903        }
904
905        let mut first_error = None;
906        for handle in handles {
907            match handle.join() {
908                Ok(Ok(chunk_payloads)) => {
909                    if first_error.is_none() {
910                        for (pos, payload) in chunk_payloads {
911                            payloads[pos] = payload;
912                        }
913                    }
914                }
915                Ok(Err(err)) => {
916                    first_error.get_or_insert(err);
917                }
918                Err(_) => {
919                    first_error.get_or_insert_with(|| {
920                        GitError::InvalidObject("pack compression worker panicked".into())
921                    });
922                }
923            }
924        }
925
926        match first_error {
927            Some(err) => Err(err),
928            None => Ok(()),
929        }
930    })?;
931    Ok(payloads)
932}
933
934pub(crate) fn compress_undeltified_payloads(
935    objects: &[Arc<EncodedObject>],
936    compression_level: u32,
937) -> Result<Vec<Vec<u8>>> {
938    if objects.is_empty() {
939        return Ok(Vec::new());
940    }
941
942    let worker_count = std::thread::available_parallelism()
943        .map(|threads| threads.get())
944        .unwrap_or(1)
945        .min(PACK_PARALLEL_COMPRESSION_MAX_THREADS)
946        .min(objects.len());
947    if worker_count <= 1 || objects.len() < PACK_PARALLEL_COMPRESSION_MIN_OBJECTS {
948        let mut payloads = Vec::with_capacity(objects.len());
949        for object in objects {
950            payloads.push(compressed_payload(&object.body, compression_level)?);
951        }
952        return Ok(payloads);
953    }
954
955    let chunk_len = objects.len().div_ceil(worker_count);
956    let mut payloads: Vec<Vec<u8>> = std::iter::repeat_with(Vec::new)
957        .take(objects.len())
958        .collect();
959    std::thread::scope(|scope| {
960        let mut handles = Vec::new();
961        for (chunk_idx, chunk) in objects.chunks(chunk_len).enumerate() {
962            let chunk_start = chunk_idx * chunk_len;
963            handles.push(scope.spawn(move || -> Result<Vec<(usize, Vec<u8>)>> {
964                let mut chunk_payloads = Vec::with_capacity(chunk.len());
965                for (offset, object) in chunk.iter().enumerate() {
966                    chunk_payloads.push((
967                        chunk_start + offset,
968                        compressed_payload(&object.body, compression_level)?,
969                    ));
970                }
971                Ok(chunk_payloads)
972            }));
973        }
974
975        let mut first_error = None;
976        for handle in handles {
977            match handle.join() {
978                Ok(Ok(chunk_payloads)) => {
979                    if first_error.is_none() {
980                        for (pos, payload) in chunk_payloads {
981                            payloads[pos] = payload;
982                        }
983                    }
984                }
985                Ok(Err(err)) => {
986                    first_error.get_or_insert(err);
987                }
988                Err(_) => {
989                    first_error.get_or_insert_with(|| {
990                        GitError::InvalidObject("pack compression worker panicked".into())
991                    });
992                }
993            }
994        }
995
996        match first_error {
997            Some(err) => Err(err),
998            None => Ok(()),
999        }
1000    })?;
1001    Ok(payloads)
1002}
1003
1004pub(crate) fn streaming_planned_payload<'a>(
1005    objects: &'a [Arc<EncodedObject>],
1006    plan: &'a [StreamingPlannedEntry],
1007    idx: usize,
1008) -> &'a [u8] {
1009    match &plan[idx].base {
1010        StreamingPlannedBase::None => &objects[idx].body,
1011        StreamingPlannedBase::Current { delta, .. }
1012        | StreamingPlannedBase::Previous { delta, .. }
1013        | StreamingPlannedBase::External { delta, .. } => delta,
1014    }
1015}
1016
1017pub(crate) fn planned_payload<'a>(
1018    objects: &'a [&'a EncodedObject],
1019    plan: &'a [PlannedEntry],
1020    idx: usize,
1021) -> &'a [u8] {
1022    match &plan[idx].base {
1023        PlannedBase::None => &objects[idx].body,
1024        PlannedBase::InPack { delta, .. } | PlannedBase::External { delta, .. } => delta,
1025    }
1026}
1027
1028pub(crate) fn compressed_payload(body: &[u8], compression_level: u32) -> Result<Vec<u8>> {
1029    let mut out = Vec::new();
1030    write_compressed_payload(&mut out, body, compression_level)?;
1031    Ok(out)
1032}
1033pub(crate) fn write_compressed_payload(
1034    out: &mut Vec<u8>,
1035    body: &[u8],
1036    compression_level: u32,
1037) -> Result<()> {
1038    let mut compressor = Compress::new(Compression::new(compression_level.min(9)), true);
1039    out.reserve(zlib_compress_bound(body.len()));
1040    let status = compressor
1041        .compress_vec(body, out, FlushCompress::Finish)
1042        .map_err(|err| GitError::InvalidObject(format!("zlib compression failed: {err}")))?;
1043    if status != Status::StreamEnd || compressor.total_in() != body.len() as u64 {
1044        return Err(GitError::InvalidObject(
1045            "zlib compression did not finish pack entry".into(),
1046        ));
1047    }
1048    Ok(())
1049}
1050
1051pub(crate) fn zlib_compress_bound(len: usize) -> usize {
1052    len.saturating_add(len >> 12)
1053        .saturating_add(len >> 14)
1054        .saturating_add(len >> 25)
1055        .saturating_add(13)
1056}
1057
1058pub(crate) fn write_entry_header(out: &mut Vec<u8>, object_type: ObjectType, size: u64) {
1059    let type_code = match object_type {
1060        ObjectType::Commit => 1,
1061        ObjectType::Tree => 2,
1062        ObjectType::Blob => 3,
1063        ObjectType::Tag => 4,
1064    };
1065    write_pack_entry_header_kind(out, type_code, size);
1066}
1067
1068pub(crate) fn write_pack_entry_header_kind(out: &mut Vec<u8>, type_code: u8, mut size: u64) {
1069    let mut byte = (type_code << 4) | ((size as u8) & 0x0f);
1070    size >>= 4;
1071    if size != 0 {
1072        byte |= 0x80;
1073    }
1074    out.push(byte);
1075    while size != 0 {
1076        let mut byte = (size as u8) & 0x7f;
1077        size >>= 7;
1078        if size != 0 {
1079            byte |= 0x80;
1080        }
1081        out.push(byte);
1082    }
1083}
1084
1085pub(crate) fn write_ofs_delta_offset(out: &mut Vec<u8>, relative: u64) -> Result<()> {
1086    if relative == 0 {
1087        return Err(GitError::InvalidFormat(
1088            "ofs-delta relative offset cannot be zero".into(),
1089        ));
1090    }
1091    let mut value = relative;
1092    let mut bytes = vec![(value & 0x7f) as u8];
1093    value >>= 7;
1094    while value != 0 {
1095        value -= 1;
1096        bytes.push(((value & 0x7f) as u8) | 0x80);
1097        value >>= 7;
1098    }
1099    bytes.reverse();
1100    out.extend_from_slice(&bytes);
1101    Ok(())
1102}
1103/// Builder that assembles a reachability bitmap (`.bitmap`) for a pack.
1104///
1105/// The writer is constructed from the object layout of a pack (one
1106/// [`ObjectType`] per object, in pack order) and the pack's trailing checksum.
1107/// Callers then register one selected commit per [`add_commit`] call, supplying
1108/// the set of pack positions reachable from that commit. [`build`]/[`write`]
1109/// produce a [`PackBitmapIndex`] / serialised `.bitmap` bytes matching git's
1110/// on-disk format (signature `BITM`, version 1).
1111///
1112/// [`add_commit`]: PackBitmapWriter::add_commit
1113/// [`build`]: PackBitmapWriter::build
1114/// [`write`]: PackBitmapWriter::write
1115#[derive(Debug, Clone)]
1116pub struct PackBitmapWriter {
1117    format: ObjectFormat,
1118    pack_checksum: ObjectId,
1119    object_count: u32,
1120    commit_positions: Vec<u32>,
1121    tree_positions: Vec<u32>,
1122    blob_positions: Vec<u32>,
1123    tag_positions: Vec<u32>,
1124    name_hash_cache: Option<Vec<u32>>,
1125    write_lookup_table: bool,
1126    selected: Vec<SelectedCommit>,
1127    pseudo_merges: Vec<PackBitmapPseudoMerge>,
1128}
1129
1130#[derive(Debug, Clone)]
1131pub(crate) struct SelectedCommit {
1132    /// Oid-sorted `.idx` position (what the on-disk entry records). The
1133    /// commit's pack-order position lives in `reachable` with the rest of the
1134    /// bits.
1135    commit_index_position: u32,
1136    flags: u8,
1137    reachable: Vec<u32>,
1138}
1139
1140impl PackBitmapWriter {
1141    /// `OBJ_NONE` selection flag: this commit's bitmap is stored in full (no XOR
1142    /// compression against a previously selected commit). This is the only flag
1143    /// value this writer emits.
1144    pub const FLAG_NONE: u8 = 0;
1145
1146    /// Creates a writer for a pack whose objects (in pack order) have the given
1147    /// [`ObjectType`]s and whose trailing checksum is `pack_checksum`.
1148    ///
1149    /// Returns an error if the pack contains more than `u32::MAX` objects, if
1150    /// `pack_checksum`'s format does not match `format`, or if any object type
1151    /// is not one of the four reachable git object kinds.
1152    pub fn new(
1153        format: ObjectFormat,
1154        pack_checksum: ObjectId,
1155        object_types: &[ObjectType],
1156    ) -> Result<Self> {
1157        if object_types.len() > u32::MAX as usize {
1158            return Err(GitError::InvalidFormat(
1159                "too many objects for a pack bitmap".into(),
1160            ));
1161        }
1162        if pack_checksum.format() != format {
1163            return Err(GitError::InvalidObjectId(
1164                "pack checksum format does not match bitmap format".into(),
1165            ));
1166        }
1167        let object_count = object_types.len() as u32;
1168        let mut commit_positions = Vec::new();
1169        let mut tree_positions = Vec::new();
1170        let mut blob_positions = Vec::new();
1171        let mut tag_positions = Vec::new();
1172        for (index, object_type) in object_types.iter().enumerate() {
1173            let position = index as u32;
1174            match object_type {
1175                ObjectType::Commit => commit_positions.push(position),
1176                ObjectType::Tree => tree_positions.push(position),
1177                ObjectType::Blob => blob_positions.push(position),
1178                ObjectType::Tag => tag_positions.push(position),
1179            }
1180        }
1181        Ok(Self {
1182            format,
1183            pack_checksum,
1184            object_count,
1185            commit_positions,
1186            tree_positions,
1187            blob_positions,
1188            tag_positions,
1189            name_hash_cache: None,
1190            write_lookup_table: false,
1191            selected: Vec::new(),
1192            pseudo_merges: Vec::new(),
1193        })
1194    }
1195
1196    /// Attaches a name-hash cache (one `u32` per object, in pack order). When
1197    /// set, the written bitmap advertises [`PackBitmapIndex::OPTION_HASH_CACHE`]
1198    /// and appends the cache after the bitmap entries, exactly as git does.
1199    ///
1200    /// Returns an error if the cache length does not equal the object count.
1201    pub fn with_name_hash_cache(mut self, cache: Vec<u32>) -> Result<Self> {
1202        if cache.len() != self.object_count as usize {
1203            return Err(GitError::InvalidFormat(format!(
1204                "name hash cache has {} entries but pack has {} objects",
1205                cache.len(),
1206                self.object_count
1207            )));
1208        }
1209        self.name_hash_cache = Some(cache);
1210        Ok(self)
1211    }
1212
1213    /// Enable the commit lookup-table extension. Each row is derived from the
1214    /// selected commit entries when the bitmap is serialised.
1215    pub fn with_lookup_table(mut self, enabled: bool) -> Self {
1216        self.write_lookup_table = enabled;
1217        self
1218    }
1219
1220    /// Registers a selected commit and the pack positions reachable from it.
1221    ///
1222    /// `commit_position` is the *pack-order* position of the commit itself (the
1223    /// bit-number space); it must reference a commit object and is implicitly
1224    /// part of the reachable set. `commit_index_position` is the commit's
1225    /// position in the *oid-sorted* pack index — this is what the on-disk entry
1226    /// records (upstream `oid_pos`); bits and entry positions live in different
1227    /// spaces. `reachable` lists the pack-order positions of every object
1228    /// reachable from the commit (it may include or omit `commit_position`;
1229    /// duplicates are fine). All positions must be in range. The commit's full
1230    /// (non-XORed) bitmap is stored.
1231    pub fn add_commit(
1232        &mut self,
1233        commit_position: u32,
1234        commit_index_position: u32,
1235        reachable: &[u32],
1236    ) -> Result<()> {
1237        if commit_position >= self.object_count {
1238            return Err(GitError::InvalidFormat(format!(
1239                "commit position {commit_position} out of range for {} objects",
1240                self.object_count
1241            )));
1242        }
1243        if commit_index_position >= self.object_count {
1244            return Err(GitError::InvalidFormat(format!(
1245                "commit index position {commit_index_position} out of range for {} objects",
1246                self.object_count
1247            )));
1248        }
1249        if !self.commit_positions.contains(&commit_position) {
1250            return Err(GitError::InvalidFormat(format!(
1251                "bitmap commit position {commit_position} is not a commit object"
1252            )));
1253        }
1254        for &position in reachable {
1255            if position >= self.object_count {
1256                return Err(GitError::InvalidFormat(format!(
1257                    "reachable position {position} out of range for {} objects",
1258                    self.object_count
1259                )));
1260            }
1261        }
1262        let mut reachable = reachable.to_vec();
1263        reachable.push(commit_position);
1264        self.selected.push(SelectedCommit {
1265            commit_index_position,
1266            flags: Self::FLAG_NONE,
1267            reachable,
1268        });
1269        Ok(())
1270    }
1271
1272    /// Registers a pseudo-merge bitmap. Both `commits` and `reachable` are
1273    /// positions in the bitmap's bit-numbering order (pack order for a single
1274    /// pack, pseudo-pack order for a MIDX). Every commit position must refer to
1275    /// a commit object; every reachable position must be in range.
1276    pub fn add_pseudo_merge(&mut self, commits: &[u32], reachable: &[u32]) -> Result<()> {
1277        if commits.is_empty() {
1278            return Err(GitError::InvalidFormat(
1279                "pseudo-merge must contain at least one commit".into(),
1280            ));
1281        }
1282        for &position in commits {
1283            if position >= self.object_count {
1284                return Err(GitError::InvalidFormat(format!(
1285                    "pseudo-merge commit position {position} out of range for {} objects",
1286                    self.object_count
1287                )));
1288            }
1289            if !self.commit_positions.contains(&position) {
1290                return Err(GitError::InvalidFormat(format!(
1291                    "pseudo-merge commit position {position} is not a commit object"
1292                )));
1293            }
1294        }
1295        for &position in reachable {
1296            if position >= self.object_count {
1297                return Err(GitError::InvalidFormat(format!(
1298                    "pseudo-merge reachable position {position} out of range for {} objects",
1299                    self.object_count
1300                )));
1301            }
1302        }
1303        self.pseudo_merges.push(PackBitmapPseudoMerge {
1304            commits: EwahBitmap::from_positions(self.object_count, commits)?,
1305            bitmap: EwahBitmap::from_positions(self.object_count, reachable)?,
1306        });
1307        Ok(())
1308    }
1309
1310    /// Builds the in-memory [`PackBitmapIndex`] without serialising it.
1311    ///
1312    /// The resulting index always advertises
1313    /// [`PackBitmapIndex::OPTION_FULL_DAG`] (the four type bitmaps fully cover
1314    /// the pack) and, when a name-hash cache was attached,
1315    /// [`PackBitmapIndex::OPTION_HASH_CACHE`].
1316    pub fn build(&self) -> Result<PackBitmapIndex> {
1317        let commits = EwahBitmap::from_positions(self.object_count, &self.commit_positions)?;
1318        let trees = EwahBitmap::from_positions(self.object_count, &self.tree_positions)?;
1319        let blobs = EwahBitmap::from_positions(self.object_count, &self.blob_positions)?;
1320        let tags = EwahBitmap::from_positions(self.object_count, &self.tag_positions)?;
1321
1322        let mut entries = Vec::with_capacity(self.selected.len());
1323        for selected in &self.selected {
1324            let bitmap = EwahBitmap::from_positions(self.object_count, &selected.reachable)?;
1325            entries.push(PackBitmapEntry {
1326                object_position: selected.commit_index_position,
1327                xor_offset: 0,
1328                flags: selected.flags,
1329                bitmap,
1330            });
1331        }
1332
1333        let mut options = PackBitmapIndex::OPTION_FULL_DAG;
1334        if self.name_hash_cache.is_some() {
1335            options |= PackBitmapIndex::OPTION_HASH_CACHE;
1336        }
1337        if !self.pseudo_merges.is_empty() {
1338            options |= PackBitmapIndex::OPTION_PSEUDO_MERGES;
1339        }
1340        if self.write_lookup_table {
1341            options |= PackBitmapIndex::OPTION_LOOKUP_TABLE;
1342        }
1343
1344        // The index checksum is only known once the body is serialised; the
1345        // dedicated `write` path fills it in. `build` reports a placeholder of
1346        // the correct format so the struct is self-consistent for callers that
1347        // only need the decoded bitmaps.
1348        let placeholder_checksum = ObjectId::null(self.format);
1349        Ok(PackBitmapIndex {
1350            version: 1,
1351            format: self.format,
1352            options,
1353            pack_checksum: self.pack_checksum.clone(),
1354            index_checksum: placeholder_checksum,
1355            type_bitmaps: PackBitmapTypeBitmaps {
1356                commits,
1357                trees,
1358                blobs,
1359                tags,
1360            },
1361            entries,
1362            pseudo_merges: self.pseudo_merges.clone(),
1363            lookup_table: self.write_lookup_table,
1364            name_hash_cache: self.name_hash_cache.clone(),
1365        })
1366    }
1367
1368    /// Builds and serialises the `.bitmap` file, returning the on-disk bytes
1369    /// (including the trailing index checksum).
1370    pub fn write(&self) -> Result<Vec<u8>> {
1371        self.build()?.write()
1372    }
1373}
1374
1375impl PackBitmapIndex {
1376    /// Serialises this index into git's on-disk `.bitmap` byte layout.
1377    ///
1378    /// This is the exact inverse of [`PackBitmapIndex::parse`]: signature
1379    /// `BITM`, version (u16 BE), options (u16 BE), entry count (u32 BE), the
1380    /// pack checksum, the four type bitmaps (commits, trees, blobs, tags), each
1381    /// commit entry (object position, XOR offset, flags, EWAH bitmap), the
1382    /// optional pseudo-merge extension, the optional name-hash cache, and
1383    /// finally the trailing index checksum over everything written so far.
1384    ///
1385    /// The `index_checksum` field of `self` is ignored and recomputed from the
1386    /// serialised body. Returns an error for unsupported versions, mismatched
1387    /// object-id formats, an oversized entry table, or an inconsistent name-hash
1388    /// cache.
1389    pub fn write(&self) -> Result<Vec<u8>> {
1390        if self.version != 1 {
1391            return Err(GitError::Unsupported(format!(
1392                "bitmap index version {}",
1393                self.version
1394            )));
1395        }
1396        let mut options = self.options;
1397        if !self.pseudo_merges.is_empty() {
1398            options |= Self::OPTION_PSEUDO_MERGES;
1399        }
1400        if self.lookup_table {
1401            options |= Self::OPTION_LOOKUP_TABLE;
1402        }
1403        let known_options = Self::OPTION_FULL_DAG
1404            | Self::OPTION_HASH_CACHE
1405            | Self::OPTION_LOOKUP_TABLE
1406            | Self::OPTION_PSEUDO_MERGES;
1407        if options & !known_options != 0 {
1408            return Err(GitError::Unsupported(format!(
1409                "bitmap index options {:#06x}",
1410                options & !known_options
1411            )));
1412        }
1413        if self.pack_checksum.format() != self.format {
1414            return Err(GitError::InvalidObjectId(
1415                "bitmap pack checksum format does not match index format".into(),
1416            ));
1417        }
1418        if self.entries.len() > u32::MAX as usize {
1419            return Err(GitError::InvalidFormat(
1420                "too many bitmap index entries".into(),
1421            ));
1422        }
1423        if options & Self::OPTION_PSEUDO_MERGES != 0 && self.pseudo_merges.is_empty() {
1424            return Err(GitError::InvalidFormat(
1425                "OPTION_PSEUDO_MERGES set without pseudo-merge records".into(),
1426            ));
1427        }
1428        let want_cache = options & Self::OPTION_HASH_CACHE != 0;
1429        match (&self.name_hash_cache, want_cache) {
1430            (Some(_), false) => {
1431                return Err(GitError::InvalidFormat(
1432                    "name hash cache present without OPTION_HASH_CACHE".into(),
1433                ));
1434            }
1435            (None, true) => {
1436                return Err(GitError::InvalidFormat(
1437                    "OPTION_HASH_CACHE set without a name hash cache".into(),
1438                ));
1439            }
1440            _ => {}
1441        }
1442
1443        let mut out = Vec::new();
1444        out.extend_from_slice(b"BITM");
1445        out.extend_from_slice(&self.version.to_be_bytes());
1446        out.extend_from_slice(&options.to_be_bytes());
1447        out.extend_from_slice(&(self.entries.len() as u32).to_be_bytes());
1448        out.extend_from_slice(self.pack_checksum.as_bytes());
1449
1450        self.type_bitmaps.commits.append_bytes(&mut out);
1451        self.type_bitmaps.trees.append_bytes(&mut out);
1452        self.type_bitmaps.blobs.append_bytes(&mut out);
1453        self.type_bitmaps.tags.append_bytes(&mut out);
1454
1455        let mut entry_offsets = Vec::with_capacity(self.entries.len());
1456        for (idx, entry) in self.entries.iter().enumerate() {
1457            if entry.xor_offset as usize > idx {
1458                return Err(GitError::InvalidFormat(
1459                    "bitmap index entry has invalid XOR offset".into(),
1460                ));
1461            }
1462            entry_offsets.push(out.len() as u64);
1463            out.extend_from_slice(&entry.object_position.to_be_bytes());
1464            out.push(entry.xor_offset);
1465            out.push(entry.flags);
1466            entry.bitmap.append_bytes(&mut out);
1467        }
1468
1469        if !self.pseudo_merges.is_empty() {
1470            append_bitmap_pseudo_merges(&mut out, &self.pseudo_merges)?;
1471        }
1472
1473        if self.lookup_table {
1474            append_bitmap_lookup_table(&mut out, &self.entries, &entry_offsets)?;
1475        }
1476
1477        if let Some(cache) = &self.name_hash_cache {
1478            for value in cache {
1479                out.extend_from_slice(&value.to_be_bytes());
1480            }
1481        }
1482
1483        let checksum = sley_core::digest_bytes(self.format, &out)?;
1484        out.extend_from_slice(checksum.as_bytes());
1485        Ok(out)
1486    }
1487}
1488
1489fn append_bitmap_lookup_table(
1490    out: &mut Vec<u8>,
1491    entries: &[PackBitmapEntry],
1492    entry_offsets: &[u64],
1493) -> Result<()> {
1494    if entries.len() != entry_offsets.len() {
1495        return Err(GitError::InvalidFormat(
1496            "bitmap lookup table offset count mismatch".into(),
1497        ));
1498    }
1499    let mut table: Vec<usize> = (0..entries.len()).collect();
1500    table.sort_by_key(|&index| entries[index].object_position);
1501    let mut inverse = vec![0u32; entries.len()];
1502    for (row, &entry_index) in table.iter().enumerate() {
1503        inverse[entry_index] = row as u32;
1504    }
1505    for &entry_index in &table {
1506        let entry = &entries[entry_index];
1507        let xor_row = if entry.xor_offset == 0 {
1508            u32::MAX
1509        } else {
1510            let base = entry_index
1511                .checked_sub(entry.xor_offset as usize)
1512                .ok_or_else(|| {
1513                    GitError::InvalidFormat("bitmap lookup table XOR base underflow".into())
1514                })?;
1515            inverse[base]
1516        };
1517        out.extend_from_slice(&entry.object_position.to_be_bytes());
1518        out.extend_from_slice(&entry_offsets[entry_index].to_be_bytes());
1519        out.extend_from_slice(&xor_row.to_be_bytes());
1520    }
1521    Ok(())
1522}
1523
1524pub(crate) fn append_bitmap_pseudo_merges(
1525    out: &mut Vec<u8>,
1526    pseudo_merges: &[PackBitmapPseudoMerge],
1527) -> Result<()> {
1528    if pseudo_merges.len() > u32::MAX as usize {
1529        return Err(GitError::InvalidFormat(
1530            "too many pseudo-merge bitmap records".into(),
1531        ));
1532    }
1533    let start = out.len();
1534    let mut pseudo_offsets = Vec::with_capacity(pseudo_merges.len());
1535    let mut commit_to_offsets: BTreeMap<u32, Vec<u64>> = BTreeMap::new();
1536    for merge in pseudo_merges {
1537        let offset = u64::try_from(out.len())
1538            .map_err(|_| GitError::InvalidFormat("bitmap file offset overflow".into()))?;
1539        pseudo_offsets.push(offset);
1540        for commit_pos in merge.commits.to_positions()? {
1541            commit_to_offsets
1542                .entry(commit_pos)
1543                .or_default()
1544                .push(offset);
1545        }
1546        merge.commits.append_bytes(out);
1547        merge.bitmap.append_bytes(out);
1548    }
1549    if commit_to_offsets.len() > u32::MAX as usize {
1550        return Err(GitError::InvalidFormat(
1551            "too many pseudo-merge commits".into(),
1552        ));
1553    }
1554
1555    let lookup_start = out.len();
1556    let lookup_len = commit_to_offsets
1557        .len()
1558        .checked_mul(12)
1559        .ok_or_else(|| GitError::InvalidFormat("pseudo-merge lookup overflow".into()))?;
1560    let mut next_extended = u64::try_from(
1561        lookup_start
1562            .checked_add(lookup_len)
1563            .ok_or_else(|| GitError::InvalidFormat("pseudo-merge lookup overflow".into()))?,
1564    )
1565    .map_err(|_| GitError::InvalidFormat("bitmap file offset overflow".into()))?;
1566    let mut rows = Vec::with_capacity(commit_to_offsets.len());
1567    for (commit_pos, offsets) in commit_to_offsets {
1568        let extended_offset = if offsets.len() > 1 {
1569            if next_extended & (1u64 << 63) != 0 {
1570                return Err(GitError::InvalidFormat(
1571                    "pseudo-merge extended offset overflow".into(),
1572                ));
1573            }
1574            let offset = next_extended;
1575            let ext_len = offsets
1576                .len()
1577                .checked_mul(8)
1578                .and_then(|len| len.checked_add(4))
1579                .ok_or_else(|| {
1580                    GitError::InvalidFormat("pseudo-merge extended lookup overflow".into())
1581                })?;
1582            next_extended = next_extended.checked_add(ext_len as u64).ok_or_else(|| {
1583                GitError::InvalidFormat("pseudo-merge extended lookup overflow".into())
1584            })?;
1585            Some(offset)
1586        } else {
1587            None
1588        };
1589        rows.push((commit_pos, offsets, extended_offset));
1590    }
1591
1592    for (commit_pos, offsets, extended_offset) in &rows {
1593        out.extend_from_slice(&commit_pos.to_be_bytes());
1594        match extended_offset {
1595            Some(offset) => out.extend_from_slice(&(offset | (1u64 << 63)).to_be_bytes()),
1596            None => out.extend_from_slice(&offsets[0].to_be_bytes()),
1597        }
1598    }
1599
1600    for (_commit_pos, offsets, extended_offset) in &rows {
1601        if extended_offset.is_none() {
1602            continue;
1603        }
1604        let count = u32::try_from(offsets.len())
1605            .map_err(|_| GitError::InvalidFormat("pseudo-merge extended lookup overflow".into()))?;
1606        out.extend_from_slice(&count.to_be_bytes());
1607        for offset in offsets {
1608            out.extend_from_slice(&offset.to_be_bytes());
1609        }
1610    }
1611
1612    for offset in &pseudo_offsets {
1613        out.extend_from_slice(&offset.to_be_bytes());
1614    }
1615    out.extend_from_slice(&(pseudo_merges.len() as u32).to_be_bytes());
1616    out.extend_from_slice(&(rows.len() as u32).to_be_bytes());
1617    let lookup_relative = lookup_start
1618        .checked_sub(start)
1619        .ok_or_else(|| GitError::InvalidFormat("pseudo-merge lookup underflow".into()))?;
1620    out.extend_from_slice(&(lookup_relative as u64).to_be_bytes());
1621    let extension_size = out
1622        .len()
1623        .checked_sub(start)
1624        .and_then(|len| len.checked_add(8))
1625        .ok_or_else(|| GitError::InvalidFormat("pseudo-merge extension overflow".into()))?;
1626    out.extend_from_slice(&(extension_size as u64).to_be_bytes());
1627    Ok(())
1628}
1629
1630/// Convenience wrapper that builds a `.bitmap` file in one call.
1631///
1632/// `object_types` lists the [`ObjectType`] of every pack object in pack order,
1633/// `pack_checksum` is the pack's trailing checksum, and `commits` carries, per
1634/// selected commit, `(pack_position, index_position, reachable_pack_positions)`
1635/// (see [`PackBitmapWriter::add_commit`] for the two position spaces). An
1636/// optional `name_hash_cache` (one entry per object) may be supplied to emit
1637/// the hash-cache extension.
1638pub fn write_bitmap(
1639    format: ObjectFormat,
1640    pack_checksum: ObjectId,
1641    object_types: &[ObjectType],
1642    commits: &[(u32, u32, Vec<u32>)],
1643    name_hash_cache: Option<Vec<u32>>,
1644) -> Result<Vec<u8>> {
1645    let mut writer = PackBitmapWriter::new(format, pack_checksum, object_types)?;
1646    if let Some(cache) = name_hash_cache {
1647        writer = writer.with_name_hash_cache(cache)?;
1648    }
1649    for (commit_position, commit_index_position, reachable) in commits {
1650        writer.add_commit(*commit_position, *commit_index_position, reachable)?;
1651    }
1652    writer.write()
1653}