crates/storage-git-gix/parked-gix-2/gunnar-wire-gix/git_harness/mod.rs
//! **The `ext::` harness that makes stock `git` talk to `gunnar-wire`.**
//!
//! Extracted from `real_git_interop.rs` so a second interop binary can drive
//! the same server without a second copy of it (LAW 5). Nothing here is a test;
//! it is the pipe, the fixtures, and the `git` invocation builder.
//!
//! # How git is made to talk to a library
//!
//! `git clone "ext::<command> %S <path>"` runs `<command>` and speaks the smart
//! protocol over its stdin and stdout. The command is **the test binary that
//! linked this module**, re-executed with `GUNNAR_WIRE_SERVE` set, which is why
//! every such target is `harness = false`: it needs a `main` it can dispatch on.
//! [`helper_exe`] is `current_exe()`, so each binary re-execs *itself* and the
//! two targets never cross.
//!
//! So the bytes on the wire are produced by `gunnar-wire` and consumed by
//! `/usr/bin/git`, over a real pipe, with git choosing every request.
//!
//! # The v0 fallback is exercised for free, and not by choice
//!
//! Measured 2026-08-03: over `ext::`, git 2.53.0 passes `GIT_EXT_SERVICE` and
//! `GIT_DIR` to the helper but **not `GIT_PROTOCOL`**, so a clone falls back to
//! protocol v0 unless the variable is supplied. [`Scene::client`] sets it for
//! the v2 cases exactly as an SSH `SendEnv` would, and every case asserts which
//! protocol the server actually spoke.
#![allow(dead_code)]
use std::collections::BTreeMap;
use std::io::{BufRead, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use gix_hash::Kind;
use gunnar_pack::{index_thin_pack, IndexOptions, IndexedPack};
use gunnar_store::fs::{FsObjects, FsStore};
use gunnar_store::{GixObjects, ObjectStore};
use gunnar_wire::receive_pack::AllowAll;
use gunnar_wire::{ProtocolVersion, ReceivePack, UploadPack};
/// Run a table of named cases, reporting like libtest and exiting non-zero on
/// any failure.
///
/// `expected` is asserted against the table's length rather than reported.
/// **The empty-run trap:** "0 passed, 0 failed, out of 0" exits 0 and is shaped
/// exactly like a clean sweep, so a table that lost its entries would report
/// success having run nothing.
pub fn run_cases(suite: &str, cases: &[(&str, fn())], expected: usize) {
assert_eq!(
cases.len(),
expected,
"the {suite} case table changed size — update its expected count deliberately, \
so a table that lost its entries cannot report a clean sweep"
);
// **A missing `git` is a failure, not a skip** (`S-026`). These targets are
// `harness = false`, so the exit code *is* the result: an `eprintln! +
// exit(0)` would make the whole driver report success having run nothing,
// and `cargo test` prints no test names to give it away.
assert!(
git_available(),
"this test requires `git` on PATH — §15 interop cannot be proven without it"
);
let mut failed = Vec::new();
for (name, case) in cases {
print!("test {name} ... ");
std::io::stdout().flush().ok();
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(case)) {
Ok(()) => println!("ok"),
Err(_) => {
println!("FAILED");
failed.push(*name);
}
}
}
println!(
"\n{suite}: {} passed, {} failed, out of {}",
cases.len() - failed.len(),
failed.len(),
cases.len()
);
if !failed.is_empty() {
eprintln!("failing cases: {failed:?}");
std::process::exit(1);
}
}
/// Where [`Scene::dated`]'s timestamps start. A real epoch rather than 1970:
/// git accepts both, and a fixture dated before the tool existed invites a
/// future "surely that is the bug" that is not the bug.
pub const DATED_BASE: i64 = 1_700_000_000;
/// The `--shallow-since` cut used against [`Scene::dated`]: between `B` and
/// `C`, so it is not the same answer as any `--depth`.
pub const SHALLOW_SINCE: &str = "--shallow-since=@1700002500";
/// Commits of churn in [`Scene::churned`], and lines in the file each one
/// rewrites a couple of. Capped on purpose: this is ~52 KiB of raw revisions,
/// and a fixture whose size is a free parameter crossed with a large input is
/// how a test in this tree took a 499 GB box down.
pub const CHURN_COMMITS: usize = 32;
pub const CHURN_LINES: usize = 240;
// ── the server end of the pipe ──────────────────────────────────────────────
/// Re-executed by git's `ext::` transport. `argv[1]` is the service git wants
/// (`%S`), `argv[2]` the repository path this test told it to pass.
pub fn serve() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = std::env::args().collect();
let service = args.get(1).cloned().unwrap_or_default();
let git_dir = PathBuf::from(args.get(2).cloned().unwrap_or_default());
let breakage = std::env::var("GUNNAR_WIRE_BREAK").unwrap_or_default();
let hash_kind = detect_hash_kind(&git_dir);
let store = FsStore::open_or_init(&git_dir, hash_kind)?;
let objects = store.objects();
let refs = store.refs();
let stdin = std::io::stdin();
let mut input = stdin.lock();
let stdout = std::io::stdout();
let mut raw = BufWriter::new(stdout.lock());
match service.as_str() {
"git-upload-pack" => {
let version =
ProtocolVersion::from_git_protocol(std::env::var("GIT_PROTOCOL").ok().as_deref());
// A lying store is the documented spec §6 bug reproduced through
// the real code path: `hash_kind()` is the *only* source of
// `object-format`, so corrupting it there corrupts exactly what a
// careless implementation would get wrong.
let lying;
let served: &dyn ObjectStore = if breakage == "hash-lie" {
lying = WrongHashKind {
inner: objects.clone(),
claim: Kind::Sha1,
};
&lying
} else {
&objects
};
let upload = UploadPack::new(served, &refs);
if breakage == "no-sideband" {
// Strip the band byte off every packfile line on its way past.
// This has to *stream*: buffering the whole session and
// rewriting it at the end deadlocks, because git is waiting
// for the capability advertisement that is still sitting in
// the buffer. (Learned the hard way, 2026-08-03.)
let mut sabotaged = StripSideband::new(&mut raw);
let summary = upload.serve(version, &mut input, &mut sabotaged)?;
sabotaged.flush()?;
log(&summary_line(&service, version, &summary));
} else {
let summary = upload.serve(version, &mut input, &mut raw)?;
log(&summary_line(&service, version, &summary));
}
}
"git-receive-pack" => {
let pack_dir = git_dir.join("objects").join("pack");
std::fs::create_dir_all(&pack_dir)?;
let ingest_objects = objects.clone();
let ingest = move |pack: &mut dyn BufRead| -> gunnar_wire::Result<IndexedPack> {
let indexed = index_thin_pack(
pack,
Some(&pack_dir),
GixObjects(ingest_objects.clone()),
&IndexOptions::new(hash_kind),
None,
)?;
// gunnar-pack hands the `.keep` to the caller to remove once
// the references point at the pack. Nothing collects objects
// in a test, so it goes now.
if let Some(keep) = &indexed.keep_path {
let _ = std::fs::remove_file(keep);
}
Ok(indexed)
};
let receive = ReceivePack::new(&objects, &refs);
// The gate seam is what `gunnar-policy` will occupy. Exercising a
// *refusing* gate here is the only way to prove that gunnar's
// `ng <ref> <reason>` report is understood by a real client:
// stock git refuses a stale-old-value push on its own side, so a
// well-behaved client never produces one.
let allow = AllowAll;
let refuse_all = RefuseThese(None);
let refuse_side = RefuseThese(Some("refs/heads/side"));
let gate: &dyn gunnar_wire::PushGate = match breakage.as_str() {
"reject-push" => &refuse_all,
"reject-side" => &refuse_side,
_ => &allow,
};
let outcome = receive.serve(&mut input, &mut raw, &ingest, gate)?;
let statuses = outcome
.as_ref()
.map(|outcome| {
outcome
.statuses
.iter()
.map(|status| match status {
gunnar_wire::RefStatus::Ok => "ok".to_owned(),
gunnar_wire::RefStatus::Rejected(reason) => format!("ng:{reason}"),
})
.collect::<Vec<_>>()
.join(",")
})
.unwrap_or_else(|| "none".to_owned());
log(&format!(
"service=git-receive-pack version=0 statuses={statuses}"
));
}
other => return Err(format!("gunnar serves no {other:?}").into()),
}
raw.flush()?;
Ok(())
}
pub fn summary_line(
service: &str,
version: ProtocolVersion,
summary: &gunnar_wire::UploadSummary,
) -> String {
// `copied=`/`recompressed=` is `P-001`'s applied output carried out to the
// client side of the test. "the clone succeeded" is true of a server that
// shipped the client's own deltas back and of one that inflated and
// re-deflated every byte to send 18× as many; only these two numbers tell
// them apart, and only from inside the server.
format!(
"service={service} version={} rounds={} objects={} copied={} recompressed={}",
version.number(),
summary.fetch_rounds,
summary.objects_sent,
summary.objects_copied_from_pack,
summary.objects_recompressed,
)
}
pub fn log(line: &str) {
if let Ok(path) = std::env::var("GUNNAR_WIRE_LOG") {
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
let _ = writeln!(file, "{line}");
}
}
}
/// The repository's hash algorithm, read from the repository rather than
/// assumed, the same discipline the server itself is held to.
pub fn detect_hash_kind(git_dir: &Path) -> Kind {
let config = std::fs::read_to_string(git_dir.join("config")).unwrap_or_default();
if config.to_ascii_lowercase().contains("sha256") {
Kind::Sha256
} else {
Kind::Sha1
}
}
/// A gate that refuses one named reference, or every reference when it names
/// none.
struct RefuseThese(Option<&'static str>);
impl gunnar_wire::PushGate for RefuseThese {
fn check(
&self,
command: &gunnar_wire::RefCommand,
_facts: &gunnar_wire::PushFacts<'_>,
) -> Result<(), String> {
match self.0 {
Some(only) if command.name.to_str_lossy() != only => Ok(()),
_ => Err("the gunnar test gate says no".to_owned()),
}
}
}
/// An `ObjectStore` that misreports its hash algorithm. Nothing but the lie.
struct WrongHashKind {
inner: FsObjects,
claim: Kind,
}
impl ObjectStore for WrongHashKind {
fn hash_kind(&self) -> Kind {
self.claim
}
fn contains(&self, id: &gix_hash::oid) -> bool {
self.inner.contains(id)
}
/// Forwarded. The trait default would route this back through `contains`
/// — a correct answer down the *slow* path, so nothing observable would
/// catch the omission. See `ObjectStore::contains_without_rescan`.
fn contains_without_rescan(&self, id: &gix_hash::oid) -> bool {
self.inner.contains_without_rescan(id)
}
fn read<'a>(
&self,
id: &gix_hash::oid,
buf: &'a mut Vec<u8>,
) -> gunnar_store::Result<Option<gix_object::Data<'a>>> {
self.inner.read(id, buf)
}
fn header(&self, id: &gix_hash::oid) -> gunnar_store::Result<Option<gix_object::Header>> {
self.inner.header(id)
}
fn write(
&self,
kind: gix_object::Kind,
bytes: &[u8],
) -> gunnar_store::Result<gix_hash::ObjectId> {
self.inner.write(kind, bytes)
}
}
/// A `Write` that re-frames the stream passing through it, dropping the
/// sideband band byte from every line after the `packfile` section header.
///
/// It forwards whole pkt-lines as soon as they are complete, so the peer is
/// never left waiting; the sabotage changes the bytes, not the timing.
struct StripSideband<'a, W: Write> {
out: &'a mut W,
pending: Vec<u8>,
in_pack: bool,
}
impl<'a, W: Write> StripSideband<'a, W> {
pub fn new(out: &'a mut W) -> Self {
StripSideband {
out,
pending: Vec::new(),
in_pack: false,
}
}
fn drain(&mut self) -> std::io::Result<()> {
use gunnar_pktline::{decode, Decoded, Packet};
let mut consumed = 0;
loop {
match decode(&self.pending[consumed..]) {
Ok(Decoded::Packet(packet, used)) => {
match &packet {
Packet::Data(payload) if self.in_pack && !payload.is_empty() => {
gunnar_pktline::write_data(&mut *self.out, &payload[1..])
.map_err(std::io::Error::other)?;
}
Packet::Data(payload) => {
if payload.strip_suffix(b"\n").unwrap_or(payload) == b"packfile" {
self.in_pack = true;
}
gunnar_pktline::write_data(&mut *self.out, payload)
.map_err(std::io::Error::other)?;
}
other => gunnar_pktline::write_packet(&mut *self.out, other)
.map_err(std::io::Error::other)?,
}
consumed += used;
}
Ok(Decoded::Incomplete) => break,
Err(err) => return Err(std::io::Error::other(err.to_string())),
}
}
self.pending.drain(..consumed);
Ok(())
}
}
impl<W: Write> Write for StripSideband<'_, W> {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
self.pending.extend_from_slice(data);
self.drain()?;
Ok(data.len())
}
fn flush(&mut self) -> std::io::Result<()> {
self.drain()?;
self.out.flush()
}
}
// ── the client end: driving real git ────────────────────────────────────────
pub fn git_available() -> bool {
Command::new("git")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn helper_exe() -> PathBuf {
std::env::current_exe().expect("this test binary's own path")
}
/// A `git` invocation with the developer's own configuration removed, so
/// nothing here depends on `~/.gitconfig`.
pub struct Git {
args: Vec<String>,
env: BTreeMap<String, String>,
cwd: Option<PathBuf>,
}
impl Git {
pub fn new() -> Self {
let mut env = BTreeMap::new();
env.insert("GIT_CONFIG_GLOBAL".into(), "/dev/null".into());
env.insert("GIT_CONFIG_SYSTEM".into(), "/dev/null".into());
env.insert("GIT_TERMINAL_PROMPT".into(), "0".into());
env.insert("GIT_AUTHOR_NAME".into(), "Gunnar Test".into());
env.insert("GIT_AUTHOR_EMAIL".into(), "gunnar@example.invalid".into());
env.insert("GIT_COMMITTER_NAME".into(), "Gunnar Test".into());
env.insert(
"GIT_COMMITTER_EMAIL".into(),
"gunnar@example.invalid".into(),
);
Git {
args: vec!["-c".into(), "protocol.ext.allow=always".into()],
env,
cwd: None,
}
}
pub fn arg(mut self, value: impl Into<String>) -> Self {
self.args.push(value.into());
self
}
pub fn args<I: IntoIterator<Item = S>, S: Into<String>>(mut self, values: I) -> Self {
self.args.extend(values.into_iter().map(Into::into));
self
}
pub fn cwd(mut self, dir: impl AsRef<Path>) -> Self {
self.cwd = Some(dir.as_ref().to_path_buf());
self
}
pub fn env(mut self, key: &str, value: impl Into<String>) -> Self {
self.env.insert(key.into(), value.into());
self
}
pub fn run(self) -> (bool, String, String) {
// Every invocation is capped. A protocol deadlock is a bug, and a bug
// that presents as "the test suite never finishes" is a bug nobody
// reads the message of.
let mut command = Command::new("timeout");
command.arg("--kill-after=5").arg("120").arg("git");
command.args(&self.args);
// Clearing the whole environment would lose PATH; removing the one
// variable that decides the protocol version is what matters, and it
// is put back explicitly by the v2 cases.
command.env_remove("GIT_PROTOCOL");
for (key, value) in &self.env {
command.env(key, value);
}
if let Some(dir) = &self.cwd {
command.current_dir(dir);
}
let output = command.output().expect("could not run git");
(
output.status.success(),
String::from_utf8_lossy(&output.stdout)
.trim_end()
.to_owned(),
String::from_utf8_lossy(&output.stderr)
.trim_end()
.to_owned(),
)
}
pub fn ok(self) -> String {
let args = self.args.clone();
let (ok, stdout, stderr) = self.run();
assert!(ok, "git {args:?} failed:\n{stderr}");
stdout
}
}
pub fn git_in(dir: &Path) -> Git {
Git::new().cwd(dir)
}
/// A scratch area: a fixture bare repository plus the log the server writes.
pub struct Scene {
_dir: tempfile::TempDir,
pub root: PathBuf,
pub origin: PathBuf,
pub log: PathBuf,
}
impl Scene {
/// A bare repository with a merge, a divergent branch, and an annotated
/// tag: the shape spec §6 insists negotiation be tested against, rather
/// than a three-commit line where the first `have` always hits.
pub fn new(hash_kind: Kind) -> Self {
let dir = tempfile::tempdir().expect("a temporary directory");
let root = dir.path().to_path_buf();
let work = root.join("seed");
let mut init = Git::new().cwd(&root).args(["init", "-q", "-b", "main"]);
if hash_kind == Kind::Sha256 {
init = init.arg("--object-format=sha256");
}
init.arg(work.to_str().unwrap()).ok();
commit(&work, "a.txt", "one", "first");
commit(&work, "b.txt", "two", "second");
git_in(&work)
.args(["checkout", "-q", "-b", "side", "HEAD~1"])
.ok();
commit(&work, "c.txt", "three", "on the side");
git_in(&work).args(["checkout", "-q", "main"]).ok();
git_in(&work)
.args(["merge", "-q", "--no-ff", "side", "-m", "merge the side"])
.ok();
git_in(&work)
.args(["tag", "-a", "v1", "-m", "release one"])
.ok();
commit(&work, "d.txt", "four", "after the merge");
let origin = root.join("origin.git");
Git::new()
.cwd(&root)
.args(["clone", "-q", "--bare"])
.arg(work.to_str().unwrap())
.arg(origin.to_str().unwrap())
.ok();
let log = root.join("server.log");
Scene {
_dir: dir,
root,
origin,
log,
}
}
/// A bare repository whose **dates and shape make the three deepening
/// variants disagree** (`S-031`).
///
/// ```text
/// A(t=+1000) ─ B(t=+2000) ────── M(t=+4000) ─ D(t=+5000) refs/heads/main
/// └─ C(t=+3000) ───────┘ refs/heads/side
/// ```
///
/// The merge is the point. `graph.rs` names "a merge cut off from *one* of
/// its parents" as a bug worth naming, and `--shallow-exclude=side` is
/// exactly that request: it must drop `C` (and `A`, which only `C` reaches
/// from below) while keeping `B`. A boundary cannot express that; a parent
/// restriction can, and this fixture is what tells the two apart.
///
/// The dates are what make `--shallow-since` a different question from
/// `--depth`. **Measured against stock git 2.53.0 on 2026-08-03**, against
/// this shape:
///
/// | request | commits | `.git/shallow` |
/// |---|---|---|
/// | `--depth=2` | `D`, `M` | `M` |
/// | `--shallow-since=+2500` | `D`, `M` | `C`, `M` |
/// | `--shallow-exclude=side` | `D`, `M` | `B`, `M` |
///
/// Three requests, three different answers — but the answer is the
/// **boundary**, not the commit count, and this table used to say
/// otherwise. git's shallow file can only say "this commit has no
/// parents", so a merge that lost one parent is grafted whole; `C` (or
/// `B`) would arrive unreachable from anything, and git does not send it.
/// What git does do is name it as a cut point, and that is what tells the
/// three requests apart. A server that parsed each argument and ignored it
/// would produce one boundary for all three.
pub fn dated(hash_kind: Kind) -> Self {
let dir = tempfile::tempdir().expect("a temporary directory");
let root = dir.path().to_path_buf();
let work = root.join("seed");
let mut init = Git::new().cwd(&root).args(["init", "-q", "-b", "main"]);
if hash_kind == Kind::Sha256 {
init = init.arg("--object-format=sha256");
}
init.arg(work.to_str().unwrap()).ok();
commit_at(&work, "a.txt", "one", "A", Some(DATED_BASE + 1000));
commit_at(&work, "b.txt", "two", "B", Some(DATED_BASE + 2000));
git_in(&work)
.args(["checkout", "-q", "-b", "side", "HEAD~1"])
.ok();
commit_at(&work, "c.txt", "three", "C", Some(DATED_BASE + 3000));
git_in(&work).args(["checkout", "-q", "main"]).ok();
git_in(&work)
.args(["merge", "-q", "--no-ff", "side", "-m", "M"])
.env("GIT_AUTHOR_DATE", format!("@{} +0000", DATED_BASE + 4000))
.env(
"GIT_COMMITTER_DATE",
format!("@{} +0000", DATED_BASE + 4000),
)
.ok();
commit_at(&work, "d.txt", "four", "D", Some(DATED_BASE + 5000));
let origin = root.join("origin.git");
Git::new()
.cwd(&root)
.args(["clone", "-q", "--bare"])
.arg(work.to_str().unwrap())
.arg(origin.to_str().unwrap())
.ok();
let log = root.join("server.log");
Scene {
_dir: dir,
root,
origin,
log,
}
}
/// A bare repository of **churn**, packed and delta-compressed by stock
/// git. This is the fixture `P-001` needs and [`Scene::new`] cannot be.
///
/// The six-object merge fixture is the right shape for negotiation and the
/// wrong shape for delta reuse: there is nothing near-duplicate in it, so a
/// working pack-copy and a broken one produce the same bytes. That is the
/// identity-value trap `P-001` and `S-003` both name, and it is why this
/// rewrites a couple of lines of one file per commit instead.
///
/// gitoxide has no delta encoder at all (`P-009`), so these deltas can only
/// come from `git repack`. That is not a shortcut, it is the situation:
/// what gunnar reuses is what the pushing client computed.
pub fn churned(hash_kind: Kind) -> Self {
let dir = tempfile::tempdir().expect("a temporary directory");
let root = dir.path().to_path_buf();
let work = root.join("seed");
let mut init = Git::new().cwd(&root).args(["init", "-q", "-b", "main"]);
if hash_kind == Kind::Sha256 {
init = init.arg("--object-format=sha256");
}
init.arg(work.to_str().unwrap()).ok();
let mut lines: Vec<String> = (0..CHURN_LINES)
.map(|i| format!("line {i:04} of the churned file, original text"))
.collect();
for revision in 0..CHURN_COMMITS {
for step in 0..CHURN_LINES / 50 {
let index = (revision * 7 + step * 53) % CHURN_LINES;
lines[index] = format!("line {index:04} rewritten at revision {revision}");
}
std::fs::write(work.join("churn.txt"), lines.join("\n")).unwrap();
git_in(&work).args(["add", "churn.txt"]).ok();
git_in(&work)
.args(["commit", "-q", "-m", &format!("revision {revision}")])
.ok();
}
let origin = root.join("origin.git");
Git::new()
.cwd(&root)
.args(["clone", "-q", "--bare"])
.arg(work.to_str().unwrap())
.arg(origin.to_str().unwrap())
.ok();
// `-a -d -f`: one pack, no loose objects, deltas recomputed from
// scratch. This line is what puts the delta chains into the fixture.
Git::new().cwd(&origin).args(["repack", "-adf", "-q"]).ok();
let log = root.join("server.log");
Scene {
_dir: dir,
root,
origin,
log,
}
}
pub fn url(&self) -> String {
format!(
"ext::{} %S {}",
helper_exe().display(),
self.origin.display()
)
}
/// The same repository addressed so that **stock `git-upload-pack`**
/// serves it.
///
/// `file://` rather than a bare path on purpose: a plain local path takes
/// git's local-clone shortcut, which copies objects and never speaks the
/// protocol at all, so the comparison would be against nothing.
pub fn local_url(&self) -> String {
format!("file://{}", self.origin.display())
}
/// A git invocation wired to talk to gunnar over `ext::`.
pub fn client(&self, protocol: ProtocolVersion) -> Git {
let mut git = Git::new()
.cwd(&self.root)
.env("GUNNAR_WIRE_SERVE", "1")
.env("GUNNAR_WIRE_LOG", self.log.to_str().unwrap());
if protocol == ProtocolVersion::V2 {
git = git.env("GIT_PROTOCOL", "version=2");
}
git
}
pub fn server_log(&self) -> Vec<String> {
std::fs::read_to_string(&self.log)
.unwrap_or_default()
.lines()
.map(str::to_owned)
.collect()
}
pub fn clear_log(&self) {
let _ = std::fs::remove_file(&self.log);
}
pub fn origin_git(&self) -> Git {
Git::new()
.cwd(&self.root)
.args(["--git-dir", self.origin.to_str().unwrap()])
}
pub fn origin_ref(&self, name: &str) -> String {
self.origin_git().args(["rev-parse", name]).ok()
}
}
pub fn commit(work: &Path, file: &str, content: &str, message: &str) {
commit_at(work, file, content, message, None);
}
/// [`commit`], with the author and committer dates pinned.
///
/// `--shallow-since` cuts on the **committer** date, so a fixture whose commits
/// all carry "now" cannot tell a working date cut from one that ignores its
/// argument. Both dates are set because git derives neither from the other.
pub fn commit_at(work: &Path, file: &str, content: &str, message: &str, at: Option<i64>) {
std::fs::write(work.join(file), format!("{content}\n")).unwrap();
git_in(work).args(["add", file]).ok();
let mut git = git_in(work).args(["commit", "-q", "-m", message]);
if let Some(at) = at {
// `@<epoch>` is git's raw-timestamp form; a bare number is rejected.
let stamp = format!("@{at} +0000");
git = git
.env("GIT_AUTHOR_DATE", stamp.clone())
.env("GIT_COMMITTER_DATE", stamp);
}
git.ok();
}
/// The largest `objects=` the server logged. That is how many objects it
/// actually packed, which is the only way to tell a negotiated fetch from a
/// server that ships the repository every time.
pub fn objects_sent(scene: &Scene) -> u32 {
scene
.server_log()
.iter()
.filter_map(|line| line.split("objects=").nth(1))
.filter_map(|value| value.split_whitespace().next())
.filter_map(|value| value.parse().ok())
.max()
.unwrap_or(0)
}
/// The largest value of a `<key>=<number>` field across the server's log.
///
/// `objects_sent` is the same shape and predates this; both take the maximum
/// because a v2 session logs one line per `serve()` and only the round that
/// carried the pack has a non-zero count.
pub fn log_field(log: &[String], key: &str) -> usize {
log.iter()
.filter_map(|line| line.split(key).nth(1))
.filter_map(|value| value.split_whitespace().next())
.filter_map(|value| value.parse().ok())
.max()
.unwrap_or(0)
}
pub fn fetch_rounds(scene: &Scene) -> usize {
scene
.server_log()
.iter()
.filter_map(|line| line.split("rounds=").nth(1))
.filter_map(|value| value.split_whitespace().next())
.filter_map(|value| value.parse().ok())
.max()
.unwrap_or(0)
}
/// Clone the fixture through gunnar and assert the result is a real, complete
/// repository; `fsck` walks every object it received.
pub fn clone_and_verify(scene: &Scene, protocol: ProtocolVersion, into: &str) -> PathBuf {
let target = scene.root.join(into);
scene.clear_log();
scene
.client(protocol)
.args(["clone", "-q"])
.arg(scene.url())
.arg(target.to_str().unwrap())
.ok();
let log = scene.server_log();
assert!(
log.iter()
.any(|line| line.contains(&format!("version={}", protocol.number()))),
"the server did not speak protocol {}; it logged {log:?}",
protocol.number()
);
git_in(&target).args(["fsck", "--no-progress"]).ok();
assert_eq!(
git_in(&target).args(["rev-parse", "HEAD"]).ok(),
scene.origin_ref("refs/heads/main"),
"the clone's HEAD is not the origin's main"
);
assert_eq!(
git_in(&target).args(["rev-parse", "refs/tags/v1"]).ok(),
scene.origin_ref("refs/tags/v1"),
"the annotated tag did not come across"
);
// A merge means two parents. A server that served only the first-parent
// line would still produce a clone that looks right at the tip.
let cloned: usize = git_in(&target)
.args(["rev-list", "--count", "HEAD"])
.ok()
.parse()
.unwrap();
let original: usize = scene
.origin_git()
.args(["rev-list", "--count", "refs/heads/main"])
.ok()
.parse()
.unwrap();
assert_eq!(
cloned, original,
"the clone has {cloned} commits where the origin has {original}"
);
target
}
/// The `ext::` helper entry point: if git re-executed this binary as the
/// server end of a pipe, serve and exit; otherwise return and let the caller
/// run its cases.
///
/// Every `harness = false` interop target calls this as the first line of its
/// `main`, so the two halves of the pipe are the same executable.
pub fn serve_if_asked() {
if std::env::var_os("GUNNAR_WIRE_SERVE").is_none() {
return;
}
match serve() {
Ok(()) => std::process::exit(0),
Err(err) => {
eprintln!("gunnar-wire helper failed: {err}");
std::process::exit(1);
}
}
}