From 5416b451204c9e61a06675036d6d1be488a0ca8c Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Thu, 10 Sep 2026 00:23:45 +0800 Subject: [PATCH 1/4] fix(dev): install hooks during worktree bootstrap --- scripts/setup-worktree.js | 37 ++++++++++++++-- scripts/tests/package-scripts.test.js | 62 ++++++++++++++++++++------- 2 files changed, 79 insertions(+), 20 deletions(-) diff --git a/scripts/setup-worktree.js b/scripts/setup-worktree.js index 3df91b5a24f..0ea269493ad 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,33 @@ function runPnpm(args) { }); } +function getHooksPath() { + const result = spawnSync('git', ['config', '--get', 'core.hooksPath'], { + cwd: rootDir, + env, + encoding: 'utf8', + }); + return result.status === 0 ? result.stdout.trim() : undefined; +} + 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); + } + const husky = runPnpm(['exec', 'husky']); + if (husky.status === 0 && getHooksPath() !== '.husky/_') { + console.error('worktree setup failed: Husky did not install hooks'); + process.exit(1); + } + exitWithResult(husky); + } + return result; } function exitWithResult(result) { diff --git a/scripts/tests/package-scripts.test.js b/scripts/tests/package-scripts.test.js index 7b69e81811b..39a403d245b 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,12 +216,31 @@ 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); + 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( @@ -230,21 +255,18 @@ describe('package scripts', () => { 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', ); @@ -270,10 +292,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, @@ -344,7 +372,8 @@ describe('package scripts', () => { encoding: 'utf8', env: { ...process.env, - PATH: binDir, + ...huskyTestEnv, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`, WORKTREE_SETUP_LOG: logFile, }, }, @@ -354,6 +383,7 @@ describe('package scripts', () => { expect(readFileSync(logFile, 'utf8').trim().split(/\r?\n/)).toEqual([ '1 1 pnpm install --frozen-lockfile --offline', '1 1 pnpm install --frozen-lockfile --prefer-offline', + '1 1 pnpm exec husky', ]); } finally { rmSync(binDir, { recursive: true, force: true }); From d0cf2061de5687f4f2aa6199d94b056c78317487 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 10 Sep 2026 07:01:53 +0800 Subject: [PATCH 2/4] fix(dev): keep worktree hook setup out of the shared git config Husky runs `git config core.hooksPath .husky/_` with no --worktree, so from a linked worktree the value lands in the config every worktree of the repository shares while `.husky/_` is created only in the checkout being bootstrapped. Skip the Husky step and report it when the key is unset and this checkout does not own the repository config, so a bootstrap can no longer repoint hook resolution for roots that never received the wrappers. A primary checkout still installs hooks, and an already-configured `core.hooksPath` is untouched. Also drop the caller's success exit, which `install()` made unreachable when it started exiting on every successful path, and bring the pnpm-worktree-bootstrap design doc in line with a hook step it still recorded as deliberately skipped. The new fixture runs the real script against a throwaway root whose `.git` is a file or a directory and whose config comes from a real `git init` repo, which makes both new branches reachable and pins the fail-closed guard: the injected `GIT_CONFIG_*` constant holds one value for the child's whole lifetime and cannot express the unset state that asks husky to write. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmtuo29vduf --- .../2026-08-29-pnpm-worktree-bootstrap.md | 13 +- scripts/setup-worktree.js | 34 ++++- scripts/tests/package-scripts.test.js | 140 ++++++++++++++++++ 3 files changed, 181 insertions(+), 6 deletions(-) diff --git a/docs/design/2026-08-29-pnpm-worktree-bootstrap.md b/docs/design/2026-08-29-pnpm-worktree-bootstrap.md index 30753793618..11e84e1cea9 100644 --- a/docs/design/2026-08-29-pnpm-worktree-bootstrap.md +++ b/docs/design/2026-08-29-pnpm-worktree-bootstrap.md @@ -49,8 +49,17 @@ 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 linked worktree whose +`core.hooksPath` is unset 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. 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 0ea269493ad..7a82d44420a 100644 --- a/scripts/setup-worktree.js +++ b/scripts/setup-worktree.js @@ -5,7 +5,7 @@ */ import { spawnSync } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readFileSync, statSync } from 'node:fs'; import { constants as osConstants } from 'node:os'; import { delimiter, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -74,6 +74,18 @@ function getHooksPath() { return result.status === 0 ? result.stdout.trim() : undefined; } +// Husky runs `git config core.hooksPath .husky/_` with no --worktree, so the +// value always lands in the config of the root that owns `.git` while the +// `.husky/_` wrappers are created in the working directory it was invoked from. +// Those are the same root here unless `.git` is a file, which is how git marks +// a linked worktree pointing at the primary's `.git/worktrees/`. +function ownsRepositoryConfig() { + const gitEntry = statSync(resolve(rootDir, '.git'), { + throwIfNoEntry: false, + }); + return gitEntry === undefined || gitEntry.isDirectory(); +} + function install(cacheMode) { const result = runPnpm(['install', '--frozen-lockfile', cacheMode]); if (result.status === 0) { @@ -84,6 +96,20 @@ function install(cacheMode) { ) { exitWithResult(result); } + // With the key unset, husky's write would add it to the config shared by + // every worktree of this repository while only this checkout receives + // `.husky/_`, silently repointing hook resolution for roots that never got + // the wrappers. Leave that config alone and say so instead. `prepare.js`'s + // `run('husky')` needs no such guard: it installs the checkout that owns + // the config it writes. + if (hooksPath === undefined && !ownsRepositoryConfig()) { + console.log( + 'worktree setup: core.hooksPath is unset and this linked worktree does ' + + 'not own the repository config; skipping Husky so the hooks path is ' + + 'not rewritten for every other worktree.', + ); + exitWithResult(result); + } const husky = runPnpm(['exec', 'husky']); if (husky.status === 0 && getHooksPath() !== '.husky/_') { console.error('worktree setup failed: Husky did not install hooks'); @@ -109,10 +135,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 39a403d245b..fb22fb2149b 100644 --- a/scripts/tests/package-scripts.test.js +++ b/scripts/tests/package-scripts.test.js @@ -275,6 +275,146 @@ describe('package scripts', () => { } }); + /** + * The hook logic reads `core.hooksPath` from git and decides from `.git` + * whether this checkout owns the config that write would land in. The + * injected `GIT_CONFIG_*` constant above cannot express either: it pins the + * value for the child's whole lifetime, so the unset state — the only one + * where husky is asked to write — is unreachable from it. These cases run the + * real script against a throwaway root instead: a synthetic checkout whose + * `.git` is a file (linked worktree) or a directory (primary), plus a real + * `git init` repo the child reads and writes through `GIT_DIR`, and a stub + * husky whose write the case controls. + */ + it('installs hooks only where the checkout owns the repository config', () => { + const sandbox = mkdtempSync(path.join(tmpdir(), 'qwen-worktree-hooks-')); + const checkout = path.join(sandbox, 'checkout'); + const commandDir = path.join(sandbox, 'bin'); + const configRepo = path.join(sandbox, 'config-repo'); + const configRepoGitDir = path.join(configRepo, '.git'); + const logFile = path.join(sandbox, 'corepack.log'); + const emptyConfig = path.join(sandbox, 'empty-config'); + + try { + mkdirSync(path.join(checkout, 'scripts'), { recursive: true }); + mkdirSync(commandDir, { recursive: true }); + writeFileSync(emptyConfig, ''); + 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'), + ); + } + + // Stands in for husky: logs the invocation like the neighbouring stubs, + // then writes the hooks path only when the case asks it to. + 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_SETS_HOOKS_PATH%"=="1" exit /b 0\r\n@git config core.hooksPath .husky/_\r\n' + : '#!/bin/sh\necho "$QWEN_SKIP_PREPARE $QWEN_SKIP_NOTICE_GENERATION $*" >> "$WORKTREE_SETUP_LOG"\n[ "$2" = "exec" ] || exit 0\n[ "$STUB_HUSKY_SETS_HOOKS_PATH" = "1" ] || exit 0\ngit config core.hooksPath .husky/_\n'; + writeFileSync( + path.join( + commandDir, + process.platform === 'win32' ? 'corepack.cmd' : 'corepack', + ), + stub, + ); + if (process.platform !== 'win32') { + chmodSync(path.join(commandDir, 'corepack'), 0o755); + } + expect( + spawnSync('git', ['init', '--quiet', configRepo], { encoding: 'utf8' }) + .status, + ).toBe(0); + + const sharedConfigEnv = { + ...process.env, + GIT_DIR: configRepoGitDir, + GIT_CONFIG_COUNT: '0', + }; + const runSetup = ({ ownsConfig, stubSetsHooksPath }) => { + // `.git` as a file is how git marks a linked worktree, and the script + // only ever looks at it through `statSync`, so a stand-in is enough. + rmSync(path.join(checkout, '.git'), { recursive: true, force: true }); + if (ownsConfig) { + mkdirSync(path.join(checkout, '.git')); + } else { + writeFileSync( + path.join(checkout, '.git'), + `gitdir: ${path.join(configRepoGitDir, 'worktrees', 'checkout')}\n`, + ); + } + 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: stubSetsHooksPath ? '1' : '0', + PATH: `${commandDir}${path.delimiter}${process.env.PATH ?? ''}`, + WORKTREE_SETUP_LOG: logFile, + // Read the throwaway repo's config rather than the host + // checkout's, which already carries `core.hooksPath`. + GIT_DIR: configRepoGitDir, + GIT_CONFIG_COUNT: '0', + GIT_CONFIG_GLOBAL: emptyConfig, + GIT_CONFIG_SYSTEM: emptyConfig, + }, + }, + ); + }; + const sharedHooksPath = () => + spawnSync('git', ['config', '--get', 'core.hooksPath'], { + cwd: configRepo, + encoding: 'utf8', + env: sharedConfigEnv, + }).status === 0; + + // A linked worktree with the key unset must not let husky add it to the + // config every worktree of the repository shares. + const linked = runSetup({ ownsConfig: false, stubSetsHooksPath: true }); + expect(linked.status).toBe(0); + expect(linked.stdout).toContain('skipping Husky'); + expect(readFileSync(logFile, 'utf8').trim()).toBe( + '1 1 pnpm install --frozen-lockfile --offline', + ); + expect(sharedHooksPath()).toBe(false); + + // The primary checkout owns the config husky writes, so the same unset + // key installs hooks there. + const primary = runSetup({ ownsConfig: true, stubSetsHooksPath: true }); + expect(primary.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(sharedHooksPath()).toBe(true); + + // A husky that exits 0 without configuring the hooks path fails closed + // instead of shipping a checkout with no hooks. + expect( + spawnSync('git', ['config', '--unset', 'core.hooksPath'], { + cwd: configRepo, + encoding: 'utf8', + env: sharedConfigEnv, + }).status, + ).toBe(0); + const hookless = runSetup({ ownsConfig: true, stubSetsHooksPath: false }); + expect(hookless.status).toBe(1); + expect(hookless.stderr).toContain('Husky did not install hooks'); + } finally { + rmSync(sandbox, { recursive: true, force: true }); + } + }); + it.skipIf(process.platform !== 'win32')( 'resolves the path variable under its native Windows casing', () => { From 03b1493cc045da0897ad8ca08aadec72a7b610cd Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 10 Sep 2026 12:11:52 +0800 Subject: [PATCH 3/4] fix(dev): ask git which root owns the worktree hook config `ownsRepositoryConfig()` inferred repository-config ownership from the filesystem shape of `.git`, and the proxy is wrong at both ends. With no `.git` at all, `statSync(..., { throwIfNoEntry: false })` returns `undefined` and the predicate folded that absence into "owns the config", so a repository-less checkout ran husky into its `.git can't be found` soft failure (exit 0) and the fail-closed check then turned a successful dependency install into exit 1 blaming Husky. A `.git` file is not only a linked worktree either: `git clone --separate-git-dir` checkouts and submodules have one too and do own their config, so hooks were declined where they would have been correctly scoped. Ask git instead: `rev-parse --git-dir` differs from `--git-common-dir` only in a linked worktree, and a failed `rev-parse` names the no-repository state, so the skip notice stops asserting "linked worktree" and the bootstrap does not gain a hard git dependency. Also bind the fail-closed check to an artefact husky's own write produced, not only to the config value. husky 9.1.7 exits 0 on every soft-failure path (`index.js:16` git command not found, `index.js:17` refused `git config` write) before the `mkdirSync(_())` on line 19, and a linked worktree inherits `core.hooksPath` from the config it shares, so re-reading that value compared it against itself and passed exactly when husky had created nothing. Restore the registry-fallback case's hermeticity: `PATH` holds only the stub directory again, which now also pins that the retry needs no ambient git. Rebuild the ownership fixture from real git layouts, because `rev-parse` resolves nothing for a `mkdirSync`'d `.git` or a hand-written `gitdir:` file, and give the stub husky a failing mode so the exit code husky returns is pinned rather than the install result's. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmtuys2rjuu --- .../2026-08-29-pnpm-worktree-bootstrap.md | 9 +- scripts/setup-worktree.js | 62 +++-- scripts/tests/package-scripts.test.js | 243 ++++++++++++------ 3 files changed, 209 insertions(+), 105 deletions(-) diff --git a/docs/design/2026-08-29-pnpm-worktree-bootstrap.md b/docs/design/2026-08-29-pnpm-worktree-bootstrap.md index 11e84e1cea9..cc4d6eadfe4 100644 --- a/docs/design/2026-08-29-pnpm-worktree-bootstrap.md +++ b/docs/design/2026-08-29-pnpm-worktree-bootstrap.md @@ -53,12 +53,15 @@ 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 linked worktree whose -`core.hooksPath` is unset it skips Husky and reports that, since husky's +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. Tree cleanliness is preserved by the `.gitignore` husky +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 7a82d44420a..61234793a6b 100644 --- a/scripts/setup-worktree.js +++ b/scripts/setup-worktree.js @@ -5,7 +5,7 @@ */ import { spawnSync } from 'node:child_process'; -import { existsSync, readFileSync, statSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { constants as osConstants } from 'node:os'; import { delimiter, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -75,15 +75,21 @@ function getHooksPath() { } // Husky runs `git config core.hooksPath .husky/_` with no --worktree, so the -// value always lands in the config of the root that owns `.git` while the -// `.husky/_` wrappers are created in the working directory it was invoked from. -// Those are the same root here unless `.git` is a file, which is how git marks -// a linked worktree pointing at the primary's `.git/worktrees/`. -function ownsRepositoryConfig() { - const gitEntry = statSync(resolve(rootDir, '.git'), { - throwIfNoEntry: false, - }); - return gitEntry === undefined || gitEntry.isDirectory(); +// 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) { @@ -96,22 +102,36 @@ function install(cacheMode) { ) { exitWithResult(result); } - // With the key unset, husky's write would add it to the config shared by - // every worktree of this repository while only this checkout receives - // `.husky/_`, silently repointing hook resolution for roots that never got - // the wrappers. Leave that config alone and say so instead. `prepare.js`'s - // `run('husky')` needs no such guard: it installs the checkout that owns - // the config it writes. - if (hooksPath === undefined && !ownsRepositoryConfig()) { + // 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( - 'worktree setup: core.hooksPath is unset and this linked worktree does ' + - 'not own the repository config; skipping Husky so the hooks path is ' + - 'not rewritten for every other worktree.', + 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.', ); 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/_') { + 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); } diff --git a/scripts/tests/package-scripts.test.js b/scripts/tests/package-scripts.test.js index fb22fb2149b..b5ee8225f5f 100644 --- a/scripts/tests/package-scripts.test.js +++ b/scripts/tests/package-scripts.test.js @@ -221,6 +221,11 @@ describe('package scripts', () => { 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, ''); @@ -245,12 +250,12 @@ describe('package scripts', () => { 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); } @@ -271,51 +276,54 @@ describe('package scripts', () => { '1 1 pnpm install --frozen-lockfile --offline', ); } finally { + if (!hadStubHooksDir) { + rmSync(stubHooksDir, { recursive: true, force: true }); + } rmSync(binDir, { recursive: true, force: true }); } }); /** - * The hook logic reads `core.hooksPath` from git and decides from `.git` - * whether this checkout owns the config that write would land in. The - * injected `GIT_CONFIG_*` constant above cannot express either: it pins the - * value for the child's whole lifetime, so the unset state — the only one - * where husky is asked to write — is unreachable from it. These cases run the - * real script against a throwaway root instead: a synthetic checkout whose - * `.git` is a file (linked worktree) or a directory (primary), plus a real - * `git init` repo the child reads and writes through `GIT_DIR`, and a stub - * husky whose write the case controls. + * 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 checkout = path.join(sandbox, 'checkout'); const commandDir = path.join(sandbox, 'bin'); - const configRepo = path.join(sandbox, 'config-repo'); - const configRepoGitDir = path.join(configRepo, '.git'); + 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(path.join(checkout, 'scripts'), { recursive: true }); mkdirSync(commandDir, { recursive: true }); writeFileSync(emptyConfig, ''); - 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'), - ); - } // Stands in for husky: logs the invocation like the neighbouring stubs, - // then writes the hooks path only when the case asks it to. + // 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_SETS_HOOKS_PATH%"=="1" exit /b 0\r\n@git config core.hooksPath .husky/_\r\n' - : '#!/bin/sh\necho "$QWEN_SKIP_PREPARE $QWEN_SKIP_NOTICE_GENERATION $*" >> "$WORKTREE_SETUP_LOG"\n[ "$2" = "exec" ] || exit 0\n[ "$STUB_HUSKY_SETS_HOOKS_PATH" = "1" ] || exit 0\ngit config core.hooksPath .husky/_\n'; + ? '@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, @@ -326,28 +334,55 @@ describe('package scripts', () => { if (process.platform !== 'win32') { chmodSync(path.join(commandDir, 'corepack'), 0o755); } - expect( - spawnSync('git', ['init', '--quiet', configRepo], { encoding: 'utf8' }) - .status, - ).toBe(0); - const sharedConfigEnv = { - ...process.env, - GIT_DIR: configRepoGitDir, - GIT_CONFIG_COUNT: '0', - }; - const runSetup = ({ ownsConfig, stubSetsHooksPath }) => { - // `.git` as a file is how git marks a linked worktree, and the script - // only ever looks at it through `statSync`, so a stand-in is enough. - rmSync(path.join(checkout, '.git'), { recursive: true, force: true }); - if (ownsConfig) { - mkdirSync(path.join(checkout, '.git')); - } else { + 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, '.git'), - `gitdir: ${path.join(configRepoGitDir, 'worktrees', 'checkout')}\n`, + 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, @@ -358,12 +393,13 @@ describe('package scripts', () => { env: { ...process.env, HUSKY: '1', - STUB_HUSKY_SETS_HOOKS_PATH: stubSetsHooksPath ? '1' : '0', + 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 the throwaway repo's config rather than the host - // checkout's, which already carries `core.hooksPath`. - GIT_DIR: configRepoGitDir, + // 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, @@ -371,45 +407,86 @@ describe('package scripts', () => { }, ); }; - const sharedHooksPath = () => - spawnSync('git', ['config', '--get', 'core.hooksPath'], { - cwd: configRepo, - encoding: 'utf8', - env: sharedConfigEnv, - }).status === 0; + 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 linked = runSetup({ ownsConfig: false, stubSetsHooksPath: true }); - expect(linked.status).toBe(0); - expect(linked.stdout).toContain('skipping Husky'); - expect(readFileSync(logFile, 'utf8').trim()).toBe( - '1 1 pnpm install --frozen-lockfile --offline', - ); - expect(sharedHooksPath()).toBe(false); + const linkedRun = runSetup(linked, { + setsHooksPath: true, + writesHooks: true, + }); + expect(linkedRun.status).toBe(0); + expect(linkedRun.stdout).toContain('skipping Husky'); + 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 primary = runSetup({ ownsConfig: true, stubSetsHooksPath: true }); - expect(primary.status).toBe(0); + const primaryRun = runSetup(primary, { + setsHooksPath: true, + writesHooks: true, + }); + expect(primaryRun.status).toBe(0); expect(readFileSync(logFile, 'utf8').trim().split(/\r?\n/)).toEqual([ - '1 1 pnpm install --frozen-lockfile --offline', - '1 1 pnpm exec husky', + installLine, + huskyLine, ]); - expect(sharedHooksPath()).toBe(true); + expect(hooksPathIsSet(primary)).toBe(true); + expect(existsSync(path.join(primary, '.husky', '_', 'pre-commit'))).toBe( + true, + ); - // A husky that exits 0 without configuring the hooks path fails closed - // instead of shipping a checkout with no hooks. - expect( - spawnSync('git', ['config', '--unset', 'core.hooksPath'], { - cwd: configRepo, - encoding: 'utf8', - env: sharedConfigEnv, - }).status, - ).toBe(0); - const hookless = runSetup({ ownsConfig: true, stubSetsHooksPath: false }); + // 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, + ]); } finally { rmSync(sandbox, { recursive: true, force: true }); } @@ -510,20 +587,24 @@ 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, - ...huskyTestEnv, - PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`, + HUSKY: '1', + PATH: binDir, WORKTREE_SETUP_LOG: logFile, }, }, ); 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', - '1 1 pnpm exec husky', ]); } finally { rmSync(binDir, { recursive: true, force: true }); From 56786510349c715fa7b09b0a1b92f41fbfca8fc7 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 10 Sep 2026 14:20:29 +0800 Subject: [PATCH 4/4] fix(dev): surface hooks-path read failures in worktree bootstrap - getHooksPath() no longer collapses a refused git config read (exit 128/2/3) into "unset": only an absent key (exit 1) or a missing git binary keeps the skip path; anything else fails the bootstrap with the read error instead of a green, hook-less worktree. - The linked-worktree skip notice now names the recovery path: re-run this script once the primary checkout has hooks installed. - Tests cover a git stub exiting 128, the real-world unset HUSKY state, and pin the recovery sentence in the skip notice. --- scripts/setup-worktree.js | 14 ++++++-- scripts/tests/package-scripts.test.js | 46 +++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/scripts/setup-worktree.js b/scripts/setup-worktree.js index 61234793a6b..2fc6a4d4944 100644 --- a/scripts/setup-worktree.js +++ b/scripts/setup-worktree.js @@ -71,7 +71,16 @@ function getHooksPath() { env, encoding: 'utf8', }); - return result.status === 0 ? result.stdout.trim() : undefined; + 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 @@ -119,7 +128,8 @@ function install(cacheMode) { '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.', + 'not rewritten for every other worktree. Re-run this script here ' + + 'once hooks are installed in the primary checkout.', ); exitWithResult(result); } diff --git a/scripts/tests/package-scripts.test.js b/scripts/tests/package-scripts.test.js index b5ee8225f5f..a3559d8a1d3 100644 --- a/scripts/tests/package-scripts.test.js +++ b/scripts/tests/package-scripts.test.js @@ -275,6 +275,31 @@ describe('package scripts', () => { 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 }); @@ -422,6 +447,11 @@ describe('package scripts', () => { }); 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); @@ -487,6 +517,22 @@ describe('package scripts', () => { 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 }); }