kvgit 0.3.8__tar.gz → 0.3.9__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 (47) hide show
  1. {kvgit-0.3.8 → kvgit-0.3.9}/PKG-INFO +1 -1
  2. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/__init__.py +12 -2
  3. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/content_types.py +14 -5
  4. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/errors.py +9 -0
  5. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/merges.py +76 -24
  6. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/staged.py +8 -0
  7. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/store.py +7 -0
  8. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/versioned/base.py +47 -21
  9. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/versioned/kv.py +89 -33
  10. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/versioned/protocol.py +6 -0
  11. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit.egg-info/PKG-INFO +1 -1
  12. {kvgit-0.3.8 → kvgit-0.3.9}/pyproject.toml +1 -1
  13. {kvgit-0.3.8 → kvgit-0.3.9}/tests/test_content_types.py +22 -0
  14. {kvgit-0.3.8 → kvgit-0.3.9}/tests/test_merges.py +121 -1
  15. {kvgit-0.3.8 → kvgit-0.3.9}/tests/test_staged.py +34 -0
  16. {kvgit-0.3.8 → kvgit-0.3.9}/tests/test_store_factory.py +18 -0
  17. {kvgit-0.3.8 → kvgit-0.3.9}/LICENSE +0 -0
  18. {kvgit-0.3.8 → kvgit-0.3.9}/README.md +0 -0
  19. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/codecs/__init__.py +0 -0
  20. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/codecs/_hash.py +0 -0
  21. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/codecs/base.py +0 -0
  22. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/codecs/numpy.py +0 -0
  23. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/codecs/pandas.py +0 -0
  24. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/codecs/pickler.py +0 -0
  25. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/encoding.py +0 -0
  26. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/hamt.py +0 -0
  27. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/kv/__init__.py +0 -0
  28. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/kv/base.py +0 -0
  29. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/kv/composite.py +0 -0
  30. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/kv/disk.py +0 -0
  31. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/kv/indexeddb.py +0 -0
  32. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/kv/memory.py +0 -0
  33. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/namespaced.py +0 -0
  34. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/py.typed +0 -0
  35. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/versioned/__init__.py +0 -0
  36. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/versioned/helpers.py +0 -0
  37. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/versioned/keyset.py +0 -0
  38. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/versioned/merge.py +0 -0
  39. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit.egg-info/SOURCES.txt +0 -0
  40. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit.egg-info/dependency_links.txt +0 -0
  41. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit.egg-info/requires.txt +0 -0
  42. {kvgit-0.3.8 → kvgit-0.3.9}/kvgit.egg-info/top_level.txt +0 -0
  43. {kvgit-0.3.8 → kvgit-0.3.9}/setup.cfg +0 -0
  44. {kvgit-0.3.8 → kvgit-0.3.9}/tests/test_delete_branches.py +0 -0
  45. {kvgit-0.3.8 → kvgit-0.3.9}/tests/test_hamt.py +0 -0
  46. {kvgit-0.3.8 → kvgit-0.3.9}/tests/test_merge_policy.py +0 -0
  47. {kvgit-0.3.8 → kvgit-0.3.9}/tests/test_namespaced.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kvgit
3
- Version: 0.3.8
3
+ Version: 0.3.9
4
4
  Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
5
5
  Author: ashenfad
6
6
  License: MIT
@@ -1,8 +1,16 @@
1
1
  """kvgit: Versioned key-value store."""
2
2
 
3
3
  from .content_types import MergeFn, counter, last_writer_wins, text_merge
4
- from .errors import ConcurrencyError, GcBusy, MergeConflict
5
- from .merges import CantMark, TextMergeFn, make_text_merge, ours, text, theirs
4
+ from .errors import ConcurrencyError, GcBusy, MergeConflict, UnknownBranchError
5
+ from .merges import (
6
+ CantMark,
7
+ TextMergeFn,
8
+ make_text_merge,
9
+ ours,
10
+ text,
11
+ text_merge_result,
12
+ theirs,
13
+ )
6
14
  from .namespaced import Namespaced
7
15
  from .staged import Staged
8
16
  from .store import delete_branches, delete_tags, store
