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