#![allow(clippy::unwrap_used)]
use std::fs;
use std::sync::atomic::{AtomicUsize, Ordering};
use mkit_cli::remote_dispatch::{fetch_all, push_all};
use mkit_core::hash::Hash;
use mkit_core::layout::RepoLayout;
use mkit_core::protocol::{PackKey, RefWriteCondition, Transport, TransportResult};
use mkit_core::refs::{self, Ref};
use mkit_core::store::ObjectStore;
use mkit_transport_memory::MemoryTransport;
mod common;
use common::Repo;
struct CountingTransport {
inner: MemoryTransport,
pack_downloads: AtomicUsize,
}
impl CountingTransport {
fn new() -> Self {
Self {
inner: MemoryTransport::new(),
pack_downloads: AtomicUsize::new(0),
}
}
fn take_pack_downloads(&self) -> usize {
self.pack_downloads.swap(0, Ordering::SeqCst)
}
}
impl Transport for CountingTransport {
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.pack_downloads.fetch_add(1, Ordering::SeqCst);
self.inner.download_pack(key)
}
fn pack_exists(&self, key: &PackKey) -> TransportResult<bool> {
self.inner.pack_exists(key)
}
fn upload_blob(&self, bytes: &[u8], key: &PackKey) -> TransportResult<()> {
self.inner.upload_blob(bytes, key)
}
fn download_blob(&self, key: &PackKey) -> TransportResult<Vec<u8>> {
self.inner.download_blob(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)
}
}
#[test]
fn remove_deletes_record_and_readd_fetch_has_no_self_heal() {
let origin = Repo::new();
let store_dir = origin.path().join("store");
fs::create_dir_all(&store_dir).unwrap();
let url = format!("mkit+file://{}", store_dir.display());
origin.ok(&["remote", "add", &url]);
origin.commit_file("a.txt", b"v1\n", "c1");
origin.ok(&["push", "--all"]);
origin.commit_file("a.txt", b"v2\n", "c2");
origin.ok(&["push", "--all"]);
let origin_tip = refs::read_ref(&RepoLayout::single(origin.path()), "main")
.unwrap()
.unwrap();
let consumer = Repo::new();
consumer.commit_file("local.txt", b"l\n", "local base");
consumer.ok(&["remote", "add", "up", &url]);
consumer.ok(&["fetch", "up"]);
let record = consumer.mkit_dir().join("applied-packs").join("up");
assert!(
record.is_file(),
"fetch must create the applied-packs record"
);
consumer.ok(&["remote", "remove", "up"]);
assert!(
!record.exists(),
"remote remove must delete the applied-packs record"
);
consumer.ok(&["gc", "--grace-secs", "0"]);
let consumer_layout = RepoLayout::single(consumer.path());
let consumer_store = ObjectStore::open(&consumer_layout).unwrap();
assert!(
consumer_store.read(&origin_tip).is_err(),
"precondition: gc must have pruned the removed remote's objects, \
or the no-self-heal assertion below is vacuous"
);
consumer.ok(&["remote", "add", "up", &url]);
let out = consumer.ok(&["fetch", "up"]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("looks stale"),
"re-add after remove must not trip the stale-record self-heal: {stderr}"
);
let tracked = refs::read_remote_ref(&consumer_layout, "up", "main")
.unwrap()
.unwrap();
assert_eq!(tracked, origin_tip, "re-fetch must land on origin's tip");
assert!(
record.is_file() && !fs::read(&record).unwrap().is_empty(),
"the re-fetch must rebuild a fresh record"
);
}
#[test]
fn stale_record_surviving_remove_trips_self_heal_note() {
let origin = Repo::new();
let store_dir = origin.path().join("store");
fs::create_dir_all(&store_dir).unwrap();
let url = format!("mkit+file://{}", store_dir.display());
origin.ok(&["remote", "add", &url]);
origin.commit_file("a.txt", b"v1\n", "c1");
origin.ok(&["push", "--all"]);
origin.commit_file("a.txt", b"v2\n", "c2");
origin.ok(&["push", "--all"]);
let origin_tip = refs::read_ref(&RepoLayout::single(origin.path()), "main")
.unwrap()
.unwrap();
let consumer = Repo::new();
consumer.commit_file("local.txt", b"l\n", "local base");
consumer.ok(&["remote", "add", "up", &url]);
consumer.ok(&["fetch", "up"]);
let record = consumer.mkit_dir().join("applied-packs").join("up");
let record_bytes = fs::read(&record).unwrap();
assert!(
!record_bytes.is_empty(),
"precondition: record must list applied packs"
);
consumer.ok(&["remote", "remove", "up"]);
consumer.ok(&["gc", "--grace-secs", "0"]);
let consumer_layout = RepoLayout::single(consumer.path());
let consumer_store = ObjectStore::open(&consumer_layout).unwrap();
assert!(
consumer_store.read(&origin_tip).is_err(),
"precondition: gc must have pruned the removed remote's objects, \
or the self-heal assertion below is vacuous"
);
consumer.ok(&["remote", "add", "up", &url]);
fs::create_dir_all(record.parent().unwrap()).unwrap();
fs::write(&record, &record_bytes).unwrap();
let out = consumer.ok(&["fetch", "up"]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("looks stale"),
"a stale record over a pruned store must trip the self-heal note: {stderr}"
);
let tracked = refs::read_remote_ref(&consumer_layout, "up", "main")
.unwrap()
.unwrap();
assert_eq!(
tracked, origin_tip,
"self-heal must recover the fetch to origin's tip"
);
}
#[test]
fn rename_moves_record_and_next_fetch_reuses_it() {
let alice = Repo::new();
let bob = Repo::new();
let tx = CountingTransport::new();
alice.commit_file("a.txt", b"v1", "c1");
push_all(alice.path(), &tx).expect("push 1");
alice.commit_file("a.txt", b"v2", "c2");
push_all(alice.path(), &tx).expect("push 2");
fetch_all(bob.path(), &tx, "team/upstream").expect("fetch 1");
assert!(tx.take_pack_downloads() >= 1);
fetch_all(bob.path(), &tx, "team/upstream").expect("fetch 2 (steady state)");
assert_eq!(tx.take_pack_downloads(), 0);
bob.ok(&["remote", "add", "team/upstream", "mkit+memory://unused"]);
bob.ok(&["remote", "rename", "team/upstream", "archive/upstream"]);
let dir = bob.mkit_dir().join("applied-packs");
let names: Vec<String> = fs::read_dir(&dir)
.unwrap()
.filter_map(Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(
names,
vec!["archive%2Fupstream".to_owned()],
"rename must move the record and leave no orphan"
);
fetch_all(bob.path(), &tx, "archive/upstream").expect("fetch under new name");
assert_eq!(
tx.take_pack_downloads(),
0,
"a steady-state fetch under the renamed remote must reuse the moved \
record and download zero packs"
);
alice.commit_file("a.txt", b"v3", "c3");
push_all(alice.path(), &tx).expect("push 3");
fetch_all(bob.path(), &tx, "archive/upstream").expect("incremental fetch");
assert_eq!(tx.take_pack_downloads(), 1);
}
#[test]
fn remove_default_remote_deletes_its_record() {
let alice = Repo::new();
let bob = Repo::new();
let tx = CountingTransport::new();
alice.commit_file("a.txt", b"v1", "c1");
push_all(alice.path(), &tx).expect("push");
fetch_all(bob.path(), &tx, "default").expect("fetch");
let record = bob.mkit_dir().join("applied-packs").join("default");
assert!(record.is_file(), "precondition: default record exists");
bob.ok(&["remote", "add", "mkit+memory://unused"]); bob.ok(&["remote", "remove", "default"]);
assert!(
!record.exists(),
"removing the default remote must delete its applied-packs record"
);
}
#[test]
fn remove_and_rename_are_non_fatal_when_record_missing() {
let r = Repo::new();
r.ok(&["remote", "add", "up", "mkit+file:///tmp/nowhere"]);
let out = r.ok(&["remote", "remove", "up"]);
assert!(
!String::from_utf8_lossy(&out.stderr).contains("applied-packs"),
"missing record must not warn on remove"
);
r.ok(&["remote", "add", "old", "mkit+file:///tmp/nowhere"]);
let out = r.ok(&["remote", "rename", "old", "new"]);
assert!(
!String::from_utf8_lossy(&out.stderr).contains("applied-packs"),
"missing record must not warn on rename"
);
}