fix(dev): isolate Git state in pre-push tests

This commit is contained in:
Éverton Toffanetto
2026-09-23 19:36:48 -03:00
parent 24ae5dd0b8
commit 38c267d9b4
3 changed files with 764 additions and 7 deletions
+6
View File
@@ -81,6 +81,12 @@ Bash on Windows). It appends or updates only ai-memory's managed block in
`.git/hooks/pre-push`, preserving any existing hook body. Bypass it on a
work-in-progress branch with `git push --no-verify`.
The managed test block clears Git's repository environment and disables global
and system Git configuration for Cargo and its children. Fixture commands can
then use their own repositories without inheriting the checkout being pushed.
The publishing Git process and other hook code retain their configuration.
Run the installer again to update an existing installation.
Integration tests live in `tests/suite/` per crate and compile into the
crate's own test harness (declare a new file with `mod name;` in
`tests/suite/mod.rs`); only the CLI keeps a separate test binary, because its
@@ -1144,6 +1144,740 @@ fn macos_docs_use_valid_install_commands_and_release_body_points_to_them() {
assert!(release.contains("follow the bundled docs/macos.md"));
}
// ---------------------------------------------------------------------------
// The generated pre-push hook and the Git environment it hands to Cargo.
//
// Git exports the repository-local environment to every hook it runs. The test
// suite builds throwaway Git repositories as fixtures, and a fixture that
// inherits GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE runs against the checkout
// being pushed instead of its own temp directory. These tests pin the scrub.
//
// `cargo` here is a fake -- an exported Bash function that records its argv and
// the environment it was given and returns a chosen code. It is an observation
// boundary only: nothing below runs the real test suite. Git's own repository
// and config resolution is never mocked: both fixture repositories are real and
// every probe is a read-only `git` command.
//
// Unix and Windows both run these; on Windows via Git for Windows' bash.exe.
// Every give-up path goes through `skip_pre_push_tests`.
// ---------------------------------------------------------------------------
/// Every external command the installer, the generated hook, and the probes
/// below actually run. Shell builtins (`printf`, `echo`, `command`) are not
/// here; neither is `cargo`, which is an exported function, nor `rm`, which
/// only the installer's failure trap needs and these tests never reach.
#[cfg(any(unix, windows))]
const FIXTURE_TOOLS: &[&str] = &[
"awk", "bash", "cat", "chmod", "env", "git", "grep", "mktemp", "mv", "sort", "uname",
];
/// Give up on the pre-push tests, or refuse to.
///
/// A contributor whose machine lacks Git Bash or a fixture tool gets a skip,
/// matching the convention in `install_hooks`' shell-contract test. On CI that
/// same skip would turn a missing prerequisite into a green Windows job that
/// proved nothing, so there it is a failure with the concrete reason.
#[cfg(any(unix, windows))]
fn skip_pre_push_tests<T>(reason: &str) -> Option<T> {
let on_ci = ["GITHUB_ACTIONS", "CI"].iter().any(|key| {
std::env::var_os(key).is_some_and(|value| !value.is_empty() && value != "false")
});
assert!(
!on_ci,
"the pre-push hook regressions must run on CI, but {reason}"
);
eprintln!("skipping pre-push hook tests: {reason}");
None
}
/// Resolve `tool` against a `PATH`-shaped value, trying the bare name and the
/// `.exe` Windows carries.
#[cfg(any(unix, windows))]
fn resolve_in_path(path: &std::ffi::OsStr, tool: &str) -> Option<PathBuf> {
std::env::split_paths(path).find_map(|dir| {
let bare = dir.join(tool);
if bare.is_file() {
return Some(bare);
}
let exe = dir.join(format!("{tool}.exe"));
exe.is_file().then_some(exe)
})
}
/// macOS hands back `/var/folders/...` while Git reports `/private/var/...`.
/// Windows canonicalization yields a `\\?\` verbatim path that Git Bash cannot
/// use, so leave it alone there.
#[cfg(unix)]
fn sandbox_root(path: PathBuf) -> PathBuf {
std::fs::canonicalize(path).unwrap()
}
#[cfg(windows)]
fn sandbox_root(path: PathBuf) -> PathBuf {
path
}
/// The Git for Windows installation root, derived from wherever `bash.exe`
/// lives. Mirrors the discovery in `install_hooks`' shell-contract test, plus
/// the root implied by a `git.exe` already on `PATH`.
#[cfg(windows)]
fn git_for_windows_root() -> Option<PathBuf> {
let mut candidates = Vec::new();
if let Some(root) = std::env::var_os("EXEPATH") {
candidates.push(PathBuf::from(root));
}
for env_key in ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] {
if let Some(root) = std::env::var_os(env_key) {
candidates.push(PathBuf::from(root).join("Git"));
}
}
// `<root>/cmd/git.exe` and `<root>/mingw64/bin/git.exe` both sit under the
// installation root; walk up to it.
for dir in std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) {
if !dir.join("git.exe").is_file() {
continue;
}
let mut up = dir.clone();
for _ in 0..2 {
if !up.pop() {
break;
}
candidates.push(up.clone());
}
}
candidates.sort();
candidates.dedup();
candidates
.into_iter()
.find(|root| {
root.join("usr").join("bin").join("bash.exe").is_file()
|| root.join("bin").join("bash.exe").is_file()
})
.or_else(|| skip_pre_push_tests("Git for Windows bash.exe was not found"))
}
/// The `PATH` the fixture hands the installer and the hook.
///
/// It must hold every tool those scripts run and must not hold `cargo-nextest`,
/// which the hook branches on. `cargo` is an exported Bash function, so it
/// never needs to be on `PATH` at all.
///
/// Unix: symlink each tool into the fixture's own `bin`. Dropping inherited
/// `PATH` directories instead would cost a contributor with `cargo-nextest`
/// next to `git` or `bash` those unrelated commands.
#[cfg(unix)]
fn fixture_path(bin: &Path) -> Option<std::ffi::OsString> {
let dirs: Vec<PathBuf> =
std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect();
for tool in FIXTURE_TOOLS {
let Some(real) = dirs
.iter()
.map(|dir| dir.join(tool))
.find(|candidate| candidate.is_file())
else {
return skip_pre_push_tests(&format!("{tool} was not found on PATH"));
};
std::os::unix::fs::symlink(real, bin.join(tool)).unwrap();
}
Some(bin.as_os_str().to_owned())
}
/// Windows: NTFS has no mode bits and MSYS will not treat a plain fixture file
/// as executable, so a curated `bin` of shims is not available. Use the Git for
/// Windows tool directories instead -- they hold every tool in
/// [`FIXTURE_TOOLS`], and `cargo-nextest` (installed under `~/.cargo/bin`) is
/// excluded by construction rather than by filtering the inherited `PATH`.
#[cfg(windows)]
fn fixture_path(_bin: &Path) -> Option<std::ffi::OsString> {
let root = git_for_windows_root()?;
let dirs: Vec<PathBuf> = [
Path::new("usr").join("bin"),
Path::new("mingw64").join("bin"),
Path::new("mingw32").join("bin"),
PathBuf::from("bin"),
PathBuf::from("cmd"),
]
.iter()
.map(|relative| root.join(relative))
.filter(|dir| dir.is_dir())
.collect();
Some(std::env::join_paths(dirs).expect("Git for Windows paths must not contain ';'"))
}
#[cfg(any(unix, windows))]
fn log_field<'a>(log: &'a str, key: &str) -> &'a str {
let prefix = format!("{key}:");
log.lines()
.find_map(|line| line.strip_prefix(prefix.as_str()))
.unwrap_or_else(|| panic!("no `{key}:` line in:\n{log}"))
}
#[cfg(any(unix, windows))]
fn log_section<'a>(log: &'a str, name: &str) -> &'a str {
let begin = format!("{name}-begin\n");
let end = format!("{name}-end\n");
let start = log
.find(begin.as_str())
.unwrap_or_else(|| panic!("no `{name}-begin` in:\n{log}"))
+ begin.len();
let rest = &log[start..];
let stop = rest
.find(end.as_str())
.unwrap_or_else(|| panic!("no `{name}-end` in:\n{log}"));
&rest[..stop]
}
/// A retained sandbox for the generated pre-push hook.
///
/// `hook_repo` stands in for the checkout being pushed; `fixture_repo` for the
/// throwaway repository a test would build. Both are real Git repositories in
/// the same temp root, so even the pre-fix hook cannot reach this checkout.
#[cfg(any(unix, windows))]
struct PrePushFixture {
hook_repo: PathBuf,
fixture_repo: PathBuf,
home: PathBuf,
hook: PathBuf,
runner: PathBuf,
cargo_log: PathBuf,
caller_log: PathBuf,
global_config: PathBuf,
system_config: PathBuf,
path: std::ffi::OsString,
bash: PathBuf,
git: PathBuf,
/// `Some` under Git Bash, where scripts need MSYS paths. `None` on Unix.
cygpath: Option<PathBuf>,
hook_toplevel: String,
fixture_toplevel: String,
}
#[cfg(any(unix, windows))]
impl PrePushFixture {
fn new() -> Option<Self> {
// `keep()`: the sandbox is the evidence behind a failure, so it outlives
// the test rather than being deleted on drop.
let root = sandbox_root(tempfile::tempdir().unwrap().keep());
assert!(
!root.starts_with(repo_root()),
"the sandbox must live outside this checkout, got {}",
root.display()
);
// Every fixture path carries an apostrophe and a space, so a path
// interpolated into a shell literal anywhere below would break.
let work = root.join("it's a sandbox");
let home = work.join("home");
let bin = work.join("bin");
let hook_repo = work.join("hook repo");
let fixture_repo = work.join("fixture repo");
for dir in [&home, &bin, &hook_repo, &fixture_repo] {
std::fs::create_dir_all(dir).unwrap();
}
std::fs::create_dir_all(home.join(".config")).unwrap();
// Synthetic machine config. The caller must keep seeing these; Cargo
// must not.
let global_config = work.join("global.gitconfig");
let system_config = work.join("system.gitconfig");
std::fs::write(&global_config, "[fixture]\n\tglobal = from-global-config\n").unwrap();
std::fs::write(&system_config, "[fixture]\n\tsystem = from-system-config\n").unwrap();
let path = fixture_path(&bin)?;
for tool in FIXTURE_TOOLS {
if resolve_in_path(&path, tool).is_none() {
return skip_pre_push_tests(&format!(
"{tool} is missing from the fixture PATH ({})",
path.to_string_lossy()
));
}
}
if let Some(found) = resolve_in_path(&path, "cargo-nextest") {
return skip_pre_push_tests(&format!(
"cargo-nextest is reachable at {}, so the fallback branch is not deterministic",
found.display()
));
}
let cygpath = resolve_in_path(&path, "cygpath");
if cfg!(windows) && cygpath.is_none() {
return skip_pre_push_tests("cygpath is missing from the Git for Windows PATH");
}
let mut fixture = Self {
bash: resolve_in_path(&path, "bash").expect("checked above"),
git: resolve_in_path(&path, "git").expect("checked above"),
hook: hook_repo.join(".git").join("hooks").join("pre-push"),
runner: work.join("runner.sh"),
cargo_log: work.join("cargo.log"),
caller_log: work.join("caller.log"),
cygpath,
path,
hook_repo,
fixture_repo,
home,
global_config,
system_config,
hook_toplevel: String::new(),
fixture_toplevel: String::new(),
};
fixture.git(&fixture.hook_repo, &["init", "-q"]);
fixture.git(&fixture.fixture_repo, &["init", "-q"]);
// Compare Git's own answers rather than Rust path strings: Git Bash
// reports `C:/...` where Rust reports `C:\...`.
fixture.hook_toplevel = fixture.git(&fixture.hook_repo, &["rev-parse", "--show-toplevel"]);
fixture.fixture_toplevel =
fixture.git(&fixture.fixture_repo, &["rev-parse", "--show-toplevel"]);
Some(fixture)
}
/// Drop the ambient Git environment and point HOME and the global/system
/// config at the sandbox.
fn isolate(&self, command: &mut Command) {
for (key, _) in std::env::vars_os() {
if key.to_string_lossy().starts_with("GIT_") {
command.env_remove(&key);
}
}
command
.env("PATH", &self.path)
.env("HOME", &self.home)
.env("USERPROFILE", &self.home)
.env("XDG_CONFIG_HOME", self.home.join(".config"))
.env("GIT_CONFIG_GLOBAL", &self.global_config)
.env("GIT_CONFIG_SYSTEM", &self.system_config);
}
/// The paths the generated scripts read. They go through the environment so
/// no path is ever interpolated into a shell literal.
fn shell_env(&self, command: &mut Command) {
command
.env("AI_MEMORY_FIXTURE_REPO", self.shell_arg(&self.fixture_repo))
.env(
"AI_MEMORY_FIXTURE_CARGO_LOG",
self.shell_arg(&self.cargo_log),
)
.env(
"AI_MEMORY_FIXTURE_CALLER_LOG",
self.shell_arg(&self.caller_log),
)
.env("AI_MEMORY_FIXTURE_HOOK", self.shell_arg(&self.hook));
}
/// A path as the fixture's shell sees it: unchanged on Unix, MSYS-converted
/// by `cygpath` under Git Bash.
fn shell_arg(&self, path: &Path) -> String {
let Some(cygpath) = &self.cygpath else {
return path.display().to_string();
};
let output = Command::new(cygpath).arg("-u").arg(path).output().unwrap();
assert!(
output.status.success(),
"cygpath -u {} failed: {}",
path.display(),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_string()
}
fn git(&self, dir: &Path, args: &[&str]) -> String {
let mut command = Command::new(&self.git);
self.isolate(&mut command);
let output = command.args(args).current_dir(dir).output().unwrap();
assert!(
output.status.success(),
"git {args:?} in {} failed: {}",
dir.display(),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_string()
}
/// A read-only probe block, reused by the fake Cargo and by the user hook.
fn probe(&self, name: &str) -> String {
let log = if name == "cargo" {
"AI_MEMORY_FIXTURE_CARGO_LOG"
} else {
"AI_MEMORY_FIXTURE_CALLER_LOG"
};
format!(
r#"{{
printf '{name}-begin\n'
env | grep '^GIT_' | LC_ALL=C sort
printf '{name}-end\n'
printf '{name}-ifs:[%s]\n' "$IFS"
printf '{name}-toplevel:%s\n' "$(git -C "$AI_MEMORY_FIXTURE_REPO" rev-parse --show-toplevel 2>&1)"
printf '{name}-injected:%s\n' "$(git -C "$AI_MEMORY_FIXTURE_REPO" config --get fixture.injected 2>&1)"
printf '{name}-counted:%s\n' "$(git -C "$AI_MEMORY_FIXTURE_REPO" config --get fixture.counted 2>&1)"
printf '{name}-global:%s\n' "$(git -C "$AI_MEMORY_FIXTURE_REPO" config --get fixture.global 2>&1)"
printf '{name}-system:%s\n' "$(git -C "$AI_MEMORY_FIXTURE_REPO" config --get fixture.system 2>&1)"
}} >> "${log}" 2>&1
"#
)
}
/// The runner that stands in for Cargo and then execs the installed hook.
///
/// Both fakes are exported Bash functions: a function needs no execute bit,
/// which NTFS cannot give a fixture file under Git Bash, and it lets the
/// hook's `command -v cargo-nextest` probe answer what the test asked for.
fn write_runner(&self, exit_code: i32, with_nextest: bool) {
let mut runner = format!(
"#!/usr/bin/env bash\n\
cargo() {{\n\
\x20 printf 'cargo-argv:%s\\n' \"$*\" >> \"$AI_MEMORY_FIXTURE_CARGO_LOG\"\n\
{probe}\
\x20 return {exit_code}\n\
}}\n\
export -f cargo\n",
probe = self.probe("cargo"),
);
if with_nextest {
runner.push_str("cargo-nextest() { return 0; }\nexport -f cargo-nextest\n");
}
runner.push_str("exec bash \"$AI_MEMORY_FIXTURE_HOOK\"\n");
std::fs::write(&self.runner, runner).unwrap();
}
/// Seed a user hook whose own content precedes the managed block.
fn write_user_hook(&self, prelude: &str) {
std::fs::write(
&self.hook,
format!(
"#!/usr/bin/env bash\n# A user hook that predates the managed block.\n{prelude}{}",
self.probe("before"),
),
)
.unwrap();
}
/// Append the user's trailing content after the managed block's end marker.
fn append_after_managed_block(&self) {
let installed = std::fs::read_to_string(&self.hook).unwrap();
assert!(
installed
.trim_end()
.ends_with("# <<< ai-memory pre-push <<<"),
"the managed block should close the installed hook:\n{installed}"
);
std::fs::write(&self.hook, format!("{installed}{}", self.probe("after"))).unwrap();
}
fn install_hook(&self) {
let mut command = Command::new(&self.bash);
command.arg(self.shell_arg(&repo_root().join("scripts/install-git-hooks.sh")));
self.isolate(&mut command);
let output = command.current_dir(&self.hook_repo).output().unwrap();
assert!(
output.status.success(),
"installer failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
/// Run the installed hook with exactly the repository-local environment Git
/// exports to a pre-push hook. Every injected path is inside the sandbox.
fn run_hook(&self) -> std::process::Output {
let mut command = Command::new(&self.bash);
command.arg(self.shell_arg(&self.runner));
self.isolate(&mut command);
self.shell_env(&mut command);
command
.current_dir(&self.hook_repo)
.env("GIT_DIR", self.hook_repo.join(".git"))
.env("GIT_COMMON_DIR", self.hook_repo.join(".git"))
.env("GIT_WORK_TREE", &self.hook_repo)
.env("GIT_INDEX_FILE", self.hook_repo.join(".git").join("index"))
.env("GIT_PREFIX", "")
.env(
"GIT_CONFIG_PARAMETERS",
"'fixture.injected=from-parameters'",
)
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "fixture.counted")
.env("GIT_CONFIG_VALUE_0", "from-count");
command.output().unwrap()
}
fn cargo_log(&self) -> String {
std::fs::read_to_string(&self.cargo_log).unwrap_or_default()
}
fn caller_log(&self) -> String {
std::fs::read_to_string(&self.caller_log).unwrap_or_default()
}
}
#[test]
#[cfg(any(unix, windows))]
fn pre_push_hook_scrubs_the_repository_git_environment_before_cargo() {
let Some(fixture) = PrePushFixture::new() else {
return;
};
fixture.write_runner(0, false);
fixture.install_hook();
let output = fixture.run_hook();
assert!(
output.status.success(),
"hook failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let log = fixture.cargo_log();
let git_env = log_section(&log, "cargo");
for leaked in [
"GIT_DIR=",
"GIT_COMMON_DIR=",
"GIT_WORK_TREE=",
"GIT_INDEX_FILE=",
"GIT_CONFIG_PARAMETERS=",
"GIT_CONFIG_COUNT=",
"GIT_PREFIX=",
] {
assert!(
!git_env.contains(leaked),
"{leaked} reached cargo; a fixture repo would act on the pushed checkout:\n{git_env}"
);
}
for expected in [
"GIT_CONFIG_NOSYSTEM=1",
"GIT_CONFIG_GLOBAL=/dev/null",
"GIT_CONFIG_SYSTEM=/dev/null",
] {
assert!(
git_env.contains(expected),
"cargo should run with {expected}:\n{git_env}"
);
}
// The decisive check: a child `git` aimed at the fixture repository must
// land there, not in the repository whose hook is running.
assert_eq!(
log_field(&log, "cargo-toplevel"),
fixture.fixture_toplevel,
"child git resolved the wrong repository:\n{log}"
);
for (key, source) in [
("cargo-injected", "GIT_CONFIG_PARAMETERS"),
("cargo-counted", "GIT_CONFIG_COUNT"),
("cargo-global", "the global config"),
("cargo-system", "the system config"),
] {
assert_eq!(
log_field(&log, key),
"",
"{source} still reached the fixture repository:\n{log}"
);
}
}
#[test]
#[cfg(any(unix, windows))]
fn pre_push_hook_leaves_the_user_hook_environment_intact_around_the_block() {
let Some(fixture) = PrePushFixture::new() else {
return;
};
fixture.write_runner(0, false);
fixture.write_user_hook("");
fixture.install_hook();
fixture.append_after_managed_block();
let output = fixture.run_hook();
assert!(
output.status.success(),
"hook failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let log = fixture.caller_log();
for stage in ["before", "after"] {
let git_env = log_section(&log, stage);
for kept in ["GIT_DIR=", "GIT_INDEX_FILE=", "GIT_CONFIG_COUNT=1"] {
assert!(
git_env.contains(kept),
"the scrub escaped the managed block and took {kept} from the {stage} content:\n{git_env}"
);
}
// The scrub's own settings must not leak outward either: the caller
// keeps its synthetic global/system config and never sees NOSYSTEM.
assert!(
!git_env.contains("GIT_CONFIG_GLOBAL=/dev/null")
&& !git_env.contains("GIT_CONFIG_SYSTEM=/dev/null"),
"the {stage} content must keep the caller's own config paths:\n{git_env}"
);
assert!(
!git_env.contains("GIT_CONFIG_NOSYSTEM"),
"the {stage} content must not inherit the scrub's GIT_CONFIG_NOSYSTEM:\n{git_env}"
);
// The caller's Git still resolves through the inherited GIT_DIR, which
// points at the hook's repository rather than the fixture repository
// the command names.
assert_eq!(
log_field(&log, &format!("{stage}-toplevel")),
fixture.hook_toplevel,
"the {stage} content lost the repository Git gave it:\n{log}"
);
for (key, expected, source) in [
("global", "from-global-config", "the global config"),
("system", "from-system-config", "the system config"),
("injected", "from-parameters", "GIT_CONFIG_PARAMETERS"),
("counted", "from-count", "GIT_CONFIG_COUNT"),
] {
assert_eq!(
log_field(&log, &format!("{stage}-{key}")),
expected,
"the {stage} content lost {source}:\n{log}"
);
}
}
}
/// Reinstalling must keep the user's own content and leave exactly one managed
/// block.
///
/// It asserts nothing about where that content ends up relative to the block:
/// the installer strips the old block and appends the new one at the end, so a
/// body that used to follow the block now precedes it. That reordering predates
/// this change and is deliberately left alone here.
#[test]
#[cfg(any(unix, windows))]
fn pre_push_hook_reinstall_keeps_user_content_and_one_managed_block() {
let Some(fixture) = PrePushFixture::new() else {
return;
};
fixture.write_runner(0, false);
fixture.write_user_hook("");
fixture.install_hook();
fixture.append_after_managed_block();
fixture.install_hook();
let installed = std::fs::read_to_string(&fixture.hook).unwrap();
assert_eq!(
installed.matches("# >>> ai-memory pre-push >>>").count(),
1,
"reinstalling duplicated the managed block:\n{installed}"
);
assert_eq!(
installed.matches("printf 'before-begin\\n'").count(),
1,
"reinstalling lost or duplicated the user's leading content:\n{installed}"
);
assert_eq!(
installed.matches("printf 'after-begin\\n'").count(),
1,
"reinstalling lost or duplicated the user's trailing content:\n{installed}"
);
let output = fixture.run_hook();
assert!(
output.status.success(),
"reinstalled hook failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
log_field(&fixture.cargo_log(), "cargo-toplevel"),
fixture.fixture_toplevel,
"the scrub did not survive a reinstall"
);
}
/// A user hook that narrowed `IFS` before the managed block used to break the
/// split of `git rev-parse --local-env-vars`, leaving the whole newline-joined
/// list as one word.
#[test]
#[cfg(any(unix, windows))]
fn pre_push_hook_splits_the_local_env_var_list_under_a_narrowed_ifs() {
let Some(fixture) = PrePushFixture::new() else {
return;
};
fixture.write_runner(0, false);
fixture.write_user_hook("IFS=:\n");
fixture.install_hook();
fixture.append_after_managed_block();
let output = fixture.run_hook();
assert!(
output.status.success(),
"the hook did not survive a narrowed IFS: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
log_field(&fixture.cargo_log(), "cargo-toplevel"),
fixture.fixture_toplevel,
"the scrub did not survive a narrowed IFS"
);
let caller = fixture.caller_log();
for stage in ["before", "after"] {
assert_eq!(
log_field(&caller, &format!("{stage}-ifs")),
"[:]",
"the managed block changed the user hook's IFS:\n{caller}"
);
}
}
#[test]
#[cfg(any(unix, windows))]
fn pre_push_hook_keeps_the_full_tier_and_propagates_a_cargo_failure() {
for (with_nextest, expected_argv, expected_notice) in [
(
true,
"nextest run --workspace -P full",
"pre-push: cargo nextest run --workspace -P full",
),
(
false,
"test --workspace --all-targets",
"pre-push: cargo test --workspace --all-targets (nextest not installed)",
),
] {
let Some(fixture) = PrePushFixture::new() else {
return;
};
fixture.write_runner(0, with_nextest);
fixture.install_hook();
let output = fixture.run_hook();
assert!(
output.status.success(),
"hook failed with {expected_argv:?}: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
log_field(&fixture.cargo_log(), "cargo-argv"),
expected_argv,
"wrong cargo invocation for nextest={with_nextest}"
);
assert!(
String::from_utf8_lossy(&output.stdout).contains(expected_notice),
"the hook stopped announcing what it runs: {}",
String::from_utf8_lossy(&output.stdout)
);
}
// A failing test run must still stop the push.
let Some(fixture) = PrePushFixture::new() else {
return;
};
fixture.write_runner(3, true);
fixture.install_hook();
let output = fixture.run_hook();
assert_eq!(
output.status.code(),
Some(3),
"the subshell swallowed cargo's exit status: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
/// The slow tier. These drive the real wrapper scripts, a fake `uname`, and
/// fake container engines, 10-20s each, and cannot break from an ordinary Rust
/// edit. `.config/nextest.toml` skips every `slow::` module in the everyday
+24 -7
View File
@@ -43,13 +43,30 @@ if [ "$(uname -s 2>/dev/null || true)" = "Darwin" ] && [ -z "${SSL_CERT_FILE:-}"
export SSL_CERT_FILE=/etc/ssl/cert.pem
fi
if command -v cargo-nextest >/dev/null 2>&1; then
echo "pre-push: cargo nextest run --workspace -P full"
cargo nextest run --workspace -P full
else
echo "pre-push: cargo test --workspace --all-targets (nextest not installed)"
cargo test --workspace --all-targets
fi
# Git's hook environment would redirect fixture commands into this checkout.
# Isolate Cargo and its children so other hook code keeps its Git context.
(
# A plain assignment, so `set -e` still aborts if `git rev-parse` fails.
git_local_env_vars=$(git rev-parse --local-env-vars)
# A surrounding user hook may have narrowed IFS; the list below is split on
# newlines, so restore the default before splitting it.
IFS=$' \t\n'
# Unquoted on purpose: the output is one variable name per line.
for git_local_env_var in $git_local_env_vars; do
unset "$git_local_env_var"
done
# Fixture repositories must not inherit machine settings such as signing.
# Git for Windows maps /dev/null to nul.
export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null
if command -v cargo-nextest >/dev/null 2>&1; then
echo "pre-push: cargo nextest run --workspace -P full"
cargo nextest run --workspace -P full
else
echo "pre-push: cargo test --workspace --all-targets (nextest not installed)"
cargo test --workspace --all-targets
fi
)
# <<< ai-memory pre-push <<<
HOOK