kvgit 0.3.3__tar.gz → 0.3.4__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 (44) hide show
  1. {kvgit-0.3.3 → kvgit-0.3.4}/PKG-INFO +1 -1
  2. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/store.py +24 -2
  3. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/versioned/kv.py +208 -29
  4. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit.egg-info/PKG-INFO +1 -1
  5. {kvgit-0.3.3 → kvgit-0.3.4}/pyproject.toml +1 -1
  6. {kvgit-0.3.3 → kvgit-0.3.4}/LICENSE +0 -0
  7. {kvgit-0.3.3 → kvgit-0.3.4}/README.md +0 -0
  8. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/__init__.py +0 -0
  9. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/codecs/__init__.py +0 -0
  10. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/codecs/_hash.py +0 -0
  11. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/codecs/base.py +0 -0
  12. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/codecs/numpy.py +0 -0
  13. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/codecs/pandas.py +0 -0
  14. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/codecs/pickler.py +0 -0
  15. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/content_types.py +0 -0
  16. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/encoding.py +0 -0
  17. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/errors.py +0 -0
  18. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/hamt.py +0 -0
  19. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/kv/__init__.py +0 -0
  20. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/kv/base.py +0 -0
  21. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/kv/composite.py +0 -0
  22. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/kv/disk.py +0 -0
  23. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/kv/indexeddb.py +0 -0
  24. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/kv/memory.py +0 -0
  25. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/namespaced.py +0 -0
  26. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/py.typed +0 -0
  27. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/staged.py +0 -0
  28. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/versioned/__init__.py +0 -0
  29. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/versioned/base.py +0 -0
  30. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/versioned/helpers.py +0 -0
  31. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/versioned/keyset.py +0 -0
  32. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/versioned/merge.py +0 -0
  33. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/versioned/protocol.py +0 -0
  34. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit.egg-info/SOURCES.txt +0 -0
  35. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit.egg-info/dependency_links.txt +0 -0
  36. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit.egg-info/requires.txt +0 -0
  37. {kvgit-0.3.3 → kvgit-0.3.4}/kvgit.egg-info/top_level.txt +0 -0
  38. {kvgit-0.3.3 → kvgit-0.3.4}/setup.cfg +0 -0
  39. {kvgit-0.3.3 → kvgit-0.3.4}/tests/test_content_types.py +0 -0
  40. {kvgit-0.3.3 → kvgit-0.3.4}/tests/test_delete_branches.py +0 -0
  41. {kvgit-0.3.3 → kvgit-0.3.4}/tests/test_hamt.py +0 -0
  42. {kvgit-0.3.3 → kvgit-0.3.4}/tests/test_namespaced.py +0 -0
  43. {kvgit-0.3.3 → kvgit-0.3.4}/tests/test_staged.py +0 -0
  44. {kvgit-0.3.3 → kvgit-0.3.4}/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
3
+ Version: 0.3.4
4
4
  Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
5
5
  Author: ashenfad
6
6
  License: MIT
@@ -7,7 +7,13 @@ from typing import Any, Literal
7
7
  from .kv.base import KVStore
8
8
  from .kv.memory import Memory
9
9
  from .staged import Staged
10
- from .versioned.kv import BRANCH_HEAD, BRANCH_HEAD_PREV, VersionedKV, clean_orphans
10
+ from .versioned.kv import (
11
+ BRANCH_HEAD,
12
+ BRANCH_HEAD_PREV,
13
+ CorruptHeadRecoverer,
14
+ VersionedKV,
15
+ clean_orphans,
16
+ )
11
17
 
12
18
 
