//! Isolated `git` invocations. //! //! Every command built here ignores the machine's system and global Git //! configuration, never prompts for credentials, uses a fixed author and //! committer identity, and runs no automatic maintenance //! ([`NO_AUTO_MAINTENANCE`]), so tests behave the same on a laptop and in CI. use std::ffi::OsStr; use std::io; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; /// Global `-c` options that stop `git` from starting automatic maintenance. /// /// `git fetch`, `commit`, `merge`, `am`, `rebase` and `receive-pack` end by /// starting `git maintenance run --auto`, detached by default. Since Git 2.54 /// that runs a `git repack -d -l --cruft --write-midx` whenever a /// repository holds two or more packs that are not in a geometric size /// progression, which a fetch with packfile URIs (one pack per URI) almost /// always leaves. The repack rewrites `objects/pack` in the background, so a /// `git fsck --strict` the test runs next fails now and then with `packfile /// .../.tmp--pack-.pack index not opened` or `unable to load /// rev-index`: fsck lists an `.idx` the repack then renames or deletes /// (issues #72 and #207). With maintenance off, a test checks exactly what /// the server delivered. /// /// `maintenance.auto=false` turns it off; `gc.auto=0` does on Git older than /// 2.29, which runs `git gc --auto` instead. Pass these before the /// subcommand: `git fetch ...`. [`isolated_git`] /// already does; a suite that builds its own `git` command must add them. pub const NO_AUTO_MAINTENANCE: [&str; 4] = ["-c", "maintenance.auto=false", "-c", "gc.auto=0"]; /// A `git` command isolated from the machine's system and global config, with /// `home` as its home directory, a fixed author identity and no automatic /// maintenance ([`NO_AUTO_MAINTENANCE`]). pub fn isolated_git(home: &Path) -> Command { let mut cmd = Command::new("git"); cmd.env("HOME", home) .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_TERMINAL_PROMPT", "0") .env("GIT_AUTHOR_NAME", "Aspen Interop") .env("GIT_AUTHOR_EMAIL", "interop@aspen.invalid") .env("GIT_COMMITTER_NAME", "Aspen Interop") .env("GIT_COMMITTER_EMAIL", "interop@aspen.invalid") .env_remove("GIT_DIR") .env_remove("GIT_WORK_TREE") .env_remove("GIT_INDEX_FILE") .env_remove("GIT_OBJECT_DIRECTORY") .env_remove("GIT_PROTOCOL") .env_remove("GIT_TRACE") .env_remove("GIT_TRACE_PACKET") .args(NO_AUTO_MAINTENANCE); cmd } /// Creates an empty bare repository at `root/name` that accepts pushes over /// HTTP, and returns its path. pub fn init_bare(root: &Path, name: &str) -> io::Result { let path = root.join(name); run(isolated_git(root) .args(["init", "--quiet", "--bare", "--initial-branch=main"]) .arg(&path))?; run(isolated_git(root) .args(["config", "http.receivepack", "true"]) .current_dir(&path))?; Ok(path) } /// Runs `cmd` and returns its stdout. A non-zero exit becomes an error whose /// message holds the command line, the exit status, stdout and stderr. pub fn run(cmd: &mut Command) -> io::Result { let out = cmd.output()?; if out.status.success() { Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } else { Err(io::Error::other(describe_failure(cmd, &out))) } } /// A readable account of a failed command, for error messages. pub fn describe_failure(cmd: &Command, out: &Output) -> String { format!( "{} failed with {}\nstdout:\n{}\nstderr:\n{}", command_line(cmd), out.status, String::from_utf8_lossy(&out.stdout).trim_end(), String::from_utf8_lossy(&out.stderr).trim_end() ) } fn command_line(cmd: &Command) -> String { let mut line = cmd.get_program().to_string_lossy().into_owned(); for arg in cmd.get_args() { line.push(' '); line.push_str(&arg.to_string_lossy()); } if let Some(dir) = cmd.get_current_dir() { line.push_str(&format!(" (in {})", dir.display())); } line } /// The `git --version` line of the `git` on `PATH`. pub fn version() -> io::Result { Ok(run(Command::new("git").arg("--version"))?.trim().to_owned()) } /// A git client with its own home directory and optional `-c` settings, /// for tests that run many commands as one user. #[derive(Debug, Clone)] pub struct GitClient { home: PathBuf, config: Vec, } impl GitClient { /// A client whose `HOME` is `home`. The directory must exist. pub fn new(home: &Path) -> Self { Self { home: home.to_path_buf(), config: Vec::new(), } } /// Adds a `-c key=value` setting to every command this client builds, /// for example `protocol.version=2`. pub fn with_config(mut self, key: &str, value: &str) -> Self { self.config.push(format!("{key}={value}")); self } /// The client's home directory. pub fn home(&self) -> &Path { &self.home } /// An isolated `git` command that runs in `dir`. pub fn command(&self, dir: &Path) -> Command { let mut cmd = isolated_git(&self.home); cmd.current_dir(dir); for setting in &self.config { cmd.arg("-c").arg(setting); } cmd } /// Runs `git ` in `dir` and returns stdout; fails on a non-zero exit. pub fn run(&self, dir: &Path, args: I) -> io::Result where I: IntoIterator, S: AsRef, { run(self.command(dir).args(args)) } /// Runs `git ` in `dir` and returns the raw output, whatever the /// exit status. Fails only if `git` could not be started. pub fn output(&self, dir: &Path, args: I) -> io::Result where I: IntoIterator, S: AsRef, { self.command(dir).args(args).output() } } #[cfg(test)] mod tests { use super::*; #[test] fn failures_name_the_command_and_its_stderr() { let tmp = tempfile::tempdir().unwrap(); let client = GitClient::new(tmp.path()); let err = client .run(tmp.path(), ["rev-parse", "--verify", "no-such-ref"]) .unwrap_err() .to_string(); let line = format!( "git {} rev-parse --verify no-such-ref", NO_AUTO_MAINTENANCE.join(" ") ); assert!(err.contains(&line), "{err}"); assert!(err.contains("stderr:"), "{err}"); } #[test] fn init_bare_accepts_http_pushes() { let tmp = tempfile::tempdir().unwrap(); let path = init_bare(tmp.path(), "r.git").unwrap(); let value = GitClient::new(tmp.path()) .run(&path, ["config", "http.receivepack"]) .unwrap(); assert_eq!(value.trim(), "true"); } /// A fetch into a repository that already holds several packs starts /// no `git maintenance` (#72, #207). Git decides whether to start it /// from config alone, before looking at the packs, so this holds on /// every Git version; on Git 2.54 and later the maintenance it would /// start repacks these packs. #[test] fn a_fetch_starts_no_automatic_maintenance() { let tmp = tempfile::tempdir().unwrap(); let client = GitClient::new(tmp.path()); let src = tmp.path().join("src"); let dst = tmp.path().join("dst"); for repo in [&src, &dst] { run(client .command(tmp.path()) .args(["init", "--quiet", "-b", "main"]) .arg(repo)) .unwrap(); } let trace = tmp.path().join("trace2"); for i in 0..3 { std::fs::write(src.join("f"), format!("{i}\n")).unwrap(); client.run(&src, ["add", "f"]).unwrap(); client.run(&src, ["commit", "--quiet", "-m", "c"]).unwrap(); // `fetch.unpackLimit=1` keeps every fetched pack as a pack. run(client .command(&dst) .env("GIT_TRACE2", &trace) .args(["-c", "fetch.unpackLimit=1", "fetch", "--quiet"]) .arg(&src) .arg("main:refs/remotes/origin/main")) .unwrap(); } let trace = std::fs::read_to_string(&trace).unwrap(); assert!( trace.contains(" fetch "), "the trace saw the fetches:\n{trace}" ); assert!( !trace.contains("maintenance run") && !trace.contains("gc --auto"), "a fetch started automatic maintenance:\n{trace}" ); let packs = std::fs::read_dir(dst.join(".git/objects/pack")) .unwrap() .filter(|e| e.as_ref().unwrap().path().extension() == Some(OsStr::new("pack"))) .count(); assert_eq!( packs, 3, "each fetch kept its pack, and nothing repacked them" ); } #[test] fn global_config_is_ignored() { let tmp = tempfile::tempdir().unwrap(); std::fs::write( tmp.path().join(".gitconfig"), "[user]\n\tname = Someone Else\n", ) .unwrap(); let name = GitClient::new(tmp.path()) .output(tmp.path(), ["config", "user.name"]) .unwrap(); assert!(!name.status.success(), "global config leaked in"); } }