Skip to content
2 changes: 1 addition & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,7 @@ Explicitly adopt a stopped, verified existing Compose instance
hack config adopt [options]
```

Qualifies a strict static legacy subset with exact existing data volumes. --dry-run reports fields without writes. Adoption journals the stopped format switch and holds original files for rollback; retained-container commands never create replacement data. Requires an upgraded launcher. Linked Git/local inheritance and container recreation remain unsupported.
Qualifies a strict static legacy subset with exact existing data volumes. --dry-run reports fields without writes. Adoption journals the stopped format switch and holds original files for rollback; retained-container commands never create replacement data. Requires an upgraded launcher. Verified linked Git checkouts retain explicit existing identities. Local inheritance, generated overrides and container recreation remain unsupported.

### Options

Expand Down
55 changes: 51 additions & 4 deletions docs/reference/native-compose-adoption.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,27 @@ the exact `.hack/hack.config.json` and `.hack/docker-compose.yml` pair, bounded
regular files, strict maintained parsing and private original-byte freshness.
Matching explicit canonical names in both documents are required. There is no
caller-supplied resource name, branch override or directory-name fallback.
Local/dotenv inputs, `.git` file layouts, symlinks, competing input families and
source changes refuse under the same rules as preview.
Local/dotenv inputs, symlinks, competing input families and source changes refuse.
The private adoption owner additionally accepts exact-root linked Git checkouts
verified through the existing bounded Git owner. Separate Git directories and
nested project roots remain outside this linked-checkout slice; ordinary import
preview still refuses `.git` files.

Linked checkout receipts use private version 2. They bind the raw `.git` pointer,
administrative backlink and common-directory pointer, together with the held Git
administrative, common and primary directory identities. Pointer edits, redirected
paths, replaced directories and lost linkage refuse before publication or engine
effects. These private identities and digests never enter public reports. Existing
directory checkout receipts retain version 1 and their original wire shape.

The static retained-container slice also inspects relevant filenames in the
verified primary checkout when local inheritance is enabled. Managed-env,
dotenv, typed local settings and legacy extra-host aliases refuse without reading
their contents. The existing explicit `inherit_local: false`, CI and slim-runner
exclusions still exclude that primary scope. Inherited inputs added after
preparation refuse publication or later retained-container mutations; they cannot
be silently applied or ignored by a fresh migration. Successful generated-source
and managed-env inheritance adoption remains a separate NC04 requirement.

The pure `planLegacyComposeAdoption` prerequisite retains original field pointers
and positions. It reuses all preview mapping refusals, including unknown fields
Expand Down Expand Up @@ -89,7 +108,7 @@ not serialized. Orphan preparations are retained as evidence after a refused
transition. The native version-one receipt and manifest contract is unchanged.

`hack config adopt` requires all original containers stopped. It refuses local,
dotenv or managed-env inputs, linked/separate Git layouts, selected profiles,
dotenv or managed-env inputs, unverified linked or separate Git layouts, selected profiles,
unsupported source mappings and changed source/resource ownership. It never
silently stops a running instance. Add explicit `--stop` to journal and stop all
verified original IDs before the format switch. `--dry-run --stop` qualifies that
Expand Down Expand Up @@ -156,6 +175,34 @@ after adopted execution and rollback, retained container/network/volume anchors,
an interrupted partial stop, process-killed switch and rollback repair, candidate
edit/removal refusal, exact original-file inode restoration and owned cleanup.
These observed boundaries supplement the synthetic probe/interruption tests.
Full NC04 remains open for typed local inheritance, linked-worktree isolation,
Full NC04 remains open for typed local inheritance, generated-source provenance,
advanced lossless mappings, recreation and application migration; that acceptance
does not qualify those unsupported cases or an atomic freeze of external actors.

The maintained `native-compose-adoption-worktrees` Docker scenario exercises two
real linked checkouts with distinct canonical Compose names, original PostgreSQL
volumes and stored SQL rows. It checks inherited primary local-env refusal before
adoption state, then qualifies the static source pair while that unsupported local
input is withheld. Partial-stop repair, retained-container execution and rollback
must preserve the other checkout's source inodes, bytes, resource IDs and SQL row.
Stopped originals remain bound, and unsupported recreation through `run` refuses.
Cleanup uses only captured, reverified original IDs and volume creation facts.
Failed acceptance retains its disposable sources and isolated home for inspection;
retention alone does not prove engine cleanup succeeded.

Run it with the current compiled CLI and adjacent matching compiler, a Linux
Docker daemon and cached `postgres:17.6-alpine` image:

```sh
HACK_E2E_CLI_BIN=./dist/hack HACK_E2E_DOCKER=1 HACK_E2E_REQUIRE_DOCKER=1 HACK_E2E_KEEP=1 \
bun tests/e2e/run.ts --only=native-compose-adoption-worktrees
```

This fixture distinguishes static isolation from successful typed inheritance;
its inherited-input refusal does not qualify migration of generated Compose
overrides or managed values. The maintained scenario passed on macOS against a
Linux Docker daemon: both original SQL rows and resource identities survived
partial-stop repair, adopted execution and separate rollback. Exact owned cleanup
and an independent inventory check restored the original engine baseline.
Each later fixture inspection acquires a fresh bounded probe; a probe owner has
one aggregate acquisition deadline and cannot span the entire lifecycle.
2 changes: 1 addition & 1 deletion src/commands/config-adopt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const spec = defineCommand({
summary: "Explicitly adopt a stopped, verified existing Compose instance",
group: "Project",
description:
"Qualifies a strict static legacy subset with exact existing data volumes. --dry-run reports fields without writes. Adoption journals the stopped format switch and holds original files for rollback; retained-container commands never create replacement data. Requires an upgraded launcher. Linked Git/local inheritance and container recreation remain unsupported.",
"Qualifies a strict static legacy subset with exact existing data volumes. --dry-run reports fields without writes. Adoption journals the stopped format switch and holds original files for rollback; retained-container commands never create replacement data. Requires an upgraded launcher. Verified linked Git checkouts retain explicit existing identities. Local inheritance, generated overrides and container recreation remain unsupported.",
options: [
defineOption({
name: "stop",
Expand Down
31 changes: 30 additions & 1 deletion src/lib/native-compose-adoption-binding.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { resolve } from "node:path";
import { isRecord } from "./guards.ts";
import { legacyComposeAdoptionLayoutSupported } from "./native-compose-adoption-contract.ts";
import {
type LegacyComposeStorageIntent,
planLegacyComposeAdoption,
Expand All @@ -12,7 +13,10 @@ import {
acquireNativeConfigImportInputs,
type NativeConfigImportSourceIdentity,
} from "./native-config-import-inputs.ts";
import { freezeImportValue } from "./native-config-import-plan.ts";
import {
freezeImportValue,
mapLegacyNativeStorageAdoption,
} from "./native-config-import-plan.ts";

const ID = /^[a-f0-9]{64}$/;
const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,254}$/;
Expand All @@ -28,6 +32,8 @@ const ROUTING = [
"DOCKER_TLS_VERIFY",
"DOCKER_CERT_PATH",
"DOCKER_API_VERSION",
"CI",
"HACK_EXECUTION_MODE",
] as const;
const formats = {
container: {
Expand Down Expand Up @@ -587,6 +593,7 @@ export async function acquireLegacyComposeAdoptionBinding(input: {
const source = await acquireNativeConfigImportInputs({
projectRoot: root,
signal,
allowLinkedWorktree: true,
});
if (!source.ok) {
refuse("E_LEGACY_COMPOSE_BINDING_UNSUPPORTED");
Expand All @@ -599,6 +606,26 @@ export async function acquireLegacyComposeAdoptionBinding(input: {
if (!intent) {
refuse("E_LEGACY_COMPOSE_BINDING_UNSUPPORTED");
}
const mapped = mapLegacyNativeStorageAdoption({
configText: source.configText,
composeText: source.composeText,
});
const candidate = mapped.candidate;
const layoutSupported = async (selectedSignal?: AbortSignal) => {
if (
!(
candidate &&
(await legacyComposeAdoptionLayoutSupported({
projectRoot: root,
candidate,
signal: selectedSignal,
}))
)
) {
refuse("E_LEGACY_COMPOSE_BINDING_UNSUPPORTED");
}
};
await layoutSupported(signal);
const baseline = await inspectLegacyComposeAdoptionResources({
root,
intent,
Expand All @@ -625,6 +652,7 @@ export async function acquireLegacyComposeAdoptionBinding(input: {
refuse("E_LEGACY_COMPOSE_BINDING_CHANGED");
}
await source.assertFresh({ signal: currentSignal });
await layoutSupported(currentSignal);
const observed = await inspectLegacyComposeAdoptionResources({
root,
intent,
Expand All @@ -635,6 +663,7 @@ export async function acquireLegacyComposeAdoptionBinding(input: {
refuse("E_LEGACY_COMPOSE_BINDING_CHANGED");
}
await source.assertFresh({ signal: currentSignal });
await layoutSupported(currentSignal);
cancelled(signal);
if (JSON.stringify(routing()) !== route) {
refuse("E_LEGACY_COMPOSE_BINDING_CHANGED");
Expand Down
166 changes: 166 additions & 0 deletions src/lib/native-compose-adoption-checkout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { lstat } from "node:fs/promises";
import { join } from "node:path";
import { isRecord } from "./guards.ts";
import {
type HeldDirectory,
holdDirectory,
recheckDirectories,
} from "./native-compose-private-state.ts";
import { readNativeConfigImportSourceFile } from "./native-config-import-inputs.ts";
import { resolveVerifiedGitCheckoutLocation } from "./worktree-local-config.ts";

type Identity = { readonly dev: number; readonly ino: number };
type Source = Identity & { readonly hash: string };
export type LinkedAdoptionGitIdentity = {
readonly kind: "linked-worktree";
readonly marker: Source;
readonly admin: Identity;
readonly common: Identity;
readonly primary: Identity;
readonly backlink: Source;
readonly commonLink: Source;
};
function refuse(): never {
throw new Error(
"Legacy adoption Git checkout is unsafe or changed; values omitted."
);
}
function identity(info: Identity): Identity {
return Object.freeze({ dev: info.dev, ino: info.ino });
}
function capability(opts: {
readonly identity: Identity | LinkedAdoptionGitIdentity;
readonly directories: readonly HeldDirectory[];
readonly assertFresh: () => Promise<void>;
}) {
const result = { ...opts, directories: Object.freeze([...opts.directories]) };
for (const key of Object.keys(result)) {
Object.defineProperty(result, key, { enumerable: false });
}
return Object.freeze(result);
}
async function source(path: string, signal?: AbortSignal): Promise<Source> {
const read = await readNativeConfigImportSourceFile({ path, signal });
if (read.info.uid !== process.getuid?.() || (read.info.mode & 0o022) !== 0) {
refuse();
}
return Object.freeze({
...identity(read.info),
hash: new Bun.CryptoHasher("sha256").update(read.bytes).digest("hex"),
});
}

/**
* Private checkout authority for the durable adoption owner and selector.
* Directory receipts retain their original identity contract. Linked receipts
* additionally bind the verified Git family and the raw pointer files; no path,
* pointer content or digest belongs in public metadata. Checks cannot freeze Git.
* The caller owns returned directory descriptors and must close them.
*/
export async function acquireLegacyComposeAdoptionCheckout(input: {
readonly projectRoot: string;
readonly signal?: AbortSignal;
}) {
const directories: HeldDirectory[] = [];
try {
if (
!isRecord(input) ||
typeof input.projectRoot !== "string" ||
!input.projectRoot.length ||
input.projectRoot.includes("\0") ||
(input.signal !== undefined && !(input.signal instanceof AbortSignal))
) {
refuse();
}
const opts = { projectRoot: input.projectRoot, signal: input.signal };
if (opts.signal?.aborted) {
refuse();
}
const markerPath = join(opts.projectRoot, ".git");
const marker = await lstat(markerPath);
if (marker.isDirectory()) {
const held = await holdDirectory(markerPath, false);
directories.push(held);
return capability({
identity: identity(held.info),
directories,
assertFresh: async () => {
try {
if (opts.signal?.aborted) {
refuse();
}
await recheckDirectories(directories);
} catch {
refuse();
}
},
});
}
if (!marker.isFile() || marker.isSymbolicLink()) {
refuse();
}
const location = await resolveVerifiedGitCheckoutLocation(opts);
if (!location.primaryRoot || location.gitDir === location.commonDir) {
refuse();
}
for (const path of [
location.gitDir,
location.commonDir,
location.primaryRoot,
]) {
directories.push(await holdDirectory(path, false));
}
const [admin, common, primary] = directories;
if (!(admin && common && primary)) {
refuse();
}
const read = async (): Promise<LinkedAdoptionGitIdentity> => {
const backlinkPath = join(location.gitDir, "gitdir");
const backlink = await readNativeConfigImportSourceFile({
path: backlinkPath,
signal: opts.signal,
});
const back = new TextDecoder("utf-8", { fatal: true }).decode(
backlink.bytes
);
if (back !== `${markerPath}\n`) {
refuse();
}
const result: LinkedAdoptionGitIdentity = {
kind: "linked-worktree",
marker: await source(markerPath, opts.signal),
admin: identity(admin.info),
common: identity(common.info),
primary: identity(primary.info),
backlink: await source(backlinkPath, opts.signal),
commonLink: await source(
join(location.gitDir, "commondir"),
opts.signal
),
};
await recheckDirectories(directories);
return Object.freeze(result);
};
const binding = await read();
if (
JSON.stringify(await resolveVerifiedGitCheckoutLocation(opts)) !==
JSON.stringify(location)
) {
refuse();
}
const assertFresh = async () => {
try {
if (JSON.stringify(await read()) !== JSON.stringify(binding)) {
refuse();
}
} catch {
refuse();
}
};
await assertFresh();
return capability({ identity: binding, directories, assertFresh });
} catch {
await Promise.all(directories.map((held) => held.file.close()));
refuse();
}
}
Loading
Loading