2008 lines · 73.3 KB · Rust
RawDownload
//! 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<T = ()> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>; /// 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<T = ()> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>; /// 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>) -> 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<std::sync::atomic::AtomicBool>,        server_addr: std::net::SocketAddr,        listener_thread: Option<std::thread::JoinHandle<()>>,    },} 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<Mutex<BTreeMap<PathBuf, u32>>> = LazyLock::new(|| Mutex::new(BTreeMap::new())); #[cfg(feature = "worktree-exclusions")]static EXCLUDE_LUT: LazyLock<Mutex<Option<gix_worktree::Stack>>> = 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<PathBuf> = 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 <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<Path>) -> std::io::Result<AutoRevertToPreviousCWD> {    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<std::process::ExitStatus> {    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<Path>, 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<Path>) -> std::io::Result<GitDaemon> {    #[cfg(unix)]    {        spawn_git_daemon_inetd(working_dir)    }    #[cfg(not(unix))]

Lines 1–500 of 2008.