use std::fs::File;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail};
pub struct Git {
dir: PathBuf,
}
impl Git {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Git { dir: dir.into() }
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn run(&self, args: &[&str]) -> Result<String> {
let deadline = Instant::now() + LOCK_WAIT;
let mut output = self.output(args)?;
while !output.status.success() && held_lock(&output.stderr) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(100));
output = self.output(args)?;
}
if !output.status.success() {
let tail: Vec<String> = [&output.stderr, &output.stdout]
.into_iter()
.flat_map(|stream| last_lines(stream))
.collect();
let mut message = format!("git {} failed", args.join(" "));
if !tail.is_empty() {
message = format!("{message}: {}", tail.join("\n"));
}
bail!(message);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn common_dir(&self) -> Result<PathBuf> {
Ok(self.dir.join(self.run(&["rev-parse", "--git-common-dir"])?))
}
pub fn lock(&self, name: &str) -> Result<File> {
let path = self.common_dir()?.join(name);
let file = File::create(&path).with_context(|| format!("can't open {}", path.display()))?;
file.lock()
.with_context(|| format!("can't lock {}", path.display()))?;
Ok(file)
}
pub fn succeeds(&self, args: &[&str]) -> Result<bool> {
Ok(self.output(args)?.status.success())
}
fn output(&self, args: &[&str]) -> Result<Output> {
Command::new("git")
.args(args)
.current_dir(&self.dir)
.output()
.with_context(|| format!("could not run git {}", args.join(" ")))
}
}
const LOCK_WAIT: Duration = Duration::from_secs(10);
fn held_lock(stderr: &[u8]) -> bool {
let stderr = String::from_utf8_lossy(stderr);
stderr.contains(".lock': File exists")
|| stderr.contains("could not lock config file")
|| (stderr.contains("cannot lock ref") && stderr.contains("but expected"))
}
const MAX_LINES: usize = 10;
fn last_lines(stream: &[u8]) -> Vec<String> {
let text = String::from_utf8_lossy(stream);
let lines: Vec<&str> = text
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect();
lines[lines.len().saturating_sub(MAX_LINES)..]
.iter()
.map(|line| line.to_string())
.collect()
}
#[cfg(test)]
mod tests {
use std::os::unix::fs::PermissionsExt;
use super::*;
fn repo_with_origin() -> (tempfile::TempDir, Git) {
let temp = tempfile::TempDir::new().unwrap();
let isolated = |dir: &Path, args: &[&str]| {
let status = Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.status()
.unwrap();
assert!(status.success(), "git {args:?}");
};
isolated(temp.path(), &["init", "-q", "--bare", "origin.git"]);
isolated(temp.path(), &["init", "-q", "-b", "main", "work"]);
let work = temp.path().join("work");
for (key, value) in [
("user.name", "Test Runner"),
("user.email", "runner@example.com"),
("remote.origin.url", "../origin.git"),
] {
isolated(&work, &["config", key, value]);
}
isolated(&work, &["commit", "-q", "--allow-empty", "-m", "Initial"]);
(temp, Git::new(work))
}
#[test]
fn a_failure_includes_a_hooks_output_and_gits_own_error() {
let (_temp, git) = repo_with_origin();
let hook = git.dir().join(".git/hooks/pre-push");
std::fs::write(&hook, "#!/bin/sh\necho 'hook says no'\nexit 1\n").unwrap();
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
let error = git
.run(&["push", "origin", "main"])
.unwrap_err()
.to_string();
assert!(error.contains("hook says no"), "{error}");
assert!(error.contains("failed to push some refs"), "{error}");
}
#[test]
fn a_failure_includes_stdout() {
let (_temp, git) = repo_with_origin();
let error = git
.run(&["-c", "alias.fail=!echo $((6 * 7)); exit 1", "fail"])
.unwrap_err()
.to_string();
assert!(error.ends_with(": 42"), "{error}");
}
#[test]
fn a_failure_keeps_only_the_last_lines_of_a_stream() {
let (_temp, git) = repo_with_origin();
let error = git
.run(&["-c", "alias.fail=!seq 1 100; exit 1", "fail"])
.unwrap_err()
.to_string();
assert!(error.contains("\n100"), "{error}");
assert!(!error.contains("\n50\n"), "{error}");
}
#[test]
fn a_lock_another_git_holds_is_waited_for() {
let commands: [(&str, &[&str]); 2] = [
(".git/config.lock", &["config", "thirdshift.test", "yes"]),
(
".git/refs/heads/other.lock",
&["update-ref", "refs/heads/other", "HEAD"],
),
];
for (lock, args) in commands {
let (_temp, git) = repo_with_origin();
let lock = git.dir().join(lock);
std::fs::write(&lock, "").unwrap();
let released = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(300));
std::fs::remove_file(lock).unwrap();
});
let result = git.run(args);
released.join().unwrap();
result.unwrap();
}
}
#[test]
fn a_ref_another_git_moved_meanwhile_is_tried_again() {
let (temp, git) = repo_with_origin();
let moved = temp.path().join("moved");
let alias = format!(
"alias.race=!test -f {0} && exit 0; touch {0}; \
echo \"error: cannot lock ref 'refs/remotes/origin/main': is at 1 but expected 2\" >&2; \
exit 1",
moved.display()
);
git.run(&["-c", &alias, "race"]).unwrap();
}
#[test]
fn a_failure_with_nothing_on_either_stream_says_only_that_git_failed() {
let (_temp, git) = repo_with_origin();
let error = git
.run(&["-c", "alias.fail=!exit 1", "fail"])
.unwrap_err()
.to_string();
assert_eq!(error, "git -c alias.fail=!exit 1 fail failed");
}
}