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' ;
5858import { appendFileSync , existsSync , mkdirSync , mkdtempSync , readFileSync , realpathSync , rmSync , writeFileSync } from 'node:fs' ;
5959import { tmpdir } from 'node:os' ;
6060import { 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+ */
167234function 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+
14061559if ( 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
0 commit comments