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.
Files changed (46) hide show
  1. {kvgit-0.3.4 → kvgit-0.3.6}/PKG-INFO +6 -1
  2. {kvgit-0.3.4 → kvgit-0.3.6}/README.md +5 -0
  3. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/__init__.py +9 -2
  4. kvgit-0.3.6/kvgit/merges.py +247 -0
  5. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/staged.py +52 -7
  6. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/store.py +69 -1
  7. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/versioned/__init__.py +2 -1
  8. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/versioned/kv.py +385 -36
  9. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/versioned/protocol.py +41 -2
  10. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit.egg-info/PKG-INFO +6 -1
  11. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit.egg-info/SOURCES.txt +2 -0
  12. {kvgit-0.3.4 → kvgit-0.3.6}/pyproject.toml +1 -1
  13. kvgit-0.3.6/tests/test_merges.py +300 -0
  14. {kvgit-0.3.4 → kvgit-0.3.6}/LICENSE +0 -0
  15. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/codecs/__init__.py +0 -0
  16. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/codecs/_hash.py +0 -0
  17. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/codecs/base.py +0 -0
  18. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/codecs/numpy.py +0 -0
  19. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/codecs/pandas.py +0 -0
  20. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/codecs/pickler.py +0 -0
  21. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/content_types.py +0 -0
  22. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/encoding.py +0 -0
  23. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/errors.py +0 -0
  24. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/hamt.py +0 -0
  25. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/kv/__init__.py +0 -0
  26. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/kv/base.py +0 -0
  27. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/kv/composite.py +0 -0
  28. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/kv/disk.py +0 -0
  29. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/kv/indexeddb.py +0 -0
  30. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/kv/memory.py +0 -0
  31. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/namespaced.py +0 -0
  32. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/py.typed +0 -0
  33. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/versioned/base.py +0 -0
  34. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/versioned/helpers.py +0 -0
  35. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/versioned/keyset.py +0 -0
  36. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit/versioned/merge.py +0 -0
  37. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit.egg-info/dependency_links.txt +0 -0
  38. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit.egg-info/requires.txt +0 -0
  39. {kvgit-0.3.4 → kvgit-0.3.6}/kvgit.egg-info/top_level.txt +0 -0
  40. {kvgit-0.3.4 → kvgit-0.3.6}/setup.cfg +0 -0
  41. {kvgit-0.3.4 → kvgit-0.3.6}/tests/test_content_types.py +0 -0
  42. {kvgit-0.3.4 → kvgit-0.3.6}/tests/test_delete_branches.py +0 -0
  43. {kvgit-0.3.4 → kvgit-0.3.6}/tests/test_hamt.py +0 -0
  44. {kvgit-0.3.4 → kvgit-0.3.6}/tests/test_namespaced.py +0 -0
  45. {kvgit-0.3.4 → kvgit-0.3.6}/tests/test_staged.py +0 -0
  46. {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.4
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, commit_hash: str, *, branch: str | None = None
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. Returns None if not found."""
413
- v = self._versioned.checkout(commit_hash, branch=branch)
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(self, key: str, *, branch: str) -> Any:
434
- """Read a key from another branch without switching. Returns None if not found."""
435
- raw = self._versioned.peek(key, branch=branch)
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. All heads
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
  ]