diff --git a/docs/cli.md b/docs/cli.md index 91f561d96..fa60c1d5b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -325,7 +325,12 @@ names the phase that was hit, its budget, and the variable that raises it; with `depends_on` waiting, not lifecycle hooks or an end-to-end application readiness deadline. A restart repair attempt has its own budgets and is reported separately. Foreground `up` stays a single unbounded Compose invocation; `down` -retains its separate 90-second limit. +retains its separate 90-second limit. A native `down` reads that limit from +`HACK_COMPOSE_STARTUP_TIMEOUT_MS`, and each check that it still owns its containers +may use that time too, at least 15 and at most 60 seconds. The last check before +`compose down` must also leave about a fifth of that limit, at most 5 seconds, for the +stop itself; otherwise `down` refuses before it starts Compose, and `hack down --recover` +completes the stop. On timeout, Hack terminates the owned Compose process group of that phase. After an image-phase timeout no container was started by that attempt, but containers may diff --git a/docs/env.md b/docs/env.md index 129967f8e..a61477d07 100644 --- a/docs/env.md +++ b/docs/env.md @@ -715,9 +715,15 @@ message in the envelope: - `E_PATH_CONFLICT`: a path is the wrong kind of entry, such as a file where Hack needs a directory, a directory where Hack needs a file (including a `.hack/.gitignore` that is a directory or a symlink), or a `.hack/.internal` that `hack env hydrate` finds to - be a symlink (dangling or not) or a file. A file name or path that is too long for - the filesystem is reported with this code too; use a shorter `--env` overlay name or + be a symlink (dangling or not). A file name or path that is too long for the + filesystem is reported with this code too; use a shorter `--env` overlay name or move the project to a shorter path. +- `E_CONFIG_INVALID`: `hack env hydrate` could not check its project inputs. A + `.hack/.internal` that is a file gives this code with `Cannot inspect Hack project + inputs. Check filesystem permissions and paths before retrying. Values omitted.`: + Hack cannot tell whether the private state it keeps inside `.hack/.internal` + exists, so it stops before hydrating instead of treating it as absent. Replace + the file with a directory (or remove it), then retry. - `E_LOCKED`: the project env key lock (`/.hack.secret.key.lock`, taken by the first `--secret` value) stayed held for the whole 10-second wait. Nothing was written. Another hack command may be creating the key; retry when it finishes. If no @@ -775,8 +781,10 @@ command is running, remove both and retry. `hack env hydrate` reports the same m when it cannot get the env file's lock. `.hack/.internal/` is ignored by the managed `.hack/.gitignore`, so lock files never show -in `git status`. If `.hack/.internal` is a symlink or not a directory, these commands -refuse instead of creating the lock through it. If you cannot write the directory that +in `git status`. If `.hack/.internal` is a symlink, dangling or not, these commands +refuse with one `E_PATH_CONFLICT` line instead of creating the lock through it. If it is a +regular file, they refuse with one `E_CONFIG_INVALID` line (`Cannot inspect Hack project +inputs...`) and write nothing. If you cannot write the directory that holds the env file (for example a read-only checkout), no lock is taken: no command can replace the file there, so a command that changes nothing still succeeds and one that would change the file is refused as before. diff --git a/docs/reference/native-compose-storage-xattr-carrier.md b/docs/reference/native-compose-storage-xattr-carrier.md index a53df64ac..03119f216 100644 --- a/docs/reference/native-compose-storage-xattr-carrier.md +++ b/docs/reference/native-compose-storage-xattr-carrier.md @@ -437,7 +437,11 @@ Budgets are layered and all finite: helpers on loaded hosted runners as `storage-helper-start`/`probe-timeout` while most of the 90-second budget remained (HACK-1369). A timeout that ends before the carrier's deadline still reports `probe-timeout`; one that reaches - it reports `storage-budget`. A recorded command's 15-second settlement budget + it reports `storage-budget`. A hung volume create or helper create/start is + bounded only by the carrier's remaining budget, so it always reports + `storage-budget` (for a start, `storage-helper-start`/`storage-budget`), never + `probe-timeout`: the remedy is the storage budget the summary names + (HACK-1459). A recorded command's 15-second settlement budget follows its exit. - An unrecorded command (an identity read or a legacy root/seed command) waits at most three seconds for its process group to become absent after exit, diff --git a/src/lib/env-layer-lock.ts b/src/lib/env-layer-lock.ts index 760bc6662..18c788e94 100644 --- a/src/lib/env-layer-lock.ts +++ b/src/lib/env-layer-lock.ts @@ -1,7 +1,7 @@ import { constants } from "node:fs"; import { access, lstat } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; -import { ClosedCliError } from "./cli-result.ts"; +import { ClosedCliError, HackCliError } from "./cli-result.ts"; import { closedFileWriteError, ensureDirForWrite } from "./fs.ts"; import { isRecord } from "./guards.ts"; import { @@ -65,14 +65,18 @@ function envLayerLockTimeoutMessage(opts: { } /** - * A `.internal` entry next to the layer that is a symlink or not a directory. - * The lock is never created through it. + * A `.internal` entry next to the layer that is a symlink (dangling or not) or + * not a directory. The lock is never created through it. Closed and + * value-free, with the stable `E_PATH_CONFLICT` code that `hack env hydrate` + * gives the same entry, so the CLI prints one `E_PATH_CONFLICT: ...` line and + * `--json` reports that code instead of `E_UNEXPECTED` (HACK-1459). */ -export class EnvLayerLockRefusedError extends ClosedCliError { +export class EnvLayerLockRefusedError extends HackCliError { constructor(opts: { readonly path: string }) { - super( - `Refused to update ${opts.path}: the .internal entry beside it is a symlink or not a directory, so its write lock cannot be taken safely. Replace it with a regular directory and retry. Nothing was written.` - ); + super({ + code: "E_PATH_CONFLICT", + message: `Refused to update ${opts.path}: the .internal entry beside it is a symlink or not a directory, so its write lock cannot be taken safely. Replace it with a regular directory and retry. Nothing was written.`, + }); this.name = "EnvLayerLockRefusedError"; } } diff --git a/src/lib/native-compose-command.ts b/src/lib/native-compose-command.ts index f53b82b3d..885421f85 100644 --- a/src/lib/native-compose-command.ts +++ b/src/lib/native-compose-command.ts @@ -108,6 +108,9 @@ import { NativeComposeOwnershipError, type NativeComposeOwnershipObservation, type NativeComposeOwnershipOptions, + nativeComposePreSpawnOwnershipTimeoutMs, + nativeComposeStopChildReserveMs, + nativeComposeStopOwnershipTimeoutMs, observeSavedNativeComposeOwned, waitNativeComposeStartupOwned, } from "./native-compose-ownership.ts"; @@ -437,6 +440,13 @@ async function ownershipSelection(opts: { ...(opts.operation === "down" && opts.recover ? { recovery: "down" as const } : {}), + ...(opts.operation === "down" + ? { + timeoutMs: nativeComposeStopOwnershipTimeoutMs( + resolveComposeStartupTimeoutMs() + ), + } + : {}), signal: opts.signal, }; } @@ -810,8 +820,8 @@ async function savedCommand(opts: { >; } = { files: null }; try { - const stopDeadline = - Date.now() + resolveComposeStartupTimeoutMs(); + const stopBudgetMs = resolveComposeStartupTimeoutMs(); + const stopDeadline = Date.now() + stopBudgetMs; const code = await runNativeComposeOwnedFileChild({ command: [ ...composeArgs(generation), @@ -820,6 +830,7 @@ async function savedCommand(opts: { ], signal, deadline: () => stopDeadline, + minimumChildMs: nativeComposeStopChildReserveMs(stopBudgetMs), arm: async () => { stopping.files = await prepareNativeComposeSavedFileStop({ mutation, @@ -827,7 +838,19 @@ async function savedCommand(opts: { saved, }); }, - assertOwned, + assertOwned: async (admittedMs) => { + await prepared.assertOwned({ + timeoutMs: nativeComposePreSpawnOwnershipTimeoutMs({ + stopBudgetMs, + admittedMs, + }), + }); + await assertNativeComposeSavedFileEngines({ + saved, + store, + signal, + }); + }, options: { cwd: base.cwd, env: base.env, @@ -1048,8 +1071,11 @@ async function prepareSavedStop(opts: { const hasDownHooks = Object.hasOwn(document, "x-hack-native-down-hooks"); let engineStopped = false; let storage: readonly NativeComposeRetainedVolume[] | null = null; - const assertOwned = async () => { - const observed = await assertNativeComposeOwned(selection); + const assertOwned = async (bound?: { readonly timeoutMs: number }) => { + const observed = await assertNativeComposeOwned({ + ...selection, + ...bound, + }); storage = observed.volumes; if ( engineStopped && diff --git a/src/lib/native-compose-file-command.ts b/src/lib/native-compose-file-command.ts index a42f41526..723efae85 100644 --- a/src/lib/native-compose-file-command.ts +++ b/src/lib/native-compose-file-command.ts @@ -57,7 +57,11 @@ type Saved = { * Cancellation never grants a later retry authority over an earlier armed child. */ export async function runNativeComposeOwnedFileChild(opts: { readonly arm: () => Promise; - readonly assertOwned: () => Promise; + /** + * Final guard. `admittedMs` is the time it may use and still leave the child + * {@link minimumChildMs}; a guard that takes longer is refused with no child. + */ + readonly assertOwned: (admittedMs: number) => Promise; readonly command: readonly string[]; readonly signal: AbortSignal; /** @@ -66,6 +70,8 @@ export async function runNativeComposeOwnedFileChild(opts: { * only by storage proof time that the guard excludes from the start budget. */ readonly deadline: () => number; + /** Least time before the deadline that admits the child; defaults to 0. */ + readonly minimumChildMs?: number; readonly options: Omit< RunOptions, "timeoutMs" | "beforeSpawn" | "onSpawn" | "onExit" @@ -82,15 +88,17 @@ export async function runNativeComposeOwnedFileChild(opts: { options.unsetEnvKeys = [...opts.options.unsetEnvKeys]; } const hooks = opts.hooks; + const minimumChildMs = opts.minimumChildMs ?? 0; + const admittedMs = () => deadline() - minimumChildMs - Date.now(); const requireAdmission = () => { - if (signal.aborted || Date.now() >= deadline()) { + if (signal.aborted || admittedMs() <= 0) { throw new NativeComposeGenerationError("E_NATIVE_COMPOSE_UNCERTAIN"); } }; requireAdmission(); await arm(); requireAdmission(); - await assertOwned(); + await assertOwned(admittedMs()); requireAdmission(); return await run(command, { ...options, diff --git a/src/lib/native-compose-ownership.ts b/src/lib/native-compose-ownership.ts index d26f4e3fc..8644e658e 100644 --- a/src/lib/native-compose-ownership.ts +++ b/src/lib/native-compose-ownership.ts @@ -16,6 +16,8 @@ const OUTPUT_LIMIT = 8 * 1024 * 1024; const STDERR_LIMIT = 16 * 1024; const ARGUMENT_LIMIT = 16 * 1024; const DEFAULT_TIMEOUT_MS = 15_000; +/** The most {@link createNativeComposeProbe} accepts for one acquisition. */ +const MAX_PROBE_TIMEOUT_MS = 60_000; const PROJECT_LABEL = "com.docker.compose.project"; const PREFIX = "io.hack.native-config"; @@ -1376,6 +1378,60 @@ function batches(resources: readonly Inventory[]): Inventory[][] { return result; } +/** + * Aggregate bound of one ownership query inside a native `down`, from the stop's + * budget (`HACK_COMPOSE_STARTUP_TIMEOUT_MS`). A fixed 15 s bound refused a healthy + * but slow engine as `probe-timeout` while most of that budget remained + * (HACK-1459). A shorter budget never shortens the query below the 15 s default, + * and a hung engine is still refused within the probe's 60 s ceiling. + */ +export function nativeComposeStopOwnershipTimeoutMs( + stopBudgetMs: number +): number { + return Math.min( + MAX_PROBE_TIMEOUT_MS, + Math.max(DEFAULT_TIMEOUT_MS, stopBudgetMs) + ); +} + +/** The most stop time {@link nativeComposeStopChildReserveMs} keeps for the child. */ +const MAX_STOP_CHILD_RESERVE_MS = 5000; + +/** The shortest budget accepted, which has no stop time to set aside. */ +const MIN_STOP_BUDGET_MS = 1000; + +/** + * Stop time kept for `compose down` itself: a fifth of the stop's budget above its + * 1 s minimum, at most 5 s. An engine that answers the last ownership check just + * before the deadline would otherwise start a child that is killed partway, with an + * incomplete stop. A short budget keeps nearly all of its time for that check. + */ +export function nativeComposeStopChildReserveMs(stopBudgetMs: number): number { + return Math.min( + MAX_STOP_CHILD_RESERVE_MS, + Math.floor(Math.max(0, stopBudgetMs - MIN_STOP_BUDGET_MS) / 5) + ); +} + +/** + * Bound of the ownership query just before `compose down` starts: the smaller of + * the probe timeout and the time admitted before the stop deadline, less the + * child's reserve. With no time left it is the probe's 1 ms minimum, which refuses + * as `probe-timeout` before any child runs. + */ +export function nativeComposePreSpawnOwnershipTimeoutMs(opts: { + readonly stopBudgetMs: number; + readonly admittedMs: number; +}): number { + return Math.max( + 1, + Math.min( + nativeComposeStopOwnershipTimeoutMs(opts.stopBudgetMs), + Math.floor(opts.admittedMs) + ) + ); +} + /** * One bounded aggregate Docker acquisition; callers supply only fixed read commands. * Lifecycle polling must issue a fresh owner for each capture, rather than reuse @@ -1393,7 +1449,11 @@ export function createNativeComposeProbe( ): (args: readonly string[]) => Promise { const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; if ( - !(Number.isSafeInteger(timeoutMs) && timeoutMs >= 1 && timeoutMs <= 60_000) + !( + Number.isSafeInteger(timeoutMs) && + timeoutMs >= 1 && + timeoutMs <= MAX_PROBE_TIMEOUT_MS + ) ) { probeRefused("E_NATIVE_COMPOSE_OWNERSHIP", "operation"); } diff --git a/tests/branches-effective-branch.test.ts b/tests/branches-effective-branch.test.ts index 038877831..336cb470e 100644 --- a/tests/branches-effective-branch.test.ts +++ b/tests/branches-effective-branch.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { resolveEffectiveBranch } from "../src/lib/branches.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; const tempDirs = new Set(); @@ -16,6 +17,7 @@ afterEach(async () => { async function runGit(args: readonly string[], cwd: string): Promise { const proc = Bun.spawn({ + env: fixtureGitEnv(), cmd: ["git", ...args], cwd, stdout: "pipe", diff --git a/tests/checkout-write-guard.test.ts b/tests/checkout-write-guard.test.ts index da162d093..78152c1a2 100644 --- a/tests/checkout-write-guard.test.ts +++ b/tests/checkout-write-guard.test.ts @@ -18,6 +18,7 @@ import { } from "./helpers/checkout-write-guard.ts"; import { expectClosedThrow } from "./helpers/closed-refusal.ts"; import { fixtureChildren } from "./helpers/fixture-children.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { requireFixtureRoot } from "./helpers/fixture-root.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; @@ -63,14 +64,12 @@ const fixture = (): string => async function run(opts: { readonly argv: readonly string[]; readonly cwd: string; - readonly env?: Readonly>; + /** The complete child environment; defaults to the private CLI env. */ + readonly env?: Record; }): Promise<{ readonly exitCode: number; readonly output: string }> { const child = Bun.spawn([...opts.argv], { cwd: opts.cwd, - env: privateCliEnv({ - root: fixture(), - env: { NO_COLOR: "1", ...opts.env }, - }), + env: opts.env ?? privateCliEnv({ root: fixture(), env: { NO_COLOR: "1" } }), stdin: "ignore", stdout: "pipe", stderr: "pipe", @@ -93,7 +92,11 @@ async function gitCheckout( ): Promise { const checkout = join(fixture(), name); await mkdir(checkout); - const init = await run({ argv: ["git", "init", "-q"], cwd: checkout }); + const init = await run({ + env: fixtureGitEnv(), + argv: ["git", "init", "-q"], + cwd: checkout, + }); expect(init.exitCode).toBe(0); const files = Object.keys(committed); if (files.length === 0) { @@ -103,11 +106,13 @@ async function gitCheckout( await writeFile(join(checkout, file), committed[file] ?? ""); } const add = await run({ + env: fixtureGitEnv(), argv: ["git", "add", "--", ...files], cwd: checkout, }); expect(add.exitCode).toBe(0); const commit = await run({ + env: fixtureGitEnv(), argv: [ "git", "-c", @@ -451,7 +456,10 @@ test( const result = await run({ argv: [process.execPath, "test"], cwd: checkout, - env: { PATH: `${bin}:${process.env.PATH ?? ""}` }, + env: privateCliEnv({ + root: fixture(), + env: { NO_COLOR: "1", PATH: `${bin}:${process.env.PATH ?? ""}` }, + }), }); expect(result.output).not.toContain("checkout write guard"); expect(result.output).not.toContain("dangling process"); diff --git a/tests/dependency-cache.test.ts b/tests/dependency-cache.test.ts index 36ee7076b..c10d4a29c 100644 --- a/tests/dependency-cache.test.ts +++ b/tests/dependency-cache.test.ts @@ -8,6 +8,7 @@ import { resolveDependencyCacheOverride, } from "../src/lib/dependency-cache.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; const tempDirs: string[] = []; @@ -184,6 +185,7 @@ test("compatible linked worktrees share the same declared runtime cache", async const project = await createProject(); const git = async (args: string[]) => { const child = Bun.spawn(["git", ...args], { + env: fixtureGitEnv(), cwd: project.projectRoot, stdout: "ignore", stderr: "pipe", diff --git a/tests/dispatch-e2e.test.ts b/tests/dispatch-e2e.test.ts index 5a81e855c..22a96a04c 100644 --- a/tests/dispatch-e2e.test.ts +++ b/tests/dispatch-e2e.test.ts @@ -9,6 +9,7 @@ import { import { findProjectContext } from "../src/lib/project.ts"; import { upsertProjectRegistration } from "../src/lib/projects-registry.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; const shouldRun = process.env.HACK_DISPATCH_E2E === "1"; @@ -521,7 +522,7 @@ async function runGit(input: { const result = await runCommand({ cmd: ["git", ...input.args], cwd: input.cwd, - env: childEnv({}), + env: fixtureGitEnv({ env: childEnv({}) }), }); if (result.exitCode !== 0) { throw new Error( diff --git a/tests/doctor-env-key.test.ts b/tests/doctor-env-key.test.ts index 05a9c8c7b..381a497a3 100644 --- a/tests/doctor-env-key.test.ts +++ b/tests/doctor-env-key.test.ts @@ -25,6 +25,7 @@ import { type InspectEnvKeyOptions, inspectProjectEnvKey, } from "../src/lib/doctor-env-key.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; const KEY_FILE = ".hack.secret.key"; const uid = typeof process.getuid === "function" ? process.getuid() : null; @@ -713,7 +714,7 @@ describe("gitListTrackedKeyPaths", () => { }; const git = async (args: readonly string[]) => { const proc = Bun.spawn(["git", "-C", root, ...args], { - env: gitEnv, + env: fixtureGitEnv({ env: gitEnv }), stdin: "ignore", stdout: "ignore", stderr: "ignore", @@ -780,6 +781,7 @@ describe("gitIsLocalKeyIgnored", () => { await withPrivateGitEnvironment(join(root, "home"), async () => { expect(await gitIsLocalKeyIgnored({ projectRoot: outside })).toBeNull(); const init = Bun.spawn(["git", "-C", repo, "init", "-q"], { + env: fixtureGitEnv(), stdin: "ignore", stdout: "ignore", stderr: "ignore", diff --git a/tests/doctor-generated-files.test.ts b/tests/doctor-generated-files.test.ts index 2e99f29ba..edf73952a 100644 --- a/tests/doctor-generated-files.test.ts +++ b/tests/doctor-generated-files.test.ts @@ -26,6 +26,7 @@ import { ensureHackDirGitignore, HackDirGitignoreRefusalError, } from "../src/lib/project-env-config.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; const tempDirs = new Set(); @@ -38,6 +39,7 @@ afterEach(async () => { async function runGit(args: readonly string[], cwd: string): Promise { const proc = Bun.spawn({ + env: fixtureGitEnv(), cmd: ["git", ...args], cwd, stdout: "pipe", diff --git a/tests/doctor-worktree.test.ts b/tests/doctor-worktree.test.ts index 97f84c0f9..e2cb6716f 100644 --- a/tests/doctor-worktree.test.ts +++ b/tests/doctor-worktree.test.ts @@ -12,6 +12,7 @@ import type { RuntimeContainer, RuntimeProject, } from "../src/lib/runtime-projects.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; const tempDirs = new Set(); @@ -24,6 +25,7 @@ afterEach(async () => { async function runGit(args: readonly string[], cwd: string): Promise { const proc = Bun.spawn({ + env: fixtureGitEnv(), cmd: ["git", ...args], cwd, stdout: "pipe", diff --git a/tests/e2e/fixture.ts b/tests/e2e/fixture.ts index 4c1ceebb3..b42e7495c 100644 --- a/tests/e2e/fixture.ts +++ b/tests/e2e/fixture.ts @@ -7,6 +7,7 @@ import { HACK_DIR_GITIGNORE_END_MARKER, HACK_DIR_GITIGNORE_ENTRIES, } from "../../src/templates.ts"; +import { fixtureGitEnv } from "../helpers/fixture-git.ts"; import { recordOwnedFixture } from "./docker-ownership.ts"; import { runCommand } from "./harness.ts"; @@ -144,6 +145,7 @@ export async function addLinkedWorktree(opts: { `${opts.fixture.name}-${opts.branch}` ); const result = await runCommand({ + exactEnv: fixtureGitEnv(), argv: [ "git", "-C", @@ -517,6 +519,7 @@ async function git(opts: { readonly args: readonly string[]; }): Promise { const result = await runCommand({ + exactEnv: fixtureGitEnv(), argv: [ "git", "-C", diff --git a/tests/e2e/harness.ts b/tests/e2e/harness.ts index 98642e8c4..6d7838cdc 100644 --- a/tests/e2e/harness.ts +++ b/tests/e2e/harness.ts @@ -26,6 +26,7 @@ import { LAUNCHD_ALLOW_ISOLATED_ENV, LAUNCHD_DISABLE_ENV, } from "../../src/daemon/launchd.ts"; +import { fixtureGitEnv } from "../helpers/fixture-git.ts"; import { type ComposeResource, @@ -560,13 +561,19 @@ async function readCapture(opts: { readonly path: string }): Promise { export async function runCommand(opts: { readonly argv: readonly string[]; readonly cwd: string; + /** Laid over the harness environment. Ignored when `exactEnv` is set. */ readonly env?: Readonly>; + /** + * The child's complete environment, with nothing from the harness. Fixture + * `git` passes `fixtureGitEnv()` here (tests/helpers/fixture-git.ts). + */ + readonly exactEnv?: Readonly>; readonly timeoutMs?: number; readonly onStderrChunk?: (chunk: string) => void; }): Promise { const proc = Bun.spawn([...opts.argv], { cwd: opts.cwd, - env: { ...process.env, ...(opts.env ?? {}) }, + env: opts.exactEnv ?? { ...process.env, ...(opts.env ?? {}) }, stdin: "ignore", stdout: "pipe", stderr: "pipe", @@ -855,6 +862,7 @@ async function writeCanaryProject(opts: { ["services: {}", ""].join("\n") ); await runCommand({ + exactEnv: fixtureGitEnv(), argv: ["git", "init", "-q", "-b", "main"], cwd: opts.projectRoot, }); diff --git a/tests/e2e/run-dependency-cache.test.ts b/tests/e2e/run-dependency-cache.test.ts index c60e4be62..792c8b3b4 100644 --- a/tests/e2e/run-dependency-cache.test.ts +++ b/tests/e2e/run-dependency-cache.test.ts @@ -9,6 +9,7 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { fixtureGitEnv } from "../helpers/fixture-git.ts"; import { privateCliEnv } from "../helpers/private-cli-env.ts"; const roots: string[] = []; @@ -66,6 +67,7 @@ volumes: await writeFile(join(primary, "bun.lock"), "lock-one\n"); const git = (args: string[]) => { const result = Bun.spawnSync(["git", "-C", primary, ...args], { + env: fixtureGitEnv(), stdout: "pipe", stderr: "pipe", }); diff --git a/tests/e2e/scenarios/agent-surface-freeze.ts b/tests/e2e/scenarios/agent-surface-freeze.ts index c3239ff82..36f8c5061 100644 --- a/tests/e2e/scenarios/agent-surface-freeze.ts +++ b/tests/e2e/scenarios/agent-surface-freeze.ts @@ -1,6 +1,6 @@ import { mkdir } from "node:fs/promises"; import { join, relative } from "node:path"; - +import { fixtureGitEnv } from "../../helpers/fixture-git.ts"; import { createMonorepoFixture, diffFileHashes, @@ -338,6 +338,7 @@ async function gitStatusPaths(opts: { readonly root: string; }): Promise { const status = await runCommand({ + exactEnv: fixtureGitEnv(), argv: [ "git", "-C", diff --git a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts index 441be3b1b..ac4a56a9f 100644 --- a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts @@ -13,6 +13,7 @@ import { isRecord } from "../../../src/lib/guards.ts"; import { createNativeComposeProbe } from "../../../src/lib/native-compose-ownership.ts"; import { setProjectEnvValue } from "../../../src/lib/project-env-config.ts"; import { buildRuntimeHostMetadataOverride } from "../../../src/lib/runtime-host-metadata.ts"; +import { fixtureGitEnv } from "../../helpers/fixture-git.ts"; import { addLinkedWorktree, commitAll, @@ -4316,6 +4317,7 @@ async function runBranchWorktrees(ctx: ScenarioContext) { refused(); } const switched = await runCommand({ + exactEnv: fixtureGitEnv(), argv: [ "git", "-C", diff --git a/tests/e2e/scenarios/portable-bootstrap-negatives.ts b/tests/e2e/scenarios/portable-bootstrap-negatives.ts index 80b66ca83..ce835cf28 100644 --- a/tests/e2e/scenarios/portable-bootstrap-negatives.ts +++ b/tests/e2e/scenarios/portable-bootstrap-negatives.ts @@ -1,6 +1,7 @@ import { randomBytes } from "node:crypto"; import { join } from "node:path"; import { encryptSynthetic } from "../../../scripts/portable-bootstrap-smoke.ts"; +import { fixtureGitEnv } from "../../helpers/fixture-git.ts"; import { commitAll, createMonorepoFixture } from "../fixture.ts"; import { type CliResult, @@ -196,6 +197,7 @@ export const portableBootstrapNegativesScenario: Scenario = { const git = async (args: readonly string[]) => { const result = await runCommand({ + exactEnv: fixtureGitEnv(), argv: ["git", "-C", fixture.root, ...args], cwd: fixture.root, }); diff --git a/tests/e2e/scenarios/worktree-branch-default.ts b/tests/e2e/scenarios/worktree-branch-default.ts index 9a187b662..f1650262d 100644 --- a/tests/e2e/scenarios/worktree-branch-default.ts +++ b/tests/e2e/scenarios/worktree-branch-default.ts @@ -1,3 +1,4 @@ +import { fixtureGitEnv } from "../../helpers/fixture-git.ts"; import { addLinkedWorktree, createMonorepoFixture } from "../fixture.ts"; import { expect, @@ -307,6 +308,7 @@ export const worktreeBranchDefaultScenario: Scenario = { }); const detach = await runCommand({ + exactEnv: fixtureGitEnv(), argv: ["git", "checkout", "--detach"], cwd: worktreePath, }); diff --git a/tests/e2e/scenarios/worktree-parallel-up.ts b/tests/e2e/scenarios/worktree-parallel-up.ts index 9c49c7a72..f6e1a3f41 100644 --- a/tests/e2e/scenarios/worktree-parallel-up.ts +++ b/tests/e2e/scenarios/worktree-parallel-up.ts @@ -1,3 +1,4 @@ +import { fixtureGitEnv } from "../../helpers/fixture-git.ts"; import { addLinkedWorktree, createMonorepoFixture } from "../fixture.ts"; import { expect, @@ -227,6 +228,7 @@ async function expectCleanGitStatus(opts: { readonly after: string; }): Promise { const status = await runCommand({ + exactEnv: fixtureGitEnv(), argv: ["git", "status", "--porcelain", "--untracked-files=all"], cwd: opts.cwd, }); diff --git a/tests/e2e/scenarios/worktree-secrets.ts b/tests/e2e/scenarios/worktree-secrets.ts index 2e0b646a2..b4e397848 100644 --- a/tests/e2e/scenarios/worktree-secrets.ts +++ b/tests/e2e/scenarios/worktree-secrets.ts @@ -1,5 +1,5 @@ import { join } from "node:path"; - +import { fixtureGitEnv } from "../../helpers/fixture-git.ts"; import { addLinkedWorktree, commitAll, @@ -140,6 +140,7 @@ async function assertNoDivergentKey(opts: { readonly worktreeRoot: string; }): Promise { const commonDirResult = await runCommand({ + exactEnv: fixtureGitEnv(), argv: ["git", "-C", opts.primaryRoot, "rev-parse", "--git-common-dir"], cwd: opts.primaryRoot, }); diff --git a/tests/env-get.test.ts b/tests/env-get.test.ts index 353d4b546..5152b59d6 100644 --- a/tests/env-get.test.ts +++ b/tests/env-get.test.ts @@ -15,6 +15,7 @@ import { resolveHackEnv } from "../src/lib/hack-env.ts"; import { setProjectEnvValue } from "../src/lib/project-env-config.ts"; import { resolveSecretStore } from "../src/lib/secret-store.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; const savedEnv = new Map(); let root: string; @@ -531,7 +532,12 @@ async function git(cwd: string, args: readonly string[]) { "core.hooksPath=/dev/null", ...args, ], - { cwd, stdout: "pipe", stderr: "pipe" } + { + env: fixtureGitEnv(), + cwd, + stdout: "pipe", + stderr: "pipe", + } ); const [code, stderr] = await Promise.all([ child.exited, diff --git a/tests/env-legacy-service-scope.test.ts b/tests/env-legacy-service-scope.test.ts index 32b883c03..e1a704abb 100644 --- a/tests/env-legacy-service-scope.test.ts +++ b/tests/env-legacy-service-scope.test.ts @@ -41,6 +41,7 @@ import { setProjectEnvValue, } from "../src/lib/project-env-config.ts"; import { resolveEnvMeta } from "../src/lib/project-meta.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; /** @@ -833,16 +834,7 @@ async function git(opts: { readonly args: readonly string[]; }): Promise { const proc = Bun.spawn(["git", "-C", opts.repo.projectRoot, ...opts.args], { - env: { - PATH: process.env.PATH ?? "/usr/bin:/bin", - HOME: opts.repo.root, - GIT_CONFIG_NOSYSTEM: "1", - GIT_CONFIG_GLOBAL: "/dev/null", - GIT_AUTHOR_NAME: "Synthetic", - GIT_AUTHOR_EMAIL: "synthetic@example.invalid", - GIT_COMMITTER_NAME: "Synthetic", - GIT_COMMITTER_EMAIL: "synthetic@example.invalid", - }, + env: fixtureGitEnv({ env: { HOME: opts.repo.root } }), stdin: "ignore", stdout: "pipe", stderr: "pipe", diff --git a/tests/env-set-local-contract.test.ts b/tests/env-set-local-contract.test.ts index 62f5de65e..114413c20 100644 --- a/tests/env-set-local-contract.test.ts +++ b/tests/env-set-local-contract.test.ts @@ -11,6 +11,7 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, relative, resolve } from "node:path"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; /** Contract migration must keep legacy plaintext machine-local (HACK-1712/1713). */ @@ -335,6 +336,7 @@ async function runDoctor(project: Project) { async function git(project: Project, args: string[]) { const proc = Bun.spawn(["git", ...args], { cwd: project.projectRoot, + env: fixtureGitEnv(), stdout: "pipe", stderr: "pipe", }); diff --git a/tests/fixture-hermeticity.test.ts b/tests/fixture-hermeticity.test.ts new file mode 100644 index 000000000..705945c3d --- /dev/null +++ b/tests/fixture-hermeticity.test.ts @@ -0,0 +1,457 @@ +import { afterEach, expect, setDefaultTimeout, test } from "bun:test"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, relative, resolve } from "node:path"; +import { restoreEnv } from "./helpers/env.ts"; +import { + FIXTURE_GIT_IDENTITY, + fixtureGitEnv, + fixtureGitShellEnv, + runFixtureGit, + runFixtureGitSync, +} from "./helpers/fixture-git.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +/** + * HACK-1459: test fixtures must not reach outside their temp roots through the + * caller's environment. A caller-exported `GIT_DIR` once made fixture `git` + * commit, branch and register worktrees in another repository, and a + * contributor's global `core.hooksPath` refused fixture commits. + */ + +// These cases run many git processes and read every test file. On a loaded host +// they passed Bun's default 5 s, and a test that outlives it fails after its root +// is gone. No assertion depends on this budget. +setDefaultTimeout(120_000); + +const roots: string[] = []; +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +async function scratchRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "hack-fixture-hermeticity-")); + roots.push(root); + return root; +} + +function git(cwd: string, args: readonly string[]): string { + const result = runFixtureGitSync({ cwd, args }); + expect(result.exitCode, result.stderr).toBe(0); + return result.stdout; +} + +/** Every file below `root`, with its bytes, so any write shows up. */ +function tree(root: string): Record { + const files: Record = {}; + const walk = (directory: string) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + walk(path); + } else { + files[relative(root, path)] = readFileSync(path).toString("base64"); + } + } + }; + walk(root); + return files; +} + +async function withCallerEnv( + env: Readonly>, + run: () => Promise +): Promise { + const saved = new Map(); + for (const [key, value] of Object.entries(env)) { + saved.set(key, process.env[key]); + process.env[key] = value; + } + try { + return await run(); + } finally { + for (const [key, value] of saved) { + restoreEnv(key, value); + } + } +} + +test("fixture git ignores a caller-exported GIT_DIR, work tree, index and global hooks", async () => { + const root = await scratchRoot(); + // The repository a caller's environment points at. + const scratch = join(root, "scratch"); + await mkdir(scratch); + git(scratch, ["init", "--quiet", "-b", "main"]); + await writeFile(join(scratch, "tracked.txt"), "scratch\n"); + git(scratch, ["add", "tracked.txt"]); + git(scratch, ["commit", "--quiet", "-m", "scratch"]); + const before = tree(scratch); + // A global config whose hook refuses every commit and leaves a marker. + const hooks = join(root, "hostile-hooks"); + const marker = join(root, "hostile-hook-ran"); + await mkdir(hooks); + for (const hook of ["pre-commit", "post-checkout", "reference-transaction"]) { + await writeFile( + join(hooks, hook), + `#!/bin/sh\necho ran >> ${JSON.stringify(marker)}\nexit 1\n` + ); + await chmod(join(hooks, hook), 0o755); + } + const globalConfig = join(root, "hostile-gitconfig"); + await writeFile( + globalConfig, + `[core]\n\thooksPath = ${hooks}\n[commit]\n\tgpgsign = true\n[user]\n\tname = Hostile\n\temail = hostile@example.invalid\n` + ); + + const fixture = join(root, "fixture"); + const linked = join(root, "linked"); + await mkdir(fixture); + await withCallerEnv( + { + GIT_DIR: join(scratch, ".git"), + GIT_WORK_TREE: scratch, + GIT_INDEX_FILE: join(scratch, ".git", "index"), + GIT_OBJECT_DIRECTORY: join(scratch, ".git", "objects"), + GIT_COMMON_DIR: join(scratch, ".git"), + GIT_CONFIG_GLOBAL: globalConfig, + GIT_AUTHOR_NAME: "Hostile", + GIT_COMMITTER_NAME: "Hostile", + }, + async () => { + // Every route a fixture uses: the sync and async runners, a raw spawn + // with the shared env, and the src/lib/shell.ts options. + git(fixture, ["init", "--quiet", "-b", "main"]); + await writeFile(join(fixture, "fixture.txt"), "fixture\n"); + expect( + (await runFixtureGit({ cwd: fixture, args: ["add", "fixture.txt"] })) + .exitCode + ).toBe(0); + const commit = Bun.spawnSync( + ["git", "commit", "--quiet", "-m", "fixture"], + { cwd: fixture, env: fixtureGitEnv(), stdout: "pipe", stderr: "pipe" } + ); + expect(commit.exitCode, commit.stderr.toString()).toBe(0); + const { env, unsetEnvKeys } = fixtureGitShellEnv(); + for (const key of [ + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_COMMON_DIR", + ]) { + expect(Object.hasOwn(env, key)).toBe(false); + expect(unsetEnvKeys).toContain(key); + } + git(fixture, ["worktree", "add", "--quiet", "-b", "feature", linked]); + } + ); + + // The fixture got its commit, identity and linked worktree... + expect(git(fixture, ["log", "-1", "--format=%an <%ae>|%cn"]).trim()).toBe( + `${FIXTURE_GIT_IDENTITY.name} <${FIXTURE_GIT_IDENTITY.email}>|${FIXTURE_GIT_IDENTITY.name}` + ); + expect(git(fixture, ["ls-files"]).trim()).toBe("fixture.txt"); + expect(git(linked, ["branch", "--show-current"]).trim()).toBe("feature"); + // ...and the repository the caller pointed at is byte-for-byte unchanged. + expect(tree(scratch)).toEqual(before); + expect(git(scratch, ["worktree", "list", "--porcelain"])).not.toContain( + linked + ); + expect(existsSync(marker)).toBe(false); +}); + +test("a fixture HOME's own global git config cannot add hooks, signing or identity", async () => { + const root = await scratchRoot(); + const home = join(root, "home"); + const hooks = join(root, "hooks"); + const marker = join(root, "hook-ran"); + await mkdir(home); + await mkdir(hooks); + await writeFile( + join(hooks, "pre-commit"), + `#!/bin/sh\necho ran >> ${JSON.stringify(marker)}\nexit 1\n` + ); + await chmod(join(hooks, "pre-commit"), 0o755); + await writeFile( + join(home, ".gitconfig"), + `[core]\n\thooksPath = ${hooks}\n[commit]\n\tgpgsign = true\n[user]\n\tname = Home\n\temail = home@example.invalid\n` + ); + const repo = join(root, "repo"); + await mkdir(repo); + const run = (args: readonly string[]) => { + const result = runFixtureGitSync({ cwd: repo, args, env: { HOME: home } }); + expect(result.exitCode, result.stderr).toBe(0); + return result.stdout; + }; + run(["init", "--quiet", "-b", "main"]); + await writeFile(join(repo, "file.txt"), "file\n"); + run(["add", "file.txt"]); + run(["commit", "--quiet", "-m", "fixture"]); + expect(run(["log", "-1", "--format=%ae"]).trim()).toBe( + FIXTURE_GIT_IDENTITY.email + ); + expect(existsSync(marker)).toBe(false); +}); + +test("fixture git and the private CLI env never carry an ambient Docker selection or runner home", async () => { + const root = await scratchRoot(); + await withCallerEnv( + { + DOCKER_HOST: `unix://${join(root, "missing-docker.sock")}`, + DOCKER_CONTEXT: "hack-test-missing-context", + XDG_CONFIG_HOME: join(root, "runner-xdg"), + }, + async () => { + // A caller that copies the runner's values into the fixture env. + const gitEnv = fixtureGitEnv({ + env: { + ...privateCliEnv({ root }), + DOCKER_HOST: process.env.DOCKER_HOST, + DOCKER_CONTEXT: process.env.DOCKER_CONTEXT, + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + }, + }); + expect(gitEnv.DOCKER_HOST).toBeUndefined(); + expect(gitEnv.DOCKER_CONTEXT).toBeUndefined(); + expect(gitEnv.XDG_CONFIG_HOME).toBeUndefined(); + // A private HOME passed on purpose is kept. + expect(gitEnv.HOME).toBe(join(root, "user-home")); + // A copy of the runner's own HOME is not. + expect( + fixtureGitEnv({ env: { HOME: process.env.HOME ?? "/" } }).HOME + ).toBeUndefined(); + const cliEnv = privateCliEnv({ root }); + expect(cliEnv.DOCKER_HOST).toBeUndefined(); + expect(cliEnv.DOCKER_CONTEXT).toBeUndefined(); + } + ); +}); + +test("a Git variable set on purpose passes through gitEnv but never overrides the hermetic settings", () => { + const env = fixtureGitEnv({ + env: { GIT_INDEX_FILE: "/caller/index" }, + gitEnv: { + GIT_INDEX_FILE: "/fixture/index", + GIT_CONFIG_GLOBAL: "/caller/gitconfig", + NOT_GIT: "ignored", + }, + }); + expect(env.GIT_INDEX_FILE).toBe("/fixture/index"); + expect(env.GIT_CONFIG_GLOBAL).toBe("/dev/null"); + expect(env.GIT_CONFIG_NOSYSTEM).toBe("1"); + expect(env.NOT_GIT).toBeUndefined(); +}); + +test("fixture git never starts detached automatic maintenance", async () => { + // A detached `git maintenance run --auto` after `commit` kept writing in the + // repository after the command returned (a flake in the GIT_DIR case above). + const root = await scratchRoot(); + for (const [key, value] of [ + ["maintenance.auto", "false"], + ["gc.auto", "0"], + ] as const) { + const result = runFixtureGitSync({ + cwd: root, + args: ["config", "--get", key], + }); + expect(result.stdout.trim()).toBe(value); + } +}); + +// ----------------------------------------------------------------------------- +// Every fixture `git` spawn in tests/ goes through the shared environment. +// ----------------------------------------------------------------------------- + +const TESTS_ROOT = resolve(import.meta.dir); +/** Files whose `["git"` text is data (scanner examples), not a spawn. */ +const NOT_SPAWNS: ReadonlyMap = new Map([ + [ + "cli-spawn-env-isolation.test.ts", + "holds spawn snippets as strings for the CLI spawn scanner", + ], + ["fixture-hermeticity.test.ts", "this scanner's own patterns"], + ["fixture-git.ts", "the shared helper, which builds the environment itself"], +]); +/** + * A literal `["git"` argv passed to a spawn call (`spawn`, `spawnSync`, `exec`, + * `execFile`, or `run` from src/lib/shell.ts), or held in a `cmd`/`argv` field. + * + * Known gap: an argv built in a separate variable and passed by name is not + * seen, because nothing at the call says it runs git. + */ +const GIT_SPAWN = + /(?:\b(?:spawn|spawnSync|exec|execFile|run)\(\s*|\b(?:cmd|argv)\s*:\s*)\[\s*"git"/g; +/** A Bun shell template that starts with git. It inherits the runner's env. */ +const GIT_SHELL_TEMPLATE = /\$`\s*git\b/g; +const SHARED_ENV = /fixtureGit(?:Shell)?Env\(|runFixtureGit(?:Sync)?\(/; +const SHELL_SHARED_ENV = /^\s*\.env\(\s*fixtureGitEnv\(/; + +/** The index just past the string or template literal that opens at `start`. */ +function skipLiteral(text: string, start: number): number { + const quote = text[start]; + let i = start + 1; + while (i < text.length) { + const c = text[i]; + if (c === "\\") { + i += 2; + } else if (c === quote) { + return i + 1; + } else if (quote === "`" && c === "$" && text[i + 1] === "{") { + i = closingIndex(text, i + 1) + 1; + } else { + i++; + } + } + return text.length; +} + +/** + * The index of the bracket that closes the one at `open`, ignoring brackets in + * string and template text. Unbalanced text gives the last index. + */ +function closingIndex(text: string, open: number): number { + let depth = 0; + let i = open; + while (i < text.length) { + const c = text[i] ?? ""; + if (c === '"' || c === "'" || c === "`") { + i = skipLiteral(text, i); + continue; + } + if ("({[".includes(c)) { + depth++; + } else if (")}]".includes(c)) { + depth--; + if (depth === 0) { + return i; + } + } + i++; + } + return text.length - 1; +} + +/** + * The text that must name the shared environment for one `GIT_SPAWN` match: a + * call's own parentheses, or for a `cmd`/`argv` field the object that holds it. + * A helper elsewhere in the surrounding function does not route this spawn. + */ +function spawnSite(text: string, at: number, matched: string): string { + const paren = matched.indexOf("("); + let start = at + paren; + if (paren < 0) { + let depth = 0; + for (start = at; start >= 0; start--) { + const c = text[start] ?? ""; + if (")}]".includes(c)) { + depth++; + } else if ("({[".includes(c)) { + if (depth === 0) { + break; + } + depth--; + } + } + } + return text.slice(start, closingIndex(text, start) + 1); +} + +/** Offsets of every git spawn in `text` that does not use the shared env. */ +function unroutedGitSpawns(text: string): readonly number[] { + const unrouted: number[] = []; + for (const match of text.matchAll(GIT_SPAWN)) { + const at = match.index ?? 0; + if (!SHARED_ENV.test(spawnSite(text, at, match[0]))) { + unrouted.push(at); + } + } + for (const match of text.matchAll(GIT_SHELL_TEMPLATE)) { + const at = match.index ?? 0; + const after = skipLiteral(text, at + 1); + if (!SHELL_SHARED_ENV.test(text.slice(after))) { + unrouted.push(at); + } + } + return unrouted; +} + +test("every fixture git spawn in tests/ uses the shared hermetic environment", () => { + const unrouted: string[] = []; + for (const file of new Bun.Glob("**/*.ts").scanSync(TESTS_ROOT)) { + const name = file.split("/").pop() ?? file; + if (NOT_SPAWNS.has(name) || file.startsWith("apps/")) { + continue; + } + const text = readFileSync(join(TESTS_ROOT, file), "utf8"); + for (const at of unroutedGitSpawns(text)) { + const line = text.slice(0, at).split("\n").length; + unrouted.push(`tests/${file}:${line}`); + } + } + expect(unrouted).toEqual([]); + // Positive control: the scan still finds a routed fixture spawn. + const routed = readFileSync( + join(TESTS_ROOT, "native-env-metadata.test.ts"), + "utf8" + ); + const sites = [...routed.matchAll(GIT_SPAWN)]; + expect(sites.length).toBeGreaterThan(0); + expect(unroutedGitSpawns(routed)).toEqual([]); +}); + +// Inline snippets, so the controls do not depend on any fixture staying as it is. +const UNROUTED_GIT_SNIPPETS: Readonly> = { + "a call beside a stored helper result": `test("x", () => { + const env = fixtureGitEnv(); + Bun.spawnSync(["git", "init"], { cwd, env: process.env }); + use(env); + });`, + "a call beside a routed call": `test("x", () => { + Bun.spawnSync(["git", "add", "."], { env: fixtureGitEnv() }); + Bun.spawn(["git", "init"], { env: process.env }); + });`, + "a helper in the next call's arguments": `Bun.spawnSync(["git", "init"]); + other(fixtureGitEnv());`, + "a closing parenthesis in the argv text": `Bun.spawnSync(["git", "log", "--format=%h)"], { + env: process.env, + }); + other(fixtureGitEnv());`, + "a run call from src/lib/shell.ts": `test("x", async () => { + const options = fixtureGitShellEnv(); + await run(["git", "init"], { cwd }); + use(options); + });`, + "an options object beside a helper": `test("x", () => { + const env = fixtureGitEnv(); + Bun.spawn({ cmd: ["git", "init"], env: process.env }); + use(env); + });`, + "a Bun shell template": `test("x", async () => { + const env = fixtureGitEnv(); + await $\`git init\`; + use(env); + });`, + "a Bun shell template with the runner's env": `await $\`git init\`.env(process.env); + other(fixtureGitEnv());`, +}; + +for (const [label, snippet] of Object.entries(UNROUTED_GIT_SNIPPETS)) { + test(`the git spawn scan flags ${label}`, () => { + expect(unroutedGitSpawns(snippet)).toHaveLength(1); + }); +} + +test("the git spawn scan accepts each routed form", () => { + for (const snippet of [ + 'Bun.spawnSync(["git", "init"], { env: fixtureGitEnv() });', + 'Bun.spawnSync(["git", "log", "--format=%h)"], { env: fixtureGitEnv({ env }) });', + 'await run(["git", "init"], { cwd, ...fixtureGitShellEnv() });', + 'Bun.spawn({ cmd: ["git", "init"], env: fixtureGitEnv() });', + "await $`git init`.env(fixtureGitEnv());", + ]) { + expect(unroutedGitSpawns(snippet)).toEqual([]); + } +}); diff --git a/tests/git-info-exclude.test.ts b/tests/git-info-exclude.test.ts index 1de846017..4801a12cb 100644 --- a/tests/git-info-exclude.test.ts +++ b/tests/git-info-exclude.test.ts @@ -22,6 +22,7 @@ import { renderHackGitInfoExcludeBlock, resetHackGitInfoExcludeCacheForTests, } from "../src/lib/git-info-exclude.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; let root: string; let repo: string; @@ -29,11 +30,13 @@ let repo: string; async function git(args: readonly string[], cwd: string): Promise { const proc = Bun.spawn(["git", ...args], { cwd, - env: { - PATH: process.env.PATH ?? "/usr/bin:/bin", - GIT_CONFIG_NOSYSTEM: "1", - HOME: root, - }, + env: fixtureGitEnv({ + env: { + PATH: process.env.PATH ?? "/usr/bin:/bin", + GIT_CONFIG_NOSYSTEM: "1", + HOME: root, + }, + }), stdin: "ignore", stdout: "ignore", stderr: "pipe", diff --git a/tests/git-registration-metadata.test.ts b/tests/git-registration-metadata.test.ts index 26a64a84e..0dd50976b 100644 --- a/tests/git-registration-metadata.test.ts +++ b/tests/git-registration-metadata.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; let fixture = ""; let env: Record; @@ -42,7 +43,10 @@ async function canUseReftable() { const child = Bun.spawn( ["git", "init", "--ref-format=reftable", join(root, "repo")], { - env: isolatedEnv(root), + env: fixtureGitEnv({ + env: isolatedEnv(root), + gitEnv: { GIT_CEILING_DIRECTORIES: root }, + }), stdin: "ignore", stdout: "pipe", stderr: "pipe", @@ -89,7 +93,10 @@ afterEach(async () => { async function git(repoRoot: string, args: readonly string[]) { const child = Bun.spawn(["git", "-C", repoRoot, ...args], { - env, + env: fixtureGitEnv({ + env, + gitEnv: { GIT_CEILING_DIRECTORIES: fixture }, + }), stdin: "ignore", stdout: "pipe", stderr: "pipe", diff --git a/tests/hack-gitignore.test.ts b/tests/hack-gitignore.test.ts index 514fce43f..787f9c728 100644 --- a/tests/hack-gitignore.test.ts +++ b/tests/hack-gitignore.test.ts @@ -28,6 +28,7 @@ import { HACK_DIR_GITIGNORE_END_MARKER, HACK_DIR_GITIGNORE_ENTRIES, } from "../src/templates.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; const tempDirs = new Set(); @@ -47,6 +48,7 @@ async function createTempDir(): Promise { async function runGit(args: readonly string[], cwd: string): Promise { const proc = Bun.spawn({ + env: fixtureGitEnv(), cmd: ["git", ...args], cwd, stdout: "pipe", @@ -74,6 +76,7 @@ async function gitCheckIgnore(opts: { readonly path: string; }): Promise { const proc = Bun.spawn({ + env: fixtureGitEnv(), cmd: ["git", "check-ignore", "-q", "--", opts.path], cwd: opts.repoRoot, stdout: "ignore", diff --git a/tests/helpers/checkout-write-guard.ts b/tests/helpers/checkout-write-guard.ts index a8756b97f..b51ee69fe 100644 --- a/tests/helpers/checkout-write-guard.ts +++ b/tests/helpers/checkout-write-guard.ts @@ -1,4 +1,5 @@ import { ClosedCliError } from "../../src/lib/cli-result.ts"; +import { GIT_REPOSITORY_ENV_KEYS } from "./fixture-git.ts"; /** * Checkout write guard (HACK-1343). @@ -164,6 +165,12 @@ export function installCheckoutWriteGuard(opts: { env[key] = value; } } + // Inspect the checkout that contains `start`, never a repository, work tree + // or index a caller exported (HACK-1459). The contributor's config stays, so + // their global excludes keep hiding the files they already ignore. + for (const key of GIT_REPOSITORY_ENV_KEYS) { + Reflect.deleteProperty(env, key); + } // Read-only status: never take the index lock a contributor's git may need. env.GIT_OPTIONAL_LOCKS = "0"; const ci = env.CI === "true" || env.CI === "1"; diff --git a/tests/helpers/fixture-git.ts b/tests/helpers/fixture-git.ts new file mode 100644 index 000000000..2d688736f --- /dev/null +++ b/tests/helpers/fixture-git.ts @@ -0,0 +1,232 @@ +/** + * The one environment every test-fixture `git` command runs with (HACK-1459). + * + * A fixture `git` that inherits the runner's environment is not hermetic: + * - `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE`, `GIT_OBJECT_DIRECTORY`, + * `GIT_COMMON_DIR` and the other repository variables redirect the command + * into whatever repository the caller exported. With `GIT_DIR` set to a real + * checkout, a fixture's `init`/`add`/`commit`/`worktree add` writes commits, + * branches, worktree registrations and files into that checkout. + * - The contributor's global and system config (`~/.gitconfig`, + * `$XDG_CONFIG_HOME/git/config`, `/etc/gitconfig`) can set `core.hooksPath` + * to a guard that refuses fixture commits, turn on commit signing, or change + * the default branch; `$XDG_CONFIG_HOME/git/ignore` and `attributes` change + * what `status` and `check-ignore` report. + * - Without an identity of its own, `commit` depends on the runner's + * `user.name`/`user.email`, which a private HOME or CI does not have. + * + * {@link fixtureGitEnv} therefore builds the child's environment from scratch: + * only `PATH` and `TMPDIR` come from the runner, then the values the caller + * passes, then every `GIT_*` variable is removed (including ones the caller's + * values copied from the runner) and `HOME`/`XDG_CONFIG_HOME` are dropped + * unless the caller passed them, and finally the fixed hermetic settings below + * are applied. `DOCKER_HOST` and `DOCKER_CONTEXT` never pass through either. + * + * Route every fixture `git` spawn through {@link fixtureGitEnv}, + * {@link runFixtureGitSync} or {@link runFixtureGit}; + * `tests/fixture-hermeticity.test.ts` refuses a test file that spawns `git` + * without one of them. + */ + +/** Author and committer identity of every fixture commit. */ +export const FIXTURE_GIT_IDENTITY = Object.freeze({ + name: "Hack Fixture", + email: "fixture@example.invalid", +}); + +/** Runner variables a fixture `git` inherits; nothing else is copied. */ +export const FIXTURE_GIT_INHERITED_ENV = ["PATH", "TMPDIR"] as const; + +/** + * Settings applied last, over the runner and the caller's values. Global and + * system config are disabled, the identity is fixed and Git never prompts. + * + * Automatic maintenance is off too. Otherwise `commit` starts a detached + * `git maintenance run --auto` that takes `objects/maintenance.lock` in the + * fixture repository after the command returned. That process outlives the + * fixture command, can still be writing when a test compares the repository + * or removes its temp root, and is a process the test never owns. + */ +export const FIXTURE_GIT_HERMETIC_ENV: Readonly> = + Object.freeze({ + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_COUNT: "2", + GIT_CONFIG_KEY_0: "maintenance.auto", + GIT_CONFIG_VALUE_0: "false", + GIT_CONFIG_KEY_1: "gc.auto", + GIT_CONFIG_VALUE_1: "0", + GIT_AUTHOR_NAME: FIXTURE_GIT_IDENTITY.name, + GIT_AUTHOR_EMAIL: FIXTURE_GIT_IDENTITY.email, + GIT_COMMITTER_NAME: FIXTURE_GIT_IDENTITY.name, + GIT_COMMITTER_EMAIL: FIXTURE_GIT_IDENTITY.email, + GIT_TERMINAL_PROMPT: "0", + }); + +/** + * Variables that select which repository, work tree, index or object store a + * `git` command uses instead of discovering it from its working directory. + * {@link fixtureGitEnv} removes these with every other `GIT_*` variable; a + * helper that must keep the contributor's config (the checkout write guard) + * removes only these. + */ +export const GIT_REPOSITORY_ENV_KEYS = Object.freeze([ + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_NAMESPACE", + "GIT_CEILING_DIRECTORIES", + "GIT_DISCOVERY_ACROSS_FILESYSTEM", + "GIT_PREFIX", + "GIT_INTERNAL_SUPER_PREFIX", + "GIT_QUARANTINE_PATH", + "GIT_REPLACE_REF_BASE", + "GIT_SHALLOW_FILE", + "GIT_GRAFT_FILE", +] as const); + +/** Runner variables that never reach a fixture `git`, even when passed in. */ +const DROPPED_KEYS = new Set(["DOCKER_HOST", "DOCKER_CONTEXT"]); +/** Dropped when they come from the runner; kept when the caller sets them. */ +const RUNNER_HOME_KEYS = ["HOME", "XDG_CONFIG_HOME"] as const; + +function isGitVariable(key: string): boolean { + return key.startsWith("GIT_"); +} + +/** + * Builds the complete environment for one fixture `git` command. + * + * `env` holds what the command needs beyond `PATH` and `TMPDIR`, such as a + * private `HOME`. An `undefined` value removes a key. Any `GIT_*` key in `env` + * is removed too, because callers often pass a copy of the runner's + * environment; pass `gitEnv` for a Git variable the test sets on purpose (for + * example `GIT_AUTHOR_DATE`). `gitEnv` cannot override the hermetic settings. + */ +export function fixtureGitEnv( + opts: { + readonly env?: Readonly>; + readonly gitEnv?: Readonly>; + } = {} +): Record { + const passed = opts.env ?? {}; + const env = withPassedEnv(inheritedEnv(), passed); + for (const key of Object.keys(env)) { + if (isGitVariable(key) || DROPPED_KEYS.has(key)) { + Reflect.deleteProperty(env, key); + } + } + for (const key of RUNNER_HOME_KEYS) { + const value = passed[key]; + // A value equal to the runner's own is a copy of the runner environment, + // not a fixture home. + if (value === undefined || value === process.env[key]) { + Reflect.deleteProperty(env, key); + } + } + for (const [key, value] of Object.entries(opts.gitEnv ?? {})) { + if (isGitVariable(key) && !Object.hasOwn(FIXTURE_GIT_HERMETIC_ENV, key)) { + env[key] = value; + } + } + return Object.assign(env, FIXTURE_GIT_HERMETIC_ENV); +} + +function inheritedEnv(): Record { + const env: Record = {}; + for (const key of FIXTURE_GIT_INHERITED_ENV) { + const value = process.env[key]; + if (typeof value === "string" && value.length > 0) { + env[key] = value; + } + } + return env; +} + +function withPassedEnv( + env: Record, + passed: Readonly> +): Record { + for (const [key, value] of Object.entries(passed)) { + if (value === undefined) { + Reflect.deleteProperty(env, key); + } else { + env[key] = value; + } + } + return env; +} + +/** + * Options for `run`/`exec` in src/lib/shell.ts, which lay `env` over the + * runner's whole `process.env`: `unsetEnvKeys` names every runner key that + * {@link fixtureGitEnv} did not set, so the child gets exactly that + * environment. Spread the result into the options right before the call. + */ +export function fixtureGitShellEnv( + opts: Parameters[0] = {} +): { + readonly env: Record; + readonly unsetEnvKeys: readonly string[]; +} { + const env = fixtureGitEnv(opts); + return { + env, + unsetEnvKeys: Object.keys(process.env).filter( + (key) => !Object.hasOwn(env, key) + ), + }; +} + +/** Options shared by {@link runFixtureGitSync} and {@link runFixtureGit}. */ +export type FixtureGitOptions = { + /** Working directory of the command; must be a fixture path. */ + readonly cwd: string; + readonly args: readonly string[]; + /** Extra environment; see {@link fixtureGitEnv}. */ + readonly env?: Readonly>; + readonly gitEnv?: Readonly>; +}; + +/** Result of one fixture `git` command. */ +export type FixtureGitResult = { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +}; + +function spawnOptions(opts: FixtureGitOptions) { + return { + cwd: opts.cwd, + env: fixtureGitEnv({ env: opts.env, gitEnv: opts.gitEnv }), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } as const; +} + +/** Runs one fixture `git` command synchronously. It does not throw on failure. */ +export function runFixtureGitSync(opts: FixtureGitOptions): FixtureGitResult { + const result = Bun.spawnSync(["git", ...opts.args], spawnOptions(opts)); + return { + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + }; +} + +/** Runs one fixture `git` command. It does not throw on failure. */ +export async function runFixtureGit( + opts: FixtureGitOptions +): Promise { + const child = Bun.spawn(["git", ...opts.args], spawnOptions(opts)); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + return { exitCode, stdout, stderr }; +} diff --git a/tests/helpers/secret-fixture.ts b/tests/helpers/secret-fixture.ts index d8829be1b..93c4ed632 100644 --- a/tests/helpers/secret-fixture.ts +++ b/tests/helpers/secret-fixture.ts @@ -1,5 +1,6 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; +import { fixtureGitEnv } from "./fixture-git.ts"; /** * Disposable Git fixtures for env-secret tests: a Hack project committed in a @@ -22,16 +23,18 @@ export function runFixtureGit(opts: { }): string { const result = Bun.spawnSync(["git", ...opts.args], { cwd: opts.cwd, - env: { - PATH: process.env.PATH ?? "/usr/bin:/bin", - HOME: opts.home, - GIT_CONFIG_NOSYSTEM: "1", - GIT_CONFIG_GLOBAL: "/dev/null", - GIT_AUTHOR_NAME: "Hack Test", - GIT_AUTHOR_EMAIL: "hack-test@example.invalid", - GIT_COMMITTER_NAME: "Hack Test", - GIT_COMMITTER_EMAIL: "hack-test@example.invalid", - }, + env: fixtureGitEnv({ + env: { + PATH: process.env.PATH ?? "/usr/bin:/bin", + HOME: opts.home, + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_AUTHOR_NAME: "Hack Test", + GIT_AUTHOR_EMAIL: "hack-test@example.invalid", + GIT_COMMITTER_NAME: "Hack Test", + GIT_COMMITTER_EMAIL: "hack-test@example.invalid", + }, + }), stdout: "pipe", stderr: "pipe", }); diff --git a/tests/internal-extra-hosts-worktree.test.ts b/tests/internal-extra-hosts-worktree.test.ts index 96dd82963..4ee15c06c 100644 --- a/tests/internal-extra-hosts-worktree.test.ts +++ b/tests/internal-extra-hosts-worktree.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { resolveInternalExtraHosts } from "../src/lib/internal-extra-hosts.ts"; import { resolvePrimaryLocalProjectDir } from "../src/lib/worktree-local-config.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; const roots: string[] = []; let savedCi: string | undefined; @@ -41,6 +42,7 @@ async function fixture() { await mkdir(primary); const git = async (...args: string[]) => { const child = Bun.spawn(["git", "-C", primary, ...args], { + env: fixtureGitEnv(), stdout: "pipe", stderr: "pipe", }); diff --git a/tests/lifecycle-git-status-clean.test.ts b/tests/lifecycle-git-status-clean.test.ts index 80fac8823..0858fb5d8 100644 --- a/tests/lifecycle-git-status-clean.test.ts +++ b/tests/lifecycle-git-status-clean.test.ts @@ -11,6 +11,7 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; /** @@ -63,7 +64,7 @@ function cliEnv(): Record { async function git(args: readonly string[], cwd: string): Promise { const proc = Bun.spawn(["git", ...args], { cwd, - env: cliEnv(), + env: fixtureGitEnv({ env: cliEnv() }), stdin: "ignore", stdout: "pipe", stderr: "pipe", @@ -280,7 +281,7 @@ test("doctor reports a missing .hack/.gitignore in an initialized project", asyn async function isIgnored(path: string, cwd: string): Promise { const proc = Bun.spawn(["git", "check-ignore", "-q", "--no-index", path], { cwd, - env: cliEnv(), + env: fixtureGitEnv({ env: cliEnv() }), stdin: "ignore", stdout: "ignore", stderr: "ignore", diff --git a/tests/lifecycle-json.test.ts b/tests/lifecycle-json.test.ts index 795a01445..54bceae6d 100644 --- a/tests/lifecycle-json.test.ts +++ b/tests/lifecycle-json.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { buildLifecycleJsonData } from "../src/commands/project.ts"; import { setLoggerBackendOverride } from "../src/ui/logger.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; type CapturedRunResult = { readonly exitCode: number; @@ -179,6 +180,7 @@ function runGit(opts: { readonly args: readonly string[]; }): void { const result = Bun.spawnSync(["git", "-C", opts.cwd, ...opts.args], { + env: fixtureGitEnv(), stdin: "ignore", stdout: "pipe", stderr: "pipe", diff --git a/tests/native-acquisition-project.test.ts b/tests/native-acquisition-project.test.ts index 05d6daf47..97cabeef9 100644 --- a/tests/native-acquisition-project.test.ts +++ b/tests/native-acquisition-project.test.ts @@ -16,6 +16,7 @@ import { validateNativeProject, } from "../src/lib/native-project-validation.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; const PROTOCOL = { transport_version: 1, @@ -166,6 +167,7 @@ async function managedValues( async function git(project: string, args: string[]) { const child = Bun.spawn(["git", "-C", project, ...args], { + env: fixtureGitEnv(), stdin: "ignore", stdout: "ignore", stderr: "ignore", diff --git a/tests/native-compose-adoption-env-inputs.test.ts b/tests/native-compose-adoption-env-inputs.test.ts index 6dcf1ed68..1f3cfa1c8 100644 --- a/tests/native-compose-adoption-env-inputs.test.ts +++ b/tests/native-compose-adoption-env-inputs.test.ts @@ -20,6 +20,7 @@ import { setProjectEnvValue, } from "../src/lib/project-env-config.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; const CANARY = "synthetic-adoption-env-value-never-public"; const KEY = "synthetic-adoption-key-not-real-credentials"; @@ -138,11 +139,13 @@ async function git(projectRoot: string, args: readonly string[]) { stdin: "ignore", stdout: "ignore", stderr: "ignore", - env: { - ...process.env, - GIT_CONFIG_GLOBAL: "/dev/null", - GIT_CONFIG_NOSYSTEM: "1", - }, + env: fixtureGitEnv({ + env: { + ...process.env, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + }, + }), }); expect(await child.exited).toBe(0); } diff --git a/tests/native-compose-file-owner.test.ts b/tests/native-compose-file-owner.test.ts index 8ae179804..7f50515cf 100644 --- a/tests/native-compose-file-owner.test.ts +++ b/tests/native-compose-file-owner.test.ts @@ -1,4 +1,10 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + afterEach, + beforeEach, + test as bunTest, + expect, + setDefaultTimeout, +} from "bun:test"; import { lstat, mkdir, @@ -70,6 +76,57 @@ const SOURCE = { }; const BYTES = Buffer.from([0, 255, 10]); const compiler = requireTestConfigCompiler(); +/** + * One budget for every case here (HACK-1460). A case opens a durable store, + * runs the real config compiler, stages fsynced material and often spawns a + * child Bun; the cancellation cases add durable arm/journal writes. The + * `up` cancellation case took 5.3 s on the macOS Intel runner against Bun's + * 5 s default, and other cases already needed 30 s. The cases' own product + * deadlines (for example the 10 s admission window) stay as they are, so a + * slow host still ends in the product's closed refusal before this expires. + */ +const FILE_OWNER_CASE_BUDGET_MS = 60_000; +setDefaultTimeout(FILE_OWNER_CASE_BUDGET_MS); +/** + * The body of the case that is running. Bun starts `afterEach` as soon as a + * case times out while the body keeps running, and the body reads the + * module-level `store`, `root` and `materialRoot`, which the next case's + * `beforeEach` replaces. A timed-out body could then take the next case's + * mutation lock or change its state and fail it as unsafe. `afterEach` therefore + * waits for the body to settle before it closes and removes anything, so the + * next case starts only after the previous body has finished. + */ +let runningCase: Promise | undefined; +function tracked( + body: (...args: A) => Promise | void +): (...args: A) => Promise { + return (...args) => { + const run = Promise.resolve().then(() => body(...args)); + runningCase = run; + return run; + }; +} +// A case's own timeout is honored but never drops below the file budget. +const test = Object.assign( + ( + name: string, + body: () => Promise | void, + timeoutMs = FILE_OWNER_CASE_BUDGET_MS + ) => { + bunTest( + name, + tracked(body), + Math.max(timeoutMs, FILE_OWNER_CASE_BUDGET_MS) + ); + }, + { + each: + (table: readonly T[]) => + (name: string, body: (row: T) => Promise | void) => { + bunTest.each([...table])(name, tracked(body)); + }, + } +); let parent = ""; let root = ""; let materialRoot = ""; @@ -151,6 +208,9 @@ beforeEach(async () => { stores.push(store); }); afterEach(async () => { + // A failure belongs to the case that already reported it. + await runningCase?.catch(() => undefined); + runningCase = undefined; await Promise.all(owners.splice(0).map((owner) => owner.close())); await Promise.all(sources.splice(0).map(closeNativeComposeFileSources)); await Promise.all(stores.splice(0).map((store) => store.close())); @@ -158,7 +218,7 @@ afterEach(async () => { restoreEnv(key, saved[key]); } await rm(parent, { recursive: true, force: true }); -}); +}, FILE_OWNER_CASE_BUDGET_MS); function ownerFor( mutation: NativeComposeMutation, opts: { root?: string; afterMemberUnlink?: () => Promise } = {} @@ -294,85 +354,78 @@ async function expectAbsent(paths: readonly string[]) { } } for (const operation of ["up", "down"] as const) { - test( - `cancellation during the last durable ${operation} arm never spawns a child and retains the exact pending material`, - async () => { - const initial = - operation === "down" ? await store.withMutation(running) : null; - await store.withMutation(async (mutation) => { - const selected = initial ?? (await staged(mutation)); - const owner = initial ? ownerFor(mutation) : selected.owner; - const controller = new AbortController(); - const marker = join(parent, "unexpected-child"); - const admittedUntil = Date.now() + 10_000; - await expect( - mutation.runEffect({ - generation: selected.generation, - operation, - assertFresh: operation === "up" ? selected.assertFresh : undefined, - assertOwned: async () => {}, - effect: async () => { - const input = { - command: [ - process.execPath, - "-e", - `await Bun.write(${JSON.stringify(marker)}, "effect")`, - ], - signal: controller.signal, - deadline: () => admittedUntil, - options: { - stdin: "ignore" as const, - stdout: "ignore" as const, - stderr: "ignore" as const, - }, - assertOwned: async () => {}, - arm: async () => { - if (operation === "up") { - await owner.arm({ - attempt: selected.attempt, - generation: selected.generation, - }); - } else { - expect( - await owner.armStop(selected.generation) - ).not.toBeNull(); - } - controller.abort(); - Object.assign(input, { - signal: new AbortController().signal, - deadline: () => admittedUntil + 50_000, + test(`cancellation during the last durable ${operation} arm never spawns a child and retains the exact pending material`, async () => { + const initial = + operation === "down" ? await store.withMutation(running) : null; + await store.withMutation(async (mutation) => { + const selected = initial ?? (await staged(mutation)); + const owner = initial ? ownerFor(mutation) : selected.owner; + const controller = new AbortController(); + const marker = join(parent, "unexpected-child"); + const admittedUntil = Date.now() + 10_000; + await expect( + mutation.runEffect({ + generation: selected.generation, + operation, + assertFresh: operation === "up" ? selected.assertFresh : undefined, + assertOwned: async () => {}, + effect: async () => { + const input = { + command: [ + process.execPath, + "-e", + `await Bun.write(${JSON.stringify(marker)}, "effect")`, + ], + signal: controller.signal, + deadline: () => admittedUntil, + options: { + stdin: "ignore" as const, + stdout: "ignore" as const, + stderr: "ignore" as const, + }, + assertOwned: async () => {}, + arm: async () => { + if (operation === "up") { + await owner.arm({ + attempt: selected.attempt, + generation: selected.generation, }); - }, - }; - const code = await runNativeComposeOwnedFileChild(input); - return { outcome: "complete", value: code }; - }, - }) - ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); - expect(await Bun.file(marker).exists()).toBe(false); - expect((await store.loadPending())?.generationId).toBe( - selected.generation.generationId - ); - expect( - (await store.readGenerationDocument(selected.generation))[ - NATIVE_COMPOSE_FILES_EXTENSION - ] - ).toEqual(selected.projection.reference); - const journal = await readFile( - journalPath(selected.projection), - "utf8" - ); - expect(journal).toContain( - operation === "up" ? '"phase":"armed"' : '"phase":"stop-armed"' - ); - expect(journal).not.toContain( - operation === "up" ? '"phase":"reaped"' : '"phase":"stop-reaped"' - ); - await expectPresent(memberPaths(selected.projection)); - }); - }, - operation === "down" ? 30_000 : 5000 - ); + } else { + expect( + await owner.armStop(selected.generation) + ).not.toBeNull(); + } + controller.abort(); + Object.assign(input, { + signal: new AbortController().signal, + deadline: () => admittedUntil + 50_000, + }); + }, + }; + const code = await runNativeComposeOwnedFileChild(input); + return { outcome: "complete", value: code }; + }, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + expect(await Bun.file(marker).exists()).toBe(false); + expect((await store.loadPending())?.generationId).toBe( + selected.generation.generationId + ); + expect( + (await store.readGenerationDocument(selected.generation))[ + NATIVE_COMPOSE_FILES_EXTENSION + ] + ).toEqual(selected.projection.reference); + const journal = await readFile(journalPath(selected.projection), "utf8"); + expect(journal).toContain( + operation === "up" ? '"phase":"armed"' : '"phase":"stop-armed"' + ); + expect(journal).not.toContain( + operation === "up" ? '"phase":"reaped"' : '"phase":"stop-reaped"' + ); + await expectPresent(memberPaths(selected.projection)); + }); + }); } test("actual acquisition stages binary and empty 0444 files outside checkout", async () => { await store.withMutation(async (mutation) => { @@ -475,7 +528,7 @@ test("literal dollar roots and targets are encoded once in binds while saved fil diagnostic?.("saved-ready", "end"); }); diagnostic?.("mutation", "end"); -}, 30_000); +}); test("actual renderer requires the exact live owner projection and preserves literal bind encoding once", async () => { await dollarFixture(); await store.withMutation(async (mutation) => { @@ -546,7 +599,7 @@ test("actual renderer requires the exact live owner projection and preserves lit renderNativeCompose({ ...options, fileProjection: projection }) ).toThrow(); }); -}, 30_000); +}); test("only the original live stop attempt may reap; copied and replacement-owner handles refuse", async () => { const selected = await store.withMutation(running); await store.withMutation(async (mutation) => { @@ -588,7 +641,7 @@ test("only the original live stop attempt may reap; copied and replacement-owner }); }); await expectAbsent(memberPaths(selected.projection)); -}, 30_000); +}); test("stop authority exists only within the exact live down effect; an unknown stop survives mutation revocation and cannot be handed off", async () => { const selected = await store.withMutation(running); let original: Awaited< @@ -653,7 +706,7 @@ test("stop authority exists only within the exact live down effect; an unknown s selected.generation.generationId ); await expectPresent(memberPaths(selected.projection)); -}, 30_000); +}); test("a known stop can re-arm after partial retirement intent and finish exact saved retry without recreating missing members", async () => { const selected = await store.withMutation(running); let unlinked = 0; @@ -716,7 +769,7 @@ test("a known stop can re-arm after partial retirement intent and finish exact s }); await expectAbsent(memberPaths(selected.projection)); expect(await store.loadPending()).toBeNull(); -}, 30_000); +}); test.each([ "extra-private-bind", "interpolated-bind", @@ -886,7 +939,7 @@ test("source-unavailable saved down retires only exact material before completed code: "E_NATIVE_COMPOSE_STATE", }); }); -}, 30_000); +}); test.each([ "absent-proof", "missing-before-intent", @@ -940,7 +993,7 @@ test.each([ expect((await store.loadCurrent()).stopped).toBe(false); await expectPresent(paths.slice(1)); }); -}, 30_000); +}); test("partial unlink interruption retains durable intent and exact pending reference; fresh saved mutation resumes without source", async () => { const selected = await store.withMutation(running); let unlinked = 0; @@ -998,7 +1051,7 @@ test("partial unlink interruption retains durable intent and exact pending refer expect((await store.loadCurrent()).pending).toBeNull(); expect((await store.loadCurrent()).stopped).toBe(true); }); -}, 30_000); +}); test("failure after retired marker but before receipt commit keeps exact recovery generation and retries marker idempotently", async () => { const selected = await store.withMutation(running); await store.withMutation(async (mutation) => { @@ -1048,7 +1101,7 @@ test("failure after retired marker but before receipt commit keeps exact recover expect((await store.loadCurrent()).stopped).toBe(true); await expectAbsent(memberPaths(selected.projection)); }); -}, 30_000); +}); test("uncertain after hook permits owned stop but retains material and blocks completion", async () => { let hooks = 0; const selected = await store.withMutation(async (mutation) => { @@ -1119,7 +1172,7 @@ test("uncertain after hook permits owned stop but retains material and blocks co await readFile(journalPath(selected.projection), "utf8") ).not.toContain('"phase":"retiring"'); }); -}, 30_000); +}); test("live unarmed rollback retires exact new files despite source change; armed or copied attempts cannot authorize it", async () => { await store.withMutation(async (mutation) => { @@ -1174,7 +1227,7 @@ test("ownership drift after material retirement blocks completed receipt and kee '"phase":"retired"' ); }); -}, 30_000); +}); test("absence proof drift during retirement preserves other members and pending recovery state", async () => { const selected = await store.withMutation(running); await store.withMutation(async (mutation) => { @@ -1211,7 +1264,7 @@ test("absence proof drift during retirement preserves other members and pending await readFile(journalPath(selected.projection), "utf8") ).not.toContain('"phase":"retired"'); }); -}, 30_000); +}); test("replacement retires obsolete current snapshot before receipt handoff while new pending material remains", async () => { await store.withMutation(async (mutation) => { @@ -1258,7 +1311,7 @@ test("replacement retires obsolete current snapshot before receipt handoff while ); await current.owner.assertSavedReady(current.generation); }); -}, 30_000); +}); test("a replacement member after partial retirement is never deleted on retry", async () => { const selected = await store.withMutation(running); const paths = memberPaths(selected.projection); @@ -1315,7 +1368,7 @@ test("a replacement member after partial retirement is never deleted on retry", await readFile(journalPath(selected.projection), "utf8") ).not.toContain('"phase":"retired"'); }); -}, 30_000); +}); // Budget: two owned mutations (acquire ~0.5 s, prepare ~0.5 s, publish/assert-fresh // ~1.5 s, arm/reap ~0.8 s, then the refused recovery stop) measured 5113 ms on the @@ -1390,7 +1443,7 @@ test("armed startup without durable reaped evidence cannot retire from container ); await expectPresent(memberPaths(selected.projection)); }); -}, 15_000); +}); test("known reaped readiness failure retains pending material until a separate verified stop retires it", async () => { const selected = await store.withMutation(async (mutation) => { const selected = await staged(mutation); @@ -1438,4 +1491,4 @@ test("known reaped readiness failure retains pending material until a separate v expect((await store.loadCurrent()).stopped).toBe(true); await expectAbsent(memberPaths(selected.projection)); }); -}, 30_000); +}); diff --git a/tests/native-compose-file-sources.test.ts b/tests/native-compose-file-sources.test.ts index dd28c2fc2..bc1d97381 100644 --- a/tests/native-compose-file-sources.test.ts +++ b/tests/native-compose-file-sources.test.ts @@ -36,6 +36,7 @@ import { import { setProjectEnvValue } from "../src/lib/project-env-config.ts"; import { resolveVerifiedPrimaryWorktreeRoot } from "../src/lib/worktree-local-config.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { requireTestConfigCompiler } from "./helpers/test-config-compiler.ts"; const KEYS = [ @@ -522,6 +523,7 @@ test("managed secret file decrypts only through the existing project key owner, }); async function git(args: readonly string[]): Promise { const child = Bun.spawn(["git", "-C", root, ...args], { + env: fixtureGitEnv(), stdin: "ignore", stdout: "ignore", stderr: "ignore", diff --git a/tests/native-compose-generation.test.ts b/tests/native-compose-generation.test.ts index 4cf221084..f20b4e6c1 100644 --- a/tests/native-compose-generation.test.ts +++ b/tests/native-compose-generation.test.ts @@ -34,6 +34,7 @@ import { type NativeComposeRouteClaims, openNativeComposeRouteClaims, } from "../src/lib/native-compose-route-claims.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; const REVISION = createHash("sha256").update("synthetic input").digest("hex"); const fixtures: string[] = []; @@ -566,100 +567,107 @@ async function routeStore( return result; } -test.each([ - "complete", - "failed", -] as const)("dependent route finalizer %s preserves receipt ordering and hostname handoff", async (outcome) => { - const root = await fixture(); - const owner = await store(root); - const old = await activate(owner); - const claims = await routeStore(root, owner.identity); - const foreign = await routeStore(root, { - composeProject: "foreign-finalizer-fixture", - ownerToken: "e".repeat(32), - }); - const oldHost = "old.fixture.test"; - const newHost = "new.fixture.test"; - const oldClaim = await claims.acquire({ - hostnames: [oldHost], - generationIdentity: old.generationId, - }); - await claims.markEffectsPossible(oldClaim); - await claims.complete({ - attempt: oldClaim, - assertTransition: async () => {}, - }); - let ownershipChecks = 0; - await owner.withMutation(async (mutation) => { - const generation = await publish(mutation); - const attempt = await claims.acquire({ - hostnames: [oldHost, newHost], - generationIdentity: generation.generationId, +/** + * Each case opens three private stores and takes real route claims through + * acquire, complete and hostname handoff. Under load the cases went past Bun's + * 5 s default in 2 of 10 runs (HACK-1459). + */ +const DEPENDENT_ROUTE_FINALIZER_BUDGET_MS = 30_000; +test.each(["complete", "failed"] as const)( + "dependent route finalizer %s preserves receipt ordering and hostname handoff", + async (outcome) => { + const root = await fixture(); + const owner = await store(root); + const old = await activate(owner); + const claims = await routeStore(root, owner.identity); + const foreign = await routeStore(root, { + composeProject: "foreign-finalizer-fixture", + ownerToken: "e".repeat(32), }); - const effect = mutation.runEffect({ - generation, - operation: "up", - assertFresh: async () => {}, - assertOwned: async () => { - ownershipChecks += 1; - }, - effect: async () => { - await claims.markEffectsPossible(attempt); - return { outcome: "complete", value: 0 }; - }, - beforeComplete: async () => { - const pending = await owner.loadCurrent(); - expect(pending.generation?.generationId).toBe(old.generationId); - expect(pending.pending?.generationId).toBe(generation.generationId); - expect(ownershipChecks).toBe(3); - await claims.complete({ - attempt, - assertTransition: async () => { - if (outcome === "failed") { - throw new Error("private-finalizer-canary"); - } - }, - }); - await claims.release({ - keepHostnames: [newHost], - assertAbsent: async () => {}, - }); - const handoff = await foreign.acquire({ - hostnames: [oldHost], - generationIdentity: "f".repeat(32), - }); - await foreign.rollback(handoff); - }, + const oldHost = "old.fixture.test"; + const newHost = "new.fixture.test"; + const oldClaim = await claims.acquire({ + hostnames: [oldHost], + generationIdentity: old.generationId, }); - if (outcome === "failed") { - await rejected(effect, "E_NATIVE_COMPOSE_UNCERTAIN"); - await claims.retain(attempt); - expect((await claims.reopen(attempt.reference)).phase).toBe("retained"); - for (const hostname of [oldHost, newHost]) { - await expect( - foreign.acquire({ - hostnames: [hostname], + await claims.markEffectsPossible(oldClaim); + await claims.complete({ + attempt: oldClaim, + assertTransition: async () => {}, + }); + let ownershipChecks = 0; + await owner.withMutation(async (mutation) => { + const generation = await publish(mutation); + const attempt = await claims.acquire({ + hostnames: [oldHost, newHost], + generationIdentity: generation.generationId, + }); + const effect = mutation.runEffect({ + generation, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => { + ownershipChecks += 1; + }, + effect: async () => { + await claims.markEffectsPossible(attempt); + return { outcome: "complete", value: 0 }; + }, + beforeComplete: async () => { + const pending = await owner.loadCurrent(); + expect(pending.generation?.generationId).toBe(old.generationId); + expect(pending.pending?.generationId).toBe(generation.generationId); + expect(ownershipChecks).toBe(3); + await claims.complete({ + attempt, + assertTransition: async () => { + if (outcome === "failed") { + throw new Error("private-finalizer-canary"); + } + }, + }); + await claims.release({ + keepHostnames: [newHost], + assertAbsent: async () => {}, + }); + const handoff = await foreign.acquire({ + hostnames: [oldHost], generationIdentity: "f".repeat(32), - }) - ).rejects.toThrow(); + }); + await foreign.rollback(handoff); + }, + }); + if (outcome === "failed") { + await rejected(effect, "E_NATIVE_COMPOSE_UNCERTAIN"); + await claims.retain(attempt); + expect((await claims.reopen(attempt.reference)).phase).toBe("retained"); + for (const hostname of [oldHost, newHost]) { + await expect( + foreign.acquire({ + hostnames: [hostname], + generationIdentity: "f".repeat(32), + }) + ).rejects.toThrow(); + } + await rejected(publish(mutation), "E_NATIVE_COMPOSE_UNCERTAIN"); + } else { + expect(await effect).toEqual({ outcome: "complete", value: 0 }); } - await rejected(publish(mutation), "E_NATIVE_COMPOSE_UNCERTAIN"); - } else { - expect(await effect).toEqual({ outcome: "complete", value: 0 }); - } - const saved = await owner.loadCurrent(); - expect(saved.generation?.generationId).toBe( - outcome === "complete" ? generation.generationId : old.generationId - ); - expect(saved.pending?.generationId ?? null).toBe( - outcome === "complete" ? null : generation.generationId - ); - expect(ownershipChecks).toBe(outcome === "complete" ? 4 : 3); - expect(await Bun.file(receiptPath(owner)).text()).not.toContain( - "private-finalizer-canary" - ); - }); -}); + const saved = await owner.loadCurrent(); + expect(saved.generation?.generationId).toBe( + outcome === "complete" ? generation.generationId : old.generationId + ); + expect(saved.pending?.generationId ?? null).toBe( + outcome === "complete" ? null : generation.generationId + ); + expect(ownershipChecks).toBe(outcome === "complete" ? 4 : 3); + expect(await Bun.file(receiptPath(owner)).text()).not.toContain( + "private-finalizer-canary" + ); + }); + }, + DEPENDENT_ROUTE_FINALIZER_BUDGET_MS +); test.each([ "incomplete", @@ -2378,40 +2386,58 @@ for (const unsafe of [ }); } -test("real linked worktree verifies shared repository but keeps checkout instance/data namespace separate", async () => { - const root = await fixture(); - const git = async (args: readonly string[]) => { - const child = Bun.spawn(["git", "-C", root, ...args], { - stdin: "ignore", - stdout: "ignore", - stderr: "pipe", - }); - expect(await child.exited).toBe(0); - }; - await git(["init", "-q"]); - await git(["add", ".hack/hack.project.json"]); - await git([ - "-c", - "user.name=Fixture", - "-c", - "user.email=fixture@example.invalid", - "commit", - "-qm", - "fixture", - ]); - const linkedRoot = join(dirname(root), "linked"); - await git(["worktree", "add", "-q", "-b", "feature", linkedRoot]); - const primary = await store(root); - const linked = await store(linkedRoot, "feature"); - expect(primary.identity.repositoryRoot).toBe(linked.identity.repositoryRoot); - expect(primary.identity.checkoutRoot).not.toBe(linked.identity.checkoutRoot); - expect(primary.identity.composeProject).not.toBe( - linked.identity.composeProject - ); - const primaryGeneration = await activate(primary); - const linkedGeneration = await activate(linked); - expect(primaryGeneration.composeFile).not.toBe(linkedGeneration.composeFile); -}); +/** + * Four fixture `git` processes, then two stores whose identity verifies the + * linked worktree with the product's own `git` reads, and two activations. It + * took 2-4 s on an idle host and more than Bun's 5 s default at load average + * above 100 (HACK-1459). + */ +const LINKED_WORKTREE_BUDGET_MS = 30_000; +test( + "real linked worktree verifies shared repository but keeps checkout instance/data namespace separate", + async () => { + const root = await fixture(); + const git = async (args: readonly string[]) => { + const child = Bun.spawn(["git", "-C", root, ...args], { + env: fixtureGitEnv(), + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }); + expect(await child.exited).toBe(0); + }; + await git(["init", "-q"]); + await git(["add", ".hack/hack.project.json"]); + await git([ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "-qm", + "fixture", + ]); + const linkedRoot = join(dirname(root), "linked"); + await git(["worktree", "add", "-q", "-b", "feature", linkedRoot]); + const primary = await store(root); + const linked = await store(linkedRoot, "feature"); + expect(primary.identity.repositoryRoot).toBe( + linked.identity.repositoryRoot + ); + expect(primary.identity.checkoutRoot).not.toBe( + linked.identity.checkoutRoot + ); + expect(primary.identity.composeProject).not.toBe( + linked.identity.composeProject + ); + const primaryGeneration = await activate(primary); + const linkedGeneration = await activate(linked); + expect(primaryGeneration.composeFile).not.toBe( + linkedGeneration.composeFile + ); + }, + LINKED_WORKTREE_BUDGET_MS +); test("orphan recovery guard with absent mutation lock blocks admission and explicit recovery", async () => { const root = await fixture(); diff --git a/tests/native-compose-stop-admission.test.ts b/tests/native-compose-stop-admission.test.ts new file mode 100644 index 000000000..b0e92a407 --- /dev/null +++ b/tests/native-compose-stop-admission.test.ts @@ -0,0 +1,252 @@ +import { expect, test } from "bun:test"; +import { chmod, mkdtemp, rename, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runNativeComposeOwnedFileChild } from "../src/lib/native-compose-file-command.ts"; +import { + nativeComposePreSpawnOwnershipTimeoutMs, + nativeComposeStopChildReserveMs, +} from "../src/lib/native-compose-ownership.ts"; +import { fixture, invoke } from "./helpers/native-compose-command.ts"; + +/** + * In a native `down` the stop deadline is fixed first, and the last ownership + * check runs after it. That check must not use up the time `compose down` needs: + * an engine that answered at the last moment started a child that was killed + * partway, where a clean refusal before any child is the safe result. + */ + +/** Generous budget for the commands that set up each case. */ +const SETUP_BUDGET_MS = 90_000; +const COMMAND_KILL_MS = 150_000; +/** Budget of the stop under test, which keeps a 2.8 s reserve for the child. */ +const STOP_BUDGET_MS = 15_000; +/** + * How long the check takes, whatever the host's load: longer than the 12.2 s it + * may use, and short enough to end inside the budget. Without the reserve, the + * check succeeds and `compose down` starts with too little time. + */ +const SLOW_CHECK_MS = 13_000; +/** Each case runs a few cold source CLI processes with synthetic Docker. */ +const CASE_BUDGET_MS = 300_000; + +test("the stop keeps a fifth of its budget above 1 s for the child, at most 5 s", () => { + expect(nativeComposeStopChildReserveMs(1000)).toBe(0); + expect(nativeComposeStopChildReserveMs(6000)).toBe(1000); + expect(nativeComposeStopChildReserveMs(20_000)).toBe(3800); + expect(nativeComposeStopChildReserveMs(26_000)).toBe(5000); + expect(nativeComposeStopChildReserveMs(90_000)).toBe(5000); +}); + +test("the last ownership check uses the smaller of the probe bound and the admitted time", () => { + const bound = nativeComposePreSpawnOwnershipTimeoutMs; + // The reviewed case: a 20 s budget with 16.2 s admitted. + expect(bound({ stopBudgetMs: 20_000, admittedMs: 16_200 })).toBe(16_200); + // The probe's own bound still wins when more time is admitted. + expect(bound({ stopBudgetMs: 90_000, admittedMs: 85_000 })).toBe(60_000); + expect(bound({ stopBudgetMs: 10_000, admittedMs: 9000 })).toBe(9000); + // No time left stays a valid probe bound, which refuses at once. + expect(bound({ stopBudgetMs: 5000, admittedMs: 0 })).toBe(1); + expect(bound({ stopBudgetMs: 5000, admittedMs: -40 })).toBe(1); +}); + +/** A child that leaves a marker, so a case can prove whether it started. */ +async function childCase( + run: (child: { + readonly command: readonly string[]; + readonly options: { readonly cwd: string }; + readonly started: () => Promise; + }) => Promise +) { + const root = await mkdtemp(join(tmpdir(), "hack-stop-admission-")); + const marker = join(root, "child-started"); + try { + await run({ + command: [ + process.execPath, + "-e", + `await Bun.write(${JSON.stringify(marker)}, "")`, + ], + options: { cwd: root }, + started: () => Bun.file(marker).exists(), + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +test("the final guard is told the time that still leaves the child its minimum", async () => { + await childCase(async ({ command, options, started }) => { + const before = Date.now(); + const deadline = before + 60_000; + let admitted = 0; + const code = await runNativeComposeOwnedFileChild({ + command, + options, + arm: async () => {}, + assertOwned: async (admittedMs) => { + admitted = admittedMs; + }, + signal: new AbortController().signal, + deadline: () => deadline, + minimumChildMs: 10_000, + }); + expect(code).toBe(0); + expect(await started()).toBe(true); + expect(admitted).toBeLessThanOrEqual(50_000); + expect(admitted).toBeGreaterThan(0); + }); +}); + +test("a final guard that leaves the child less than its minimum starts no child", async () => { + await childCase(async ({ command, options, started }) => { + let deadline = Date.now() + 60_000; + let guarded = false; + await expect( + runNativeComposeOwnedFileChild({ + command, + options, + arm: async () => {}, + assertOwned: async () => { + guarded = true; + // The guard returns before the deadline, inside the child's minimum. + deadline = Date.now() + 5000; + }, + signal: new AbortController().signal, + deadline: () => deadline, + minimumChildMs: 10_000, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + expect(guarded).toBe(true); + expect(await started()).toBe(false); + }); +}); + +/** Docker reads and calls recorded by the stand-in, oldest first. */ +async function requests(root: string): Promise { + const text = await Bun.file(join(root, "requests")).text(); + return text + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as string[]); +} + +const isContainerList = (args: readonly string[]) => + args[0] === "container" && args[1] === "ls"; +const isComposeDown = (args: readonly string[]) => + args[0] === "compose" && args.includes("down"); + +async function runningFixture(): Promise { + const root = await fixture("", false, { noHooks: true }); + const up = await invoke( + root, + ["up", "--detach", "--json"], + SETUP_BUDGET_MS, + COMMAND_KILL_MS + ); + expect(up.code, up.stderr).toBe(0); + return root; +} + +/** The 1-based first and last Docker reads of a healthy `down`'s last check. */ +async function lastCheckReads(): Promise<{ + readonly first: number; + readonly last: number; +}> { + const root = await runningFixture(); + const seen = (await requests(root)).length; + const down = await invoke( + root, + ["down", "--json"], + SETUP_BUDGET_MS, + COMMAND_KILL_MS + ); + expect(down.code, down.stderr).toBe(0); + const calls = (await requests(root)).slice(seen); + const stop = calls.findIndex(isComposeDown); + expect(stop).toBeGreaterThan(0); + // The effect's own ownership query, then the last check under test. + const before = calls.slice(0, stop); + expect(before.filter(isContainerList).length).toBeGreaterThanOrEqual(2); + return { + first: before.findLastIndex(isContainerList) + 1, + last: before.length, + }; +} + +/** + * Holds the check's last Docker read until the check has taken + * {@link SLOW_CHECK_MS} since its first read, then runs the stand-in as usual. The + * marker file proves the hold ran, so the case cannot pass without it. + */ +async function slowLastCheck( + root: string, + reads: { readonly first: number; readonly last: number } +) { + const docker = join(root, "docker"); + const standIn = join(root, "docker-stand-in"); + const counter = join(root, "docker-reads"); + const began = join(root, "docker-check-began"); + const delayed = join(root, "docker-delay-ran"); + await rename(docker, standIn); + await Bun.write( + docker, + `#!${process.execPath} +const args=process.argv.slice(2); +const file=Bun.file(${JSON.stringify(counter)}); +const seen=(await file.exists()?Number(await file.text()):0)+1; +await Bun.write(${JSON.stringify(counter)},String(seen)); +if(seen===${reads.first})await Bun.write(${JSON.stringify(began)},String(Date.now())); +if(seen===${reads.last}){await Bun.write(${JSON.stringify(delayed)},"");await Bun.sleep(Number(await Bun.file(${JSON.stringify(began)}).text())+${SLOW_CHECK_MS}-Date.now());} +const child=Bun.spawn([${JSON.stringify(standIn)},...args],{stdio:["inherit","inherit","inherit"]}); +process.exit(await child.exited); +` + ); + await chmod(docker, 0o700); + return delayed; +} + +test( + "a slow last ownership check refuses down before compose down, and explicit recovery completes the stop", + async () => { + const reads = await lastCheckReads(); + const root = await runningFixture(); + const delayed = await slowLastCheck(root, reads); + const seen = (await requests(root)).length; + + const refused = await invoke( + root, + ["down", "--json"], + STOP_BUDGET_MS, + COMMAND_KILL_MS + ); + + expect(await Bun.file(delayed).exists()).toBe(true); + expect(refused.code).toBe(1); + expect(JSON.parse(refused.stdout)).toMatchObject({ + ok: false, + error: { + detail: { + native_compose_effect_refusal: { + stage: "effect-execution", + reason: "probe-timeout", + }, + }, + }, + }); + const calls = (await requests(root)).slice(seen); + expect(calls.some(isComposeDown)).toBe(false); + expect(await Bun.file(join(root, "engine")).exists()).toBe(true); + + // The saved instance stays stoppable: explicit recovery completes the stop. + const retried = await invoke( + root, + ["down", "--recover", "--json"], + SETUP_BUDGET_MS, + COMMAND_KILL_MS + ); + expect(retried.code, retried.stdout).toBe(0); + expect(await Bun.file(join(root, "engine")).exists()).toBe(false); + }, + CASE_BUDGET_MS +); diff --git a/tests/native-compose-storage-probe-budget.test.ts b/tests/native-compose-storage-probe-budget.test.ts index 2ba6f53c6..c048f92a3 100644 --- a/tests/native-compose-storage-probe-budget.test.ts +++ b/tests/native-compose-storage-probe-budget.test.ts @@ -755,7 +755,9 @@ test("a helper start that outlasts the whole storage budget still refuses at the const { engine, result, error } = await proveWithSlowStart({ budgetMs }); expect(result).toBeNull(); // The start was granted only the carrier's remaining budget; its timeout - // lands at the carrier's end and names that exhausted budget. + // lands at the carrier's end and names that exhausted budget. This pins the + // decided reason for a hung helper start since #437: `storage-budget`, never + // `probe-timeout` (HACK-1459; docs/reference/native-compose-storage-xattr-carrier.md). expect(nativeComposeEffectRefusal(error)).toEqual({ stage: "storage-helper-start", reason: "storage-budget", diff --git a/tests/native-compose-storage-witness-command.test.ts b/tests/native-compose-storage-witness-command.test.ts index 8b162db08..b20ba2e3b 100644 --- a/tests/native-compose-storage-witness-command.test.ts +++ b/tests/native-compose-storage-witness-command.test.ts @@ -1,6 +1,12 @@ -import { expect, test } from "bun:test"; +import { expect, setDefaultTimeout, test } from "bun:test"; +import { chmod, rename } from "node:fs/promises"; import { join } from "node:path"; +import { + DEFAULT_COMPOSE_STARTUP_TIMEOUT_MS, + DEFAULT_NATIVE_STORAGE_TIMEOUT_MS, +} from "../src/lib/compose-startup-budget.ts"; import { openNativeComposeGenerationStore } from "../src/lib/native-compose-generation.ts"; +import { nativeComposeStopOwnershipTimeoutMs } from "../src/lib/native-compose-ownership.ts"; import { nativeComposeRetainedVolumesValid } from "../src/lib/native-compose-retained-storage.ts"; import { enrollNativeComposeStorageWitness, @@ -12,9 +18,117 @@ import { import { captureNativeComposeStorageXattrCarrier } from "../src/lib/native-compose-storage-witness-xattr-carrier.ts"; import { encodeNativeComposeStorageXattrResponse } from "../src/lib/native-compose-storage-witness-xattr-codec.ts"; import { runNativeComposeStorageXattrHelper } from "../src/lib/native-compose-storage-witness-xattr-helper.ts"; +import { restoreEnv } from "./helpers/env.ts"; import { invoke } from "./helpers/native-compose-command.ts"; import { legacyStorageFixture } from "./helpers/native-compose-legacy-storage.ts"; +/** + * `down --recover` bounds its owned stop by `HACK_COMPOSE_STARTUP_TIMEOUT_MS`, + * which `invoke` sets to 1 s by default. On a loaded host (HACK-1459: load + * average above 60) the stop's synthetic Docker processes alone take longer, + * the deadline expires mid-stop, and the CLI reports a `private-uncertain` + * effect refusal instead of the `E_COMPOSE_FAILED` these cases pin. A 30 s + * deadline still expired at load average 120. Every source CLI process here + * (`ps`, `down`, `up`/`run`/`exec` refusals and recovery) therefore runs with + * the product's default budget, as on a host that sets none; a CLI that hangs + * is still killed by {@link CLI_COMMAND_TIMEOUT_MS}. + */ +const RECOVERY_DEADLINE_MS = DEFAULT_COMPOSE_STARTUP_TIMEOUT_MS; +/** + * Kill bound for one source CLI process here. `invoke`'s 15 s default killed a + * cold CLI start with its synthetic Docker and compiler processes (exit 137) + * at load average 110. It stays a minute above the recovery deadline, which + * covers the stop's bounded cleanup after that deadline. + */ +const CLI_COMMAND_TIMEOUT_MS = RECOVERY_DEADLINE_MS + 60_000; +/** + * Each case builds a legacy fixture through the public store, runs up to four + * source CLI processes (each a cold Bun start plus synthetic Docker and compiler + * processes) and reads saved state back. At load average 120 a case took up to + * 4 minutes; Bun's old 30 s per-case budget ended cases while their CLI child + * still ran. In CI the whole file takes about 5 s. + */ +const STORAGE_WITNESS_CASE_BUDGET_MS = 8 * 60_000; +setDefaultTimeout(STORAGE_WITNESS_CASE_BUDGET_MS); + +function cli(root: string, args: string[]) { + return invoke(root, args, RECOVERY_DEADLINE_MS, CLI_COMMAND_TIMEOUT_MS); +} + +function recover(root: string) { + return invoke( + root, + ["down", "--recover", "--json"], + RECOVERY_DEADLINE_MS, + CLI_COMMAND_TIMEOUT_MS + ); +} + +/** + * Longer than the 15 s floor of an ownership query in `down`, so the slow-engine + * case fails with `probe-timeout` if the query ignores the stop's budget. On a + * loaded host (HACK-1459) many slow stand-in processes add up to the same wait. + */ +const SLOW_OWNERSHIP_QUERY_MS = 16_000; + +/** + * Delays the next Docker read once, then runs the stand-in as usual. The marker + * file proves the delay ran, so the case cannot pass without a slow query. + */ +async function delayNextDockerRead(root: string, delayMs: number) { + const docker = join(root, "docker"); + const standIn = join(root, "docker-stand-in"); + const armed = join(root, "docker-delay-armed"); + await rename(docker, standIn); + await Bun.write(armed, ""); + await Bun.write( + docker, + `#!${process.execPath} +import {rm} from "node:fs/promises"; +const claimed=await rm(${JSON.stringify(armed)}).then(()=>true,()=>false); +if(claimed)await Bun.sleep(${delayMs}); +const child=Bun.spawn([${JSON.stringify(standIn)},...process.argv.slice(2)],{stdio:["inherit","inherit","inherit"]}); +process.exit(await child.exited); +` + ); + await chmod(docker, 0o700); + return armed; +} + +/** + * Docker client selection a contributor or CI step may export. No case may read + * it: the source CLI runs with an isolated client config and the in-process + * store never selects an engine, so a missing socket changes nothing. + */ +const AMBIENT_DOCKER_SELECTIONS = [ + { label: "", env: {} }, + { + label: " with an ambient DOCKER_HOST at a missing socket", + env: { + DOCKER_HOST: "unix:///nonexistent-hack-test/missing-docker.sock", + DOCKER_CONTEXT: "hack-test-missing-context", + }, + }, +] as const; + +async function withAmbientEnv( + env: Readonly>, + run: () => Promise +): Promise { + const saved = new Map(); + for (const [key, value] of Object.entries(env)) { + saved.set(key, process.env[key]); + process.env[key] = value; + } + try { + return await run(); + } finally { + for (const [key, value] of saved) { + restoreEnv(key, value); + } + } +} + async function prepared(enroll: boolean) { const root = await legacyStorageFixture(); const store = await openNativeComposeGenerationStore({ @@ -113,7 +227,7 @@ test("source CLI cannot fall back to metadata for enrolled v3 startup, run or ex ["run", "web", "--", "synthetic"], ["exec", "web", "--", "synthetic"], ]) { - const result = await invoke(root, args); + const result = await cli(root, args); expect(result.code).toBe(1); expect(`${result.stdout}\n${result.stderr}`).toContain( "E_NATIVE_PROJECT_UNSUPPORTED" @@ -125,12 +239,60 @@ test("source CLI cannot fall back to metadata for enrolled v3 startup, run or ex expect(await Bun.file(join(root, "requests")).text()).toBe(requests); expect(await Bun.file(join(root, "order")).text()).toBe(order); expect((await saved(root)).storageWitnesses).toEqual(witnesses); -}, 30_000); +}); -test("source-unavailable explicit saved recovery stops engine but retains Expected intent and ownership anchor", async () => { +for (const ambient of AMBIENT_DOCKER_SELECTIONS) { + test(`source-unavailable explicit saved recovery stops engine but retains Expected intent and ownership anchor${ambient.label}`, async () => { + await withAmbientEnv(ambient.env, async () => { + const { root, witnesses, generationId } = await prepared(false); + const requests = await Bun.file(join(root, "requests")).text(); + const compiler = await Bun.file(join(root, "compiler-requests")).text(); + await Bun.write( + join(root, ".hack/hack.project.json"), + "malformed authored source" + ); + await Bun.write( + join(root, ".hack/hack.env.default.yaml"), + "malformed private env" + ); + const normal = await cli(root, ["down", "--json"]); + expect(normal.code).toBe(1); + expect(await Bun.file(join(root, "requests")).text()).toBe(requests); + const recovery = await recover(root); + expect(recovery.code).toBe(1); + expect(JSON.parse(recovery.stdout)).toMatchObject({ + ok: false, + error: { code: "E_COMPOSE_FAILED" }, + }); + expect(await Bun.file(join(root, "engine")).exists()).toBe(false); + await noRecoveryHelperRequests(root, requests); + expect(await Bun.file(join(root, "compiler-requests")).text()).toBe( + compiler + ); + const current = await saved(root); + expect(current.storageWitnesses).toEqual(witnesses); + expect(current.storageWitnessesPending).toBe(true); + expect(current.pending?.generationId).toBe(generationId); + expect(current.retainedStorage).toHaveLength(1); + expect(JSON.stringify(current)).not.toContain(".hack-storage-"); + }); + }); +} + +test("the ownership query in down follows the stop budget between its floor and the probe ceiling", () => { + expect(nativeComposeStopOwnershipTimeoutMs(1000)).toBe(15_000); + expect(nativeComposeStopOwnershipTimeoutMs(15_000)).toBe(15_000); + expect(nativeComposeStopOwnershipTimeoutMs(30_000)).toBe(30_000); + expect(nativeComposeStopOwnershipTimeoutMs(60_000)).toBe(60_000); + expect(nativeComposeStopOwnershipTimeoutMs(RECOVERY_DEADLINE_MS)).toBe( + 60_000 + ); + expect(nativeComposeStopOwnershipTimeoutMs(3_600_000)).toBe(60_000); +}); + +test("explicit saved recovery keeps its ownership query alive on a slow engine within the stop budget", async () => { const { root, witnesses, generationId } = await prepared(false); const requests = await Bun.file(join(root, "requests")).text(); - const compiler = await Bun.file(join(root, "compiler-requests")).text(); await Bun.write( join(root, ".hack/hack.project.json"), "malformed authored source" @@ -139,10 +301,9 @@ test("source-unavailable explicit saved recovery stops engine but retains Expect join(root, ".hack/hack.env.default.yaml"), "malformed private env" ); - const normal = await invoke(root, ["down", "--json"]); - expect(normal.code).toBe(1); - expect(await Bun.file(join(root, "requests")).text()).toBe(requests); - const recovery = await invoke(root, ["down", "--recover", "--json"]); + const armed = await delayNextDockerRead(root, SLOW_OWNERSHIP_QUERY_MS); + const recovery = await recover(root); + expect(await Bun.file(armed).exists()).toBe(false); expect(recovery.code).toBe(1); expect(JSON.parse(recovery.stdout)).toMatchObject({ ok: false, @@ -150,14 +311,10 @@ test("source-unavailable explicit saved recovery stops engine but retains Expect }); expect(await Bun.file(join(root, "engine")).exists()).toBe(false); await noRecoveryHelperRequests(root, requests); - expect(await Bun.file(join(root, "compiler-requests")).text()).toBe(compiler); const current = await saved(root); expect(current.storageWitnesses).toEqual(witnesses); - expect(current.storageWitnessesPending).toBe(true); expect(current.pending?.generationId).toBe(generationId); - expect(current.retainedStorage).toHaveLength(1); - expect(JSON.stringify(current)).not.toContain(".hack-storage-"); -}, 30_000); +}); test("source CLI reports and explicitly stops saved resources for unknown enrolled preflight helper with no main pending", async () => { const root = await legacyStorageFixture(); @@ -193,7 +350,9 @@ test("source CLI reports and explicitly stops saved resources for unknown enroll kernelAbi: 1, }, signal: new AbortController().signal, - deadline: Date.now() + 30_000, + // The product's default per-carrier budget. A shorter one expired under + // load before enrollment finished and failed the case as uncertain. + deadline: Date.now() + DEFAULT_NATIVE_STORAGE_TIMEOUT_MS, ports: { inspect: async () => ({ engineId: "d".repeat(64), @@ -307,7 +466,7 @@ test("source CLI reports and explicitly stops saved resources for unknown enroll } finally { await store.close(); } - const ps = await invoke(root, ["ps", "--json"]); + const ps = await cli(root, ["ps", "--json"]); expect(ps.code).toBe(0); expect(JSON.parse(ps.stdout)).toMatchObject({ ok: true, @@ -325,9 +484,9 @@ test("source CLI reports and explicitly stops saved resources for unknown enroll join(root, ".hack/hack.env.default.yaml"), "malformed private env" ); - expect((await invoke(root, ["down", "--json"])).code).toBe(1); + expect((await cli(root, ["down", "--json"])).code).toBe(1); expect(await Bun.file(join(root, "requests")).text()).toBe(requests); - const recovered = await invoke(root, ["down", "--recover", "--json"]); + const recovered = await recover(root); expect(recovered.code).toBe(1); expect(JSON.parse(recovered.stdout)).toMatchObject({ ok: false, @@ -341,4 +500,4 @@ test("source CLI reports and explicitly stops saved resources for unknown enroll expect(retained.storageWitnesses).toEqual(witnesses); expect(retained.storageWitnessesPending).toBe(true); expect(retained.retainedStorage).toHaveLength(1); -}, 30_000); +}); diff --git a/tests/native-env-metadata.test.ts b/tests/native-env-metadata.test.ts index 5b91066b4..91d79e148 100644 --- a/tests/native-env-metadata.test.ts +++ b/tests/native-env-metadata.test.ts @@ -4,6 +4,7 @@ import { beforeAll, beforeEach, expect, + setDefaultTimeout, test, } from "bun:test"; import { @@ -26,6 +27,7 @@ import { resolveProjectEnvMetadataForNativeSelection, } from "../src/lib/project-env-config.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; const SENTINEL = "private-synthetic-value-must-not-escape"; const secure = { secure: `invalid-ciphertext-${SENTINEL}` }; @@ -120,15 +122,14 @@ function metadata( } /** * Fixture Git ignores the caller's global/system configuration, so a contributor's - * hooks, fsmonitor or signing setup cannot add processes or latency to a test. + * hooks, fsmonitor or signing setup cannot add processes or latency to a test, + * and a caller-exported `GIT_DIR`/`GIT_WORK_TREE` cannot point it at another + * repository (HACK-1459): `beforeAll` runs before the `beforeEach` that clears + * those variables for the product code. */ async function git(projectRoot: string, args: string[]) { const child = Bun.spawn(["git", "-C", projectRoot, ...args], { - env: { - ...process.env, - GIT_CONFIG_GLOBAL: "/dev/null", - GIT_CONFIG_NOSYSTEM: "1", - }, + env: fixtureGitEnv(), stdin: "ignore", stdout: "ignore", stderr: "ignore", @@ -141,9 +142,49 @@ async function git(projectRoot: string, args: string[]) { * checkout); on a loaded runner those writes, not the product's read-only * inspections, took seconds per test and hit bun's 5 s test budget (HACK-1362). * A copy is filesystem-only and spawns no Git. + * + * The build itself is five Git processes that write two repositories and a + * linked worktree. Under heavy I/O (8 writers plus `sync` loops) 24 of 36 runs + * spent more than Bun's 5 s hook default on it (HACK-1459), so both hooks get + * this budget instead; a Git that never exits still fails the file. */ -let templates: string; -beforeAll(async () => { +const TEMPLATE_BUDGET_MS = 60_000; +/** + * Per-case budget. A case copies a template and runs the product's read-only + * metadata resolution, whose Git worktree verification starts several `git` + * processes. At load average above 100 one such case took 5.3 s against Bun's + * 5 s default (HACK-1459); the product's own inspection bounds stay unchanged. + */ +const CASE_BUDGET_MS = 30_000; +setDefaultTimeout(CASE_BUDGET_MS); +let templates = ""; +let templatesBuild: Promise | undefined; +let templatesClosed = false; +async function removeTemplates() { + if (templates !== "") { + await rm(templates, { recursive: true, force: true }); + } +} +/** + * A timed-out `beforeAll` keeps running: Bun starts `afterAll` at once, while + * the build may still create the template directory and its repositories. So + * `afterAll` waits for the build to settle before removing it, and a build that + * outlives even that wait removes the directory itself when it ends. + */ +beforeAll(() => { + templatesBuild = buildTemplates().finally(async () => { + if (templatesClosed) { + await removeTemplates(); + } + }); + return templatesBuild; +}, TEMPLATE_BUDGET_MS); +afterAll(async () => { + templatesClosed = true; + await templatesBuild?.catch(() => undefined); + await removeTemplates(); +}, TEMPLATE_BUDGET_MS); +async function buildTemplates() { templates = await realpath( await mkdtemp(join(tmpdir(), "native-env-metadata-template-")) ); @@ -181,10 +222,7 @@ beforeAll(async () => { ]); })(), ]); -}); -afterAll(async () => { - await rm(templates, { recursive: true, force: true }); -}); +} async function copyTemplate(name: "repository" | "linked") { await cp(join(templates, name), root, { recursive: true, diff --git a/tests/native-env-values.test.ts b/tests/native-env-values.test.ts index 86e2c3012..2bfac748b 100644 --- a/tests/native-env-values.test.ts +++ b/tests/native-env-values.test.ts @@ -17,6 +17,7 @@ import { setProjectEnvValue, } from "../src/lib/project-env-config.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; const SENTINEL = "private-synthetic-value-must-not-escape"; const KEY = "synthetic-key-never-real-credentials"; @@ -104,6 +105,7 @@ function values( async function git(projectRoot: string, args: string[]) { const child = Bun.spawn(["git", "-C", projectRoot, ...args], { + env: fixtureGitEnv(), stdin: "ignore", stdout: "ignore", stderr: "ignore", diff --git a/tests/native-process-project.test.ts b/tests/native-process-project.test.ts index 949c0e65c..2f66f1e61 100644 --- a/tests/native-process-project.test.ts +++ b/tests/native-process-project.test.ts @@ -16,6 +16,7 @@ import { validateNativeProject, } from "../src/lib/native-project-validation.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; const PROTOCOL = { transport_version: 1, @@ -169,6 +170,7 @@ async function managedValues( async function git(project: string, args: string[]) { const child = Bun.spawn(["git", "-C", project, ...args], { + env: fixtureGitEnv(), stdin: "ignore", stdout: "ignore", stderr: "ignore", diff --git a/tests/native-project-branch-cli.test.ts b/tests/native-project-branch-cli.test.ts index ba1a80217..8d6d44056 100644 --- a/tests/native-project-branch-cli.test.ts +++ b/tests/native-project-branch-cli.test.ts @@ -13,6 +13,7 @@ import { loadNativeProjectRun, saveNativeProjectRun, } from "../src/backends/native-project-run.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; const roots: string[] = []; const entrypoint = resolve("index.ts"); @@ -161,7 +162,7 @@ for (const command of ["up"] as const) { ]) { const result = Bun.spawnSync(["git", ...args], { cwd: f.project, - env: f.env, + env: fixtureGitEnv({ env: f.env }), stdout: "pipe", stderr: "pipe", }); diff --git a/tests/native-project-input.test.ts b/tests/native-project-input.test.ts index ed7598f98..8c04394d2 100644 --- a/tests/native-project-input.test.ts +++ b/tests/native-project-input.test.ts @@ -13,6 +13,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { prepareNativeProjectInput } from "../src/backends/native-project-input.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; const roots: string[] = []; afterEach(async () => { @@ -146,6 +147,7 @@ test("linked worktree inherits primary local environment and observes checkout o const opts = await fixture({ services: { api: { image: "bun:1" } } }); const git = async (args: string[]) => { const child = Bun.spawn(["git", ...args], { + env: fixtureGitEnv(), cwd: opts.projectRoot, stdout: "ignore", stderr: "ignore", diff --git a/tests/native-project-inputs.test.ts b/tests/native-project-inputs.test.ts index ecf3964a4..0ad0b43fa 100644 --- a/tests/native-project-inputs.test.ts +++ b/tests/native-project-inputs.test.ts @@ -22,6 +22,7 @@ import { } from "../src/lib/native-project-inputs.ts"; import { resolveVerifiedPrimaryWorktreeRoot } from "../src/lib/worktree-local-config.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; const ENV_KEYS = [ "CI", @@ -74,6 +75,7 @@ async function git(opts: { readonly args: readonly string[]; }) { const child = Bun.spawn(["git", "-C", opts.projectRoot, ...opts.args], { + env: fixtureGitEnv(), stdin: "ignore", stdout: "ignore", stderr: "pipe", diff --git a/tests/native-routing-inputs.test.ts b/tests/native-routing-inputs.test.ts index 21cc22c01..dcb129a72 100644 --- a/tests/native-routing-inputs.test.ts +++ b/tests/native-routing-inputs.test.ts @@ -13,6 +13,7 @@ import { join } from "node:path"; import { acquireNativeGlobalDomain } from "../src/lib/native-routing-inputs.ts"; import { resolveVerifiedNativeBranch } from "../src/lib/worktree-local-config.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; const KEYS = [ "PATH", @@ -60,6 +61,7 @@ async function policy(text: string | Uint8Array) { } async function git(cwd: string, args: readonly string[]) { const child = Bun.spawn(["git", "-C", cwd, ...args], { + env: fixtureGitEnv(), stdin: "ignore", stdout: "ignore", stderr: "ignore", diff --git a/tests/node-workspace-bootstrap.test.ts b/tests/node-workspace-bootstrap.test.ts index a86c95a9e..8a65b43eb 100644 --- a/tests/node-workspace-bootstrap.test.ts +++ b/tests/node-workspace-bootstrap.test.ts @@ -5,6 +5,7 @@ import { join, resolve } from "node:path"; import { handleNodeRoutes } from "../src/daemon/routes/node.ts"; import { exec } from "../src/lib/shell.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitShellEnv } from "./helpers/fixture-git.ts"; let tempDir: string | null = null; let originalHome: string | undefined; @@ -374,6 +375,7 @@ async function runGit(opts: { readonly args: readonly string[]; }): Promise { const result = await exec(["git", ...opts.args], { + ...fixtureGitShellEnv(), cwd: opts.cwd, stdin: "ignore", }); @@ -395,6 +397,7 @@ async function assertTrackedPaths(opts: { const result = await exec( ["git", "ls-tree", "--name-only", "HEAD", "--", filePath], { + ...fixtureGitShellEnv(), cwd: opts.cwd, stdin: "ignore", } diff --git a/tests/portable-bootstrap-env-refusal.test.ts b/tests/portable-bootstrap-env-refusal.test.ts index 78ead7e3b..3ceb20781 100644 --- a/tests/portable-bootstrap-env-refusal.test.ts +++ b/tests/portable-bootstrap-env-refusal.test.ts @@ -18,6 +18,7 @@ import { renderBootstrapLock, sha256Hex, } from "./e2e/portable-bootstrap-controls.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; const cli = resolve(import.meta.dir, "../index.ts"); @@ -91,6 +92,7 @@ async function fixture() { } const git = (args: string[]) => { const result = Bun.spawnSync(["git", "-C", project, ...args], { + env: fixtureGitEnv(), stdout: "pipe", stderr: "pipe", }); diff --git a/tests/pre-commit-hook.test.ts b/tests/pre-commit-hook.test.ts index 2982676ed..7d136de15 100644 --- a/tests/pre-commit-hook.test.ts +++ b/tests/pre-commit-hook.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, test } from "bun:test"; import { chmod, copyFile, mkdir, mkdtemp, rm, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; /** * The repository pre-commit hook formats staged files with Biome (HACK-1665). @@ -22,26 +23,13 @@ afterEach(async () => { ); }); -/** Git environment without inherited repository, hook or user configuration. */ -function gitEnv(): Record { - const env: Record = {}; - for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined && !key.startsWith("GIT_")) { - env[key] = value; - } - } - env.GIT_CONFIG_GLOBAL = "/dev/null"; - env.GIT_CONFIG_NOSYSTEM = "1"; - return env; -} - function git( cwd: string, args: readonly string[] ): { exitCode: number; output: string } { const result = Bun.spawnSync(["git", ...args], { cwd, - env: gitEnv(), + env: fixtureGitEnv(), stdin: "ignore", stdout: "pipe", stderr: "pipe", diff --git a/tests/project-domain-migration.test.ts b/tests/project-domain-migration.test.ts index f264b2c2c..b07f799c7 100644 --- a/tests/project-domain-migration.test.ts +++ b/tests/project-domain-migration.test.ts @@ -18,6 +18,7 @@ import { previewProjectDomainMigration as preview, rollbackProjectDomainMigration as rollback, } from "../src/lib/project-domain-migration.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; const roots: string[] = []; const config = '{"dev_host":"demo.hack", "custom":"preserve"}\n'; @@ -248,6 +249,7 @@ test("journal and interrupted staging remain Git ignored without parent rules", const projectDir = await fixture(); const git = async (args: string[]) => { const child = Bun.spawn(["git", "-C", projectDir, ...args], { + env: fixtureGitEnv(), stdout: "pipe", stderr: "ignore", }); diff --git a/tests/project-down-worktree-safety.test.ts b/tests/project-down-worktree-safety.test.ts index b64920a07..7a0db3d97 100644 --- a/tests/project-down-worktree-safety.test.ts +++ b/tests/project-down-worktree-safety.test.ts @@ -9,6 +9,7 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; type CapturedRunResult = { readonly exitCode: number; @@ -445,6 +446,7 @@ function runGit(opts: { readonly args: readonly string[]; }): void { const result = Bun.spawnSync(["git", "-C", opts.cwd, ...opts.args], { + env: fixtureGitEnv(), stdin: "ignore", stdout: "pipe", stderr: "pipe", diff --git a/tests/project-env-config.test.ts b/tests/project-env-config.test.ts index 7133ab821..150a62a1d 100644 --- a/tests/project-env-config.test.ts +++ b/tests/project-env-config.test.ts @@ -51,6 +51,7 @@ import { } from "../src/lib/project-env-config.ts"; import { resolveSecretStore } from "../src/lib/secret-store.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; const tempDirs = new Set(); @@ -94,6 +95,7 @@ async function createRepo(): Promise<{ async function runGit(args: readonly string[], cwd: string): Promise { const proc = Bun.spawn({ + env: fixtureGitEnv(), cmd: ["git", ...args], cwd, stdout: "pipe", diff --git a/tests/project-env-hydrate.test.ts b/tests/project-env-hydrate.test.ts index 869eefc29..1f8594395 100644 --- a/tests/project-env-hydrate.test.ts +++ b/tests/project-env-hydrate.test.ts @@ -33,6 +33,7 @@ import { resolveProjectEnvLocalConfigPath, setProjectEnvValue, } from "../src/lib/project-env-config.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; /** @@ -207,9 +208,11 @@ async function lockExists(repo: Repo): Promise { async function git(repo: Repo, args: readonly string[]): Promise { const proc = Bun.spawn(["git", ...args], { cwd: repo.projectRoot, - env: privateCliEnv({ - root: repo.root, - env: { GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" }, + env: fixtureGitEnv({ + env: privateCliEnv({ + root: repo.root, + env: { GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" }, + }), }), stdout: "pipe", stderr: "pipe", diff --git a/tests/project-env-key-ignore.test.ts b/tests/project-env-key-ignore.test.ts index 3c2845a5f..c3440f37d 100644 --- a/tests/project-env-key-ignore.test.ts +++ b/tests/project-env-key-ignore.test.ts @@ -20,6 +20,7 @@ import { import { ensureGitignoreEntry } from "../src/lib/fs.ts"; import { resolveProjectEnvConfigPath } from "../src/lib/project-env-config.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; /** @@ -90,7 +91,7 @@ async function git( args: readonly string[] ): Promise<{ readonly exitCode: number; readonly stdout: string }> { const proc = Bun.spawn(["git", "-C", repo.projectRoot, ...args], { - env: gitEnv(repo.root), + env: fixtureGitEnv({ env: gitEnv(repo.root) }), stdin: "ignore", stdout: "pipe", stderr: "ignore", diff --git a/tests/project-env-layer-lock.test.ts b/tests/project-env-layer-lock.test.ts index 7ece97a6e..ff8767725 100644 --- a/tests/project-env-layer-lock.test.ts +++ b/tests/project-env-layer-lock.test.ts @@ -18,7 +18,7 @@ import { PROJECT_COMPOSE_FILENAME, PROJECT_CONFIG_FILENAME, } from "../src/constants.ts"; -import { ClosedCliError } from "../src/lib/cli-result.ts"; +import { ClosedCliError, HackCliError } from "../src/lib/cli-result.ts"; import { parseDotEnv } from "../src/lib/env.ts"; import { EnvLayerLockRefusedError, @@ -490,11 +490,12 @@ async function holdLayerLock(layerPath: string): Promise { return lockPath; } +/** The closed refusal `pending` rejects with: a coded or an uncoded one. */ async function closedRefusalOf(pending: Promise): Promise { try { await pending; } catch (error: unknown) { - if (error instanceof ClosedCliError) { + if (error instanceof ClosedCliError || error instanceof HackCliError) { return error; } throw error; @@ -664,6 +665,7 @@ test("a symlinked .internal beside the layer is refused before any lock or write }) ); expect(refusal).toBeInstanceOf(EnvLayerLockRefusedError); + expect(refusal).toMatchObject({ code: "E_PATH_CONFLICT" }); expect(refusal.message).toBe( `Refused to update ${file}: the .internal entry beside it is a symlink or not a directory, so its write lock cannot be taken safely. Replace it with a regular directory and retry. Nothing was written.` ); diff --git a/tests/project-env-local-inheritance.test.ts b/tests/project-env-local-inheritance.test.ts index 4673a15c8..d06087ebe 100644 --- a/tests/project-env-local-inheritance.test.ts +++ b/tests/project-env-local-inheritance.test.ts @@ -23,6 +23,7 @@ import { unsetProjectEnvValue, } from "../src/lib/project-env-config.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; const roots: string[] = []; @@ -39,6 +40,7 @@ afterEach(async () => { async function git(cwd: string, args: string[]) { const child = Bun.spawn(["git", ...args], { + env: fixtureGitEnv(), cwd, stdout: "pipe", stderr: "pipe", diff --git a/tests/project-env-metadata.test.ts b/tests/project-env-metadata.test.ts index 8cf48db48..6b3468cd3 100644 --- a/tests/project-env-metadata.test.ts +++ b/tests/project-env-metadata.test.ts @@ -20,6 +20,7 @@ import { resolveProjectEnvMetadata, } from "../src/lib/project-env-config.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; const roots: string[] = []; const originalEnv = { @@ -96,6 +97,7 @@ function metadataFromRuntime(resolved: ProjectEnvResolvedConfig | null) { async function git(cwd: string, args: string[]) { const child = Bun.spawn(["git", ...args], { + env: fixtureGitEnv(), cwd, stdout: "pipe", stderr: "pipe", diff --git a/tests/project-env-stable-ciphertext.test.ts b/tests/project-env-stable-ciphertext.test.ts index 85a566642..1f5d4372a 100644 --- a/tests/project-env-stable-ciphertext.test.ts +++ b/tests/project-env-stable-ciphertext.test.ts @@ -32,6 +32,7 @@ import { resolveProjectEnvLocalConfigPath, setProjectEnvValue, } from "../src/lib/project-env-config.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; /** @@ -150,9 +151,11 @@ function ivSegment(ciphertext: string): string { async function git(repo: Repo, args: readonly string[]): Promise { const proc = Bun.spawn(["git", ...args], { cwd: repo.projectRoot, - env: privateCliEnv({ - root: repo.root, - env: { GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" }, + env: fixtureGitEnv({ + env: privateCliEnv({ + root: repo.root, + env: { GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" }, + }), }), stdout: "pipe", stderr: "pipe", diff --git a/tests/project-env-worktree-key.test.ts b/tests/project-env-worktree-key.test.ts index 4c6349f14..9573e35a7 100644 --- a/tests/project-env-worktree-key.test.ts +++ b/tests/project-env-worktree-key.test.ts @@ -25,6 +25,7 @@ import { resolveProjectEnvSharedKeyLocation, setProjectEnvValue, } from "../src/lib/project-env-config.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; const tempDirs = new Set(); @@ -43,6 +44,7 @@ afterEach(async () => { async function runGit(args: readonly string[], cwd: string): Promise { const proc = Bun.spawn({ + env: fixtureGitEnv(), cmd: ["git", ...args], cwd, stdout: "pipe", diff --git a/tests/project-env-worktree-local.test.ts b/tests/project-env-worktree-local.test.ts index 492e98817..52208e2e9 100644 --- a/tests/project-env-worktree-local.test.ts +++ b/tests/project-env-worktree-local.test.ts @@ -22,6 +22,7 @@ import { setProjectEnvValue, } from "../src/lib/project-env-config.ts"; import { resolvePrimaryLocalProjectDir } from "../src/lib/worktree-local-config.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; const tempDirs = new Set(); @@ -77,7 +78,7 @@ async function git(cwd: string, args: string[]): Promise { "init.templateDir=", ]; const result = Bun.spawnSync(["git", "-C", cwd, ...isolation, ...args], { - env: { PATH: process.env.PATH ?? "", ...gitIsolationEnv }, + env: fixtureGitEnv(), stdout: "pipe", stderr: "pipe", }); diff --git a/tests/project-env-write-refusal-gaps.test.ts b/tests/project-env-write-refusal-gaps.test.ts index 90504b431..f882a7608 100644 --- a/tests/project-env-write-refusal-gaps.test.ts +++ b/tests/project-env-write-refusal-gaps.test.ts @@ -46,6 +46,7 @@ import { ProjectsRegistryLockTimeoutError, withProjectsRegistryLock, } from "../src/lib/projects-registry-lock.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; /** @@ -175,9 +176,11 @@ async function runCli(opts: { async function runGit(repo: Repo, args: readonly string[]): Promise { const proc = Bun.spawn(["git", ...args], { cwd: repo.projectRoot, - env: privateCliEnv({ - root: repo.root, - env: { GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" }, + env: fixtureGitEnv({ + env: privateCliEnv({ + root: repo.root, + env: { GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" }, + }), }), stdout: "ignore", stderr: "pipe", @@ -557,6 +560,74 @@ for (const kind of ["dangling", "live"] as const) { }, 60_000); } +// A `.hack/.internal` that is a file stops hydrate earlier: its check of the +// legacy adoption state under `.hack/.internal` finds a non-directory on the +// way (ENOTDIR), which is an input access failure, never absence, so it refuses +// closed before hydrate reaches its lock directory. docs/env.md says so +// (HACK-1459). +const INTERNAL_FILE_REFUSAL = + "E_CONFIG_INVALID: Cannot inspect Hack project inputs. Check filesystem permissions and paths before retrying. Values omitted."; + +test("hydrate with a .hack/.internal file is a closed E_CONFIG_INVALID input refusal", async () => { + const repo = await createRepo(); + const internal = resolve(repo.projectDir, ".internal"); + await writeFile(internal, "not a directory\n"); + + for (const logger of ["console", "clack", "gum"]) { + const run = await runCli({ + repo, + args: ["env", "hydrate", "--path", repo.projectRoot], + stdin: HYDRATE_INPUT, + logger, + }); + expectOneLineRefusal(run, INTERNAL_FILE_REFUSAL); + } + const json = await runCli({ + repo, + args: ["env", "hydrate", "--json", "--path", repo.projectRoot], + stdin: HYDRATE_INPUT, + }); + expect(jsonFailure(json)).toEqual({ + code: "E_CONFIG_INVALID", + message: INTERNAL_FILE_REFUSAL.replace("E_CONFIG_INVALID: ", ""), + }); + // The file and the layer are untouched. + expect(await readFile(internal, "utf8")).toBe("not a directory\n"); + expect(await readFile(repo.tracked, "utf8")).toBe(LAYER); +}, 60_000); + +// The per-layer write lock refuses the same dangling `.hack/.internal` with the +// same stable code as hydrate: one `E_PATH_CONFLICT` line (HACK-1459). +const LAYER_LOCK_REFUSAL = (layer: string) => + `E_PATH_CONFLICT: Refused to update ${layer}: the .internal entry beside it is a symlink or not a directory, so its write lock cannot be taken safely. Replace it with a regular directory and retry. Nothing was written.`; + +test("env set and unset with a dangling .hack/.internal symlink are one closed E_PATH_CONFLICT line", async () => { + const repo = await createRepo(); + const internal = resolve(repo.projectDir, ".internal"); + const elsewhere = resolve(repo.root, "elsewhere"); + await symlink(elsewhere, internal); + + const set = await runCli({ + repo, + args: setArgs(repo, "NEW_SETTING"), + stdin: NEW_VALUE, + }); + expectOneLineRefusal(set, LAYER_LOCK_REFUSAL(repo.tracked)); + const unsetArgs = [ + "env", + "unset", + "KEPT_SETTING", + "--path", + repo.projectRoot, + ]; + const unset = await runCli({ repo, args: unsetArgs }); + expectOneLineRefusal(unset, LAYER_LOCK_REFUSAL(repo.tracked)); + // Nothing was created through the link, and the layer is unchanged. + expect(await readlink(internal)).toBe(elsewhere); + expect(await readdir(elsewhere).catch(() => null)).toBeNull(); + expect(await readFile(repo.tracked, "utf8")).toBe(LAYER); +}, 60_000); + test("ensureDirForWrite refuses a dangling symlink or a file closed, not as EEXIST", async () => { const directory = await scratch(); const dangling = join(directory, "dangling"); diff --git a/tests/project-env-write-refusals.test.ts b/tests/project-env-write-refusals.test.ts index 013612b25..33a4802e2 100644 --- a/tests/project-env-write-refusals.test.ts +++ b/tests/project-env-write-refusals.test.ts @@ -45,6 +45,7 @@ import { setProjectEnvValue, unsetProjectEnvValue, } from "../src/lib/project-env-config.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; /** @@ -603,9 +604,11 @@ const SECOND_SECRET = ["synthetic", "second", "secret", "e07c"].join("-"); async function runGit(repo: Repo, args: readonly string[]): Promise { const proc = Bun.spawn(["git", ...args], { cwd: repo.projectRoot, - env: privateCliEnv({ - root: repo.root, - env: { GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" }, + env: fixtureGitEnv({ + env: privateCliEnv({ + root: repo.root, + env: { GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" }, + }), }), stdout: "ignore", stderr: "pipe", diff --git a/tests/project-name-registry.test.ts b/tests/project-name-registry.test.ts index 81d7e1eb8..1d75ef936 100644 --- a/tests/project-name-registry.test.ts +++ b/tests/project-name-registry.test.ts @@ -9,6 +9,7 @@ import { upsertProjectRegistration, } from "../src/lib/projects-registry.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { projectNameFixture } from "./helpers/project-name-fixture.ts"; let fixture: Awaited>; @@ -142,6 +143,7 @@ test("independent Git checkouts sharing a remote cannot take over a missing proj ["remote", "add", "origin", "https://example.invalid/same-repo.git"], ]) { const child = Bun.spawn(["git", ...args], { + env: fixtureGitEnv(), cwd: project.projectRoot, stdout: "pipe", stderr: "pipe", diff --git a/tests/project-target-inspect.test.ts b/tests/project-target-inspect.test.ts index 7de241f73..a1be62c05 100644 --- a/tests/project-target-inspect.test.ts +++ b/tests/project-target-inspect.test.ts @@ -24,6 +24,7 @@ import { targetRefusalEnvelope, } from "../src/lib/target-inspect.ts"; import { buildTargetInspectArgs, createMcpServer } from "../src/mcp/server.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; // --------------------------------------------------------------------------- @@ -73,7 +74,7 @@ function runGit(opts: { readonly args: readonly string[]; }): string { const result = Bun.spawnSync(["git", "-C", opts.cwd, ...opts.args], { - env: gitEnv(opts.home), + env: fixtureGitEnv({ env: gitEnv(opts.home) }), stdin: "ignore", stdout: "pipe", stderr: "pipe", diff --git a/tests/project-task-catalog.test.ts b/tests/project-task-catalog.test.ts index caf799c50..ea56e9df9 100644 --- a/tests/project-task-catalog.test.ts +++ b/tests/project-task-catalog.test.ts @@ -43,6 +43,7 @@ import { createMcpServer, } from "../src/mcp/server.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; // --------------------------------------------------------------------------- @@ -360,22 +361,19 @@ async function writeAlpha(alpha: string, canary: string): Promise { /** * Run git in a fixture project with no global or system config, a private - * HOME and only PATH/TMPDIR inherited. + * HOME and only PATH/TMPDIR inherited. `gitEnv` holds Git variables a test sets + * on purpose, such as an alternate `GIT_INDEX_FILE`. */ async function git( cwd: string, args: readonly string[], - env: Readonly> = {} + gitEnv: Readonly> = {} ): Promise { const proc = Bun.spawn(["git", ...args], { cwd, - env: privateCliEnv({ - root: dirname(cwd), - env: { - GIT_CONFIG_GLOBAL: "/dev/null", - GIT_CONFIG_NOSYSTEM: "1", - ...env, - }, + env: fixtureGitEnv({ + env: privateCliEnv({ root: dirname(cwd) }), + gitEnv, }), stdin: "ignore", stdout: "pipe", diff --git a/tests/project-up-startup-state.test.ts b/tests/project-up-startup-state.test.ts index 0ba52c81f..7cc4e99f0 100644 --- a/tests/project-up-startup-state.test.ts +++ b/tests/project-up-startup-state.test.ts @@ -22,6 +22,7 @@ import { import { evaluateComposeServiceEnvFreshness } from "../src/lib/compose-env-fingerprint.ts"; import { readProjectRuntimeStateEntry } from "../src/lib/project-runtime-state.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; const psRows: string[] = []; @@ -1062,6 +1063,7 @@ test("linked startup combines inherited aliases, local static precedence and sam }); const git = async (...args: string[]) => { const child = Bun.spawn(["git", "-C", primary, ...args], { + env: fixtureGitEnv(), stdout: "ignore", stderr: "pipe", }); diff --git a/tests/projects-registry-admission.test.ts b/tests/projects-registry-admission.test.ts index a32c3f7ee..a45efeb0b 100644 --- a/tests/projects-registry-admission.test.ts +++ b/tests/projects-registry-admission.test.ts @@ -14,6 +14,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; import { readProjectsRegistry } from "../src/lib/projects-registry.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; let fixture = ""; let registryDir = ""; @@ -102,7 +103,7 @@ afterEach(async () => { async function git(cwd: string, args: readonly string[]) { const child = Bun.spawn(["git", "-C", cwd, ...args], { - env, + env: fixtureGitEnv({ env }), stdout: "pipe", stderr: "pipe", }); diff --git a/tests/projects-registry-identity.test.ts b/tests/projects-registry-identity.test.ts index e86e50ab3..a9b60f64a 100644 --- a/tests/projects-registry-identity.test.ts +++ b/tests/projects-registry-identity.test.ts @@ -21,6 +21,7 @@ import { } from "../src/lib/projects-registry.ts"; import { withProjectsRegistryLock } from "../src/lib/projects-registry-lock.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { projectNameFixture } from "./helpers/project-name-fixture.ts"; const NOW = "2026-01-02T00:00:00Z"; @@ -114,7 +115,7 @@ test("registered lookup stays at its stored root instead of an ancestor Compose function git(cwd: string, args: readonly string[]) { const result = Bun.spawnSync(["git", "-C", cwd, ...args], { - env: fixture.env, + env: fixtureGitEnv({ env: fixture.env }), stdout: "pipe", stderr: "pipe", timeout: 10_000, diff --git a/tests/projects-registry-worktrees.test.ts b/tests/projects-registry-worktrees.test.ts index b5254cf9d..835afe580 100644 --- a/tests/projects-registry-worktrees.test.ts +++ b/tests/projects-registry-worktrees.test.ts @@ -14,6 +14,7 @@ import { upsertProjectRegistration, } from "../src/lib/projects-registry.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; let tempDir: string | null = null; let originalHome: string | undefined; @@ -37,6 +38,7 @@ function runGit(opts: { readonly args: readonly string[]; }): void { const result = Bun.spawnSync(["git", "-C", opts.cwd, ...opts.args], { + env: fixtureGitEnv(), stdin: "ignore", stdout: "pipe", stderr: "pipe", diff --git a/tests/projects-registry.test.ts b/tests/projects-registry.test.ts index ae8c350d0..244527f8c 100644 --- a/tests/projects-registry.test.ts +++ b/tests/projects-registry.test.ts @@ -21,6 +21,7 @@ import { upsertProjectRegistration, } from "../src/lib/projects-registry.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; let tempDir: string | null = null; let originalHome: string | undefined; @@ -300,6 +301,7 @@ function runGit(opts: { readonly args: readonly string[]; }): string { const result = Bun.spawnSync(["git", "-C", opts.cwd, ...opts.args], { + env: fixtureGitEnv(), stdin: "ignore", stdout: "pipe", stderr: "pipe", diff --git a/tests/registry-touch-worktrees.test.ts b/tests/registry-touch-worktrees.test.ts index 304a7e79f..fc307b0eb 100644 --- a/tests/registry-touch-worktrees.test.ts +++ b/tests/registry-touch-worktrees.test.ts @@ -23,6 +23,7 @@ import { touchProjectRegistration, upsertProjectRegistration, } from "../src/lib/projects-registry.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; let tempDir: string | null = null; let originalHome: string | undefined; @@ -62,6 +63,7 @@ function runGit(opts: { readonly args: readonly string[]; }): void { const result = Bun.spawnSync(["git", "-C", opts.cwd, ...opts.args], { + env: fixtureGitEnv(), stdin: "ignore", stdout: "pipe", stderr: "pipe", diff --git a/tests/run-exec-branch-default.test.ts b/tests/run-exec-branch-default.test.ts index 3fb4daa13..ddfa776dc 100644 --- a/tests/run-exec-branch-default.test.ts +++ b/tests/run-exec-branch-default.test.ts @@ -10,6 +10,7 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; type CapturedRunResult = { readonly exitCode: number; @@ -50,6 +51,7 @@ function runGit(opts: { readonly args: readonly string[]; }): void { const result = Bun.spawnSync(["git", "-C", opts.cwd, ...opts.args], { + env: fixtureGitEnv(), stdin: "ignore", stdout: "pipe", stderr: "pipe", diff --git a/tests/test-shard.test.ts b/tests/test-shard.test.ts index 67c67c28c..1944bc29d 100644 --- a/tests/test-shard.test.ts +++ b/tests/test-shard.test.ts @@ -36,6 +36,7 @@ import { suiteForFile, type TestSuite, } from "../scripts/test-shard-plan.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; const repoRoot = resolve(import.meta.dir, ".."); const owned: string[] = []; @@ -60,6 +61,7 @@ async function fixtureRepo(opts: { const root = await mkdtemp(join(tmpdir(), "hack-test-shard-")); owned.push(root); const init = Bun.spawnSync(["git", "init", "--quiet", root], { + env: fixtureGitEnv(), stdin: "ignore", stdout: "ignore", stderr: "ignore",