use std::ffi::OsStr;
use std::fmt::Display;
use std::path::Path;
use std::process::Output;
use std::process::{CommandArgs, CommandEnvs, ExitStatus, Stdio};
use std::sync::LazyLock;
use owo_colors::OwoColorize;
use prek_consts::env_vars::EnvVars;
use thiserror::Error;
use tokio::io::AsyncReadExt;
use tracing::trace;
use crate::git::GIT;
static LOG_TRUNCATE_LIMIT: LazyLock<usize> = LazyLock::new(|| {
EnvVars::var(EnvVars::PREK_LOG_TRUNCATE_LIMIT)
.ok()
.and_then(|limit| limit.parse::<usize>().ok())
.filter(|limit| *limit > 0)
.unwrap_or(120)
});
#[derive(Debug, Error)]
pub enum Error {
#[error("Run command `{summary}` failed")]
Exec {
summary: String,
#[source]
cause: std::io::Error,
},
#[error("Command `{summary}` exited with an error:\n{error}")]
Status { summary: String, error: StatusError },
#[cfg(not(windows))]
#[error("Failed to open pty")]
Pty(#[from] prek_pty::Error),
#[error("Failed to setup subprocess for pty")]
PtySetup(#[from] std::io::Error),
}
#[derive(Debug)]
pub struct StatusError {
pub status: ExitStatus,
pub output: Option<Output>,
}
impl Display for StatusError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "\n{}\n{}", "[status]".red(), self.status)?;
if let Some(output) = &self.output {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
write_trimmed_output_section(f, "[stdout]", &stdout)?;
write_trimmed_output_section(f, "[stderr]", &stderr)?;
}
Ok(())
}
}
fn write_trimmed_output_section(
f: &mut std::fmt::Formatter<'_>,
label: &str,
output: &str,
) -> std::fmt::Result {
let mut lines = output.split('\n').filter_map(|line| {
let line = line.trim();
if line.is_empty() { None } else { Some(line) }
});
let Some(first) = lines.next() else {
return Ok(());
};
writeln!(f, "\n{}\n{}", label.red(), first)?;
for line in lines {
writeln!(f, "{line}")?;
}
Ok(())
}
pub struct Cmd {
pub inner: tokio::process::Command,
summary: String,
check_status: bool,
}
pub(crate) trait OutputSink {
fn write_chunk(&mut self, chunk: &[u8]);
}
fn write_output_chunk(output: &mut Vec<u8>, sink: &mut impl OutputSink, chunk: &[u8]) {
output.extend_from_slice(chunk);
sink.write_chunk(chunk);
}
impl Cmd {
pub fn new(command: impl AsRef<OsStr>, summary: impl Into<String>) -> Self {
let inner = tokio::process::Command::new(command);
Self {
summary: summary.into(),
inner,
check_status: true,
}
}
}
impl Cmd {
pub fn stdout_to_stderr(&mut self) -> &mut Self {
self.inner.stdout(std::io::stderr());
self
}
pub fn check(&mut self, checked: bool) -> &mut Self {
self.check_status = checked;
self
}
}
impl Cmd {
pub async fn run(&mut self) -> Result<(), Error> {
self.status().await?;
Ok(())
}
pub fn spawn(&mut self) -> Result<tokio::process::Child, Error> {
self.log_command();
self.inner.spawn().map_err(|cause| Error::Exec {
summary: self.summary.clone(),
cause,
})
}
pub async fn output(&mut self) -> Result<Output, Error> {
self.log_command();
let output = self.inner.output().await.map_err(|cause| Error::Exec {
summary: self.summary.clone(),
cause,
})?;
self.maybe_check_output(&output)?;
Ok(output)
}
pub(crate) async fn output_with_sink<S: OutputSink>(
&mut self,
mut sink: S,
) -> Result<Output, Error> {
self.log_command();
self.inner.stdin(Stdio::null());
self.inner.stdout(Stdio::piped());
self.inner.stderr(Stdio::piped());
let mut child = self.inner.spawn().map_err(|cause| Error::Exec {
summary: self.summary.clone(),
cause,
})?;
let mut stdout = child
.stdout
.take()
.expect("child stdout must be piped before spawn");
let mut stderr = child
.stderr
.take()
.expect("child stderr must be piped before spawn");
let mut stdout_done = false;
let mut stderr_done = false;
let mut stdout_buffer = [0u8; 4096];
let mut stderr_buffer = [0u8; 4096];
let mut stdout_output = Vec::new();
let mut stderr_output = Vec::new();
while !stdout_done || !stderr_done {
tokio::select! {
result = stdout.read(&mut stdout_buffer), if !stdout_done => {
match result {
Ok(0) => stdout_done = true,
Ok(n) => write_output_chunk(&mut stdout_output, &mut sink, &stdout_buffer[..n]),
Err(cause) => {
return Err(Error::Exec {
summary: self.summary.clone(),
cause,
});
}
}
}
result = stderr.read(&mut stderr_buffer), if !stderr_done => {
match result {
Ok(0) => stderr_done = true,
Ok(n) => write_output_chunk(&mut stderr_output, &mut sink, &stderr_buffer[..n]),
Err(cause) => {
return Err(Error::Exec {
summary: self.summary.clone(),
cause,
});
}
}
}
}
}
let status = child.wait().await.map_err(|cause| Error::Exec {
summary: self.summary.clone(),
cause,
})?;
let output = Output {
status,
stdout: stdout_output,
stderr: stderr_output,
};
self.maybe_check_output(&output)?;
Ok(output)
}
#[cfg(windows)]
pub(crate) async fn pty_output_with_sink<S: OutputSink>(
&mut self,
sink: S,
) -> Result<Output, Error> {
self.output_with_sink(sink).await
}
#[cfg(not(windows))]
pub(crate) async fn pty_output_with_sink<S: OutputSink>(
&mut self,
sink: S,
) -> Result<Output, Error> {
if !*crate::run::USE_COLOR {
return self.output_with_sink(sink).await;
}
self.run_on_pty(sink).await
}
#[cfg(not(windows))]
async fn run_on_pty<S: OutputSink>(&mut self, mut sink: S) -> Result<Output, Error> {
let (mut pty, pts) = prek_pty::open()?;
let (_, stdout, stderr) = pts.setup_subprocess()?;
self.inner.stdin(Stdio::null());
self.inner.stdout(stdout);
self.inner.stderr(stderr);
let mut child = self.spawn()?;
drop(pts);
let mut buffer = [0u8; 4096];
let mut output = Vec::new();
let status = loop {
tokio::select! {
read_result = pty.read(&mut buffer) => {
match read_result {
Ok(0) => break child.wait().await.map_err(|cause| Error::Exec {
summary: self.summary.clone(),
cause,
})?,
Ok(n) => write_output_chunk(&mut output, &mut sink, &buffer[..n]),
Err(err) if err.raw_os_error() == Some(libc::EIO) => {
break child.wait().await.map_err(|cause| Error::Exec {
summary: self.summary.clone(),
cause,
})?;
}
Err(err) => return Err(Error::PtySetup(err)),
}
}
status = child.wait() => {
let status = status.map_err(|cause| Error::Exec {
summary: self.summary.clone(),
cause,
})?;
loop {
match pty.try_read(&mut buffer) {
Ok(0) => break,
Ok(n) => write_output_chunk(&mut output, &mut sink, &buffer[..n]),
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => break,
Err(err) if err.raw_os_error() == Some(libc::EIO) => break,
Err(err) => return Err(Error::PtySetup(err)),
}
}
break status;
}
}
};
child.stdin.take();
child.stdout.take();
child.stderr.take();
let output = Output {
status,
stdout: output,
stderr: Vec::new(),
};
self.maybe_check_output(&output)?;
Ok(output)
}
pub async fn status(&mut self) -> Result<ExitStatus, Error> {
self.log_command();
let status = self.inner.status().await.map_err(|cause| Error::Exec {
summary: self.summary.clone(),
cause,
})?;
self.maybe_check_status(status)?;
Ok(status)
}
}
impl Cmd {
pub fn arg<S: AsRef<OsStr>>(&mut self, arg: S) -> &mut Self {
self.inner.arg(arg);
self
}
pub fn args<I, S>(&mut self, args: I) -> &mut Self
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.inner.args(args);
self
}
pub fn env<K, V>(&mut self, key: K, val: V) -> &mut Self
where
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
self.inner.env(key, val);
self
}
pub fn envs<I, K, V>(&mut self, vars: I) -> &mut Self
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
self.inner.envs(vars);
self
}
pub fn env_remove<K: AsRef<OsStr>>(&mut self, key: K) -> &mut Self {
self.inner.env_remove(key);
self
}
pub fn env_clear(&mut self) -> &mut Self {
self.inner.env_clear();
self
}
pub fn current_dir<P: AsRef<Path>>(&mut self, dir: P) -> &mut Self {
self.inner.current_dir(dir);
self
}
pub fn stdin<T: Into<Stdio>>(&mut self, cfg: T) -> &mut Self {
self.inner.stdin(cfg);
self
}
pub fn stdout<T: Into<Stdio>>(&mut self, cfg: T) -> &mut Self {
self.inner.stdout(cfg);
self
}
pub fn stderr<T: Into<Stdio>>(&mut self, cfg: T) -> &mut Self {
self.inner.stderr(cfg);
self
}
pub fn get_program(&self) -> &OsStr {
self.inner.as_std().get_program()
}
pub fn get_args(&self) -> CommandArgs<'_> {
self.inner.as_std().get_args()
}
pub fn get_envs(&self) -> CommandEnvs<'_> {
self.inner.as_std().get_envs()
}
pub fn get_current_dir(&self) -> Option<&Path> {
self.inner.as_std().get_current_dir()
}
pub fn remove_git_envs(&mut self) -> &mut Self {
for (key, _) in crate::git::GIT_ENV_TO_REMOVE.iter() {
self.inner.env_remove(key);
}
self
}
}
impl Cmd {
pub fn check_status(&self, status: ExitStatus) -> Result<(), Error> {
if status.success() {
Ok(())
} else {
Err(Error::Status {
summary: self.summary.clone(),
error: StatusError {
status,
output: None,
},
})
}
}
pub fn check_output(&self, output: &Output) -> Result<(), Error> {
if output.status.success() {
Ok(())
} else {
Err(Error::Status {
summary: self.summary.clone(),
error: StatusError {
status: output.status,
output: Some(output.clone()),
},
})
}
}
pub fn maybe_check_status(&self, status: ExitStatus) -> Result<(), Error> {
if self.check_status {
self.check_status(status)?;
}
Ok(())
}
pub fn maybe_check_output(&self, output: &Output) -> Result<(), Error> {
if self.check_status {
self.check_output(output)?;
}
Ok(())
}
pub fn log_command(&self) {
trace!("Executing `{self}`");
}
}
fn skip_args(cmd: &OsStr, cur: &OsStr, next: Option<&&OsStr>) -> usize {
if GIT.as_ref().is_ok_and(|git| cmd == git) {
if cur == "-c" {
if let Some(flag) = next {
let flag = flag.as_encoded_bytes();
if flag.starts_with(b"core.useBuiltinFSMonitor")
|| flag.starts_with(b"protocol.version")
{
return 2;
}
}
} else if cur == "--no-ext-diff"
|| cur == "--no-textconv"
|| cur == "--ignore-submodules"
|| cur == "--no-color"
{
return 1;
}
}
0
}
impl Display for Cmd {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(cwd) = self.get_current_dir() {
write!(f, "cd {} && ", cwd.to_string_lossy())?;
}
let program = self.get_program();
let mut args = self.get_args().peekable();
write!(f, "{}", program.to_string_lossy())?;
if args.peek().is_some_and(|arg| *arg == program) {
args.next(); }
let mut len = 0;
while let Some(arg) = args.next() {
let skip = skip_args(program, arg, args.peek());
if skip > 0 {
for _ in 1..skip {
args.next();
}
continue;
}
write!(f, " {}", arg.to_string_lossy())?;
len += arg.len() + 1;
if len > *LOG_TRUNCATE_LIMIT {
write!(f, " [...]")?;
break;
}
}
Ok(())
}
}
#[cfg(all(test, not(windows)))]
mod tests {
use std::sync::{Arc, Mutex};
use super::{Cmd, OutputSink};
#[derive(Default)]
struct RecordingSink {
chunks: Arc<Mutex<usize>>,
}
impl OutputSink for RecordingSink {
fn write_chunk(&mut self, _chunk: &[u8]) {
*self.chunks.lock().unwrap() += 1;
}
}
#[tokio::test]
async fn output_with_sink_streams_piped_stdout_and_stderr() {
let chunks = Arc::new(Mutex::new(0));
let output = Cmd::new("/bin/sh", "piped streaming output test")
.arg("-c")
.arg("printf 'OUT\\n'; printf 'ERR\\n' >&2")
.check(false)
.output_with_sink(RecordingSink {
chunks: Arc::clone(&chunks),
})
.await
.expect("piped command should succeed");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stdout.contains("OUT\n"));
assert!(stderr.contains("ERR\n"));
assert_ne!(*chunks.lock().unwrap(), 0);
}
#[tokio::test]
async fn pty_output_captures_trailing_output_after_fast_exit() {
for _ in 0..20 {
let output = Cmd::new("/bin/sh", "pty trailing output test")
.arg("-c")
.arg("printf 'FINAL\\n'")
.check(false)
.run_on_pty(RecordingSink::default())
.await
.expect("pty command should succeed");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout).replace("\r\n", "\n");
assert_eq!(stdout, "FINAL\n");
assert!(output.stderr.is_empty());
}
}
}