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:
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"]);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user