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

nordisk/gunnar

crates/gunnar-wire/tests/force_update.rs

//! **Force updates, and what the frontier does with tags.**
//!
//! `bare_lifecycle` (a bench) refused a force-push rewind with *"missing
//! necessary objects … (peeling a tag)"*, and the sentence is the reason this
//! file exists twice over:
//!
//! 1. The **refusal** had to be reproduced or ruled out. A force update differs
//!    from a fast-forward in exactly one way the connectivity check can see —
//!    the new tip is not a descendant of the old one — and the frontier is what
//!    decides whether that matters. Every shape a tag can take in a frontier is
//!    pushed against here.
//! 2. The **sentence** was a lie either way. `Graph::peel` labels its *first*
//!    object read `"peeling a tag"`, so a tip that is simply absent — a commit,
//!    no tag anywhere near it — is reported as a tag-peeling failure. An
//!    operator reading that message goes looking for a tag that was never
//!    involved, which is how a one-line diagnostic costs an afternoon.
//!
//! These run over `MemGitServe`. The bench arm ran over the znippy engine, so a
//! green file here does not clear that engine — it clears the *protocol* half
//! and leaves the store half named.

mod common;

use gix_hash::Kind;
use gunnar_wire::receive_pack::AllowAll;
use gunnar_wire::{ReceivePack, RefStatus};

use common::{empty_pack, push_bytes, validating_ingest, write_object, Fixture};

/// An annotated tag naming `target`, byte for byte what git writes.
fn tag_object(target: &gix_hash::oid, tag: &str, kind: gix_object::Kind) -> Vec<u8> {
    format!(
        "object {}\ntype {kind}\ntag {tag}\n\
         tagger Gunnar Test <gunnar@example.invalid> 1700000006 +0000\n\nrelease\n",
        target.to_hex()
    )
    .into_bytes()
}

/// Drive one push against `fixture` and hand back the per-reference statuses.
fn push(fixture: &Fixture, commands: &[(&str, &str, &str)], pack: &[u8]) -> Vec<RefStatus> {
    let receive = ReceivePack::new(&fixture.git, fixture.kind);
    let input = push_bytes(commands, "report-status object-format=sha1", pack);
    let mut input = std::io::Cursor::new(input);
    let mut out = Vec::new();
    receive
        .serve(
            &mut input,
            &mut out,
            &validating_ingest(Kind::Sha1),
            &AllowAll,
        )
        .expect("the session itself completes")
        .expect("a push outcome")
        .statuses
}

fn rejection(status: &RefStatus) -> &str {
    match status {
        RefStatus::Rejected(reason) => reason,
        RefStatus::Ok => panic!("expected a refusal, got `ok`"),
    }
}

/// **A rewind is served, with an annotated tag standing in the frontier.**
///
/// The fixture's `refs/tags/v1` peels to `merge`, which is *ahead* of the tip
/// this push rewinds to. So the frontier holds a commit the new tip cannot
/// reach, which is the arrangement a force-push rewind always produces and the
/// one `bare_lifecycle` was in when it was refused.
#[test]
fn a_force_rewind_is_served_while_an_annotated_tag_names_a_newer_commit() {
    let fixture = Fixture::build(Kind::Sha1);

    let statuses = push(
        &fixture,
        &[(
            &fixture.four.to_string(),
            &fixture.two.to_string(),
            "refs/heads/main",
        )],
        &empty_pack(Kind::Sha1),
    );

    assert_eq!(
        statuses[0],
        RefStatus::Ok,
        "a rewind onto an object the repository already holds was refused: {:?}",
        statuses[0]
    );
    assert_eq!(
        fixture.read_ref("refs/heads/main"),
        Some(fixture.two),
        "the push reported ok and the reference did not move"
    );
}

/// **A tag reference may be force-moved onto a new tag object.**
///
/// The new tip *is* a tag here, so `verify_connectivity`'s peel step is the
/// subject rather than an incidental cost: it has to read the new tag, follow
/// it to `four`, and stop there because `four` is a frontier commit.
#[test]
fn a_force_update_of_an_annotated_tag_is_served() {
    let fixture = Fixture::build(Kind::Sha1);
    let moved = write_object(
        &fixture.git,
        Kind::Sha1,
        gix_object::Kind::Tag,
        &tag_object(&fixture.four, "v1", gix_object::Kind::Commit),
    );

    let statuses = push(
        &fixture,
        &[(&fixture.tag.to_string(), &moved.to_string(), "refs/tags/v1")],
        &empty_pack(Kind::Sha1),
    );

    assert_eq!(
        statuses[0],
        RefStatus::Ok,
        "moving a tag onto a tag object whose target the repository holds was refused: {:?}",
        statuses[0]
    );
    assert_eq!(fixture.read_ref("refs/tags/v1"), Some(moved));
}

