test(testing-utils): detach git fixtures from the contributor's global config (#14715)

* test(testing-utils): detach git fixtures from the contributor's global config

`pipeline_cache::symlinked_inputs_are_hashed_as_link_targets` builds a
project whose tracked input is a symlink named `CLAUDE.md`, and asserts
that retargeting the link invalidates the task. On a machine whose
`core.excludesFile` ignores that name, `git add -A` never stages the link
and the `git ls-files --cached --others --exclude-standard` pnpm runs to
collect cache inputs never lists it. The link is then not part of the
task's cache key, retargeting it changes nothing, and pnpm correctly
reports the hit the test refuses.

The symlink hashing the test covers is right, so the fixture is what
needs fixing. `detach_from_global_config` points `core.excludesFile` at a
path that does not exist, in the repo's own local configuration. Local
configuration also reaches the `git` pnpm itself spawns inside the repo,
so one setting covers the fixture's staging and the listing under test.
The path is inside `.git/info` rather than `/dev/null` so it holds on
Windows too. The `gpgsign` overrides `GitRepoFixture::init` already made
for the same reason move in beside it.

`init_isolated_repo` gives the two suites that call `git init` directly
the same isolation without the work tree and bare clone pair
`GitRepoFixture` exists to provide.

`symlinked_input_project` now checks that the link is staged before any
caching assertion runs, because the failure this produced named neither
git nor the ignore rule that caused it.

Closes pnpm/pnpm#14713

* fix(cargo): gate the non-UTF-8 name test's imports with the test

`import_package`, `CheckoutPackage`, and `BTreeSet` are referenced only
by `a_file_whose_name_is_not_utf8_is_refused`, which is compiled out on
macOS because APFS answers `EILSEQ` for the name it needs. At module
scope they were dead imports there, so `cargo clippy -D warnings` failed
on macOS while passing on Linux.

* test(testing-utils): narrow the fixture isolation contract to what it delivers

`init_isolated_repo` promised that a contributor's global git
configuration could not reach the repo, which overstated three
`git config` overrides. Full isolation is not available here: the point
of writing the overrides into the repo's local configuration is that they
also govern the `git` pnpm spawns inside it, which an environment
variable on the fixture's own commands would miss. So the doc now names
the settings it handles and says that anything else still reaches the
repo, and `detach_from_global_config` becomes `override_global_config` to
match.

`core.hooksPath` joins the overridden set. A contributor who points it at
a hooks directory globally would otherwise have their own hooks, which
are arbitrary code, run on a fixture's commits.

`init_isolated_repo` passes `-b main` rather than leaving the branch name
to `init.defaultBranch`, as `GitRepoFixture::init` already does.

`tracked_files` replaces the hand-rolled `git ls-files` in
`symlinked_input_project`, which read stdout and ignored the exit status.
A failing `git` would have surfaced as an empty listing, blaming the
ignore rule the assertion exists to detect for what was a git error. The
existing `git` helper already asserts the status and carries stderr into
the panic.

* Fix typo in documentation comment for git_repo.rs

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* test(testing-utils): isolate the bare repo and every suite that commits

Running the git-backed suites with `GIT_CONFIG_GLOBAL` pointed at a
hostile configuration (a global excludes file, a `core.hooksPath` whose
hooks all fail, `init.defaultBranch = trunk`, `gpgsign = true`) showed
three gaps the fixture isolation still left open.

`GitRepoFixture` overrode the work tree only. `git push` into the bare
repo runs `pre-receive` and `update` from that repo's own
`core.hooksPath`, so a contributor's global hooks rejected every mirror.
The bare repo now gets the same overrides, and `override_global_config`
takes the git directory so the placeholder path lands inside it for
both repo shapes. The bare init also passes `-b main`: without it,
`HEAD` inherits `init.defaultBranch` and points at a branch the mirror
never pushes.

`pipeline_cargo_cache` and `exec_recursive` also `git init` their own
repos and commit into them, and both failed on the global `pre-commit`
hook. They now go through `init_isolated_repo`, which makes their
per-command `-c user.name`, `-c commit.gpgsign=false`, and
`--no-gpg-sign` overrides redundant.

`tracked_files` listed tracked and untracked-but-not-ignored files, so
it is now `unignored_files`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPJkFm8WwhDuQpvC5CAmBo

* test(testing-utils): keep global templates and attributes out of fixtures

Two more user-global settings reached the fixture repos.

`init.templateDir` seeds every new repo's `info/exclude`, and no
configuration setting overrides that file, so a template that ignores a
fixture name hid it from `git add -A` and from the listing pnpm hashes
exactly as the global excludes file did. Every fixture `git init` now
passes `--template=`, which skips the template directory entirely.

`core.attributesFile` can assign a `clean` filter to a fixture's files,
and git runs that filter, the contributor's own command, on `git add`.
The override now points it at the same absent path as the excludes file
and hooks directory.