13
19
  def _make_backend(
@@ -46,6 +52,7 @@ def store(
46
52
  encoder: Callable[..., bytes] = pickle.dumps,
47
53
  decoder: Callable[..., Any] = pickle.loads,
48
54
  codecs: str | None = None,
55
+ recover_from_corrupt_head: CorruptHeadRecoverer | None = None,
49
56
  ) -> Staged:
50
57
  """Create a Staged store with sensible defaults.
51
58
 
@@ -62,6 +69,13 @@ def store(
62
69
  ``"scientific"`` — numpy/pandas chunked codecs (requires
63
70
  numpy; install with ``pip install kvgit[scientific]``).
64
71
  Mutually exclusive with explicit ``encoder`` / ``decoder``.
72
+ recover_from_corrupt_head: Optional last-resort recovery for a
73
+ branch whose HEAD is unresolvable *and* whose prev-HEAD
74
+ backup is gone. Off by default: with both gone the store
75
+ does not know what the branch pointed at, so the honest
76
+ answer is to fail rather than guess. Pass
77
+ ``kvgit.versioned.kv.recover_by_commit_scan`` to opt into
78
+ the scan, having read what it can return.
65
79
 
66
80
  Returns:
67
81
  A ``Staged`` store instance.
@@ -85,7 +99,15 @@ def store(
85
99
 
86
100
  backend = _make_backend(kind, path=path, db_name=db_name)
87
101
 
88
- return Staged(VersionedKV(backend, branch=branch), encoder=encoder, decoder=decoder)
102
+ return Staged(
103
+ VersionedKV(
104
+ backend,
105
+ branch=branch,
106
+ recover_from_corrupt_head=recover_from_corrupt_head,
107
+ ),
108
+ encoder=encoder,
109
+ decoder=decoder,
110
+ )
89
111
 
90
112
 
91
113
  def delete_branches(
@@ -53,6 +53,7 @@ import hashlib
53
53
  import json
54
54
  import logging
55
55
  import time
56
+ from collections.abc import Callable
56
57
 
57
58
  from ..encoding import dumps, loads, safe_loads
58
59
  from ..hamt import EMPTY_HASH
@@ -151,8 +152,30 @@ def _load_root(store: KVStore, commit_hash: str) -> str | None:
151
152
  return val if isinstance(val, str) else None
152
153
 
153
154
 
154
- def _resolve_head(store: KVStore, branch: str) -> str | None:
155
- """Resolve a branch HEAD, falling back to prev HEAD or commit scan.
155
+ CorruptHeadRecoverer = Callable[[KVStore, str], "str | None"]
156
+ """Last-resort recovery for a HEAD that is present but unresolvable.
157
+
158
+ Called with the store and the branch name; returns a commit hash to
159
+ treat as that branch's HEAD, or ``None`` if it cannot say. Mirrors the
160
+ TypeScript port's ``CorruptHeadRecoverer`` so the two implementations
161
+ read the same.
162
+
163
+ There is **no default**. When HEAD is unresolvable and the prev-HEAD
164
+ backup is missing or equally broken, the information needed is not in
165
+ the store, so no implementation can be correct — only lucky. kvgit
166
+ reports ``None`` and leaves the guess to a caller who has decided the
167
+ trade is worth it. :func:`recover_by_commit_scan` is the implementation
168
+ to hand in if that caller is you.
169
+ """
170
+
171
+
172
+ def _resolve_head(
173
+ store: KVStore,
174
+ branch: str,
175
+ *,
176
+ recover_from_corrupt_head: CorruptHeadRecoverer | None = None,
177
+ ) -> str | None:
178
+ """Resolve a branch HEAD, falling back to prev HEAD then an injected recoverer.
156
179
 
157
180
  **Never writes.** Every read path in the library goes through here,
158
181
  so healing the damage in place would make an ordinary ``get`` a
@@ -163,10 +186,15 @@ def _resolve_head(store: KVStore, branch: str) -> str | None:
163
186
  HEAD itself as part of the CAS that has to move it anyway.
164
187
 
165
188
  The cost of not persisting is paid per read on a damaged store: two
166
- extra ``get`` calls for the prev-HEAD tier, and a full store scan
167
- for the tier below it. That scan already ran per *write* before this
168
- change (``latest_head`` never repaired), and a store sitting on a
169
- corrupt HEAD has a bigger problem than read latency.
189
+ extra ``get`` calls for the prev-HEAD tier, plus whatever the
190
+ injected recoverer costs below it. A store sitting on a corrupt HEAD
191
+ has a bigger problem than read latency.
192
+
193
+ Args:
194
+ recover_from_corrupt_head: Optional third tier, fired only when
195
+ HEAD exists, is unusable, and the backup did not save it.
196
+ Unset — the default — means such a branch resolves to None.
197
+ See :data:`CorruptHeadRecoverer`.
170
198
 
171
199
  Returns a valid commit hash, or None if unrecoverable.
172
200
  """
@@ -180,8 +208,21 @@ def _resolve_head(store: KVStore, branch: str) -> str | None:
180
208
  ):
