Structural merge & semantic diff · Git-native

Merge by structure, not by line order.

Meridian is a domain-neutral structural merge and semantic diff toolkit for source files whose intent is richer than the order of their lines.

It parses XML, JSON, YAML, HTML, JavaScript, CSS, Liquid, binary payloads, and nested content into mergeable structure. Repositories supply the schema rules; Meridian supplies the Git-facing merge and diff behavior without absorbing product-specific knowledge.

View on GitHub See the merge case →
install
dotnet tool install --global MeridianGit
A merge driver that reads structure before it writes markers. Git reports this as one nearby-line conflict. Meridian reads the three file versions first, matches nodes by schema identity, and only writes markers when the same semantic node was changed incompatibly.
Clean merge
The problem

The conflict is not always where the disagreement is.

A plain text merge can treat two safe edits as a collision because both edits happened near the same lines. That is painful in generated-ish metadata, unpacked solution files, YAML manifests, templated files, and other source formats where order, identity, and nesting carry meaning.

Meridian changes the question. Instead of asking whether nearby lines changed, it asks which tree node changed, how that node is identified, and whether sibling order is actually semantic for that parent.

Not a sorter
Sorting the entries would dodge this one example. Meridian keeps order meaningful only where the schema says it is, because real files mix unordered settings and lookups with ordered pipelines, UI layout, and fallback precedence.
catalog.xmlXML
<<<<<<< ours (current change)<Product sku="atlas" status="preview">  <Name>Atlas Sensor Pro</Name></Product><Product sku="beacon" status="active"> ...||||||| base<Product sku="atlas" status="active"> ...<Product sku="beacon" status="active"> ...=======<Product sku="atlas" status="active"> ...<Product sku="beacon" status="active">  <Label languagecode="en">Beacon Control Hub</Label></Product><Product sku="caldera" status="active"> ...>>>>>>> theirs (incoming change)
One conflict block — yet atlas and beacon were edited by different people, and caldera is brand new.
The merge model

Small at the center, semantic at the edges.

Format adapters own physical syntax. Schemas own merge-relevant semantics. The merge engine works over trees and refuses to invent identity when the repository has not supplied enough information.

01 · Parse
Three Git inputs
base
ours
theirs
02 · Identify
Semantic trees
Catalog
Product[atlas]
Product[beacon]
Label[en]
ProductRef[caldera]
matched by sku · languagecode
03 · Diff
Change vs base
ours · atlas → preview
theirs · beacon → +labels
theirs · +caldera, appended
different slots, no overlap
04 · Merge
Identity slots
atlas = preview
beacon + labels
DisplayOrder += caldera
all applied, clean
01

Parse base, ours, and theirs as files

The XML adapter turns each of the three catalog.xml versions into a document tree of node kind, fields, scalar value, children, and identity metadata — three structures, not three blobs of text.

02

Extract semantic trees by identity

Schema discriminators name each node: Product[sku=atlas], Product[sku=beacon], Label[languagecode=en], ProductRef[sku=caldera]. Ambiguous repeated nodes fail loudly rather than fall back to positional guesses.

03

Compare each side against base

Ours changed atlas; theirs changed beacon, added caldera, and appended it to display order. Each diff is taken against base, so unrelated edits never look like a collision.

04

Merge by identity slots

Different nodes combine cleanly, and the ordered DisplayOrder accepts the one-sided append. The result is rendered back through the XML adapter; only a genuine same-node clash would ever write a marker.

Meridian schema

The merge above was made possible by this schema.

No model guesses what sku means. A repository commits one small, readable schema next to catalog.xml, and every merge and diff reads it.

catalog.meridian.yamlYAML
$schema: …/schemas/meridian.schema.jsonschemaVersion: 0.1name: catalog defaults:  globalDiscriminatorFields:    - id    - languagecode files:  - match: catalog.xml    root: Catalog    discriminators:      - path: Catalog/Products/Product        key:          attribute: sku      - path: Catalog/DisplayOrder/ProductRef        key:          attribute: sku    orderedChildren:      - Catalog/DisplayOrder
globalDiscriminatorFields

