diff --git a/pnpm/crates/cli/tests/suite/exec_recursive.rs b/pnpm/crates/cli/tests/suite/exec_recursive.rs index 3730a6de12..16a44f466d 100644 --- a/pnpm/crates/cli/tests/suite/exec_recursive.rs +++ b/pnpm/crates/cli/tests/suite/exec_recursive.rs @@ -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") diff --git a/pnpm/crates/cli/tests/suite/pipeline_cache.rs b/pnpm/crates/cli/tests/suite/pipeline_cache.rs index 49c477143f..b8e2043fd7 100644 --- a/pnpm/crates/cli/tests/suite/pipeline_cache.rs +++ b/pnpm/crates/cli/tests/suite/pipeline_cache.rs @@ -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)] diff --git a/pnpm/crates/cli/tests/suite/pipeline_cargo_cache.rs b/pnpm/crates/cli/tests/suite/pipeline_cargo_cache.rs index acdd32dcbf..2506ca5064 100644 --- a/pnpm/crates/cli/tests/suite/pipeline_cargo_cache.rs +++ b/pnpm/crates/cli/tests/suite/pipeline_cargo_cache.rs @@ -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"]); diff --git a/pnpm/crates/testing-utils/src/git_repo.rs b/pnpm/crates/testing-utils/src/git_repo.rs index 3e661e10e7..04ce378a98 100644 --- a/pnpm/crates/testing-utils/src/git_repo.rs +++ b/pnpm/crates/testing-utils/src/git_repo.rs @@ -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 { + 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