Repository: vercel/next.js
Pull request: add git merge driver for errors.json

Adds a merge driver that can automatically re-number messages added to `errors.json` if a merge conflict occurs. Now, it would be easier to just run `pnpm update-error-codes`, but that's slow and requires installing deps + building everything, so i'm just manually merging + renumbering instead.

To avoid disrupting people's workflows (in case this is buggy, or something), it's currently *not enabled by default*. To enable the merge driver, run:
```shell
scripts/merge-errors-json/install
```
which will add it to your `.git/config`. Once it's been confirmed to work well, we can make this command run by default in `postinstall`.

### Testing

Other than the test i added (which doesn't run git), i've tested this manually with this script that rebases some stacked branches:

<details>
<summary>merge-driver-test.sh</summary>

```bash
#!/usr/bin/env bash
set -eu -o pipefail

cd "$1"
current_branch="$(git branch --show-current)"

(
  set -eu -o pipefail
  
  git switch lubieowoce/errors-json-merge-driver

  git switch --force-create 'test/errors-json/base'
  echo '{
    "1": "base message"
  }
  ' > packages/next/errors.json
  git commit -am 'base commit'


  git switch --force-create 'test/errors-json/one';
  echo '{
    "1": "base message",
    "2": "message from branch one"
  }
  ' > packages/next/errors.json
  git commit -am 'commit 1'


  git switch 'test/errors-json/base'

  git switch --force-create 'test/errors-json/stacked-1'
  echo '{
    "1": "base message",
    "2": "message from stacked 1"
  }
  ' > packages/next/errors.json
  git commit -am 'stacked 1'


  git switch --force-create 'test/errors-json/stacked-2'
  echo '{
    "1": "base message",
    "2": "message from stacked 1",
    "3": "message from stacked 2"
  }
  ' > packages/next/errors.json
  git commit -am 'stacked 2'

  git rebase 'test/errors-json/one' --update-refs || true
  bat packages/next/errors.json
  git rebase --abort || true
)

git switch "$current_branch"
```
</details>

The script does a bunch of git operations, so it's easiest to put it outside of your next.js repo and call `bash merge-driver-test.sh path/to/next.js`.

Changed files:
filename: .gitattributes
patch: @@ -4,3 +4,6 @@ packages/next/compiled/** -text linguist-vendored
 
 # Make next/src/build folder indexable for github search
 build/** linguist-generated=false
+
+# Custom merge driver for auto-generated errors.json
+packages/next/errors.json merge=errors-json

filename: scripts/merge-errors-json/install
patch: @@ -0,0 +1,4 @@
+#!/bin/sh
+driver_name='errors-json'
+git config merge."$driver_name".name 'Automatically merge packages/next/errors.json'
+git config merge."$driver_name".driver 'node scripts/merge-errors-json/merge.mjs %A %O %B'

filename: scripts/merge-errors-json/merge.mjs
patch: @@ -0,0 +1,111 @@
+// @ts-check
+
+/**
+ * Git merge driver for packages/next/errors.json
+ *
+ * This script automatically resolves merge conflicts in the auto-generated
+ * errors.json file by reassigning error codes to avoid conflicts.
+ *
+ * Usage: node merge-errors-json.mjs <current> <base> <other> [<marker-size>]
+ *
+ * Arguments:
+ * - current: Path to the current version (our changes)
+ * - base: Path to the common ancestor version
+ * - other: Path to the other version (their changes)
+ * - marker-size: Size of conflict markers (optional, defaults to 7)
+ *
+ * Exit codes:
+ * - 0: Merge successful, result written to current file
+ * - 1: Merge failed, conflicts remain
+ */
+
+import { readFileSync, writeFileSync } from 'node:fs'
+
+function main() {
+  const args = process.argv.slice(2)
+
+  if (args.length < 3) {
+    console.error(
+      'Usage: node merge-errors-json.mjs <current> <base> <other> [<marker-size>]'
+    )
+    process.exit(1)
+  }
+
+  const [currentPath, basePath, otherPath] = args
+
+  try {
+    const base = readJsonSync(basePath)
+    const current = readJsonSync(currentPath)
+    const other = readJsonSync(otherPath)
+
+    const merged = mergeErrors(base, current, other)
+
+    // Git expects the result to be written to the "current" file
+    writeJsonSync(currentPath, merged)
+
+    const addedCount = Object.keys(merged).length - Object.keys(current).length
+    console.log(
+      `merge-errors-json: added ${addedCount === 1 ? '1 new message' : `${addedCount} new messages`} to errors.json`
+    )
+    process.exit(0)
+  } catch (error) {
+    console.error('merge-errors-json: merge failed:', error.message)
+    process.exit(1)
+  }
+}
+
+/**
+ * @typedef {Record<string, string>} ErrorsMap
+ */
+
+/**
+ * Merge three versions of errors.json, resolving conflicts by assigning new sequential IDs
+ * @param {ErrorsMap} base - Base version (common ancestor)
+ * @param {ErrorsMap} current - Current version (our changes, or the state of the branch we're rebasing onto)
+ * @param {ErrorsMap} other - Other version (their changes, or the state the branch we're rebasing, a.k.a. "incoming")
+ * @returns {ErrorsMap}
+ */
+function mergeErrors(base, current, other) {
+  /** @type {ErrorsMap} */
+  const result = { ...current }
+
+  /** @type {Set<string>} */
+  const existingMessages = new Set(Object.values(result))
+
+  let nextKey = Object.keys(result).length + 1
+
+  for (const message of getNewMessages(base, other)) {
+    if (existingMessages.has(message)) {
+      continue
+    }
+
+    const key = nextKey++
+    result[key] = message
+    existingMessages.add(message)
+  }
+
+  return result
+}
+
+function getNewMessages(
+  /** @type {ErrorsMap} */ prev,
+  /** @type {ErrorsMap} */ current
+) {
+  const existing = new Set(Object.values(prev))
+  return Object.values(current).filter((msg) => !existing.has(msg))
+}
+
+function readJsonSync(/** @type {string} */ filePath) {
+  const content = readFileSync(filePath, 'utf8')
+  return JSON.parse(content)
+}
+
+function writeJsonSync(
+  /** @type {string} */ filePath,
+  /** @type {any} */ value
+) {
+  const content = JSON.stringify(value, null, 2) + '\n'
+  writeFileSync(filePath, content, 'utf8')
+}
+
+main()

filename: test/scripts/merge-errors-json/merge-errors-json.test.ts
patch: @@ -0,0 +1,124 @@
+import { mkdtempSync, rmSync } from 'fs'
+import { writeJSONSync, readJSONSync } from 'fs-extra'
+import { join } from 'path'
+import { tmpdir } from 'os'
+import execa from 'execa'
+
+describe('merge-errors-json driver', () => {
+  it('should correctly merge errors.json files with conflicting new entries', async () => {
+    const base = {
+      '1': 'Original message 1',
+      '2': 'Original message 2',
+    }
+    const current = {
+      ...base,
+      '3': 'From a conflicting branch that was merged first 1',
+      '4': 'From a conflicting branch that was merged first 2',
+    }
+    const rebased = await simulateRebase({
+      base,
+      current,
+      incoming: {
+        ...base,
+        '3': 'From our branch 1',
+        '4': 'From our branch 2',
+      },
+    })
+    expect(rebased).toEqual({
+      ...current,
+      '5': 'From our branch 1',
+      '6': 'From our branch 2',
+    })
+  })
+
+  it('should handle empty base file', async () => {
+    const base = {}
+    const current = {
+      '1': 'Message from a conflicting branch that was merged first',
+    }
+    const rebased = await simulateRebase({
+      base,
+      current,
+      incoming: { '1': 'New message from our branch' },
+    })
+    expect(rebased).toEqual({
+      ...current,
+      '2': 'New message from our branch',
+    })
+  })
+
+  it('should handle stacked PRs', async () => {
+    const base = {
+      '1': 'Original message 1',
+      '2': 'Original message 2',
+    }
+    const current = {
+      ...base,
+      '3': 'Message from a conflicting branch that was merged first',
+    }
+    const ourBranch1 = {
+      ...base,
+      '3': 'New message from our-branch-1',
+    }
+    const ourBranch2 = {
+      ...ourBranch1,
+      '4': 'New message from our-branch-2', // will conflict after our-branch-1 is rebased and its error is renumbered to 4
+    }
+
+    const branch1Rebased = await simulateRebase({
+      base: base,
+      current: current,
+      incoming: ourBranch1,
+    })
+    expect(branch1Rebased).toEqual({
+      ...current,
+      '4': 'New message from our-branch-1',
+    })
+
+    const branch2Rebased = await simulateRebase({
+      base: ourBranch1,
+      current: branch1Rebased,
+      incoming: ourBranch2,
+    })
+    expect(branch2Rebased).toEqual({
+      ...branch1Rebased,
+      '5': 'New message from our-branch-2',
+    })
+  })
+})
+
+type Contents = Record<string, string>
+
+async function simulateRebase({
+  base,
+  current,
+  incoming,
+}: {
+  base: Contents
+  current: Contents
+  incoming: Contents
+}) {
+  const tempDir = mkdtempSync(join(tmpdir(), 'merge-driver-test-'))
+
+  const paths = {
+    base: join(tempDir, 'base.json'),
+    current: join(tempDir, 'current.json'),
+    other: join(tempDir, 'other.json'),
+  }
+
+  writeJSONSync(paths.base, base, { spaces: 2 })
+  // during a rebase, the branch we're rebasing onto will be "current", and our commit will be "other"
+  writeJSONSync(paths.current, current, { spaces: 2 })
+  writeJSONSync(paths.other, incoming, { spaces: 2 })
+
+  await execa('node', [
+    'scripts/merge-errors-json/merge.mjs',
+    paths.current,
+    paths.base,
+    paths.other,
+  ])
+
+  const result = readJSONSync(paths.current)
+  rmSync(tempDir, { recursive: true, force: true })
+  return result
+}