kvgit 0.2.0__tar.gz → 0.2.2__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 (38) hide show
  1. {kvgit-0.2.0 → kvgit-0.2.2}/PKG-INFO +1 -2
  2. {kvgit-0.2.0 → kvgit-0.2.2}/README.md +0 -1
  3. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/hamt.py +20 -5
  4. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/kv/indexeddb.py +18 -1
  5. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/staged.py +36 -11
  6. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/versioned/keyset.py +6 -3
  7. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/versioned/kv.py +9 -3
  8. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit.egg-info/PKG-INFO +1 -2
  9. {kvgit-0.2.0 → kvgit-0.2.2}/pyproject.toml +1 -1
  10. {kvgit-0.2.0 → kvgit-0.2.2}/tests/test_hamt.py +113 -0
  11. {kvgit-0.2.0 → kvgit-0.2.2}/tests/test_staged.py +111 -0
  12. {kvgit-0.2.0 → kvgit-0.2.2}/LICENSE +0 -0
  13. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/__init__.py +0 -0
  14. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/content_types.py +0 -0
  15. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/encoding.py +0 -0
  16. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/errors.py +0 -0
  17. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/kv/__init__.py +0 -0
  18. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/kv/base.py +0 -0
  19. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/kv/composite.py +0 -0
  20. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/kv/disk.py +0 -0
  21. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/kv/memory.py +0 -0
  22. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/namespaced.py +0 -0
  23. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/py.typed +0 -0
  24. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/store.py +0 -0
  25. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/versioned/__init__.py +0 -0
  26. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/versioned/base.py +0 -0
  27. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/versioned/gp.py +0 -0
  28. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/versioned/helpers.py +0 -0
  29. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/versioned/merge.py +0 -0
  30. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/versioned/protocol.py +0 -0
  31. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit.egg-info/SOURCES.txt +0 -0
  32. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit.egg-info/dependency_links.txt +0 -0
  33. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit.egg-info/requires.txt +0 -0
  34. {kvgit-0.2.0 → kvgit-0.2.2}/kvgit.egg-info/top_level.txt +0 -0
  35. {kvgit-0.2.0 → kvgit-0.2.2}/setup.cfg +0 -0
  36. {kvgit-0.2.0 → kvgit-0.2.2}/tests/test_content_types.py +0 -0
  37. {kvgit-0.2.0 → kvgit-0.2.2}/tests/test_namespaced.py +0 -0
  38. {kvgit-0.2.0 → kvgit-0.2.2}/tests/test_store_factory.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kvgit
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
5
5
  Author: ashenfad
6
6
  License: MIT
@@ -49,7 +49,6 @@ Git-style versioning for your data. Commits, branches, and merges -- backed by a
49
49
  | **Commits** | Immutable, content-addressable snapshots with rollback |
50
50
  | **Branches** | Cheap forks with CAS-based optimistic concurrency |
51
51
  | **Three-way merge** | Auto-merges non-overlapping changes; pluggable merge fns for conflicts |
52
- | **Eviction** | High/low water rebase drops least-recently-used keys automatically |
53
52
  | **Pluggable backends** | In-memory, disk (diskcache), git (GitPython), IndexedDB (Pyodide/browser), or bring your own `KVStore` |
54
53
 
55
54
  ## Install
@@ -8,7 +8,6 @@ Git-style versioning for your data. Commits, branches, and merges -- backed by a
8
8
  | **Commits** | Immutable, content-addressable snapshots with rollback |
9
9
  | **Branches** | Cheap forks with CAS-based optimistic concurrency |
10
10
  | **Three-way merge** | Auto-merges non-overlapping changes; pluggable merge fns for conflicts |
11
- | **Eviction** | High/low water rebase drops least-recently-used keys automatically |
12
11
  | **Pluggable backends** | In-memory, disk (diskcache), git (GitPython), IndexedDB (Pyodide/browser), or bring your own `KVStore` |
13
12
 
14
13
  ## Install
@@ -217,7 +217,9 @@ class Hamt:
217
217
  """
218
218
  return self.walk()[0]
219
219
 
220
- def walk(self) -> tuple[dict[str, bytes], set[str]]:
220
+ def walk(
221
+ self, skip_nodes: set[str] | None = None
222
+ ) -> tuple[dict[str, bytes], set[str]]:
221
223
  """Walk the entire HAMT, returning (items, node_hashes).
