//! 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. Load more
//! 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))]