Skip to content

fix(dev): install hooks during worktree bootstrap - #11496

Merged
yiliang114 merged 6 commits into
mainfrom
codex/fix-worktree-husky
Sep 10, 2026
Merged

fix(dev): install hooks during worktree bootstrap#11496
yiliang114 merged 6 commits into
mainfrom
codex/fix-worktree-husky

Conversation

@yiliang114

Copy link
Copy Markdown
Collaborator

What this PR does

Ensures the optimized pnpm worktree bootstrap installs the repository's Husky hooks after dependency installation. It preserves an existing custom hooks path, respects the HUSKY=0 opt-out, and fails if Husky reports success without configuring the expected hooks path.

Why it's needed

The worktree bootstrap intentionally skips the root prepare phase to avoid rebuilding and bundling the project. That also skipped Husky, so a fresh worktree could have core.hooksPath=.husky/_ without the generated hook wrappers. Commits from that worktree then bypassed the Prettier and ESLint pre-commit checks, leaving CI to report formatting failures.

Reviewer Test Plan

How to verify

Create a fresh linked worktree, run the documented pnpm worktree bootstrap, and confirm that the pre-commit hook is generated and executable. Confirm that a custom hooks path and HUSKY=0 are left unchanged.

Evidence (Before & After)

N/A (non-UI change). Before the fix, the bootstrap completed successfully while the generated pre-commit hook remained absent and a traced commit invoked no hook. After the fix, the bootstrap creates the hook, retains the expected hooks path, and the regression tests cover the normal, opt-out, custom-path, offline, and registry-fallback paths.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

macOS arm64, Node.js 22.22.0, pnpm 11.24.0.

Risk & Scope

  • Main risk or tradeoff: Worktree setup now fails when Husky cannot configure the repository hooks path instead of silently producing a checkout whose pre-commit checks do not run.
  • Not validated / out of scope: The full build currently fails because the existing document-export runtime exceeds its size budget by 15,245 bytes; typecheck then cannot resolve the missing generated web-template output. The focused script tests, Prettier, ESLint, and a real worktree bootstrap pass locally. Windows and Linux behavior is covered by the existing worktree smoke workflow.
  • Breaking changes / migration notes: None. Existing custom hooks paths and the supported Husky opt-out are preserved.

Linked Issues

Related CI failure: https://github.com/QwenLM/qwen-code/actions/runs/34353187365/job/102471375065?pr=11270

中文说明

本 PR 做了什么

确保优化后的 pnpm worktree 初始化在依赖安装完成后安装仓库的 Husky hooks。同时保留已有的自定义 hooks 路径、尊重 HUSKY=0 的禁用设置,并在 Husky 返回成功但未配置预期 hooks 路径时让初始化失败。

为什么需要这个改动

worktree 初始化为了避免重新构建和打包项目,会有意跳过根目录的 prepare 阶段,但这也一并跳过了 Husky。因此,新 worktree 可能已配置 core.hooksPath=.husky/_,却没有生成 hook 包装脚本;从该 worktree 提交时就会绕过 Prettier 和 ESLint 的 pre-commit 检查,最后由 CI 报出格式错误。

Reviewer 测试计划

如何验证

创建一个新的 linked worktree,运行文档中的 pnpm worktree 初始化,然后确认 pre-commit hook 已生成且可执行。再确认自定义 hooks 路径和 HUSKY=0 设置不会被修改。

证据(修复前后)

不适用(非 UI 改动)。修复前,初始化会成功完成,但生成的 pre-commit hook 仍不存在,跟踪提交过程也确认没有调用 hook。修复后,初始化会生成 hook 并保留预期的 hooks 路径;回归测试覆盖正常、禁用、自定义路径、离线安装和联网回退路径。

测试平台

系统 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

macOS arm64、Node.js 22.22.0、pnpm 11.24.0。

风险与范围

  • 主要风险或取舍:当 Husky 无法配置仓库 hooks 路径时,worktree 初始化现在会失败,而不是静默生成一个不会运行 pre-commit 检查的 checkout。
  • 未验证 / 范围外:完整构建目前因为现有的文档导出运行时代码超过体积预算 15,245 字节而失败;typecheck 随后无法解析缺失的 web-template 生成产物。针对脚本的测试、Prettier、ESLint 和真实 worktree 初始化均已在本地通过。Windows 和 Linux 行为由现有的 worktree smoke workflow 覆盖。
  • 破坏性变更 / 迁移说明:无。已有自定义 hooks 路径和 Husky 官方禁用方式都会保留。

关联问题

相关 CI 失败:https://github.com/QwenLM/qwen-code/actions/runs/34353187365/job/102471375065?pr=11270

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@yiliang114
yiliang114 enabled auto-merge September 9, 2026 16:28
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Re-run against b788256, which is two substantive commits past the d0cf206 I gated last time. Head note before anything else: this PR merged at 09:45:05Z, about seven minutes after the /triage trigger and while this pass was still reading code. So this is no longer an admission decision — it is a review of what actually landed, and anything I find is a follow-up rather than a gate. The squash commit on main is 585589ac; I diffed all three landed files against b788256 and they are byte-identical, so what I reviewed is what shipped.

Template looks good ✓

Problem: observed, not theoretical, and it is now evidenced three independent ways. scripts/prepare.js:28 runs husky unconditionally but :12 gates the whole heavy path behind QWEN_SKIP_PREPARE, which setup-worktree.js sets — so the pnpm worktree bootstrap never armed hooks. The symptom is in a real CI log (run 34353187365, PR 11270, Lint & Static (ubuntu-latest, Node 22.x), failure, [warn] Code style issues found in 1 file). And the /verify run reproduced it end-to-end against the base build: its cell 01 shows base exiting 0 with the wrappers absent and a traced commit invoking no hook, while cell 02 on head produces executable wrappers and a commit that really fires .husky/pre-commit. That is about as solid a before/after as this gate gets to see.

Direction: aligned. Internal developer tooling — no product surface, and nothing touching auth, sandbox, model selection, telemetry, release, or a public contract. The only caller of this script outside its own tests is .github/workflows/pnpm-worktree-smoke.yml:84, so the blast radius is the documented worktree bootstrap and that one lane. No CHANGELOG signal applies, which is expected for DX.

Size: not applicable — no core path is touched (Stage 0 does not fire), and no Stage 1e high-risk path matches. 99 production lines (scripts/setup-worktree.js, +92/−7), 16 doc lines (+14/−2), 331 test lines (scripts/tests/package-scripts.test.js, +314/−17), 0 generated/schema. Well under both the 500-line escalation and the 1000-line advisory.

Approach: the two new commits are 03b1493 (ownership now comes from git rev-parse --git-dir --git-common-dir instead of the .git file-vs-directory shape) and 5678651 (a hooks-path read failure surfaces instead of collapsing into "unset"). Both trace to specific findings rather than to review-round drift — the first closes the fix-induced Critical that the .git proxy misclassifies a --separate-git-dir clone or a submodule, the second closes the Suggestion that one undefined sentinel carried two meanings. Every prior thread is resolved (13/13), and the growth is mostly test scaffolding: the fixture now builds real git trees instead of faking a .git file, which is what makes the new cases discriminating. Nothing here is a drive-by. I do flag one behaviour change these commits introduced that I think deserves a follow-up — it is in the review below, not here.

One thing worth saying plainly, since it is the honest read of the round count: this PR went through two Criticals and eleven Suggestions across four heads, and AGENTS.md asks that after roughly five rounds only Critical fixes land. Both new commits are defensible under that rule — one answers a Critical, the other a Suggestion whose wrong-signal shape was measured — but this is the point where further rounds should move to a follow-up issue rather than widen this diff. That is now moot for merging and still useful for the residuals.

Risk: no elevated risk signals.

Reviewed what landed. 🔍

中文说明

针对 b788256 的重跑——比我上次准入判断的 d0cf206 多了两个实质提交。先说最重要的一点:本 PR 已于 09:45:05Z 合并,就在 /triage 触发之后约七分钟、本次审查还在读代码的过程中。所以这已经不是一个准入决定,而是对实际落地代码的审查;我发现的任何问题都属于后续跟进,而不是门禁。main 上的 squash 提交是 585589ac;我把落地的三个文件与 b788256 逐一 diff,字节完全一致,所以我审查的就是已发布的内容。

模板完整 ✓

**问题:**已观测到,不是理论性的,而且现在有三重独立证据。scripts/prepare.js:28 无条件运行 husky,但 :12 把整条重活路径放在 QWEN_SKIP_PREPARE 之后,而 setup-worktree.js 正好设置了它——所以 pnpm worktree 初始化从来没有武装过 hooks。症状在真实 CI 日志里(run 34353187365,PR 11270,Lint & Static (ubuntu-latest, Node 22.x)failure[warn] Code style issues found in 1 file)。而 /verify 运行对照 base 构建做了端到端复现:cell 01 显示 base 退出 0、包装脚本缺失、被跟踪的提交不触发任何 hook;cell 02 在 head 上生成了可执行的包装脚本,提交真实触发了 .husky/pre-commit。这几乎是这道门禁能看到的、最扎实的 before/after。

**方向:**对齐。纯内部开发工具——没有产品界面,也不涉及 auth、sandbox、模型选择、telemetry、发布或对外契约。除自身测试外,这个脚本唯一的调用方是 .github/workflows/pnpm-worktree-smoke.yml:84,所以影响面就是文档化的 worktree 初始化和这一条 lane。没有 CHANGELOG 信号,这对 DX 来说符合预期。

**规模:**不适用——没有触及核心路径(Stage 0 不触发),Stage 1e 的高风险路径也没有命中。99 行生产代码(scripts/setup-worktree.js,+92/−7)、16 行文档(+14/−2)、331 行测试(scripts/tests/package-scripts.test.js,+314/−17)、0 行生成/schema。远低于 500 行升级阈值和 1000 行大 PR 建议阈值。

**方案:**两个新提交是 03b1493(归属判断改为来自 git rev-parse --git-dir --git-common-dir,不再依据 .git 是文件还是目录)和 5678651(hooks-path 读取失败会被暴露,而不再塌缩成「未设置」)。两者都能追溯到具体 finding,而不是 review 轮次膨胀——前者关闭了「.git 形态代理会误判 --separate-git-dir clone 或 submodule」这条修复引入的 Critical,后者关闭了「一个 undefined 哨兵承载两种含义」这条 Suggestion。此前所有线程都已解决(13/13),增量主要是测试脚手架:夹具现在构建真实的 git 树,而不是伪造一个 .git 文件,这正是新用例具备区分力的原因。这里没有夹带任何顺手改动。我确实要指出这两个提交引入的一处行为变化,我认为值得后续跟进——它在下面的审查里,不在这里。

有句话该直说,因为这是对轮次的诚实判断:这个 PR 在四个 head 上经历了两条 Critical 和十一条 Suggestion,而 AGENTS.md 要求大约五轮之后只落地 Critical 修复。这两个新提交在该规则下都站得住——一个回应 Critical,另一个回应的 Suggestion 其错误信号形态是被实测过的——但这已经到了「后续轮次应该转成跟进 issue、而不是继续加宽这个 diff」的节点。对合并而言这已经无所谓,对残留项仍然有用。

**风险:**无升级风险信号。

已审查落地的内容 🔍

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at b788256771bcd256daa8cb64b9362f9442d53131 · landed as 585589ac677379c12e3049ba735bbe21004d2010 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Code review

Review of b788256, which is what landed. Before opening the delta I wrote down what I would do from the title and the "Why it's needed" section alone: run husky after a successful frozen install, but only from a root that actually owns the config the write would land in — and ask git which root that is rather than inferring it from filesystem shape; leave HUSKY=0 and a pre-existing custom core.hooksPath alone; then confirm hooks really got armed instead of trusting husky's exit code. That is what the delta does, including the part I most wanted (ownership from git, not from .git's shape). I'll note where hindsight helps me here: having read the previous round's Critical, "ask git" is partly a known answer — but filesystem shape is a proxy for a relation git exposes directly, so I'd pick the probe independently.

No reuse path was skipped, worth saying because packages/core/src/services/gitWorktreeService.ts already contains worktree hooks logic. This script has to run before the repository is built, and it deliberately sets QWEN_SKIP_PREPARE=1 to stay that way, so it cannot import from packages/core; it stays a dependency-free .js file. Correct call.

Both Criticals are fixed, and I checked each against the landed file rather than the thread replies.

R1-7 (original) — husky's write escaping the bootstrapped worktree. install() resolves ownership at :119 and short-circuits at :120-135 on ownership === 'none' || (hooksPath === undefined && ownership === 'linked'), which is before husky is spawned at :139. No write reaches the shared config in that state. The linkedRun case pins it with the only assertion shape that fails when the guard is removed: status 0, skipping Husky on stdout, the recovery sentence, a corepack log holding the install line only, hooksPathIsSet(primary) false, and existsSync(linked/.husky) false.

R1-7 (fix-induced) — the .git shape as an ownership proxy. ownsRepositoryConfig() and the statSync import are gone outright (:8); repositoryConfigOwnership() at :93-102 asks git rev-parse --git-dir --git-common-dir and compares them. The new fixture builds a real git clone --separate-git-dir, whose .git is a file but which owns its config, and separateRun expects husky to run and hooksPathIsSet(separate) to be true. Under the old heuristic that clone classified as linked and skipped — so this test discriminates the change instead of merely describing it. That is the difference between a fixture and a proof, and it is the right one.

I also re-derived the cross-component constraint rather than reusing last round's note. configureHooksPath() reads git config --local core.hooksPath (gitWorktreeService.ts:2255) and writes only when that read comes back empty (:2266). Because the skip leaves the shared key unset, that read stays empty and the runtime service is no longer pre-empted by the bootstrap. The two still compose.

The eleven Suggestions are all closed, and three of them are closed by tests that are better than the fix required. The two new failure conjuncts at :140-144 are pinned independently, which I specifically looked for: configOnly sets the config but writes no wrapper, so it fails on the artifact half alone; hookless does neither, so it fails on the config half alone. That only holds because resetPrimaryHooks() is called before both — without the second call hookless would have inherited .husky/_ from configOnly and quietly stopped testing the config conjunct while still passing. It is called. failing (stub exits 7) pins that exitWithResult(husky) at :148 propagates husky's own code rather than the install's 0. And the new HUSKY-unset arm deletes every HUSKY key case-insensitively and asserts the pnpm exec husky line still appears — that guards against a !== '1' inversion, which is the realistic way this gate silently breaks in six months. The registry-fallback case re-narrows PATH to the stub directory alone, restoring the "needs no ambient tooling" assertion I flagged as lost last round.

The exit-128 arm is the one I'd most have pushed on if it were weak, and it isn't: it asserts status 1, that stdout does not contain skipping Husky, and that stderr names could not read core.hooksPath. That pins the actual mechanism of R2-1 — a read failure must not land on a green skip branch — not just its wording.

