11import { lookup } from "node:dns/promises" ;
22import { dirname , resolve } from "node:path" ;
33import { confirm , isCancel , note , spinner } from "@clack/prompts" ;
4+ import { YAML } from "bun" ;
45import type { CommandHandlerFor } from "../cli/command.ts" ;
56import { defineCommand , defineOption , withHandler } from "../cli/command.ts" ;
67import { optPath } from "../cli/options.ts" ;
@@ -36,13 +37,18 @@ import {
3637} from "../lib/caddy-hosts.ts" ;
3738import { resolveGlobalConfigPath } from "../lib/config-paths.ts" ;
3839import { checkMacHostTlsTrust } from "../lib/doctor-host-tls.ts" ;
40+ import {
41+ findCrossCheckoutInstances ,
42+ inspectWorktreeSecretKeys ,
43+ } from "../lib/doctor-worktree.ts" ;
3944import { parseDotEnv } from "../lib/env.ts" ;
4045import {
4146 ensureDir ,
4247 pathExists ,
4348 readTextFile ,
4449 writeTextFileIfChanged ,
4550} from "../lib/fs.ts" ;
51+ import { getString , isRecord } from "../lib/guards.ts" ;
4652import { resolveHackInvocation } from "../lib/hack-cli.ts" ;
4753import { inspectHackEnvOverlayWarnings } from "../lib/hack-env.ts" ;
4854import {
@@ -53,6 +59,7 @@ import {
5359} from "../lib/mutagen.ts" ;
5460import { isMac } from "../lib/os.ts" ;
5561import {
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+
18932071async function checkCaddyHostMapping ( {
18942072 startDir,
18952073} : {
0 commit comments