tests/crdt_test.rs
Ref: Size: 18.8 KiB
mod common;
use common::{alice, bob, init_repo, test_signing_key};
use ed25519_dalek::SigningKey;
use git2::{Oid, Repository};
use tempfile::TempDir;
use git_collab::dag;
use git_collab::event::{Action, Event};
use git_collab::state::{IssueState, IssueStatus, PatchState, PatchStatus};
fn read_event_clock(repo: &Repository, oid: Oid) -> u64 {
let commit = repo.find_commit(oid).unwrap();
let tree = commit.tree().unwrap();
let entry = tree.get_name("event.json").unwrap();
let blob = repo.find_blob(entry.id()).unwrap();
let event: Event = serde_json::from_slice(blob.content()).unwrap();
event.clock
}
// ── Phase 2: Clock propagation tests ─────────────────────────────
#[test]
fn create_root_event_sets_clock_to_1() {
let dir = TempDir::new().unwrap();
let repo = init_repo(dir.path(), &alice());
let sk = test_signing_key();
let event = Event {
timestamp: "2026-01-01T00:00:00Z".to_string(),
author: alice(),
action: Action::IssueOpen {
title: "test".to_string(),
body: "body".to_string(),
relates_to: None,
},
clock: 0, // caller passes 0, DAG should override to 1
};
let oid = dag::create_root_event(&repo, &event, &sk).unwrap();
assert_eq!(read_event_clock(&repo, oid), 1);
}
#[test]
fn append_event_increments_clock() {
let dir = TempDir::new().unwrap();
let repo = init_repo(dir.path(), &alice());
let sk = test_signing_key();
let open_event = Event {
timestamp: "2026-01-01T00:00:00Z".to_string(),
author: alice(),
action: Action::IssueOpen {
title: "test".to_string(),
body: "body".to_string(),
relates_to: None,
},
clock: 0,
};
let root_oid = dag::create_root_event(&repo, &open_event, &sk).unwrap();
let ref_name = format!("refs/collab/issues/{}", root_oid);
repo.reference(&ref_name, root_oid, false, "test").unwrap();
// First append should get clock=2
let comment_event = Event {
timestamp: "2026-01-02T00:00:00Z".to_string(),
author: alice(),
action: Action::IssueComment {
body: "comment 1".to_string(),
},
clock: 0,
};
let oid2 = dag::append_event(&repo, &ref_name, &comment_event, &sk).unwrap();
assert_eq!(read_event_clock(&repo, oid2), 2);
// Second append should get clock=3
let comment_event2 = Event {
timestamp: "2026-01-03T00:00:00Z".to_string(),
author: alice(),
action: Action::IssueComment {
body: "comment 2".to_string(),
},
clock: 0,
};
let oid3 = dag::append_event(&repo, &ref_name, &comment_event2, &sk).unwrap();
assert_eq!(read_event_clock(&repo, oid3), 3);
}
#[test]
fn max_clock_returns_highest_clock_in_dag() {
let dir = TempDir::new().unwrap();
let repo = init_repo(dir.path(), &alice());
let sk = test_signing_key();
let event = Event {
timestamp: "2026-01-01T00:00:00Z".to_string(),
author: alice(),
action: Action::IssueOpen {
title: "test".to_string(),
body: "body".to_string(),
relates_to: None,
},
clock: 0,
};
let root_oid = dag::create_root_event(&repo, &event, &sk).unwrap();
let ref_name = format!("refs/collab/issues/{}", root_oid);
repo.reference(&ref_name, root_oid, false, "test").unwrap();
assert_eq!(dag::max_clock(&repo, root_oid).unwrap(), 1);
let comment = Event {
timestamp: "2026-01-02T00:00:00Z".to_string(),
author: alice(),
action: Action::IssueComment {
body: "comment".to_string(),
},
clock: 0,
};
let tip = dag::append_event(&repo, &ref_name, &comment, &sk).unwrap();
assert_eq!(dag::max_clock(&repo, tip).unwrap(), 2);
}
// ── Phase 3: Reconcile merge clock test ──────────────────────────
#[test]
fn reconcile_merge_clock_is_max_of_both_branches_plus_one() {
let dir = TempDir::new().unwrap();
let repo = init_repo(dir.path(), &alice());
let sk = test_signing_key();
// Create root event (clock=1)
let open = Event {
timestamp: "2026-01-01T00:00:00Z".to_string(),
author: alice(),
action: Action::IssueOpen {
title: "test".to_string(),
body: "body".to_string(),
relates_to: None,
},
clock: 0,
};
let root_oid = dag::create_root_event(&repo, &open, &sk).unwrap();
let local_ref = "refs/collab/issues/test-reconcile";
let remote_ref = "refs/collab/sync/issues/test-reconcile";
repo.reference(local_ref, root_oid, false, "test").unwrap();
repo.reference(remote_ref, root_oid, false, "test").unwrap();
// Append 2 events on local (clock=2,3)
let comment1 = Event {
timestamp: "2026-01-02T00:00:00Z".to_string(),
author: alice(),
action: Action::IssueComment {
body: "local 1".to_string(),
},
clock: 0,
};
dag::append_event(&repo, local_ref, &comment1, &sk).unwrap();
let comment2 = Event {
timestamp: "2026-01-03T00:00:00Z".to_string(),
author: alice(),
action: Action::IssueComment {
body: "local 2".to_string(),
},
clock: 0,
};
dag::append_event(&repo, local_ref, &comment2, &sk).unwrap();
// Append 4 events on remote (clock=2,3,4,5)
for i in 1..=4 {
let comment = Event {
timestamp: format!("2026-02-{:02}T00:00:00Z", i),
author: bob(),
action: Action::IssueComment {
body: format!("remote {}", i),
},
clock: 0,
};
dag::append_event(&repo, remote_ref, &comment, &sk).unwrap();
}
let (merge_oid, _) = dag::reconcile(&repo, local_ref, remote_ref, &alice(), &sk).unwrap();
// Remote max is 5, local max is 3, so merge should be 6
assert_eq!(read_event_clock(&repo, merge_oid), 6);
}
// ── Phase 5: Concurrent status resolution using clock+OID ───────
#[test]
fn concurrent_issue_close_reopen_higher_clock_wins() {
// When one branch closes and another reopens at the same clock,
// the OID tiebreaker should produce a deterministic result.
// But when clocks differ, the higher clock always wins.
let dir = TempDir::new().unwrap();
let repo = init_repo(dir.path(), &alice());
let sk = test_signing_key();
// Create root issue (clock=1)
let open = Event {
timestamp: "2026-01-01T00:00:00Z".to_string(),
author: alice(),
action: Action::IssueOpen {
title: "test".to_string(),
body: "body".to_string(),
relates_to: None,
},
clock: 0,
};
let root_oid = dag::create_root_event(&repo, &open, &sk).unwrap();
let local_ref = "refs/collab/issues/test-concurrent";
let remote_ref = "refs/collab/sync/issues/test-concurrent";
repo.reference(local_ref, root_oid, false, "test").unwrap();
repo.reference(remote_ref, root_oid, false, "test").unwrap();
// Local: close (clock=2)
let close = Event {
timestamp: "2026-01-02T00:00:00Z".to_string(),
author: alice(),
action: Action::IssueClose { reason: None },
clock: 0,
};
dag::append_event(&repo, local_ref, &close, &sk).unwrap();
// Remote: add comment (clock=2) then reopen (clock=3)
let comment = Event {
timestamp: "2026-01-02T00:00:00Z".to_string(),
author: bob(),
action: Action::IssueComment {
body: "comment".to_string(),
},
clock: 0,
};
dag::append_event(&repo, remote_ref, &comment, &sk).unwrap();
let reopen = Event {
timestamp: "2026-01-03T00:00:00Z".to_string(),
author: bob(),
action: Action::IssueReopen,
clock: 0,
};
dag::append_event(&repo, remote_ref, &reopen, &sk).unwrap();
// Reconcile
dag::reconcile(&repo, local_ref, remote_ref, &alice(), &sk).unwrap();
// After reconcile: close had clock=2, reopen had clock=3, so reopen wins
let state = IssueState::from_ref(&repo, local_ref, "test-concurrent").unwrap();
assert_eq!(state.status, IssueStatus::Open);
}
#[test]
fn concurrent_issue_same_clock_oid_breaks_tie() {
// When two status changes have the same clock, higher OID (lexicographic) wins.
let dir = TempDir::new().unwrap();
let repo = init_repo(dir.path(), &alice());
let sk = test_signing_key();
// Create root issue (clock=1)
let open = Event {
timestamp: "2026-01-01T00:00:00Z".to_string(),
author: alice(),
action: Action::IssueOpen {
title: "test".to_string(),
body: "body".to_string(),
relates_to: None,
},
clock: 0,
};
let root_oid = dag::create_root_event(&repo, &open, &sk).unwrap();
let local_ref = "refs/collab/issues/test-tie";
let remote_ref = "refs/collab/sync/issues/test-tie";
repo.reference(local_ref, root_oid, false, "test").unwrap();
repo.reference(remote_ref, root_oid, false, "test").unwrap();
// Both branches: close at clock=2 (both directly from root)
let close1 = Event {
timestamp: "2026-01-02T00:00:00Z".to_string(),
author: alice(),
action: Action::IssueClose {
reason: Some("alice close".to_string()),
},
clock: 0,
};
dag::append_event(&repo, local_ref, &close1, &sk).unwrap();
let close2 = Event {
timestamp: "2026-01-02T00:00:00Z".to_string(),
author: bob(),
action: Action::IssueClose {
reason: Some("bob close".to_string()),
},
clock: 0,
};
dag::append_event(&repo, remote_ref, &close2, &sk).unwrap();
// Reconcile
dag::reconcile(&repo, local_ref, remote_ref, &alice(), &sk).unwrap();
// Both are closes with clock=2, so the issue should be closed.
// The one with the higher OID wins and determines the close reason.
let state = IssueState::from_ref(&repo, local_ref, "test-tie").unwrap();
assert_eq!(state.status, IssueStatus::Closed);
// We can't predict which OID wins, but one of the reasons should be present
assert!(
state.close_reason == Some("alice close".to_string())
|| state.close_reason == Some("bob close".to_string())
);
}
#[test]
fn concurrent_patch_close_merge_higher_clock_wins() {
let dir = TempDir::new().unwrap();
let repo = init_repo(dir.path(), &alice());
let sk = test_signing_key();
// Need a branch for the patch
let main_oid = repo.refname_to_id("refs/heads/main").unwrap();
let initial_commit = repo.find_commit(main_oid).unwrap();
repo.branch("test-branch", &initial_commit, false).unwrap();
// Create root patch (clock=1)
let create = Event {
timestamp: "2026-01-01T00:00:00Z".to_string(),
author: alice(),
action: Action::PatchCreate {
title: "test patch".to_string(),
body: "body".to_string(),
base_ref: "main".to_string(),
branch: "test-branch".to_string(),
fixes: None,
commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
base_commit: None,
},
clock: 0,
};
let root_oid = dag::create_root_event(&repo, &create, &sk).unwrap();
let local_ref = "refs/collab/patches/test-patch";
let remote_ref = "refs/collab/sync/patches/test-patch";
repo.reference(local_ref, root_oid, false, "test").unwrap();
repo.reference(remote_ref, root_oid, false, "test")
.unwrap();
// Local: close (clock=2)
let close = Event {
timestamp: "2026-01-02T00:00:00Z".to_string(),
author: alice(),
action: Action::PatchClose { reason: None },
clock: 0,
};
dag::append_event(&repo, local_ref, &close, &sk).unwrap();
// Remote: comment (clock=2), then merge (clock=3)
let comment = Event {
timestamp: "2026-01-02T00:00:00Z".to_string(),
author: bob(),
action: Action::PatchComment {
body: "lgtm".to_string(),
},
clock: 0,
};
dag::append_event(&repo, remote_ref, &comment, &sk).unwrap();
let merge = Event {
timestamp: "2026-01-03T00:00:00Z".to_string(),
author: bob(),
action: Action::PatchMerge,
clock: 0,
};
dag::append_event(&repo, remote_ref, &merge, &sk).unwrap();
// Reconcile
dag::reconcile(&repo, local_ref, remote_ref, &alice(), &sk).unwrap();
// Merge at clock=3 should win over close at clock=2
let state = PatchState::from_ref(&repo, local_ref, "test-patch").unwrap();
assert_eq!(state.status, PatchStatus::Merged);
}
// ── Phase 7: Migration tests ────────────────────────────────────
#[test]
fn migrate_clocks_assigns_sequential_clocks() {
let dir = TempDir::new().unwrap();
let repo = init_repo(dir.path(), &alice());
let sk = test_signing_key();
// Create a DAG with events that have clock=0 (simulating pre-migration data)
// We need to create commits directly to simulate old format
let open = Event {
timestamp: "2026-01-01T00:00:00Z".to_string(),
author: alice(),
action: Action::IssueOpen {
title: "test".to_string(),
body: "body".to_string(),
relates_to: None,
},
clock: 0,
};
let root_oid = dag::create_root_event(&repo, &open, &sk).unwrap();
let ref_name = "refs/collab/issues/test-migrate";
repo.reference(ref_name, root_oid, false, "test").unwrap();
// Append a couple events
let comment = Event {
timestamp: "2026-01-02T00:00:00Z".to_string(),
author: alice(),
action: Action::IssueComment {
body: "comment".to_string(),
},
clock: 0,
};
dag::append_event(&repo, ref_name, &comment, &sk).unwrap();
// After creation through the DAG functions, clocks should already be set
// Let's verify the walk returns correct clocks
let events = dag::walk_events(&repo, ref_name).unwrap();
assert_eq!(events.len(), 2);
assert_eq!(events[0].1.clock, 1);
assert_eq!(events[1].1.clock, 2);
// Now test migrate_clocks on a ref that has correct clocks (should be a no-op effectively)
dag::migrate_clocks(&repo, ref_name, &sk).unwrap();
let events_after = dag::walk_events(&repo, ref_name).unwrap();
assert_eq!(events_after.len(), 2);
assert!(events_after[0].1.clock >= 1);
assert!(events_after[1].1.clock >= 2);
}
#[test]
fn migrate_clocks_on_zero_clock_events() {
// Build a DAG with clock=0 events by writing directly (bypassing DAG functions)
let dir = TempDir::new().unwrap();
let repo = init_repo(dir.path(), &alice());
let sk = test_signing_key();
let event1 = Event {
timestamp: "2026-01-01T00:00:00Z".to_string(),
author: alice(),
action: Action::IssueOpen {
title: "test".to_string(),
body: "body".to_string(),
relates_to: None,
},
clock: 0,
};
// Write directly with clock=0 (simulating pre-CRDT data)
let oid1 = write_raw_event(&repo, &event1, &sk, &[]);
let ref_name = "refs/collab/issues/test-migrate-zero";
repo.reference(ref_name, oid1, false, "test").unwrap();
let event2 = Event {
timestamp: "2026-01-02T00:00:00Z".to_string(),
author: alice(),
action: Action::IssueComment {
body: "comment".to_string(),
},
clock: 0,
};
let parent = repo.find_commit(oid1).unwrap();
let oid2 = write_raw_event(&repo, &event2, &sk, &[&parent]);
repo.reference(ref_name, oid2, true, "test append").unwrap();
// Verify clocks are 0
assert_eq!(read_event_clock(&repo, oid1), 0);
assert_eq!(read_event_clock(&repo, oid2), 0);
// Migrate
dag::migrate_clocks(&repo, ref_name, &sk).unwrap();
// After migration, clocks should be sequential (1, 2)
let events = dag::walk_events(&repo, ref_name).unwrap();
assert_eq!(events.len(), 2);
assert_eq!(events[0].1.clock, 1);
assert_eq!(events[1].1.clock, 2);
}
// ── Phase 8: Serialization preservation test ─────────────────────
#[test]
fn clock_field_survives_serialization_round_trip() {
let event = Event {
timestamp: "2026-01-01T00:00:00Z".to_string(),
author: alice(),
action: Action::IssueOpen {
title: "test".to_string(),
body: "body".to_string(),
relates_to: None,
},
clock: 42,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"clock\":42"));
let deserialized: Event = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.clock, 42);
}
#[test]
fn clock_field_in_dag_round_trip() {
let dir = TempDir::new().unwrap();
let repo = init_repo(dir.path(), &alice());
let sk = test_signing_key();
let event = Event {
timestamp: "2026-01-01T00:00:00Z".to_string(),
author: alice(),
action: Action::IssueOpen {
title: "test".to_string(),
body: "body".to_string(),
relates_to: None,
},
clock: 0, // Caller passes 0
};
let oid = dag::create_root_event(&repo, &event, &sk).unwrap();
let ref_name = format!("refs/collab/issues/{}", oid);
repo.reference(&ref_name, oid, false, "test").unwrap();
// Read back and verify clock was set to 1 and persisted
let events = dag::walk_events(&repo, &ref_name).unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].1.clock, 1);
}
// ── Helper: write a raw event commit bypassing DAG clock logic ───
fn write_raw_event(
repo: &Repository,
event: &Event,
sk: &SigningKey,
parents: &[&git2::Commit],
) -> Oid {
use git_collab::signing::sign_event;
let detached = sign_event(event, sk).unwrap();
let json = serde_json::to_vec_pretty(event).unwrap();
let event_blob = repo.blob(&json).unwrap();
let sig_blob = repo.blob(detached.signature.as_bytes()).unwrap();
let pubkey_blob = repo.blob(detached.pubkey.as_bytes()).unwrap();
let manifest = br#"{"version":1,"format":"git-collab"}"#;
let manifest_blob = repo.blob(manifest).unwrap();
let mut tb = repo.treebuilder(None).unwrap();
tb.insert("event.json", event_blob, 0o100644).unwrap();
tb.insert("signature", sig_blob, 0o100644).unwrap();
tb.insert("pubkey", pubkey_blob, 0o100644).unwrap();
tb.insert("manifest.json", manifest_blob, 0o100644).unwrap();
let tree_oid = tb.write().unwrap();
let tree = repo.find_tree(tree_oid).unwrap();
let sig = git2::Signature::now(&event.author.name, &event.author.email).unwrap();
repo.commit(None, &sig, &sig, "raw event", &tree, parents)
.unwrap()
}