use std::collections::HashSet;
use std::fs;
use std::process::Command;
use std::sync::Arc;
use mkit_cli::remote_dispatch::{DispatchError, fetch_all, pull_all, push_all};
use mkit_core::ops::reachable_objects;
use mkit_core::refs;
use mkit_core::store::ObjectStore;
use mkit_transport_memory::MemoryTransport;
fn mkit_bin() -> &'static str {
env!("CARGO_BIN_EXE_mkit")
}
fn run_in(cwd: &std::path::Path, args: &[&str]) -> std::process::Output {
let xdg = tempfile::tempdir().expect("xdg tempdir");
let out = Command::new(mkit_bin())
.args(args)
.current_dir(cwd)
.env("XDG_CONFIG_HOME", xdg.path())
.output()
.expect("spawn mkit");
drop(xdg);
out
}
#[test]
fn push_pull_transfers_full_object_closure_via_memory_transport() {
let alice = tempfile::tempdir().unwrap();
let bob = tempfile::tempdir().unwrap();
assert!(run_in(alice.path(), &["init"]).status.success());
assert!(run_in(alice.path(), &["keygen"]).status.success());
assert!(run_in(bob.path(), &["init"]).status.success());
assert!(run_in(bob.path(), &["keygen"]).status.success());
fs::write(alice.path().join("README.md"), b"# project\n").unwrap();
fs::create_dir_all(alice.path().join("src")).unwrap();
fs::write(alice.path().join("src/main.rs"), b"fn main() {}\n").unwrap();
assert!(run_in(alice.path(), &["add", "."]).status.success());
assert!(
run_in(alice.path(), &["commit", "-m", "alice-1"])
.status
.success()
);
let tx: Arc<MemoryTransport> = Arc::new(MemoryTransport::new());
let pushed = push_all(alice.path(), tx.as_ref()).expect("push");
assert!(pushed >= 1);
let pulled = pull_all(bob.path(), tx.as_ref(), "default").expect("pull");
assert_eq!(pulled, pushed);
let alice_mkit = alice.path().join(".mkit");
let bob_mkit = bob.path().join(".mkit");
let alice_tip = refs::read_ref(&alice_mkit, "main").unwrap().unwrap();
let bob_tip = refs::read_ref(&bob_mkit, "main").unwrap().unwrap();
assert_eq!(alice_tip, bob_tip);
let alice_store = ObjectStore::open(alice.path()).unwrap();
let bob_store = ObjectStore::open(bob.path()).unwrap();
let alice_set: HashSet<_> = reachable_objects(&alice_store, &alice_tip)
.unwrap()
.into_iter()
.collect();
let bob_set: HashSet<_> = reachable_objects(&bob_store, &bob_tip)
.unwrap()
.into_iter()
.collect();
assert_eq!(alice_set, bob_set, "object closures must match after pull");
assert!(
alice_set.len() >= 4,
"closure must include ≥ commit+tree+2 blobs"
);
}
#[test]
fn fetch_all_does_not_move_bob_head() {
let alice = tempfile::tempdir().unwrap();
let bob = tempfile::tempdir().unwrap();
assert!(run_in(alice.path(), &["init"]).status.success());
assert!(run_in(alice.path(), &["keygen"]).status.success());
assert!(run_in(bob.path(), &["init"]).status.success());
assert!(run_in(bob.path(), &["keygen"]).status.success());
fs::write(alice.path().join("a.txt"), b"alpha").unwrap();
assert!(run_in(alice.path(), &["add", "."]).status.success());
assert!(
run_in(alice.path(), &["commit", "-m", "alice-1"])
.status
.success()
);
let tx: Arc<MemoryTransport> = Arc::new(MemoryTransport::new());
let _ = push_all(alice.path(), tx.as_ref()).unwrap();
let bob_mkit = bob.path().join(".mkit");
let head_before = fs::read_to_string(bob_mkit.join("HEAD")).unwrap_or_default();
let n = fetch_all(bob.path(), tx.as_ref(), "default").expect("fetch");
assert!(n >= 1);
let head_after = fs::read_to_string(bob_mkit.join("HEAD")).unwrap_or_default();
assert_eq!(head_before, head_after, "fetch MUST NOT rewrite HEAD");
assert!(!bob_mkit.join("refs/heads/main").exists());
assert!(bob_mkit.join("refs/remotes/default/main").exists());
}
#[test]
fn fetch_all_does_not_overwrite_local_branch() {
let alice = tempfile::tempdir().unwrap();
let bob = tempfile::tempdir().unwrap();
assert!(run_in(alice.path(), &["init"]).status.success());
assert!(run_in(alice.path(), &["keygen"]).status.success());
assert!(run_in(bob.path(), &["init"]).status.success());
assert!(run_in(bob.path(), &["keygen"]).status.success());
fs::write(alice.path().join("a.txt"), b"alice").unwrap();
assert!(run_in(alice.path(), &["add", "."]).status.success());
assert!(
run_in(alice.path(), &["commit", "-m", "alice"])
.status
.success()
);
fs::write(bob.path().join("b.txt"), b"bob").unwrap();
assert!(run_in(bob.path(), &["add", "."]).status.success());
assert!(
run_in(bob.path(), &["commit", "-m", "bob"])
.status
.success()
);
let tx: Arc<MemoryTransport> = Arc::new(MemoryTransport::new());
let _ = push_all(alice.path(), tx.as_ref()).unwrap();
let bob_mkit = bob.path().join(".mkit");
let bob_before = refs::read_ref(&bob_mkit, "main").unwrap().unwrap();
let n = fetch_all(bob.path(), tx.as_ref(), "default").expect("fetch");
assert!(n >= 1);
let bob_after = refs::read_ref(&bob_mkit, "main").unwrap().unwrap();
let remote_main = refs::read_remote_ref(&bob_mkit, "default", "main")
.unwrap()
.unwrap();
assert_eq!(bob_before, bob_after, "fetch must not move local main");
assert_ne!(bob_after, remote_main, "remote-tracking ref should differ");
}
#[test]
fn pull_all_fast_forwards_current_branch() {
let alice = tempfile::tempdir().unwrap();
let bob = tempfile::tempdir().unwrap();
assert!(run_in(alice.path(), &["init"]).status.success());
assert!(run_in(alice.path(), &["keygen"]).status.success());
assert!(run_in(bob.path(), &["init"]).status.success());
assert!(run_in(bob.path(), &["keygen"]).status.success());
fs::write(alice.path().join("a.txt"), b"v1").unwrap();
assert!(run_in(alice.path(), &["add", "."]).status.success());
assert!(
run_in(alice.path(), &["commit", "-m", "v1"])
.status
.success()
);
let tx: Arc<MemoryTransport> = Arc::new(MemoryTransport::new());
let _ = push_all(alice.path(), tx.as_ref()).unwrap();
pull_all(bob.path(), tx.as_ref(), "default").expect("initial pull");
fs::write(alice.path().join("a.txt"), b"v2").unwrap();
assert!(run_in(alice.path(), &["add", "."]).status.success());
assert!(
run_in(alice.path(), &["commit", "-m", "v2"])
.status
.success()
);
let _ = push_all(alice.path(), tx.as_ref()).unwrap();
pull_all(bob.path(), tx.as_ref(), "default").expect("fast-forward pull");
let alice_tip = refs::read_ref(&alice.path().join(".mkit"), "main")
.unwrap()
.unwrap();
let bob_tip = refs::read_ref(&bob.path().join(".mkit"), "main")
.unwrap()
.unwrap();
assert_eq!(alice_tip, bob_tip);
assert_eq!(fs::read(bob.path().join("a.txt")).unwrap(), b"v2");
}
#[test]
fn pull_all_refuses_divergent_current_branch() {
let alice = tempfile::tempdir().unwrap();
let bob = tempfile::tempdir().unwrap();
assert!(run_in(alice.path(), &["init"]).status.success());
assert!(run_in(alice.path(), &["keygen"]).status.success());
assert!(run_in(bob.path(), &["init"]).status.success());
assert!(run_in(bob.path(), &["keygen"]).status.success());
fs::write(alice.path().join("a.txt"), b"v1").unwrap();
assert!(run_in(alice.path(), &["add", "."]).status.success());
assert!(
run_in(alice.path(), &["commit", "-m", "v1"])
.status
.success()
);
let tx: Arc<MemoryTransport> = Arc::new(MemoryTransport::new());
let _ = push_all(alice.path(), tx.as_ref()).unwrap();
pull_all(bob.path(), tx.as_ref(), "default").expect("initial pull");
fs::write(alice.path().join("a.txt"), b"alice v2").unwrap();
assert!(run_in(alice.path(), &["add", "."]).status.success());
assert!(
run_in(alice.path(), &["commit", "-m", "alice-v2"])
.status
.success()
);
let _ = push_all(alice.path(), tx.as_ref()).unwrap();
fs::write(bob.path().join("a.txt"), b"bob v2").unwrap();
assert!(run_in(bob.path(), &["add", "."]).status.success());
assert!(
run_in(bob.path(), &["commit", "-m", "bob-v2"])
.status
.success()
);
let bob_tip = refs::read_ref(&bob.path().join(".mkit"), "main")
.unwrap()
.unwrap();
let err = pull_all(bob.path(), tx.as_ref(), "default").unwrap_err();
assert!(matches!(err, DispatchError::NonFastForwardPull { .. }));
assert_eq!(
refs::read_ref(&bob.path().join(".mkit"), "main")
.unwrap()
.unwrap(),
bob_tip
);
}
#[test]
fn pull_all_refuses_dirty_worktree_before_fast_forward() {
let alice = tempfile::tempdir().unwrap();
let bob = tempfile::tempdir().unwrap();
assert!(run_in(alice.path(), &["init"]).status.success());
assert!(run_in(alice.path(), &["keygen"]).status.success());
assert!(run_in(bob.path(), &["init"]).status.success());
assert!(run_in(bob.path(), &["keygen"]).status.success());
fs::write(alice.path().join("a.txt"), b"v1").unwrap();
assert!(run_in(alice.path(), &["add", "."]).status.success());
assert!(
run_in(alice.path(), &["commit", "-m", "v1"])
.status
.success()
);
let tx: Arc<MemoryTransport> = Arc::new(MemoryTransport::new());
let _ = push_all(alice.path(), tx.as_ref()).unwrap();
pull_all(bob.path(), tx.as_ref(), "default").expect("initial pull");
fs::write(alice.path().join("a.txt"), b"v2").unwrap();
assert!(run_in(alice.path(), &["add", "."]).status.success());
assert!(
run_in(alice.path(), &["commit", "-m", "v2"])
.status
.success()
);
let _ = push_all(alice.path(), tx.as_ref()).unwrap();
fs::write(bob.path().join("a.txt"), b"local dirty").unwrap();
let bob_tip = refs::read_ref(&bob.path().join(".mkit"), "main")
.unwrap()
.unwrap();
let err = pull_all(bob.path(), tx.as_ref(), "default").unwrap_err();
assert!(matches!(err, DispatchError::RestoreSafety(_)));
assert_eq!(fs::read(bob.path().join("a.txt")).unwrap(), b"local dirty");
assert_eq!(
refs::read_ref(&bob.path().join(".mkit"), "main")
.unwrap()
.unwrap(),
bob_tip
);
}
#[cfg(unix)]
#[test]
fn pull_all_ref_update_failure_does_not_restore_worktree() {
use std::os::unix::fs::PermissionsExt;
let alice = tempfile::tempdir().unwrap();
let bob = tempfile::tempdir().unwrap();
assert!(run_in(alice.path(), &["init"]).status.success());
assert!(run_in(alice.path(), &["keygen"]).status.success());
assert!(run_in(bob.path(), &["init"]).status.success());
assert!(run_in(bob.path(), &["keygen"]).status.success());
fs::write(alice.path().join("a.txt"), b"v1").unwrap();
assert!(run_in(alice.path(), &["add", "."]).status.success());
assert!(
run_in(alice.path(), &["commit", "-m", "v1"])
.status
.success()
);
let tx: Arc<MemoryTransport> = Arc::new(MemoryTransport::new());
let _ = push_all(alice.path(), tx.as_ref()).unwrap();
pull_all(bob.path(), tx.as_ref(), "default").expect("initial pull");
fs::write(alice.path().join("a.txt"), b"v2").unwrap();
assert!(run_in(alice.path(), &["add", "."]).status.success());
assert!(
run_in(alice.path(), &["commit", "-m", "v2"])
.status
.success()
);
let _ = push_all(alice.path(), tx.as_ref()).unwrap();
let bob_mkit = bob.path().join(".mkit");
let bob_tip = refs::read_ref(&bob_mkit, "main").unwrap().unwrap();
let heads_dir = bob_mkit.join("refs/heads");
let original_perms = fs::metadata(&heads_dir).unwrap().permissions();
let mut readonly = original_perms.clone();
readonly.set_mode(0o555);
fs::set_permissions(&heads_dir, readonly).unwrap();
let err = pull_all(bob.path(), tx.as_ref(), "default").unwrap_err();
fs::set_permissions(&heads_dir, original_perms).unwrap();
assert!(
matches!(err, DispatchError::Refs(_)),
"unexpected error: {err}"
);
assert_eq!(fs::read(bob.path().join("a.txt")).unwrap(), b"v1");
assert_eq!(refs::read_ref(&bob_mkit, "main").unwrap().unwrap(), bob_tip);
}
#[test]
fn pull_all_preserves_ignored_untracked_files() {
let alice = tempfile::tempdir().unwrap();
let bob = tempfile::tempdir().unwrap();
assert!(run_in(alice.path(), &["init"]).status.success());
assert!(run_in(alice.path(), &["keygen"]).status.success());
assert!(run_in(bob.path(), &["init"]).status.success());
assert!(run_in(bob.path(), &["keygen"]).status.success());
fs::write(alice.path().join("a.txt"), b"v1").unwrap();
assert!(run_in(alice.path(), &["add", "."]).status.success());
assert!(
run_in(alice.path(), &["commit", "-m", "v1"])
.status
.success()
);
let tx: Arc<MemoryTransport> = Arc::new(MemoryTransport::new());
let _ = push_all(alice.path(), tx.as_ref()).unwrap();
pull_all(bob.path(), tx.as_ref(), "default").expect("initial pull");
fs::write(alice.path().join("a.txt"), b"v2").unwrap();
assert!(run_in(alice.path(), &["add", "."]).status.success());
assert!(
run_in(alice.path(), &["commit", "-m", "v2"])
.status
.success()
);
let _ = push_all(alice.path(), tx.as_ref()).unwrap();
fs::write(bob.path().join(".mkitignore"), "local.txt\n").unwrap();
fs::write(bob.path().join("local.txt"), b"local only\n").unwrap();
pull_all(bob.path(), tx.as_ref(), "default").expect("fast-forward pull");
assert_eq!(fs::read(bob.path().join("a.txt")).unwrap(), b"v2");
assert_eq!(
fs::read(bob.path().join("local.txt")).unwrap(),
b"local only\n"
);
assert_eq!(
fs::read_to_string(bob.path().join(".mkitignore")).unwrap(),
"local.txt\n"
);
}