@@ -28,6 +36,7 @@ __all__ = [
28
36
  "Staged",
29
37
  "TagInfo",
30
38
  "TextMergeFn",
39
+ "UnknownBranchError",
31
40
  "Versioned",
32
41
  "VersionedKV",
33
42
  "counter",
@@ -39,5 +48,6 @@ __all__ = [
39
48
  "store",
40
49
  "text",
41
50
  "text_merge",
51
+ "text_merge_result",
42
52
  "theirs",
43
53
  ]
@@ -31,7 +31,12 @@ def last_writer_wins() -> MergeFn:
31
31
  return lambda old, ours, theirs: theirs
32
32
 
33
33
 
34
- def text_merge(*, ours_label: str = "ours", theirs_label: str = "theirs") -> MergeFn:
34
+ def text_merge(
35
+ *,
36
+ ours_label: str = "ours",
37
+ theirs_label: str = "theirs",
38
+ strict: bool = False,
39
+ ) -> MergeFn:
35
40
  """Marker merge for ``str`` (or ``bytes``) values, for use with ``Staged``.
36
41
 
37
42
  The value-level counterpart to :func:`kvgit.merges.text`. ``Staged``
@@ -43,13 +48,17 @@ def text_merge(*, ours_label: str = "ours", theirs_label: str = "theirs") -> Mer
43
48
 
44
49
  Disjoint line changes merge cleanly and overlapping ones come back
45
50
  with git-style ``<<<<<<<`` markers (labelled by ``ours_label`` /
46
- ``theirs_label``) rather than raising. A value that is neither
47
- ``str`` nor ``bytes``, and anything the marker merge cannot handle —
48
- undecodable bytes, NUL bytes, inputs over the size cap — raises
51
+ ``theirs_label``) rather than raising. With ``strict=True`` a
52
+ conflict raises instead of marking, so the merge aborts rather than
53
+ landing hunks. A value that is neither ``str`` nor ``bytes``, and
54
+ anything the marker merge cannot handle — undecodable bytes, NUL
55
+ bytes, inputs over the size cap — raises
49
56
  :class:`~kvgit.merges.CantMark`, which the merge machinery files as
50
57
  an ordinary conflict.
51
58
  """
52
- merge_bytes = make_text_merge(ours_label=ours_label, theirs_label=theirs_label)
59
+ merge_bytes = make_text_merge(
60
+ ours_label=ours_label, theirs_label=theirs_label, strict=strict
61
+ )
53
62
 
54
63
  def encode(value: Any) -> bytes | None:
55
64
  if value is None or isinstance(value, bytes):
@@ -28,6 +28,15 @@ class MergeConflict(Exception):
28
28
  super().__init__(f"Merge conflict on keys: {keys_str}")
29
29
 
30
30
 
31
+ class UnknownBranchError(ValueError):
32
+ """Raised when opening or switching to a branch that does not exist.
33
+
34
+ A subclass of ``ValueError``, so existing ``except ValueError``
35
+ handlers keep working. Open with ``create=True`` (the default) to
36
+ mint the branch instead.
37
+ """
38
+
39
+
31
40
  class GcBusy(Exception):
32
41
  """Raised when a deep clean cannot take the store's GC lease.
33
42
 
@@ -27,13 +27,24 @@ from .versioned.protocol import BytesMergeFn, MergeChoice
27
27
  #: is a whole-file conflict by inspection anyway.
28
28
  MAX_MARK_BYTES = 1024 * 1024
29
29
 
30
+ #: Line boundaries recognized by ``str.splitlines`` beyond ``"\n"``. A
31
+ #: line ending in any of these is already terminated — gluing the next
32
+ #: line after it re-splits to the same lines — so :func:`_terminate`
33
+ #: leaves it alone, and labels may not contain any of them.
34
+ _LINE_BOUNDARIES = frozenset(
35
+ ["\n", "\r", "\v", "\f", "\x1c", "\x1d", "\x1e", "\x85", "\u2028", "\u2029"]
36
+ )
37
+
30
38
 
31
39
  class CantMark(Exception):
32
- """A value cannot be marker-merged: not text, or too big.
40
+ """A value cannot be marker-merged: not text, too big, or a conflict
41
+ under ``strict=True``.
33
42
 
34
43
  Raised instead of returning bytes when the inputs are undecodable as
35
- UTF-8, contain NUL bytes, or exceed ``MAX_MARK_BYTES`` combined.
36
- The merge machinery reports it as a conflict for that key.
44
+ UTF-8, contain NUL bytes, or exceed ``MAX_MARK_BYTES`` combined — or,
45
+ when built with ``strict=True``, when the merge would have written
46
+ conflict markers. The merge machinery reports it as a conflict for
47
+ that key.
37
48
  """
38
49
 
39
50
 
@@ -69,15 +80,17 @@ def _changes(base: list[str], side: list[str]) -> list[tuple[int, int, list[str]
69
80
 
70
81
 
71
82
  def _terminate(lines: list[str]) -> list[str]:
72
- """Ensure every non-final line ends with a newline.
83
+ """Ensure every non-final line ends with a line break.
73
84
 
74
- A line without a trailing newline glued onto following output would
85
+ A line without a trailing break glued onto following output would
75
86
  corrupt both; clean regions stay byte-exact (a final line keeps its
76
- missing newline), markers pay the newline tax.
87
+ missing break, and lines ending in a non-LF ``splitlines`` boundary
88
+ such as lone ``\\r`` are already terminated, so no ``\\n`` is added),
89
+ markers pay the newline tax.
77
90
  """
78
91
  fixed: list[str] = []
79
92
  for i, ln in enumerate(lines):
80
- if not ln.endswith("\n") and i + 1 < len(lines):
93
+ if i + 1 < len(lines) and ln[-1:] not in _LINE_BOUNDARIES:
81
94
  ln += "\n"
82
95
  fixed.append(ln)
83
96
  return fixed
@@ -210,33 +223,72 @@ def _merge_lines(
210
223
  return _terminate(out), conflicted
211
224
 
212
225
 
226
+ def _check_labels(ours_label: str, theirs_label: str) -> None:
227
+ for name, label in (("ours_label", ours_label), ("theirs_label", theirs_label)):
228
+ if any(boundary in label for boundary in _LINE_BOUNDARIES):
229
+ raise ValueError(f"{name} may not contain a newline: {label!r}")
230
+
231
+
232
+ def text_merge_result(
233
+ old: bytes | None,
234
+ ours: bytes | None,
235
+ theirs: bytes | None,
236
+ *,
237
+ ours_label: str = "ours",
238
+ theirs_label: str = "theirs",
239
+ ) -> tuple[bytes, bool]:
240
+ """Marker-merge returning ``(merged_bytes, conflicted)``.
241
+
242
+ Same merge as :func:`make_text_merge`, but the flag tells whether the
243
+ merge introduced conflict hunks — reliably, where counting
244
+ ``<<<<<<<`` lines cannot, because the inputs may legitimately contain
245
+ marker-like lines that merge through untouched. Anything unmarkable
246
+ still raises :class:`CantMark`.
247
+ """
248
+ _check_labels(ours_label, theirs_label)
249
+ total = sum(len(d) for d in (old, ours, theirs) if d is not None)
250
+ if total > MAX_MARK_BYTES:
251
+ raise CantMark(f"inputs total {total} bytes over cap {MAX_MARK_BYTES}")
252
+ merged, conflicted = _merge_lines(
253
+ _decode(old),
254
+ _decode(ours),
255
+ _decode(theirs),
256
+ ours_label,
257
+ theirs_label,
258
+ ours_deleted=ours is None and old is not None,
259
+ theirs_deleted=theirs is None and old is not None,
260
+ )
261
+ return "".join(merged).encode("utf-8"), conflicted
262
+
263
+
213
264
  def make_text_merge(
214
- *, ours_label: str = "ours", theirs_label: str = "theirs"
265
+ *,
266
+ ours_label: str = "ours",
267
+ theirs_label: str = "theirs",
268
+ strict: bool = False,
215
269
  ) -> BytesMergeFn:
216
270
  """Build a marker-merge fn with custom conflict labels.
217
271
 
218
272
  Labels ride git's positions (``<<<<<<< <ours>`` /
219
273
  ``>>>>>>> <theirs>``); pass branch names so conflicts read
220
- attributably. Labels may not contain newlines.
274
+ attributably. Labels may not contain newlines (any ``str.splitlines``
275
+ boundary: LF, lone CR, VT/FF, FS/GS/RS, NEL, U+2028/2029).
276
+
277
+ With ``strict=True`` the fn raises :class:`CantMark` instead of
278
+ writing markers when sides conflict — for branches where a true
279
+ conflict must abort the commit rather than land hunks. The merge
280
+ machinery files it as an ordinary conflict, same as unmarkable
281
+ input.
221
282
  """
222
- for name, label in (("ours_label", ours_label), ("theirs_label", theirs_label)):
223
- if "\n" in label:
224
- raise ValueError(f"{name} may not contain a newline: {label!r}")
283
+ _check_labels(ours_label, theirs_label)
225
284
 
226
285
  def merge(old: bytes | None, ours: bytes | None, theirs: bytes | None) -> bytes:
227
- total = sum(len(d) for d in (old, ours, theirs) if d is not None)
228
- if total > MAX_MARK_BYTES:
229
- raise CantMark(f"inputs total {total} bytes over cap {MAX_MARK_BYTES}")
230
- merged, _ = _merge_lines(
231
- _decode(old),
232
- _decode(ours),
233
- _decode(theirs),
234
- ours_label,
235
- theirs_label,
236
- ours_deleted=ours is None and old is not None,
237
- theirs_deleted=theirs is None and old is not None,
286
+ merged, conflicted = text_merge_result(
287
+ old, ours, theirs, ours_label=ours_label, theirs_label=theirs_label
238
288
  )
239
- return "".join(merged).encode("utf-8")
289
+ if conflicted and strict:
290
+ raise CantMark("strict text merge: conflicting changes cannot be marked")
291
+ return merged
240
292
 
241
293
  return merge
242
294
 
@@ -553,6 +553,10 @@ class Staged(MutableMapping[str, Any]):
553
553
  """List all branch names in the store."""
554
554
  return self._versioned.list_branches()
555
555
 
556
+ def branch_exists(self, name: str) -> bool:
557
+ """Whether a branch exists in the store."""
558
+ return self._versioned.branch_exists(name)
559
+
556
560
  def delete_branch(self, name: str) -> None:
557
561
  """Delete a branch by name. Cannot delete the current branch."""
558
562
  self._versioned.delete_branch(name)
@@ -625,6 +629,10 @@ class Staged(MutableMapping[str, Any]):
625
629
  """Yield the commit chain from newest to oldest."""
626
630
  return self._versioned.history(commit_hash, all_parents=all_parents)
627
631
 
632
+ def merge_base(self, commit_a: str, commit_b: str) -> str | None:
633
+ """Lowest common ancestor of two commits, or None if unrelated."""
634
+ return self._versioned.merge_base(commit_a, commit_b)
635
+
628
636
  def refresh(self) -> None:
629
637
  """Reload from HEAD and discard staged changes."""
630
638
  self._versioned.refresh()
@@ -52,6 +52,7 @@ def store(
52
52
  path: str | None = None,
53
53
  db_name: str = "kvgit",
54
54
  branch: str = "main",
55
+ create: bool = True,
55
56
  encoder: Callable[..., bytes] = pickle.dumps,
56
57
  decoder: Callable[..., Any] = pickle.loads,
57
58
  codecs: str | None = None,
@@ -66,6 +67,11 @@ def store(
66
67
  db_name: IndexedDB database name (default ``"kvgit"``).
67
68
  Only used when ``kind="indexeddb"``.
68
69
  branch: Branch name (default ``"main"``).
70
+ create: Mint the branch with an initial commit when it does not
71
+ exist (default ``True``). Pass ``False`` to raise
72
+ :class:`~kvgit.errors.UnknownBranchError` instead, so a
73
+ read after a delete cannot resurrect the branch and block
74
+ a later create.
69
75
  encoder: Value encoder (default ``pickle.dumps``).
70
76
  decoder: Value decoder (default ``pickle.loads``).
71
77
  codecs: Optional named codec preset. Currently supported:
@@ -106,6 +112,7 @@ def store(
106
112
  VersionedKV(
107
113
  backend,
108
114
  branch=branch,
115
+ create=create,
109
116
  recover_from_corrupt_head=recover_from_corrupt_head,
110
117
  ),
111
118
  encoder=encoder,
@@ -118,6 +118,15 @@ class VersionedBase(ABC):
118
118
  target = commit_hash or self._current_commit
119
119
  return self._load_parents(target)
120
120
 
121
+ def merge_base(self, commit_a: str, commit_b: str) -> str | None:
122
+ """Lowest common ancestor of two commits, or None if unrelated.
123
+
124
+ When several commits tie for lowest (criss-cross histories),
125
+ the smallest hash wins — deterministic, but arbitrary. This is
126
+ exactly the base a merge of the two commits would use.
127
+ """
128
+ return self._find_lca(commit_a, commit_b)
129
+
121
130
  # -- Commit orchestration --
122
131
 
123
132
  def commit(
@@ -137,6 +146,10 @@ class VersionedBase(ABC):
137
146
 
138
147
  Creates a new commit with the given changes and advances the
139
148
  branch HEAD. If HEAD has diverged, performs a three-way merge.
149
+ A HEAD move that lands inside the fast-forward CAS window is
150
+ retried once against the new HEAD through that same merge path,
151
+ so a single well-behaved writer never sees ``ConcurrencyError``
152
+ for a race it can merge past.
140
153
 
141
154
  Args:
142
155
  updates: Key-value pairs to add or update (bytes values).
@@ -182,6 +195,7 @@ class VersionedBase(ABC):
182
195
  )
183
196
 
184
197
  current_head = self.latest_head
198
+ ours_built = False
185
199
 
186
200
  if current_head == self._base_commit:
187
201
  # Fast-forward path
@@ -205,31 +219,43 @@ class VersionedBase(ABC):
205
219
  )
206
220
  self.last_merge_result = result
207
221
  return result
208
- self._restore_state(saved)
209
- if on_conflict == "abandon":
210
- result = MergeResult(
211
- merged=False,
212
- commit=None,
213
- strategy="fast_forward",
214
- auto_merged_keys=(),
215
- carried_keys=(),
216
- )
217
- self.last_merge_result = result
218
- return result
219
- raise ConcurrencyError(
220
- f"HEAD changed from {self._base_commit}. Refresh and retry."
221
- )
222
+ # Lost the fast-forward race: another writer advanced HEAD
223
+ # between our read and our CAS. Re-read HEAD and merge, the
224
+ # way the base-behind-head case already does — in either
225
+ # mode, so a lost race is never mistaken for a conflict and
226
+ # the caller never has to refresh (and drop staged work)
227
+ # just to replay a mergeable commit.
228
+ try:
229
+ current_head = self.latest_head
230
+ except Exception:
231
+ # A failing re-read must not leave phantom state behind
232
+ # either.
233
+ self._restore_state(saved)
234
+ raise
235
+ # Keep the commit just built as our side of the merge rather
236
+ # than restoring and rebuilding it: a rebuild hashes
237
+ # identically (created_at is not in the commit hash) but
238
+ # writes different HAMT nodes, orphaning the first attempt's
239
+ # nodes where clean_orphans cannot find them.
240
+ ours_built = True
222
241
 
223
242
  # Three-way merge path
224
243
  if current_head is None:
244
+ # A retry that finds its branch gone restores the pre-commit
245
+ # snapshot, so the failed commit leaves the handle exactly as
246
+ # it found it. A null first read builds nothing, so only a
247
+ # retry can have anything to restore.
248
+ if ours_built:
249
+ self._restore_state(saved)
225
250
  raise ValueError(f"Branch '{self._branch}' has no HEAD")
226
- saved = self._snapshot_state()
227
- self._create_commit(
228
- updates,
229
- removals,
230
- chunks=chunks,
231
- chunk_refs=chunk_refs,
232
- )
251
+ if not ours_built:
252
+ saved = self._snapshot_state()
253
+ self._create_commit(
254
+ updates,
255
+ removals,
256
+ chunks=chunks,
257
+ chunk_refs=chunk_refs,
258
+ )
233
259
  return self._three_way_merge(
234
260
  current_head,
235
261
  on_conflict=on_conflict,
@@ -65,10 +65,11 @@ import logging
65
65
  import os
66
66
  import time
67
67
  import uuid
68
+ from collections import deque
68
69
  from collections.abc import Callable
69
70
 
70
71
  from ..encoding import dumps, loads, safe_loads
71
- from ..errors import GcBusy
72
+ from ..errors import GcBusy, UnknownBranchError
72
73
  from ..hamt import EMPTY_HASH
73
74
  from ..kv.base import KVStore
74
75
  from ..kv.memory import Memory
@@ -1184,6 +1185,7 @@ class VersionedKV(VersionedBase):
1184
1185
  *,
1185
1186
  commit_hash: str | None = None,
1186
1187
  branch: str = "main",
1188
+ create: bool = True,
1187
1189
  recover_from_corrupt_head: CorruptHeadRecoverer | None = None,
1188
1190
  ) -> None:
1189
1191
  if store is None:
@@ -1205,6 +1207,11 @@ class VersionedKV(VersionedBase):
1205
1207
  if commit_hash is None and store.get(BRANCH_HEAD % branch) is not None:
1206
1208
  raise ValueError(f"Branch '{branch}' HEAD is corrupt and unrecoverable")
1207
1209
  if commit_hash is None:
1210
+ if not create:
1211
+ raise UnknownBranchError(
1212
+ f"Branch '{branch}' does not exist "
1213
+ "(open with create=True to create it)"
1214
+ )
1208
1215
  # Create initial empty commit
1209
1216
  commit_hash = content_hash((), {}, {})
1210
1217
  initial = {
@@ -1578,41 +1585,71 @@ class VersionedKV(VersionedBase):
1578
1585
  return tuple(raw)
1579
1586
 
1580
1587
  def _find_lca(self, commit_a: str, commit_b: str) -> str | None:
1581
- """Find the lowest common ancestor of two commits."""
1588
+ """Find the lowest common ancestor of two commits.
1589
+
1590
+ Ancestor-set intersection with non-minimal candidates dropped
1591
+ (a candidate that is itself an ancestor of another candidate is
1592
+ not lowest). When several commits tie for lowest — criss-cross
1593
+ histories — the smallest hash wins: deterministic, but
1594
+ arbitrary, so criss-cross merges resolve cleanly rather than
1595
+ raising.
1596
+ """
1582
1597
  if commit_a == commit_b:
1583
1598
  return commit_a
1584
1599
 
1585
- from collections import deque
1586
-
1587
- seen_a: set[str] = {commit_a}
1588
- seen_b: set[str] = {commit_b}
1589
- queue_a: deque[str] = deque([commit_a])
1590
- queue_b: deque[str] = deque([commit_b])
1591
-
1592
- while queue_a or queue_b:
1593
- if queue_a:
1594
- current = queue_a.popleft()
1595
- if current in seen_b:
1596
- return current
1597
- for p in self._load_parents(current):
1598
- if p not in seen_a:
1599
- seen_a.add(p)
1600
- queue_a.append(p)
1601
- if p in seen_b:
1602
- return p
1603
-
1604
- if queue_b:
1605
- current = queue_b.popleft()
1606
- if current in seen_a:
1607
- return current
1608
- for p in self._load_parents(current):
1609
- if p not in seen_b:
1610
- seen_b.add(p)
1611
- queue_b.append(p)
1612
- if p in seen_a:
1613
- return p
1600
+ parents: dict[str, tuple[str, ...]] = {}
1601
+ ancestors_a = self._walk_ancestors(commit_a, parents)
1602
+ # Fast path: b inside a's history (or vice versa) names the
1603
+ # lowest directly — every other common ancestor sits above it.
1604
+ if commit_b in ancestors_a:
1605
+ return commit_b
1606
+ ancestors_b = self._walk_ancestors(commit_b, parents)
1607
+ if commit_a in ancestors_b:
1608
+ return commit_a
1614
1609
 
1615
- return None
1610
+ common = ancestors_a & ancestors_b
1611
+ if not common:
1612
+ return None
1613
+ # Minimality in one bottom-up pass: a candidate is lowest when
1614
+ # no other candidate sits below it. Propagate "a candidate is
1615
+ # at-or-below here" from tips to roots over the in-memory
1616
+ # parent map — no further store reads, linear in the history.
1617
+ children: dict[str, list[str]] = {node: [] for node in parents}
1618
+ for node, node_parents in parents.items():
1619
+ for parent in node_parents:
1620
+ children[parent].append(node)
1621
+ below: dict[str, bool] = dict.fromkeys(parents, False)
1622
+ remaining = {node: len(kids) for node, kids in children.items()}
1623
+ queue = deque(node for node, kids in children.items() if not kids)
1624
+ while queue:
1625
+ node = queue.popleft()
1626
+ for parent in parents[node]:
1627
+ if node in common or below[node]:
1628
+ below[parent] = True
1629
+ remaining[parent] -= 1
1630
+ if remaining[parent] == 0:
1631
+ queue.append(parent)
1632
+ best = {c for c in common if not below[c]}
1633
+ return min(best) if best else None
1634
+
1635
+ def _walk_ancestors(
1636
+ self, start: str, parents: dict[str, tuple[str, ...]]
1637
+ ) -> set[str]:
1638
+ """All ancestors of ``start`` (itself included), recording each
1639
+ visited commit's parents in ``parents`` for later passes."""
1640
+ ancestors = {start}
1641
+ stack = [start]
1642
+ while stack:
1643
+ current = stack.pop()
1644
+ if current in parents:
1645
+ continue
1646
+ node_parents = self._load_parents(current)
1647
+ parents[current] = node_parents
1648
+ for parent in node_parents:
1649
+ if parent not in ancestors:
1650
+ ancestors.add(parent)
1651
+ stack.append(parent)
1652
+ return ancestors
1616
1653
 
1617
1654
  def _read_blob(self, content_id: str) -> bytes | None:
1618
1655
  """Read a blob by its versioned key."""
@@ -1731,7 +1768,7 @@ class VersionedKV(VersionedBase):
1731
1768
  if commit_hash is None:
1732
1769
  if self.store.get(BRANCH_HEAD % name) is not None:
1733
1770
  raise ValueError(f"Branch '{name}' HEAD is corrupt and unrecoverable")
1734
- raise ValueError(f"Branch '{name}' does not exist")
1771
+ raise UnknownBranchError(f"Branch '{name}' does not exist")
1735
1772
  self._branch = name
1736
1773
  self._load_commit(commit_hash, update_base=True)
1737
1774
 
@@ -1816,6 +1853,25 @@ class VersionedKV(VersionedBase):
1816
1853
  """List all branch names in the store."""
1817
1854
  return VersionedKV.branches(self.store)
1818
1855
 
1856
+ @staticmethod
1857
+ def exists(store: KVStore, name: str) -> bool:
1858
+ """Whether a branch exists — openable without creating.
1859
+
1860
+ The same predicate :meth:`branches` lists by: the branch has a
1861
+ HEAD entry. Reserved ``refs/tags/`` names are never branches,
1862
+ so they read as missing here as they are excluded there. A
1863
+ damaged HEAD still counts as existing; opening it raises either
1864
+ way. Never writes, so checking cannot resurrect a deleted
1865
+ branch.
1866
+ """
1867
+ if not isinstance(name, str) or name.startswith(TAG_BRANCH_PREFIX):
1868
+ return False
1869
+ return store.get(BRANCH_HEAD % name) is not None
1870
+
1871
+ def branch_exists(self, name: str) -> bool:
1872
+ """Whether a branch exists in this handle's store."""
1873
+ return VersionedKV.exists(self.store, name)
1874
+
1819
1875
  # -- Tags --
1820
1876
 
1821
1877
  def tag(self, name: str, *, at: str | None = None, info: dict | None = None) -> str:
@@ -219,8 +219,14 @@ class Versioned(Protocol):
219
219
 
220
220
  def list_branches(self) -> list[str]: ...
221
221
 
222
+ def branch_exists(self, name: str) -> bool: ...
223
+
222
224
  def commit_info(self, commit_hash: str | None = None) -> dict | None: ...
223
225
 
224
226
  def diff(self, commit_a: str, commit_b: str) -> DiffResult: ...
225
227
 
228
+ def merge_base(self, commit_a: str, commit_b: str) -> str | None:
229
+ """Lowest common ancestor of two commits, or None if unrelated."""
230
+ ...
231
+
226
232
  def parents(self, commit_hash: str | None = None) -> tuple[str, ...]: ...
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kvgit
3
- Version: 0.3.8
3
+ Version: 0.3.9
4
4
  Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
5
5
  Author: ashenfad
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kvgit"
7
- version = "0.3.8"
7
+ version = "0.3.9"
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"
@@ -189,3 +189,25 @@ class TestTextMerge:
189
189
  fn = text_merge()
190
190
  with pytest.raises(CantMark):
191
191
  fn(None, "text\n", b"\x00binary")
192
+
193
+ def test_strict_raises_on_conflict(self):
194
+ fn = text_merge(strict=True)
195
+ with pytest.raises(CantMark):
196
+ fn("a\nb\n", "a\nX\n", "a\nY\n")
197
+
198
+ def test_strict_passes_clean_merge_through(self):
199
+ fn = text_merge(strict=True)
200
+ assert fn("a\nb\n", "a\nB\n", "a\nb\n") == "a\nB\n"
201
+
202
+ def test_strict_conflict_aborts_staged_merge(self):
203
+ main = Staged(Versioned(Memory()))
204
+ main["doc"] = "a\nb\nc\nd\n"
205
+ main.commit()
206
+ dev = main.create_branch("dev")
207
+ dev["doc"] = "a\nY\nc\nd\n"
208
+ dev.commit()
209
+ main["doc"] = "a\nX\nc\nd\n"
210
+ main.commit()
211
+ with pytest.raises(MergeConflict) as exc_info:
212
+ main.merge(dev.current_commit, default_merge=text_merge(strict=True))
213
+ assert "doc" in exc_info.value.conflicting_keys
@@ -10,7 +10,7 @@ import pytest
10
10
 
11
11
  from kvgit import MergeConflict, VersionedKV as Versioned
12
12
  from kvgit.kv.memory import Memory
13
- from kvgit.merges import CantMark, make_text_merge, text
13
+ from kvgit.merges import CantMark, make_text_merge, text, text_merge_result
14
14
 
15
15
  GIT = shutil.which("git")
16
16
  needs_git = pytest.mark.skipif(GIT is None, reason="git binary not available")
@@ -45,6 +45,14 @@ class TestCleanMerges:
45
45
  def test_no_trailing_newline_preserved(self):
46
46
  assert text(b"a\nb", b"a\nB", b"a\nb") == b"a\nB"
47
47
 
48
+ def test_non_lf_endings_preserved_on_clean_merge(self):
49
+ """Lines ending in a non-LF splitlines boundary (lone CR here)
50
+ are already terminated: gluing the next line after one re-splits
51
+ to the same lines, so a clean merge must not rewrite them to
52
+ CRLF."""
53
+ base = b"a\rb\rc\r"
54
+ assert text(base, b"a\rB\rc\r", b"a\rb\rC\r") == b"a\rB\rC\r"
55
+
48
56
  def test_empty_inputs(self):
49
57
  assert text(b"", b"", b"") == b""
50
58
 
@@ -79,6 +87,34 @@ class TestConflicts:
79
87
  with pytest.raises(ValueError):
80
88
  make_text_merge(ours_label="a\nb")
81
89
 
90
+ @pytest.mark.parametrize(
91
+ "bad",
92
+ [
93
+ "a\rb",
94
+ "a\vb",
95
+ "a\fb",
96
+ "a\x1cb",
97
+ "a\x1db",
98
+ "a\x1eb",
99
+ "a\x85b",
100
+ "a\u2028b",
101
+ "a\u2029b",
102
+ ],
103
+ )
104
+ def test_label_other_line_breaks_rejected(self, bad):
105
+ """Every str.splitlines boundary can split a marker line for some
106
+ consumer, so labels may not contain any of them — not just LF."""
107
+ with pytest.raises(ValueError):
108
+ make_text_merge(ours_label=bad)
109
+ with pytest.raises(ValueError):
110
+ make_text_merge(theirs_label=bad)
111
+
112
+ def test_conflict_markers_over_cr_content(self):
113
+ """Conflict markers still arrive newline-terminated while CR
114
+ content lines pass through byte-exact."""
115
+ out = text(b"a\rb\rc\r", b"a\rB\rc\r", b"a\rX\rc\r")
116
+ assert out == b"a\r<<<<<<< ours\nB\r=======\nX\r>>>>>>> theirs\nc\r"
117
+
82
118
  def test_deterministic(self):
83
119
  args = (BASE, b"a\nX\nc\nd\n", b"a\nY\nc\nd\n")
84
120
  assert text(*args) == text(*args)
@@ -298,3 +334,87 @@ class TestVersionedIntegration:
298
334
  v2.commit({"doc": b"a\nb\nc\nD\n"}, default_merge=text_merge)
299
335
  assert "doc" in exc_info.value.conflicting_keys
300
336
  assert isinstance(exc_info.value.merge_errors["doc"], CantMark)
337
+
338
+
339
+ class TestStrictAndFlag:
340
+ """Issue #38: strict mode aborts instead of marking; the flag reports
341
+ whether hunks were introduced (marker-like content merging cleanly
342
+ must not count)."""
343
+
344
+ def test_strict_raises_on_conflict(self):
345
+ fn = make_text_merge(strict=True)
346
+ with pytest.raises(CantMark):
347
+ fn(BASE, b"a\nX\nc\nd\n", b"a\nY\nc\nd\n")
348
+
349
+ def test_strict_passes_clean_merge_through(self):
350
+ fn = make_text_merge(strict=True)
351
+ assert fn(BASE, b"a\nB\nc\nd\n", b"a\nb\nc\nD\n") == b"a\nB\nc\nD\n"
352
+
353
+ def test_strict_ignores_carried_marker_like_content(self):
354
+ markerish = b"<<<<<<< not a real hunk\nsame\n"
355
+ fn = make_text_merge(strict=True)
356
+ assert fn(BASE, markerish, BASE) == markerish
357
+
358
+ def test_non_strict_default_still_marks(self):
359
+ out = make_text_merge()(BASE, b"a\nX\nc\nd\n", b"a\nY\nc\nd\n")
360
+ assert b"<<<<<<< ours\n" in out
361
+
362
+ def test_flag_false_on_clean_merge(self):
363
+ merged, conflicted = text_merge_result(BASE, b"a\nB\nc\nd\n", b"a\nb\nc\nD\n")
364
+ assert (merged, conflicted) == (b"a\nB\nc\nD\n", False)
365
+
366
+ def test_flag_false_for_carried_marker_like_content(self):
367
+ markerish = b"<<<<<<< not a real hunk\nsame\n"
368
+ merged, conflicted = text_merge_result(BASE, markerish, BASE)
369
+ assert (merged, conflicted) == (markerish, False)
370
+
371
+ def test_flag_true_on_conflict_and_bytes_match_marking_fn(self):
372
+ args = (BASE, b"a\nX\nc\nd\n", b"a\nY\nc\nd\n")
373
+ merged, conflicted = text_merge_result(*args)
374
+ assert conflicted is True
375
+ assert merged == make_text_merge()(*args) == text(*args)
376
+
377
+ def test_flag_custom_labels(self):
378
+ merged, conflicted = text_merge_result(
379
+ BASE,
380
+ b"a\nX\nc\nd\n",
381
+ b"a\nY\nc\nd\n",
382
+ ours_label="main",
383
+ theirs_label="dev",
384
+ )
385
+ assert conflicted is True
386
+ assert b"<<<<<<< main\n" in merged
387
+ assert b">>>>>>> dev\n" in merged
388
+
389
+ def test_unmarkable_still_raises(self):
390
+ with pytest.raises(CantMark):
391
+ text_merge_result(BASE, b"\xff binary", BASE)
392
+ with pytest.raises(CantMark):
393
+ make_text_merge(strict=True)(BASE, b"\xff binary", BASE)
394
+
395
+ def test_strict_conflict_aborts_versioned_merge(self):
396
+ store = Memory()
397
+ v1 = Versioned(store)
398
+ v1.commit({"doc": BASE})
399
+ v2 = Versioned(store)
400
+ v1.commit({"doc": b"a\nX\nc\nd\n"})
401
+ with pytest.raises(MergeConflict) as exc_info:
402
+ v2.commit(
403
+ {"doc": b"a\nY\nc\nd\n"},
404
+ default_merge=make_text_merge(strict=True),
405
+ )
406
+ assert "doc" in exc_info.value.conflicting_keys
407
+ assert isinstance(exc_info.value.merge_errors["doc"], CantMark)
408
+
409
+ def test_strict_clean_change_still_merges(self):
410
+ store = Memory()
411
+ v1 = Versioned(store)
412
+ v1.commit({"doc": BASE})
413
+ v2 = Versioned(store)
414
+ v1.commit({"doc": b"a\nB\nc\nd\n"})
415
+ result = v2.commit(
416
+ {"doc": b"a\nb\nc\nD\n"},
417
+ default_merge=make_text_merge(strict=True),
418
+ )
419
+ assert result
420
+ assert v2.get("doc") == b"a\nB\nc\nD\n"
@@ -504,6 +504,40 @@ class TestStagedRefresh:
504
504
  assert not s.has_changes
505
505
 
506
506
 
507
+ class TestStagedCommitRace:
508
+ """Issue #39 at the Staged level: dirty before, committed after —
509
+ no refresh (which would discard the buffer) required."""
510
+
511
+ def test_commit_survives_fast_forward_race(self):
512
+ store = Memory()
513
+ s1 = Staged(Versioned(store))
514
+ s1["seed"] = "0"
515
+ s1.commit()
516
+ s2 = Staged(Versioned(store))
517
+
518
+ s1["mine"] = "2"
519
+ assert s1.has_changes
520
+ real_cas = s1.versioned._cas_head
521
+ raced = False
522
+
523
+ def cas(expected, new_head):
524
+ nonlocal raced
525
+ if not raced:
526
+ raced = True
527
+ s2["other"] = "1"
528
+ s2.commit()
529
+ return real_cas(expected, new_head)
530
+
531
+ s1.versioned._cas_head = cas
532
+ result = s1.commit()
533
+
534
+ assert result.merged
535
+ assert result.strategy == "three_way"
536
+ assert not s1.has_changes
537
+ assert s1.get("mine") == "2"
538
+ assert s1.get("other") == "1"
539
+
540
+
507
541
  class TestStagedEncoder:
508
542
  def test_custom_encoder_decoder(self):
509
543
  import json
@@ -26,6 +26,24 @@ class TestStoreFactory:
26
26
  assert isinstance(s, Staged)
27
27
  assert s.versioned._branch == "dev"
28
28
 
29
+ def test_branch_create_false_raises_on_fresh_store(self):
30
+ from kvgit import UnknownBranchError
31
+
32
+ with pytest.raises(UnknownBranchError):
33
+ store(branch="dev", create=False)
34
+
35
+ def test_branch_create_false_opens_shared_disk_branch(self):
36
+ from kvgit import UnknownBranchError
37
+
38
+ with tempfile.TemporaryDirectory() as path:
39
+ s = store(kind="disk", path=path)
40
+ s["k"] = "v"
41
+ s.commit()
42
+ s_main = store(kind="disk", path=path, branch="main", create=False)
43
+ assert s_main.get("k") == "v"
44
+ with pytest.raises(UnknownBranchError):
45
+ store(kind="disk", path=path, branch="dev", create=False)
46
+
29
47
 
30
48
  class TestStoreFactoryRoundTrip:
31
49
  def test_set_commit_get(self):
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