a73x

a3bc0526

Add collab log, delete, benchmarks and JSON state tests

a73x   2026-03-21 16:21

Commit message
Add collab log, delete, benchmarks and JSON state tests

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 }