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