use std::fs;
use std::io::{self, BufRead, BufReader, Read, Seek, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use gix::bstr::BStr;
use gix_hash::ObjectId;
use gix_pack::Find as _;
use gix_pack::data::output::bytes::FromEntriesIter;
use gix_pack::data::output::{count, entry};
use tempfile::NamedTempFile;
use thiserror::Error;
use crate::git::{PeeledTip, Sha};
const BUNDLE_V2_MAGIC: &str = "# v2 git bundle";
const BUNDLE_V3_MAGIC: &str = "# v3 git bundle";
const MAX_HEADER_LINE_BYTES: u64 = 16 * 1_024;
const MAX_HEADER_TOTAL_BYTES: u64 = 64 * 1_024 * 1_024;
#[allow(dead_code)]
pub struct BundleHeader {
pub version: u8,
pub prerequisites: Vec<ObjectId>,
pub refs: Vec<(ObjectId, Vec<u8>)>,
pub pack_offset: u64,
}
impl BundleHeader {
pub fn read(path: &Path) -> Result<Self, BundleError> {
let mut file = BufReader::new(fs::File::open(path)?);
let mut line = String::new();
let mut buf: Vec<u8> = Vec::new();
let mut total_bytes: u64 = 0;
read_header_line(&mut file, &mut line, &mut buf, &mut total_bytes)?;
let magic = line.trim_end_matches(['\n', '\r']);
if magic == BUNDLE_V3_MAGIC {
return Err(BundleError::UnsupportedVersion(3));
}
if magic != BUNDLE_V2_MAGIC {
return Err(BundleError::InvalidHeader(format!(
"expected \"# v2 git bundle\", got {magic:?}",
)));
}
let mut prerequisites = Vec::new();
let mut refs = Vec::new();
loop {
read_header_line(&mut file, &mut line, &mut buf, &mut total_bytes)?;
match parse_header_entry(&line)? {
HeaderEntry::End => break,
HeaderEntry::Prerequisite(oid) => prerequisites.push(oid),
HeaderEntry::Ref(oid, name) => refs.push((oid, name)),
}
}
let pack_offset = file.stream_position()?;
verify_pack_magic(&mut file)?;
Ok(BundleHeader {
version: 2,
prerequisites,
refs,
pack_offset,
})
}
}
fn read_header_line<R: BufRead>(
reader: &mut R,
line: &mut String,
buf: &mut Vec<u8>,
total_bytes: &mut u64,
) -> Result<(), BundleError> {
line.clear();
buf.clear();
let remaining_total = MAX_HEADER_TOTAL_BYTES.saturating_sub(*total_bytes);
let budget = MAX_HEADER_LINE_BYTES.min(remaining_total);
if budget == 0 {
return Err(BundleError::InvalidHeader(format!(
"bundle header exceeds {MAX_HEADER_TOTAL_BYTES}-byte cap",
)));
}
let n = reader.by_ref().take(budget).read_until(b'\n', buf)?;
if n == 0 {
return Err(BundleError::InvalidHeader(
"unexpected end of bundle header".to_owned(),
));
}
let n_u64 = u64::try_from(n)
.map_err(|_| BundleError::InvalidHeader("header line length overflow".to_owned()))?;
if n_u64 == budget && buf.last() != Some(&b'\n') {
return Err(BundleError::InvalidHeader(
if budget < MAX_HEADER_LINE_BYTES {
format!("bundle header exceeds {MAX_HEADER_TOTAL_BYTES}-byte cap")
} else {
format!("bundle header line exceeds {MAX_HEADER_LINE_BYTES}-byte cap")
},
));
}
*total_bytes = total_bytes.saturating_add(n_u64);
let decoded = std::str::from_utf8(buf).map_err(|_| {
BundleError::InvalidHeader("bundle header line is not valid UTF-8".to_owned())
})?;
line.push_str(decoded);
Ok(())
}
#[cfg_attr(test, derive(Debug))]
enum HeaderEntry {
End,
Prerequisite(ObjectId),
Ref(ObjectId, Vec<u8>),
}
fn parse_header_entry(line: &str) -> Result<HeaderEntry, BundleError> {
let trimmed = line.trim_end_matches(['\n', '\r']);
if trimmed.is_empty() {
return Ok(HeaderEntry::End);
}
if let Some(rest) = trimmed.strip_prefix('-') {
let sha_hex = rest.split_once(' ').map_or(rest, |(s, _)| s);
let oid = parse_header_oid(sha_hex, "prerequisite")?;
return Ok(HeaderEntry::Prerequisite(oid));
}
let mut parts = trimmed.splitn(2, ' ');
let sha_hex = parts
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| BundleError::InvalidHeader(format!("empty ref line: {trimmed:?}")))?;
let ref_name = parts
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| BundleError::InvalidHeader(format!("missing ref name: {trimmed:?}")))?;
let oid = parse_header_oid(sha_hex, "ref")?;
Ok(HeaderEntry::Ref(oid, ref_name.as_bytes().to_vec()))
}
fn parse_header_oid(sha_hex: &str, context: &str) -> Result<ObjectId, BundleError> {
ObjectId::from_hex(sha_hex.as_bytes())
.map_err(|_| BundleError::InvalidHeader(format!("bad {context} SHA: {sha_hex:?}")))
}
fn verify_pack_magic<R: Read>(file: &mut R) -> Result<(), BundleError> {
let mut buf = [0u8; 4];
file.read_exact(&mut buf).map_err(|e| {
if e.kind() == io::ErrorKind::UnexpectedEof {
BundleError::InvalidHeader("bundle truncated before PACK data".to_owned())
} else {
BundleError::Io(e)
}
})?;
if &buf != b"PACK" {
return Err(BundleError::InvalidHeader(
"expected PACK magic after bundle header".to_owned(),
));
}
Ok(())
}
pub(crate) fn count_objects_as_is<F>(
odb: F,
object_ids: &[ObjectId],
) -> Result<Vec<gix_pack::data::output::Count>, count::objects::Error>
where
F: gix_pack::Find + Send + Clone + 'static,
{
if object_ids.is_empty() {
return Ok(Vec::new());
}
let owned = object_ids.to_vec();
let (counts, _) = count::objects(
odb,
Box::new(
owned
.into_iter()
.map(Ok::<_, Box<dyn std::error::Error + Send + Sync + 'static>>),
),
&gix::progress::Discard,
&AtomicBool::new(false),
count::objects::Options {
input_object_expansion: count::objects::ObjectExpansion::AsIs,
thread_limit: Some(1),
..Default::default()
},
)?;
Ok(counts)
}
pub fn create(cwd: &Path, folder: &Path, sha: Sha, spec: &str) -> Result<PathBuf, BundleError> {
let repo = gix::open(cwd)?;
let (peeled, ref_name) = resolve_spec_to_ref(&repo, spec)?;
let mut odb = repo.objects.clone().into_inner();
odb.prevent_pack_unload();
let (input_oids, expansion, tag_chain) = match peeled {
PeeledTip::Commit { commit, tag_chain } => {
let ids = collect_commit_ids(&repo, *commit.as_object_id())?;
(
ids,
count::objects::ObjectExpansion::TreeContents,
tag_chain,
)
}
PeeledTip::Tree { tree, tag_chain } => {
let ids = crate::packchain::git::enumerate_tree_closure(&repo, tree)
.map_err(|e| BundleError::Git(Box::new(e)))?;
(ids, count::objects::ObjectExpansion::AsIs, tag_chain)
}
PeeledTip::Blob { blob, tag_chain } => {
(vec![blob], count::objects::ObjectExpansion::AsIs, tag_chain)
}
};
let (mut counts, _) = count::objects(
odb.clone(),
Box::new(
input_oids
.into_iter()
.map(Ok::<_, Box<dyn std::error::Error + Send + Sync + 'static>>),
),
&gix::progress::Discard,
&AtomicBool::new(false),
count::objects::Options {
input_object_expansion: expansion,
thread_limit: Some(1),
..Default::default()
},
)?;
counts.extend(count_objects_as_is(odb.clone(), &tag_chain)?);
let num_entries = u32::try_from(counts.len())
.map_err(|_| BundleError::PackEntry("too many objects for a single pack".to_owned()))?;
let entries_iter = entry::iter_from_counts(
counts,
odb,
Box::new(gix::progress::Discard),
entry::iter_from_counts::Options {
thread_limit: Some(1),
..Default::default()
},
)
.map(|r| r.map(|(_, entries)| entries));
let folder = folder.canonicalize()?;
let bundle_path = folder.join(format!("{sha}.bundle"));
let mut tmp = NamedTempFile::new_in(&folder)?;
write_bundle_header(&mut tmp, sha, &ref_name)?;
let pack_iter = FromEntriesIter::new(
entries_iter,
&mut tmp,
num_entries,
gix_pack::data::Version::V2,
gix_hash::Kind::Sha1,
);
for result in pack_iter {
result.map_err(|e| BundleError::PackEntry(e.to_string()))?;
}
tmp.persist(&bundle_path)
.map_err(|e| BundleError::Io(e.error))?;
Ok(bundle_path)
}
fn collect_commit_ids(
repo: &gix::Repository,
tip_id: ObjectId,
) -> Result<Vec<ObjectId>, BundleError> {
repo.rev_walk([tip_id])
.all()
.map_err(|e| BundleError::Walk(Box::new(e)))?
.map(|info| info.map(|i| i.id))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| BundleError::Walk(Box::new(e)))
}
fn write_bundle_header<W: Write>(
writer: &mut W,
sha: Sha,
ref_name: &str,
) -> Result<(), BundleError> {
writeln!(writer, "{BUNDLE_V2_MAGIC}")?;
writeln!(writer, "{sha} {ref_name}")?;
writeln!(writer)?;
Ok(())
}
pub(crate) fn unbundle(cwd: &Path, folder: &Path, sha: Sha) -> Result<(), BundleError> {
let folder = folder.canonicalize()?;
let bundle_path = folder.join(format!("{sha}.bundle"));
let header = BundleHeader::read(&bundle_path)?;
let repo = gix::open(cwd)?;
let odb = repo.objects.clone().into_inner();
for prereq in &header.prerequisites {
if !odb.contains(prereq) {
return Err(BundleError::MissingPrerequisite(*prereq));
}
}
let pack_dir = repo.git_dir().join("objects/pack");
fs::create_dir_all(&pack_dir)?;
let mut bundle_file = BufReader::new(fs::File::open(&bundle_path)?);
bundle_file.seek(io::SeekFrom::Start(header.pack_offset))?;
let interrupted = AtomicBool::new(false);
let outcome = gix_pack::Bundle::write_to_directory(
&mut bundle_file,
Some(&pack_dir),
&mut gix::progress::Discard,
&interrupted,
None::<gix::odb::Handle>,
gix_pack::bundle::write::Options {
object_hash: gix_hash::Kind::Sha1,
..Default::default()
},
)?;
if let Some(keep_path) = outcome.keep_path
&& let Err(e) = fs::remove_file(&keep_path)
&& e.kind() != io::ErrorKind::NotFound
{
return Err(BundleError::Io(e));
}
Ok(())
}
fn resolve_spec_to_ref(
repo: &gix::Repository,
spec: &str,
) -> Result<(PeeledTip, String), BundleError> {
let resolved = repo.rev_parse_single(BStr::new(spec))?.detach();
let peeled = crate::git::peel_tag_chain(repo, Sha::from_object_id(resolved))
.map_err(|e| BundleError::Git(Box::new(e)))?;
let ref_name = match repo.try_find_reference(spec) {
Ok(Some(r)) => {
let bytes = if let Some(Ok(followed)) = r.follow() {
followed.name().as_bstr().to_vec()
} else {
r.name().as_bstr().to_vec()
};
String::from_utf8(bytes)
.map_err(|_| BundleError::InvalidHeader("ref name is not valid UTF-8".to_owned()))?
}
_ => spec.to_owned(),
};
Ok((peeled, ref_name))
}
#[derive(Debug, Error)]
pub enum BundleError {
#[error("invalid bundle header: {0}")]
InvalidHeader(String),
#[error("unsupported bundle version {0}; only v2 is supported")]
UnsupportedVersion(u8),
#[error("missing prerequisite {0}")]
MissingPrerequisite(ObjectId),
#[error("open repository: {0}")]
Repo(Box<gix::open::Error>),
#[error("rev-parse: {0}")]
RevParse(Box<gix::revision::spec::parse::single::Error>),
#[error("find object: {0}")]
FindObject(Box<gix::object::find::existing::Error>),
#[error("peel to commit: {0}")]
PeelToKind(Box<gix::object::peel::to_kind::Error>),
#[error("object walk: {0}")]
Walk(Box<dyn std::error::Error + Send + Sync + 'static>),
#[error("pack count: {0}")]
PackCount(Box<count::objects::Error>),
#[error("pack entry: {0}")]
PackEntry(String),
#[error("pack write: {0}")]
PackWrite(Box<gix_pack::bundle::write::Error>),
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
Git(Box<crate::git::GitError>),
}
impl From<gix::open::Error> for BundleError {
fn from(e: gix::open::Error) -> Self {
Self::Repo(Box::new(e))
}
}
impl From<gix::revision::spec::parse::single::Error> for BundleError {
fn from(e: gix::revision::spec::parse::single::Error) -> Self {
Self::RevParse(Box::new(e))
}
}
impl From<gix::object::find::existing::Error> for BundleError {
fn from(e: gix::object::find::existing::Error) -> Self {
Self::FindObject(Box::new(e))
}
}
impl From<gix::object::peel::to_kind::Error> for BundleError {
fn from(e: gix::object::peel::to_kind::Error) -> Self {
Self::PeelToKind(Box::new(e))
}
}
impl From<count::objects::Error> for BundleError {
fn from(e: count::objects::Error) -> Self {
Self::PackCount(Box::new(e))
}
}
impl From<gix_pack::bundle::write::Error> for BundleError {
fn from(e: gix_pack::bundle::write::Error) -> Self {
Self::PackWrite(Box::new(e))
}
}
#[cfg(test)]
mod tests {
use super::*;
const SHA: &str = "0123456789abcdef0123456789abcdef01234567";
const OTHER_SHA: &str = "fedcba9876543210fedcba9876543210fedcba98";
#[test]
fn parse_header_entry_recognises_blank_line_as_end() {
match parse_header_entry("\n").expect("parse") {
HeaderEntry::End => {}
other => panic!("expected End, got {other:?}"),
}
match parse_header_entry("\r\n").expect("parse") {
HeaderEntry::End => {}
other => panic!("expected End, got {other:?}"),
}
match parse_header_entry("").expect("parse") {
HeaderEntry::End => {}
other => panic!("expected End, got {other:?}"),
}
}
#[test]
fn parse_header_entry_parses_prerequisite_with_optional_comment() {
let line = format!("-{SHA}\n");
let entry = parse_header_entry(&line).expect("parse");
let HeaderEntry::Prerequisite(oid) = entry else {
panic!("expected Prerequisite, got {entry:?}");
};
assert_eq!(oid.to_hex().to_string(), SHA);
let with_comment = format!("-{OTHER_SHA} a comment\n");
let entry = parse_header_entry(&with_comment).expect("parse");
let HeaderEntry::Prerequisite(oid) = entry else {
panic!("expected Prerequisite, got {entry:?}");
};
assert_eq!(oid.to_hex().to_string(), OTHER_SHA);
}
#[test]
fn parse_header_entry_parses_ref_line() {
let line = format!("{SHA} refs/heads/main\n");
let entry = parse_header_entry(&line).expect("parse");
let HeaderEntry::Ref(oid, name_bytes) = entry else {
panic!("expected Ref, got {entry:?}");
};
assert_eq!(oid.to_hex().to_string(), SHA);
assert_eq!(name_bytes, b"refs/heads/main");
}
#[test]
fn parse_header_entry_rejects_truncated_ref_line() {
let line = format!("{SHA}\n");
match parse_header_entry(&line) {
Err(BundleError::InvalidHeader(msg)) => {
assert!(
msg.contains("missing ref name"),
"expected missing-ref-name wording, got {msg:?}",
);
}
other => panic!("expected InvalidHeader, got {other:?}"),
}
}
#[test]
fn parse_header_entry_rejects_bad_sha_in_ref_line() {
let bad = "0123456789abcdef0123456789abcdef0123456";
assert_eq!(bad.len(), 39);
let line = format!("{bad} refs/heads/main\n");
match parse_header_entry(&line) {
Err(BundleError::InvalidHeader(msg)) => {
assert!(
msg.contains("bad ref SHA"),
"expected ref SHA wording, got {msg:?}",
);
assert!(
msg.contains(bad),
"expected echoed SHA in message, got {msg:?}",
);
}
other => panic!("expected InvalidHeader, got {other:?}"),
}
}
#[test]
fn parse_header_entry_rejects_bad_sha_in_prerequisite_line() {
let line = "-not-a-sha\n";
match parse_header_entry(line) {
Err(BundleError::InvalidHeader(msg)) => {
assert!(
msg.contains("bad prerequisite SHA"),
"expected prerequisite SHA wording, got {msg:?}",
);
}
other => panic!("expected InvalidHeader, got {other:?}"),
}
}
#[test]
fn parse_header_oid_accepts_lowercase_hex() {
let oid = parse_header_oid(SHA, "test").expect("parse");
assert_eq!(oid.to_hex().to_string(), SHA);
}
#[test]
fn parse_header_oid_rejects_short_hex_and_names_context() {
let err = parse_header_oid("abc", "ref").unwrap_err();
let BundleError::InvalidHeader(msg) = err else {
panic!("expected InvalidHeader, got {err:?}");
};
assert!(msg.contains("bad ref SHA"), "context not in message: {msg}");
}
#[test]
fn read_header_line_reuses_caller_buffer_across_lines() {
let mut reader: &[u8] = b"line one\nline two\n";
let mut line = String::new();
let mut buf: Vec<u8> = Vec::new();
let mut total: u64 = 0;
read_header_line(&mut reader, &mut line, &mut buf, &mut total).expect("first line");
assert_eq!(line, "line one\n");
read_header_line(&mut reader, &mut line, &mut buf, &mut total).expect("second line");
assert_eq!(line, "line two\n");
}
#[test]
fn verify_pack_magic_accepts_pack_bytes() {
let mut data: &[u8] = b"PACK extra";
verify_pack_magic(&mut data).expect("PACK accepted");
assert_eq!(data, b" extra");
}
#[test]
fn verify_pack_magic_rejects_non_pack_bytes() {
let mut data: &[u8] = b"NOPE";
match verify_pack_magic(&mut data) {
Err(BundleError::InvalidHeader(msg)) => {
assert!(msg.contains("expected PACK magic"), "wrong wording: {msg}");
}
other => panic!("expected InvalidHeader, got {other:?}"),
}
}
#[test]
fn verify_pack_magic_rejects_truncated_input_with_specific_error() {
let mut data: &[u8] = b"PA";
match verify_pack_magic(&mut data) {
Err(BundleError::InvalidHeader(msg)) => {
assert!(
msg.contains("truncated before PACK"),
"wrong wording: {msg}",
);
}
other => panic!("expected InvalidHeader for truncation, got {other:?}"),
}
}
fn write_bundle_bytes(dir: &Path, bytes: &[u8]) -> PathBuf {
let path = dir.join("bundle");
fs::write(&path, bytes).expect("write fixture bundle");
path
}
#[test]
fn bundle_header_read_rejects_overlong_line() {
let dir = tempfile::tempdir().unwrap();
let overlong =
vec![b'#'; usize::try_from(MAX_HEADER_LINE_BYTES + 1).expect("cap fits usize")];
let path = write_bundle_bytes(dir.path(), &overlong);
let err = BundleHeader::read(&path)
.err()
.expect("expected InvalidHeader");
let BundleError::InvalidHeader(msg) = err else {
panic!("expected InvalidHeader, got {err:?}");
};
assert!(
msg.contains("line exceeds"),
"expected per-line cap wording, got {msg:?}",
);
}
#[test]
fn bundle_header_read_rejects_line_exactly_one_over_cap() {
let dir = tempfile::tempdir().unwrap();
let mut overlong =
vec![b'#'; usize::try_from(MAX_HEADER_LINE_BYTES).expect("cap fits usize")];
overlong.push(b'\n');
let path = write_bundle_bytes(dir.path(), &overlong);
let err = BundleHeader::read(&path)
.err()
.expect("expected InvalidHeader");
let BundleError::InvalidHeader(msg) = err else {
panic!("expected InvalidHeader, got {err:?}");
};
assert!(
msg.contains("line exceeds"),
"expected per-line cap wording, got {msg:?}",
);
}
#[test]
fn bundle_header_read_rejects_total_header_over_cap() {
let dir = tempfile::tempdir().unwrap();
let mut bytes = Vec::new();
bytes.extend_from_slice(BUNDLE_V2_MAGIC.as_bytes());
bytes.push(b'\n');
let chunk_size: usize = 8 * 1_024;
let pad_len = chunk_size - 1 - 40 - 1 - 1;
let mut chunk = Vec::with_capacity(chunk_size);
chunk.push(b'-');
chunk.extend_from_slice(SHA.as_bytes());
chunk.push(b' ');
chunk.extend(std::iter::repeat_n(b'x', pad_len));
chunk.push(b'\n');
assert_eq!(chunk.len(), chunk_size);
let line_count = (MAX_HEADER_TOTAL_BYTES / chunk_size as u64) + 1;
for _ in 0..line_count {
bytes.extend_from_slice(&chunk);
}
let path = write_bundle_bytes(dir.path(), &bytes);
let err = BundleHeader::read(&path)
.err()
.expect("expected InvalidHeader");
let BundleError::InvalidHeader(msg) = err else {
panic!("expected InvalidHeader for total cap, got {err:?}");
};
assert!(
msg.contains("header exceeds"),
"expected total-cap wording, got {msg:?}",
);
}
#[test]
fn bundle_header_read_accepts_legitimate_header() {
let dir = tempfile::tempdir().unwrap();
let mut bytes = Vec::new();
bytes.extend_from_slice(BUNDLE_V2_MAGIC.as_bytes());
bytes.push(b'\n');
bytes.extend_from_slice(format!("{SHA} refs/heads/main\n").as_bytes());
bytes.extend_from_slice(format!("-{OTHER_SHA}\n").as_bytes());
bytes.push(b'\n'); bytes.extend_from_slice(b"PACK");
let path = write_bundle_bytes(dir.path(), &bytes);
let header = BundleHeader::read(&path).expect("legitimate header parses");
assert_eq!(header.version, 2);
assert_eq!(header.refs.len(), 1);
assert_eq!(header.refs[0].1, b"refs/heads/main");
assert_eq!(header.prerequisites.len(), 1);
assert_eq!(header.prerequisites[0].to_hex().to_string(), OTHER_SHA);
}
#[test]
fn bundle_header_read_rejects_missing_trailing_newline() {
let dir = tempfile::tempdir().unwrap();
let path = write_bundle_bytes(dir.path(), BUNDLE_V2_MAGIC.as_bytes());
let err = BundleHeader::read(&path)
.err()
.expect("expected InvalidHeader");
let message = match &err {
BundleError::InvalidHeader(m) => m.clone(),
other => panic!("expected InvalidHeader, got {other:?}"),
};
assert!(
message.contains("unexpected end"),
"expected 'unexpected end' in message, got {message:?}",
);
}
use gix::actor::SignatureRef;
use tempfile::TempDir;
fn signature() -> SignatureRef<'static> {
SignatureRef {
name: BStr::new("Tester"),
email: BStr::new("t@example.com"),
time: "0 +0000",
}
}
fn fixture_commit() -> (TempDir, ObjectId) {
let tmp = TempDir::new().unwrap();
let repo = gix::init(tmp.path()).unwrap();
let blob = repo.write_blob(b"hello").unwrap().detach();
let tree = repo
.write_object(&gix::objs::Tree {
entries: vec![gix::objs::tree::Entry {
mode: gix::objs::tree::EntryKind::Blob.into(),
filename: "a.txt".into(),
oid: blob,
}],
})
.unwrap()
.detach();
let commit = repo
.commit_as(
signature(),
signature(),
"refs/heads/main",
"first",
tree,
std::iter::empty::<ObjectId>(),
)
.unwrap()
.detach();
(tmp, commit)
}
fn write_annotated_tag(
repo: &gix::Repository,
target: ObjectId,
target_kind: gix::object::Kind,
name: &str,
) -> ObjectId {
let tag = gix::objs::Tag {
target,
target_kind,
name: name.into(),
tagger: Some(signature().to_owned().expect("static signature is valid")),
message: "release".into(),
pgp_signature: None,
};
repo.write_object(&tag).unwrap().detach()
}
fn create_tag_ref(repo: &gix::Repository, name: &str, target: ObjectId) {
repo.reference(
name,
target,
gix::refs::transaction::PreviousValue::MustNotExist,
"create tag",
)
.unwrap();
}
fn install_bundle_into_fresh_repo(bundle_path: &Path, sha: Sha) -> (TempDir, gix::Repository) {
let dst = TempDir::new().unwrap();
gix::init(dst.path()).unwrap();
let folder = bundle_path.parent().unwrap().to_owned();
unbundle(dst.path(), &folder, sha).unwrap();
let dst_repo = gix::open(dst.path()).unwrap();
(dst, dst_repo)
}
#[test]
fn bundle_create_round_trips_annotated_tag() {
let (repo_dir, commit) = fixture_commit();
let repo = gix::open(repo_dir.path()).unwrap();
let tag_oid = write_annotated_tag(&repo, commit, gix::object::Kind::Commit, "v1");
create_tag_ref(&repo, "refs/tags/v1", tag_oid);
drop(repo);
let folder = TempDir::new().unwrap();
let tag_sha = Sha::from_object_id(tag_oid);
let bundle_path =
create(repo_dir.path(), folder.path(), tag_sha, "refs/tags/v1").expect("create bundle");
let (_dst_dir, dst_repo) = install_bundle_into_fresh_repo(&bundle_path, tag_sha);
let odb = dst_repo.objects.clone().into_inner();
assert!(
odb.contains(&tag_oid),
"tag object must be installed by unbundle",
);
assert!(
odb.contains(&commit),
"commit target must also be installed"
);
let tag_obj = dst_repo
.find_object(tag_oid)
.unwrap()
.peel_to_kind(gix::object::Kind::Tag)
.unwrap();
assert_eq!(
tag_obj.into_tag().target_id().unwrap().detach(),
commit,
"round-tripped tag must point at the original commit",
);
}
#[test]
fn bundle_create_with_branch_tip_emits_unchanged_pack() {
let (repo_dir, commit) = fixture_commit();
let folder = TempDir::new().unwrap();
create(
repo_dir.path(),
folder.path(),
Sha::from_object_id(commit),
"refs/heads/main",
)
.expect("create bundle");
let dst = TempDir::new().unwrap();
gix::init(dst.path()).unwrap();
unbundle(dst.path(), folder.path(), Sha::from_object_id(commit)).unwrap();
let dst_repo = gix::open(dst.path()).unwrap();
let pack_dir = dst_repo.git_dir().join("objects/pack");
let idx_path = std::fs::read_dir(&pack_dir)
.unwrap()
.filter_map(std::result::Result::ok)
.map(|e| e.path())
.find(|p| p.extension().is_some_and(|ext| ext == "idx"))
.expect("idx file must exist");
let idx = gix_pack::index::File::at(&idx_path, gix_hash::Kind::Sha1).unwrap();
assert_eq!(
idx.num_objects(),
3,
"branch-tip bundle must contain commit + tree + blob (no tag chain)",
);
}
#[test]
fn bundle_create_round_trips_tag_pointing_to_blob() {
let (repo_dir, _commit) = fixture_commit();
let repo = gix::open(repo_dir.path()).unwrap();
let blob = repo.write_blob(b"data").unwrap().detach();
let tag_oid = write_annotated_tag(&repo, blob, gix::object::Kind::Blob, "blob-tag");
create_tag_ref(&repo, "refs/tags/blob-tag", tag_oid);
drop(repo);
let folder = TempDir::new().unwrap();
let tag_sha = Sha::from_object_id(tag_oid);
let bundle_path = create(
repo_dir.path(),
folder.path(),
tag_sha,
"refs/tags/blob-tag",
)
.expect("blob-tag bundle must build");
let (_dst_dir, dst_repo) = install_bundle_into_fresh_repo(&bundle_path, tag_sha);
let odb = dst_repo.objects.clone().into_inner();
assert!(odb.contains(&tag_oid), "tag object must land in pack");
assert!(odb.contains(&blob), "blob target must land in pack");
let pack_dir = dst_repo.git_dir().join("objects/pack");
let idx_path = std::fs::read_dir(&pack_dir)
.unwrap()
.filter_map(std::result::Result::ok)
.map(|e| e.path())
.find(|p| p.extension().is_some_and(|ext| ext == "idx"))
.expect("idx file must exist");
let idx = gix_pack::index::File::at(&idx_path, gix_hash::Kind::Sha1).unwrap();
assert_eq!(
idx.num_objects(),
2,
"blob-tag bundle must contain exactly the blob + the tag",
);
let tag_obj = dst_repo
.find_object(tag_oid)
.unwrap()
.peel_to_kind(gix::object::Kind::Tag)
.unwrap();
let target_id = tag_obj.into_tag().target_id().unwrap().detach();
assert_eq!(
target_id, blob,
"tag must point at the blob it was created for",
);
}
#[test]
fn bundle_create_round_trips_tag_pointing_to_tree() {
let (repo_dir, commit) = fixture_commit();
let repo = gix::open(repo_dir.path()).unwrap();
let tree_id = repo
.find_object(commit)
.unwrap()
.peel_to_kind(gix::object::Kind::Commit)
.unwrap()
.into_commit()
.tree_id()
.unwrap()
.detach();
let tag_oid = write_annotated_tag(&repo, tree_id, gix::object::Kind::Tree, "tree-tag");
create_tag_ref(&repo, "refs/tags/tree-tag", tag_oid);
let tree_blobs: Vec<ObjectId> = {
let tree_obj = repo.find_object(tree_id).unwrap().into_tree();
tree_obj
.iter()
.map(|e| e.unwrap().oid().to_owned())
.collect()
};
drop(repo);
let folder = TempDir::new().unwrap();
let tag_sha = Sha::from_object_id(tag_oid);
let bundle_path = create(
repo_dir.path(),
folder.path(),
tag_sha,
"refs/tags/tree-tag",
)
.expect("tree-tag bundle must build");
let (_dst_dir, dst_repo) = install_bundle_into_fresh_repo(&bundle_path, tag_sha);
let odb = dst_repo.objects.clone().into_inner();
assert!(odb.contains(&tag_oid), "tag must land in pack");
assert!(odb.contains(&tree_id), "leaf tree must land in pack");
for blob in &tree_blobs {
assert!(odb.contains(blob), "tree blob {blob} must land in pack");
}
}
}