From e5233ce7683c386272f6b836513f674555b50a69 Mon Sep 17 00:00:00 2001 From: Rob Sanheim Date: Thu, 9 Jul 2026 20:44:46 -0500 Subject: [PATCH 1/3] feat: full-screen ratatui TUI for live TTY runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an alternate live renderer for interactive terminals: a yazi-style bordered layout with a scope header, a scrolling per-repo table (spinner while running, colored ✓/●/✗ by outcome), and a progress footer with a gauge, counts, and elapsed clock. The TUI owns the runner's RepoEvent channel and polls ~100ms for spinner animation, the elapsed clock, and key input (q/Ctrl-C to quit, j/k/arrows to scroll, g/G for top/follow). A guard plus a panic hook always restore raw mode and the alt-screen; a plain per-repo record is printed to scrollback on exit. It engages only when stdout is a TTY and not --dry-run or trace mode; piped, redirected, dry-run, and trace runs keep the existing line printers exactly. ExecutionContext gains command_label for the header. --- rust/Cargo.lock | 342 +++++++++++++++++++++++++- rust/Cargo.toml | 1 + rust/src/lib.rs | 2 + rust/src/runner.rs | 155 +++++++++--- rust/src/tui.rs | 587 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1037 insertions(+), 50 deletions(-) create mode 100644 rust/src/tui.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index e8fe691..b86496b 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2,6 +2,12 @@ # 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 = "anstream" version = "0.6.21" @@ -38,7 +44,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -49,7 +55,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -64,6 +70,21 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[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" @@ -126,6 +147,20 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -135,6 +170,22 @@ dependencies = [ "unicode-segmentation", ] +[[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 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm" version = "0.29.0" @@ -147,7 +198,7 @@ dependencies = [ "document-features", "mio", "parking_lot", - "rustix", + "rustix 1.1.3", "signal-hook", "signal-hook-mio", "winapi", @@ -162,6 +213,40 @@ 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 = "derive_more" version = "2.1.1" @@ -193,6 +278,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "equivalent" version = "1.0.2" @@ -206,7 +297,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -240,7 +331,8 @@ version = "0.7.4" dependencies = [ "anyhow", "clap", - "crossterm", + "crossterm 0.29.0", + "ratatui", "tempfile", ] @@ -250,6 +342,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -271,6 +365,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" version = "2.13.0" @@ -283,12 +383,43 @@ dependencies = [ "serde_core", ] +[[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 = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[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.17" @@ -307,6 +438,12 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -334,6 +471,15 @@ 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 0.15.5", +] + [[package]] name = "memchr" version = "2.8.0" @@ -349,7 +495,7 @@ dependencies = [ "libc", "log", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -387,6 +533,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "prettyplease" version = "0.2.37" @@ -421,6 +573,27 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[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 0.28.1", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -445,6 +618,19 @@ dependencies = [ "semver", ] +[[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 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.3" @@ -454,10 +640,22 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", - "windows-sys", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[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" @@ -549,12 +747,40 @@ 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.114" @@ -575,8 +801,8 @@ dependencies = [ "fastrand", "getrandom", "once_cell", - "rustix", - "windows-sys", + "rustix 1.1.3", + "windows-sys 0.61.2", ] [[package]] @@ -591,6 +817,29 @@ 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 = "unicode-xid" version = "0.2.6" @@ -689,6 +938,15 @@ 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" @@ -698,6 +956,70 @@ 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" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 3531fca..66240de 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -15,6 +15,7 @@ description = "parallel git across many repositories" clap = { version = "4.5", features = ["derive"] } anyhow = "1.0" crossterm = "0.29" +ratatui = "0.29" [dev-dependencies] tempfile = "3" diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 20e477d..94853eb 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -12,6 +12,7 @@ mod printer; mod repo; mod runner; mod trace; +mod tui; use commands::{fetch, passthrough, pull, status}; use repo::{ScanDepth, find_git_repos_in, is_inside_git_repo, parse_scan_depth}; @@ -188,6 +189,7 @@ pub fn run() -> Result<()> { cli.ssh_multiplexing, workers, cwd, + command_label(&cli.command).to_string(), trace, ); diff --git a/rust/src/runner.rs b/rust/src/runner.rs index 5e147b5..78329c8 100644 --- a/rust/src/runner.rs +++ b/rust/src/runner.rs @@ -51,7 +51,9 @@ const MAX_REPO_NAME_WIDTH_CAP: usize = 48; /// Trace sample for a completed repo (`None` when `GIT_ALL_TRACE` is off). type RepoCompletion = Option; -enum RepoEvent { +/// Live execution events emitted by the parallel runner. The TUI consumes these +/// directly (it owns the receiver), so the type is visible crate-wide. +pub(crate) enum RepoEvent { Started { idx: usize, }, @@ -82,6 +84,37 @@ fn compute_name_width(repos: &[PathBuf], display_root: &Path) -> usize { capped.max(MIN_REPO_NAME_WIDTH) } +/// One-line scope banner shown in the TUI header, e.g. +/// `git-all fetch · 98 repos · ~/work · 16 workers`. +fn run_header(ctx: &ExecutionContext, repo_count: usize, max_workers: usize) -> String { + let workers = match max_workers { + 0 => "unlimited workers".to_string(), + 1 => "1 worker".to_string(), + n => format!("{n} workers"), + }; + format!( + "git-all {} · {} repo{} · {} · {}", + ctx.command_label(), + repo_count, + if repo_count == 1 { "" } else { "s" }, + abbreviate_home(ctx.display_root()), + workers, + ) +} + +/// Render a path with `$HOME` collapsed to `~` for a shorter, friendlier banner. +fn abbreviate_home(path: &Path) -> String { + let home = std::env::var("HOME").unwrap_or_default(); + if home.is_empty() { + return path.display().to_string(); + } + match path.strip_prefix(&home) { + Ok(rest) if rest.as_os_str().is_empty() => "~".to_string(), + Ok(rest) => format!("~/{}", rest.display()), + Err(_) => path.display().to_string(), + } +} + /// Cross-cutting options that apply to every git invocation in a run. #[derive(Clone, Copy)] pub struct GitInvocationOptions { @@ -96,6 +129,7 @@ pub struct ExecutionContext { ssh_multiplexing: bool, max_connections: usize, display_root: PathBuf, + command_label: String, trace: TraceSink, } @@ -106,6 +140,7 @@ impl ExecutionContext { ssh_multiplexing: bool, max_connections: usize, display_root: PathBuf, + command_label: String, trace: TraceSink, ) -> Self { Self { @@ -114,6 +149,7 @@ impl ExecutionContext { ssh_multiplexing, max_connections, display_root, + command_label, trace, } } @@ -122,6 +158,12 @@ impl ExecutionContext { self.dry_run } + /// The git subcommand for this run (e.g. `status`, `fetch`), used in the + /// TUI's scope banner. + pub fn command_label(&self) -> &str { + &self.command_label + } + pub fn git_invocation_options(&self) -> GitInvocationOptions { GitInvocationOptions { url_scheme: self.url_scheme, @@ -280,15 +322,21 @@ where } let name_width = compute_name_width(repos, ctx.display_root()); + let max_workers = ctx.max_connections(); let run_started_at = Instant::now(); let mut rows: Vec = repos .iter() .map(|repo| RepoRow::pending(repo_display_name(repo, ctx.display_root()))) .collect(); + let stdout = std::io::stdout(); let is_tty = stdout.is_tty(); - // 0 means the terminal did not report a size; the printer falls back to a - // sensible default. A real width (however small) is passed through as-is. + // The full-screen ratatui TUI takes over an interactive terminal. Trace mode + // writes structured records to stderr/a file and must stay plain, so it falls + // back to the line printers. + let use_tui = is_tty && !trace_enabled; + // 0 means the terminal did not report a size; the line printer falls back to + // a sensible default. A real width (however small) is passed through as-is. let terminal_columns = if is_tty { terminal_size() .map(|(columns, _rows)| columns as usize) @@ -296,15 +344,6 @@ where } else { 0 }; - let stdout = stdout.lock(); - let mut printer: Box = if is_tty { - Box::new(TtyTablePrinter::new(stdout, terminal_columns, name_width)) - } else { - Box::new(PlainPrinter::new(stdout, name_width)) - }; - printer.start(&rows)?; - - let max_workers = ctx.max_connections(); let semaphore = if max_workers > 0 && max_workers < repos.len() { Some(Arc::new(Semaphore::new(max_workers))) @@ -372,41 +411,77 @@ where } drop(tx); - for event in rx { - match event { - RepoEvent::Started { idx } => { - rows[idx].mark_running(); - let elapsed_ms = run_started_at.elapsed().as_millis(); - let _ = printer.update_row(&rows, idx, elapsed_ms)?; - } - RepoEvent::Completed { - idx, - result, - trace_sample, - } => { - rows[idx].mark_finished(formatter.format_result(&result)); - completions[idx] = Some(trace_sample); - let elapsed_ms = run_started_at.elapsed().as_millis(); - let printed = printer.update_row(&rows, idx, elapsed_ms)?; - emit_traces_for_printed_rows( - ctx, - repos, - &completions, - &printed, - elapsed_ms, - &mut summary, - )?; + if use_tui { + // The TUI owns the receiver and renders the live full-screen view. + let header = run_header(ctx, repos.len(), max_workers); + crate::tui::run( + rx, + &mut rows, + &header, + name_width, + run_started_at, + formatter, + )?; + } else { + let stdout = std::io::stdout().lock(); + let mut printer: Box = if is_tty { + Box::new(TtyTablePrinter::new(stdout, terminal_columns, name_width)) + } else { + Box::new(PlainPrinter::new(stdout, name_width)) + }; + printer.start(&rows)?; + + for event in rx { + match event { + RepoEvent::Started { idx } => { + rows[idx].mark_running(); + let elapsed_ms = run_started_at.elapsed().as_millis(); + let _ = printer.update_row(&rows, idx, elapsed_ms)?; + } + RepoEvent::Completed { + idx, + result, + trace_sample, + } => { + rows[idx].mark_finished(formatter.format_result(&result)); + completions[idx] = Some(trace_sample); + let elapsed_ms = run_started_at.elapsed().as_millis(); + let printed = printer.update_row(&rows, idx, elapsed_ms)?; + emit_traces_for_printed_rows( + ctx, + repos, + &completions, + &printed, + elapsed_ms, + &mut summary, + )?; + } } } + + let total_ms = run_started_at.elapsed().as_millis(); + let printed = printer.complete(&rows, total_ms)?; + emit_traces_for_printed_rows( + ctx, + repos, + &completions, + &printed, + total_ms, + &mut summary, + )?; } Ok(()) })?; let total_ms = run_started_at.elapsed().as_millis(); - let printed = printer.complete(&rows, total_ms)?; - emit_traces_for_printed_rows(ctx, repos, &completions, &printed, total_ms, &mut summary)?; - ctx.trace_mut() - .emit_summary(repos.len(), &summary, total_ms)?; + if use_tui { + // Alt-screen output vanishes on exit, so leave a plain record behind. + let header = run_header(ctx, repos.len(), max_workers); + crate::tui::print_summary(&rows, &header, name_width, total_ms)?; + } else { + ctx.trace_mut() + .emit_summary(repos.len(), &summary, total_ms)?; + } Ok(()) } diff --git a/rust/src/tui.rs b/rust/src/tui.rs new file mode 100644 index 0000000..adb9d6d --- /dev/null +++ b/rust/src/tui.rs @@ -0,0 +1,587 @@ +//! Full-screen ratatui TUI for live `git-all` runs. +//! +//! Active only on an interactive stdout that is not `--dry-run` or trace mode. +//! It owns the runner's [`RepoEvent`] channel and renders a yazi-style bordered +//! layout — a scope header, a scrolling per-repo table, and a progress footer — +//! then restores the terminal cleanly on completion, quit, or panic. + +use std::io::{self, Write}; +use std::sync::Once; +use std::sync::mpsc::{Receiver, TryRecvError}; +use std::time::{Duration, Instant}; + +use anyhow::Result; +use ratatui::Frame; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use ratatui::crossterm::cursor::{Hide, Show}; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use ratatui::crossterm::execute; +use ratatui::crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Margin, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{ + Block, BorderType, Borders, Cell, Gauge, Paragraph, Row, Scrollbar, ScrollbarOrientation, + ScrollbarState, Table, TableState, +}; + +use crate::printer::{RepoRow, RowState, format_repo_name}; +use crate::runner::{OutputFormatter, RepoEvent}; + +/// Redraw / input-poll cadence. Drives the spinner and elapsed clock. +const TICK: Duration = Duration::from_millis(100); + +/// Braille spinner frames for in-flight repos. +const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +// A small, terminal-safe palette. Named colors keep it readable everywhere; +// indexed grays give the yazi-like muted frame and subtle row highlight. +const ACCENT: Color = Color::Cyan; +const FRAME: Color = Color::Indexed(240); +const MUTED: Color = Color::DarkGray; +const TEXT: Color = Color::Gray; +const HIGHLIGHT_BG: Color = Color::Indexed(236); +const GAUGE_BG: Color = Color::Indexed(238); + +/// Per-repo visual outcome, derived from row state and the result string. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Outcome { + Pending, + Running, + Ok, + Notable, + Error, +} + +impl Outcome { + fn color(self) -> Color { + match self { + Outcome::Pending => MUTED, + Outcome::Running => ACCENT, + Outcome::Ok => Color::Green, + Outcome::Notable => Color::Yellow, + Outcome::Error => Color::Red, + } + } + + /// Static glyph. Running rows substitute the animated spinner frame. + fn glyph(self) -> &'static str { + match self { + Outcome::Pending => "○", + Outcome::Running => "◍", + Outcome::Ok => "✓", + Outcome::Notable => "●", + Outcome::Error => "✗", + } + } +} + +fn outcome_of(row: &RepoRow) -> Outcome { + match row.state { + RowState::Pending => Outcome::Pending, + RowState::Running => Outcome::Running, + RowState::Finished => classify_finished(&row.output), + } +} + +/// Split finished results into green (nothing to do), yellow (something changed), +/// and red (failed) buckets from the formatter's one-line summary. +fn classify_finished(output: &str) -> Outcome { + let lower = output.to_ascii_lowercase(); + if lower.contains("error") || lower.contains("fatal") || lower.contains("fail") { + Outcome::Error + } else if matches!( + output, + "clean" | "no new commits" | "fetched" | "Already up to date" + ) { + Outcome::Ok + } else { + Outcome::Notable + } +} + +#[derive(Default)] +struct Counts { + total: usize, + done: usize, + ok: usize, + notable: usize, + error: usize, + running: usize, + pending: usize, +} + +fn tally(rows: &[RepoRow]) -> Counts { + let mut c = Counts { + total: rows.len(), + ..Counts::default() + }; + for row in rows { + match outcome_of(row) { + Outcome::Pending => c.pending += 1, + Outcome::Running => c.running += 1, + Outcome::Ok => { + c.done += 1; + c.ok += 1; + } + Outcome::Notable => { + c.done += 1; + c.notable += 1; + } + Outcome::Error => { + c.done += 1; + c.error += 1; + } + } + } + c +} + +/// Index of the last repo that has left the pending state — the "leading edge" +/// of activity the view auto-follows so progress stays on screen. +fn frontier(rows: &[RepoRow]) -> usize { + rows.iter() + .rposition(|r| r.state != RowState::Pending) + .unwrap_or(0) +} + +/// Restore raw mode, alt-screen, and the cursor on the way out. Held for the +/// lifetime of a TUI session so any early return leaves the terminal usable. +struct TerminalGuard; + +impl TerminalGuard { + fn enter() -> io::Result { + enable_raw_mode()?; + execute!(io::stdout(), EnterAlternateScreen, Hide)?; + Ok(Self) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + restore_terminal(); + } +} + +fn restore_terminal() { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen, Show); +} + +/// Restore the terminal before a panic prints, so a crash mid-run doesn't leave +/// the shell in raw mode / the alt-screen. +fn install_panic_hook() { + static HOOK: Once = Once::new(); + HOOK.call_once(|| { + let original = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + restore_terminal(); + original(info); + })); + }); +} + +/// Run the live TUI, consuming runner events until every repo finishes or the +/// user quits. Mutates `rows` in place so the caller can print a final record. +pub(crate) fn run( + rx: Receiver, + rows: &mut [RepoRow], + header: &str, + name_width: usize, + started_at: Instant, + formatter: &dyn OutputFormatter, +) -> Result<()> { + install_panic_hook(); + let _guard = TerminalGuard::enter()?; + let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; + + let mut follow = true; + let mut selected = 0usize; + let last = rows.len().saturating_sub(1); + + loop { + // Drain everything the workers have sent since the last frame. + let mut disconnected = false; + loop { + match rx.try_recv() { + Ok(RepoEvent::Started { idx }) => rows[idx].mark_running(), + Ok(RepoEvent::Completed { idx, result, .. }) => { + rows[idx].mark_finished(formatter.format_result(&result)); + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } + } + + if follow { + selected = frontier(rows); + } + selected = selected.min(last); + + let elapsed = started_at.elapsed(); + terminal.draw(|f| draw(f, rows, header, name_width, elapsed, selected))?; + + // Exit once the channel is closed and nothing is left in flight. + if disconnected && rows.iter().all(|r| r.state == RowState::Finished) { + break; + } + + if !event::poll(TICK)? { + continue; + } + let Event::Key(key) = event::read()? else { + continue; + }; + if key.kind != KeyEventKind::Press { + continue; + } + match key.code { + KeyCode::Char('q') | KeyCode::Esc => break, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break, + KeyCode::Char('j') | KeyCode::Down => { + follow = false; + selected = (selected + 1).min(last); + } + KeyCode::Char('k') | KeyCode::Up => { + follow = false; + selected = selected.saturating_sub(1); + } + KeyCode::Char('g') | KeyCode::Home => { + follow = false; + selected = 0; + } + KeyCode::Char('G') | KeyCode::End => follow = true, + _ => {} + } + } + + Ok(()) +} + +fn draw( + f: &mut Frame, + rows: &[RepoRow], + header: &str, + name_width: usize, + elapsed: Duration, + selected: usize, +) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // header + Constraint::Min(1), // repo table + Constraint::Length(4), // progress footer + ]) + .split(f.area()); + + render_header(f, chunks[0], header, elapsed); + render_table(f, chunks[1], rows, name_width, elapsed, selected); + render_footer(f, chunks[2], rows); +} + +fn framed(title: &str) -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(FRAME)) + .title(Span::styled( + format!(" {title} "), + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), + )) +} + +fn render_header(f: &mut Frame, area: Rect, header: &str, elapsed: Duration) { + let block = framed("git-all"); + let inner = block.inner(area); + f.render_widget(block, area); + + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(10), Constraint::Length(10)]) + .split(inner); + + f.render_widget( + Paragraph::new(Line::from(header)).style(Style::default().fg(TEXT)), + cols[0], + ); + f.render_widget( + Paragraph::new(Line::from(fmt_elapsed(elapsed))) + .alignment(Alignment::Right) + .style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + cols[1], + ); +} + +fn render_table( + f: &mut Frame, + area: Rect, + rows: &[RepoRow], + name_width: usize, + elapsed: Duration, + selected: usize, +) { + let c = tally(rows); + let block = framed(&format!("repositories · {}/{} done", c.done, c.total)); + let spinner = SPINNER[(elapsed.as_millis() / 90) as usize % SPINNER.len()]; + + let table_rows: Vec = rows + .iter() + .map(|row| { + let outcome = outcome_of(row); + let glyph = if outcome == Outcome::Running { + spinner + } else { + outcome.glyph() + }; + let result = match outcome { + Outcome::Pending => "pending".to_string(), + Outcome::Running => "running…".to_string(), + _ => row.output.clone(), + }; + let name_style = if outcome == Outcome::Pending { + Style::default().fg(MUTED) + } else { + Style::default().fg(TEXT) + }; + Row::new(vec![ + Cell::from(Span::styled( + glyph, + Style::default() + .fg(outcome.color()) + .add_modifier(Modifier::BOLD), + )), + Cell::from(Span::styled(row.repo.clone(), name_style)), + Cell::from(Span::styled(result, Style::default().fg(outcome.color()))), + ]) + }) + .collect(); + + let name_col = (name_width as u16).clamp(8, 48); + let widths = [ + Constraint::Length(1), + Constraint::Length(name_col), + Constraint::Min(10), + ]; + let table = Table::new(table_rows, widths) + .header( + Row::new(vec![ + Cell::from(""), + Cell::from("repository"), + Cell::from("result"), + ]) + .style(Style::default().fg(MUTED).add_modifier(Modifier::BOLD)), + ) + .block(block) + .column_spacing(1) + .row_highlight_style(Style::default().bg(HIGHLIGHT_BG)); + + let mut state = TableState::default().with_selected(Some(selected)); + f.render_stateful_widget(table, area, &mut state); + + // Scrollbar only when the list overflows the visible rows (minus borders and + // the table's own header row). + let visible = area.height.saturating_sub(3) as usize; + if rows.len() > visible && visible > 0 { + let mut sb = ScrollbarState::new(rows.len()).position(selected); + f.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None) + .thumb_symbol("█") + .track_symbol(Some("│")) + .style(Style::default().fg(FRAME)), + area.inner(Margin { + vertical: 1, + horizontal: 0, + }), + &mut sb, + ); + } +} + +fn render_footer(f: &mut Frame, area: Rect, rows: &[RepoRow]) { + let c = tally(rows); + let block = framed("progress"); + let inner = block.inner(area); + f.render_widget(block, area); + + let lines = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Length(1)]) + .split(inner); + + let ratio = if c.total == 0 { + 0.0 + } else { + c.done as f64 / c.total as f64 + }; + f.render_widget( + Gauge::default() + .gauge_style(Style::default().fg(Color::Green).bg(GAUGE_BG)) + .ratio(ratio) + .label(format!("{}/{} {:.0}%", c.done, c.total, ratio * 100.0)), + lines[0], + ); + + let status = Line::from(vec![ + count_span("✓", c.ok, "ok", Color::Green), + Span::raw(" "), + count_span("●", c.notable, "changed", Color::Yellow), + Span::raw(" "), + count_span("✗", c.error, "error", Color::Red), + Span::raw(" "), + count_span("◍", c.running, "running", ACCENT), + Span::raw(" "), + count_span("○", c.pending, "pending", MUTED), + ]); + + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(10), Constraint::Length(20)]) + .split(lines[1]); + f.render_widget(Paragraph::new(status), cols[0]); + f.render_widget( + Paragraph::new(Line::from("q quit · j/k scroll")) + .alignment(Alignment::Right) + .style(Style::default().fg(MUTED)), + cols[1], + ); +} + +fn count_span(glyph: &str, n: usize, label: &str, color: Color) -> Span<'static> { + Span::styled(format!("{glyph} {n} {label}"), Style::default().fg(color)) +} + +fn fmt_elapsed(elapsed: Duration) -> String { + let secs = elapsed.as_secs_f64(); + if secs < 60.0 { + format!("{secs:.1}s") + } else { + format!("{}m{:02}s", (secs / 60.0) as u64, (secs % 60.0) as u64) + } +} + +/// Print a plain, greppable record of the run to normal scrollback after the +/// alt-screen closes, since its contents vanish on exit. +pub(crate) fn print_summary( + rows: &[RepoRow], + header: &str, + name_width: usize, + elapsed_ms: u128, +) -> io::Result<()> { + let mut out = io::stdout().lock(); + writeln!(out, "{header}")?; + for row in rows { + writeln!( + out, + "{} {}", + format_repo_name(&row.repo, name_width), + row.output + )?; + } + let c = tally(rows); + writeln!( + out, + "{} of {} done · {} ok · {} changed · {} error · {:.1}s", + c.done, + c.total, + c.ok, + c.notable, + c.error, + elapsed_ms as f64 / 1000.0, + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + + fn rows() -> Vec { + vec![ + RepoRow::finished("activities".to_string(), "clean".to_string()), + RepoRow::finished("billing-svc".to_string(), "2 modified".to_string()), + RepoRow::finished("edge-proxy".to_string(), "fatal: bad ref".to_string()), + RepoRow::running("gateway".to_string()), + RepoRow::pending("zebra-cache".to_string()), + ] + } + + #[test] + fn classify_buckets_results_by_color() { + assert_eq!(classify_finished("clean"), Outcome::Ok); + assert_eq!(classify_finished("no new commits"), Outcome::Ok); + assert_eq!( + classify_finished("2 modified, 1 untracked"), + Outcome::Notable + ); + assert_eq!(classify_finished("1 branch updated"), Outcome::Notable); + assert_eq!( + classify_finished("fatal: not a git repository"), + Outcome::Error + ); + assert_eq!(classify_finished("ERROR: broken pipe"), Outcome::Error); + } + + #[test] + fn tally_counts_each_state() { + let c = tally(&rows()); + assert_eq!(c.total, 5); + assert_eq!(c.done, 3); + assert_eq!(c.ok, 1); + assert_eq!(c.notable, 1); + assert_eq!(c.error, 1); + assert_eq!(c.running, 1); + assert_eq!(c.pending, 1); + } + + #[test] + fn frontier_tracks_last_active_repo() { + // gateway (idx 3) is the last non-pending row. + assert_eq!(frontier(&rows()), 3); + } + + #[test] + fn fmt_elapsed_switches_to_minutes() { + assert_eq!(fmt_elapsed(Duration::from_millis(3400)), "3.4s"); + assert_eq!(fmt_elapsed(Duration::from_secs(75)), "1m15s"); + } + + #[test] + fn draw_renders_header_table_and_footer() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).expect("terminal"); + let data = rows(); + terminal + .draw(|f| { + draw( + f, + &data, + "git-all status · 5 repos · ~/work · 8 workers", + 14, + Duration::from_millis(1200), + 3, + ) + }) + .expect("draw"); + + let buffer = terminal.backend().buffer().clone(); + let text: String = buffer.content().iter().map(|cell| cell.symbol()).collect(); + assert!(text.contains("git-all")); + assert!(text.contains("activities")); + assert!(text.contains("repositories")); + assert!(text.contains("progress")); + } +} From d60384b3dab35c0e4214d7bceb07d9b394ba8f6c Mon Sep 17 00:00:00 2001 From: Rob Sanheim Date: Thu, 9 Jul 2026 22:15:04 -0500 Subject: [PATCH 2/3] fix: tear down in-flight git children when quitting the TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quitting the TUI (q / Ctrl-C / Esc) restored the terminal instantly but the process then blocked in thread::scope until every already-spawned git finished — up to the full run length on a slow fetch — and pending repos even kept spawning new gits. Add a CancelRegistry that tracks each running child's pid. On quit the TUI signals them SIGTERM (git's ssh/helper subprocesses cascade-exit when git closes their pipes), then a background thread escalates to SIGKILL after a 1.5s grace for anything that ignores it. Workers also check the cancel flag after acquiring a worker slot so no new git starts once a quit is in flight. Children are signalled by pid (not isolated into new process groups), so they stay in git-all's group and an external SIGHUP/SIGINT still reaches the whole tree — no orphans. Verified under tmux with a slow-git stand-in: quit went from blocking 30s+ (8 children left running) to exiting in ~0.07s with 0 children left. Normal completion and non-TTY output are unchanged. libc is a Unix-only dep; the signalling is a no-op elsewhere. --- rust/Cargo.lock | 1 + rust/Cargo.toml | 5 ++ rust/src/runner.rs | 153 ++++++++++++++++++++++++++++++++++++++++++++- rust/src/tui.rs | 23 +++++-- 4 files changed, 176 insertions(+), 6 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index b86496b..e59a571 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -332,6 +332,7 @@ dependencies = [ "anyhow", "clap", "crossterm 0.29.0", + "libc", "ratatui", "tempfile", ] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 66240de..ba6f25e 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -17,6 +17,11 @@ anyhow = "1.0" crossterm = "0.29" ratatui = "0.29" +# Signal in-flight git children (SIGTERM/SIGKILL) so a TUI quit tears them down +# promptly instead of blocking on network ops. Unix-only. +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [dev-dependencies] tempfile = "3" diff --git a/rust/src/runner.rs b/rust/src/runner.rs index 78329c8..13fd4fe 100644 --- a/rust/src/runner.rs +++ b/rust/src/runner.rs @@ -1,11 +1,13 @@ use anyhow::Result; use crossterm::terminal::size as terminal_size; use crossterm::tty::IsTty; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; use std::sync::{Arc, Condvar, Mutex}; -use std::time::Instant; +use std::time::{Duration, Instant}; use crate::printer::{PlainPrinter, Printer, RepoRow, TtyTablePrinter}; use crate::repo::repo_display_name; @@ -48,6 +50,96 @@ impl Semaphore { const MIN_REPO_NAME_WIDTH: usize = 4; const MAX_REPO_NAME_WIDTH_CAP: usize = 48; +/// Grace period between the polite SIGTERM and the forceful SIGKILL when a quit +/// tears down in-flight git children. +const KILL_GRACE: Duration = Duration::from_millis(1500); + +/// Send a signal to a single process (`pid > 0`). Errors (e.g. the process has +/// already exited) are ignored on purpose. +#[cfg(unix)] +fn signal_pid(pid: i32, sig: i32) { + // SAFETY: `kill` is async-signal-safe and merely posts a signal; a stale pid + // yields ESRCH, which we intentionally ignore. + unsafe { + libc::kill(pid, sig); + } +} + +#[cfg(not(unix))] +fn signal_pid(_pid: i32, _sig: i32) { + // No POSIX signals off Unix; a quit falls back to waiting for git to finish. +} + +#[cfg(unix)] +const SIG_TERM: i32 = libc::SIGTERM; +#[cfg(unix)] +const SIG_KILL: i32 = libc::SIGKILL; +#[cfg(not(unix))] +const SIG_TERM: i32 = 15; +#[cfg(not(unix))] +const SIG_KILL: i32 = 9; + +/// Tracks the pids of in-flight git children so a quit from the TUI can signal +/// them (SIGTERM, then SIGKILL) instead of blocking until every network op +/// finishes. git's own ssh/helper subprocesses cascade-exit when git closes +/// their pipes, so signalling git is enough to tear the operation down. +pub(crate) struct CancelRegistry { + cancelled: AtomicBool, + /// repo index -> child pid, present only while that child is running. + running: Mutex>, +} + +impl CancelRegistry { + fn new() -> Self { + Self { + cancelled: AtomicBool::new(false), + running: Mutex::new(HashMap::new()), + } + } + + fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::SeqCst) + } + + /// Track a freshly-spawned child. If a quit already happened, signal it right + /// away so it doesn't outlive the cancel. Registration and [`cancel`] both + /// take the same lock, so no child can slip through unsignalled. + fn register(&self, idx: usize, pid: i32) { + let mut running = self.running.lock().unwrap(); + running.insert(idx, pid); + if self.cancelled.load(Ordering::SeqCst) { + signal_pid(pid, SIG_TERM); + } + } + + fn unregister(&self, idx: usize) { + self.running.lock().unwrap().remove(&idx); + } + + /// Terminate every in-flight child, then escalate to SIGKILL after a grace + /// period for any that ignore SIGTERM (so a wedged process can't stall the + /// quit). Safe to call once per run. + pub(crate) fn cancel(self: &Arc) { + let pids: Vec = { + let running = self.running.lock().unwrap(); + // Flip the flag under the lock so a concurrently-registering worker + // either shows up here or observes the flag and self-signals. + self.cancelled.store(true, Ordering::SeqCst); + running.values().copied().collect() + }; + for pid in pids { + signal_pid(pid, SIG_TERM); + } + let reg = Arc::clone(self); + std::thread::spawn(move || { + std::thread::sleep(KILL_GRACE); + for pid in reg.running.lock().unwrap().values() { + signal_pid(*pid, SIG_KILL); + } + }); + } +} + /// Trace sample for a completed repo (`None` when `GIT_ALL_TRACE` is off). type RepoCompletion = Option; @@ -351,6 +443,10 @@ where None }; + // Only the TUI has a quit key, so non-TUI runs skip the cancel registry and + // its per-child pid bookkeeping entirely. + let cancel: Option> = use_tui.then(|| Arc::new(CancelRegistry::new())); + let mut completions: Vec> = (0..repos.len()).map(|_| None).collect(); let mut summary = TraceSummary::default(); @@ -361,11 +457,20 @@ where for (idx, _) in repos.iter().enumerate() { let tx = tx.clone(); let sem = semaphore.clone(); + let cancel = cancel.clone(); s.spawn(move || { if let Some(ref sem) = sem { sem.acquire(); } + // A quit may have arrived while we waited for a worker slot; if + // so, don't start new git work. + if cancel.as_ref().is_some_and(|c| c.is_cancelled()) { + if let Some(ref sem) = sem { + sem.release(); + } + return; + } if is_tty { let _ = tx.send(RepoEvent::Started { idx }); } @@ -375,7 +480,19 @@ where let spawn_result = cmd.spawn(opts); let spawn_ms = run_started_at.elapsed().as_millis(); let result = match spawn_result { - Ok(child) => child.wait_with_output(), + Ok(child) => { + // Track the pid so a quit can signal it; drop it from the + // registry as soon as it exits so we never signal a + // reaped pid. + if let Some(ref reg) = cancel { + reg.register(idx, child.id() as i32); + } + let out = child.wait_with_output(); + if let Some(ref reg) = cancel { + reg.unregister(idx); + } + out + } Err(err) => Err(err), }; @@ -414,6 +531,7 @@ where if use_tui { // The TUI owns the receiver and renders the live full-screen view. let header = run_header(ctx, repos.len(), max_workers); + let cancel = cancel.clone().expect("use_tui implies a cancel registry"); crate::tui::run( rx, &mut rows, @@ -421,6 +539,7 @@ where name_width, run_started_at, formatter, + cancel, )?; } else { let stdout = std::io::stdout().lock(); @@ -506,6 +625,36 @@ mod tests { assert_eq!(tiny_width, MIN_REPO_NAME_WIDTH); } + #[test] + fn cancel_registry_flips_flag() { + let reg = Arc::new(CancelRegistry::new()); + assert!(!reg.is_cancelled()); + // Empty registry: cancel has nothing to signal, it just flips the flag. + reg.cancel(); + assert!(reg.is_cancelled()); + } + + #[test] + fn cancel_registry_tracks_running_children() { + // A non-cancelled registry only bookkeeps pids; it does not signal them, + // so this touches no real process. + let reg = CancelRegistry::new(); + reg.register(0, 424_242); + reg.register(1, 424_243); + assert_eq!(reg.running.lock().unwrap().len(), 2); + reg.unregister(0); + assert!(!reg.is_cancelled()); + assert_eq!( + reg.running + .lock() + .unwrap() + .keys() + .copied() + .collect::>(), + vec![1] + ); + } + /// Test that large output (>64KB) doesn't cause pipe buffer deadlock. /// wait_with_output() internally spawns threads to drain pipes, so this should complete. #[test] diff --git a/rust/src/tui.rs b/rust/src/tui.rs index adb9d6d..3b356d8 100644 --- a/rust/src/tui.rs +++ b/rust/src/tui.rs @@ -6,8 +6,8 @@ //! then restores the terminal cleanly on completion, quit, or panic. use std::io::{self, Write}; -use std::sync::Once; use std::sync::mpsc::{Receiver, TryRecvError}; +use std::sync::{Arc, Once}; use std::time::{Duration, Instant}; use anyhow::Result; @@ -29,7 +29,7 @@ use ratatui::widgets::{ }; use crate::printer::{RepoRow, RowState, format_repo_name}; -use crate::runner::{OutputFormatter, RepoEvent}; +use crate::runner::{CancelRegistry, OutputFormatter, RepoEvent}; /// Redraw / input-poll cadence. Drives the spinner and elapsed clock. const TICK: Duration = Duration::from_millis(100); @@ -193,6 +193,7 @@ pub(crate) fn run( name_width: usize, started_at: Instant, formatter: &dyn OutputFormatter, + cancel: Arc, ) -> Result<()> { install_panic_hook(); let _guard = TerminalGuard::enter()?; @@ -200,6 +201,7 @@ pub(crate) fn run( let mut follow = true; let mut selected = 0usize; + let mut user_quit = false; let last = rows.len().saturating_sub(1); loop { @@ -242,8 +244,14 @@ pub(crate) fn run( continue; } match key.code { - KeyCode::Char('q') | KeyCode::Esc => break, - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break, + KeyCode::Char('q') | KeyCode::Esc => { + user_quit = true; + break; + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + user_quit = true; + break; + } KeyCode::Char('j') | KeyCode::Down => { follow = false; selected = (selected + 1).min(last); @@ -261,6 +269,13 @@ pub(crate) fn run( } } + // On an explicit quit, tear down the in-flight git children so the caller's + // thread join returns promptly instead of waiting out every network op. A + // natural finish leaves nothing running to signal. + if user_quit { + cancel.cancel(); + } + Ok(()) } From ad03ed07659d8ba0c5843c1e816b97e630d86ee6 Mon Sep 17 00:00:00 2001 From: Rob Sanheim Date: Fri, 10 Jul 2026 00:55:16 -0500 Subject: [PATCH 3/3] fix: trustworthy error counts + footer that fits narrow terminals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rough edges found driving real fetches at various terminal sizes: 1. Failed fetches were miscounted. A 404 ("remote: Repository not found.") rendered yellow and counted as "changed" because the outcome was inferred from the message string. Decide finished-row outcomes from the command's exit status instead, so any failed command is an error regardless of wording. The TUI returns its outcomes so the plain scrollback summary counts match the live footer. 2. The footer counts collided with the key hint below ~90 cols ("○ 62 pe q quit..."). Move the "q quit · j/k scroll" hint onto the gauge line so the counts line gets the full width and degrades by dropping trailing counts cleanly instead of mid-word. Verified at 80 cols fetching ~/src/oss: counts line shows all states uncollided, and the nb-cli 404 now reads "0 changed · 1 error". --- rust/src/runner.rs | 17 ++--- rust/src/tui.rs | 152 +++++++++++++++++++++++++++++++++++---------- 2 files changed, 130 insertions(+), 39 deletions(-) diff --git a/rust/src/runner.rs b/rust/src/runner.rs index 13fd4fe..cfb9d64 100644 --- a/rust/src/runner.rs +++ b/rust/src/runner.rs @@ -532,7 +532,7 @@ where // The TUI owns the receiver and renders the live full-screen view. let header = run_header(ctx, repos.len(), max_workers); let cancel = cancel.clone().expect("use_tui implies a cancel registry"); - crate::tui::run( + let outcomes = crate::tui::run( rx, &mut rows, &header, @@ -541,6 +541,11 @@ where formatter, cancel, )?; + // The alt-screen is gone once `run` returns; leave a plain record in + // scrollback, using the TUI's exit-status-based outcomes so the + // summary counts match what the live footer showed. + let total_ms = run_started_at.elapsed().as_millis(); + crate::tui::print_summary(&rows, &outcomes, &header, name_width, total_ms)?; } else { let stdout = std::io::stdout().lock(); let mut printer: Box = if is_tty { @@ -592,12 +597,10 @@ where Ok(()) })?; - let total_ms = run_started_at.elapsed().as_millis(); - if use_tui { - // Alt-screen output vanishes on exit, so leave a plain record behind. - let header = run_header(ctx, repos.len(), max_workers); - crate::tui::print_summary(&rows, &header, name_width, total_ms)?; - } else { + // The TUI prints its own scrollback summary above (it needs the outcomes it + // computed). Non-TUI runs still emit the trace summary here. + if !use_tui { + let total_ms = run_started_at.elapsed().as_millis(); ctx.trace_mut() .emit_summary(repos.len(), &summary, total_ms)?; } diff --git a/rust/src/tui.rs b/rust/src/tui.rs index 3b356d8..85c939d 100644 --- a/rust/src/tui.rs +++ b/rust/src/tui.rs @@ -46,9 +46,9 @@ const TEXT: Color = Color::Gray; const HIGHLIGHT_BG: Color = Color::Indexed(236); const GAUGE_BG: Color = Color::Indexed(238); -/// Per-repo visual outcome, derived from row state and the result string. +/// Per-repo visual outcome, derived from row state and the command's result. #[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum Outcome { +pub(crate) enum Outcome { Pending, Running, Ok, @@ -87,6 +87,26 @@ fn outcome_of(row: &RepoRow) -> Outcome { } } +/// Decide a finished row's display text and outcome from its command result. +/// The outcome is driven by exit status, so a failed command (e.g. a 404 fetch +/// whose message doesn't contain "error"/"fatal") still counts as an error. +fn finished_outcome( + result: &Result, + formatter: &dyn OutputFormatter, +) -> (String, Outcome) { + let failed = match result { + Ok(output) => !output.status.success(), + Err(_) => true, + }; + let text = formatter.format_result(result); + let outcome = if failed { + Outcome::Error + } else { + classify_finished(&text) + }; + (text, outcome) +} + /// Split finished results into green (nothing to do), yellow (something changed), /// and red (failed) buckets from the formatter's one-line summary. fn classify_finished(output: &str) -> Outcome { @@ -114,13 +134,13 @@ struct Counts { pending: usize, } -fn tally(rows: &[RepoRow]) -> Counts { +fn tally(outcomes: &[Outcome]) -> Counts { let mut c = Counts { - total: rows.len(), + total: outcomes.len(), ..Counts::default() }; - for row in rows { - match outcome_of(row) { + for &outcome in outcomes { + match outcome { Outcome::Pending => c.pending += 1, Outcome::Running => c.running += 1, Outcome::Ok => { @@ -140,6 +160,16 @@ fn tally(rows: &[RepoRow]) -> Counts { c } +/// Resolve each row's outcome for a frame. Finished rows use the outcome decided +/// from their command's exit status at completion; if none was recorded (e.g. in +/// tests), fall back to classifying the message. Pending/running come from state. +fn row_outcomes(rows: &[RepoRow], finished: &[Option]) -> Vec { + rows.iter() + .enumerate() + .map(|(i, row)| finished[i].unwrap_or_else(|| outcome_of(row))) + .collect() +} + /// Index of the last repo that has left the pending state — the "leading edge" /// of activity the view auto-follows so progress stays on screen. fn frontier(rows: &[RepoRow]) -> usize { @@ -194,7 +224,7 @@ pub(crate) fn run( started_at: Instant, formatter: &dyn OutputFormatter, cancel: Arc, -) -> Result<()> { +) -> Result> { install_panic_hook(); let _guard = TerminalGuard::enter()?; let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; @@ -203,6 +233,9 @@ pub(crate) fn run( let mut selected = 0usize; let mut user_quit = false; let last = rows.len().saturating_sub(1); + // Finished-row outcomes decided from each command's exit status (not its + // message text), so a failed fetch counts as an error rather than a change. + let mut finished: Vec> = vec![None; rows.len()]; loop { // Drain everything the workers have sent since the last frame. @@ -211,7 +244,9 @@ pub(crate) fn run( match rx.try_recv() { Ok(RepoEvent::Started { idx }) => rows[idx].mark_running(), Ok(RepoEvent::Completed { idx, result, .. }) => { - rows[idx].mark_finished(formatter.format_result(&result)); + let (text, outcome) = finished_outcome(&result, formatter); + finished[idx] = Some(outcome); + rows[idx].mark_finished(text); } Err(TryRecvError::Empty) => break, Err(TryRecvError::Disconnected) => { @@ -226,8 +261,9 @@ pub(crate) fn run( } selected = selected.min(last); + let outcomes = row_outcomes(rows, &finished); let elapsed = started_at.elapsed(); - terminal.draw(|f| draw(f, rows, header, name_width, elapsed, selected))?; + terminal.draw(|f| draw(f, rows, &outcomes, header, name_width, elapsed, selected))?; // Exit once the channel is closed and nothing is left in flight. if disconnected && rows.iter().all(|r| r.state == RowState::Finished) { @@ -276,12 +312,13 @@ pub(crate) fn run( cancel.cancel(); } - Ok(()) + Ok(row_outcomes(rows, &finished)) } fn draw( f: &mut Frame, rows: &[RepoRow], + outcomes: &[Outcome], header: &str, name_width: usize, elapsed: Duration, @@ -297,8 +334,8 @@ fn draw( .split(f.area()); render_header(f, chunks[0], header, elapsed); - render_table(f, chunks[1], rows, name_width, elapsed, selected); - render_footer(f, chunks[2], rows); + render_table(f, chunks[1], rows, outcomes, name_width, elapsed, selected); + render_footer(f, chunks[2], outcomes); } fn framed(title: &str) -> Block<'static> { @@ -342,18 +379,19 @@ fn render_table( f: &mut Frame, area: Rect, rows: &[RepoRow], + outcomes: &[Outcome], name_width: usize, elapsed: Duration, selected: usize, ) { - let c = tally(rows); + let c = tally(outcomes); let block = framed(&format!("repositories · {}/{} done", c.done, c.total)); let spinner = SPINNER[(elapsed.as_millis() / 90) as usize % SPINNER.len()]; let table_rows: Vec = rows .iter() - .map(|row| { - let outcome = outcome_of(row); + .zip(outcomes) + .map(|(row, &outcome)| { let glyph = if outcome == Outcome::Running { spinner } else { @@ -425,8 +463,8 @@ fn render_table( } } -fn render_footer(f: &mut Frame, area: Rect, rows: &[RepoRow]) { - let c = tally(rows); +fn render_footer(f: &mut Frame, area: Rect, outcomes: &[Outcome]) { + let c = tally(outcomes); let block = framed("progress"); let inner = block.inner(area); f.render_widget(block, area); @@ -436,6 +474,18 @@ fn render_footer(f: &mut Frame, area: Rect, rows: &[RepoRow]) { .constraints([Constraint::Length(1), Constraint::Length(1)]) .split(inner); + // Gauge line: progress bar on the left, key hints right-aligned. Keeping the + // hint up here lets the counts line below use the full width, so it never + // collides with the hint on a narrow terminal. + let hint = "q quit · j/k scroll"; + let gauge_cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(10), + Constraint::Length(hint.len() as u16 + 1), + ]) + .split(lines[0]); + let ratio = if c.total == 0 { 0.0 } else { @@ -446,7 +496,13 @@ fn render_footer(f: &mut Frame, area: Rect, rows: &[RepoRow]) { .gauge_style(Style::default().fg(Color::Green).bg(GAUGE_BG)) .ratio(ratio) .label(format!("{}/{} {:.0}%", c.done, c.total, ratio * 100.0)), - lines[0], + gauge_cols[0], + ); + f.render_widget( + Paragraph::new(Line::from(hint)) + .alignment(Alignment::Right) + .style(Style::default().fg(MUTED)), + gauge_cols[1], ); let status = Line::from(vec![ @@ -460,18 +516,7 @@ fn render_footer(f: &mut Frame, area: Rect, rows: &[RepoRow]) { Span::raw(" "), count_span("○", c.pending, "pending", MUTED), ]); - - let cols = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Min(10), Constraint::Length(20)]) - .split(lines[1]); - f.render_widget(Paragraph::new(status), cols[0]); - f.render_widget( - Paragraph::new(Line::from("q quit · j/k scroll")) - .alignment(Alignment::Right) - .style(Style::default().fg(MUTED)), - cols[1], - ); + f.render_widget(Paragraph::new(status), lines[1]); } fn count_span(glyph: &str, n: usize, label: &str, color: Color) -> Span<'static> { @@ -491,6 +536,7 @@ fn fmt_elapsed(elapsed: Duration) -> String { /// alt-screen closes, since its contents vanish on exit. pub(crate) fn print_summary( rows: &[RepoRow], + outcomes: &[Outcome], header: &str, name_width: usize, elapsed_ms: u128, @@ -505,7 +551,7 @@ pub(crate) fn print_summary( row.output )?; } - let c = tally(rows); + let c = tally(outcomes); writeln!( out, "{} of {} done · {} ok · {} changed · {} error · {:.1}s", @@ -552,7 +598,9 @@ mod tests { #[test] fn tally_counts_each_state() { - let c = tally(&rows()); + let data = rows(); + let outcomes = row_outcomes(&data, &vec![None; data.len()]); + let c = tally(&outcomes); assert_eq!(c.total, 5); assert_eq!(c.done, 3); assert_eq!(c.ok, 1); @@ -562,6 +610,44 @@ mod tests { assert_eq!(c.pending, 1); } + #[cfg(unix)] + #[test] + fn finished_outcome_uses_exit_status_not_wording() { + use std::os::unix::process::ExitStatusExt; + + struct Fmt; + impl OutputFormatter for Fmt { + fn format(&self, output: &std::process::Output) -> String { + if output.status.success() { + "no new commits".to_string() + } else { + String::from_utf8_lossy(&output.stderr) + .lines() + .next() + .unwrap_or("") + .to_string() + } + } + } + let out = |ok: bool, stderr: &str| std::process::Output { + status: std::process::ExitStatus::from_raw(if ok { 0 } else { 256 }), + stdout: Vec::new(), + stderr: stderr.as_bytes().to_vec(), + }; + + // A failed fetch whose message reads benignly is still an error. + let (text, oc) = finished_outcome(&Ok(out(false, "remote: Repository not found.")), &Fmt); + assert_eq!(oc, Outcome::Error); + assert_eq!(text, "remote: Repository not found."); + + // A spawn failure is an error. + let err = Err(std::io::Error::other("boom")); + assert_eq!(finished_outcome(&err, &Fmt).1, Outcome::Error); + + // A successful result classifies by its message. + assert_eq!(finished_outcome(&Ok(out(true, "")), &Fmt).1, Outcome::Ok); + } + #[test] fn frontier_tracks_last_active_repo() { // gateway (idx 3) is the last non-pending row. @@ -579,11 +665,13 @@ mod tests { let backend = TestBackend::new(80, 20); let mut terminal = Terminal::new(backend).expect("terminal"); let data = rows(); + let outcomes = row_outcomes(&data, &vec![None; data.len()]); terminal .draw(|f| { draw( f, &data, + &outcomes, "git-all status · 5 repos · ~/work · 8 workers", 14, Duration::from_millis(1200),