diff --git a/docs/dev/index.md b/docs/dev/index.md index 10d3581..b1bbb81 100644 --- a/docs/dev/index.md +++ b/docs/dev/index.md @@ -6,3 +6,4 @@ Notes and documentation for `git-all` development. * [Future Plans](git-all-future.md) - Planned functionality and roadmap * [Git Notes](git-notes.md) - Git internals and implementation notes * [Crossterm Streaming Issue](issue-crossterm-streaming.md) - Investigation into streaming output +* [TTY Startup Feedback Issue](issue-tty-startup-feedback.md) - Why the sticky footer feels dead at startup, and options diff --git a/docs/dev/issue-tty-startup-feedback.md b/docs/dev/issue-tty-startup-feedback.md new file mode 100644 index 0000000..ffac808 --- /dev/null +++ b/docs/dev/issue-tty-startup-feedback.md @@ -0,0 +1,244 @@ +# Issue: TTY gives almost no feedback until repos start finishing + +## Status + +Phase 1 implemented (Options A + B). This doc frames the problem, records the +options, and marks what shipped. Option C (completion-order flushing) and the +Phase 2 full-live-table directions remain open for discussion. + +### What Phase 1 shipped + +A scope header and a live footer on the TTY path (`TtyTablePrinter` / +`run_parallel`): + +* A one-time scope banner above the footer, e.g. + `git-all fetch · 98 repos · ~/work · 16 workers`, so the size of the job is + visible immediately. +* The footer now redraws on a 200ms tick (via `recv_timeout`), so the elapsed + clock advances even when no repo has completed — it never looks frozen. +* The footer lists the in-flight repos (up to six names, then `+N more`), so you + can see what's being worked on before anything finishes. + +Non-TTY / redirected output is unchanged: no header, no footer, no ANSI — plain +completion rows only. Verified end-to-end under tmux for both `status` and +`fetch` (clean in-place redraw, `MoveUp` only ever 2–3 lines, no scrollback +pollution). Head-of-line blocking of the finished *rows* is unchanged and is what +Option C would address next. + +Reproduce locally with `script/tty-lab`, which builds a throwaway workspace of +fake repos behind a `git` latency shim so you can watch the live footer without +the network — `script/tty-lab` for an interactive shell, or +`script/tty-lab --run fetch` for a one-shot. + +## Summary + +After consolidating the TTY UX onto the sticky-footer printer (shipped 0.7.3+), +`git-all` shows essentially nothing but a frozen one-line summary until repos +begin to complete. On a directory with many repos and network-bound work — e.g. +`git all fetch` in `~/src/oss` or `~/work` — the tool looks hung for the first +several seconds. + +An earlier approach printed the *full list of repos first* and then updated each +row in place. That gave better perceived feedback but was dropped during +consolidation because of rendering complexity. This doc explains why the current +design behaves the way it does, what the older approach traded away, and the +options for getting the feedback back. + +## Current behavior + +The TTY path is [`TtyTablePrinter`](../../rust/src/printer.rs) driven by +[`run_parallel`](../../rust/src/runner.rs). On a run: + +1. Repos are discovered and sorted alphabetically; each becomes a + `RepoRow::pending`. +2. `printer.start()` renders **only** a two-line footer: + + ```text + -------------------------------------------------------------------------------- + SUMMARY 0 of 98 done | 0 running | 98 pending | 0.0s + ``` + + No repo list is printed. This is pinned by the test + `tty_table_printer_start_writes_only_the_footer`. +3. Worker threads acquire the semaphore, then send `Started`. On a TTY the row is + marked running and the footer is redrawn, so it flips to + `0 of 98 done | 16 running | 82 pending`. (`fetch`/`pull` default to 16 + workers, `status` to 8 — see `default_workers` in `rust/src/lib.rs`.) +4. Finished rows are flushed to scrollback **only in contiguous alphabetical + order** (`flush_finished_rows` advances `next_to_print` only while the *next* + row is `Finished`). The footer's "done" count climbs as repos finish, but the + actual repo lines stay hidden behind the alphabetically-earliest repo that is + still running. + +### Why it feels dead + +Three things combine into "no feedback": + +* *No scope shown up front.* You never see the list, and the count is buried in + the footer as `98 pending`. There is no `Fetching 98 repos…` moment. +* *No ticking clock.* The footer is only redrawn on channel events + (`Started` / `Completed`). Between the initial burst of `Started` events and + the first `Completed`, nothing redraws — the elapsed time does not advance on + its own. For network-bound `fetch`/`pull`, that dead window is routinely + multiple seconds, and the frozen `0.0s` makes it look hung. +* *Head-of-line blocking in the displayed lines.* Because rows flush in + contiguous alphabetical order, a slow alphabetically-early repo (say + `activities`) hides every already-finished repo behind it. The footer counter + moves, but no repo lines appear. + +Net: for the first few seconds you stare at a single, static +`0 of 98 done | 16 running | 82 pending | 0.0s` line. + +## What the older approach did (and why it was dropped) + +The "list first, update in place" UX the older branches had corresponds to +*Option 1: Reserved TTY Slots* in the earlier latency analysis, explored as two +spikes: + +* **Reserved rows (Spike 2, `spike/tty-row-updates`).** Print one sorted + `running…` row per repo up front, then rewrite each row in place on + completion. Best-in-class for *seeing the whole run at a glance*, but on a + 98-repo workspace it floods the terminal with a "placeholder burst" of 98 rows + before anything useful happens, and the hand-rolled ANSI cursor math garbled + scrollback history. +* **Full-page viewport (commit `431b50f`, `crossterm-smart-tty` lineage).** A + live in-place table that page-stepped through repos with a summary line, then + flowed the full sorted listing into scrollback at the end via + `render_complete`. Per the project memory this had *nicer live UX* than the + sticky footer, but it fought the terminal: each redraw could scroll the top row + into scrollback (duplicate `abq clean` lines), and on completion the viewport + parked on whatever page finished last, hiding the rest. + +The sticky footer (Spike 4 lineage, `spike-crossterm-smart-tty`) won the +consolidation because it is structurally simpler, keeps clean scrollback for free +(every finished row is a plain `writeln!`), works when repo count exceeds +terminal height, and matches the `cargo build` / `npm install` / +`indicatif::MultiProgress` mental model. The tradeoff we're now feeling: it +optimized scrollback and simplicity at the cost of *startup* and *in-flight* +feedback. + +Spike history and metrics live on the `spike/completion-order-output` branch +under `docs/plans/` (`output-spikes.md`, `output-spike-tracker.md`, +`ordered-output-latency.md`), and the shipped design lives in +[`docs/superpowers/plans/2026-05-09-printer-sticky-footer.md`](../superpowers/plans/2026-05-09-printer-sticky-footer.md). + +## Constraint worth restating + +From the latency analysis: in a plain append-only stream you cannot have all +three of (1) strict alphabetical order, (2) a line the instant a repo finishes, +and (3) no placeholders/redraws. Any two are achievable; getting all the "feels +responsive" properties means either giving up strict live ordering (completion +order) or adopting in-place updates (reserved rows / viewport). + +## Options + +Ordered roughly smallest-to-largest. They are not mutually exclusive — A, B, and +C compose into one modest change. + +### Option A — Make the footer feel alive + +Keep the sticky footer, but: + +* Redraw on a timer (a lightweight ticker event, e.g. every 100–250ms) so the + elapsed clock advances and the display never looks frozen. +* List the currently in-flight repos in the footer — e.g. grow it to a few lines + showing `running: repo-a, repo-b, repo-c …(+13)`. This gives immediate + "something is happening, and here's what" without an N-row burst. + +*Pros:* small, stays inside the current architecture, directly kills the +"looks hung" feeling. *Cons:* footer redraw logic gets slightly more stateful; +need to bound the running list to a few names so it doesn't churn. + +### Option B — Print a scope header up front + +On `start()`, print one permanent line before the footer: + +```text +Fetching 98 repos in ~/work (16 workers) +``` + +*Pros:* trivial; you immediately know the size of the job. *Cons:* on its own it +doesn't fix the dead window between start and first completion (pairs naturally +with A). + +### Option C — Flush finished rows in completion order + +Drop the contiguous-alphabetical buffering: print each repo the moment it +finishes, with a stable sorted index prefix so runs stay greppable, and +optionally emit a final sorted summary block. This is Spike 1, which measured the +biggest win on the trace metrics (`delayed_repos` collapsed toward zero, +`first_print_ms` dropped from ~500ms to ~140ms). + +*Pros:* removes head-of-line blocking in the *displayed* lines — repos appear as +they finish. Smallest change that makes the list actually populate promptly. +*Cons:* live scrollback is no longer strictly alphabetical (mitigated by the +index prefix and/or an optional final sorted summary). + +### Option D — Reserved live rows / bounded viewport ("list first") + +The UX the older branches had, done within a viewport bounded to terminal height: +show up to `min(N, viewport_height)` live rows with running/finished state, +update in place, and flow all rows into scrollback on completion (the +`render_complete` idea from `431b50f`). + +*Pros:* best "see the whole run at a glance" experience. *Cons:* this is the +approach that was ruled out — placeholder burst, viewport/scrollback bookkeeping, +and the terminal-height problem are real. Highest complexity; likely wants a +maintained library rather than hand-rolled cursor math. + +### Option E — Adopt a progress library (`indicatif`) + +Use `indicatif::MultiProgress` (already the reference model for the sticky +footer) to manage the multi-line live region + scrollback interleaving instead of +hand-rolling it. + +*Pros:* offloads the terminal math that bit us in D; well-tested cross-platform. +*Cons:* new dependency; need to confirm non-TTY / redirected output stays plain, +and that it plays well with our per-repo line format. + +## Recommendation + +Two phases: + +* [x] **Phase 1 (low risk, high value):** Option A + Option B — an immediate + scope line plus a live-ticking footer that shows what's in flight. This + addresses the actual complaint ("no feedback / looks hung") without reopening + the viewport complexity. Shipped; see *What Phase 1 shipped* above. +* [ ] **Option C (optional follow-up):** completion-order flushing so finished + repo *rows* appear the instant they complete rather than waiting behind the + alphabetically-earliest unfinished repo. Deferred pending the default-vs-opt-in + decision below. +* [ ] **Phase 2 (if Phase 1 isn't enough):** evaluate Option D or E as the + "full live table" north star, informed by whether Phase 1 already feels good on + `~/work` / `~/src/oss`. + +This keeps the change proportional to the problem and preserves the sticky +footer's wins (clean scrollback, simplicity, no height limit) while restoring the +startup and in-flight feedback the consolidation traded away. + +## Open questions + +* Should completion-order (Option C) be the default, or opt-in behind a flag with + alphabetical-append kept as default? The trace data favors completion order, + but greppability/muscle memory may favor keeping sorted output the default. +* How many in-flight repo names should the footer show before collapsing to a + `(+N)` count? +* Is a ticker thread acceptable, or should the redraw cadence be driven some + other way (e.g. a short channel `recv_timeout`)? + +## References + +* [`rust/src/printer.rs`](../../rust/src/printer.rs) — `TtyTablePrinter`, + `PlainPrinter`, footer rendering. +* [`rust/src/runner.rs`](../../rust/src/runner.rs) — `run_parallel`, the + `Started`/`Completed` event loop. +* [`docs/dev/issue-crossterm-streaming.md`](issue-crossterm-streaming.md) — the + original streaming investigation. +* [`docs/superpowers/plans/2026-05-09-printer-sticky-footer.md`](../superpowers/plans/2026-05-09-printer-sticky-footer.md) — + the shipped sticky-footer design. +* Spike docs on branch `spike/completion-order-output`: `docs/plans/output-spikes.md`, + `docs/plans/output-spike-tracker.md`, `docs/plans/ordered-output-latency.md`. +* Commit `431b50f` — the full-page viewport printer (`render_complete`), prior art + for Option D. + + diff --git a/rust/Cargo.lock b/rust/Cargo.lock index e8fe691..030d1bb 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -236,7 +236,7 @@ dependencies = [ [[package]] name = "git-all" -version = "0.7.4" +version = "0.7.5-rc.1" dependencies = [ "anyhow", "clap", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 3531fca..6ef73fa 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -7,7 +7,7 @@ default-members = ["."] [package] name = "git-all" -version = "0.7.4" +version = "0.7.5-rc.1" edition = "2024" description = "parallel git across many repositories" diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 20e477d..f6ea120 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -188,6 +188,7 @@ pub fn run() -> Result<()> { cli.ssh_multiplexing, workers, cwd, + command_label(&cli.command).to_string(), trace, ); diff --git a/rust/src/printer.rs b/rust/src/printer.rs index 56a986f..895ed62 100644 --- a/rust/src/printer.rs +++ b/rust/src/printer.rs @@ -109,6 +109,13 @@ pub trait Printer { elapsed_ms: u128, ) -> io::Result>; fn complete(&mut self, rows: &[RepoRow], elapsed_ms: u128) -> io::Result>; + + /// Redraw live progress without any row-state change (e.g. a clock tick). + /// No-op for printers with no live region. + fn tick(&mut self, rows: &[RepoRow], elapsed_ms: u128) -> io::Result<()> { + let _ = (rows, elapsed_ms); + Ok(()) + } } pub struct PlainPrinter { @@ -172,11 +179,17 @@ pub struct TtyTablePrinter { terminal_columns: usize, repo_width: usize, next_to_print: usize, - footer_active: bool, + /// Lines the live footer currently occupies (0 when not drawn). The footer + /// grows a line while repos are in flight, so its height varies. + footer_height: u16, + /// One-time scope line printed above the footer on `start`. + header: Option, } impl TtyTablePrinter { - const FOOTER_HEIGHT: u16 = 2; + /// Max in-flight repo names to list in the footer before collapsing the + /// remainder into a "+N more" count. + const MAX_RUNNING_NAMES: usize = 6; pub fn new(writer: W, terminal_columns: usize, repo_width: usize) -> Self { Self { @@ -184,10 +197,17 @@ impl TtyTablePrinter { terminal_columns, repo_width, next_to_print: 0, - footer_active: false, + footer_height: 0, + header: None, } } + /// Set the one-time scope line shown above the footer. + pub fn with_header(mut self, header: Option) -> Self { + self.header = header; + self + } + fn terminal_width(&self) -> usize { if self.terminal_columns == 0 { DEFAULT_TERMINAL_COLUMNS @@ -227,14 +247,14 @@ impl TtyTablePrinter { } fn clear_footer(&mut self) -> io::Result<()> { - if self.footer_active { + if self.footer_height > 0 { queue!( self.writer, MoveToColumn(0), - MoveUp(Self::FOOTER_HEIGHT), + MoveUp(self.footer_height), Clear(ClearType::FromCursorDown) )?; - self.footer_active = false; + self.footer_height = 0; } Ok(()) } @@ -243,10 +263,16 @@ impl TtyTablePrinter { let mut complete = 0usize; let mut running = 0usize; let mut pending = 0usize; + let mut running_names: Vec<&str> = Vec::new(); for row in rows { match row.state { RowState::Finished => complete += 1, - RowState::Running => running += 1, + RowState::Running => { + running += 1; + if running_names.len() < Self::MAX_RUNNING_NAMES { + running_names.push(&row.repo); + } + } RowState::Pending => pending += 1, } } @@ -264,16 +290,32 @@ impl TtyTablePrinter { footer.render_message(), width = self.repo_width ); + + let mut height: u16 = 0; writeln!(self.writer, "{}", separator)?; + height += 1; writeln!(self.writer, "{}", self.fit_line(&summary))?; + height += 1; + if running > 0 { + let mut line = format!("running: {}", running_names.join(", ")); + if running > running_names.len() { + line.push_str(&format!(", +{} more", running - running_names.len())); + } + writeln!(self.writer, "{}", self.fit_line(&line))?; + height += 1; + } self.writer.flush()?; - self.footer_active = true; + self.footer_height = height; Ok(()) } } impl Printer for TtyTablePrinter { fn start(&mut self, rows: &[RepoRow]) -> io::Result<()> { + if let Some(header) = self.header.clone() { + let line = self.fit_line(&header); + writeln!(self.writer, "{}", line)?; + } self.render_footer(rows, 0) } @@ -295,6 +337,11 @@ impl Printer for TtyTablePrinter { self.render_footer(rows, elapsed_ms)?; Ok(printed) } + + fn tick(&mut self, rows: &[RepoRow], elapsed_ms: u128) -> io::Result<()> { + self.clear_footer()?; + self.render_footer(rows, elapsed_ms) + } } #[cfg(test)] @@ -593,10 +640,7 @@ mod tests { rows[1].mark_finished("clean".to_string()); let printed = printer.update_row(&rows, 1, 100).expect("tty late finish"); - assert!( - printed.is_empty(), - "row 1 must buffer until row 0 finishes" - ); + assert!(printed.is_empty(), "row 1 must buffer until row 0 finishes"); let mid = strip_ansi_sequences(&output.rendered()); assert!( !mid.contains("agentic-dev clean"), @@ -629,9 +673,11 @@ mod tests { } let rendered = String::from_utf8(output).expect("utf8"); + // The start footer is 3 lines (separator + summary + running list) while + // "activities" is in flight, so the in-place clear moves up by 3. assert!( - rendered.contains("\x1b[2A"), - "expected MoveUp(2) escape; got: {rendered:?}" + rendered.contains("\x1b[3A"), + "expected MoveUp(3) escape; got: {rendered:?}" ); assert!( rendered.contains("\x1b[J") || rendered.contains("\x1b[0J"), @@ -639,6 +685,89 @@ mod tests { ); } + #[test] + fn tty_table_printer_prints_header_once_above_the_footer() { + let rows = vec![RepoRow::pending("activities".to_string())]; + let mut output = Vec::new(); + + { + let mut printer = TtyTablePrinter::new(&mut output, 80, 14).with_header(Some( + "git-all fetch · 1 repo · ~/work · 8 workers".to_string(), + )); + printer.start(&rows).expect("tty start"); + } + + let stripped = strip_ansi_sequences(&String::from_utf8(output).expect("utf8")); + assert!(stripped.contains("git-all fetch · 1 repo · ~/work · 8 workers")); + assert!(stripped.contains("SUMMARY")); + // Header sits above the footer. + let header_pos = stripped.find("git-all fetch").expect("header printed"); + let summary_pos = stripped.find("SUMMARY").expect("summary printed"); + assert!(header_pos < summary_pos); + } + + #[test] + fn tty_table_printer_footer_lists_in_flight_repos() { + let rows = vec![ + RepoRow::running("activities".to_string()), + RepoRow::pending("agentic-dev".to_string()), + ]; + let mut output = Vec::new(); + + { + let mut printer = TtyTablePrinter::new(&mut output, 80, 14); + printer.start(&rows).expect("tty start"); + } + + let stripped = strip_ansi_sequences(&String::from_utf8(output).expect("utf8")); + assert!(stripped.contains("running: activities")); + assert!(stripped.contains("0 of 2 done | 1 running | 1 pending")); + } + + #[test] + fn tty_table_printer_running_line_caps_names_and_counts_the_rest() { + let rows: Vec = (0..10) + .map(|i| RepoRow::running(format!("repo-{i:02}"))) + .collect(); + let mut output = Vec::new(); + + { + // Wide terminal so the running line is not truncated. + let mut printer = TtyTablePrinter::new(&mut output, 200, 14); + printer.start(&rows).expect("tty start"); + } + + let stripped = strip_ansi_sequences(&String::from_utf8(output).expect("utf8")); + // Six names shown, remaining four summarized. + assert!(stripped.contains("repo-05")); + assert!(!stripped.contains("repo-06")); + assert!(stripped.contains("+4 more")); + } + + #[test] + fn tty_table_printer_tick_redraws_footer_with_updated_elapsed() { + let rows = vec![RepoRow::running("activities".to_string())]; + let mut output = Vec::new(); + + { + let mut printer = TtyTablePrinter::new(&mut output, 80, 14); + printer.start(&rows).expect("tty start"); + printer.tick(&rows, 2500).expect("tty tick"); + } + + let rendered = String::from_utf8(output).expect("utf8"); + let stripped = strip_ansi_sequences(&rendered); + assert!( + stripped.contains("2.5s"), + "tick should show new elapsed time" + ); + // A tick clears the previous footer in place before redrawing it. + assert!( + rendered.contains("\x1b[3A"), + "tick should move up over the footer" + ); + } + #[test] fn tty_table_printer_does_not_truncate_finished_row_content() { let long_status = "2410 modified, 473 deleted, 47 untracked"; diff --git a/rust/src/runner.rs b/rust/src/runner.rs index 5e147b5..666be07 100644 --- a/rust/src/runner.rs +++ b/rust/src/runner.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; 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 +48,10 @@ impl Semaphore { const MIN_REPO_NAME_WIDTH: usize = 4; const MAX_REPO_NAME_WIDTH_CAP: usize = 48; +/// How often the live footer is redrawn while waiting for repos to finish, so +/// the elapsed clock advances even when no repo has completed yet. +const TICK_INTERVAL: Duration = Duration::from_millis(200); + /// Trace sample for a completed repo (`None` when `GIT_ALL_TRACE` is off). type RepoCompletion = Option; @@ -82,6 +86,37 @@ fn compute_name_width(repos: &[PathBuf], display_root: &Path) -> usize { capped.max(MIN_REPO_NAME_WIDTH) } +/// One-line scope banner shown above the live footer on a TTY, 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 +131,7 @@ pub struct ExecutionContext { ssh_multiplexing: bool, max_connections: usize, display_root: PathBuf, + command_label: String, trace: TraceSink, } @@ -106,6 +142,7 @@ impl ExecutionContext { ssh_multiplexing: bool, max_connections: usize, display_root: PathBuf, + command_label: String, trace: TraceSink, ) -> Self { Self { @@ -114,6 +151,7 @@ impl ExecutionContext { ssh_multiplexing, max_connections, display_root, + command_label, trace, } } @@ -122,6 +160,10 @@ impl ExecutionContext { self.dry_run } + pub fn command_label(&self) -> &str { + &self.command_label + } + pub fn git_invocation_options(&self) -> GitInvocationOptions { GitInvocationOptions { url_scheme: self.url_scheme, @@ -280,6 +322,7 @@ 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() @@ -298,14 +341,15 @@ where }; let stdout = stdout.lock(); let mut printer: Box = if is_tty { - Box::new(TtyTablePrinter::new(stdout, terminal_columns, name_width)) + let header = run_header(ctx, repos.len(), max_workers); + Box::new( + TtyTablePrinter::new(stdout, terminal_columns, name_width).with_header(Some(header)), + ) } 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))) } else { @@ -372,18 +416,18 @@ where } drop(tx); - for event in rx { - match event { - RepoEvent::Started { idx } => { + loop { + match rx.recv_timeout(TICK_INTERVAL) { + Ok(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 { + Ok(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(); @@ -397,6 +441,13 @@ where &mut summary, )?; } + // No repo has changed state yet — redraw so the clock advances. + Err(mpsc::RecvTimeoutError::Timeout) => { + let elapsed_ms = run_started_at.elapsed().as_millis(); + printer.tick(&rows, elapsed_ms)?; + } + // All workers have finished and dropped their senders. + Err(mpsc::RecvTimeoutError::Disconnected) => break, } } Ok(()) diff --git a/script/tty-lab b/script/tty-lab new file mode 100755 index 0000000..a194c10 --- /dev/null +++ b/script/tty-lab @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# +# tty-lab — spin up a throwaway workspace of fake repos behind a latency shim so +# you can eyeball git-all's live TTY output without needing the network. +# +# It builds a temp dir of N git repos, puts a slow `git` shim on PATH (each git +# call sleeps a randomized MIN..MAX ms, simulating network/IO), and either runs +# one git-all command or drops you into an interactive subshell where `git-all` +# points at your build and `git` is the shim. +# +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +usage() { + cat <<'USAGE' +tty-lab — fake latency workspace for eyeballing git-all's live TTY output. + +Usage: + script/tty-lab # 24 repos, open an interactive shell + script/tty-lab 80 # 80 repos (bare number = --repos) + script/tty-lab --run status # run one command and exit + script/tty-lab 80 --run fetch + script/tty-lab --min-ms 100 --max-ms 3000 # slower, more dramatic + script/tty-lab --remotes --run fetch # wire local remotes so fetch is clean + script/tty-lab --keep # keep the workspace after exit + +Options: + --repos N number of fake repos to create (default: 24; or a bare number) + --dirty K leave K repos with uncommitted changes (default: 4) + --min-ms MS minimum per-git-call delay in ms (default: 300) + --max-ms MS maximum per-git-call delay in ms (default: 1500) + --remotes give each repo a local bare remote (makes `fetch` meaningful) + --bin PATH git-all binary to drive (default: release, else debug build) + --run "CMD" run `git-all CMD` once, then exit instead of opening a shell + --keep keep the temp workspace instead of deleting it on exit + -h, --help show this help + +In interactive mode, `git` is the latency shim and `git-all` points at your +build. Type `exit` to tear the workspace down. +USAGE +} + +repos=24 +dirty=4 +min_ms=300 +max_ms=1500 +remotes=0 +keep=0 +run_cmd="" +bin="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --repos) repos="$2"; shift 2 ;; + --dirty) dirty="$2"; shift 2 ;; + --min-ms) min_ms="$2"; shift 2 ;; + --max-ms) max_ms="$2"; shift 2 ;; + --remotes) remotes=1; shift ;; + --bin) bin="$2"; shift 2 ;; + --run) run_cmd="$2"; shift 2 ;; + --keep) keep=1; shift ;; + -h|--help) usage; exit 0 ;; + -*) echo "tty-lab: unknown option: $1" >&2; usage >&2; exit 2 ;; + '' | *[!0-9]*) echo "tty-lab: unexpected argument: $1" >&2; usage >&2; exit 2 ;; + *) repos="$1"; shift ;; # a bare number is the repo count + esac +done + +if (( repos < 1 )); then + echo "tty-lab: --repos must be at least 1 (got $repos)" >&2 + exit 2 +fi + +# Pick a git-all binary: explicit --bin, else release, else debug. +if [[ -z "$bin" ]]; then + if [[ -x "$ROOT/rust/target/release/git-all" ]]; then + bin="$ROOT/rust/target/release/git-all" + elif [[ -x "$ROOT/rust/target/debug/git-all" ]]; then + bin="$ROOT/rust/target/debug/git-all" + else + echo "tty-lab: no git-all binary found. Build one first:" >&2 + echo " script/build -t rust # release" >&2 + echo " (cd rust && cargo build) # debug" >&2 + exit 1 + fi +fi +[[ -x "$bin" ]] || { echo "tty-lab: not executable: $bin" >&2; exit 1; } + +real_git="$(command -v git)" + +lab="$(mktemp -d "${TMPDIR:-/tmp}/git-all-tty-lab.XXXXXX")" +ws="$lab/ws" +shim="$lab/shim" +mkdir -p "$ws" "$shim" + +cleanup() { [[ "$keep" == 1 ]] || rm -r "$lab" 2>/dev/null || true; } +trap cleanup EXIT + +# A spread of alphabetically-distributed names; synthesized past the list end. +names=(activities agentic-dev amion-api backend-core billing-svc cache-layer + dashboard data-pipeline edge-proxy feature-flags gateway growth-tools + identity infra-tools kafka-bridge ledger metrics-agent notifications + orders-api payments quota-svc reporting search-index webhooks + xylophone-svc yak-shaver zebra-cache) + +chosen=() +for ((i = 0; i < repos; i++)); do + if (( i < ${#names[@]} )); then + chosen+=("${names[i]}") + else + chosen+=("$(printf 'repo-%03d' "$i")") + fi +done + +echo "tty-lab: creating $repos repos in $ws ..." +for n in "${chosen[@]}"; do + r="$ws/$n" + git init -q "$r" + git -C "$r" config user.email lab@example.com + git -C "$r" config user.name tty-lab + echo "# $n" > "$r/README.md" + git -C "$r" add README.md + git -C "$r" commit -q -m init +done + +# Leave the first `dirty` repos with uncommitted changes so status output varies. +for ((i = 0; i < dirty && i < ${#chosen[@]}; i++)); do + : > "$ws/${chosen[i]}/uncommitted.tmp" +done + +if [[ "$remotes" == 1 ]]; then + echo "tty-lab: wiring local bare remotes ..." + mkdir -p "$lab/remotes" + for n in "${chosen[@]}"; do + git init -q --bare "$lab/remotes/$n.git" + git -C "$ws/$n" remote add origin "$lab/remotes/$n.git" + git -C "$ws/$n" push -q origin HEAD:refs/heads/main + done +fi + +# Latency shim: sleep a random MIN..MAX ms, then exec the real git. +cat > "$shim/git" < "$shim/git-all" < "$rc" <