diff --git a/CHANGELOG.md b/CHANGELOG.md index 652d0f91a..7cdb8f066 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [1.91.209.0] - 2026-10-25 + +**The free test suite no longer rewrites a contributor's global git pre-push hook (#3353).** On a machine with a global `core.hooksPath`, `test/redact-prepush-target.test.ts` and `test/fs-utils.test.ts` ran `gstack-redact install-prepush-hook` with the parent environment. Git resolved the hooks directory to the global one, so the install moved the developer's own `pre-push` to `pre-push.local` and left a managed wrapper pointing into the test checkout, which breaks every later push once that checkout is gone. The same config also failed 4 tests in the target file, 5 in `test/redact-prepush-hook.test.ts` and 1 in `fs-utils`. CI has no global hooks path, so it stayed green. + +### Fixed +- **`test/helpers/git-env.ts`** (new): `gitEnv()` is the one fixture environment for git and hook installs: `GIT_CONFIG_GLOBAL=/dev/null`, `GIT_CONFIG_NOSYSTEM=1`, `GIT_CONFIG_COUNT=0` and no `GIT_CONFIG_PARAMETERS`. Config written inside the fixture repo still applies. +- `test/redact-prepush-target.test.ts`, `test/redact-prepush-hook.test.ts` and `test/fs-utils.test.ts` pass it to every fixture git, install and hook spawn. `test/redact-prepush-scan-range.test.ts` and `test/redact-prepush-rebase-force-push.test.ts` drop their private copies (#2967) and import it. + +### Tests +- A new case in `test/redact-prepush-target.test.ts` sets a global, system and environment `core.hooksPath` to a hook that fails, builds the fixture, and checks the developer's hooks directory is untouched, the managed hook landed in the fixture repo, and a push never runs the developer's hook. It fails without the shared environment. +- Rerun of the issue's repro with `HOME` pointing at a gitconfig with a global hooks dir: on main, two files rewrote it and three files had failures; with this change all six `redact-prepush-*` and `fs-utils` files pass and the directory is unchanged. + ## [1.91.208.0] - 2026-10-25 **The Capy install guide says that the team-mode session-start updater also waits for live workflow leases.** With #3385 (#3363), `bin/gstack-session-update` skips its background fast-forward while another session holds a live workflow lease, the same leases `bin/gstack-upgrade` already honors. `docs/capy-install.md` described only the upgrade side. diff --git a/VERSION b/VERSION index b3d823cb4..32b3c2d81 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.208.0 +1.91.209.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 2283fc59f..bfdfaa808 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.208.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.209.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/package.json b/package.json index cd2c1099d..da6af4fc8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.208", + "version": "1.91.209", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/fs-utils.test.ts b/test/fs-utils.test.ts index f15c45199..e2caa2f53 100644 --- a/test/fs-utils.test.ts +++ b/test/fs-utils.test.ts @@ -14,6 +14,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { spawnSync } from "child_process"; +import { gitEnv } from "./helpers/git-env"; import { mkdirpSync } from "../lib/fs-utils"; const REDACT = path.resolve(import.meta.dir, "..", "bin", "gstack-redact"); @@ -93,7 +94,7 @@ describe("install-prepush-hook under bun-on-Windows EEXIST semantics (#2635)", ( const base = tmpdir(); try { const repo = path.join(base, "repo"); - spawnSync("git", ["init", "-q", repo], { timeout: 30_000 }); + spawnSync("git", ["init", "-q", repo], { timeout: 30_000, env: gitEnv() }); const hookDir = path.join(repo, ".git", "hooks"); fs.mkdirSync(hookDir, { recursive: true }); const hookPath = path.join(hookDir, "pre-push"); @@ -106,6 +107,7 @@ describe("install-prepush-hook under bun-on-Windows EEXIST semantics (#2635)", ( cwd: repo, encoding: "utf8", timeout: 30_000, + env: gitEnv(), }); expect(r.status).toBe(0); expect(r.stderr ?? "").not.toContain("EEXIST"); diff --git a/test/helpers/git-env.ts b/test/helpers/git-env.ts new file mode 100644 index 000000000..48b4a9eff --- /dev/null +++ b/test/helpers/git-env.ts @@ -0,0 +1,20 @@ +/** + * Environment for fixture git commands and `gstack-redact install-prepush-hook`. + * + * With the parent environment, a developer's global or system core.hooksPath + * (or the same key via GIT_CONFIG_COUNT / GIT_CONFIG_PARAMETERS) reaches the + * fixture: `git rev-parse --git-path hooks` resolves to that directory, so the + * install rewrites the developer's own pre-push (#3353), and their hook runs + * on fixture pushes and rejects the local remote (#2967). /dev/null drops the + * global file on every platform: Git for Windows rejects NUL (see + * EMPTY_GLOBAL_GIT_CONFIG in lib/cso/process.ts). Config written inside the + * fixture repo still applies. + */ +export function gitEnv(extra: Record = {}): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env, ...extra }; + env.GIT_CONFIG_GLOBAL = "/dev/null"; + env.GIT_CONFIG_NOSYSTEM = "1"; + env.GIT_CONFIG_COUNT = "0"; + delete env.GIT_CONFIG_PARAMETERS; + return env; +} diff --git a/test/redact-prepush-hook.test.ts b/test/redact-prepush-hook.test.ts index 964e4c511..84c07690b 100644 --- a/test/redact-prepush-hook.test.ts +++ b/test/redact-prepush-hook.test.ts @@ -13,6 +13,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { spawnSync } from "child_process"; +import { gitEnv } from "./helpers/git-env"; const PREPUSH = path.resolve(import.meta.dir, "..", "bin", "gstack-redact-prepush"); const REDACT = path.resolve(import.meta.dir, "..", "bin", "gstack-redact"); @@ -21,7 +22,7 @@ let repo: string; let stateHome: string; function git(args: string[], cwd = repo): string { - const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000 }); + const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: gitEnv() }); return r.stdout?.trim() ?? ""; } @@ -40,7 +41,7 @@ function runHook( cwd: repo, input: Buffer.from(stdinLines), encoding: "utf8", - env: { ...process.env, ...env }, + env: gitEnv(env), timeout: 30_000, }); return { code: r.status ?? 0, stderr: r.stderr ?? "" }; @@ -439,7 +440,7 @@ describe("install UX surfaces (#1946 / eng review D3+D10)", () => { const r = spawnSync("bash", ["-c", script], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], // stdin not a TTY - env: { ...process.env, GSTACK_HOME: home }, + env: gitEnv({ GSTACK_HOME: home }), timeout: 15_000, }); expect(r.status).toBe(0); @@ -467,7 +468,7 @@ describe("install UX surfaces (#1946 / eng review D3+D10)", () => { const r = spawnSync("bash", ["-c", script], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env, GSTACK_HOME: home }, + env: gitEnv({ GSTACK_HOME: home }), timeout: 15_000, }); expect(r.status).toBe(0); @@ -502,7 +503,7 @@ describe("install / chaining", () => { const existing = path.join(hookDir, "pre-push"); fs.writeFileSync(existing, "#!/usr/bin/env bash\necho mine\n", { mode: 0o755 }); - const r = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const r = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(r.status).toBe(0); const installed = fs.readFileSync(existing, "utf8"); expect(installed).toContain("gstack-redact pre-push (managed)"); @@ -516,7 +517,7 @@ describe("install / chaining", () => { test("chained pre-push.local receives the final ref line (trailing newline preserved)", () => { const hookDir = path.join(repo, ".git", "hooks"); fs.mkdirSync(hookDir, { recursive: true }); - spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000 }); + spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000, env: gitEnv() }); const seen = path.join(repo, "seen.txt"); fs.writeFileSync( @@ -531,7 +532,7 @@ describe("install / chaining", () => { cwd: repo, input: Buffer.from(line), encoding: "utf8", - env: { ...process.env, GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }, + env: gitEnv({ GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }), timeout: 30_000, }); expect(r.status).toBe(0); @@ -545,7 +546,7 @@ describe("install / chaining", () => { test("a blocking pre-push.local still short-circuits the push", () => { const hookDir = path.join(repo, ".git", "hooks"); fs.mkdirSync(hookDir, { recursive: true }); - spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000 }); + spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000, env: gitEnv() }); fs.writeFileSync( path.join(hookDir, "pre-push.local"), "#!/usr/bin/env bash\nwhile read -r _a _b _c _d || [ -n \"${_a:-}\" ]; do exit 1; done\nexit 0\n", @@ -555,7 +556,7 @@ describe("install / chaining", () => { cwd: repo, input: Buffer.from(`refs/heads/main ${"b".repeat(40)} refs/heads/main ${ZERO}\n`), encoding: "utf8", - env: { ...process.env, GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }, + env: gitEnv({ GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }), timeout: 30_000, }); expect(r.status).toBe(1); @@ -603,6 +604,7 @@ describe("install / chaining", () => { timeout: 30_000, cwd: repo, encoding: "utf8", + env: gitEnv(), }); expect(r.status).toBe(0); expect(r.stdout).toContain("refreshed stale managed pre-push hook"); @@ -618,7 +620,7 @@ describe("install / chaining", () => { cwd: repo, input: Buffer.from(`refs/heads/main ${sha} refs/heads/main ${ZERO}\n`), encoding: "utf8", - env: { ...process.env, GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }, + env: gitEnv({ GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }), }); expect(run.status).toBe(0); expect(fs.readFileSync(path.join(stateHome, "security", "prepush-skip.jsonl"), "utf8")).toContain("env-skip"); @@ -630,13 +632,14 @@ describe("install / chaining", () => { fs.mkdirSync(hookDir, { recursive: true }); const hook = path.join(hookDir, "pre-push"); - spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo }); + spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, env: gitEnv() }); const first = fs.readFileSync(hook, "utf8"); const stamp = fs.statSync(hook).mtimeMs; const again = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", + env: gitEnv(), }); expect(again.status).toBe(0); expect(again.stdout).toContain("already installed"); @@ -651,8 +654,8 @@ describe("install / chaining", () => { fs.writeFileSync(path.join(hookDir, "pre-push"), "#!/usr/bin/env bash\necho mine\n", { mode: 0o755, }); - spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000 }); - spawnSync("bun", [REDACT, "uninstall-prepush-hook"], { cwd: repo, timeout: 30_000 }); + spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000, env: gitEnv() }); + spawnSync("bun", [REDACT, "uninstall-prepush-hook"], { cwd: repo, timeout: 30_000, env: gitEnv() }); const restored = fs.readFileSync(path.join(hookDir, "pre-push"), "utf8"); expect(restored).toContain("echo mine"); expect(restored).not.toContain("managed"); @@ -671,7 +674,7 @@ describe("base resolution when the default branch is neither main nor master", ( // blocks the push having scanned NOTHING — the "scans more, never less" // fallback inverting into "scans nothing". const bare = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-remote-")); - spawnSync("git", ["init", "-q", "--bare", "-b", "trunk", bare], { timeout: 30_000 }); + spawnSync("git", ["init", "-q", "--bare", "-b", "trunk", bare], { timeout: 30_000, env: gitEnv() }); git(["branch", "-M", "trunk"]); const old = commit("legacy.txt", FAKE_AWS_KEY + "\n", "secret already on the remote"); diff --git a/test/redact-prepush-rebase-force-push.test.ts b/test/redact-prepush-rebase-force-push.test.ts index 5b9af16d2..c6e0da0cd 100644 --- a/test/redact-prepush-rebase-force-push.test.ts +++ b/test/redact-prepush-rebase-force-push.test.ts @@ -31,23 +31,13 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { spawnSync } from "child_process"; +import { gitEnv } from "./helpers/git-env"; const PREPUSH = path.resolve(import.meta.dir, "..", "bin", "gstack-redact-prepush"); let repo: string; let remote: string; -// Same blanking as test/redact-prepush-scan-range.test.ts (#2967). A -// hooksPath written into the fixture repo still runs. -function gitEnv(): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { ...process.env }; - env.GIT_CONFIG_GLOBAL = "/dev/null"; - env.GIT_CONFIG_NOSYSTEM = "1"; - env.GIT_CONFIG_COUNT = "0"; - delete env.GIT_CONFIG_PARAMETERS; - return env; -} - function git(args: string[], cwd = repo): string { const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: gitEnv() }); if (r.status !== 0) throw new Error(`git ${args.join(" ")}\n${r.stderr}`); diff --git a/test/redact-prepush-scan-range.test.ts b/test/redact-prepush-scan-range.test.ts index 316bf7787..c29003b01 100644 --- a/test/redact-prepush-scan-range.test.ts +++ b/test/redact-prepush-scan-range.test.ts @@ -17,25 +17,11 @@ */ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { spawnSync } from "child_process"; +import { gitEnv } from "./helpers/git-env"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { dirname, join } from "path"; -// Fixture git was spawned with the parent environment. A global or system -// core.hooksPath, or the same key via GIT_CONFIG_COUNT / -// GIT_CONFIG_PARAMETERS, runs on these pushes and rejects the local remote -// (#2967). /dev/null drops the global file on every platform: Git for -// Windows rejects NUL (see EMPTY_GLOBAL_GIT_CONFIG in lib/cso/process.ts). -// Config written inside the fixture repo still applies. -function gitEnv(): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { ...process.env }; - env.GIT_CONFIG_GLOBAL = "/dev/null"; - env.GIT_CONFIG_NOSYSTEM = "1"; - env.GIT_CONFIG_COUNT = "0"; - delete env.GIT_CONFIG_PARAMETERS; - return env; -} - let dir: string; const run = (args: string[], cwd = dir): string => { const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: gitEnv() }); diff --git a/test/redact-prepush-target.test.ts b/test/redact-prepush-target.test.ts index 4635722ee..250512d8c 100644 --- a/test/redact-prepush-target.test.ts +++ b/test/redact-prepush-target.test.ts @@ -4,13 +4,14 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { scan } from "../lib/redact-engine"; +import { gitEnv } from "./helpers/git-env"; const REDACT = path.resolve(import.meta.dir, "../bin/gstack-redact"); const roots: string[] = []; const key = ["AKIA", "1234567890ABCDEF"].join(""); function git(cwd: string, ...args: string[]): string { - const result = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000 }); + const result = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: gitEnv() }); if (result.status !== 0) throw new Error(`git ${args[0]} failed: ${result.stderr}`); return result.stdout.trim(); } @@ -35,7 +36,7 @@ function fixture(initialSecret = true, sha256 = false): { repo: string; origin: git(repo, "remote", "add", "origin", origin); git(repo, "push", "-q", "-u", "origin", "main"); git(repo, "remote", "add", "publish", publish); - const install = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const install = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(install.status).toBe(0); return { repo, origin, publish, head }; } @@ -58,11 +59,49 @@ afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); }); +describe("fixture installs ignore the developer's hook config (#3353)", () => { + test("a global, system or environment core.hooksPath is neither rewritten nor run", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-target-global-")); + roots.push(root); + const hooks = path.join(root, "global-hooks"); + fs.mkdirSync(hooks); + const original = "#!/bin/sh\necho developer-hook >&2\nexit 1\n"; + fs.writeFileSync(path.join(hooks, "pre-push"), original, { mode: 0o755 }); + const config = path.join(root, "gitconfig"); + fs.writeFileSync(config, `[core]\n\thooksPath = ${JSON.stringify(hooks)}\n`); + const keys = ["GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_CONFIG_NOSYSTEM", "GIT_CONFIG_COUNT", + "GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0", "GIT_CONFIG_PARAMETERS"] as const; + const prev = Object.fromEntries(keys.map(key => [key, process.env[key]])); + try { + process.env.GIT_CONFIG_GLOBAL = config; + process.env.GIT_CONFIG_SYSTEM = config; + delete process.env.GIT_CONFIG_NOSYSTEM; + process.env.GIT_CONFIG_COUNT = "1"; + process.env.GIT_CONFIG_KEY_0 = "core.hooksPath"; + process.env.GIT_CONFIG_VALUE_0 = hooks; + process.env.GIT_CONFIG_PARAMETERS = `'core.hooksPath=${hooks}'`; + const { repo } = fixture(false); + expect(fs.readdirSync(hooks)).toEqual(["pre-push"]); + expect(fs.readFileSync(path.join(hooks, "pre-push"), "utf8")).toBe(original); + const installed = git(repo, "rev-parse", "--path-format=absolute", "--git-path", "hooks/pre-push"); + expect(fs.readFileSync(installed, "utf8")).toContain("gstack-redact pre-push (managed)"); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); + expect(push.status, push.stderr).toBe(0); + expect(push.stderr).not.toContain("developer-hook"); + } finally { + for (const [key, value] of Object.entries(prev)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); +}); + describe("installed pre-push guard uses the actual destination", () => { test("origin tracking at HEAD cannot excuse the same credential on first push to publish", () => { const { repo, publish, head } = fixture(); expect(git(repo, "rev-parse", "origin/main")).toBe(head); - const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("aws.access_key"); expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe(""); @@ -71,7 +110,7 @@ describe("installed pre-push guard uses the actual destination", () => { test("a configured remote's different push URL cannot reuse its fetch tracking tip", () => { const { repo, publish } = fixture(); git(repo, "remote", "set-url", "--push", "origin", publish); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("aws.access_key"); expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe(""); @@ -92,7 +131,7 @@ describe("installed pre-push guard uses the actual destination", () => { git(repo, "update-ref", "refs/heads/publish/main", tip); expect(git(repo, "rev-parse", "publish/main")).toBe(tip); expect(git(repo, "rev-parse", "refs/remotes/publish/main")).toBe(base); - const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("aws.access_key"); expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe(""); @@ -108,7 +147,7 @@ describe("installed pre-push guard uses the actual destination", () => { expect(git(repo, "rev-parse", "refs/remotes/publish/main")).toBe(git(repo, "rev-parse", "HEAD")); git(repo, "remote", "set-url", "--add", "publish", second); expect(git(repo, "remote", "get-url", "publish")).toBe(publish); - const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("aws.access_key"); expect(git(second, "for-each-ref", "--format=%(refname)")).toBe(""); @@ -116,7 +155,7 @@ describe("installed pre-push guard uses the actual destination", () => { test("direct URL push cannot borrow origin's tracking history", () => { const { repo, publish } = fixture(); - const push = spawnSync("git", ["push", publish, "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", publish, "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("aws.access_key"); expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe(""); @@ -125,8 +164,8 @@ describe("installed pre-push guard uses the actual destination", () => { test("an advertised remote tip absent from local objects cannot justify a guessed base", () => { const { repo, publish } = fixture(); const remoteTip = seedIndependentRemote(repo, publish); - expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000 }).status).not.toBe(0); - const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000, env: gitEnv() }).status).not.toBe(0); + const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("aws.access_key"); expect(git(publish, "rev-parse", "refs/heads/main")).toBe(remoteTip); @@ -135,21 +174,21 @@ describe("installed pre-push guard uses the actual destination", () => { test("an absent advertised tip permits a clean forced update after conservative scanning", () => { const { repo, publish, head } = fixture(false); const remoteTip = seedIndependentRemote(repo, publish); - expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000 }).status).not.toBe(0); - const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000, env: gitEnv() }).status).not.toBe(0); + const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(0); expect(git(publish, "rev-parse", "refs/heads/main")).toBe(head); }); test("new SHA-256 branch scans the correct empty tree and allows a clean push", () => { const { repo, publish } = fixture(false, true); - const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(0); expect(git(publish, "rev-parse", "refs/heads/main")).toHaveLength(64); fs.writeFileSync(path.join(repo, "leak.txt"), `key ${key}\n`); git(repo, "add", "leak.txt"); git(repo, "commit", "-qm", "new secret"); - const blocked = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const blocked = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(blocked.status).toBe(1); expect(blocked.stderr).toContain("aws.access_key"); }); @@ -164,7 +203,7 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "clean.txt"), "safe update\n"); git(repo, "add", "clean.txt"); git(repo, "commit", "-qm", "clean branch"); - const push = spawnSync("git", ["push", "origin", "main", "leak"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main", "leak"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("aws.access_key"); expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head); @@ -183,7 +222,7 @@ describe("installed pre-push guard uses the actual destination", () => { git(repo, "add", "clean.txt"); git(repo, "commit", "-qm", "clean update"); const head = git(repo, "rev-parse", "HEAD"); - const push = spawnSync("git", ["push", "origin", "main", ":disposable"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main", ":disposable"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(0); expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head); expect(git(origin, "for-each-ref", "--format=%(refname)", "refs/heads/disposable")).toBe(""); @@ -193,7 +232,7 @@ describe("installed pre-push guard uses the actual destination", () => { const { repo, origin, head } = fixture(false); const hook = git(repo, "rev-parse", "--git-path", "hooks/pre-push"); const result = spawnSync("bash", [hook, "origin", origin], { - cwd: repo, input: "refs/heads/main malformed\n", encoding: "utf8", timeout: 30_000, + cwd: repo, input: "refs/heads/main malformed\n", encoding: "utf8", timeout: 30_000, env: gitEnv(), }); expect(result.status, result.stderr || result.error?.message).toBe(1); expect(result.stderr).toContain("could not parse a pre-push ref line"); @@ -208,7 +247,7 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "payload.txt"), payload); git(repo, "add", "payload.txt"); git(repo, "commit", "-qm", "near seam"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("aws.secret_key"); }); @@ -221,7 +260,7 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "payload.txt"), payload); git(repo, "add", "payload.txt"); git(repo, "commit", "-qm", "unlabeled seam"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(0); expect(push.stderr).not.toContain("aws.secret_key"); }); @@ -232,7 +271,7 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "payload.txt"), payload); git(repo, "add", "payload.txt"); git(repo, "commit", "-qm", "one finding at seam"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr.match(/HIGH aws\.access_key/g)).toHaveLength(1); }); @@ -247,7 +286,7 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "payload.txt"), payload); git(repo, "add", "payload.txt"); git(repo, "commit", "-qm", "supplementary normalization seam"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr.match(/HIGH aws\.access_key/g)).toHaveLength(1); expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head); @@ -264,7 +303,7 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "payload.txt"), payload); git(repo, "add", "payload.txt"); git(repo, "commit", "-qm", "mid-line control"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(0); expect(push.stderr).not.toContain("MEDIUM finding"); }); @@ -277,7 +316,7 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "payload.txt"), payload); git(repo, "add", "payload.txt"); git(repo, "commit", "-qm", "embedded key control"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(0); expect(push.stderr).not.toContain("aws.access_key"); expect(git(origin, "rev-parse", "refs/heads/main")).not.toBe(head); @@ -294,7 +333,7 @@ describe("installed pre-push guard uses the actual destination", () => { git(repo, "add", "payload.txt"); git(repo, "commit", "-qm", "long bearer advisory"); const tip = git(repo, "rev-parse", "HEAD"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(0); expect(push.stderr).toContain("MEDIUM finding"); expect(git(origin, "rev-parse", "refs/heads/main")).toBe(tip); @@ -308,7 +347,7 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "payload.txt"), payload); git(repo, "add", "payload.txt"); git(repo, "commit", "-qm", "invisible seam"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("aws.secret_key"); }); @@ -320,7 +359,7 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "payload.txt"), payload); git(repo, "add", "payload.txt"); git(repo, "commit", "-qm", "unscannable context"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("engine.input_too_large"); expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head); @@ -339,7 +378,7 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "payload.txt"), payload); git(repo, "add", "payload.txt"); git(repo, "commit", "-qm", "normalization seam"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(expected ? 1 : 0); expect(push.stderr.includes("aws.secret_key")).toBe(expected); }); @@ -350,19 +389,19 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "large.txt"), "ordinary content\n".repeat(160_000)); git(repo, "add", "large.txt"); git(repo, "commit", "-qm", "large clean diff"); - let push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000 }); + let push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000, env: gitEnv() }); expect(push.status).toBe(0); fs.writeFileSync(path.join(repo, "long.txt"), "x".repeat(900_000) + "\n"); git(repo, "add", "long.txt"); git(repo, "commit", "-qm", "long clean line"); - push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000 }); + push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000, env: gitEnv() }); expect(push.status).toBe(0); const priorTip = git(origin, "rev-parse", "refs/heads/main"); expect(priorTip).not.toBe(head); fs.writeFileSync(path.join(repo, "too-long.txt"), "x".repeat(1_100_000) + "\n"); git(repo, "add", "too-long.txt"); git(repo, "commit", "-qm", "over cap line"); - push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000 }); + push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("engine.input_too_large"); expect(git(origin, "rev-parse", "refs/heads/main")).toBe(priorTip); @@ -373,7 +412,7 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "long.txt"), `${"x".repeat(900_000)} key ${key}\n`); git(repo, "add", "long.txt"); git(repo, "commit", "-qm", "long line with key"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("aws.access_key"); expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head); @@ -385,7 +424,7 @@ describe("installed pre-push guard uses the actual destination", () => { git(repo, "add", "long.txt"); git(repo, "commit", "-qm", "neighboring long lines"); const tip = git(repo, "rev-parse", "HEAD"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(0); expect(push.stderr).not.toContain("engine.input_too_large"); expect(git(origin, "rev-parse", "refs/heads/main")).toBe(tip);