- Python 99.7%
- Just 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| docs | ||
| src | ||
| .gitignore | ||
| .python-version | ||
| AGENTS.md | ||
| justfile | ||
| pyproject.toml | ||
| README.md | ||
| renovate.json5 | ||
| uv.lock | ||
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 everymerge()argument must bestrorUnits; anything else raisesTypeError- Malformed documents raise
ParseError(aValueErrorsubclass) with the 1-basedlinenumber 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
- unclosed git conflict block (missing
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 linesConflictUnit– a conflict with a base and one or more versions
- Flow:
parse(String) -> Doc -> merge(base, ours, theirs[, *more]) -> Doc -> to_*() -> String merge()acceptsstror parsedUnitsfor 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)
- A merge neither adds nor removes a trailing newline: lines are normalized
at parse time and the original state is carried in
Units.ends_with_newline— see docs/newline-handling.md
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
docs/ # architectural decisions (newline handling)
CI
Forgejo workflows in .forgejo/workflows/: tests, version tags and package publish.