use std::ffi::{OsStr, OsString};
use std::io;
use std::process::{Command, ExitStatus, Stdio};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum RunError {
#[error("git isn't installed or isn't on PATH")]
NotFound,
#[error("couldn't run git: {0}")]
Spawn(io::Error),
#[error("`git {command}` failed (exit code {code}): {stderr}")]
Failed {
command: String,
code: i32,
stderr: String,
},
#[error("`git {0}` printed output that isn't valid UTF-8")]
NotUtf8(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Captured {
pub code: i32,
pub stdout: String,
pub stderr: String,
}
#[derive(Debug, Clone)]
pub struct Git {
program: OsString,
global_options: Vec<OsString>,
env: Vec<(OsString, OsString)>,
}
impl Git {
pub fn new(global_options: &[OsString]) -> Self {
Self {
program: "git".into(),
global_options: global_options.to_vec(),
env: Vec::new(),
}
}
pub fn env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
self.env.push((key.into(), value.into()));
self
}
pub fn command<I, S>(&self, args: I) -> Command
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let mut command = Command::new(&self.program);
command
.args(&self.global_options)
.args(args)
.envs(self.env.iter().map(|(k, v)| (k, v)));
command
}
pub fn capture(&self, args: &[&str]) -> Result<Captured, RunError> {
let output = self
.command(args)
.stdin(Stdio::null())
.output()
.map_err(spawn_error)?;
let describe = || args.join(" ");
let stdout = String::from_utf8(output.stdout).map_err(|_| RunError::NotUtf8(describe()))?;
Ok(Captured {
code: exit_code(output.status),
stdout: stdout.trim_end_matches(['\n', '\r']).to_owned(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
})
}
pub fn exec(&self, args: &[OsString]) -> RunError {
let mut command = self.command(args);
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
spawn_error(command.exec())
}
#[cfg(not(unix))]
{
match command.status() {
Ok(status) => std::process::exit(exit_code(status)),
Err(e) => spawn_error(e),
}
}
}
}
pub fn exit_code(status: ExitStatus) -> i32 {
if let Some(code) = status.code() {
return code;
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(signal) = status.signal() {
return 128 + signal;
}
}
1
}
pub(crate) fn spawn_error(e: io::Error) -> RunError {
if e.kind() == io::ErrorKind::NotFound {
RunError::NotFound
} else {
RunError::Spawn(e)
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
fn status_of(script: &str) -> ExitStatus {
Command::new("sh").args(["-c", script]).status().unwrap()
}
#[test]
fn exit_codes_pass_through() {
assert_eq!(exit_code(status_of("exit 0")), 0);
assert_eq!(exit_code(status_of("exit 42")), 42);
assert_eq!(exit_code(status_of("exit 128")), 128);
}
#[test]
fn signals_become_128_plus_the_signal() {
assert_eq!(exit_code(status_of("kill -TERM $$")), 128 + 15);
assert_eq!(exit_code(status_of("kill -KILL $$")), 128 + 9);
}
#[test]
fn capture_returns_output_and_code() {
let git = Git::new(&[]);
let version = git.capture(&["--version"]).unwrap();
assert_eq!(version.code, 0);
assert!(version.stdout.starts_with("git version"));
assert!(!version.stdout.ends_with('\n'));
let bad = git.capture(&["not-a-git-command"]).unwrap();
assert_ne!(bad.code, 0);
assert!(!bad.stderr.is_empty());
}
#[test]
fn global_options_are_applied() {
let dir = tempfile::TempDir::new().unwrap();
let git = Git::new(&["-C".into(), dir.path().into()]);
let top = git
.capture(&["rev-parse", "--is-inside-work-tree"])
.unwrap();
assert_ne!(top.code, 0, "an empty temp dir isn't a repository");
}
#[test]
fn missing_git_is_reported_clearly() {
let mut git = Git::new(&[]);
git.program = "gitaway-test-no-such-program".into();
assert!(matches!(
git.capture(&["--version"]),
Err(RunError::NotFound)
));
assert!(matches!(git.exec(&[]), RunError::NotFound));
}
}