├── .envrc ├── .github ├── renovate.json └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .prettierignore ├── CHANGELOG.md ├── CONVENTIONS.md ├── Cargo.lock ├── Cargo.toml ├── README.md ├── assets ├── README.md └── logo.png ├── build.rs ├── flake.lock ├── flake.nix ├── nix ├── default.nix ├── flake.lock └── flake.nix ├── rust-toolchain.toml └── src ├── branch_name.rs ├── cli.rs ├── default_branch.rs ├── disjoint_branch.rs ├── editor.rs ├── error.rs ├── execute.rs ├── git2_repository.rs ├── github_repository_metadata.rs ├── interact.rs ├── issue.rs ├── issue_group.rs ├── issue_group_map.rs ├── little_anyhow.rs ├── log_file.rs ├── main.rs ├── pull_request.rs ├── pull_request_message.rs └── pull_request_metadata.rs /.envrc: -------------------------------------------------------------------------------- 1 | watch_file nix/default.nix 2 | use flake ./nix 3 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:js-app" 4 | ], 5 | "labels": [ 6 | "dependencies" 7 | ], 8 | "assignees": [ 9 | "@ericcrosson" 10 | ], 11 | "packageRules": [ 12 | { 13 | "matchUpdateTypes": [ 14 | "minor", 15 | "patch", 16 | "pin", 17 | "digest" 18 | ], 19 | "automerge": true 20 | } 21 | ], 22 | "timezone": "America/Chicago", 23 | "schedule": [ 24 | "after 10pm every weekday", 25 | "before 5am every weekday", 26 | "every weekend" 27 | ] 28 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | pull_request: 6 | 7 | jobs: 8 | test: 9 | uses: semantic-release-action/rust/.github/workflows/ci.yml@v5 10 | with: 11 | toolchain: nightly 12 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | 4 | on: 5 | push: 6 | branches: 7 | - master 8 | - next 9 | - next-major 10 | - beta 11 | - alpha 12 | - "[0-9]+.[0-9]+.x" 13 | - "[0-9]+.x" 14 | 15 | jobs: 16 | release: 17 | uses: semantic-release-action/rust/.github/workflows/release-binary.yml@v5 18 | with: 19 | toolchain: nightly 20 | github_app_id: ${{ vars.SEMANTIC_RELEASE_GITHUB_APP_ID }} 21 | secrets: 22 | cargo-registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }} 23 | github_app_private_key: ${{ secrets.SEMANTIC_RELEASE_GITHUB_APP_PRIVATE_KEY }} 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.pre-commit-config.yaml 2 | /.direnv/ 3 | /node_modules 4 | /result 5 | /target 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | flake.lock 2 | -------------------------------------------------------------------------------- /CONVENTIONS.md: -------------------------------------------------------------------------------- 1 | # Git-Disjoint Conventions 2 | 3 | ## Project Overview 4 | 5 | Git-disjoint is a tool that helps developers manage multiple related commits across different issues without manual branch management. 6 | It identifies commits by their issue references in commit messages, groups them appropriately, and creates pull requests automatically. 7 | 8 | ## Core Functionality 9 | 10 | 1. **Issue Identification**: Git-disjoint parses commit messages for issue references (e.g., "Fixes #123" or "JIRA-456") to group related commits. 11 | 12 | 2. **Automatic Branch Creation**: Creates branches for each issue group without requiring manual branch management. 13 | 14 | 3. **Pull Request Generation**: Automatically creates pull requests for each issue group, linking them to the appropriate issue tracker items. 15 | 16 | ## Workflow Example 17 | 18 | 1. Developer makes several commits directly to their local master branch 19 | 2. Each commit includes proper issue references in the commit message 20 | 3. When ready to submit changes, developer runs `git-disjoint` 21 | 4. The tool identifies related commits, creates branches, and submits PRs 22 | 5. After PRs are merged, developer pulls changes, advancing the default branch pointer beyond the local commits, concluding the workflow. 23 | 24 | ## Development Guidelines 25 | 26 | 1. **Commit Messages**: Include issue references as trailers in commit messages (e.g., "Fixes #123") 27 | 2. **Error Handling**: Use modular errors, as described in [Modular Errors in Rust](https://sabrinajewson.org/blog/errors). Never use the `anyhow` crate - the project should use custom, well-defined error types that provide clear context and follow the modular error pattern. 28 | 3. **Testing**: Write tests for all functionality. Avoid unnecessary coupling of tests to internal implementation details, so refactoring the implementation does not break tests. Tests should be written from the user's perspective and exhaustively cover happy paths and edge cases. Practice test-driven development. 29 | 4. **Code Style**: Follow Rust idioms. Use ports and adapters, implemented as traits and impls. Use a cargo workspace, organized as described in https://matklad.github.io/2021/08/22/large-rust-workspaces.html 30 | 5. **Usability**: The primary goal of this package is to offer a supreme developer experience. This means beautiful output, clear error messages, and simulating an operation before attempting it, to prevent leaving the user's git repository in an unfamiliar, broken state. 31 | 6. **Dependencies**: It is a goal to reduce the number of production dependencies to zero, or as near to it as is practical. Stability of any existing dependencies is paramount, minimizing the need to update dependencies and risk breaking changes or manual intervention to adapt to a new crate API. 32 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "addr2line" 7 | version = "0.21.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler" 16 | version = "1.0.2" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" 19 | 20 | [[package]] 21 | name = "aho-corasick" 22 | version = "1.1.2" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" 25 | dependencies = [ 26 | "memchr", 27 | ] 28 | 29 | [[package]] 30 | name = "anstream" 31 | version = "0.6.11" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "6e2e1ebcb11de5c03c67de28a7df593d32191b44939c482e97702baaaa6ab6a5" 34 | dependencies = [ 35 | "anstyle", 36 | "anstyle-parse", 37 | "anstyle-query", 38 | "anstyle-wincon", 39 | "colorchoice", 40 | "utf8parse", 41 | ] 42 | 43 | [[package]] 44 | name = "anstyle" 45 | version = "1.0.8" 46 | source = "registry+https://github.com/rust-lang/crates.io-index" 47 | checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" 48 | 49 | [[package]] 50 | name = "anstyle-parse" 51 | version = "0.2.3" 52 | source = "registry+https://github.com/rust-lang/crates.io-index" 53 | checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" 54 | dependencies = [ 55 | "utf8parse", 56 | ] 57 | 58 | [[package]] 59 | name = "anstyle-query" 60 | version = "1.0.2" 61 | source = "registry+https://github.com/rust-lang/crates.io-index" 62 | checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" 63 | dependencies = [ 64 | "windows-sys 0.52.0", 65 | ] 66 | 67 | [[package]] 68 | name = "anstyle-wincon" 69 | version = "3.0.2" 70 | source = "registry+https://github.com/rust-lang/crates.io-index" 71 | checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" 72 | dependencies = [ 73 | "anstyle", 74 | "windows-sys 0.52.0", 75 | ] 76 | 77 | [[package]] 78 | name = "autocfg" 79 | version = "1.1.0" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 82 | 83 | [[package]] 84 | name = "backtrace" 85 | version = "0.3.69" 86 | source = "registry+https://github.com/rust-lang/crates.io-index" 87 | checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" 88 | dependencies = [ 89 | "addr2line", 90 | "cc", 91 | "cfg-if", 92 | "libc", 93 | "miniz_oxide", 94 | "object", 95 | "rustc-demangle", 96 | ] 97 | 98 | [[package]] 99 | name = "base64" 100 | version = "0.22.0" 101 | source = "registry+https://github.com/rust-lang/crates.io-index" 102 | checksum = "9475866fec1451be56a3c2400fd081ff546538961565ccb5b7142cbd22bc7a51" 103 | 104 | [[package]] 105 | name = "bit-set" 106 | version = "0.8.0" 107 | source = "registry+https://github.com/rust-lang/crates.io-index" 108 | checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" 109 | dependencies = [ 110 | "bit-vec", 111 | ] 112 | 113 | [[package]] 114 | name = "bit-vec" 115 | version = "0.8.0" 116 | source = "registry+https://github.com/rust-lang/crates.io-index" 117 | checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" 118 | 119 | [[package]] 120 | name = "bitflags" 121 | version = "1.3.2" 122 | source = "registry+https://github.com/rust-lang/crates.io-index" 123 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 124 | 125 | [[package]] 126 | name = "bitflags" 127 | version = "2.9.1" 128 | source = "registry+https://github.com/rust-lang/crates.io-index" 129 | checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" 130 | 131 | [[package]] 132 | name = "bumpalo" 133 | version = "3.14.0" 134 | source = "registry+https://github.com/rust-lang/crates.io-index" 135 | checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" 136 | 137 | [[package]] 138 | name = "byteorder" 139 | version = "1.5.0" 140 | source = "registry+https://github.com/rust-lang/crates.io-index" 141 | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 142 | 143 | [[package]] 144 | name = "bytes" 145 | version = "1.9.0" 146 | source = "registry+https://github.com/rust-lang/crates.io-index" 147 | checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" 148 | 149 | [[package]] 150 | name = "cc" 151 | version = "1.2.16" 152 | source = "registry+https://github.com/rust-lang/crates.io-index" 153 | checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" 154 | dependencies = [ 155 | "jobserver", 156 | "libc", 157 | "shlex", 158 | ] 159 | 160 | [[package]] 161 | name = "cfg-if" 162 | version = "1.0.0" 163 | source = "registry+https://github.com/rust-lang/crates.io-index" 164 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 165 | 166 | [[package]] 167 | name = "clap" 168 | version = "4.5.39" 169 | source = "registry+https://github.com/rust-lang/crates.io-index" 170 | checksum = "fd60e63e9be68e5fb56422e397cf9baddded06dae1d2e523401542383bc72a9f" 171 | dependencies = [ 172 | "clap_builder", 173 | "clap_derive", 174 | ] 175 | 176 | [[package]] 177 | name = "clap_builder" 178 | version = "4.5.39" 179 | source = "registry+https://github.com/rust-lang/crates.io-index" 180 | checksum = "89cc6392a1f72bbeb820d71f32108f61fdaf18bc526e1d23954168a67759ef51" 181 | dependencies = [ 182 | "anstream", 183 | "anstyle", 184 | "clap_lex", 185 | "strsim", 186 | "terminal_size", 187 | ] 188 | 189 | [[package]] 190 | name = "clap_complete" 191 | version = "4.5.54" 192 | source = "registry+https://github.com/rust-lang/crates.io-index" 193 | checksum = "aad5b1b4de04fead402672b48897030eec1f3bfe1550776322f59f6d6e6a5677" 194 | dependencies = [ 195 | "clap", 196 | ] 197 | 198 | [[package]] 199 | name = "clap_derive" 200 | version = "4.5.32" 201 | source = "registry+https://github.com/rust-lang/crates.io-index" 202 | checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" 203 | dependencies = [ 204 | "heck", 205 | "proc-macro2", 206 | "quote", 207 | "syn", 208 | ] 209 | 210 | [[package]] 211 | name = "clap_lex" 212 | version = "0.7.4" 213 | source = "registry+https://github.com/rust-lang/crates.io-index" 214 | checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" 215 | 216 | [[package]] 217 | name = "clap_mangen" 218 | version = "0.2.27" 219 | source = "registry+https://github.com/rust-lang/crates.io-index" 220 | checksum = "fc33c849748320656a90832f54a5eeecaa598e92557fb5dedebc3355746d31e4" 221 | dependencies = [ 222 | "clap", 223 | "roff", 224 | ] 225 | 226 | [[package]] 227 | name = "cmake" 228 | version = "0.1.50" 229 | source = "registry+https://github.com/rust-lang/crates.io-index" 230 | checksum = "a31c789563b815f77f4250caee12365734369f942439b7defd71e18a48197130" 231 | dependencies = [ 232 | "cc", 233 | ] 234 | 235 | [[package]] 236 | name = "colorchoice" 237 | version = "1.0.0" 238 | source = "registry+https://github.com/rust-lang/crates.io-index" 239 | checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" 240 | 241 | [[package]] 242 | name = "console" 243 | version = "0.15.8" 244 | source = "registry+https://github.com/rust-lang/crates.io-index" 245 | checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" 246 | dependencies = [ 247 | "encode_unicode", 248 | "lazy_static", 249 | "libc", 250 | "unicode-width 0.1.11", 251 | "windows-sys 0.52.0", 252 | ] 253 | 254 | [[package]] 255 | name = "crossterm" 256 | version = "0.25.0" 257 | source = "registry+https://github.com/rust-lang/crates.io-index" 258 | checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" 259 | dependencies = [ 260 | "bitflags 1.3.2", 261 | "crossterm_winapi", 262 | "libc", 263 | "mio 0.8.10", 264 | "parking_lot", 265 | "signal-hook", 266 | "signal-hook-mio", 267 | "winapi", 268 | ] 269 | 270 | [[package]] 271 | name = "crossterm_winapi" 272 | version = "0.9.1" 273 | source = "registry+https://github.com/rust-lang/crates.io-index" 274 | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" 275 | dependencies = [ 276 | "winapi", 277 | ] 278 | 279 | [[package]] 280 | name = "displaydoc" 281 | version = "0.2.5" 282 | source = "registry+https://github.com/rust-lang/crates.io-index" 283 | checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" 284 | dependencies = [ 285 | "proc-macro2", 286 | "quote", 287 | "syn", 288 | ] 289 | 290 | [[package]] 291 | name = "dyn-clone" 292 | version = "1.0.16" 293 | source = "registry+https://github.com/rust-lang/crates.io-index" 294 | checksum = "545b22097d44f8a9581187cdf93de7a71e4722bf51200cfaba810865b49a495d" 295 | 296 | [[package]] 297 | name = "encode_unicode" 298 | version = "0.3.6" 299 | source = "registry+https://github.com/rust-lang/crates.io-index" 300 | checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" 301 | 302 | [[package]] 303 | name = "equivalent" 304 | version = "1.0.1" 305 | source = "registry+https://github.com/rust-lang/crates.io-index" 306 | checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 307 | 308 | [[package]] 309 | name = "errno" 310 | version = "0.3.8" 311 | source = "registry+https://github.com/rust-lang/crates.io-index" 312 | checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" 313 | dependencies = [ 314 | "libc", 315 | "windows-sys 0.52.0", 316 | ] 317 | 318 | [[package]] 319 | name = "fastrand" 320 | version = "2.0.1" 321 | source = "registry+https://github.com/rust-lang/crates.io-index" 322 | checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" 323 | 324 | [[package]] 325 | name = "fnv" 326 | version = "1.0.7" 327 | source = "registry+https://github.com/rust-lang/crates.io-index" 328 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 329 | 330 | [[package]] 331 | name = "form_urlencoded" 332 | version = "1.2.1" 333 | source = "registry+https://github.com/rust-lang/crates.io-index" 334 | checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" 335 | dependencies = [ 336 | "percent-encoding", 337 | ] 338 | 339 | [[package]] 340 | name = "futures-channel" 341 | version = "0.3.30" 342 | source = "registry+https://github.com/rust-lang/crates.io-index" 343 | checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" 344 | dependencies = [ 345 | "futures-core", 346 | "futures-sink", 347 | ] 348 | 349 | [[package]] 350 | name = "futures-core" 351 | version = "0.3.30" 352 | source = "registry+https://github.com/rust-lang/crates.io-index" 353 | checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" 354 | 355 | [[package]] 356 | name = "futures-io" 357 | version = "0.3.30" 358 | source = "registry+https://github.com/rust-lang/crates.io-index" 359 | checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" 360 | 361 | [[package]] 362 | name = "futures-sink" 363 | version = "0.3.30" 364 | source = "registry+https://github.com/rust-lang/crates.io-index" 365 | checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" 366 | 367 | [[package]] 368 | name = "futures-task" 369 | version = "0.3.30" 370 | source = "registry+https://github.com/rust-lang/crates.io-index" 371 | checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" 372 | 373 | [[package]] 374 | name = "futures-util" 375 | version = "0.3.30" 376 | source = "registry+https://github.com/rust-lang/crates.io-index" 377 | checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" 378 | dependencies = [ 379 | "futures-core", 380 | "futures-io", 381 | "futures-sink", 382 | "futures-task", 383 | "memchr", 384 | "pin-project-lite", 385 | "pin-utils", 386 | "slab", 387 | ] 388 | 389 | [[package]] 390 | name = "fuzzy-matcher" 391 | version = "0.3.7" 392 | source = "registry+https://github.com/rust-lang/crates.io-index" 393 | checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" 394 | dependencies = [ 395 | "thread_local", 396 | ] 397 | 398 | [[package]] 399 | name = "fxhash" 400 | version = "0.2.1" 401 | source = "registry+https://github.com/rust-lang/crates.io-index" 402 | checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" 403 | dependencies = [ 404 | "byteorder", 405 | ] 406 | 407 | [[package]] 408 | name = "getrandom" 409 | version = "0.2.12" 410 | source = "registry+https://github.com/rust-lang/crates.io-index" 411 | checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" 412 | dependencies = [ 413 | "cfg-if", 414 | "js-sys", 415 | "libc", 416 | "wasi 0.11.0+wasi-snapshot-preview1", 417 | "wasm-bindgen", 418 | ] 419 | 420 | [[package]] 421 | name = "getrandom" 422 | version = "0.3.3" 423 | source = "registry+https://github.com/rust-lang/crates.io-index" 424 | checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" 425 | dependencies = [ 426 | "cfg-if", 427 | "libc", 428 | "r-efi", 429 | "wasi 0.14.2+wasi-0.2.4", 430 | ] 431 | 432 | [[package]] 433 | name = "gimli" 434 | version = "0.28.1" 435 | source = "registry+https://github.com/rust-lang/crates.io-index" 436 | checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" 437 | 438 | [[package]] 439 | name = "git-disjoint" 440 | version = "0.10.278" 441 | dependencies = [ 442 | "clap", 443 | "clap_complete", 444 | "clap_mangen", 445 | "git2", 446 | "indexmap", 447 | "indicatif", 448 | "inquire", 449 | "open", 450 | "parse-git-url", 451 | "proptest", 452 | "proptest-derive", 453 | "regex", 454 | "reqwest", 455 | "sanitize-git-ref", 456 | "serde", 457 | "serde_json", 458 | ] 459 | 460 | [[package]] 461 | name = "git2" 462 | version = "0.20.2" 463 | source = "registry+https://github.com/rust-lang/crates.io-index" 464 | checksum = "2deb07a133b1520dc1a5690e9bd08950108873d7ed5de38dcc74d3b5ebffa110" 465 | dependencies = [ 466 | "bitflags 2.9.1", 467 | "libc", 468 | "libgit2-sys", 469 | "log", 470 | "url", 471 | ] 472 | 473 | [[package]] 474 | name = "hashbrown" 475 | version = "0.15.2" 476 | source = "registry+https://github.com/rust-lang/crates.io-index" 477 | checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" 478 | 479 | [[package]] 480 | name = "heck" 481 | version = "0.5.0" 482 | source = "registry+https://github.com/rust-lang/crates.io-index" 483 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 484 | 485 | [[package]] 486 | name = "http" 487 | version = "1.1.0" 488 | source = "registry+https://github.com/rust-lang/crates.io-index" 489 | checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" 490 | dependencies = [ 491 | "bytes", 492 | "fnv", 493 | "itoa", 494 | ] 495 | 496 | [[package]] 497 | name = "http-body" 498 | version = "1.0.0" 499 | source = "registry+https://github.com/rust-lang/crates.io-index" 500 | checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" 501 | dependencies = [ 502 | "bytes", 503 | "http", 504 | ] 505 | 506 | [[package]] 507 | name = "http-body-util" 508 | version = "0.1.1" 509 | source = "registry+https://github.com/rust-lang/crates.io-index" 510 | checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d" 511 | dependencies = [ 512 | "bytes", 513 | "futures-core", 514 | "http", 515 | "http-body", 516 | "pin-project-lite", 517 | ] 518 | 519 | [[package]] 520 | name = "httparse" 521 | version = "1.10.1" 522 | source = "registry+https://github.com/rust-lang/crates.io-index" 523 | checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 524 | 525 | [[package]] 526 | name = "hyper" 527 | version = "1.6.0" 528 | source = "registry+https://github.com/rust-lang/crates.io-index" 529 | checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" 530 | dependencies = [ 531 | "bytes", 532 | "futures-channel", 533 | "futures-util", 534 | "http", 535 | "http-body", 536 | "httparse", 537 | "itoa", 538 | "pin-project-lite", 539 | "smallvec", 540 | "tokio", 541 | "want", 542 | ] 543 | 544 | [[package]] 545 | name = "hyper-rustls" 546 | version = "0.27.2" 547 | source = "registry+https://github.com/rust-lang/crates.io-index" 548 | checksum = "5ee4be2c948921a1a5320b629c4193916ed787a7f7f293fd3f7f5a6c9de74155" 549 | dependencies = [ 550 | "futures-util", 551 | "http", 552 | "hyper", 553 | "hyper-util", 554 | "rustls", 555 | "rustls-pki-types", 556 | "tokio", 557 | "tokio-rustls", 558 | "tower-service", 559 | "webpki-roots 0.26.1", 560 | ] 561 | 562 | [[package]] 563 | name = "hyper-util" 564 | version = "0.1.13" 565 | source = "registry+https://github.com/rust-lang/crates.io-index" 566 | checksum = "b1c293b6b3d21eca78250dc7dbebd6b9210ec5530e038cbfe0661b5c47ab06e8" 567 | dependencies = [ 568 | "base64", 569 | "bytes", 570 | "futures-channel", 571 | "futures-core", 572 | "futures-util", 573 | "http", 574 | "http-body", 575 | "hyper", 576 | "ipnet", 577 | "libc", 578 | "percent-encoding", 579 | "pin-project-lite", 580 | "socket2", 581 | "tokio", 582 | "tower-service", 583 | "tracing", 584 | ] 585 | 586 | [[package]] 587 | name = "icu_collections" 588 | version = "1.5.0" 589 | source = "registry+https://github.com/rust-lang/crates.io-index" 590 | checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" 591 | dependencies = [ 592 | "displaydoc", 593 | "yoke", 594 | "zerofrom", 595 | "zerovec", 596 | ] 597 | 598 | [[package]] 599 | name = "icu_locid" 600 | version = "1.5.0" 601 | source = "registry+https://github.com/rust-lang/crates.io-index" 602 | checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" 603 | dependencies = [ 604 | "displaydoc", 605 | "litemap", 606 | "tinystr", 607 | "writeable", 608 | "zerovec", 609 | ] 610 | 611 | [[package]] 612 | name = "icu_locid_transform" 613 | version = "1.5.0" 614 | source = "registry+https://github.com/rust-lang/crates.io-index" 615 | checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" 616 | dependencies = [ 617 | "displaydoc", 618 | "icu_locid", 619 | "icu_locid_transform_data", 620 | "icu_provider", 621 | "tinystr", 622 | "zerovec", 623 | ] 624 | 625 | [[package]] 626 | name = "icu_locid_transform_data" 627 | version = "1.5.0" 628 | source = "registry+https://github.com/rust-lang/crates.io-index" 629 | checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" 630 | 631 | [[package]] 632 | name = "icu_normalizer" 633 | version = "1.5.0" 634 | source = "registry+https://github.com/rust-lang/crates.io-index" 635 | checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" 636 | dependencies = [ 637 | "displaydoc", 638 | "icu_collections", 639 | "icu_normalizer_data", 640 | "icu_properties", 641 | "icu_provider", 642 | "smallvec", 643 | "utf16_iter", 644 | "utf8_iter", 645 | "write16", 646 | "zerovec", 647 | ] 648 | 649 | [[package]] 650 | name = "icu_normalizer_data" 651 | version = "1.5.0" 652 | source = "registry+https://github.com/rust-lang/crates.io-index" 653 | checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" 654 | 655 | [[package]] 656 | name = "icu_properties" 657 | version = "1.5.1" 658 | source = "registry+https://github.com/rust-lang/crates.io-index" 659 | checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" 660 | dependencies = [ 661 | "displaydoc", 662 | "icu_collections", 663 | "icu_locid_transform", 664 | "icu_properties_data", 665 | "icu_provider", 666 | "tinystr", 667 | "zerovec", 668 | ] 669 | 670 | [[package]] 671 | name = "icu_properties_data" 672 | version = "1.5.0" 673 | source = "registry+https://github.com/rust-lang/crates.io-index" 674 | checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" 675 | 676 | [[package]] 677 | name = "icu_provider" 678 | version = "1.5.0" 679 | source = "registry+https://github.com/rust-lang/crates.io-index" 680 | checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" 681 | dependencies = [ 682 | "displaydoc", 683 | "icu_locid", 684 | "icu_provider_macros", 685 | "stable_deref_trait", 686 | "tinystr", 687 | "writeable", 688 | "yoke", 689 | "zerofrom", 690 | "zerovec", 691 | ] 692 | 693 | [[package]] 694 | name = "icu_provider_macros" 695 | version = "1.5.0" 696 | source = "registry+https://github.com/rust-lang/crates.io-index" 697 | checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" 698 | dependencies = [ 699 | "proc-macro2", 700 | "quote", 701 | "syn", 702 | ] 703 | 704 | [[package]] 705 | name = "idna" 706 | version = "1.0.3" 707 | source = "registry+https://github.com/rust-lang/crates.io-index" 708 | checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" 709 | dependencies = [ 710 | "idna_adapter", 711 | "smallvec", 712 | "utf8_iter", 713 | ] 714 | 715 | [[package]] 716 | name = "idna_adapter" 717 | version = "1.2.0" 718 | source = "registry+https://github.com/rust-lang/crates.io-index" 719 | checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" 720 | dependencies = [ 721 | "icu_normalizer", 722 | "icu_properties", 723 | ] 724 | 725 | [[package]] 726 | name = "indexmap" 727 | version = "2.9.0" 728 | source = "registry+https://github.com/rust-lang/crates.io-index" 729 | checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" 730 | dependencies = [ 731 | "equivalent", 732 | "hashbrown", 733 | ] 734 | 735 | [[package]] 736 | name = "indicatif" 737 | version = "0.17.11" 738 | source = "registry+https://github.com/rust-lang/crates.io-index" 739 | checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" 740 | dependencies = [ 741 | "console", 742 | "number_prefix", 743 | "portable-atomic", 744 | "unicode-width 0.2.0", 745 | "web-time", 746 | ] 747 | 748 | [[package]] 749 | name = "inquire" 750 | version = "0.7.5" 751 | source = "registry+https://github.com/rust-lang/crates.io-index" 752 | checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" 753 | dependencies = [ 754 | "bitflags 2.9.1", 755 | "crossterm", 756 | "dyn-clone", 757 | "fuzzy-matcher", 758 | "fxhash", 759 | "newline-converter", 760 | "once_cell", 761 | "unicode-segmentation", 762 | "unicode-width 0.1.11", 763 | ] 764 | 765 | [[package]] 766 | name = "ipnet" 767 | version = "2.9.0" 768 | source = "registry+https://github.com/rust-lang/crates.io-index" 769 | checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" 770 | 771 | [[package]] 772 | name = "iri-string" 773 | version = "0.7.8" 774 | source = "registry+https://github.com/rust-lang/crates.io-index" 775 | checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" 776 | dependencies = [ 777 | "memchr", 778 | "serde", 779 | ] 780 | 781 | [[package]] 782 | name = "is-docker" 783 | version = "0.2.0" 784 | source = "registry+https://github.com/rust-lang/crates.io-index" 785 | checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" 786 | dependencies = [ 787 | "once_cell", 788 | ] 789 | 790 | [[package]] 791 | name = "is-wsl" 792 | version = "0.4.0" 793 | source = "registry+https://github.com/rust-lang/crates.io-index" 794 | checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" 795 | dependencies = [ 796 | "is-docker", 797 | "once_cell", 798 | ] 799 | 800 | [[package]] 801 | name = "itoa" 802 | version = "1.0.10" 803 | source = "registry+https://github.com/rust-lang/crates.io-index" 804 | checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" 805 | 806 | [[package]] 807 | name = "jobserver" 808 | version = "0.1.32" 809 | source = "registry+https://github.com/rust-lang/crates.io-index" 810 | checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" 811 | dependencies = [ 812 | "libc", 813 | ] 814 | 815 | [[package]] 816 | name = "js-sys" 817 | version = "0.3.77" 818 | source = "registry+https://github.com/rust-lang/crates.io-index" 819 | checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" 820 | dependencies = [ 821 | "once_cell", 822 | "wasm-bindgen", 823 | ] 824 | 825 | [[package]] 826 | name = "lazy_static" 827 | version = "1.4.0" 828 | source = "registry+https://github.com/rust-lang/crates.io-index" 829 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 830 | 831 | [[package]] 832 | name = "libc" 833 | version = "0.2.171" 834 | source = "registry+https://github.com/rust-lang/crates.io-index" 835 | checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" 836 | 837 | [[package]] 838 | name = "libgit2-sys" 839 | version = "0.18.1+1.9.0" 840 | source = "registry+https://github.com/rust-lang/crates.io-index" 841 | checksum = "e1dcb20f84ffcdd825c7a311ae347cce604a6f084a767dec4a4929829645290e" 842 | dependencies = [ 843 | "cc", 844 | "libc", 845 | "libssh2-sys", 846 | "libz-sys", 847 | "pkg-config", 848 | ] 849 | 850 | [[package]] 851 | name = "libssh2-sys" 852 | version = "0.3.0" 853 | source = "registry+https://github.com/rust-lang/crates.io-index" 854 | checksum = "2dc8a030b787e2119a731f1951d6a773e2280c660f8ec4b0f5e1505a386e71ee" 855 | dependencies = [ 856 | "cc", 857 | "libc", 858 | "libz-sys", 859 | "openssl-sys", 860 | "pkg-config", 861 | "vcpkg", 862 | ] 863 | 864 | [[package]] 865 | name = "libz-sys" 866 | version = "1.1.15" 867 | source = "registry+https://github.com/rust-lang/crates.io-index" 868 | checksum = "037731f5d3aaa87a5675e895b63ddff1a87624bc29f77004ea829809654e48f6" 869 | dependencies = [ 870 | "cc", 871 | "cmake", 872 | "libc", 873 | "pkg-config", 874 | "vcpkg", 875 | ] 876 | 877 | [[package]] 878 | name = "linux-raw-sys" 879 | version = "0.4.13" 880 | source = "registry+https://github.com/rust-lang/crates.io-index" 881 | checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" 882 | 883 | [[package]] 884 | name = "litemap" 885 | version = "0.7.5" 886 | source = "registry+https://github.com/rust-lang/crates.io-index" 887 | checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" 888 | 889 | [[package]] 890 | name = "lock_api" 891 | version = "0.4.11" 892 | source = "registry+https://github.com/rust-lang/crates.io-index" 893 | checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" 894 | dependencies = [ 895 | "autocfg", 896 | "scopeguard", 897 | ] 898 | 899 | [[package]] 900 | name = "log" 901 | version = "0.4.20" 902 | source = "registry+https://github.com/rust-lang/crates.io-index" 903 | checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" 904 | 905 | [[package]] 906 | name = "memchr" 907 | version = "2.7.1" 908 | source = "registry+https://github.com/rust-lang/crates.io-index" 909 | checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" 910 | 911 | [[package]] 912 | name = "mime" 913 | version = "0.3.17" 914 | source = "registry+https://github.com/rust-lang/crates.io-index" 915 | checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 916 | 917 | [[package]] 918 | name = "miniz_oxide" 919 | version = "0.7.1" 920 | source = "registry+https://github.com/rust-lang/crates.io-index" 921 | checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" 922 | dependencies = [ 923 | "adler", 924 | ] 925 | 926 | [[package]] 927 | name = "mio" 928 | version = "0.8.10" 929 | source = "registry+https://github.com/rust-lang/crates.io-index" 930 | checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" 931 | dependencies = [ 932 | "libc", 933 | "log", 934 | "wasi 0.11.0+wasi-snapshot-preview1", 935 | "windows-sys 0.48.0", 936 | ] 937 | 938 | [[package]] 939 | name = "mio" 940 | version = "1.0.3" 941 | source = "registry+https://github.com/rust-lang/crates.io-index" 942 | checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" 943 | dependencies = [ 944 | "libc", 945 | "wasi 0.11.0+wasi-snapshot-preview1", 946 | "windows-sys 0.52.0", 947 | ] 948 | 949 | [[package]] 950 | name = "newline-converter" 951 | version = "0.3.0" 952 | source = "registry+https://github.com/rust-lang/crates.io-index" 953 | checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" 954 | dependencies = [ 955 | "unicode-segmentation", 956 | ] 957 | 958 | [[package]] 959 | name = "num-traits" 960 | version = "0.2.17" 961 | source = "registry+https://github.com/rust-lang/crates.io-index" 962 | checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c" 963 | dependencies = [ 964 | "autocfg", 965 | ] 966 | 967 | [[package]] 968 | name = "number_prefix" 969 | version = "0.4.0" 970 | source = "registry+https://github.com/rust-lang/crates.io-index" 971 | checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" 972 | 973 | [[package]] 974 | name = "object" 975 | version = "0.32.2" 976 | source = "registry+https://github.com/rust-lang/crates.io-index" 977 | checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" 978 | dependencies = [ 979 | "memchr", 980 | ] 981 | 982 | [[package]] 983 | name = "once_cell" 984 | version = "1.21.1" 985 | source = "registry+https://github.com/rust-lang/crates.io-index" 986 | checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc" 987 | 988 | [[package]] 989 | name = "open" 990 | version = "5.3.2" 991 | source = "registry+https://github.com/rust-lang/crates.io-index" 992 | checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95" 993 | dependencies = [ 994 | "is-wsl", 995 | "libc", 996 | "pathdiff", 997 | ] 998 | 999 | [[package]] 1000 | name = "openssl-sys" 1001 | version = "0.9.99" 1002 | source = "registry+https://github.com/rust-lang/crates.io-index" 1003 | checksum = "22e1bf214306098e4832460f797824c05d25aacdf896f64a985fb0fd992454ae" 1004 | dependencies = [ 1005 | "cc", 1006 | "libc", 1007 | "pkg-config", 1008 | "vcpkg", 1009 | ] 1010 | 1011 | [[package]] 1012 | name = "parking_lot" 1013 | version = "0.12.1" 1014 | source = "registry+https://github.com/rust-lang/crates.io-index" 1015 | checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" 1016 | dependencies = [ 1017 | "lock_api", 1018 | "parking_lot_core", 1019 | ] 1020 | 1021 | [[package]] 1022 | name = "parking_lot_core" 1023 | version = "0.9.9" 1024 | source = "registry+https://github.com/rust-lang/crates.io-index" 1025 | checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" 1026 | dependencies = [ 1027 | "cfg-if", 1028 | "libc", 1029 | "redox_syscall", 1030 | "smallvec", 1031 | "windows-targets 0.48.5", 1032 | ] 1033 | 1034 | [[package]] 1035 | name = "parse-git-url" 1036 | version = "0.5.1" 1037 | source = "registry+https://github.com/rust-lang/crates.io-index" 1038 | checksum = "9cd626725d3855a68fdede6483fae43429129bf246f42d8db598911c8036cf47" 1039 | dependencies = [ 1040 | "tracing", 1041 | "url", 1042 | ] 1043 | 1044 | [[package]] 1045 | name = "pathdiff" 1046 | version = "0.2.1" 1047 | source = "registry+https://github.com/rust-lang/crates.io-index" 1048 | checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" 1049 | 1050 | [[package]] 1051 | name = "percent-encoding" 1052 | version = "2.3.1" 1053 | source = "registry+https://github.com/rust-lang/crates.io-index" 1054 | checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" 1055 | 1056 | [[package]] 1057 | name = "pin-project-lite" 1058 | version = "0.2.13" 1059 | source = "registry+https://github.com/rust-lang/crates.io-index" 1060 | checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" 1061 | 1062 | [[package]] 1063 | name = "pin-utils" 1064 | version = "0.1.0" 1065 | source = "registry+https://github.com/rust-lang/crates.io-index" 1066 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 1067 | 1068 | [[package]] 1069 | name = "pkg-config" 1070 | version = "0.3.29" 1071 | source = "registry+https://github.com/rust-lang/crates.io-index" 1072 | checksum = "2900ede94e305130c13ddd391e0ab7cbaeb783945ae07a279c268cb05109c6cb" 1073 | 1074 | [[package]] 1075 | name = "portable-atomic" 1076 | version = "1.9.0" 1077 | source = "registry+https://github.com/rust-lang/crates.io-index" 1078 | checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2" 1079 | 1080 | [[package]] 1081 | name = "ppv-lite86" 1082 | version = "0.2.17" 1083 | source = "registry+https://github.com/rust-lang/crates.io-index" 1084 | checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" 1085 | 1086 | [[package]] 1087 | name = "proc-macro2" 1088 | version = "1.0.94" 1089 | source = "registry+https://github.com/rust-lang/crates.io-index" 1090 | checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" 1091 | dependencies = [ 1092 | "unicode-ident", 1093 | ] 1094 | 1095 | [[package]] 1096 | name = "proptest" 1097 | version = "1.7.0" 1098 | source = "registry+https://github.com/rust-lang/crates.io-index" 1099 | checksum = "6fcdab19deb5195a31cf7726a210015ff1496ba1464fd42cb4f537b8b01b471f" 1100 | dependencies = [ 1101 | "bit-set", 1102 | "bit-vec", 1103 | "bitflags 2.9.1", 1104 | "lazy_static", 1105 | "num-traits", 1106 | "rand 0.9.1", 1107 | "rand_chacha 0.9.0", 1108 | "rand_xorshift", 1109 | "regex-syntax", 1110 | "rusty-fork", 1111 | "tempfile", 1112 | "unarray", 1113 | ] 1114 | 1115 | [[package]] 1116 | name = "proptest-derive" 1117 | version = "0.6.0" 1118 | source = "registry+https://github.com/rust-lang/crates.io-index" 1119 | checksum = "095a99f75c69734802359b682be8daaf8980296731f6470434ea2c652af1dd30" 1120 | dependencies = [ 1121 | "proc-macro2", 1122 | "quote", 1123 | "syn", 1124 | ] 1125 | 1126 | [[package]] 1127 | name = "quick-error" 1128 | version = "1.2.3" 1129 | source = "registry+https://github.com/rust-lang/crates.io-index" 1130 | checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" 1131 | 1132 | [[package]] 1133 | name = "quinn" 1134 | version = "0.11.2" 1135 | source = "registry+https://github.com/rust-lang/crates.io-index" 1136 | checksum = "e4ceeeeabace7857413798eb1ffa1e9c905a9946a57d81fb69b4b71c4d8eb3ad" 1137 | dependencies = [ 1138 | "bytes", 1139 | "pin-project-lite", 1140 | "quinn-proto", 1141 | "quinn-udp", 1142 | "rustc-hash 1.1.0", 1143 | "rustls", 1144 | "thiserror 1.0.61", 1145 | "tokio", 1146 | "tracing", 1147 | ] 1148 | 1149 | [[package]] 1150 | name = "quinn-proto" 1151 | version = "0.11.9" 1152 | source = "registry+https://github.com/rust-lang/crates.io-index" 1153 | checksum = "a2fe5ef3495d7d2e377ff17b1a8ce2ee2ec2a18cde8b6ad6619d65d0701c135d" 1154 | dependencies = [ 1155 | "bytes", 1156 | "getrandom 0.2.12", 1157 | "rand 0.8.5", 1158 | "ring", 1159 | "rustc-hash 2.1.1", 1160 | "rustls", 1161 | "rustls-pki-types", 1162 | "slab", 1163 | "thiserror 2.0.12", 1164 | "tinyvec", 1165 | "tracing", 1166 | "web-time", 1167 | ] 1168 | 1169 | [[package]] 1170 | name = "quinn-udp" 1171 | version = "0.5.2" 1172 | source = "registry+https://github.com/rust-lang/crates.io-index" 1173 | checksum = "9096629c45860fc7fb143e125eb826b5e721e10be3263160c7d60ca832cf8c46" 1174 | dependencies = [ 1175 | "libc", 1176 | "once_cell", 1177 | "socket2", 1178 | "tracing", 1179 | "windows-sys 0.52.0", 1180 | ] 1181 | 1182 | [[package]] 1183 | name = "quote" 1184 | version = "1.0.35" 1185 | source = "registry+https://github.com/rust-lang/crates.io-index" 1186 | checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" 1187 | dependencies = [ 1188 | "proc-macro2", 1189 | ] 1190 | 1191 | [[package]] 1192 | name = "r-efi" 1193 | version = "5.2.0" 1194 | source = "registry+https://github.com/rust-lang/crates.io-index" 1195 | checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" 1196 | 1197 | [[package]] 1198 | name = "rand" 1199 | version = "0.8.5" 1200 | source = "registry+https://github.com/rust-lang/crates.io-index" 1201 | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 1202 | dependencies = [ 1203 | "libc", 1204 | "rand_chacha 0.3.1", 1205 | "rand_core 0.6.4", 1206 | ] 1207 | 1208 | [[package]] 1209 | name = "rand" 1210 | version = "0.9.1" 1211 | source = "registry+https://github.com/rust-lang/crates.io-index" 1212 | checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" 1213 | dependencies = [ 1214 | "rand_chacha 0.9.0", 1215 | "rand_core 0.9.3", 1216 | ] 1217 | 1218 | [[package]] 1219 | name = "rand_chacha" 1220 | version = "0.3.1" 1221 | source = "registry+https://github.com/rust-lang/crates.io-index" 1222 | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 1223 | dependencies = [ 1224 | "ppv-lite86", 1225 | "rand_core 0.6.4", 1226 | ] 1227 | 1228 | [[package]] 1229 | name = "rand_chacha" 1230 | version = "0.9.0" 1231 | source = "registry+https://github.com/rust-lang/crates.io-index" 1232 | checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 1233 | dependencies = [ 1234 | "ppv-lite86", 1235 | "rand_core 0.9.3", 1236 | ] 1237 | 1238 | [[package]] 1239 | name = "rand_core" 1240 | version = "0.6.4" 1241 | source = "registry+https://github.com/rust-lang/crates.io-index" 1242 | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 1243 | dependencies = [ 1244 | "getrandom 0.2.12", 1245 | ] 1246 | 1247 | [[package]] 1248 | name = "rand_core" 1249 | version = "0.9.3" 1250 | source = "registry+https://github.com/rust-lang/crates.io-index" 1251 | checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" 1252 | dependencies = [ 1253 | "getrandom 0.3.3", 1254 | ] 1255 | 1256 | [[package]] 1257 | name = "rand_xorshift" 1258 | version = "0.4.0" 1259 | source = "registry+https://github.com/rust-lang/crates.io-index" 1260 | checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" 1261 | dependencies = [ 1262 | "rand_core 0.9.3", 1263 | ] 1264 | 1265 | [[package]] 1266 | name = "redox_syscall" 1267 | version = "0.4.1" 1268 | source = "registry+https://github.com/rust-lang/crates.io-index" 1269 | checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" 1270 | dependencies = [ 1271 | "bitflags 1.3.2", 1272 | ] 1273 | 1274 | [[package]] 1275 | name = "regex" 1276 | version = "1.11.1" 1277 | source = "registry+https://github.com/rust-lang/crates.io-index" 1278 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 1279 | dependencies = [ 1280 | "aho-corasick", 1281 | "memchr", 1282 | "regex-automata", 1283 | "regex-syntax", 1284 | ] 1285 | 1286 | [[package]] 1287 | name = "regex-automata" 1288 | version = "0.4.8" 1289 | source = "registry+https://github.com/rust-lang/crates.io-index" 1290 | checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" 1291 | dependencies = [ 1292 | "aho-corasick", 1293 | "memchr", 1294 | "regex-syntax", 1295 | ] 1296 | 1297 | [[package]] 1298 | name = "regex-syntax" 1299 | version = "0.8.5" 1300 | source = "registry+https://github.com/rust-lang/crates.io-index" 1301 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 1302 | 1303 | [[package]] 1304 | name = "reqwest" 1305 | version = "0.12.19" 1306 | source = "registry+https://github.com/rust-lang/crates.io-index" 1307 | checksum = "a2f8e5513d63f2e5b386eb5106dc67eaf3f84e95258e210489136b8b92ad6119" 1308 | dependencies = [ 1309 | "base64", 1310 | "bytes", 1311 | "futures-channel", 1312 | "futures-core", 1313 | "futures-util", 1314 | "http", 1315 | "http-body", 1316 | "http-body-util", 1317 | "hyper", 1318 | "hyper-rustls", 1319 | "hyper-util", 1320 | "ipnet", 1321 | "js-sys", 1322 | "log", 1323 | "mime", 1324 | "once_cell", 1325 | "percent-encoding", 1326 | "pin-project-lite", 1327 | "quinn", 1328 | "rustls", 1329 | "rustls-pki-types", 1330 | "serde", 1331 | "serde_json", 1332 | "serde_urlencoded", 1333 | "sync_wrapper", 1334 | "tokio", 1335 | "tokio-rustls", 1336 | "tower", 1337 | "tower-http", 1338 | "tower-service", 1339 | "url", 1340 | "wasm-bindgen", 1341 | "wasm-bindgen-futures", 1342 | "web-sys", 1343 | "webpki-roots 1.0.0", 1344 | ] 1345 | 1346 | [[package]] 1347 | name = "ring" 1348 | version = "0.17.14" 1349 | source = "registry+https://github.com/rust-lang/crates.io-index" 1350 | checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" 1351 | dependencies = [ 1352 | "cc", 1353 | "cfg-if", 1354 | "getrandom 0.2.12", 1355 | "libc", 1356 | "untrusted", 1357 | "windows-sys 0.52.0", 1358 | ] 1359 | 1360 | [[package]] 1361 | name = "roff" 1362 | version = "0.2.1" 1363 | source = "registry+https://github.com/rust-lang/crates.io-index" 1364 | checksum = "b833d8d034ea094b1ea68aa6d5c740e0d04bad9d16568d08ba6f76823a114316" 1365 | 1366 | [[package]] 1367 | name = "rustc-demangle" 1368 | version = "0.1.23" 1369 | source = "registry+https://github.com/rust-lang/crates.io-index" 1370 | checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" 1371 | 1372 | [[package]] 1373 | name = "rustc-hash" 1374 | version = "1.1.0" 1375 | source = "registry+https://github.com/rust-lang/crates.io-index" 1376 | checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" 1377 | 1378 | [[package]] 1379 | name = "rustc-hash" 1380 | version = "2.1.1" 1381 | source = "registry+https://github.com/rust-lang/crates.io-index" 1382 | checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" 1383 | 1384 | [[package]] 1385 | name = "rustix" 1386 | version = "0.38.30" 1387 | source = "registry+https://github.com/rust-lang/crates.io-index" 1388 | checksum = "322394588aaf33c24007e8bb3238ee3e4c5c09c084ab32bc73890b99ff326bca" 1389 | dependencies = [ 1390 | "bitflags 2.9.1", 1391 | "errno", 1392 | "libc", 1393 | "linux-raw-sys", 1394 | "windows-sys 0.52.0", 1395 | ] 1396 | 1397 | [[package]] 1398 | name = "rustls" 1399 | version = "0.23.10" 1400 | source = "registry+https://github.com/rust-lang/crates.io-index" 1401 | checksum = "05cff451f60db80f490f3c182b77c35260baace73209e9cdbbe526bfe3a4d402" 1402 | dependencies = [ 1403 | "once_cell", 1404 | "ring", 1405 | "rustls-pki-types", 1406 | "rustls-webpki", 1407 | "subtle", 1408 | "zeroize", 1409 | ] 1410 | 1411 | [[package]] 1412 | name = "rustls-pki-types" 1413 | version = "1.12.0" 1414 | source = "registry+https://github.com/rust-lang/crates.io-index" 1415 | checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" 1416 | dependencies = [ 1417 | "web-time", 1418 | "zeroize", 1419 | ] 1420 | 1421 | [[package]] 1422 | name = "rustls-webpki" 1423 | version = "0.102.4" 1424 | source = "registry+https://github.com/rust-lang/crates.io-index" 1425 | checksum = "ff448f7e92e913c4b7d4c6d8e4540a1724b319b4152b8aef6d4cf8339712b33e" 1426 | dependencies = [ 1427 | "ring", 1428 | "rustls-pki-types", 1429 | "untrusted", 1430 | ] 1431 | 1432 | [[package]] 1433 | name = "rustversion" 1434 | version = "1.0.20" 1435 | source = "registry+https://github.com/rust-lang/crates.io-index" 1436 | checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" 1437 | 1438 | [[package]] 1439 | name = "rusty-fork" 1440 | version = "0.3.0" 1441 | source = "registry+https://github.com/rust-lang/crates.io-index" 1442 | checksum = "cb3dcc6e454c328bb824492db107ab7c0ae8fcffe4ad210136ef014458c1bc4f" 1443 | dependencies = [ 1444 | "fnv", 1445 | "quick-error", 1446 | "tempfile", 1447 | "wait-timeout", 1448 | ] 1449 | 1450 | [[package]] 1451 | name = "ryu" 1452 | version = "1.0.16" 1453 | source = "registry+https://github.com/rust-lang/crates.io-index" 1454 | checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" 1455 | 1456 | [[package]] 1457 | name = "sanitize-git-ref" 1458 | version = "1.0.12" 1459 | source = "registry+https://github.com/rust-lang/crates.io-index" 1460 | checksum = "dad160fe43549bdb9a04ab2e269bf50d415d86467463bc766c30d67dea822bd1" 1461 | 1462 | [[package]] 1463 | name = "scopeguard" 1464 | version = "1.2.0" 1465 | source = "registry+https://github.com/rust-lang/crates.io-index" 1466 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 1467 | 1468 | [[package]] 1469 | name = "serde" 1470 | version = "1.0.219" 1471 | source = "registry+https://github.com/rust-lang/crates.io-index" 1472 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 1473 | dependencies = [ 1474 | "serde_derive", 1475 | ] 1476 | 1477 | [[package]] 1478 | name = "serde_derive" 1479 | version = "1.0.219" 1480 | source = "registry+https://github.com/rust-lang/crates.io-index" 1481 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 1482 | dependencies = [ 1483 | "proc-macro2", 1484 | "quote", 1485 | "syn", 1486 | ] 1487 | 1488 | [[package]] 1489 | name = "serde_json" 1490 | version = "1.0.140" 1491 | source = "registry+https://github.com/rust-lang/crates.io-index" 1492 | checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 1493 | dependencies = [ 1494 | "itoa", 1495 | "memchr", 1496 | "ryu", 1497 | "serde", 1498 | ] 1499 | 1500 | [[package]] 1501 | name = "serde_urlencoded" 1502 | version = "0.7.1" 1503 | source = "registry+https://github.com/rust-lang/crates.io-index" 1504 | checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 1505 | dependencies = [ 1506 | "form_urlencoded", 1507 | "itoa", 1508 | "ryu", 1509 | "serde", 1510 | ] 1511 | 1512 | [[package]] 1513 | name = "shlex" 1514 | version = "1.3.0" 1515 | source = "registry+https://github.com/rust-lang/crates.io-index" 1516 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 1517 | 1518 | [[package]] 1519 | name = "signal-hook" 1520 | version = "0.3.17" 1521 | source = "registry+https://github.com/rust-lang/crates.io-index" 1522 | checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" 1523 | dependencies = [ 1524 | "libc", 1525 | "signal-hook-registry", 1526 | ] 1527 | 1528 | [[package]] 1529 | name = "signal-hook-mio" 1530 | version = "0.2.3" 1531 | source = "registry+https://github.com/rust-lang/crates.io-index" 1532 | checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" 1533 | dependencies = [ 1534 | "libc", 1535 | "mio 0.8.10", 1536 | "signal-hook", 1537 | ] 1538 | 1539 | [[package]] 1540 | name = "signal-hook-registry" 1541 | version = "1.4.1" 1542 | source = "registry+https://github.com/rust-lang/crates.io-index" 1543 | checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" 1544 | dependencies = [ 1545 | "libc", 1546 | ] 1547 | 1548 | [[package]] 1549 | name = "slab" 1550 | version = "0.4.9" 1551 | source = "registry+https://github.com/rust-lang/crates.io-index" 1552 | checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" 1553 | dependencies = [ 1554 | "autocfg", 1555 | ] 1556 | 1557 | [[package]] 1558 | name = "smallvec" 1559 | version = "1.13.1" 1560 | source = "registry+https://github.com/rust-lang/crates.io-index" 1561 | checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" 1562 | 1563 | [[package]] 1564 | name = "socket2" 1565 | version = "0.5.10" 1566 | source = "registry+https://github.com/rust-lang/crates.io-index" 1567 | checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" 1568 | dependencies = [ 1569 | "libc", 1570 | "windows-sys 0.52.0", 1571 | ] 1572 | 1573 | [[package]] 1574 | name = "stable_deref_trait" 1575 | version = "1.2.0" 1576 | source = "registry+https://github.com/rust-lang/crates.io-index" 1577 | checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" 1578 | 1579 | [[package]] 1580 | name = "strsim" 1581 | version = "0.11.0" 1582 | source = "registry+https://github.com/rust-lang/crates.io-index" 1583 | checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01" 1584 | 1585 | [[package]] 1586 | name = "subtle" 1587 | version = "2.5.0" 1588 | source = "registry+https://github.com/rust-lang/crates.io-index" 1589 | checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" 1590 | 1591 | [[package]] 1592 | name = "syn" 1593 | version = "2.0.100" 1594 | source = "registry+https://github.com/rust-lang/crates.io-index" 1595 | checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" 1596 | dependencies = [ 1597 | "proc-macro2", 1598 | "quote", 1599 | "unicode-ident", 1600 | ] 1601 | 1602 | [[package]] 1603 | name = "sync_wrapper" 1604 | version = "1.0.1" 1605 | source = "registry+https://github.com/rust-lang/crates.io-index" 1606 | checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" 1607 | dependencies = [ 1608 | "futures-core", 1609 | ] 1610 | 1611 | [[package]] 1612 | name = "synstructure" 1613 | version = "0.13.1" 1614 | source = "registry+https://github.com/rust-lang/crates.io-index" 1615 | checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" 1616 | dependencies = [ 1617 | "proc-macro2", 1618 | "quote", 1619 | "syn", 1620 | ] 1621 | 1622 | [[package]] 1623 | name = "tempfile" 1624 | version = "3.9.0" 1625 | source = "registry+https://github.com/rust-lang/crates.io-index" 1626 | checksum = "01ce4141aa927a6d1bd34a041795abd0db1cccba5d5f24b009f694bdf3a1f3fa" 1627 | dependencies = [ 1628 | "cfg-if", 1629 | "fastrand", 1630 | "redox_syscall", 1631 | "rustix", 1632 | "windows-sys 0.52.0", 1633 | ] 1634 | 1635 | [[package]] 1636 | name = "terminal_size" 1637 | version = "0.4.0" 1638 | source = "registry+https://github.com/rust-lang/crates.io-index" 1639 | checksum = "4f599bd7ca042cfdf8f4512b277c02ba102247820f9d9d4a9f521f496751a6ef" 1640 | dependencies = [ 1641 | "rustix", 1642 | "windows-sys 0.59.0", 1643 | ] 1644 | 1645 | [[package]] 1646 | name = "thiserror" 1647 | version = "1.0.61" 1648 | source = "registry+https://github.com/rust-lang/crates.io-index" 1649 | checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709" 1650 | dependencies = [ 1651 | "thiserror-impl 1.0.61", 1652 | ] 1653 | 1654 | [[package]] 1655 | name = "thiserror" 1656 | version = "2.0.12" 1657 | source = "registry+https://github.com/rust-lang/crates.io-index" 1658 | checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" 1659 | dependencies = [ 1660 | "thiserror-impl 2.0.12", 1661 | ] 1662 | 1663 | [[package]] 1664 | name = "thiserror-impl" 1665 | version = "1.0.61" 1666 | source = "registry+https://github.com/rust-lang/crates.io-index" 1667 | checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" 1668 | dependencies = [ 1669 | "proc-macro2", 1670 | "quote", 1671 | "syn", 1672 | ] 1673 | 1674 | [[package]] 1675 | name = "thiserror-impl" 1676 | version = "2.0.12" 1677 | source = "registry+https://github.com/rust-lang/crates.io-index" 1678 | checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" 1679 | dependencies = [ 1680 | "proc-macro2", 1681 | "quote", 1682 | "syn", 1683 | ] 1684 | 1685 | [[package]] 1686 | name = "thread_local" 1687 | version = "1.1.8" 1688 | source = "registry+https://github.com/rust-lang/crates.io-index" 1689 | checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" 1690 | dependencies = [ 1691 | "cfg-if", 1692 | "once_cell", 1693 | ] 1694 | 1695 | [[package]] 1696 | name = "tinystr" 1697 | version = "0.7.6" 1698 | source = "registry+https://github.com/rust-lang/crates.io-index" 1699 | checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" 1700 | dependencies = [ 1701 | "displaydoc", 1702 | "zerovec", 1703 | ] 1704 | 1705 | [[package]] 1706 | name = "tinyvec" 1707 | version = "1.6.0" 1708 | source = "registry+https://github.com/rust-lang/crates.io-index" 1709 | checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" 1710 | dependencies = [ 1711 | "tinyvec_macros", 1712 | ] 1713 | 1714 | [[package]] 1715 | name = "tinyvec_macros" 1716 | version = "0.1.1" 1717 | source = "registry+https://github.com/rust-lang/crates.io-index" 1718 | checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" 1719 | 1720 | [[package]] 1721 | name = "tokio" 1722 | version = "1.44.2" 1723 | source = "registry+https://github.com/rust-lang/crates.io-index" 1724 | checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" 1725 | dependencies = [ 1726 | "backtrace", 1727 | "bytes", 1728 | "libc", 1729 | "mio 1.0.3", 1730 | "pin-project-lite", 1731 | "socket2", 1732 | "windows-sys 0.52.0", 1733 | ] 1734 | 1735 | [[package]] 1736 | name = "tokio-rustls" 1737 | version = "0.26.0" 1738 | source = "registry+https://github.com/rust-lang/crates.io-index" 1739 | checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" 1740 | dependencies = [ 1741 | "rustls", 1742 | "rustls-pki-types", 1743 | "tokio", 1744 | ] 1745 | 1746 | [[package]] 1747 | name = "tower" 1748 | version = "0.5.2" 1749 | source = "registry+https://github.com/rust-lang/crates.io-index" 1750 | checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" 1751 | dependencies = [ 1752 | "futures-core", 1753 | "futures-util", 1754 | "pin-project-lite", 1755 | "sync_wrapper", 1756 | "tokio", 1757 | "tower-layer", 1758 | "tower-service", 1759 | ] 1760 | 1761 | [[package]] 1762 | name = "tower-http" 1763 | version = "0.6.5" 1764 | source = "registry+https://github.com/rust-lang/crates.io-index" 1765 | checksum = "5cc2d9e086a412a451384326f521c8123a99a466b329941a9403696bff9b0da2" 1766 | dependencies = [ 1767 | "bitflags 2.9.1", 1768 | "bytes", 1769 | "futures-util", 1770 | "http", 1771 | "http-body", 1772 | "iri-string", 1773 | "pin-project-lite", 1774 | "tower", 1775 | "tower-layer", 1776 | "tower-service", 1777 | ] 1778 | 1779 | [[package]] 1780 | name = "tower-layer" 1781 | version = "0.3.3" 1782 | source = "registry+https://github.com/rust-lang/crates.io-index" 1783 | checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" 1784 | 1785 | [[package]] 1786 | name = "tower-service" 1787 | version = "0.3.3" 1788 | source = "registry+https://github.com/rust-lang/crates.io-index" 1789 | checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 1790 | 1791 | [[package]] 1792 | name = "tracing" 1793 | version = "0.1.40" 1794 | source = "registry+https://github.com/rust-lang/crates.io-index" 1795 | checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" 1796 | dependencies = [ 1797 | "pin-project-lite", 1798 | "tracing-attributes", 1799 | "tracing-core", 1800 | ] 1801 | 1802 | [[package]] 1803 | name = "tracing-attributes" 1804 | version = "0.1.27" 1805 | source = "registry+https://github.com/rust-lang/crates.io-index" 1806 | checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" 1807 | dependencies = [ 1808 | "proc-macro2", 1809 | "quote", 1810 | "syn", 1811 | ] 1812 | 1813 | [[package]] 1814 | name = "tracing-core" 1815 | version = "0.1.32" 1816 | source = "registry+https://github.com/rust-lang/crates.io-index" 1817 | checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" 1818 | dependencies = [ 1819 | "once_cell", 1820 | ] 1821 | 1822 | [[package]] 1823 | name = "try-lock" 1824 | version = "0.2.5" 1825 | source = "registry+https://github.com/rust-lang/crates.io-index" 1826 | checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 1827 | 1828 | [[package]] 1829 | name = "unarray" 1830 | version = "0.1.4" 1831 | source = "registry+https://github.com/rust-lang/crates.io-index" 1832 | checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" 1833 | 1834 | [[package]] 1835 | name = "unicode-ident" 1836 | version = "1.0.12" 1837 | source = "registry+https://github.com/rust-lang/crates.io-index" 1838 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 1839 | 1840 | [[package]] 1841 | name = "unicode-segmentation" 1842 | version = "1.10.1" 1843 | source = "registry+https://github.com/rust-lang/crates.io-index" 1844 | checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" 1845 | 1846 | [[package]] 1847 | name = "unicode-width" 1848 | version = "0.1.11" 1849 | source = "registry+https://github.com/rust-lang/crates.io-index" 1850 | checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" 1851 | 1852 | [[package]] 1853 | name = "unicode-width" 1854 | version = "0.2.0" 1855 | source = "registry+https://github.com/rust-lang/crates.io-index" 1856 | checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" 1857 | 1858 | [[package]] 1859 | name = "untrusted" 1860 | version = "0.9.0" 1861 | source = "registry+https://github.com/rust-lang/crates.io-index" 1862 | checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 1863 | 1864 | [[package]] 1865 | name = "url" 1866 | version = "2.5.4" 1867 | source = "registry+https://github.com/rust-lang/crates.io-index" 1868 | checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" 1869 | dependencies = [ 1870 | "form_urlencoded", 1871 | "idna", 1872 | "percent-encoding", 1873 | ] 1874 | 1875 | [[package]] 1876 | name = "utf16_iter" 1877 | version = "1.0.5" 1878 | source = "registry+https://github.com/rust-lang/crates.io-index" 1879 | checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" 1880 | 1881 | [[package]] 1882 | name = "utf8_iter" 1883 | version = "1.0.4" 1884 | source = "registry+https://github.com/rust-lang/crates.io-index" 1885 | checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 1886 | 1887 | [[package]] 1888 | name = "utf8parse" 1889 | version = "0.2.1" 1890 | source = "registry+https://github.com/rust-lang/crates.io-index" 1891 | checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" 1892 | 1893 | [[package]] 1894 | name = "vcpkg" 1895 | version = "0.2.15" 1896 | source = "registry+https://github.com/rust-lang/crates.io-index" 1897 | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 1898 | 1899 | [[package]] 1900 | name = "wait-timeout" 1901 | version = "0.2.0" 1902 | source = "registry+https://github.com/rust-lang/crates.io-index" 1903 | checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" 1904 | dependencies = [ 1905 | "libc", 1906 | ] 1907 | 1908 | [[package]] 1909 | name = "want" 1910 | version = "0.3.1" 1911 | source = "registry+https://github.com/rust-lang/crates.io-index" 1912 | checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" 1913 | dependencies = [ 1914 | "try-lock", 1915 | ] 1916 | 1917 | [[package]] 1918 | name = "wasi" 1919 | version = "0.11.0+wasi-snapshot-preview1" 1920 | source = "registry+https://github.com/rust-lang/crates.io-index" 1921 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 1922 | 1923 | [[package]] 1924 | name = "wasi" 1925 | version = "0.14.2+wasi-0.2.4" 1926 | source = "registry+https://github.com/rust-lang/crates.io-index" 1927 | checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" 1928 | dependencies = [ 1929 | "wit-bindgen-rt", 1930 | ] 1931 | 1932 | [[package]] 1933 | name = "wasm-bindgen" 1934 | version = "0.2.100" 1935 | source = "registry+https://github.com/rust-lang/crates.io-index" 1936 | checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" 1937 | dependencies = [ 1938 | "cfg-if", 1939 | "once_cell", 1940 | "rustversion", 1941 | "wasm-bindgen-macro", 1942 | ] 1943 | 1944 | [[package]] 1945 | name = "wasm-bindgen-backend" 1946 | version = "0.2.100" 1947 | source = "registry+https://github.com/rust-lang/crates.io-index" 1948 | checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" 1949 | dependencies = [ 1950 | "bumpalo", 1951 | "log", 1952 | "proc-macro2", 1953 | "quote", 1954 | "syn", 1955 | "wasm-bindgen-shared", 1956 | ] 1957 | 1958 | [[package]] 1959 | name = "wasm-bindgen-futures" 1960 | version = "0.4.40" 1961 | source = "registry+https://github.com/rust-lang/crates.io-index" 1962 | checksum = "bde2032aeb86bdfaecc8b261eef3cba735cc426c1f3a3416d1e0791be95fc461" 1963 | dependencies = [ 1964 | "cfg-if", 1965 | "js-sys", 1966 | "wasm-bindgen", 1967 | "web-sys", 1968 | ] 1969 | 1970 | [[package]] 1971 | name = "wasm-bindgen-macro" 1972 | version = "0.2.100" 1973 | source = "registry+https://github.com/rust-lang/crates.io-index" 1974 | checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" 1975 | dependencies = [ 1976 | "quote", 1977 | "wasm-bindgen-macro-support", 1978 | ] 1979 | 1980 | [[package]] 1981 | name = "wasm-bindgen-macro-support" 1982 | version = "0.2.100" 1983 | source = "registry+https://github.com/rust-lang/crates.io-index" 1984 | checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" 1985 | dependencies = [ 1986 | "proc-macro2", 1987 | "quote", 1988 | "syn", 1989 | "wasm-bindgen-backend", 1990 | "wasm-bindgen-shared", 1991 | ] 1992 | 1993 | [[package]] 1994 | name = "wasm-bindgen-shared" 1995 | version = "0.2.100" 1996 | source = "registry+https://github.com/rust-lang/crates.io-index" 1997 | checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" 1998 | dependencies = [ 1999 | "unicode-ident", 2000 | ] 2001 | 2002 | [[package]] 2003 | name = "web-sys" 2004 | version = "0.3.67" 2005 | source = "registry+https://github.com/rust-lang/crates.io-index" 2006 | checksum = "58cd2333b6e0be7a39605f0e255892fd7418a682d8da8fe042fe25128794d2ed" 2007 | dependencies = [ 2008 | "js-sys", 2009 | "wasm-bindgen", 2010 | ] 2011 | 2012 | [[package]] 2013 | name = "web-time" 2014 | version = "1.1.0" 2015 | source = "registry+https://github.com/rust-lang/crates.io-index" 2016 | checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" 2017 | dependencies = [ 2018 | "js-sys", 2019 | "wasm-bindgen", 2020 | ] 2021 | 2022 | [[package]] 2023 | name = "webpki-roots" 2024 | version = "0.26.1" 2025 | source = "registry+https://github.com/rust-lang/crates.io-index" 2026 | checksum = "b3de34ae270483955a94f4b21bdaaeb83d508bb84a01435f393818edb0012009" 2027 | dependencies = [ 2028 | "rustls-pki-types", 2029 | ] 2030 | 2031 | [[package]] 2032 | name = "webpki-roots" 2033 | version = "1.0.0" 2034 | source = "registry+https://github.com/rust-lang/crates.io-index" 2035 | checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb" 2036 | dependencies = [ 2037 | "rustls-pki-types", 2038 | ] 2039 | 2040 | [[package]] 2041 | name = "winapi" 2042 | version = "0.3.9" 2043 | source = "registry+https://github.com/rust-lang/crates.io-index" 2044 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 2045 | dependencies = [ 2046 | "winapi-i686-pc-windows-gnu", 2047 | "winapi-x86_64-pc-windows-gnu", 2048 | ] 2049 | 2050 | [[package]] 2051 | name = "winapi-i686-pc-windows-gnu" 2052 | version = "0.4.0" 2053 | source = "registry+https://github.com/rust-lang/crates.io-index" 2054 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 2055 | 2056 | [[package]] 2057 | name = "winapi-x86_64-pc-windows-gnu" 2058 | version = "0.4.0" 2059 | source = "registry+https://github.com/rust-lang/crates.io-index" 2060 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 2061 | 2062 | [[package]] 2063 | name = "windows-sys" 2064 | version = "0.48.0" 2065 | source = "registry+https://github.com/rust-lang/crates.io-index" 2066 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 2067 | dependencies = [ 2068 | "windows-targets 0.48.5", 2069 | ] 2070 | 2071 | [[package]] 2072 | name = "windows-sys" 2073 | version = "0.52.0" 2074 | source = "registry+https://github.com/rust-lang/crates.io-index" 2075 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 2076 | dependencies = [ 2077 | "windows-targets 0.52.6", 2078 | ] 2079 | 2080 | [[package]] 2081 | name = "windows-sys" 2082 | version = "0.59.0" 2083 | source = "registry+https://github.com/rust-lang/crates.io-index" 2084 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 2085 | dependencies = [ 2086 | "windows-targets 0.52.6", 2087 | ] 2088 | 2089 | [[package]] 2090 | name = "windows-targets" 2091 | version = "0.48.5" 2092 | source = "registry+https://github.com/rust-lang/crates.io-index" 2093 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 2094 | dependencies = [ 2095 | "windows_aarch64_gnullvm 0.48.5", 2096 | "windows_aarch64_msvc 0.48.5", 2097 | "windows_i686_gnu 0.48.5", 2098 | "windows_i686_msvc 0.48.5", 2099 | "windows_x86_64_gnu 0.48.5", 2100 | "windows_x86_64_gnullvm 0.48.5", 2101 | "windows_x86_64_msvc 0.48.5", 2102 | ] 2103 | 2104 | [[package]] 2105 | name = "windows-targets" 2106 | version = "0.52.6" 2107 | source = "registry+https://github.com/rust-lang/crates.io-index" 2108 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 2109 | dependencies = [ 2110 | "windows_aarch64_gnullvm 0.52.6", 2111 | "windows_aarch64_msvc 0.52.6", 2112 | "windows_i686_gnu 0.52.6", 2113 | "windows_i686_gnullvm", 2114 | "windows_i686_msvc 0.52.6", 2115 | "windows_x86_64_gnu 0.52.6", 2116 | "windows_x86_64_gnullvm 0.52.6", 2117 | "windows_x86_64_msvc 0.52.6", 2118 | ] 2119 | 2120 | [[package]] 2121 | name = "windows_aarch64_gnullvm" 2122 | version = "0.48.5" 2123 | source = "registry+https://github.com/rust-lang/crates.io-index" 2124 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 2125 | 2126 | [[package]] 2127 | name = "windows_aarch64_gnullvm" 2128 | version = "0.52.6" 2129 | source = "registry+https://github.com/rust-lang/crates.io-index" 2130 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 2131 | 2132 | [[package]] 2133 | name = "windows_aarch64_msvc" 2134 | version = "0.48.5" 2135 | source = "registry+https://github.com/rust-lang/crates.io-index" 2136 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 2137 | 2138 | [[package]] 2139 | name = "windows_aarch64_msvc" 2140 | version = "0.52.6" 2141 | source = "registry+https://github.com/rust-lang/crates.io-index" 2142 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 2143 | 2144 | [[package]] 2145 | name = "windows_i686_gnu" 2146 | version = "0.48.5" 2147 | source = "registry+https://github.com/rust-lang/crates.io-index" 2148 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 2149 | 2150 | [[package]] 2151 | name = "windows_i686_gnu" 2152 | version = "0.52.6" 2153 | source = "registry+https://github.com/rust-lang/crates.io-index" 2154 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 2155 | 2156 | [[package]] 2157 | name = "windows_i686_gnullvm" 2158 | version = "0.52.6" 2159 | source = "registry+https://github.com/rust-lang/crates.io-index" 2160 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 2161 | 2162 | [[package]] 2163 | name = "windows_i686_msvc" 2164 | version = "0.48.5" 2165 | source = "registry+https://github.com/rust-lang/crates.io-index" 2166 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 2167 | 2168 | [[package]] 2169 | name = "windows_i686_msvc" 2170 | version = "0.52.6" 2171 | source = "registry+https://github.com/rust-lang/crates.io-index" 2172 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 2173 | 2174 | [[package]] 2175 | name = "windows_x86_64_gnu" 2176 | version = "0.48.5" 2177 | source = "registry+https://github.com/rust-lang/crates.io-index" 2178 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 2179 | 2180 | [[package]] 2181 | name = "windows_x86_64_gnu" 2182 | version = "0.52.6" 2183 | source = "registry+https://github.com/rust-lang/crates.io-index" 2184 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 2185 | 2186 | [[package]] 2187 | name = "windows_x86_64_gnullvm" 2188 | version = "0.48.5" 2189 | source = "registry+https://github.com/rust-lang/crates.io-index" 2190 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 2191 | 2192 | [[package]] 2193 | name = "windows_x86_64_gnullvm" 2194 | version = "0.52.6" 2195 | source = "registry+https://github.com/rust-lang/crates.io-index" 2196 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 2197 | 2198 | [[package]] 2199 | name = "windows_x86_64_msvc" 2200 | version = "0.48.5" 2201 | source = "registry+https://github.com/rust-lang/crates.io-index" 2202 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 2203 | 2204 | [[package]] 2205 | name = "windows_x86_64_msvc" 2206 | version = "0.52.6" 2207 | source = "registry+https://github.com/rust-lang/crates.io-index" 2208 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 2209 | 2210 | [[package]] 2211 | name = "wit-bindgen-rt" 2212 | version = "0.39.0" 2213 | source = "registry+https://github.com/rust-lang/crates.io-index" 2214 | checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" 2215 | dependencies = [ 2216 | "bitflags 2.9.1", 2217 | ] 2218 | 2219 | [[package]] 2220 | name = "write16" 2221 | version = "1.0.0" 2222 | source = "registry+https://github.com/rust-lang/crates.io-index" 2223 | checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" 2224 | 2225 | [[package]] 2226 | name = "writeable" 2227 | version = "0.5.5" 2228 | source = "registry+https://github.com/rust-lang/crates.io-index" 2229 | checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" 2230 | 2231 | [[package]] 2232 | name = "yoke" 2233 | version = "0.7.5" 2234 | source = "registry+https://github.com/rust-lang/crates.io-index" 2235 | checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" 2236 | dependencies = [ 2237 | "serde", 2238 | "stable_deref_trait", 2239 | "yoke-derive", 2240 | "zerofrom", 2241 | ] 2242 | 2243 | [[package]] 2244 | name = "yoke-derive" 2245 | version = "0.7.5" 2246 | source = "registry+https://github.com/rust-lang/crates.io-index" 2247 | checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" 2248 | dependencies = [ 2249 | "proc-macro2", 2250 | "quote", 2251 | "syn", 2252 | "synstructure", 2253 | ] 2254 | 2255 | [[package]] 2256 | name = "zerofrom" 2257 | version = "0.1.6" 2258 | source = "registry+https://github.com/rust-lang/crates.io-index" 2259 | checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" 2260 | dependencies = [ 2261 | "zerofrom-derive", 2262 | ] 2263 | 2264 | [[package]] 2265 | name = "zerofrom-derive" 2266 | version = "0.1.6" 2267 | source = "registry+https://github.com/rust-lang/crates.io-index" 2268 | checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" 2269 | dependencies = [ 2270 | "proc-macro2", 2271 | "quote", 2272 | "syn", 2273 | "synstructure", 2274 | ] 2275 | 2276 | [[package]] 2277 | name = "zeroize" 2278 | version = "1.7.0" 2279 | source = "registry+https://github.com/rust-lang/crates.io-index" 2280 | checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" 2281 | 2282 | [[package]] 2283 | name = "zerovec" 2284 | version = "0.10.4" 2285 | source = "registry+https://github.com/rust-lang/crates.io-index" 2286 | checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" 2287 | dependencies = [ 2288 | "yoke", 2289 | "zerofrom", 2290 | "zerovec-derive", 2291 | ] 2292 | 2293 | [[package]] 2294 | name = "zerovec-derive" 2295 | version = "0.10.3" 2296 | source = "registry+https://github.com/rust-lang/crates.io-index" 2297 | checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" 2298 | dependencies = [ 2299 | "proc-macro2", 2300 | "quote", 2301 | "syn", 2302 | ] 2303 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "git-disjoint" 3 | version = "0.10.278" 4 | edition = "2021" 5 | authors = ["Eric Crosson "] 6 | license = "MIT OR Apache-2.0" 7 | description = "A tool to batch commits by issue into GitHub PRs" 8 | readme = "README.md" 9 | repository = "https://github.com/ericcrosson/git-disjoint" 10 | keywords = ["cli", "git", "pull-request"] 11 | categories = ["command-line-utilities"] 12 | exclude = [ 13 | "/.envrc", 14 | "/.github", 15 | "/.gitignore", 16 | "/.releaserc.json", 17 | "/CHANGELOG.md", 18 | "/flake.{lock,nix}", 19 | "/package*.json", 20 | "/rust-toolchain", 21 | "/assets" 22 | ] 23 | 24 | [dependencies] 25 | clap = { version = "=4.5.39", features = ["cargo", "derive", "env", "wrap_help"] } 26 | parse-git-url = "=0.5.1" 27 | git2 = { version = "=0.20.2", default-features = false, features = ["zlib-ng-compat"] } 28 | indexmap = "=2.9.0" 29 | indicatif = "=0.17.11" 30 | inquire = "=0.7.5" 31 | open = "=5.3.2" 32 | regex = "=1.11.1" 33 | reqwest = { version = "=0.12.19", default-features = false, features = ["blocking", "json", "rustls-tls"] } 34 | sanitize-git-ref = "=1.0.12" 35 | serde = { version = "=1.0.219", features = ["derive"] } 36 | serde_json = "=1.0.140" 37 | 38 | [dev-dependencies] 39 | proptest = "=1.7.0" 40 | proptest-derive = "=0.6.0" 41 | 42 | [profile.release] 43 | lto = true 44 | codegen-units = 1 45 | strip = true 46 | 47 | [build-dependencies] 48 | clap = { version = "=4.5.39", features = ["cargo", "derive", "wrap_help"] } 49 | clap_complete = "=4.5.54" 50 | clap_mangen = "=0.2.27" 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # git disjoint 2 | 3 | [![Build Status]](https://github.com/EricCrosson/git-disjoint/actions/workflows/release.yml) 4 | 5 | [build status]: https://github.com/EricCrosson/git-disjoint/actions/workflows/release.yml/badge.svg?event=push 6 | 7 |

