Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -658,9 +658,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 (`<git common dir>/.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
Expand Down Expand Up @@ -719,7 +725,7 @@ 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
refuse with one `E_PATH_CONFLICT` line instead of creating the lock through it. 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.
Expand Down
6 changes: 5 additions & 1 deletion docs/reference/native-compose-storage-xattr-carrier.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,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. Like
Expand Down
18 changes: 11 additions & 7 deletions src/lib/env-layer-lock.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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";
}
}
Expand Down
2 changes: 2 additions & 0 deletions tests/branches-effective-branch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

Expand All @@ -16,6 +17,7 @@ afterEach(async () => {

async function runGit(args: readonly string[], cwd: string): Promise<string> {
const proc = Bun.spawn({
env: fixtureGitEnv(),
cmd: ["git", ...args],
cwd,
stdout: "pipe",
Expand Down
13 changes: 11 additions & 2 deletions tests/checkout-write-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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";

Expand Down Expand Up @@ -54,10 +55,12 @@ const fixture = (): string =>
async function run(opts: {
readonly argv: readonly string[];
readonly cwd: string;
/** The complete child environment; defaults to the private CLI env. */
readonly env?: Record<string, string>;
}): Promise<{ readonly exitCode: number; readonly output: string }> {
const child = Bun.spawn([...opts.argv], {
cwd: opts.cwd,
env: privateCliEnv({ root: fixture(), env: { NO_COLOR: "1" } }),
env: opts.env ?? privateCliEnv({ root: fixture(), env: { NO_COLOR: "1" } }),
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
Expand All @@ -80,7 +83,11 @@ async function gitCheckout(
): Promise<string> {
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) {
Expand All @@ -90,11 +97,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",
Expand Down
2 changes: 2 additions & 0 deletions tests/dependency-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion tests/dispatch-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion tests/doctor-env-key.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions tests/doctor-generated-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

Expand All @@ -38,6 +39,7 @@ afterEach(async () => {

async function runGit(args: readonly string[], cwd: string): Promise<string> {
const proc = Bun.spawn({
env: fixtureGitEnv(),
cmd: ["git", ...args],
cwd,
stdout: "pipe",
Expand Down
2 changes: 2 additions & 0 deletions tests/doctor-worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

Expand All @@ -24,6 +25,7 @@ afterEach(async () => {

async function runGit(args: readonly string[], cwd: string): Promise<void> {
const proc = Bun.spawn({
env: fixtureGitEnv(),
cmd: ["git", ...args],
cwd,
stdout: "pipe",
Expand Down
4 changes: 3 additions & 1 deletion tests/e2e/fixture.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Stats } from "node:fs";
import { lstat, mkdir, readdir, readlink } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";

import { fixtureGitEnv } from "../helpers/fixture-git.ts";
import { runCommand } from "./harness.ts";

/**
Expand Down Expand Up @@ -112,6 +112,7 @@ export async function addLinkedWorktree(opts: {
`${opts.fixture.name}-${opts.branch}`
);
const result = await runCommand({
exactEnv: fixtureGitEnv(),
argv: [
"git",
"-C",
Expand Down Expand Up @@ -477,6 +478,7 @@ async function git(opts: {
readonly args: readonly string[];
}): Promise<void> {
const result = await runCommand({
exactEnv: fixtureGitEnv(),
argv: [
"git",
"-C",
Expand Down
10 changes: 9 additions & 1 deletion tests/e2e/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
LAUNCHD_ALLOW_ISOLATED_ENV,
LAUNCHD_DISABLE_ENV,
} from "../../src/daemon/launchd.ts";
import { fixtureGitEnv } from "../helpers/fixture-git.ts";

/**
* Core end-to-end harness for the hack CLI.
Expand Down Expand Up @@ -544,13 +545,19 @@ async function readCapture(opts: { readonly path: string }): Promise<string> {
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<Record<string, string>>;
/**
* The child's complete environment, with nothing from the harness. Fixture
* `git` passes `fixtureGitEnv()` here (tests/helpers/fixture-git.ts).
*/
readonly exactEnv?: Readonly<Record<string, string>>;
readonly timeoutMs?: number;
readonly onStderrChunk?: (chunk: string) => void;
}): Promise<CliResult> {
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",
Expand Down Expand Up @@ -839,6 +846,7 @@ async function writeCanaryProject(opts: {
["services: {}", ""].join("\n")
);
await runCommand({
exactEnv: fixtureGitEnv(),
argv: ["git", "init", "-q", "-b", "main"],
cwd: opts.projectRoot,
});
Expand Down
2 changes: 2 additions & 0 deletions tests/e2e/run-dependency-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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",
});
Expand Down
3 changes: 2 additions & 1 deletion tests/e2e/scenarios/agent-surface-freeze.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -345,6 +345,7 @@ async function gitStatusPaths(opts: {
readonly root: string;
}): Promise<readonly StatusLine[]> {
const status = await runCommand({
exactEnv: fixtureGitEnv(),
argv: [
"git",
"-C",
Expand Down
2 changes: 2 additions & 0 deletions tests/e2e/scenarios/native-compose-adoption-worktrees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -4316,6 +4317,7 @@ async function runBranchWorktrees(ctx: ScenarioContext) {
refused();
}
const switched = await runCommand({
exactEnv: fixtureGitEnv(),
argv: [
"git",
"-C",
Expand Down
2 changes: 2 additions & 0 deletions tests/e2e/scenarios/portable-bootstrap-negatives.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
});
Expand Down
2 changes: 2 additions & 0 deletions tests/e2e/scenarios/worktree-branch-default.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { fixtureGitEnv } from "../../helpers/fixture-git.ts";
import { addLinkedWorktree, createMonorepoFixture } from "../fixture.ts";
import {
expect,
Expand Down Expand Up @@ -307,6 +308,7 @@ export const worktreeBranchDefaultScenario: Scenario = {
});

const detach = await runCommand({
exactEnv: fixtureGitEnv(),
argv: ["git", "checkout", "--detach"],
cwd: worktreePath,
});
Expand Down
3 changes: 2 additions & 1 deletion tests/e2e/scenarios/worktree-secrets.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { join } from "node:path";

import { fixtureGitEnv } from "../../helpers/fixture-git.ts";
import {
addLinkedWorktree,
commitAll,
Expand Down Expand Up @@ -140,6 +140,7 @@ async function assertNoDivergentKey(opts: {
readonly worktreeRoot: string;
}): Promise<void> {
const commonDirResult = await runCommand({
exactEnv: fixtureGitEnv(),
argv: ["git", "-C", opts.primaryRoot, "rev-parse", "--git-common-dir"],
cwd: opts.primaryRoot,
});
Expand Down
Loading
Loading