use std::path::Path;
use grit_lib::config::environment::RepositoryOptions;
use grit_lib::objects::ObjectId;
use grit_lib::remote::push_report::PushRefStatus;
use grit_lib::remote::transfer::{push_local, PushOptions, PushRefSpec};
use grit_lib::remote::transport_path::resolve_local_remote_git_dir;
use grit_lib::repo::Repository;
use grit_test_support::{git, git_cmd};
fn git_try(dir: &Path, args: &[&str]) -> (bool, String) {
let out = git_cmd(args).in_dir(dir).exec();
let ok = out.ok();
let mut combined = out.stdout;
combined.push_str(&out.stderr);
(ok, 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),
src_ref: None,
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
}],
&PushOptions::default(),
&grit_lib::progress::NeverCancel,
)
.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),
src_ref: None,
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
}],
&PushOptions::default(),
&grit_lib::progress::NeverCancel,
)
.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),
src_ref: None,
dst: "refs/heads/main".to_owned(),
force: true,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
}],
&PushOptions::default(),
&grit_lib::progress::NeverCancel,
)
.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,
src_ref: None,
dst: "refs/heads/main".to_owned(),
force: false,
delete: true,
expected_old: None,
expect_absent: false,
..Default::default()
}],
&PushOptions::default(),
&grit_lib::progress::NeverCancel,
)
.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),
src_ref: None,
dst: "refs/heads/cas".to_owned(),
force: true,
delete: false,
expected_old: Some(c2),
expect_absent: false,
..Default::default()
}],
&PushOptions::default(),
&grit_lib::progress::NeverCancel,
)
.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),
src_ref: None,
dst: "refs/heads/cas".to_owned(),
force: true,
delete: false,
expected_old: Some(c1),
expect_absent: false,
..Default::default()
}],
&PushOptions::default(),
&grit_lib::progress::NeverCancel,
)
.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),
src_ref: None,
dst: "refs/heads/locked".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
}],
&PushOptions::default(),
&grit_lib::progress::NeverCancel,
)
.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),
src_ref: None,
dst: "refs/heads/fresh".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
},
PushRefSpec {
src: Some(c2),
src_ref: None,
dst: "refs/heads/locked".to_owned(),
force: false,
delete: false,
expected_old: Some(c2), expect_absent: false,
..Default::default()
},
],
&PushOptions {
atomic: true,
dry_run: false,
..PushOptions::default()
},
&grit_lib::progress::NeverCancel,
)
.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);
}
#[test]
fn push_local_non_atomic_df_applies_first_ref_only() {
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("f.txt"), "x\n").unwrap();
git(&local, &["add", "f.txt"]);
git(&local, &["commit", "-q", "-m", "tip"]);
let tip = rev_parse(&local, "refs/heads/main");
let outcome = push_local(
&local_git,
remote_git,
&[
PushRefSpec {
src: Some(tip),
src_ref: None,
dst: "refs/heads/parent".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
},
PushRefSpec {
src: Some(tip),
src_ref: None,
dst: "refs/heads/parent/child".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
},
],
&PushOptions::default(),
&grit_lib::progress::NeverCancel,
)
.expect("non-atomic df push");
let parent = outcome
.results
.iter()
.find(|r| r.remote_ref == "refs/heads/parent")
.expect("parent result");
let child = outcome
.results
.iter()
.find(|r| r.remote_ref == "refs/heads/parent/child")
.expect("child result");
assert_eq!(parent.status, PushRefStatus::Ok);
assert_eq!(child.status, PushRefStatus::RemoteRejected);
assert_eq!(remote_ref(remote_git, "refs/heads/parent"), Some(tip));
assert_eq!(remote_ref(remote_git, "refs/heads/parent/child"), None);
fsck_clean(remote_git);
}
#[test]
fn push_local_atomic_store_batch_df_marks_atomic_push_failed() {
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("f.txt"), "x\n").unwrap();
git(&local, &["add", "f.txt"]);
git(&local, &["commit", "-q", "-m", "tip"]);
let tip = rev_parse(&local, "refs/heads/main");
let outcome = push_local(
&local_git,
remote_git,
&[
PushRefSpec {
src: Some(tip),
src_ref: None,
dst: "refs/heads/parent".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
},
PushRefSpec {
src: Some(tip),
src_ref: None,
dst: "refs/heads/parent/child".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
},
],
&PushOptions {
atomic: true,
..PushOptions::default()
},
&grit_lib::progress::NeverCancel,
)
.expect("atomic df push");
let parent = outcome
.results
.iter()
.find(|r| r.remote_ref == "refs/heads/parent")
.expect("parent result");
let child = outcome
.results
.iter()
.find(|r| r.remote_ref == "refs/heads/parent/child")
.expect("child result");
assert_eq!(
child.status,
PushRefStatus::RemoteRejected,
"D/F batch should reject the nested ref"
);
assert_eq!(
parent.status,
PushRefStatus::AtomicPushFailed,
"otherwise-accepted ref demoted when atomic batch fails"
);
assert_eq!(remote_ref(remote_git, "refs/heads/parent"), None);
assert_eq!(remote_ref(remote_git, "refs/heads/parent/child"), None);
}
#[test]
fn push_local_updates_remote_tracking_ref_when_configured() {
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");
push_local(
&local_git,
remote_git,
&[PushRefSpec {
src: Some(c1),
src_ref: None,
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
}],
&PushOptions {
tracking_remote: Some("origin".to_owned()),
..PushOptions::default()
},
&grit_lib::progress::NeverCancel,
)
.expect("push with tracking");
assert_eq!(
remote_ref(&local_git, "refs/remotes/origin/main"),
Some(c1),
"local remote-tracking ref should match the pushed tip"
);
}
#[test]
fn push_local_up_to_date_refreshes_stale_remote_tracking_ref() {
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");
git(
&local,
&["remote", "add", "origin", remote_git.to_str().unwrap()],
);
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 c1 = rev_parse(&local, "HEAD~1");
let c2 = rev_parse(&local, "refs/heads/main");
push_local(
&local_git,
remote_git,
&[PushRefSpec {
src: Some(c2),
src_ref: None,
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
}],
&PushOptions {
tracking_remote: Some("origin".to_owned()),
..PushOptions::default()
},
&grit_lib::progress::NeverCancel,
)
.expect("initial push");
grit_lib::refs::write_ref(&local_git, "refs/remotes/origin/main", &c1).expect("stale tracking");
let outcome = push_local(
&local_git,
remote_git,
&[PushRefSpec {
src: Some(c2),
src_ref: None,
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
}],
&PushOptions {
tracking_remote: Some("origin".to_owned()),
..PushOptions::default()
},
&grit_lib::progress::NeverCancel,
)
.expect("up-to-date push");
assert_eq!(outcome.results[0].status, PushRefStatus::UpToDate);
assert_eq!(
remote_ref(&local_git, "refs/remotes/origin/main"),
Some(c2),
"up-to-date push should refresh stale remote-tracking ref"
);
}
#[test]
fn push_local_honors_custom_fetch_refspec_for_tracking() {
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");
git(
&local,
&["remote", "add", "origin", remote_git.to_str().unwrap()],
);
git(&local, &["config", "--unset-all", "remote.origin.fetch"]);
git(
&local,
&[
"config",
"--add",
"remote.origin.fetch",
"+refs/heads/*:refs/custom/origin/*",
],
);
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");
push_local(
&local_git,
remote_git,
&[PushRefSpec {
src: Some(c1),
src_ref: None,
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
}],
&PushOptions {
tracking_remote: Some("origin".to_owned()),
..PushOptions::default()
},
&grit_lib::progress::NeverCancel,
)
.expect("push with custom tracking mapping");
assert_eq!(
remote_ref(&local_git, "refs/custom/origin/main"),
Some(c1),
"tracking ref should follow fetch refspec destination"
);
assert_eq!(
remote_ref(&local_git, "refs/remotes/origin/main"),
None,
"must not write the conventional refs/remotes path when fetch maps elsewhere"
);
}
#[test]
fn push_local_missing_remote_is_error() {
let tmp = tempfile::tempdir().expect("tempdir");
let local = tmp.path().join("local");
std::fs::create_dir_all(&local).unwrap();
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, "HEAD");
let missing = tmp.path().join("no-such-remote.git");
let err = push_local(
&local_git,
&missing,
&[PushRefSpec {
src: Some(c1),
src_ref: None,
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
}],
&PushOptions::default(),
&grit_lib::progress::NeverCancel,
)
.expect_err("push to missing remote must fail");
let msg = err.to_string();
assert!(
msg.contains("could not find repository"),
"unexpected error: {msg}"
);
assert!(!missing.exists(), "must not create a new repository path");
}
#[test]
fn push_local_does_not_use_decoy_repo_when_parent_relative_target_missing() {
let tmp = tempfile::tempdir().expect("tempdir");
let parent = tmp.path();
let clone_wt = parent.join("clone");
let local_git = clone_wt.join(".git");
std::fs::create_dir_all(&clone_wt).unwrap();
git(&clone_wt, &["init", "-q", "-b", "main", "."]);
std::fs::write(clone_wt.join("a.txt"), "one\n").unwrap();
git(&clone_wt, &["add", "a.txt"]);
git(&clone_wt, &["commit", "-q", "-m", "c1"]);
let c1 = rev_parse(&clone_wt, "HEAD");
let decoy = clone_wt.join("wrong.git");
std::fs::create_dir_all(&decoy).unwrap();
git(&decoy, &["init", "-q", "--bare", "-b", "main", "."]);
let intended = parent.join("wrong.git");
assert!(!intended.exists());
let remote_git = grit_lib::remote::transport_path::resolve_local_remote_git_dir(
"../wrong.git",
&local_git,
Some(&clone_wt),
);
assert_eq!(remote_git, clone_wt.join("../wrong.git"));
let err = push_local(
&local_git,
&remote_git,
&[PushRefSpec {
src: Some(c1),
src_ref: None,
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
}],
&PushOptions::default(),
&grit_lib::progress::NeverCancel,
)
.expect_err("missing ../wrong.git must fail");
assert!(
err.to_string().contains("could not find repository"),
"{}",
err
);
assert_eq!(remote_ref(&decoy, "refs/heads/main"), None);
}
#[test]
fn linked_worktree_resolves_relative_remote_from_worktree_root() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
let origin = root.join("origin.git");
std::fs::create_dir_all(&origin).unwrap();
git(&origin, &["init", "-q", "--bare", "-b", "main", "."]);
let seed = root.join("seed");
std::fs::create_dir_all(&seed).unwrap();
git(&seed, &["init", "-q", "-b", "main", "."]);
std::fs::write(seed.join("f"), "1\n").unwrap();
git(&seed, &["add", "f"]);
git(&seed, &["commit", "-q", "-m", "seed"]);
git(
&seed,
&["remote", "add", "origin", origin.to_str().unwrap()],
);
git(&seed, &["push", "-q", "origin", "HEAD:refs/heads/main"]);
let main = root.join("main");
std::fs::create_dir_all(&main).unwrap();
git(&main, &["init", "-q", "-b", "main", "."]);
git(&main, &["remote", "add", "origin", "../origin.git"]);
git(&main, &["fetch", "origin"]);
git(&main, &["reset", "--hard", "origin/main"]);
let linked = root.join("linked");
git(
&main,
&[
"worktree",
"add",
"-b",
"linked",
linked.to_str().unwrap(),
"main",
],
);
let repo = Repository::discover(Some(linked.as_path()), &RepositoryOptions::empty())
.expect("discover linked worktree");
assert!(
repo.work_tree().is_some_and(|wt| wt.ends_with("linked")),
"expected work_tree at linked checkout, got {:?}",
repo.work_tree()
);
let resolved = resolve_local_remote_git_dir("../origin.git", repo.git_dir(), repo.work_tree());
assert_eq!(
resolved.canonicalize().expect("origin path"),
origin.canonicalize().expect("origin canonical")
);
let head = rev_parse(&linked, "HEAD");
let outcome = push_local(
repo.git_dir(),
&resolved,
&[PushRefSpec {
src: Some(head),
src_ref: None,
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
}],
&PushOptions::default(),
&grit_lib::progress::NeverCancel,
)
.expect("push from linked worktree with resolved remote");
assert_eq!(outcome.results.len(), 1);
assert_eq!(outcome.results[0].status, PushRefStatus::UpToDate);
}
fn pack_files(git_dir: &Path) -> Vec<std::path::PathBuf> {
std::fs::read_dir(git_dir.join("objects/pack"))
.expect("pack dir")
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "pack"))
.collect()
}
#[test]
fn push_local_into_empty_repo_links_local_pack() {
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");
for i in 0..3 {
std::fs::write(local.join(format!("f{i}.txt")), format!("payload {i}\n")).unwrap();
git(&local, &["add", "."]);
git(&local, &["commit", "-q", "-m", &format!("c{i}")]);
}
git(&local, &["repack", "-a", "-d", "-f"]);
let local_packs = pack_files(&local_git);
assert_eq!(local_packs.len(), 1, "fixture must have one pack");
let local_pack_name = local_packs[0].file_name().expect("pack name").to_owned();
let tip = rev_parse(&local, "refs/heads/main");
push_local(
&local_git,
remote_git,
&[PushRefSpec {
src: Some(tip),
src_ref: None,
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
}],
&PushOptions::default(),
&grit_lib::progress::NeverCancel,
)
.expect("push_local");
let remote_packs = pack_files(remote_git);
assert_eq!(remote_packs.len(), 1, "expected one linked pack");
assert_eq!(
remote_packs[0].file_name().expect("remote pack name"),
local_pack_name.as_os_str(),
"initial push must link local packfiles instead of rebuilding"
);
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let local_meta = std::fs::metadata(&local_packs[0]).expect("local pack metadata");
let remote_meta = std::fs::metadata(&remote_packs[0]).expect("remote pack metadata");
assert_eq!(
(local_meta.dev(), local_meta.ino()),
(remote_meta.dev(), remote_meta.ino()),
"linked pack should share device and inode"
);
}
fsck_clean(remote_git);
}
#[test]
fn push_local_shallow_repacked_missing_parent_fails() {
let tmp = tempfile::tempdir().expect("tempdir");
let origin = tmp.path().join("origin.git");
let seed = tmp.path().join("seed");
let shallow = tmp.path().join("shallow");
let dest = tmp.path().join("dest.git");
std::fs::create_dir_all(&origin).unwrap();
std::fs::create_dir_all(&seed).unwrap();
git(&origin, &["init", "-q", "--bare", "-b", "main", "."]);
git(&seed, &["init", "-q", "-b", "main", "."]);
std::fs::write(seed.join("a.txt"), "one\n").unwrap();
git(&seed, &["add", "a.txt"]);
git(&seed, &["commit", "-q", "-m", "c1"]);
std::fs::write(seed.join("b.txt"), "two\n").unwrap();
git(&seed, &["add", "b.txt"]);
git(&seed, &["commit", "-q", "-m", "c2"]);
git(
&seed,
&[
"push",
"-q",
origin.to_str().unwrap(),
"refs/heads/main:refs/heads/main",
],
);
std::fs::create_dir_all(&shallow).unwrap();
let origin_url = format!(
"file://{}",
origin.canonicalize().expect("origin path").display()
);
git(&shallow, &["clone", "-q", "--depth", "1", &origin_url, "."]);
git(&shallow, &["repack", "-a", "-d", "-f"]);
let shallow_git = shallow.join(".git");
let tip = rev_parse(&shallow, "refs/heads/main");
std::fs::create_dir_all(&dest).unwrap();
git(&dest, &["init", "-q", "--bare", "-b", "main", "."]);
let dest_git = dest.as_path();
let err = push_local(
&shallow_git,
dest_git,
&[PushRefSpec {
src: Some(tip),
src_ref: None,
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
}],
&PushOptions::default(),
&grit_lib::progress::NeverCancel,
)
.expect_err("shallow push must fail when parent objects are absent");
let msg = err.to_string();
assert!(
msg.contains("missing") || msg.contains("not found") || msg.contains("Object"),
"expected missing-object error, got: {msg}"
);
assert_eq!(
remote_ref(dest_git, "refs/heads/main"),
None,
"ref must not be updated when object copy fails"
);
}
fn git_count_objects(git_dir: &Path) -> (u32, u32) {
let out = git(git_dir, &["count-objects", "-v"]);
let mut loose = 0u32;
let mut packs = 0u32;
for line in out.lines() {
if let Some(v) = line.strip_prefix("count: ") {
loose = v.trim().parse().expect("count");
}
if let Some(v) = line.strip_prefix("packs: ") {
packs = v.trim().parse().expect("packs");
}
}
(loose, packs)
}
#[test]
fn push_local_installs_single_pack_without_loose_objects() {
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");
for i in 0..6 {
let mut body = String::from("shared header for delta tests\n");
body.push_str(&"z".repeat(2048));
body.push_str(&format!("file-{i}\n"));
std::fs::write(local.join(format!("blob{i}.txt")), body).unwrap();
git(&local, &["add", "."]);
git(&local, &["commit", "-q", "-m", &format!("commit {i}")]);
}
git(
&local,
&[
"repack",
"-q",
"-a",
"-d",
"-f",
"--window=10",
"--depth=50",
],
);
let tip = rev_parse(&local, "refs/heads/main");
push_local(
&local_git,
remote_git,
&[PushRefSpec {
src: Some(tip),
src_ref: None,
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
}],
&PushOptions::default(),
&grit_lib::progress::NeverCancel,
)
.expect("push to empty bare remote");
let (loose, packs) = git_count_objects(remote_git);
assert_eq!(
loose, 0,
"remote must not store pushed objects as loose files"
);
assert_eq!(packs, 1, "remote must retain one packfile");
fsck_clean(remote_git);
}