Skip to content

Commit abc695d

Browse files
roodboiclaude
andcommitted
feat(worktree): first-class linked-worktree development
Secret keys never silently diverge (shared-location writes, primary-key adoption, loud fallback warnings). hack up/down/restart/ps/logs/open default to a branch instance named for the worktree branch (opt out: worktree.auto_branch=false or explicit --branch). hack doctor gains divergent-key and cross-checkout instance checks. Registry tracks sibling worktree checkouts, shown in hack projects --details/--json. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3660c27 commit abc695d

18 files changed

Lines changed: 1768 additions & 49 deletions

docs/cli.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,23 @@ hack up --detach
5757
hack open
5858
```
5959

60+
## Branch instances and linked worktrees
61+
62+
`--branch <name>` on `hack up/down/restart/ps/logs/open` targets a separate branch instance
63+
(compose project `<name>--<branch>`, hostnames prefixed with the branch).
64+
65+
In a linked git worktree, these commands default the branch instance to the sanitized current
66+
git branch when no `--branch` is passed, so two checkouts never fight over the same hostnames.
67+
A one-line notice is printed when the default kicks in.
68+
69+
Opt out:
70+
71+
- pass `--branch <name>` explicitly (always wins), or
72+
- set `worktree.auto_branch` to `false` in `.hack/hack.config.json` to target the base instance.
73+
74+
The primary checkout is unchanged: no `--branch` means the base instance.
75+
`hack run` and `hack exec` still target the base instance unless `--branch` is passed.
76+
6077
## Environment model
6178

6279
Canonical env files:

docs/env.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,15 @@ Linked worktree behavior:
242242
- sibling worktrees can decrypt the same committed secrets without manually copying gitignored files
243243
- if a checkout-local `.hack.secret.key` exists, it still wins for that checkout
244244

245+
Write-path guarantees in a linked worktree (first secret added from any checkout):
246+
247+
- an existing checkout-local key is reused as-is
248+
- otherwise an existing shared key (git common dir) is reused
249+
- otherwise the primary checkout's key is adopted by copying it to the shared location, so sibling checkouts converge on one key
250+
- only then is a new key generated, and it is written to the shared location — never silently to the checkout
251+
- if the shared location cannot be resolved (git unavailable) or written, Hack falls back to a checkout-local key and prints a loud divergence warning
252+
- `hack doctor` flags divergent `.hack.secret.key` contents across checkouts with the exact paths
253+
245254
CI and managed container fallback:
246255

247256
- if `.hack.secret.key` is missing, Hack falls back to `HACK_ENV_SECRET_KEY`

src/commands/doctor.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { lookup } from "node:dns/promises";
22
import { dirname, resolve } from "node:path";
33
import { confirm, isCancel, note, spinner } from "@clack/prompts";
4+
import { YAML } from "bun";
45
import type { CommandHandlerFor } from "../cli/command.ts";
56
import { defineCommand, defineOption, withHandler } from "../cli/command.ts";
67
import { optPath } from "../cli/options.ts";
@@ -36,13 +37,18 @@ import {
3637
} from "../lib/caddy-hosts.ts";
3738
import { resolveGlobalConfigPath } from "../lib/config-paths.ts";
3839
import { checkMacHostTlsTrust } from "../lib/doctor-host-tls.ts";
40+
import {
41+
findCrossCheckoutInstances,
42+
inspectWorktreeSecretKeys,
43+
} from "../lib/doctor-worktree.ts";
3944
import { parseDotEnv } from "../lib/env.ts";
4045
import {
4146
ensureDir,
4247
pathExists,
4348
readTextFile,
4449
writeTextFileIfChanged,
4550
} from "../lib/fs.ts";
51+
import { getString, isRecord } from "../lib/guards.ts";
4652
import { resolveHackInvocation } from "../lib/hack-cli.ts";
4753
import { inspectHackEnvOverlayWarnings } from "../lib/hack-env.ts";
4854
import {
@@ -53,6 +59,7 @@ import {
5359
} from "../lib/mutagen.ts";
5460
import { isMac } from "../lib/os.ts";
5561
import {
62+
defaultProjectSlugFromPath,
5663
findProjectContext,
5764
readProjectConfig,
5865
readProjectDevHost,
@@ -208,6 +215,8 @@ const DOCTOR_SUMMARY_GROUPS = [
208215
"env mode",
209216
"env materialization",
210217
"env overlay warnings",
218+
"worktree keys",
219+
"worktree instances",
211220
]),
212221
},
213222
{
@@ -488,6 +497,32 @@ const handleDoctor: CommandHandlerFor<typeof doctorSpec> = async ({
488497
checkProjectEnvOverlayWarnings({ startDir })
489498
)
490499
);
500+
results.push(
501+
await runCheck(
502+
s,
503+
"worktree keys",
504+
() => checkWorktreeSecretKeys({ startDir }),
505+
{
506+
timeoutMs: 5000,
507+
}
508+
)
509+
);
510+
if (
511+
results.some(
512+
(result) => result.name === "docker daemon" && result.status === "ok"
513+
)
514+
) {
515+
results.push(
516+
await runCheck(
517+
s,
518+
"worktree instances",
519+
() => checkWorktreeInstanceCollisions({ startDir }),
520+
{
521+
timeoutMs: 5000,
522+
}
523+
)
524+
);
525+
}
491526
results.push(
492527
await runCheck(
493528
s,
@@ -1890,6 +1925,149 @@ async function checkProjectEnvMaterialization({
18901925
};
18911926
}
18921927

1928+
async function checkWorktreeSecretKeys({
1929+
startDir,
1930+
}: {
1931+
readonly startDir: string;
1932+
}): Promise<CheckResult> {
1933+
const name = "worktree keys";
1934+
const ctx = await findProjectContext(startDir);
1935+
if (!ctx) {
1936+
return {
1937+
name,
1938+
status: "warn",
1939+
message: `Skipped (no ${HACK_PROJECT_DIR_PRIMARY}/ found)`,
1940+
};
1941+
}
1942+
1943+
const inspection = await inspectWorktreeSecretKeys({
1944+
projectRoot: ctx.projectRoot,
1945+
});
1946+
if (!inspection) {
1947+
return {
1948+
name,
1949+
status: "ok",
1950+
message: "Skipped (not a git checkout)",
1951+
};
1952+
}
1953+
if (inspection.checkouts.length <= 1) {
1954+
return {
1955+
name,
1956+
status: "ok",
1957+
message: "Single checkout (no linked worktrees)",
1958+
};
1959+
}
1960+
if (inspection.divergent) {
1961+
const shared = inspection.sharedKeyPath ?? "unavailable";
1962+
return {
1963+
name,
1964+
status: "warn",
1965+
message: `Divergent .hack.secret.key contents across checkouts: ${inspection.divergentKeyPaths.join(
1966+
", "
1967+
)}. Secrets encrypted in one checkout will not decrypt in another. Keep one key (shared location: ${shared}) and remove the divergent copies.`,
1968+
};
1969+
}
1970+
1971+
return {
1972+
name,
1973+
status: "ok",
1974+
message: `Env key consistent across ${inspection.checkouts.length} checkouts`,
1975+
};
1976+
}
1977+
1978+
async function checkWorktreeInstanceCollisions({
1979+
startDir,
1980+
}: {
1981+
readonly startDir: string;
1982+
}): Promise<CheckResult> {
1983+
const name = "worktree instances";
1984+
const ctx = await findProjectContext(startDir);
1985+
if (!ctx) {
1986+
return {
1987+
name,
1988+
status: "warn",
1989+
message: `Skipped (no ${HACK_PROJECT_DIR_PRIMARY}/ found)`,
1990+
};
1991+
}
1992+
1993+
const baseProjectName = await resolveDoctorBaseProjectName(ctx);
1994+
const runtime = await readRuntimeProjects({ includeGlobal: false });
1995+
if (!runtime.ok) {
1996+
return {
1997+
name,
1998+
status: "warn",
1999+
message: `Skipped (${runtime.error})`,
2000+
};
2001+
}
2002+
2003+
const instances = findCrossCheckoutInstances({
2004+
baseProjectName,
2005+
currentProjectDir: ctx.projectDir,
2006+
runtime: runtime.runtime,
2007+
});
2008+
const runningBase = instances.filter(
2009+
(instance) => instance.running && instance.branch === null
2010+
);
2011+
if (runningBase.length > 0) {
2012+
const dirs = runningBase
2013+
.map((instance) => instance.workingDir ?? "unknown path")
2014+
.join(", ");
2015+
return {
2016+
name,
2017+
status: "warn",
2018+
message: `Base instance "${baseProjectName}" is running from another checkout (${dirs}) and claims this project's dev_host. Use --branch here (linked worktrees default to a branch instance) or run 'hack down' in that checkout.`,
2019+
};
2020+
}
2021+
2022+
const runningBranches = instances.filter(
2023+
(instance) => instance.running && instance.branch !== null
2024+
);
2025+
if (runningBranches.length > 0) {
2026+
const summary = runningBranches
2027+
.map(
2028+
(instance) =>
2029+
`${instance.composeProject} (${instance.workingDir ?? "unknown path"})`
2030+
)
2031+
.join(", ");
2032+
return {
2033+
name,
2034+
status: "ok",
2035+
message: `Branch instances running from other checkouts: ${summary}`,
2036+
};
2037+
}
2038+
2039+
return {
2040+
name,
2041+
status: "ok",
2042+
message: "No cross-checkout instances running",
2043+
};
2044+
}
2045+
2046+
async function resolveDoctorBaseProjectName(
2047+
ctx: NonNullable<Awaited<ReturnType<typeof findProjectContext>>>
2048+
): Promise<string> {
2049+
const text = await readTextFile(ctx.composeFile);
2050+
if (text) {
2051+
let parsed: unknown = null;
2052+
try {
2053+
parsed = YAML.parse(text);
2054+
} catch {
2055+
parsed = null;
2056+
}
2057+
if (isRecord(parsed)) {
2058+
const composeName = getString(parsed, "name")?.trim();
2059+
if (composeName && composeName.length > 0) {
2060+
return composeName;
2061+
}
2062+
}
2063+
}
2064+
2065+
const cfg = await readProjectConfig(ctx);
2066+
const derived = defaultProjectSlugFromPath(ctx.projectRoot);
2067+
const cfgName = (cfg.name ?? derived).trim();
2068+
return cfgName.length > 0 ? cfgName : derived;
2069+
}
2070+
18932071
async function checkCaddyHostMapping({
18942072
startDir,
18952073
}: {

0 commit comments

Comments
 (0)