diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index 172d788fe4..c07276dd0f 100755
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -5,6 +5,18 @@
# which `pnpm install` runs. Cheap by construction: with no pending marker it
# exits before doing any work, which is every commit that did not just merge a
# generator-owned artifact (#4675).
+#
+# One commit is exempt, and only one: the MERGE commit itself, which the
+# sanctioned landing sequence (`scripts/pm/os-regen-merge.sh`, step 3) commits
+# BEFORE regenerating, so that "what main brought" stays readable apart from
+# "what the change produces". That commit records a deferral instead of being
+# refused; the immediately following commit must discharge it, and
+# `.githooks/pre-push` refuses the push if none ever does (#8047).
+#
+# ⚠️ git does not run this hook for a merge it completes ITSELF — only for one
+# you finish with `git commit`. A clean auto-committed merge therefore lands
+# with the marker untouched and the refusal falls on the next commit, which is
+# the same collection point either way.
if [ -z "$OS_SKIP_REGEN_CHECK" ]; then
node "$(git rev-parse --show-toplevel)/scripts/check-regen-pending.mjs" || exit 1
diff --git a/.githooks/pre-push b/.githooks/pre-push
new file mode 100755
index 0000000000..126341db8e
--- /dev/null
+++ b/.githooks/pre-push
@@ -0,0 +1,24 @@
+#!/bin/sh
+# Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+#
+# The collection half of the os-regen deferred merge (#8047). `pre-commit` may
+# accept a MERGE commit whose regeneration is deferred to the next commit — but
+# at the moment it decides, that next commit does not exist yet, so it can only
+# RECORD the deferral. Something has to be the event that notices the deferral
+# was never discharged. Only two events can follow a merge commit: another
+# commit (which `pre-commit` already refuses while the artifacts are stale) and
+# the push. This is the push.
+#
+# Registered by the same `core.hooksPath=.githooks` that registers `pre-commit`,
+# so it needs no change to `scripts/setup-git-hooks.mjs` and no separate opt-in.
+#
+# Cheap by construction, exactly like `pre-commit`: with no pending marker the
+# script exits before doing any work, which is every push in this repo that did
+# not just defer a merge. The ref list git writes on stdin is drained and
+# ignored on purpose — the marker is per-worktree state, not per-ref state, so
+# which refs are being pushed cannot change the answer.
+
+if [ -z "$OS_SKIP_REGEN_CHECK" ]; then
+ cat >/dev/null
+ node "$(git rev-parse --show-toplevel)/scripts/check-regen-pending.mjs" --pre-push || exit 1
+fi
diff --git a/AGENTS.md b/AGENTS.md
index d47fad31f2..9e79f13901 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -364,6 +364,18 @@ Even inside your own worktree, operate defensively:
`$GIT_DIR/os-regen-pending` and `pre-commit` refuses the commit until those
artifacts check clean. Sequence after a merge unchanged from §9: rebuild, then
`check:generated --fix` — you just cannot forget it. Worth knowing:
+ - **The MERGE commit itself is the one exemption, and it is a deferral, not a
+ pass** (maintainer ruling 2026-08-12). `scripts/pm/os-regen-merge.sh` is the
+ in-repo authority for landing one of these branches, and its step 3 commits
+ the merge **before**
+ regenerating on purpose: the driver exits 0 while silently dropping one side, so
+ only a separate regeneration commit on a known-good base lets a reviewer read
+ "what main brought" apart from "what the change produces". `pre-commit` records
+ that merge as a deferral and then holds you to it — the immediately following
+ commit must discharge it (every commit until then is refused, and a second merge
+ cannot defer on top of an outstanding one), and `.githooks/pre-push` refuses a
+ push that still owes one. ⛔ So this step never needs `--no-verify`, which was
+ the old spelling and skips *every* pre-commit check rather than this one.
- **The driver is a LOCAL facility** — the merge queue rebuilds server-side where no
custom driver runs, so the three hottest artifacts are **sharded** per
category/entry (`authorable-surface/`, `json-schema.manifest/`, `api-surface/`) to
diff --git a/scripts/check-regen-pending.mjs b/scripts/check-regen-pending.mjs
index ddc0be61c9..5598c98742 100755
--- a/scripts/check-regen-pending.mjs
+++ b/scripts/check-regen-pending.mjs
@@ -19,6 +19,37 @@
* whether you regenerated them or the merge simply did not change them, the
* marker is removed and the commit proceeds.
*
+ * ## The deferred merge (#8047)
+ *
+ * One commit is exempt from the refusal above, and only one: the MERGE commit
+ * itself. `scripts/pm/os-regen-merge.sh` — the sanctioned landing sequence, and
+ * the in-repo authority for it — takes main's side of every generated path and
+ * **commits the merge before regenerating anything**, deliberately, because the
+ * driver exits 0 while silently dropping one side: a clean merge and a lost
+ * baseline are indistinguishable, and only a separate regeneration commit on a
+ * known-good base lets a reviewer read "what main brought" apart from "what my
+ * change produces". Refusing that commit made one in-repo authority forbid what
+ * another in-repo tool deliberately produces, and the way out people learned was
+ * to skip the ENTIRE pre-commit hook — trading one false positive for a blanket
+ * bypass. Measured on PR #7851, ruled 2026-08-12.
+ *
+ * So a merge commit whose artifacts are stale is **deferred, not passed**: the
+ * deferral is recorded in the marker (`deferred-at
`) and the
+ * commit proceeds. Two properties make that a split rather than an escape hatch:
+ *
+ * - **It is one commit deep, by construction.** A deferral is only ever entered
+ * while `MERGE_HEAD` exists, and a second merge attempted while one is still
+ * outstanding is refused. Every non-merge commit after it is refused too — by
+ * the ordinary staleness check, unchanged — so nothing can land between the
+ * merge and its discharge. "The immediately following commit" is enforced by
+ * there being no other commit it could be.
+ * - **The discharge is checked where it becomes knowable.** At the instant the
+ * merge commit is created, the commit that discharges it does not exist yet,
+ * so `pre-commit` can only RECORD the deferral. The two events that can
+ * follow are the next commit (this same check, which refuses while stale) and
+ * the push (`.githooks/pre-push`, which runs this with `--pre-push`). A
+ * deferral that is never discharged therefore cannot leave the machine.
+ *
* ## The dist trap
*
* `gen:api-surface` reads the BUILT `dist/*.d.ts`. On a stale dist it does not
@@ -29,11 +60,13 @@
*
* Usage:
* node scripts/check-regen-pending.mjs # pre-commit
- * node scripts/check-regen-pending.mjs --self-test # no repo state touched
+ * node scripts/check-regen-pending.mjs --pre-push # pre-push: never defers
+ * node scripts/check-regen-pending.mjs --self-test # fixtures only, no repo state
*/
-import { execFileSync, execSync } from 'node:child_process';
-import { existsSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
+import { execFileSync, execSync, spawnSync } from 'node:child_process';
+import { appendFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -42,6 +75,22 @@ import { PENDING_MARKER, entryForPath } from './regen-artifacts.mjs';
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const SPEC_DIR = join(REPO_ROOT, 'packages/spec');
+/**
+ * Where the `check:*` gates are spawned. `--self-test`'s fixtures point this at a
+ * throwaway package so the two-commit sequence can be replayed without spawning
+ * the real spec gates; nothing else sets it. A mistake here fails SAFE — a
+ * directory without those scripts makes pnpm exit non-zero, which reads as stale.
+ */
+const GATE_CWD = process.env.OS_REGEN_GATE_CWD || SPEC_DIR;
+
+/**
+ * Marker line recording a deferral, distinguished from the driver's path lines by
+ * a prefix no repo path can have (it contains a space; `%P` pathnames do not).
+ * An unrecognised line would be reported as an unknown pending path — blocked,
+ * the conservative direction — rather than silently ignored.
+ */
+const DEFERRAL_PREFIX = 'deferred-at ';
+
/** Newest mtime under `dir` for files matching `pred`, or 0 when there are none. */
function newestMtime(dir, pred, depth = 0) {
if (depth > 12 || !existsSync(dir)) return 0;
@@ -95,36 +144,98 @@ export function schemaTreeIsStale(specDir = SPEC_DIR) {
return newestMtime(join(specDir, 'src'), (n) => n.endsWith('.ts') && !n.endsWith('.test.ts')) > tree;
}
-function markerPath() {
- const gitDir = execFileSync('git', ['rev-parse', '--absolute-git-dir'], { encoding: 'utf8' }).trim();
- return join(gitDir, PENDING_MARKER);
+/**
+ * This worktree's git dir — `--absolute-git-dir` resolves to `.git/worktrees/`
+ * in a linked worktree, so the marker and its deferral are per-worktree state and
+ * two agents merging in parallel cannot collect each other's debt.
+ */
+function gitDirPath() {
+ return execFileSync('git', ['rev-parse', '--absolute-git-dir'], { encoding: 'utf8' }).trim();
}
-function readPending(marker) {
- if (!existsSync(marker)) return [];
- return [...new Set(readFileSync(marker, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean))];
+/** The marker's two kinds of line: the driver's deferred paths, and our deferral record. */
+export function readMarker(marker) {
+ if (!existsSync(marker)) return { paths: [], deferral: null };
+ const lines = readFileSync(marker, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean);
+ const record = lines.find((l) => l.startsWith(DEFERRAL_PREFIX));
+ const [head, ...mergeHeads] = record ? record.slice(DEFERRAL_PREFIX.length).trim().split(/\s+/) : [];
+ return {
+ paths: [...new Set(lines.filter((l) => !l.startsWith(DEFERRAL_PREFIX)))],
+ deferral: record ? { head, mergeHeads } : null,
+ };
+}
+
+/** Append the deferral record. Appended, never rewritten — the driver owns the path lines. */
+export function recordDeferral(marker, { head, mergeHeads }) {
+ appendFileSync(marker, `${DEFERRAL_PREFIX}${[head, ...mergeHeads].join(' ')}\n`);
+}
+
+/**
+ * Is a merge commit being created right now? `MERGE_HEAD` exists only between a
+ * stopped merge and the commit that completes it, which is exactly the window
+ * `pre-commit` runs in for a merge commit.
+ *
+ * ⚠️ A merge that completes WITHOUT stopping never reaches this file: git does not
+ * run `pre-commit` for the commit it makes itself (verified, git 2.43). Such a
+ * merge lands with the marker set and untouched, and the refusal falls on the next
+ * commit — which is the deferral's discharge point either way.
+ */
+export function mergeInProgress(gitDir) {
+ const file = join(gitDir, 'MERGE_HEAD');
+ if (!existsSync(file)) return null;
+ const mergeHeads = readFileSync(file, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean);
+ let head = '';
+ try {
+ head = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
+ } catch {
+ head = '(root)'; // A merge as the first commit has no HEAD. Vanishingly rare, not an error.
+ }
+ return { head, mergeHeads };
+}
+
+/**
+ * The whole state machine, as a pure function — five cases, no I/O, so the
+ * self-test can pin every one of them including the ones a fixture cannot reach.
+ *
+ * `blocked` is how many artifacts failed their gate; `merging` is `MERGE_HEAD`
+ * present; `deferral` is a record already outstanding; `allowDefer` is false on
+ * `--pre-push`, where deferring would defeat the point of checking at all.
+ */
+export function decide({ blocked, merging, deferral, allowDefer = true }) {
+ if (!blocked) return deferral ? 'discharged' : 'clear';
+ if (merging && deferral) return 'refuse-second-deferral';
+ if (merging && allowDefer) return 'defer';
+ if (deferral) return 'refuse-undischarged';
+ return 'refuse-stale';
}
function runCheck(script) {
try {
- execSync(`pnpm -s ${script}`, { cwd: SPEC_DIR, stdio: ['ignore', 'pipe', 'pipe'] });
+ execSync(`pnpm -s ${script}`, { cwd: GATE_CWD, stdio: ['ignore', 'pipe', 'pipe'] });
return { ok: true, output: '' };
} catch (err) {
return { ok: false, output: `${err?.stdout?.toString() ?? ''}${err?.stderr?.toString() ?? ''}`.trim() };
}
}
-function main() {
- const marker = markerPath();
- const pending = readPending(marker);
- if (!pending.length) return 0;
+function main({ prePush = false } = {}) {
+ const gitDir = gitDirPath();
+ const marker = join(gitDir, PENDING_MARKER);
+ const { paths: pending, deferral } = readMarker(marker);
+ if (!pending.length) {
+ // Nothing deferred (or a record with no paths left to owe): the marker has no
+ // debt to collect, so it cannot get stuck. Same clearing property as before.
+ if (deferral) rmSync(marker, { force: true });
+ return 0;
+ }
+ const merging = prePush ? null : mergeInProgress(gitDir);
const entries = pending.map((p) => ({ path: p, entry: entryForPath(p) })).filter((x) => x.entry);
const unknown = pending.filter((p) => !entryForPath(p));
console.error(
`\nos-regen: ${pending.length} generated artifact(s) were merged WITHOUT a text merge and must be `
- + `regenerated from the merged tree before this commit.\n`,
+ + `regenerated from the merged tree${prePush ? ' before this push' : ' before this commit'}.\n`,
);
// Group by gate: `gen:schema` owns two artifacts, so running it twice is waste.
@@ -177,18 +288,70 @@ function main() {
console.error(` ✗ ${p} — recorded as pending but absent from scripts/regen-artifacts.mjs (cannot verify)`);
}
- if (blocked) {
+ const action = decide({ blocked, merging, deferral, allowDefer: !prePush });
+ const owed = `the ${blocked} stale artifact(s) above`;
+
+ if (action === 'defer') {
+ recordDeferral(marker, merging);
console.error(
- `\nRegenerate the ${blocked} stale artifact(s) above, \`git add\` them, and commit again.\n`
+ `\nos-regen: this is a MERGE commit — ${owed} are DEFERRED to the next commit, not passed.\n`
+ + ` Recorded against ${merging.head.slice(0, 12)} + ${merging.mergeHeads.map((h) => h.slice(0, 12)).join(' ')}.\n`
+ + ' The merge lands as its own commit on purpose (`scripts/pm/os-regen-merge.sh` step 3): the\n'
+ + ' os-regen driver exits 0 while silently dropping one side, so only a separate regeneration\n'
+ + ' commit on a known-good base tells "what main brought" apart from "what your change produces".\n'
+ + '\n ⚠️ The NEXT commit must discharge this — regenerate, `git add`, commit. Until it does, every\n'
+ + ' commit is refused, a second merge cannot defer on top, and `git push` is refused too.\n',
+ );
+ return 0;
+ }
+
+ if (action === 'refuse-second-deferral') {
+ console.error(
+ `\nos-regen: a deferral is already outstanding (taken at ${deferral.head.slice(0, 12)}) and ${owed}\n`
+ + ' are still stale, so THIS merge cannot defer on top of it. A deferral is one commit deep by\n'
+ + ' construction — any deeper and it is an escape hatch, not the split-commit procedure.\n'
+ + ' Discharge the first one (regenerate + commit), then merge again.\n',
+ );
+ return 1;
+ }
+
+ if (action === 'refuse-undischarged') {
+ console.error(
+ `\nos-regen: the deferral taken on the merge commit at ${deferral.head.slice(0, 12)} is UNDISCHARGED —\n`
+ + (prePush
+ ? ` this push still owes ${owed}. A merge may defer its regeneration to the next commit;\n`
+ + ' it may not defer it past the push, which is the last moment anything local can see it.\n'
+ : ` this is the commit that owes ${owed}. Regenerate them and \`git add\` them.\n`)
+ + ' (`scripts/pm/os-regen-merge.sh` prints the step-4 chain for the surface you touched.)\n',
+ );
+ return 1;
+ }
+
+ if (action === 'refuse-stale') {
+ console.error(
+ `\nRegenerate ${owed}, \`git add\` them, and ${prePush ? 'commit the result before pushing' : 'commit again'}.\n`
+ ' This check clears itself the moment they are current — nothing to reset by hand.\n'
- + ' Bypass with --no-verify only if you intend CI to catch it: every one of these has a\n'
- + ' required gate on the PR.\n',
+ + ' Landing a merge? `bash scripts/pm/os-regen-merge.sh` runs the sanctioned sequence — it commits\n'
+ + ' the merge first (this hook records that as a deferral) and regeneration follows as its own\n'
+ + ' commit. Every artifact above also has a required gate on the PR.\n',
);
return 1;
}
+ if (prePush && blocked) {
+ // Unreachable via `decide` — `allowDefer: false` routes every blocked push into
+ // one of the refusals above. Kept as a floor: a future case that forgets the
+ // push path must not let a stale artifact out of the machine by falling through.
+ console.error('\nos-regen: refusing to push with deferred artifacts still stale.\n');
+ return 1;
+ }
+
rmSync(marker, { force: true });
- console.error('os-regen: all deferred artifacts are current — marker cleared.\n');
+ console.error(
+ action === 'discharged'
+ ? 'os-regen: deferred regeneration discharged — all artifacts current, marker cleared.\n'
+ : 'os-regen: all deferred artifacts are current — marker cleared.\n',
+ );
return 0;
}
@@ -196,6 +359,177 @@ function main() {
// import — only when this file IS the entry point.
const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
+/**
+ * Replay the deferred-merge sequence against a THROWAWAY git repo (#8047).
+ *
+ * This behaviour is a property of a PAIR of commits — accept here, collect there
+ * — which no single-tree assertion can express, so the fixture builds the pair.
+ * The worked precedent it is shaped after is PR #7851: merge commit `cbea40d`
+ * (parents `c57e636` + `e3c8ed0`) followed by regeneration `7e4799f`.
+ *
+ * The gates are redirected at the fixture's own `package.json` via
+ * `OS_REGEN_GATE_CWD`, so `check:spec-changes` is a one-line stub the fixture
+ * flips from failing to passing — the two-commit shape is what is under test, not
+ * the spec gates, and spawning the real ones here would make the self-test cost
+ * a full spec build.
+ */
+function fixtureSelfTest() {
+ const dir = mkdtempSync(join(tmpdir(), 'os-regen-defer-'));
+ const git = (args, opts = {}) =>
+ execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });
+ const results = [];
+ const check = (label, cond) => {
+ results.push(cond);
+ console.log(` ${cond ? '✓' : '✗'} ${label}`);
+ };
+
+ /** Run the real script inside the fixture, with the stub gate in the given state. */
+ const runHook = (gate, args = []) => {
+ writeFileSync(
+ join(dir, 'package.json'),
+ `${JSON.stringify({ name: 'os-regen-fixture', scripts: { 'check:spec-changes': gate === 'clean' ? 'exit 0' : 'exit 1' } }, null, 2)}\n`,
+ );
+ // `spawnSync`, not `execFileSync`: every message this script prints goes to
+ // STDERR, which execFileSync returns only on the failure path — capturing the
+ // accept-path wording is half of what is under test here.
+ const r = spawnSync(process.execPath, [fileURLToPath(import.meta.url), ...args], {
+ cwd: dir,
+ encoding: 'utf8',
+ env: { ...process.env, OS_REGEN_GATE_CWD: dir },
+ });
+ return { code: r.status ?? 1, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
+ };
+
+ try {
+ git(['init', '-q', '-b', 'main', '.']);
+ git(['config', 'user.email', 'fixture@objectstack.test']);
+ git(['config', 'user.name', 'os-regen fixture']);
+ git(['config', 'core.hooksPath', '/dev/null']); // the fixture drives the script itself
+ const gitDir = git(['rev-parse', '--absolute-git-dir']).trim();
+ const marker = join(gitDir, PENDING_MARKER);
+ const pendingPath = 'packages/spec/spec-changes.json'; // a real REGEN_ARTIFACTS entry
+ const write = (f, c) => writeFileSync(join(dir, f), c);
+
+ write('f.txt', 'base\n');
+ git(['add', '-A']);
+ git(['commit', '-qm', 'base']);
+ git(['checkout', '-qb', 'side']);
+ write('s.txt', 'side\n');
+ git(['add', '-A']);
+ git(['commit', '-qm', 'side']);
+ git(['checkout', '-q', 'main']);
+ write('f.txt', 'main moved\n');
+ git(['add', '-A']);
+ git(['commit', '-qm', 'main moves']);
+
+ // A merge left uncommitted — the state `pre-commit` sees when it runs for a
+ // merge commit — with the driver's marker set, artifacts stale.
+ git(['merge', '--no-commit', '--no-ff', 'side'], { stdio: ['ignore', 'pipe', 'pipe'] });
+ writeFileSync(marker, `${pendingPath}\n`);
+
+ const merge = runHook('stale');
+ check('a MERGE commit with stale artifacts is ACCEPTED as a deferral', merge.code === 0);
+ check(' …and says so — "DEFERRED", not a silent pass', /DEFERRED to the next commit/.test(merge.out));
+ check(' …and points at the sanctioned procedure, never at skipping the hook',
+ /os-regen-merge\.sh/.test(merge.out) && !/no-verify/.test(merge.out));
+ const recorded = readMarker(marker).deferral;
+ check(' …recording the merge it was taken on', Boolean(recorded?.head) && recorded.mergeHeads.length === 1);
+ git(['commit', '-q', '--no-verify', '-m', 'merge side (regeneration follows)']);
+
+ // Still stale one commit later: the deferral is now due, and this is where the
+ // pre-fix hook and this one must AGREE — both refuse.
+ const due = runHook('stale');
+ check('the NEXT commit is REFUSED while the deferral is undischarged', due.code === 1);
+ check(' …naming the merge that owes it', /UNDISCHARGED/.test(due.out));
+
+ // A second merge cannot stack a second deferral on the first.
+ git(['checkout', '-qb', 'side2', 'main~1']);
+ write('s2.txt', 'side2\n');
+ git(['add', '-A']);
+ git(['commit', '-qm', 'side2']);
+ git(['checkout', '-q', 'main']);
+ git(['merge', '--no-commit', '--no-ff', 'side2'], { stdio: ['ignore', 'pipe', 'pipe'] });
+ const second = runHook('stale');
+ check('a SECOND merge cannot defer on top of an outstanding deferral', second.code === 1);
+ check(' …so the exemption stays one commit deep', /one commit deep/.test(second.out));
+ git(['merge', '--abort']);
+
+ // The push is the other event that can follow a merge: it must not carry an
+ // undischarged deferral off the machine.
+ const push = runHook('stale', ['--pre-push']);
+ check('`--pre-push` REFUSES an undischarged deferral', push.code === 1);
+ const pushDefers = /DEFERRED to the next commit/.test(push.out);
+ check(' …and never defers, whatever the merge state', !pushDefers);
+
+ // Discharge: the artifacts come current, exactly as the regeneration commit makes them.
+ const discharged = runHook('clean');
+ check('regeneration DISCHARGES the deferral and clears the marker', discharged.code === 0);
+ check(' …saying which of the two clearing paths ran', /discharged/.test(discharged.out));
+ check(' …and the marker is gone, so nothing can get stuck', !existsSync(marker));
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ return results.every(Boolean);
+}
+
+/**
+ * The pre-push hook must be executable **in the index**, not just on this disk —
+ * git silently ignores a non-executable hook, printing at most an
+ * `advice.ignoredHook` note nobody reads. `git-merge-regen.mjs --self-test`
+ * already asserts this for `.githooks/pre-commit`; the assertion lives here for
+ * its sibling because `check:merge-driver` runs both halves, so the coverage
+ * lands in the same gate either way, and a disarmed pre-push means an
+ * undischarged deferral leaves the machine in silence.
+ */
+function prePushIsArmedSelfTest() {
+ let mode = '';
+ try {
+ mode = execFileSync('git', ['ls-files', '-s', '.githooks/pre-push'], {
+ cwd: REPO_ROOT,
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ }).trim().split(/\s+/)[0] ?? '';
+ } catch (err) {
+ console.log(` ✗ could not stat .githooks/pre-push: ${err?.message ?? err}`);
+ return false;
+ }
+ const ok = mode === '100755';
+ console.log(
+ ok
+ ? ' ✓ .githooks/pre-push is executable in the index (100755)'
+ : ` ✗ .githooks/pre-push is mode ${mode || ''} in the index, not 100755.\n`
+ + ' Git IGNORES a non-executable hook — the deferral would never be collected.\n'
+ + ' Fix: git update-index --chmod=+x .githooks/pre-push',
+ );
+ return ok;
+}
+
+/** The five `decide` cases, including the ones a fixture cannot reach. */
+function decisionTableSelfTest() {
+ const cases = [
+ [{ blocked: 0, merging: null, deferral: null }, 'clear'],
+ [{ blocked: 0, merging: null, deferral: { head: 'a' } }, 'discharged'],
+ [{ blocked: 2, merging: { head: 'a' }, deferral: null }, 'defer'],
+ [{ blocked: 2, merging: { head: 'a' }, deferral: { head: 'b' } }, 'refuse-second-deferral'],
+ [{ blocked: 2, merging: null, deferral: { head: 'b' } }, 'refuse-undischarged'],
+ [{ blocked: 2, merging: null, deferral: null }, 'refuse-stale'],
+ // `--pre-push`: deferring is off, so a merge in progress cannot buy a pass.
+ [{ blocked: 2, merging: { head: 'a' }, deferral: null, allowDefer: false }, 'refuse-stale'],
+ [{ blocked: 2, merging: { head: 'a' }, deferral: { head: 'b' }, allowDefer: false }, 'refuse-second-deferral'],
+ ];
+ let ok = true;
+ for (const [input, expected] of cases) {
+ const got = decide(input);
+ if (got !== expected) ok = false;
+ console.log(
+ ` ${got === expected ? '✓' : '✗'} blocked=${input.blocked} merging=${input.merging ? 'y' : 'n'} `
+ + `deferral=${input.deferral ? 'y' : 'n'} allowDefer=${input.allowDefer !== false ? 'y' : 'n'} → ${got}`
+ + (got === expected ? '' : ` (expected ${expected})`),
+ );
+ }
+ return ok;
+}
+
if (invokedDirectly) {
if (process.argv.includes('--self-test')) {
// Touches no repo state: the interesting logic is the staleness rules, and
@@ -204,9 +538,15 @@ if (invokedDirectly) {
console.log(`${noDist ? '✓' : '✗'} a directory with no dist/ reads as STALE (conservative default)`);
const noTree = schemaTreeIsStale(join(REPO_ROOT, 'scripts')) === true;
console.log(`${noTree ? '✓' : '✗'} a directory with no json-schema/ reads as STALE (conservative default)`);
- const ok = noDist && noTree;
+ console.log('\ndeferred-merge collection point:');
+ const armed = prePushIsArmedSelfTest();
+ console.log('\ndeferred-merge decision table:');
+ const table = decisionTableSelfTest();
+ console.log('\ndeferred-merge sequence, replayed on a throwaway repo:');
+ const fixture = fixtureSelfTest();
+ const ok = noDist && noTree && armed && table && fixture;
console.log(ok ? '\n✓ check-regen-pending self-test passed.' : '\n✗ self-test failed.');
process.exit(ok ? 0 : 1);
}
- process.exit(main());
+ process.exit(main({ prePush: process.argv.includes('--pre-push') }));
}
diff --git a/scripts/pm/os-regen-merge.sh b/scripts/pm/os-regen-merge.sh
index 50b47248b5..1ae5e78b90 100644
--- a/scripts/pm/os-regen-merge.sh
+++ b/scripts/pm/os-regen-merge.sh
@@ -29,6 +29,18 @@
# bogus 5xx assertions in the rest package — rerun
# `pnpm --filter @objectstack/spec gen:openapi` to restore.
#
+# ## Step 3 and the pre-commit hook agree (#8047)
+#
+# They used to contradict each other: this script deliberately produces the
+# commit the `os-regen` pre-commit hook refused, so following the procedure
+# meant skipping the hook — and `--no-verify` skips EVERY pre-commit check, not
+# just this one. Ruled 2026-08-12: the hook moved. It now recognises a merge
+# commit whose artifacts are stale and records a DEFERRAL rather than refusing,
+# then holds you to it — every commit after the merge is refused until the
+# regeneration lands, a second merge cannot defer on top of an outstanding one,
+# and `.githooks/pre-push` refuses a push that still owes one. So step 3 needs
+# no bypass, and step 4 is what clears the marker.
+#
# The os-regen path list is read from .gitattributes AT RUN TIME — the one copy
# that cannot rot is the one that does not exist.