use std::path::{Path, PathBuf};
use std::process::Command;
use std::str::FromStr;
use crate::util::{wrap_git_error, GitExecutable, GitVersion};
use anyhow::Context;
use fn_error_context::context;
const DUMMY_NAME: &str = "Testy McTestface";
const DUMMY_EMAIL: &str = "test@example.com";
const DUMMY_DATE: &str = "Wed 29 Oct 12:34:56 2020 PDT";
#[derive(Debug)]
pub struct Git<'a> {
pub repo_path: &'a Path,
pub git_executable: &'a Path,
}
#[derive(Debug)]
pub struct GitInitOptions {
pub make_initial_commit: bool,
}
impl Default for GitInitOptions {
fn default() -> Self {
GitInitOptions {
make_initial_commit: true,
}
}
}
#[derive(Debug)]
pub struct GitRunOptions {
pub time: isize,
pub expected_exit_code: i32,
pub use_system_git: bool,
}
impl Default for GitRunOptions {
fn default() -> Self {
GitRunOptions {
time: 0,
expected_exit_code: 0,
use_system_git: false,
}
}
}
impl<'a> Git<'a> {
pub fn new(repo_path: &'a Path, git_executable: &'a GitExecutable) -> Self {
let GitExecutable(git_executable) = git_executable;
Git {
repo_path,
git_executable,
}
}
pub fn preprocess_stdout(&self, stdout: String) -> anyhow::Result<String> {
let git_executable = self
.git_executable
.to_str()
.ok_or_else(|| anyhow::anyhow!("Could not convert Git executable to string"))?;
let stdout = stdout.replace(git_executable, "<git-executable>");
Ok(stdout)
}
#[context("Running Git command with args: {:?} and options: {:?}", args, options)]
pub fn run_with_options<S: AsRef<str> + std::fmt::Debug>(
&self,
args: &[S],
options: &GitRunOptions,
) -> anyhow::Result<(String, String)> {
let GitRunOptions {
use_system_git,
time,
expected_exit_code,
} = options;
let git_executable = if !use_system_git {
self.git_executable
} else {
Path::new("/usr/bin/git")
};
let date = format!("{date} -{time:0>2}", date = DUMMY_DATE, time = time);
let args: Vec<&str> = {
let repo_path = self.repo_path.to_str().expect("Could not decode repo path");
let mut new_args: Vec<&str> = vec!["-C", repo_path];
new_args.extend(args.iter().map(|arg| arg.as_ref()));
new_args
};
let cargo_bin_path = assert_cmd::cargo::cargo_bin("git-branchless");
let branchless_path = cargo_bin_path
.parent()
.expect("Unable to find git-branchless path parent");
let git_path = self
.git_executable
.parent()
.expect("Unable to find git path parent");
let new_path = vec![
branchless_path,
git_path,
]
.iter()
.map(|path| path.to_str().expect("Unable to decode path component"))
.collect::<Vec<_>>()
.join(":");
let env: Vec<(&str, &str)> = vec![
("GIT_AUTHOR_DATE", &date),
("GIT_COMMITTER_DATE", &date),
("GIT_EDITOR", {
if Path::new("/bin/true").exists() {
"/bin/true"
} else {
"/usr/bin/true"
}
}),
(
"PATH_TO_GIT",
git_executable
.to_str()
.expect("Could not decode `git_executable`"),
),
("PATH", &new_path),
];
let mut command = Command::new(&git_executable);
command.args(&args).env_clear().envs(env.iter().copied());
let result = command.output().with_context(|| {
format!(
"Running git
Executable: {:?}
Args: {:?}
Env: <not shown>",
&self.git_executable, &args
)
})?;
let exit_code = result
.status
.code()
.expect("Failed to read exit code from Git process");
let result = if exit_code != *expected_exit_code {
anyhow::bail!(
"Git command {:?} {:?} exited with unexpected code {} (expected {})
stdout:
{}
stderr:
{}",
&self.git_executable,
&args,
exit_code,
expected_exit_code,
&String::from_utf8_lossy(&result.stdout),
&String::from_utf8_lossy(&result.stderr),
)
} else {
result
};
let stdout = String::from_utf8(result.stdout)?;
let stdout = self.preprocess_stdout(stdout)?;
let stderr = String::from_utf8(result.stderr)?;
Ok((stdout, stderr))
}
pub fn run<S: AsRef<str> + std::fmt::Debug>(
&self,
args: &[S],
) -> anyhow::Result<(String, String)> {
self.run_with_options(args, &Default::default())
}
#[context("Initializing Git repo with options: {:?}", options)]
pub fn init_repo_with_options(&self, options: &GitInitOptions) -> anyhow::Result<()> {
self.run(&["init"])?;
self.run(&["config", "user.name", DUMMY_NAME])?;
self.run(&["config", "user.email", DUMMY_EMAIL])?;
if options.make_initial_commit {
self.commit_file("initial", 0)?;
}
self.run(&["config", "advice.detachedHead", "false"])?;
self.run(&["config", "branchless.commitMetadata.relativeTime", "false"])?;
self.run(&["branchless", "init"])?;
Ok(())
}
pub fn init_repo(&self) -> anyhow::Result<()> {
self.init_repo_with_options(&Default::default())
}
#[context(
"Committing file {:?} at time {:?} with contents: {:?}",
name,
time,
contents
)]
pub fn commit_file_with_contents(
&self,
name: &str,
time: isize,
contents: &str,
) -> anyhow::Result<git2::Oid> {
let file_path = self.repo_path.join(format!("{}.txt", name));
std::fs::write(&file_path, contents)?;
self.run(&["add", "."])?;
self.run_with_options(
&["commit", "-m", &format!("create {}.txt", name)],
&GitRunOptions {
time,
..Default::default()
},
)?;
let repo = self.get_repo()?;
let oid = repo.head()?.peel_to_commit()?.id();
Ok(oid)
}
pub fn commit_file(&self, name: &str, time: isize) -> anyhow::Result<git2::Oid> {
self.commit_file_with_contents(name, time, &format!("{} contents\n", name))
}
#[context("Detaching HEAD")]
pub fn detach_head(&self) -> anyhow::Result<()> {
self.run(&["checkout", "--detach"])?;
Ok(())
}
#[context("Getting the `git2::Repository` object for {:?}", self)]
pub fn get_repo(&self) -> anyhow::Result<git2::Repository> {
git2::Repository::open(&self.repo_path).map_err(wrap_git_error)
}
#[context("Getting the Git version for {:?}", self)]
pub fn get_version(&self) -> anyhow::Result<GitVersion> {
let (version_str, _stderr) = self.run(&["version"])?;
version_str.parse()
}
#[context("Detecting reference-transaction support for {:?}", self)]
pub fn supports_reference_transactions(&self) -> anyhow::Result<bool> {
let version = self.get_version()?;
Ok(version >= GitVersion(2, 29, 0))
}
#[context("Resolving file {:?} with contents: {:?}", name, contents)]
pub fn resolve_file(&self, name: &str, contents: &str) -> anyhow::Result<()> {
let file_path = self.repo_path.join(format!("{}.txt", name));
std::fs::write(&file_path, contents)?;
let file_path = match file_path.to_str() {
None => anyhow::bail!("Could not convert file path to string: {:?}", file_path),
Some(file_path) => file_path,
};
self.run(&["add", file_path])?;
Ok(())
}
}
#[context("Getting the Git executable to use")]
pub fn get_git_executable() -> anyhow::Result<PathBuf> {
let git_executable = std::env::var("PATH_TO_GIT").with_context(|| {
"No path to git set. Try running as: PATH_TO_GIT=$(which git) cargo test ..."
})?;
let git_executable = PathBuf::from_str(&git_executable)?;
Ok(git_executable)
}
pub fn with_git(f: fn(Git) -> anyhow::Result<()>) -> anyhow::Result<()> {
let repo_dir = tempfile::tempdir()?;
let git_executable = get_git_executable()?;
let git_executable = GitExecutable(&git_executable);
let git = Git::new(Path::new(repo_dir.path()), &git_executable);
f(git)
}