diff --git a/docs/design/2026-08-29-pnpm-worktree-bootstrap.md b/docs/design/2026-08-29-pnpm-worktree-bootstrap.md index 30753793618..cc4d6eadfe4 100644 --- a/docs/design/2026-08-29-pnpm-worktree-bootstrap.md +++ b/docs/design/2026-08-29-pnpm-worktree-bootstrap.md @@ -49,8 +49,20 @@ registry access only when that cache-only attempt is incomplete. This avoids waiting for pnpm to prefetch optional binaries for other platforms on the common warm-store path. The script sets `QWEN_SKIP_PREPARE=1` plus a bootstrap-private notice-generation guard, keeping dependency install scripts -enabled while skipping repository build, bundle, Husky setup, and npm-layout -notice generation. Script execution does not +enabled while skipping repository build, bundle, and npm-layout notice +generation. Because `QWEN_SKIP_PREPARE=1` also suppresses the Husky step that +`scripts/prepare.js` runs for an npm install, the bootstrap installs Husky +hooks itself once the frozen install succeeds. It preserves an existing +non-default `core.hooksPath` and honours `HUSKY=0`; in a checkout that does not +own the repository config Husky would write, which git reports as a linked +worktree whose `core.hooksPath` is unset or as no repository at all, it skips +Husky and reports that, since husky's +unguarded `git config` write would otherwise set the hooks path in the config +every worktree of the repository shares while creating `.husky/_` only in the +bootstrapped checkout; and it fails closed when a Husky run leaves the hooks +path unconfigured or writes no hook wrappers. Tree cleanliness is preserved by +the `.gitignore` husky +generates inside `.husky/_`. Script execution does not implicitly install stale dependencies; the bootstrap command is the explicit installation boundary. Building from this pnpm layout is deferred to Stage 2. diff --git a/scripts/setup-worktree.js b/scripts/setup-worktree.js index 3df91b5a24f..2fc6a4d4944 100644 --- a/scripts/setup-worktree.js +++ b/scripts/setup-worktree.js @@ -28,10 +28,14 @@ const env = { // A spread of process.env is an ordinary object: on Windows the path // variable canonically arrives as `Path`, so a case-sensitive `env.PATH` // read misses it and corepack is never found. +function envValue(name) { + if (process.platform !== 'win32') return env[name]; + const key = Object.keys(env).find((key) => key.toUpperCase() === name); + return key === undefined ? undefined : env[key]; +} + function pathValue() { - if (process.platform !== 'win32') return env.PATH ?? ''; - const key = Object.keys(env).find((name) => name.toUpperCase() === 'PATH'); - return (key !== undefined ? env[key] : env.PATH) ?? ''; + return envValue('PATH') ?? ''; } function findOnPath(command) { @@ -61,8 +65,89 @@ function runPnpm(args) { }); } +function getHooksPath() { + const result = spawnSync('git', ['config', '--get', 'core.hooksPath'], { + cwd: rootDir, + env, + encoding: 'utf8', + }); + if (result.status === 0) return result.stdout.trim(); + // git exits 1 when the key is absent, and a spawn failure means git itself + // is unavailable — the ownership probe below reports that shape as having + // no repository. Any other status is a read failure (a refused config on a + // shared host, a config error) the hooks decision must not be made from. + if (result.status === 1 || result.error) return undefined; + console.error( + `worktree setup failed: could not read core.hooksPath (${result.stderr.trim()})`, + ); + process.exit(1); +} + +// Husky runs `git config core.hooksPath .husky/_` with no --worktree, so the +// value always lands in the config of the root that owns the repository while +// the `.husky/_` wrappers are created in the working directory it was invoked +// from. Git names that root: `--git-dir` differs from `--git-common-dir` only +// in a linked worktree, whose config every sibling worktree shares. The shape +// of `.git` is no proxy for it — a file also means a `--separate-git-dir` clone +// or a submodule, which own their config, and no `.git` means no repository. +function repositoryConfigOwnership() { + const probe = spawnSync( + 'git', + ['rev-parse', '--git-dir', '--git-common-dir'], + { cwd: rootDir, env, encoding: 'utf8' }, + ); + if (probe.status !== 0) return 'none'; + const [gitDir, commonDir] = probe.stdout.trim().split(/\r?\n/); + return gitDir === commonDir ? 'owns' : 'linked'; +} + function install(cacheMode) { - return runPnpm(['install', '--frozen-lockfile', cacheMode]); + const result = runPnpm(['install', '--frozen-lockfile', cacheMode]); + if (result.status === 0) { + const hooksPath = getHooksPath(); + if ( + envValue('HUSKY') === '0' || + (hooksPath !== undefined && hooksPath !== '.husky/_') + ) { + exitWithResult(result); + } + // Without a repository there is no config for husky to write. With the key + // unset in a linked worktree, husky's write would add it to the config + // every worktree of this repository shares while only this checkout + // receives `.husky/_`, silently repointing hook resolution for roots that + // never got the wrappers. Leave both alone and say so instead. + const ownership = repositoryConfigOwnership(); + if ( + ownership === 'none' || + (hooksPath === undefined && ownership === 'linked') + ) { + console.log( + ownership === 'none' + ? 'worktree setup: git could not resolve a repository for this ' + + 'checkout; skipping Husky because there is no repository config ' + + 'for it to write.' + : 'worktree setup: core.hooksPath is unset and this checkout does not ' + + 'own the repository config; skipping Husky so the hooks path is ' + + 'not rewritten for every other worktree. Re-run this script here ' + + 'once hooks are installed in the primary checkout.', + ); + exitWithResult(result); + } + // Husky exits 0 on every soft failure (`.git can't be found`, a refused + // `git config` write), so success takes both proofs: the config value says + // git will use the hooks, and a wrapper on disk says husky wrote them here. + const husky = runPnpm(['exec', 'husky']); + if ( + husky.status === 0 && + (getHooksPath() !== '.husky/_' || + !existsSync(resolve(rootDir, '.husky', '_', 'pre-commit'))) + ) { + console.error('worktree setup failed: Husky did not install hooks'); + process.exit(1); + } + exitWithResult(husky); + } + return result; } function exitWithResult(result) { @@ -80,10 +165,10 @@ function exitWithResult(result) { process.exit(result.status ?? 1); } +// install() exits the process on every path where the install succeeded, so it +// returns only a failed result and the registry retry below is the only +// decision left for this driver to make. const cachedInstall = install('--offline'); -if (cachedInstall.status === 0) { - process.exit(0); -} if ( cachedInstall.error || diff --git a/scripts/tests/package-scripts.test.js b/scripts/tests/package-scripts.test.js index 7b69e81811b..a3559d8a1d3 100644 --- a/scripts/tests/package-scripts.test.js +++ b/scripts/tests/package-scripts.test.js @@ -28,6 +28,12 @@ import { getWorkflowJob, getWorkflowStep } from './workflow-helpers.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(__dirname, '../..'); +const huskyTestEnv = { + HUSKY: '1', + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'core.hooksPath', + GIT_CONFIG_VALUE_0: '.husky/_', +}; function readPackageJson() { return JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')); @@ -210,49 +216,328 @@ describe('package scripts', () => { expect(new Set(specifiers)).toEqual(new Set(['workspace:*'])); }); - it('bootstraps worktrees with frozen pnpm dependencies and skips prepare', () => { + it('bootstraps worktrees and preserves explicit hook settings', () => { const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-worktree-setup-')); const commandDir = path.join(binDir, 'runner bin'); const logFile = path.join(binDir, 'corepack.log'); mkdirSync(commandDir); + // The stub husky has to leave the wrapper the bootstrap verifies. It is + // confined to `.husky/_` and removed again unless a real Husky install + // already put one there. + const stubHooksDir = path.join(root, '.husky', '_'); + const hadStubHooksDir = existsSync(stubHooksDir); + + const runSetup = (envOverride = {}) => { + writeFileSync(logFile, ''); + return spawnSync( + process.execPath, + [path.join(root, 'scripts/setup-worktree.js')], + { + cwd: root, + encoding: 'utf8', + env: { + ...process.env, + ...huskyTestEnv, + ...envOverride, + PATH: `${commandDir}${path.delimiter}${process.env.PATH ?? ''}`, + WORKTREE_SETUP_LOG: logFile, + }, + }, + ); + }; try { if (process.platform === 'win32') { writeFileSync( path.join(commandDir, 'corepack.cmd'), - '@echo %QWEN_SKIP_PREPARE% %QWEN_SKIP_NOTICE_GENERATION% %*>>"%WORKTREE_SETUP_LOG%"\r\n', + '@echo %QWEN_SKIP_PREPARE% %QWEN_SKIP_NOTICE_GENERATION% %*>>"%WORKTREE_SETUP_LOG%"\r\n@if not "%2"=="exec" exit /b 0\r\n@if exist ".husky\\_\\pre-commit" exit /b 0\r\n@if not exist ".husky\\_" mkdir ".husky\\_"\r\n@type nul > ".husky\\_\\pre-commit"\r\n', ); } else { writeFileSync( path.join(commandDir, 'corepack'), - '#!/bin/sh\necho "$QWEN_SKIP_PREPARE $QWEN_SKIP_NOTICE_GENERATION $*" >> "$WORKTREE_SETUP_LOG"\n', + '#!/bin/sh\necho "$QWEN_SKIP_PREPARE $QWEN_SKIP_NOTICE_GENERATION $*" >> "$WORKTREE_SETUP_LOG"\n[ "$2" = "exec" ] || exit 0\n[ -e .husky/_/pre-commit ] && exit 0\nmkdir -p .husky/_\n: > .husky/_/pre-commit\n', ); chmodSync(path.join(commandDir, 'corepack'), 0o755); } - const result = spawnSync( - process.execPath, - [path.join(root, 'scripts/setup-worktree.js')], - { - cwd: root, - encoding: 'utf8', - env: { - ...process.env, - PATH: `${commandDir}${path.delimiter}${process.env.PATH ?? ''}`, - WORKTREE_SETUP_LOG: logFile, - }, - }, - ); + const result = runSetup(); expect(result.status).toBe(0); + expect(readFileSync(logFile, 'utf8').trim().split(/\r?\n/)).toEqual([ + '1 1 pnpm install --frozen-lockfile --offline', + '1 1 pnpm exec husky', + ]); + expect(runSetup({ HUSKY: '0' }).status).toBe(0); expect(readFileSync(logFile, 'utf8').trim()).toBe( '1 1 pnpm install --frozen-lockfile --offline', ); + expect(runSetup({ GIT_CONFIG_VALUE_0: '/custom/hooks' }).status).toBe(0); + expect(readFileSync(logFile, 'utf8').trim()).toBe( + '1 1 pnpm install --frozen-lockfile --offline', + ); + + // The pin above proves an explicit HUSKY value is honoured, but the + // state every real shell and CI job is in is unset. Deleting the key + // must still reach husky, so a gate that treats unset as disabled + // (e.g. `!== '1'`) goes red on the missing exec line. + const unsetEnv = { + ...process.env, + ...huskyTestEnv, + PATH: `${commandDir}${path.delimiter}${process.env.PATH ?? ''}`, + WORKTREE_SETUP_LOG: logFile, + }; + for (const key of Object.keys(unsetEnv)) { + if (key.toUpperCase() === 'HUSKY') delete unsetEnv[key]; + } + writeFileSync(logFile, ''); + const unsetResult = spawnSync( + process.execPath, + [path.join(root, 'scripts/setup-worktree.js')], + { cwd: root, encoding: 'utf8', env: unsetEnv }, + ); + expect(unsetResult.status).toBe(0); + expect(readFileSync(logFile, 'utf8').trim().split(/\r?\n/)).toEqual([ + '1 1 pnpm install --frozen-lockfile --offline', + '1 1 pnpm exec husky', + ]); } finally { + if (!hadStubHooksDir) { + rmSync(stubHooksDir, { recursive: true, force: true }); + } rmSync(binDir, { recursive: true, force: true }); } }); + /** + * The hook logic reads `core.hooksPath` from git and asks git which root owns + * the config husky would write. Neither is reachable from the injected + * `GIT_CONFIG_*` constant above: it pins the value for the child's whole + * lifetime, so the unset state — the only one where husky is asked to write — + * cannot come from it, and ownership comes from `git rev-parse`, which + * answers only for a real layout. These cases build throwaway trees with real + * git commands instead: a primary checkout (`.git` directory), a linked + * worktree and a `--separate-git-dir` clone (both `.git` files, only the + * clone owning its config), and a directory with no repository at all. The + * stub husky lets each case choose what husky writes and what it exits with. + */ + it('installs hooks only where the checkout owns the repository config', () => { + const sandbox = mkdtempSync(path.join(tmpdir(), 'qwen-worktree-hooks-')); + const commandDir = path.join(sandbox, 'bin'); + const primary = path.join(sandbox, 'primary'); + const linked = path.join(sandbox, 'linked'); + const separate = path.join(sandbox, 'separate'); + const separateGitDir = path.join(sandbox, 'separate-git'); + const noRepo = path.join(sandbox, 'no-repo'); + const logFile = path.join(sandbox, 'corepack.log'); + const emptyConfig = path.join(sandbox, 'empty-config'); + const gitEnv = { + ...process.env, + GIT_CONFIG_COUNT: '0', + GIT_CONFIG_GLOBAL: emptyConfig, + GIT_CONFIG_SYSTEM: emptyConfig, + }; + const installLine = '1 1 pnpm install --frozen-lockfile --offline'; + const huskyLine = '1 1 pnpm exec husky'; + + try { + mkdirSync(commandDir, { recursive: true }); + writeFileSync(emptyConfig, ''); + + // Stands in for husky: logs the invocation like the neighbouring stubs, + // then writes what husky writes and exits as the case asks. + const stub = + process.platform === 'win32' + ? '@echo %QWEN_SKIP_PREPARE% %QWEN_SKIP_NOTICE_GENERATION% %*>>"%WORKTREE_SETUP_LOG%"\r\n@if not "%2"=="exec" exit /b 0\r\n@if not "%STUB_HUSKY_EXIT%"=="" exit /b %STUB_HUSKY_EXIT%\r\n@if "%STUB_HUSKY_SETS_HOOKS_PATH%"=="1" git config core.hooksPath .husky/_\r\n@if not "%STUB_HUSKY_WRITES_HOOKS%"=="1" exit /b 0\r\n@if not exist ".husky\\_" mkdir ".husky\\_"\r\n@type nul > ".husky\\_\\pre-commit"\r\n' + : '#!/bin/sh\necho "$QWEN_SKIP_PREPARE $QWEN_SKIP_NOTICE_GENERATION $*" >> "$WORKTREE_SETUP_LOG"\n[ "$2" = "exec" ] || exit 0\n[ -z "$STUB_HUSKY_EXIT" ] || exit "$STUB_HUSKY_EXIT"\n[ "$STUB_HUSKY_SETS_HOOKS_PATH" = "1" ] && git config core.hooksPath .husky/_\n[ "$STUB_HUSKY_WRITES_HOOKS" = "1" ] && mkdir -p .husky/_ && : > .husky/_/pre-commit\nexit 0\n'; + writeFileSync( + path.join( + commandDir, + process.platform === 'win32' ? 'corepack.cmd' : 'corepack', + ), + stub, + ); + if (process.platform !== 'win32') { + chmodSync(path.join(commandDir, 'corepack'), 0o755); + } + + const seedCheckout = (checkout) => { + mkdirSync(path.join(checkout, 'scripts'), { recursive: true }); + writeFileSync( + path.join(checkout, 'package.json'), + `${JSON.stringify({ packageManager: 'pnpm@11.24.0' }, null, 2)}\n`, + ); + for (const script of ['setup-worktree.js', 'pnpm-package.js']) { + writeFileSync( + path.join(checkout, 'scripts', script), + readFileSync(path.join(root, 'scripts', script), 'utf8'), + ); + } + }; + const git = (args, cwd) => + spawnSync('git', args, { cwd, encoding: 'utf8', env: gitEnv }); + const identity = [ + '-c', + 'user.name=fixture', + '-c', + 'user.email=fixture@example.com', + ]; + + seedCheckout(primary); + expect(git(['init', '--quiet', primary], sandbox).status).toBe(0); + expect(git(['add', '--all'], primary).status).toBe(0); + expect( + git([...identity, 'commit', '--quiet', '-m', 'fixture'], primary) + .status, + ).toBe(0); + expect( + git(['worktree', 'add', '--quiet', '--detach', linked], primary).status, + ).toBe(0); + expect( + git( + [ + 'clone', + '--quiet', + '--separate-git-dir', + separateGitDir, + primary, + separate, + ], + sandbox, + ).status, + ).toBe(0); + seedCheckout(noRepo); + + const runSetup = (checkout, huskyStub = {}) => { + writeFileSync(logFile, ''); + return spawnSync( + process.execPath, + [path.join(checkout, 'scripts', 'setup-worktree.js')], + { + cwd: checkout, + encoding: 'utf8', + env: { + ...process.env, + HUSKY: '1', + STUB_HUSKY_SETS_HOOKS_PATH: huskyStub.setsHooksPath ? '1' : '0', + STUB_HUSKY_WRITES_HOOKS: huskyStub.writesHooks ? '1' : '0', + STUB_HUSKY_EXIT: huskyStub.exit ?? '', + PATH: `${commandDir}${path.delimiter}${process.env.PATH ?? ''}`, + WORKTREE_SETUP_LOG: logFile, + // Read and write the throwaway trees' own config rather than the + // host checkout's, which already carries `core.hooksPath`. + GIT_CONFIG_COUNT: '0', + GIT_CONFIG_GLOBAL: emptyConfig, + GIT_CONFIG_SYSTEM: emptyConfig, + }, + }, + ); + }; + const hooksPathIsSet = (checkout) => + git(['config', '--get', 'core.hooksPath'], checkout).status === 0; + const resetPrimaryHooks = () => { + git(['config', '--unset', 'core.hooksPath'], primary); + rmSync(path.join(primary, '.husky'), { recursive: true, force: true }); + }; + + // A linked worktree with the key unset must not let husky add it to the + // config every worktree of the repository shares. + const linkedRun = runSetup(linked, { + setsHooksPath: true, + writesHooks: true, + }); + expect(linkedRun.status).toBe(0); + expect(linkedRun.stdout).toContain('skipping Husky'); + // The skip leaves the worktree hook-less until it is re-run after the + // primary installs, so the notice must carry that recovery path. + expect(linkedRun.stdout).toContain( + 'Re-run this script here once hooks are installed in the primary checkout.', + ); + expect(readFileSync(logFile, 'utf8').trim()).toBe(installLine); + expect(hooksPathIsSet(primary)).toBe(false); + expect(existsSync(path.join(linked, '.husky'))).toBe(false); + + // The primary checkout owns the config husky writes, so the same unset + // key installs hooks there. + const primaryRun = runSetup(primary, { + setsHooksPath: true, + writesHooks: true, + }); + expect(primaryRun.status).toBe(0); + expect(readFileSync(logFile, 'utf8').trim().split(/\r?\n/)).toEqual([ + installLine, + huskyLine, + ]); + expect(hooksPathIsSet(primary)).toBe(true); + expect(existsSync(path.join(primary, '.husky', '_', 'pre-commit'))).toBe( + true, + ); + + // A `.git` file does not by itself mean another root owns the config: a + // `--separate-git-dir` clone owns its own, so hooks install there. + const separateRun = runSetup(separate, { + setsHooksPath: true, + writesHooks: true, + }); + expect(separateRun.status).toBe(0); + expect(readFileSync(logFile, 'utf8').trim().split(/\r?\n/)).toEqual([ + installLine, + huskyLine, + ]); + expect(hooksPathIsSet(separate)).toBe(true); + + // With no repository at all there is no config to write, so husky is + // skipped rather than run into its `.git can't be found` soft failure. + const noRepoRun = runSetup(noRepo, { + setsHooksPath: true, + writesHooks: true, + }); + expect(noRepoRun.status).toBe(0); + expect(noRepoRun.stdout).toContain('skipping Husky'); + expect(readFileSync(logFile, 'utf8').trim()).toBe(installLine); + expect(existsSync(path.join(noRepo, '.husky'))).toBe(false); + + // A husky that configures the hooks path but writes no wrappers still + // ships a hook-less checkout, so the on-disk half fires on its own. + resetPrimaryHooks(); + const configOnly = runSetup(primary, { setsHooksPath: true }); + expect(configOnly.status).toBe(1); + expect(configOnly.stderr).toContain('Husky did not install hooks'); + + // A husky that exits 0 having written nothing fails closed too. + resetPrimaryHooks(); + const hookless = runSetup(primary); + expect(hookless.status).toBe(1); + expect(hookless.stderr).toContain('Husky did not install hooks'); + + // A husky that fails outright decides the exit code, not the install + // result that preceded it. + const failing = runSetup(primary, { exit: '7' }); + expect(failing.status).toBe(7); + expect(readFileSync(logFile, 'utf8').trim().split(/\r?\n/)).toEqual([ + installLine, + huskyLine, + ]); + + // A git that answers but refuses to read the config (a shared pool's + // dubious-ownership exit 128, a config error) is not "key unset": the + // read failure must surface rather than land on a skip branch with a + // green exit. A script stub cannot shadow git.exe on Windows. + if (process.platform !== 'win32') { + writeFileSync(path.join(commandDir, 'git'), '#!/bin/sh\nexit 128\n'); + chmodSync(path.join(commandDir, 'git'), 0o755); + const unreadable = runSetup(primary, { + setsHooksPath: true, + writesHooks: true, + }); + expect(unreadable.status).toBe(1); + expect(unreadable.stdout).not.toContain('skipping Husky'); + expect(unreadable.stderr).toContain('could not read core.hooksPath'); + } + } finally { + rmSync(sandbox, { recursive: true, force: true }); + } + }); + it.skipIf(process.platform !== 'win32')( 'resolves the path variable under its native Windows casing', () => { @@ -270,10 +555,16 @@ describe('package scripts', () => { // Native shells expose the path variable as `Path`; a case-sensitive // `env.PATH` read on the spread object would miss it and report // Corepack unavailable. - const env = { ...process.env, WORKTREE_SETUP_LOG: logFile }; + const env = { + ...process.env, + ...huskyTestEnv, + WORKTREE_SETUP_LOG: logFile, + }; delete env.PATH; delete env.Path; + delete env.HUSKY; env.Path = `${commandDir}${path.delimiter}${process.env.Path ?? process.env.PATH ?? ''}`; + env.Husky = '0'; const result = spawnSync( process.execPath, @@ -342,8 +633,13 @@ describe('package scripts', () => { { cwd: root, encoding: 'utf8', + // `PATH` holds only the stub directory, so the fallback stays pinned + // as needing no ambient tooling: with no git to resolve a repository + // the hook step reports itself skipped rather than failing an install + // that succeeded. env: { ...process.env, + HUSKY: '1', PATH: binDir, WORKTREE_SETUP_LOG: logFile, }, @@ -351,6 +647,7 @@ describe('package scripts', () => { ); expect(result.status).toBe(0); + expect(result.stdout).toContain('skipping Husky'); expect(readFileSync(logFile, 'utf8').trim().split(/\r?\n/)).toEqual([ '1 1 pnpm install --frozen-lockfile --offline', '1 1 pnpm install --frozen-lockfile --prefer-offline',