#![allow(clippy::unwrap_used)]
mod common;
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use std::sync::Mutex;
use common::Repo;
use mkit_cli::remote_dispatch::{
DispatchError, fetch_all, pull_all, push_all, push_branch_with_depth,
};
use mkit_core::hash::Hash;
use mkit_core::layout::RepoLayout;
use mkit_core::object::Object;
use mkit_core::pack::delta_base_hashes;
use mkit_core::protocol::{PackKey, RefWriteCondition, Transport, TransportResult};
use mkit_core::refs::{self, Ref};
use mkit_core::store::ObjectStore;
use mkit_core::transfer::{self, PackListNode};
use mkit_transport_file::FileTransport;
use mkit_transport_memory::MemoryTransport;
const TEST_DEPTH: usize = 3;
fn head_hash(dir: &Path) -> Hash {
refs::read_ref(&RepoLayout::single(dir), "main")
.unwrap()
.unwrap()
}
fn file_url(dir: &Path) -> String {
format!("mkit+file://{}", dir.display())
}
fn packmap_chain(tx: &dyn Transport, branch: &str) -> Vec<PackListNode> {
let mut nodes = Vec::new();
let mut cursor = tx.read_ref(&format!("refs/mkit/packmap/{branch}")).unwrap();
while let Some(key) = cursor {
let bytes = tx.download_blob(&PackKey::from_hash(key)).unwrap();
let node = transfer::decode_packlist(&bytes).unwrap();
cursor = node.prev;
nodes.push(node);
}
nodes
}
fn chain_is_all_raw(tx: &dyn Transport, chain: &[PackListNode]) -> bool {
chain.iter().flat_map(|n| &n.packs).all(|pack_key| {
let bytes = tx.download_pack(&PackKey::from_hash(*pack_key)).unwrap();
delta_base_hashes(&bytes).unwrap().is_empty()
})
}
fn all_ancestor_commit_hashes(store: &ObjectStore, tip: Hash) -> HashSet<Hash> {
let mut out = HashSet::new();
let mut stack = vec![tip];
while let Some(h) = stack.pop() {
if !out.insert(h) {
continue;
}
if let Object::Commit(c) = store.read_object(&h).unwrap() {
stack.extend(c.parents);
}
}
out
}
#[test]
fn four_pushes_at_depth_3_over_a_non_atomic_transport_never_reset() {
let alice = Repo::new();
let remote = tempfile::tempdir().unwrap();
let url = file_url(remote.path());
alice.ok(&["remote", "add", &url]);
let mut tips = Vec::new();
for i in 0..4u32 {
alice.commit_file("f.txt", i.to_string().as_bytes(), &format!("c{i}"));
tips.push(head_hash(alice.path()));
alice.ok_env(&["push"], &[("MKIT_PACK_REBASELINE_DEPTH", "3")]);
}
let final_tip = *tips.last().unwrap();
let tx = FileTransport::new(remote.path());
let chain = packmap_chain(&tx, "main");
assert_eq!(
chain.len(),
4,
"a non-atomic transport must never reset the chain, even past the \
re-baseline threshold — it must keep appending, one node per push"
);
for (idx, node) in chain.iter().enumerate() {
if idx + 1 < chain.len() {
assert!(
node.prev.is_some(),
"node {idx} unexpectedly reset to prev = None on a non-atomic transport"
);
}
}
assert_eq!(
tx.read_ref("refs/heads/main").unwrap(),
Some(final_tip),
"head must resolve to the latest tip"
);
let dest = tempfile::tempdir().unwrap();
let xdg = tempfile::tempdir().unwrap();
let out = common::mkit(dest.path(), xdg.path(), &["clone", &url, "bob"]);
assert!(out.status.success(), "clone failed: {out:?}");
let bob = dest.path().join("bob");
assert_eq!(head_hash(&bob), final_tip);
let bob_store = ObjectStore::open(&RepoLayout::single(&bob)).unwrap();
let ancestry = all_ancestor_commit_hashes(&bob_store, final_tip);
for tip in &tips {
assert!(
ancestry.contains(tip),
"commit {tip:?} missing from the reconstructed history"
);
}
assert_eq!(ancestry.len(), 4, "all 4 commits, and only those, present");
assert_eq!(fs::read(bob.join("f.txt")).unwrap(), b"3");
}
#[test]
fn two_pushes_below_depth_3_threshold_keep_two_nodes() {
let alice = Repo::new();
let remote = tempfile::tempdir().unwrap();
let url = file_url(remote.path());
alice.ok(&["remote", "add", &url]);
for i in 0..2u32 {
alice.commit_file("f.txt", i.to_string().as_bytes(), &format!("c{i}"));
alice.ok_env(&["push"], &[("MKIT_PACK_REBASELINE_DEPTH", "3")]);
}
let tx = FileTransport::new(remote.path());
let chain = packmap_chain(&tx, "main");
assert_eq!(
chain.len(),
2,
"no premature reset while the chain is still below the threshold"
);
}
#[test]
fn depth_zero_disables_rebaseline() {
let alice = Repo::new();
let remote = tempfile::tempdir().unwrap();
let url = file_url(remote.path());
alice.ok(&["remote", "add", &url]);
for i in 0..4u32 {
alice.commit_file("f.txt", i.to_string().as_bytes(), &format!("c{i}"));
alice.ok_env(&["push"], &[("MKIT_PACK_REBASELINE_DEPTH", "0")]);
}
let tx = FileTransport::new(remote.path());
let chain = packmap_chain(&tx, "main");
assert_eq!(
chain.len(),
4,
"MKIT_PACK_REBASELINE_DEPTH=0 must disable re-baselining"
);
}
struct AtomicTransport {
inner: MemoryTransport,
lock: Mutex<()>,
}
impl AtomicTransport {
fn new() -> Self {
Self {
inner: MemoryTransport::new(),
lock: Mutex::new(()),
}
}
}
fn condition_holds(condition: RefWriteCondition, current: Option<Hash>) -> bool {
match condition {
RefWriteCondition::Any => true,
RefWriteCondition::Missing => current.is_none(),
RefWriteCondition::Match(h) => current == Some(h),
}
}
impl Transport for AtomicTransport {
fn upload_pack(&self, bytes: &[u8], key: &PackKey) -> TransportResult<()> {
self.inner.upload_pack(bytes, key)
}
fn download_pack(&self, key: &PackKey) -> TransportResult<Vec<u8>> {
self.inner.download_pack(key)
}
fn pack_exists(&self, key: &PackKey) -> TransportResult<bool> {
self.inner.pack_exists(key)
}
fn update_ref(
&self,
name: &str,
condition: RefWriteCondition,
hash: &Hash,
) -> TransportResult<()> {
self.inner.update_ref(name, condition, hash)
}
fn read_ref(&self, name: &str) -> TransportResult<Option<Hash>> {
self.inner.read_ref(name)
}
fn list_refs(&self, prefix: &str) -> TransportResult<Vec<Ref>> {
self.inner.list_refs(prefix)
}
fn advance_refs(
&self,
head_ref: &str,
head_condition: RefWriteCondition,
head_value: &Hash,
packmap_ref: &str,
packmap_condition: RefWriteCondition,
packmap_value: &Hash,
) -> TransportResult<mkit_core::protocol::AdvanceOutcome> {
use mkit_core::protocol::AdvanceOutcome;
let _guard = self.lock.lock().unwrap();
if !condition_holds(packmap_condition, self.inner.read_ref(packmap_ref)?) {
return Ok(AdvanceOutcome::PackmapConflict);
}
if !condition_holds(head_condition, self.inner.read_ref(head_ref)?) {
return Ok(AdvanceOutcome::HeadConflict);
}
self.inner
.update_ref(packmap_ref, packmap_condition, packmap_value)?;
self.inner
.update_ref(head_ref, head_condition, head_value)?;
Ok(AdvanceOutcome::Committed)
}
fn supports_atomic_advance(&self) -> bool {
true
}
}
fn grow_chain_to_test_depth(alice: &Repo, tx: &dyn Transport) -> Hash {
for i in 1..TEST_DEPTH {
alice.commit_file("f.txt", i.to_string().as_bytes(), &format!("alice-{i}"));
push_all(alice.path(), tx).unwrap_or_else(|e| panic!("push alice-{i}: {e}"));
}
assert_eq!(packmap_chain(tx, "main").len(), TEST_DEPTH);
head_hash(alice.path())
}
#[test]
#[allow(clippy::too_many_lines)] fn divergent_push_that_would_rebaseline_blocks_then_retry_stays_clonable() {
let alice = Repo::new();
let bob = Repo::new();
let tx = AtomicTransport::new();
alice.commit_file("f.txt", b"0", "base");
push_all(alice.path(), &tx).expect("alice base push");
pull_all(bob.path(), &tx, "default", None).expect("bob clones base");
let shared_tip = head_hash(alice.path());
let alice_tip = grow_chain_to_test_depth(&alice, &tx);
bob.commit_file("f.txt", b"bob-divergent", "bob-divergent");
let bob_tip = head_hash(bob.path());
let bob_store = ObjectStore::open(&RepoLayout::single(bob.path())).unwrap();
let err = push_branch_with_depth(
&tx,
&bob_store,
"main",
bob_tip,
RefWriteCondition::Match(shared_tip),
TEST_DEPTH,
)
.unwrap_err();
assert!(
matches!(err, DispatchError::NonFastForwardPush { .. }),
"expected NonFastForwardPush, got {err:?}"
);
assert_eq!(tx.read_ref("refs/heads/main").unwrap(), Some(alice_tip));
assert_eq!(packmap_chain(&tx, "main").len(), TEST_DEPTH);
let carol = Repo::new();
pull_all(carol.path(), &tx, "default", None)
.expect("remote must stay clonable after the loser's blocked re-baseline");
assert_eq!(
fs::read(carol.path().join("f.txt")).unwrap(),
(TEST_DEPTH - 1).to_string().as_bytes()
);
fetch_all(bob.path(), &tx, "default").expect("bob's retry fetch");
let alice_hex = mkit_core::to_hex(&alice_tip);
bob.ok(&["reset", "--hard", "-f", &alice_hex]);
bob.commit_file("f.txt", b"bob-retry", "bob-retry");
let bob_retry_tip = head_hash(bob.path());
let bob_store2 = ObjectStore::open(&RepoLayout::single(bob.path())).unwrap();
push_branch_with_depth(
&tx,
&bob_store2,
"main",
bob_retry_tip,
RefWriteCondition::Match(alice_tip),
TEST_DEPTH,
)
.expect("bob's retry push should succeed");
assert_eq!(tx.read_ref("refs/heads/main").unwrap(), Some(bob_retry_tip));
let chain = packmap_chain(&tx, "main");
assert_eq!(chain.len(), 1, "retry re-baseline collapses the chain");
assert!(chain_is_all_raw(&tx, &chain));
let dave = Repo::new();
pull_all(dave.path(), &tx, "default", None).expect("clone after the retry re-baseline");
assert_eq!(fs::read(dave.path().join("f.txt")).unwrap(), b"bob-retry");
let dave_tip = head_hash(dave.path());
assert_eq!(dave_tip, bob_retry_tip);
let dave_store = ObjectStore::open(&RepoLayout::single(dave.path())).unwrap();
let ancestry = all_ancestor_commit_hashes(&dave_store, dave_tip);
assert_eq!(
ancestry.len(),
TEST_DEPTH + 1,
"base + {} alice commits + bob's retry commit",
TEST_DEPTH - 1
);
for h in &ancestry {
let bytes = dave_store
.read(h)
.expect("object present and hash-verified");
assert_eq!(
mkit_core::serialize::deserialize(&bytes)
.unwrap()
.id()
.unwrap(),
*h
);
}
}
#[test]
fn force_push_at_threshold_appends_and_never_resets() {
let alice = Repo::new();
let tx = AtomicTransport::new();
alice.commit_file("f.txt", b"0", "base");
push_all(alice.path(), &tx).expect("alice base push");
grow_chain_to_test_depth(&alice, &tx);
alice.commit_file("f.txt", b"forced", "forced");
let forced_tip = head_hash(alice.path());
let store = ObjectStore::open(&RepoLayout::single(alice.path())).unwrap();
push_branch_with_depth(
&tx,
&store,
"main",
forced_tip,
RefWriteCondition::Any,
TEST_DEPTH,
)
.expect("force push at threshold should append, not reset");
let chain = packmap_chain(&tx, "main");
assert_eq!(
chain.len(),
TEST_DEPTH + 1,
"a force push at the threshold must append, not reset to a single node"
);
assert!(
chain[0].prev.is_some(),
"newest node reset to prev = None — a re-baseline leaked through on an `Any` force push"
);
assert_eq!(tx.read_ref("refs/heads/main").unwrap(), Some(forced_tip));
let bob = Repo::new();
pull_all(bob.path(), &tx, "default", None).expect("clone after the force-push append");
assert_eq!(fs::read(bob.path().join("f.txt")).unwrap(), b"forced");
assert_eq!(head_hash(bob.path()), forced_tip);
let bob_store = ObjectStore::open(&RepoLayout::single(bob.path())).unwrap();
let ancestry = all_ancestor_commit_hashes(&bob_store, forced_tip);
assert_eq!(
ancestry.len(),
TEST_DEPTH + 1,
"base + {} appending commits + the forced commit",
TEST_DEPTH - 1
);
}