181
209
  return commit_hash
182
210
 
183
- # 2. Try previous HEAD (backup written after each successful CAS)
184
- prev_bytes = store.get(BRANCH_HEAD_PREV % branch)
211
+ # 2. HEAD is present but unusable — try the backup.
212
+ #
213
+ # Only reached when HEAD exists. An absent HEAD does not mean
214
+ # damage, it means the branch is gone — ``delete_branch`` removes
215
+ # the key — and a backup that outlives its branch must not bring
216
+ # the branch back. One can outlive it: a writer descheduled between
217
+ # its CAS and its backup write, resuming after a concurrent delete,
218
+ # recreates only the backup.
219
+ #
220
+ # Nothing legitimate needs the ungated form: ``_cas_head`` writes
221
+ # the backup only after a successful CAS, so HEAD exists whenever
222
+ # the backup means anything.
223
+ prev_bytes = (
224
+ store.get(BRANCH_HEAD_PREV % branch) if head_bytes is not None else None
225
+ )
185
226
  if prev_bytes is not None:
186
227
  commit_hash = safe_loads(prev_bytes)
187
228
  if (
@@ -193,23 +234,75 @@ def _resolve_head(store: KVStore, branch: str) -> str | None:
193
234
  )
194
235
  return commit_hash
195
236
 
196
- # 3. HEAD existed but is corrupt and no prev — scan for best commit
197
- if head_bytes is not None:
198
- commit_hash = _scan_for_best_commit(store, branch)
199
- if commit_hash is not None:
237
+ # 3. HEAD existed, is corrupt, and the backup did not save it. The
238
+ # store no longer holds the answer, so there is nothing left to
239
+ # read — only to guess. Guessing is the caller's call, not ours.
240
+ if recover_from_corrupt_head is not None and head_bytes is not None:
241
+ commit_hash = recover_from_corrupt_head(store, branch)
242
+ # A recoverer is caller-supplied, so its answer is checked the
243
+ # same way anything else read out of the store is: a string
244
+ # naming a commit whose root is present. This function promises
245
+ # a valid commit or None, and an unchecked answer is worse than
246
+ # no answer — ``repair_head`` makes it durable, replacing
247
+ # obviously-corrupt HEAD bytes with a plausible hash naming
248
+ # nothing, which is harder to diagnose than the damage it
249
+ # replaced.
250
+ if (
251
+ isinstance(commit_hash, str)
252
+ and store.get(COMMIT_ROOT % commit_hash) is not None
253
+ ):
200
254
  logger.warning(
201
- "Branch '%s': HEAD corrupt, recovered via commit scan", branch
255
+ "Branch '%s': HEAD corrupt, recovered via injected recoverer",
256
+ branch,
202
257
  )
203
258
  return commit_hash
259
+ if commit_hash is not None:
260
+ logger.warning(
261
+ "Branch '%s': recoverer returned %r, which is not a commit in "
262
+ "this store; treating the branch as unrecoverable",
263
+ branch,
264
+ commit_hash,
265
+ )
204
266
 
205
267
  return None
206
268
 
207
269
 
208
- def _scan_for_best_commit(store: KVStore, branch: str) -> str | None:
209
- """Scan the store for the best valid commit for a corrupt branch.
270
+ def recover_by_commit_scan(store: KVStore, branch: str) -> str | None:
271
+ """Guess a corrupt branch's HEAD by scanning every commit. Opt-in.
272
+
273
+ Finds all valid commits, excludes those reachable from healthy
274
+ branches, and returns the most recent remaining tip (by
275
+ ``__commit_time__``). A :data:`CorruptHeadRecoverer`, so it is
276
+ passed in rather than reached for::
277
+
278
+ from kvgit.versioned.kv import recover_by_commit_scan
279
+
280
+ v = VersionedKV(store, recover_from_corrupt_head=recover_by_commit_scan)
281
+
282
+ kvgit's default through v0.3.3, and **not** the default any more.
283
+ The name says what it does rather than what it is for, because what
284
+ it is for is the part that cannot be guaranteed: this is a heuristic
285
+ over a store that has already lost the answer.
210
286
 
