a3bc0526
Add collab log, delete, benchmarks and JSON state tests
a73x 2026-03-21 16:21
Commit message
Cargo.lock
| Old | New | ||
|---|---|---|---|
| @@ -115,7 +115,16 @@ version = "0.5.3" | |||
| 115 | source = "registry+https://github.com/rust-lang/crates.io-index" | 115 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 116 | checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" | 116 | checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" |
| 117 | dependencies = [ | 117 | dependencies = [ |
| 118 | "bit-vec", | 118 | "bit-vec 0.6.3", |
| 119 | ] | ||
| 120 | |||
| 121 | [[package]] | ||
| 122 | name = "bit-set" | ||
| 123 | version = "0.8.0" | ||
| 124 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 125 | checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" | ||
| 126 | dependencies = [ | ||
| 127 | "bit-vec 0.8.0", | ||
| 119 | ] | 128 | ] |
| 120 | 129 | ||
| 121 | [[package]] | 130 | [[package]] |
| @@ -125,6 +134,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 125 | checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" | 134 | checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" |
| 126 | 135 | ||
| 127 | [[package]] | 136 | [[package]] |
| 137 | name = "bit-vec" | ||
| 138 | version = "0.8.0" | ||
| 139 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 140 | checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" | ||
| 141 | |||
| 142 | [[package]] | ||
| 128 | name = "bitflags" | 143 | name = "bitflags" |
| 129 | version = "1.3.2" | 144 | version = "1.3.2" |
| 130 | source = "registry+https://github.com/rust-lang/crates.io-index" | 145 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -227,6 +242,15 @@ dependencies = [ | |||
| 227 | ] | 242 | ] |
| 228 | 243 | ||
| 229 | [[package]] | 244 | [[package]] |
| 245 | name = "clap_complete" | ||
| 246 | version = "4.6.0" | ||
| 247 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 248 | checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb" | ||
| 249 | dependencies = [ | ||
| 250 | "clap", | ||
| 251 | ] | ||
| 252 | |||
| 253 | [[package]] | ||
| 230 | name = "clap_derive" | 254 | name = "clap_derive" |
| 231 | version = "4.6.0" | 255 | version = "4.6.0" |
| 232 | source = "registry+https://github.com/rust-lang/crates.io-index" | 256 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -528,7 +552,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" | |||
| 528 | dependencies = [ | 552 | dependencies = [ |
| 529 | "curve25519-dalek", | 553 | "curve25519-dalek", |
| 530 | "ed25519", | 554 | "ed25519", |
| 531 | "rand_core", | 555 | "rand_core 0.6.4", |
| 532 | "serde", | 556 | "serde", |
| 533 | "sha2", | 557 | "sha2", |
| 534 | "subtle", | 558 | "subtle", |
| @@ -572,7 +596,7 @@ version = "0.11.0" | |||
| 572 | source = "registry+https://github.com/rust-lang/crates.io-index" | 596 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 573 | checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" | 597 | checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" |
| 574 | dependencies = [ | 598 | dependencies = [ |
| 575 | "bit-set", | 599 | "bit-set 0.5.3", |
| 576 | "regex", | 600 | "regex", |
| 577 | ] | 601 | ] |
| 578 | 602 | ||
| @@ -697,12 +721,14 @@ dependencies = [ | |||
| 697 | "base64", | 721 | "base64", |
| 698 | "chrono", | 722 | "chrono", |
| 699 | "clap", | 723 | "clap", |
| 724 | "clap_complete", | ||
| 700 | "clap_mangen", | 725 | "clap_mangen", |
| 701 | "crossterm", | 726 | "crossterm", |
| 702 | "dirs", | 727 | "dirs", |
| 703 | "ed25519-dalek", | 728 | "ed25519-dalek", |
| 704 | "git2", | 729 | "git2", |
| 705 | "rand_core", | 730 | "proptest", |
| 731 | "rand_core 0.6.4", | ||
| 706 | "ratatui", | 732 | "ratatui", |
| 707 | "serde", | 733 | "serde", |
| 708 | "serde_json", | 734 | "serde_json", |
| @@ -1356,7 +1382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1356 | checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" | 1382 | checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" |
| 1357 | dependencies = [ | 1383 | dependencies = [ |
| 1358 | "phf_shared", | 1384 | "phf_shared", |
| 1359 | "rand", | 1385 | "rand 0.8.5", |
| 1360 | ] | 1386 | ] |
| 1361 | 1387 | ||
| 1362 | [[package]] | 1388 | [[package]] |
| @@ -1419,6 +1445,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1419 | checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" | 1445 | checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" |
| 1420 | 1446 | ||
| 1421 | [[package]] | 1447 | [[package]] |
| 1448 | name = "ppv-lite86" | ||
| 1449 | version = "0.2.21" | ||
| 1450 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1451 | checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" | ||
| 1452 | dependencies = [ | ||
| 1453 | "zerocopy", | ||
| 1454 | ] | ||
| 1455 | |||
| 1456 | [[package]] | ||
| 1422 | name = "prettyplease" | 1457 | name = "prettyplease" |
| 1423 | version = "0.2.37" | 1458 | version = "0.2.37" |
| 1424 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1459 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1438,6 +1473,31 @@ dependencies = [ | |||
| 1438 | ] | 1473 | ] |
| 1439 | 1474 | ||
| 1440 | [[package]] | 1475 | [[package]] |
| 1476 | name = "proptest" | ||
| 1477 | version = "1.10.0" | ||
| 1478 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1479 | checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" | ||
| 1480 | dependencies = [ | ||
| 1481 | "bit-set 0.8.0", | ||
| 1482 | "bit-vec 0.8.0", | ||
| 1483 | "bitflags 2.11.0", | ||
| 1484 | "num-traits", | ||
| 1485 | "rand 0.9.2", | ||
| 1486 | "rand_chacha", | ||
| 1487 | "rand_xorshift", | ||
| 1488 | "regex-syntax", | ||
| 1489 | "rusty-fork", | ||
| 1490 | "tempfile", | ||
| 1491 | "unarray", | ||
| 1492 | ] | ||
| 1493 | |||
| 1494 | [[package]] | ||
| 1495 | name = "quick-error" | ||
| 1496 | version = "1.2.3" | ||
| 1497 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1498 | checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" | ||
| 1499 | |||
| 1500 | [[package]] | ||
| 1441 | name = "quote" | 1501 | name = "quote" |
| 1442 | version = "1.0.45" | 1502 | version = "1.0.45" |
| 1443 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1503 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1464,7 +1524,27 @@ version = "0.8.5" | |||
| 1464 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1524 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1465 | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" | 1525 | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" |
| 1466 | dependencies = [ | 1526 | dependencies = [ |
| 1467 | "rand_core", | 1527 | "rand_core 0.6.4", |
| 1528 | ] | ||
| 1529 | |||
| 1530 | [[package]] | ||
| 1531 | name = "rand" | ||
| 1532 | version = "0.9.2" | ||
| 1533 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1534 | checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" | ||
| 1535 | dependencies = [ | ||
| 1536 | "rand_chacha", | ||
| 1537 | "rand_core 0.9.5", | ||
| 1538 | ] | ||
| 1539 | |||
| 1540 | [[package]] | ||
| 1541 | name = "rand_chacha" | ||
| 1542 | version = "0.9.0" | ||
| 1543 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1544 | checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" | ||
| 1545 | dependencies = [ | ||
| 1546 | "ppv-lite86", | ||
| 1547 | "rand_core 0.9.5", | ||
| 1468 | ] | 1548 | ] |
| 1469 | 1549 | ||
| 1470 | [[package]] | 1550 | [[package]] |
| @@ -1477,6 +1557,24 @@ dependencies = [ | |||
| 1477 | ] | 1557 | ] |
| 1478 | 1558 | ||
| 1479 | [[package]] | 1559 | [[package]] |
| 1560 | name = "rand_core" | ||
| 1561 | version = "0.9.5" | ||
| 1562 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1563 | checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" | ||
| 1564 | dependencies = [ | ||
| 1565 | "getrandom 0.3.4", | ||
| 1566 | ] | ||
| 1567 | |||
| 1568 | [[package]] | ||
| 1569 | name = "rand_xorshift" | ||
| 1570 | version = "0.4.0" | ||
| 1571 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1572 | checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" | ||
| 1573 | dependencies = [ | ||
| 1574 | "rand_core 0.9.5", | ||
| 1575 | ] | ||
| 1576 | |||
| 1577 | [[package]] | ||
| 1480 | name = "ratatui" | 1578 | name = "ratatui" |
| 1481 | version = "0.30.0" | 1579 | version = "0.30.0" |
| 1482 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1580 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1645,6 +1743,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1645 | checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" | 1743 | checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" |
| 1646 | 1744 | ||
| 1647 | [[package]] | 1745 | [[package]] |
| 1746 | name = "rusty-fork" | ||
| 1747 | version = "0.3.1" | ||
| 1748 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1749 | checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" | ||
| 1750 | dependencies = [ | ||
| 1751 | "fnv", | ||
| 1752 | "quick-error", | ||
| 1753 | "tempfile", | ||
| 1754 | "wait-timeout", | ||
| 1755 | ] | ||
| 1756 | |||
| 1757 | [[package]] | ||
| 1648 | name = "ryu" | 1758 | name = "ryu" |
| 1649 | version = "1.0.23" | 1759 | version = "1.0.23" |
| 1650 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1760 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1759,7 +1869,7 @@ version = "2.2.0" | |||
| 1759 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1869 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1760 | checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" | 1870 | checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" |
| 1761 | dependencies = [ | 1871 | dependencies = [ |
| 1762 | "rand_core", | 1872 | "rand_core 0.6.4", |
| 1763 | ] | 1873 | ] |
| 1764 | 1874 | ||
| 1765 | [[package]] | 1875 | [[package]] |
| @@ -2022,6 +2132,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2022 | checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" | 2132 | checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" |
| 2023 | 2133 | ||
| 2024 | [[package]] | 2134 | [[package]] |
| 2135 | name = "unarray" | ||
| 2136 | version = "0.1.4" | ||
| 2137 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2138 | checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" | ||
| 2139 | |||
| 2140 | [[package]] | ||
| 2025 | name = "unicode-ident" | 2141 | name = "unicode-ident" |
| 2026 | version = "1.0.24" | 2142 | version = "1.0.24" |
| 2027 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2143 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2114,6 +2230,15 @@ dependencies = [ | |||
| 2114 | ] | 2230 | ] |
| 2115 | 2231 | ||
| 2116 | [[package]] | 2232 | [[package]] |
| 2233 | name = "wait-timeout" | ||
| 2234 | version = "0.2.1" | ||
| 2235 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2236 | checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" | ||
| 2237 | dependencies = [ | ||
| 2238 | "libc", | ||
| 2239 | ] | ||
| 2240 | |||
| 2241 | [[package]] | ||
| 2117 | name = "wasi" | 2242 | name = "wasi" |
| 2118 | version = "0.11.1+wasi-snapshot-preview1" | 2243 | version = "0.11.1+wasi-snapshot-preview1" |
| 2119 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2244 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2562,6 +2687,26 @@ dependencies = [ | |||
| 2562 | ] | 2687 | ] |
| 2563 | 2688 | ||
| 2564 | [[package]] | 2689 | [[package]] |
| 2690 | name = "zerocopy" | ||
| 2691 | version = "0.8.47" | ||
| 2692 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2693 | checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" | ||
| 2694 | dependencies = [ | ||
| 2695 | "zerocopy-derive", | ||
| 2696 | ] | ||
| 2697 | |||
| 2698 | [[package]] | ||
| 2699 | name = "zerocopy-derive" | ||
| 2700 | version = "0.8.47" | ||
| 2701 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2702 | checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" | ||
| 2703 | dependencies = [ | ||
| 2704 | "proc-macro2", | ||
| 2705 | "quote", | ||
| 2706 | "syn 2.0.117", | ||
| 2707 | ] | ||
| 2708 | |||
| 2709 | [[package]] | ||
| 2565 | name = "zerofrom" | 2710 | name = "zerofrom" |
| 2566 | version = "0.1.6" | 2711 | version = "0.1.6" |
| 2567 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2712 | source = "registry+https://github.com/rust-lang/crates.io-index" |
Cargo.toml
| Old | New | ||
|---|---|---|---|
| @@ -6,6 +6,7 @@ edition = "2021" | |||
| 6 | [dependencies] | 6 | [dependencies] |
| 7 | git2 = "0.19" | 7 | git2 = "0.19" |
| 8 | clap = { version = "4", features = ["derive"] } | 8 | clap = { version = "4", features = ["derive"] } |
| 9 | clap_complete = "4" | ||
| 9 | serde = { version = "1", features = ["derive"] } | 10 | serde = { version = "1", features = ["derive"] } |
| 10 | serde_json = "1" | 11 | serde_json = "1" |
| 11 | chrono = { version = "0.4", features = ["serde"] } | 12 | chrono = { version = "0.4", features = ["serde"] } |
| @@ -19,6 +20,7 @@ dirs = "5" | |||
| 19 | 20 | ||
| 20 | [build-dependencies] | 21 | [build-dependencies] |
| 21 | clap = { version = "4", features = ["derive"] } | 22 | clap = { version = "4", features = ["derive"] } |
| 23 | clap_complete = "4" | ||
| 22 | clap_mangen = "0.2" | 24 | clap_mangen = "0.2" |
| 23 | 25 | ||
| 24 | [dev-dependencies] | 26 | [dev-dependencies] |
src/cli.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,4 +1,5 @@ | |||
| 1 | use clap::{Parser, Subcommand}; | 1 | use clap::{Parser, Subcommand}; |
| 2 | use clap_complete::Shell; | ||
| 2 | 3 | ||
| 3 | #[derive(Parser)] | 4 | #[derive(Parser)] |
| 4 | #[command( | 5 | #[command( |
| @@ -23,9 +24,25 @@ pub enum Commands { | |||
| 23 | #[command(subcommand)] | 24 | #[command(subcommand)] |
| 24 | Patch(PatchCmd), | 25 | Patch(PatchCmd), |
| 25 | 26 | ||
| 27 | /// Show project status overview | ||
| 28 | Status, | ||
| 29 | |||
| 26 | /// Interactive patch review dashboard | 30 | /// Interactive patch review dashboard |
| 27 | Dashboard, | 31 | Dashboard, |
| 28 | 32 | ||
| 33 | /// Show raw collab event stream in chronological order | ||
| 34 | Log { | ||
| 35 | /// Maximum number of events to show | ||
| 36 | #[arg(short = 'n', long)] | ||
| 37 | limit: Option<usize>, | ||
| 38 | }, | ||
| 39 | |||
| 40 | /// Generate shell completions | ||
| 41 | Completions { | ||
| 42 | /// Shell to generate completions for | ||
| 43 | shell: Shell, | ||
| 44 | }, | ||
| 45 | |||
| 29 | /// Sync with a remote (fetch, reconcile, push) | 46 | /// Sync with a remote (fetch, reconcile, push) |
| 30 | Sync { | 47 | Sync { |
| 31 | /// Remote name (default: origin) | 48 | /// Remote name (default: origin) |
| @@ -62,11 +79,23 @@ pub enum IssueCmd { | |||
| 62 | /// Show closed issues too | 79 | /// Show closed issues too |
| 63 | #[arg(short = 'a', long)] | 80 | #[arg(short = 'a', long)] |
| 64 | all: bool, | 81 | all: bool, |
| 82 | /// Maximum number of issues to display | ||
| 83 | #[arg(short = 'n', long)] | ||
| 84 | limit: Option<usize>, | ||
| 85 | /// Number of issues to skip before displaying | ||
| 86 | #[arg(long)] | ||
| 87 | offset: Option<usize>, | ||
| 88 | /// Output as JSON | ||
| 89 | #[arg(long)] | ||
| 90 | json: bool, | ||
| 65 | }, | 91 | }, |
| 66 | /// Show issue details | 92 | /// Show issue details |
| 67 | Show { | 93 | Show { |
| 68 | /// Issue ID (prefix match) | 94 | /// Issue ID (prefix match) |
| 69 | id: String, | 95 | id: String, |
| 96 | /// Output as JSON | ||
| 97 | #[arg(long)] | ||
| 98 | json: bool, | ||
| 70 | }, | 99 | }, |
| 71 | /// Comment on an issue | 100 | /// Comment on an issue |
| 72 | Comment { | 101 | Comment { |
| @@ -177,11 +206,23 @@ pub enum PatchCmd { | |||
| 177 | /// Show closed/merged patches too | 206 | /// Show closed/merged patches too |
| 178 | #[arg(short = 'a', long)] | 207 | #[arg(short = 'a', long)] |
| 179 | all: bool, | 208 | all: bool, |
| 209 | /// Maximum number of patches to display | ||
| 210 | #[arg(short = 'n', long)] | ||
| 211 | limit: Option<usize>, | ||
| 212 | /// Number of patches to skip before displaying | ||
| 213 | #[arg(long)] | ||
| 214 | offset: Option<usize>, | ||
| 215 | /// Output as JSON | ||
| 216 | #[arg(long)] | ||
| 217 | json: bool, | ||
| 180 | }, | 218 | }, |
| 181 | /// Show patch details | 219 | /// Show patch details |
| 182 | Show { | 220 | Show { |
| 183 | /// Patch ID (prefix match) | 221 | /// Patch ID (prefix match) |
| 184 | id: String, | 222 | id: String, |
| 223 | /// Output as JSON | ||
| 224 | #[arg(long)] | ||
| 225 | json: bool, | ||
| 185 | }, | 226 | }, |
| 186 | /// Show diff between base and head | 227 | /// Show diff between base and head |
| 187 | Diff { | 228 | Diff { |
src/dag.rs
| Old | New | ||
|---|---|---|---|
| @@ -165,7 +165,20 @@ pub fn walk_events(repo: &Repository, ref_name: &str) -> Result<Vec<(Oid, Event) | |||
| 165 | Ok(events) | 165 | Ok(events) |
| 166 | } | 166 | } |
| 167 | 167 | ||
| 168 | /// Reconcile a local ref with a remote ref. Returns the final tip OID. | 168 | /// Outcome of reconciling a local ref with a remote ref. |
| 169 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 170 | pub enum ReconcileOutcome { | ||
| 171 | /// Both refs already point to the same commit. No action taken. | ||
| 172 | AlreadyCurrent, | ||
| 173 | /// Local is ahead of remote. No action taken. | ||
| 174 | LocalAhead, | ||
| 175 | /// Local was fast-forwarded to the remote tip. | ||
| 176 | FastForward, | ||
| 177 | /// A merge commit was created to reconcile divergent histories. | ||
| 178 | Merge, | ||
| 179 | } | ||
| 180 | |||
| 181 | /// Reconcile a local ref with a remote ref. Returns the outcome and final tip OID. | ||
| 169 | /// | 182 | /// |
| 170 | /// - If they're the same: no-op | 183 | /// - If they're the same: no-op |
| 171 | /// - If remote is ancestor of local: local is ahead, no-op | 184 | /// - If remote is ancestor of local: local is ahead, no-op |
| @@ -177,25 +190,25 @@ pub fn reconcile( | |||
| 177 | remote_ref: &str, | 190 | remote_ref: &str, |
| 178 | merge_author: &crate::event::Author, | 191 | merge_author: &crate::event::Author, |
| 179 | signing_key: &ed25519_dalek::SigningKey, | 192 | signing_key: &ed25519_dalek::SigningKey, |
| 180 | ) -> Result<Oid, Error> { | 193 | ) -> Result<(Oid, ReconcileOutcome), Error> { |
| 181 | let local_oid = repo.refname_to_id(local_ref)?; | 194 | let local_oid = repo.refname_to_id(local_ref)?; |
| 182 | let remote_oid = repo.refname_to_id(remote_ref)?; | 195 | let remote_oid = repo.refname_to_id(remote_ref)?; |
| 183 | 196 | ||
| 184 | if local_oid == remote_oid { | 197 | if local_oid == remote_oid { |
| 185 | return Ok(local_oid); | 198 | return Ok((local_oid, ReconcileOutcome::AlreadyCurrent)); |
| 186 | } | 199 | } |
| 187 | 200 | ||
| 188 | let merge_base = repo.merge_base(local_oid, remote_oid)?; | 201 | let merge_base = repo.merge_base(local_oid, remote_oid)?; |
| 189 | 202 | ||
| 190 | if merge_base == remote_oid { | 203 | if merge_base == remote_oid { |
| 191 | // Remote is ancestor of local — local is ahead | 204 | // Remote is ancestor of local — local is ahead |
| 192 | return Ok(local_oid); | 205 | return Ok((local_oid, ReconcileOutcome::LocalAhead)); |
| 193 | } | 206 | } |
| 194 | 207 | ||
| 195 | if merge_base == local_oid { | 208 | if merge_base == local_oid { |
| 196 | // Local is ancestor of remote — fast-forward | 209 | // Local is ancestor of remote — fast-forward |
| 197 | repo.reference(local_ref, remote_oid, true, "fast-forward reconcile")?; | 210 | repo.reference(local_ref, remote_oid, true, "fast-forward reconcile")?; |
| 198 | return Ok(remote_oid); | 211 | return Ok((remote_oid, ReconcileOutcome::FastForward)); |
| 199 | } | 212 | } |
| 200 | 213 | ||
| 201 | // True fork — create merge commit with clock = max(local, remote) + 1 | 214 | // True fork — create merge commit with clock = max(local, remote) + 1 |
| @@ -238,7 +251,7 @@ pub fn reconcile( | |||
| 238 | &[&local_commit, &remote_commit], | 251 | &[&local_commit, &remote_commit], |
| 239 | )?; | 252 | )?; |
| 240 | 253 | ||
| 241 | Ok(oid) | 254 | Ok((oid, ReconcileOutcome::Merge)) |
| 242 | } | 255 | } |
| 243 | 256 | ||
| 244 | /// Migrate a DAG ref so that every event with clock=0 gets a sequential clock | 257 | /// Migrate a DAG ref so that every event with clock=0 gets a sequential clock |
src/event.rs
| Old | New | ||
|---|---|---|---|
| @@ -54,11 +54,12 @@ pub enum Action { | |||
| 54 | }, | 54 | }, |
| 55 | #[serde(rename = "issue.reopen")] | 55 | #[serde(rename = "issue.reopen")] |
| 56 | IssueReopen, | 56 | IssueReopen, |
| 57 | #[serde(rename = "patch.create")] | 57 | #[serde(rename = "patch.create", alias = "PatchCreate")] |
| 58 | PatchCreate { | 58 | PatchCreate { |
| 59 | title: String, | 59 | title: String, |
| 60 | body: String, | 60 | body: String, |
| 61 | base_ref: String, | 61 | base_ref: String, |
| 62 | #[serde(alias = "head_commit")] | ||
| 62 | branch: String, | 63 | branch: String, |
| 63 | #[serde(default, skip_serializing_if = "Option::is_none")] | 64 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 64 | fixes: Option<String>, | 65 | fixes: Option<String>, |
src/issue.rs
| Old | New | ||
|---|---|---|---|
| @@ -30,19 +30,63 @@ pub struct ListEntry { | |||
| 30 | pub unread: Option<usize>, | 30 | pub unread: Option<usize>, |
| 31 | } | 31 | } |
| 32 | 32 | ||
| 33 | pub fn list(repo: &Repository, show_closed: bool) -> Result<Vec<ListEntry>, crate::error::Error> { | 33 | pub fn list( |
| 34 | repo: &Repository, | ||
| 35 | show_closed: bool, | ||
| 36 | limit: Option<usize>, | ||
| 37 | offset: Option<usize>, | ||
| 38 | ) -> Result<Vec<ListEntry>, crate::error::Error> { | ||
| 34 | let issues = state::list_issues(repo)?; | 39 | let issues = state::list_issues(repo)?; |
| 35 | let entries = issues | 40 | let entries: Vec<_> = issues |
| 36 | .into_iter() | 41 | .into_iter() |
| 37 | .filter(|i| show_closed || i.status == IssueStatus::Open) | 42 | .filter(|i| show_closed || i.status == IssueStatus::Open) |
| 38 | .map(|issue| { | 43 | .map(|issue| { |
| 39 | let unread = count_unread(repo, &issue.id); | 44 | let unread = count_unread(repo, &issue.id); |
| 40 | ListEntry { issue, unread } | 45 | ListEntry { issue, unread } |
| 41 | }) | 46 | }) |
| 47 | .skip(offset.unwrap_or(0)) | ||
| 48 | .take(limit.unwrap_or(usize::MAX)) | ||
| 42 | .collect(); | 49 | .collect(); |
| 43 | Ok(entries) | 50 | Ok(entries) |
| 44 | } | 51 | } |
| 45 | 52 | ||
| 53 | pub fn list_to_writer( | ||
| 54 | repo: &Repository, | ||
| 55 | show_closed: bool, | ||
| 56 | limit: Option<usize>, | ||
| 57 | offset: Option<usize>, | ||
| 58 | writer: &mut dyn std::io::Write, | ||
| 59 | ) -> Result<(), crate::error::Error> { | ||
| 60 | let entries = list(repo, show_closed, limit, offset)?; | ||
| 61 | if entries.is_empty() { | ||
| 62 | writeln!(writer, "No issues found.").ok(); | ||
| 63 | return Ok(()); | ||
| 64 | } | ||
| 65 | for e in &entries { | ||
| 66 | let i = &e.issue; | ||
| 67 | let status = match i.status { | ||
| 68 | IssueStatus::Open => "open", | ||
| 69 | IssueStatus::Closed => "closed", | ||
| 70 | }; | ||
| 71 | let labels = if i.labels.is_empty() { | ||
| 72 | String::new() | ||
| 73 | } else { | ||
| 74 | format!(" [{}]", i.labels.join(", ")) | ||
| 75 | }; | ||
| 76 | let unread = match e.unread { | ||
| 77 | Some(n) if n > 0 => format!(" ({} new)", n), | ||
| 78 | _ => String::new(), | ||
| 79 | }; | ||
| 80 | writeln!( | ||
| 81 | writer, | ||
| 82 | "{:.8} {:6} {}{} (by {}){}", | ||
| 83 | i.id, status, i.title, labels, i.author.name, unread | ||
| 84 | ) | ||
| 85 | .ok(); | ||
| 86 | } | ||
| 87 | Ok(()) | ||
| 88 | } | ||
| 89 | |||
| 46 | /// Count events after the last-seen mark. Returns None if never viewed. | 90 | /// Count events after the last-seen mark. Returns None if never viewed. |
| 47 | fn count_unread(repo: &git2::Repository, id: &str) -> Option<usize> { | 91 | fn count_unread(repo: &git2::Repository, id: &str) -> Option<usize> { |
| 48 | let seen_ref = format!("refs/collab/local/seen/issues/{}", id); | 92 | let seen_ref = format!("refs/collab/local/seen/issues/{}", id); |
| @@ -63,6 +107,21 @@ fn count_unread(repo: &git2::Repository, id: &str) -> Option<usize> { | |||
| 63 | Some(revwalk.count()) | 107 | Some(revwalk.count()) |
| 64 | } | 108 | } |
| 65 | 109 | ||
| 110 | pub fn list_json(repo: &Repository, show_closed: bool) -> Result<String, crate::error::Error> { | ||
| 111 | let issues = state::list_issues(repo)?; | ||
| 112 | let filtered: Vec<_> = issues | ||
| 113 | .into_iter() | ||
| 114 | .filter(|i| show_closed || i.status == IssueStatus::Open) | ||
| 115 | .collect(); | ||
| 116 | Ok(serde_json::to_string_pretty(&filtered)?) | ||
| 117 | } | ||
| 118 | |||
| 119 | pub fn show_json(repo: &Repository, id_prefix: &str) -> Result<String, crate::error::Error> { | ||
| 120 | let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?; | ||
| 121 | let issue = IssueState::from_ref(repo, &ref_name, &id)?; | ||
| 122 | Ok(serde_json::to_string_pretty(&issue)?) | ||
| 123 | } | ||
| 124 | |||
| 66 | pub fn show(repo: &Repository, id_prefix: &str) -> Result<IssueState, crate::error::Error> { | 125 | pub fn show(repo: &Repository, id_prefix: &str) -> Result<IssueState, crate::error::Error> { |
| 67 | let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?; | 126 | let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?; |
| 68 | let issue = IssueState::from_ref(repo, &ref_name, &id)?; | 127 | let issue = IssueState::from_ref(repo, &ref_name, &id)?; |
src/lib.rs
| Old | New | ||
|---|---|---|---|
| @@ -5,9 +5,11 @@ pub mod error; | |||
| 5 | pub mod event; | 5 | pub mod event; |
| 6 | pub mod identity; | 6 | pub mod identity; |
| 7 | pub mod issue; | 7 | pub mod issue; |
| 8 | pub mod log; | ||
| 8 | pub mod patch; | 9 | pub mod patch; |
| 9 | pub mod state; | 10 | pub mod state; |
| 10 | pub mod signing; | 11 | pub mod signing; |
| 12 | pub mod status; | ||
| 11 | pub mod sync; | 13 | pub mod sync; |
| 12 | pub mod sync_lock; | 14 | pub mod sync_lock; |
| 13 | pub mod trust; | 15 | pub mod trust; |
| @@ -28,8 +30,13 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 28 | println!("Opened issue {:.8}", id); | 30 | println!("Opened issue {:.8}", id); |
| 29 | Ok(()) | 31 | Ok(()) |
| 30 | } | 32 | } |
| 31 | IssueCmd::List { all } => { | 33 | IssueCmd::List { all, limit, offset, json } => { |
| 32 | let entries = issue::list(repo, all)?; | 34 | if json { |
| 35 | let output = issue::list_json(repo, all)?; | ||
| 36 | println!("{}", output); | ||
| 37 | return Ok(()); | ||
| 38 | } | ||
| 39 | let entries = issue::list(repo, all, limit, offset)?; | ||
| 33 | if entries.is_empty() { | 40 | if entries.is_empty() { |
| 34 | println!("No issues found."); | 41 | println!("No issues found."); |
| 35 | } else { | 42 | } else { |
| @@ -56,7 +63,12 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 56 | } | 63 | } |
| 57 | Ok(()) | 64 | Ok(()) |
| 58 | } | 65 | } |
| 59 | IssueCmd::Show { id } => { | 66 | IssueCmd::Show { id, json } => { |
| 67 | if json { | ||
| 68 | let output = issue::show_json(repo, &id)?; | ||
| 69 | println!("{}", output); | ||
| 70 | return Ok(()); | ||
| 71 | } | ||
| 60 | let i = issue::show(repo, &id)?; | 72 | let i = issue::show(repo, &id)?; |
| 61 | let status = match i.status { | 73 | let status = match i.status { |
| 62 | IssueStatus::Open => "open", | 74 | IssueStatus::Open => "open", |
| @@ -169,8 +181,13 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 169 | println!("Created patch {:.8}", id); | 181 | println!("Created patch {:.8}", id); |
| 170 | Ok(()) | 182 | Ok(()) |
| 171 | } | 183 | } |
| 172 | PatchCmd::List { all } => { | 184 | PatchCmd::List { all, limit, offset, json } => { |
| 173 | let patches = patch::list(repo, all)?; | 185 | if json { |
| 186 | let output = patch::list_json(repo, all)?; | ||
| 187 | println!("{}", output); | ||
| 188 | return Ok(()); | ||
| 189 | } | ||
| 190 | let patches = patch::list(repo, all, limit, offset)?; | ||
| 174 | if patches.is_empty() { | 191 | if patches.is_empty() { |
| 175 | println!("No patches found."); | 192 | println!("No patches found."); |
| 176 | } else { | 193 | } else { |
| @@ -188,7 +205,12 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 188 | } | 205 | } |
| 189 | Ok(()) | 206 | Ok(()) |
| 190 | } | 207 | } |
| 191 | PatchCmd::Show { id } => { | 208 | PatchCmd::Show { id, json } => { |
| 209 | if json { | ||
| 210 | let output = patch::show_json(repo, &id)?; | ||
| 211 | println!("{}", output); | ||
| 212 | return Ok(()); | ||
| 213 | } | ||
| 192 | let p = patch::show(repo, &id)?; | 214 | let p = patch::show(repo, &id)?; |
| 193 | let status = match p.status { | 215 | let status = match p.status { |
| 194 | PatchStatus::Open => "open", | 216 | PatchStatus::Open => "open", |
| @@ -294,7 +316,14 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 294 | Ok(()) | 316 | Ok(()) |
| 295 | } | 317 | } |
| 296 | }, | 318 | }, |
| 319 | Commands::Status => { | ||
| 320 | let project_status = status::compute(repo)?; | ||
| 321 | print!("{}", project_status); | ||
| 322 | Ok(()) | ||
| 323 | } | ||
| 324 | Commands::Log { limit } => log::print_log(repo, limit), | ||
| 297 | Commands::Dashboard => tui::run(repo), | 325 | Commands::Dashboard => tui::run(repo), |
| 326 | Commands::Completions { .. } => unreachable!("handled before repo open"), | ||
| 298 | Commands::Sync { remote } => sync::sync(repo, &remote), | 327 | Commands::Sync { remote } => sync::sync(repo, &remote), |
| 299 | Commands::InitKey { force } => { | 328 | Commands::InitKey { force } => { |
| 300 | let config_dir = signing::signing_key_dir()?; | 329 | let config_dir = signing::signing_key_dir()?; |
src/log.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,175 @@ | |||
| 1 | use git2::Repository; | ||
| 2 | |||
| 3 | use crate::dag; | ||
| 4 | use crate::error::Error; | ||
| 5 | use crate::event::{Action, Author}; | ||
| 6 | |||
| 7 | /// A single entry in the collab log output. | ||
| 8 | #[derive(Debug, Clone)] | ||
| 9 | pub struct LogEntry { | ||
| 10 | pub timestamp: String, | ||
| 11 | pub event_type: String, | ||
| 12 | pub entity_kind: String, | ||
| 13 | pub entity_id: String, | ||
| 14 | pub author: Author, | ||
| 15 | pub summary: String, | ||
| 16 | } | ||
| 17 | |||
| 18 | /// Collect all events from every collab ref, sorted by timestamp. | ||
| 19 | /// If `limit` is provided, return at most that many entries (most recent last). | ||
| 20 | pub fn collect_events(repo: &Repository, limit: Option<usize>) -> Result<Vec<LogEntry>, Error> { | ||
| 21 | let mut entries = Vec::new(); | ||
| 22 | |||
| 23 | // Walk issues | ||
| 24 | let issue_refs = repo.references_glob("refs/collab/issues/*")?; | ||
| 25 | for r in issue_refs { | ||
| 26 | let r = r?; | ||
| 27 | let ref_name = r.name().unwrap_or_default().to_string(); | ||
| 28 | let id = ref_name | ||
| 29 | .strip_prefix("refs/collab/issues/") | ||
| 30 | .unwrap_or_default() | ||
| 31 | .to_string(); | ||
| 32 | if let Ok(events) = dag::walk_events(repo, &ref_name) { | ||
| 33 | for (_oid, event) in events { | ||
| 34 | entries.push(LogEntry { | ||
| 35 | timestamp: event.timestamp.clone(), | ||
| 36 | event_type: action_type_name(&event.action), | ||
| 37 | entity_kind: "issue".to_string(), | ||
| 38 | entity_id: id.clone(), | ||
| 39 | author: event.author, | ||
| 40 | summary: action_summary(&event.action), | ||
| 41 | }); | ||
| 42 | } | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | // Walk patches | ||
| 47 | let patch_refs = repo.references_glob("refs/collab/patches/*")?; | ||
| 48 | for r in patch_refs { | ||
| 49 | let r = r?; | ||
| 50 | let ref_name = r.name().unwrap_or_default().to_string(); | ||
| 51 | let id = ref_name | ||
| 52 | .strip_prefix("refs/collab/patches/") | ||
| 53 | .unwrap_or_default() | ||
| 54 | .to_string(); | ||
| 55 | if let Ok(events) = dag::walk_events(repo, &ref_name) { | ||
| 56 | for (_oid, event) in events { | ||
| 57 | entries.push(LogEntry { | ||
| 58 | timestamp: event.timestamp.clone(), | ||
| 59 | event_type: action_type_name(&event.action), | ||
| 60 | entity_kind: "patch".to_string(), | ||
| 61 | entity_id: id.clone(), | ||
| 62 | author: event.author, | ||
| 63 | summary: action_summary(&event.action), | ||
| 64 | }); | ||
| 65 | } | ||
| 66 | } | ||
| 67 | } | ||
| 68 | |||
| 69 | // Sort by timestamp (chronological) | ||
| 70 | entries.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); | ||
| 71 | |||
| 72 | if let Some(n) = limit { | ||
| 73 | entries.truncate(n); | ||
| 74 | } | ||
| 75 | |||
| 76 | Ok(entries) | ||
| 77 | } | ||
| 78 | |||
| 79 | /// Format log entries into a human-readable string. | ||
| 80 | pub fn format_log(entries: &[LogEntry]) -> String { | ||
| 81 | let mut out = String::new(); | ||
| 82 | for entry in entries { | ||
| 83 | out.push_str(&format!( | ||
| 84 | "{} {} {:.8} {} <{}> {}\n", | ||
| 85 | entry.timestamp, | ||
| 86 | entry.event_type, | ||
| 87 | entry.entity_kind, | ||
| 88 | entry.entity_id.get(..8).unwrap_or(&entry.entity_id), | ||
| 89 | entry.author.name, | ||
| 90 | entry.summary, | ||
| 91 | )); | ||
| 92 | } | ||
| 93 | out | ||
| 94 | } | ||
| 95 | |||
| 96 | /// Print the log to stdout. | ||
| 97 | pub fn print_log(repo: &Repository, limit: Option<usize>) -> Result<(), Error> { | ||
| 98 | let entries = collect_events(repo, limit)?; | ||
| 99 | if entries.is_empty() { | ||
| 100 | println!("No collab events found."); | ||
| 101 | return Ok(()); | ||
| 102 | } | ||
| 103 | print!("{}", format_log(&entries)); | ||
| 104 | Ok(()) | ||
| 105 | } | ||
| 106 | |||
| 107 | fn action_type_name(action: &Action) -> String { | ||
| 108 | match action { | ||
| 109 | Action::IssueOpen { .. } => "IssueOpen".to_string(), | ||
| 110 | Action::IssueComment { .. } => "IssueComment".to_string(), | ||
| 111 | Action::IssueClose { .. } => "IssueClose".to_string(), | ||
| 112 | Action::IssueEdit { .. } => "IssueEdit".to_string(), | ||
| 113 | Action::IssueLabel { .. } => "IssueLabel".to_string(), | ||
| 114 | Action::IssueUnlabel { .. } => "IssueUnlabel".to_string(), | ||
| 115 | Action::IssueAssign { .. } => "IssueAssign".to_string(), | ||
| 116 | Action::IssueUnassign { .. } => "IssueUnassign".to_string(), | ||
| 117 | Action::IssueReopen => "IssueReopen".to_string(), | ||
| 118 | Action::PatchCreate { .. } => "PatchCreate".to_string(), | ||
| 119 | Action::PatchRevise { .. } => "PatchRevise".to_string(), | ||
| 120 | Action::PatchReview { .. } => "PatchReview".to_string(), | ||
| 121 | Action::PatchComment { .. } => "PatchComment".to_string(), | ||
| 122 | Action::PatchInlineComment { .. } => "PatchInlineComment".to_string(), | ||
| 123 | Action::PatchClose { .. } => "PatchClose".to_string(), | ||
| 124 | Action::PatchMerge => "PatchMerge".to_string(), | ||
| 125 | Action::Merge => "Merge".to_string(), | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 129 | fn action_summary(action: &Action) -> String { | ||
| 130 | match action { | ||
| 131 | Action::IssueOpen { title, .. } => format!("open \"{}\"", title), | ||
| 132 | Action::IssueComment { body } => truncate(body, 60), | ||
| 133 | Action::IssueClose { reason } => match reason { | ||
| 134 | Some(r) => format!("close: {}", r), | ||
| 135 | None => "close".to_string(), | ||
| 136 | }, | ||
| 137 | Action::IssueEdit { title, body } => { | ||
| 138 | let parts: Vec<&str> = [ | ||
| 139 | title.as_ref().map(|_| "title"), | ||
| 140 | body.as_ref().map(|_| "body"), | ||
| 141 | ] | ||
| 142 | .into_iter() | ||
| 143 | .flatten() | ||
| 144 | .collect(); | ||
| 145 | format!("edit {}", parts.join(", ")) | ||
| 146 | } | ||
| 147 | Action::IssueLabel { label } => format!("label \"{}\"", label), | ||
| 148 | Action::IssueUnlabel { label } => format!("unlabel \"{}\"", label), | ||
| 149 | Action::IssueAssign { assignee } => format!("assign \"{}\"", assignee), | ||
| 150 | Action::IssueUnassign { assignee } => format!("unassign \"{}\"", assignee), | ||
| 151 | Action::IssueReopen => "reopen".to_string(), | ||
| 152 | Action::PatchCreate { title, .. } => format!("create \"{}\"", title), | ||
| 153 | Action::PatchRevise { body, .. } => match body { | ||
| 154 | Some(b) => format!("revise: {}", truncate(b, 50)), | ||
| 155 | None => "revise".to_string(), | ||
| 156 | }, | ||
| 157 | Action::PatchReview { verdict, .. } => format!("review: {:?}", verdict), | ||
| 158 | Action::PatchComment { body } => truncate(body, 60), | ||
| 159 | Action::PatchInlineComment { file, line, .. } => format!("comment on {}:{}", file, line), | ||
| 160 | Action::PatchClose { reason } => match reason { | ||
| 161 | Some(r) => format!("close: {}", r), | ||
| 162 | None => "close".to_string(), | ||
| 163 | }, | ||
| 164 | Action::PatchMerge => "merge".to_string(), | ||
| 165 | Action::Merge => "dag merge".to_string(), | ||
| 166 | } | ||
| 167 | } | ||
| 168 | |||
| 169 | fn truncate(s: &str, max: usize) -> String { | ||
| 170 | if s.len() <= max { | ||
| 171 | s.to_string() | ||
| 172 | } else { | ||
| 173 | format!("{}...", &s[..max]) | ||
| 174 | } | ||
| 175 | } | ||
src/main.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,11 +1,22 @@ | |||
| 1 | use clap::Parser; | 1 | use clap::{CommandFactory, Parser}; |
| 2 | use git2::Repository; | 2 | use git2::Repository; |
| 3 | 3 | ||
| 4 | use git_collab::cli::Cli; | 4 | use git_collab::cli::{Cli, Commands}; |
| 5 | 5 | ||
| 6 | fn main() { | 6 | fn main() { |
| 7 | let cli = Cli::parse(); | 7 | let cli = Cli::parse(); |
| 8 | 8 | ||
| 9 | // Handle completions before opening the repo (no git repo needed) | ||
| 10 | if let Commands::Completions { shell } = &cli.command { | ||
| 11 | clap_complete::generate( | ||
| 12 | *shell, | ||
| 13 | &mut Cli::command(), | ||
| 14 | "git-collab", | ||
| 15 | &mut std::io::stdout(), | ||
| 16 | ); | ||
| 17 | return; | ||
| 18 | } | ||
| 19 | |||
| 9 | let repo = match Repository::open_from_env() { | 20 | let repo = match Repository::open_from_env() { |
| 10 | Ok(r) => r, | 21 | Ok(r) => r, |
| 11 | Err(e) => { | 22 | Err(e) => { |
src/patch.rs
| Old | New | ||
|---|---|---|---|
| @@ -59,15 +59,65 @@ pub fn create( | |||
| 59 | Ok(id) | 59 | Ok(id) |
| 60 | } | 60 | } |
| 61 | 61 | ||
| 62 | pub fn list(repo: &Repository, show_closed: bool) -> Result<Vec<PatchState>, crate::error::Error> { | 62 | pub fn list( |
| 63 | repo: &Repository, | ||
| 64 | show_closed: bool, | ||
| 65 | limit: Option<usize>, | ||
| 66 | offset: Option<usize>, | ||
| 67 | ) -> Result<Vec<PatchState>, crate::error::Error> { | ||
| 63 | let patches = state::list_patches(repo)?; | 68 | let patches = state::list_patches(repo)?; |
| 64 | let filtered = patches | 69 | let filtered = patches |
| 65 | .into_iter() | 70 | .into_iter() |
| 66 | .filter(|p| show_closed || p.status == PatchStatus::Open) | 71 | .filter(|p| show_closed || p.status == PatchStatus::Open) |
| 72 | .skip(offset.unwrap_or(0)) | ||
| 73 | .take(limit.unwrap_or(usize::MAX)) | ||
| 67 | .collect(); | 74 | .collect(); |
| 68 | Ok(filtered) | 75 | Ok(filtered) |
| 69 | } | 76 | } |
| 70 | 77 | ||
| 78 | pub fn list_to_writer( | ||
| 79 | repo: &Repository, | ||
| 80 | show_closed: bool, | ||
| 81 | limit: Option<usize>, | ||
| 82 | offset: Option<usize>, | ||
| 83 | writer: &mut dyn std::io::Write, | ||
| 84 | ) -> Result<(), crate::error::Error> { | ||
| 85 | let patches = list(repo, show_closed, limit, offset)?; | ||
| 86 | if patches.is_empty() { | ||
| 87 | writeln!(writer, "No patches found.").ok(); | ||
| 88 | return Ok(()); | ||
| 89 | } | ||
| 90 | for p in &patches { | ||
| 91 | let status = match p.status { | ||
| 92 | PatchStatus::Open => "open", | ||
| 93 | PatchStatus::Closed => "closed", | ||
| 94 | PatchStatus::Merged => "merged", | ||
| 95 | }; | ||
| 96 | writeln!( | ||
| 97 | writer, | ||
| 98 | "{:.8} {:6} {} (by {})", | ||
| 99 | p.id, status, p.title, p.author.name | ||
| 100 | ) | ||
| 101 | .ok(); | ||
| 102 | } | ||
| 103 | Ok(()) | ||
| 104 | } | ||
| 105 | |||
| 106 | pub fn list_json(repo: &Repository, show_closed: bool) -> Result<String, crate::error::Error> { | ||
| 107 | let patches = state::list_patches(repo)?; | ||
| 108 | let filtered: Vec<_> = patches | ||
| 109 | .into_iter() | ||
| 110 | .filter(|p| show_closed || p.status == PatchStatus::Open) | ||
| 111 | .collect(); | ||
| 112 | Ok(serde_json::to_string_pretty(&filtered)?) | ||
| 113 | } | ||
| 114 | |||
| 115 | pub fn show_json(repo: &Repository, id_prefix: &str) -> Result<String, crate::error::Error> { | ||
| 116 | let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; | ||
| 117 | let p = PatchState::from_ref(repo, &ref_name, &id)?; | ||
| 118 | Ok(serde_json::to_string_pretty(&p)?) | ||
| 119 | } | ||
| 120 | |||
| 71 | pub fn show(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::error::Error> { | 121 | pub fn show(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::error::Error> { |
| 72 | let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; | 122 | let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; |
| 73 | PatchState::from_ref(repo, &ref_name, &id) | 123 | PatchState::from_ref(repo, &ref_name, &id) |
src/state.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,30 +1,54 @@ | |||
| 1 | use git2::{Oid, Repository}; | 1 | use git2::{Oid, Repository}; |
| 2 | use serde::Serialize; | ||
| 2 | 3 | ||
| 3 | use crate::dag; | 4 | use crate::dag; |
| 4 | use crate::event::{Action, Author, ReviewVerdict}; | 5 | use crate::event::{Action, Author, ReviewVerdict}; |
| 5 | 6 | ||
| 6 | #[derive(Debug, Clone, PartialEq)] | 7 | fn serialize_oid<S: serde::Serializer>(oid: &Oid, s: S) -> Result<S::Ok, S::Error> { |
| 8 | s.serialize_str(&oid.to_string()) | ||
| 9 | } | ||
| 10 | |||
| 11 | fn serialize_oid_option<S: serde::Serializer>(oid: &Option<Oid>, s: S) -> Result<S::Ok, S::Error> { | ||
| 12 | match oid { | ||
| 13 | Some(o) => s.serialize_some(&o.to_string()), | ||
| 14 | None => s.serialize_none(), | ||
| 15 | } | ||
| 16 | } | ||
| 17 | |||
| 18 | fn serialize_verdict<S: serde::Serializer>(v: &ReviewVerdict, s: S) -> Result<S::Ok, S::Error> { | ||
| 19 | let str_val = match v { | ||
| 20 | ReviewVerdict::Approve => "approve", | ||
| 21 | ReviewVerdict::RequestChanges => "request-changes", | ||
| 22 | ReviewVerdict::Comment => "comment", | ||
| 23 | }; | ||
| 24 | s.serialize_str(str_val) | ||
| 25 | } | ||
| 26 | |||
| 27 | #[derive(Debug, Clone, PartialEq, Serialize)] | ||
| 28 | #[serde(rename_all = "lowercase")] | ||
| 7 | pub enum IssueStatus { | 29 | pub enum IssueStatus { |
| 8 | Open, | 30 | Open, |
| 9 | Closed, | 31 | Closed, |
| 10 | } | 32 | } |
| 11 | 33 | ||
| 12 | #[derive(Debug, Clone)] | 34 | #[derive(Debug, Clone, Serialize)] |
| 13 | #[allow(dead_code)] | 35 | #[allow(dead_code)] |
| 14 | pub struct Comment { | 36 | pub struct Comment { |
| 15 | pub author: Author, | 37 | pub author: Author, |
| 16 | pub body: String, | 38 | pub body: String, |
| 17 | pub timestamp: String, | 39 | pub timestamp: String, |
| 40 | #[serde(serialize_with = "serialize_oid")] | ||
| 18 | pub commit_id: Oid, | 41 | pub commit_id: Oid, |
| 19 | } | 42 | } |
| 20 | 43 | ||
| 21 | #[derive(Debug, Clone)] | 44 | #[derive(Debug, Clone, Serialize)] |
| 22 | pub struct IssueState { | 45 | pub struct IssueState { |
| 23 | pub id: String, | 46 | pub id: String, |
| 24 | pub title: String, | 47 | pub title: String, |
| 25 | pub body: String, | 48 | pub body: String, |
| 26 | pub status: IssueStatus, | 49 | pub status: IssueStatus, |
| 27 | pub close_reason: Option<String>, | 50 | pub close_reason: Option<String>, |
| 51 | #[serde(serialize_with = "serialize_oid_option")] | ||
| 28 | pub closed_by: Option<Oid>, | 52 | pub closed_by: Option<Oid>, |
| 29 | pub labels: Vec<String>, | 53 | pub labels: Vec<String>, |
| 30 | pub assignees: Vec<String>, | 54 | pub assignees: Vec<String>, |
| @@ -33,15 +57,17 @@ pub struct IssueState { | |||
| 33 | pub author: Author, | 57 | pub author: Author, |
| 34 | } | 58 | } |
| 35 | 59 | ||
| 36 | #[derive(Debug, Clone)] | 60 | #[derive(Debug, Clone, Serialize)] |
| 37 | pub struct Review { | 61 | pub struct Review { |
| 38 | pub author: Author, | 62 | pub author: Author, |
| 63 | #[serde(serialize_with = "serialize_verdict")] | ||
| 39 | pub verdict: ReviewVerdict, | 64 | pub verdict: ReviewVerdict, |
| 40 | pub body: String, | 65 | pub body: String, |
| 41 | pub timestamp: String, | 66 | pub timestamp: String, |
| 42 | } | 67 | } |
| 43 | 68 | ||
| 44 | #[derive(Debug, Clone, PartialEq)] | 69 | #[derive(Debug, Clone, PartialEq, Serialize)] |
| 70 | #[serde(rename_all = "lowercase")] | ||
| 45 | #[allow(dead_code)] | 71 | #[allow(dead_code)] |
| 46 | pub enum PatchStatus { | 72 | pub enum PatchStatus { |
| 47 | Open, | 73 | Open, |
| @@ -49,7 +75,7 @@ pub enum PatchStatus { | |||
| 49 | Merged, | 75 | Merged, |
| 50 | } | 76 | } |
| 51 | 77 | ||
| 52 | #[derive(Debug, Clone)] | 78 | #[derive(Debug, Clone, Serialize)] |
| 53 | pub struct InlineComment { | 79 | pub struct InlineComment { |
| 54 | pub author: Author, | 80 | pub author: Author, |
| 55 | pub file: String, | 81 | pub file: String, |
| @@ -58,7 +84,7 @@ pub struct InlineComment { | |||
| 58 | pub timestamp: String, | 84 | pub timestamp: String, |
| 59 | } | 85 | } |
| 60 | 86 | ||
| 61 | #[derive(Debug, Clone)] | 87 | #[derive(Debug, Clone, Serialize)] |
| 62 | pub struct PatchState { | 88 | pub struct PatchState { |
| 63 | pub id: String, | 89 | pub id: String, |
| 64 | pub title: String, | 90 | pub title: String, |
| @@ -182,6 +208,13 @@ impl IssueState { | |||
| 182 | impl PatchState { | 208 | impl PatchState { |
| 183 | /// Resolve the current head commit OID for this patch by looking up `refs/heads/{branch}`. | 209 | /// Resolve the current head commit OID for this patch by looking up `refs/heads/{branch}`. |
| 184 | pub fn resolve_head(&self, repo: &Repository) -> Result<Oid, crate::error::Error> { | 210 | pub fn resolve_head(&self, repo: &Repository) -> Result<Oid, crate::error::Error> { |
| 211 | // Try parsing as a hex OID first (for patches created with head_commit) | ||
| 212 | if let Ok(oid) = Oid::from_str(&self.branch) { | ||
| 213 | if repo.find_commit(oid).is_ok() { | ||
| 214 | return Ok(oid); | ||
| 215 | } | ||
| 216 | } | ||
| 217 | // Fall back to branch name lookup | ||
| 185 | let ref_name = format!("refs/heads/{}", self.branch); | 218 | let ref_name = format!("refs/heads/{}", self.branch); |
| 186 | repo.refname_to_id(&ref_name).map_err(|e| { | 219 | repo.refname_to_id(&ref_name).map_err(|e| { |
| 187 | crate::error::Error::Cmd(format!( | 220 | crate::error::Error::Cmd(format!( |
src/status.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,153 @@ | |||
| 1 | use std::fmt; | ||
| 2 | |||
| 3 | use git2::Repository; | ||
| 4 | |||
| 5 | use crate::error::Error; | ||
| 6 | use crate::event::ReviewVerdict; | ||
| 7 | use crate::state::{self, IssueStatus, PatchStatus}; | ||
| 8 | |||
| 9 | /// A single recently-updated item (issue or patch) for the status summary. | ||
| 10 | #[derive(Debug, Clone)] | ||
| 11 | pub struct RecentItem { | ||
| 12 | pub kind: &'static str, | ||
| 13 | pub id: String, | ||
| 14 | pub title: String, | ||
| 15 | pub status: String, | ||
| 16 | pub created_at: String, | ||
| 17 | } | ||
| 18 | |||
| 19 | /// Aggregated project status computed from all collab refs. | ||
| 20 | #[derive(Debug, Clone)] | ||
| 21 | pub struct ProjectStatus { | ||
| 22 | pub issues_open: usize, | ||
| 23 | pub issues_closed: usize, | ||
| 24 | pub patches_open: usize, | ||
| 25 | pub patches_closed: usize, | ||
| 26 | pub patches_merged: usize, | ||
| 27 | /// Number of open patches whose latest review verdict is Approve. | ||
| 28 | pub patches_approved: usize, | ||
| 29 | /// Number of open patches whose latest review verdict is RequestChanges. | ||
| 30 | pub patches_changes_requested: usize, | ||
| 31 | /// Most recently created items (up to 10), sorted newest first. | ||
| 32 | pub recent_items: Vec<RecentItem>, | ||
| 33 | } | ||
| 34 | |||
| 35 | /// Compute an aggregate status for the project. | ||
| 36 | pub fn compute(repo: &Repository) -> Result<ProjectStatus, Error> { | ||
| 37 | let issues = state::list_issues(repo)?; | ||
| 38 | let patches = state::list_patches(repo)?; | ||
| 39 | |||
| 40 | let issues_open = issues | ||
| 41 | .iter() | ||
| 42 | .filter(|i| i.status == IssueStatus::Open) | ||
| 43 | .count(); | ||
| 44 | let issues_closed = issues | ||
| 45 | .iter() | ||
| 46 | .filter(|i| i.status == IssueStatus::Closed) | ||
| 47 | .count(); | ||
| 48 | |||
| 49 | let patches_open = patches | ||
| 50 | .iter() | ||
| 51 | .filter(|p| p.status == PatchStatus::Open) | ||
| 52 | .count(); | ||
| 53 | let patches_closed = patches | ||
| 54 | .iter() | ||
| 55 | .filter(|p| p.status == PatchStatus::Closed) | ||
| 56 | .count(); | ||
| 57 | let patches_merged = patches | ||
| 58 | .iter() | ||
| 59 | .filter(|p| p.status == PatchStatus::Merged) | ||
| 60 | .count(); | ||
| 61 | |||
| 62 | // Review summary: for each open patch, look at the latest review verdict. | ||
| 63 | let mut patches_approved = 0usize; | ||
| 64 | let mut patches_changes_requested = 0usize; | ||
| 65 | for p in patches.iter().filter(|p| p.status == PatchStatus::Open) { | ||
| 66 | if let Some(last_review) = p.reviews.last() { | ||
| 67 | match last_review.verdict { | ||
| 68 | ReviewVerdict::Approve => patches_approved += 1, | ||
| 69 | ReviewVerdict::RequestChanges => patches_changes_requested += 1, | ||
| 70 | ReviewVerdict::Comment => {} | ||
| 71 | } | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | // Collect recent items from both issues and patches, sort by created_at descending, cap at 10. | ||
| 76 | let mut recent_items: Vec<RecentItem> = Vec::new(); | ||
| 77 | for issue in &issues { | ||
| 78 | recent_items.push(RecentItem { | ||
| 79 | kind: "issue", | ||
| 80 | id: issue.id[..8.min(issue.id.len())].to_string(), | ||
| 81 | title: issue.title.clone(), | ||
| 82 | status: match issue.status { | ||
| 83 | IssueStatus::Open => "open".to_string(), | ||
| 84 | IssueStatus::Closed => "closed".to_string(), | ||
| 85 | }, | ||
| 86 | created_at: issue.created_at.clone(), | ||
| 87 | }); | ||
| 88 | } | ||
| 89 | for patch in &patches { | ||
| 90 | recent_items.push(RecentItem { | ||
| 91 | kind: "patch", | ||
| 92 | id: patch.id[..8.min(patch.id.len())].to_string(), | ||
| 93 | title: patch.title.clone(), | ||
| 94 | status: match patch.status { | ||
| 95 | PatchStatus::Open => "open".to_string(), | ||
| 96 | PatchStatus::Closed => "closed".to_string(), | ||
| 97 | PatchStatus::Merged => "merged".to_string(), | ||
| 98 | }, | ||
| 99 | created_at: patch.created_at.clone(), | ||
| 100 | }); | ||
| 101 | } | ||
| 102 | recent_items.sort_by(|a, b| b.created_at.cmp(&a.created_at)); | ||
| 103 | recent_items.truncate(10); | ||
| 104 | |||
| 105 | Ok(ProjectStatus { | ||
| 106 | issues_open, | ||
| 107 | issues_closed, | ||
| 108 | patches_open, | ||
| 109 | patches_closed, | ||
| 110 | patches_merged, | ||
| 111 | patches_approved, | ||
| 112 | patches_changes_requested, | ||
| 113 | recent_items, | ||
| 114 | }) | ||
| 115 | } | ||
| 116 | |||
| 117 | impl fmt::Display for ProjectStatus { | ||
| 118 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| 119 | writeln!(f, "Issues: {} open, {} closed", self.issues_open, self.issues_closed)?; | ||
| 120 | writeln!( | ||
| 121 | f, | ||
| 122 | "Patches: {} open, {} merged, {} closed", | ||
| 123 | self.patches_open, self.patches_merged, self.patches_closed | ||
| 124 | )?; | ||
| 125 | |||
| 126 | if self.patches_open > 0 { | ||
| 127 | writeln!( | ||
| 128 | f, | ||
| 129 | " Review: {} approved, {} changes requested, {} pending", | ||
| 130 | self.patches_approved, | ||
| 131 | self.patches_changes_requested, | ||
| 132 | self.patches_open - self.patches_approved - self.patches_changes_requested | ||
| 133 | )?; | ||
| 134 | } | ||
| 135 | |||
| 136 | if !self.recent_items.is_empty() { | ||
| 137 | writeln!(f)?; | ||
| 138 | writeln!(f, "Recently updated:")?; | ||
| 139 | for item in &self.recent_items { | ||
| 140 | writeln!( | ||
| 141 | f, | ||
| 142 | " [{kind}] {id} {title} ({status})", | ||
| 143 | kind = item.kind, | ||
| 144 | id = item.id, | ||
| 145 | title = item.title, | ||
| 146 | status = item.status, | ||
| 147 | )?; | ||
| 148 | } | ||
| 149 | } | ||
| 150 | |||
| 151 | Ok(()) | ||
| 152 | } | ||
| 153 | } | ||
src/sync.rs
| Old | New | ||
|---|---|---|---|
| @@ -523,7 +523,15 @@ fn reconcile_refs( | |||
| 523 | let local_ref = format!("refs/collab/{}/{}", kind, id); | 523 | let local_ref = format!("refs/collab/{}/{}", kind, id); |
| 524 | if repo.refname_to_id(&local_ref).is_ok() { | 524 | if repo.refname_to_id(&local_ref).is_ok() { |
| 525 | match dag::reconcile(repo, &local_ref, remote_ref, author, signing_key) { | 525 | match dag::reconcile(repo, &local_ref, remote_ref, author, signing_key) { |
| 526 | Ok(_) => println!(" Reconciled {} {:.8}", kind, id), | 526 | Ok((_oid, outcome)) => { |
| 527 | let action = match outcome { | ||
| 528 | dag::ReconcileOutcome::AlreadyCurrent => "already current", | ||
| 529 | dag::ReconcileOutcome::LocalAhead => "local ahead", | ||
| 530 | dag::ReconcileOutcome::FastForward => "fast-forwarded", | ||
| 531 | dag::ReconcileOutcome::Merge => "merged", | ||
| 532 | }; | ||
| 533 | println!(" Reconciled {} {:.8} ({})", kind, id, action); | ||
| 534 | } | ||
| 527 | Err(e) => eprintln!(" Failed to reconcile {} {:.8}: {}", kind, id, e), | 535 | Err(e) => eprintln!(" Failed to reconcile {} {:.8}: {}", kind, id, e), |
| 528 | } | 536 | } |
| 529 | } else { | 537 | } else { |
src/tui.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,4 +1,3 @@ | |||
| 1 | use std::collections::HashMap; | ||
| 2 | use std::io::{self, stdout}; | 1 | use std::io::{self, stdout}; |
| 3 | use std::time::Duration; | 2 | use std::time::Duration; |
| 4 | 3 | ||
| @@ -7,7 +6,7 @@ use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; | |||
| 7 | use crossterm::ExecutableCommand; | 6 | use crossterm::ExecutableCommand; |
| 8 | use git2::{Oid, Repository}; | 7 | use git2::{Oid, Repository}; |
| 9 | use ratatui::prelude::*; | 8 | use ratatui::prelude::*; |
| 10 | use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap}; | 9 | use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}; |
| 11 | 10 | ||
| 12 | use crate::error::Error; | 11 | use crate::error::Error; |
| 13 | use crate::event::Action; | 12 | use crate::event::Action; |
| @@ -20,16 +19,9 @@ enum Pane { | |||
| 20 | Detail, | 19 | Detail, |
| 21 | } | 20 | } |
| 22 | 21 | ||
| 23 | #[derive(Debug, PartialEq, Clone, Copy)] | ||
| 24 | enum Tab { | ||
| 25 | Issues, | ||
| 26 | Patches, | ||
| 27 | } | ||
| 28 | |||
| 29 | #[derive(Debug, PartialEq)] | 22 | #[derive(Debug, PartialEq)] |
| 30 | enum ViewMode { | 23 | enum ViewMode { |
| 31 | Details, | 24 | Details, |
| 32 | Diff, | ||
| 33 | CommitList, | 25 | CommitList, |
| 34 | CommitDetail, | 26 | CommitDetail, |
| 35 | } | 27 | } |
| @@ -75,20 +67,10 @@ enum InputMode { | |||
| 75 | CreateBody, | 67 | CreateBody, |
| 76 | } | 68 | } |
| 77 | 69 | ||
| 78 | /// Cached staleness info for a patch. | ||
| 79 | #[derive(Clone)] | ||
| 80 | struct PatchBranchInfo { | ||
| 81 | staleness: Option<(usize, usize)>, | ||
| 82 | branch_exists: bool, | ||
| 83 | } | ||
| 84 | |||
| 85 | struct App { | 70 | struct App { |
| 86 | tab: Tab, | ||
| 87 | issues: Vec<IssueState>, | 71 | issues: Vec<IssueState>, |
| 88 | patches: Vec<PatchState>, | 72 | patches: Vec<PatchState>, |
| 89 | list_state: ListState, | 73 | list_state: ListState, |
| 90 | diff_cache: HashMap<String, String>, | ||
| 91 | branch_info_cache: HashMap<String, PatchBranchInfo>, | ||
| 92 | scroll: u16, | 74 | scroll: u16, |
| 93 | pane: Pane, | 75 | pane: Pane, |
| 94 | mode: ViewMode, | 76 | mode: ViewMode, |
| @@ -109,12 +91,9 @@ impl App { | |||
| 109 | list_state.select(Some(0)); | 91 | list_state.select(Some(0)); |
| 110 | } | 92 | } |
| 111 | Self { | 93 | Self { |
| 112 | tab: Tab::Issues, | ||
| 113 | issues, | 94 | issues, |
| 114 | patches, | 95 | patches, |
| 115 | list_state, | 96 | list_state, |
| 116 | diff_cache: HashMap::new(), | ||
| 117 | branch_info_cache: HashMap::new(), | ||
| 118 | scroll: 0, | 97 | scroll: 0, |
| 119 | pane: Pane::ItemList, | 98 | pane: Pane::ItemList, |
| 120 | mode: ViewMode::Details, | 99 | mode: ViewMode::Details, |
| @@ -150,25 +129,8 @@ impl App { | |||
| 150 | .collect() | 129 | .collect() |
| 151 | } | 130 | } |
| 152 | 131 | ||
| 153 | fn visible_patches(&self) -> Vec<&PatchState> { | ||
| 154 | self.patches | ||
| 155 | .iter() | ||
| 156 | .filter(|p| match self.status_filter { | ||
| 157 | StatusFilter::Open => p.status == PatchStatus::Open, | ||
| 158 | StatusFilter::Closed => { | ||
| 159 | p.status == PatchStatus::Closed || p.status == PatchStatus::Merged | ||
| 160 | } | ||
| 161 | StatusFilter::All => true, | ||
| 162 | }) | ||
| 163 | .filter(|p| self.matches_search(&p.title)) | ||
| 164 | .collect() | ||
| 165 | } | ||
| 166 | |||
| 167 | fn visible_count(&self) -> usize { | 132 | fn visible_count(&self) -> usize { |
| 168 | match self.tab { | 133 | self.visible_issues().len() |
| 169 | Tab::Issues => self.visible_issues().len(), | ||
| 170 | Tab::Patches => self.visible_patches().len(), | ||
| 171 | } | ||
| 172 | } | 134 | } |
| 173 | 135 | ||
| 174 | fn move_selection(&mut self, delta: i32) { | 136 | fn move_selection(&mut self, delta: i32) { |
| @@ -186,91 +148,6 @@ impl App { | |||
| 186 | self.scroll = 0; | 148 | self.scroll = 0; |
| 187 | } | 149 | } |
| 188 | 150 | ||
| 189 | fn switch_tab(&mut self, tab: Tab) { | ||
| 190 | if self.tab == tab { | ||
| 191 | return; | ||
| 192 | } | ||
| 193 | self.tab = tab; | ||
| 194 | self.list_state.select(if self.visible_count() > 0 { | ||
| 195 | Some(0) | ||
| 196 | } else { | ||
| 197 | None | ||
| 198 | }); | ||
| 199 | self.scroll = 0; | ||
| 200 | self.mode = ViewMode::Details; | ||
| 201 | self.pane = Pane::ItemList; | ||
| 202 | } | ||
| 203 | |||
| 204 | fn follow_link(&mut self) -> bool { | ||
| 205 | match self.tab { | ||
| 206 | Tab::Issues => { | ||
| 207 | // From an issue, jump to the first patch that fixes it | ||
| 208 | let visible = self.visible_issues(); | ||
| 209 | if let Some(idx) = self.list_state.selected() { | ||
| 210 | if let Some(issue) = visible.get(idx) { | ||
| 211 | let issue_id = issue.id.clone(); | ||
| 212 | let target = self | ||
| 213 | .patches | ||
| 214 | .iter() | ||
| 215 | .enumerate() | ||
| 216 | .find(|(_, p)| p.fixes.as_deref() == Some(&issue_id)); | ||
| 217 | if let Some((patch_idx, _)) = target { | ||
| 218 | if self.status_filter != StatusFilter::All { | ||
| 219 | let visible_patches = self.visible_patches(); | ||
| 220 | if !visible_patches | ||
| 221 | .iter() | ||
| 222 | .any(|p| p.fixes.as_deref() == Some(&issue_id)) | ||
| 223 | { | ||
| 224 | self.status_filter = StatusFilter::All; | ||
| 225 | } | ||
| 226 | } | ||
| 227 | let visible_patches = self.visible_patches(); | ||
| 228 | if let Some(vi) = visible_patches | ||
| 229 | .iter() | ||
| 230 | .position(|p| p.id == self.patches[patch_idx].id) | ||
| 231 | { | ||
| 232 | self.tab = Tab::Patches; | ||
| 233 | self.list_state.select(Some(vi)); | ||
| 234 | self.scroll = 0; | ||
| 235 | self.mode = ViewMode::Details; | ||
| 236 | return true; | ||
| 237 | } | ||
| 238 | } | ||
| 239 | } | ||
| 240 | } | ||
| 241 | false | ||
| 242 | } | ||
| 243 | Tab::Patches => { | ||
| 244 | // From a patch, jump to the linked issue (fixes field) | ||
| 245 | let visible = self.visible_patches(); | ||
| 246 | if let Some(idx) = self.list_state.selected() { | ||
| 247 | if let Some(patch) = visible.get(idx) { | ||
| 248 | if let Some(ref fixes_id) = patch.fixes { | ||
| 249 | let fixes_id = fixes_id.clone(); | ||
| 250 | if self.status_filter != StatusFilter::All { | ||
| 251 | let visible_issues = self.visible_issues(); | ||
| 252 | if !visible_issues.iter().any(|i| i.id == fixes_id) { | ||
| 253 | self.status_filter = StatusFilter::All; | ||
| 254 | } | ||
| 255 | } | ||
| 256 | let visible_issues = self.visible_issues(); | ||
| 257 | if let Some(vi) = | ||
| 258 | visible_issues.iter().position(|i| i.id == fixes_id) | ||
| 259 | { | ||
| 260 | self.tab = Tab::Issues; | ||
| 261 | self.list_state.select(Some(vi)); | ||
| 262 | self.scroll = 0; | ||
| 263 | self.mode = ViewMode::Details; | ||
| 264 | return true; | ||
| 265 | } | ||
| 266 | } | ||
| 267 | } | ||
| 268 | } | ||
| 269 | false | ||
| 270 | } | ||
| 271 | } | ||
| 272 | } | ||
| 273 | |||
| 274 | fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> KeyAction { | 151 | fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> KeyAction { |
| 275 | // Handle CommitDetail mode first | 152 | // Handle CommitDetail mode first |
| 276 | if self.mode == ViewMode::CommitDetail { | 153 | if self.mode == ViewMode::CommitDetail { |
| @@ -346,7 +223,7 @@ impl App { | |||
| 346 | } | 223 | } |
| 347 | } | 224 | } |
| 348 | 225 | ||
| 349 | // Normal Details/Diff mode handling | 226 | // Normal Details mode handling |
| 350 | match code { | 227 | match code { |
| 351 | KeyCode::Char('q') | KeyCode::Esc => KeyAction::Quit, | 228 | KeyCode::Char('q') | KeyCode::Esc => KeyAction::Quit, |
| 352 | KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => KeyAction::Quit, | 229 | KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => KeyAction::Quit, |
| @@ -358,14 +235,6 @@ impl App { | |||
| 358 | KeyAction::Continue | 235 | KeyAction::Continue |
| 359 | } | 236 | } |
| 360 | } | 237 | } |
| 361 | KeyCode::Char('1') => { | ||
| 362 | self.switch_tab(Tab::Issues); | ||
| 363 | KeyAction::Continue | ||
| 364 | } | ||
| 365 | KeyCode::Char('2') => { | ||
| 366 | self.switch_tab(Tab::Patches); | ||
| 367 | KeyAction::Continue | ||
| 368 | } | ||
| 369 | KeyCode::Char('j') | KeyCode::Down => { | 238 | KeyCode::Char('j') | KeyCode::Down => { |
| 370 | if self.pane == Pane::ItemList { | 239 | if self.pane == Pane::ItemList { |
| 371 | self.move_selection(1); | 240 | self.move_selection(1); |
| @@ -397,22 +266,6 @@ impl App { | |||
| 397 | }; | 266 | }; |
| 398 | KeyAction::Continue | 267 | KeyAction::Continue |
| 399 | } | 268 | } |
| 400 | KeyCode::Char('d') => { | ||
| 401 | if self.tab == Tab::Patches { | ||
| 402 | match self.mode { | ||
| 403 | ViewMode::Details => { | ||
| 404 | self.mode = ViewMode::Diff; | ||
| 405 | self.scroll = 0; | ||
| 406 | } | ||
| 407 | ViewMode::Diff => { | ||
| 408 | self.mode = ViewMode::Details; | ||
| 409 | self.scroll = 0; | ||
| 410 | } | ||
| 411 | _ => {} | ||
| 412 | } | ||
| 413 | } | ||
| 414 | KeyAction::Continue | ||
| 415 | } | ||
| 416 | KeyCode::Char('a') => { | 269 | KeyCode::Char('a') => { |
| 417 | self.status_filter = self.status_filter.next(); | 270 | self.status_filter = self.status_filter.next(); |
| 418 | let count = self.visible_count(); | 271 | let count = self.visible_count(); |
| @@ -427,25 +280,13 @@ impl App { | |||
| 427 | 280 | ||
| 428 | fn selected_item_id(&self) -> Option<String> { | 281 | fn selected_item_id(&self) -> Option<String> { |
| 429 | let idx = self.list_state.selected()?; | 282 | let idx = self.list_state.selected()?; |
| 430 | match self.tab { | 283 | let visible = self.visible_issues(); |
| 431 | Tab::Issues => { | 284 | visible.get(idx).map(|i| i.id.clone()) |
| 432 | let visible = self.visible_issues(); | ||
| 433 | visible.get(idx).map(|i| i.id.clone()) | ||
| 434 | } | ||
| 435 | Tab::Patches => { | ||
| 436 | let visible = self.visible_patches(); | ||
| 437 | visible.get(idx).map(|p| p.id.clone()) | ||
| 438 | } | ||
| 439 | } | ||
| 440 | } | 285 | } |
| 441 | 286 | ||
| 442 | fn selected_ref_name(&self) -> Option<String> { | 287 | fn selected_ref_name(&self) -> Option<String> { |
| 443 | let id = self.selected_item_id()?; | 288 | let id = self.selected_item_id()?; |
| 444 | let prefix = match self.tab { | 289 | Some(format!("refs/collab/issues/{}", id)) |
| 445 | Tab::Issues => "refs/collab/issues", | ||
| 446 | Tab::Patches => "refs/collab/patches", | ||
| 447 | }; | ||
| 448 | Some(format!("{}/{}", prefix, id)) | ||
| 449 | } | 290 | } |
| 450 | 291 | ||
| 451 | fn reload(&mut self, repo: &Repository) { | 292 | fn reload(&mut self, repo: &Repository) { |
| @@ -490,71 +331,6 @@ fn action_type_label(action: &Action) -> &str { | |||
| 490 | } | 331 | } |
| 491 | } | 332 | } |
| 492 | 333 | ||
| 493 | fn generate_diff(repo: &Repository, patch: &PatchState) -> String { | ||
| 494 | let result = (|| -> Result<String, Error> { | ||
| 495 | let head_oid = patch.resolve_head(repo)?; | ||
| 496 | let head_commit = repo.find_commit(head_oid) | ||
| 497 | .map_err(|e| Error::Cmd(format!("bad head ref: {}", e)))?; | ||
| 498 | let head_tree = head_commit.tree()?; | ||
| 499 | |||
| 500 | let base_ref = format!("refs/heads/{}", patch.base_ref); | ||
| 501 | |||
| 502 | let base_tree = if let Ok(base_oid) = repo.refname_to_id(&base_ref) { | ||
| 503 | if let Ok(merge_base_oid) = repo.merge_base(base_oid, head_oid) { | ||
| 504 | let merge_base_commit = repo.find_commit(merge_base_oid)?; | ||
| 505 | Some(merge_base_commit.tree()?) | ||
| 506 | } else { | ||
| 507 | let base_commit = repo.find_commit(base_oid)?; | ||
| 508 | Some(base_commit.tree()?) | ||
| 509 | } | ||
| 510 | } else { | ||
| 511 | None | ||
| 512 | }; | ||
| 513 | |||
| 514 | let diff = repo.diff_tree_to_tree(base_tree.as_ref(), Some(&head_tree), None)?; | ||
| 515 | |||
| 516 | let mut output = String::new(); | ||
| 517 | let mut lines = 0usize; | ||
| 518 | diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { | ||
| 519 | if lines >= 5000 { | ||
| 520 | return false; | ||
| 521 | } | ||
| 522 | let prefix = match line.origin() { | ||
| 523 | '+' => "+", | ||
| 524 | '-' => "-", | ||
| 525 | ' ' => " ", | ||
| 526 | 'H' | 'F' => "", | ||
| 527 | _ => "", | ||
| 528 | }; | ||
| 529 | if !prefix.is_empty() || matches!(line.origin(), 'H' | 'F') { | ||
| 530 | output.push_str(prefix); | ||
| 531 | } | ||
| 532 | if let Ok(content) = std::str::from_utf8(line.content()) { | ||
| 533 | output.push_str(content); | ||
| 534 | } | ||
| 535 | lines += 1; | ||
| 536 | true | ||
| 537 | })?; | ||
| 538 | |||
| 539 | if lines >= 5000 { | ||
| 540 | output.push_str("\n[truncated at 5000 lines]"); | ||
| 541 | } | ||
| 542 | |||
| 543 | Ok(output) | ||
| 544 | })(); | ||
| 545 | |||
| 546 | match result { | ||
| 547 | Ok(diff) => { | ||
| 548 | if diff.is_empty() { | ||
| 549 | "No diff available (commits may be identical)".to_string() | ||
| 550 | } else { | ||
| 551 | diff | ||
| 552 | } | ||
| 553 | } | ||
| 554 | Err(e) => format!("Diff unavailable: {}", e), | ||
| 555 | } | ||
| 556 | } | ||
| 557 | |||
| 558 | fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> String { | 334 | fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> String { |
| 559 | let short_oid = &oid.to_string()[..7]; | 335 | let short_oid = &oid.to_string()[..7]; |
| 560 | let action_label = action_type_label(&event.action); | 336 | let action_label = action_type_label(&event.action); |
| @@ -665,52 +441,6 @@ fn run_loop( | |||
| 665 | repo: &Repository, | 441 | repo: &Repository, |
| 666 | ) -> Result<(), Error> { | 442 | ) -> Result<(), Error> { |
| 667 | loop { | 443 | loop { |
| 668 | // Cache diff and branch info for selected patch if needed | ||
| 669 | if app.tab == Tab::Patches { | ||
| 670 | if let Some(idx) = app.list_state.selected() { | ||
| 671 | // Collect info we need without holding the borrow | ||
| 672 | let patch_data = { | ||
| 673 | let visible = app.visible_patches(); | ||
| 674 | visible.get(idx).map(|patch| { | ||
| 675 | let id = patch.id.clone(); | ||
| 676 | let needs_branch_info = !app.branch_info_cache.contains_key(&id); | ||
| 677 | let needs_diff = app.mode == ViewMode::Diff && !app.diff_cache.contains_key(&id); | ||
| 678 | let branch_info = if needs_branch_info { | ||
| 679 | Some({ | ||
| 680 | let branch_exists = patch.resolve_head(repo).is_ok(); | ||
| 681 | let staleness = if branch_exists { | ||
| 682 | patch.staleness(repo).ok() | ||
| 683 | } else { | ||
| 684 | None | ||
| 685 | }; | ||
| 686 | PatchBranchInfo { | ||
| 687 | staleness, | ||
| 688 | branch_exists, | ||
| 689 | } | ||
| 690 | }) | ||
| 691 | } else { | ||
| 692 | None | ||
| 693 | }; | ||
| 694 | let diff = if needs_diff { | ||
| 695 | Some(generate_diff(repo, patch)) | ||
| 696 | } else { | ||
| 697 | None | ||
| 698 | }; | ||
| 699 | (id, branch_info, diff) | ||
| 700 | }) | ||
| 701 | }; | ||
| 702 | |||
| 703 | if let Some((id, branch_info, diff)) = patch_data { | ||
| 704 | if let Some(info) = branch_info { | ||
| 705 | app.branch_info_cache.insert(id.clone(), info); | ||
| 706 | } | ||
| 707 | if let Some(d) = diff { | ||
| 708 | app.diff_cache.insert(id, d); | ||
| 709 | } | ||
| 710 | } | ||
| 711 | } | ||
| 712 | } | ||
| 713 | |||
| 714 | terminal.draw(|frame| ui(frame, app))?; | 444 | terminal.draw(|frame| ui(frame, app))?; |
| 715 | 445 | ||
| 716 | if event::poll(Duration::from_millis(100))? { | 446 | if event::poll(Duration::from_millis(100))? { |
| @@ -824,7 +554,7 @@ fn run_loop( | |||
| 824 | 554 | ||
| 825 | // Handle keys that need repo access or are run_loop-specific | 555 | // Handle keys that need repo access or are run_loop-specific |
| 826 | // before delegating to handle_key | 556 | // before delegating to handle_key |
| 827 | if app.mode == ViewMode::Details || app.mode == ViewMode::Diff { | 557 | if app.mode == ViewMode::Details { |
| 828 | match key.code { | 558 | match key.code { |
| 829 | KeyCode::Char('/') => { | 559 | KeyCode::Char('/') => { |
| 830 | app.input_mode = InputMode::Search; | 560 | app.input_mode = InputMode::Search; |
| @@ -832,48 +562,29 @@ fn run_loop( | |||
| 832 | continue; | 562 | continue; |
| 833 | } | 563 | } |
| 834 | KeyCode::Char('n') => { | 564 | KeyCode::Char('n') => { |
| 835 | if app.tab != Tab::Issues { | ||
| 836 | app.switch_tab(Tab::Issues); | ||
| 837 | } | ||
| 838 | app.input_mode = InputMode::CreateTitle; | 565 | app.input_mode = InputMode::CreateTitle; |
| 839 | app.input_buf.clear(); | 566 | app.input_buf.clear(); |
| 840 | app.create_title.clear(); | 567 | app.create_title.clear(); |
| 841 | continue; | 568 | continue; |
| 842 | } | 569 | } |
| 843 | KeyCode::Char('g') => { | ||
| 844 | if !app.follow_link() { | ||
| 845 | app.status_msg = Some("No linked item to follow".to_string()); | ||
| 846 | } | ||
| 847 | continue; | ||
| 848 | } | ||
| 849 | KeyCode::Char('o') => { | 570 | KeyCode::Char('o') => { |
| 850 | // Check out the relevant commit for local browsing | 571 | // Check out the relevant commit for local browsing |
| 851 | let checkout_target = match app.tab { | 572 | let checkout_target = { |
| 852 | Tab::Patches => { | 573 | let visible = app.visible_issues(); |
| 853 | let visible = app.visible_patches(); | 574 | app.list_state |
| 854 | app.list_state | 575 | .selected() |
| 855 | .selected() | 576 | .and_then(|idx| visible.get(idx)) |
| 856 | .and_then(|idx| visible.get(idx)) | 577 | .and_then(|issue| { |
| 857 | .map(|p| p.branch.clone()) | 578 | // Try linked patch first |
| 858 | } | 579 | app.patches |
| 859 | Tab::Issues => { | 580 | .iter() |
| 860 | // Find linked patch's branch, or fall back to closing commit | 581 | .find(|p| p.fixes.as_deref() == Some(&issue.id)) |
| 861 | let visible = app.visible_issues(); | 582 | .map(|p| p.branch.clone()) |
| 862 | app.list_state | 583 | // Fall back to closing commit |
| 863 | .selected() | 584 | .or_else(|| { |
| 864 | .and_then(|idx| visible.get(idx)) | 585 | issue.closed_by.map(|oid| oid.to_string()) |
| 865 | .and_then(|issue| { | 586 | }) |
| 866 | // Try linked patch first | 587 | }) |
| 867 | app.patches | ||
| 868 | .iter() | ||
| 869 | .find(|p| p.fixes.as_deref() == Some(&issue.id)) | ||
| 870 | .map(|p| p.branch.clone()) | ||
| 871 | // Fall back to closing commit | ||
| 872 | .or_else(|| { | ||
| 873 | issue.closed_by.map(|oid| oid.to_string()) | ||
| 874 | }) | ||
| 875 | }) | ||
| 876 | } | ||
| 877 | }; | 588 | }; |
| 878 | if let Some(head) = checkout_target { | 589 | if let Some(head) = checkout_target { |
| 879 | // Exit TUI, checkout, and return | 590 | // Exit TUI, checkout, and return |
| @@ -938,31 +649,13 @@ fn ui(frame: &mut Frame, app: &mut App) { | |||
| 938 | let chunks = Layout::default() | 649 | let chunks = Layout::default() |
| 939 | .direction(Direction::Vertical) | 650 | .direction(Direction::Vertical) |
| 940 | .constraints([ | 651 | .constraints([ |
| 941 | Constraint::Length(1), | ||
| 942 | Constraint::Min(1), | 652 | Constraint::Min(1), |
| 943 | Constraint::Length(1), | 653 | Constraint::Length(1), |
| 944 | ]) | 654 | ]) |
| 945 | .split(frame.area()); | 655 | .split(frame.area()); |
| 946 | 656 | ||
| 947 | let tab_area = chunks[0]; | 657 | let main_area = chunks[0]; |
| 948 | let main_area = chunks[1]; | 658 | let footer_area = chunks[1]; |
| 949 | let footer_area = chunks[2]; | ||
| 950 | |||
| 951 | // Tab bar | ||
| 952 | let tab_titles = vec!["1:Issues", "2:Patches"]; | ||
| 953 | let selected_tab = match app.tab { | ||
| 954 | Tab::Issues => 0, | ||
| 955 | Tab::Patches => 1, | ||
| 956 | }; | ||
| 957 | let tabs = Tabs::new(tab_titles) | ||
| 958 | .select(selected_tab) | ||
| 959 | .highlight_style( | ||
| 960 | Style::default() | ||
| 961 | .fg(Color::Yellow) | ||
| 962 | .add_modifier(Modifier::BOLD), | ||
| 963 | ) | ||
| 964 | .divider(" | "); | ||
| 965 | frame.render_widget(tabs, tab_area); | ||
| 966 | 659 | ||
| 967 | let panes = Layout::default() | 660 | let panes = Layout::default() |
| 968 | .direction(Direction::Horizontal) | 661 | .direction(Direction::Horizontal) |
| @@ -981,80 +674,39 @@ fn render_list(frame: &mut Frame, app: &mut App, area: Rect) { | |||
| 981 | Style::default().fg(Color::DarkGray) | 674 | Style::default().fg(Color::DarkGray) |
| 982 | }; | 675 | }; |
| 983 | 676 | ||
| 984 | match app.tab { | 677 | let visible = app.visible_issues(); |
| 985 | Tab::Issues => { | 678 | let items: Vec<ListItem> = visible |
| 986 | let visible = app.visible_issues(); | 679 | .iter() |
| 987 | let items: Vec<ListItem> = visible | 680 | .map(|i| { |
| 988 | .iter() | 681 | let status = match i.status { |
| 989 | .map(|i| { | 682 | IssueStatus::Open => "open", |
| 990 | let status = match i.status { | 683 | IssueStatus::Closed => "closed", |
| 991 | IssueStatus::Open => "open", | 684 | }; |
| 992 | IssueStatus::Closed => "closed", | 685 | let style = match i.status { |
| 993 | }; | 686 | IssueStatus::Open => Style::default().fg(Color::Green), |
| 994 | let style = match i.status { | 687 | IssueStatus::Closed => Style::default().fg(Color::Red), |
| 995 | IssueStatus::Open => Style::default().fg(Color::Green), | 688 | }; |
| 996 | IssueStatus::Closed => Style::default().fg(Color::Red), | 689 | ListItem::new(format!("{:.8} {:6} {}", i.id, status, i.title)).style(style) |
| 997 | }; | 690 | }) |
| 998 | ListItem::new(format!("{:.8} {:6} {}", i.id, status, i.title)).style(style) | 691 | .collect(); |
| 999 | }) | 692 | |
| 1000 | .collect(); | 693 | let title = format!("Issues ({})", app.status_filter.label()); |
| 1001 | 694 | ||
| 1002 | let title = format!("Issues ({})", app.status_filter.label()); | 695 | let list = List::new(items) |
| 1003 | 696 | .block( | |
| 1004 | let list = List::new(items) | 697 | Block::default() |
| 1005 | .block( | 698 | .borders(Borders::ALL) |
| 1006 | Block::default() | 699 | .title(title) |
| 1007 | .borders(Borders::ALL) | 700 | .border_style(border_style), |
| 1008 | .title(title) | 701 | ) |
| 1009 | .border_style(border_style), | 702 | .highlight_style( |
| 1010 | ) | 703 | Style::default() |
| 1011 | .highlight_style( | 704 | .bg(Color::DarkGray) |
| 1012 | Style::default() | 705 | .add_modifier(Modifier::BOLD), |
| 1013 | .bg(Color::DarkGray) | 706 | ) |
| 1014 | .add_modifier(Modifier::BOLD), | 707 | .highlight_symbol("> "); |
| 1015 | ) | 708 | |
| 1016 | .highlight_symbol("> "); | 709 | frame.render_stateful_widget(list, area, &mut app.list_state); |
| 1017 | |||
| 1018 | frame.render_stateful_widget(list, area, &mut app.list_state); | ||
| 1019 | } | ||
| 1020 | Tab::Patches => { | ||
| 1021 | let visible = app.visible_patches(); | ||
| 1022 | let items: Vec<ListItem> = visible | ||
| 1023 | .iter() | ||
| 1024 | .map(|p| { | ||
| 1025 | let status = match p.status { | ||
| 1026 | PatchStatus::Open => "open", | ||
| 1027 | PatchStatus::Closed => "closed", | ||
| 1028 | PatchStatus::Merged => "merged", | ||
| 1029 | }; | ||
| 1030 | let style = match p.status { | ||
| 1031 | PatchStatus::Open => Style::default().fg(Color::Green), | ||
| 1032 | PatchStatus::Closed => Style::default().fg(Color::Red), | ||
| 1033 | PatchStatus::Merged => Style::default().fg(Color::Cyan), | ||
| 1034 | }; | ||
| 1035 | ListItem::new(format!("{:.8} {:6} {}", p.id, status, p.title)).style(style) | ||
| 1036 | }) | ||
| 1037 | .collect(); | ||
| 1038 | |||
| 1039 | let title = format!("Patches ({})", app.status_filter.label()); | ||
| 1040 | |||
| 1041 | let list = List::new(items) | ||
| 1042 | .block( | ||
| 1043 | Block::default() | ||
| 1044 | .borders(Borders::ALL) | ||
| 1045 | .title(title) | ||
| 1046 | .border_style(border_style), | ||
| 1047 | ) | ||
| 1048 | .highlight_style( | ||
| 1049 | Style::default() | ||
| 1050 | .bg(Color::DarkGray) | ||
| 1051 | .add_modifier(Modifier::BOLD), | ||
| 1052 | ) | ||
| 1053 | .highlight_symbol("> "); | ||
| 1054 | |||
| 1055 | frame.render_stateful_widget(list, area, &mut app.list_state); | ||
| 1056 | } | ||
| 1057 | } | ||
| 1058 | } | 710 | } |
| 1059 | 711 | ||
| 1060 | fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) { | 712 | fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) { |
| @@ -1121,49 +773,16 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) { | |||
| 1121 | return; | 773 | return; |
| 1122 | } | 774 | } |
| 1123 | 775 | ||
| 1124 | let title = match (&app.tab, &app.mode) { | 776 | let visible = app.visible_issues(); |
| 1125 | (Tab::Issues, _) => "Issue Details", | 777 | let selected_idx = app.list_state.selected().unwrap_or(0); |
| 1126 | (Tab::Patches, ViewMode::Details) => "Patch Details", | 778 | let content: Text = match visible.get(selected_idx) { |
| 1127 | (Tab::Patches, ViewMode::Diff) => "Diff", | 779 | Some(issue) => build_issue_detail(issue, &app.patches), |
| 1128 | _ => "Details", | 780 | None => Text::raw("No matches for current filter."), |
| 1129 | }; | ||
| 1130 | |||
| 1131 | let content: Text = match app.tab { | ||
| 1132 | Tab::Issues => { | ||
| 1133 | let visible = app.visible_issues(); | ||
| 1134 | let selected_idx = app.list_state.selected().unwrap_or(0); | ||
| 1135 | match visible.get(selected_idx) { | ||
| 1136 | Some(issue) => build_issue_detail(issue, &app.patches), | ||
| 1137 | None => Text::raw("No matches for current filter."), | ||
| 1138 | } | ||
| 1139 | } | ||
| 1140 | Tab::Patches => { | ||
| 1141 | let visible = app.visible_patches(); | ||
| 1142 | let selected_idx = app.list_state.selected().unwrap_or(0); | ||
| 1143 | match visible.get(selected_idx) { | ||
| 1144 | Some(patch) => match app.mode { | ||
| 1145 | ViewMode::Details => { | ||
| 1146 | let branch_info = app.branch_info_cache.get(&patch.id).cloned(); | ||
| 1147 | build_patch_detail(patch, branch_info.as_ref()) | ||
| 1148 | } | ||
| 1149 | ViewMode::Diff => { | ||
| 1150 | let diff_text = app | ||
| 1151 | .diff_cache | ||
| 1152 | .get(&patch.id) | ||
| 1153 | .map(|s| s.as_str()) | ||
| 1154 | .unwrap_or("Loading..."); | ||
| 1155 | colorize_diff(diff_text, &patch.inline_comments) | ||
| 1156 | } | ||
| 1157 | _ => Text::raw(""), | ||
| 1158 | }, | ||
| 1159 | None => Text::raw("No matches for current filter."), | ||
| 1160 | } | ||
| 1161 | } | ||
| 1162 | }; | 781 | }; |
| 1163 | 782 | ||
| 1164 | let block = Block::default() | 783 | let block = Block::default() |
| 1165 | .borders(Borders::ALL) | 784 | .borders(Borders::ALL) |
| 1166 | .title(title) | 785 | .title("Issue Details") |
| 1167 | .border_style(border_style); | 786 | .border_style(border_style); |
| 1168 | 787 | ||
| 1169 | let para = Paragraph::new(content) | 788 | let para = Paragraph::new(content) |
| @@ -1303,301 +922,6 @@ fn build_issue_detail(issue: &IssueState, patches: &[PatchState]) -> Text<'stati | |||
| 1303 | Text::from(lines) | 922 | Text::from(lines) |
| 1304 | } | 923 | } |
| 1305 | 924 | ||
| 1306 | fn build_patch_detail(patch: &PatchState, branch_info: Option<&PatchBranchInfo>) -> Text<'static> { | ||
| 1307 | let status = match patch.status { | ||
| 1308 | PatchStatus::Open => "open", | ||
| 1309 | PatchStatus::Closed => "closed", | ||
| 1310 | PatchStatus::Merged => "merged", | ||
| 1311 | }; | ||
| 1312 | |||
| 1313 | let mut lines: Vec<Line> = vec![ | ||
| 1314 | Line::from(vec![ | ||
| 1315 | Span::styled("Patch ", Style::default().add_modifier(Modifier::BOLD)), | ||
| 1316 | Span::styled( | ||
| 1317 | format!("{:.8}", patch.id), | ||
| 1318 | Style::default() | ||
| 1319 | .fg(Color::Yellow) | ||
| 1320 | .add_modifier(Modifier::BOLD), | ||
| 1321 | ), | ||
| 1322 | Span::raw(format!(" [{}]", status)), | ||
| 1323 | ]), | ||
| 1324 | Line::from(vec![ | ||
| 1325 | Span::styled("Title: ", Style::default().fg(Color::DarkGray)), | ||
| 1326 | Span::raw(patch.title.clone()), | ||
| 1327 | ]), | ||
| 1328 | Line::from(vec![ | ||
| 1329 | Span::styled("Author: ", Style::default().fg(Color::DarkGray)), | ||
| 1330 | Span::raw(format!("{} <{}>", patch.author.name, patch.author.email)), | ||
| 1331 | ]), | ||
| 1332 | ]; | ||
| 1333 | |||
| 1334 | if let Some(info) = branch_info { | ||
| 1335 | if info.branch_exists { | ||
| 1336 | lines.push(Line::from(vec![ | ||
| 1337 | Span::styled("Branch: ", Style::default().fg(Color::DarkGray)), | ||
| 1338 | Span::styled( | ||
| 1339 | patch.branch.clone(), | ||
| 1340 | Style::default().fg(Color::Cyan), | ||
| 1341 | ), | ||
| 1342 | Span::raw(format!(" -> {}", patch.base_ref)), | ||
| 1343 | ])); | ||
| 1344 | if let Some((ahead, behind)) = info.staleness { | ||
| 1345 | let freshness = if behind == 0 { "up-to-date" } else { "outdated" }; | ||
| 1346 | lines.push(Line::from(vec![ | ||
| 1347 | Span::styled("Commits: ", Style::default().fg(Color::DarkGray)), | ||
| 1348 | Span::raw(format!("{} ahead, {} behind ({})", ahead, behind, freshness)), | ||
| 1349 | ])); | ||
| 1350 | } | ||
| 1351 | } else { | ||
| 1352 | lines.push(Line::from(vec![ | ||
| 1353 | Span::styled("Branch: ", Style::default().fg(Color::DarkGray)), | ||
| 1354 | Span::styled( | ||
| 1355 | patch.branch.clone(), | ||
| 1356 | Style::default().fg(Color::Red), | ||
| 1357 | ), | ||
| 1358 | Span::styled(" (not found)", Style::default().fg(Color::Red)), | ||
| 1359 | ])); | ||
| 1360 | } | ||
| 1361 | } else { | ||
| 1362 | lines.push(Line::from(vec![ | ||
| 1363 | Span::styled("Branch: ", Style::default().fg(Color::DarkGray)), | ||
| 1364 | Span::raw(patch.branch.clone()), | ||
| 1365 | Span::raw(format!(" -> {}", patch.base_ref)), | ||
| 1366 | ])); | ||
| 1367 | } | ||
| 1368 | |||
| 1369 | lines.push(Line::from(vec![ | ||
| 1370 | Span::styled("Created: ", Style::default().fg(Color::DarkGray)), | ||
| 1371 | Span::raw(patch.created_at.clone()), | ||
| 1372 | ])); | ||
| 1373 | |||
| 1374 | if let Some(ref fixes) = patch.fixes { | ||
| 1375 | lines.push(Line::from(vec![ | ||
| 1376 | Span::styled("Fixes: ", Style::default().fg(Color::DarkGray)), | ||
| 1377 | Span::styled( | ||
| 1378 | format!("{:.8}", fixes), | ||
| 1379 | Style::default().fg(Color::Yellow), | ||
| 1380 | ), | ||
| 1381 | ])); | ||
| 1382 | } | ||
| 1383 | |||
| 1384 | if !patch.body.is_empty() { | ||
| 1385 | lines.push(Line::raw("")); | ||
| 1386 | for l in patch.body.lines() { | ||
| 1387 | lines.push(Line::raw(l.to_string())); | ||
| 1388 | } | ||
| 1389 | } | ||
| 1390 | |||
| 1391 | if !patch.reviews.is_empty() { | ||
| 1392 | lines.push(Line::raw("")); | ||
| 1393 | lines.push(Line::styled( | ||
| 1394 | "--- Reviews ---", | ||
| 1395 | Style::default() | ||
| 1396 | .fg(Color::Magenta) | ||
| 1397 | .add_modifier(Modifier::BOLD), | ||
| 1398 | )); | ||
| 1399 | for r in &patch.reviews { | ||
| 1400 | lines.push(Line::raw("")); | ||
| 1401 | let verdict_style = match r.verdict { | ||
| 1402 | crate::event::ReviewVerdict::Approve => Style::default().fg(Color::Green), | ||
| 1403 | crate::event::ReviewVerdict::RequestChanges => Style::default().fg(Color::Red), | ||
| 1404 | crate::event::ReviewVerdict::Comment => Style::default().fg(Color::Yellow), | ||
| 1405 | }; | ||
| 1406 | lines.push(Line::from(vec![ | ||
| 1407 | Span::raw(format!("{} ", r.author.name)), | ||
| 1408 | Span::styled(format!("({:?})", r.verdict), verdict_style), | ||
| 1409 | Span::styled( | ||
| 1410 | format!(" - {}", r.timestamp), | ||
| 1411 | Style::default().fg(Color::DarkGray), | ||
| 1412 | ), | ||
| 1413 | ])); | ||
| 1414 | for l in r.body.lines() { | ||
| 1415 | lines.push(Line::raw(format!(" {}", l))); | ||
| 1416 | } | ||
| 1417 | } | ||
| 1418 | } | ||
| 1419 | |||
| 1420 | if !patch.inline_comments.is_empty() { | ||
| 1421 | lines.push(Line::raw("")); | ||
| 1422 | lines.push(Line::styled( | ||
| 1423 | "--- Inline Comments ---", | ||
| 1424 | Style::default() | ||
| 1425 | .fg(Color::Magenta) | ||
| 1426 | .add_modifier(Modifier::BOLD), | ||
| 1427 | )); | ||
| 1428 | for c in &patch.inline_comments { | ||
| 1429 | lines.push(Line::raw("")); | ||
| 1430 | lines.push(Line::from(vec![ | ||
| 1431 | Span::styled( | ||
| 1432 | format!("{}:{}", c.file, c.line), | ||
| 1433 | Style::default().fg(Color::Cyan), | ||
| 1434 | ), | ||
| 1435 | Span::raw(format!(" - {} ", c.author.name)), | ||
| 1436 | Span::styled( | ||
| 1437 | format!("({})", c.timestamp), | ||
| 1438 | Style::default().fg(Color::DarkGray), | ||
| 1439 | ), | ||
| 1440 | ])); | ||
| 1441 | for l in c.body.lines() { | ||
| 1442 | lines.push(Line::raw(format!(" {}", l))); | ||
| 1443 | } | ||
| 1444 | } | ||
| 1445 | } | ||
| 1446 | |||
| 1447 | if !patch.comments.is_empty() { | ||
| 1448 | lines.push(Line::raw("")); | ||
| 1449 | lines.push(Line::styled( | ||
| 1450 | "--- Comments ---", | ||
| 1451 | Style::default() | ||
| 1452 | .fg(Color::Blue) | ||
| 1453 | .add_modifier(Modifier::BOLD), | ||
| 1454 | )); | ||
| 1455 | for c in &patch.comments { | ||
| 1456 | lines.push(Line::raw("")); | ||
| 1457 | lines.push(Line::from(vec![ | ||
| 1458 | Span::styled( | ||
| 1459 | c.author.name.clone(), | ||
| 1460 | Style::default().add_modifier(Modifier::BOLD), | ||
| 1461 | ), | ||
| 1462 | Span::styled( | ||
| 1463 | format!(" ({})", c.timestamp), | ||
| 1464 | Style::default().fg(Color::DarkGray), | ||
| 1465 | ), | ||
| 1466 | ])); | ||
| 1467 | for l in c.body.lines() { | ||
| 1468 | lines.push(Line::raw(format!(" {}", l))); | ||
| 1469 | } | ||
| 1470 | } | ||
| 1471 | } | ||
| 1472 | |||
| 1473 | Text::from(lines) | ||
| 1474 | } | ||
| 1475 | |||
| 1476 | fn colorize_diff(diff: &str, inline_comments: &[state::InlineComment]) -> Text<'static> { | ||
| 1477 | // Build a lookup: (file, line) -> vec of comments | ||
| 1478 | let mut comment_map: HashMap<(String, u32), Vec<&state::InlineComment>> = HashMap::new(); | ||
| 1479 | for c in inline_comments { | ||
| 1480 | comment_map | ||
| 1481 | .entry((c.file.clone(), c.line)) | ||
| 1482 | .or_default() | ||
| 1483 | .push(c); | ||
| 1484 | } | ||
| 1485 | |||
| 1486 | let mut lines: Vec<Line> = Vec::new(); | ||
| 1487 | let mut current_file = String::new(); | ||
| 1488 | let mut current_new_line: u32 = 0; | ||
| 1489 | |||
| 1490 | for diff_line in diff.lines() { | ||
| 1491 | // Track which file we're in from +++ headers | ||
| 1492 | if let Some(file) = diff_line.strip_prefix("+++ b/") { | ||
| 1493 | current_file = file.to_string(); | ||
| 1494 | } else if diff_line.starts_with("+++ ") { | ||
| 1495 | // +++ /dev/null or similar | ||
| 1496 | current_file.clear(); | ||
| 1497 | } | ||
| 1498 | |||
| 1499 | // Track line numbers from @@ hunk headers | ||
| 1500 | if diff_line.starts_with("@@") { | ||
| 1501 | // Parse @@ -old,len +new,len @@ | ||
| 1502 | if let Some(plus_part) = diff_line.split('+').nth(1) { | ||
| 1503 | if let Some(line_str) = plus_part.split(',').next().or(plus_part.split(' ').next()) | ||
| 1504 | { | ||
| 1505 | current_new_line = line_str.parse().unwrap_or(0); | ||
| 1506 | } | ||
| 1507 | } | ||
| 1508 | } | ||
| 1509 | |||
| 1510 | // Colorize the diff line | ||
| 1511 | let style = if diff_line.starts_with('+') && !diff_line.starts_with("+++") { | ||
| 1512 | Style::default().fg(Color::Green) | ||
| 1513 | } else if diff_line.starts_with('-') && !diff_line.starts_with("---") { | ||
| 1514 | Style::default().fg(Color::Red) | ||
| 1515 | } else if diff_line.starts_with("@@") { | ||
| 1516 | Style::default().fg(Color::Cyan) | ||
| 1517 | } else if diff_line.starts_with("diff ") || diff_line.starts_with("index ") { | ||
| 1518 | Style::default().fg(Color::DarkGray) | ||
| 1519 | } else if diff_line.starts_with("+++") || diff_line.starts_with("---") { | ||
| 1520 | Style::default().fg(Color::Yellow) | ||
| 1521 | } else { | ||
| 1522 | Style::default() | ||
| 1523 | }; | ||
| 1524 | lines.push(Line::styled(diff_line.to_string(), style)); | ||
| 1525 | |||
| 1526 | // After rendering this line, check for inline comments at current position | ||
| 1527 | if !current_file.is_empty() | ||
| 1528 | && current_new_line > 0 | ||
| 1529 | && (diff_line.starts_with('+') | ||
| 1530 | || diff_line.starts_with(' ') | ||
| 1531 | || diff_line.starts_with("@@")) | ||
| 1532 | { | ||
| 1533 | if let Some(comments) = comment_map.get(&(current_file.clone(), current_new_line)) { | ||
| 1534 | for c in comments { | ||
| 1535 | lines.push(Line::styled( | ||
| 1536 | format!(" ┌─ {} ({})", c.author.name, c.timestamp), | ||
| 1537 | Style::default() | ||
| 1538 | .fg(Color::Magenta) | ||
| 1539 | .add_modifier(Modifier::BOLD), | ||
| 1540 | )); | ||
| 1541 | for body_line in c.body.lines() { | ||
| 1542 | lines.push(Line::styled( | ||
| 1543 | format!(" │ {}", body_line), | ||
| 1544 | Style::default().fg(Color::Magenta), | ||
| 1545 | )); | ||
| 1546 | } | ||
| 1547 | lines.push(Line::styled( | ||
| 1548 | " └─".to_string(), | ||
| 1549 | Style::default().fg(Color::Magenta), | ||
| 1550 | )); | ||
| 1551 | } | ||
| 1552 | } | ||
| 1553 | } | ||
| 1554 | |||
| 1555 | // Advance line counter for added/context lines (not removed or header) | ||
| 1556 | if (diff_line.starts_with('+') && !diff_line.starts_with("+++")) | ||
| 1557 | || diff_line.starts_with(' ') | ||
| 1558 | { | ||
| 1559 | current_new_line += 1; | ||
| 1560 | } | ||
| 1561 | } | ||
| 1562 | |||
| 1563 | // Show any inline comments for files not covered by the diff | ||
| 1564 | let files_in_diff: std::collections::HashSet<&str> = diff | ||
| 1565 | .lines() | ||
| 1566 | .filter_map(|l| l.strip_prefix("+++ b/")) | ||
| 1567 | .collect(); | ||
| 1568 | let mut orphan_comments: Vec<&state::InlineComment> = inline_comments | ||
| 1569 | .iter() | ||
| 1570 | .filter(|c| !files_in_diff.contains(c.file.as_str())) | ||
| 1571 | .collect(); | ||
| 1572 | if !orphan_comments.is_empty() { | ||
| 1573 | orphan_comments.sort_by(|a, b| (&a.file, a.line).cmp(&(&b.file, b.line))); | ||
| 1574 | lines.push(Line::raw("")); | ||
| 1575 | lines.push(Line::styled( | ||
| 1576 | "--- Comments on files not in diff ---", | ||
| 1577 | Style::default() | ||
| 1578 | .fg(Color::Magenta) | ||
| 1579 | .add_modifier(Modifier::BOLD), | ||
| 1580 | )); | ||
| 1581 | for c in orphan_comments { | ||
| 1582 | lines.push(Line::styled( | ||
| 1583 | format!( | ||
| 1584 | " {}:{} - {} ({}):", | ||
| 1585 | c.file, c.line, c.author.name, c.timestamp | ||
| 1586 | ), | ||
| 1587 | Style::default().fg(Color::Magenta), | ||
| 1588 | )); | ||
| 1589 | for body_line in c.body.lines() { | ||
| 1590 | lines.push(Line::styled( | ||
| 1591 | format!(" {}", body_line), | ||
| 1592 | Style::default().fg(Color::Magenta), | ||
| 1593 | )); | ||
| 1594 | } | ||
| 1595 | } | ||
| 1596 | } | ||
| 1597 | |||
| 1598 | Text::from(lines) | ||
| 1599 | } | ||
| 1600 | |||
| 1601 | fn render_footer(frame: &mut Frame, app: &App, area: Rect) { | 925 | fn render_footer(frame: &mut Frame, app: &App, area: Rect) { |
| 1602 | match app.input_mode { | 926 | match app.input_mode { |
| 1603 | InputMode::Search => { | 927 | InputMode::Search => { |
| @@ -1653,17 +977,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { | |||
| 1653 | let mode_hint = match app.mode { | 977 | let mode_hint = match app.mode { |
| 1654 | ViewMode::CommitList => " Esc:back", | 978 | ViewMode::CommitList => " Esc:back", |
| 1655 | ViewMode::CommitDetail => " Esc:back j/k:scroll", | 979 | ViewMode::CommitDetail => " Esc:back j/k:scroll", |
| 1656 | _ => { | 980 | ViewMode::Details => " c:events", |
| 1657 | if app.tab == Tab::Patches { | ||
| 1658 | match app.mode { | ||
| 1659 | ViewMode::Details => " d:diff c:events", | ||
| 1660 | ViewMode::Diff => " d:details c:events", | ||
| 1661 | _ => "", | ||
| 1662 | } | ||
| 1663 | } else { | ||
| 1664 | " c:events" | ||
| 1665 | } | ||
| 1666 | } | ||
| 1667 | }; | 981 | }; |
| 1668 | let filter_hint = match app.status_filter { | 982 | let filter_hint = match app.status_filter { |
| 1669 | StatusFilter::Open => "a:show all", | 983 | StatusFilter::Open => "a:show all", |
| @@ -1671,7 +985,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { | |||
| 1671 | StatusFilter::Closed => "a:open only", | 985 | StatusFilter::Closed => "a:open only", |
| 1672 | }; | 986 | }; |
| 1673 | let text = format!( | 987 | let text = format!( |
| 1674 | " 1:issues 2:patches j/k:navigate Tab:pane {}{} /:search n:new issue g:follow o:checkout r:refresh q:quit", | 988 | " j/k:navigate Tab:pane {}{} /:search n:new issue o:checkout r:refresh q:quit", |
| 1675 | filter_hint, mode_hint | 989 | filter_hint, mode_hint |
| 1676 | ); | 990 | ); |
| 1677 | let para = Paragraph::new(text).style(Style::default().bg(Color::DarkGray).fg(Color::White)); | 991 | let para = Paragraph::new(text).style(Style::default().bg(Color::DarkGray).fg(Color::White)); |
| @@ -1748,17 +1062,6 @@ mod tests { | |||
| 1748 | assert_eq!(visible[0].title, "Fix login bug"); | 1062 | assert_eq!(visible[0].title, "Fix login bug"); |
| 1749 | } | 1063 | } |
| 1750 | 1064 | ||
| 1751 | // T011: visible_patches filters by search_query (case-insensitive) | ||
| 1752 | #[test] | ||
| 1753 | fn test_visible_patches_text_filter() { | ||
| 1754 | let mut app = test_app(); | ||
| 1755 | app.status_filter = StatusFilter::All; | ||
| 1756 | app.search_query = "DASHBOARD".into(); | ||
| 1757 | let visible = app.visible_patches(); | ||
| 1758 | assert_eq!(visible.len(), 1); | ||
| 1759 | assert_eq!(visible[0].title, "Dashboard patch"); | ||
| 1760 | } | ||
| 1761 | |||
| 1762 | // T012: visible_issues returns all status-matching items when search_query is empty | 1065 | // T012: visible_issues returns all status-matching items when search_query is empty |
| 1763 | #[test] | 1066 | #[test] |
| 1764 | fn test_visible_issues_no_text_filter() { | 1067 | fn test_visible_issues_no_text_filter() { |
| @@ -1787,17 +1090,6 @@ mod tests { | |||
| 1787 | assert_eq!(visible[0].title, "Old closed issue"); | 1090 | assert_eq!(visible[0].title, "Old closed issue"); |
| 1788 | } | 1091 | } |
| 1789 | 1092 | ||
| 1790 | // T021: visible_patches returns closed AND merged when status_filter is Closed | ||
| 1791 | #[test] | ||
| 1792 | fn test_visible_patches_closed_filter() { | ||
| 1793 | let mut app = test_app(); | ||
| 1794 | app.status_filter = StatusFilter::Closed; | ||
| 1795 | let visible = app.visible_patches(); | ||
| 1796 | assert_eq!(visible.len(), 2); | ||
| 1797 | assert!(visible.iter().any(|p| p.status == PatchStatus::Closed)); | ||
| 1798 | assert!(visible.iter().any(|p| p.status == PatchStatus::Merged)); | ||
| 1799 | } | ||
| 1800 | |||
| 1801 | // T025: combined status + text filter | 1093 | // T025: combined status + text filter |
| 1802 | #[test] | 1094 | #[test] |
| 1803 | fn test_combined_filters() { | 1095 | fn test_combined_filters() { |
| @@ -2387,35 +1679,6 @@ mod tests { | |||
| 2387 | } | 1679 | } |
| 2388 | 1680 | ||
| 2389 | #[test] | 1681 | #[test] |
| 2390 | fn test_handle_key_tab_switch() { | ||
| 2391 | let mut app = make_app(3, 3); | ||
| 2392 | app.handle_key(KeyCode::Char('2'), KeyModifiers::empty()); | ||
| 2393 | assert_eq!(app.tab, Tab::Patches); | ||
| 2394 | app.handle_key(KeyCode::Char('1'), KeyModifiers::empty()); | ||
| 2395 | assert_eq!(app.tab, Tab::Issues); | ||
| 2396 | } | ||
| 2397 | |||
| 2398 | #[test] | ||
| 2399 | fn test_handle_key_diff_toggle_patches() { | ||
| 2400 | let mut app = make_app(0, 3); | ||
| 2401 | app.switch_tab(Tab::Patches); | ||
| 2402 | assert_eq!(app.mode, ViewMode::Details); | ||
| 2403 | app.handle_key(KeyCode::Char('d'), KeyModifiers::empty()); | ||
| 2404 | assert_eq!(app.mode, ViewMode::Diff); | ||
| 2405 | app.handle_key(KeyCode::Char('d'), KeyModifiers::empty()); | ||
| 2406 | assert_eq!(app.mode, ViewMode::Details); | ||
| 2407 | } | ||
| 2408 | |||
| 2409 | #[test] | ||
| 2410 | fn test_handle_key_diff_noop_issues() { | ||
| 2411 | let mut app = make_app(3, 0); | ||
| 2412 | assert_eq!(app.tab, Tab::Issues); | ||
| 2413 | assert_eq!(app.mode, ViewMode::Details); | ||
| 2414 | app.handle_key(KeyCode::Char('d'), KeyModifiers::empty()); | ||
| 2415 | assert_eq!(app.mode, ViewMode::Details); | ||
| 2416 | } | ||
| 2417 | |||
| 2418 | #[test] | ||
| 2419 | fn test_handle_key_pane_toggle() { | 1682 | fn test_handle_key_pane_toggle() { |
| 2420 | let mut app = make_app(3, 3); | 1683 | let mut app = make_app(3, 3); |
| 2421 | assert_eq!(app.pane, Pane::ItemList); | 1684 | assert_eq!(app.pane, Pane::ItemList); |
| @@ -2462,17 +1725,6 @@ mod tests { | |||
| 2462 | } | 1725 | } |
| 2463 | 1726 | ||
| 2464 | #[test] | 1727 | #[test] |
| 2465 | fn test_selected_ref_name_patches() { | ||
| 2466 | let mut app = make_app(0, 3); | ||
| 2467 | app.switch_tab(Tab::Patches); | ||
| 2468 | let ref_name = app.selected_ref_name(); | ||
| 2469 | assert_eq!( | ||
| 2470 | ref_name, | ||
| 2471 | Some("refs/collab/patches/p0000000".to_string()) | ||
| 2472 | ); | ||
| 2473 | } | ||
| 2474 | |||
| 2475 | #[test] | ||
| 2476 | fn test_selected_ref_name_none_when_empty() { | 1728 | fn test_selected_ref_name_none_when_empty() { |
| 2477 | let app = make_app(0, 0); | 1729 | let app = make_app(0, 0); |
| 2478 | assert_eq!(app.selected_ref_name(), None); | 1730 | assert_eq!(app.selected_ref_name(), None); |
| @@ -2485,8 +1737,7 @@ mod tests { | |||
| 2485 | let mut app = make_app(3, 2); | 1737 | let mut app = make_app(3, 2); |
| 2486 | app.status_filter = StatusFilter::All; | 1738 | app.status_filter = StatusFilter::All; |
| 2487 | let buf = render_app(&mut app); | 1739 | let buf = render_app(&mut app); |
| 2488 | assert_buffer_contains(&buf, "1:Issues"); | 1740 | assert_buffer_contains(&buf, "Issues"); |
| 2489 | assert_buffer_contains(&buf, "2:Patches"); | ||
| 2490 | assert_buffer_contains(&buf, "00000000"); | 1741 | assert_buffer_contains(&buf, "00000000"); |
| 2491 | assert_buffer_contains(&buf, "00000001"); | 1742 | assert_buffer_contains(&buf, "00000001"); |
| 2492 | assert_buffer_contains(&buf, "00000002"); | 1743 | assert_buffer_contains(&buf, "00000002"); |
| @@ -2494,18 +1745,6 @@ mod tests { | |||
| 2494 | } | 1745 | } |
| 2495 | 1746 | ||
| 2496 | #[test] | 1747 | #[test] |
| 2497 | fn test_render_patches_tab() { | ||
| 2498 | let mut app = make_app(2, 3); | ||
| 2499 | app.status_filter = StatusFilter::All; | ||
| 2500 | app.switch_tab(Tab::Patches); | ||
| 2501 | let buf = render_app(&mut app); | ||
| 2502 | assert_buffer_contains(&buf, "p0000000"); | ||
| 2503 | assert_buffer_contains(&buf, "p0000001"); | ||
| 2504 | assert_buffer_contains(&buf, "p0000002"); | ||
| 2505 | assert_buffer_contains(&buf, "Patch 0"); | ||
| 2506 | } | ||
| 2507 | |||
| 2508 | #[test] | ||
| 2509 | fn test_render_empty_state() { | 1748 | fn test_render_empty_state() { |
| 2510 | let mut app = make_app(0, 0); | 1749 | let mut app = make_app(0, 0); |
| 2511 | let buf = render_app(&mut app); | 1750 | let buf = render_app(&mut app); |
tests/collab_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -156,7 +156,8 @@ fn test_concurrent_comments_create_fork_and_reconcile() { | |||
| 156 | repo.reference(&ref_name, alice_tip, true, "restore alice tip") | 156 | repo.reference(&ref_name, alice_tip, true, "restore alice tip") |
| 157 | .unwrap(); | 157 | .unwrap(); |
| 158 | 158 | ||
| 159 | let merge_oid = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap(); | 159 | let (merge_oid, outcome) = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap(); |
| 160 | assert_eq!(outcome, dag::ReconcileOutcome::Merge); | ||
| 160 | 161 | ||
| 161 | let merge_commit = repo.find_commit(merge_oid).unwrap(); | 162 | let merge_commit = repo.find_commit(merge_oid).unwrap(); |
| 162 | assert_eq!(merge_commit.parent_count(), 2); | 163 | assert_eq!(merge_commit.parent_count(), 2); |
| @@ -263,8 +264,9 @@ fn test_fast_forward_reconcile() { | |||
| 263 | repo.reference(&remote_ref, ahead_tip, true, "remote ahead") | 264 | repo.reference(&remote_ref, ahead_tip, true, "remote ahead") |
| 264 | .unwrap(); | 265 | .unwrap(); |
| 265 | 266 | ||
| 266 | let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap(); | 267 | let (result, outcome) = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap(); |
| 267 | assert_eq!(result, ahead_tip, "should fast-forward to remote tip"); | 268 | assert_eq!(result, ahead_tip, "should fast-forward to remote tip"); |
| 269 | assert_eq!(outcome, dag::ReconcileOutcome::FastForward); | ||
| 268 | 270 | ||
| 269 | let events = dag::walk_events(&repo, &ref_name).unwrap(); | 271 | let events = dag::walk_events(&repo, &ref_name).unwrap(); |
| 270 | assert_eq!(events.len(), 2); | 272 | assert_eq!(events.len(), 2); |
| @@ -284,8 +286,9 @@ fn test_no_op_when_already_in_sync() { | |||
| 284 | let remote_ref = format!("refs/collab/sync/origin/issues/{}", id); | 286 | let remote_ref = format!("refs/collab/sync/origin/issues/{}", id); |
| 285 | repo.reference(&remote_ref, tip, true, "same tip").unwrap(); | 287 | repo.reference(&remote_ref, tip, true, "same tip").unwrap(); |
| 286 | 288 | ||
| 287 | let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap(); | 289 | let (result, outcome) = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap(); |
| 288 | assert_eq!(result, tip); | 290 | assert_eq!(result, tip); |
| 291 | assert_eq!(outcome, dag::ReconcileOutcome::AlreadyCurrent); | ||
| 289 | } | 292 | } |
| 290 | 293 | ||
| 291 | #[test] | 294 | #[test] |
| @@ -303,8 +306,9 @@ fn test_local_ahead_no_merge() { | |||
| 303 | repo.reference(&remote_ref, root_oid, true, "remote behind") | 306 | repo.reference(&remote_ref, root_oid, true, "remote behind") |
| 304 | .unwrap(); | 307 | .unwrap(); |
| 305 | 308 | ||
| 306 | let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap(); | 309 | let (result, outcome) = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap(); |
| 307 | assert_eq!(result, local_tip, "local should stay ahead"); | 310 | assert_eq!(result, local_tip, "local should stay ahead"); |
| 311 | assert_eq!(outcome, dag::ReconcileOutcome::LocalAhead); | ||
| 308 | } | 312 | } |
| 309 | 313 | ||
| 310 | // --------------------------------------------------------------------------- | 314 | // --------------------------------------------------------------------------- |
| @@ -831,6 +835,87 @@ fn test_patch_show_outdated_staleness() { | |||
| 831 | } | 835 | } |
| 832 | 836 | ||
| 833 | // --------------------------------------------------------------------------- | 837 | // --------------------------------------------------------------------------- |
| 838 | // Backward compat: head_commit alias for branch field | ||
| 839 | // --------------------------------------------------------------------------- | ||
| 840 | |||
| 841 | #[test] | ||
| 842 | fn test_patch_create_with_head_commit_field_deserializes() { | ||
| 843 | // Events created with "head_commit" instead of "branch" should still work | ||
| 844 | let json = r#"{ | ||
| 845 | "timestamp": "2026-03-21T00:00:00+00:00", | ||
| 846 | "author": {"name": "agent", "email": "agent@test"}, | ||
| 847 | "action": { | ||
| 848 | "type": "patch.create", | ||
| 849 | "title": "Agent patch", | ||
| 850 | "body": "from worktree", | ||
| 851 | "base_ref": "main", | ||
| 852 | "head_commit": "abc123def456" | ||
| 853 | } | ||
| 854 | }"#; | ||
| 855 | let event: Event = serde_json::from_str(json).unwrap(); | ||
| 856 | match event.action { | ||
| 857 | Action::PatchCreate { branch, .. } => { | ||
| 858 | assert_eq!(branch, "abc123def456"); | ||
| 859 | } | ||
| 860 | _ => panic!("expected PatchCreate"), | ||
| 861 | } | ||
| 862 | } | ||
| 863 | |||
| 864 | #[test] | ||
| 865 | fn test_patch_create_with_branch_field_still_works() { | ||
| 866 | let json = r#"{ | ||
| 867 | "timestamp": "2026-03-21T00:00:00+00:00", | ||
| 868 | "author": {"name": "dev", "email": "dev@test"}, | ||
| 869 | "action": { | ||
| 870 | "type": "patch.create", | ||
| 871 | "title": "Normal patch", | ||
| 872 | "body": "", | ||
| 873 | "base_ref": "main", | ||
| 874 | "branch": "feature-branch" | ||
| 875 | } | ||
| 876 | }"#; | ||
| 877 | let event: Event = serde_json::from_str(json).unwrap(); | ||
| 878 | match event.action { | ||
| 879 | Action::PatchCreate { branch, .. } => { | ||
| 880 | assert_eq!(branch, "feature-branch"); | ||
| 881 | } | ||
| 882 | _ => panic!("expected PatchCreate"), | ||
| 883 | } | ||
| 884 | } | ||
| 885 | |||
| 886 | #[test] | ||
| 887 | fn test_resolve_head_with_oid_string() { | ||
| 888 | // If branch field contains a hex OID, resolve_head should try to parse it as an OID | ||
| 889 | let tmp = TempDir::new().unwrap(); | ||
| 890 | let repo = init_repo(tmp.path(), &alice()); | ||
| 891 | make_initial_commit(&repo, "main"); | ||
| 892 | let tip = add_commit_on_branch(&repo, "main", "f.rs", b"content"); | ||
| 893 | |||
| 894 | // Create a patch where "branch" is actually a commit OID string | ||
| 895 | let sk = test_signing_key(); | ||
| 896 | let event = Event { | ||
| 897 | timestamp: now(), | ||
| 898 | author: alice(), | ||
| 899 | action: Action::PatchCreate { | ||
| 900 | title: "OID-based patch".to_string(), | ||
| 901 | body: "".to_string(), | ||
| 902 | base_ref: "main".to_string(), | ||
| 903 | branch: tip.to_string(), | ||
| 904 | fixes: None, | ||
| 905 | }, | ||
| 906 | clock: 0, | ||
| 907 | }; | ||
| 908 | let oid = dag::create_root_event(&repo, &event, &sk).unwrap(); | ||
| 909 | let id = oid.to_string(); | ||
| 910 | let ref_name = format!("refs/collab/patches/{}", id); | ||
| 911 | repo.reference(&ref_name, oid, false, "test").unwrap(); | ||
| 912 | |||
| 913 | let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 914 | let resolved = state.resolve_head(&repo).unwrap(); | ||
| 915 | assert_eq!(resolved, tip); | ||
| 916 | } | ||
| 917 | |||
| 918 | // --------------------------------------------------------------------------- | ||
| 834 | // Phase 5: US3 — Merge (T024-T025) | 919 | // Phase 5: US3 — Merge (T024-T025) |
| 835 | // --------------------------------------------------------------------------- | 920 | // --------------------------------------------------------------------------- |
| 836 | 921 | ||
| @@ -913,3 +998,501 @@ fn test_branch_push_auto_reflects_in_patch() { | |||
| 913 | assert_eq!(head2, new_tip, "head should be the new tip"); | 998 | assert_eq!(head2, new_tip, "head should be the new tip"); |
| 914 | } | 999 | } |
| 915 | 1000 | ||
| 1001 | // --------------------------------------------------------------------------- | ||
| 1002 | // Log command tests | ||
| 1003 | // --------------------------------------------------------------------------- | ||
| 1004 | |||
| 1005 | use git_collab::issue; | ||
| 1006 | use git_collab::log; | ||
| 1007 | |||
| 1008 | #[test] | ||
| 1009 | fn test_log_collects_events_from_all_refs() { | ||
| 1010 | let tmp = TempDir::new().unwrap(); | ||
| 1011 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1012 | |||
| 1013 | // Create an issue and a patch | ||
| 1014 | let (ref1, _) = open_issue(&repo, &alice(), "Log test issue"); | ||
| 1015 | add_comment(&repo, &ref1, &bob(), "A comment"); | ||
| 1016 | |||
| 1017 | let (ref2, _) = create_patch(&repo, &alice(), "Log test patch"); | ||
| 1018 | add_review(&repo, &ref2, &bob(), ReviewVerdict::Approve); | ||
| 1019 | |||
| 1020 | let entries = log::collect_events(&repo, None).unwrap(); | ||
| 1021 | // 2 issue events + 2 patch events = 4 | ||
| 1022 | assert_eq!(entries.len(), 4); | ||
| 1023 | } | ||
| 1024 | |||
| 1025 | #[test] | ||
| 1026 | fn test_log_entries_sorted_by_timestamp() { | ||
| 1027 | let tmp = TempDir::new().unwrap(); | ||
| 1028 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1029 | |||
| 1030 | let (ref1, _) = open_issue(&repo, &alice(), "First"); | ||
| 1031 | add_comment(&repo, &ref1, &bob(), "Second"); | ||
| 1032 | |||
| 1033 | let entries = log::collect_events(&repo, None).unwrap(); | ||
| 1034 | assert!(entries.len() >= 2); | ||
| 1035 | // Timestamps should be non-decreasing | ||
| 1036 | for w in entries.windows(2) { | ||
| 1037 | assert!(w[0].timestamp <= w[1].timestamp); | ||
| 1038 | } | ||
| 1039 | } | ||
| 1040 | |||
| 1041 | #[test] | ||
| 1042 | fn test_log_limit() { | ||
| 1043 | let tmp = TempDir::new().unwrap(); | ||
| 1044 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1045 | |||
| 1046 | let (ref1, _) = open_issue(&repo, &alice(), "Limit test"); | ||
| 1047 | add_comment(&repo, &ref1, &alice(), "c1"); | ||
| 1048 | add_comment(&repo, &ref1, &alice(), "c2"); | ||
| 1049 | add_comment(&repo, &ref1, &alice(), "c3"); | ||
| 1050 | |||
| 1051 | let entries = log::collect_events(&repo, Some(2)).unwrap(); | ||
| 1052 | assert_eq!(entries.len(), 2); | ||
| 1053 | } | ||
| 1054 | |||
| 1055 | #[test] | ||
| 1056 | fn test_log_entry_fields() { | ||
| 1057 | let tmp = TempDir::new().unwrap(); | ||
| 1058 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1059 | |||
| 1060 | let (_, _) = open_issue(&repo, &alice(), "Field test"); | ||
| 1061 | |||
| 1062 | let entries = log::collect_events(&repo, None).unwrap(); | ||
| 1063 | assert_eq!(entries.len(), 1); | ||
| 1064 | |||
| 1065 | let entry = &entries[0]; | ||
| 1066 | assert_eq!(entry.entity_kind, "issue"); | ||
| 1067 | assert_eq!(entry.event_type, "IssueOpen"); | ||
| 1068 | assert_eq!(entry.author.name, "Alice"); | ||
| 1069 | assert_eq!(entry.summary, "open \"Field test\""); | ||
| 1070 | assert!(!entry.entity_id.is_empty()); | ||
| 1071 | assert!(!entry.timestamp.is_empty()); | ||
| 1072 | } | ||
| 1073 | |||
| 1074 | #[test] | ||
| 1075 | fn test_log_empty_repo() { | ||
| 1076 | let tmp = TempDir::new().unwrap(); | ||
| 1077 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1078 | |||
| 1079 | let entries = log::collect_events(&repo, None).unwrap(); | ||
| 1080 | assert!(entries.is_empty()); | ||
| 1081 | } | ||
| 1082 | |||
| 1083 | #[test] | ||
| 1084 | fn test_log_format_output() { | ||
| 1085 | let tmp = TempDir::new().unwrap(); | ||
| 1086 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1087 | |||
| 1088 | let (_, _) = open_issue(&repo, &alice(), "Format test"); | ||
| 1089 | |||
| 1090 | let entries = log::collect_events(&repo, None).unwrap(); | ||
| 1091 | let output = log::format_log(&entries); | ||
| 1092 | assert!(output.contains("issue")); | ||
| 1093 | assert!(output.contains("Alice")); | ||
| 1094 | assert!(output.contains("IssueOpen")); | ||
| 1095 | } | ||
| 1096 | |||
| 1097 | // --------------------------------------------------------------------------- | ||
| 1098 | // Pagination tests | ||
| 1099 | // --------------------------------------------------------------------------- | ||
| 1100 | |||
| 1101 | /// Helper: capture output from issue::list_to_writer | ||
| 1102 | fn capture_issue_list( | ||
| 1103 | repo: &git2::Repository, | ||
| 1104 | show_closed: bool, | ||
| 1105 | limit: Option<usize>, | ||
| 1106 | offset: Option<usize>, | ||
| 1107 | ) -> String { | ||
| 1108 | let mut buf = Vec::new(); | ||
| 1109 | issue::list_to_writer(repo, show_closed, limit, offset, &mut buf).unwrap(); | ||
| 1110 | String::from_utf8(buf).unwrap() | ||
| 1111 | } | ||
| 1112 | |||
| 1113 | /// Helper: capture output from patch::list_to_writer | ||
| 1114 | fn capture_patch_list( | ||
| 1115 | repo: &git2::Repository, | ||
| 1116 | show_closed: bool, | ||
| 1117 | limit: Option<usize>, | ||
| 1118 | offset: Option<usize>, | ||
| 1119 | ) -> String { | ||
| 1120 | let mut buf = Vec::new(); | ||
| 1121 | patch::list_to_writer(repo, show_closed, limit, offset, &mut buf).unwrap(); | ||
| 1122 | String::from_utf8(buf).unwrap() | ||
| 1123 | } | ||
| 1124 | |||
| 1125 | #[test] | ||
| 1126 | fn test_issue_list_limit() { | ||
| 1127 | let tmp = TempDir::new().unwrap(); | ||
| 1128 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1129 | |||
| 1130 | open_issue(&repo, &alice(), "Issue A"); | ||
| 1131 | open_issue(&repo, &alice(), "Issue B"); | ||
| 1132 | open_issue(&repo, &alice(), "Issue C"); | ||
| 1133 | |||
| 1134 | let all = state::list_issues(&repo).unwrap(); | ||
| 1135 | assert_eq!(all.len(), 3); | ||
| 1136 | |||
| 1137 | let output = capture_issue_list(&repo, false, Some(2), None); | ||
| 1138 | let lines: Vec<_> = output.lines().filter(|l| !l.is_empty()).collect(); | ||
| 1139 | assert_eq!(lines.len(), 2, "limit=2 should show exactly 2 issues"); | ||
| 1140 | } | ||
| 1141 | |||
| 1142 | #[test] | ||
| 1143 | fn test_issue_list_offset() { | ||
| 1144 | let tmp = TempDir::new().unwrap(); | ||
| 1145 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1146 | |||
| 1147 | open_issue(&repo, &alice(), "Issue A"); | ||
| 1148 | open_issue(&repo, &alice(), "Issue B"); | ||
| 1149 | open_issue(&repo, &alice(), "Issue C"); | ||
| 1150 | |||
| 1151 | let output_all = capture_issue_list(&repo, false, None, None); | ||
| 1152 | let all_lines: Vec<_> = output_all.lines().filter(|l| !l.is_empty()).collect(); | ||
| 1153 | |||
| 1154 | let output_offset = capture_issue_list(&repo, false, None, Some(1)); | ||
| 1155 | let offset_lines: Vec<_> = output_offset.lines().filter(|l| !l.is_empty()).collect(); | ||
| 1156 | |||
| 1157 | assert_eq!( | ||
| 1158 | offset_lines.len(), | ||
| 1159 | all_lines.len() - 1, | ||
| 1160 | "offset=1 should skip one issue" | ||
| 1161 | ); | ||
| 1162 | assert_eq!(offset_lines, &all_lines[1..]); | ||
| 1163 | } | ||
| 1164 | |||
| 1165 | #[test] | ||
| 1166 | fn test_issue_list_limit_and_offset() { | ||
| 1167 | let tmp = TempDir::new().unwrap(); | ||
| 1168 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1169 | |||
| 1170 | open_issue(&repo, &alice(), "Issue A"); | ||
| 1171 | open_issue(&repo, &alice(), "Issue B"); | ||
| 1172 | open_issue(&repo, &alice(), "Issue C"); | ||
| 1173 | open_issue(&repo, &alice(), "Issue D"); | ||
| 1174 | |||
| 1175 | let output_all = capture_issue_list(&repo, false, None, None); | ||
| 1176 | let all_lines: Vec<_> = output_all.lines().filter(|l| !l.is_empty()).collect(); | ||
| 1177 | assert_eq!(all_lines.len(), 4); | ||
| 1178 | |||
| 1179 | let output = capture_issue_list(&repo, false, Some(2), Some(1)); | ||
| 1180 | let lines: Vec<_> = output.lines().filter(|l| !l.is_empty()).collect(); | ||
| 1181 | assert_eq!( | ||
| 1182 | lines.len(), | ||
| 1183 | 2, | ||
| 1184 | "limit=2,offset=1 should show exactly 2 issues" | ||
| 1185 | ); | ||
| 1186 | assert_eq!(lines, &all_lines[1..3]); | ||
| 1187 | } | ||
| 1188 | |||
| 1189 | #[test] | ||
| 1190 | fn test_issue_list_offset_beyond_end() { | ||
| 1191 | let tmp = TempDir::new().unwrap(); | ||
| 1192 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1193 | |||
| 1194 | open_issue(&repo, &alice(), "Issue A"); | ||
| 1195 | |||
| 1196 | let output = capture_issue_list(&repo, false, None, Some(100)); | ||
| 1197 | assert!( | ||
| 1198 | output.contains("No issues found."), | ||
| 1199 | "offset beyond end should show 'No issues found.'" | ||
| 1200 | ); | ||
| 1201 | } | ||
| 1202 | |||
| 1203 | #[test] | ||
| 1204 | fn test_patch_list_limit() { | ||
| 1205 | let tmp = TempDir::new().unwrap(); | ||
| 1206 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1207 | |||
| 1208 | create_patch(&repo, &alice(), "Patch A"); | ||
| 1209 | create_patch(&repo, &alice(), "Patch B"); | ||
| 1210 | create_patch(&repo, &alice(), "Patch C"); | ||
| 1211 | |||
| 1212 | let output = capture_patch_list(&repo, false, Some(2), None); | ||
| 1213 | let lines: Vec<_> = output.lines().filter(|l| !l.is_empty()).collect(); | ||
| 1214 | assert_eq!(lines.len(), 2, "limit=2 should show exactly 2 patches"); | ||
| 1215 | } | ||
| 1216 | |||
| 1217 | #[test] | ||
| 1218 | fn test_patch_list_offset() { | ||
| 1219 | let tmp = TempDir::new().unwrap(); | ||
| 1220 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1221 | |||
| 1222 | create_patch(&repo, &alice(), "Patch A"); | ||
| 1223 | create_patch(&repo, &alice(), "Patch B"); | ||
| 1224 | create_patch(&repo, &alice(), "Patch C"); | ||
| 1225 | |||
| 1226 | let output_all = capture_patch_list(&repo, false, None, None); | ||
| 1227 | let all_lines: Vec<_> = output_all.lines().filter(|l| !l.is_empty()).collect(); | ||
| 1228 | |||
| 1229 | let output_offset = capture_patch_list(&repo, false, None, Some(1)); | ||
| 1230 | let offset_lines: Vec<_> = output_offset.lines().filter(|l| !l.is_empty()).collect(); | ||
| 1231 | |||
| 1232 | assert_eq!(offset_lines.len(), all_lines.len() - 1); | ||
| 1233 | assert_eq!(offset_lines, &all_lines[1..]); | ||
| 1234 | } | ||
| 1235 | |||
| 1236 | #[test] | ||
| 1237 | fn test_patch_list_limit_and_offset() { | ||
| 1238 | let tmp = TempDir::new().unwrap(); | ||
| 1239 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1240 | |||
| 1241 | create_patch(&repo, &alice(), "Patch A"); | ||
| 1242 | create_patch(&repo, &alice(), "Patch B"); | ||
| 1243 | create_patch(&repo, &alice(), "Patch C"); | ||
| 1244 | create_patch(&repo, &alice(), "Patch D"); | ||
| 1245 | |||
| 1246 | let output_all = capture_patch_list(&repo, false, None, None); | ||
| 1247 | let all_lines: Vec<_> = output_all.lines().filter(|l| !l.is_empty()).collect(); | ||
| 1248 | |||
| 1249 | let output = capture_patch_list(&repo, false, Some(2), Some(1)); | ||
| 1250 | let lines: Vec<_> = output.lines().filter(|l| !l.is_empty()).collect(); | ||
| 1251 | assert_eq!(lines.len(), 2); | ||
| 1252 | assert_eq!(lines, &all_lines[1..3]); | ||
| 1253 | } | ||
| 1254 | |||
| 1255 | #[test] | ||
| 1256 | fn test_patch_list_offset_beyond_end() { | ||
| 1257 | let tmp = TempDir::new().unwrap(); | ||
| 1258 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1259 | |||
| 1260 | create_patch(&repo, &alice(), "Patch A"); | ||
| 1261 | |||
| 1262 | let output = capture_patch_list(&repo, false, None, Some(100)); | ||
| 1263 | assert!( | ||
| 1264 | output.contains("No patches found."), | ||
| 1265 | "offset beyond end should show 'No patches found.'" | ||
| 1266 | ); | ||
| 1267 | } | ||
| 1268 | |||
| 1269 | // --------------------------------------------------------------------------- | ||
| 1270 | // JSON serialization tests | ||
| 1271 | // --------------------------------------------------------------------------- | ||
| 1272 | |||
| 1273 | #[test] | ||
| 1274 | fn test_issue_state_serializes_to_json() { | ||
| 1275 | let tmp = TempDir::new().unwrap(); | ||
| 1276 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1277 | |||
| 1278 | let (ref_name, id) = open_issue(&repo, &alice(), "JSON test issue"); | ||
| 1279 | add_comment(&repo, &ref_name, &bob(), "A comment for JSON"); | ||
| 1280 | |||
| 1281 | let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 1282 | let json = serde_json::to_value(&state).unwrap(); | ||
| 1283 | |||
| 1284 | assert_eq!(json["title"], "JSON test issue"); | ||
| 1285 | assert_eq!(json["status"], "open"); | ||
| 1286 | assert_eq!(json["author"]["name"], "Alice"); | ||
| 1287 | assert_eq!(json["comments"].as_array().unwrap().len(), 1); | ||
| 1288 | assert_eq!(json["comments"][0]["author"]["name"], "Bob"); | ||
| 1289 | assert_eq!(json["comments"][0]["body"], "A comment for JSON"); | ||
| 1290 | // commit_id should be serialized as a hex string | ||
| 1291 | assert!(json["comments"][0]["commit_id"].is_string()); | ||
| 1292 | } | ||
| 1293 | |||
| 1294 | #[test] | ||
| 1295 | fn test_issue_state_closed_serializes() { | ||
| 1296 | let tmp = TempDir::new().unwrap(); | ||
| 1297 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1298 | |||
| 1299 | let (ref_name, id) = open_issue(&repo, &alice(), "Closed JSON"); | ||
| 1300 | close_issue(&repo, &ref_name, &alice()); | ||
| 1301 | |||
| 1302 | let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 1303 | let json = serde_json::to_value(&state).unwrap(); | ||
| 1304 | assert_eq!(json["status"], "closed"); | ||
| 1305 | } | ||
| 1306 | |||
| 1307 | #[test] | ||
| 1308 | fn test_patch_state_serializes_to_json() { | ||
| 1309 | let tmp = TempDir::new().unwrap(); | ||
| 1310 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1311 | |||
| 1312 | let (ref_name, id) = create_patch(&repo, &alice(), "JSON patch test"); | ||
| 1313 | add_review(&repo, &ref_name, &bob(), ReviewVerdict::Approve); | ||
| 1314 | |||
| 1315 | let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 1316 | let json = serde_json::to_value(&state).unwrap(); | ||
| 1317 | |||
| 1318 | assert_eq!(json["title"], "JSON patch test"); | ||
| 1319 | assert_eq!(json["status"], "open"); | ||
| 1320 | assert_eq!(json["base_ref"], "main"); | ||
| 1321 | assert_eq!(json["branch"], "test-branch"); | ||
| 1322 | assert_eq!(json["reviews"].as_array().unwrap().len(), 1); | ||
| 1323 | assert_eq!(json["reviews"][0]["verdict"], "approve"); | ||
| 1324 | } | ||
| 1325 | |||
| 1326 | #[test] | ||
| 1327 | fn test_issue_list_json_output() { | ||
| 1328 | let tmp = TempDir::new().unwrap(); | ||
| 1329 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1330 | |||
| 1331 | open_issue(&repo, &alice(), "Issue one"); | ||
| 1332 | open_issue(&repo, &bob(), "Issue two"); | ||
| 1333 | |||
| 1334 | let json_str = git_collab::issue::list_json(&repo, false).unwrap(); | ||
| 1335 | let value: serde_json::Value = serde_json::from_str(&json_str).unwrap(); | ||
| 1336 | let arr = value.as_array().unwrap(); | ||
| 1337 | assert_eq!(arr.len(), 2); | ||
| 1338 | |||
| 1339 | let titles: Vec<&str> = arr.iter().map(|v| v["title"].as_str().unwrap()).collect(); | ||
| 1340 | assert!(titles.contains(&"Issue one")); | ||
| 1341 | assert!(titles.contains(&"Issue two")); | ||
| 1342 | } | ||
| 1343 | |||
| 1344 | #[test] | ||
| 1345 | fn test_issue_list_json_filters_closed() { | ||
| 1346 | let tmp = TempDir::new().unwrap(); | ||
| 1347 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1348 | |||
| 1349 | open_issue(&repo, &alice(), "Open issue"); | ||
| 1350 | let (ref2, _) = open_issue(&repo, &alice(), "Closed issue"); | ||
| 1351 | close_issue(&repo, &ref2, &alice()); | ||
| 1352 | |||
| 1353 | // Without --all, only open issues | ||
| 1354 | let json_str = git_collab::issue::list_json(&repo, false).unwrap(); | ||
| 1355 | let value: serde_json::Value = serde_json::from_str(&json_str).unwrap(); | ||
| 1356 | assert_eq!(value.as_array().unwrap().len(), 1); | ||
| 1357 | |||
| 1358 | // With --all, both | ||
| 1359 | let json_str = git_collab::issue::list_json(&repo, true).unwrap(); | ||
| 1360 | let value: serde_json::Value = serde_json::from_str(&json_str).unwrap(); | ||
| 1361 | assert_eq!(value.as_array().unwrap().len(), 2); | ||
| 1362 | } | ||
| 1363 | |||
| 1364 | #[test] | ||
| 1365 | fn test_issue_show_json_output() { | ||
| 1366 | let tmp = TempDir::new().unwrap(); | ||
| 1367 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1368 | |||
| 1369 | let (ref_name, id) = open_issue(&repo, &alice(), "Show JSON test"); | ||
| 1370 | add_comment(&repo, &ref_name, &bob(), "test comment"); | ||
| 1371 | |||
| 1372 | let json_str = git_collab::issue::show_json(&repo, &id[..8]).unwrap(); | ||
| 1373 | let value: serde_json::Value = serde_json::from_str(&json_str).unwrap(); | ||
| 1374 | assert_eq!(value["title"], "Show JSON test"); | ||
| 1375 | assert_eq!(value["comments"].as_array().unwrap().len(), 1); | ||
| 1376 | } | ||
| 1377 | |||
| 1378 | #[test] | ||
| 1379 | fn test_patch_list_json_output() { | ||
| 1380 | let tmp = TempDir::new().unwrap(); | ||
| 1381 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1382 | |||
| 1383 | create_patch(&repo, &alice(), "Patch one"); | ||
| 1384 | create_patch(&repo, &bob(), "Patch two"); | ||
| 1385 | |||
| 1386 | let json_str = git_collab::patch::list_json(&repo, false).unwrap(); | ||
| 1387 | let value: serde_json::Value = serde_json::from_str(&json_str).unwrap(); | ||
| 1388 | let arr = value.as_array().unwrap(); | ||
| 1389 | assert_eq!(arr.len(), 2); | ||
| 1390 | } | ||
| 1391 | |||
| 1392 | #[test] | ||
| 1393 | fn test_patch_show_json_output() { | ||
| 1394 | let tmp = TempDir::new().unwrap(); | ||
| 1395 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1396 | |||
| 1397 | let (ref_name, id) = create_patch(&repo, &alice(), "Show patch JSON"); | ||
| 1398 | add_review(&repo, &ref_name, &bob(), ReviewVerdict::Approve); | ||
| 1399 | |||
| 1400 | let json_str = git_collab::patch::show_json(&repo, &id[..8]).unwrap(); | ||
| 1401 | let value: serde_json::Value = serde_json::from_str(&json_str).unwrap(); | ||
| 1402 | assert_eq!(value["title"], "Show patch JSON"); | ||
| 1403 | assert_eq!(value["reviews"].as_array().unwrap().len(), 1); | ||
| 1404 | } | ||
| 1405 | |||
| 1406 | // --------------------------------------------------------------------------- | ||
| 1407 | // ReconcileOutcome tests | ||
| 1408 | // --------------------------------------------------------------------------- | ||
| 1409 | |||
| 1410 | #[test] | ||
| 1411 | fn test_reconcile_outcome_merge_on_divergent_refs() { | ||
| 1412 | let tmp = TempDir::new().unwrap(); | ||
| 1413 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1414 | let sk = test_signing_key(); | ||
| 1415 | |||
| 1416 | let (ref_name, id) = open_issue(&repo, &alice(), "Divergent"); | ||
| 1417 | |||
| 1418 | // Save root OID, then add a local comment | ||
| 1419 | let root_oid = repo.refname_to_id(&ref_name).unwrap(); | ||
| 1420 | add_comment(&repo, &ref_name, &alice(), "Local comment"); | ||
| 1421 | let local_tip = repo.refname_to_id(&ref_name).unwrap(); | ||
| 1422 | |||
| 1423 | // Create a remote branch from root with a different comment | ||
| 1424 | let remote_ref = format!("refs/collab/sync/origin/issues/{}", id); | ||
| 1425 | repo.reference(&remote_ref, root_oid, true, "remote branch point") | ||
| 1426 | .unwrap(); | ||
| 1427 | add_comment(&repo, &remote_ref, &bob(), "Remote comment"); | ||
| 1428 | |||
| 1429 | // Restore local tip | ||
| 1430 | repo.reference(&ref_name, local_tip, true, "restore local tip") | ||
| 1431 | .unwrap(); | ||
| 1432 | |||
| 1433 | let (_oid, outcome) = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &sk).unwrap(); | ||
| 1434 | assert_eq!(outcome, dag::ReconcileOutcome::Merge); | ||
| 1435 | } | ||
| 1436 | |||
| 1437 | #[test] | ||
| 1438 | fn test_reconcile_outcome_fast_forward() { | ||
| 1439 | let tmp = TempDir::new().unwrap(); | ||
| 1440 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1441 | let sk = test_signing_key(); | ||
| 1442 | |||
| 1443 | let (ref_name, id) = open_issue(&repo, &alice(), "FF test"); | ||
| 1444 | let root_oid = repo.refname_to_id(&ref_name).unwrap(); | ||
| 1445 | |||
| 1446 | // Remote is ahead: add a comment on the remote branch | ||
| 1447 | let remote_ref = format!("refs/collab/sync/origin/issues/{}", id); | ||
| 1448 | repo.reference(&remote_ref, root_oid, true, "remote start") | ||
| 1449 | .unwrap(); | ||
| 1450 | add_comment(&repo, &remote_ref, &bob(), "Remote only"); | ||
| 1451 | let remote_tip = repo.refname_to_id(&remote_ref).unwrap(); | ||
| 1452 | |||
| 1453 | // Local stays at root | ||
| 1454 | repo.reference(&ref_name, root_oid, true, "reset local") | ||
| 1455 | .unwrap(); | ||
| 1456 | |||
| 1457 | let (oid, outcome) = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &sk).unwrap(); | ||
| 1458 | assert_eq!(outcome, dag::ReconcileOutcome::FastForward); | ||
| 1459 | assert_eq!(oid, remote_tip); | ||
| 1460 | } | ||
| 1461 | |||
| 1462 | #[test] | ||
| 1463 | fn test_reconcile_outcome_already_current() { | ||
| 1464 | let tmp = TempDir::new().unwrap(); | ||
| 1465 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1466 | let sk = test_signing_key(); | ||
| 1467 | |||
| 1468 | let (ref_name, id) = open_issue(&repo, &alice(), "Same tip"); | ||
| 1469 | let tip = repo.refname_to_id(&ref_name).unwrap(); | ||
| 1470 | |||
| 1471 | let remote_ref = format!("refs/collab/sync/origin/issues/{}", id); | ||
| 1472 | repo.reference(&remote_ref, tip, true, "same").unwrap(); | ||
| 1473 | |||
| 1474 | let (oid, outcome) = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &sk).unwrap(); | ||
| 1475 | assert_eq!(outcome, dag::ReconcileOutcome::AlreadyCurrent); | ||
| 1476 | assert_eq!(oid, tip); | ||
| 1477 | } | ||
| 1478 | |||
| 1479 | #[test] | ||
| 1480 | fn test_reconcile_outcome_local_ahead() { | ||
| 1481 | let tmp = TempDir::new().unwrap(); | ||
| 1482 | let repo = init_repo(tmp.path(), &alice()); | ||
| 1483 | let sk = test_signing_key(); | ||
| 1484 | |||
| 1485 | let (ref_name, id) = open_issue(&repo, &alice(), "Local ahead"); | ||
| 1486 | let root_oid = repo.refname_to_id(&ref_name).unwrap(); | ||
| 1487 | add_comment(&repo, &ref_name, &alice(), "Local only"); | ||
| 1488 | let local_tip = repo.refname_to_id(&ref_name).unwrap(); | ||
| 1489 | |||
| 1490 | let remote_ref = format!("refs/collab/sync/origin/issues/{}", id); | ||
| 1491 | repo.reference(&remote_ref, root_oid, true, "remote behind") | ||
| 1492 | .unwrap(); | ||
| 1493 | |||
| 1494 | let (oid, outcome) = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &sk).unwrap(); | ||
| 1495 | assert_eq!(outcome, dag::ReconcileOutcome::LocalAhead); | ||
| 1496 | assert_eq!(oid, local_tip); | ||
| 1497 | } | ||
| 1498 | |||
tests/completions_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,48 @@ | |||
| 1 | use std::process::Command; | ||
| 2 | |||
| 3 | /// Verify `completions bash` parses successfully and produces output | ||
| 4 | #[test] | ||
| 5 | fn completions_bash_produces_output() { | ||
| 6 | let output = Command::new(env!("CARGO_BIN_EXE_git-collab")) | ||
| 7 | .args(["completions", "bash"]) | ||
| 8 | .output() | ||
| 9 | .expect("failed to run binary"); | ||
| 10 | assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); | ||
| 11 | let stdout = String::from_utf8_lossy(&output.stdout); | ||
| 12 | assert!(stdout.contains("complete"), "bash completions should contain 'complete': {}", stdout); | ||
| 13 | } | ||
| 14 | |||
| 15 | /// Verify `completions zsh` parses successfully and produces output | ||
| 16 | #[test] | ||
| 17 | fn completions_zsh_produces_output() { | ||
| 18 | let output = Command::new(env!("CARGO_BIN_EXE_git-collab")) | ||
| 19 | .args(["completions", "zsh"]) | ||
| 20 | .output() | ||
| 21 | .expect("failed to run binary"); | ||
| 22 | assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); | ||
| 23 | let stdout = String::from_utf8_lossy(&output.stdout); | ||
| 24 | assert!(!stdout.is_empty(), "zsh completions should not be empty"); | ||
| 25 | } | ||
| 26 | |||
| 27 | /// Verify `completions fish` parses successfully and produces output | ||
| 28 | #[test] | ||
| 29 | fn completions_fish_produces_output() { | ||
| 30 | let output = Command::new(env!("CARGO_BIN_EXE_git-collab")) | ||
| 31 | .args(["completions", "fish"]) | ||
| 32 | .output() | ||
| 33 | .expect("failed to run binary"); | ||
| 34 | assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); | ||
| 35 | let stdout = String::from_utf8_lossy(&output.stdout); | ||
| 36 | assert!(stdout.contains("complete"), "fish completions should contain 'complete': {}", stdout); | ||
| 37 | } | ||
| 38 | |||
| 39 | /// Verify completions work without a git repo (run from /tmp) | ||
| 40 | #[test] | ||
| 41 | fn completions_work_without_git_repo() { | ||
| 42 | let output = Command::new(env!("CARGO_BIN_EXE_git-collab")) | ||
| 43 | .args(["completions", "bash"]) | ||
| 44 | .current_dir(std::env::temp_dir()) | ||
| 45 | .output() | ||
| 46 | .expect("failed to run binary"); | ||
| 47 | assert!(output.status.success(), "completions should work outside a git repo: {}", String::from_utf8_lossy(&output.stderr)); | ||
| 48 | } | ||
tests/crdt_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -204,7 +204,7 @@ fn reconcile_merge_clock_is_max_of_both_branches_plus_one() { | |||
| 204 | dag::append_event(&repo, remote_ref, &comment, &sk).unwrap(); | 204 | dag::append_event(&repo, remote_ref, &comment, &sk).unwrap(); |
| 205 | } | 205 | } |
| 206 | 206 | ||
| 207 | let merge_oid = dag::reconcile(&repo, local_ref, remote_ref, &alice(), &sk).unwrap(); | 207 | let (merge_oid, _) = dag::reconcile(&repo, local_ref, remote_ref, &alice(), &sk).unwrap(); |
| 208 | // Remote max is 5, local max is 3, so merge should be 6 | 208 | // Remote max is 5, local max is 3, so merge should be 6 |
| 209 | assert_eq!(read_event_clock(&repo, merge_oid), 6); | 209 | assert_eq!(read_event_clock(&repo, merge_oid), 6); |
| 210 | } | 210 | } |
tests/status_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,153 @@ | |||
| 1 | mod common; | ||
| 2 | |||
| 3 | use tempfile::TempDir; | ||
| 4 | |||
| 5 | use git_collab::event::ReviewVerdict; | ||
| 6 | use git_collab::status; | ||
| 7 | |||
| 8 | use common::{alice, bob, create_patch, init_repo, open_issue, close_issue, add_review}; | ||
| 9 | |||
| 10 | // Additional helpers not in common/mod.rs | ||
| 11 | |||
| 12 | fn close_patch(repo: &git2::Repository, ref_name: &str, author: &git_collab::event::Author) { | ||
| 13 | let sk = common::test_signing_key(); | ||
| 14 | let event = git_collab::event::Event { | ||
| 15 | timestamp: common::now(), | ||
| 16 | author: author.clone(), | ||
| 17 | action: git_collab::event::Action::PatchClose { reason: None }, | ||
| 18 | clock: 0, | ||
| 19 | }; | ||
| 20 | git_collab::dag::append_event(repo, ref_name, &event, &sk).unwrap(); | ||
| 21 | } | ||
| 22 | |||
| 23 | fn merge_patch(repo: &git2::Repository, ref_name: &str, author: &git_collab::event::Author) { | ||
| 24 | let sk = common::test_signing_key(); | ||
| 25 | let event = git_collab::event::Event { | ||
| 26 | timestamp: common::now(), | ||
| 27 | author: author.clone(), | ||
| 28 | action: git_collab::event::Action::PatchMerge, | ||
| 29 | clock: 0, | ||
| 30 | }; | ||
| 31 | git_collab::dag::append_event(repo, ref_name, &event, &sk).unwrap(); | ||
| 32 | } | ||
| 33 | |||
| 34 | // --------------------------------------------------------------------------- | ||
| 35 | // Tests | ||
| 36 | // --------------------------------------------------------------------------- | ||
| 37 | |||
| 38 | #[test] | ||
| 39 | fn test_status_empty_repo() { | ||
| 40 | let tmp = TempDir::new().unwrap(); | ||
| 41 | let repo = init_repo(tmp.path(), &alice()); | ||
| 42 | |||
| 43 | let status = status::compute(&repo).unwrap(); | ||
| 44 | assert_eq!(status.issues_open, 0); | ||
| 45 | assert_eq!(status.issues_closed, 0); | ||
| 46 | assert_eq!(status.patches_open, 0); | ||
| 47 | assert_eq!(status.patches_closed, 0); | ||
| 48 | assert_eq!(status.patches_merged, 0); | ||
| 49 | assert_eq!(status.patches_approved, 0); | ||
| 50 | assert_eq!(status.patches_changes_requested, 0); | ||
| 51 | assert!(status.recent_items.is_empty()); | ||
| 52 | } | ||
| 53 | |||
| 54 | #[test] | ||
| 55 | fn test_status_counts_issues() { | ||
| 56 | let tmp = TempDir::new().unwrap(); | ||
| 57 | let repo = init_repo(tmp.path(), &alice()); | ||
| 58 | |||
| 59 | // 2 open, 1 closed | ||
| 60 | open_issue(&repo, &alice(), "Open one"); | ||
| 61 | open_issue(&repo, &bob(), "Open two"); | ||
| 62 | let (ref_name, _) = open_issue(&repo, &alice(), "Will close"); | ||
| 63 | close_issue(&repo, &ref_name, &alice()); | ||
| 64 | |||
| 65 | let status = status::compute(&repo).unwrap(); | ||
| 66 | assert_eq!(status.issues_open, 2); | ||
| 67 | assert_eq!(status.issues_closed, 1); | ||
| 68 | } | ||
| 69 | |||
| 70 | #[test] | ||
| 71 | fn test_status_counts_patches_by_state() { | ||
| 72 | let tmp = TempDir::new().unwrap(); | ||
| 73 | let repo = init_repo(tmp.path(), &alice()); | ||
| 74 | |||
| 75 | // 1 open, 1 closed, 1 merged | ||
| 76 | create_patch(&repo, &alice(), "Open patch"); | ||
| 77 | let (ref_name, _) = create_patch(&repo, &alice(), "Closed patch"); | ||
| 78 | close_patch(&repo, &ref_name, &alice()); | ||
| 79 | let (ref_name, _) = create_patch(&repo, &alice(), "Merged patch"); | ||
| 80 | merge_patch(&repo, &ref_name, &alice()); | ||
| 81 | |||
| 82 | let status = status::compute(&repo).unwrap(); | ||
| 83 | assert_eq!(status.patches_open, 1); | ||
| 84 | assert_eq!(status.patches_closed, 1); | ||
| 85 | assert_eq!(status.patches_merged, 1); | ||
| 86 | } | ||
| 87 | |||
| 88 | #[test] | ||
| 89 | fn test_status_review_summary_for_open_patches() { | ||
| 90 | let tmp = TempDir::new().unwrap(); | ||
| 91 | let repo = init_repo(tmp.path(), &alice()); | ||
| 92 | |||
| 93 | // Patch with approval | ||
| 94 | let (ref_name, _) = create_patch(&repo, &alice(), "Approved patch"); | ||
| 95 | add_review(&repo, &ref_name, &bob(), ReviewVerdict::Approve); | ||
| 96 | |||
| 97 | // Patch with changes requested | ||
| 98 | let (ref_name, _) = create_patch(&repo, &alice(), "Needs work"); | ||
| 99 | add_review(&repo, &ref_name, &bob(), ReviewVerdict::RequestChanges); | ||
| 100 | |||
| 101 | // Patch with no reviews (just open) | ||
| 102 | create_patch(&repo, &alice(), "No reviews yet"); | ||
| 103 | |||
| 104 | let status = status::compute(&repo).unwrap(); | ||
| 105 | assert_eq!(status.patches_open, 3); | ||
| 106 | assert_eq!(status.patches_approved, 1); | ||
| 107 | assert_eq!(status.patches_changes_requested, 1); | ||
| 108 | } | ||
| 109 | |||
| 110 | #[test] | ||
| 111 | fn test_status_recent_items() { | ||
| 112 | let tmp = TempDir::new().unwrap(); | ||
| 113 | let repo = init_repo(tmp.path(), &alice()); | ||
| 114 | |||
| 115 | open_issue(&repo, &alice(), "Recent issue"); | ||
| 116 | create_patch(&repo, &alice(), "Recent patch"); | ||
| 117 | |||
| 118 | let status = status::compute(&repo).unwrap(); | ||
| 119 | assert_eq!(status.recent_items.len(), 2); | ||
| 120 | let titles: Vec<&str> = status.recent_items.iter().map(|i| i.title.as_str()).collect(); | ||
| 121 | assert!(titles.contains(&"Recent issue")); | ||
| 122 | assert!(titles.contains(&"Recent patch")); | ||
| 123 | } | ||
| 124 | |||
| 125 | #[test] | ||
| 126 | fn test_status_recent_items_capped_at_10() { | ||
| 127 | let tmp = TempDir::new().unwrap(); | ||
| 128 | let repo = init_repo(tmp.path(), &alice()); | ||
| 129 | |||
| 130 | for i in 0..15 { | ||
| 131 | open_issue(&repo, &alice(), &format!("Issue {}", i)); | ||
| 132 | } | ||
| 133 | |||
| 134 | let status = status::compute(&repo).unwrap(); | ||
| 135 | assert_eq!(status.recent_items.len(), 10); | ||
| 136 | } | ||
| 137 | |||
| 138 | #[test] | ||
| 139 | fn test_status_display_format() { | ||
| 140 | let tmp = TempDir::new().unwrap(); | ||
| 141 | let repo = init_repo(tmp.path(), &alice()); | ||
| 142 | |||
| 143 | open_issue(&repo, &alice(), "Test issue"); | ||
| 144 | create_patch(&repo, &alice(), "Test patch"); | ||
| 145 | |||
| 146 | let status = status::compute(&repo).unwrap(); | ||
| 147 | let output = status.to_string(); | ||
| 148 | |||
| 149 | // Should contain section headers and counts | ||
| 150 | assert!(output.contains("Issues")); | ||
| 151 | assert!(output.contains("Patches")); | ||
| 152 | assert!(output.contains("1 open")); | ||
| 153 | } | ||