Skip to content

build: merge driver for generator-owned spec artifacts (#4675) - #4702

Merged
os-zhuang merged 1 commit into
mainfrom
claude/spec-merge-driver-4675
Aug 2, 2026
Merged

build: merge driver for generator-owned spec artifacts (#4675)#4702
os-zhuang merged 1 commit into
mainfrom
claude/spec-merge-driver-4675

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #4675.

packages/spec's checked-in artifacts are sorted arrays and append-only ledgers. Two PRs each adding a few lines is a set union — semantically composable — but git reports a text conflict a human must resolve. Measured over one afternoon (2026-08-02): four merges, nine conflicts across those files, and not one was a real semantic conflict. Every correct resolution was the same three steps — discard both sides, re-run the generator, re-run the gates — at a dozen-plus minutes each, on a main moving fast enough that the rerun could itself go stale.

.gitattributes now routes those paths to merge=os-regen.

The driver deliberately does not regenerate

This is the design decision in the change, and it inverts what the issue proposed.

The obvious implementation — "on conflict, run the generator" — is wrong, and measurably so. Git invokes merge drivers while it is merging, in index order, and the worktree still holds the pre-merge sources at that moment. packages/spec/spec-changes.json sorts before packages/spec/src/… ('p' < 'r'), so a driver firing there reads a migrations/registry.ts with the incoming side's retirements missing. Verified directly rather than assumed:

=== driver invoked for %P: packages/spec/spec-changes.json ===
--- worktree src/registry.ts at that moment:
SRC base
SRC theirs-added          ← ours only; the incoming side is not merged yet

It would write a confidently wrong artifact — strictly worse than the conflict it replaces:

A conflict marker is a visible error. A plausible-looking generated file is an invisible one.

This repo already has that scar. On #4687 a gen:api-surface run against an incomplete dist silently dropped an unrelated ./studio export and ratcheted a docs-import-surface.baseline.json exemption in to cover the hole. Nothing failed. It was caught only by diffing generated files against main.

So the driver defers: it resolves the path (no text merge, no markers, exit 0) and records it in $GIT_DIR/os-regen-pending. A pre-commit hook then refuses the commit until those artifacts check clean — so regeneration happens on the fully-merged tree, the only state in which it is correct, and cannot be forgotten.

The hook verifies and clears; it never regenerates. Blanket regeneration from a hook would rewrite artifacts whose staleness nobody saw, which is the signal-destroying behaviour check:generated already refuses for the same reason. And a marker cannot get stuck: it clears the moment the artifacts are current, whether you regenerated them or the merge simply did not change them.

The dist trap, made unsurvivable where it writes

check:generated --fix now refuses gen:api-surface when dist is older than src, instead of printing advice a reader can skip. On a stale dist that generator does not fail — it emits a plausible surface missing every export added since the last build. --fix is the one path that writes, so it is the one place the trap cannot be survived. The staleness rule is shared with the pre-commit half rather than copied: the direction two copies drift in is the one that writes a wrong artifact.

Excluded on purpose

Shrink-only ratchets (docs-import-surface.baseline.json, dual-source-exports.baseline.json), the hand-written migrations/conversions registries, and variant-docs.json stay on text merge. Recomputing a shrink-only ratchet can WIDEN it, laundering a new exemption in as merge noise. NOT_DRIVER_MANAGED records the reason per path — the point being that "a generator writes it" is not sufficient grounds to let a merge recompute it.

Verification

pnpm check:merge-driver:

  • reconciles .gitattributes against the one table in both directions (a path in only one place fails);
  • pins that .githooks/pre-commit is mode 100755 in the index — git silently ignores a non-executable hook, and this was caught exactly that way here, with two e2e commits sailing past an installed-but-inert hook;
  • proves the driver end to end against real git.

Beyond the self-tests, the whole loop was exercised on real files and real generators — two branches each appending a retirement to migrations/registry.ts, then merged:

step result
merge 3 conflicts → 1 (only the hand-written registry)
spec-changes.json, protocol-upgrade-guide.md resolved, zero conflict markers, both recorded pending
commit without regenerating BLOCKED, with the exact gen: command per artifact; HEAD unmoved
regenerate → commit passes, marker self-cleared
merged artifacts both sides' entries present — the set union the text merge could not express

Also verified: fresh build → distIsStale = false; stale src → true (no false positive from branch switching, since git only rewrites files whose content actually differs). The refusal path was confirmed to fire by forcing a stale artifact against a stale dist.

Gates: pnpm typecheck (exit 0), pnpm lint clean, check:generated 8/8 current, check:adr-anchors OK, changeset validators pass.

Deployment note

Registration is per clone.gitattributes and .githooks/ are committed, but git will not let a repository execute code on you just because you cloned it. pnpm install does it via prepare. A clone where that never ran falls back to git's default text merge — verified: pre-#4675 behaviour, not breakage — so committing .gitattributes cannot regress anyone, and setup-git-hooks.mjs warns-and-exits-0 on every failure path rather than breaking pnpm install.

Refs: #4675, #4663, #4650, #4687


Generated by Claude Code

`packages/spec`'s checked-in artifacts are sorted arrays and append-only
ledgers. Two PRs each adding a few lines is a set union — semantically
composable — but git reports a text conflict a human must resolve. Measured
over one afternoon (2026-08-02): four merges, nine conflicts across those
files, and NOT ONE was a real semantic conflict. Every correct resolution was
the same three steps — discard both sides, re-run the generator, re-run the
gates — at a dozen-plus minutes each, on a `main` moving fast enough that the
rerun could itself go stale.

`.gitattributes` now routes those paths to `merge=os-regen`.

## The driver deliberately does not regenerate

The obvious implementation — "on conflict, run the generator" — is wrong, and
measurably so. Git invokes merge drivers WHILE merging, in index order, and the
worktree still holds pre-merge sources at that moment. Verified directly:
`packages/spec/spec-changes.json` sorts before `packages/spec/src/...`, and a
driver firing there sees a `migrations/registry.ts` with the incoming side's
retirements missing. It would write a confidently wrong artifact.

That is strictly worse than the conflict it replaces. A conflict marker is a
visible error; a plausible generated file is an invisible one — and this repo
already has the scar: on #4687 a `gen:api-surface` against an incomplete `dist`
silently dropped an unrelated `./studio` export and ratcheted a baseline
exemption in to cover the hole. Nothing failed; it was caught by diffing
generated files against `main`.

So the driver defers: it resolves the path (no markers, exit 0) and records it
in `$GIT_DIR/os-regen-pending`. A `pre-commit` hook then refuses the commit
until those artifacts check clean — regeneration happens on the fully-merged
tree, the only state in which it is correct, and cannot be forgotten. The hook
verifies and clears; it never regenerates, because blanket regeneration
rewrites artifacts whose staleness nobody saw (the same reason
`check:generated` refuses it). A marker cannot get stuck: it clears the moment
the artifacts are current.

## The dist trap, made unsurvivable where it writes

`check:generated --fix` now REFUSES `gen:api-surface` when `dist` is older than
`src`, rather than printing advice a reader can skip. On a stale dist that
generator does not fail — it emits a plausible surface missing every export
added since the last build. `--fix` is the one path that writes, so it is the
one place the trap cannot be survived. The staleness rule is shared with the
pre-commit half rather than copied, because the direction two copies drift in
is the one that writes a wrong artifact.

## Excluded on purpose

Shrink-only ratchets (`docs-import-surface.baseline.json`,
`dual-source-exports.baseline.json`), the hand-written migrations/conversions
registries, and `variant-docs.json` stay on text merge. Recomputing a
shrink-only ratchet can WIDEN it, laundering a new exemption in as merge noise.
`NOT_DRIVER_MANAGED` records the reason per path.

## Verification

`pnpm check:merge-driver` reconciles `.gitattributes` against the one table in
both directions, pins that `.githooks/pre-commit` is mode 100755 in the index
(git silently IGNORES a non-executable hook — caught exactly that way here,
with two e2e commits sailing past an installed-but-inert hook), and proves the
driver end to end against real git.

Beyond the self-tests, the whole loop was exercised on real files and real
generators: two branches each appending a retirement, then merged. Three
conflicts became one (the hand-written registry), both generated files came out
marker-free and recorded, committing without regenerating was BLOCKED with the
exact commands, and after regenerating the commit went through with the marker
self-cleared — with both sides' entries present, which is the set union the
text merge could not express.

Registration is per clone via `prepare`; an unregistered clone falls back to
git's default text merge — pre-#4675 behaviour, not breakage.

Closes #4675

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NKcGqCYCCpMkB5UW8jNPXx
@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 2, 2026 7:16pm

Request Review

@github-actions github-actions Bot added the size/l label Aug 2, 2026
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

No hand-written docs reference the 0 changed package(s). ✅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec 生成物没有 merge driver:两个 PR 各改几行,语义上是集合运算,却每次都打成文本冲突

2 participants