Skip to content

feat(merge-driver): resolve JSON conflicts structurally - #1869

Closed
jd wants to merge 1 commit into
mainfrom
devs/jd/jd/mrgfy-9993-json-merge-driver-in-rust/resolve-json-conflicts-structurally--4919b525
Closed

jd wants to merge 1 commit into
mainfrom
devs/jd/jd/mrgfy-9993-json-merge-driver-in-rust/resolve-json-conflicts-structurally--4919b525

Conversation

@jd

@jd jd commented Oct 1, 2026

Copy link
Copy Markdown
Member

git's line merge conflicts whenever two edits touch neighbouring lines.
In JSON that is usually noise: two renovate bumps of adjacent
dependencies in a package.json, two new endpoints in a generated
OpenAPI schema. mergify merge-driver json %A %O %B is a git merge
driver that merges those files by structure instead, in a new crate,
mergify-json-merge.

The bar is "correct whenever it claims success, declines otherwise". A
decline falls back to git merge-file, so it costs what having no
driver costs: conflict markers for a person, a conflicted path for the
merge queue. Before claiming success, the driver parses its own output
again and compares it with the value it meant to produce, so a mistake
in assembling the text becomes a decline, never a wrong file. A clean
fallback line merge that produces invalid JSON from three valid inputs
(the same key added at both ends of an object) is reported as a
conflict.

The three decisions:

  • Arrays merge element by element (diff3 over elements). A stretch only
    one side changed takes that side's version. A stretch both changed
    merges only when both edited the same elements in place (same count,
    nothing moved), each element then merged recursively. Everything
    else declines, including two insertions at the same point. A
    JSON-Schema required list would take either order, an ordered
    pipeline maybe neither, and nothing in the file says which kind of
    array it is. The same insertion on both sides is taken once.
  • Key order and formatting: the result is ours' text, edited, never
    re-serialised. Unchanged bytes are copied, and theirs' changes are
    spliced in as theirs wrote them, shifted to ours' indentation. A key
    only theirs added goes to its sorted place when both sides keep the
    object sorted (package.json dependencies, schema components).
    Otherwise it goes after the key that precedes it in theirs. Two
    different values for one key decline, and so does a key one side
    deleted and the other changed.
  • Byte-identical output: met on every pair it merged in the eval below,
    generated schemas included. What it cannot know is the order a
    generator gives two new fields of one model. Where CI regenerates and
    compares, that difference turns the batch red rather than landing.

Eval on monorepo's history (2026-08-01 → 2026-10-01, the census
window). Each pair of changes merged within 72h of each other is
replayed as two branches off the same base and compared with what
landed:

  • dashboard/package.json: 264 replayable pairs. 23 conflict under
    the line merge, and the driver resolves all 23 byte-identical to
    what landed. The other 241 merge cleanly, also byte-identical.
    Nothing declined, nothing wrong. 137 pairs could not be replayed by
    line position (mostly sequential bumps of one pin).
  • engine/schemas/{api-internal,api-public-future,api-public,mergify-config-schema}.json,
    consecutive changes: 189 of 189 separable pairs are byte-identical to
    the regenerated file. The pairs that could not be separated show the
    declines working as intended: one change editing a key the other
    added, and two values appended to one enum.
  • Cost: about 4 ms of process startup, 64 ms for a full merge of the
    2 MB api-internal.json, under 1 ms of merge work on a package.json.

This alone does not unblock the automated updates queue. There, a
package.json conflict comes with a pnpm-lock.yaml one, and the lockfile
still stops composition. That is MRGFY-9186, not this.

How it reaches the engine. The engine image is python-slim with git and
uv, and has no Rust toolchain. Options:

  1. Ship it as a subcommand of the mergify CLI, installed as a Python
    dependency of the engine. Chosen. mergify-cli is on PyPI as a
    binary-only wheel (manylinux and musllinux, x86_64 and aarch64), so
    one line in the engine's pyproject puts mergify in the venv, with
    the version pinned in uv.lock and bumped by renovate. No new
    release artifact, no Dockerfile download, no checksum to maintain.
  2. A standalone binary attached to each release and COPYed or
    downloaded into the image. That means a second artifact to build and
    publish, a version and checksum pinned in the Dockerfile, and a
    renovate rule for it, all to save a 12 MB binary.
  3. A Python port inside the engine. Python's startup alone is longer
    than this driver's whole run on a package.json.

