kvgit 0.3.8__tar.gz → 0.4.0__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 (63) hide show
  1. {kvgit-0.3.8 → kvgit-0.4.0}/PKG-INFO +104 -25
  2. kvgit-0.4.0/README.md +242 -0
  3. kvgit-0.4.0/kvgit/__init__.py +81 -0
  4. kvgit-0.4.0/kvgit/_codec.py +162 -0
  5. kvgit-0.4.0/kvgit/_open.py +62 -0
  6. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/codecs/__init__.py +7 -8
  7. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/codecs/base.py +3 -2
  8. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/content_types.py +17 -8
  9. kvgit-0.4.0/kvgit/errors.py +87 -0
  10. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/hamt.py +201 -71
  11. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/kv/__init__.py +7 -0
  12. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/kv/base.py +49 -4
  13. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/kv/composite.py +40 -29
  14. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/kv/disk.py +23 -12
  15. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/kv/indexeddb.py +79 -33
  16. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/kv/memory.py +15 -10
  17. kvgit-0.4.0/kvgit/kv/postgres.py +314 -0
  18. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/merges.py +76 -24
  19. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/namespaced.py +1 -1
  20. kvgit-0.4.0/kvgit/refs.py +159 -0
  21. kvgit-0.4.0/kvgit/repo.py +401 -0
  22. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/versioned/__init__.py +1 -3
  23. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/versioned/base.py +188 -63
  24. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/versioned/helpers.py +12 -24
  25. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/versioned/keyset.py +13 -4
  26. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/versioned/kv.py +1136 -715
  27. kvgit-0.4.0/kvgit/versioned/merge.py +197 -0
  28. kvgit-0.4.0/kvgit/versioned/protocol.py +134 -0
  29. kvgit-0.4.0/kvgit/worktree.py +432 -0
  30. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit.egg-info/PKG-INFO +104 -25
  31. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit.egg-info/SOURCES.txt +10 -4
  32. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit.egg-info/requires.txt +6 -0
  33. {kvgit-0.3.8 → kvgit-0.4.0}/pyproject.toml +9 -2
  34. {kvgit-0.3.8 → kvgit-0.4.0}/tests/test_content_types.py +42 -27
  35. kvgit-0.4.0/tests/test_delete_branches.py +139 -0
  36. {kvgit-0.3.8 → kvgit-0.4.0}/tests/test_hamt.py +63 -0
  37. {kvgit-0.3.8 → kvgit-0.4.0}/tests/test_merge_policy.py +47 -54
  38. {kvgit-0.3.8 → kvgit-0.4.0}/tests/test_merges.py +123 -2
  39. {kvgit-0.3.8 → kvgit-0.4.0}/tests/test_namespaced.py +6 -11
  40. kvgit-0.4.0/tests/test_open.py +118 -0
  41. kvgit-0.4.0/tests/test_repo.py +447 -0
  42. kvgit-0.4.0/tests/test_storage_v4.py +398 -0
  43. kvgit-0.4.0/tests/test_worktree.py +655 -0
  44. kvgit-0.3.8/README.md +0 -168
  45. kvgit-0.3.8/kvgit/__init__.py +0 -43
  46. kvgit-0.3.8/kvgit/errors.py +0 -38
  47. kvgit-0.3.8/kvgit/staged.py +0 -633
  48. kvgit-0.3.8/kvgit/store.py +0 -247
  49. kvgit-0.3.8/kvgit/versioned/merge.py +0 -222
  50. kvgit-0.3.8/kvgit/versioned/protocol.py +0 -226
  51. kvgit-0.3.8/tests/test_delete_branches.py +0 -209
  52. kvgit-0.3.8/tests/test_staged.py +0 -536
  53. kvgit-0.3.8/tests/test_store_factory.py +0 -99
  54. {kvgit-0.3.8 → kvgit-0.4.0}/LICENSE +0 -0
  55. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/codecs/_hash.py +0 -0
  56. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/codecs/numpy.py +0 -0
  57. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/codecs/pandas.py +0 -0
  58. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/codecs/pickler.py +0 -0
  59. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/encoding.py +0 -0
  60. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/py.typed +0 -0
  61. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit.egg-info/dependency_links.txt +0 -0
  62. {kvgit-0.3.8 → kvgit-0.4.0}/kvgit.egg-info/top_level.txt +0 -0
  63. {kvgit-0.3.8 → kvgit-0.4.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kvgit
3
- Version: 0.3.8
3
+ Version: 0.4.0
4
4
  Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
5
5
  Author: ashenfad
6
6
  License: MIT
@@ -35,10 +35,15 @@ Requires-Dist: pandas>=2.0; extra == "pandas"
35
35
  Provides-Extra: scientific
36
36
  Requires-Dist: numpy>=1.24; extra == "scientific"
37
37
  Requires-Dist: pandas>=2.0; extra == "scientific"
38
+ Provides-Extra: postgres
39
+ Requires-Dist: psycopg[binary]>=3.2; extra == "postgres"
40
+ Requires-Dist: psycopg-pool>=3.2; extra == "postgres"
38
41
  Provides-Extra: all
39
42
  Requires-Dist: diskcache; extra == "all"
40
43
  Requires-Dist: numpy>=1.24; extra == "all"
41
44
  Requires-Dist: pandas>=2.0; extra == "all"
45
+ Requires-Dist: psycopg[binary]>=3.2; extra == "all"
46
+ Requires-Dist: psycopg-pool>=3.2; extra == "all"
42
47
  Provides-Extra: dev
43
48
  Requires-Dist: pytest; extra == "dev"
44
49
  Requires-Dist: diskcache; extra == "dev"
@@ -58,14 +63,17 @@ Git-style versioning for your data. Commits, branches, and merges -- backed by a
58
63
  | **Branches** | Cheap forks with CAS-based optimistic concurrency |
59
64
  | **Tags** | Immutable names for commits; a tagged commit outlives every branch that reached it, in every kvgit version |
60
65
  | **Three-way merge** | Auto-merges non-overlapping changes; pluggable merge fns for conflicts |
61
- | **Pluggable backends** | In-memory, disk (diskcache), IndexedDB (Pyodide/browser), or bring your own `KVStore` |
62
- | **Chunked codecs** | Optional content-addressed dedup for large numpy arrays and pandas DataFrames -- equal buffers stored once across keys, commits, and branches |
66
+ | **Pluggable backends** | In-memory, disk (diskcache), PostgreSQL, IndexedDB (Pyodide/browser), or bring your own `KVStore` -- any store with an atomic conditional batch |
67
+ | **Concurrent GC** | Garbage collection runs beside live writers, from any process sharing the store |
68
+ | **Content-addressed storage** | Equal values are stored once across keys, commits, and branches |
69
+ | **Chunked codecs** | Optional dedup *inside* large numpy arrays and pandas DataFrames -- equal buffers (slices included) stored once |
63
70
 
64
71
  ## Install
65
72
 
66
73
  ```bash
67
74
  pip install kvgit # in-memory only
68
75
  pip install kvgit[disk] # adds disk backend via diskcache
76
+ pip install kvgit[postgres] # adds PostgreSQL backend via psycopg
69
77
  pip install kvgit[scientific] # adds chunked codecs for numpy / pandas
70
78
  # IndexedDB backend is available automatically in Pyodide (browser) environments
71
79
  ```
@@ -75,14 +83,16 @@ pip install kvgit[scientific] # adds chunked codecs for numpy / pandas
75
83
  ```python
76
84
  import kvgit
77
85
 
78
- main = kvgit.store()
86
+ main = kvgit.open() # a Worktree on branch "main" of an in-memory Repo
87
+ repo = main.repo
79
88
 
80
89
  main["user"] = "alice"
81
90
  main["score"] = 0
82
91
  main.commit()
83
92
 
84
93
  # Branch and diverge
85
- dev = main.create_branch("dev")
94
+ repo.branches.create("dev", at=main.head)
95
+ dev = repo.worktree("dev")
86
96
  dev["score"] = 999
87
97
  dev.commit()
88
98
 
@@ -90,22 +100,70 @@ print(main["score"]) # 0 (main unchanged)
90
100
  print(dev["score"]) # 999 (dev branch)
91
101
 
92
102
  # Tag a commit by name -- immutable, and safe from garbage collection
93
- main.tag("v1")
94
- print(main.peek("score", tag="v1")) # 0
103
+ repo.tags.create("v1", main.head)
104
+ print(repo.snapshot(tag="v1")["score"]) # 0
95
105
  ```
96
106
 
107
+ A `Repo` owns the backend and everything store-wide: branches, tags,
108
+ history, snapshots, garbage collection. `repo.branches` and `repo.tags`
109
+ are live mappings of name to commit, with `create` and `delete`, shaped
110
+ like pygit2's `repo.branches`. A `Worktree` is one branch
111
+ checked out for work -- a dict whose writes stay pending until
112
+ `commit()`. A `Snapshot` is a read-only dict pinned to one commit.
113
+
114
+ ```python
115
+ from kvgit import Repo
116
+ from kvgit.kv.disk import Disk
117
+
118
+ with Repo(Disk("/tmp/kvgit-demo")) as on_disk:
119
+ wt = on_disk.worktree("main", create=True) # open, or create at the empty root
120
+ wt["k"] = "v"
121
+ print(wt.status()) # Status(updated=frozenset({'k'}), removed=frozenset())
122
+ wt.commit(info={"msg": "first"})
123
+ for c in on_disk.log(branch="main"):
124
+ print(c.hash[:8], c.info) # the commit, then the empty root commit
125
+ ```
126
+
127
+ ## For git users
128
+
129
+ | git | kvgit |
130
+ |---|---|
131
+ | `git worktree add` / `git checkout s1` | `repo.worktree("s1")` |
132
+ | edit, `git add` | `wt[k] = v` -- no index; everything is pending until commit |
133
+ | `git status` | `wt.status()` |
134
+ | `git commit [paths]` | `wt.commit([keys=...])` |
135
+ | `git restore .` | `wt.discard()` |
136
+ | `git reset --hard c` | `wt.reset(c)` |
137
+ | `git merge x` | `wt.merge(branch="x")` |
138
+ | `git cherry-pick c` / `git revert c` | `wt.cherry_pick(c)` / `wt.revert(c)` |
139
+ | `git branch`, `git tag` (list) | `list(repo.branches)`, `dict(repo.tags)` |
140
+ | `git branch [-D] x` | `repo.branches.create("x")` / `repo.branches.delete("x")` |
141
+ | `git tag [-d] v1` | `repo.tags.create("v1", c)` / `repo.tags.delete("v1")` |
142
+ | `git log`, `git show c`, `git diff a b`, `git merge-base a b` | `repo.log(...)`, `repo.get_commit(c)`, `repo.diff(a, b)`, `repo.merge_base(a, b)` |
143
+ | `git show v1:path` | `repo.snapshot(tag="v1")[key]` |
144
+ | `git gc [--prune=now]` | `repo.gc([min_age=0])` |
145
+
146
+ Where it differs: there is no index; several worktrees can hold one
147
+ branch, and their commits race and merge rather than being refused; a
148
+ conflicted merge raises `MergeConflict` and changes nothing (unless a
149
+ merge function such as `text_merge` writes markers into the value);
150
+ commit hashes are not git's, and there are no remotes.
151
+
97
152
  ## Merging
98
153
 
99
- `Staged.merge()` merges another head -- usually another branch's HEAD --
100
- into the current branch: lowest common ancestor, three-way resolve, and
101
- a two-parent merge commit guarded on your own head:
154
+ `Worktree.merge()` merges a branch, tag or commit into the worktree's
155
+ branch: lowest common ancestor, three-way resolve, and a two-parent
156
+ merge commit guarded on your own head. As in git, a branch that has not
157
+ moved since the fork fast-forwards instead, and `fast_forward=False`
158
+ writes the merge commit anyway:
102
159
 
103
160
  ```python
104
161
  dev["score"] = 500
105
162
  dev.commit()
106
163
 
107
- result = main.merge(dev.current_commit) # True when merged
108
- print(main["score"]) # 500 (fast-forward: main hadn't diverged)
164
+ result = main.merge(branch="dev") # truthy when merged
165
+ print(result.strategy) # "fast_forward": main hadn't moved since dev forked
166
+ print(main["score"]) # 500
109
167
  ```
110
168
 
111
169
  Overlapping changes need a merge function per key, or a `default_merge`
@@ -120,21 +178,21 @@ from kvgit import text_merge
120
178
  main["notes"] = "alpha\nbeta\n"
121
179
  main.commit()
122
180
 
123
- edits = main.create_branch("edits")
181
+ repo.branches.create("edits", at=main.head)
182
+ edits = repo.worktree("edits")
124
183
  edits["notes"] = "alpha\nBETA\n"
125
184
  edits.commit()
126
185
 
127
186
  main["notes"] = "ALPHA\nbeta\n"
128
187
  main.commit()
129
188
 
130
- main.merge(edits.current_commit, default_merge=text_merge())
189
+ main.merge(branch="edits", default_merge=text_merge())
131
190
  print(main["notes"]) # "ALPHA\nBETA\n" -- both edits kept
132
191
  ```
133
192
 
134
- `kvgit.merges.text` is the same merge one level down, over raw bytes,
135
- for `VersionedKV`. A `Staged` decodes each side before calling a merge
136
- function, so register `text_merge()` there unless the key's values are
137
- already `bytes`.
193
+ Merge functions see decoded values, so `text_merge()` works on `str`
194
+ values under the default codec. `kvgit.merges.text` is the same merge
195
+ over raw bytes, for keys whose values already are `bytes`.
138
196
 
139
197
  Registrations resolve most-specific-first: a key takes its exact-key
140
198
  registration, else the longest registered prefix it starts with, else
@@ -148,6 +206,11 @@ main.set_merge_prefix("runs/", MergeChoice.OURS) # this branch owns runs/
148
206
  main.set_merge_fn("runs/index", text_merge()) # except this one key
149
207
  ```
150
208
 
209
+ Rules can live on the `Repo` too -- `Repo(backend, merge_fns=...,
210
+ merge_prefixes=..., default_merge=...)` -- for every worktree it opens.
211
+ A worktree's own registrations layer over the repo's, and a call's
212
+ `merge_fns=` / `merge_prefixes=` / `default_merge=` over both.
213
+
151
214
  A registration holds either a merge function or a `MergeChoice`, and the
152
215
  two reach differently:
153
216
 
@@ -165,17 +228,33 @@ keep that side's stored value as it stands rather than rewriting it, so
165
228
  the merge writes no new blob -- and if the chosen side removed the key,
166
229
  the merge removes it.
167
230
 
168
- `commit()` and `merge()` take `merge_fns=` / `merge_prefixes=` /
169
- `default_merge=` for one call, layered over what is registered.
170
-
171
231
  Keys both sides changed to the same bytes merge cleanly with no merge
172
232
  function, even though each side wrote its own copy.
173
233
 
174
234
  A `post_check(key, merged_bytes)` predicate runs over every
175
235
  merge-produced value; returning `False` files that key as conflicted.
176
236
  `on_conflict="abandon"` leaves the branch untouched instead of raising.
177
- Merging refuses with `ValueError` when the staging buffer holds
178
- uncommitted changes -- commit or reset first.
237
+ Merging refuses with `ValueError` while the worktree has pending
238
+ changes -- commit or `discard()` first.
239
+
240
+ `cherry_pick(c)`, `revert(c)` and the general `apply(base, target)`
241
+ replay one change onto the worktree's branch as an ordinary
242
+ single-parent commit, with the same merge options.
243
+
244
+ ## Codecs and trust
245
+
246
+ A repo's codec turns values into stored bytes. `codec="pickle"` is the
247
+ default: it is what makes a worktree a dict of anything. But unpickling
248
+ can execute code, so anyone who can write a pickle-codec store -- a
249
+ shared Postgres table, a disk directory -- can run code in every process
250
+ that reads it. For a store more than one party can write, use
251
+ `codec="bytes"`: kvgit then never decodes anything, values must be
252
+ `bytes`, and you choose where (if anywhere) untrusted pickles are
253
+ loaded. `snapshot.raw` reads the stored bytes under any codec.
254
+
255
+ The codec is fixed per store in practice: values written under one read
256
+ back as that codec's bytes under another, so switching an existing
257
+ store means rewriting its values.
179
258
 
180
259
  ## Chunked codecs (numpy / pandas)
181
260
 
@@ -185,7 +264,7 @@ Large numpy arrays and pandas DataFrames -- and any sliced views of them -- can
185
264
  import kvgit
186
265
  import numpy as np
187
266
 
188
- s = kvgit.store(codecs="scientific")
267
+ s = kvgit.open(codec="scientific")
189
268
 
190
269
  big = np.arange(1_000_000, dtype="float64") # ~8 MB
191
270
  s["full"] = big
@@ -199,7 +278,7 @@ Pandas DataFrames piggyback on the numpy codec via their underlying block ndarra
199
278
 
200
279
  ## Part of the agex stack
201
280
 
202
- kvgit provides versioned agent memory in [agex](https://github.com/ashenfad/agex) with branching and rollback. It also works as a versioned backing store for [monkeyfs](https://github.com/ashenfad/monkeyfs) virtual filesystems -- pass a `Staged` instance anywhere a dict is expected.
281
+ kvgit provides versioned agent memory in [agex](https://github.com/ashenfad/agex) with branching and rollback. It also works as a versioned backing store for [monkeyfs](https://github.com/ashenfad/monkeyfs) virtual filesystems -- pass a `Worktree` anywhere a dict is expected.
203
282
 
204
283
  ## Development
205
284
 
kvgit-0.4.0/README.md ADDED
@@ -0,0 +1,242 @@
1
+ # kvgit 🔀
2
+
3
+ Git-style versioning for your data. Commits, branches, and merges -- backed by a dict-like `MutableMapping`.
4
+
5
+ | Features | Description |
6
+ |---|---|
7
+ | **Dict interface** | `MutableMapping[str, Any]` -- reads and writes work like a dict |
8
+ | **Commits** | Immutable, content-addressable snapshots with rollback |
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 |
11
+ | **Three-way merge** | Auto-merges non-overlapping changes; pluggable merge fns for conflicts |
12
+ | **Pluggable backends** | In-memory, disk (diskcache), PostgreSQL, IndexedDB (Pyodide/browser), or bring your own `KVStore` -- any store with an atomic conditional batch |
13
+ | **Concurrent GC** | Garbage collection runs beside live writers, from any process sharing the store |
14
+ | **Content-addressed storage** | Equal values are stored once across keys, commits, and branches |
15
+ | **Chunked codecs** | Optional dedup *inside* large numpy arrays and pandas DataFrames -- equal buffers (slices included) stored once |
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install kvgit # in-memory only
21
+ pip install kvgit[disk] # adds disk backend via diskcache
22
+ pip install kvgit[postgres] # adds PostgreSQL backend via psycopg
23
+ pip install kvgit[scientific] # adds chunked codecs for numpy / pandas
24
+ # IndexedDB backend is available automatically in Pyodide (browser) environments
25
+ ```
26
+
27
+ ## Quick example
28
+
29
+ ```python
30
+ import kvgit
31
+
32
+ main = kvgit.open() # a Worktree on branch "main" of an in-memory Repo
33
+ repo = main.repo
34
+
35
+ main["user"] = "alice"
36
+ main["score"] = 0
37
+ main.commit()
38
+
39
+ # Branch and diverge
40
+ repo.branches.create("dev", at=main.head)
41
+ dev = repo.worktree("dev")
42
+ dev["score"] = 999
43
+ dev.commit()
44
+
45
+ print(main["score"]) # 0 (main unchanged)
46
+ print(dev["score"]) # 999 (dev branch)
47
+
48
+ # Tag a commit by name -- immutable, and safe from garbage collection
49
+ repo.tags.create("v1", main.head)
50
+ print(repo.snapshot(tag="v1")["score"]) # 0
51
+ ```
52
+
53
+ A `Repo` owns the backend and everything store-wide: branches, tags,
54
+ history, snapshots, garbage collection. `repo.branches` and `repo.tags`
55
+ are live mappings of name to commit, with `create` and `delete`, shaped
56
+ like pygit2's `repo.branches`. A `Worktree` is one branch
57
+ checked out for work -- a dict whose writes stay pending until
58
+ `commit()`. A `Snapshot` is a read-only dict pinned to one commit.
59
+
60
+ ```python
61
+ from kvgit import Repo
62
+ from kvgit.kv.disk import Disk
63
+
64
+ with Repo(Disk("/tmp/kvgit-demo")) as on_disk:
65
+ wt = on_disk.worktree("main", create=True) # open, or create at the empty root
66
+ wt["k"] = "v"
67
+ print(wt.status()) # Status(updated=frozenset({'k'}), removed=frozenset())
68
+ wt.commit(info={"msg": "first"})
69
+ for c in on_disk.log(branch="main"):
70
+ print(c.hash[:8], c.info) # the commit, then the empty root commit
71
+ ```
72
+
73
+ ## For git users
74
+
75
+ | git | kvgit |
76
+ |---|---|
77
+ | `git worktree add` / `git checkout s1` | `repo.worktree("s1")` |
78
+ | edit, `git add` | `wt[k] = v` -- no index; everything is pending until commit |
79
+ | `git status` | `wt.status()` |
80
+ | `git commit [paths]` | `wt.commit([keys=...])` |
81
+ | `git restore .` | `wt.discard()` |
82
+ | `git reset --hard c` | `wt.reset(c)` |
83
+ | `git merge x` | `wt.merge(branch="x")` |
84
+ | `git cherry-pick c` / `git revert c` | `wt.cherry_pick(c)` / `wt.revert(c)` |
85
+ | `git branch`, `git tag` (list) | `list(repo.branches)`, `dict(repo.tags)` |
86
+ | `git branch [-D] x` | `repo.branches.create("x")` / `repo.branches.delete("x")` |
87
+ | `git tag [-d] v1` | `repo.tags.create("v1", c)` / `repo.tags.delete("v1")` |
88
+ | `git log`, `git show c`, `git diff a b`, `git merge-base a b` | `repo.log(...)`, `repo.get_commit(c)`, `repo.diff(a, b)`, `repo.merge_base(a, b)` |
89
+ | `git show v1:path` | `repo.snapshot(tag="v1")[key]` |
90
+ | `git gc [--prune=now]` | `repo.gc([min_age=0])` |
91
+
92
+ Where it differs: there is no index; several worktrees can hold one
93
+ branch, and their commits race and merge rather than being refused; a
94
+ conflicted merge raises `MergeConflict` and changes nothing (unless a
95
+ merge function such as `text_merge` writes markers into the value);
96
+ commit hashes are not git's, and there are no remotes.
97
+
98
+ ## Merging
99
+
100
+ `Worktree.merge()` merges a branch, tag or commit into the worktree's
101
+ branch: lowest common ancestor, three-way resolve, and a two-parent
102
+ merge commit guarded on your own head. As in git, a branch that has not
103
+ moved since the fork fast-forwards instead, and `fast_forward=False`
104
+ writes the merge commit anyway:
105
+
106
+ ```python
107
+ dev["score"] = 500
108
+ dev.commit()
109
+
110
+ result = main.merge(branch="dev") # truthy when merged
111
+ print(result.strategy) # "fast_forward": main hadn't moved since dev forked
112
+ print(main["score"]) # 500
113
+ ```
114
+
115
+ Overlapping changes need a merge function per key, or a `default_merge`
116
+ fallback. `text_merge()` resolves line-oriented text with git-style
117
+ `<<<<<<<` markers (its `ours_label` / `theirs_label` arguments name
118
+ them); anything it cannot mark -- binary, non-UTF-8, oversized -- raises
119
+ `CantMark`, filed as an ordinary conflict:
120
+
121
+ ```python
122
+ from kvgit import text_merge
123
+
124
+ main["notes"] = "alpha\nbeta\n"
125
+ main.commit()
126
+
127
+ repo.branches.create("edits", at=main.head)
128
+ edits = repo.worktree("edits")
129
+ edits["notes"] = "alpha\nBETA\n"
130
+ edits.commit()
131
+
132
+ main["notes"] = "ALPHA\nbeta\n"
133
+ main.commit()
134
+
135
+ main.merge(branch="edits", default_merge=text_merge())
136
+ print(main["notes"]) # "ALPHA\nBETA\n" -- both edits kept
137
+ ```
138
+
139
+ Merge functions see decoded values, so `text_merge()` works on `str`
140
+ values under the default codec. `kvgit.merges.text` is the same merge
141
+ over raw bytes, for keys whose values already are `bytes`.
142
+
143
+ Registrations resolve most-specific-first: a key takes its exact-key
144
+ registration, else the longest registered prefix it starts with, else
145
+ `default_merge`. Prefixes cover keys whose names are not known when the
146
+ policy is set:
147
+
148
+ ```python
149
+ from kvgit import MergeChoice, text_merge
150
+
151
+ main.set_merge_prefix("runs/", MergeChoice.OURS) # this branch owns runs/
152
+ main.set_merge_fn("runs/index", text_merge()) # except this one key
153
+ ```
154
+
155
+ Rules can live on the `Repo` too -- `Repo(backend, merge_fns=...,
156
+ merge_prefixes=..., default_merge=...)` -- for every worktree it opens.
157
+ A worktree's own registrations layer over the repo's, and a call's
158
+ `merge_fns=` / `merge_prefixes=` / `default_merge=` over both.
159
+
160
+ A registration holds either a merge function or a `MergeChoice`, and the
161
+ two reach differently:
162
+
163
+ * A **merge function** is consulted only where both sides changed a key.
164
+ A change only one side made is applied as it always was.
165
+ * A **`MergeChoice`** is a standing policy: it hands that side every key
166
+ either side changed under it. So `OURS` also drops a key the other
167
+ side added and keeps one the other side removed -- what a branch that
168
+ owns a whole namespace needs. Nothing is read or decoded for those
169
+ keys.
170
+
171
+ `kvgit.merges.ours` and `kvgit.merges.theirs` are the merge-function
172
+ form, for keys where one side wins only when both sides collide. They
173
+ keep that side's stored value as it stands rather than rewriting it, so
174
+ the merge writes no new blob -- and if the chosen side removed the key,
175
+ the merge removes it.
176
+
177
+ Keys both sides changed to the same bytes merge cleanly with no merge
178
+ function, even though each side wrote its own copy.
179
+
180
+ A `post_check(key, merged_bytes)` predicate runs over every
181
+ merge-produced value; returning `False` files that key as conflicted.
182
+ `on_conflict="abandon"` leaves the branch untouched instead of raising.
183
+ Merging refuses with `ValueError` while the worktree has pending
184
+ changes -- commit or `discard()` first.
185
+
186
+ `cherry_pick(c)`, `revert(c)` and the general `apply(base, target)`
187
+ replay one change onto the worktree's branch as an ordinary
188
+ single-parent commit, with the same merge options.
189
+
190
+ ## Codecs and trust
191
+
192
+ A repo's codec turns values into stored bytes. `codec="pickle"` is the
193
+ default: it is what makes a worktree a dict of anything. But unpickling
194
+ can execute code, so anyone who can write a pickle-codec store -- a
195
+ shared Postgres table, a disk directory -- can run code in every process
196
+ that reads it. For a store more than one party can write, use
197
+ `codec="bytes"`: kvgit then never decodes anything, values must be
198
+ `bytes`, and you choose where (if anywhere) untrusted pickles are
199
+ loaded. `snapshot.raw` reads the stored bytes under any codec.
200
+
201
+ The codec is fixed per store in practice: values written under one read
202
+ back as that codec's bytes under another, so switching an existing
203
+ store means rewriting its values.
204
+
205
+ ## Chunked codecs (numpy / pandas)
206
+
207
+ Large numpy arrays and pandas DataFrames -- and any sliced views of them -- can be stored once and shared across keys, commits, and branches:
208
+
209
+ ```python
210
+ import kvgit
211
+ import numpy as np
212
+
213
+ s = kvgit.open(codec="scientific")
214
+
215
+ big = np.arange(1_000_000, dtype="float64") # ~8 MB
216
+ s["full"] = big
217
+ s["head"] = big[:100_000]
218
+ s["tail"] = big[-100_000:]
219
+ s.commit()
220
+ # All three keys reference the same chunk on disk -- ~8 MB total, not ~24 MB.
221
+ ```
222
+
223
+ Pandas DataFrames piggyback on the numpy codec via their underlying block ndarrays. See [`docs/quick-start.md`](docs/quick-start.md#storing-scientific-data-efficiently-chunked-codecs) and the [API reference](docs/api.md#chunked-codecs).
224
+
225
+ ## Part of the agex stack
226
+
227
+ kvgit provides versioned agent memory in [agex](https://github.com/ashenfad/agex) with branching and rollback. It also works as a versioned backing store for [monkeyfs](https://github.com/ashenfad/monkeyfs) virtual filesystems -- pass a `Worktree` anywhere a dict is expected.
228
+
229
+ ## Development
230
+
231
+ ```bash
232
+ uv sync --extra dev
233
+ uv run pytest
234
+ ```
235
+
236
+ ## Documentation
237
+
238
+ See [`docs/`](docs/) for detailed documentation:
239
+
240
+ - [Quick Start](docs/quick-start.md) -- common patterns with runnable examples
241
+ - [API Reference](docs/api.md) -- full reference for all classes, methods, and types
242
+ - [Browser persistence (Pyodide)](docs/pyodide.md) -- choosing between the IndexedDB and OPFS-mounted-disk backends, plus the syncfs flush requirement and recommended host-side patterns
@@ -0,0 +1,81 @@
1
+ """kvgit: versioned key-value store."""
2
+
3
+ from ._open import open
4
+ from .content_types import MergeFn, counter, last_writer_wins, text_merge
5
+ from .errors import (
6
+ BranchExistsError,
7
+ ConcurrencyError,
8
+ CorruptHeadError,
9
+ GcBusy,
10
+ KvgitError,
11
+ MergeConflict,
12
+ StorageVersionError,
13
+ TagExistsError,
14
+ UnknownBranchError,
15
+ UnknownCommitError,
16
+ UnknownTagError,
17
+ )
18
+ from .merges import (
19
+ CantMark,
20
+ TextMergeFn,
21
+ make_text_merge,
22
+ ours,
23
+ text,
24
+ text_merge_result,
25
+ theirs,
26
+ )
27
+ from .namespaced import Namespaced
28
+ from .refs import Branches, Tags
29
+ from .repo import Commit, RawSnapshot, Repo, Snapshot
30
+ from .versioned.kv import ROOT_COMMIT, CorruptHeadRecoverer, recover_by_commit_scan
31
+ from .versioned.protocol import (
32
+ DiffResult,
33
+ MergeChoice,
34
+ MergePolicy,
35
+ MergeResult,
36
+ TagInfo,
37
+ )
38
+ from .worktree import Status, Worktree
39
+
40
+ __all__ = [
41
+ "ROOT_COMMIT",
42
+ "BranchExistsError",
43
+ "Branches",
44
+ "CantMark",
45
+ "Commit",
46
+ "ConcurrencyError",
47
+ "CorruptHeadError",
48
+ "CorruptHeadRecoverer",
49
+ "DiffResult",
50
+ "GcBusy",
51
+ "KvgitError",
52
+ "MergeChoice",
53
+ "MergeConflict",
54
+ "MergeFn",
55
+ "MergePolicy",
56
+ "MergeResult",
57
+ "Namespaced",
58
+ "RawSnapshot",
59
+ "Repo",
60
+ "Snapshot",
61
+ "Status",
62
+ "StorageVersionError",
63
+ "TagExistsError",
64
+ "TagInfo",
65
+ "Tags",
66
+ "TextMergeFn",
67
+ "UnknownBranchError",
68
+ "UnknownCommitError",
69
+ "UnknownTagError",
70
+ "Worktree",
71
+ "counter",
72
+ "last_writer_wins",
73
+ "make_text_merge",
74
+ "open",
75
+ "ours",
76
+ "recover_by_commit_scan",
77
+ "text",
78
+ "text_merge",
79
+ "text_merge_result",
80
+ "theirs",
81
+ ]