Sibling Label nodes are told apart by languagecode, so the en and fr labels merge as separate slots.

Product · key: sku

atlas, beacon, and caldera are matched by sku — one side's edit to atlas can never collide with the other's edit to beacon.

ProductRef · key: sku

Display references are aligned by the product they point at, not by their position in the list.

orderedChildren: Catalog/DisplayOrder

Order matters here only. The one-sided append of caldera is accepted; two sides reordering it differently would be reported as a conflict, never silently sorted.

Format support

Every format is a plugin.

The core knows nothing about syntax. Each format is an adapter that implements one small contract — recognise a file, parse its text into a structural tree, and render a tree back to text, preserving the original formatting wherever it can.

Adapters are discovered at runtime, so support is additive. Drop in an adapter for a proprietary or in-house format and it takes part in structural merge, semantic diff, and recursive nesting exactly like the formats that ship in the box — no changes to the engine.

Built-in adapters ship as grouped provider packages — MeridianGit.Formats.Markup, .Web, .Images, .PowerPlatform, and .Binary. Your own adapter just implements MeridianGit.Abstractions.

The adapter contract discovered at runtime
Source text
.xml .json .yaml
parse render
Format adapter
detect parse render
Structural tree merge engine

Shipped as provider bundles

Grouped MeridianGit.Formats.* packages — install only what a repo needs.
Recursive formats

A value can be a whole document.

Real source files smuggle one format inside another — a JSON payload in an XML attribute, an HTML fragment in JSON, an escaped JSON string nested inside that. To a line merge it is one opaque blob. To Meridian it is a tree with more trees inside it.

When an adapter recognises a scalar as another format, it parses that payload as its own document — under its own schema — and merges it at that depth. The clean result is re-embedded through the parent adapter, which owns the quoting and escaping, so encoding stays correct all the way back up.

Schemas compose the same way. An outer schema points an inner payload at the schema that governs it, and Meridian traverses that chain — each level keeping its own identity and ordering rules.

The XML adapter never has to know what the JSON inside means. Each format stays responsible for exactly one layer — and only the layer that actually changed is touched.
XMLweb.config dotnet-config
<add key="FeatureFlags" value="{&quot;enabled&quot;:true,&quot;ru…" />
↳ the value scalar parses as JSON
JSONvalue feature-flags
{ "enabled": true, "rules": "{\"minVersion\":\"3…" }
↳ the rules string is escaped JSON
JSONrules · escaped release
{ "minVersion": "3.1", "stage": "beta" }
Parsed down through every layer, merged at depth, then re-embedded back up with escaping intact.
Design constraints

Constraints that keep the merge honest.

The tool is intentionally conservative. It exists to preserve semantic intent across source-controlled files, not to smooth over uncertainty by guessing.

Quick start

Install it and wire it into Git.

Install the meridian CLI, register it as a Git merge and diff driver, and commit your schema files alongside the source they describe.

1 · Install the CLI

Add Meridian as a global .NET tool — the meridian command lands on your PATH.

install · global tool
dotnet tool install --global MeridianGitmeridian --version

2 · Run a merge by hand

Invoke a three-way structural merge directly — handy for trying a schema before wiring it into Git.

meridian merge · three-way
meridian merge \  --base base.xml \  --ours ours.xml \  --theirs theirs.xml \  --path catalog.xml

3 · Wire it into Git

Register the merge and diff driver, then opt files in so every clone merges and diffs structurally.

.gitattributesINI
[merge "meridian"]    name = Meridian structural merge    driver = meridian merge \      --base %O --ours %A --theirs %B --path %P *.config merge=meridian diff=meridian*.xml    merge=meridian diff=meridian*.json   merge=meridian diff=meridian

4 · Minimal schema shape

Schemas describe merge-relevant facts: identity, ordering, nested content, companion payloads, and aliases.

config.meridian.yamlYAML
schemaVersion: 0.1name: dotnet-config defaults:  globalDiscriminatorFields:    - key    - name files:  - match: "*.config"    discriminators:      - path: configuration/appSettings/add        key: {attribute: key}    orderedChildren:      - configuration/system.webServer/handlers
Read the README Architecture notes Schema contract