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/push_deltas.rs

//! **The pack a push sends must carry deltas**, judged by stock git.
//!
//! # What went wrong, and why nothing caught it
//!
//! `drive_push` called `gunnar_pack::write_pack`, whose own doc says it *"emits
//! no deltas at all"*. Every correctness test in the tree stayed green, because
//! an undeltified pack is a perfectly correct pack: the object closure is
//! complete, `git fsck --strict` passes, the oids match. It is simply four to
//! twelve times too big, and the server's pack-copy then reproduces that
//! size into every later clone, forever.
//!
//! MEASURED 2026-08-03, 19 real nordisk repositories: the server stored
//! 407.08 MiB for gunnar's client and 88.14 MiB for stock git — 4.62×, and
//! 11.80× on `nornir` alone.
//!
//! # The instrument
//!
//! `git verify-pack -v`. It is the only thing that can see this, and everything
//! here is read out of it: a non-delta entry is `<oid> <type> <size>
//! <size-in-pack> <offset>` and a delta entry appends `<depth> <base-oid>`, so
//! the deltified share is a property of the pack **stock git wrote to its own
//! disk**, not of anything gunnar reports about itself.
//!
//! # Every assertion here is proved red
//!
//! The metric is the same shape as the bug: "0.0%" and "not measured" look
//! alike. So [`the_old_whole_object_writer_is_what_zero_percent_looks_like`]
//! runs the identical object set through `gunnar_pack::write_pack` — the writer
//! that was being called — and requires the counter to come back **0**. If the
//! parser were blind, or if `deltified_share` counted fields wrong, that test
//! would fail with the same number the good one passes with, and one of the two
//! is always lying.

mod common;

use std::path::{Path, PathBuf};

use common::*;
use gunnar_client::{push_over, PushOptions, RefSpec};

/// The one `.pack` in a repository's object store.
fn the_pack(bare: &Path) -> PathBuf {
    let dir = bare.join("objects/pack");
    let mut packs: Vec<PathBuf> = std::fs::read_dir(&dir)
        .unwrap_or_else(|e| panic!("read {dir:?}: {e}"))
        .map(|e| e.expect("dir entry").path())
        .filter(|p| p.extension().is_some_and(|x| x == "pack"))
        .collect();
    packs.sort();
    assert_eq!(
        packs.len(),
        1,
        "expected exactly one pack in {dir:?}, found {packs:?}. A push that landed as \
         loose objects has no pack to measure, and `receive.unpackLimit` is what \
         decides that"
    );
    packs.pop().expect("one pack")
}

/// `(objects, deltified objects, pack bytes)` as **stock git** reports them.
///
/// `verify-pack -v` also reconstructs and re-hashes every object on its way
/// through, so a pack that gets a number out of this is a pack git accepted.
fn verify_pack(bare: &Path) -> (usize, usize, u64) {
    let pack = the_pack(bare);
    let bytes = std::fs::metadata(&pack).expect("stat the pack").len();
    let out = git(bare, &["verify-pack", "-v", &pack.to_string_lossy()]);

    let (mut total, mut deltas) = (0usize, 0usize);
    for line in out.lines() {
        let f: Vec<&str> = line.split_whitespace().collect();
        // `<oid> <type> <size> <size-in-pack> <offset> [<depth> <base-oid>]`.
        // Everything else `verify-pack -v` prints — the chain-length summary,
        // the "non delta:" line — starts with a word that is not an oid.
        if f.len() < 5 || !f[0].chars().all(|c| c.is_ascii_hexdigit()) {
            continue;
        }
        if !matches!(f[1], "blob" | "tree" | "commit" | "tag") {
            continue;
        }
        total += 1;
        if f.len() >= 7 {
            deltas += 1;
        }
    }
    assert!(
        total > 0,
        "`git verify-pack -v` named no objects at all in {pack:?}. An empty parse is \
         shaped exactly like a pack with no deltas, so this is refused rather than \
         reported as 0%"
    );
    (total, deltas, bytes)
}

fn share(deltas: usize, total: usize) -> f64 {
    100.0 * deltas as f64 / total as f64
}

