kvgit 0.1.6__tar.gz → 0.1.8__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 (36) hide show
  1. {kvgit-0.1.6 → kvgit-0.1.8}/PKG-INFO +1 -1
  2. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/kv/indexeddb.py +10 -3
  3. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/store.py +1 -29
  4. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/versioned/__init__.py +0 -2
  5. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/versioned/kv.py +96 -1
  6. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit.egg-info/PKG-INFO +1 -1
  7. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit.egg-info/SOURCES.txt +0 -1
  8. {kvgit-0.1.6 → kvgit-0.1.8}/pyproject.toml +1 -1
  9. {kvgit-0.1.6 → kvgit-0.1.8}/tests/test_store_factory.py +0 -6
  10. kvgit-0.1.6/kvgit/versioned/gc.py +0 -334
  11. {kvgit-0.1.6 → kvgit-0.1.8}/LICENSE +0 -0
  12. {kvgit-0.1.6 → kvgit-0.1.8}/README.md +0 -0
  13. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/__init__.py +0 -0
  14. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/content_types.py +0 -0
  15. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/encoding.py +0 -0
  16. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/errors.py +0 -0
  17. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/kv/__init__.py +0 -0
  18. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/kv/base.py +0 -0
  19. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/kv/composite.py +0 -0
  20. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/kv/disk.py +0 -0
  21. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/kv/memory.py +0 -0
  22. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/namespaced.py +0 -0
  23. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/py.typed +0 -0
  24. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/staged.py +0 -0
  25. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/versioned/base.py +0 -0
  26. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/versioned/gp.py +0 -0
  27. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/versioned/helpers.py +0 -0
  28. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/versioned/merge.py +0 -0
  29. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/versioned/protocol.py +0 -0
  30. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit.egg-info/dependency_links.txt +0 -0
  31. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit.egg-info/requires.txt +0 -0
  32. {kvgit-0.1.6 → kvgit-0.1.8}/kvgit.egg-info/top_level.txt +0 -0
  33. {kvgit-0.1.6 → kvgit-0.1.8}/setup.cfg +0 -0
  34. {kvgit-0.1.6 → kvgit-0.1.8}/tests/test_content_types.py +0 -0
  35. {kvgit-0.1.6 → kvgit-0.1.8}/tests/test_namespaced.py +0 -0
  36. {kvgit-0.1.6 → kvgit-0.1.8}/tests/test_staged.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kvgit
3
- Version: 0.1.6
3
+ Version: 0.1.8
4
4
  Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
5
5
  Author: ashenfad
6
6
  License: MIT
@@ -117,10 +117,17 @@ async def _idb_tx_complete(tx):
117
117
 
118
118
 
119
119
  def _to_bytes(js_value) -> bytes | None:
120
- """Convert a JS result to bytes, or None if absent."""
120
+ """Convert a JS result to bytes, or None if absent.
121
+
122
+ Uses Uint8Array.to_py() for fast memcpy from JS to WASM memory,
123
+ avoiding the slow element-wise iteration of bytes(js_proxy).
124
+ """
121
125
  if js_value is None or js_value is undefined:
122
126
  return None
123
- return bytes(js_value)
127
+ from js import Uint8Array # type: ignore[import-not-found]
128
+
129
+ arr = Uint8Array.new(js_value)
130
+ return arr.to_py().tobytes()
124
131
 
125
132
 
126
133
  class IndexedDB(KVStore):
@@ -206,7 +213,7 @@ class IndexedDB(KVStore):
206
213
  def on_success(event):
207
214
  cursor = event.target.result
208
215
  if cursor:
209
- results.append((str(cursor.key), bytes(cursor.value)))
216
+ results.append((str(cursor.key), _to_bytes(cursor.value)))
210
217
  cursor.continue_()
211
218
  else:
212
219
  resolve(None)
@@ -6,7 +6,6 @@ from typing import Any, Callable, Literal
6
6
  from .kv.base import KVStore
7
7
  from .kv.memory import Memory
8
8
  from .staged import Staged
9
- from .versioned.gc import GCVersionedKV
10
9
  from .versioned.kv import VersionedKV
11
10
  from .versioned.protocol import Versioned
12
11
 
@@ -19,9 +18,6 @@ def store(
19
18
  branch: str = "main",
20
19
  encoder: Callable[[Any], bytes] = pickle.dumps,
21
20
  decoder: Callable[[bytes], Any] = pickle.loads,
22
- high_water_bytes: int | None = None,
23
- low_water_bytes: int | None = None,
24
- is_protected: Callable[[str], bool] | None = None,
25
21
  ) -> Staged:
