a73x

ba27ce44

Add collect_linked_shas helper to commit_link module

alex emery   2026-04-12 06:42

Walks an issue's DAG and returns every SHA appearing in an
IssueCommitLink event. Used by scan_and_link as the per-issue
dedup source of truth, cached on first match per sync.

diff --git a/src/commit_link.rs b/src/commit_link.rs
index 0e1955d..cd5f631 100644
--- a/src/commit_link.rs
+++ b/src/commit_link.rs
@@ -2,6 +2,29 @@
//!
//! See: docs/superpowers/specs/2026-04-12-commit-issue-link-design.md

use std::collections::HashSet;

use git2::Repository;

use crate::dag;
use crate::error::Error;
use crate::event::Action;

/// Walk an issue's event DAG and return every commit SHA that has an
/// `IssueCommitLink` event attached. Called lazily on first match per issue
/// during `scan_and_link`; the result is cached in the orchestrator's
/// `HashMap<RefName, HashSet<String>>`.
pub fn collect_linked_shas(repo: &Repository, issue_ref: &str) -> Result<HashSet<String>, Error> {
    let events = dag::walk_events(repo, issue_ref)?;
    let mut shas = HashSet::new();
    for (_oid, event) in events {
        if let Action::IssueCommitLink { commit } = event.action {
            shas.insert(commit);
        }
    }
    Ok(shas)
}

/// Parse `Issue:` trailers from a commit message.
///
/// Returns the list of trailer values in order of appearance. Follows git's
diff --git a/tests/commit_link_test.rs b/tests/commit_link_test.rs
index bd07976..dabf514 100644
--- a/tests/commit_link_test.rs
+++ b/tests/commit_link_test.rs
@@ -181,3 +181,35 @@ 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)));
}