/// A history with real redundancy in it: one file rewritten many times, which is
/// what a delta window is for, plus enough objects that `receive-pack` keeps a
/// pack instead of exploding it into loose objects.
fn build_history(src: &Path) {
    let mut body = String::new();
    for i in 0..400 {
        body.push_str(&format!(
            "line {i}: the quick brown fox jumps over the lazy dog\n"
        ));
    }
    std::fs::write(src.join("a.txt"), &body).unwrap();
    git(src, &["add", "a.txt"]);
    git(src, &["commit", "-q", "-m", "seed"]);

    for rev in 0..40 {
        body.push_str(&format!("revision {rev} appended this line\n"));
        std::fs::write(src.join("a.txt"), &body).unwrap();
        // A second file so trees change shape too, not only one blob.
        std::fs::create_dir_all(src.join("sub")).unwrap();
        std::fs::write(src.join(format!("sub/f{rev}.txt")), &body).unwrap();
        git(src, &["add", "-A"]);
        git(src, &["commit", "-q", "-m", &format!("rev {rev}")]);
    }
}

/// Keep the pack. `receive-pack` explodes a push of 100 objects or fewer into
/// loose objects by default, and a repository with no pack cannot be measured.
fn keep_packs(bare: &Path) {
    git(bare, &["config", "receive.unpackLimit", "1"]);
    git(bare, &["config", "transfer.unpackLimit", "1"]);
}

fn push_with_gunnar(src: &Path, bare: &Path) -> gunnar_client::PushOutcome {
    let repo = open(src);
    let mut t = GitChild::receive_pack(bare);
    let specs = RefSpec::parse_all(&["refs/heads/main:refs/heads/main"]).unwrap();
    let outcome = push_over(&repo, &mut t, &specs, &PushOptions::new()).expect("push");
    t.finish();
    assert!(outcome.is_ok(), "the push was rejected: {outcome:?}");
    outcome
}

/// The headline: our push is deltified, and it is in stock git's league.
#[test]
fn a_push_carries_computed_deltas_and_git_accepts_them() {
    for kind in BOTH_HASH_KINDS {
        let f = Fixture::new(kind);
        build_history(&f.src());
        keep_packs(&f.bare());

        let outcome = push_with_gunnar(&f.src(), &f.bare());

        // ── the client's own applied output ─────────────────────────────────
        assert!(
            outcome.objects_deltified > 0,
            "{kind:?}: the client wrote {} objects and computed 0 deltas. That is the \
             defect this test exists for, exactly as it shipped",
            outcome.objects_sent
        );

        // ── and the same claim, read back out of stock git's own pack ───────
        let (total, deltas, bytes) = verify_pack(&f.bare());
        assert!(
            deltas > 0,
            "{kind:?}: `git verify-pack -v` says {deltas}/{total} of the objects the \
             server stored are deltas — 0.0%, which is what the defect measured"
        );
        // Not a tight bound: it is a floor far below what was measured, so that
        // it fails on a regression to whole objects and not on a heuristic
        // tweak. Observed on this fixture: see the printout below.
        assert!(
            share(deltas, total) > 25.0,
            "{kind:?}: only {:.1}% of the pack is deltas ({deltas}/{total})",
            share(deltas, total)
        );
        // The client's count and git's must agree about the same pack. They are
        // two independent measurements of one artefact; if they disagree, one of
        // the two numbers in this file is fiction.
        assert_eq!(
            outcome.objects_deltified, deltas,
            "{kind:?}: the client says it computed {} deltas, `git verify-pack -v` \
             counts {deltas} in the pack the server wrote",
            outcome.objects_deltified
        );

        // ── the correctness bar, and it comes first ─────────────────────────
        //
        // A smaller pack that reconstructs one byte wrong is not an
        // improvement, it is a corrupt repository discovered at some later
        // clone. `verify-pack -v` above already rebuilt and re-hashed every
        // entry; these add the object closure and the connectivity check.
        assert_eq!(
            git(&f.bare(), &["rev-parse", "refs/heads/main"]),
            git(&f.src(), &["rev-parse", "refs/heads/main"]),
            "{kind:?}: the pushed tip differs"
        );
        assert_eq!(
            objects_via_git(&f.bare(), "refs/heads/main"),
            objects_via_git(&f.src(), "refs/heads/main"),
            "{kind:?}: the deltified pack must carry the COMPLETE object closure"
        );
        let fsck = git_try(&f.bare(), &["fsck", "--full", "--strict", "--no-progress"]);
        assert!(fsck.is_ok(), "{kind:?}: git fsck rejected it: {fsck:?}");

        println!(
            "{kind:?}: {deltas}/{total} deltified ({:.1}%), {bytes} pack bytes",
            share(deltas, total)
        );
    }
}