The coupling option 1 creates: the engine depends on the command line
mergify merge-driver json <ours> <base> <theirs>. That is why it is a
documented public subcommand rather than an _internal one. Engine
behaviour also moves only on a dependency bump, so every change to the
driver reaches the queue through a reviewed PR. The engine-side wiring
(the driver table entry, the strategy name, the dependency, and the
PATH the composer's git sees) is a separate monorepo change.

Fixes MRGFY-9993

Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com

git's line merge conflicts whenever two edits touch neighbouring lines.
In JSON that is usually noise: two renovate bumps of adjacent
dependencies in a package.json, two new endpoints in a generated
OpenAPI schema. `mergify merge-driver json %A %O %B` is a git merge
driver that merges those files by structure instead, in a new crate,
`mergify-json-merge`.

The bar is "correct whenever it claims success, declines otherwise". A
decline falls back to `git merge-file`, so it costs what having no
driver costs: conflict markers for a person, a conflicted path for the
merge queue. Before claiming success, the driver parses its own output
again and compares it with the value it meant to produce, so a mistake
in assembling the text becomes a decline, never a wrong file. A clean
fallback line merge that produces invalid JSON from three valid inputs
(the same key added at both ends of an object) is reported as a
conflict.

The three decisions:

- Arrays merge element by element (diff3 over elements). A stretch only
  one side changed takes that side's version. A stretch both changed
  merges only when both edited the same elements in place (same count,
  nothing moved), each element then merged recursively. Everything
  else declines, including two insertions at the same point. A
  JSON-Schema `required` list would take either order, an ordered
  pipeline maybe neither, and nothing in the file says which kind of
  array it is. The same insertion on both sides is taken once.
- Key order and formatting: the result is ours' text, edited, never
  re-serialised. Unchanged bytes are copied, and theirs' changes are
  spliced in as theirs wrote them, shifted to ours' indentation. A key
  only theirs added goes to its sorted place when both sides keep the
  object sorted (package.json dependencies, schema components).
  Otherwise it goes after the key that precedes it in theirs. Two
  different values for one key decline, and so does a key one side
  deleted and the other changed.
- Byte-identical output: met on every pair it merged in the eval below,
  generated schemas included. What it cannot know is the order a
  generator gives two new fields of one model. Where CI regenerates and
  compares, that difference turns the batch red rather than landing.

Eval on monorepo's history (2026-08-01 → 2026-10-01, the census
window). Each pair of changes merged within 72h of each other is
replayed as two branches off the same base and compared with what
landed:

- `dashboard/package.json`: 264 replayable pairs. 23 conflict under
  the line merge, and the driver resolves all 23 byte-identical to
  what landed. The other 241 merge cleanly, also byte-identical.
  Nothing declined, nothing wrong. 137 pairs could not be replayed by
  line position (mostly sequential bumps of one pin).
- `engine/schemas/{api-internal,api-public-future,api-public,mergify-config-schema}.json`,
  consecutive changes: 189 of 189 separable pairs are byte-identical to
  the regenerated file. The pairs that could not be separated show the
  declines working as intended: one change editing a key the other
  added, and two values appended to one `enum`.
- Cost: about 4 ms of process startup, 64 ms for a full merge of the
  2 MB `api-internal.json`, under 1 ms of merge work on a package.json.

This alone does not unblock the `automated updates` queue. There, a
package.json conflict comes with a pnpm-lock.yaml one, and the lockfile
still stops composition. That is MRGFY-9186, not this.

How it reaches the engine. The engine image is python-slim with git and
uv, and has no Rust toolchain. Options:

1. Ship it as a subcommand of the `mergify` CLI, installed as a Python
   dependency of the engine. Chosen. `mergify-cli` is on PyPI as a
   binary-only wheel (manylinux and musllinux, x86_64 and aarch64), so
   one line in the engine's pyproject puts `mergify` in the venv, with
   the version pinned in `uv.lock` and bumped by renovate. No new
   release artifact, no Dockerfile download, no checksum to maintain.
2. A standalone binary attached to each release and `COPY`ed or
   downloaded into the image. That means a second artifact to build and
   publish, a version and checksum pinned in the Dockerfile, and a
   renovate rule for it, all to save a 12 MB binary.
3. A Python port inside the engine. Python's startup alone is longer
   than this driver's whole run on a package.json.

