From 63b75ced56d4c4ff4d1244d6f16ff8dd7b6dfca3 Mon Sep 17 00:00:00 2001 From: mintaka Date: Fri, 11 Sep 2026 17:13:59 -0400 Subject: [PATCH] [upstream handoff] fix: fall back to flat output when stderr is not a terminal This branch is based on upstream v0.5.4 (0c0341846), not on this fork's main. Matt pulls it and pushes it to the upstream Codeberg repo (codeberg.org/abrenneke/jj-vine). The two sections below are the text to open the upstream PR with; they are the deliverable, not this fork PR's own description. ## Upstream PR title fix: fall back to flat output when stderr is not a terminal ## Upstream PR description `run_stdout` selects the `indicatif` spinner whenever the command supports it and `--verbose` is off, without checking whether stderr is a terminal. `indicatif` draws nothing when stderr is not a TTY, so running with stderr piped or captured (CI, an editor's terminal, `2>file`) shows no progress at all and the command looks like it has hung. This gates the choice on `std::io::stderr().is_terminal()` through a small pure helper. When stderr is not a terminal the output falls back to the flat logger, which writes each step through `tracing` and stays visible. `--verbose` still forces flat output. Adds unit tests over the verbose / command-supports-interactive / is-a-terminal combinations. Co-authored-by: Matt Wilkinson --- src/cli.rs | 51 ++++++++++++++++++++++++++++++++++++++++++++++----- src/output.rs | 9 +++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index ee1aa10..c174f05 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -65,11 +65,17 @@ impl Cli { Commands::Init => false, }; - let output: SyncOutput = if self.verbose || !can_have_interactive_output { - SyncOutput::Flat(FlatOutput::new()) - } else { - SyncOutput::Interactive(InteractiveOutput::new()) - }; + let output: SyncOutput = + match interactive_output_candidate(self.verbose, can_have_interactive_output) { + // Only keep the spinner if indicatif will actually draw it. On a + // non-terminal stderr (piped/captured, or `TERM=dumb`/unset) the + // spinner is hidden and every `log_message` through it is dropped, + // so fall back to flat logging, which stays visible via `tracing`. + Some(interactive) if !interactive.is_hidden() => { + SyncOutput::Interactive(interactive) + } + _ => SyncOutput::Flat(FlatOutput::new()), + }; let filter = EnvFilter::builder() .with_default_directive(Level::INFO.into()) @@ -149,3 +155,38 @@ impl Cli { } } } + +/// Build an interactive spinner candidate when the run is eligible for one, or +/// `None` when it must use flat logging regardless of the terminal. +/// +/// This decides only the two conditions the caller controls: `verbose` forces +/// flat logging, and a command that shows no progress (e.g. `init`) is never +/// interactive. Whether stderr is actually a usable terminal is left to +/// `indicatif` — the caller checks [`InteractiveOutput::is_hidden`] on the +/// returned candidate. Deriving the terminal test here (e.g. only +/// `stderr().is_terminal()`) would miss cases `indicatif` still hides, such as +/// `TERM=dumb` or an unset `TERM` on a real pty, and the spinner's output would +/// vanish silently. +#[must_use] +fn interactive_output_candidate( + verbose: bool, + can_have_interactive_output: bool, +) -> Option { + (!verbose && can_have_interactive_output).then(InteractiveOutput::new) +} + +#[cfg(test)] +mod tests { + use super::interactive_output_candidate; + + #[test] + fn candidate_only_when_eligible() { + // A command that supports a spinner, not verbose: eligible (the caller + // still drops it if indicatif would hide it on a non-terminal). + assert!(interactive_output_candidate(false, true).is_some()); + // Verbose always uses flat logging, even when a spinner is supported. + assert!(interactive_output_candidate(true, true).is_none()); + // A command that cannot show a spinner (e.g. init) is never interactive. + assert!(interactive_output_candidate(false, false).is_none()); + } +} diff --git a/src/output.rs b/src/output.rs index 41a6eae..2532302 100644 --- a/src/output.rs +++ b/src/output.rs @@ -154,6 +154,15 @@ impl InteractiveOutput { substeps: RwLock::new(Vec::new()), } } + + /// Whether indicatif is suppressing this spinner's output because stderr is + /// not a usable terminal (piped or captured, or `TERM=dumb`/unset). When + /// true, the caller should fall back to flat logging so progress stays + /// visible. + #[must_use] + pub fn is_hidden(&self) -> bool { + self.spinner.is_hidden() + } } impl Default for InteractiveOutput {