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