tests/legacy_data_shapes_test.rs
Ref: Size: 15.3 KiB History
//! Event shapes that live in repositories people still hold.
//!
//! Every shape asserted here was taken from a real repository, not invented:
//! `~/code/rad/eitri` and `~/code/rad/waystty` were written by a git-collab
//! from July 2026 and between them hold 39 `patch.create` events with no
//! `commit` and no `tree`, 33 `patch.review` events with no `revision`, 23
//! `patch.merge` events with no `commit`, and one `patch.revise`. Issue
//! `e5096ffc` removed the reader's tolerance for all four after auditing one
//! clone and the hosted server repositories — a sample that happened to contain
//! none of them — and issue `05b18df6` is what that cost.
//!
//! The rule these tests encode is not "keep everything forever". It is: **a
//! shape that can still exist in data someone holds is a shape the reader has
//! to be able to express.** A field absent from an event written before the
//! field existed is not transitional debt, because no migration can invent a
//! value for it — exactly the argument `Revision::base` was already kept on.
//! Code that only ever *wrote* a superseded shape stays removed; nothing here
//! writes any of these.
//!
//! The second, harder requirement is that reading one of these events must not
//! change its bytes. Signatures are verified by re-serializing the event
//! (`signing::canonical_json`), so a reader that helpfully filled in an absent
//! key would invalidate the signature on every event it touched — turning a
//! display bug into a verification failure, which is how a sync drops a patch.
//! Each shape below is therefore asserted to survive a sign/verify round trip.
mod common;
use common::{write_raw_event, TestRepo};
use git_collab::event::Event;
use git_collab::signing;
use git_collab::state::{PatchState, PatchStatus};
use serde_json::json;
/// The `patch.create` shape held by every legacy patch in `eitri`: a title, a
/// body, a base ref, a branch, and nothing at all about where the code stands.
/// Copied field-for-field from `refs/collab/patches/0b72bae8…`.
fn legacy_create() -> serde_json::Value {
json!({
"type": "patch.create",
"title": "feat: one-time stream tickets replace admin token in SSE URLs",
"body": "[claude 2026-07-04] Fixes 44f5b0ee (DEF-6).",
"base_ref": "main",
"branch": "fix/def6-stream-tickets",
"fixes": "44f5b0ee",
})
}
/// Write a patch DAG at `refs/collab/patches/<id>/events` from a list of
/// actions, oldest first, and return the patch id.
fn legacy_patch(repo: &TestRepo, actions: &[serde_json::Value]) -> String {
let git_repo = git2::Repository::open(repo.dir.path()).unwrap();
let mut parent = None;
let mut root = None;
for (i, action) in actions.iter().enumerate() {
let oid = write_raw_event(&git_repo, parent, action.clone(), i as u64 + 1);
root.get_or_insert(oid);
parent = Some(oid);
}
let id = root.expect("at least one event").to_string();
git_repo
.reference(
&format!("refs/collab/patches/{}/events", id),
parent.unwrap(),
false,
"events",
)
.unwrap();
id
}
/// Read the patch back through the same fold every command uses.
fn fold(repo: &TestRepo, id: &str) -> PatchState {
let git_repo = git2::Repository::open(repo.dir.path()).unwrap();
PatchState::from_ref_uncached(&git_repo, &format!("refs/collab/patches/{}/events", id), id)
.unwrap_or_else(|e| panic!("the fold must read this patch, got: {}", e))
}
/// Assert that reading and re-writing this event does not change its bytes.
///
/// This is the property that decides whether an absent key may be tolerated at
/// all: `signing::canonical_json` is what both signing and verification run
/// over, so if the round trip is not exact the event's existing signature stops
/// verifying and `sync` rejects the whole patch.
fn assert_round_trips(action: &serde_json::Value) {
let raw = json!({
"timestamp": "2026-07-04T15:37:13.808147145+00:00",
"author": { "name": "a73x", "email": "dev@a73x.sh" },
"action": action,
"clock": 1,
});
let original = serde_json::to_string(&raw).unwrap();
let event: Event = serde_json::from_str(&original)
.unwrap_or_else(|e| panic!("this shape must deserialize, got: {}\n{}", e, original));
let reserialized = String::from_utf8(signing::canonical_json(&event).unwrap()).unwrap();
assert_eq!(
original, reserialized,
"reading a legacy event must not change its bytes — its signature covers them"
);
}
// ===========================================================================
// patch.merge with no commit
// ===========================================================================
/// The exact shape in the issue report: a merge recorded before merge-recording
/// stored a commit.
fn legacy_merge() -> serde_json::Value {
json!({ "type": "patch.merge" })
}
#[test]
fn a_merge_event_with_no_commit_key_deserializes() {
assert_round_trips(&legacy_merge());
}
#[test]
fn a_merge_event_with_no_commit_key_folds_to_merged_with_no_merge_commit() {
// `merge_commit` is already `Option`, and absent has to mean `None` rather
// than an empty string surfacing as a commit id — "not recorded" is not
// "the null OID". The status is the part that matters to a reader: the
// patch *was* merged, and losing that because the commit is unknown would
// report merged work as still open.
let repo = TestRepo::new("Alice", "alice@example.com");
let id = legacy_patch(&repo, &[legacy_create(), legacy_merge()]);
let state = fold(&repo, &id);
assert_eq!(state.status, PatchStatus::Merged);
assert_eq!(
state.merge_commit, None,
"an unrecorded merge commit is unknown, not empty"
);
}
#[test]
fn a_merge_event_with_no_commit_key_is_listed_not_skipped() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = legacy_patch(&repo, &[legacy_create(), legacy_merge()]);
let out = repo.run_ok(&["patch", "list", "--all"]);
assert!(
out.contains(&id[..8]),
"a legacy merged patch must appear in the list, got:\n{}",
out
);
}
// ===========================================================================
// patch.create with no commit and no tree
// ===========================================================================
#[test]
fn a_create_event_with_no_commit_or_tree_deserializes() {
// This, not the merge event, is what actually made `eitri` unreadable:
// every one of its 17 patches failed with
// `missing field `commit` at line 14 column 3`, which is the closing brace
// of the action object in a `patch.create`.
assert_round_trips(&legacy_create());
}
#[test]
fn a_create_event_with_no_commit_or_tree_folds_to_a_revision_with_no_commit() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = legacy_patch(&repo, &[legacy_create()]);
let state = fold(&repo, &id);
assert_eq!(state.revisions.len(), 1);
assert!(
state.revisions[0].commit.is_empty(),
"a revision that recorded no commit must say so, not guess one"
);
assert_eq!(
state.revisions[0].short_commit(),
None,
"nothing may render an unrecorded commit as an abbreviated OID"
);
}
#[test]
fn a_repository_of_legacy_patches_is_not_an_empty_list() {
// The regression as the user met it: 17 patches, 17 warnings,
// `No patches found.`
let repo = TestRepo::new("Alice", "alice@example.com");
let mut ids = Vec::new();
for n in 0..3 {
let mut create = legacy_create();
create["title"] = json!(format!("legacy patch {}", n));
ids.push(legacy_patch(&repo, &[create, legacy_merge()]));
}
let output = repo.run(&["patch", "list", "--all"]);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"patch list must succeed: {}",
stderr
);
for id in &ids {
assert!(
stdout.contains(&id[..8]),
"patch {:.8} must be listed, got:\n{}\n{}",
id,
stdout,
stderr
);
}
assert!(
!stderr.contains("skipping patch"),
"no legacy patch may be skipped, got:\n{}",
stderr
);
}
#[test]
fn a_patch_that_never_recorded_a_commit_is_not_told_to_fetch_one() {
// The same defect as the ref-layout advice, one layer down. A patch with no
// recorded commit has no diff, and saying "fetch the patch's revision refs,
// or the commit it stands on" sends its owner looking for objects that were
// never written by anyone — the message reads as a fetchable problem and is
// not one. It has to say the record is what is empty, and that the rest of
// the patch is fine.
let repo = TestRepo::new("Alice", "alice@example.com");
let id = legacy_patch(&repo, &[legacy_create(), legacy_merge()]);
let output = repo.run(&["patch", "show", &id[..8]]);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
combined.contains("nothing to fetch"),
"the message must say there is nothing to fetch, got:\n{}",
combined
);
assert!(
!combined.contains("fetch the patch's revision refs"),
"and must not send the reader after objects nobody has, got:\n{}",
combined
);
// The rest of the patch still reads, which is the claim the message makes.
assert!(
String::from_utf8_lossy(&output.stdout).contains("one-time stream tickets"),
"the patch's own content must still be shown, got:\n{}",
combined
);
}
// ===========================================================================
// patch.review with no revision
// ===========================================================================
fn legacy_review(body: &str) -> serde_json::Value {
json!({ "type": "patch.review", "verdict": "Comment", "body": body })
}
#[test]
fn a_review_with_no_revision_deserializes() {
assert_round_trips(&legacy_review("looks good"));
}
#[test]
fn a_review_with_no_revision_is_read_and_left_unattributed() {
// Restoring the *field* is not restoring the attribution. Recovering a
// missing revision from the event's position in the DAG was removed for a
// real reason (issue 33b5e541): position is not signed, so two clones
// holding the same events in different parent orders could attribute one
// review to different revisions, and because attribution feeds vote
// supersession that could drop a vote rather than merely mislabel it.
//
// So an unattributed review stays unattributed. `None` is the honest
// answer and it is stable across clones, which is the property the
// guessing lacked.
let repo = TestRepo::new("Alice", "alice@example.com");
let id = legacy_patch(
&repo,
&[
legacy_create(),
legacy_review("r1 era"),
legacy_review("r2 era"),
],
);
let state = fold(&repo, &id);
assert_eq!(state.reviews.len(), 2, "both reviews must survive the fold");
for review in &state.reviews {
assert_eq!(
review.revision, None,
"a review that recorded no revision is not attributed to one"
);
}
}
#[test]
fn an_unattributed_vote_does_not_supersede_an_attributed_one() {
// Vote supersession is per (author, revision). An unattributed review is
// its own bucket rather than a wildcard: letting it collide with every
// revision would let a legacy review silently retract a current vote.
let repo = TestRepo::new("Alice", "alice@example.com");
let id = legacy_patch(
&repo,
&[
legacy_create(),
json!({ "type": "patch.review", "verdict": "Approve", "body": "old" }),
json!({ "type": "patch.review", "verdict": "Approve", "body": "new", "revision": 1 }),
],
);
let state = fold(&repo, &id);
assert_eq!(
state.reviews.len(),
2,
"an unattributed vote and a revision-1 vote are different votes, got: {:?}",
state
.reviews
.iter()
.map(|r| (&r.body, r.revision))
.collect::<Vec<_>>()
);
}
// ===========================================================================
// patch.revise
// ===========================================================================
/// The one `patch.revise` in `eitri`, byte for byte.
fn legacy_revise() -> serde_json::Value {
json!({ "type": "patch.revise", "body": null })
}
#[test]
fn a_patch_revise_event_deserializes_and_keeps_its_own_name() {
// Deliberately its own variant rather than a `#[serde(alias)]` on
// `patch.revision`. An alias reads the event and then re-serializes it
// under the *new* name, which changes the signed bytes and fails
// verification — so an alias would read the patch locally and still have
// `sync` reject it. A variant that keeps its own tag round trips.
assert_round_trips(&legacy_revise());
}
#[test]
fn a_patch_revise_event_folds_to_a_revision_with_no_commit() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = legacy_patch(&repo, &[legacy_create(), legacy_revise()]);
let state = fold(&repo, &id);
assert_eq!(
state.revisions.len(),
2,
"a revise event records a revision, even one that says nothing about a commit"
);
assert!(state.revisions[1].commit.is_empty());
assert_eq!(state.revisions[1].number, 2);
}
#[test]
fn two_revisions_that_recorded_no_commit_do_not_collapse_into_one() {
// Revisions are deduplicated by commit OID. With the `""`-means-unknown
// convention back, two revisions that both recorded nothing are not the
// same revision — dropping one would renumber every revision after it and
// silently lose a step of the patch's history.
let repo = TestRepo::new("Alice", "alice@example.com");
let id = legacy_patch(&repo, &[legacy_create(), legacy_revise(), legacy_revise()]);
let state = fold(&repo, &id);
assert_eq!(state.revisions.len(), 3);
}
// ===========================================================================
// Signatures over the whole DAG
// ===========================================================================
#[test]
fn every_legacy_shape_still_verifies_as_signed() {
// The end-to-end version of `assert_round_trips`: this is the same path
// `sync` runs before it will reconcile anything. If any shape above did not
// round trip exactly, the patch would read locally and still be refused on
// the way in from a remote.
let repo = TestRepo::new("Alice", "alice@example.com");
let id = legacy_patch(
&repo,
&[
legacy_create(),
legacy_revise(),
legacy_review("no revision recorded"),
legacy_merge(),
],
);
let git_repo = git2::Repository::open(repo.dir.path()).unwrap();
let results =
signing::verify_ref(&git_repo, &format!("refs/collab/patches/{}/events", id)).unwrap();
assert_eq!(results.len(), 4);
for r in &results {
assert_eq!(
r.status,
signing::VerifyStatus::Valid,
"commit {} must still verify: {:?}",
r.commit_id,
r.error
);
}
}