crates/gunnar-client/tests/real_git_push.rs
//! Push, against the real `git receive-pack` binary.
//!
//! Every test here builds its repositories with stock `git 2.53`, drives
//! gunnar's send-pack over pipes into stock `git receive-pack`, and then asserts
//! the result **with stock git again**: `for-each-ref`, `rev-list --objects`,
//! `fsck`, `cat-file`. Nothing in this file believes gunnar about gunnar.
//!
//! Hermetic: temporary directories, no network, no sshd. `#[ignore]` is not
//! needed and not used.
mod common;
use common::*;
use gix::hash::{Kind, ObjectId};
use gunnar_client::{
push_mirror_over, push_over, read_advertisement, send_pack, PushCommand, PushOptions, RefSpec,
SendPackOptions, Transport,
};
fn oid(hex: &str) -> ObjectId {
ObjectId::from_hex(hex.as_bytes()).unwrap()
}
/// The load-bearing assertion: after the push, the *bare repository stock git
/// owns* must contain the same object closure and the same ref value as the
/// source, and must pass `git fsck`.
fn assert_mirrored(src: &std::path::Path, bare: &std::path::Path, rev: &str) {
let want = git(src, &["rev-parse", rev]);
let got = git(bare, &["rev-parse", rev]);
assert_eq!(
got, want,
"{rev} must point at the same commit on both sides"
);
assert_eq!(
objects_via_git(bare, rev),
objects_via_git(src, rev),
"the pushed pack must carry the COMPLETE object closure, not just the tip"
);
let fsck = git_try(bare, &["fsck", "--strict", "--no-progress"]);
assert!(
fsck.is_ok(),
"git fsck rejected the pushed repository: {fsck:?}"
);
// And the content reads back; presence alone is not the claim.
let blob = git(bare, &["show", &format!("{rev}:a.txt")]);
assert_eq!(blob, git(src, &["show", &format!("{rev}:a.txt")]));
}
#[test]
fn push_a_fresh_branch_into_an_empty_repo_sha1_and_sha256() {
for kind in BOTH_HASH_KINDS {
let f = Fixture::new(kind);
commit_file(&f.src(), "a.txt", "hello\n", "first");
commit_file(&f.src(), "b.txt", "more\n", "second");
let repo = open(&f.src());
assert_eq!(repo.object_hash(), kind, "fixture is really {kind:?}");
let mut t = GitChild::receive_pack(&f.bare());
let specs = RefSpec::parse_all(&["refs/heads/main:refs/heads/main"]).unwrap();
let outcome = push_over(&repo, &mut t, &specs, &PushOptions::new()).unwrap();
t.finish();
let report = outcome.report.as_ref().expect("a real push reports");
assert_eq!(report.unpack, "ok", "{kind:?}: {report:?}");
assert!(outcome.is_ok(), "{kind:?}: {:?}", report.failure_summary());
assert_eq!(report.refs.len(), 1);
assert_eq!(report.refs[0].name, "refs/heads/main");
assert!(
outcome.objects_sent >= 5,
"2 commits, 2 trees, 2 blobs at least"
);
assert!(outcome.pack_bytes > 0, "a pack was actually written");
assert_mirrored(&f.src(), &f.bare(), "refs/heads/main");
}
}
/// The second push must carry only what the first one did not. If the boundary
/// subtraction in `objects::plan_objects` were dead, this would send the whole
/// history again, and every "did the push work" assertion would still pass.
#[test]
fn a_second_push_sends_only_the_delta() {
let f = Fixture::new(Kind::Sha1);
commit_file(&f.src(), "a.txt", "one\n", "first");
commit_file(&f.src(), "b.txt", "two\n", "second");
commit_file(&f.src(), "c.txt", "three\n", "third");
let repo = open(&f.src());
let specs = RefSpec::parse_all(&["refs/heads/main:refs/heads/main"]).unwrap();
let mut t = GitChild::receive_pack(&f.bare());
let first = push_over(&repo, &mut t, &specs, &PushOptions::new()).unwrap();
t.finish();
assert!(first.is_ok());
let full = first.objects_sent;
assert!(full >= 9, "three commits worth of objects, got {full}");
commit_file(&f.src(), "d.txt", "four\n", "fourth");
let repo = open(&f.src());
let mut t = GitChild::receive_pack(&f.bare());
let second = push_over(&repo, &mut t, &specs, &PushOptions::new()).unwrap();
t.finish();
assert!(second.is_ok(), "{:?}", second.report);
assert_eq!(
second.objects_sent, 3,
"one commit + one root tree + one blob, got {}, so the \
have-subtraction did nothing",
second.objects_sent
);
assert!(
second.objects_sent < full,
"the incremental push must be smaller than the initial one"
);
assert_mirrored(&f.src(), &f.bare(), "refs/heads/main");
}
/// Nothing to do must be visibly nothing to do, and must leave the connection in
/// a state `receive-pack` exits cleanly from.
#[test]
fn an_up_to_date_push_sends_no_command_list() {
let f = Fixture::new(Kind::Sha1);
commit_file(&f.src(), "a.txt", "one\n", "first");
let repo = open(&f.src());
let specs = RefSpec::parse_all(&["refs/heads/main:refs/heads/main"]).unwrap();
let mut t = GitChild::receive_pack(&f.bare());
push_over(&repo, &mut t, &specs, &PushOptions::new()).unwrap();
t.finish();
let mut t = GitChild::receive_pack(&f.bare());
let again = push_over(&repo, &mut t, &specs, &PushOptions::new()).unwrap();
t.finish();
assert!(again.commands.is_empty());
assert_eq!(again.up_to_date, vec!["refs/heads/main".to_string()]);
assert!(again.report.is_none(), "no conversation, so no report");
assert_eq!(again.objects_sent, 0);
}
/// **The deliberate mutation (measure, never assert).** The `<old>` oid in the
/// command list is the compare-and-swap that makes a push safe. Send a wrong
/// one and the real `receive-pack` must refuse. If it did not, every green
/// push test above would be green for the wrong reason.
#[test]
fn a_wrong_old_oid_is_rejected_by_real_receive_pack() {
let f = Fixture::new(Kind::Sha1);
let first = commit_file(&f.src(), "a.txt", "one\n", "first");
let repo = open(&f.src());
let specs = RefSpec::parse_all(&["refs/heads/main:refs/heads/main"]).unwrap();
// Land an honest push first, so the ref exists and has a known value.
let mut t = GitChild::receive_pack(&f.bare());
push_over(&repo, &mut t, &specs, &PushOptions::new()).unwrap();
t.finish();
let second = commit_file(&f.src(), "b.txt", "two\n", "second");
let repo = open(&f.src());
// Now hand-build the command list with a LIE about the remote's current value.
let mut t = GitChild::receive_pack(&f.bare());
let adv = {
let (r, _) = t.io();
read_advertisement(r).unwrap()
};
assert_eq!(
adv.oid_of("refs/heads/main"),
oid(&first),
"the advertisement says what the remote really has"
);
let bogus = oid("dead00beef00dead00beef00dead00beef00dead");
let commands = vec![PushCommand::update("refs/heads/main", bogus, oid(&second))];
let plan = gunnar_client::plan_objects(&repo, &[oid(&second)], &[oid(&first)]).unwrap();
let (pack, _) = gunnar_pack::write_pack_to_vec(
&plan.ids,
&gunnar_client::pack_source::PlumbingObjects(&repo),
&gunnar_pack::WriteOptions::new(Kind::Sha1),
)
.unwrap();
let report = send_pack(
&mut t,
&adv,
&commands,
Kind::Sha1,
Some(|w: &mut dyn std::io::Write| -> gunnar_client::Result<()> {
w.write_all(&pack)?;
Ok(())
}),
&SendPackOptions::default(),
)
.unwrap();
t.finish();
assert!(
!report.is_ok(),
"receive-pack accepted a command list with a fabricated old-oid: {report:?}"
);
let ng = report
.rejected()
.next()
.expect("the ref must be reported as ng");
assert_eq!(ng.name, "refs/heads/main");
// git 2.53 words this "incorrect old value provided"; older ones say "fetch
// first" / "stale info". Any of them is the compare-and-swap firing.
//
// **git 2.43 says "failed to update ref"**, and that is the same event
// reported one layer up: the specific sentence ("cannot lock ref …: is at X
// but expected Y") goes into the ref *transaction's* error, and the
// per-reference `ng` line carries the transaction's generic verdict. The
// list is a list of git's spellings across versions, not a list of
// different behaviours — every entry means the same refusal, and the
// assertion below is what proves the refusal was real: the reference did
// not move.
let reason = ng.reason.clone().unwrap_or_default();
assert!(
[
"incorrect old value",
"fetch first",
"stale info",
"non-fast-forward",
"failed to update ref",
]
.iter()
.any(|w| reason.contains(w)),
"unexpected rejection reason: {reason:?}"
);
// And the remote is untouched: a rejected push must not move the ref.
assert_eq!(
git(&f.bare(), &["rev-parse", "refs/heads/main"]),
first,
"a rejected push moved the ref anyway"
);
}
/// The mirror image: an honest command list against the same remote state is
/// accepted. Without this the test above would pass on a client that could not
/// push at all.
#[test]
fn the_same_push_with_the_right_old_oid_is_accepted() {
let f = Fixture::new(Kind::Sha1);
commit_file(&f.src(), "a.txt", "one\n", "first");
let repo = open(&f.src());
let specs = RefSpec::parse_all(&["refs/heads/main:refs/heads/main"]).unwrap();
let mut t = GitChild::receive_pack(&f.bare());
push_over(&repo, &mut t, &specs, &PushOptions::new()).unwrap();
t.finish();
let second = commit_file(&f.src(), "b.txt", "two\n", "second");
let repo = open(&f.src());
let mut t = GitChild::receive_pack(&f.bare());
let outcome = push_over(&repo, &mut t, &specs, &PushOptions::new()).unwrap();
t.finish();
assert!(outcome.is_ok(), "{:?}", outcome.report);
assert_eq!(git(&f.bare(), &["rev-parse", "refs/heads/main"]), second);
}
/// A non-fast-forward is caught **locally**, before anything goes on the wire.
#[test]
fn a_non_fast_forward_is_refused_before_the_wire() {
let f = Fixture::new(Kind::Sha1);
commit_file(&f.src(), "a.txt", "one\n", "first");
commit_file(&f.src(), "b.txt", "two\n", "second");
let repo = open(&f.src());
let specs = RefSpec::parse_all(&["refs/heads/main:refs/heads/main"]).unwrap();
let mut t = GitChild::receive_pack(&f.bare());
push_over(&repo, &mut t, &specs, &PushOptions::new()).unwrap();
t.finish();
// Rewrite history locally: drop the last commit and put a different one there.
git(&f.src(), &["reset", "-q", "--hard", "HEAD~1"]);
let divergent = commit_file(&f.src(), "c.txt", "three\n", "divergent");
let repo = open(&f.src());
let mut t = GitChild::receive_pack(&f.bare());
let err = push_over(&repo, &mut t, &specs, &PushOptions::new()).unwrap_err();
t.abandon();
assert!(
format!("{err}").contains("not a fast-forward"),
"unexpected error: {err}"
);
// …and `+` gets through, which is what makes the refusal a guard rather than
// a wall.
let repo = open(&f.src());
let forced = RefSpec::parse_all(&["+refs/heads/main:refs/heads/main"]).unwrap();
let mut t = GitChild::receive_pack(&f.bare());
let outcome = push_over(&repo, &mut t, &forced, &PushOptions::new()).unwrap();
t.finish();
assert!(outcome.is_ok(), "{:?}", outcome.report);
assert_eq!(git(&f.bare(), &["rev-parse", "refs/heads/main"]), divergent);
assert_mirrored(&f.src(), &f.bare(), "refs/heads/main");
}
#[test]
fn deleting_a_ref_sends_no_pack_and_removes_it() {
let f = Fixture::new(Kind::Sha1);
commit_file(&f.src(), "a.txt", "one\n", "first");
let repo = open(&f.src());
let specs = RefSpec::parse_all(&[
"refs/heads/main:refs/heads/main",
"refs/heads/main:refs/heads/doomed",
])
.unwrap();
let mut t = GitChild::receive_pack(&f.bare());
push_over(&repo, &mut t, &specs, &PushOptions::new()).unwrap();
t.finish();
assert!(refs_via_git(&f.bare())
.iter()
.any(|r| r.ends_with("refs/heads/doomed")));
let mut t = GitChild::receive_pack(&f.bare());
let del = RefSpec::parse_all(&[":refs/heads/doomed"]).unwrap();
let outcome = push_over(&repo, &mut t, &del, &PushOptions::new()).unwrap();
t.finish();
assert!(outcome.is_ok(), "{:?}", outcome.report);
assert_eq!(outcome.objects_sent, 0, "a delete carries no objects");
assert_eq!(outcome.pack_bytes, 0, "a delete carries no pack at all");
let refs = refs_via_git(&f.bare());
assert!(
!refs.iter().any(|r| r.ends_with("refs/heads/doomed")),
"the ref survived the delete: {refs:?}"
);
assert!(refs.iter().any(|r| r.ends_with("refs/heads/main")));
}
/// Deleting a ref the remote does not have is refused locally; otherwise the
/// command list would name a ref the remote cannot compare-and-swap on.
#[test]
fn deleting_a_ref_the_remote_lacks_is_refused() {
let f = Fixture::new(Kind::Sha1);
commit_file(&f.src(), "a.txt", "one\n", "first");
let repo = open(&f.src());
let mut t = GitChild::receive_pack(&f.bare());
let del = RefSpec::parse_all(&[":refs/heads/never-existed"]).unwrap();
let err = push_over(&repo, &mut t, &del, &PushOptions::new()).unwrap_err();
t.abandon();
assert!(format!("{err}").contains("does not have it"), "{err}");
}
/// `side-band-64k` on and off must both work against the real server, and must
/// produce the same result. The demultiplexer has a unit test; this is the
/// assertion that it agrees with what git actually sends.
#[test]
fn sideband_on_and_off_both_push_successfully() {
for side_band in [true, false] {
let f = Fixture::new(Kind::Sha1);
commit_file(&f.src(), "a.txt", "one\n", "first");
let repo = open(&f.src());
let specs = RefSpec::parse_all(&["refs/heads/main:refs/heads/main"]).unwrap();
let opts = PushOptions {
send: SendPackOptions {
side_band,
..Default::default()
},
..PushOptions::new()
};
let mut t = GitChild::receive_pack(&f.bare());
let outcome = push_over(&repo, &mut t, &specs, &opts).unwrap();
t.finish();
assert!(
outcome.is_ok(),
"side_band={side_band}: {:?}",
outcome.report
);
assert_mirrored(&f.src(), &f.bare(), "refs/heads/main");
}
}
/// `report-status` v1 must be usable too. A server without v2 is not a broken
/// server, and a client that only handles v2 would fail against one.
#[test]
fn report_status_v1_round_trips_against_real_git() {
let f = Fixture::new(Kind::Sha1);
commit_file(&f.src(), "a.txt", "one\n", "first");
let repo = open(&f.src());
let specs = RefSpec::parse_all(&["refs/heads/main:refs/heads/main"]).unwrap();
let opts = PushOptions {
send: SendPackOptions {
report_status_v2: false,
..Default::default()
},
..PushOptions::new()
};
let mut t = GitChild::receive_pack(&f.bare());
let outcome = push_over(&repo, &mut t, &specs, &opts).unwrap();
t.finish();
assert!(outcome.is_ok(), "{:?}", outcome.report);
assert_mirrored(&f.src(), &f.bare(), "refs/heads/main");
}
#[test]
fn atomic_push_is_accepted_by_real_git() {
let f = Fixture::new(Kind::Sha1);
commit_file(&f.src(), "a.txt", "one\n", "first");
let repo = open(&f.src());
let specs = RefSpec::parse_all(&[
"refs/heads/main:refs/heads/main",
"refs/heads/main:refs/heads/two",
])
.unwrap();
let opts = PushOptions {
send: SendPackOptions {
atomic: true,
..Default::default()
},
..PushOptions::new()
};
let mut t = GitChild::receive_pack(&f.bare());
let outcome = push_over(&repo, &mut t, &specs, &opts).unwrap();
t.finish();
assert!(outcome.is_ok(), "{:?}", outcome.report);
assert_eq!(outcome.report.unwrap().refs.len(), 2);
}
/// An **annotated tag** is a tag object plus a ref. Packing the ref without the
/// object leaves the remote with a dangling reference, which `fsck --strict`
/// catches and a plain "did the ref move" assertion does not.
#[test]
fn pushing_an_annotated_tag_carries_the_tag_object() {
for kind in BOTH_HASH_KINDS {
let f = Fixture::new(kind);
commit_file(&f.src(), "a.txt", "one\n", "first");
git(&f.src(), &["tag", "-a", "v1.0", "-m", "release one"]);
let tag_oid = git(&f.src(), &["rev-parse", "refs/tags/v1.0"]);
assert_eq!(
git(&f.src(), &["cat-file", "-t", &tag_oid]),
"tag",
"the fixture really made an annotated tag"
);
let repo = open(&f.src());
let specs = RefSpec::parse_all(&[
"refs/heads/main:refs/heads/main",
"refs/tags/v1.0:refs/tags/v1.0",
])
.unwrap();
let mut t = GitChild::receive_pack(&f.bare());
let outcome = push_over(&repo, &mut t, &specs, &PushOptions::new()).unwrap();
t.finish();
assert!(outcome.is_ok(), "{kind:?}: {:?}", outcome.report);
assert_eq!(git(&f.bare(), &["rev-parse", "refs/tags/v1.0"]), tag_oid);
assert_eq!(git(&f.bare(), &["cat-file", "-t", &tag_oid]), "tag");
assert!(
git_try(&f.bare(), &["fsck", "--strict", "--no-progress"]).is_ok(),
"{kind:?}: the tag object was not carried"
);
}
}
/// A dry run resolves and plans but must put nothing on the wire.
#[test]
fn a_dry_run_changes_nothing_on_the_remote() {
let f = Fixture::new(Kind::Sha1);
commit_file(&f.src(), "a.txt", "one\n", "first");
let repo = open(&f.src());
let specs = RefSpec::parse_all(&["refs/heads/main:refs/heads/main"]).unwrap();
let opts = PushOptions {
dry_run: true,
..PushOptions::new()
};
let mut t = GitChild::receive_pack(&f.bare());
let outcome = push_over(&repo, &mut t, &specs, &opts).unwrap();
t.finish();
assert!(outcome.dry_run);
assert_eq!(
outcome.commands.len(),
1,
"it still worked out what it would do"
);
assert!(outcome.objects_sent >= 3, "and what it would send");
assert_eq!(outcome.pack_bytes, 0);
assert!(
refs_via_git(&f.bare()).is_empty(),
"a dry run created a ref on the remote"
);
}
/// Several branches and a tag in one command list, cross-checked ref by ref.
#[test]
fn a_multi_ref_push_applies_every_command() {
let f = Fixture::new(Kind::Sha256);
commit_file(&f.src(), "a.txt", "one\n", "first");
git(&f.src(), &["branch", "topic"]);
commit_file(&f.src(), "b.txt", "two\n", "second");
git(&f.src(), &["tag", "v1"]);
let repo = open(&f.src());
let specs = RefSpec::parse_all(&[
"refs/heads/main:refs/heads/main",
"refs/heads/topic:refs/heads/topic",
"refs/tags/v1:refs/tags/v1",
])
.unwrap();
let mut t = GitChild::receive_pack(&f.bare());
let outcome = push_over(&repo, &mut t, &specs, &PushOptions::new()).unwrap();
t.finish();
assert!(outcome.is_ok(), "{:?}", outcome.report);
let mut got = refs_via_git(&f.bare());
let mut want = refs_via_git(&f.src());
got.sort();
want.sort();
assert_eq!(got, want, "every ref must land with the same value");
assert!(git_try(&f.bare(), &["fsck", "--strict", "--no-progress"]).is_ok());
}
/// An annotated tag pushed **after** its commit already reached the remote. The
/// commit is subtracted away, so the pack contains exactly the tag object. If
/// `plan_objects` treated a tag as nothing but a pointer to a commit, the pack
/// would be empty and the remote would end up with a ref to an object it does
/// not have.
#[test]
fn a_tag_pushed_after_its_commit_still_carries_the_tag_object() {
let f = Fixture::new(Kind::Sha1);
commit_file(&f.src(), "a.txt", "one\n", "first");
let repo = open(&f.src());
let branch = RefSpec::parse_all(&["refs/heads/main:refs/heads/main"]).unwrap();
let mut t = GitChild::receive_pack(&f.bare());
push_over(&repo, &mut t, &branch, &PushOptions::new()).unwrap();
t.finish();
git(&f.src(), &["tag", "-a", "v2", "-m", "later"]);
let tag_oid = git(&f.src(), &["rev-parse", "refs/tags/v2"]);
let repo = open(&f.src());
let tagspec = RefSpec::parse_all(&["refs/tags/v2:refs/tags/v2"]).unwrap();
let mut t = GitChild::receive_pack(&f.bare());
let outcome = push_over(&repo, &mut t, &tagspec, &PushOptions::new()).unwrap();
t.finish();
assert!(outcome.is_ok(), "{:?}", outcome.report);
assert_eq!(
outcome.objects_sent, 1,
"only the tag object is new, got {}",
outcome.objects_sent
);
assert_eq!(git(&f.bare(), &["rev-parse", "refs/tags/v2"]), tag_oid);
assert_eq!(git(&f.bare(), &["cat-file", "-t", &tag_oid]), "tag");
assert!(
git_try(&f.bare(), &["fsck", "--strict", "--no-progress"]).is_ok(),
"the tag ref points at an object the remote does not have"
);
}
// ───────────────────────────────────────────────────────────────────────────
// mirror push: replication's half, against stock git
// ───────────────────────────────────────────────────────────────────────────
/// `push_mirror_over` converges a remote onto this repository in **one**
/// connection: every branch and tag, and the deletions.
///
/// Asserted against stock `git receive-pack`, not against gunnar's own
/// receive-pack, because replication is the one place where both ends
/// of a gunnar↔gunnar conversation are gunnar's, and two halves that are wrong
/// the same way would agree perfectly.
#[test]
fn a_mirror_push_carries_every_branch_and_tag_in_one_connection() {
let f = Fixture::new(Kind::Sha1);
commit_file(&f.src(), "a.txt", "one\n", "first");
git(&f.src(), &["branch", "side"]);
git(&f.src(), &["tag", "-a", "v1", "-m", "annotated"]);
git(&f.src(), &["tag", "light"]);
let tag_oid = git(&f.src(), &["rev-parse", "refs/tags/v1"]);
let repo = open(&f.src());
let mut t = GitChild::receive_pack(&f.bare());
let outcome = push_mirror_over(&repo, &mut t, &PushOptions::new()).unwrap();
t.finish();
assert!(outcome.is_ok(), "{:?}", outcome.report);
let mut moved: Vec<String> = outcome
.commands
.iter()
.map(|c| c.name.to_string())
.collect();
moved.sort();
assert_eq!(
moved,
[
"refs/heads/main",
"refs/heads/side",
"refs/tags/light",
"refs/tags/v1"
],
"a mirror push takes every branch and tag, and nothing was asked for by name"
);
// Applied output, read with stock git out of the repository stock git owns.
for name in ["refs/heads/main", "refs/heads/side", "refs/tags/light"] {
assert_eq!(
git(&f.bare(), &["rev-parse", name]),
git(&f.src(), &["rev-parse", name]),
"{name} must have landed"
);
}
// The annotated tag must still BE a tag on the mirror. Peeling it would
// leave both copies passing `git fsck` while the mirror silently held a
// lightweight tag. That is the failure mode `follow_symrefs` documents.
assert_eq!(git(&f.bare(), &["rev-parse", "refs/tags/v1"]), tag_oid);
assert_eq!(git(&f.bare(), &["cat-file", "-t", &tag_oid]), "tag");
assert!(git_try(&f.bare(), &["fsck", "--strict", "--no-progress"]).is_ok());
}
/// **The arm that makes it a mirror rather than a copy**: a branch deleted here
/// is deleted there, and a ref the remote has that this repository never had is
/// removed too. A replicator that only ever added refs would pass every other
/// assertion in this file.
#[test]
fn a_mirror_push_deletes_what_the_source_no_longer_has() {
let f = Fixture::new(Kind::Sha1);
commit_file(&f.src(), "a.txt", "one\n", "first");
git(&f.src(), &["branch", "doomed"]);
let repo = open(&f.src());
let mut t = GitChild::receive_pack(&f.bare());
push_mirror_over(&repo, &mut t, &PushOptions::new()).unwrap();
t.finish();
assert!(git_try(&f.bare(), &["rev-parse", "refs/heads/doomed"]).is_ok());
// …and one the source has never heard of, made directly on the remote.
let head = git(&f.bare(), &["rev-parse", "refs/heads/main"]);
git(&f.bare(), &["update-ref", "refs/heads/stray", &head]);
git(&f.src(), &["branch", "-D", "doomed"]);
let repo = open(&f.src());
let mut t = GitChild::receive_pack(&f.bare());
let outcome = push_mirror_over(&repo, &mut t, &PushOptions::new()).unwrap();
t.finish();
assert!(outcome.is_ok(), "{:?}", outcome.report);
let mut deleted: Vec<String> = outcome
.commands
.iter()
.filter(|c| c.is_delete())
.map(|c| c.name.to_string())
.collect();
deleted.sort();
assert_eq!(deleted, ["refs/heads/doomed", "refs/heads/stray"]);
assert!(
git_try(&f.bare(), &["rev-parse", "--verify", "refs/heads/doomed"]).is_err(),
"a branch deleted on the source must be deleted on the mirror"
);
assert!(
git_try(&f.bare(), &["rev-parse", "--verify", "refs/heads/stray"]).is_err(),
"a ref the source never had must not survive on the mirror"
);
assert_eq!(
git(&f.bare(), &["rev-parse", "refs/heads/main"]),
git(&f.src(), &["rev-parse", "refs/heads/main"]),
"converging must delete only what the source no longer has"
);
}
/// A mirror push against an already-current remote sends **nothing**, and says
/// which refs it found were already there — a report that is merely empty is
/// indistinguishable from one that computed nothing at all.
#[test]
fn a_mirror_push_to_a_current_remote_is_a_no_op_that_still_reports() {
let f = Fixture::new(Kind::Sha256);
commit_file(&f.src(), "a.txt", "one\n", "first");
let repo = open(&f.src());
let mut t = GitChild::receive_pack(&f.bare());
push_mirror_over(&repo, &mut t, &PushOptions::new()).unwrap();
t.finish();
let repo = open(&f.src());
let mut t = GitChild::receive_pack(&f.bare());
let outcome = push_mirror_over(&repo, &mut t, &PushOptions::new()).unwrap();
t.finish();
assert!(outcome.commands.is_empty(), "nothing had moved");
assert_eq!(outcome.objects_sent, 0);
assert_eq!(outcome.pack_bytes, 0);
assert_eq!(
outcome.up_to_date,
["refs/heads/main"],
"the refs that were already current must be named"
);
}