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::{
TEST_GIT, TEST_SEPARATE_COMMAND_BINARIES, get_git_exec_path, get_path_to_git,
should_use_separate_command_binary,
};
use crate::git::{GitRunInfo, GitVersion, NonZeroOid, Repo};
use crate::util::get_sh;
use color_eyre::Help;
use eyre::Context;
use itertools::Itertools;
use lazy_static::lazy_static;
use once_cell::sync::OnceCell;
use regex::{Captures, Regex};
use tempfile::TempDir;
use tracing::{instrument, warn};
fn find_cargo_target_profile_dir() -> Option<PathBuf> {
let current_exe = std::env::current_exe().ok()?;
let maybe_deps = current_exe.parent()?;
if maybe_deps.file_name()? == "deps" {
Some(maybe_deps.parent()?.to_path_buf())
} else {
Some(maybe_deps.to_path_buf())
}
}
fn try_find_cargo_bin(name: &str) -> Option<PathBuf> {
let bin_path =
find_cargo_target_profile_dir()?.join(format!("{name}{}", std::env::consts::EXE_SUFFIX));
bin_path.exists().then_some(bin_path)
}
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>,
pub subdir: Option<PathBuf>,
}
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();
lazy_static! {
static ref WHITESPACE_ONLY_LINE_RE: Regex = Regex::new(r"(?m)^ {4}$").unwrap();
}
let output = WHITESPACE_ONLY_LINE_RE
.replace_all(&output, "")
.into_owned();
Ok(output)
}
pub fn get_path_for_env(&self) -> OsString {
let branchless_path = find_cargo_target_profile_dir()
.expect("Unable to find git-branchless target directory");
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(),
])
.expect("joining paths")
}
pub fn get_base_env(&self, time: isize) -> Vec<(OsString, OsString)> {
let date: OsString = format!("{DUMMY_DATE} -{time:0>2}").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()),
("LC_ALL", "C".into()),
("PATH", new_path),
(TEST_GIT, self.path_to_git.as_os_str().into()),
(
TEST_SEPARATE_COMMAND_BINARIES,
std::env::var_os(TEST_SEPARATE_COMMAND_BINARIES).unwrap_or_default(),
),
];
envs.into_iter()
.map(|(key, value)| (OsString::from(key), value))
.collect()
}
#[track_caller]
#[instrument]
fn run_with_options_inner(
&self,
args: &[&str],
options: &GitRunOptions,
) -> eyre::Result<(String, String)> {
let GitRunOptions {
time,
expected_exit_code,
input,
env,
subdir,
} = options;
let current_dir = subdir.as_ref().map_or(self.repo_path.clone(), |subdir| {
let mut p = self.repo_path.clone();
p.push(subdir);
p
});
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(¤t_dir)
.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))
}
#[track_caller]
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,
)
}
#[track_caller]
pub fn run<S: AsRef<str> + std::fmt::Debug>(
&self,
args: &[S],
) -> eyre::Result<(String, String)> {
if let Some(first_arg) = args.first() {
if first_arg.as_ref() == "branchless" {
eyre::bail!(
r#"Refusing to invoke `branchless` via `git.run(&["branchless", ...])`; instead, call `git.branchless(&[...])`"#
);
}
}
self.run_with_options(args, &Default::default())
}
#[instrument]
pub fn smartlog(&self) -> eyre::Result<String> {
let (stdout, _stderr) = self.branchless("smartlog", &[])?;
Ok(stdout)
}
#[track_caller]
#[instrument]
pub fn branchless(&self, subcommand: &str, args: &[&str]) -> eyre::Result<(String, String)> {
self.branchless_with_options(subcommand, args, &Default::default())
}
#[track_caller]
#[instrument]
pub fn branchless_with_options(
&self,
subcommand: &str,
args: &[&str],
options: &GitRunOptions,
) -> eyre::Result<(String, String)> {
let mut git_run_args = Vec::new();
if should_use_separate_command_binary(subcommand) {
git_run_args.push(format!("branchless-{subcommand}"));
} else {
git_run_args.push("branchless".to_string());
git_run_args.push(subcommand.to_string());
}
git_run_args.extend(args.iter().map(|arg| arg.to_string()));
let result = self.run_with_options(&git_run_args, options);
if !should_use_separate_command_binary(subcommand) {
let main_command_exe = try_find_cargo_bin("git-branchless");
let subcommand_exe = try_find_cargo_bin(&format!("git-branchless-{subcommand}"));
if let (Some(main_command_exe), Some(subcommand_exe)) =
(main_command_exe, subcommand_exe)
{
let main_command_mtime = main_command_exe.metadata()?.modified()?;
let subcommand_mtime = subcommand_exe.metadata()?.modified()?;
if subcommand_mtime > main_command_mtime {
result.suggestion(format!(
"\
The modified time for {main_command_exe:?} was before the modified time for
{subcommand_exe:?}, which may indicate that you made changes to the subcommand
without building the main executable. This may cause spurious test failures
because the main executable code is out of date.
If so, you should either explicitly run: cargo -p git-branchless
to build the main executable before running this test; or, if it's okay to skip
building the main executable and test only the subcommand executable, you
can set the environment variable
`{TEST_SEPARATE_COMMAND_BINARIES}={subcommand}` to directly invoke it.\
"
))
} else {
result
}
} else {
result
}
} else {
result.suggestion(format!(
"\
If you have set the {TEST_SEPARATE_COMMAND_BINARIES} environment variable, then \
the git-branchless-{subcommand} binary is NOT automatically built or updated when \
running integration tests for other binaries (see \
https://github.com/rust-lang/cargo/issues/4316 for more details).
Make sure that git-branchless-{subcommand} has been built before running \
integration tests. You can build it with: cargo build -p git-branchless-{subcommand}
If you have not set the {TEST_SEPARATE_COMMAND_BINARIES} environment variable, \
then you can only run tests in the main `git-branchless` and \
`git-branchless-lib` crates.\
",
))
}
}
#[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])?;
self.run(&["config", "core.abbrev", "7"])?;
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.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())?;
let new_repo = Git {
repo_path: target.repo_path.clone(),
..self.clone()
};
new_repo.init_repo_with_options(&GitInitOptions {
make_initial_commit: false,
run_branchless_init: false,
})?;
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!("{name}.txt"));
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!("{name}.txt"));
fs::set_permissions(file_path, permissions)?;
Ok(())
}
#[instrument]
pub fn get_trimmed_diff(&self, file: &str, commit: &str) -> eyre::Result<String> {
let (stdout, _stderr) = self.run(&["show", "--pretty=format:", commit])?;
let split_on = format!("+++ b/{file}\n");
match stdout.as_str().split_once(split_on.as_str()) {
Some((_, diff)) => Ok(diff.to_string()),
None => eyre::bail!("Error trimming diff. Could not split on '{split_on}'"),
}
}
#[track_caller]
#[instrument]
pub fn commit_file_with_contents_and_message(
&self,
name: &str,
time: isize,
contents: &str,
message_prefix: &str,
) -> eyre::Result<NonZeroOid> {
let message = format!("{message_prefix} {name}.txt");
self.write_file_txt(name, contents)?;
self.run(&["add", "."])?;
self.run_with_options(
&["commit", "-m", &message],
&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)
}
#[track_caller]
#[instrument]
pub fn commit_file_with_contents(
&self,
name: &str,
time: isize,
contents: &str,
) -> eyre::Result<NonZeroOid> {
self.commit_file_with_contents_and_message(name, time, contents, "create")
}
#[track_caller]
#[instrument]
pub fn commit_file(&self, name: &str, time: isize) -> eyre::Result<NonZeroOid> {
self.commit_file_with_contents(name, time, &format!("{name} contents\n"))
}
#[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))
}
pub fn produces_auto_merge_refs(&self) -> eyre::Result<bool> {
let version = self.get_version()?;
Ok(version >= GitVersion(2, 44, 0))
}
#[instrument]
pub fn resolve_file(&self, name: &str, contents: &str) -> eyre::Result<()> {
let file_path = self.repo_path.join(format!("{name}.txt"));
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 })
}
}
static COLOR_EYRE_INSTALL: OnceCell<()> = OnceCell::new();
pub fn make_git() -> eyre::Result<GitWrapper> {
COLOR_EYRE_INSTALL.get_or_try_init(color_eyre::install)?;
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",
"--detach",
worktree_path.to_str().unwrap(),
])?;
let worktree = Git {
repo_path: worktree_path,
..git.clone()
};
Ok(GitWorktreeWrapper { temp_dir, worktree })
}
pub fn extract_hint_command(stdout: &str) -> Vec<String> {
let hint_command = stdout
.split_once("disable this hint by running: ")
.map(|(_first, second)| second)
.unwrap()
.split('\n')
.next()
.unwrap();
hint_command
.split(' ')
.skip(1) .filter(|s| s != &"--global")
.map(|s| s.to_owned())
.collect_vec()
}
pub fn remove_rebase_lines(output: String) -> String {
output
.lines()
.filter(|line| !line.contains("First, rewinding head") && !line.contains("Applying:"))
.filter(|line| {
!line.contains("Auto-merging")
&& !line.contains("use \"git pull\"")
})
.flat_map(|line| [line, "\n"])
.collect()
}
pub fn trim_lines(output: String) -> String {
output
.lines()
.flat_map(|line| vec![line.trim_end(), "\n"].into_iter())
.collect()
}
pub fn remove_nondeterministic_lines(output: String) -> String {
output
.lines()
.filter(|line| {
!line.contains("Fetching")
&& !line.contains("Your branch is up to date")
&& !line.contains("Switched to branch")
&& !line.contains("hint:")
&& !line.is_empty()
})
.flat_map(|line| [line, "\n"])
.collect()
}
pub mod pty {
use std::sync::{Arc, Mutex, mpsc::channel};
use std::thread;
use std::time::Duration;
use eyre::eyre;
use portable_pty::{CommandBuilder, ExitStatus, PtySize, native_pty_system};
use super::Git;
pub const UP_ARROW: &str = "\x1b[A";
pub const DOWN_ARROW: &str = "\x1b[B";
pub enum PtyAction<'a> {
Write(&'a str),
WaitUntilContains(&'a str),
}
#[track_caller]
pub fn run_in_pty(
git: &Git,
branchless_subcommand: &str,
args: &[&str],
inputs: &[PtyAction],
) -> eyre::Result<ExitStatus> {
let pty_system = native_pty_system();
let pty_size = PtySize::default();
let pty = pty_system
.openpty(pty_size)
.map_err(|e| eyre!("Could not open pty: {}", e))?;
let mut pty_master = pty
.master
.take_writer()
.map_err(|e| eyre!("Could not take PTY master writer: {e}"))?;
let mut cmd = CommandBuilder::new(&git.path_to_git);
cmd.env_clear();
for (k, v) in git.get_base_env(0) {
cmd.env(k, v);
}
cmd.env("TERM", "xterm");
cmd.arg("branchless");
cmd.arg(branchless_subcommand);
cmd.args(args);
cmd.cwd(&git.repo_path);
let mut child = pty
.slave
.spawn_command(cmd)
.map_err(|e| eyre!("Could not spawn child: {}", e))?;
let reader = pty
.master
.try_clone_reader()
.map_err(|e| eyre!("Could not clone reader: {}", e))?;
let reader = Arc::new(Mutex::new(reader));
let parser = vt100::Parser::new(pty_size.rows, pty_size.cols, 0);
let parser = Arc::new(Mutex::new(parser));
for action in inputs {
match action {
PtyAction::WaitUntilContains(value) => {
let (finished_tx, finished_rx) = channel();
let wait_thread = {
let parser = Arc::clone(&parser);
let reader = Arc::clone(&reader);
let value = value.to_string();
thread::spawn(move || -> anyhow::Result<()> {
loop {
{
let parser = parser.lock().unwrap();
if parser.screen().contents().contains(&value) {
break;
}
}
let mut reader = reader.lock().unwrap();
const BUF_SIZE: usize = 4096;
let mut buffer = [0; BUF_SIZE];
let n = reader.read(&mut buffer)?;
assert!(n < BUF_SIZE, "filled up PTY buffer by reading {n} bytes",);
{
let mut parser = parser.lock().unwrap();
parser.process(&buffer[..n]);
}
}
finished_tx.send(()).unwrap();
Ok(())
})
};
if finished_rx.recv_timeout(Duration::from_secs(5)).is_err() {
panic!(
"\
Timed out waiting for virtual terminal to show string: {:?}
Screen contents:
-----
{}
-----
",
value,
parser.lock().unwrap().screen().contents(),
);
}
wait_thread.join().unwrap().unwrap();
}
PtyAction::Write(value) => {
if let Ok(Some(exit_status)) = child.try_wait() {
panic!(
"\
Tried to write {value:?} to PTY, but the process has already exited with status {exit_status:?}. Screen contents:
-----
{}
-----
", parser.lock().unwrap().screen().contents(),
);
}
write!(pty_master, "{value}")?;
pty_master.flush()?;
}
}
}
let read_remainder_of_pty_output_thread = thread::spawn({
let reader = Arc::clone(&reader);
move || {
let mut reader = reader.lock().unwrap();
let mut buffer = Vec::new();
reader.read_to_end(&mut buffer).expect("finish reading pty");
String::from_utf8(buffer).unwrap()
}
});
let exit_status = child.wait()?;
let _ = read_remainder_of_pty_output_thread;
Ok(exit_status)
}
}