The coupling option 1 creates: the engine depends on the command line
`mergify merge-driver json <ours> <base> <theirs>`. That is why it is a
documented public subcommand rather than an `_internal` one. Engine
behaviour also moves only on a dependency bump, so every change to the
driver reaches the queue through a reviewed PR. The engine-side wiring
(the driver table entry, the strategy name, the dependency, and the
PATH the composer's git sees) is a separate monorepo change.

Fixes MRGFY-9993

Change-Id: I4919b5253e93d7f465c696cb4b9d7db374b2b63e
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 1, 2026 15:57
@mergify
mergify Bot had a problem deploying to Mergify Merge Protections October 1, 2026 15:57 Failure
@jd
jd deployed to func-tests-live October 1, 2026 15:57 — with GitHub Actions Active
@mergify

mergify Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Merge Protections

🔴 2 of 6 protections blocking · waiting on 👀 reviews

Protection Waiting on
🔴 👀 Review Requirements 👀 reviews
🔴 🔎 Reviews 👀 reviews
🟢 🤖 Continuous Integration —
🟢 Enforce conventional commit —
🟢 📕 PR description —
🟢 🚦 Auto-queue —

🔴 👀 Review Requirements

Waiting for

  • #approved-reviews-by>=2
This rule is failing.
  • any of:
    • #approved-reviews-by>=2
    • author = dependabot[bot]
    • author = mergify-ci-bot
    • author = renovate[bot]

🔴 🔎 Reviews

Waiting for

  • #review-requested = 0
  • #review-threads-unresolved = 0
This rule is failing.
  • #review-requested = 0
  • #review-threads-unresolved = 0
  • #changes-requested-reviews-by = 0

Show 4 satisfied protections

🟢 🤖 Continuous Integration

  • all of:
    • check-success=ci-gate

🟢 Enforce conventional commit

Make sure that we follow https://www.conventionalcommits.org/en/v1.0.0/

  • title ~= ^(fix|feat|internal|docs|style|refactor|perf|test|build|ci|chore|revert|ui)(?:\(.+\))?!?:

🟢 📕 PR description

  • body ~= (?ms:.{48,})

🟢 🚦 Auto-queue

When all merge protections are satisfied, this pull request will be queued automatically.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Edited array moves can be incorrectly merged by position while claiming structural success.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)
What changed in this PR

Adds a lossless structural JSON merge driver and exposes it through the native CLI.

Changes:

  • Implements conservative object/array three-way merging with Git fallback.
  • Registers and documents mergify merge-driver json.
  • Adds parser, merge, driver, and end-to-end Git tests.
File Description
README.md Documents merge-driver setup.
Cargo.lock Locks the new crate.
crates/​mergify-json-merge/​Cargo.toml Defines the merge crate.
crates/​mergify-json-merge/​src/​lib.rs Exposes the public API.
crates/​mergify-json-merge/​src/​value.rs Implements lossless JSON parsing.
crates/​mergify-json-merge/​src/​sequence.rs Implements array diff3 alignment.
crates/​mergify-json-merge/​src/​merge.rs Implements structural merging and rendering.
crates/​mergify-json-merge/​src/​driver.rs Handles files and Git fallback.
crates/​mergify-json-merge/​tests/​driver.rs Tests driver fallback behavior.
crates/​mergify-cli/​Cargo.toml Adds the merge crate dependency.
crates/​mergify-cli/​src/​main.rs Registers and dispatches the command.
crates/​mergify-cli/​src/​snapshots/​mergify__tests__cli_schema_golden.snap Updates the CLI schema golden.
crates/​mergify-cli/​tests/​merge_driver_json.rs Tests real Git integration.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +412 to +415
} else if bs.len() == os.len()
&& bs.len() == ts.len()
&& !moved(bs, os)
&& !moved(bs, ts)
marker_size: Option<u32>,

/// Path being merged (git's `%P`), named in messages.
#[arg(long, value_name = "PATH")]
@mergify
mergify Bot requested a review from a team October 1, 2026 16:07
@jd jd closed this Oct 1, 2026

This branch had an error being deployed

1 failed and 1 active deployments
func-tests-live — bfc465e3 Deployed Oct 1, 2026 by jd via live-tests #1958
Mergify Merge Protections — bfc465e3 Deployed Oct 1, 2026 by mergify[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

2 participants