use std::ffi::OsStr;
use std::fmt::Display;
use std::io::PipeReader;
use std::ops::Range;
use std::path::Path;
use std::process::Output;
use std::process::{CommandArgs, CommandEnvs, ExitStatus, Stdio};
use owo_colors::OwoColorize;
use thiserror::Error;
use tokio::io::AsyncReadExt;
use tracing::{enabled, trace};
use crate::run::HookRunOutput;
#[cfg(not(windows))]
use crate::run::USE_COLOR;
#[derive(Debug, Error)]
pub enum Error {
#[error("Failed to run `{command}`")]
Exec {
command: String,
#[source]
cause: std::io::Error,
},
#[error("Command `{command}` exited with an error:\n{error}")]
Status { command: 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,
hidden_arg_ranges: Vec<Range<usize>>,
file_arg_boundary: usize,
check_status: bool,
}
pub(crate) trait OutputSink {
fn write_chunk(&mut self, chunk: &[u8]);
}
#[derive(Debug)]
pub(crate) struct MergedOutput {
status: ExitStatus,
bytes: Vec<u8>,
}
impl From<MergedOutput> for HookRunOutput {
fn from(output: MergedOutput) -> Self {
Self::new(output.status.code().unwrap_or(1), output.bytes)
}
}
fn write_output_chunk(output: &mut Vec<u8>, sink: &mut impl OutputSink, chunk: &[u8]) {
output.extend_from_slice(chunk);
sink.write_chunk(chunk);
}
struct AsyncPipeReader {
#[cfg(unix)]
inner: tokio::net::unix::pipe::Receiver,
#[cfg(windows)]
inner: tokio::fs::File,
}
impl AsyncPipeReader {
#[cfg(unix)]
fn new(reader: PipeReader) -> std::io::Result<Self> {
use std::os::fd::OwnedFd;
let inner = tokio::net::unix::pipe::Receiver::from_owned_fd(OwnedFd::from(reader))?;
Ok(Self { inner })
}
#[cfg(windows)]
#[expect(
clippy::unnecessary_wraps,
reason = "keep the constructor fallible on every platform"
)]
fn new(reader: PipeReader) -> std::io::Result<Self> {
use std::os::windows::io::OwnedHandle;
let file = std::fs::File::from(OwnedHandle::from(reader));
Ok(Self {
inner: tokio::fs::File::from_std(file),
})
}
async fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
self.inner.read(buffer).await
}
}
impl Cmd {
pub fn new(command: impl AsRef<OsStr>) -> Self {
let inner = tokio::process::Command::new(command);
Self {
inner,
hidden_arg_ranges: Vec::new(),
file_arg_boundary: usize::MAX,
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| self.exec_error(cause))
}
pub async fn output(&mut self) -> Result<Output, Error> {
self.log_command();
let output = self
.inner
.output()
.await
.map_err(|cause| self.exec_error(cause))?;
self.maybe_check_output(output)
}
pub(crate) async fn output_with_sink<S: OutputSink>(
&mut self,
mut sink: S,
) -> Result<MergedOutput, Error> {
self.log_command();
let (mut child, mut reader) = self.spawn_with_output()?;
let mut buffer = [0u8; 4096];
let mut bytes = Vec::new();
loop {
let n = reader
.read(&mut buffer)
.await
.map_err(|cause| self.exec_error(cause))?;
if n == 0 {
break;
}
write_output_chunk(&mut bytes, &mut sink, &buffer[..n]);
}
let status = child.wait().await.map_err(|cause| self.exec_error(cause))?;
self.maybe_check_captured_output(MergedOutput { status, bytes })
}
fn spawn_with_output(&mut self) -> Result<(tokio::process::Child, AsyncPipeReader), Error> {
let (reader, writer) = std::io::pipe().map_err(|cause| self.exec_error(cause))?;
let reader = AsyncPipeReader::new(reader).map_err(|cause| self.exec_error(cause))?;
let stdout = writer.try_clone().map_err(|cause| self.exec_error(cause))?;
self.inner.stdin(Stdio::null());
self.inner.stdout(stdout);
self.inner.stderr(writer);
let child = self.inner.spawn();
self.inner.stdout(Stdio::piped());
self.inner.stderr(Stdio::piped());
let child = child.map_err(|cause| self.exec_error(cause))?;
Ok((child, reader))
}
#[cfg(windows)]
pub(crate) async fn pty_output_with_sink<S: OutputSink>(
&mut self,
sink: S,
) -> Result<MergedOutput, 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<MergedOutput, Error> {
if !*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<MergedOutput, 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 bytes = 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| self.exec_error(cause))?,
Ok(n) => write_output_chunk(&mut bytes, &mut sink, &buffer[..n]),
Err(err) if err.raw_os_error() == Some(libc::EIO) => {
break child.wait().await.map_err(|cause| self.exec_error(cause))?;
}
Err(err) => return Err(Error::PtySetup(err)),
}
}
status = child.wait() => {
let status = status.map_err(|cause| self.exec_error(cause))?;
loop {
match pty.try_read(&mut buffer) {
Ok(0) => break,
Ok(n) => write_output_chunk(&mut bytes, &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();
self.maybe_check_captured_output(MergedOutput { status, bytes })
}
pub async fn status(&mut self) -> Result<ExitStatus, Error> {
self.log_command();
let status = self
.inner
.status()
.await
.map_err(|cause| self.exec_error(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 hidden_args<I, S>(&mut self, args: I) -> &mut Self
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let start = self.get_args().count();
self.inner.args(args);
let end = self.get_args().count();
if start < end {
if let Some(last) = self.hidden_arg_ranges.last_mut()
&& last.end >= start
{
last.end = last.end.max(end);
return self;
}
self.hidden_arg_ranges.push(start..end);
}
self
}
pub fn file_args<I, S>(&mut self, args: I) -> &mut Self
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
if self.file_arg_boundary != usize::MAX {
self.inner.args(args);
return self;
}
let mut args = args.into_iter().peekable();
if args.peek().is_none() {
return self;
}
let start = self.get_args().count();
self.inner.args(args);
self.file_arg_boundary = start;
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()
}
}
impl Cmd {
fn exec_error(&self, cause: std::io::Error) -> Error {
let mut command = String::new();
let _ = write_command_line(&mut command, None, self.get_program(), self.display_args());
Error::Exec { command, cause }
}
fn status_error(&self, status: ExitStatus, output: Option<Output>) -> Error {
let mut command = String::new();
let _ = write_command_line(&mut command, None, self.get_program(), self.display_args());
Error::Status {
command,
error: StatusError { status, output },
}
}
pub fn check_status(&self, status: ExitStatus) -> Result<(), Error> {
if status.success() {
Ok(())
} else {
Err(self.status_error(status, None))
}
}
pub fn check_output(&self, output: Output) -> Result<Output, Error> {
if output.status.success() {
Ok(output)
} else {
Err(self.status_error(output.status, Some(output)))
}
}
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<Output, Error> {
if self.check_status {
self.check_output(output)
} else {
Ok(output)
}
}
fn maybe_check_captured_output(&self, output: MergedOutput) -> Result<MergedOutput, Error> {
if self.check_status && !output.status.success() {
let status = output.status;
let output = Output {
status,
stdout: output.bytes,
stderr: Vec::new(),
};
Err(self.status_error(status, Some(output)))
} else {
Ok(output)
}
}
pub fn log_command(&self) {
if !enabled!(tracing::Level::TRACE) {
return;
}
let mut command = String::new();
let _ = write_command_line(
&mut command,
self.get_current_dir(),
self.get_program(),
self.non_file_args(),
);
trace!("Executing `{command}`");
}
fn display_args(&self) -> impl Iterator<Item = &OsStr> {
self.non_file_args().enumerate().filter_map(|(index, arg)| {
if self.is_hidden_arg(index) {
None
} else {
Some(arg)
}
})
}
fn is_hidden_arg(&self, index: usize) -> bool {
self.hidden_arg_ranges
.iter()
.any(|range| range.contains(&index))
}
fn non_file_args(&self) -> impl Iterator<Item = &OsStr> {
self.get_args().take(self.file_arg_boundary)
}
}
impl Display for Cmd {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write_command_line(
f,
self.get_current_dir(),
self.get_program(),
self.display_args(),
)
}
}
fn write_command_line<'a>(
f: &mut impl std::fmt::Write,
cwd: Option<&Path>,
program: &OsStr,
args: impl IntoIterator<Item = &'a OsStr>,
) -> std::fmt::Result {
if let Some(cwd) = cwd {
write!(f, "cd {} && ", cwd.to_string_lossy())?;
}
let program_display = program.to_string_lossy();
write!(f, "{program_display}")?;
let mut args = args.into_iter().peekable();
if args.peek().is_some_and(|arg| *arg == program) {
args.next(); }
for arg in args {
write!(f, " {}", arg.to_string_lossy())?;
}
Ok(())
}
#[cfg(all(test, not(windows)))]
mod tests {
use std::error::Error as _;
use std::sync::{Arc, Mutex};
use super::{Cmd, OutputSink, write_command_line};
#[derive(Default)]
struct RecordingSink {
chunks: Arc<Mutex<usize>>,
}
impl OutputSink for RecordingSink {
fn write_chunk(&mut self, _chunk: &[u8]) {
*self.chunks.lock().unwrap() += 1;
}
}
fn command_log_string(cmd: &Cmd) -> String {
let mut command = String::new();
let _ = write_command_line(
&mut command,
cmd.get_current_dir(),
cmd.get_program(),
cmd.non_file_args(),
);
command
}
#[tokio::test]
async fn status_reports_missing_executable_name() {
let err = Cmd::new("__prek_missing_command__")
.status()
.await
.expect_err("command should not exist");
assert_eq!(err.to_string(), "Failed to run `__prek_missing_command__`");
let source = err.source().expect("missing executable error has source");
let io_error = source
.downcast_ref::<std::io::Error>()
.expect("source is an io error");
assert_eq!(io_error.kind(), std::io::ErrorKind::NotFound);
}
#[test]
fn display_and_log_commands_omit_file_args() {
let mut cmd = Cmd::new("prek");
cmd.arg("run")
.arg("hook-id")
.file_args(["file-0.rs"])
.file_args(["file-1.rs"]);
assert_eq!(cmd.to_string(), "prek run hook-id");
assert_eq!(command_log_string(&cmd), "prek run hook-id");
assert_eq!(
cmd.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>(),
["run", "hook-id", "file-0.rs", "file-1.rs"]
);
assert!(cmd.hidden_arg_ranges.is_empty());
assert_eq!(cmd.file_arg_boundary, 2);
}
#[test]
fn display_command_omits_hidden_args() {
let mut cmd = Cmd::new("git");
cmd.hidden_args(["-c", "core.useBuiltinFSMonitor=false"])
.arg("diff")
.arg("--name-only")
.hidden_args(["--no-ext-diff", "--ignore-submodules"])
.arg("HEAD");
assert_eq!(cmd.to_string(), "git diff --name-only HEAD");
assert_eq!(
cmd.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>(),
[
"-c",
"core.useBuiltinFSMonitor=false",
"diff",
"--name-only",
"--no-ext-diff",
"--ignore-submodules",
"HEAD"
]
);
}
#[test]
fn command_log_includes_hidden_args() {
let mut cmd = Cmd::new("git");
cmd.hidden_args(["-c", "core.useBuiltinFSMonitor=false"])
.arg("diff")
.arg("--name-only")
.hidden_args(["--no-ext-diff", "--ignore-submodules"])
.arg("HEAD");
assert_eq!(cmd.to_string(), "git diff --name-only HEAD");
assert_eq!(
command_log_string(&cmd),
"git -c core.useBuiltinFSMonitor=false diff --name-only --no-ext-diff --ignore-submodules HEAD"
);
}
#[test]
fn command_log_skips_repeated_program_arg() {
let mut cmd = Cmd::new("python");
cmd.arg("python").arg("-m").arg("module");
assert_eq!(cmd.to_string(), "python -m module");
assert_eq!(command_log_string(&cmd), "python -m module");
}
#[test]
fn hidden_args_merges_adjacent_ranges() {
let mut cmd = Cmd::new("uv");
cmd.arg("venv")
.arg("/tmp/python-abc")
.hidden_args(["--python-preference", "managed"])
.hidden_args(["--no-project"])
.hidden_args(["--project", "/"]);
assert_eq!(cmd.to_string(), "uv venv /tmp/python-abc");
assert_eq!(cmd.hidden_arg_ranges, vec![2..7]);
assert_eq!(cmd.file_arg_boundary, usize::MAX);
}
#[tokio::test]
async fn pty_output_captures_trailing_output_after_fast_exit() {
for _ in 0..20 {
let output = Cmd::new("/bin/sh")
.arg("-c")
.arg("printf 'FINAL\\n'")
.check(false)
.run_on_pty(RecordingSink::default())
.await
.expect("pty command should succeed");
assert!(output.status.success());
let output = String::from_utf8_lossy(&output.bytes).replace("\r\n", "\n");
assert_eq!(output, "FINAL\n");
}
}
}