/// **A tag chain in the frontier is followed, not tripped over.**
///
/// A tag on a tag on a commit is legal git and `MAX_TAG_DEPTH` allows it. If
/// the frontier stopped at the outer tag, the commit under it would not be a
/// frontier commit and the walk below would keep going past it — visible here
/// as a refusal only when the objects under it are gone, so this arm pushes
/// against the chain rather than asserting on a counter.
#[test]
fn a_tag_chain_in_the_frontier_peels_all_the_way_to_its_commit() {
    let fixture = Fixture::build(Kind::Sha1);
    let inner = write_object(
        &fixture.git,
        Kind::Sha1,
        gix_object::Kind::Tag,
        &tag_object(&fixture.four, "inner", gix_object::Kind::Commit),
    );
    let outer = write_object(
        &fixture.git,
        Kind::Sha1,
        gix_object::Kind::Tag,
        &tag_object(&inner, "outer", gix_object::Kind::Tag),
    );
    fixture.force_ref("refs/tags/chained", outer);

    // A branch onto `four`, which the chained tag also names. The frontier must
    // contain `four` by way of the two-tag chain for this to stop immediately;
    // it would still pass if it walked further, so the assertion that carries
    // the claim is the rewind arm above. What this one rules out is the chain
    // *failing* — a `MAX_TAG_DEPTH` off-by-one or a peel that refuses a tag
    // whose target is a tag.
    let statuses = push(
        &fixture,
        &[(
            &Kind::Sha1.null().to_string(),
            &fixture.four.to_string(),
            "refs/heads/onto-chain",
        )],
        &empty_pack(Kind::Sha1),
    );

    assert_eq!(
        statuses[0],
        RefStatus::Ok,
        "a two-deep tag chain in the frontier refused an unrelated push: {:?}",
        statuses[0]
    );
}

/// **A dangling tag does not take an unrelated push down with it.**
///
/// The tag object is present and its target is not — the state a partial
/// import or an interrupted gc leaves behind. `Frontier::new` documents that it
/// skips such a value rather than failing, and this is the test that documents
/// it back: the repository is broken, and refusing every push until someone
/// notices is a second outage on top of the first.
#[test]
fn a_tag_whose_target_is_absent_does_not_refuse_an_unrelated_push() {
    let fixture = Fixture::build(Kind::Sha1);
    let absent =
        gix_object::compute_hash(Kind::Sha1, gix_object::Kind::Blob, b"never stored").unwrap();
    let dangling = write_object(
        &fixture.git,
        Kind::Sha1,
        gix_object::Kind::Tag,
        &tag_object(&absent, "ghost", gix_object::Kind::Commit),
    );
    fixture.force_ref("refs/tags/ghost", dangling);

    let statuses = push(
        &fixture,
        &[(
            &fixture.four.to_string(),
            &fixture.two.to_string(),
            "refs/heads/main",
        )],
        &empty_pack(Kind::Sha1),
    );

    assert_eq!(
        statuses[0],
        RefStatus::Ok,
        "one dangling tag elsewhere in the repository refused a push that has nothing to do \
         with it: {:?}",
        statuses[0]
    );
}

/// **An absent tip is refused, and the refusal names the tip.**
///
/// Seen red before the fix: the message read *"missing necessary objects: … is
/// not in this repository (peeling a tag)"* for a push with no tag in it at
/// all, because [`Graph::peel`] labelled its first read `"peeling a tag"`
/// whatever kind came back. The object id was in the sentence; the *reason*
/// given for wanting it was fiction, and it is the reason an operator reads
/// first.
#[test]
fn an_absent_tip_is_refused_with_a_reason_that_does_not_invent_a_tag() {
    let fixture = Fixture::build(Kind::Sha1);
    let absent =
        gix_object::compute_hash(Kind::Sha1, gix_object::Kind::Commit, b"never stored").unwrap();

    let statuses = push(
        &fixture,
        &[(
            &Kind::Sha1.null().to_string(),
            &absent.to_string(),
            "refs/heads/nowhere",
        )],
        &empty_pack(Kind::Sha1),
    );

    let reason = rejection(&statuses[0]);
    assert!(
        reason.contains(&absent.to_string()),
        "the refusal does not say which object is missing: {reason}"
    );
    assert!(
        !reason.contains("peeling a tag"),
        "no tag was pushed, named, or stored, and the refusal blames tag peeling: {reason}"
    );
    assert!(
        fixture.read_ref("refs/heads/nowhere").is_none(),
        "the reference was created for an object the repository does not have"
    );
}

/// **A tag pushed without its target is refused, and *that* one does say tag.**
///
/// The counterpart to the arm above: when a tag really is what could not be
/// followed, the operator must be told so. A diagnostic that never mentions
/// tags is as useless as one that always does.
#[test]
fn a_tag_whose_target_was_not_pushed_is_refused_as_a_tag() {
    let fixture = Fixture::build(Kind::Sha1);
    let absent =
        gix_object::compute_hash(Kind::Sha1, gix_object::Kind::Commit, b"never stored").unwrap();
    let orphan = write_object(
        &fixture.git,
        Kind::Sha1,
        gix_object::Kind::Tag,
        &tag_object(&absent, "orphan", gix_object::Kind::Commit),
    );

    let statuses = push(
        &fixture,
        &[(
            &Kind::Sha1.null().to_string(),
            &orphan.to_string(),
            "refs/tags/orphan",
        )],
        &empty_pack(Kind::Sha1),
    );

    let reason = rejection(&statuses[0]);
    assert!(
        reason.contains("tag"),
        "a tag was pushed without the commit it names and the refusal does not mention the \
         tag: {reason}"
    );
    assert!(
        reason.contains(&absent.to_string()),
        "the refusal does not say which object the tag names: {reason}"
    );
}