222
224
 
223
225
  Single batched BFS that collects both the key→value entries
@@ -229,8 +231,19 @@ class Hamt:
229
231
  Same batching properties as ``materialize()``: one
230
232
  ``get_many`` call per tree level, O(log_branching N)
231
233
  round-trips total.
234
+
235
+ ``skip_nodes`` is an optional set of node hashes to treat as
236
+ already-visited. Skipped subtrees are not fetched, not
237
+ recursed into, and not included in the returned ``nodes``
238
+ set. Items beneath skipped subtrees are also omitted from
239
+ the returned ``items`` dict. Pass a cumulative seen-set
240
+ across multiple ``walk()`` calls (e.g. across the commits
241
+ of a branch's history) to share work where the underlying
242
+ HAMTs share structure — turns N-walks-over-shared-tree from
243
+ O(N · subtree) into O(unique nodes).
232
244
  """
233
- if self.root == EMPTY_HASH:
245
+ skip = skip_nodes if skip_nodes is not None else set()
246
+ if self.root == EMPTY_HASH or self.root in skip:
234
247
  return {}, set()
235
248
 
236
249
  items: dict[str, bytes] = {}
@@ -239,11 +252,13 @@ class Hamt:
239
252
 
240
253
  while current_level:
241
254
  # Partition this level: nodes already in pending vs
242
- # nodes that need to be fetched from the store.
255
+ # nodes that need to be fetched from the store. Drop
256
+ # anything in skip_nodes — those subtrees have already
257
+ # been visited by a prior walk.
243
258
  cached_nodes: dict[str, dict] = {}
244
259
  to_fetch: list[str] = []
245
260
  for node_hash in current_level:
246
- if node_hash == EMPTY_HASH:
261
+ if node_hash == EMPTY_HASH or node_hash in skip:
247
262
  continue
248
263
  prefixed = self.prefix + node_hash
249
264
  if prefixed in self.pending:
@@ -261,7 +276,7 @@ class Hamt:
261
276
  # node hash we successfully load.
262
277
  next_level: list[str] = []
263
278
  for node_hash in current_level:
264
- if node_hash == EMPTY_HASH:
279
+ if node_hash == EMPTY_HASH or node_hash in skip:
265
280
  continue
266
281
  if node_hash in cached_nodes:
267
282
  node = cached_nodes[node_hash]
@@ -63,7 +63,14 @@ def _promise(executor):
63
63
 
64
64
 
65
65
  async def _idb_open(db_name: str, store_name: str):
66
- """Open (or create) an IndexedDB database, returning the IDBDatabase."""
66
+ """Open (or create) an IndexedDB database, returning the IDBDatabase.
67
+
68
+ Rejects with a clear error when ``onblocked`` fires — a zombie
69
+ connection from another tab / window (or transient browser-side
70
+ state) can refuse the open indefinitely. Without an explicit
71
+ handler the promise would never resolve and ``run_sync`` would
72
+ hang forever.
73
+ """
67
74
 
68
75
  def _executor(resolve, reject):
69
76
  request = indexedDB.open(db_name, 1)
@@ -79,9 +86,19 @@ async def _idb_open(db_name: str, store_name: str):
79
86
  def on_error(event):
80
87
  reject(event.target.error)
81
88
 
89
+ def on_blocked(event):
90
+ reject(
91
+ Exception(
92
+ f"IndexedDB open of {db_name!r} is blocked. Close other "
93
+ f"tabs / windows holding the database open and reload, "
94
+ f"or restart the browser."
95
+ )
96
+ )
97
+
82
98
  request.onupgradeneeded = on_upgrade
83
99
  request.onsuccess = on_success
84
100
  request.onerror = on_error
101
+ request.onblocked = on_blocked
85
102
 
86
103
  return await _promise(_executor)
87
104
 
@@ -140,6 +140,7 @@ class Staged(MutableMapping[str, Any]):
140
140
  def commit(
141
141
  self,
142
142
  *,
143
+ keys: set[str] | None = None,
143
144
  on_conflict: str = "raise",
144
145
  merge_fns: dict[str, MergeFn] | None = None,
145
146
  default_merge: MergeFn | None = None,
@@ -148,20 +149,36 @@ class Staged(MutableMapping[str, Any]):
148
149
  """Flush staged changes to the underlying Versioned store.
149
150
 
150
151
  Encodes staged values to bytes, wraps merge functions, and
151
- calls ``Versioned.commit()``. On success, clears the staging
152
- buffer.
152
+ calls ``Versioned.commit()``. On success, clears the committed
153
+ entries from the staging buffer.
154
+
155
+ Args:
156
+ keys: If provided, only commit these specific keys.
157
+ Keys not in ``_updates`` or ``_removals`` are silently
158
+ ignored. Uncommitted keys remain staged for a future
159
+ commit. When ``None`` (default), all staged changes
160
+ are committed.
153
161
 
154
162
  Returns:
155
163
  A MergeResult (truthy when committed).
156
164
  """
157
- # Encode staged updates to bytes
165
+ # Encode staged updates to bytes — scoped to keys if provided
158
166
  encoded_updates: dict[str, bytes] | None = None
159
- if self._updates:
160
- encoded_updates = {
161
- key: self._encoder(value) for key, value in self._updates.items()
162
- }
163
-
164
- removals = self._removals if self._removals else None
167
+ if keys is not None:
168
+ # Iterate the (typically small) keys set, not the full _updates dict
169
+ matched_updates = {k: self._updates[k] for k in keys if k in self._updates}
170
+ if matched_updates:
171
+ encoded_updates = {
172
+ k: self._encoder(v) for k, v in matched_updates.items()
173
+ }
174
+ # .intersection() accepts any iterable, not just sets
175
+ removals = self._removals.intersection(keys) or None
176
+ else:
177
+ if self._updates:
178
+ encoded_updates = {
179
+ key: self._encoder(value) for key, value in self._updates.items()
180
+ }
181
+ removals = self._removals if self._removals else None
165
182
 
166
183
  # Build effective merge fns and wrap to bytes-level
167
184
  effective_fns = dict(self._merge_fns)
@@ -188,8 +205,16 @@ class Staged(MutableMapping[str, Any]):
188
205
  info=info,
189
206
  )
190
207
  if result.merged:
191
- self._updates.clear()
192
- self._removals.clear()
208
+ if keys is not None:
209
+ # Only clear the committed keys from staging
210
+ for k in keys:
211
+ self._updates.pop(k, None)
212
+ self._removals.discard(k)
213
+ else:
214
+ self._updates.clear()
215
+ self._removals.clear()
216
+ # Always clear the full read cache — HEAD moved, so cached
217
+ # values from other keys may be stale after a merge.
193
218
  self._cache.clear()
194
219
  return result
195
220
 
@@ -146,14 +146,17 @@ class Keyset:
146
146
  """
147
147
  return {k: decode_entry(v) for k, v in self._hamt.materialize().items()}
148
148
 
149
- def walk(self) -> tuple[dict[str, KeysetEntry], set[str]]:
149
+ def walk(
150
+ self, skip_nodes: set[str] | None = None
151
+ ) -> tuple[dict[str, KeysetEntry], set[str]]:
150
152
  """Single batched walk returning (entries, hamt_node_hashes).
151
153
 
152
154
  Equivalent to ``materialize()`` plus collecting every HAMT
153
155
  node hash, in one tree traversal. Used by GC mark phases
154
- that need both — see ``Hamt.walk``.
156
+ that need both — see ``Hamt.walk`` (including the
157
+ ``skip_nodes`` cumulative seen-set semantics).
155
158
  """
156
- raw_items, nodes = self._hamt.walk()
159
+ raw_items, nodes = self._hamt.walk(skip_nodes=skip_nodes)
157
160
  return {k: decode_entry(v) for k, v in raw_items.items()}, nodes
158
161
 
159
162
  def keys(self) -> Iterator[str]:
@@ -735,11 +735,17 @@ class VersionedKV(VersionedBase):
735
735
  if root is None:
736
736
  continue
737
737
  # Single batched walk per commit collects both the
738
- # blob references and the HAMT node hashes.
739
- entries, nodes = Keyset(self.store, root=root).walk()
738
+ # blob references and the HAMT node hashes. Pass the
739
+ # cumulative ``reachable_nodes`` as ``skip_nodes`` so
740
+ # subtrees already seen on a prior commit / branch are
741
+ # not re-fetched — structural sharing means the blobs
742
+ # under those subtrees are also already accounted for.
743
+ entries, new_nodes = Keyset(self.store, root=root).walk(
744
+ skip_nodes=reachable_nodes
745
+ )
740
746
  for entry in entries.values():
741
747
  reachable_blobs.add(entry.blob)
742
- reachable_nodes.update(nodes)
748
+ reachable_nodes.update(new_nodes)
743
749
 
744
750
  # Sweep phase: find orphaned commits via __commit_root__ scan.
745
751
  cutoff_time = time.time() - min_age
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kvgit
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
5
5
  Author: ashenfad
6
6
  License: MIT
@@ -49,7 +49,6 @@ Git-style versioning for your data. Commits, branches, and merges -- backed by a
49
49
  | **Commits** | Immutable, content-addressable snapshots with rollback |
50
50
  | **Branches** | Cheap forks with CAS-based optimistic concurrency |
51
51
  | **Three-way merge** | Auto-merges non-overlapping changes; pluggable merge fns for conflicts |
52
- | **Eviction** | High/low water rebase drops least-recently-used keys automatically |
53
52
  | **Pluggable backends** | In-memory, disk (diskcache), git (GitPython), IndexedDB (Pyodide/browser), or bring your own `KVStore` |
54
53
 
55
54
  ## Install
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kvgit"
7
- version = "0.2.0"
7
+ version = "0.2.2"
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"
@@ -779,3 +779,116 @@ def test_materialize_is_walk_dot_zero():
779
779
  items = {f"k{i:03d}": f"v{i}".encode() for i in range(50)}
780
780
  h = Hamt(_store(), bucket_max=4).persist(items)
781
781
  assert h.materialize() == h.walk()[0]
782
+
783
+
784
+ # ---- walk(skip_nodes=...) cumulative seen-set ----
785
+
786
+
787
+ def test_walk_skip_nodes_root_short_circuits():
788
+ """Skipping the root yields nothing — entire tree pruned."""
789
+ items = {f"k{i:03d}": f"v{i}".encode() for i in range(20)}
790
+ h = Hamt(_store(), bucket_max=4).persist(items)
791
+ walked_items, walked_nodes = h.walk(skip_nodes={h.root})
792
+ assert walked_items == {}
793
+ assert walked_nodes == set()
794
+
795
+
796
+ def test_walk_skip_nodes_excludes_skipped_from_returned_set():
797
+ """Returned ``nodes`` excludes anything in ``skip_nodes``."""
798
+ items = {f"k{i:03d}": f"v{i}".encode() for i in range(60)}
799
+ h = Hamt(_store(), bucket_max=4).persist(items)
800
+ full_nodes = h.walk()[1]
801
+ # Pick an arbitrary non-root node to skip.
802
+ interior = next(iter(full_nodes - {h.root}))
803
+ _, walked_nodes = h.walk(skip_nodes={interior})
804
+ assert interior not in walked_nodes
805
+ # Other nodes are still visited (root at minimum).
806
+ assert h.root in walked_nodes
807
+
808
+
809
+ def test_walk_skip_nodes_skips_subtree_items():
810
+ """Items beneath a skipped subtree are not returned."""
811
+ items = {f"k{i:03d}": f"v{i}".encode() for i in range(80)}
812
+ h = Hamt(_store(), bucket_max=4).persist(items)
813
+
814
+ # Find a non-root branch node and the items reachable beneath it,
815
+ # then verify those items are absent when we skip that node.
816
+ full_items, full_nodes = h.walk()
817
+ interior_candidates = full_nodes - {h.root}
818
+ # Walk only the subtree rooted at the candidate to see what's under it.
819
+ for candidate in interior_candidates:
820
+ sub_items = Hamt(h.store, root=candidate, bucket_max=4).walk()[0]
821
+ if sub_items and len(sub_items) < len(full_items):
822
+ walked_items, _ = h.walk(skip_nodes={candidate})
823
+ for k in sub_items:
824
+ assert k not in walked_items
825
+ # And the items not in that subtree are still present.
826
+ for k in full_items:
827
+ if k not in sub_items:
828
+ assert walked_items[k] == full_items[k]
829
+ return
830
+ pytest.fail("expected to find at least one non-root subtree to skip")
831
+
832
+
833
+ def test_walk_skip_nodes_does_not_fetch_skipped():
834
+ """Skipped nodes should not be fetched from the store at all."""
835
+ store = _CountingMemory()
836
+ items = {f"k{i:04d}": f"v{i}".encode() for i in range(200)}
837
+ h = Hamt(store, bucket_max=4).persist(items)
838
+ full_nodes = h.walk()[1]
839
+
840
+ # Skip every node — the only fetches should be ones forced by
841
+ # the level-batched fetch *before* we filter (we filter first
842
+ # in the implementation, so even those should be zero).
843
+ store.reset_counts()
844
+ walked_items, walked_nodes = h.walk(skip_nodes=full_nodes)
845
+ assert walked_items == {}
846
+ assert walked_nodes == set()
847
+ assert store.get_calls == 0
848
+ assert store.get_many_calls == 0, (
849
+ f"expected zero fetches when skipping the whole tree, "
850
+ f"got {store.get_many_calls}"
851
+ )
852
+
853
+
854
+ def test_walk_skip_nodes_cumulative_across_shared_subtree():
855
+ """Two HAMTs sharing structure: walking the second with the
856
+ first's nodes as skip_nodes should fetch only what's new."""
857
+ base_items = {f"k{i:04d}": f"v{i}".encode() for i in range(120)}
858
+ store = _CountingMemory()
859
+ h1 = Hamt(store, bucket_max=4).persist(base_items)
860
+
861
+ # h2 shares the bulk of h1's structure — single-key delta.
862
+ h2 = h1.persist({"new-key": b"new-value"})
863
+ assert h2.root != h1.root
864
+
865
+ # First walk seeds the seen-set.
866
+ seen: set[str] = set()
867
+ items1, nodes1 = h1.walk(skip_nodes=seen)
868
+ seen |= nodes1
869
+
870
+ # Second walk: only the path from h2.root down to the changed
871
+ # leaf should be visited; the rest of the tree is in ``seen``.
872
+ store.reset_counts()
873
+ items2, nodes2 = h2.walk(skip_nodes=seen)
874
+ # New items: just the added key (and only items along visited
875
+ # paths — which by definition contain the new key).
876
+ assert "new-key" in items2
877
+ # Visited node count should be tiny relative to the full tree.
878
+ assert len(nodes2) < len(nodes1) // 4, (
879
+ f"expected shared-structure walk to revisit << {len(nodes1) // 4} "
880
+ f"nodes; got {len(nodes2)}"
881
+ )
882
+ # And no overlap between the two walks' returned node sets.
883
+ assert nodes1.isdisjoint(nodes2)
884
+
885
+
886
+ def test_walk_skip_nodes_none_matches_no_arg():
887
+ """Passing skip_nodes=None is identical to omitting it."""
888
+ items = {f"k{i:03d}": f"v{i}".encode() for i in range(40)}
889
+ h = Hamt(_store(), bucket_max=4).persist(items)
890
+ a_items, a_nodes = h.walk()
891
+ b_items, b_nodes = h.walk(skip_nodes=None)
892
+ c_items, c_nodes = h.walk(skip_nodes=set())
893
+ assert a_items == b_items == c_items
894
+ assert a_nodes == b_nodes == c_nodes
@@ -173,6 +173,117 @@ class TestStagedCommit:
173
173
  assert s.versioned.commit_info() == {"author": "test"}
