docs/superpowers/plans/2026-04-12-commit-issue-link.md
Ref: Size: 62.8 KiB
# Auto-Link Commits to Issues Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** During `git-collab sync`, scan all local branches for commits whose messages contain an `Issue: <id>` trailer and emit idempotent `issue.commit_link` events on the matching issue DAGs, then render them under the issue in both the CLI and TUI.
**Architecture:** One new event variant `Action::IssueCommitLink { commit }`. One new module `src/commit_link.rs` holding a pure trailer parser, a per-issue linked-SHA helper, and a `scan_and_link` orchestrator that walks `refs/heads/*`, dedups per-sync via `HashSet<Oid>`, and lazily reads each matched issue's event log for a cross-sync `HashMap<RefName, HashSet<String>>` dedup cache. `sync::sync` calls `scan_and_link` after `reconcile_refs("patches")` and before `collect_push_refs`, so any new link events are pushed in the same sync. `IssueState` gains a `linked_commits: Vec<LinkedCommit>` field populated by `from_ref_uncached`, which dedups by commit SHA at materialization time (the walk is topo-oldest-first, so the first-seen event wins). Both the CLI `issue show` and the TUI issue-detail widget render a new `--- Linked Commits ---` section under `--- Comments ---`.
**Tech Stack:** Rust 2021, git2 0.19, serde 1, ed25519-dalek 2. No new dependencies — trailer parsing is hand-rolled string matching (no regex crate in this project).
**Spec reference:** `docs/superpowers/specs/2026-04-12-commit-issue-link-design.md`
---
## File Structure
| File | Role |
|------|------|
| `src/event.rs` | **Modify.** Add `Action::IssueCommitLink { commit }` variant. |
| `src/state.rs` | **Modify.** Add `LinkedCommit` struct, `IssueState::linked_commits` field, materializer arm in `from_ref_uncached` with dedup. |
| `src/commit_link.rs` | **Create.** Pure trailer parser, `collect_linked_shas`, `scan_and_link`. |
| `src/lib.rs` | **Modify.** Declare the `commit_link` module. Add `--- Linked Commits ---` rendering to the `IssueCmd::Show` handler. |
| `src/sync.rs` | **Modify.** Call `commit_link::scan_and_link` between `reconcile_refs("patches")` and `collect_push_refs`. |
| `src/tui/widgets.rs` | **Modify.** Add `--- Linked Commits ---` section to `build_issue_detail` after comments. |
| `tests/common/mod.rs` | **Modify.** Add `add_commit_link` helper mirroring `add_comment`. |
| `tests/commit_link_test.rs` | **Create.** Unit tests for `parse_issue_trailers` (table-driven). |
| `tests/sync_test.rs` | **Modify.** Add integration tests for the full sync→link flow. |
---
## Task 1: Add the `IssueCommitLink` event variant
**Files:**
- Modify: `src/event.rs` (after `Action::IssueReopen` at line 58)
- Test: `tests/commit_link_test.rs` (new — start the file here)
- [ ] **Step 1: Create the test file with a serde round-trip test that will fail because the variant doesn't exist yet**
Create `tests/commit_link_test.rs`:
```rust
//! Unit tests for src/commit_link.rs and the Action::IssueCommitLink variant.
use git_collab::event::{Action, Author, Event};
fn test_author() -> Author {
Author {
name: "Alice".to_string(),
email: "alice@example.com".to_string(),
}
}
#[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),
}
}
```
- [ ] **Step 2: Run the test and confirm it fails**
Run: `cargo test --test commit_link_test issue_commit_link_variant_round_trips`
Expected: FAIL with `no variant or associated item named 'IssueCommitLink' found for enum 'Action'` (or similar).
- [ ] **Step 3: Add the variant to `src/event.rs`**
Open `src/event.rs`. Locate `Action::IssueReopen` (around line 58). After it (before the `PatchCreate` variant), insert:
```rust
#[serde(rename = "issue.commit_link")]
IssueCommitLink {
commit: String,
},
```
- [ ] **Step 4: Run the test and confirm it passes**
Run: `cargo test --test commit_link_test issue_commit_link_variant_round_trips`
Expected: PASS.
- [ ] **Step 5: Run the full test suite to verify nothing regressed**
Run: `cargo test`
Expected: PASS. If any pre-existing `match event.action { ... }` now warns about a non-exhaustive pattern, that's only a warning — it's caught by the `_ => {}` arm already present at `src/state.rs:339` and `src/state.rs:551`. Don't silence warnings here; later tasks will add explicit arms where needed.
- [ ] **Step 6: Commit**
```bash
git add src/event.rs tests/commit_link_test.rs
git commit -m "Add Action::IssueCommitLink event variant
Introduces the new event kind that will be emitted during sync when a
commit's message contains an Issue: trailer. Variant only; no emission
sites or renderers yet."
```
---
## Task 2: Extend `IssueState` with `linked_commits` and populate with dedup
**Files:**
- Modify: `src/state.rs` (struct definition around line 85, materializer around line 237)
- Modify: `tests/common/mod.rs` (add `add_commit_link` helper for reuse)
- Test: `tests/commit_link_test.rs` (extend with materializer tests)
- [ ] **Step 1: Add the `add_commit_link` test helper**
Open `tests/common/mod.rs`. After `add_comment` (around line 229), add:
```rust
/// Append an IssueCommitLink event to an issue ref. Returns the new DAG tip OID.
pub fn add_commit_link(
repo: &Repository,
ref_name: &str,
author: &Author,
commit_sha: &str,
) -> git2::Oid {
let sk = test_signing_key();
let event = Event {
timestamp: now(),
author: author.clone(),
action: Action::IssueCommitLink {
commit: commit_sha.to_string(),
},
clock: 0,
};
dag::append_event(repo, ref_name, &event, &sk).unwrap()
}
```
- [ ] **Step 2: Write a failing test for the materializer**
Append to `tests/commit_link_test.rs`:
```rust
use git2::Repository;
use git_collab::state::{self, IssueState};
use tempfile::TempDir;
mod common;
use common::{add_commit_link, alice, init_repo, open_issue, ScopedTestConfig};
fn sha(byte: u8) -> String {
format!("{:02x}{}", byte, "00".repeat(19))
}
#[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<&str> = issue
.linked_commits
.iter()
.map(|lc| lc.commit.as_str())
.collect();
assert_eq!(commits, vec![sha(0xaa), sha(0xbb)]);
}
#[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");
}
```
Note: `mod common;` inside `tests/commit_link_test.rs` references `tests/common/mod.rs` via the shared test-helper convention already used in `tests/sync_test.rs:8`.
- [ ] **Step 3: Run the tests and confirm they fail**
Run: `cargo test --test commit_link_test issue_state_surfaces_commit_links_in_order issue_state_dedups_commit_links_by_sha_keeping_earliest`
Expected: FAIL — `IssueState` has no `linked_commits` field.
- [ ] **Step 4: Add `LinkedCommit` and the `linked_commits` field**
Open `src/state.rs`. After the `Comment` struct (ends around line 82), add:
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkedCommit {
/// Full 40-char commit SHA from the trailer.
pub commit: String,
/// Author of the `IssueCommitLink` event (who ran sync).
pub event_author: Author,
/// Timestamp of the `IssueCommitLink` event.
pub event_timestamp: String,
}
```
In the `IssueState` struct (around line 85), add a new field at the end (keep `relates_to` last is fine; add `linked_commits` just before `relates_to`):
```rust
#[serde(default)]
pub linked_commits: Vec<LinkedCommit>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relates_to: Option<String>,
```
`#[serde(default)]` ensures older cached `IssueState` JSON (which has no `linked_commits` key) still deserializes.
- [ ] **Step 5: Initialize the field in the `IssueOpen` arm of `from_ref_uncached`**
In `src/state.rs:256`, locate the `Action::IssueOpen { ... } => { state = Some(IssueState { ... }); }` block and add `linked_commits: Vec::new(),` alongside the existing `comments: Vec::new(),` initialization:
```rust
Action::IssueOpen { title, body, relates_to } => {
state = Some(IssueState {
id: id.to_string(),
title,
body,
status: IssueStatus::Open,
close_reason: None,
closed_by: None,
labels: Vec::new(),
assignees: Vec::new(),
comments: Vec::new(),
linked_commits: Vec::new(),
created_at: event.timestamp.clone(),
last_updated: String::new(),
author: event.author.clone(),
relates_to,
});
}
```
- [ ] **Step 6: Add an `IssueCommitLink` arm to the materializer**
Still in `from_ref_uncached`, before the catchall `_ => {}` arm (around line 339), add:
```rust
Action::IssueCommitLink { commit } => {
if let Some(ref mut s) = state {
// Render-time dedup by commit SHA. The revwalk is
// topo-oldest-first (Sort::TOPOLOGICAL | Sort::REVERSE),
// so the first event we see for a given SHA is the
// earliest emission — exactly what the spec requires.
if !s.linked_commits.iter().any(|lc| lc.commit == commit) {
s.linked_commits.push(LinkedCommit {
commit,
event_author: event.author.clone(),
event_timestamp: event.timestamp.clone(),
});
}
}
}
```
- [ ] **Step 7: Run the tests and confirm they pass**
Run: `cargo test --test commit_link_test issue_state_surfaces_commit_links_in_order issue_state_dedups_commit_links_by_sha_keeping_earliest`
Expected: PASS.
- [ ] **Step 8: Run the full test suite**
Run: `cargo test`
Expected: PASS. The `#[serde(default)]` keeps backward compatibility with cached issue state.
- [ ] **Step 9: Commit**
```bash
git add src/state.rs tests/common/mod.rs tests/commit_link_test.rs
git commit -m "Surface linked commits on IssueState with first-seen dedup
Adds LinkedCommit + IssueState.linked_commits, populated from the new
event variant in IssueState::from_ref_uncached. Walking topo-oldest-
first means the first-seen event for a given SHA wins, which matches
the spec's render-time dedup rule. serde(default) keeps older cached
state compatible."
```
---
## Task 3: Create `src/commit_link.rs` with the trailer parser
**Files:**
- Create: `src/commit_link.rs`
- Modify: `src/lib.rs` (module declaration)
- Test: `tests/commit_link_test.rs`
- [ ] **Step 1: Write the full parser test table**
Append to `tests/commit_link_test.rs`:
```rust
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());
}
```
- [ ] **Step 2: Run the tests to confirm they all fail**
Run: `cargo test --test commit_link_test parser_`
Expected: FAIL — `parse_issue_trailers` and the `commit_link` module don't exist.
- [ ] **Step 3: Create `src/commit_link.rs` with the parser**
Create `src/commit_link.rs`:
```rust
//! Auto-link commits to issues via `Issue:` git trailers during sync.
//!
//! See: docs/superpowers/specs/2026-04-12-commit-issue-link-design.md
/// Parse `Issue:` trailers from a commit message.
///
/// Returns the list of trailer values in order of appearance. Follows git's
/// own trailer-block semantics: only the final paragraph is considered, and
/// *every* non-empty line in it must be trailer-shaped (a `token: value`
/// line) for the paragraph to qualify. Any prose line in the final paragraph
/// disqualifies the whole paragraph — this prevents false positives like
/// `"Thanks Bob.\nIssue: abc"` in commit bodies.
///
/// The key match is `(?i)issue`; the value must be a single non-whitespace
/// token followed by optional trailing whitespace and end-of-line. Values
/// like `abc fixes thing` are rejected so that loose commentary never
/// becomes a silent issue-prefix lookup that warns every sync forever.
pub fn parse_issue_trailers(message: &str) -> Vec<String> {
// 1. Split into paragraphs (blank-line separated), preserving order.
// Trim trailing whitespace from each line for the trailer-shape check,
// but keep enough structure to recognize blank lines.
let lines: Vec<&str> = message.lines().collect();
// 2. Find the last paragraph: the longest tail slice that contains at
// least one non-empty line and has no blank line *before* its first
// non-empty line in the tail.
//
// Walking from the end: skip trailing blank/whitespace-only lines,
// then collect lines until we hit a blank line.
let mut end = lines.len();
while end > 0 && lines[end - 1].trim().is_empty() {
end -= 1;
}
if end == 0 {
return Vec::new();
}
let mut start = end;
while start > 0 && !lines[start - 1].trim().is_empty() {
start -= 1;
}
let paragraph = &lines[start..end];
// 3. Validate every non-empty line in the paragraph is trailer-shaped.
for line in paragraph {
if line.trim().is_empty() {
continue;
}
if !is_trailer_shaped(line) {
return Vec::new();
}
}
// 4. Extract `Issue:` values.
let mut out = Vec::new();
for line in paragraph {
if let Some(value) = match_issue_line(line) {
out.push(value);
}
}
out
}
/// Returns true if a line looks like a git trailer: `<token>: <value>`, where
/// token starts with a letter and consists of `[A-Za-z0-9-]`, and value is at
/// least one non-whitespace character.
fn is_trailer_shaped(line: &str) -> bool {
let trimmed = line.trim_start();
let Some(colon_pos) = trimmed.find(':') else {
return false;
};
let token = &trimmed[..colon_pos];
if token.is_empty() {
return false;
}
let mut chars = token.chars();
let first = chars.next().unwrap();
if !first.is_ascii_alphabetic() {
return false;
}
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '-') {
return false;
}
let value = trimmed[colon_pos + 1..].trim();
!value.is_empty()
}
/// If `line` is an `Issue: <token>` trailer with exactly one non-whitespace
/// token in its value, returns the token. Otherwise returns None.
fn match_issue_line(line: &str) -> Option<String> {
let trimmed = line.trim_start();
let colon_pos = trimmed.find(':')?;
let key = trimmed[..colon_pos].trim_end();
if !key.eq_ignore_ascii_case("issue") {
return None;
}
let value_region = &trimmed[colon_pos + 1..];
let value = value_region.trim();
if value.is_empty() {
return None;
}
// Reject values with interior whitespace: `abc fixes thing` must not
// parse to `abc` silently — it must parse to nothing so the user sees
// that their commentary is being ignored.
if value.split_whitespace().count() != 1 {
return None;
}
Some(value.to_string())
}
```
- [ ] **Step 4: Declare the module in `src/lib.rs`**
Open `src/lib.rs`. At the top of the module declarations (lines 1–17), insert alphabetically after `pub mod cli;`:
```rust
pub mod commit_link;
```
Final ordering should be:
```rust
pub mod cache;
pub mod cli;
pub mod commit_link;
pub mod dag;
pub mod editor;
...
```
- [ ] **Step 5: Run the parser tests and confirm all pass**
Run: `cargo test --test commit_link_test parser_`
Expected: PASS for all 14 parser tests.
- [ ] **Step 6: Run clippy on the new module**
Run: `cargo clippy --all-targets -- -D warnings`
Expected: no warnings.
- [ ] **Step 7: Commit**
```bash
git add src/commit_link.rs src/lib.rs tests/commit_link_test.rs
git commit -m "Add src/commit_link.rs with strict Issue: trailer parser
Implements the spec's strict trailer-block semantics: the final
paragraph qualifies only if every non-empty line is trailer-shaped,
which rejects prose false positives like 'Thanks Bob.\\nIssue: a3f9'.
The Issue: value must be a single token, so loose commentary like
'Issue: abc fixes thing' is also rejected. Pure parser, no git or I/O,
14 table-driven unit tests."
```
---
## Task 4: Add `collect_linked_shas` helper
**Files:**
- Modify: `src/commit_link.rs`
- Test: `tests/commit_link_test.rs`
- [ ] **Step 1: Write a failing test**
Append to `tests/commit_link_test.rs`:
```rust
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)));
}
```
- [ ] **Step 2: Run the tests and confirm they fail**
Run: `cargo test --test commit_link_test collect_linked_shas_`
Expected: FAIL — `collect_linked_shas` is not defined.
- [ ] **Step 3: Add `collect_linked_shas` to `src/commit_link.rs`**
Append to `src/commit_link.rs`:
```rust
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)
}
```
- [ ] **Step 4: Run the tests and confirm they pass**
Run: `cargo test --test commit_link_test collect_linked_shas_`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/commit_link.rs tests/commit_link_test.rs
git commit -m "Add collect_linked_shas helper to commit_link module
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."
```
---
## Task 5: Implement `scan_and_link` orchestrator with a happy-path integration test
**Files:**
- Modify: `src/commit_link.rs`
- Test: `tests/sync_test.rs` (add the first integration test)
- [ ] **Step 1: Write the happy-path integration test**
Open `tests/sync_test.rs`. Find the end of the existing test module (near line 1200+) and append a new section:
```rust
// ---------------------------------------------------------------------------
// Commit-link tests (src/commit_link.rs)
// ---------------------------------------------------------------------------
use git_collab::commit_link;
fn make_commit_with_message(cluster: &TestCluster, repo: &Repository, message: &str) -> git2::Oid {
let _ = cluster; // silence unused if not needed
let sig = git2::Signature::now("Alice", "alice@example.com").unwrap();
let head = repo.head().unwrap();
let parent_oid = head.target().unwrap();
let parent = repo.find_commit(parent_oid).unwrap();
let tree_oid = parent.tree().unwrap().id();
let tree = repo.find_tree(tree_oid).unwrap();
// Commit on current branch (refs/heads/main).
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])
.unwrap()
}
#[test]
fn commit_link_scan_emits_event_for_matching_trailer() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
// Open an issue.
let (issue_ref, issue_id) = open_issue(&alice_repo, &alice(), "fix the walker");
// Create a commit whose trailer references that issue.
let message = format!("Fix walker\n\nIssue: {}", &issue_id[..8]);
let commit_oid = make_commit_with_message(&cluster, &alice_repo, &message);
// Run the scanner directly (we test the sync integration in later tests).
let author = git_collab::identity::get_author(&alice_repo).unwrap();
let sk = signing::load_signing_key(
&signing::signing_key_dir().unwrap(),
)
.unwrap();
let emitted = commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap();
assert_eq!(emitted, 1);
// Walk the issue's event log and find the link.
let issue = IssueState::from_ref_uncached(&alice_repo, &issue_ref, &issue_id).unwrap();
assert_eq!(issue.linked_commits.len(), 1);
assert_eq!(issue.linked_commits[0].commit, commit_oid.to_string());
}
```
- [ ] **Step 2: Run the test and confirm it fails**
Run: `cargo test --test sync_test commit_link_scan_emits_event_for_matching_trailer`
Expected: FAIL — `scan_and_link` is not defined.
- [ ] **Step 3: Add `scan_and_link` to `src/commit_link.rs`**
Append to `src/commit_link.rs`:
```rust
use std::collections::HashMap;
use git2::{Oid, Sort};
use crate::event::{Author, Event};
const ACTIVE_ISSUE_PREFIX: &str = "refs/collab/issues/";
const ARCHIVED_ISSUE_PREFIX: &str = "refs/collab/archive/issues/";
/// Walk every commit reachable from `refs/heads/*`, parse `Issue:` trailers,
/// resolve each to an issue, and emit an `IssueCommitLink` event for any
/// (issue, commit) pair that doesn't already have one.
///
/// **Never breaks sync.** Per-commit and per-issue errors are logged as
/// one-line stderr warnings and iteration continues. The only errors that
/// propagate are "couldn't even start" failures (opening the repo, building
/// the revwalk). Callers treat a returned `Err` as "skip the link scan for
/// this sync" and proceed.
///
/// Returns the number of events actually emitted.
pub fn scan_and_link(
repo: &Repository,
author: &Author,
sk: &ed25519_dalek::SigningKey,
) -> Result<usize, Error> {
// Build a revwalk seeded from every local branch tip.
let mut revwalk = repo.revwalk()?;
revwalk.set_sorting(Sort::TOPOLOGICAL)?;
let mut seeded_any = false;
for reference in repo.references_glob("refs/heads/*")? {
let Ok(reference) = reference else { continue };
let Some(target) = reference.target() else { continue };
// `revwalk.push` dedups commits across branch tips internally.
if revwalk.push(target).is_ok() {
seeded_any = true;
}
}
if !seeded_any {
// Detached HEAD with no local branches. Silent no-op per spec.
return Ok(0);
}
// Per-sync dedup of commits already visited.
let mut visited: HashSet<Oid> = HashSet::new();
// Cache of existing link SHAs per resolved issue ref. `None` = poisoned.
let mut link_cache: HashMap<String, Option<HashSet<String>>> = HashMap::new();
let mut emitted: usize = 0;
for oid_result in revwalk {
let oid = match oid_result {
Ok(o) => o,
Err(e) => {
eprintln!("warning: revwalk error, stopping scan: {}", e);
break;
}
};
if !visited.insert(oid) {
continue;
}
let commit = match repo.find_commit(oid) {
Ok(c) => c,
Err(e) => {
eprintln!("warning: cannot load commit {}: {}", oid, e);
continue;
}
};
let message = commit.message().unwrap_or("");
let trailers = parse_issue_trailers(message);
if trailers.is_empty() {
continue;
}
for prefix in trailers {
match crate::state::resolve_issue_ref(repo, &prefix) {
Ok((resolved_ref, _resolved_id)) => {
if resolved_ref.starts_with(ARCHIVED_ISSUE_PREFIX) {
eprintln!(
"warning: commit {}: Issue: {} — issue is archived, skipping",
oid, prefix
);
continue;
}
if !resolved_ref.starts_with(ACTIVE_ISSUE_PREFIX) {
// Unknown namespace. Should not happen with current
// resolver, but belt-and-braces.
eprintln!(
"warning: commit {}: Issue: {} — resolved to unexpected ref {}, skipping",
oid, prefix, resolved_ref
);
continue;
}
let entry = link_cache.entry(resolved_ref.clone()).or_insert_with(|| {
match collect_linked_shas(repo, &resolved_ref) {
Ok(set) => Some(set),
Err(e) => {
eprintln!(
"warning: cannot read link events for {}: {} — skipping issue for the rest of this sync",
resolved_ref, e
);
None
}
}
});
let Some(ref mut set) = entry else { continue };
let sha = oid.to_string();
if set.contains(&sha) {
continue;
}
let event = Event {
timestamp: chrono::Utc::now().to_rfc3339(),
author: author.clone(),
action: Action::IssueCommitLink {
commit: sha.clone(),
},
clock: 0,
};
match dag::append_event(repo, &resolved_ref, &event, sk) {
Ok(_) => {
set.insert(sha);
emitted += 1;
}
Err(e) => {
eprintln!(
"warning: failed to emit IssueCommitLink on {}: {}",
resolved_ref, e
);
}
}
}
Err(e) => {
// resolve_issue_ref error message already distinguishes
// "no issue found" from "ambiguous prefix".
eprintln!(
"warning: commit {}: Issue: {} — {}, skipping",
oid, prefix, e
);
}
}
}
}
Ok(emitted)
}
```
- [ ] **Step 4: Run the integration test and confirm it passes**
Run: `cargo test --test sync_test commit_link_scan_emits_event_for_matching_trailer`
Expected: PASS. The test opens an issue, writes a commit with `Issue: <prefix>`, calls `scan_and_link` directly, and confirms the link event was appended.
- [ ] **Step 5: Run clippy**
Run: `cargo clippy --all-targets -- -D warnings`
Expected: no warnings.
- [ ] **Step 6: Commit**
```bash
git add src/commit_link.rs tests/sync_test.rs
git commit -m "Implement commit_link::scan_and_link orchestrator
Walks refs/heads/* (dedup'd via HashSet<Oid>), parses Issue: trailers
from each commit, resolves to an issue ref, and appends IssueCommitLink
events. Per-sync cache keyed by resolved ref name absorbs repeat
matches; all per-commit and per-issue errors become stderr warnings so
the scan never breaks sync. Archived issues are skipped with a warning."
```
---
## Task 6: Wire `scan_and_link` into `sync::sync`
**Files:**
- Modify: `src/sync.rs` (after `reconcile_refs("patches", ...)` at line 365)
- Modify: `tests/sync_test.rs` (confirm the wiring runs end-to-end via `sync::sync`)
- [ ] **Step 1: Write an end-to-end test driving the full `sync::sync` entry point**
Append to `tests/sync_test.rs` below the existing commit-link section:
```rust
#[test]
fn sync_entry_point_emits_commit_link_events_and_pushes_them() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
// Alice opens an issue and syncs it up so Bob will see it too.
let (_issue_ref, issue_id) = open_issue(&alice_repo, &alice(), "bug");
sync::sync(&alice_repo, "origin").unwrap();
// Alice makes a real commit with an Issue: trailer.
let message = format!("Fix the thing\n\nIssue: {}", &issue_id[..8]);
make_commit_with_message(&cluster, &alice_repo, &message);
// Running sync should scan, emit the link, and push it.
sync::sync(&alice_repo, "origin").unwrap();
// Bob fetches and should see the link in the materialized issue.
let bob_repo = cluster.bob_repo();
sync::sync(&bob_repo, "origin").unwrap();
let bob_issue_ref = format!("refs/collab/issues/{}", issue_id);
let bob_issue = IssueState::from_ref_uncached(&bob_repo, &bob_issue_ref, &issue_id).unwrap();
assert_eq!(bob_issue.linked_commits.len(), 1);
}
```
- [ ] **Step 2: Run the test and confirm it fails**
Run: `cargo test --test sync_test sync_entry_point_emits_commit_link_events_and_pushes_them`
Expected: FAIL — `sync::sync` doesn't call `scan_and_link` yet, so no link events exist for Bob to fetch.
- [ ] **Step 3: Wire the call into `src/sync.rs`**
Open `src/sync.rs`. Find the block around line 362–366:
```rust
let repo = Repository::open(repo.path())?;
let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
reconcile_refs(&repo, "issues", &author, &sk)?;
reconcile_refs(&repo, "patches", &author, &sk)?;
// Step 3: Push collab refs individually
```
Insert the scan call between `reconcile_refs("patches", ...)` and `// Step 3: Push collab refs individually`:
```rust
let repo = Repository::open(repo.path())?;
let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
reconcile_refs(&repo, "issues", &author, &sk)?;
reconcile_refs(&repo, "patches", &author, &sk)?;
// Step 2.5: Scan local branches for Issue: trailers and emit link events.
// Never breaks sync — scan_and_link absorbs per-commit/per-issue errors.
match crate::commit_link::scan_and_link(&repo, &author, &sk) {
Ok(n) if n > 0 => println!("Linked {} commit(s) to issues.", n),
Ok(_) => {}
Err(e) => eprintln!("warning: commit link scan failed: {}", e),
}
// Step 3: Push collab refs individually
```
- [ ] **Step 4: Add the missing `crate::commit_link` import (if clippy wants it)**
`crate::commit_link::scan_and_link` is fully qualified in the call above, so no `use` is needed. Leave as-is.
- [ ] **Step 5: Run the integration test and confirm it passes**
Run: `cargo test --test sync_test sync_entry_point_emits_commit_link_events_and_pushes_them`
Expected: PASS.
- [ ] **Step 6: Run the full sync test suite**
Run: `cargo test --test sync_test`
Expected: PASS. Existing sync tests should be unaffected because no existing test creates commits with `Issue:` trailers.
- [ ] **Step 7: Commit**
```bash
git add src/sync.rs tests/sync_test.rs
git commit -m "Wire commit_link::scan_and_link into the sync pipeline
Called after reconcile_refs so the dedup cache reflects merged remote
state, and before collect_push_refs so new link events ride out in
the same sync. End-to-end test: Alice writes a commit with an
Issue: trailer, sync emits+pushes, Bob fetches and materializes the
linked commit on the issue."
```
---
## Task 7: Add the remaining integration tests
**Files:**
- Modify: `tests/sync_test.rs`
- [ ] **Step 1: Add idempotency and multi-branch tests**
Append to `tests/sync_test.rs`:
```rust
#[test]
fn commit_link_scan_is_idempotent_across_runs() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let (issue_ref, issue_id) = open_issue(&alice_repo, &alice(), "bug");
let message = format!("Fix thing\n\nIssue: {}", &issue_id[..8]);
make_commit_with_message(&cluster, &alice_repo, &message);
let author = git_collab::identity::get_author(&alice_repo).unwrap();
let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 1);
assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 0);
assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 0);
let issue = IssueState::from_ref_uncached(&alice_repo, &issue_ref, &issue_id).unwrap();
assert_eq!(issue.linked_commits.len(), 1);
}
#[test]
fn commit_link_scan_walks_all_local_branches_and_dedups_shared_ancestors() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let (issue_ref, issue_id) = open_issue(&alice_repo, &alice(), "bug");
// Commit on main with the trailer. Both branches will reach it.
let message = format!("Fix\n\nIssue: {}", &issue_id[..8]);
let linked_commit = make_commit_with_message(&cluster, &alice_repo, &message);
// Create a second branch pointing at the same commit.
{
let commit = alice_repo.find_commit(linked_commit).unwrap();
alice_repo.branch("feature-x", &commit, false).unwrap();
}
let author = git_collab::identity::get_author(&alice_repo).unwrap();
let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
// Should emit exactly one event despite the commit being reachable from
// two branch tips.
assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 1);
let issue = IssueState::from_ref_uncached(&alice_repo, &issue_ref, &issue_id).unwrap();
assert_eq!(issue.linked_commits.len(), 1);
}
#[test]
fn commit_link_scan_handles_multiple_issue_trailers_on_one_commit() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let (issue_ref_a, id_a) = open_issue(&alice_repo, &alice(), "bug a");
let (issue_ref_b, id_b) = open_issue(&alice_repo, &alice(), "bug b");
let message = format!(
"Fix both\n\nIssue: {}\nIssue: {}",
&id_a[..8],
&id_b[..8]
);
make_commit_with_message(&cluster, &alice_repo, &message);
let author = git_collab::identity::get_author(&alice_repo).unwrap();
let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 2);
let issue_a = IssueState::from_ref_uncached(&alice_repo, &issue_ref_a, &id_a).unwrap();
let issue_b = IssueState::from_ref_uncached(&alice_repo, &issue_ref_b, &id_b).unwrap();
assert_eq!(issue_a.linked_commits.len(), 1);
assert_eq!(issue_b.linked_commits.len(), 1);
}
```
- [ ] **Step 2: Run these three tests**
Run: `cargo test --test sync_test commit_link_scan_is_idempotent commit_link_scan_walks_all commit_link_scan_handles_multiple`
Expected: PASS.
- [ ] **Step 3: Add unknown/ambiguous/archived prefix tests**
Append to `tests/sync_test.rs`:
```rust
#[test]
fn commit_link_scan_skips_unknown_prefix_without_error() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
// No issue exists. Commit uses a completely unrelated prefix.
make_commit_with_message(
&cluster,
&alice_repo,
"Fix\n\nIssue: zzzzzzzz",
);
let author = git_collab::identity::get_author(&alice_repo).unwrap();
let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 0);
}
#[test]
fn commit_link_scan_skips_ambiguous_prefix_without_error() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
// Open two issues. Their OIDs are random, so we can't guarantee they
// share a prefix — instead, use a single-character prefix that is
// likely to be ambiguous. If the two OIDs happen to not share a first
// char, this test degrades to a no-op but still passes the "no error"
// check, which is the important property.
let (_, id_a) = open_issue(&alice_repo, &alice(), "a");
let (_, id_b) = open_issue(&alice_repo, &alice(), "b");
// Find a shared character prefix, or fall back to the first char of a.
let shared_prefix = if id_a.chars().next() == id_b.chars().next() {
id_a[..1].to_string()
} else {
// No ambiguity possible — use a prefix that matches only a, which
// exercises the resolve-success path instead. Test name still holds
// because "without error" is the core assertion.
id_a[..8].to_string()
};
make_commit_with_message(
&cluster,
&alice_repo,
&format!("Touch\n\nIssue: {}", shared_prefix),
);
let author = git_collab::identity::get_author(&alice_repo).unwrap();
let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
// Must not error regardless of whether the prefix ambiguously matched.
let _ = commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap();
}
#[test]
fn commit_link_scan_skips_archived_issues_with_warning() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let (_, issue_id) = open_issue(&alice_repo, &alice(), "old bug");
// Archive the issue via the state helper.
state::archive_issue_ref(&alice_repo, &issue_id).unwrap();
make_commit_with_message(
&cluster,
&alice_repo,
&format!("Reference old\n\nIssue: {}", &issue_id[..8]),
);
let author = git_collab::identity::get_author(&alice_repo).unwrap();
let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 0);
// Confirm the archived ref did not accrue a new event: the archived
// DAG tip should still be the archive-time tip.
let archived_ref = format!("refs/collab/archive/issues/{}", issue_id);
let archived_state = IssueState::from_ref_uncached(&alice_repo, &archived_ref, &issue_id).unwrap();
assert!(archived_state.linked_commits.is_empty());
}
```
- [ ] **Step 4: Run these tests**
Run: `cargo test --test sync_test commit_link_scan_skips_unknown commit_link_scan_skips_ambiguous commit_link_scan_skips_archived`
Expected: PASS. Warning output on stderr is expected and does not fail the test.
- [ ] **Step 5: Add detached-HEAD and remote-originated dedup tests**
Append to `tests/sync_test.rs`:
```rust
#[test]
fn commit_link_scan_no_op_on_detached_head_with_no_branches() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
// Delete all local branches and put HEAD in detached state.
let head_oid = alice_repo.head().unwrap().target().unwrap();
alice_repo.set_head_detached(head_oid).unwrap();
// Remove refs/heads/main.
alice_repo.find_reference("refs/heads/main").unwrap().delete().unwrap();
let author = git_collab::identity::get_author(&alice_repo).unwrap();
let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
// No branches to walk — silent no-op.
assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 0);
}
#[test]
fn commit_link_scan_dedups_against_remote_originated_events() {
// Simulates the cross-machine dedup case: Bob's repo fetches a link
// event that Alice already emitted, then runs scan locally with the
// same commit reachable from his branches. He must not emit a
// duplicate.
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let bob_repo = cluster.bob_repo();
// Both repos get the same issue.
let (_, issue_id) = open_issue(&alice_repo, &alice(), "bug");
sync::sync(&alice_repo, "origin").unwrap();
sync::sync(&bob_repo, "origin").unwrap();
// Alice writes a commit and pushes it to the bare remote so Bob can
// fetch it. First the regular git push; then sync for the link event.
let message = format!("Fix thing\n\nIssue: {}", &issue_id[..8]);
let linked_commit = make_commit_with_message(&cluster, &alice_repo, &message);
// Push the branch so Bob sees the commit too.
let mut cmd = Command::new("git");
cmd.args(["push", "origin", "main"])
.current_dir(cluster.alice_dir.path());
assert!(cmd.status().unwrap().success());
sync::sync(&alice_repo, "origin").unwrap();
// Bob fetches both the branch and the collab link event.
let mut cmd = Command::new("git");
cmd.args(["fetch", "origin", "main:main"])
.current_dir(cluster.bob_dir.path());
assert!(cmd.status().unwrap().success());
sync::sync(&bob_repo, "origin").unwrap();
// At this point Bob's issue already has the link event from Alice.
// Re-running scan on Bob's repo must find the commit locally and
// decide "already linked", emitting zero events.
let author = git_collab::identity::get_author(&bob_repo).unwrap();
let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
let emitted = commit_link::scan_and_link(&bob_repo, &author, &sk).unwrap();
assert_eq!(emitted, 0, "Bob must not duplicate Alice's link event");
// And the commit on Bob's side really is the one linked.
let bob_ref = format!("refs/collab/issues/{}", issue_id);
let bob_issue = IssueState::from_ref_uncached(&bob_repo, &bob_ref, &issue_id).unwrap();
assert_eq!(bob_issue.linked_commits.len(), 1);
assert_eq!(bob_issue.linked_commits[0].commit, linked_commit.to_string());
}
```
- [ ] **Step 6: Run these tests**
Run: `cargo test --test sync_test commit_link_scan_no_op_on_detached commit_link_scan_dedups_against_remote`
Expected: PASS.
- [ ] **Step 7: Run the entire sync test suite as a regression check**
Run: `cargo test --test sync_test`
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add tests/sync_test.rs
git commit -m "Add integration tests for commit-link scan edge cases
Covers idempotency, multi-branch ancestor dedup, multi-issue trailers,
unknown/ambiguous/archived prefixes, detached HEAD silent no-op, and
cross-machine dedup against remote-originated link events."
```
---
## Task 8: Render linked commits in the CLI `issue show` output
**Files:**
- Modify: `src/lib.rs` (the `IssueCmd::Show` match arm, around lines 115–148)
- Test: `tests/cli_test.rs` or add a new CLI-level assertion in `sync_test.rs` — we'll extend `sync_test.rs` for co-location with other commit-link tests.
- [ ] **Step 1: Write a failing test that uses the CLI to show an issue with a link**
Append to `tests/sync_test.rs`. Note: this uses `TestRepo` from `common`, which runs the binary — so it's a process-level test.
```rust
#[test]
fn cli_issue_show_renders_linked_commits_section() {
use common::TestRepo;
let repo = TestRepo::new("Alice", "alice@example.com");
let issue_id = repo.issue_open("fix the thing");
// Write a commit with an Issue: trailer via git CLI.
let full_id = {
let out = repo.run_ok(&["issue", "show", &issue_id, "--json"]);
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
v["id"].as_str().unwrap().to_string()
};
let msg = format!("Fix a thing\n\nIssue: {}", &full_id[..8]);
repo.git(&["commit", "--allow-empty", "-m", &msg]);
// Run sync against a bare remote we create inline.
let bare = TempDir::new().unwrap();
Command::new("git")
.args(["init", "--bare"])
.current_dir(bare.path())
.status()
.unwrap();
repo.git(&["remote", "add", "origin", bare.path().to_str().unwrap()]);
repo.git(&["push", "-u", "origin", "main"]);
repo.run_ok(&["init"]);
repo.run_ok(&["sync"]);
let show = repo.run_ok(&["issue", "show", &issue_id]);
assert!(
show.contains("--- Linked Commits ---"),
"expected linked commits section, got:\n{}",
show
);
assert!(
show.contains("by Alice"),
"expected commit author rendered, got:\n{}",
show
);
assert!(
show.contains("(linked by Alice"),
"expected event author rendered, got:\n{}",
show
);
}
```
- [ ] **Step 2: Run the test and confirm it fails**
Run: `cargo test --test sync_test cli_issue_show_renders_linked_commits_section`
Expected: FAIL — the renderer does not yet output the `--- Linked Commits ---` section.
- [ ] **Step 3: Add the renderer in `src/lib.rs`**
Open `src/lib.rs`. Find the `IssueCmd::Show` arm (starts around line 115). Locate the block that renders comments:
```rust
if !i.comments.is_empty() {
println!("\n--- Comments ---");
for c in &i.comments {
println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body);
}
}
Ok(())
```
Immediately before `Ok(())`, add the linked-commits renderer:
```rust
if !i.linked_commits.is_empty() {
println!("\n--- Linked Commits ---");
for lc in &i.linked_commits {
let short_sha = if lc.commit.len() >= 7 { &lc.commit[..7] } else { &lc.commit };
let (subject, commit_author) = match git2::Oid::from_str(&lc.commit)
.ok()
.and_then(|oid| repo.find_commit(oid).ok())
{
Some(commit) => {
let subject = commit
.summary()
.map(|s| truncate_summary(s, 60))
.unwrap_or_default();
let author = commit
.author()
.name()
.unwrap_or("unknown")
.to_string();
(Some(subject), Some(author))
}
None => (None, None),
};
match (subject, commit_author) {
(Some(subject), Some(author)) => {
println!(
"· linked {} \"{}\" by {} (linked by {}, {})",
short_sha,
subject,
author,
lc.event_author.name,
lc.event_timestamp,
);
}
_ => {
println!(
"· linked {} (commit {} not in local repo) (linked by {}, {})",
short_sha,
short_sha,
lc.event_author.name,
lc.event_timestamp,
);
}
}
}
}
Ok(())
```
Add a helper at the bottom of `src/lib.rs` (outside all `fn run` code, next to `fn search`):
```rust
fn truncate_summary(s: &str, max_chars: usize) -> String {
let mut out = String::new();
let mut count = 0;
for c in s.chars() {
if count + 1 > max_chars {
out.push('…');
return out;
}
out.push(c);
count += 1;
}
out
}
```
- [ ] **Step 4: Run the test and confirm it passes**
Run: `cargo test --test sync_test cli_issue_show_renders_linked_commits_section`
Expected: PASS.
- [ ] **Step 5: Run the full CLI test suite**
Run: `cargo test`
Expected: PASS. No existing `issue show` tests should break because they use issues without any linked commits — the new section is only printed when non-empty.
- [ ] **Step 6: Commit**
```bash
git add src/lib.rs tests/sync_test.rs
git commit -m "Render --- Linked Commits --- section in CLI issue show
Each line shows the short SHA, commit subject (truncated to 60 chars),
commit author, and then (linked by <event-author>, <timestamp>). When
the commit isn't locally reachable (GC'd, shallow), falls back to a
no-subject variant that still shows the linked-by metadata."
```
---
## Task 9: Render linked commits in the TUI issue detail widget
**Files:**
- Modify: `src/tui/widgets.rs` (function rendering issue detail, around lines 390–464)
- Test: Extend an existing TUI smoke test or rely on the existing dashboard smoke harness — the widget is ultimately driven by `IssueState`, which already carries `linked_commits`, so we'll test via a direct widget call.
- [ ] **Step 1: Locate the relevant widget function**
Run: `cargo clippy --all-targets 2>&1 | head -5` — just to confirm build is clean before editing. Then open `src/tui/widgets.rs` and read the function that builds issue detail (search for `--- Comments ---` — it's the same function at line 438).
- [ ] **Step 2: Add the rendering block after the comments section**
In `src/tui/widgets.rs`, find the `if !issue.comments.is_empty() { ... }` block (around line 438–462) and add a parallel block immediately after it, before `Text::from(lines)`:
```rust
if !issue.linked_commits.is_empty() {
lines.push(Line::raw(""));
lines.push(Line::styled(
"--- Linked Commits ---",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
));
for lc in &issue.linked_commits {
let short_sha: String = lc.commit.chars().take(7).collect();
let (subject, commit_author) = git2::Oid::from_str(&lc.commit)
.ok()
.and_then(|oid| repo.find_commit(oid).ok())
.map(|commit| {
let subject = commit.summary().unwrap_or("").to_string();
let author = commit.author().name().unwrap_or("unknown").to_string();
(subject, author)
})
.unwrap_or_else(|| (String::new(), String::new()));
let line_text = if commit_author.is_empty() {
format!(
"· linked {} (commit {} not in local repo) (linked by {}, {})",
short_sha, short_sha, lc.event_author.name, lc.event_timestamp
)
} else {
format!(
"· linked {} \"{}\" by {} (linked by {}, {})",
short_sha, subject, commit_author, lc.event_author.name, lc.event_timestamp
)
};
lines.push(Line::raw(line_text));
}
}
```
The `repo` parameter is already in scope — check the enclosing function's signature. If `build_issue_detail` does *not* currently take `&Repository`, thread it through: look at how callers pass `patches` in — they also pass the repo, so the plumbing exists. If the function really doesn't have repo access, fall back to the "(commit not in local repo)" form unconditionally; the CLI renderer is the authoritative one and the TUI can degrade.
- [ ] **Step 3: Build and check**
Run: `cargo build`
Expected: clean build. If there's a missing `repo` parameter, the error message will point at the exact spot; thread the `&Repository` argument through from the caller (`src/tui/mod.rs` or `src/tui/state.rs`) to `build_issue_detail` and retry.
- [ ] **Step 4: Run clippy**
Run: `cargo clippy --all-targets -- -D warnings`
Expected: no warnings.
- [ ] **Step 5: Run the existing TUI smoke tests to confirm no regression**
Run: `cargo test --test dashboard_test`
Expected: PASS (if a dashboard test suite exists — skip this step if it doesn't).
- [ ] **Step 6: Commit**
```bash
git add src/tui/widgets.rs src/tui/mod.rs src/tui/state.rs
git commit -m "Render linked commits section in TUI issue detail
Mirrors the CLI renderer: --- Linked Commits --- section after
comments, with short SHA + subject + commit author + linked-by
metadata. Degrades gracefully when the commit isn't locally available."
```
(If `src/tui/mod.rs` or `src/tui/state.rs` weren't touched, drop them from the add — `git status` will show what to include.)
---
## Task 10: Final verification and cleanup
**Files:** none
- [ ] **Step 1: Run the full test suite**
Run: `cargo test`
Expected: PASS across all targets.
- [ ] **Step 2: Run clippy with deny warnings**
Run: `cargo clippy --all-targets -- -D warnings`
Expected: no warnings.
- [ ] **Step 3: Manual end-to-end smoke test**
In a scratch directory (the worktree is fine):
```bash
cd /tmp
rm -rf commit-link-smoke && mkdir commit-link-smoke && cd commit-link-smoke
git init -b main
git config user.name "Smoke"
git config user.email "smoke@example.com"
git commit --allow-empty -m "root"
# Use the locally-built binary.
BIN=/home/xanderle/dev/git-collab/target/debug/git-collab
$BIN issue open -t "smoke test issue"
ID=$($BIN issue list | head -1 | awk '{print $1}')
echo "Issue: $ID"
git commit --allow-empty -m "Fix thing
Issue: $ID"
git init --bare /tmp/commit-link-smoke-bare
git remote add origin /tmp/commit-link-smoke-bare
git push -u origin main
$BIN init
$BIN sync
$BIN issue show $ID
```
Expected in the output: a `--- Linked Commits ---` section with one line like `· linked <sha> "Fix thing" by Smoke (linked by Smoke, 2026-...)`.
- [ ] **Step 4: Commit any final cleanup**
If the smoke test reveals anything missing (e.g. a poorly-formatted line, a missing newline), fix it and commit:
```bash
git add -p
git commit -m "Polish commit-link renderer based on smoke test feedback"
```
Otherwise, this task is a no-op commit-wise.
---
## Self-Review
Checking the plan against the spec `docs/superpowers/specs/2026-04-12-commit-issue-link-design.md`:
**Spec coverage:**
- ✅ `Action::IssueCommitLink` variant — Task 1.
- ✅ `src/commit_link.rs` module with `parse_issue_trailers`, `collect_linked_shas`, `scan_and_link` — Tasks 3, 4, 5.
- ✅ Strict trailer-block rule (every non-empty line must be trailer-shaped) — Task 3 parser + test `parser_mixed_final_paragraph_rejects_all`.
- ✅ Single-token value rule (`(\S+)` equivalent via `split_whitespace().count() != 1`) — Task 3 parser + test `parser_rejects_value_with_trailing_garbage`.
- ✅ Case-insensitive key match — Task 3 test `parser_case_variants`.
- ✅ Sync insertion point (after `reconcile_refs("patches")`, before `collect_push_refs`) — Task 6.
- ✅ Revwalk of all `refs/heads/*` with per-sync `HashSet<Oid>` dedup — Task 5 (`scan_and_link`).
- ✅ Per-issue linked-SHA cache keyed by resolved ref name — Task 5 (`link_cache: HashMap<String, Option<HashSet<String>>>`).
- ✅ Archived-issue skip with warning — Task 5 (`ARCHIVED_ISSUE_PREFIX` check) + integration test in Task 7.
- ✅ Detached HEAD / no local branches silent no-op — Task 5 (`seeded_any` check) + integration test in Task 7.
- ✅ Cross-machine duplication handled at render time via first-seen dedup — Task 2 (`IssueState::from_ref_uncached` arm).
- ✅ Renderer shows both commit author and event author — Tasks 8 (CLI) and 9 (TUI).
- ✅ Renderer fallback when commit is not locally available — Tasks 8 and 9 (fallback branches).
- ✅ "Never breaks sync" error handling philosophy — Task 5 (`eprintln!` warnings + `continue`).
- ✅ Per-issue cache poisoning within a single sync — Task 5 (`Option<HashSet<...>>` where `None` is poisoned, `Some(_)` is live).
- ✅ Unit tests for parser (all spec fixtures) — Task 3.
- ✅ Integration tests: happy path, idempotency, multi-branch, multi-issue, unknown/ambiguous/archived, detached HEAD, remote-originated dedup, CLI rendering, render-time dedup — Tasks 2, 5, 6, 7, 8.
**Placeholder scan:**
- No "TBD", "TODO", "fill in later" in any task.
- Every code block contains the actual code to write.
- Every test has real assertions.
- The TUI task has one conditional ("if `repo` isn't in scope, thread it through") — that's an *explicit* decision branch with a concrete fallback, not a placeholder. Acceptable.
**Type/name consistency:**
- `LinkedCommit { commit, event_author, event_timestamp }` used consistently across Task 2 struct definition, Task 2 materializer, Task 8 CLI renderer, Task 9 TUI renderer.
- `scan_and_link(repo, author, sk) -> Result<usize, Error>` used consistently in Task 5 impl and Tasks 5, 6, 7 tests.
- `collect_linked_shas(repo, issue_ref) -> Result<HashSet<String>, Error>` consistent Task 4 impl + Task 5 caller.
- `parse_issue_trailers(message) -> Vec<String>` consistent Task 3 impl + Task 3 tests + Task 5 caller.
- `Action::IssueCommitLink { commit }` — field name `commit` used in Task 1 variant, Task 2 materializer, Task 4 helper, Task 5 orchestrator, Task 2/5 test helpers.
**Note on one simplification** introduced by the plan vs. the spec's wording:
The spec says "each time a given issue is matched in a sync, we walk its DAG once to build the set, cache it". The plan implements this literally via `HashMap<String, Option<HashSet<String>>>` in `scan_and_link`, where `Option::None` represents a poisoned (read-failed) issue. The spec wording "poisoned sentinel" is preserved.
The spec calls the renderer output an "issue-timeline line"; the actual code has no timeline, just separate `--- Comments ---` / `--- Linked Patches ---` sections. The plan adapts this to a new `--- Linked Commits ---` section matching the existing sectioned layout. This is a faithful translation of the spec's intent into the codebase's actual UI structure.
---
## Execution Handoff
Plan complete and saved to `docs/superpowers/plans/2026-04-12-commit-issue-link.md`. Two execution options:
**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration.
**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints.
Which approach?