Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/merge-driver-worktree-independent.md
Original file line number Diff line number Diff line change
@@ -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`.
139 changes: 134 additions & 5 deletions scripts/git-merge-regen.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)), '..');

Expand Down Expand Up @@ -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 <script>`.
const command = value.replace(/(\s+%[A-Za-z])+\s*$/, '');
const expr = /^\s*node\s+(\S.*)$/.exec(command)?.[1];
if (!expr) return { skip: `not a \`node <script>\` command: "${value}"` };
try {
return {
path: execFileSync('sh', ['-c', `printf '%s' ${expr}`], {
cwd: REPO_ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim(),
};
} catch (err) {
// No POSIX shell (some Windows setups). Git could not run the driver either,
// so there is nothing this check could assert that would still be true.
if (err?.code === 'ENOENT') return { skip: 'no POSIX shell available to expand it' };
return { error: err?.stderr?.toString().trim() || err?.message || String(err) };
}
}

/** Best-effort realpath: symlinked checkouts otherwise read as "outside the worktree". */
function realpath(p) {
try {
return realpathSync(p);
} catch {
return p;
}
}

/**
* Prove the driver end to end against real git: a conflicting change on both
* sides of a mapped path must come out resolved, marker-free, and recorded.
Expand All @@ -207,7 +325,12 @@ function endToEnd() {
git('config', 'user.email', 'selftest@objectstack.ai');
git('config', 'user.name', 'self-test');
git('config', 'merge.os-regen.name', 'regenerate instead of text-merging');
git('config', 'merge.os-regen.driver', `node ${join(REPO_ROOT, 'scripts/git-merge-regen.mjs')} %O %A %B %P`);
// Absolute on purpose, and NOT the value we register in a real clone: this temp
// repo is not the ObjectStack worktree, so the registered
// `$(git rev-parse --show-toplevel)` would resolve to `dir` — which has no
// scripts/. Here we want the driver under test, i.e. this clone's copy.
// Checking the value real clones get is `registeredDriverResolves()`'s job (#4868).
git('config', 'merge.os-regen.driver', `node "${join(REPO_ROOT, 'scripts/git-merge-regen.mjs')}" %O %A %B %P`);

const target = REGEN_ARTIFACTS[0].path;
mkdirSync(join(dir, dirname(target)), { recursive: true });
Expand Down Expand Up @@ -245,7 +368,13 @@ function endToEnd() {

if (process.argv.includes('--self-test')) {
console.log('git-merge-regen --self-test\n');
const results = [reconcileAttributes(), reconcileScripts(), hookIsExecutable(), endToEnd()];
const results = [
reconcileAttributes(),
reconcileScripts(),
hookIsExecutable(),
registeredDriverResolves(),
endToEnd(),
];
console.log(
results.every(Boolean)
? `\n✓ merge driver wiring is consistent (${NOT_DRIVER_MANAGED.length} path(s) deliberately excluded).`
Expand Down
27 changes: 27 additions & 0 deletions scripts/regen-artifacts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,33 @@ export const PENDING_MARKER = 'os-regen-pending';
/** The git config key pair that registers the driver in a clone. */
export const DRIVER_NAME = 'os-regen';

/**
* The script git runs as the merge driver, as a shell word (#4868).
*
* Resolved at MERGE time against the worktree being merged, never at install time
* against the worktree that happened to run `pnpm install`. Linked worktrees share
* one `.git/config`, so an absolute path here is a container-wide setting written
* by whoever installed last — and it dangles the moment that worktree is removed,
* which AGENTS.md requires on task cleanup. See `setup-git-hooks.mjs` for the two
* constraints this spelling satisfies and the two traps it avoids.
*/
export const DRIVER_SCRIPT_EXPR = '"$(git rev-parse --show-toplevel)/scripts/git-merge-regen.mjs"';

/**
* Every git config setting that `pnpm install` registers, declared once.
*
* `setup-git-hooks.mjs` writes these; `git-merge-regen.mjs --self-test` asserts the
* live config still matches them and that the driver script actually resolves. One
* declaration, so the registrar and the gate cannot drift apart.
*/
export const GIT_SETTINGS = Object.freeze([
{ key: `merge.${DRIVER_NAME}.name`, value: 'regenerate generator-owned artifacts instead of text-merging' },
// %O %A %B %P — ancestor, ours (the output file), theirs, pathname. Unquoted on
// purpose: git generates %O %A %B as temp names and shell-quotes %P itself.
{ key: `merge.${DRIVER_NAME}.driver`, value: `node ${DRIVER_SCRIPT_EXPR} %O %A %B %P` },
{ key: 'core.hooksPath', value: '.githooks' },
]);

/** Resolve the entry that owns a path, or undefined. Handles the one `**` entry. */
export function entryForPath(p) {
return REGEN_ARTIFACTS.find((e) =>
Expand Down
42 changes: 32 additions & 10 deletions scripts/setup-git-hooks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,43 @@
*/

import { execFileSync } from 'node:child_process';
import { dirname, join, resolve } from 'node:path';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

import { DRIVER_NAME } from './regen-artifacts.mjs';
import { GIT_SETTINGS } from './regen-artifacts.mjs';

const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');

const SETTINGS = [
{ key: `merge.${DRIVER_NAME}.name`, value: 'regenerate generator-owned artifacts instead of text-merging' },
// %O %A %B %P — ancestor, ours (the output file), theirs, pathname. `node` and
// a repo-relative path keep this working on Windows and in linked worktrees,
// where a bare `./scripts/...` would resolve against the wrong root.
{ key: `merge.${DRIVER_NAME}.driver`, value: `node "${join(REPO_ROOT, 'scripts/git-merge-regen.mjs')}" %O %A %B %P` },
{ key: 'core.hooksPath', value: '.githooks' },
];
// What gets registered lives in `regen-artifacts.mjs` — one declaration, read both
// by this registrar and by the `--self-test` gate that verifies it (#4868).
//
// The driver value is deliberately NOT an absolute path any more. It must satisfy
// two constraints at once, and the obvious spellings each satisfy only one:
//
// - It must not bind to one specific worktree. Baking an absolute
// `${REPO_ROOT}/scripts/...` in here did exactly that: linked worktrees SHARE
// one `.git/config`, so every `pnpm install` re-pointed the container-wide
// driver at the installing worktree, and the moment that worktree was removed
// — which AGENTS.md *requires* on task cleanup — every merge of a
// `merge=os-regen` path in every other worktree died with MODULE_NOT_FOUND.
// Observed drifting across four worktrees before anyone noticed.
// - It must still resolve to the right root. A bare `./scripts/...` relies on
// git's (undocumented) choice of cwd for merge drivers, which is what the
// absolute path was originally there to avoid.
//
// `$(git rev-parse --show-toplevel)` satisfies both: git runs merge drivers
// through a shell, so the substitution happens per invocation, inside the worktree
// being merged. Verified in git 2.43 from a linked worktree, invoked from both the
// worktree root and a subdirectory.
//
// Two traps, both verified empirically rather than assumed:
// - NO leading `!`. That prefix is alias/credential-helper syntax; a merge driver
// value is already handed to the shell verbatim, so `!node ...` runs a program
// literally named `!node` — "not found", and git falls back to a text merge.
// - The placeholders stay UNQUOTED. git substitutes %O %A %B as generated temp
// names and already shell-quotes %P itself; wrapping them in quotes of our own
// hands the driver a pathname with literal quote characters in it.
const SETTINGS = GIT_SETTINGS;

function git(args, opts = {}) {
return execFileSync('git', args, { cwd: REPO_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });
Expand Down
Loading