a73x

c9ee615a

Link commits to issues and add auto-sync, search and patch checkout

a73x   2026-08-08 17:57

Commit message
Link commits to issues and add auto-sync, search and patch checkout

.gitignore
Old New
@@ -1,2 +1,3 @@
1 /target 1 /target
2 /man 2 /man
3 /.claude/worktrees/
docs/superpowers/plans/2026-04-12-commit-issue-link.md
Old New
@@ -0,0 +1,1663 @@
1 # Auto-Link Commits to Issues Implementation Plan
2
3 > **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.
4
5 **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.
6
7 **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 ---`.
8
9 **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).
10
11 **Spec reference:** `docs/superpowers/specs/2026-04-12-commit-issue-link-design.md`
12
13 ---
14
15 ## File Structure
16
17 | File | Role |
18 |------|------|
19 | `src/event.rs` | **Modify.** Add `Action::IssueCommitLink { commit }` variant. |
20 | `src/state.rs` | **Modify.** Add `LinkedCommit` struct, `IssueState::linked_commits` field, materializer arm in `from_ref_uncached` with dedup. |
21 | `src/commit_link.rs` | **Create.** Pure trailer parser, `collect_linked_shas`, `scan_and_link`. |
22 | `src/lib.rs` | **Modify.** Declare the `commit_link` module. Add `--- Linked Commits ---` rendering to the `IssueCmd::Show` handler. |
23 | `src/sync.rs` | **Modify.** Call `commit_link::scan_and_link` between `reconcile_refs("patches")` and `collect_push_refs`. |
24 | `src/tui/widgets.rs` | **Modify.** Add `--- Linked Commits ---` section to `build_issue_detail` after comments. |
25 | `tests/common/mod.rs` | **Modify.** Add `add_commit_link` helper mirroring `add_comment`. |
26 | `tests/commit_link_test.rs` | **Create.** Unit tests for `parse_issue_trailers` (table-driven). |
27 | `tests/sync_test.rs` | **Modify.** Add integration tests for the full sync→link flow. |
28
29 ---
30
31 ## Task 1: Add the `IssueCommitLink` event variant
32
33 **Files:**
34 - Modify: `src/event.rs` (after `Action::IssueReopen` at line 58)
35 - Test: `tests/commit_link_test.rs` (new — start the file here)
36
37 - [ ] **Step 1: Create the test file with a serde round-trip test that will fail because the variant doesn't exist yet**
38
39 Create `tests/commit_link_test.rs`:
40
41 ```rust
42 //! Unit tests for src/commit_link.rs and the Action::IssueCommitLink variant.
43
44 use git_collab::event::{Action, Author, Event};
45
46 fn test_author() -> Author {
47 Author {
48 name: "Alice".to_string(),
49 email: "alice@example.com".to_string(),
50 }
51 }
52
53 #[test]
54 fn issue_commit_link_variant_round_trips() {
55 let event = Event {
56 timestamp: "2026-04-12T12:00:00Z".to_string(),
57 author: test_author(),
58 action: Action::IssueCommitLink {
59 commit: "4b2e1cd0123456789012345678901234567890ab".to_string(),
60 },
61 clock: 3,
62 };
63
64 let json = serde_json::to_string(&event).expect("serialize");
65 assert!(
66 json.contains("\"type\":\"issue.commit_link\""),
67 "expected serde tag issue.commit_link, got: {}",
68 json
69 );
70 assert!(
71 json.contains("\"commit\":\"4b2e1cd0123456789012345678901234567890ab\""),
72 "expected commit field, got: {}",
73 json
74 );
75
76 let parsed: Event = serde_json::from_str(&json).expect("deserialize");
77 match parsed.action {
78 Action::IssueCommitLink { commit } => {
79 assert_eq!(commit, "4b2e1cd0123456789012345678901234567890ab");
80 }
81 other => panic!("expected IssueCommitLink, got {:?}", other),
82 }
83 }
84 ```
85
86 - [ ] **Step 2: Run the test and confirm it fails**
87
88 Run: `cargo test --test commit_link_test issue_commit_link_variant_round_trips`
89 Expected: FAIL with `no variant or associated item named 'IssueCommitLink' found for enum 'Action'` (or similar).
90
91 - [ ] **Step 3: Add the variant to `src/event.rs`**
92
93 Open `src/event.rs`. Locate `Action::IssueReopen` (around line 58). After it (before the `PatchCreate` variant), insert:
94
95 ```rust
96 #[serde(rename = "issue.commit_link")]
97 IssueCommitLink {
98 commit: String,
99 },
100 ```
101
102 - [ ] **Step 4: Run the test and confirm it passes**
103
104 Run: `cargo test --test commit_link_test issue_commit_link_variant_round_trips`
105 Expected: PASS.
106
107 - [ ] **Step 5: Run the full test suite to verify nothing regressed**
108
109 Run: `cargo test`
110 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.
111
112 - [ ] **Step 6: Commit**
113
114 ```bash
115 git add src/event.rs tests/commit_link_test.rs
116 git commit -m "Add Action::IssueCommitLink event variant
117
118 Introduces the new event kind that will be emitted during sync when a
119 commit's message contains an Issue: trailer. Variant only; no emission
120 sites or renderers yet."
121 ```
122
123 ---
124
125 ## Task 2: Extend `IssueState` with `linked_commits` and populate with dedup
126
127 **Files:**
128 - Modify: `src/state.rs` (struct definition around line 85, materializer around line 237)
129 - Modify: `tests/common/mod.rs` (add `add_commit_link` helper for reuse)
130 - Test: `tests/commit_link_test.rs` (extend with materializer tests)
131
132 - [ ] **Step 1: Add the `add_commit_link` test helper**
133
134 Open `tests/common/mod.rs`. After `add_comment` (around line 229), add:
135
136 ```rust
137 /// Append an IssueCommitLink event to an issue ref. Returns the new DAG tip OID.
138 pub fn add_commit_link(
139 repo: &Repository,
140 ref_name: &str,
141 author: &Author,
142 commit_sha: &str,
143 ) -> git2::Oid {
144 let sk = test_signing_key();
145 let event = Event {
146 timestamp: now(),
147 author: author.clone(),
148 action: Action::IssueCommitLink {
149 commit: commit_sha.to_string(),
150 },
151 clock: 0,
152 };
153 dag::append_event(repo, ref_name, &event, &sk).unwrap()
154 }
155 ```
156
157 - [ ] **Step 2: Write a failing test for the materializer**
158
159 Append to `tests/commit_link_test.rs`:
160
161 ```rust
162 use git2::Repository;
163 use git_collab::state::{self, IssueState};
164 use tempfile::TempDir;
165
166 mod common;
167 use common::{add_commit_link, alice, init_repo, open_issue, ScopedTestConfig};
168
169 fn sha(byte: u8) -> String {
170 format!("{:02x}{}", byte, "00".repeat(19))
171 }
172
173 #[test]
174 fn issue_state_surfaces_commit_links_in_order() {
175 let _config = ScopedTestConfig::new();
176 let dir = TempDir::new().unwrap();
177 let repo = init_repo(dir.path(), &alice());
178 let (ref_name, id) = open_issue(&repo, &alice(), "bug");
179
180 add_commit_link(&repo, &ref_name, &alice(), &sha(0xaa));
181 add_commit_link(&repo, &ref_name, &alice(), &sha(0xbb));
182
183 let issue = IssueState::from_ref_uncached(&repo, &ref_name, &id).unwrap();
184 let commits: Vec<&str> = issue
185 .linked_commits
186 .iter()
187 .map(|lc| lc.commit.as_str())
188 .collect();
189 assert_eq!(commits, vec![sha(0xaa), sha(0xbb)]);
190 }
191
192 #[test]
193 fn issue_state_dedups_commit_links_by_sha_keeping_earliest() {
194 let _config = ScopedTestConfig::new();
195 let dir = TempDir::new().unwrap();
196 let repo = init_repo(dir.path(), &alice());
197 let (ref_name, id) = open_issue(&repo, &alice(), "bug");
198
199 // Two different emitters link the same commit. First-seen should win.
200 add_commit_link(&repo, &ref_name, &alice(), &sha(0xcc));
201 let bob_link = common::bob();
202 add_commit_link(&repo, &ref_name, &bob_link, &sha(0xcc));
203
204 let issue = IssueState::from_ref_uncached(&repo, &ref_name, &id).unwrap();
205 assert_eq!(issue.linked_commits.len(), 1);
206 assert_eq!(issue.linked_commits[0].commit, sha(0xcc));
207 assert_eq!(issue.linked_commits[0].event_author.name, "Alice");
208 }
209 ```
210
211 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`.
212
213 - [ ] **Step 3: Run the tests and confirm they fail**
214
215 Run: `cargo test --test commit_link_test issue_state_surfaces_commit_links_in_order issue_state_dedups_commit_links_by_sha_keeping_earliest`
216 Expected: FAIL — `IssueState` has no `linked_commits` field.
217
218 - [ ] **Step 4: Add `LinkedCommit` and the `linked_commits` field**
219
220 Open `src/state.rs`. After the `Comment` struct (ends around line 82), add:
221
222 ```rust
223 #[derive(Debug, Clone, Serialize, Deserialize)]
224 pub struct LinkedCommit {
225 /// Full 40-char commit SHA from the trailer.
226 pub commit: String,
227 /// Author of the `IssueCommitLink` event (who ran sync).
228 pub event_author: Author,
229 /// Timestamp of the `IssueCommitLink` event.
230 pub event_timestamp: String,
231 }
232 ```
233
234 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`):
235
236 ```rust
237 #[serde(default)]
238 pub linked_commits: Vec<LinkedCommit>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub relates_to: Option<String>,
241 ```
242
243 `#[serde(default)]` ensures older cached `IssueState` JSON (which has no `linked_commits` key) still deserializes.
244
245 - [ ] **Step 5: Initialize the field in the `IssueOpen` arm of `from_ref_uncached`**
246
247 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:
248
249 ```rust
250 Action::IssueOpen { title, body, relates_to } => {
251 state = Some(IssueState {
252 id: id.to_string(),
253 title,
254 body,
255 status: IssueStatus::Open,
256 close_reason: None,
257 closed_by: None,
258 labels: Vec::new(),
259 assignees: Vec::new(),
260 comments: Vec::new(),
261 linked_commits: Vec::new(),
262 created_at: event.timestamp.clone(),
263 last_updated: String::new(),
264 author: event.author.clone(),
265 relates_to,
266 });
267 }
268 ```
269
270 - [ ] **Step 6: Add an `IssueCommitLink` arm to the materializer**
271
272 Still in `from_ref_uncached`, before the catchall `_ => {}` arm (around line 339), add:
273
274 ```rust
275 Action::IssueCommitLink { commit } => {
276 if let Some(ref mut s) = state {
277 // Render-time dedup by commit SHA. The revwalk is
278 // topo-oldest-first (Sort::TOPOLOGICAL | Sort::REVERSE),
279 // so the first event we see for a given SHA is the
280 // earliest emission — exactly what the spec requires.
281 if !s.linked_commits.iter().any(|lc| lc.commit == commit) {
282 s.linked_commits.push(LinkedCommit {
283 commit,
284 event_author: event.author.clone(),
285 event_timestamp: event.timestamp.clone(),
286 });
287 }
288 }
289 }
290 ```
291
292 - [ ] **Step 7: Run the tests and confirm they pass**
293
294 Run: `cargo test --test commit_link_test issue_state_surfaces_commit_links_in_order issue_state_dedups_commit_links_by_sha_keeping_earliest`
295 Expected: PASS.
296
297 - [ ] **Step 8: Run the full test suite**
298
299 Run: `cargo test`
300 Expected: PASS. The `#[serde(default)]` keeps backward compatibility with cached issue state.
301
302 - [ ] **Step 9: Commit**
303
304 ```bash
305 git add src/state.rs tests/common/mod.rs tests/commit_link_test.rs
306 git commit -m "Surface linked commits on IssueState with first-seen dedup
307
308 Adds LinkedCommit + IssueState.linked_commits, populated from the new
309 event variant in IssueState::from_ref_uncached. Walking topo-oldest-
310 first means the first-seen event for a given SHA wins, which matches
311 the spec's render-time dedup rule. serde(default) keeps older cached
312 state compatible."
313 ```
314
315 ---
316
317 ## Task 3: Create `src/commit_link.rs` with the trailer parser
318
319 **Files:**
320 - Create: `src/commit_link.rs`
321 - Modify: `src/lib.rs` (module declaration)
322 - Test: `tests/commit_link_test.rs`
323
324 - [ ] **Step 1: Write the full parser test table**
325
326 Append to `tests/commit_link_test.rs`:
327
328 ```rust
329 use git_collab::commit_link::parse_issue_trailers;
330
331 #[test]
332 fn parser_no_trailer_block() {
333 assert_eq!(parse_issue_trailers("Just a plain commit"), Vec::<String>::new());
334 }
335
336 #[test]
337 fn parser_empty_message() {
338 assert_eq!(parse_issue_trailers(""), Vec::<String>::new());
339 }
340
341 #[test]
342 fn parser_single_trailer_in_pure_block() {
343 let msg = "Fix thing\n\nSome context in the body.\n\nIssue: abc";
344 assert_eq!(parse_issue_trailers(msg), vec!["abc".to_string()]);
345 }
346
347 #[test]
348 fn parser_case_variants() {
349 let msg1 = "subject\n\nissue: abc";
350 let msg2 = "subject\n\nISSUE : abc";
351 let msg3 = "subject\n\n Issue: abc ";
352 assert_eq!(parse_issue_trailers(msg1), vec!["abc".to_string()]);
353 assert_eq!(parse_issue_trailers(msg2), vec!["abc".to_string()]);
354 assert_eq!(parse_issue_trailers(msg3), vec!["abc".to_string()]);
355 }
356
357 #[test]
358 fn parser_two_trailers_in_pure_block() {
359 let msg = "subject\n\nIssue: abc\nIssue: def";
360 assert_eq!(parse_issue_trailers(msg), vec!["abc".to_string(), "def".to_string()]);
361 }
362
363 #[test]
364 fn parser_issue_in_body_but_not_final_paragraph() {
365 let msg = "subject\n\nIssue: abc\n\nSigned-off-by: alice <a@example.com>";
366 // The final paragraph is the signed-off-by block, not the issue line.
367 // It's a valid trailer block (Signed-off-by is trailer-shaped), but it
368 // contains no Issue: key, so we extract nothing.
369 assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
370 }
371
372 #[test]
373 fn parser_wrong_key() {
374 let msg = "subject\n\nIssues: abc";
375 assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
376 }
377
378 #[test]
379 fn parser_prose_mention() {
380 let msg = "subject\n\nthis fixes issue abc in the body";
381 assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
382 }
383
384 #[test]
385 fn parser_single_paragraph_whole_message_is_trailer_block() {
386 let msg = "Issue: abc";
387 assert_eq!(parse_issue_trailers(msg), vec!["abc".to_string()]);
388 }
389
390 #[test]
391 fn parser_mixed_final_paragraph_rejects_all() {
392 let msg = "subject\n\nThanks to Bob for the catch.\nIssue: a3f9";
393 // Final paragraph has a prose line, so it's not a trailer block and we
394 // extract nothing. This is the "false positive in prose" guard.
395 assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
396 }
397
398 #[test]
399 fn parser_trailing_whitespace_paragraph_does_not_shadow_trailer_block() {
400 // The final paragraph is empty/whitespace, so the walk should fall back
401 // to the previous non-empty paragraph, which is a valid trailer block.
402 let msg = "subject\n\nIssue: abc\n\n \n";
403 assert_eq!(parse_issue_trailers(msg), vec!["abc".to_string()]);
404 }
405
406 #[test]
407 fn parser_pure_block_with_mixed_keys() {
408 let msg = "subject\n\nSigned-off-by: alice <a@example.com>\nIssue: abc";
409 assert_eq!(parse_issue_trailers(msg), vec!["abc".to_string()]);
410 }
411
412 #[test]
413 fn parser_rejects_value_with_trailing_garbage() {
414 let msg = "subject\n\nIssue: abc fixes thing";
415 assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
416 }
417
418 #[test]
419 fn parser_rejects_empty_value() {
420 let msg = "subject\n\nIssue: ";
421 assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
422 }
423 ```
424
425 - [ ] **Step 2: Run the tests to confirm they all fail**
426
427 Run: `cargo test --test commit_link_test parser_`
428 Expected: FAIL — `parse_issue_trailers` and the `commit_link` module don't exist.
429
430 - [ ] **Step 3: Create `src/commit_link.rs` with the parser**
431
432 Create `src/commit_link.rs`:
433
434 ```rust
435 //! Auto-link commits to issues via `Issue:` git trailers during sync.
436 //!
437 //! See: docs/superpowers/specs/2026-04-12-commit-issue-link-design.md
438
439 /// Parse `Issue:` trailers from a commit message.
440 ///
441 /// Returns the list of trailer values in order of appearance. Follows git's
442 /// own trailer-block semantics: only the final paragraph is considered, and
443 /// *every* non-empty line in it must be trailer-shaped (a `token: value`
444 /// line) for the paragraph to qualify. Any prose line in the final paragraph
445 /// disqualifies the whole paragraph — this prevents false positives like
446 /// `"Thanks Bob.\nIssue: abc"` in commit bodies.
447 ///
448 /// The key match is `(?i)issue`; the value must be a single non-whitespace
449 /// token followed by optional trailing whitespace and end-of-line. Values
450 /// like `abc fixes thing` are rejected so that loose commentary never
451 /// becomes a silent issue-prefix lookup that warns every sync forever.
452 pub fn parse_issue_trailers(message: &str) -> Vec<String> {
453 // 1. Split into paragraphs (blank-line separated), preserving order.
454 // Trim trailing whitespace from each line for the trailer-shape check,
455 // but keep enough structure to recognize blank lines.
456 let lines: Vec<&str> = message.lines().collect();
457
458 // 2. Find the last paragraph: the longest tail slice that contains at
459 // least one non-empty line and has no blank line *before* its first
460 // non-empty line in the tail.
461 //
462 // Walking from the end: skip trailing blank/whitespace-only lines,
463 // then collect lines until we hit a blank line.
464 let mut end = lines.len();
465 while end > 0 && lines[end - 1].trim().is_empty() {
466 end -= 1;
467 }
468 if end == 0 {
469 return Vec::new();
470 }
471 let mut start = end;
472 while start > 0 && !lines[start - 1].trim().is_empty() {
473 start -= 1;
474 }
475 let paragraph = &lines[start..end];
476
477 // 3. Validate every non-empty line in the paragraph is trailer-shaped.
478 for line in paragraph {
479 if line.trim().is_empty() {
480 continue;
481 }
482 if !is_trailer_shaped(line) {
483 return Vec::new();
484 }
485 }
486
487 // 4. Extract `Issue:` values.
488 let mut out = Vec::new();
489 for line in paragraph {
490 if let Some(value) = match_issue_line(line) {
491 out.push(value);
492 }
493 }
494 out
495 }
496
497 /// Returns true if a line looks like a git trailer: `<token>: <value>`, where
498 /// token starts with a letter and consists of `[A-Za-z0-9-]`, and value is at
499 /// least one non-whitespace character.
500 fn is_trailer_shaped(line: &str) -> bool {
501 let trimmed = line.trim_start();
502 let Some(colon_pos) = trimmed.find(':') else {
503 return false;
504 };
505 let token = &trimmed[..colon_pos];
506 if token.is_empty() {
507 return false;
508 }
509 let mut chars = token.chars();
510 let first = chars.next().unwrap();
511 if !first.is_ascii_alphabetic() {
512 return false;
513 }
514 if !chars.all(|c| c.is_ascii_alphanumeric() || c == '-') {
515 return false;
516 }
517 let value = trimmed[colon_pos + 1..].trim();
518 !value.is_empty()
519 }
520
521 /// If `line` is an `Issue: <token>` trailer with exactly one non-whitespace
522 /// token in its value, returns the token. Otherwise returns None.
523 fn match_issue_line(line: &str) -> Option<String> {
524 let trimmed = line.trim_start();
525 let colon_pos = trimmed.find(':')?;
526 let key = trimmed[..colon_pos].trim_end();
527 if !key.eq_ignore_ascii_case("issue") {
528 return None;
529 }
530 let value_region = &trimmed[colon_pos + 1..];
531 let value = value_region.trim();
532 if value.is_empty() {
533 return None;
534 }
535 // Reject values with interior whitespace: `abc fixes thing` must not
536 // parse to `abc` silently — it must parse to nothing so the user sees
537 // that their commentary is being ignored.
538 if value.split_whitespace().count() != 1 {
539 return None;
540 }
541 Some(value.to_string())
542 }
543 ```
544
545 - [ ] **Step 4: Declare the module in `src/lib.rs`**
546
547 Open `src/lib.rs`. At the top of the module declarations (lines 1–17), insert alphabetically after `pub mod cli;`:
548
549 ```rust
550 pub mod commit_link;
551 ```
552
553 Final ordering should be:
554 ```rust
555 pub mod cache;
556 pub mod cli;
557 pub mod commit_link;
558 pub mod dag;
559 pub mod editor;
560 ...
561 ```
562
563 - [ ] **Step 5: Run the parser tests and confirm all pass**
564
565 Run: `cargo test --test commit_link_test parser_`
566 Expected: PASS for all 14 parser tests.
567
568 - [ ] **Step 6: Run clippy on the new module**
569
570 Run: `cargo clippy --all-targets -- -D warnings`
571 Expected: no warnings.
572
573 - [ ] **Step 7: Commit**
574
575 ```bash
576 git add src/commit_link.rs src/lib.rs tests/commit_link_test.rs
577 git commit -m "Add src/commit_link.rs with strict Issue: trailer parser
578
579 Implements the spec's strict trailer-block semantics: the final
580 paragraph qualifies only if every non-empty line is trailer-shaped,
581 which rejects prose false positives like 'Thanks Bob.\\nIssue: a3f9'.
582 The Issue: value must be a single token, so loose commentary like
583 'Issue: abc fixes thing' is also rejected. Pure parser, no git or I/O,
584 14 table-driven unit tests."
585 ```
586
587 ---
588
589 ## Task 4: Add `collect_linked_shas` helper
590
591 **Files:**
592 - Modify: `src/commit_link.rs`
593 - Test: `tests/commit_link_test.rs`
594
595 - [ ] **Step 1: Write a failing test**
596
597 Append to `tests/commit_link_test.rs`:
598
599 ```rust
600 use git_collab::commit_link::collect_linked_shas;
601
602 #[test]
603 fn collect_linked_shas_empty_for_fresh_issue() {
604 let _config = ScopedTestConfig::new();
605 let dir = TempDir::new().unwrap();
606 let repo = init_repo(dir.path(), &alice());
607 let (ref_name, _id) = open_issue(&repo, &alice(), "bug");
608
609 let shas = collect_linked_shas(&repo, &ref_name).unwrap();
610 assert!(shas.is_empty());
611 }
612
613 #[test]
614 fn collect_linked_shas_returns_all_linked_commits_including_duplicates() {
615 let _config = ScopedTestConfig::new();
616 let dir = TempDir::new().unwrap();
617 let repo = init_repo(dir.path(), &alice());
618 let (ref_name, _id) = open_issue(&repo, &alice(), "bug");
619
620 add_commit_link(&repo, &ref_name, &alice(), &sha(0xaa));
621 add_commit_link(&repo, &ref_name, &alice(), &sha(0xbb));
622 // Even a duplicate DAG entry (cross-machine race) is surfaced here —
623 // this is the "source of truth" for whether we need to emit.
624 add_commit_link(&repo, &ref_name, &alice(), &sha(0xaa));
625
626 let shas = collect_linked_shas(&repo, &ref_name).unwrap();
627 assert_eq!(shas.len(), 2);
628 assert!(shas.contains(&sha(0xaa)));
629 assert!(shas.contains(&sha(0xbb)));
630 }
631 ```
632
633 - [ ] **Step 2: Run the tests and confirm they fail**
634
635 Run: `cargo test --test commit_link_test collect_linked_shas_`
636 Expected: FAIL — `collect_linked_shas` is not defined.
637
638 - [ ] **Step 3: Add `collect_linked_shas` to `src/commit_link.rs`**
639
640 Append to `src/commit_link.rs`:
641
642 ```rust
643 use std::collections::HashSet;
644
645 use git2::Repository;
646
647 use crate::dag;
648 use crate::error::Error;
649 use crate::event::Action;
650
651 /// Walk an issue's event DAG and return every commit SHA that has an
652 /// `IssueCommitLink` event attached. Called lazily on first match per issue
653 /// during `scan_and_link`; the result is cached in the orchestrator's
654 /// `HashMap<RefName, HashSet<String>>`.
655 pub fn collect_linked_shas(repo: &Repository, issue_ref: &str) -> Result<HashSet<String>, Error> {
656 let events = dag::walk_events(repo, issue_ref)?;
657 let mut shas = HashSet::new();
658 for (_oid, event) in events {
659 if let Action::IssueCommitLink { commit } = event.action {
660 shas.insert(commit);
661 }
662 }
663 Ok(shas)
664 }
665 ```
666
667 - [ ] **Step 4: Run the tests and confirm they pass**
668
669 Run: `cargo test --test commit_link_test collect_linked_shas_`
670 Expected: PASS.
671
672 - [ ] **Step 5: Commit**
673
674 ```bash
675 git add src/commit_link.rs tests/commit_link_test.rs
676 git commit -m "Add collect_linked_shas helper to commit_link module
677
678 Walks an issue's DAG and returns every SHA appearing in an
679 IssueCommitLink event. Used by scan_and_link as the per-issue
680 dedup source of truth, cached on first match per sync."
681 ```
682
683 ---
684
685 ## Task 5: Implement `scan_and_link` orchestrator with a happy-path integration test
686
687 **Files:**
688 - Modify: `src/commit_link.rs`
689 - Test: `tests/sync_test.rs` (add the first integration test)
690
691 - [ ] **Step 1: Write the happy-path integration test**
692
693 Open `tests/sync_test.rs`. Find the end of the existing test module (near line 1200+) and append a new section:
694
695 ```rust
696 // ---------------------------------------------------------------------------
697 // Commit-link tests (src/commit_link.rs)
698 // ---------------------------------------------------------------------------
699
700 use git_collab::commit_link;
701
702 fn make_commit_with_message(cluster: &TestCluster, repo: &Repository, message: &str) -> git2::Oid {
703 let _ = cluster; // silence unused if not needed
704 let sig = git2::Signature::now("Alice", "alice@example.com").unwrap();
705 let head = repo.head().unwrap();
706 let parent_oid = head.target().unwrap();
707 let parent = repo.find_commit(parent_oid).unwrap();
708 let tree_oid = parent.tree().unwrap().id();
709 let tree = repo.find_tree(tree_oid).unwrap();
710 // Commit on current branch (refs/heads/main).
711 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])
712 .unwrap()
713 }
714
715 #[test]
716 fn commit_link_scan_emits_event_for_matching_trailer() {
717 let cluster = TestCluster::new();
718 let alice_repo = cluster.alice_repo();
719
720 // Open an issue.
721 let (issue_ref, issue_id) = open_issue(&alice_repo, &alice(), "fix the walker");
722
723 // Create a commit whose trailer references that issue.
724 let message = format!("Fix walker\n\nIssue: {}", &issue_id[..8]);
725 let commit_oid = make_commit_with_message(&cluster, &alice_repo, &message);
726
727 // Run the scanner directly (we test the sync integration in later tests).
728 let author = git_collab::identity::get_author(&alice_repo).unwrap();
729 let sk = signing::load_signing_key(
730 &signing::signing_key_dir().unwrap(),
731 )
732 .unwrap();
733 let emitted = commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap();
734 assert_eq!(emitted, 1);
735
736 // Walk the issue's event log and find the link.
737 let issue = IssueState::from_ref_uncached(&alice_repo, &issue_ref, &issue_id).unwrap();
738 assert_eq!(issue.linked_commits.len(), 1);
739 assert_eq!(issue.linked_commits[0].commit, commit_oid.to_string());
740 }
741 ```
742
743 - [ ] **Step 2: Run the test and confirm it fails**
744
745 Run: `cargo test --test sync_test commit_link_scan_emits_event_for_matching_trailer`
746 Expected: FAIL — `scan_and_link` is not defined.
747
748 - [ ] **Step 3: Add `scan_and_link` to `src/commit_link.rs`**
749
750 Append to `src/commit_link.rs`:
751
752 ```rust
753 use std::collections::HashMap;
754
755 use git2::{Oid, Sort};
756
757 use crate::event::{Author, Event};
758
759 const ACTIVE_ISSUE_PREFIX: &str = "refs/collab/issues/";
760 const ARCHIVED_ISSUE_PREFIX: &str = "refs/collab/archive/issues/";
761
762 /// Walk every commit reachable from `refs/heads/*`, parse `Issue:` trailers,
763 /// resolve each to an issue, and emit an `IssueCommitLink` event for any
764 /// (issue, commit) pair that doesn't already have one.
765 ///
766 /// **Never breaks sync.** Per-commit and per-issue errors are logged as
767 /// one-line stderr warnings and iteration continues. The only errors that
768 /// propagate are "couldn't even start" failures (opening the repo, building
769 /// the revwalk). Callers treat a returned `Err` as "skip the link scan for
770 /// this sync" and proceed.
771 ///
772 /// Returns the number of events actually emitted.
773 pub fn scan_and_link(
774 repo: &Repository,
775 author: &Author,
776 sk: &ed25519_dalek::SigningKey,
777 ) -> Result<usize, Error> {
778 // Build a revwalk seeded from every local branch tip.
779 let mut revwalk = repo.revwalk()?;
780 revwalk.set_sorting(Sort::TOPOLOGICAL)?;
781
782 let mut seeded_any = false;
783 for reference in repo.references_glob("refs/heads/*")? {
784 let Ok(reference) = reference else { continue };
785 let Some(target) = reference.target() else { continue };
786 // `revwalk.push` dedups commits across branch tips internally.
787 if revwalk.push(target).is_ok() {
788 seeded_any = true;
789 }
790 }
791 if !seeded_any {
792 // Detached HEAD with no local branches. Silent no-op per spec.
793 return Ok(0);
794 }
795
796 // Per-sync dedup of commits already visited.
797 let mut visited: HashSet<Oid> = HashSet::new();
798 // Cache of existing link SHAs per resolved issue ref. `None` = poisoned.
799 let mut link_cache: HashMap<String, Option<HashSet<String>>> = HashMap::new();
800 let mut emitted: usize = 0;
801
802 for oid_result in revwalk {
803 let oid = match oid_result {
804 Ok(o) => o,
805 Err(e) => {
806 eprintln!("warning: revwalk error, stopping scan: {}", e);
807 break;
808 }
809 };
810 if !visited.insert(oid) {
811 continue;
812 }
813 let commit = match repo.find_commit(oid) {
814 Ok(c) => c,
815 Err(e) => {
816 eprintln!("warning: cannot load commit {}: {}", oid, e);
817 continue;
818 }
819 };
820 let message = commit.message().unwrap_or("");
821 let trailers = parse_issue_trailers(message);
822 if trailers.is_empty() {
823 continue;
824 }
825
826 for prefix in trailers {
827 match crate::state::resolve_issue_ref(repo, &prefix) {
828 Ok((resolved_ref, _resolved_id)) => {
829 if resolved_ref.starts_with(ARCHIVED_ISSUE_PREFIX) {
830 eprintln!(
831 "warning: commit {}: Issue: {} — issue is archived, skipping",
832 oid, prefix
833 );
834 continue;
835 }
836 if !resolved_ref.starts_with(ACTIVE_ISSUE_PREFIX) {
837 // Unknown namespace. Should not happen with current
838 // resolver, but belt-and-braces.
839 eprintln!(
840 "warning: commit {}: Issue: {} — resolved to unexpected ref {}, skipping",
841 oid, prefix, resolved_ref
842 );
843 continue;
844 }
845
846 let entry = link_cache.entry(resolved_ref.clone()).or_insert_with(|| {
847 match collect_linked_shas(repo, &resolved_ref) {
848 Ok(set) => Some(set),
849 Err(e) => {
850 eprintln!(
851 "warning: cannot read link events for {}: {} — skipping issue for the rest of this sync",
852 resolved_ref, e
853 );
854 None
855 }
856 }
857 });
858 let Some(ref mut set) = entry else { continue };
859
860 let sha = oid.to_string();
861 if set.contains(&sha) {
862 continue;
863 }
864
865 let event = Event {
866 timestamp: chrono::Utc::now().to_rfc3339(),
867 author: author.clone(),
868 action: Action::IssueCommitLink {
869 commit: sha.clone(),
870 },
871 clock: 0,
872 };
873 match dag::append_event(repo, &resolved_ref, &event, sk) {
874 Ok(_) => {
875 set.insert(sha);
876 emitted += 1;
877 }
878 Err(e) => {
879 eprintln!(
880 "warning: failed to emit IssueCommitLink on {}: {}",
881 resolved_ref, e
882 );
883 }
884 }
885 }
886 Err(e) => {
887 // resolve_issue_ref error message already distinguishes
888 // "no issue found" from "ambiguous prefix".
889 eprintln!(
890 "warning: commit {}: Issue: {} — {}, skipping",
891 oid, prefix, e
892 );
893 }
894 }
895 }
896 }
897
898 Ok(emitted)
899 }
900 ```
901
902 - [ ] **Step 4: Run the integration test and confirm it passes**
903
904 Run: `cargo test --test sync_test commit_link_scan_emits_event_for_matching_trailer`
905 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.
906
907 - [ ] **Step 5: Run clippy**
908
909 Run: `cargo clippy --all-targets -- -D warnings`
910 Expected: no warnings.
911
912 - [ ] **Step 6: Commit**
913
914 ```bash
915 git add src/commit_link.rs tests/sync_test.rs
916 git commit -m "Implement commit_link::scan_and_link orchestrator
917
918 Walks refs/heads/* (dedup'd via HashSet<Oid>), parses Issue: trailers
919 from each commit, resolves to an issue ref, and appends IssueCommitLink
920 events. Per-sync cache keyed by resolved ref name absorbs repeat
921 matches; all per-commit and per-issue errors become stderr warnings so
922 the scan never breaks sync. Archived issues are skipped with a warning."
923 ```
924
925 ---
926
927 ## Task 6: Wire `scan_and_link` into `sync::sync`
928
929 **Files:**
930 - Modify: `src/sync.rs` (after `reconcile_refs("patches", ...)` at line 365)
931 - Modify: `tests/sync_test.rs` (confirm the wiring runs end-to-end via `sync::sync`)
932
933 - [ ] **Step 1: Write an end-to-end test driving the full `sync::sync` entry point**
934
935 Append to `tests/sync_test.rs` below the existing commit-link section:
936
937 ```rust
938 #[test]
939 fn sync_entry_point_emits_commit_link_events_and_pushes_them() {
940 let cluster = TestCluster::new();
941 let alice_repo = cluster.alice_repo();
942
943 // Alice opens an issue and syncs it up so Bob will see it too.
944 let (_issue_ref, issue_id) = open_issue(&alice_repo, &alice(), "bug");
945 sync::sync(&alice_repo, "origin").unwrap();
946
947 // Alice makes a real commit with an Issue: trailer.
948 let message = format!("Fix the thing\n\nIssue: {}", &issue_id[..8]);
949 make_commit_with_message(&cluster, &alice_repo, &message);
950
951 // Running sync should scan, emit the link, and push it.
952 sync::sync(&alice_repo, "origin").unwrap();
953
954 // Bob fetches and should see the link in the materialized issue.
955 let bob_repo = cluster.bob_repo();
956 sync::sync(&bob_repo, "origin").unwrap();
957 let bob_issue_ref = format!("refs/collab/issues/{}", issue_id);
958 let bob_issue = IssueState::from_ref_uncached(&bob_repo, &bob_issue_ref, &issue_id).unwrap();
959 assert_eq!(bob_issue.linked_commits.len(), 1);
960 }
961 ```
962
963 - [ ] **Step 2: Run the test and confirm it fails**
964
965 Run: `cargo test --test sync_test sync_entry_point_emits_commit_link_events_and_pushes_them`
966 Expected: FAIL — `sync::sync` doesn't call `scan_and_link` yet, so no link events exist for Bob to fetch.
967
968 - [ ] **Step 3: Wire the call into `src/sync.rs`**
969
970 Open `src/sync.rs`. Find the block around line 362–366:
971
972 ```rust
973 let repo = Repository::open(repo.path())?;
974 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
975 reconcile_refs(&repo, "issues", &author, &sk)?;
976 reconcile_refs(&repo, "patches", &author, &sk)?;
977
978 // Step 3: Push collab refs individually
979 ```
980
981 Insert the scan call between `reconcile_refs("patches", ...)` and `// Step 3: Push collab refs individually`:
982
983 ```rust
984 let repo = Repository::open(repo.path())?;
985 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
986 reconcile_refs(&repo, "issues", &author, &sk)?;
987 reconcile_refs(&repo, "patches", &author, &sk)?;
988
989 // Step 2.5: Scan local branches for Issue: trailers and emit link events.
990 // Never breaks sync — scan_and_link absorbs per-commit/per-issue errors.
991 match crate::commit_link::scan_and_link(&repo, &author, &sk) {
992 Ok(n) if n > 0 => println!("Linked {} commit(s) to issues.", n),
993 Ok(_) => {}
994 Err(e) => eprintln!("warning: commit link scan failed: {}", e),
995 }
996
997 // Step 3: Push collab refs individually
998 ```
999
1000 - [ ] **Step 4: Add the missing `crate::commit_link` import (if clippy wants it)**
1001
1002 `crate::commit_link::scan_and_link` is fully qualified in the call above, so no `use` is needed. Leave as-is.
1003
1004 - [ ] **Step 5: Run the integration test and confirm it passes**
1005
1006 Run: `cargo test --test sync_test sync_entry_point_emits_commit_link_events_and_pushes_them`
1007 Expected: PASS.
1008
1009 - [ ] **Step 6: Run the full sync test suite**
1010
1011 Run: `cargo test --test sync_test`
1012 Expected: PASS. Existing sync tests should be unaffected because no existing test creates commits with `Issue:` trailers.
1013
1014 - [ ] **Step 7: Commit**
1015
1016 ```bash
1017 git add src/sync.rs tests/sync_test.rs
1018 git commit -m "Wire commit_link::scan_and_link into the sync pipeline
1019
1020 Called after reconcile_refs so the dedup cache reflects merged remote
1021 state, and before collect_push_refs so new link events ride out in
1022 the same sync. End-to-end test: Alice writes a commit with an
1023 Issue: trailer, sync emits+pushes, Bob fetches and materializes the
1024 linked commit on the issue."
1025 ```
1026
1027 ---
1028
1029 ## Task 7: Add the remaining integration tests
1030
1031 **Files:**
1032 - Modify: `tests/sync_test.rs`
1033
1034 - [ ] **Step 1: Add idempotency and multi-branch tests**
1035
1036 Append to `tests/sync_test.rs`:
1037
1038 ```rust
1039 #[test]
1040 fn commit_link_scan_is_idempotent_across_runs() {
1041 let cluster = TestCluster::new();
1042 let alice_repo = cluster.alice_repo();
1043 let (issue_ref, issue_id) = open_issue(&alice_repo, &alice(), "bug");
1044
1045 let message = format!("Fix thing\n\nIssue: {}", &issue_id[..8]);
1046 make_commit_with_message(&cluster, &alice_repo, &message);
1047
1048 let author = git_collab::identity::get_author(&alice_repo).unwrap();
1049 let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
1050
1051 assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 1);
1052 assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 0);
1053 assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 0);
1054
1055 let issue = IssueState::from_ref_uncached(&alice_repo, &issue_ref, &issue_id).unwrap();
1056 assert_eq!(issue.linked_commits.len(), 1);
1057 }
1058
1059 #[test]
1060 fn commit_link_scan_walks_all_local_branches_and_dedups_shared_ancestors() {
1061 let cluster = TestCluster::new();
1062 let alice_repo = cluster.alice_repo();
1063 let (issue_ref, issue_id) = open_issue(&alice_repo, &alice(), "bug");
1064
1065 // Commit on main with the trailer. Both branches will reach it.
1066 let message = format!("Fix\n\nIssue: {}", &issue_id[..8]);
1067 let linked_commit = make_commit_with_message(&cluster, &alice_repo, &message);
1068
1069 // Create a second branch pointing at the same commit.
1070 {
1071 let commit = alice_repo.find_commit(linked_commit).unwrap();
1072 alice_repo.branch("feature-x", &commit, false).unwrap();
1073 }
1074
1075 let author = git_collab::identity::get_author(&alice_repo).unwrap();
1076 let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
1077
1078 // Should emit exactly one event despite the commit being reachable from
1079 // two branch tips.
1080 assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 1);
1081 let issue = IssueState::from_ref_uncached(&alice_repo, &issue_ref, &issue_id).unwrap();
1082 assert_eq!(issue.linked_commits.len(), 1);
1083 }
1084
1085 #[test]
1086 fn commit_link_scan_handles_multiple_issue_trailers_on_one_commit() {
1087 let cluster = TestCluster::new();
1088 let alice_repo = cluster.alice_repo();
1089 let (issue_ref_a, id_a) = open_issue(&alice_repo, &alice(), "bug a");
1090 let (issue_ref_b, id_b) = open_issue(&alice_repo, &alice(), "bug b");
1091
1092 let message = format!(
1093 "Fix both\n\nIssue: {}\nIssue: {}",
1094 &id_a[..8],
1095 &id_b[..8]
1096 );
1097 make_commit_with_message(&cluster, &alice_repo, &message);
1098
1099 let author = git_collab::identity::get_author(&alice_repo).unwrap();
1100 let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
1101 assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 2);
1102
1103 let issue_a = IssueState::from_ref_uncached(&alice_repo, &issue_ref_a, &id_a).unwrap();
1104 let issue_b = IssueState::from_ref_uncached(&alice_repo, &issue_ref_b, &id_b).unwrap();
1105 assert_eq!(issue_a.linked_commits.len(), 1);
1106 assert_eq!(issue_b.linked_commits.len(), 1);
1107 }
1108 ```
1109
1110 - [ ] **Step 2: Run these three tests**
1111
1112 Run: `cargo test --test sync_test commit_link_scan_is_idempotent commit_link_scan_walks_all commit_link_scan_handles_multiple`
1113 Expected: PASS.
1114
1115 - [ ] **Step 3: Add unknown/ambiguous/archived prefix tests**
1116
1117 Append to `tests/sync_test.rs`:
1118
1119 ```rust
1120 #[test]
1121 fn commit_link_scan_skips_unknown_prefix_without_error() {
1122 let cluster = TestCluster::new();
1123 let alice_repo = cluster.alice_repo();
1124
1125 // No issue exists. Commit uses a completely unrelated prefix.
1126 make_commit_with_message(
1127 &cluster,
1128 &alice_repo,
1129 "Fix\n\nIssue: zzzzzzzz",
1130 );
1131
1132 let author = git_collab::identity::get_author(&alice_repo).unwrap();
1133 let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
1134 assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 0);
1135 }
1136
1137 #[test]
1138 fn commit_link_scan_skips_ambiguous_prefix_without_error() {
1139 let cluster = TestCluster::new();
1140 let alice_repo = cluster.alice_repo();
1141
1142 // Open two issues. Their OIDs are random, so we can't guarantee they
1143 // share a prefix — instead, use a single-character prefix that is
1144 // likely to be ambiguous. If the two OIDs happen to not share a first
1145 // char, this test degrades to a no-op but still passes the "no error"
1146 // check, which is the important property.
1147 let (_, id_a) = open_issue(&alice_repo, &alice(), "a");
1148 let (_, id_b) = open_issue(&alice_repo, &alice(), "b");
1149
1150 // Find a shared character prefix, or fall back to the first char of a.
1151 let shared_prefix = if id_a.chars().next() == id_b.chars().next() {
1152 id_a[..1].to_string()
1153 } else {
1154 // No ambiguity possible — use a prefix that matches only a, which
1155 // exercises the resolve-success path instead. Test name still holds
1156 // because "without error" is the core assertion.
1157 id_a[..8].to_string()
1158 };
1159
1160 make_commit_with_message(
1161 &cluster,
1162 &alice_repo,
1163 &format!("Touch\n\nIssue: {}", shared_prefix),
1164 );
1165
1166 let author = git_collab::identity::get_author(&alice_repo).unwrap();
1167 let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
1168 // Must not error regardless of whether the prefix ambiguously matched.
1169 let _ = commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap();
1170 }
1171
1172 #[test]
1173 fn commit_link_scan_skips_archived_issues_with_warning() {
1174 let cluster = TestCluster::new();
1175 let alice_repo = cluster.alice_repo();
1176
1177 let (_, issue_id) = open_issue(&alice_repo, &alice(), "old bug");
1178 // Archive the issue via the state helper.
1179 state::archive_issue_ref(&alice_repo, &issue_id).unwrap();
1180
1181 make_commit_with_message(
1182 &cluster,
1183 &alice_repo,
1184 &format!("Reference old\n\nIssue: {}", &issue_id[..8]),
1185 );
1186
1187 let author = git_collab::identity::get_author(&alice_repo).unwrap();
1188 let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
1189 assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 0);
1190
1191 // Confirm the archived ref did not accrue a new event: the archived
1192 // DAG tip should still be the archive-time tip.
1193 let archived_ref = format!("refs/collab/archive/issues/{}", issue_id);
1194 let archived_state = IssueState::from_ref_uncached(&alice_repo, &archived_ref, &issue_id).unwrap();
1195 assert!(archived_state.linked_commits.is_empty());
1196 }
1197 ```
1198
1199 - [ ] **Step 4: Run these tests**
1200
1201 Run: `cargo test --test sync_test commit_link_scan_skips_unknown commit_link_scan_skips_ambiguous commit_link_scan_skips_archived`
1202 Expected: PASS. Warning output on stderr is expected and does not fail the test.
1203
1204 - [ ] **Step 5: Add detached-HEAD and remote-originated dedup tests**
1205
1206 Append to `tests/sync_test.rs`:
1207
1208 ```rust
1209 #[test]
1210 fn commit_link_scan_no_op_on_detached_head_with_no_branches() {
1211 let cluster = TestCluster::new();
1212 let alice_repo = cluster.alice_repo();
1213
1214 // Delete all local branches and put HEAD in detached state.
1215 let head_oid = alice_repo.head().unwrap().target().unwrap();
1216 alice_repo.set_head_detached(head_oid).unwrap();
1217 // Remove refs/heads/main.
1218 alice_repo.find_reference("refs/heads/main").unwrap().delete().unwrap();
1219
1220 let author = git_collab::identity::get_author(&alice_repo).unwrap();
1221 let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
1222 // No branches to walk — silent no-op.
1223 assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 0);
1224 }
1225
1226 #[test]
1227 fn commit_link_scan_dedups_against_remote_originated_events() {
1228 // Simulates the cross-machine dedup case: Bob's repo fetches a link
1229 // event that Alice already emitted, then runs scan locally with the
1230 // same commit reachable from his branches. He must not emit a
1231 // duplicate.
1232 let cluster = TestCluster::new();
1233 let alice_repo = cluster.alice_repo();
1234 let bob_repo = cluster.bob_repo();
1235
1236 // Both repos get the same issue.
1237 let (_, issue_id) = open_issue(&alice_repo, &alice(), "bug");
1238 sync::sync(&alice_repo, "origin").unwrap();
1239 sync::sync(&bob_repo, "origin").unwrap();
1240
1241 // Alice writes a commit and pushes it to the bare remote so Bob can
1242 // fetch it. First the regular git push; then sync for the link event.
1243 let message = format!("Fix thing\n\nIssue: {}", &issue_id[..8]);
1244 let linked_commit = make_commit_with_message(&cluster, &alice_repo, &message);
1245 // Push the branch so Bob sees the commit too.
1246 let mut cmd = Command::new("git");
1247 cmd.args(["push", "origin", "main"])
1248 .current_dir(cluster.alice_dir.path());
1249 assert!(cmd.status().unwrap().success());
1250 sync::sync(&alice_repo, "origin").unwrap();
1251
1252 // Bob fetches both the branch and the collab link event.
1253 let mut cmd = Command::new("git");
1254 cmd.args(["fetch", "origin", "main:main"])
1255 .current_dir(cluster.bob_dir.path());
1256 assert!(cmd.status().unwrap().success());
1257 sync::sync(&bob_repo, "origin").unwrap();
1258
1259 // At this point Bob's issue already has the link event from Alice.
1260 // Re-running scan on Bob's repo must find the commit locally and
1261 // decide "already linked", emitting zero events.
1262 let author = git_collab::identity::get_author(&bob_repo).unwrap();
1263 let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
1264 let emitted = commit_link::scan_and_link(&bob_repo, &author, &sk).unwrap();
1265 assert_eq!(emitted, 0, "Bob must not duplicate Alice's link event");
1266
1267 // And the commit on Bob's side really is the one linked.
1268 let bob_ref = format!("refs/collab/issues/{}", issue_id);
1269 let bob_issue = IssueState::from_ref_uncached(&bob_repo, &bob_ref, &issue_id).unwrap();
1270 assert_eq!(bob_issue.linked_commits.len(), 1);
1271 assert_eq!(bob_issue.linked_commits[0].commit, linked_commit.to_string());
1272 }
1273 ```
1274
1275 - [ ] **Step 6: Run these tests**
1276
1277 Run: `cargo test --test sync_test commit_link_scan_no_op_on_detached commit_link_scan_dedups_against_remote`
1278 Expected: PASS.
1279
1280 - [ ] **Step 7: Run the entire sync test suite as a regression check**
1281
1282 Run: `cargo test --test sync_test`
1283 Expected: PASS.
1284
1285 - [ ] **Step 8: Commit**
1286
1287 ```bash
1288 git add tests/sync_test.rs
1289 git commit -m "Add integration tests for commit-link scan edge cases
1290
1291 Covers idempotency, multi-branch ancestor dedup, multi-issue trailers,
1292 unknown/ambiguous/archived prefixes, detached HEAD silent no-op, and
1293 cross-machine dedup against remote-originated link events."
1294 ```
1295
1296 ---
1297
1298 ## Task 8: Render linked commits in the CLI `issue show` output
1299
1300 **Files:**
1301 - Modify: `src/lib.rs` (the `IssueCmd::Show` match arm, around lines 115–148)
1302 - 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.
1303
1304 - [ ] **Step 1: Write a failing test that uses the CLI to show an issue with a link**
1305
1306 Append to `tests/sync_test.rs`. Note: this uses `TestRepo` from `common`, which runs the binary — so it's a process-level test.
1307
1308 ```rust
1309 #[test]
1310 fn cli_issue_show_renders_linked_commits_section() {
1311 use common::TestRepo;
1312
1313 let repo = TestRepo::new("Alice", "alice@example.com");
1314 let issue_id = repo.issue_open("fix the thing");
1315
1316 // Write a commit with an Issue: trailer via git CLI.
1317 let full_id = {
1318 let out = repo.run_ok(&["issue", "show", &issue_id, "--json"]);
1319 let v: serde_json::Value = serde_json::from_str(&out).unwrap();
1320 v["id"].as_str().unwrap().to_string()
1321 };
1322 let msg = format!("Fix a thing\n\nIssue: {}", &full_id[..8]);
1323 repo.git(&["commit", "--allow-empty", "-m", &msg]);
1324
1325 // Run sync against a bare remote we create inline.
1326 let bare = TempDir::new().unwrap();
1327 Command::new("git")
1328 .args(["init", "--bare"])
1329 .current_dir(bare.path())
1330 .status()
1331 .unwrap();
1332 repo.git(&["remote", "add", "origin", bare.path().to_str().unwrap()]);
1333 repo.git(&["push", "-u", "origin", "main"]);
1334 repo.run_ok(&["init"]);
1335 repo.run_ok(&["sync"]);
1336
1337 let show = repo.run_ok(&["issue", "show", &issue_id]);
1338 assert!(
1339 show.contains("--- Linked Commits ---"),
1340 "expected linked commits section, got:\n{}",
1341 show
1342 );
1343 assert!(
1344 show.contains("by Alice"),
1345 "expected commit author rendered, got:\n{}",
1346 show
1347 );
1348 assert!(
1349 show.contains("(linked by Alice"),
1350 "expected event author rendered, got:\n{}",
1351 show
1352 );
1353 }
1354 ```
1355
1356 - [ ] **Step 2: Run the test and confirm it fails**
1357
1358 Run: `cargo test --test sync_test cli_issue_show_renders_linked_commits_section`
1359 Expected: FAIL — the renderer does not yet output the `--- Linked Commits ---` section.
1360
1361 - [ ] **Step 3: Add the renderer in `src/lib.rs`**
1362
1363 Open `src/lib.rs`. Find the `IssueCmd::Show` arm (starts around line 115). Locate the block that renders comments:
1364
1365 ```rust
1366 if !i.comments.is_empty() {
1367 println!("\n--- Comments ---");
1368 for c in &i.comments {
1369 println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body);
1370 }
1371 }
1372 Ok(())
1373 ```
1374
1375 Immediately before `Ok(())`, add the linked-commits renderer:
1376
1377 ```rust
1378 if !i.linked_commits.is_empty() {
1379 println!("\n--- Linked Commits ---");
1380 for lc in &i.linked_commits {
1381 let short_sha = if lc.commit.len() >= 7 { &lc.commit[..7] } else { &lc.commit };
1382 let (subject, commit_author) = match git2::Oid::from_str(&lc.commit)
1383 .ok()
1384 .and_then(|oid| repo.find_commit(oid).ok())
1385 {
1386 Some(commit) => {
1387 let subject = commit
1388 .summary()
1389 .map(|s| truncate_summary(s, 60))
1390 .unwrap_or_default();
1391 let author = commit
1392 .author()
1393 .name()
1394 .unwrap_or("unknown")
1395 .to_string();
1396 (Some(subject), Some(author))
1397 }
1398 None => (None, None),
1399 };
1400 match (subject, commit_author) {
1401 (Some(subject), Some(author)) => {
1402 println!(
1403 "· linked {} \"{}\" by {} (linked by {}, {})",
1404 short_sha,
1405 subject,
1406 author,
1407 lc.event_author.name,
1408 lc.event_timestamp,
1409 );
1410 }
1411 _ => {
1412 println!(
1413 "· linked {} (commit {} not in local repo) (linked by {}, {})",
1414 short_sha,
1415 short_sha,
1416 lc.event_author.name,
1417 lc.event_timestamp,
1418 );
1419 }
1420 }
1421 }
1422 }
1423 Ok(())
1424 ```
1425
1426 Add a helper at the bottom of `src/lib.rs` (outside all `fn run` code, next to `fn search`):
1427
1428 ```rust
1429 fn truncate_summary(s: &str, max_chars: usize) -> String {
1430 let mut out = String::new();
1431 let mut count = 0;
1432 for c in s.chars() {
1433 if count + 1 > max_chars {
1434 out.push('…');
1435 return out;
1436 }
1437 out.push(c);
1438 count += 1;
1439 }
1440 out
1441 }
1442 ```
1443
1444 - [ ] **Step 4: Run the test and confirm it passes**
1445
1446 Run: `cargo test --test sync_test cli_issue_show_renders_linked_commits_section`
1447 Expected: PASS.
1448
1449 - [ ] **Step 5: Run the full CLI test suite**
1450
1451 Run: `cargo test`
1452 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.
1453
1454 - [ ] **Step 6: Commit**
1455
1456 ```bash
1457 git add src/lib.rs tests/sync_test.rs
1458 git commit -m "Render --- Linked Commits --- section in CLI issue show
1459
1460 Each line shows the short SHA, commit subject (truncated to 60 chars),
1461 commit author, and then (linked by <event-author>, <timestamp>). When
1462 the commit isn't locally reachable (GC'd, shallow), falls back to a
1463 no-subject variant that still shows the linked-by metadata."
1464 ```
1465
1466 ---
1467
1468 ## Task 9: Render linked commits in the TUI issue detail widget
1469
1470 **Files:**
1471 - Modify: `src/tui/widgets.rs` (function rendering issue detail, around lines 390–464)
1472 - 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.
1473
1474 - [ ] **Step 1: Locate the relevant widget function**
1475
1476 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).
1477
1478 - [ ] **Step 2: Add the rendering block after the comments section**
1479
1480 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)`:
1481
1482 ```rust
1483 if !issue.linked_commits.is_empty() {
1484 lines.push(Line::raw(""));
1485 lines.push(Line::styled(
1486 "--- Linked Commits ---",
1487 Style::default()
1488 .fg(Color::Magenta)
1489 .add_modifier(Modifier::BOLD),
1490 ));
1491 for lc in &issue.linked_commits {
1492 let short_sha: String = lc.commit.chars().take(7).collect();
1493 let (subject, commit_author) = git2::Oid::from_str(&lc.commit)
1494 .ok()
1495 .and_then(|oid| repo.find_commit(oid).ok())
1496 .map(|commit| {
1497 let subject = commit.summary().unwrap_or("").to_string();
1498 let author = commit.author().name().unwrap_or("unknown").to_string();
1499 (subject, author)
1500 })
1501 .unwrap_or_else(|| (String::new(), String::new()));
1502 let line_text = if commit_author.is_empty() {
1503 format!(
1504 "· linked {} (commit {} not in local repo) (linked by {}, {})",
1505 short_sha, short_sha, lc.event_author.name, lc.event_timestamp
1506 )
1507 } else {
1508 format!(
1509 "· linked {} \"{}\" by {} (linked by {}, {})",
1510 short_sha, subject, commit_author, lc.event_author.name, lc.event_timestamp
1511 )
1512 };
1513 lines.push(Line::raw(line_text));
1514 }
1515 }
1516 ```
1517
1518 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.
1519
1520 - [ ] **Step 3: Build and check**
1521
1522 Run: `cargo build`
1523 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.
1524
1525 - [ ] **Step 4: Run clippy**
1526
1527 Run: `cargo clippy --all-targets -- -D warnings`
1528 Expected: no warnings.
1529
1530 - [ ] **Step 5: Run the existing TUI smoke tests to confirm no regression**
1531
1532 Run: `cargo test --test dashboard_test`
1533 Expected: PASS (if a dashboard test suite exists — skip this step if it doesn't).
1534
1535 - [ ] **Step 6: Commit**
1536
1537 ```bash
1538 git add src/tui/widgets.rs src/tui/mod.rs src/tui/state.rs
1539 git commit -m "Render linked commits section in TUI issue detail
1540
1541 Mirrors the CLI renderer: --- Linked Commits --- section after
1542 comments, with short SHA + subject + commit author + linked-by
1543 metadata. Degrades gracefully when the commit isn't locally available."
1544 ```
1545
1546 (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.)
1547
1548 ---
1549
1550 ## Task 10: Final verification and cleanup
1551
1552 **Files:** none
1553
1554 - [ ] **Step 1: Run the full test suite**
1555
1556 Run: `cargo test`
1557 Expected: PASS across all targets.
1558
1559 - [ ] **Step 2: Run clippy with deny warnings**
1560
1561 Run: `cargo clippy --all-targets -- -D warnings`
1562 Expected: no warnings.
1563
1564 - [ ] **Step 3: Manual end-to-end smoke test**
1565
1566 In a scratch directory (the worktree is fine):
1567
1568 ```bash
1569 cd /tmp
1570 rm -rf commit-link-smoke && mkdir commit-link-smoke && cd commit-link-smoke
1571 git init -b main
1572 git config user.name "Smoke"
1573 git config user.email "smoke@example.com"
1574 git commit --allow-empty -m "root"
1575
1576 # Use the locally-built binary.
1577 BIN=/home/xanderle/dev/git-collab/target/debug/git-collab
1578 $BIN issue open -t "smoke test issue"
1579 ID=$($BIN issue list | head -1 | awk '{print $1}')
1580 echo "Issue: $ID"
1581
1582 git commit --allow-empty -m "Fix thing
1583
1584 Issue: $ID"
1585
1586 git init --bare /tmp/commit-link-smoke-bare
1587 git remote add origin /tmp/commit-link-smoke-bare
1588 git push -u origin main
1589 $BIN init
1590 $BIN sync
1591
1592 $BIN issue show $ID
1593 ```
1594
1595 Expected in the output: a `--- Linked Commits ---` section with one line like `· linked <sha> "Fix thing" by Smoke (linked by Smoke, 2026-...)`.
1596
1597 - [ ] **Step 4: Commit any final cleanup**
1598
1599 If the smoke test reveals anything missing (e.g. a poorly-formatted line, a missing newline), fix it and commit:
1600
1601 ```bash
1602 git add -p
1603 git commit -m "Polish commit-link renderer based on smoke test feedback"
1604 ```
1605
1606 Otherwise, this task is a no-op commit-wise.
1607
1608 ---
1609
1610 ## Self-Review
1611
1612 Checking the plan against the spec `docs/superpowers/specs/2026-04-12-commit-issue-link-design.md`:
1613
1614 **Spec coverage:**
1615
1616 - ✅ `Action::IssueCommitLink` variant — Task 1.
1617 - ✅ `src/commit_link.rs` module with `parse_issue_trailers`, `collect_linked_shas`, `scan_and_link` — Tasks 3, 4, 5.
1618 - ✅ Strict trailer-block rule (every non-empty line must be trailer-shaped) — Task 3 parser + test `parser_mixed_final_paragraph_rejects_all`.
1619 - ✅ Single-token value rule (`(\S+)` equivalent via `split_whitespace().count() != 1`) — Task 3 parser + test `parser_rejects_value_with_trailing_garbage`.
1620 - ✅ Case-insensitive key match — Task 3 test `parser_case_variants`.
1621 - ✅ Sync insertion point (after `reconcile_refs("patches")`, before `collect_push_refs`) — Task 6.
1622 - ✅ Revwalk of all `refs/heads/*` with per-sync `HashSet<Oid>` dedup — Task 5 (`scan_and_link`).
1623 - ✅ Per-issue linked-SHA cache keyed by resolved ref name — Task 5 (`link_cache: HashMap<String, Option<HashSet<String>>>`).
1624 - ✅ Archived-issue skip with warning — Task 5 (`ARCHIVED_ISSUE_PREFIX` check) + integration test in Task 7.
1625 - ✅ Detached HEAD / no local branches silent no-op — Task 5 (`seeded_any` check) + integration test in Task 7.
1626 - ✅ Cross-machine duplication handled at render time via first-seen dedup — Task 2 (`IssueState::from_ref_uncached` arm).
1627 - ✅ Renderer shows both commit author and event author — Tasks 8 (CLI) and 9 (TUI).
1628 - ✅ Renderer fallback when commit is not locally available — Tasks 8 and 9 (fallback branches).
1629 - ✅ "Never breaks sync" error handling philosophy — Task 5 (`eprintln!` warnings + `continue`).
1630 - ✅ Per-issue cache poisoning within a single sync — Task 5 (`Option<HashSet<...>>` where `None` is poisoned, `Some(_)` is live).
1631 - ✅ Unit tests for parser (all spec fixtures) — Task 3.
1632 - ✅ 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.
1633
1634 **Placeholder scan:**
1635 - No "TBD", "TODO", "fill in later" in any task.
1636 - Every code block contains the actual code to write.
1637 - Every test has real assertions.
1638 - 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.
1639
1640 **Type/name consistency:**
1641 - `LinkedCommit { commit, event_author, event_timestamp }` used consistently across Task 2 struct definition, Task 2 materializer, Task 8 CLI renderer, Task 9 TUI renderer.
1642 - `scan_and_link(repo, author, sk) -> Result<usize, Error>` used consistently in Task 5 impl and Tasks 5, 6, 7 tests.
1643 - `collect_linked_shas(repo, issue_ref) -> Result<HashSet<String>, Error>` consistent Task 4 impl + Task 5 caller.
1644 - `parse_issue_trailers(message) -> Vec<String>` consistent Task 3 impl + Task 3 tests + Task 5 caller.
1645 - `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.
1646
1647 **Note on one simplification** introduced by the plan vs. the spec's wording:
1648
1649 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.
1650
1651 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.
1652
1653 ---
1654
1655 ## Execution Handoff
1656
1657 Plan complete and saved to `docs/superpowers/plans/2026-04-12-commit-issue-link.md`. Two execution options:
1658
1659 **1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration.
1660
1661 **2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints.
1662
1663 Which approach?
docs/superpowers/specs/2026-04-12-commit-issue-link-design.md
Old New
@@ -0,0 +1,230 @@
1 # Auto-Link Commits to Issues via `Issue:` Trailer
2
3 **Status:** design approved
4 **Date:** 2026-04-12
5
6 ## Summary
7
8 During `git-collab sync`, walk all local branches and look for commits whose messages contain an `Issue: <prefix>` git trailer. For each such commit, resolve the prefix to an issue and emit a new `issue.commit_link` event on that issue's DAG. The operation is idempotent from a single machine: re-running sync against unchanged history produces no new events. Across machines, temporary duplication is possible if two contributors emit a link event for the same (issue, commit) before either has pushed; the renderer dedups by commit SHA so the timeline never shows the same link twice.
9
10 ## Motivation
11
12 Today, commits and issues live in parallel. A contributor can mention an issue ID in a commit message, but nothing structured ties them together — the collab view of an issue has no idea which commits reference it. Auto-linking closes that gap without requiring any new command, UI, or manual step beyond writing a trailer.
13
14 Explicitly out of scope: auto-resolving or auto-closing issues from commits. This change records links only.
15
16 ## User Experience
17
18 A contributor writes a commit message like:
19
20 ```
21 Fix off-by-one in patch walker
22
23 The revwalk was skipping the final commit on branches that
24 diverged from main before the last sync.
25
26 Issue: a3f9
27 ```
28
29 They run `git-collab sync`. Output includes:
30
31 ```
32 Linked 1 commit(s) to issues.
33 ```
34
35 Later, `git-collab issue show a3f9` shows a new line in the timeline:
36
37 ```
38 · linked 4b2e1cd "Fix off-by-one in patch walker" by bob (linked by alice, 2h ago)
39 ```
40
41 Showing both the commit author (`bob`) and the event author (`alice`) matters on shared checkouts and CI boxes: the person who runs sync isn't always the person who wrote the commit, and the timeline needs to reflect that honestly.
42
43 If the contributor forgets the trailer on the first commit but adds it on a later commit, running sync again picks up just the new one. Re-running sync against unchanged history does nothing and prints nothing.
44
45 ## Architecture
46
47 One new module, one new event variant, one insertion point in the sync pipeline, one new case in the issue-timeline renderer.
48
49 ### Data flow per sync
50
51 1. `sync()` completes `reconcile_refs("issues", ...)` and `reconcile_refs("patches", ...)` as today. After reconcile, the local issue DAGs contain every link event that has been pushed to the remote, so the dedup cache we're about to build reflects the merged remote state.
52 2. Before `collect_push_refs`, `sync()` calls `commit_link::scan_and_link(&repo, &author, &sk)`.
53 3. `scan_and_link` builds a revwalk seeded from every `refs/heads/*` tip, deduplicating shared ancestors in a per-sync `HashSet<Oid>`. If there are no local branches (e.g. detached HEAD immediately after `git checkout <sha>`) the revwalk is empty and `scan_and_link` is a silent no-op. Shallow clones are fine: the revwalk stops at the shallow boundary, so we link whatever is locally reachable, and deepening the clone and re-syncing picks up anything we missed.
54 4. For each commit, the module parses `Issue:` trailers from the commit message.
55 5. For each trailer value, it resolves the prefix via `state::resolve_issue_ref`. Unresolved or ambiguous prefixes become one-line stderr warnings and are skipped. If the resolved ref lives under `refs/collab/archive/issues/...`, the module warns and skips — archived issues are a frozen namespace and should not accrue new events.
56 6. For each resolved (non-archived) issue, the module lazily loads the issue's existing event log into a per-issue `HashSet<String>` of already-linked commit SHAs. If this commit's SHA is not in the set, it emits a new `IssueCommitLink { commit }` event via `dag::append_event` and adds the SHA to the set.
57 7. New events are picked up by the subsequent `collect_push_refs` and pushed to the remote in the same sync.
58
59 ### Dedup — why two sets
60
61 **Revwalk dedup (`HashSet<Oid>`) — within a single sync.** Branches share ancestors. Without this set, `main`'s history would be walked once per branch tip. It's a perf optimization; correctness is unaffected.
62
63 **Per-issue linked-SHA dedup (`HashMap<IssueRef, HashSet<String>>`) — across sync runs.** A commit with `Issue: abc` will keep matching on every future sync — we need to not emit a new link event every time. The source of truth is the issue's own event DAG: if it already contains an `IssueCommitLink` for this commit, we skip. The first time a given issue is matched in a sync, we walk its DAG once to build the set, cache it, and use the cache for the rest of the sync. New emissions are inserted into the cache so two trailers pointing at the same issue in one sync don't both try to emit.
64
65 The cache key is the *resolved ref name* returned by `state::resolve_issue_ref`, not a reconstructed `refs/collab/issues/<id>` string. This matters because resolution may return either an active or archived ref; using the resolved name keeps the cache, `collect_linked_shas`, and `dag::append_event` all talking about the same ref. (Archived refs are then filtered out entirely per step 5 above; the cache keying is still the right shape for when active refs collide with archived ones.)
66
67 Net effect on a single machine: each (issue, commit) pair produces exactly one `issue.commit_link` event, ever, regardless of how many branches contain the commit or how often sync runs.
68
69 **Cross-machine duplication is possible and tolerated.** If Alice emits a link event that fails to push, and Bob syncs from a different machine before Alice's next sync, Bob's `collect_linked_shas` won't see Alice's pending event (it isn't on the remote yet), and Bob will emit a second link for the same (issue, commit). Both events eventually merge into the DAG. This is correct for the event model but would produce a duplicated timeline entry, so the renderer dedups by commit SHA at render time (see "Issue timeline renderer" below).
70
71 ## Components
72
73 ### `src/commit_link.rs` (new)
74
75 ```rust
76 pub fn scan_and_link(
77 repo: &Repository,
78 author: &Author,
79 sk: &SigningKey,
80 ) -> Result<usize, Error>; // returns count of events emitted
81
82 fn parse_issue_trailers(message: &str) -> Vec<String>;
83 fn collect_linked_shas(repo: &Repository, issue_ref: &str) -> Result<HashSet<String>, Error>;
84 ```
85
86 `parse_issue_trailers` is pure and string-only: no git, no I/O. It implements the strict trailer-block rules in the "Trailer Parsing Rules" section below. Easy to unit-test with a table of fixtures.
87
88 `collect_linked_shas` walks the DAG for a single issue ref and returns every SHA mentioned in an existing `IssueCommitLink` event. Called lazily on first match per issue; result cached in `scan_and_link`'s local `HashMap` keyed by the *resolved ref name* (not a reconstructed path).
89
90 `scan_and_link` orchestrates: build a `git2::Revwalk`, push every `refs/heads/*` tip, iterate, dedup via `HashSet<Oid>`, per commit call `parse_issue_trailers`, for each trailer value call `state::resolve_issue_ref`, skip archived refs with a warning, populate the per-issue cache on demand (keyed by resolved ref name), and emit via `dag::append_event` using the same resolved ref name.
91
92 ### `src/event.rs`
93
94 Add one variant to the `Action` enum (currently ending at line 105):
95
96 ```rust
97 #[serde(rename = "issue.commit_link")]
98 IssueCommitLink { commit: String },
99 ```
100
101 Serialization follows the existing `rename` pattern. No `alias` needed — this is a new type.
102
103 ### `src/sync.rs`
104
105 Insert one call after `reconcile_refs(&repo, "patches", ...)` at line 365, before `collect_push_refs`:
106
107 ```rust
108 match commit_link::scan_and_link(&repo, &author, &sk) {
109 Ok(n) if n > 0 => println!("Linked {} commit(s) to issues.", n),
110 Ok(_) => {}
111 Err(e) => eprintln!("warning: commit link scan failed: {}", e),
112 }
113 ```
114
115 Sync proceeds to `collect_push_refs` regardless of the result. Any events that were successfully emitted before a failure still get pushed in the same sync.
116
117 ### Issue timeline renderer
118
119 Locate the existing match statement that renders events for `issue show`. Add an arm for `IssueCommitLink` that prints:
120
121 ```
122 · linked <short-sha> "<commit subject>" by <commit-author> (linked by <event-author>, <rel-time>)
123 ```
124
125 - `<short-sha>`: first 7 characters of the commit SHA.
126 - `<commit subject>`: first line of the commit message, truncated to 60 characters (with `…` on truncation).
127 - `<commit-author>`: `commit.author().name()` from the git commit object — the person who wrote the code.
128 - `<event-author>` and `<rel-time>`: from the event's existing `author` and `timestamp` fields — the person whose sync emitted the link event. On a single-user checkout these are usually the same; on shared checkouts and CI boxes they differ, and showing both is the honest rendering.
129
130 If the commit has been GC'd or isn't available locally at render time, fall back to:
131
132 ```
133 · linked <short-sha> (commit <short-sha> not in local repo) (linked by <event-author>, <rel-time>)
134 ```
135
136 **Render-time dedup.** Before rendering, collect `IssueCommitLink` events into a `HashMap<commit_sha, Event>` keyed by commit SHA, keeping the earliest (lowest `clock`, then earliest `timestamp`) event per key. Only the kept event is rendered. This absorbs the cross-machine duplication case described in the dedup section: two machines each emitting a link for the same (issue, commit) yields two DAG events but exactly one timeline line.
137
138 ## Trailer Parsing Rules
139
140 **Strict trailer block, matching git's own definition.** Split the commit message on blank lines to get paragraphs. Take the final paragraph. The final paragraph is a trailer block **only if every non-empty line in it is trailer-shaped** — that is, matches `^[A-Za-z][A-Za-z0-9-]*\s*:\s*\S.*$` (a token, colon, non-empty value). If *any* line in the final paragraph is not trailer-shaped, the final paragraph is prose, not a trailer block, and we extract zero trailers from the message. If the whole message is one paragraph with no blank lines, the same rule applies to the whole message.
141
142 This rule deliberately diverges from "final paragraph contains any trailer-looking line" (which would false-positive on a paragraph like `Thanks to Bob for the catch.\nIssue: a3f9`) and aligns with `git interpret-trailers --parse` semantics. Contributors who want to link an issue must use a dedicated trailer block separated from prose by a blank line — the same convention they already use for `Signed-off-by:`.
143
144 **Line match within a confirmed trailer block** = `(?i)^\s*issue\s*:\s*(\S+)\s*$`. Only the exact key `Issue`, case-insensitive. The value must be a single non-whitespace token followed by optional trailing whitespace and end-of-line — nothing else. `Issue: abc fixes thing` does **not** match (the trailing ` fixes thing` is rejected). That's deliberate: anything that looks like a loose commentary on the issue should not silently become a prefix lookup that warns every sync forever. Not `Issues`, not `Fixes`, not `Closes`. Scope is deliberately tight; other keys can be added in follow-up work if needed.
145
146 **One issue prefix per line.** No comma-separated lists. If a contributor wants to link two issues, they write two `Issue:` lines. The `(\S+)` match enforces this at parse time.
147
148 **Multiple trailers on one commit** are all honored. Each resolves independently; each produces its own `IssueCommitLink` event on its respective issue.
149
150 **Resolution** goes through `state::resolve_issue_ref`, which already handles prefix matching and ambiguity:
151
152 - unknown prefix → `warning: commit <sha>: Issue: <val> — no such issue, skipping`
153 - ambiguous prefix → `warning: commit <sha>: Issue: <val> — ambiguous (matches N issues), skipping`
154 - resolved to an archived ref → `warning: commit <sha>: Issue: <val> — issue is archived, skipping`
155 - resolved to an active ref → proceed to dedup and emit
156
157 **Not parsed**: prose mentions in the commit body like "this fixes issue abc". Only the structured trailer form. That's the point of picking a trailer — no false positives from commit messages that happen to mention an issue ID.
158
159 ## Error Handling
160
161 **Philosophy: `scan_and_link` must never break sync.** Commits with trailers are a nice-to-have; losing the ability to push collab state because of a parse hiccup would be a bad trade.
162
163 **Inside `scan_and_link`** — per-commit and per-issue errors are caught, turned into one-line stderr warnings prefixed `warning:`, and iteration continues:
164
165 - Commit object fails to load → warn, skip commit.
166 - Trailer resolves to unknown or ambiguous issue prefix → warn with commit SHA and trailer value, skip that trailer (other trailers on the same commit are still processed).
167 - Reading an issue's existing event log fails → warn, mark that issue as poisoned in the per-sync cache so we don't retry on every subsequent match *within this sync run*. Poisoning is not persisted; the next sync starts fresh and tries again.
168 - `dag::append_event` fails for one issue → warn with issue ID and commit SHA, continue. Other emissions in the same sync are unaffected.
169
170 `scan_and_link` returns `Result<usize, Error>` but the only errors that actually propagate are "couldn't even start" failures: repo handle invalid, HEAD missing, revwalk construction failed. Everything else is absorbed.
171
172 **At the sync call site**, the `Err` branch logs and continues. Sync reaches `collect_push_refs` regardless. Any link events that were emitted before the failure still get pushed.
173
174 **Warnings are one-line, `warning:` prefix, stderr**, matching the existing sync output style.
175
176 **Deliberately not handled:**
177 - Duplicate-warning suppression. An ambiguous or unknown prefix will warn on every sync until the contributor amends the trailer. That's the right incentive.
178 - Retrying failed dag writes. The next sync naturally retries via a cache miss.
179
180 ## Testing
181
182 ### Unit tests — `tests/commit_link_test.rs` (new)
183
184 Pure parser, table-driven over message → expected trailers:
185
186 - No trailer block → `[]`
187 - Single `Issue: abc` in a pure trailer-block final paragraph → `["abc"]`
188 - Case variants (`issue:`, `ISSUE :`, ` Issue: abc `) → `["abc"]`
189 - Two `Issue:` lines in a pure trailer block → `["abc", "def"]`
190 - `Issue:` in body but not the final paragraph → `[]`
191 - `Issues: abc` (wrong key) → `[]`
192 - Prose mention `"fixes issue abc"` → `[]`
193 - Message with no blank lines, the whole message is a pure trailer block containing `Issue: abc` → `["abc"]`
194 - Empty message → `[]`
195 - **Mixed final paragraph** (`Thanks to Bob for the catch.\nIssue: a3f9`) → `[]` — final paragraph contains a prose line, so it's not a trailer block and we extract nothing
196 - **Trailing whitespace-only final paragraph** after a real trailer block → `["abc"]` — empty trailing paragraphs don't shadow the previous trailer block
197 - **Pure trailer block with mixed keys** (`Signed-off-by: alice <a@x>\nIssue: abc`) → `["abc"]` — `Signed-off-by:` is trailer-shaped so the paragraph still qualifies as a trailer block
198 - **Value with trailing garbage** (`Issue: abc fixes thing`) → `[]` — `(\S+)` with anchored `\s*$` rejects the second token
199 - **Value with only whitespace** (`Issue: `) → `[]` — no captured token
200
201 ### Integration tests — `tests/sync_test.rs` (extending existing patterns)
202
203 1. **Happy path** — create issue `foo`, commit with `Issue: foo` trailer, run sync, assert the issue ref has one new `IssueCommitLink` event with the right SHA.
204 2. **Idempotency** — run sync twice against the same history, assert exactly one link event exists.
205 3. **Multiple branches sharing an ancestor** — same linked commit reachable from two branches, assert exactly one link event (exercises both dedup sets).
206 4. **Multi-issue trailer** — commit with two `Issue:` lines, assert both issues get one event each.
207 5. **Unknown prefix** — `Issue: zzz` where no issue matches, assert sync completes successfully, no event emitted, warning on stderr.
208 6. **Ambiguous prefix** — two issues sharing a prefix, trailer uses that prefix, assert no event, warning, sync still succeeds.
209 7. **Prior manual link then re-sync** — seed the issue's DAG with an `IssueCommitLink` for SHA X, add a new commit with `Issue: foo`, sync, assert only the new event is added.
210 8. **Remote-originated link dedup** — simulate a remote that already holds an `IssueCommitLink` for commit X (set up via a second repo, push, then clone/fetch into the test repo). Run sync locally with the same commit X present in local history. After `reconcile_refs` the merged log contains the remote event; assert `scan_and_link` sees it via `collect_linked_shas` and does not emit a duplicate.
211 9. **Archived issue** — archive an issue, add a commit with `Issue: <archived-id>`, sync, assert no event emitted, warning on stderr, archived issue's DAG unchanged, sync still succeeds.
212 10. **Detached HEAD** — check out a bare SHA with no branches pointing at it, add a commit with `Issue: foo` (orphaned), sync, assert no event emitted (revwalk from `refs/heads/*` is empty) and sync succeeds without error.
213 11. **Commit-author vs event-author rendering** — emit a link event where the commit author email differs from the git user running the test. Render the issue; assert the one-line format shows both names distinctly.
214 12. **Render-time dedup** — seed an issue's DAG with two `IssueCommitLink` events for the same commit SHA from different event authors. Render the issue; assert exactly one link line appears, showing the earliest event.
215
216 ### Renderer
217
218 Add a case to the existing `issue show` event-rendering test. Assert the one-line format renders as specified. The exact test file will be located during implementation.
219
220 ### Not tested
221
222 - Performance on very large histories. The O(history) walk is an accepted cost per the depth-handling decision: parsing is microsecond-cheap, and only commits with actual trailers pay any I/O cost beyond that.
223 - Concurrent syncs. Already covered by the existing `SyncLock` in `sync.rs`.
224
225 ## Alternatives Considered
226
227 1. **Commit-msg hook for immediate linking.** Rejected: requires per-machine install via `git-collab init`, fails open when not installed, and duplicates the sync path for a feature that naturally belongs at publish time.
228 2. **Persistent scan cursor (last-scanned OID per branch) to bound re-walk work.** Rejected as speculative optimization. Parsing a commit message is microseconds; the per-issue dedup cache is `O(events_on_issue)`; the only real cost is trailer-bearing commits, which are rare.
229 3. **Cap scan at N commits from each tip.** Rejected: bounded but lossy. Silently misses trailers on older commits for contributors who sync late.
230 4. **Also parse `Fixes:` / `Closes:` keys and auto-close issues.** Explicitly out of scope. The user asked for link tracking only, and mixing status mutation into the scan would broaden the feature surface considerably.
src/cli.rs
Old New
@@ -115,6 +115,12 @@ pub enum Commands {
115 /// Manage identity aliases 115 /// Manage identity aliases
116 #[command(subcommand)] 116 #[command(subcommand)]
117 Identity(IdentityCmd), 117 Identity(IdentityCmd),
118
119 /// Full-text search across all issues and patches
120 Search {
121 /// Search query (case-insensitive substring match)
122 query: String,
123 },
118 } 124 }
119 125
120 #[derive(Subcommand)] 126 #[derive(Subcommand)]
@@ -377,6 +383,38 @@ pub enum PatchCmd {
377 /// Patch ID (prefix match) 383 /// Patch ID (prefix match)
378 id: String, 384 id: String,
379 }, 385 },
386 /// Check out a patch's latest revision as a local branch
387 Checkout {
388 /// Patch ID (prefix match)
389 id: String,
390 },
391 }
392
393 impl Commands {
394 pub fn is_write(&self) -> bool {
395 match self {
396 Commands::Issue(cmd) => matches!(
397 cmd,
398 IssueCmd::Open { .. }
399 | IssueCmd::Comment { .. }
400 | IssueCmd::Close { .. }
401 | IssueCmd::Edit { .. }
402 | IssueCmd::Label { .. }
403 | IssueCmd::Unlabel { .. }
404 | IssueCmd::Assign { .. }
405 | IssueCmd::Unassign { .. }
406 ),
407 Commands::Patch(cmd) => matches!(
408 cmd,
409 PatchCmd::Create { .. }
410 | PatchCmd::Comment { .. }
411 | PatchCmd::Review { .. }
412 | PatchCmd::Revise { .. }
413 | PatchCmd::Close { .. }
414 ),
415 _ => false,
416 }
417 }
380 } 418 }
381 419
382 #[derive(Subcommand)] 420 #[derive(Subcommand)]
src/commit_link.rs
Old New
@@ -0,0 +1,279 @@
1 //! Auto-link commits to issues via `Issue:` git trailers during sync.
2 //!
3 //! See: docs/superpowers/specs/2026-04-12-commit-issue-link-design.md
4
5 use std::collections::{HashMap, HashSet};
6
7 use git2::{Oid, Repository, Sort};
8
9 use crate::dag;
10 use crate::error::Error;
11 use crate::event::{Action, Author, Event};
12
13 /// Walk an issue's event DAG and return every commit SHA that has an
14 /// `IssueCommitLink` event attached. Called lazily on first match per issue
15 /// during `scan_and_link`; the result is cached in the orchestrator's
16 /// `HashMap<RefName, HashSet<String>>`.
17 pub fn collect_linked_shas(repo: &Repository, issue_ref: &str) -> Result<HashSet<String>, Error> {
18 let events = dag::walk_events(repo, issue_ref)?;
19 let mut shas = HashSet::new();
20 for (_oid, event) in events {
21 if let Action::IssueCommitLink { commit } = event.action {
22 shas.insert(commit);
23 }
24 }
25 Ok(shas)
26 }
27
28 /// Parse `Issue:` trailers from a commit message.
29 ///
30 /// Returns the list of trailer values in order of appearance. Follows git's
31 /// own trailer-block semantics: only the final paragraph is considered, and
32 /// *every* non-empty line in it must be trailer-shaped (a `token: value`
33 /// line) for the paragraph to qualify. Any prose line in the final paragraph
34 /// disqualifies the whole paragraph — this prevents false positives like
35 /// `"Thanks Bob.\nIssue: abc"` in commit bodies.
36 ///
37 /// The key match is `(?i)issue`; the value must be a single non-whitespace
38 /// token followed by optional trailing whitespace and end-of-line. Values
39 /// like `abc fixes thing` are rejected so that loose commentary never
40 /// becomes a silent issue-prefix lookup that warns every sync forever.
41 pub fn parse_issue_trailers(message: &str) -> Vec<String> {
42 // 1. Split into paragraphs (blank-line separated), preserving order.
43 // Trim trailing whitespace from each line for the trailer-shape check,
44 // but keep enough structure to recognize blank lines.
45 let lines: Vec<&str> = message.lines().collect();
46
47 // 2. Find the last paragraph: the longest tail slice that contains at
48 // least one non-empty line and has no blank line *before* its first
49 // non-empty line in the tail.
50 //
51 // Walking from the end: skip trailing blank/whitespace-only lines,
52 // then collect lines until we hit a blank line.
53 let mut end = lines.len();
54 while end > 0 && lines[end - 1].trim().is_empty() {
55 end -= 1;
56 }
57 if end == 0 {
58 return Vec::new();
59 }
60 let mut start = end;
61 while start > 0 && !lines[start - 1].trim().is_empty() {
62 start -= 1;
63 }
64 let paragraph = &lines[start..end];
65
66 // 3. Validate every non-empty line in the paragraph is trailer-shaped.
67 for line in paragraph {
68 if line.trim().is_empty() {
69 continue;
70 }
71 if !is_trailer_shaped(line) {
72 return Vec::new();
73 }
74 }
75
76 // 4. Extract `Issue:` values.
77 let mut out = Vec::new();
78 for line in paragraph {
79 if let Some(value) = match_issue_line(line) {
80 out.push(value);
81 }
82 }
83 out
84 }
85
86 /// Returns true if a line looks like a git trailer: `<token>: <value>`, where
87 /// token starts with a letter and consists of `[A-Za-z0-9-]`, and value is at
88 /// least one non-whitespace character.
89 fn is_trailer_shaped(line: &str) -> bool {
90 let trimmed = line.trim_start();
91 let Some(colon_pos) = trimmed.find(':') else {
92 return false;
93 };
94 // Use trim_end() so that `ISSUE : abc` is recognized as the token `ISSUE`
95 // — matching what `match_issue_line` does. Without this, the space before
96 // the colon would disqualify the line and make the whole paragraph fail
97 // the trailer-shape check.
98 let token = trimmed[..colon_pos].trim_end();
99 if token.is_empty() {
100 return false;
101 }
102 let mut chars = token.chars();
103 let first = chars.next().unwrap();
104 if !first.is_ascii_alphabetic() {
105 return false;
106 }
107 if !chars.all(|c| c.is_ascii_alphanumeric() || c == '-') {
108 return false;
109 }
110 let value = trimmed[colon_pos + 1..].trim();
111 !value.is_empty()
112 }
113
114 /// If `line` is an `Issue: <token>` trailer with exactly one non-whitespace
115 /// token in its value, returns the token. Otherwise returns None.
116 fn match_issue_line(line: &str) -> Option<String> {
117 let trimmed = line.trim_start();
118 let colon_pos = trimmed.find(':')?;
119 let key = trimmed[..colon_pos].trim_end();
120 if !key.eq_ignore_ascii_case("issue") {
121 return None;
122 }
123 let value_region = &trimmed[colon_pos + 1..];
124 let value = value_region.trim();
125 if value.is_empty() {
126 return None;
127 }
128 // Reject values with interior whitespace: `abc fixes thing` must not
129 // parse to `abc` silently — it must parse to nothing so the user sees
130 // that their commentary is being ignored.
131 if value.split_whitespace().count() != 1 {
132 return None;
133 }
134 Some(value.to_string())
135 }
136
137 const ACTIVE_ISSUE_PREFIX: &str = "refs/collab/issues/";
138 const ARCHIVED_ISSUE_PREFIX: &str = "refs/collab/archive/issues/";
139
140 /// Walk every commit reachable from `refs/heads/*`, parse `Issue:` trailers,
141 /// resolve each to an issue, and emit an `IssueCommitLink` event for any
142 /// (issue, commit) pair that doesn't already have one.
143 ///
144 /// **Never breaks sync.** Per-commit and per-issue errors are logged as
145 /// one-line stderr warnings and iteration continues. The only errors that
146 /// propagate are "couldn't even start" failures (opening the repo, building
147 /// the revwalk). Callers treat a returned `Err` as "skip the link scan for
148 /// this sync" and proceed.
149 ///
150 /// Returns the number of events actually emitted.
151 pub fn scan_and_link(
152 repo: &Repository,
153 author: &Author,
154 sk: &ed25519_dalek::SigningKey,
155 ) -> Result<usize, Error> {
156 // Build a revwalk seeded from every local branch tip.
157 let mut revwalk = repo.revwalk()?;
158 revwalk.set_sorting(Sort::TOPOLOGICAL)?;
159
160 let mut seeded_any = false;
161 for reference in repo.references_glob("refs/heads/*")? {
162 let Ok(reference) = reference else { continue };
163 let Some(target) = reference.target() else {
164 continue;
165 };
166 // `revwalk.push` dedups commits across branch tips internally.
167 if revwalk.push(target).is_ok() {
168 seeded_any = true;
169 }
170 }
171 if !seeded_any {
172 // Detached HEAD with no local branches. Silent no-op per spec.
173 return Ok(0);
174 }
175
176 // Per-sync dedup of commits already visited.
177 let mut visited: HashSet<Oid> = HashSet::new();
178 // Cache of existing link SHAs per resolved issue ref. `None` = poisoned.
179 let mut link_cache: HashMap<String, Option<HashSet<String>>> = HashMap::new();
180 let mut emitted: usize = 0;
181
182 for oid_result in revwalk {
183 let oid = match oid_result {
184 Ok(o) => o,
185 Err(e) => {
186 eprintln!("warning: revwalk error, stopping scan: {}", e);
187 break;
188 }
189 };
190 if !visited.insert(oid) {
191 continue;
192 }
193 let commit = match repo.find_commit(oid) {
194 Ok(c) => c,
195 Err(e) => {
196 eprintln!("warning: cannot load commit {}: {}", oid, e);
197 continue;
198 }
199 };
200 let message = commit.message().unwrap_or("");
201 let trailers = parse_issue_trailers(message);
202 if trailers.is_empty() {
203 continue;
204 }
205
206 for prefix in trailers {
207 match crate::state::resolve_issue_ref(repo, &prefix) {
208 Ok((resolved_ref, _resolved_id)) => {
209 if resolved_ref.starts_with(ARCHIVED_ISSUE_PREFIX) {
210 eprintln!(
211 "warning: commit {}: Issue: {} — issue is archived, skipping",
212 oid, prefix
213 );
214 continue;
215 }
216 if !resolved_ref.starts_with(ACTIVE_ISSUE_PREFIX) {
217 // Unknown namespace. Should not happen with current
218 // resolver, but belt-and-braces.
219 eprintln!(
220 "warning: commit {}: Issue: {} — resolved to unexpected ref {}, skipping",
221 oid, prefix, resolved_ref
222 );
223 continue;
224 }
225
226 let entry = link_cache.entry(resolved_ref.clone()).or_insert_with(|| {
227 match collect_linked_shas(repo, &resolved_ref) {
228 Ok(set) => Some(set),
229 Err(e) => {
230 eprintln!(
231 "warning: cannot read link events for {}: {} — skipping issue for the rest of this sync",
232 resolved_ref, e
233 );
234 None
235 }
236 }
237 });
238 let Some(set) = entry.as_mut() else { continue };
239
240 let sha = oid.to_string();
241 if set.contains(&sha) {
242 continue;
243 }
244
245 let event = Event {
246 timestamp: chrono::Utc::now().to_rfc3339(),
247 author: author.clone(),
248 action: Action::IssueCommitLink {
249 commit: sha.clone(),
250 },
251 clock: 0,
252 };
253 match dag::append_event(repo, &resolved_ref, &event, sk) {
254 Ok(_) => {
255 set.insert(sha);
256 emitted += 1;
257 }
258 Err(e) => {
259 eprintln!(
260 "warning: commit {}: failed to emit IssueCommitLink on {}: {}",
261 oid, resolved_ref, e
262 );
263 }
264 }
265 }
266 Err(e) => {
267 // resolve_issue_ref error message already distinguishes
268 // "no issue found" from "ambiguous prefix".
269 eprintln!(
270 "warning: commit {}: Issue: {} — {}, skipping",
271 oid, prefix, e
272 );
273 }
274 }
275 }
276 }
277
278 Ok(emitted)
279 }
src/dag.rs
Old New
@@ -205,7 +205,13 @@ pub fn reconcile(
205 return Ok((local_oid, ReconcileOutcome::AlreadyCurrent)); 205 return Ok((local_oid, ReconcileOutcome::AlreadyCurrent));
206 } 206 }
207 207
208 let merge_base = repo.merge_base(local_oid, remote_oid)?; 208 let merge_base = repo.merge_base(local_oid, remote_oid).map_err(|e| {
209 Error::DisjointHistories {
210 local_ref: local_ref.to_string(),
211 remote_ref: remote_ref.to_string(),
212 detail: e.message().to_string(),
213 }
214 })?;
209 215
210 if merge_base == remote_oid { 216 if merge_base == remote_oid {
211 // Remote is ancestor of local — local is ahead 217 // Remote is ancestor of local — local is ahead
@@ -251,7 +257,11 @@ pub fn reconcile(
251 257
252 /// Migrate a DAG ref so that every event with clock=0 gets a sequential clock 258 /// Migrate a DAG ref so that every event with clock=0 gets a sequential clock
253 /// assigned in topological order. Events that already have clock>0 are left as-is. 259 /// assigned in topological order. Events that already have clock>0 are left as-is.
254 /// This rewrites the commit chain (new OIDs) and updates the ref. 260 ///
261 /// **WARNING**: This rewrites the commit chain, producing new OIDs for every
262 /// commit. If the ref has already been pushed to a remote, other users who
263 /// fetched the old OIDs will encounter disjoint histories on their next sync.
264 /// Only call this on refs that have not been shared.
255 pub fn migrate_clocks( 265 pub fn migrate_clocks(
256 repo: &Repository, 266 repo: &Repository,
257 ref_name: &str, 267 ref_name: &str,
@@ -314,6 +324,7 @@ fn commit_message(action: &Action) -> String {
314 Action::IssueComment { .. } => "issue: comment".to_string(), 324 Action::IssueComment { .. } => "issue: comment".to_string(),
315 Action::IssueClose { .. } => "issue: close".to_string(), 325 Action::IssueClose { .. } => "issue: close".to_string(),
316 Action::IssueReopen => "issue: reopen".to_string(), 326 Action::IssueReopen => "issue: reopen".to_string(),
327 Action::IssueCommitLink { commit } => format!("issue: commit link {}", &commit[..commit.len().min(7)]),
317 Action::PatchCreate { title, .. } => format!("patch: create \"{}\"", title), 328 Action::PatchCreate { title, .. } => format!("patch: create \"{}\"", title),
318 Action::PatchRevision { .. } => "patch: revision".to_string(), 329 Action::PatchRevision { .. } => "patch: revision".to_string(),
319 Action::PatchReview { verdict, .. } => format!("patch: review ({})", verdict), 330 Action::PatchReview { verdict, .. } => format!("patch: review ({})", verdict),
src/error.rs
Old New
@@ -40,4 +40,11 @@ pub enum Error {
40 40
41 #[error("ambiguous id prefix '{prefix}': {count} matches")] 41 #[error("ambiguous id prefix '{prefix}': {count} matches")]
42 AmbiguousId { prefix: String, count: usize }, 42 AmbiguousId { prefix: String, count: usize },
43
44 #[error("disjoint histories: local ref '{local_ref}' and remote ref '{remote_ref}' share no common ancestor ({detail})")]
45 DisjointHistories {
46 local_ref: String,
47 remote_ref: String,
48 detail: String,
49 },
43 } 50 }
src/event.rs
Old New
@@ -44,6 +44,10 @@ pub enum Action {
44 IssueUnassign { assignee: String }, 44 IssueUnassign { assignee: String },
45 #[serde(rename = "issue.reopen")] 45 #[serde(rename = "issue.reopen")]
46 IssueReopen, 46 IssueReopen,
47 #[serde(rename = "issue.commit_link")]
48 IssueCommitLink {
49 commit: String,
50 },
47 #[serde(rename = "patch.create", alias = "PatchCreate")] 51 #[serde(rename = "patch.create", alias = "PatchCreate")]
48 PatchCreate { 52 PatchCreate {
49 title: String, 53 title: String,
src/lib.rs
Old New
@@ -1,5 +1,6 @@
1 pub mod cache; 1 pub mod cache;
2 pub mod cli; 2 pub mod cli;
3 pub mod commit_link;
3 pub mod dag; 4 pub mod dag;
4 pub mod editor; 5 pub mod editor;
5 pub mod error; 6 pub mod error;
@@ -43,7 +44,37 @@ pub fn staleness_warning(repo: &Repository, patch: &state::PatchState) -> Option
43 )) 44 ))
44 } 45 }
45 46
47 fn maybe_auto_sync(repo: &Repository) {
48 let enabled = repo
49 .config()
50 .ok()
51 .and_then(|c| c.get_bool("collab.autoSync").ok())
52 .unwrap_or(true);
53
54 if !enabled {
55 return;
56 }
57
58 let remote = repo
59 .config()
60 .ok()
61 .and_then(|c| c.get_string("collab.autoSyncRemote").ok())
62 .unwrap_or_else(|| "origin".to_string());
63
64 eprintln!("Auto-syncing with '{}'...", remote);
65 match sync::sync(repo, &remote) {
66 Ok(()) => {}
67 Err(error::Error::PartialSync { succeeded, total }) => {
68 eprintln!("warning: auto-sync partially failed ({}/{} refs pushed)", succeeded, total);
69 }
70 Err(e) => {
71 eprintln!("warning: auto-sync failed: {}", e);
72 }
73 }
74 }
75
46 pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { 76 pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
77 let is_write = cli.command.is_write();
47 match cli.command { 78 match cli.command {
48 Commands::Init => sync::init(repo), 79 Commands::Init => sync::init(repo),
49 Commands::Issue(cmd) => match cmd { 80 Commands::Issue(cmd) => match cmd {
@@ -125,6 +156,51 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
125 println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body); 156 println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body);
126 } 157 }
127 } 158 }
159 if !i.linked_commits.is_empty() {
160 println!("\n--- Linked Commits ---");
161 for lc in &i.linked_commits {
162 let short_sha = if lc.commit.len() >= 7 { &lc.commit[..7] } else { &lc.commit };
163 let (subject, commit_author) = match git2::Oid::from_str(&lc.commit)
164 .ok()
165 .and_then(|oid| repo.find_commit(oid).ok())
166 {
167 Some(commit) => {
168 let subject = commit
169 .summary()
170 .map(|s| truncate_summary(s, 60))
171 .unwrap_or_default();
172 let author = commit
173 .author()
174 .name()
175 .unwrap_or("unknown")
176 .to_string();
177 (Some(subject), Some(author))
178 }
179 None => (None, None),
180 };
181 match (subject, commit_author) {
182 (Some(subject), Some(author)) => {
183 println!(
184 "· linked {} \"{}\" by {} (linked by {}, {})",
185 short_sha,
186 subject,
187 author,
188 lc.event_author.name,
189 lc.event_timestamp,
190 );
191 }
192 _ => {
193 println!(
194 "· linked {} (commit {} not in local repo) (linked by {}, {})",
195 short_sha,
196 short_sha,
197 lc.event_author.name,
198 lc.event_timestamp,
199 );
200 }
201 }
202 }
203 }
128 Ok(()) 204 Ok(())
129 } 205 }
130 IssueCmd::Label { id, label } => { 206 IssueCmd::Label { id, label } => {
@@ -225,14 +301,24 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
225 println!("{}", output); 301 println!("{}", output);
226 return Ok(()); 302 return Ok(());
227 } 303 }
228 let patches = patch::list(repo, all, archived, limit, offset, sort)?; 304 let entries = patch::list(repo, all, archived, limit, offset, sort)?;
229 if patches.is_empty() { 305 if entries.is_empty() {
230 println!("No patches found."); 306 println!("No patches found.");
231 } else { 307 } else {
232 for p in &patches { 308 for e in &entries {
309 let p = &e.patch;
310 let stale = match p.staleness(repo) {
311 Ok((_, behind)) if behind > 0 => format!(" [behind {}]", behind),
312 Ok(_) => String::new(),
313 Err(_) => String::new(),
314 };
315 let unread = match e.unread {
316 Some(n) if n > 0 => format!(" ({} new)", n),
317 _ => String::new(),
318 };
233 println!( 319 println!(
234 "{:.8} {:6} {} (by {})", 320 "{:.8} {:6} {} (by {}){}{}",
235 p.id, p.status, p.title, p.author.name 321 p.id, p.status, p.title, p.author.name, stale, unread
236 ); 322 );
237 } 323 }
238 } 324 }
@@ -246,7 +332,13 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
246 } 332 }
247 let p = patch::show(repo, &id)?; 333 let p = patch::show(repo, &id)?;
248 let rev_count = p.revisions.len(); 334 let rev_count = p.revisions.len();
249 println!("Patch {} [{}] (r{})", &p.id[..8], p.status, rev_count); 335 let status_detail = match p.staleness(repo) {
336 Ok((_, behind)) if behind > 0 => {
337 format!(" (branch is {} commits behind {})", behind, p.base_ref)
338 }
339 _ => String::new(),
340 };
341 println!("Patch {} [{}{}] (r{})", &p.id[..8], p.status, status_detail, rev_count);
250 println!("Title: {}", p.title); 342 println!("Title: {}", p.title);
251 println!("Author: {} <{}>", p.author.name, p.author.email); 343 println!("Author: {} <{}>", p.author.name, p.author.email);
252 match p.resolve_head(repo) { 344 match p.resolve_head(repo) {
@@ -421,6 +513,10 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
421 println!("Deleted patch {:.8}", full_id); 513 println!("Deleted patch {:.8}", full_id);
422 Ok(()) 514 Ok(())
423 } 515 }
516 PatchCmd::Checkout { id } => {
517 patch::checkout(repo, &id)?;
518 Ok(())
519 }
424 }, 520 },
425 Commands::Release(cmd) => match cmd { 521 Commands::Release(cmd) => match cmd {
426 ReleaseCmd::Publish { 522 ReleaseCmd::Publish {
@@ -595,5 +691,102 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
595 Ok(()) 691 Ok(())
596 } 692 }
597 }, 693 },
694 Commands::Search { query } => search(repo, &query),
695 }?;
696
697 if is_write {
698 maybe_auto_sync(repo);
699 }
700
701 Ok(())
702 }
703
704 fn search(repo: &Repository, query: &str) -> Result<(), error::Error> {
705 let q = query.to_lowercase();
706
707 let issues = state::list_issues_with_archived(repo)?;
708 let mut issue_results: Vec<(&str, &str, &str, String)> = Vec::new();
709
710 for issue in &issues {
711 let mut matches = Vec::new();
712 if issue.title.to_lowercase().contains(&q) {
713 matches.push("title");
714 }
715 if issue.body.to_lowercase().contains(&q) {
716 matches.push("body");
717 }
718 if issue.comments.iter().any(|c| c.body.to_lowercase().contains(&q)) {
719 matches.push("comment");
720 }
721 if !matches.is_empty() {
722 issue_results.push((
723 &issue.id,
724 issue.status.as_str(),
725 &issue.title,
726 matches.join(", "),
727 ));
728 }
729 }
730
731 let patches = state::list_patches_with_archived(repo)?;
732 let mut patch_results: Vec<(&str, &str, &str, String)> = Vec::new();
733
734 for patch in &patches {
735 let mut matches = Vec::new();
736 if patch.title.to_lowercase().contains(&q) {
737 matches.push("title");
738 }
739 if patch.body.to_lowercase().contains(&q) {
740 matches.push("body");
741 }
742 if patch.comments.iter().any(|c| c.body.to_lowercase().contains(&q)) {
743 matches.push("comment");
744 }
745 if patch.reviews.iter().any(|r| r.body.to_lowercase().contains(&q)) {
746 matches.push("review");
747 }
748 if patch.inline_comments.iter().any(|ic| ic.body.to_lowercase().contains(&q)) {
749 matches.push("inline comment");
750 }
751 if !matches.is_empty() {
752 patch_results.push((
753 &patch.id,
754 patch.status.as_str(),
755 &patch.title,
756 matches.join(", "),
757 ));
758 }
759 }
760
761 println!("Issues:");
762 if issue_results.is_empty() {
763 println!(" (none)");
764 } else {
765 for (id, status, title, match_field) in &issue_results {
766 println!(" {:.8} {:6} {} ({} match)", id, status, title, match_field);
767 }
768 }
769 println!();
770 println!("Patches:");
771 if patch_results.is_empty() {
772 println!(" (none)");
773 } else {
774 for (id, status, title, match_field) in &patch_results {
775 println!(" {:.8} {:6} {} ({} match)", id, status, title, match_field);
776 }
777 }
778
779 Ok(())
780 }
781
782 pub(crate) fn truncate_summary(s: &str, max_chars: usize) -> String {
783 let mut out = String::new();
784 for (count, c) in s.chars().enumerate() {
785 if count + 1 > max_chars {
786 out.push('…');
787 return out;
788 }
789 out.push(c);
598 } 790 }
791 out
599 } 792 }
src/log.rs
Old New
@@ -115,6 +115,7 @@ fn action_type_name(action: &Action) -> String {
115 Action::IssueAssign { .. } => "IssueAssign".to_string(), 115 Action::IssueAssign { .. } => "IssueAssign".to_string(),
116 Action::IssueUnassign { .. } => "IssueUnassign".to_string(), 116 Action::IssueUnassign { .. } => "IssueUnassign".to_string(),
117 Action::IssueReopen => "IssueReopen".to_string(), 117 Action::IssueReopen => "IssueReopen".to_string(),
118 Action::IssueCommitLink { .. } => "IssueCommitLink".to_string(),
118 Action::PatchCreate { .. } => "PatchCreate".to_string(), 119 Action::PatchCreate { .. } => "PatchCreate".to_string(),
119 Action::PatchRevision { .. } => "PatchRevision".to_string(), 120 Action::PatchRevision { .. } => "PatchRevision".to_string(),
120 Action::PatchReview { .. } => "PatchReview".to_string(), 121 Action::PatchReview { .. } => "PatchReview".to_string(),
@@ -149,6 +150,9 @@ fn action_summary(action: &Action) -> String {
149 Action::IssueAssign { assignee } => format!("assign \"{}\"", assignee), 150 Action::IssueAssign { assignee } => format!("assign \"{}\"", assignee),
150 Action::IssueUnassign { assignee } => format!("unassign \"{}\"", assignee), 151 Action::IssueUnassign { assignee } => format!("unassign \"{}\"", assignee),
151 Action::IssueReopen => "reopen".to_string(), 152 Action::IssueReopen => "reopen".to_string(),
153 Action::IssueCommitLink { commit } => {
154 format!("commit link {}", &commit[..commit.len().min(7)])
155 }
152 Action::PatchCreate { title, .. } => format!("create \"{}\"", title), 156 Action::PatchCreate { title, .. } => format!("create \"{}\"", title),
153 Action::PatchRevision { body, .. } => match body { 157 Action::PatchRevision { body, .. } => match body {
154 Some(b) => format!("revision: {}", truncate(b, 50)), 158 Some(b) => format!("revision: {}", truncate(b, 50)),
src/patch.rs
Old New
@@ -130,6 +130,31 @@ pub fn create(
130 Ok(id) 130 Ok(id)
131 } 131 }
132 132
133 pub struct ListEntry {
134 pub patch: PatchState,
135 pub unread: Option<usize>,
136 }
137
138 /// Count events after the last-seen mark. Returns None if never viewed.
139 fn count_unread(repo: &git2::Repository, id: &str) -> Option<usize> {
140 let seen_ref = format!("refs/collab/local/seen/patches/{}", id);
141 let seen_oid = repo.refname_to_id(&seen_ref).ok()?;
142 let ref_name = format!("refs/collab/patches/{}", id);
143 let tip = repo.refname_to_id(&ref_name).ok()?;
144
145 if seen_oid == tip {
146 return Some(0);
147 }
148
149 let mut revwalk = repo.revwalk().ok()?;
150 revwalk
151 .set_sorting(git2::Sort::TOPOLOGICAL)
152 .ok()?;
153 revwalk.push(tip).ok()?;
154 revwalk.hide(seen_oid).ok()?;
155 Some(revwalk.count())
156 }
157
133 pub fn list( 158 pub fn list(
134 repo: &Repository, 159 repo: &Repository,
135 show_closed: bool, 160 show_closed: bool,
@@ -137,19 +162,21 @@ pub fn list(
137 limit: Option<usize>, 162 limit: Option<usize>,
138 offset: Option<usize>, 163 offset: Option<usize>,
139 sort: SortMode, 164 sort: SortMode,
140 ) -> Result<Vec<PatchState>, crate::error::Error> { 165 ) -> Result<Vec<ListEntry>, crate::error::Error> {
141 let patches = if show_archived { 166 let patches = if show_archived {
142 state::list_patches_with_archived(repo)? 167 state::list_patches_with_archived(repo)?
143 } else { 168 } else {
144 state::list_patches(repo)? 169 state::list_patches(repo)?
145 }; 170 };
146 Ok(cli::filter_sort_paginate( 171 let filtered = cli::filter_sort_paginate(patches, show_closed, sort, offset, limit);
147 patches, 172 let entries = filtered
148 show_closed, 173 .into_iter()
149 sort, 174 .map(|patch| {
150 offset, 175 let unread = count_unread(repo, &patch.id);
151 limit, 176 ListEntry { patch, unread }
152 )) 177 })
178 .collect();
179 Ok(entries)
153 } 180 }
154 181
155 pub fn list_to_writer( 182 pub fn list_to_writer(
@@ -161,16 +188,26 @@ pub fn list_to_writer(
161 sort: SortMode, 188 sort: SortMode,
162 writer: &mut dyn std::io::Write, 189 writer: &mut dyn std::io::Write,
163 ) -> Result<(), crate::error::Error> { 190 ) -> Result<(), crate::error::Error> {
164 let patches = list(repo, show_closed, show_archived, limit, offset, sort)?; 191 let entries = list(repo, show_closed, show_archived, limit, offset, sort)?;
165 if patches.is_empty() { 192 if entries.is_empty() {
166 writeln!(writer, "No patches found.").ok(); 193 writeln!(writer, "No patches found.").ok();
167 return Ok(()); 194 return Ok(());
168 } 195 }
169 for p in &patches { 196 for e in &entries {
197 let p = &e.patch;
198 let stale = match p.staleness(repo) {
199 Ok((_, behind)) if behind > 0 => format!(" [behind {}]", behind),
200 Ok(_) => String::new(),
201 Err(_) => String::new(),
202 };
203 let unread = match e.unread {
204 Some(n) if n > 0 => format!(" ({} new)", n),
205 _ => String::new(),
206 };
170 writeln!( 207 writeln!(
171 writer, 208 writer,
172 "{:.8} {:6} {} (by {})", 209 "{:.8} {:6} {} (by {}){}{}",
173 p.id, p.status, p.title, p.author.name 210 p.id, p.status, p.title, p.author.name, stale, unread
174 ) 211 )
175 .ok(); 212 .ok();
176 } 213 }
@@ -183,7 +220,8 @@ pub fn list_json(
183 show_archived: bool, 220 show_archived: bool,
184 sort: SortMode, 221 sort: SortMode,
185 ) -> Result<String, crate::error::Error> { 222 ) -> Result<String, crate::error::Error> {
186 let patches = list(repo, show_closed, show_archived, None, None, sort)?; 223 let entries = list(repo, show_closed, show_archived, None, None, sort)?;
224 let patches: Vec<&PatchState> = entries.iter().map(|e| &e.patch).collect();
187 Ok(serde_json::to_string_pretty(&patches)?) 225 Ok(serde_json::to_string_pretty(&patches)?)
188 } 226 }
189 227
@@ -195,7 +233,12 @@ pub fn show_json(repo: &Repository, id_prefix: &str) -> Result<String, crate::er
195 233
196 pub fn show(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::error::Error> { 234 pub fn show(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::error::Error> {
197 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; 235 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
198 PatchState::from_ref(repo, &ref_name, &id) 236 let patch = PatchState::from_ref(repo, &ref_name, &id)?;
237 // Mark as read: store current tip as seen
238 let tip = repo.refname_to_id(&ref_name)?;
239 let seen_ref = format!("refs/collab/local/seen/patches/{}", id);
240 repo.reference(&seen_ref, tip, true, "mark seen")?;
241 Ok(patch)
199 } 242 }
200 243
201 pub fn comment( 244 pub fn comment(
@@ -580,6 +623,51 @@ pub fn patch_log_json(patch: &PatchState) -> Result<String, Error> {
580 Ok(serde_json::to_string_pretty(&patch.revisions)?) 623 Ok(serde_json::to_string_pretty(&patch.revisions)?)
581 } 624 }
582 625
626 pub fn checkout(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> {
627 let (_ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
628 let patch = show(repo, id_prefix)?;
629
630 let latest_rev = patch
631 .revisions
632 .last()
633 .ok_or_else(|| Error::Cmd("patch has no revisions".to_string()))?;
634 let commit_oid = Oid::from_str(&latest_rev.commit)
635 .map_err(|e| Error::Cmd(format!("invalid commit OID in revision: {}", e)))?;
636 let commit = repo
637 .find_commit(commit_oid)
638 .map_err(|e| Error::Cmd(format!("commit {} not found: {}", &latest_rev.commit, e)))?;
639
640 let short_id = &id[..std::cmp::min(8, id.len())];
641 let branch_name = {
642 let candidate = format!("collab/{}", short_id);
643 if repo.find_branch(&candidate, git2::BranchType::Local).is_err() {
644 candidate
645 } else {
646 let mut n = 1u32;
647 loop {
648 let suffixed = format!("collab/{}-{}", short_id, n);
649 if repo.find_branch(&suffixed, git2::BranchType::Local).is_err() {
650 break suffixed;
651 }
652 n += 1;
653 }
654 }
655 };
656
657 repo.branch(&branch_name, &commit, false)?;
658
659 let refname = format!("refs/heads/{}", branch_name);
660 let obj = repo.revparse_single(&refname)?;
661 repo.checkout_tree(&obj, None)?;
662 repo.set_head(&refname)?;
663
664 println!(
665 "Checked out patch {} (revision {}) on branch {}",
666 short_id, latest_rev.number, branch_name
667 );
668 Ok(())
669 }
670
583 pub fn delete(repo: &Repository, id_prefix: &str) -> Result<String, crate::error::Error> { 671 pub fn delete(repo: &Repository, id_prefix: &str) -> Result<String, crate::error::Error> {
584 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; 672 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
585 repo.find_reference(&ref_name)?.delete()?; 673 repo.find_reference(&ref_name)?.delete()?;
src/server/ssh/auth.rs
Old New
@@ -20,11 +20,7 @@ pub fn parse_authorized_keys(content: &str) -> Vec<AuthorizedKey> {
20 let key_type = parts.next()?.to_string(); 20 let key_type = parts.next()?.to_string();
21 let key_data = parts.next()?.to_string(); 21 let key_data = parts.next()?.to_string();
22 let comment = parts.next().map(|s| s.to_string()); 22 let comment = parts.next().map(|s| s.to_string());
23 Some(AuthorizedKey { 23 Some(AuthorizedKey { key_type, key_data, comment })
24 key_type,
25 key_data,
26 comment,
27 })
28 }) 24 })
29 .collect() 25 .collect()
30 } 26 }
@@ -34,9 +30,16 @@ pub fn load_authorized_keys(path: &Path) -> Result<Vec<AuthorizedKey>, std::io::
34 Ok(parse_authorized_keys(&content)) 30 Ok(parse_authorized_keys(&content))
35 } 31 }
36 32
33 fn is_rsa_key_type(key_type: &str) -> bool {
34 matches!(key_type, "ssh-rsa" | "rsa-sha2-256" | "rsa-sha2-512")
35 }
36
37 pub fn is_authorized(keys: &[AuthorizedKey], key_type: &str, key_data: &str) -> bool { 37 pub fn is_authorized(keys: &[AuthorizedKey], key_type: &str, key_data: &str) -> bool {
38 keys.iter() 38 keys.iter().any(|k| {
39 .any(|k| k.key_type == key_type && k.key_data == key_data) 39 let type_matches = k.key_type == key_type
40 || (is_rsa_key_type(&k.key_type) && is_rsa_key_type(key_type));
41 type_matches && k.key_data == key_data
42 })
40 } 43 }
41 44
42 #[cfg(test)] 45 #[cfg(test)]
@@ -85,4 +88,13 @@ mod tests {
85 assert!(!is_authorized(&keys, "ssh-ed25519", "AAAA9999")); 88 assert!(!is_authorized(&keys, "ssh-ed25519", "AAAA9999"));
86 assert!(!is_authorized(&keys, "ssh-rsa", "AAAA1111")); 89 assert!(!is_authorized(&keys, "ssh-rsa", "AAAA1111"));
87 } 90 }
91
92 #[test]
93 fn rsa_key_type_variants_match() {
94 let keys = parse_authorized_keys("ssh-rsa AAAARSA user\n");
95 assert!(is_authorized(&keys, "ssh-rsa", "AAAARSA"));
96 assert!(is_authorized(&keys, "rsa-sha2-256", "AAAARSA"));
97 assert!(is_authorized(&keys, "rsa-sha2-512", "AAAARSA"));
98 assert!(!is_authorized(&keys, "rsa-sha2-256", "WRONG"));
99 }
88 } 100 }
src/signing.rs
Old New
@@ -139,11 +139,11 @@ pub fn load_verifying_key(config_dir: &Path) -> Result<VerifyingKey, Error> {
139 .map_err(|e| Error::Verification(format!("invalid verifying key: {}", e))) 139 .map_err(|e| Error::Verification(format!("invalid verifying key: {}", e)))
140 } 140 }
141 141
142 /// Serialize an Event to canonical JSON bytes. 142 /// Serialize an Event to canonical JSON bytes with deterministic key ordering.
143 /// 143 ///
144 /// Uses `serde_json::Value` as an intermediate step. Since serde_json uses 144 /// Relies on `serde_json::Map` being backed by `BTreeMap` (sorted keys) when
145 /// BTreeMap-backed Map (no `preserve_order` feature), keys are sorted 145 /// the `preserve_order` feature is **not** enabled. A test below verifies this
146 /// alphabetically, ensuring deterministic output. 146 /// invariant so we catch breakage from transitive feature activation.
147 pub fn canonical_json(event: &Event) -> Result<Vec<u8>, Error> { 147 pub fn canonical_json(event: &Event) -> Result<Vec<u8>, Error> {
148 let value = serde_json::to_value(event)?; 148 let value = serde_json::to_value(event)?;
149 let json = serde_json::to_string(&value)?; 149 let json = serde_json::to_string(&value)?;
@@ -332,4 +332,33 @@ mod tests {
332 let status = verify_detached(&event, &sig).unwrap(); 332 let status = verify_detached(&event, &sig).unwrap();
333 assert_eq!(status, VerifyStatus::Valid); 333 assert_eq!(status, VerifyStatus::Valid);
334 } 334 }
335
336 #[test]
337 fn canonical_json_keys_are_sorted() {
338 // Guard against serde_json's `preserve_order` feature being activated
339 // by a transitive dependency, which would break signature determinism.
340 let event = Event {
341 timestamp: "2026-03-21T00:00:00Z".to_string(),
342 author: Author {
343 name: "Alice".to_string(),
344 email: "alice@example.com".to_string(),
345 },
346 action: Action::IssueOpen {
347 title: "Test".to_string(),
348 body: "Body".to_string(),
349 relates_to: None,
350 },
351 clock: 1,
352 };
353 let json = canonical_json(&event).unwrap();
354 let parsed: serde_json::Value = serde_json::from_slice(&json).unwrap();
355 if let serde_json::Value::Object(map) = parsed {
356 let keys: Vec<&String> = map.keys().collect();
357 let mut sorted = keys.clone();
358 sorted.sort();
359 assert_eq!(keys, sorted, "top-level JSON keys must be alphabetically sorted");
360 } else {
361 panic!("expected JSON object");
362 }
363 }
335 } 364 }
src/state.rs
Old New
@@ -1,5 +1,7 @@
1 use std::collections::HashMap;
1 use std::fmt; 2 use std::fmt;
2 3
4 use chrono::{DateTime, Utc};
3 use git2::{Oid, Repository}; 5 use git2::{Oid, Repository};
4 use serde::{Deserialize, Serialize}; 6 use serde::{Deserialize, Serialize};
5 7
@@ -7,6 +9,14 @@ use crate::cache;
7 use crate::dag; 9 use crate::dag;
8 use crate::event::{Action, Author, ReviewVerdict}; 10 use crate::event::{Action, Author, ReviewVerdict};
9 11
12 /// Parse an RFC3339 timestamp, returning the earliest representable time on
13 /// failure so that unparseable timestamps sort before any valid one.
14 fn parse_timestamp(s: &str) -> DateTime<Utc> {
15 DateTime::parse_from_rfc3339(s)
16 .map(|dt| dt.with_timezone(&Utc))
17 .unwrap_or(DateTime::<Utc>::MIN_UTC)
18 }
19
10 fn serialize_oid<S: serde::Serializer>(oid: &Oid, s: S) -> Result<S::Ok, S::Error> { 20 fn serialize_oid<S: serde::Serializer>(oid: &Oid, s: S) -> Result<S::Ok, S::Error> {
11 s.serialize_str(&oid.to_string()) 21 s.serialize_str(&oid.to_string())
12 } 22 }
@@ -75,6 +85,16 @@ pub struct Comment {
75 } 85 }
76 86
77 #[derive(Debug, Clone, Serialize, Deserialize)] 87 #[derive(Debug, Clone, Serialize, Deserialize)]
88 pub struct LinkedCommit {
89 /// Full 40-char commit SHA from the trailer.
90 pub commit: String,
91 /// Author of the `IssueCommitLink` event (who ran sync).
92 pub event_author: Author,
93 /// Timestamp of the `IssueCommitLink` event.
94 pub event_timestamp: String,
95 }
96
97 #[derive(Debug, Clone, Serialize, Deserialize)]
78 pub struct IssueState { 98 pub struct IssueState {
79 pub id: String, 99 pub id: String,
80 pub title: String, 100 pub title: String,
@@ -89,6 +109,8 @@ pub struct IssueState {
89 pub labels: Vec<String>, 109 pub labels: Vec<String>,
90 pub assignees: Vec<String>, 110 pub assignees: Vec<String>,
91 pub comments: Vec<Comment>, 111 pub comments: Vec<Comment>,
112 #[serde(default)]
113 pub linked_commits: Vec<LinkedCommit>,
92 pub created_at: String, 114 pub created_at: String,
93 #[serde(default)] 115 #[serde(default)]
94 pub last_updated: String, 116 pub last_updated: String,
@@ -237,15 +259,24 @@ impl IssueState {
237 ) -> Result<Self, crate::error::Error> { 259 ) -> Result<Self, crate::error::Error> {
238 let events = dag::walk_events(repo, ref_name)?; 260 let events = dag::walk_events(repo, ref_name)?;
239 let mut state: Option<IssueState> = None; 261 let mut state: Option<IssueState> = None;
240 let mut max_timestamp = String::new(); 262 let mut latest: Option<(DateTime<Utc>, String)> = None;
241 263
242 // Track the (clock, commit_oid_hex) of the latest status-changing event. 264 // Track the (clock, commit_oid_hex) of the latest status-changing event.
243 // Higher clock wins; on tie, lexicographically higher OID wins. 265 // Higher clock wins; on tie, lexicographically higher OID wins.
244 let mut status_key: Option<(u64, String)> = None; 266 let mut status_key: Option<(u64, String)> = None;
245 267
268 // Accumulator for IssueCommitLink dedup. Keyed by commit SHA, valued by
269 // the (clock, timestamp, oid_hex) sort key plus the LinkedCommit payload.
270 // We keep the entry with the minimum sort key per SHA — i.e. the
271 // earliest emission by (clock, timestamp, oid). Topological walk order
272 // alone does not guarantee this for cross-machine concurrent events
273 // that are reconciled via merge commits, so we sort explicitly.
274 let mut link_acc: HashMap<String, ((u64, String, String), LinkedCommit)> = HashMap::new();
275
246 for (oid, event) in events { 276 for (oid, event) in events {
247 if event.timestamp > max_timestamp { 277 let ts = parse_timestamp(&event.timestamp);
248 max_timestamp = event.timestamp.clone(); 278 if latest.as_ref().is_none_or(|(prev, _)| ts > *prev) {
279 latest = Some((ts, event.timestamp.clone()));
249 } 280 }
250 match event.action { 281 match event.action {
251 Action::IssueOpen { 282 Action::IssueOpen {
@@ -263,6 +294,7 @@ impl IssueState {
263 labels: Vec::new(), 294 labels: Vec::new(),
264 assignees: Vec::new(), 295 assignees: Vec::new(),
265 comments: Vec::new(), 296 comments: Vec::new(),
297 linked_commits: Vec::new(),
266 created_at: event.timestamp.clone(), 298 created_at: event.timestamp.clone(),
267 last_updated: String::new(), 299 last_updated: String::new(),
268 author: event.author.clone(), 300 author: event.author.clone(),
@@ -335,13 +367,44 @@ impl IssueState {
335 } 367 }
336 } 368 }
337 } 369 }
338 Action::Merge => {} 370 Action::IssueCommitLink { commit } => {
371 if state.is_some() {
372 // Render-time dedup by commit SHA. We compare an
373 // explicit (clock, timestamp, oid_hex) key per event
374 // and keep the minimum so the surviving entry is the
375 // earliest emission per the spec, regardless of how
376 // the merged DAG happens to topo-order itself.
377 let key = (event.clock, event.timestamp.clone(), oid.to_string());
378 let new_link = LinkedCommit {
379 commit: commit.clone(),
380 event_author: event.author.clone(),
381 event_timestamp: event.timestamp.clone(),
382 };
383 link_acc
384 .entry(commit)
385 .and_modify(|existing| {
386 if key < existing.0 {
387 *existing = (key.clone(), new_link.clone());
388 }
389 })
390 .or_insert((key, new_link));
391 }
392 }
339 _ => {} 393 _ => {}
340 } 394 }
341 } 395 }
342 396
343 if let Some(ref mut s) = state { 397 if let Some(ref mut s) = state {
344 s.last_updated = max_timestamp; 398 s.last_updated = latest.map(|(_, raw)| raw).unwrap_or_default();
399
400 // Flush the linked-commit accumulator into state.linked_commits in
401 // a stable order. Sort by the same (clock, timestamp, oid) key we
402 // used for the per-SHA min so the rendered list is deterministic
403 // across runs (HashMap iteration order is randomized).
404 let mut entries: Vec<((u64, String, String), LinkedCommit)> =
405 link_acc.into_values().collect();
406 entries.sort_by(|a, b| a.0.cmp(&b.0));
407 s.linked_commits = entries.into_iter().map(|(_, lc)| lc).collect();
345 } 408 }
346 state.ok_or_else(|| git2::Error::from_str("no IssueOpen event found in DAG").into()) 409 state.ok_or_else(|| git2::Error::from_str("no IssueOpen event found in DAG").into())
347 } 410 }
@@ -441,13 +504,14 @@ impl PatchState {
441 ) -> Result<Self, crate::error::Error> { 504 ) -> Result<Self, crate::error::Error> {
442 let events = dag::walk_events(repo, ref_name)?; 505 let events = dag::walk_events(repo, ref_name)?;
443 let mut state: Option<PatchState> = None; 506 let mut state: Option<PatchState> = None;
444 let mut max_timestamp = String::new(); 507 let mut latest: Option<(DateTime<Utc>, String)> = None;
445 508
446 let mut status_key: Option<(u64, String)> = None; 509 let mut status_key: Option<(u64, String)> = None;
447 510
448 for (oid, event) in events { 511 for (oid, event) in events {
449 if event.timestamp > max_timestamp { 512 let ts = parse_timestamp(&event.timestamp);
450 max_timestamp = event.timestamp.clone(); 513 if latest.as_ref().is_none_or(|(prev, _)| ts > *prev) {
514 latest = Some((ts, event.timestamp.clone()));
451 } 515 }
452 match event.action { 516 match event.action {
453 Action::PatchCreate { 517 Action::PatchCreate {
@@ -571,13 +635,12 @@ impl PatchState {
571 } 635 }
572 } 636 }
573 } 637 }
574 Action::Merge => {}
575 _ => {} 638 _ => {}
576 } 639 }
577 } 640 }
578 641
579 if let Some(ref mut s) = state { 642 if let Some(ref mut s) = state {
580 s.last_updated = max_timestamp; 643 s.last_updated = latest.map(|(_, raw)| raw).unwrap_or_default();
581 s.check_auto_merge(repo); 644 s.check_auto_merge(repo);
582 } 645 }
583 state.ok_or_else(|| git2::Error::from_str("no PatchCreate event found in DAG").into()) 646 state.ok_or_else(|| git2::Error::from_str("no PatchCreate event found in DAG").into())
@@ -668,7 +731,11 @@ pub fn list_issues(repo: &Repository) -> Result<Vec<IssueState>, crate::error::E
668 let items = collab_refs(repo, "issues")? 731 let items = collab_refs(repo, "issues")?
669 .into_iter() 732 .into_iter()
670 .filter(|(_, id)| !archived_ids.contains(id)) 733 .filter(|(_, id)| !archived_ids.contains(id))
671 .filter_map(|(ref_name, id)| IssueState::from_ref(repo, &ref_name, &id).ok()) 734 .filter_map(|(ref_name, id)| {
735 IssueState::from_ref(repo, &ref_name, &id)
736 .inspect_err(|e| eprintln!("warning: skipping issue {:.8}: {}", id, e))
737 .ok()
738 })
672 .collect(); 739 .collect();
673 Ok(items) 740 Ok(items)
674 } 741 }
@@ -682,7 +749,11 @@ pub fn list_patches(repo: &Repository) -> Result<Vec<PatchState>, crate::error::
682 let items = collab_refs(repo, "patches")? 749 let items = collab_refs(repo, "patches")?
683 .into_iter() 750 .into_iter()
684 .filter(|(_, id)| !archived_ids.contains(id)) 751 .filter(|(_, id)| !archived_ids.contains(id))
685 .filter_map(|(ref_name, id)| PatchState::from_ref(repo, &ref_name, &id).ok()) 752 .filter_map(|(ref_name, id)| {
753 PatchState::from_ref(repo, &ref_name, &id)
754 .inspect_err(|e| eprintln!("warning: skipping patch {:.8}: {}", id, e))
755 .ok()
756 })
686 .collect(); 757 .collect();
687 Ok(items) 758 Ok(items)
688 } 759 }
@@ -698,15 +769,17 @@ pub fn list_issues_with_archived(
698 // Archived first so they take priority 769 // Archived first so they take priority
699 for (ref_name, id) in collab_archive_refs(repo, "issues")? { 770 for (ref_name, id) in collab_archive_refs(repo, "issues")? {
700 if seen.insert(id.clone()) { 771 if seen.insert(id.clone()) {
701 if let Ok(state) = IssueState::from_ref(repo, &ref_name, &id) { 772 match IssueState::from_ref(repo, &ref_name, &id) {
702 items.push(state); 773 Ok(state) => items.push(state),
774 Err(e) => eprintln!("warning: skipping issue {:.8}: {}", id, e),
703 } 775 }
704 } 776 }
705 } 777 }
706 for (ref_name, id) in collab_refs(repo, "issues")? { 778 for (ref_name, id) in collab_refs(repo, "issues")? {
707 if seen.insert(id.clone()) { 779 if seen.insert(id.clone()) {
708 if let Ok(state) = IssueState::from_ref(repo, &ref_name, &id) { 780 match IssueState::from_ref(repo, &ref_name, &id) {
709 items.push(state); 781 Ok(state) => items.push(state),
782 Err(e) => eprintln!("warning: skipping issue {:.8}: {}", id, e),
710 } 783 }
711 } 784 }
712 } 785 }
@@ -723,15 +796,17 @@ pub fn list_patches_with_archived(
723 796
724 for (ref_name, id) in collab_archive_refs(repo, "patches")? { 797 for (ref_name, id) in collab_archive_refs(repo, "patches")? {
725 if seen.insert(id.clone()) { 798 if seen.insert(id.clone()) {
726 if let Ok(state) = PatchState::from_ref(repo, &ref_name, &id) { 799 match PatchState::from_ref(repo, &ref_name, &id) {
727 items.push(state); 800 Ok(state) => items.push(state),
801 Err(e) => eprintln!("warning: skipping patch {:.8}: {}", id, e),
728 } 802 }
729 } 803 }
730 } 804 }
731 for (ref_name, id) in collab_refs(repo, "patches")? { 805 for (ref_name, id) in collab_refs(repo, "patches")? {
732 if seen.insert(id.clone()) { 806 if seen.insert(id.clone()) {
733 if let Ok(state) = PatchState::from_ref(repo, &ref_name, &id) { 807 match PatchState::from_ref(repo, &ref_name, &id) {
734 items.push(state); 808 Ok(state) => items.push(state),
809 Err(e) => eprintln!("warning: skipping patch {:.8}: {}", id, e),
735 } 810 }
736 } 811 }
737 } 812 }
src/sync.rs
Old New
@@ -150,9 +150,50 @@ impl SyncState {
150 // Push helpers (T003, T004) 150 // Push helpers (T003, T004)
151 // --------------------------------------------------------------------------- 151 // ---------------------------------------------------------------------------
152 152
153 /// Push a single ref to the remote. Returns a RefPushResult. 153 /// Push multiple refs to the remote in a single `git push` invocation.
154 fn push_ref(workdir: &Path, remote_name: &str, ref_name: &str) -> RefPushResult { 154 ///
155 let refspec = format!("+{}:{}", ref_name, ref_name); 155 /// Uses non-force refspecs so the remote will reject the push if it has
156 /// commits we haven't reconciled. This prevents silently discarding events
157 /// that another pusher added between our fetch and push.
158 ///
159 /// On success, all refs are marked as Pushed. On failure, falls back to
160 /// per-ref pushes to determine which specific refs failed.
161 fn push_refs_batched(workdir: &Path, remote_name: &str, refs: &[String]) -> Vec<RefPushResult> {
162 if refs.is_empty() {
163 return Vec::new();
164 }
165
166 let refspecs: Vec<String> = refs.iter().map(|r| format!("{}:{}", r, r)).collect();
167 let mut args = vec!["push", remote_name];
168 args.extend(refspecs.iter().map(|s| s.as_str()));
169
170 match Command::new("git")
171 .args(&args)
172 .current_dir(workdir)
173 .output()
174 {
175 Ok(output) if output.status.success() => {
176 // All refs pushed successfully
177 refs.iter()
178 .map(|r| RefPushResult {
179 ref_name: r.clone(),
180 status: PushStatus::Pushed,
181 error: None,
182 })
183 .collect()
184 }
185 Ok(_) | Err(_) => {
186 // Batch failed — retry each ref individually to isolate failures
187 refs.iter()
188 .map(|ref_name| push_ref_single(workdir, remote_name, ref_name))
189 .collect()
190 }
191 }
192 }
193
194 /// Push a single ref to the remote. Used as a fallback when batched push fails.
195 fn push_ref_single(workdir: &Path, remote_name: &str, ref_name: &str) -> RefPushResult {
196 let refspec = format!("{}:{}", ref_name, ref_name);
156 match Command::new("git") 197 match Command::new("git")
157 .args(["push", remote_name, &refspec]) 198 .args(["push", remote_name, &refspec])
158 .current_dir(workdir) 199 .current_dir(workdir)
@@ -317,12 +358,23 @@ pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> {
317 } 358 }
318 359
319 // Step 2: Reconcile 360 // Step 2: Reconcile
320 // Re-open repo to see the fetched refs (git2 caches ref state) 361 // Re-open the repo because git2 caches the ref list at open time.
362 // The `git fetch` above wrote new refs to disk, but the original
363 // `repo` handle won't see them. `repo.path()` returns the `.git`
364 // directory, which is the correct argument to `Repository::open`.
321 let repo = Repository::open(repo.path())?; 365 let repo = Repository::open(repo.path())?;
322 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; 366 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
323 reconcile_refs(&repo, "issues", &author, &sk)?; 367 reconcile_refs(&repo, "issues", &author, &sk)?;
324 reconcile_refs(&repo, "patches", &author, &sk)?; 368 reconcile_refs(&repo, "patches", &author, &sk)?;
325 369
370 // Step 2.5: Scan local branches for Issue: trailers and emit link events.
371 // Never breaks sync — scan_and_link absorbs per-commit/per-issue errors.
372 match crate::commit_link::scan_and_link(&repo, &author, &sk) {
373 Ok(n) if n > 0 => println!("Linked {} commit(s) to issues.", n),
374 Ok(_) => {}
375 Err(e) => eprintln!("warning: commit link scan failed: {}", e),
376 }
377
326 // Step 3: Push collab refs individually 378 // Step 3: Push collab refs individually
327 println!("Pushing to '{}'...", remote_name); 379 println!("Pushing to '{}'...", remote_name);
328 let refs_to_push = collect_push_refs(&repo)?; 380 let refs_to_push = collect_push_refs(&repo)?;
@@ -330,7 +382,7 @@ pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> {
330 if refs_to_push.is_empty() { 382 if refs_to_push.is_empty() {
331 println!("Nothing to push."); 383 println!("Nothing to push.");
332 } else { 384 } else {
333 let sync_result = push_refs_individually(&workdir, remote_name, &refs_to_push); 385 let sync_result = push_refs(&workdir, remote_name, &refs_to_push);
334 386
335 if !sync_result.is_complete() { 387 if !sync_result.is_complete() {
336 // Save state for resume 388 // Save state for resume
@@ -395,7 +447,7 @@ fn sync_resume(
395 447
396 // Push the pending refs 448 // Push the pending refs
397 let ref_names: Vec<String> = state.pending_refs.iter().map(|(r, _)| r.clone()).collect(); 449 let ref_names: Vec<String> = state.pending_refs.iter().map(|(r, _)| r.clone()).collect();
398 let sync_result = push_refs_individually(workdir, remote_name, &ref_names); 450 let sync_result = push_refs(workdir, remote_name, &ref_names);
399 451
400 if sync_result.is_complete() { 452 if sync_result.is_complete() {
401 // All pending refs pushed successfully 453 // All pending refs pushed successfully
@@ -438,22 +490,20 @@ fn sync_resume(
438 } 490 }
439 } 491 }
440 492
441 /// Push refs one at a time, printing per-ref status, and return aggregated results. 493 /// Push all refs in a single batch, printing per-ref status, and return aggregated results.
442 fn push_refs_individually(workdir: &Path, remote_name: &str, refs: &[String]) -> SyncResult { 494 fn push_refs(workdir: &Path, remote_name: &str, refs: &[String]) -> SyncResult {
443 let mut results = Vec::new(); 495 let results = push_refs_batched(workdir, remote_name, refs);
444 for ref_name in refs { 496 for result in &results {
445 let result = push_ref(workdir, remote_name, ref_name);
446 match &result.status { 497 match &result.status {
447 PushStatus::Pushed => println!(" Pushed {}", ref_name), 498 PushStatus::Pushed => println!(" Pushed {}", result.ref_name),
448 PushStatus::Failed => { 499 PushStatus::Failed => {
449 eprintln!( 500 eprintln!(
450 " FAILED {}: {}", 501 " FAILED {}: {}",
451 ref_name, 502 result.ref_name,
452 result.error.as_deref().unwrap_or("unknown error") 503 result.error.as_deref().unwrap_or("unknown error")
453 ); 504 );
454 } 505 }
455 } 506 }
456 results.push(result);
457 } 507 }
458 SyncResult { 508 SyncResult {
459 results, 509 results,
src/tui/events.rs
Old New
@@ -21,7 +21,7 @@ pub(crate) fn run_loop(
21 repo: &Repository, 21 repo: &Repository,
22 ) -> Result<(), Error> { 22 ) -> Result<(), Error> {
23 loop { 23 loop {
24 terminal.draw(|frame| ui(frame, app))?; 24 terminal.draw(|frame| ui(frame, app, Some(repo)))?;
25 25
26 if event::poll(Duration::from_millis(100))? { 26 if event::poll(Duration::from_millis(100))? {
27 if let Event::Key(key) = event::read()? { 27 if let Event::Key(key) = event::read()? {
src/tui/mod.rs
Old New
@@ -63,6 +63,7 @@ mod tests {
63 labels: vec![], 63 labels: vec![],
64 assignees: vec![], 64 assignees: vec![],
65 comments: vec![], 65 comments: vec![],
66 linked_commits: vec![],
66 created_at: String::new(), 67 created_at: String::new(),
67 last_updated: String::new(), 68 last_updated: String::new(),
68 author: make_author(), 69 author: make_author(),
@@ -253,6 +254,7 @@ mod tests {
253 labels: vec![], 254 labels: vec![],
254 assignees: vec![], 255 assignees: vec![],
255 comments: Vec::new(), 256 comments: Vec::new(),
257 linked_commits: Vec::new(),
256 created_at: "2026-01-01T00:00:00Z".to_string(), 258 created_at: "2026-01-01T00:00:00Z".to_string(),
257 last_updated: "2026-01-01T00:00:00Z".to_string(), 259 last_updated: "2026-01-01T00:00:00Z".to_string(),
258 author: test_author(), 260 author: test_author(),
@@ -294,7 +296,7 @@ mod tests {
294 fn render_app(app: &mut App) -> Buffer { 296 fn render_app(app: &mut App) -> Buffer {
295 let backend = TestBackend::new(80, 24); 297 let backend = TestBackend::new(80, 24);
296 let mut terminal = Terminal::new(backend).unwrap(); 298 let mut terminal = Terminal::new(backend).unwrap();
297 terminal.draw(|frame| ui(frame, app)).unwrap(); 299 terminal.draw(|frame| ui(frame, app, None)).unwrap();
298 terminal.backend().buffer().clone() 300 terminal.backend().buffer().clone()
299 } 301 }
300 302
@@ -955,7 +957,7 @@ mod tests {
955 let mut app = make_app(3, 3); 957 let mut app = make_app(3, 3);
956 let backend = TestBackend::new(20, 10); 958 let backend = TestBackend::new(20, 10);
957 let mut terminal = Terminal::new(backend).unwrap(); 959 let mut terminal = Terminal::new(backend).unwrap();
958 terminal.draw(|frame| ui(frame, &mut app)).unwrap(); 960 terminal.draw(|frame| ui(frame, &mut app, None)).unwrap();
959 } 961 }
960 962
961 // ── Integration: full browse flow ──────────────────────────────────── 963 // ── Integration: full browse flow ────────────────────────────────────
src/tui/widgets.rs
Old New
@@ -1,4 +1,4 @@
1 use git2::Oid; 1 use git2::{Oid, Repository};
2 use ratatui::prelude::*; 2 use ratatui::prelude::*;
3 use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; 3 use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
4 4
@@ -26,6 +26,7 @@ pub(crate) fn action_type_label(action: &Action) -> &str {
26 Action::IssueUnlabel { .. } => "Issue Unlabel", 26 Action::IssueUnlabel { .. } => "Issue Unlabel",
27 Action::IssueAssign { .. } => "Issue Assign", 27 Action::IssueAssign { .. } => "Issue Assign",
28 Action::IssueUnassign { .. } => "Issue Unassign", 28 Action::IssueUnassign { .. } => "Issue Unassign",
29 Action::IssueCommitLink { .. } => "Issue Commit Link",
29 } 30 }
30 } 31 }
31 32
@@ -113,13 +114,16 @@ pub(crate) fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> Str
113 Action::IssueUnassign { assignee } => { 114 Action::IssueUnassign { assignee } => {
114 detail.push_str(&format!("\nRemoved Assignee: {}\n", assignee)); 115 detail.push_str(&format!("\nRemoved Assignee: {}\n", assignee));
115 } 116 }
117 Action::IssueCommitLink { commit } => {
118 detail.push_str(&format!("\nCommit: {}\n", commit));
119 }
116 Action::IssueReopen | Action::PatchMerge | Action::Merge => {} 120 Action::IssueReopen | Action::PatchMerge | Action::Merge => {}
117 } 121 }
118 122
119 detail 123 detail
120 } 124 }
121 125
122 pub(crate) fn ui(frame: &mut Frame, app: &mut App) { 126 pub(crate) fn ui(frame: &mut Frame, app: &mut App, repo: Option<&Repository>) {
123 let chunks = Layout::default() 127 let chunks = Layout::default()
124 .direction(Direction::Vertical) 128 .direction(Direction::Vertical)
125 .constraints([Constraint::Min(1), Constraint::Length(1)]) 129 .constraints([Constraint::Min(1), Constraint::Length(1)])
@@ -134,7 +138,7 @@ pub(crate) fn ui(frame: &mut Frame, app: &mut App) {
134 .split(main_area); 138 .split(main_area);
135 139
136 render_list(frame, app, panes[0]); 140 render_list(frame, app, panes[0]);
137 render_detail(frame, app, panes[1]); 141 render_detail(frame, app, panes[1], repo);
138 render_footer(frame, app, footer_area); 142 render_footer(frame, app, footer_area);
139 } 143 }
140 144
@@ -214,7 +218,7 @@ fn render_list(frame: &mut Frame, app: &mut App, area: Rect) {
214 } 218 }
215 } 219 }
216 220
217 fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) { 221 fn render_detail(frame: &mut Frame, app: &mut App, area: Rect, repo: Option<&Repository>) {
218 let border_style = if app.pane == Pane::Detail { 222 let border_style = if app.pane == Pane::Detail {
219 Style::default().fg(Color::Yellow) 223 Style::default().fg(Color::Yellow)
220 } else { 224 } else {
@@ -298,7 +302,7 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) {
298 let visible = app.visible_issues(); 302 let visible = app.visible_issues();
299 let selected_idx = app.list_state.selected().unwrap_or(0); 303 let selected_idx = app.list_state.selected().unwrap_or(0);
300 let content: Text = match visible.get(selected_idx) { 304 let content: Text = match visible.get(selected_idx) {
301 Some(issue) => build_issue_detail(issue, &app.patches), 305 Some(issue) => build_issue_detail(issue, &app.patches, repo),
302 None => Text::raw("No matches for current filter."), 306 None => Text::raw("No matches for current filter."),
303 }; 307 };
304 308
@@ -337,7 +341,11 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) {
337 } 341 }
338 } 342 }
339 343
340 fn build_issue_detail(issue: &IssueState, patches: &[PatchState]) -> Text<'static> { 344 fn build_issue_detail(
345 issue: &IssueState,
346 patches: &[PatchState],
347 repo: Option<&Repository>,
348 ) -> Text<'static> {
341 let status = issue.status.as_str(); 349 let status = issue.status.as_str();
342 350
343 let mut lines: Vec<Line> = vec![ 351 let mut lines: Vec<Line> = vec![
@@ -454,6 +462,46 @@ fn build_issue_detail(issue: &IssueState, patches: &[PatchState]) -> Text<'stati
454 } 462 }
455 } 463 }
456 464
465 if !issue.linked_commits.is_empty() {
466 lines.push(Line::raw(""));
467 lines.push(Line::styled(
468 "--- Linked Commits ---",
469 Style::default()
470 .fg(Color::Magenta)
471 .add_modifier(Modifier::BOLD),
472 ));
473 for lc in &issue.linked_commits {
474 let short_sha: String = lc.commit.chars().take(7).collect();
475 let (subject, commit_author) = repo
476 .and_then(|r| {
477 Oid::from_str(&lc.commit)
478 .ok()
479 .and_then(|oid| r.find_commit(oid).ok())
480 .map(|commit| {
481 let subject = commit
482 .summary()
483 .map(|s| crate::truncate_summary(s, 60))
484 .unwrap_or_default();
485 let author = commit.author().name().unwrap_or("unknown").to_string();
486 (subject, author)
487 })
488 })
489 .unwrap_or_else(|| (String::new(), String::new()));
490 let line_text = if commit_author.is_empty() {
491 format!(
492 "· linked {} (commit {} not in local repo) (linked by {}, {})",
493 short_sha, short_sha, lc.event_author.name, lc.event_timestamp
494 )
495 } else {
496 format!(
497 "· linked {} \"{}\" by {} (linked by {}, {})",
498 short_sha, subject, commit_author, lc.event_author.name, lc.event_timestamp
499 )
500 };
501 lines.push(Line::raw(line_text));
502 }
503 }
504
457 Text::from(lines) 505 Text::from(lines)
458 } 506 }
459 507
tests/collab_test.rs
Old New
@@ -10,7 +10,7 @@ use git_collab::state::{self, IssueState, IssueStatus, PatchState, PatchStatus};
10 10
11 use common::{ 11 use common::{
12 add_comment, add_review, add_review_on, alice, bob, close_issue, create_patch, init_repo, now, 12 add_comment, add_review, add_review_on, alice, bob, close_issue, create_patch, init_repo, now,
13 open_issue, reopen_issue, setup_signing_key, test_signing_key, 13 open_issue, reopen_issue, setup_signing_key, test_signing_key, ScopedTestConfig,
14 }; 14 };
15 15
16 // --------------------------------------------------------------------------- 16 // ---------------------------------------------------------------------------
@@ -804,6 +804,8 @@ use git_collab::patch;
804 #[test] 804 #[test]
805 fn test_create_patch_from_branch_populates_branch_field() { 805 fn test_create_patch_from_branch_populates_branch_field() {
806 // T009: creating a patch from current branch populates `branch` field 806 // T009: creating a patch from current branch populates `branch` field
807 let cfg = ScopedTestConfig::new();
808 cfg.ensure_signing_key();
807 let tmp = TempDir::new().unwrap(); 809 let tmp = TempDir::new().unwrap();
808 let repo = init_repo(tmp.path(), &alice()); 810 let repo = init_repo(tmp.path(), &alice());
809 make_initial_commit(&repo, "main"); 811 make_initial_commit(&repo, "main");
@@ -825,6 +827,8 @@ fn test_create_patch_from_branch_populates_branch_field() {
825 #[test] 827 #[test]
826 fn test_create_duplicate_patch_for_same_branch_returns_error() { 828 fn test_create_duplicate_patch_for_same_branch_returns_error() {
827 // T011: creating a duplicate patch for same branch returns error 829 // T011: creating a duplicate patch for same branch returns error
830 let cfg = ScopedTestConfig::new();
831 cfg.ensure_signing_key();
828 let tmp = TempDir::new().unwrap(); 832 let tmp = TempDir::new().unwrap();
829 let repo = init_repo(tmp.path(), &alice()); 833 let repo = init_repo(tmp.path(), &alice());
830 make_initial_commit(&repo, "main"); 834 make_initial_commit(&repo, "main");
@@ -1004,6 +1008,8 @@ fn test_auto_detect_merged_patch_via_git_merge() {
1004 // When a user merges the patch branch into the base branch manually 1008 // When a user merges the patch branch into the base branch manually
1005 // (using git merge), PatchState should auto-detect that the patch 1009 // (using git merge), PatchState should auto-detect that the patch
1006 // is merged without needing `patch merge`. 1010 // is merged without needing `patch merge`.
1011 let cfg = ScopedTestConfig::new();
1012 cfg.ensure_signing_key();
1007 let tmp = TempDir::new().unwrap(); 1013 let tmp = TempDir::new().unwrap();
1008 let repo = init_repo(tmp.path(), &alice()); 1014 let repo = init_repo(tmp.path(), &alice());
1009 make_initial_commit(&repo, "main"); 1015 make_initial_commit(&repo, "main");
@@ -1039,6 +1045,8 @@ fn test_auto_detect_merged_patch_via_git_merge() {
1039 fn test_auto_detect_merged_patch_deleted_branch() { 1045 fn test_auto_detect_merged_patch_deleted_branch() {
1040 // If the patch branch was deleted after a manual merge, 1046 // If the patch branch was deleted after a manual merge,
1041 // auto-detection should not crash — patch stays Open. 1047 // auto-detection should not crash — patch stays Open.
1048 let cfg = ScopedTestConfig::new();
1049 cfg.ensure_signing_key();
1042 let tmp = TempDir::new().unwrap(); 1050 let tmp = TempDir::new().unwrap();
1043 let repo = init_repo(tmp.path(), &alice()); 1051 let repo = init_repo(tmp.path(), &alice());
1044 make_initial_commit(&repo, "main"); 1052 make_initial_commit(&repo, "main");
@@ -1067,6 +1075,8 @@ fn test_cache_does_not_defeat_auto_detect_merge() {
1067 // Regression: from_ref() returned cached Open status even after the 1075 // Regression: from_ref() returned cached Open status even after the
1068 // patch branch was merged into main, because the cache hit bypassed 1076 // patch branch was merged into main, because the cache hit bypassed
1069 // the auto-detect merge logic that only ran in from_ref_uncached(). 1077 // the auto-detect merge logic that only ran in from_ref_uncached().
1078 let cfg = ScopedTestConfig::new();
1079 cfg.ensure_signing_key();
1070 let tmp = TempDir::new().unwrap(); 1080 let tmp = TempDir::new().unwrap();
1071 let repo = init_repo(tmp.path(), &alice()); 1081 let repo = init_repo(tmp.path(), &alice());
1072 make_initial_commit(&repo, "main"); 1082 make_initial_commit(&repo, "main");
tests/commit_link_test.rs
Old New
@@ -0,0 +1,312 @@
1 //! Unit tests for the Action::IssueCommitLink event variant.
2
3 use git2::Repository;
4 use git_collab::event::{Action, Author, Event};
5 use git_collab::identity::author_signature;
6 use git_collab::signing::sign_event;
7 use git_collab::state::IssueState;
8 use tempfile::TempDir;
9
10 mod common;
11 use common::{
12 add_commit_link, alice, init_repo, open_issue, test_signing_key, ScopedTestConfig,
13 };
14
15 /// Append a commit-link event with an explicitly chosen `clock` value,
16 /// bypassing `dag::append_event`'s automatic clock-bump. The new commit's
17 /// parent is the current ref tip. Returns the new tip OID.
18 fn append_commit_link_with_clock(
19 repo: &Repository,
20 ref_name: &str,
21 author: &Author,
22 commit_sha: &str,
23 timestamp: &str,
24 clock: u64,
25 ) -> git2::Oid {
26 let sk = test_signing_key();
27 let event = Event {
28 timestamp: timestamp.to_string(),
29 author: author.clone(),
30 action: Action::IssueCommitLink {
31 commit: commit_sha.to_string(),
32 },
33 clock,
34 };
35
36 let detached = sign_event(&event, &sk).unwrap();
37 let event_json = serde_json::to_vec_pretty(&event).unwrap();
38 let manifest = br#"{"version":1,"format":"git-collab"}"#;
39
40 let event_blob = repo.blob(&event_json).unwrap();
41 let sig_blob = repo.blob(detached.signature.as_bytes()).unwrap();
42 let pubkey_blob = repo.blob(detached.pubkey.as_bytes()).unwrap();
43 let manifest_blob = repo.blob(manifest).unwrap();
44
45 let mut tb = repo.treebuilder(None).unwrap();
46 tb.insert("event.json", event_blob, 0o100644).unwrap();
47 tb.insert("signature", sig_blob, 0o100644).unwrap();
48 tb.insert("pubkey", pubkey_blob, 0o100644).unwrap();
49 tb.insert("manifest.json", manifest_blob, 0o100644).unwrap();
50 let tree_oid = tb.write().unwrap();
51 let tree = repo.find_tree(tree_oid).unwrap();
52
53 let sig = author_signature(author).unwrap();
54 let parent_oid = repo.refname_to_id(ref_name).unwrap();
55 let parent = repo.find_commit(parent_oid).unwrap();
56 repo.commit(Some(ref_name), &sig, &sig, "issue.commit_link", &tree, &[&parent])
57 .unwrap()
58 }
59
60 fn test_author() -> Author {
61 Author {
62 name: "Alice".to_string(),
63 email: "alice@example.com".to_string(),
64 }
65 }
66
67 fn sha(byte: u8) -> String {
68 format!("{:02x}{}", byte, "00".repeat(19))
69 }
70
71 #[test]
72 fn issue_commit_link_variant_round_trips() {
73 let event = Event {
74 timestamp: "2026-04-12T12:00:00Z".to_string(),
75 author: test_author(),
76 action: Action::IssueCommitLink {
77 commit: "4b2e1cd0123456789012345678901234567890ab".to_string(),
78 },
79 clock: 3,
80 };
81
82 let json = serde_json::to_string(&event).expect("serialize");
83 assert!(
84 json.contains("\"type\":\"issue.commit_link\""),
85 "expected serde tag issue.commit_link, got: {}",
86 json
87 );
88 assert!(
89 json.contains("\"commit\":\"4b2e1cd0123456789012345678901234567890ab\""),
90 "expected commit field, got: {}",
91 json
92 );
93
94 let parsed: Event = serde_json::from_str(&json).expect("deserialize");
95 match parsed.action {
96 Action::IssueCommitLink { commit } => {
97 assert_eq!(commit, "4b2e1cd0123456789012345678901234567890ab");
98 }
99 other => panic!("expected IssueCommitLink, got {:?}", other),
100 }
101 }
102
103 #[test]
104 fn issue_state_surfaces_commit_links_in_order() {
105 let _config = ScopedTestConfig::new();
106 let dir = TempDir::new().unwrap();
107 let repo = init_repo(dir.path(), &alice());
108 let (ref_name, id) = open_issue(&repo, &alice(), "bug");
109
110 add_commit_link(&repo, &ref_name, &alice(), &sha(0xaa));
111 add_commit_link(&repo, &ref_name, &alice(), &sha(0xbb));
112
113 let issue = IssueState::from_ref_uncached(&repo, &ref_name, &id).unwrap();
114 let commits: Vec<String> = issue
115 .linked_commits
116 .iter()
117 .map(|lc| lc.commit.clone())
118 .collect();
119 assert_eq!(commits, vec![sha(0xaa), sha(0xbb)]);
120 }
121
122 #[test]
123 fn issue_state_dedups_commit_links_keeps_lower_clock_even_when_appended_later() {
124 // This locks in the (clock, timestamp, oid) tiebreak rule. We append two
125 // events for the same commit SHA in linear order, but the SECOND event we
126 // append has a LOWER clock than the first — simulating a cross-machine
127 // case where Bob's locally-appended event was actually authored earlier
128 // (in clock terms) than Alice's. A naive first-seen-wins implementation
129 // would surface Alice's event because it appears first in the topo walk;
130 // the spec-correct dedup must surface Bob's lower-clock event instead.
131 let _config = ScopedTestConfig::new();
132 let dir = TempDir::new().unwrap();
133 let repo = init_repo(dir.path(), &alice());
134 let (ref_name, id) = open_issue(&repo, &alice(), "bug");
135
136 let target_sha = sha(0xdd);
137
138 // Alice appends with clock 50 first.
139 append_commit_link_with_clock(
140 &repo,
141 &ref_name,
142 &alice(),
143 &target_sha,
144 "2026-04-12T12:00:00Z",
145 50,
146 );
147 // Bob appends second, but with a LOWER clock (10) — as if his event was
148 // authored earlier on his machine and we're now seeing the merged DAG.
149 append_commit_link_with_clock(
150 &repo,
151 &ref_name,
152 &common::bob(),
153 &target_sha,
154 "2026-04-12T11:00:00Z",
155 10,
156 );
157
158 let issue = IssueState::from_ref_uncached(&repo, &ref_name, &id).unwrap();
159 assert_eq!(issue.linked_commits.len(), 1);
160 assert_eq!(issue.linked_commits[0].commit, target_sha);
161 // The lower-clock event wins, even though it was appended later.
162 assert_eq!(
163 issue.linked_commits[0].event_author.name,
164 "Bob",
165 "expected Bob's lower-clock event to win the tiebreak"
166 );
167 }
168
169 #[test]
170 fn issue_state_dedups_commit_links_by_sha_keeping_earliest() {
171 let _config = ScopedTestConfig::new();
172 let dir = TempDir::new().unwrap();
173 let repo = init_repo(dir.path(), &alice());
174 let (ref_name, id) = open_issue(&repo, &alice(), "bug");
175
176 // Two different emitters link the same commit. First-seen should win.
177 add_commit_link(&repo, &ref_name, &alice(), &sha(0xcc));
178 let bob_link = common::bob();
179 add_commit_link(&repo, &ref_name, &bob_link, &sha(0xcc));
180
181 let issue = IssueState::from_ref_uncached(&repo, &ref_name, &id).unwrap();
182 assert_eq!(issue.linked_commits.len(), 1);
183 assert_eq!(issue.linked_commits[0].commit, sha(0xcc));
184 assert_eq!(issue.linked_commits[0].event_author.name, "Alice");
185 }
186
187 use git_collab::commit_link::parse_issue_trailers;
188
189 #[test]
190 fn parser_no_trailer_block() {
191 assert_eq!(parse_issue_trailers("Just a plain commit"), Vec::<String>::new());
192 }
193
194 #[test]
195 fn parser_empty_message() {
196 assert_eq!(parse_issue_trailers(""), Vec::<String>::new());
197 }
198
199 #[test]
200 fn parser_single_trailer_in_pure_block() {
201 let msg = "Fix thing\n\nSome context in the body.\n\nIssue: abc";
202 assert_eq!(parse_issue_trailers(msg), vec!["abc".to_string()]);
203 }
204
205 #[test]
206 fn parser_case_variants() {
207 let msg1 = "subject\n\nissue: abc";
208 let msg2 = "subject\n\nISSUE : abc";
209 let msg3 = "subject\n\n Issue: abc ";
210 assert_eq!(parse_issue_trailers(msg1), vec!["abc".to_string()]);
211 assert_eq!(parse_issue_trailers(msg2), vec!["abc".to_string()]);
212 assert_eq!(parse_issue_trailers(msg3), vec!["abc".to_string()]);
213 }
214
215 #[test]
216 fn parser_two_trailers_in_pure_block() {
217 let msg = "subject\n\nIssue: abc\nIssue: def";
218 assert_eq!(parse_issue_trailers(msg), vec!["abc".to_string(), "def".to_string()]);
219 }
220
221 #[test]
222 fn parser_issue_in_body_but_not_final_paragraph() {
223 let msg = "subject\n\nIssue: abc\n\nSigned-off-by: alice <a@example.com>";
224 // The final paragraph is the signed-off-by block, not the issue line.
225 // It's a valid trailer block (Signed-off-by is trailer-shaped), but it
226 // contains no Issue: key, so we extract nothing.
227 assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
228 }
229
230 #[test]
231 fn parser_wrong_key() {
232 let msg = "subject\n\nIssues: abc";
233 assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
234 }
235
236 #[test]
237 fn parser_prose_mention() {
238 let msg = "subject\n\nthis fixes issue abc in the body";
239 assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
240 }
241
242 #[test]
243 fn parser_single_paragraph_whole_message_is_trailer_block() {
244 let msg = "Issue: abc";
245 assert_eq!(parse_issue_trailers(msg), vec!["abc".to_string()]);
246 }
247
248 #[test]
249 fn parser_mixed_final_paragraph_rejects_all() {
250 let msg = "subject\n\nThanks to Bob for the catch.\nIssue: a3f9";
251 // Final paragraph has a prose line, so it's not a trailer block and we
252 // extract nothing. This is the "false positive in prose" guard.
253 assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
254 }
255
256 #[test]
257 fn parser_trailing_whitespace_paragraph_does_not_shadow_trailer_block() {
258 // The final paragraph is empty/whitespace, so the walk should fall back
259 // to the previous non-empty paragraph, which is a valid trailer block.
260 let msg = "subject\n\nIssue: abc\n\n \n";
261 assert_eq!(parse_issue_trailers(msg), vec!["abc".to_string()]);
262 }
263
264 #[test]
265 fn parser_pure_block_with_mixed_keys() {
266 let msg = "subject\n\nSigned-off-by: alice <a@example.com>\nIssue: abc";
267 assert_eq!(parse_issue_trailers(msg), vec!["abc".to_string()]);
268 }
269
270 #[test]
271 fn parser_rejects_value_with_trailing_garbage() {
272 let msg = "subject\n\nIssue: abc fixes thing";
273 assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
274 }
275
276 #[test]
277 fn parser_rejects_empty_value() {
278 let msg = "subject\n\nIssue: ";
279 assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
280 }
281
282 use git_collab::commit_link::collect_linked_shas;
283
284 #[test]
285 fn collect_linked_shas_empty_for_fresh_issue() {
286 let _config = ScopedTestConfig::new();
287 let dir = TempDir::new().unwrap();
288 let repo = init_repo(dir.path(), &alice());
289 let (ref_name, _id) = open_issue(&repo, &alice(), "bug");
290
291 let shas = collect_linked_shas(&repo, &ref_name).unwrap();
292 assert!(shas.is_empty());
293 }
294
295 #[test]
296 fn collect_linked_shas_returns_all_linked_commits_including_duplicates() {
297 let _config = ScopedTestConfig::new();
298 let dir = TempDir::new().unwrap();
299 let repo = init_repo(dir.path(), &alice());
300 let (ref_name, _id) = open_issue(&repo, &alice(), "bug");
301
302 add_commit_link(&repo, &ref_name, &alice(), &sha(0xaa));
303 add_commit_link(&repo, &ref_name, &alice(), &sha(0xbb));
304 // Even a duplicate DAG entry (cross-machine race) is surfaced here —
305 // this is the "source of truth" for whether we need to emit.
306 add_commit_link(&repo, &ref_name, &alice(), &sha(0xaa));
307
308 let shas = collect_linked_shas(&repo, &ref_name).unwrap();
309 assert_eq!(shas.len(), 2);
310 assert!(shas.contains(&sha(0xaa)));
311 assert!(shas.contains(&sha(0xbb)));
312 }
tests/common/mod.rs
Old New
@@ -177,6 +177,8 @@ impl Drop for ScopedTestConfig {
177 /// and an initial empty commit on `main`. 177 /// and an initial empty commit on `main`.
178 pub fn init_repo(dir: &Path, author: &Author) -> Repository { 178 pub fn init_repo(dir: &Path, author: &Author) -> Repository {
179 let repo = Repository::init(dir).expect("init repo"); 179 let repo = Repository::init(dir).expect("init repo");
180 // Ensure HEAD points to main regardless of the system's default branch name
181 repo.set_head("refs/heads/main").expect("set HEAD to main");
180 { 182 {
181 let mut config = repo.config().unwrap(); 183 let mut config = repo.config().unwrap();
182 config.set_str("user.name", &author.name).unwrap(); 184 config.set_str("user.name", &author.name).unwrap();
@@ -227,6 +229,25 @@ pub fn add_comment(repo: &Repository, ref_name: &str, author: &Author, body: &st
227 dag::append_event(repo, ref_name, &event, &sk).unwrap(); 229 dag::append_event(repo, ref_name, &event, &sk).unwrap();
228 } 230 }
229 231
232 /// Append an IssueCommitLink event to an issue ref. Returns the new DAG tip OID.
233 pub fn add_commit_link(
234 repo: &Repository,
235 ref_name: &str,
236 author: &Author,
237 commit_sha: &str,
238 ) -> git2::Oid {
239 let sk = test_signing_key();
240 let event = Event {
241 timestamp: now(),
242 author: author.clone(),
243 action: Action::IssueCommitLink {
244 commit: commit_sha.to_string(),
245 },
246 clock: 0,
247 };
248 dag::append_event(repo, ref_name, &event, &sk).unwrap()
249 }
250
230 /// Append a close event to an issue ref. 251 /// Append a close event to an issue ref.
231 pub fn close_issue(repo: &Repository, ref_name: &str, author: &Author) { 252 pub fn close_issue(repo: &Repository, ref_name: &str, author: &Author) {
232 let sk = test_signing_key(); 253 let sk = test_signing_key();
@@ -327,6 +348,8 @@ impl TestRepo {
327 git_with_env(dir.path(), &["init", "-b", "main"], &env); 348 git_with_env(dir.path(), &["init", "-b", "main"], &env);
328 git_with_env(dir.path(), &["config", "user.name", name], &env); 349 git_with_env(dir.path(), &["config", "user.name", name], &env);
329 git_with_env(dir.path(), &["config", "user.email", email], &env); 350 git_with_env(dir.path(), &["config", "user.email", email], &env);
351 // Disable auto-sync for tests so CLI output isn't polluted by sync attempts
352 git_with_env(dir.path(), &["config", "collab.autoSync", "false"], &env);
330 git_with_env( 353 git_with_env(
331 dir.path(), 354 dir.path(),
332 &["commit", "--allow-empty", "-m", "initial"], 355 &["commit", "--allow-empty", "-m", "initial"],
tests/sort_test.rs
Old New
@@ -229,11 +229,11 @@ fn test_patch_default_sort_by_recency() {
229 // Patch B: created later, never updated 229 // Patch B: created later, never updated
230 let (_, _) = create_patch_at(&repo, &alice(), "Beta patch", "2025-06-01T00:00:00Z"); 230 let (_, _) = create_patch_at(&repo, &alice(), "Beta patch", "2025-06-01T00:00:00Z");
231 231
232 let patches = 232 let entries =
233 git_collab::patch::list(&repo, true, false, None, None, SortMode::Recent).unwrap(); 233 git_collab::patch::list(&repo, true, false, None, None, SortMode::Recent).unwrap();
234 assert_eq!(patches.len(), 2); 234 assert_eq!(entries.len(), 2);
235 assert_eq!(patches[0].title, "Alpha patch"); 235 assert_eq!(entries[0].patch.title, "Alpha patch");
236 assert_eq!(patches[1].title, "Beta patch"); 236 assert_eq!(entries[1].patch.title, "Beta patch");
237 } 237 }
238 238
239 #[test] 239 #[test]
@@ -252,11 +252,11 @@ fn test_patch_sort_by_created() {
252 252
253 let (_, _) = create_patch_at(&repo, &alice(), "Beta patch", "2025-06-01T00:00:00Z"); 253 let (_, _) = create_patch_at(&repo, &alice(), "Beta patch", "2025-06-01T00:00:00Z");
254 254
255 let patches = 255 let entries =
256 git_collab::patch::list(&repo, true, false, None, None, SortMode::Created).unwrap(); 256 git_collab::patch::list(&repo, true, false, None, None, SortMode::Created).unwrap();
257 assert_eq!(patches.len(), 2); 257 assert_eq!(entries.len(), 2);
258 assert_eq!(patches[0].title, "Beta patch"); 258 assert_eq!(entries[0].patch.title, "Beta patch");
259 assert_eq!(patches[1].title, "Alpha patch"); 259 assert_eq!(entries[1].patch.title, "Alpha patch");
260 } 260 }
261 261
262 #[test] 262 #[test]
@@ -268,11 +268,11 @@ fn test_patch_sort_alpha() {
268 create_patch_at(&repo, &alice(), "Apple patch", "2025-06-01T00:00:00Z"); 268 create_patch_at(&repo, &alice(), "Apple patch", "2025-06-01T00:00:00Z");
269 create_patch_at(&repo, &alice(), "Mango patch", "2025-03-01T00:00:00Z"); 269 create_patch_at(&repo, &alice(), "Mango patch", "2025-03-01T00:00:00Z");
270 270
271 let patches = git_collab::patch::list(&repo, true, false, None, None, SortMode::Alpha).unwrap(); 271 let entries = git_collab::patch::list(&repo, true, false, None, None, SortMode::Alpha).unwrap();
272 assert_eq!(patches.len(), 3); 272 assert_eq!(entries.len(), 3);
273 assert_eq!(patches[0].title, "Apple patch"); 273 assert_eq!(entries[0].patch.title, "Apple patch");
274 assert_eq!(patches[1].title, "Mango patch"); 274 assert_eq!(entries[1].patch.title, "Mango patch");
275 assert_eq!(patches[2].title, "Zebra patch"); 275 assert_eq!(entries[2].patch.title, "Zebra patch");
276 } 276 }
277 277
278 // ---- CLI integration test ---- 278 // ---- CLI integration test ----
tests/sync_test.rs
Old New
@@ -1207,3 +1207,380 @@ fn test_corrupted_state_file_handled_gracefully() {
1207 "corrupted state file should be deleted" 1207 "corrupted state file should be deleted"
1208 ); 1208 );
1209 } 1209 }
1210
1211 // ---------------------------------------------------------------------------
1212 // Commit-link tests (src/commit_link.rs)
1213 // ---------------------------------------------------------------------------
1214
1215 use git_collab::commit_link;
1216
1217 fn make_commit_with_message(repo: &Repository, message: &str) -> git2::Oid {
1218 let sig = git2::Signature::now("Alice", "alice@example.com").unwrap();
1219 // The TestCluster bare repo commits onto refs/heads/main but its HEAD
1220 // remains the default refs/heads/master, so clones don't get a local
1221 // refs/heads/main automatically. Make sure it exists before we extend it.
1222 let parent_oid = if let Ok(r) = repo.find_reference("refs/heads/main") {
1223 r.target().unwrap()
1224 } else {
1225 let remote_main = repo
1226 .find_reference("refs/remotes/origin/main")
1227 .expect("origin/main should exist on the cloned test repo");
1228 let oid = remote_main.target().unwrap();
1229 repo.reference("refs/heads/main", oid, false, "seed local main")
1230 .unwrap();
1231 oid
1232 };
1233 let parent = repo.find_commit(parent_oid).unwrap();
1234 let tree_oid = parent.tree().unwrap().id();
1235 let tree = repo.find_tree(tree_oid).unwrap();
1236 repo.commit(
1237 Some("refs/heads/main"),
1238 &sig,
1239 &sig,
1240 message,
1241 &tree,
1242 &[&parent],
1243 )
1244 .unwrap()
1245 }
1246
1247 #[test]
1248 fn commit_link_scan_emits_event_for_matching_trailer() {
1249 let cluster = TestCluster::new();
1250 let alice_repo = cluster.alice_repo();
1251
1252 // Open an issue.
1253 let (issue_ref, issue_id) = open_issue(&alice_repo, &alice(), "fix the walker");
1254
1255 // Create a commit whose trailer references that issue.
1256 let message = format!("Fix walker\n\nIssue: {}", &issue_id[..8]);
1257 let commit_oid = make_commit_with_message(&alice_repo, &message);
1258
1259 // Run the scanner directly (we test the sync integration in later tests).
1260 let author = git_collab::identity::get_author(&alice_repo).unwrap();
1261 let sk = signing::load_signing_key(
1262 &signing::signing_key_dir().unwrap(),
1263 )
1264 .unwrap();
1265 let emitted = commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap();
1266 assert_eq!(emitted, 1);
1267
1268 // Walk the issue's event log and find the link.
1269 let issue = IssueState::from_ref_uncached(&alice_repo, &issue_ref, &issue_id).unwrap();
1270 assert_eq!(issue.linked_commits.len(), 1);
1271 assert_eq!(issue.linked_commits[0].commit, commit_oid.to_string());
1272 }
1273
1274 #[test]
1275 fn sync_entry_point_emits_commit_link_events_and_pushes_them() {
1276 let cluster = TestCluster::new();
1277 let alice_repo = cluster.alice_repo();
1278
1279 // Alice opens an issue and syncs it up so Bob will see it too.
1280 let (_issue_ref, issue_id) = open_issue(&alice_repo, &alice(), "bug");
1281 sync::sync(&alice_repo, "origin").unwrap();
1282
1283 // Alice makes a real commit with an Issue: trailer.
1284 let message = format!("Fix the thing\n\nIssue: {}", &issue_id[..8]);
1285 make_commit_with_message(&alice_repo, &message);
1286
1287 // Running sync should scan, emit the link, and push it.
1288 sync::sync(&alice_repo, "origin").unwrap();
1289
1290 // Bob fetches and should see the link in the materialized issue.
1291 let bob_repo = cluster.bob_repo();
1292 sync::sync(&bob_repo, "origin").unwrap();
1293 let bob_issue_ref = format!("refs/collab/issues/{}", issue_id);
1294 let bob_issue = IssueState::from_ref_uncached(&bob_repo, &bob_issue_ref, &issue_id).unwrap();
1295 assert_eq!(bob_issue.linked_commits.len(), 1);
1296 }
1297
1298 #[test]
1299 fn commit_link_scan_is_idempotent_across_runs() {
1300 let cluster = TestCluster::new();
1301 let alice_repo = cluster.alice_repo();
1302 let (issue_ref, issue_id) = open_issue(&alice_repo, &alice(), "bug");
1303
1304 let message = format!("Fix thing\n\nIssue: {}", &issue_id[..8]);
1305 make_commit_with_message(&alice_repo, &message);
1306
1307 let author = git_collab::identity::get_author(&alice_repo).unwrap();
1308 let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
1309
1310 assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 1);
1311 assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 0);
1312 assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 0);
1313
1314 let issue = IssueState::from_ref_uncached(&alice_repo, &issue_ref, &issue_id).unwrap();
1315 assert_eq!(issue.linked_commits.len(), 1);
1316 }
1317
1318 #[test]
1319 fn commit_link_scan_walks_all_local_branches_and_dedups_shared_ancestors() {
1320 let cluster = TestCluster::new();
1321 let alice_repo = cluster.alice_repo();
1322 let (issue_ref, issue_id) = open_issue(&alice_repo, &alice(), "bug");
1323
1324 // Commit on main with the trailer. Both branches will reach it.
1325 let message = format!("Fix\n\nIssue: {}", &issue_id[..8]);
1326 let linked_commit = make_commit_with_message(&alice_repo, &message);
1327
1328 // Create a second branch pointing at the same commit.
1329 {
1330 let commit = alice_repo.find_commit(linked_commit).unwrap();
1331 alice_repo.branch("feature-x", &commit, false).unwrap();
1332 }
1333
1334 let author = git_collab::identity::get_author(&alice_repo).unwrap();
1335 let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
1336
1337 // Should emit exactly one event despite the commit being reachable from
1338 // two branch tips.
1339 assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 1);
1340 let issue = IssueState::from_ref_uncached(&alice_repo, &issue_ref, &issue_id).unwrap();
1341 assert_eq!(issue.linked_commits.len(), 1);
1342 }
1343
1344 #[test]
1345 fn commit_link_scan_handles_multiple_issue_trailers_on_one_commit() {
1346 let cluster = TestCluster::new();
1347 let alice_repo = cluster.alice_repo();
1348 let (issue_ref_a, id_a) = open_issue(&alice_repo, &alice(), "bug a");
1349 let (issue_ref_b, id_b) = open_issue(&alice_repo, &alice(), "bug b");
1350
1351 let message = format!(
1352 "Fix both\n\nIssue: {}\nIssue: {}",
1353 &id_a[..8],
1354 &id_b[..8]
1355 );
1356 make_commit_with_message(&alice_repo, &message);
1357
1358 let author = git_collab::identity::get_author(&alice_repo).unwrap();
1359 let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
1360 assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 2);
1361
1362 let issue_a = IssueState::from_ref_uncached(&alice_repo, &issue_ref_a, &id_a).unwrap();
1363 let issue_b = IssueState::from_ref_uncached(&alice_repo, &issue_ref_b, &id_b).unwrap();
1364 assert_eq!(issue_a.linked_commits.len(), 1);
1365 assert_eq!(issue_b.linked_commits.len(), 1);
1366 }
1367
1368 #[test]
1369 fn commit_link_scan_skips_unknown_prefix_without_error() {
1370 let cluster = TestCluster::new();
1371 let alice_repo = cluster.alice_repo();
1372
1373 // No issue exists. Commit uses a completely unrelated prefix.
1374 make_commit_with_message(
1375 &alice_repo,
1376 "Fix\n\nIssue: zzzzzzzz",
1377 );
1378
1379 let author = git_collab::identity::get_author(&alice_repo).unwrap();
1380 let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
1381 assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 0);
1382 }
1383
1384 #[test]
1385 fn commit_link_scan_skips_genuinely_ambiguous_prefix() {
1386 let cluster = TestCluster::new();
1387 let alice_repo = cluster.alice_repo();
1388
1389 // Open 17 issues. By pigeonhole on the 16 possible hex first-chars,
1390 // at least two of the resulting issue IDs must share a first character,
1391 // guaranteeing we can construct an ambiguous one-char prefix.
1392 let mut ids: Vec<String> = Vec::with_capacity(17);
1393 for i in 0..17 {
1394 let (_, id) = open_issue(&alice_repo, &alice(), &format!("issue {}", i));
1395 ids.push(id);
1396 }
1397
1398 // Find a first-char that has at least 2 matching IDs.
1399 let mut counts: std::collections::HashMap<char, Vec<&str>> =
1400 std::collections::HashMap::new();
1401 for id in &ids {
1402 let c = id.chars().next().unwrap();
1403 counts.entry(c).or_default().push(id.as_str());
1404 }
1405 let (ambiguous_char, matching_ids) = counts
1406 .iter()
1407 .find(|(_, v)| v.len() >= 2)
1408 .map(|(c, v)| (*c, v.clone()))
1409 .expect("pigeonhole guarantees at least one shared first char among 17 hex IDs");
1410 let ambiguous_prefix = ambiguous_char.to_string();
1411
1412 make_commit_with_message(
1413 &alice_repo,
1414 &format!("Touch\n\nIssue: {}", ambiguous_prefix),
1415 );
1416
1417 let author = git_collab::identity::get_author(&alice_repo).unwrap();
1418 let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
1419 // Ambiguous prefix must be skipped silently — no event emitted.
1420 assert_eq!(
1421 commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(),
1422 0
1423 );
1424
1425 // None of the candidate issues should have grown a commit-link event:
1426 // each one still consists only of its IssueOpen event.
1427 for id in &matching_ids {
1428 let issue_ref = format!("refs/collab/issues/{}", id);
1429 let state = IssueState::from_ref_uncached(&alice_repo, &issue_ref, id).unwrap();
1430 assert!(
1431 state.linked_commits.is_empty(),
1432 "candidate issue {} should not have any linked commits, but has {}",
1433 id,
1434 state.linked_commits.len()
1435 );
1436 }
1437 }
1438
1439 #[test]
1440 fn commit_link_scan_skips_archived_issues_with_warning() {
1441 let cluster = TestCluster::new();
1442 let alice_repo = cluster.alice_repo();
1443
1444 let (_, issue_id) = open_issue(&alice_repo, &alice(), "old bug");
1445 // Archive the issue via the state helper.
1446 state::archive_issue_ref(&alice_repo, &issue_id).unwrap();
1447
1448 make_commit_with_message(
1449 &alice_repo,
1450 &format!("Reference old\n\nIssue: {}", &issue_id[..8]),
1451 );
1452
1453 let author = git_collab::identity::get_author(&alice_repo).unwrap();
1454 let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
1455 assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 0);
1456
1457 // Confirm the archived ref did not accrue a new event: the archived
1458 // DAG tip should still be the archive-time tip.
1459 let archived_ref = format!("refs/collab/archive/issues/{}", issue_id);
1460 let archived_state =
1461 IssueState::from_ref_uncached(&alice_repo, &archived_ref, &issue_id).unwrap();
1462 assert!(archived_state.linked_commits.is_empty());
1463 }
1464
1465 #[test]
1466 fn commit_link_scan_no_op_on_detached_head_with_no_branches() {
1467 let cluster = TestCluster::new();
1468 let alice_repo = cluster.alice_repo();
1469
1470 // Seed refs/heads/main so HEAD resolves to a commit we can detach onto.
1471 // make_commit_with_message creates refs/heads/main if missing.
1472 make_commit_with_message(&alice_repo, "seed for detached head test");
1473
1474 // Put HEAD in detached state pointing at the current main tip, then
1475 // delete all local branches so scan_and_link has nothing to walk.
1476 let head_oid = alice_repo
1477 .find_reference("refs/heads/main")
1478 .unwrap()
1479 .target()
1480 .unwrap();
1481 alice_repo.set_head_detached(head_oid).unwrap();
1482 alice_repo
1483 .find_reference("refs/heads/main")
1484 .unwrap()
1485 .delete()
1486 .unwrap();
1487
1488 let author = git_collab::identity::get_author(&alice_repo).unwrap();
1489 let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
1490 // No branches to walk — silent no-op.
1491 assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 0);
1492 }
1493
1494 #[test]
1495 fn commit_link_scan_dedups_against_remote_originated_events() {
1496 // Simulates the cross-machine dedup case: Bob's repo fetches a link
1497 // event that Alice already emitted, then runs scan locally with the
1498 // same commit reachable from his branches. He must not emit a
1499 // duplicate.
1500 let cluster = TestCluster::new();
1501 let alice_repo = cluster.alice_repo();
1502 let bob_repo = cluster.bob_repo();
1503
1504 // Both repos get the same issue.
1505 let (_, issue_id) = open_issue(&alice_repo, &alice(), "bug");
1506 sync::sync(&alice_repo, "origin").unwrap();
1507 sync::sync(&bob_repo, "origin").unwrap();
1508
1509 // Alice writes a commit and pushes it to the bare remote so Bob can
1510 // fetch it. First the regular git push; then sync for the link event.
1511 let message = format!("Fix thing\n\nIssue: {}", &issue_id[..8]);
1512 let linked_commit = make_commit_with_message(&alice_repo, &message);
1513 // Push the branch so Bob sees the commit too.
1514 let mut cmd = Command::new("git");
1515 cmd.args(["push", "origin", "main"])
1516 .current_dir(cluster.alice_dir.path());
1517 assert!(cmd.status().unwrap().success());
1518 sync::sync(&alice_repo, "origin").unwrap();
1519
1520 // Bob fetches both the branch and the collab link event.
1521 let mut cmd = Command::new("git");
1522 cmd.args(["fetch", "origin", "main:main"])
1523 .current_dir(cluster.bob_dir.path());
1524 assert!(cmd.status().unwrap().success());
1525 sync::sync(&bob_repo, "origin").unwrap();
1526
1527 // At this point Bob's issue already has the link event from Alice.
1528 // Re-running scan on Bob's repo must find the commit locally and
1529 // decide "already linked", emitting zero events.
1530 let author = git_collab::identity::get_author(&bob_repo).unwrap();
1531 let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
1532 let emitted = commit_link::scan_and_link(&bob_repo, &author, &sk).unwrap();
1533 assert_eq!(emitted, 0, "Bob must not duplicate Alice's link event");
1534
1535 // And the commit on Bob's side really is the one linked.
1536 let bob_ref = format!("refs/collab/issues/{}", issue_id);
1537 let bob_issue = IssueState::from_ref_uncached(&bob_repo, &bob_ref, &issue_id).unwrap();
1538 assert_eq!(bob_issue.linked_commits.len(), 1);
1539 assert_eq!(bob_issue.linked_commits[0].commit, linked_commit.to_string());
1540 }
1541
1542 #[test]
1543 fn cli_issue_show_renders_linked_commits_section() {
1544 use common::TestRepo;
1545
1546 let repo = TestRepo::new("Alice", "alice@example.com");
1547 let issue_id = repo.issue_open("fix the thing");
1548
1549 // Resolve the full id via --json so we can format a trailer prefix.
1550 let full_id = {
1551 let out = repo.run_ok(&["issue", "show", &issue_id, "--json"]);
1552 let v: serde_json::Value = serde_json::from_str(&out).unwrap();
1553 v["id"].as_str().unwrap().to_string()
1554 };
1555 let msg = format!("Fix a thing\n\nIssue: {}", &full_id[..8]);
1556 repo.git(&["commit", "--allow-empty", "-m", &msg]);
1557
1558 // Set up a bare remote so sync has something to push to.
1559 let bare = TempDir::new().unwrap();
1560 Command::new("git")
1561 .args(["init", "--bare"])
1562 .current_dir(bare.path())
1563 .status()
1564 .unwrap();
1565 repo.git(&["remote", "add", "origin", bare.path().to_str().unwrap()]);
1566 repo.git(&["push", "-u", "origin", "main"]);
1567 repo.run_ok(&["init"]);
1568 repo.run_ok(&["sync"]);
1569
1570 let show = repo.run_ok(&["issue", "show", &issue_id]);
1571 assert!(
1572 show.contains("--- Linked Commits ---"),
1573 "expected linked commits section, got:\n{}",
1574 show
1575 );
1576 assert!(
1577 show.contains("by Alice"),
1578 "expected commit author rendered, got:\n{}",
1579 show
1580 );
1581 assert!(
1582 show.contains("(linked by Alice"),
1583 "expected event author rendered, got:\n{}",
1584 show
1585 );
1586 }