174
174
 
175
175
 
176
+ class TestStagedPartialCommit:
177
+ """Tests for commit(keys=...) partial commit support."""
178
+
179
+ def test_partial_commit_only_flushes_specified_keys(self):
180
+ store = Memory()
181
+ v = Versioned(store)
182
+ s = Staged(v)
183
+ s["a"] = 1
184
+ s["b"] = 2
185
+ result = s.commit(keys={"a"})
186
+ assert result.merged
187
+
188
+ # "a" is committed and no longer staged
189
+ assert not s.is_staged("a")
190
+ # "b" is still staged
191
+ assert s.is_staged("b")
192
+ assert s["b"] == 2
193
+
194
+ def test_partial_commit_persists_committed_keys(self):
195
+ store = Memory()
196
+ v = Versioned(store)
197
+ s = Staged(v)
198
+ s["a"] = 1
199
+ s["b"] = 2
200
+ s.commit(keys={"a"})
201
+
202
+ # Verify "a" is persisted (visible from a fresh Staged)
203
+ s2 = Staged(Versioned(store))
204
+ assert s2.get("a") == 1
205
+ # "b" is NOT persisted (still only in s's staging buffer)
206
+ assert s2.get("b") is None
207
+
208
+ def test_partial_commit_with_info(self):
209
+ s = Staged(Versioned())
210
+ s["a"] = 1
211
+ s["b"] = 2
212
+ result = s.commit(keys={"a"}, info={"message": "just a"})
213
+ assert result.merged
214
+ assert s.versioned.commit_info()["message"] == "just a"
215
+
216
+ def test_partial_commit_ignores_unknown_keys(self):
217
+ s = Staged(Versioned())
218
+ s["a"] = 1
219
+ # "ghost" is not staged — should be silently ignored
220
+ result = s.commit(keys={"a", "ghost"})
221
+ assert result.merged
222
+ assert not s.is_staged("a")
223
+
224
+ def test_partial_commit_with_removals(self):
225
+ s = Staged(Versioned())
226
+ s["a"] = 1
227
+ s["b"] = 2
228
+ s.commit()
229
+
230
+ del s["a"]
231
+ s["c"] = 3
232
+ # Partial commit: only the removal of "a"
233
+ s.commit(keys={"a"})
234
+ assert s.get("a") is None # deleted
235
+ assert s.is_staged("c") # still pending
236
+
237
+ def test_partial_commit_clears_full_cache(self):
238
+ """Regression test: after a partial commit, the entire read cache
239
+ must be cleared because HEAD moved. Cached values from non-committed
240
+ keys could be stale if a concurrent writer modified them.
241
+ """
242
+ store = Memory()
243
+ v = Versioned(store)
244
+ s = Staged(v)
245
+
246
+ s["x"] = "original_x"
247
+ s["y"] = "original_y"
248
+ s.commit()
249
+
250
+ # Read "y" to populate the cache
251
+ assert s["y"] == "original_y"
252
+
253
+ # Simulate a concurrent writer updating "y" on the same branch
254
+ v2 = Versioned(store)
255
+ v2.commit({"y": s._encoder("updated_by_other")})
256
+
257
+ # Partial commit of "x" — HEAD moves to include the concurrent write
258
+ s["x"] = "new_x"
259
+ s.commit(keys={"x"})
260
+
261
+ # After partial commit, "y" should reflect the concurrent update,
262
+ # NOT the stale cached value. Before the fix, only "x" was evicted
263
+ # from cache, leaving "y" stale as "original_y".
264
+ s.refresh()
265
+ assert s["y"] == "updated_by_other"
266
+
267
+ def test_keys_accepts_non_set_iterable(self):
268
+ """keys parameter should accept any iterable, not just sets."""
269
+ s = Staged(Versioned())
270
+ s["a"] = 1
271
+ s["b"] = 2
272
+ # Pass a list instead of a set
273
+ result = s.commit(keys=["a"])
274
+ assert result.merged
275
+ assert not s.is_staged("a")
276
+ assert s.is_staged("b")
277
+
278
+ def test_full_commit_unchanged_when_keys_is_none(self):
279
+ """Without keys, commit flushes everything (backwards compatible)."""
280
+ s = Staged(Versioned())
281
+ s["a"] = 1
282
+ s["b"] = 2
283
+ s.commit()
284
+ assert not s.has_changes
285
+
286
+
176
287
  class TestStagedReset:
177
288
  def test_reset_clears_staging(self):
178
289
  s = Staged(Versioned())
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