Verified under a global config carrying a template whose `info/exclude`
lists `CLAUDE.md` and a required `*.md` clean filter that exits 1: the
seven git-backed suites and the `cargo_deps::git` unit tests pass, and
a repo initialised the old way loses the file and fails the add.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPJkFm8WwhDuQpvC5CAmBo

* test(testing-utils): keep a global fsmonitor out of fixtures

A user-global `core.fsmonitor` names a command git consults whenever it
refreshes the index, so `git add` and commit in a fixture repo ran the
contributor's own program. The fixture override now sets it to `false`
locally, beside the hooks and attributes overrides that close the same
class of setting.

Verified with a global `core.fsmonitor` that prints and exits 1: a repo
initialised the old way runs it on `git add`, the fixture does not, and
the seven git-backed suites and `cargo_deps::git` unit tests pass under
the full hostile configuration.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPJkFm8WwhDuQpvC5CAmBo

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-09-09 18:44:13 +00:00
1 parent 21e6009756
commit 4ca0c170b5
4 files changed
+90 -28

No files matched your search

@@ -270,14 +270,12 @@ fn recursive_exec_diff_selector_selects_changed_projects() {
String::from_utf8_lossy(&output.stderr),
);
};
git(&["init", "--initial-branch=main"]);
git(&["config", "user.email", "x@y.z"]);
git(&["config", "user.name", "xyz"]);
pnpm_testing_utils::git_repo::init_isolated_repo(&workspace);
git(&["add", "."]);
git(&["commit", "-m", "base", "--no-gpg-sign"]);
git(&["commit", "-m", "base"]);
fs::write(workspace.join("project-1").join("changed.js"), "").expect("write changed file");
git(&["add", "."]);
git(&["commit", "-m", "change project-1", "--no-gpg-sign"]);
git(&["commit", "-m", "change project-1"]);
pacquet
.with_arg("-r")
@@ -5,7 +5,7 @@ use std::{fs, process::Command};
#[test]
fn configured_environment_changes_invalidate_task_outputs() {
let project = tempfile::tempdir().unwrap();
assert!(Command::new("git").arg("init").arg(project.path()).output().unwrap().status.success());
pnpm_testing_utils::git_repo::init_isolated_repo(project.path());
fs::create_dir(project.path().join("src")).unwrap();
fs::write(project.path().join("src/input"), "source").unwrap();
fs::write(project.path().join(".gitignore"), "out/\nnode_modules/\nhook-count\n").unwrap();
@@ -310,7 +310,7 @@ fn projects_rooted_in_submodules_bypass_task_caching() {
fn symlinked_input_project(project: &std::path::Path, task_settings: &str) {
use std::os::unix::fs::symlink;
assert!(Command::new("git").arg("init").arg(project).output().unwrap().status.success());
pnpm_testing_utils::git_repo::init_isolated_repo(project);
fs::write(project.join("AGENTS.md"), "shared text").unwrap();
fs::write(project.join("NOTES.md"), "shared text").unwrap();
symlink("AGENTS.md", project.join("CLAUDE.md")).unwrap();
@@ -329,6 +329,12 @@ fn symlinked_input_project(project: &std::path::Path, task_settings: &str) {
)
.unwrap();
Command::new("git").current_dir(project).args(["add", "-A"]).assert().success();
let inputs = pnpm_testing_utils::git_repo::unignored_files(project);
assert!(
inputs.iter().any(|path| path == "CLAUDE.md"),
"the link the task reads must be one of the files pnpm hashes, or nothing below tests \
what it claims to; git reported: {inputs:?}",
);
}
#[cfg(unix)]
@@ -49,7 +49,7 @@ fn cargo_state_is_shared_between_worktrees_and_survives_cache_deletion() {
let second_worktree = root.join("b");
let cache = temp.path().join("cache");
fs::create_dir_all(first_worktree.join("src")).unwrap();
git(&first_worktree, &["init"]);
pnpm_testing_utils::git_repo::init_isolated_repo(&first_worktree);
fs::write(first_worktree.join(".gitignore"), "target/\nnode_modules/\n").unwrap();
fs::write(
first_worktree.join("Cargo.toml"),
@@ -81,20 +81,7 @@ fn cargo_state_is_shared_between_worktrees_and_survives_cache_deletion() {
);
pnpm(&first_worktree, &cache, &["install"]);
git(&first_worktree, &["add", "."]);
git(
&first_worktree,
&[
"-c",
"user.name=Fixture",
"-c",
"user.email=fixture@example.com",
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"fixture",
],
);
git(&first_worktree, &["commit", "-m", "fixture"]);
git(&first_worktree, &["worktree", "add", "--detach", second_worktree.to_str().unwrap()]);
let first = pnpm(&first_worktree, &cache, &["pipeline", "--full"]);
+77 -6
View File
@@ -41,14 +41,12 @@ impl GitRepoFixture {
fs::create_dir_all(&work).expect("create git work tree");
fs::create_dir_all(&bare).expect("create bare repo directory");
git(&bare, &["init", "-q", "--bare"]);
git(&work, &["init", "-q", "-b", "main"]);
git(&bare, &["init", "-q", "--bare", "-b", "main", "--template="]);
override_global_config(&bare, &bare);
git(&work, &["init", "-q", "-b", "main", "--template="]);
git(&work, &["config", "user.email", "test@example.invalid"]);
git(&work, &["config", "user.name", "Test"]);
// Neutralise a user-global `gpgsign = true`, which would
// otherwise demand a real signing key for every commit and tag.
git(&work, &["config", "commit.gpgsign", "false"]);
git(&work, &["config", "tag.gpgsign", "false"]);
override_global_config(&work, &work.join(".git"));
git(&work, &["remote", "add", "origin", &bare.to_string_lossy()]);
Self { work, bare }
@@ -131,6 +129,79 @@ impl GitRepoFixture {
}
}
/// `git init` a repository at `path` on branch `main`, whatever the
/// contributor's `init.defaultBranch`, for a test that needs a repo
/// without the work tree and bare clone [`GitRepoFixture`] pairs up.
///
/// Overrides the user-global `core.excludesFile`, `core.attributesFile`,
/// `core.hooksPath`, `core.fsmonitor`, and `gpgsign` settings and skips
/// the user-global `init.templateDir`, so a contributor's own git
/// configuration cannot change what the repo ignores, what it runs on
/// staging and commit, or whether it demands a signing key.
/// Configuration beyond those still reaches it.
pub fn init_isolated_repo(path: &Path) {
fs::create_dir_all(path).expect("create git repo directory");
git(path, &["init", "-q", "-b", "main", "--template="]);
git(path, &["config", "user.email", "test@example.invalid"]);
git(path, &["config", "user.name", "Test"]);
override_global_config(path, &path.join(".git"));
}
/// The path of every file in `repo` that git does not ignore, tracked or
/// not, as `git ls-files --cached --others --exclude-standard` lists them.
///
/// A test that asserts on pnpm's cache keys can check its own premise
/// with this: pnpm derives a task's inputs from the same listing, so a
/// fixture file missing here is a file the cache key cannot see.
#[must_use]
pub fn unignored_files(repo: &Path) -> Vec<String> {
git(repo, &["ls-files", "--cached", "--others", "--exclude-standard"])
.lines()
.map(str::to_string)
.collect()
}
/// Override, in the local configuration of the repo at `repo` whose git
/// directory is `git_dir`, the user-global settings that would otherwise
/// change what a fixture repo does: `core.excludesFile`,
/// `core.attributesFile`, `core.hooksPath`, `core.fsmonitor`, and
/// `gpgsign`. Configuration this does not name still reaches the repo.
///
/// `git ls-files --exclude-standard` consults the user-global excludes
/// file, and pnpm builds a task's cache inputs from that listing. A
/// contributor who ignores one of a fixture's file names globally would
/// otherwise watch the file drop out of the hashed inputs, and the test
/// asserting that editing it invalidates the task would fail on their
/// machine alone. Local configuration also covers the `git` that pnpm
/// itself spawns inside the repo, not just the fixture's own calls.
///
/// A bare repo needs this too: `git push` runs the receiving side's
/// `pre-receive` and `update` hooks from that repo's `core.hooksPath`.
///
/// The repo must have been created with `git init --template=`, since
/// a user-global `init.templateDir` would otherwise seed `info/exclude`,
/// which no configuration setting overrides.
fn override_global_config(repo: &Path, git_dir: &Path) {
// A path that does not exist: git reads a missing excludes or
// attributes file as empty, and a missing hooks directory as no
// hooks. `/dev/null` would not work on Windows.
let absent = git_dir.join("absent-global-config");
let absent = absent.to_string_lossy();
git(repo, &["config", "core.excludesFile", &absent]);
// User-global attributes can assign a `clean` filter to a fixture's
// files, a user-global `core.hooksPath` its own hooks, and a
// user-global `core.fsmonitor` a command git consults whenever it
// refreshes the index: each runs the contributor's arbitrary code on
// `git add` and commit.
git(repo, &["config", "core.attributesFile", &absent]);
git(repo, &["config", "core.hooksPath", &absent]);
git(repo, &["config", "core.fsmonitor", "false"]);
// Neutralise a user-global `gpgsign = true`, which would
// otherwise demand a real signing key for every commit and tag.
git(repo, &["config", "commit.gpgsign", "false"]);
git(repo, &["config", "tag.gpgsign", "false"]);
}
/// Run `git` with `args` in `cwd` and return its stdout.
///
/// Panics when `git` is missing or the command fails — per