211
- Finds all valid commits, excludes those reachable from healthy branches,
212
- and returns the most recent tip (by ``__commit_time__``).
287
+ Two things to weigh before wiring it in.
288
+
289
+ **It can serve another branch's deleted data.** "Not claimed by a
290
+ healthy branch" is the only signal it has for whose commit a commit
291
+ is, and a deleted branch's commits are unclaimed by definition until
292
+ :func:`clean_orphans` collects them. Delete a branch, damage an
293
+ unrelated branch's HEAD, lose its backup, and this returns the
294
+ deleted branch's tip — grafting onto the survivor a lineage it never
295
+ had, behind a ``logger.warning``. No race, no concurrency, no
296
+ unusual store required.
297
+
298
+ **It is O(store).** Every ``__commit_root__`` and every branch
299
+ ancestry, walked per unresolved read until someone calls
300
+ :func:`repair_head`.
301
+
302
+ It is worth it when losing the branch outright is worse than
303
+ recovering it to a plausible commit — a single-branch store, or one
304
+ where branches are never deleted, has neither hazard in play. That
305
+ judgement belongs to whoever owns the data.
213
306
  """
214
307
  root_prefix = COMMIT_ROOT.replace("%s", "")
215
308
  all_commits: dict[str, float] = {}
@@ -321,7 +414,12 @@ def _heal_head(store: KVStore, branch: str, recovered: bytes) -> bool:
321
414
  return True
322
415
 
323
416
 
324
- def repair_head(store: KVStore, branch: str = "main") -> str | None:
417
+ def repair_head(
418
+ store: KVStore,
419
+ branch: str = "main",
420
+ *,
421
+ recover_from_corrupt_head: CorruptHeadRecoverer | None = None,
422
+ ) -> str | None:
325
423
  """Persist a recovered HEAD for a damaged branch.
326
424
 
327
425
  Read paths recover a corrupt ``__branch_head__`` in memory and leave
@@ -343,16 +441,28 @@ def repair_head(store: KVStore, branch: str = "main") -> str | None:
343
441
  commit than HEAD actually holds. The branch is re-resolved instead,
344
442
  so the answer describes the store rather than the attempt.
345
443
 
444
+ Args:
445
+ recover_from_corrupt_head: Optional last-resort recovery, used
446
+ only for a HEAD that is present, unusable, and has no usable
447
+ backup. Unset — the default — means such a branch is
448
+ reported unrecoverable rather than guessed at. See
449
+ :data:`CorruptHeadRecoverer` and
450
+ :func:`recover_by_commit_scan`.
451
+
346
452
  Returns:
347
453
  The commit HEAD now names, or None if the branch does not exist
348
454
  or nothing recoverable was found.
349
455
  """
350
- commit_hash = _resolve_head(store, branch)
456
+ commit_hash = _resolve_head(
457
+ store, branch, recover_from_corrupt_head=recover_from_corrupt_head
458
+ )
351
459
  if commit_hash is None:
352
460
  return None
353
461
  if _heal_head(store, branch, dumps(commit_hash)):
354
462
  return commit_hash
355
- return _resolve_head(store, branch)
463
+ return _resolve_head(
464
+ store, branch, recover_from_corrupt_head=recover_from_corrupt_head
465
+ )
356
466
 
357
467
 
358
468
  def clean_orphans(store: KVStore, min_age: float = 3600) -> int:
@@ -473,6 +583,19 @@ def _sweep(store: KVStore, min_age: float, *, deep: bool) -> int:
473
583
  if not (isinstance(key, str) and key.startswith(branch_prefix)):
474
584
  continue
475
585
  branch_name = key[len(branch_prefix) :]
586
+ # No ``recover_from_corrupt_head`` here, deliberately, even when
587
+ # the caller has one wired into their handles. GC must not
588
+ # decide reachability from a guess. A wrong answer from a
589
+ # last-resort recoverer marks the wrong commits live — real
590
+ # garbage survives forever, and a guessed tip gets walked as
591
+ # though it were this branch's own history, so another branch's
592
+ # ancestry can be pinned into this one's mark set. The sweep
593
+ # should see only what the store actually claims: a branch whose
594
+ # HEAD resolves is marked from its real HEAD, and one whose HEAD
595
+ # does not resolve marks nothing and keeps its commits as young
596
+ # orphans until ``min_age`` and an explicit ``repair_head``
597
+ # settle what it points at. The inconsistency with the read
598
+ # paths is the point, not an oversight.
476
599
  branch_head = _resolve_head(store, branch_name)
477
600
  if branch_head is None:
478
601
  continue
@@ -610,6 +733,13 @@ class VersionedKV(VersionedBase):
610
733
  - ``commit()`` to atomically write changes and advance HEAD
611
734
  - ``refresh()`` to reload from HEAD
612
735
  - ``checkout()`` / ``history()`` for navigating commits
736
+
737
+ ``recover_from_corrupt_head`` is the optional last-resort tier of
738
+ HEAD resolution, for a HEAD that is present, unusable, and has no
739
+ usable backup. Unset by default, which makes such a branch
740
+ unrecoverable rather than guessed at; pass
741
+ :func:`recover_by_commit_scan` to restore kvgit's pre-0.3.4
742
+ behaviour. See :data:`CorruptHeadRecoverer`.
613
743
  """