/// The control. Same history, pushed by **stock git** into its own bare
/// repository, on the same machine in the same run.
///
/// This is the ratio the bench arm reports at corpus scale (4.62× before the
/// fix), reduced to something a unit test can hold. The bound is deliberately
/// loose — git orders objects by type, path and recency and this crate orders by
/// size alone, so parity is not the claim — but a 4× blow-up is not a heuristic
/// difference and must fail here.
#[test]
fn our_pack_is_in_stock_gits_league_not_four_times_it() {
    let kind = gix::hash::Kind::Sha1;
    let f = Fixture::new(kind);
    build_history(&f.src());
    keep_packs(&f.bare());
    push_with_gunnar(&f.src(), &f.bare());
    let (ours_n, ours_d, ours_bytes) = verify_pack(&f.bare());

    // A second bare repository, pushed by git itself.
    let control = f.dir.path().join("control.git");
    init_bare(&control, kind);
    keep_packs(&control);
    git(
        &f.src(),
        &[
            "push",
            "--quiet",
            &control.to_string_lossy(),
            "refs/heads/main:refs/heads/main",
        ],
    );
    let (git_n, git_d, git_bytes) = verify_pack(&control);

    assert_eq!(
        ours_n, git_n,
        "the two pushes did not carry the same object set, so their sizes are not \
         comparable"
    );
    let ratio = ours_bytes as f64 / git_bytes as f64;
    println!(
        "ours {ours_bytes} B ({:.1}% deltified), git {git_bytes} B ({:.1}% deltified), \
         ratio {ratio:.3}x",
        share(ours_d, ours_n),
        share(git_d, git_n)
    );
    assert!(
        ratio < 2.0,
        "our pack is {ratio:.2}x stock git's ({ours_bytes} B vs {git_bytes} B). Before \
         the encoder was wired in this was 4.62x across 19 real repositories"
    );
}

/// **The negative control**, and the reason the numbers above can be believed.
///
/// The identical object set, written by `gunnar_pack::write_pack` — the writer
/// `drive_push` used to call — must come back at **0 deltas**. If the parser in
/// this file counted fields wrongly, or counted the summary lines, or counted
/// nothing at all, this test would report a number other than zero and the
/// suite would be seen to contradict itself.
#[test]
fn the_old_whole_object_writer_is_what_zero_percent_looks_like() {
    let kind = gix::hash::Kind::Sha1;
    let f = Fixture::new(kind);
    build_history(&f.src());
    keep_packs(&f.bare());

    // Push with the OLD writer, by driving send-pack with our own pack function.
    let repo = open(&f.src());
    let mut t = GitChild::receive_pack(&f.bare());
    let adv = {
        let (r, _) = gunnar_client::Transport::io(&mut t);
        gunnar_client::read_advertisement(r).expect("advertisement")
    };
    let head = git(&f.src(), &["rev-parse", "refs/heads/main"]);
    let new = gix::hash::ObjectId::from_hex(head.as_bytes()).unwrap();
    let commands = vec![gunnar_client::PushCommand::create("refs/heads/main", new)];
    let plan = gunnar_client::objects::plan_objects(&repo, &[new], &[]).expect("plan");
    let opts = gunnar_pack::WriteOptions::new(kind);
    let report = gunnar_client::send_pack(
        &mut t,
        &adv,
        &commands,
        kind,
        Some(
            |w: &mut dyn std::io::Write| -> Result<(), gunnar_client::Error> {
                // The exact call `crates/gunnar-client/src/push.rs:527` used to make.
                let outcome = gunnar_pack::write_pack(
                    &plan.ids,
                    &gunnar_client::pack_source::PlumbingObjects(&repo),
                    w,
                    &opts,
                )?;
                assert_eq!(
                    outcome.objects_deltified, 0,
                    "`write_pack` has no encoder; a non-zero count here means the field is \
                 being filled in by something other than the code that computed a delta"
                );
                Ok(())
            },
        ),
        &Default::default(),
    )
    .expect("send-pack");
    t.finish();
    assert!(report.is_ok(), "the control push was rejected: {report:?}");

    let (total, deltas, bytes) = verify_pack(&f.bare());
    assert_eq!(
        deltas, 0,
        "the whole-object writer produced {deltas}/{total} deltas. Either it grew an \
         encoder, or `verify_pack` in this file is counting something that is not a \
         delta — and in the second case every other number here is worthless"
    );
    println!("control (write_pack): {total} objects, 0 deltas, {bytes} pack bytes");
}

