5920e2cd
Add distributed issues and patch review over git
a73x 2026-03-20 20:22
Commit message
.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 | } | ||