I re-checked install()'s contract after the statSync removal, since a comment and a driver both depend on it: every path inside if (result.status === 0) still terminates (three exitWithResult, two process.exit(1)), so the comment at :152-154 and the offline→registry retry below it remain accurate.

Findings on what landed

None of these blocked the merge — it already happened — so they are follow-ups. Two are worth a maintainer's attention, and I'd put the first one at the top of the list.

1. HUSKY=0 is no longer an unconditional opt-out (new in 5678651; a behaviour change against both d0cf206 and the pre-PR base). install() calls getHooksPath() at :107, before the HUSKY === '0' short-circuit at :109. Since 5678651, getHooksPath() exits the process at :80-83 on any git status other than 0 or 1, so a developer who explicitly set HUSKY=0 now gets worktree setup failed: could not read core.hooksPath (...) and exit 1 after an install that succeeded. At d0cf206 the same state returned undefined and the opt-out exited 0; the base never invoked git at all. The realistic trigger is git exiting 128 — the dubious-ownership refusal on a shared runner or a container where the checkout is owned by another UID, which is the exact shape the author's own test comment names. I did not measure git's exit code myself; the fixture stubs it, so the branch is proven and the trigger is the author's stated one.

The fix is two lines: hoist if (envValue('HUSKY') === '0') exitWithResult(result); above the read. The custom-hooks-path arm genuinely needs the value, so it cannot be hoisted — that half is inherent to the design. The honest counter-argument is that an unreadable git config means a broken environment and failing loudly is defensible; if that's the intended reading, then the guarantee is what should change, since both the PR body ("respects the HUSKY=0 opt-out") and the design doc ("honours HUSKY=0") currently state it unconditionally.

2. getHooksPath() reads the effective value, not the scope husky writes into. git config --get at :69 resolves across system, global, local and worktree config, while husky's own write carries no scope flag and lands in the repository-local config. So a linked worktree whose core.hooksPath=.husky/_ comes from a global config (or from config.worktree) reads as "already configured", the guard at :122 is not taken, husky runs, and the shared config is written — R1-7's mechanism through a different door. /verify measured this at d0cf206: the shared config was written and the primary checkout's pre-existing .git/hooks/pre-commit silently stopped firing, with the commit still succeeding. It was not addressed by the two new commits and it is now on main.

Two things make this more than a hypothetical, and both are new this round. The repo already treats an ambient core.hooksPath as hostile input: .github/workflows/qwen-autofix.yml:747-752 resolves hooks with GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null, and its comment records that this was verified in qwen-triage — "with a global core.hooksPath set, git rev-parse --git-path hooks returns that path"; :728-735 deletes config.worktree outright precisely because git config --local neither lists nor unsets it. And gitWorktreeService.ts:2255 reads with --local before deciding whether to write. So the scope discipline this function lacks is established house practice in two places, one of them in the runtime service that has to interoperate with this script.

Why the suite cannot catch it: every arm of the ownership fixture sets GIT_CONFIG_COUNT=0 plus an empty GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM, which is correct isolation for what those tests assert and is exactly what makes the effective-value read indistinguishable from a local-only read in all of them. Any fix needs a case that plants the value in an ambient scope on purpose.

One caution on the obvious fix: /verify sketched an 18-line change to --local --get, but reading local only would also stop honouring a global custom core.hooksPath, so a developer using a global hooks framework would get husky writing a local override instead of being left alone. The house idiom (suppress global and system for the read) has the same shape. So this is a decision about which scope is authoritative for this script, not a one-flag swap — which is why it belongs in a follow-up with its own test rather than in a fifth round here.

3. The design doc is still English-only. docs/design/ holds 2026-08-29-pnpm-worktree-bootstrap.md with no .zh-CN.md counterpart. AGENTS.md asks for linked English and Chinese versions kept in sync in the same change, and this PR edited the doc — which is the moment that rule names. It is a pre-existing gap, and per the repo's review rules a translation gap alone is a Suggestion, not a Critical.

4. Nit — the artifact witness hardcodes one hook name. :143 checks .husky/_/pre-commit. .husky/ contains exactly that one hook today, and it is the one running the Prettier/ESLint checks that motivated the PR, so it is the right witness right now. If the hook set ever changes, a healthy husky run would hard-fail the bootstrap. A name-free alternative is ".husky/_ exists and holds something beyond the .gitignore husky always writes".

5. Nit — the Windows arms of the fixture stay unexercised on pull_request. test:scripts is invoked once in ci.yml, at :767 inside the ubuntu test job; test_macos (:1555) and test_windows (:1652) do not run it. The fixture carries a parallel corepack.cmd batch stub that grew this round (it now also materialises .husky/_\pre-commit), and the new exit-128 arm is explicitly POSIX-gated for a correct reason — a script named git cannot shadow git.exe. Both therefore first execute in the merge queue. The mitigation is real rather than rhetorical: Install (windows-latest) in the smoke lane runs the actual script with real husky and is green at this commit.

6. Nit — one message can misattribute its own cause. repositoryConfigOwnership() returns 'none' for both "not a repository" and "git binary missing" (a spawn error leaves probe.status null, and :99 tests !== 0), while the :126 message asserts the former. The action is right in both cases and the author chose this deliberately — it is documented in the getHooksPath comment and pinned by the registry-fallback test, which needs "no ambient tooling → skip, exit 0". Only the wording overstates what was learned.

CI test evidence

Unattended CI run — I built nothing, executed nothing, and ran no PR-derived code. Everything below is this PR's own check results for the reviewed commit, read through the API: 24 check-runs, zero failures, so there is no failing-job log to quote.

The lane that matters most for the newest commit is green on all three platforms. pnpm Worktree Smoke triggers on scripts/setup-worktree.js and runs the real node scripts/setup-worktree.js with real corepack, pnpm and husky (:84), then asserts git status --porcelain is empty. Install (ubuntu|macos|windows-latest) are all success at b788256 — which is direct evidence that the artifact witness added in 03b1493 does not false-fail against real husky, on any platform. That is the check I most wanted, because a hardcoded .husky/_/pre-commit assertion is exactly the kind of thing that passes with a stub and breaks in production. Test (ubuntu-latest, Node 22.x) is success, and ci.yml:767 runs npm run test:scripts there, so the rebuilt fixture passed on Linux. Lint & Static is success, which also settles the design doc's ragged wrapping — Prettier's proseWrap defaults to preserve, so the short lines are as authored.

What no lane covers is worth stating exactly, because it is the arm this PR exists for. The smoke job runs against a fresh actions/checkout clone — .git is a directory, core.hooksPath unset — so it exercises primary + unset, where husky runs for real and both conjuncts of the fail-closed guard are live. The motivating linked worktree arms and the new no repository arm are exercised only by the unit fixture. The fixture is much stronger than it was (real git init, real worktree add, real clone --separate-git-dir, only husky stubbed), but husky is still stubbed, so no lane pairs a real linked worktree with real husky at this commit.

Check Conclusion
Classify PR ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Install (macos-latest) ✅ success
Install (ubuntu-latest) ✅ success
Install (windows-latest) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
Lint & Static (ubuntu-latest, Node 22.x) ✅ success
Remind on force-push ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
assign ✅ success
authorize ✅ success
delay-automatic-review ✅ success
label ✅ success
review-pr ⏳ in progress
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

review-pr is a bot orchestration job still in flight, not this PR's CI. Test (macos-latest) and Test (windows-latest) are skipped by design on pull_request events — see nit 5.

Sandboxed verification would settle what changed since the last A/B: @qwen-code /verify against the landed head, specifically that (a) the ownership probe still classifies a real git worktree add checkout as linked and a real --separate-git-dir clone as owning, with real husky rather than a stub; (b) a linked worktree inheriting core.hooksPath=.husky/_ still ends up with an executable pre-commit and a commit that fires it, now that the artifact witness is part of the guard; and (c) finding 1 — that HUSKY=0 plus an unreadable git config exits 1 where it used to exit 0. The existing /verify report is good evidence but it verified d0cf206, which predates both new commits and used the .git-shape proxy that 03b1493 removed; its cell 01/02 conclusion carries over mechanically (linked + defined key still runs husky) but was not re-measured.

Not verified: the linked-worktree and no-repository arms with real husky (no lane pairs them, as above); the fixture's Windows batch-stub arm and its POSIX-gated exit-128 arm (that lane is off for PRs); git's actual exit code for a dubious-ownership refusal, which I took from the author's test comment rather than measuring; and finding 2's ambient-config door, which /verify measured at d0cf206 and nothing has re-measured at this head.

中文说明

代码审查

针对 b788256(即实际落地的版本)的审查。打开增量之前,我只根据标题和「为什么需要这个改动」写下了自己的方案:冻结安装成功后运行 husky,但只从一个真正拥有该写入所落配置的根目录去运行——而且要向 git 询问是哪个根目录,而不是从文件系统形态去推断;HUSKY=0 和已存在的自定义 core.hooksPath 都不动;然后确认 hooks 真的被武装了,而不是相信 husky 的退出码。这个增量做的正是如此,包括我最想要的那一部分(归属来自 git,而不是 .git 的形态)。这里我要说明后见之明帮到了我哪里:读过上一轮的 Critical 之后,「问 git」有一部分是已知答案——但文件系统形态只是 git 直接暴露的那个关系的一个代理,所以这个探针我会独立地选它。

没有跳过任何可复用的实现,这一点值得说,因为 packages/core/src/services/gitWorktreeService.ts 里已经有 worktree hooks 逻辑。这个脚本必须在仓库构建之前运行,而且它有意设置 QWEN_SKIP_PREPARE=1 来保持这种状态,所以它不能从 packages/core 导入;它保持为一个无依赖的 .js 文件。这个取舍是对的。

两条 Critical 都已修复,而我是对照落地的文件核实的,不是采信线程里的回复。

R1-7(原始)——husky 的写入逃出被初始化的 worktree。 install():119 解析归属,并在 :120-135 上对 ownership === 'none' || (hooksPath === undefined && ownership === 'linked') 短路,而这发生在 husky 于 :139 被启动之前。该状态下共享配置收不到任何写入。linkedRun 用例用「守卫被移除时唯一会失败」的断言形态钉住了它:退出码 0、stdout 含 skipping Husky、包含恢复提示句、corepack 日志含安装那一行、hooksPathIsSet(primary) 为 false,以及 existsSync(linked/.husky) 为 false。

R1-7(修复引入)——用 .git 形态作为归属代理。 ownsRepositoryConfig()statSync 导入被彻底移除(:8);:93-102repositoryConfigOwnership() 询问 git rev-parse --git-dir --git-common-dir 并比较两者。新夹具构建了一个真实的 git clone --separate-git-dir——它的 .git 是文件,但它拥有自己的配置——separateRun 期望 husky 运行且 hooksPathIsSet(separate) 为 true。在旧启发式下这个 clone 会被判为 linked 并跳过,所以这个测试是在区分这个改动,而不只是描述它。这就是夹具与证明的区别,而且是正确的那一种。

我也重新推导了跨组件约束,而不是复用上一轮的笔记。configureHooksPath() 读取 git config --local core.hooksPathgitWorktreeService.ts:2255),并且只在该读取为空时写入(:2266)。由于跳过让共享的 key 保持未设置,该读取仍为空,运行时服务不再被初始化脚本抢先。两者仍然协同。

十一条 Suggestion 全部关闭,其中三条的关闭方式比修复本身要求的更好。 :140-144 的两个新失败条件是各自独立被钉住的,这是我特意去找的:configOnly 设置配置但不写包装脚本,所以它只由产物那一半触发失败;hookless 两者都不做,所以它只由配置那一半触发失败。这只成立是因为 resetPrimaryHooks()两者之前都被调用了——如果没有第二次调用,hookless 会从 configOnly 继承 .husky/_,于是在仍然通过的同时悄悄不再测试配置这个条件。它确实被调用了。failing(桩退出 7)钉住了 :148exitWithResult(husky) 传播 husky 自己的状态码,而不是安装结果的 0。新增的 HUSKY-未设置分支会不区分大小写地删除每一个 HUSKY key,并断言 pnpm exec husky 那一行仍然出现——这防住了 !== '1' 的反转,而那正是这道门禁在六个月后悄悄失效的现实方式。registry 回退用例把 PATH 重新收窄为仅桩目录,恢复了我上一轮指出已丢失的「不需要任何环境工具」断言。

exit-128 分支是如果薄弱我就会去推的那一个,而它不薄弱:它断言退出码 1、stdout skipping Husky、stderr 含 could not read core.hooksPath。这钉住的是 R2-1 的真实机制——读取失败不能落在一个绿色的跳过分支上——而不只是它的措辞。

移除 statSync 之后我重新核实了 install() 的契约,因为一段注释和一段驱动逻辑都依赖它:if (result.status === 0) 内的每条路径仍然会终止(三个 exitWithResult、两个 process.exit(1)),所以 :152-154 的注释和其下的 offline→registry 重试仍然准确。

对落地代码的发现

这些都没有阻塞合并——合并已经发生——所以它们是后续项。两条值得维护者关注,而我会把第一条放在最前面。

1. HUSKY=0 不再是无条件的退出开关5678651 新增;相对 d0cf206 和 PR 之前的 base 都是行为变化)。install():107 调用 getHooksPath()早于 :109HUSKY === '0' 短路。自 5678651 起,getHooksPath() 会在 git 状态既非 0 也非 1 时于 :80-83 结束进程,所以一个明确设置了 HUSKY=0 的开发者,现在会在一次成功的安装之后得到 worktree setup failed: could not read core.hooksPath (...) 和退出码 1。在 d0cf206 上同样的状态返回 undefined,退出开关会以 0 退出;base 则完全不调用 git。现实的触发条件是 git 退出 128——共享 runner 或 checkout 属于另一个 UID 的容器里的 dubious-ownership 拒绝,而这正是作者自己的测试注释点名的形态。我没有亲自测量 git 的退出码;夹具把它桩掉了,所以被证明的是分支,而触发条件是作者陈述的那一个。

修复是两行:把 if (envValue('HUSKY') === '0') exitWithResult(result); 提到读取之前。自定义 hooks path 那一支确实需要这个值,所以它无法上提——那一半是设计固有的。诚实的反方意见是:git 配置不可读意味着环境已损坏,响亮地失败是可以辩护的;如果这才是有意的读法,那么该改的是那句保证,因为 PR 正文("respects the HUSKY=0 opt-out")和设计文档("honours HUSKY=0")目前都是无条件陈述它的。

2. getHooksPath() 读的是生效值,而不是 husky 写入的那个作用域。 :69git config --get 会跨 system、global、local 和 worktree 配置解析,而 husky 自己的写入不带作用域参数,落在仓库 local 配置里。所以一个 core.hooksPath=.husky/_ 来自 global 配置(或来自 config.worktree)的 linked worktree 会被读成「已配置」,:122 的守卫不生效,husky 运行,共享配置被写入——R1-7 的机制从另一扇门进来。/verifyd0cf206 上实测到了这一点:共享配置被写入,primary checkout 里原有的 .git/hooks/pre-commit 静默不再触发,而提交仍然成功。两个新提交没有处理它,而它现在在 main 上。

