From ce291af06aaea81872a6c70069574bbecd06ed25 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Wed, 29 Apr 2026 20:21:51 -0600 Subject: [PATCH] feat: terminal UI for reporting jj bisect verdicts --- .gitignore | 1 + Cargo.lock | 600 ++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 8 + src/main.rs | 506 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1115 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2b2ea54 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,600 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bshelper" +version = "0.1.0" +dependencies = [ + "anyhow", + "ratatui", +] + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..47cb597 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "bshelper" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1" +ratatui = "0.29" diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..adfa1a5 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,506 @@ +use std::process::{Command, Stdio}; + +use anyhow::Result; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::{DefaultTerminal, Frame}; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Verdict { + Good, + Bad, + Skip, + Abort, +} + +impl Verdict { + const ORDER: [Verdict; 4] = [Verdict::Good, Verdict::Bad, Verdict::Skip, Verdict::Abort]; + + fn exit_code(self) -> i32 { + match self { + Verdict::Good => 0, + Verdict::Bad => 1, + Verdict::Skip => 125, + Verdict::Abort => 127, + } + } + + fn label(self) -> &'static str { + match self { + Verdict::Good => "GOOD", + Verdict::Bad => "BAD", + Verdict::Skip => "SKIP", + Verdict::Abort => "ABORT", + } + } + + fn description(self) -> &'static str { + match self { + Verdict::Good => "build / test passed", + Verdict::Bad => "build / test failed", + Verdict::Skip => "can't tell — mark indecisive", + Verdict::Abort => "stop the bisection now", + } + } + + fn color(self) -> Color { + match self { + Verdict::Good => Color::Green, + Verdict::Bad => Color::Red, + Verdict::Skip => Color::Yellow, + Verdict::Abort => Color::Gray, + } + } + + fn summary_emoji(self) -> &'static str { + match self { + Verdict::Good => "✅", + Verdict::Bad => "❌", + Verdict::Skip => "❔", + Verdict::Abort => "⛔", + } + } + + fn summary_text(self) -> &'static str { + match self { + Verdict::Good => "This revision was marked GOOD.", + Verdict::Bad => "This revision was marked BAD.", + Verdict::Skip => "This revision was SKIPPED.", + Verdict::Abort => "Bisection ABORTED.", + } + } + + fn ansi_color(self) -> &'static str { + match self { + Verdict::Good => "32", // green + Verdict::Bad => "31", // red + Verdict::Skip => "33", // yellow + Verdict::Abort => "90", // bright black / gray + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Vcs { + Jj, + Git, + None, +} + +impl Vcs { + fn tag(self) -> &'static str { + match self { + Vcs::Jj => "jj", + Vcs::Git => "git", + Vcs::None => "no vcs", + } + } + + fn color(self) -> Color { + match self { + Vcs::Jj => Color::Magenta, + Vcs::Git => Color::LightRed, + Vcs::None => Color::DarkGray, + } + } +} + +struct CommitInfo { + vcs: Vcs, + change_id: Option, + commit_id: Option, + author: Option, + timestamp: Option, + description: Option, +} + +impl CommitInfo { + fn fetch() -> Self { + // jj always wins over git when both are present. + Self::fetch_jj().or_else(Self::fetch_git).unwrap_or(Self { + vcs: Vcs::None, + change_id: None, + commit_id: None, + author: None, + timestamp: None, + description: None, + }) + } + + fn fetch_jj() -> Option { + let template = concat!( + r#"change_id.short() ++ "\n""#, + r#" ++ commit_id.short() ++ "\n""#, + r#" ++ author.name() ++ "\n""#, + r#" ++ author.email() ++ "\n""#, + r#" ++ author.timestamp().format("%Y-%m-%d %H:%M") ++ "\n""#, + r#" ++ description"#, + ); + let output = Command::new("jj") + .args([ + "log", + "-r", + "@", + "--no-graph", + "--color", + "never", + "-T", + template, + ]) + .stderr(Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let raw = String::from_utf8_lossy(&output.stdout).into_owned(); + let mut parts = raw.splitn(6, '\n'); + let mut take = || { + parts + .next() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + }; + Some(Self { + vcs: Vcs::Jj, + change_id: take(), + commit_id: take(), + author: combine_author(take(), take()), + timestamp: take(), + description: take().and_then(|d| d.lines().next().map(String::from)), + }) + } + + fn fetch_git() -> Option { + let output = Command::new("git") + .args([ + "log", + "-1", + "--format=%h%n%an%n%ae%n%ad%n%s", + "--date=format:%Y-%m-%d %H:%M", + ]) + .stderr(Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let raw = String::from_utf8_lossy(&output.stdout).into_owned(); + let mut parts = raw.splitn(5, '\n'); + let mut take = || { + parts + .next() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + }; + Some(Self { + vcs: Vcs::Git, + change_id: None, + commit_id: take(), + author: combine_author(take(), take()), + timestamp: take(), + description: take().and_then(|d| d.lines().next().map(String::from)), + }) + } +} + +fn combine_author(name: Option, email: Option) -> Option { + match (name, email) { + (Some(n), Some(e)) => Some(format!("{n} <{e}>")), + (Some(n), None) => Some(n), + (None, Some(e)) => Some(e), + (None, None) => None, + } +} + +fn main() -> Result<()> { + let info = CommitInfo::fetch(); + let mut terminal = ratatui::init(); + let result = run(&mut terminal, &info); + ratatui::restore(); + let verdict = result?; + print_verdict_summary(verdict); + std::process::exit(verdict.exit_code()); +} + +fn print_verdict_summary(v: Verdict) { + eprintln!( + "{} \x1b[1;{}m{}\x1b[0m", + v.summary_emoji(), + v.ansi_color(), + v.summary_text(), + ); +} + +fn run(terminal: &mut DefaultTerminal, info: &CommitInfo) -> Result { + // SKIP is the safe default: a stray Enter wastes a step but doesn't + // poison the bisect. + let mut selected: usize = 2; + + loop { + terminal.draw(|f| render(f, info, selected))?; + + let Event::Key(key) = event::read()? else { + continue; + }; + if key.kind != KeyEventKind::Press { + continue; + } + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + selected = selected.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + selected = (selected + 1).min(Verdict::ORDER.len() - 1); + } + KeyCode::Enter => return Ok(Verdict::ORDER[selected]), + // Esc, q, Ctrl-C, and everything else are deliberate no-ops. + // The only way out is to pick a verdict (ABORT included) and + // press Enter — there is no second escape hatch to advertise. + _ => {} + } + } +} + +const HPAD: u16 = 3; +const VPAD: u16 = 1; +const VERDICT_LABEL_WIDTH: usize = 5; // widest label, ABORT + +fn render(frame: &mut Frame, info: &CommitInfo, selected: usize) { + let term = frame.area(); + + let header_lines = build_header_lines(info); + let footer_line = build_footer_line(selected); + + let header_w = header_lines.iter().map(Line::width).max().unwrap_or(0); + let verdict_w = Verdict::ORDER + .iter() + .map(|v| verdict_row_width(*v)) + .max() + .unwrap_or(0); + // Width is computed against the widest footer (ABORT label) so the + // modal stays the same size as the user navigates between verdicts. + let footer_w = (0..Verdict::ORDER.len()) + .map(|i| build_footer_line(i).width()) + .max() + .unwrap_or(0); + let content_w = header_w.max(verdict_w).max(footer_w); + + let inner_w = (content_w as u16) + HPAD * 2; + let inner_h: u16 = VPAD // top pad + + header_lines.len() as u16 // header + + 1 // blank + + 1 // separator + + 1 // blank + + Verdict::ORDER.len() as u16 // verdicts + + 1 // blank + + 1 // separator + + 1 // footer + + VPAD; // bottom pad + + let modal_w = (inner_w + 2).min(term.width); + let modal_h = (inner_h + 2).min(term.height); + let modal = centered_rect(term, modal_w, modal_h); + + frame.render_widget(Clear, modal); + + let title_line = Line::from(vec![ + Span::raw(" bshelper "), + Span::styled( + format!("· {} ", info.vcs.tag()), + Style::default() + .fg(info.vcs.color()) + .add_modifier(Modifier::BOLD), + ), + ]); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(title_line) + .title_alignment(Alignment::Center); + let inner = block.inner(modal); + frame.render_widget(block, modal); + + let chunks = Layout::vertical([ + Constraint::Length(VPAD), + Constraint::Length(header_lines.len() as u16), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(Verdict::ORDER.len() as u16), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(VPAD), + ]) + .split(inner); + + frame.render_widget(Paragraph::new(header_lines), pad(chunks[1])); + render_separator(frame, pad(chunks[3])); + + let verdict_rect = pad(chunks[5]); + let verdict_rows = + Layout::vertical([Constraint::Length(1); Verdict::ORDER.len()]).split(verdict_rect); + for (i, v) in Verdict::ORDER.iter().enumerate() { + render_verdict_row(frame, verdict_rows[i], *v, i == selected); + } + + render_separator(frame, pad(chunks[7])); + let footer_rect = pad(chunks[8]); + frame.render_widget( + Paragraph::new(footer_line).alignment(Alignment::Center), + footer_rect, + ); +} + +fn pad(r: Rect) -> Rect { + Rect { + x: r.x + HPAD, + y: r.y, + width: r.width.saturating_sub(HPAD * 2), + height: r.height, + } +} + +fn centered_rect(area: Rect, w: u16, h: u16) -> Rect { + let w = w.min(area.width); + let h = h.min(area.height); + let x = area.x + (area.width.saturating_sub(w)) / 2; + let y = area.y + (area.height.saturating_sub(h)) / 2; + Rect { + x, + y, + width: w, + height: h, + } +} + +fn build_header_lines(info: &CommitInfo) -> Vec> { + let label_style = Style::default().fg(Color::DarkGray); + let id_style = Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD); + let hash_style = Style::default().fg(Color::Cyan); + + let mut lines: Vec = Vec::new(); + + let commit_line = match (&info.change_id, &info.commit_id, info.vcs) { + (Some(c), Some(h), _) => Line::from(vec![ + Span::styled("Commit ", label_style), + Span::styled(c.clone(), id_style), + Span::raw(" "), + Span::styled(h.clone(), hash_style), + ]), + (None, Some(h), _) => Line::from(vec![ + Span::styled("Commit ", label_style), + Span::styled(h.clone(), hash_style), + ]), + _ => Line::from(vec![ + Span::styled("Commit ", label_style), + Span::styled("(no revision info)", label_style), + ]), + }; + lines.push(commit_line); + + if let Some(author) = &info.author { + lines.push(Line::from(vec![ + Span::styled("Author ", label_style), + Span::raw(author.clone()), + ])); + } + if let Some(ts) = &info.timestamp { + lines.push(Line::from(vec![ + Span::styled("Date ", label_style), + Span::raw(ts.clone()), + ])); + } + + lines.push(Line::from("")); + + let desc_text = info.description.clone().unwrap_or_else(|| { + if info.vcs == Vcs::None { + String::new() + } else { + "(no description)".to_string() + } + }); + if !desc_text.is_empty() { + lines.push(Line::from(Span::styled( + desc_text, + Style::default().add_modifier(Modifier::ITALIC), + ))); + } + + lines +} + +fn verdict_row_width(_v: Verdict) -> usize { + // " ▶ {label:>5} {description} " + let max_desc = Verdict::ORDER + .iter() + .map(|v| v.description().chars().count()) + .max() + .unwrap_or(0); + 2 + 1 + 2 + VERDICT_LABEL_WIDTH + 3 + max_desc + 2 +} + +fn render_verdict_row(frame: &mut Frame, area: Rect, v: Verdict, is_selected: bool) { + if is_selected { + let content = format!( + " ▶ {:>w$} {}", + v.label(), + v.description(), + w = VERDICT_LABEL_WIDTH + ); + let style = Style::default() + .fg(Color::Black) + .bg(v.color()) + .add_modifier(Modifier::BOLD); + frame.render_widget(Paragraph::new(content).style(style), area); + } else { + let line = Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("{:>w$}", v.label(), w = VERDICT_LABEL_WIDTH), + Style::default().fg(v.color()).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(v.description(), Style::default().fg(Color::DarkGray)), + ]); + frame.render_widget(Paragraph::new(line), area); + } +} + +fn render_separator(frame: &mut Frame, area: Rect) { + let line = "─".repeat(area.width as usize); + frame.render_widget( + Paragraph::new(line).style(Style::default().fg(Color::DarkGray)), + area, + ); +} + +fn build_footer_line(selected: usize) -> Line<'static> { + let v = Verdict::ORDER[selected]; + let dim = Style::default().fg(Color::DarkGray); + let kbd = Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD); + + Line::from(vec![ + Span::styled("↑/k", kbd), + Span::styled(" ", dim), + Span::styled("↓/j", kbd), + Span::styled(" navigate · ", dim), + Span::styled("Enter", kbd), + Span::styled(" commits: ", dim), + Span::styled( + format!("{: