diff --git a/rust/Cargo.lock b/rust/Cargo.lock index e8fe691..e59a571 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,9 @@ version = "0.7.4" dependencies = [ "anyhow", "clap", - "crossterm", + "crossterm 0.29.0", + "libc", + "ratatui", "tempfile", ] @@ -250,6 +343,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -271,6 +366,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 +384,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 +439,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 +472,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 +496,7 @@ dependencies = [ "libc", "log", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -387,6 +534,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 +574,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 +619,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 +641,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 +748,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 +802,8 @@ dependencies = [ "fastrand", "getrandom", "once_cell", - "rustix", - "windows-sys", + "rustix 1.1.3", + "windows-sys 0.61.2", ] [[package]] @@ -591,6 +818,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 +939,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 +957,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..ba6f25e 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -15,6 +15,12 @@ description = "parallel git across many repositories" clap = { version = "4.5", features = ["derive"] } 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/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..cfb9d64 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,10 +50,102 @@ 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; -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 +176,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 +221,7 @@ pub struct ExecutionContext { ssh_multiplexing: bool, max_connections: usize, display_root: PathBuf, + command_label: String, trace: TraceSink, } @@ -106,6 +232,7 @@ impl ExecutionContext { ssh_multiplexing: bool, max_connections: usize, display_root: PathBuf, + command_label: String, trace: TraceSink, ) -> Self { Self { @@ -114,6 +241,7 @@ impl ExecutionContext { ssh_multiplexing, max_connections, display_root, + command_label, trace, } } @@ -122,6 +250,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 +414,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 +436,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))) @@ -312,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(); @@ -322,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 }); } @@ -336,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), }; @@ -372,41 +528,82 @@ 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); + let cancel = cancel.clone().expect("use_tui implies a cancel registry"); + let outcomes = crate::tui::run( + rx, + &mut rows, + &header, + name_width, + run_started_at, + 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 { + 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)?; + // 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)?; + } Ok(()) } @@ -431,6 +628,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 new file mode 100644 index 0000000..85c939d --- /dev/null +++ b/rust/src/tui.rs @@ -0,0 +1,690 @@ +//! 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::mpsc::{Receiver, TryRecvError}; +use std::sync::{Arc, Once}; +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::{CancelRegistry, 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 command's result. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) 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), + } +} + +/// 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 { + 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(outcomes: &[Outcome]) -> Counts { + let mut c = Counts { + total: outcomes.len(), + ..Counts::default() + }; + for &outcome in outcomes { + match outcome { + 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 +} + +/// 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 { + 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, + cancel: Arc, +) -> 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 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. + let mut disconnected = false; + loop { + match rx.try_recv() { + Ok(RepoEvent::Started { idx }) => rows[idx].mark_running(), + Ok(RepoEvent::Completed { idx, result, .. }) => { + let (text, outcome) = finished_outcome(&result, formatter); + finished[idx] = Some(outcome); + rows[idx].mark_finished(text); + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } + } + + if follow { + selected = frontier(rows); + } + selected = selected.min(last); + + let outcomes = row_outcomes(rows, &finished); + let elapsed = started_at.elapsed(); + 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) { + 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 => { + 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); + } + 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, + _ => {} + } + } + + // 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(row_outcomes(rows, &finished)) +} + +fn draw( + f: &mut Frame, + rows: &[RepoRow], + outcomes: &[Outcome], + 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, outcomes, name_width, elapsed, selected); + render_footer(f, chunks[2], outcomes); +} + +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], + outcomes: &[Outcome], + name_width: usize, + elapsed: Duration, + selected: usize, +) { + 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() + .zip(outcomes) + .map(|(row, &outcome)| { + 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, outcomes: &[Outcome]) { + let c = tally(outcomes); + 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); + + // 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 { + 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)), + 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![ + 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), + ]); + f.render_widget(Paragraph::new(status), lines[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], + outcomes: &[Outcome], + 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(outcomes); + 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 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); + assert_eq!(c.notable, 1); + assert_eq!(c.error, 1); + assert_eq!(c.running, 1); + 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. + 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(); + 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), + 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")); + } +}