a73x

tests/commit_link_test.rs

Ref:   Size: 10.6 KiB

//! Unit tests for the Action::IssueCommitLink event variant.

use git2::Repository;
use git_collab::event::{Action, Author, Event};
use git_collab::identity::author_signature;
use git_collab::signing::sign_event;
use git_collab::state::IssueState;
use tempfile::TempDir;

mod common;
use common::{
    add_commit_link, alice, init_repo, open_issue, test_signing_key, ScopedTestConfig,
};

/// Append a commit-link event with an explicitly chosen `clock` value,
/// bypassing `dag::append_event`'s automatic clock-bump. The new commit's
/// parent is the current ref tip. Returns the new tip OID.
fn append_commit_link_with_clock(
    repo: &Repository,
    ref_name: &str,
    author: &Author,
    commit_sha: &str,
    timestamp: &str,
    clock: u64,
) -> git2::Oid {
    let sk = test_signing_key();
    let event = Event {
        timestamp: timestamp.to_string(),
        author: author.clone(),
        action: Action::IssueCommitLink {
            commit: commit_sha.to_string(),
        },
        clock,
    };

    let detached = sign_event(&event, &sk).unwrap();
    let event_json = serde_json::to_vec_pretty(&event).unwrap();
    let manifest = br#"{"version":1,"format":"git-collab"}"#;

    let event_blob = repo.blob(&event_json).unwrap();
    let sig_blob = repo.blob(detached.signature.as_bytes()).unwrap();
    let pubkey_blob = repo.blob(detached.pubkey.as_bytes()).unwrap();
    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 = author_signature(author).unwrap();
    let parent_oid = repo.refname_to_id(ref_name).unwrap();
    let parent = repo.find_commit(parent_oid).unwrap();
    repo.commit(Some(ref_name), &sig, &sig, "issue.commit_link", &tree, &[&parent])
        .unwrap()
}

fn test_author() -> Author {
    Author {
        name: "Alice".to_string(),
        email: "alice@example.com".to_string(),
    }
}

fn sha(byte: u8) -> String {
    format!("{:02x}{}", byte, "00".repeat(19))
}

#[test]
fn issue_commit_link_variant_round_trips() {
    let event = Event {
        timestamp: "2026-04-12T12:00:00Z".to_string(),
        author: test_author(),
        action: Action::IssueCommitLink {
            commit: "4b2e1cd0123456789012345678901234567890ab".to_string(),
        },
        clock: 3,
    };

    let json = serde_json::to_string(&event).expect("serialize");
    assert!(
        json.contains("\"type\":\"issue.commit_link\""),
        "expected serde tag issue.commit_link, got: {}",
        json
    );
    assert!(
        json.contains("\"commit\":\"4b2e1cd0123456789012345678901234567890ab\""),
        "expected commit field, got: {}",
        json
    );

    let parsed: Event = serde_json::from_str(&json).expect("deserialize");
    match parsed.action {
        Action::IssueCommitLink { commit } => {
            assert_eq!(commit, "4b2e1cd0123456789012345678901234567890ab");
        }
        other => panic!("expected IssueCommitLink, got {:?}", other),
    }
}

#[test]
fn issue_state_surfaces_commit_links_in_order() {
    let _config = ScopedTestConfig::new();
    let dir = TempDir::new().unwrap();
    let repo = init_repo(dir.path(), &alice());
    let (ref_name, id) = open_issue(&repo, &alice(), "bug");

    add_commit_link(&repo, &ref_name, &alice(), &sha(0xaa));
    add_commit_link(&repo, &ref_name, &alice(), &sha(0xbb));

    let issue = IssueState::from_ref_uncached(&repo, &ref_name, &id).unwrap();
    let commits: Vec<String> = issue
        .linked_commits
        .iter()
        .map(|lc| lc.commit.clone())
        .collect();
    assert_eq!(commits, vec![sha(0xaa), sha(0xbb)]);
}

#[test]
fn issue_state_dedups_commit_links_keeps_lower_clock_even_when_appended_later() {
    // This locks in the (clock, timestamp, oid) tiebreak rule. We append two
    // events for the same commit SHA in linear order, but the SECOND event we
    // append has a LOWER clock than the first — simulating a cross-machine
    // case where Bob's locally-appended event was actually authored earlier
    // (in clock terms) than Alice's. A naive first-seen-wins implementation
    // would surface Alice's event because it appears first in the topo walk;
    // the spec-correct dedup must surface Bob's lower-clock event instead.
    let _config = ScopedTestConfig::new();
    let dir = TempDir::new().unwrap();
    let repo = init_repo(dir.path(), &alice());
    let (ref_name, id) = open_issue(&repo, &alice(), "bug");

    let target_sha = sha(0xdd);

    // Alice appends with clock 50 first.
    append_commit_link_with_clock(
        &repo,
        &ref_name,
        &alice(),
        &target_sha,
        "2026-04-12T12:00:00Z",
        50,
    );
    // Bob appends second, but with a LOWER clock (10) — as if his event was
    // authored earlier on his machine and we're now seeing the merged DAG.
    append_commit_link_with_clock(
        &repo,
        &ref_name,
        &common::bob(),
        &target_sha,
        "2026-04-12T11:00:00Z",
        10,
    );

    let issue = IssueState::from_ref_uncached(&repo, &ref_name, &id).unwrap();
    assert_eq!(issue.linked_commits.len(), 1);
    assert_eq!(issue.linked_commits[0].commit, target_sha);
    // The lower-clock event wins, even though it was appended later.
    assert_eq!(
        issue.linked_commits[0].event_author.name,
        "Bob",
        "expected Bob's lower-clock event to win the tiebreak"
    );
}