有两点使它不只是假设,而且都是本轮新增的。仓库已经把环境里的 core.hooksPath 当作敌意输入对待:.github/workflows/qwen-autofix.yml:747-752GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null 解析 hooks,其注释记录这是在 qwen-triage 中验证过的——"with a global core.hooksPath set, git rev-parse --git-path hooks returns that path";:728-735 则直接删除 config.worktree,正因为 git config --local 既不列出也不取消它。而 gitWorktreeService.ts:2255 在决定是否写入之前用 --local 读取。所以这个函数缺少的作用域纪律,在本仓库两个地方都是既有惯例,其中之一还是必须与这个脚本协同的运行时服务。

为什么测试套件抓不到它:归属夹具的每一个分支都设置了 GIT_CONFIG_COUNT=0 加上空的 GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM,这对它们所断言的内容是正确的隔离,而这恰恰使得生效值读取与仅 local 读取在所有用例里都无法区分。任何修复都需要一个刻意把该值植入环境作用域的用例。

对那个显而易见的修复的一点提醒:/verify 勾勒过一个 18 行的改动去用 --local --get,但读 local 也会同时停止尊重 global 的自定义 core.hooksPath,于是一个使用全局 hooks 框架的开发者会得到 husky 写入一个 local 覆盖,而不是被放着不动。仓库的既有写法(读取时屏蔽 global 和 system)有同样的形态。所以这是一个关于「哪个作用域对这个脚本是权威的」的决定,不是一次单参数替换——这也是它应该带着自己的测试进入后续改动、而不是在这里进行第五轮的原因。

3. 设计文档仍然只有英文版。 docs/design/ 里只有 2026-08-29-pnpm-worktree-bootstrap.md,没有对应的 .zh-CN.md。AGENTS.md 要求在同一次改动中提供互相链接的英文和中文版本并保持同步,而这个 PR 修改了该文档——那正是这条规则点名的时机。这是既有缺口,而按仓库的 review 规则,单纯的翻译缺口是 Suggestion,不是 Critical。

4. 小问题——产物见证硬编码了一个 hook 名。 :143 检查 .husky/_/pre-commit.husky/ 今天正好只有这一个 hook,而它正是运行本 PR 动因所在 Prettier/ESLint 检查的那一个,所以此刻它是正确的见证。如果 hook 集合有变化,一次健康的 husky 运行会让初始化硬失败。一个不依赖名字的替代是「.husky/_ 存在,且除 husky 总会写的 .gitignore 之外还有内容」。

5. 小问题——夹具的 Windows 分支在 pull_request 上仍未被执行。 test:scriptsci.yml 里只被调用一次,即 :767、ubuntu test job 内;test_macos:1555)和 test_windows:1652)都不运行它。夹具带有一个平行的 corepack.cmd 批处理桩,本轮还变长了(现在也会生成 .husky/_\pre-commit),而新的 exit-128 分支出于一个正确的理由被显式限制在 POSIX——一个名为 git 的脚本无法遮蔽 git.exe。因此两者都要到合并队列才首次执行。缓解是实在的而非修辞性的:smoke lane 的 Install (windows-latest) 在这个 commit 上用真实 husky 运行了真实脚本,并且是绿的。

6. 小问题——有一条信息会误报自己的成因。 repositoryConfigOwnership() 对「不是仓库」和「git 二进制缺失」都返回 'none'(spawn 失败会让 probe.status 为 null,而 :99 判断 !== 0),而 :126 的信息断言的是前者。两种情况下动作都是对的,而且作者是有意为之——它写在 getHooksPath 的注释里,并由 registry 回退测试钉住,那个测试需要「无环境工具 → 跳过,退出 0」。只有措辞夸大了实际获知的信息。

CI 测试证据

无人值守的 CI 运行——我没有构建任何东西、没有执行任何东西,也没有运行任何来自 PR 的代码。下面全部内容都是这个 PR 自己的 check 结果,针对被审查的 commit 通过 API 读取:24 个 check-run,零失败,所以没有失败 job 的日志可引用。

对最新提交最重要的那条 lane 在三个平台上都是绿的。pnpm Worktree Smokescripts/setup-worktree.js 触发,用真实的 corepack、pnpm 和 husky 运行真实的 node scripts/setup-worktree.js:84),然后断言 git status --porcelain 为空。Install (ubuntu|macos|windows-latest)b788256 上全部 success——这直接证明了 03b1493 新增的产物见证不会在任何平台上对真实 husky 误报失败。这是我最想要的检查,因为一个硬编码的 .husky/_/pre-commit 断言正是那种用桩能过、在生产里会炸的东西。Test (ubuntu-latest, Node 22.x)success,而 ci.yml:767 在那里运行 npm run test:scripts,所以重建后的夹具在 Linux 上通过了。Lint & Staticsuccess,这同时也了结了设计文档那个参差不齐的换行——Prettier 的 proseWrap 默认是 preserve,所以那些短行就是作者写的样子。

没有任何 lane 覆盖的部分值得精确说明,因为那正是本 PR 存在的分支。smoke job 跑在全新的 actions/checkout clone 上——.git 是目录,core.hooksPath 未设置——所以它走的是 primary + 未设置,那里 husky 真实运行,fail-closed 守卫的两个条件都是活的。作为动因的 linked worktree 各分支以及新增的 无仓库 分支,只由单元夹具覆盖。夹具比过去强得多(真实的 git init、真实的 worktree add、真实的 clone --separate-git-dir,只有 husky 被桩掉),但 husky 仍然是桩,所以在这个 commit 上没有任何 lane 把真实的 linked worktree 与真实的 husky 配在一起。

review-pr 是仍在运行的机器人编排 job,不属于这个 PR 的 CI。Test (macos-latest)Test (windows-latest)pull_request 事件上按设计被跳过——见小问题 5。

沙箱验证可以定论自上次 A/B 以来变化的部分:@qwen-code /verify针对落地的 head,具体验证(a)归属探针在真实 husky(而非桩)下,仍然把真实的 git worktree add checkout 判为 linked、把真实的 --separate-git-dir clone 判为拥有配置;(b)一个继承 core.hooksPath=.husky/_ 的 linked worktree,在产物见证已成为守卫一部分之后,仍然得到可执行的 pre-commit 并且提交会触发它;以及(c)发现 1——HUSKY=0 加上不可读的 git 配置现在退出 1,而过去退出 0。已有的 /verify 报告是很好的证据,但它验证的是 d0cf206,早于两个新提交,并且用的是 03b1493 已移除的 .git 形态代理;它的 cell 01/02 结论在机制上可以延续(linked + 已定义的 key 仍然运行 husky),但没有被重新实测。

未验证:使用真实 husky 的 linked-worktree 与无仓库分支(如上,没有 lane 把它们配在一起);夹具的 Windows 批处理桩分支和被限制在 POSIX 的 exit-128 分支(该 lane 对 PR 关闭);git 对 dubious-ownership 拒绝的实际退出码,这一点我取自作者的测试注释而非亲自测量;以及发现 2 的环境配置之门,/verifyd0cf206 上测过,在这个 head 上没有任何东西重测过。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at b788256771bcd256daa8cb64b9362f9442d53131 · landed as 585589ac677379c12e3049ba735bbe21004d2010 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — the code that landed is solid: both Criticals are verifiably fixed and pinned by tests that would go red if the guards were removed, and CI is fully green at this commit including real husky on all three platforms. What keeps it off 5 is two residual doors, one of them newly introduced by the last commit and now live on main.

There is no gate action available, so I am not taking one. This PR merged at 09:45:05Z, seven minutes after the /triage trigger and while this pass was still reading code. I am not submitting an approval — it would be attesting to a decision already taken, and the bot's last approval was pinned to d0cf206, two substantive commits behind what actually shipped. I am not submitting a request-changes either: it cannot gate merged code, and on a merged PR it reads as noise rather than as a finding. What follows is a handover.

Stepping back over the whole arc rather than the last delta: this PR started as a hooks-not-installed bug with a real CI log behind it, and the first fix introduced a worse bug than the one it closed — husky's unguarded git config write repointing hook resolution for every sibling worktree. That is the usual shape of a fix in this area, and it is why the second round mattered. What I want to say about the third and fourth rounds is that they went the right way. Faced with a Critical that the .git file-vs-directory proxy misclassifies a --separate-git-dir clone, the author did not narrow the assertion or reword the comment — they deleted the proxy and asked git, which is the signal that actually exists. Then, faced with a Suggestion that one undefined sentinel meant two things, they split the states and made the read failure loud. Both are mechanism-level answers, and both came with a fixture change that makes the old code fail rather than merely the new code pass. That is the part I check for on a re-run, because it is the part that is easiest to fake.

If I pick this up in six months I thank the author. The guard is explained by a comment that names the mechanism it is defending against, including why .git's shape is not a proxy for it — that comment is what stops someone re-breaking this by "simplifying" the probe back to a statSync. The failure mode is loud rather than silent. The design doc that would otherwise have justified the re-break was updated in the same change instead of being left to contradict the code. And the fixture builds real git trees, so it will keep discriminating rather than decay into a description of itself.

The two things I would want done next, in order:

HUSKY=0 no longer opts out of the git read. getHooksPath() runs before the opt-out check, and since 5678651 it can exit the process. A developer who explicitly said "no hooks" gets a failed bootstrap after a successful install when git's config read fails — the realistic trigger being a dubious-ownership exit 128 on a shared runner, the exact shape the author's own test names. Two lines to fix, and both the PR body and the design doc currently promise the opt-out unconditionally. This arrived in the last substantive commit and no formal review covered that commit before the merge, which is how it reached main.

The guard reads the effective core.hooksPath, not the scope husky writes into. A global or worktree-scoped value opens the same door R1-7 closed, and /verify measured it: the shared config gets written and the primary checkout's existing pre-commit silently stops firing. This one is more than theoretical in this repo — qwen-autofix.yml already suppresses global and system config before resolving hooks, with a comment recording that it was verified in qwen-triage that an ambient value steers git's resolution, and gitWorktreeService.ts reads --local before deciding to write. The fix is a scope decision rather than a flag swap, so it wants its own test that plants the value in an ambient scope on purpose — something the current fixture structurally cannot do, because every arm isolates global and system config to an empty file.

One process note, offered neutrally because it is the reason this comment reads as a post-mortem: the standing review decision at merge time was CHANGES_REQUESTED, and the last formal reviews on this PR are both pinned to d0cf206. 03b1493 and 5678651 — the commits that removed the .git proxy and added the artifact witness, and that carry the first finding above — landed without a review submitted against them. Merging over a bot's CHANGES_REQUESTED is entirely within a maintainer's rights and I am not questioning the call; I am noting that the gap between the reviewed SHA and the landed SHA is exactly where an unreviewed behaviour change got through, and that re-running /triage and then merging seven minutes later cannot close it. If the intent was to have this pass gate the merge, it needed to finish first.

@yiliang114 the two follow-ups above are yours to schedule — the first is small enough to land on its own, and the second is worth an issue since it needs a new fixture axis.

中文说明

信心度:4/5 — 落地的代码是扎实的:两条 Critical 都被可验证地修复,并由「守卫被移除就会变红」的测试钉住;CI 在这个 commit 上全绿,包括三个平台上真实的 husky。让它到不了 5 分的是两扇残留的门,其中一扇由最后一个提交新引入,现在已经在 main 上。

没有任何可用的门禁动作,所以我一个也不做。 本 PR 已于 09:45:05Z 合并,就在 /triage 触发之后七分钟、本次审查还在读代码的过程中。我不会提交批准——那等于为一个已经做出的决定背书,而且机器人上一次批准锚定的是 d0cf206,比实际发布的内容落后两个实质提交。我也不会提交 request-changes:它无法对已合并的代码起门禁作用,而在一个已合并的 PR 上它读起来是噪音,不是发现。下面是交接。

跳出最后的增量、回看整条弧线:这个 PR 起点是一个有真实 CI 日志支撑的「hooks 未安装」bug,而第一次修复引入了一个比它所关闭的更糟的 bug——husky 无保护的 git config 写入为所有同级 worktree 重定向了 hook 解析。这是这个领域里修复的常见形态,也正是第二轮重要的原因。关于第三、第四轮我想说的是:它们走对了方向。面对「.git 是文件还是目录这个代理会误判 --separate-git-dir clone」这条 Critical,作者没有收窄断言、也没有改写注释——他们删掉了这个代理,去问 git,也就是那个真实存在的信号。然后面对「一个 undefined 哨兵意味着两件事」这条 Suggestion,他们把两种状态拆开,并让读取失败变响亮。两者都是机制层面的回应,而且都带着一次夹具改动,使代码失败,而不只是让新代码通过。这正是我在重跑时要检查的部分,因为它是最容易伪造的部分。

如果六个月后我接手这块代码,我会感谢作者。这道守卫由一段点名了它所防御机制的注释解释,包括为什么 .git 的形态不是它的代理——那段注释正是阻止某人把这个探针「简化」回一个 statSync、从而重新破坏此修复的东西。失败方式是响亮的而不是静默的。那份本来会成为重新破坏之依据的设计文档,在同一次改动里被更新了,而不是被留着与代码矛盾。而夹具构建真实的 git 树,所以它会持续具备区分力,而不是退化成对自身的描述。

接下来我希望被做的两件事,按顺序:

HUSKY=0 不再能退出这次 git 读取。 getHooksPath() 在退出开关检查之前运行,而自 5678651 起它可以结束进程。当 git 的配置读取失败时,一个明确说了「不要 hooks」的开发者会在一次成功的安装之后得到一次失败的初始化——现实的触发条件是共享 runner 上的 dubious-ownership 退出 128,正是作者自己的测试点名的形态。修复是两行,而 PR 正文和设计文档目前都无条件地承诺了这个退出开关。它出现在最后一个实质提交里,而在合并之前没有任何正式 review 覆盖过那个提交,它就是这样到了 main

守卫读的是生效的 core.hooksPath,而不是 husky 写入的那个作用域。 一个 global 或 worktree 作用域的值会打开 R1-7 已关闭的同一扇门,而 /verify 实测到了:共享配置被写入,primary checkout 里原有的 pre-commit 静默不再触发。这一条在仓库里不只是理论——qwen-autofix.yml 在解析 hooks 之前就已经屏蔽 global 和 system 配置,其注释记录这是在 qwen-triage 中验证过的:环境里的值会左右 git 的解析;而 gitWorktreeService.ts 在决定是否写入之前用 --local 读取。这个修复是一个作用域决定,不是一次参数替换,所以它需要自己的测试,刻意把该值植入环境作用域——而这是当前夹具在结构上做不到的,因为它的每个分支都把 global 和 system 配置隔离到一个空文件。

