a73x

0100af26

Sign events with Ed25519 and verify signatures on sync

a73x   2026-03-21 07:24

Commit message
Sign events with Ed25519 and verify signatures on sync

Cargo.lock
Old New
@@ -62,7 +62,7 @@ version = "1.1.5"
62 source = "registry+https://github.com/rust-lang/crates.io-index" 62 source = "registry+https://github.com/rust-lang/crates.io-index"
63 checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" 63 checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
64 dependencies = [ 64 dependencies = [
65 "windows-sys", 65 "windows-sys 0.61.2",
66 ] 66 ]
67 67
68 [[package]] 68 [[package]]
@@ -73,7 +73,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
73 dependencies = [ 73 dependencies = [
74 "anstyle", 74 "anstyle",
75 "once_cell_polyfill", 75 "once_cell_polyfill",
76 "windows-sys", 76 "windows-sys 0.61.2",
77 ] 77 ]
78 78
79 [[package]] 79 [[package]]
@@ -104,6 +104,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
104 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 104 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
105 105
106 [[package]] 106 [[package]]
107 name = "base64ct"
108 version = "1.8.3"
109 source = "registry+https://github.com/rust-lang/crates.io-index"
110 checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
111
112 [[package]]
107 name = "bit-set" 113 name = "bit-set"
108 version = "0.5.3" 114 version = "0.5.3"
109 source = "registry+https://github.com/rust-lang/crates.io-index" 115 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -259,6 +265,12 @@ dependencies = [
259 ] 265 ]
260 266
261 [[package]] 267 [[package]]
268 name = "const-oid"
269 version = "0.9.6"
270 source = "registry+https://github.com/rust-lang/crates.io-index"
271 checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
272
273 [[package]]
262 name = "convert_case" 274 name = "convert_case"
263 version = "0.10.0" 275 version = "0.10.0"
264 source = "registry+https://github.com/rust-lang/crates.io-index" 276 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -330,6 +342,33 @@ dependencies = [
330 ] 342 ]
331 343
332 [[package]] 344 [[package]]
345 name = "curve25519-dalek"
346 version = "4.1.3"
347 source = "registry+https://github.com/rust-lang/crates.io-index"
348 checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
349 dependencies = [
350 "cfg-if",
351 "cpufeatures",
352 "curve25519-dalek-derive",
353 "digest",
354 "fiat-crypto",
355 "rustc_version",
356 "subtle",
357 "zeroize",
358 ]
359
360 [[package]]
361 name = "curve25519-dalek-derive"
362 version = "0.1.1"
363 source = "registry+https://github.com/rust-lang/crates.io-index"
364 checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
365 dependencies = [
366 "proc-macro2",
367 "quote",
368 "syn 2.0.117",
369 ]
370
371 [[package]]
333 name = "darling" 372 name = "darling"
334 version = "0.23.0" 373 version = "0.23.0"
335 source = "registry+https://github.com/rust-lang/crates.io-index" 374 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -370,6 +409,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
370 checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" 409 checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
371 410
372 [[package]] 411 [[package]]
412 name = "der"
413 version = "0.7.10"
414 source = "registry+https://github.com/rust-lang/crates.io-index"
415 checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
416 dependencies = [
417 "const-oid",
418 "zeroize",
419 ]
420
421 [[package]]
373 name = "deranged" 422 name = "deranged"
374 version = "0.5.8" 423 version = "0.5.8"
375 source = "registry+https://github.com/rust-lang/crates.io-index" 424 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -411,6 +460,27 @@ dependencies = [
411 ] 460 ]
412 461
413 [[package]] 462 [[package]]
463 name = "dirs"
464 version = "5.0.1"
465 source = "registry+https://github.com/rust-lang/crates.io-index"
466 checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
467 dependencies = [
468 "dirs-sys",
469 ]
470
471 [[package]]
472 name = "dirs-sys"
473 version = "0.4.1"
474 source = "registry+https://github.com/rust-lang/crates.io-index"
475 checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
476 dependencies = [
477 "libc",
478 "option-ext",
479 "redox_users",
480 "windows-sys 0.48.0",
481 ]
482
483 [[package]]
414 name = "displaydoc" 484 name = "displaydoc"
415 version = "0.2.5" 485 version = "0.2.5"
416 source = "registry+https://github.com/rust-lang/crates.io-index" 486 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -431,6 +501,31 @@ dependencies = [
431 ] 501 ]
432 502
433 [[package]] 503 [[package]]
504 name = "ed25519"
505 version = "2.2.3"
506 source = "registry+https://github.com/rust-lang/crates.io-index"
507 checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
508 dependencies = [
509 "pkcs8",
510 "signature",
511 ]
512
513 [[package]]
514 name = "ed25519-dalek"
515 version = "2.2.0"
516 source = "registry+https://github.com/rust-lang/crates.io-index"
517 checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
518 dependencies = [
519 "curve25519-dalek",
520 "ed25519",
521 "rand_core",
522 "serde",
523 "sha2",
524 "subtle",
525 "zeroize",
526 ]
527
528 [[package]]
434 name = "either" 529 name = "either"
435 version = "1.15.0" 530 version = "1.15.0"
436 source = "registry+https://github.com/rust-lang/crates.io-index" 531 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -449,7 +544,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
449 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 544 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
450 dependencies = [ 545 dependencies = [
451 "libc", 546 "libc",
452 "windows-sys", 547 "windows-sys 0.61.2",
453 ] 548 ]
454 549
455 [[package]] 550 [[package]]
@@ -478,6 +573,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
478 checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 573 checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
479 574
480 [[package]] 575 [[package]]
576 name = "fiat-crypto"
577 version = "0.2.9"
578 source = "registry+https://github.com/rust-lang/crates.io-index"
579 checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
580
581 [[package]]
481 name = "filedescriptor" 582 name = "filedescriptor"
482 version = "0.8.3" 583 version = "0.8.3"
483 source = "registry+https://github.com/rust-lang/crates.io-index" 584 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -545,6 +646,17 @@ dependencies = [
545 646
546 [[package]] 647 [[package]]
547 name = "getrandom" 648 name = "getrandom"
649 version = "0.2.17"
650 source = "registry+https://github.com/rust-lang/crates.io-index"
651 checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
652 dependencies = [
653 "cfg-if",
654 "libc",
655 "wasi",
656 ]
657
658 [[package]]
659 name = "getrandom"
548 version = "0.3.4" 660 version = "0.3.4"
549 source = "registry+https://github.com/rust-lang/crates.io-index" 661 source = "registry+https://github.com/rust-lang/crates.io-index"
550 checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 662 checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
@@ -572,10 +684,14 @@ dependencies = [
572 name = "git-collab" 684 name = "git-collab"
573 version = "0.1.0" 685 version = "0.1.0"
574 dependencies = [ 686 dependencies = [
687 "base64",
575 "chrono", 688 "chrono",
576 "clap", 689 "clap",
577 "crossterm", 690 "crossterm",
691 "dirs",
692 "ed25519-dalek",
578 "git2", 693 "git2",
694 "rand_core",
579 "ratatui", 695 "ratatui",
580 "serde", 696 "serde",
581 "serde_json", 697 "serde_json",
@@ -893,6 +1009,15 @@ dependencies = [
893 ] 1009 ]
894 1010
895 [[package]] 1011 [[package]]
1012 name = "libredox"
1013 version = "0.1.14"
1014 source = "registry+https://github.com/rust-lang/crates.io-index"
1015 checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
1016 dependencies = [
1017 "libc",
1018 ]
1019
1020 [[package]]
896 name = "libssh2-sys" 1021 name = "libssh2-sys"
897 version = "0.3.1" 1022 version = "0.3.1"
898 source = "registry+https://github.com/rust-lang/crates.io-index" 1023 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1015,7 +1140,7 @@ dependencies = [
1015 "libc", 1140 "libc",
1016 "log", 1141 "log",
1017 "wasi", 1142 "wasi",
1018 "windows-sys", 1143 "windows-sys 0.61.2",
1019 ] 1144 ]
1020 1145
1021 [[package]] 1146 [[package]]
@@ -1107,6 +1232,12 @@ dependencies = [
1107 ] 1232 ]
1108 1233
1109 [[package]] 1234 [[package]]
1235 name = "option-ext"
1236 version = "0.2.0"
1237 source = "registry+https://github.com/rust-lang/crates.io-index"
1238 checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
1239
1240 [[package]]
1110 name = "ordered-float" 1241 name = "ordered-float"
1111 version = "4.6.0" 1242 version = "4.6.0"
1112 source = "registry+https://github.com/rust-lang/crates.io-index" 1243 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1240,6 +1371,16 @@ dependencies = [
1240 ] 1371 ]
1241 1372
1242 [[package]] 1373 [[package]]
1374 name = "pkcs8"
1375 version = "0.10.2"
1376 source = "registry+https://github.com/rust-lang/crates.io-index"
1377 checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
1378 dependencies = [
1379 "der",
1380 "spki",
1381 ]
1382
1383 [[package]]
1243 name = "pkg-config" 1384 name = "pkg-config"
1244 version = "0.3.32" 1385 version = "0.3.32"
1245 source = "registry+https://github.com/rust-lang/crates.io-index" 1386 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1320,6 +1461,9 @@ name = "rand_core"
1320 version = "0.6.4" 1461 version = "0.6.4"
1321 source = "registry+https://github.com/rust-lang/crates.io-index" 1462 source = "registry+https://github.com/rust-lang/crates.io-index"
1322 checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 1463 checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
1464 dependencies = [
1465 "getrandom 0.2.17",
1466 ]
1323 1467
1324 [[package]] 1468 [[package]]
1325 name = "ratatui" 1469 name = "ratatui"
@@ -1416,6 +1560,17 @@ dependencies = [
1416 ] 1560 ]
1417 1561
1418 [[package]] 1562 [[package]]
1563 name = "redox_users"
1564 version = "0.4.6"
1565 source = "registry+https://github.com/rust-lang/crates.io-index"
1566 checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
1567 dependencies = [
1568 "getrandom 0.2.17",
1569 "libredox",
1570 "thiserror 1.0.69",
1571 ]
1572
1573 [[package]]
1419 name = "regex" 1574 name = "regex"
1420 version = "1.12.3" 1575 version = "1.12.3"
1421 source = "registry+https://github.com/rust-lang/crates.io-index" 1576 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1463,7 +1618,7 @@ dependencies = [
1463 "errno", 1618 "errno",
1464 "libc", 1619 "libc",
1465 "linux-raw-sys", 1620 "linux-raw-sys",
1466 "windows-sys", 1621 "windows-sys 0.61.2",
1467 ] 1622 ]
1468 1623
1469 [[package]] 1624 [[package]]
@@ -1582,6 +1737,15 @@ dependencies = [
1582 ] 1737 ]
1583 1738
1584 [[package]] 1739 [[package]]
1740 name = "signature"
1741 version = "2.2.0"
1742 source = "registry+https://github.com/rust-lang/crates.io-index"
1743 checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
1744 dependencies = [
1745 "rand_core",
1746 ]
1747
1748 [[package]]
1585 name = "siphasher" 1749 name = "siphasher"
1586 version = "1.0.2" 1750 version = "1.0.2"
1587 source = "registry+https://github.com/rust-lang/crates.io-index" 1751 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1594,6 +1758,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1594 checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 1758 checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
1595 1759
1596 [[package]] 1760 [[package]]
1761 name = "spki"
1762 version = "0.7.3"
1763 source = "registry+https://github.com/rust-lang/crates.io-index"
1764 checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
1765 dependencies = [
1766 "base64ct",
1767 "der",
1768 ]
1769
1770 [[package]]
1597 name = "stable_deref_trait" 1771 name = "stable_deref_trait"
1598 version = "1.2.1" 1772 version = "1.2.1"
1599 source = "registry+https://github.com/rust-lang/crates.io-index" 1773 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1633,6 +1807,12 @@ dependencies = [
1633 ] 1807 ]
1634 1808
1635 [[package]] 1809 [[package]]
1810 name = "subtle"
1811 version = "2.6.1"
1812 source = "registry+https://github.com/rust-lang/crates.io-index"
1813 checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
1814
1815 [[package]]
1636 name = "syn" 1816 name = "syn"
1637 version = "1.0.109" 1817 version = "1.0.109"
1638 source = "registry+https://github.com/rust-lang/crates.io-index" 1818 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1675,7 +1855,7 @@ dependencies = [
1675 "getrandom 0.4.2", 1855 "getrandom 0.4.2",
1676 "once_cell", 1856 "once_cell",
1677 "rustix", 1857 "rustix",
1678 "windows-sys", 1858 "windows-sys 0.61.2",
1679 ] 1859 ]
1680 1860
1681 [[package]] 1861 [[package]]
@@ -2174,6 +2354,15 @@ dependencies = [
2174 2354
2175 [[package]] 2355 [[package]]
2176 name = "windows-sys" 2356 name = "windows-sys"
2357 version = "0.48.0"
2358 source = "registry+https://github.com/rust-lang/crates.io-index"
2359 checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
2360 dependencies = [
2361 "windows-targets",
2362 ]
2363
2364 [[package]]
2365 name = "windows-sys"
2177 version = "0.61.2" 2366 version = "0.61.2"
2178 source = "registry+https://github.com/rust-lang/crates.io-index" 2367 source = "registry+https://github.com/rust-lang/crates.io-index"
2179 checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 2368 checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
@@ -2182,6 +2371,63 @@ dependencies = [
2182 ] 2371 ]
2183 2372
2184 [[package]] 2373 [[package]]
2374 name = "windows-targets"
2375 version = "0.48.5"
2376 source = "registry+https://github.com/rust-lang/crates.io-index"
2377 checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
2378 dependencies = [
2379 "windows_aarch64_gnullvm",
2380 "windows_aarch64_msvc",
2381 "windows_i686_gnu",
2382 "windows_i686_msvc",
2383 "windows_x86_64_gnu",
2384 "windows_x86_64_gnullvm",
2385 "windows_x86_64_msvc",
2386 ]
2387
2388 [[package]]
2389 name = "windows_aarch64_gnullvm"
2390 version = "0.48.5"
2391 source = "registry+https://github.com/rust-lang/crates.io-index"
2392 checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
2393
2394 [[package]]
2395 name = "windows_aarch64_msvc"
2396 version = "0.48.5"
2397 source = "registry+https://github.com/rust-lang/crates.io-index"
2398 checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
2399
2400 [[package]]
2401 name = "windows_i686_gnu"
2402 version = "0.48.5"
2403 source = "registry+https://github.com/rust-lang/crates.io-index"
2404 checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
2405
2406 [[package]]
2407 name = "windows_i686_msvc"
2408 version = "0.48.5"
2409 source = "registry+https://github.com/rust-lang/crates.io-index"
2410 checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
2411
2412 [[package]]
2413 name = "windows_x86_64_gnu"
2414 version = "0.48.5"
2415 source = "registry+https://github.com/rust-lang/crates.io-index"
2416 checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
2417
2418 [[package]]
2419 name = "windows_x86_64_gnullvm"
2420 version = "0.48.5"
2421 source = "registry+https://github.com/rust-lang/crates.io-index"
2422 checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
2423
2424 [[package]]
2425 name = "windows_x86_64_msvc"
2426 version = "0.48.5"
2427 source = "registry+https://github.com/rust-lang/crates.io-index"
2428 checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
2429
2430 [[package]]
2185 name = "wit-bindgen" 2431 name = "wit-bindgen"
2186 version = "0.51.0" 2432 version = "0.51.0"
2187 source = "registry+https://github.com/rust-lang/crates.io-index" 2433 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2320,6 +2566,12 @@ dependencies = [
2320 ] 2566 ]
2321 2567
2322 [[package]] 2568 [[package]]
2569 name = "zeroize"
2570 version = "1.8.2"
2571 source = "registry+https://github.com/rust-lang/crates.io-index"
2572 checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
2573
2574 [[package]]
2323 name = "zerotrie" 2575 name = "zerotrie"
2324 version = "0.2.3" 2576 version = "0.2.3"
2325 source = "registry+https://github.com/rust-lang/crates.io-index" 2577 source = "registry+https://github.com/rust-lang/crates.io-index"
Cargo.toml
Old New
@@ -12,6 +12,10 @@ chrono = { version = "0.4", features = ["serde"] }
12 thiserror = "2" 12 thiserror = "2"
13 ratatui = "0.30.0" 13 ratatui = "0.30.0"
14 crossterm = "0.29.0" 14 crossterm = "0.29.0"
15 ed25519-dalek = { version = "2", features = ["rand_core"] }
16 rand_core = { version = "0.6", features = ["getrandom"] }
17 base64 = "0.22"
18 dirs = "5"
15 19
16 [dev-dependencies] 20 [dev-dependencies]
17 tempfile = "3" 21 tempfile = "3"
src/cli.rs
Old New
@@ -32,6 +32,14 @@ pub enum Commands {
32 #[arg(default_value = "origin")] 32 #[arg(default_value = "origin")]
33 remote: String, 33 remote: String,
34 }, 34 },
35
36 /// Generate an Ed25519 signing keypair
37 #[clap(name = "init-key")]
38 InitKey {
39 /// Overwrite existing key files
40 #[arg(long)]
41 force: bool,
42 },
35 } 43 }
36 44
37 #[derive(Subcommand)] 45 #[derive(Subcommand)]
@@ -64,6 +72,45 @@ pub enum IssueCmd {
64 #[arg(short, long)] 72 #[arg(short, long)]
65 body: String, 73 body: String,
66 }, 74 },
75 /// Edit an issue's title or body
76 Edit {
77 /// Issue ID (prefix match)
78 id: String,
79 /// New title
80 #[arg(short, long)]
81 title: Option<String>,
82 /// New body
83 #[arg(short, long)]
84 body: Option<String>,
85 },
86 /// Add a label to an issue
87 Label {
88 /// Issue ID (prefix match)
89 id: String,
90 /// Label to add
91 label: String,
92 },
93 /// Remove a label from an issue
94 Unlabel {
95 /// Issue ID (prefix match)
96 id: String,
97 /// Label to remove
98 label: String,
99 },
100 /// Assign an issue to someone
101 Assign {
102 /// Issue ID (prefix match)
103 id: String,
104 /// Name to assign
105 name: String,
106 },
107 /// Unassign someone from an issue
108 Unassign {
109 /// Issue ID (prefix match)
110 id: String,
111 /// Name to unassign
112 name: String,
113 },
67 /// Close an issue 114 /// Close an issue
68 Close { 115 Close {
69 /// Issue ID (prefix match) 116 /// Issue ID (prefix match)
@@ -95,6 +142,9 @@ pub enum PatchCmd {
95 /// Head commit to review 142 /// Head commit to review
96 #[arg(long)] 143 #[arg(long)]
97 head: String, 144 head: String,
145 /// Issue ID this patch fixes (auto-closes on merge)
146 #[arg(long)]
147 fixes: Option<String>,
98 }, 148 },
99 /// List patches 149 /// List patches
100 List { 150 List {
@@ -107,6 +157,11 @@ pub enum PatchCmd {
107 /// Patch ID (prefix match) 157 /// Patch ID (prefix match)
108 id: String, 158 id: String,
109 }, 159 },
160 /// Show diff between base and head
161 Diff {
162 /// Patch ID (prefix match)
163 id: String,
164 },
110 /// Comment on a patch (use --file and --line for inline comments) 165 /// Comment on a patch (use --file and --line for inline comments)
111 Comment { 166 Comment {
112 /// Patch ID (prefix match) 167 /// Patch ID (prefix match)
src/dag.rs
Old New
@@ -3,11 +3,17 @@ use git2::{Oid, Repository, Sort};
3 use crate::error::Error; 3 use crate::error::Error;
4 use crate::event::{Action, Event}; 4 use crate::event::{Action, Event};
5 use crate::identity::author_signature; 5 use crate::identity::author_signature;
6 use crate::signing::{sign_event, SignedEvent};
6 7
7 /// Create an orphan commit (no parents) with the given event. 8 /// Create an orphan commit (no parents) with the given event.
8 /// Returns the new commit OID which also serves as the entity ID. 9 /// Returns the new commit OID which also serves as the entity ID.
9 pub fn create_root_event(repo: &Repository, event: &Event) -> Result<Oid, Error> { 10 pub fn create_root_event(
10 let json = serde_json::to_vec_pretty(event)?; 11 repo: &Repository,
12 event: &Event,
13 signing_key: &ed25519_dalek::SigningKey,
14 ) -> Result<Oid, Error> {
15 let signed = sign_event(event, signing_key)?;
16 let json = serde_json::to_vec_pretty(&signed)?;
11 let blob_oid = repo.blob(&json)?; 17 let blob_oid = repo.blob(&json)?;
12 18
13 let mut tb = repo.treebuilder(None)?; 19 let mut tb = repo.treebuilder(None)?;
@@ -23,8 +29,14 @@ pub fn create_root_event(repo: &Repository, event: &Event) -> Result<Oid, Error>
23 } 29 }
24 30
25 /// Append an event to an existing DAG. The current tip is the parent. 31 /// Append an event to an existing DAG. The current tip is the parent.
26 pub fn append_event(repo: &Repository, ref_name: &str, event: &Event) -> Result<Oid, Error> { 32 pub fn append_event(
27 let json = serde_json::to_vec_pretty(event)?; 33 repo: &Repository,
34 ref_name: &str,
35 event: &Event,
36 signing_key: &ed25519_dalek::SigningKey,
37 ) -> Result<Oid, Error> {
38 let signed = sign_event(event, signing_key)?;
39 let json = serde_json::to_vec_pretty(&signed)?;
28 let blob_oid = repo.blob(&json)?; 40 let blob_oid = repo.blob(&json)?;
29 41
30 let mut tb = repo.treebuilder(None)?; 42 let mut tb = repo.treebuilder(None)?;
@@ -59,7 +71,13 @@ pub fn walk_events(repo: &Repository, ref_name: &str) -> Result<Vec<(Oid, Event)
59 .get_name("event.json") 71 .get_name("event.json")
60 .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?; 72 .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?;
61 let blob = repo.find_blob(entry.id())?; 73 let blob = repo.find_blob(entry.id())?;
62 let event: Event = serde_json::from_slice(blob.content())?; 74 let content = blob.content();
75 // Try SignedEvent first, fall back to plain Event for backward compat
76 let event: Event = if let Ok(signed) = serde_json::from_slice::<SignedEvent>(content) {
77 signed.event
78 } else {
79 serde_json::from_slice(content)?
80 };
63 events.push((oid, event)); 81 events.push((oid, event));
64 } 82 }
65 Ok(events) 83 Ok(events)
@@ -76,6 +94,7 @@ pub fn reconcile(
76 local_ref: &str, 94 local_ref: &str,
77 remote_ref: &str, 95 remote_ref: &str,
78 merge_author: &crate::event::Author, 96 merge_author: &crate::event::Author,
97 signing_key: &ed25519_dalek::SigningKey,
79 ) -> Result<Oid, Error> { 98 ) -> Result<Oid, Error> {
80 let local_oid = repo.refname_to_id(local_ref)?; 99 let local_oid = repo.refname_to_id(local_ref)?;
81 let remote_oid = repo.refname_to_id(remote_ref)?; 100 let remote_oid = repo.refname_to_id(remote_ref)?;
@@ -104,7 +123,8 @@ pub fn reconcile(
104 action: Action::Merge, 123 action: Action::Merge,
105 }; 124 };
106 125
107 let json = serde_json::to_vec_pretty(&merge_event)?; 126 let signed = sign_event(&merge_event, signing_key)?;
127 let json = serde_json::to_vec_pretty(&signed)?;
108 let blob_oid = repo.blob(&json)?; 128 let blob_oid = repo.blob(&json)?;
109 let mut tb = repo.treebuilder(None)?; 129 let mut tb = repo.treebuilder(None)?;
110 tb.insert("event.json", blob_oid, 0o100644)?; 130 tb.insert("event.json", blob_oid, 0o100644)?;
@@ -130,6 +150,11 @@ pub fn reconcile(
130 fn commit_message(action: &Action) -> String { 150 fn commit_message(action: &Action) -> String {
131 match action { 151 match action {
132 Action::IssueOpen { title, .. } => format!("issue: open \"{}\"", title), 152 Action::IssueOpen { title, .. } => format!("issue: open \"{}\"", title),
153 Action::IssueEdit { .. } => "issue: edit".to_string(),
154 Action::IssueLabel { ref label } => format!("issue: label \"{}\"", label),
155 Action::IssueUnlabel { ref label } => format!("issue: unlabel \"{}\"", label),
156 Action::IssueAssign { ref assignee } => format!("issue: assign \"{}\"", assignee),
157 Action::IssueUnassign { ref assignee } => format!("issue: unassign \"{}\"", assignee),
133 Action::IssueComment { .. } => "issue: comment".to_string(), 158 Action::IssueComment { .. } => "issue: comment".to_string(),
134 Action::IssueClose { .. } => "issue: close".to_string(), 159 Action::IssueClose { .. } => "issue: close".to_string(),
135 Action::IssueReopen => "issue: reopen".to_string(), 160 Action::IssueReopen => "issue: reopen".to_string(),
src/error.rs
Old New
@@ -13,4 +13,13 @@ pub enum Error {
13 13
14 #[error(transparent)] 14 #[error(transparent)]
15 Io(#[from] std::io::Error), 15 Io(#[from] std::io::Error),
16
17 #[error("signing error: {0}")]
18 Signing(String),
19
20 #[error("verification error: {0}")]
21 Verification(String),
22
23 #[error("no signing key found — run 'collab init-key' to generate one")]
24 KeyNotFound,
16 } 25 }
src/event.rs
Old New
@@ -26,12 +26,30 @@ pub enum Action {
26 IssueClose { 26 IssueClose {
27 reason: Option<String>, 27 reason: Option<String>,
28 }, 28 },
29 IssueEdit {
30 title: Option<String>,
31 body: Option<String>,
32 },
33 IssueLabel {
34 label: String,
35 },
36 IssueUnlabel {
37 label: String,
38 },
39 IssueAssign {
40 assignee: String,
41 },
42 IssueUnassign {
43 assignee: String,
44 },
29 IssueReopen, 45 IssueReopen,
30 PatchCreate { 46 PatchCreate {
31 title: String, 47 title: String,
32 body: String, 48 body: String,
33 base_ref: String, 49 base_ref: String,
34 head_commit: String, 50 head_commit: String,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 fixes: Option<String>,
35 }, 53 },
36 PatchRevise { 54 PatchRevise {
37 body: Option<String>, 55 body: Option<String>,
src/issue.rs
Old New
@@ -3,9 +3,11 @@ use git2::Repository;
3 use crate::dag; 3 use crate::dag;
4 use crate::event::{Action, Event}; 4 use crate::event::{Action, Event};
5 use crate::identity::get_author; 5 use crate::identity::get_author;
6 use crate::state::{self, IssueStatus}; 6 use crate::signing;
7 use crate::state::{self, IssueState, IssueStatus};
7 8
8 pub fn open(repo: &Repository, title: &str, body: &str) -> Result<String, crate::error::Error> { 9 pub fn open(repo: &Repository, title: &str, body: &str) -> Result<String, crate::error::Error> {
10 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
9 let author = get_author(repo)?; 11 let author = get_author(repo)?;
10 let event = Event { 12 let event = Event {
11 timestamp: chrono::Utc::now().to_rfc3339(), 13 timestamp: chrono::Utc::now().to_rfc3339(),
@@ -15,66 +17,157 @@ pub fn open(repo: &Repository, title: &str, body: &str) -> Result<String, crate:
15 body: body.to_string(), 17 body: body.to_string(),
16 }, 18 },
17 }; 19 };
18 let oid = dag::create_root_event(repo, &event)?; 20 let oid = dag::create_root_event(repo, &event, &sk)?;
19 let id = oid.to_string(); 21 let id = oid.to_string();
20 let ref_name = format!("refs/collab/issues/{}", id); 22 let ref_name = format!("refs/collab/issues/{}", id);
21 repo.reference(&ref_name, oid, false, "issue open")?; 23 repo.reference(&ref_name, oid, false, "issue open")?;
22 Ok(id) 24 Ok(id)
23 } 25 }
24 26
25 pub fn list(repo: &Repository, show_closed: bool) -> Result<(), crate::error::Error> { 27 pub struct ListEntry {
28 pub issue: IssueState,
29 pub unread: Option<usize>,
30 }
31
32 pub fn list(repo: &Repository, show_closed: bool) -> Result<Vec<ListEntry>, crate::error::Error> {
26 let issues = state::list_issues(repo)?; 33 let issues = state::list_issues(repo)?;
27 let filtered: Vec<_> = issues 34 let entries = issues
28 .iter() 35 .into_iter()
29 .filter(|i| show_closed || i.status == IssueStatus::Open) 36 .filter(|i| show_closed || i.status == IssueStatus::Open)
37 .map(|issue| {
38 let unread = count_unread(repo, &issue.id);
39 ListEntry { issue, unread }
40 })
30 .collect(); 41 .collect();
42 Ok(entries)
43 }
31 44
32 if filtered.is_empty() { 45 /// Count events after the last-seen mark. Returns None if never viewed.
33 println!("No issues found."); 46 fn count_unread(repo: &git2::Repository, id: &str) -> Option<usize> {
34 return Ok(()); 47 let seen_ref = format!("refs/collab/local/seen/issues/{}", id);
35 } 48 let seen_oid = repo.refname_to_id(&seen_ref).ok()?;
49 let ref_name = format!("refs/collab/issues/{}", id);
50 let tip = repo.refname_to_id(&ref_name).ok()?;
36 51
37 for issue in &filtered { 52 if seen_oid == tip {
38 let status = match issue.status { 53 return Some(0);
39 IssueStatus::Open => "open",
40 IssueStatus::Closed => "closed",
41 };
42 println!(
43 "{:.8} {:6} {} (by {})",
44 issue.id, status, issue.title, issue.author.name
45 );
46 } 54 }
47 Ok(()) 55
56 let mut revwalk = repo.revwalk().ok()?;
57 revwalk
58 .set_sorting(git2::Sort::TOPOLOGICAL)
59 .ok()?;
60 revwalk.push(tip).ok()?;
61 revwalk.hide(seen_oid).ok()?;
62 Some(revwalk.count())
48 } 63 }
49 64
50 pub fn show(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> { 65 pub fn show(repo: &Repository, id_prefix: &str) -> Result<IssueState, crate::error::Error> {
51 let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?; 66 let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?;
52 let issue = state::IssueState::from_ref(repo, &ref_name, &id)?; 67 let issue = IssueState::from_ref(repo, &ref_name, &id)?;
68 // Mark as read: store current tip as seen
69 let tip = repo.refname_to_id(&ref_name)?;
70 let seen_ref = format!("refs/collab/local/seen/issues/{}", id);
71 repo.reference(&seen_ref, tip, true, "mark seen")?;
72 Ok(issue)
73 }
53 74
54 let status = match issue.status { 75 pub fn label(repo: &Repository, id_prefix: &str, label: &str) -> Result<(), crate::error::Error> {
55 IssueStatus::Open => "open", 76 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
56 IssueStatus::Closed => "closed", 77 let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
78 let author = get_author(repo)?;
79 let event = Event {
80 timestamp: chrono::Utc::now().to_rfc3339(),
81 author,
82 action: Action::IssueLabel {
83 label: label.to_string(),
84 },
57 }; 85 };
58 println!("Issue {} [{}]", &issue.id[..8], status); 86 dag::append_event(repo, &ref_name, &event, &sk)?;
59 println!("Title: {}", issue.title); 87 Ok(())
60 println!("Author: {} <{}>", issue.author.name, issue.author.email); 88 }
61 println!("Created: {}", issue.created_at); 89
62 if let Some(ref reason) = issue.close_reason { 90 pub fn unlabel(repo: &Repository, id_prefix: &str, label: &str) -> Result<(), crate::error::Error> {
63 println!("Closed: {}", reason); 91 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
64 } 92 let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
65 if !issue.body.is_empty() { 93 let author = get_author(repo)?;
66 println!("\n{}", issue.body); 94 let event = Event {
67 } 95 timestamp: chrono::Utc::now().to_rfc3339(),
68 if !issue.comments.is_empty() { 96 author,
69 println!("\n--- Comments ---"); 97 action: Action::IssueUnlabel {
70 for c in &issue.comments { 98 label: label.to_string(),
71 println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body); 99 },
72 } 100 };
101 dag::append_event(repo, &ref_name, &event, &sk)?;
102 Ok(())
103 }
104
105 pub fn assign(
106 repo: &Repository,
107 id_prefix: &str,
108 assignee: &str,
109 ) -> Result<(), crate::error::Error> {
110 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
111 let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
112 let author = get_author(repo)?;
113 let event = Event {
114 timestamp: chrono::Utc::now().to_rfc3339(),
115 author,
116 action: Action::IssueAssign {
117 assignee: assignee.to_string(),
118 },
119 };
120 dag::append_event(repo, &ref_name, &event, &sk)?;
121 Ok(())
122 }
123
124 pub fn unassign(
125 repo: &Repository,
126 id_prefix: &str,
127 assignee: &str,
128 ) -> Result<(), crate::error::Error> {
129 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
130 let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
131 let author = get_author(repo)?;
132 let event = Event {
133 timestamp: chrono::Utc::now().to_rfc3339(),
134 author,
135 action: Action::IssueUnassign {
136 assignee: assignee.to_string(),
137 },
138 };
139 dag::append_event(repo, &ref_name, &event, &sk)?;
140 Ok(())
141 }
142
143 pub fn edit(
144 repo: &Repository,
145 id_prefix: &str,
146 title: Option<&str>,
147 body: Option<&str>,
148 ) -> Result<(), crate::error::Error> {
149 if title.is_none() && body.is_none() {
150 return Err(
151 git2::Error::from_str("at least one of --title or --body must be provided").into(),
152 );
73 } 153 }
154 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
155 let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
156 let author = get_author(repo)?;
157 let event = Event {
158 timestamp: chrono::Utc::now().to_rfc3339(),
159 author,
160 action: Action::IssueEdit {
161 title: title.map(|s| s.to_string()),
162 body: body.map(|s| s.to_string()),
163 },
164 };
165 dag::append_event(repo, &ref_name, &event, &sk)?;
74 Ok(()) 166 Ok(())
75 } 167 }
76 168
77 pub fn comment(repo: &Repository, id_prefix: &str, body: &str) -> Result<(), crate::error::Error> { 169 pub fn comment(repo: &Repository, id_prefix: &str, body: &str) -> Result<(), crate::error::Error> {
170 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
78 let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?; 171 let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
79 let author = get_author(repo)?; 172 let author = get_author(repo)?;
80 let event = Event { 173 let event = Event {
@@ -84,8 +177,7 @@ pub fn comment(repo: &Repository, id_prefix: &str, body: &str) -> Result<(), cra
84 body: body.to_string(), 177 body: body.to_string(),
85 }, 178 },
86 }; 179 };
87 dag::append_event(repo, &ref_name, &event)?; 180 dag::append_event(repo, &ref_name, &event, &sk)?;
88 println!("Comment added.");
89 Ok(()) 181 Ok(())
90 } 182 }
91 183
@@ -94,6 +186,7 @@ pub fn close(
94 id_prefix: &str, 186 id_prefix: &str,
95 reason: Option<&str>, 187 reason: Option<&str>,
96 ) -> Result<(), crate::error::Error> { 188 ) -> Result<(), crate::error::Error> {
189 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
97 let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?; 190 let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
98 let author = get_author(repo)?; 191 let author = get_author(repo)?;
99 let event = Event { 192 let event = Event {
@@ -103,12 +196,12 @@ pub fn close(
103 reason: reason.map(|s| s.to_string()), 196 reason: reason.map(|s| s.to_string()),
104 }, 197 },
105 }; 198 };
106 dag::append_event(repo, &ref_name, &event)?; 199 dag::append_event(repo, &ref_name, &event, &sk)?;
107 println!("Issue closed.");
108 Ok(()) 200 Ok(())
109 } 201 }
110 202
111 pub fn reopen(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> { 203 pub fn reopen(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> {
204 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
112 let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?; 205 let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
113 let author = get_author(repo)?; 206 let author = get_author(repo)?;
114 let event = Event { 207 let event = Event {
@@ -116,7 +209,6 @@ pub fn reopen(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Er
116 author, 209 author,
117 action: Action::IssueReopen, 210 action: Action::IssueReopen,
118 }; 211 };
119 dag::append_event(repo, &ref_name, &event)?; 212 dag::append_event(repo, &ref_name, &event, &sk)?;
120 println!("Issue reopened.");
121 Ok(()) 213 Ok(())
122 } 214 }
src/lib.rs
Old New
@@ -6,5 +6,267 @@ pub mod identity;
6 pub mod issue; 6 pub mod issue;
7 pub mod patch; 7 pub mod patch;
8 pub mod state; 8 pub mod state;
9 pub mod signing;
9 pub mod sync; 10 pub mod sync;
10 pub mod tui; 11 pub mod tui;
12
13 use base64::Engine;
14 use cli::{Commands, IssueCmd, PatchCmd};
15 use event::ReviewVerdict;
16 use git2::Repository;
17 use state::{IssueStatus, PatchStatus};
18
19 pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
20 match cli.command {
21 Commands::Init => sync::init(repo),
22 Commands::Issue(cmd) => match cmd {
23 IssueCmd::Open { title, body } => {
24 let id = issue::open(repo, &title, &body)?;
25 println!("Opened issue {:.8}", id);
26 Ok(())
27 }
28 IssueCmd::List { all } => {
29 let entries = issue::list(repo, all)?;
30 if entries.is_empty() {
31 println!("No issues found.");
32 } else {
33 for e in &entries {
34 let i = &e.issue;
35 let status = match i.status {
36 IssueStatus::Open => "open",
37 IssueStatus::Closed => "closed",
38 };
39 let labels = if i.labels.is_empty() {
40 String::new()
41 } else {
42 format!(" [{}]", i.labels.join(", "))
43 };
44 let unread = match e.unread {
45 Some(n) if n > 0 => format!(" ({} new)", n),
46 _ => String::new(),
47 };
48 println!(
49 "{:.8} {:6} {}{} (by {}){}",
50 i.id, status, i.title, labels, i.author.name, unread
51 );
52 }
53 }
54 Ok(())
55 }
56 IssueCmd::Show { id } => {
57 let i = issue::show(repo, &id)?;
58 let status = match i.status {
59 IssueStatus::Open => "open",
60 IssueStatus::Closed => "closed",
61 };
62 println!("Issue {} [{}]", &i.id[..8], status);
63 println!("Title: {}", i.title);
64 println!("Author: {} <{}>", i.author.name, i.author.email);
65 println!("Created: {}", i.created_at);
66 if !i.labels.is_empty() {
67 println!("Labels: {}", i.labels.join(", "));
68 }
69 if !i.assignees.is_empty() {
70 println!("Assignees: {}", i.assignees.join(", "));
71 }
72 if let Some(ref reason) = i.close_reason {
73 println!("Closed: {}", reason);
74 }
75 if !i.body.is_empty() {
76 println!("\n{}", i.body);
77 }
78 if !i.comments.is_empty() {
79 println!("\n--- Comments ---");
80 for c in &i.comments {
81 println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body);
82 }
83 }
84 Ok(())
85 }
86 IssueCmd::Label { id, label } => {
87 issue::label(repo, &id, &label)?;
88 println!("Label '{}' added.", label);
89 Ok(())
90 }
91 IssueCmd::Unlabel { id, label } => {
92 issue::unlabel(repo, &id, &label)?;
93 println!("Label '{}' removed.", label);
94 Ok(())
95 }
96 IssueCmd::Assign { id, name } => {
97 issue::assign(repo, &id, &name)?;
98 println!("Assigned to '{}'.", name);
99 Ok(())
100 }
101 IssueCmd::Unassign { id, name } => {
102 issue::unassign(repo, &id, &name)?;
103 println!("Unassigned '{}'.", name);
104 Ok(())
105 }
106 IssueCmd::Edit { id, title, body } => {
107 issue::edit(repo, &id, title.as_deref(), body.as_deref())?;
108 println!("Issue updated.");
109 Ok(())
110 }
111 IssueCmd::Comment { id, body } => {
112 issue::comment(repo, &id, &body)?;
113 println!("Comment added.");
114 Ok(())
115 }
116 IssueCmd::Close { id, reason } => {
117 issue::close(repo, &id, reason.as_deref())?;
118 println!("Issue closed.");
119 Ok(())
120 }
121 IssueCmd::Reopen { id } => {
122 issue::reopen(repo, &id)?;
123 println!("Issue reopened.");
124 Ok(())
125 }
126 },
127 Commands::Patch(cmd) => match cmd {
128 PatchCmd::Create {
129 title,
130 body,
131 base,
132 head,
133 fixes,
134 } => {
135 let id = patch::create(repo, &title, &body, &base, &head, fixes.as_deref())?;
136 println!("Created patch {:.8}", id);
137 Ok(())
138 }
139 PatchCmd::List { all } => {
140 let patches = patch::list(repo, all)?;
141 if patches.is_empty() {
142 println!("No patches found.");
143 } else {
144 for p in &patches {
145 let status = match p.status {
146 PatchStatus::Open => "open",
147 PatchStatus::Closed => "closed",
148 PatchStatus::Merged => "merged",
149 };
150 println!(
151 "{:.8} {:6} {} (by {})",
152 p.id, status, p.title, p.author.name
153 );
154 }
155 }
156 Ok(())
157 }
158 PatchCmd::Show { id } => {
159 let p = patch::show(repo, &id)?;
160 let status = match p.status {
161 PatchStatus::Open => "open",
162 PatchStatus::Closed => "closed",
163 PatchStatus::Merged => "merged",
164 };
165 println!("Patch {} [{}]", &p.id[..8], status);
166 println!("Title: {}", p.title);
167 println!("Author: {} <{}>", p.author.name, p.author.email);
168 println!("Base: {} Head: {:.8}", p.base_ref, p.head_commit);
169 println!("Created: {}", p.created_at);
170 if let Some(ref fixes) = p.fixes {
171 println!("Fixes: {:.8}", fixes);
172 }
173 if !p.body.is_empty() {
174 println!("\n{}", p.body);
175 }
176 if !p.reviews.is_empty() {
177 println!("\n--- Reviews ---");
178 for r in &p.reviews {
179 println!(
180 "\n{} ({:?}) - {}:\n{}",
181 r.author.name, r.verdict, r.timestamp, r.body
182 );
183 }
184 }
185 if !p.inline_comments.is_empty() {
186 println!("\n--- Inline Comments ---");
187 for c in &p.inline_comments {
188 println!(
189 "\n{} on {}:{} ({}):\n {}",
190 c.author.name, c.file, c.line, c.timestamp, c.body
191 );
192 }
193 }
194 if !p.comments.is_empty() {
195 println!("\n--- Comments ---");
196 for c in &p.comments {
197 println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body);
198 }
199 }
200 Ok(())
201 }
202 PatchCmd::Diff { id } => {
203 let diff = patch::diff(repo, &id)?;
204 if diff.is_empty() {
205 println!("No diff available (commits may be identical).");
206 } else {
207 print!("{}", diff);
208 }
209 Ok(())
210 }
211 PatchCmd::Comment {
212 id,
213 body,
214 file,
215 line,
216 } => {
217 patch::comment(repo, &id, &body, file.as_deref(), line)?;
218 println!("Comment added.");
219 Ok(())
220 }
221 PatchCmd::Review { id, verdict, body } => {
222 let v = match verdict.as_str() {
223 "approve" => ReviewVerdict::Approve,
224 "request-changes" => ReviewVerdict::RequestChanges,
225 "comment" => ReviewVerdict::Comment,
226 _ => {
227 return Err(git2::Error::from_str(
228 "verdict must be: approve, request-changes, or comment",
229 )
230 .into());
231 }
232 };
233 patch::review(repo, &id, v, &body)?;
234 println!("Review submitted.");
235 Ok(())
236 }
237 PatchCmd::Revise { id, head, body } => {
238 patch::revise(repo, &id, &head, body.as_deref())?;
239 println!("Patch revised.");
240 Ok(())
241 }
242 PatchCmd::Merge { id } => {
243 let p = patch::merge(repo, &id)?;
244 println!("Patch {:.8} merged into {}.", p.id, p.base_ref);
245 Ok(())
246 }
247 PatchCmd::Close { id, reason } => {
248 patch::close(repo, &id, reason.as_deref())?;
249 println!("Patch closed.");
250 Ok(())
251 }
252 },
253 Commands::Dashboard => tui::run(repo),
254 Commands::Sync { remote } => sync::sync(repo, &remote),
255 Commands::InitKey { force } => {
256 let config_dir = signing::signing_key_dir()?;
257 let sk_path = config_dir.join("signing-key");
258 if sk_path.exists() && !force {
259 return Err(error::Error::Signing(
260 "signing key already exists; use --force to overwrite".to_string(),
261 ));
262 }
263
264 let vk = signing::generate_keypair(&config_dir)?;
265 let pubkey_b64 =
266 base64::engine::general_purpose::STANDARD.encode(vk.to_bytes());
267 println!("Signing key generated.");
268 println!("Public key: {}", pubkey_b64);
269 Ok(())
270 }
271 }
272 }
src/main.rs
Old New
@@ -1,86 +1,21 @@
1 mod cli;
2 mod dag;
3 mod error;
4 mod event;
5 mod identity;
6 mod issue;
7 mod patch;
8 mod state;
9 mod sync;
10 mod tui;
11
12 use clap::Parser; 1 use clap::Parser;
13 use cli::{Cli, Commands, IssueCmd, PatchCmd};
14 use event::ReviewVerdict;
15 use git2::Repository; 2 use git2::Repository;
16 3
4 use git_collab::cli::Cli;
5
17 fn main() { 6 fn main() {
18 let cli = Cli::parse(); 7 let cli = Cli::parse();
19 8
20 let result = run(cli); 9 let repo = match Repository::open_from_env() {
21 if let Err(e) = result { 10 Ok(r) => r,
11 Err(e) => {
12 eprintln!("error: {}", e);
13 std::process::exit(1);
14 }
15 };
16
17 if let Err(e) = git_collab::run(cli, &repo) {
22 eprintln!("error: {}", e); 18 eprintln!("error: {}", e);
23 std::process::exit(1); 19 std::process::exit(1);
24 } 20 }
25 } 21 }
26
27 fn run(cli: Cli) -> Result<(), error::Error> {
28 let repo = Repository::open_from_env()?;
29
30 match cli.command {
31 Commands::Init => sync::init(&repo),
32 Commands::Issue(cmd) => match cmd {
33 IssueCmd::Open { title, body } => {
34 let id = issue::open(&repo, &title, &body)?;
35 println!("Opened issue {:.8}", id);
36 Ok(())
37 }
38 IssueCmd::List { all } => issue::list(&repo, all),
39 IssueCmd::Show { id } => issue::show(&repo, &id),
40 IssueCmd::Comment { id, body } => issue::comment(&repo, &id, &body),
41 IssueCmd::Close { id, reason } => issue::close(&repo, &id, reason.as_deref()),
42 IssueCmd::Reopen { id } => issue::reopen(&repo, &id),
43 },
44 Commands::Patch(cmd) => match cmd {
45 PatchCmd::Create {
46 title,
47 body,
48 base,
49 head,
50 } => {
51 let id = patch::create(&repo, &title, &body, &base, &head)?;
52 println!("Created patch {:.8}", id);
53 Ok(())
54 }
55 PatchCmd::List { all } => patch::list(&repo, all),
56 PatchCmd::Show { id } => patch::show(&repo, &id),
57 PatchCmd::Comment {
58 id,
59 body,
60 file,
61 line,
62 } => patch::comment(&repo, &id, &body, file.as_deref(), line),
63 PatchCmd::Review { id, verdict, body } => {
64 let v = match verdict.as_str() {
65 "approve" => ReviewVerdict::Approve,
66 "request-changes" => ReviewVerdict::RequestChanges,
67 "comment" => ReviewVerdict::Comment,
68 _ => {
69 return Err(git2::Error::from_str(
70 "verdict must be: approve, request-changes, or comment",
71 )
72 .into());
73 }
74 };
75 patch::review(&repo, &id, v, &body)
76 }
77 PatchCmd::Revise { id, head, body } => {
78 patch::revise(&repo, &id, &head, body.as_deref())
79 }
80 PatchCmd::Merge { id } => patch::merge(&repo, &id),
81 PatchCmd::Close { id, reason } => patch::close(&repo, &id, reason.as_deref()),
82 },
83 Commands::Dashboard => tui::run(&repo),
84 Commands::Sync { remote } => sync::sync(&repo, &remote),
85 }
86 }
src/patch.rs
Old New
@@ -1,9 +1,11 @@
1 use git2::Repository; 1 use git2::{DiffFormat, Repository};
2 2
3 use crate::dag; 3 use crate::dag;
4 use crate::error::Error;
4 use crate::event::{Action, Event, ReviewVerdict}; 5 use crate::event::{Action, Event, ReviewVerdict};
5 use crate::identity::get_author; 6 use crate::identity::get_author;
6 use crate::state::{self, PatchStatus}; 7 use crate::signing;
8 use crate::state::{self, PatchState, PatchStatus};
7 9
8 pub fn create( 10 pub fn create(
9 repo: &Repository, 11 repo: &Repository,
@@ -11,7 +13,9 @@ pub fn create(
11 body: &str, 13 body: &str,
12 base_ref: &str, 14 base_ref: &str,
13 head_commit: &str, 15 head_commit: &str,
16 fixes: Option<&str>,
14 ) -> Result<String, crate::error::Error> { 17 ) -> Result<String, crate::error::Error> {
18 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
15 let author = get_author(repo)?; 19 let author = get_author(repo)?;
16 let event = Event { 20 let event = Event {
17 timestamp: chrono::Utc::now().to_rfc3339(), 21 timestamp: chrono::Utc::now().to_rfc3339(),
@@ -21,83 +25,28 @@ pub fn create(
21 body: body.to_string(), 25 body: body.to_string(),
22 base_ref: base_ref.to_string(), 26 base_ref: base_ref.to_string(),
23 head_commit: head_commit.to_string(), 27 head_commit: head_commit.to_string(),
28 fixes: fixes.map(|s| s.to_string()),
24 }, 29 },
25 }; 30 };
26 let oid = dag::create_root_event(repo, &event)?; 31 let oid = dag::create_root_event(repo, &event, &sk)?;
27 let id = oid.to_string(); 32 let id = oid.to_string();
28 let ref_name = format!("refs/collab/patches/{}", id); 33 let ref_name = format!("refs/collab/patches/{}", id);
29 repo.reference(&ref_name, oid, false, "patch create")?; 34 repo.reference(&ref_name, oid, false, "patch create")?;
30 Ok(id) 35 Ok(id)
31 } 36 }
32 37
33 pub fn list(repo: &Repository, show_closed: bool) -> Result<(), crate::error::Error> { 38 pub fn list(repo: &Repository, show_closed: bool) -> Result<Vec<PatchState>, crate::error::Error> {
34 let patches = state::list_patches(repo)?; 39 let patches = state::list_patches(repo)?;
35 let filtered: Vec<_> = patches 40 let filtered = patches
36 .iter() 41 .into_iter()
37 .filter(|p| show_closed || p.status == PatchStatus::Open) 42 .filter(|p| show_closed || p.status == PatchStatus::Open)
38 .collect(); 43 .collect();
39 44 Ok(filtered)
40 if filtered.is_empty() {
41 println!("No patches found.");
42 return Ok(());
43 }
44
45 for p in &filtered {
46 let status = match p.status {
47 PatchStatus::Open => "open",
48 PatchStatus::Closed => "closed",
49 PatchStatus::Merged => "merged",
50 };
51 println!(
52 "{:.8} {:6} {} (by {})",
53 p.id, status, p.title, p.author.name
54 );
55 }
56 Ok(())
57 } 45 }
58 46
59 pub fn show(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> { 47 pub fn show(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::error::Error> {
60 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; 48 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
61 let p = state::PatchState::from_ref(repo, &ref_name, &id)?; 49 PatchState::from_ref(repo, &ref_name, &id)
62
63 let status = match p.status {
64 PatchStatus::Open => "open",
65 PatchStatus::Closed => "closed",
66 PatchStatus::Merged => "merged",
67 };
68 println!("Patch {} [{}]", &p.id[..8], status);
69 println!("Title: {}", p.title);
70 println!("Author: {} <{}>", p.author.name, p.author.email);
71 println!("Base: {} Head: {:.8}", p.base_ref, p.head_commit);
72 println!("Created: {}", p.created_at);
73 if !p.body.is_empty() {
74 println!("\n{}", p.body);
75 }
76 if !p.reviews.is_empty() {
77 println!("\n--- Reviews ---");
78 for r in &p.reviews {
79 println!(
80 "\n{} ({:?}) - {}:\n{}",
81 r.author.name, r.verdict, r.timestamp, r.body
82 );
83 }
84 }
85 if !p.inline_comments.is_empty() {
86 println!("\n--- Inline Comments ---");
87 for c in &p.inline_comments {
88 println!(
89 "\n{} on {}:{} ({}):\n {}",
90 c.author.name, c.file, c.line, c.timestamp, c.body
91 );
92 }
93 }
94 if !p.comments.is_empty() {
95 println!("\n--- Comments ---");
96 for c in &p.comments {
97 println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body);
98 }
99 }
100 Ok(())
101 } 50 }
102 51
103 pub fn comment( 52 pub fn comment(
@@ -107,6 +56,7 @@ pub fn comment(
107 file: Option<&str>, 56 file: Option<&str>,
108 line: Option<u32>, 57 line: Option<u32>,
109 ) -> Result<(), crate::error::Error> { 58 ) -> Result<(), crate::error::Error> {
59 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
110 let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?; 60 let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?;
111 let author = get_author(repo)?; 61 let author = get_author(repo)?;
112 62
@@ -132,8 +82,7 @@ pub fn comment(
132 author, 82 author,
133 action, 83 action,
134 }; 84 };
135 dag::append_event(repo, &ref_name, &event)?; 85 dag::append_event(repo, &ref_name, &event, &sk)?;
136 println!("Comment added.");
137 Ok(()) 86 Ok(())
138 } 87 }
139 88
@@ -143,6 +92,7 @@ pub fn review(
143 verdict: ReviewVerdict, 92 verdict: ReviewVerdict,
144 body: &str, 93 body: &str,
145 ) -> Result<(), crate::error::Error> { 94 ) -> Result<(), crate::error::Error> {
95 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
146 let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?; 96 let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?;
147 let author = get_author(repo)?; 97 let author = get_author(repo)?;
148 let event = Event { 98 let event = Event {
@@ -153,8 +103,7 @@ pub fn review(
153 body: body.to_string(), 103 body: body.to_string(),
154 }, 104 },
155 }; 105 };
156 dag::append_event(repo, &ref_name, &event)?; 106 dag::append_event(repo, &ref_name, &event, &sk)?;
157 println!("Review submitted.");
158 Ok(()) 107 Ok(())
159 } 108 }
160 109
@@ -164,6 +113,7 @@ pub fn revise(
164 head_commit: &str, 113 head_commit: &str,
165 body: Option<&str>, 114 body: Option<&str>,
166 ) -> Result<(), crate::error::Error> { 115 ) -> Result<(), crate::error::Error> {
116 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
167 let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?; 117 let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?;
168 let author = get_author(repo)?; 118 let author = get_author(repo)?;
169 let event = Event { 119 let event = Event {
@@ -174,14 +124,14 @@ pub fn revise(
174 head_commit: head_commit.to_string(), 124 head_commit: head_commit.to_string(),
175 }, 125 },
176 }; 126 };
177 dag::append_event(repo, &ref_name, &event)?; 127 dag::append_event(repo, &ref_name, &event, &sk)?;
178 println!("Patch revised.");
179 Ok(()) 128 Ok(())
180 } 129 }
181 130
182 pub fn merge(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> { 131 pub fn merge(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::error::Error> {
132 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
183 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; 133 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
184 let p = state::PatchState::from_ref(repo, &ref_name, &id)?; 134 let p = PatchState::from_ref(repo, &ref_name, &id)?;
185 135
186 if p.status != PatchStatus::Open { 136 if p.status != PatchStatus::Open {
187 return Err(git2::Error::from_str(&format!( 137 return Err(git2::Error::from_str(&format!(
@@ -237,12 +187,80 @@ pub fn merge(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Err
237 let author = get_author(repo)?; 187 let author = get_author(repo)?;
238 let event = Event { 188 let event = Event {
239 timestamp: chrono::Utc::now().to_rfc3339(), 189 timestamp: chrono::Utc::now().to_rfc3339(),
240 author, 190 author: author.clone(),
241 action: Action::PatchMerge, 191 action: Action::PatchMerge,
242 }; 192 };
243 dag::append_event(repo, &ref_name, &event)?; 193 dag::append_event(repo, &ref_name, &event, &sk)?;
244 println!("Patch {:.8} merged into {}.", id, p.base_ref); 194
245 Ok(()) 195 // Auto-close linked issue if present
196 if let Some(ref fixes_id) = p.fixes {
197 if let Ok((issue_ref, _)) = state::resolve_issue_ref(repo, fixes_id) {
198 let close_event = Event {
199 timestamp: chrono::Utc::now().to_rfc3339(),
200 author,
201 action: Action::IssueClose {
202 reason: Some(format!("Fixed by patch {:.8}", p.id)),
203 },
204 };
205 dag::append_event(repo, &issue_ref, &close_event, &sk)?;
206 }
207 }
208
209 Ok(p)
210 }
211
212 /// Generate a unified diff between a patch's base branch and head commit.
213 pub fn diff(repo: &Repository, id_prefix: &str) -> Result<String, Error> {
214 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
215 let p = PatchState::from_ref(repo, &ref_name, &id)?;
216 generate_diff(repo, &p)
217 }
218
219 /// Generate a diff string from a patch's base and head.
220 pub fn generate_diff(repo: &Repository, patch: &PatchState) -> Result<String, Error> {
221 let head_obj = repo
222 .revparse_single(&patch.head_commit)
223 .map_err(|e| Error::Cmd(format!("bad head ref: {}", e)))?;
224 let head_commit = head_obj
225 .into_commit()
226 .map_err(|_| Error::Cmd("head ref is not a commit".to_string()))?;
227 let head_tree = head_commit.tree()?;
228
229 let base_ref = format!("refs/heads/{}", patch.base_ref);
230 let base_tree = if let Ok(base_oid) = repo.refname_to_id(&base_ref) {
231 let base_commit = repo.find_commit(base_oid)?;
232 Some(base_commit.tree()?)
233 } else {
234 None
235 };
236
237 let git_diff = repo.diff_tree_to_tree(base_tree.as_ref(), Some(&head_tree), None)?;
238
239 let mut output = String::new();
240 let mut lines = 0usize;
241 git_diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
242 if lines >= 5000 {
243 return false;
244 }
245 let prefix = match line.origin() {
246 '+' => "+",
247 '-' => "-",
248 ' ' => " ",
249 _ => "",
250 };
251 output.push_str(prefix);
252 if let Ok(content) = std::str::from_utf8(line.content()) {
253 output.push_str(content);
254 }
255 lines += 1;
256 true
257 })?;
258
259 if lines >= 5000 {
260 output.push_str("\n[truncated at 5000 lines]");
261 }
262
263 Ok(output)
246 } 264 }
247 265
248 pub fn close( 266 pub fn close(
@@ -250,6 +268,7 @@ pub fn close(
250 id_prefix: &str, 268 id_prefix: &str,
251 reason: Option<&str>, 269 reason: Option<&str>,
252 ) -> Result<(), crate::error::Error> { 270 ) -> Result<(), crate::error::Error> {
271 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
253 let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?; 272 let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?;
254 let author = get_author(repo)?; 273 let author = get_author(repo)?;
255 let event = Event { 274 let event = Event {
@@ -259,7 +278,6 @@ pub fn close(
259 reason: reason.map(|s| s.to_string()), 278 reason: reason.map(|s| s.to_string()),
260 }, 279 },
261 }; 280 };
262 dag::append_event(repo, &ref_name, &event)?; 281 dag::append_event(repo, &ref_name, &event, &sk)?;
263 println!("Patch closed.");
264 Ok(()) 282 Ok(())
265 } 283 }
src/signing.rs
Old New
@@ -0,0 +1,317 @@
1 use std::fs;
2 use std::path::{Path, PathBuf};
3
4 use base64::engine::general_purpose::STANDARD;
5 use base64::Engine;
6 use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
7 use git2::Oid;
8 use rand_core::OsRng;
9 use serde::{Deserialize, Serialize};
10
11 use git2::{Repository, Sort};
12
13 use crate::error::Error;
14 use crate::event::Event;
15
16 /// Return the directory where signing keys are stored.
17 pub fn signing_key_dir() -> Result<PathBuf, Error> {
18 let config = dirs::config_dir().or_else(|| {
19 std::env::var("HOME")
20 .ok()
21 .map(|h| PathBuf::from(h).join(".config"))
22 });
23 match config {
24 Some(dir) => Ok(dir.join("git-collab")),
25 None => Err(Error::Signing(
26 "cannot determine config directory: HOME is not set".to_string(),
27 )),
28 }
29 }
30
31 /// Wrapper around Event that adds Ed25519 signature fields.
32 /// Serialized as the event.json blob in git commits.
33 #[derive(Debug, Clone, Serialize, Deserialize)]
34 pub struct SignedEvent {
35 #[serde(flatten)]
36 pub event: Event,
37 pub signature: String,
38 pub pubkey: String,
39 }
40
41 /// Result of verifying an event commit's signature.
42 #[derive(Debug, Clone, PartialEq)]
43 pub enum VerifyStatus {
44 /// Signature verified successfully against the embedded public key.
45 Valid,
46 /// Signature present but verification failed (tampered or wrong key).
47 Invalid,
48 /// No signature or pubkey field in event.json.
49 Missing,
50 }
51
52 /// Detailed verification result for a single commit.
53 #[derive(Debug, Clone)]
54 pub struct SignatureVerificationResult {
55 pub commit_id: Oid,
56 pub status: VerifyStatus,
57 pub pubkey: Option<String>,
58 pub error: Option<String>,
59 }
60
61 /// Generate an Ed25519 keypair and store it in `config_dir`.
62 ///
63 /// Creates `config_dir` (with 0o700 permissions on Unix) if it doesn't exist.
64 /// Writes the private key (base64) to `{config_dir}/signing-key` with 0o600
65 /// permissions and the public key (base64) to `{config_dir}/signing-key.pub`.
66 pub fn generate_keypair(config_dir: &Path) -> Result<VerifyingKey, Error> {
67 // Create config dir if needed
68 if !config_dir.exists() {
69 fs::create_dir_all(config_dir)?;
70 #[cfg(unix)]
71 {
72 use std::os::unix::fs::PermissionsExt;
73 fs::set_permissions(config_dir, fs::Permissions::from_mode(0o700))?;
74 }
75 }
76
77 let signing_key = SigningKey::generate(&mut OsRng);
78 let verifying_key = signing_key.verifying_key();
79
80 // Write private key
81 let sk_path = config_dir.join("signing-key");
82 let sk_b64 = STANDARD.encode(signing_key.to_bytes());
83 fs::write(&sk_path, &sk_b64)?;
84 #[cfg(unix)]
85 {
86 use std::os::unix::fs::PermissionsExt;
87 fs::set_permissions(&sk_path, fs::Permissions::from_mode(0o600))?;
88 }
89
90 // Write public key
91 let vk_path = config_dir.join("signing-key.pub");
92 let vk_b64 = STANDARD.encode(verifying_key.to_bytes());
93 fs::write(&vk_path, &vk_b64)?;
94
95 Ok(verifying_key)
96 }
97
98 /// Load the Ed25519 signing (private) key from `config_dir/signing-key`.
99 pub fn load_signing_key(config_dir: &Path) -> Result<SigningKey, Error> {
100 let path = config_dir.join("signing-key");
101 if !path.exists() {
102 return Err(Error::KeyNotFound);
103 }
104 #[cfg(unix)]
105 {
106 use std::os::unix::fs::PermissionsExt;
107 let mode = fs::metadata(&path)?.permissions().mode() & 0o777;
108 if mode & 0o077 != 0 {
109 eprintln!(
110 "warning: signing key {:?} has permissions {:04o} — should be 0600",
111 path, mode
112 );
113 }
114 }
115 let b64 = fs::read_to_string(&path)?;
116 let bytes = STANDARD
117 .decode(b64.trim())
118 .map_err(|e| Error::Signing(format!("invalid signing key base64: {}", e)))?;
119 let key_bytes: [u8; 32] = bytes
120 .try_into()
121 .map_err(|_| Error::Signing("signing key must be 32 bytes".to_string()))?;
122 Ok(SigningKey::from_bytes(&key_bytes))
123 }
124
125 /// Load the Ed25519 verifying (public) key from `config_dir/signing-key.pub`.
126 pub fn load_verifying_key(config_dir: &Path) -> Result<VerifyingKey, Error> {
127 let path = config_dir.join("signing-key.pub");
128 if !path.exists() {
129 return Err(Error::KeyNotFound);
130 }
131 let b64 = fs::read_to_string(&path)?;
132 let bytes = STANDARD
133 .decode(b64.trim())
134 .map_err(|e| Error::Verification(format!("invalid verifying key base64: {}", e)))?;
135 let key_bytes: [u8; 32] = bytes
136 .try_into()
137 .map_err(|_| Error::Verification("verifying key must be 32 bytes".to_string()))?;
138 VerifyingKey::from_bytes(&key_bytes)
139 .map_err(|e| Error::Verification(format!("invalid verifying key: {}", e)))
140 }
141
142 /// Serialize an Event to canonical JSON bytes.
143 ///
144 /// Uses `serde_json::Value` as an intermediate step. Since serde_json uses
145 /// BTreeMap-backed Map (no `preserve_order` feature), keys are sorted
146 /// alphabetically, ensuring deterministic output.
147 pub fn canonical_json(event: &Event) -> Result<Vec<u8>, Error> {
148 let value = serde_json::to_value(event)?;
149 let json = serde_json::to_string(&value)?;
150 Ok(json.into_bytes())
151 }
152
153 /// Sign an Event with the given signing key, producing a SignedEvent.
154 pub fn sign_event(event: &Event, signing_key: &SigningKey) -> Result<SignedEvent, Error> {
155 let canonical = canonical_json(event)?;
156 let signature = signing_key.sign(&canonical);
157 let verifying_key = signing_key.verifying_key();
158
159 Ok(SignedEvent {
160 event: event.clone(),
161 signature: STANDARD.encode(signature.to_bytes()),
162 pubkey: STANDARD.encode(verifying_key.to_bytes()),
163 })
164 }
165
166 /// Verify a SignedEvent's signature against its embedded public key.
167 ///
168 /// Returns `Missing` if signature or pubkey fields are empty,
169 /// `Valid` if the signature checks out, `Invalid` otherwise.
170 pub fn verify_signed_event(signed: &SignedEvent) -> Result<VerifyStatus, Error> {
171 if signed.signature.is_empty() || signed.pubkey.is_empty() {
172 return Ok(VerifyStatus::Missing);
173 }
174
175 let sig_bytes = match STANDARD.decode(&signed.signature) {
176 Ok(b) => b,
177 Err(_) => return Ok(VerifyStatus::Invalid),
178 };
179 let pubkey_bytes = match STANDARD.decode(&signed.pubkey) {
180 Ok(b) => b,
181 Err(_) => return Ok(VerifyStatus::Invalid),
182 };
183
184 let sig_array: [u8; 64] = match sig_bytes.try_into() {
185 Ok(a) => a,
186 Err(_) => return Ok(VerifyStatus::Invalid),
187 };
188 let key_array: [u8; 32] = match pubkey_bytes.try_into() {
189 Ok(a) => a,
190 Err(_) => return Ok(VerifyStatus::Invalid),
191 };
192
193 let signature = Signature::from_bytes(&sig_array);
194 let verifying_key = match VerifyingKey::from_bytes(&key_array) {
195 Ok(vk) => vk,
196 Err(_) => return Ok(VerifyStatus::Invalid),
197 };
198
199 let canonical = canonical_json(&signed.event)?;
200
201 match verifying_key.verify(&canonical, &signature) {
202 Ok(()) => Ok(VerifyStatus::Valid),
203 Err(_) => Ok(VerifyStatus::Invalid),
204 }
205 }
206
207 /// Walk the DAG for the given ref and verify every event commit's signature.
208 ///
209 /// For each commit, reads `event.json` from the tree:
210 /// - If it deserializes as a `SignedEvent`, calls `verify_signed_event()`.
211 /// - If it only deserializes as a plain `Event` (no signature/pubkey), marks as `Missing`.
212 ///
213 /// Returns one `SignatureVerificationResult` per commit.
214 pub fn verify_ref(
215 repo: &Repository,
216 ref_name: &str,
217 ) -> Result<Vec<SignatureVerificationResult>, Error> {
218 let tip = repo.refname_to_id(ref_name)?;
219 let mut revwalk = repo.revwalk()?;
220 revwalk.set_sorting(Sort::TOPOLOGICAL | Sort::REVERSE)?;
221 revwalk.push(tip)?;
222
223 let mut results = Vec::new();
224 for oid_result in revwalk {
225 let oid = oid_result?;
226 let commit = repo.find_commit(oid)?;
227 let tree = commit.tree()?;
228 let entry = tree
229 .get_name("event.json")
230 .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?;
231 let blob = repo.find_blob(entry.id())?;
232 let content = blob.content();
233
234 // Try to deserialize as SignedEvent first
235 if let Ok(signed) = serde_json::from_slice::<SignedEvent>(content) {
236 if signed.signature.is_empty() || signed.pubkey.is_empty() {
237 results.push(SignatureVerificationResult {
238 commit_id: oid,
239 status: VerifyStatus::Missing,
240 pubkey: None,
241 error: Some("missing signature".to_string()),
242 });
243 } else {
244 match verify_signed_event(&signed)? {
245 VerifyStatus::Valid => {
246 results.push(SignatureVerificationResult {
247 commit_id: oid,
248 status: VerifyStatus::Valid,
249 pubkey: Some(signed.pubkey),
250 error: None,
251 });
252 }
253 VerifyStatus::Invalid => {
254 results.push(SignatureVerificationResult {
255 commit_id: oid,
256 status: VerifyStatus::Invalid,
257 pubkey: Some(signed.pubkey),
258 error: Some("invalid signature".to_string()),
259 });
260 }
261 VerifyStatus::Missing => {
262 results.push(SignatureVerificationResult {
263 commit_id: oid,
264 status: VerifyStatus::Missing,
265 pubkey: None,
266 error: Some("missing signature".to_string()),
267 });
268 }
269 }
270 }
271 } else {
272 // Plain Event without signature fields
273 results.push(SignatureVerificationResult {
274 commit_id: oid,
275 status: VerifyStatus::Missing,
276 pubkey: None,
277 error: Some("missing signature".to_string()),
278 });
279 }
280 }
281
282 Ok(results)
283 }
284
285 #[cfg(test)]
286 mod tests {
287 use super::*;
288 use crate::event::{Action, Author};
289
290 #[test]
291 fn signed_event_flatten_round_trip() {
292 let event = Event {
293 timestamp: "2026-03-21T00:00:00Z".to_string(),
294 author: Author {
295 name: "Alice".to_string(),
296 email: "alice@example.com".to_string(),
297 },
298 action: Action::IssueOpen {
299 title: "Test".to_string(),
300 body: "Body".to_string(),
301 },
302 };
303 let signed = SignedEvent {
304 event,
305 signature: "dGVzdA==".to_string(),
306 pubkey: "cHVia2V5".to_string(),
307 };
308 let json = serde_json::to_string_pretty(&signed).unwrap();
309 let deserialized: SignedEvent = serde_json::from_str(&json).unwrap();
310 assert_eq!(deserialized.signature, "dGVzdA==");
311 assert_eq!(deserialized.pubkey, "cHVia2V5");
312 match deserialized.event.action {
313 Action::IssueOpen { ref title, .. } => assert_eq!(title, "Test"),
314 _ => panic!("Wrong action type after round-trip"),
315 }
316 }
317 }
src/state.rs
Old New
@@ -25,6 +25,8 @@ pub struct IssueState {
25 pub body: String, 25 pub body: String,
26 pub status: IssueStatus, 26 pub status: IssueStatus,
27 pub close_reason: Option<String>, 27 pub close_reason: Option<String>,
28 pub labels: Vec<String>,
29 pub assignees: Vec<String>,
28 pub comments: Vec<Comment>, 30 pub comments: Vec<Comment>,
29 pub created_at: String, 31 pub created_at: String,
30 pub author: Author, 32 pub author: Author,
@@ -63,6 +65,7 @@ pub struct PatchState {
63 pub status: PatchStatus, 65 pub status: PatchStatus,
64 pub base_ref: String, 66 pub base_ref: String,
65 pub head_commit: String, 67 pub head_commit: String,
68 pub fixes: Option<String>,
66 pub comments: Vec<Comment>, 69 pub comments: Vec<Comment>,
67 pub inline_comments: Vec<InlineComment>, 70 pub inline_comments: Vec<InlineComment>,
68 pub reviews: Vec<Review>, 71 pub reviews: Vec<Review>,
@@ -93,6 +96,8 @@ impl IssueState {
93 body, 96 body,
94 status: IssueStatus::Open, 97 status: IssueStatus::Open,
95 close_reason: None, 98 close_reason: None,
99 labels: Vec::new(),
100 assignees: Vec::new(),
96 comments: Vec::new(), 101 comments: Vec::new(),
97 created_at: event.timestamp.clone(), 102 created_at: event.timestamp.clone(),
98 author: event.author.clone(), 103 author: event.author.clone(),
@@ -117,6 +122,40 @@ impl IssueState {
117 } 122 }
118 } 123 }
119 } 124 }
125 Action::IssueEdit { title, body } => {
126 if let Some(ref mut s) = state {
127 if let Some(t) = title {
128 s.title = t;
129 }
130 if let Some(b) = body {
131 s.body = b;
132 }
133 }
134 }
135 Action::IssueLabel { label } => {
136 if let Some(ref mut s) = state {
137 if !s.labels.contains(&label) {
138 s.labels.push(label);
139 }
140 }
141 }
142 Action::IssueUnlabel { label } => {
143 if let Some(ref mut s) = state {
144 s.labels.retain(|l| l != &label);
145 }
146 }
147 Action::IssueAssign { assignee } => {
148 if let Some(ref mut s) = state {
149 if !s.assignees.contains(&assignee) {
150 s.assignees.push(assignee);
151 }
152 }
153 }
154 Action::IssueUnassign { assignee } => {
155 if let Some(ref mut s) = state {
156 s.assignees.retain(|a| a != &assignee);
157 }
158 }
120 Action::IssueReopen => { 159 Action::IssueReopen => {
121 if let Some(ref mut s) = state { 160 if let Some(ref mut s) = state {
122 if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) { 161 if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) {
@@ -152,6 +191,7 @@ impl PatchState {
152 body, 191 body,
153 base_ref, 192 base_ref,
154 head_commit, 193 head_commit,
194 fixes,
155 } => { 195 } => {
156 state = Some(PatchState { 196 state = Some(PatchState {
157 id: id.to_string(), 197 id: id.to_string(),
@@ -160,6 +200,7 @@ impl PatchState {
160 status: PatchStatus::Open, 200 status: PatchStatus::Open,
161 base_ref, 201 base_ref,
162 head_commit, 202 head_commit,
203 fixes,
163 comments: Vec::new(), 204 comments: Vec::new(),
164 inline_comments: Vec::new(), 205 inline_comments: Vec::new(),
165 reviews: Vec::new(), 206 reviews: Vec::new(),
@@ -231,67 +272,46 @@ impl PatchState {
231 } 272 }
232 } 273 }
233 274
234 /// List all issue refs and return their materialized state. 275 /// Enumerate all collab refs of a given kind, returning (ref_name, id) pairs.
235 pub fn list_issues(repo: &Repository) -> Result<Vec<IssueState>, crate::error::Error> { 276 fn collab_refs(
236 let mut issues = Vec::new(); 277 repo: &Repository,
237 let refs = repo.references_glob("refs/collab/issues/*")?; 278 kind: &str,
238 for r in refs { 279 ) -> Result<Vec<(String, String)>, crate::error::Error> {
239 let r = r?; 280 let prefix = format!("refs/collab/{}/", kind);
240 let ref_name = r.name().unwrap_or_default().to_string(); 281 let glob = format!("{}*", prefix);
241 let id = ref_name 282 let refs = repo.references_glob(&glob)?;
242 .strip_prefix("refs/collab/issues/") 283 let mut result = Vec::new();
243 .unwrap_or_default()
244 .to_string();
245 match IssueState::from_ref(repo, &ref_name, &id) {
246 Ok(state) => issues.push(state),
247 Err(_) => continue,
248 }
249 }
250 Ok(issues)
251 }
252
253 /// List all patch refs and return their materialized state.
254 pub fn list_patches(repo: &Repository) -> Result<Vec<PatchState>, crate::error::Error> {
255 let mut patches = Vec::new();
256 let refs = repo.references_glob("refs/collab/patches/*")?;
257 for r in refs { 284 for r in refs {
258 let r = r?; 285 let r = r?;
259 let ref_name = r.name().unwrap_or_default().to_string(); 286 let ref_name = r.name().unwrap_or_default().to_string();
260 let id = ref_name 287 let id = ref_name
261 .strip_prefix("refs/collab/patches/") 288 .strip_prefix(&prefix)
262 .unwrap_or_default() 289 .unwrap_or_default()
263 .to_string(); 290 .to_string();
264 match PatchState::from_ref(repo, &ref_name, &id) { 291 result.push((ref_name, id));
265 Ok(state) => patches.push(state),
266 Err(_) => continue,
267 }
268 } 292 }
269 Ok(patches) 293 Ok(result)
270 } 294 }
271 295
272 /// Resolve a short ID prefix to the full ref name. Returns (ref_name, id). 296 /// Resolve a short ID prefix to a full ref. Returns (ref_name, id).
273 pub fn resolve_issue_ref( 297 fn resolve_ref(
274 repo: &Repository, 298 repo: &Repository,
299 kind: &str,
300 singular: &str,
275 prefix: &str, 301 prefix: &str,
276 ) -> Result<(String, String), crate::error::Error> { 302 ) -> Result<(String, String), crate::error::Error> {
277 let refs = repo.references_glob("refs/collab/issues/*")?; 303 let matches: Vec<_> = collab_refs(repo, kind)?
278 let mut matches = Vec::new(); 304 .into_iter()
279 for r in refs { 305 .filter(|(_, id)| id.starts_with(prefix))
280 let r = r?; 306 .collect();
281 let ref_name = r.name().unwrap_or_default().to_string();
282 let id = ref_name
283 .strip_prefix("refs/collab/issues/")
284 .unwrap_or_default()
285 .to_string();
286 if id.starts_with(prefix) {
287 matches.push((ref_name, id));
288 }
289 }
290 match matches.len() { 307 match matches.len() {
291 0 => Err(git2::Error::from_str(&format!("no issue found matching '{}'", prefix)).into()), 308 0 => Err(
309 git2::Error::from_str(&format!("no {} found matching '{}'", singular, prefix)).into(),
310 ),
292 1 => Ok(matches.into_iter().next().unwrap()), 311 1 => Ok(matches.into_iter().next().unwrap()),
293 _ => Err(git2::Error::from_str(&format!( 312 _ => Err(git2::Error::from_str(&format!(
294 "ambiguous issue prefix '{}': {} matches", 313 "ambiguous {} prefix '{}': {} matches",
314 singular,
295 prefix, 315 prefix,
296 matches.len() 316 matches.len()
297 )) 317 ))
@@ -299,32 +319,36 @@ pub fn resolve_issue_ref(
299 } 319 }
300 } 320 }
301 321
302 /// Resolve a short ID prefix to the full patch ref name. 322 /// List all issue refs and return their materialized state.
323 pub fn list_issues(repo: &Repository) -> Result<Vec<IssueState>, crate::error::Error> {
324 let items = collab_refs(repo, "issues")?
325 .into_iter()
326 .filter_map(|(ref_name, id)| IssueState::from_ref(repo, &ref_name, &id).ok())
327 .collect();
328 Ok(items)
329 }
330
331 /// List all patch refs and return their materialized state.
332 pub fn list_patches(repo: &Repository) -> Result<Vec<PatchState>, crate::error::Error> {
333 let items = collab_refs(repo, "patches")?
334 .into_iter()
335 .filter_map(|(ref_name, id)| PatchState::from_ref(repo, &ref_name, &id).ok())
336 .collect();
337 Ok(items)
338 }
339
340 /// Resolve a short ID prefix to the full issue ref name. Returns (ref_name, id).
341 pub fn resolve_issue_ref(
342 repo: &Repository,
343 prefix: &str,
344 ) -> Result<(String, String), crate::error::Error> {
345 resolve_ref(repo, "issues", "issue", prefix)
346 }
347
348 /// Resolve a short ID prefix to the full patch ref name. Returns (ref_name, id).
303 pub fn resolve_patch_ref( 349 pub fn resolve_patch_ref(
304 repo: &Repository, 350 repo: &Repository,
305 prefix: &str, 351 prefix: &str,
306 ) -> Result<(String, String), crate::error::Error> { 352 ) -> Result<(String, String), crate::error::Error> {
307 let refs = repo.references_glob("refs/collab/patches/*")?; 353 resolve_ref(repo, "patches", "patch", prefix)
308 let mut matches = Vec::new();
309 for r in refs {
310 let r = r?;
311 let ref_name = r.name().unwrap_or_default().to_string();
312 let id = ref_name
313 .strip_prefix("refs/collab/patches/")
314 .unwrap_or_default()
315 .to_string();
316 if id.starts_with(prefix) {
317 matches.push((ref_name, id));
318 }
319 }
320 match matches.len() {
321 0 => Err(git2::Error::from_str(&format!("no patch found matching '{}'", prefix)).into()),
322 1 => Ok(matches.into_iter().next().unwrap()),
323 _ => Err(git2::Error::from_str(&format!(
324 "ambiguous patch prefix '{}': {} matches",
325 prefix,
326 matches.len()
327 ))
328 .into()),
329 }
330 } 354 }
src/sync.rs
Old New
@@ -5,6 +5,7 @@ use git2::Repository;
5 use crate::dag; 5 use crate::dag;
6 use crate::error::Error; 6 use crate::error::Error;
7 use crate::identity::get_author; 7 use crate::identity::get_author;
8 use crate::signing;
8 9
9 /// Add collab refspecs to all remotes. 10 /// Add collab refspecs to all remotes.
10 pub fn init(repo: &Repository) -> Result<(), Error> { 11 pub fn init(repo: &Repository) -> Result<(), Error> {
@@ -50,8 +51,9 @@ pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> {
50 // Step 2: Reconcile 51 // Step 2: Reconcile
51 // Re-open repo to see the fetched refs (git2 caches ref state) 52 // Re-open repo to see the fetched refs (git2 caches ref state)
52 let repo = Repository::open(repo.path())?; 53 let repo = Repository::open(repo.path())?;
53 reconcile_refs(&repo, "issues", &author)?; 54 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
54 reconcile_refs(&repo, "patches", &author)?; 55 reconcile_refs(&repo, "issues", &author, &sk)?;
56 reconcile_refs(&repo, "patches", &author, &sk)?;
55 57
56 // Step 3: Push collab refs using system git 58 // Step 3: Push collab refs using system git
57 println!("Pushing to '{}'...", remote_name); 59 println!("Pushing to '{}'...", remote_name);
@@ -109,6 +111,7 @@ fn reconcile_refs(
109 repo: &Repository, 111 repo: &Repository,
110 kind: &str, 112 kind: &str,
111 author: &crate::event::Author, 113 author: &crate::event::Author,
114 signing_key: &ed25519_dalek::SigningKey,
112 ) -> Result<(), Error> { 115 ) -> Result<(), Error> {
113 let sync_prefix = format!("refs/collab/sync/{}/", kind); 116 let sync_prefix = format!("refs/collab/sync/{}/", kind);
114 let sync_refs: Vec<(String, String)> = { 117 let sync_refs: Vec<(String, String)> = {
@@ -123,9 +126,38 @@ fn reconcile_refs(
123 }; 126 };
124 127
125 for (remote_ref, id) in &sync_refs { 128 for (remote_ref, id) in &sync_refs {
129 // Verify all commits on the remote ref before reconciling
130 match signing::verify_ref(repo, remote_ref) {
131 Ok(results) => {
132 let failures: Vec<_> = results
133 .iter()
134 .filter(|r| r.status != signing::VerifyStatus::Valid)
135 .collect();
136 if !failures.is_empty() {
137 for f in &failures {
138 eprintln!(
139 " Rejecting {} {:.8}: commit {} — {}",
140 kind,
141 id,
142 f.commit_id,
143 f.error.as_deref().unwrap_or("unknown error")
144 );
145 }
146 continue;
147 }
148 }
149 Err(e) => {
150 eprintln!(
151 " Failed to verify {} {:.8}: {}",
152 kind, id, e
153 );
154 continue;
155 }
156 }
157
126 let local_ref = format!("refs/collab/{}/{}", kind, id); 158 let local_ref = format!("refs/collab/{}/{}", kind, id);
127 if repo.refname_to_id(&local_ref).is_ok() { 159 if repo.refname_to_id(&local_ref).is_ok() {
128 match dag::reconcile(repo, &local_ref, remote_ref, author) { 160 match dag::reconcile(repo, &local_ref, remote_ref, author, signing_key) {
129 Ok(_) => println!(" Reconciled {} {:.8}", kind, id), 161 Ok(_) => println!(" Reconciled {} {:.8}", kind, id),
130 Err(e) => eprintln!(" Failed to reconcile {} {:.8}: {}", kind, id, e), 162 Err(e) => eprintln!(" Failed to reconcile {} {:.8}: {}", kind, id, e),
131 } 163 }
src/tui.rs
Old New
@@ -5,11 +5,12 @@ use std::time::Duration;
5 use crossterm::event::{self, Event, KeyCode, KeyModifiers}; 5 use crossterm::event::{self, Event, KeyCode, KeyModifiers};
6 use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; 6 use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
7 use crossterm::ExecutableCommand; 7 use crossterm::ExecutableCommand;
8 use git2::{DiffFormat, Repository}; 8 use git2::Repository;
9 use ratatui::prelude::*; 9 use ratatui::prelude::*;
10 use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap}; 10 use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};
11 11
12 use crate::error::Error; 12 use crate::error::Error;
13 use crate::patch as patch_mod;
13 use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; 14 use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus};
14 15
15 #[derive(PartialEq)] 16 #[derive(PartialEq)]
@@ -151,68 +152,6 @@ impl App {
151 } 152 }
152 } 153 }
153 154
154 fn generate_diff(repo: &Repository, patch: &PatchState) -> String {
155 let result = (|| -> Result<String, Error> {
156 let head_obj = repo
157 .revparse_single(&patch.head_commit)
158 .map_err(|e| Error::Cmd(format!("bad head ref: {}", e)))?;
159 let head_commit = head_obj
160 .into_commit()
161 .map_err(|_| Error::Cmd("head ref is not a commit".to_string()))?;
162 let head_tree = head_commit.tree()?;
163
164 let base_ref = format!("refs/heads/{}", patch.base_ref);
165 let base_tree = if let Ok(base_oid) = repo.refname_to_id(&base_ref) {
166 let base_commit = repo.find_commit(base_oid)?;
167 Some(base_commit.tree()?)
168 } else {
169 None
170 };
171
172 let diff = repo.diff_tree_to_tree(base_tree.as_ref(), Some(&head_tree), None)?;
173
174 let mut output = String::new();
175 let mut lines = 0usize;
176 diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
177 if lines >= 5000 {
178 return false;
179 }
180 let prefix = match line.origin() {
181 '+' => "+",
182 '-' => "-",
183 ' ' => " ",
184 'H' | 'F' => "",
185 _ => "",
186 };
187 if !prefix.is_empty() || matches!(line.origin(), 'H' | 'F') {
188 output.push_str(prefix);
189 }
190 if let Ok(content) = std::str::from_utf8(line.content()) {
191 output.push_str(content);
192 }
193 lines += 1;
194 true
195 })?;
196
197 if lines >= 5000 {
198 output.push_str("\n[truncated at 5000 lines]");
199 }
200
201 Ok(output)
202 })();
203
204 match result {
205 Ok(diff) => {
206 if diff.is_empty() {
207 "No diff available (commits may be identical)".to_string()
208 } else {
209 diff
210 }
211 }
212 Err(e) => format!("Diff unavailable: {}", e),
213 }
214 }
215
216 pub fn run(repo: &Repository) -> Result<(), Error> { 155 pub fn run(repo: &Repository) -> Result<(), Error> {
217 let issues = state::list_issues(repo)?; 156 let issues = state::list_issues(repo)?;
218 let patches = state::list_patches(repo)?; 157 let patches = state::list_patches(repo)?;
@@ -244,7 +183,13 @@ fn run_loop(
244 if let Some(patch) = visible.get(idx) { 183 if let Some(patch) = visible.get(idx) {
245 if !app.diff_cache.contains_key(&patch.id) { 184 if !app.diff_cache.contains_key(&patch.id) {
246 let id = patch.id.clone(); 185 let id = patch.id.clone();
247 let diff = generate_diff(repo, patch); 186 let diff = match patch_mod::generate_diff(repo, patch) {
187 Ok(d) if d.is_empty() => {
188 "No diff available (commits may be identical)".to_string()
189 }
190 Ok(d) => d,
191 Err(e) => format!("Diff unavailable: {}", e),
192 };
248 app.diff_cache.insert(id, diff); 193 app.diff_cache.insert(id, diff);
249 } 194 }
250 } 195 }
tests/cli_test.rs
Old New
@@ -0,0 +1,865 @@
1 mod common;
2
3 use common::TestRepo;
4
5 // ===========================================================================
6 // Issue commands
7 // ===========================================================================
8
9 #[test]
10 fn test_issue_open_and_show() {
11 let repo = TestRepo::new("Alice", "alice@example.com");
12
13 let out = repo.run_ok(&["issue", "open", "-t", "My first bug"]);
14 assert!(out.starts_with("Opened issue "));
15 let id = out.trim().strip_prefix("Opened issue ").unwrap();
16 assert_eq!(id.len(), 8, "should print 8-char short ID");
17
18 let out = repo.run_ok(&["issue", "show", id]);
19 assert!(out.contains("My first bug"));
20 assert!(out.contains("[open]"));
21 assert!(out.contains("Alice"));
22 }
23
24 #[test]
25 fn test_issue_open_with_body() {
26 let repo = TestRepo::new("Alice", "alice@example.com");
27
28 let out = repo.run_ok(&["issue", "open", "-t", "Bug", "-b", "Steps to reproduce..."]);
29 let id = out.trim().strip_prefix("Opened issue ").unwrap();
30
31 let out = repo.run_ok(&["issue", "show", id]);
32 assert!(out.contains("Steps to reproduce..."));
33 }
34
35 #[test]
36 fn test_issue_list_filters_closed() {
37 let repo = TestRepo::new("Alice", "alice@example.com");
38
39 repo.issue_open("Open bug");
40 let closed_id = repo.issue_open("Closed bug");
41 repo.run_ok(&["issue", "close", &closed_id]);
42
43 let out = repo.run_ok(&["issue", "list"]);
44 assert!(out.contains("Open bug"));
45 assert!(!out.contains("Closed bug"));
46
47 let out = repo.run_ok(&["issue", "list", "--all"]);
48 assert!(out.contains("Open bug"));
49 assert!(out.contains("Closed bug"));
50 assert!(out.contains("closed"));
51 }
52
53 #[test]
54 fn test_issue_comment() {
55 let repo = TestRepo::new("Alice", "alice@example.com");
56 let id = repo.issue_open("Discussion");
57
58 let out = repo.run_ok(&["issue", "comment", &id, "-b", "First thought"]);
59 assert!(out.contains("Comment added"));
60
61 let out = repo.run_ok(&["issue", "show", &id]);
62 assert!(out.contains("First thought"));
63 assert!(out.contains("Comments"));
64 }
65
66 #[test]
67 fn test_issue_close_with_reason() {
68 let repo = TestRepo::new("Alice", "alice@example.com");
69 let id = repo.issue_open("Will close");
70
71 repo.run_ok(&["issue", "close", &id, "-r", "Duplicate of #42"]);
72
73 let out = repo.run_ok(&["issue", "show", &id]);
74 assert!(out.contains("[closed]"));
75 assert!(out.contains("Duplicate of #42"));
76 }
77
78 #[test]
79 fn test_issue_close_without_reason() {
80 let repo = TestRepo::new("Alice", "alice@example.com");
81 let id = repo.issue_open("Close silently");
82
83 let out = repo.run_ok(&["issue", "close", &id]);
84 assert!(out.contains("Issue closed"));
85
86 let out = repo.run_ok(&["issue", "show", &id]);
87 assert!(out.contains("[closed]"));
88 }
89
90 #[test]
91 fn test_issue_reopen() {
92 let repo = TestRepo::new("Alice", "alice@example.com");
93 let id = repo.issue_open("Reopen me");
94 repo.run_ok(&["issue", "close", &id]);
95
96 let out = repo.run_ok(&["issue", "reopen", &id]);
97 assert!(out.contains("Issue reopened"));
98
99 let out = repo.run_ok(&["issue", "show", &id]);
100 assert!(out.contains("[open]"));
101 }
102
103 #[test]
104 fn test_issue_prefix_resolution() {
105 let repo = TestRepo::new("Alice", "alice@example.com");
106 let id = repo.issue_open("Prefix test");
107
108 // Use first 4 chars as prefix
109 let short = &id[..4];
110 let out = repo.run_ok(&["issue", "show", short]);
111 assert!(out.contains("Prefix test"));
112 }
113
114 #[test]
115 fn test_issue_nonexistent_id() {
116 let repo = TestRepo::new("Alice", "alice@example.com");
117
118 let err = repo.run_err(&["issue", "show", "deadbeef"]);
119 assert!(err.contains("no issue found"));
120 }
121
122 #[test]
123 fn test_issue_list_empty() {
124 let repo = TestRepo::new("Alice", "alice@example.com");
125
126 let out = repo.run_ok(&["issue", "list"]);
127 assert!(out.contains("No issues found"));
128 }
129
130 #[test]
131 fn test_multiple_issues_independent() {
132 let repo = TestRepo::new("Alice", "alice@example.com");
133
134 let id1 = repo.issue_open("Issue one");
135 let id2 = repo.issue_open("Issue two");
136
137 repo.run_ok(&["issue", "comment", &id1, "-b", "Comment on one"]);
138 repo.run_ok(&["issue", "close", &id2]);
139
140 let out = repo.run_ok(&["issue", "show", &id1]);
141 assert!(out.contains("[open]"));
142 assert!(out.contains("Comment on one"));
143
144 let out = repo.run_ok(&["issue", "show", &id2]);
145 assert!(out.contains("[closed]"));
146 assert!(!out.contains("Comment on one"));
147 }
148
149 // ===========================================================================
150 // Issue edit
151 // ===========================================================================
152
153 #[test]
154 fn test_issue_edit_title() {
155 let repo = TestRepo::new("Alice", "alice@example.com");
156 let id = repo.issue_open("Old title");
157
158 let out = repo.run_ok(&["issue", "edit", &id, "-t", "New title"]);
159 assert!(out.contains("Issue updated"));
160
161 let out = repo.run_ok(&["issue", "show", &id]);
162 assert!(out.contains("New title"));
163 assert!(!out.contains("Old title"));
164 }
165
166 #[test]
167 fn test_issue_edit_body() {
168 let repo = TestRepo::new("Alice", "alice@example.com");
169 let out = repo.run_ok(&["issue", "open", "-t", "Bug", "-b", "Old body"]);
170 let id = out.trim().strip_prefix("Opened issue ").unwrap();
171
172 repo.run_ok(&["issue", "edit", id, "-b", "New body with details"]);
173
174 let out = repo.run_ok(&["issue", "show", id]);
175 assert!(out.contains("New body with details"));
176 assert!(!out.contains("Old body"));
177 }
178
179 #[test]
180 fn test_issue_edit_title_and_body() {
181 let repo = TestRepo::new("Alice", "alice@example.com");
182 let out = repo.run_ok(&["issue", "open", "-t", "Original", "-b", "Original body"]);
183 let id = out.trim().strip_prefix("Opened issue ").unwrap();
184
185 repo.run_ok(&["issue", "edit", id, "-t", "Updated", "-b", "Updated body"]);
186
187 let out = repo.run_ok(&["issue", "show", id]);
188 assert!(out.contains("Updated"));
189 assert!(out.contains("Updated body"));
190 }
191
192 #[test]
193 fn test_issue_edit_preserves_comments() {
194 let repo = TestRepo::new("Alice", "alice@example.com");
195 let id = repo.issue_open("Will edit");
196
197 repo.run_ok(&["issue", "comment", &id, "-b", "A comment before edit"]);
198 repo.run_ok(&["issue", "edit", &id, "-t", "Edited title"]);
199
200 let out = repo.run_ok(&["issue", "show", &id]);
201 assert!(out.contains("Edited title"));
202 assert!(out.contains("A comment before edit"));
203 }
204
205 #[test]
206 fn test_issue_edit_requires_title_or_body() {
207 let repo = TestRepo::new("Alice", "alice@example.com");
208 let id = repo.issue_open("No change");
209
210 let err = repo.run_err(&["issue", "edit", &id]);
211 assert!(
212 err.contains("--title") || err.contains("--body") || err.contains("at least one"),
213 "should require at least --title or --body, got: {}",
214 err
215 );
216 }
217
218 // ===========================================================================
219 // Issue labels
220 // ===========================================================================
221
222 #[test]
223 fn test_issue_label_and_show() {
224 let repo = TestRepo::new("Alice", "alice@example.com");
225 let id = repo.issue_open("Labeled issue");
226
227 let out = repo.run_ok(&["issue", "label", &id, "bug"]);
228 assert!(out.contains("Label") && out.contains("added"));
229
230 let out = repo.run_ok(&["issue", "show", &id]);
231 assert!(out.contains("bug"));
232 }
233
234 #[test]
235 fn test_issue_multiple_labels() {
236 let repo = TestRepo::new("Alice", "alice@example.com");
237 let id = repo.issue_open("Multi-label issue");
238
239 repo.run_ok(&["issue", "label", &id, "bug"]);
240 repo.run_ok(&["issue", "label", &id, "priority"]);
241
242 let out = repo.run_ok(&["issue", "show", &id]);
243 assert!(out.contains("bug"));
244 assert!(out.contains("priority"));
245 }
246
247 #[test]
248 fn test_issue_unlabel() {
249 let repo = TestRepo::new("Alice", "alice@example.com");
250 let id = repo.issue_open("Remove label");
251
252 repo.run_ok(&["issue", "label", &id, "bug"]);
253 repo.run_ok(&["issue", "label", &id, "wontfix"]);
254 repo.run_ok(&["issue", "unlabel", &id, "bug"]);
255
256 let out = repo.run_ok(&["issue", "show", &id]);
257 assert!(!out.contains("bug"));
258 assert!(out.contains("wontfix"));
259 }
260
261 #[test]
262 fn test_issue_label_shown_in_list() {
263 let repo = TestRepo::new("Alice", "alice@example.com");
264 let id = repo.issue_open("Listed with label");
265
266 repo.run_ok(&["issue", "label", &id, "enhancement"]);
267
268 let out = repo.run_ok(&["issue", "list"]);
269 assert!(out.contains("enhancement"));
270 }
271
272 // ===========================================================================
273 // Issue assignees
274 // ===========================================================================
275
276 #[test]
277 fn test_issue_assign_and_show() {
278 let repo = TestRepo::new("Alice", "alice@example.com");
279 let id = repo.issue_open("Assigned issue");
280
281 let out = repo.run_ok(&["issue", "assign", &id, "Bob"]);
282 assert!(out.contains("Assigned"));
283
284 let out = repo.run_ok(&["issue", "show", &id]);
285 assert!(out.contains("Bob"));
286 assert!(out.contains("Assignee"));
287 }
288
289 #[test]
290 fn test_issue_unassign() {
291 let repo = TestRepo::new("Alice", "alice@example.com");
292 let id = repo.issue_open("Unassign test");
293
294 repo.run_ok(&["issue", "assign", &id, "Bob"]);
295 repo.run_ok(&["issue", "unassign", &id, "Bob"]);
296
297 let out = repo.run_ok(&["issue", "show", &id]);
298 assert!(!out.contains("Bob") || !out.contains("Assignee"));
299 }
300
301 // ===========================================================================
302 // Patch commands
303 // ===========================================================================
304
305 #[test]
306 fn test_patch_create_and_show() {
307 let repo = TestRepo::new("Alice", "alice@example.com");
308 repo.commit_file("hello.txt", "hello world", "add hello");
309
310 let id = repo.patch_create("Add hello");
311
312 let out = repo.run_ok(&["patch", "show", &id]);
313 assert!(out.contains("Add hello"));
314 assert!(out.contains("[open]"));
315 assert!(out.contains("Alice"));
316 }
317
318 #[test]
319 fn test_patch_list_filters_by_status() {
320 let repo = TestRepo::new("Alice", "alice@example.com");
321
322 repo.patch_create("Open patch");
323 let closed_id = repo.patch_create("Closed patch");
324 repo.run_ok(&["patch", "close", &closed_id]);
325
326 let out = repo.run_ok(&["patch", "list"]);
327 assert!(out.contains("Open patch"));
328 assert!(!out.contains("Closed patch"));
329
330 let out = repo.run_ok(&["patch", "list", "--all"]);
331 assert!(out.contains("Open patch"));
332 assert!(out.contains("Closed patch"));
333 }
334
335 #[test]
336 fn test_patch_comment() {
337 let repo = TestRepo::new("Alice", "alice@example.com");
338 let id = repo.patch_create("Review me");
339
340 let out = repo.run_ok(&["patch", "comment", &id, "-b", "Looks good overall"]);
341 assert!(out.contains("Comment added"));
342
343 let out = repo.run_ok(&["patch", "show", &id]);
344 assert!(out.contains("Looks good overall"));
345 assert!(out.contains("Comments"));
346 }
347
348 #[test]
349 fn test_patch_inline_comment() {
350 let repo = TestRepo::new("Alice", "alice@example.com");
351 let id = repo.patch_create("Review me");
352
353 repo.run_ok(&[
354 "patch",
355 "comment",
356 &id,
357 "-b",
358 "Use const here",
359 "-f",
360 "src/main.rs",
361 "-l",
362 "42",
363 ]);
364
365 let out = repo.run_ok(&["patch", "show", &id]);
366 assert!(out.contains("Use const here"));
367 assert!(out.contains("src/main.rs"));
368 assert!(out.contains("42"));
369 assert!(out.contains("Inline Comments"));
370 }
371
372 #[test]
373 fn test_patch_inline_comment_requires_both_file_and_line() {
374 let repo = TestRepo::new("Alice", "alice@example.com");
375 let id = repo.patch_create("Review me");
376
377 // --file without --line
378 let err = repo.run_err(&["patch", "comment", &id, "-b", "nope", "-f", "foo.rs"]);
379 assert!(err.contains("--file and --line must both be provided"));
380
381 // --line without --file
382 let err = repo.run_err(&["patch", "comment", &id, "-b", "nope", "-l", "10"]);
383 assert!(err.contains("--file and --line must both be provided"));
384 }
385
386 #[test]
387 fn test_patch_review_approve() {
388 let repo = TestRepo::new("Alice", "alice@example.com");
389 let id = repo.patch_create("Feature X");
390
391 let out = repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "LGTM!"]);
392 assert!(out.contains("Review submitted"));
393
394 let out = repo.run_ok(&["patch", "show", &id]);
395 assert!(out.contains("LGTM!"));
396 assert!(out.contains("Approve"));
397 assert!(out.contains("Reviews"));
398 }
399
400 #[test]
401 fn test_patch_review_request_changes() {
402 let repo = TestRepo::new("Alice", "alice@example.com");
403 let id = repo.patch_create("Feature Y");
404
405 repo.run_ok(&[
406 "patch",
407 "review",
408 &id,
409 "-v",
410 "request-changes",
411 "-b",
412 "Needs error handling",
413 ]);
414
415 let out = repo.run_ok(&["patch", "show", &id]);
416 assert!(out.contains("RequestChanges"));
417 assert!(out.contains("Needs error handling"));
418 }
419
420 #[test]
421 fn test_patch_review_invalid_verdict() {
422 let repo = TestRepo::new("Alice", "alice@example.com");
423 let id = repo.patch_create("Feature Z");
424
425 let err = repo.run_err(&["patch", "review", &id, "-v", "yolo", "-b", "whatever"]);
426 assert!(err.contains("verdict must be"));
427 }
428
429 #[test]
430 fn test_patch_revise() {
431 let repo = TestRepo::new("Alice", "alice@example.com");
432 let id = repo.patch_create("WIP feature");
433
434 let new_head = repo.commit_file("v2.txt", "v2", "version 2");
435 let out = repo.run_ok(&[
436 "patch",
437 "revise",
438 &id,
439 "--head",
440 &new_head,
441 "-b",
442 "Updated implementation",
443 ]);
444 assert!(out.contains("Patch revised"));
445
446 let out = repo.run_ok(&["patch", "show", &id]);
447 assert!(out.contains(&new_head[..8]));
448 assert!(out.contains("Updated implementation"));
449 }
450
451 #[test]
452 fn test_patch_diff() {
453 let repo = TestRepo::new("Alice", "alice@example.com");
454
455 // Create a feature branch with a new file
456 repo.git(&["checkout", "-b", "feature"]);
457 repo.commit_file(
458 "hello.rs",
459 "fn main() {\n println!(\"hello\");\n}\n",
460 "add hello",
461 );
462 let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
463 repo.git(&["checkout", "main"]);
464
465 let out = repo.run_ok(&["patch", "create", "-t", "Add hello", "--head", &head]);
466 let id = out.trim().strip_prefix("Created patch ").unwrap();
467
468 let out = repo.run_ok(&["patch", "diff", id]);
469 assert!(out.contains("hello.rs"), "should show filename");
470 assert!(out.contains("fn main()"), "should show added code");
471 assert!(out.contains("+"), "should show + for additions");
472 }
473
474 #[test]
475 fn test_patch_diff_no_changes() {
476 let repo = TestRepo::new("Alice", "alice@example.com");
477 // Patch pointing at same commit as main — no diff
478 let id = repo.patch_create("No diff patch");
479
480 let out = repo.run_ok(&["patch", "diff", &id]);
481 assert!(
482 out.contains("No diff") || out.contains("identical"),
483 "should indicate no diff, got: {}",
484 out
485 );
486 }
487
488 #[test]
489 fn test_patch_close() {
490 let repo = TestRepo::new("Alice", "alice@example.com");
491 let id = repo.patch_create("Will close");
492
493 let out = repo.run_ok(&["patch", "close", &id, "-r", "Superseded"]);
494 assert!(out.contains("Patch closed"));
495
496 let out = repo.run_ok(&["patch", "show", &id]);
497 assert!(out.contains("[closed]"));
498 }
499
500 #[test]
501 fn test_patch_merge_fast_forward() {
502 let repo = TestRepo::new("Alice", "alice@example.com");
503
504 // Create a feature branch ahead of main
505 repo.git(&["checkout", "-b", "feature"]);
506 let head = repo.commit_file("feature.txt", "new feature", "add feature");
507 repo.git(&["checkout", "main"]);
508
509 // Create patch pointing at the feature commit
510 let out = repo.run_ok(&["patch", "create", "-t", "Add feature", "--head", &head]);
511 let id = out.trim().strip_prefix("Created patch ").unwrap();
512
513 let out = repo.run_ok(&["patch", "merge", id]);
514 assert!(out.contains("merged into main"));
515
516 // Main should now point at the feature commit
517 let main_head = repo.git(&["rev-parse", "main"]).trim().to_string();
518 assert_eq!(main_head, head);
519
520 let out = repo.run_ok(&["patch", "show", id]);
521 assert!(out.contains("[merged]"));
522 }
523
524 #[test]
525 fn test_patch_cannot_merge_closed() {
526 let repo = TestRepo::new("Alice", "alice@example.com");
527
528 repo.git(&["checkout", "-b", "feature"]);
529 let head = repo.commit_file("f.txt", "f", "feature commit");
530 repo.git(&["checkout", "main"]);
531
532 let out = repo.run_ok(&["patch", "create", "-t", "Will close", "--head", &head]);
533 let id = out.trim().strip_prefix("Created patch ").unwrap();
534
535 repo.run_ok(&["patch", "close", id]);
536
537 let err = repo.run_err(&["patch", "merge", id]);
538 assert!(err.contains("can only merge open patches"));
539 }
540
541 #[test]
542 fn test_patch_list_empty() {
543 let repo = TestRepo::new("Alice", "alice@example.com");
544
545 let out = repo.run_ok(&["patch", "list"]);
546 assert!(out.contains("No patches found"));
547 }
548
549 // ===========================================================================
550 // Cross-references (patch --fixes issue)
551 // ===========================================================================
552
553 #[test]
554 fn test_patch_create_with_fixes() {
555 let repo = TestRepo::new("Alice", "alice@example.com");
556
557 let issue_id = repo.issue_open("Login bug");
558
559 repo.git(&["checkout", "-b", "fix"]);
560 let head = repo.commit_file("fix.rs", "fixed", "fix login");
561 repo.git(&["checkout", "main"]);
562
563 let out = repo.run_ok(&[
564 "patch", "create",
565 "-t", "Fix login bug",
566 "--head", &head,
567 "--fixes", &issue_id,
568 ]);
569 let patch_id = out.trim().strip_prefix("Created patch ").unwrap();
570
571 // Patch show should mention the linked issue
572 let out = repo.run_ok(&["patch", "show", patch_id]);
573 assert!(out.contains("Fixes"), "should show Fixes field");
574 assert!(out.contains(&issue_id[..8]), "should show linked issue ID");
575 }
576
577 #[test]
578 fn test_patch_merge_auto_closes_linked_issue() {
579 let repo = TestRepo::new("Alice", "alice@example.com");
580
581 let issue_id = repo.issue_open("Crash on startup");
582
583 repo.git(&["checkout", "-b", "fix"]);
584 let head = repo.commit_file("fix.rs", "fixed", "fix crash");
585 repo.git(&["checkout", "main"]);
586
587 let out = repo.run_ok(&[
588 "patch", "create",
589 "-t", "Fix crash",
590 "--head", &head,
591 "--fixes", &issue_id,
592 ]);
593 let patch_id = out.trim().strip_prefix("Created patch ").unwrap();
594
595 repo.run_ok(&["patch", "merge", patch_id]);
596
597 // Issue should now be closed
598 let out = repo.run_ok(&["issue", "show", &issue_id]);
599 assert!(out.contains("[closed]"), "linked issue should be auto-closed on merge");
600 }
601
602 // ===========================================================================
603 // Unread tracking
604 // ===========================================================================
605
606 #[test]
607 fn test_issue_list_shows_unread_after_new_comments() {
608 let repo = TestRepo::new("Alice", "alice@example.com");
609 let id = repo.issue_open("Unread test");
610
611 // View the issue to mark it as read
612 repo.run_ok(&["issue", "show", &id]);
613
614 // Add comments after viewing
615 repo.run_ok(&["issue", "comment", &id, "-b", "New comment 1"]);
616 repo.run_ok(&["issue", "comment", &id, "-b", "New comment 2"]);
617
618 // List should show unread count
619 let out = repo.run_ok(&["issue", "list"]);
620 assert!(
621 out.contains("2 new"),
622 "should show 2 new events, got: {}",
623 out
624 );
625 }
626
627 #[test]
628 fn test_issue_show_marks_as_read() {
629 let repo = TestRepo::new("Alice", "alice@example.com");
630 let id = repo.issue_open("Read test");
631
632 repo.run_ok(&["issue", "comment", &id, "-b", "A comment"]);
633
634 // View to mark as read
635 repo.run_ok(&["issue", "show", &id]);
636
637 // List should show no unread
638 let out = repo.run_ok(&["issue", "list"]);
639 assert!(
640 !out.contains("new"),
641 "should not show 'new' after viewing, got: {}",
642 out
643 );
644 }
645
646 #[test]
647 fn test_issue_list_no_unread_for_never_viewed() {
648 let repo = TestRepo::new("Alice", "alice@example.com");
649 // A brand new issue that's never been shown should not show "new"
650 // (only show unread count after the user has viewed it once)
651 repo.issue_open("Fresh issue");
652
653 let out = repo.run_ok(&["issue", "list"]);
654 assert!(
655 !out.contains("new"),
656 "never-viewed issue should not show unread count, got: {}",
657 out
658 );
659 }
660
661 // ===========================================================================
662 // Init command
663 // ===========================================================================
664
665 #[test]
666 fn test_init_no_remotes() {
667 let repo = TestRepo::new("Alice", "alice@example.com");
668
669 let out = repo.run_ok(&["init"]);
670 assert!(out.contains("No remotes"));
671 }
672
673 // ===========================================================================
674 // Full scenario tests
675 // ===========================================================================
676
677 #[test]
678 fn test_full_issue_lifecycle() {
679 let repo = TestRepo::new("Alice", "alice@example.com");
680
681 // Open
682 let id = repo.issue_open("Login page crashes");
683
684 // Multiple comments
685 repo.run_ok(&["issue", "comment", &id, "-b", "Stack trace attached"]);
686 repo.run_ok(&["issue", "comment", &id, "-b", "Reproduced on Chrome"]);
687
688 let out = repo.run_ok(&["issue", "show", &id]);
689 assert!(out.contains("Stack trace attached"));
690 assert!(out.contains("Reproduced on Chrome"));
691
692 // Close with reason
693 repo.run_ok(&["issue", "close", &id, "-r", "Fixed in commit abc123"]);
694 let out = repo.run_ok(&["issue", "show", &id]);
695 assert!(out.contains("[closed]"));
696 assert!(out.contains("Fixed in commit abc123"));
697
698 // Reopen
699 repo.run_ok(&["issue", "reopen", &id]);
700 let out = repo.run_ok(&["issue", "show", &id]);
701 assert!(out.contains("[open]"));
702
703 // Close again (no reason)
704 repo.run_ok(&["issue", "close", &id]);
705 let out = repo.run_ok(&["issue", "list"]);
706 assert!(!out.contains("Login page crashes") || out.contains("No issues"));
707 }
708
709 #[test]
710 fn test_full_patch_review_cycle() {
711 let repo = TestRepo::new("Alice", "alice@example.com");
712
713 // Create feature branch with v1
714 repo.git(&["checkout", "-b", "feature"]);
715 let v1 = repo.commit_file("feature.rs", "fn hello() {}", "v1 of feature");
716 repo.git(&["checkout", "main"]);
717
718 let out = repo.run_ok(&["patch", "create", "-t", "Add hello function", "--head", &v1]);
719 let id = out
720 .trim()
721 .strip_prefix("Created patch ")
722 .unwrap()
723 .to_string();
724
725 // Review: request changes
726 repo.run_ok(&[
727 "patch",
728 "review",
729 &id,
730 "-v",
731 "request-changes",
732 "-b",
733 "Add documentation",
734 ]);
735
736 // Inline comment on the code
737 repo.run_ok(&[
738 "patch",
739 "comment",
740 &id,
741 "-b",
742 "Missing doc comment",
743 "-f",
744 "feature.rs",
745 "-l",
746 "1",
747 ]);
748
749 // General comment
750 repo.run_ok(&["patch", "comment", &id, "-b", "Otherwise looks good"]);
751
752 // Revise with updated code
753 repo.git(&["checkout", "feature"]);
754 let v2 = repo.commit_file(
755 "feature.rs",
756 "/// Says hello\nfn hello() {}",
757 "v2: add docs",
758 );
759 repo.git(&["checkout", "main"]);
760
761 repo.run_ok(&[
762 "patch",
763 "revise",
764 &id,
765 "--head",
766 &v2,
767 "-b",
768 "Added documentation",
769 ]);
770
771 // Approve
772 repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "LGTM now"]);
773
774 // Merge
775 repo.run_ok(&["patch", "merge", &id]);
776
777 // Verify final state
778 let out = repo.run_ok(&["patch", "show", &id]);
779 assert!(out.contains("[merged]"));
780 assert!(out.contains("Added documentation"));
781 assert!(out.contains("LGTM now"));
782 assert!(out.contains("RequestChanges"));
783 assert!(out.contains("Approve"));
784 assert!(out.contains("Missing doc comment"));
785 assert!(out.contains("Otherwise looks good"));
786 assert!(out.contains("Inline Comments"));
787 assert!(out.contains("feature.rs"));
788 }
789
790 // ===========================================================================
791 // T014: init-key CLI command
792 // ===========================================================================
793
794 #[test]
795 fn test_init_key_creates_key_files() {
796 // Use a custom HOME so we don't clobber real keys
797 let tmp_home = tempfile::TempDir::new().unwrap();
798 let config_dir = tmp_home.path().join(".config").join("git-collab");
799
800 // Create a repo for the CLI to run in
801 let repo_dir = tempfile::TempDir::new().unwrap();
802 common::git_cmd(repo_dir.path(), &["init", "-b", "main"]);
803 common::git_cmd(repo_dir.path(), &["config", "user.name", "Alice"]);
804 common::git_cmd(repo_dir.path(), &["config", "user.email", "alice@example.com"]);
805 common::git_cmd(repo_dir.path(), &["commit", "--allow-empty", "-m", "init"]);
806
807 // Run init-key with overridden HOME
808 let output = std::process::Command::new(env!("CARGO_BIN_EXE_git-collab"))
809 .args(["init-key"])
810 .current_dir(repo_dir.path())
811 .env("HOME", tmp_home.path())
812 .env("XDG_CONFIG_HOME", tmp_home.path().join(".config"))
813 .output()
814 .expect("failed to run git-collab");
815
816 let stdout = String::from_utf8(output.stdout).unwrap();
817 let stderr = String::from_utf8(output.stderr).unwrap();
818 assert!(
819 output.status.success(),
820 "init-key should succeed: stdout={}, stderr={}",
821 stdout,
822 stderr
823 );
824 assert!(stdout.contains("Signing key generated"), "should print success message");
825 assert!(stdout.contains("Public key:"), "should print public key");
826
827 // Key files should exist
828 assert!(config_dir.join("signing-key").exists(), "private key file should exist");
829 assert!(config_dir.join("signing-key.pub").exists(), "public key file should exist");
830
831 // Run init-key again without --force: should fail
832 let output = std::process::Command::new(env!("CARGO_BIN_EXE_git-collab"))
833 .args(["init-key"])
834 .current_dir(repo_dir.path())
835 .env("HOME", tmp_home.path())
836 .env("XDG_CONFIG_HOME", tmp_home.path().join(".config"))
837 .output()
838 .expect("failed to run git-collab");
839 assert!(
840 !output.status.success(),
841 "init-key without --force should fail when key exists"
842 );
843 let stderr = String::from_utf8(output.stderr).unwrap();
844 assert!(
845 stderr.contains("already exists") || stderr.contains("--force"),
846 "error should mention --force, got: {}",
847 stderr
848 );
849
850 // Run init-key with --force: should succeed
851 let output = std::process::Command::new(env!("CARGO_BIN_EXE_git-collab"))
852 .args(["init-key", "--force"])
853 .current_dir(repo_dir.path())
854 .env("HOME", tmp_home.path())
855 .env("XDG_CONFIG_HOME", tmp_home.path().join(".config"))
856 .output()
857 .expect("failed to run git-collab");
858 let stdout = String::from_utf8(output.stdout).unwrap();
859 assert!(
860 output.status.success(),
861 "init-key --force should succeed: {}",
862 String::from_utf8_lossy(&output.stderr)
863 );
864 assert!(stdout.contains("Signing key generated"));
865 }
tests/collab_test.rs
Old New
@@ -1,92 +1,17 @@
1 use git2::Repository; 1 mod common;
2 use std::path::Path; 2
3 use tempfile::TempDir; 3 use tempfile::TempDir;
4 4
5 use git_collab::dag; 5 use git_collab::dag;
6 use git_collab::error::Error;
6 use git_collab::event::{Action, Author, Event, ReviewVerdict}; 7 use git_collab::event::{Action, Author, Event, ReviewVerdict};
8 use git_collab::signing::{self, SignedEvent, VerifyStatus};
7 use git_collab::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; 9 use git_collab::state::{self, IssueState, IssueStatus, PatchState, PatchStatus};
8 10
9 // --------------------------------------------------------------------------- 11 use common::{
10 // Helpers 12 add_comment, add_review, alice, bob, close_issue, create_patch, init_repo, now, open_issue,
11 // --------------------------------------------------------------------------- 13 reopen_issue, setup_signing_key, test_signing_key,
12 14 };
13 fn alice() -> Author {
14 Author {
15 name: "Alice".to_string(),
16 email: "alice@example.com".to_string(),
17 }
18 }
19
20 fn bob() -> Author {
21 Author {
22 name: "Bob".to_string(),
23 email: "bob@example.com".to_string(),
24 }
25 }
26
27 fn now() -> String {
28 chrono::Utc::now().to_rfc3339()
29 }
30
31 /// Create a bare repo in a tempdir and configure user identity.
32 fn init_repo(dir: &Path, author: &Author) -> Repository {
33 let repo = Repository::init(dir).expect("init repo");
34 {
35 let mut config = repo.config().unwrap();
36 config.set_str("user.name", &author.name).unwrap();
37 config.set_str("user.email", &author.email).unwrap();
38 }
39 repo
40 }
41
42 /// Open an issue directly using DAG primitives (for fine-grained control in tests).
43 fn open_issue(repo: &Repository, author: &Author, title: &str) -> (String, String) {
44 let event = Event {
45 timestamp: now(),
46 author: author.clone(),
47 action: Action::IssueOpen {
48 title: title.to_string(),
49 body: "".to_string(),
50 },
51 };
52 let oid = dag::create_root_event(repo, &event).unwrap();
53 let id = oid.to_string();
54 let ref_name = format!("refs/collab/issues/{}", id);
55 repo.reference(&ref_name, oid, false, "test open").unwrap();
56 (ref_name, id)
57 }
58
59 /// Append a comment event to a ref.
60 fn add_comment(repo: &Repository, ref_name: &str, author: &Author, body: &str) {
61 let event = Event {
62 timestamp: now(),
63 author: author.clone(),
64 action: Action::IssueComment {
65 body: body.to_string(),
66 },
67 };
68 dag::append_event(repo, ref_name, &event).unwrap();
69 }
70
71 /// Append a close event to a ref.
72 fn close_issue(repo: &Repository, ref_name: &str, author: &Author) {
73 let event = Event {
74 timestamp: now(),
75 author: author.clone(),
76 action: Action::IssueClose { reason: None },
77 };
78 dag::append_event(repo, ref_name, &event).unwrap();
79 }
80
81 /// Append a reopen event to a ref.
82 fn reopen_issue(repo: &Repository, ref_name: &str, author: &Author) {
83 let event = Event {
84 timestamp: now(),
85 author: author.clone(),
86 action: Action::IssueReopen,
87 };
88 dag::append_event(repo, ref_name, &event).unwrap();
89 }
90 15
91 // --------------------------------------------------------------------------- 16 // ---------------------------------------------------------------------------
92 // Basic DAG tests 17 // Basic DAG tests
@@ -152,74 +77,101 @@ fn test_list_issues_filters_by_status() {
152 } 77 }
153 78
154 // --------------------------------------------------------------------------- 79 // ---------------------------------------------------------------------------
80 // Issue edit via DAG
81 // ---------------------------------------------------------------------------
82
83 #[test]
84 fn test_issue_edit_updates_title_and_body() {
85 let tmp = TempDir::new().unwrap();
86 let repo = init_repo(tmp.path(), &alice());
87
88 let (ref_name, id) = open_issue(&repo, &alice(), "Original title");
89
90 let sk = test_signing_key();
91 let event = Event {
92 timestamp: now(),
93 author: alice(),
94 action: Action::IssueEdit {
95 title: Some("New title".to_string()),
96 body: Some("New body".to_string()),
97 },
98 };
99 dag::append_event(&repo, &ref_name, &event, &sk).unwrap();
100
101 let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
102 assert_eq!(state.title, "New title");
103 assert_eq!(state.body, "New body");
104 }
105
106 #[test]
107 fn test_issue_edit_partial_update() {
108 let tmp = TempDir::new().unwrap();
109 let repo = init_repo(tmp.path(), &alice());
110
111 let (ref_name, id) = open_issue(&repo, &alice(), "Keep this title");
112
113 // Edit only body
114 let sk = test_signing_key();
115 let event = Event {
116 timestamp: now(),
117 author: alice(),
118 action: Action::IssueEdit {
119 title: None,
120 body: Some("Added body".to_string()),
121 },
122 };
123 dag::append_event(&repo, &ref_name, &event, &sk).unwrap();
124
125 let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
126 assert_eq!(state.title, "Keep this title");
127 assert_eq!(state.body, "Added body");
128 }
129
130 // ---------------------------------------------------------------------------
155 // Multi-user collaboration: concurrent edits → forked DAG → reconcile 131 // Multi-user collaboration: concurrent edits → forked DAG → reconcile
156 // --------------------------------------------------------------------------- 132 // ---------------------------------------------------------------------------
157 133
158 #[test] 134 #[test]
159 fn test_concurrent_comments_create_fork_and_reconcile() { 135 fn test_concurrent_comments_create_fork_and_reconcile() {
160 // Simulate two users commenting on the same issue concurrently.
161 //
162 // Starting state:
163 // commit A (IssueOpen)
164 //
165 // Alice adds comment → commit B (parent: A)
166 // Bob adds comment → commit C (parent: A) ← fork!
167 //
168 // Reconciliation creates merge commit M (parents: B, C)
169 // State replay should see all 3 events (open + 2 comments)
170
171 let tmp = TempDir::new().unwrap(); 136 let tmp = TempDir::new().unwrap();
172 let repo = init_repo(tmp.path(), &alice()); 137 let repo = init_repo(tmp.path(), &alice());
173 138
174 // Create the issue (commit A)
175 let (ref_name, id) = open_issue(&repo, &alice(), "Concurrent test"); 139 let (ref_name, id) = open_issue(&repo, &alice(), "Concurrent test");
176 let root_oid = repo.refname_to_id(&ref_name).unwrap(); 140 let root_oid = repo.refname_to_id(&ref_name).unwrap();
177 141
178 // Alice comments (commit B) — advances the ref
179 add_comment(&repo, &ref_name, &alice(), "Alice's comment"); 142 add_comment(&repo, &ref_name, &alice(), "Alice's comment");
180 let alice_tip = repo.refname_to_id(&ref_name).unwrap(); 143 let alice_tip = repo.refname_to_id(&ref_name).unwrap();
181 144
182 // Simulate Bob's concurrent comment: reset ref back to root, then append
183 repo.reference(&ref_name, root_oid, true, "simulate bob fork") 145 repo.reference(&ref_name, root_oid, true, "simulate bob fork")
184 .unwrap(); 146 .unwrap();
185 add_comment(&repo, &ref_name, &bob(), "Bob's comment"); 147 add_comment(&repo, &ref_name, &bob(), "Bob's comment");
186 let bob_tip = repo.refname_to_id(&ref_name).unwrap(); 148 let bob_tip = repo.refname_to_id(&ref_name).unwrap();
187 149
188 // Now we have two tips diverged from root_oid.
189 // Put Bob's tip in a "remote" ref so we can reconcile.
190 let remote_ref = format!("refs/collab/sync/origin/issues/{}", id); 150 let remote_ref = format!("refs/collab/sync/origin/issues/{}", id);
191 repo.reference(&remote_ref, bob_tip, true, "remote tip") 151 repo.reference(&remote_ref, bob_tip, true, "remote tip")
192 .unwrap(); 152 .unwrap();
193 153
194 // Reset local ref to Alice's tip
195 repo.reference(&ref_name, alice_tip, true, "restore alice tip") 154 repo.reference(&ref_name, alice_tip, true, "restore alice tip")
196 .unwrap(); 155 .unwrap();
197 156
198 // Reconcile 157 let merge_oid = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap();
199 let merge_oid = dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap();
200 158
201 // Verify merge commit has 2 parents
202 let merge_commit = repo.find_commit(merge_oid).unwrap(); 159 let merge_commit = repo.find_commit(merge_oid).unwrap();
203 assert_eq!(merge_commit.parent_count(), 2); 160 assert_eq!(merge_commit.parent_count(), 2);
204 161
205 // Walk events — should see open + both comments + merge
206 let events = dag::walk_events(&repo, &ref_name).unwrap(); 162 let events = dag::walk_events(&repo, &ref_name).unwrap();
207 let actions: Vec<_> = events.iter().map(|(_, e)| &e.action).collect(); 163 let actions: Vec<_> = events.iter().map(|(_, e)| &e.action).collect();
208 164
209 // Must have the open event
210 assert!(actions 165 assert!(actions
211 .iter() 166 .iter()
212 .any(|a| matches!(a, Action::IssueOpen { .. }))); 167 .any(|a| matches!(a, Action::IssueOpen { .. })));
213 // Must have both comments
214 let comments: Vec<_> = actions 168 let comments: Vec<_> = actions
215 .iter() 169 .iter()
216 .filter(|a| matches!(a, Action::IssueComment { .. })) 170 .filter(|a| matches!(a, Action::IssueComment { .. }))
217 .collect(); 171 .collect();
218 assert_eq!(comments.len(), 2, "both concurrent comments should appear"); 172 assert_eq!(comments.len(), 2, "both concurrent comments should appear");
219 // Must have the merge
220 assert!(actions.iter().any(|a| matches!(a, Action::Merge))); 173 assert!(actions.iter().any(|a| matches!(a, Action::Merge)));
221 174
222 // State should show both comments
223 let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); 175 let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
224 assert_eq!(state.comments.len(), 2); 176 assert_eq!(state.comments.len(), 2);
225 assert_eq!(state.status, IssueStatus::Open); 177 assert_eq!(state.status, IssueStatus::Open);
@@ -227,61 +179,46 @@ fn test_concurrent_comments_create_fork_and_reconcile() {
227 179
228 #[test] 180 #[test]
229 fn test_concurrent_close_and_comment() { 181 fn test_concurrent_close_and_comment() {
230 // Alice closes the issue while Bob comments on it concurrently.
231 // After reconciliation, the issue should be closed (last event wins by topo order)
232 // and Bob's comment should still be visible.
233
234 let tmp = TempDir::new().unwrap(); 182 let tmp = TempDir::new().unwrap();
235 let repo = init_repo(tmp.path(), &alice()); 183 let repo = init_repo(tmp.path(), &alice());
236 184
237 let (ref_name, id) = open_issue(&repo, &alice(), "Close vs comment"); 185 let (ref_name, id) = open_issue(&repo, &alice(), "Close vs comment");
238 let root_oid = repo.refname_to_id(&ref_name).unwrap(); 186 let root_oid = repo.refname_to_id(&ref_name).unwrap();
239 187
240 // Alice closes
241 close_issue(&repo, &ref_name, &alice()); 188 close_issue(&repo, &ref_name, &alice());
242 let alice_tip = repo.refname_to_id(&ref_name).unwrap(); 189 let alice_tip = repo.refname_to_id(&ref_name).unwrap();
243 190
244 // Bob comments (from root)
245 repo.reference(&ref_name, root_oid, true, "bob fork") 191 repo.reference(&ref_name, root_oid, true, "bob fork")
246 .unwrap(); 192 .unwrap();
247 add_comment(&repo, &ref_name, &bob(), "Wait, I have thoughts"); 193 add_comment(&repo, &ref_name, &bob(), "Wait, I have thoughts");
248 let bob_tip = repo.refname_to_id(&ref_name).unwrap(); 194 let bob_tip = repo.refname_to_id(&ref_name).unwrap();
249 195
250 // Set up for reconciliation
251 let remote_ref = format!("refs/collab/sync/origin/issues/{}", id); 196 let remote_ref = format!("refs/collab/sync/origin/issues/{}", id);
252 repo.reference(&remote_ref, bob_tip, true, "remote") 197 repo.reference(&remote_ref, bob_tip, true, "remote")
253 .unwrap(); 198 .unwrap();
254 repo.reference(&ref_name, alice_tip, true, "restore") 199 repo.reference(&ref_name, alice_tip, true, "restore")
255 .unwrap(); 200 .unwrap();
256 201
257 dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap(); 202 dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap();
258 203
259 let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); 204 let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
260 // Both the close and comment should be in the DAG
261 assert_eq!(state.comments.len(), 1); 205 assert_eq!(state.comments.len(), 1);
262 // The close event should have taken effect
263 assert_eq!(state.status, IssueStatus::Closed); 206 assert_eq!(state.status, IssueStatus::Closed);
264 } 207 }
265 208
266 #[test] 209 #[test]
267 fn test_concurrent_close_and_reopen() { 210 fn test_concurrent_close_and_reopen() {
268 // Alice closes while Bob reopens from a previously-closed state.
269 // This tests conflicting status transitions.
270
271 let tmp = TempDir::new().unwrap(); 211 let tmp = TempDir::new().unwrap();
272 let repo = init_repo(tmp.path(), &alice()); 212 let repo = init_repo(tmp.path(), &alice());
273 213
274 let (ref_name, id) = open_issue(&repo, &alice(), "Status conflict"); 214 let (ref_name, id) = open_issue(&repo, &alice(), "Status conflict");
275 215
276 // First, close the issue so both users start from "closed"
277 close_issue(&repo, &ref_name, &alice()); 216 close_issue(&repo, &ref_name, &alice());
278 let closed_oid = repo.refname_to_id(&ref_name).unwrap(); 217 let closed_oid = repo.refname_to_id(&ref_name).unwrap();
279 218
280 // Alice comments on the closed issue
281 add_comment(&repo, &ref_name, &alice(), "Staying closed"); 219 add_comment(&repo, &ref_name, &alice(), "Staying closed");
282 let alice_tip = repo.refname_to_id(&ref_name).unwrap(); 220 let alice_tip = repo.refname_to_id(&ref_name).unwrap();
283 221
284 // Bob reopens from the closed state
285 repo.reference(&ref_name, closed_oid, true, "bob fork") 222 repo.reference(&ref_name, closed_oid, true, "bob fork")
286 .unwrap(); 223 .unwrap();
287 reopen_issue(&repo, &ref_name, &bob()); 224 reopen_issue(&repo, &ref_name, &bob());
@@ -293,11 +230,9 @@ fn test_concurrent_close_and_reopen() {
293 repo.reference(&ref_name, alice_tip, true, "restore") 230 repo.reference(&ref_name, alice_tip, true, "restore")
294 .unwrap(); 231 .unwrap();
295 232
296 dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap(); 233 dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap();
297 234
298 let _state = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); 235 let _state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
299 // Both branches are replayed — the final status depends on topo order.
300 // The important thing is that we don't crash and both events are present.
301 let events = dag::walk_events(&repo, &ref_name).unwrap(); 236 let events = dag::walk_events(&repo, &ref_name).unwrap();
302 let has_close = events 237 let has_close = events
303 .iter() 238 .iter()
@@ -311,30 +246,24 @@ fn test_concurrent_close_and_reopen() {
311 246
312 #[test] 247 #[test]
313 fn test_fast_forward_reconcile() { 248 fn test_fast_forward_reconcile() {
314 // If local is behind remote (remote has strictly more events), reconcile
315 // should fast-forward without creating a merge commit.
316
317 let tmp = TempDir::new().unwrap(); 249 let tmp = TempDir::new().unwrap();
318 let repo = init_repo(tmp.path(), &alice()); 250 let repo = init_repo(tmp.path(), &alice());
319 251
320 let (ref_name, id) = open_issue(&repo, &alice(), "FF test"); 252 let (ref_name, id) = open_issue(&repo, &alice(), "FF test");
321 let root_oid = repo.refname_to_id(&ref_name).unwrap(); 253 let root_oid = repo.refname_to_id(&ref_name).unwrap();
322 254
323 // Add comment (advances ref)
324 add_comment(&repo, &ref_name, &alice(), "Extra comment"); 255 add_comment(&repo, &ref_name, &alice(), "Extra comment");
325 let ahead_tip = repo.refname_to_id(&ref_name).unwrap(); 256 let ahead_tip = repo.refname_to_id(&ref_name).unwrap();
326 257
327 // Simulate: local is at root, remote is at ahead_tip
328 repo.reference(&ref_name, root_oid, true, "reset local") 258 repo.reference(&ref_name, root_oid, true, "reset local")
329 .unwrap(); 259 .unwrap();
330 let remote_ref = format!("refs/collab/sync/origin/issues/{}", id); 260 let remote_ref = format!("refs/collab/sync/origin/issues/{}", id);
331 repo.reference(&remote_ref, ahead_tip, true, "remote ahead") 261 repo.reference(&remote_ref, ahead_tip, true, "remote ahead")
332 .unwrap(); 262 .unwrap();
333 263
334 let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap(); 264 let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap();
335 assert_eq!(result, ahead_tip, "should fast-forward to remote tip"); 265 assert_eq!(result, ahead_tip, "should fast-forward to remote tip");
336 266
337 // No merge commit — walk should have exactly 2 events
338 let events = dag::walk_events(&repo, &ref_name).unwrap(); 267 let events = dag::walk_events(&repo, &ref_name).unwrap();
339 assert_eq!(events.len(), 2); 268 assert_eq!(events.len(), 2);
340 assert!(!events 269 assert!(!events
@@ -353,13 +282,12 @@ fn test_no_op_when_already_in_sync() {
353 let remote_ref = format!("refs/collab/sync/origin/issues/{}", id); 282 let remote_ref = format!("refs/collab/sync/origin/issues/{}", id);
354 repo.reference(&remote_ref, tip, true, "same tip").unwrap(); 283 repo.reference(&remote_ref, tip, true, "same tip").unwrap();
355 284
356 let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap(); 285 let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap();
357 assert_eq!(result, tip); 286 assert_eq!(result, tip);
358 } 287 }
359 288
360 #[test] 289 #[test]
361 fn test_local_ahead_no_merge() { 290 fn test_local_ahead_no_merge() {
362 // If local has more events than remote, reconcile should be a no-op.
363 let tmp = TempDir::new().unwrap(); 291 let tmp = TempDir::new().unwrap();
364 let repo = init_repo(tmp.path(), &alice()); 292 let repo = init_repo(tmp.path(), &alice());
365 293
@@ -373,7 +301,7 @@ fn test_local_ahead_no_merge() {
373 repo.reference(&remote_ref, root_oid, true, "remote behind") 301 repo.reference(&remote_ref, root_oid, true, "remote behind")
374 .unwrap(); 302 .unwrap();
375 303
376 let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap(); 304 let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap();
377 assert_eq!(result, local_tip, "local should stay ahead"); 305 assert_eq!(result, local_tip, "local should stay ahead");
378 } 306 }
379 307
@@ -381,36 +309,6 @@ fn test_local_ahead_no_merge() {
381 // Patch collaboration tests 309 // Patch collaboration tests
382 // --------------------------------------------------------------------------- 310 // ---------------------------------------------------------------------------
383 311
384 fn create_patch(repo: &Repository, author: &Author, title: &str) -> (String, String) {
385 let event = Event {
386 timestamp: now(),
387 author: author.clone(),
388 action: Action::PatchCreate {
389 title: title.to_string(),
390 body: "".to_string(),
391 base_ref: "main".to_string(),
392 head_commit: "abc123".to_string(),
393 },
394 };
395 let oid = dag::create_root_event(repo, &event).unwrap();
396 let id = oid.to_string();
397 let ref_name = format!("refs/collab/patches/{}", id);
398 repo.reference(&ref_name, oid, false, "test patch").unwrap();
399 (ref_name, id)
400 }
401
402 fn add_review(repo: &Repository, ref_name: &str, author: &Author, verdict: ReviewVerdict) {
403 let event = Event {
404 timestamp: now(),
405 author: author.clone(),
406 action: Action::PatchReview {
407 verdict,
408 body: "review comment".to_string(),
409 },
410 };
411 dag::append_event(repo, ref_name, &event).unwrap();
412 }
413
414 #[test] 312 #[test]
415 fn test_patch_review_workflow() { 313 fn test_patch_review_workflow() {
416 let tmp = TempDir::new().unwrap(); 314 let tmp = TempDir::new().unwrap();
@@ -418,14 +316,13 @@ fn test_patch_review_workflow() {
418 316
419 let (ref_name, id) = create_patch(&repo, &alice(), "Add feature X"); 317 let (ref_name, id) = create_patch(&repo, &alice(), "Add feature X");
420 318
421 // Bob reviews
422 add_review(&repo, &ref_name, &bob(), ReviewVerdict::RequestChanges); 319 add_review(&repo, &ref_name, &bob(), ReviewVerdict::RequestChanges);
423 320
424 let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); 321 let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap();
425 assert_eq!(state.reviews.len(), 1); 322 assert_eq!(state.reviews.len(), 1);
426 assert_eq!(state.reviews[0].verdict, ReviewVerdict::RequestChanges); 323 assert_eq!(state.reviews[0].verdict, ReviewVerdict::RequestChanges);
427 324
428 // Alice revises 325 let sk = test_signing_key();
429 let event = Event { 326 let event = Event {
430 timestamp: now(), 327 timestamp: now(),
431 author: alice(), 328 author: alice(),
@@ -434,9 +331,8 @@ fn test_patch_review_workflow() {
434 head_commit: "def456".to_string(), 331 head_commit: "def456".to_string(),
435 }, 332 },
436 }; 333 };
437 dag::append_event(&repo, &ref_name, &event).unwrap(); 334 dag::append_event(&repo, &ref_name, &event, &sk).unwrap();
438 335
439 // Bob approves
440 add_review(&repo, &ref_name, &bob(), ReviewVerdict::Approve); 336 add_review(&repo, &ref_name, &bob(), ReviewVerdict::Approve);
441 337
442 let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); 338 let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap();
@@ -447,31 +343,27 @@ fn test_patch_review_workflow() {
447 343
448 #[test] 344 #[test]
449 fn test_concurrent_reviews_on_patch() { 345 fn test_concurrent_reviews_on_patch() {
450 // Alice and Bob both review the same patch concurrently.
451 let tmp = TempDir::new().unwrap(); 346 let tmp = TempDir::new().unwrap();
452 let repo = init_repo(tmp.path(), &alice()); 347 let repo = init_repo(tmp.path(), &alice());
453 348
454 let (ref_name, id) = create_patch(&repo, &alice(), "Concurrent review"); 349 let (ref_name, id) = create_patch(&repo, &alice(), "Concurrent review");
455 let root_oid = repo.refname_to_id(&ref_name).unwrap(); 350 let root_oid = repo.refname_to_id(&ref_name).unwrap();
456 351
457 // Alice approves
458 add_review(&repo, &ref_name, &alice(), ReviewVerdict::Approve); 352 add_review(&repo, &ref_name, &alice(), ReviewVerdict::Approve);
459 let alice_tip = repo.refname_to_id(&ref_name).unwrap(); 353 let alice_tip = repo.refname_to_id(&ref_name).unwrap();
460 354
461 // Bob requests changes (from root)
462 repo.reference(&ref_name, root_oid, true, "bob fork") 355 repo.reference(&ref_name, root_oid, true, "bob fork")
463 .unwrap(); 356 .unwrap();
464 add_review(&repo, &ref_name, &bob(), ReviewVerdict::RequestChanges); 357 add_review(&repo, &ref_name, &bob(), ReviewVerdict::RequestChanges);
465 let bob_tip = repo.refname_to_id(&ref_name).unwrap(); 358 let bob_tip = repo.refname_to_id(&ref_name).unwrap();
466 359
467 // Reconcile
468 let remote_ref = format!("refs/collab/sync/origin/patches/{}", id); 360 let remote_ref = format!("refs/collab/sync/origin/patches/{}", id);
469 repo.reference(&remote_ref, bob_tip, true, "remote") 361 repo.reference(&remote_ref, bob_tip, true, "remote")
470 .unwrap(); 362 .unwrap();
471 repo.reference(&ref_name, alice_tip, true, "restore") 363 repo.reference(&ref_name, alice_tip, true, "restore")
472 .unwrap(); 364 .unwrap();
473 365
474 dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap(); 366 dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap();
475 367
476 let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); 368 let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap();
477 assert_eq!(state.reviews.len(), 2, "both reviews should be present"); 369 assert_eq!(state.reviews.len(), 2, "both reviews should be present");
@@ -484,9 +376,6 @@ fn test_concurrent_reviews_on_patch() {
484 376
485 #[test] 377 #[test]
486 fn test_three_way_fork_sequential_reconcile() { 378 fn test_three_way_fork_sequential_reconcile() {
487 // Three users all comment from the same base.
488 // We reconcile them pairwise: first alice+bob, then result+charlie.
489
490 let tmp = TempDir::new().unwrap(); 379 let tmp = TempDir::new().unwrap();
491 let repo = init_repo(tmp.path(), &alice()); 380 let repo = init_repo(tmp.path(), &alice());
492 381
@@ -498,33 +387,28 @@ fn test_three_way_fork_sequential_reconcile() {
498 let (ref_name, id) = open_issue(&repo, &alice(), "Three-way"); 387 let (ref_name, id) = open_issue(&repo, &alice(), "Three-way");
499 let root_oid = repo.refname_to_id(&ref_name).unwrap(); 388 let root_oid = repo.refname_to_id(&ref_name).unwrap();
500 389
501 // Alice comments
502 add_comment(&repo, &ref_name, &alice(), "Alice here"); 390 add_comment(&repo, &ref_name, &alice(), "Alice here");
503 let alice_tip = repo.refname_to_id(&ref_name).unwrap(); 391 let alice_tip = repo.refname_to_id(&ref_name).unwrap();
504 392
505 // Bob comments from root
506 repo.reference(&ref_name, root_oid, true, "bob").unwrap(); 393 repo.reference(&ref_name, root_oid, true, "bob").unwrap();
507 add_comment(&repo, &ref_name, &bob(), "Bob here"); 394 add_comment(&repo, &ref_name, &bob(), "Bob here");
508 let bob_tip = repo.refname_to_id(&ref_name).unwrap(); 395 let bob_tip = repo.refname_to_id(&ref_name).unwrap();
509 396
510 // Charlie comments from root
511 repo.reference(&ref_name, root_oid, true, "charlie") 397 repo.reference(&ref_name, root_oid, true, "charlie")
512 .unwrap(); 398 .unwrap();
513 add_comment(&repo, &ref_name, &charlie, "Charlie here"); 399 add_comment(&repo, &ref_name, &charlie, "Charlie here");
514 let charlie_tip = repo.refname_to_id(&ref_name).unwrap(); 400 let charlie_tip = repo.refname_to_id(&ref_name).unwrap();
515 401
516 // Reconcile alice + bob
517 repo.reference(&ref_name, alice_tip, true, "alice").unwrap(); 402 repo.reference(&ref_name, alice_tip, true, "alice").unwrap();
518 let bob_ref = "refs/collab/sync/origin/issues/bob_temp"; 403 let bob_ref = "refs/collab/sync/origin/issues/bob_temp";
519 repo.reference(bob_ref, bob_tip, true, "bob remote") 404 repo.reference(bob_ref, bob_tip, true, "bob remote")
520 .unwrap(); 405 .unwrap();
521 dag::reconcile(&repo, &ref_name, bob_ref, &alice()).unwrap(); 406 dag::reconcile(&repo, &ref_name, bob_ref, &alice(), &test_signing_key()).unwrap();
522 407
523 // Reconcile result + charlie
524 let charlie_ref = "refs/collab/sync/origin/issues/charlie_temp"; 408 let charlie_ref = "refs/collab/sync/origin/issues/charlie_temp";
525 repo.reference(charlie_ref, charlie_tip, true, "charlie remote") 409 repo.reference(charlie_ref, charlie_tip, true, "charlie remote")
526 .unwrap(); 410 .unwrap();
527 dag::reconcile(&repo, &ref_name, charlie_ref, &alice()).unwrap(); 411 dag::reconcile(&repo, &ref_name, charlie_ref, &alice(), &test_signing_key()).unwrap();
528 412
529 let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); 413 let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
530 assert_eq!(state.comments.len(), 3, "all three comments must survive"); 414 assert_eq!(state.comments.len(), 3, "all three comments must survive");
@@ -599,3 +483,73 @@ fn test_resolve_prefix_match() {
599 assert_eq!(resolved_id, id); 483 assert_eq!(resolved_id, id);
600 assert_eq!(resolved_ref, format!("refs/collab/issues/{}", id)); 484 assert_eq!(resolved_ref, format!("refs/collab/issues/{}", id));
601 } 485 }
486
487 // ---------------------------------------------------------------------------
488 // T012: Signed event integration test
489 // ---------------------------------------------------------------------------
490
491 #[test]
492 fn test_signed_event_in_dag() {
493 let tmp = TempDir::new().unwrap();
494 let repo = init_repo(tmp.path(), &alice());
495
496 // Set up a signing key on disk so issue::open() can find it
497 let config_dir = tmp.path().join("test-config");
498 setup_signing_key(&config_dir);
499 let sk = signing::load_signing_key(&config_dir).unwrap();
500
501 // Create an issue using dag primitives with the signing key
502 let event = Event {
503 timestamp: now(),
504 author: alice(),
505 action: Action::IssueOpen {
506 title: "Signed issue".to_string(),
507 body: "".to_string(),
508 },
509 };
510 let oid = dag::create_root_event(&repo, &event, &sk).unwrap();
511 let id = oid.to_string();
512 let ref_name = format!("refs/collab/issues/{}", id);
513 repo.reference(&ref_name, oid, false, "test open").unwrap();
514
515 // Read the raw blob from the commit and deserialize as SignedEvent
516 let tip = repo.refname_to_id(&ref_name).unwrap();
517 let commit = repo.find_commit(tip).unwrap();
518 let tree = commit.tree().unwrap();
519 let entry = tree.get_name("event.json").unwrap();
520 let blob = repo.find_blob(entry.id()).unwrap();
521 let signed: SignedEvent = serde_json::from_slice(blob.content()).unwrap();
522
523 // Assert signature and pubkey are present
524 assert!(!signed.signature.is_empty(), "signature should be present");
525 assert!(!signed.pubkey.is_empty(), "pubkey should be present");
526
527 // Verify the signature
528 let status = signing::verify_signed_event(&signed).unwrap();
529 assert_eq!(status, VerifyStatus::Valid, "signature should verify as valid");
530
531 // walk_events should still extract the Event correctly
532 let events = dag::walk_events(&repo, &ref_name).unwrap();
533 assert_eq!(events.len(), 1);
534 assert!(matches!(events[0].1.action, Action::IssueOpen { .. }));
535 }
536
537 // ---------------------------------------------------------------------------
538 // T013: Missing signing key error test
539 // ---------------------------------------------------------------------------
540
541 #[test]
542 fn test_issue_open_without_signing_key_returns_key_not_found() {
543 let tmp = TempDir::new().unwrap();
544 let _repo = init_repo(tmp.path(), &alice());
545
546 // Point to a nonexistent config dir so load_signing_key fails
547 let bad_config = tmp.path().join("nonexistent-config");
548 let result = signing::load_signing_key(&bad_config);
549
550 assert!(result.is_err());
551 match result.unwrap_err() {
552 Error::KeyNotFound => {} // expected
553 other => panic!("expected KeyNotFound error, got: {:?}", other),
554 }
555 }
tests/common/mod.rs
Old New
@@ -0,0 +1,318 @@
1 #![allow(dead_code)]
2
3 use std::path::Path;
4 use std::process::{Command, Output};
5
6 use ed25519_dalek::SigningKey;
7 use git2::Repository;
8 use rand_core::OsRng;
9 use tempfile::TempDir;
10
11 use git_collab::dag;
12 use git_collab::event::{Action, Author, Event, ReviewVerdict};
13 use git_collab::signing;
14
15 // ===========================================================================
16 // Library-level helpers (for collab_test / sync_test)
17 // ===========================================================================
18
19 pub fn alice() -> Author {
20 Author {
21 name: "Alice".to_string(),
22 email: "alice@example.com".to_string(),
23 }
24 }
25
26 pub fn bob() -> Author {
27 Author {
28 name: "Bob".to_string(),
29 email: "bob@example.com".to_string(),
30 }
31 }
32
33 pub fn now() -> String {
34 chrono::Utc::now().to_rfc3339()
35 }
36
37 /// Generate a test signing key and return it. Does NOT write to disk.
38 pub fn test_signing_key() -> SigningKey {
39 SigningKey::generate(&mut OsRng)
40 }
41
42 /// Generate a test signing key and write it to a config dir so that
43 /// code using `signing::load_signing_key()` can find it.
44 pub fn setup_signing_key(config_dir: &Path) {
45 git_collab::signing::generate_keypair(config_dir).expect("generate test keypair");
46 }
47
48 /// Create a non-bare repo in a directory with user identity configured.
49 pub fn init_repo(dir: &Path, author: &Author) -> Repository {
50 let repo = Repository::init(dir).expect("init repo");
51 {
52 let mut config = repo.config().unwrap();
53 config.set_str("user.name", &author.name).unwrap();
54 config.set_str("user.email", &author.email).unwrap();
55 }
56 repo
57 }
58
59 /// Open an issue using DAG primitives. Returns (ref_name, id).
60 pub fn open_issue(repo: &Repository, author: &Author, title: &str) -> (String, String) {
61 let sk = test_signing_key();
62 let event = Event {
63 timestamp: now(),
64 author: author.clone(),
65 action: Action::IssueOpen {
66 title: title.to_string(),
67 body: "".to_string(),
68 },
69 };
70 let oid = dag::create_root_event(repo, &event, &sk).unwrap();
71 let id = oid.to_string();
72 let ref_name = format!("refs/collab/issues/{}", id);
73 repo.reference(&ref_name, oid, false, "test open").unwrap();
74 (ref_name, id)
75 }
76
77 /// Append a comment event to an issue ref.
78 pub fn add_comment(repo: &Repository, ref_name: &str, author: &Author, body: &str) {
79 let sk = test_signing_key();
80 let event = Event {
81 timestamp: now(),
82 author: author.clone(),
83 action: Action::IssueComment {
84 body: body.to_string(),
85 },
86 };
87 dag::append_event(repo, ref_name, &event, &sk).unwrap();
88 }
89
90 /// Append a close event to an issue ref.
91 pub fn close_issue(repo: &Repository, ref_name: &str, author: &Author) {
92 let sk = test_signing_key();
93 let event = Event {
94 timestamp: now(),
95 author: author.clone(),
96 action: Action::IssueClose { reason: None },
97 };
98 dag::append_event(repo, ref_name, &event, &sk).unwrap();
99 }
100
101 /// Append a reopen event to an issue ref.
102 pub fn reopen_issue(repo: &Repository, ref_name: &str, author: &Author) {
103 let sk = test_signing_key();
104 let event = Event {
105 timestamp: now(),
106 author: author.clone(),
107 action: Action::IssueReopen,
108 };
109 dag::append_event(repo, ref_name, &event, &sk).unwrap();
110 }
111
112 /// Create a patch using DAG primitives. Returns (ref_name, id).
113 pub fn create_patch(repo: &Repository, author: &Author, title: &str) -> (String, String) {
114 let sk = test_signing_key();
115 let event = Event {
116 timestamp: now(),
117 author: author.clone(),
118 action: Action::PatchCreate {
119 title: title.to_string(),
120 body: "".to_string(),
121 base_ref: "main".to_string(),
122 head_commit: "abc123".to_string(),
123 fixes: None,
124 },
125 };
126 let oid = dag::create_root_event(repo, &event, &sk).unwrap();
127 let id = oid.to_string();
128 let ref_name = format!("refs/collab/patches/{}", id);
129 repo.reference(&ref_name, oid, false, "test patch").unwrap();
130 (ref_name, id)
131 }
132
133 /// Append a review event to a patch ref.
134 pub fn add_review(repo: &Repository, ref_name: &str, author: &Author, verdict: ReviewVerdict) {
135 let sk = test_signing_key();
136 let event = Event {
137 timestamp: now(),
138 author: author.clone(),
139 action: Action::PatchReview {
140 verdict,
141 body: "review comment".to_string(),
142 },
143 };
144 dag::append_event(repo, ref_name, &event, &sk).unwrap();
145 }
146
147 // ===========================================================================
148 // CLI-level helpers (for cli_test)
149 // ===========================================================================
150
151 /// A temporary git repository for end-to-end CLI testing.
152 pub struct TestRepo {
153 pub dir: TempDir,
154 }
155
156 impl TestRepo {
157 /// Create a new repo with user identity and an initial empty commit on `main`.
158 /// Also ensures a signing key exists in the default config dir.
159 pub fn new(name: &str, email: &str) -> Self {
160 let dir = TempDir::new().unwrap();
161 git(dir.path(), &["init", "-b", "main"]);
162 git(dir.path(), &["config", "user.name", name]);
163 git(dir.path(), &["config", "user.email", email]);
164 git(dir.path(), &["commit", "--allow-empty", "-m", "initial"]);
165
166 // Ensure signing key exists for CLI operations
167 let config_dir = dirs::config_dir()
168 .unwrap_or_else(|| {
169 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
170 std::path::PathBuf::from(home).join(".config")
171 })
172 .join("git-collab");
173 if !config_dir.join("signing-key").exists() {
174 setup_signing_key(&config_dir);
175 }
176
177 TestRepo { dir }
178 }
179
180 /// Run git-collab and return raw output.
181 pub fn run(&self, args: &[&str]) -> Output {
182 Command::new(env!("CARGO_BIN_EXE_git-collab"))
183 .args(args)
184 .current_dir(self.dir.path())
185 .output()
186 .expect("failed to run git-collab")
187 }
188
189 /// Run git-collab, assert success, return stdout.
190 pub fn run_ok(&self, args: &[&str]) -> String {
191 let output = self.run(args);
192 let stdout = String::from_utf8(output.stdout).unwrap();
193 let stderr = String::from_utf8(output.stderr).unwrap();
194 assert!(
195 output.status.success(),
196 "git-collab {:?} failed (exit {:?}):\nstdout: {}\nstderr: {}",
197 args,
198 output.status.code(),
199 stdout,
200 stderr
201 );
202 stdout
203 }
204
205 /// Run git-collab, assert failure, return stderr.
206 pub fn run_err(&self, args: &[&str]) -> String {
207 let output = self.run(args);
208 assert!(
209 !output.status.success(),
210 "expected git-collab {:?} to fail but it succeeded:\nstdout: {}",
211 args,
212 String::from_utf8_lossy(&output.stdout)
213 );
214 String::from_utf8(output.stderr).unwrap()
215 }
216
217 /// Open an issue and return the 8-char short ID.
218 pub fn issue_open(&self, title: &str) -> String {
219 let out = self.run_ok(&["issue", "open", "-t", title]);
220 out.trim()
221 .strip_prefix("Opened issue ")
222 .unwrap_or_else(|| panic!("unexpected issue open output: {}", out))
223 .to_string()
224 }
225
226 /// Create a patch with HEAD as the head commit. Returns the 8-char short ID.
227 pub fn patch_create(&self, title: &str) -> String {
228 let head = self.git(&["rev-parse", "HEAD"]).trim().to_string();
229 let out = self.run_ok(&["patch", "create", "-t", title, "--head", &head]);
230 out.trim()
231 .strip_prefix("Created patch ")
232 .unwrap_or_else(|| panic!("unexpected patch create output: {}", out))
233 .to_string()
234 }
235
236 /// Run a git command in this repo and return stdout.
237 pub fn git(&self, args: &[&str]) -> String {
238 let output = Command::new("git")
239 .args(args)
240 .current_dir(self.dir.path())
241 .output()
242 .expect("failed to run git");
243 assert!(
244 output.status.success(),
245 "git {:?} failed: {}",
246 args,
247 String::from_utf8_lossy(&output.stderr)
248 );
249 String::from_utf8(output.stdout).unwrap()
250 }
251
252 /// Create a file, stage it, and commit. Returns the commit OID.
253 pub fn commit_file(&self, path: &str, content: &str, message: &str) -> String {
254 let full_path = self.dir.path().join(path);
255 if let Some(parent) = full_path.parent() {
256 std::fs::create_dir_all(parent).unwrap();
257 }
258 std::fs::write(&full_path, content).unwrap();
259 self.git(&["add", path]);
260 self.git(&["commit", "-m", message]);
261 self.git(&["rev-parse", "HEAD"]).trim().to_string()
262 }
263 }
264
265 /// Create an unsigned event commit (plain Event JSON, no signature fields).
266 /// Returns the commit OID.
267 pub fn create_unsigned_event(repo: &Repository, event: &Event) -> git2::Oid {
268 let json = serde_json::to_vec_pretty(event).unwrap();
269 let blob_oid = repo.blob(&json).unwrap();
270
271 let mut tb = repo.treebuilder(None).unwrap();
272 tb.insert("event.json", blob_oid, 0o100644).unwrap();
273 let tree_oid = tb.write().unwrap();
274 let tree = repo.find_tree(tree_oid).unwrap();
275
276 let sig = git2::Signature::now(&event.author.name, &event.author.email).unwrap();
277 repo.commit(None, &sig, &sig, "unsigned event", &tree, &[])
278 .unwrap()
279 }
280
281 /// Create a tampered event commit: sign the event, then modify the body but keep
282 /// the original signature. Returns the commit OID.
283 pub fn create_tampered_event(repo: &Repository, event: &Event) -> git2::Oid {
284 let sk = test_signing_key();
285 let mut signed = signing::sign_event(event, &sk).unwrap();
286 // Tamper with the event content while keeping the original signature
287 signed.event.timestamp = "2099-01-01T00:00:00Z".to_string();
288 let json = serde_json::to_vec_pretty(&signed).unwrap();
289 let blob_oid = repo.blob(&json).unwrap();
290
291 let mut tb = repo.treebuilder(None).unwrap();
292 tb.insert("event.json", blob_oid, 0o100644).unwrap();
293 let tree_oid = tb.write().unwrap();
294 let tree = repo.find_tree(tree_oid).unwrap();
295
296 let sig = git2::Signature::now(&event.author.name, &event.author.email).unwrap();
297 repo.commit(None, &sig, &sig, "tampered event", &tree, &[])
298 .unwrap()
299 }
300
301 /// Run a git command in the given directory (public for use in test files).
302 pub fn git_cmd(dir: &Path, args: &[&str]) {
303 git(dir, args);
304 }
305
306 fn git(dir: &Path, args: &[&str]) {
307 let output = Command::new("git")
308 .args(args)
309 .current_dir(dir)
310 .output()
311 .expect("failed to run git");
312 assert!(
313 output.status.success(),
314 "git {:?} failed: {}",
315 args,
316 String::from_utf8_lossy(&output.stderr)
317 );
318 }
tests/signing_test.rs
Old New
@@ -0,0 +1,241 @@
1 use git_collab::event::{Action, Author, Event};
2 use git_collab::signing::{
3 canonical_json, generate_keypair, load_signing_key, load_verifying_key, sign_event,
4 verify_signed_event, SignedEvent, VerifyStatus,
5 };
6 use tempfile::tempdir;
7
8 fn make_event() -> Event {
9 Event {
10 timestamp: "2026-03-21T00:00:00Z".to_string(),
11 author: Author {
12 name: "Alice".to_string(),
13 email: "alice@example.com".to_string(),
14 },
15 action: Action::IssueOpen {
16 title: "Test issue".to_string(),
17 body: "This is a test".to_string(),
18 },
19 }
20 }
21
22 // ── T004: Key generation and storage ──
23
24 #[test]
25 fn generate_keypair_creates_key_files() {
26 let dir = tempdir().unwrap();
27 let config = dir.path().join("git-collab");
28
29 let vk = generate_keypair(&config).unwrap();
30 assert!(config.join("signing-key").exists());
31 assert!(config.join("signing-key.pub").exists());
32
33 // Verify the public key file content matches the returned key
34 let loaded_vk = load_verifying_key(&config).unwrap();
35 assert_eq!(vk.to_bytes(), loaded_vk.to_bytes());
36 }
37
38 #[cfg(unix)]
39 #[test]
40 fn generate_keypair_sets_private_key_permissions() {
41 use std::os::unix::fs::PermissionsExt;
42
43 let dir = tempdir().unwrap();
44 let config = dir.path().join("git-collab");
45
46 generate_keypair(&config).unwrap();
47
48 let meta = std::fs::metadata(config.join("signing-key")).unwrap();
49 let mode = meta.permissions().mode() & 0o777;
50 assert_eq!(mode, 0o600, "private key should have 0o600 permissions");
51 }
52
53 #[test]
54 fn load_keypair_from_disk() {
55 let dir = tempdir().unwrap();
56 let config = dir.path().join("git-collab");
57
58 generate_keypair(&config).unwrap();
59
60 let sk = load_signing_key(&config).unwrap();
61 let vk = load_verifying_key(&config).unwrap();
62
63 // The verifying key derived from loaded signing key should match stored pub key
64 let derived_vk = sk.verifying_key();
65 assert_eq!(derived_vk.to_bytes(), vk.to_bytes());
66 }
67
68 #[test]
69 fn load_signing_key_missing_returns_error() {
70 let dir = tempdir().unwrap();
71 let config = dir.path().join("nonexistent");
72
73 let err = load_signing_key(&config).unwrap_err();
74 let msg = format!("{}", err);
75 assert!(
76 msg.contains("signing key"),
77 "error should mention signing key: {}",
78 msg
79 );
80 }
81
82 #[test]
83 fn load_verifying_key_missing_returns_error() {
84 let dir = tempdir().unwrap();
85 let config = dir.path().join("nonexistent");
86
87 let err = load_verifying_key(&config).unwrap_err();
88 let msg = format!("{}", err);
89 assert!(
90 msg.contains("signing key"),
91 "error should mention signing key: {}",
92 msg
93 );
94 }
95
96 // ── T005: Sign/verify round-trip ──
97
98 #[test]
99 fn sign_event_produces_nonempty_signature_and_pubkey() {
100 let dir = tempdir().unwrap();
101 let config = dir.path().join("git-collab");
102 generate_keypair(&config).unwrap();
103 let sk = load_signing_key(&config).unwrap();
104
105 let event = make_event();
106 let signed = sign_event(&event, &sk).unwrap();
107
108 assert!(!signed.signature.is_empty(), "signature should not be empty");
109 assert!(!signed.pubkey.is_empty(), "pubkey should not be empty");
110
111 // Verify they are valid base64
112 use base64::engine::general_purpose::STANDARD;
113 use base64::Engine;
114 STANDARD
115 .decode(&signed.signature)
116 .expect("signature should be valid base64");
117 STANDARD
118 .decode(&signed.pubkey)
119 .expect("pubkey should be valid base64");
120 }
121
122 #[test]
123 fn verify_valid_signed_event_returns_valid() {
124 let dir = tempdir().unwrap();
125 let config = dir.path().join("git-collab");
126 generate_keypair(&config).unwrap();
127 let sk = load_signing_key(&config).unwrap();
128
129 let event = make_event();
130 let signed = sign_event(&event, &sk).unwrap();
131
132 let status = verify_signed_event(&signed).unwrap();
133 assert_eq!(status, VerifyStatus::Valid);
134 }
135
136 #[test]
137 fn verify_tampered_event_returns_invalid() {
138 let dir = tempdir().unwrap();
139 let config = dir.path().join("git-collab");
140 generate_keypair(&config).unwrap();
141 let sk = load_signing_key(&config).unwrap();
142
143 let event = make_event();
144 let mut signed = sign_event(&event, &sk).unwrap();
145
146 // Tamper with the event
147 signed.event.author.name = "Mallory".to_string();
148
149 let status = verify_signed_event(&signed).unwrap();
150 assert_eq!(status, VerifyStatus::Invalid);
151 }
152
153 #[test]
154 fn verify_missing_signature_returns_missing() {
155 let event = make_event();
156 let signed = SignedEvent {
157 event,
158 signature: String::new(),
159 pubkey: String::new(),
160 };
161
162 let status = verify_signed_event(&signed).unwrap();
163 assert_eq!(status, VerifyStatus::Missing);
164 }
165
166 // ── T006: Canonical serialization ──
167
168 #[test]
169 fn canonical_json_deterministic() {
170 let event = make_event();
171
172 let bytes1 = canonical_json(&event).unwrap();
173 let bytes2 = canonical_json(&event).unwrap();
174
175 assert_eq!(bytes1, bytes2, "canonical_json should produce identical output");
176 }
177
178 #[test]
179 fn signed_event_json_contains_all_fields() {
180 let dir = tempdir().unwrap();
181 let config = dir.path().join("git-collab");
182 generate_keypair(&config).unwrap();
183 let sk = load_signing_key(&config).unwrap();
184
185 let event = make_event();
186 let signed = sign_event(&event, &sk).unwrap();
187
188 let json = serde_json::to_string(&signed).unwrap();
189 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
190 let obj = value.as_object().unwrap();
191
192 // Flattened event fields
193 assert!(obj.contains_key("timestamp"), "missing timestamp");
194 assert!(obj.contains_key("author"), "missing author");
195 assert!(obj.contains_key("action") || obj.contains_key("type"), "missing action/type");
196
197 // Signature fields
198 assert!(obj.contains_key("signature"), "missing signature");
199 assert!(obj.contains_key("pubkey"), "missing pubkey");
200 }
201
202 #[test]
203 fn signed_event_flatten_round_trip_with_tagged_enum() {
204 let event = Event {
205 timestamp: "2026-03-21T12:00:00Z".to_string(),
206 author: Author {
207 name: "Bob".to_string(),
208 email: "bob@example.com".to_string(),
209 },
210 action: Action::PatchCreate {
211 title: "Fix bug".to_string(),
212 body: "Fixes #42".to_string(),
213 base_ref: "main".to_string(),
214 head_commit: "abc123".to_string(),
215 fixes: Some("deadbeef".to_string()),
216 },
217 };
218
219 let signed = SignedEvent {
220 event,
221 signature: "dGVzdHNpZw==".to_string(),
222 pubkey: "dGVzdGtleQ==".to_string(),
223 };
224
225 let json = serde_json::to_string_pretty(&signed).unwrap();
226 let deserialized: SignedEvent = serde_json::from_str(&json).unwrap();
227
228 assert_eq!(deserialized.signature, signed.signature);
229 assert_eq!(deserialized.pubkey, signed.pubkey);
230 match deserialized.event.action {
231 Action::PatchCreate {
232 ref title,
233 ref fixes,
234 ..
235 } => {
236 assert_eq!(title, "Fix bug");
237 assert_eq!(fixes.as_deref(), Some("deadbeef"));
238 }
239 _ => panic!("Wrong action type after round-trip"),
240 }
241 }
tests/sync_test.rs
Old New
@@ -5,42 +5,31 @@
5 //! bare_remote <---push/fetch---> alice_repo 5 //! bare_remote <---push/fetch---> alice_repo
6 //! <---push/fetch---> bob_repo 6 //! <---push/fetch---> bob_repo
7 7
8 mod common;
9
8 use tempfile::TempDir; 10 use tempfile::TempDir;
9 11
10 use git2::Repository; 12 use git2::Repository;
11 use git_collab::dag; 13 use git_collab::dag;
12 use git_collab::event::{Action, Author, Event, ReviewVerdict}; 14 use git_collab::event::{Action, Event, ReviewVerdict};
15 use git_collab::signing;
13 use git_collab::state::{self, IssueState, IssueStatus, PatchState}; 16 use git_collab::state::{self, IssueState, IssueStatus, PatchState};
14 use git_collab::sync; 17 use git_collab::sync;
15 18
19 use common::{
20 add_comment, alice, bob, close_issue, create_tampered_event, create_unsigned_event, now,
21 open_issue, setup_signing_key, test_signing_key,
22 };
23
16 // --------------------------------------------------------------------------- 24 // ---------------------------------------------------------------------------
17 // Helpers 25 // Test cluster
18 // --------------------------------------------------------------------------- 26 // ---------------------------------------------------------------------------
19 27
20 fn alice() -> Author {
21 Author {
22 name: "Alice".to_string(),
23 email: "alice@example.com".to_string(),
24 }
25 }
26
27 fn bob() -> Author {
28 Author {
29 name: "Bob".to_string(),
30 email: "bob@example.com".to_string(),
31 }
32 }
33
34 fn now() -> String {
35 chrono::Utc::now().to_rfc3339()
36 }
37
38 /// Set up the standard test topology: bare remote + two clones.
39 /// Returns (bare_dir, alice_dir, bob_dir) — TempDirs that must be kept alive.
40 struct TestCluster { 28 struct TestCluster {
41 _bare_dir: TempDir, 29 _bare_dir: TempDir,
42 alice_dir: TempDir, 30 alice_dir: TempDir,
43 bob_dir: TempDir, 31 bob_dir: TempDir,
32 _key_setup: (), // signing key created in default config dir
44 } 33 }
45 34
46 impl TestCluster { 35 impl TestCluster {
@@ -48,8 +37,6 @@ impl TestCluster {
48 let bare_dir = TempDir::new().unwrap(); 37 let bare_dir = TempDir::new().unwrap();
49 let bare_repo = Repository::init_bare(bare_dir.path()).unwrap(); 38 let bare_repo = Repository::init_bare(bare_dir.path()).unwrap();
50 39
51 // Need at least one ref in the bare repo for clones to work,
52 // so we create a dummy initial commit on refs/heads/main.
53 { 40 {
54 let sig = git2::Signature::now("init", "init@test").unwrap(); 41 let sig = git2::Signature::now("init", "init@test").unwrap();
55 let tree_oid = bare_repo.treebuilder(None).unwrap().write().unwrap(); 42 let tree_oid = bare_repo.treebuilder(None).unwrap().write().unwrap();
@@ -63,7 +50,6 @@ impl TestCluster {
63 let alice_dir = TempDir::new().unwrap(); 50 let alice_dir = TempDir::new().unwrap();
64 let bob_dir = TempDir::new().unwrap(); 51 let bob_dir = TempDir::new().unwrap();
65 52
66 // Clone for Alice
67 let alice_repo = 53 let alice_repo =
68 Repository::clone(bare_dir.path().to_str().unwrap(), alice_dir.path()).unwrap(); 54 Repository::clone(bare_dir.path().to_str().unwrap(), alice_dir.path()).unwrap();
69 { 55 {
@@ -73,7 +59,6 @@ impl TestCluster {
73 } 59 }
74 sync::init(&alice_repo).unwrap(); 60 sync::init(&alice_repo).unwrap();
75 61
76 // Clone for Bob
77 let bob_repo = 62 let bob_repo =
78 Repository::clone(bare_dir.path().to_str().unwrap(), bob_dir.path()).unwrap(); 63 Repository::clone(bare_dir.path().to_str().unwrap(), bob_dir.path()).unwrap();
79 { 64 {
@@ -83,10 +68,22 @@ impl TestCluster {
83 } 68 }
84 sync::init(&bob_repo).unwrap(); 69 sync::init(&bob_repo).unwrap();
85 70
71 // Ensure signing key exists for sync reconciliation
72 let config_dir = dirs::config_dir()
73 .unwrap_or_else(|| {
74 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
75 std::path::PathBuf::from(home).join(".config")
76 })
77 .join("git-collab");
78 if !config_dir.join("signing-key").exists() {
79 setup_signing_key(&config_dir);
80 }
81
86 TestCluster { 82 TestCluster {
87 _bare_dir: bare_dir, 83 _bare_dir: bare_dir,
88 alice_dir, 84 alice_dir,
89 bob_dir, 85 bob_dir,
86 _key_setup: (),
90 } 87 }
91 } 88 }
92 89
@@ -99,42 +96,6 @@ impl TestCluster {
99 } 96 }
100 } 97 }
101 98
102 fn open_issue(repo: &Repository, author: &Author, title: &str) -> (String, String) {
103 let event = Event {
104 timestamp: now(),
105 author: author.clone(),
106 action: Action::IssueOpen {
107 title: title.to_string(),
108 body: "".to_string(),
109 },
110 };
111 let oid = dag::create_root_event(repo, &event).unwrap();
112 let id = oid.to_string();
113 let ref_name = format!("refs/collab/issues/{}", id);
114 repo.reference(&ref_name, oid, false, "open").unwrap();
115 (ref_name, id)
116 }
117
118 fn add_comment(repo: &Repository, ref_name: &str, author: &Author, body: &str) {
119 let event = Event {
120 timestamp: now(),
121 author: author.clone(),
122 action: Action::IssueComment {
123 body: body.to_string(),
124 },
125 };
126 dag::append_event(repo, ref_name, &event).unwrap();
127 }
128
129 fn close_issue(repo: &Repository, ref_name: &str, author: &Author) {
130 let event = Event {
131 timestamp: now(),
132 author: author.clone(),
133 action: Action::IssueClose { reason: None },
134 };
135 dag::append_event(repo, ref_name, &event).unwrap();
136 }
137
138 // --------------------------------------------------------------------------- 99 // ---------------------------------------------------------------------------
139 // Tests 100 // Tests
140 // --------------------------------------------------------------------------- 101 // ---------------------------------------------------------------------------
@@ -145,11 +106,9 @@ fn test_alice_creates_issue_bob_syncs_and_sees_it() {
145 let alice_repo = cluster.alice_repo(); 106 let alice_repo = cluster.alice_repo();
146 let bob_repo = cluster.bob_repo(); 107 let bob_repo = cluster.bob_repo();
147 108
148 // Alice creates an issue and syncs
149 let (_ref_name, id) = open_issue(&alice_repo, &alice(), "Bug from Alice"); 109 let (_ref_name, id) = open_issue(&alice_repo, &alice(), "Bug from Alice");
150 sync::sync(&alice_repo, "origin").unwrap(); 110 sync::sync(&alice_repo, "origin").unwrap();
151 111
152 // Bob syncs and should see the issue
153 sync::sync(&bob_repo, "origin").unwrap(); 112 sync::sync(&bob_repo, "origin").unwrap();
154 113
155 let bob_ref = format!("refs/collab/issues/{}", id); 114 let bob_ref = format!("refs/collab/issues/{}", id);
@@ -165,17 +124,14 @@ fn test_bob_comments_on_alice_issue_then_sync() {
165 let alice_repo = cluster.alice_repo(); 124 let alice_repo = cluster.alice_repo();
166 let bob_repo = cluster.bob_repo(); 125 let bob_repo = cluster.bob_repo();
167 126
168 // Alice creates issue, syncs
169 let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Needs discussion"); 127 let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Needs discussion");
170 sync::sync(&alice_repo, "origin").unwrap(); 128 sync::sync(&alice_repo, "origin").unwrap();
171 129
172 // Bob syncs, sees the issue, adds a comment, syncs
173 sync::sync(&bob_repo, "origin").unwrap(); 130 sync::sync(&bob_repo, "origin").unwrap();
174 let bob_ref = format!("refs/collab/issues/{}", id); 131 let bob_ref = format!("refs/collab/issues/{}", id);
175 add_comment(&bob_repo, &bob_ref, &bob(), "I have thoughts on this"); 132 add_comment(&bob_repo, &bob_ref, &bob(), "I have thoughts on this");
176 sync::sync(&bob_repo, "origin").unwrap(); 133 sync::sync(&bob_repo, "origin").unwrap();
177 134
178 // Alice syncs again — should see Bob's comment
179 sync::sync(&alice_repo, "origin").unwrap(); 135 sync::sync(&alice_repo, "origin").unwrap();
180 let state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap(); 136 let state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
181 assert_eq!(state.comments.len(), 1); 137 assert_eq!(state.comments.len(), 1);
@@ -185,40 +141,28 @@ fn test_bob_comments_on_alice_issue_then_sync() {
185 141
186 #[test] 142 #[test]
187 fn test_concurrent_comments_sync_convergence() { 143 fn test_concurrent_comments_sync_convergence() {
188 // Alice and Bob both comment on the same issue without syncing first.
189 // After both sync, they should converge to the same state.
190
191 let cluster = TestCluster::new(); 144 let cluster = TestCluster::new();
192 let alice_repo = cluster.alice_repo(); 145 let alice_repo = cluster.alice_repo();
193 let bob_repo = cluster.bob_repo(); 146 let bob_repo = cluster.bob_repo();
194 147
195 // Alice creates issue, syncs so Bob can get it
196 let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Concurrent comments"); 148 let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Concurrent comments");
197 sync::sync(&alice_repo, "origin").unwrap(); 149 sync::sync(&alice_repo, "origin").unwrap();
198 sync::sync(&bob_repo, "origin").unwrap(); 150 sync::sync(&bob_repo, "origin").unwrap();
199 151
200 // Both comment independently (no sync between)
201 add_comment(&alice_repo, &alice_ref, &alice(), "Alice's take"); 152 add_comment(&alice_repo, &alice_ref, &alice(), "Alice's take");
202 let bob_ref = format!("refs/collab/issues/{}", id); 153 let bob_ref = format!("refs/collab/issues/{}", id);
203 add_comment(&bob_repo, &bob_ref, &bob(), "Bob's take"); 154 add_comment(&bob_repo, &bob_ref, &bob(), "Bob's take");
204 155
205 // Alice syncs first — pushes her comment
206 sync::sync(&alice_repo, "origin").unwrap(); 156 sync::sync(&alice_repo, "origin").unwrap();
207
208 // Bob syncs — fetches Alice's comment, reconciles fork, pushes merge
209 sync::sync(&bob_repo, "origin").unwrap(); 157 sync::sync(&bob_repo, "origin").unwrap();
210
211 // Alice syncs again to get the merge
212 sync::sync(&alice_repo, "origin").unwrap(); 158 sync::sync(&alice_repo, "origin").unwrap();
213 159
214 // Both should now have the same state
215 let alice_state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap(); 160 let alice_state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
216 let bob_state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap(); 161 let bob_state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap();
217 162
218 assert_eq!(alice_state.comments.len(), 2, "Alice should see 2 comments"); 163 assert_eq!(alice_state.comments.len(), 2, "Alice should see 2 comments");
219 assert_eq!(bob_state.comments.len(), 2, "Bob should see 2 comments"); 164 assert_eq!(bob_state.comments.len(), 2, "Bob should see 2 comments");
220 165
221 // Both should have the same comment bodies (order may vary)
222 let mut alice_bodies: Vec<&str> = alice_state 166 let mut alice_bodies: Vec<&str> = alice_state
223 .comments 167 .comments
224 .iter() 168 .iter()
@@ -234,9 +178,6 @@ fn test_concurrent_comments_sync_convergence() {
234 178
235 #[test] 179 #[test]
236 fn test_both_create_different_issues() { 180 fn test_both_create_different_issues() {
237 // Alice and Bob each create their own issue without syncing.
238 // After sync, both should see both issues.
239
240 let cluster = TestCluster::new(); 181 let cluster = TestCluster::new();
241 let alice_repo = cluster.alice_repo(); 182 let alice_repo = cluster.alice_repo();
242 let bob_repo = cluster.bob_repo(); 183 let bob_repo = cluster.bob_repo();
@@ -244,16 +185,10 @@ fn test_both_create_different_issues() {
244 let (_, _alice_issue_id) = open_issue(&alice_repo, &alice(), "Alice's bug"); 185 let (_, _alice_issue_id) = open_issue(&alice_repo, &alice(), "Alice's bug");
245 let (_, _bob_issue_id) = open_issue(&bob_repo, &bob(), "Bob's feature request"); 186 let (_, _bob_issue_id) = open_issue(&bob_repo, &bob(), "Bob's feature request");
246 187
247 // Alice syncs first
248 sync::sync(&alice_repo, "origin").unwrap(); 188 sync::sync(&alice_repo, "origin").unwrap();
249
250 // Bob syncs — gets Alice's issue, pushes his own
251 sync::sync(&bob_repo, "origin").unwrap(); 189 sync::sync(&bob_repo, "origin").unwrap();
252
253 // Alice syncs again — gets Bob's issue
254 sync::sync(&alice_repo, "origin").unwrap(); 190 sync::sync(&alice_repo, "origin").unwrap();
255 191
256 // Both repos should have both issues
257 let alice_issues = state::list_issues(&alice_repo).unwrap(); 192 let alice_issues = state::list_issues(&alice_repo).unwrap();
258 let bob_issues = state::list_issues(&bob_repo).unwrap(); 193 let bob_issues = state::list_issues(&bob_repo).unwrap();
259 194
@@ -267,8 +202,6 @@ fn test_both_create_different_issues() {
267 202
268 #[test] 203 #[test]
269 fn test_alice_closes_while_bob_comments() { 204 fn test_alice_closes_while_bob_comments() {
270 // Alice closes an issue while Bob comments on it concurrently.
271
272 let cluster = TestCluster::new(); 205 let cluster = TestCluster::new();
273 let alice_repo = cluster.alice_repo(); 206 let alice_repo = cluster.alice_repo();
274 let bob_repo = cluster.bob_repo(); 207 let bob_repo = cluster.bob_repo();
@@ -277,37 +210,25 @@ fn test_alice_closes_while_bob_comments() {
277 sync::sync(&alice_repo, "origin").unwrap(); 210 sync::sync(&alice_repo, "origin").unwrap();
278 sync::sync(&bob_repo, "origin").unwrap(); 211 sync::sync(&bob_repo, "origin").unwrap();
279 212
280 // Alice closes
281 close_issue(&alice_repo, &alice_ref, &alice()); 213 close_issue(&alice_repo, &alice_ref, &alice());
282 214
283 // Bob comments
284 let bob_ref = format!("refs/collab/issues/{}", id); 215 let bob_ref = format!("refs/collab/issues/{}", id);
285 add_comment(&bob_repo, &bob_ref, &bob(), "But wait..."); 216 add_comment(&bob_repo, &bob_ref, &bob(), "But wait...");
286 217
287 // Alice pushes first
288 sync::sync(&alice_repo, "origin").unwrap(); 218 sync::sync(&alice_repo, "origin").unwrap();
289
290 // Bob syncs — reconciles the fork
291 sync::sync(&bob_repo, "origin").unwrap(); 219 sync::sync(&bob_repo, "origin").unwrap();
292
293 // Alice syncs to get the merge
294 sync::sync(&alice_repo, "origin").unwrap(); 220 sync::sync(&alice_repo, "origin").unwrap();
295 221
296 // Both should see the comment AND the close
297 let alice_state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap(); 222 let alice_state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
298 let bob_state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap(); 223 let bob_state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap();
299 224
300 assert_eq!(alice_state.comments.len(), 1); 225 assert_eq!(alice_state.comments.len(), 1);
301 assert_eq!(bob_state.comments.len(), 1); 226 assert_eq!(bob_state.comments.len(), 1);
302 // Close happened, so status should be closed
303 // (both close and comment are in DAG; topo replay applies both)
304 assert_eq!(alice_state.status, bob_state.status); 227 assert_eq!(alice_state.status, bob_state.status);
305 } 228 }
306 229
307 #[test] 230 #[test]
308 fn test_sync_idempotent() { 231 fn test_sync_idempotent() {
309 // Syncing twice in a row should be a no-op the second time.
310
311 let cluster = TestCluster::new(); 232 let cluster = TestCluster::new();
312 let alice_repo = cluster.alice_repo(); 233 let alice_repo = cluster.alice_repo();
313 let bob_repo = cluster.bob_repo(); 234 let bob_repo = cluster.bob_repo();
@@ -317,7 +238,6 @@ fn test_sync_idempotent() {
317 sync::sync(&alice_repo, "origin").unwrap(); 238 sync::sync(&alice_repo, "origin").unwrap();
318 239
319 sync::sync(&bob_repo, "origin").unwrap(); 240 sync::sync(&bob_repo, "origin").unwrap();
320 // Sync again immediately — should not fail or duplicate
321 sync::sync(&bob_repo, "origin").unwrap(); 241 sync::sync(&bob_repo, "origin").unwrap();
322 242
323 let bob_ref = format!("refs/collab/issues/{}", id); 243 let bob_ref = format!("refs/collab/issues/{}", id);
@@ -327,24 +247,18 @@ fn test_sync_idempotent() {
327 247
328 #[test] 248 #[test]
329 fn test_three_user_convergence() { 249 fn test_three_user_convergence() {
330 // Three users (Alice, Bob, Charlie) all working on the same issue.
331 // Charlie uses Alice's repo path as a second remote.
332
333 let cluster = TestCluster::new(); 250 let cluster = TestCluster::new();
334 let alice_repo = cluster.alice_repo(); 251 let alice_repo = cluster.alice_repo();
335 let bob_repo = cluster.bob_repo(); 252 let bob_repo = cluster.bob_repo();
336 253
337 // Alice creates issue, everyone syncs
338 let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Three users"); 254 let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Three users");
339 sync::sync(&alice_repo, "origin").unwrap(); 255 sync::sync(&alice_repo, "origin").unwrap();
340 sync::sync(&bob_repo, "origin").unwrap(); 256 sync::sync(&bob_repo, "origin").unwrap();
341 257
342 // Alice and Bob both comment
343 add_comment(&alice_repo, &alice_ref, &alice(), "Alice's comment"); 258 add_comment(&alice_repo, &alice_ref, &alice(), "Alice's comment");
344 let bob_ref = format!("refs/collab/issues/{}", id); 259 let bob_ref = format!("refs/collab/issues/{}", id);
345 add_comment(&bob_repo, &bob_ref, &bob(), "Bob's comment"); 260 add_comment(&bob_repo, &bob_ref, &bob(), "Bob's comment");
346 261
347 // Alice syncs, Bob syncs, Alice syncs again (full convergence)
348 sync::sync(&alice_repo, "origin").unwrap(); 262 sync::sync(&alice_repo, "origin").unwrap();
349 sync::sync(&bob_repo, "origin").unwrap(); 263 sync::sync(&bob_repo, "origin").unwrap();
350 sync::sync(&alice_repo, "origin").unwrap(); 264 sync::sync(&alice_repo, "origin").unwrap();
@@ -362,7 +276,6 @@ fn test_patch_review_across_repos() {
362 let alice_repo = cluster.alice_repo(); 276 let alice_repo = cluster.alice_repo();
363 let bob_repo = cluster.bob_repo(); 277 let bob_repo = cluster.bob_repo();
364 278
365 // Alice creates a patch
366 let event = Event { 279 let event = Event {
367 timestamp: now(), 280 timestamp: now(),
368 author: alice(), 281 author: alice(),
@@ -371,19 +284,19 @@ fn test_patch_review_across_repos() {
371 body: "Please review".to_string(), 284 body: "Please review".to_string(),
372 base_ref: "main".to_string(), 285 base_ref: "main".to_string(),
373 head_commit: "abc123".to_string(), 286 head_commit: "abc123".to_string(),
287 fixes: None,
374 }, 288 },
375 }; 289 };
376 let oid = dag::create_root_event(&alice_repo, &event).unwrap(); 290 let sk = test_signing_key();
291 let oid = dag::create_root_event(&alice_repo, &event, &sk).unwrap();
377 let id = oid.to_string(); 292 let id = oid.to_string();
378 let alice_ref = format!("refs/collab/patches/{}", id); 293 let alice_ref = format!("refs/collab/patches/{}", id);
379 alice_repo 294 alice_repo
380 .reference(&alice_ref, oid, false, "patch create") 295 .reference(&alice_ref, oid, false, "patch create")
381 .unwrap(); 296 .unwrap();
382 297
383 // Alice syncs
384 sync::sync(&alice_repo, "origin").unwrap(); 298 sync::sync(&alice_repo, "origin").unwrap();
385 299
386 // Bob syncs, reviews the patch
387 sync::sync(&bob_repo, "origin").unwrap(); 300 sync::sync(&bob_repo, "origin").unwrap();
388 let bob_ref = format!("refs/collab/patches/{}", id); 301 let bob_ref = format!("refs/collab/patches/{}", id);
389 let review_event = Event { 302 let review_event = Event {
@@ -394,10 +307,9 @@ fn test_patch_review_across_repos() {
394 body: "LGTM!".to_string(), 307 body: "LGTM!".to_string(),
395 }, 308 },
396 }; 309 };
397 dag::append_event(&bob_repo, &bob_ref, &review_event).unwrap(); 310 dag::append_event(&bob_repo, &bob_ref, &review_event, &sk).unwrap();
398 sync::sync(&bob_repo, "origin").unwrap(); 311 sync::sync(&bob_repo, "origin").unwrap();
399 312
400 // Alice syncs and sees the review
401 sync::sync(&alice_repo, "origin").unwrap(); 313 sync::sync(&alice_repo, "origin").unwrap();
402 let state = PatchState::from_ref(&alice_repo, &alice_ref, &id).unwrap(); 314 let state = PatchState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
403 assert_eq!(state.reviews.len(), 1); 315 assert_eq!(state.reviews.len(), 1);
@@ -407,13 +319,10 @@ fn test_patch_review_across_repos() {
407 319
408 #[test] 320 #[test]
409 fn test_concurrent_review_and_revise() { 321 fn test_concurrent_review_and_revise() {
410 // Bob reviews while Alice revises the patch concurrently.
411
412 let cluster = TestCluster::new(); 322 let cluster = TestCluster::new();
413 let alice_repo = cluster.alice_repo(); 323 let alice_repo = cluster.alice_repo();
414 let bob_repo = cluster.bob_repo(); 324 let bob_repo = cluster.bob_repo();
415 325
416 // Alice creates patch, syncs
417 let event = Event { 326 let event = Event {
418 timestamp: now(), 327 timestamp: now(),
419 author: alice(), 328 author: alice(),
@@ -422,9 +331,11 @@ fn test_concurrent_review_and_revise() {
422 body: "".to_string(), 331 body: "".to_string(),
423 base_ref: "main".to_string(), 332 base_ref: "main".to_string(),
424 head_commit: "v1".to_string(), 333 head_commit: "v1".to_string(),
334 fixes: None,
425 }, 335 },
426 }; 336 };
427 let oid = dag::create_root_event(&alice_repo, &event).unwrap(); 337 let sk = test_signing_key();
338 let oid = dag::create_root_event(&alice_repo, &event, &sk).unwrap();
428 let id = oid.to_string(); 339 let id = oid.to_string();
429 let alice_ref = format!("refs/collab/patches/{}", id); 340 let alice_ref = format!("refs/collab/patches/{}", id);
430 alice_repo 341 alice_repo
@@ -433,7 +344,6 @@ fn test_concurrent_review_and_revise() {
433 sync::sync(&alice_repo, "origin").unwrap(); 344 sync::sync(&alice_repo, "origin").unwrap();
434 sync::sync(&bob_repo, "origin").unwrap(); 345 sync::sync(&bob_repo, "origin").unwrap();
435 346
436 // Alice revises (without syncing)
437 let revise_event = Event { 347 let revise_event = Event {
438 timestamp: now(), 348 timestamp: now(),
439 author: alice(), 349 author: alice(),
@@ -442,9 +352,8 @@ fn test_concurrent_review_and_revise() {
442 head_commit: "v2".to_string(), 352 head_commit: "v2".to_string(),
443 }, 353 },
444 }; 354 };
445 dag::append_event(&alice_repo, &alice_ref, &revise_event).unwrap(); 355 dag::append_event(&alice_repo, &alice_ref, &revise_event, &sk).unwrap();
446 356
447 // Bob reviews (without syncing)
448 let bob_ref = format!("refs/collab/patches/{}", id); 357 let bob_ref = format!("refs/collab/patches/{}", id);
449 let review_event = Event { 358 let review_event = Event {
450 timestamp: now(), 359 timestamp: now(),
@@ -454,49 +363,40 @@ fn test_concurrent_review_and_revise() {
454 body: "Needs work".to_string(), 363 body: "Needs work".to_string(),
455 }, 364 },
456 }; 365 };
457 dag::append_event(&bob_repo, &bob_ref, &review_event).unwrap(); 366 dag::append_event(&bob_repo, &bob_ref, &review_event, &sk).unwrap();
458 367
459 // Both sync
460 sync::sync(&alice_repo, "origin").unwrap(); 368 sync::sync(&alice_repo, "origin").unwrap();
461 sync::sync(&bob_repo, "origin").unwrap(); 369 sync::sync(&bob_repo, "origin").unwrap();
462 sync::sync(&alice_repo, "origin").unwrap(); 370 sync::sync(&alice_repo, "origin").unwrap();
463 371
464 // Both should see revise + review
465 let alice_state = PatchState::from_ref(&alice_repo, &alice_ref, &id).unwrap(); 372 let alice_state = PatchState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
466 let bob_state = PatchState::from_ref(&bob_repo, &bob_ref, &id).unwrap(); 373 let bob_state = PatchState::from_ref(&bob_repo, &bob_ref, &id).unwrap();
467 374
468 assert_eq!(alice_state.reviews.len(), 1); 375 assert_eq!(alice_state.reviews.len(), 1);
469 assert_eq!(bob_state.reviews.len(), 1); 376 assert_eq!(bob_state.reviews.len(), 1);
470 // The revise should have updated the head_commit
471 assert_eq!(alice_state.head_commit, bob_state.head_commit); 377 assert_eq!(alice_state.head_commit, bob_state.head_commit);
472 } 378 }
473 379
474 #[test] 380 #[test]
475 fn test_multiple_rounds_of_sync() { 381 fn test_multiple_rounds_of_sync() {
476 // Simulate a realistic back-and-forth conversation on an issue.
477
478 let cluster = TestCluster::new(); 382 let cluster = TestCluster::new();
479 let alice_repo = cluster.alice_repo(); 383 let alice_repo = cluster.alice_repo();
480 let bob_repo = cluster.bob_repo(); 384 let bob_repo = cluster.bob_repo();
481 385
482 // Round 1: Alice opens issue
483 let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Discussion thread"); 386 let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Discussion thread");
484 sync::sync(&alice_repo, "origin").unwrap(); 387 sync::sync(&alice_repo, "origin").unwrap();
485 sync::sync(&bob_repo, "origin").unwrap(); 388 sync::sync(&bob_repo, "origin").unwrap();
486 389
487 let bob_ref = format!("refs/collab/issues/{}", id); 390 let bob_ref = format!("refs/collab/issues/{}", id);
488 391
489 // Round 2: Bob comments
490 add_comment(&bob_repo, &bob_ref, &bob(), "First response"); 392 add_comment(&bob_repo, &bob_ref, &bob(), "First response");
491 sync::sync(&bob_repo, "origin").unwrap(); 393 sync::sync(&bob_repo, "origin").unwrap();
492 sync::sync(&alice_repo, "origin").unwrap(); 394 sync::sync(&alice_repo, "origin").unwrap();
493 395
494 // Round 3: Alice replies
495 add_comment(&alice_repo, &alice_ref, &alice(), "Thanks for the input"); 396 add_comment(&alice_repo, &alice_ref, &alice(), "Thanks for the input");
496 sync::sync(&alice_repo, "origin").unwrap(); 397 sync::sync(&alice_repo, "origin").unwrap();
497 sync::sync(&bob_repo, "origin").unwrap(); 398 sync::sync(&bob_repo, "origin").unwrap();
498 399
499 // Round 4: Bob closes
500 close_issue(&bob_repo, &bob_ref, &bob()); 400 close_issue(&bob_repo, &bob_ref, &bob());
501 sync::sync(&bob_repo, "origin").unwrap(); 401 sync::sync(&bob_repo, "origin").unwrap();
502 sync::sync(&alice_repo, "origin").unwrap(); 402 sync::sync(&alice_repo, "origin").unwrap();
@@ -507,3 +407,228 @@ fn test_multiple_rounds_of_sync() {
507 assert_eq!(state.comments[0].body, "First response"); 407 assert_eq!(state.comments[0].body, "First response");
508 assert_eq!(state.comments[1].body, "Thanks for the input"); 408 assert_eq!(state.comments[1].body, "Thanks for the input");
509 } 409 }
410
411 // ---------------------------------------------------------------------------
412 // T022: Signed issue sync succeeds
413 // ---------------------------------------------------------------------------
414
415 #[test]
416 fn test_signed_issue_sync_succeeds() {
417 let cluster = TestCluster::new();
418 let alice_repo = cluster.alice_repo();
419 let bob_repo = cluster.bob_repo();
420
421 // Alice creates a signed issue (open_issue uses signing)
422 let (_ref_name, id) = open_issue(&alice_repo, &alice(), "Signed bug report");
423 sync::sync(&alice_repo, "origin").unwrap();
424
425 // Bob syncs — should succeed since all events are signed
426 sync::sync(&bob_repo, "origin").unwrap();
427
428 // Verify issue is present on Bob's side
429 let bob_ref = format!("refs/collab/issues/{}", id);
430 let state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap();
431 assert_eq!(state.title, "Signed bug report");
432 assert_eq!(state.author.name, "Alice");
433 assert_eq!(state.status, IssueStatus::Open);
434
435 // Verify the event is actually signed
436 let results = signing::verify_ref(&bob_repo, &bob_ref).unwrap();
437 assert_eq!(results.len(), 1);
438 assert_eq!(results[0].status, signing::VerifyStatus::Valid);
439 }
440
441 // ---------------------------------------------------------------------------
442 // T023: Unsigned event sync is rejected
443 // ---------------------------------------------------------------------------
444
445 #[test]
446 fn test_unsigned_event_sync_rejected() {
447 // Set up Alice's repo with an unsigned event directly via git2
448 let alice_dir = TempDir::new().unwrap();
449 let alice_repo = common::init_repo(alice_dir.path(), &alice());
450 // Create initial commit so repo is not empty
451 {
452 let sig = git2::Signature::now("Alice", "alice@example.com").unwrap();
453 let tree_oid = alice_repo.treebuilder(None).unwrap().write().unwrap();
454 let tree = alice_repo.find_tree(tree_oid).unwrap();
455 alice_repo
456 .commit(Some("refs/heads/main"), &sig, &sig, "init", &tree, &[])
457 .unwrap();
458 }
459
460 // Create an unsigned event
461 let event = Event {
462 timestamp: now(),
463 author: alice(),
464 action: Action::IssueOpen {
465 title: "Unsigned issue".to_string(),
466 body: "No signature".to_string(),
467 },
468 };
469 let oid = create_unsigned_event(&alice_repo, &event);
470 let id = oid.to_string();
471 let ref_name = format!("refs/collab/issues/{}", id);
472 alice_repo
473 .reference(&ref_name, oid, false, "unsigned issue")
474 .unwrap();
475
476 // Verify the ref directly — should show Missing status
477 let results = signing::verify_ref(&alice_repo, &ref_name).unwrap();
478 assert_eq!(results.len(), 1);
479 assert_eq!(results[0].status, signing::VerifyStatus::Missing);
480 assert_eq!(results[0].commit_id, oid);
481 let error_msg = results[0].error.as_deref().unwrap();
482 assert!(
483 error_msg.contains("missing signature"),
484 "expected 'missing signature' in error, got: {}",
485 error_msg
486 );
487 }
488
489 // ---------------------------------------------------------------------------
490 // T024: Tampered event sync is rejected
491 // ---------------------------------------------------------------------------
492
493 #[test]
494 fn test_tampered_event_sync_rejected() {
495 // Set up a repo with a tampered event
496 let dir = TempDir::new().unwrap();
497 let repo = common::init_repo(dir.path(), &alice());
498 // Create initial commit
499 {
500 let sig = git2::Signature::now("Alice", "alice@example.com").unwrap();
501 let tree_oid = repo.treebuilder(None).unwrap().write().unwrap();
502 let tree = repo.find_tree(tree_oid).unwrap();
503 repo.commit(Some("refs/heads/main"), &sig, &sig, "init", &tree, &[])
504 .unwrap();
505 }
506
507 // Create a tampered event (signed then modified)
508 let event = Event {
509 timestamp: now(),
510 author: alice(),
511 action: Action::IssueOpen {
512 title: "Tampered issue".to_string(),
513 body: "Will be tampered".to_string(),
514 },
515 };
516 let oid = create_tampered_event(&repo, &event);
517 let id = oid.to_string();
518 let ref_name = format!("refs/collab/issues/{}", id);
519 repo.reference(&ref_name, oid, false, "tampered issue")
520 .unwrap();
521
522 // Verify the ref — should show Invalid status
523 let results = signing::verify_ref(&repo, &ref_name).unwrap();
524 assert_eq!(results.len(), 1);
525 assert_eq!(results[0].status, signing::VerifyStatus::Invalid);
526 assert_eq!(results[0].commit_id, oid);
527 let error_msg = results[0].error.as_deref().unwrap();
528 assert!(
529 error_msg.contains("invalid signature"),
530 "expected 'invalid signature' in error, got: {}",
531 error_msg
532 );
533 }
534
535 // ---------------------------------------------------------------------------
536 // T028: Merge commit during reconciliation has valid Ed25519 signature
537 // ---------------------------------------------------------------------------
538
539 #[test]
540 fn test_reconciliation_merge_commit_is_signed() {
541 let cluster = TestCluster::new();
542 let alice_repo = cluster.alice_repo();
543 let bob_repo = cluster.bob_repo();
544
545 // Alice creates an issue and syncs it to remote
546 let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Divergent history test");
547 sync::sync(&alice_repo, "origin").unwrap();
548
549 // Bob syncs to get the issue
550 sync::sync(&bob_repo, "origin").unwrap();
551 let bob_ref = format!("refs/collab/issues/{}", id);
552
553 // Both add comments — creating divergent history
554 add_comment(&alice_repo, &alice_ref, &alice(), "Alice's divergent comment");
555 add_comment(&bob_repo, &bob_ref, &bob(), "Bob's divergent comment");
556
557 // Bob pushes his comment to remote
558 sync::sync(&bob_repo, "origin").unwrap();
559
560 // Alice syncs — this triggers reconciliation (merge commit) because
561 // Alice has a local comment and Bob's comment comes from remote
562 sync::sync(&alice_repo, "origin").unwrap();
563
564 // Walk the DAG and find the merge event
565 let events = dag::walk_events(&alice_repo, &alice_ref).unwrap();
566 let merge_events: Vec<_> = events
567 .iter()
568 .filter(|(_, e)| matches!(e.action, Action::Merge))
569 .collect();
570 assert!(
571 !merge_events.is_empty(),
572 "Expected at least one merge event after reconciliation"
573 );
574
575 // Verify ALL events on the ref have valid signatures (including the merge)
576 let results = signing::verify_ref(&alice_repo, &alice_ref).unwrap();
577 assert!(
578 results.len() >= 4,
579 "Expected at least 4 commits (open + 2 comments + merge), got {}",
580 results.len()
581 );
582
583 for result in &results {
584 assert_eq!(
585 result.status,
586 signing::VerifyStatus::Valid,
587 "Commit {} has status {:?}, expected Valid. Error: {:?}",
588 result.commit_id,
589 result.status,
590 result.error
591 );
592 }
593
594 // Verify the merge commit specifically is signed by the syncing user's key
595 // (the key stored in the config dir, which sync::sync() loads)
596 let config_dir = dirs::config_dir()
597 .unwrap_or_else(|| {
598 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
599 std::path::PathBuf::from(home).join(".config")
600 })
601 .join("git-collab");
602 let syncing_vk = signing::load_verifying_key(&config_dir).unwrap();
603 let syncing_pubkey = base64::Engine::encode(
604 &base64::engine::general_purpose::STANDARD,
605 syncing_vk.to_bytes(),
606 );
607
608 // Find the merge commit and check its pubkey matches the syncing user's key
609 let tip = alice_repo.refname_to_id(&alice_ref).unwrap();
610 let commit = alice_repo.find_commit(tip).unwrap();
611 // The tip should be the merge commit (it's the most recent)
612 let tree = commit.tree().unwrap();
613 let entry = tree.get_name("event.json").unwrap();
614 let blob = alice_repo.find_blob(entry.id()).unwrap();
615 let signed: signing::SignedEvent = serde_json::from_slice(blob.content()).unwrap();
616
617 assert!(
618 matches!(signed.event.action, Action::Merge),
619 "Expected tip commit to be a Merge event, got {:?}",
620 signed.event.action
621 );
622 assert_eq!(
623 signed.pubkey, syncing_pubkey,
624 "Merge commit should be signed by the syncing user's key"
625 );
626
627 // Verify the signature is cryptographically valid
628 let status = signing::verify_signed_event(&signed).unwrap();
629 assert_eq!(
630 status,
631 signing::VerifyStatus::Valid,
632 "Merge commit signature must be valid"
633 );
634 }