use std::collections::{BTreeMap, HashMap};
use std::ffi::OsString;
use std::fs;
use std::io::Write;
use std::ops::Deref;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use crate::core::config::env_vars::{get_git_exec_path, get_path_to_git, TEST_GIT};
use crate::git::{GitRunInfo, GitVersion, NonZeroOid, Repo};
use crate::util::get_sh;
use eyre::Context;
use itertools::Itertools;
use lazy_static::lazy_static;
use regex::{Captures, Regex};
use tempfile::TempDir;
use tracing::instrument;
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(Clone, Debug)]
pub struct Git {
pub repo_path: PathBuf,
pub path_to_git: PathBuf,
pub git_exec_path: PathBuf,
}
#[derive(Debug)]
pub struct GitInitOptions {
pub make_initial_commit: bool,
pub run_branchless_init: bool,
}
impl Default for GitInitOptions {
fn default() -> Self {
GitInitOptions {
make_initial_commit: true,
run_branchless_init: true,
}
}
}
#[derive(Debug, Default)]
pub struct GitRunOptions {
pub time: isize,
pub expected_exit_code: i32,
pub input: Option<String>,
pub env: HashMap<String, String>,
}
impl Git {
pub fn new(path_to_git: PathBuf, repo_path: PathBuf, git_exec_path: PathBuf) -> Self {
Git {
repo_path,
path_to_git,
git_exec_path,
}
}
pub fn preprocess_output(&self, stdout: String) -> eyre::Result<String> {
let path_to_git = self
.path_to_git
.to_str()
.ok_or_else(|| eyre::eyre!("Could not convert path to Git to string"))?;
let output = stdout.replace(path_to_git, "<git-executable>");
let repo_path = std::fs::canonicalize(&self.repo_path)?;
let repo_path = repo_path
.to_str()
.ok_or_else(|| eyre::eyre!("Could not convert repo path to string"))?;
let output = output.replace(repo_path, "<repo-path>");
lazy_static! {
static ref CLEAR_LINE_RE: Regex = Regex::new(r"(^|\n).*(\r|\x1B\[K)").unwrap();
}
let output = CLEAR_LINE_RE
.replace_all(&output, |captures: &Captures| {
captures[1].to_string()
})
.into_owned();
Ok(output)
}
pub fn get_path_for_env(&self) -> OsString {
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 bash = get_sh().expect("bash missing?");
let bash_path = bash.parent().unwrap();
std::env::join_paths(
vec![
branchless_path.as_os_str(),
self.git_exec_path.as_os_str(),
bash_path.as_os_str(),
]
.into_iter(),
)
.expect("joining paths")
}
pub fn get_base_env(&self, time: isize) -> Vec<(OsString, OsString)> {
let date: OsString = format!("{date} -{time:0>2}", date = DUMMY_DATE, time = time).into();
let git_editor = OsString::from(":");
let new_path = self.get_path_for_env();
let envs = vec![
("GIT_CONFIG_NOSYSTEM", OsString::from("1")),
("GIT_AUTHOR_DATE", date.clone()),
("GIT_COMMITTER_DATE", date),
("GIT_EDITOR", git_editor),
("GIT_EXEC_PATH", self.git_exec_path.as_os_str().into()),
("PATH", new_path),
(TEST_GIT, self.path_to_git.as_os_str().into()),
];
envs.into_iter()
.map(|(key, value)| (OsString::from(key), value))
.collect()
}
#[instrument]
fn run_with_options_inner(
&self,
args: &[&str],
options: &GitRunOptions,
) -> eyre::Result<(String, String)> {
let GitRunOptions {
time,
expected_exit_code,
input,
env,
} = options;
let env: BTreeMap<_, _> = self
.get_base_env(*time)
.into_iter()
.chain(
env.iter()
.map(|(k, v)| (OsString::from(k), OsString::from(v))),
)
.collect();
let mut command = Command::new(&self.path_to_git);
command
.current_dir(&self.repo_path)
.args(args)
.env_clear()
.envs(&env);
let result = if let Some(input) = input {
let mut child = command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
write!(child.stdin.take().unwrap(), "{}", &input)?;
child.wait_with_output().wrap_err_with(|| {
format!(
"Running git
Executable: {:?}
Args: {:?}
Stdin: {:?}
Env: <not shown>",
&self.path_to_git, &args, input
)
})?
} else {
command.output().wrap_err_with(|| {
format!(
"Running git
Executable: {:?}
Args: {:?}
Env: <not shown>",
&self.path_to_git, &args
)
})?
};
let exit_code = result
.status
.code()
.expect("Failed to read exit code from Git process");
let result = if exit_code != *expected_exit_code {
eyre::bail!(
"Git command {:?} {:?} exited with unexpected code {} (expected {})
env:
{:#?}
stdout:
{}
stderr:
{}",
&self.path_to_git,
&args,
exit_code,
expected_exit_code,
&env,
&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_output(stdout)?;
let stderr = String::from_utf8(result.stderr)?;
let stderr = self.preprocess_output(stderr)?;
Ok((stdout, stderr))
}
pub fn run_with_options<S: AsRef<str> + std::fmt::Debug>(
&self,
args: &[S],
options: &GitRunOptions,
) -> eyre::Result<(String, String)> {
self.run_with_options_inner(
args.iter().map(|arg| arg.as_ref()).collect_vec().as_slice(),
options,
)
}
pub fn run<S: AsRef<str> + std::fmt::Debug>(
&self,
args: &[S],
) -> eyre::Result<(String, String)> {
self.run_with_options(args, &Default::default())
}
#[instrument]
pub fn init_repo_with_options(&self, options: &GitInitOptions) -> eyre::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",
"branchless.commitDescriptors.relativeTime",
"false",
])?;
self.run(&["config", "branchless.restack.preserveTimestamps", "true"])?;
self.run(&["config", "core.autocrlf", "false"])?;
if options.run_branchless_init {
self.run(&["branchless", "init"])?;
}
Ok(())
}
pub fn init_repo(&self) -> eyre::Result<()> {
self.init_repo_with_options(&Default::default())
}
pub fn clone_repo_into(&self, target: &Git, additional_args: &[&str]) -> eyre::Result<()> {
let remote = format!("file://{}", self.repo_path.to_str().unwrap());
let args = {
let mut args = vec![
"clone",
"-c",
"core.autocrlf=false",
&remote,
target.repo_path.to_str().unwrap(),
];
args.extend(additional_args.iter());
args
};
let (_stdout, _stderr) = self.run(args.as_slice())?;
Ok(())
}
pub fn write_file_txt(&self, name: &str, contents: &str) -> eyre::Result<()> {
let name = format!("{name}.txt");
self.write_file(&name, contents)
}
pub fn write_file(&self, name: &str, contents: &str) -> eyre::Result<()> {
let path = self.repo_path.join(name);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(self.repo_path.join(dir))?;
}
std::fs::write(&path, contents)?;
Ok(())
}
pub fn delete_file(&self, name: &str) -> eyre::Result<()> {
let file_path = self.repo_path.join(format!("{}.txt", name));
fs::remove_file(file_path)?;
Ok(())
}
pub fn set_file_permissions(
&self,
name: &str,
permissions: fs::Permissions,
) -> eyre::Result<()> {
let file_path = self.repo_path.join(format!("{}.txt", name));
fs::set_permissions(file_path, permissions)?;
Ok(())
}
#[instrument]
pub fn commit_file_with_contents(
&self,
name: &str,
time: isize,
contents: &str,
) -> eyre::Result<NonZeroOid> {
self.write_file_txt(name, 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
.get_head_info()?
.oid
.expect("Could not find OID for just-created commit");
Ok(oid)
}
pub fn commit_file(&self, name: &str, time: isize) -> eyre::Result<NonZeroOid> {
self.commit_file_with_contents(name, time, &format!("{} contents\n", name))
}
#[instrument]
pub fn detach_head(&self) -> eyre::Result<()> {
self.run(&["checkout", "--detach"])?;
Ok(())
}
#[instrument]
pub fn get_repo(&self) -> eyre::Result<Repo> {
let repo = Repo::from_dir(&self.repo_path)?;
Ok(repo)
}
#[instrument]
pub fn get_version(&self) -> eyre::Result<GitVersion> {
let (version_str, _stderr) = self.run(&["version"])?;
let version = version_str.parse()?;
Ok(version)
}
#[instrument]
pub fn get_git_run_info(&self) -> GitRunInfo {
GitRunInfo {
path_to_git: self.path_to_git.clone(),
working_directory: self.repo_path.clone(),
env: self.get_base_env(0).into_iter().collect(),
}
}
#[instrument]
pub fn supports_reference_transactions(&self) -> eyre::Result<bool> {
let version = self.get_version()?;
Ok(version >= GitVersion(2, 29, 0))
}
pub fn supports_committer_date_is_author_date(&self) -> eyre::Result<bool> {
let version = self.get_version()?;
Ok(version >= GitVersion(2, 29, 0))
}
pub fn supports_log_exclude_decoration(&self) -> eyre::Result<bool> {
let version = self.get_version()?;
Ok(version >= GitVersion(2, 27, 0))
}
#[instrument]
pub fn resolve_file(&self, name: &str, contents: &str) -> eyre::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 => eyre::bail!("Could not convert file path to string: {:?}", file_path),
Some(file_path) => file_path,
};
self.run(&["add", file_path])?;
Ok(())
}
#[instrument]
pub fn clear_event_log(&self) -> eyre::Result<()> {
let event_log_path = self.repo_path.join(".git/branchless/db.sqlite3");
std::fs::remove_file(event_log_path)?;
Ok(())
}
}
pub struct GitWrapper {
repo_dir: TempDir,
git: Git,
}
impl Deref for GitWrapper {
type Target = Git;
fn deref(&self) -> &Self::Target {
&self.git
}
}
fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
fs::create_dir_all(&dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
if ty.is_dir() {
copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
} else {
fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
}
}
Ok(())
}
impl GitWrapper {
pub fn duplicate_repo(&self) -> eyre::Result<Self> {
let repo_dir = tempfile::tempdir()?;
copy_dir_all(&self.repo_dir, &repo_dir)?;
let git = Git {
repo_path: repo_dir.path().to_path_buf(),
..self.git.clone()
};
Ok(Self { repo_dir, git })
}
}
pub fn make_git() -> eyre::Result<GitWrapper> {
let repo_dir = tempfile::tempdir()?;
let path_to_git = get_path_to_git()?;
let git_exec_path = get_git_exec_path()?;
let git = Git::new(path_to_git, repo_dir.path().to_path_buf(), git_exec_path);
Ok(GitWrapper { repo_dir, git })
}
pub struct GitWrapperWithRemoteRepo {
pub temp_dir: TempDir,
pub original_repo: Git,
pub cloned_repo: Git,
}
pub fn make_git_with_remote_repo() -> eyre::Result<GitWrapperWithRemoteRepo> {
let path_to_git = get_path_to_git()?;
let git_exec_path = get_git_exec_path()?;
let temp_dir = tempfile::tempdir()?;
let original_repo_path = temp_dir.path().join("original");
std::fs::create_dir_all(&original_repo_path)?;
let original_repo = Git::new(
path_to_git.clone(),
original_repo_path,
git_exec_path.clone(),
);
let cloned_repo_path = temp_dir.path().join("cloned");
let cloned_repo = Git::new(path_to_git, cloned_repo_path, git_exec_path);
Ok(GitWrapperWithRemoteRepo {
temp_dir,
original_repo,
cloned_repo,
})
}
pub struct GitWorktreeWrapper {
pub temp_dir: TempDir,
pub worktree: Git,
}
pub fn make_git_worktree(git: &Git, worktree_name: &str) -> eyre::Result<GitWorktreeWrapper> {
let temp_dir = tempfile::tempdir()?;
let worktree_path = temp_dir.path().join(worktree_name);
git.run(&["worktree", "add", worktree_path.to_str().unwrap()])?;
let worktree = Git {
repo_path: worktree_path,
..git.clone()
};
Ok(GitWorktreeWrapper { temp_dir, worktree })
}