Skip to content

Commit af29b66

Browse files
claude[bot]claude
andauthored
fix(devx): a merge-tree probe no longer records an os-regen deferral, and name the sound probe spelling (#15868)
`git merge-tree --write-tree` runs merge-ort, so it invokes the `os-regen` merge driver — which recorded `$GIT_DIR/os-regen-pending` for a merge that wrote nothing. The next ordinary commit in that checkout was then refused by `pre-commit` with a staleness report for a merge that never happened. Gate the marker write on the index lock. Measured across ten invocation shapes: `GITHEAD_*`/`GIT_REFLOG_ACTION` — the obvious signals — are absent for cherry-pick and rebase, which are real merges that do owe the marker; the index lock is held by all eight real-merge shapes and by neither merge-tree mode. Pinned in `--self-test` in both directions, with a firing control so "no marker" cannot pass by never reaching the driver. `scripts/pm/os-regen-merge.sh`'s header gains the corollary and the sound probe spelling. The `-c merge.os-regen.driver=` spelling is documented as REFUSED: the empty string does not disable the driver, so it reports a conflict for every routed path, including ones that text-merge cleanly. Part of #15815 Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk Co-authored-by: Claude <noreply@anthropic.com>
1 parent b398ad2 commit af29b66

2 files changed

Lines changed: 211 additions & 9 deletions

File tree

scripts/git-merge-regen.mjs

Lines changed: 163 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
* node scripts/git-merge-regen.mjs --self-test # reconcile + end-to-end merge proof
5555
*/
5656

57-
import { execFileSync } from 'node:child_process';
57+
import { execFileSync, spawnSync } from 'node:child_process';
5858
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
5959
import { tmpdir } from 'node:os';
6060
import { dirname, join, relative, resolve } from 'node:path';
@@ -163,8 +163,76 @@ function gitDir(cwd = process.cwd()) {
163163
return execFileSync('git', ['rev-parse', '--absolute-git-dir'], { cwd, encoding: 'utf8' }).trim();
164164
}
165165

166-
/** Record `path` as needing regeneration. Idempotent — a path is listed once. */
166+
/**
167+
* Is this invocation a `git merge-tree` PROBE rather than a merge that is writing
168+
* this worktree? (#15815)
169+
*
170+
* `git merge-tree --write-tree` runs the same merge-ort machinery as `git merge`,
171+
* so it invokes this driver too — and the deferral's marker then records a
172+
* regeneration nobody owes, because a probe writes neither the index nor the
173+
* worktree. Measured cost: a seat probing mergeability from the shared primary
174+
* checkout left `os-regen-pending` behind, and the next ORDINARY commit in that
175+
* checkout was refused by `pre-commit` with a staleness report for a merge that
176+
* never happened. Self-healing (the artifact checks clean, because nothing was
177+
* merged) but it reads as a real red to whoever hits it.
178+
*
179+
* ⚠️ The signal is the INDEX LOCK, and the two candidates a reader reaches for
180+
* first are both WRONG. MEASURED (git 2.43.0; one path routed to this driver and
181+
* conflicting on both sides; the driver replaced by a shim that dumped argv, env
182+
* and git state; every invocation shape rebuilt from a fresh fixture):
183+
*
184+
* shape GITHEAD_* GIT_REFLOG_ACTION $(git-path index).lock
185+
* merge-tree 0 <unset> no
186+
* merge-tree (old mode) 0 <unset> no
187+
* merge 1 merge other YES
188+
* merge --squash 1 merge other YES
189+
* merge --no-commit · · YES
190+
* pull 1 pull … other YES
191+
* cherry-pick 0 <unset> YES
192+
* rebase 0 <unset> YES
193+
* am -3 · · YES
194+
* checkout -m · · YES
195+
*
196+
* ⛔ `GITHEAD_*` and `GIT_REFLOG_ACTION` are absent for CHERRY-PICK and REBASE —
197+
* real merges that DO write this worktree and DO owe the marker. Gating on either
198+
* would drop the marker there: silent, and in the dangerous direction.
199+
*
200+
* The lock is not an accident of this git version, it is the mechanism: `git
201+
* merge-tree` is the low-level command that does not touch the index or the
202+
* working tree, and every merge that DOES touch them holds the index lock across
203+
* the driver call. Read through `git rev-parse --git-path index`, never
204+
* `$GIT_DIR/index` — a linked worktree has its own index (verified from one:
205+
* probe `no`, real merge `YES`), and `GIT_INDEX_FILE` moves it again.
206+
*
207+
* ⚠️ The two failure directions are NOT symmetric, which is why anything
208+
* unmeasurable falls back to MARKING. Reading "real merge" when it was a probe
209+
* costs one stale marker that clears the moment the artifact checks clean — i.e.
210+
* today's behaviour. Reading "probe" when it was a real merge drops the
211+
* deferral's own record and leaves `pre-commit` nothing to refuse. Even then it
212+
* is not silent — the `check:*` gates over every generated artifact run on every
213+
* PR and do not depend on this file — but it moves a red from the seat's terminal
214+
* to CI, so the default has to be the other way.
215+
*/
216+
function isProbeInvocation(cwd = process.cwd()) {
217+
try {
218+
const index = resolve(
219+
cwd,
220+
execFileSync('git', ['rev-parse', '--git-path', 'index'], { cwd, encoding: 'utf8' }).trim(),
221+
);
222+
return !existsSync(`${index}.lock`);
223+
} catch {
224+
// No answer is not a probe. See the asymmetry above.
225+
return false;
226+
}
227+
}
228+
229+
/**
230+
* Record `path` as needing regeneration. Idempotent — a path is listed once.
231+
*
232+
* A `merge-tree` probe records NOTHING: it wrote no tree anyone has to regenerate.
233+
*/
167234
function markPending(path, cwd = process.cwd()) {
235+
if (isProbeInvocation(cwd)) return null;
168236
const marker = join(gitDir(cwd), PENDING_MARKER);
169237
const existing = existsSync(marker) ? readFileSync(marker, 'utf8').split('\n').filter(Boolean) : [];
170238
if (existing.includes(path)) return marker;
@@ -344,7 +412,7 @@ function drive(argv) {
344412
// ── Why the CALLEE NAME is the battery ──
345413
//
346414
// This file has no `selfTest()` entry function and no named section banners:
347-
// the `--self-test` dispatch at the bottom invokes THIRTEEN named callees, each
415+
// the `--self-test` dispatch at the bottom invokes FOURTEEN named callees, each
348416
// printing its own line and returning a boolean. So the roster's unit is the
349417
// CALLEE, and its label is the one the SOURCE ALREADY CARRIES — the function's
350418
// own name. Nothing is invented and nothing is judged per comment, and a set
@@ -359,7 +427,7 @@ function drive(argv) {
359427
// (PR #15271, `check-sdui-manifest`) makes a table row a battery. It does so
360428
// for a file whose SELF-TEST *is* the table: one literal table, one driving
361429
// loop over it, and a sink that writes only when a row fails. Here the table is
362-
// a local of ONE callee among thirteen, its rows are evaluated eagerly into
430+
// a local of ONE callee among fourteen, its rows are evaluated eagerly into
363431
// booleans before anything loops, and the callee already reduces them to a
364432
// single printed verdict of its own. Flooring those rows would floor one
365433
// callee's internals while the other twelve stayed at callee granularity — a
@@ -386,6 +454,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
386454
reconcileMixedComparators: 1,
387455
endToEnd: 1,
388456
endToEndMixed: 1,
457+
probeLeavesNoMarker: 1,
389458
});
390459

391460
// DELETING an entry silences that battery's floor exactly as effectively as
@@ -394,7 +463,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
394463
// key in the literal above, so the roster falls below this number; the
395464
// roster ↔ dispatch cross-check in the floor block is the other half, and it
396465
// names WHICH callee was listed twice.
397-
const SELF_TEST_BATTERY_FLOOR = 13;
466+
const SELF_TEST_BATTERY_FLOOR = 14;
398467

399468
// The key a registration is filed under when a callee registers no name at all.
400469
// It is not a declared battery, so it reds by the same set difference rather
@@ -404,7 +473,7 @@ const UNATTRIBUTED_BATTERY = '(no callee named)';
404473
// The battery ledger, read by `batteryFloorFailures()` from the dispatch block
405474
// at the very bottom of this file. It is MODULE-level rather than local to a
406475
// self-test body because this file HAS no self-test body: the registrations
407-
// happen inside thirteen separate callees and the floor is read at the dispatch's
476+
// happen inside fourteen separate callees and the floor is read at the dispatch's
408477
// verdict site, so the ledger has to outlive every one of those frames.
409478
//
410479
// ⚠️ Named for the roster's role, deliberately NOT with a self-test spelling:
@@ -416,7 +485,7 @@ const batterySeen = new Map();
416485
/**
417486
* Record that a self-test callee RAN.
418487
*
419-
* Called as the FIRST statement of each of the thirteen callees the `--self-test`
488+
* Called as the FIRST statement of each of the fourteen callees the `--self-test`
420489
* dispatch invokes — above any early return, so a callee that bails out early
421490
* still reports that it ran, and the floor is never met by a frame that
422491
* returned before doing anything.
@@ -429,7 +498,7 @@ function registerCase(name) {
429498
/**
430499
* The floor: every declared callee RAN (#13489).
431500
*
432-
* Evaluated at the dispatch's verdict site — after all thirteen callees have had
501+
* Evaluated at the dispatch's verdict site — after all fourteen callees have had
433502
* their chance and immediately before the success line — and reached only from
434503
* the `--self-test` branch, so a production merge-driver run never reads the
435504
* ledger at all.
@@ -1403,9 +1472,93 @@ function endToEndMixed() {
14031472
return true;
14041473
}
14051474

1475+
/**
1476+
* The probe gate, BOTH directions, in ONE fixture repo (#15815).
1477+
*
1478+
* Leg 1 is the fix: `git merge-tree --write-tree` reaches this driver and must
1479+
* leave no pending marker, because it wrote no tree anyone owes a regeneration
1480+
* for. Leg 2 is the control WITHOUT WHICH LEG 1 IS FREE — `markPending` replaced
1481+
* by `return null` passes leg 1 perfectly, and takes the deferral's whole
1482+
* enforcement with it. The same repo's real merge must still record.
1483+
*
1484+
* ⚠️ Leg 1 also carries its own firing control, and it is the one that matters
1485+
* most: "no marker" is exactly what a probe that NEVER REACHED THE DRIVER also
1486+
* produces (a broken fixture, a `.gitattributes` that stopped routing, a git
1487+
* whose merge-tree skips custom drivers — which is the very thing this whole
1488+
* card would be moot under). So the driver's own deferral notice must appear on
1489+
* the probe's stderr before the absent marker is allowed to mean anything.
1490+
*/
1491+
function probeLeavesNoMarker() {
1492+
registerCase('probeLeavesNoMarker');
1493+
const dir = mkdtempSync(join(tmpdir(), 'os-regen-probe-'));
1494+
const git = (...args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
1495+
try {
1496+
git('init', '-q', '--initial-branch=main', '.');
1497+
git('config', 'user.email', 'selftest@objectstack.ai');
1498+
git('config', 'user.name', 'self-test');
1499+
git('config', 'merge.os-regen.name', 'regenerate instead of text-merging');
1500+
// Absolute, and the driver under test — same reasoning as `endToEnd()`.
1501+
git('config', 'merge.os-regen.driver', `node "${join(REPO_ROOT, 'scripts/git-merge-regen.mjs')}" %O %A %B %P`);
1502+
1503+
const target = REGEN_ARTIFACTS[0].path;
1504+
mkdirSync(join(dir, dirname(target)), { recursive: true });
1505+
writeFileSync(join(dir, '.gitattributes'), `${target} merge=os-regen\n`);
1506+
writeFileSync(join(dir, target), '["base"]\n');
1507+
git('add', '-A');
1508+
git('commit', '-qm', 'base');
1509+
1510+
git('checkout', '-qb', 'incoming');
1511+
writeFileSync(join(dir, target), '["base","theirs"]\n');
1512+
git('commit', '-qam', 'theirs');
1513+
1514+
git('checkout', '-q', 'main');
1515+
writeFileSync(join(dir, target), '["base","ours"]\n');
1516+
git('commit', '-qam', 'ours');
1517+
1518+
const marker = join(gitDir(dir), PENDING_MARKER);
1519+
1520+
// ── Leg 1: the probe ──
1521+
// `spawnSync`, not `execFileSync`: the driver's notice goes to stderr, and it
1522+
// is wanted on the SUCCESS path (merge-tree exits 0 when the driver resolves),
1523+
// where `execFileSync` discards it.
1524+
const probe = spawnSync('git', ['merge-tree', '--write-tree', 'main', 'incoming'], { cwd: dir, encoding: 'utf8' });
1525+
if (!`${probe.stderr ?? ''}`.includes('not text-merged — it is generated')) {
1526+
return fail(
1527+
'self-test: the merge-tree probe never reached the driver, so "no marker" proves nothing.\n'
1528+
+ ` merge-tree exit ${probe.status}, stderr: ${`${probe.stderr ?? ''}`.trim() || '<empty>'}`,
1529+
);
1530+
}
1531+
if (existsSync(marker)) {
1532+
return fail(
1533+
`self-test: a \`git merge-tree\` probe wrote ${PENDING_MARKER} — the probe gate is not firing.\n`
1534+
+ ' A probe writes neither index nor worktree, so it owes no regeneration; the marker it leaves\n'
1535+
+ ' refuses an unrelated commit in that checkout with a staleness report for a merge that never happened.',
1536+
);
1537+
}
1538+
1539+
// ── Leg 2: the control, same repo ──
1540+
git('merge', 'incoming', '-m', 'merge');
1541+
if (!existsSync(marker)) {
1542+
return fail(
1543+
'self-test: a REAL merge left no pending marker — the probe gate is OVER-firing.\n'
1544+
+ ' This is the direction that costs something: the deferral kept one side, and nothing now\n'
1545+
+ ' refuses the commit until the artifact is regenerated.',
1546+
);
1547+
}
1548+
if (!readFileSync(marker, 'utf8').includes(target)) return fail(`self-test: ${target} absent from the pending marker`);
1549+
1550+
console.log('✓ probe gate: `git merge-tree` reaches the driver and records nothing; the same repo\'s real merge still records');
1551+
return true;
1552+
} catch (err) {
1553+
return fail(`self-test: ${err?.stderr?.toString() || err?.message || err}`);
1554+
} finally {
1555+
rmSync(dir, { recursive: true, force: true });
1556+
}
1557+
}
1558+
14061559
if (process.argv.includes('--self-test')) {
14071560
console.log('git-merge-regen --self-test\n');
1408-
// The thirteen callees as a literal LIST rather than thirteen bare calls, so the
1561+
// The fourteen callees as a literal LIST rather than fourteen bare calls, so the
14091562
// names this block invokes are data the floor below can cross-check the
14101563
// roster against, in both directions. The names are read off the function
14111564
// declarations themselves (`fn.name`), so a renamed callee moves this list
@@ -1424,6 +1577,7 @@ if (process.argv.includes('--self-test')) {
14241577
reconcileMixedComparators,
14251578
endToEnd,
14261579
endToEndMixed,
1580+
probeLeavesNoMarker,
14271581
];
14281582
const results = callees.map((run) => run());
14291583

scripts/pm/os-regen-merge.sh

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,54 @@
122122
# os-regen: all deferred artifacts are current - marker cleared
123123
# So step 3 needs no bypass, and step 4 is what clears the marker.
124124
#
125+
# ## ⛔ A local `merge-tree` says NOTHING about GitHub's mergeability (#15815)
126+
#
127+
# The corollary AGENTS.md §11 leaves unstated, and it costs a round trip:
128+
# **a local `merge-tree` of any `merge=os-regen` path, in a clone where the driver
129+
# is registered, is not evidence about whether GitHub can merge the PR.**
130+
# `git merge-tree --write-tree` runs the same merge-ort machinery as `git merge`,
131+
# so it HONOURS the driver — and GitHub runs no custom merge driver at all. The two
132+
# are answering different questions. Already paid for once: a probe read MERGES
133+
# CLEAN where the PR page read `dirty`, and a merge-conflict card was dispatched
134+
# for a contradiction that was really an instrument mismatch.
135+
#
136+
# The sound probe is one where the driver is genuinely ABSENT — a throwaway bare
137+
# clone sharing the object store, which is GitHub's actual condition:
138+
#
139+
# git clone --bare --shared . PROBE.git
140+
# git --git-dir=PROBE.git merge-tree --write-tree --name-only <base> <head>
141+
# rm -rf PROBE.git # exit 1 + the paths = really conflicted
142+
#
143+
# ⛔ NOT `git -c merge.os-regen.driver= merge-tree --write-tree <base> <head>`. The
144+
# empty string does not DISABLE the driver — git still tries to RUN it, fails, and
145+
# marks the path conflicted, so that spelling reports a conflict for EVERY routed
146+
# path including ones whose text merges perfectly. MEASURED (git 2.43.0, two pairs
147+
# over packages/spec/spec-changes.json, ground truth = `git merge-file` on the
148+
# three blobs, which is what a driver-less server-side merge runs):
149+
#
150+
# pair truth driver ON `-c …driver=` bare shared clone
151+
# same line, both sides exit 1 exit 0 ✗ exit 1 ✓ exit 1 ✓
152+
# 1996 lines apart exit 0 exit 0 ✓ exit 1 ✗ exit 0 ✓
153+
#
154+
# The middle column is the trap this section is about; the third is a false
155+
# POSITIVE instrument that agrees with the truth only by coincidence, printing
156+
# `error: cannot run : No such file or directory` while it does. Only the last
157+
# column tracks the truth in both rows.
158+
#
159+
# ⚠️ Which way the driver errs is input-dependent, so the trap is intermittent: on
160+
# a MIXED row it defers (exit 0) only when the incoming side carries nothing but
161+
# the generated half, and text-merges deliberately when it carries prose. "Is
162+
# `merge-tree` lying here" therefore has no fixed answer, and a probe that agreed
163+
# with GitHub once proves nothing about the next one. Use the driver-free
164+
# instrument always rather than reasoning about when the driver is trustworthy.
165+
#
166+
# A probe with the driver ON also used to leave `$GIT_DIR/os-regen-pending`
167+
# behind, and the next ORDINARY commit in that checkout was refused by
168+
# `pre-commit` for a merge that never happened. `git-merge-regen.mjs` now declines
169+
# to record a marker when no index lock is held (i.e. under `merge-tree`); the
170+
# measurement, and why the two obvious env-var signals are wrong, are in that
171+
# file's `isProbeInvocation()`. The driver-free probe above never had the problem.
172+
#
125173
# The os-regen path list is read from .gitattributes AT RUN TIME — the one copy
126174
# that cannot rot is the one that does not exist.
127175

0 commit comments

Comments
 (0)