Skip to content
Merged
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
17 changes: 13 additions & 4 deletions docs/reference/native-compose-adoption.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,13 @@ publication before it can select ancestor Compose inputs or write legacy files.
An active adopted receipt remains a selection boundary even if its candidate is
removed; missing or edited candidates refuse execution.

Project commands look for an adoption before ordinary discovery. The search goes up
from the start directory to the nearest project root or the filesystem root, as far
as ordinary discovery goes, with no depth limit. Ordinary discovery reads an active
adoption as native input, so an adoption the search missed would reach the native
path. From a directory with no project, however deep, a command gets the ordinary
missing-project error. Unsafe adoption state is still refused with `E_CONFIG_INVALID`.

The distinct execution owner consumes the saved binding. It never sends these
resources through ordinary native startup, which allocates a different Compose
namespace and native volume names. `up --detach`, `restart` and `down` start, restart and
Expand Down Expand Up @@ -435,14 +442,16 @@ failing, with status `ok` and the message
Doctor uses `warn` for a skip that points at something to fix, such as no `.hack/`
found. These skips apply to every adopted project by design, so they are not warnings.
A pending adoption is a warning that asks for explicit recovery, and the `--fix` steps
treat it like an active one. The adoption search stops after 64 parent directories.
From a deeper start directory doctor finds no adoption and uses ordinary project
discovery instead of failing. The
treat it like an active one. Doctor uses the same adoption search as project
commands, so from a directory with no adoption above it, however deep, doctor uses
ordinary project discovery instead of failing. The
`--fix` plan says that it leaves the adoption's inputs, retained containers and state
alone. For the project it only removes tracked generated files from the Git index
(the files stay on disk) and keeps an env override that the adoption pins. It leaves
`.hack/.gitignore` unchanged; a committed `.hack.secret.key` is also added to the
root `.gitignore`. Global repairs still run.
root `.gitignore`. Global repairs still run. The tracked generated files warning for
an adopted project says that `--fix` leaves `.hack/.gitignore` unchanged, so the
files must be kept out of later commits.

## Rollback and interruption recovery

Expand Down
16 changes: 12 additions & 4 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3018,13 +3018,19 @@ async function checkTrackedGeneratedFiles({
message: `Skipped (no ${HACK_PROJECT_DIR_PRIMARY}/ found)`,
};
}
return await checkTrackedGeneratedFilesAt(ctx);
return await checkTrackedGeneratedFilesAt({ ...ctx, adopted: false });
}

