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.
- {kvgit-0.3.8 → kvgit-0.3.9}/PKG-INFO +1 -1
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/__init__.py +12 -2
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/content_types.py +14 -5
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/errors.py +9 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/merges.py +76 -24
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/staged.py +8 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/store.py +7 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/versioned/base.py +47 -21
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/versioned/kv.py +89 -33
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/versioned/protocol.py +6 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit.egg-info/PKG-INFO +1 -1
- {kvgit-0.3.8 → kvgit-0.3.9}/pyproject.toml +1 -1
- {kvgit-0.3.8 → kvgit-0.3.9}/tests/test_content_types.py +22 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/tests/test_merges.py +121 -1
- {kvgit-0.3.8 → kvgit-0.3.9}/tests/test_staged.py +34 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/tests/test_store_factory.py +18 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/LICENSE +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/README.md +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/codecs/__init__.py +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/codecs/_hash.py +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/codecs/base.py +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/codecs/numpy.py +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/codecs/pandas.py +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/codecs/pickler.py +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/encoding.py +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/hamt.py +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/kv/__init__.py +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/kv/base.py +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/kv/composite.py +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/kv/disk.py +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/kv/indexeddb.py +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/kv/memory.py +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/namespaced.py +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/py.typed +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/versioned/__init__.py +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/versioned/helpers.py +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/versioned/keyset.py +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit/versioned/merge.py +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit.egg-info/SOURCES.txt +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit.egg-info/dependency_links.txt +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit.egg-info/requires.txt +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/kvgit.egg-info/top_level.txt +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/setup.cfg +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/tests/test_delete_branches.py +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/tests/test_hamt.py +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/tests/test_merge_policy.py +0 -0
- {kvgit-0.3.8 → kvgit-0.3.9}/tests/test_namespaced.py +0 -0
|
@@ -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
|
|
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(
|
|
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.
|
|
47
|
-
|
|
48
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
83
|
+
"""Ensure every non-final line ends with a line break.
|
|
73
84
|
|
|
74
|
-
A line without a trailing
|
|
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
|
|
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
|
|
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
|
-
*,
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
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
|
-
|
|
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
|
|
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, ...]: ...
|
|
@@ -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.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|