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