diff --git a/tests/broadside-repo-collection.test.mjs b/tests/broadside-repo-collection.test.mjs index b047dc1..ed2e817 100644 --- a/tests/broadside-repo-collection.test.mjs +++ b/tests/broadside-repo-collection.test.mjs @@ -12,6 +12,7 @@ // refused before pricing, and the manifests present name candidates // that the source-file counts decide between. +import "./helpers/git-config-isolation.mjs"; import { test } from "node:test"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; @@ -36,11 +37,6 @@ const { statusText, } = await import(pathToFileURL(`${REPO_ROOT}/core/broadside.ts`).href); -// Git fixtures must not inherit the developer's global config (see -// release-cycle notes): a `url.insteadOf` or `commit.gpgsign` breaks them. -process.env.GIT_CONFIG_GLOBAL = join(tmpdir(), "cc-no-such-gitconfig"); -process.env.GIT_CONFIG_SYSTEM = join(tmpdir(), "cc-no-such-gitconfig"); - async function git(dir, ...args) { await execFileAsync("git", ["-C", dir, ...args], { maxBuffer: 16 * 1024 * 1024 }); } diff --git a/tests/broadside.test.mjs b/tests/broadside.test.mjs index 54efc71..0839c91 100644 --- a/tests/broadside.test.mjs +++ b/tests/broadside.test.mjs @@ -3,6 +3,7 @@ // in-memory. The real OpenRouter API is covered by a manual smoke path, not // this suite. +import "./helpers/git-config-isolation.mjs"; import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; @@ -10,18 +11,6 @@ import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -// Git fixtures must not inherit the developer's git configuration. A global -// `url..insteadOf` rewrites what `git remote get-url` reports — which is -// exactly what resolvePublishSourceRepo reads — so a verbatim-URL assertion -// fails on any machine carrying that common setting while staying green on -// CI's bare runners. `commit.gpgsign` and `init.defaultBranch` reach the -// committing fixtures the same way. Point both config layers at a path that -// does not exist: git reads a missing file as empty config. Identity is set -// per fixture in repo-local config, so commits still work. -const ABSENT_GIT_CONFIG = join(tmpdir(), "codecarto-tests-absent-gitconfig"); -process.env.GIT_CONFIG_GLOBAL = ABSENT_GIT_CONFIG; -process.env.GIT_CONFIG_SYSTEM = ABSENT_GIT_CONFIG; - const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const core = await import(pathToFileURL(join(REPO_ROOT, "core/index.ts")).href); @@ -1902,7 +1891,6 @@ test("saveLensResults marks truncated content and writes parsed JSON cleanly", a } }); - // ---------- outcomes reported for non-completed batches ---------- // // Leads from the second live Broad-Side scan of this repository, each verified diff --git a/tests/git-config-isolation.test.mjs b/tests/git-config-isolation.test.mjs new file mode 100644 index 0000000..ba61ad1 --- /dev/null +++ b/tests/git-config-isolation.test.mjs @@ -0,0 +1,276 @@ +// Git-configuration isolation in the test harness (#412, pilot B01). +// +// Seven test files build real Git fixtures and must not inherit the +// developer's Git configuration. The guard they carried pointed +// GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM at a path that does not exist, +// which neutralizes the two FILE sources — but git has a third source, +// injected through GIT_CONFIG_COUNT / GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n, +// whose origin git reports as `command line:`. Redirecting file lookups +// cannot reach it, so a contributor with +// +// url.https://github.com/.insteadOf = git@github.com: +// +// in their environment saw `resolvePublishSourceRepo records origin's fetch +// URL verbatim` fail on a tree that is green on CI's bare runners. +// +// These tests exercise the isolation boundary itself. Every case runs the +// real thing in a FRESH CHILD PROCESS with the rewrite injected into that +// child's environment, because the boundary is about what a process inherits +// at startup: asserting on `process.env` in this process, or letting an +// earlier test's cleanup set the stage, would manufacture a pass. +// +// The injected variables never leave the child. Nothing here reads or writes +// the user's Git configuration. + +import { execFile } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import test from "node:test"; +import assert from "node:assert/strict"; + +const execFileAsync = promisify(execFile); +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +/** + * The isolation helper as a file:// URL. A bare Windows path (`D:\\...`) is + * not a valid ESM specifier, so interpolating REPO_ROOT directly into an + * `import` inside a generated probe fails with ERR_UNSUPPORTED_ESM_URL_SCHEME. + */ +const HELPER_URL = pathToFileURL(join(REPO_ROOT, "tests/helpers/git-config-isolation.mjs")).href; + +/** A path git reads as empty config. Matches the helper's own constant. */ +const ABSENT = join(tmpdir(), "codecarto-tests-absent-gitconfig"); + +/** The rewrite that reproduces #412: SSH-syntax remotes routed over HTTPS. */ +const INJECTED = { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "url.https://github.com/.insteadOf", + GIT_CONFIG_VALUE_0: "git@github.com:", +}; + +/** The seven files that build Git fixtures and carry the guard. */ +const GUARDED_FILES = [ + "tests/broadside-repo-collection.test.mjs", + "tests/broadside.test.mjs", + "tests/library.test.mjs", + "tests/mcp-uncovered-handlers.test.mjs", + "tests/pi-broadside.test.mjs", + "tests/pi-command-handlers.test.mjs", + "tests/pi-publish.test.mjs", +]; + +async function withTempDir(fn) { + const dir = await mkdtemp(join(tmpdir(), "codecarto-gitconfig-")); + try { + return await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** Run a script in a fresh child process with `env` merged over the parent's. */ +async function runChild(source, env) { + return await withTempDir(async (dir) => { + const file = join(dir, "probe.mjs"); + await writeFile(file, source, "utf8"); + const { stdout } = await execFileAsync(process.execPath, ["--experimental-strip-types", "--disable-warning=ExperimentalWarning", file], { + env: { ...process.env, ...env }, + cwd: REPO_ROOT, + }); + return stdout.trim(); + }); +} + +test("the injected rewrite really does reach git — the defect is real", async () => { + // The negative control. Without this, every assertion below could pass + // because the rewrite never worked in the first place. + await withTempDir(async (dir) => { + await execFileAsync("git", ["-C", dir, "init", "--quiet"]); + await execFileAsync("git", ["-C", dir, "remote", "add", "origin", "git@github.com:Acme/Tool.git"]); + const { stdout } = await execFileAsync("git", ["-C", dir, "remote", "get-url", "origin"], { + env: { ...process.env, ...INJECTED, GIT_CONFIG_GLOBAL: "/nonexistent", GIT_CONFIG_SYSTEM: "/nonexistent" }, + }); + assert.equal( + stdout.trim(), + "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/Acme/Tool.git", + "with only the file lookups redirected, the injected rewrite still applies — this is #412", + ); + }); +}); + +test("the helper neutralizes the injected rewrite in a fresh child process", async () => { + const out = await runChild( + `import "${HELPER_URL}"; + import { execFile } from "node:child_process"; + import { mkdtemp } from "node:fs/promises"; + import { tmpdir } from "node:os"; + import { join } from "node:path"; + import { promisify } from "node:util"; + const run = promisify(execFile); + const dir = await mkdtemp(join(tmpdir(), "probe-")); + await run("git", ["-C", dir, "init", "--quiet"]); + await run("git", ["-C", dir, "remote", "add", "origin", "git@github.com:Acme/Tool.git"]); + const { stdout } = await run("git", ["-C", dir, "remote", "get-url", "origin"]); + process.stdout.write(stdout.trim());`, + INJECTED, + ); + assert.equal(out, "git@github.com:Acme/Tool.git", "the SCP-syntax remote is preserved verbatim"); +}); + +test("isolation survives a large injected config set, not just one entry", async () => { + // GIT_CONFIG_COUNT=0 makes every KEY_n/VALUE_n inert without needing to + // know how many were supplied. Deleting them one at a time would not. + const many = { GIT_CONFIG_COUNT: "3" }; + for (const [i, [k, v]] of [ + ["url.https://github.com/.insteadOf", "git@github.com:"], + ["core.autocrlf", "true"], + ["commit.gpgsign", "true"], + ].entries()) { + many[`GIT_CONFIG_KEY_${i}`] = k; + many[`GIT_CONFIG_VALUE_${i}`] = v; + } + const out = await runChild( + `import "${HELPER_URL}"; + import { execFile } from "node:child_process"; + import { promisify } from "node:util"; + const run = promisify(execFile); + const { stdout } = await run("git", ["config", "--list", "--show-origin"]); + process.stdout.write(String(stdout.split("\\n").filter((l) => l.startsWith("command line:")).length));`, + many, + ); + assert.equal(out, "0", "no configuration is still arriving from the command line"); +}); + +test("the helper runs before the modules under test, not merely at module scope", async () => { + // The ordering property the old guard lacked. In ESM every static import + // is evaluated before the importing module's body, so three lines at + // module scope run AFTER the code under test has been imported — and in + // broadside-repo-collection.test.mjs the guard sat after a dynamic + // `await import()` of that very code. No module under test currently + // resolves git configuration while it is being evaluated, so that was a + // latent hazard rather than a live failure — but it is the kind that + // appears silently the first time one does. Here the probe imports the + // isolation first, so the observer must see a clean environment. + const out = await runChild( + `import "${HELPER_URL}"; + import { execFile } from "node:child_process"; + import { promisify } from "node:util"; + const run = promisify(execFile); + // Simulates a module that resolves git config during its own evaluation. + const { stdout } = await run("git", ["config", "--get", "url.https://github.com/.insteadOf"]).catch(() => ({ stdout: "" })); + process.stdout.write(stdout.trim() === "" ? "clean" : "leaked:" + stdout.trim());`, + INJECTED, + ); + assert.equal(out, "clean", "the rewrite is gone before anything else is imported"); +}); + +test("GIT_CONFIG_PARAMETERS is neutralized, not merely counted down", async () => { + // The fourth source, and the one the first version of this fix missed. + // Git uses it to hand `-c key=value` to its OWN subprocesses, so it + // arrives without anyone setting it deliberately — running the suite + // under `git bisect run` is enough. It carries its own entries, so + // GIT_CONFIG_COUNT=0 does not disarm it. + const injected = { GIT_CONFIG_PARAMETERS: "'url.https://github.com/.insteadOf'='git@github.com:'" }; + + // First prove the vector is real: with the other three neutralized but + // this one left alone, the rewrite still reaches a fixture. + await withTempDir(async (dir) => { + await execFileAsync("git", ["-C", dir, "init", "--quiet"]); + await execFileAsync("git", ["-C", dir, "remote", "add", "origin", "git@github.com:Acme/Tool.git"]); + const { stdout } = await execFileAsync("git", ["-C", dir, "remote", "get-url", "origin"], { + env: { ...process.env, ...injected, GIT_CONFIG_GLOBAL: ABSENT, GIT_CONFIG_SYSTEM: ABSENT, GIT_CONFIG_COUNT: "0" }, + }); + assert.equal(stdout.trim(), "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/Acme/Tool.git", "the vector is real: COUNT=0 does not disarm it"); + }); + + // Then prove the helper closes it. + const out = await runChild( + `import "${HELPER_URL}"; + import { execFile } from "node:child_process"; + import { mkdtemp } from "node:fs/promises"; + import { tmpdir } from "node:os"; + import { join } from "node:path"; + import { promisify } from "node:util"; + const run = promisify(execFile); + const dir = await mkdtemp(join(tmpdir(), "probe-")); + await run("git", ["-C", dir, "init", "--quiet"]); + await run("git", ["-C", dir, "remote", "add", "origin", "git@github.com:Acme/Tool.git"]); + const { stdout } = await run("git", ["-C", dir, "remote", "get-url", "origin"]); + process.stdout.write(stdout.trim());`, + injected, + ); + assert.equal(out, "git@github.com:Acme/Tool.git", "the SCP-syntax remote survives GIT_CONFIG_PARAMETERS"); +}); + +test("every guarded file imports the isolation helper first", async () => { + // One passing file is insufficient (B01-A3): assert the boundary is + // applied at all seven sites, and that it is genuinely FIRST — the defect + // in broadside-repo-collection.test.mjs was position, not absence. + const { readFile } = await import("node:fs/promises"); + for (const relative of GUARDED_FILES) { + const source = await readFile(join(REPO_ROOT, relative), "utf8"); + const firstImport = source.match(/^import .*$/m); + assert.ok(firstImport, `${relative}: expected at least one import`); + assert.equal( + firstImport[0], + 'import "./helpers/git-config-isolation.mjs";', + `${relative}: the isolation import must be the first import in the file`, + ); + assert.ok( + !/process\.env\.GIT_CONFIG_(GLOBAL|SYSTEM)\s*=/.test(source), + `${relative}: the open-coded guard should be gone, replaced by the shared helper`, + ); + } +}); + +test("the named failing test passes with the rewrite injected into its runner", async () => { + // B01-A2 as an assertion rather than a claim: the regression suite passes + // while its PARENT still supplies the rewrite. Removing the injection + // from the command would not count. + // + // `node --test` refuses to recurse ("run() is being called recursively + // within a test file. skipping running files"), so the child runs the + // file DIRECTLY. `node:test` still executes and reports on exit, and a + // failing assertion still yields a non-zero exit code — which is the + // signal this case needs. + const child = await execFileAsync( + process.execPath, + ["--experimental-strip-types", "--disable-warning=ExperimentalWarning", "tests/library.test.mjs"], + { env: { ...process.env, ...INJECTED }, cwd: REPO_ROOT, maxBuffer: 32 * 1024 * 1024 }, + ).catch((error) => error); + const output = `${child.stdout ?? ""}${child.stderr ?? ""}`; + assert.ok(!(child instanceof Error), `library.test.mjs must pass under injection; exit ${child.code}\n${output.slice(-1500)}`); + assert.match(output, /resolvePublishSourceRepo records origin's fetch URL verbatim/, "the named test really ran"); + assert.ok(!/^not ok/m.test(output), "no test failed"); +}); + +test("isolation does not touch the user's Git configuration", async () => { + // B01-A5. The helper only ever assigns to process.env of its own process. + const { readFile } = await import("node:fs/promises"); + const source = await readFile(join(REPO_ROOT, "tests/helpers/git-config-isolation.mjs"), "utf8"); + // Scan the CODE, not the prose: the comments legitimately discuss + // `git config` and the variables git reads, and an assertion that greps + // the whole file fails the moment the documentation improves. + const code = source + .split("\n") + .filter((line) => !line.trim().startsWith("//") && !line.trim().startsWith("*") && !line.trim().startsWith("/*")) + .join("\n"); + assert.ok(!/execFile|spawn|writeFile|appendFile|"git"|'git'/.test(code), "the helper runs no commands and writes no files"); + // An allow-list, not an exact set: the point is that the helper touches + // NOTHING outside git's own configuration variables. Pinning the exact + // triple made this test fail the moment a fourth source ( + // GIT_CONFIG_PARAMETERS) had to be neutralized — a test that resists + // widening the isolation it exists to protect. + const touched = [...source.matchAll(/(?:delete\s+)?env\.([A-Z_]+)/g)].map((m) => m[1]); + assert.ok(touched.length > 0, "the helper must touch something"); + for (const name of touched) { + assert.match(name, /^GIT_CONFIG(_[A-Z]+)?$/, `${name} is outside git's configuration variables`); + } + // And every source git reads from the environment is actually covered. + for (const required of ["GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_CONFIG_COUNT", "GIT_CONFIG_PARAMETERS"]) { + assert.ok(touched.includes(required), `${required} must be neutralized`); + } +}); diff --git a/tests/helpers/git-config-isolation.mjs b/tests/helpers/git-config-isolation.mjs new file mode 100644 index 0000000..d8d3b46 --- /dev/null +++ b/tests/helpers/git-config-isolation.mjs @@ -0,0 +1,83 @@ +// Git-configuration isolation for tests that build real Git fixtures. +// +// Import this FIRST, for its side effects, before any module that shells out +// to git: +// +// import "./helpers/git-config-isolation.mjs"; +// +// Git takes configuration from four environment-reachable sources, and +// neutralizing some of them is not isolation: +// +// 1. the system config file -> GIT_CONFIG_SYSTEM +// 2. the global config file -> GIT_CONFIG_GLOBAL +// 3. config injected through the environment +// -> GIT_CONFIG_COUNT / _KEY_n / _VALUE_n +// 4. config git passes to its own subprocesses +// -> GIT_CONFIG_PARAMETERS +// +// The first two are FILE lookups, so pointing them at a path that does not +// exist makes git read them as empty. The third is not a file at all — git +// reports its origin as `command line:` — so redirecting file lookups cannot +// reach it. A contributor whose environment supplies, say, +// +// url.https://github.com/.insteadOf = git@github.com: +// +// (a common setup that routes SSH-syntax traffic over HTTPS) would still see +// that rewrite applied to fixture remotes, and +// `resolvePublishSourceRepo records origin's fetch URL verbatim` would fail +// on their machine while staying green on CI's bare runners. Setting +// GIT_CONFIG_COUNT=0 tells git there are zero injected entries, which +// neutralizes the whole GIT_CONFIG_KEY_n/VALUE_n set regardless of how many +// were supplied. +// +// The fourth is how git hands `-c key=value` down to the subprocesses it +// spawns, so it arrives without anyone setting it deliberately: run the suite +// under `git -c … `, or inside `git bisect run`, and every fixture +// command inherits it. It carries its own count, so GIT_CONFIG_COUNT=0 does +// not disarm it. Deleting it and setting it to "" both work — git reads an +// empty string as no entries — but deleting is what the name of the +// operation should say: the variable has no business being in the +// environment of a fixture command at all. +// +// Why a module rather than three lines in each test file: this must run +// before the code under test is imported. In ESM, static imports are +// evaluated before the importing module's body, so three lines at module +// scope run AFTER every static import — and in one file they sat after a +// dynamic `await import()` of the module under test. That did not break +// anything at the time (the imported module makes no git call while it is +// being evaluated), so it was a latent hazard rather than a realized +// defect. A side-effect import placed first is ordered by the module system +// instead of by line position, which is the property the guard needs if a +// module under test ever does resolve git configuration at import time. +// +// This only ever writes to `process.env` of the current process. It does not +// read, write, or modify the user's Git configuration. + +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +/** A path git will read as empty config. It is never created. */ +export const ABSENT_GIT_CONFIG = join(tmpdir(), "codecarto-tests-absent-gitconfig"); + +/** + * Neutralize every environment-reachable Git configuration source for this + * process. + * + * Idempotent, and safe to call from a child process that inherited an + * injected environment: the point is to overwrite what was inherited. + */ +export function isolateGitConfig(env = process.env) { + env.GIT_CONFIG_GLOBAL = ABSENT_GIT_CONFIG; + env.GIT_CONFIG_SYSTEM = ABSENT_GIT_CONFIG; + // Zero injected entries. Git stops reading GIT_CONFIG_KEY_n/VALUE_n + // entirely, so any number of already-set pairs become inert; deleting + // them one by one would require knowing the original count. + env.GIT_CONFIG_COUNT = "0"; + // This variable carries its own entries, so GIT_CONFIG_COUNT cannot + // disarm it. Deleted rather than blanked: it should not be in a fixture + // command's environment at all. + delete env.GIT_CONFIG_PARAMETERS; + return env; +} + +isolateGitConfig(); diff --git a/tests/library.test.mjs b/tests/library.test.mjs index ca8bbd9..fd27540 100644 --- a/tests/library.test.mjs +++ b/tests/library.test.mjs @@ -16,6 +16,7 @@ // gate describes a publish with (#162), and the git-remote resolver the Pi // command records source_repo from (#147). +import "./helpers/git-config-isolation.mjs"; import { test } from "node:test"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; @@ -25,18 +26,6 @@ import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; -// Git fixtures must not inherit the developer's git configuration. A global -// `url..insteadOf` rewrites what `git remote get-url` reports — which is -// exactly what resolvePublishSourceRepo reads — so a verbatim-URL assertion -// fails on any machine carrying that common setting while staying green on -// CI's bare runners. `commit.gpgsign` and `init.defaultBranch` reach the -// committing fixtures the same way. Point both config layers at a path that -// does not exist: git reads a missing file as empty config. Identity is set -// per fixture in repo-local config, so commits still work. -const ABSENT_GIT_CONFIG = join(tmpdir(), "codecarto-tests-absent-gitconfig"); -process.env.GIT_CONFIG_GLOBAL = ABSENT_GIT_CONFIG; -process.env.GIT_CONFIG_SYSTEM = ABSENT_GIT_CONFIG; - const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const lib = await import(pathToFileURL(`${REPO_ROOT}/core/library.ts`).href); const core = await import(pathToFileURL(`${REPO_ROOT}/core/index.ts`).href); diff --git a/tests/mcp-uncovered-handlers.test.mjs b/tests/mcp-uncovered-handlers.test.mjs index f0c4663..89502b5 100644 --- a/tests/mcp-uncovered-handlers.test.mjs +++ b/tests/mcp-uncovered-handlers.test.mjs @@ -8,6 +8,7 @@ // // Everything here runs offline. No model, no network, no API key. +import "./helpers/git-config-isolation.mjs"; import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; @@ -15,10 +16,6 @@ import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -const ABSENT_GIT_CONFIG = join(tmpdir(), "codecarto-tests-absent-gitconfig"); -process.env.GIT_CONFIG_GLOBAL = ABSENT_GIT_CONFIG; -process.env.GIT_CONFIG_SYSTEM = ABSENT_GIT_CONFIG; - const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const { handleInit, diff --git a/tests/pi-broadside.test.mjs b/tests/pi-broadside.test.mjs index c2f0d9e..346500d 100644 --- a/tests/pi-broadside.test.mjs +++ b/tests/pi-broadside.test.mjs @@ -4,6 +4,7 @@ // asks a human about the money instead of refusing over max_cost, and it runs // on a repository with no CodeCartographer workspace. +import "./helpers/git-config-isolation.mjs"; import { test } from "node:test"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; @@ -13,18 +14,6 @@ import { promisify } from "node:util"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -// Git fixtures must not inherit the developer's git configuration. A global -// `url..insteadOf` rewrites what `git remote get-url` reports — which is -// exactly what resolvePublishSourceRepo reads — so a verbatim-URL assertion -// fails on any machine carrying that common setting while staying green on -// CI's bare runners. `commit.gpgsign` and `init.defaultBranch` reach the -// committing fixtures the same way. Point both config layers at a path that -// does not exist: git reads a missing file as empty config. Identity is set -// per fixture in repo-local config, so commits still work. -const ABSENT_GIT_CONFIG = join(tmpdir(), "codecarto-tests-absent-gitconfig"); -process.env.GIT_CONFIG_GLOBAL = ABSENT_GIT_CONFIG; -process.env.GIT_CONFIG_SYSTEM = ABSENT_GIT_CONFIG; - const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const { default: codeCartographerExtension } = await import( pathToFileURL(`${REPO_ROOT}/extensions/codecarto/index.ts`).href diff --git a/tests/pi-command-handlers.test.mjs b/tests/pi-command-handlers.test.mjs index 8343323..4172980 100644 --- a/tests/pi-command-handlers.test.mjs +++ b/tests/pi-command-handlers.test.mjs @@ -13,6 +13,7 @@ // contract — refuse cleanly, report through the UI, never throw — is fully // observable here. +import "./helpers/git-config-isolation.mjs"; import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; @@ -20,11 +21,6 @@ import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -// Keep git out of the user's real config, as the sibling fixtures do. -const ABSENT_GIT_CONFIG = join(tmpdir(), "codecarto-tests-absent-gitconfig"); -process.env.GIT_CONFIG_GLOBAL = ABSENT_GIT_CONFIG; -process.env.GIT_CONFIG_SYSTEM = ABSENT_GIT_CONFIG; - const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const { default: codeCartographerExtension } = await import(pathToFileURL(`${REPO_ROOT}/extensions/codecarto/index.ts`).href); const { getWorkspaceState } = await import(pathToFileURL(`${REPO_ROOT}/core/index.ts`).href); diff --git a/tests/pi-publish.test.mjs b/tests/pi-publish.test.mjs index 45fa618..59cc8b0 100644 --- a/tests/pi-publish.test.mjs +++ b/tests/pi-publish.test.mjs @@ -7,6 +7,7 @@ // upgraded Pi trips it on its own, when an entry recorded under the old // directory shape meets a publish carrying the new remote shape. +import "./helpers/git-config-isolation.mjs"; import { test } from "node:test"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; @@ -16,18 +17,6 @@ import { promisify } from "node:util"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -// Git fixtures must not inherit the developer's git configuration. A global -// `url..insteadOf` rewrites what `git remote get-url` reports — which is -// exactly what resolvePublishSourceRepo reads — so a verbatim-URL assertion -// fails on any machine carrying that common setting while staying green on -// CI's bare runners. `commit.gpgsign` and `init.defaultBranch` reach the -// committing fixtures the same way. Point both config layers at a path that -// does not exist: git reads a missing file as empty config. Identity is set -// per fixture in repo-local config, so commits still work. -const ABSENT_GIT_CONFIG = join(tmpdir(), "codecarto-tests-absent-gitconfig"); -process.env.GIT_CONFIG_GLOBAL = ABSENT_GIT_CONFIG; -process.env.GIT_CONFIG_SYSTEM = ABSENT_GIT_CONFIG; - const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const { default: codeCartographerExtension } = await import( pathToFileURL(`${REPO_ROOT}/extensions/codecarto/index.ts`).href