//! Utilities for testing `gitoxide` crates, many of which might be useful for testing programs that use `git` in general.#![deny(missing_docs)]use std::{ collections::BTreeMap, convert::Infallible, ffi::OsString, io::Read, path::{Path, PathBuf}, time::Duration,}; pub use bstr;use bstr::{BStr, ByteSlice};use io_close::Close;pub use is_ci;use nom::error::VerboseError;pub use once_cell;use once_cell::sync::Lazy;use parking_lot::Mutex;pub use tempfile; /// A result type to allow using the try operator `?` in unit tests.////// Use it like so:////// ```no_run/// use git_testtools::Result;////// #[test]/// fn this() -> Result {/// let x: usize = "42".parse()?; /// Ok(())////// }/// ```pub type Result<T = ()> = std::result::Result<T, Box<dyn std::error::Error>>; static SCRIPT_IDENTITY: Lazy<Mutex<BTreeMap<PathBuf, u32>>> = Lazy::new(|| Mutex::new(BTreeMap::new()));static EXCLUDE_LUT: Lazy<Mutex<Option<git_worktree::fs::Cache<'static>>>> = Lazy::new(|| { let cache = (|| { let (repo_path, _) = git_discover::upwards(Path::new(".")).ok()?; let (git_dir, work_tree) = repo_path.into_repository_and_work_tree_directories(); let work_tree = work_tree?.canonicalize().ok()?; let mut buf = Vec::with_capacity(512); let case = git_worktree::fs::Capabilities::probe(&work_tree) .ignore_case .then(|| git_attributes::glob::pattern::Case::Fold) .unwrap_or_default(); let state = git_worktree::fs::cache::State::IgnoreStack(git_worktree::fs::cache::state::Ignore::new( Default::default(), git_attributes::MatchGroup::<git_attributes::Ignore>::from_git_dir(git_dir, None, &mut buf).ok()?, None, case, )); Some(git_worktree::fs::Cache::new( work_tree, state, case, buf, Default::default(), )) })(); Mutex::new(cache)}); /// Define how [scripted_fixture_repo_writable_with_args()] uses produces the writable copy.pub enum Creation { /// Run the script once and copy the data from its output to the writable location. /// This is fast but won't work if absolute paths are produced by the script. CopyFromReadOnly, /// Run the script in the writable location. That way, absolute paths match the location. ExecuteScript,} /// Run `git` in `working_dir` with all provided `args`.pub fn run_git(working_dir: &Path, args: &[&str]) -> std::io::Result<std::process::ExitStatus> { std::process::Command::new("git") .current_dir(working_dir) .args(args) .status()} /// Convert a hexadecimal hash into its corresponding `ObjectId` or _panic_.pub fn hex_to_id(hex: &str) -> git_hash::ObjectId { git_hash::ObjectId::from_hex(hex.as_bytes()).expect("40 bytes hex")} /// Return the path to the `<crate-root>/tests/fixtures/<path>` directory.pub fn fixture_path(path: impl AsRef<Path>) -> PathBuf { PathBuf::from("tests").join("fixtures").join(path.as_ref())} /// Load the fixture from `<crate-root>/tests/fixtures/<path>` and return its data, or _panic_.pub fn fixture_bytes(path: impl AsRef<Path>) -> Vec<u8> { match std::fs::read(fixture_path(path.as_ref())) { Ok(res) => res, Err(_) => panic!("File at '{}' not found", path.as_ref().display()), }} /// Run the executable at `script_name`, like `make_repo.sh` to produce a read-only directory to which/// the path is returned.////// Note that it persists and the script at `script_name` will only be executed once if it ran without error.////// ### Automatic Archive Creation////// In order to speed up CI and even local runs should the cache get purged, the result of each script run/// is automatically placed into a compressed _tar_ archive./// If a script result doesn't exist, these will be checked first and extracted if present, which they are by default./// This behaviour can be prohibited by setting the `GITOXIDE_TEST_IGNORE_ARCHIVES` to any value.////// To speed CI up, one can add these archives to the repository. It's absoutely recommended to use `git-lfs` for that to/// not bloat the repository size.////// #### Disable Archive Creation////// If archives aren't useful, they can be disabled by using `.gitignore` specifications./// That way it's trivial to prevent creation of all archives with `generated-archives/*.tar.xz` in the root/// or more specific `.gitignore` configurations in lower levels of the work tree.////// The latter is useful if the the script's output is platform specific.pub fn scripted_fixture_repo_read_only(script_name: impl AsRef<Path>) -> Result<PathBuf> { scripted_fixture_repo_read_only_with_args(script_name, None)} /// Run the executable at `script_name`, like `make_repo.sh` to produce a writable directory to which/// the tempdir is returned. It will be removed automatically, courtesy of [`tempfile::TempDir`].////// Note that `script_name` is only executed once, so the data can be copied from its read-only location.pub fn scripted_fixture_repo_writable(script_name: &str) -> Result<tempfile::TempDir> { scripted_fixture_repo_writable_with_args(script_name, None, Creation::CopyFromReadOnly)} /// Like [`scripted_fixture_repo_writable()`], but passes `args` to `script_name` while providing control over/// the way files are created with `mode`.pub fn scripted_fixture_repo_writable_with_args( script_name: &str, args: impl IntoIterator<Item = &'static str>, mode: Creation,) -> Result<tempfile::TempDir> { let dst = tempfile::TempDir::new()?; Ok(match mode { Creation::CopyFromReadOnly => { let ro_dir = scripted_fixture_repo_read_only_with_args_inner(script_name, args, None)?; copy_recursively_into_existing_dir(&ro_dir, dst.path())?; dst } Creation::ExecuteScript => { scripted_fixture_repo_read_only_with_args_inner(script_name, args, dst.path().into())?; dst } })} /// A utility to copy the entire contents of `src_dir` into `dst_dir`.pub fn copy_recursively_into_existing_dir(src_dir: impl AsRef<Path>, dst_dir: impl AsRef<Path>) -> std::io::Result<()> { fs_extra::copy_items( &std::fs::read_dir(src_dir)? .map(|e| e.map(|e| e.path())) .collect::<std::result::Result<Vec<_>, _>>()?, dst_dir, &fs_extra::dir::CopyOptions { overwrite: false, skip_exist: false, copy_inside: false, content_only: false, ..Default::default() }, ) .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))?; Ok(())} /// Like `scripted_fixture_repo_read_only()`], but passes `args` to `script_name`.pub fn scripted_fixture_repo_read_only_with_args( script_name: impl AsRef<Path>, args: impl IntoIterator<Item = &'static str>,) -> Result<PathBuf> { scripted_fixture_repo_read_only_with_args_inner(script_name, args, None)} fn scripted_fixture_repo_read_only_with_args_inner( script_name: impl AsRef<Path>, args: impl IntoIterator<Item = &'static str>, destination_dir: Option<&Path>,) -> Result<PathBuf> { // Assure tempfiles get removed when aborting the test. git_lock::tempfile::setup( git_lock::tempfile::SignalHandlerMode::DeleteTempfilesOnTerminationAndRestoreDefaultBehaviour, ); let script_location = script_name.as_ref(); let script_path = fixture_path(script_location); // keep this lock to assure we don't return unfinished directories for threaded callers let args: Vec<String> = args.into_iter().map(Into::into).collect(); let mut map = SCRIPT_IDENTITY.lock(); let script_identity = map .entry(args.iter().fold(script_path.clone(), |p, a| p.join(a))) .or_insert_with(|| { let crc_value = crc::Crc::<u32>::new(&crc::CRC_32_CKSUM); let mut crc_digest = crc_value.digest(); crc_digest.update(&std::fs::read(&script_path).unwrap_or_else(|err| { panic!( "file {script_path:?} in CWD {:?} could not be read: {err}", std::env::current_dir().expect("valid cwd"), ) })); for arg in args.iter() { crc_digest.update(arg.as_bytes()); } crc_digest.finalize() }) .to_owned(); let script_basename = script_location.file_stem().unwrap_or(script_location.as_os_str()); let archive_file_path = fixture_path( Path::new("generated-archives").join(format!("{}.tar.xz", script_basename.to_str().expect("valid UTF-8"))), ); let (force_run, script_result_directory) = destination_dir.map(|d| (true, d.to_owned())).unwrap_or_else(|| { let dir = fixture_path(Path::new("generated-do-not-edit").join(script_basename).join(format!( "{}-{}", script_identity, family_name() ))); (false, dir) }); let _marker = git_lock::Marker::acquire_to_hold_resource( script_basename, git_lock::acquire::Fail::AfterDurationWithBackoff(Duration::from_secs(if cfg!(windows) { 3 * 60 } else { 30 })), None, )?; let failure_marker = script_result_directory.join("_invalid_state_due_to_script_failure_"); if force_run || !script_result_directory.is_dir() || failure_marker.is_file() { if failure_marker.is_file() { std::fs::remove_dir_all(&script_result_directory)?; } std::fs::create_dir_all(&script_result_directory)?; match extract_archive(&archive_file_path, &script_result_directory, script_identity) { Ok((archive_id, platform)) => { eprintln!( "Extracted fixture from archive '{}' ({}, {:?})", archive_file_path.display(), archive_id, platform ) } Err(err) => { if err.kind() != std::io::ErrorKind::NotFound { eprintln!("failed to extract '{}': {}", archive_file_path.display(), err); } else if !is_excluded(&archive_file_path) { eprintln!( "Archive at '{}' not found, creating fixture using script '{}'", archive_file_path.display(), script_location.display() ); } let script_absolute_path = std::env::current_dir()?.join(script_path); let output = std::process::Command::new("bash") .arg(script_absolute_path) .args(args) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .current_dir(&script_result_directory) .env_remove("GIT_DIR") .env("GIT_AUTHOR_DATE", "2000-01-01 00:00:00 +0000") .env("GIT_AUTHOR_EMAIL", "author@example.com") .env("GIT_AUTHOR_NAME", "author") .env("GIT_COMMITTER_DATE", "2000-01-02 00:00:00 +0000") .env("GIT_COMMITTER_EMAIL", "committer@example.com") .env("GIT_COMMITTER_NAME", "committer") .env("GIT_CONFIG_COUNT", "2") .env("GIT_CONFIG_KEY_0", "commit.gpgsign") .env("GIT_CONFIG_VALUE_0", "false") .env("GIT_CONFIG_KEY_1", "init.defaultBranch") .env("GIT_CONFIG_VALUE_1", "main") .output()?; if !output.status.success() { write_failure_marker(&failure_marker); } assert!( output.status.success(), "repo script failed: stdout: {}\nstderr: {}", output.stdout.as_bstr(), output.stderr.as_bstr() ); create_archive_if_not_on_ci(&script_result_directory, &archive_file_path, script_identity).map_err( |err| { write_failure_marker(&failure_marker); err }, )?; } } } Ok(script_result_directory)} fn write_failure_marker(failure_marker: &Path) { std::fs::write(failure_marker, &[]).ok();} /// The `script_identity` will be baked into the soon to be created `archive` as it identitifies the script/// that created the contents of `source_dir`.fn create_archive_if_not_on_ci(source_dir: &Path, archive: &Path, script_identity: u32) -> std::io::Result<()> { if is_ci::cached() { return Ok(()); } if is_excluded(archive) { return Ok(()); } std::fs::create_dir_all(archive.parent().expect("archive is a file"))?; let meta_dir = populate_meta_dir(source_dir, script_identity)?; let res = (move || { let mut buf = Vec::<u8>::new(); { let mut ar = tar::Builder::new(&mut buf); ar.mode(tar::HeaderMode::Deterministic); ar.follow_symlinks(false); ar.append_dir_all(".", source_dir)?; ar.finish()?; } let archive = std::fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .open(archive)?; let mut xz_write = xz2::write::XzEncoder::new(archive, 3); std::io::copy(&mut &*buf, &mut xz_write)?; xz_write.finish()?.close() })(); #[cfg(not(windows))] std::fs::remove_dir_all(meta_dir)?; #[cfg(windows)] std::fs::remove_dir_all(meta_dir).ok(); // it really can't delete these directories for some reason (even after 10 seconds) res} fn is_excluded(archive: &Path) -> bool { let mut lut = EXCLUDE_LUT.lock(); lut.as_mut() .and_then(|cache| { let archive = std::env::current_dir().ok()?.join(archive); let relative_path = archive.strip_prefix(cache.base()).ok()?; cache .at_path( relative_path, Some(false), |_oid, _buf| -> std::result::Result<_, Infallible> { unreachable!("") }, ) .ok()? .is_excluded() .into() }) .unwrap_or(false)} const META_DIR_NAME: &str = "__gitoxide_meta__";const META_IDENTITY: &str = "identity";const META_GIT_VERSION: &str = "git-version"; fn populate_meta_dir(destination_dir: &Path, script_identity: u32) -> std::io::Result<PathBuf> { let meta_dir = destination_dir.join(META_DIR_NAME); std::fs::create_dir_all(&meta_dir)?; std::fs::write( meta_dir.join(META_IDENTITY), format!("{}-{}", script_identity, family_name()).as_bytes(), )?; std::fs::write( meta_dir.join(META_GIT_VERSION), &std::process::Command::new("git").arg("--version").output()?.stdout, )?; Ok(meta_dir)} /// `required_script_identity` is the identity of the script that generated the state that is contained in `archive`./// If this is not the case, the arvhive will be ignored.fn extract_archive( archive: &Path, destination_dir: &Path, required_script_identity: u32,) -> std::io::Result<(u32, Option<String>)> { let archive_buf: Vec<u8> = { let mut buf = Vec::new(); let input_archive = std::fs::File::open(archive)?; if std::env::var_os("GITOXIDE_TEST_IGNORE_ARCHIVES").is_some() { return Err(std::io::Error::new( std::io::ErrorKind::Other, format!( "Ignoring archive at '{}' as GITOXIDE_TEST_IGNORE_ARCHIVES is set.", archive.display() ), )); } let mut decoder = xz2::bufread::XzDecoder::new(std::io::BufReader::new(input_archive)); std::io::copy(&mut decoder, &mut buf)?; buf }; let mut entry_buf = Vec::<u8>::new(); let (archive_identity, platform): (u32, _) = tar::Archive::new(std::io::Cursor::new(&mut &*archive_buf)) .entries_with_seek()? .filter_map(|e| e.ok()) .find_map(|mut e: tar::Entry<'_, _>| { let path = e.path().ok()?; if path.parent()?.file_name()? == META_DIR_NAME && path.file_name()? == META_IDENTITY { entry_buf.clear(); e.read_to_end(&mut entry_buf).ok()?; let mut tokens = entry_buf.to_str().ok()?.trim().splitn(2, '-'); match (tokens.next(), tokens.next()) { (Some(id), platform) => Some((id.parse().ok()?, platform.map(ToOwned::to_owned))), _ => None, } } else { None } }) .ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::Other, "BUG: Could not find meta directory in our own archive", ) })?; if archive_identity != required_script_identity { return Err(std::io::ErrorKind::NotFound.into()); } for entry in tar::Archive::new(&mut &*archive_buf).entries()? { let mut entry = entry?; let path = entry.path()?; if path.to_str() == Some(META_DIR_NAME) || path.parent().and_then(|p| p.to_str()) == Some(META_DIR_NAME) { continue; } entry.unpack_in(&destination_dir)?; } Ok((archive_identity, platform))} /// Transform a verbose bom errors from raw bytes into a `BStr` to make printing/debugging human-readable.pub fn to_bstr_err(err: nom::Err<VerboseError<&[u8]>>) -> VerboseError<&BStr> { let err = match err { nom::Err::Error(err) | nom::Err::Failure(err) => err, nom::Err::Incomplete(_) => unreachable!("not a streaming parser"), }; VerboseError { errors: err.errors.into_iter().map(|(i, v)| (i.as_bstr(), v)).collect(), }} fn family_name() -> &'static str { if cfg!(windows) { "windows" } else { "unix" }} /// A utility to set environment variables, while unsetting them (or resetting them to their previous value) on drop.#[derive(Default)]pub struct Env<'a> { altered_vars: Vec<(&'a str, Option<OsString>)>,} impl<'a> Env<'a> { /// Create a new instance. pub fn new() -> Self { Env { altered_vars: Vec::new(), } } /// Set `var` to `value`. pub fn set(mut self, var: &'a str, value: impl Into<String>) -> Self { let prev = std::env::var_os(var); std::env::set_var(var, value.into()); self.altered_vars.push((var, prev)); self }} impl<'a> Drop for Env<'a> { fn drop(&mut self) { for (var, prev_value) in &self.altered_vars { match prev_value { Some(value) => std::env::set_var(var, value), None => std::env::remove_var(var), } } }}
//! Utilities for testing `gitoxide` crates, many of which might be useful for testing programs that use `git` in general.
#![deny(missing_docs)]
use std::{
collections::BTreeMap,
convert::Infallible,
ffi::OsString,
io::Read,
path::{Path, PathBuf},
time::Duration,
};
pub use bstr;
use bstr::{BStr, ByteSlice};
use io_close::Close;
pub use is_ci;
use nom::error::VerboseError;
pub use once_cell;
use once_cell::sync::Lazy;
use parking_lot::Mutex;
pub use tempfile;
/// A result type to allow using the try operator `?` in unit tests.
///
/// Use it like so:
///
/// ```no_run
/// use git_testtools::Result;
///
/// #[test]
/// fn this() -> Result {
/// let x: usize = "42".parse()?;
/// Ok(())
///
/// }
/// ```
pub type Result<T = ()> = std::result::Result<T, Box<dyn std::error::Error>>;
static SCRIPT_IDENTITY: Lazy<Mutex<BTreeMap<PathBuf, u32>>> = Lazy::new(|| Mutex::new(BTreeMap::new()));
static EXCLUDE_LUT: Lazy<Mutex<Option<git_worktree::fs::Cache<'static>>>> = Lazy::new(|| {
let cache = (|| {
let (repo_path, _) = git_discover::upwards(Path::new(".")).ok()?;
let (git_dir, work_tree) = repo_path.into_repository_and_work_tree_directories();
let work_tree = work_tree?.canonicalize().ok()?;
let mut buf = Vec::with_capacity(512);
let case = git_worktree::fs::Capabilities::probe(&work_tree)
.ignore_case
.then(|| git_attributes::glob::pattern::Case::Fold)
.unwrap_or_default();
let state = git_worktree::fs::cache::State::IgnoreStack(git_worktree::fs::cache::state::Ignore::new(
Default::default(),
git_attributes::MatchGroup::<git_attributes::Ignore>::from_git_dir(git_dir, None, &mut buf).ok()?,
None,
case,
));
Some(git_worktree::fs::Cache::new(
work_tree,
state,
case,
buf,
Default::default(),
))
})();
Mutex::new(cache)
});
/// Define how [scripted_fixture_repo_writable_with_args()] uses produces the writable copy.
pub enum Creation {
/// Run the script once and copy the data from its output to the writable location.
/// This is fast but won't work if absolute paths are produced by the script.
CopyFromReadOnly,
/// Run the script in the writable location. That way, absolute paths match the location.
ExecuteScript,
}
/// Run `git` in `working_dir` with all provided `args`.
pub fn run_git(working_dir: &Path, args: &[&str]) -> std::io::Result<std::process::ExitStatus> {
std::process::Command::new("git")
.current_dir(working_dir)
.args(args)
.status()
}
/// Convert a hexadecimal hash into its corresponding `ObjectId` or _panic_.
pub fn hex_to_id(hex: &str) -> git_hash::ObjectId {
git_hash::ObjectId::from_hex(hex.as_bytes()).expect("40 bytes hex")
}
/// Return the path to the `<crate-root>/tests/fixtures/<path>` directory.
pub fn fixture_path(path: impl AsRef<Path>) -> PathBuf {
PathBuf::from("tests").join("fixtures").join(path.as_ref())
}
/// Load the fixture from `<crate-root>/tests/fixtures/<path>` and return its data, or _panic_.
pub fn fixture_bytes(path: impl AsRef<Path>) -> Vec<u8> {
match std::fs::read(fixture_path(path.as_ref())) {
Ok(res) => res,
Err(_) => panic!("File at '{}' not found", path.as_ref().display()),
}
}
/// Run the executable at `script_name`, like `make_repo.sh` to produce a read-only directory to which
/// the path is returned.
///
/// Note that it persists and the script at `script_name` will only be executed once if it ran without error.
///
/// ### Automatic Archive Creation
///
/// In order to speed up CI and even local runs should the cache get purged, the result of each script run
/// is automatically placed into a compressed _tar_ archive.
/// If a script result doesn't exist, these will be checked first and extracted if present, which they are by default.
/// This behaviour can be prohibited by setting the `GITOXIDE_TEST_IGNORE_ARCHIVES` to any value.
///
/// To speed CI up, one can add these archives to the repository. It's absoutely recommended to use `git-lfs` for that to
/// not bloat the repository size.
///
/// #### Disable Archive Creation
///
/// If archives aren't useful, they can be disabled by using `.gitignore` specifications.
/// That way it's trivial to prevent creation of all archives with `generated-archives/*.tar.xz` in the root
/// or more specific `.gitignore` configurations in lower levels of the work tree.
///
/// The latter is useful if the the script's output is platform specific.
pub fn scripted_fixture_repo_read_only(script_name: impl AsRef<Path>) -> Result<PathBuf> {
scripted_fixture_repo_read_only_with_args(script_name, None)
}
/// Run the executable at `script_name`, like `make_repo.sh` to produce a writable directory to which
/// the tempdir is returned. It will be removed automatically, courtesy of [`tempfile::TempDir`].
///
/// Note that `script_name` is only executed once, so the data can be copied from its read-only location.
pub fn scripted_fixture_repo_writable(script_name: &str) -> Result<tempfile::TempDir> {
scripted_fixture_repo_writable_with_args(script_name, None, Creation::CopyFromReadOnly)
}
/// Like [`scripted_fixture_repo_writable()`], but passes `args` to `script_name` while providing control over
/// the way files are created with `mode`.
pub fn scripted_fixture_repo_writable_with_args(
script_name: &str,
args: impl IntoIterator<Item = &'static str>,
mode: Creation,
) -> Result<tempfile::TempDir> {
let dst = tempfile::TempDir::new()?;
Ok(match mode {
Creation::CopyFromReadOnly => {
let ro_dir = scripted_fixture_repo_read_only_with_args_inner(script_name, args, None)?;
copy_recursively_into_existing_dir(&ro_dir, dst.path())?;
dst
}
Creation::ExecuteScript => {
scripted_fixture_repo_read_only_with_args_inner(script_name, args, dst.path().into())?;
dst
}
})
}
/// A utility to copy the entire contents of `src_dir` into `dst_dir`.
pub fn copy_recursively_into_existing_dir(src_dir: impl AsRef<Path>, dst_dir: impl AsRef<Path>) -> std::io::Result<()> {
fs_extra::copy_items(
&std::fs::read_dir(src_dir)?
.map(|e| e.map(|e| e.path()))
.collect::<std::result::Result<Vec<_>, _>>()?,
dst_dir,
&fs_extra::dir::CopyOptions {
overwrite: false,
skip_exist: false,
copy_inside: false,
content_only: false,
..Default::default()
},
)
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))?;
Ok(())
}
/// Like `scripted_fixture_repo_read_only()`], but passes `args` to `script_name`.
pub fn scripted_fixture_repo_read_only_with_args(
script_name: impl AsRef<Path>,
args: impl IntoIterator<Item = &'static str>,
) -> Result<PathBuf> {
scripted_fixture_repo_read_only_with_args_inner(script_name, args, None)
}
fn scripted_fixture_repo_read_only_with_args_inner(
script_name: impl AsRef<Path>,
args: impl IntoIterator<Item = &'static str>,
destination_dir: Option<&Path>,
) -> Result<PathBuf> {
// Assure tempfiles get removed when aborting the test.
git_lock::tempfile::setup(
git_lock::tempfile::SignalHandlerMode::DeleteTempfilesOnTerminationAndRestoreDefaultBehaviour,
);
let script_location = script_name.as_ref();
let script_path = fixture_path(script_location);
// keep this lock to assure we don't return unfinished directories for threaded callers
let args: Vec<String> = args.into_iter().map(Into::into).collect();
let mut map = SCRIPT_IDENTITY.lock();
let script_identity = map
.entry(args.iter().fold(script_path.clone(), |p, a| p.join(a)))
.or_insert_with(|| {
let crc_value = crc::Crc::<u32>::new(&crc::CRC_32_CKSUM);
let mut crc_digest = crc_value.digest();
crc_digest.update(&std::fs::read(&script_path).unwrap_or_else(|err| {
panic!(
"file {script_path:?} in CWD {:?} could not be read: {err}",
std::env::current_dir().expect("valid cwd"),
)
}));
for arg in args.iter() {
crc_digest.update(arg.as_bytes());
}
crc_digest.finalize()
})
.to_owned();
let script_basename = script_location.file_stem().unwrap_or(script_location.as_os_str());
let archive_file_path = fixture_path(
Path::new("generated-archives").join(format!("{}.tar.xz", script_basename.to_str().expect("valid UTF-8"))),
);
let (force_run, script_result_directory) = destination_dir.map(|d| (true, d.to_owned())).unwrap_or_else(|| {
let dir = fixture_path(Path::new("generated-do-not-edit").join(script_basename).join(format!(
"{}-{}",
script_identity,
family_name()
)));
(false, dir)
});
let _marker = git_lock::Marker::acquire_to_hold_resource(
script_basename,
git_lock::acquire::Fail::AfterDurationWithBackoff(Duration::from_secs(if cfg!(windows) { 3 * 60 } else { 30 })),
None,
)?;
let failure_marker = script_result_directory.join("_invalid_state_due_to_script_failure_");
if force_run || !script_result_directory.is_dir() || failure_marker.is_file() {
if failure_marker.is_file() {
std::fs::remove_dir_all(&script_result_directory)?;
}
std::fs::create_dir_all(&script_result_directory)?;
match extract_archive(&archive_file_path, &script_result_directory, script_identity) {
Ok((archive_id, platform)) => {
eprintln!(
"Extracted fixture from archive '{}' ({}, {:?})",
archive_file_path.display(),
archive_id,
platform
)
}
Err(err) => {
if err.kind() != std::io::ErrorKind::NotFound {
eprintln!("failed to extract '{}': {}", archive_file_path.display(), err);
} else if !is_excluded(&archive_file_path) {
eprintln!(
"Archive at '{}' not found, creating fixture using script '{}'",
archive_file_path.display(),
script_location.display()
);
}
let script_absolute_path = std::env::current_dir()?.join(script_path);
let output = std::process::Command::new("bash")
.arg(script_absolute_path)
.args(args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.current_dir(&script_result_directory)
.env_remove("GIT_DIR")
.env("GIT_AUTHOR_DATE", "2000-01-01 00:00:00 +0000")
.env("GIT_AUTHOR_EMAIL", "author@example.com")
.env("GIT_AUTHOR_NAME", "author")
.env("GIT_COMMITTER_DATE", "2000-01-02 00:00:00 +0000")
.env("GIT_COMMITTER_EMAIL", "committer@example.com")
.env("GIT_COMMITTER_NAME", "committer")
.env("GIT_CONFIG_COUNT", "2")
.env("GIT_CONFIG_KEY_0", "commit.gpgsign")
.env("GIT_CONFIG_VALUE_0", "false")
.env("GIT_CONFIG_KEY_1", "init.defaultBranch")
.env("GIT_CONFIG_VALUE_1", "main")
.output()?;
if !output.status.success() {
write_failure_marker(&failure_marker);
}
assert!(
output.status.success(),
"repo script failed: stdout: {}\nstderr: {}",
output.stdout.as_bstr(),
output.stderr.as_bstr()
);
create_archive_if_not_on_ci(&script_result_directory, &archive_file_path, script_identity).map_err(
|err| {
write_failure_marker(&failure_marker);
err
},
)?;
}
}
}
Ok(script_result_directory)
}
fn write_failure_marker(failure_marker: &Path) {
std::fs::write(failure_marker, &[]).ok();
}
/// The `script_identity` will be baked into the soon to be created `archive` as it identitifies the script
/// that created the contents of `source_dir`.
fn create_archive_if_not_on_ci(source_dir: &Path, archive: &Path, script_identity: u32) -> std::io::Result<()> {
if is_ci::cached() {
return Ok(());
}
if is_excluded(archive) {
return Ok(());
}
std::fs::create_dir_all(archive.parent().expect("archive is a file"))?;
let meta_dir = populate_meta_dir(source_dir, script_identity)?;
let res = (move || {
let mut buf = Vec::<u8>::new();
{
let mut ar = tar::Builder::new(&mut buf);
ar.mode(tar::HeaderMode::Deterministic);
ar.follow_symlinks(false);
ar.append_dir_all(".", source_dir)?;
ar.finish()?;
}
let archive = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(archive)?;
let mut xz_write = xz2::write::XzEncoder::new(archive, 3);
std::io::copy(&mut &*buf, &mut xz_write)?;
xz_write.finish()?.close()
})();
#[cfg(not(windows))]
std::fs::remove_dir_all(meta_dir)?;
#[cfg(windows)]
std::fs::remove_dir_all(meta_dir).ok(); // it really can't delete these directories for some reason (even after 10 seconds)
res
}
fn is_excluded(archive: &Path) -> bool {
let mut lut = EXCLUDE_LUT.lock();
lut.as_mut()
.and_then(|cache| {
let archive = std::env::current_dir().ok()?.join(archive);
let relative_path = archive.strip_prefix(cache.base()).ok()?;
cache
.at_path(
relative_path,
Some(false),
|_oid, _buf| -> std::result::Result<_, Infallible> { unreachable!("") },
)
.ok()?
.is_excluded()
.into()
})
.unwrap_or(false)
}
const META_DIR_NAME: &str = "__gitoxide_meta__";
const META_IDENTITY: &str = "identity";
const META_GIT_VERSION: &str = "git-version";
fn populate_meta_dir(destination_dir: &Path, script_identity: u32) -> std::io::Result<PathBuf> {
let meta_dir = destination_dir.join(META_DIR_NAME);
std::fs::create_dir_all(&meta_dir)?;
std::fs::write(
meta_dir.join(META_IDENTITY),
format!("{}-{}", script_identity, family_name()).as_bytes(),
)?;
std::fs::write(
meta_dir.join(META_GIT_VERSION),
&std::process::Command::new("git").arg("--version").output()?.stdout,
)?;
Ok(meta_dir)
}
/// `required_script_identity` is the identity of the script that generated the state that is contained in `archive`.
/// If this is not the case, the arvhive will be ignored.
fn extract_archive(
archive: &Path,
destination_dir: &Path,
required_script_identity: u32,
) -> std::io::Result<(u32, Option<String>)> {
let archive_buf: Vec<u8> = {
let mut buf = Vec::new();
let input_archive = std::fs::File::open(archive)?;
if std::env::var_os("GITOXIDE_TEST_IGNORE_ARCHIVES").is_some() {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
format!(
"Ignoring archive at '{}' as GITOXIDE_TEST_IGNORE_ARCHIVES is set.",
archive.display()
),
));
}
let mut decoder = xz2::bufread::XzDecoder::new(std::io::BufReader::new(input_archive));
std::io::copy(&mut decoder, &mut buf)?;
buf
};
let mut entry_buf = Vec::<u8>::new();
let (archive_identity, platform): (u32, _) = tar::Archive::new(std::io::Cursor::new(&mut &*archive_buf))
.entries_with_seek()?
.filter_map(|e| e.ok())
.find_map(|mut e: tar::Entry<'_, _>| {
let path = e.path().ok()?;
if path.parent()?.file_name()? == META_DIR_NAME && path.file_name()? == META_IDENTITY {
entry_buf.clear();
e.read_to_end(&mut entry_buf).ok()?;
let mut tokens = entry_buf.to_str().ok()?.trim().splitn(2, '-');
match (tokens.next(), tokens.next()) {
(Some(id), platform) => Some((id.parse().ok()?, platform.map(ToOwned::to_owned))),
_ => None,
}
} else {
None
}
})
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::Other,
"BUG: Could not find meta directory in our own archive",
)
})?;
if archive_identity != required_script_identity {
return Err(std::io::ErrorKind::NotFound.into());
}
for entry in tar::Archive::new(&mut &*archive_buf).entries()? {
let mut entry = entry?;
let path = entry.path()?;
if path.to_str() == Some(META_DIR_NAME) || path.parent().and_then(|p| p.to_str()) == Some(META_DIR_NAME) {
continue;
}
entry.unpack_in(&destination_dir)?;
}
Ok((archive_identity, platform))
}
/// Transform a verbose bom errors from raw bytes into a `BStr` to make printing/debugging human-readable.
pub fn to_bstr_err(err: nom::Err<VerboseError<&[u8]>>) -> VerboseError<&BStr> {
let err = match err {
nom::Err::Error(err) | nom::Err::Failure(err) => err,
nom::Err::Incomplete(_) => unreachable!("not a streaming parser"),
};
VerboseError {
errors: err.errors.into_iter().map(|(i, v)| (i.as_bstr(), v)).collect(),
}
}
fn family_name() -> &'static str {
if cfg!(windows) {
"windows"
} else {
"unix"
}
}
/// A utility to set environment variables, while unsetting them (or resetting them to their previous value) on drop.
#[derive(Default)]
pub struct Env<'a> {
altered_vars: Vec<(&'a str, Option<OsString>)>,
}
impl<'a> Env<'a> {
/// Create a new instance.
pub fn new() -> Self {
Env {
altered_vars: Vec::new(),
}
}
/// Set `var` to `value`.
pub fn set(mut self, var: &'a str, value: impl Into<String>) -> Self {
let prev = std::env::var_os(var);
std::env::set_var(var, value.into());
self.altered_vars.push((var, prev));
self
}
}
impl<'a> Drop for Env<'a> {
fn drop(&mut self) {
for (var, prev_value) in &self.altered_vars {
match prev_value {
Some(value) => std::env::set_var(var, value),
None => std::env::remove_var(var),
}
}
}
}