#[test]
fn issue_state_dedups_commit_links_by_sha_keeping_earliest() {
    let _config = ScopedTestConfig::new();
    let dir = TempDir::new().unwrap();
    let repo = init_repo(dir.path(), &alice());
    let (ref_name, id) = open_issue(&repo, &alice(), "bug");

    // Two different emitters link the same commit. First-seen should win.
    add_commit_link(&repo, &ref_name, &alice(), &sha(0xcc));
    let bob_link = common::bob();
    add_commit_link(&repo, &ref_name, &bob_link, &sha(0xcc));

    let issue = IssueState::from_ref_uncached(&repo, &ref_name, &id).unwrap();
    assert_eq!(issue.linked_commits.len(), 1);
    assert_eq!(issue.linked_commits[0].commit, sha(0xcc));
    assert_eq!(issue.linked_commits[0].event_author.name, "Alice");
}

use git_collab::commit_link::parse_issue_trailers;

#[test]
fn parser_no_trailer_block() {
    assert_eq!(parse_issue_trailers("Just a plain commit"), Vec::<String>::new());
}

#[test]
fn parser_empty_message() {
    assert_eq!(parse_issue_trailers(""), Vec::<String>::new());
}

#[test]
fn parser_single_trailer_in_pure_block() {
    let msg = "Fix thing\n\nSome context in the body.\n\nIssue: abc";
    assert_eq!(parse_issue_trailers(msg), vec!["abc".to_string()]);
}

#[test]
fn parser_case_variants() {
    let msg1 = "subject\n\nissue: abc";
    let msg2 = "subject\n\nISSUE : abc";
    let msg3 = "subject\n\n  Issue:  abc  ";
    assert_eq!(parse_issue_trailers(msg1), vec!["abc".to_string()]);
    assert_eq!(parse_issue_trailers(msg2), vec!["abc".to_string()]);
    assert_eq!(parse_issue_trailers(msg3), vec!["abc".to_string()]);
}

#[test]
fn parser_two_trailers_in_pure_block() {
    let msg = "subject\n\nIssue: abc\nIssue: def";
    assert_eq!(parse_issue_trailers(msg), vec!["abc".to_string(), "def".to_string()]);
}

#[test]
fn parser_issue_in_body_but_not_final_paragraph() {
    let msg = "subject\n\nIssue: abc\n\nSigned-off-by: alice <a@example.com>";
    // The final paragraph is the signed-off-by block, not the issue line.
    // It's a valid trailer block (Signed-off-by is trailer-shaped), but it
    // contains no Issue: key, so we extract nothing.
    assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
}

#[test]
fn parser_wrong_key() {
    let msg = "subject\n\nIssues: abc";
    assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
}

#[test]
fn parser_prose_mention() {
    let msg = "subject\n\nthis fixes issue abc in the body";
    assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
}

#[test]
fn parser_single_paragraph_whole_message_is_trailer_block() {
    let msg = "Issue: abc";
    assert_eq!(parse_issue_trailers(msg), vec!["abc".to_string()]);
}

#[test]
fn parser_mixed_final_paragraph_rejects_all() {
    let msg = "subject\n\nThanks to Bob for the catch.\nIssue: a3f9";
    // Final paragraph has a prose line, so it's not a trailer block and we
    // extract nothing. This is the "false positive in prose" guard.
    assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
}

#[test]
fn parser_trailing_whitespace_paragraph_does_not_shadow_trailer_block() {
    // The final paragraph is empty/whitespace, so the walk should fall back
    // to the previous non-empty paragraph, which is a valid trailer block.
    let msg = "subject\n\nIssue: abc\n\n   \n";
    assert_eq!(parse_issue_trailers(msg), vec!["abc".to_string()]);
}

#[test]
fn parser_pure_block_with_mixed_keys() {
    let msg = "subject\n\nSigned-off-by: alice <a@example.com>\nIssue: abc";
    assert_eq!(parse_issue_trailers(msg), vec!["abc".to_string()]);
}

#[test]
fn parser_rejects_value_with_trailing_garbage() {
    let msg = "subject\n\nIssue: abc fixes thing";
    assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
}

#[test]
fn parser_rejects_empty_value() {
    let msg = "subject\n\nIssue:   ";
    assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
}

use git_collab::commit_link::collect_linked_shas;

#[test]
fn collect_linked_shas_empty_for_fresh_issue() {
    let _config = ScopedTestConfig::new();
    let dir = TempDir::new().unwrap();
    let repo = init_repo(dir.path(), &alice());
    let (ref_name, _id) = open_issue(&repo, &alice(), "bug");

    let shas = collect_linked_shas(&repo, &ref_name).unwrap();
    assert!(shas.is_empty());
}

#[test]
fn collect_linked_shas_returns_all_linked_commits_including_duplicates() {
    let _config = ScopedTestConfig::new();
    let dir = TempDir::new().unwrap();
    let repo = init_repo(dir.path(), &alice());
    let (ref_name, _id) = open_issue(&repo, &alice(), "bug");

    add_commit_link(&repo, &ref_name, &alice(), &sha(0xaa));
    add_commit_link(&repo, &ref_name, &alice(), &sha(0xbb));
    // Even a duplicate DAG entry (cross-machine race) is surfaced here —
    // this is the "source of truth" for whether we need to emit.
    add_commit_link(&repo, &ref_name, &alice(), &sha(0xaa));

    let shas = collect_linked_shas(&repo, &ref_name).unwrap();
    assert_eq!(shas.len(), 2);
    assert!(shas.contains(&sha(0xaa)));
    assert!(shas.contains(&sha(0xbb)));
}