diff --git a/Cargo.lock b/Cargo.lock index 963635c..2abc03f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -447,6 +447,7 @@ dependencies = [ "indicatif", "is-terminal", "keyring", + "libc", "owo-colors", "reqwest", "serde", diff --git a/Cargo.toml b/Cargo.toml index 318e7da..eb51f38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ dialoguer = { version = "0.11", default-features = false, features = ["password" is-terminal = "0.4" textwrap = "0.16" futures-util = "0.3" +libc = "0.2" [profile.release] lto = "thin" diff --git a/hooks/pre-push b/hooks/pre-push index 9d5893f..5194672 100755 --- a/hooks/pre-push +++ b/hooks/pre-push @@ -2,6 +2,10 @@ # fj pre-push hook — local CI gate. # Runs fmt-check, clippy, tests, and a release build. With FJ_E2E=1 also # runs the live-API smoke suite against the currently signed-in host. +# +# This script is invoked by git push with the list of refs being pushed on +# stdin. We close stdin ourselves and run every step with stdin redirected +# from /dev/null so cargo / tests can't accidentally block waiting for input. set -euo pipefail @@ -13,25 +17,40 @@ if [[ "${FJ_SKIP_PREPUSH:-0}" = "1" ]]; then exit 0 fi +# Drain whatever git fed us on stdin so we don't deadlock if a child inherits +# our stdin and blocks. Then close it for the rest of the hook. +cat >/dev/null || true +exec 0&2 +} + +run() { + # Run with stdin closed and stdout/stderr passed through. + "$@" &2 diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 3777d7c..c801180 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -44,6 +44,10 @@ pub struct Cli { #[arg(long, global = true, env = "FJ_DEBUG")] pub debug: bool, + /// Skip piping long output through `$FJ_PAGER` / `$PAGER` (default: less). + #[arg(long, global = true)] + pub no_pager: bool, + #[command(subcommand)] pub command: Command, } @@ -116,6 +120,27 @@ pub struct CompletionArgs { pub async fn run(cli: Cli) -> Result<()> { crate::client::set_debug(cli.debug); + if cli.no_pager { + // SAFETY: setting an env var is safe; child processes inherit it. + unsafe { std::env::set_var("FJ_NO_PAGER", "1") }; + } + // Page commands whose output can run long: any list, view, diff, or + // commits. Each call site re-enters this helper if it wants pager support. + // We start it conditionally here too so most `--help` invocations stay + // unpaged. + let _pager = match &cli.command { + Command::Repo(_) + | Command::Issue(_) + | Command::Pr(_) + | Command::Release(_) + | Command::Search(_) + | Command::Status(_) + | Command::Label(_) + | Command::Run(_) + | Command::Api(_) => crate::output::pager::maybe_start(), + _ => None, + }; + match cli.command { Command::Auth(cmd) => auth::run(cmd).await, Command::Repo(cmd) => repo::run(cmd, cli.host.as_deref()).await, diff --git a/src/output/mod.rs b/src/output/mod.rs index b1700f8..7aa6b60 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -1,3 +1,5 @@ +pub mod pager; + use std::io::IsTerminal; use anyhow::Result; diff --git a/src/output/pager.rs b/src/output/pager.rs new file mode 100644 index 0000000..37ac71d --- /dev/null +++ b/src/output/pager.rs @@ -0,0 +1,110 @@ +//! Pager redirection. When stdout is a TTY (and the user hasn't opted out), +//! `maybe_start()` spawns `$FJ_PAGER` / `$PAGER` / `less -FRX` and dup2's our +//! stdout to its stdin. The returned guard waits on the child on drop, so all +//! output gets flushed before the process exits. +//! +//! The dup2 strategy means the rest of fj keeps using plain `println!` / +//! `print!` without knowing it's piped. `less -FRX` exits immediately if the +//! content fits one screen, so short output is unaffected. + +use std::io::IsTerminal; +use std::os::fd::IntoRawFd; +use std::os::unix::io::AsRawFd; +use std::process::{Child, Command, Stdio}; + +pub struct PagerGuard { + /// Saved copy of the original stdout fd so we can restore it before + /// waiting on the child (otherwise the child would block reading from a + /// stdin pipe we still hold open). + saved_stdout: Option, + child: Option, +} + +impl Drop for PagerGuard { + fn drop(&mut self) { + // Make sure anything buffered in stdout reaches the pager before we + // close our write end. + use std::io::Write; + let _ = std::io::stdout().flush(); + + if let Some(saved) = self.saved_stdout.take() { + unsafe { + libc::dup2(saved, libc::STDOUT_FILENO); + libc::close(saved); + } + } + if let Some(mut c) = self.child.take() { + let _ = c.wait(); + } + } +} + +/// If conditions are right, redirect this process's stdout to a pager. Returns +/// `None` if paging is disabled, stdout isn't a TTY, or spawning the pager +/// failed. +/// +/// The returned guard must outlive all `println!`/`print!` calls produced by +/// the current command. +pub fn maybe_start() -> Option { + if std::env::var_os("FJ_NO_PAGER").is_some() { + return None; + } + if !std::io::stdout().is_terminal() { + return None; + } + let pager_cmd = std::env::var("FJ_PAGER") + .ok() + .or_else(|| std::env::var("PAGER").ok()) + .unwrap_or_else(|| "less -FRX".into()); + let pager_cmd = pager_cmd.trim().to_string(); + if pager_cmd.is_empty() || pager_cmd == "cat" { + return None; + } + let mut parts = pager_cmd.split_whitespace(); + let program = parts.next()?; + let args: Vec<&str> = parts.collect(); + + // Save the current stdout so the guard can restore it. + let saved = unsafe { libc::dup(libc::STDOUT_FILENO) }; + if saved < 0 { + return None; + } + + let mut child = match Command::new(program) + .args(&args) + .stdin(Stdio::piped()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + { + Ok(c) => c, + Err(_) => { + unsafe { libc::close(saved) }; + return None; + } + }; + + // Take the child's stdin pipe and dup it onto our stdout. + let Some(stdin) = child.stdin.take() else { + unsafe { libc::close(saved) }; + let _ = child.kill(); + let _ = child.wait(); + return None; + }; + let pipe_fd = stdin.as_raw_fd(); + let result = unsafe { libc::dup2(pipe_fd, libc::STDOUT_FILENO) }; + if result < 0 { + unsafe { libc::close(saved) }; + let _ = child.kill(); + let _ = child.wait(); + return None; + } + // We dup'd the pipe fd onto stdout. Drop the ChildStdin handle without + // closing its underlying fd (it's now stdout's fd, owned by the process). + let _ = stdin.into_raw_fd(); + + Some(PagerGuard { + saved_stdout: Some(saved), + child: Some(child), + }) +}