use std::ffi::OsStr;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use anyhow::{Context, Result};
const HARDENING: [&str; 4] = [
"-c",
"core.hooksPath=/dev/null",
"-c",
"protocol.ext.allow=never",
];
const AUTHOR_NAME: &str = "Mermaid";
const AUTHOR_EMAIL: &str = "mermaid@localhost";
pub struct GitCommand {
cmd: Command,
display: Vec<String>,
cwd: Option<std::path::PathBuf>,
stdin: Option<Vec<u8>>,
}
impl GitCommand {
pub fn new() -> Self {
let mut cmd = Command::new("git");
cmd.args(HARDENING)
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_AUTHOR_NAME", AUTHOR_NAME)
.env("GIT_AUTHOR_EMAIL", AUTHOR_EMAIL)
.env("GIT_COMMITTER_NAME", AUTHOR_NAME)
.env("GIT_COMMITTER_EMAIL", AUTHOR_EMAIL);
Self {
cmd,
display: Vec::new(),
cwd: None,
stdin: None,
}
}
pub fn cwd(mut self, dir: &Path) -> Self {
self.cmd.current_dir(dir);
self.cwd = Some(dir.to_path_buf());
self
}
pub fn arg<S: AsRef<OsStr>>(mut self, arg: S) -> Self {
let arg = arg.as_ref();
self.display.push(arg.to_string_lossy().into_owned());
self.cmd.arg(arg);
self
}
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
for arg in args {
self = self.arg(arg);
}
self
}
pub fn stdin_bytes(mut self, data: Vec<u8>) -> Self {
self.stdin = Some(data);
self
}
pub fn run(self) -> Result<()> {
let display = self.display.join(" ");
let (ok, _, stderr) = self.capture()?;
anyhow::ensure!(ok, "git {display} failed: {}", stderr.trim());
Ok(())
}
pub fn success(self) -> Result<bool> {
let (ok, _, _) = self.capture()?;
Ok(ok)
}
pub fn output(self) -> Result<String> {
let raw = self.output_bytes()?;
Ok(String::from_utf8_lossy(&raw).trim().to_string())
}
pub fn output_bytes(self) -> Result<Vec<u8>> {
let display = self.display.join(" ");
let (ok, stdout, stderr) = self.capture()?;
anyhow::ensure!(ok, "git {display} failed: {}", stderr.trim());
Ok(stdout)
}
fn capture(mut self) -> Result<(bool, Vec<u8>, String)> {
self.cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
self.cmd.stdin(if self.stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
});
let display = self.display.join(" ");
let where_ = match &self.cwd {
Some(dir) => format!(" in {}", dir.display()),
None => String::new(),
};
let mut child = self.cmd.spawn().with_context(|| {
format!(
"failed to run git {display}{where_} (missing directory, or git not installed?)"
)
})?;
if let Some(data) = self.stdin.take() {
let mut pipe = child
.stdin
.take()
.context("git stdin pipe missing after spawn")?;
let _ = pipe.write_all(&data);
drop(pipe);
}
let out = child
.wait_with_output()
.with_context(|| format!("git {display} was not reapable"))?;
Ok((
out.status.success(),
out.stdout,
String::from_utf8_lossy(&out.stderr).into_owned(),
))
}
}
impl Default for GitCommand {
fn default() -> Self {
Self::new()
}
}
pub fn git(dir: &Path) -> GitCommand {
GitCommand::new().cwd(dir)
}
pub fn is_work_tree(dir: &Path) -> bool {
git(dir)
.args(["rev-parse", "--is-inside-work-tree"])
.output()
.is_ok_and(|out| out == "true")
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn unique_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("mermaid_git_{tag}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).unwrap().replace("\r\n", "\n")
}
fn init_repo(dir: &Path) -> bool {
if git(dir).args(["init", "-q"]).run().is_err() {
return false;
}
std::fs::write(dir.join("seed.txt"), "seed\n").unwrap();
git(dir).args(["add", "-A"]).run().unwrap();
git(dir).args(["commit", "-qm", "seed"]).run().unwrap();
true
}
#[test]
fn commits_without_a_configured_user_identity() {
let repo = unique_dir("identity");
if !init_repo(&repo) {
return;
}
let author = git(&repo)
.args(["log", "-1", "--format=%an <%ae>"])
.output()
.unwrap();
assert_eq!(author, format!("{AUTHOR_NAME} <{AUTHOR_EMAIL}>"));
}
#[test]
fn success_reports_predicate_exits_without_erroring() {
let repo = unique_dir("predicate");
if !init_repo(&repo) {
return;
}
assert!(git(&repo).args(["diff", "--quiet"]).success().unwrap());
std::fs::write(repo.join("seed.txt"), "changed\n").unwrap();
assert!(!git(&repo).args(["diff", "--quiet"]).success().unwrap());
}
#[test]
fn stdin_feeds_a_patch_to_git_apply() {
let repo = unique_dir("stdin");
if !init_repo(&repo) {
return;
}
std::fs::write(repo.join("seed.txt"), "changed\n").unwrap();
let patch = git(&repo)
.args(["diff", "--binary"])
.output_bytes()
.unwrap();
assert!(!patch.is_empty());
git(&repo)
.args(["checkout", "--", "seed.txt"])
.run()
.unwrap();
assert_eq!(read(&repo.join("seed.txt")), "seed\n");
git(&repo).args(["apply"]).stdin_bytes(patch).run().unwrap();
assert_eq!(read(&repo.join("seed.txt")), "changed\n");
}
#[test]
fn failure_surfaces_the_failing_command_in_the_error() {
let repo = unique_dir("failure");
if !init_repo(&repo) {
return;
}
let err = git(&repo)
.args(["rev-parse", "--verify", "definitely-not-a-ref"])
.output()
.unwrap_err()
.to_string();
assert!(err.contains("rev-parse"), "{err}");
}
#[test]
fn is_work_tree_distinguishes_a_repo_from_a_plain_directory() {
let repo = unique_dir("worktree_yes");
if !init_repo(&repo) {
return;
}
assert!(is_work_tree(&repo));
assert!(!is_work_tree(&unique_dir("worktree_no")));
}
}