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//! ## Feature Flags
4#![cfg_attr(
5    all(doc, feature = "document-features"),
6    doc = ::document_features::document_features!()
7)]
8#![cfg_attr(all(doc, feature = "document-features"), feature(doc_cfg))]
9#![deny(missing_docs)]
10
11use std::{
12    collections::BTreeMap,
13    env,
14    ffi::{OsStr, OsString},
15    io::Read,
16    path::{Path, PathBuf},
17    str::FromStr,
18    time::Duration,
19};
20
21pub use bstr;
22use bstr::ByteSlice;
23use io_close::Close;
24pub use is_ci;
25use parking_lot::Mutex;
26use std::sync::LazyLock;
27pub use tempfile;
28
29/// A result type to allow using the try operator `?` in unit tests.
30///
31/// Use it like so:
32///
33/// ```no_run
34/// use gix_testtools::Result;
35///
36/// #[test]
37/// fn this() -> Result {
38///     let x: usize = "42".parse()?;
39///     Ok(())
40///
41/// }
42/// ```
43pub type Result<T = ()> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
44
45/// A wrapper for a running git-daemon process which is killed automatically on drop.
46///
47/// Note that we will swallow any errors, assuming that the test would have failed if the daemon crashed.
48pub struct GitDaemon {
49    child: std::process::Child,
50    /// The base url under which all repositories are hosted, typically `git://127.0.0.1:port`.
51    pub url: String,
52}
53
54impl Drop for GitDaemon {
55    fn drop(&mut self) {
56        self.child.kill().ok();
57    }
58}
59
60static SCRIPT_IDENTITY: LazyLock<Mutex<BTreeMap<PathBuf, u32>>> = LazyLock::new(|| Mutex::new(BTreeMap::new()));
61
62static EXCLUDE_LUT: LazyLock<Mutex<Option<gix_worktree::Stack>>> = LazyLock::new(|| {
63    let cache = (|| {
64        let (repo_path, _) = gix_discover::upwards(Path::new(".")).ok()?;
65        let (gix_dir, work_tree) = repo_path.into_repository_and_work_tree_directories();
66        let work_tree = work_tree?.canonicalize().ok()?;
67
68        let mut buf = Vec::with_capacity(512);
69        let case = if gix_fs::Capabilities::probe(&work_tree).ignore_case {
70            gix_worktree::ignore::glob::pattern::Case::Fold
71        } else {
72            Default::default()
73        };
74        let state = gix_worktree::stack::State::IgnoreStack(gix_worktree::stack::state::Ignore::new(
75            Default::default(),
76            gix_worktree::ignore::Search::from_git_dir(
77                &gix_dir,
78                None,
79                &mut buf,
80                gix_worktree::stack::state::ignore::ParseIgnore {
81                    support_precious: false,
82                },
83            )
84            .ok()?,
85            None,
86            gix_worktree::stack::state::ignore::Source::WorktreeThenIdMappingIfNotSkipped,
87            Default::default(),
88        ));
89        Some(gix_worktree::Stack::new(
90            work_tree,
91            state,
92            case,
93            buf,
94            Default::default(),
95        ))
96    })();
97    Mutex::new(cache)
98});
99
100#[cfg(windows)]
101const GIT_PROGRAM: &str = "git.exe";
102#[cfg(not(windows))]
103const GIT_PROGRAM: &str = "git";
104
105static GIT_CORE_DIR: LazyLock<PathBuf> = LazyLock::new(|| {
106    let output = std::process::Command::new(GIT_PROGRAM)
107        .arg("--exec-path")
108        .output()
109        .expect("can execute `git --exec-path`");
110
111    assert!(output.status.success(), "`git --exec-path` failed");
112
113    output
114        .stdout
115        .strip_suffix(b"\n")
116        .expect("`git --exec-path` output to be well-formed")
117        .to_os_str()
118        .expect("no invalid UTF-8 in `--exec-path` except as OS allows")
119        .into()
120});
121
122/// The major, minor and patch level of the git version on the system.
123pub static GIT_VERSION: LazyLock<(u8, u8, u8)> =
124    LazyLock::new(|| parse_git_version().expect("git version to be parsable"));
125
126/// Define how [`scripted_fixture_writable_with_args()`] uses produces the writable copy.
127pub enum Creation {
128    /// Run the script once and copy the data from its output to the writable location.
129    /// This is fast but won't work if absolute paths are produced by the script.
130    ///
131    /// ### Limitation
132    ///
133    /// Cannot handle symlinks currently. Waiting for [this PR](https://github.com/webdesus/fs_extra/pull/70).
134    CopyFromReadOnly,
135    /// Run the script in the writable location. That way, absolute paths match the location.
136    ExecuteScript,
137}
138
139/// Returns true if the given `major`, `minor` and `patch` is smaller than the actual git version on the system
140/// to facilitate skipping a test on the caller.
141/// Will never return true on CI which is expected to have a recent enough git version.
142///
143/// # Panics
144///
145/// If `git` cannot be executed or if its version output cannot be parsed.
146pub fn should_skip_as_git_version_is_smaller_than(major: u8, minor: u8, patch: u8) -> bool {
147    if is_ci::cached() {
148        return false; // CI should be made to use a recent git version, it should run there.
149    }
150    *GIT_VERSION < (major, minor, patch)
151}
152
153fn parse_git_version() -> Result<(u8, u8, u8)> {
154    let output = std::process::Command::new(GIT_PROGRAM).arg("--version").output()?;
155    git_version_from_bytes(&output.stdout)
156}
157
158fn git_version_from_bytes(bytes: &[u8]) -> Result<(u8, u8, u8)> {
159    let mut numbers = bytes
160        .split(|b| *b == b' ' || *b == b'\n')
161        .nth(2)
162        .expect("git version <version>")
163        .split(|b| *b == b'.')
164        .take(3)
165        .map(|n| std::str::from_utf8(n).expect("valid utf8 in version number"))
166        .map(u8::from_str);
167
168    Ok((|| -> Result<_> {
169        Ok((
170            numbers.next().expect("major")?,
171            numbers.next().expect("minor")?,
172            numbers.next().expect("patch")?,
173        ))
174    })()
175    .map_err(|err| {
176        format!(
177            "Could not parse version from output of 'git --version' ({:?}) with error: {}",
178            bytes.to_str_lossy(),
179            err
180        )
181    })?)
182}
183
184/// Set the current working dir to `new_cwd` and return a type that returns to the previous working dir on drop.
185pub fn set_current_dir(new_cwd: impl AsRef<Path>) -> std::io::Result<AutoRevertToPreviousCWD> {
186    let cwd = env::current_dir()?;
187    env::set_current_dir(new_cwd)?;
188    Ok(AutoRevertToPreviousCWD(cwd))
189}
190
191/// A utility to set the current working dir to the given value, on drop.
192///
193/// # Panics
194///
195/// Note that this will panic if the CWD cannot be set on drop.
196#[derive(Debug)]
197#[must_use]
198pub struct AutoRevertToPreviousCWD(PathBuf);
199
200impl Drop for AutoRevertToPreviousCWD {
201    fn drop(&mut self) {
202        env::set_current_dir(&self.0).unwrap();
203    }
204}
205
206/// Run `git` in `working_dir` with all provided `args`.
207pub fn run_git(working_dir: &Path, args: &[&str]) -> std::io::Result<std::process::ExitStatus> {
208    std::process::Command::new(GIT_PROGRAM)
209        .current_dir(working_dir)
210        .args(args)
211        .status()
212}
213
214/// Spawn a git daemon process to host all repository at or below `working_dir`.
215pub fn spawn_git_daemon(working_dir: impl AsRef<Path>) -> std::io::Result<GitDaemon> {
216    let mut ports: Vec<_> = (9419u16..9419 + 100).collect();
217    fastrand::shuffle(&mut ports);
218    let addr_at = |port| std::net::SocketAddr::from(([127, 0, 0, 1], port));
219    let free_port = {
220        let listener = std::net::TcpListener::bind(ports.into_iter().map(addr_at).collect::<Vec<_>>().as_slice())?;
221        listener.local_addr().expect("listener address is available").port()
222    };
223
224    let child =
225        std::process::Command::new(GIT_CORE_DIR.join(if cfg!(windows) { "git-daemon.exe" } else { "git-daemon" }))
226            .current_dir(working_dir)
227            .args(["--verbose", "--base-path=.", "--export-all", "--user-path"])
228            .arg(format!("--port={free_port}"))
229            .spawn()?;
230
231    let server_addr = addr_at(free_port);
232    for time in gix_lock::backoff::Quadratic::default_with_random() {
233        std::thread::sleep(time);
234        if std::net::TcpStream::connect(server_addr).is_ok() {
235            break;
236        }
237    }
238    Ok(GitDaemon {
239        child,
240        url: format!("git://{server_addr}"),
241    })
242}
243
244#[derive(Copy, Clone)]
245enum DirectoryRoot {
246    IntegrationTest,
247    StandaloneTest,
248}
249
250/// Don't add a suffix to the archive name as `args` are platform dependent, none-deterministic,
251/// or otherwise don't influence the content of the archive.
252/// Note that this also means that `args` won't be used to control the hash of the archive itself.
253#[derive(Copy, Clone)]
254enum ArgsInHash {
255    Yes,
256    No,
257}
258
259/// Return the path to the `<crate-root>/tests/fixtures/<path>` directory.
260pub fn fixture_path(path: impl AsRef<Path>) -> PathBuf {
261    fixture_path_inner(path, DirectoryRoot::IntegrationTest)
262}
263
264/// Return the path to the `<crate-root>/fixtures/<path>` directory.
265pub fn fixture_path_standalone(path: impl AsRef<Path>) -> PathBuf {
266    fixture_path_inner(path, DirectoryRoot::StandaloneTest)
267}
268/// Return the path to the `<crate-root>/tests/fixtures/<path>` directory.
269fn fixture_path_inner(path: impl AsRef<Path>, root: DirectoryRoot) -> PathBuf {
270    match root {
271        DirectoryRoot::StandaloneTest => PathBuf::from("fixtures").join(path.as_ref()),
272        DirectoryRoot::IntegrationTest => PathBuf::from("tests").join("fixtures").join(path.as_ref()),
273    }
274}
275
276/// Load the fixture from `<crate-root>/tests/fixtures/<path>` and return its data, or _panic_.
277pub fn fixture_bytes(path: impl AsRef<Path>) -> Vec<u8> {
278    fixture_bytes_inner(path, DirectoryRoot::IntegrationTest)
279}
280
281/// Like [`scripted_fixture_writable`], but does not prefix the fixture directory with `tests`
282pub fn fixture_bytes_standalone(path: impl AsRef<Path>) -> Vec<u8> {
283    fixture_bytes_inner(path, DirectoryRoot::StandaloneTest)
284}
285
286fn fixture_bytes_inner(path: impl AsRef<Path>, root: DirectoryRoot) -> Vec<u8> {
287    match std::fs::read(fixture_path_inner(path.as_ref(), root)) {
288        Ok(res) => res,
289        Err(_) => panic!("File at '{}' not found", path.as_ref().display()),
290    }
291}
292
293/// Run the executable at `script_name`, like `make_repo.sh` or `my_setup.py` to produce a read-only directory to which
294/// the path is returned.
295///
296/// Note that it persists and the script at `script_name` will only be executed once if it ran without error.
297///
298/// ### Automatic Archive Creation
299///
300/// In order to speed up CI and even local runs should the cache get purged, the result of each script run
301/// is automatically placed into a compressed _tar_ archive.
302/// If a script result doesn't exist, these will be checked first and extracted if present, which they are by default.
303/// This behaviour can be prohibited by setting the `GIX_TEST_IGNORE_ARCHIVES` to any value.
304///
305/// To speed CI up, one can add these archives to the repository. Since LFS is not currently being used, it is
306/// important to check their size first, though in most cases generated archives will not be very large.
307///
308/// #### Disable Archive Creation
309///
310/// If archives aren't useful, they can be disabled by using `.gitignore` specifications.
311/// That way it's trivial to prevent creation of all archives with `generated-archives/*.tar{.xz}` in the root
312/// or more specific `.gitignore` configurations in lower levels of the work tree.
313///
314/// The latter is useful if the script's output is platform specific.
315pub fn scripted_fixture_read_only(script_name: impl AsRef<Path>) -> Result<PathBuf> {
316    scripted_fixture_read_only_with_args(script_name, None::<String>)
317}
318
319/// Like [`scripted_fixture_read_only`], but does not prefix the fixture directory with `tests`
320pub fn scripted_fixture_read_only_standalone(script_name: impl AsRef<Path>) -> Result<PathBuf> {
321    scripted_fixture_read_only_with_args_standalone(script_name, None::<String>)
322}
323
324/// Run the executable at `script_name`, like `make_repo.sh` to produce a writable directory to which
325/// the tempdir is returned. It will be removed automatically, courtesy of [`tempfile::TempDir`].
326///
327/// Note that `script_name` is only executed once, so the data can be copied from its read-only location.
328pub fn scripted_fixture_writable(script_name: impl AsRef<Path>) -> Result<tempfile::TempDir> {
329    scripted_fixture_writable_with_args(script_name, None::<String>, Creation::CopyFromReadOnly)
330}
331
332/// Like [`scripted_fixture_writable`], but does not prefix the fixture directory with `tests`
333pub fn scripted_fixture_writable_standalone(script_name: &str) -> Result<tempfile::TempDir> {
334    scripted_fixture_writable_with_args_standalone(script_name, None::<String>, Creation::CopyFromReadOnly)
335}
336
337/// Like [`scripted_fixture_writable()`], but passes `args` to `script_name` while providing control over
338/// the way files are created with `mode`.
339pub fn scripted_fixture_writable_with_args(
340    script_name: impl AsRef<Path>,
341    args: impl IntoIterator<Item = impl Into<String>>,
342    mode: Creation,
343) -> Result<tempfile::TempDir> {
344    scripted_fixture_writable_with_args_inner(script_name, args, mode, DirectoryRoot::IntegrationTest, ArgsInHash::Yes)
345}
346
347/// Like [`scripted_fixture_writable()`], but passes `args` to `script_name` while providing control over
348/// the way files are created with `mode`.
349///
350/// See [`scripted_fixture_read_only_with_args_single_archive()`] for important details on what `single_archive` means.
351pub fn scripted_fixture_writable_with_args_single_archive(
352    script_name: impl AsRef<Path>,
353    args: impl IntoIterator<Item = impl Into<String>>,
354    mode: Creation,
355) -> Result<tempfile::TempDir> {
356    scripted_fixture_writable_with_args_inner(script_name, args, mode, DirectoryRoot::IntegrationTest, ArgsInHash::No)
357}
358
359/// Like [`scripted_fixture_writable_with_args`], but does not prefix the fixture directory with `tests`
360pub fn scripted_fixture_writable_with_args_standalone(
361    script_name: &str,
362    args: impl IntoIterator<Item = impl Into<String>>,
363    mode: Creation,
364) -> Result<tempfile::TempDir> {
365    scripted_fixture_writable_with_args_inner(script_name, args, mode, DirectoryRoot::StandaloneTest, ArgsInHash::Yes)
366}
367
368/// Like [`scripted_fixture_writable_with_args`], but does not prefix the fixture directory with `tests`
369///
370/// See [`scripted_fixture_read_only_with_args_single_archive()`] for important details on what `single_archive` means.
371pub fn scripted_fixture_writable_with_args_standalone_single_archive(
372    script_name: &str,
373    args: impl IntoIterator<Item = impl Into<String>>,
374    mode: Creation,
375) -> Result<tempfile::TempDir> {
376    scripted_fixture_writable_with_args_inner(script_name, args, mode, DirectoryRoot::StandaloneTest, ArgsInHash::No)
377}
378
379fn scripted_fixture_writable_with_args_inner(
380    script_name: impl AsRef<Path>,
381    args: impl IntoIterator<Item = impl Into<String>>,
382    mode: Creation,
383    root: DirectoryRoot,
384    args_in_hash: ArgsInHash,
385) -> Result<tempfile::TempDir> {
386    let dst = tempfile::TempDir::new()?;
387    Ok(match mode {
388        Creation::CopyFromReadOnly => {
389            let ro_dir = scripted_fixture_read_only_with_args_inner(script_name, args, None, root, args_in_hash)?;
390            copy_recursively_into_existing_dir(ro_dir, dst.path())?;
391            dst
392        }
393        Creation::ExecuteScript => {
394            scripted_fixture_read_only_with_args_inner(script_name, args, dst.path().into(), root, args_in_hash)?;
395            dst
396        }
397    })
398}
399
400/// A utility to copy the entire contents of `src_dir` into `dst_dir`.
401pub fn copy_recursively_into_existing_dir(src_dir: impl AsRef<Path>, dst_dir: impl AsRef<Path>) -> std::io::Result<()> {
402    fs_extra::copy_items(
403        &std::fs::read_dir(src_dir)?
404            .map(|e| e.map(|e| e.path()))
405            .collect::<std::result::Result<Vec<_>, _>>()?,
406        dst_dir,
407        &fs_extra::dir::CopyOptions {
408            overwrite: false,
409            skip_exist: false,
410            copy_inside: false,
411            content_only: false,
412            ..Default::default()
413        },
414    )
415    .map_err(std::io::Error::other)?;
416    Ok(())
417}
418
419/// Like `scripted_fixture_read_only()`], but passes `args` to `script_name`.
420pub fn scripted_fixture_read_only_with_args(
421    script_name: impl AsRef<Path>,
422    args: impl IntoIterator<Item = impl Into<String>>,
423) -> Result<PathBuf> {
424    scripted_fixture_read_only_with_args_inner(script_name, args, None, DirectoryRoot::IntegrationTest, ArgsInHash::Yes)
425}
426
427/// Like `scripted_fixture_read_only()`], but passes `args` to `script_name`.
428///
429/// Also, don't add a suffix to the archive name as `args` are platform dependent, none-deterministic,
430/// or otherwise don't influence the content of the archive.
431/// Note that this also means that `args` won't be used to control the hash of the archive itself.
432///
433/// Sometimes, this should be combined with adding the archive name to `.gitignore` to prevent its creation
434/// in the first place.
435///
436/// Note that suffixing archives by default helps to learn what calls are made, and forces the author to
437/// think about what should be done to get it right.
438pub fn scripted_fixture_read_only_with_args_single_archive(
439    script_name: impl AsRef<Path>,
440    args: impl IntoIterator<Item = impl Into<String>>,
441) -> Result<PathBuf> {
442    scripted_fixture_read_only_with_args_inner(script_name, args, None, DirectoryRoot::IntegrationTest, ArgsInHash::No)
443}
444
445/// Like [`scripted_fixture_read_only_with_args()`], but does not prefix the fixture directory with `tests`
446pub fn scripted_fixture_read_only_with_args_standalone(
447    script_name: impl AsRef<Path>,
448    args: impl IntoIterator<Item = impl Into<String>>,
449) -> Result<PathBuf> {
450    scripted_fixture_read_only_with_args_inner(script_name, args, None, DirectoryRoot::StandaloneTest, ArgsInHash::Yes)
451}
452
453/// Like [`scripted_fixture_read_only_with_args_standalone()`], only has a single archive.
454pub fn scripted_fixture_read_only_with_args_standalone_single_archive(
455    script_name: impl AsRef<Path>,
456    args: impl IntoIterator<Item = impl Into<String>>,
457) -> Result<PathBuf> {
458    scripted_fixture_read_only_with_args_inner(script_name, args, None, DirectoryRoot::StandaloneTest, ArgsInHash::No)
459}
460
461fn scripted_fixture_read_only_with_args_inner(
462    script_name: impl AsRef<Path>,
463    args: impl IntoIterator<Item = impl Into<String>>,
464    destination_dir: Option<&Path>,
465    root: DirectoryRoot,
466    args_in_hash: ArgsInHash,
467) -> Result<PathBuf> {
468    // Assure tempfiles get removed when aborting the test.
469    gix_tempfile::signal::setup(
470        gix_tempfile::signal::handler::Mode::DeleteTempfilesOnTerminationAndRestoreDefaultBehaviour,
471    );
472
473    let script_location = script_name.as_ref();
474    let script_path = fixture_path_inner(script_location, root);
475
476    // keep this lock to assure we don't return unfinished directories for threaded callers
477    let args: Vec<String> = args.into_iter().map(Into::into).collect();
478    let script_identity = {
479        let mut map = SCRIPT_IDENTITY.lock();
480        map.entry(args.iter().fold(script_path.clone(), |p, a| p.join(a)))
481            .or_insert_with(|| {
482                let crc_value = crc::Crc::<u32>::new(&crc::CRC_32_CKSUM);
483                let mut crc_digest = crc_value.digest();
484                crc_digest.update(&std::fs::read(&script_path).unwrap_or_else(|err| {
485                    panic!(
486                        "file {script_path} in CWD '{cwd}' could not be read: {err}",
487                        cwd = env::current_dir().expect("valid cwd").display(),
488                        script_path = script_path.display(),
489                    )
490                }));
491                for arg in &args {
492                    crc_digest.update(arg.as_bytes());
493                }
494                crc_digest.finalize()
495            })
496            .to_owned()
497    };
498
499    let script_basename = script_location.file_stem().unwrap_or(script_location.as_os_str());
500    let archive_file_path = fixture_path_inner(
501        {
502            let suffix = match args_in_hash {
503                ArgsInHash::Yes => {
504                    let mut suffix = args.join("_");
505                    if !suffix.is_empty() {
506                        suffix.insert(0, '_');
507                    }
508                    suffix.replace(['\\', '/', ' ', '.'], "_")
509                }
510                ArgsInHash::No => "".into(),
511            };
512            Path::new("generated-archives").join(format!(
513                "{}{suffix}.tar{}",
514                script_basename.to_str().expect("valid UTF-8"),
515                if cfg!(feature = "xz") { ".xz" } else { "" }
516            ))
517        },
518        root,
519    );
520    let (force_run, script_result_directory) = destination_dir.map_or_else(
521        || {
522            let dir = fixture_path_inner(
523                Path::new("generated-do-not-edit").join(script_basename).join(format!(
524                    "{}-{}",
525                    script_identity,
526                    family_name()
527                )),
528                root,
529            );
530            (false, dir)
531        },
532        |d| (true, d.to_owned()),
533    );
534
535    // We may that destination_dir is already unique (i.e. temp-dir) - thus there is no need for a lock,
536    // and we can execute scripts in parallel.
537    let _marker = destination_dir
538        .is_none()
539        .then(|| {
540            gix_lock::Marker::acquire_to_hold_resource(
541                script_basename,
542                gix_lock::acquire::Fail::AfterDurationWithBackoff(Duration::from_secs(6 * 60)),
543                None,
544            )
545        })
546        .transpose()?;
547    let failure_marker = script_result_directory.join("_invalid_state_due_to_script_failure_");
548    if force_run || !script_result_directory.is_dir() || failure_marker.is_file() {
549        if failure_marker.is_file() {
550            std::fs::remove_dir_all(&script_result_directory).map_err(|err| {
551                format!("Failed to remove '{script_result_directory}', please try to do that by hand. Original error: {err}",
552                        script_result_directory = script_result_directory.display())
553            })?;
554        }
555        std::fs::create_dir_all(&script_result_directory)?;
556        let script_identity_for_archive = match args_in_hash {
557            ArgsInHash::Yes => script_identity,
558            ArgsInHash::No => 0,
559        };
560        match extract_archive(
561            &archive_file_path,
562            &script_result_directory,
563            script_identity_for_archive,
564        ) {
565            Ok((archive_id, platform)) => {
566                eprintln!(
567                    "Extracted fixture from archive '{}' ({}, {:?})",
568                    archive_file_path.display(),
569                    archive_id,
570                    platform
571                );
572            }
573            Err(err) => {
574                if err.kind() != std::io::ErrorKind::NotFound {
575                    eprintln!("failed to extract '{}': {}", archive_file_path.display(), err);
576                    std::fs::remove_dir_all(&script_result_directory)
577                        .map_err(|err| {
578                            format!("Failed to remove '{script_result_directory}', please try to do that by hand. Original error: {err}",
579                                    script_result_directory = script_result_directory.display())
580                        })?;
581                    std::fs::create_dir_all(&script_result_directory)?;
582                } else if !is_excluded(&archive_file_path) {
583                    eprintln!(
584                        "Archive at '{}' not found, creating fixture using script '{}'",
585                        archive_file_path.display(),
586                        script_location.display()
587                    );
588                }
589                let script_absolute_path = env::current_dir()?.join(script_path);
590                let mut cmd = std::process::Command::new(&script_absolute_path);
591                let output = match configure_command(&mut cmd, &args, &script_result_directory).output() {
592                    Ok(out) => out,
593                    Err(err)
594                    if err.kind() == std::io::ErrorKind::PermissionDenied || err.raw_os_error() == Some(193) /* windows */ =>
595                        {
596                            cmd = std::process::Command::new(bash_program());
597                            configure_command(cmd.arg(script_absolute_path), &args, &script_result_directory).output()?
598                        }
599                    Err(err) => return Err(err.into()),
600                };
601                if !output.status.success() {
602                    write_failure_marker(&failure_marker);
603                    eprintln!("stdout: {}", output.stdout.as_bstr());
604                    eprintln!("stderr: {}", output.stderr.as_bstr());
605                    return Err(format!("fixture script of {cmd:?} failed").into());
606                }
607                create_archive_if_we_should(
608                    &script_result_directory,
609                    &archive_file_path,
610                    script_identity_for_archive,
611                )
612                .inspect_err(|_err| {
613                    write_failure_marker(&failure_marker);
614                })?;
615            }
616        }
617    }
618    Ok(script_result_directory)
619}
620
621#[cfg(windows)]
622const NULL_DEVICE: &str = "nul"; // See `gix_path::env::git::NULL_DEVICE` on why this form is used.
623#[cfg(not(windows))]
624const NULL_DEVICE: &str = "/dev/null";
625
626fn configure_command<'a, I: IntoIterator<Item = S>, S: AsRef<OsStr>>(
627    cmd: &'a mut std::process::Command,
628    args: I,
629    script_result_directory: &Path,
630) -> &'a mut std::process::Command {
631    // For simplicity, we extend the `MSYS` variable from our own environment. This disregards
632    // state from any prior `cmd.env("MSYS")` or `cmd.env_remove("MSYS")` calls. Such calls should
633    // either be avoided, or made after this function returns (but before spawning the command).
634    let mut msys_for_git_bash_on_windows = env::var_os("MSYS").unwrap_or_default();
635    msys_for_git_bash_on_windows.push(" winsymlinks:nativestrict");
636    cmd.args(args)
637        .stdout(std::process::Stdio::piped())
638        .stderr(std::process::Stdio::piped())
639        .current_dir(script_result_directory)
640        .env_remove("GIT_DIR")
641        .env_remove("GIT_INDEX_FILE")
642        .env_remove("GIT_OBJECT_DIRECTORY")
643        .env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES")
644        .env_remove("GIT_WORK_TREE")
645        .env_remove("GIT_COMMON_DIR")
646        .env_remove("GIT_ASKPASS")
647        .env_remove("SSH_ASKPASS")
648        .env("MSYS", msys_for_git_bash_on_windows)
649        .env("GIT_CONFIG_NOSYSTEM", "1")
650        .env("GIT_CONFIG_GLOBAL", NULL_DEVICE)
651        .env("GIT_TERMINAL_PROMPT", "false")
652        .env("GIT_AUTHOR_DATE", "2000-01-01 00:00:00 +0000")
653        .env("GIT_AUTHOR_EMAIL", "author@example.com")
654        .env("GIT_AUTHOR_NAME", "author")
655        .env("GIT_COMMITTER_DATE", "2000-01-02 00:00:00 +0000")
656        .env("GIT_COMMITTER_EMAIL", "committer@example.com")
657        .env("GIT_COMMITTER_NAME", "committer")
658        .env("GIT_CONFIG_COUNT", "4")
659        .env("GIT_CONFIG_KEY_0", "commit.gpgsign")
660        .env("GIT_CONFIG_VALUE_0", "false")
661        .env("GIT_CONFIG_KEY_1", "tag.gpgsign")
662        .env("GIT_CONFIG_VALUE_1", "false")
663        .env("GIT_CONFIG_KEY_2", "init.defaultBranch")
664        .env("GIT_CONFIG_VALUE_2", "main")
665        .env("GIT_CONFIG_KEY_3", "protocol.file.allow")
666        .env("GIT_CONFIG_VALUE_3", "always")
667}
668
669/// Get the path attempted as a `bash` interpreter, for fixture scripts having no `#!` we can use.
670///
671/// This is rarely called on Unix-like systems, provided that fixture scripts have usable shebang
672/// (`#!`) lines and are marked executable. However, Windows does not recognize `#!` when executing
673/// a file. If all fixture scripts that cannot be directly executed are `bash` scripts or can be
674/// treated as such, fixture generation still works on Windows, as long as this function manages to
675/// find or guess a suitable `bash` interpreter.
676///
677/// ### Search order
678///
679/// This function is used internally. It is public to facilitate diagnostic use. The following
680/// details are subject to change without warning, and changes are treated as non-breaking.
681///
682/// The `bash.exe` found in a path search is not always suitable on Windows. This is mainly because
683/// `bash.exe` in `System32`, which is associated with WSL, would often be found first. But even
684/// where that is not the case, the best `bash.exe` to use to run fixture scripts to set up Git
685/// repositories for testing is usually one associated with Git for Windows, even if some other
686/// `bash.exe` would be found in a path search. Currently, the search order we use is as follows:
687///
688/// 1. The shim `bash.exe`, which sets environment variables when run and is, on some systems,
689///    needed to find the POSIX utilities that scripts need (or correct versions of them).
690///
691/// 2. The non-shim `bash.exe`, which is sometimes available even when the shim is not available.
692///    This is mainly because the Git for Windows SDK does not come with a `bash.exe` shim.
693///
694/// 3. As a fallback, the simple name `bash.exe`, which triggers a path search when run.
695///
696/// On non-Windows systems, the simple name `bash` is used, which triggers a path search when run.
697pub fn bash_program() -> &'static Path {
698    // TODO(deps): Unify with `gix_path::env::shell()` by having both call a more general function
699    //             in `gix-path`. See https://github.com/GitoxideLabs/gitoxide/issues/1886.
700    static GIT_BASH: LazyLock<PathBuf> = LazyLock::new(|| {
701        if cfg!(windows) {
702            GIT_CORE_DIR
703                .ancestors()
704                .nth(3)
705                .map(OsStr::new)
706                .iter()
707                .flat_map(|prefix| {
708                    // Go down to places `bash.exe` usually is. Keep using `/` separators, not `\`.
709                    ["/bin/bash.exe", "/usr/bin/bash.exe"].into_iter().map(|suffix| {
710                        let mut raw_path = (*prefix).to_owned();
711                        raw_path.push(suffix);
712                        raw_path
713                    })
714                })
715                .map(PathBuf::from)
716                .find(|bash| bash.is_file())
717                .unwrap_or_else(|| "bash.exe".into())
718        } else {
719            "bash".into()
720        }
721    });
722    GIT_BASH.as_ref()
723}
724
725fn write_failure_marker(failure_marker: &Path) {
726    std::fs::write(failure_marker, []).ok();
727}
728
729fn should_skip_all_archive_creation() -> bool {
730    // On Windows, we fail to remove the meta_dir and can't do anything about it, which means tests will see more
731    // in the directory than they should which makes them fail. It's probably a bad idea to generate archives on Windows
732    // anyway. Either Unix is portable OR no archive is created anywhere. This also means that Windows users can't create
733    // archives, but that's not a deal-breaker.
734    cfg!(windows) || (is_ci::cached() && env::var_os("GIX_TEST_CREATE_ARCHIVES_EVEN_ON_CI").is_none())
735}
736
737fn is_lfs_pointer_file(path: &Path) -> bool {
738    const PREFIX: &[u8] = b"version https://git-lfs";
739    let mut buf = [0_u8; PREFIX.len()];
740    std::fs::OpenOptions::new()
741        .read(true)
742        .open(path)
743        .is_ok_and(|mut f| f.read_exact(&mut buf).is_ok_and(|_| buf.starts_with(PREFIX)))
744}
745
746/// The `script_identity` will be baked into the soon to be created `archive` as it identifies the script
747/// that created the contents of `source_dir`.
748fn create_archive_if_we_should(source_dir: &Path, archive: &Path, script_identity: u32) -> std::io::Result<()> {
749    if should_skip_all_archive_creation() || is_excluded(archive) {
750        return Ok(());
751    }
752    if is_lfs_pointer_file(archive) {
753        eprintln!(
754            "Refusing to overwrite `gix-lfs` pointer file at \"{}\" - git lfs might not be properly installed.",
755            archive.display()
756        );
757        return Ok(());
758    }
759    std::fs::create_dir_all(archive.parent().expect("archive is a file"))?;
760
761    let meta_dir = populate_meta_dir(source_dir, script_identity)?;
762    let res = (move || {
763        let mut buf = Vec::<u8>::new();
764        {
765            let mut ar = tar::Builder::new(&mut buf);
766            ar.mode(tar::HeaderMode::Deterministic);
767            ar.follow_symlinks(false);
768            ar.append_dir_all(".", source_dir)?;
769            ar.finish()?;
770        }
771        #[cfg_attr(feature = "xz", allow(unused_mut))]
772        let mut archive = std::fs::OpenOptions::new()
773            .write(true)
774            .create(true)
775            .truncate(true)
776            .open(archive)?;
777        #[cfg(feature = "xz")]
778        {
779            let mut xz_write = xz2::write::XzEncoder::new(archive, 3);
780            std::io::copy(&mut &*buf, &mut xz_write)?;
781            xz_write.finish()?.close()
782        }
783        #[cfg(not(feature = "xz"))]
784        {
785            use std::io::Write;
786            archive.write_all(&buf)?;
787            archive.close()
788        }
789    })();
790    #[cfg(not(windows))]
791    std::fs::remove_dir_all(meta_dir)?;
792    #[cfg(windows)]
793    std::fs::remove_dir_all(meta_dir).ok(); // it really can't delete these directories for some reason (even after 10 seconds)
794
795    res
796}
797
798fn is_excluded(archive: &Path) -> bool {
799    let mut lut = EXCLUDE_LUT.lock();
800    lut.as_mut()
801        .and_then(|cache| {
802            let archive = env::current_dir().ok()?.join(archive);
803            let relative_path = archive.strip_prefix(cache.base()).ok()?;
804            cache
805                .at_path(
806                    relative_path,
807                    Some(gix_worktree::index::entry::Mode::FILE),
808                    &gix_worktree::object::find::Never,
809                )
810                .ok()?
811                .is_excluded()
812                .into()
813        })
814        .unwrap_or(false)
815}
816
817const META_DIR_NAME: &str = "__gitoxide_meta__";
818const META_IDENTITY: &str = "identity";
819const META_GIT_VERSION: &str = "git-version";
820
821fn populate_meta_dir(destination_dir: &Path, script_identity: u32) -> std::io::Result<PathBuf> {
822    let meta_dir = destination_dir.join(META_DIR_NAME);
823    std::fs::create_dir_all(&meta_dir)?;
824    std::fs::write(
825        meta_dir.join(META_IDENTITY),
826        format!("{}-{}", script_identity, family_name()).as_bytes(),
827    )?;
828    std::fs::write(
829        meta_dir.join(META_GIT_VERSION),
830        std::process::Command::new(GIT_PROGRAM)
831            .arg("--version")
832            .output()?
833            .stdout,
834    )?;
835    Ok(meta_dir)
836}
837
838/// `required_script_identity` is the identity of the script that generated the state that is contained in `archive`.
839/// If this is not the case, the arvhive will be ignored.
840fn extract_archive(
841    archive: &Path,
842    destination_dir: &Path,
843    required_script_identity: u32,
844) -> std::io::Result<(u32, Option<String>)> {
845    let archive_buf: Vec<u8> = {
846        let mut buf = Vec::new();
847        #[cfg_attr(feature = "xz", allow(unused_mut))]
848        let mut input_archive = std::fs::File::open(archive)?;
849        if env::var_os("GIX_TEST_IGNORE_ARCHIVES").is_some() {
850            return Err(std::io::Error::other(format!(
851                "Ignoring archive at '{}' as GIX_TEST_IGNORE_ARCHIVES is set.",
852                archive.display()
853            )));
854        }
855        #[cfg(feature = "xz")]
856        {
857            let mut decoder = xz2::bufread::XzDecoder::new(std::io::BufReader::new(input_archive));
858            std::io::copy(&mut decoder, &mut buf)?;
859        }
860        #[cfg(not(feature = "xz"))]
861        {
862            input_archive.read_to_end(&mut buf)?;
863        }
864        buf
865    };
866
867    let mut entry_buf = Vec::<u8>::new();
868    let (archive_identity, platform): (u32, _) = tar::Archive::new(std::io::Cursor::new(&mut &*archive_buf))
869        .entries_with_seek()?
870        .filter_map(std::result::Result::ok)
871        .find_map(|mut e: tar::Entry<'_, _>| {
872            let path = e.path().ok()?;
873            if path.parent()?.file_name()? == META_DIR_NAME && path.file_name()? == META_IDENTITY {
874                entry_buf.clear();
875                e.read_to_end(&mut entry_buf).ok()?;
876                let mut tokens = entry_buf.to_str().ok()?.trim().splitn(2, '-');
877                match (tokens.next(), tokens.next()) {
878                    (Some(id), platform) => Some((id.parse().ok()?, platform.map(ToOwned::to_owned))),
879                    _ => None,
880                }
881            } else {
882                None
883            }
884        })
885        .ok_or_else(|| std::io::Error::other("BUG: Could not find meta directory in our own archive"))
886        .map_err(|err| {
887            std::io::Error::other(format!(
888                "Could not extract archive at '{archive}': {err}",
889                archive = archive.display()
890            ))
891        })?;
892    if archive_identity != required_script_identity {
893        eprintln!(
894            "Ignoring archive at '{}' as its generating script changed",
895            archive.display()
896        );
897        return Err(std::io::ErrorKind::NotFound.into());
898    }
899
900    for entry in tar::Archive::new(&mut &*archive_buf).entries()? {
901        let mut entry = entry?;
902        let path = entry.path()?;
903        if path.to_str() == Some(META_DIR_NAME) || path.parent().and_then(Path::to_str) == Some(META_DIR_NAME) {
904            continue;
905        }
906        entry.unpack_in(destination_dir)?;
907    }
908    Ok((archive_identity, platform))
909}
910
911/// Transform a verbose parser errors from raw bytes into a `BStr` to make printing/debugging human-readable.
912pub fn to_bstr_err(
913    err: winnow::error::ErrMode<winnow::error::TreeError<&[u8], winnow::error::StrContext>>,
914) -> winnow::error::TreeError<&winnow::stream::BStr, winnow::error::StrContext> {
915    let err = err.into_inner().expect("not a streaming parser");
916    err.map_input(winnow::stream::BStr::new)
917}
918
919fn family_name() -> &'static str {
920    if cfg!(windows) {
921        "windows"
922    } else {
923        "unix"
924    }
925}
926
927/// A utility to set and unset environment variables, while restoring or removing them on drop.
928#[derive(Default)]
929pub struct Env<'a> {
930    altered_vars: Vec<(&'a str, Option<OsString>)>,
931}
932
933impl<'a> Env<'a> {
934    /// Create a new instance.
935    pub fn new() -> Self {
936        Env {
937            altered_vars: Vec::new(),
938        }
939    }
940
941    /// Set `var` to `value`.
942    pub fn set(mut self, var: &'a str, value: impl Into<String>) -> Self {
943        let prev = env::var_os(var);
944        env::set_var(var, value.into());
945        self.altered_vars.push((var, prev));
946        self
947    }
948
949    /// Unset `var`.
950    pub fn unset(mut self, var: &'a str) -> Self {
951        let prev = env::var_os(var);
952        env::remove_var(var);
953        self.altered_vars.push((var, prev));
954        self
955    }
956}
957
958impl Drop for Env<'_> {
959    fn drop(&mut self) {
960        for (var, prev_value) in self.altered_vars.iter().rev() {
961            match prev_value {
962                Some(value) => env::set_var(var, value),
963                None => env::remove_var(var),
964            }
965        }
966    }
967}
968
969/// Check data structure size, comparing strictly on 64-bit targets.
970///
971/// - On 32-bit targets, checks if `actual_size` is at most `expected_64_bit_size`.
972/// - On 64-bit targets, checks if `actual_size` is exactly `expected_64_bit_size`.
973///
974/// This is for assertions about the size of data structures, when the goal is to keep them from
975/// growing too large even across breaking changes. Such assertions must always fail when data
976/// structures grow larger than they have ever been, for which `<=` is enough. But it also helps to
977/// know when they have shrunk unexpectedly. They may shrink, other changes may rely on the smaller
978/// size for acceptable performance, and then they may grow again to their earlier size.
979///
980/// The problem with `==` is that data structures are often smaller on 32-bit targets. This could
981/// be addressed by asserting separate exact 64-bit and 32-bit sizes. But sizes may also differ
982/// across 32-bit targets, due to ABI and layout/packing details. That can happen across 64-bit
983/// targets too, but it seems less common.
984///
985/// For those reasons, this function does a `==` on 64-bit targets, but a `<=` on 32-bit targets.
986pub fn size_ok(actual_size: usize, expected_64_bit_size: usize) -> bool {
987    #[cfg(target_pointer_width = "64")]
988    return actual_size == expected_64_bit_size;
989    #[cfg(target_pointer_width = "32")]
990    return actual_size <= expected_64_bit_size;
991}
992
993/// Get the umask in a way that is safe, but may be too slow for use outside of tests.
994#[cfg(unix)]
995pub fn umask() -> u32 {
996    let output = std::process::Command::new("/bin/sh")
997        .args(["-c", "umask"])
998        .output()
999        .expect("can execute `sh -c umask`");
1000    assert!(output.status.success(), "`sh -c umask` failed");
1001    assert_eq!(output.stderr.as_bstr(), "", "`sh -c umask` unexpected message");
1002    let text = output.stdout.to_str().expect("valid Unicode").trim();
1003    u32::from_str_radix(text, 8).expect("parses as octal number")
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008    use super::*;
1009
1010    #[test]
1011    fn parse_version() {
1012        assert_eq!(git_version_from_bytes(b"git version 2.37.2").unwrap(), (2, 37, 2));
1013        assert_eq!(
1014            git_version_from_bytes(b"git version 2.32.1 (Apple Git-133)").unwrap(),
1015            (2, 32, 1)
1016        );
1017    }
1018
1019    #[test]
1020    fn parse_version_with_trailing_newline() {
1021        assert_eq!(git_version_from_bytes(b"git version 2.37.2\n").unwrap(), (2, 37, 2));
1022    }
1023
1024    const SCOPE_ENV_VALUE: &str = "gitconfig";
1025
1026    fn populate_ad_hoc_config_files(dir: &Path) {
1027        const CONFIG_DATA: &[u8] = b"[foo]\n\tbar = baz\n";
1028
1029        let paths: &[PathBuf] = if cfg!(windows) {
1030            let unc_literal_nul = dir.canonicalize().expect("directory exists").join("nul");
1031            &[dir.join(SCOPE_ENV_VALUE), dir.join("-"), unc_literal_nul]
1032        } else {
1033            &[dir.join(SCOPE_ENV_VALUE), dir.join("-"), dir.join(":")]
1034        };
1035        // Create the files.
1036        for path in paths {
1037            std::fs::write(path, CONFIG_DATA).expect("can write contents");
1038        }
1039        // Verify the files. This is mostly to show we really made a `\\?\...\nul` on Windows.
1040        for path in paths {
1041            let buf = std::fs::read(path).expect("the file really exists");
1042            assert_eq!(buf, CONFIG_DATA, "{path:?} should be a config file");
1043        }
1044    }
1045
1046    #[test]
1047    fn configure_command_clears_external_config() {
1048        let temp = tempfile::TempDir::new().expect("can create temp dir");
1049        populate_ad_hoc_config_files(temp.path());
1050
1051        let mut cmd = std::process::Command::new(GIT_PROGRAM);
1052        cmd.env("GIT_CONFIG_SYSTEM", SCOPE_ENV_VALUE);
1053        cmd.env("GIT_CONFIG_GLOBAL", SCOPE_ENV_VALUE);
1054        configure_command(&mut cmd, ["config", "-l", "--show-origin"], temp.path());
1055
1056        let output = cmd.output().expect("can run git");
1057        let lines: Vec<_> = output
1058            .stdout
1059            .to_str()
1060            .expect("valid UTF-8")
1061            .lines()
1062            .filter(|line| !line.starts_with("command line:\t"))
1063            .collect();
1064        let status = output.status.code().expect("terminated normally");
1065        assert_eq!(lines, Vec::<&str>::new(), "should be no config variables from files");
1066        assert_eq!(status, 0, "reading the config should succeed");
1067    }
1068
1069    #[test]
1070    #[cfg(windows)]
1071    fn bash_program_ok_for_platform() {
1072        let path = bash_program();
1073        assert!(path.is_absolute());
1074
1075        let for_version = std::process::Command::new(path)
1076            .arg("--version")
1077            .output()
1078            .expect("can pass it `--version`");
1079        assert!(for_version.status.success(), "passing `--version` succeeds");
1080        let version_line = for_version
1081            .stdout
1082            .lines()
1083            .nth(0)
1084            .expect("`--version` output has first line");
1085        assert!(
1086            version_line.ends_with(b"-pc-msys)"), // On Windows, "-pc-linux-gnu)" would be WSL.
1087            "it is an MSYS bash (such as Git Bash)"
1088        );
1089
1090        let for_uname_os = std::process::Command::new(path)
1091            .args(["-c", "uname -o"])
1092            .output()
1093            .expect("can tell it to run `uname -o`");
1094        assert!(for_uname_os.status.success(), "telling it to run `uname -o` succeeds");
1095        assert_eq!(
1096            for_uname_os.stdout.trim_end(),
1097            b"Msys",
1098            "it runs commands in an MSYS environment"
1099        );
1100    }
1101
1102    #[test]
1103    #[cfg(not(windows))]
1104    fn bash_program_ok_for_platform() {
1105        assert_eq!(bash_program(), Path::new("bash"));
1106    }
1107
1108    #[test]
1109    fn bash_program_unix_path() {
1110        let path = bash_program()
1111            .to_str()
1112            .expect("This test depends on the bash path being valid Unicode");
1113        assert!(
1114            !path.contains('\\'),
1115            "The path to bash should have no backslashes, barring very unusual environments"
1116        );
1117    }
1118
1119    fn is_rooted_relative(path: impl AsRef<Path>) -> bool {
1120        let p = path.as_ref();
1121        p.is_relative() && p.has_root()
1122    }
1123
1124    #[test]
1125    #[cfg(windows)]
1126    fn unix_style_absolute_is_rooted_relative() {
1127        assert!(is_rooted_relative("/bin/bash"), "can detect paths like /bin/bash");
1128    }
1129
1130    #[test]
1131    fn bash_program_absolute_or_unrooted() {
1132        let bash = bash_program();
1133        assert!(!is_rooted_relative(bash), "{bash:?}");
1134    }
1135}