0100af26
Sign events with Ed25519 and verify signatures on sync
a73x 2026-03-21 07:24
Commit message
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 | } | ||