YAML Merge

Structural merge that keeps your comments, anchors, and block style intact.

Overview

YAML is JSON's cousin with opinions. It has comments, anchors, aliases, multi-document streams, block vs flow styles, multi-line scalars, and meaningful whitespace. A text merger destroys all of it at the first sign of indentation drift. A naive structural merger round-trips through a plain object and loses comments, anchor references, and the precise quoting choices your team relies on.

Aura's YAML merger sits in the middle: it is fully structural, node-aware, and preserves formatting, comments, and anchors through the merge. It uses the same three-way model as the other mergers — ancestor, ours, theirs — and it ships with presets for the YAML files you actually have in your repo: GitHub Actions workflows, Kubernetes manifests, Helm charts, Docker Compose files, CI configs.

YAML is not a data format. It is a configuration language with a personality. Merge it accordingly.

How It Works

Parse with events, not just values

Aura parses YAML using a grammar that emits both the structural tree and the attached trivia: comments (leading, trailing, footer), blank-line separators, flow vs block style choices, scalar style (plain, single-quoted, double-quoted, literal |, folded >), and anchor/alias relationships.

Each node carries:

Node {
  kind:     Scalar | Mapping | Sequence | Anchor | Alias,
  value:    normalized form,
  style:    Plain | Quoted("\"") | Literal | Folded | Flow | Block,
  leading:  comment[],
  trailing: comment[],
  anchor:   Option<"name">,
  alias_of: Option<"name">,
}

Three-way diff on the structural tree

Just like JSON deep merge, the YAML merger walks the three trees in lockstep, classifying changes at each path. The interaction table is the same. The difference is what counts as "modified":

  • Changing a scalar value → modified.
  • Changing only its quote style → not modified. (Aura notes the style change and picks ours's style on conflict.)
  • Adding a leading comment → not a structural modification, but tracked.
  • Reordering mapping keys → tracked as a reorder; never a conflict unless paired with structural edits.

Anchors and aliases

Anchors (&name) and aliases (*name) form a reference graph. A merge that breaks this graph would silently corrupt the document. Aura's algorithm:

  1. Collect the anchor table from all three versions.
  2. Merge anchor targets (the node labeled with the &) as normal mapping/sequence nodes.
  3. Aliases (*name) follow the merged target automatically.
  4. If both sides add different anchors with the same name, Aura renames the theirs-side anchor (name-2) and rewrites its aliases — never silently losing one.

Comments travel with their anchor point

Comments are attached to a semantic anchor: the key they precede, the sequence item they sit above, or the scalar they annotate. During merge, comments follow their anchor:

  • If the anchor node survives, its comments survive.
  • If both sides added leading comments to the same anchor, both are preserved in order (ours-first).
  • If both sides modified the exact same comment text, Aura raises a comment-level conflict — rare but real for doc-heavy YAML.

Serialize with the inherited style profile

After merge, Aura writes the tree back using a style profile learned from ancestor + ours: indent width (2 or 4), sequence dash indentation, flow vs block preference per path, scalar quoting habits, document-end ... markers. The output diffs cleanly against the ours side on all unchanged regions.

Examples

A: Two steps added to a GitHub Actions workflow

Ancestor .github/workflows/ci.yml:

name: CI
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm install
      - run: npm test

Ours adds a cache step:

    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache@v4
        with:
          path: node_modules
          key: ${{ hashFiles('package-lock.json') }}
      - run: npm install
      - run: npm test

Theirs adds a lint step:

    steps:
      - uses: actions/checkout@v4
      - run: npm install
      - run: npm run lint
      - run: npm test

With keyed:sequence-index this would conflict. With Aura's default for workflow steps — append-distinct keyed by the uses/run content — both additions interleave in the order the user would expect:

    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache@v4
        with:
          path: node_modules
          key: ${{ hashFiles('package-lock.json') }}
      - run: npm install
      - run: npm run lint
      - run: npm test

Comments above each step, if present, travel with the step.

B: Comments preserved

Ancestor:

# Retry policy for the payment worker
retries: 3
# Exponential base in seconds
backoff: 2

Ours:

# Retry policy for the payment worker
retries: 5
# Exponential base in seconds
backoff: 2

Theirs:

# Retry policy for the payment worker
retries: 3
# Exponential base in seconds
backoff: 4

Merged:

# Retry policy for the payment worker
retries: 5
# Exponential base in seconds
backoff: 4

Both comments intact. Line merge often drops the second comment when hunks straddle it.

C: Anchors and aliases

Ancestor:

defaults: &defaults
  timeout: 30
  retries: 3

dev:
  <<: *defaults
  host: dev.local

prod:
  <<: *defaults
  host: prod.example.com

Ours tightens defaults:

defaults: &defaults
  timeout: 15
  retries: 3

Theirs adds a field to defaults:

defaults: &defaults
  timeout: 30
  retries: 3
  user_agent: aura/1.0

Merged:

defaults: &defaults
  timeout: 15
  retries: 3
  user_agent: aura/1.0

dev:
  <<: *defaults
  host: dev.local

prod:
  <<: *defaults
  host: prod.example.com

Both aliases continue to resolve against the merged anchor.

D: Flow vs block style

Ancestor:

ports: [80, 443]

Ours switches to block style and adds a port:

ports:
  - 80
  - 443
  - 8080

Theirs adds a port in place:

ports: [80, 443, 8443]

Aura detects both as inserts into the same sequence. It applies both, chooses the ours-side style (block), and emits:

ports:
  - 80
  - 443
  - 8080
  - 8443

The style change is surfaced in the merge summary, not as a conflict.

E: Multi-document streams

Kubernetes manifests routinely hold multiple documents separated by ---. Aura merges documents by identity — apiVersion + kind + metadata.name + metadata.namespace — rather than by position. Adding a new Service in ours and a new ConfigMap in theirs merges both; editing the same Deployment on both sides raises a conflict scoped to that document.

Edge Cases

Mixed tabs and spaces. YAML disallows tabs for indentation. If any side introduces a tab, the parser errors and Aura falls back to text merge with a diagnostic.

Merge keys (<<:). Treated as structural inheritance, not as a scalar. Aura merges the referenced anchor and keeps the <<: in place.

Tags and custom types. !!str, !!int, !Secret — tags are preserved on nodes. A tag change on one side combined with a value edit on the other is a conflict; isolated tag changes follow the same take-theirs / keep-ours rules as values.

Duplicate keys. Invalid per spec but occasionally seen. Aura emits a warning and treats the last occurrence as authoritative for merge; the duplicated key in the output is preserved if both ancestor and merged copies still carry duplicates.

Very long scalars. Block scalars (|, >) preserve their chomping indicator (|-, |+, >-). Content edits within a block scalar fall back to line-level merge within that scalar — YAML does not give you finer structure than a literal string.

Round-trip fidelity. Aura passes a round-trip test on every supported YAML file in its corpus: parse, serialize, re-parse, check structural equality. The test is run as part of CI on every grammar or serializer change. Files failing round-trip are either excluded from AST merge or the adapter is patched — Aura never silently drops structure.

Custom schemas. For in-house YAML formats, .aura/merge.json accepts per-path strategy rules exactly like JSON. A workflow file might declare steps as append-distinct, env as keyed:name, and artifacts as union. Strategies cascade — an unspecified child path inherits its parent's rule.

Block scalar styles. The | (literal) and > (folded) indicators, along with chomping (-, +) and indentation indicators, are preserved verbatim on unchanged scalars. When one side edits a block scalar's content, Aura keeps the style from that side. When both sides edit content with differing styles, the conflict record surfaces both styles so the resolver can pick.

Stream separators and directives. Multi-document streams use --- separators and can declare %YAML 1.2 directives. Aura preserves the separator placement and any directive comments attached to it. A document added on one side appears after the existing documents, with a fresh separator, unless a positional hint (top-of-file header comment) indicates otherwise.

Empty values and missing keys. timeout: (no value, implicit null) and timeout: null parse to the same value but serialize differently. Aura preserves the observed form of the winning side; explicit null and implicit null do not cross-contaminate.

.j2 / Go templates inside YAML. Helm and Ansible embed Jinja or Go template syntax that is not valid YAML until rendered. Aura's grammar is tolerant of templated braces in scalars but errors on structural interpolation (template constructs spanning mapping keys). On failure, Aura falls back to text merge. See limitations and edge cases.

See Also