n-way-merge (0.3.5)

Published 2026-09-27 12:06:37 +00:00 by maximilianbarg in maximilianbarg/n-way-merge

Installation

pip install --index-url  n-way-merge

About this package

N way merge extending classic git merge behaviour

n-way-merge

N-way merge that extends classic git merge behaviour.

Classic git can only merge two sides against a base (3-way). This project merges iteratively: the result (including unresolved conflict blocks) is used as the base for the next merge, so conflicts can accumulate n versions.

Quick start

from n_way_merge import NWayMerger

merger = NWayMerger()

base = "a\nb\nc"
ours = "a\nX\nc"
theirs = "a\nY\nc"

merged = merger.merge(base, ours, theirs)

print(merged.to_git())
# a
# <<<<<<< ours
# X
# =======
# Y
# >>>>>>> theirs
# c

Merging more than two versions

Pass further versions as additional arguments. The first two are merged against the base; each additional version is merged into the accumulated result, so conflicts can accumulate n versions:

merged = merger.merge(base, ours, theirs, third, fourth)

print(merged.to_git_extended())

Merging versions one at a time

If versions arrive incrementally (e.g. one user at a time), keep the original base fixed and carry the accumulated result forward. Each new version is merged against the base; conflict blocks that already exist in the result are preserved, so they accumulate n versions just like with merge(base, …, *more):

base = "a\nb\nc\nd"
u1 = "a\nU1\nc\nd"
u2 = "a\nU2\nc\nd"
u3 = "a\nU3\nc\nd"

current = base
for user in (u1, u2, u3):
    current = merger.merge(base, current, user)

print(current.to_git_extended())
# a
# <<<<<<< ours
# U1
# =======
# U2
# =======
# U3
# >>>>>>> theirs
# c
# d

Existing conflict blocks in current are kept because conflict versions are read from the base, ours and theirs — not only from the base. That is what lets an accumulated result hold its conflicts even though the base is the original, conflict-free document.

Working with units directly

parse() is available to inspect or transform units before merging; every merge argument accepts both str and Units:

base_units = merger.parse(base)

merged = merger.merge(base_units, ours, merger.parse(theirs))

Output formats

Call any of them on the merge result:

merged.to_git()  # classic git conflict block (first 2 versions)
merged.to_git_extended()  # git conflict block with any number of versions
merged.to_structured()  # text with conflicts as inline JSON units
merged.to_json_string()  # whole document as JSON
merged.to_json_lines()  # list of unit dicts

Error handling

  • parse() and every merge() argument must be str or Units; anything else raises TypeError
  • Malformed documents raise ParseError (a ValueError subclass) with the 1-based line number of the offending line:
    • unclosed git conflict block (missing ======= or >>>>>>>)
    • unit line that looks like JSON but is not valid JSON
    • unit JSON with missing keys (base, versions, lines)
    • unknown unit type
from n_way_merge import NWayMerger, ParseError

try:
    merger.parse("a\n<<<<<<< ours\nX\n")
except ParseError as e:
    print(e)  # line 2: unclosed conflict block: missing '======='

How it works

  • A document is a list of units (see src/n_way_merge/units.py):
    • CleanUnit – a clean region of lines
    • ConflictUnit – a conflict with a base and one or more versions
  • Flow: parse(String) -> Doc -> merge(base, ours, theirs[, *more]) -> Doc -> to_*() -> String
  • merge() accepts str or parsed Units for every argument; the first three arguments form the classic 3-way merge, each further argument is one additional 3-way round on the accumulated result
  • Semantics per region:
    • ours == theirs (and != base) → resolved, clean (common value)
    • ours == theirs == base → unchanged; existing conflicts are kept
    • clean base region → classic 3-way (one side changed → clean, differing changes → conflict)
    • conflict region → conflict remains; versions = versions of any conflict block present in base/ours/theirs + ours/theirs changes (dedup, base does not count as a version, anchor units excluded)

Tests

just tests            # all tests
just tests --filter=unit   # filter by test directory
# or directly:
uv run pytest src/test/unit

Project layout

src/n_way_merge/
├── __init__.py        # package entry point (NWayMerger, ParseError)
├── errors.py          # ParseError
├── n_way_merger.py    # NWayMerger: parse + 3-way merge logic
├── unit_parser.py     # parses strings (git blocks/JSON) into units
├── units.py           # CleanUnit, ConflictUnit
├── merged.py          # Units document + serializers (Git/JSON/structured)
└── utils.py           # small helpers
src/test/unit/         # pytest tests

CI

Forgejo workflows in .forgejo/workflows/: tests, version tags and package publish.

Requirements

Requires Python: >=3.7
Details
PyPI
2026-09-27 12:06:37 +00:00
2
Maximilian Barg
19 KiB
Assets (2)
Versions (4) View all
0.4.1 2026-09-27
0.4.0 2026-09-27
0.3.5 2026-09-27
0.1.0 2026-09-27