/** The tracked generated-files check for a resolved project (legacy or adopted). */
/**
* The tracked generated-files check for a resolved project (legacy or
* adopted). For an adopted project `--fix` only untracks the files and leaves
* the adoption's `.gitignore` unchanged, so the warning says that instead of
* promising to ensure it (HACK-1380).
*/
async function checkTrackedGeneratedFilesAt(ctx: {
readonly projectRoot: string;
readonly projectDirName: string;
readonly adopted: boolean;
}): Promise<CheckResult> {
const name = "generated files";
const inspection = await inspectTrackedGeneratedFiles({
Expand Down Expand Up @@ -3057,7 +3063,9 @@ async function checkTrackedGeneratedFilesAt(ctx: {
return {
name,
status: "warn",
message: `Machine-local generated files are tracked in git: ${offenders}. Run 'hack doctor --fix' to untrack them (files stay on disk) and ensure the committed ${ctx.projectDirName}/.gitignore.`,
message: ctx.adopted
? `Machine-local generated files are tracked in git: ${offenders}. Run 'hack doctor --fix' to untrack them (files stay on disk). It leaves the adopted project's ${ctx.projectDirName}/.gitignore unchanged, so keep these files out of later commits.`
: `Machine-local generated files are tracked in git: ${offenders}. Run 'hack doctor --fix' to untrack them (files stay on disk) and ensure the committed ${ctx.projectDirName}/.gitignore.`,
};
}

Expand Down Expand Up @@ -3346,7 +3354,7 @@ async function runAdoptedProjectChecks(opts: {
await runCheck(
progress,
"generated files",
() => checkTrackedGeneratedFilesAt(adopted),
() => checkTrackedGeneratedFilesAt({ ...adopted, adopted: true }),
{ timeoutMs: 5000 }
),
await runCheck(progress, COMPOSE_ENV_AT_REST_CHECK, () =>
Expand Down
43 changes: 10 additions & 33 deletions src/lib/doctor-adopted-project.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { join } from "node:path";
import { HACK_PROJECT_DIR_PRIMARY } from "../constants.ts";
import { HackCliError } from "./cli-result.ts";
import { COMPOSE_BACKEND_CHECK } from "./doctor-compose-backend.ts";
import { selectLegacyComposeAdoptedRoot } from "./native-compose-adoption-command.ts";
import { inspectLegacyComposeAdoptionSelection } from "./native-compose-adoption-marker.ts";
Expand All @@ -26,26 +25,18 @@ export type DoctorAdoptedProject = {

/**
* The adopted project that owns `startDir`, or null when discovery from there
* reaches no active or pending adoption (or a nearer legacy project), or when
* the adoption selector's ancestor bound ends the search first. Unsafe
* adoption state throws the adoption selector's closed, value-free refusal.
* reaches no active or pending adoption (or a nearer legacy project). The
* adoption selector walks every ancestor that ordinary discovery walks, so a
* deep start directory with no adoption above it is null here, not a throw
* (HACK-1380). Unsafe adoption state throws the selector's closed, value-free
* refusal (`E_CONFIG_INVALID`).
*/
export async function findDoctorAdoptedProject(opts: {
readonly startDir: string;
}): Promise<DoctorAdoptedProject | null> {
let projectRoot: string | null;
try {
projectRoot = await selectLegacyComposeAdoptedRoot({
cwd: opts.startDir,
});
} catch (error: unknown) {
if (isAdoptionSearchBoundRefusal(error)) {
// A start directory more than the selector's 64 ancestors deep with no
// root on the way: no adoption was found, so ordinary discovery decides.
return null;
}
throw error;
}
const projectRoot = await selectLegacyComposeAdoptedRoot({
cwd: opts.startDir,
});
if (!projectRoot) {
return null;
}
Expand All @@ -62,26 +53,12 @@ export async function findDoctorAdoptedProject(opts: {
};
}

/**
* Whether `error` is the adoption selector's ancestor-bound refusal. Without
* `--project` or `--path`, `selectLegacyComposeAdoptedRoot` throws
* `E_NATIVE_PROJECT_UNSUPPORTED` only when it walked its 64 ancestors without
* reaching a root; unsafe adoption state is a different code
* (`E_CONFIG_INVALID`) and still propagates.
*/
function isAdoptionSearchBoundRefusal(error: unknown): boolean {
return (
error instanceof HackCliError &&
error.code === "E_NATIVE_PROJECT_UNSUPPORTED"
);
}

/**
* The adopted project doctor reports, or null after ordinary discovery's
* native-input refusal passed. An adopted project's inputs belong to its
* adoption, so that discovery would refuse it as native input; doctor selects
* it first (HACK-1311). A start directory too deep for the adoption selector
* is not an adoption, so it gets ordinary discovery instead of a throw.
* it first (HACK-1311). A start directory with no adoption above it, however
* deep, gets ordinary discovery instead of a throw.
*/
export async function preflightDoctorProject(opts: {
readonly startDir: string;
Expand Down
22 changes: 18 additions & 4 deletions src/lib/native-compose-adoption-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,25 @@ async function registeredAdoptedRoot(project: string) {
return state === "active" || state === "pending" ? registered.repoRoot : null;
}

/** Check private adoption ownership before ordinary authored discovery can allocate a fresh native namespace. */
/**
* Check private adoption ownership before ordinary authored discovery can
* allocate a fresh native namespace. Returns the adopted project root, or null
* when there is no active or pending adoption on the way to the nearest
* project root or the filesystem root. Unsafe adoption state throws the
* marker's closed, value-free refusal (`E_CONFIG_INVALID`).
*
* Invariant: this walk covers every ancestor that ordinary discovery
* (`discoverProjectInputs`) covers. That discovery reads an active adoption as
* native input, so an adoption this walk missed would reach the native
* authored path. The walk is therefore unbounded, like that discovery: it
* ends at a project root or the filesystem root, since `dirname` of an
* absolute path always reaches `/`. It once stopped after 64 directories and
* then refused every command, even with no project at all, as an unsupported
* adopted command (HACK-1380).
*/
export async function selectLegacyComposeAdoptedRoot(
options: Pick<NativeComposeCommandOptions, "cwd" | "path" | "project">
) {
): Promise<string | null> {
if (options.path && options.project !== undefined) {
throw new CliUsageError("Use either --path or --project (not both).");
}
Expand All @@ -70,7 +85,7 @@ export async function selectLegacyComposeAdoptedRoot(
}
let current = resolve(options.cwd, options.path ?? ".");
let nearestLegacy = false;
for (let depth = 0; depth < 64; depth++) {
while (true) {
const state = await inspectLegacyComposeAdoptionSelection({
projectRoot: current,
});
Expand All @@ -92,7 +107,6 @@ export async function selectLegacyComposeAdoptedRoot(
}
current = parent;
}
unsupported();
}
function validate(options: NativeComposeCommandOptions) {
if (
Expand Down
119 changes: 119 additions & 0 deletions tests/doctor-adopted-generated-files.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { afterEach, beforeEach, expect, test } from "bun:test";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { inspectLegacyComposeAdoptionSelection } from "../src/lib/native-compose-adoption-marker.ts";
import { privateCliEnv } from "./helpers/private-cli-env.ts";
import {
retainedSourceBindFixture,
SOURCE_BIND_CANARY,
} from "./helpers/retained-source-bind-adoption.ts";

/**
* HACK-1380: in an adopted (`hack config adopt`) project, `hack doctor --fix`
* only untracks tracked generated files and leaves the adoption's
* `.hack/.gitignore` unchanged. The `generated files` warning must say so
* instead of promising to ensure the committed `.hack/.gitignore`. The
* fixture is the synthetic retained source-bind adoption (no real engine);
* Git runs only against the fixture checkout with no global or system config.
*/
let h: Awaited<ReturnType<typeof retainedSourceBindFixture>>;
beforeEach(async () => {
h = await retainedSourceBindFixture();
});
afterEach(async () => {
await h.cleanup();
});

const GIT_ENV = {
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_CONFIG_NOSYSTEM: "1",
} as const;

function git(args: readonly string[]): void {
const result = Bun.spawnSync(["/usr/bin/git", ...args], {
cwd: h.root,
env: { PATH: "/usr/bin:/bin", HOME: h.outer, ...GIT_ENV },
stdout: "ignore",
stderr: "pipe",
});
if (result.exitCode !== 0) {
throw new Error(`git ${args[0]} failed in the fixture checkout.`);
}
}

/** Read-only `doctor --json` in a private HOME and HACK_HOME, stub engine only. */
async function doctorJson(): Promise<string> {
const state = join(h.outer, "state");
await mkdir(state, { recursive: true });
const child = Bun.spawn(
[
process.execPath,
resolve(import.meta.dir, "..", "index.ts"),
"doctor",
"--path",
h.root,
"--json",
],
{
cwd: h.root,
env: privateCliEnv({
root: h.outer,
env: {
HACK_HOME: state,
HACK_GLOBAL_CONFIG_PATH: join(state, "hack.config.json"),
HACK_DAEMON_DISABLE: "1",
HACK_NO_INTERACTIVE: "1",
HACK_LOGGER: "console",
HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK: "1",
XDG_CACHE_HOME: join(h.outer, "cache"),
PATH: `${h.outer}:/usr/bin:/bin`,
...GIT_ENV,
},
}),
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
}
);
const [, stdout] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
]);
return stdout;
}

test("doctor's generated files warning for an adopted project says --fix leaves .hack/.gitignore unchanged", async () => {
// A real Git checkout with a committed generated file.
await rm(join(h.root, ".git"), { recursive: true });
git(["init", "-q"]);
await writeFile(join(h.root, ".hack/.env.state.json"), "{}\n", {
mode: 0o600,
});
git(["add", "-f", ".hack/.env.state.json"]);
const store = await h.store();
try {
const generation = await store.prepare({ binary: h.compiler });
await store.publish({ generation, binary: h.compiler });
} finally {
await store.close();
}
expect(
await inspectLegacyComposeAdoptionSelection({ projectRoot: h.root })
).toBe("active");

const stdout = await doctorJson();
expect(stdout).not.toContain(SOURCE_BIND_CANARY);
const parsed: unknown = JSON.parse(stdout);
const checks = (
parsed as {
data: { checks: { id: string; status: string; detail: string }[] };
}
).data.checks;
expect(checks.find((check) => check.id === "generated files")).toMatchObject({
status: "warn",
detail:
"Machine-local generated files are tracked in git: .hack/.env.state.json. Run 'hack doctor --fix' to untrack them (files stay on disk). It leaves the adopted project's .hack/.gitignore unchanged, so keep these files out of later commits.",
});
expect(stdout).not.toContain("ensure the committed");
}, 120_000);
15 changes: 7 additions & 8 deletions tests/doctor-adopted-project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,17 +242,16 @@ test("doctor reports a pending adoption as a project warning that asks for recov
).toEqual([]);
}, 120_000);

test("a start directory deeper than the adoption search bound gets ordinary discovery instead of a throw", async () => {
// More ancestors than the adoption selector walks (64), with no project root
// on the way: the selector's bound ends the search.
test("a start directory deeper than the old adoption search bound gets ordinary discovery instead of a throw", async () => {
// More ancestors than the adoption selector once walked (64), with no
// project root on the way. The selector now walks every ancestor (HACK-1380)
// and finds no adoption, so doctor needs no catch of its own.
let deep = join(h.outer, "deep");
for (let level = 0; level < 70; level++) {
deep = join(deep, "d");
}
await mkdir(deep, { recursive: true });
await expect(
selectLegacyComposeAdoptedRoot({ cwd: deep })
).rejects.toMatchObject({ code: "E_NATIVE_PROJECT_UNSUPPORTED" });
expect(await selectLegacyComposeAdoptedRoot({ cwd: deep })).toBeNull();

expect(await findDoctorAdoptedProject({ startDir: deep })).toBeNull();
expect(await preflightDoctorProject({ startDir: deep })).toBeNull();
Expand All @@ -265,8 +264,8 @@ test("a start directory deeper than the adoption search bound gets ordinary disc
}, 60_000);

test("unsafe adoption state still refuses doctor with the selector's closed error", async () => {
// A state root that is not a directory is unsafe adoption state, not the
// search bound, so doctor must not read it as "no adoption".
// A state root that is not a directory is unsafe adoption state, so doctor
// must not read it as "no adoption".
const root = join(h.outer, "unsafe");
await mkdir(join(root, ".hack/.internal"), { recursive: true });
await writeFile(
Expand Down
Loading
Loading