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.
Files changed (46) hide show
  1. {kvgit-0.3.5 → kvgit-0.3.7}/PKG-INFO +33 -1
  2. {kvgit-0.3.5 → kvgit-0.3.7}/README.md +32 -0
  3. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/__init__.py +5 -0
  4. kvgit-0.3.7/kvgit/merges.py +247 -0
  5. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/staged.py +56 -1
  6. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/versioned/base.py +69 -5
  7. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/versioned/kv.py +15 -10
  8. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/versioned/protocol.py +25 -0
  9. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit.egg-info/PKG-INFO +33 -1
  10. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit.egg-info/SOURCES.txt +2 -0
  11. {kvgit-0.3.5 → kvgit-0.3.7}/pyproject.toml +1 -1
  12. kvgit-0.3.7/tests/test_merges.py +300 -0
  13. {kvgit-0.3.5 → kvgit-0.3.7}/LICENSE +0 -0
  14. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/codecs/__init__.py +0 -0
  15. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/codecs/_hash.py +0 -0
  16. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/codecs/base.py +0 -0
  17. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/codecs/numpy.py +0 -0
  18. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/codecs/pandas.py +0 -0
  19. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/codecs/pickler.py +0 -0
  20. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/content_types.py +0 -0
  21. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/encoding.py +0 -0
  22. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/errors.py +0 -0
  23. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/hamt.py +0 -0
  24. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/kv/__init__.py +0 -0
  25. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/kv/base.py +0 -0
  26. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/kv/composite.py +0 -0
  27. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/kv/disk.py +0 -0
  28. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/kv/indexeddb.py +0 -0
  29. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/kv/memory.py +0 -0
  30. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/namespaced.py +0 -0
  31. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/py.typed +0 -0
  32. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/store.py +0 -0
  33. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/versioned/__init__.py +0 -0
  34. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/versioned/helpers.py +0 -0
  35. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/versioned/keyset.py +0 -0
  36. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit/versioned/merge.py +0 -0
  37. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit.egg-info/dependency_links.txt +0 -0
  38. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit.egg-info/requires.txt +0 -0
  39. {kvgit-0.3.5 → kvgit-0.3.7}/kvgit.egg-info/top_level.txt +0 -0
  40. {kvgit-0.3.5 → kvgit-0.3.7}/setup.cfg +0 -0
  41. {kvgit-0.3.5 → kvgit-0.3.7}/tests/test_content_types.py +0 -0
  42. {kvgit-0.3.5 → kvgit-0.3.7}/tests/test_delete_branches.py +0 -0
  43. {kvgit-0.3.5 → kvgit-0.3.7}/tests/test_hamt.py +0 -0
  44. {kvgit-0.3.5 → kvgit-0.3.7}/tests/test_namespaced.py +0 -0
  45. {kvgit-0.3.5 → kvgit-0.3.7}/tests/test_staged.py +0 -0
  46. {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.5
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 BytesMergeFn, MergeResult, TagInfo, Versioned
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 = (their_head, self._current_commit)
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 to merge_hash
301
- if self._cas_head(their_head, merge_hash):
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). Their parent's meta
1186
- # we have to walk via the HAMT.
1187
- their_root = _load_root(self.store, parents[0])
1188
- their_meta: dict[str, MetaEntry] = {}
1189
- if their_root is not None:
1190
- their_ks = Keyset(self.store, root=their_root)
1191
- for key, entry in their_ks.items():
1192
- their_meta[key] = entry.meta
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 their_meta:
1204
- merged_meta[key] = their_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.5
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.5"
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