Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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"
    );
}