a73x

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()
}