crates/gunnar-cli/tests/twin_push_is_framed.rs
#![cfg(feature = "server")]
//! **A pushed pack is a transaction** — through the real receive path, not `put_pack`.
//!
//! The first KVM twin drill (2026-09-15) found the push path never framed its pack: a client's
//! pack enters `Engine::ingest_push`, which opens the engine by `git_dir` and never passes the
//! `OpenRepo::logged` tee, while the ref update after it does. The twin and a restored primary
//! then both refused `refs/heads/main would point at …, which this repository does not have`.
//! The native twin tests had stayed green because they call `put_pack` directly. This test
//! pushes with stock git over SSH into a live server, follows its log into a twin, restores the
//! twin's log into a fresh data root, and asks each for the pushed commit.
mod common;
use std::sync::Arc;
use common::{local_repo, tools_present, Harness};
use gunnar_cli::server::twin::{restore, Follower, Progress, TwinConfig};
use gunnar_cli::server::ServerStores;
use gunnar_core::txlog::Meta;
use gunnar_core::{HashKind, RepoRef};
const NS: &str = "team";
const REPO: &str = "project";
/// Does `stores` hold `oid_hex` in `team/project`?
fn holds(stores: &ServerStores, oid_hex: &str) -> bool {
let Some(row) = stores
.catalog()
.get(&RepoRef::new(NS, REPO).unwrap())
.unwrap()
else {
return false;
};
let store = stores.repo_store(&row).unwrap();
let oid = gix_hash::ObjectId::from_hex(oid_hex.as_bytes()).unwrap();
store.serve().header(oid.as_bytes()).unwrap().is_some()
}
/// What `refs/heads/main` of `team/project` points at on `stores`.
fn main_of(stores: &ServerStores) -> Option<Vec<u8>> {
let row = stores
.catalog()
.get(&RepoRef::new(NS, REPO).unwrap())
.unwrap()?;
let store = stores.repo_store(&row).unwrap();
store
.serve()
.refs()
.unwrap()
.into_iter()
.find(|r| r.name == "refs/heads/main")
.and_then(|r| r.oid)
}
#[test]
fn a_pushed_pack_is_a_frame_before_its_refs_and_the_twin_and_a_restore_hold_it() {
if !tools_present() {
eprintln!("skipping: no git or ssh on PATH");
return;
}
let harness = Harness::start(NS, REPO, HashKind::Sha256);
let work = harness.dir.path().join("work");
let head = local_repo(&harness, &work, HashKind::Sha256, "framed over the wire\n");
harness
.git(
&work,
&["push", &harness.url(NS, REPO), "HEAD:refs/heads/main"],
)
.expect_ok("git push");
let head_bytes = gix_hash::ObjectId::from_hex(head.as_bytes())
.unwrap()
.as_bytes()
.to_vec();
// The primary's log: a Pack frame for the repository BEFORE the Refs frame that moves main.
let log = Arc::clone(harness.stores.txlog());
let entries = log.range(log.first_seq(), log.head_seq()).unwrap();
let key = format!("{NS}/{REPO}");
let refs_at = entries
.iter()
.position(|e| match e.meta() {
Ok(Meta::Refs { repo, edits, .. }) => {
repo == key
&& edits.iter().any(|ed| {
ed.name == "refs/heads/main" && ed.new.as_deref() == Some(head.as_str())
})
}
_ => false,
})
.expect("a Refs frame moves refs/heads/main to the pushed commit");
let pack_at = entries
.iter()
.position(|e| matches!(e.meta(), Ok(Meta::Pack { ref repo, .. }) if *repo == key))
.expect("the pushed pack is a Pack frame — the receive path framed it");
assert!(
pack_at < refs_at,
"the pack (index {pack_at}) is framed before the ref update (index {refs_at})"
);
// The twin follows that log and holds the commit and the ref.
let twin_dir = tempfile::tempdir().unwrap();
TwinConfig {
primary: "harness".into(),
joined_at_unix_ms: 0,
}
.save(twin_dir.path())
.unwrap();
let twin = Arc::new(ServerStores::open(twin_dir.path()).unwrap());
let progress = Follower::new(Arc::clone(&twin), Box::new(Arc::clone(&log)))
.step()
.unwrap();
assert!(matches!(progress, Progress::Applied { .. }), "{progress:?}");
// **The twin only stores** (ruling 2026-09-22): the pushed pack and the ref update are in
// its LOG, frame for frame, and it opened no repository store of its own. The restore below
// is where they become a repository again.
let tlog = twin.txlog();
assert_eq!(
tlog.head_seq(),
log.head_seq(),
"the twin's log is at the primary's head"
);
for (i, e) in entries.iter().enumerate() {
let t = tlog.read(e.seq).unwrap();
assert!(
t.kind == e.kind && t.meta == e.meta && t.blob == e.blob,
"frame {i} (seq {}) is byte for byte the primary's",
e.seq
);
}
assert!(
!twin
.repo_dir(&RepoRef::new(NS, REPO).unwrap())
.unwrap()
.exists(),
"a store-only twin opens no repository store"
);
// A fresh data root restored from the twin's log, reopened as the primary it was.
let fresh_dir = tempfile::tempdir().unwrap();
{
let fresh = Arc::new(ServerStores::open(fresh_dir.path()).unwrap());
// `""` = "the stream names no log id", the same value every other call site in
// the tree passes (`twin/tests.rs`). T4 added the parameter in `f80c867` and
// these two gunnar-cli tests were not updated with it, so the crate has not
// compiled its tests since that merge. Not this lane's change; fixed here
// because a lane cannot run its own gate past somebody else's build error.
restore(Arc::clone(twin.txlog()), fresh, "").unwrap();
}
let fresh = ServerStores::open(fresh_dir.path()).unwrap();
assert!(
holds(&fresh, &head),
"the restored primary holds the pushed commit {head}"
);
assert_eq!(
main_of(&fresh).as_deref(),
Some(head_bytes.as_slice()),
"the restored primary's main is the pushed commit"
);
}