614
744
 
615
745
  def __init__(
@@ -618,15 +748,23 @@ class VersionedKV(VersionedBase):
618
748
  *,
619
749
  commit_hash: str | None = None,
620
750
  branch: str = "main",
751
+ recover_from_corrupt_head: CorruptHeadRecoverer | None = None,
621
752
  ) -> None:
622
753
  if store is None:
623
754
  store = Memory()
624
755
  self.store = store
756
+ # Applies to every resolve this handle makes — opening, reading
757
+ # HEAD, refreshing, switching, peeking, repairing — and is
758
+ # inherited by the handles ``checkout`` and ``create_branch``
759
+ # hand back, so a caller opts in once rather than per call.
760
+ self._recover_from_corrupt_head = recover_from_corrupt_head
625
761
 
626
762
  _check_storage_version(store)
627
763
 
628
764
  if commit_hash is None:
629
- commit_hash = _resolve_head(store, branch)
765
+ commit_hash = _resolve_head(
766
+ store, branch, recover_from_corrupt_head=recover_from_corrupt_head
767
+ )
630
768
  if commit_hash is None and store.get(BRANCH_HEAD % branch) is not None:
631
769
  raise ValueError(f"Branch '{branch}' HEAD is corrupt and unrecoverable")
632
770
  if commit_hash is None:
@@ -639,6 +777,11 @@ class VersionedKV(VersionedBase):
639
777
  BRANCH_HEAD % branch: dumps(commit_hash),
640
778
  }
641
779
  store.set_many(initial)
780
+ # Same reasoning as ``create_branch``: this name had no HEAD
781
+ # a moment ago, so it has no previous HEAD either, and a
782
+ # backup that outlived a delete must not become reachable
783
+ # again through the anchor we just installed.
784
+ store.remove(BRANCH_HEAD_PREV % branch)
642
785
 
643
786
  if not isinstance(commit_hash, str):
644
787
  raise TypeError(
@@ -672,7 +815,11 @@ class VersionedKV(VersionedBase):
672
815
  @property
673
816
  def latest_head(self) -> str | None:
674
817
  """Read HEAD directly from the KV store (reflects other writers)."""
675
- return _resolve_head(self.store, self._branch)
818
+ return _resolve_head(
819
+ self.store,
820
+ self._branch,
821
+ recover_from_corrupt_head=self._recover_from_corrupt_head,
822
+ )
676
823
 
677
824
  # -- Read operations --
678
825
 
@@ -908,8 +1055,8 @@ class VersionedKV(VersionedBase):
908
1055
  never before. Written first, it lands whether or not the CAS
909
1056
  does, so a writer that loses the race still leaves its own stale
910
1057
  ``expected`` as the branch's recovery target — clobbering the
911
- winner's backup, and, when ``expected`` came from the commit
912
- scan, naming a commit that was never HEAD at all. Writing it
1058
+ winner's backup, and, when ``expected`` came from an injected
1059
+ recoverer, naming a commit that was never HEAD at all. Writing it
913
1060
  afterwards makes it always a value ``__branch_head__`` really
914
1061
  held.
915
1062
 
@@ -1013,7 +1160,11 @@ class VersionedKV(VersionedBase):
1013
1160
 
1014
1161
  def refresh(self) -> None:
1015
1162
  """Reload state from HEAD."""
1016
- commit_hash = _resolve_head(self.store, self._branch)
1163
+ commit_hash = _resolve_head(
1164
+ self.store,
1165
+ self._branch,
1166
+ recover_from_corrupt_head=self._recover_from_corrupt_head,
1167
+ )
1017
1168
  if commit_hash is None:
1018
1169
  raise ValueError(f"No HEAD commit found for branch {self._branch}")
1019
1170
  self._load_commit(commit_hash, update_base=True)
@@ -1028,6 +1179,7 @@ class VersionedKV(VersionedBase):
1028
1179
  self.store,
1029
1180
  commit_hash=commit_hash,
1030
1181
  branch=branch or self._branch,
1182
+ recover_from_corrupt_head=self._recover_from_corrupt_head,
1031
1183
  )
