From b303bf271c65ce067e74c9b39fa8eae84d09518e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 15:33:27 +0000 Subject: [PATCH] =?UTF-8?q?fix(tooling):=20merge=20=E9=A9=B1=E5=8A=A8?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E7=BB=91=E5=AE=9A=E5=88=B0=E3=80=8C=E4=B8=8A?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E8=A3=85=E8=BF=87=E4=BE=9D=E8=B5=96=E7=9A=84?= =?UTF-8?q?=20worktree=E3=80=8D,=E5=B9=B6=E8=A1=A5=E4=B8=8A=E6=82=AC?= =?UTF-8?q?=E7=A9=BA=E5=88=A4=E7=BA=A2=E7=9A=84=E6=96=AD=E8=A8=80=20(#4868?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `setup-git-hooks.mjs` 把绝对路径 `${REPO_ROOT}/scripts/git-merge-regen.mjs` 写进 `.git/config`。linked worktree 共用同一份 config,于是每次 `pnpm install` 都把全容器 的驱动改指向刚装完的那个 worktree;而 AGENTS.md 要求收尾时 `git worktree remove` —— 遵守这条纪律恰恰就是触发缺陷的动作。该 worktree 一删,所有 agent 凡碰到 `merge=os-regen` 映射文件的 merge 全部 MODULE_NOT_FOUND。路径已漂过四个 worktree。 改为 `node "$(git rev-parse --show-toplevel)/scripts/git-merge-regen.mjs" %O %A %B %P`: git 把 merge 驱动交给 shell 执行,命令替换在每次调用时、在正在被合并的那个工作树里 求值 —— 既不绑定任何具体 worktree,又仍能解析到当前工作树根(绝对路径当初正是为了 后者)。既有 clone 下次 `pnpm install` 自愈。 自检此前照不出这个缺陷:每条既有 `--self-test` 检查都自建临时仓库、注册自己的驱动, 所以真实 config 悬空时它们全绿。新增 `registeredDriverResolves()` 读**实时** config, 在脚本不存在、指向本工作树以外(缺陷咬人前一步)、或与注册值漂移时判红。注册方与 校验方现在共读 `regen-artifacts.mjs` 的同一份 `GIT_SETTINGS` 声明。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ --- .../merge-driver-worktree-independent.md | 28 ++++ scripts/git-merge-regen.mjs | 139 +++++++++++++++++- scripts/regen-artifacts.mjs | 27 ++++ scripts/setup-git-hooks.mjs | 42 ++++-- 4 files changed, 221 insertions(+), 15 deletions(-) create mode 100644 .changeset/merge-driver-worktree-independent.md diff --git a/.changeset/merge-driver-worktree-independent.md b/.changeset/merge-driver-worktree-independent.md new file mode 100644 index 0000000000..a39bf70ba3 --- /dev/null +++ b/.changeset/merge-driver-worktree-independent.md @@ -0,0 +1,28 @@ +--- +--- + +Tooling-only: `merge.os-regen.driver` is registered as a worktree-independent +command, and `check:merge-driver` now asserts the registered driver actually +resolves (#4868). Releases nothing. + +`setup-git-hooks.mjs` baked an absolute `${REPO_ROOT}/scripts/git-merge-regen.mjs` +into `.git/config`. Linked worktrees SHARE one `.git/config`, so every +`pnpm install` re-pointed the container-wide driver at whichever worktree had just +installed — and the moment that worktree was removed, which AGENTS.md *requires* +on task cleanup, every merge touching a `merge=os-regen` path in every other +worktree died with `MODULE_NOT_FOUND`. Following the cleanup rule is what +triggered the breakage, which is why it recurred across four worktrees. + +The value is now `node "$(git rev-parse --show-toplevel)/scripts/git-merge-regen.mjs" %O %A %B %P`. +Git hands a merge driver to a shell, so the substitution runs per invocation +inside the worktree being merged: it binds to no worktree yet still resolves to +the right root — the property the absolute path was there to guarantee. Existing +clones self-heal on the next `pnpm install`. + +The gate could not see any of this, because it never looked: every existing +`--self-test` check builds its own temp repo and registers its own driver, so all +of them stayed green while the live config dangled. A new `registeredDriverResolves()` +check reads the *live* config and fails when the script does not exist, when it +points outside the current worktree (the same bug one step before it bites), or +when the value has drifted from what the registrar writes. The registrar and the +gate now read one declaration, `GIT_SETTINGS` in `regen-artifacts.mjs`. diff --git a/scripts/git-merge-regen.mjs b/scripts/git-merge-regen.mjs index 11e6d03b19..2b456a62bd 100755 --- a/scripts/git-merge-regen.mjs +++ b/scripts/git-merge-regen.mjs @@ -55,12 +55,19 @@ */ import { execFileSync } from 'node:child_process'; -import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; +import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { NOT_DRIVER_MANAGED, PENDING_MARKER, REGEN_ARTIFACTS, entryForPath } from './regen-artifacts.mjs'; +import { + DRIVER_NAME, + GIT_SETTINGS, + NOT_DRIVER_MANAGED, + PENDING_MARKER, + REGEN_ARTIFACTS, + entryForPath, +} from './regen-artifacts.mjs'; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -193,6 +200,117 @@ function hookIsExecutable() { } } +/** + * The driver registered in THIS clone must resolve — here, now (#4868). + * + * Every other check in this self-test builds a throwaway repo and registers its own + * driver into it, so all of them stayed green for weeks while the real + * `merge.os-regen.driver` in the shared `.git/config` pointed at a DELETED worktree + * and every real merge of a `merge=os-regen` path died with MODULE_NOT_FOUND. The + * self-test and the live merge path were simply not the same path. This check reads + * the live one, which is the only reason it can catch that class of failure. + * + * It fails in three distinguishable ways, all of which have happened or are one + * `pnpm install` away: + * - the script the value names does not exist (the dangling-worktree bug); + * - it exists but lives outside this worktree (bound to someone else's worktree — + * green for whoever installed last, broken for everyone else, so this is the + * check that catches the bug *before* the other worktree is removed); + * - the value has drifted from what `setup-git-hooks.mjs` registers. + */ +function registeredDriverResolves() { + const { key, value: expected } = GIT_SETTINGS.find((s) => s.key === `merge.${DRIVER_NAME}.driver`); + + let actual = ''; + try { + actual = execFileSync('git', ['config', '--get', key], { + cwd: REPO_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + actual = ''; // unset — `git config --get` exits 1 + } + + if (!actual) { + // A supported state, not a failure: git falls back to a text merge, which is + // exactly the pre-#4675 behaviour. `pnpm install` registers it. + console.log(`✓ ${key} is unregistered — merges text-merge as they did before #4675`); + return true; + } + + const expansion = expandDriverScript(actual); + if (expansion.skip) { + console.log(`✓ ${key} not checked for resolution (${expansion.skip})`); + return true; + } + if (expansion.error) return fail(`could not expand ${key} ("${actual}"): ${expansion.error}`); + + const script = expansion.path; + if (!existsSync(script)) { + return fail(`${key} names a script that does not exist:\n` + + ` ${script}\n` + + ` Registered value: ${actual}\n` + + ' Every merge touching a merge=os-regen path in this clone dies with MODULE_NOT_FOUND,\n' + + ' and git leaves the path CONFLICTED with ours in it and no conflict markers.\n' + + ' Fix: pnpm install (re-registers the driver for this worktree)'); + } + + const root = realpath(REPO_ROOT); + if (relative(root, realpath(script)).startsWith('..')) { + return fail(`${key} points OUTSIDE this worktree:\n` + + ` ${script}\n` + + ` Linked worktrees share one .git/config, so this is bound to another worktree and\n` + + ' breaks for everyone the moment that one is removed.\n' + + ' Fix: pnpm install (re-registers the driver for this worktree)'); + } + + if (actual !== expected) { + return fail(`${key} has drifted from what setup-git-hooks.mjs registers.\n` + + ` registered: ${actual}\n` + + ` expected: ${expected}\n` + + ' Fix: pnpm install'); + } + + console.log(`✓ merge.${DRIVER_NAME}.driver resolves in THIS worktree (${relative(root, realpath(script))})`); + return true; +} + +/** + * Expand the driver value's script path the way git will: git hands a merge driver + * command to a shell, so `$(git rev-parse --show-toplevel)` is only meaningful once + * a shell has run it, from inside the worktree being merged. + */ +function expandDriverScript(value) { + // Drop the trailing %O %A %B %P placeholders; what remains is `node