//! Utilities for testing `gitoxide` crates, many of which might be useful for testing programs that use `git` in general. //! //! ## Environment Variables //! //! ### `GIX_TEST_FIXTURE_HASH` //! //! Set this variable to control which hash function is used when creating or loading test fixtures. //! Valid values are the names of hash functions supported by `gix_hash::Kind` (e.g., `sha1`, `sha256`). //! If not set, the default hash function via `gix_hash::Kind::default()` is used. //! //! ## Feature Flags #![cfg_attr( all(doc, feature = "document-features"), doc = ::document_features::document_features!() )] #![cfg_attr(all(doc, feature = "document-features"), feature(doc_cfg))] #![deny(missing_docs)] use std::{ collections::{BTreeMap, HashMap}, env, ffi::{OsStr, OsString}, io::Read, path::{Path, PathBuf}, str::FromStr, time::Duration, }; pub use bstr; use bstr::ByteSlice; use io_close::Close; pub use is_ci; use parking_lot::Mutex; use std::sync::LazyLock; pub use tempfile; const ARCHIVE_DIR_NAME: &str = "generated-archives"; /// A result type to allow using the try operator `?` in unit tests. /// /// Use it like so: /// /// ```no_run /// use gix_testtools::Result; /// /// #[test] /// fn this() -> Result { /// let x: usize = "42".parse()?; /// Ok(()) /// /// } /// ``` pub type Result = std::result::Result>; /// A result type for post-processing closures in `*_with_post` fixture functions. /// /// The closure can return any value `T`, which will be returned alongside the fixture path. /// This is useful for computing values based on the fixture contents. pub type PostResult = std::result::Result>; /// Build `example` from `package` and copy the executable to this test process' temporary target directory. /// /// The returned executable path is stable for the lifetime of the test process and avoids races with other /// concurrently running tests that may cause Cargo to update the shared example binary in `target/debug/examples`. pub fn build_example_for_test(package: &str, example: &str, target_tmpdir: impl Into) -> PathBuf { let mut cargo = std::process::Command::new(env::var_os("CARGO").unwrap_or_else(|| OsString::from(env!("CARGO")))); let res = cargo .args(["build", "-p", package, "--example", example]) .status() .expect("cargo should run fine"); assert!(res.success(), "cargo invocation should be successful"); let target_tmpdir = target_tmpdir.into(); let shared_path = target_tmpdir .ancestors() .nth(1) .expect("first parent in target dir") .join("debug") .join("examples") .join(format!("{example}{}", std::env::consts::EXE_SUFFIX)); let stable_path = target_tmpdir.join(format!( "{example}-{}{}", std::process::id(), std::env::consts::EXE_SUFFIX )); let mut last_err = None; for _ in 0..10 { match std::fs::copy(&shared_path, &stable_path) { Ok(_) => return stable_path, Err(err) => { last_err = Some(err); std::thread::sleep(Duration::from_millis(50)); } } } panic!( "driver at {} could be copied for stable test execution: {last_err:?}", shared_path.display() ); } /// Indicates the state of a fixture when a closure is called. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FixtureState<'a> { /// The fixture is newly created and needs post-processing. /// /// The closure should perform any necessary modifications to the fixture /// directory and compute its return value. Uninitialized(&'a Path), /// The fixture was already created (cached) and only needs to produce a return value. /// /// The closure should NOT modify the fixture directory, but only compute /// and return a value based on the existing contents. Fresh(&'a Path), } impl FixtureState<'_> { /// Returns the path of the fixture, which is always a directory. pub fn path(&self) -> &Path { match self { FixtureState::Uninitialized(path) | FixtureState::Fresh(path) => path, } } /// Returns true if the fixture is uninitialized and needs to be modified. pub fn is_uninitialized(&self) -> bool { matches!(self, FixtureState::Uninitialized(_)) } } /// Determines whether fixture generation should skip creating, updating, or overwriting a cached fixture archive. /// /// In this module, an archive is the tar file under [`ARCHIVE_DIR_NAME`] that stores the output of a fixture /// script or Rust fixture closure. The read-only fixture helpers unpack that archive when it already exists, and /// [`create_archive_if_we_should()`] consults this trait before writing a new archive from the generated fixture /// directory. trait IsExcluded { /// Return true if `archive` matches the configured exclusion source. fn is_excluded(&self, archive: &Path) -> bool; } /// Checks whether `archive` matches `.gitignore`-style lines read by [`GitignoreExclusions`]. /// /// This is the fallback used when the `worktree-exclusions` feature is disabled, so the full `gix-worktree` /// exclusion stack is not available. In that configuration, [`GitignoreExclusions::is_excluded()`] reads the /// `.gitignore` next to the generated archive and delegates the line matching to this function. #[cfg(not(feature = "worktree-exclusions"))] fn is_excluded_by_lines(lines: &str, archive: &Path) -> bool { let archive = archive.to_string_lossy().replace('\\', "/"); let filename = archive.rsplit('/').next().unwrap_or(&archive); lines.lines().any(|line| { let pattern = line.trim(); if pattern.is_empty() || pattern.starts_with('#') { return false; } let pattern = pattern.trim_start_matches('/'); let candidate = if pattern.contains('/') { archive.as_str() } else { filename }; wildcard_match(pattern, candidate) }) } /// Matches `text` against the fallback exclusion pattern syntax. /// /// The matcher understands literal characters and `*`, where `*` matches any byte sequence, including path /// separators. Patterns are anchored to the full `text`; other `.gitignore` features such as `?`, character /// classes, directory-only matches, and negation are not supported here. /// /// For example, `*.tar` matches `fixture.tar`, `generated-archives/*.tar` matches /// `generated-archives/fixture.tar`, and `generated-*/*.tar` matches `generated-archives/fixture.tar`. #[cfg(not(feature = "worktree-exclusions"))] fn wildcard_match(pattern: &str, text: &str) -> bool { if !pattern.contains('*') { return pattern == text; } let mut remainder = text; let mut parts = pattern.split('*').peekable(); let first = parts.next().expect("split yields at least one item"); if !first.is_empty() { let Some(stripped) = remainder.strip_prefix(first) else { return false; }; remainder = stripped; } while let Some(part) = parts.next() { if part.is_empty() { continue; } let Some(pos) = remainder.find(part) else { return false; }; remainder = &remainder[pos + part.len()..]; if parts.peek().is_none() && !pattern.ends_with('*') { return remainder.is_empty(); } } pattern.ends_with('*') || remainder.is_empty() } /// A wrapper for a running git-daemon which is stopped automatically on drop. /// /// Note that we will swallow any errors, assuming that the test would have failed if the daemon crashed. pub struct GitDaemon { process: GitDaemonProcess, /// The base url under which all repositories are hosted, typically `git://127.0.0.1:port`. pub url: String, } enum GitDaemonProcess { #[cfg(not(unix))] Child(std::process::Child), #[cfg(unix)] Inetd { shutdown: std::sync::Arc, server_addr: std::net::SocketAddr, listener_thread: Option>, }, } impl Drop for GitDaemon { fn drop(&mut self) { match &mut self.process { #[cfg(not(unix))] GitDaemonProcess::Child(child) => { child.kill().ok(); } #[cfg(unix)] GitDaemonProcess::Inetd { shutdown, server_addr, listener_thread, } => { shutdown.store(true, std::sync::atomic::Ordering::SeqCst); std::net::TcpStream::connect(*server_addr).ok(); if let Some(listener_thread) = listener_thread.take() { listener_thread.join().ok(); } } } } } static SCRIPT_IDENTITY: LazyLock>> = LazyLock::new(|| Mutex::new(BTreeMap::new())); #[cfg(feature = "worktree-exclusions")] static EXCLUDE_LUT: LazyLock>> = LazyLock::new(|| { let cache = (|| { let (repo_path, _) = gix_discover::upwards(Path::new(".")).ok()?; let (gix_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 = if gix_fs::Capabilities::probe(&work_tree).ignore_case { gix_worktree::ignore::glob::pattern::Case::Fold } else { Default::default() }; let state = gix_worktree::stack::State::IgnoreStack(gix_worktree::stack::state::Ignore::new( Default::default(), gix_worktree::ignore::Search::from_git_dir( &gix_dir, None, &mut buf, gix_worktree::stack::state::ignore::ParseIgnore { support_precious: false, }, ) .ok()?, None, gix_worktree::stack::state::ignore::Source::WorktreeThenIdMappingIfNotSkipped, Default::default(), )); Some(gix_worktree::Stack::new( work_tree, state, case, buf, Default::default(), )) })(); Mutex::new(cache) }); #[cfg(feature = "worktree-exclusions")] struct WorktreeExclusions; #[cfg(feature = "worktree-exclusions")] impl IsExcluded for WorktreeExclusions { fn is_excluded(&self, archive: &Path) -> bool { let mut lut = EXCLUDE_LUT.lock(); lut.as_mut() .and_then(|cache| { let archive = env::current_dir().ok()?.join(archive); let relative_path = archive.strip_prefix(cache.base()).ok()?; cache .at_path( relative_path, Some(gix_worktree::index::entry::Mode::FILE), &gix_worktree::object::find::Never, ) .ok()? .is_excluded() .into() }) .unwrap_or(false) } } #[cfg(feature = "worktree-exclusions")] fn default_excludes() -> &'static dyn IsExcluded { static WORKTREE_EXCLUSIONS: WorktreeExclusions = WorktreeExclusions; &WORKTREE_EXCLUSIONS } #[cfg(not(feature = "worktree-exclusions"))] struct GitignoreExclusions; #[cfg(not(feature = "worktree-exclusions"))] impl IsExcluded for GitignoreExclusions { fn is_excluded(&self, archive: &Path) -> bool { let Some(parent) = archive.parent() else { return false; }; std::fs::read_to_string(parent.join(".gitignore")).is_ok_and(|lines| is_excluded_by_lines(&lines, archive)) } } #[cfg(not(feature = "worktree-exclusions"))] fn default_excludes() -> &'static dyn IsExcluded { static GITIGNORE_EXCLUSIONS: GitignoreExclusions = GitignoreExclusions; &GITIGNORE_EXCLUSIONS } #[cfg(windows)] const GIT_PROGRAM: &str = "git.exe"; #[cfg(not(windows))] const GIT_PROGRAM: &str = "git"; const DISABLE_AUTO_MAINTENANCE_CONFIG: &[(&str, &str)] = &[("maintenance.auto", "false"), ("gc.auto", "0")]; const ISOLATED_GIT_CONFIG: &[(&str, &str)] = &[ ("commit.gpgsign", "false"), ("tag.gpgsign", "false"), ("init.defaultBranch", "main"), ("protocol.file.allow", "always"), ("maintenance.auto", "false"), ("gc.auto", "0"), ]; static GIT_CORE_DIR: LazyLock = LazyLock::new(|| { let output = std::process::Command::new(GIT_PROGRAM) .arg("--exec-path") .output() .expect("can execute `git --exec-path`"); assert!(output.status.success(), "`git --exec-path` failed"); output .stdout .strip_suffix(b"\n") .expect("`git --exec-path` output to be well-formed") .to_os_str() .expect("no invalid UTF-8 in `--exec-path` except as OS allows") .into() }); /// The major, minor and patch level of the git version on the system. pub static GIT_VERSION: LazyLock<(u8, u8, u8)> = LazyLock::new(|| parse_git_version().expect("git version to be parsable")); /// Define how [`scripted_fixture_writable_with_args()`] and [`rust_fixture_writable()`] /// produces the writable copy. pub enum Creation { /// Run the code 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. /// /// ### Limitation /// /// Cannot handle symlinks currently. Waiting for [this PR](https://github.com/webdesus/fs_extra/pull/70). CopyFromReadOnly, /// Run the code in the writable location. That way, absolute paths match the location. Execute, } /// Returns true if the given `major`, `minor` and `patch` is smaller than the actual git version on the system /// to facilitate skipping a test on the caller. /// Will never return true on CI which is expected to have a recent enough git version. /// /// # Panics /// /// If `git` cannot be executed or if its version output cannot be parsed. pub fn should_skip_as_git_version_is_smaller_than(major: u8, minor: u8, patch: u8) -> bool { if is_ci::cached() { return false; // CI should be made to use a recent git version, it should run there. } *GIT_VERSION < (major, minor, patch) } fn parse_git_version() -> Result<(u8, u8, u8)> { let output = std::process::Command::new(GIT_PROGRAM).arg("--version").output()?; git_version_from_bytes(&output.stdout) } fn git_version_from_bytes(bytes: &[u8]) -> Result<(u8, u8, u8)> { let mut numbers = bytes .split(|b| *b == b' ' || *b == b'\n') .nth(2) .expect("git version ") .split(|b| *b == b'.') .take(3) .map(|n| std::str::from_utf8(n).expect("valid utf8 in version number")) .map(u8::from_str); Ok((|| -> Result<_> { Ok(( numbers.next().expect("major")?, numbers.next().expect("minor")?, numbers.next().expect("patch")?, )) })() .map_err(|err| { format!( "Could not parse version from output of 'git --version' ({:?}) with error: {}", bytes.to_str_lossy(), err ) })?) } /// Set the current working dir to `new_cwd` and return a type that returns to the previous working dir on drop. pub fn set_current_dir(new_cwd: impl AsRef) -> std::io::Result { let cwd = env::current_dir()?; env::set_current_dir(new_cwd)?; Ok(AutoRevertToPreviousCWD(cwd)) } /// A utility to set the current working dir to the given value, on drop. /// /// # Panics /// /// Note that this will panic if the CWD cannot be set on drop. #[derive(Debug)] #[must_use] pub struct AutoRevertToPreviousCWD(PathBuf); impl Drop for AutoRevertToPreviousCWD { fn drop(&mut self) { env::set_current_dir(&self.0).unwrap(); } } /// Run `git` in `working_dir` with all provided `args`. pub fn run_git(working_dir: &Path, args: &[&str]) -> std::io::Result { let mut cmd = std::process::Command::new(GIT_PROGRAM); apply_git_config_by_environment(&mut cmd, DISABLE_AUTO_MAINTENANCE_CONFIG) .current_dir(working_dir) .args(args) .status() } /// Run `script` with [`bash_program()`] in `cwd`. /// /// Standard input is disconnected while standard output and error stay attached to the inherited /// handles. /// /// # Panics /// /// This function expects the script to succeed and will panic otherwise. pub fn invoke_bash(cwd: impl AsRef, script: &str) { let mut cmd = std::process::Command::new(bash_program()); let status = apply_git_config_by_environment(&mut cmd, DISABLE_AUTO_MAINTENANCE_CONFIG) .current_dir(cwd) .arg("-c") .arg(script) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) .status() .expect("can run bash script"); assert!(status.success(), "bash script failed with {status}"); } /// Spawn a git daemon to host all repositories at or below `working_dir`. /// /// It runs in the background until the [`GitDaemon`] is dropped. pub fn spawn_git_daemon(working_dir: impl AsRef) -> std::io::Result { #[cfg(unix)] { spawn_git_daemon_inetd(working_dir) } #[cfg(not(unix))] { spawn_git_daemon_process(working_dir) } } #[cfg(not(unix))] fn spawn_git_daemon_process(working_dir: impl AsRef) -> std::io::Result { let mut ports: Vec<_> = (9419u16..9419 + 100).collect(); fastrand::shuffle(&mut ports); let addr_at = |port| std::net::SocketAddr::from(([127, 0, 0, 1], port)); let free_port = { let listener = std::net::TcpListener::bind(ports.into_iter().map(addr_at).collect::>().as_slice())?; listener.local_addr().expect("listener address is available").port() }; let child = { let mut cmd = std::process::Command::new(GIT_CORE_DIR.join(if cfg!(windows) { "git-daemon.exe" } else { "git-daemon" })); apply_git_config_by_environment(&mut cmd, DISABLE_AUTO_MAINTENANCE_CONFIG) .current_dir(working_dir) .args(["--verbose", "--base-path=.", "--export-all", "--user-path"]) .arg(format!("--port={free_port}")) .spawn()? }; let server_addr = addr_at(free_port); for time in gix_lock::backoff::Quadratic::default_with_random() { std::thread::sleep(time); if std::net::TcpStream::connect(server_addr).is_ok() { break; } } Ok(GitDaemon { process: GitDaemonProcess::Child(child), url: format!("git://{server_addr}"), }) } #[cfg(unix)] fn spawn_git_daemon_inetd(working_dir: impl AsRef) -> std::io::Result { use std::{ net::{TcpListener, TcpStream}, os::fd::{FromRawFd, IntoRawFd}, process::Stdio, sync::{ Arc, atomic::{AtomicBool, Ordering}, }, }; fn stream_to_stdio(stream: TcpStream) -> Stdio { // SAFETY: `into_raw_fd()` transfers ownership of the socket fd, and `Stdio` // takes over closing it in the spawned child. unsafe { Stdio::from_raw_fd(stream.into_raw_fd()) } } let working_dir = working_dir.as_ref().to_owned(); let listener = TcpListener::bind(("127.0.0.1", 0))?; let server_addr = listener.local_addr()?; let shutdown = Arc::new(AtomicBool::new(false)); let listener_thread = std::thread::spawn({ let shutdown = shutdown.clone(); move || { for incoming in listener.incoming() { let stream = match incoming { Ok(stream) => stream, Err(_) => break, }; if shutdown.load(Ordering::SeqCst) { break; } let peer_addr = stream.peer_addr().ok(); let stdin = match stream.try_clone() { Ok(stream) => stream_to_stdio(stream), Err(_) => continue, }; let stdout = stream_to_stdio(stream); let mut cmd = std::process::Command::new(GIT_PROGRAM); let Ok(mut child) = apply_git_config_by_environment(&mut cmd, DISABLE_AUTO_MAINTENANCE_CONFIG) .args([ "-c", "uploadpack.allowrefinwant", "daemon", "--inetd", "--verbose", "--base-path=.", "--export-all", "--user-path", ]) .current_dir(&working_dir) .stdin(stdin) .stdout(stdout) .stderr(Stdio::null()) .envs(remote_env(peer_addr)) .spawn() else { continue; }; std::thread::spawn(move || { let _ = child.wait(); }); } } }); Ok(GitDaemon { process: GitDaemonProcess::Inetd { shutdown, server_addr, listener_thread: Some(listener_thread), }, url: format!("git://{server_addr}"), }) } #[cfg(unix)] fn remote_env(peer_addr: Option) -> Vec<(&'static str, String)> { peer_addr .map(|addr| { vec![ ("REMOTE_ADDR", addr.ip().to_string()), ("REMOTE_PORT", addr.port().to_string()), ] }) .unwrap_or_default() } /// Don't add a suffix to the archive name as `args` are platform dependent, non-deterministic, /// or otherwise don't influence the content of the archive. /// Note that this also means that `args` won't be used to control the hash of the archive itself. #[derive(Copy, Clone)] enum ArgsInHash { Yes, No, } /// Return the path to the `/tests/fixtures/` directory. pub fn fixture_path(path: impl AsRef) -> PathBuf { fixture_base().join(path.as_ref()) } fn fixture_base() -> PathBuf { PathBuf::from("tests").join("fixtures") } /// Load the fixture from `/tests/fixtures/` and return its data, or _panic_. pub fn fixture_bytes(path: impl AsRef) -> Vec { 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` or `my_setup.py` 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 `GIX_TEST_IGNORE_ARCHIVES` to any value. /// /// To speed CI up, one can add these archives to the repository. Since LFS is not currently being used, it is /// important to check their size first, though in most cases generated archives will not be very large. /// /// #### 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 script's output is platform specific. pub fn scripted_fixture_read_only(script_name: impl AsRef) -> Result { scripted_fixture_read_only_with_args(script_name, None::) } /// Like [`scripted_fixture_read_only()`], but uses a matching existing archive even if /// `GIX_TEST_IGNORE_ARCHIVES` is set. /// /// Use this only for fixtures whose generated contents are not stable across /// platforms or filesystems and must therefore be frozen by the checked-in /// archive. /// /// CI normally sets `GIX_TEST_IGNORE_ARCHIVES` so fixture scripts are rerun and /// tracked archives are proven reproducible. This helper is the opt-out for /// fixtures where rerunning the producer can legitimately change /// without changing semantics, for example when Git writes entries in filesystem /// traversal order. pub fn scripted_fixture_read_only_needs_archive(script_name: impl AsRef) -> Result { scripted_fixture_read_only_with_args_inner::) -> PostResult, ()>( script_name, None::, None, ArgsInHash::Yes, default_excludes(), None::<(u32, _)>, true, ) .map(|(dir, _)| dir) } /// 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_writable(script_name: impl AsRef) -> Result { scripted_fixture_writable_with_args(script_name, None::, Creation::CopyFromReadOnly) } /// Like [`scripted_fixture_writable()`], but passes `args` to `script_name` while providing control over /// the way files are created with `mode`. pub fn scripted_fixture_writable_with_args( script_name: impl AsRef, args: impl IntoIterator>, mode: Creation, ) -> Result { scripted_fixture_writable_with_args_inner::) -> PostResult, ()>( script_name, args, mode, ArgsInHash::Yes, default_excludes(), None::<(u32, _)>, ) .map(|(dir, _)| dir) } /// Like [`scripted_fixture_writable()`], but passes `args` to `script_name` while providing control over /// the way files are created with `mode`. /// /// See [`scripted_fixture_read_only_with_args_single_archive()`] for important details on what `single_archive` means. pub fn scripted_fixture_writable_with_args_single_archive( script_name: impl AsRef, args: impl IntoIterator>, mode: Creation, ) -> Result { scripted_fixture_writable_with_args_inner::) -> PostResult, ()>( script_name, args, mode, ArgsInHash::No, default_excludes(), None::<(u32, _)>, ) .map(|(dir, _)| dir) } fn scripted_fixture_writable_with_args_inner( script_name: impl AsRef, args: impl IntoIterator>, mode: Creation, args_in_hash: ArgsInHash, excludes: &dyn IsExcluded, mut post_process: Option<(u32, F)>, ) -> Result<(tempfile::TempDir, Option)> where F: FnMut(FixtureState<'_>) -> PostResult, { let dst = tempfile::TempDir::new()?; Ok(match mode { Creation::CopyFromReadOnly => { // Create the read-only fixture with post_process (modifications are cached) let (ro_dir, _res_ignored) = scripted_fixture_read_only_with_args_inner( script_name, args, None, args_in_hash, excludes, post_process.as_mut().map(|(v, f)| (*v, f)), false, )?; copy_recursively_into_existing_dir(ro_dir, dst.path())?; (dst, _res_ignored) } Creation::Execute => { // Execute directly in the temp dir with post_process let (_, post_result) = scripted_fixture_read_only_with_args_inner( script_name, args, dst.path().into(), args_in_hash, excludes, post_process.as_mut().map(|(v, f)| (*v, f)), false, )?; (dst, post_result) } }) } /// A utility to copy the entire contents of `src_dir` into `dst_dir`. pub fn copy_recursively_into_existing_dir(src_dir: impl AsRef, dst_dir: impl AsRef) -> std::io::Result<()> { fs_extra::copy_items( &std::fs::read_dir(src_dir)? .map(|e| e.map(|e| e.path())) .collect::, _>>()?, dst_dir, &fs_extra::dir::CopyOptions { overwrite: false, skip_exist: false, copy_inside: false, content_only: false, ..Default::default() }, ) .map_err(std::io::Error::other)?; Ok(()) } /// Like [`scripted_fixture_read_only()`], but passes `args` to `script_name`. pub fn scripted_fixture_read_only_with_args( script_name: impl AsRef, args: impl IntoIterator>, ) -> Result { scripted_fixture_read_only_with_args_inner::) -> PostResult, ()>( script_name, args, None, ArgsInHash::Yes, default_excludes(), None::<(u32, _)>, false, ) .map(|(dir, _)| dir) } /// Like `scripted_fixture_read_only()`], but passes `args` to `script_name`. /// /// Also, don't add a suffix to the archive name as `args` are platform dependent, none-deterministic, /// or otherwise don't influence the content of the archive. /// Note that this also means that `args` won't be used to control the hash of the archive itself. /// /// Sometimes, this should be combined with adding the archive name to `.gitignore` to prevent its creation /// in the first place. /// /// Note that suffixing archives by default helps to learn what calls are made, and forces the author to /// think about what should be done to get it right. pub fn scripted_fixture_read_only_with_args_single_archive( script_name: impl AsRef, args: impl IntoIterator>, ) -> Result { scripted_fixture_read_only_with_args_inner::) -> PostResult, ()>( script_name, args, None, ArgsInHash::No, default_excludes(), None::<(u32, _)>, false, ) .map(|(dir, _)| dir) } /// Like [`scripted_fixture_read_only`], but runs a Rust closure after the script completes. /// /// - `version` should be incremented when the closure's behavior changes to invalidate the cache. /// - The closure receives a [`FixtureState`] enum indicating whether the fixture is newly created /// or was loaded from cache. /// - For uninitialized fixtures, the closure can modify the directory and compute values. /// - For fresh fixtures, the closure should only compute values without modifications. /// - The closure always runs, ensuring the returned value is always available. pub fn scripted_fixture_read_only_with_post( script_name: impl AsRef, version: u32, post_process: impl FnMut(FixtureState<'_>) -> PostResult, ) -> Result<(PathBuf, T)> { scripted_fixture_read_only_with_args_inner( script_name, None::, None, ArgsInHash::Yes, default_excludes(), Some((version, post_process)), false, ) .map(|(path, opt)| (path, opt.expect("post_process was provided"))) } /// Like [`scripted_fixture_read_only_with_args`], but runs a Rust closure after the script completes. /// /// See [`scripted_fixture_read_only_with_post`] for details on the closure behavior. pub fn scripted_fixture_read_only_with_args_with_post( script_name: impl AsRef, args: impl IntoIterator>, version: u32, post_process: impl FnMut(FixtureState<'_>) -> PostResult, ) -> Result<(PathBuf, T)> { scripted_fixture_read_only_with_args_inner( script_name, args, None, ArgsInHash::Yes, default_excludes(), Some((version, post_process)), false, ) .map(|(path, opt)| (path, opt.expect("post_process was provided"))) } /// Like [`scripted_fixture_read_only_with_args_single_archive`], but runs a Rust closure after the script completes. /// /// See [`scripted_fixture_read_only_with_post`] for details on the closure behavior. pub fn scripted_fixture_read_only_with_args_single_archive_with_post( script_name: impl AsRef, args: impl IntoIterator>, version: u32, post_process: impl FnMut(FixtureState<'_>) -> PostResult, ) -> Result<(PathBuf, T)> { scripted_fixture_read_only_with_args_inner( script_name, args, None, ArgsInHash::No, default_excludes(), Some((version, post_process)), false, ) .map(|(path, opt)| (path, opt.expect("post_process was provided"))) } /// Like [`scripted_fixture_writable`], but runs a Rust closure after the script completes. /// /// - `version` should be incremented when the closure's behavior changes to invalidate the cache. /// - The closure receives a [`FixtureState`] enum indicating whether the fixture is newly created /// (`Fresh`) or was loaded from cache (`Cached`). Both variants carry the fixture directory path. /// - For `Fresh` fixtures, the closure can modify the directory and compute values. /// - For `Cached` fixtures, the closure should only compute values without modifications. /// - The closure always runs, ensuring the returned value is always available. pub fn scripted_fixture_writable_with_post( script_name: impl AsRef, version: u32, post_process: impl FnMut(FixtureState<'_>) -> PostResult, ) -> Result<(tempfile::TempDir, T)> { scripted_fixture_writable_with_args_inner( script_name, None::, Creation::CopyFromReadOnly, ArgsInHash::Yes, default_excludes(), Some((version, post_process)), ) .map(|(tmp, opt)| (tmp, opt.expect("post_process was provided"))) } /// Like [`scripted_fixture_writable_with_args`], but runs a Rust closure after the script completes. /// /// See [`scripted_fixture_writable_with_post`] for details on the closure behavior. pub fn scripted_fixture_writable_with_args_with_post( script_name: impl AsRef, args: impl IntoIterator>, mode: Creation, version: u32, post_process: impl FnMut(FixtureState<'_>) -> PostResult, ) -> Result<(tempfile::TempDir, T)> { scripted_fixture_writable_with_args_inner( script_name, args, mode, ArgsInHash::Yes, default_excludes(), Some((version, post_process)), ) .map(|(tmp, opt)| (tmp, opt.expect("post_process was provided"))) } /// Like [`scripted_fixture_writable_with_args_single_archive`], but runs a Rust closure after the script completes. /// /// See [`scripted_fixture_writable_with_post`] for details on the closure behavior. pub fn scripted_fixture_writable_with_args_single_archive_with_post( script_name: impl AsRef, args: impl IntoIterator>, mode: Creation, version: u32, post_process: impl FnMut(FixtureState<'_>) -> PostResult, ) -> Result<(tempfile::TempDir, T)> { scripted_fixture_writable_with_args_inner( script_name, args, mode, ArgsInHash::No, default_excludes(), Some((version, post_process)), ) .map(|(tmp, opt)| (tmp, opt.expect("post_process was provided"))) } /// Execute a Rust closure in a directory, returning a read-only fixture path. /// /// - `version` should be incremented when the closure's behavior changes to invalidate the cache. /// - `name` is used to identify this fixture for caching purposes and should be unique within the crate. /// - `make_fixture(fixture_state)` is the closure that creates the fixture, with the `fixture_state`, /// indicating whether or not the fixture should be written to. /// /// This is an alternative to script-based fixtures that allows creating fixtures in pure Rust, /// while still benefiting from the caching system. /// /// ### Archive Creation /// /// Just like script-based fixtures, the result is cached and compressed archives can be created. /// Increment the `version` number whenever the closure's behavior changes to force recreation. /// /// #### Disable Archive Creation /// /// Archives can be disabled by using `.gitignore` specifications, /// for example `generated-archives/rust-*.tar` or `generated-archives/rust-*.tar.xz` /// in the `tests/fixtures` directory. /// /// ### Example /// /// ```no_run /// use gix_testtools::{Result, FixtureState}; /// /// #[test] /// fn test_with_rust_fixture() -> Result { /// let (dir, _) = gix_testtools::rust_fixture_read_only("my_fixture", 1, |state| { /// if let FixtureState::Uninitialized(path) = state { /// std::fs::write(path.join("file.txt"), "content")?; /// } /// Ok(()) /// })?; /// assert!(dir.join("file.txt").exists()); /// Ok(()) /// } /// ``` pub fn rust_fixture_read_only(name: &str, version: u32, make_fixture: F) -> Result<(PathBuf, T)> where F: FnOnce(FixtureState<'_>) -> PostResult, { rust_fixture_read_only_inner(name, version, None, make_fixture, None, default_excludes()) } /// Execute a Rust closure in a directory, returning a writable temporary directory. /// /// The closure is used to create a fixture in the given directory. /// The resulting directory is writable and will be automatically cleaned up when the returned /// [`tempfile::TempDir`] is dropped. /// It may be called multiple times, and the returned `T` will be primed on the final, writable location. /// /// `version` should be incremented when the closure's behavior changes to invalidate the cache. /// `name` is used to identify this fixture for caching purposes and should be unique within the crate. /// /// ### Example /// /// ```no_run /// use gix_testtools::{Result, Creation, FixtureState}; /// /// #[test] /// fn test_with_writable_rust_fixture() -> Result { /// let (dir, ()) = gix_testtools::rust_fixture_writable("my_fixture", 1, Creation::CopyFromReadOnly, |state| { /// if let FixtureState::Uninitialized(path) = state { /// std::fs::write(path.join("file.txt"), "content")?; /// } /// Ok(()) /// })?; /// // Can modify files in dir /// std::fs::write(dir.path().join("new_file.txt"), "new content")?; /// Ok(()) /// } /// ``` pub fn rust_fixture_writable( name: &str, version: u32, mode: Creation, make_fixture: F, ) -> Result<(tempfile::TempDir, T)> where F: FnMut(FixtureState<'_>) -> PostResult, { rust_fixture_writable_inner(name, version, None, make_fixture, mode, default_excludes()) } fn rust_fixture_writable_inner( name: &str, version: u32, object_hash: Option, mut make_fixture: F, mode: Creation, excludes: &dyn IsExcluded, ) -> Result<(tempfile::TempDir, T)> where F: FnMut(FixtureState<'_>) -> PostResult, { let dst = tempfile::TempDir::new()?; let res = match mode { Creation::CopyFromReadOnly => { let (ro_dir, _res_ignored) = rust_fixture_read_only_inner(name, version, object_hash, &mut make_fixture, None, excludes)?; copy_recursively_into_existing_dir(ro_dir, dst.path())?; make_fixture(FixtureState::Fresh(dst.path()))? } Creation::Execute => { let (_, res) = rust_fixture_read_only_inner(name, version, object_hash, make_fixture, Some(dst.path()), excludes)?; res } }; Ok((dst, res)) } fn rust_fixture_read_only_inner( name: &str, version: u32, object_hash: Option, make_fixture: F, destination_dir: Option<&Path>, excludes: &dyn IsExcluded, ) -> Result<(PathBuf, T)> where F: FnOnce(FixtureState<'_>) -> PostResult, { // Assure tempfiles get removed when aborting the test. gix_tempfile::signal::setup( gix_tempfile::signal::handler::Mode::DeleteTempfilesOnTerminationAndRestoreDefaultBehaviour, ); // For Rust fixtures, the identity is simply the provided version number. // Users must increment this manually when the closure behavior changes. let script_identity = version; let archive_name = format!("rust-{name}"); let fixture_base = fixture_base(); let archive_file_path = fixture_base .join(ARCHIVE_DIR_NAME) .join(format!("{archive_name}.{}", tar_extension())); let (force_run, script_result_directory) = force_and_dir( destination_dir, &fixture_base, &archive_name, object_hash, &script_identity, None, ); let _marker = marker_if_needed(destination_dir, archive_name)?; run_fixture_generator_with_marker_handling( &archive_file_path, &script_result_directory, script_identity, force_run, false, excludes, &format!("using Rust closure '{name}'"), make_fixture, ) .map(|res| (script_result_directory, res)) } // We may assume that destination_dir is already unique (i.e. temp-dir) if present - thus there is no need for a lock, // and we can execute closures in parallel. Otherwise, we need to acquire a lock to ensure that only one closure is running at a time. fn marker_if_needed( destination_dir: Option<&Path>, archive_name: impl AsRef, ) -> Result> { Ok(destination_dir .is_none() .then(|| { gix_lock::Marker::acquire_to_hold_resource( archive_name, gix_lock::acquire::Fail::AfterDurationWithBackoff(Duration::from_secs(6 * 60)), None, ) }) .transpose()?) } fn force_and_dir( destination_dir: Option<&Path>, fixture_base: &Path, archive_name: impl AsRef, object_hash: Option, script_identity: &dyn std::fmt::Display, cache_variant: Option<&str>, ) -> (bool, PathBuf) { destination_dir.map_or_else( || { let mut dir = fixture_base.join( Path::new("generated-do-not-edit") .join(archive_name) .join(object_hash.unwrap_or_else(self::object_hash).to_string()), ); if let Some(cache_variant) = cache_variant { dir = dir.join(cache_variant); } let dir = dir.join(format!("{}-{}", script_identity, family_name())); (false, dir) }, |d| (true, d.to_owned()), ) } #[expect(clippy::too_many_arguments)] fn run_fixture_generator_with_marker_handling( archive_file_path: &Path, script_result_directory: &Path, script_identity: u32, force_run: bool, needs_archive: bool, excludes: &dyn IsExcluded, description: &str, make_fixture: F, ) -> Result where F: FnOnce(FixtureState<'_>) -> PostResult, { 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).map_err(|err| { format!( "Failed to remove '{script_result_directory}', please try to do that by hand. Original error: {err}", script_result_directory = script_result_directory.display() ) })?; } std::fs::create_dir_all(script_result_directory)?; match extract_archive( archive_file_path, script_result_directory, script_identity, needs_archive, ) { Ok((archive_id, platform)) => { eprintln!( "Extracted fixture from archive '{}' ({}, {:?})", archive_file_path.display(), archive_id, platform ); make_fixture(FixtureState::Fresh(script_result_directory)) } Err(err) => { if err.kind() != std::io::ErrorKind::NotFound { eprintln!("failed to extract '{}': {}", archive_file_path.display(), err); std::fs::remove_dir_all(script_result_directory).map_err(|err| { format!( "Failed to remove '{script_result_directory}', please try to do that by hand. Original error: {err}", script_result_directory = script_result_directory.display() ) })?; std::fs::create_dir_all(script_result_directory)?; } else if !excludes.is_excluded(archive_file_path) { eprintln!( "Archive at '{}' not found, creating fixture {}", archive_file_path.display(), description ); } let res = match make_fixture(FixtureState::Uninitialized(script_result_directory)) { Ok(value) => value, Err(err) => { write_failure_marker(&failure_marker); return Err(err); } }; create_archive_if_we_should(script_result_directory, archive_file_path, script_identity, excludes) .inspect_err(|_err| { write_failure_marker(&failure_marker); })?; Ok(res) } } } else { make_fixture(FixtureState::Fresh(script_result_directory)) } } fn scripted_fixture_read_only_with_args_inner( script_name: impl AsRef, args: impl IntoIterator>, destination_dir: Option<&Path>, args_in_hash: ArgsInHash, excludes: &dyn IsExcluded, post_process: Option<(u32, F)>, needs_archive: bool, ) -> Result<(PathBuf, Option)> where F: FnMut(FixtureState<'_>) -> PostResult, { // Assure tempfiles get removed when aborting the test. gix_tempfile::signal::setup( gix_tempfile::signal::handler::Mode::DeleteTempfilesOnTerminationAndRestoreDefaultBehaviour, ); let object_hash = object_hash(); let script_location = script_name.as_ref(); let fixture_base = fixture_base(); let script_path = fixture_path(script_location); // keep this lock to assure we don't return unfinished directories for threaded callers let args: Vec = args.into_iter().map(Into::into).collect(); let post_version = post_process.as_ref().map(|(v, _)| *v); let script_identity = { let mut map = SCRIPT_IDENTITY.lock(); let init = if object_hash == gix_hash::Kind::Sha1 { script_path.clone() } else { script_path.clone().join(object_hash.to_string()) }; let key = args.iter().fold(init, |p, a| p.join(a)); // Include post_version in the key if present let key = if let Some(v) = post_version { key.join(format!("post-v{v}")) } else { key }; map.entry(key) .or_insert_with(|| { let crc_value = crc::Crc::::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 '{cwd}' could not be read: {err}", cwd = env::current_dir().expect("valid cwd").display(), script_path = script_path.display(), ) })); for arg in &args { crc_digest.update(arg.as_bytes()); } // Hash the post_process version if present if let Some(v) = post_version { crc_digest.update(&v.to_le_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_base.join(ARCHIVE_DIR_NAME).join({ let suffix = match args_in_hash { ArgsInHash::Yes => { let mut suffix = args.join("_"); if !suffix.is_empty() { suffix.insert(0, '_'); } suffix.replace(['\\', '/', ' ', '.'], "_") } ArgsInHash::No => "".into(), }; let potential_hash_suffix = if object_hash == gix_hash::Kind::Sha1 { "".into() } else { format!("_{object_hash}") }; format!( "{}{suffix}{potential_hash_suffix}.{}", script_basename.to_str().expect("valid UTF-8"), tar_extension() ) }); let (force_run, script_result_directory) = force_and_dir( destination_dir, &fixture_base, script_basename, Some(object_hash), &script_identity, needs_archive.then_some("archive"), ); let _marker = marker_if_needed(destination_dir, script_basename)?; let script_identity_for_archive = match args_in_hash { ArgsInHash::Yes => script_identity, ArgsInHash::No => 0, }; let script_absolute_path = env::current_dir()?.join(&script_path); let post_process_closure = post_process.map(|(_, f)| f); let res = run_fixture_generator_with_marker_handling( &archive_file_path, &script_result_directory, script_identity_for_archive, force_run, needs_archive, excludes, &format!("using script '{}'", script_location.display()), |fixture_state| { if let FixtureState::Uninitialized(dir) = fixture_state { let mut cmd = std::process::Command::new(&script_absolute_path); let output = match configure_command(&mut cmd, object_hash, &args, dir).output() { Ok(out) => out, Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied || err.raw_os_error() == Some(193) /* windows */ => { cmd = std::process::Command::new(bash_program()); configure_command(cmd.arg(&script_absolute_path), object_hash, &args, dir).output()? } Err(err) => return Err(err.into()), }; if !output.status.success() { eprintln!("stdout: {}", output.stdout.as_bstr()); eprintln!("stderr: {}", output.stderr.as_bstr()); return Err(format!("fixture script of {cmd:?} failed").into()); } } if let Some(mut f) = post_process_closure { f(fixture_state).map(Some) } else { Ok(None) } }, )?; Ok((script_result_directory, res)) } /// Returns the hash function that is used when creating or loading test fixtures. /// /// The value returned is derived from the environment variable `GIX_TEST_FIXTURE_HASH`. /// Use this, e. g., when you need to run different assertions depending on the hash /// function used in a specific fixture. /// /// Returns `None` if the environment variable isn't set. /// /// # Panics /// /// If the value set in `GIX_TEST_FIXTURE_HASH` is not valid. pub fn object_hash_from_env() -> Option { static FIXTURE_HASH: LazyLock> = LazyLock::new(|| { env::var_os("GIX_TEST_FIXTURE_HASH").and_then(|value| value.into_string().ok()).map(|object_kind| { gix_hash::Kind::from_str(&object_kind).unwrap_or_else(|_| { panic!( "GIX_TEST_FIXTURE_HASH was set to {object_kind} which is an invalid value. Valid values are {}. Exiting.", gix_hash::Kind::all().iter().map(std::string::ToString::to_string).collect::>().join(", ") ) }) }) }); *FIXTURE_HASH } /// Like [`object_hash_from_env()`], but returns the default hash if `GIX_TEST_FIXTURE_HASH` is not set. pub fn object_hash() -> gix_hash::Kind { object_hash_from_env().unwrap_or_default() } /// Run `git` in `current_dir` with shell-like whitespace-separated `arguments`, returning stdout as UTF-8. /// /// Note that Git is run as isolated as possible, just like scripts. /// /// Arguments may be split across multiple lines. Single and double quotes can be used to keep whitespace /// within an argument, for example `commit -m 'a message with spaces'`. pub fn git(current_dir: impl AsRef, arguments: &str) -> Result { let args = split_git_arguments(arguments)?; let cwd = current_dir.as_ref(); let mut cmd = std::process::Command::new(GIT_PROGRAM); let output = configure_command(&mut cmd, object_hash(), args.iter().map(String::as_str), cwd) .current_dir(cwd) .output()?; if !output.status.success() { return Err(format!( "{cmd:?} failed with status {}\nstdout: {}\nstderr: {}", output.status, output.stdout.as_bstr(), output.stderr.as_bstr() ) .into()); } Ok(String::from_utf8(output.stdout)?) } fn split_git_arguments(input: &str) -> Result> { let mut args = Vec::new(); let mut arg = String::new(); let mut quote = None; let mut has_arg = false; let mut chars = input.chars(); while let Some(ch) = chars.next() { match quote { Some('\'') => { if ch == '\'' { quote = None; } else { arg.push(ch); } } Some('"') => { if ch == '"' { quote = None; } else if ch == '\\' { if let Some(next) = chars.next() { arg.push(next); } } else { arg.push(ch); } } Some(_) => unreachable!("only single and double quotes are set"), None => { if ch.is_whitespace() { if has_arg { args.push(std::mem::take(&mut arg)); has_arg = false; } } else if matches!(ch, '\'' | '"') { quote = Some(ch); has_arg = true; } else if ch == '\\' { if let Some(next) = chars.next() { arg.push(next); } has_arg = true; } else { arg.push(ch); has_arg = true; } } } } if let Some(quote) = quote { return Err(format!("unterminated {quote:?} quote in git arguments").into()); } if has_arg { args.push(arg); } Ok(args) } /// Normalize debug-formatted `value` so one snapshot can be reused for SHA-1 and SHA-256 fixtures. /// /// The helper rewrites 40- and 64-character hexadecimal object IDs to stable `Oid()` /// placeholders in first-seen order while leaving the surrounding pretty-debug formatting untouched. /// Debug wrappers like `Sha1()` and `Sha256()` are collapsed to the same placeholder. /// It also returns the replaced object IDs in first-seen order, so `Oid(n)` can be looked up as /// `result.1[n - 1]`. pub fn normalize_debug_snapshot(value: &dyn std::fmt::Debug) -> (String, Vec) { normalize_hashes(&format!("{value:#?}")) } /// Normalize 40- and 64-character hexadecimal object IDs in `input`. /// /// This is like [`normalize_debug_snapshot()`], but operates on already-formatted text. pub fn normalize_hashes(input: &str) -> (String, Vec) { let mut out = String::with_capacity(input.len()); let mut seen = HashMap::::new(); let mut removed = Vec::::new(); let mut chars = input.chars().peekable(); let mut hex = String::new(); while let Some(ch) = chars.next() { if ch.is_ascii_hexdigit() { hex.clear(); hex.push(ch); while let Some(ch) = chars.next_if(char::is_ascii_hexdigit) { hex.push(ch); } if let Some(oid) = raw_object_id(&hex) { strip_debug_hash_wrapper(&mut out, &mut chars); push_normalized_oid(oid, &mut seen, &mut removed, &mut out); } else { out.push_str(&hex); } } else { out.push(ch); } } (out, removed) } fn raw_object_id(input: &str) -> Option { if !matches!(input.len(), 40 | 64) { return None; } gix_hash::ObjectId::from_hex(input.as_bytes()).ok() } fn strip_debug_hash_wrapper(out: &mut String, chars: &mut std::iter::Peekable>) { if !matches!(chars.peek(), Some(')')) { return; } // The `Sha1(` / `Sha256(` prefix was already copied before the hex run was // recognized as an object ID. Remove it, then consume the matching `)`. let consume_closing_parenthesis = if out.ends_with("Sha1(") { out.truncate(out.len() - "Sha1(".len()); true } else if out.ends_with("Sha256(") { out.truncate(out.len() - "Sha256(".len()); true } else { false }; if consume_closing_parenthesis { chars.next(); } } fn push_normalized_oid( oid: gix_hash::ObjectId, seen: &mut HashMap, removed: &mut Vec, out: &mut String, ) { let normalized = *seen.entry(oid).or_insert_with(|| { let current = removed.len(); removed.push(oid); current }); out.push_str("Oid("); out.push_str(&(normalized + 1).to_string()); out.push(')'); } #[cfg(windows)] const NULL_DEVICE: &str = "nul"; // See `gix_path::env::git::NULL_DEVICE` on why this form is used. #[cfg(not(windows))] const NULL_DEVICE: &str = "/dev/null"; fn configure_command<'a, I: IntoIterator, S: AsRef>( cmd: &'a mut std::process::Command, object_hash: gix_hash::Kind, args: I, script_result_directory: &Path, ) -> &'a mut std::process::Command { // For simplicity, we extend the `MSYS` variable from our own environment. This disregards // state from any prior `cmd.env("MSYS")` or `cmd.env_remove("MSYS")` calls. Such calls should // either be avoided, or made after this function returns (but before spawning the command). let mut msys_for_git_bash_on_windows = env::var_os("MSYS").unwrap_or_default(); msys_for_git_bash_on_windows.push(" winsymlinks:nativestrict"); cmd.args(args) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .current_dir(script_result_directory) .env_remove("GIT_DIR") .env_remove("GIT_INDEX_FILE") .env_remove("GIT_OBJECT_DIRECTORY") .env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES") .env_remove("GIT_WORK_TREE") .env_remove("GIT_COMMON_DIR") .env_remove("GIT_ASKPASS") .env_remove("SSH_ASKPASS") .env("MSYS", msys_for_git_bash_on_windows) .env( "XDG_CONFIG_HOME", script_result_directory.join(".gix-testtools-xdg-config"), ) .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_CONFIG_GLOBAL", NULL_DEVICE) .env("GIT_TERMINAL_PROMPT", "false") .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_DEFAULT_HASH", object_hash.to_string()); apply_git_config_by_environment(cmd, ISOLATED_GIT_CONFIG) } /// Apply command-scoped Git `config` to `cmd`, and return it. /// /// This sets `GIT_CONFIG_COUNT` and matching `GIT_CONFIG_KEY_` / /// `GIT_CONFIG_VALUE_` environment variables, which Git treats like /// command-line `-c =` entries for the spawned process. Existing /// values for these variables on `cmd` are overwritten for the configured /// indices. pub fn apply_git_config_by_environment<'a>( cmd: &'a mut std::process::Command, config: &[(&str, &str)], ) -> &'a mut std::process::Command { cmd.env("GIT_CONFIG_COUNT", config.len().to_string()); for (idx, (key, value)) in config.iter().enumerate() { cmd.env(format!("GIT_CONFIG_KEY_{idx}"), key); cmd.env(format!("GIT_CONFIG_VALUE_{idx}"), value); } cmd } /// Get the path attempted as a `bash` interpreter, for fixture scripts having no `#!` we can use. /// /// This is rarely called on Unix-like systems, provided that fixture scripts have usable shebang /// (`#!`) lines and are marked executable. However, Windows does not recognize `#!` when executing /// a file. If all fixture scripts that cannot be directly executed are `bash` scripts or can be /// treated as such, fixture generation still works on Windows, as long as this function manages to /// find or guess a suitable `bash` interpreter. /// /// ### Search order /// /// This function is used internally. It is public to facilitate diagnostic use. The following /// details are subject to change without warning, and changes are treated as non-breaking. /// /// The `bash.exe` found in a path search is not always suitable on Windows. This is mainly because /// `bash.exe` in `System32`, which is associated with WSL, would often be found first. But even /// where that is not the case, the best `bash.exe` to use to run fixture scripts to set up Git /// repositories for testing is usually one associated with Git for Windows, even if some other /// `bash.exe` would be found in a path search. Currently, the search order we use is as follows: /// /// 1. The shim `bash.exe`, which sets environment variables when run and is, on some systems, /// needed to find the POSIX utilities that scripts need (or correct versions of them). /// /// 2. The non-shim `bash.exe`, which is sometimes available even when the shim is not available. /// This is mainly because the Git for Windows SDK does not come with a `bash.exe` shim. /// /// 3. As a fallback, the simple name `bash.exe`, which triggers a path search when run. /// /// On non-Windows systems, the simple name `bash` is used, which triggers a path search when run. pub fn bash_program() -> &'static Path { // TODO(deps): Unify with `gix_path::env::shell()` by having both call a more general function // in `gix-path`. See https://github.com/GitoxideLabs/gitoxide/issues/1886. static GIT_BASH: LazyLock = LazyLock::new(|| { if cfg!(windows) { GIT_CORE_DIR .ancestors() .nth(3) .map(OsStr::new) .iter() .flat_map(|prefix| { // Go down to places `bash.exe` usually is. Keep using `/` separators, not `\`. ["/bin/bash.exe", "/usr/bin/bash.exe"].into_iter().map(|suffix| { let mut raw_path = (*prefix).to_owned(); raw_path.push(suffix); raw_path }) }) .map(PathBuf::from) .find(|bash| bash.is_file()) .unwrap_or_else(|| "bash.exe".into()) } else { "bash".into() } }); GIT_BASH.as_ref() } fn write_failure_marker(failure_marker: &Path) { std::fs::write(failure_marker, []).ok(); } fn should_skip_all_archive_creation() -> bool { // On Windows, we fail to remove the meta_dir and can't do anything about it, which means tests will see more // in the directory than they should which makes them fail. It's probably a bad idea to generate archives on Windows // anyway. Either Unix is portable OR no archive is created anywhere. This also means that Windows users can't create // archives, but that's not a deal-breaker. cfg!(windows) || (is_ci::cached() && env::var_os("GIX_TEST_CREATE_ARCHIVES_EVEN_ON_CI").is_none()) } fn is_lfs_pointer_file(path: &Path) -> bool { const PREFIX: &[u8] = b"version https://git-lfs"; let mut buf = [0_u8; PREFIX.len()]; std::fs::OpenOptions::new() .read(true) .open(path) .is_ok_and(|mut f| f.read_exact(&mut buf).is_ok_and(|_| buf.starts_with(PREFIX))) } /// The `script_identity` will be baked into the soon to be created `archive` as it identifies the script /// that created the contents of `source_dir`. fn create_archive_if_we_should( source_dir: &Path, archive: &Path, script_identity: u32, excludes: &dyn IsExcluded, ) -> std::io::Result<()> { if should_skip_all_archive_creation() || excludes.is_excluded(archive) { return Ok(()); } if is_lfs_pointer_file(archive) { eprintln!( "Refusing to overwrite `gix-lfs` pointer file at \"{}\" - git lfs might not be properly installed.", archive.display() ); 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::::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()?; } #[cfg_attr(feature = "xz", allow(unused_mut))] let mut archive = std::fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .open(archive)?; #[cfg(feature = "xz")] { let mut xz_write = xz2::write::XzEncoder::new(archive, 3); std::io::copy(&mut &*buf, &mut xz_write)?; xz_write.finish()?.close() } #[cfg(not(feature = "xz"))] { use std::io::Write; archive.write_all(&buf)?; archive.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 } 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 { 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_PROGRAM) .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, needs_archive: bool, ) -> std::io::Result<(u32, Option)> { let archive_buf: Vec = { let mut buf = Vec::new(); #[cfg_attr(feature = "xz", allow(unused_mut))] let mut input_archive = std::fs::File::open(archive)?; if !needs_archive && env::var_os("GIX_TEST_IGNORE_ARCHIVES").is_some() { return Err(std::io::Error::other(format!( "Ignoring archive at '{}' as GIX_TEST_IGNORE_ARCHIVES is set.", archive.display() ))); } #[cfg(feature = "xz")] { let mut decoder = xz2::bufread::XzDecoder::new(std::io::BufReader::new(input_archive)); std::io::copy(&mut decoder, &mut buf)?; } #[cfg(not(feature = "xz"))] { input_archive.read_to_end(&mut buf)?; } buf }; let mut entry_buf = Vec::::new(); let (archive_identity, platform): (u32, _) = tar::Archive::new(std::io::Cursor::new(&mut &*archive_buf)) .entries_with_seek()? .filter_map(std::result::Result::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::other("BUG: Could not find meta directory in our own archive")) .map_err(|err| { std::io::Error::other(format!( "Could not extract archive at '{archive}': {err}", archive = archive.display() )) })?; if archive_identity != required_script_identity { eprintln!( "Ignoring archive at '{}' as its generating script changed", archive.display() ); 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(Path::to_str) == Some(META_DIR_NAME) { continue; } entry.unpack_in(destination_dir)?; } Ok((archive_identity, platform)) } fn family_name() -> &'static str { if cfg!(windows) { "windows" } else { "unix" } } /// A utility to set and unset environment variables, while restoring or removing them on drop. #[derive(Default)] pub struct Env<'a> { altered_vars: Vec<(&'a str, Option)>, } fn set_var(var: &str, value: impl AsRef) { // SAFETY: Tests using this helper are responsible for serializing access to // process-wide environment variables they mutate. unsafe { env::set_var(var, value) }; } fn remove_var(var: &str) { // SAFETY: Tests using this helper are responsible for serializing access to // process-wide environment variables they mutate. unsafe { env::remove_var(var) }; } 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) -> Self { let prev = env::var_os(var); set_var(var, value.into()); self.altered_vars.push((var, prev)); self } /// Unset `var`. pub fn unset(mut self, var: &'a str) -> Self { let prev = env::var_os(var); remove_var(var); self.altered_vars.push((var, prev)); self } } impl Drop for Env<'_> { fn drop(&mut self) { for (var, prev_value) in self.altered_vars.iter().rev() { match prev_value { Some(value) => set_var(var, value), None => remove_var(var), } } } } /// Check data structure size, comparing strictly on 64-bit targets. /// /// - On 32-bit targets, checks if `actual_size` is at most `expected_64_bit_size`. /// - On 64-bit targets, checks if `actual_size` is exactly `expected_64_bit_size`. /// /// This is for assertions about the size of data structures, when the goal is to keep them from /// growing too large even across breaking changes. Such assertions must always fail when data /// structures grow larger than they have ever been, for which `<=` is enough. But it also helps to /// know when they have shrunk unexpectedly. They may shrink, other changes may rely on the smaller /// size for acceptable performance, and then they may grow again to their earlier size. /// /// The problem with `==` is that data structures are often smaller on 32-bit targets. This could /// be addressed by asserting separate exact 64-bit and 32-bit sizes. But sizes may also differ /// across 32-bit targets, due to ABI and layout/packing details. That can happen across 64-bit /// targets too, but it seems less common. /// /// For those reasons, this function does a `==` on 64-bit targets, but a `<=` on 32-bit targets. pub fn size_ok(actual_size: usize, expected_64_bit_size: usize) -> bool { #[cfg(target_pointer_width = "64")] return actual_size == expected_64_bit_size; #[cfg(target_pointer_width = "32")] return actual_size <= expected_64_bit_size; } /// Get the umask in a way that is safe, but may be too slow for use outside of tests. #[cfg(unix)] pub fn umask() -> u32 { let output = std::process::Command::new("/bin/sh") .args(["-c", "umask"]) .output() .expect("can execute `sh -c umask`"); assert!(output.status.success(), "`sh -c umask` failed"); assert_eq!(output.stderr.as_bstr(), "", "`sh -c umask` unexpected message"); let text = output.stdout.to_str().expect("valid Unicode").trim(); u32::from_str_radix(text, 8).expect("parses as octal number") } fn tar_extension() -> &'static str { if cfg!(feature = "xz") { "tar.xz" } else { "tar" } } #[cfg(test)] mod tests;