Keeping generated files out of merge conflicts Jump to heading

A conflict in a generated file has no manual resolution that is defensible. Picking lines from two versions of a resolved dependency graph, a compiled schema or a generated client produces an artefact that corresponds to no input — it may parse, it may even build, and it describes a state nobody chose. The correct resolution is always the same: take either side, then re-run the generator against the merged inputs. A merge driver makes that automatic. This recipe builds one, within merge strategies and gitattributes.

When to use this approach Jump to heading

  • The same generated file conflicts on most merges.
  • Someone has resolved a generated file by hand and something broke afterwards.
  • Your repository commits lockfiles, protocol stubs, API clients or compiled schemas.
  • The generator is deterministic and available on every developer machine.
  • If the generator needs credentials or a service that is not always reachable, a driver will fail at awkward moments; prefer regenerating in CI and marking the file generated.

Step 1 — Confirm the generator is deterministic Jump to heading

A driver that regenerates non-deterministically converts a merge conflict into a permanent diff.

npm run codegen && git diff --exit-code src/api/generated/ \
  && echo "deterministic — safe to drive" \
  || echo "NOT deterministic: output differs from what is committed"
# Run it twice and compare, to rule out timestamps and ordering
npm run codegen && sha256sum src/api/generated/client.ts
npm run codegen && sha256sum src/api/generated/client.ts
# Verification: both hashes match

Non-determinism is usually a timestamp, a map iteration order, or an embedded version string. All three are worth fixing regardless of merging, because they also make every unrelated change look like a change to the generated file.

Step 2 — Write the driver Jump to heading

Git passes three temporary files: %O the common ancestor, %A our version, %B theirs. The driver must leave the result in %A and exit zero.

# The general shape: take one side, then regenerate from the merged inputs
git config merge.generated.name 'regenerate from source instead of merging'
git config merge.generated.driver 'npm run --silent codegen && cp "%A" "%A"'
# For a lockfile, where the inputs are the merged manifests
git config merge.lockfile.name 'regenerate the lockfile from the manifest'
git config merge.lockfile.driver \
  'cp "%B" "%A" && npm install --package-lock-only --silent --ignore-scripts'
# Verification: the driver is registered and its command is what you expect
git config --get merge.lockfile.driver

The cp "%B" "%A" step matters even though the content is about to be overwritten: some generators read the existing file to preserve ordering or comments, and starting from a valid file rather than a conflicted one avoids confusing them.

What Git asks the driver to doGit detects a conflict in a path whose attribute names a driver, writes the three versions to temporary files, and runs the command. The driver overwrites the ours file with a freshly generated result and exits zero, and Git treats the path as resolved.mergeattributesdriverindexwhich driver for this path?merge=lockfilerun with %O %A %Bregenerate from manifestsexit 0, result in %Astaged as resolveda non-zero exit leaves the path conflicted, which is the right behaviour on failure

Step 3 — Point the attributes at it Jump to heading

cat >> .gitattributes <<'ATTR'
package-lock.json       merge=lockfile   linguist-generated=true
pnpm-lock.yaml          merge=lockfile   linguist-generated=true
src/api/generated/**    merge=generated  linguist-generated=true
schema.graphql          merge=generated  linguist-generated=true
ATTR
git add .gitattributes && git commit -m 'chore: regenerate rather than merge generated files'
# Verification: paths resolve to the intended driver
git check-attr merge linguist-generated -- package-lock.json src/api/generated/client.ts

Step 4 — Distribute the driver to every clone Jump to heading

Attributes are committed; drivers are not, because a driver is a command. Every clone needs the definition installed.

# In the committed team configuration, included by each clone once
cat >> .gitconfig-team <<'INI'
[merge "lockfile"]
    name = regenerate the lockfile from the manifest
    driver = cp \"%B\" \"%A\" && npm install --package-lock-only --silent --ignore-scripts
[merge "generated"]
    name = regenerate from source instead of merging
    driver = npm run --silent codegen
INI
git config --local include.path ../.gitconfig-team
# Verification: the driver resolves in a fresh clone after the include
git clone "$(git remote get-url origin)" /tmp/fresh
(cd /tmp/fresh && git config --local include.path ../.gitconfig-team && git config --get merge.lockfile.driver)

SAFETY WARNING — a clone without the driver installed falls back to a textual merge of a generated file, silently producing exactly the corrupt artefact the driver exists to prevent. The failure is invisible in the merge and appears later as an install that resolves unexpected versions. Check for the driver in the configuration audit, and have CI verify that the committed artefact matches its inputs on every pull request.

Step 5 — Verify the result in CI Jump to heading

The driver makes merges deterministic; CI is what proves they were also correct.

      - run: npm ci --ignore-scripts
      - name: Generated output matches its inputs
        run: |
          npm run codegen
          npm install --package-lock-only --ignore-scripts
          git diff --exit-code -- 'src/api/generated/**' package-lock.json
# Verification locally before pushing
npm run codegen && npm install --package-lock-only && git diff --exit-code && echo "in sync"
Three ways a generated file can be mergedA textual merge produces an artefact matching no input. Taking one side wholesale keeps a file that does not match the merged manifests. Regenerating from the merged inputs is the only option that produces something the inputs actually describe.Take one sideRegeneratematches the merged inputsnoyesdeterministicyesyesneeds the generatornoyescaught by CI if wrongonly laterimmediatelya textual merge is worse than either and is what happens without an attribute When a driver is the wrong answerA deterministic generator that runs anywhere is a good candidate for a driver. One that needs credentials or a network service will fail during merges, and one that is not deterministic will produce a permanent diff. In both cases, regenerate in CI and mark the file generated instead.Can the generator run reliably on every machine?yes, deterministicMerge driverconflicts disappearneeds credentialsCI onlymark generated, verify therenot deterministicFix that firsta driver would churnthe middle branch is common for anything generated from a live schema

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

What if the driver fails during a merge? Jump to heading

Exiting non-zero leaves the path conflicted, which is correct: the merge stops and a person decides. That is much better than a driver that swallows errors, because a silent failure here produces a plausible-looking artefact nobody will question.

Should generated files be committed at all? Jump to heading

It depends on whether a fresh clone must build without running the generator. Committing them makes the repository self-contained at the cost of churn; generating on demand keeps the repository small but makes the generator a hard build dependency. The trade is laid out in keeping generated files out of review diffs.

Does the driver run during a rebase or a cherry-pick? Jump to heading

Yes — all three use the same merge machinery, so the attribute and the driver apply identically. That is usually welcome, though it means a long rebase will run the generator once per conflicting commit, which is worth knowing when a replay seems unexpectedly slow.