c9ee615a
Link commits to issues and add auto-sync, search and patch checkout
a73x 2026-08-08 17:57
Commit message
.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 | } | ||