crates/gunnar-sendpack/tests/against_real_git.rs
//! The only judge that cannot be wrong in the same way we are.
//!
//! Every test here drives the **real `git receive-pack` binary** over a pair of
//! pipes and then reads the result back with stock `git` — `rev-parse`,
//! `for-each-ref`, `fsck --strict`. A client and a server that agree only with
//! each other can both be wrong with every test passing, which is why the
//! round-trips in `grammar.rs` are not enough on their own.
//!
//! The packfile is made by `git pack-objects`, deliberately. This crate is the
//! *protocol* and owns no pack writer: [`gunnar_sendpack::send_pack`] takes the
//! pack as a closure precisely so the caller brings its own. Using git's packer
//! here keeps the test about the conversation.
//!
//! Skipped, loudly, when `git` is not on `PATH`.
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,
};
// ── fixtures ────────────────────────────────────────────────────────────────
fn have_git() -> bool {
Command::new("git")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
/// Run git in `dir`, in a hermetic environment, and return stdout.
///
/// The environment is pinned rather than inherited: a developer's
/// `~/.gitconfig` carrying `push.default`, a signing key or a template
/// directory would change what these tests measure, and the failure would look
/// like a protocol bug.
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,
}
/// A work repository with two commits on `main` and one tag, plus an **empty**
/// bare repository to push into.
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()
}
/// A packfile containing everything reachable from `tips`, made by git.
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
}
/// Run one push through this crate against a real `git receive-pack`.
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")
// `receive-pack` speaks v0 and only v0; leaving a v2 hint in the
// environment would have it answer a protocol this crate does not
// implement, and the failure would look like a framing bug.
.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,
);
// Close the writer so `receive-pack` sees EOF and exits, then reap it.
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];
// ── the tests ───────────────────────────────────────────────────────────────
#[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");
// Applied output, read back by stock git rather than by us.
assert_eq!(f.remote_rev("refs/heads/main"), tip.to_string());
git(&f.bare, &["fsck", "--strict", "--no-progress"]);
}
}
/// The compare-and-swap. A wrong `<old>` must be **refused by the remote**, and
/// the ref must not move.
#[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());
// Claim the remote is at `tip` when it is at `parent`.
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"
);
// …and the same push with the right `<old>` is taken.
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());
}
/// An all-deletes push carries **no pack at all**. Sending one is what a
/// framing mistake looks like from the other end.
#[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"
);
}
/// Sideband on and off are two different read paths, and the one that is off by
/// default in a test is the one that rots.
#[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());
}
}
/// `report-status` v1 is what an old server answers, and the parser for it is
/// otherwise never reached because every modern git offers v2.
#[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"
);
}
/// Several refs in one connection, including an annotated tag, whose tag object
/// is a fourth object kind the pack has to carry.
#[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"]);
}
/// `atomic` is a guarantee, and the point of asking for it is that a rejected
/// command takes the whole push down with it.
#[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),
// A stale `<old>`: this one cannot be applied.
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:?}"
);
}