1032
1184
 
1033
1185
  def create_branch(self, name: str, *, at: str | None = None) -> "VersionedKV":
@@ -1041,7 +1193,22 @@ class VersionedKV(VersionedBase):
1041
1193
  raise ValueError(f"Commit '{at}' does not exist")
1042
1194
  if not self.store.cas(branch_key, dumps(target), expected=None):
1043
1195
  raise ValueError(f"Branch '{name}' already exists")
1044
- return VersionedKV(self.store, commit_hash=target, branch=name)
1196
+ # A branch that has just been created has no previous HEAD, so any
1197
+ # backup under this name is stale by definition. One can outlive a
1198
+ # ``delete_branch`` — a writer descheduled between its CAS and its
1199
+ # backup write, resuming after the delete — and while the name is
1200
+ # unclaimed head resolution ignores it, but re-installing an anchor
1201
+ # would make it reachable again: corrupt this branch's fresh HEAD
1202
+ # before its first successful CAS and the prev-HEAD tier would serve
1203
+ # the *deleted* branch's tip. Dropped after the CAS, so a losing
1204
+ # attempt cannot take out the existing branch's real backup.
1205
+ self.store.remove(BRANCH_HEAD_PREV % name)
1206
+ return VersionedKV(
1207
+ self.store,
1208
+ commit_hash=target,
1209
+ branch=name,
1210
+ recover_from_corrupt_head=self._recover_from_corrupt_head,
1211
+ )
1045
1212
 
1046
1213
  def delete_branch(self, name: str) -> None:
1047
1214
  """Delete a branch and clean up orphaned commits."""
@@ -1060,7 +1227,11 @@ class VersionedKV(VersionedBase):
1060
1227
 
1061
1228
  def switch_branch(self, name: str) -> None:
1062
1229
  """Switch this instance to a different branch in-place."""
1063
- commit_hash = _resolve_head(self.store, name)
1230
+ commit_hash = _resolve_head(
1231
+ self.store,
1232
+ name,
1233
+ recover_from_corrupt_head=self._recover_from_corrupt_head,
1234
+ )
1064
1235
  if commit_hash is None:
1065
1236
  if self.store.get(BRANCH_HEAD % name) is not None:
1066
1237
  raise ValueError(f"Branch '{name}' HEAD is corrupt and unrecoverable")
@@ -1070,7 +1241,11 @@ class VersionedKV(VersionedBase):
1070
1241
 
1071
1242
  def peek(self, key: str, *, branch: str) -> bytes | None:
1072
1243
  """Read a key from another branch's HEAD without switching."""
1073
- commit_hash = _resolve_head(self.store, branch)
1244
+ commit_hash = _resolve_head(
1245
+ self.store,
1246
+ branch,
1247
+ recover_from_corrupt_head=self._recover_from_corrupt_head,
1248
+ )
1074
1249
  if commit_hash is None:
1075
1250
  return None
1076
1251
  root = _load_root(self.store, commit_hash)
@@ -1133,7 +1308,11 @@ class VersionedKV(VersionedBase):
1133
1308
  The commit HEAD now names, or None if nothing was
1134
1309
  recoverable.
1135
1310
  """
1136
- return repair_head(self.store, self._branch)
1311
+ return repair_head(
1312
+ self.store,
1313
+ self._branch,
1314
+ recover_from_corrupt_head=self._recover_from_corrupt_head,
1315
+ )
1137
1316
 
1138
1317
  # -- Orphan cleanup --
1139
1318
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kvgit
3
- Version: 0.3.3
3
+ Version: 0.3.4
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.3"
7
+ version = "0.3.4"
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"
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
File without changes
File without changes
File without changes
File without changes
File without changes