Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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")
    }
}