crates/gunnar-client/tests/common/mod.rs
//! Test fixtures, and the one thing that makes this suite worth anything:
//! **a transport to the real `git` binary**.
//!
//! the verification rule: *gunnar never runs git; its tests do.* A test where
//! gunnar's client agrees with gunnar's server proves nothing; both halves can
//! be wrong the same way. `git receive-pack` and `git upload-pack` are ordinary
//! programs that speak the smart protocol on stdin/stdout, which is precisely
//! what an SSH channel gives them, so driving them over a pair of pipes is not a
//! simulation of the real thing. It **is** the real thing, minus the SSH.
//!
//! Everything here is hermetic: temporary directories, `GIT_CONFIG_GLOBAL` and
//! `GIT_CONFIG_SYSTEM` pointed at nothing, fixed author/committer identity and
//! timestamps. No network.
#![allow(dead_code)] // each test file uses a different subset
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use gunnar_client::Transport;
/// A fixed identity and clock, so a repository built by these helpers hashes to
/// the same oids on every machine and in every run.
const ENV: &[(&str, &str)] = &[
// **English, whatever the desktop speaks.** git is translated, and several
// assertions in these suites read its own words ("protocol error", "bad
// line length character", refspec complaints). On a Swedish machine those
// arrive as `fatal: protokollfel: …` and the tests fail on correct
// behaviour — a verdict about the operator's locale rather than about the
// server. `LC_ALL` outranks every other locale variable, so one entry
// settles it.
("LC_ALL", "C"),
("GIT_CONFIG_GLOBAL", "/dev/null"),
("GIT_CONFIG_SYSTEM", "/dev/null"),
("GIT_AUTHOR_NAME", "Fixture"),
("GIT_AUTHOR_EMAIL", "fixture@example.invalid"),
("GIT_AUTHOR_DATE", "1700000000 +0000"),
("GIT_COMMITTER_NAME", "Fixture"),
("GIT_COMMITTER_EMAIL", "fixture@example.invalid"),
("GIT_COMMITTER_DATE", "1700000000 +0000"),
];
/// Run stock `git` in `cwd` and return its stdout, asserting it succeeded.
///
/// **Asserts on output and status, never on status alone** (measure, never
/// assert): a git invocation that fails prints to stderr and exits non-zero,
/// and an empty stdout from a failed command is shaped exactly like an empty
/// stdout from a clean one.
pub fn git(cwd: &Path, args: &[&str]) -> String {
git_env(cwd, args, &[])
}
/// [`git`] with `extra` environment on top of the hermetic fixture set.
///
/// The one caller that needs it is a dated history: [`ENV`] pins every commit to
/// one second so oids are reproducible, and `deepen-since` needs commits that
/// differ in time. Written as one function rather than a second copy of the
/// spawn-and-assert body (one implementation).
pub fn git_env(cwd: &Path, args: &[&str], extra: &[(&str, &str)]) -> String {
let mut cmd = Command::new("git");
cmd.current_dir(cwd).args(args);
for (k, v) in ENV {
cmd.env(k, v);
}
for (k, v) in extra {
cmd.env(k, v);
}
let out = cmd
.output()
.unwrap_or_else(|e| panic!("failed to run `git {}`: {e}", args.join(" ")));
assert!(
out.status.success(),
"`git {}` in {} failed with {:?}\nstdout: {}\nstderr: {}",
args.join(" "),
cwd.display(),
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
String::from_utf8_lossy(&out.stdout).trim_end().to_string()
}
/// Run stock `git`, returning `Err(stderr)` instead of panicking.
pub fn git_try(cwd: &Path, args: &[&str]) -> Result<String, String> {
git_try_env(cwd, args, &[])
}
/// [`git_try`] with `extra` environment on top of the hermetic fixture set.
///
/// The caller that needs it is partial clone, which sets `GIT_NO_LAZY_FETCH=1`.
/// Asking a promisor repository whether it holds an object will otherwise
/// **fetch the object in order to answer**, so the question changes the answer
/// and every object looks present.
pub fn git_try_env(cwd: &Path, args: &[&str], extra: &[(&str, &str)]) -> Result<String, String> {
let mut cmd = Command::new("git");
cmd.current_dir(cwd).args(args);
for (k, v) in ENV {
cmd.env(k, v);
}
for (k, v) in extra {
cmd.env(k, v);
}
let out = cmd.output().expect("spawn git");
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
} else {
Err(String::from_utf8_lossy(&out.stderr).to_string())
}
}
/// The `--object-format` flag for a hash kind.
pub fn format_flag(kind: gix::hash::Kind) -> &'static str {
match kind {
gix::hash::Kind::Sha256 => "--object-format=sha256",
_ => "--object-format=sha1",
}
}
/// Both hash kinds, so no test can pass on sha1 alone.
pub const BOTH_HASH_KINDS: [gix::hash::Kind; 2] = [gix::hash::Kind::Sha1, gix::hash::Kind::Sha256];
/// Create a non-bare repository **with stock git** and give it one commit.
pub fn init_worktree(path: &Path, kind: gix::hash::Kind) {
std::fs::create_dir_all(path).unwrap();
git(path, &["init", "-q", "-b", "main", format_flag(kind), "."]);
git(path, &["config", "user.name", "Fixture"]);
git(path, &["config", "user.email", "fixture@example.invalid"]);
}
/// Create a **bare** repository with stock git, the push target.
pub fn init_bare(path: &Path, kind: gix::hash::Kind) {
std::fs::create_dir_all(path).unwrap();
git(
path,
&["init", "-q", "--bare", "-b", "main", format_flag(kind), "."],
);
}
/// Write a file and commit it with stock git; returns the commit sha.
pub fn commit_file(repo: &Path, name: &str, content: &str, message: &str) -> String {
std::fs::write(repo.join(name), content).unwrap();
git(repo, &["add", name]);
git(repo, &["commit", "-q", "-m", message]);
git(repo, &["rev-parse", "HEAD"])
}
/// [`commit_file`] at a chosen commit time, for the `deepen-since` fixtures.
///
/// Author and committer date are both set: git's `--shallow-since` cuts on the
/// **committer** date, and a fixture that only moved the author date would
/// produce three commits at the same instant and a test that passes whatever the
/// cutoff does.
pub fn commit_file_at(repo: &Path, name: &str, content: &str, message: &str, unix: i64) -> String {
std::fs::write(repo.join(name), content).unwrap();
let date = format!("{unix} +0000");
git_env(repo, &["add", name], &[]);
git_env(
repo,
&["commit", "-q", "-m", message],
&[("GIT_AUTHOR_DATE", &date), ("GIT_COMMITTER_DATE", &date)],
);
git(repo, &["rev-parse", "HEAD"])
}
/// The lines of a repository's `shallow` file, sorted. Empty when the repository
/// is complete or the file is absent.
///
/// **Applied output** (measure, never assert): the boundary the fetch actually
/// left behind, read off disk with no gunnar code in the path, so it can be
/// compared against the file stock git wrote for the same request.
pub fn shallow_file(git_dir: &Path) -> Vec<String> {
let path = if git_dir.join(".git").is_dir() {
git_dir.join(".git").join("shallow")
} else {
git_dir.join("shallow")
};
let mut lines: Vec<String> = std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect();
lines.sort();
lines
}
/// How many commits a ref reaches, as stock git counts them.
pub fn commit_count(repo: &Path, rev: &str) -> usize {
git(repo, &["rev-list", "--count", rev])
.trim()
.parse()
.expect("rev-list --count prints a number")
}
/// A writer that hands every byte to the real transport and keeps a copy.
///
/// The only way to prove a wire argument was **sent**. A shallow fetch whose
/// `deepen` line never left the client still succeeds, still writes refs, and
/// against a small enough fixture still produces the right commit count — so an
/// assertion on the result alone can pass with the argument missing. This is what
/// `tests/real_git_shallow.rs` reads to fail in that case, and what
/// `tests/real_git_fetch.rs` reads to prove the `agent=` string.
pub struct Tee<W: Write> {
inner: W,
seen: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
}
impl<W: Write> Tee<W> {
/// Wrap `inner`; the handle returned second accumulates everything written.
pub fn new(inner: W) -> (Self, std::sync::Arc<std::sync::Mutex<Vec<u8>>>) {
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
(
Tee {
inner,
seen: std::sync::Arc::clone(&seen),
},
seen,
)
}
}
impl<W: Write> Write for Tee<W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let n = self.inner.write(buf)?;
self.seen.lock().unwrap().extend_from_slice(&buf[..n]);
Ok(n)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
/// Every `refs/*` in a repository, as stock git reports them: `<oid> <name>`,
/// sorted.
///
/// The cross-check every interop assertion ends in: the state gunnar produced,
/// read back by the other implementation.
pub fn refs_via_git(repo: &Path) -> Vec<String> {
let out = git(repo, &["for-each-ref", "--format=%(objectname) %(refname)"]);
let mut v: Vec<String> = out
.lines()
.map(str::to_string)
.filter(|l| !l.is_empty())
.collect();
v.sort();
v
}
/// `git rev-list --objects <rev>`: every object reachable from `rev`, sorted.
///
/// Comparing this between source and destination is the assertion that the push
/// carried a *complete* closure rather than a ref update. `git fsck` catches
/// the same thing; both are asserted, because fsck on an empty repository is
/// also clean.
pub fn objects_via_git(repo: &Path, rev: &str) -> Vec<String> {
let out = git(repo, &["rev-list", "--objects", rev]);
let mut v: Vec<String> = out
.lines()
.map(|l| l.split_whitespace().next().unwrap_or_default().to_string())
.filter(|l| !l.is_empty())
.collect();
v.sort();
v.dedup();
v
}
/// Open a repository with gix, with an identity so ref updates can write reflogs.
pub fn open(path: &Path) -> gix::Repository {
let mut repo = gix::open(path).expect("gix::open");
let _ = repo.committer_or_set_generic_fallback();
repo
}
// ── the transport to the real git binary ────────────────────────────────────
/// A running `git receive-pack` / `git upload-pack`, wired to pipes.
pub struct GitChild {
child: Child,
stdout: Option<ChildStdout>,
stdin: Option<ChildStdin>,
what: String,
}
impl GitChild {
/// Spawn `git <subcommand> <repo>`; `protocol_v2` sets `GIT_PROTOCOL`, which
/// is exactly what an SSH client does before `exec`.
pub fn spawn(subcommand: &str, repo: &Path, protocol_v2: bool) -> Self {
GitChild::spawn_with_config(subcommand, repo, protocol_v2, &[])
}
/// [`GitChild::spawn`] with `-c <key>=<value>` settings in front of the
/// subcommand.
///
/// The one caller that needs it is partial clone: `uploadpack.allowFilter`
/// defaults to **false**, so a stock `git upload-pack` does not advertise
/// `filter` at all and refuses the argument. A filter test that forgot this
/// would be testing a server that cannot filter.
pub fn spawn_with_config(
subcommand: &str,
repo: &Path,
protocol_v2: bool,
config: &[(&str, &str)],
) -> Self {
let mut cmd = Command::new("git");
for (key, value) in config {
cmd.arg("-c").arg(format!("{key}={value}"));
}
cmd.arg(subcommand)
.arg(repo)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
for (k, v) in ENV {
cmd.env(k, v);
}
if protocol_v2 {
cmd.env("GIT_PROTOCOL", "version=2");
} else {
cmd.env_remove("GIT_PROTOCOL");
}
let mut child = cmd
.spawn()
.unwrap_or_else(|e| panic!("spawn `git {subcommand}`: {e}"));
let stdout = child.stdout.take();
let stdin = child.stdin.take();
GitChild {
child,
stdout,
stdin,
what: format!("git {subcommand} {}", repo.display()),
}
}
/// `git receive-pack` at protocol v0, which is what push speaks.
pub fn receive_pack(repo: &Path) -> Self {
GitChild::spawn("receive-pack", repo, false)
}
/// `git upload-pack`, with protocol v2 negotiated, as gix expects.
pub fn upload_pack(repo: &Path) -> Self {
GitChild::spawn("upload-pack", repo, true)
}
/// `git upload-pack` at protocol **v0**: no `GIT_PROTOCOL` in the
/// environment, which is exactly how an SSH server without protocol
/// negotiation starts it.
///
/// Not a formality. The v0/v2 split produced four separate wrong answers in
/// gunnar in one day, every one of them invisible to a v2-only test, because
/// the two versions carry the same arguments in different frames and gate
/// them on differently-named capabilities. A test that only spawns
/// [`GitChild::upload_pack`] cannot see any of them.
pub fn upload_pack_v0(repo: &Path) -> Self {
GitChild::spawn("upload-pack", repo, false)
}
/// `git upload-pack` that will actually serve a partial clone.
///
/// `uploadpack.allowFilter` is off by default in stock git, and without it
/// the server advertises no `filter` capability, so the client's filter
/// argument is refused (v0) or never legal (v2).
pub fn upload_pack_filtering(repo: &Path, protocol_v2: bool) -> Self {
GitChild::spawn_with_config(
"upload-pack",
repo,
protocol_v2,
&[("uploadpack.allowFilter", "true")],
)
}
/// The raw halves, for a caller that needs them by value (gix's transport
/// does).
pub fn into_halves(mut self) -> (ChildStdout, ChildStdin, Child) {
(
self.stdout.take().expect("stdout"),
self.stdin.take().expect("stdin"),
self.child,
)
}
/// Close stdin, wait, and assert the process exited cleanly.
///
/// Waiting matters: `receive-pack` writes its report *after* applying the ref
/// transaction, but a test that read the report and then immediately inspected
/// the repository would still race the process's own exit path. It also means
/// a git that died mid-conversation is reported here rather than as a
/// mysterious assertion failure three lines later.
pub fn finish(mut self) {
drop(self.stdin.take());
drop(self.stdout.take());
let status = self.child.wait().expect("wait for git");
assert!(status.success(), "{} exited with {status:?}", self.what);
}
/// Close the pipes and reap the child **without** asserting on its status.
///
/// For the tests where gunnar refuses to push *before* saying anything: the
/// remote then sees its stdin close mid-conversation and exits with "the
/// remote end hung up unexpectedly", which is the correct behaviour of both
/// sides and not a failure of either.
pub fn abandon(mut self) {
drop(self.stdin.take());
drop(self.stdout.take());
let _ = self.child.wait();
}
}
impl Transport for GitChild {
fn io(&mut self) -> (&mut dyn Read, &mut dyn Write) {
(
self.stdout.as_mut().expect("stdout present"),
self.stdin.as_mut().expect("stdin present"),
)
}
}
/// A temporary directory plus the paths a push test needs.
pub struct Fixture {
pub dir: tempfile::TempDir,
pub kind: gix::hash::Kind,
}
impl Fixture {
/// A source worktree with `n` commits and a bare target, both made by stock
/// git.
pub fn new(kind: gix::hash::Kind) -> Self {
let dir = tempfile::tempdir().unwrap();
init_worktree(&dir.path().join("src"), kind);
init_bare(&dir.path().join("bare.git"), kind);
Fixture { dir, kind }
}
pub fn src(&self) -> PathBuf {
self.dir.path().join("src")
}
pub fn bare(&self) -> PathBuf {
self.dir.path().join("bare.git")
}
}