Metadata-Version: 2.3
Name: n-way-merge
Version: 0.4.1
Summary: N way merge extending classic git merge behaviour
Author: Maximilian Barg
Author-email: Maximilian Barg <maxi@downisaurier.de>
Requires-Python: >=3.7
Description-Content-Type: text/markdown

# 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

```python
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:

```python
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)`:

```python
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`:

```python
base_units = merger.parse(base)

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

### Output formats

Call any of them on the merge result:

```python
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

```python
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)
- 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](docs/newline-handling.md)

## Tests

```sh
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.
