use std::ffi::OsStr;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
#[derive(Clone)]
pub struct Git {
dir: PathBuf,
hooks: bool,
}
impl Git {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Git {
dir: dir.into(),
hooks: false,
}
}
pub fn in_code_worktree(dir: impl Into<PathBuf>) -> Self {
Git {
dir: dir.into(),
hooks: true,
}
}
fn command(&self) -> Command {
let mut command = Command::new("git");
command.current_dir(&self.dir);
if self.hooks {
crate::gitenv::apply(&mut command);
} else {
crate::gitenv::apply_hooks_off(&mut command);
}
command
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn output<I, S>(&self, args: I) -> Result<(Vec<String>, Output), String>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let args: Vec<String> = args
.into_iter()
.map(|a| a.as_ref().to_string_lossy().into_owned())
.collect();
let mut command = self.command();
command.args(&args);
let output = command
.output()
.map_err(|e| format!("error: could not run git: {}", e))?;
Ok((args, output))
}
pub fn run<I, S>(&self, args: I) -> Result<String, String>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let (args, output) = self.output(args)?;
if !output.status.success() {
return Err(failure_message(&args, &output));
}
String::from_utf8(output.stdout).map_err(|_| {
format!(
"error: git {} printed output that is not UTF-8",
args.join(" ")
)
})
}
pub fn run_stdin<I, S>(&self, args: I, input: &str) -> Result<String, String>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let args: Vec<String> = args
.into_iter()
.map(|a| a.as_ref().to_string_lossy().into_owned())
.collect();
let mut command = self.command();
command.args(&args);
let mut child = command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("error: could not run git: {}", e))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(input.as_bytes())
.map_err(|e| format!("error: could not write to git {}: {}", args.join(" "), e))?;
}
let output = child
.wait_with_output()
.map_err(|e| format!("error: could not run git: {}", e))?;
if !output.status.success() {
return Err(failure_message(&args, &output));
}
Ok(String::from_utf8_lossy(&output.stdout)
.trim_end_matches(['\n', '\r'])
.to_string())
}
pub fn run_line<I, S>(&self, args: I) -> Result<String, String>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
Ok(self.run(args)?.trim_end_matches(['\n', '\r']).to_string())
}
}
pub fn failure_message(args: &[String], output: &Output) -> String {
let mut stderr = String::from_utf8_lossy(&output.stdout).trim().to_string();
let err = String::from_utf8_lossy(&output.stderr);
let err = err.trim();
if !stderr.is_empty() && !err.is_empty() {
stderr.push('\n');
}
stderr.push_str(err);
let stderr = stderr.as_str();
let status = match output.status.code() {
Some(code) => format!("exit status {}", code),
None => "terminated by a signal".to_string(),
};
if stderr.is_empty() {
format!("error: git {} failed ({})", args.join(" "), status)
} else {
format!(
"error: git {} failed ({}):\n{}",
args.join(" "),
status,
stderr
)
}
}
pub fn split_nul(text: &str) -> Vec<&str> {
text.split('\0').filter(|s| !s.is_empty()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_nul_drops_empty_records() {
assert_eq!(split_nul("a\0b c\0\0d\0"), vec!["a", "b c", "d"]);
assert!(split_nul("").is_empty());
}
#[test]
fn run_reports_failure_with_args_and_stderr() {
let dir = tempfile::tempdir().unwrap();
let git = Git::new(dir.path());
let err = git
.run(["rev-parse", "--verify", "refs/heads/no-such-ref"])
.unwrap_err();
assert!(
err.contains("git rev-parse --verify refs/heads/no-such-ref failed"),
"{}",
err
);
}
#[test]
fn hooks_are_off_unless_the_helper_is_for_the_code_worktree() {
let dir = tempfile::tempdir().unwrap();
let git = Git::new(dir.path());
git.run(["init", "-q", "-b", "main"]).unwrap();
let hooks = dir.path().join(".git/hooks");
let marker = dir.path().join("marker");
let hook = hooks.join("pre-commit");
std::fs::write(
&hook,
format!("#!/bin/sh\ntouch {}\nexit 1\n", marker.display()),
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
let commit = ["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q"];
let mut args = commit.to_vec();
args.extend(["--allow-empty", "-m", "x"]);
git.run(args.clone()).unwrap();
assert!(!marker.exists());
assert!(Git::in_code_worktree(dir.path()).run(args).is_err());
assert!(marker.exists());
}
#[test]
fn run_line_strips_trailing_newline() {
let dir = tempfile::tempdir().unwrap();
let git = Git::new(dir.path());
let version = git.run_line(["--version"]).unwrap();
assert!(version.starts_with("git version"));
assert!(!version.ends_with('\n'));
}
}