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

nordisk/edda

crates/storage-git-gix/tests/round_trip.rs

//! The smoke suite: a real pack, made by real `git`, pushed through `GitOps`,
//! and read back both by this crate and by stock `git`.
//!
//! # Why real `git` builds the fixture and real `git` checks the result
//!
//! The charred tree's most valuable test was
//! `parked-gix-2/gunnar-store-gix/tests/real_git_reads_what_we_wrote.rs`,
//! and its shape is the reason: a store that only its own reader can read has
//! proved nothing. `S-026` in the charred record is the matching warning —
//! *"the real-git interop suites self-skip and report green"* — so these tests
//! **fail** when `git` is missing rather than skipping. A green run means the
//! interop actually happened.

use std::path::Path;
use std::process::Command;

use git_storage_trait::{GitOps, RefUpdate};
use storage_git_gix::GixStore;

/// Run `git` in `dir`, failing loudly. Never skips: see the module doc.
fn git(dir: &Path, args: &[&str]) -> String {
    let out = Command::new("git")
        .current_dir(dir)
        .args(args)
        .output()
        .unwrap_or_else(|e| panic!("`git {}` could not be run: {e}", args.join(" ")));
    assert!(
        out.status.success(),
        "`git {}` failed: {}",
        args.join(" "),
        String::from_utf8_lossy(&out.stderr)
    );
    String::from_utf8_lossy(&out.stdout).trim().to_string()
}

/// A source repository with three commits, and a pack holding all of it.
fn fixture(root: &Path) -> (Vec<u8>, String) {
    let src = root.join("src");
    std::fs::create_dir_all(&src).unwrap();
    git(&src, &["init", "-q", "--initial-branch=main"]);
    git(&src, &["config", "user.email", "t@example.invalid"]);
    git(&src, &["config", "user.name", "T"]);

    for i in 0..3 {
        std::fs::write(src.join(format!("f{i}.txt")), format!("contents {i}\n")).unwrap();
        std::fs::create_dir_all(src.join("sub")).unwrap();
        std::fs::write(src.join("sub").join(format!("s{i}.txt")), format!("sub {i}\n")).unwrap();
        git(&src, &["add", "-A"]);
        git(&src, &["commit", "-q", "-m", &format!("commit {i}")]);
    }
    let head = git(&src, &["rev-parse", "HEAD"]);

    // `git pack-objects` over the whole history, **with `--delta-base-offset`**.
    //
    // That flag is not cosmetic and it is not the test being made easy. Without
    // it `git pack-objects` emits `OBJ_REF_DELTA`, and gitoxide cannot index a
    // pack whose ref-delta bases live *inside that same pack*: its thin-pack
    // resolver consults only the external base source it was handed and never
    // looks in the pack it is reading. That is the charred record's `S-003`
    // amendment — *"gitoxide's `LookupRefDeltaObjectsIter` never looks inside
    // the pack it is reading"* — and this session reproduced it independently.
    //
    // `--delta-base-offset` is also what really happens on the wire: every
    // modern server advertises the `ofs-delta` capability and every modern
    // client then sends `OBJ_OFS_DELTA`. So this fixture is the realistic shape,
    // and `ref_delta_with_an_in_pack_base_is_a_known_gap` below pins the shape
    // that is not supported, so the gap is visible rather than hidden.
    let out = Command::new("git")
        .current_dir(&src)
        .args(["pack-objects", "--revs", "--delta-base-offset", "--stdout"])
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
        .and_then(|mut c| {
            use std::io::Write;
            c.stdin.take().unwrap().write_all(b"HEAD\n")?;
            c.wait_with_output()
        })
        .expect("git pack-objects");
    assert!(out.status.success(), "pack-objects failed");
    assert!(!out.stdout.is_empty(), "pack-objects produced no bytes");
    (out.stdout, head)
}

