use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use gix_hash::Kind;
use gunnar_sendpack::{
read_advertisement, send_pack, IoPair, PushCommand, SendPackOptions, Transport,
};
fn have_git() -> bool {
Command::new("git")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
fn git(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@example.invalid")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@example.invalid")
.env("GIT_AUTHOR_DATE", "1700000000 +0000")
.env("GIT_COMMITTER_DATE", "1700000000 +0000")
.output()
.unwrap_or_else(|e| panic!("git {args:?}: {e}"));
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_owned()
}
struct Fixture {
_dir: tempfile::TempDir,
work: PathBuf,
bare: PathBuf,
kind: Kind,
}
fn fixture(kind: Kind) -> Fixture {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path().to_path_buf();
let (work, bare) = (root.join("work"), root.join("bare.git"));
std::fs::create_dir_all(&work).unwrap();
let format = match kind {
Kind::Sha256 => "sha256",
_ => "sha1",
};
git(
&root,
&[
"init",
"--quiet",
"--initial-branch=main",
&format!("--object-format={format}"),
"work",
],
);
git(
&root,
&[
"init",
"--quiet",
"--bare",
"--initial-branch=main",
&format!("--object-format={format}"),
"bare.git",
],
);
for n in 0..2 {
std::fs::write(work.join(format!("f{n}")), format!("contents {n}\n")).unwrap();
git(&work, &["add", "."]);
git(&work, &["commit", "--quiet", "-m", &format!("c{n}")]);
}
git(&work, &["tag", "-a", "v1", "-m", "one"]);
Fixture {
_dir: dir,
work,
bare,
kind,
}
}
impl Fixture {
fn rev(&self, spec: &str) -> gix_hash::ObjectId {
gix_hash::ObjectId::from_hex(git(&self.work, &["rev-parse", spec]).as_bytes()).unwrap()
}
fn pack(&self, tips: &[gix_hash::ObjectId]) -> Vec<u8> {
let mut child = Command::new("git")
.args(["pack-objects", "--stdout", "--revs", "--quiet"])
.current_dir(&self.work)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("git pack-objects");
{
let mut stdin = child.stdin.take().unwrap();
for tip in tips {
writeln!(stdin, "{tip}").unwrap();
}
}
let out = child.wait_with_output().unwrap();
assert!(out.status.success(), "git pack-objects failed");
assert!(out.stdout.starts_with(b"PACK"), "that is not a packfile");
out.stdout
}
fn push(
&self,
commands: &[PushCommand],
pack: Option<Vec<u8>>,
opts: &SendPackOptions,
) -> gunnar_sendpack::PushReport {
let mut child = Command::new("git")
.arg("receive-pack")
.arg(&self.bare)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env_remove("GIT_PROTOCOL")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("git receive-pack");
let mut transport = IoPair::new(child.stdout.take().unwrap(), child.stdin.take().unwrap());
let adv = {
let (reader, _) = transport.io();
read_advertisement(reader).expect("advertisement")
};
assert_eq!(adv.hash_kind, self.kind, "the remote's object format");
let report = send_pack(
&mut transport,
&adv,
commands,
self.kind,
pack.map(|bytes| move |w: &mut dyn Write| Ok(w.write_all(&bytes)?)),
opts,
);
drop(transport);
let _ = child.wait();
report.expect("send-pack")
}
fn remote_rev(&self, name: &str) -> String {
git(&self.bare, &["rev-parse", name])
}
}
const KINDS: [Kind; 2] = [Kind::Sha1, Kind::Sha256];
#[test]
fn a_fresh_branch_lands_in_a_real_receive_pack_for_both_hash_kinds() {
if !have_git() {
eprintln!("SKIP: git is not on PATH");
return;
}
for kind in KINDS {
let f = fixture(kind);
let tip = f.rev("refs/heads/main");
let report = f.push(
&[PushCommand::create("refs/heads/main", tip)],
Some(f.pack(&[tip])),
&SendPackOptions::default(),
);
assert!(
report.is_ok(),
"{kind:?}: {:?} / {:?}",
report.failure_summary(),
report.remote_errors
);
assert_eq!(report.unpack, "ok");
assert_eq!(report.refs.len(), 1);
assert_eq!(report.refs[0].name, "refs/heads/main");
assert_eq!(f.remote_rev("refs/heads/main"), tip.to_string());
git(&f.bare, &["fsck", "--strict", "--no-progress"]);
}
}
#[test]
fn a_wrong_old_oid_is_rejected_and_the_ref_does_not_move() {
if !have_git() {
eprintln!("SKIP: git is not on PATH");
return;
}
let f = fixture(Kind::Sha1);
let tip = f.rev("refs/heads/main");
let parent = f.rev("refs/heads/main~1");
f.push(
&[PushCommand::create("refs/heads/main", parent)],
Some(f.pack(&[parent])),
&SendPackOptions::default(),
);
assert_eq!(f.remote_rev("refs/heads/main"), parent.to_string());
let report = f.push(
&[PushCommand::update("refs/heads/main", tip, tip)],
Some(f.pack(&[tip])),
&SendPackOptions::default(),
);
assert!(!report.is_ok(), "the remote must refuse a stale <old>");
assert!(!report.refs[0].accepted);
assert_eq!(
f.remote_rev("refs/heads/main"),
parent.to_string(),
"a refused command must not move the reference"
);
let report = f.push(
&[PushCommand::update("refs/heads/main", parent, tip)],
Some(f.pack(&[tip])),
&SendPackOptions::default(),
);
assert!(report.is_ok(), "{:?}", report.failure_summary());
assert_eq!(f.remote_rev("refs/heads/main"), tip.to_string());
}
#[test]
fn deleting_a_ref_sends_no_pack_and_removes_it() {
if !have_git() {
eprintln!("SKIP: git is not on PATH");
return;
}
let f = fixture(Kind::Sha1);
let tip = f.rev("refs/heads/main");
f.push(
&[PushCommand::create("refs/heads/side", tip)],
Some(f.pack(&[tip])),
&SendPackOptions::default(),
);
assert_eq!(f.remote_rev("refs/heads/side"), tip.to_string());
let report = f.push(
&[PushCommand::delete("refs/heads/side", tip)],
None,
&SendPackOptions::default(),
);
assert!(report.is_ok(), "{:?}", report.failure_summary());
assert!(
git(&f.bare, &["for-each-ref", "--format=%(refname)"])
.lines()
.all(|l| l != "refs/heads/side"),
"the reference is still there"
);
}
#[test]
fn sideband_on_and_off_both_complete() {
if !have_git() {
eprintln!("SKIP: git is not on PATH");
return;
}
for side_band in [true, false] {
let f = fixture(Kind::Sha1);
let tip = f.rev("refs/heads/main");
let report = f.push(
&[PushCommand::create("refs/heads/main", tip)],
Some(f.pack(&[tip])),
&SendPackOptions {
side_band,
..Default::default()
},
);
assert!(
report.is_ok(),
"side_band={side_band}: {:?}",
report.failure_summary()
);
assert_eq!(f.remote_rev("refs/heads/main"), tip.to_string());
}
}
#[test]
fn report_status_v1_round_trips_against_real_git() {
if !have_git() {
eprintln!("SKIP: git is not on PATH");
return;
}
let f = fixture(Kind::Sha1);
let tip = f.rev("refs/heads/main");
let report = f.push(
&[PushCommand::create("refs/heads/main", tip)],
Some(f.pack(&[tip])),
&SendPackOptions {
report_status_v2: false,
..Default::default()
},
);
assert!(report.is_ok(), "{:?}", report.failure_summary());
assert!(
report.refs.iter().all(|r| r.options.is_empty()),
"v1 carries no `option` lines"
);
}
#[test]
fn a_multi_ref_push_applies_every_command_and_carries_the_tag_object() {
if !have_git() {
eprintln!("SKIP: git is not on PATH");
return;
}
let f = fixture(Kind::Sha1);
let tip = f.rev("refs/heads/main");
let parent = f.rev("refs/heads/main~1");
let tag = f.rev("refs/tags/v1");
let report = f.push(
&[
PushCommand::create("refs/heads/main", tip),
PushCommand::create("refs/heads/old", parent),
PushCommand::create("refs/tags/v1", tag),
],
Some(f.pack(&[tip, tag])),
&SendPackOptions::default(),
);
assert!(report.is_ok(), "{:?}", report.failure_summary());
assert_eq!(report.refs.len(), 3);
assert_eq!(f.remote_rev("refs/heads/main"), tip.to_string());
assert_eq!(f.remote_rev("refs/heads/old"), parent.to_string());
assert_eq!(f.remote_rev("refs/tags/v1"), tag.to_string());
assert_eq!(
git(&f.bare, &["cat-file", "-t", &tag.to_string()]),
"tag",
"the annotated tag object itself has to arrive, not only its target"
);
git(&f.bare, &["fsck", "--strict", "--no-progress"]);
}
#[test]
fn an_atomic_push_with_one_bad_command_moves_nothing() {
if !have_git() {
eprintln!("SKIP: git is not on PATH");
return;
}
let f = fixture(Kind::Sha1);
let tip = f.rev("refs/heads/main");
let parent = f.rev("refs/heads/main~1");
let opts = SendPackOptions {
atomic: true,
..Default::default()
};
let report = f.push(
&[
PushCommand::create("refs/heads/good", tip),
PushCommand::update("refs/heads/bad", parent, tip),
],
Some(f.pack(&[tip])),
&opts,
);
assert!(!report.is_ok(), "one command was impossible");
let refs = git(&f.bare, &["for-each-ref", "--format=%(refname)"]);
assert!(
!refs.lines().any(|l| l == "refs/heads/good"),
"atomic means the GOOD command was rolled back too; refs were: {refs:?}"
);
}