a73x

5920e2cd

Add distributed issues and patch review over git

a73x   2026-03-20 20:22

Commit message
Add distributed issues and patch review over git

.gitignore
Old New
@@ -0,0 +1 @@
1 /target
Cargo.lock
Old New
@@ -0,0 +1,2359 @@
1 # This file is automatically @generated by Cargo.
2 # It is not intended for manual editing.
3 version = 4
4
5 [[package]]
6 name = "aho-corasick"
7 version = "1.1.4"
8 source = "registry+https://github.com/rust-lang/crates.io-index"
9 checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
10 dependencies = [
11 "memchr",
12 ]
13
14 [[package]]
15 name = "allocator-api2"
16 version = "0.2.21"
17 source = "registry+https://github.com/rust-lang/crates.io-index"
18 checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
19
20 [[package]]
21 name = "android_system_properties"
22 version = "0.1.5"
23 source = "registry+https://github.com/rust-lang/crates.io-index"
24 checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
25 dependencies = [
26 "libc",
27 ]
28
29 [[package]]
30 name = "anstream"
31 version = "1.0.0"
32 source = "registry+https://github.com/rust-lang/crates.io-index"
33 checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
34 dependencies = [
35 "anstyle",
36 "anstyle-parse",
37 "anstyle-query",
38 "anstyle-wincon",
39 "colorchoice",
40 "is_terminal_polyfill",
41 "utf8parse",
42 ]
43
44 [[package]]
45 name = "anstyle"
46 version = "1.0.14"
47 source = "registry+https://github.com/rust-lang/crates.io-index"
48 checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
49
50 [[package]]
51 name = "anstyle-parse"
52 version = "1.0.0"
53 source = "registry+https://github.com/rust-lang/crates.io-index"
54 checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
55 dependencies = [
56 "utf8parse",
57 ]
58
59 [[package]]
60 name = "anstyle-query"
61 version = "1.1.5"
62 source = "registry+https://github.com/rust-lang/crates.io-index"
63 checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
64 dependencies = [
65 "windows-sys",
66 ]
67
68 [[package]]
69 name = "anstyle-wincon"
70 version = "3.0.11"
71 source = "registry+https://github.com/rust-lang/crates.io-index"
72 checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
73 dependencies = [
74 "anstyle",
75 "once_cell_polyfill",
76 "windows-sys",
77 ]
78
79 [[package]]
80 name = "anyhow"
81 version = "1.0.102"
82 source = "registry+https://github.com/rust-lang/crates.io-index"
83 checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
84
85 [[package]]
86 name = "atomic"
87 version = "0.6.1"
88 source = "registry+https://github.com/rust-lang/crates.io-index"
89 checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340"
90 dependencies = [
91 "bytemuck",
92 ]
93
94 [[package]]
95 name = "autocfg"
96 version = "1.5.0"
97 source = "registry+https://github.com/rust-lang/crates.io-index"
98 checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
99
100 [[package]]
101 name = "base64"
102 version = "0.22.1"
103 source = "registry+https://github.com/rust-lang/crates.io-index"
104 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
105
106 [[package]]
107 name = "bit-set"
108 version = "0.5.3"
109 source = "registry+https://github.com/rust-lang/crates.io-index"
110 checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
111 dependencies = [
112 "bit-vec",
113 ]
114
115 [[package]]
116 name = "bit-vec"
117 version = "0.6.3"
118 source = "registry+https://github.com/rust-lang/crates.io-index"
119 checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
120
121 [[package]]
122 name = "bitflags"
123 version = "1.3.2"
124 source = "registry+https://github.com/rust-lang/crates.io-index"
125 checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
126
127 [[package]]
128 name = "bitflags"
129 version = "2.11.0"
130 source = "registry+https://github.com/rust-lang/crates.io-index"
131 checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
132
133 [[package]]
134 name = "block-buffer"
135 version = "0.10.4"
136 source = "registry+https://github.com/rust-lang/crates.io-index"
137 checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
138 dependencies = [
139 "generic-array",
140 ]
141
142 [[package]]
143 name = "bumpalo"
144 version = "3.20.2"
145 source = "registry+https://github.com/rust-lang/crates.io-index"
146 checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
147
148 [[package]]
149 name = "bytemuck"
150 version = "1.25.0"
151 source = "registry+https://github.com/rust-lang/crates.io-index"
152 checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
153
154 [[package]]
155 name = "castaway"
156 version = "0.2.4"
157 source = "registry+https://github.com/rust-lang/crates.io-index"
158 checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
159 dependencies = [
160 "rustversion",
161 ]
162
163 [[package]]
164 name = "cc"
165 version = "1.2.57"
166 source = "registry+https://github.com/rust-lang/crates.io-index"
167 checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
168 dependencies = [
169 "find-msvc-tools",
170 "jobserver",
171 "libc",
172 "shlex",
173 ]
174
175 [[package]]
176 name = "cfg-if"
177 version = "1.0.4"
178 source = "registry+https://github.com/rust-lang/crates.io-index"
179 checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
180
181 [[package]]
182 name = "cfg_aliases"
183 version = "0.2.1"
184 source = "registry+https://github.com/rust-lang/crates.io-index"
185 checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
186
187 [[package]]
188 name = "chrono"
189 version = "0.4.44"
190 source = "registry+https://github.com/rust-lang/crates.io-index"
191 checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
192 dependencies = [
193 "iana-time-zone",
194 "js-sys",
195 "num-traits",
196 "serde",
197 "wasm-bindgen",
198 "windows-link",
199 ]
200
201 [[package]]
202 name = "clap"
203 version = "4.6.0"
204 source = "registry+https://github.com/rust-lang/crates.io-index"
205 checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
206 dependencies = [
207 "clap_builder",
208 "clap_derive",
209 ]
210
211 [[package]]
212 name = "clap_builder"
213 version = "4.6.0"
214 source = "registry+https://github.com/rust-lang/crates.io-index"
215 checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
216 dependencies = [
217 "anstream",
218 "anstyle",
219 "clap_lex",
220 "strsim",
221 ]
222
223 [[package]]
224 name = "clap_derive"
225 version = "4.6.0"
226 source = "registry+https://github.com/rust-lang/crates.io-index"
227 checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
228 dependencies = [
229 "heck",
230 "proc-macro2",
231 "quote",
232 "syn 2.0.117",
233 ]
234
235 [[package]]
236 name = "clap_lex"
237 version = "1.1.0"
238 source = "registry+https://github.com/rust-lang/crates.io-index"
239 checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
240
241 [[package]]
242 name = "colorchoice"
243 version = "1.0.5"
244 source = "registry+https://github.com/rust-lang/crates.io-index"
245 checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
246
247 [[package]]
248 name = "compact_str"
249 version = "0.9.0"
250 source = "registry+https://github.com/rust-lang/crates.io-index"
251 checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a"
252 dependencies = [
253 "castaway",
254 "cfg-if",
255 "itoa",
256 "rustversion",
257 "ryu",
258 "static_assertions",
259 ]
260
261 [[package]]
262 name = "convert_case"
263 version = "0.10.0"
264 source = "registry+https://github.com/rust-lang/crates.io-index"
265 checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
266 dependencies = [
267 "unicode-segmentation",
268 ]
269
270 [[package]]
271 name = "core-foundation-sys"
272 version = "0.8.7"
273 source = "registry+https://github.com/rust-lang/crates.io-index"
274 checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
275
276 [[package]]
277 name = "cpufeatures"
278 version = "0.2.17"
279 source = "registry+https://github.com/rust-lang/crates.io-index"
280 checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
281 dependencies = [
282 "libc",
283 ]
284
285 [[package]]
286 name = "crossterm"
287 version = "0.29.0"
288 source = "registry+https://github.com/rust-lang/crates.io-index"
289 checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
290 dependencies = [
291 "bitflags 2.11.0",
292 "crossterm_winapi",
293 "derive_more",
294 "document-features",
295 "mio",
296 "parking_lot",
297 "rustix",
298 "signal-hook",
299 "signal-hook-mio",
300 "winapi",
301 ]
302
303 [[package]]
304 name = "crossterm_winapi"
305 version = "0.9.1"
306 source = "registry+https://github.com/rust-lang/crates.io-index"
307 checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
308 dependencies = [
309 "winapi",
310 ]
311
312 [[package]]
313 name = "crypto-common"
314 version = "0.1.7"
315 source = "registry+https://github.com/rust-lang/crates.io-index"
316 checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
317 dependencies = [
318 "generic-array",
319 "typenum",
320 ]
321
322 [[package]]
323 name = "csscolorparser"
324 version = "0.6.2"
325 source = "registry+https://github.com/rust-lang/crates.io-index"
326 checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf"
327 dependencies = [
328 "lab",
329 "phf",
330 ]
331
332 [[package]]
333 name = "darling"
334 version = "0.23.0"
335 source = "registry+https://github.com/rust-lang/crates.io-index"
336 checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
337 dependencies = [
338 "darling_core",
339 "darling_macro",
340 ]
341
342 [[package]]
343 name = "darling_core"
344 version = "0.23.0"
345 source = "registry+https://github.com/rust-lang/crates.io-index"
346 checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
347 dependencies = [
348 "ident_case",
349 "proc-macro2",
350 "quote",
351 "strsim",
352 "syn 2.0.117",
353 ]
354
355 [[package]]
356 name = "darling_macro"
357 version = "0.23.0"
358 source = "registry+https://github.com/rust-lang/crates.io-index"
359 checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
360 dependencies = [
361 "darling_core",
362 "quote",
363 "syn 2.0.117",
364 ]
365
366 [[package]]
367 name = "deltae"
368 version = "0.3.2"
369 source = "registry+https://github.com/rust-lang/crates.io-index"
370 checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
371
372 [[package]]
373 name = "deranged"
374 version = "0.5.8"
375 source = "registry+https://github.com/rust-lang/crates.io-index"
376 checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
377 dependencies = [
378 "powerfmt",
379 ]
380
381 [[package]]
382 name = "derive_more"
383 version = "2.1.1"
384 source = "registry+https://github.com/rust-lang/crates.io-index"
385 checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
386 dependencies = [
387 "derive_more-impl",
388 ]
389
390 [[package]]
391 name = "derive_more-impl"
392 version = "2.1.1"
393 source = "registry+https://github.com/rust-lang/crates.io-index"
394 checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
395 dependencies = [
396 "convert_case",
397 "proc-macro2",
398 "quote",
399 "rustc_version",
400 "syn 2.0.117",
401 ]
402
403 [[package]]
404 name = "digest"
405 version = "0.10.7"
406 source = "registry+https://github.com/rust-lang/crates.io-index"
407 checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
408 dependencies = [
409 "block-buffer",
410 "crypto-common",
411 ]
412
413 [[package]]
414 name = "displaydoc"
415 version = "0.2.5"
416 source = "registry+https://github.com/rust-lang/crates.io-index"
417 checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
418 dependencies = [
419 "proc-macro2",
420 "quote",
421 "syn 2.0.117",
422 ]
423
424 [[package]]
425 name = "document-features"
426 version = "0.2.12"
427 source = "registry+https://github.com/rust-lang/crates.io-index"
428 checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
429 dependencies = [
430 "litrs",
431 ]
432
433 [[package]]
434 name = "either"
435 version = "1.15.0"
436 source = "registry+https://github.com/rust-lang/crates.io-index"
437 checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
438
439 [[package]]
440 name = "equivalent"
441 version = "1.0.2"
442 source = "registry+https://github.com/rust-lang/crates.io-index"
443 checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
444
445 [[package]]
446 name = "errno"
447 version = "0.3.14"
448 source = "registry+https://github.com/rust-lang/crates.io-index"
449 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
450 dependencies = [
451 "libc",
452 "windows-sys",
453 ]
454
455 [[package]]
456 name = "euclid"
457 version = "0.22.14"
458 source = "registry+https://github.com/rust-lang/crates.io-index"
459 checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
460 dependencies = [
461 "num-traits",
462 ]
463
464 [[package]]
465 name = "fancy-regex"
466 version = "0.11.0"
467 source = "registry+https://github.com/rust-lang/crates.io-index"
468 checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
469 dependencies = [
470 "bit-set",
471 "regex",
472 ]
473
474 [[package]]
475 name = "fastrand"
476 version = "2.3.0"
477 source = "registry+https://github.com/rust-lang/crates.io-index"
478 checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
479
480 [[package]]
481 name = "filedescriptor"
482 version = "0.8.3"
483 source = "registry+https://github.com/rust-lang/crates.io-index"
484 checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
485 dependencies = [
486 "libc",
487 "thiserror 1.0.69",
488 "winapi",
489 ]
490
491 [[package]]
492 name = "find-msvc-tools"
493 version = "0.1.9"
494 source = "registry+https://github.com/rust-lang/crates.io-index"
495 checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
496
497 [[package]]
498 name = "finl_unicode"
499 version = "1.4.0"
500 source = "registry+https://github.com/rust-lang/crates.io-index"
501 checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
502
503 [[package]]
504 name = "fixedbitset"
505 version = "0.4.2"
506 source = "registry+https://github.com/rust-lang/crates.io-index"
507 checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
508
509 [[package]]
510 name = "fnv"
511 version = "1.0.7"
512 source = "registry+https://github.com/rust-lang/crates.io-index"
513 checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
514
515 [[package]]
516 name = "foldhash"
517 version = "0.1.5"
518 source = "registry+https://github.com/rust-lang/crates.io-index"
519 checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
520
521 [[package]]
522 name = "foldhash"
523 version = "0.2.0"
524 source = "registry+https://github.com/rust-lang/crates.io-index"
525 checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
526
527 [[package]]
528 name = "form_urlencoded"
529 version = "1.2.2"
530 source = "registry+https://github.com/rust-lang/crates.io-index"
531 checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
532 dependencies = [
533 "percent-encoding",
534 ]
535
536 [[package]]
537 name = "generic-array"
538 version = "0.14.7"
539 source = "registry+https://github.com/rust-lang/crates.io-index"
540 checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
541 dependencies = [
542 "typenum",
543 "version_check",
544 ]
545
546 [[package]]
547 name = "getrandom"
548 version = "0.3.4"
549 source = "registry+https://github.com/rust-lang/crates.io-index"
550 checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
551 dependencies = [
552 "cfg-if",
553 "libc",
554 "r-efi 5.3.0",
555 "wasip2",
556 ]
557
558 [[package]]
559 name = "getrandom"
560 version = "0.4.2"
561 source = "registry+https://github.com/rust-lang/crates.io-index"
562 checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
563 dependencies = [
564 "cfg-if",
565 "libc",
566 "r-efi 6.0.0",
567 "wasip2",
568 "wasip3",
569 ]
570
571 [[package]]
572 name = "git-collab"
573 version = "0.1.0"
574 dependencies = [
575 "chrono",
576 "clap",
577 "crossterm",
578 "git2",
579 "ratatui",
580 "serde",
581 "serde_json",
582 "tempfile",
583 "thiserror 2.0.18",
584 ]
585
586 [[package]]
587 name = "git2"
588 version = "0.19.0"
589 source = "registry+https://github.com/rust-lang/crates.io-index"
590 checksum = "b903b73e45dc0c6c596f2d37eccece7c1c8bb6e4407b001096387c63d0d93724"
591 dependencies = [
592 "bitflags 2.11.0",
593 "libc",
594 "libgit2-sys",
595 "log",
596 "openssl-probe",
597 "openssl-sys",
598 "url",
599 ]
600
601 [[package]]
602 name = "hashbrown"
603 version = "0.15.5"
604 source = "registry+https://github.com/rust-lang/crates.io-index"
605 checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
606 dependencies = [
607 "foldhash 0.1.5",
608 ]
609
610 [[package]]
611 name = "hashbrown"
612 version = "0.16.1"
613 source = "registry+https://github.com/rust-lang/crates.io-index"
614 checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
615 dependencies = [
616 "allocator-api2",
617 "equivalent",
618 "foldhash 0.2.0",
619 ]
620
621 [[package]]
622 name = "heck"
623 version = "0.5.0"
624 source = "registry+https://github.com/rust-lang/crates.io-index"
625 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
626
627 [[package]]
628 name = "hex"
629 version = "0.4.3"
630 source = "registry+https://github.com/rust-lang/crates.io-index"
631 checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
632
633 [[package]]
634 name = "iana-time-zone"
635 version = "0.1.65"
636 source = "registry+https://github.com/rust-lang/crates.io-index"
637 checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
638 dependencies = [
639 "android_system_properties",
640 "core-foundation-sys",
641 "iana-time-zone-haiku",
642 "js-sys",
643 "log",
644 "wasm-bindgen",
645 "windows-core",
646 ]
647
648 [[package]]
649 name = "iana-time-zone-haiku"
650 version = "0.1.2"
651 source = "registry+https://github.com/rust-lang/crates.io-index"
652 checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
653 dependencies = [
654 "cc",
655 ]
656
657 [[package]]
658 name = "icu_collections"
659 version = "2.1.1"
660 source = "registry+https://github.com/rust-lang/crates.io-index"
661 checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
662 dependencies = [
663 "displaydoc",
664 "potential_utf",
665 "yoke",
666 "zerofrom",
667 "zerovec",
668 ]
669
670 [[package]]
671 name = "icu_locale_core"
672 version = "2.1.1"
673 source = "registry+https://github.com/rust-lang/crates.io-index"
674 checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
675 dependencies = [
676 "displaydoc",
677 "litemap",
678 "tinystr",
679 "writeable",
680 "zerovec",
681 ]
682
683 [[package]]
684 name = "icu_normalizer"
685 version = "2.1.1"
686 source = "registry+https://github.com/rust-lang/crates.io-index"
687 checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
688 dependencies = [
689 "icu_collections",
690 "icu_normalizer_data",
691 "icu_properties",
692 "icu_provider",
693 "smallvec",
694 "zerovec",
695 ]
696
697 [[package]]
698 name = "icu_normalizer_data"
699 version = "2.1.1"
700 source = "registry+https://github.com/rust-lang/crates.io-index"
701 checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
702
703 [[package]]
704 name = "icu_properties"
705 version = "2.1.2"
706 source = "registry+https://github.com/rust-lang/crates.io-index"
707 checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
708 dependencies = [
709 "icu_collections",
710 "icu_locale_core",
711 "icu_properties_data",
712 "icu_provider",
713 "zerotrie",
714 "zerovec",
715 ]
716
717 [[package]]
718 name = "icu_properties_data"
719 version = "2.1.2"
720 source = "registry+https://github.com/rust-lang/crates.io-index"
721 checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
722
723 [[package]]
724 name = "icu_provider"
725 version = "2.1.1"
726 source = "registry+https://github.com/rust-lang/crates.io-index"
727 checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
728 dependencies = [
729 "displaydoc",
730 "icu_locale_core",
731 "writeable",
732 "yoke",
733 "zerofrom",
734 "zerotrie",
735 "zerovec",
736 ]
737
738 [[package]]
739 name = "id-arena"
740 version = "2.3.0"
741 source = "registry+https://github.com/rust-lang/crates.io-index"
742 checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
743
744 [[package]]
745 name = "ident_case"
746 version = "1.0.1"
747 source = "registry+https://github.com/rust-lang/crates.io-index"
748 checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
749
750 [[package]]
751 name = "idna"
752 version = "1.1.0"
753 source = "registry+https://github.com/rust-lang/crates.io-index"
754 checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
755 dependencies = [
756 "idna_adapter",
757 "smallvec",
758 "utf8_iter",
759 ]
760
761 [[package]]
762 name = "idna_adapter"
763 version = "1.2.1"
764 source = "registry+https://github.com/rust-lang/crates.io-index"
765 checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
766 dependencies = [
767 "icu_normalizer",
768 "icu_properties",
769 ]
770
771 [[package]]
772 name = "indexmap"
773 version = "2.13.0"
774 source = "registry+https://github.com/rust-lang/crates.io-index"
775 checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
776 dependencies = [
777 "equivalent",
778 "hashbrown 0.16.1",
779 "serde",
780 "serde_core",
781 ]
782
783 [[package]]
784 name = "indoc"
785 version = "2.0.7"
786 source = "registry+https://github.com/rust-lang/crates.io-index"
787 checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
788 dependencies = [
789 "rustversion",
790 ]
791
792 [[package]]
793 name = "instability"
794 version = "0.3.12"
795 source = "registry+https://github.com/rust-lang/crates.io-index"
796 checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
797 dependencies = [
798 "darling",
799 "indoc",
800 "proc-macro2",
801 "quote",
802 "syn 2.0.117",
803 ]
804
805 [[package]]
806 name = "is_terminal_polyfill"
807 version = "1.70.2"
808 source = "registry+https://github.com/rust-lang/crates.io-index"
809 checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
810
811 [[package]]
812 name = "itertools"
813 version = "0.14.0"
814 source = "registry+https://github.com/rust-lang/crates.io-index"
815 checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
816 dependencies = [
817 "either",
818 ]
819
820 [[package]]
821 name = "itoa"
822 version = "1.0.18"
823 source = "registry+https://github.com/rust-lang/crates.io-index"
824 checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
825
826 [[package]]
827 name = "jobserver"
828 version = "0.1.34"
829 source = "registry+https://github.com/rust-lang/crates.io-index"
830 checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
831 dependencies = [
832 "getrandom 0.3.4",
833 "libc",
834 ]
835
836 [[package]]
837 name = "js-sys"
838 version = "0.3.91"
839 source = "registry+https://github.com/rust-lang/crates.io-index"
840 checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
841 dependencies = [
842 "once_cell",
843 "wasm-bindgen",
844 ]
845
846 [[package]]
847 name = "kasuari"
848 version = "0.4.12"
849 source = "registry+https://github.com/rust-lang/crates.io-index"
850 checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
851 dependencies = [
852 "hashbrown 0.16.1",
853 "portable-atomic",
854 "thiserror 2.0.18",
855 ]
856
857 [[package]]
858 name = "lab"
859 version = "0.11.0"
860 source = "registry+https://github.com/rust-lang/crates.io-index"
861 checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f"
862
863 [[package]]
864 name = "lazy_static"
865 version = "1.5.0"
866 source = "registry+https://github.com/rust-lang/crates.io-index"
867 checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
868
869 [[package]]
870 name = "leb128fmt"
871 version = "0.1.0"
872 source = "registry+https://github.com/rust-lang/crates.io-index"
873 checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
874
875 [[package]]
876 name = "libc"
877 version = "0.2.183"
878 source = "registry+https://github.com/rust-lang/crates.io-index"
879 checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
880
881 [[package]]
882 name = "libgit2-sys"
883 version = "0.17.0+1.8.1"
884 source = "registry+https://github.com/rust-lang/crates.io-index"
885 checksum = "10472326a8a6477c3c20a64547b0059e4b0d086869eee31e6d7da728a8eb7224"
886 dependencies = [
887 "cc",
888 "libc",
889 "libssh2-sys",
890 "libz-sys",
891 "openssl-sys",
892 "pkg-config",
893 ]
894
895 [[package]]
896 name = "libssh2-sys"
897 version = "0.3.1"
898 source = "registry+https://github.com/rust-lang/crates.io-index"
899 checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9"
900 dependencies = [
901 "cc",
902 "libc",
903 "libz-sys",
904 "openssl-sys",
905 "pkg-config",
906 "vcpkg",
907 ]
908
909 [[package]]
910 name = "libz-sys"
911 version = "1.1.25"
912 source = "registry+https://github.com/rust-lang/crates.io-index"
913 checksum = "d52f4c29e2a68ac30c9087e1b772dc9f44a2b66ed44edf2266cf2be9b03dafc1"
914 dependencies = [
915 "cc",
916 "libc",
917 "pkg-config",
918 "vcpkg",
919 ]
920
921 [[package]]
922 name = "line-clipping"
923 version = "0.3.5"
924 source = "registry+https://github.com/rust-lang/crates.io-index"
925 checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a"
926 dependencies = [
927 "bitflags 2.11.0",
928 ]
929
930 [[package]]
931 name = "linux-raw-sys"
932 version = "0.12.1"
933 source = "registry+https://github.com/rust-lang/crates.io-index"
934 checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
935
936 [[package]]
937 name = "litemap"
938 version = "0.8.1"
939 source = "registry+https://github.com/rust-lang/crates.io-index"
940 checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
941
942 [[package]]
943 name = "litrs"
944 version = "1.0.0"
945 source = "registry+https://github.com/rust-lang/crates.io-index"
946 checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
947
948 [[package]]
949 name = "lock_api"
950 version = "0.4.14"
951 source = "registry+https://github.com/rust-lang/crates.io-index"
952 checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
953 dependencies = [
954 "scopeguard",
955 ]
956
957 [[package]]
958 name = "log"
959 version = "0.4.29"
960 source = "registry+https://github.com/rust-lang/crates.io-index"
961 checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
962
963 [[package]]
964 name = "lru"
965 version = "0.16.3"
966 source = "registry+https://github.com/rust-lang/crates.io-index"
967 checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593"
968 dependencies = [
969 "hashbrown 0.16.1",
970 ]
971
972 [[package]]
973 name = "mac_address"
974 version = "1.1.8"
975 source = "registry+https://github.com/rust-lang/crates.io-index"
976 checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
977 dependencies = [
978 "nix",
979 "winapi",
980 ]
981
982 [[package]]
983 name = "memchr"
984 version = "2.8.0"
985 source = "registry+https://github.com/rust-lang/crates.io-index"
986 checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
987
988 [[package]]
989 name = "memmem"
990 version = "0.1.1"
991 source = "registry+https://github.com/rust-lang/crates.io-index"
992 checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15"
993
994 [[package]]
995 name = "memoffset"
996 version = "0.9.1"
997 source = "registry+https://github.com/rust-lang/crates.io-index"
998 checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
999 dependencies = [
1000 "autocfg",
1001 ]
1002
1003 [[package]]
1004 name = "minimal-lexical"
1005 version = "0.2.1"
1006 source = "registry+https://github.com/rust-lang/crates.io-index"
1007 checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
1008
1009 [[package]]
1010 name = "mio"
1011 version = "1.1.1"
1012 source = "registry+https://github.com/rust-lang/crates.io-index"
1013 checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
1014 dependencies = [
1015 "libc",
1016 "log",
1017 "wasi",
1018 "windows-sys",
1019 ]
1020
1021 [[package]]
1022 name = "nix"
1023 version = "0.29.0"
1024 source = "registry+https://github.com/rust-lang/crates.io-index"
1025 checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
1026 dependencies = [
1027 "bitflags 2.11.0",
1028 "cfg-if",
1029 "cfg_aliases",
1030 "libc",
1031 "memoffset",
1032 ]
1033
1034 [[package]]
1035 name = "nom"
1036 version = "7.1.3"
1037 source = "registry+https://github.com/rust-lang/crates.io-index"
1038 checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
1039 dependencies = [
1040 "memchr",
1041 "minimal-lexical",
1042 ]
1043
1044 [[package]]
1045 name = "num-conv"
1046 version = "0.2.0"
1047 source = "registry+https://github.com/rust-lang/crates.io-index"
1048 checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
1049
1050 [[package]]
1051 name = "num-derive"
1052 version = "0.4.2"
1053 source = "registry+https://github.com/rust-lang/crates.io-index"
1054 checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
1055 dependencies = [
1056 "proc-macro2",
1057 "quote",
1058 "syn 2.0.117",
1059 ]
1060
1061 [[package]]
1062 name = "num-traits"
1063 version = "0.2.19"
1064 source = "registry+https://github.com/rust-lang/crates.io-index"
1065 checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
1066 dependencies = [
1067 "autocfg",
1068 ]
1069
1070 [[package]]
1071 name = "num_threads"
1072 version = "0.1.7"
1073 source = "registry+https://github.com/rust-lang/crates.io-index"
1074 checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
1075 dependencies = [
1076 "libc",
1077 ]
1078
1079 [[package]]
1080 name = "once_cell"
1081 version = "1.21.4"
1082 source = "registry+https://github.com/rust-lang/crates.io-index"
1083 checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
1084
1085 [[package]]
1086 name = "once_cell_polyfill"
1087 version = "1.70.2"
1088 source = "registry+https://github.com/rust-lang/crates.io-index"
1089 checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
1090
1091 [[package]]
1092 name = "openssl-probe"
1093 version = "0.1.6"
1094 source = "registry+https://github.com/rust-lang/crates.io-index"
1095 checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
1096
1097 [[package]]
1098 name = "openssl-sys"
1099 version = "0.9.112"
1100 source = "registry+https://github.com/rust-lang/crates.io-index"
1101 checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
1102 dependencies = [
1103 "cc",
1104 "libc",
1105 "pkg-config",
1106 "vcpkg",
1107 ]
1108
1109 [[package]]
1110 name = "ordered-float"
1111 version = "4.6.0"
1112 source = "registry+https://github.com/rust-lang/crates.io-index"
1113 checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
1114 dependencies = [
1115 "num-traits",
1116 ]
1117
1118 [[package]]
1119 name = "parking_lot"
1120 version = "0.12.5"
1121 source = "registry+https://github.com/rust-lang/crates.io-index"
1122 checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
1123 dependencies = [
1124 "lock_api",
1125 "parking_lot_core",
1126 ]
1127
1128 [[package]]
1129 name = "parking_lot_core"
1130 version = "0.9.12"
1131 source = "registry+https://github.com/rust-lang/crates.io-index"
1132 checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
1133 dependencies = [
1134 "cfg-if",
1135 "libc",
1136 "redox_syscall",
1137 "smallvec",
1138 "windows-link",
1139 ]
1140
1141 [[package]]
1142 name = "percent-encoding"
1143 version = "2.3.2"
1144 source = "registry+https://github.com/rust-lang/crates.io-index"
1145 checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
1146
1147 [[package]]
1148 name = "pest"
1149 version = "2.8.6"
1150 source = "registry+https://github.com/rust-lang/crates.io-index"
1151 checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662"
1152 dependencies = [
1153 "memchr",
1154 "ucd-trie",
1155 ]
1156
1157 [[package]]
1158 name = "pest_derive"
1159 version = "2.8.6"
1160 source = "registry+https://github.com/rust-lang/crates.io-index"
1161 checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77"
1162 dependencies = [
1163 "pest",
1164 "pest_generator",
1165 ]
1166
1167 [[package]]
1168 name = "pest_generator"
1169 version = "2.8.6"
1170 source = "registry+https://github.com/rust-lang/crates.io-index"
1171 checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f"
1172 dependencies = [
1173 "pest",
1174 "pest_meta",
1175 "proc-macro2",
1176 "quote",
1177 "syn 2.0.117",
1178 ]
1179
1180 [[package]]
1181 name = "pest_meta"
1182 version = "2.8.6"
1183 source = "registry+https://github.com/rust-lang/crates.io-index"
1184 checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
1185 dependencies = [
1186 "pest",
1187 "sha2",
1188 ]
1189
1190 [[package]]
1191 name = "phf"
1192 version = "0.11.3"
1193 source = "registry+https://github.com/rust-lang/crates.io-index"
1194 checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
1195 dependencies = [
1196 "phf_macros",
1197 "phf_shared",
1198 ]
1199
1200 [[package]]
1201 name = "phf_codegen"
1202 version = "0.11.3"
1203 source = "registry+https://github.com/rust-lang/crates.io-index"
1204 checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
1205 dependencies = [
1206 "phf_generator",
1207 "phf_shared",
1208 ]
1209
1210 [[package]]
1211 name = "phf_generator"
1212 version = "0.11.3"
1213 source = "registry+https://github.com/rust-lang/crates.io-index"
1214 checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
1215 dependencies = [
1216 "phf_shared",
1217 "rand",
1218 ]
1219
1220 [[package]]
1221 name = "phf_macros"
1222 version = "0.11.3"
1223 source = "registry+https://github.com/rust-lang/crates.io-index"
1224 checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
1225 dependencies = [
1226 "phf_generator",
1227 "phf_shared",
1228 "proc-macro2",
1229 "quote",
1230 "syn 2.0.117",
1231 ]
1232
1233 [[package]]
1234 name = "phf_shared"
1235 version = "0.11.3"
1236 source = "registry+https://github.com/rust-lang/crates.io-index"
1237 checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
1238 dependencies = [
1239 "siphasher",
1240 ]
1241
1242 [[package]]
1243 name = "pkg-config"
1244 version = "0.3.32"
1245 source = "registry+https://github.com/rust-lang/crates.io-index"
1246 checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
1247
1248 [[package]]
1249 name = "portable-atomic"
1250 version = "1.13.1"
1251 source = "registry+https://github.com/rust-lang/crates.io-index"
1252 checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
1253
1254 [[package]]
1255 name = "potential_utf"
1256 version = "0.1.4"
1257 source = "registry+https://github.com/rust-lang/crates.io-index"
1258 checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
1259 dependencies = [
1260 "zerovec",
1261 ]
1262
1263 [[package]]
1264 name = "powerfmt"
1265 version = "0.2.0"
1266 source = "registry+https://github.com/rust-lang/crates.io-index"
1267 checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
1268
1269 [[package]]
1270 name = "prettyplease"
1271 version = "0.2.37"
1272 source = "registry+https://github.com/rust-lang/crates.io-index"
1273 checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
1274 dependencies = [
1275 "proc-macro2",
1276 "syn 2.0.117",
1277 ]
1278
1279 [[package]]
1280 name = "proc-macro2"
1281 version = "1.0.106"
1282 source = "registry+https://github.com/rust-lang/crates.io-index"
1283 checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
1284 dependencies = [
1285 "unicode-ident",
1286 ]
1287
1288 [[package]]
1289 name = "quote"
1290 version = "1.0.45"
1291 source = "registry+https://github.com/rust-lang/crates.io-index"
1292 checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
1293 dependencies = [
1294 "proc-macro2",
1295 ]
1296
1297 [[package]]
1298 name = "r-efi"
1299 version = "5.3.0"
1300 source = "registry+https://github.com/rust-lang/crates.io-index"
1301 checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
1302
1303 [[package]]
1304 name = "r-efi"
1305 version = "6.0.0"
1306 source = "registry+https://github.com/rust-lang/crates.io-index"
1307 checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1308
1309 [[package]]
1310 name = "rand"
1311 version = "0.8.5"
1312 source = "registry+https://github.com/rust-lang/crates.io-index"
1313 checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
1314 dependencies = [
1315 "rand_core",
1316 ]
1317
1318 [[package]]
1319 name = "rand_core"
1320 version = "0.6.4"
1321 source = "registry+https://github.com/rust-lang/crates.io-index"
1322 checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
1323
1324 [[package]]
1325 name = "ratatui"
1326 version = "0.30.0"
1327 source = "registry+https://github.com/rust-lang/crates.io-index"
1328 checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc"
1329 dependencies = [
1330 "instability",
1331 "ratatui-core",
1332 "ratatui-crossterm",
1333 "ratatui-macros",
1334 "ratatui-termwiz",
1335 "ratatui-widgets",
1336 ]
1337
1338 [[package]]
1339 name = "ratatui-core"
1340 version = "0.1.0"
1341 source = "registry+https://github.com/rust-lang/crates.io-index"
1342 checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293"
1343 dependencies = [
1344 "bitflags 2.11.0",
1345 "compact_str",
1346 "hashbrown 0.16.1",
1347 "indoc",
1348 "itertools",
1349 "kasuari",
1350 "lru",
1351 "strum",
1352 "thiserror 2.0.18",
1353 "unicode-segmentation",
1354 "unicode-truncate",
1355 "unicode-width",
1356 ]
1357
1358 [[package]]
1359 name = "ratatui-crossterm"
1360 version = "0.1.0"
1361 source = "registry+https://github.com/rust-lang/crates.io-index"
1362 checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3"
1363 dependencies = [
1364 "cfg-if",
1365 "crossterm",
1366 "instability",
1367 "ratatui-core",
1368 ]
1369
1370 [[package]]
1371 name = "ratatui-macros"
1372 version = "0.7.0"
1373 source = "registry+https://github.com/rust-lang/crates.io-index"
1374 checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4"
1375 dependencies = [
1376 "ratatui-core",
1377 "ratatui-widgets",
1378 ]
1379
1380 [[package]]
1381 name = "ratatui-termwiz"
1382 version = "0.1.0"
1383 source = "registry+https://github.com/rust-lang/crates.io-index"
1384 checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c"
1385 dependencies = [
1386 "ratatui-core",
1387 "termwiz",
1388 ]
1389
1390 [[package]]
1391 name = "ratatui-widgets"
1392 version = "0.3.0"
1393 source = "registry+https://github.com/rust-lang/crates.io-index"
1394 checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db"
1395 dependencies = [
1396 "bitflags 2.11.0",
1397 "hashbrown 0.16.1",
1398 "indoc",
1399 "instability",
1400 "itertools",
1401 "line-clipping",
1402 "ratatui-core",
1403 "strum",
1404 "time",
1405 "unicode-segmentation",
1406 "unicode-width",
1407 ]
1408
1409 [[package]]
1410 name = "redox_syscall"
1411 version = "0.5.18"
1412 source = "registry+https://github.com/rust-lang/crates.io-index"
1413 checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
1414 dependencies = [
1415 "bitflags 2.11.0",
1416 ]
1417
1418 [[package]]
1419 name = "regex"
1420 version = "1.12.3"
1421 source = "registry+https://github.com/rust-lang/crates.io-index"
1422 checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
1423 dependencies = [
1424 "aho-corasick",
1425 "memchr",
1426 "regex-automata",
1427 "regex-syntax",
1428 ]
1429
1430 [[package]]
1431 name = "regex-automata"
1432 version = "0.4.14"
1433 source = "registry+https://github.com/rust-lang/crates.io-index"
1434 checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
1435 dependencies = [
1436 "aho-corasick",
1437 "memchr",
1438 "regex-syntax",
1439 ]
1440
1441 [[package]]
1442 name = "regex-syntax"
1443 version = "0.8.10"
1444 source = "registry+https://github.com/rust-lang/crates.io-index"
1445 checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
1446
1447 [[package]]
1448 name = "rustc_version"
1449 version = "0.4.1"
1450 source = "registry+https://github.com/rust-lang/crates.io-index"
1451 checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
1452 dependencies = [
1453 "semver",
1454 ]
1455
1456 [[package]]
1457 name = "rustix"
1458 version = "1.1.4"
1459 source = "registry+https://github.com/rust-lang/crates.io-index"
1460 checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
1461 dependencies = [
1462 "bitflags 2.11.0",
1463 "errno",
1464 "libc",
1465 "linux-raw-sys",
1466 "windows-sys",
1467 ]
1468
1469 [[package]]
1470 name = "rustversion"
1471 version = "1.0.22"
1472 source = "registry+https://github.com/rust-lang/crates.io-index"
1473 checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
1474
1475 [[package]]
1476 name = "ryu"
1477 version = "1.0.23"
1478 source = "registry+https://github.com/rust-lang/crates.io-index"
1479 checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
1480
1481 [[package]]
1482 name = "scopeguard"
1483 version = "1.2.0"
1484 source = "registry+https://github.com/rust-lang/crates.io-index"
1485 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
1486
1487 [[package]]
1488 name = "semver"
1489 version = "1.0.27"
1490 source = "registry+https://github.com/rust-lang/crates.io-index"
1491 checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
1492
1493 [[package]]
1494 name = "serde"
1495 version = "1.0.228"
1496 source = "registry+https://github.com/rust-lang/crates.io-index"
1497 checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
1498 dependencies = [
1499 "serde_core",
1500 "serde_derive",
1501 ]
1502
1503 [[package]]
1504 name = "serde_core"
1505 version = "1.0.228"
1506 source = "registry+https://github.com/rust-lang/crates.io-index"
1507 checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
1508 dependencies = [
1509 "serde_derive",
1510 ]
1511
1512 [[package]]
1513 name = "serde_derive"
1514 version = "1.0.228"
1515 source = "registry+https://github.com/rust-lang/crates.io-index"
1516 checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
1517 dependencies = [
1518 "proc-macro2",
1519 "quote",
1520 "syn 2.0.117",
1521 ]
1522
1523 [[package]]
1524 name = "serde_json"
1525 version = "1.0.149"
1526 source = "registry+https://github.com/rust-lang/crates.io-index"
1527 checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
1528 dependencies = [
1529 "itoa",
1530 "memchr",
1531 "serde",
1532 "serde_core",
1533 "zmij",
1534 ]
1535
1536 [[package]]
1537 name = "sha2"
1538 version = "0.10.9"
1539 source = "registry+https://github.com/rust-lang/crates.io-index"
1540 checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
1541 dependencies = [
1542 "cfg-if",
1543 "cpufeatures",
1544 "digest",
1545 ]
1546
1547 [[package]]
1548 name = "shlex"
1549 version = "1.3.0"
1550 source = "registry+https://github.com/rust-lang/crates.io-index"
1551 checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
1552
1553 [[package]]
1554 name = "signal-hook"
1555 version = "0.3.18"
1556 source = "registry+https://github.com/rust-lang/crates.io-index"
1557 checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
1558 dependencies = [
1559 "libc",
1560 "signal-hook-registry",
1561 ]
1562
1563 [[package]]
1564 name = "signal-hook-mio"
1565 version = "0.2.5"
1566 source = "registry+https://github.com/rust-lang/crates.io-index"
1567 checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
1568 dependencies = [
1569 "libc",
1570 "mio",
1571 "signal-hook",
1572 ]
1573
1574 [[package]]
1575 name = "signal-hook-registry"
1576 version = "1.4.8"
1577 source = "registry+https://github.com/rust-lang/crates.io-index"
1578 checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
1579 dependencies = [
1580 "errno",
1581 "libc",
1582 ]
1583
1584 [[package]]
1585 name = "siphasher"
1586 version = "1.0.2"
1587 source = "registry+https://github.com/rust-lang/crates.io-index"
1588 checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
1589
1590 [[package]]
1591 name = "smallvec"
1592 version = "1.15.1"
1593 source = "registry+https://github.com/rust-lang/crates.io-index"
1594 checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
1595
1596 [[package]]
1597 name = "stable_deref_trait"
1598 version = "1.2.1"
1599 source = "registry+https://github.com/rust-lang/crates.io-index"
1600 checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
1601
1602 [[package]]
1603 name = "static_assertions"
1604 version = "1.1.0"
1605 source = "registry+https://github.com/rust-lang/crates.io-index"
1606 checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
1607
1608 [[package]]
1609 name = "strsim"
1610 version = "0.11.1"
1611 source = "registry+https://github.com/rust-lang/crates.io-index"
1612 checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
1613
1614 [[package]]
1615 name = "strum"
1616 version = "0.27.2"
1617 source = "registry+https://github.com/rust-lang/crates.io-index"
1618 checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
1619 dependencies = [
1620 "strum_macros",
1621 ]
1622
1623 [[package]]
1624 name = "strum_macros"
1625 version = "0.27.2"
1626 source = "registry+https://github.com/rust-lang/crates.io-index"
1627 checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
1628 dependencies = [
1629 "heck",
1630 "proc-macro2",
1631 "quote",
1632 "syn 2.0.117",
1633 ]
1634
1635 [[package]]
1636 name = "syn"
1637 version = "1.0.109"
1638 source = "registry+https://github.com/rust-lang/crates.io-index"
1639 checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
1640 dependencies = [
1641 "proc-macro2",
1642 "quote",
1643 "unicode-ident",
1644 ]
1645
1646 [[package]]
1647 name = "syn"
1648 version = "2.0.117"
1649 source = "registry+https://github.com/rust-lang/crates.io-index"
1650 checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
1651 dependencies = [
1652 "proc-macro2",
1653 "quote",
1654 "unicode-ident",
1655 ]
1656
1657 [[package]]
1658 name = "synstructure"
1659 version = "0.13.2"
1660 source = "registry+https://github.com/rust-lang/crates.io-index"
1661 checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
1662 dependencies = [
1663 "proc-macro2",
1664 "quote",
1665 "syn 2.0.117",
1666 ]
1667
1668 [[package]]
1669 name = "tempfile"
1670 version = "3.27.0"
1671 source = "registry+https://github.com/rust-lang/crates.io-index"
1672 checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
1673 dependencies = [
1674 "fastrand",
1675 "getrandom 0.4.2",
1676 "once_cell",
1677 "rustix",
1678 "windows-sys",
1679 ]
1680
1681 [[package]]
1682 name = "terminfo"
1683 version = "0.9.0"
1684 source = "registry+https://github.com/rust-lang/crates.io-index"
1685 checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662"
1686 dependencies = [
1687 "fnv",
1688 "nom",
1689 "phf",
1690 "phf_codegen",
1691 ]
1692
1693 [[package]]
1694 name = "termios"
1695 version = "0.3.3"
1696 source = "registry+https://github.com/rust-lang/crates.io-index"
1697 checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b"
1698 dependencies = [
1699 "libc",
1700 ]
1701
1702 [[package]]
1703 name = "termwiz"
1704 version = "0.23.3"
1705 source = "registry+https://github.com/rust-lang/crates.io-index"
1706 checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
1707 dependencies = [
1708 "anyhow",
1709 "base64",
1710 "bitflags 2.11.0",
1711 "fancy-regex",
1712 "filedescriptor",
1713 "finl_unicode",
1714 "fixedbitset",
1715 "hex",
1716 "lazy_static",
1717 "libc",
1718 "log",
1719 "memmem",
1720 "nix",
1721 "num-derive",
1722 "num-traits",
1723 "ordered-float",
1724 "pest",
1725 "pest_derive",
1726 "phf",
1727 "sha2",
1728 "signal-hook",
1729 "siphasher",
1730 "terminfo",
1731 "termios",
1732 "thiserror 1.0.69",
1733 "ucd-trie",
1734 "unicode-segmentation",
1735 "vtparse",
1736 "wezterm-bidi",
1737 "wezterm-blob-leases",
1738 "wezterm-color-types",
1739 "wezterm-dynamic",
1740 "wezterm-input-types",
1741 "winapi",
1742 ]
1743
1744 [[package]]
1745 name = "thiserror"
1746 version = "1.0.69"
1747 source = "registry+https://github.com/rust-lang/crates.io-index"
1748 checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
1749 dependencies = [
1750 "thiserror-impl 1.0.69",
1751 ]
1752
1753 [[package]]
1754 name = "thiserror"
1755 version = "2.0.18"
1756 source = "registry+https://github.com/rust-lang/crates.io-index"
1757 checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
1758 dependencies = [
1759 "thiserror-impl 2.0.18",
1760 ]
1761
1762 [[package]]
1763 name = "thiserror-impl"
1764 version = "1.0.69"
1765 source = "registry+https://github.com/rust-lang/crates.io-index"
1766 checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
1767 dependencies = [
1768 "proc-macro2",
1769 "quote",
1770 "syn 2.0.117",
1771 ]
1772
1773 [[package]]
1774 name = "thiserror-impl"
1775 version = "2.0.18"
1776 source = "registry+https://github.com/rust-lang/crates.io-index"
1777 checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
1778 dependencies = [
1779 "proc-macro2",
1780 "quote",
1781 "syn 2.0.117",
1782 ]
1783
1784 [[package]]
1785 name = "time"
1786 version = "0.3.47"
1787 source = "registry+https://github.com/rust-lang/crates.io-index"
1788 checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
1789 dependencies = [
1790 "deranged",
1791 "libc",
1792 "num-conv",
1793 "num_threads",
1794 "powerfmt",
1795 "serde_core",
1796 "time-core",
1797 ]
1798
1799 [[package]]
1800 name = "time-core"
1801 version = "0.1.8"
1802 source = "registry+https://github.com/rust-lang/crates.io-index"
1803 checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
1804
1805 [[package]]
1806 name = "tinystr"
1807 version = "0.8.2"
1808 source = "registry+https://github.com/rust-lang/crates.io-index"
1809 checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
1810 dependencies = [
1811 "displaydoc",
1812 "zerovec",
1813 ]
1814
1815 [[package]]
1816 name = "typenum"
1817 version = "1.19.0"
1818 source = "registry+https://github.com/rust-lang/crates.io-index"
1819 checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
1820
1821 [[package]]
1822 name = "ucd-trie"
1823 version = "0.1.7"
1824 source = "registry+https://github.com/rust-lang/crates.io-index"
1825 checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
1826
1827 [[package]]
1828 name = "unicode-ident"
1829 version = "1.0.24"
1830 source = "registry+https://github.com/rust-lang/crates.io-index"
1831 checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
1832
1833 [[package]]
1834 name = "unicode-segmentation"
1835 version = "1.12.0"
1836 source = "registry+https://github.com/rust-lang/crates.io-index"
1837 checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
1838
1839 [[package]]
1840 name = "unicode-truncate"
1841 version = "2.0.1"
1842 source = "registry+https://github.com/rust-lang/crates.io-index"
1843 checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
1844 dependencies = [
1845 "itertools",
1846 "unicode-segmentation",
1847 "unicode-width",
1848 ]
1849
1850 [[package]]
1851 name = "unicode-width"
1852 version = "0.2.2"
1853 source = "registry+https://github.com/rust-lang/crates.io-index"
1854 checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
1855
1856 [[package]]
1857 name = "unicode-xid"
1858 version = "0.2.6"
1859 source = "registry+https://github.com/rust-lang/crates.io-index"
1860 checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
1861
1862 [[package]]
1863 name = "url"
1864 version = "2.5.8"
1865 source = "registry+https://github.com/rust-lang/crates.io-index"
1866 checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
1867 dependencies = [
1868 "form_urlencoded",
1869 "idna",
1870 "percent-encoding",
1871 "serde",
1872 ]
1873
1874 [[package]]
1875 name = "utf8_iter"
1876 version = "1.0.4"
1877 source = "registry+https://github.com/rust-lang/crates.io-index"
1878 checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
1879
1880 [[package]]
1881 name = "utf8parse"
1882 version = "0.2.2"
1883 source = "registry+https://github.com/rust-lang/crates.io-index"
1884 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
1885
1886 [[package]]
1887 name = "uuid"
1888 version = "1.22.0"
1889 source = "registry+https://github.com/rust-lang/crates.io-index"
1890 checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
1891 dependencies = [
1892 "atomic",
1893 "getrandom 0.4.2",
1894 "js-sys",
1895 "wasm-bindgen",
1896 ]
1897
1898 [[package]]
1899 name = "vcpkg"
1900 version = "0.2.15"
1901 source = "registry+https://github.com/rust-lang/crates.io-index"
1902 checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
1903
1904 [[package]]
1905 name = "version_check"
1906 version = "0.9.5"
1907 source = "registry+https://github.com/rust-lang/crates.io-index"
1908 checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1909
1910 [[package]]
1911 name = "vtparse"
1912 version = "0.6.2"
1913 source = "registry+https://github.com/rust-lang/crates.io-index"
1914 checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0"
1915 dependencies = [
1916 "utf8parse",
1917 ]
1918
1919 [[package]]
1920 name = "wasi"
1921 version = "0.11.1+wasi-snapshot-preview1"
1922 source = "registry+https://github.com/rust-lang/crates.io-index"
1923 checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
1924
1925 [[package]]
1926 name = "wasip2"
1927 version = "1.0.2+wasi-0.2.9"
1928 source = "registry+https://github.com/rust-lang/crates.io-index"
1929 checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
1930 dependencies = [
1931 "wit-bindgen",
1932 ]
1933
1934 [[package]]
1935 name = "wasip3"
1936 version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
1937 source = "registry+https://github.com/rust-lang/crates.io-index"
1938 checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
1939 dependencies = [
1940 "wit-bindgen",
1941 ]
1942
1943 [[package]]
1944 name = "wasm-bindgen"
1945 version = "0.2.114"
1946 source = "registry+https://github.com/rust-lang/crates.io-index"
1947 checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
1948 dependencies = [
1949 "cfg-if",
1950 "once_cell",
1951 "rustversion",
1952 "wasm-bindgen-macro",
1953 "wasm-bindgen-shared",
1954 ]
1955
1956 [[package]]
1957 name = "wasm-bindgen-macro"
1958 version = "0.2.114"
1959 source = "registry+https://github.com/rust-lang/crates.io-index"
1960 checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
1961 dependencies = [
1962 "quote",
1963 "wasm-bindgen-macro-support",
1964 ]
1965
1966 [[package]]
1967 name = "wasm-bindgen-macro-support"
1968 version = "0.2.114"
1969 source = "registry+https://github.com/rust-lang/crates.io-index"
1970 checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
1971 dependencies = [
1972 "bumpalo",
1973 "proc-macro2",
1974 "quote",
1975 "syn 2.0.117",
1976 "wasm-bindgen-shared",
1977 ]
1978
1979 [[package]]
1980 name = "wasm-bindgen-shared"
1981 version = "0.2.114"
1982 source = "registry+https://github.com/rust-lang/crates.io-index"
1983 checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
1984 dependencies = [
1985 "unicode-ident",
1986 ]
1987
1988 [[package]]
1989 name = "wasm-encoder"
1990 version = "0.244.0"
1991 source = "registry+https://github.com/rust-lang/crates.io-index"
1992 checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
1993 dependencies = [
1994 "leb128fmt",
1995 "wasmparser",
1996 ]
1997
1998 [[package]]
1999 name = "wasm-metadata"
2000 version = "0.244.0"
2001 source = "registry+https://github.com/rust-lang/crates.io-index"
2002 checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
2003 dependencies = [
2004 "anyhow",
2005 "indexmap",
2006 "wasm-encoder",
2007 "wasmparser",
2008 ]
2009
2010 [[package]]
2011 name = "wasmparser"
2012 version = "0.244.0"
2013 source = "registry+https://github.com/rust-lang/crates.io-index"
2014 checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
2015 dependencies = [
2016 "bitflags 2.11.0",
2017 "hashbrown 0.15.5",
2018 "indexmap",
2019 "semver",
2020 ]
2021
2022 [[package]]
2023 name = "wezterm-bidi"
2024 version = "0.2.3"
2025 source = "registry+https://github.com/rust-lang/crates.io-index"
2026 checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec"
2027 dependencies = [
2028 "log",
2029 "wezterm-dynamic",
2030 ]
2031
2032 [[package]]
2033 name = "wezterm-blob-leases"
2034 version = "0.1.1"
2035 source = "registry+https://github.com/rust-lang/crates.io-index"
2036 checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7"
2037 dependencies = [
2038 "getrandom 0.3.4",
2039 "mac_address",
2040 "sha2",
2041 "thiserror 1.0.69",
2042 "uuid",
2043 ]
2044
2045 [[package]]
2046 name = "wezterm-color-types"
2047 version = "0.3.0"
2048 source = "registry+https://github.com/rust-lang/crates.io-index"
2049 checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296"
2050 dependencies = [
2051 "csscolorparser",
2052 "deltae",
2053 "lazy_static",
2054 "wezterm-dynamic",
2055 ]
2056
2057 [[package]]
2058 name = "wezterm-dynamic"
2059 version = "0.2.1"
2060 source = "registry+https://github.com/rust-lang/crates.io-index"
2061 checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac"
2062 dependencies = [
2063 "log",
2064 "ordered-float",
2065 "strsim",
2066 "thiserror 1.0.69",
2067 "wezterm-dynamic-derive",
2068 ]
2069
2070 [[package]]
2071 name = "wezterm-dynamic-derive"
2072 version = "0.1.1"
2073 source = "registry+https://github.com/rust-lang/crates.io-index"
2074 checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b"
2075 dependencies = [
2076 "proc-macro2",
2077 "quote",
2078 "syn 1.0.109",
2079 ]
2080
2081 [[package]]
2082 name = "wezterm-input-types"
2083 version = "0.1.0"
2084 source = "registry+https://github.com/rust-lang/crates.io-index"
2085 checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e"
2086 dependencies = [
2087 "bitflags 1.3.2",
2088 "euclid",
2089 "lazy_static",
2090 "serde",
2091 "wezterm-dynamic",
2092 ]
2093
2094 [[package]]
2095 name = "winapi"
2096 version = "0.3.9"
2097 source = "registry+https://github.com/rust-lang/crates.io-index"
2098 checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
2099 dependencies = [
2100 "winapi-i686-pc-windows-gnu",
2101 "winapi-x86_64-pc-windows-gnu",
2102 ]
2103
2104 [[package]]
2105 name = "winapi-i686-pc-windows-gnu"
2106 version = "0.4.0"
2107 source = "registry+https://github.com/rust-lang/crates.io-index"
2108 checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
2109
2110 [[package]]
2111 name = "winapi-x86_64-pc-windows-gnu"
2112 version = "0.4.0"
2113 source = "registry+https://github.com/rust-lang/crates.io-index"
2114 checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
2115
2116 [[package]]
2117 name = "windows-core"
2118 version = "0.62.2"
2119 source = "registry+https://github.com/rust-lang/crates.io-index"
2120 checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
2121 dependencies = [
2122 "windows-implement",
2123 "windows-interface",
2124 "windows-link",
2125 "windows-result",
2126 "windows-strings",
2127 ]
2128
2129 [[package]]
2130 name = "windows-implement"
2131 version = "0.60.2"
2132 source = "registry+https://github.com/rust-lang/crates.io-index"
2133 checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
2134 dependencies = [
2135 "proc-macro2",
2136 "quote",
2137 "syn 2.0.117",
2138 ]
2139
2140 [[package]]
2141 name = "windows-interface"
2142 version = "0.59.3"
2143 source = "registry+https://github.com/rust-lang/crates.io-index"
2144 checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
2145 dependencies = [
2146 "proc-macro2",
2147 "quote",
2148 "syn 2.0.117",
2149 ]
2150
2151 [[package]]
2152 name = "windows-link"
2153 version = "0.2.1"
2154 source = "registry+https://github.com/rust-lang/crates.io-index"
2155 checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
2156
2157 [[package]]
2158 name = "windows-result"
2159 version = "0.4.1"
2160 source = "registry+https://github.com/rust-lang/crates.io-index"
2161 checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
2162 dependencies = [
2163 "windows-link",
2164 ]
2165
2166 [[package]]
2167 name = "windows-strings"
2168 version = "0.5.1"
2169 source = "registry+https://github.com/rust-lang/crates.io-index"
2170 checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
2171 dependencies = [
2172 "windows-link",
2173 ]
2174
2175 [[package]]
2176 name = "windows-sys"
2177 version = "0.61.2"
2178 source = "registry+https://github.com/rust-lang/crates.io-index"
2179 checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
2180 dependencies = [
2181 "windows-link",
2182 ]
2183
2184 [[package]]
2185 name = "wit-bindgen"
2186 version = "0.51.0"
2187 source = "registry+https://github.com/rust-lang/crates.io-index"
2188 checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
2189 dependencies = [
2190 "wit-bindgen-rust-macro",
2191 ]
2192
2193 [[package]]
2194 name = "wit-bindgen-core"
2195 version = "0.51.0"
2196 source = "registry+https://github.com/rust-lang/crates.io-index"
2197 checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
2198 dependencies = [
2199 "anyhow",
2200 "heck",
2201 "wit-parser",
2202 ]
2203
2204 [[package]]
2205 name = "wit-bindgen-rust"
2206 version = "0.51.0"
2207 source = "registry+https://github.com/rust-lang/crates.io-index"
2208 checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
2209 dependencies = [
2210 "anyhow",
2211 "heck",
2212 "indexmap",
2213 "prettyplease",
2214 "syn 2.0.117",
2215 "wasm-metadata",
2216 "wit-bindgen-core",
2217 "wit-component",
2218 ]
2219
2220 [[package]]
2221 name = "wit-bindgen-rust-macro"
2222 version = "0.51.0"
2223 source = "registry+https://github.com/rust-lang/crates.io-index"
2224 checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
2225 dependencies = [
2226 "anyhow",
2227 "prettyplease",
2228 "proc-macro2",
2229 "quote",
2230 "syn 2.0.117",
2231 "wit-bindgen-core",
2232 "wit-bindgen-rust",
2233 ]
2234
2235 [[package]]
2236 name = "wit-component"
2237 version = "0.244.0"
2238 source = "registry+https://github.com/rust-lang/crates.io-index"
2239 checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
2240 dependencies = [
2241 "anyhow",
2242 "bitflags 2.11.0",
2243 "indexmap",
2244 "log",
2245 "serde",
2246 "serde_derive",
2247 "serde_json",
2248 "wasm-encoder",
2249 "wasm-metadata",
2250 "wasmparser",
2251 "wit-parser",
2252 ]
2253
2254 [[package]]
2255 name = "wit-parser"
2256 version = "0.244.0"
2257 source = "registry+https://github.com/rust-lang/crates.io-index"
2258 checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
2259 dependencies = [
2260 "anyhow",
2261 "id-arena",
2262 "indexmap",
2263 "log",
2264 "semver",
2265 "serde",
2266 "serde_derive",
2267 "serde_json",
2268 "unicode-xid",
2269 "wasmparser",
2270 ]
2271
2272 [[package]]
2273 name = "writeable"
2274 version = "0.6.2"
2275 source = "registry+https://github.com/rust-lang/crates.io-index"
2276 checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
2277
2278 [[package]]
2279 name = "yoke"
2280 version = "0.8.1"
2281 source = "registry+https://github.com/rust-lang/crates.io-index"
2282 checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
2283 dependencies = [
2284 "stable_deref_trait",
2285 "yoke-derive",
2286 "zerofrom",
2287 ]
2288
2289 [[package]]
2290 name = "yoke-derive"
2291 version = "0.8.1"
2292 source = "registry+https://github.com/rust-lang/crates.io-index"
2293 checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
2294 dependencies = [
2295 "proc-macro2",
2296 "quote",
2297 "syn 2.0.117",
2298 "synstructure",
2299 ]
2300
2301 [[package]]
2302 name = "zerofrom"
2303 version = "0.1.6"
2304 source = "registry+https://github.com/rust-lang/crates.io-index"
2305 checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
2306 dependencies = [
2307 "zerofrom-derive",
2308 ]
2309
2310 [[package]]
2311 name = "zerofrom-derive"
2312 version = "0.1.6"
2313 source = "registry+https://github.com/rust-lang/crates.io-index"
2314 checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
2315 dependencies = [
2316 "proc-macro2",
2317 "quote",
2318 "syn 2.0.117",
2319 "synstructure",
2320 ]
2321
2322 [[package]]
2323 name = "zerotrie"
2324 version = "0.2.3"
2325 source = "registry+https://github.com/rust-lang/crates.io-index"
2326 checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
2327 dependencies = [
2328 "displaydoc",
2329 "yoke",
2330 "zerofrom",
2331 ]
2332
2333 [[package]]
2334 name = "zerovec"
2335 version = "0.11.5"
2336 source = "registry+https://github.com/rust-lang/crates.io-index"
2337 checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
2338 dependencies = [
2339 "yoke",
2340 "zerofrom",
2341 "zerovec-derive",
2342 ]
2343
2344 [[package]]
2345 name = "zerovec-derive"
2346 version = "0.11.2"
2347 source = "registry+https://github.com/rust-lang/crates.io-index"
2348 checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
2349 dependencies = [
2350 "proc-macro2",
2351 "quote",
2352 "syn 2.0.117",
2353 ]
2354
2355 [[package]]
2356 name = "zmij"
2357 version = "1.0.21"
2358 source = "registry+https://github.com/rust-lang/crates.io-index"
2359 checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
Cargo.toml
Old New
@@ -0,0 +1,17 @@
1 [package]
2 name = "git-collab"
3 version = "0.1.0"
4 edition = "2021"
5
6 [dependencies]
7 git2 = "0.19"
8 clap = { version = "4", features = ["derive"] }
9 serde = { version = "1", features = ["derive"] }
10 serde_json = "1"
11 chrono = { version = "0.4", features = ["serde"] }
12 thiserror = "2"
13 ratatui = "0.30.0"
14 crossterm = "0.29.0"
15
16 [dev-dependencies]
17 tempfile = "3"
Makefile
Old New
@@ -0,0 +1,52 @@
1 CARGO := cargo
2 PROFILE ?= debug
3 BIN := git-collab
4 PREFIX ?= /usr/local
5
6 # --- Building blocks ---
7 .PHONY: fmt check clippy test build install clean
8
9 fmt:
10 $(CARGO) fmt
11
12 check:
13 $(CARGO) check
14
15 clippy:
16 $(CARGO) clippy -- -D warnings
17
18 test:
19 $(CARGO) test
20
21 build:
22 $(CARGO) build $(if $(filter release,$(PROFILE)),--release,)
23
24 # --- Composites ---
25 .PHONY: lint ci
26
27 lint: fmt check clippy
28
29 ci: lint test build
30
31 # --- Install ---
32 install: PROFILE := release
33 install: build
34 install -Dm755 target/release/$(BIN) $(PREFIX)/bin/$(BIN)
35
36 uninstall:
37 rm -f $(PREFIX)/bin/$(BIN)
38
39 clean:
40 $(CARGO) clean
41
42 # --- Dev helpers ---
43 .PHONY: run dev dashboard
44
45 run:
46 $(CARGO) run -- $(ARGS)
47
48 dev: fmt
49 $(CARGO) run -- $(ARGS)
50
51 dashboard: fmt
52 $(CARGO) run -- dashboard
src/cli.rs
Old New
@@ -0,0 +1,159 @@
1 use clap::{Parser, Subcommand};
2
3 #[derive(Parser)]
4 #[command(
5 name = "git-collab",
6 about = "Distributed issues and code review over Git"
7 )]
8 pub struct Cli {
9 #[command(subcommand)]
10 pub command: Commands,
11 }
12
13 #[derive(Subcommand)]
14 pub enum Commands {
15 /// Initialize collab refspecs on all remotes
16 Init,
17
18 /// Manage issues
19 #[command(subcommand)]
20 Issue(IssueCmd),
21
22 /// Manage patches (code review)
23 #[command(subcommand)]
24 Patch(PatchCmd),
25
26 /// Interactive patch review dashboard
27 Dashboard,
28
29 /// Sync with a remote (fetch, reconcile, push)
30 Sync {
31 /// Remote name (default: origin)
32 #[arg(default_value = "origin")]
33 remote: String,
34 },
35 }
36
37 #[derive(Subcommand)]
38 pub enum IssueCmd {
39 /// Open a new issue
40 Open {
41 /// Issue title
42 #[arg(short, long)]
43 title: String,
44 /// Issue body
45 #[arg(short, long, default_value = "")]
46 body: String,
47 },
48 /// List issues
49 List {
50 /// Show closed issues too
51 #[arg(short = 'a', long)]
52 all: bool,
53 },
54 /// Show issue details
55 Show {
56 /// Issue ID (prefix match)
57 id: String,
58 },
59 /// Comment on an issue
60 Comment {
61 /// Issue ID (prefix match)
62 id: String,
63 /// Comment body
64 #[arg(short, long)]
65 body: String,
66 },
67 /// Close an issue
68 Close {
69 /// Issue ID (prefix match)
70 id: String,
71 /// Reason for closing
72 #[arg(short, long)]
73 reason: Option<String>,
74 },
75 /// Reopen a closed issue
76 Reopen {
77 /// Issue ID (prefix match)
78 id: String,
79 },
80 }
81
82 #[derive(Subcommand)]
83 pub enum PatchCmd {
84 /// Create a new patch for review
85 Create {
86 /// Patch title
87 #[arg(short, long)]
88 title: String,
89 /// Patch description
90 #[arg(short, long, default_value = "")]
91 body: String,
92 /// Base branch ref
93 #[arg(long, default_value = "main")]
94 base: String,
95 /// Head commit to review
96 #[arg(long)]
97 head: String,
98 },
99 /// List patches
100 List {
101 /// Show closed/merged patches too
102 #[arg(short = 'a', long)]
103 all: bool,
104 },
105 /// Show patch details
106 Show {
107 /// Patch ID (prefix match)
108 id: String,
109 },
110 /// Comment on a patch (use --file and --line for inline comments)
111 Comment {
112 /// Patch ID (prefix match)
113 id: String,
114 /// Comment body
115 #[arg(short, long)]
116 body: String,
117 /// File path for inline comment
118 #[arg(short, long)]
119 file: Option<String>,
120 /// Line number for inline comment
121 #[arg(short, long)]
122 line: Option<u32>,
123 },
124 /// Review a patch
125 Review {
126 /// Patch ID (prefix match)
127 id: String,
128 /// Verdict: approve, request-changes, comment
129 #[arg(short, long)]
130 verdict: String,
131 /// Review body
132 #[arg(short, long)]
133 body: String,
134 },
135 /// Revise a patch with a new head commit
136 Revise {
137 /// Patch ID (prefix match)
138 id: String,
139 /// New head commit
140 #[arg(long)]
141 head: String,
142 /// Updated description
143 #[arg(short, long)]
144 body: Option<String>,
145 },
146 /// Merge a patch into its base branch
147 Merge {
148 /// Patch ID (prefix match)
149 id: String,
150 },
151 /// Close a patch
152 Close {
153 /// Patch ID (prefix match)
154 id: String,
155 /// Reason for closing
156 #[arg(short, long)]
157 reason: Option<String>,
158 },
159 }
src/dag.rs
Old New
@@ -0,0 +1,147 @@
1 use git2::{Oid, Repository, Sort};
2
3 use crate::error::Error;
4 use crate::event::{Action, Event};
5 use crate::identity::author_signature;
6
7 /// Create an orphan commit (no parents) with the given event.
8 /// Returns the new commit OID which also serves as the entity ID.
9 pub fn create_root_event(repo: &Repository, event: &Event) -> Result<Oid, Error> {
10 let json = serde_json::to_vec_pretty(event)?;
11 let blob_oid = repo.blob(&json)?;
12
13 let mut tb = repo.treebuilder(None)?;
14 tb.insert("event.json", blob_oid, 0o100644)?;
15 let tree_oid = tb.write()?;
16 let tree = repo.find_tree(tree_oid)?;
17
18 let sig = author_signature(&event.author)?;
19 let message = commit_message(&event.action);
20
21 let oid = repo.commit(None, &sig, &sig, &message, &tree, &[])?;
22 Ok(oid)
23 }
24
25 /// Append an event to an existing DAG. The current tip is the parent.
26 pub fn append_event(repo: &Repository, ref_name: &str, event: &Event) -> Result<Oid, Error> {
27 let json = serde_json::to_vec_pretty(event)?;
28 let blob_oid = repo.blob(&json)?;
29
30 let mut tb = repo.treebuilder(None)?;
31 tb.insert("event.json", blob_oid, 0o100644)?;
32 let tree_oid = tb.write()?;
33 let tree = repo.find_tree(tree_oid)?;
34
35 let sig = author_signature(&event.author)?;
36 let message = commit_message(&event.action);
37
38 let parent_oid = repo.refname_to_id(ref_name)?;
39 let parent = repo.find_commit(parent_oid)?;
40
41 let oid = repo.commit(Some(ref_name), &sig, &sig, &message, &tree, &[&parent])?;
42 Ok(oid)
43 }
44
45 /// Walk the DAG from the given ref in topological order (oldest first).
46 /// Returns (commit_oid, event) pairs.
47 pub fn walk_events(repo: &Repository, ref_name: &str) -> Result<Vec<(Oid, Event)>, Error> {
48 let tip = repo.refname_to_id(ref_name)?;
49 let mut revwalk = repo.revwalk()?;
50 revwalk.set_sorting(Sort::TOPOLOGICAL | Sort::REVERSE)?;
51 revwalk.push(tip)?;
52
53 let mut events = Vec::new();
54 for oid_result in revwalk {
55 let oid = oid_result?;
56 let commit = repo.find_commit(oid)?;
57 let tree = commit.tree()?;
58 let entry = tree
59 .get_name("event.json")
60 .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?;
61 let blob = repo.find_blob(entry.id())?;
62 let event: Event = serde_json::from_slice(blob.content())?;
63 events.push((oid, event));
64 }
65 Ok(events)
66 }
67
68 /// Reconcile a local ref with a remote ref. Returns the final tip OID.
69 ///
70 /// - If they're the same: no-op
71 /// - If remote is ancestor of local: local is ahead, no-op
72 /// - If local is ancestor of remote: fast-forward
73 /// - Otherwise: create a merge commit
74 pub fn reconcile(
75 repo: &Repository,
76 local_ref: &str,
77 remote_ref: &str,
78 merge_author: &crate::event::Author,
79 ) -> Result<Oid, Error> {
80 let local_oid = repo.refname_to_id(local_ref)?;
81 let remote_oid = repo.refname_to_id(remote_ref)?;
82
83 if local_oid == remote_oid {
84 return Ok(local_oid);
85 }
86
87 let merge_base = repo.merge_base(local_oid, remote_oid)?;
88
89 if merge_base == remote_oid {
90 // Remote is ancestor of local — local is ahead
91 return Ok(local_oid);
92 }
93
94 if merge_base == local_oid {
95 // Local is ancestor of remote — fast-forward
96 repo.reference(local_ref, remote_oid, true, "fast-forward reconcile")?;
97 return Ok(remote_oid);
98 }
99
100 // True fork — create merge commit
101 let merge_event = Event {
102 timestamp: chrono::Utc::now().to_rfc3339(),
103 author: merge_author.clone(),
104 action: Action::Merge,
105 };
106
107 let json = serde_json::to_vec_pretty(&merge_event)?;
108 let blob_oid = repo.blob(&json)?;
109 let mut tb = repo.treebuilder(None)?;
110 tb.insert("event.json", blob_oid, 0o100644)?;
111 let tree_oid = tb.write()?;
112 let tree = repo.find_tree(tree_oid)?;
113
114 let sig = author_signature(merge_author)?;
115 let local_commit = repo.find_commit(local_oid)?;
116 let remote_commit = repo.find_commit(remote_oid)?;
117
118 let oid = repo.commit(
119 Some(local_ref),
120 &sig,
121 &sig,
122 "collab: reconcile merge",
123 &tree,
124 &[&local_commit, &remote_commit],
125 )?;
126
127 Ok(oid)
128 }
129
130 fn commit_message(action: &Action) -> String {
131 match action {
132 Action::IssueOpen { title, .. } => format!("issue: open \"{}\"", title),
133 Action::IssueComment { .. } => "issue: comment".to_string(),
134 Action::IssueClose { .. } => "issue: close".to_string(),
135 Action::IssueReopen => "issue: reopen".to_string(),
136 Action::PatchCreate { title, .. } => format!("patch: create \"{}\"", title),
137 Action::PatchRevise { .. } => "patch: revise".to_string(),
138 Action::PatchReview { verdict, .. } => format!("patch: review ({:?})", verdict),
139 Action::PatchComment { .. } => "patch: comment".to_string(),
140 Action::PatchInlineComment { ref file, line, .. } => {
141 format!("patch: inline comment on {}:{}", file, line)
142 }
143 Action::PatchClose { .. } => "patch: close".to_string(),
144 Action::PatchMerge => "patch: merge".to_string(),
145 Action::Merge => "collab: merge".to_string(),
146 }
147 }
src/error.rs
Old New
@@ -0,0 +1,16 @@
1 use thiserror::Error;
2
3 #[derive(Debug, Error)]
4 pub enum Error {
5 #[error(transparent)]
6 Git(#[from] git2::Error),
7
8 #[error(transparent)]
9 Json(#[from] serde_json::Error),
10
11 #[error("{0}")]
12 Cmd(String),
13
14 #[error(transparent)]
15 Io(#[from] std::io::Error),
16 }
src/event.rs
Old New
@@ -0,0 +1,64 @@
1 use serde::{Deserialize, Serialize};
2
3 #[derive(Debug, Clone, Serialize, Deserialize)]
4 pub struct Author {
5 pub name: String,
6 pub email: String,
7 }
8
9 #[derive(Debug, Clone, Serialize, Deserialize)]
10 pub struct Event {
11 pub timestamp: String,
12 pub author: Author,
13 pub action: Action,
14 }
15
16 #[derive(Debug, Clone, Serialize, Deserialize)]
17 #[serde(tag = "type")]
18 pub enum Action {
19 IssueOpen {
20 title: String,
21 body: String,
22 },
23 IssueComment {
24 body: String,
25 },
26 IssueClose {
27 reason: Option<String>,
28 },
29 IssueReopen,
30 PatchCreate {
31 title: String,
32 body: String,
33 base_ref: String,
34 head_commit: String,
35 },
36 PatchRevise {
37 body: Option<String>,
38 head_commit: String,
39 },
40 PatchReview {
41 verdict: ReviewVerdict,
42 body: String,
43 },
44 PatchComment {
45 body: String,
46 },
47 PatchInlineComment {
48 file: String,
49 line: u32,
50 body: String,
51 },
52 PatchClose {
53 reason: Option<String>,
54 },
55 PatchMerge,
56 Merge,
57 }
58
59 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60 pub enum ReviewVerdict {
61 Approve,
62 RequestChanges,
63 Comment,
64 }
src/identity.rs
Old New
@@ -0,0 +1,14 @@
1 use git2::Repository;
2
3 use crate::event::Author;
4
5 pub fn get_author(repo: &Repository) -> Result<Author, git2::Error> {
6 let config = repo.config()?;
7 let name = config.get_string("user.name")?;
8 let email = config.get_string("user.email")?;
9 Ok(Author { name, email })
10 }
11
12 pub fn author_signature(author: &Author) -> Result<git2::Signature<'static>, git2::Error> {
13 git2::Signature::now(&author.name, &author.email)
14 }
src/issue.rs
Old New
@@ -0,0 +1,122 @@
1 use git2::Repository;
2
3 use crate::dag;
4 use crate::event::{Action, Event};
5 use crate::identity::get_author;
6 use crate::state::{self, IssueStatus};
7
8 pub fn open(repo: &Repository, title: &str, body: &str) -> Result<String, crate::error::Error> {
9 let author = get_author(repo)?;
10 let event = Event {
11 timestamp: chrono::Utc::now().to_rfc3339(),
12 author,
13 action: Action::IssueOpen {
14 title: title.to_string(),
15 body: body.to_string(),
16 },
17 };
18 let oid = dag::create_root_event(repo, &event)?;
19 let id = oid.to_string();
20 let ref_name = format!("refs/collab/issues/{}", id);
21 repo.reference(&ref_name, oid, false, "issue open")?;
22 Ok(id)
23 }
24
25 pub fn list(repo: &Repository, show_closed: bool) -> Result<(), crate::error::Error> {
26 let issues = state::list_issues(repo)?;
27 let filtered: Vec<_> = issues
28 .iter()
29 .filter(|i| show_closed || i.status == IssueStatus::Open)
30 .collect();
31
32 if filtered.is_empty() {
33 println!("No issues found.");
34 return Ok(());
35 }
36
37 for issue in &filtered {
38 let status = match issue.status {
39 IssueStatus::Open => "open",
40 IssueStatus::Closed => "closed",
41 };
42 println!(
43 "{:.8} {:6} {} (by {})",
44 issue.id, status, issue.title, issue.author.name
45 );
46 }
47 Ok(())
48 }
49
50 pub fn show(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> {
51 let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?;
52 let issue = state::IssueState::from_ref(repo, &ref_name, &id)?;
53
54 let status = match issue.status {
55 IssueStatus::Open => "open",
56 IssueStatus::Closed => "closed",
57 };
58 println!("Issue {} [{}]", &issue.id[..8], status);
59 println!("Title: {}", issue.title);
60 println!("Author: {} <{}>", issue.author.name, issue.author.email);
61 println!("Created: {}", issue.created_at);
62 if let Some(ref reason) = issue.close_reason {
63 println!("Closed: {}", reason);
64 }
65 if !issue.body.is_empty() {
66 println!("\n{}", issue.body);
67 }
68 if !issue.comments.is_empty() {
69 println!("\n--- Comments ---");
70 for c in &issue.comments {
71 println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body);
72 }
73 }
74 Ok(())
75 }
76
77 pub fn comment(repo: &Repository, id_prefix: &str, body: &str) -> Result<(), crate::error::Error> {
78 let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
79 let author = get_author(repo)?;
80 let event = Event {
81 timestamp: chrono::Utc::now().to_rfc3339(),
82 author,
83 action: Action::IssueComment {
84 body: body.to_string(),
85 },
86 };
87 dag::append_event(repo, &ref_name, &event)?;
88 println!("Comment added.");
89 Ok(())
90 }
91
92 pub fn close(
93 repo: &Repository,
94 id_prefix: &str,
95 reason: Option<&str>,
96 ) -> Result<(), crate::error::Error> {
97 let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
98 let author = get_author(repo)?;
99 let event = Event {
100 timestamp: chrono::Utc::now().to_rfc3339(),
101 author,
102 action: Action::IssueClose {
103 reason: reason.map(|s| s.to_string()),
104 },
105 };
106 dag::append_event(repo, &ref_name, &event)?;
107 println!("Issue closed.");
108 Ok(())
109 }
110
111 pub fn reopen(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> {
112 let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
113 let author = get_author(repo)?;
114 let event = Event {
115 timestamp: chrono::Utc::now().to_rfc3339(),
116 author,
117 action: Action::IssueReopen,
118 };
119 dag::append_event(repo, &ref_name, &event)?;
120 println!("Issue reopened.");
121 Ok(())
122 }
src/lib.rs
Old New
@@ -0,0 +1,10 @@
1 pub mod cli;
2 pub mod dag;
3 pub mod error;
4 pub mod event;
5 pub mod identity;
6 pub mod issue;
7 pub mod patch;
8 pub mod state;
9 pub mod sync;
10 pub mod tui;
src/main.rs
Old New
@@ -0,0 +1,86 @@
1 mod cli;
2 mod dag;
3 mod error;
4 mod event;
5 mod identity;
6 mod issue;
7 mod patch;
8 mod state;
9 mod sync;
10 mod tui;
11
12 use clap::Parser;
13 use cli::{Cli, Commands, IssueCmd, PatchCmd};
14 use event::ReviewVerdict;
15 use git2::Repository;
16
17 fn main() {
18 let cli = Cli::parse();
19
20 let result = run(cli);
21 if let Err(e) = result {
22 eprintln!("error: {}", e);
23 std::process::exit(1);
24 }
25 }
26
27 fn run(cli: Cli) -> Result<(), error::Error> {
28 let repo = Repository::open_from_env()?;
29
30 match cli.command {
31 Commands::Init => sync::init(&repo),
32 Commands::Issue(cmd) => match cmd {
33 IssueCmd::Open { title, body } => {
34 let id = issue::open(&repo, &title, &body)?;
35 println!("Opened issue {:.8}", id);
36 Ok(())
37 }
38 IssueCmd::List { all } => issue::list(&repo, all),
39 IssueCmd::Show { id } => issue::show(&repo, &id),
40 IssueCmd::Comment { id, body } => issue::comment(&repo, &id, &body),
41 IssueCmd::Close { id, reason } => issue::close(&repo, &id, reason.as_deref()),
42 IssueCmd::Reopen { id } => issue::reopen(&repo, &id),
43 },
44 Commands::Patch(cmd) => match cmd {
45 PatchCmd::Create {
46 title,
47 body,
48 base,
49 head,
50 } => {
51 let id = patch::create(&repo, &title, &body, &base, &head)?;
52 println!("Created patch {:.8}", id);
53 Ok(())
54 }
55 PatchCmd::List { all } => patch::list(&repo, all),
56 PatchCmd::Show { id } => patch::show(&repo, &id),
57 PatchCmd::Comment {
58 id,
59 body,
60 file,
61 line,
62 } => patch::comment(&repo, &id, &body, file.as_deref(), line),
63 PatchCmd::Review { id, verdict, body } => {
64 let v = match verdict.as_str() {
65 "approve" => ReviewVerdict::Approve,
66 "request-changes" => ReviewVerdict::RequestChanges,
67 "comment" => ReviewVerdict::Comment,
68 _ => {
69 return Err(git2::Error::from_str(
70 "verdict must be: approve, request-changes, or comment",
71 )
72 .into());
73 }
74 };
75 patch::review(&repo, &id, v, &body)
76 }
77 PatchCmd::Revise { id, head, body } => {
78 patch::revise(&repo, &id, &head, body.as_deref())
79 }
80 PatchCmd::Merge { id } => patch::merge(&repo, &id),
81 PatchCmd::Close { id, reason } => patch::close(&repo, &id, reason.as_deref()),
82 },
83 Commands::Dashboard => tui::run(&repo),
84 Commands::Sync { remote } => sync::sync(&repo, &remote),
85 }
86 }
src/patch.rs
Old New
@@ -0,0 +1,265 @@
1 use git2::Repository;
2
3 use crate::dag;
4 use crate::event::{Action, Event, ReviewVerdict};
5 use crate::identity::get_author;
6 use crate::state::{self, PatchStatus};
7
8 pub fn create(
9 repo: &Repository,
10 title: &str,
11 body: &str,
12 base_ref: &str,
13 head_commit: &str,
14 ) -> Result<String, crate::error::Error> {
15 let author = get_author(repo)?;
16 let event = Event {
17 timestamp: chrono::Utc::now().to_rfc3339(),
18 author,
19 action: Action::PatchCreate {
20 title: title.to_string(),
21 body: body.to_string(),
22 base_ref: base_ref.to_string(),
23 head_commit: head_commit.to_string(),
24 },
25 };
26 let oid = dag::create_root_event(repo, &event)?;
27 let id = oid.to_string();
28 let ref_name = format!("refs/collab/patches/{}", id);
29 repo.reference(&ref_name, oid, false, "patch create")?;
30 Ok(id)
31 }
32
33 pub fn list(repo: &Repository, show_closed: bool) -> Result<(), crate::error::Error> {
34 let patches = state::list_patches(repo)?;
35 let filtered: Vec<_> = patches
36 .iter()
37 .filter(|p| show_closed || p.status == PatchStatus::Open)
38 .collect();
39
40 if filtered.is_empty() {
41 println!("No patches found.");
42 return Ok(());
43 }
44
45 for p in &filtered {
46 let status = match p.status {
47 PatchStatus::Open => "open",
48 PatchStatus::Closed => "closed",
49 PatchStatus::Merged => "merged",
50 };
51 println!(
52 "{:.8} {:6} {} (by {})",
53 p.id, status, p.title, p.author.name
54 );
55 }
56 Ok(())
57 }
58
59 pub fn show(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> {
60 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
61 let p = state::PatchState::from_ref(repo, &ref_name, &id)?;
62
63 let status = match p.status {
64 PatchStatus::Open => "open",
65 PatchStatus::Closed => "closed",
66 PatchStatus::Merged => "merged",
67 };
68 println!("Patch {} [{}]", &p.id[..8], status);
69 println!("Title: {}", p.title);
70 println!("Author: {} <{}>", p.author.name, p.author.email);
71 println!("Base: {} Head: {:.8}", p.base_ref, p.head_commit);
72 println!("Created: {}", p.created_at);
73 if !p.body.is_empty() {
74 println!("\n{}", p.body);
75 }
76 if !p.reviews.is_empty() {
77 println!("\n--- Reviews ---");
78 for r in &p.reviews {
79 println!(
80 "\n{} ({:?}) - {}:\n{}",
81 r.author.name, r.verdict, r.timestamp, r.body
82 );
83 }
84 }
85 if !p.inline_comments.is_empty() {
86 println!("\n--- Inline Comments ---");
87 for c in &p.inline_comments {
88 println!(
89 "\n{} on {}:{} ({}):\n {}",
90 c.author.name, c.file, c.line, c.timestamp, c.body
91 );
92 }
93 }
94 if !p.comments.is_empty() {
95 println!("\n--- Comments ---");
96 for c in &p.comments {
97 println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body);
98 }
99 }
100 Ok(())
101 }
102
103 pub fn comment(
104 repo: &Repository,
105 id_prefix: &str,
106 body: &str,
107 file: Option<&str>,
108 line: Option<u32>,
109 ) -> Result<(), crate::error::Error> {
110 let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?;
111 let author = get_author(repo)?;
112
113 let action = match (file, line) {
114 (Some(f), Some(l)) => Action::PatchInlineComment {
115 file: f.to_string(),
116 line: l,
117 body: body.to_string(),
118 },
119 (Some(_), None) | (None, Some(_)) => {
120 return Err(git2::Error::from_str(
121 "--file and --line must both be provided for inline comments",
122 )
123 .into());
124 }
125 (None, None) => Action::PatchComment {
126 body: body.to_string(),
127 },
128 };
129
130 let event = Event {
131 timestamp: chrono::Utc::now().to_rfc3339(),
132 author,
133 action,
134 };
135 dag::append_event(repo, &ref_name, &event)?;
136 println!("Comment added.");
137 Ok(())
138 }
139
140 pub fn review(
141 repo: &Repository,
142 id_prefix: &str,
143 verdict: ReviewVerdict,
144 body: &str,
145 ) -> Result<(), crate::error::Error> {
146 let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?;
147 let author = get_author(repo)?;
148 let event = Event {
149 timestamp: chrono::Utc::now().to_rfc3339(),
150 author,
151 action: Action::PatchReview {
152 verdict,
153 body: body.to_string(),
154 },
155 };
156 dag::append_event(repo, &ref_name, &event)?;
157 println!("Review submitted.");
158 Ok(())
159 }
160
161 pub fn revise(
162 repo: &Repository,
163 id_prefix: &str,
164 head_commit: &str,
165 body: Option<&str>,
166 ) -> Result<(), crate::error::Error> {
167 let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?;
168 let author = get_author(repo)?;
169 let event = Event {
170 timestamp: chrono::Utc::now().to_rfc3339(),
171 author,
172 action: Action::PatchRevise {
173 body: body.map(|s| s.to_string()),
174 head_commit: head_commit.to_string(),
175 },
176 };
177 dag::append_event(repo, &ref_name, &event)?;
178 println!("Patch revised.");
179 Ok(())
180 }
181
182 pub fn merge(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> {
183 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
184 let p = state::PatchState::from_ref(repo, &ref_name, &id)?;
185
186 if p.status != PatchStatus::Open {
187 return Err(git2::Error::from_str(&format!(
188 "patch is {:?}, can only merge open patches",
189 p.status
190 ))
191 .into());
192 }
193
194 // Resolve the head commit (supports short OIDs via revparse)
195 let head_obj = repo
196 .revparse_single(&p.head_commit)
197 .map_err(|_| git2::Error::from_str("cannot resolve head commit in patch"))?;
198 let head_commit = head_obj
199 .into_commit()
200 .map_err(|_| git2::Error::from_str("head ref is not a commit"))?;
201
202 let base_ref = format!("refs/heads/{}", p.base_ref);
203 let base_oid = repo.refname_to_id(&base_ref)?;
204
205 // Fast-forward: the base must be an ancestor of head
206 let head_oid = head_commit.id();
207 if repo.graph_descendant_of(head_oid, base_oid)? {
208 repo.reference(&base_ref, head_oid, true, "collab: merge patch")?;
209 } else if head_oid == base_oid {
210 // Already at the same point, nothing to do
211 } else {
212 // Not a fast-forward — create a merge commit on the base branch
213 let base_commit = repo.find_commit(base_oid)?;
214 let author = get_author(repo)?;
215 let sig = crate::identity::author_signature(&author)?;
216 let mut index = repo.merge_commits(&base_commit, &head_commit, None)?;
217 if index.has_conflicts() {
218 return Err(git2::Error::from_str(
219 "merge has conflicts; resolve manually then revise the patch",
220 )
221 .into());
222 }
223 let tree_oid = index.write_tree_to(repo)?;
224 let tree = repo.find_tree(tree_oid)?;
225 let msg = format!("Merge patch {:.8}: {}", id, p.title);
226 repo.commit(
227 Some(&base_ref),
228 &sig,
229 &sig,
230 &msg,
231 &tree,
232 &[&base_commit, &head_commit],
233 )?;
234 }
235
236 // Record the merge event in the patch DAG
237 let author = get_author(repo)?;
238 let event = Event {
239 timestamp: chrono::Utc::now().to_rfc3339(),
240 author,
241 action: Action::PatchMerge,
242 };
243 dag::append_event(repo, &ref_name, &event)?;
244 println!("Patch {:.8} merged into {}.", id, p.base_ref);
245 Ok(())
246 }
247
248 pub fn close(
249 repo: &Repository,
250 id_prefix: &str,
251 reason: Option<&str>,
252 ) -> Result<(), crate::error::Error> {
253 let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?;
254 let author = get_author(repo)?;
255 let event = Event {
256 timestamp: chrono::Utc::now().to_rfc3339(),
257 author,
258 action: Action::PatchClose {
259 reason: reason.map(|s| s.to_string()),
260 },
261 };
262 dag::append_event(repo, &ref_name, &event)?;
263 println!("Patch closed.");
264 Ok(())
265 }
src/state.rs
Old New
@@ -0,0 +1,330 @@
1 use git2::{Oid, Repository};
2
3 use crate::dag;
4 use crate::event::{Action, Author, ReviewVerdict};
5
6 #[derive(Debug, Clone, PartialEq)]
7 pub enum IssueStatus {
8 Open,
9 Closed,
10 }
11
12 #[derive(Debug, Clone)]
13 #[allow(dead_code)]
14 pub struct Comment {
15 pub author: Author,
16 pub body: String,
17 pub timestamp: String,
18 pub commit_id: Oid,
19 }
20
21 #[derive(Debug, Clone)]
22 pub struct IssueState {
23 pub id: String,
24 pub title: String,
25 pub body: String,
26 pub status: IssueStatus,
27 pub close_reason: Option<String>,
28 pub comments: Vec<Comment>,
29 pub created_at: String,
30 pub author: Author,
31 }
32
33 #[derive(Debug, Clone)]
34 pub struct Review {
35 pub author: Author,
36 pub verdict: ReviewVerdict,
37 pub body: String,
38 pub timestamp: String,
39 }
40
41 #[derive(Debug, Clone, PartialEq)]
42 #[allow(dead_code)]
43 pub enum PatchStatus {
44 Open,
45 Closed,
46 Merged,
47 }
48
49 #[derive(Debug, Clone)]
50 pub struct InlineComment {
51 pub author: Author,
52 pub file: String,
53 pub line: u32,
54 pub body: String,
55 pub timestamp: String,
56 }
57
58 #[derive(Debug, Clone)]
59 pub struct PatchState {
60 pub id: String,
61 pub title: String,
62 pub body: String,
63 pub status: PatchStatus,
64 pub base_ref: String,
65 pub head_commit: String,
66 pub comments: Vec<Comment>,
67 pub inline_comments: Vec<InlineComment>,
68 pub reviews: Vec<Review>,
69 pub created_at: String,
70 pub author: Author,
71 }
72
73 impl IssueState {
74 pub fn from_ref(
75 repo: &Repository,
76 ref_name: &str,
77 id: &str,
78 ) -> Result<Self, crate::error::Error> {
79 let events = dag::walk_events(repo, ref_name)?;
80 let mut state: Option<IssueState> = None;
81
82 // Track the timestamp of the latest status-changing event so that
83 // concurrent close/reopen conflicts resolve deterministically:
84 // the event with the later timestamp wins.
85 let mut status_ts: Option<String> = None;
86
87 for (oid, event) in events {
88 match event.action {
89 Action::IssueOpen { title, body } => {
90 state = Some(IssueState {
91 id: id.to_string(),
92 title,
93 body,
94 status: IssueStatus::Open,
95 close_reason: None,
96 comments: Vec::new(),
97 created_at: event.timestamp.clone(),
98 author: event.author.clone(),
99 });
100 }
101 Action::IssueComment { body } => {
102 if let Some(ref mut s) = state {
103 s.comments.push(Comment {
104 author: event.author.clone(),
105 body,
106 timestamp: event.timestamp.clone(),
107 commit_id: oid,
108 });
109 }
110 }
111 Action::IssueClose { reason } => {
112 if let Some(ref mut s) = state {
113 if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) {
114 s.status = IssueStatus::Closed;
115 s.close_reason = reason;
116 status_ts = Some(event.timestamp.clone());
117 }
118 }
119 }
120 Action::IssueReopen => {
121 if let Some(ref mut s) = state {
122 if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) {
123 s.status = IssueStatus::Open;
124 status_ts = Some(event.timestamp.clone());
125 }
126 }
127 }
128 Action::Merge => {}
129 _ => {}
130 }
131 }
132
133 state.ok_or_else(|| git2::Error::from_str("no IssueOpen event found in DAG").into())
134 }
135 }
136
137 impl PatchState {
138 pub fn from_ref(
139 repo: &Repository,
140 ref_name: &str,
141 id: &str,
142 ) -> Result<Self, crate::error::Error> {
143 let events = dag::walk_events(repo, ref_name)?;
144 let mut state: Option<PatchState> = None;
145
146 let mut status_ts: Option<String> = None;
147
148 for (oid, event) in events {
149 match event.action {
150 Action::PatchCreate {
151 title,
152 body,
153 base_ref,
154 head_commit,
155 } => {
156 state = Some(PatchState {
157 id: id.to_string(),
158 title,
159 body,
160 status: PatchStatus::Open,
161 base_ref,
162 head_commit,
163 comments: Vec::new(),
164 inline_comments: Vec::new(),
165 reviews: Vec::new(),
166 created_at: event.timestamp.clone(),
167 author: event.author.clone(),
168 });
169 }
170 Action::PatchRevise { body, head_commit } => {
171 if let Some(ref mut s) = state {
172 s.head_commit = head_commit;
173 if let Some(b) = body {
174 s.body = b;
175 }
176 }
177 }
178 Action::PatchReview { verdict, body } => {
179 if let Some(ref mut s) = state {
180 s.reviews.push(Review {
181 author: event.author.clone(),
182 verdict,
183 body,
184 timestamp: event.timestamp.clone(),
185 });
186 }
187 }
188 Action::PatchComment { body } => {
189 if let Some(ref mut s) = state {
190 s.comments.push(Comment {
191 author: event.author.clone(),
192 body,
193 timestamp: event.timestamp.clone(),
194 commit_id: oid,
195 });
196 }
197 }
198 Action::PatchInlineComment { file, line, body } => {
199 if let Some(ref mut s) = state {
200 s.inline_comments.push(InlineComment {
201 author: event.author.clone(),
202 file,
203 line,
204 body,
205 timestamp: event.timestamp.clone(),
206 });
207 }
208 }
209 Action::PatchClose { .. } => {
210 if let Some(ref mut s) = state {
211 if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) {
212 s.status = PatchStatus::Closed;
213 status_ts = Some(event.timestamp.clone());
214 }
215 }
216 }
217 Action::PatchMerge => {
218 if let Some(ref mut s) = state {
219 if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) {
220 s.status = PatchStatus::Merged;
221 status_ts = Some(event.timestamp.clone());
222 }
223 }
224 }
225 Action::Merge => {}
226 _ => {}
227 }
228 }
229
230 state.ok_or_else(|| git2::Error::from_str("no PatchCreate event found in DAG").into())
231 }
232 }
233
234 /// List all issue refs and return their materialized state.
235 pub fn list_issues(repo: &Repository) -> Result<Vec<IssueState>, crate::error::Error> {
236 let mut issues = Vec::new();
237 let refs = repo.references_glob("refs/collab/issues/*")?;
238 for r in refs {
239 let r = r?;
240 let ref_name = r.name().unwrap_or_default().to_string();
241 let id = ref_name
242 .strip_prefix("refs/collab/issues/")
243 .unwrap_or_default()
244 .to_string();
245 match IssueState::from_ref(repo, &ref_name, &id) {
246 Ok(state) => issues.push(state),
247 Err(_) => continue,
248 }
249 }
250 Ok(issues)
251 }
252
253 /// List all patch refs and return their materialized state.
254 pub fn list_patches(repo: &Repository) -> Result<Vec<PatchState>, crate::error::Error> {
255 let mut patches = Vec::new();
256 let refs = repo.references_glob("refs/collab/patches/*")?;
257 for r in refs {
258 let r = r?;
259 let ref_name = r.name().unwrap_or_default().to_string();
260 let id = ref_name
261 .strip_prefix("refs/collab/patches/")
262 .unwrap_or_default()
263 .to_string();
264 match PatchState::from_ref(repo, &ref_name, &id) {
265 Ok(state) => patches.push(state),
266 Err(_) => continue,
267 }
268 }
269 Ok(patches)
270 }
271
272 /// Resolve a short ID prefix to the full ref name. Returns (ref_name, id).
273 pub fn resolve_issue_ref(
274 repo: &Repository,
275 prefix: &str,
276 ) -> Result<(String, String), crate::error::Error> {
277 let refs = repo.references_glob("refs/collab/issues/*")?;
278 let mut matches = Vec::new();
279 for r in refs {
280 let r = r?;
281 let ref_name = r.name().unwrap_or_default().to_string();
282 let id = ref_name
283 .strip_prefix("refs/collab/issues/")
284 .unwrap_or_default()
285 .to_string();
286 if id.starts_with(prefix) {
287 matches.push((ref_name, id));
288 }
289 }
290 match matches.len() {
291 0 => Err(git2::Error::from_str(&format!("no issue found matching '{}'", prefix)).into()),
292 1 => Ok(matches.into_iter().next().unwrap()),
293 _ => Err(git2::Error::from_str(&format!(
294 "ambiguous issue prefix '{}': {} matches",
295 prefix,
296 matches.len()
297 ))
298 .into()),
299 }
300 }
301
302 /// Resolve a short ID prefix to the full patch ref name.
303 pub fn resolve_patch_ref(
304 repo: &Repository,
305 prefix: &str,
306 ) -> Result<(String, String), crate::error::Error> {
307 let refs = repo.references_glob("refs/collab/patches/*")?;
308 let mut matches = Vec::new();
309 for r in refs {
310 let r = r?;
311 let ref_name = r.name().unwrap_or_default().to_string();
312 let id = ref_name
313 .strip_prefix("refs/collab/patches/")
314 .unwrap_or_default()
315 .to_string();
316 if id.starts_with(prefix) {
317 matches.push((ref_name, id));
318 }
319 }
320 match matches.len() {
321 0 => Err(git2::Error::from_str(&format!("no patch found matching '{}'", prefix)).into()),
322 1 => Ok(matches.into_iter().next().unwrap()),
323 _ => Err(git2::Error::from_str(&format!(
324 "ambiguous patch prefix '{}': {} matches",
325 prefix,
326 matches.len()
327 ))
328 .into()),
329 }
330 }
src/sync.rs
Old New
@@ -0,0 +1,139 @@
1 use std::process::Command;
2
3 use git2::Repository;
4
5 use crate::dag;
6 use crate::error::Error;
7 use crate::identity::get_author;
8
9 /// Add collab refspecs to all remotes.
10 pub fn init(repo: &Repository) -> Result<(), Error> {
11 let remotes = repo.remotes()?;
12 if remotes.is_empty() {
13 println!("No remotes configured.");
14 return Ok(());
15 }
16 for remote_name in remotes.iter().flatten() {
17 let fetch_spec = format!("+refs/collab/*:refs/collab/sync/{}/*", remote_name);
18 repo.remote_add_fetch(remote_name, &fetch_spec)?;
19 println!("Configured remote '{}'", remote_name);
20 }
21 println!("Collab refspecs initialized.");
22 Ok(())
23 }
24
25 /// Sync with a specific remote: fetch, reconcile, push.
26 pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> {
27 let author = get_author(repo)?;
28 let workdir = repo.path().parent().unwrap_or(repo.path()).to_path_buf();
29
30 // Step 1: Fetch collab refs using system git (handles SSH agent, credentials, etc.)
31 println!("Fetching from '{}'...", remote_name);
32 let fetch_status = Command::new("git")
33 .args([
34 "fetch",
35 remote_name,
36 "+refs/collab/issues/*:refs/collab/sync/issues/*",
37 "+refs/collab/patches/*:refs/collab/sync/patches/*",
38 ])
39 .current_dir(&workdir)
40 .status()
41 .map_err(|e| Error::Cmd(format!("failed to run git fetch: {}", e)))?;
42
43 if !fetch_status.success() {
44 return Err(Error::Cmd(format!(
45 "git fetch exited with status {}",
46 fetch_status
47 )));
48 }
49
50 // Step 2: Reconcile
51 // Re-open repo to see the fetched refs (git2 caches ref state)
52 let repo = Repository::open(repo.path())?;
53 reconcile_refs(&repo, "issues", &author)?;
54 reconcile_refs(&repo, "patches", &author)?;
55
56 // Step 3: Push collab refs using system git
57 println!("Pushing to '{}'...", remote_name);
58 let mut push_args = vec!["push", remote_name];
59 let mut refspecs: Vec<String> = Vec::new();
60
61 for pattern in &["refs/collab/issues/*", "refs/collab/patches/*"] {
62 let refs = repo.references_glob(pattern)?;
63 for r in refs {
64 let r = r?;
65 if let Some(name) = r.name() {
66 refspecs.push(format!("+{}:{}", name, name));
67 }
68 }
69 }
70
71 if refspecs.is_empty() {
72 println!("Nothing to push.");
73 } else {
74 let refspec_strs: Vec<&str> = refspecs.iter().map(|s| s.as_str()).collect();
75 push_args.extend(refspec_strs);
76
77 let push_status = Command::new("git")
78 .args(&push_args)
79 .current_dir(&workdir)
80 .status()
81 .map_err(|e| Error::Cmd(format!("failed to run git push: {}", e)))?;
82
83 if !push_status.success() {
84 return Err(Error::Cmd(format!(
85 "git push exited with status {}",
86 push_status
87 )));
88 }
89 }
90
91 // Step 4: Clean up sync refs
92 for prefix in &["refs/collab/sync/issues/", "refs/collab/sync/patches/"] {
93 let refs: Vec<String> = repo
94 .references_glob(&format!("{}*", prefix))?
95 .filter_map(|r| r.ok()?.name().map(|n| n.to_string()))
96 .collect();
97 for ref_name in refs {
98 let mut r = repo.find_reference(&ref_name)?;
99 r.delete()?;
100 }
101 }
102
103 println!("Sync complete.");
104 Ok(())
105 }
106
107 /// Reconcile all refs of a given kind (issues or patches) from sync refs.
108 fn reconcile_refs(
109 repo: &Repository,
110 kind: &str,
111 author: &crate::event::Author,
112 ) -> Result<(), Error> {
113 let sync_prefix = format!("refs/collab/sync/{}/", kind);
114 let sync_refs: Vec<(String, String)> = {
115 let refs = repo.references_glob(&format!("{}*", sync_prefix))?;
116 refs.filter_map(|r| {
117 let r = r.ok()?;
118 let name = r.name()?.to_string();
119 let id = name.strip_prefix(&sync_prefix)?.to_string();
120 Some((name, id))
121 })
122 .collect()
123 };
124
125 for (remote_ref, id) in &sync_refs {
126 let local_ref = format!("refs/collab/{}/{}", kind, id);
127 if repo.refname_to_id(&local_ref).is_ok() {
128 match dag::reconcile(repo, &local_ref, remote_ref, author) {
129 Ok(_) => println!(" Reconciled {} {:.8}", kind, id),
130 Err(e) => eprintln!(" Failed to reconcile {} {:.8}: {}", kind, id, e),
131 }
132 } else {
133 let oid = repo.refname_to_id(remote_ref)?;
134 repo.reference(&local_ref, oid, false, "sync: new from remote")?;
135 println!(" New {} {:.8} from remote", kind, id);
136 }
137 }
138 Ok(())
139 }
src/tui.rs
Old New
@@ -0,0 +1,851 @@
1 use std::collections::HashMap;
2 use std::io::{self, stdout};
3 use std::time::Duration;
4
5 use crossterm::event::{self, Event, KeyCode, KeyModifiers};
6 use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
7 use crossterm::ExecutableCommand;
8 use git2::{DiffFormat, Repository};
9 use ratatui::prelude::*;
10 use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};
11
12 use crate::error::Error;
13 use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus};
14
15 #[derive(PartialEq)]
16 enum Pane {
17 ItemList,
18 Detail,
19 }
20
21 #[derive(PartialEq, Clone, Copy)]
22 enum Tab {
23 Issues,
24 Patches,
25 }
26
27 #[derive(PartialEq)]
28 enum ViewMode {
29 Details,
30 Diff,
31 }
32
33 struct App {
34 tab: Tab,
35 issues: Vec<IssueState>,
36 patches: Vec<PatchState>,
37 list_state: ListState,
38 diff_cache: HashMap<String, String>,
39 scroll: u16,
40 pane: Pane,
41 mode: ViewMode,
42 show_all: bool,
43 }
44
45 impl App {
46 fn new(issues: Vec<IssueState>, patches: Vec<PatchState>) -> Self {
47 let mut list_state = ListState::default();
48 if !issues.is_empty() {
49 list_state.select(Some(0));
50 }
51 Self {
52 tab: Tab::Issues,
53 issues,
54 patches,
55 list_state,
56 diff_cache: HashMap::new(),
57 scroll: 0,
58 pane: Pane::ItemList,
59 mode: ViewMode::Details,
60 show_all: false,
61 }
62 }
63
64 fn visible_issue_count(&self) -> usize {
65 if self.show_all {
66 self.issues.len()
67 } else {
68 self.issues
69 .iter()
70 .filter(|i| i.status == IssueStatus::Open)
71 .count()
72 }
73 }
74
75 fn visible_issues(&self) -> Vec<&IssueState> {
76 if self.show_all {
77 self.issues.iter().collect()
78 } else {
79 self.issues
80 .iter()
81 .filter(|i| i.status == IssueStatus::Open)
82 .collect()
83 }
84 }
85
86 fn visible_patches(&self) -> Vec<&PatchState> {
87 if self.show_all {
88 self.patches.iter().collect()
89 } else {
90 self.patches
91 .iter()
92 .filter(|p| p.status == PatchStatus::Open)
93 .collect()
94 }
95 }
96
97 fn visible_count(&self) -> usize {
98 match self.tab {
99 Tab::Issues => self.visible_issue_count(),
100 Tab::Patches => self.visible_patches().len(),
101 }
102 }
103
104 fn move_selection(&mut self, delta: i32) {
105 let len = self.visible_count();
106 if len == 0 {
107 return;
108 }
109 let current = self.list_state.selected().unwrap_or(0);
110 let new = if delta > 0 {
111 (current + delta as usize).min(len - 1)
112 } else {
113 current.saturating_sub((-delta) as usize)
114 };
115 self.list_state.select(Some(new));
116 self.scroll = 0;
117 }
118
119 fn switch_tab(&mut self, tab: Tab) {
120 if self.tab == tab {
121 return;
122 }
123 self.tab = tab;
124 self.list_state.select(if self.visible_count() > 0 {
125 Some(0)
126 } else {
127 None
128 });
129 self.scroll = 0;
130 self.mode = ViewMode::Details;
131 self.pane = Pane::ItemList;
132 }
133
134 fn reload(&mut self, repo: &Repository) {
135 if let Ok(issues) = state::list_issues(repo) {
136 self.issues = issues;
137 }
138 if let Ok(patches) = state::list_patches(repo) {
139 self.patches = patches;
140 }
141 let visible_len = self.visible_count();
142 if let Some(sel) = self.list_state.selected() {
143 if sel >= visible_len {
144 self.list_state.select(if visible_len > 0 {
145 Some(visible_len - 1)
146 } else {
147 None
148 });
149 }
150 }
151 }
152 }
153
154 fn generate_diff(repo: &Repository, patch: &PatchState) -> String {
155 let result = (|| -> Result<String, Error> {
156 let head_obj = repo
157 .revparse_single(&patch.head_commit)
158 .map_err(|e| Error::Cmd(format!("bad head ref: {}", e)))?;
159 let head_commit = head_obj
160 .into_commit()
161 .map_err(|_| Error::Cmd("head ref is not a commit".to_string()))?;
162 let head_tree = head_commit.tree()?;
163
164 let base_ref = format!("refs/heads/{}", patch.base_ref);
165 let base_tree = if let Ok(base_oid) = repo.refname_to_id(&base_ref) {
166 let base_commit = repo.find_commit(base_oid)?;
167 Some(base_commit.tree()?)
168 } else {
169 None
170 };
171
172 let diff = repo.diff_tree_to_tree(base_tree.as_ref(), Some(&head_tree), None)?;
173
174 let mut output = String::new();
175 let mut lines = 0usize;
176 diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
177 if lines >= 5000 {
178 return false;
179 }
180 let prefix = match line.origin() {
181 '+' => "+",
182 '-' => "-",
183 ' ' => " ",
184 'H' | 'F' => "",
185 _ => "",
186 };
187 if !prefix.is_empty() || matches!(line.origin(), 'H' | 'F') {
188 output.push_str(prefix);
189 }
190 if let Ok(content) = std::str::from_utf8(line.content()) {
191 output.push_str(content);
192 }
193 lines += 1;
194 true
195 })?;
196
197 if lines >= 5000 {
198 output.push_str("\n[truncated at 5000 lines]");
199 }
200
201 Ok(output)
202 })();
203
204 match result {
205 Ok(diff) => {
206 if diff.is_empty() {
207 "No diff available (commits may be identical)".to_string()
208 } else {
209 diff
210 }
211 }
212 Err(e) => format!("Diff unavailable: {}", e),
213 }
214 }
215
216 pub fn run(repo: &Repository) -> Result<(), Error> {
217 let issues = state::list_issues(repo)?;
218 let patches = state::list_patches(repo)?;
219
220 let mut app = App::new(issues, patches);
221
222 terminal::enable_raw_mode()?;
223 stdout().execute(EnterAlternateScreen)?;
224 let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
225
226 let result = run_loop(&mut terminal, &mut app, repo);
227
228 terminal::disable_raw_mode()?;
229 stdout().execute(LeaveAlternateScreen)?;
230
231 result
232 }
233
234 fn run_loop(
235 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
236 app: &mut App,
237 repo: &Repository,
238 ) -> Result<(), Error> {
239 loop {
240 // Cache diff for selected patch if needed
241 if app.tab == Tab::Patches && app.mode == ViewMode::Diff {
242 if let Some(idx) = app.list_state.selected() {
243 let visible = app.visible_patches();
244 if let Some(patch) = visible.get(idx) {
245 if !app.diff_cache.contains_key(&patch.id) {
246 let id = patch.id.clone();
247 let diff = generate_diff(repo, patch);
248 app.diff_cache.insert(id, diff);
249 }
250 }
251 }
252 }
253
254 terminal.draw(|frame| ui(frame, app))?;
255
256 if event::poll(Duration::from_millis(100))? {
257 if let Event::Key(key) = event::read()? {
258 match key.code {
259 KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
260 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
261 return Ok(())
262 }
263 KeyCode::Char('1') => app.switch_tab(Tab::Issues),
264 KeyCode::Char('2') => app.switch_tab(Tab::Patches),
265 KeyCode::Char('j') | KeyCode::Down => {
266 if app.pane == Pane::ItemList {
267 app.move_selection(1);
268 } else {
269 app.scroll = app.scroll.saturating_add(1);
270 }
271 }
272 KeyCode::Char('k') | KeyCode::Up => {
273 if app.pane == Pane::ItemList {
274 app.move_selection(-1);
275 } else {
276 app.scroll = app.scroll.saturating_sub(1);
277 }
278 }
279 KeyCode::PageDown => app.scroll = app.scroll.saturating_add(20),
280 KeyCode::PageUp => app.scroll = app.scroll.saturating_sub(20),
281 KeyCode::Tab | KeyCode::Enter => {
282 app.pane = match app.pane {
283 Pane::ItemList => Pane::Detail,
284 Pane::Detail => Pane::ItemList,
285 };
286 }
287 KeyCode::Char('d') => {
288 if app.tab == Tab::Patches {
289 app.mode = match app.mode {
290 ViewMode::Details => ViewMode::Diff,
291 ViewMode::Diff => ViewMode::Details,
292 };
293 app.scroll = 0;
294 }
295 }
296 KeyCode::Char('a') => {
297 app.show_all = !app.show_all;
298 let count = app.visible_count();
299 app.list_state
300 .select(if count > 0 { Some(0) } else { None });
301 }
302 KeyCode::Char('r') => {
303 app.reload(repo);
304 }
305 _ => {}
306 }
307 }
308 }
309 }
310 }
311
312 fn ui(frame: &mut Frame, app: &mut App) {
313 let chunks = Layout::default()
314 .direction(Direction::Vertical)
315 .constraints([
316 Constraint::Length(1),
317 Constraint::Min(1),
318 Constraint::Length(1),
319 ])
320 .split(frame.area());
321
322 let tab_area = chunks[0];
323 let main_area = chunks[1];
324 let footer_area = chunks[2];
325
326 // Tab bar
327 let tab_titles = vec!["1:Issues", "2:Patches"];
328 let selected_tab = match app.tab {
329 Tab::Issues => 0,
330 Tab::Patches => 1,
331 };
332 let tabs = Tabs::new(tab_titles)
333 .select(selected_tab)
334 .highlight_style(
335 Style::default()
336 .fg(Color::Yellow)
337 .add_modifier(Modifier::BOLD),
338 )
339 .divider(" | ");
340 frame.render_widget(tabs, tab_area);
341
342 let panes = Layout::default()
343 .direction(Direction::Horizontal)
344 .constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
345 .split(main_area);
346
347 render_list(frame, app, panes[0]);
348 render_detail(frame, app, panes[1]);
349 render_footer(frame, app, footer_area);
350 }
351
352 fn render_list(frame: &mut Frame, app: &mut App, area: Rect) {
353 let border_style = if app.pane == Pane::ItemList {
354 Style::default().fg(Color::Yellow)
355 } else {
356 Style::default().fg(Color::DarkGray)
357 };
358
359 match app.tab {
360 Tab::Issues => {
361 let visible = app.visible_issues();
362 let items: Vec<ListItem> = visible
363 .iter()
364 .map(|i| {
365 let status = match i.status {
366 IssueStatus::Open => "open",
367 IssueStatus::Closed => "closed",
368 };
369 let style = match i.status {
370 IssueStatus::Open => Style::default().fg(Color::Green),
371 IssueStatus::Closed => Style::default().fg(Color::Red),
372 };
373 ListItem::new(format!("{:.8} {:6} {}", i.id, status, i.title)).style(style)
374 })
375 .collect();
376
377 let title = if app.show_all {
378 "Issues (all)"
379 } else {
380 "Issues (open)"
381 };
382
383 let list = List::new(items)
384 .block(
385 Block::default()
386 .borders(Borders::ALL)
387 .title(title)
388 .border_style(border_style),
389 )
390 .highlight_style(
391 Style::default()
392 .bg(Color::DarkGray)
393 .add_modifier(Modifier::BOLD),
394 )
395 .highlight_symbol("> ");
396
397 frame.render_stateful_widget(list, area, &mut app.list_state);
398 }
399 Tab::Patches => {
400 let visible = app.visible_patches();
401 let items: Vec<ListItem> = visible
402 .iter()
403 .map(|p| {
404 let status = match p.status {
405 PatchStatus::Open => "open",
406 PatchStatus::Closed => "closed",
407 PatchStatus::Merged => "merged",
408 };
409 let style = match p.status {
410 PatchStatus::Open => Style::default().fg(Color::Green),
411 PatchStatus::Closed => Style::default().fg(Color::Red),
412 PatchStatus::Merged => Style::default().fg(Color::Cyan),
413 };
414 ListItem::new(format!("{:.8} {:6} {}", p.id, status, p.title)).style(style)
415 })
416 .collect();
417
418 let title = if app.show_all {
419 "Patches (all)"
420 } else {
421 "Patches (open)"
422 };
423
424 let list = List::new(items)
425 .block(
426 Block::default()
427 .borders(Borders::ALL)
428 .title(title)
429 .border_style(border_style),
430 )
431 .highlight_style(
432 Style::default()
433 .bg(Color::DarkGray)
434 .add_modifier(Modifier::BOLD),
435 )
436 .highlight_symbol("> ");
437
438 frame.render_stateful_widget(list, area, &mut app.list_state);
439 }
440 }
441 }
442
443 fn render_detail(frame: &mut Frame, app: &App, area: Rect) {
444 let border_style = if app.pane == Pane::Detail {
445 Style::default().fg(Color::Yellow)
446 } else {
447 Style::default().fg(Color::DarkGray)
448 };
449
450 let title = match (&app.tab, &app.mode) {
451 (Tab::Issues, _) => "Issue Details",
452 (Tab::Patches, ViewMode::Details) => "Patch Details",
453 (Tab::Patches, ViewMode::Diff) => "Diff",
454 };
455
456 let content: Text = match app.tab {
457 Tab::Issues => {
458 let visible = app.visible_issues();
459 let selected_idx = app.list_state.selected().unwrap_or(0);
460 match visible.get(selected_idx) {
461 Some(issue) => build_issue_detail(issue),
462 None => Text::raw("No issues to display."),
463 }
464 }
465 Tab::Patches => {
466 let visible = app.visible_patches();
467 let selected_idx = app.list_state.selected().unwrap_or(0);
468 match visible.get(selected_idx) {
469 Some(patch) => match app.mode {
470 ViewMode::Details => build_patch_detail(patch),
471 ViewMode::Diff => {
472 let diff_text = app
473 .diff_cache
474 .get(&patch.id)
475 .map(|s| s.as_str())
476 .unwrap_or("Loading...");
477 colorize_diff(diff_text, &patch.inline_comments)
478 }
479 },
480 None => Text::raw("No patches to display."),
481 }
482 }
483 };
484
485 let block = Block::default()
486 .borders(Borders::ALL)
487 .title(title)
488 .border_style(border_style);
489
490 let para = Paragraph::new(content)
491 .block(block)
492 .wrap(Wrap { trim: false })
493 .scroll((app.scroll, 0));
494
495 frame.render_widget(para, area);
496 }
497
498 fn build_issue_detail(issue: &IssueState) -> Text<'static> {
499 let status = match issue.status {
500 IssueStatus::Open => "open",
501 IssueStatus::Closed => "closed",
502 };
503
504 let mut lines: Vec<Line> = vec![
505 Line::from(vec![
506 Span::styled("Issue ", Style::default().add_modifier(Modifier::BOLD)),
507 Span::styled(
508 format!("{:.8}", issue.id),
509 Style::default()
510 .fg(Color::Yellow)
511 .add_modifier(Modifier::BOLD),
512 ),
513 Span::raw(format!(" [{}]", status)),
514 ]),
515 Line::from(vec![
516 Span::styled("Title: ", Style::default().fg(Color::DarkGray)),
517 Span::raw(issue.title.clone()),
518 ]),
519 Line::from(vec![
520 Span::styled("Author: ", Style::default().fg(Color::DarkGray)),
521 Span::raw(format!("{} <{}>", issue.author.name, issue.author.email)),
522 ]),
523 Line::from(vec![
524 Span::styled("Created: ", Style::default().fg(Color::DarkGray)),
525 Span::raw(issue.created_at.clone()),
526 ]),
527 ];
528
529 if let Some(ref reason) = issue.close_reason {
530 lines.push(Line::from(vec![
531 Span::styled("Closed: ", Style::default().fg(Color::Red)),
532 Span::raw(reason.clone()),
533 ]));
534 }
535
536 if !issue.body.is_empty() {
537 lines.push(Line::raw(""));
538 for l in issue.body.lines() {
539 lines.push(Line::raw(l.to_string()));
540 }
541 }
542
543 if !issue.comments.is_empty() {
544 lines.push(Line::raw(""));
545 lines.push(Line::styled(
546 "--- Comments ---",
547 Style::default()
548 .fg(Color::Blue)
549 .add_modifier(Modifier::BOLD),
550 ));
551 for c in &issue.comments {
552 lines.push(Line::raw(""));
553 lines.push(Line::from(vec![
554 Span::styled(
555 c.author.name.clone(),
556 Style::default().add_modifier(Modifier::BOLD),
557 ),
558 Span::styled(
559 format!(" ({})", c.timestamp),
560 Style::default().fg(Color::DarkGray),
561 ),
562 ]));
563 for l in c.body.lines() {
564 lines.push(Line::raw(format!(" {}", l)));
565 }
566 }
567 }
568
569 Text::from(lines)
570 }
571
572 fn build_patch_detail(patch: &PatchState) -> Text<'static> {
573 let status = match patch.status {
574 PatchStatus::Open => "open",
575 PatchStatus::Closed => "closed",
576 PatchStatus::Merged => "merged",
577 };
578
579 let mut lines: Vec<Line> = vec![
580 Line::from(vec![
581 Span::styled("Patch ", Style::default().add_modifier(Modifier::BOLD)),
582 Span::styled(
583 format!("{:.8}", patch.id),
584 Style::default()
585 .fg(Color::Yellow)
586 .add_modifier(Modifier::BOLD),
587 ),
588 Span::raw(format!(" [{}]", status)),
589 ]),
590 Line::from(vec![
591 Span::styled("Title: ", Style::default().fg(Color::DarkGray)),
592 Span::raw(patch.title.clone()),
593 ]),
594 Line::from(vec![
595 Span::styled("Author: ", Style::default().fg(Color::DarkGray)),
596 Span::raw(format!("{} <{}>", patch.author.name, patch.author.email)),
597 ]),
598 Line::from(vec![
599 Span::styled("Base: ", Style::default().fg(Color::DarkGray)),
600 Span::raw(patch.base_ref.clone()),
601 Span::raw(" "),
602 Span::styled("Head: ", Style::default().fg(Color::DarkGray)),
603 Span::styled(
604 format!("{:.8}", patch.head_commit),
605 Style::default().fg(Color::Cyan),
606 ),
607 ]),
608 Line::from(vec![
609 Span::styled("Created: ", Style::default().fg(Color::DarkGray)),
610 Span::raw(patch.created_at.clone()),
611 ]),
612 ];
613
614 if !patch.body.is_empty() {
615 lines.push(Line::raw(""));
616 for l in patch.body.lines() {
617 lines.push(Line::raw(l.to_string()));
618 }
619 }
620
621 if !patch.reviews.is_empty() {
622 lines.push(Line::raw(""));
623 lines.push(Line::styled(
624 "--- Reviews ---",
625 Style::default()
626 .fg(Color::Magenta)
627 .add_modifier(Modifier::BOLD),
628 ));
629 for r in &patch.reviews {
630 lines.push(Line::raw(""));
631 let verdict_style = match r.verdict {
632 crate::event::ReviewVerdict::Approve => Style::default().fg(Color::Green),
633 crate::event::ReviewVerdict::RequestChanges => Style::default().fg(Color::Red),
634 crate::event::ReviewVerdict::Comment => Style::default().fg(Color::Yellow),
635 };
636 lines.push(Line::from(vec![
637 Span::raw(format!("{} ", r.author.name)),
638 Span::styled(format!("({:?})", r.verdict), verdict_style),
639 Span::styled(
640 format!(" - {}", r.timestamp),
641 Style::default().fg(Color::DarkGray),
642 ),
643 ]));
644 for l in r.body.lines() {
645 lines.push(Line::raw(format!(" {}", l)));
646 }
647 }
648 }
649
650 if !patch.inline_comments.is_empty() {
651 lines.push(Line::raw(""));
652 lines.push(Line::styled(
653 "--- Inline Comments ---",
654 Style::default()
655 .fg(Color::Magenta)
656 .add_modifier(Modifier::BOLD),
657 ));
658 for c in &patch.inline_comments {
659 lines.push(Line::raw(""));
660 lines.push(Line::from(vec![
661 Span::styled(
662 format!("{}:{}", c.file, c.line),
663 Style::default().fg(Color::Cyan),
664 ),
665 Span::raw(format!(" - {} ", c.author.name)),
666 Span::styled(
667 format!("({})", c.timestamp),
668 Style::default().fg(Color::DarkGray),
669 ),
670 ]));
671 for l in c.body.lines() {
672 lines.push(Line::raw(format!(" {}", l)));
673 }
674 }
675 }
676
677 if !patch.comments.is_empty() {
678 lines.push(Line::raw(""));
679 lines.push(Line::styled(
680 "--- Comments ---",
681 Style::default()
682 .fg(Color::Blue)
683 .add_modifier(Modifier::BOLD),
684 ));
685 for c in &patch.comments {
686 lines.push(Line::raw(""));
687 lines.push(Line::from(vec![
688 Span::styled(
689 c.author.name.clone(),
690 Style::default().add_modifier(Modifier::BOLD),
691 ),
692 Span::styled(
693 format!(" ({})", c.timestamp),
694 Style::default().fg(Color::DarkGray),
695 ),
696 ]));
697 for l in c.body.lines() {
698 lines.push(Line::raw(format!(" {}", l)));
699 }
700 }
701 }
702
703 Text::from(lines)
704 }
705
706 fn colorize_diff(diff: &str, inline_comments: &[state::InlineComment]) -> Text<'static> {
707 // Build a lookup: (file, line) -> vec of comments
708 let mut comment_map: HashMap<(String, u32), Vec<&state::InlineComment>> = HashMap::new();
709 for c in inline_comments {
710 comment_map
711 .entry((c.file.clone(), c.line))
712 .or_default()
713 .push(c);
714 }
715
716 let mut lines: Vec<Line> = Vec::new();
717 let mut current_file = String::new();
718 let mut current_new_line: u32 = 0;
719
720 for diff_line in diff.lines() {
721 // Track which file we're in from +++ headers
722 if let Some(file) = diff_line.strip_prefix("+++ b/") {
723 current_file = file.to_string();
724 } else if diff_line.starts_with("+++ ") {
725 // +++ /dev/null or similar
726 current_file.clear();
727 }
728
729 // Track line numbers from @@ hunk headers
730 if diff_line.starts_with("@@") {
731 // Parse @@ -old,len +new,len @@
732 if let Some(plus_part) = diff_line.split('+').nth(1) {
733 if let Some(line_str) = plus_part.split(',').next().or(plus_part.split(' ').next())
734 {
735 current_new_line = line_str.parse().unwrap_or(0);
736 }
737 }
738 }
739
740 // Colorize the diff line
741 let style = if diff_line.starts_with('+') && !diff_line.starts_with("+++") {
742 Style::default().fg(Color::Green)
743 } else if diff_line.starts_with('-') && !diff_line.starts_with("---") {
744 Style::default().fg(Color::Red)
745 } else if diff_line.starts_with("@@") {
746 Style::default().fg(Color::Cyan)
747 } else if diff_line.starts_with("diff ") || diff_line.starts_with("index ") {
748 Style::default().fg(Color::DarkGray)
749 } else if diff_line.starts_with("+++") || diff_line.starts_with("---") {
750 Style::default().fg(Color::Yellow)
751 } else {
752 Style::default()
753 };
754 lines.push(Line::styled(diff_line.to_string(), style));
755
756 // After rendering this line, check for inline comments at current position
757 if !current_file.is_empty()
758 && current_new_line > 0
759 && (diff_line.starts_with('+')
760 || diff_line.starts_with(' ')
761 || diff_line.starts_with("@@"))
762 {
763 if let Some(comments) = comment_map.get(&(current_file.clone(), current_new_line)) {
764 for c in comments {
765 lines.push(Line::styled(
766 format!(" ┌─ {} ({})", c.author.name, c.timestamp),
767 Style::default()
768 .fg(Color::Magenta)
769 .add_modifier(Modifier::BOLD),
770 ));
771 for body_line in c.body.lines() {
772 lines.push(Line::styled(
773 format!(" │ {}", body_line),
774 Style::default().fg(Color::Magenta),
775 ));
776 }
777 lines.push(Line::styled(
778 " └─".to_string(),
779 Style::default().fg(Color::Magenta),
780 ));
781 }
782 }
783 }
784
785 // Advance line counter for added/context lines (not removed or header)
786 if (diff_line.starts_with('+') && !diff_line.starts_with("+++"))
787 || diff_line.starts_with(' ')
788 {
789 current_new_line += 1;
790 }
791 }
792
793 // Show any inline comments for files not covered by the diff
794 let files_in_diff: std::collections::HashSet<&str> = diff
795 .lines()
796 .filter_map(|l| l.strip_prefix("+++ b/"))
797 .collect();
798 let mut orphan_comments: Vec<&state::InlineComment> = inline_comments
799 .iter()
800 .filter(|c| !files_in_diff.contains(c.file.as_str()))
801 .collect();
802 if !orphan_comments.is_empty() {
803 orphan_comments.sort_by(|a, b| (&a.file, a.line).cmp(&(&b.file, b.line)));
804 lines.push(Line::raw(""));
805 lines.push(Line::styled(
806 "--- Comments on files not in diff ---",
807 Style::default()
808 .fg(Color::Magenta)
809 .add_modifier(Modifier::BOLD),
810 ));
811 for c in orphan_comments {
812 lines.push(Line::styled(
813 format!(
814 " {}:{} - {} ({}):",
815 c.file, c.line, c.author.name, c.timestamp
816 ),
817 Style::default().fg(Color::Magenta),
818 ));
819 for body_line in c.body.lines() {
820 lines.push(Line::styled(
821 format!(" {}", body_line),
822 Style::default().fg(Color::Magenta),
823 ));
824 }
825 }
826 }
827
828 Text::from(lines)
829 }
830
831 fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
832 let mode_hint = if app.tab == Tab::Patches {
833 match app.mode {
834 ViewMode::Details => " d:diff",
835 ViewMode::Diff => " d:details",
836 }
837 } else {
838 ""
839 };
840 let filter_hint = if app.show_all {
841 "a:open only"
842 } else {
843 "a:show all"
844 };
845 let text = format!(
846 " 1:issues 2:patches j/k:navigate Tab:pane {}{} r:refresh q:quit",
847 filter_hint, mode_hint
848 );
849 let para = Paragraph::new(text).style(Style::default().bg(Color::DarkGray).fg(Color::White));
850 frame.render_widget(para, area);
851 }
tests/collab_test.rs
Old New
@@ -0,0 +1,601 @@
1 use git2::Repository;
2 use std::path::Path;
3 use tempfile::TempDir;
4
5 use git_collab::dag;
6 use git_collab::event::{Action, Author, Event, ReviewVerdict};
7 use git_collab::state::{self, IssueState, IssueStatus, PatchState, PatchStatus};
8
9 // ---------------------------------------------------------------------------
10 // Helpers
11 // ---------------------------------------------------------------------------
12
13 fn alice() -> Author {
14 Author {
15 name: "Alice".to_string(),
16 email: "alice@example.com".to_string(),
17 }
18 }
19
20 fn bob() -> Author {
21 Author {
22 name: "Bob".to_string(),
23 email: "bob@example.com".to_string(),
24 }
25 }
26
27 fn now() -> String {
28 chrono::Utc::now().to_rfc3339()
29 }
30
31 /// Create a bare repo in a tempdir and configure user identity.
32 fn init_repo(dir: &Path, author: &Author) -> Repository {
33 let repo = Repository::init(dir).expect("init repo");
34 {
35 let mut config = repo.config().unwrap();
36 config.set_str("user.name", &author.name).unwrap();
37 config.set_str("user.email", &author.email).unwrap();
38 }
39 repo
40 }
41
42 /// Open an issue directly using DAG primitives (for fine-grained control in tests).
43 fn open_issue(repo: &Repository, author: &Author, title: &str) -> (String, String) {
44 let event = Event {
45 timestamp: now(),
46 author: author.clone(),
47 action: Action::IssueOpen {
48 title: title.to_string(),
49 body: "".to_string(),
50 },
51 };
52 let oid = dag::create_root_event(repo, &event).unwrap();
53 let id = oid.to_string();
54 let ref_name = format!("refs/collab/issues/{}", id);
55 repo.reference(&ref_name, oid, false, "test open").unwrap();
56 (ref_name, id)
57 }
58
59 /// Append a comment event to a ref.
60 fn add_comment(repo: &Repository, ref_name: &str, author: &Author, body: &str) {
61 let event = Event {
62 timestamp: now(),
63 author: author.clone(),
64 action: Action::IssueComment {
65 body: body.to_string(),
66 },
67 };
68 dag::append_event(repo, ref_name, &event).unwrap();
69 }
70
71 /// Append a close event to a ref.
72 fn close_issue(repo: &Repository, ref_name: &str, author: &Author) {
73 let event = Event {
74 timestamp: now(),
75 author: author.clone(),
76 action: Action::IssueClose { reason: None },
77 };
78 dag::append_event(repo, ref_name, &event).unwrap();
79 }
80
81 /// Append a reopen event to a ref.
82 fn reopen_issue(repo: &Repository, ref_name: &str, author: &Author) {
83 let event = Event {
84 timestamp: now(),
85 author: author.clone(),
86 action: Action::IssueReopen,
87 };
88 dag::append_event(repo, ref_name, &event).unwrap();
89 }
90
91 // ---------------------------------------------------------------------------
92 // Basic DAG tests
93 // ---------------------------------------------------------------------------
94
95 #[test]
96 fn test_create_and_walk_issue_dag() {
97 let tmp = TempDir::new().unwrap();
98 let repo = init_repo(tmp.path(), &alice());
99
100 let (ref_name, id) = open_issue(&repo, &alice(), "First issue");
101 add_comment(&repo, &ref_name, &alice(), "A comment");
102
103 let events = dag::walk_events(&repo, &ref_name).unwrap();
104 assert_eq!(events.len(), 2);
105 assert!(matches!(events[0].1.action, Action::IssueOpen { .. }));
106 assert!(matches!(events[1].1.action, Action::IssueComment { .. }));
107
108 // State materializes correctly
109 let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
110 assert_eq!(state.title, "First issue");
111 assert_eq!(state.status, IssueStatus::Open);
112 assert_eq!(state.comments.len(), 1);
113 }
114
115 #[test]
116 fn test_issue_lifecycle() {
117 let tmp = TempDir::new().unwrap();
118 let repo = init_repo(tmp.path(), &alice());
119
120 let (ref_name, id) = open_issue(&repo, &alice(), "Bug report");
121 add_comment(&repo, &ref_name, &bob(), "I can reproduce");
122 close_issue(&repo, &ref_name, &alice());
123
124 let s = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
125 assert_eq!(s.status, IssueStatus::Closed);
126 assert_eq!(s.comments.len(), 1);
127
128 // Reopen
129 reopen_issue(&repo, &ref_name, &bob());
130 let s = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
131 assert_eq!(s.status, IssueStatus::Open);
132 }
133
134 #[test]
135 fn test_list_issues_filters_by_status() {
136 let tmp = TempDir::new().unwrap();
137 let repo = init_repo(tmp.path(), &alice());
138
139 let (_ref1, _) = open_issue(&repo, &alice(), "Open issue");
140 let (ref2, _) = open_issue(&repo, &alice(), "Closed issue");
141 close_issue(&repo, &ref2, &alice());
142
143 let all = state::list_issues(&repo).unwrap();
144 assert_eq!(all.len(), 2);
145
146 let open: Vec<_> = all
147 .iter()
148 .filter(|i| i.status == IssueStatus::Open)
149 .collect();
150 assert_eq!(open.len(), 1);
151 assert_eq!(open[0].title, "Open issue");
152 }
153
154 // ---------------------------------------------------------------------------
155 // Multi-user collaboration: concurrent edits → forked DAG → reconcile
156 // ---------------------------------------------------------------------------
157
158 #[test]
159 fn test_concurrent_comments_create_fork_and_reconcile() {
160 // Simulate two users commenting on the same issue concurrently.
161 //
162 // Starting state:
163 // commit A (IssueOpen)
164 //
165 // Alice adds comment → commit B (parent: A)
166 // Bob adds comment → commit C (parent: A) ← fork!
167 //
168 // Reconciliation creates merge commit M (parents: B, C)
169 // State replay should see all 3 events (open + 2 comments)
170
171 let tmp = TempDir::new().unwrap();
172 let repo = init_repo(tmp.path(), &alice());
173
174 // Create the issue (commit A)
175 let (ref_name, id) = open_issue(&repo, &alice(), "Concurrent test");
176 let root_oid = repo.refname_to_id(&ref_name).unwrap();
177
178 // Alice comments (commit B) — advances the ref
179 add_comment(&repo, &ref_name, &alice(), "Alice's comment");
180 let alice_tip = repo.refname_to_id(&ref_name).unwrap();
181
182 // Simulate Bob's concurrent comment: reset ref back to root, then append
183 repo.reference(&ref_name, root_oid, true, "simulate bob fork")
184 .unwrap();
185 add_comment(&repo, &ref_name, &bob(), "Bob's comment");
186 let bob_tip = repo.refname_to_id(&ref_name).unwrap();
187
188 // Now we have two tips diverged from root_oid.
189 // Put Bob's tip in a "remote" ref so we can reconcile.
190 let remote_ref = format!("refs/collab/sync/origin/issues/{}", id);
191 repo.reference(&remote_ref, bob_tip, true, "remote tip")
192 .unwrap();
193
194 // Reset local ref to Alice's tip
195 repo.reference(&ref_name, alice_tip, true, "restore alice tip")
196 .unwrap();
197
198 // Reconcile
199 let merge_oid = dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap();
200
201 // Verify merge commit has 2 parents
202 let merge_commit = repo.find_commit(merge_oid).unwrap();
203 assert_eq!(merge_commit.parent_count(), 2);
204
205 // Walk events — should see open + both comments + merge
206 let events = dag::walk_events(&repo, &ref_name).unwrap();
207 let actions: Vec<_> = events.iter().map(|(_, e)| &e.action).collect();
208
209 // Must have the open event
210 assert!(actions
211 .iter()
212 .any(|a| matches!(a, Action::IssueOpen { .. })));
213 // Must have both comments
214 let comments: Vec<_> = actions
215 .iter()
216 .filter(|a| matches!(a, Action::IssueComment { .. }))
217 .collect();
218 assert_eq!(comments.len(), 2, "both concurrent comments should appear");
219 // Must have the merge
220 assert!(actions.iter().any(|a| matches!(a, Action::Merge)));
221
222 // State should show both comments
223 let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
224 assert_eq!(state.comments.len(), 2);
225 assert_eq!(state.status, IssueStatus::Open);
226 }
227
228 #[test]
229 fn test_concurrent_close_and_comment() {
230 // Alice closes the issue while Bob comments on it concurrently.
231 // After reconciliation, the issue should be closed (last event wins by topo order)
232 // and Bob's comment should still be visible.
233
234 let tmp = TempDir::new().unwrap();
235 let repo = init_repo(tmp.path(), &alice());
236
237 let (ref_name, id) = open_issue(&repo, &alice(), "Close vs comment");
238 let root_oid = repo.refname_to_id(&ref_name).unwrap();
239
240 // Alice closes
241 close_issue(&repo, &ref_name, &alice());
242 let alice_tip = repo.refname_to_id(&ref_name).unwrap();
243
244 // Bob comments (from root)
245 repo.reference(&ref_name, root_oid, true, "bob fork")
246 .unwrap();
247 add_comment(&repo, &ref_name, &bob(), "Wait, I have thoughts");
248 let bob_tip = repo.refname_to_id(&ref_name).unwrap();
249
250 // Set up for reconciliation
251 let remote_ref = format!("refs/collab/sync/origin/issues/{}", id);
252 repo.reference(&remote_ref, bob_tip, true, "remote")
253 .unwrap();
254 repo.reference(&ref_name, alice_tip, true, "restore")
255 .unwrap();
256
257 dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap();
258
259 let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
260 // Both the close and comment should be in the DAG
261 assert_eq!(state.comments.len(), 1);
262 // The close event should have taken effect
263 assert_eq!(state.status, IssueStatus::Closed);
264 }
265
266 #[test]
267 fn test_concurrent_close_and_reopen() {
268 // Alice closes while Bob reopens from a previously-closed state.
269 // This tests conflicting status transitions.
270
271 let tmp = TempDir::new().unwrap();
272 let repo = init_repo(tmp.path(), &alice());
273
274 let (ref_name, id) = open_issue(&repo, &alice(), "Status conflict");
275
276 // First, close the issue so both users start from "closed"
277 close_issue(&repo, &ref_name, &alice());
278 let closed_oid = repo.refname_to_id(&ref_name).unwrap();
279
280 // Alice comments on the closed issue
281 add_comment(&repo, &ref_name, &alice(), "Staying closed");
282 let alice_tip = repo.refname_to_id(&ref_name).unwrap();
283
284 // Bob reopens from the closed state
285 repo.reference(&ref_name, closed_oid, true, "bob fork")
286 .unwrap();
287 reopen_issue(&repo, &ref_name, &bob());
288 let bob_tip = repo.refname_to_id(&ref_name).unwrap();
289
290 let remote_ref = format!("refs/collab/sync/origin/issues/{}", id);
291 repo.reference(&remote_ref, bob_tip, true, "remote")
292 .unwrap();
293 repo.reference(&ref_name, alice_tip, true, "restore")
294 .unwrap();
295
296 dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap();
297
298 let _state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
299 // Both branches are replayed — the final status depends on topo order.
300 // The important thing is that we don't crash and both events are present.
301 let events = dag::walk_events(&repo, &ref_name).unwrap();
302 let has_close = events
303 .iter()
304 .any(|(_, e)| matches!(e.action, Action::IssueClose { .. }));
305 let has_reopen = events
306 .iter()
307 .any(|(_, e)| matches!(e.action, Action::IssueReopen));
308 assert!(has_close, "close event must be in DAG");
309 assert!(has_reopen, "reopen event must be in DAG");
310 }
311
312 #[test]
313 fn test_fast_forward_reconcile() {
314 // If local is behind remote (remote has strictly more events), reconcile
315 // should fast-forward without creating a merge commit.
316
317 let tmp = TempDir::new().unwrap();
318 let repo = init_repo(tmp.path(), &alice());
319
320 let (ref_name, id) = open_issue(&repo, &alice(), "FF test");
321 let root_oid = repo.refname_to_id(&ref_name).unwrap();
322
323 // Add comment (advances ref)
324 add_comment(&repo, &ref_name, &alice(), "Extra comment");
325 let ahead_tip = repo.refname_to_id(&ref_name).unwrap();
326
327 // Simulate: local is at root, remote is at ahead_tip
328 repo.reference(&ref_name, root_oid, true, "reset local")
329 .unwrap();
330 let remote_ref = format!("refs/collab/sync/origin/issues/{}", id);
331 repo.reference(&remote_ref, ahead_tip, true, "remote ahead")
332 .unwrap();
333
334 let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap();
335 assert_eq!(result, ahead_tip, "should fast-forward to remote tip");
336
337 // No merge commit — walk should have exactly 2 events
338 let events = dag::walk_events(&repo, &ref_name).unwrap();
339 assert_eq!(events.len(), 2);
340 assert!(!events
341 .iter()
342 .any(|(_, e)| matches!(e.action, Action::Merge)));
343 }
344
345 #[test]
346 fn test_no_op_when_already_in_sync() {
347 let tmp = TempDir::new().unwrap();
348 let repo = init_repo(tmp.path(), &alice());
349
350 let (ref_name, id) = open_issue(&repo, &alice(), "Already synced");
351 let tip = repo.refname_to_id(&ref_name).unwrap();
352
353 let remote_ref = format!("refs/collab/sync/origin/issues/{}", id);
354 repo.reference(&remote_ref, tip, true, "same tip").unwrap();
355
356 let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap();
357 assert_eq!(result, tip);
358 }
359
360 #[test]
361 fn test_local_ahead_no_merge() {
362 // If local has more events than remote, reconcile should be a no-op.
363 let tmp = TempDir::new().unwrap();
364 let repo = init_repo(tmp.path(), &alice());
365
366 let (ref_name, id) = open_issue(&repo, &alice(), "Local ahead");
367 let root_oid = repo.refname_to_id(&ref_name).unwrap();
368
369 add_comment(&repo, &ref_name, &alice(), "Local only comment");
370 let local_tip = repo.refname_to_id(&ref_name).unwrap();
371
372 let remote_ref = format!("refs/collab/sync/origin/issues/{}", id);
373 repo.reference(&remote_ref, root_oid, true, "remote behind")
374 .unwrap();
375
376 let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap();
377 assert_eq!(result, local_tip, "local should stay ahead");
378 }
379
380 // ---------------------------------------------------------------------------
381 // Patch collaboration tests
382 // ---------------------------------------------------------------------------
383
384 fn create_patch(repo: &Repository, author: &Author, title: &str) -> (String, String) {
385 let event = Event {
386 timestamp: now(),
387 author: author.clone(),
388 action: Action::PatchCreate {
389 title: title.to_string(),
390 body: "".to_string(),
391 base_ref: "main".to_string(),
392 head_commit: "abc123".to_string(),
393 },
394 };
395 let oid = dag::create_root_event(repo, &event).unwrap();
396 let id = oid.to_string();
397 let ref_name = format!("refs/collab/patches/{}", id);
398 repo.reference(&ref_name, oid, false, "test patch").unwrap();
399 (ref_name, id)
400 }
401
402 fn add_review(repo: &Repository, ref_name: &str, author: &Author, verdict: ReviewVerdict) {
403 let event = Event {
404 timestamp: now(),
405 author: author.clone(),
406 action: Action::PatchReview {
407 verdict,
408 body: "review comment".to_string(),
409 },
410 };
411 dag::append_event(repo, ref_name, &event).unwrap();
412 }
413
414 #[test]
415 fn test_patch_review_workflow() {
416 let tmp = TempDir::new().unwrap();
417 let repo = init_repo(tmp.path(), &alice());
418
419 let (ref_name, id) = create_patch(&repo, &alice(), "Add feature X");
420
421 // Bob reviews
422 add_review(&repo, &ref_name, &bob(), ReviewVerdict::RequestChanges);
423
424 let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap();
425 assert_eq!(state.reviews.len(), 1);
426 assert_eq!(state.reviews[0].verdict, ReviewVerdict::RequestChanges);
427
428 // Alice revises
429 let event = Event {
430 timestamp: now(),
431 author: alice(),
432 action: Action::PatchRevise {
433 body: Some("Updated implementation".to_string()),
434 head_commit: "def456".to_string(),
435 },
436 };
437 dag::append_event(&repo, &ref_name, &event).unwrap();
438
439 // Bob approves
440 add_review(&repo, &ref_name, &bob(), ReviewVerdict::Approve);
441
442 let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap();
443 assert_eq!(state.reviews.len(), 2);
444 assert_eq!(state.head_commit, "def456");
445 assert_eq!(state.body, "Updated implementation");
446 }
447
448 #[test]
449 fn test_concurrent_reviews_on_patch() {
450 // Alice and Bob both review the same patch concurrently.
451 let tmp = TempDir::new().unwrap();
452 let repo = init_repo(tmp.path(), &alice());
453
454 let (ref_name, id) = create_patch(&repo, &alice(), "Concurrent review");
455 let root_oid = repo.refname_to_id(&ref_name).unwrap();
456
457 // Alice approves
458 add_review(&repo, &ref_name, &alice(), ReviewVerdict::Approve);
459 let alice_tip = repo.refname_to_id(&ref_name).unwrap();
460
461 // Bob requests changes (from root)
462 repo.reference(&ref_name, root_oid, true, "bob fork")
463 .unwrap();
464 add_review(&repo, &ref_name, &bob(), ReviewVerdict::RequestChanges);
465 let bob_tip = repo.refname_to_id(&ref_name).unwrap();
466
467 // Reconcile
468 let remote_ref = format!("refs/collab/sync/origin/patches/{}", id);
469 repo.reference(&remote_ref, bob_tip, true, "remote")
470 .unwrap();
471 repo.reference(&ref_name, alice_tip, true, "restore")
472 .unwrap();
473
474 dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap();
475
476 let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap();
477 assert_eq!(state.reviews.len(), 2, "both reviews should be present");
478 assert_eq!(state.status, PatchStatus::Open);
479 }
480
481 // ---------------------------------------------------------------------------
482 // Three-way concurrent edits
483 // ---------------------------------------------------------------------------
484
485 #[test]
486 fn test_three_way_fork_sequential_reconcile() {
487 // Three users all comment from the same base.
488 // We reconcile them pairwise: first alice+bob, then result+charlie.
489
490 let tmp = TempDir::new().unwrap();
491 let repo = init_repo(tmp.path(), &alice());
492
493 let charlie = Author {
494 name: "Charlie".to_string(),
495 email: "charlie@example.com".to_string(),
496 };
497
498 let (ref_name, id) = open_issue(&repo, &alice(), "Three-way");
499 let root_oid = repo.refname_to_id(&ref_name).unwrap();
500
501 // Alice comments
502 add_comment(&repo, &ref_name, &alice(), "Alice here");
503 let alice_tip = repo.refname_to_id(&ref_name).unwrap();
504
505 // Bob comments from root
506 repo.reference(&ref_name, root_oid, true, "bob").unwrap();
507 add_comment(&repo, &ref_name, &bob(), "Bob here");
508 let bob_tip = repo.refname_to_id(&ref_name).unwrap();
509
510 // Charlie comments from root
511 repo.reference(&ref_name, root_oid, true, "charlie")
512 .unwrap();
513 add_comment(&repo, &ref_name, &charlie, "Charlie here");
514 let charlie_tip = repo.refname_to_id(&ref_name).unwrap();
515
516 // Reconcile alice + bob
517 repo.reference(&ref_name, alice_tip, true, "alice").unwrap();
518 let bob_ref = "refs/collab/sync/origin/issues/bob_temp";
519 repo.reference(bob_ref, bob_tip, true, "bob remote")
520 .unwrap();
521 dag::reconcile(&repo, &ref_name, bob_ref, &alice()).unwrap();
522
523 // Reconcile result + charlie
524 let charlie_ref = "refs/collab/sync/origin/issues/charlie_temp";
525 repo.reference(charlie_ref, charlie_tip, true, "charlie remote")
526 .unwrap();
527 dag::reconcile(&repo, &ref_name, charlie_ref, &alice()).unwrap();
528
529 let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
530 assert_eq!(state.comments.len(), 3, "all three comments must survive");
531
532 let bodies: Vec<&str> = state.comments.iter().map(|c| c.body.as_str()).collect();
533 assert!(bodies.contains(&"Alice here"));
534 assert!(bodies.contains(&"Bob here"));
535 assert!(bodies.contains(&"Charlie here"));
536 }
537
538 // ---------------------------------------------------------------------------
539 // Multiple issues don't interfere
540 // ---------------------------------------------------------------------------
541
542 #[test]
543 fn test_multiple_issues_independent() {
544 let tmp = TempDir::new().unwrap();
545 let repo = init_repo(tmp.path(), &alice());
546
547 let (ref1, id1) = open_issue(&repo, &alice(), "Issue one");
548 let (ref2, id2) = open_issue(&repo, &bob(), "Issue two");
549
550 add_comment(&repo, &ref1, &bob(), "Comment on issue one");
551 close_issue(&repo, &ref2, &bob());
552
553 let s1 = IssueState::from_ref(&repo, &ref1, &id1).unwrap();
554 let s2 = IssueState::from_ref(&repo, &ref2, &id2).unwrap();
555
556 assert_eq!(s1.status, IssueStatus::Open);
557 assert_eq!(s1.comments.len(), 1);
558 assert_eq!(s2.status, IssueStatus::Closed);
559 assert_eq!(s2.comments.len(), 0);
560 }
561
562 // ---------------------------------------------------------------------------
563 // Edge cases
564 // ---------------------------------------------------------------------------
565
566 #[test]
567 fn test_empty_issue_body() {
568 let tmp = TempDir::new().unwrap();
569 let repo = init_repo(tmp.path(), &alice());
570
571 let (ref_name, id) = open_issue(&repo, &alice(), "No body");
572 let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
573 assert_eq!(state.body, "");
574 }
575
576 #[test]
577 fn test_many_comments_performance() {
578 let tmp = TempDir::new().unwrap();
579 let repo = init_repo(tmp.path(), &alice());
580
581 let (ref_name, id) = open_issue(&repo, &alice(), "Many comments");
582 for i in 0..50 {
583 add_comment(&repo, &ref_name, &alice(), &format!("Comment {}", i));
584 }
585
586 let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
587 assert_eq!(state.comments.len(), 50);
588 }
589
590 #[test]
591 fn test_resolve_prefix_match() {
592 let tmp = TempDir::new().unwrap();
593 let repo = init_repo(tmp.path(), &alice());
594
595 let (_, id) = open_issue(&repo, &alice(), "Prefix test");
596 let short = &id[..8];
597
598 let (resolved_ref, resolved_id) = state::resolve_issue_ref(&repo, short).unwrap();
599 assert_eq!(resolved_id, id);
600 assert_eq!(resolved_ref, format!("refs/collab/issues/{}", id));
601 }
tests/sync_test.rs
Old New
@@ -0,0 +1,509 @@
1 //! End-to-end sync tests with two repos sharing a bare remote.
2 //!
3 //! Topology for every test:
4 //!
5 //! bare_remote <---push/fetch---> alice_repo
6 //! <---push/fetch---> bob_repo
7
8 use tempfile::TempDir;
9
10 use git2::Repository;
11 use git_collab::dag;
12 use git_collab::event::{Action, Author, Event, ReviewVerdict};
13 use git_collab::state::{self, IssueState, IssueStatus, PatchState};
14 use git_collab::sync;
15
16 // ---------------------------------------------------------------------------
17 // Helpers
18 // ---------------------------------------------------------------------------
19
20 fn alice() -> Author {
21 Author {
22 name: "Alice".to_string(),
23 email: "alice@example.com".to_string(),
24 }
25 }
26
27 fn bob() -> Author {
28 Author {
29 name: "Bob".to_string(),
30 email: "bob@example.com".to_string(),
31 }
32 }
33
34 fn now() -> String {
35 chrono::Utc::now().to_rfc3339()
36 }
37
38 /// Set up the standard test topology: bare remote + two clones.
39 /// Returns (bare_dir, alice_dir, bob_dir) — TempDirs that must be kept alive.
40 struct TestCluster {
41 _bare_dir: TempDir,
42 alice_dir: TempDir,
43 bob_dir: TempDir,
44 }
45
46 impl TestCluster {
47 fn new() -> Self {
48 let bare_dir = TempDir::new().unwrap();
49 let bare_repo = Repository::init_bare(bare_dir.path()).unwrap();
50
51 // Need at least one ref in the bare repo for clones to work,
52 // so we create a dummy initial commit on refs/heads/main.
53 {
54 let sig = git2::Signature::now("init", "init@test").unwrap();
55 let tree_oid = bare_repo.treebuilder(None).unwrap().write().unwrap();
56 let tree = bare_repo.find_tree(tree_oid).unwrap();
57 bare_repo
58 .commit(Some("refs/heads/main"), &sig, &sig, "init", &tree, &[])
59 .unwrap();
60 }
61 drop(bare_repo);
62
63 let alice_dir = TempDir::new().unwrap();
64 let bob_dir = TempDir::new().unwrap();
65
66 // Clone for Alice
67 let alice_repo =
68 Repository::clone(bare_dir.path().to_str().unwrap(), alice_dir.path()).unwrap();
69 {
70 let mut config = alice_repo.config().unwrap();
71 config.set_str("user.name", "Alice").unwrap();
72 config.set_str("user.email", "alice@example.com").unwrap();
73 }
74 sync::init(&alice_repo).unwrap();
75
76 // Clone for Bob
77 let bob_repo =
78 Repository::clone(bare_dir.path().to_str().unwrap(), bob_dir.path()).unwrap();
79 {
80 let mut config = bob_repo.config().unwrap();
81 config.set_str("user.name", "Bob").unwrap();
82 config.set_str("user.email", "bob@example.com").unwrap();
83 }
84 sync::init(&bob_repo).unwrap();
85
86 TestCluster {
87 _bare_dir: bare_dir,
88 alice_dir,
89 bob_dir,
90 }
91 }
92
93 fn alice_repo(&self) -> Repository {
94 Repository::open(self.alice_dir.path()).unwrap()
95 }
96
97 fn bob_repo(&self) -> Repository {
98 Repository::open(self.bob_dir.path()).unwrap()
99 }
100 }
101
102 fn open_issue(repo: &Repository, author: &Author, title: &str) -> (String, String) {
103 let event = Event {
104 timestamp: now(),
105 author: author.clone(),
106 action: Action::IssueOpen {
107 title: title.to_string(),
108 body: "".to_string(),
109 },
110 };
111 let oid = dag::create_root_event(repo, &event).unwrap();
112 let id = oid.to_string();
113 let ref_name = format!("refs/collab/issues/{}", id);
114 repo.reference(&ref_name, oid, false, "open").unwrap();
115 (ref_name, id)
116 }
117
118 fn add_comment(repo: &Repository, ref_name: &str, author: &Author, body: &str) {
119 let event = Event {
120 timestamp: now(),
121 author: author.clone(),
122 action: Action::IssueComment {
123 body: body.to_string(),
124 },
125 };
126 dag::append_event(repo, ref_name, &event).unwrap();
127 }
128
129 fn close_issue(repo: &Repository, ref_name: &str, author: &Author) {
130 let event = Event {
131 timestamp: now(),
132 author: author.clone(),
133 action: Action::IssueClose { reason: None },
134 };
135 dag::append_event(repo, ref_name, &event).unwrap();
136 }
137
138 // ---------------------------------------------------------------------------
139 // Tests
140 // ---------------------------------------------------------------------------
141
142 #[test]
143 fn test_alice_creates_issue_bob_syncs_and_sees_it() {
144 let cluster = TestCluster::new();
145 let alice_repo = cluster.alice_repo();
146 let bob_repo = cluster.bob_repo();
147
148 // Alice creates an issue and syncs
149 let (_ref_name, id) = open_issue(&alice_repo, &alice(), "Bug from Alice");
150 sync::sync(&alice_repo, "origin").unwrap();
151
152 // Bob syncs and should see the issue
153 sync::sync(&bob_repo, "origin").unwrap();
154
155 let bob_ref = format!("refs/collab/issues/{}", id);
156 let state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap();
157 assert_eq!(state.title, "Bug from Alice");
158 assert_eq!(state.author.name, "Alice");
159 assert_eq!(state.status, IssueStatus::Open);
160 }
161
162 #[test]
163 fn test_bob_comments_on_alice_issue_then_sync() {
164 let cluster = TestCluster::new();
165 let alice_repo = cluster.alice_repo();
166 let bob_repo = cluster.bob_repo();
167
168 // Alice creates issue, syncs
169 let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Needs discussion");
170 sync::sync(&alice_repo, "origin").unwrap();
171
172 // Bob syncs, sees the issue, adds a comment, syncs
173 sync::sync(&bob_repo, "origin").unwrap();
174 let bob_ref = format!("refs/collab/issues/{}", id);
175 add_comment(&bob_repo, &bob_ref, &bob(), "I have thoughts on this");
176 sync::sync(&bob_repo, "origin").unwrap();
177
178 // Alice syncs again — should see Bob's comment
179 sync::sync(&alice_repo, "origin").unwrap();
180 let state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
181 assert_eq!(state.comments.len(), 1);
182 assert_eq!(state.comments[0].author.name, "Bob");
183 assert_eq!(state.comments[0].body, "I have thoughts on this");
184 }
185
186 #[test]
187 fn test_concurrent_comments_sync_convergence() {
188 // Alice and Bob both comment on the same issue without syncing first.
189 // After both sync, they should converge to the same state.
190
191 let cluster = TestCluster::new();
192 let alice_repo = cluster.alice_repo();
193 let bob_repo = cluster.bob_repo();
194
195 // Alice creates issue, syncs so Bob can get it
196 let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Concurrent comments");
197 sync::sync(&alice_repo, "origin").unwrap();
198 sync::sync(&bob_repo, "origin").unwrap();
199
200 // Both comment independently (no sync between)
201 add_comment(&alice_repo, &alice_ref, &alice(), "Alice's take");
202 let bob_ref = format!("refs/collab/issues/{}", id);
203 add_comment(&bob_repo, &bob_ref, &bob(), "Bob's take");
204
205 // Alice syncs first — pushes her comment
206 sync::sync(&alice_repo, "origin").unwrap();
207
208 // Bob syncs — fetches Alice's comment, reconciles fork, pushes merge
209 sync::sync(&bob_repo, "origin").unwrap();
210
211 // Alice syncs again to get the merge
212 sync::sync(&alice_repo, "origin").unwrap();
213
214 // Both should now have the same state
215 let alice_state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
216 let bob_state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap();
217
218 assert_eq!(alice_state.comments.len(), 2, "Alice should see 2 comments");
219 assert_eq!(bob_state.comments.len(), 2, "Bob should see 2 comments");
220
221 // Both should have the same comment bodies (order may vary)
222 let mut alice_bodies: Vec<&str> = alice_state
223 .comments
224 .iter()
225 .map(|c| c.body.as_str())
226 .collect();
227 let mut bob_bodies: Vec<&str> = bob_state.comments.iter().map(|c| c.body.as_str()).collect();
228 alice_bodies.sort();
229 bob_bodies.sort();
230 assert_eq!(alice_bodies, bob_bodies);
231 assert!(alice_bodies.contains(&"Alice's take"));
232 assert!(alice_bodies.contains(&"Bob's take"));
233 }
234
235 #[test]
236 fn test_both_create_different_issues() {
237 // Alice and Bob each create their own issue without syncing.
238 // After sync, both should see both issues.
239
240 let cluster = TestCluster::new();
241 let alice_repo = cluster.alice_repo();
242 let bob_repo = cluster.bob_repo();
243
244 let (_, _alice_issue_id) = open_issue(&alice_repo, &alice(), "Alice's bug");
245 let (_, _bob_issue_id) = open_issue(&bob_repo, &bob(), "Bob's feature request");
246
247 // Alice syncs first
248 sync::sync(&alice_repo, "origin").unwrap();
249
250 // Bob syncs — gets Alice's issue, pushes his own
251 sync::sync(&bob_repo, "origin").unwrap();
252
253 // Alice syncs again — gets Bob's issue
254 sync::sync(&alice_repo, "origin").unwrap();
255
256 // Both repos should have both issues
257 let alice_issues = state::list_issues(&alice_repo).unwrap();
258 let bob_issues = state::list_issues(&bob_repo).unwrap();
259
260 assert_eq!(alice_issues.len(), 2);
261 assert_eq!(bob_issues.len(), 2);
262
263 let alice_titles: Vec<&str> = alice_issues.iter().map(|i| i.title.as_str()).collect();
264 assert!(alice_titles.contains(&"Alice's bug"));
265 assert!(alice_titles.contains(&"Bob's feature request"));
266 }
267
268 #[test]
269 fn test_alice_closes_while_bob_comments() {
270 // Alice closes an issue while Bob comments on it concurrently.
271
272 let cluster = TestCluster::new();
273 let alice_repo = cluster.alice_repo();
274 let bob_repo = cluster.bob_repo();
275
276 let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Close vs comment");
277 sync::sync(&alice_repo, "origin").unwrap();
278 sync::sync(&bob_repo, "origin").unwrap();
279
280 // Alice closes
281 close_issue(&alice_repo, &alice_ref, &alice());
282
283 // Bob comments
284 let bob_ref = format!("refs/collab/issues/{}", id);
285 add_comment(&bob_repo, &bob_ref, &bob(), "But wait...");
286
287 // Alice pushes first
288 sync::sync(&alice_repo, "origin").unwrap();
289
290 // Bob syncs — reconciles the fork
291 sync::sync(&bob_repo, "origin").unwrap();
292
293 // Alice syncs to get the merge
294 sync::sync(&alice_repo, "origin").unwrap();
295
296 // Both should see the comment AND the close
297 let alice_state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
298 let bob_state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap();
299
300 assert_eq!(alice_state.comments.len(), 1);
301 assert_eq!(bob_state.comments.len(), 1);
302 // Close happened, so status should be closed
303 // (both close and comment are in DAG; topo replay applies both)
304 assert_eq!(alice_state.status, bob_state.status);
305 }
306
307 #[test]
308 fn test_sync_idempotent() {
309 // Syncing twice in a row should be a no-op the second time.
310
311 let cluster = TestCluster::new();
312 let alice_repo = cluster.alice_repo();
313 let bob_repo = cluster.bob_repo();
314
315 let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Idempotent test");
316 add_comment(&alice_repo, &alice_ref, &alice(), "A comment");
317 sync::sync(&alice_repo, "origin").unwrap();
318
319 sync::sync(&bob_repo, "origin").unwrap();
320 // Sync again immediately — should not fail or duplicate
321 sync::sync(&bob_repo, "origin").unwrap();
322
323 let bob_ref = format!("refs/collab/issues/{}", id);
324 let state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap();
325 assert_eq!(state.comments.len(), 1, "no duplicate comments");
326 }
327
328 #[test]
329 fn test_three_user_convergence() {
330 // Three users (Alice, Bob, Charlie) all working on the same issue.
331 // Charlie uses Alice's repo path as a second remote.
332
333 let cluster = TestCluster::new();
334 let alice_repo = cluster.alice_repo();
335 let bob_repo = cluster.bob_repo();
336
337 // Alice creates issue, everyone syncs
338 let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Three users");
339 sync::sync(&alice_repo, "origin").unwrap();
340 sync::sync(&bob_repo, "origin").unwrap();
341
342 // Alice and Bob both comment
343 add_comment(&alice_repo, &alice_ref, &alice(), "Alice's comment");
344 let bob_ref = format!("refs/collab/issues/{}", id);
345 add_comment(&bob_repo, &bob_ref, &bob(), "Bob's comment");
346
347 // Alice syncs, Bob syncs, Alice syncs again (full convergence)
348 sync::sync(&alice_repo, "origin").unwrap();
349 sync::sync(&bob_repo, "origin").unwrap();
350 sync::sync(&alice_repo, "origin").unwrap();
351
352 let alice_state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
353 let bob_state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap();
354
355 assert_eq!(alice_state.comments.len(), 2);
356 assert_eq!(bob_state.comments.len(), 2);
357 }
358
359 #[test]
360 fn test_patch_review_across_repos() {
361 let cluster = TestCluster::new();
362 let alice_repo = cluster.alice_repo();
363 let bob_repo = cluster.bob_repo();
364
365 // Alice creates a patch
366 let event = Event {
367 timestamp: now(),
368 author: alice(),
369 action: Action::PatchCreate {
370 title: "Add feature X".to_string(),
371 body: "Please review".to_string(),
372 base_ref: "main".to_string(),
373 head_commit: "abc123".to_string(),
374 },
375 };
376 let oid = dag::create_root_event(&alice_repo, &event).unwrap();
377 let id = oid.to_string();
378 let alice_ref = format!("refs/collab/patches/{}", id);
379 alice_repo
380 .reference(&alice_ref, oid, false, "patch create")
381 .unwrap();
382
383 // Alice syncs
384 sync::sync(&alice_repo, "origin").unwrap();
385
386 // Bob syncs, reviews the patch
387 sync::sync(&bob_repo, "origin").unwrap();
388 let bob_ref = format!("refs/collab/patches/{}", id);
389 let review_event = Event {
390 timestamp: now(),
391 author: bob(),
392 action: Action::PatchReview {
393 verdict: ReviewVerdict::Approve,
394 body: "LGTM!".to_string(),
395 },
396 };
397 dag::append_event(&bob_repo, &bob_ref, &review_event).unwrap();
398 sync::sync(&bob_repo, "origin").unwrap();
399
400 // Alice syncs and sees the review
401 sync::sync(&alice_repo, "origin").unwrap();
402 let state = PatchState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
403 assert_eq!(state.reviews.len(), 1);
404 assert_eq!(state.reviews[0].verdict, ReviewVerdict::Approve);
405 assert_eq!(state.reviews[0].author.name, "Bob");
406 }
407
408 #[test]
409 fn test_concurrent_review_and_revise() {
410 // Bob reviews while Alice revises the patch concurrently.
411
412 let cluster = TestCluster::new();
413 let alice_repo = cluster.alice_repo();
414 let bob_repo = cluster.bob_repo();
415
416 // Alice creates patch, syncs
417 let event = Event {
418 timestamp: now(),
419 author: alice(),
420 action: Action::PatchCreate {
421 title: "WIP feature".to_string(),
422 body: "".to_string(),
423 base_ref: "main".to_string(),
424 head_commit: "v1".to_string(),
425 },
426 };
427 let oid = dag::create_root_event(&alice_repo, &event).unwrap();
428 let id = oid.to_string();
429 let alice_ref = format!("refs/collab/patches/{}", id);
430 alice_repo
431 .reference(&alice_ref, oid, false, "patch")
432 .unwrap();
433 sync::sync(&alice_repo, "origin").unwrap();
434 sync::sync(&bob_repo, "origin").unwrap();
435
436 // Alice revises (without syncing)
437 let revise_event = Event {
438 timestamp: now(),
439 author: alice(),
440 action: Action::PatchRevise {
441 body: Some("Updated description".to_string()),
442 head_commit: "v2".to_string(),
443 },
444 };
445 dag::append_event(&alice_repo, &alice_ref, &revise_event).unwrap();
446
447 // Bob reviews (without syncing)
448 let bob_ref = format!("refs/collab/patches/{}", id);
449 let review_event = Event {
450 timestamp: now(),
451 author: bob(),
452 action: Action::PatchReview {
453 verdict: ReviewVerdict::RequestChanges,
454 body: "Needs work".to_string(),
455 },
456 };
457 dag::append_event(&bob_repo, &bob_ref, &review_event).unwrap();
458
459 // Both sync
460 sync::sync(&alice_repo, "origin").unwrap();
461 sync::sync(&bob_repo, "origin").unwrap();
462 sync::sync(&alice_repo, "origin").unwrap();
463
464 // Both should see revise + review
465 let alice_state = PatchState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
466 let bob_state = PatchState::from_ref(&bob_repo, &bob_ref, &id).unwrap();
467
468 assert_eq!(alice_state.reviews.len(), 1);
469 assert_eq!(bob_state.reviews.len(), 1);
470 // The revise should have updated the head_commit
471 assert_eq!(alice_state.head_commit, bob_state.head_commit);
472 }
473
474 #[test]
475 fn test_multiple_rounds_of_sync() {
476 // Simulate a realistic back-and-forth conversation on an issue.
477
478 let cluster = TestCluster::new();
479 let alice_repo = cluster.alice_repo();
480 let bob_repo = cluster.bob_repo();
481
482 // Round 1: Alice opens issue
483 let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Discussion thread");
484 sync::sync(&alice_repo, "origin").unwrap();
485 sync::sync(&bob_repo, "origin").unwrap();
486
487 let bob_ref = format!("refs/collab/issues/{}", id);
488
489 // Round 2: Bob comments
490 add_comment(&bob_repo, &bob_ref, &bob(), "First response");
491 sync::sync(&bob_repo, "origin").unwrap();
492 sync::sync(&alice_repo, "origin").unwrap();
493
494 // Round 3: Alice replies
495 add_comment(&alice_repo, &alice_ref, &alice(), "Thanks for the input");
496 sync::sync(&alice_repo, "origin").unwrap();
497 sync::sync(&bob_repo, "origin").unwrap();
498
499 // Round 4: Bob closes
500 close_issue(&bob_repo, &bob_ref, &bob());
501 sync::sync(&bob_repo, "origin").unwrap();
502 sync::sync(&alice_repo, "origin").unwrap();
503
504 let state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
505 assert_eq!(state.comments.len(), 2);
506 assert_eq!(state.status, IssueStatus::Closed);
507 assert_eq!(state.comments[0].body, "First response");
508 assert_eq!(state.comments[1].body, "Thanks for the input");
509 }