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

nordisk/edda

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:?}"
    );
}