26
22
  """Create a Staged store with sensible defaults.
27
23
 
@@ -34,14 +30,6 @@ def store(
34
30
  branch: Branch name (default ``"main"``).
35
31
  encoder: Value encoder (default ``pickle.dumps``).
36
32
  decoder: Value decoder (default ``pickle.loads``).
37
- high_water_bytes: Enable GC with this high-water threshold.
38
- Not supported with ``kind="git"``.
39
- low_water_bytes: GC low-water threshold (defaults to 80%
40
- of high_water). Not supported with ``kind="git"``.
41
- is_protected: Callable that returns True for keys GC should
42
- never drop. Only used when ``high_water_bytes`` is set.
43
- Defaults to protecting keys starting with ``__``.
44
- Not supported with ``kind="git"``.
45
33
 
46
34
  Returns:
47
35
  A ``Staged`` store instance.
@@ -49,12 +37,6 @@ def store(
49
37
  versioned: Versioned
50
38
 
51
39
  if kind == "git":
52
- if (
53
- high_water_bytes is not None
54
- or low_water_bytes is not None
55
- or is_protected is not None
56
- ):
57
- raise ValueError("GC parameters are not supported with kind='git'")
58
40
  if path is None:
59
41
  raise ValueError("path is required when kind='git'")
60
42
  from .versioned.gp import VersionedGP
@@ -78,16 +60,6 @@ def store(
78
60
  else:
79
61
  raise ValueError(f"Unknown kind: {kind!r}")
80
62
 
81
- if high_water_bytes is not None:
82
- gc_kwargs: dict[str, Any] = {
83
- "branch": branch,
84
- "high_water_bytes": high_water_bytes,
85
- "low_water_bytes": low_water_bytes,
86
- }
87
- if is_protected is not None:
88
- gc_kwargs["is_protected"] = is_protected
89
- versioned = GCVersionedKV(backend, **gc_kwargs)
90
- else:
91
- versioned = VersionedKV(backend, branch=branch)
63
+ versioned = VersionedKV(backend, branch=branch)
92
64
 
93
65
  return Staged(versioned, encoder=encoder, decoder=decoder)
@@ -1,13 +1,11 @@
1
1
  """Versioned store implementations."""
2
2
 
3
- from .gc import GCVersionedKV
4
3
  from .kv import VersionedKV
5
4
  from .protocol import BytesMergeFn, DiffResult, MergeResult, Versioned
6
5
 
7
6
  __all__ = [
8
7
  "BytesMergeFn",
9
8
  "DiffResult",
10
- "GCVersionedKV",
11
9
  "MergeResult",
12
10
  "Versioned",
13
11
  "VersionedKV",
@@ -2,6 +2,7 @@
2
2
 
3
3
  import hashlib
4
4
  import json
5
+ import logging
5
6
  import time
6
7
 
7
8
  from ..encoding import MetaEntry, from_bytes, meta_from_bytes, meta_to_bytes, to_bytes
@@ -397,13 +398,14 @@ class VersionedKV(VersionedBase):
397
398
  return VersionedKV(self.store, commit_hash=target, branch=name)
398
399
 
399
400
  def delete_branch(self, name: str) -> None:
400
- """Delete a branch by name."""
401
+ """Delete a branch and clean up orphaned commits."""
401
402
  if name == self._branch:
402
403
  raise ValueError("Cannot delete the current branch")
403
404
  branch_key = BRANCH_HEAD % name
404
405
  if self.store.get(branch_key) is None:
405
406
  raise ValueError(f"Branch '{name}' does not exist")
406
407
  self.store.remove(branch_key)
408
+ self.clean_orphans(min_age=0)
407
409
 
408
410
  def switch_branch(self, name: str) -> None:
409
411
  """Switch this instance to a different branch in-place."""
@@ -461,6 +463,99 @@ class VersionedKV(VersionedBase):
461
463
  return None
462
464
  return from_bytes(info_bytes)
463
465
 
466
+ # -- Orphan cleanup --
467
+
468
+ def clean_orphans(self, min_age: float = 3600) -> int:
469
+ """Remove orphaned commits unreachable from any branch HEAD.
470
+
471
+ Traces all reachable commits from live branch HEADs, then
472
+ deletes commit metadata and versioned keys for any commit
473
+ not in the reachable set. Only deletes blobs that are not
474
+ referenced by any reachable commit.
475
+
476
+ The ``min_age`` guard (default 1 hour) prevents recently
477
+ created commits from being falsely swept during concurrent
478
+ writes. Use ``min_age=0`` when concurrency is not a concern
479
+ (e.g. single-user browser environments).
480
+
481
+ Returns:
482
+ Number of orphaned commits removed.
483
+ """
484
+ logger = logging.getLogger("kvgit.orphans")
485
+
486
+ # Mark phase: find all reachable commits and their blob keys
487
+ reachable: set[str] = set()
488
+ reachable_blobs: set[str] = set()
489
+ prefix = BRANCH_HEAD.replace("%s", "")
490
+ for key in self.store.keys():
491
+ if isinstance(key, str) and key.startswith(prefix):
492
+ head_bytes = self.store.get(key)
493
+ if head_bytes is None:
494
+ continue
495
+ branch_head = from_bytes(head_bytes)
496
+ for commit in self.history(commit_hash=branch_head, all_parents=True):
497
+ if commit not in reachable:
498
+ reachable.add(commit)
499
+ ks_bytes = self.store.get(COMMIT_KEYSET % commit)
500
+ if ks_bytes:
501
+ try:
502
+ for vk in from_bytes(ks_bytes).values():
503
+ reachable_blobs.add(vk)
504
+ except Exception:
505
+ pass
506
+
507
+ # Sweep phase: find orphaned commits by scanning for meta keys
508
+ meta_prefix = META_KEY.replace("%s", "")
509
+ cutoff_time = time.time() - min_age
510
+ orphans: list[str] = []
511
+
512
+ for key in self.store.keys():
513
+ if not isinstance(key, str) or not key.startswith(meta_prefix):
514
+ continue
515
+ commit_hash = key[len(meta_prefix) :]
516
+ if not commit_hash or commit_hash in reachable:
517
+ continue
518
+ # Check age
519
+ meta_bytes = self.store.get(key)
520
+ if meta_bytes is None:
521
+ continue
522
+ try:
523
+ meta = meta_from_bytes(meta_bytes)
524
+ if meta:
525
+ newest = max(e.created_at for e in meta.values())
526
+ if newest < cutoff_time:
527
+ orphans.append(commit_hash)
528
+ else:
529
+ orphans.append(commit_hash)
530
+ except (json.JSONDecodeError, TypeError, KeyError):
531
+ continue
532
+
533
+ # Delete orphaned commits — only remove blobs not used by reachable commits
534
+ for orphan_hash in orphans:
535
+ keyset_bytes = self.store.get(COMMIT_KEYSET % orphan_hash)
536
+ if keyset_bytes:
537
+ try:
538
+ keyset = from_bytes(keyset_bytes)
539
+ orphan_only = [
540
+ vk for vk in keyset.values() if vk not in reachable_blobs
541
+ ]
542
+ if orphan_only:
543
+ self.store.remove_many(*orphan_only)
544
+ except Exception:
545
+ pass
546
+ self.store.remove_many(
547
+ META_KEY % orphan_hash,
548
+ COMMIT_KEYSET % orphan_hash,
549
+ PARENT_COMMIT % orphan_hash,
550
+ TOTAL_VAR_SIZE_KEY % orphan_hash,
551
+ INFO_KEY % orphan_hash,
552
+ )
553
+
554
+ if orphans:
555
+ logger.debug("Cleaned %d orphaned commit(s)", len(orphans))
556
+
557
+ return len(orphans)
558
+
464
559
  # -- Internal --
465
560
 
466
561
  def _touch(self, key: str) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kvgit
3
- Version: 0.1.6
3
+ Version: 0.1.8
4
4
  Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
5
5
  Author: ashenfad
6
6
  License: MIT
@@ -22,7 +22,6 @@ kvgit/kv/indexeddb.py
22
22
  kvgit/kv/memory.py
23
23
  kvgit/versioned/__init__.py
24
24
  kvgit/versioned/base.py
25
- kvgit/versioned/gc.py
26
25
  kvgit/versioned/gp.py
27
26
  kvgit/versioned/helpers.py
28
27
  kvgit/versioned/kv.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kvgit"
7
- version = "0.1.6"
7
+ version = "0.1.8"
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"
@@ -3,7 +3,6 @@
3
3
  import pytest
4
4
 
5
5
  from kvgit import Staged, store
6
- from kvgit.versioned.gc import GCVersionedKV as GCVersioned
7
6
 
8
7
 
9
8
  class TestStoreFactory:
@@ -19,11 +18,6 @@ class TestStoreFactory:
19
18
  with pytest.raises(ValueError, match="path is required"):
20
19
  store(kind="disk")
21
20
 
22
- def test_gc_versioned(self):
23
- s = store(high_water_bytes=5000)
24
- assert isinstance(s, Staged)
25
- assert isinstance(s.versioned, GCVersioned)
26
-
27
21
  def test_branch_parameter(self):
28
22
  s = store(branch="dev")
29
23
  assert isinstance(s, Staged)
@@ -1,334 +0,0 @@
1
- """GCVersionedKV: VersionedKV with automatic garbage collection."""
2
-
3
- import json
4
- import time
5
- from dataclasses import dataclass
6
- from typing import Callable
7
-
8
- from ..errors import ConcurrencyError
9
- from ..kv.base import KVStore
10
- from ..encoding import MetaEntry, from_bytes, meta_from_bytes, meta_to_bytes, to_bytes
11
- from .protocol import MergeResult
12
- from .kv import (
13
- BRANCH_HEAD,
14
- COMMIT_KEYSET,
15
- INFO_KEY,
16
- META_KEY,
17
- PARENT_COMMIT,
18
- TOTAL_VAR_SIZE_KEY,
19
- VersionedKV,
20
- content_hash,
21
- )
22
-
23
-
24
- def is_system_key(key: str) -> bool:
25
- """Check if a key is a system/protected key (starts with ``__``).
26
-
27
- Handles both direct keys (``"__foo__"``) and namespaced keys
28
- (``"ns/__foo__"``) by extracting the base key name.
29
-
30
- This is the default ``is_protected`` policy for ``GCVersionedKV``.
31
- """
32
- base_key = key.split("/")[-1] if "/" in key else key
33
- return base_key.startswith("__")
34
-
35
-
36
- @dataclass(frozen=True)
37
- class RebaseResult:
38
- """Result of a rebase/GC operation."""
39
-
40
- performed: bool
41
- new_commit: str | None
42
- dropped_keys: tuple[str, ...]
43
- kept_keys: tuple[str, ...]
44
- total_size_before: int
45
- total_size_after: int
46
- orphans_cleaned: int = 0
47
-
48
-
49
- class GCVersionedKV(VersionedKV):
50
- """VersionedKV with built-in garbage collection via rebase.
51
-
52
- Rebase strategy (high/low water):
53
- - Track total persisted user-var size from commit metadata.
54
- - If total <= high_water_bytes: no-op.
55
- - If total > high_water_bytes: drop coldest user keys (oldest touch,
56
- then largest) until total <= low_water_bytes (default 80% of high).
57
- - Protected keys (as determined by ``is_protected``) are always retained.
58
- - Write a fresh root commit with only retained keys, then delete
59
- dropped blobs and orphaned commits.
60
-
61
- Every ``commit()`` auto-runs the high/low check.
62
- """
63
-
64
- def __init__(
65
- self,
66
- store: KVStore | None = None,
67
- *,
68
- commit_hash: str | None = None,
69
- branch: str = "main",
70
- high_water_bytes: int,
71
- low_water_bytes: int | None = None,
72
- is_protected: Callable[[str], bool] = is_system_key,
73
- ) -> None:
74
- super().__init__(store, commit_hash=commit_hash, branch=branch)
75
- if high_water_bytes <= 0:
76
- raise ValueError("high_water_bytes must be > 0")
77
- self.high_water = high_water_bytes
78
- self.low_water = (
79
- low_water_bytes
80
- if low_water_bytes is not None
81
- else int(high_water_bytes * 0.8)
82
- )
83
- if self.low_water <= 0 or self.low_water > self.high_water:
84
- self.low_water = int(high_water_bytes * 0.8)
85
- self._is_protected = is_protected
86
- self.last_rebase_result: RebaseResult | None = None
87
-
88
- def commit(
89
- self,
90
- updates: dict[str, bytes] | None = None,
91
- removals: set[str] | None = None,
92
- *,
93
- on_conflict: str = "raise",
94
- merge_fns=None,
95
- default_merge=None,
96
- info: dict | None = None,
97
- ) -> "MergeResult":
98
- """Commit changes, then run GC if above high water mark."""
99
-
100
- result = super().commit(
101
- updates,
102
- removals,
103
- on_conflict=on_conflict,
104
- merge_fns=merge_fns,
105
- default_merge=default_merge,
106
- info=info,
107
- )
108
- if result.merged:
109
- rebase_result = self.maybe_rebase()
110
- self.last_rebase_result = rebase_result
111
- return result
112
-
113
- def maybe_rebase(self) -> RebaseResult:
114
- """Run rebase only if total size exceeds high water mark."""
115
- total = self._load_total_size()
116
- if total <= self.high_water:
117
- return RebaseResult(
118
- performed=False,
119
- new_commit=None,
120
- dropped_keys=(),
121
- kept_keys=tuple(self._commit_keys.keys()),
122
- total_size_before=total,
123
- total_size_after=total,
124
- )
125
- return self.rebase()
126
-
127
- def rebase(
128
- self,
129
- keep_keys: set[str] | None = None,
130
- *,
131
- info: dict | None = None,
132
- ) -> RebaseResult:
133
- """Rebase: create a fresh root commit, dropping cold keys."""
134
- meta = self._meta
135
- total_before = self._load_total_size(
136
- default=sum(e.size or 0 for e in meta.values())
137
- )
138
-
139
- # Identify protected and user keys
140
- protected_keys = {
141
- k: v for k, v in self._commit_keys.items() if self._is_protected(k)
142
- }
143
- user_meta = {k: v for k, v in meta.items() if not self._is_protected(k)}
144
-
145
- retained_keys = set(protected_keys.keys()) | set(user_meta.keys())
146
- total = sum(e.size or 0 for e in user_meta.values())
147
- dropped: list[str] = []
148
-
149
- if keep_keys is not None:
150
- # Explicit keep set — drop everything not in it (except protected keys)
151
- for key in list(retained_keys):
152
- if self._is_protected(key):
153
- continue
154
- if key not in keep_keys:
155
- retained_keys.discard(key)
156
- dropped.append(key)
157
- total -= (user_meta.get(key) and user_meta[key].size) or 0
158
- else:
159
- # High/low water strategy: drop coldest until under low water
160
- candidates: list[tuple[str, MetaEntry]] = sorted(
161
- user_meta.items(),
162
- key=lambda kv: (kv[1].last_touch, -(kv[1].size or 0)),
163
- )
164
- for key, entry in candidates:
165
- if total <= self.low_water:
166
- break
167
- retained_keys.discard(key)
168
- dropped.append(key)
169
- total -= entry.size or 0
170
-
171
- # Build new commit with retained keys
172
-
173
- # Collect retained data
174
- new_commit_keys: dict[str, str] = {}
175
- new_meta: dict[str, MetaEntry] = {}
176
- retained_data: dict[str, bytes] = {}
177
-
178
- for key in retained_keys:
179
- versioned_key = self._commit_keys.get(key)
180
- if not versioned_key:
181
- continue
182
- value = self.store.get(versioned_key)
183
- if value is None:
184
- continue
185
- if key in meta:
186
- new_meta[key] = meta[key]
187
- if not self._is_protected(key):
188
- retained_data[key] = value
189
-
190
- # Content-addressable hash for the rebase commit (parent=None, fresh root)
191
- preview_keys: dict[str, str] = {}
192
- for key in protected_keys:
193
- preview_keys[key] = protected_keys[key]
194
- for key in retained_data:
195
- preview_keys[key] = f"<pending:{key}>"
196
- new_hash = content_hash((), preview_keys, retained_data, info=info)
197
-
198
- # Build the write batch
199
- diffs: dict[str, bytes] = {}
200
-
201
- # Protected keys — copy blobs with new versioned keys
202
- for key, old_vk in protected_keys.items():
203
- value = self.store.get(old_vk)
204
- if value is None:
205
- continue
206
- new_vk = f"{new_hash}:{key}"
207
- new_commit_keys[key] = new_vk
208
- diffs[new_vk] = value
209
-
210
- # Retained user keys
211
- for key, value in retained_data.items():
212
- new_vk = f"{new_hash}:{key}"
213
- new_commit_keys[key] = new_vk
214
- diffs[new_vk] = value
215
-
216
- # Commit metadata
217
- diffs[COMMIT_KEYSET % new_hash] = to_bytes(new_commit_keys)
218
- diffs[PARENT_COMMIT % new_hash] = to_bytes([])
219
- diffs[META_KEY % new_hash] = meta_to_bytes(new_meta)
220
- total_after = sum(e.size or 0 for e in new_meta.values())
221
- diffs[TOTAL_VAR_SIZE_KEY % new_hash] = to_bytes(total_after)
222
- if info is not None:
223
- diffs[INFO_KEY % new_hash] = to_bytes(info)
224
-
225
- self.store.set_many(**diffs)
226
-
227
- # CAS HEAD to the new rebase commit
228
- branch_key = BRANCH_HEAD % self._branch
229
- expected = to_bytes(self._base_commit)
230
- if not self.store.cas(branch_key, to_bytes(new_hash), expected=expected):
231
- raise ConcurrencyError("HEAD changed during rebase.")
232
-
233
- # Delete dropped blobs
234
- to_delete = []
235
- for key in dropped:
236
- vk = self._commit_keys.get(key)
237
- if vk:
238
- to_delete.append(vk)
239
- if to_delete:
240
- self.store.remove_many(*to_delete)
241
-
242
- # Update in-memory state
243
- self._commit_keys = new_commit_keys
244
- self._current_commit = new_hash
245
- self._base_commit = new_hash
246
- self._meta = new_meta
247
-
248
- # Clean orphaned commits
249
- orphans_cleaned = self.clean_orphans()
250
-
251
- return RebaseResult(
252
- performed=True,
253
- new_commit=new_hash,
254
- dropped_keys=tuple(dropped),
255
- kept_keys=tuple(retained_keys),
256
- total_size_before=total_before,
257
- total_size_after=total_after,
258
- orphans_cleaned=orphans_cleaned,
259
- )
260
-
261
- def clean_orphans(self, min_age: float = 3600) -> int:
262
- """Remove orphaned commits unreachable from any branch HEAD.
263
-
264
- Advisory: this is not atomic. Concurrent writers may create
265
- commits between the mark and sweep phases. The ``min_age``
266
- guard (default 1 hour) prevents recently created commits from
267
- being falsely swept.
268
- """
269
- # Mark phase: find all reachable commits across ALL branches
270
- reachable: set[str] = set()
271
- prefix = BRANCH_HEAD.replace("%s", "")
272
- for key in self.store.keys():
273
- if isinstance(key, str) and key.startswith(prefix):
274
- head_bytes = self.store.get(key)
275
- if head_bytes is None:
276
- continue
277
- branch_head = from_bytes(head_bytes)
278
- for commit in self.history(commit_hash=branch_head, all_parents=True):
279
- reachable.add(commit)
280
-
281
- # Sweep phase: find orphaned commits by scanning for meta keys
282
- meta_prefix = META_KEY.replace("%s", "")
283
- cutoff_time = time.time() - min_age
284
- orphans: list[str] = []
285
-
286
- for key in self.store.keys():
287
- if not isinstance(key, str) or not key.startswith(meta_prefix):
288
- continue
289
- commit_hash = key[len(meta_prefix) :]
290
- if not commit_hash or commit_hash in reachable:
291
- continue
292
- # Check age
293
- meta_bytes = self.store.get(key)
294
- if meta_bytes is None:
295
- continue
296
- try:
297
- meta = meta_from_bytes(meta_bytes)
298
- if meta:
299
- first_entry = next(iter(meta.values()), None)
300
- if first_entry and first_entry.created_at < cutoff_time:
301
- orphans.append(commit_hash)
302
- except (json.JSONDecodeError, TypeError, KeyError):
303
- continue
304
-
305
- # Delete orphaned commits and their data
306
- for orphan_hash in orphans:
307
- keyset_bytes = self.store.get(COMMIT_KEYSET % orphan_hash)
308
- if keyset_bytes:
309
- try:
310
- keyset = from_bytes(keyset_bytes)
311
- blob_keys = list(keyset.values())
312
- if blob_keys:
313
- self.store.remove_many(*blob_keys)
314
- except Exception:
315
- pass
316
- self.store.remove_many(
317
- META_KEY % orphan_hash,
318
- COMMIT_KEYSET % orphan_hash,
319
- PARENT_COMMIT % orphan_hash,
320
- TOTAL_VAR_SIZE_KEY % orphan_hash,
321
- INFO_KEY % orphan_hash,
322
- )
323
-
324
- return len(orphans)
325
-
326
- def _load_total_size(self, default: int = 0) -> int:
327
- """Load the total variable size for the current commit."""
328
- total_bytes = self.store.get(TOTAL_VAR_SIZE_KEY % self._current_commit)
329
- if total_bytes is None:
330
- return default
331
- try:
332
- return from_bytes(total_bytes)
333
- except Exception:
334
- return default
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