kvgit 0.3.5__tar.gz → 0.3.7__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.7}/PKG-INFO +33 -1
- {kvgit-0.3.5 → kvgit-0.3.7}/README.md +32 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/__init__.py +5 -0
- kvgit-0.3.7/kvgit/merges.py +247 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/staged.py +56 -1
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/versioned/base.py +69 -5
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/versioned/kv.py +15 -10
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/versioned/protocol.py +25 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit.egg-info/PKG-INFO +33 -1
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit.egg-info/SOURCES.txt +2 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/pyproject.toml +1 -1
- kvgit-0.3.7/tests/test_merges.py +300 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/LICENSE +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/codecs/__init__.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/codecs/_hash.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/codecs/base.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/codecs/numpy.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/codecs/pandas.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/codecs/pickler.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/content_types.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/encoding.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/errors.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/hamt.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/kv/__init__.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/kv/base.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/kv/composite.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/kv/disk.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/kv/indexeddb.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/kv/memory.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/namespaced.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/py.typed +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/store.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/versioned/__init__.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/versioned/helpers.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/versioned/keyset.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/versioned/merge.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit.egg-info/dependency_links.txt +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit.egg-info/requires.txt +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/kvgit.egg-info/top_level.txt +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/setup.cfg +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/tests/test_content_types.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/tests/test_delete_branches.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/tests/test_hamt.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/tests/test_namespaced.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/tests/test_staged.py +0 -0
- {kvgit-0.3.5 → kvgit-0.3.7}/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.
|
|
3
|
+
Version: 0.3.7
|
|
4
4
|
Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
|
|
5
5
|
Author: ashenfad
|
|
6
6
|
License: MIT
|
|
@@ -94,6 +94,38 @@ main.tag("v1")
|
|
|
94
94
|
print(main.peek("score", tag="v1")) # 0
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
+
## Merging
|
|
98
|
+
|
|
99
|
+
`Staged.merge()` merges another head -- usually another branch's HEAD --
|
|
100
|
+
into the current branch: lowest common ancestor, three-way resolve, and
|
|
101
|
+
a two-parent merge commit guarded on your own head:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
dev["score"] = 500
|
|
105
|
+
dev.commit()
|
|
106
|
+
|
|
107
|
+
result = main.merge(dev.current_commit) # True when merged
|
|
108
|
+
print(main["score"]) # 500 (fast-forward: main hadn't diverged)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Overlapping changes need a merge function per key, or a `default_merge`
|
|
112
|
+
fallback. `kvgit.merges.text` resolves line-oriented text with git-style
|
|
113
|
+
`<<<<<<<` markers (see `make_text_merge` for custom labels); anything it
|
|
114
|
+
cannot mark -- binary, non-UTF-8, oversized -- raises `CantMark`, filed
|
|
115
|
+
as an ordinary conflict:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from kvgit.merges import text
|
|
119
|
+
|
|
120
|
+
result = main.merge(dev.current_commit, default_merge=text)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
A `post_check(key, merged_bytes)` predicate runs over every
|
|
124
|
+
merge-produced value; returning `False` files that key as conflicted.
|
|
125
|
+
`on_conflict="abandon"` leaves the branch untouched instead of raising.
|
|
126
|
+
Merging refuses with `ValueError` when the staging buffer holds
|
|
127
|
+
uncommitted changes -- commit or reset first.
|
|
128
|
+
|
|
97
129
|
## Chunked codecs (numpy / pandas)
|
|
98
130
|
|
|
99
131
|
Large numpy arrays and pandas DataFrames -- and any sliced views of them -- can be stored once and shared across keys, commits, and branches:
|
|
@@ -45,6 +45,38 @@ main.tag("v1")
|
|
|
45
45
|
print(main.peek("score", tag="v1")) # 0
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
+
## Merging
|
|
49
|
+
|
|
50
|
+
`Staged.merge()` merges another head -- usually another branch's HEAD --
|
|
51
|
+
into the current branch: lowest common ancestor, three-way resolve, and
|
|
52
|
+
a two-parent merge commit guarded on your own head:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
dev["score"] = 500
|
|
56
|
+
dev.commit()
|
|
57
|
+
|
|
58
|
+
result = main.merge(dev.current_commit) # True when merged
|
|
59
|
+
print(main["score"]) # 500 (fast-forward: main hadn't diverged)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Overlapping changes need a merge function per key, or a `default_merge`
|
|
63
|
+
fallback. `kvgit.merges.text` resolves line-oriented text with git-style
|
|
64
|
+
`<<<<<<<` markers (see `make_text_merge` for custom labels); anything it
|
|
65
|
+
cannot mark -- binary, non-UTF-8, oversized -- raises `CantMark`, filed
|
|
66
|
+
as an ordinary conflict:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from kvgit.merges import text
|
|
70
|
+
|
|
71
|
+
result = main.merge(dev.current_commit, default_merge=text)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
A `post_check(key, merged_bytes)` predicate runs over every
|
|
75
|
+
merge-produced value; returning `False` files that key as conflicted.
|
|
76
|
+
`on_conflict="abandon"` leaves the branch untouched instead of raising.
|
|
77
|
+
Merging refuses with `ValueError` when the staging buffer holds
|
|
78
|
+
uncommitted changes -- commit or reset first.
|
|
79
|
+
|
|
48
80
|
## Chunked codecs (numpy / pandas)
|
|
49
81
|
|
|
50
82
|
Large numpy arrays and pandas DataFrames -- and any sliced views of them -- can be stored once and shared across keys, commits, and branches:
|
|
@@ -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``)."""
|
|
@@ -8,7 +8,13 @@ from typing import Any
|
|
|
8
8
|
from .codecs._hash import hash_bytes
|
|
9
9
|
from .content_types import MergeFn
|
|
10
10
|
from .versioned.kv import CHUNK_PREFIX, VersionedKV
|
|
11
|
-
from .versioned.protocol import
|
|
11
|
+
from .versioned.protocol import (
|
|
12
|
+
BytesMergeFn,
|
|
13
|
+
MergeResult,
|
|
14
|
+
PostCheck,
|
|
15
|
+
TagInfo,
|
|
16
|
+
Versioned,
|
|
17
|
+
)
|
|
12
18
|
|
|
13
19
|
|
|
14
20
|
class _ChunkSink:
|
|
@@ -355,6 +361,55 @@ class Staged(MutableMapping[str, Any]):
|
|
|
355
361
|
self._cache.clear()
|
|
356
362
|
return result
|
|
357
363
|
|
|
364
|
+
def merge(
|
|
365
|
+
self,
|
|
366
|
+
their_head: str,
|
|
367
|
+
*,
|
|
368
|
+
on_conflict: str = "raise",
|
|
369
|
+
merge_fns: dict[str, MergeFn] | None = None,
|
|
370
|
+
default_merge: MergeFn | None = None,
|
|
371
|
+
post_check: PostCheck | None = None,
|
|
372
|
+
info: dict | None = None,
|
|
373
|
+
) -> MergeResult:
|
|
374
|
+
"""Merge another head (usually another branch's HEAD) into this branch.
|
|
375
|
+
|
|
376
|
+
Lowest common ancestor + three-way resolve + a two-parent merge
|
|
377
|
+
commit, CAS-guarded on our own head. Refuses with ``ValueError``
|
|
378
|
+
when the staging buffer holds uncommitted changes — commit or
|
|
379
|
+
reset first, so the merge reads committed heads on both sides.
|
|
380
|
+
|
|
381
|
+
``merge_fns`` / ``default_merge`` take decoded values (wrapped
|
|
382
|
+
like :meth:`commit`); ``post_check`` takes merged bytes.
|
|
383
|
+
"""
|
|
384
|
+
if self._updates or self._removals:
|
|
385
|
+
raise ValueError("cannot merge with staged changes; commit or reset first")
|
|
386
|
+
# Same assembly as commit(): registered fns first, per-call
|
|
387
|
+
# overrides on top — a configured Staged merges without repeats.
|
|
388
|
+
effective_fns = dict(self._merge_fns)
|
|
389
|
+
if merge_fns:
|
|
390
|
+
effective_fns.update(merge_fns)
|
|
391
|
+
effective_default = default_merge or self._default_merge
|
|
392
|
+
bytes_merge_fns = (
|
|
393
|
+
{key: self._wrap_merge_fn(fn) for key, fn in effective_fns.items()}
|
|
394
|
+
if effective_fns
|
|
395
|
+
else None
|
|
396
|
+
)
|
|
397
|
+
bytes_default = (
|
|
398
|
+
self._wrap_merge_fn(effective_default) if effective_default else None
|
|
399
|
+
)
|
|
400
|
+
result = self._versioned.merge_heads(
|
|
401
|
+
their_head,
|
|
402
|
+
on_conflict=on_conflict,
|
|
403
|
+
merge_fns=bytes_merge_fns,
|
|
404
|
+
default_merge=bytes_default,
|
|
405
|
+
post_check=post_check,
|
|
406
|
+
info=info,
|
|
407
|
+
)
|
|
408
|
+
if result.merged:
|
|
409
|
+
# HEAD moved: cached values may be stale after a merge.
|
|
410
|
+
self._cache.clear()
|
|
411
|
+
return result
|
|
412
|
+
|
|
358
413
|
def reset(self) -> None:
|
|
359
414
|
"""Discard all staged changes."""
|
|
360
415
|
self._updates.clear()
|
|
@@ -6,7 +6,7 @@ from collections.abc import Iterable
|
|
|
6
6
|
from ..errors import ConcurrencyError, MergeConflict
|
|
7
7
|
from .helpers import diff_keysets, walk_history
|
|
8
8
|
from .merge import MergeResolution, resolve_merge
|
|
9
|
-
from .protocol import BytesMergeFn, DiffResult, MergeResult
|
|
9
|
+
from .protocol import BytesMergeFn, DiffResult, MergeResult, PostCheck
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class VersionedBase(ABC):
|
|
@@ -223,10 +223,20 @@ class VersionedBase(ABC):
|
|
|
223
223
|
on_conflict: str,
|
|
224
224
|
merge_fns: dict[str, BytesMergeFn] | None,
|
|
225
225
|
default_merge: BytesMergeFn | None,
|
|
226
|
+
post_check: PostCheck | None = None,
|
|
226
227
|
info: dict | None,
|
|
227
228
|
saved_state: tuple | None = None,
|
|
229
|
+
cas_from: str | None = None,
|
|
230
|
+
parents: tuple[str, str] | None = None,
|
|
228
231
|
) -> MergeResult:
|
|
229
|
-
"""Perform a three-way merge between our branch and their HEAD.
|
|
232
|
+
"""Perform a three-way merge between our branch and their HEAD.
|
|
233
|
+
|
|
234
|
+
``their_head`` is any commit in the store — the moved HEAD of our
|
|
235
|
+
own branch (the concurrent-write path) or another branch's HEAD
|
|
236
|
+
(cross-branch merge). ``cas_from`` names the commit the merge
|
|
237
|
+
commits on top of (defaults to ``their_head``, the concurrent
|
|
238
|
+
case); cross-branch callers pass their own head.
|
|
239
|
+
"""
|
|
230
240
|
lca = self._find_lca(self._current_commit, their_head)
|
|
231
241
|
if lca is None:
|
|
232
242
|
if saved_state is not None:
|
|
@@ -275,6 +285,14 @@ class VersionedBase(ABC):
|
|
|
275
285
|
merge_fns=effective_fns,
|
|
276
286
|
default_merge=effective_default,
|
|
277
287
|
)
|
|
288
|
+
if post_check is not None:
|
|
289
|
+
refused = {
|
|
290
|
+
key
|
|
291
|
+
for key, value in resolution.merged_values.items()
|
|
292
|
+
if not post_check(key, value)
|
|
293
|
+
}
|
|
294
|
+
if refused:
|
|
295
|
+
raise MergeConflict(refused)
|
|
278
296
|
except MergeConflict:
|
|
279
297
|
if saved_state is not None:
|
|
280
298
|
self._restore_state(saved_state)
|
|
@@ -291,14 +309,19 @@ class VersionedBase(ABC):
|
|
|
291
309
|
raise
|
|
292
310
|
|
|
293
311
|
auto_merged = resolution.auto_merged_keys
|
|
294
|
-
parents
|
|
312
|
+
if parents is None:
|
|
313
|
+
# Concurrent-write default: keep following the moved HEAD, as
|
|
314
|
+
# before. Cross-branch callers pass (our_head, their_head) so
|
|
315
|
+
# linear history stays on the merging branch (git convention).
|
|
316
|
+
parents = (their_head, self._current_commit)
|
|
295
317
|
|
|
296
318
|
self._create_merge_commit(resolution, parents, info)
|
|
297
319
|
merge_hash = self._current_commit
|
|
298
320
|
merged_keyset = self._commit_keys
|
|
299
321
|
|
|
300
|
-
# CAS HEAD from their_head
|
|
301
|
-
|
|
322
|
+
# CAS HEAD onto the merge commit: from their_head in the
|
|
323
|
+
# concurrent-write path, from our own head cross-branch.
|
|
324
|
+
if self._cas_head(cas_from or their_head, merge_hash):
|
|
302
325
|
self._base_commit = merge_hash
|
|
303
326
|
result = MergeResult(
|
|
304
327
|
merged=True,
|
|
@@ -330,6 +353,47 @@ class VersionedBase(ABC):
|
|
|
330
353
|
"HEAD changed during three-way merge. Refresh and retry."
|
|
331
354
|
)
|
|
332
355
|
|
|
356
|
+
def merge_heads(
|
|
357
|
+
self,
|
|
358
|
+
their_head: str,
|
|
359
|
+
*,
|
|
360
|
+
on_conflict: str = "raise",
|
|
361
|
+
merge_fns: dict[str, BytesMergeFn] | None = None,
|
|
362
|
+
default_merge: BytesMergeFn | None = None,
|
|
363
|
+
post_check: PostCheck | None = None,
|
|
364
|
+
info: dict | None = None,
|
|
365
|
+
) -> MergeResult:
|
|
366
|
+
"""Merge another head into this branch.
|
|
367
|
+
|
|
368
|
+
``their_head`` is any commit in the store — usually another
|
|
369
|
+
branch's HEAD. Finds the lowest common ancestor with our head,
|
|
370
|
+
three-way resolves, and creates a two-parent merge commit,
|
|
371
|
+
CAS-guarded on our own head (a race raises ``ConcurrencyError``
|
|
372
|
+
and changes nothing). No common ancestor raises
|
|
373
|
+
``ConcurrencyError`` likewise without changing anything; retrying
|
|
374
|
+
cannot help that case, unlike a race.
|
|
375
|
+
|
|
376
|
+
``post_check`` runs over each merge-function-produced value;
|
|
377
|
+
a False files that key as conflicted (handled per
|
|
378
|
+
``on_conflict`` like any other conflict).
|
|
379
|
+
"""
|
|
380
|
+
if on_conflict not in ("raise", "abandon"):
|
|
381
|
+
raise ValueError(
|
|
382
|
+
f"on_conflict must be 'raise' or 'abandon', got {on_conflict!r}"
|
|
383
|
+
)
|
|
384
|
+
our_head = self._current_commit
|
|
385
|
+
return self._three_way_merge(
|
|
386
|
+
their_head,
|
|
387
|
+
on_conflict=on_conflict,
|
|
388
|
+
merge_fns=merge_fns,
|
|
389
|
+
default_merge=default_merge,
|
|
390
|
+
post_check=post_check,
|
|
391
|
+
info=info,
|
|
392
|
+
saved_state=self._snapshot_state(),
|
|
393
|
+
cas_from=our_head,
|
|
394
|
+
parents=(our_head, their_head),
|
|
395
|
+
)
|
|
396
|
+
|
|
333
397
|
# -- Abstract methods (implemented by subclasses) --
|
|
334
398
|
|
|
335
399
|
@property
|
|
@@ -1182,14 +1182,19 @@ class VersionedKV(VersionedBase):
|
|
|
1182
1182
|
diffs[vk] = value
|
|
1183
1183
|
|
|
1184
1184
|
# Build merged meta from the parents' meta. ``self._meta`` is
|
|
1185
|
-
# already our parent's meta (in memory).
|
|
1186
|
-
# we
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1185
|
+
# already our parent's meta (in memory). Any other parent's meta
|
|
1186
|
+
# we walk via the HAMT — position-independent, since merge
|
|
1187
|
+
# commits record ours-first: union every parent so their-side
|
|
1188
|
+
# keys resolve however the tuple is ordered. Ties keep ours
|
|
1189
|
+
# (checked first below), matching the old parents[0]-is-theirs
|
|
1190
|
+
# behavior exactly.
|
|
1191
|
+
parent_meta: dict[str, MetaEntry] = {}
|
|
1192
|
+
for parent in parents:
|
|
1193
|
+
parent_root = _load_root(self.store, parent)
|
|
1194
|
+
if parent_root is None:
|
|
1195
|
+
continue
|
|
1196
|
+
for key, entry in Keyset(self.store, root=parent_root).items():
|
|
1197
|
+
parent_meta.setdefault(key, entry.meta)
|
|
1193
1198
|
|
|
1194
1199
|
merged_meta: dict[str, MetaEntry] = {}
|
|
1195
1200
|
for key in merged_keyset:
|
|
@@ -1200,8 +1205,8 @@ class VersionedKV(VersionedBase):
|
|
|
1200
1205
|
)
|
|
1201
1206
|
elif key in self._meta:
|
|
1202
1207
|
merged_meta[key] = self._meta[key]
|
|
1203
|
-
elif key in
|
|
1204
|
-
merged_meta[key] =
|
|
1208
|
+
elif key in parent_meta:
|
|
1209
|
+
merged_meta[key] = parent_meta[key]
|
|
1205
1210
|
|
|
1206
1211
|
# Apply the merge result on top of our parent's HAMT. We compute
|
|
1207
1212
|
# the minimal updates and removals so structural sharing kicks in
|
|
@@ -10,6 +10,15 @@ BytesMergeFn = Callable[[bytes | None, bytes | None, bytes | None], bytes]
|
|
|
10
10
|
Any argument can be None (key absent or removed on that side).
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
+
PostCheck = Callable[[str, bytes], bool]
|
|
14
|
+
"""Post-merge predicate: (key, merged_bytes) -> accept?
|
|
15
|
+
|
|
16
|
+
Runs over values a merge function produced. Returning False files the
|
|
17
|
+
key as conflicted, as if no merge function had resolved it. kvgit never
|
|
18
|
+
inspects the bytes itself — callers that know what their values mean
|
|
19
|
+
(marker scans, schema checks) decide.
|
|
20
|
+
"""
|
|
21
|
+
|
|
13
22
|
|
|
14
23
|
@dataclass(frozen=True)
|
|
15
24
|
class DiffResult:
|
|
@@ -110,6 +119,22 @@ class Versioned(Protocol):
|
|
|
110
119
|
chunk_refs: dict[str, list[str]] | None = None,
|
|
111
120
|
) -> MergeResult: ...
|
|
112
121
|
|
|
122
|
+
def merge_heads(
|
|
123
|
+
self,
|
|
124
|
+
their_head: str,
|
|
125
|
+
*,
|
|
126
|
+
on_conflict: str = "raise",
|
|
127
|
+
merge_fns: dict[str, BytesMergeFn] | None = None,
|
|
128
|
+
default_merge: BytesMergeFn | None = None,
|
|
129
|
+
post_check: PostCheck | None = None,
|
|
130
|
+
info: dict | None = None,
|
|
131
|
+
) -> MergeResult:
|
|
132
|
+
"""Merge another head (any commit in the store, usually another
|
|
133
|
+
branch's HEAD) into this branch: LCA + three-way resolve + a
|
|
134
|
+
two-parent merge commit, CAS-guarded on our own head.
|
|
135
|
+
"""
|
|
136
|
+
...
|
|
137
|
+
|
|
113
138
|
def refresh(self) -> None: ...
|
|
114
139
|
|
|
115
140
|
def checkout(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kvgit
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.7
|
|
4
4
|
Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
|
|
5
5
|
Author: ashenfad
|
|
6
6
|
License: MIT
|
|
@@ -94,6 +94,38 @@ main.tag("v1")
|
|
|
94
94
|
print(main.peek("score", tag="v1")) # 0
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
+
## Merging
|
|
98
|
+
|
|
99
|
+
`Staged.merge()` merges another head -- usually another branch's HEAD --
|
|
100
|
+
into the current branch: lowest common ancestor, three-way resolve, and
|
|
101
|
+
a two-parent merge commit guarded on your own head:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
dev["score"] = 500
|
|
105
|
+
dev.commit()
|
|
106
|
+
|
|
107
|
+
result = main.merge(dev.current_commit) # True when merged
|
|
108
|
+
print(main["score"]) # 500 (fast-forward: main hadn't diverged)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Overlapping changes need a merge function per key, or a `default_merge`
|
|
112
|
+
fallback. `kvgit.merges.text` resolves line-oriented text with git-style
|
|
113
|
+
`<<<<<<<` markers (see `make_text_merge` for custom labels); anything it
|
|
114
|
+
cannot mark -- binary, non-UTF-8, oversized -- raises `CantMark`, filed
|
|
115
|
+
as an ordinary conflict:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from kvgit.merges import text
|
|
119
|
+
|
|
120
|
+
result = main.merge(dev.current_commit, default_merge=text)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
A `post_check(key, merged_bytes)` predicate runs over every
|
|
124
|
+
merge-produced value; returning `False` files that key as conflicted.
|
|
125
|
+
`on_conflict="abandon"` leaves the branch untouched instead of raising.
|
|
126
|
+
Merging refuses with `ValueError` when the staging buffer holds
|
|
127
|
+
uncommitted changes -- commit or reset first.
|
|
128
|
+
|
|
97
129
|
## Chunked codecs (numpy / pandas)
|
|
98
130
|
|
|
99
131
|
Large numpy arrays and pandas DataFrames -- and any sliced views of them -- can be stored once and shared across keys, commits, and branches:
|
|
@@ -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.7"
|
|
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
|