8 | conceptual diagram of git-disjoint operation 9 |

10 | 11 | **git-disjoint** automates an optimal git workflow for PR authors and reviewers 12 | by grouping commits by issue into GitHub PRs. 13 | 14 | This encourages the submission of small, independent PRs, minimizing cognitive 15 | load on reviewers, maximizing the utility of your git history, and keeping 16 | cycle time low. 17 | 18 | ## Elevator Pitch 19 | 20 |

21 | git-disjoint demo 22 |

23 | 24 | You're working on a feature. As you work, you create some commits that don't directly 25 | implement your feature. Maybe you improve some documentation, fix a minor bug, or 26 | first refactor to make the change easy, before making the easy change[^1]. In any case, 27 | you commit directly to master as you go[^2], because you want each change to persist 28 | in your development environment, even before it's gone through code review and landed 29 | upstream. 30 | 31 | When you come to a natural stopping point, you are ready to ship several 32 | commits. Each commit is atomic, relating to just one topic. It comes with a 33 | detailed commit message referencing an issue in your work tracker, passing 34 | unit tests, and documentation. You don't want to shove all these changes into 35 | a single PR, because they deal with orthogonal concerns. You trust your team to 36 | contribute quality code reviews, and iterating on one changeset shouldn't delay 37 | unrelated changes from merging. 38 | 39 | Instead of creating a PR directly from your master, or manually moving commits into separate 40 | branches, do this: 41 | 42 | ```shell 43 | git disjoint 44 | ``` 45 | 46 | **git-disjoint** will identify which commits relate to the same issue, batch these commits 47 | into a new branch, and create a PR. 48 | 49 | [^1]: https://www.adamtal.me/2019/05/first-make-the-change-easy-then-make-the-easy-change 50 | [^2]: https://drewdevault.com/2020/04/06/My-weird-branchless-git-workflow.html 51 | 52 | ## How does it work? 53 | 54 | **git-disjoint** looks for trailers[^3] in each commit message to determine 55 | which issue a commit relates to. By default, it creates one PR for each issue 56 | and associates the PR to an existing issue in your work tracker. 57 | 58 | When a PR merges, your next `git pull` effectively moves upstream's master from 59 | behind your local commits to ahead of them. 60 | 61 | [^3]: https://git-scm.com/docs/git-interpret-trailers 62 | 63 | ## Supported Integrations 64 | 65 | **git-disjoint** adds value to your workflow if you: 66 | 67 | - use a work tracker (supports Jira and GitHub Issues) 68 | - use GitHub and Pull Requests 69 | 70 | ## Requirements 71 | 72 | You need a GitHub [personal access token] with `repo` scope. Either export this 73 | as the `GITHUB_TOKEN` environment variable or pass it to **git-disjoint** with 74 | the `--token` option. 75 | 76 | [personal access token]: https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token 77 | 78 | ## Install 79 | 80 | ### From GitHub releases 81 | 82 | The easiest way to install **git-disjoint** is to download a release compatible 83 | with your OS and architecture from the [Releases] page. 84 | 85 | Alternatively, install **git-disjoint** with one of the following package managers: 86 | 87 | | Repository | Command | 88 | | -------------- | ----------------------------------------------------- | 89 | | Cargo | `cargo +nightly install git-disjoint` | 90 | | Cargo binstall | `cargo binstall git-disjoint` | 91 | | Nix | `nix profile install github:EricCrosson/git-disjoint` | 92 | 93 | [releases]: https://github.com/EricCrosson/git-disjoint/releases/latest 94 | 95 | ## Use 96 | 97 | ### Make commits 98 | 99 | 1. Add all of your commits to a single branch. I recommend using the repository's default branch. 100 | 101 | 1. In each commit message, include a reference to the relevant issue. 102 | 103 | For example, use the Jira automation [format][jira]: 104 | 105 | ``` 106 | Ticket: COOL-123 107 | ``` 108 | 109 | or 110 | 111 | ``` 112 | Closes Ticket: COOL-123 113 | ``` 114 | 115 | Or use the GitHub [format][github]: 116 | 117 | ``` 118 | Closes #123 119 | ``` 120 | 121 | [jira]: https://support.atlassian.com/jira-software-cloud/docs/reference-issues-in-your-development-work/ 122 | [github]: https://github.blog/2013-01-22-closing-issues-via-commit-messages/ 123 | 124 | ### Open PRs 125 | 126 | When you're ready to: 127 | 128 | 1. turn the set of commits addressing each issue into its own branch, 129 | 1. push that branch, and 130 | 1. create a draft PR, 131 | 132 | run `git disjoint`. 133 | 134 | ## How-to Guide 135 | 136 | ### How do I ignore certain commits? 137 | 138 | To ignore commits associated with an issue, use the `--choose` flag. This will 139 | open a menu where you can select the issues to create PRs for. 140 | 141 | ### How do I use git-disjoint on commits without an associated issue? 142 | 143 | Use the `--all` flag to include commits without a recognized trailer. 144 | -------------------------------------------------------------------------------- /assets/README.md: -------------------------------------------------------------------------------- 1 | This logo was made using [Excalidraw]. You can import this png back into Excalidraw for editing. 2 | 3 | [excalidraw]: https://github.com/excalidraw/excalidraw 4 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EricCrosson/git-disjoint/0cc545b6f1211630aea53817fb76a064cf0eed17/assets/logo.png -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use clap::{CommandFactory, ValueEnum}; 4 | use clap_complete::{generate_to, Shell}; 5 | 6 | #[path = "src/cli.rs"] 7 | mod cli; 8 | 9 | fn generate_man_pages(out_dir: &Path, command: clap::Command) -> std::io::Result<()> { 10 | let man = clap_mangen::Man::new(command); 11 | let mut buffer: Vec = Default::default(); 12 | man.render(&mut buffer)?; 13 | 14 | std::fs::write(out_dir.join("git-disjoint.1"), buffer)?; 15 | Ok(()) 16 | } 17 | 18 | fn generate_shell_completions(out_dir: &Path, mut command: clap::Command) -> std::io::Result<()> { 19 | Shell::value_variants().iter().try_for_each(|shell| { 20 | generate_to(*shell, &mut command, "git-disjoint", out_dir)?; 21 | Ok(()) 22 | }) 23 | } 24 | 25 | fn main() -> std::io::Result<()> { 26 | println!("cargo:rerun-if-changed=Cargo.lock"); 27 | println!("cargo:rerun-if-changed=build.rs"); 28 | println!("cargo:rerun-if-changed=src/args.rs"); 29 | 30 | let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").ok_or(std::io::ErrorKind::NotFound)?); 31 | let command = cli::Cli::command(); 32 | 33 | generate_man_pages(&out_dir, command.clone())?; 34 | generate_shell_completions(&out_dir, command)?; 35 | 36 | Ok(()) 37 | } 38 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "crane": { 4 | "locked": { 5 | "lastModified": 1746291859, 6 | "narHash": "sha256-DdWJLA+D5tcmrRSg5Y7tp/qWaD05ATI4Z7h22gd1h7Q=", 7 | "owner": "ipetkov", 8 | "repo": "crane", 9 | "rev": "dfd9a8dfd09db9aad544c4d3b6c47b12562544a5", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "ipetkov", 14 | "repo": "crane", 15 | "type": "github" 16 | } 17 | }, 18 | "fenix": { 19 | "inputs": { 20 | "nixpkgs": [ 21 | "nixpkgs" 22 | ], 23 | "rust-analyzer-src": "rust-analyzer-src" 24 | }, 25 | "locked": { 26 | "lastModified": 1747118403, 27 | "narHash": "sha256-6LDKrSTxPmh9c1f79ixqIwg6mlXE2FKwi11x9GNPKhA=", 28 | "owner": "nix-community", 29 | "repo": "fenix", 30 | "rev": "01daa5be6a29caab8b6831b0e936750ea66d463d", 31 | "type": "github" 32 | }, 33 | "original": { 34 | "owner": "nix-community", 35 | "repo": "fenix", 36 | "type": "github" 37 | } 38 | }, 39 | "nixpkgs": { 40 | "locked": { 41 | "lastModified": 1747153466, 42 | "narHash": "sha256-l0UwImKjJ1UcVSie9FjSrTM4An4saczG+cyPEK1GYqo=", 43 | "owner": "nixos", 44 | "repo": "nixpkgs", 45 | "rev": "714bd2160a645807d76f7e454a98a516eda0aedd", 46 | "type": "github" 47 | }, 48 | "original": { 49 | "owner": "nixos", 50 | "repo": "nixpkgs", 51 | "type": "github" 52 | } 53 | }, 54 | "root": { 55 | "inputs": { 56 | "crane": "crane", 57 | "fenix": "fenix", 58 | "nixpkgs": "nixpkgs" 59 | } 60 | }, 61 | "rust-analyzer-src": { 62 | "flake": false, 63 | "locked": { 64 | "lastModified": 1746889290, 65 | "narHash": "sha256-h3LQYZgyv2l3U7r+mcsrEOGRldaK0zJFwAAva4hV/6g=", 66 | "owner": "rust-lang", 67 | "repo": "rust-analyzer", 68 | "rev": "2bafe9d96c6734aacfd49e115f6cf61e7adc68bc", 69 | "type": "github" 70 | }, 71 | "original": { 72 | "owner": "rust-lang", 73 | "ref": "nightly", 74 | "repo": "rust-analyzer", 75 | "type": "github" 76 | } 77 | } 78 | }, 79 | "root": "root", 80 | "version": 7 81 | } 82 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:nixos/nixpkgs"; 4 | crane = { 5 | url = "github:ipetkov/crane"; 6 | }; 7 | fenix = { 8 | url = "github:nix-community/fenix"; 9 | inputs.nixpkgs.follows = "nixpkgs"; 10 | }; 11 | }; 12 | 13 | outputs = { 14 | self, 15 | nixpkgs, 16 | crane, 17 | fenix, 18 | }: let 19 | forEachSystem = nixpkgs.lib.genAttrs [ 20 | "aarch64-darwin" 21 | "aarch64-linux" 22 | "x86_64-darwin" 23 | "x86_64-linux" 24 | ]; 25 | in { 26 | packages = forEachSystem (system: let 27 | craneDerivations = nixpkgs.legacyPackages.${system}.callPackage ./nix/default.nix { 28 | inherit crane fenix; 29 | }; 30 | in { 31 | default = craneDerivations.myCrate; 32 | }); 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /nix/default.nix: -------------------------------------------------------------------------------- 1 | { 2 | pkgs, 3 | system, 4 | crane, 5 | fenix, 6 | }: let 7 | craneLib = (crane.mkLib pkgs).overrideToolchain (p: let 8 | fenix-channel = fenix.packages.${system}.latest; 9 | fenix-toolchain = fenix-channel.withComponents [ 10 | "rustc" 11 | "cargo" 12 | "clippy" 13 | "rust-analysis" 14 | "rust-src" 15 | "rustfmt" 16 | ]; 17 | in 18 | fenix-toolchain); 19 | 20 | runtimeInputs = with pkgs; [gitMinimal]; 21 | 22 | # Common derivation arguments used for all builds 23 | commonArgs = { 24 | src = craneLib.cleanCargoSource ../.; 25 | 26 | buildInputs = with pkgs; 27 | [ 28 | openssl 29 | ] 30 | ++ pkgs.lib.optionals pkgs.stdenv.isDarwin 31 | [ 32 | libiconv 33 | ]; 34 | 35 | nativeBuildInputs = with pkgs; [ 36 | cmake 37 | ]; 38 | }; 39 | 40 | # Build *just* the cargo dependencies, so we can reuse 41 | # all of that work (e.g. via cachix) when running in CI 42 | cargoArtifacts = craneLib.buildDepsOnly commonArgs; 43 | 44 | # Run clippy (and deny all warnings) on the crate source, 45 | # resuing the dependency artifacts (e.g. from build scripts or 46 | # proc-macros) from above. 47 | # 48 | # Note that this is done as a separate derivation so it 49 | # does not impact building just the crate by itself. 50 | myCrateClippy = craneLib.cargoClippy (commonArgs 51 | // { 52 | # Again we apply some extra arguments only to this derivation 53 | # and not every where else. In this case we add some clippy flags 54 | inherit cargoArtifacts; 55 | cargoClippyExtraArgs = "-- --deny warnings"; 56 | }); 57 | 58 | # Next, we want to run the tests and collect code-coverage, _but only if 59 | # the clippy checks pass_ so we do not waste any extra cycles. 60 | myCrateCoverage = craneLib.cargoNextest (commonArgs 61 | // { 62 | cargoArtifacts = myCrateClippy; 63 | }); 64 | 65 | # Build the actual crate itself, reusing the dependency 66 | # artifacts from above. 67 | myCrate = craneLib.buildPackage (commonArgs 68 | // { 69 | inherit cargoArtifacts runtimeInputs; 70 | 71 | nativeBuildInputs = with pkgs; [ 72 | findutils 73 | installShellFiles 74 | makeWrapper 75 | ]; 76 | 77 | postInstall = '' 78 | installManPage "$( 79 | find target/release/build -type f -name git-disjoint.1 -print0 \ 80 | | xargs -0 ls -t \ 81 | | head -n 1 82 | )" 83 | installShellCompletion \ 84 | "$( 85 | find target/release/build -type f -name git-disjoint.bash -print0 \ 86 | | xargs -0 ls -t \ 87 | | head -n 1 88 | )" \ 89 | "$( 90 | find target/release/build -type f -name git-disjoint.fish -print0 \ 91 | | xargs -0 ls -t \ 92 | | head -n 1 93 | )" \ 94 | --zsh "$( 95 | find target/release/build -type f -name _git-disjoint -print0 \ 96 | | xargs -0 ls -t \ 97 | | head -n 1 98 | )" 99 | 100 | wrapProgram $out/bin/git-disjoint \ 101 | --prefix PATH ${pkgs.lib.makeBinPath runtimeInputs} 102 | ''; 103 | }); 104 | in { 105 | inherit 106 | commonArgs 107 | myCrate 108 | myCrateClippy 109 | myCrateCoverage 110 | runtimeInputs 111 | ; 112 | } 113 | -------------------------------------------------------------------------------- /nix/flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "crane": { 4 | "locked": { 5 | "lastModified": 1733016477, 6 | "narHash": "sha256-Hh0khbqBeCtiNS0SJgqdWrQDem9WlPEc2KF5pAY+st0=", 7 | "owner": "ipetkov", 8 | "repo": "crane", 9 | "rev": "76d64e779e2fbaf172110038492343a8c4e29b55", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "ipetkov", 14 | "repo": "crane", 15 | "type": "github" 16 | } 17 | }, 18 | "fenix": { 19 | "inputs": { 20 | "nixpkgs": [ 21 | "nixpkgs" 22 | ], 23 | "rust-analyzer-src": "rust-analyzer-src" 24 | }, 25 | "locked": { 26 | "lastModified": 1732689334, 27 | "narHash": "sha256-yKI1KiZ0+bvDvfPTQ1ZT3oP/nIu3jPYm4dnbRd6hYg4=", 28 | "owner": "nix-community", 29 | "repo": "fenix", 30 | "rev": "a8a983027ca02b363dfc82fbe3f7d9548a8d3dce", 31 | "type": "github" 32 | }, 33 | "original": { 34 | "owner": "nix-community", 35 | "repo": "fenix", 36 | "type": "github" 37 | } 38 | }, 39 | "flake-compat": { 40 | "flake": false, 41 | "locked": { 42 | "lastModified": 1696426674, 43 | "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", 44 | "owner": "edolstra", 45 | "repo": "flake-compat", 46 | "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", 47 | "type": "github" 48 | }, 49 | "original": { 50 | "owner": "edolstra", 51 | "repo": "flake-compat", 52 | "type": "github" 53 | } 54 | }, 55 | "gitignore": { 56 | "inputs": { 57 | "nixpkgs": [ 58 | "pre-commit-hooks", 59 | "nixpkgs" 60 | ] 61 | }, 62 | "locked": { 63 | "lastModified": 1709087332, 64 | "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", 65 | "owner": "hercules-ci", 66 | "repo": "gitignore.nix", 67 | "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", 68 | "type": "github" 69 | }, 70 | "original": { 71 | "owner": "hercules-ci", 72 | "repo": "gitignore.nix", 73 | "type": "github" 74 | } 75 | }, 76 | "nixpkgs": { 77 | "locked": { 78 | "lastModified": 1733254120, 79 | "narHash": "sha256-FVZKU8VYNST8+LKQBt1Nij9FAXEDufXGgFbg26wqmZk=", 80 | "owner": "nixos", 81 | "repo": "nixpkgs", 82 | "rev": "b256a82610f369cd2734dd89585ac5cfa29d34f1", 83 | "type": "github" 84 | }, 85 | "original": { 86 | "owner": "nixos", 87 | "repo": "nixpkgs", 88 | "type": "github" 89 | } 90 | }, 91 | "nixpkgs-stable": { 92 | "locked": { 93 | "lastModified": 1730741070, 94 | "narHash": "sha256-edm8WG19kWozJ/GqyYx2VjW99EdhjKwbY3ZwdlPAAlo=", 95 | "owner": "NixOS", 96 | "repo": "nixpkgs", 97 | "rev": "d063c1dd113c91ab27959ba540c0d9753409edf3", 98 | "type": "github" 99 | }, 100 | "original": { 101 | "owner": "NixOS", 102 | "ref": "nixos-24.05", 103 | "repo": "nixpkgs", 104 | "type": "github" 105 | } 106 | }, 107 | "pre-commit-hooks": { 108 | "inputs": { 109 | "flake-compat": "flake-compat", 110 | "gitignore": "gitignore", 111 | "nixpkgs": [ 112 | "nixpkgs" 113 | ], 114 | "nixpkgs-stable": "nixpkgs-stable" 115 | }, 116 | "locked": { 117 | "lastModified": 1732021966, 118 | "narHash": "sha256-mnTbjpdqF0luOkou8ZFi2asa1N3AA2CchR/RqCNmsGE=", 119 | "owner": "cachix", 120 | "repo": "pre-commit-hooks.nix", 121 | "rev": "3308484d1a443fc5bc92012435d79e80458fe43c", 122 | "type": "github" 123 | }, 124 | "original": { 125 | "owner": "cachix", 126 | "repo": "pre-commit-hooks.nix", 127 | "type": "github" 128 | } 129 | }, 130 | "root": { 131 | "inputs": { 132 | "crane": "crane", 133 | "fenix": "fenix", 134 | "nixpkgs": "nixpkgs", 135 | "pre-commit-hooks": "pre-commit-hooks" 136 | } 137 | }, 138 | "rust-analyzer-src": { 139 | "flake": false, 140 | "locked": { 141 | "lastModified": 1732633904, 142 | "narHash": "sha256-7VKcoLug9nbAN2txqVksWHHJplqK9Ou8dXjIZAIYSGc=", 143 | "owner": "rust-lang", 144 | "repo": "rust-analyzer", 145 | "rev": "8d5e91c94f80c257ce6dbdfba7bd63a5e8a03fa6", 146 | "type": "github" 147 | }, 148 | "original": { 149 | "owner": "rust-lang", 150 | "ref": "nightly", 151 | "repo": "rust-analyzer", 152 | "type": "github" 153 | } 154 | } 155 | }, 156 | "root": "root", 157 | "version": 7 158 | } 159 | -------------------------------------------------------------------------------- /nix/flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:nixos/nixpkgs"; 4 | crane = { 5 | url = "github:ipetkov/crane"; 6 | }; 7 | fenix = { 8 | url = "github:nix-community/fenix"; 9 | inputs.nixpkgs.follows = "nixpkgs"; 10 | }; 11 | pre-commit-hooks = { 12 | url = "github:cachix/pre-commit-hooks.nix"; 13 | inputs.nixpkgs.follows = "nixpkgs"; 14 | }; 15 | }; 16 | 17 | outputs = { 18 | self, 19 | nixpkgs, 20 | crane, 21 | fenix, 22 | pre-commit-hooks, 23 | }: let 24 | forEachSystem = nixpkgs.lib.genAttrs [ 25 | "aarch64-darwin" 26 | "aarch64-linux" 27 | "x86_64-darwin" 28 | "x86_64-linux" 29 | ]; 30 | in { 31 | checks = forEachSystem (system: let 32 | craneDerivations = nixpkgs.legacyPackages.${system}.callPackage ./default.nix { 33 | inherit crane fenix; 34 | }; 35 | pre-commit-check = pre-commit-hooks.lib.${system}.run { 36 | src = ../.; 37 | hooks = { 38 | actionlint.enable = true; 39 | alejandra.enable = true; 40 | prettier.enable = true; 41 | rustfmt.enable = true; 42 | }; 43 | }; 44 | in { 45 | inherit 46 | (craneDerivations) 47 | myCrate 48 | myCrateClippy 49 | myCrateCoverage 50 | ; 51 | inherit pre-commit-check; 52 | }); 53 | 54 | devShells = forEachSystem (system: let 55 | craneDerivations = nixpkgs.legacyPackages.${system}.callPackage ./default.nix { 56 | inherit crane fenix; 57 | }; 58 | in { 59 | default = nixpkgs.legacyPackages.${system}.mkShell { 60 | # DISCUSS: can we use inherit instead? 61 | # DISCUSS: can we use inputsFrom instead? 62 | buildInputs = craneDerivations.commonArgs.buildInputs ++ craneDerivations.runtimeInputs; 63 | nativeBuildInputs = 64 | craneDerivations.commonArgs.nativeBuildInputs 65 | ++ [ 66 | fenix.packages.${system}.rust-analyzer 67 | ]; 68 | 69 | inherit (self.checks.${system}.pre-commit-check) shellHook; 70 | }; 71 | }); 72 | }; 73 | } 74 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "nightly" 3 | -------------------------------------------------------------------------------- /src/branch_name.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use sanitize_git_ref::sanitize_git_ref_onelevel; 4 | 5 | use crate::issue_group::IssueGroup; 6 | 7 | /// Characters to be replaced with a hyphen, since they interfere with terminal 8 | /// tab-completion. 9 | static CHARACTERS_TO_REPLACE_WITH_HYPHEN: &[char] = &['!', '`', '(', ')']; 10 | 11 | /// Characters to be deleted, since they interfere with terminal tab-completion. 12 | static CHARACTERS_TO_REMOVE: &[char] = &['\'', '"']; 13 | 14 | #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] 15 | pub(crate) struct BranchName(String); 16 | 17 | fn elide_consecutive_hyphens(mut s: String) -> String { 18 | let mut current_run = 0; 19 | s.retain(|c| { 20 | match c == '-' { 21 | true => current_run += 1, 22 | false => current_run = 0, 23 | }; 24 | current_run < 2 25 | }); 26 | s 27 | } 28 | 29 | impl BranchName { 30 | pub fn as_str(&self) -> &str { 31 | &self.0 32 | } 33 | 34 | pub fn from_issue_group(issue_group: &IssueGroup, summary: &str) -> Self { 35 | let raw_branch_name = match issue_group { 36 | IssueGroup::Issue(issue_group) => format!( 37 | "{}-{}", 38 | issue_group.issue_identifier(), 39 | summary.to_lowercase() 40 | ), 41 | IssueGroup::Commit(summary) => summary.0.clone().to_lowercase(), 42 | }; 43 | Self::new(sanitize_git_ref_onelevel(&raw_branch_name)) 44 | } 45 | 46 | pub fn new(value: String) -> Self { 47 | let s = value.replace(CHARACTERS_TO_REPLACE_WITH_HYPHEN, "-"); 48 | let s = elide_consecutive_hyphens(s); 49 | let s = s.replace(CHARACTERS_TO_REMOVE, ""); 50 | Self(s) 51 | } 52 | } 53 | 54 | impl Display for BranchName { 55 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 56 | write!(f, "{}", self.0) 57 | } 58 | } 59 | 60 | impl From for BranchName { 61 | fn from(s: String) -> Self { 62 | Self::new(s) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::{ArgAction, Parser}; 2 | 3 | #[derive(Copy, Clone, Debug, Eq, PartialEq)] 4 | pub(crate) enum CommitsToConsider { 5 | All, 6 | WithTrailer, 7 | } 8 | 9 | impl From<&str> for CommitsToConsider { 10 | fn from(value: &str) -> Self { 11 | match value { 12 | "true" => Self::All, 13 | "false" => Self::WithTrailer, 14 | _ => unreachable!(), 15 | } 16 | } 17 | } 18 | 19 | #[derive(Copy, Clone, Debug, Eq, PartialEq)] 20 | pub(crate) enum PromptUserToChooseCommits { 21 | Yes, 22 | No, 23 | } 24 | 25 | impl From<&str> for PromptUserToChooseCommits { 26 | fn from(value: &str) -> Self { 27 | match value { 28 | "true" => Self::Yes, 29 | "false" => Self::No, 30 | _ => unreachable!(), 31 | } 32 | } 33 | } 34 | 35 | #[derive(Copy, Clone, Debug, Eq, PartialEq)] 36 | pub(crate) enum OverlayCommitsIntoOnePullRequest { 37 | Yes, 38 | No, 39 | } 40 | 41 | impl From<&str> for OverlayCommitsIntoOnePullRequest { 42 | fn from(value: &str) -> Self { 43 | match value { 44 | "true" => Self::Yes, 45 | "false" => Self::No, 46 | _ => unreachable!(), 47 | } 48 | } 49 | } 50 | 51 | #[derive(Copy, Clone, Debug, Eq, PartialEq)] 52 | pub(crate) enum CommitGrouping { 53 | Individual, 54 | ByIssue, 55 | } 56 | 57 | impl From<&str> for CommitGrouping { 58 | fn from(value: &str) -> Self { 59 | match value { 60 | "true" => Self::Individual, 61 | "false" => Self::ByIssue, 62 | _ => unreachable!(), 63 | } 64 | } 65 | } 66 | 67 | #[derive(Clone, Debug, Parser)] 68 | #[command(author, version, about)] 69 | pub(crate) struct Cli { 70 | /// Do not ignore commits without an issue trailer. 71 | /// 72 | /// Commits without an issue trailer are considered to be their own 73 | /// group, so will be the only commit in their PR. 74 | /// 75 | /// There is no change to commits with an issue trailer. 76 | /// 77 | /// This flag can be combined with the --choose flag. 78 | #[arg( 79 | short, 80 | long, 81 | help = "Consider every commit, even commits without an issue trailer", 82 | action = ArgAction::SetTrue, 83 | )] 84 | pub all: CommitsToConsider, 85 | 86 | /// The starting point (exclusive) of commits to act on. 87 | /// 88 | /// Defaults to the repository's default branch. 89 | #[arg( 90 | short, 91 | long, 92 | help = "The starting point (exclusive) of commits to act on", 93 | value_name = "REF" 94 | )] 95 | pub base: Option, 96 | 97 | /// Prompt the user to select which issues to create PRs for. 98 | /// 99 | /// Select a whitelist of issues (or commits, if the --all flag is active) 100 | /// in a terminal UI. 101 | #[arg( 102 | short, 103 | long, 104 | help = "Prompt the user to select which issues to create PRs for", 105 | action = ArgAction::SetTrue, 106 | )] 107 | pub choose: PromptUserToChooseCommits, 108 | 109 | /// Show the work that would be performed without taking any action. 110 | #[arg( 111 | short, 112 | long, 113 | env = "GIT_DISJOINT_DRY_RUN", 114 | help = "Show the work that would be performed without taking any action" 115 | )] 116 | pub dry_run: bool, 117 | 118 | /// GitHub API token with repo permissions. 119 | #[arg( 120 | long, 121 | env = "GITHUB_TOKEN", 122 | help = "GitHub API token", 123 | value_name = "TOKEN" 124 | )] 125 | pub github_token: String, 126 | 127 | /// Combine multiple issue groups into one PR. 128 | /// 129 | /// When this flag is active, git-disjoint will create only one PR. 130 | /// 131 | /// This can be useful when you have multiple commits with no trailer that 132 | /// would be better reviewed or merged together. 133 | #[arg( 134 | short, 135 | long, 136 | help = "Combine multiple issue groups into one PR", 137 | action = ArgAction::SetTrue, 138 | )] 139 | pub overlay: OverlayCommitsIntoOnePullRequest, 140 | 141 | /// Do not group commits by issue. 142 | /// 143 | /// Treat each commit independently, regardless of issue trailer. Each 144 | /// PR created will have one and only one commit associated with it. 145 | /// 146 | /// This is the same behavior as when no commit has an issue trailer and 147 | /// the --all flag is active. 148 | /// 149 | /// This can be useful when you have numerous changes that belong under 150 | /// one issue, but would be better reviewed independently. 151 | #[arg( 152 | short, 153 | long, 154 | help = "Treat every commit separately; do not group by issue", 155 | action = ArgAction::SetTrue, 156 | )] 157 | pub separate: CommitGrouping, 158 | } 159 | -------------------------------------------------------------------------------- /src/default_branch.rs: -------------------------------------------------------------------------------- 1 | use std::{error::Error, fmt::Display}; 2 | 3 | use serde::Deserialize; 4 | 5 | use crate::github_repository_metadata::GithubRepositoryMetadata; 6 | 7 | // REFACTOR: remove pub of inner type 8 | #[derive(Clone, Debug)] 9 | pub(crate) struct DefaultBranch(pub String); 10 | 11 | impl Display for DefaultBranch { 12 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 13 | write!(f, "{}", self.0) 14 | } 15 | } 16 | 17 | #[derive(Debug, Deserialize)] 18 | struct GetRepositoryResponse { 19 | default_branch: String, 20 | } 21 | 22 | #[derive(Debug)] 23 | #[non_exhaustive] 24 | pub(crate) struct TryDefaultError { 25 | // owner/name repository slug 26 | url: String, 27 | kind: TryDefaultErrorKind, 28 | } 29 | 30 | impl Display for TryDefaultError { 31 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 32 | match &self.kind { 33 | TryDefaultErrorKind::Http(_) => write!(f, "http error: GET {}", self.url), 34 | TryDefaultErrorKind::Parse(_) => { 35 | write!(f, "unable to parse response from GET {}", self.url) 36 | } 37 | } 38 | } 39 | } 40 | 41 | impl Error for TryDefaultError { 42 | fn source(&self) -> Option<&(dyn Error + 'static)> { 43 | match &self.kind { 44 | TryDefaultErrorKind::Http(err) => Some(err), 45 | TryDefaultErrorKind::Parse(err) => Some(err), 46 | } 47 | } 48 | } 49 | 50 | #[derive(Debug)] 51 | pub(crate) enum TryDefaultErrorKind { 52 | #[non_exhaustive] 53 | Http(reqwest::Error), 54 | #[non_exhaustive] 55 | Parse(reqwest::Error), 56 | } 57 | 58 | impl DefaultBranch { 59 | pub(crate) fn try_get_default( 60 | repository_metadata: &GithubRepositoryMetadata, 61 | github_token: &str, 62 | ) -> Result { 63 | let http_client = reqwest::blocking::Client::new(); 64 | let url = format!( 65 | "https://api.github.com/repos/{}/{}", 66 | repository_metadata.owner, repository_metadata.name 67 | ); 68 | let response: GetRepositoryResponse = http_client 69 | .get(&url) 70 | .header("User-Agent", "git-disjoint") 71 | .header("Accept", "application/vnd.github+json") 72 | .header("Authorization", format!("token {github_token}")) 73 | .send() 74 | .map_err(|err| TryDefaultError { 75 | url: url.clone(), 76 | kind: TryDefaultErrorKind::Http(err), 77 | })? 78 | .json() 79 | .map_err(|err| TryDefaultError { 80 | url, 81 | kind: TryDefaultErrorKind::Parse(err), 82 | })?; 83 | 84 | // Assumption: `origin` is the upstream/main repositiory 85 | Ok(DefaultBranch(response.default_branch)) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/disjoint_branch.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashSet, error::Error, fmt::Display}; 2 | 3 | use git2::Commit; 4 | use indexmap::IndexMap; 5 | 6 | use crate::{branch_name::BranchName, issue_group::IssueGroup, issue_group_map::IssueGroupMap}; 7 | 8 | #[derive(Debug)] 9 | pub(crate) struct DisjointBranch<'repo> { 10 | // REFACTOR: make this private 11 | pub branch_name: BranchName, 12 | // REFACTOR: make this private 13 | pub commits: Vec>, 14 | } 15 | 16 | #[derive(Debug)] 17 | pub(crate) struct DisjointBranchMap<'repo>(IndexMap>); 18 | 19 | #[derive(Debug)] 20 | #[non_exhaustive] 21 | pub(crate) struct FromIssueGroupMapError { 22 | kind: FromIssueGroupMapErrorKind, 23 | } 24 | 25 | impl Display for FromIssueGroupMapError { 26 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 27 | match &self.kind { 28 | FromIssueGroupMapErrorKind::InvalidUtf8(commit) => { 29 | write!(f, "commit summary contains invalid UTF-8: {}", commit) 30 | } 31 | } 32 | } 33 | } 34 | 35 | impl Error for FromIssueGroupMapError { 36 | fn source(&self) -> Option<&(dyn Error + 'static)> { 37 | match &self.kind { 38 | FromIssueGroupMapErrorKind::InvalidUtf8(_) => None, 39 | } 40 | } 41 | } 42 | 43 | #[derive(Debug)] 44 | pub(crate) enum FromIssueGroupMapErrorKind { 45 | #[non_exhaustive] 46 | InvalidUtf8(String), 47 | } 48 | 49 | impl From for FromIssueGroupMapError { 50 | fn from(kind: FromIssueGroupMapErrorKind) -> Self { 51 | Self { kind } 52 | } 53 | } 54 | 55 | impl<'repo> FromIterator<(IssueGroup, DisjointBranch<'repo>)> for DisjointBranchMap<'repo> { 56 | fn from_iter)>>(iter: T) -> Self { 57 | Self(iter.into_iter().collect()) 58 | } 59 | } 60 | 61 | impl<'repo> IntoIterator for DisjointBranchMap<'repo> { 62 | type Item = (IssueGroup, DisjointBranch<'repo>); 63 | 64 | type IntoIter = indexmap::map::IntoIter>; 65 | 66 | fn into_iter(self) -> Self::IntoIter { 67 | self.0.into_iter() 68 | } 69 | } 70 | 71 | impl<'repo> TryFrom> for DisjointBranchMap<'repo> { 72 | type Error = FromIssueGroupMapError; 73 | 74 | /// Plan out branch names to avoid collisions. 75 | /// 76 | /// This function does not take into account existing branch names in the local 77 | /// or remote repository. It only looks at branch names that git-disjoint is 78 | /// going to generate to make sure one invocation of git-disjoint won't try to 79 | /// create a branch with the same name twice. 80 | fn try_from(commits_by_issue_group: IssueGroupMap<'repo>) -> Result { 81 | let mut suffix: u32 = 0; 82 | let mut seen_branch_names = HashSet::new(); 83 | commits_by_issue_group 84 | .into_iter() 85 | .map(|(issue_group, commits)| { 86 | // Grab the first summary to convert into a branch name. 87 | // We only choose the first summary because we know each Vec is 88 | // non-empty and the first element is convenient. 89 | let summary = { 90 | let commit = &commits[0]; 91 | commit.summary().ok_or_else(|| { 92 | FromIssueGroupMapErrorKind::InvalidUtf8(commit.id().to_string()) 93 | })? 94 | }; 95 | let generated_branch_name = BranchName::from_issue_group(&issue_group, summary); 96 | let mut proposed_branch_name = generated_branch_name.clone(); 97 | 98 | while seen_branch_names.contains(&proposed_branch_name) { 99 | suffix += 1; 100 | // OPTIMIZE: no need to call sanitize_git_ref here again 101 | proposed_branch_name = format!("{generated_branch_name}_{suffix}").into(); 102 | } 103 | 104 | seen_branch_names.insert(proposed_branch_name.clone()); 105 | 106 | Ok(( 107 | issue_group, 108 | DisjointBranch { 109 | branch_name: proposed_branch_name, 110 | commits, 111 | }, 112 | )) 113 | }) 114 | .collect() 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/editor.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | error::Error, 3 | fmt::Display, 4 | fs::{self, File}, 5 | io::{self, Write}, 6 | path::Path, 7 | process::Command, 8 | }; 9 | 10 | use git2::Commit; 11 | 12 | use crate::{ 13 | pull_request_message::PullRequestMessageTemplate, 14 | pull_request_metadata::{self, PullRequestMetadata}, 15 | }; 16 | 17 | #[derive(Debug)] 18 | #[non_exhaustive] 19 | pub(crate) struct GetPullRequestMetadataError { 20 | kind: GetPullRequestMetadataErrorKind, 21 | } 22 | 23 | impl Display for GetPullRequestMetadataError { 24 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 25 | match &self.kind { 26 | GetPullRequestMetadataErrorKind::AmbiguousEditor => write!( 27 | f, 28 | "unknown editor -- user should set VISUAL or EDITOR environment variable" 29 | ), 30 | GetPullRequestMetadataErrorKind::CreateFile(_) => { 31 | write!(f, "unable to create .git/PULLREQ_MSG file") 32 | } 33 | GetPullRequestMetadataErrorKind::BufferWrite(_) => { 34 | write!(f, "error writing to .git/PULLREQ_MSG file") 35 | } 36 | GetPullRequestMetadataErrorKind::EmptyPullRequest(_) => { 37 | write!(f, "user gave abort signal") 38 | } 39 | GetPullRequestMetadataErrorKind::Editor(_) => write!(f, "error invoking editor"), 40 | GetPullRequestMetadataErrorKind::ReadFile(_) => { 41 | write!(f, "error reading .git/PULLREQ_MSG file") 42 | } 43 | } 44 | } 45 | } 46 | 47 | impl Error for GetPullRequestMetadataError { 48 | fn source(&self) -> Option<&(dyn Error + 'static)> { 49 | match &self.kind { 50 | GetPullRequestMetadataErrorKind::AmbiguousEditor => None, 51 | GetPullRequestMetadataErrorKind::CreateFile(err) => Some(err), 52 | GetPullRequestMetadataErrorKind::BufferWrite(err) => Some(err), 53 | GetPullRequestMetadataErrorKind::EmptyPullRequest(err) => Some(err), 54 | GetPullRequestMetadataErrorKind::Editor(err) => Some(err), 55 | GetPullRequestMetadataErrorKind::ReadFile(err) => Some(err), 56 | } 57 | } 58 | } 59 | 60 | #[derive(Debug)] 61 | pub(crate) enum GetPullRequestMetadataErrorKind { 62 | #[non_exhaustive] 63 | AmbiguousEditor, 64 | #[non_exhaustive] 65 | CreateFile(io::Error), 66 | #[non_exhaustive] 67 | BufferWrite(io::Error), 68 | #[non_exhaustive] 69 | Editor(io::Error), 70 | #[non_exhaustive] 71 | ReadFile(io::Error), 72 | #[non_exhaustive] 73 | EmptyPullRequest(pull_request_metadata::FromStrError), 74 | } 75 | 76 | impl From for GetPullRequestMetadataError { 77 | fn from(kind: GetPullRequestMetadataErrorKind) -> Self { 78 | Self { kind } 79 | } 80 | } 81 | 82 | pub(crate) fn interactive_get_pr_metadata<'repo>( 83 | root: &Path, 84 | commits: impl IntoIterator>>, 85 | ) -> Result { 86 | let editor = get_editor().ok_or(GetPullRequestMetadataErrorKind::AmbiguousEditor)?; 87 | 88 | let file_path = root.join(".git").join("PULLREQ_MSG"); 89 | let mut buffer = 90 | File::create(&file_path).map_err(GetPullRequestMetadataErrorKind::CreateFile)?; 91 | 92 | writeln!( 93 | buffer, 94 | "{}", 95 | commits 96 | .into_iter() 97 | .map(Into::into) 98 | .collect::() 99 | ) 100 | .map_err(GetPullRequestMetadataErrorKind::BufferWrite)?; 101 | 102 | Command::new(editor) 103 | .arg(&file_path) 104 | .status() 105 | .map_err(GetPullRequestMetadataErrorKind::Editor)?; 106 | 107 | let file_content = 108 | fs::read_to_string(file_path).map_err(GetPullRequestMetadataErrorKind::ReadFile)?; 109 | 110 | Ok(file_content 111 | .parse() 112 | .map_err(GetPullRequestMetadataErrorKind::EmptyPullRequest)?) 113 | } 114 | 115 | fn get_editor() -> Option { 116 | use std::env::var; 117 | var("VISUAL").or_else(|_| var("EDITOR")).ok() 118 | } 119 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | //! The error type for top-level errors in git-disjoint. 2 | 3 | use std::fmt::Display; 4 | 5 | use crate::{ 6 | default_branch, disjoint_branch, editor, execute, git2_repository, github_repository_metadata, 7 | interact, issue_group_map, pull_request, pull_request_metadata, 8 | }; 9 | 10 | #[derive(Debug)] 11 | #[non_exhaustive] 12 | pub(crate) struct Error { 13 | pub kind: ErrorKind, 14 | } 15 | 16 | impl Display for Error { 17 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 18 | match &self.kind { 19 | ErrorKind::CreatePullRequest(_) => write!(f, "unable to create pull request"), 20 | ErrorKind::WebBrowser(_) => write!(f, "unable to open pull request in browser"), 21 | ErrorKind::CherryPick(_, commit) => { 22 | write!(f, "unable to cherry-pick commit {:?}", commit) 23 | } 24 | ErrorKind::RepositoryMetadata(_) => write!(f, "unable to gather repository metadata"), 25 | ErrorKind::DefaultBranch(_) => write!(f, "unable to query repository's default branch"), 26 | ErrorKind::BaseCommit(_) => write!(f, "unable to identify repository's base commit"), 27 | ErrorKind::WalkCommits(_) => write!(f, "unable to walk commits"), 28 | ErrorKind::IssueGroup(_) => write!(f, "unable to group commits by issue"), 29 | ErrorKind::SelectIssues(_) => write!(f, "unable to select issue groups"), 30 | ErrorKind::PlanBranches(_) => write!(f, "unable to plan commits onto branches"), 31 | ErrorKind::Git(_) => write!(f, "git operation failed"), 32 | ErrorKind::Execute(_) => write!(f, "command failed to execute"), 33 | ErrorKind::GetPullRequestMetadata(_) => { 34 | write!(f, "unable to query pull request metadata") 35 | } 36 | ErrorKind::ParsePullRequestMetadata(_) => { 37 | write!(f, "unable to parse pull request metadata") 38 | } 39 | } 40 | } 41 | } 42 | 43 | impl std::error::Error for Error { 44 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 45 | match &self.kind { 46 | ErrorKind::CreatePullRequest(err) => Some(err), 47 | ErrorKind::WebBrowser(err) => Some(err), 48 | ErrorKind::CherryPick(err, _) => Some(err), 49 | ErrorKind::RepositoryMetadata(err) => Some(err), 50 | ErrorKind::DefaultBranch(err) => Some(err), 51 | ErrorKind::BaseCommit(err) => Some(err), 52 | ErrorKind::WalkCommits(err) => Some(err), 53 | ErrorKind::IssueGroup(err) => Some(err), 54 | ErrorKind::SelectIssues(err) => Some(err), 55 | ErrorKind::PlanBranches(err) => Some(err), 56 | ErrorKind::Git(err) => Some(err), 57 | ErrorKind::Execute(err) => Some(err), 58 | ErrorKind::GetPullRequestMetadata(err) => Some(err), 59 | ErrorKind::ParsePullRequestMetadata(err) => Some(err), 60 | } 61 | } 62 | } 63 | 64 | #[derive(Debug)] 65 | pub(crate) enum ErrorKind { 66 | #[non_exhaustive] 67 | RepositoryMetadata(github_repository_metadata::TryDefaultError), 68 | #[non_exhaustive] 69 | DefaultBranch(default_branch::TryDefaultError), 70 | #[non_exhaustive] 71 | BaseCommit(git2_repository::BaseCommitError), 72 | #[non_exhaustive] 73 | WalkCommits(git2_repository::WalkCommitsError), 74 | #[non_exhaustive] 75 | IssueGroup(issue_group_map::FromCommitsError), 76 | #[non_exhaustive] 77 | SelectIssues(interact::SelectIssuesError), 78 | #[non_exhaustive] 79 | PlanBranches(disjoint_branch::FromIssueGroupMapError), 80 | #[non_exhaustive] 81 | Git(git2::Error), 82 | #[non_exhaustive] 83 | Execute(execute::ExecuteError), 84 | #[non_exhaustive] 85 | GetPullRequestMetadata(editor::GetPullRequestMetadataError), 86 | #[non_exhaustive] 87 | ParsePullRequestMetadata(pull_request_metadata::FromStrError), 88 | #[non_exhaustive] 89 | CreatePullRequest(pull_request::CreatePullRequestError), 90 | #[non_exhaustive] 91 | WebBrowser(pull_request::CreatePullRequestError), 92 | #[non_exhaustive] 93 | CherryPick(execute::ExecuteError, String), 94 | } 95 | 96 | impl From for Error { 97 | fn from(err: github_repository_metadata::TryDefaultError) -> Self { 98 | Self { 99 | kind: ErrorKind::RepositoryMetadata(err), 100 | } 101 | } 102 | } 103 | 104 | impl From for Error { 105 | fn from(err: default_branch::TryDefaultError) -> Self { 106 | Self { 107 | kind: ErrorKind::DefaultBranch(err), 108 | } 109 | } 110 | } 111 | 112 | impl From for Error { 113 | fn from(err: git2_repository::BaseCommitError) -> Self { 114 | Self { 115 | kind: ErrorKind::BaseCommit(err), 116 | } 117 | } 118 | } 119 | 120 | impl From for Error { 121 | fn from(err: git2_repository::WalkCommitsError) -> Self { 122 | Self { 123 | kind: ErrorKind::WalkCommits(err), 124 | } 125 | } 126 | } 127 | 128 | impl From for Error { 129 | fn from(err: issue_group_map::FromCommitsError) -> Self { 130 | Self { 131 | kind: ErrorKind::IssueGroup(err), 132 | } 133 | } 134 | } 135 | 136 | impl From for Error { 137 | fn from(err: interact::SelectIssuesError) -> Self { 138 | Self { 139 | kind: ErrorKind::SelectIssues(err), 140 | } 141 | } 142 | } 143 | 144 | impl From for Error { 145 | fn from(err: disjoint_branch::FromIssueGroupMapError) -> Self { 146 | Self { 147 | kind: ErrorKind::PlanBranches(err), 148 | } 149 | } 150 | } 151 | 152 | impl From for Error { 153 | fn from(err: git2::Error) -> Self { 154 | Self { 155 | kind: ErrorKind::Git(err), 156 | } 157 | } 158 | } 159 | 160 | impl From for Error { 161 | fn from(err: execute::ExecuteError) -> Self { 162 | Self { 163 | kind: ErrorKind::Execute(err), 164 | } 165 | } 166 | } 167 | 168 | impl From for Error { 169 | fn from(err: editor::GetPullRequestMetadataError) -> Self { 170 | Self { 171 | kind: ErrorKind::GetPullRequestMetadata(err), 172 | } 173 | } 174 | } 175 | 176 | impl From for Error { 177 | fn from(err: pull_request_metadata::FromStrError) -> Self { 178 | Self { 179 | kind: ErrorKind::ParsePullRequestMetadata(err), 180 | } 181 | } 182 | } 183 | 184 | impl From for Error { 185 | fn from(err: pull_request::CreatePullRequestError) -> Self { 186 | match &err.kind { 187 | pull_request::CreatePullRequestErrorKind::Http(_) 188 | | pull_request::CreatePullRequestErrorKind::Parse(_) => Self { 189 | kind: ErrorKind::CreatePullRequest(err), 190 | }, 191 | pull_request::CreatePullRequestErrorKind::OpenBrowser(_) => Self { 192 | kind: ErrorKind::WebBrowser(err), 193 | }, 194 | } 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/execute.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::fmt::Display; 3 | use std::fs::OpenOptions; 4 | use std::io::{self, prelude::*}; 5 | use std::process::{Command, ExitStatusError, Stdio}; 6 | 7 | use crate::log_file::LogFile; 8 | 9 | #[derive(Debug)] 10 | #[non_exhaustive] 11 | pub(crate) struct ExecuteError { 12 | kind: ExecuteErrorKind, 13 | } 14 | 15 | impl Display for ExecuteError { 16 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 17 | match &self.kind { 18 | ExecuteErrorKind::Write(_) => write!(f, "unable to write to stream"), 19 | ExecuteErrorKind::Exec(_, command) => { 20 | write!(f, "unable to execute command: {}", command.join(" ")) 21 | } 22 | ExecuteErrorKind::Child(_, command) => write!( 23 | f, 24 | "child process exited with non-zero code: {}", 25 | command.join(" ") 26 | ), 27 | } 28 | } 29 | } 30 | 31 | impl Error for ExecuteError { 32 | fn source(&self) -> Option<&(dyn Error + 'static)> { 33 | match &self.kind { 34 | ExecuteErrorKind::Write(err) => Some(err), 35 | ExecuteErrorKind::Exec(err, _) => Some(err), 36 | ExecuteErrorKind::Child(err, _) => Some(err), 37 | } 38 | } 39 | } 40 | 41 | #[derive(Debug)] 42 | pub(crate) enum ExecuteErrorKind { 43 | #[non_exhaustive] 44 | Write(io::Error), 45 | /// Error while executing the child process 46 | #[non_exhaustive] 47 | Exec(io::Error, Vec), 48 | /// The child process exited with non-zero exit code 49 | #[non_exhaustive] 50 | Child(ExitStatusError, Vec), 51 | } 52 | 53 | pub(crate) fn execute(command: &[&str], log_file: &LogFile) -> Result<(), ExecuteError> { 54 | (|| -> Result<(), ExecuteErrorKind> { 55 | let mut runner = Command::new(command[0]); 56 | 57 | let mut file = OpenOptions::new() 58 | .create(true) 59 | .append(true) 60 | .open(log_file) 61 | .expect(&format!( 62 | "should be able to append to log file {:?}", 63 | log_file 64 | )); 65 | 66 | writeln!(file, "$ {:?}", command.join(" ")).map_err(ExecuteErrorKind::Write)?; 67 | 68 | // DISCUSS: how to pipe stdout to the same file? 69 | // Do we need the duct crate? 70 | // https://stackoverflow.com/a/41025699 71 | // It's not immediately obvious to me how we pass `command` 72 | // to a duct `cmd`, but I bet there's a way to separate 73 | // the head and the tail from our slice. 74 | runner.stdout(Stdio::null()); 75 | runner.stderr(file); 76 | 77 | for argument in command.iter().skip(1) { 78 | runner.arg(argument); 79 | } 80 | 81 | // Try to run the command 82 | let status = runner.status().map_err(|err| { 83 | ExecuteErrorKind::Exec( 84 | err, 85 | command 86 | .iter() 87 | .map(ToOwned::to_owned) 88 | .map(ToOwned::to_owned) 89 | .collect(), 90 | ) 91 | })?; 92 | 93 | // Return an Err if the exit status is non-zero 94 | if let Err(error) = status.exit_ok() { 95 | return Err(ExecuteErrorKind::Child( 96 | error, 97 | command 98 | .iter() 99 | .map(ToOwned::to_owned) 100 | .map(ToOwned::to_owned) 101 | .collect(), 102 | )); 103 | } 104 | Ok(()) 105 | })() 106 | .map_err(|kind| ExecuteError { kind }) 107 | } 108 | -------------------------------------------------------------------------------- /src/git2_repository.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | error::Error, 3 | fmt::Display, 4 | ops::Deref, 5 | path::{Path, PathBuf}, 6 | }; 7 | 8 | use git2::{Commit, RepositoryState}; 9 | 10 | use crate::default_branch::DefaultBranch; 11 | 12 | pub(crate) struct Repository(git2::Repository); 13 | 14 | impl Deref for Repository { 15 | type Target = git2::Repository; 16 | 17 | fn deref(&self) -> &Self::Target { 18 | &self.0 19 | } 20 | } 21 | 22 | impl From for Repository { 23 | fn from(value: git2::Repository) -> Self { 24 | Self(value) 25 | } 26 | } 27 | 28 | #[derive(Debug)] 29 | #[non_exhaustive] 30 | pub(crate) struct FromPathError { 31 | path: PathBuf, 32 | kind: FromPathErrorKind, 33 | } 34 | 35 | impl Display for FromPathError { 36 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 37 | match &self.kind { 38 | FromPathErrorKind::OpenRepository(_) => { 39 | write!(f, "unable to open git repository in {:?}", self.path) 40 | } 41 | FromPathErrorKind::OperationInProgress(state) => write!( 42 | f, 43 | "expected repository to be in a clean state, got {state:?}" 44 | ), 45 | FromPathErrorKind::UncommittedFiles => write!( 46 | f, 47 | "repository contains staged or unstaged changes to tracked files" 48 | ), 49 | FromPathErrorKind::Git(_) => write!(f, "git error"), 50 | } 51 | } 52 | } 53 | 54 | impl Error for FromPathError { 55 | fn source(&self) -> Option<&(dyn Error + 'static)> { 56 | match &self.kind { 57 | FromPathErrorKind::OpenRepository(err) => Some(err), 58 | FromPathErrorKind::OperationInProgress(_) => None, 59 | FromPathErrorKind::UncommittedFiles => None, 60 | FromPathErrorKind::Git(err) => Some(err), 61 | } 62 | } 63 | } 64 | 65 | #[derive(Debug)] 66 | pub(crate) enum FromPathErrorKind { 67 | #[non_exhaustive] 68 | OpenRepository(git2::Error), 69 | #[non_exhaustive] 70 | OperationInProgress(RepositoryState), 71 | #[non_exhaustive] 72 | UncommittedFiles, 73 | #[non_exhaustive] 74 | Git(git2::Error), 75 | } 76 | 77 | impl TryFrom<&Path> for Repository { 78 | type Error = FromPathError; 79 | 80 | fn try_from(root: &Path) -> Result { 81 | (|| { 82 | let repo: Repository = git2::Repository::open(root) 83 | .map(Into::into) 84 | .map_err(FromPathErrorKind::OpenRepository)?; 85 | 86 | repo.assert_repository_state_is_clean()?; 87 | repo.assert_tree_matches_workdir_with_index()?; 88 | Ok(repo) 89 | })() 90 | .map_err(|kind| FromPathError { 91 | path: root.to_owned(), 92 | kind, 93 | }) 94 | } 95 | } 96 | 97 | #[derive(Debug)] 98 | #[non_exhaustive] 99 | pub(crate) struct BaseCommitError { 100 | base: DefaultBranch, 101 | kind: BaseCommitErrorKind, 102 | } 103 | 104 | impl Display for BaseCommitError { 105 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 106 | match &self.kind { 107 | BaseCommitErrorKind::Revparse(_) => write!(f, "git rev-parse error"), 108 | BaseCommitErrorKind::AmbigiousBase => { 109 | write!(f, "expected --base to identify a ref, got {}", self.base) 110 | } 111 | } 112 | } 113 | } 114 | 115 | impl Error for BaseCommitError { 116 | fn source(&self) -> Option<&(dyn Error + 'static)> { 117 | match &self.kind { 118 | BaseCommitErrorKind::Revparse(err) => Some(err), 119 | BaseCommitErrorKind::AmbigiousBase => None, 120 | } 121 | } 122 | } 123 | 124 | #[derive(Debug)] 125 | pub(crate) enum BaseCommitErrorKind { 126 | #[non_exhaustive] 127 | Revparse(git2::Error), 128 | #[non_exhaustive] 129 | AmbigiousBase, 130 | } 131 | 132 | #[derive(Debug)] 133 | #[non_exhaustive] 134 | pub(crate) struct WalkCommitsError { 135 | kind: WalkCommitsErrorKind, 136 | } 137 | 138 | impl Display for WalkCommitsError { 139 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 140 | match &self.kind { 141 | WalkCommitsErrorKind::Revwalk(_) => write!(f, "git2::revwalk error"), 142 | WalkCommitsErrorKind::Push(_) => write!(f, "git2::push_head error"), 143 | WalkCommitsErrorKind::Hide(_) => write!(f, "git2::hide error"), 144 | WalkCommitsErrorKind::SetSorting(_) => write!(f, "git2::set_sorting error"), 145 | } 146 | } 147 | } 148 | 149 | impl Error for WalkCommitsError { 150 | fn source(&self) -> Option<&(dyn Error + 'static)> { 151 | match &self.kind { 152 | WalkCommitsErrorKind::Revwalk(err) => Some(err), 153 | WalkCommitsErrorKind::Push(err) => Some(err), 154 | WalkCommitsErrorKind::Hide(err) => Some(err), 155 | WalkCommitsErrorKind::SetSorting(err) => Some(err), 156 | } 157 | } 158 | } 159 | 160 | #[derive(Debug)] 161 | pub(crate) enum WalkCommitsErrorKind { 162 | #[non_exhaustive] 163 | Revwalk(git2::Error), 164 | #[non_exhaustive] 165 | Push(git2::Error), 166 | #[non_exhaustive] 167 | Hide(git2::Error), 168 | #[non_exhaustive] 169 | SetSorting(git2::Error), 170 | } 171 | 172 | impl Repository { 173 | /// Return an error if the repository state is not clean. 174 | /// 175 | /// This prevents invoking `git disjoint` on a repository in the middle 176 | /// of some other operation, like a `git rebase`. 177 | fn assert_repository_state_is_clean(&self) -> Result<(), FromPathErrorKind> { 178 | let state = self.state(); 179 | match state { 180 | RepositoryState::Clean => Ok(()), 181 | _ => Err(FromPathErrorKind::OperationInProgress(state)), 182 | } 183 | } 184 | 185 | /// Return an error if there are any diffs to tracked files, staged or unstaged. 186 | /// 187 | /// This emulates `git diff` by diffing the tree to the index and the index to 188 | /// the working directory and blending the results into a single diff that includes 189 | /// staged, deletec, etc. 190 | /// 191 | /// This check currently excludes untracked files, but I'm not tied to this behavior. 192 | fn assert_tree_matches_workdir_with_index(&self) -> Result<(), FromPathErrorKind> { 193 | let files_changed = (|| { 194 | let originally_checked_out_commit = self.head()?.resolve()?.peel_to_commit()?; 195 | let originally_checked_out_tree = originally_checked_out_commit.tree()?; 196 | 197 | let files_changed = self 198 | .diff_tree_to_workdir_with_index(Some(&originally_checked_out_tree), None)? 199 | .stats()? 200 | .files_changed(); 201 | Ok(files_changed) 202 | })() 203 | .map_err(FromPathErrorKind::Git)?; 204 | 205 | match files_changed { 206 | 0 => Ok(()), 207 | _ => Err(FromPathErrorKind::UncommittedFiles), 208 | } 209 | } 210 | 211 | /// Assumption: `base` indicates a single commit 212 | /// Assumption: `origin` is the upstream/main repositiory 213 | pub fn base_commit(&self, base: &DefaultBranch) -> Result { 214 | (|| { 215 | let start_point = self 216 | .revparse_single(&format!("origin/{}", &base.0)) 217 | .map_err(BaseCommitErrorKind::Revparse)?; 218 | start_point 219 | .as_commit() 220 | .ok_or(BaseCommitErrorKind::AmbigiousBase) 221 | .cloned() 222 | })() 223 | .map_err(|kind| BaseCommitError { 224 | base: base.to_owned(), 225 | kind, 226 | }) 227 | } 228 | 229 | /// Identify commits by topologically traversing commits starting from HEAD 230 | /// and working towards base (no parents before all its children are shown). 231 | pub fn commits_since_base<'repo>( 232 | &'repo self, 233 | base: &'repo Commit, 234 | ) -> Result>, WalkCommitsError> { 235 | macro_rules! filter_try { 236 | ($e:expr) => { 237 | match $e { 238 | Ok(t) => t, 239 | Err(_) => return None, 240 | } 241 | }; 242 | } 243 | 244 | let revwalk = (|| { 245 | let mut revwalk = self.revwalk().map_err(WalkCommitsErrorKind::Revwalk)?; 246 | 247 | // Starting from HEAD 248 | revwalk.push_head().map_err(WalkCommitsErrorKind::Push)?; 249 | 250 | // ignore the base branch and all of its ancestors 251 | revwalk 252 | .hide(base.id()) 253 | .map_err(WalkCommitsErrorKind::Hide)?; 254 | 255 | // then reverse the ordering 256 | revwalk 257 | .set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE) 258 | .map_err(WalkCommitsErrorKind::SetSorting)?; 259 | 260 | Ok(revwalk) 261 | })() 262 | .map_err(|kind| WalkCommitsError { kind })?; 263 | 264 | let iter = revwalk.filter_map(|id| { 265 | // FIXME: do not silently drop errors 266 | let id = filter_try!(id); 267 | let commit = filter_try!(self.find_commit(id)); 268 | Some(commit) 269 | }); 270 | 271 | Ok(iter) 272 | } 273 | } 274 | 275 | // TEST: can possibly find inspiration from 276 | // https://github.com/libgit2/libgit2/blob/ccb1b990b0d105a7a9d7cb4d870d8033c47a69f2/tests/revwalk/basic.c 277 | -------------------------------------------------------------------------------- /src/github_repository_metadata.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashSet, error::Error, fmt::Display, io, path::PathBuf, process::Command, 3 | string::FromUtf8Error, 4 | }; 5 | 6 | use parse_git_url::GitUrl; 7 | 8 | use crate::git2_repository::{self, Repository}; 9 | 10 | pub(crate) struct GithubRepositoryMetadata { 11 | pub owner: String, 12 | // Terminology from https://stackoverflow.com/a/72018520 13 | // Best to clean that up 14 | pub forker: String, 15 | pub remote: String, 16 | pub name: String, 17 | pub root: PathBuf, 18 | pub repository: Repository, 19 | } 20 | 21 | #[derive(Debug)] 22 | #[non_exhaustive] 23 | pub(crate) struct TryDefaultError { 24 | kind: TryDefaultErrorKind, 25 | } 26 | 27 | impl Display for TryDefaultError { 28 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 29 | match &self.kind { 30 | TryDefaultErrorKind::RunCommand(_) => write!(f, "error running command"), 31 | TryDefaultErrorKind::ParseCommandOutput(_) => write!(f, "command output contains invalid UTF-8"), 32 | TryDefaultErrorKind::OpenRepository(_) => write!(f, "unable to open git repository"), 33 | TryDefaultErrorKind::ParseGitUrl => write!(f, "unable to parse git remote"), 34 | TryDefaultErrorKind::ListRemotes(_) => write!(f, "unable to list git remotes"), 35 | TryDefaultErrorKind::AmbiguousGitRemote => write!(f, "unable to choose a git remote to push to, expected to find a remote named 'fork' or 'origin'"), 36 | } 37 | } 38 | } 39 | 40 | impl Error for TryDefaultError { 41 | fn source(&self) -> Option<&(dyn Error + 'static)> { 42 | match &self.kind { 43 | TryDefaultErrorKind::RunCommand(err) => Some(err), 44 | TryDefaultErrorKind::ParseCommandOutput(err) => Some(err), 45 | TryDefaultErrorKind::OpenRepository(err) => Some(err), 46 | TryDefaultErrorKind::ParseGitUrl => None, 47 | TryDefaultErrorKind::ListRemotes(err) => Some(err), 48 | TryDefaultErrorKind::AmbiguousGitRemote => None, 49 | } 50 | } 51 | } 52 | 53 | #[derive(Debug)] 54 | pub(crate) enum TryDefaultErrorKind { 55 | #[non_exhaustive] 56 | RunCommand(io::Error), 57 | #[non_exhaustive] 58 | ParseCommandOutput(FromUtf8Error), 59 | #[non_exhaustive] 60 | OpenRepository(git2_repository::FromPathError), 61 | #[non_exhaustive] 62 | ParseGitUrl, 63 | #[non_exhaustive] 64 | ListRemotes(git2::Error), 65 | #[non_exhaustive] 66 | AmbiguousGitRemote, 67 | } 68 | 69 | impl From for TryDefaultError { 70 | fn from(kind: TryDefaultErrorKind) -> Self { 71 | Self { kind } 72 | } 73 | } 74 | 75 | impl From for TryDefaultError { 76 | fn from(err: git2_repository::FromPathError) -> Self { 77 | Self { 78 | kind: TryDefaultErrorKind::OpenRepository(err), 79 | } 80 | } 81 | } 82 | 83 | impl GithubRepositoryMetadata { 84 | pub fn try_default() -> Result { 85 | let repo_root = get_repository_root()?; 86 | let repository = repo_root.as_path().try_into()?; 87 | let origin = get_remote_url("origin")?; 88 | let remote = get_user_remote(&repository)?; 89 | 90 | Ok(GithubRepositoryMetadata { 91 | owner: origin.owner.unwrap(), 92 | forker: get_remote_url(&remote)?.owner.unwrap(), 93 | remote, 94 | name: origin.name, 95 | root: get_repository_root()?, 96 | repository, 97 | }) 98 | } 99 | } 100 | 101 | fn get_user_remote(repo: &Repository) -> Result { 102 | let repo_remotes = repo.remotes().map_err(TryDefaultErrorKind::ListRemotes)?; 103 | let mut remotes: HashSet<&str> = repo_remotes.iter().flatten().collect(); 104 | 105 | remotes 106 | .take("fork") 107 | .or_else(|| remotes.take("origin")) 108 | .map(|str| str.to_owned()) 109 | .ok_or(TryDefaultErrorKind::AmbiguousGitRemote) 110 | } 111 | 112 | fn get_repository_root() -> Result { 113 | let output_buffer = Command::new("git") 114 | .arg("rev-parse") 115 | .arg("--show-toplevel") 116 | .output() 117 | .map_err(TryDefaultErrorKind::RunCommand)? 118 | .stdout; 119 | let output = String::from_utf8(output_buffer) 120 | .map_err(TryDefaultErrorKind::ParseCommandOutput)? 121 | .trim() 122 | .to_owned(); 123 | Ok(PathBuf::from(output)) 124 | } 125 | 126 | fn get_remote_url(remote: &str) -> Result { 127 | let output_buffer = Command::new("git") 128 | .arg("config") 129 | .arg("--get") 130 | .arg(format!("remote.{remote}.url")) 131 | .output() 132 | .map_err(TryDefaultErrorKind::RunCommand)? 133 | .stdout; 134 | let output = String::from_utf8(output_buffer) 135 | .map_err(TryDefaultErrorKind::ParseCommandOutput)? 136 | .trim() 137 | .to_owned(); 138 | GitUrl::parse(&output).map_err(|_| TryDefaultErrorKind::ParseGitUrl) 139 | } 140 | -------------------------------------------------------------------------------- /src/interact.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashSet, error::Error, fmt::Display}; 2 | 3 | use inquire::{formatter::MultiOptionFormatter, MultiSelect}; 4 | 5 | use crate::issue_group::IssueGroup; 6 | 7 | #[derive(Debug)] 8 | pub(crate) enum IssueGroupWhitelist { 9 | Whitelist(HashSet), 10 | WhitelistDNE, 11 | } 12 | 13 | #[derive(Debug)] 14 | #[non_exhaustive] 15 | pub(crate) struct SelectIssuesError { 16 | kind: SelectIssuesErrorKind, 17 | } 18 | 19 | impl Display for SelectIssuesError { 20 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 21 | match &self.kind { 22 | SelectIssuesErrorKind::Prompt(_) => write!(f, "unable to process issue selection"), 23 | } 24 | } 25 | } 26 | 27 | impl Error for SelectIssuesError { 28 | fn source(&self) -> Option<&(dyn Error + 'static)> { 29 | match &self.kind { 30 | SelectIssuesErrorKind::Prompt(err) => Some(err), 31 | } 32 | } 33 | } 34 | 35 | #[derive(Debug)] 36 | pub(crate) enum SelectIssuesErrorKind { 37 | #[non_exhaustive] 38 | Prompt(inquire::InquireError), 39 | } 40 | 41 | impl From for SelectIssuesError { 42 | fn from(err: inquire::InquireError) -> Self { 43 | Self { 44 | kind: SelectIssuesErrorKind::Prompt(err), 45 | } 46 | } 47 | } 48 | 49 | // TODO: refactor into a better abstraction -- this seems coupled with IssueGroupMap 50 | pub(crate) fn prompt_user<'a, I: Iterator>( 51 | choices: I, 52 | ) -> Result, SelectIssuesError> { 53 | let formatter: MultiOptionFormatter<&IssueGroup> = 54 | &|selected| format!("Selected: {selected:?}"); 55 | 56 | Ok( 57 | MultiSelect::new("Select the issues to create PRs for:", choices.collect()) 58 | .with_formatter(formatter) 59 | .prompt()? 60 | .into_iter() 61 | .cloned() 62 | .collect(), 63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /src/issue.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | use std::sync::OnceLock; 3 | 4 | macro_rules! regex { 5 | ($re:literal $(,)?) => {{ 6 | static RE: OnceLock = OnceLock::new(); 7 | RE.get_or_init(|| regex::Regex::new($re).unwrap()) 8 | }}; 9 | } 10 | 11 | /// Jira or GitHub issue identifier. 12 | #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] 13 | #[cfg_attr(test, derive(proptest_derive::Arbitrary))] 14 | pub(crate) enum Issue { 15 | Jira(String), 16 | GitHub(String), 17 | } 18 | 19 | impl Issue { 20 | pub fn parse_from_commit_message>(commit_message: S) -> Option { 21 | let regex_jira_issue = regex!(r"(?m)^(?:Closes )?Ticket:\s+(\S+)"); 22 | if let Some(jira_captures) = regex_jira_issue.captures(commit_message.as_ref()) { 23 | return Some(Issue::Jira( 24 | jira_captures[jira_captures.len() - 1].to_owned(), 25 | )); 26 | } 27 | 28 | let regex_github_issue = regex!(r"(?im)^(?:closes|close|closed|fixes|fixed)\s+#(\d+)"); 29 | if let Some(github_captures) = regex_github_issue.captures(commit_message.as_ref()) { 30 | return Some(Issue::GitHub( 31 | github_captures[github_captures.len() - 1].to_owned(), 32 | )); 33 | } 34 | None 35 | } 36 | 37 | pub fn issue_identifier(&self) -> &str { 38 | match self { 39 | Issue::Jira(ticket) => ticket, 40 | Issue::GitHub(issue) => issue, 41 | } 42 | } 43 | } 44 | 45 | impl Display for Issue { 46 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 47 | write!( 48 | f, 49 | "{}{}", 50 | match self { 51 | Issue::Jira(_) => "Jira ", 52 | Issue::GitHub(_) => "GitHub #", 53 | }, 54 | self.issue_identifier() 55 | ) 56 | } 57 | } 58 | 59 | #[cfg(test)] 60 | mod test { 61 | use super::Issue; 62 | 63 | #[test] 64 | fn display_jira_issue() { 65 | let issue = Issue::Jira("GD-0".to_string()); 66 | assert_eq!(format!("{issue}"), "Jira GD-0"); 67 | } 68 | 69 | #[test] 70 | fn display_github_issue() { 71 | let issue = Issue::GitHub("123".to_string()); 72 | assert_eq!(format!("{issue}"), "GitHub #123"); 73 | } 74 | 75 | macro_rules! test_parses { 76 | ($unit_test:ident, $input:expr, $output:expr) => { 77 | #[test] 78 | fn $unit_test() { 79 | let message = $input; 80 | let issue = Issue::parse_from_commit_message(message); 81 | assert!( 82 | issue.is_some(), 83 | "Expected to parse issue from commit message" 84 | ); 85 | let issue = issue.unwrap(); 86 | assert_eq!(issue, $output); 87 | } 88 | }; 89 | } 90 | 91 | test_parses!( 92 | successfully_parse_jira_ticket_from_commit_message_without_newline, 93 | r#" 94 | feat(foo): add hyperdrive 95 | 96 | Ticket: AB-123 97 | "#, 98 | Issue::Jira("AB-123".to_string()) 99 | ); 100 | 101 | test_parses!( 102 | successfully_parse_jira_ticket_from_commit_message_with_newline, 103 | r#" 104 | feat(foo): add hyperdrive 105 | 106 | Ticket: AB-123 107 | 108 | "#, 109 | Issue::Jira("AB-123".to_string()) 110 | ); 111 | 112 | test_parses!( 113 | successfully_parse_jira_ticket_from_commit_message_with_trailer, 114 | r#" 115 | feat(foo): add hyperdrive 116 | 117 | Ticket: AB-123 118 | Footer: http://example.com 119 | "#, 120 | Issue::Jira("AB-123".to_string()) 121 | ); 122 | 123 | test_parses!( 124 | successfully_parse_jira_ticket_closes_ticket_from_commit_message_without_newline, 125 | r#" 126 | feat(foo): add hyperdrive 127 | 128 | Closes Ticket: AB-123 129 | "#, 130 | Issue::Jira("AB-123".to_string()) 131 | ); 132 | 133 | test_parses!( 134 | successfully_parse_jira_ticket_closes_ticket_from_commit_message_with_newline, 135 | r#" 136 | feat(foo): add hyperdrive 137 | 138 | Closes Ticket: AB-123 139 | 140 | "#, 141 | Issue::Jira("AB-123".to_string()) 142 | ); 143 | 144 | test_parses!( 145 | successfully_parse_jira_ticket_closes_ticket_from_commit_message_with_trailer, 146 | r#" 147 | feat(foo): add hyperdrive 148 | 149 | Closes Ticket: AB-123 150 | Footer: http://example.com 151 | "#, 152 | Issue::Jira("AB-123".to_string()) 153 | ); 154 | 155 | test_parses!( 156 | successfully_parse_github_issue_from_commit_message_without_newline, 157 | r#" 158 | feat(foo): add hyperdrive 159 | 160 | Closes #123 161 | "#, 162 | Issue::GitHub("123".to_string()) 163 | ); 164 | 165 | test_parses!( 166 | successfully_parse_github_issue_from_commit_message_with_newline, 167 | r#" 168 | feat(foo): add hyperdrive 169 | 170 | Closes #123 171 | "#, 172 | Issue::GitHub("123".to_string()) 173 | ); 174 | 175 | test_parses!( 176 | successfully_parse_github_issue_from_commit_message_with_trailer, 177 | r#" 178 | feat(foo): add hyperdrive 179 | 180 | Closes #123 181 | Footer: http://example.com 182 | "#, 183 | Issue::GitHub("123".to_string()) 184 | ); 185 | 186 | test_parses!( 187 | successfully_parse_github_issue_closes_ticket_from_commit_message_without_newline, 188 | r#" 189 | feat(foo): add hyperdrive 190 | 191 | Closes #123 192 | "#, 193 | Issue::GitHub("123".to_string()) 194 | ); 195 | 196 | test_parses!( 197 | successfully_parse_github_issue_closes_ticket_from_commit_message_with_newline, 198 | r#" 199 | feat(foo): add hyperdrive 200 | 201 | Closes #123 202 | 203 | "#, 204 | Issue::GitHub("123".to_string()) 205 | ); 206 | 207 | test_parses!( 208 | successfully_parse_github_issue_closes_ticket_from_commit_message_with_trailer, 209 | r#" 210 | feat(foo): add hyperdrive 211 | 212 | Closes #123 213 | Footer: http://example.com 214 | "#, 215 | Issue::GitHub("123".to_string()) 216 | ); 217 | 218 | #[test] 219 | fn unnsuccessfully_parse_from_commit_message() { 220 | let message = "feat(foo): add hyperdrive"; 221 | let issue = Issue::parse_from_commit_message(message); 222 | assert!( 223 | issue.is_none(), 224 | "Expected to find no issue to parse from commit message" 225 | ); 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/issue_group.rs: -------------------------------------------------------------------------------- 1 | use std::{error::Error, fmt::Display}; 2 | 3 | use git2::Commit; 4 | 5 | use crate::issue::Issue; 6 | 7 | #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 8 | pub(crate) struct GitCommitSummary(pub String); 9 | 10 | impl Display for GitCommitSummary { 11 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 12 | write!(f, "{}", self.0) 13 | } 14 | } 15 | 16 | #[derive(Debug)] 17 | #[non_exhaustive] 18 | pub(crate) struct FromCommitError { 19 | commit: git2::Oid, 20 | kind: FromCommitErrorKind, 21 | } 22 | 23 | impl Display for FromCommitError { 24 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 25 | match &self.kind { 26 | FromCommitErrorKind::InvalidUtf8 => { 27 | write!(f, "summary for commit {:?} is not valid UTF-8", self.commit) 28 | } 29 | } 30 | } 31 | } 32 | 33 | impl Error for FromCommitError { 34 | fn source(&self) -> Option<&(dyn Error + 'static)> { 35 | match &self.kind { 36 | FromCommitErrorKind::InvalidUtf8 => None, 37 | } 38 | } 39 | } 40 | 41 | #[derive(Debug)] 42 | pub(crate) enum FromCommitErrorKind { 43 | #[non_exhaustive] 44 | InvalidUtf8, 45 | } 46 | 47 | impl<'a> TryFrom<&Commit<'a>> for GitCommitSummary { 48 | type Error = FromCommitError; 49 | 50 | fn try_from(commit: &Commit) -> Result { 51 | Ok(Self( 52 | commit 53 | .summary() 54 | .ok_or(FromCommitError { 55 | commit: commit.id(), 56 | kind: FromCommitErrorKind::InvalidUtf8, 57 | })? 58 | .to_owned(), 59 | )) 60 | } 61 | } 62 | 63 | #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 64 | pub(crate) enum IssueGroup { 65 | Issue(Issue), 66 | Commit(GitCommitSummary), 67 | } 68 | 69 | impl Display for IssueGroup { 70 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 71 | match self { 72 | IssueGroup::Issue(issue) => write!(f, "{issue}"), 73 | IssueGroup::Commit(commit) => write!(f, "{commit}"), 74 | } 75 | } 76 | } 77 | 78 | impl From for IssueGroup { 79 | fn from(value: GitCommitSummary) -> Self { 80 | Self::Commit(value) 81 | } 82 | } 83 | 84 | impl From for IssueGroup { 85 | fn from(value: Issue) -> Self { 86 | Self::Issue(value) 87 | } 88 | } 89 | 90 | #[cfg(test)] 91 | mod test_display { 92 | use crate::{issue::Issue, issue_group::GitCommitSummary}; 93 | 94 | use super::IssueGroup; 95 | 96 | fn check>(issue_group: I, displays_as: &str) { 97 | assert_eq!(displays_as, format!("{}", issue_group.into())); 98 | } 99 | 100 | #[test] 101 | fn display_human_readable_issue() { 102 | check(Issue::Jira("COOL-123".to_string()), "Jira COOL-123"); 103 | } 104 | 105 | #[test] 106 | fn display_human_readable_commit() { 107 | check( 108 | GitCommitSummary(String::from("this is a cool summary")), 109 | "this is a cool summary", 110 | ); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/issue_group_map.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashSet, 3 | error::Error, 4 | fmt::Display, 5 | io::{self, Write}, 6 | }; 7 | 8 | use git2::Commit; 9 | use indexmap::IndexMap; 10 | 11 | use crate::{ 12 | cli::{ 13 | CommitGrouping, CommitsToConsider, OverlayCommitsIntoOnePullRequest, 14 | PromptUserToChooseCommits, 15 | }, 16 | interact::{prompt_user, IssueGroupWhitelist, SelectIssuesError}, 17 | issue::Issue, 18 | issue_group::{self, GitCommitSummary, IssueGroup}, 19 | }; 20 | 21 | #[derive(Debug, Default)] 22 | pub(crate) struct IssueGroupMap<'repo>(IndexMap>>); 23 | 24 | impl<'repo> IntoIterator for IssueGroupMap<'repo> { 25 | type Item = (IssueGroup, Vec>); 26 | 27 | type IntoIter = indexmap::map::IntoIter>>; 28 | 29 | fn into_iter(self) -> Self::IntoIter { 30 | self.0.into_iter() 31 | } 32 | } 33 | 34 | impl<'repo> FromIterator<(IssueGroup, Vec>)> for IssueGroupMap<'repo> { 35 | fn from_iter>)>>(iter: T) -> Self { 36 | Self(iter.into_iter().collect()) 37 | } 38 | } 39 | 40 | #[derive(Debug)] 41 | #[non_exhaustive] 42 | pub(crate) struct FromCommitsError { 43 | kind: FromCommitsErrorKind, 44 | } 45 | 46 | impl Display for FromCommitsError { 47 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 48 | match &self.kind { 49 | FromCommitsErrorKind::FromCommit(_) => write!(f, "unable to get commit summary"), 50 | FromCommitsErrorKind::IO(_) => write!(f, "unable to write to stream"), 51 | } 52 | } 53 | } 54 | 55 | impl Error for FromCommitsError { 56 | fn source(&self) -> Option<&(dyn Error + 'static)> { 57 | match &self.kind { 58 | FromCommitsErrorKind::FromCommit(err) => Some(err), 59 | FromCommitsErrorKind::IO(err) => Some(err), 60 | } 61 | } 62 | } 63 | 64 | #[derive(Debug)] 65 | pub(crate) enum FromCommitsErrorKind { 66 | #[non_exhaustive] 67 | FromCommit(issue_group::FromCommitError), 68 | #[non_exhaustive] 69 | IO(io::Error), 70 | } 71 | 72 | impl From for FromCommitsError { 73 | fn from(err: issue_group::FromCommitError) -> Self { 74 | Self { 75 | kind: FromCommitsErrorKind::FromCommit(err), 76 | } 77 | } 78 | } 79 | 80 | impl From for FromCommitsError { 81 | fn from(err: io::Error) -> Self { 82 | Self { 83 | kind: FromCommitsErrorKind::IO(err), 84 | } 85 | } 86 | } 87 | 88 | impl<'repo> IssueGroupMap<'repo> { 89 | fn with_capacity(n: usize) -> Self { 90 | Self(IndexMap::with_capacity(n)) 91 | } 92 | 93 | fn insert(&mut self, key: IssueGroup, value: Vec>) { 94 | self.0.insert(key, value); 95 | } 96 | 97 | pub fn try_from_commits( 98 | commits: I, 99 | commits_to_consider: CommitsToConsider, 100 | commit_grouping: CommitGrouping, 101 | ) -> Result 102 | where 103 | I: IntoIterator>, 104 | { 105 | let mut suffix: u32 = 0; 106 | let mut seen_issue_groups = HashSet::new(); 107 | let commits_by_issue: IndexMap> = commits 108 | .into_iter() 109 | // Parse issue from commit message 110 | .map( 111 | |commit| -> Result, FromCommitsError> { 112 | let issue = commit.message().and_then(Issue::parse_from_commit_message); 113 | // If: 114 | // - we're grouping commits by issue, and 115 | // - this commit includes an issue, 116 | // then add this commit to that issue's group. 117 | if commit_grouping == CommitGrouping::ByIssue { 118 | if let Some(issue) = issue { 119 | return Ok(Some((issue.into(), commit))); 120 | } 121 | } 122 | 123 | // If: 124 | // - we're treating every commit separately, or 125 | // - we're considering all commits (even commits without an issue), 126 | // add this commit to a unique issue-group. 127 | if commit_grouping == CommitGrouping::Individual 128 | || commits_to_consider == CommitsToConsider::All 129 | { 130 | let summary: GitCommitSummary = (&commit).try_into()?; 131 | let mut proposed_issue_group = summary.clone(); 132 | 133 | // Use unique issue group names so each commit is 134 | // addressable in the selection menu. 135 | // DISCUSS: would it be better to use an array? 136 | // No, because there's so much ambiguity. Should we expose the 137 | // commit hash? Probably 138 | while seen_issue_groups.contains(&proposed_issue_group) { 139 | suffix += 1; 140 | proposed_issue_group = GitCommitSummary(format!("{summary}_{suffix}")); 141 | } 142 | 143 | seen_issue_groups.insert(proposed_issue_group.clone()); 144 | 145 | return Ok(Some((IssueGroup::Commit(proposed_issue_group), commit))); 146 | } 147 | 148 | // Otherwise, skip this commit. 149 | writeln!( 150 | io::stderr(), 151 | "Warning: ignoring commit without issue trailer: {:?}", 152 | commit.id() 153 | )?; 154 | Ok(None) 155 | }, 156 | ) 157 | .filter_map(Result::transpose) 158 | .try_fold( 159 | Default::default(), 160 | |mut map, 161 | maybe_tuple| 162 | -> Result>, FromCommitsError> { 163 | let (issue, commit) = maybe_tuple?; 164 | let commits = map.entry(issue).or_default(); 165 | commits.push(commit); 166 | Ok(map) 167 | }, 168 | )?; 169 | 170 | Ok(Self(commits_by_issue)) 171 | } 172 | 173 | pub fn select_issues( 174 | self, 175 | choose: PromptUserToChooseCommits, 176 | overlay: OverlayCommitsIntoOnePullRequest, 177 | ) -> Result { 178 | let selected_issue_groups: IssueGroupWhitelist = { 179 | if choose == PromptUserToChooseCommits::No 180 | && overlay == OverlayCommitsIntoOnePullRequest::No 181 | { 182 | IssueGroupWhitelist::WhitelistDNE 183 | } else { 184 | let keys = self.0.keys(); 185 | IssueGroupWhitelist::Whitelist(prompt_user(keys)?) 186 | } 187 | }; 188 | 189 | Ok(match &selected_issue_groups { 190 | // If there is a whitelist, only operate on issue_groups in the whitelist 191 | IssueGroupWhitelist::Whitelist(whitelist) => self 192 | .into_iter() 193 | .filter(|(issue_group, _commits)| whitelist.contains(issue_group)) 194 | .collect(), 195 | // If there is no whitelist, then operate on every issue 196 | IssueGroupWhitelist::WhitelistDNE => self, 197 | }) 198 | } 199 | 200 | pub fn apply_overlay(self, overlay: OverlayCommitsIntoOnePullRequest) -> Self { 201 | match overlay { 202 | // If we are overlaying all active issue groups into one PR, 203 | // combine all active commits under the first issue group 204 | OverlayCommitsIntoOnePullRequest::Yes => self 205 | .into_iter() 206 | .reduce(|mut accumulator, mut item| { 207 | accumulator.1.append(&mut item.1); 208 | accumulator 209 | }) 210 | // Map the option back into an IndexMap 211 | .map(|(issue_group, commits)| { 212 | let mut map = Self::with_capacity(1); 213 | map.insert(issue_group, commits); 214 | map 215 | }) 216 | .unwrap_or_default(), 217 | // If we are not overlaying issue groups, keep them separate 218 | OverlayCommitsIntoOnePullRequest::No => self, 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/little_anyhow.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | 3 | use crate::log_file::LogFile; 4 | 5 | /// A vendored error type providing the Debug format from `anyhow::Error`. 6 | /// 7 | /// This error type is meant to be used in one place in your binary: the error 8 | /// type of the `Result` your `main` function returns. This converts any error 9 | /// your `main` function produces into a `little_anyhow::Error`, which provides 10 | /// human-readable error messages for your users. 11 | /// 12 | /// Error messages look like: 13 | /// 14 | /// ```ignore 15 | /// Error: error reading `Blocks.txt` 16 | /// 17 | /// Caused by: 18 | /// 0: invalid Blocks.txt data on line 223 19 | /// 1: one end of range is not a valid hexidecimal integer 20 | /// 2: invalid digit found in string 21 | /// ``` 22 | /// 23 | /// For more information, see: 24 | /// 25 | /// - [Modular Errors in Rust] 26 | /// - [little-anyhow] 27 | /// 28 | /// [modular errors in rust]: https://sabrinajewson.org/blog/errors#guidelines-for-good-errors 29 | /// [little-anyhow]: https://github.com/EricCrosson/little-anyhow 30 | /// 31 | /// # Examples 32 | /// 33 | /// ```should_panic 34 | /// use std::io::{self, Write}; 35 | /// 36 | /// // Return `Result<(), little_anyhow::Error>` from `main` for 37 | /// // human-readable errors from your binary 38 | /// fn main() -> Result<(), little_anyhow::Error> { 39 | /// writeln!( 40 | /// io::stdout(), 41 | /// "You can create a little_anyhow::Error from any type implementing `std::error::Error`" 42 | /// )?; 43 | /// 44 | /// let simulated_error = std::fmt::Error; // an easy-to-create error type 45 | /// Err(simulated_error)? 46 | /// } 47 | /// ``` 48 | pub(crate) struct Error { 49 | err: Box, 50 | log_file: Option, 51 | } 52 | 53 | impl Error { 54 | pub fn new(err: crate::error::Error, log_file: LogFile) -> Self { 55 | Self { 56 | err: Box::new(err), 57 | log_file: Some(log_file), 58 | } 59 | } 60 | } 61 | 62 | impl std::fmt::Debug for Error { 63 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 64 | write!(f, "{}", self.err)?; 65 | 66 | if let Some(source) = self.err.source() { 67 | write!(f, "\n\nCaused by:")?; 68 | let mut n: u32 = 0; 69 | let mut error = Some(source); 70 | while let Some(current_error) = error { 71 | write!(f, "\n {}: {}", n, current_error)?; 72 | n += 1; 73 | error = current_error.source(); 74 | } 75 | } 76 | 77 | if let Some(log_file) = &self.log_file { 78 | if let Ok(log_contents) = fs::read_to_string(&log_file.0) { 79 | writeln!(f, "\n\nLog contents:")?; 80 | writeln!(f, "{}", log_contents)?; 81 | } else { 82 | writeln!(f, "\n\nFailed to read log file: {:?}", log_file)?; 83 | } 84 | writeln!(f, "\nLog file: {:?}", log_file.0)?; 85 | } else { 86 | writeln!(f, "\n\nNo log file available.")?; 87 | } 88 | 89 | Ok(()) 90 | } 91 | } 92 | 93 | impl From for Error 94 | where 95 | E: std::error::Error + 'static, 96 | { 97 | fn from(error: E) -> Self { 98 | Self { 99 | err: Box::new(error), 100 | log_file: None, 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/log_file.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | error::Error, 3 | fmt::Display, 4 | fs, io, 5 | path::{Path, PathBuf}, 6 | }; 7 | 8 | #[derive(Clone, Debug)] 9 | pub(crate) struct LogFile(pub PathBuf); 10 | 11 | impl AsRef for LogFile { 12 | fn as_ref(&self) -> &Path { 13 | &self.0 14 | } 15 | } 16 | 17 | impl Default for LogFile { 18 | fn default() -> Self { 19 | use std::env::temp_dir; 20 | use std::time::{SystemTime, UNIX_EPOCH}; 21 | 22 | let start = SystemTime::now(); 23 | let dir = temp_dir(); 24 | let filename = format!( 25 | "git-disjoint-{:?}", 26 | start.duration_since(UNIX_EPOCH).unwrap() 27 | ); 28 | Self(dir.join(filename)) 29 | } 30 | } 31 | 32 | #[derive(Debug)] 33 | #[non_exhaustive] 34 | pub(crate) struct DeleteError { 35 | path: PathBuf, 36 | kind: DeleteErrorKind, 37 | } 38 | 39 | impl Display for DeleteError { 40 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 41 | match &self.kind { 42 | DeleteErrorKind::Delete(_) => write!(f, "unable to delete file {:?}", self.path), 43 | } 44 | } 45 | } 46 | 47 | impl Error for DeleteError { 48 | fn source(&self) -> Option<&(dyn Error + 'static)> { 49 | match &self.kind { 50 | DeleteErrorKind::Delete(err) => Some(err), 51 | } 52 | } 53 | } 54 | 55 | #[derive(Debug)] 56 | pub(crate) enum DeleteErrorKind { 57 | #[non_exhaustive] 58 | Delete(io::Error), 59 | } 60 | 61 | impl LogFile { 62 | pub fn delete(self) -> Result<(), DeleteError> { 63 | if self.0.exists() { 64 | return fs::remove_file(&self.0).map_err(|err| DeleteError { 65 | path: self.0, 66 | kind: DeleteErrorKind::Delete(err), 67 | }); 68 | } 69 | Ok(()) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![forbid(unsafe_code)] 2 | #![feature(exit_status_error)] 3 | 4 | use std::sync::{mpsc, LazyLock}; 5 | use std::thread::{self, ScopedJoinHandle}; 6 | use std::time::Duration; 7 | 8 | use clap::Parser; 9 | use default_branch::DefaultBranch; 10 | use disjoint_branch::{DisjointBranch, DisjointBranchMap}; 11 | use git2::Commit; 12 | use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; 13 | use issue_group_map::IssueGroupMap; 14 | use log_file::LogFile; 15 | use pull_request::PullRequest; 16 | 17 | mod branch_name; 18 | mod cli; 19 | mod default_branch; 20 | mod disjoint_branch; 21 | mod editor; 22 | mod error; 23 | mod execute; 24 | mod git2_repository; 25 | mod github_repository_metadata; 26 | mod interact; 27 | mod issue; 28 | mod issue_group; 29 | mod issue_group_map; 30 | mod little_anyhow; 31 | mod log_file; 32 | mod pull_request; 33 | mod pull_request_message; 34 | mod pull_request_metadata; 35 | 36 | use crate::branch_name::BranchName; 37 | use crate::cli::Cli; 38 | use crate::editor::interactive_get_pr_metadata; 39 | use crate::error::Error; 40 | use crate::execute::execute; 41 | use crate::github_repository_metadata::GithubRepositoryMetadata; 42 | use crate::issue_group::IssueGroup; 43 | 44 | // DISCUSS: how to handle cherry-pick merge conflicts, and resuming gracefully 45 | // What if we stored a log of what we were going to do before we took any action? 46 | // Or kept it as a list of things to do, removing successful items. 47 | 48 | const PREFIX_PENDING: &str = " "; 49 | const PREFIX_WORKING: &str = ">"; 50 | const PREFIX_DONE: &str = "✔"; 51 | 52 | static STYLE_ISSUE_GROUP_STABLE: LazyLock = 53 | LazyLock::new(|| ProgressStyle::with_template("{prefix:.green} {msg}").unwrap()); 54 | static STYLE_ISSUE_GROUP_WORKING: LazyLock = 55 | LazyLock::new(|| ProgressStyle::with_template("{prefix:.yellow} {msg}").unwrap()); 56 | static STYLE_COMMIT_STABLE: LazyLock = 57 | LazyLock::new(|| ProgressStyle::with_template(" {prefix:.green} {msg}").unwrap()); 58 | static STYLE_COMMIT_WORKING: LazyLock = 59 | LazyLock::new(|| ProgressStyle::with_template(" {spinner:.yellow} {msg}").unwrap()); 60 | 61 | #[derive(Debug)] 62 | struct CommitWork<'repo> { 63 | commit: Commit<'repo>, 64 | progress_bar: ProgressBar, 65 | } 66 | 67 | impl<'repo> Into<&Commit<'repo>> for &'repo CommitWork<'repo> { 68 | fn into(self) -> &'repo Commit<'repo> { 69 | &self.commit 70 | } 71 | } 72 | 73 | impl<'repo> From> for CommitWork<'repo> { 74 | fn from(commit: Commit<'repo>) -> Self { 75 | let progress_bar = ProgressBar::new(1) 76 | .with_style(STYLE_COMMIT_STABLE.clone()) 77 | .with_prefix(PREFIX_PENDING) 78 | .with_message(commit.summary().unwrap().to_string()); 79 | Self { 80 | commit, 81 | progress_bar, 82 | } 83 | } 84 | } 85 | 86 | #[derive(Debug)] 87 | struct WorkOrder<'repo> { 88 | branch_name: BranchName, 89 | commit_work: Vec>, 90 | progress_bar: ProgressBar, 91 | } 92 | 93 | impl<'repo> From<(IssueGroup, DisjointBranch<'repo>)> for WorkOrder<'repo> { 94 | fn from((issue_group, commit_plan): (IssueGroup, DisjointBranch<'repo>)) -> Self { 95 | let num_commits: u64 = commit_plan.commits.len().try_into().unwrap(); 96 | let progress_bar = ProgressBar::new(num_commits) 97 | .with_style(STYLE_ISSUE_GROUP_STABLE.clone()) 98 | .with_prefix(PREFIX_PENDING) 99 | .with_message(format!("{issue_group}")); 100 | // REFACTOR: using into 101 | WorkOrder { 102 | branch_name: commit_plan.branch_name, 103 | // REFACTOR: 104 | commit_work: commit_plan 105 | .commits 106 | .into_iter() 107 | .map(CommitWork::from) 108 | .collect(), 109 | progress_bar, 110 | } 111 | } 112 | } 113 | 114 | fn cherry_pick(commit: String, log_file: LogFile) -> Result<(), Error> { 115 | execute(&["git", "cherry-pick", "--allow-empty", &commit], &log_file).map_err(|err| Error { 116 | kind: error::ErrorKind::CherryPick(err, commit), 117 | }) 118 | } 119 | 120 | fn update_spinner(receiver: mpsc::Receiver, progress_bar: ProgressBar) -> Result<(), Error> { 121 | let mut keep_going = true; 122 | while keep_going { 123 | progress_bar.tick(); 124 | thread::sleep(Duration::from_millis(15)); 125 | match receiver.try_recv() { 126 | Ok(_) => { 127 | keep_going = false; 128 | } 129 | Err(err) => match &err { 130 | mpsc::TryRecvError::Empty => (), // worker thread is not done, so keep updating the UI 131 | mpsc::TryRecvError::Disconnected => panic!("sender should never disconnect"), 132 | }, 133 | } 134 | } 135 | Ok(()) 136 | } 137 | 138 | fn sleep(duration: Duration) -> Result<(), Error> { 139 | thread::sleep(duration); 140 | Ok(()) 141 | } 142 | 143 | fn do_git_disjoint(cli: Cli, log_file: LogFile) -> Result<(), Error> { 144 | thread::scope(|s| { 145 | let Cli { 146 | all, 147 | base: _, 148 | choose, 149 | // REFACTOR: use an enum 150 | dry_run, 151 | github_token, 152 | overlay, 153 | separate, 154 | } = cli; 155 | 156 | let repository_metadata = GithubRepositoryMetadata::try_default()?; 157 | let base_branch = cli.base.clone(); 158 | let base_branch = match base_branch { 159 | Some(base) => DefaultBranch(base), 160 | None => DefaultBranch::try_get_default(&repository_metadata, &github_token)?, 161 | }; 162 | 163 | let GithubRepositoryMetadata { 164 | owner, 165 | forker, 166 | remote, 167 | name, 168 | root, 169 | repository, 170 | } = repository_metadata; 171 | 172 | let base_commit = repository.base_commit(&base_branch)?; 173 | let commits = repository.commits_since_base(&base_commit)?; 174 | // We have to make a first pass to determine the issue groups in play 175 | let commits_by_issue_group = IssueGroupMap::try_from_commits(commits, all, separate)? 176 | // Now filter the set of all issue groups to just the whitelisted issue groups 177 | .select_issues(choose, overlay)? 178 | .apply_overlay(overlay); 179 | 180 | let commit_plan_by_issue_group: DisjointBranchMap = commits_by_issue_group.try_into()?; 181 | 182 | let work_orders: Vec = commit_plan_by_issue_group 183 | .into_iter() 184 | .map(WorkOrder::from) 185 | .collect(); 186 | 187 | // Short-circuit early if there is no work to do. 188 | if work_orders.is_empty() { 189 | return Ok(()); 190 | } 191 | 192 | let http_client = reqwest::blocking::Client::new(); 193 | 194 | let multi_progress_bar = MultiProgress::new(); 195 | 196 | for work_order in work_orders.iter() { 197 | // Insert one progress bar for the issue group. 198 | multi_progress_bar.insert_from_back(0, work_order.progress_bar.clone()); 199 | work_order.progress_bar.tick(); 200 | // and one progress bar for each ticket. 201 | // `tick` is necessary to force a repaint 202 | for commit_work in work_order.commit_work.iter() { 203 | multi_progress_bar.insert_from_back(0, commit_work.progress_bar.clone()); 204 | commit_work.progress_bar.tick(); 205 | } 206 | } 207 | 208 | let mut join_handles: Vec>> = 209 | Vec::with_capacity(work_orders.len()); 210 | 211 | for work_order in work_orders { 212 | work_order 213 | .progress_bar 214 | .set_style(STYLE_ISSUE_GROUP_WORKING.clone()); 215 | work_order.progress_bar.set_prefix(PREFIX_WORKING); 216 | work_order.progress_bar.tick(); 217 | 218 | let branch_ref = format!("refs/heads/{}", work_order.branch_name); 219 | let branch_obj = repository.revparse_single(&branch_ref); 220 | 221 | // If branch already exists, assume we've already handled this ticket 222 | // DISCUSS: in the future, we could compare this branch against the desired 223 | // commits, and add any missing commits to this branch and then update the remote 224 | if branch_obj.is_ok() { 225 | eprintln!( 226 | "Warning: a branch named {:?} already exists", 227 | work_order.branch_name 228 | ); 229 | continue; 230 | } 231 | 232 | if !dry_run { 233 | // Create a branch 234 | repository.branch(work_order.branch_name.as_str(), &base_commit, true)?; 235 | 236 | // Check out the new branch 237 | let branch_obj = repository.revparse_single(&branch_ref)?; 238 | repository.checkout_tree(&branch_obj, None)?; 239 | repository.set_head(&branch_ref)?; 240 | } 241 | 242 | // Cherry-pick commits related to the target issue 243 | for commit_work in work_order.commit_work.iter() { 244 | commit_work 245 | .progress_bar 246 | .set_style(STYLE_COMMIT_WORKING.clone()); 247 | 248 | // If we need to update the UI and perform a blocking action, spawn 249 | // a worker thread. If there's no work to do, we can keep the UI 250 | // activity on the main thread. But we don't, so the dry_run flag 251 | // exercises more of the same code paths as a live run does. 252 | let log_file = log_file.clone(); 253 | thread::scope(|s| { 254 | let (sender, receiver) = mpsc::channel(); 255 | 256 | let progress_bar = commit_work.progress_bar.clone(); 257 | let ui_thread = s.spawn(|| update_spinner(receiver, progress_bar)); 258 | 259 | let commit_hash = commit_work.commit.id().to_string(); 260 | let worker_thread = s.spawn(move || { 261 | let result = match dry_run { 262 | true => sleep(Duration::from_millis(750)), 263 | false => cherry_pick(commit_hash, log_file), 264 | }; 265 | // tell the ui_thread to stop 266 | sender 267 | .send(true) 268 | .expect("should always be able to communicate to UI thread"); 269 | result 270 | }); 271 | ui_thread 272 | .join() 273 | .expect("ui thread should propagate errors instead of panicking")?; 274 | worker_thread 275 | .join() 276 | .expect("worker thread should propagate errors instead of panicking")?; 277 | Ok::<(), Error>(()) 278 | })?; 279 | 280 | commit_work 281 | .progress_bar 282 | .set_style(STYLE_COMMIT_STABLE.clone()); 283 | commit_work.progress_bar.set_prefix(PREFIX_DONE); 284 | commit_work.progress_bar.finish() 285 | } 286 | 287 | if !dry_run { 288 | // Push the branch 289 | execute( 290 | &["git", "push", &remote, (work_order.branch_name.as_str())], 291 | &log_file, 292 | )?; 293 | 294 | // Open a pull request 295 | // Only ask the user to edit the PR metadata when multiple commits 296 | // create ambiguity about the contents of the PR title and body. 297 | let needs_edit = work_order.commit_work.len() > 1; 298 | 299 | let pr_metadata = match needs_edit { 300 | true => interactive_get_pr_metadata(&root, &work_order.commit_work)?, 301 | false => { 302 | // REFACTOR: clean this up 303 | let commit = &work_order.commit_work.get(0).unwrap().commit; 304 | commit.message().unwrap().parse()? 305 | } 306 | }; 307 | 308 | let pull_request = PullRequest { 309 | owner: owner.clone(), 310 | name: name.clone(), 311 | forker: forker.clone(), 312 | title: pr_metadata.title, 313 | body: pr_metadata.body, 314 | github_token: github_token.clone(), 315 | branch_name: work_order.branch_name.clone(), 316 | base: base_branch.clone(), 317 | }; 318 | 319 | let http_client = http_client.clone(); 320 | let pull_request_join_handle = 321 | s.spawn(move || pull_request.create(http_client).map_err(Into::into)); 322 | join_handles.push(pull_request_join_handle); 323 | 324 | // Finally, check out the original ref 325 | execute(&["git", "checkout", "-"], &log_file)?; 326 | } 327 | 328 | work_order 329 | .progress_bar 330 | .set_style(STYLE_ISSUE_GROUP_STABLE.clone()); 331 | work_order.progress_bar.set_prefix(PREFIX_DONE); 332 | work_order.progress_bar.finish(); 333 | } 334 | 335 | for handle in join_handles { 336 | handle.join().unwrap()?; 337 | } 338 | 339 | Ok(()) 340 | }) 341 | } 342 | 343 | fn main() -> Result<(), little_anyhow::Error> { 344 | let cli = Cli::parse(); 345 | 346 | let log_file = LogFile::default(); 347 | 348 | // TODO: rename for clarity 349 | do_git_disjoint(cli.clone(), log_file.clone()) 350 | .map_err(|err| little_anyhow::Error::new(err, log_file.clone()))?; 351 | 352 | log_file.delete()?; 353 | Ok(()) 354 | } 355 | -------------------------------------------------------------------------------- /src/pull_request.rs: -------------------------------------------------------------------------------- 1 | use std::{error::Error, fmt::Display, io}; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use crate::{branch_name::BranchName, default_branch::DefaultBranch}; 6 | 7 | #[derive(Debug)] 8 | pub(crate) struct PullRequest { 9 | pub owner: String, 10 | pub name: String, 11 | pub forker: String, 12 | pub title: String, 13 | pub body: String, 14 | pub github_token: String, 15 | pub branch_name: BranchName, 16 | pub base: DefaultBranch, 17 | } 18 | 19 | // https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#create-a-pull-request 20 | #[derive(Debug, Serialize)] 21 | struct CreatePullRequestRequest { 22 | title: String, 23 | body: String, 24 | head: String, 25 | base: String, 26 | draft: bool, 27 | } 28 | 29 | // https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#create-a-pull-request 30 | #[derive(Debug, Deserialize)] 31 | struct CreatePullRequestResponse { 32 | html_url: String, 33 | } 34 | 35 | #[derive(Debug)] 36 | #[non_exhaustive] 37 | pub(crate) struct CreatePullRequestError { 38 | url: String, 39 | pub kind: CreatePullRequestErrorKind, 40 | } 41 | 42 | impl Display for CreatePullRequestError { 43 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 44 | match &self.kind { 45 | CreatePullRequestErrorKind::Http(_) => write!(f, "http error: POST {}", self.url), 46 | CreatePullRequestErrorKind::Parse(_) => { 47 | write!(f, "unable to parse response from POST {}", self.url) 48 | } 49 | CreatePullRequestErrorKind::OpenBrowser(_) => { 50 | write!(f, "unable to open web browser to page {}", self.url) 51 | } 52 | } 53 | } 54 | } 55 | 56 | impl Error for CreatePullRequestError { 57 | fn source(&self) -> Option<&(dyn Error + 'static)> { 58 | match &self.kind { 59 | CreatePullRequestErrorKind::Http(err) => Some(err), 60 | CreatePullRequestErrorKind::Parse(err) => Some(err), 61 | CreatePullRequestErrorKind::OpenBrowser(err) => Some(err), 62 | } 63 | } 64 | } 65 | 66 | #[derive(Debug)] 67 | pub(crate) enum CreatePullRequestErrorKind { 68 | #[non_exhaustive] 69 | Http(reqwest::Error), 70 | #[non_exhaustive] 71 | Parse(reqwest::Error), 72 | #[non_exhaustive] 73 | OpenBrowser(io::Error), 74 | } 75 | 76 | impl PullRequest { 77 | pub fn create( 78 | self, 79 | http_client: reqwest::blocking::Client, 80 | ) -> Result<(), CreatePullRequestError> { 81 | let url = format!( 82 | "https://api.github.com/repos/{}/{}/pulls", 83 | self.owner, self.name 84 | ); 85 | let response: CreatePullRequestResponse = http_client 86 | .post(&url) 87 | .header("User-Agent", "git-disjoint") 88 | .header("Accept", "application/vnd.github.v3+json") 89 | .header("Authorization", format!("token {}", self.github_token)) 90 | .json(&CreatePullRequestRequest { 91 | title: self.title, 92 | body: self.body, 93 | head: format!("{}:{}", self.forker, self.branch_name), 94 | base: self.base.0, 95 | draft: true, 96 | }) 97 | .send() 98 | .map_err(|err| CreatePullRequestError { 99 | url: url.clone(), 100 | kind: CreatePullRequestErrorKind::Http(err), 101 | })? 102 | .json() 103 | .map_err(|err| CreatePullRequestError { 104 | url: url.clone(), 105 | kind: CreatePullRequestErrorKind::Parse(err), 106 | })?; 107 | 108 | let url = response.html_url; 109 | open::that(&url).map_err(|err| CreatePullRequestError { 110 | url, 111 | kind: CreatePullRequestErrorKind::OpenBrowser(err), 112 | })?; 113 | 114 | Ok(()) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/pull_request_message.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use git2::Commit; 4 | 5 | pub(crate) const IGNORE_MARKER: &str = "# ------------------------ >8 ------------------------"; 6 | 7 | const PULL_REQUEST_INSTRUCTIONS: &str = r#" 8 | # Do not modify or remove the line above. 9 | # Everything below it will be ignored. 10 | 11 | Write a message for this pull request. The first block 12 | of text is the title and the rest is the description. 13 | 14 | Changes: 15 | "#; 16 | 17 | #[derive(Clone, Debug)] 18 | pub(crate) struct PullRequestMessageTemplate<'repo> { 19 | commits: Vec<&'repo Commit<'repo>>, 20 | } 21 | 22 | impl<'repo> FromIterator<&'repo Commit<'repo>> for PullRequestMessageTemplate<'repo> { 23 | fn from_iter>>(iter: T) -> Self { 24 | Self { 25 | commits: iter.into_iter().collect(), 26 | } 27 | } 28 | } 29 | 30 | impl<'repo> Display for PullRequestMessageTemplate<'repo> { 31 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 32 | write!(f, "\n{}\n{}", IGNORE_MARKER, PULL_REQUEST_INSTRUCTIONS)?; 33 | for commit in &self.commits { 34 | writeln!( 35 | f, 36 | "{:.7} ({:?})", 37 | &commit.id(), 38 | commit.author().name().unwrap_or("unknown"), 39 | )?; 40 | for line in commit.message().unwrap_or_default().lines() { 41 | writeln!(f, " {line}")?; 42 | } 43 | writeln!(f)?; 44 | } 45 | Ok(()) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/pull_request_metadata.rs: -------------------------------------------------------------------------------- 1 | use std::{error::Error, fmt::Display, str::FromStr}; 2 | 3 | use crate::pull_request_message::IGNORE_MARKER; 4 | 5 | #[derive(Clone, Debug, Eq, PartialEq, Hash)] 6 | pub(crate) struct PullRequestMetadata { 7 | pub title: String, 8 | pub body: String, 9 | } 10 | 11 | #[derive(Debug)] 12 | #[non_exhaustive] 13 | pub(crate) struct FromStrError { 14 | kind: FromStrErrorKind, 15 | } 16 | 17 | impl Display for FromStrError { 18 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 19 | match &self.kind { 20 | FromStrErrorKind::EmptyPullRequestMessage => { 21 | write!(f, "pull request metadata is empty") 22 | } 23 | } 24 | } 25 | } 26 | 27 | impl Error for FromStrError { 28 | fn source(&self) -> Option<&(dyn Error + 'static)> { 29 | match &self.kind { 30 | FromStrErrorKind::EmptyPullRequestMessage => None, 31 | } 32 | } 33 | } 34 | 35 | #[derive(Debug)] 36 | pub(crate) enum FromStrErrorKind { 37 | #[non_exhaustive] 38 | EmptyPullRequestMessage, 39 | } 40 | 41 | impl From for FromStrError { 42 | fn from(kind: FromStrErrorKind) -> Self { 43 | Self { kind } 44 | } 45 | } 46 | 47 | impl FromStr for PullRequestMetadata { 48 | type Err = FromStrError; 49 | 50 | fn from_str(s: &str) -> Result { 51 | if s.is_empty() { 52 | return Err(FromStrErrorKind::EmptyPullRequestMessage)?; 53 | } 54 | 55 | let mut iterator = s.lines(); 56 | let title = iterator.next().unwrap_or_default().trim().to_owned(); 57 | let body = iterator 58 | .take_while(|line| line != &IGNORE_MARKER) 59 | .collect::>() 60 | .join("\n") 61 | .trim() 62 | .to_owned(); 63 | 64 | Ok(Self { title, body }) 65 | } 66 | } 67 | --------------------------------------------------------------------------------