一条流程说明,中立地提出,因为它正是本条读起来像事后分析的原因:合并时的常驻 review 决定是 CHANGES_REQUESTED,而这个 PR 上最后的正式 review 都锚定在 d0cf20603b14935678651——移除 .git 代理、加入产物见证的两个提交,也是携带上面第一条发现的提交——在没有针对它们提交过 review 的情况下落地了。越过机器人的 CHANGES_REQUESTED 合并完全在维护者的权限之内,我不是在质疑这个决定;我要指出的是,被审查的 SHA 与落地的 SHA 之间的这段差距,正是一个未经审查的行为变化得以通过的地方,而重跑 /triage 然后在七分钟后合并无法弥合它。如果本意是让这一次审查为合并把关,它需要先跑完。

@yiliang114 上面两条后续项由你来排期——第一条小到可以独立落地,第二条值得开一个 issue,因为它需要一条新的夹具轴。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at b788256771bcd256daa8cb64b9362f9442d53131 · landed as 585589ac677379c12e3049ba735bbe21004d2010 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship — CI landed green after the review. ✅

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed.

Not reviewed: build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI for this PR (they are gated on merge_group/schedule/workflow_dispatch), so the changed script suite never executed on those platforms, and this review's own build-test scoped to zero workspaces so it never ran that suite locally either (the Linux Test lane did run it and passed).

Not explored to full depth (tool budget reached): "agent 1d": none — I completed the walk and the lane checks; no candidate was cut short..

Not reviewed: reverse audit — stopped before round 3 by the review time budget.

中文说明

仅完成部分审查,审查缺口已披露。

未审查(原文为英文):build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI for this PR (they are gated on merge_group/schedule/workflow_dispatch), so the changed script suite never executed on those platforms, and this review's own build-test scoped to zero workspaces so it never ran that suite locally either (the Linux Test lane did run it and passed).

未探索到全部深度(达到工具调用预算):"agent 1d"none — I completed the walk and the lane checks; no candidate was cut short.

未审查:反向审计——评审时间预算不足,未能开始第 3 轮。

— qwen3.8-max via Qwen Code /review (v0.23.2)

Comment thread scripts/setup-worktree.js
Comment thread scripts/setup-worktree.js Outdated
Comment thread scripts/tests/package-scripts.test.js
Comment thread scripts/setup-worktree.js
Comment thread scripts/setup-worktree.js
Comment thread scripts/tests/package-scripts.test.js Outdated
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 <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtuo29vduf
@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 118 passed · 0 failed · 118 total

Flakiness gate: ✅ 1 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:118 通过 · 0 失败 · 118 总计

抖动门:✅ 1 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR #11496 — deep verification report

Verdict: findings — 118/118 scripted assertions passed, 0 unexpected failures. The central claim is proven load-bearing by a real A/B; nothing measured is blocking. The findings below are reviewer-worthy but non-blocking: two residual doors in commit 2's guard (measured, with a measured fix), a new hard-failure mode outside a git repository, a translation gap in the design doc, and a Reviewer Test Plan step that is configuration-dependent. One correction to the PR description: its stated build/typecheck blocker does not reproduce at this head.

