Skip to content

Two people changed the same manifest. How do I merge their changes and keep the history?

A person registry keys each person by an internal id. Two people change its manifest, starting from the same version. One decides that the social security number identifies a person and adds a flag, ssn_verified. The other decides that the email identifies a person and adds email_verified.

If the manifest is saved as one version after another, the second change overwrites the first. You want to keep both changes as history. You want to see the one real disagreement, which key identifies a person, next to what the manifest looked like before either change. Everything else should combine without a question, and the result should be recorded with both changes as its parents.

GraFlo records each change of a manifest as a commit, and merges two branches against the commit they both start from: a three-way merge. Combining two manifests that share no history is a different operation, the union shown in Combine two manifests.

flowchart LR
    base["97382d5d<br>track when a person was first seen"]
    ssn["f42a0133<br>key people by SSN"]
    email["78397faf<br>key people by email"]
    merged["f26ae87f<br>merge 78397faf into f42a0133"]
    base --> ssn --> merged
    base --> email --> merged

What you need

  • GraFlo installed (pip install graflo). No database is needed.

The data

There are no data files: a commit records a change of the manifest. The base, manifest.yaml, declares one vertex type:

-   name: person
    properties:
    -   name: id
        type: STRING
    -   name: ssn
        type: STRING
    -   name: email
        type: STRING
    identity: [id]

Steps

1. Record the two branches

cd examples/22-version-control
uv run python build_history.py
commit     kind     ops  label
97382d5d   edit     1    track when a person was first seen
78397faf   edit     2    key people by email  (head)
f42a0133   edit     2    key people by SSN  (head)

2 heads: the history has forked. Run merge_branches.py to merge them.

stored: artifacts/commits

build_history.py records three commits. A commit is a list of evolution operations (changes to the manifest) plus its parents:

by_ssn = build_commit(
    after_shared,
    [
        _rekey("ssn"),
        AddVertexPropertiesOp(additions={"person": ["ssn_verified"]}),
    ],
    parents=[shared.id],
    label="key people by SSN",
    created_at=STAMP,
)

_rekey("ssn") makes ssn the identity of person and keeps id as a plain property. The email branch is the same with email. Both have the same parent, 97382d5d, which adds created_at. Each commit id is computed from the commit's operations and parents, so you get the ids shown here. The history now has two heads: both changes are kept, and neither overwrites the other.

2. Merge the branches and decide the conflict

uv run python merge_branches.py
left  : f42a0133  key people by SSN
right : 78397faf  key people by email
base  : 97382d5d

1 conflict(s):
  slot   : vertex/person/identity
  reason : both sides changed this slot differently
  left   : ['replace_identity']
  right  : ['replace_identity']
  base   : identity ['id']

merged (took left):
  identity  : ['ssn']
  properties: ['id', 'ssn', 'email', 'created_at', 'ssn_verified', 'email_verified']
  hash      : fb66c0ec806b

merge commit: f26ae87f
  parents   : f42a0133, 78397faf
  recipe    : d7e4607459e1

heads after merging: 1
stored: artifacts/commits

merge_branches.py finds the base, the commit both branches start from, and compares each branch with it place by place. GraFlo calls such a place a slot. Both branches change the slot vertex/person/identity, in different ways, so that is a conflict, shown with the key it had in the base: ['id']. The two new properties are in different slots and are combined without a question.

The slot vertex/person/identity, with the operation of each branch and the state in the base

The script decides the conflict by taking the left side, the SSN branch. The merged manifest keys people by ssn and has both ssn_verified and email_verified. The merge commit f26ae87f has both branches as parents and stores the decision. Each branch had to add a property the other does not touch: if the key were the only change, taking one side would reproduce that side exactly, and GraFlo refuses to record a merge commit that changes nothing.

3. Change a branch and merge again

uv run python merge_branches.py --advance-left
left branch advanced: person gains 'nickname'
[...]
replayed the stored decision:
  note: 1 recorded resolution(s) replayed: vertex/person/identity

merged (stored decision):
  identity  : ['ssn']
  properties: ['id', 'ssn', 'email', 'created_at', 'ssn_verified', 'nickname', 'email_verified']
  hash      : 1cb2ab519fc0

The SSN branch moves on: person gains nickname. The same conflict comes back, and the decision stored in the merge commit is applied again instead of asking you. Only a conflict in a new slot would need a new decision. A stored decision whose slot no longer conflicts is reported as not needed and is not applied, because applying it could undo a change nobody disputes. This run stores nothing.

What you should see

artifacts/commits/ holds one YAML file per commit, and graflo log shows the history with the parents of each commit:

uv run graflo log --store artifacts/commits --graph
commit     kind     rev?  ops  label
97382d5d   edit     True  1    track when a person was first seen
78397faf   edit     True  2    key people by email
           └─ parents: 97382d5d
f42a0133   edit     True  2    key people by SSN
           └─ parents: 97382d5d
f26ae87f   merge3   True  1    merge 78397faf into f42a0133 (head)
           └─ parents: f42a0133, 78397faf

The merge commit stores one operation, the addition of email_verified: the change from its first parent, the SSN branch, to the merged manifest. That is what lets graflo verify --base manifest.yaml --store artifacts/commits replay the whole history from the base (history replays cleanly (4 commit(s), 1 head(s))), and graflo checkout --base manifest.yaml --store artifacts/commits f26ae87f rebuild the merged manifest (manifest hash: fb66c0ec806b).

uv run python merge_branches.py --take right decides for the email branch instead and stores that merge commit in place of f26ae87f: the identity becomes ['email'], and the hash (e294e3f07eb7) and the merge commit (6daeb51f) change with it.

Also possible

  • The same merge from the shell, on a store that holds the two branches: graflo merge3 f42a0133 78397faf --base manifest.yaml --store artifacts/commits lists the conflict and stops; add --take left to record the merge commit.
  • uv run python merge_branches.py --plot-dir figs draws the slot figure above and figs/merge-history.svg, the history with the base marked. graflo merge3 draws the same with --plot and --plot-history.

Files

The example lives in examples/22-version-control.

manifest.yaml
# The base manifest: a person keyed by an internal id. Two people will branch
# from it and disagree about what identifies a person.
schema:
    metadata:
        name: people
        version: 1.0.0
    graph:
        vertex_config:
            vertices:
            -   name: person
                properties:
                -   name: id
                    type: STRING
                -   name: ssn
                    type: STRING
                -   name: email
                    type: STRING
                identity: [id]
        edge_config:
            edges: []
ingestion_model:
    resources:
    -   name: people
        pipeline:
        -   vertex: person
    transforms: []
build_history.py
"""Two people changed the same manifest. How do I merge their changes and keep the history?

Records the history of ``manifest.yaml`` as commits: one change both people
start from, then one branch that keys people by SSN and one that keys them by
email. Writes one YAML file per commit to ``artifacts/commits/`` and prints
the log. ``merge_branches.py`` merges the two branches. Run it from this
directory:

    uv run python build_history.py            # write artifacts/commits/
    uv run python build_history.py --show     # print the stored log only
"""

from __future__ import annotations

import os
from pathlib import Path

import click
from suthing import FileHandle

from graflo import GraphManifest
from graflo.architecture.evolution import (
    FileCommitStore,
    History,
    apply_evolution,
    build_commit,
)
from graflo.architecture.evolution.ops import (
    AddVertexPropertiesOp,
    IdentityReplacement,
    NaturalIdentityTarget,
    ReplaceIdentityOp,
)

EXAMPLE_DIR = Path(__file__).resolve().parent
MANIFEST_PATH = EXAMPLE_DIR / "manifest.yaml"
STORE_ROOT = EXAMPLE_DIR / "artifacts" / "commits"

#: A commit id is computed from its operations and its parents. The timestamp
#: is fixed so that the stored commit files are the same on every run.
STAMP = "2026-01-01T00:00:00+00:00"


def load_base() -> GraphManifest:
    """The manifest every commit in this history descends from."""
    manifest = GraphManifest.from_config(FileHandle.load(MANIFEST_PATH))
    manifest.finish_init()
    return manifest


def _rekey(field: str) -> ReplaceIdentityOp:
    """Make *field* the identity of ``person``, keeping the old key as a property."""
    return ReplaceIdentityOp(
        replacements={
            "person": IdentityReplacement(
                to=NaturalIdentityTarget(identity=[field]), retire="keep"
            )
        }
    )


def build_history() -> tuple[GraphManifest, History]:
    """The base manifest and the forked history over it.

    Writes nothing: ``merge_branches.py`` imports this function, so both
    scripts work on the same commits.
    """
    base = load_base()

    # The change both branches start from: their common ancestor.
    shared = build_commit(
        base,
        [AddVertexPropertiesOp(additions={"person": ["created_at"]})],
        label="track when a person was first seen",
        created_at=STAMP,
    )
    after_shared = apply_evolution(
        base, list(shared.ops), bump_version=False, finish_init=False
    )

    # Each branch changes the key and also adds a property the other branch
    # does not touch. If the key were the only change, taking one side would
    # reproduce that side exactly, and a merge commit would have nothing to
    # record.
    by_ssn = build_commit(
        after_shared,
        [
            _rekey("ssn"),
            AddVertexPropertiesOp(additions={"person": ["ssn_verified"]}),
        ],
        parents=[shared.id],
        label="key people by SSN",
        created_at=STAMP,
    )
    by_email = build_commit(
        after_shared,
        [
            _rekey("email"),
            AddVertexPropertiesOp(additions={"person": ["email_verified"]}),
        ],
        parents=[shared.id],
        label="key people by email",
        created_at=STAMP,
    )
    return base, History(commits=[shared, by_ssn, by_email])


def print_log(history: History) -> None:
    """Print the history, oldest first, marking the heads."""
    heads = {head.id for head in history.heads()}
    click.echo(f"{'commit':<10} {'kind':<8} {'ops':<4} label")
    for commit in history.topological():
        marker = "  (head)" if commit.id in heads else ""
        click.echo(
            f"{commit.short():<10} {commit.kind:<8} {len(commit.ops):<4} "
            f"{commit.label}{marker}"
        )
    if len(heads) > 1:
        click.echo(
            f"\n{len(heads)} heads: the history has forked. "
            "Run merge_branches.py to merge them."
        )


@click.command()
@click.option(
    "--store",
    type=click.Path(path_type=Path),
    default=STORE_ROOT,
    show_default=True,
    help="Where the commits are written.",
)
@click.option("--show", is_flag=True, help="Print the stored log without rewriting it.")
def main(store: Path, show: bool) -> None:
    """Record the history, or print the one already recorded."""
    if show:
        print_log(FileCommitStore(store).load())
        return

    _base, history = build_history()
    FileCommitStore(store).save(history)
    print_log(history)
    click.echo(f"\nstored: {os.path.relpath(store)}")


if __name__ == "__main__":
    main()
merge_branches.py
"""Merge the two branches recorded by ``build_history.py`` against their base.

Finds the commit both branches start from, runs a three-way merge, prints the
conflict, resolves it by taking one side, and records a merge commit with both
parents in ``artifacts/commits/``. Run it from this directory:

    uv run python merge_branches.py                 # take the SSN branch
    uv run python merge_branches.py --take right    # take the email branch
    uv run python merge_branches.py --advance-left  # change a branch, merge again
    uv run python merge_branches.py --plot-dir figs # draw the slots and the history

``--advance-left`` adds a property to the SSN branch and merges again with the
decision stored in the merge commit, so the conflict is not asked a second
time. It stores nothing.
"""

from __future__ import annotations

import os
from pathlib import Path

import click
from build_history import STAMP, build_history

from graflo import GraphManifest
from graflo.architecture.evolution import (
    FileCommitStore,
    History,
    MergeRecipe,
    MergeRecipeRef,
    apply_evolution,
    build_multi_parent_commit,
    build_recipe,
    checkout,
    describe_slot,
    find_merge_base,
    manifest_hash,
    merge_three_way,
    re_merge,
    take_left,
    take_right,
)
from graflo.architecture.evolution.ops import AddVertexPropertiesOp
from graflo.architecture.schema.vertex import Vertex

EXAMPLE_DIR = Path(__file__).resolve().parent
STORE_ROOT = EXAMPLE_DIR / "artifacts" / "commits"


def _person(manifest: GraphManifest) -> Vertex:
    """The ``person`` vertex of *manifest*."""
    schema = manifest.graph_schema
    assert schema is not None
    return schema.core_schema.vertex_config.vertices[0]


def _print_merged(merged: GraphManifest, how: str) -> None:
    """Print the key, the properties and the hash of the merged manifest."""
    person = _person(merged)
    click.echo(f"\nmerged ({how}):")
    click.echo(f"  identity  : {person.identity}")
    click.echo(f"  properties: {person.property_names}")
    click.echo(f"  hash      : {manifest_hash(merged)[:12]}")


def _stored_recipe(store: Path) -> MergeRecipe:
    """The decision recorded in the merge commit of the stored history."""
    for commit in FileCommitStore(store).load().commits:
        if commit.merge_recipe is not None:
            return MergeRecipe.model_validate(commit.merge_recipe.payload)
    raise click.ClickException(
        "no merge commit is stored yet; run merge_branches.py without flags first"
    )


@click.command()
@click.option(
    "--plot-dir",
    type=click.Path(path_type=Path),
    default=None,
    help="Draw the slot tree and the commit history into this directory.",
)
@click.option(
    "--take",
    type=click.Choice(["left", "right"]),
    default="left",
    show_default=True,
    help="Which side wins the conflict.",
)
@click.option(
    "--advance-left",
    is_flag=True,
    help="Change the left branch, then merge again with the stored decision.",
)
@click.option(
    "--store",
    type=click.Path(path_type=Path),
    default=STORE_ROOT,
    show_default=True,
)
def main(plot_dir: Path | None, take: str, advance_left: bool, store: Path) -> None:
    """Merge the two heads, resolving the conflict on the identity."""
    base, history = build_history()

    heads = history.heads()
    if len(heads) != 2:
        raise click.ClickException(
            f"expected a forked history with two heads, found {len(heads)}"
        )
    # Sorted by label, so that the SSN branch is always the left side.
    left_commit, right_commit = sorted(heads, key=lambda commit: commit.label or "")

    click.echo(f"left  : {left_commit.short()}  {left_commit.label}")
    click.echo(f"right : {right_commit.short()}  {right_commit.label}")

    base_id = find_merge_base(history, left_commit.id, right_commit.id)
    if base_id is None:
        raise click.ClickException(
            "the two heads share no ancestor; combine unrelated manifests with "
            "a union (`graflo merge`) instead"
        )
    click.echo(f"base  : {base_id[:8]}\n")

    ancestor = checkout(base, history, base_id)
    left_state = checkout(base, history, left_commit.id)
    right_state = checkout(base, history, right_commit.id)

    if advance_left:
        left_state = apply_evolution(
            left_state,
            [AddVertexPropertiesOp(additions={"person": ["nickname"]})],
            bump_version=False,
            finish_init=False,
        )
        click.echo("left branch advanced: person gains 'nickname'\n")

    # The merge without a decision: it stops at the conflict.
    merged, result = merge_three_way(ancestor, left_state, right_state)

    if plot_dir is not None:
        # Drawn from the unresolved result, where the conflict is still open.
        # Drawing writes nothing to the store.
        _plot(
            result,
            history,
            plot_dir,
            heads=[left_commit.id, right_commit.id],
            merge_base=base_id,
        )
        return
    if merged is not None:
        raise click.ClickException("expected a conflict; the example is stale")

    click.echo(f"{len(result.conflicts)} conflict(s):")
    for conflict in result.conflicts:
        click.echo(f"  slot   : {describe_slot(conflict.slot_key)}")
        click.echo(f"  reason : {conflict.reason}")
        click.echo(f"  left   : {[op.op for op in conflict.left_ops]}")
        click.echo(f"  right  : {[op.op for op in conflict.right_ops]}")
        click.echo(f"  base   : identity {conflict.base_excerpt.get('identity')}")

    if advance_left:
        merged, result = re_merge(
            _stored_recipe(store), ancestor, left_state, right_state
        )
        click.echo("\nreplayed the stored decision:")
        for warning in result.warnings:
            click.echo(f"  note: {warning}")
        if merged is None:
            raise click.ClickException("the merge did not resolve")
        _print_merged(merged, "stored decision")
        click.echo("\n(not stored: --advance-left merges a changed branch)")
        return

    # The decision, kept in a recipe so that a later merge can replay it.
    chooser = take_left if take == "left" else take_right
    resolutions = [chooser(conflict) for conflict in result.conflicts]
    recipe = build_recipe(ancestor, left_state, right_state, resolutions=resolutions)
    merged, result = merge_three_way(
        ancestor, left_state, right_state, resolutions=resolutions
    )
    if merged is None:
        raise click.ClickException("the merge did not resolve")
    _print_merged(merged, f"took {take}")

    # The merge commit: both heads as parents, the recipe attached.
    commit = build_multi_parent_commit(
        left_state,
        merged,
        parents=[left_commit.id, right_commit.id],
        label=f"merge {right_commit.short()} into {left_commit.short()}",
        created_at=STAMP,
        merge_recipe=MergeRecipeRef(
            hash=recipe.content_hash(), kind=recipe.kind, payload=recipe.to_dict()
        ),
    )
    click.echo(f"\nmerge commit: {commit.short()}")
    click.echo(f"  parents   : {', '.join(p[:8] for p in commit.parents)}")
    click.echo(f"  recipe    : {recipe.content_hash()[:12]}")

    merged_history = History(commits=[*history.commits, commit])
    FileCommitStore(store).save(merged_history)
    click.echo(f"\nheads after merging: {len(merged_history.heads())}")
    click.echo(f"stored: {os.path.relpath(store)}")


def _plot(
    result,
    history: History,
    plot_dir: Path,
    *,
    heads: list[str],
    merge_base: str,
) -> None:
    """Draw where the branches met, and the history they met on."""
    from graflo.architecture.evolution.preview import build_merge3_preview
    from graflo.plot.merge3 import plot_history, plot_merge3_preview

    preview = build_merge3_preview(result)
    slots = plot_merge3_preview(preview, plot_dir / "merge-slots.svg")
    lineage = plot_history(
        history, plot_dir / "merge-history.svg", heads=heads, merge_base=merge_base
    )
    click.echo(f"slot tree ({preview.conflicts} contested) -> {os.path.relpath(slots)}")
    click.echo(f"history -> {os.path.relpath(lineage)}\n")


if __name__ == "__main__":
    main()