use std::path::Path;
use std::process::Command;
use crate::error::{Error, Result};
#[derive(Debug, Clone)]
pub struct GitOutput {
pub success: bool,
pub stdout: String,
pub stderr: String,
}
pub trait GitCli {
fn run_raw(&self, repo: &Path, args: &[&str]) -> Result<GitOutput>;
fn run(&self, repo: &Path, args: &[&str]) -> Result<String> {
let out = self.run_raw(repo, args)?;
if out.success {
return Ok(out.stdout);
}
let mut stderr = out.stderr.trim_end().to_string();
if stderr.contains(".lock") {
stderr.push_str("\n(hint: another git process holds the worktree lock)");
}
Err(Error::Subprocess {
program: "git".into(),
stderr,
})
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RealGit;
impl GitCli for RealGit {
fn run_raw(&self, repo: &Path, args: &[&str]) -> Result<GitOutput> {
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.map_err(|e| Error::Subprocess {
program: "git".into(),
stderr: format!("failed to run git: {e}"),
})?;
Ok(GitOutput {
success: output.status.success(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Canned(GitOutput);
impl GitCli for Canned {
fn run_raw(&self, _repo: &Path, _args: &[&str]) -> Result<GitOutput> {
Ok(self.0.clone())
}
}
#[test]
fn run_returns_stdout_on_success() {
let git = Canned(GitOutput {
success: true,
stdout: "ok\n".into(),
stderr: String::new(),
});
assert_eq!(git.run(Path::new("/r"), &["status"]).unwrap(), "ok\n");
}
#[test]
fn run_surfaces_stderr_on_failure() {
let git = Canned(GitOutput {
success: false,
stdout: String::new(),
stderr: "fatal: nope\n".into(),
});
let err = git.run(Path::new("/r"), &["x"]).unwrap_err();
match err {
Error::Subprocess { program, stderr } => {
assert_eq!(program, "git");
assert_eq!(stderr, "fatal: nope");
}
other => panic!("expected subprocess error, got {other:?}"),
}
}
#[test]
fn run_annotates_lock_contention() {
let git = Canned(GitOutput {
success: false,
stdout: String::new(),
stderr: "fatal: could not lock .git/worktrees/x/HEAD.lock".into(),
});
let err = git.run(Path::new("/r"), &["worktree", "add"]).unwrap_err();
assert!(
err.to_string()
.contains("another git process holds the worktree lock")
);
}
#[test]
fn real_git_runs_version() {
let out = RealGit.run_raw(Path::new("."), &["--version"]).unwrap();
assert!(out.success);
assert!(out.stdout.contains("git version"));
}
}