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