/// ★ **A PACKED source is sent the way stock git sends it: its deltas COPIED, not recomputed**
/// (turn 2026-10-12: the kit's seed of workspace_nordisk, 78 MB from a mirror, took 23 s
/// optimised and 243 s in a dev build recomputing every delta; `git push` reused them in 7 s).
/// After `git gc` the history lives in one pack with git's own deltas: the push copies them,
/// stock git stores a deltified pack, and the closure and fsck hold.
#[test]
fn a_packed_source_copies_its_deltas_instead_of_recomputing_them() {
    for kind in BOTH_HASH_KINDS {
        let f = Fixture::new(kind);
        build_history(&f.src());
        git(&f.src(), &["gc", "-q", "--aggressive", "--prune=now"]);
        keep_packs(&f.bare());
        let outcome = push_with_gunnar(&f.src(), &f.bare());
        assert!(
            outcome.objects_copied as u32 == outcome.objects_sent && outcome.objects_sent > 0,
            "{kind:?}: a packed source copies every object: {outcome:?}"
        );
        assert_eq!(
            outcome.objects_deltified, 0,
            "{kind:?}: no delta was recomputed"
        );
        let (total, deltas, _) = verify_pack(&f.bare());
        assert!(
            share(deltas, total) > 25.0,
            "{kind:?}: git's deltas survive the copy ({deltas}/{total})"
        );
        assert_eq!(
            objects_via_git(&f.bare(), "refs/heads/main"),
            objects_via_git(&f.src(), "refs/heads/main"),
            "{kind:?}: the complete closure"
        );
        assert!(
            git_try(&f.bare(), &["fsck", "--full", "--strict", "--no-progress"]).is_ok(),
            "{kind:?}: fsck"
        );
    }
}

/// MEASURE (ignored): `PUSH_MEASURE_SRC=<a packed mirror>` — gunnar's push of every head of it
/// into a fresh bare repository through stock `git receive-pack`, timed against `git push` of
/// the same refs. Run with `--ignored --nocapture`.
#[test]
#[ignore]
fn measure_a_packed_mirror_against_git() {
    let Some(src) = std::env::var_os("PUSH_MEASURE_SRC").map(PathBuf::from) else {
        return;
    };
    let kind = gix::open(&src).expect("open the source").object_hash();
    let specs_all = RefSpec::parse_all(&["refs/heads/*:refs/heads/*"]);
    let tmp = std::env::temp_dir().join(format!("push-measure-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&tmp);
    for (who, bare) in [("gunnar", tmp.join("g.git")), ("git", tmp.join("s.git"))] {
        std::fs::create_dir_all(&bare).unwrap();
        let fmt = if kind == gix::hash::Kind::Sha1 {
            "sha1"
        } else {
            "sha256"
        };
        git(
            &bare,
            &["init", "-q", "--bare", &format!("--object-format={fmt}")],
        );
        let t = std::time::Instant::now();
        if who == "gunnar" {
            let repo = open(&src);
            let heads: Vec<String> =
                git(&src, &["for-each-ref", "--format=%(refname)", "refs/heads"])
                    .lines()
                    .map(|r| format!("{r}:{r}"))
                    .collect();
            let specs = RefSpec::parse_all(&heads).unwrap();
            let mut tr = GitChild::receive_pack(&bare);
            let o = push_over(&repo, &mut tr, &specs, &PushOptions::new()).expect("push");
            tr.finish();
            println!(
                "gunnar: {:?} — {} objects, {} copied, {} computed deltas, {} pack bytes",
                t.elapsed(),
                o.objects_sent,
                o.objects_copied,
                o.objects_deltified,
                o.pack_bytes
            );
        } else {
            git(
                &src,
                &[
                    "push",
                    "-q",
                    &bare.to_string_lossy(),
                    "refs/heads/*:refs/heads/*",
                ],
            );
            println!("git:    {:?}", t.elapsed());
        }
    }
    let _ = specs_all;
    let _ = std::fs::remove_dir_all(&tmp);
}