Skip to main content

grit_lib/bundle/
core.rs

1//! Git bundle format ([gitformat-bundle](https://git-scm.com/docs/gitformat-bundle)).
2//!
3//! Bundles combine a text header (signature, optional v3 capabilities, prerequisite
4//! commits, ref tips) with a thin packfile. This module reads and writes that format,
5//! verifies prerequisite connectivity against a local repository, and ingests the pack
6//! without updating refs (callers apply [`BundleHeader::refs`] themselves).
7
8use std::collections::{HashMap, HashSet, VecDeque};
9use std::fs::File;
10use std::io::{BufRead, Read, Seek, Write};
11use std::path::{Path, PathBuf};
12
13use thiserror::Error;
14
15use crate::commit_pretty::message_subject;
16use crate::connectivity::bundle_prerequisites_connected_to_refs;
17use crate::error::{Error, Result};
18use crate::index_pack::{ingest_received_pack_path_with_progress, IngestPackOptions};
19use crate::objects::{parse_commit, HashAlgo, ObjectId, ObjectKind};
20use crate::odb::Odb;
21use crate::pack_objects::{write_upload_pack_stream, PackStats};
22use crate::progress::{NullProgress, ProgressPhase, ProgressSink, ProgressUnit};
23use crate::repo::Repository;
24use crate::rev_list::{
25    FilterObjectKind, MissingAction, ObjectFilter, RevListOptions, RevListResult,
26};
27use crate::transfer::{build_pack_from_send_list, reachable_closure, PackBuildOptions};
28
29const V2_SIGNATURE: &str = "# v2 git bundle\n";
30const V3_SIGNATURE: &str = "# v3 git bundle\n";
31
32/// Supported on-disk bundle header versions.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum BundleVersion {
35    /// Legacy header (SHA-1 repositories without filter capabilities).
36    V2,
37    /// Capabilities for object format and partial-clone filter.
38    V3,
39}
40
41/// Wire filter specification carried in v3 `@filter=` capabilities.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct FilterSpec(ObjectFilter);
44
45impl FilterSpec {
46    /// Parse a filter capability value (same grammar as `--filter=` on fetch).
47    ///
48    /// # Errors
49    ///
50    /// Returns [`BundleError::MalformedLine`] when the spec is invalid.
51    pub fn parse(spec: &str) -> std::result::Result<Self, BundleError> {
52        ObjectFilter::parse(spec)
53            .map(Self)
54            .map_err(BundleError::MalformedLine)
55    }
56
57    /// Borrow the parsed filter.
58    #[must_use]
59    pub fn as_filter(&self) -> &ObjectFilter {
60        &self.0
61    }
62
63    /// Serialize for an `@filter=` capability line (percent-encoding when needed).
64    #[must_use]
65    pub fn to_capability_value(&self) -> String {
66        filter_to_capability_value(&self.0)
67    }
68}
69
70/// Parsed bundle header (everything before the `PACK` stream).
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct BundleHeader {
73    /// Header version (`2` or `3`).
74    pub version: BundleVersion,
75    /// Object hash used in the pack and OID lines.
76    pub object_format: HashAlgo,
77    /// Partial-clone filter advertised by the bundle (v3 only).
78    pub filter: Option<FilterSpec>,
79    /// Prerequisite commits that must already exist (`-oid` lines).
80    pub prerequisites: Vec<(ObjectId, Option<String>)>,
81    /// Ref tips included in the pack (`oid refname` lines).
82    pub refs: Vec<(String, ObjectId)>,
83}
84
85/// A single ref entry returned from [`Bundle::unbundle`].
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct UnbundledRef {
88    /// Full ref name (e.g. `refs/heads/main`).
89    pub name: String,
90    /// Tip object id.
91    pub oid: ObjectId,
92}
93
94/// Result of ingesting a bundle's embedded pack into a repository.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct UnbundleOutcome {
97    /// Ref tips from the bundle header (refs are not updated on disk by unbundle).
98    pub refs: Vec<UnbundledRef>,
99    /// Objects and bytes installed from the bundle pack.
100    pub transfer: crate::transfer::TransferStats,
101    /// Object ids indexed from the installed pack.
102    pub pack_object_ids: HashSet<ObjectId>,
103}
104
105/// Outcome of [`Bundle::verify`].
106#[derive(Debug, Clone, Default, PartialEq, Eq)]
107pub struct BundleVerifyReport {
108    /// Prerequisites missing from the object database.
109    pub missing_prerequisites: Vec<(ObjectId, Option<String>)>,
110    /// Prerequisites exist as objects but are not reachable from local refs.
111    pub prerequisites_not_connected: bool,
112}
113
114impl BundleVerifyReport {
115    /// Whether verification succeeded with no issues.
116    #[must_use]
117    pub fn is_ok(&self) -> bool {
118        self.missing_prerequisites.is_empty() && !self.prerequisites_not_connected
119    }
120
121    /// Convert validation failures into a [`BundleError`].
122    ///
123    /// Returns `Ok(())` when [`Self::is_ok`].
124    ///
125    /// # Errors
126    ///
127    /// Returns the first missing prerequisite, or [`BundleError::PrerequisitesNotConnected`].
128    pub fn into_result(self) -> std::result::Result<(), BundleError> {
129        if let Some((oid, subject)) = self.missing_prerequisites.into_iter().next() {
130            return Err(BundleError::MissingPrerequisite { oid, subject });
131        }
132        if self.prerequisites_not_connected {
133            return Err(BundleError::PrerequisitesNotConnected);
134        }
135        Ok(())
136    }
137}
138
139/// Bundle read/write/verify failures.
140#[non_exhaustive]
141#[derive(Debug, Error, Clone, PartialEq, Eq)]
142pub enum BundleError {
143    /// The file does not begin with a v2/v3 bundle signature.
144    #[error("not a v2 or v3 git bundle")]
145    BadSignature,
146    /// Only versions 2 and 3 are supported.
147    #[error("unsupported bundle version")]
148    UnsupportedVersion,
149    /// A v3 `@` capability was not recognized.
150    #[error("unknown bundle capability: {0}")]
151    UnsupportedCapability(String),
152    /// A header line could not be parsed.
153    #[error("malformed bundle header: {0}")]
154    MalformedLine(String),
155    /// Header object format disagrees with the repository.
156    #[error("bundle object format {bundle_name} does not match repository {repo_name}")]
157    ObjectFormatMismatch {
158        /// Format recorded in the bundle header.
159        bundle_name: &'static str,
160        /// Format configured for the open repository.
161        repo_name: &'static str,
162    },
163    /// A prerequisite commit is absent from the ODB.
164    #[error("missing prerequisite commit {oid}")]
165    MissingPrerequisite {
166        /// Required commit oid.
167        oid: ObjectId,
168        /// Optional subject from the header line.
169        subject: Option<String>,
170    },
171    /// Prerequisite objects exist but are not connected to local refs.
172    #[error("prerequisite commits are not connected to local refs")]
173    PrerequisitesNotConnected,
174    /// No ref tips were listed in the header.
175    #[error("bundle contains no refs")]
176    EmptyRefs,
177    /// The pack stream did not start where expected.
178    #[error("bundle pack stream missing or invalid")]
179    MissingPack,
180    /// Underlying I/O failure.
181    #[error("{0}")]
182    Io(String),
183    /// The operation was cancelled before the bundle finished unpacking.
184    #[error("operation cancelled")]
185    Cancelled,
186}
187
188impl From<std::io::Error> for BundleError {
189    fn from(e: std::io::Error) -> Self {
190        Self::Io(e.to_string())
191    }
192}
193
194fn io_open_path(path: &Path, source: std::io::Error) -> BundleError {
195    BundleError::Io(format!("could not open '{}': {source}", path.display()))
196}
197
198impl BundleError {
199    /// Whether the bundle operation was cancelled.
200    #[must_use]
201    pub fn is_cancelled(&self) -> bool {
202        matches!(self, Self::Cancelled)
203    }
204}
205
206/// Input for [`write_bundle`].
207#[derive(Debug, Clone, Default)]
208pub struct BundleSpec {
209    /// Tips to include (`oid` + ref name for the header).
210    pub include: Vec<(ObjectId, String)>,
211    /// Commits treated as already present (incremental bundles).
212    pub exclude: Vec<ObjectId>,
213    /// Optional partial-clone filter (forces v3).
214    pub filter: Option<FilterSpec>,
215}
216
217/// One ref tip that would appear in a bundle header.
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct BundleRefEntry {
220    /// Full ref name (for example `refs/heads/main`).
221    pub name: String,
222    /// Tip object id.
223    pub oid: ObjectId,
224}
225
226/// One prerequisite commit line (`-oid`) for an incremental bundle.
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct BundlePrerequisite {
229    /// Required commit oid.
230    pub oid: ObjectId,
231    /// Optional subject from the commit message.
232    pub subject: Option<String>,
233}
234
235/// Planned bundle contents without writing a file.
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct BundleCreatePlan {
238    /// Ref tips that would be listed in the header.
239    pub refs: Vec<BundleRefEntry>,
240    /// Prerequisite commits the recipient must already have.
241    pub prerequisites: Vec<BundlePrerequisite>,
242    /// Object count encoded in the pack stream.
243    pub object_count: u32,
244}
245
246/// One ref movement that would occur when applying bundle header tips locally.
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct BundleRefUpdatePlan {
249    /// Ref to update.
250    pub name: String,
251    /// Current tip when the ref exists locally.
252    pub old_oid: Option<ObjectId>,
253    /// Tip from the bundle header.
254    pub new_oid: ObjectId,
255}
256
257/// Planned unbundle work: verification outcome and ref updates without ingesting the pack.
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct BundleUnbundlePlan {
260    /// Verification against the open repository.
261    pub verify: BundleVerifyReport,
262    /// Ref updates implied by the bundle header (refs are not written during unbundle).
263    pub ref_updates: Vec<BundleRefUpdatePlan>,
264    /// Object count in the embedded pack.
265    pub object_count: u32,
266}
267
268/// An opened bundle file on disk.
269#[derive(Debug, Clone)]
270pub struct Bundle {
271    path: PathBuf,
272    header: BundleHeader,
273    pack_offset: u64,
274}
275
276impl Bundle {
277    /// Open `path` and parse the header, leaving the pack at `pack_offset`.
278    ///
279    /// # Errors
280    ///
281    /// Returns [`BundleError`] when the signature or header lines are invalid.
282    pub fn open(path: impl AsRef<Path>) -> std::result::Result<Self, BundleError> {
283        let path = path.as_ref().to_path_buf();
284        let (header, pack_offset) = read_header_from_file(&path)?;
285        Ok(Self {
286            path,
287            header,
288            pack_offset,
289        })
290    }
291
292    /// Parsed header.
293    #[must_use]
294    pub fn header(&self) -> &BundleHeader {
295        &self.header
296    }
297
298    /// Path passed to [`Self::open`].
299    #[must_use]
300    pub fn path(&self) -> &Path {
301        &self.path
302    }
303
304    /// Object count from the embedded pack header.
305    ///
306    /// # Errors
307    ///
308    /// Returns [`BundleError::MissingPack`] when the pack stream is absent or truncated.
309    pub fn pack_object_count(&self) -> std::result::Result<u32, BundleError> {
310        bundle_pack_object_count(self)
311    }
312
313    /// Byte offset in [`Self::path`] where the `PACK` stream begins.
314    #[must_use]
315    pub fn pack_byte_offset(&self) -> u64 {
316        self.pack_offset
317    }
318
319    /// Check prerequisites against `repo` and ref connectivity.
320    ///
321    /// # Errors
322    ///
323    /// Returns [`BundleError::ObjectFormatMismatch`] when hash algorithms disagree.
324    pub fn verify(
325        &self,
326        repo: &Repository,
327    ) -> std::result::Result<BundleVerifyReport, BundleError> {
328        let repo_algo = repo.odb().hash_algo();
329        if self.header.object_format != repo_algo {
330            return Err(BundleError::ObjectFormatMismatch {
331                bundle_name: self.header.object_format.name(),
332                repo_name: repo_algo.name(),
333            });
334        }
335        let mut report = BundleVerifyReport::default();
336        for (oid, subject) in &self.header.prerequisites {
337            if repo.odb().read(oid).is_err() {
338                report.missing_prerequisites.push((*oid, subject.clone()));
339            }
340        }
341        if report.missing_prerequisites.is_empty() {
342            let prereq_oids: Vec<ObjectId> =
343                self.header.prerequisites.iter().map(|(o, _)| *o).collect();
344            let connected = bundle_prerequisites_connected_to_refs(repo, &prereq_oids)
345                .map_err(|e| BundleError::Io(e.to_string()))?;
346            report.prerequisites_not_connected = !connected;
347        }
348        Ok(report)
349    }
350
351    /// Stream the embedded pack into `repo`'s ODB (does not update refs).
352    ///
353    /// With a files-backed primary the pack is installed under `objects/pack/`. With a
354    /// custom [`OdbBuilder::primary`](crate::odb::OdbBuilder::primary) store the pack is
355    /// read straight from the bundle file and every object is written into that store;
356    /// no temporary file is created. A filtered (v3 `filter`) bundle is then not marked
357    /// as a promisor pack, since there is no pack to mark.
358    ///
359    /// Pack integrity is checked while building the pack index (object hashes and the
360    /// pack trailer); there is no second full read of the pack after indexing.
361    ///
362    /// # Errors
363    ///
364    /// Returns [`BundleError`] or ingest failures wrapped as [`BundleError::Io`].
365    pub fn unbundle(
366        &self,
367        repo: &Repository,
368    ) -> std::result::Result<Vec<UnbundledRef>, BundleError> {
369        Ok(self
370            .unbundle_with_progress(repo, &mut NullProgress, &crate::progress::NeverCancel)?
371            .refs)
372    }
373
374    /// Like [`Self::unbundle`], reporting receive and index-pack progress through `progress`.
375    pub fn unbundle_with_progress(
376        &self,
377        repo: &Repository,
378        progress: &mut dyn ProgressSink,
379        cancel: &dyn crate::progress::Cancel,
380    ) -> std::result::Result<UnbundleOutcome, BundleError> {
381        crate::progress::poll_cancel(progress, cancel).map_err(bundle_ingest_error)?;
382        if self.header.object_format != repo.odb().hash_algo() {
383            return Err(BundleError::ObjectFormatMismatch {
384                bundle_name: self.header.object_format.name(),
385                repo_name: repo.odb().hash_algo().name(),
386            });
387        }
388        let ingested = if repo.odb().uses_files_primary() {
389            self.install_pack(repo, progress, cancel)?
390        } else {
391            let mut input = File::open(&self.path)?;
392            Seek::seek(&mut input, std::io::SeekFrom::Start(self.pack_offset))?;
393            let mut reader = std::io::BufReader::with_capacity(1 << 20, input);
394            crate::unpack_objects::ingest_pack_into_odb(
395                &mut reader,
396                repo.odb(),
397                &crate::unpack_objects::UnpackOptions::default(),
398                Some(progress),
399                Some(cancel),
400            )
401            .map(crate::index_pack::IngestedPack::from)
402            .map_err(bundle_ingest_error)?
403        };
404        if let (Some(_), Some(pack_path)) = (&self.header.filter, &ingested.pack_path) {
405            std::fs::write(pack_path.with_extension("promisor"), b"")
406                .map_err(|e| BundleError::Io(e.to_string()))?;
407            repo.odb().invalidate_packs();
408        }
409        let transfer = crate::transfer::TransferStats::from_ingested_pack(&ingested);
410        let refs = self
411            .header
412            .refs
413            .iter()
414            .map(|(name, oid)| UnbundledRef {
415                name: name.clone(),
416                oid: *oid,
417            })
418            .collect();
419        Ok(UnbundleOutcome {
420            refs,
421            transfer,
422            pack_object_ids: ingested.object_ids,
423        })
424    }
425
426    /// Copy the embedded pack under `objects/pack/` and install it there.
427    fn install_pack(
428        &self,
429        repo: &Repository,
430        progress: &mut dyn ProgressSink,
431        cancel: &dyn crate::progress::Cancel,
432    ) -> std::result::Result<crate::index_pack::IngestedPack, BundleError> {
433        let pack_bytes = std::fs::metadata(&self.path)
434            .map(|m| m.len().saturating_sub(self.pack_offset))
435            .unwrap_or(0);
436        progress.start_with_unit(
437            ProgressPhase::ReceivingObjects,
438            ProgressUnit::Bytes,
439            Some(pack_bytes),
440        );
441        let pack_dir = repo.odb().objects_dir().join("pack");
442        std::fs::create_dir_all(&pack_dir).map_err(BundleError::from)?;
443        let tmp = tempfile::NamedTempFile::new_in(&pack_dir)
444            .map_err(|e| BundleError::Io(e.to_string()))?;
445        {
446            let mut input = File::open(&self.path)?;
447            Seek::seek(&mut input, std::io::SeekFrom::Start(self.pack_offset))
448                .map_err(BundleError::from)?;
449            let mut output = std::io::BufWriter::new(tmp.as_file());
450            let mut copied = 0u64;
451            let mut buf = [0u8; 64 * 1024];
452            loop {
453                crate::progress::poll_cancel(progress, cancel).map_err(bundle_ingest_error)?;
454                let n = input.read(&mut buf).map_err(BundleError::from)?;
455                if n == 0 {
456                    break;
457                }
458                output.write_all(&buf[..n]).map_err(BundleError::from)?;
459                copied = copied.saturating_add(n as u64);
460                progress.set(copied);
461            }
462            output.flush().map_err(BundleError::from)?;
463        }
464        progress.finish();
465        crate::progress::poll_cancel(progress, cancel).map_err(bundle_ingest_error)?;
466        let opts = IngestPackOptions {
467            fix_thin: true,
468            // Indexing hashes every object and the trailer is checked while
469            // installing, so a second full read of the pack proves nothing.
470            skip_post_index_verify: true,
471            ..Default::default()
472        };
473        ingest_received_pack_path_with_progress(
474            tmp.path().to_path_buf(),
475            repo.odb(),
476            &opts,
477            Some(progress),
478            Some(cancel),
479        )
480        .map_err(bundle_ingest_error)
481    }
482}
483
484fn bundle_ingest_error(e: Error) -> BundleError {
485    match e {
486        Error::Cancelled => BundleError::Cancelled,
487        other => BundleError::Io(other.to_string()),
488    }
489}
490
491/// Parse a bundle header from `reader`, leaving the cursor at the first `PACK` byte.
492///
493/// # Errors
494///
495/// Returns [`BundleError`] when the signature or header is invalid.
496pub fn read_header(reader: &mut dyn BufRead) -> std::result::Result<BundleHeader, BundleError> {
497    let header = read_header_lines(reader)?;
498    ensure_pack_magic(reader)?;
499    Ok(header)
500}
501
502/// Parse the signature, capability, prerequisite and ref lines up to and including the
503/// blank line that ends the header, without inspecting the pack that follows.
504fn read_header_lines(reader: &mut dyn BufRead) -> std::result::Result<BundleHeader, BundleError> {
505    let signature = read_line(reader)?;
506    let version = match signature.as_str() {
507        s if s == V2_SIGNATURE.trim_end() => BundleVersion::V2,
508        s if s == V3_SIGNATURE.trim_end() => BundleVersion::V3,
509        _ => return Err(BundleError::BadSignature),
510    };
511
512    let mut object_format = HashAlgo::Sha1;
513    let mut filter = None;
514    let mut prerequisites = Vec::new();
515    let mut refs = Vec::new();
516
517    loop {
518        let line = read_line(reader)?;
519        if line.is_empty() {
520            break;
521        }
522        if version == BundleVersion::V3 && line.starts_with('@') {
523            parse_capability(&line[1..], &mut object_format, &mut filter)?;
524            continue;
525        }
526        let (is_prereq, rest) = if let Some(stripped) = line.strip_prefix('-') {
527            (true, stripped)
528        } else {
529            (false, line.as_str())
530        };
531        let (oid, tail) = parse_oid_field(rest, object_format)?;
532        if is_prereq {
533            let subject = tail
534                .map(str::trim)
535                .filter(|s| !s.is_empty())
536                .map(str::to_owned);
537            prerequisites.push((oid, subject));
538        } else {
539            let refname = tail.ok_or_else(|| {
540                BundleError::MalformedLine("ref line missing ref name".to_owned())
541            })?;
542            let refname = refname.trim();
543            if refname.is_empty() {
544                return Err(BundleError::MalformedLine(
545                    "ref line missing ref name".to_owned(),
546                ));
547            }
548            refs.push((refname.to_owned(), oid));
549        }
550    }
551
552    Ok(BundleHeader {
553        version,
554        object_format,
555        filter,
556        prerequisites,
557        refs,
558    })
559}
560
561/// Buffer size for streaming the header; most headers fit in one read, and the pack that
562/// follows (often far larger than the header) is never pulled into memory.
563const HEADER_READ_BUF: usize = 64 * 1024;
564
565fn read_header_from_file(path: &Path) -> std::result::Result<(BundleHeader, u64), BundleError> {
566    let file = File::open(path).map_err(|e| io_open_path(path, e))?;
567    let mut reader = std::io::BufReader::with_capacity(HEADER_READ_BUF, file);
568    let header = read_header_lines(&mut reader)?;
569    let mut pack_offset = reader.stream_position().map_err(BundleError::from)?;
570    let mut magic = [0u8; 4];
571    loop {
572        read_exact_or_missing_pack(&mut reader, &mut magic[..1])?;
573        if magic[0] != b'\n' && magic[0] != b'\r' {
574            break;
575        }
576        pack_offset += 1;
577    }
578    read_exact_or_missing_pack(&mut reader, &mut magic[1..])?;
579    if &magic != b"PACK" {
580        return Err(BundleError::MissingPack);
581    }
582    Ok((header, pack_offset))
583}
584
585fn read_exact_or_missing_pack(
586    reader: &mut dyn std::io::Read,
587    buf: &mut [u8],
588) -> std::result::Result<(), BundleError> {
589    reader.read_exact(buf).map_err(|e| match e.kind() {
590        std::io::ErrorKind::UnexpectedEof => BundleError::MissingPack,
591        _ => BundleError::from(e),
592    })
593}
594
595fn ensure_pack_magic(reader: &mut dyn BufRead) -> std::result::Result<(), BundleError> {
596    loop {
597        let buf = reader.fill_buf().map_err(BundleError::from)?;
598        if buf.is_empty() {
599            return Err(BundleError::MissingPack);
600        }
601        if buf.starts_with(b"PACK") {
602            return Ok(());
603        }
604        if buf[0] == b'\n' || buf[0] == b'\r' {
605            reader.consume(1);
606            continue;
607        }
608        return Err(BundleError::MissingPack);
609    }
610}
611
612/// Plan a bundle for `spec` without writing a file.
613///
614/// # Errors
615///
616/// Returns [`BundleError::EmptyRefs`] when `spec.include` is empty, or pack generation failures.
617pub fn plan_bundle_create(
618    repo: &Repository,
619    spec: &BundleSpec,
620) -> std::result::Result<BundleCreatePlan, BundleError> {
621    if spec.include.is_empty() {
622        return Err(BundleError::EmptyRefs);
623    }
624    let header = bundle_header_parts(repo, spec)?;
625    let object_count = write_bundle_pack(repo, spec, &mut std::io::sink())?.object_count;
626    Ok(BundleCreatePlan {
627        refs: header
628            .refs
629            .into_iter()
630            .map(|(oid, name)| BundleRefEntry { name, oid })
631            .collect(),
632        prerequisites: header
633            .prerequisites
634            .into_iter()
635            .map(|(oid, subject)| BundlePrerequisite { oid, subject })
636            .collect(),
637        object_count,
638    })
639}
640
641/// Plan unbundle verification and ref updates without ingesting the pack or writing refs.
642///
643/// # Errors
644///
645/// Returns [`BundleError`] when the bundle cannot be read or verified.
646pub fn plan_bundle_unbundle(
647    bundle: &Bundle,
648    repo: &Repository,
649) -> std::result::Result<BundleUnbundlePlan, BundleError> {
650    let verify = bundle.verify(repo)?;
651    let ref_updates = planned_ref_updates_from_header(repo, &bundle.header().refs);
652    let object_count = bundle_pack_object_count(bundle)?;
653    Ok(BundleUnbundlePlan {
654        verify,
655        ref_updates,
656        object_count,
657    })
658}
659
660fn bundle_pack_object_count(bundle: &Bundle) -> std::result::Result<u32, BundleError> {
661    let mut input = File::open(&bundle.path).map_err(BundleError::from)?;
662    Seek::seek(&mut input, std::io::SeekFrom::Start(bundle.pack_offset))
663        .map_err(BundleError::from)?;
664    let mut header = [0u8; 12];
665    read_exact_or_missing_pack(&mut input, &mut header)?;
666    Ok(crate::receive_pack::pack_object_count(&header).unwrap_or(0))
667}
668
669fn planned_ref_updates_from_header(
670    repo: &Repository,
671    refs: &[(String, ObjectId)],
672) -> Vec<BundleRefUpdatePlan> {
673    let git_dir = repo.git_dir();
674    let mut updates = Vec::new();
675    for (name, new_oid) in refs {
676        if name.ends_with("^{}") {
677            continue;
678        }
679        let old_oid = crate::refs::resolve_ref(git_dir, name).ok();
680        if old_oid.as_ref() == Some(new_oid) {
681            continue;
682        }
683        updates.push(BundleRefUpdatePlan {
684            name: name.clone(),
685            old_oid,
686            new_oid: *new_oid,
687        });
688    }
689    updates
690}
691
692struct BundleHeaderParts {
693    prerequisites: Vec<(ObjectId, Option<String>)>,
694    refs: Vec<(ObjectId, String)>,
695}
696
697fn bundle_header_parts(
698    repo: &Repository,
699    spec: &BundleSpec,
700) -> std::result::Result<BundleHeaderParts, BundleError> {
701    let include_oids: Vec<ObjectId> = spec.include.iter().map(|(o, _)| *o).collect();
702    let prerequisites = prerequisite_commits(repo.odb(), &include_oids, &spec.exclude)?;
703    Ok(BundleHeaderParts {
704        prerequisites,
705        refs: spec.include.clone(),
706    })
707}
708
709/// Stream the bundle's pack to `out` and return its statistics.
710///
711/// Uses the same settings as serving a fetch ([`PackBuildOptions::for_serve`]): deltas
712/// and whole objects already in local packs are copied, and only blobs not already
713/// stored as deltas enter the delta search, so memory stays bounded by the pack index
714/// instead of the inflated object size. Bundles with prerequisites are thin.
715fn write_bundle_pack(
716    repo: &Repository,
717    spec: &BundleSpec,
718    out: &mut dyn Write,
719) -> std::result::Result<PackStats, BundleError> {
720    let io_err = |e: Error| BundleError::Io(e.to_string());
721    let include_oids: Vec<ObjectId> = spec.include.iter().map(|(o, _)| *o).collect();
722    let mut haves: Vec<ObjectId> = spec
723        .exclude
724        .iter()
725        .map(|oid| peel_to_commit(repo.odb(), *oid))
726        .collect::<Result<Vec<_>>>()
727        .map_err(io_err)?;
728    haves.sort();
729    haves.dedup();
730    let cfg = repo.config().ok();
731    let pack_opts = PackBuildOptions::for_serve(cfg.as_deref(), !haves.is_empty(), true);
732    let no_shallow = HashSet::new();
733    let Some(filter) = spec.filter.as_ref() else {
734        return write_upload_pack_stream(
735            repo,
736            &include_oids,
737            &haves,
738            &no_shallow,
739            &no_shallow,
740            None,
741            &pack_opts,
742            &mut |_| {},
743            out,
744        )
745        .map_err(io_err);
746    };
747    let have_closure = reachable_closure(repo.odb(), &haves, &HashSet::new(), true, &no_shallow)
748        .map_err(io_err)?;
749    let send = filtered_pack_objects(
750        repo,
751        &include_oids,
752        &spec.exclude,
753        filter.as_filter(),
754        &have_closure,
755    )
756    .map_err(io_err)?;
757    let pack = build_pack_from_send_list(
758        repo.odb(),
759        &send,
760        &have_closure,
761        &pack_opts,
762        &HashMap::new(),
763    )
764    .map_err(io_err)?;
765    out.write_all(&pack).map_err(BundleError::from)?;
766    Ok(PackStats {
767        object_count: crate::receive_pack::pack_object_count(&pack).unwrap_or(0),
768        pack_bytes: pack.len() as u64,
769        ..PackStats::default()
770    })
771}
772
773/// Write a bundle to `out` from `repo` using `spec`.
774///
775/// The header is written first, then the pack is streamed straight into `out`; the
776/// pack is never held in memory as a whole (filtered bundles excepted). Deltas and
777/// whole objects already stored in local packs are copied verbatim rather than
778/// recomputed, and bundles with prerequisites are thin (deltas may name a
779/// prerequisite-reachable base the recipient already holds). Pass a buffered writer:
780/// the pack is emitted in many small writes.
781///
782/// # Errors
783///
784/// Returns [`BundleError::EmptyRefs`] when `spec.include` is empty, or I/O / pack errors.
785pub fn write_bundle(
786    repo: &Repository,
787    spec: &BundleSpec,
788    out: &mut dyn Write,
789) -> std::result::Result<(), BundleError> {
790    if spec.include.is_empty() {
791        return Err(BundleError::EmptyRefs);
792    }
793    let header = bundle_header_parts(repo, spec)?;
794    write_header(
795        out,
796        repo.odb().hash_algo(),
797        spec.filter.as_ref(),
798        &header.prerequisites,
799        &header.refs,
800    )?;
801
802    write_bundle_pack(repo, spec, out)?;
803    Ok(())
804}
805
806/// Write a bundle header: the signature, the capabilities a non-SHA-1 bundle
807/// needs, one `-<oid> <subject>` line per prerequisite, one `<oid> <ref>` line
808/// per tip, and the blank line the pack follows.
809///
810/// For callers that already have a pack for those tips (such as a push), so a
811/// bundle is this header followed by the pack bytes.
812///
813/// # Errors
814///
815/// [`BundleError::Io`] when writing fails.
816pub fn write_bundle_header(
817    out: &mut dyn Write,
818    algo: HashAlgo,
819    prerequisites: &[(ObjectId, Option<String>)],
820    refs: &[(ObjectId, String)],
821) -> std::result::Result<(), BundleError> {
822    write_header(out, algo, None, prerequisites, refs)
823}
824
825fn write_header(
826    out: &mut dyn Write,
827    algo: HashAlgo,
828    filter: Option<&FilterSpec>,
829    prerequisites: &[(ObjectId, Option<String>)],
830    refs: &[(ObjectId, String)],
831) -> std::result::Result<(), BundleError> {
832    let version = if algo != HashAlgo::Sha1 || filter.is_some() {
833        BundleVersion::V3
834    } else {
835        BundleVersion::V2
836    };
837    write_signature(out, version)?;
838    if version == BundleVersion::V3 {
839        write_capability_object_format(out, algo)?;
840        if let Some(filter) = filter {
841            write_capability_filter(out, filter)?;
842        }
843    }
844    for (oid, subject) in prerequisites {
845        write!(out, "-{} ", oid.to_hex()).map_err(|e| BundleError::Io(e.to_string()))?;
846        if let Some(s) = subject {
847            write!(out, "{s}").map_err(|e| BundleError::Io(e.to_string()))?;
848        }
849        writeln!(out).map_err(|e| BundleError::Io(e.to_string()))?;
850    }
851    for (oid, name) in refs {
852        writeln!(out, "{} {name}", oid.to_hex()).map_err(|e| BundleError::Io(e.to_string()))?;
853    }
854    writeln!(out).map_err(|e| BundleError::Io(e.to_string()))
855}
856
857/// Incremental writer for bundle bytes (header + pack).
858pub struct BundleWriter<W: Write> {
859    inner: W,
860    finished_header: bool,
861}
862
863impl<W: Write> BundleWriter<W> {
864    /// Create a writer; call [`Self::write_header_and_pack`] to emit a complete bundle.
865    pub fn new(inner: W) -> Self {
866        Self {
867            inner,
868            finished_header: false,
869        }
870    }
871
872    /// Write the header and pack in one step.
873    ///
874    /// # Errors
875    ///
876    /// Same as [`write_bundle`].
877    pub fn write_header_and_pack(
878        mut self,
879        repo: &Repository,
880        spec: &BundleSpec,
881    ) -> std::result::Result<(), BundleError> {
882        write_bundle(repo, spec, &mut self.inner)?;
883        self.finished_header = true;
884        Ok(())
885    }
886}
887
888fn write_signature(
889    out: &mut dyn Write,
890    version: BundleVersion,
891) -> std::result::Result<(), BundleError> {
892    let sig = match version {
893        BundleVersion::V2 => V2_SIGNATURE,
894        BundleVersion::V3 => V3_SIGNATURE,
895    };
896    out.write_all(sig.as_bytes())
897        .map_err(|e| BundleError::Io(e.to_string()))
898}
899
900fn write_capability_object_format(
901    out: &mut dyn Write,
902    algo: HashAlgo,
903) -> std::result::Result<(), BundleError> {
904    writeln!(out, "@object-format={}", algo.name()).map_err(|e| BundleError::Io(e.to_string()))
905}
906
907fn write_capability_filter(
908    out: &mut dyn Write,
909    filter: &FilterSpec,
910) -> std::result::Result<(), BundleError> {
911    writeln!(out, "@filter={}", filter.to_capability_value())
912        .map_err(|e| BundleError::Io(e.to_string()))
913}
914
915fn parse_capability(
916    cap: &str,
917    object_format: &mut HashAlgo,
918    filter: &mut Option<FilterSpec>,
919) -> std::result::Result<(), BundleError> {
920    if let Some(name) = cap.strip_prefix("object-format=") {
921        *object_format = HashAlgo::from_name(name)
922            .ok_or_else(|| BundleError::MalformedLine(format!("unknown object format: {name}")))?;
923        return Ok(());
924    }
925    if let Some(spec) = cap.strip_prefix("filter=") {
926        *filter = Some(FilterSpec::parse(spec)?);
927        return Ok(());
928    }
929    Err(BundleError::UnsupportedCapability(cap.to_owned()))
930}
931
932fn read_line(reader: &mut dyn BufRead) -> std::result::Result<String, BundleError> {
933    let mut line = String::new();
934    let n = reader.read_line(&mut line).map_err(BundleError::from)?;
935    if n == 0 {
936        return Err(BundleError::MalformedLine(
937            "unexpected end of header".to_owned(),
938        ));
939    }
940    if line.ends_with('\n') {
941        line.pop();
942        if line.ends_with('\r') {
943            line.pop();
944        }
945    }
946    Ok(line)
947}
948
949fn parse_oid_field(
950    line: &str,
951    algo: HashAlgo,
952) -> std::result::Result<(ObjectId, Option<&str>), BundleError> {
953    let hex_len = algo.hex_len();
954    if line.len() < hex_len {
955        return Err(BundleError::MalformedLine("short object id".to_owned()));
956    }
957    let (hex, tail) = line.split_at(hex_len);
958    if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
959        return Err(BundleError::MalformedLine("invalid object id".to_owned()));
960    }
961    let oid = ObjectId::from_hex(hex)
962        .map_err(|_| BundleError::MalformedLine("invalid object id".to_owned()))?;
963    if hex.len() != algo.hex_len() {
964        return Err(BundleError::MalformedLine(
965            "object id length mismatch".to_owned(),
966        ));
967    }
968    let tail = match tail.chars().next() {
969        None => None,
970        Some(c) if c.is_whitespace() => Some(tail.trim_start()),
971        Some(_) => {
972            return Err(BundleError::MalformedLine(
973                "garbage after object id".to_owned(),
974            ))
975        }
976    };
977    Ok((oid, tail))
978}
979
980fn peel_to_commit(odb: &Odb, oid: ObjectId) -> Result<ObjectId> {
981    match odb.peel_tags(oid)? {
982        (commit, ObjectKind::Commit) => Ok(commit),
983        (other, _) => Err(Error::CorruptObject(format!(
984            "object {other} is not commit or tag"
985        ))),
986    }
987}
988
989fn commit_parent_set(odb: &Odb, commit: ObjectId) -> Result<Vec<ObjectId>> {
990    let obj = odb.read(&commit)?;
991    let commit = parse_commit(&obj.data)?;
992    Ok(commit.parents)
993}
994
995fn commit_closure_roots(odb: &Odb, roots: &[ObjectId]) -> Result<HashSet<ObjectId>> {
996    let mut seen = HashSet::new();
997    let mut q: VecDeque<ObjectId> = VecDeque::new();
998    for &root in roots {
999        let c = peel_to_commit(odb, root)?;
1000        if seen.insert(c) {
1001            q.push_back(c);
1002        }
1003    }
1004    while let Some(c) = q.pop_front() {
1005        for p in commit_parent_set(odb, c)? {
1006            if seen.insert(p) {
1007                q.push_back(p);
1008            }
1009        }
1010    }
1011    Ok(seen)
1012}
1013
1014fn prerequisite_commits(
1015    odb: &Odb,
1016    includes: &[ObjectId],
1017    excludes: &[ObjectId],
1018) -> std::result::Result<Vec<(ObjectId, Option<String>)>, BundleError> {
1019    if excludes.is_empty() {
1020        return Ok(Vec::new());
1021    }
1022    let exclude_set =
1023        commit_closure_roots(odb, excludes).map_err(|e| BundleError::Io(e.to_string()))?;
1024    let mut prerequisites = Vec::new();
1025    let mut seen_prereq = HashSet::new();
1026    let mut walk_seen = HashSet::new();
1027    let mut q: VecDeque<ObjectId> = VecDeque::new();
1028    for &root in includes {
1029        let c = peel_to_commit(odb, root).map_err(|e| BundleError::Io(e.to_string()))?;
1030        if exclude_set.contains(&c) {
1031            continue;
1032        }
1033        if walk_seen.insert(c) {
1034            q.push_back(c);
1035        }
1036    }
1037    while let Some(c) = q.pop_front() {
1038        let parents = commit_parent_set(odb, c).map_err(|e| BundleError::Io(e.to_string()))?;
1039        for p in parents {
1040            if exclude_set.contains(&p) && seen_prereq.insert(p) {
1041                let subject = commit_subject(odb, p).map_err(|e| BundleError::Io(e.to_string()))?;
1042                prerequisites.push((p, subject));
1043            }
1044            if !exclude_set.contains(&p) && walk_seen.insert(p) {
1045                q.push_back(p);
1046            }
1047        }
1048    }
1049    Ok(prerequisites)
1050}
1051
1052fn commit_subject(odb: &Odb, commit: ObjectId) -> Result<Option<String>> {
1053    let obj = odb.read(&commit)?;
1054    let parsed = parse_commit(&obj.data)?;
1055    let subj = message_subject(&parsed.message);
1056    if subj.is_empty() {
1057        Ok(None)
1058    } else {
1059        Ok(Some(subj))
1060    }
1061}
1062
1063fn filter_to_capability_value(filter: &ObjectFilter) -> String {
1064    match filter {
1065        ObjectFilter::BlobNone => "blob:none".to_owned(),
1066        ObjectFilter::BlobLimit(n) => format!("blob:limit={n}"),
1067        ObjectFilter::TreeDepth(d) => format!("tree:{d}"),
1068        ObjectFilter::SparseOid(s) => format!("sparse:oid={s}"),
1069        ObjectFilter::ObjectType(k) => format!("object:type={}", filter_object_kind_name(*k)),
1070        ObjectFilter::Combine(parts) => {
1071            let encoded: Vec<String> = parts
1072                .iter()
1073                .map(|p| {
1074                    crate::rev_list::url_encode_object_filter_subspec(&filter_to_capability_value(
1075                        p,
1076                    ))
1077                })
1078                .collect();
1079            format!("combine:{}", encoded.join("+"))
1080        }
1081    }
1082}
1083
1084fn filter_object_kind_name(kind: FilterObjectKind) -> &'static str {
1085    match kind {
1086        FilterObjectKind::Blob => "blob",
1087        FilterObjectKind::Tree => "tree",
1088        FilterObjectKind::Commit => "commit",
1089        FilterObjectKind::Tag => "tag",
1090    }
1091}
1092
1093/// Object list for a filtered bundle pack (`rev-list --objects --filter`).
1094fn filtered_pack_objects(
1095    repo: &Repository,
1096    include: &[ObjectId],
1097    exclude: &[ObjectId],
1098    filter: &ObjectFilter,
1099    have_closure: &HashSet<ObjectId>,
1100) -> Result<Vec<ObjectId>> {
1101    let positive: Vec<String> = include.iter().map(ObjectId::to_hex).collect();
1102    // `rev_list` takes the excluded tips bare: they are already the negative side.
1103    let negative: Vec<String> = exclude.iter().map(ObjectId::to_hex).collect();
1104    let options = RevListOptions {
1105        objects: true,
1106        no_object_names: true,
1107        quiet: true,
1108        filter: Some(filter.clone()),
1109        missing_action: MissingAction::Error,
1110        ..Default::default()
1111    };
1112    let walk = crate::rev_list::rev_list(repo, &positive, &negative, &options)?;
1113    Ok(collect_filtered_send_list(&walk, have_closure))
1114}
1115
1116fn collect_filtered_send_list(
1117    walk: &RevListResult,
1118    have_closure: &HashSet<ObjectId>,
1119) -> Vec<ObjectId> {
1120    let mut send = Vec::new();
1121    let mut seen = HashSet::new();
1122    let mut push = |oid: ObjectId| {
1123        if have_closure.contains(&oid) {
1124            return;
1125        }
1126        if seen.insert(oid) {
1127            send.push(oid);
1128        }
1129    };
1130    for oid in &walk.commits {
1131        push(*oid);
1132    }
1133    for (oid, _) in &walk.objects {
1134        push(*oid);
1135    }
1136    for tag_oid in walk.tip_annotated_tag_by_commit.values().flatten() {
1137        push(*tag_oid);
1138    }
1139    send
1140}
1141
1142#[cfg(test)]
1143mod tests {
1144    use super::*;
1145    use std::io::Cursor;
1146
1147    #[test]
1148    fn parse_v2_header_and_refs() {
1149        let oid = "a".repeat(40);
1150        let data = format!("# v2 git bundle\n{oid} refs/heads/main\n\nPACK");
1151        let mut cur = Cursor::new(data.into_bytes());
1152        let header = read_header(&mut cur).expect("header");
1153        assert_eq!(header.version, BundleVersion::V2);
1154        assert_eq!(header.object_format, HashAlgo::Sha1);
1155        assert_eq!(header.prerequisites.len(), 0);
1156        assert_eq!(header.refs.len(), 1);
1157        assert_eq!(header.refs[0].0, "refs/heads/main");
1158    }
1159
1160    #[test]
1161    fn bad_signature_yields_error() {
1162        let mut cur = Cursor::new(b"# v4 git bundle\n\nPACK");
1163        let err = read_header(&mut cur).unwrap_err();
1164        assert_eq!(err, BundleError::BadSignature);
1165    }
1166
1167    #[test]
1168    fn unknown_capability() {
1169        let mut cur = Cursor::new(b"# v3 git bundle\n@unknown=yes\n\nPACK");
1170        let err = read_header(&mut cur).unwrap_err();
1171        assert!(matches!(err, BundleError::UnsupportedCapability(_)));
1172    }
1173
1174    #[test]
1175    fn truncated_ref_line() {
1176        let oid = "a".repeat(40);
1177        let data = format!("# v2 git bundle\n{oid}\n\nPACK");
1178        let mut cur = Cursor::new(data.into_bytes());
1179        let err = read_header(&mut cur).unwrap_err();
1180        assert!(matches!(err, BundleError::MalformedLine(_)));
1181    }
1182
1183    #[test]
1184    fn filter_spec_roundtrip_blob_none() {
1185        let f = FilterSpec::parse("blob:none").expect("parse");
1186        assert_eq!(f.to_capability_value(), "blob:none");
1187    }
1188
1189    #[test]
1190    fn verify_report_missing_prerequisite_error() {
1191        let oid = ObjectId::from_hex(&"b".repeat(40)).expect("oid");
1192        let report = BundleVerifyReport {
1193            missing_prerequisites: vec![(oid, Some("subj".to_owned()))],
1194            prerequisites_not_connected: false,
1195        };
1196        let err = report.into_result().unwrap_err();
1197        assert!(matches!(err, BundleError::MissingPrerequisite { .. }));
1198    }
1199
1200    fn write_bundle_file(bytes: &[u8]) -> tempfile::NamedTempFile {
1201        let mut file = tempfile::NamedTempFile::new().expect("tempfile");
1202        file.write_all(bytes).expect("write bundle");
1203        file.flush().expect("flush bundle");
1204        file
1205    }
1206
1207    #[test]
1208    fn open_reports_pack_offset_after_header() {
1209        let oid = "a".repeat(40);
1210        let header = format!("# v2 git bundle\n{oid} refs/heads/main\n\n");
1211        let mut data = header.clone().into_bytes();
1212        data.extend_from_slice(b"PACK\0\0\0\x02\0\0\0\0");
1213        let file = write_bundle_file(&data);
1214        let bundle = Bundle::open(file.path()).expect("open");
1215        assert_eq!(bundle.pack_byte_offset(), header.len() as u64);
1216        assert_eq!(bundle.header().refs.len(), 1);
1217    }
1218
1219    #[test]
1220    fn open_skips_blank_lines_before_pack() {
1221        let oid = "a".repeat(40);
1222        let header = format!("# v2 git bundle\n{oid} HEAD\n\n\r\n\n");
1223        let mut data = header.clone().into_bytes();
1224        data.extend_from_slice(b"PACK");
1225        let file = write_bundle_file(&data);
1226        let bundle = Bundle::open(file.path()).expect("open");
1227        assert_eq!(bundle.pack_byte_offset(), header.len() as u64);
1228    }
1229
1230    #[test]
1231    fn open_handles_pack_magic_across_read_buffer_boundary() {
1232        let oid = "c".repeat(40);
1233        let signature = "# v2 git bundle\n";
1234        let ref_line = |i: usize| format!("{oid} refs/tags/t{i:06}\n");
1235        let line_len = ref_line(0).len();
1236        // Grow the header until the blank terminator lands two bytes before the first
1237        // buffer refill, so "PACK" straddles the boundary.
1238        let mut refs = 0usize;
1239        while signature.len() + (refs + 1) * line_len + 1 + 2 <= HEADER_READ_BUF {
1240            refs += 1;
1241        }
1242        let mut header = String::from(signature);
1243        for i in 0..refs {
1244            header.push_str(&ref_line(i));
1245        }
1246        header.push('\n');
1247        let pad = HEADER_READ_BUF - 2 - header.len();
1248        let mut data = header.clone().into_bytes();
1249        data.extend(std::iter::repeat_n(b'\n', pad));
1250        data.extend_from_slice(b"PACK\0\0\0\x02");
1251        assert_eq!(&data[HEADER_READ_BUF - 2..HEADER_READ_BUF + 2], b"PACK");
1252        let file = write_bundle_file(&data);
1253        let bundle = Bundle::open(file.path()).expect("open");
1254        assert_eq!(bundle.header().refs.len(), refs);
1255        assert_eq!(bundle.pack_byte_offset(), (HEADER_READ_BUF - 2) as u64);
1256    }
1257
1258    #[test]
1259    fn open_missing_file_names_path() {
1260        let path = std::path::Path::new("/no/such/bundle/path.bundle");
1261        let err = Bundle::open(path).unwrap_err();
1262        let msg = err.to_string();
1263        assert!(
1264            msg.contains("could not open") && msg.contains("path.bundle"),
1265            "{msg}"
1266        );
1267    }
1268
1269    #[test]
1270    fn open_without_pack_is_missing_pack() {
1271        let oid = "a".repeat(40);
1272        let truncated = format!("# v2 git bundle\n{oid} HEAD\n\n");
1273        let file = write_bundle_file(truncated.as_bytes());
1274        assert_eq!(
1275            Bundle::open(file.path()).unwrap_err(),
1276            BundleError::MissingPack
1277        );
1278
1279        let short = format!("# v2 git bundle\n{oid} HEAD\n\nPA");
1280        let file = write_bundle_file(short.as_bytes());
1281        assert_eq!(
1282            Bundle::open(file.path()).unwrap_err(),
1283            BundleError::MissingPack
1284        );
1285
1286        let garbage = format!("# v2 git bundle\n{oid} HEAD\n\nJUNK");
1287        let file = write_bundle_file(garbage.as_bytes());
1288        assert_eq!(
1289            Bundle::open(file.path()).unwrap_err(),
1290            BundleError::MissingPack
1291        );
1292    }
1293}