use std::path::Path;
use std::process::Command;
use grit_lib::objects::ObjectId;
use grit_lib::push_report::PushRefStatus;
use grit_lib::transfer::{push_local, PushOptions, PushRefSpec};
fn git(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.current_dir(dir)
.args(args)
.env("GIT_AUTHOR_NAME", "T")
.env("GIT_AUTHOR_EMAIL", "t@example.com")
.env("GIT_AUTHOR_DATE", "2005-04-07T22:13:13 +0200")
.env("GIT_COMMITTER_NAME", "T")
.env("GIT_COMMITTER_EMAIL", "t@example.com")
.env("GIT_COMMITTER_DATE", "2005-04-07T22:13:13 +0200")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("run git");
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).expect("utf8 git output")
}
fn git_try(dir: &Path, args: &[&str]) -> (bool, String) {
let out = Command::new("git")
.current_dir(dir)
.args(args)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("run git");
let mut combined = String::from_utf8_lossy(&out.stdout).into_owned();
combined.push_str(&String::from_utf8_lossy(&out.stderr));
(out.status.success(), combined)
}
fn rev_parse(dir: &Path, rev: &str) -> ObjectId {
ObjectId::from_hex(git(dir, &["rev-parse", rev]).trim()).expect("valid oid")
}
fn remote_ref(remote_git: &Path, dst: &str) -> Option<ObjectId> {
let (ok, out) = git_try(remote_git, &["rev-parse", "--verify", "-q", dst]);
if ok {
ObjectId::from_hex(out.trim()).ok()
} else {
None
}
}
fn fsck_clean(remote_git: &Path) {
let (ok, out) = git_try(remote_git, &["fsck", "--strict"]);
assert!(ok, "git fsck on remote not clean: {out}");
}
#[test]
fn push_local_full_lifecycle() {
let tmp = tempfile::tempdir().expect("tempdir");
let remote = tmp.path().join("remote.git");
let local = tmp.path().join("local");
std::fs::create_dir_all(&remote).unwrap();
std::fs::create_dir_all(&local).unwrap();
git(&remote, &["init", "-q", "--bare", "-b", "main", "."]);
let remote_git = remote.as_path();
git(&local, &["init", "-q", "-b", "main", "."]);
let local_git = local.join(".git");
std::fs::write(local.join("a.txt"), "one\n").unwrap();
git(&local, &["add", "a.txt"]);
git(&local, &["commit", "-q", "-m", "c1"]);
std::fs::write(local.join("b.txt"), "two\n").unwrap();
git(&local, &["add", "b.txt"]);
git(&local, &["commit", "-q", "-m", "c2"]);
let c2 = rev_parse(&local, "refs/heads/main");
let c1 = rev_parse(&local, "HEAD~1");
let b_blob = rev_parse(&local, "HEAD:b.txt");
let outcome = push_local(
&local_git,
remote_git,
&[PushRefSpec {
src: Some(c2),
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
}],
&PushOptions::default(),
)
.expect("push main");
assert_eq!(outcome.results.len(), 1);
let r = &outcome.results[0];
assert_eq!(r.status, PushRefStatus::Ok);
assert!(!r.forced);
assert_eq!(r.new_oid, Some(c2));
assert!(r.old_oid.is_none(), "new ref has no old oid");
assert_eq!(remote_ref(remote_git, "refs/heads/main"), Some(c2));
assert!(git_try(remote_git, &["cat-file", "-e", &c2.to_hex()]).0);
assert!(git_try(remote_git, &["cat-file", "-e", &c1.to_hex()]).0);
assert!(
git_try(remote_git, &["cat-file", "-e", &b_blob.to_hex()]).0,
"b.txt blob copied to remote"
);
fsck_clean(remote_git);
git(&local, &["reset", "-q", "--hard", "HEAD~1"]);
std::fs::write(local.join("d.txt"), "four\n").unwrap();
git(&local, &["add", "d.txt"]);
git(&local, &["commit", "-q", "-m", "c2-prime"]);
let c2_prime = rev_parse(&local, "refs/heads/main");
assert_ne!(c2_prime, c2);
let (anc, _) = git_try(&local, &["merge-base", "--is-ancestor", &c2.to_hex(), &c2_prime.to_hex()]);
assert!(!anc, "rewrite must be non-fast-forward");
let outcome = push_local(
&local_git,
remote_git,
&[PushRefSpec {
src: Some(c2_prime),
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
}],
&PushOptions::default(),
)
.expect("non-ff push call");
assert_eq!(
outcome.results[0].status,
PushRefStatus::RejectNonFastForward
);
assert_eq!(remote_ref(remote_git, "refs/heads/main"), Some(c2));
let outcome = push_local(
&local_git,
remote_git,
&[PushRefSpec {
src: Some(c2_prime),
dst: "refs/heads/main".to_owned(),
force: true,
delete: false,
expected_old: None,
expect_absent: false,
}],
&PushOptions::default(),
)
.expect("forced push call");
assert_eq!(outcome.results[0].status, PushRefStatus::Ok);
assert!(outcome.results[0].forced, "forced flag set");
assert_eq!(remote_ref(remote_git, "refs/heads/main"), Some(c2_prime));
assert!(git_try(remote_git, &["cat-file", "-e", &c2_prime.to_hex()]).0);
fsck_clean(remote_git);
let outcome = push_local(
&local_git,
remote_git,
&[PushRefSpec {
src: None,
dst: "refs/heads/main".to_owned(),
force: false,
delete: true,
expected_old: None,
expect_absent: false,
}],
&PushOptions::default(),
)
.expect("delete push call");
assert_eq!(outcome.results[0].status, PushRefStatus::Ok);
assert!(outcome.results[0].deletion);
assert_eq!(remote_ref(remote_git, "refs/heads/main"), None, "ref deleted");
git(
remote_git,
&["update-ref", "refs/heads/cas", &c1.to_hex()],
);
assert_eq!(remote_ref(remote_git, "refs/heads/cas"), Some(c1));
let outcome = push_local(
&local_git,
remote_git,
&[PushRefSpec {
src: Some(c2_prime),
dst: "refs/heads/cas".to_owned(),
force: true,
delete: false,
expected_old: Some(c2),
expect_absent: false,
}],
&PushOptions::default(),
)
.expect("cas push call");
assert_eq!(outcome.results[0].status, PushRefStatus::RejectStale);
assert_eq!(remote_ref(remote_git, "refs/heads/cas"), Some(c1));
let outcome = push_local(
&local_git,
remote_git,
&[PushRefSpec {
src: Some(c2_prime),
dst: "refs/heads/cas".to_owned(),
force: true,
delete: false,
expected_old: Some(c1),
expect_absent: false,
}],
&PushOptions::default(),
)
.expect("cas push call (matching)");
assert_eq!(outcome.results[0].status, PushRefStatus::Ok);
assert_eq!(remote_ref(remote_git, "refs/heads/cas"), Some(c2_prime));
fsck_clean(remote_git);
}
#[test]
fn push_local_atomic_rejects_all_on_any_failure() {
let tmp = tempfile::tempdir().expect("tempdir");
let remote = tmp.path().join("remote.git");
let local = tmp.path().join("local");
std::fs::create_dir_all(&remote).unwrap();
std::fs::create_dir_all(&local).unwrap();
git(&remote, &["init", "-q", "--bare", "-b", "main", "."]);
let remote_git = remote.as_path();
git(&local, &["init", "-q", "-b", "main", "."]);
let local_git = local.join(".git");
std::fs::write(local.join("a.txt"), "one\n").unwrap();
git(&local, &["add", "a.txt"]);
git(&local, &["commit", "-q", "-m", "c1"]);
let c1 = rev_parse(&local, "refs/heads/main");
std::fs::write(local.join("b.txt"), "two\n").unwrap();
git(&local, &["add", "b.txt"]);
git(&local, &["commit", "-q", "-m", "c2"]);
let c2 = rev_parse(&local, "refs/heads/main");
push_local(
&local_git,
remote_git,
&[PushRefSpec {
src: Some(c1),
dst: "refs/heads/locked".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
}],
&PushOptions::default(),
)
.expect("seed locked ref");
assert_eq!(remote_ref(remote_git, "refs/heads/locked"), Some(c1));
let outcome = push_local(
&local_git,
remote_git,
&[
PushRefSpec {
src: Some(c2),
dst: "refs/heads/fresh".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
},
PushRefSpec {
src: Some(c2),
dst: "refs/heads/locked".to_owned(),
force: false,
delete: false,
expected_old: Some(c2), expect_absent: false,
},
],
&PushOptions {
atomic: true,
dry_run: false,
..PushOptions::default()
},
)
.expect("atomic push call");
let fresh = outcome
.results
.iter()
.find(|r| r.remote_ref == "refs/heads/fresh")
.unwrap();
let locked = outcome
.results
.iter()
.find(|r| r.remote_ref == "refs/heads/locked")
.unwrap();
assert_eq!(locked.status, PushRefStatus::RejectStale);
assert_eq!(
fresh.status,
PushRefStatus::AtomicPushFailed,
"accepted ref demoted under atomic failure"
);
assert_eq!(remote_ref(remote_git, "refs/heads/fresh"), None);
assert_eq!(remote_ref(remote_git, "refs/heads/locked"), Some(c1));
fsck_clean(remote_git);
}