Skip to content

Commit 689d606

Browse files
claude[bot]claude
andauthored
test(merge-driver): pin the os-regen deferral refusal on the linked-worktree path, and make the self-test exit non-zero when it fails (#18165)
Fixes #17602 ## PREMISE P was measured first, and it HOLDS — so the driver is not the defect The card asks whether a zero exit from `merge=os-regen` can be trusted. The `domain:devx` seat's mechanism reading (comment `5631161285`) narrowed that to one falsifiable premise: is `content/docs/references/**` really "generated whole", as `.gitattributes:84` declares? If yes, the unconditional deferral is the design and `markPending` + regeneration closes the loop; if no, the row is MIXED without a `mixed` field and the deferral is a silent deletion — and the defect would be in the declaration. Measured on `origin/main` `394ccf549`, with the post-driver state reproduced exactly (the artifact missing main's side while the source still carries it), the generator re-run, and both controls lit: ``` before mutation after mutation after gen:docs main's #groupBy whitespace note 2 0 2 <- P FIRING CONTROL (marker injected into the mdx) 0 1 0 EMPTY CONTROL (a string never written) 0 0 0 blob sha of content/docs/references/ui/view.mdx HEAD 2fa435c mutated 2e40a12d888d9babaa254974a69b3feece62a93a (the mutation landed on disk) regenerated 2fa435c (byte-identical to HEAD) ``` The note is not hand-written: it is a `.describe()` at `packages/spec/src/ui/view.zod.ts:917`, rendered into the page by `gen:docs`. The firing control proves the generator rewrites this file wholesale rather than skipping or appending — the injected marker is gone. Regeneration reproduces the page **byte for byte**. ⇒ `.gitattributes:84` is true for this family. `scripts/git-merge-regen.mjs` behaved exactly as its own comment says it should, and ⛔ nothing in this PR changes the driver's merge behaviour, weakens a gate, adds an exemption, or makes the driver prefer a side in a new case. ## So the work is ask 2 — and the safety net DOES hold, but nothing proved it The remaining question is the one the ruling names: on the path this incident took — a human or agent hand-repairing a merge **in a linked worktree** — does the refusal actually fire? Measured live, in this worktree, with a real merge of a real routed path: ``` git merge exit 0 (the card's zero exit, reproduced) marker written to .git/worktrees/.../os-regen-pending YES the ordinary commit right after REFUSED by .githooks/pre-commit the push REFUSED by .githooks/pre-push ``` The net holds. What is unguarded is that **no test says so.** The driver ends every deferral by *printing* "The pre-commit hook will not let this commit through until you do", and the two cases that come closest each stop one step short in the same direction: - `endToEnd()` performs a real merge, asserts the marker exists, and never commits — so git never runs a hook. - `check-regen-pending.mjs`'s deferred-merge fixture sets `core.hooksPath=/dev/null` ("the fixture drives the script itself") and writes the marker by hand — it grades the checker's verdict, never git's willingness to invoke it, and never the path the driver wrote to. The joint property had no coverage at all, and the one arrangement in which it can break is the one AGENTS.md Prime Directive #11 makes mandatory. `markPending()` in the driver and `gitDirPath()` in the checker are two **independent** `--absolute-git-dir` call sites; in a primary checkout that directory and `--git-common-dir` are the **same directory**, so a regression to the common dir keeps every single-tree fixture green while every real merge in every agent's worktree silently loses its refusal. ## What landed **1. `endToEndWorktreeRefusal()` — a sixteenth self-test battery.** It builds a throwaway repo with a linked worktree, reaches the REAL `.githooks` and the REAL checker through untracked symlinks (so the *relative* `core.hooksPath` resolves per worktree, as `setup-git-hooks.mjs` registers it — a hook this file wrote out would test an imitation), merges a routed path inside the linked worktree, and asserts nine legs: | leg | assertion | |---|---| | 1 | the marker lands in the LINKED worktree's own git dir | | 2 | …and NOT in the shared common git dir — the discrimination only a linked worktree can make | | 3 | the ordinary commit after the merge is REFUSED, by git, through the real `pre-commit` | | 4 | …naming the artifact, so the refusal is this one and not some other failure | | 5,6 | the push is REFUSED too, by the real `pre-push`, via its os-regen leg | | 7,8,9 | **FIRING CONTROL** — with the gate clean the same commit is ACCEPTED, the marker is gone, and the push goes through | Legs 7-9 are what legs 1-6 rest on: a hook that can only fail — a missing interpreter, a typo'd path, node erroring — satisfies every refusal assertion while proving nothing, and from the outside that reads exactly like "the net holds". **2. The self-test's exit code now agrees with its own verdict.** Found by the ablation below, not by reading. The `--self-test` dispatch computed `failures` from the booleans its callees return and printed `✗ merge driver wiring is inconsistent — N failure(s)` from that count — then exited **0**. The only thing that ever set a non-zero code was `fail()`, which every callee happened to call. The two agreed by convention, not by construction, and `pnpm check:merge-driver` chains this file with `&&`: the first callee to report a failure by returning `false` alone would have made the whole battery advisory. The verdict line and the exit code now come from the same count. This is an in-place fix under the bounded exemption — same defect class as the card (a failed check reporting as a pass), mechanical, on a file this PR already declares, in the same gate family, adding no new verification surface. ## Ablation — the battery can fail, and the existing cases cannot catch what it catches `markPending()` regressed to the shared common git dir (the exact regression class the case exists for), run from the committed state, mutation proven on disk before the run and the restore proven byte-identical after: ``` occurrences of the replaced line 1 -> 0 injected marker 0 -> 1 blob sha HEAD 4506a6d -> ebd8f1077e725251161a067d50905c3d8b116c10 self-test exit under the ablation 1 (before fix 2: 0 — see above) ✗ endToEndWorktreeRefusal — all six refusal legs red, firing control red ✓ end-to-end: conflicting packages/spec/spec-changes.json … recorded as pending <- STILL GREEN ✓ end-to-end (mixed): anchors-only deferred to OURS … <- STILL GREEN ✓ probe gate: `git merge-tree` reaches the driver and records nothing <- STILL GREEN restore: blob sha back to 4506a6d…, residual injected text 0, `git diff HEAD` empty ``` Three pre-existing end-to-end cases stay green through a regression that disarms the refusal for every worktree in the container. That asymmetry is the case's whole justification. ## Which of the three unreliable signals this makes trustworthy Triage `5628590957` records three cards describing one apparatus whose every signal is unreliable, and asks which one a change repairs. This PR repairs **the driver's exit code being the only evidence the merge was safe** (#17602): after it, the claim "the hook refuses until regeneration has run" is a test rather than a sentence, on the path real merges take. It also, incidentally, moves **#13799**'s signal for this one member: the `os-regen-merge` self-test (`.github/workflows/lint.yml`) is named in #13799's population, and fix 2 means that battery can no longer report a failure at exit 0. ⛔ That is one member, ⛔ not #13799. It does **not** touch **#16717** — `check:merge-driver` remains red on macOS for the runner-missing classifier's reasons, so a green local run of this battery is not evidence there. The new case is platform-neutral (git, symlinks, `pnpm -s` on a stub), but it inherits #16717's blind spot like every other case in the file. ## Effect on #16612 (shares this file, serialized behind this card) **Neither easier nor harder — but it is now instrumented.** #16612 proposes retiring the only `mixed` row, which would drop `system-context.mdx` onto the unconditional-deferral branch this card measured. Nothing here changes that branch. What changes is that whoever takes #16612 now has a case that proves the deferral's refusal actually fires end to end, so the question "is the safety net behind the deferral real?" is answerable by running `pnpm check:merge-driver` instead of by argument. ⛔ Not fixed in passing; ⛔ not re-priced here. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived **32** commands from the real diff (1 path, `scripts/git-merge-regen.mjs`, vs merge base `394ccf549`). All 32 run and exit 0, reconciled with `--ran`. Exit codes landed to disk per command. `pnpm check:merge-driver` — the gate that owns this battery — is among them. `pnpm lint`'s scan was not narrowed and needs no narrowing argument: the whole set ran at `3d8e4c63e` — `eslint . --no-inline-config --format json` over **6752** files, **0** errors, **0** warnings. (The config declares it never enables type-aware linting for any file, so a one-file diff could not have moved another file's verdict in any case.) changeset: **`skip-changeset`**, measured rather than assumed. The diff is one root `scripts/**` file; the root package is `private: true`; all 70 published packages declare `files[]` and none can reach root `scripts/`; the new symbol occurs in zero published package sources, against a lit positive control (`defineStack` hits in `packages/spec/src`). ## Acceptance notes - `SELF_TEST_BATTERY_FLOOR` moved 15 to 16 and the roster/dispatch gained the new callee, as that mechanism requires. The callee-count prose in the same block moved with it; one number there ("the other thirteen") was already off by one against a roster of fifteen and is now correct at fifteen against sixteen. Mechanical, in the block being edited. - Noted, not filed: `check-regen-pending.mjs`'s deferred-merge fixture still sets `core.hooksPath=/dev/null` and writes its marker by hand. That is deliberate and correct for what it grades (the checker's verdict machine), and the new case covers the half it cannot; a second end-to-end fixture there would duplicate it. Successor: whoever next widens that fixture's scenario set. - Noted, not filed: the driver's `--self-test` prints its verdict and, before this PR, derived its exit code elsewhere. The sibling self-tests chained beside it in `check:merge-driver` (`git-env.mjs`, `check-regen-pending.mjs`) were not audited for the same shape — out of scope here, and neither is known to have it. Successor: none currently queued. --- _Generated by [Claude Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d588fb2 commit 689d606

1 file changed

Lines changed: 236 additions & 9 deletions

File tree

‎scripts/git-merge-regen.mjs‎

Lines changed: 236 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
*/
7979

8080
import { execFileSync, spawnSync } from 'node:child_process';
81-
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
81+
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
8282
import { tmpdir } from 'node:os';
8383
import { dirname, join, relative, resolve } from 'node:path';
8484
import { fileURLToPath } from 'node:url';
@@ -455,7 +455,7 @@ function drive(argv) {
455455
// ── Why the CALLEE NAME is the battery ──
456456
//
457457
// This file has no `selfTest()` entry function and no named section banners:
458-
// the `--self-test` dispatch at the bottom invokes FIFTEEN named callees, each
458+
// the `--self-test` dispatch at the bottom invokes SIXTEEN named callees, each
459459
// printing its own line and returning a boolean. So the roster's unit is the
460460
// CALLEE, and its label is the one the SOURCE ALREADY CARRIES — the function's
461461
// own name. Nothing is invented and nothing is judged per comment, and a set
@@ -470,10 +470,10 @@ function drive(argv) {
470470
// (PR #15271, `check-sdui-manifest`) makes a table row a battery. It does so
471471
// for a file whose SELF-TEST *is* the table: one literal table, one driving
472472
// loop over it, and a sink that writes only when a row fails. Here the table is
473-
// a local of ONE callee among fifteen, its rows are evaluated eagerly into
473+
// a local of ONE callee among sixteen, its rows are evaluated eagerly into
474474
// booleans before anything loops, and the callee already reduces them to a
475475
// single printed verdict of its own. Flooring those rows would floor one
476-
// callee's internals while the other thirteen stayed at callee granularity — a
476+
// callee's internals while the other fifteen stayed at callee granularity — a
477477
// roster whose unit changes per entry. The rule: the battery is the unit the
478478
// DISPATCH names.
479479
//
@@ -497,6 +497,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
497497
reconcileMixedComparators: 1,
498498
endToEnd: 1,
499499
endToEndMixed: 1,
500+
endToEndWorktreeRefusal: 1,
500501
probeLeavesNoMarker: 1,
501502
ambientGitEnvIsolation: 1,
502503
});
@@ -507,7 +508,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
507508
// key in the literal above, so the roster falls below this number; the
508509
// roster ↔ dispatch cross-check in the floor block is the other half, and it
509510
// names WHICH callee was listed twice.
510-
const SELF_TEST_BATTERY_FLOOR = 15;
511+
const SELF_TEST_BATTERY_FLOOR = 16;
511512

512513
// The key a registration is filed under when a callee registers no name at all.
513514
// It is not a declared battery, so it reds by the same set difference rather
@@ -517,7 +518,7 @@ const UNATTRIBUTED_BATTERY = '(no callee named)';
517518
// The battery ledger, read by `batteryFloorFailures()` from the dispatch block
518519
// at the very bottom of this file. It is MODULE-level rather than local to a
519520
// self-test body because this file HAS no self-test body: the registrations
520-
// happen inside fifteen separate callees and the floor is read at the dispatch's
521+
// happen inside sixteen separate callees and the floor is read at the dispatch's
521522
// verdict site, so the ledger has to outlive every one of those frames.
522523
//
523524
// ⚠️ Named for the roster's role, deliberately NOT with a self-test spelling:
@@ -529,7 +530,7 @@ const batterySeen = new Map();
529530
/**
530531
* Record that a self-test callee RAN.
531532
*
532-
* Called as the FIRST statement of each of the fifteen callees the `--self-test`
533+
* Called as the FIRST statement of each of the sixteen callees the `--self-test`
533534
* dispatch invokes — above any early return, so a callee that bails out early
534535
* still reports that it ran, and the floor is never met by a frame that
535536
* returned before doing anything.
@@ -542,7 +543,7 @@ function registerCase(name) {
542543
/**
543544
* The floor: every declared callee RAN (#13489).
544545
*
545-
* Evaluated at the dispatch's verdict site — after all fifteen callees have had
546+
* Evaluated at the dispatch's verdict site — after all sixteen callees have had
546547
* their chance and immediately before the success line — and reached only from
547548
* the `--self-test` branch, so a production merge-driver run never reads the
548549
* ledger at all.
@@ -1624,6 +1625,224 @@ function probeLeavesNoMarker() {
16241625
*/
16251626
const AMBIENT_ISOLATION_FUSE = 'OS_REGEN_AMBIENT_ISOLATION_INNER';
16261627

1628+
/**
1629+
* ⭐ The driver's own closing promise, ENFORCED instead of printed — and the one
1630+
* arrangement in which it is falsifiable: a LINKED WORKTREE.
1631+
*
1632+
* Every deferral `drive()` takes ends by printing "The pre-commit hook will not
1633+
* let this commit through until you do." Nothing anywhere tested that sentence.
1634+
* The two cases that come closest each stop one step short, and in the same
1635+
* direction:
1636+
*
1637+
* - `endToEnd()` above performs a real merge and asserts the marker was
1638+
* written — then stops. It never commits, so git never runs a hook.
1639+
* - `check-regen-pending.mjs`'s deferred-merge fixture sets
1640+
* `core.hooksPath=/dev/null` ("the fixture drives the script itself") and
1641+
* WRITES the marker itself. It grades the checker's verdict, never git's
1642+
* willingness to invoke it and never the path the driver actually wrote to.
1643+
*
1644+
* So the JOINT property — the driver writes a marker that the hook git really
1645+
* runs reads back — was asserted by neither half. `markPending()` here and
1646+
* `gitDirPath()` there are two INDEPENDENT `--absolute-git-dir` call sites, and
1647+
* in a primary checkout that directory and `--git-common-dir` are the SAME
1648+
* directory: a regression to the common dir keeps every single-tree fixture
1649+
* green while every real merge in every agent's worktree loses its refusal. A
1650+
* linked worktree is the only arrangement where the two answers differ — and it
1651+
* is the arrangement this repo mandates (AGENTS.md Prime Directive #11), so it
1652+
* is the path every real merge here takes.
1653+
*
1654+
* Five legs. Legs 2, 4 and 5 are the controls without which the other two are
1655+
* free:
1656+
*
1657+
* 1. the marker lands in the LINKED worktree's own git dir;
1658+
* 2. …and NOT in the common one. The discrimination only a linked worktree can
1659+
* make: leg 1 alone is satisfied by a driver that writes the common dir,
1660+
* whenever the two are the same directory.
1661+
* 3. the ordinary commit right after the merge is REFUSED — by git, through the
1662+
* REAL `.githooks/pre-commit` and the REAL checker, neither of them called
1663+
* by this file;
1664+
* 4. the push is REFUSED too, by the REAL `.githooks/pre-push`;
1665+
* 5. with the gate CLEAN, that same commit SUCCEEDS, the marker is gone and the
1666+
* push goes through. This is the FIRING CONTROL, and it is what legs 3 and 4
1667+
* rest on: a hook that always fails — a missing interpreter, a typo'd path,
1668+
* node itself erroring — satisfies them both while proving nothing, and that
1669+
* failure reads from the outside exactly like "the net holds".
1670+
*
1671+
* The real hooks and the real checker are reached through UNTRACKED SYMLINKS
1672+
* (`scripts/`, `.githooks/`) rather than copies, and that is not a convenience:
1673+
* `core.hooksPath` is registered as the RELATIVE `.githooks`, which git resolves
1674+
* against each worktree's own root — the property that lets one shared
1675+
* `.git/config` arm every linked worktree at once. A hook this file wrote out
1676+
* would test this file's imitation of `.githooks/pre-commit`; the symlink tests
1677+
* `.githooks/pre-commit`. Untracked (and `info/exclude`d) so they can never enter
1678+
* the merge under test.
1679+
*
1680+
* The gate itself is the same one-line stub `check-regen-pending.mjs`'s own
1681+
* fixture uses, selected by `OS_REGEN_GATE_CWD` — what is under test here is the
1682+
* wiring between driver, git and hook, never `check:spec-changes`'s opinion.
1683+
*/
1684+
function endToEndWorktreeRefusal() {
1685+
registerCase('endToEndWorktreeRefusal');
1686+
const root = mkdtempSync(join(tmpdir(), 'os-regen-worktree-'));
1687+
const primary = join(root, 'primary');
1688+
const linked = join(root, 'linked');
1689+
const remote = join(root, 'remote.git');
1690+
const results = [];
1691+
const check = (label, cond) => {
1692+
results.push(Boolean(cond));
1693+
console.log(` ${cond ? '✓' : '✗'} ${label}`);
1694+
return Boolean(cond);
1695+
};
1696+
1697+
// ⚠️ `gitFreeEnv()` on every git child: their subject is the throwaway
1698+
// repository their own `cwd` names, never the merge this process might be
1699+
// driving (#16753). The two `git commit` / `git push` calls that must reach a
1700+
// HOOK add `OS_REGEN_GATE_CWD` on top, because the hook's checker spawns the
1701+
// gate and the fixture's stub is what it must find.
1702+
const git = (cwd, args, env = gitFreeEnv()) =>
1703+
execFileSync('git', args, { cwd, encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'] });
1704+
1705+
try {
1706+
const target = REGEN_ARTIFACTS[0].path;
1707+
const gateScript = REGEN_ARTIFACTS[0].check;
1708+
1709+
/* The stub gate, and the pin that keeps it honest. `pnpm -s` resolves its own
1710+
* version from the manifest of the directory it runs in, so a fixture with no
1711+
* `packageManager` grades whatever Corepack resolves as `latest` instead of the
1712+
* pnpm this repository pins — the same trap `check-regen-pending.mjs`'s fixture
1713+
* records. Read off the ROOT manifest rather than written as a literal. */
1714+
const rootPackageManager = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')).packageManager;
1715+
const writeStub = (dir, verdict) =>
1716+
writeFileSync(
1717+
join(dir, 'package.json'),
1718+
`${JSON.stringify(
1719+
{ name: 'os-regen-worktree-fixture', private: true, packageManager: rootPackageManager, scripts: { [gateScript]: verdict } },
1720+
null,
1721+
2,
1722+
)}\n`,
1723+
);
1724+
1725+
// The real checker and the real hooks, reached by name from inside the
1726+
// fixture. `.githooks/pre-commit` runs `"$(git rev-parse --show-toplevel)"
1727+
// /scripts/check-regen-pending.mjs`, so `scripts` has to resolve from the
1728+
// worktree root for the hook to reach anything at all — that indirection is
1729+
// part of what is under test, not something to route around.
1730+
const wire = (dir) => {
1731+
symlinkSync(join(REPO_ROOT, 'scripts'), join(dir, 'scripts'), 'dir');
1732+
symlinkSync(join(REPO_ROOT, '.githooks'), join(dir, '.githooks'), 'dir');
1733+
};
1734+
1735+
mkdirSync(primary, { recursive: true });
1736+
git(primary, ['init', '-q', '--initial-branch=main', '.']);
1737+
git(primary, ['config', 'user.email', 'selftest@objectstack.ai']);
1738+
git(primary, ['config', 'user.name', 'self-test']);
1739+
git(primary, ['config', 'merge.os-regen.name', 'regenerate instead of text-merging']);
1740+
// Absolute, and NOT the value a real clone gets, for `endToEnd()`'s reason:
1741+
// `$(git rev-parse --show-toplevel)` would resolve to the fixture.
1742+
git(primary, ['config', 'merge.os-regen.driver', `node "${join(REPO_ROOT, 'scripts/git-merge-regen.mjs')}" %O %A %B %P`]);
1743+
// RELATIVE, exactly as `setup-git-hooks.mjs` registers it — see the docblock.
1744+
git(primary, ['config', 'core.hooksPath', '.githooks']);
1745+
1746+
const commonDir = realpath(resolve(primary, git(primary, ['rev-parse', '--path-format=absolute', '--git-common-dir']).trim()));
1747+
// Untracked harness files must never enter the merge under test; `info/exclude`
1748+
// lives in the COMMON dir, so one write covers both worktrees (#9258 records
1749+
// what a tracked stub costs a merge in progress).
1750+
mkdirSync(join(commonDir, 'info'), { recursive: true });
1751+
appendFileSync(join(commonDir, 'info', 'exclude'), '\n# self-test harness — never track\npackage.json\nscripts\n.githooks\n');
1752+
1753+
mkdirSync(join(primary, dirname(target)), { recursive: true });
1754+
writeFileSync(join(primary, '.gitattributes'), `${target} merge=os-regen\n`);
1755+
writeFileSync(join(primary, target), '["base"]\n');
1756+
git(primary, ['add', '-A']);
1757+
git(primary, ['commit', '-qm', 'base']);
1758+
1759+
git(primary, ['checkout', '-qb', 'incoming']);
1760+
writeFileSync(join(primary, target), '["base","theirs"]\n');
1761+
git(primary, ['commit', '-qam', 'theirs']);
1762+
git(primary, ['checkout', '-q', 'main']);
1763+
writeFileSync(join(primary, target), '["base","ours"]\n');
1764+
git(primary, ['commit', '-qam', 'ours']);
1765+
1766+
// ── The arrangement the incident took: the merge happens in a LINKED worktree.
1767+
git(primary, ['worktree', 'add', '-q', '-b', 'landing', linked, 'main']);
1768+
wire(primary);
1769+
wire(linked);
1770+
writeStub(linked, 'exit 1'); // a gate that RAN and found the artifact stale
1771+
git(primary, ['init', '-q', '--bare', remote]);
1772+
git(linked, ['remote', 'add', 'origin', remote]);
1773+
1774+
const linkedGitDir = realpath(git(linked, ['rev-parse', '--absolute-git-dir']).trim());
1775+
const linkedMarker = join(linkedGitDir, PENDING_MARKER);
1776+
const commonMarker = join(commonDir, PENDING_MARKER);
1777+
if (linkedGitDir === commonDir) {
1778+
return fail('self-test: the linked worktree shares the common git dir — the arrangement under test was never built');
1779+
}
1780+
1781+
git(linked, ['merge', '--no-ff', '-m', 'merge main into landing', 'incoming']);
1782+
1783+
// ── Legs 1 and 2 ──────────────────────────────────────────────────────
1784+
check('the deferral marker lands in the LINKED worktree\'s own git dir',
1785+
existsSync(linkedMarker) && readFileSync(linkedMarker, 'utf8').includes(target));
1786+
check(' …and NOT in the shared common git dir, where a sibling worktree would read it',
1787+
!existsSync(commonMarker));
1788+
1789+
// ── Leg 3: git runs the REAL pre-commit, and it refuses ───────────────
1790+
const hookEnv = { ...gitFreeEnv(), OS_REGEN_GATE_CWD: linked };
1791+
delete hookEnv.OS_SKIP_REGEN_CHECK;
1792+
appendFileSync(join(linked, target), '\n');
1793+
git(linked, ['add', '--', target]);
1794+
const staleCommit = spawnSync('git', ['commit', '-m', 'ordinary commit after the merge'], {
1795+
cwd: linked, encoding: 'utf8', env: hookEnv,
1796+
});
1797+
const staleOut = `${staleCommit.stdout ?? ''}${staleCommit.stderr ?? ''}`;
1798+
check('the ordinary commit after the merge is REFUSED — by git, through the real pre-commit',
1799+
staleCommit.status !== 0);
1800+
check(` …naming ${target}, so the refusal is this one and not some other failure`,
1801+
staleOut.includes(target) && /os-regen:/.test(staleOut));
1802+
1803+
// ── Leg 4: and the push is refused too ────────────────────────────────
1804+
const stalePush = spawnSync('git', ['push', '-q', 'origin', 'landing'], {
1805+
cwd: linked, encoding: 'utf8', env: hookEnv,
1806+
});
1807+
const pushOut = `${stalePush.stdout ?? ''}${stalePush.stderr ?? ''}`;
1808+
check('the push is REFUSED too, through the real pre-push', stalePush.status !== 0);
1809+
check(' …by the os-regen leg of it, not by the card-trailer leg beside it',
1810+
/os-regen:/.test(pushOut));
1811+
1812+
// ── Leg 5: THE FIRING CONTROL ─────────────────────────────────────────
1813+
// Without this, every leg above is satisfied by a hook that can only fail.
1814+
writeStub(linked, 'exit 0');
1815+
const cleanCommit = spawnSync('git', ['commit', '-m', 'ordinary commit after the merge'], {
1816+
cwd: linked, encoding: 'utf8', env: hookEnv,
1817+
});
1818+
check('FIRING CONTROL: with the artifact CURRENT the same commit is ACCEPTED',
1819+
cleanCommit.status === 0);
1820+
check(' …and the marker is gone, so nothing can get stuck', !existsSync(linkedMarker));
1821+
const cleanPush = spawnSync('git', ['push', '-q', 'origin', 'landing'], {
1822+
cwd: linked, encoding: 'utf8', env: hookEnv,
1823+
});
1824+
check(' …and the push goes through', cleanPush.status === 0);
1825+
1826+
if (!results.every(Boolean)) {
1827+
// ⛔ Through `fail()`, never a bare `return false`: the verdict LINE at the
1828+
// dispatch counts returned booleans, but the EXIT CODE is set only here. A
1829+
// callee that reported a failure by returning false alone printed
1830+
// "✗ … inconsistent" and exited 0 — and `pnpm check:merge-driver` chains
1831+
// this file with `&&`, so the gate read that as a pass. The dispatch now
1832+
// sets the code from its own count as well, and this stays a `fail()` so
1833+
// the failing callee is named in the same place every other one is.
1834+
return fail('self-test (linked worktree): the deferral\'s refusal did not hold — see the failing leg(s) above');
1835+
}
1836+
console.log('✓ linked worktree: the driver\'s deferral is REFUSED by the real hooks git runs there');
1837+
return true;
1838+
} catch (err) {
1839+
return fail(`self-test: ${err?.stderr?.toString() || err?.message || err}`);
1840+
} finally {
1841+
try { git(primary, ['worktree', 'remove', '--force', linked]); } catch { /* the rm below is the real cleanup */ }
1842+
rmSync(root, { recursive: true, force: true });
1843+
}
1844+
}
1845+
16271846
/**
16281847
* ⭐ The throwaway repositories this file builds must be isolated from the
16291848
* AMBIENT one (#16753 — Tier A of #16644; the incident is #16624).
@@ -1754,7 +1973,7 @@ function ambientGitEnvIsolation() {
17541973

17551974
if (process.argv.includes('--self-test')) {
17561975
console.log('git-merge-regen --self-test\n');
1757-
// The fifteen callees as a literal LIST rather than fifteen bare calls, so the
1976+
// The sixteen callees as a literal LIST rather than sixteen bare calls, so the
17581977
// names this block invokes are data the floor below can cross-check the
17591978
// roster against, in both directions. The names are read off the function
17601979
// declarations themselves (`fn.name`), so a renamed callee moves this list
@@ -1773,6 +1992,7 @@ if (process.argv.includes('--self-test')) {
17731992
reconcileMixedComparators,
17741993
endToEnd,
17751994
endToEndMixed,
1995+
endToEndWorktreeRefusal,
17761996
probeLeavesNoMarker,
17771997
ambientGitEnvIsolation,
17781998
];
@@ -1790,6 +2010,13 @@ if (process.argv.includes('--self-test')) {
17902010
for (const breach of floorBreaches) fail(`self-test floor: ${breach}`);
17912011

17922012
const failures = results.filter((ok) => !ok).length + floorBreaches.length;
2013+
// ⛔ The exit code is set from the SAME count the verdict line is printed from.
2014+
// It used to come only from `fail()`, which every callee happened to call — so
2015+
// the two agreed by convention rather than by construction, and the first
2016+
// callee to report a failure by returning `false` alone would print
2017+
// "✗ … inconsistent" and exit 0. `pnpm check:merge-driver` chains this file
2018+
// with `&&`: a zero there is the gate passing. One count, one verdict, one code.
2019+
if (failures > 0) process.exitCode = 1;
17932020
console.log(
17942021
failures === 0
17952022
? `\n✓ merge driver wiring is consistent (${NOT_DRIVER_MANAGED.length} path(s) deliberately excluded).`

0 commit comments

Comments
 (0)