kvgit 0.3.4__tar.gz → 0.3.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {kvgit-0.3.4 → kvgit-0.3.6}/PKG-INFO +6 -1
- {kvgit-0.3.4 → kvgit-0.3.6}/README.md +5 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/__init__.py +9 -2
- kvgit-0.3.6/kvgit/merges.py +247 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/staged.py +52 -7
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/store.py +69 -1
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/versioned/__init__.py +2 -1
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/versioned/kv.py +385 -36
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/versioned/protocol.py +41 -2
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit.egg-info/PKG-INFO +6 -1
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit.egg-info/SOURCES.txt +2 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/pyproject.toml +1 -1
- kvgit-0.3.6/tests/test_merges.py +300 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/LICENSE +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/codecs/__init__.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/codecs/_hash.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/codecs/base.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/codecs/numpy.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/codecs/pandas.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/codecs/pickler.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/content_types.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/encoding.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/errors.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/hamt.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/kv/__init__.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/kv/base.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/kv/composite.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/kv/disk.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/kv/indexeddb.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/kv/memory.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/namespaced.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/py.typed +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/versioned/base.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/versioned/helpers.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/versioned/keyset.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/versioned/merge.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit.egg-info/dependency_links.txt +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit.egg-info/requires.txt +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/kvgit.egg-info/top_level.txt +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/setup.cfg +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/tests/test_content_types.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/tests/test_delete_branches.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/tests/test_hamt.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/tests/test_namespaced.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/tests/test_staged.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.6}/tests/test_store_factory.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kvgit
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.6
|
|
4
4
|
Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
|
|
5
5
|
Author: ashenfad
|
|
6
6
|
License: MIT
|
|
@@ -56,6 +56,7 @@ Git-style versioning for your data. Commits, branches, and merges -- backed by a
|
|
|
56
56
|
| **Dict interface** | `MutableMapping[str, Any]` -- reads and writes work like a dict |
|
|
57
57
|
| **Commits** | Immutable, content-addressable snapshots with rollback |
|
|
58
58
|
| **Branches** | Cheap forks with CAS-based optimistic concurrency |
|
|
59
|
+
| **Tags** | Immutable names for commits; a tagged commit outlives every branch that reached it, in every kvgit version |
|
|
59
60
|
| **Three-way merge** | Auto-merges non-overlapping changes; pluggable merge fns for conflicts |
|
|
60
61
|
| **Pluggable backends** | In-memory, disk (diskcache), IndexedDB (Pyodide/browser), or bring your own `KVStore` |
|
|
61
62
|
| **Chunked codecs** | Optional content-addressed dedup for large numpy arrays and pandas DataFrames -- equal buffers stored once across keys, commits, and branches |
|
|
@@ -87,6 +88,10 @@ dev.commit()
|
|
|
87
88
|
|
|
88
89
|
print(main["score"]) # 0 (main unchanged)
|
|
89
90
|
print(dev["score"]) # 999 (dev branch)
|
|
91
|
+
|
|
92
|
+
# Tag a commit by name -- immutable, and safe from garbage collection
|
|
93
|
+
main.tag("v1")
|
|
94
|
+
print(main.peek("score", tag="v1")) # 0
|
|
90
95
|
```
|
|
91
96
|
|
|
92
97
|
## Chunked codecs (numpy / pandas)
|
|
@@ -7,6 +7,7 @@ Git-style versioning for your data. Commits, branches, and merges -- backed by a
|
|
|
7
7
|
| **Dict interface** | `MutableMapping[str, Any]` -- reads and writes work like a dict |
|
|
8
8
|
| **Commits** | Immutable, content-addressable snapshots with rollback |
|
|
9
9
|
| **Branches** | Cheap forks with CAS-based optimistic concurrency |
|
|
10
|
+
| **Tags** | Immutable names for commits; a tagged commit outlives every branch that reached it, in every kvgit version |
|
|
10
11
|
| **Three-way merge** | Auto-merges non-overlapping changes; pluggable merge fns for conflicts |
|
|
11
12
|
| **Pluggable backends** | In-memory, disk (diskcache), IndexedDB (Pyodide/browser), or bring your own `KVStore` |
|
|
12
13
|
| **Chunked codecs** | Optional content-addressed dedup for large numpy arrays and pandas DataFrames -- equal buffers stored once across keys, commits, and branches |
|
|
@@ -38,6 +39,10 @@ dev.commit()
|
|
|
38
39
|
|
|
39
40
|
print(main["score"]) # 0 (main unchanged)
|
|
40
41
|
print(dev["score"]) # 999 (dev branch)
|
|
42
|
+
|
|
43
|
+
# Tag a commit by name -- immutable, and safe from garbage collection
|
|
44
|
+
main.tag("v1")
|
|
45
|
+
print(main.peek("score", tag="v1")) # 0
|
|
41
46
|
```
|
|
42
47
|
|
|
43
48
|
## Chunked codecs (numpy / pandas)
|
|
@@ -2,23 +2,30 @@
|
|
|
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
|
-
from .store import delete_branches, store
|
|
8
|
+
from .store import delete_branches, delete_tags, store
|
|
8
9
|
from .versioned.kv import VersionedKV
|
|
9
|
-
from .versioned.protocol import MergeResult, Versioned
|
|
10
|
+
from .versioned.protocol import MergeResult, TagInfo, Versioned
|
|
10
11
|
|
|
11
12
|
__all__ = [
|
|
13
|
+
"CantMark",
|
|
12
14
|
"ConcurrencyError",
|
|
13
15
|
"MergeConflict",
|
|
14
16
|
"MergeFn",
|
|
15
17
|
"MergeResult",
|
|
16
18
|
"Namespaced",
|
|
17
19
|
"Staged",
|
|
20
|
+
"TagInfo",
|
|
21
|
+
"TextMergeFn",
|
|
18
22
|
"Versioned",
|
|
19
23
|
"VersionedKV",
|
|
20
24
|
"counter",
|
|
21
25
|
"delete_branches",
|
|
26
|
+
"delete_tags",
|
|
22
27
|
"last_writer_wins",
|
|
28
|
+
"make_text_merge",
|
|
23
29
|
"store",
|
|
30
|
+
"text",
|
|
24
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,7 @@ 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, Versioned
|
|
11
|
+
from .versioned.protocol import BytesMergeFn, MergeResult, TagInfo, Versioned
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class _ChunkSink:
|
|
@@ -407,10 +407,22 @@ class Staged(MutableMapping[str, Any]):
|
|
|
407
407
|
)
|
|
408
408
|
|
|
409
409
|
def checkout(
|
|
410
|
-
self,
|
|
410
|
+
self,
|
|
411
|
+
commit_hash: str | None = None,
|
|
412
|
+
*,
|
|
413
|
+
branch: str | None = None,
|
|
414
|
+
tag: str | None = None,
|
|
411
415
|
) -> "Staged | None":
|
|
412
|
-
"""Create a new Staged at a specific commit
|
|
413
|
-
|
|
416
|
+
"""Create a new Staged at a specific commit or tag.
|
|
417
|
+
|
|
418
|
+
Pass exactly one of ``commit_hash`` or ``tag``. Returns None if
|
|
419
|
+
the commit or tag is not in the store.
|
|
420
|
+
|
|
421
|
+
The handle is a normal writable one on the caller's branch: a
|
|
422
|
+
commit from it advances that branch when it has not moved, and
|
|
423
|
+
conflicts when it has.
|
|
424
|
+
"""
|
|
425
|
+
v = self._versioned.checkout(commit_hash, branch=branch, tag=tag)
|
|
414
426
|
if v is None:
|
|
415
427
|
return None
|
|
416
428
|
return Staged(v, encoder=self._encoder, decoder=self._decoder)
|
|
@@ -430,13 +442,46 @@ class Staged(MutableMapping[str, Any]):
|
|
|
430
442
|
self._removals.clear()
|
|
431
443
|
self._cache.clear()
|
|
432
444
|
|
|
433
|
-
def peek(
|
|
434
|
-
|
|
435
|
-
|
|
445
|
+
def peek(
|
|
446
|
+
self, key: str, *, branch: str | None = None, tag: str | None = None
|
|
447
|
+
) -> Any:
|
|
448
|
+
"""Read a key from another branch, or from a tag, without switching.
|
|
449
|
+
|
|
450
|
+
Pass exactly one of ``branch`` or ``tag``. Returns None if not
|
|
451
|
+
found.
|
|
452
|
+
"""
|
|
453
|
+
raw = self._versioned.peek(key, branch=branch, tag=tag)
|
|
436
454
|
if raw is None:
|
|
437
455
|
return None
|
|
438
456
|
return self._decode(raw)
|
|
439
457
|
|
|
458
|
+
# -- Tags --
|
|
459
|
+
|
|
460
|
+
def tag(self, name: str, *, at: str | None = None, info: dict | None = None) -> str:
|
|
461
|
+
"""Name a commit permanently. Returns the commit hash tagged.
|
|
462
|
+
|
|
463
|
+
Tags the **current commit** — the last committed state — not the
|
|
464
|
+
staging buffer. Staged changes are not part of any commit yet,
|
|
465
|
+
so there is nothing there to name; commit first if you meant to
|
|
466
|
+
tag them.
|
|
467
|
+
|
|
468
|
+
Immutable: an existing name raises rather than moving. Tags keep
|
|
469
|
+
their commit's ancestry alive through garbage collection.
|
|
470
|
+
"""
|
|
471
|
+
return self._versioned.tag(name, at=at, info=info)
|
|
472
|
+
|
|
473
|
+
def tags(self) -> dict[str, str]:
|
|
474
|
+
"""Map every tag in the store to the commit it names."""
|
|
475
|
+
return self._versioned.tags()
|
|
476
|
+
|
|
477
|
+
def tag_info(self, name: str) -> TagInfo | None:
|
|
478
|
+
"""Describe one tag, or None if the store has no such tag."""
|
|
479
|
+
return self._versioned.tag_info(name)
|
|
480
|
+
|
|
481
|
+
def delete_tag(self, name: str) -> None:
|
|
482
|
+
"""Delete a tag, then sweep commits nothing else reaches."""
|
|
483
|
+
self._versioned.delete_tag(name)
|
|
484
|
+
|
|
440
485
|
def reset_to(self, commit_hash: str) -> bool:
|
|
441
486
|
"""Reset HEAD to a specific commit and clear staged changes.
|
|
442
487
|
|
|
@@ -10,8 +10,11 @@ from .staged import Staged
|
|
|
10
10
|
from .versioned.kv import (
|
|
11
11
|
BRANCH_HEAD,
|
|
12
12
|
BRANCH_HEAD_PREV,
|
|
13
|
+
TAG_BRANCH_PREFIX,
|
|
14
|
+
TAG_INFO_KEY,
|
|
13
15
|
CorruptHeadRecoverer,
|
|
14
16
|
VersionedKV,
|
|
17
|
+
_assert_supported_version,
|
|
15
18
|
clean_orphans,
|
|
16
19
|
)
|
|
17
20
|
|
|
@@ -137,7 +140,10 @@ def delete_branches(
|
|
|
137
140
|
just mints a fresh empty ``main`` on next open.
|
|
138
141
|
|
|
139
142
|
One :func:`clean_orphans` sweep runs after all removals, so commits
|
|
140
|
-
that only the deleted branches referenced are reclaimed.
|
|
143
|
+
that only the deleted branches referenced are reclaimed. A tag is a
|
|
144
|
+
head under the reserved ``refs/tags/`` name, so a tagged commit
|
|
145
|
+
survives the deletion of every ordinary branch that reached it —
|
|
146
|
+
the sweep marks from it like any other head. All heads
|
|
141
147
|
(and prev-heads) are gone before the sweep, so it can't mistake a
|
|
142
148
|
doomed branch for a live one. ``min_age`` defaults to
|
|
143
149
|
``clean_orphans``'s one-hour guard (matching ``delete_branch``), so
|
|
@@ -169,6 +175,12 @@ def delete_branches(
|
|
|
169
175
|
return # nothing asked: skip the backend open and the sweep
|
|
170
176
|
backend = _make_backend(kind, path=path, db_name=db_name)
|
|
171
177
|
try:
|
|
178
|
+
# No ``VersionedKV`` is constructed here, so nothing else on this
|
|
179
|
+
# path checks the store's layout version. Checked before the
|
|
180
|
+
# first removal rather than leaving it to the sweep: a store this
|
|
181
|
+
# code cannot read must come out of the call untouched, not with
|
|
182
|
+
# its branch keys already gone.
|
|
183
|
+
_assert_supported_version(backend)
|
|
172
184
|
for name in doomed:
|
|
173
185
|
backend.remove(BRANCH_HEAD % name)
|
|
174
186
|
backend.remove(BRANCH_HEAD_PREV % name)
|
|
@@ -177,3 +189,59 @@ def delete_branches(
|
|
|
177
189
|
close = getattr(backend, "close", None)
|
|
178
190
|
if callable(close):
|
|
179
191
|
close()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def delete_tags(
|
|
195
|
+
names: str | Iterable[str],
|
|
196
|
+
*,
|
|
197
|
+
kind: Literal["memory", "disk", "indexeddb"] = "disk",
|
|
198
|
+
path: str | None = None,
|
|
199
|
+
db_name: str = "kvgit",
|
|
200
|
+
min_age: float = 3600,
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Delete tags with no branch anchor, then sweep orphans.
|
|
203
|
+
|
|
204
|
+
The tag counterpart of :func:`delete_branches`, and anchor-free for
|
|
205
|
+
the same reason: teardown should not need a handle, and a handle
|
|
206
|
+
always has a current branch to open on. A tag is stored as a branch
|
|
207
|
+
head under the reserved name ``refs/tags/<name>``, so for each name
|
|
208
|
+
that head, its ``__branch_head_prev__`` backup and its
|
|
209
|
+
``__tag_info__<name>`` record are removed, then a single
|
|
210
|
+
:func:`clean_orphans` sweep reclaims commits that only the deleted
|
|
211
|
+
tags kept alive. The backup is defensive: this code never writes one
|
|
212
|
+
for a tag, but something that treated the tag as an ordinary branch
|
|
213
|
+
could have, and a backup outliving its head would let a later tag of
|
|
214
|
+
the same name resolve to the deleted commit. A name with no tag is
|
|
215
|
+
skipped — idempotency beats a ``ValueError`` in teardown.
|
|
216
|
+
|
|
217
|
+
That sweep does not reclaim chunks; follow up with ``deep_clean`` on
|
|
218
|
+
a quiescent store if the store uses chunked codecs.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
names: Tag names to delete — one name or an iterable of them.
|
|
222
|
+
Missing names are no-ops. (A bare string is treated as ONE
|
|
223
|
+
name, not iterated per character.)
|
|
224
|
+
kind: ``"disk"`` (default), ``"memory"``, or ``"indexeddb"``.
|
|
225
|
+
path: Required when ``kind="disk"``. Store directory.
|
|
226
|
+
db_name: IndexedDB database name (``kind="indexeddb"`` only).
|
|
227
|
+
min_age: Passed to :func:`clean_orphans` — commits younger than
|
|
228
|
+
this many seconds survive the sweep.
|
|
229
|
+
"""
|
|
230
|
+
doomed = {names} if isinstance(names, str) else set(names)
|
|
231
|
+
if not doomed:
|
|
232
|
+
return # nothing asked: skip the backend open and the sweep
|
|
233
|
+
backend = _make_backend(kind, path=path, db_name=db_name)
|
|
234
|
+
try:
|
|
235
|
+
# Same reasoning as delete_branches: no handle is constructed on
|
|
236
|
+
# this path, so the layout check has to happen here, before
|
|
237
|
+
# anything is removed.
|
|
238
|
+
_assert_supported_version(backend)
|
|
239
|
+
for name in doomed:
|
|
240
|
+
backend.remove(BRANCH_HEAD % (TAG_BRANCH_PREFIX + name))
|
|
241
|
+
backend.remove(BRANCH_HEAD_PREV % (TAG_BRANCH_PREFIX + name))
|
|
242
|
+
backend.remove(TAG_INFO_KEY % name)
|
|
243
|
+
clean_orphans(backend, min_age=min_age)
|
|
244
|
+
finally:
|
|
245
|
+
close = getattr(backend, "close", None)
|
|
246
|
+
if callable(close):
|
|
247
|
+
close()
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"""Versioned store implementations."""
|
|
2
2
|
|
|
3
3
|
from .kv import VersionedKV
|
|
4
|
-
from .protocol import BytesMergeFn, DiffResult, MergeResult, Versioned
|
|
4
|
+
from .protocol import BytesMergeFn, DiffResult, MergeResult, TagInfo, Versioned
|
|
5
5
|
|
|
6
6
|
__all__ = [
|
|
7
7
|
"BytesMergeFn",
|
|
8
8
|
"DiffResult",
|
|
9
9
|
"MergeResult",
|
|
10
|
+
"TagInfo",
|
|
10
11
|
"Versioned",
|
|
11
12
|
"VersionedKV",
|
|
12
13
|
]
|