Verified head: d0cf2061de5687f4f2aa6199d94b056c78317487 (git rev-parse HEAD^2; the snapshot's headRefOid matches). Base tip: a0f0d38d4ce6519fedfd1e3beda0c8d14990e0a0 (HEAD^1). Assertions: 118 pass / 0 fail / 118 total (assertions.json = 56 A/B + 52 real-husky mutant/bystander + 10 house-style gates).

中文摘要
  • 结论findings(有值得 reviewer 关注的具体问题,但没有阻塞项)。118/118 条脚本化断言全部通过。
  • A/B 结论:核心主张成立且有承载作用。在"共享 git config 已有 core.hooksPath=.husky/_、但 linked worktree 没有 .husky/_ 包装脚本"这一 PR 描述的场景下,base 的 bootstrap 以 0 退出且 worktree 仍然无 hook(traced commit 不触发任何 hook,即 issue 描述的 bug 复现);head 会生成可执行的 .husky/_ 包装脚本,且该 worktree 的 commit 真实触发了 .husky/pre-commit(见 01-ab-matrix-hook-fires-on-head-not-base.png,cell 01 vs 02)。
  • findings
    1. commit 2 的守卫用"生效值"(git config --get)判断,而 husky 的写入永远落在仓库共享 config。两类非默认配置可以绕过守卫:worktree 级 config.worktree 里的 core.hooksPath=.husky/_(需 extensions.worktreeConfig=true),以及全局 config 里的同名值。实测:共享 config 被写入,且 primary checkout 里原有的 .git/hooks/pre-commit 静默不再触发(bystander 1 → 1,commit 仍成功)。仓库内没有任何地方默认开启这两种配置。已给出并实测一个 18 行的候选修复(守卫改读 git config --local --get):两个门都关上,12 个良性 cell 结果逐字节不变,prettier/eslint 干净,测试套件计数不变(即套件没有钉住这条轴,需要补 fixture)。
    2. 新增硬失败:在没有 .git 的目录里跑 bootstrap,head 以 1 退出(base 为 0)。原因仍会打印在 stdout(husky 输出 .git can't be found),可诊断性没问题。
    3. 设计文档只有英文版,缺 …zh-CN.md(AGENTS.md 要求双语;属既有缺口,按仓库规则为 Suggestion)。
    4. Reviewer Test Plan 的"新建 linked worktree 并确认 pre-commit 已生成"一步依赖前置条件:仅当共享 config 已有 core.hooksPath=.husky/_ 时成立;key 未设时 bootstrap 会按 commit 2 的设计跳过 husky(本 CI 容器自己的 checkout 恰好就是未设状态)。
  • 更正:PR 正文称"完整 build 因 document-export 超出 size budget 15,245 字节而失败、typecheck 随之失败"。在本 lane 的 head 上实测 npm run buildnpm run typecheck 均退出 0;document-export 超出的是警告阈值而非硬上限(具体字节数见下文 Corrections 一节,不在此重复以免两处数字漂移)。
  • 未覆盖范围:Windows/macOS、真实 monorepo 的完整 pnpm worktree 安装、pnpm-worktree-smoke.yml 在 runner 上的端到端、按 commit 归因(shallow checkout,commit 1 不可达)、全仓库 npm run test。详见下文 Not covered。

Scope selection

  • Central claim: in a linked worktree whose shared git config already carries core.hooksPath=.husky/_ but whose working tree never received the .husky/_ wrappers, node scripts/setup-worktree.js now generates executable wrappers so that a commit from that worktree actually invokes .husky/pre-commit; base exits 0 and leaves the worktree silently hookless.
  • Secondary 1: the bootstrap preserves an existing non-default core.hooksPath, honours HUSKY=0, and fails closed (exit 1, named reason) when husky exits 0 without configuring the expected path.
  • Secondary 2: in a linked worktree with the key unset, the bootstrap skips husky and leaves the shared repository config untouched, so no other checkout's hook resolution is rewritten (commit 2's guard).

Everything else is listed under Not covered.

Central claim — A/B against the base build

One fixture repository per cell, built once and copied per cell; every cell runs the real scripts/setup-worktree.js bytes (head = git show HEAD^2:…, base = git show HEAD^1:…, sha256-verified) as a child process against real git 2.39.5 worktree add, real corepack + pnpm 11.24.0 (the repo's pinned packageManager string, verbatim), and real husky 9.1.7 from the registry. The fixture copies scripts/prepare.js and scripts/pnpm-package.js verbatim and commits only .husky/pre-commit, so husky's own generated .gitignore is what has to keep the tree clean. Witness: evidence/01-ab-matrix-hook-fires-on-head-not-base.png; raw per-cell logs in cell-logs/, machine-readable in ab-results.json.

cell arm checkout shared config before exit .husky/_ exec hook fired (traced commit) shared config after git status
01 base linked .husky/_ 0 NO NO no .husky/_ clean
02 head linked .husky/_ 0 yes yes YES .husky/_ clean
03 head linked unset 0 NO NO no unset clean
04 base linked unset 0 NO NO no unset clean
05 head linked /custom/hooks 0 NO NO no /custom/hooks clean
06 head linked .husky/_ + HUSKY=0 0 NO NO no .husky/_ clean
07 head primary unset 0 yes yes YES .husky/_ clean
08 base primary unset 0 NO NO no unset clean
09 head primary unset + stub husky 1 NO NO no unset clean
10 base primary unset + stub husky 0 NO NO no unset clean
11 head primary unset + no .git 1 NO NO n/a unset n/a
12 base primary unset + no .git 0 NO NO n/a unset n/a

The load-bearing pair is 01/02: identical environment, identical traced commit, hook fired flips no → YES. Cell 01 reproduces the reported bug end-to-end (bootstrap exit 0, wrappers absent, commit succeeds while invoking no hook). Cell 02 additionally proves the wrappers are executable and that the tree stays clean — the docs-diff claim that husky's generated .gitignore preserves cleanliness, measured in a fixture that deliberately does not gitignore .husky/_. Cell 07 is the input state of .github/workflows/pnpm-worktree-smoke.yml (fresh primary checkout, key unset) and satisfies that workflow's git status --porcelain must-be-empty step. 56/56 scripted expectations passed.

Corrections to the PR description

The stated build/typecheck blocker does not reproduce at the verified head. The body says: "The full build currently fails because the existing document-export runtime exceeds its size budget by 15,245 bytes; typecheck then cannot resolve the missing generated web-template output." Measured in this lane at HEAD:

  • npm run buildexit 0 (build-at-head.log, BUILD_EXIT=0).
  • npm run typecheckexit 0 (typecheck-at-head.log, TYPECHECK_EXIT=0).
  • The document-export runtime is 4,142,391 bytes (build-at-head.log:532). Per packages/web-templates/src/export-html/build.mjs, DOCUMENT_RUNTIME_WARNING_BYTES = 4_100_000 only console.warns; the build throws only above MAX_DOCUMENT_RUNTIME_BYTES = 4_200_000. So the overshoot is 42,391 bytes against a warning, and 57,609 bytes under the hard cap — not a failure, and not 15,245 bytes.

This is a correction to the description, not a request to change code: the author used that blocker to justify skipping build/typecheck, and at this head neither gate is blocked. (The number presumably reflects the author's local main at writing time; the warn/fail distinction, however, is structural.)

Findings

F1 — commit 2's guard keys on the effective core.hooksPath, but husky's write always lands in the repository config; two doors walk past it (Suggestion, non-blocking)

The guard asks getHooksPath() === undefined, where getHooksPath() is git config --get core.hooksPath — the effective value. Husky's write (git config core.hooksPath .husky/_, no --worktree) always lands in the repository config that git config --local --get reads. Any source that can supply the effective value while the repository config is unset therefore bypasses the guard. Measured with real husky (bystander-harness.mjs, witness evidence/03-real-husky-mutants-bystander-blast-radius.png):

cell config source of .husky/_ repository config after bystander hook in primary (before → after)
C-head unset everywhere unset 1 → 2 (alive)
C-M3 (guard removed) unset everywhere written 1 → 1 (dead)
D-head worktree config.worktree (extensions.worktreeConfig=true) written 1 → 1 (dead)
D-patched same unset 1 → 2 (alive)
E-head global config written 0 → 0 (already redirected before the bootstrap)
E-patched same unset 0 → 0 (same)

In D the bystander oracle is a plain .git/hooks/pre-commit planted in the primary checkout: after bootstrapping the linked worktree, the primary's own hook silently stops firing while its commits keep succeeding (exit 0) — the exact harm commit 2's guard exists to prevent, reached through a door the guard does not cover. Reachability is narrow and I measured it as such: nothing in this repository sets extensions.worktreeConfig in a developer checkout (the workflows only sanitize it away as a known hazard, e.g. .github/workflows/qwen-triage.yml:2748), and a global core.hooksPath of exactly .husky/_ is unusual. Base never writes the config in either configuration (base never runs husky), so this is new surface introduced by the PR, in configurations base also mishandled differently (by leaving the worktree hookless).

Reproduce:

D=tmp/pr11496-verify-20260910-002421
node $D/ab-harness.mjs $D                       # builds the fixture template
BH_SRCS=head node $D/bystander-harness.mjs $D   # cells D-*/E-*
# read-scope probe that names the mechanism:
git -C <linked> config --worktree core.hooksPath .husky/_
git -C <linked> config --get core.hooksPath         # -> .husky/_  (what the guard reads)
git -C <linked> config --local --get core.hooksPath # -> unset     (what husky writes)
Measured minimal fix (candidate, not applied to the PR)

Ask the file husky writes, not the effective value, in the guard only — setup-worktree.patched.js in this artifact dir, prettier- and eslint-clean:

+function getSharedHooksPath() {
+  const result = spawnSync(
+    'git',
+    ['config', '--local', '--get', 'core.hooksPath'],
+    { cwd: rootDir, env, encoding: 'utf8' },
+  );
+  return result.status === 0 ? result.stdout.trim() : undefined;
+}
...
-    if (hooksPath === undefined && !ownsRepositoryConfig()) {
+    if (getSharedHooksPath() === undefined && !ownsRepositoryConfig()) {

Three measured results:

  1. Hostile fixtures go clean: BH_SRCS=head,patched → D/E-patched leave the repository config unset, print skipping Husky, and the bystander stays alive (1 → 2). 52/52 expectations pass.
  2. Zero collateral: the patched source through all 12 benign A/B cells produces byte-identical outcomes — 56/56 pass, table identical (ab-results-patched.json vs ab-results-head.json).
  3. Suite counts unchanged: npx vitest run --config ./scripts/tests/vitest.config.ts scripts/tests/package-scripts.test.js gives 40 passed | 1 skipped both with and without the patch — i.e. the suite pins nothing along this axis. The fixture that would pin it is a fourth case in installs hooks only where the checkout owns the repository config: set the value with git config --worktree (or via GIT_CONFIG_GLOBAL, which that test currently pins to an empty file — exactly why it cannot see door E) while the repository config is unset, and assert the repository config stays unset and stdout reports the skip.

Tradeoff to state plainly: with the patch, D/E worktrees get no wrappers (their hooks stay silent) — the same tradeoff commit 2 already accepts for the unset-key case, applied consistently.

F2 — new hard failure outside a git repository (informational)

With no .git at all, head exits 1 with worktree setup failed: Husky did not install hooks where base exits 0 (cells 11/12). The mechanism is real husky's own silent-success path: husky 9.1.7 returns the string .git can't be found and still exits 0, so the fail-closed check fires. The cause survives — husky's reason is printed on stdout above the script's message — so diagnosability is intact, and "fail closed" is a defensible choice for a bootstrap that is documented for worktrees only. The same conflation means a broken git config --get (any non-zero exit) reads as "unset": in a primary that then runs husky, whose write also fails, the operator sees husky's stderr followed by the fail-closed message. Reported as informational, not a defect: getHooksPath() returns one sentinel for "unset" and for "could not measure", but every consumer still ends in an observable outcome.

F3 — design doc has no Chinese counterpart (Suggestion, pre-existing gap)

docs/design/2026-08-29-pnpm-worktree-bootstrap.md was updated; docs/design/2026-08-29-pnpm-worktree-bootstrap.zh-CN.md does not exist (the doc was created without one). AGENTS.md requires both languages with reciprocal links and synchronised updates. Per this repo's review rules a translation gap alone is a Suggestion, and it predates this PR — but this PR widened the gap by adding English-only prose about the new hook behaviour.

F4 — one Reviewer Test Plan step is configuration-dependent (Suggestion, plan text)

Step 1 of How to verify — "Create a fresh linked worktree, run the documented pnpm worktree bootstrap, and confirm that the pre-commit hook is generated and executable" — passes only when the shared config already carries core.hooksPath=.husky/_ (cell 02). In a checkout where the key is unset the bootstrap deliberately skips husky and prints why (cell 03), so the step cannot be performed as written. Notably, this lane's own checkout is in the unset state: .husky/_ exists (mtime 00:11:07, from an earlier job's install, whose husky wrote the config) while .git/config was rewritten at 00:17:19 by actions/checkout with no core.hooksPath — so a maintainer following the plan in a CI-like checkout would hit the skip. The plan should name the precondition; the code's behaviour is commit 2's intent.

Full plan walk: step 1 → passes in the reported configuration (02), not reproducible in the unset configuration (03, see above); step 2 ("custom hooks path and HUSKY=0 are left unchanged") → passes (05, 06: values preserved byte-identical, no husky invocation, no wrappers); body claim "fails if Husky reports success without configuring the expected hooks path" → passes (09 with a stub husky, 11 with real husky's own silent-success path); body claim about tree cleanliness → passes (02, 07).

Mutation matrix and vacuity

mutants.mjs writes each mutant into the real working tree and runs the PR's own suite; witness evidence/02-mutation-matrix-killed-vs-survived.png, detail in mutants/matrix.json.

mutant what suite
CTRL whole-file revert to HEAD^1 positive control KILLED — 3 tests red on their intended assertions (package-scripts.test.js:261 missing pnpm exec husky log line, :385 missing skipping Husky, :523 missing husky line in the registry-fallback case)
M0 husky never invoked control KILLED — same 3
M1 drop HUSKY === '0' clause guard KILLED — :266 (invocation log)
M2 drop custom-path clause guard KILLED — :269
M12 drop both early-exit clauses combination KILLED — :266 (no hidden layering: both singles die alone)
M3 drop linked-worktree guard commit 2's guard KILLED — :385
M4 drop fail-closed check guard KILLED — :411
M5 ownsRepositoryConfig() always true guard KILLED — :385
M6 ownsRepositoryConfig() always false guard KILLED — :395
M7 revert the pathValueenvValue extraction refactor SURVIVED (40 pass)
M8 fail-closed compares '.husky' guard KILLED — 3 tests
M9 drop the skip message observability KILLED — :385

9/10 mutants killed; the two controls prove the harness can make the suite fail. The single survivor M7 is correctly classified as not a coverage gap: base's pathValue() already handled the Windows Path casing, so the extraction is behaviour-preserving on every platform; the newly observable behaviour it enables (case-insensitive HUSKY read) is covered by the win32-only test resolves the path variable under its native Windows casing, which is skipIf(platform !== 'win32') and therefore never runs here. Nothing to write.

The suite's oracles differ from production's. M1 and M2 are killed by the invocation log and by a nonzero exit respectively, because the suite's corepack is a stub that logs and exits 0 without ever running husky. With real husky the consequences are different and worse-shaped: M1 turns the supported HUSKY=0 opt-out into a hard failure (exit 1 via the fail-closed check, cell A), and M2 silently clobbers an operator's /custom/hooks to .husky/_ while exiting 0 (cell B). The guards are load-bearing in reality; the suite pins them by a proxy. Recorded as completeness reporting, not a merge condition.

Vacuity: reverting scripts/setup-worktree.js to HEAD^1 turns exactly 3 tests red, each failing on the behavioural assertion it exists to catch (quoted above), not on an import/compile error; the file was restored byte-identical (sha256 68a8f4ec…).

Targeted gates

  • npx vitest run --config ./scripts/tests/vitest.config.ts scripts/tests/package-scripts.test.js40 passed | 1 skipped (41), 1.17 s. The skip is the win32-only test.
  • npm run test:scripts (whole scripts workspace) → 82 files, 2187 passed | 15 failed | 1 skipped. Both failing files are environmental and outside this PR's import closure (neither references setup-worktree): scripts/tests/check-tui-dep-direction.test.js fails with EACCES: permission denied, mkdtemp '…/.qwen/gate-e2e-XXXXXX'.qwen is root-owned mode dr-xr-xr-x while the suite runs as uid 1000, and the same mkdtempSync fails with no PR code involved (A/A probe, quoted in test-scripts-full.log); scripts/tests/install-script.test.js fails on docker build with docker absent from the container. The PR's own file passed inside this run.
  • Prettier and ESLint on the three changed files → clean, each proven live by a planted violation that was reported and then restored byte-identical (sha256 verified); 10/10 scripted gate assertions in gates-results.json.
  • npm run build → exit 0; npm run typecheck → exit 0 (see Corrections).

Not covered

  • Windows and macOS. The win32-only test is skipped on Linux and no Windows host is available here; the envValue() casing path and the smoke workflow's windows-latest/macos-latest legs are unexercised.
  • The real monorepo bootstrap. The fixture uses the real pinned pnpm, real husky and real git, but a single-package layout: the repo's own workspace graph, patches/, postinstall: patch-package, and pnpm-workspace.yaml settings are not part of any cell.
  • pnpm-worktree-smoke.yml end-to-end on a runner. Cell 07 reproduces its input state (fresh primary checkout, key unset) and its clean-tree assertion; the workflow itself was not executed.
  • Per-commit attribution. The checkout is shallow (git rev-parse --is-shallow-repository = true) and git rev-list --count HEAD^1..HEAD^2 returns the shallow-boundary trap value 1 against the snapshot's 2 commits; commit 1 (5416b451) is not locally reachable. The aggregate HEAD^1..HEAD diff is what was verified; commit 2's guard is isolated separately by mutants M3/M5/M6 and cells C/D/E.
  • The linked CI failure (actions/runs/34353187365) — no GitHub token in this lane; not fetched.
  • Repo-wide npm run test across workspaces — the PR touches no package code; out of scope by design.
  • The full scripts suite's 15 failures were not re-run on a base worktree. They are attributed by mechanism (root-owned read-only .qwen, absent docker) with an A/A probe of the failing operation itself, not by a base-side rerun.
  • verify-capture.mjs worked as wired; no capture pipeline was reconstructed.

Methodology

Environment: the CI verify container (node:22-bookworm, node v22.23.2, git 2.39.5, corepack 0.34.6 resolving the repo-pinned pnpm 11.24.0), working tree = refs/pull/11496/merge at depth 2, npm ci + npm run build pre-run. The A/B fixture lives in /tmp/pr11496-fixture (outside the repository on purpose: cell 11/12 delete .git, and a fixture nested under this checkout would otherwise resolve every git config to the outer qwen-code repository); scratch git repos, real git worktree add linked checkouts, real corepack/pnpm installs against a warm store, and the real husky 9.1.7 package are the only things between the script under test and .husky/_/pre-commit — the sole stubs are the two cells that say so (09/10, husky replaced by a package that exits 0 configuring nothing) and the PR suite's own stub corepack, which is why the real-husky harness exists. Harnesses (ab-harness.mjs, bystander-harness.mjs, mutants.mjs, gates.mjs) and raw per-cell logs (cell-logs/, cell-logs-head/, cell-logs-patched/, build-at-head.log, typecheck-at-head.log, test-scripts-full.log) are in this directory; ab-results.json, mutant-real-results.json, mutants/matrix.json, gates-results.json carry every number quoted above.

Flakiness gate log

rounds=5 files=1 skipped=0
file scripts/tests/package-scripts.test.js: (cd .) npx --no-install vitest run --config ./scripts/tests/vitest.config.ts ./scripts/tests/package-scripts.test.js


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  scripts/tests/package-scripts.test.js: PPPPP

verdict: pass
summary: 1 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · scripts/tests/package-scripts.test.js: P (exit 0)
round 2 · scripts/tests/package-scripts.test.js: P (exit 0)
round 3 · scripts/tests/package-scripts.test.js: P (exit 0)
round 4 · scripts/tests/package-scripts.test.js: P (exit 0)
round 5 · scripts/tests/package-scripts.test.js: P (exit 0)

Evidence images

01-ab-matrix-hook-fires-on-head-not-base

02-mutation-matrix-killed-vs-survived

03-real-husky-mutants-bystander-blast-radius

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅ The Critical from the previous round is fixed at this commit and pinned by a test that fails without it; CI is green on all lanes that run for PRs. The two open Suggestions (the config-only post-check and the git-unreadable misattribution) are recorded in my notes above as follow-ups, not blockers.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed.

2 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R1-1 fail-closed post-check re-reads core.hooksPath rather than verifying husky's wrappers — still stands, already reported (comment 3971820508)
  • R1-9 fallback test's widened PATH dropped the no-ambient-tooling assertion — still stands, already reported (comment 3971820547)

Not reviewed: build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI and the changed script suite never executed on those platforms; it ran on Linux in CI and locally (40 passed | 1 skipped).

Not explored to full depth (tool budget reached): "agent 3c": could not run eslint / prettier / vitest or read node_modules/husky to confirm husky v9's exact exit behaviour when .git is missing — node_modules is em….

Not reviewed: reverse audit — stopped before round 3 by the review time budget.

Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:

  • scripts/setup-worktree.js:69 — [probe] Guard decides ownership from the filesystem while the config read and husky's write both follow the caller's GIT_DIR, so the repository reasoned about and the repository written are independent inputs
中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 2 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI and the changed script suite never executed on those platforms; it ran on Linux in CI and locally (40 passed | 1 skipped).

未探索到全部深度(达到工具调用预算):"agent 3c"could not run eslint / prettier / vitest or read node_modules/husky to confirm husky v9's exact exit behaviour when .git is missing — node_modules is em…

未审查:反向审计——评审时间预算不足,未能开始第 3 轮。

收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 1 条(原文未翻译,列表见上方英文部分)。

— qwen3.8-max via Qwen Code /review (v0.23.2)

Comment thread scripts/setup-worktree.js Outdated
Comment thread scripts/setup-worktree.js Outdated
Comment thread scripts/setup-worktree.js Outdated
Comment thread scripts/setup-worktree.js Outdated
Comment thread scripts/setup-worktree.js Outdated
Comment thread scripts/setup-worktree.js
Comment thread scripts/tests/package-scripts.test.js
yiliang114 and others added 3 commits September 10, 2026 12:11
`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 <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtuys2rjuu
- 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.
@yiliang114

Copy link
Copy Markdown
Collaborator Author

The red Test (ubuntu-latest, Node 22.x) at head 0bc0b858 is not from this PR. Three legs:

  1. Diff scope — this PR touches 3 files: docs/design/2026-08-29-pnpm-worktree-bootstrap.md, scripts/setup-worktree.js, scripts/tests/package-scripts.test.js. Nothing under packages/web-shell.
  2. Failure location — all 8 failures are in packages/web-shell/client: WorkspaceSessionProvider.loading.test.tsx:199 (6 variants — StrictMode true/false × initialFailure none/http/network), now seeing an extra GET /brand after GET /capabilities; plus WebShellSidebar.brand.test.tsx:204 and :212 (2 tests), where the brand version tooltip [title="… v1.2.3"] resolves to null. Run 34445347379, job 102768993438: Failed Tests 8 / Tests 8 failed | 7105 passed (7113).
  3. main is red with the identical set — run 34447493274 (head 1097b9fe0a), job 102780062265, fails the same 8 tests at the same file:line, Tests 8 failed | 7140 passed (7148). Same story on runs 34447005542 (head b1ac3e2970) and 34445109731 (head d8baa8730f). Only that one job fails; everything else on main is green.

Likely origin (attribution, not blame): 13b69b3313dafeat(web-shell): make the product name and logo configurable (#11244), merged today 04:54:45Z, 53 files / 26 under packages/web-shell. It added the brand context and the GET /brand fetch. Supporting detail: WebShellSidebar.brand.test.tsx has exactly one commit (13b69b3313), while WorkspaceSessionProvider.loading.test.tsx was last touched by a0f0d38d4c (#11413) at 2026-09-09T22:43:19Z — i.e. before #11244 landed, so the new /brand call was never reflected in that suite's expectations.

No fix has landed yet. Live main head is now b74592785bff (run 34451479882, pending); the prior head b3d022b35724 (run 34450051699) still has Test (ubuntu-latest, Node 22.x) in_progress. No green main run since the breakage. Qwen CI Failure Patrol did run on the failing heads (e.g. 34447683293 on 1097b9fe0a, success) but its job is scoped to stale PR failures, so it doesn't own this. I found no open issue tracking the main-side breakage either (searched brand test, WebShellSidebar) — not filing one here since main CI health is a maintainer call.

What this PR needs: nothing from the author's side. The check goes green once main is fixed and this branch re-runs or re-merges main. A bot review (🧐 Qwen Pull Request Review, run 34445345697) is already in flight at this exact head, so I deliberately did not fire /triage or /review — that would be a duplicate trigger. 0 unresolved review threads.

Pick up main's 2488d12 (fix(web-shell): realign two suites with shipped
behavior to unbreak main CI, #11530). This branch's Test job failed only in
packages/web-shell (WorkspaceSessionProvider.loading.test.tsx and
WebShellSidebar.brand.test.tsx), which it does not touch -- those were
main's own failures, already fixed on main.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmtva7llqva
@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 388 passed · 0 failed · 388 total

Flakiness gate: ✅ 1 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:388 通过 · 0 失败 · 388 总计

抖动门:✅ 1 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR #11496 — deep verification report (follow-up round)

Verdict: findings — 388/388 scripted assertions passed, 0 unexpected failures. The central claim is proven load-bearing by a real A/B against the base build, and both of commit 3's and commit 4's new claims hold. Nothing measured is blocking. Two concrete problems are worth a reviewer's attention: the guard introduced in commit 2 still has two doors at this head (F1, carried forward, re-measured, harm demonstrated end-to-end), and commit 4 introduced a new ordering defect that defeats the PR's own stated HUSKY=0 guarantee (N1). Both have measured fixes.

Verified head: b788256771bcd256daa8cb64b9362f9442d53131 (git rev-parse HEAD^2; the snapshot's headRefOid matches). Base tip: fe7cf686440d34c99e6980f274130c67533b764f (HEAD^1). Assertions: 388 pass / 0 fail / 388 total (assertions.json = 223 A/B run 1 + 50 A/B run 2 + 9 git-semantics + 13 zero-collateral + 68 mutation-matrix adjudication + 18 targeted gates + 7 candidate-fix style gates).

中文摘要
  • 结论findings(有值得 reviewer 关注的具体问题,但没有阻塞项)。388/388 条脚本化断言全部通过,0 条意外失败。
  • 本轮是复查轮:上一轮验证的是 commit 2(d0cf2061),本轮 head 新增了 commit 3(03b1493c)和 commit 4(56786510)。scripts/setup-worktree.js 在两轮之间发生了变化,输入闭包不成立,因此上一轮的每一项测量都在新 head 上重新跑过,没有用哈希相同为由跳过任何一项。
  • A/B 结论:核心主张成立且有承载作用。在"共享 git config 已有 core.hooksPath=.husky/_、但 linked worktree 没有 .husky/_ 包装脚本"这一 PR 描述的场景下,base 的 bootstrap 以 0 退出、worktree 仍然无 hook,traced commit 静默成功且不触发任何 hook(即 issue 描述的 bug 端到端复现);head 生成可执行的包装脚本,同一个 traced commit 真实触发了 .husky/pre-commit。这个翻转在本仓库自己的 .qwen/worktrees/<slug> 布局下同样成立(见 Central claim 表的 01/02 与 23b/23 两对;图 01-ab-cells-hook-fires-on-head-not-base.png)。commit 3 与 commit 4 各自的新主张也都被证实:--separate-git-dir clone 与 submodule 现在判定为"拥有自己的 config"并正常安装 hook;无仓库目录干净跳过(退出 0);config 读取被拒绝时 bootstrap 明确失败并打印原因。
  • 上一轮 findings 的状态(详见 Previous-finding status 表):F2 已修复(并被 mutant M9 钉住);F1 仍然存在;F3、F4 仍然存在;关于 build/typecheck 的更正仍然成立,但数字变了(main 改了预算口径,上一轮的字节数已不描述本 head)。
  • F1 的可达性本轮有了新的证据,比上一轮更值得看:门 D(extensions.worktreeConfig=true + worktree 级 core.hooksPath)并不是假想配置——仓库自己的 .github/workflows/qwen-triage.yml:2748-2755 用维护者的原话记录了它("a prior run can set --worktree core.hooksPath=/, survive the sweep untouched"),并以直接删除 config.worktree 作为缓解;qwen-autofix.yml 里有三处同样的处理。暴露面仍然窄,是因为没有任何 workflow 会在不是刚创建的 workspace 里跑 setup-worktree.js(smoke workflow 用的是全新的 actions/checkout,即 cell 07 的状态);现实中会走到门 D 的是本地自行开启 worktree config 的开发者。危害本身在 cell 19 端到端复现:主 checkout 里的 .git/hooks/pre-commit 由触发变为不触发(1→0),而它的 commit 仍然成功、退出码仍然是 0——没有任何提示。
  • 本轮新发现
    1. N1(新增)HUSKY=0 这个 PR 正文明确承诺尊重的开关,在 git 拒绝读取 config 时不再有效——getHooksPath()HUSKY 判断之前执行,读取失败直接 exit 1(cell 18:head 退出 1,base 退出 0)。已给出并实测候选修复(把 HUSKY=0 判断提前,6 增 4 删,prettier/eslint 干净):目标 cell 转为退出 0,7 个良性 cell 结果不变,测试套件计数不变。
    2. N2(信息性):commit 3 的归属判定用字符串比较 gitDir === commonDir,而 git 在非 worktree 根目录下会把 --git-dir 打成绝对路径、--git-common-dir 打成相对路径(../.git),因此任何"worktree 根以外的目录"都会被判成 linked。实测 13 种布局有 3 处不符。方向是 fail-safe 的:真实 linked worktree 从未被误判为 owns,所以 commit 2 要防的危害没有从这个口子回来;误判只发生在 rootDir 本身不是仓库根的情形,而那种情形下 husky 自己也会软失败。唯一实际影响是跳过提示的措辞会把"这里根本没有仓库"说成"等主 checkout 装好 hook 再重跑"。
    3. N3:变异矩阵 4 个存活者逐一裁定(见 N3 表)。其中 M11(fail-closed 去掉 config 值那一半)经 cells 24/25 证明该分支可判定(head 退出 1、mutant 退出 0),所以不是死代码;但用仓库钉住的 husky 9.1.7 时该状态不可达(husky 先写 config 再写包装脚本),因此对该依赖是冗余防御、对"换一个 husky"则是覆盖缺口。
    4. N4:M16 的"被杀死"是变异形状造成的崩溃(直接运行证明是 ReferenceError: envValue is not defined),不是断言;接口保持型的 M16b 存活,说明 win32 大小写这条轴在 Linux 上按构造不可观测(对应测试是 skipIf(platform !== 'win32'))。
  • 未覆盖范围:Windows/macOS、真实 monorepo 的完整 pnpm worktree 安装、pnpm-worktree-smoke.yml 在 runner 上的端到端、按 commit 归因(shallow checkout,6 个 commit 只有 1 个本地可达)、全仓库 npm run test、dubious-ownership 的真实构造(本容器 uid 1000,无法造出异主目录)。详见 Not covered

Previous-finding status

The previous substantive report verified d0cf2061 (commit 2). This head adds commit 3 (03b1493c), commit 4 (56786510) and two merge commits. scripts/setup-worktree.js changed between the rounds, so the input closure is not identical and no measurement was carried forward by hash — every row below was rebuilt and re-run at b7882567.

# finding severity status at b7882567
F1 guard keys on the effective core.hooksPath while husky's write always lands in the repository config; doors D (worktree config) and E (global config) walk past it Suggestion stands — re-measured with real husky: cells 19/20 write the shared config, and in cell 19 the primary checkout's own .git/hooks/pre-commit goes 1 → 0 (dead) while its commits keep succeeding. Candidate fix re-derived against the new source (commit 3 rewrote the surrounding code) and re-measured: both doors close, 13/13 benign cells identical across 17 oracles, suite counts unchanged.
F2 new hard failure outside a git repository (head exit 1 vs base exit 0), caused by husky's .git can't be found soft success tripping the fail-closed check informational fixed — commit 3 replaced the .git-shape proxy with git rev-parse, which returns 'none' for no repository. Cell 11 now exits 0 with the no-repository notice, and the predicate returns the correct verdict for all six real layouts (owns for primary / --separate-git-dir clone / submodule / bare, linked for a real linked worktree, none for no repository). The fix is pinned: mutant M9 (revert probe.status !== 0 → 'none' to 'owns') is killed at test.js:494.
F3 design doc has no Chinese counterpart Suggestion stands, and wideneddocs/design/2026-08-29-pnpm-worktree-bootstrap.zh-CN.md still does not exist, and this head adds further English-only prose about the hook behaviour. Pre-existing gap; per this repo's review rules a translation gap alone is a Suggestion.
F4 Reviewer Test Plan step 1 is configuration-dependent Suggestion stands — the precondition is unchanged: step 1 passes only when the shared config already carries core.hooksPath=.husky/_ (cell 02). This lane's own checkout is still in the unset stategit config --get core.hooksPath exits 1 while .husky/_/pre-commit exists (mtime 09:40) and .git/config was rewritten at 09:53 — so a maintainer following the plan here hits the skip. Commit 4 improved the notice (it now names the recovery path, pinned by M14) but did not change the step's precondition.
Corr the description's stated build/typecheck blocker does not reproduce correction stands; the numbers moved under itnpm run build and npm run typecheck both exit 0 at this head, and main changed what the budget measures, so last round's figures no longer describe this head. See Corrections.

Scope selection

  • Central claim: in a linked worktree whose shared git config already carries core.hooksPath=.husky/_ but whose working tree never received the .husky/_ wrappers, node scripts/setup-worktree.js now generates executable wrappers so a commit from that worktree actually invokes .husky/pre-commit; base exits 0 and leaves the worktree silently hookless.
  • Secondary 1 (commit 3): ownership of the config husky writes is decided by git rev-parse --git-dir vs --git-common-dir, not by the filesystem shape of .git — so a --separate-git-dir clone and a submodule install hooks, a directory with no repository skips cleanly instead of hard-failing, and the bootstrap gains no hard git dependency.
  • Secondary 2 (commit 4): a refused core.hooksPath read surfaces as a named hard failure instead of being folded into "unset", the skip notice names a recovery path, and the fail-closed check requires an artefact husky's own write produced rather than re-reading a value the worktree may inherit.

Everything else is listed under Not covered.

Central claim — A/B against the base build

45 cells in run 1 plus 10 in run 2, one throwaway repository per cell, all outside the checkout on purpose (cells 11/12 have no .git at all and cell 15 relies on git rev-parse walking up, so a fixture nested under this repository would resolve every git config and rev-parse against qwen-code itself). Every cell runs the real script bytes — head git show HEAD^2:… (sha256 d45c4975…), base git show HEAD^1:… (sha256 819d0eac…) — as a child process against real git 2.39.5 worktree add / clone --separate-git-dir / submodule add, real corepack 0.34.6 + the repo-pinned pnpm 11.24.0 (the packageManager string verbatim), and real husky 9.1.7 from the registry. Oracles are read off the filesystem and off real git, never off the script's own opinion: exit code, wrapper presence and exec bit, the raw bytes of the .git/config husky wrote into, git status --porcelain, and whether a traced commit appended to a hook trace file. Witness for run 1's 45 cells: 01-ab-cells-hook-fires-on-head-not-base.png; per-cell raw logs for both runs in cell-logs/ (55 files), machine-readable in ab-results.json / ab-results-2.json.

The load-bearing pair. Cells 01-base and 02 are identical in environment and in the traced commit; only the script bytes differ.

cell arm checkout shared config before exit .husky/_ + exec hook fired commit exit shared config after tree
01 base linked .husky/_ 0 NO no 0 (silent) .husky/_ clean
02 head linked .husky/_ 0 yes FIRED 0 .husky/_ clean
23b base linked inside primary .husky/_ 0 NO no 0 (silent) .husky/_ clean
23 head linked inside primary .husky/_ 0 yes FIRED 0 .husky/_ clean

Cell 01 reproduces the reported bug end-to-end: the bootstrap is green, the wrappers are absent, and the commit succeeds with empty stderr while invoking no hook — the silent-bypass shape, which is why this outranks a loud failure. Cells 23b/23 repeat the pair in the layout this repository's own tooling actually uses (a linked worktree created under <primary>/.qwen/worktrees/<slug>), so the fix is not an artefact of a sibling-directory layout.

Everything else measured, grouped by what it decides (01-ab-cells-hook-fires-on-head-not-base.png carries run 1's 45 rows with their oracles; cells 24/25 are run 2, in ab-results-2.json):

question cells result
unset key in a linked worktree must not touch the shared config 03 head / 04 base skip notice printed, shared config untouched, exit 0 — commit 2's guard holds at this head
explicit settings preserved 05, 05b head /custom/hooks byte-identical after the run, and with a stub that logs every invocation husky is never invoked
HUSKY=0 honoured 06 head husky never invoked, no wrappers, exit 0
primary checkout (the smoke workflow's input state) 07 head / 08 base head installs executable wrappers, a traced commit fires, core.hooksPath=.husky/_ written locally, tree stays clean — satisfies pnpm-worktree-smoke.yml's git status --porcelain must-be-empty step
fail-closed on a husky that exits 0 configuring nothing 09 head / 10 base head exit 1 Husky did not install hooks, base exit 0
F2 fix: no repository at all 11 head / 12 base head exit 0 with the no-repository notice (was exit 1 at d0cf2061), base exit 0
commit 3: .git-file checkouts that own their config 13 separate-git-dir, 14 submodule both owns, both install executable wrappers, both fire a traced commit
commit 3: no hard git dependency 17 head / 17b base, PATH holds no git exit 0, skip notice, no wrappers — matches base
commit 4: refused config read surfaces 16 head / 16b base, corrupt .git/config head exit 1 could not read core.hooksPath (fatal: bad config line 7 in file .git/config), base exit 0
N1: HUSKY=0 against a refused read 18 head / 18b base head exit 1, base exit 0 — the opt-out is defeated (see Findings)
F1 doors D and E 19/19b/19p, 20/20b/20p head writes the shared config in both; base writes it in neither (see Findings)
ownership predicate's blind spot 15 head, non-repo dir nested in another repo exit 0, skip, but with the linked-worktree wording — see N2
fail-closed's two halves are independently decidable 24 head / 25 the M11 mutant head exit 1, mutant exit 0 — see N3

Two scalar facts settle questions no amount of reading settles, both measured here rather than inferred:

  • git config --get core.hooksPath with the key set in both global and local returns exit 0 with one value — so the multi-value collision I expected to turn into commit 4's new hard-failure branch does not happen. That consequence was tested and does not hold.
  • husky 9.1.7 writes wrappers for all 14 hook names unconditionally (index.js:22), independent of which hooks exist in .husky/. So the fail-closed check's hardcoded .husky/_/pre-commit is a safe artefact to demand from the pinned husky, and a repository shipping only .husky/commit-msg would not false-fail.

Corrections

The stated build/typecheck blocker still does not reproduce, and main has since changed what the budget measures. The body says: "The full build currently fails because the existing document-export runtime exceeds its size budget by 15,245 bytes; typecheck then cannot resolve the missing generated web-template output." Measured in this lane at HEAD:

  • npm run buildexit 0 (build-at-head.log, BUILD_EXIT=0).
  • npm run typecheckexit 0 (typecheck-at-head.log, TYPECHECK_EXIT=0).
  • packages/web-templates/src/export-html/build.mjs:349 budgets Buffer.byteLength(documentJsBundle.text) — the renderer JS only — against DOCUMENT_RUNTIME_WARNING_BYTES = 1_870_000 (warns) and MAX_DOCUMENT_RUNTIME_BYTES = 1_930_000 (throws). Measured: 1,837,054 bytes, i.e. 32,946 under the warning and 92,946 under the cap. No document-export warning line appears in the build log at all. The component CSS (2,302,905 bytes) is reported on the same line but is not budgeted.

This is a correction to the description, not a request to change code. Note for anyone comparing rounds — the two measurements are not comparable, and the reason is measured rather than assumed: the previous round reported a single "document-export runtime" of 4,142,391 bytes against thresholds of 4,100,000 / 4,200,000 read from the same file. At this head those thresholds are 1,870,000 / 1,930,000 and the budgeted quantity is the renderer JS bundle alone, with the CSS printed beside it but not compared against either limit. Today's JS + CSS is 1,837,054 + 2,302,905 = 4,139,959, within 2,432 bytes of the previous round's combined figure, which is consistent with that round's number having been the two added together — so the basis changed on main between the two base tips, and neither the old byte count nor the old thresholds describe this head. The structural point survives unchanged and does not depend on any of that arithmetic: the author used this blocker to justify skipping build and typecheck, and at this head neither gate is blocked.

Findings

F1 — the guard reads the effective core.hooksPath, but husky's write always lands in the repository config; two doors walk past it, and through one the exact harm commit 2 exists to prevent occurs (Suggestion, stands from the previous round)

install() gates on getHooksPath() === undefined, where getHooksPath() is git config --get core.hooksPath — the effective value. Husky's write (git config core.hooksPath .husky/_, no --worktree, index.js:14) lands in the file git config --local --get reads. Any source that supplies the effective value while the repository config is unset therefore satisfies the guard's premise and walks past it. Commit 3 replaced the ownership half of the guard with git rev-parse and commit 4 hardened the read, but neither changed which value the guard asks for, so the doors are still open. Measured with real husky and a bystander oracle (print-f1-doors.mjs, witness 05-f1-guard-blind-spot-bystander-dies.png):

cell arm source of the effective .husky/_ shared config after bystander in primary
19 head door D: config.worktree (extensions.worktreeConfig=true) WRITTEN 1 → 0 DEAD
19b base door D untouched 1 → 1 alive
19p patched door D untouched 1 → 1 alive
20 head door E: global config WRITTEN 0 → 0
20b base door E untouched 0 → 0
20p patched door E untouched 0 → 0
03 head control: unset everywhere untouched

The bystander is a plain .git/hooks/pre-commit planted in the primary checkout before the linked worktree is bootstrapped. In cell 19, after the bootstrap, the primary's own hook silently stops firing while its commits keep succeeding — hook resolution repointed for a root that never received the wrappers. That is precisely the harm commit 2's guard was written to prevent, reached through a door it does not cover. Door E's bystander is degenerate by construction (a global core.hooksPath already redirects the primary before the bootstrap runs), so its oracle is the shared-config write itself: the redirection becomes persistent in the repository rather than ambient.

Bounds, stated plainly. Base never writes the shared config in either door (base never runs husky), so this is surface the PR introduces. Reachability differs between the two doors and I measured both rather than averaging them:

  • Door E (a global core.hooksPath of exactly .husky/_) is unusual in a developer checkout, and nothing in this repository sets one. Narrow.
  • Door D (extensions.worktreeConfig=true plus a worktree-scoped core.hooksPath) is a state this repository has already met. .github/workflows/qwen-triage.yml:2748-2755 documents it in the maintainers' own words — "extensions.worktreeConfig=true … activates .git/config.worktree — a second config file that git config --local neither lists nor unsets, and that CAN carry core.hooksPath. Verified: a prior run can set --worktree core.hooksPath=/, survive the sweep untouched" — and mitigates by deleting config.worktree outright. The same block appears three times in qwen-autofix.yml. So the configuration is not hypothetical in CI; it is one the bot sanitizes on sight. What keeps the exposure narrow is that no workflow runs setup-worktree.js in a workspace it has not just created: pnpm-worktree-smoke.yml uses a fresh actions/checkout (cell 07's state), and the bot's own workspaces go through the sweep above. A developer who enables worktree config locally is the realistic door-D path, and nothing in the repo suggests or forbids that.

No exploit or data loss in either door; the consequence is a silently hookless sibling checkout, which is the same class of harm the PR set out to close — and cell 19 shows it arriving with a green exit code, so nothing tells the operator.

Reproduce:

D=tmp/pr11496-verify-20260910-095805
node $D/ab-harness.mjs $D                                   # runs all 45 cells
node $D/print-f1-doors.mjs $D                               # the table above
# the mechanism in three commands:
git -C <linked> config --worktree core.hooksPath .husky/_   # needs extensions.worktreeConfig=true
git -C <linked> config --get core.hooksPath                 # -> .husky/_   (what the guard reads)
git -C <linked> config --local --get core.hooksPath         # -> exit 1     (what husky writes)
Measured minimal fix (candidate, not applied to the PR)

Ask the file husky writes, not the effective value, in the guard only — patched-setup-worktree.js in this artifact dir, 13 additions / 1 deletion, prettier- and eslint-clean:

+function getSharedHooksPath() {
+  const result = spawnSync(
+    'git',
+    ['config', '--local', '--get', 'core.hooksPath'],
+    { cwd: rootDir, env, encoding: 'utf8' },
+  );
+  return result.status === 0 ? result.stdout.trim() : undefined;
+}
...
-      (hooksPath === undefined && ownership === 'linked')
+      (getSharedHooksPath() === undefined && ownership === 'linked')

Three measured results (zero-collateral.txt, gates-results.json):

  1. Hostile fixtures go clean: cells 19p/20p leave the shared config untouched, print skipping Husky, and door D's bystander stays alive (1 → 1).
  2. Zero collateral: across all 13 benign cells the patched source produces identical outcomes on every one of 17 oracles — exit, wrappers, exec bit, hook fired, local/effective/worktree config values, shared-config write, tree cleanliness, all three notice flags, both fail-closed flags, stub invocation, ownership verdict, and both bystander counts. 13/13 identical, 0 differ.
  3. Suite counts unchanged: 40 passed | 1 skipped with and without the patch — i.e. the suite pins nothing along this axis, which is why the mutation matrix's FURTHER-FIX row survives. The fixture that would pin it is a fourth case in installs hooks only where the checkout owns the repository config: set the value with git config --worktree (or via GIT_CONFIG_GLOBAL, which that test currently pins to an empty file — exactly why it cannot see door E) while the repository config is unset, then assert the repository config stays unset and stdout reports the skip.

Two tradeoffs to state rather than hide. First, with the patch, door D/E worktrees get no wrappers — the same tradeoff commit 2 already accepts for the unset-key case, applied consistently. Second, --local --get returns exit 128 outside a repository (measured: cell 11 local=unset(exit 128)), so a patch written this way re-introduces, on the local read, the same "unset vs could not measure" conflation commit 4 just removed from the effective read; a version meant to land should carry commit 4's exit-code discrimination over to getSharedHooksPath() too. The cells above pass either way because the ownership === 'none' branch short-circuits first.

N1 — HUSKY=0 does not survive a refused config read, so the opt-out the PR body promises is defeated in exactly the configuration commit 4 was written for (Suggestion, new at this head)

install() reads the hooks path before it looks at the opt-out:

const hooksPath = getHooksPath();                 // can console.error + process.exit(1)
if (envValue('HUSKY') === '0' || (hooksPath !== undefined && hooksPath !== '.husky/_')) {

Commit 4 made getHooksPath() exit 1 on any status other than 0, 1, or a spawn error. The two facts combine: when git answers but refuses the read, the bootstrap hard-fails before the HUSKY === '0' branch is reached. The PR body states the change "respects the HUSKY=0 opt-out"; measured, it does not in this configuration.

cell arm config read HUSKY exit stderr
18 head refused (git stub exits 128 for this one invocation only) 0 1 worktree setup failed: could not read core.hooksPath (fatal: simulated refused config read)
18b base refused 0 0
06 head normal 0 0 — (husky never invoked)

Cell 16 shows the same exit is correct when hooks are actually wanted (corrupt .git/config, no HUSKY opt-out → fail loud rather than ship a green hookless checkout). The defect is only the ordering: an operator who has explicitly said "no hooks" is failed by a read whose result they never asked for. The two conditions plausibly co-occur — commit 4's own message names "a shared pool's dubious-ownership exit 128", and HUSKY=0 is what an operator sets on a shared host.

Measured minimal fix (candidate, not applied to the PR)

patched2-setup-worktree.js, 6 additions / 4 deletions, prettier- and eslint-clean:

   if (result.status === 0) {
+    if (envValue('HUSKY') === '0') {
+      exitWithResult(result);
+    }
     const hooksPath = getHooksPath();
-    if (
-      envValue('HUSKY') === '0' ||
-      (hooksPath !== undefined && hooksPath !== '.husky/_')
-    ) {
+    if (hooksPath !== undefined && hooksPath !== '.husky/_') {
       exitWithResult(result);
     }

Measured (ab-results-2.json, cells H02–H18):

  1. Target cell flips: H18 → exit 0, no could not read message, husky never invoked.
  2. Zero collateral: H16 still exits 1 with could not read core.hooksPath (the loud path survives when hooks are wanted); H02 wrappers + hook fired; H03 skip notice + recovery sentence; H05 /custom/hooks preserved; H06 opt-out still never invokes husky; H07 wrappers + hook fired; H09 still fails closed. 8/8 cells as expected — 36/36 assertions across them, within run 2's 50/50 (the remaining 14 are the M11 pair, cells 24/25).
  3. Suite counts unchanged: 40 passed | 1 skipped with the patch applied. As with F1, that means the axis is unpinned — the fixture that would pin it is the existing exit-128 case at test.js:526 run a second time with HUSKY: '0' in the environment, asserting exit 0 and no could not read message.

Both candidate fixes were also put through the repository's own house-style gates in the location the config governs (style-gates.mjs, 7/7): written into scripts/setup-worktree.js, npx prettier --check and npx eslint both exit 0, then the file is restored byte-identical (sha256 d45c4975…) and git status --porcelain is empty. Those two tools were proven live earlier in the same round on the same path — a planted formatting break and a planted unused variable were each reported, then restored (gates-results.json) — so the clean results here are measurements, not an unmatched glob.

F3 — design doc has no Chinese counterpart (Suggestion, pre-existing gap, widened here)

docs/design/2026-08-29-pnpm-worktree-bootstrap.md gained 14 lines (net +12) of English-only prose about the new hook behaviour; …​.zh-CN.md still does not exist (ls docs/design/ | grep -i pnpm returns the English file alone). AGENTS.md requires both languages with reciprocal links, kept synchronised in the same change. Per this repo's review rules a translation gap alone is a Suggestion, and the gap predates this PR — but this head widens it. The English prose itself is accurate against the code as measured: the three skip conditions, the two fail-closed conditions and the .gitignore-based cleanliness claim all match the cells above. One omission: it does not mention commit 4's third failure path (could not read core.hooksPath), which is a behaviour change a reader of the doc would not learn about.

F4 — one Reviewer Test Plan step is configuration-dependent (Suggestion, plan text)

Step 1 of How to verify — "Create a fresh linked worktree, run the documented pnpm worktree bootstrap, and confirm that the pre-commit hook is generated and executable" — passes only when the shared config already carries core.hooksPath=.husky/_ (cell 02). With the key unset the bootstrap deliberately skips husky and prints why (cell 03), so the step cannot be performed as written; this lane's own checkout is in the unset state, so a maintainer following the plan in a CI-like checkout hits the skip on the first attempt. Step 2 ("a custom hooks path and HUSKY=0 are left unchanged") is reproducible as written and passes (cells 05/05b/06).

Full plan walk: step 1 → passes in the reported configuration (02, and 23 in the repo's own worktree layout), not reproducible in the unset configuration (03); step 2 → passes (05, 05b, 06); body claim "fails if Husky reports success without configuring the expected hooks path" → passes (09 with a stub, 24 with a husky that writes wrappers only); body claim "preserves an existing custom hooks path, respects the HUSKY=0 opt-out" → passes for a custom path (05) and for HUSKY=0 in a healthy checkout (06), fails for HUSKY=0 against a refused config read (18, see N1); body claim about tree cleanliness → passes (02, 07, 13, 14, 23 all clean, via the .gitignore husky generates).

N2 — the ownership predicate compares two strings whose form depends on where you stand; the error direction is fail-safe, and no reachable configuration turns it into a wrong hook decision (informational, new at this head)

Commit 3 decides ownership with gitDir === commonDir over the two lines of git rev-parse --git-dir --git-common-dir. Git does not print those two in the same form: from a worktree root both are .git/.git or both absolute, but from any directory below a root --git-dir becomes absolute while --git-common-dir stays relative. Measured across 13 real layouts (02-revparse-ownership-string-comparison.png, revparse-cwd.log):

layout --git-dir / --git-common-dir verdict truth
primary root .git / .git owns
primary subdir /abs/.git / ../.git linked
primary subdir (2 deep) /abs/.git / ../../.git linked
linked worktree root /abs/.git/worktrees/x / /abs/.git linked
linked worktree subdir same as its root linked
--separate-git-dir clone equal absolutes owns
submodule equal absolutes owns
bare repo . / . owns
no repository exit 128 none
non-repo dir nested in a repo /abs/outer/.git / ../.git linked
primary root with GIT_DIR=.git .git / .git owns
primary root via symlink .git / .git owns

3 mismatches out of 13, all of them a directory that is not a worktree root, all in the same direction: something that owns its config is reported as linked, which makes the bootstrap decline hooks.

Why this does not become a defect, checked rather than assumed. rootDir is fileURLToPath(new URL('..', import.meta.url)) — the parent of scripts/ — so for this repository it is always the worktree root, where the two forms agree. For the mismatch to matter, rootDir would have to be a strict subdirectory of a worktree while also being a project root; and in that case husky could not have worked anyway, because husky's own first check is existsSync('.git') relative to its cwd (index.js:11). Cell 15 measures exactly this and the outcome is benign: exit 0 with the skip notice, no wrappers, and the outer repository's .git/config contains no hooksPath afterwards (verified by reading the file, not by asking the script) — the husky branch, the only code that writes config, was never reached. The dangerous direction — a real linked worktree reported as owns, which would let husky write the shared config — is not reachable: cells 01–06, 19–23 all classify linked correctly, including the .qwen/worktrees/ layout nested inside the primary. So commit 3's predicate never re-opens the hazard commit 2 closed.

What survives is a wording defect, not a decision defect: in cell 15 the notice says "core.hooksPath is unset and this checkout does not own the repository config … Re-run this script here once hooks are installed in the primary checkout", which tells the operator to wait for a primary checkout that does not exist for that path. Comparing resolved paths (realpath) instead of raw strings would make the predicate independent of cwd form; the current behaviour is safe without it.

N3 — mutation-matrix survivors, adjudicated (completeness reporting, not merge conditions)

14 killed / 18 mutants, unmutated control green at 40 passed | 1 skipped, witness 03-mutation-matrix-killed-vs-survived.png, detail in mutants/summary.json. Every survivor is classified rather than left as a bare "survived":

mutant what it removes result classification
CTRL whole file → HEAD^1 KILLED, 3 tests vacuity control passes: test.js:266 (log missing "1 1 pnpm exec husky"), :449 and :650 (stdout missing 'skipping Husky') — each on its intended behavioural assertion with an expected/received diff, none on an import or compile error
M1 / M2 / M12 HUSKY=0 clause / custom-path clause / both KILLED at :271, :274, :271 guards load-bearing; the combination row shows no hidden layering — both singles die alone
M3 the whole ownership skip block KILLED, 2 tests commit 2/3's guard pinned
M5 / M6 / M7 / M8 owns / linked / none always; comparison inverted all KILLED commit 3's predicate pinned in all four directions
M9 failed rev-parse'owns' (revert the F2 fix) KILLED at :494 the F2 fix is pinned
M10 fail-closed's on-disk wrapper half KILLED at :503 the artefact half commit 3 added is load-bearing
M13 fold every non-zero exit into "unset" (revert commit 4) KILLED at :532 commit 4's exit-code discrimination is load-bearing
M14 the recovery sentence KILLED at :452 commit 4's notice change is pinned
M11 fail-closed's config-value half SURVIVED decidable, so not dead code — cells 24/25 drive a husky that writes wrappers but not the config: head exits 1, the mutant exits 0. Against the pinned husky 9.1.7 that state is unreachable (the config write at index.js:14 precedes wrapper creation at :19-22, so a soft failure produces neither), making the clause redundant defence today and a coverage gap against any husky that reorders. Fixture that would pin it: the stub already in the test with setsHooksPath: false, writesHooks: true, asserting exit 1.
M15 the no-repository wording branch (prints the linked message for 'none') SURVIVED coverage gap: the test asserts only .toContain('skipping Husky'), which both wordings satisfy, so nothing distinguishes the two notices commit 4 wrote. Fixture: assert noRepoRun.stdout contains git could not resolve a repository.
M16 revert the pathValueenvValue extraction KILLED — but see N4 killed by the mutation's own shape, not by an assertion
M16b interface-preserving: collapse envValue() to a case-sensitive read SURVIVED unobservable on Linux by construction; the test that would pin it is skipIf(process.platform !== 'win32'). Nothing to write on this platform.
FURTHER-FIX the F1 candidate patch applied SURVIVED the suite pins nothing on the F1 axis — the unpinned-axis signal for F1

N4 — a kill that proves nothing, corrected by a finer mutation (informational)

M16 deletes envValue() while envValue('HUSKY') is still called, so the script crashes and the suite goes red on expected 1 to be +0. Run directly, outside vitest (04-m16-kill-is-a-crash-not-an-assertion.png, mutants/m16-direct-run.log):

ReferenceError: envValue is not defined
    at install (file:///tmp/m16probe/scripts/setup-worktree.js:104:5)
M16_DIRECT_EXIT=1

A red suite from a crash is indistinguishable from a red suite from a behavioural assertion unless you look, and the difference matters: reported naively, M16 would have claimed the win32 casing extraction is pinned when what is pinned is only that later code calls the function. M16b re-measures the same axis interface-preserving and survives, which is the honest result. The matrix is quoted with M16 attributed to its crash throughout.

Not covered

  • Windows and macOS. The win32-only test is skipIf on Linux and no Windows host is available here, so the envValue() casing path, corepack.cmd, and the smoke workflow's windows-latest / macos-latest legs are all unexercised. N4 shows this axis cannot be pinned on Linux even in principle.
  • The real monorepo bootstrap. The fixtures use the real pinned pnpm, real husky and real git, but a single-package layout: the repository's own workspace graph, patches/, postinstall: patch-package and the pnpm-workspace.yaml settings are in no cell.
  • pnpm-worktree-smoke.yml end-to-end on a runner. Cell 07 reproduces its input state (fresh primary checkout, key unset) and its clean-tree assertion; the workflow itself was not executed.
  • Per-commit attribution. The checkout is shallow and git rev-list HEAD^1..HEAD^2 returns 1 commit against the snapshot's 6 — the shallow-boundary trap value, not a real count. Commits 3 and 4 are not individually reachable, so the aggregate HEAD^1..HEAD diff is what was verified. Commit 3's and commit 4's contributions are separated by their own cells (13/14/17 vs 16/18) and mutants (M5–M9 vs M10/M13/M14), not by per-commit checkouts.
  • Dubious ownership, constructed for real. Commit 4's message names "a shared pool's dubious-ownership exit 128". This container runs as uid 1000 with no privilege to create a foreign-owned tree, so exit 128 was reached via a corrupt .git/config (cell 16, a real git refusal with a real fatal: message) and a single-invocation git stub (cell 18). The exit-code handling is identical, but the ownership path itself was not reproduced.
  • The linked CI failure (actions/runs/34353187365) — no GitHub token in this lane; not fetched.
  • Repo-wide npm run test — the PR touches no package code; out of scope by design.
  • npm run test:scripts failures were not re-run on a base worktree. Full run at head: 2 failed | 83 passed (85 files), 15 failed | 2217 passed | 1 skipped (2233 tests). Both failing files are outside this PR's closure (the diff touches 3 files; neither test file references setup-worktree), and each cause was reproduced with no PR code involved: check-tui-dep-direction.test.js dies on EACCES: permission denied, mkdtemp '/__w/qwen-code/qwen-code/.qwen/gate-e2e-XXXXXX'.qwen is dr-xr-xr-x root root while the suite runs as uid 1000, and a bare mkdtempSync against it fails identically; install-script.test.js dies on docker build with docker absent from the container. Attributed by A/A probe of the failing operation, not by a base-side rerun. The PR's own file passed inside this run.
  • The rev-parse layout sweep (13 rows) is a characterization, not an assertion set — its 3 mismatches are reported as N2's measurement and are deliberately excluded from assertions.json; the behavioural consequences are asserted instead (cell 15).
  • verify-capture.mjs worked as wired for all five images; no capture pipeline was reconstructed.

Methodology

Environment: the CI verify container (node:22-bookworm, node v22.23.2, git 2.39.5, corepack 0.34.6 resolving the repo-pinned pnpm 11.24.0, husky 9.1.7), working tree = refs/pull/11496/merge at depth 2, npm ci + npm run build pre-run; no zstd binary in the image, $RUNNER_TEMP unset in this shell.

Why the control needed no base worktree or rebuild. The PR's effective diff is three files, and only one of them is executable: git diff --name-only HEAD^1..HEAD returns the design doc, scripts/setup-worktree.js and its test. package.json and package-lock.json are untouched, so the dependency tree is not part of the change, and no @qwen-code/* workspace package is on the path under test — there are no internal symlinks whose realpath could quietly resolve a "base" cell into head code. The control is therefore the base script bytes (git show HEAD^1:scripts/setup-worktree.js, sha256 819d0eac…) run in the identical fixture, differing from head by nothing but the change under test. Both arms were sha256-verified against the objects they came from before any cell ran.

Fixtures live under /tmp/pr11496-fixture and /tmp/pr11496-cells, outside the repository on purpose: cells 11/12 have no .git and cell 15 relies on git rev-parse walking up, so a fixture nested under this checkout would resolve against qwen-code's own repository and the ownership measurements would be meaningless. A warmed template (package.json + generated pnpm-lock.yaml + the real scripts/pnpm-package.js + a .husky/pre-commit that appends to a trace file) is copied per cell, so each cell's pnpm install --frozen-lockfile --offline is a real ~0.6 s install against a real store rather than a stub. Between the script under test and .husky/_/pre-commit sit only real git, real corepack/pnpm and real husky; the sole stubs are the four kinds the table names (declining-corepack, wrappers-only-husky, refusing-git — which refuses the one config --get core.hooksPath invocation and execs real git for everything else — and gitless). Post-state oracles are always measured with a working toolchain even in cells that stub git or corepack, so a stub never blinds its own oracle.

Harnesses (ab-harness.mjs, git-semantics.mjs, revparse-cwd.mjs, mutants.mjs, parse-mutants.mjs, assert-matrix.mjs, zero-collateral.mjs, gates.mjs, style-gates.mjs, make-assertions.mjs, plus the printer behind each evidence image) and raw per-cell logs (cell-logs/, mutants/, build-at-head.log, typecheck-at-head.log, test-scripts-full.log) are in this directory; ab-results.json, ab-results-2.json, mutants/matrix.json, mutants/summary.json, gates-results.json, style-gates-results.json, git-semantics.json and assertions.json carry every number quoted above, and assertions.json is assembled from the other result files by make-assertions.mjs rather than typed. Mutants and candidate fixes were written into the real working tree and restored byte-identical after each run (sha256 d45c4975… verified before and after every such run, and git status --porcelain empty at the end of each gate script). Evidence images were produced with the repository's own scripts/verify-capture.mjs; no capture pipeline was reconstructed.

Flakiness gate log

rounds=5 files=1 skipped=0
file scripts/tests/package-scripts.test.js: (cd .) npx --no-install vitest run --config ./scripts/tests/vitest.config.ts ./scripts/tests/package-scripts.test.js


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  scripts/tests/package-scripts.test.js: PPPPP

verdict: pass
summary: 1 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · scripts/tests/package-scripts.test.js: P (exit 0)
round 2 · scripts/tests/package-scripts.test.js: P (exit 0)
round 3 · scripts/tests/package-scripts.test.js: P (exit 0)
round 4 · scripts/tests/package-scripts.test.js: P (exit 0)
round 5 · scripts/tests/package-scripts.test.js: P (exit 0)

Evidence images

01-ab-cells-hook-fires-on-head-not-base

02-revparse-ownership-string-comparison

03-mutation-matrix-killed-vs-survived

04-m16-kill-is-a-crash-not-an-assertion

05-f1-guard-blind-spot-bystander-dies

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking findings.
Approval blockers: none.

Triage: Standard — contained fix with comprehensive test coverage.

What I checked:

  1. Husky skip conditions — correctly identifies when NOT to run husky:

    • HUSKY=0 env set → skip (respects opt-out)
    • Custom hooks path set and differs from .husky/_ → skip (preserves existing config)
    • No repository (ownership === 'none') → skip (no config to write)
    • Linked worktree + unset core.hooksPath → skip (would repoint shared config for all worktrees)
  2. Config ownership probe (repositoryConfigOwnership) — correctly distinguishes:

    • 'owns': --git-dir === --git-common-dir (primary checkout or --separate-git-dir clone) → husky is safe
    • 'linked': linked worktree where config is shared → husky only safe if core.hooksPath already set
    • 'none': no repository → skip
  3. Fail-closed verification — after husky exec, checks BOTH core.hooksPath === '.husky/_' AND .husky/_/pre-commit exists. Husky exits 0 on soft failures (.git can't be found, refused git config write), so both proofs are required.

  4. envValue() helper — correctly extracts case-insensitive env reads on Windows, factored out from pathValue().

  5. Test coverage — comprehensive:

    • Normal flow: install + husky exec
    • HUSKY=0 opt-out: only install
    • Custom hooks path: only install
    • Unset HUSKY env: still reaches husky (not treated as disabled)
    • Primary checkout: hooks install
    • Linked worktree with unset core.hooksPath: skipped
    • --separate-git-dir clone: hooks install (owns its own config)
    • No repository: skipped
    • Husky configures path but writes no wrappers: fail-closed
    • Husky exits 0 writing nothing: fail-closed
    • Husky fails with non-zero exit: propagates exit code
    • Git refuses to read config (exit 128): surfaces error
  6. Cross-check: qwen-code-ci-bot approved. The Critical from previous round is fixed and pinned by a test that fails without it.

Unreviewed dimensions: Windows/Linux behavior (covered by existing worktree smoke workflow per PR description).

Reviewed with AI assistance.

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical-only scan at b7882567 — both historical Criticals verified fixed in the current code; no new blocking defect. Approving.

Read in full: scripts/setup-worktree.js (183 lines at this head), the ownership and hook-install decision path, and the changed fixtures in scripts/tests/package-scripts.test.js. The design-doc hunk is documentation and carries no runtime surface.

Historical blocking issues — both confirmed fixed, by reading this head rather than the replies

Two [Critical] findings were filed against earlier commits, the second one fix-induced by the first one's remedy. Both are certifies-falsely + regression. I verified each against the code as it stands now.

Round-1 Critical (husky invoked from a linked worktree rewrites the config every sibling worktree shares) — fixed and still fixed. The skip is intact at setup-worktree.js:119-131: when core.hooksPath is unset and the checkout does not own the repository config, husky is never invoked and the script exits with the install result, so the shared config is not written.

Round-2 Critical (ownsRepositoryConfig() inferred ownership from the filesystem shape of .git, misclassifying both ends) — fixed. The .git-shape proxy is gone entirely: neither statSync nor throwIfNoEntry appears anywhere in the file now. Ownership comes from git, at setup-worktree.js:93-102:

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';

That is the discriminator the finding prescribed, and it closes each of the three states it named:

  • No .git at allrev-parse fails → 'none' → the skip branch at :120-121 fires and prints "git could not resolve a repository for this checkout; skipping Husky because there is no repository config for it to write", then exitWithResult(result) on the successful install. The regression — a source tarball or zip checkout turning a clean dependency install into exit 1 blaming Husky — cannot occur, because husky is never reached in that state.
  • .git as a file where git-dir == git-common-dir (a --separate-git-dir clone, a submodule) → 'owns' → husky runs and hooks are installed. The silent skip that left every commit in such a checkout bypassing .husky/pre-commit is gone.
  • A genuine linked worktree → the two paths differ → 'linked' → skipped with the config left alone, which is what keeps the round-1 blocker fixed.

The notice text no longer asserts "linked worktree" as the explanation for a .git file; each branch names its own state, as the finding required.

The nested-repository false success is also closed, by a different mechanism than the finding proposed but a sufficient one. A checkout nested in a repository that already carries core.hooksPath=.husky/_ does not match the skip condition, so husky runs and is then held to two proofs at :139-147 — the config value must read .husky/_ and .husky/_/pre-commit must exist on disk — otherwise the script exits 1. Since husky exits 0 on soft failures, requiring both is what stops a run from reporting success while having created nothing.

The two facts the finding said a fix must not violate still hold. expect(hookless.status).toBe(1) and expect(hookless.stderr).toContain('Husky did not install hooks') are present at package-scripts.test.js:509-510, alongside the configOnly assertion at :504.

The fixture constraint was honoured, which is the part most likely to have been missed. The finding warned that .git stand-ins are valid only while the script uses statSync, and that mkdirSync(checkout/'.git') or a hand-written gitdir: file resolves to nothing under rev-parse. No such stand-in remains — a search for mkdirSync on a .git path and for a literal gitdir: returns nothing in the test file. The fixtures now build real layouts with real git: git init --quiet (:386), git worktree add --quiet --detach (:393) and a --separate-git-dir clone (:400), which is exactly the P/L/S set the finding's witness enumerated, and :476 asserts the --separate-git-dir shape installs hooks rather than skipping.

Independent scan of the current code — no Critical

  • getHooksPath() (:69-85) maps exit 0 to the value, exit 1 or a spawn error to undefined, and fails closed with a diagnostic on any other status. Treating "git unavailable" the same as "key absent" is what prevents a missing git from becoming a spurious exit 1, and the ownership probe independently reports that shape as 'none'. The remaining fail-closed arm needs an already-unreadable git config to trigger, a state in which the hooks decision genuinely should not be guessed.
  • The decision order in install() is correct: the HUSKY=0 opt-out and an explicit non-husky core.hooksPath both exit with the install result before ownership is consulted, so a pre-existing hook configuration is never overridden.
  • The registry-retry driver at :172-183 is unchanged in substance and only reached on a failed install, since install() exits on every success path.
  • Nothing here writes to a shared git config, touches secrets, or can lose work. spawnSync('git', …) without a shell is fine on Windows, where runPnpm already sets shell: process.platform === 'win32'.

CI

Green at this head with nothing attributable to the diff: Test (ubuntu-latest, Node 22.x), Lint & Static, Integration Tests (no-AK, No Sandbox), web-shell E2E Smoke, both Desktop Shell jobs and all three Install jobs (ubuntu, macOS, windows) pass — the Install legs matter here because a case in this suite smoke-tests the real worktree bootstrap on every supported host. Only review-pr is still in progress, which is not a gate on this review. A fresh sandboxed verification was running at publish time; I did not wait on it.

@yiliang114
yiliang114 added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 585589a Sep 10, 2026
62 of 63 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.23.3.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants