1#![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
40pub mod signature;
42
43pub mod repository;
45
46const ARCHIVE_DIR_NAME: &str = "generated-archives";
47
48pub type Result<T = ()> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
63
64pub type PostResult<T = ()> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
69
70pub fn build_example_for_test(package: &str, example: &str, target_tmpdir: impl Into<PathBuf>) -> PathBuf {
75 let mut cargo = std::process::Command::new(env::var_os("CARGO").unwrap_or_else(|| OsString::from(env!("CARGO"))));
76 let res = cargo
77 .args(["build", "-p", package, "--example", example])
78 .status()
79 .expect("cargo should run fine");
80 assert!(res.success(), "cargo invocation should be successful");
81
82 let target_tmpdir = target_tmpdir.into();
83 let shared_path = target_tmpdir
84 .ancestors()
85 .nth(1)
86 .expect("first parent in target dir")
87 .join("debug")
88 .join("examples")
89 .join(format!("{example}{}", std::env::consts::EXE_SUFFIX));
90
91 let stable_path = target_tmpdir.join(format!(
92 "{example}-{}{}",
93 std::process::id(),
94 std::env::consts::EXE_SUFFIX
95 ));
96 let mut last_err = None;
97 for _ in 0..10 {
98 match std::fs::copy(&shared_path, &stable_path) {
99 Ok(_) => return stable_path,
100 Err(err) => {
101 last_err = Some(err);
102 std::thread::sleep(Duration::from_millis(50));
103 }
104 }
105 }
106 panic!(
107 "driver at {} could be copied for stable test execution: {last_err:?}",
108 shared_path.display()
109 );
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum FixtureState<'a> {
115 Uninitialized(&'a Path),
120 Fresh(&'a Path),
125}
126
127impl FixtureState<'_> {
128 pub fn path(&self) -> &Path {
130 match self {
131 FixtureState::Uninitialized(path) | FixtureState::Fresh(path) => path,
132 }
133 }
134
135 pub fn is_uninitialized(&self) -> bool {
137 matches!(self, FixtureState::Uninitialized(_))
138 }
139}
140
141trait IsExcluded {
148 fn is_excluded(&self, archive: &Path) -> bool;
150}
151
152#[cfg(not(feature = "worktree-exclusions"))]
158fn is_excluded_by_lines(lines: &str, archive: &Path) -> bool {
159 let archive = archive.to_string_lossy().replace('\\', "/");
160 let filename = archive.rsplit('/').next().unwrap_or(&archive);
161 lines.lines().any(|line| {
162 let pattern = line.trim();
163 if pattern.is_empty() || pattern.starts_with('#') {
164 return false;
165 }
166 let pattern = pattern.trim_start_matches('/');
167 let candidate = if pattern.contains('/') {
168 archive.as_str()
169 } else {
170 filename
171 };
172 wildcard_match(pattern, candidate)
173 })
174}
175
176#[cfg(not(feature = "worktree-exclusions"))]
185fn wildcard_match(pattern: &str, text: &str) -> bool {
186 if !pattern.contains('*') {
187 return pattern == text;
188 }
189
190 let mut remainder = text;
191 let mut parts = pattern.split('*').peekable();
192 let first = parts.next().expect("split yields at least one item");
193 if !first.is_empty() {
194 let Some(stripped) = remainder.strip_prefix(first) else {
195 return false;
196 };
197 remainder = stripped;
198 }
199
200 while let Some(part) = parts.next() {
201 if part.is_empty() {
202 continue;
203 }
204 let Some(pos) = remainder.find(part) else {
205 return false;
206 };
207 remainder = &remainder[pos + part.len()..];
208 if parts.peek().is_none() && !pattern.ends_with('*') {
209 return remainder.is_empty();
210 }
211 }
212 pattern.ends_with('*') || remainder.is_empty()
213}
214
215pub struct GitDaemon {
219 process: GitDaemonProcess,
220 pub url: String,
222}
223
224enum GitDaemonProcess {
225 #[cfg(not(unix))]
226 Child(std::process::Child),
227 #[cfg(unix)]
228 Inetd {
229 shutdown: std::sync::Arc<std::sync::atomic::AtomicBool>,
230 server_addr: std::net::SocketAddr,
231 listener_thread: Option<std::thread::JoinHandle<()>>,
232 },
233}
234
235impl Drop for GitDaemon {
236 fn drop(&mut self) {
237 match &mut self.process {
238 #[cfg(not(unix))]
239 GitDaemonProcess::Child(child) => {
240 child.kill().ok();
241 }
242 #[cfg(unix)]
243 GitDaemonProcess::Inetd {
244 shutdown,
245 server_addr,
246 listener_thread,
247 } => {
248 shutdown.store(true, std::sync::atomic::Ordering::SeqCst);
249 std::net::TcpStream::connect(*server_addr).ok();
250 if let Some(listener_thread) = listener_thread.take() {
251 listener_thread.join().ok();
252 }
253 }
254 }
255 }
256}
257
258static SCRIPT_IDENTITY: LazyLock<Mutex<BTreeMap<PathBuf, u32>>> = LazyLock::new(|| Mutex::new(BTreeMap::new()));
259
260#[cfg(feature = "worktree-exclusions")]
261static EXCLUDE_LUT: LazyLock<Mutex<Option<gix_worktree::Stack>>> = LazyLock::new(|| {
262 let cache = (|| {
263 let (repo_path, _) = gix_discover::upwards(Path::new(".")).ok()?;
264 let (gix_dir, work_tree) = repo_path.into_repository_and_work_tree_directories();
265 let work_tree = work_tree?.canonicalize().ok()?;
266
267 let mut buf = Vec::with_capacity(512);
268 let case = if gix_fs::Capabilities::probe(&work_tree).ignore_case {
269 gix_worktree::ignore::glob::pattern::Case::Fold
270 } else {
271 Default::default()
272 };
273 let state = gix_worktree::stack::State::IgnoreStack(gix_worktree::stack::state::Ignore::new(
274 Default::default(),
275 gix_worktree::ignore::Search::from_git_dir(
276 &gix_dir,
277 None,
278 &mut buf,
279 gix_worktree::stack::state::ignore::ParseIgnore {
280 support_precious: false,
281 },
282 )
283 .ok()?,
284 None,
285 gix_worktree::stack::state::ignore::Source::WorktreeThenIdMappingIfNotSkipped,
286 Default::default(),
287 ));
288 Some(gix_worktree::Stack::new(
289 work_tree,
290 state,
291 case,
292 buf,
293 Default::default(),
294 ))
295 })();
296 Mutex::new(cache)
297});
298
299#[cfg(feature = "worktree-exclusions")]
300struct WorktreeExclusions;
301
302#[cfg(feature = "worktree-exclusions")]
303impl IsExcluded for WorktreeExclusions {
304 fn is_excluded(&self, archive: &Path) -> bool {
305 let mut lut = EXCLUDE_LUT.lock();
306 lut.as_mut()
307 .and_then(|cache| {
308 let archive = env::current_dir().ok()?.join(archive);
309 let relative_path = archive.strip_prefix(cache.base()).ok()?;
310 cache
311 .at_path(
312 relative_path,
313 Some(gix_worktree::index::entry::Mode::FILE),
314 &gix_worktree::object::find::Never,
315 )
316 .ok()?
317 .is_excluded()
318 .into()
319 })
320 .unwrap_or(false)
321 }
322}
323
324#[cfg(feature = "worktree-exclusions")]
325fn default_excludes() -> &'static dyn IsExcluded {
326 static WORKTREE_EXCLUSIONS: WorktreeExclusions = WorktreeExclusions;
327 &WORKTREE_EXCLUSIONS
328}
329
330#[cfg(not(feature = "worktree-exclusions"))]
331struct GitignoreExclusions;
332
333#[cfg(not(feature = "worktree-exclusions"))]
334impl IsExcluded for GitignoreExclusions {
335 fn is_excluded(&self, archive: &Path) -> bool {
336 let Some(parent) = archive.parent() else {
337 return false;
338 };
339 std::fs::read_to_string(parent.join(".gitignore")).is_ok_and(|lines| is_excluded_by_lines(&lines, archive))
340 }
341}
342
343#[cfg(not(feature = "worktree-exclusions"))]
344fn default_excludes() -> &'static dyn IsExcluded {
345 static GITIGNORE_EXCLUSIONS: GitignoreExclusions = GitignoreExclusions;
346 &GITIGNORE_EXCLUSIONS
347}
348
349const DISABLE_AUTO_MAINTENANCE_CONFIG: &[(&str, &str)] = &[("maintenance.auto", "false"), ("gc.auto", "0")];
350
351const ISOLATED_GIT_CONFIG: &[(&str, &str)] = &[
352 ("commit.gpgsign", "false"),
353 ("tag.gpgsign", "false"),
354 ("init.defaultBranch", "main"),
355 ("protocol.file.allow", "always"),
356 ("maintenance.auto", "false"),
357 ("gc.auto", "0"),
358];
359
360static GIT_CORE_DIR: LazyLock<PathBuf> = LazyLock::new(|| {
361 let output = std::process::Command::new(gix_path::env::exe_invocation())
362 .arg("--exec-path")
363 .output()
364 .expect("can execute `git --exec-path`");
365
366 assert!(output.status.success(), "`git --exec-path` failed");
367
368 output
369 .stdout
370 .strip_suffix(b"\n")
371 .expect("`git --exec-path` output to be well-formed")
372 .to_os_str()
373 .expect("no invalid UTF-8 in `--exec-path` except as OS allows")
374 .into()
375});
376
377pub static GIT_VERSION: LazyLock<(u8, u8, u8)> =
379 LazyLock::new(|| parse_git_version().expect("git version to be parsable"));
380
381pub enum Creation {
385 CopyFromReadOnly,
392 Execute,
394}
395
396pub fn should_skip_as_git_version_is_smaller_than(major: u8, minor: u8, patch: u8) -> bool {
404 if is_ci::cached() {
405 return false; }
407 *GIT_VERSION < (major, minor, patch)
408}
409
410fn parse_git_version() -> Result<(u8, u8, u8)> {
411 let output = std::process::Command::new(gix_path::env::exe_invocation())
412 .arg("--version")
413 .output()?;
414 git_version_from_bytes(&output.stdout)
415}
416
417fn git_version_from_bytes(bytes: &[u8]) -> Result<(u8, u8, u8)> {
418 let mut numbers = bytes
419 .split(|b| *b == b' ' || *b == b'\n')
420 .nth(2)
421 .expect("git version <version>")
422 .split(|b| *b == b'.')
423 .take(3)
424 .map(|n| std::str::from_utf8(n).expect("valid utf8 in version number"))
425 .map(u8::from_str);
426
427 Ok((|| -> Result<_> {
428 Ok((
429 numbers.next().expect("major")?,
430 numbers.next().expect("minor")?,
431 numbers.next().expect("patch")?,
432 ))
433 })()
434 .map_err(|err| {
435 format!(
436 "Could not parse version from output of 'git --version' ({:?}) with error: {}",
437 bytes.to_str_lossy(),
438 err
439 )
440 })?)
441}
442
443pub fn set_current_dir(new_cwd: impl AsRef<Path>) -> std::io::Result<AutoRevertToPreviousCWD> {
445 let cwd = env::current_dir()?;
446 env::set_current_dir(new_cwd)?;
447 Ok(AutoRevertToPreviousCWD(cwd))
448}
449
450#[derive(Debug)]
456#[must_use]
457pub struct AutoRevertToPreviousCWD(PathBuf);
458
459impl Drop for AutoRevertToPreviousCWD {
460 fn drop(&mut self) {
461 env::set_current_dir(&self.0).unwrap();
462 }
463}
464
465pub fn run_git(working_dir: &Path, args: &[&str]) -> std::io::Result<std::process::ExitStatus> {
467 let mut cmd = std::process::Command::new(gix_path::env::exe_invocation());
468 apply_git_config_by_environment(&mut cmd, DISABLE_AUTO_MAINTENANCE_CONFIG)
469 .current_dir(working_dir)
470 .args(args)
471 .status()
472}
473
474pub fn invoke_bash(cwd: impl AsRef<Path>, script: &str) {
483 let mut cmd = std::process::Command::new(bash_program());
484 let status = apply_git_config_by_environment(&mut cmd, DISABLE_AUTO_MAINTENANCE_CONFIG)
485 .current_dir(cwd)
486 .arg("-c")
487 .arg(script)
488 .stdin(std::process::Stdio::null())
489 .stdout(std::process::Stdio::inherit())
490 .stderr(std::process::Stdio::inherit())
491 .status()
492 .expect("can run bash script");
493 assert!(status.success(), "bash script failed with {status}");
494}
495
496pub fn spawn_git_daemon(working_dir: impl AsRef<Path>) -> std::io::Result<GitDaemon> {
500 #[cfg(unix)]
501 {
502 spawn_git_daemon_inetd(working_dir)
503 }
504 #[cfg(not(unix))]
505 {
506 spawn_git_daemon_process(working_dir)
507 }
508}
509
510#[cfg(not(unix))]
511fn spawn_git_daemon_process(working_dir: impl AsRef<Path>) -> std::io::Result<GitDaemon> {
512 let mut ports: Vec<_> = (9419u16..9419 + 100).collect();
513 fastrand::shuffle(&mut ports);
514 let addr_at = |port| std::net::SocketAddr::from(([127, 0, 0, 1], port));
515 let free_port = {
516 let listener = std::net::TcpListener::bind(ports.into_iter().map(addr_at).collect::<Vec<_>>().as_slice())?;
517 listener.local_addr().expect("listener address is available").port()
518 };
519
520 let child = {
521 let mut cmd =
522 std::process::Command::new(GIT_CORE_DIR.join(if cfg!(windows) { "git-daemon.exe" } else { "git-daemon" }));
523 apply_git_config_by_environment(&mut cmd, DISABLE_AUTO_MAINTENANCE_CONFIG)
524 .current_dir(working_dir)
525 .args(["--verbose", "--base-path=.", "--export-all", "--user-path"])
526 .arg(format!("--port={free_port}"))
527 .spawn()?
528 };
529
530 let server_addr = addr_at(free_port);
531 for time in gix_lock::backoff::Quadratic::default_with_random() {
532 std::thread::sleep(time);
533 if std::net::TcpStream::connect(server_addr).is_ok() {
534 break;
535 }
536 }
537 Ok(GitDaemon {
538 process: GitDaemonProcess::Child(child),
539 url: format!("git://{server_addr}"),
540 })
541}
542
543#[cfg(unix)]
544fn spawn_git_daemon_inetd(working_dir: impl AsRef<Path>) -> std::io::Result<GitDaemon> {
545 use std::{
546 net::{TcpListener, TcpStream},
547 os::fd::{FromRawFd, IntoRawFd},
548 process::Stdio,
549 sync::{
550 Arc,
551 atomic::{AtomicBool, Ordering},
552 },
553 };
554
555 fn stream_to_stdio(stream: TcpStream) -> Stdio {
556 unsafe { Stdio::from_raw_fd(stream.into_raw_fd()) }
559 }
560
561 let working_dir = working_dir.as_ref().to_owned();
562 let listener = TcpListener::bind(("127.0.0.1", 0))?;
563 let server_addr = listener.local_addr()?;
564 let shutdown = Arc::new(AtomicBool::new(false));
565 let listener_thread = std::thread::spawn({
566 let shutdown = shutdown.clone();
567 move || {
568 for incoming in listener.incoming() {
569 let stream = match incoming {
570 Ok(stream) => stream,
571 Err(_) => break,
572 };
573 if shutdown.load(Ordering::SeqCst) {
574 break;
575 }
576
577 let peer_addr = stream.peer_addr().ok();
578 let stdin = match stream.try_clone() {
579 Ok(stream) => stream_to_stdio(stream),
580 Err(_) => continue,
581 };
582 let stdout = stream_to_stdio(stream);
583 let mut cmd = std::process::Command::new(gix_path::env::exe_invocation());
584 let Ok(mut child) = apply_git_config_by_environment(&mut cmd, DISABLE_AUTO_MAINTENANCE_CONFIG)
585 .args([
586 "-c",
587 "uploadpack.allowrefinwant",
588 "daemon",
589 "--inetd",
590 "--verbose",
591 "--base-path=.",
592 "--export-all",
593 "--user-path",
594 ])
595 .current_dir(&working_dir)
596 .stdin(stdin)
597 .stdout(stdout)
598 .stderr(Stdio::null())
599 .envs(remote_env(peer_addr))
600 .spawn()
601 else {
602 continue;
603 };
604
605 std::thread::spawn(move || {
606 let _ = child.wait();
607 });
608 }
609 }
610 });
611
612 Ok(GitDaemon {
613 process: GitDaemonProcess::Inetd {
614 shutdown,
615 server_addr,
616 listener_thread: Some(listener_thread),
617 },
618 url: format!("git://{server_addr}"),
619 })
620}
621
622#[cfg(unix)]
623fn remote_env(peer_addr: Option<std::net::SocketAddr>) -> Vec<(&'static str, String)> {
624 peer_addr
625 .map(|addr| {
626 vec![
627 ("REMOTE_ADDR", addr.ip().to_string()),
628 ("REMOTE_PORT", addr.port().to_string()),
629 ]
630 })
631 .unwrap_or_default()
632}
633
634#[derive(Copy, Clone)]
638enum ArgsInHash {
639 Yes,
640 No,
641}
642
643#[derive(Copy, Clone, Debug, Eq, PartialEq)]
645enum ArchivePolicy {
646 Normal,
648 Prefer,
650 Require,
652}
653
654fn archive_policy_for_git_version(is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool) -> ArchivePolicy {
655 if is_git_version_compatible(*GIT_VERSION) {
656 ArchivePolicy::Normal
657 } else {
658 ArchivePolicy::Require
659 }
660}
661
662impl ArchivePolicy {
663 fn cache_variant(self) -> Option<&'static str> {
667 match self {
668 ArchivePolicy::Normal => None,
669 ArchivePolicy::Prefer => Some("archive"),
670 ArchivePolicy::Require => Some("required-archive"),
671 }
672 }
673
674 fn ignores_archive_override(self) -> bool {
680 !matches!(self, ArchivePolicy::Normal)
681 }
682
683 fn allows_generation(self) -> bool {
688 !matches!(self, ArchivePolicy::Require)
689 }
690}
691
692pub fn fixture_path(path: impl AsRef<Path>) -> PathBuf {
694 fixture_base().join(path.as_ref())
695}
696
697fn fixture_base() -> PathBuf {
698 PathBuf::from("tests").join("fixtures")
699}
700
701pub fn fixture_bytes(path: impl AsRef<Path>) -> Vec<u8> {
703 match std::fs::read(fixture_path(path.as_ref())) {
704 Ok(res) => res,
705 Err(_) => panic!("File at '{}' not found", path.as_ref().display()),
706 }
707}
708
709pub fn scripted_fixture_read_only(script_name: impl AsRef<Path>) -> Result<PathBuf> {
732 scripted_fixture_read_only_with_args(script_name, None::<String>)
733}
734
735pub fn scripted_fixture_read_only_needs_archive(script_name: impl AsRef<Path>) -> Result<PathBuf> {
748 scripted_fixture_read_only_with_args_inner::<fn(FixtureState<'_>) -> PostResult, ()>(
749 script_name,
750 None::<String>,
751 None,
752 ArgsInHash::Yes,
753 default_excludes(),
754 None::<(u32, _)>,
755 ArchivePolicy::Prefer,
756 )
757 .map(|fixture| fixture.expect("preferred archives fall back to generation").0)
758}
759
760pub fn scripted_fixture_read_only_with_git_version(
768 script_name: impl AsRef<Path>,
769 is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
770) -> Result<Option<PathBuf>> {
771 scripted_fixture_read_only_with_args_with_git_version(script_name, None::<String>, is_git_version_compatible)
772}
773
774pub fn scripted_fixture_writable(script_name: impl AsRef<Path>) -> Result<tempfile::TempDir> {
779 scripted_fixture_writable_with_args(script_name, None::<String>, Creation::CopyFromReadOnly)
780}
781
782pub fn scripted_fixture_writable_with_git_version(
788 script_name: impl AsRef<Path>,
789 is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
790) -> Result<Option<tempfile::TempDir>> {
791 scripted_fixture_writable_with_args_with_git_version(
792 script_name,
793 None::<String>,
794 Creation::CopyFromReadOnly,
795 is_git_version_compatible,
796 )
797}
798
799pub fn scripted_fixture_writable_with_args(
802 script_name: impl AsRef<Path>,
803 args: impl IntoIterator<Item = impl Into<String>>,
804 mode: Creation,
805) -> Result<tempfile::TempDir> {
806 scripted_fixture_writable_with_args_inner::<fn(FixtureState<'_>) -> PostResult, ()>(
807 script_name,
808 args,
809 mode,
810 ArgsInHash::Yes,
811 default_excludes(),
812 None::<(u32, _)>,
813 ArchivePolicy::Normal,
814 )
815 .map(|fixture| fixture.expect("normal fixtures fall back to generation").0)
816}
817
818pub fn scripted_fixture_writable_with_args_with_git_version(
821 script_name: impl AsRef<Path>,
822 args: impl IntoIterator<Item = impl Into<String>>,
823 mode: Creation,
824 is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
825) -> Result<Option<tempfile::TempDir>> {
826 scripted_fixture_writable_with_args_inner::<fn(FixtureState<'_>) -> PostResult, ()>(
827 script_name,
828 args,
829 mode,
830 ArgsInHash::Yes,
831 default_excludes(),
832 None::<(u32, _)>,
833 archive_policy_for_git_version(is_git_version_compatible),
834 )
835 .map(|fixture| fixture.map(|(dir, _)| dir))
836}
837
838pub fn scripted_fixture_writable_with_args_single_archive(
843 script_name: impl AsRef<Path>,
844 args: impl IntoIterator<Item = impl Into<String>>,
845 mode: Creation,
846) -> Result<tempfile::TempDir> {
847 scripted_fixture_writable_with_args_inner::<fn(FixtureState<'_>) -> PostResult, ()>(
848 script_name,
849 args,
850 mode,
851 ArgsInHash::No,
852 default_excludes(),
853 None::<(u32, _)>,
854 ArchivePolicy::Normal,
855 )
856 .map(|fixture| fixture.expect("normal fixtures fall back to generation").0)
857}
858
859pub fn scripted_fixture_writable_with_args_single_archive_with_git_version(
861 script_name: impl AsRef<Path>,
862 args: impl IntoIterator<Item = impl Into<String>>,
863 mode: Creation,
864 is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
865) -> Result<Option<tempfile::TempDir>> {
866 scripted_fixture_writable_with_args_inner::<fn(FixtureState<'_>) -> PostResult, ()>(
867 script_name,
868 args,
869 mode,
870 ArgsInHash::No,
871 default_excludes(),
872 None::<(u32, _)>,
873 archive_policy_for_git_version(is_git_version_compatible),
874 )
875 .map(|fixture| fixture.map(|(dir, _)| dir))
876}
877
878fn scripted_fixture_writable_with_args_inner<F, T>(
879 script_name: impl AsRef<Path>,
880 args: impl IntoIterator<Item = impl Into<String>>,
881 mode: Creation,
882 args_in_hash: ArgsInHash,
883 excludes: &dyn IsExcluded,
884 mut post_process: Option<(u32, F)>,
885 archive_policy: ArchivePolicy,
886) -> Result<Option<(tempfile::TempDir, Option<T>)>>
887where
888 F: FnMut(FixtureState<'_>) -> PostResult<T>,
889{
890 let dst = tempfile::TempDir::new()?;
891 match mode {
892 Creation::CopyFromReadOnly => {
893 let Some((ro_dir, post_result)) = scripted_fixture_read_only_with_args_inner(
895 script_name,
896 args,
897 None,
898 args_in_hash,
899 excludes,
900 post_process.as_mut().map(|(v, f)| (*v, f)),
901 archive_policy,
902 )?
903 else {
904 return Ok(None);
905 };
906 copy_recursively_into_existing_dir(ro_dir, dst.path())?;
907 Ok(Some((dst, post_result)))
908 }
909 Creation::Execute => {
910 let Some((_, post_result)) = scripted_fixture_read_only_with_args_inner(
912 script_name,
913 args,
914 dst.path().into(),
915 args_in_hash,
916 excludes,
917 post_process.as_mut().map(|(v, f)| (*v, f)),
918 archive_policy,
919 )?
920 else {
921 return Ok(None);
922 };
923 Ok(Some((dst, post_result)))
924 }
925 }
926}
927
928pub fn copy_recursively_into_existing_dir(src_dir: impl AsRef<Path>, dst_dir: impl AsRef<Path>) -> std::io::Result<()> {
930 fs_extra::copy_items(
931 &std::fs::read_dir(src_dir)?
932 .map(|e| e.map(|e| e.path()))
933 .collect::<std::result::Result<Vec<_>, _>>()?,
934 dst_dir,
935 &fs_extra::dir::CopyOptions {
936 overwrite: false,
937 skip_exist: false,
938 copy_inside: false,
939 content_only: false,
940 ..Default::default()
941 },
942 )
943 .map_err(std::io::Error::other)?;
944 Ok(())
945}
946
947pub fn scripted_fixture_read_only_with_args(
949 script_name: impl AsRef<Path>,
950 args: impl IntoIterator<Item = impl Into<String>>,
951) -> Result<PathBuf> {
952 scripted_fixture_read_only_with_args_inner::<fn(FixtureState<'_>) -> PostResult, ()>(
953 script_name,
954 args,
955 None,
956 ArgsInHash::Yes,
957 default_excludes(),
958 None::<(u32, _)>,
959 ArchivePolicy::Normal,
960 )
961 .map(|fixture| fixture.expect("normal fixtures fall back to generation").0)
962}
963
964pub fn scripted_fixture_read_only_with_args_with_git_version(
966 script_name: impl AsRef<Path>,
967 args: impl IntoIterator<Item = impl Into<String>>,
968 is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
969) -> Result<Option<PathBuf>> {
970 scripted_fixture_read_only_with_args_inner::<fn(FixtureState<'_>) -> PostResult, ()>(
971 script_name,
972 args,
973 None,
974 ArgsInHash::Yes,
975 default_excludes(),
976 None::<(u32, _)>,
977 archive_policy_for_git_version(is_git_version_compatible),
978 )
979 .map(|fixture| fixture.map(|(dir, _)| dir))
980}
981
982pub fn scripted_fixture_read_only_with_args_single_archive(
994 script_name: impl AsRef<Path>,
995 args: impl IntoIterator<Item = impl Into<String>>,
996) -> Result<PathBuf> {
997 scripted_fixture_read_only_with_args_inner::<fn(FixtureState<'_>) -> PostResult, ()>(
998 script_name,
999 args,
1000 None,
1001 ArgsInHash::No,
1002 default_excludes(),
1003 None::<(u32, _)>,
1004 ArchivePolicy::Normal,
1005 )
1006 .map(|fixture| fixture.expect("normal fixtures fall back to generation").0)
1007}
1008
1009pub fn scripted_fixture_read_only_with_args_single_archive_with_git_version(
1011 script_name: impl AsRef<Path>,
1012 args: impl IntoIterator<Item = impl Into<String>>,
1013 is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
1014) -> Result<Option<PathBuf>> {
1015 scripted_fixture_read_only_with_args_inner::<fn(FixtureState<'_>) -> PostResult, ()>(
1016 script_name,
1017 args,
1018 None,
1019 ArgsInHash::No,
1020 default_excludes(),
1021 None::<(u32, _)>,
1022 archive_policy_for_git_version(is_git_version_compatible),
1023 )
1024 .map(|fixture| fixture.map(|(dir, _)| dir))
1025}
1026
1027pub fn scripted_fixture_read_only_with_post<T>(
1036 script_name: impl AsRef<Path>,
1037 version: u32,
1038 post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1039) -> Result<(PathBuf, T)> {
1040 scripted_fixture_read_only_with_args_inner(
1041 script_name,
1042 None::<String>,
1043 None,
1044 ArgsInHash::Yes,
1045 default_excludes(),
1046 Some((version, post_process)),
1047 ArchivePolicy::Normal,
1048 )
1049 .map(|fixture| {
1050 let (path, opt) = fixture.expect("normal fixtures fall back to generation");
1051 (path, opt.expect("post_process was provided"))
1052 })
1053}
1054
1055pub fn scripted_fixture_read_only_with_post_with_git_version<T>(
1057 script_name: impl AsRef<Path>,
1058 version: u32,
1059 post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1060 is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
1061) -> Result<Option<(PathBuf, T)>> {
1062 scripted_fixture_read_only_with_args_with_post_with_git_version(
1063 script_name,
1064 None::<String>,
1065 version,
1066 post_process,
1067 is_git_version_compatible,
1068 )
1069}
1070
1071pub fn scripted_fixture_read_only_with_args_with_post<T>(
1075 script_name: impl AsRef<Path>,
1076 args: impl IntoIterator<Item = impl Into<String>>,
1077 version: u32,
1078 post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1079) -> Result<(PathBuf, T)> {
1080 scripted_fixture_read_only_with_args_inner(
1081 script_name,
1082 args,
1083 None,
1084 ArgsInHash::Yes,
1085 default_excludes(),
1086 Some((version, post_process)),
1087 ArchivePolicy::Normal,
1088 )
1089 .map(|fixture| {
1090 let (path, opt) = fixture.expect("normal fixtures fall back to generation");
1091 (path, opt.expect("post_process was provided"))
1092 })
1093}
1094
1095pub fn scripted_fixture_read_only_with_args_with_post_with_git_version<T>(
1097 script_name: impl AsRef<Path>,
1098 args: impl IntoIterator<Item = impl Into<String>>,
1099 version: u32,
1100 post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1101 is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
1102) -> Result<Option<(PathBuf, T)>> {
1103 scripted_fixture_read_only_with_args_inner(
1104 script_name,
1105 args,
1106 None,
1107 ArgsInHash::Yes,
1108 default_excludes(),
1109 Some((version, post_process)),
1110 archive_policy_for_git_version(is_git_version_compatible),
1111 )
1112 .map(|fixture| fixture.map(|(path, opt)| (path, opt.expect("post_process was provided"))))
1113}
1114
1115pub fn scripted_fixture_read_only_with_args_single_archive_with_post<T>(
1119 script_name: impl AsRef<Path>,
1120 args: impl IntoIterator<Item = impl Into<String>>,
1121 version: u32,
1122 post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1123) -> Result<(PathBuf, T)> {
1124 scripted_fixture_read_only_with_args_inner(
1125 script_name,
1126 args,
1127 None,
1128 ArgsInHash::No,
1129 default_excludes(),
1130 Some((version, post_process)),
1131 ArchivePolicy::Normal,
1132 )
1133 .map(|fixture| {
1134 let (path, opt) = fixture.expect("normal fixtures fall back to generation");
1135 (path, opt.expect("post_process was provided"))
1136 })
1137}
1138
1139pub fn scripted_fixture_read_only_with_args_single_archive_with_post_with_git_version<T>(
1142 script_name: impl AsRef<Path>,
1143 args: impl IntoIterator<Item = impl Into<String>>,
1144 version: u32,
1145 post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1146 is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
1147) -> Result<Option<(PathBuf, T)>> {
1148 scripted_fixture_read_only_with_args_inner(
1149 script_name,
1150 args,
1151 None,
1152 ArgsInHash::No,
1153 default_excludes(),
1154 Some((version, post_process)),
1155 archive_policy_for_git_version(is_git_version_compatible),
1156 )
1157 .map(|fixture| fixture.map(|(path, opt)| (path, opt.expect("post_process was provided"))))
1158}
1159
1160pub fn scripted_fixture_writable_with_post<T>(
1169 script_name: impl AsRef<Path>,
1170 version: u32,
1171 post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1172) -> Result<(tempfile::TempDir, T)> {
1173 scripted_fixture_writable_with_args_inner(
1174 script_name,
1175 None::<String>,
1176 Creation::CopyFromReadOnly,
1177 ArgsInHash::Yes,
1178 default_excludes(),
1179 Some((version, post_process)),
1180 ArchivePolicy::Normal,
1181 )
1182 .map(|fixture| {
1183 let (tmp, opt) = fixture.expect("normal fixtures fall back to generation");
1184 (tmp, opt.expect("post_process was provided"))
1185 })
1186}
1187
1188pub fn scripted_fixture_writable_with_post_with_git_version<T>(
1190 script_name: impl AsRef<Path>,
1191 version: u32,
1192 post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1193 is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
1194) -> Result<Option<(tempfile::TempDir, T)>> {
1195 scripted_fixture_writable_with_args_with_post_with_git_version(
1196 script_name,
1197 None::<String>,
1198 Creation::CopyFromReadOnly,
1199 version,
1200 post_process,
1201 is_git_version_compatible,
1202 )
1203}
1204
1205pub fn scripted_fixture_writable_with_args_with_post<T>(
1209 script_name: impl AsRef<Path>,
1210 args: impl IntoIterator<Item = impl Into<String>>,
1211 mode: Creation,
1212 version: u32,
1213 post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1214) -> Result<(tempfile::TempDir, T)> {
1215 scripted_fixture_writable_with_args_inner(
1216 script_name,
1217 args,
1218 mode,
1219 ArgsInHash::Yes,
1220 default_excludes(),
1221 Some((version, post_process)),
1222 ArchivePolicy::Normal,
1223 )
1224 .map(|fixture| {
1225 let (tmp, opt) = fixture.expect("normal fixtures fall back to generation");
1226 (tmp, opt.expect("post_process was provided"))
1227 })
1228}
1229
1230pub fn scripted_fixture_writable_with_args_with_post_with_git_version<T>(
1232 script_name: impl AsRef<Path>,
1233 args: impl IntoIterator<Item = impl Into<String>>,
1234 mode: Creation,
1235 version: u32,
1236 post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1237 is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
1238) -> Result<Option<(tempfile::TempDir, T)>> {
1239 scripted_fixture_writable_with_args_inner(
1240 script_name,
1241 args,
1242 mode,
1243 ArgsInHash::Yes,
1244 default_excludes(),
1245 Some((version, post_process)),
1246 archive_policy_for_git_version(is_git_version_compatible),
1247 )
1248 .map(|fixture| fixture.map(|(tmp, opt)| (tmp, opt.expect("post_process was provided"))))
1249}
1250
1251pub fn scripted_fixture_writable_with_args_single_archive_with_post<T>(
1255 script_name: impl AsRef<Path>,
1256 args: impl IntoIterator<Item = impl Into<String>>,
1257 mode: Creation,
1258 version: u32,
1259 post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1260) -> Result<(tempfile::TempDir, T)> {
1261 scripted_fixture_writable_with_args_inner(
1262 script_name,
1263 args,
1264 mode,
1265 ArgsInHash::No,
1266 default_excludes(),
1267 Some((version, post_process)),
1268 ArchivePolicy::Normal,
1269 )
1270 .map(|fixture| {
1271 let (tmp, opt) = fixture.expect("normal fixtures fall back to generation");
1272 (tmp, opt.expect("post_process was provided"))
1273 })
1274}
1275
1276pub fn scripted_fixture_writable_with_args_single_archive_with_post_with_git_version<T>(
1279 script_name: impl AsRef<Path>,
1280 args: impl IntoIterator<Item = impl Into<String>>,
1281 mode: Creation,
1282 version: u32,
1283 post_process: impl FnMut(FixtureState<'_>) -> PostResult<T>,
1284 is_git_version_compatible: impl FnOnce((u8, u8, u8)) -> bool,
1285) -> Result<Option<(tempfile::TempDir, T)>> {
1286 scripted_fixture_writable_with_args_inner(
1287 script_name,
1288 args,
1289 mode,
1290 ArgsInHash::No,
1291 default_excludes(),
1292 Some((version, post_process)),
1293 archive_policy_for_git_version(is_git_version_compatible),
1294 )
1295 .map(|fixture| fixture.map(|(tmp, opt)| (tmp, opt.expect("post_process was provided"))))
1296}
1297
1298pub fn rust_fixture_read_only<T, F>(name: &str, version: u32, make_fixture: F) -> Result<(PathBuf, T)>
1337where
1338 F: FnOnce(FixtureState<'_>) -> PostResult<T>,
1339{
1340 rust_fixture_read_only_inner(name, version, None, make_fixture, None, default_excludes())
1341}
1342
1343pub fn rust_fixture_writable<T, F>(
1372 name: &str,
1373 version: u32,
1374 mode: Creation,
1375 make_fixture: F,
1376) -> Result<(tempfile::TempDir, T)>
1377where
1378 F: FnMut(FixtureState<'_>) -> PostResult<T>,
1379{
1380 rust_fixture_writable_inner(name, version, None, make_fixture, mode, default_excludes())
1381}
1382
1383fn rust_fixture_writable_inner<T, F>(
1384 name: &str,
1385 version: u32,
1386 object_hash: Option<gix_hash::Kind>,
1387 mut make_fixture: F,
1388 mode: Creation,
1389 excludes: &dyn IsExcluded,
1390) -> Result<(tempfile::TempDir, T)>
1391where
1392 F: FnMut(FixtureState<'_>) -> PostResult<T>,
1393{
1394 let dst = tempfile::TempDir::new()?;
1395 let res = match mode {
1396 Creation::CopyFromReadOnly => {
1397 let (ro_dir, _res_ignored) =
1398 rust_fixture_read_only_inner(name, version, object_hash, &mut make_fixture, None, excludes)?;
1399 copy_recursively_into_existing_dir(ro_dir, dst.path())?;
1400 make_fixture(FixtureState::Fresh(dst.path()))?
1401 }
1402 Creation::Execute => {
1403 let (_, res) =
1404 rust_fixture_read_only_inner(name, version, object_hash, make_fixture, Some(dst.path()), excludes)?;
1405 res
1406 }
1407 };
1408 Ok((dst, res))
1409}
1410
1411fn rust_fixture_read_only_inner<T, F>(
1412 name: &str,
1413 version: u32,
1414 object_hash: Option<gix_hash::Kind>,
1415 make_fixture: F,
1416 destination_dir: Option<&Path>,
1417 excludes: &dyn IsExcluded,
1418) -> Result<(PathBuf, T)>
1419where
1420 F: FnOnce(FixtureState<'_>) -> PostResult<T>,
1421{
1422 gix_tempfile::signal::setup(
1424 gix_tempfile::signal::handler::Mode::DeleteTempfilesOnTerminationAndRestoreDefaultBehaviour,
1425 );
1426
1427 let script_identity = version;
1430 let archive_name = format!("rust-{name}");
1431 let fixture_base = fixture_base();
1432
1433 let archive_file_path = fixture_base
1434 .join(ARCHIVE_DIR_NAME)
1435 .join(format!("{archive_name}.{}", tar_extension()));
1436 let (force_run, script_result_directory) = force_and_dir(
1437 destination_dir,
1438 &fixture_base,
1439 &archive_name,
1440 object_hash,
1441 &script_identity,
1442 None,
1443 );
1444 let _marker = marker_if_needed(destination_dir, archive_name)?;
1445
1446 run_fixture_generator_with_marker_handling(
1447 &archive_file_path,
1448 &script_result_directory,
1449 script_identity,
1450 force_run,
1451 ArchivePolicy::Normal,
1452 excludes,
1453 &format!("using Rust closure '{name}'"),
1454 make_fixture,
1455 )
1456 .map(|res| {
1457 (
1458 script_result_directory,
1459 res.expect("normal fixtures fall back to generation"),
1460 )
1461 })
1462}
1463
1464fn marker_if_needed(
1467 destination_dir: Option<&Path>,
1468 archive_name: impl AsRef<Path>,
1469) -> Result<Option<gix_lock::Marker>> {
1470 Ok(destination_dir
1471 .is_none()
1472 .then(|| {
1473 gix_lock::Marker::acquire_to_hold_resource(
1474 archive_name,
1475 gix_lock::acquire::Fail::AfterDurationWithBackoff(Duration::from_secs(6 * 60)),
1476 None,
1477 )
1478 })
1479 .transpose()?)
1480}
1481
1482fn force_and_dir(
1483 destination_dir: Option<&Path>,
1484 fixture_base: &Path,
1485 archive_name: impl AsRef<Path>,
1486 object_hash: Option<gix_hash::Kind>,
1487 script_identity: &dyn std::fmt::Display,
1488 cache_variant: Option<&str>,
1489) -> (bool, PathBuf) {
1490 destination_dir.map_or_else(
1491 || {
1492 let mut dir = fixture_base.join(
1493 Path::new("generated-do-not-edit")
1494 .join(archive_name)
1495 .join(object_hash.unwrap_or_else(self::object_hash).to_string()),
1496 );
1497 if let Some(cache_variant) = cache_variant {
1498 dir = dir.join(cache_variant);
1499 }
1500 let dir = dir.join(format!("{}-{}", script_identity, family_name()));
1501 (false, dir)
1502 },
1503 |d| (true, d.to_owned()),
1504 )
1505}
1506
1507#[expect(clippy::too_many_arguments)]
1508fn run_fixture_generator_with_marker_handling<T, F>(
1509 archive_file_path: &Path,
1510 script_result_directory: &Path,
1511 script_identity: u32,
1512 force_run: bool,
1513 archive_policy: ArchivePolicy,
1514 excludes: &dyn IsExcluded,
1515 description: &str,
1516 make_fixture: F,
1517) -> Result<Option<T>>
1518where
1519 F: FnOnce(FixtureState<'_>) -> PostResult<T>,
1520{
1521 let failure_marker = script_result_directory.join("_invalid_state_due_to_script_failure_");
1522 if force_run || !script_result_directory.is_dir() || failure_marker.is_file() {
1523 if failure_marker.is_file() {
1524 std::fs::remove_dir_all(script_result_directory).map_err(|err| {
1525 format!(
1526 "Failed to remove '{script_result_directory}', please try to do that by hand. Original error: {err}",
1527 script_result_directory = script_result_directory.display()
1528 )
1529 })?;
1530 }
1531 std::fs::create_dir_all(script_result_directory)?;
1532 if !force_run || archive_policy != ArchivePolicy::Normal {
1536 match extract_archive(
1537 archive_file_path,
1538 script_result_directory,
1539 script_identity,
1540 archive_policy.ignores_archive_override(),
1541 ) {
1542 Ok((archive_id, platform)) => {
1543 eprintln!(
1544 "Extracted fixture from archive '{}' ({}, {:?})",
1545 archive_file_path.display(),
1546 archive_id,
1547 platform
1548 );
1549 return make_fixture(FixtureState::Fresh(script_result_directory)).map(Some);
1550 }
1551 Err(err) => {
1552 let archive_missing = err.kind() == std::io::ErrorKind::NotFound;
1553 let generation_allowed = archive_policy.allows_generation();
1554 if !generation_allowed || !archive_missing {
1555 std::fs::remove_dir_all(script_result_directory).map_err(|cleanup_err| {
1558 format!(
1559 "Failed to remove incomplete fixture at '{}': {cleanup_err}",
1560 script_result_directory.display()
1561 )
1562 })?;
1563 }
1564 if !generation_allowed {
1565 if archive_missing {
1566 return Ok(None);
1567 }
1568 return Err(err.into());
1569 }
1570 if !archive_missing {
1571 eprintln!("failed to extract '{}': {}", archive_file_path.display(), err);
1572 std::fs::create_dir_all(script_result_directory)?;
1573 } else if !excludes.is_excluded(archive_file_path) {
1574 eprintln!(
1575 "Archive at '{}' not found, creating fixture {}",
1576 archive_file_path.display(),
1577 description
1578 );
1579 }
1580 }
1581 }
1582 }
1583 let res = match make_fixture(FixtureState::Uninitialized(script_result_directory)) {
1584 Ok(value) => value,
1585 Err(err) => {
1586 write_failure_marker(&failure_marker);
1587 return Err(err);
1588 }
1589 };
1590 if !force_run {
1591 create_archive_if_we_should(script_result_directory, archive_file_path, script_identity, excludes)
1592 .inspect_err(|_err| {
1593 write_failure_marker(&failure_marker);
1594 })?;
1595 }
1596 Ok(Some(res))
1597 } else {
1598 make_fixture(FixtureState::Fresh(script_result_directory)).map(Some)
1599 }
1600}
1601
1602fn scripted_fixture_read_only_with_args_inner<F, T>(
1603 script_name: impl AsRef<Path>,
1604 args: impl IntoIterator<Item = impl Into<String>>,
1605 destination_dir: Option<&Path>,
1606 args_in_hash: ArgsInHash,
1607 excludes: &dyn IsExcluded,
1608 post_process: Option<(u32, F)>,
1609 archive_policy: ArchivePolicy,
1610) -> Result<Option<(PathBuf, Option<T>)>>
1611where
1612 F: FnMut(FixtureState<'_>) -> PostResult<T>,
1613{
1614 gix_tempfile::signal::setup(
1616 gix_tempfile::signal::handler::Mode::DeleteTempfilesOnTerminationAndRestoreDefaultBehaviour,
1617 );
1618
1619 let object_hash = object_hash();
1620
1621 let script_location = script_name.as_ref();
1622 let fixture_base = fixture_base();
1623 let script_path = fixture_path(script_location);
1624
1625 let args: Vec<String> = args.into_iter().map(Into::into).collect();
1627 let post_version = post_process.as_ref().map(|(v, _)| *v);
1628 let script_identity = {
1629 let mut map = SCRIPT_IDENTITY.lock();
1630 let init = if is_sha1(object_hash) {
1631 script_path.clone()
1632 } else {
1633 script_path.clone().join(object_hash.to_string())
1634 };
1635 let key = args.iter().fold(init, |p, a| p.join(a));
1636 let key = if let Some(v) = post_version {
1638 key.join(format!("post-v{v}"))
1639 } else {
1640 key
1641 };
1642 map.entry(key)
1643 .or_insert_with(|| {
1644 let crc_value = crc::Crc::<u32>::new(&crc::CRC_32_CKSUM);
1645 let mut crc_digest = crc_value.digest();
1646 crc_digest.update(&std::fs::read(&script_path).unwrap_or_else(|err| {
1647 panic!(
1648 "file {script_path} in CWD '{cwd}' could not be read: {err}",
1649 cwd = env::current_dir().expect("valid cwd").display(),
1650 script_path = script_path.display(),
1651 )
1652 }));
1653 for arg in &args {
1654 crc_digest.update(arg.as_bytes());
1655 }
1656 if let Some(v) = post_version {
1658 crc_digest.update(&v.to_le_bytes());
1659 }
1660 crc_digest.finalize()
1661 })
1662 .to_owned()
1663 };
1664
1665 let script_basename = script_location.file_stem().unwrap_or(script_location.as_os_str());
1666 let archive_file_path = fixture_base.join(ARCHIVE_DIR_NAME).join({
1667 let suffix = match args_in_hash {
1668 ArgsInHash::Yes => {
1669 let mut suffix = args.join("_");
1670 if !suffix.is_empty() {
1671 suffix.insert(0, '_');
1672 }
1673 suffix.replace(['\\', '/', ' ', '.'], "_")
1674 }
1675 ArgsInHash::No => "".into(),
1676 };
1677 let potential_hash_suffix = if is_sha1(object_hash) {
1678 "".into()
1679 } else {
1680 format!("_{object_hash}")
1681 };
1682 format!(
1683 "{}{suffix}{potential_hash_suffix}.{}",
1684 script_basename.to_str().expect("valid UTF-8"),
1685 tar_extension()
1686 )
1687 });
1688 let (force_run, script_result_directory) = force_and_dir(
1689 destination_dir,
1690 &fixture_base,
1691 script_basename,
1692 Some(object_hash),
1693 &script_identity,
1694 archive_policy.cache_variant(),
1695 );
1696 let _marker = marker_if_needed(destination_dir, script_basename)?;
1697
1698 let script_identity_for_archive = match args_in_hash {
1699 ArgsInHash::Yes => script_identity,
1700 ArgsInHash::No => 0,
1701 };
1702 let script_absolute_path = env::current_dir()?.join(&script_path);
1703 let post_process_closure = post_process.map(|(_, f)| f);
1704
1705 let res = run_fixture_generator_with_marker_handling(
1706 &archive_file_path,
1707 &script_result_directory,
1708 script_identity_for_archive,
1709 force_run,
1710 archive_policy,
1711 excludes,
1712 &format!("using script '{}'", script_location.display()),
1713 |fixture_state| {
1714 if let FixtureState::Uninitialized(dir) = fixture_state {
1715 let mut cmd = std::process::Command::new(&script_absolute_path);
1716 let output = match configure_command(&mut cmd, object_hash, &args, dir).output() {
1717 Ok(out) => out,
1718 Err(err)
1719 if err.kind() == std::io::ErrorKind::PermissionDenied
1720 || err.raw_os_error() == Some(193) =>
1721 {
1722 cmd = std::process::Command::new(bash_program());
1723 configure_command(cmd.arg(&script_absolute_path), object_hash, &args, dir).output()?
1724 }
1725 Err(err) => return Err(err.into()),
1726 };
1727 if !output.status.success() {
1728 eprintln!("stdout: {}", output.stdout.as_bstr());
1729 eprintln!("stderr: {}", output.stderr.as_bstr());
1730 return Err(format!("fixture script of {cmd:?} failed").into());
1731 }
1732 }
1733 if let Some(mut f) = post_process_closure {
1734 f(fixture_state).map(Some)
1735 } else {
1736 Ok(None)
1737 }
1738 },
1739 )?;
1740
1741 Ok(res.map(|res| (script_result_directory, res)))
1742}
1743
1744pub fn object_hash_from_env() -> Option<gix_hash::Kind> {
1756 static FIXTURE_HASH: LazyLock<Option<gix_hash::Kind>> = LazyLock::new(|| {
1757 env::var_os("GIX_TEST_FIXTURE_HASH").and_then(|value| value.into_string().ok()).map(|object_kind| {
1758 gix_hash::Kind::from_str(&object_kind).unwrap_or_else(|_| {
1759 panic!(
1760 "GIX_TEST_FIXTURE_HASH was set to {object_kind} which is an invalid value. Valid values are {}. Exiting.",
1761 gix_hash::Kind::all().iter().map(std::string::ToString::to_string).collect::<Vec<_>>().join(", ")
1762 )
1763 })
1764 })
1765 });
1766 *FIXTURE_HASH
1767}
1768
1769pub fn object_hash() -> gix_hash::Kind {
1771 object_hash_from_env().unwrap_or_default()
1772}
1773
1774fn is_sha1(kind: gix_hash::Kind) -> bool {
1775 kind.len_in_bytes() == 20
1776}
1777
1778pub fn git(current_dir: impl AsRef<Path>, arguments: &str) -> Result<String> {
1785 let args = split_git_arguments(arguments)?;
1786 let cwd = current_dir.as_ref();
1787 let mut cmd = std::process::Command::new(gix_path::env::exe_invocation());
1788 let output = configure_command(&mut cmd, object_hash(), args.iter().map(String::as_str), cwd)
1789 .current_dir(cwd)
1790 .output()?;
1791 if !output.status.success() {
1792 return Err(format!(
1793 "{cmd:?} failed with status {}\nstdout: {}\nstderr: {}",
1794 output.status,
1795 output.stdout.as_bstr(),
1796 output.stderr.as_bstr()
1797 )
1798 .into());
1799 }
1800 Ok(String::from_utf8(output.stdout)?)
1801}
1802
1803fn split_git_arguments(input: &str) -> Result<Vec<String>> {
1804 let mut args = Vec::new();
1805 let mut arg = String::new();
1806 let mut quote = None;
1807 let mut has_arg = false;
1808 let mut chars = input.chars();
1809
1810 while let Some(ch) = chars.next() {
1811 match quote {
1812 Some('\'') => {
1813 if ch == '\'' {
1814 quote = None;
1815 } else {
1816 arg.push(ch);
1817 }
1818 }
1819 Some('"') => {
1820 if ch == '"' {
1821 quote = None;
1822 } else if ch == '\\' {
1823 if let Some(next) = chars.next() {
1824 arg.push(next);
1825 }
1826 } else {
1827 arg.push(ch);
1828 }
1829 }
1830 Some(_) => unreachable!("only single and double quotes are set"),
1831 None => {
1832 if ch.is_whitespace() {
1833 if has_arg {
1834 args.push(std::mem::take(&mut arg));
1835 has_arg = false;
1836 }
1837 } else if matches!(ch, '\'' | '"') {
1838 quote = Some(ch);
1839 has_arg = true;
1840 } else if ch == '\\' {
1841 if let Some(next) = chars.next() {
1842 arg.push(next);
1843 }
1844 has_arg = true;
1845 } else {
1846 arg.push(ch);
1847 has_arg = true;
1848 }
1849 }
1850 }
1851 }
1852
1853 if let Some(quote) = quote {
1854 return Err(format!("unterminated {quote:?} quote in git arguments").into());
1855 }
1856 if has_arg {
1857 args.push(arg);
1858 }
1859 Ok(args)
1860}
1861
1862pub fn normalize_debug_snapshot(value: &dyn std::fmt::Debug) -> (String, Vec<gix_hash::ObjectId>) {
1870 normalize_hashes(&format!("{value:#?}"))
1871}
1872
1873pub fn normalize_hashes(input: &str) -> (String, Vec<gix_hash::ObjectId>) {
1877 let mut out = String::with_capacity(input.len());
1878 let mut seen = HashMap::<gix_hash::ObjectId, usize>::new();
1879 let mut removed = Vec::<gix_hash::ObjectId>::new();
1880 let mut chars = input.chars().peekable();
1881 let mut hex = String::new();
1882
1883 while let Some(ch) = chars.next() {
1884 if ch.is_ascii_hexdigit() {
1885 hex.clear();
1886 hex.push(ch);
1887 while let Some(ch) = chars.next_if(char::is_ascii_hexdigit) {
1888 hex.push(ch);
1889 }
1890
1891 if let Some(oid) = raw_object_id(&hex) {
1892 strip_debug_hash_wrapper(&mut out, &mut chars);
1893 push_normalized_oid(oid, &mut seen, &mut removed, &mut out);
1894 } else {
1895 out.push_str(&hex);
1896 }
1897 } else {
1898 out.push(ch);
1899 }
1900 }
1901 (out, removed)
1902}
1903
1904fn raw_object_id(input: &str) -> Option<gix_hash::ObjectId> {
1905 if !matches!(input.len(), 40 | 64) {
1906 return None;
1907 }
1908 gix_hash::ObjectId::from_hex(input.as_bytes()).ok()
1909}
1910
1911fn strip_debug_hash_wrapper(out: &mut String, chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
1912 if !matches!(chars.peek(), Some(')')) {
1913 return;
1914 }
1915 let consume_closing_parenthesis = if out.ends_with("Sha1(") {
1918 out.truncate(out.len() - "Sha1(".len());
1919 true
1920 } else if out.ends_with("Sha256(") {
1921 out.truncate(out.len() - "Sha256(".len());
1922 true
1923 } else {
1924 false
1925 };
1926 if consume_closing_parenthesis {
1927 chars.next();
1928 }
1929}
1930
1931fn push_normalized_oid(
1932 oid: gix_hash::ObjectId,
1933 seen: &mut HashMap<gix_hash::ObjectId, usize>,
1934 removed: &mut Vec<gix_hash::ObjectId>,
1935 out: &mut String,
1936) {
1937 let normalized = *seen.entry(oid).or_insert_with(|| {
1938 let current = removed.len();
1939 removed.push(oid);
1940 current
1941 });
1942
1943 out.push_str("Oid(");
1944 out.push_str(&(normalized + 1).to_string());
1945 out.push(')');
1946}
1947
1948#[cfg(windows)]
1949const NULL_DEVICE: &str = "nul"; #[cfg(not(windows))]
1951const NULL_DEVICE: &str = "/dev/null";
1952
1953fn prefer_git_in_path(command: &mut std::process::Command, git: &Path) {
1959 let Some(parent) = git.is_absolute().then(|| git.parent()).flatten() else {
1960 return;
1961 };
1962 let inherited_path = env::var_os("PATH").unwrap_or_default();
1963 let paths = std::iter::once(parent.to_owned()).chain(env::split_paths(&inherited_path));
1964 if let Ok(path) = env::join_paths(paths) {
1965 command.env("PATH", path);
1966 }
1967}
1968
1969fn configure_command<'a, I: IntoIterator<Item = S>, S: AsRef<OsStr>>(
1970 cmd: &'a mut std::process::Command,
1971 object_hash: gix_hash::Kind,
1972 args: I,
1973 script_result_directory: &Path,
1974) -> &'a mut std::process::Command {
1975 let mut msys_for_git_bash_on_windows = env::var_os("MSYS").unwrap_or_default();
1979 msys_for_git_bash_on_windows.push(" winsymlinks:nativestrict");
1980 prefer_git_in_path(cmd, gix_path::env::exe_invocation());
1981 cmd.args(args)
1982 .stdout(std::process::Stdio::piped())
1983 .stderr(std::process::Stdio::piped())
1984 .current_dir(script_result_directory)
1985 .env_remove("GIT_DIR")
1986 .env_remove("GIT_INDEX_FILE")
1987 .env_remove("GIT_OBJECT_DIRECTORY")
1988 .env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES")
1989 .env_remove("GIT_WORK_TREE")
1990 .env_remove("GIT_COMMON_DIR")
1991 .env_remove("GIT_ASKPASS")
1992 .env_remove("SSH_ASKPASS")
1993 .env("MSYS", msys_for_git_bash_on_windows)
1994 .env(
1995 "XDG_CONFIG_HOME",
1996 script_result_directory.join(".gix-testtools-xdg-config"),
1997 )
1998 .env("GIT_CONFIG_NOSYSTEM", "1")
1999 .env("GIT_CONFIG_GLOBAL", NULL_DEVICE)
2000 .env("GIT_TERMINAL_PROMPT", "false")
2001 .env("GIT_AUTHOR_DATE", "2000-01-01 00:00:00 +0000")
2002 .env("GIT_AUTHOR_EMAIL", "author@example.com")
2003 .env("GIT_AUTHOR_NAME", "author")
2004 .env("GIT_COMMITTER_DATE", "2000-01-02 00:00:00 +0000")
2005 .env("GIT_COMMITTER_EMAIL", "committer@example.com")
2006 .env("GIT_COMMITTER_NAME", "committer")
2007 .env("GIT_DEFAULT_HASH", object_hash.to_string());
2008 apply_git_config_by_environment(cmd, ISOLATED_GIT_CONFIG)
2009}
2010
2011pub fn apply_git_config_by_environment<'a>(
2019 cmd: &'a mut std::process::Command,
2020 config: &[(&str, &str)],
2021) -> &'a mut std::process::Command {
2022 cmd.env("GIT_CONFIG_COUNT", config.len().to_string());
2023 for (idx, (key, value)) in config.iter().enumerate() {
2024 cmd.env(format!("GIT_CONFIG_KEY_{idx}"), key);
2025 cmd.env(format!("GIT_CONFIG_VALUE_{idx}"), value);
2026 }
2027 cmd
2028}
2029
2030pub fn bash_program() -> &'static Path {
2059 static GIT_BASH: LazyLock<PathBuf> = LazyLock::new(|| {
2062 if cfg!(windows) {
2063 GIT_CORE_DIR
2064 .ancestors()
2065 .nth(3)
2066 .map(OsStr::new)
2067 .iter()
2068 .flat_map(|prefix| {
2069 ["/bin/bash.exe", "/usr/bin/bash.exe"].into_iter().map(|suffix| {
2071 let mut raw_path = (*prefix).to_owned();
2072 raw_path.push(suffix);
2073 raw_path
2074 })
2075 })
2076 .map(PathBuf::from)
2077 .find(|bash| bash.is_file())
2078 .unwrap_or_else(|| "bash.exe".into())
2079 } else {
2080 "bash".into()
2081 }
2082 });
2083 GIT_BASH.as_ref()
2084}
2085
2086fn write_failure_marker(failure_marker: &Path) {
2087 std::fs::write(failure_marker, []).ok();
2088}
2089
2090fn should_skip_all_archive_creation() -> bool {
2091 cfg!(windows) || (is_ci::cached() && env::var_os("GIX_TEST_CREATE_ARCHIVES_EVEN_ON_CI").is_none())
2096}
2097
2098fn is_lfs_pointer_file(path: &Path) -> bool {
2099 const PREFIX: &[u8] = b"version https://git-lfs";
2100 let mut buf = [0_u8; PREFIX.len()];
2101 std::fs::OpenOptions::new()
2102 .read(true)
2103 .open(path)
2104 .is_ok_and(|mut f| f.read_exact(&mut buf).is_ok_and(|_| buf.starts_with(PREFIX)))
2105}
2106
2107fn create_archive_if_we_should(
2110 source_dir: &Path,
2111 archive: &Path,
2112 script_identity: u32,
2113 excludes: &dyn IsExcluded,
2114) -> std::io::Result<()> {
2115 if should_skip_all_archive_creation() || excludes.is_excluded(archive) {
2116 return Ok(());
2117 }
2118 if is_lfs_pointer_file(archive) {
2119 eprintln!(
2120 "Refusing to overwrite `gix-lfs` pointer file at \"{}\" - git lfs might not be properly installed.",
2121 archive.display()
2122 );
2123 return Ok(());
2124 }
2125 std::fs::create_dir_all(archive.parent().expect("archive is a file"))?;
2126
2127 let meta_dir = populate_meta_dir(source_dir, script_identity)?;
2128 let res = (move || {
2129 let mut buf = Vec::<u8>::new();
2130 {
2131 let mut ar = tar::Builder::new(&mut buf);
2132 ar.mode(tar::HeaderMode::Deterministic);
2133 ar.follow_symlinks(false);
2134 ar.append_dir_all(".", source_dir)?;
2135 ar.finish()?;
2136 }
2137 #[cfg_attr(feature = "xz", allow(unused_mut))]
2138 let mut archive = std::fs::OpenOptions::new()
2139 .write(true)
2140 .create(true)
2141 .truncate(true)
2142 .open(archive)?;
2143 #[cfg(feature = "xz")]
2144 {
2145 let mut xz_write = xz2::write::XzEncoder::new(archive, 3);
2146 std::io::copy(&mut &*buf, &mut xz_write)?;
2147 xz_write.finish()?.close()
2148 }
2149 #[cfg(not(feature = "xz"))]
2150 {
2151 use std::io::Write;
2152 archive.write_all(&buf)?;
2153 archive.close()
2154 }
2155 })();
2156 #[cfg(not(windows))]
2157 std::fs::remove_dir_all(meta_dir)?;
2158 #[cfg(windows)]
2159 std::fs::remove_dir_all(meta_dir).ok(); res
2162}
2163
2164const META_DIR_NAME: &str = "__gitoxide_meta__";
2165const META_IDENTITY: &str = "identity";
2166const META_GIT_VERSION: &str = "git-version";
2167
2168fn populate_meta_dir(destination_dir: &Path, script_identity: u32) -> std::io::Result<PathBuf> {
2169 let meta_dir = destination_dir.join(META_DIR_NAME);
2170 std::fs::create_dir_all(&meta_dir)?;
2171 std::fs::write(
2172 meta_dir.join(META_IDENTITY),
2173 format!("{}-{}", script_identity, family_name()).as_bytes(),
2174 )?;
2175 let (major, minor, patch) = *GIT_VERSION;
2176 std::fs::write(
2177 meta_dir.join(META_GIT_VERSION),
2178 format!("git version {major}.{minor}.{patch}\n"),
2179 )?;
2180 Ok(meta_dir)
2181}
2182
2183fn extract_archive(
2186 archive: &Path,
2187 destination_dir: &Path,
2188 required_script_identity: u32,
2189 ignore_archive_override: bool,
2190) -> std::io::Result<(u32, Option<String>)> {
2191 let archive_buf: Vec<u8> = {
2192 let mut buf = Vec::new();
2193 #[cfg_attr(feature = "xz", allow(unused_mut))]
2194 let mut input_archive = std::fs::File::open(archive)?;
2195 if !ignore_archive_override && env::var_os("GIX_TEST_IGNORE_ARCHIVES").is_some() {
2196 return Err(std::io::Error::other(format!(
2197 "Ignoring archive at '{}' as GIX_TEST_IGNORE_ARCHIVES is set.",
2198 archive.display()
2199 )));
2200 }
2201 #[cfg(feature = "xz")]
2202 {
2203 let mut decoder = xz2::bufread::XzDecoder::new(std::io::BufReader::new(input_archive));
2204 std::io::copy(&mut decoder, &mut buf)?;
2205 }
2206 #[cfg(not(feature = "xz"))]
2207 {
2208 input_archive.read_to_end(&mut buf)?;
2209 }
2210 buf
2211 };
2212
2213 let mut entry_buf = Vec::<u8>::new();
2214 let (archive_identity, platform): (u32, _) = tar::Archive::new(std::io::Cursor::new(&mut &*archive_buf))
2215 .entries_with_seek()?
2216 .filter_map(std::result::Result::ok)
2217 .find_map(|mut e: tar::Entry<'_, _>| {
2218 let path = e.path().ok()?;
2219 if path.parent()?.file_name()? == META_DIR_NAME && path.file_name()? == META_IDENTITY {
2220 entry_buf.clear();
2221 e.read_to_end(&mut entry_buf).ok()?;
2222 let mut tokens = entry_buf.to_str().ok()?.trim().splitn(2, '-');
2223 match (tokens.next(), tokens.next()) {
2224 (Some(id), platform) => Some((id.parse().ok()?, platform.map(ToOwned::to_owned))),
2225 _ => None,
2226 }
2227 } else {
2228 None
2229 }
2230 })
2231 .ok_or_else(|| std::io::Error::other("BUG: Could not find meta directory in our own archive"))
2232 .map_err(|err| {
2233 std::io::Error::other(format!(
2234 "Could not extract archive at '{archive}': {err}",
2235 archive = archive.display()
2236 ))
2237 })?;
2238 if archive_identity != required_script_identity {
2239 eprintln!(
2240 "Ignoring archive at '{}' as its generating script changed",
2241 archive.display()
2242 );
2243 return Err(std::io::ErrorKind::NotFound.into());
2244 }
2245
2246 for entry in tar::Archive::new(&mut &*archive_buf).entries()? {
2247 let mut entry = entry?;
2248 let path = entry.path()?;
2249 if path.to_str() == Some(META_DIR_NAME) || path.parent().and_then(Path::to_str) == Some(META_DIR_NAME) {
2250 continue;
2251 }
2252 entry.unpack_in(destination_dir)?;
2253 }
2254 Ok((archive_identity, platform))
2255}
2256
2257fn family_name() -> &'static str {
2258 if cfg!(windows) { "windows" } else { "unix" }
2259}
2260
2261#[derive(Default)]
2263pub struct Env<'a> {
2264 altered_vars: Vec<(&'a str, Option<OsString>)>,
2265}
2266
2267fn set_var(var: &str, value: impl AsRef<OsStr>) {
2268 unsafe { env::set_var(var, value) };
2271}
2272
2273fn remove_var(var: &str) {
2274 unsafe { env::remove_var(var) };
2277}
2278
2279impl<'a> Env<'a> {
2280 pub fn new() -> Self {
2282 Env {
2283 altered_vars: Vec::new(),
2284 }
2285 }
2286
2287 pub fn set(mut self, var: &'a str, value: impl Into<String>) -> Self {
2289 let prev = env::var_os(var);
2290 set_var(var, value.into());
2291 self.altered_vars.push((var, prev));
2292 self
2293 }
2294
2295 pub fn unset(mut self, var: &'a str) -> Self {
2297 let prev = env::var_os(var);
2298 remove_var(var);
2299 self.altered_vars.push((var, prev));
2300 self
2301 }
2302}
2303
2304impl Drop for Env<'_> {
2305 fn drop(&mut self) {
2306 for (var, prev_value) in self.altered_vars.iter().rev() {
2307 match prev_value {
2308 Some(value) => set_var(var, value),
2309 None => remove_var(var),
2310 }
2311 }
2312 }
2313}
2314
2315pub fn size_ok(actual_size: usize, expected_64_bit_size: usize) -> bool {
2333 #[cfg(target_pointer_width = "64")]
2334 return actual_size == expected_64_bit_size;
2335 #[cfg(target_pointer_width = "32")]
2336 return actual_size <= expected_64_bit_size;
2337}
2338
2339#[cfg(unix)]
2341pub fn umask() -> u32 {
2342 let output = std::process::Command::new("/bin/sh")
2343 .args(["-c", "umask"])
2344 .output()
2345 .expect("can execute `sh -c umask`");
2346 assert!(output.status.success(), "`sh -c umask` failed");
2347 assert_eq!(output.stderr.as_bstr(), "", "`sh -c umask` unexpected message");
2348 let text = output.stdout.to_str().expect("valid Unicode").trim();
2349 u32::from_str_radix(text, 8).expect("parses as octal number")
2350}
2351
2352fn tar_extension() -> &'static str {
2353 if cfg!(feature = "xz") { "tar.xz" } else { "tar" }
2354}
2355
2356#[cfg(test)]
2357mod tests;