kvgit 0.3.5__tar.gz → 0.3.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {kvgit-0.3.5 → kvgit-0.3.6}/PKG-INFO +1 -1
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/__init__.py +5 -0
- kvgit-0.3.6/kvgit/merges.py +247 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit.egg-info/PKG-INFO +1 -1
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit.egg-info/SOURCES.txt +2 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/pyproject.toml +1 -1
- kvgit-0.3.6/tests/test_merges.py +300 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/LICENSE +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/README.md +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/codecs/__init__.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/codecs/_hash.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/codecs/base.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/codecs/numpy.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/codecs/pandas.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/codecs/pickler.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/content_types.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/encoding.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/errors.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/hamt.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/kv/__init__.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/kv/base.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/kv/composite.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/kv/disk.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/kv/indexeddb.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/kv/memory.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/namespaced.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/py.typed +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/staged.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/store.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/versioned/__init__.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/versioned/base.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/versioned/helpers.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/versioned/keyset.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/versioned/kv.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/versioned/merge.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/versioned/protocol.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit.egg-info/dependency_links.txt +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit.egg-info/requires.txt +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/kvgit.egg-info/top_level.txt +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/setup.cfg +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/tests/test_content_types.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/tests/test_delete_branches.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/tests/test_hamt.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/tests/test_namespaced.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/tests/test_staged.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.6}/tests/test_store_factory.py +0 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from .content_types import MergeFn, counter, last_writer_wins
|
|
4
4
|
from .errors import ConcurrencyError, MergeConflict
|
|
5
|
+
from .merges import CantMark, TextMergeFn, make_text_merge, text
|
|
5
6
|
from .namespaced import Namespaced
|
|
6
7
|
from .staged import Staged
|
|
7
8
|
from .store import delete_branches, delete_tags, store
|
|
@@ -9,6 +10,7 @@ from .versioned.kv import VersionedKV
|
|
|
9
10
|
from .versioned.protocol import MergeResult, TagInfo, Versioned
|
|
10
11
|
|
|
11
12
|
__all__ = [
|
|
13
|
+
"CantMark",
|
|
12
14
|
"ConcurrencyError",
|
|
13
15
|
"MergeConflict",
|
|
14
16
|
"MergeFn",
|
|
@@ -16,11 +18,14 @@ __all__ = [
|
|
|
16
18
|
"Namespaced",
|
|
17
19
|
"Staged",
|
|
18
20
|
"TagInfo",
|
|
21
|
+
"TextMergeFn",
|
|
19
22
|
"Versioned",
|
|
20
23
|
"VersionedKV",
|
|
21
24
|
"counter",
|
|
22
25
|
"delete_branches",
|
|
23
26
|
"delete_tags",
|
|
24
27
|
"last_writer_wins",
|
|
28
|
+
"make_text_merge",
|
|
25
29
|
"store",
|
|
30
|
+
"text",
|
|
26
31
|
]
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Text merge with conflict markers.
|
|
2
|
+
|
|
3
|
+
A batteries-included ``BytesMergeFn`` for line-oriented text: disjoint
|
|
4
|
+
changes merge cleanly, overlapping changes come back with git-style
|
|
5
|
+
``<<<<<<<`` markers instead of raising. Register it as a per-key fn or
|
|
6
|
+
as ``default_merge`` wherever text blobs merge (see ``resolve_merge``).
|
|
7
|
+
|
|
8
|
+
Anything that cannot be marked — undecodable bytes, NUL bytes, inputs
|
|
9
|
+
over the size cap — raises :class:`CantMark`, which the merge machinery
|
|
10
|
+
files as an ordinary conflict. Callers that need to tell "no encoding"
|
|
11
|
+
from "too big" catch it themselves; callers that don't let it flow.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import difflib
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
|
|
19
|
+
from .versioned.protocol import BytesMergeFn
|
|
20
|
+
|
|
21
|
+
#: Inputs whose combined size exceeds this are refused with
|
|
22
|
+
#: :class:`CantMark` rather than merged. Line-diffing is quadratic-ish
|
|
23
|
+
#: in the worst case (minified single-line megabytes); a merge this big
|
|
24
|
+
#: is a whole-file conflict by inspection anyway.
|
|
25
|
+
MAX_MARK_BYTES = 1024 * 1024
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CantMark(Exception):
|
|
29
|
+
"""A value cannot be marker-merged: not text, or too big.
|
|
30
|
+
|
|
31
|
+
Raised instead of returning bytes when the inputs are undecodable as
|
|
32
|
+
UTF-8, contain NUL bytes, or exceed ``MAX_MARK_BYTES`` combined.
|
|
33
|
+
The merge machinery reports it as a conflict for that key.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _decode(data: bytes | None) -> list[str]:
|
|
38
|
+
"""Split one side into lines; a removed side is the empty list.
|
|
39
|
+
|
|
40
|
+
The markers then show one populated side against nothing, which
|
|
41
|
+
reads correctly.
|
|
42
|
+
"""
|
|
43
|
+
if data is None:
|
|
44
|
+
return []
|
|
45
|
+
if b"\x00" in data:
|
|
46
|
+
raise CantMark("NUL byte: not text")
|
|
47
|
+
try:
|
|
48
|
+
text = data.decode("utf-8")
|
|
49
|
+
except UnicodeDecodeError as e:
|
|
50
|
+
raise CantMark(f"not UTF-8: {e}") from e
|
|
51
|
+
return text.splitlines(keepends=True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _changes(base: list[str], side: list[str]) -> list[tuple[int, int, list[str]]]:
|
|
55
|
+
"""Non-equal opcode intervals of ``side`` against ``base``.
|
|
56
|
+
|
|
57
|
+
Each tuple is ``(base_start, base_end, replacement_lines)``;
|
|
58
|
+
pure insertions have ``base_start == base_end``.
|
|
59
|
+
"""
|
|
60
|
+
matcher = difflib.SequenceMatcher(None, base, side, autojunk=False)
|
|
61
|
+
return [
|
|
62
|
+
(i1, i2, side[j1:j2])
|
|
63
|
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes()
|
|
64
|
+
if tag != "equal"
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _terminate(lines: list[str]) -> list[str]:
|
|
69
|
+
"""Ensure every non-final line ends with a newline.
|
|
70
|
+
|
|
71
|
+
A line without a trailing newline glued onto following output would
|
|
72
|
+
corrupt both; clean regions stay byte-exact (a final line keeps its
|
|
73
|
+
missing newline), markers pay the newline tax.
|
|
74
|
+
"""
|
|
75
|
+
fixed: list[str] = []
|
|
76
|
+
for i, ln in enumerate(lines):
|
|
77
|
+
if not ln.endswith("\n") and i + 1 < len(lines):
|
|
78
|
+
ln += "\n"
|
|
79
|
+
fixed.append(ln)
|
|
80
|
+
return fixed
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _replay(base: list[str], start: int, end: int, events: list[tuple]) -> list[str]:
|
|
84
|
+
"""One side's full content over ``base[start:end]``.
|
|
85
|
+
|
|
86
|
+
Base lines outside the side's own hunks, replacement lines inside.
|
|
87
|
+
Same-side events come from one SequenceMatcher pass, so they are
|
|
88
|
+
disjoint and the replay is well-defined. Without this, overlapping
|
|
89
|
+
groups spanning different base ranges would emit only replacement
|
|
90
|
+
lines and silently drop the base lines between them.
|
|
91
|
+
"""
|
|
92
|
+
out: list[str] = []
|
|
93
|
+
pos = start
|
|
94
|
+
for _, s, e, lines in sorted(events, key=lambda ev: (ev[1], ev[2])):
|
|
95
|
+
out.extend(base[pos:s])
|
|
96
|
+
out.extend(lines)
|
|
97
|
+
pos = max(pos, e)
|
|
98
|
+
out.extend(base[pos:end])
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _merge_lines(
|
|
103
|
+
base: list[str],
|
|
104
|
+
ours: list[str],
|
|
105
|
+
theirs: list[str],
|
|
106
|
+
ours_label: str,
|
|
107
|
+
theirs_label: str,
|
|
108
|
+
ours_deleted: bool = False,
|
|
109
|
+
theirs_deleted: bool = False,
|
|
110
|
+
) -> tuple[list[str], bool]:
|
|
111
|
+
"""Three-way line merge. Returns ``(lines, conflicted)``.
|
|
112
|
+
|
|
113
|
+
The ``*_deleted`` flags record raw ``None`` inputs (a removed side),
|
|
114
|
+
tracked separately from decoded emptiness: an empty base file that
|
|
115
|
+
one side deletes and the other writes is a modify/delete conflict,
|
|
116
|
+
not a clean add. Still-absent on an added/added merge (base itself
|
|
117
|
+
missing) is not a deletion.
|
|
118
|
+
"""
|
|
119
|
+
if ours == theirs:
|
|
120
|
+
return list(ours), False
|
|
121
|
+
|
|
122
|
+
# Whole-file deletion on one side against any change on the other is
|
|
123
|
+
# a modify/delete conflict with the survivor shown whole — the
|
|
124
|
+
# interval machinery below only sees hunks and would drop the
|
|
125
|
+
# surviving context. (Unchanged-vs-deleted resolves above through
|
|
126
|
+
# the empty-changes fast paths.)
|
|
127
|
+
if ours_deleted and theirs != base:
|
|
128
|
+
return _terminate(
|
|
129
|
+
[
|
|
130
|
+
f"<<<<<<< {ours_label}\n",
|
|
131
|
+
"=======\n",
|
|
132
|
+
*theirs,
|
|
133
|
+
f">>>>>>> {theirs_label}\n",
|
|
134
|
+
]
|
|
135
|
+
), True
|
|
136
|
+
if theirs_deleted and ours != base:
|
|
137
|
+
return _terminate(
|
|
138
|
+
[
|
|
139
|
+
f"<<<<<<< {ours_label}\n",
|
|
140
|
+
*ours,
|
|
141
|
+
"=======\n",
|
|
142
|
+
f">>>>>>> {theirs_label}\n",
|
|
143
|
+
]
|
|
144
|
+
), True
|
|
145
|
+
|
|
146
|
+
our_changes = _changes(base, ours)
|
|
147
|
+
their_changes = _changes(base, theirs)
|
|
148
|
+
if not our_changes:
|
|
149
|
+
return list(theirs), False
|
|
150
|
+
if not their_changes:
|
|
151
|
+
return list(ours), False
|
|
152
|
+
|
|
153
|
+
# Merge overlapping change intervals into conflict groups; disjoint
|
|
154
|
+
# intervals resolve independently. Intervals are half-open over base
|
|
155
|
+
# positions; pure insertions are points. Join on strict overlap, plus
|
|
156
|
+
# same-point inserts colliding with each other — an insert merely
|
|
157
|
+
# adjacent to a change interval resolves cleanly beside it, while two
|
|
158
|
+
# different inserts at one point genuinely conflict.
|
|
159
|
+
events = [("ours", s, e, lines) for s, e, lines in our_changes]
|
|
160
|
+
events += [("theirs", s, e, lines) for s, e, lines in their_changes]
|
|
161
|
+
events.sort(key=lambda ev: (ev[1], ev[2]))
|
|
162
|
+
|
|
163
|
+
# Each group tracks its member events plus its max base end, since
|
|
164
|
+
# overlap is against the group's full span, not the last event.
|
|
165
|
+
groups: list[list[tuple]] = []
|
|
166
|
+
group_ends: list[int] = []
|
|
167
|
+
for ev in events:
|
|
168
|
+
_, s, e, _ = ev
|
|
169
|
+
if groups and (
|
|
170
|
+
s < group_ends[-1]
|
|
171
|
+
or (s == e and any(g[1] == g[2] == s for g in groups[-1]))
|
|
172
|
+
):
|
|
173
|
+
groups[-1].append(ev)
|
|
174
|
+
group_ends[-1] = max(group_ends[-1], e)
|
|
175
|
+
else:
|
|
176
|
+
groups.append([ev])
|
|
177
|
+
group_ends.append(e)
|
|
178
|
+
|
|
179
|
+
out: list[str] = []
|
|
180
|
+
conflicted = False
|
|
181
|
+
pos = 0
|
|
182
|
+
for group in groups:
|
|
183
|
+
start = min(ev[1] for ev in group)
|
|
184
|
+
end = max(ev[2] for ev in group)
|
|
185
|
+
out.extend(base[pos:start])
|
|
186
|
+
ours_ev = [ev for ev in group if ev[0] == "ours"]
|
|
187
|
+
theirs_ev = [ev for ev in group if ev[0] == "theirs"]
|
|
188
|
+
if not theirs_ev:
|
|
189
|
+
out.extend(_replay(base, start, end, ours_ev))
|
|
190
|
+
elif not ours_ev:
|
|
191
|
+
out.extend(_replay(base, start, end, theirs_ev))
|
|
192
|
+
else:
|
|
193
|
+
ours_full = _replay(base, start, end, ours_ev)
|
|
194
|
+
theirs_full = _replay(base, start, end, theirs_ev)
|
|
195
|
+
if ours_full == theirs_full:
|
|
196
|
+
out.extend(ours_full)
|
|
197
|
+
else:
|
|
198
|
+
conflicted = True
|
|
199
|
+
out.append(f"<<<<<<< {ours_label}\n")
|
|
200
|
+
out.extend(ours_full)
|
|
201
|
+
out.append("=======\n")
|
|
202
|
+
out.extend(theirs_full)
|
|
203
|
+
out.append(f">>>>>>> {theirs_label}\n")
|
|
204
|
+
pos = end
|
|
205
|
+
out.extend(base[pos:])
|
|
206
|
+
|
|
207
|
+
return _terminate(out), conflicted
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def make_text_merge(
|
|
211
|
+
*, ours_label: str = "ours", theirs_label: str = "theirs"
|
|
212
|
+
) -> BytesMergeFn:
|
|
213
|
+
"""Build a marker-merge fn with custom conflict labels.
|
|
214
|
+
|
|
215
|
+
Labels ride git's positions (``<<<<<<< <ours>`` /
|
|
216
|
+
``>>>>>>> <theirs>``); pass branch names so conflicts read
|
|
217
|
+
attributably. Labels may not contain newlines.
|
|
218
|
+
"""
|
|
219
|
+
for name, label in (("ours_label", ours_label), ("theirs_label", theirs_label)):
|
|
220
|
+
if "\n" in label:
|
|
221
|
+
raise ValueError(f"{name} may not contain a newline: {label!r}")
|
|
222
|
+
|
|
223
|
+
def merge(old: bytes | None, ours: bytes | None, theirs: bytes | None) -> bytes:
|
|
224
|
+
total = sum(len(d) for d in (old, ours, theirs) if d is not None)
|
|
225
|
+
if total > MAX_MARK_BYTES:
|
|
226
|
+
raise CantMark(f"inputs total {total} bytes over cap {MAX_MARK_BYTES}")
|
|
227
|
+
merged, _ = _merge_lines(
|
|
228
|
+
_decode(old),
|
|
229
|
+
_decode(ours),
|
|
230
|
+
_decode(theirs),
|
|
231
|
+
ours_label,
|
|
232
|
+
theirs_label,
|
|
233
|
+
ours_deleted=ours is None and old is not None,
|
|
234
|
+
theirs_deleted=theirs is None and old is not None,
|
|
235
|
+
)
|
|
236
|
+
return "".join(merged).encode("utf-8")
|
|
237
|
+
|
|
238
|
+
return merge
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def text(old: bytes | None, ours: bytes | None, theirs: bytes | None) -> bytes:
|
|
242
|
+
"""Marker-merge with default labels. Usable as ``default_merge`` directly."""
|
|
243
|
+
return make_text_merge()(old, ours, theirs)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
TextMergeFn = Callable[[bytes | None, bytes | None, bytes | None], bytes]
|
|
247
|
+
"""Type of what :func:`make_text_merge` builds (== ``BytesMergeFn``)."""
|
|
@@ -6,6 +6,7 @@ kvgit/content_types.py
|
|
|
6
6
|
kvgit/encoding.py
|
|
7
7
|
kvgit/errors.py
|
|
8
8
|
kvgit/hamt.py
|
|
9
|
+
kvgit/merges.py
|
|
9
10
|
kvgit/namespaced.py
|
|
10
11
|
kvgit/py.typed
|
|
11
12
|
kvgit/staged.py
|
|
@@ -37,6 +38,7 @@ kvgit/versioned/protocol.py
|
|
|
37
38
|
tests/test_content_types.py
|
|
38
39
|
tests/test_delete_branches.py
|
|
39
40
|
tests/test_hamt.py
|
|
41
|
+
tests/test_merges.py
|
|
40
42
|
tests/test_namespaced.py
|
|
41
43
|
tests/test_staged.py
|
|
42
44
|
tests/test_store_factory.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "kvgit"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.6"
|
|
8
8
|
description = "Versioned key-value store with git-like commit, branch, and merge semantics."
|
|
9
9
|
authors = [{ name = "ashenfad" }]
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Tests for kvgit.merges: marker-merge with git-verified expectations."""
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from kvgit import MergeConflict, VersionedKV as Versioned
|
|
12
|
+
from kvgit.kv.memory import Memory
|
|
13
|
+
from kvgit.merges import CantMark, make_text_merge, text
|
|
14
|
+
|
|
15
|
+
GIT = shutil.which("git")
|
|
16
|
+
needs_git = pytest.mark.skipif(GIT is None, reason="git binary not available")
|
|
17
|
+
|
|
18
|
+
_ALPHABET = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", ""]
|
|
19
|
+
|
|
20
|
+
BASE = b"a\nb\nc\nd\n"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestCleanMerges:
|
|
24
|
+
def test_disjoint_edits_merge(self):
|
|
25
|
+
assert text(BASE, b"a\nB\nc\nd\n", b"a\nb\nc\nD\n") == b"a\nB\nc\nD\n"
|
|
26
|
+
|
|
27
|
+
def test_identical_edits_agree(self):
|
|
28
|
+
assert text(BASE, b"a\nX\nc\nd\n", b"a\nX\nc\nd\n") == b"a\nX\nc\nd\n"
|
|
29
|
+
|
|
30
|
+
def test_one_side_unchanged(self):
|
|
31
|
+
assert text(BASE, BASE, b"a\nb\nc\nD\n") == b"a\nb\nc\nD\n"
|
|
32
|
+
assert text(BASE, b"a\nB\nc\nd\n", BASE) == b"a\nB\nc\nd\n"
|
|
33
|
+
|
|
34
|
+
def test_added_one_side(self):
|
|
35
|
+
assert text(None, None, b"new\n") == b"new\n"
|
|
36
|
+
assert text(None, b"new\n", None) == b"new\n"
|
|
37
|
+
|
|
38
|
+
def test_both_deleted(self):
|
|
39
|
+
assert text(BASE, None, None) == b""
|
|
40
|
+
|
|
41
|
+
def test_deleted_vs_unchanged_deletes(self):
|
|
42
|
+
assert text(BASE, None, BASE) == b""
|
|
43
|
+
assert text(BASE, BASE, None) == b""
|
|
44
|
+
|
|
45
|
+
def test_no_trailing_newline_preserved(self):
|
|
46
|
+
assert text(b"a\nb", b"a\nB", b"a\nb") == b"a\nB"
|
|
47
|
+
|
|
48
|
+
def test_empty_inputs(self):
|
|
49
|
+
assert text(b"", b"", b"") == b""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TestConflicts:
|
|
53
|
+
def test_overlap_markers(self):
|
|
54
|
+
out = text(BASE, b"a\nX\nc\nd\n", b"a\nY\nc\nd\n")
|
|
55
|
+
assert out == b"a\n<<<<<<< ours\nX\n=======\nY\n>>>>>>> theirs\nc\nd\n"
|
|
56
|
+
|
|
57
|
+
def test_added_added_different(self):
|
|
58
|
+
out = text(None, b"hello\n", b"world\n")
|
|
59
|
+
assert out == b"<<<<<<< ours\nhello\n=======\nworld\n>>>>>>> theirs\n"
|
|
60
|
+
|
|
61
|
+
def test_added_added_same(self):
|
|
62
|
+
assert text(None, b"same\n", b"same\n") == b"same\n"
|
|
63
|
+
|
|
64
|
+
def test_delete_vs_modify(self):
|
|
65
|
+
out = text(BASE, None, b"a\nb\nc\nD\n")
|
|
66
|
+
assert out == (b"<<<<<<< ours\n=======\na\nb\nc\nD\n>>>>>>> theirs\n")
|
|
67
|
+
|
|
68
|
+
def test_modify_vs_delete(self):
|
|
69
|
+
out = text(BASE, b"a\nB\nc\nd\n", None)
|
|
70
|
+
assert out == (b"<<<<<<< ours\na\nB\nc\nd\n=======\n>>>>>>> theirs\n")
|
|
71
|
+
|
|
72
|
+
def test_custom_labels(self):
|
|
73
|
+
fn = make_text_merge(ours_label="main", theirs_label="dev")
|
|
74
|
+
out = fn(BASE, b"a\nX\nc\nd\n", b"a\nY\nc\nd\n")
|
|
75
|
+
assert b"<<<<<<< main\n" in out
|
|
76
|
+
assert b">>>>>>> dev\n" in out
|
|
77
|
+
|
|
78
|
+
def test_label_newline_rejected(self):
|
|
79
|
+
with pytest.raises(ValueError):
|
|
80
|
+
make_text_merge(ours_label="a\nb")
|
|
81
|
+
|
|
82
|
+
def test_deterministic(self):
|
|
83
|
+
args = (BASE, b"a\nX\nc\nd\n", b"a\nY\nc\nd\n")
|
|
84
|
+
assert text(*args) == text(*args)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TestReviewRegression:
|
|
88
|
+
"""Regression tests for PR #34 review findings (all reproduced)."""
|
|
89
|
+
|
|
90
|
+
def test_overlapping_groups_keep_full_span(self):
|
|
91
|
+
"""P1: overlapping hunks over different base spans must show each
|
|
92
|
+
side's full content over the union — resolving to ours must not
|
|
93
|
+
drop unchanged lines (here: `c`). Verified byte-identical to
|
|
94
|
+
`git merge-file`."""
|
|
95
|
+
out = text(b"a\nb\nc\n", b"a\nB\nc\n", b"a\nY\nZ\n")
|
|
96
|
+
assert out == b"a\n<<<<<<< ours\nB\nc\n=======\nY\nZ\n>>>>>>> theirs\n"
|
|
97
|
+
|
|
98
|
+
def test_empty_base_delete_vs_write_conflicts(self):
|
|
99
|
+
"""P2: an empty base file one side deletes and the other writes
|
|
100
|
+
is modify/delete, not a clean add."""
|
|
101
|
+
out = text(b"", None, b"x\n")
|
|
102
|
+
assert out == b"<<<<<<< ours\n=======\nx\n>>>>>>> theirs\n"
|
|
103
|
+
out = text(b"", b"x\n", None)
|
|
104
|
+
assert out == b"<<<<<<< ours\nx\n=======\n>>>>>>> theirs\n"
|
|
105
|
+
|
|
106
|
+
def test_unterminated_survivor_terminated(self):
|
|
107
|
+
"""P2: whole-file delete/modify where the survivor lacks a trailing
|
|
108
|
+
newline must not glue content to the closing marker."""
|
|
109
|
+
assert text(b"a", None, b"B") == b"<<<<<<< ours\n=======\nB\n>>>>>>> theirs\n"
|
|
110
|
+
assert text(b"a", b"B", None) == b"<<<<<<< ours\nB\n=======\n>>>>>>> theirs\n"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class TestDocumentedDivergence:
|
|
114
|
+
def test_adjacent_insert_merges_clean(self):
|
|
115
|
+
"""Ours changes line 2, theirs inserts right after it.
|
|
116
|
+
|
|
117
|
+
git merge-file reports a conflict here (hunk-context overlap);
|
|
118
|
+
the regions genuinely don't overlap, so we merge cleanly. Pinned
|
|
119
|
+
as a known, safe-direction divergence: clean-where-git-conflicts
|
|
120
|
+
can never silently lose content the way the reverse could.
|
|
121
|
+
"""
|
|
122
|
+
out = text(BASE, b"a\nB\nc\nd\n", b"a\nb\nX\nc\nd\n")
|
|
123
|
+
assert out == b"a\nB\nX\nc\nd\n"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class TestCantMark:
|
|
127
|
+
def test_nul_byte(self):
|
|
128
|
+
with pytest.raises(CantMark):
|
|
129
|
+
text(BASE, b"a\x00b", BASE)
|
|
130
|
+
|
|
131
|
+
def test_invalid_utf8(self):
|
|
132
|
+
with pytest.raises(CantMark):
|
|
133
|
+
text(BASE, b"\xff\xfe binary", BASE)
|
|
134
|
+
|
|
135
|
+
def test_oversize(self):
|
|
136
|
+
big = b"x" * (1024 * 1024 + 1)
|
|
137
|
+
with pytest.raises(CantMark):
|
|
138
|
+
text(b"", big, b"")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _git_merge(old: bytes, ours: bytes, theirs: bytes) -> tuple[bytes, int]:
|
|
142
|
+
"""Run git merge-file; returns (stdout, exit code)."""
|
|
143
|
+
assert GIT is not None
|
|
144
|
+
with tempfile.TemporaryDirectory() as d:
|
|
145
|
+
paths = []
|
|
146
|
+
for name, data in (("old", old), ("ours", ours), ("theirs", theirs)):
|
|
147
|
+
p = Path(d) / name
|
|
148
|
+
p.write_bytes(data)
|
|
149
|
+
paths.append(str(p))
|
|
150
|
+
proc = subprocess.run(
|
|
151
|
+
[
|
|
152
|
+
GIT,
|
|
153
|
+
"merge-file",
|
|
154
|
+
"-p",
|
|
155
|
+
"-L",
|
|
156
|
+
"ours",
|
|
157
|
+
"-L",
|
|
158
|
+
"base",
|
|
159
|
+
"-L",
|
|
160
|
+
"theirs",
|
|
161
|
+
paths[1],
|
|
162
|
+
paths[0],
|
|
163
|
+
paths[2],
|
|
164
|
+
],
|
|
165
|
+
capture_output=True,
|
|
166
|
+
env={"LC_ALL": "C", "PATH": "/usr/bin:/bin"},
|
|
167
|
+
)
|
|
168
|
+
return proc.stdout, proc.returncode
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@needs_git
|
|
172
|
+
class TestGitDifferential:
|
|
173
|
+
"""Ours must match git merge-file byte-exactly on agreement-zone inputs.
|
|
174
|
+
|
|
175
|
+
The generator keeps edits separated so hunk contexts cannot overlap
|
|
176
|
+
(the zone where both algorithms provably agree); trickier shapes live
|
|
177
|
+
as pinned cases above or in the adversarial test below. Seed-pinned:
|
|
178
|
+
failures reproduce deterministically.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def _triple(self, rng: random.Random) -> tuple[bytes, bytes, bytes]:
|
|
182
|
+
n = rng.randint(2, 8)
|
|
183
|
+
base = [rng.choice(_ALPHABET) + f"-{i}" for i in range(n)]
|
|
184
|
+
ours, theirs = list(base), list(base)
|
|
185
|
+
# Edits at distinct positions with at least one untouched line
|
|
186
|
+
# between any two edit sites.
|
|
187
|
+
positions = rng.sample(range(n), k=min(n, rng.randint(0, 3)))
|
|
188
|
+
positions.sort()
|
|
189
|
+
ok = True
|
|
190
|
+
for prev, cur in zip([-10, *positions], positions, strict=False):
|
|
191
|
+
if prev != -10 and cur - prev < 2:
|
|
192
|
+
ok = False
|
|
193
|
+
if not ok:
|
|
194
|
+
return self._triple(rng)
|
|
195
|
+
for p in positions:
|
|
196
|
+
choice = rng.random()
|
|
197
|
+
if choice < 0.4:
|
|
198
|
+
ours[p] = f"ours-{p}"
|
|
199
|
+
theirs[p] = f"ours-{p}" # identical edit: must agree
|
|
200
|
+
elif choice < 0.7:
|
|
201
|
+
ours[p] = f"ours-{p}"
|
|
202
|
+
else:
|
|
203
|
+
theirs[p] = f"theirs-{p}"
|
|
204
|
+
|
|
205
|
+
def join(lines: list[str]) -> bytes:
|
|
206
|
+
return ("".join(line + "\n" for line in lines)).encode()
|
|
207
|
+
|
|
208
|
+
return join(base), join(ours), join(theirs)
|
|
209
|
+
|
|
210
|
+
def test_matches_git(self):
|
|
211
|
+
rng = random.Random(0xC0FFEE)
|
|
212
|
+
checked_clean = 0
|
|
213
|
+
for _ in range(150):
|
|
214
|
+
old, ours, theirs = self._triple(rng)
|
|
215
|
+
git_out, code = _git_merge(old, ours, theirs)
|
|
216
|
+
mine = text(old, ours, theirs)
|
|
217
|
+
if code == 0:
|
|
218
|
+
# Clean merges are uniquely determined: byte equality.
|
|
219
|
+
assert mine == git_out
|
|
220
|
+
checked_clean += 1
|
|
221
|
+
else:
|
|
222
|
+
# Conflict: agreement on conflict-hood.
|
|
223
|
+
assert b"<<<<<<< ours\n" in mine
|
|
224
|
+
assert checked_clean > 50 # generator must exercise clean merges
|
|
225
|
+
|
|
226
|
+
def test_adversarial_properties(self):
|
|
227
|
+
"""Where we may legitimately diverge from git (adjacent edits,
|
|
228
|
+
unterminated lines), assert the sound properties instead: clean
|
|
229
|
+
git merges still match byte-exactly, and our output never
|
|
230
|
+
invents lines or emits unbalanced markers."""
|
|
231
|
+
rng = random.Random(0xBEEF)
|
|
232
|
+
checked_clean = 0
|
|
233
|
+
for _ in range(150):
|
|
234
|
+
old, ours, theirs = self._triple(rng)
|
|
235
|
+
if rng.random() < 0.5:
|
|
236
|
+
# Strip a trailing newline: the adjacency trigger.
|
|
237
|
+
if rng.random() < 0.5 and ours.endswith(b"\n"):
|
|
238
|
+
ours = ours.removesuffix(b"\n")
|
|
239
|
+
elif theirs.endswith(b"\n"):
|
|
240
|
+
theirs = theirs.removesuffix(b"\n")
|
|
241
|
+
git_out, code = _git_merge(old, ours, theirs)
|
|
242
|
+
mine = text(old, ours, theirs)
|
|
243
|
+
if code == 0:
|
|
244
|
+
assert mine == git_out
|
|
245
|
+
checked_clean += 1
|
|
246
|
+
continue
|
|
247
|
+
inputs = {
|
|
248
|
+
ln.removesuffix("\n")
|
|
249
|
+
for data in (old, ours, theirs)
|
|
250
|
+
for ln in data.decode("utf-8").splitlines(keepends=True)
|
|
251
|
+
}
|
|
252
|
+
opens = closes = mids = 0
|
|
253
|
+
for ln in mine.decode("utf-8").splitlines(keepends=True):
|
|
254
|
+
if ln.startswith("<<<<<<< "):
|
|
255
|
+
opens += 1
|
|
256
|
+
elif ln.startswith(">>>>>>> "):
|
|
257
|
+
closes += 1
|
|
258
|
+
elif ln == "=======\n":
|
|
259
|
+
mids += 1
|
|
260
|
+
else:
|
|
261
|
+
# No invented content (modulo the terminator): every
|
|
262
|
+
# emitted line comes verbatim from some input.
|
|
263
|
+
assert ln.removesuffix("\n") in inputs
|
|
264
|
+
assert opens == mids == closes
|
|
265
|
+
assert checked_clean > 0 # the net must catch clean merges too
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class TestVersionedIntegration:
|
|
269
|
+
def _two_branches(self):
|
|
270
|
+
store = Memory()
|
|
271
|
+
v1 = Versioned(store)
|
|
272
|
+
v1.commit({"doc": b"a\nb\nc\nd\n"})
|
|
273
|
+
v2 = Versioned(store)
|
|
274
|
+
return v1, v2
|
|
275
|
+
|
|
276
|
+
def test_default_merge_automerges_text(self):
|
|
277
|
+
from kvgit.merges import text as text_merge
|
|
278
|
+
|
|
279
|
+
v1, v2 = self._two_branches()
|
|
280
|
+
v1.commit({"doc": b"a\nB\nc\nd\n"})
|
|
281
|
+
result = v2.commit({"doc": b"a\nb\nc\nD\n"}, default_merge=text_merge)
|
|
282
|
+
assert result
|
|
283
|
+
assert v2.get("doc") == b"a\nB\nc\nD\n"
|
|
284
|
+
|
|
285
|
+
def test_conflict_filed_without_fn(self):
|
|
286
|
+
v1, v2 = self._two_branches()
|
|
287
|
+
v1.commit({"doc": b"a\nB\nc\nd\n"})
|
|
288
|
+
with pytest.raises(MergeConflict) as exc_info:
|
|
289
|
+
v2.commit({"doc": b"a\nb\nc\nD\n"})
|
|
290
|
+
assert "doc" in exc_info.value.conflicting_keys
|
|
291
|
+
|
|
292
|
+
def test_binary_conflict_carries_cantmark(self):
|
|
293
|
+
from kvgit.merges import text as text_merge
|
|
294
|
+
|
|
295
|
+
v1, v2 = self._two_branches()
|
|
296
|
+
v1.commit({"doc": b"a\x00b"})
|
|
297
|
+
with pytest.raises(MergeConflict) as exc_info:
|
|
298
|
+
v2.commit({"doc": b"a\nb\nc\nD\n"}, default_merge=text_merge)
|
|
299
|
+
assert "doc" in exc_info.value.conflicting_keys
|
|
300
|
+
assert isinstance(exc_info.value.merge_errors["doc"], CantMark)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|