#[test]
fn a_pack_pushed_through_git_ops_is_readable_by_stock_git() {
    let tmp = tempfile::tempdir().unwrap();
    let (pack, head) = fixture(tmp.path());

    let bare = tmp.path().join("bare.git");
    let store = GixStore::open_or_init(&bare, gix_hash::Kind::Sha1).unwrap();

    let head_raw = hex_to_bytes(&head);

    // The whole push: pack first, then the ref that points into it.
    let tx = store
        .put(&pack, &[RefUpdate::set("refs/heads/main", &head)])
        .unwrap();
    assert!(tx.pack_id.is_some(), "put must report the pack it assigned");
    assert!(tx.extent.is_some(), "put must report the pack's extent");
    assert!(tx.push_seq.is_some(), "put must report a ref sequence");

    // ── this crate can read it ──────────────────────────────────────────────
    assert!(store.has(&head_raw).unwrap(), "the head commit must be here");
    let stored = store.get(&head_raw).unwrap().expect("head commit bytes");
    assert!(stored.uncompressed_size > 0);
    assert_eq!(stored.extent.1 as usize, stored.bytes.len());
    assert!(
        !stored.bytes.is_empty(),
        "verbatim pack entry bytes must not be empty"
    );
    assert_eq!(
        store.size(&head_raw).unwrap(),
        Some(stored.uncompressed_size),
        "size() and get()'s resolved size must agree"
    );

    let extents = store.extents(&[&head_raw]).unwrap();
    assert_eq!(extents.len(), 1);
    assert_eq!(extents[0], Some(stored.extent));

    // A miss must be a clean `None`/`false`, not an error.
    let absent = vec![0xabu8; 20];
    assert!(!store.has(&absent).unwrap());
    assert!(store.get(&absent).unwrap().is_none());
    assert_eq!(store.extents(&[&absent]).unwrap(), vec![None]);

    // ── the refs ────────────────────────────────────────────────────────────
    let rows = store.refs().unwrap();
    assert_eq!(rows.len(), 1, "exactly the one ref we pushed: {rows:?}");
    assert_eq!(rows[0].name, "refs/heads/main");
    assert_eq!(rows[0].oid.as_deref(), Some(head_raw.as_slice()));

    // ── reachability ────────────────────────────────────────────────────────
    let all = store.reachable(&[&head_raw], &[]).unwrap();
    assert!(
        all.len() >= 3,
        "a three-commit history reaches at least its commits, got {}",
        all.len()
    );
    // `want` minus `have` where have == want is the empty set, and getting that
    // wrong is the classic negotiation bug.
    let none = store.reachable(&[&head_raw], &[&head_raw]).unwrap();
    assert!(none.is_empty(), "want minus itself must be empty, got {none:?}");

    // ── and stock git agrees ────────────────────────────────────────────────
    // This is the assertion the whole file exists for.
    git(&bare, &["fsck", "--strict"]);
    let kind = git(&bare, &["cat-file", "-t", &head]);
    assert_eq!(kind, "commit", "real git must see a commit at HEAD");
    let listed = git(&bare, &["rev-parse", "refs/heads/main"]);
    assert_eq!(listed, head, "real git must resolve the ref we wrote");

    // Real git's own object count over the same history, against ours.
    let git_count = git(&bare, &["rev-list", "--objects", "--all"]).lines().count();
    assert_eq!(
        all.len(),
        git_count,
        "our closure must be exactly `git rev-list --objects --all`"
    );
}

#[test]
fn update_ref_is_a_real_compare_and_swap() {
    let tmp = tempfile::tempdir().unwrap();
    let (pack, head) = fixture(tmp.path());
    let bare = tmp.path().join("bare.git");
    let store = GixStore::open_or_init(&bare, gix_hash::Kind::Sha1).unwrap();
    store.put_pack(&pack).unwrap();
    let head_raw = hex_to_bytes(&head);

    // Create: old == None means "must not exist".
    store
        .update_ref("refs/heads/cas", None, Some(&head_raw))
        .unwrap();
    assert_eq!(store.refs().unwrap().len(), 1);

    // `S-023`: creating the SAME value again must still fail, and this is the
    // exact case gix short-circuits into a silent success. Without
    // `refs::precheck_cas` this assertion fails, which is why it is here.
    let again = store.update_ref("refs/heads/cas", None, Some(&head_raw));
    assert!(
        again.is_err(),
        "a create over an existing ref must fail even when the value is identical \
         (S-023: gix short-circuits this into a silent success)"
    );

    // A CAS against the wrong old value must fail.
    let wrong = vec![0x11u8; 20];
    assert!(store
        .update_ref("refs/heads/cas", Some(&wrong), Some(&head_raw))
        .is_err());

    // A CAS against the right old value must succeed, and delete works.
    store
        .update_ref("refs/heads/cas", Some(&head_raw), None)
        .unwrap();
    assert!(store.refs().unwrap().is_empty());
}

