Skip to main content

gix_testtools/
lib.rs

1//! Utilities for testing `gitoxide` crates, many of which might be useful for testing programs that use `git` in general.
2//!
3//! ## Environment Variables
4//!
5//! ### `GIX_TEST_FIXTURE_HASH`
6//!
7//! Set this variable to control which hash function is used when creating or loading test fixtures.
8//! Valid values are the names of hash functions supported by `gix_hash::Kind` (e.g., `sha1`, `sha256`).
9//! If not set, the default hash function via `gix_hash::Kind::default()` is used.
10//!
11
12//! ## Feature Flags
13#![cfg_attr(
14    all(doc, feature = "document-features"),
15    doc = ::document_features::document_features!()
16)]
17#![cfg_attr(all(doc, feature = "document-features"), feature(doc_cfg))]
18#![deny(missing_docs)]
19
20use std::{
21    collections::{BTreeMap, HashMap},
22    env,
23    ffi::{OsStr, OsString},
24    io::Read,
25    path::{Path, PathBuf},
26    str::FromStr,
27    time::Duration,
28};
29
30pub use bstr;
31use bstr::ByteSlice;
32use io_close::Close;
33
34pub use is_ci;
35use parking_lot::Mutex;
36use std::sync::LazyLock;
37
38pub use tempfile;
39
40/// Shared setup for tests involving Git-compatible signatures.
41pub mod signature;
42
43/// Capture complete, stable repository state for integration-test assertions.
44pub mod repository;
45
46const ARCHIVE_DIR_NAME: &str = "generated-archives";
47
48/// A result type to allow using the try operator `?` in unit tests.
49///
50/// Use it like so:
51///
52/// ```no_run
53/// use gix_testtools::Result;
54///
55/// #[test]
56/// fn this() -> Result {
57///     let x: usize = "42".parse()?;
58///     Ok(())
59///
60/// }
61/// ```
62pub type Result<T = ()> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
63
64/// A result type for post-processing closures in `*_with_post` fixture functions.
65///
66/// The closure can return any value `T`, which will be returned alongside the fixture path.
67/// This is useful for computing values based on the fixture contents.
68pub type PostResult<T = ()> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
69
70/// Build `example` from `package` and copy the executable to this test process' temporary target directory.
71///
72/// The returned executable path is stable for the lifetime of the test process and avoids races with other
73/// concurrently running tests that may cause Cargo to update the shared example binary in `target/debug/examples`.
74pub fn build_example_for_test(package: &str, example: &str, target_tmpdir: impl Into<PathBuf>) -> PathBuf {
75    let mut cargo = std::process::Command::new(env::var_os("CARGO").unwrap_or_else(|| OsString::from(env!("CARGO"))));
76    let res = cargo
77        .args(["build", "-p", package, "--example", example])
78        .status()
79        .expect("cargo should run fine");
80    assert!(res.success(), "cargo invocation should be successful");
81
82    let target_tmpdir = target_tmpdir.into();
83    let shared_path = target_tmpdir
84        .ancestors()
85        .nth(1)
86        .expect("first parent in target dir")
87        .join("debug")
88        .join("examples")
89        .join(format!("{example}{}", std::env::consts::EXE_SUFFIX));
90
91    let stable_path = target_tmpdir.join(format!(
92        "{example}-{}{}",
93        std::process::id(),
94        std::env::consts::EXE_SUFFIX
95    ));
96    let mut last_err = None;
97    for _ in 0..10 {
98        match std::fs::copy(&shared_path, &stable_path) {
99            Ok(_) => return stable_path,
100            Err(err) => {
101                last_err = Some(err);
102                std::thread::sleep(Duration::from_millis(50));
103            }
104        }
105    }
106    panic!(
107        "driver at {} could be copied for stable test execution: {last_err:?}",
108        shared_path.display()
109    );
110}
111
112/// Indicates the state of a fixture when a closure is called.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum FixtureState<'a> {
115    /// The fixture is newly created and needs post-processing.
116    ///
117    /// The closure should perform any necessary modifications to the fixture
118    /// directory and compute its return value.
119    Uninitialized(&'a Path),
120    /// The fixture was already created (cached) and only needs to produce a return value.
121    ///
122    /// The closure should NOT modify the fixture directory, but only compute
123    /// and return a value based on the existing contents.
124    Fresh(&'a Path),
125}
126
127impl FixtureState<'_> {
128    /// Returns the path of the fixture, which is always a directory.
129    pub fn path(&self) -> &Path {
130        match self {
131            FixtureState::Uninitialized(path) | FixtureState::Fresh(path) => path,
132        }
133    }
134
135    /// Returns true if the fixture is uninitialized and needs to be modified.
136    pub fn is_uninitialized(&self) -> bool {
137        matches!(self, FixtureState::Uninitialized(_))
138    }
139}
140
141/// Determines whether fixture generation should skip creating, updating, or overwriting a cached fixture archive.
142///
143/// In this module, an archive is the tar file under [`ARCHIVE_DIR_NAME`] that stores the output of a fixture
144/// script or Rust fixture closure. The read-only fixture helpers unpack that archive when it already exists, and
145/// [`create_archive_if_we_should()`] consults this trait before writing a new archive from the generated fixture
146/// directory.
147trait IsExcluded {
148    /// Return true if `archive` matches the configured exclusion source.
149    fn is_excluded(&self, archive: &Path) -> bool;
150}
151
152/// Checks whether `archive` matches `.gitignore`-style lines read by [`GitignoreExclusions`].
153///
154/// This is the fallback used when the `worktree-exclusions` feature is disabled, so the full `gix-worktree`
155/// exclusion stack is not available. In that configuration, [`GitignoreExclusions::is_excluded()`] reads the
156/// `.gitignore` next to the generated archive and delegates the line matching to this function.
157#[cfg(not(feature = "worktree-exclusions"))]
158fn is_excluded_by_lines(lines: &str, archive: &Path) -> bool {
159    let archive = archive.to_string_lossy().replace('\\', "/");
160    let filename = archive.rsplit('/').next().unwrap_or(&archive);
161    lines.lines().any(|line| {
162        let pattern = line.trim();
163        if pattern.is_empty() || pattern.starts_with('#') {
164            return false;
165        }
166        let pattern = pattern.trim_start_matches('/');
167        let candidate = if pattern.contains('/') {
168            archive.as_str()
169        } else {
170            filename
171        };
172        wildcard_match(pattern, candidate)
173    })
174}
175
176/// Matches `text` against the fallback exclusion pattern syntax.
177///
178/// The matcher understands literal characters and `*`, where `*` matches any byte sequence, including path
179/// separators. Patterns are anchored to the full `text`; other `.gitignore` features such as `?`, character
180/// classes, directory-only matches, and negation are not supported here.
181///
182/// For example, `*.tar` matches `fixture.tar`, `generated-archives/*.tar` matches
183/// `generated-archives/fixture.tar`, and `generated-*/*.tar` matches `generated-archives/fixture.tar`.
184#[cfg(not(feature = "worktree-exclusions"))]
185fn wildcard_match(pattern: &str, text: &str) -> bool {
186    if !pattern.contains('*') {
187        return pattern == text;
188    }
189
190    let mut remainder = text;
191    let mut parts = pattern.split('*').peekable();
192    let first = parts.next().expect("split yields at least one item");
193    if !first.is_empty() {
194        let Some(stripped) = remainder.strip_prefix(first) else {
195            return false;
196        };
197        remainder = stripped;
198    }
199
200    while let Some(part) = parts.next() {
201        if part.is_empty() {
202            continue;
203        }
204        let Some(pos) = remainder.find(part) else {
205            return false;
206        };
207        remainder = &remainder[pos + part.len()..];
208        if parts.peek().is_none() && !pattern.ends_with('*') {
209            return remainder.is_empty();
210        }
211    }
212    pattern.ends_with('*') || remainder.is_empty()
213}
214
215/// A wrapper for a running git-daemon which is stopped automatically on drop.
216///
217/// Note that we will swallow any errors, assuming that the test would have failed if the daemon crashed.
218pub struct GitDaemon {
219    process: GitDaemonProcess,
220    /// The base url under which all repositories are hosted, typically `git://127.0.0.1:port`.
221    pub url: String,
222}
223
224enum GitDaemonProcess {
225    #[cfg(not(unix))]
226    Child(std::process::Child),
227    #[cfg(unix)]
228    Inetd {
229        shutdown: std::sync::Arc<std::sync::atomic::AtomicBool>,
230        server_addr: std::net::SocketAddr,
231        listener_thread: Option<std::thread::JoinHandle<()>>,
232    },
233}
234
235impl Drop for GitDaemon {
236    fn drop(&mut self) {
237        match &mut self.process {
238            #[cfg(not(unix))]
239            GitDaemonProcess::Child(child) => {
240                child.kill().ok();
241            }
242            #[cfg(unix)]
243            GitDaemonProcess::Inetd {
244                shutdown,
245                server_addr,
246                listener_thread,
247            } => {
248                shutdown.store(true, std::sync::atomic::Ordering::SeqCst);
249                std::net::TcpStream::connect(*server_addr).ok();
250                if let Some(listener_thread) = listener_thread.take() {
251                    listener_thread.join().ok();
252                }
253            }
254        }
255    }
256}
257
258static SCRIPT_IDENTITY: LazyLock<Mutex<BTreeMap<PathBuf, u32>>> = LazyLock::new(|| Mutex::new(BTreeMap::new()));
259
260#[cfg(feature = "worktree-exclusions")]
261static EXCLUDE_LUT: LazyLock<Mutex<Option<gix_worktree::Stack>>> = LazyLock::new(|| {
262    let cache = (|| {
263        let (repo_path, _) = gix_discover::upwards(Path::new(".")).ok()?;
264        let (gix_dir, work_tree) = repo_path.into_repository_and_work_tree_directories();
265        let work_tree = work_tree?.canonicalize().ok()?;
266
267        let mut buf = Vec::with_capacity(512);
268        let case = if gix_fs::Capabilities::probe(&work_tree).ignore_case {
269            gix_worktree::ignore::glob::pattern::Case::Fold
270        } else {
271            Default::default()
272        };
273        let state = gix_worktree::stack::State::IgnoreStack(gix_worktree::stack::state::Ignore::new(
274            Default::default(),
275            gix_worktree::ignore::Search::from_git_dir(
276                &gix_dir,
277                None,
278                &mut buf,
279                gix_worktree::stack::state::ignore::ParseIgnore {
280                    support_precious: false,
281                },
282            )
283            .ok()?,
284            None,
285            gix_worktree::stack::state::ignore::Source::WorktreeThenIdMappingIfNotSkipped,
286            Default::default(),
287        ));
288        Some(gix_worktree::Stack::new(
289            work_tree,
290            state,
291            case,
292            buf,
293            Default::default(),
294        ))
295    })();
296    Mutex::new(cache)
297});
298
299#[cfg(feature = "worktree-exclusions")]
300struct WorktreeExclusions;
301
302#[cfg(feature = "worktree-exclusions")]
303impl IsExcluded for WorktreeExclusions {
304    fn is_excluded(&self, archive: &Path) -> bool {
305        let mut lut = EXCLUDE_LUT.lock();
306        lut.as_mut()
307            .and_then(|cache| {
308                let archive = env::current_dir().ok()?.join(archive);
309                let relative_path = archive.strip_prefix(cache.base()).ok()?;
310                cache
311                    .at_path(
312                        relative_path,
313                        Some(gix_worktree::index::entry::Mode::FILE),
314                        &gix_worktree::object::find::Never,
315                    )
316                    .ok()?
317                    .is_excluded()
318                    .into()
319            })
320            .unwrap_or(false)
321    }
322}
323
324#[cfg(feature = "worktree-exclusions")]
325fn default_excludes() -> &'static dyn IsExcluded {
326    static WORKTREE_EXCLUSIONS: WorktreeExclusions = WorktreeExclusions;
327    &WORKTREE_EXCLUSIONS
328}
329
330#[cfg(not(feature = "worktree-exclusions"))]
331struct GitignoreExclusions;
332
333#[cfg(not(feature = "worktree-exclusions"))]
334impl IsExcluded for GitignoreExclusions {
335    fn is_excluded(&self, archive: &Path) -> bool {
336        let Some(parent) = archive.parent() else {
337            return false;
338        };
339        std::fs::read_to_string(parent.join(".gitignore")).is_ok_and(|lines| is_excluded_by_lines(&lines, archive))
340    }
341}
342
343#[cfg(not(feature = "worktree-exclusions"))]
344fn default_excludes() -> &'static dyn IsExcluded {
345    static GITIGNORE_EXCLUSIONS: GitignoreExclusions = GitignoreExclusions;
346    &GITIGNORE_EXCLUSIONS
347}
348
349const DISABLE_AUTO_MAINTENANCE_CONFIG: &[(&str, &str)] = &[("maintenance.auto", "false"), ("gc.auto", "0")];
350
351const ISOLATED_GIT_CONFIG: &[(&str, &str)] = &[
352    ("commit.gpgsign", "false"),
353    ("tag.gpgsign", "false"),
354    ("init.defaultBranch", "main"),
355    ("protocol.file.allow", "always"),
356    ("maintenance.auto", "false"),
357    ("gc.auto", "0"),
358];
359
360static GIT_CORE_DIR: LazyLock<PathBuf> = LazyLock::new(|| {
361    let output = std::process::Command::new(gix_path::env::exe_invocation())
362        .arg("--exec-path")
363        .output()
364        .expect("can execute `git --exec-path`");
365
366    assert!(output.status.success(), "`git --exec-path` failed");
367
368    output
369        .stdout
370        .strip_suffix(b"\n")
371        .expect("`git --exec-path` output to be well-formed")
372        .to_os_str()
373        .expect("no invalid UTF-8 in `--exec-path` except as OS allows")
374        .into()
375});
376
377/// The major, minor and patch level of the git version on the system.
378pub static GIT_VERSION: LazyLock<(u8, u8, u8)> =
379    LazyLock::new(|| parse_git_version().expect("git version to be parsable"));
380
381/// Define how [`scripted_fixture_writable_with_args()`],
382/// [`scripted_fixture_writable_with_args_with_git_version()`], and [`rust_fixture_writable()`]
383/// produce the writable fixture.
384pub enum Creation {
385    /// Run the code once and copy the data from its output to the writable location.
386    /// This is fast but won't work if absolute paths are produced by the script.
387    ///
388    /// ### Limitation
389    ///
390    /// Cannot handle symlinks currently. Waiting for [this PR](https://github.com/webdesus/fs_extra/pull/70).
391    CopyFromReadOnly,
392    /// Run the code in the writable location. That way, absolute paths match the location.
393    Execute,
394}
395
396/// Returns true if the given `major`, `minor` and `patch` is smaller than the actual git version on the system
397/// to facilitate skipping a test on the caller.
398/// Will never return true on CI which is expected to have a recent enough git version.
399///
400/// # Panics
401///
402/// If `git` cannot be executed or if its version output cannot be parsed.
403pub fn should_skip_as_git_version_is_smaller_than(major: u8, minor: u8, patch: u8) -> bool {
404    if is_ci::cached() {
405        return false; // CI should be made to use a recent git version, it should run there.
406    }
407    *GIT_VERSION < (major, minor, patch)
408}
409
410fn parse_git_version() -> Result<(u8, u8, u8)> {
411    let output = std::process::Command::new(gix_path::env::exe_invocation())
412        .arg("--version")
413        .output()?;
414    git_version_from_bytes(&output.stdout)
415}
416
417fn git_version_from_bytes(bytes: &[u8]) -> Result<(u8, u8, u8)> {
418    let mut numbers = bytes
419        .split(|b| *b == b' ' || *b == b'\n')
420        .nth(2)
421        .expect("git version <version>")
422        .split(|b| *b == b'.')
423        .take(3)
424        .map(|n| std::str::from_utf8(n).expect("valid utf8 in version number"))
425        .map(u8::from_str);
426
427    Ok((|| -> Result<_> {
428        Ok((
429            numbers.next().expect("major")?,
430            numbers.next().expect("minor")?,
431            numbers.next().expect("patch")?,
432        ))
433    })()
434    .map_err(|err| {
435        format!(
436            "Could not parse version from output of 'git --version' ({:?}) with error: {}",
437            bytes.to_str_lossy(),
438            err
439        )
440    })?)
441}
442
443/// Set the current working dir to `new_cwd` and return a type that returns to the previous working dir on drop.
444pub fn set_current_dir(new_cwd: impl AsRef<Path>) -> std::io::Result<AutoRevertToPreviousCWD> {
445    let cwd = env::current_dir()?;
446    env::set_current_dir(new_cwd)?;
447    Ok(AutoRevertToPreviousCWD(cwd))
448}
449
450/// A utility to set the current working dir to the given value, on drop.
451///
452/// # Panics
453///
454/// Note that this will panic if the CWD cannot be set on drop.
455#[derive(Debug)]
456#[must_use]
457pub struct AutoRevertToPreviousCWD(PathBuf);
458
459impl Drop for AutoRevertToPreviousCWD {
460    fn drop(&mut self) {
461        env::set_current_dir(&self.0).unwrap();
462    }
463}
464
465/// Run `git` in `working_dir` with all provided `args`.
466pub fn run_git(working_dir: &Path, args: &[&str]) -> std::io::Result<std::process::ExitStatus> {
467    let mut cmd = std::process::Command::new(gix_path::env::exe_invocation());
468    apply_git_config_by_environment(&mut cmd, DISABLE_AUTO_MAINTENANCE_CONFIG)
469        .current_dir(working_dir)
470        .args(args)
471        .status()
472}
473
474/// Run `script` with [`bash_program()`] in `cwd`.
475///
476/// Standard input is disconnected while standard output and error stay attached to the inherited
477/// handles.
478///
479/// # Panics
480///
481/// This function expects the script to succeed and will panic otherwise.
482pub fn invoke_bash(cwd: impl AsRef<Path>, script: &str) {
483    let mut cmd = std::process::Command::new(bash_program());
484    let status = apply_git_config_by_environment(&mut cmd, DISABLE_AUTO_MAINTENANCE_CONFIG)
485        .current_dir(cwd)
486        .arg("-c")
487        .arg(script)
488        .stdin(std::process::Stdio::null())
489        .stdout(std::process::Stdio::inherit())
490        .stderr(std::process::Stdio::inherit())
491        .status()
492        .expect("can run bash script");
493    assert!(status.success(), "bash script failed with {status}");
494}
495
496/// Spawn a git daemon to host all repositories at or below `working_dir`.
497///
498/// It runs in the background until the [`GitDaemon`] is dropped.
499pub fn spawn_git_daemon(working_dir: impl AsRef<Path>) -> std::io::Result<GitDaemon> {
500    #[cfg(unix)]
501    {
502        spawn_git_daemon_inetd(working_dir)
503    }
504    #[cfg(not(unix))]
505    {
506        spawn_git_daemon_process(working_dir)
507    }
508}
509
510#[cfg(not(unix))]
511fn spawn_git_daemon_process(working_dir: impl AsRef<Path>) -> std::io::Result<GitDaemon> {
512    let mut ports: Vec<_> = (9419u16..9419 + 100).collect();
513    fastrand::shuffle(&mut ports);
514    let addr_at = |port| std::net::SocketAddr::from(([127, 0, 0, 1], port));
515    let free_port = {
516        let listener = std::net::TcpListener::bind(ports.into_iter().map(addr_at).collect::<Vec<_>>().as_slice())?;
517        listener.local_addr().expect("listener address is available").port()
518    };
519
520    let child = {
521        let mut cmd =
522            std::process::Command::new(GIT_CORE_DIR.join(if cfg!(windows) { "git-daemon.exe" } else { "git-daemon" }));
523        apply_git_config_by_environment(&mut cmd, DISABLE_AUTO_MAINTENANCE_CONFIG)
524            .current_dir(working_dir)
525            .args(["--verbose", "--base-path=.", "--export-all", "--user-path"])
526            .arg(format!("--port={free_port}"))
527            .spawn()?
528    };
529
530    let server_addr = addr_at(free_port);
531    for time in gix_lock::backoff::Quadratic::default_with_random() {
532        std::thread::sleep(time);
533        if std::net::TcpStream::connect(server_addr).is_ok() {
534            break;
535        }
536    }
537    Ok(GitDaemon {
538        process: GitDaemonProcess::Child(child),
539        url: format!("git://{server_addr}"),
540    })
541}
542
543#[cfg(unix)]
544fn spawn_git_daemon_inetd(working_dir: impl AsRef<Path>) -> std::io::Result<GitDaemon> {
545    use std::{
546        net::{TcpListener, TcpStream},
547        os::fd::{FromRawFd, IntoRawFd},
548        process::Stdio,
549        sync::{
550            Arc,
551            atomic::{AtomicBool, Ordering},
552        },
553    };
554
555    fn stream_to_stdio(stream: TcpStream) -> Stdio {
556        // SAFETY: `into_raw_fd()` transfers ownership of the socket fd, and `Stdio`
557        // takes over closing it in the spawned child.
558        unsafe { Stdio::from_raw_fd(stream.into_raw_fd()) }
559    }
560
561    let working_dir = working_dir.as_ref().to_owned();
562    let listener = TcpListener::bind(("127.0.0.1", 0))?;
563    let server_addr = listener.local_addr()?;
564    let shutdown = Arc::new(AtomicBool::new(false));
565    let listener_thread = std::thread::spawn({
566        let shutdown = shutdown.clone();
567        move || {
568            for incoming in listener.incoming() {
569                let stream = match incoming {
570                    Ok(stream) => stream,
571                    Err(_) => break,
572                };
573                if shutdown.load(Ordering::SeqCst) {
574                    break;
575                }
576
577                let peer_addr = stream.peer_addr().ok();
578                let stdin = match stream.try_clone() {
579                    Ok(stream) => stream_to_stdio(stream),
580                    Err(_) => continue,
581                };
582                let stdout = stream_to_stdio(stream);
583                let mut cmd = std::process::Command::new(gix_path::env::exe_invocation());
584                let Ok(mut child) = apply_git_config_by_environment(&mut cmd, DISABLE_AUTO_MAINTENANCE_CONFIG)
585                    .args([
586                        "-c",
587                        "uploadpack.allowrefinwant",
588                        "daemon",
589                        "--inetd",
590                        "--verbose",
591                        "--base-path=.",
592                        "--export-all",
593                        "--user-path",
594                    ])
595                    .current_dir(&working_dir)
596                    .stdin(stdin)
597                    .stdout(stdout)
598                    .stderr(Stdio::null())
599                    .envs(remote_env(peer_addr))
600                    .spawn()
601                else {
602                    continue;
603                };
604
605                std::thread::spawn(move || {
606                    let _ = child.wait();
607                });
608            }
609        }
610    });
611
612    Ok(GitDaemon {
613        process: GitDaemonProcess::Inetd {
614            shutdown,
615            server_addr,
616            listener_thread: Some(listener_thread),
617        },
618        url: format!("git://{server_addr}"),
619    })
620}
621
622#[cfg(unix)]
623fn remote_env(peer_addr: Option<std::net::SocketAddr>) -> Vec<(&'static str, String)> {
624    peer_addr
625        .map(|addr| {
626            vec![
627                ("REMOTE_ADDR", addr.ip().to_string()),
628                ("REMOTE_PORT", addr.port().to_string()),
629            ]
630        })
631        .unwrap_or_default()
632}
633
634/// Don't add a suffix to the archive name as `args` are platform dependent, non-deterministic,
635/// or otherwise don't influence the content of the archive.
636/// Note that this also means that `args` won't be used to control the hash of the archive itself.
637#[derive(Copy, Clone)]
638enum ArgsInHash {
639    Yes,
640    No,
641}
642
643/// Controls whether a scripted fixture may use or must use its archive.
644#[derive(Copy, Clone, Debug, Eq, PartialEq)]
645enum ArchivePolicy {
646    /// Honor `GIX_TEST_IGNORE_ARCHIVES` and generate the fixture if no archive is used.
647    Normal,
648    /// Ignore `GIX_TEST_IGNORE_ARCHIVES`, preferring the archive but falling back to generation.
649    Prefer,
650    /// Ignore `GIX_TEST_IGNORE_ARCHIVES` and return no fixture if the archive is unavailable.
651    Require,
652}
653
654fn archive_policy_for_git_version(is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool) -> ArchivePolicy {
655    if is_git_version_compatible(*GIT_VERSION) {
656        ArchivePolicy::Normal
657    } else {
658        ArchivePolicy::Require
659    }
660}
661
662impl ArchivePolicy {
663    /// Return the subdirectory used to keep this policy's extracted fixture separate.
664    /// This way fixtures extracted from a test that has a stricter policy will not accidentally
665    /// be reused by a test that has a weaker policy.
666    fn cache_variant(self) -> Option<&'static str> {
667        match self {
668            ArchivePolicy::Normal => None,
669            ArchivePolicy::Prefer => Some("archive"),
670            ArchivePolicy::Require => Some("required-archive"),
671        }
672    }
673
674    /// Return whether `GIX_TEST_IGNORE_ARCHIVES` must be ignored when extracting the fixture.
675    ///
676    /// Preferred archives freeze otherwise unstable generated contents, while required archives
677    /// are the only valid source when the installed Git is incompatible. Allowing the environment
678    /// override in either case would defeat that guarantee.
679    fn ignores_archive_override(self) -> bool {
680        !matches!(self, ArchivePolicy::Normal)
681    }
682
683    /// Return whether the fixture script may run when no usable archive is available.
684    ///
685    /// Generation is forbidden for [`ArchivePolicy::Require`] because that policy is selected when
686    /// the installed Git is incompatible; running the script would produce an unsupported fixture.
687    fn allows_generation(self) -> bool {
688        !matches!(self, ArchivePolicy::Require)
689    }
690}
691
692/// Return the path to the `<crate-root>/tests/fixtures/<path>` directory.
693pub fn fixture_path(path: impl AsRef<Path>) -> PathBuf {
694    fixture_base().join(path.as_ref())
695}
696
697fn fixture_base() -> PathBuf {
698    PathBuf::from("tests").join("fixtures")
699}
700
701/// Load the fixture from `<crate-root>/tests/fixtures/<path>` and return its data, or _panic_.
702pub fn fixture_bytes(path: impl AsRef<Path>) -> Vec<u8> {
703    match std::fs::read(fixture_path(path.as_ref())) {
704        Ok(res) => res,
705        Err(_) => panic!("File at '{}' not found", path.as_ref().display()),
706    }
707}
708
709/// Run the executable at `script_name`, like `make_repo.sh` or `my_setup.py` to produce a read-only directory to which
710/// the path is returned.
711///
712/// Note that it persists and the script at `script_name` will only be executed once if it ran without error.
713///
714/// ### Automatic Archive Creation
715///
716/// In order to speed up CI and even local runs should the cache get purged, the result of each script run
717/// is automatically placed into a compressed _tar_ archive.
718/// If a script result doesn't exist, these will be checked first and extracted if present, which they are by default.
719/// This behaviour can be prohibited by setting the `GIX_TEST_IGNORE_ARCHIVES` to any value.
720///
721/// To speed CI up, one can add these archives to the repository. Since LFS is not currently being used, it is
722/// important to check their size first, though in most cases generated archives will not be very large.
723///
724/// #### Disable Archive Creation
725///
726/// If archives aren't useful, they can be disabled by using `.gitignore` specifications.
727/// That way it's trivial to prevent creation of all archives with `generated-archives/*.tar{.xz}` in the root
728/// or more specific `.gitignore` configurations in lower levels of the work tree.
729///
730/// The latter is useful if the script's output is platform specific.
731pub fn scripted_fixture_read_only(script_name: impl AsRef<Path>) -> Result<PathBuf> {
732    scripted_fixture_read_only_with_args(script_name, None::<String>)
733}
734
735/// Like [`scripted_fixture_read_only()`], but uses a matching existing archive even if
736/// `GIX_TEST_IGNORE_ARCHIVES` is set.
737///
738/// Use this only for fixtures whose generated contents are not stable across
739/// platforms or filesystems and must therefore be frozen by the checked-in
740/// archive.
741///
742/// CI normally sets `GIX_TEST_IGNORE_ARCHIVES` so fixture scripts are rerun and
743/// tracked archives are proven reproducible. This helper is the opt-out for
744/// fixtures where rerunning the producer can legitimately change
745/// without changing semantics, for example when Git writes entries in filesystem
746/// traversal order.
747pub fn scripted_fixture_read_only_needs_archive(script_name: impl AsRef<Path>) -> Result<PathBuf> {
748    scripted_fixture_read_only_with_args_inner::<fn(FixtureState<'_>) -> PostResult, ()>(
749        script_name,
750        None::<String>,
751        None,
752        ArgsInHash::Yes,
753        default_excludes(),
754        None::<(u32, _)>,
755        ArchivePolicy::Prefer,
756    )
757    .map(|fixture| fixture.expect("preferred archives fall back to generation").0)
758}
759
760/// Produce a read-only scripted fixture when the installed Git version is compatible, or extract it from a matching
761/// archive otherwise.
762///
763/// `is_git_version_compatible` receives [`GIT_VERSION`]. If it returns `true`, this behaves like
764/// [`scripted_fixture_read_only()`]. Otherwise, `GIX_TEST_IGNORE_ARCHIVES` is ignored and the fixture is only made
765/// available by extracting an archive whose identity matches the fixture script. The script is never run with an
766/// incompatible Git version, and `None` is returned if no matching archive is available.
767pub fn scripted_fixture_read_only_with_git_version(
768    script_name: impl AsRef<Path>,
769    is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
770) -> Result<Option<PathBuf>> {
771    scripted_fixture_read_only_with_args_with_git_version(script_name, None::<String>, is_git_version_compatible)
772}
773
774/// Run the executable at `script_name`, like `make_repo.sh` to produce a writable directory to which
775/// the tempdir is returned. It will be removed automatically, courtesy of [`tempfile::TempDir`].
776///
777/// Note that `script_name` is only executed once, so the data can be copied from its read-only location.
778pub fn scripted_fixture_writable(script_name: impl AsRef<Path>) -> Result<tempfile::TempDir> {
779    scripted_fixture_writable_with_args(script_name, None::<String>, Creation::CopyFromReadOnly)
780}
781
782/// Produce a writable scripted fixture when the installed Git version is compatible, or extract it from a matching
783/// archive otherwise.
784///
785/// This is the writable equivalent of [`scripted_fixture_read_only_with_git_version()`]. It returns `None` when Git is
786/// incompatible and no matching archive is available.
787pub fn scripted_fixture_writable_with_git_version(
788    script_name: impl AsRef<Path>,
789    is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
790) -> Result<Option<tempfile::TempDir>> {
791    scripted_fixture_writable_with_args_with_git_version(
792        script_name,
793        None::<String>,
794        Creation::CopyFromReadOnly,
795        is_git_version_compatible,
796    )
797}
798
799/// Like [`scripted_fixture_writable()`], but passes `args` to `script_name` while providing control over
800/// the way files are created with `mode`.
801pub fn scripted_fixture_writable_with_args(
802    script_name: impl AsRef<Path>,
803    args: impl IntoIterator<Item = impl Into<String>>,
804    mode: Creation,
805) -> Result<tempfile::TempDir> {
806    scripted_fixture_writable_with_args_inner::<fn(FixtureState<'_>) -> PostResult, ()>(
807        script_name,
808        args,
809        mode,
810        ArgsInHash::Yes,
811        default_excludes(),
812        None::<(u32, _)>,
813        ArchivePolicy::Normal,
814    )
815    .map(|fixture| fixture.expect("normal fixtures fall back to generation").0)
816}
817
818/// Like [`scripted_fixture_writable_with_git_version()`], but passes `args` to `script_name` while providing control
819/// over the way files are created with `mode`.
820pub fn scripted_fixture_writable_with_args_with_git_version(
821    script_name: impl AsRef<Path>,
822    args: impl IntoIterator<Item = impl Into<String>>,
823    mode: Creation,
824    is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
825) -> Result<Option<tempfile::TempDir>> {
826    scripted_fixture_writable_with_args_inner::<fn(FixtureState<'_>) -> PostResult, ()>(
827        script_name,
828        args,
829        mode,
830        ArgsInHash::Yes,
831        default_excludes(),
832        None::<(u32, _)>,
833        archive_policy_for_git_version(is_git_version_compatible),
834    )
835    .map(|fixture| fixture.map(|(dir, _)| dir))
836}
837
838/// Like [`scripted_fixture_writable()`], but passes `args` to `script_name` while providing control over
839/// the way files are created with `mode`.
840///
841/// See [`scripted_fixture_read_only_with_args_single_archive()`] for important details on what `single_archive` means.
842pub fn scripted_fixture_writable_with_args_single_archive(
843    script_name: impl AsRef<Path>,
844    args: impl IntoIterator<Item = impl Into<String>>,
845    mode: Creation,
846) -> Result<tempfile::TempDir> {
847    scripted_fixture_writable_with_args_inner::<fn(FixtureState<'_>) -> PostResult, ()>(
848        script_name,
849        args,
850        mode,
851        ArgsInHash::No,
852        default_excludes(),
853        None::<(u32, _)>,
854        ArchivePolicy::Normal,
855    )
856    .map(|fixture| fixture.expect("normal fixtures fall back to generation").0)
857}
858
859/// Like [`scripted_fixture_writable_with_args_with_git_version()`], but uses a single archive for all argument sets.
860pub fn scripted_fixture_writable_with_args_single_archive_with_git_version(
861    script_name: impl AsRef<Path>,
862    args: impl IntoIterator<Item = impl Into<String>>,
863    mode: Creation,
864    is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
865) -> Result<Option<tempfile::TempDir>> {
866    scripted_fixture_writable_with_args_inner::<fn(FixtureState<'_>) -> PostResult, ()>(
867        script_name,
868        args,
869        mode,
870        ArgsInHash::No,
871        default_excludes(),
872        None::<(u32, _)>,
873        archive_policy_for_git_version(is_git_version_compatible),
874    )
875    .map(|fixture| fixture.map(|(dir, _)| dir))
876}
877
878fn scripted_fixture_writable_with_args_inner<F, T>(
879    script_name: impl AsRef<Path>,
880    args: impl IntoIterator<Item = impl Into<String>>,
881    mode: Creation,
882    args_in_hash: ArgsInHash,
883    excludes: &dyn IsExcluded,
884    mut post_process: Option<(u32, F)>,
885    archive_policy: ArchivePolicy,
886) -> Result<Option<(tempfile::TempDir, Option<T>)>>
887where
888    F: FnMut(FixtureState<'_>) -> PostResult<T>,
889{
890    let dst = tempfile::TempDir::new()?;
891    match mode {
892        Creation::CopyFromReadOnly => {
893            // Create the read-only fixture with post_process (modifications are cached)
894            let Some((ro_dir, post_result)) = scripted_fixture_read_only_with_args_inner(
895                script_name,
896                args,
897                None,
898                args_in_hash,
899                excludes,
900                post_process.as_mut().map(|(v, f)| (*v, f)),
901                archive_policy,
902            )?
903            else {
904                return Ok(None);
905            };
906            copy_recursively_into_existing_dir(ro_dir, dst.path())?;
907            Ok(Some((dst, post_result)))
908        }
909        Creation::Execute => {
910            // Execute directly in the temp dir with post_process
911            let Some((_, post_result)) = scripted_fixture_read_only_with_args_inner(
912                script_name,
913                args,
914                dst.path().into(),
915                args_in_hash,
916                excludes,
917                post_process.as_mut().map(|(v, f)| (*v, f)),
918                archive_policy,
919            )?
920            else {
921                return Ok(None);
922            };
923            Ok(Some((dst, post_result)))
924        }
925    }
926}
927
928/// A utility to copy the entire contents of `src_dir` into `dst_dir`.
929pub fn copy_recursively_into_existing_dir(src_dir: impl AsRef<Path>, dst_dir: impl AsRef<Path>) -> std::io::Result<()> {
930    fs_extra::copy_items(
931        &std::fs::read_dir(src_dir)?
932            .map(|e| e.map(|e| e.path()))
933            .collect::<std::result::Result<Vec<_>, _>>()?,
934        dst_dir,
935        &fs_extra::dir::CopyOptions {
936            overwrite: false,
937            skip_exist: false,
938            copy_inside: false,
939            content_only: false,
940            ..Default::default()
941        },
942    )
943    .map_err(std::io::Error::other)?;
944    Ok(())
945}
946
947/// Like [`scripted_fixture_read_only()`], but passes `args` to `script_name`.
948pub fn scripted_fixture_read_only_with_args(
949    script_name: impl AsRef<Path>,
950    args: impl IntoIterator<Item = impl Into<String>>,
951) -> Result<PathBuf> {
952    scripted_fixture_read_only_with_args_inner::<fn(FixtureState<'_>) -> PostResult, ()>(
953        script_name,
954        args,
955        None,
956        ArgsInHash::Yes,
957        default_excludes(),
958        None::<(u32, _)>,
959        ArchivePolicy::Normal,
960    )
961    .map(|fixture| fixture.expect("normal fixtures fall back to generation").0)
962}
963
964/// Like [`scripted_fixture_read_only_with_git_version()`], but passes `args` to `script_name`.
965pub fn scripted_fixture_read_only_with_args_with_git_version(
966    script_name: impl AsRef<Path>,
967    args: impl IntoIterator<Item = impl Into<String>>,
968    is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
969) -> Result<Option<PathBuf>> {
970    scripted_fixture_read_only_with_args_inner::<fn(FixtureState<'_>) -> PostResult, ()>(
971        script_name,
972        args,
973        None,
974        ArgsInHash::Yes,
975        default_excludes(),
976        None::<(u32, _)>,
977        archive_policy_for_git_version(is_git_version_compatible),
978    )
979    .map(|fixture| fixture.map(|(dir, _)| dir))
980}
981
982/// Like `scripted_fixture_read_only()`], but passes `args` to `script_name`.
983///
984/// Also, don't add a suffix to the archive name as `args` are platform dependent, none-deterministic,
985/// or otherwise don't influence the content of the archive.
986/// Note that this also means that `args` won't be used to control the hash of the archive itself.
987///
988/// Sometimes, this should be combined with adding the archive name to `.gitignore` to prevent its creation
989/// in the first place.
990///
991/// Note that suffixing archives by default helps to learn what calls are made, and forces the author to
992/// think about what should be done to get it right.
993pub fn scripted_fixture_read_only_with_args_single_archive(
994    script_name: impl AsRef<Path>,
995    args: impl IntoIterator<Item = impl Into<String>>,
996) -> Result<PathBuf> {
997    scripted_fixture_read_only_with_args_inner::<fn(FixtureState<'_>) -> PostResult, ()>(
998        script_name,
999        args,
1000        None,
1001        ArgsInHash::No,
1002        default_excludes(),
1003        None::<(u32, _)>,
1004        ArchivePolicy::Normal,
1005    )
1006    .map(|fixture| fixture.expect("normal fixtures fall back to generation").0)
1007}
1008
1009/// Like [`scripted_fixture_read_only_with_args_with_git_version()`], but uses a single archive for all argument sets.
1010pub fn scripted_fixture_read_only_with_args_single_archive_with_git_version(
1011    script_name: impl AsRef<Path>,
1012    args: impl IntoIterator<Item = impl Into<String>>,
1013    is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
1014) -> Result<Option<PathBuf>> {
1015    scripted_fixture_read_only_with_args_inner::<fn(FixtureState<'_>) -> PostResult, ()>(
1016        script_name,
1017        args,
1018        None,
1019        ArgsInHash::No,
1020        default_excludes(),
1021        None::<(u32, _)>,
1022        archive_policy_for_git_version(is_git_version_compatible),
1023    )
1024    .map(|fixture| fixture.map(|(dir, _)| dir))
1025}
1026
1027/// Like [`scripted_fixture_read_only`], but runs a Rust closure after the script completes.
1028///
1029/// - `version` should be incremented when the closure's behavior changes to invalidate the cache.
1030/// - The closure receives a [`FixtureState`] enum indicating whether the fixture is newly created
1031///   or was loaded from cache.
1032/// - For uninitialized fixtures, the closure can modify the directory and compute values.
1033/// - For fresh fixtures, the closure should only compute values without modifications.
1034/// - The closure always runs, ensuring the returned value is always available.
1035pub fn scripted_fixture_read_only_with_post<T>(
1036    script_name: impl AsRef<Path>,
1037    version: u32,
1038    post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1039) -> Result<(PathBuf, T)> {
1040    scripted_fixture_read_only_with_args_inner(
1041        script_name,
1042        None::<String>,
1043        None,
1044        ArgsInHash::Yes,
1045        default_excludes(),
1046        Some((version, post_process)),
1047        ArchivePolicy::Normal,
1048    )
1049    .map(|fixture| {
1050        let (path, opt) = fixture.expect("normal fixtures fall back to generation");
1051        (path, opt.expect("post_process was provided"))
1052    })
1053}
1054
1055/// Like [`scripted_fixture_read_only_with_git_version()`], but runs a Rust closure after the script completes.
1056pub fn scripted_fixture_read_only_with_post_with_git_version<T>(
1057    script_name: impl AsRef<Path>,
1058    version: u32,
1059    post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1060    is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
1061) -> Result<Option<(PathBuf, T)>> {
1062    scripted_fixture_read_only_with_args_with_post_with_git_version(
1063        script_name,
1064        None::<String>,
1065        version,
1066        post_process,
1067        is_git_version_compatible,
1068    )
1069}
1070
1071/// Like [`scripted_fixture_read_only_with_args`], but runs a Rust closure after the script completes.
1072///
1073/// See [`scripted_fixture_read_only_with_post`] for details on the closure behavior.
1074pub fn scripted_fixture_read_only_with_args_with_post<T>(
1075    script_name: impl AsRef<Path>,
1076    args: impl IntoIterator<Item = impl Into<String>>,
1077    version: u32,
1078    post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1079) -> Result<(PathBuf, T)> {
1080    scripted_fixture_read_only_with_args_inner(
1081        script_name,
1082        args,
1083        None,
1084        ArgsInHash::Yes,
1085        default_excludes(),
1086        Some((version, post_process)),
1087        ArchivePolicy::Normal,
1088    )
1089    .map(|fixture| {
1090        let (path, opt) = fixture.expect("normal fixtures fall back to generation");
1091        (path, opt.expect("post_process was provided"))
1092    })
1093}
1094
1095/// Like [`scripted_fixture_read_only_with_args_with_git_version()`], but runs a Rust closure after the script completes.
1096pub fn scripted_fixture_read_only_with_args_with_post_with_git_version<T>(
1097    script_name: impl AsRef<Path>,
1098    args: impl IntoIterator<Item = impl Into<String>>,
1099    version: u32,
1100    post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1101    is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
1102) -> Result<Option<(PathBuf, T)>> {
1103    scripted_fixture_read_only_with_args_inner(
1104        script_name,
1105        args,
1106        None,
1107        ArgsInHash::Yes,
1108        default_excludes(),
1109        Some((version, post_process)),
1110        archive_policy_for_git_version(is_git_version_compatible),
1111    )
1112    .map(|fixture| fixture.map(|(path, opt)| (path, opt.expect("post_process was provided"))))
1113}
1114
1115/// Like [`scripted_fixture_read_only_with_args_single_archive`], but runs a Rust closure after the script completes.
1116///
1117/// See [`scripted_fixture_read_only_with_post`] for details on the closure behavior.
1118pub fn scripted_fixture_read_only_with_args_single_archive_with_post<T>(
1119    script_name: impl AsRef<Path>,
1120    args: impl IntoIterator<Item = impl Into<String>>,
1121    version: u32,
1122    post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1123) -> Result<(PathBuf, T)> {
1124    scripted_fixture_read_only_with_args_inner(
1125        script_name,
1126        args,
1127        None,
1128        ArgsInHash::No,
1129        default_excludes(),
1130        Some((version, post_process)),
1131        ArchivePolicy::Normal,
1132    )
1133    .map(|fixture| {
1134        let (path, opt) = fixture.expect("normal fixtures fall back to generation");
1135        (path, opt.expect("post_process was provided"))
1136    })
1137}
1138
1139/// Like [`scripted_fixture_read_only_with_args_single_archive_with_git_version()`], but runs a Rust closure after the
1140/// script completes.
1141pub fn scripted_fixture_read_only_with_args_single_archive_with_post_with_git_version<T>(
1142    script_name: impl AsRef<Path>,
1143    args: impl IntoIterator<Item = impl Into<String>>,
1144    version: u32,
1145    post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1146    is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
1147) -> Result<Option<(PathBuf, T)>> {
1148    scripted_fixture_read_only_with_args_inner(
1149        script_name,
1150        args,
1151        None,
1152        ArgsInHash::No,
1153        default_excludes(),
1154        Some((version, post_process)),
1155        archive_policy_for_git_version(is_git_version_compatible),
1156    )
1157    .map(|fixture| fixture.map(|(path, opt)| (path, opt.expect("post_process was provided"))))
1158}
1159
1160/// Like [`scripted_fixture_writable`], but runs a Rust closure after the script completes.
1161///
1162/// - `version` should be incremented when the closure's behavior changes to invalidate the cache.
1163/// - The closure receives a [`FixtureState`] enum indicating whether the fixture is newly created
1164///   (`Fresh`) or was loaded from cache (`Cached`). Both variants carry the fixture directory path.
1165/// - For `Fresh` fixtures, the closure can modify the directory and compute values.
1166/// - For `Cached` fixtures, the closure should only compute values without modifications.
1167/// - The closure always runs, ensuring the returned value is always available.
1168pub fn scripted_fixture_writable_with_post<T>(
1169    script_name: impl AsRef<Path>,
1170    version: u32,
1171    post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1172) -> Result<(tempfile::TempDir, T)> {
1173    scripted_fixture_writable_with_args_inner(
1174        script_name,
1175        None::<String>,
1176        Creation::CopyFromReadOnly,
1177        ArgsInHash::Yes,
1178        default_excludes(),
1179        Some((version, post_process)),
1180        ArchivePolicy::Normal,
1181    )
1182    .map(|fixture| {
1183        let (tmp, opt) = fixture.expect("normal fixtures fall back to generation");
1184        (tmp, opt.expect("post_process was provided"))
1185    })
1186}
1187
1188/// Like [`scripted_fixture_writable_with_git_version()`], but runs a Rust closure after the script completes.
1189pub fn scripted_fixture_writable_with_post_with_git_version<T>(
1190    script_name: impl AsRef<Path>,
1191    version: u32,
1192    post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1193    is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
1194) -> Result<Option<(tempfile::TempDir, T)>> {
1195    scripted_fixture_writable_with_args_with_post_with_git_version(
1196        script_name,
1197        None::<String>,
1198        Creation::CopyFromReadOnly,
1199        version,
1200        post_process,
1201        is_git_version_compatible,
1202    )
1203}
1204
1205/// Like [`scripted_fixture_writable_with_args`], but runs a Rust closure after the script completes.
1206///
1207/// See [`scripted_fixture_writable_with_post`] for details on the closure behavior.
1208pub fn scripted_fixture_writable_with_args_with_post<T>(
1209    script_name: impl AsRef<Path>,
1210    args: impl IntoIterator<Item = impl Into<String>>,
1211    mode: Creation,
1212    version: u32,
1213    post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1214) -> Result<(tempfile::TempDir, T)> {
1215    scripted_fixture_writable_with_args_inner(
1216        script_name,
1217        args,
1218        mode,
1219        ArgsInHash::Yes,
1220        default_excludes(),
1221        Some((version, post_process)),
1222        ArchivePolicy::Normal,
1223    )
1224    .map(|fixture| {
1225        let (tmp, opt) = fixture.expect("normal fixtures fall back to generation");
1226        (tmp, opt.expect("post_process was provided"))
1227    })
1228}
1229
1230/// Like [`scripted_fixture_writable_with_args_with_git_version()`], but runs a Rust closure after the script completes.
1231pub fn scripted_fixture_writable_with_args_with_post_with_git_version<T>(
1232    script_name: impl AsRef<Path>,
1233    args: impl IntoIterator<Item = impl Into<String>>,
1234    mode: Creation,
1235    version: u32,
1236    post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1237    is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
1238) -> Result<Option<(tempfile::TempDir, T)>> {
1239    scripted_fixture_writable_with_args_inner(
1240        script_name,
1241        args,
1242        mode,
1243        ArgsInHash::Yes,
1244        default_excludes(),
1245        Some((version, post_process)),
1246        archive_policy_for_git_version(is_git_version_compatible),
1247    )
1248    .map(|fixture| fixture.map(|(tmp, opt)| (tmp, opt.expect("post_process was provided"))))
1249}
1250
1251/// Like [`scripted_fixture_writable_with_args_single_archive`], but runs a Rust closure after the script completes.
1252///
1253/// See [`scripted_fixture_writable_with_post`] for details on the closure behavior.
1254pub fn scripted_fixture_writable_with_args_single_archive_with_post<T>(
1255    script_name: impl AsRef<Path>,
1256    args: impl IntoIterator<Item = impl Into<String>>,
1257    mode: Creation,
1258    version: u32,
1259    post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1260) -> Result<(tempfile::TempDir, T)> {
1261    scripted_fixture_writable_with_args_inner(
1262        script_name,
1263        args,
1264        mode,
1265        ArgsInHash::No,
1266        default_excludes(),
1267        Some((version, post_process)),
1268        ArchivePolicy::Normal,
1269    )
1270    .map(|fixture| {
1271        let (tmp, opt) = fixture.expect("normal fixtures fall back to generation");
1272        (tmp, opt.expect("post_process was provided"))
1273    })
1274}
1275
1276/// Like [`scripted_fixture_writable_with_args_single_archive_with_git_version()`], but runs a Rust closure after the
1277/// script completes.
1278pub fn scripted_fixture_writable_with_args_single_archive_with_post_with_git_version<T>(
1279    script_name: impl AsRef<Path>,
1280    args: impl IntoIterator<Item = impl Into<String>>,
1281    mode: Creation,
1282    version: u32,
1283    post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1284    is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
1285) -> Result<Option<(tempfile::TempDir, T)>> {
1286    scripted_fixture_writable_with_args_inner(
1287        script_name,
1288        args,
1289        mode,
1290        ArgsInHash::No,
1291        default_excludes(),
1292        Some((version, post_process)),
1293        archive_policy_for_git_version(is_git_version_compatible),
1294    )
1295    .map(|fixture| fixture.map(|(tmp, opt)| (tmp, opt.expect("post_process was provided"))))
1296}
1297
1298/// Execute a Rust closure in a directory, returning a read-only fixture path.
1299///
1300/// - `version` should be incremented when the closure's behavior changes to invalidate the cache.
1301/// - `name` is used to identify this fixture for caching purposes and should be unique within the crate.
1302/// - `make_fixture(fixture_state)` is the closure that creates the fixture, with the `fixture_state`,
1303///   indicating whether or not the fixture should be written to.
1304///
1305/// This is an alternative to script-based fixtures that allows creating fixtures in pure Rust,
1306/// while still benefiting from the caching system.
1307///
1308/// ### Archive Creation
1309///
1310/// Just like script-based fixtures, the result is cached and compressed archives can be created.
1311/// Increment the `version` number whenever the closure's behavior changes to force recreation.
1312///
1313/// #### Disable Archive Creation
1314///
1315/// Archives can be disabled by using `.gitignore` specifications,
1316/// for example `generated-archives/rust-*.tar` or `generated-archives/rust-*.tar.xz`
1317/// in the `tests/fixtures` directory.
1318///
1319/// ### Example
1320///
1321/// ```no_run
1322/// use gix_testtools::{Result, FixtureState};
1323///
1324/// #[test]
1325/// fn test_with_rust_fixture() -> Result {
1326///     let (dir, _) = gix_testtools::rust_fixture_read_only("my_fixture", 1, |state| {
1327///         if let FixtureState::Uninitialized(path) = state {
1328///             std::fs::write(path.join("file.txt"), "content")?;
1329///         }
1330///         Ok(())
1331///     })?;
1332///     assert!(dir.join("file.txt").exists());
1333///     Ok(())
1334/// }
1335/// ```
1336pub fn rust_fixture_read_only<T, F>(name: &str, version: u32, make_fixture: F) -> Result<(PathBuf, T)>
1337where
1338    F: FnOnce(FixtureState<'_>) -> PostResult<T>,
1339{
1340    rust_fixture_read_only_inner(name, version, None, make_fixture, None, default_excludes())
1341}
1342
1343/// Execute a Rust closure in a directory, returning a writable temporary directory.
1344///
1345/// The closure is used to create a fixture in the given directory.
1346/// The resulting directory is writable and will be automatically cleaned up when the returned
1347/// [`tempfile::TempDir`] is dropped.
1348/// It may be called multiple times, and the returned `T` will be primed on the final, writable location.
1349///
1350/// `version` should be incremented when the closure's behavior changes to invalidate the cache.
1351/// `name` is used to identify this fixture for caching purposes and should be unique within the crate.
1352///
1353/// ### Example
1354///
1355/// ```no_run
1356/// use gix_testtools::{Result, Creation, FixtureState};
1357///
1358/// #[test]
1359/// fn test_with_writable_rust_fixture() -> Result {
1360///     let (dir, ()) = gix_testtools::rust_fixture_writable("my_fixture", 1, Creation::CopyFromReadOnly, |state| {
1361///         if let FixtureState::Uninitialized(path) = state {
1362///             std::fs::write(path.join("file.txt"), "content")?;
1363///         }
1364///         Ok(())
1365///     })?;
1366///     // Can modify files in dir
1367///     std::fs::write(dir.path().join("new_file.txt"), "new content")?;
1368///     Ok(())
1369/// }
1370/// ```
1371pub fn rust_fixture_writable<T, F>(
1372    name: &str,
1373    version: u32,
1374    mode: Creation,
1375    make_fixture: F,
1376) -> Result<(tempfile::TempDir, T)>
1377where
1378    F: FnMut(FixtureState<'_>) -> PostResult<T>,
1379{
1380    rust_fixture_writable_inner(name, version, None, make_fixture, mode, default_excludes())
1381}
1382
1383fn rust_fixture_writable_inner<T, F>(
1384    name: &str,
1385    version: u32,
1386    object_hash: Option<gix_hash::Kind>,
1387    mut make_fixture: F,
1388    mode: Creation,
1389    excludes: &dyn IsExcluded,
1390) -> Result<(tempfile::TempDir, T)>
1391where
1392    F: FnMut(FixtureState<'_>) -> PostResult<T>,
1393{
1394    let dst = tempfile::TempDir::new()?;
1395    let res = match mode {
1396        Creation::CopyFromReadOnly => {
1397            let (ro_dir, _res_ignored) =
1398                rust_fixture_read_only_inner(name, version, object_hash, &mut make_fixture, None, excludes)?;
1399            copy_recursively_into_existing_dir(ro_dir, dst.path())?;
1400            make_fixture(FixtureState::Fresh(dst.path()))?
1401        }
1402        Creation::Execute => {
1403            let (_, res) =
1404                rust_fixture_read_only_inner(name, version, object_hash, make_fixture, Some(dst.path()), excludes)?;
1405            res
1406        }
1407    };
1408    Ok((dst, res))
1409}
1410
1411fn rust_fixture_read_only_inner<T, F>(
1412    name: &str,
1413    version: u32,
1414    object_hash: Option<gix_hash::Kind>,
1415    make_fixture: F,
1416    destination_dir: Option<&Path>,
1417    excludes: &dyn IsExcluded,
1418) -> Result<(PathBuf, T)>
1419where
1420    F: FnOnce(FixtureState<'_>) -> PostResult<T>,
1421{
1422    // Assure tempfiles get removed when aborting the test.
1423    gix_tempfile::signal::setup(
1424        gix_tempfile::signal::handler::Mode::DeleteTempfilesOnTerminationAndRestoreDefaultBehaviour,
1425    );
1426
1427    // For Rust fixtures, the identity is simply the provided version number.
1428    // Users must increment this manually when the closure behavior changes.
1429    let script_identity = version;
1430    let archive_name = format!("rust-{name}");
1431    let fixture_base = fixture_base();
1432
1433    let archive_file_path = fixture_base
1434        .join(ARCHIVE_DIR_NAME)
1435        .join(format!("{archive_name}.{}", tar_extension()));
1436    let (force_run, script_result_directory) = force_and_dir(
1437        destination_dir,
1438        &fixture_base,
1439        &archive_name,
1440        object_hash,
1441        &script_identity,
1442        None,
1443    );
1444    let _marker = marker_if_needed(destination_dir, archive_name)?;
1445
1446    run_fixture_generator_with_marker_handling(
1447        &archive_file_path,
1448        &script_result_directory,
1449        script_identity,
1450        force_run,
1451        ArchivePolicy::Normal,
1452        excludes,
1453        &format!("using Rust closure '{name}'"),
1454        make_fixture,
1455    )
1456    .map(|res| {
1457        (
1458            script_result_directory,
1459            res.expect("normal fixtures fall back to generation"),
1460        )
1461    })
1462}
1463
1464// We may assume that destination_dir is already unique (i.e. temp-dir) if present - thus there is no need for a lock,
1465// 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.
1466fn marker_if_needed(
1467    destination_dir: Option<&Path>,
1468    archive_name: impl AsRef<Path>,
1469) -> Result<Option<gix_lock::Marker>> {
1470    Ok(destination_dir
1471        .is_none()
1472        .then(|| {
1473            gix_lock::Marker::acquire_to_hold_resource(
1474                archive_name,
1475                gix_lock::acquire::Fail::AfterDurationWithBackoff(Duration::from_secs(6 * 60)),
1476                None,
1477            )
1478        })
1479        .transpose()?)
1480}
1481
1482fn force_and_dir(
1483    destination_dir: Option<&Path>,
1484    fixture_base: &Path,
1485    archive_name: impl AsRef<Path>,
1486    object_hash: Option<gix_hash::Kind>,
1487    script_identity: &dyn std::fmt::Display,
1488    cache_variant: Option<&str>,
1489) -> (bool, PathBuf) {
1490    destination_dir.map_or_else(
1491        || {
1492            let mut dir = fixture_base.join(
1493                Path::new("generated-do-not-edit")
1494                    .join(archive_name)
1495                    .join(object_hash.unwrap_or_else(self::object_hash).to_string()),
1496            );
1497            if let Some(cache_variant) = cache_variant {
1498                dir = dir.join(cache_variant);
1499            }
1500            let dir = dir.join(format!("{}-{}", script_identity, family_name()));
1501            (false, dir)
1502        },
1503        |d| (true, d.to_owned()),
1504    )
1505}
1506
1507#[expect(clippy::too_many_arguments)]
1508fn run_fixture_generator_with_marker_handling<T, F>(
1509    archive_file_path: &Path,
1510    script_result_directory: &Path,
1511    script_identity: u32,
1512    force_run: bool,
1513    archive_policy: ArchivePolicy,
1514    excludes: &dyn IsExcluded,
1515    description: &str,
1516    make_fixture: F,
1517) -> Result<Option<T>>
1518where
1519    F: FnOnce(FixtureState<'_>) -> PostResult<T>,
1520{
1521    let failure_marker = script_result_directory.join("_invalid_state_due_to_script_failure_");
1522    if force_run || !script_result_directory.is_dir() || failure_marker.is_file() {
1523        if failure_marker.is_file() {
1524            std::fs::remove_dir_all(script_result_directory).map_err(|err| {
1525                format!(
1526                    "Failed to remove '{script_result_directory}', please try to do that by hand. Original error: {err}",
1527                    script_result_directory = script_result_directory.display()
1528                )
1529            })?;
1530        }
1531        std::fs::create_dir_all(script_result_directory)?;
1532        // An explicit destination requests execution in that exact location. Normal archives may contain absolute
1533        // paths (for example linked-worktree administration files), so extracting one would violate that contract.
1534        // Preferred and required archives remain authoritative even with an explicit destination.
1535        if !force_run || archive_policy != ArchivePolicy::Normal {
1536            match extract_archive(
1537                archive_file_path,
1538                script_result_directory,
1539                script_identity,
1540                archive_policy.ignores_archive_override(),
1541            ) {
1542                Ok((archive_id, platform)) => {
1543                    eprintln!(
1544                        "Extracted fixture from archive '{}' ({}, {:?})",
1545                        archive_file_path.display(),
1546                        archive_id,
1547                        platform
1548                    );
1549                    return make_fixture(FixtureState::Fresh(script_result_directory)).map(Some);
1550                }
1551                Err(err) => {
1552                    let archive_missing = err.kind() == std::io::ErrorKind::NotFound;
1553                    let generation_allowed = archive_policy.allows_generation();
1554                    if !generation_allowed || !archive_missing {
1555                        // Remove incomplete output, or an empty required-fixture directory that a later call could
1556                        // mistake for a valid cached fixture.
1557                        std::fs::remove_dir_all(script_result_directory).map_err(|cleanup_err| {
1558                            format!(
1559                                "Failed to remove incomplete fixture at '{}': {cleanup_err}",
1560                                script_result_directory.display()
1561                            )
1562                        })?;
1563                    }
1564                    if !generation_allowed {
1565                        if archive_missing {
1566                            return Ok(None);
1567                        }
1568                        return Err(err.into());
1569                    }
1570                    if !archive_missing {
1571                        eprintln!("failed to extract '{}': {}", archive_file_path.display(), err);
1572                        std::fs::create_dir_all(script_result_directory)?;
1573                    } else if !excludes.is_excluded(archive_file_path) {
1574                        eprintln!(
1575                            "Archive at '{}' not found, creating fixture {}",
1576                            archive_file_path.display(),
1577                            description
1578                        );
1579                    }
1580                }
1581            }
1582        }
1583        let res = match make_fixture(FixtureState::Uninitialized(script_result_directory)) {
1584            Ok(value) => value,
1585            Err(err) => {
1586                write_failure_marker(&failure_marker);
1587                return Err(err);
1588            }
1589        };
1590        if !force_run {
1591            create_archive_if_we_should(script_result_directory, archive_file_path, script_identity, excludes)
1592                .inspect_err(|_err| {
1593                    write_failure_marker(&failure_marker);
1594                })?;
1595        }
1596        Ok(Some(res))
1597    } else {
1598        make_fixture(FixtureState::Fresh(script_result_directory)).map(Some)
1599    }
1600}
1601
1602fn scripted_fixture_read_only_with_args_inner<F, T>(
1603    script_name: impl AsRef<Path>,
1604    args: impl IntoIterator<Item = impl Into<String>>,
1605    destination_dir: Option<&Path>,
1606    args_in_hash: ArgsInHash,
1607    excludes: &dyn IsExcluded,
1608    post_process: Option<(u32, F)>,
1609    archive_policy: ArchivePolicy,
1610) -> Result<Option<(PathBuf, Option<T>)>>
1611where
1612    F: FnMut(FixtureState<'_>) -> PostResult<T>,
1613{
1614    // Assure tempfiles get removed when aborting the test.
1615    gix_tempfile::signal::setup(
1616        gix_tempfile::signal::handler::Mode::DeleteTempfilesOnTerminationAndRestoreDefaultBehaviour,
1617    );
1618
1619    let object_hash = object_hash();
1620
1621    let script_location = script_name.as_ref();
1622    let fixture_base = fixture_base();
1623    let script_path = fixture_path(script_location);
1624
1625    // keep this lock to assure we don't return unfinished directories for threaded callers
1626    let args: Vec<String> = args.into_iter().map(Into::into).collect();
1627    let post_version = post_process.as_ref().map(|(v, _)| *v);
1628    let script_identity = {
1629        let mut map = SCRIPT_IDENTITY.lock();
1630        let init = if is_sha1(object_hash) {
1631            script_path.clone()
1632        } else {
1633            script_path.clone().join(object_hash.to_string())
1634        };
1635        let key = args.iter().fold(init, |p, a| p.join(a));
1636        // Include post_version in the key if present
1637        let key = if let Some(v) = post_version {
1638            key.join(format!("post-v{v}"))
1639        } else {
1640            key
1641        };
1642        map.entry(key)
1643            .or_insert_with(|| {
1644                let crc_value = crc::Crc::<u32>::new(&crc::CRC_32_CKSUM);
1645                let mut crc_digest = crc_value.digest();
1646                crc_digest.update(&std::fs::read(&script_path).unwrap_or_else(|err| {
1647                    panic!(
1648                        "file {script_path} in CWD '{cwd}' could not be read: {err}",
1649                        cwd = env::current_dir().expect("valid cwd").display(),
1650                        script_path = script_path.display(),
1651                    )
1652                }));
1653                for arg in &args {
1654                    crc_digest.update(arg.as_bytes());
1655                }
1656                // Hash the post_process version if present
1657                if let Some(v) = post_version {
1658                    crc_digest.update(&v.to_le_bytes());
1659                }
1660                crc_digest.finalize()
1661            })
1662            .to_owned()
1663    };
1664
1665    let script_basename = script_location.file_stem().unwrap_or(script_location.as_os_str());
1666    let archive_file_path = fixture_base.join(ARCHIVE_DIR_NAME).join({
1667        let suffix = match args_in_hash {
1668            ArgsInHash::Yes => {
1669                let mut suffix = args.join("_");
1670                if !suffix.is_empty() {
1671                    suffix.insert(0, '_');
1672                }
1673                suffix.replace(['\\', '/', ' ', '.'], "_")
1674            }
1675            ArgsInHash::No => "".into(),
1676        };
1677        let potential_hash_suffix = if is_sha1(object_hash) {
1678            "".into()
1679        } else {
1680            format!("_{object_hash}")
1681        };
1682        format!(
1683            "{}{suffix}{potential_hash_suffix}.{}",
1684            script_basename.to_str().expect("valid UTF-8"),
1685            tar_extension()
1686        )
1687    });
1688    let (force_run, script_result_directory) = force_and_dir(
1689        destination_dir,
1690        &fixture_base,
1691        script_basename,
1692        Some(object_hash),
1693        &script_identity,
1694        archive_policy.cache_variant(),
1695    );
1696    let _marker = marker_if_needed(destination_dir, script_basename)?;
1697
1698    let script_identity_for_archive = match args_in_hash {
1699        ArgsInHash::Yes => script_identity,
1700        ArgsInHash::No => 0,
1701    };
1702    let script_absolute_path = env::current_dir()?.join(&script_path);
1703    let post_process_closure = post_process.map(|(_, f)| f);
1704
1705    let res = run_fixture_generator_with_marker_handling(
1706        &archive_file_path,
1707        &script_result_directory,
1708        script_identity_for_archive,
1709        force_run,
1710        archive_policy,
1711        excludes,
1712        &format!("using script '{}'", script_location.display()),
1713        |fixture_state| {
1714            if let FixtureState::Uninitialized(dir) = fixture_state {
1715                let mut cmd = std::process::Command::new(&script_absolute_path);
1716                let output = match configure_command(&mut cmd, object_hash, &args, dir).output() {
1717                    Ok(out) => out,
1718                    Err(err)
1719                        if err.kind() == std::io::ErrorKind::PermissionDenied
1720                            || err.raw_os_error() == Some(193) /* windows */ =>
1721                    {
1722                        cmd = std::process::Command::new(bash_program());
1723                        configure_command(cmd.arg(&script_absolute_path), object_hash, &args, dir).output()?
1724                    }
1725                    Err(err) => return Err(err.into()),
1726                };
1727                if !output.status.success() {
1728                    eprintln!("stdout: {}", output.stdout.as_bstr());
1729                    eprintln!("stderr: {}", output.stderr.as_bstr());
1730                    return Err(format!("fixture script of {cmd:?} failed").into());
1731                }
1732            }
1733            if let Some(mut f) = post_process_closure {
1734                f(fixture_state).map(Some)
1735            } else {
1736                Ok(None)
1737            }
1738        },
1739    )?;
1740
1741    Ok(res.map(|res| (script_result_directory, res)))
1742}
1743
1744/// Returns the hash function that is used when creating or loading test fixtures.
1745///
1746/// The value returned is derived from the environment variable `GIX_TEST_FIXTURE_HASH`.
1747/// Use this, e. g., when you need to run different assertions depending on the hash
1748/// function used in a specific fixture.
1749///
1750/// Returns `None` if the environment variable isn't set.
1751///
1752/// # Panics
1753///
1754/// If the value set in `GIX_TEST_FIXTURE_HASH` is not valid.
1755pub fn object_hash_from_env() -> Option<gix_hash::Kind> {
1756    static FIXTURE_HASH: LazyLock<Option<gix_hash::Kind>> = LazyLock::new(|| {
1757        env::var_os("GIX_TEST_FIXTURE_HASH").and_then(|value| value.into_string().ok()).map(|object_kind| {
1758        gix_hash::Kind::from_str(&object_kind).unwrap_or_else(|_| {
1759                    panic!(
1760                        "GIX_TEST_FIXTURE_HASH was set to {object_kind} which is an invalid value. Valid values are {}. Exiting.",
1761                        gix_hash::Kind::all().iter().map(std::string::ToString::to_string).collect::<Vec<_>>().join(", ")
1762                    )
1763                })
1764    })
1765    });
1766    *FIXTURE_HASH
1767}
1768
1769/// Like [`object_hash_from_env()`], but returns the default hash if `GIX_TEST_FIXTURE_HASH` is not set.
1770pub fn object_hash() -> gix_hash::Kind {
1771    object_hash_from_env().unwrap_or_default()
1772}
1773
1774fn is_sha1(kind: gix_hash::Kind) -> bool {
1775    kind.len_in_bytes() == 20
1776}
1777
1778/// Run `git` in `current_dir` with shell-like whitespace-separated `arguments`, returning stdout as UTF-8.
1779///
1780/// Note that Git is run as isolated as possible, just like scripts.
1781///
1782/// Arguments may be split across multiple lines. Single and double quotes can be used to keep whitespace
1783/// within an argument, for example `commit -m 'a message with spaces'`.
1784pub fn git(current_dir: impl AsRef<Path>, arguments: &str) -> Result<String> {
1785    let args = split_git_arguments(arguments)?;
1786    let cwd = current_dir.as_ref();
1787    let mut cmd = std::process::Command::new(gix_path::env::exe_invocation());
1788    let output = configure_command(&mut cmd, object_hash(), args.iter().map(String::as_str), cwd)
1789        .current_dir(cwd)
1790        .output()?;
1791    if !output.status.success() {
1792        return Err(format!(
1793            "{cmd:?} failed with status {}\nstdout: {}\nstderr: {}",
1794            output.status,
1795            output.stdout.as_bstr(),
1796            output.stderr.as_bstr()
1797        )
1798        .into());
1799    }
1800    Ok(String::from_utf8(output.stdout)?)
1801}
1802
1803fn split_git_arguments(input: &str) -> Result<Vec<String>> {
1804    let mut args = Vec::new();
1805    let mut arg = String::new();
1806    let mut quote = None;
1807    let mut has_arg = false;
1808    let mut chars = input.chars();
1809
1810    while let Some(ch) = chars.next() {
1811        match quote {
1812            Some('\'') => {
1813                if ch == '\'' {
1814                    quote = None;
1815                } else {
1816                    arg.push(ch);
1817                }
1818            }
1819            Some('"') => {
1820                if ch == '"' {
1821                    quote = None;
1822                } else if ch == '\\' {
1823                    if let Some(next) = chars.next() {
1824                        arg.push(next);
1825                    }
1826                } else {
1827                    arg.push(ch);
1828                }
1829            }
1830            Some(_) => unreachable!("only single and double quotes are set"),
1831            None => {
1832                if ch.is_whitespace() {
1833                    if has_arg {
1834                        args.push(std::mem::take(&mut arg));
1835                        has_arg = false;
1836                    }
1837                } else if matches!(ch, '\'' | '"') {
1838                    quote = Some(ch);
1839                    has_arg = true;
1840                } else if ch == '\\' {
1841                    if let Some(next) = chars.next() {
1842                        arg.push(next);
1843                    }
1844                    has_arg = true;
1845                } else {
1846                    arg.push(ch);
1847                    has_arg = true;
1848                }
1849            }
1850        }
1851    }
1852
1853    if let Some(quote) = quote {
1854        return Err(format!("unterminated {quote:?} quote in git arguments").into());
1855    }
1856    if has_arg {
1857        args.push(arg);
1858    }
1859    Ok(args)
1860}
1861
1862/// Normalize debug-formatted `value` so one snapshot can be reused for SHA-1 and SHA-256 fixtures.
1863///
1864/// The helper rewrites 40- and 64-character hexadecimal object IDs to stable `Oid(<n>)`
1865/// placeholders in first-seen order while leaving the surrounding pretty-debug formatting untouched.
1866/// Debug wrappers like `Sha1(<hex>)` and `Sha256(<hex>)` are collapsed to the same placeholder.
1867/// It also returns the replaced object IDs in first-seen order, so `Oid(n)` can be looked up as
1868/// `result.1[n - 1]`.
1869pub fn normalize_debug_snapshot(value: &dyn std::fmt::Debug) -> (String, Vec<gix_hash::ObjectId>) {
1870    normalize_hashes(&format!("{value:#?}"))
1871}
1872
1873/// Normalize 40- and 64-character hexadecimal object IDs in `input`.
1874///
1875/// This is like [`normalize_debug_snapshot()`], but operates on already-formatted text.
1876pub fn normalize_hashes(input: &str) -> (String, Vec<gix_hash::ObjectId>) {
1877    let mut out = String::with_capacity(input.len());
1878    let mut seen = HashMap::<gix_hash::ObjectId, usize>::new();
1879    let mut removed = Vec::<gix_hash::ObjectId>::new();
1880    let mut chars = input.chars().peekable();
1881    let mut hex = String::new();
1882
1883    while let Some(ch) = chars.next() {
1884        if ch.is_ascii_hexdigit() {
1885            hex.clear();
1886            hex.push(ch);
1887            while let Some(ch) = chars.next_if(char::is_ascii_hexdigit) {
1888                hex.push(ch);
1889            }
1890
1891            if let Some(oid) = raw_object_id(&hex) {
1892                strip_debug_hash_wrapper(&mut out, &mut chars);
1893                push_normalized_oid(oid, &mut seen, &mut removed, &mut out);
1894            } else {
1895                out.push_str(&hex);
1896            }
1897        } else {
1898            out.push(ch);
1899        }
1900    }
1901    (out, removed)
1902}
1903
1904fn raw_object_id(input: &str) -> Option<gix_hash::ObjectId> {
1905    if !matches!(input.len(), 40 | 64) {
1906        return None;
1907    }
1908    gix_hash::ObjectId::from_hex(input.as_bytes()).ok()
1909}
1910
1911fn strip_debug_hash_wrapper(out: &mut String, chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
1912    if !matches!(chars.peek(), Some(')')) {
1913        return;
1914    }
1915    // The `Sha1(` / `Sha256(` prefix was already copied before the hex run was
1916    // recognized as an object ID. Remove it, then consume the matching `)`.
1917    let consume_closing_parenthesis = if out.ends_with("Sha1(") {
1918        out.truncate(out.len() - "Sha1(".len());
1919        true
1920    } else if out.ends_with("Sha256(") {
1921        out.truncate(out.len() - "Sha256(".len());
1922        true
1923    } else {
1924        false
1925    };
1926    if consume_closing_parenthesis {
1927        chars.next();
1928    }
1929}
1930
1931fn push_normalized_oid(
1932    oid: gix_hash::ObjectId,
1933    seen: &mut HashMap<gix_hash::ObjectId, usize>,
1934    removed: &mut Vec<gix_hash::ObjectId>,
1935    out: &mut String,
1936) {
1937    let normalized = *seen.entry(oid).or_insert_with(|| {
1938        let current = removed.len();
1939        removed.push(oid);
1940        current
1941    });
1942
1943    out.push_str("Oid(");
1944    out.push_str(&(normalized + 1).to_string());
1945    out.push(')');
1946}
1947
1948#[cfg(windows)]
1949const NULL_DEVICE: &str = "nul"; // See `gix_path::env::git::NULL_DEVICE` on why this form is used.
1950#[cfg(not(windows))]
1951const NULL_DEVICE: &str = "/dev/null";
1952
1953/// Ensure fixture scripts resolve `git` to the same executable used by direct helpers and version checks.
1954///
1955/// Scripts invoke `git` through `PATH`, whereas [`gix_path::env::exe_invocation()`] may select an absolute executable
1956/// outside the inherited `PATH`. Without preferring its directory, a version check can inspect a newer Git while the
1957/// fixture subsequently runs an older one which lacks the checked feature.
1958fn prefer_git_in_path(command: &mut std::process::Command, git: &Path) {
1959    let Some(parent) = git.is_absolute().then(|| git.parent()).flatten() else {
1960        return;
1961    };
1962    let inherited_path = env::var_os("PATH").unwrap_or_default();
1963    let paths = std::iter::once(parent.to_owned()).chain(env::split_paths(&inherited_path));
1964    if let Ok(path) = env::join_paths(paths) {
1965        command.env("PATH", path);
1966    }
1967}
1968
1969fn configure_command<'a, I: IntoIterator<Item = S>, S: AsRef<OsStr>>(
1970    cmd: &'a mut std::process::Command,
1971    object_hash: gix_hash::Kind,
1972    args: I,
1973    script_result_directory: &Path,
1974) -> &'a mut std::process::Command {
1975    // For simplicity, we extend the `MSYS` variable from our own environment. This disregards
1976    // state from any prior `cmd.env("MSYS")` or `cmd.env_remove("MSYS")` calls. Such calls should
1977    // either be avoided, or made after this function returns (but before spawning the command).
1978    let mut msys_for_git_bash_on_windows = env::var_os("MSYS").unwrap_or_default();
1979    msys_for_git_bash_on_windows.push(" winsymlinks:nativestrict");
1980    prefer_git_in_path(cmd, gix_path::env::exe_invocation());
1981    cmd.args(args)
1982        .stdout(std::process::Stdio::piped())
1983        .stderr(std::process::Stdio::piped())
1984        .current_dir(script_result_directory)
1985        .env_remove("GIT_DIR")
1986        .env_remove("GIT_INDEX_FILE")
1987        .env_remove("GIT_OBJECT_DIRECTORY")
1988        .env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES")
1989        .env_remove("GIT_WORK_TREE")
1990        .env_remove("GIT_COMMON_DIR")
1991        .env_remove("GIT_ASKPASS")
1992        .env_remove("SSH_ASKPASS")
1993        .env("MSYS", msys_for_git_bash_on_windows)
1994        .env(
1995            "XDG_CONFIG_HOME",
1996            script_result_directory.join(".gix-testtools-xdg-config"),
1997        )
1998        .env("GIT_CONFIG_NOSYSTEM", "1")
1999        .env("GIT_CONFIG_GLOBAL", NULL_DEVICE)
2000        .env("GIT_TERMINAL_PROMPT", "false")
2001        .env("GIT_AUTHOR_DATE", "2000-01-01 00:00:00 +0000")
2002        .env("GIT_AUTHOR_EMAIL", "author@example.com")
2003        .env("GIT_AUTHOR_NAME", "author")
2004        .env("GIT_COMMITTER_DATE", "2000-01-02 00:00:00 +0000")
2005        .env("GIT_COMMITTER_EMAIL", "committer@example.com")
2006        .env("GIT_COMMITTER_NAME", "committer")
2007        .env("GIT_DEFAULT_HASH", object_hash.to_string());
2008    apply_git_config_by_environment(cmd, ISOLATED_GIT_CONFIG)
2009}
2010
2011/// Apply command-scoped Git `config` to `cmd`, and return it.
2012///
2013/// This sets `GIT_CONFIG_COUNT` and matching `GIT_CONFIG_KEY_<n>` /
2014/// `GIT_CONFIG_VALUE_<n>` environment variables, which Git treats like
2015/// command-line `-c <key>=<value>` entries for the spawned process. Existing
2016/// values for these variables on `cmd` are overwritten for the configured
2017/// indices.
2018pub fn apply_git_config_by_environment<'a>(
2019    cmd: &'a mut std::process::Command,
2020    config: &[(&str, &str)],
2021) -> &'a mut std::process::Command {
2022    cmd.env("GIT_CONFIG_COUNT", config.len().to_string());
2023    for (idx, (key, value)) in config.iter().enumerate() {
2024        cmd.env(format!("GIT_CONFIG_KEY_{idx}"), key);
2025        cmd.env(format!("GIT_CONFIG_VALUE_{idx}"), value);
2026    }
2027    cmd
2028}
2029
2030/// Get the path attempted as a `bash` interpreter, for fixture scripts having no `#!` we can use.
2031///
2032/// This is rarely called on Unix-like systems, provided that fixture scripts have usable shebang
2033/// (`#!`) lines and are marked executable. However, Windows does not recognize `#!` when executing
2034/// a file. If all fixture scripts that cannot be directly executed are `bash` scripts or can be
2035/// treated as such, fixture generation still works on Windows, as long as this function manages to
2036/// find or guess a suitable `bash` interpreter.
2037///
2038/// ### Search order
2039///
2040/// This function is used internally. It is public to facilitate diagnostic use. The following
2041/// details are subject to change without warning, and changes are treated as non-breaking.
2042///
2043/// The `bash.exe` found in a path search is not always suitable on Windows. This is mainly because
2044/// `bash.exe` in `System32`, which is associated with WSL, would often be found first. But even
2045/// where that is not the case, the best `bash.exe` to use to run fixture scripts to set up Git
2046/// repositories for testing is usually one associated with Git for Windows, even if some other
2047/// `bash.exe` would be found in a path search. Currently, the search order we use is as follows:
2048///
2049/// 1. The shim `bash.exe`, which sets environment variables when run and is, on some systems,
2050///    needed to find the POSIX utilities that scripts need (or correct versions of them).
2051///
2052/// 2. The non-shim `bash.exe`, which is sometimes available even when the shim is not available.
2053///    This is mainly because the Git for Windows SDK does not come with a `bash.exe` shim.
2054///
2055/// 3. As a fallback, the simple name `bash.exe`, which triggers a path search when run.
2056///
2057/// On non-Windows systems, the simple name `bash` is used, which triggers a path search when run.
2058pub fn bash_program() -> &'static Path {
2059    // TODO(deps): Unify with `gix_path::env::shell()` by having both call a more general function
2060    //             in `gix-path`. See https://github.com/GitoxideLabs/gitoxide/issues/1886.
2061    static GIT_BASH: LazyLock<PathBuf> = LazyLock::new(|| {
2062        if cfg!(windows) {
2063            GIT_CORE_DIR
2064                .ancestors()
2065                .nth(3)
2066                .map(OsStr::new)
2067                .iter()
2068                .flat_map(|prefix| {
2069                    // Go down to places `bash.exe` usually is. Keep using `/` separators, not `\`.
2070                    ["/bin/bash.exe", "/usr/bin/bash.exe"].into_iter().map(|suffix| {
2071                        let mut raw_path = (*prefix).to_owned();
2072                        raw_path.push(suffix);
2073                        raw_path
2074                    })
2075                })
2076                .map(PathBuf::from)
2077                .find(|bash| bash.is_file())
2078                .unwrap_or_else(|| "bash.exe".into())
2079        } else {
2080            "bash".into()
2081        }
2082    });
2083    GIT_BASH.as_ref()
2084}
2085
2086fn write_failure_marker(failure_marker: &Path) {
2087    std::fs::write(failure_marker, []).ok();
2088}
2089
2090fn should_skip_all_archive_creation() -> bool {
2091    // On Windows, we fail to remove the meta_dir and can't do anything about it, which means tests will see more
2092    // in the directory than they should which makes them fail. It's probably a bad idea to generate archives on Windows
2093    // anyway. Either Unix is portable OR no archive is created anywhere. This also means that Windows users can't create
2094    // archives, but that's not a deal-breaker.
2095    cfg!(windows) || (is_ci::cached() && env::var_os("GIX_TEST_CREATE_ARCHIVES_EVEN_ON_CI").is_none())
2096}
2097
2098fn is_lfs_pointer_file(path: &Path) -> bool {
2099    const PREFIX: &[u8] = b"version https://git-lfs";
2100    let mut buf = [0_u8; PREFIX.len()];
2101    std::fs::OpenOptions::new()
2102        .read(true)
2103        .open(path)
2104        .is_ok_and(|mut f| f.read_exact(&mut buf).is_ok_and(|_| buf.starts_with(PREFIX)))
2105}
2106
2107/// The `script_identity` will be baked into the soon to be created `archive` as it identifies the script
2108/// that created the contents of `source_dir`.
2109fn create_archive_if_we_should(
2110    source_dir: &Path,
2111    archive: &Path,
2112    script_identity: u32,
2113    excludes: &dyn IsExcluded,
2114) -> std::io::Result<()> {
2115    if should_skip_all_archive_creation() || excludes.is_excluded(archive) {
2116        return Ok(());
2117    }
2118    if is_lfs_pointer_file(archive) {
2119        eprintln!(
2120            "Refusing to overwrite `gix-lfs` pointer file at \"{}\" - git lfs might not be properly installed.",
2121            archive.display()
2122        );
2123        return Ok(());
2124    }
2125    std::fs::create_dir_all(archive.parent().expect("archive is a file"))?;
2126
2127    let meta_dir = populate_meta_dir(source_dir, script_identity)?;
2128    let res = (move || {
2129        let mut buf = Vec::<u8>::new();
2130        {
2131            let mut ar = tar::Builder::new(&mut buf);
2132            ar.mode(tar::HeaderMode::Deterministic);
2133            ar.follow_symlinks(false);
2134            ar.append_dir_all(".", source_dir)?;
2135            ar.finish()?;
2136        }
2137        #[cfg_attr(feature = "xz", allow(unused_mut))]
2138        let mut archive = std::fs::OpenOptions::new()
2139            .write(true)
2140            .create(true)
2141            .truncate(true)
2142            .open(archive)?;
2143        #[cfg(feature = "xz")]
2144        {
2145            let mut xz_write = xz2::write::XzEncoder::new(archive, 3);
2146            std::io::copy(&mut &*buf, &mut xz_write)?;
2147            xz_write.finish()?.close()
2148        }
2149        #[cfg(not(feature = "xz"))]
2150        {
2151            use std::io::Write;
2152            archive.write_all(&buf)?;
2153            archive.close()
2154        }
2155    })();
2156    #[cfg(not(windows))]
2157    std::fs::remove_dir_all(meta_dir)?;
2158    #[cfg(windows)]
2159    std::fs::remove_dir_all(meta_dir).ok(); // it really can't delete these directories for some reason (even after 10 seconds)
2160
2161    res
2162}
2163
2164const META_DIR_NAME: &str = "__gitoxide_meta__";
2165const META_IDENTITY: &str = "identity";
2166const META_GIT_VERSION: &str = "git-version";
2167
2168fn populate_meta_dir(destination_dir: &Path, script_identity: u32) -> std::io::Result<PathBuf> {
2169    let meta_dir = destination_dir.join(META_DIR_NAME);
2170    std::fs::create_dir_all(&meta_dir)?;
2171    std::fs::write(
2172        meta_dir.join(META_IDENTITY),
2173        format!("{}-{}", script_identity, family_name()).as_bytes(),
2174    )?;
2175    let (major, minor, patch) = *GIT_VERSION;
2176    std::fs::write(
2177        meta_dir.join(META_GIT_VERSION),
2178        format!("git version {major}.{minor}.{patch}\n"),
2179    )?;
2180    Ok(meta_dir)
2181}
2182
2183/// `required_script_identity` is the identity of the script that generated the state that is contained in `archive`.
2184/// If this is not the case, the arvhive will be ignored.
2185fn extract_archive(
2186    archive: &Path,
2187    destination_dir: &Path,
2188    required_script_identity: u32,
2189    ignore_archive_override: bool,
2190) -> std::io::Result<(u32, Option<String>)> {
2191    let archive_buf: Vec<u8> = {
2192        let mut buf = Vec::new();
2193        #[cfg_attr(feature = "xz", allow(unused_mut))]
2194        let mut input_archive = std::fs::File::open(archive)?;
2195        if !ignore_archive_override && env::var_os("GIX_TEST_IGNORE_ARCHIVES").is_some() {
2196            return Err(std::io::Error::other(format!(
2197                "Ignoring archive at '{}' as GIX_TEST_IGNORE_ARCHIVES is set.",
2198                archive.display()
2199            )));
2200        }
2201        #[cfg(feature = "xz")]
2202        {
2203            let mut decoder = xz2::bufread::XzDecoder::new(std::io::BufReader::new(input_archive));
2204            std::io::copy(&mut decoder, &mut buf)?;
2205        }
2206        #[cfg(not(feature = "xz"))]
2207        {
2208            input_archive.read_to_end(&mut buf)?;
2209        }
2210        buf
2211    };
2212
2213    let mut entry_buf = Vec::<u8>::new();
2214    let (archive_identity, platform): (u32, _) = tar::Archive::new(std::io::Cursor::new(&mut &*archive_buf))
2215        .entries_with_seek()?
2216        .filter_map(std::result::Result::ok)
2217        .find_map(|mut e: tar::Entry<'_, _>| {
2218            let path = e.path().ok()?;
2219            if path.parent()?.file_name()? == META_DIR_NAME && path.file_name()? == META_IDENTITY {
2220                entry_buf.clear();
2221                e.read_to_end(&mut entry_buf).ok()?;
2222                let mut tokens = entry_buf.to_str().ok()?.trim().splitn(2, '-');
2223                match (tokens.next(), tokens.next()) {
2224                    (Some(id), platform) => Some((id.parse().ok()?, platform.map(ToOwned::to_owned))),
2225                    _ => None,
2226                }
2227            } else {
2228                None
2229            }
2230        })
2231        .ok_or_else(|| std::io::Error::other("BUG: Could not find meta directory in our own archive"))
2232        .map_err(|err| {
2233            std::io::Error::other(format!(
2234                "Could not extract archive at '{archive}': {err}",
2235                archive = archive.display()
2236            ))
2237        })?;
2238    if archive_identity != required_script_identity {
2239        eprintln!(
2240            "Ignoring archive at '{}' as its generating script changed",
2241            archive.display()
2242        );
2243        return Err(std::io::ErrorKind::NotFound.into());
2244    }
2245
2246    for entry in tar::Archive::new(&mut &*archive_buf).entries()? {
2247        let mut entry = entry?;
2248        let path = entry.path()?;
2249        if path.to_str() == Some(META_DIR_NAME) || path.parent().and_then(Path::to_str) == Some(META_DIR_NAME) {
2250            continue;
2251        }
2252        entry.unpack_in(destination_dir)?;
2253    }
2254    Ok((archive_identity, platform))
2255}
2256
2257fn family_name() -> &'static str {
2258    if cfg!(windows) { "windows" } else { "unix" }
2259}
2260
2261/// A utility to set and unset environment variables, while restoring or removing them on drop.
2262#[derive(Default)]
2263pub struct Env<'a> {
2264    altered_vars: Vec<(&'a str, Option<OsString>)>,
2265}
2266
2267fn set_var(var: &str, value: impl AsRef<OsStr>) {
2268    // SAFETY: Tests using this helper are responsible for serializing access to
2269    // process-wide environment variables they mutate.
2270    unsafe { env::set_var(var, value) };
2271}
2272
2273fn remove_var(var: &str) {
2274    // SAFETY: Tests using this helper are responsible for serializing access to
2275    // process-wide environment variables they mutate.
2276    unsafe { env::remove_var(var) };
2277}
2278
2279impl<'a> Env<'a> {
2280    /// Create a new instance.
2281    pub fn new() -> Self {
2282        Env {
2283            altered_vars: Vec::new(),
2284        }
2285    }
2286
2287    /// Set `var` to `value`.
2288    pub fn set(mut self, var: &'a str, value: impl Into<String>) -> Self {
2289        let prev = env::var_os(var);
2290        set_var(var, value.into());
2291        self.altered_vars.push((var, prev));
2292        self
2293    }
2294
2295    /// Unset `var`.
2296    pub fn unset(mut self, var: &'a str) -> Self {
2297        let prev = env::var_os(var);
2298        remove_var(var);
2299        self.altered_vars.push((var, prev));
2300        self
2301    }
2302}
2303
2304impl Drop for Env<'_> {
2305    fn drop(&mut self) {
2306        for (var, prev_value) in self.altered_vars.iter().rev() {
2307            match prev_value {
2308                Some(value) => set_var(var, value),
2309                None => remove_var(var),
2310            }
2311        }
2312    }
2313}
2314
2315/// Check data structure size, comparing strictly on 64-bit targets.
2316///
2317/// - On 32-bit targets, checks if `actual_size` is at most `expected_64_bit_size`.
2318/// - On 64-bit targets, checks if `actual_size` is exactly `expected_64_bit_size`.
2319///
2320/// This is for assertions about the size of data structures, when the goal is to keep them from
2321/// growing too large even across breaking changes. Such assertions must always fail when data
2322/// structures grow larger than they have ever been, for which `<=` is enough. But it also helps to
2323/// know when they have shrunk unexpectedly. They may shrink, other changes may rely on the smaller
2324/// size for acceptable performance, and then they may grow again to their earlier size.
2325///
2326/// The problem with `==` is that data structures are often smaller on 32-bit targets. This could
2327/// be addressed by asserting separate exact 64-bit and 32-bit sizes. But sizes may also differ
2328/// across 32-bit targets, due to ABI and layout/packing details. That can happen across 64-bit
2329/// targets too, but it seems less common.
2330///
2331/// For those reasons, this function does a `==` on 64-bit targets, but a `<=` on 32-bit targets.
2332pub fn size_ok(actual_size: usize, expected_64_bit_size: usize) -> bool {
2333    #[cfg(target_pointer_width = "64")]
2334    return actual_size == expected_64_bit_size;
2335    #[cfg(target_pointer_width = "32")]
2336    return actual_size <= expected_64_bit_size;
2337}
2338
2339/// Get the umask in a way that is safe, but may be too slow for use outside of tests.
2340#[cfg(unix)]
2341pub fn umask() -> u32 {
2342    let output = std::process::Command::new("/bin/sh")
2343        .args(["-c", "umask"])
2344        .output()
2345        .expect("can execute `sh -c umask`");
2346    assert!(output.status.success(), "`sh -c umask` failed");
2347    assert_eq!(output.stderr.as_bstr(), "", "`sh -c umask` unexpected message");
2348    let text = output.stdout.to_str().expect("valid Unicode").trim();
2349    u32::from_str_radix(text, 8).expect("parses as octal number")
2350}
2351
2352fn tar_extension() -> &'static str {
2353    if cfg!(feature = "xz") { "tar.xz" } else { "tar" }
2354}
2355
2356#[cfg(test)]
2357mod tests;