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.
Files changed (46) hide show
  1. {kvgit-0.3.5 → kvgit-0.3.6}/PKG-INFO +1 -1
  2. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/__init__.py +5 -0
  3. kvgit-0.3.6/kvgit/merges.py +247 -0
  4. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit.egg-info/PKG-INFO +1 -1
  5. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit.egg-info/SOURCES.txt +2 -0
  6. {kvgit-0.3.5 → kvgit-0.3.6}/pyproject.toml +1 -1
  7. kvgit-0.3.6/tests/test_merges.py +300 -0
  8. {kvgit-0.3.5 → kvgit-0.3.6}/LICENSE +0 -0
  9. {kvgit-0.3.5 → kvgit-0.3.6}/README.md +0 -0
  10. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/codecs/__init__.py +0 -0
  11. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/codecs/_hash.py +0 -0
  12. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/codecs/base.py +0 -0
  13. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/codecs/numpy.py +0 -0
  14. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/codecs/pandas.py +0 -0
  15. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/codecs/pickler.py +0 -0
  16. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/content_types.py +0 -0
  17. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/encoding.py +0 -0
  18. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/errors.py +0 -0
  19. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/hamt.py +0 -0
  20. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/kv/__init__.py +0 -0
  21. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/kv/base.py +0 -0
  22. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/kv/composite.py +0 -0
  23. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/kv/disk.py +0 -0
  24. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/kv/indexeddb.py +0 -0
  25. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/kv/memory.py +0 -0
  26. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/namespaced.py +0 -0
  27. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/py.typed +0 -0
  28. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/staged.py +0 -0
  29. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/store.py +0 -0
  30. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/versioned/__init__.py +0 -0
  31. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/versioned/base.py +0 -0
  32. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/versioned/helpers.py +0 -0
  33. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/versioned/keyset.py +0 -0
  34. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/versioned/kv.py +0 -0
  35. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/versioned/merge.py +0 -0
  36. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit/versioned/protocol.py +0 -0
  37. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit.egg-info/dependency_links.txt +0 -0
  38. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit.egg-info/requires.txt +0 -0
  39. {kvgit-0.3.5 → kvgit-0.3.6}/kvgit.egg-info/top_level.txt +0 -0
  40. {kvgit-0.3.5 → kvgit-0.3.6}/setup.cfg +0 -0
  41. {kvgit-0.3.5 → kvgit-0.3.6}/tests/test_content_types.py +0 -0
  42. {kvgit-0.3.5 → kvgit-0.3.6}/tests/test_delete_branches.py +0 -0
  43. {kvgit-0.3.5 → kvgit-0.3.6}/tests/test_hamt.py +0 -0
  44. {kvgit-0.3.5 → kvgit-0.3.6}/tests/test_namespaced.py +0 -0
  45. {kvgit-0.3.5 → kvgit-0.3.6}/tests/test_staged.py +0 -0
  46. {kvgit-0.3.5 → kvgit-0.3.6}/tests/test_store_factory.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kvgit
3
- Version: 0.3.5
3
+ Version: 0.3.6
4
4
  Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
5
5
  Author: ashenfad
6
6
  License: MIT
@@ -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``)."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kvgit
3
- Version: 0.3.5
3
+ Version: 0.3.6
4
4
  Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
5
5
  Author: ashenfad
6
6
  License: MIT
@@ -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.5"
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