#[test]
fn gc_retires_a_pack_nothing_points_at() {
    let tmp = tempfile::tempdir().unwrap();
    let (pack, _head) = fixture(tmp.path());
    let bare = tmp.path().join("bare.git");
    let store = GixStore::open_or_init(&bare, gix_hash::Kind::Sha1).unwrap();

    // A pack with no ref pointing into it: every object in it is dead.
    store.put_pack(&pack).unwrap();
    assert!(store.refs().unwrap().is_empty());

    let before = store.gc().unwrap();
    assert_eq!(before.strategy, "gix-mark-sweep-packs");
    assert_eq!(before.rows, 0, "nothing is reachable, so no live rows");
    assert_eq!(before.retired_packs, 1, "the unreferenced pack must be swept");
    assert!(before.bytes_after < before.bytes_before);
    assert!(before.verified);

    // And a second gc over an empty store is a no-op rather than an error.
    let again = store.gc().unwrap();
    assert_eq!(again.retired_packs, 0);
    assert_eq!(again.bytes_before, again.bytes_after);
}

#[test]
fn gc_keeps_a_pack_a_ref_points_at() {
    let tmp = tempfile::tempdir().unwrap();
    let (pack, head) = fixture(tmp.path());
    let bare = tmp.path().join("bare.git");
    let store = GixStore::open_or_init(&bare, gix_hash::Kind::Sha1).unwrap();
    store
        .put(&pack, &[RefUpdate::set("refs/heads/main", &head)])
        .unwrap();

    let report = store.gc().unwrap();
    assert_eq!(report.retired_packs, 0, "a referenced pack must survive gc");
    assert!(report.rows > 0, "the live set must be non-empty");
    assert_eq!(report.bytes_before, report.bytes_after);

    // And the repository is still intact afterwards, per real git.
    git(&bare, &["fsck", "--strict"]);
    assert!(store.has(&hex_to_bytes(&head)).unwrap());
}

fn hex_to_bytes(hex: &str) -> Vec<u8> {
    (0..hex.len())
        .step_by(2)
        .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
        .collect()
}

/// A pack using `OBJ_REF_DELTA` for a base that is **inside the same pack** is
/// accepted by `put_pack`, and the repository it lands in passes `git fsck`.
///
/// # This was the pinned gap until 2026-10-02
///
/// Until gix 0.87 `put_pack` REJECTED this shape (`git pack-objects` without
/// `--delta-base-offset` produces it), and this test asserted the clean, named
/// failure, with the instruction "if a future gitoxide resolves in-pack
/// ref-deltas, this test goes red: delete it and drop the caveat". gix 0.88
/// (gix-pack 0.75) resolves them — the test went red on the gix 0.88 wave
/// (GitoxideLabs/gitoxide#2882, filed 2026-08-05 from the quarantine's
/// `.nornir/upstream-gix-thin-base-in-pack.md`). Rather than deleting it, it now
/// pins the fixed behaviour, so a regression in a later gitoxide is caught.
///
/// Not covered here, and still an unmeasured hypothesis: the multi-pack
/// sender shape from that report (an object whole in one pack and a delta in
/// another, 13 of 46 real fetches refused by gix 0.87's `index_thin_pack`).
#[test]
fn ref_delta_with_an_in_pack_base_is_accepted() {
    let tmp = tempfile::tempdir().unwrap();
    let src = tmp.path().join("src");
    std::fs::create_dir_all(&src).unwrap();
    git(&src, &["init", "-q", "--initial-branch=main"]);
    git(&src, &["config", "user.email", "t@example.invalid"]);
    git(&src, &["config", "user.name", "T"]);
    // Two similar blobs, so git has something worth deltifying.
    for i in 0..2 {
        std::fs::write(src.join("big.txt"), "x".repeat(4096) + &format!("{i}")).unwrap();
        git(&src, &["add", "-A"]);
        git(&src, &["commit", "-q", "-m", &format!("c{i}")]);
    }

    // NOTE: no `--delta-base-offset`, so bases are named by object id.
    let out = Command::new("git")
        .current_dir(&src)
        .args(["pack-objects", "--revs", "--stdout"])
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
        .and_then(|mut c| {
            use std::io::Write;
            c.stdin.take().unwrap().write_all(b"HEAD\n")?;
            c.wait_with_output()
        })
        .expect("git pack-objects");
    assert!(out.status.success());

    let bare = tmp.path().join("bare.git");
    let store = GixStore::open_or_init(&bare, gix_hash::Kind::Sha1).unwrap();
    store
        .put_pack(&out.stdout)
        .expect("a ref-delta pack with in-pack bases indexes since gix 0.88");
    let head = git(&src, &["rev-parse", "HEAD"]);
    assert!(store.has(&hex_to_bytes(&head)).unwrap(), "the pushed tip is readable");
    git(&bare, &["fsck", "--strict"]);
}