OXYGENHUB

Import the Oxygen specification and the first Rust implementation (root change) ff0af1ee299d

Author
unknown
When
· 2026-09-26 19:37 UTC
Signature
◈ sealed by Andy McCall
Hash
ff0af1ee299d64b3ca59b3a9f70da9645e199e3e13c338c2c29a4fb300939220
Depends on
nothing: a root change
Depended on by
835ceb9b3ea7 135bdcadec4a c397a8c36a9f 6f4226bfb87f 91334b7fa574 ed931d1d1102 6c3dbe2018e0 642da2636de2 4275ecb110f5 c0d4075cadc0 80b069facc69 2452b4407ab7 4afe288b7946 9bd98cfb4f7e e02edccce98c 795b8b502ded 544d0541a7e5 157d02e95586 4476cdbb0443 570e35310f31 b260109d27c5 f3811eb20bea 7bd805ffb418 d3b270909c78 45a6f5ec85cf b63fc7a77fc6 82202a4d0025 1ec6fdbc6bcb 6f06d9f912b4 771661aa50f4 8156967b882c ef7d4ea6f33b 67a4fe1aa2d5 8b3c9b2ba502 ad2496f7e608 d36ba2f79f95 028545dfa7a4 87322662f607 c2e1d09047db 69953b507188 0299076f09a2 733ea16a9701 767ac6cd0545 7e0e8e5ad882 51d4862a16d8 b0621c2f8cc4 e59512f2f075 0ed150d62994 e5f8eb2a2006 68dcf21fe055 f05b84d1ad2d c895bc52e4e0 a2387d547373 f53cbe7a204e f0a517d28237 3a6effc74835 c63fed2e0465 02e691881a4e efad40f9ac8e a67914bced23 2cd6bf89adb4 74b4a3c62ea4 092457020692 a39754d02c58 e5d9b0513f3e 3a4b3e018326 b582948f1912 68a924846379 d0016e229b04 7fc365369758 b689779e9e74 530a0fc650a2 e2a5edbf1395 d6e0d5b8b339 aedfe465b611 cb8b8082fa73 ac03130a2b95 5552e69c7325 79b5a90ffd43 170bb436e1d5 d0bf2da15094 579afd107b9b 54e2e6309aa3 dd21fd82687d 33be24095b4b d45dab0484ea c71a5777509a ff1a64f81c1c 648189388f1a 2c9ddac3c387 47ce8b8e2d4b 7ba155f20aa0 9ae4d15a0e4b 2cd40ea1c04c c3d2ef6104c2 f874dc8659c2 8e1194860efa 8d80356371c5 a5f76c79666c 8ad9dfe16303 b277870b0749 a70dce536476 6a0008781d5e bea00d4177c6 5b4c51307b14 360aa74a6277 fddf64baa3d2 e96a430c78f3 b30a880fa409 10b4fd430eab b0461604ed98 14edbaaf5291 ff1f62fee654 3d7120efe04a fd280228bdc2 fb5b6181618a d4dbab97e948 a17fc622ca43 091fef80755d acadb5ed28a1 e266b7d4065c 638fb21420d7 d616faf5d136 ce14f0d68b15 4cce912a0953 e0d56dfca51d 36e51aa806c6 3f1b8fe25ec9 61e9ad6ddc2d 930e5f3f1682 05059d434aca f9575918477f 7eac74abc586 19527875ebdc 40a40bb7b798 dbf06d342e1d e83354cdac22 2306a4d24779 d0d52107b074 f6451fbbae3f 4d6c9c1b358c ab4d4e31b38d 0f93f49ce059 da652d6392d8 57d8dc05c9d4 a87c7b9d563a 519c7c061bcb 598009a75f6c 01a47cd7f0c8 17f94b13ae54 192fad1e46a2 206c76cad4bf 69816d688300 a4fad5b085c7 c5ce0367eddf da868d5d2646 86e1824f2d0b d443639bcf35 1a3b62032ed9 4fe7d6045860 6caacfff1d4f 33e77a5526fa 6f8aeb996558 a9f686edf60b e293c90dca62 dfb23a3a8cfa fc1a466a1e66 83d61e803ccd f7510b63cceb e7ac6c2e84e4 6a81dbab471d 1c2bd9abdd4f be451356e3ef 61e3bce7ea5b 8c30d814f2cc f8c695764c95 cc7a00565161 e8d38e47e9d7 afaba5f1306d 696bbf29d3eb 0bdc3f91a39f 7891e21c762f c863c7a7a2d1 62794ecc7f74 d42369abf180 3e5772ccf9da 19555025a2ac e7ddd552b619 07eb99be126e 1d360c91a289 4dcb8c0916b7 387335ff7f4d 37c299cec358 75036530b621 84b9e9ac0ab4 d82e44097323 9d333fc9ca1b 119bbf50cb86 7aa2dc9369e3 b0e64e81e4fb 05e71ed6cdf1 75167672ebcd e02bf0cd0533 c6aa5bd9d1c8 8b678b298fee 728da0f92161 957fee30826e 4dee277c327a fbda43e7d8ed b3c5a59a875a fe7a964b3c88 86fe29fd62ea 9ac1fbe211b4 50a046e439e9 ee06a6c557b7 b7f102fcbb17 8f10a07a1dbe 2b872d074ddb 30ea9c1e70fa 6e723b81f5b9 6706bdcfb63e 28fc23d7706a 23b217d75848 6d3eefbce2f0 cdb5beba7f05 4e88e774777a 132b884b9a8d 2863456a0f6d 7421309f5372 135212de5370 cfb7d414eae6 252e8cb23fbc 96f7610fa666 315167f3e208 c3069537d7c5 ba160f60219e f77698833f9f c07e0e7d2d85 d02e545dc265 83e0677b07fe 2b5ee371060e 982864068a59 fce44ae9ad29 096f447ccda0 750251b21a96 4161262eeb29 adec9a0796b8 5b9c6ad45f6e 86e682c242c6 b288cd254343 20247a03a596 5b1114a04905 1983686fb7a7 e993a086c13f f9c98deef326 55e5a16a2b9c 648724d8e0de
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
19 atoms, 24 tree operations, 19 files

Browse the tree at this change

added .gitignore +1 −0
1 +
/target
added .oxyignore +1 −0
1 +
/target
added Cargo.lock +622 −0
1 +
# This file is automatically @generated by Cargo.
2 +
# It is not intended for manual editing.
3 +
version = 4
4 +
5 +
[[package]]
6 +
name = "aho-corasick"
7 +
version = "1.1.5"
8 +
source = "registry+https://github.com/rust-lang/crates.io-index"
9 +
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
10 +
dependencies = [
11 +
 "memchr",
12 +
]
13 +
14 +
[[package]]
15 +
name = "anstream"
16 +
version = "1.0.0"
17 +
source = "registry+https://github.com/rust-lang/crates.io-index"
18 +
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
19 +
dependencies = [
20 +
 "anstyle",
21 +
 "anstyle-parse",
22 +
 "anstyle-query",
23 +
 "anstyle-wincon",
24 +
 "colorchoice",
25 +
 "is_terminal_polyfill",
26 +
 "utf8parse",
27 +
]
28 +
29 +
[[package]]
30 +
name = "anstyle"
31 +
version = "1.0.14"
32 +
source = "registry+https://github.com/rust-lang/crates.io-index"
33 +
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
34 +
35 +
[[package]]
36 +
name = "anstyle-parse"
37 +
version = "1.0.0"
38 +
source = "registry+https://github.com/rust-lang/crates.io-index"
39 +
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
40 +
dependencies = [
41 +
 "utf8parse",
42 +
]
43 +
44 +
[[package]]
45 +
name = "anstyle-query"
46 +
version = "1.1.5"
47 +
source = "registry+https://github.com/rust-lang/crates.io-index"
48 +
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
49 +
dependencies = [
50 +
 "windows-sys",
51 +
]
52 +
53 +
[[package]]
54 +
name = "anstyle-wincon"
55 +
version = "3.0.11"
56 +
source = "registry+https://github.com/rust-lang/crates.io-index"
57 +
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
58 +
dependencies = [
59 +
 "anstyle",
60 +
 "once_cell_polyfill",
61 +
 "windows-sys",
62 +
]
63 +
64 +
[[package]]
65 +
name = "anyhow"
66 +
version = "1.0.104"
67 +
source = "registry+https://github.com/rust-lang/crates.io-index"
68 +
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
69 +
70 +
[[package]]
71 +
name = "arrayvec"
72 +
version = "0.7.8"
73 +
source = "registry+https://github.com/rust-lang/crates.io-index"
74 +
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
75 +
76 +
[[package]]
77 +
name = "bitflags"
78 +
version = "2.13.2"
79 +
source = "registry+https://github.com/rust-lang/crates.io-index"
80 +
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
81 +
82 +
[[package]]
83 +
name = "blake3"
84 +
version = "1.8.7"
85 +
source = "registry+https://github.com/rust-lang/crates.io-index"
86 +
checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae"
87 +
dependencies = [
88 +
 "arrayvec",
89 +
 "cc",
90 +
 "cfg-if",
91 +
 "constant_time_eq",
92 +
 "cpufeatures",
93 +
]
94 +
95 +
[[package]]
96 +
name = "bstr"
97 +
version = "1.13.1"
98 +
source = "registry+https://github.com/rust-lang/crates.io-index"
99 +
checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f"
100 +
dependencies = [
101 +
 "memchr",
102 +
 "serde_core",
103 +
]
104 +
105 +
[[package]]
106 +
name = "cc"
107 +
version = "1.5.1"
108 +
source = "registry+https://github.com/rust-lang/crates.io-index"
109 +
checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
110 +
dependencies = [
111 +
 "find-msvc-tools",
112 +
 "shlex",
113 +
]
114 +
115 +
[[package]]
116 +
name = "cfg-if"
117 +
version = "1.0.5"
118 +
source = "registry+https://github.com/rust-lang/crates.io-index"
119 +
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
120 +
121 +
[[package]]
122 +
name = "clap"
123 +
version = "4.6.7"
124 +
source = "registry+https://github.com/rust-lang/crates.io-index"
125 +
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
126 +
dependencies = [
127 +
 "clap_builder",
128 +
 "clap_derive",
129 +
]
130 +
131 +
[[package]]
132 +
name = "clap_builder"
133 +
version = "4.6.7"
134 +
source = "registry+https://github.com/rust-lang/crates.io-index"
135 +
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
136 +
dependencies = [
137 +
 "anstream",
138 +
 "anstyle",
139 +
 "clap_lex",
140 +
 "strsim",
141 +
]
142 +
143 +
[[package]]
144 +
name = "clap_derive"
145 +
version = "4.6.7"
146 +
source = "registry+https://github.com/rust-lang/crates.io-index"
147 +
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
148 +
dependencies = [
149 +
 "heck",
150 +
 "proc-macro2",
151 +
 "quote",
152 +
 "syn 3.0.6",
153 +
]
154 +
155 +
[[package]]
156 +
name = "clap_lex"
157 +
version = "1.1.1"
158 +
source = "registry+https://github.com/rust-lang/crates.io-index"
159 +
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
160 +
161 +
[[package]]
162 +
name = "colorchoice"
163 +
version = "1.0.5"
164 +
source = "registry+https://github.com/rust-lang/crates.io-index"
165 +
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
166 +
167 +
[[package]]
168 +
name = "constant_time_eq"
169 +
version = "0.4.2"
170 +
source = "registry+https://github.com/rust-lang/crates.io-index"
171 +
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
172 +
173 +
[[package]]
174 +
name = "cpufeatures"
175 +
version = "0.3.1"
176 +
source = "registry+https://github.com/rust-lang/crates.io-index"
177 +
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
178 +
dependencies = [
179 +
 "libc",
180 +
]
181 +
182 +
[[package]]
183 +
name = "crossbeam-deque"
184 +
version = "0.8.8"
185 +
source = "registry+https://github.com/rust-lang/crates.io-index"
186 +
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
187 +
dependencies = [
188 +
 "crossbeam-epoch",
189 +
 "crossbeam-utils",
190 +
]
191 +
192 +
[[package]]
193 +
name = "crossbeam-epoch"
194 +
version = "0.9.21"
195 +
source = "registry+https://github.com/rust-lang/crates.io-index"
196 +
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
197 +
dependencies = [
198 +
 "crossbeam-utils",
199 +
]
200 +
201 +
[[package]]
202 +
name = "crossbeam-utils"
203 +
version = "0.8.23"
204 +
source = "registry+https://github.com/rust-lang/crates.io-index"
205 +
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
206 +
207 +
[[package]]
208 +
name = "errno"
209 +
version = "0.3.14"
210 +
source = "registry+https://github.com/rust-lang/crates.io-index"
211 +
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
212 +
dependencies = [
213 +
 "libc",
214 +
 "windows-sys",
215 +
]
216 +
217 +
[[package]]
218 +
name = "fastrand"
219 +
version = "2.5.0"
220 +
source = "registry+https://github.com/rust-lang/crates.io-index"
221 +
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
222 +
223 +
[[package]]
224 +
name = "find-msvc-tools"
225 +
version = "0.1.14"
226 +
source = "registry+https://github.com/rust-lang/crates.io-index"
227 +
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
228 +
229 +
[[package]]
230 +
name = "getrandom"
231 +
version = "0.2.17"
232 +
source = "registry+https://github.com/rust-lang/crates.io-index"
233 +
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
234 +
dependencies = [
235 +
 "cfg-if",
236 +
 "libc",
237 +
 "wasi",
238 +
]
239 +
240 +
[[package]]
241 +
name = "getrandom"
242 +
version = "0.4.3"
243 +
source = "registry+https://github.com/rust-lang/crates.io-index"
244 +
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
245 +
dependencies = [
246 +
 "cfg-if",
247 +
 "libc",
248 +
 "r-efi",
249 +
]
250 +
251 +
[[package]]
252 +
name = "globset"
253 +
version = "0.4.20"
254 +
source = "registry+https://github.com/rust-lang/crates.io-index"
255 +
checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988"
256 +
dependencies = [
257 +
 "aho-corasick",
258 +
 "bstr",
259 +
 "log",
260 +
 "regex-automata",
261 +
 "regex-syntax",
262 +
]
263 +
264 +
[[package]]
265 +
name = "heck"
266 +
version = "0.5.0"
267 +
source = "registry+https://github.com/rust-lang/crates.io-index"
268 +
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
269 +
270 +
[[package]]
271 +
name = "hex"
272 +
version = "0.4.3"
273 +
source = "registry+https://github.com/rust-lang/crates.io-index"
274 +
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
275 +
276 +
[[package]]
277 +
name = "ignore"
278 +
version = "0.4.33"
279 +
source = "registry+https://github.com/rust-lang/crates.io-index"
280 +
checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f"
281 +
dependencies = [
282 +
 "crossbeam-deque",
283 +
 "globset",
284 +
 "log",
285 +
 "memchr",
286 +
 "regex-automata",
287 +
 "same-file",
288 +
 "walkdir",
289 +
 "winapi-util",
290 +
]
291 +
292 +
[[package]]
293 +
name = "is_terminal_polyfill"
294 +
version = "1.70.2"
295 +
source = "registry+https://github.com/rust-lang/crates.io-index"
296 +
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
297 +
298 +
[[package]]
299 +
name = "libc"
300 +
version = "0.2.189"
301 +
source = "registry+https://github.com/rust-lang/crates.io-index"
302 +
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
303 +
304 +
[[package]]
305 +
name = "linux-raw-sys"
306 +
version = "0.12.1"
307 +
source = "registry+https://github.com/rust-lang/crates.io-index"
308 +
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
309 +
310 +
[[package]]
311 +
name = "log"
312 +
version = "0.4.34"
313 +
source = "registry+https://github.com/rust-lang/crates.io-index"
314 +
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
315 +
316 +
[[package]]
317 +
name = "memchr"
318 +
version = "2.8.3"
319 +
source = "registry+https://github.com/rust-lang/crates.io-index"
320 +
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
321 +
322 +
[[package]]
323 +
name = "once_cell"
324 +
version = "1.21.4"
325 +
source = "registry+https://github.com/rust-lang/crates.io-index"
326 +
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
327 +
328 +
[[package]]
329 +
name = "once_cell_polyfill"
330 +
version = "1.70.2"
331 +
source = "registry+https://github.com/rust-lang/crates.io-index"
332 +
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
333 +
334 +
[[package]]
335 +
name = "oxy"
336 +
version = "0.1.0"
337 +
dependencies = [
338 +
 "anyhow",
339 +
 "clap",
340 +
 "oxy-core",
341 +
 "tempfile",
342 +
]
343 +
344 +
[[package]]
345 +
name = "oxy-core"
346 +
version = "0.1.0"
347 +
dependencies = [
348 +
 "blake3",
349 +
 "hex",
350 +
 "ignore",
351 +
 "rand",
352 +
 "similar",
353 +
 "tempfile",
354 +
 "thiserror",
355 +
]
356 +
357 +
[[package]]
358 +
name = "ppv-lite86"
359 +
version = "0.2.21"
360 +
source = "registry+https://github.com/rust-lang/crates.io-index"
361 +
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
362 +
dependencies = [
363 +
 "zerocopy",
364 +
]
365 +
366 +
[[package]]
367 +
name = "proc-macro2"
368 +
version = "1.0.107"
369 +
source = "registry+https://github.com/rust-lang/crates.io-index"
370 +
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
371 +
dependencies = [
372 +
 "unicode-ident",
373 +
]
374 +
375 +
[[package]]
376 +
name = "quote"
377 +
version = "1.0.47"
378 +
source = "registry+https://github.com/rust-lang/crates.io-index"
379 +
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
380 +
dependencies = [
381 +
 "proc-macro2",
382 +
]
383 +
384 +
[[package]]
385 +
name = "r-efi"
386 +
version = "6.0.0"
387 +
source = "registry+https://github.com/rust-lang/crates.io-index"
388 +
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
389 +
390 +
[[package]]
391 +
name = "rand"
392 +
version = "0.8.8"
393 +
source = "registry+https://github.com/rust-lang/crates.io-index"
394 +
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
395 +
dependencies = [
396 +
 "libc",
397 +
 "rand_chacha",
398 +
 "rand_core",
399 +
]
400 +
401 +
[[package]]
402 +
name = "rand_chacha"
403 +
version = "0.3.1"
404 +
source = "registry+https://github.com/rust-lang/crates.io-index"
405 +
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
406 +
dependencies = [
407 +
 "ppv-lite86",
408 +
 "rand_core",
409 +
]
410 +
411 +
[[package]]
412 +
name = "rand_core"
413 +
version = "0.6.4"
414 +
source = "registry+https://github.com/rust-lang/crates.io-index"
415 +
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
416 +
dependencies = [
417 +
 "getrandom 0.2.17",
418 +
]
419 +
420 +
[[package]]
421 +
name = "regex-automata"
422 +
version = "0.4.18"
423 +
source = "registry+https://github.com/rust-lang/crates.io-index"
424 +
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
425 +
dependencies = [
426 +
 "aho-corasick",
427 +
 "memchr",
428 +
 "regex-syntax",
429 +
]
430 +
431 +
[[package]]
432 +
name = "regex-syntax"
433 +
version = "0.8.11"
434 +
source = "registry+https://github.com/rust-lang/crates.io-index"
435 +
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
436 +
437 +
[[package]]
438 +
name = "rustix"
439 +
version = "1.1.5"
440 +
source = "registry+https://github.com/rust-lang/crates.io-index"
441 +
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
442 +
dependencies = [
443 +
 "bitflags",
444 +
 "errno",
445 +
 "libc",
446 +
 "linux-raw-sys",
447 +
 "windows-sys",
448 +
]
449 +
450 +
[[package]]
451 +
name = "same-file"
452 +
version = "1.0.6"
453 +
source = "registry+https://github.com/rust-lang/crates.io-index"
454 +
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
455 +
dependencies = [
456 +
 "winapi-util",
457 +
]
458 +
459 +
[[package]]
460 +
name = "serde_core"
461 +
version = "1.0.229"
462 +
source = "registry+https://github.com/rust-lang/crates.io-index"
463 +
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
464 +
dependencies = [
465 +
 "serde_derive",
466 +
]
467 +
468 +
[[package]]
469 +
name = "serde_derive"
470 +
version = "1.0.229"
471 +
source = "registry+https://github.com/rust-lang/crates.io-index"
472 +
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
473 +
dependencies = [
474 +
 "proc-macro2",
475 +
 "quote",
476 +
 "syn 3.0.6",
477 +
]
478 +
479 +
[[package]]
480 +
name = "shlex"
481 +
version = "2.0.1"
482 +
source = "registry+https://github.com/rust-lang/crates.io-index"
483 +
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
484 +
485 +
[[package]]
486 +
name = "similar"
487 +
version = "2.7.0"
488 +
source = "registry+https://github.com/rust-lang/crates.io-index"
489 +
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
490 +
491 +
[[package]]
492 +
name = "strsim"
493 +
version = "0.11.1"
494 +
source = "registry+https://github.com/rust-lang/crates.io-index"
495 +
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
496 +
497 +
[[package]]
498 +
name = "syn"
499 +
version = "2.0.119"
500 +
source = "registry+https://github.com/rust-lang/crates.io-index"
501 +
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
502 +
dependencies = [
503 +
 "proc-macro2",
504 +
 "quote",
505 +
 "unicode-ident",
506 +
]
507 +
508 +
[[package]]
509 +
name = "syn"
510 +
version = "3.0.6"
511 +
source = "registry+https://github.com/rust-lang/crates.io-index"
512 +
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
513 +
dependencies = [
514 +
 "proc-macro2",
515 +
 "quote",
516 +
 "unicode-ident",
517 +
]
518 +
519 +
[[package]]
520 +
name = "tempfile"
521 +
version = "3.27.0"
522 +
source = "registry+https://github.com/rust-lang/crates.io-index"
523 +
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
524 +
dependencies = [
525 +
 "fastrand",
526 +
 "getrandom 0.4.3",
527 +
 "once_cell",
528 +
 "rustix",
529 +
 "windows-sys",
530 +
]
531 +
532 +
[[package]]
533 +
name = "thiserror"
534 +
version = "1.0.69"
535 +
source = "registry+https://github.com/rust-lang/crates.io-index"
536 +
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
537 +
dependencies = [
538 +
 "thiserror-impl",
539 +
]
540 +
541 +
[[package]]
542 +
name = "thiserror-impl"
543 +
version = "1.0.69"
544 +
source = "registry+https://github.com/rust-lang/crates.io-index"
545 +
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
546 +
dependencies = [
547 +
 "proc-macro2",
548 +
 "quote",
549 +
 "syn 2.0.119",
550 +
]
551 +
552 +
[[package]]
553 +
name = "unicode-ident"
554 +
version = "1.0.26"
555 +
source = "registry+https://github.com/rust-lang/crates.io-index"
556 +
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
557 +
558 +
[[package]]
559 +
name = "utf8parse"
560 +
version = "0.2.2"
561 +
source = "registry+https://github.com/rust-lang/crates.io-index"
562 +
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
563 +
564 +
[[package]]
565 +
name = "walkdir"
566 +
version = "2.5.0"
567 +
source = "registry+https://github.com/rust-lang/crates.io-index"
568 +
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
569 +
dependencies = [
570 +
 "same-file",
571 +
 "winapi-util",
572 +
]
573 +
574 +
[[package]]
575 +
name = "wasi"
576 +
version = "0.11.1+wasi-snapshot-preview1"
577 +
source = "registry+https://github.com/rust-lang/crates.io-index"
578 +
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
579 +
580 +
[[package]]
581 +
name = "winapi-util"
582 +
version = "0.1.11"
583 +
source = "registry+https://github.com/rust-lang/crates.io-index"
584 +
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
585 +
dependencies = [
586 +
 "windows-sys",
587 +
]
588 +
589 +
[[package]]
590 +
name = "windows-link"
591 +
version = "0.2.1"
592 +
source = "registry+https://github.com/rust-lang/crates.io-index"
593 +
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
594 +
595 +
[[package]]
596 +
name = "windows-sys"
597 +
version = "0.61.2"
598 +
source = "registry+https://github.com/rust-lang/crates.io-index"
599 +
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
600 +
dependencies = [
601 +
 "windows-link",
602 +
]
603 +
604 +
[[package]]
605 +
name = "zerocopy"
606 +
version = "0.8.59"
607 +
source = "registry+https://github.com/rust-lang/crates.io-index"
608 +
checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
609 +
dependencies = [
610 +
 "zerocopy-derive",
611 +
]
612 +
613 +
[[package]]
614 +
name = "zerocopy-derive"
615 +
version = "0.8.59"
616 +
source = "registry+https://github.com/rust-lang/crates.io-index"
617 +
checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
618 +
dependencies = [
619 +
 "proc-macro2",
620 +
 "quote",
621 +
 "syn 2.0.119",
622 +
]
added Cargo.toml +28 −0
1 +
[workspace]
2 +
members = ["crates/oxy-core", "."]
3 +
resolver = "2"
4 +
5 +
[workspace.package]
6 +
version = "0.1.0"
7 +
edition = "2021"
8 +
license = "MIT OR Apache-2.0"
9 +
10 +
[package]
11 +
name = "oxy"
12 +
version.workspace = true
13 +
edition.workspace = true
14 +
license.workspace = true
15 +
description = "Oxygen: a distributed, commutative version control system"
16 +
default-run = "oxy"
17 +
18 +
[[bin]]
19 +
name = "oxy"
20 +
path = "src/main.rs"
21 +
22 +
[dependencies]
23 +
oxy-core = { path = "crates/oxy-core" }
24 +
clap = { version = "4", features = ["derive"] }
25 +
anyhow = "1"
26 +
27 +
[dev-dependencies]
28 +
tempfile = "3"
added crates/oxy-core/Cargo.toml +17 −0
1 +
[package]
2 +
name = "oxy-core"
3 +
version.workspace = true
4 +
edition.workspace = true
5 +
license.workspace = true
6 +
description = "Core data model, change graph, and storage for Oxygen"
7 +
8 +
[dependencies]
9 +
blake3 = "1"
10 +
hex = "0.4"
11 +
rand = "0.8"
12 +
similar = "2"
13 +
thiserror = "1"
14 +
ignore = "0.4"
15 +
16 +
[dev-dependencies]
17 +
tempfile = "3"
added crates/oxy-core/src/cbor.rs +322 −0
1 +
//! Deterministic CBOR (RFC 8949 §4.2.1) restricted to the profile of spec §5.1:
2 +
//! unsigned integers, byte strings, text strings, arrays, maps with unsigned
3 +
//! integer keys in increasing order, booleans, and null. Everything else is
4 +
//! rejected on decode and cannot be expressed on encode.
5 +
6 +
use std::collections::BTreeMap;
7 +
8 +
#[derive(Clone, Debug, PartialEq, Eq)]
9 +
pub enum Value {
10 +
    Uint(u64),
11 +
    Bytes(Vec<u8>),
12 +
    Text(String),
13 +
    Array(Vec<Value>),
14 +
    Map(BTreeMap<u64, Value>),
15 +
    Bool(bool),
16 +
    Null,
17 +
}
18 +
19 +
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
20 +
pub enum CborError {
21 +
    #[error("unexpected end of input")]
22 +
    UnexpectedEnd,
23 +
    #[error("non-canonical encoding: {0}")]
24 +
    NonCanonical(&'static str),
25 +
    #[error("unsupported CBOR item: {0}")]
26 +
    Unsupported(&'static str),
27 +
    #[error("trailing bytes after value")]
28 +
    TrailingBytes,
29 +
    #[error("invalid UTF-8 in text string")]
30 +
    InvalidUtf8,
31 +
    #[error("map keys must be strictly increasing unsigned integers")]
32 +
    BadMapKey,
33 +
    #[error("length exceeds input")]
34 +
    LengthOverflow,
35 +
}
36 +
37 +
impl Value {
38 +
    pub fn map(pairs: impl IntoIterator<Item = (u64, Value)>) -> Value {
39 +
        Value::Map(pairs.into_iter().collect())
40 +
    }
41 +
    pub fn as_uint(&self) -> Option<u64> {
42 +
        if let Value::Uint(n) = self { Some(*n) } else { None }
43 +
    }
44 +
    pub fn as_bytes(&self) -> Option<&[u8]> {
45 +
        if let Value::Bytes(b) = self { Some(b) } else { None }
46 +
    }
47 +
    pub fn as_text(&self) -> Option<&str> {
48 +
        if let Value::Text(t) = self { Some(t) } else { None }
49 +
    }
50 +
    pub fn as_array(&self) -> Option<&[Value]> {
51 +
        if let Value::Array(a) = self { Some(a) } else { None }
52 +
    }
53 +
    pub fn as_map(&self) -> Option<&BTreeMap<u64, Value>> {
54 +
        if let Value::Map(m) = self { Some(m) } else { None }
55 +
    }
56 +
    pub fn as_bool(&self) -> Option<bool> {
57 +
        if let Value::Bool(b) = self { Some(*b) } else { None }
58 +
    }
59 +
    pub fn is_null(&self) -> bool {
60 +
        matches!(self, Value::Null)
61 +
    }
62 +
}
63 +
64 +
pub fn encode(v: &Value) -> Vec<u8> {
65 +
    let mut out = Vec::new();
66 +
    encode_into(v, &mut out);
67 +
    out
68 +
}
69 +
70 +
pub fn encode_into(v: &Value, out: &mut Vec<u8>) {
71 +
    match v {
72 +
        Value::Uint(n) => head(out, 0, *n),
73 +
        Value::Bytes(b) => {
74 +
            head(out, 2, b.len() as u64);
75 +
            out.extend_from_slice(b);
76 +
        }
77 +
        Value::Text(t) => {
78 +
            head(out, 3, t.len() as u64);
79 +
            out.extend_from_slice(t.as_bytes());
80 +
        }
81 +
        Value::Array(a) => {
82 +
            head(out, 4, a.len() as u64);
83 +
            for x in a {
84 +
                encode_into(x, out);
85 +
            }
86 +
        }
87 +
        Value::Map(m) => {
88 +
            head(out, 5, m.len() as u64);
89 +
            for (k, x) in m {
90 +
                head(out, 0, *k);
91 +
                encode_into(x, out);
92 +
            }
93 +
        }
94 +
        Value::Bool(false) => out.push(0xf4),
95 +
        Value::Bool(true) => out.push(0xf5),
96 +
        Value::Null => out.push(0xf6),
97 +
    }
98 +
}
99 +
100 +
fn head(out: &mut Vec<u8>, major: u8, n: u64) {
101 +
    let m = major << 5;
102 +
    if n < 24 {
103 +
        out.push(m | n as u8);
104 +
    } else if n <= 0xff {
105 +
        out.push(m | 24);
106 +
        out.push(n as u8);
107 +
    } else if n <= 0xffff {
108 +
        out.push(m | 25);
109 +
        out.extend_from_slice(&(n as u16).to_be_bytes());
110 +
    } else if n <= 0xffff_ffff {
111 +
        out.push(m | 26);
112 +
        out.extend_from_slice(&(n as u32).to_be_bytes());
113 +
    } else {
114 +
        out.push(m | 27);
115 +
        out.extend_from_slice(&n.to_be_bytes());
116 +
    }
117 +
}
118 +
119 +
pub fn decode(bytes: &[u8]) -> Result<Value, CborError> {
120 +
    let mut d = Decoder { b: bytes, pos: 0, depth: 0 };
121 +
    let v = d.value()?;
122 +
    if d.pos != bytes.len() {
123 +
        return Err(CborError::TrailingBytes);
124 +
    }
125 +
    Ok(v)
126 +
}
127 +
128 +
const MAX_DEPTH: u32 = 256;
129 +
130 +
struct Decoder<'a> {
131 +
    b: &'a [u8],
132 +
    pos: usize,
133 +
    depth: u32,
134 +
}
135 +
136 +
impl<'a> Decoder<'a> {
137 +
    fn byte(&mut self) -> Result<u8, CborError> {
138 +
        let x = *self.b.get(self.pos).ok_or(CborError::UnexpectedEnd)?;
139 +
        self.pos += 1;
140 +
        Ok(x)
141 +
    }
142 +
143 +
    fn take(&mut self, n: u64) -> Result<&'a [u8], CborError> {
144 +
        let n = usize::try_from(n).map_err(|_| CborError::LengthOverflow)?;
145 +
        let end = self.pos.checked_add(n).ok_or(CborError::LengthOverflow)?;
146 +
        if end > self.b.len() {
147 +
            return Err(CborError::UnexpectedEnd);
148 +
        }
149 +
        let s = &self.b[self.pos..end];
150 +
        self.pos = end;
151 +
        Ok(s)
152 +
    }
153 +
154 +
    /// Reads a head for majors 0..=5 and returns (major, argument), enforcing
155 +
    /// shortest-form arguments.
156 +
    fn head(&mut self) -> Result<(u8, u64), CborError> {
157 +
        let ib = self.byte()?;
158 +
        let major = ib >> 5;
159 +
        let ai = ib & 0x1f;
160 +
        if major == 7 {
161 +
            return Err(CborError::Unsupported("simple value or float where a length or integer was expected"));
162 +
        }
163 +
        let n = match ai {
164 +
            0..=23 => ai as u64,
165 +
            24 => {
166 +
                let n = self.byte()? as u64;
167 +
                if n < 24 {
168 +
                    return Err(CborError::NonCanonical("argument fits in the initial byte"));
169 +
                }
170 +
                n
171 +
            }
172 +
            25 => {
173 +
                let n = u16::from_be_bytes(self.take(2)?.try_into().unwrap()) as u64;
174 +
                if n <= 0xff {
175 +
                    return Err(CborError::NonCanonical("argument fits in one byte"));
176 +
                }
177 +
                n
178 +
            }
179 +
            26 => {
180 +
                let n = u32::from_be_bytes(self.take(4)?.try_into().unwrap()) as u64;
181 +
                if n <= 0xffff {
182 +
                    return Err(CborError::NonCanonical("argument fits in two bytes"));
183 +
                }
184 +
                n
185 +
            }
186 +
            27 => {
187 +
                let n = u64::from_be_bytes(self.take(8)?.try_into().unwrap());
188 +
                if n <= 0xffff_ffff {
189 +
                    return Err(CborError::NonCanonical("argument fits in four bytes"));
190 +
                }
191 +
                n
192 +
            }
193 +
            28..=30 => return Err(CborError::Unsupported("reserved additional information")),
194 +
            _ => return Err(CborError::Unsupported("indefinite-length item")),
195 +
        };
196 +
        Ok((major, n))
197 +
    }
198 +
199 +
    fn value(&mut self) -> Result<Value, CborError> {
200 +
        self.depth += 1;
201 +
        if self.depth > MAX_DEPTH {
202 +
            return Err(CborError::Unsupported("nesting deeper than 256"));
203 +
        }
204 +
        let ib = *self.b.get(self.pos).ok_or(CborError::UnexpectedEnd)?;
205 +
        let v = if ib >> 5 == 7 {
206 +
            self.pos += 1;
207 +
            match ib {
208 +
                0xf4 => Value::Bool(false),
209 +
                0xf5 => Value::Bool(true),
210 +
                0xf6 => Value::Null,
211 +
                _ => return Err(CborError::Unsupported("simple value, float, undefined, or break")),
212 +
            }
213 +
        } else {
214 +
            let (major, n) = self.head()?;
215 +
            match major {
216 +
                0 => Value::Uint(n),
217 +
                1 => return Err(CborError::Unsupported("negative integer")),
218 +
                2 => Value::Bytes(self.take(n)?.to_vec()),
219 +
                3 => Value::Text(
220 +
                    std::str::from_utf8(self.take(n)?)
221 +
                        .map_err(|_| CborError::InvalidUtf8)?
222 +
                        .to_owned(),
223 +
                ),
224 +
                4 => {
225 +
                    let mut a = Vec::new();
226 +
                    for _ in 0..n {
227 +
                        a.push(self.value()?);
228 +
                    }
229 +
                    Value::Array(a)
230 +
                }
231 +
                5 => {
232 +
                    let mut m = BTreeMap::new();
233 +
                    let mut last: Option<u64> = None;
234 +
                    for _ in 0..n {
235 +
                        let (km, k) = self.head()?;
236 +
                        if km != 0 {
237 +
                            return Err(CborError::BadMapKey);
238 +
                        }
239 +
                        if let Some(l) = last {
240 +
                            if k <= l {
241 +
                                return Err(CborError::BadMapKey);
242 +
                            }
243 +
                        }
244 +
                        last = Some(k);
245 +
                        let v = self.value()?;
246 +
                        m.insert(k, v);
247 +
                    }
248 +
                    Value::Map(m)
249 +
                }
250 +
                _ => return Err(CborError::Unsupported("tag")),
251 +
            }
252 +
        };
253 +
        self.depth -= 1;
254 +
        Ok(v)
255 +
    }
256 +
}
257 +
258 +
#[cfg(test)]
259 +
mod tests {
260 +
    use super::*;
261 +
262 +
    fn rt(v: Value) -> Vec<u8> {
263 +
        let b = encode(&v);
264 +
        assert_eq!(decode(&b).unwrap(), v);
265 +
        b
266 +
    }
267 +
268 +
    #[test]
269 +
    fn integers_use_shortest_form() {
270 +
        assert_eq!(rt(Value::Uint(0)), [0x00]);
271 +
        assert_eq!(rt(Value::Uint(23)), [0x17]);
272 +
        assert_eq!(rt(Value::Uint(24)), [0x18, 24]);
273 +
        assert_eq!(rt(Value::Uint(255)), [0x18, 255]);
274 +
        assert_eq!(rt(Value::Uint(256)), [0x19, 1, 0]);
275 +
        assert_eq!(rt(Value::Uint(65536)), [0x1a, 0, 1, 0, 0]);
276 +
        assert_eq!(rt(Value::Uint(1 << 32)), [0x1b, 0, 0, 0, 1, 0, 0, 0, 0]);
277 +
        assert_eq!(rt(Value::Uint(u64::MAX)).len(), 9);
278 +
    }
279 +
280 +
    #[test]
281 +
    fn rejects_non_shortest_integers() {
282 +
        assert_eq!(decode(&[0x18, 5]), Err(CborError::NonCanonical("argument fits in the initial byte")));
283 +
        assert_eq!(decode(&[0x19, 0, 5]), Err(CborError::NonCanonical("argument fits in one byte")));
284 +
        assert_eq!(decode(&[0x1a, 0, 0, 0, 5]), Err(CborError::NonCanonical("argument fits in two bytes")));
285 +
        assert_eq!(decode(&[0x1b, 0, 0, 0, 0, 0, 0, 0, 5]), Err(CborError::NonCanonical("argument fits in four bytes")));
286 +
    }
287 +
288 +
    #[test]
289 +
    fn rejects_unsupported_items() {
290 +
        assert!(matches!(decode(&[0x20]), Err(CborError::Unsupported(_)))); // -1
291 +
        assert!(matches!(decode(&[0xc0, 0x00]), Err(CborError::Unsupported(_)))); // tag
292 +
        assert!(matches!(decode(&[0xf9, 0, 0]), Err(CborError::Unsupported(_)))); // float16
293 +
        assert!(matches!(decode(&[0x9f, 0xff]), Err(CborError::Unsupported(_)))); // indefinite array
294 +
        assert!(matches!(decode(&[0xf7]), Err(CborError::Unsupported(_)))); // undefined
295 +
    }
296 +
297 +
    #[test]
298 +
    fn maps_are_sorted_and_unique() {
299 +
        let v = Value::map([(2, Value::Uint(1)), (0, Value::Null), (1, Value::Bool(true))]);
300 +
        let b = rt(v);
301 +
        assert_eq!(b, [0xa3, 0x00, 0xf6, 0x01, 0xf5, 0x02, 0x01]);
302 +
        assert_eq!(decode(&[0xa2, 0x01, 0x00, 0x00, 0x00]), Err(CborError::BadMapKey));
303 +
        assert_eq!(decode(&[0xa2, 0x01, 0x00, 0x01, 0x00]), Err(CborError::BadMapKey));
304 +
        assert_eq!(decode(&[0xa1, 0x61, 0x61, 0x00]), Err(CborError::BadMapKey)); // text key
305 +
    }
306 +
307 +
    #[test]
308 +
    fn strings_arrays_and_trailing() {
309 +
        rt(Value::Bytes(vec![1, 2, 3]));
310 +
        rt(Value::Text("héllo".into()));
311 +
        rt(Value::Array(vec![Value::Uint(1), Value::Text("x".into()), Value::Array(vec![])]));
312 +
        assert_eq!(decode(&[0x00, 0x00]), Err(CborError::TrailingBytes));
313 +
        assert_eq!(decode(&[0x62, 0xff, 0xfe]), Err(CborError::InvalidUtf8));
314 +
        assert_eq!(decode(&[0x43, 1, 2]), Err(CborError::UnexpectedEnd));
315 +
    }
316 +
317 +
    #[test]
318 +
    fn huge_length_does_not_allocate() {
319 +
        assert_eq!(decode(&[0x5b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]), Err(CborError::LengthOverflow));
320 +
        assert!(decode(&[0x9b, 0x00, 0x00, 0x00, 0x01, 0, 0, 0, 0]).is_err());
321 +
    }
322 +
}
added crates/oxy-core/src/diff.rs +146 −0
1 +
//! Turn a working-copy file into edits against the live atoms of a node.
2 +
//! Anchors follow the convention in spec §7.4: an insertion at old position
3 +
//! `i` is anchored after `old[i-1]` and before `old[i]`, both of which are
4 +
//! adjacent in the author's view.
5 +
6 +
use crate::graph::AtomKey;
7 +
use crate::ids::NodeId;
8 +
use crate::objects::{AtomRange, AtomRef, Edit};
9 +
use similar::{capture_diff_slices, Algorithm, DiffOp};
10 +
11 +
/// Split into lines, each keeping its terminator; a final unterminated line
12 +
/// is its own atom.
13 +
pub fn split_lines(b: &[u8]) -> Vec<&[u8]> {
14 +
    let mut out = Vec::new();
15 +
    let mut start = 0;
16 +
    for (i, &c) in b.iter().enumerate() {
17 +
        if c == b'\n' {
18 +
            out.push(&b[start..=i]);
19 +
            start = i + 1;
20 +
        }
21 +
    }
22 +
    if start < b.len() {
23 +
        out.push(&b[start..]);
24 +
    }
25 +
    out
26 +
}
27 +
28 +
/// Coalesce atom keys into half-open ranges of consecutive indices per change.
29 +
pub fn ranges(keys: &[AtomKey]) -> Vec<AtomRange> {
30 +
    let mut out: Vec<AtomRange> = Vec::new();
31 +
    for &(c, i) in keys {
32 +
        match out.last_mut() {
33 +
            Some(r) if r.change == c && r.to == i => r.to = i + 1,
34 +
            _ => out.push(AtomRange { change: c, from: i, to: i + 1 }),
35 +
        }
36 +
    }
37 +
    out
38 +
}
39 +
40 +
/// Edits that transform the live lines `old` of `node` into `new`.
41 +
pub fn text_edits(node: NodeId, old: &[(AtomKey, Vec<u8>)], new: &[u8]) -> Vec<Edit> {
42 +
    let old_lines: Vec<&[u8]> = old.iter().map(|(_, l)| l.as_slice()).collect();
43 +
    let new_lines = split_lines(new);
44 +
    let ops = capture_diff_slices(Algorithm::Myers, &old_lines, &new_lines);
45 +
46 +
    let anchor = |i: usize| -> (AtomRef, AtomRef) {
47 +
        let after = if i == 0 { AtomRef::Start } else { AtomRef::Atom { change: old[i - 1].0 .0, index: old[i - 1].0 .1 } };
48 +
        let before = if i >= old.len() { AtomRef::End } else { AtomRef::Atom { change: old[i].0 .0, index: old[i].0 .1 } };
49 +
        (after, before)
50 +
    };
51 +
52 +
    let mut deleted: Vec<AtomKey> = Vec::new();
53 +
    let mut inserts: Vec<Edit> = Vec::new();
54 +
    for op in ops {
55 +
        match op {
56 +
            DiffOp::Equal { .. } => {}
57 +
            DiffOp::Delete { old_index, old_len, .. } => {
58 +
                deleted.extend(old[old_index..old_index + old_len].iter().map(|(k, _)| *k));
59 +
            }
60 +
            DiffOp::Insert { old_index, new_index, new_len } => {
61 +
                let (after, before) = anchor(old_index);
62 +
                inserts.push(Edit::Insert {
63 +
                    node,
64 +
                    after,
65 +
                    before,
66 +
                    lines: new_lines[new_index..new_index + new_len].iter().map(|l| l.to_vec()).collect(),
67 +
                });
68 +
            }
69 +
            DiffOp::Replace { old_index, old_len, new_index, new_len } => {
70 +
                deleted.extend(old[old_index..old_index + old_len].iter().map(|(k, _)| *k));
71 +
                let (after, before) = anchor(old_index);
72 +
                inserts.push(Edit::Insert {
73 +
                    node,
74 +
                    after,
75 +
                    before,
76 +
                    lines: new_lines[new_index..new_index + new_len].iter().map(|l| l.to_vec()).collect(),
77 +
                });
78 +
            }
79 +
        }
80 +
    }
81 +
    let mut edits = Vec::new();
82 +
    if !deleted.is_empty() {
83 +
        edits.push(Edit::Delete { node, atoms: ranges(&deleted) });
84 +
    }
85 +
    edits.extend(inserts);
86 +
    edits
87 +
}
88 +
89 +
#[cfg(test)]
90 +
mod tests {
91 +
    use super::*;
92 +
    use crate::hash::ObjectRef;
93 +
94 +
    fn h(n: u8) -> ObjectRef {
95 +
        ObjectRef([n; 32])
96 +
    }
97 +
98 +
    #[test]
99 +
    fn split_keeps_terminators() {
100 +
        assert_eq!(split_lines(b"a\nb\n"), vec![&b"a\n"[..], &b"b\n"[..]]);
101 +
        assert_eq!(split_lines(b"a\nb"), vec![&b"a\n"[..], &b"b"[..]]);
102 +
        assert_eq!(split_lines(b""), Vec::<&[u8]>::new());
103 +
        assert_eq!(split_lines(b"\n\n"), vec![&b"\n"[..], &b"\n"[..]]);
104 +
    }
105 +
106 +
    #[test]
107 +
    fn ranges_coalesce() {
108 +
        let r = ranges(&[(h(1), 0), (h(1), 1), (h(1), 3), (h(2), 0)]);
109 +
        assert_eq!(
110 +
            r,
111 +
            vec![
112 +
                AtomRange { change: h(1), from: 0, to: 2 },
113 +
                AtomRange { change: h(1), from: 3, to: 4 },
114 +
                AtomRange { change: h(2), from: 0, to: 1 }
115 +
            ]
116 +
        );
117 +
    }
118 +
119 +
    #[test]
120 +
    fn edits_for_typical_changes() {
121 +
        let node = NodeId::random();
122 +
        let old: Vec<(AtomKey, Vec<u8>)> = ["a\n", "b\n", "c\n"].iter().enumerate().map(|(i, l)| ((h(1), i as u32), l.as_bytes().to_vec())).collect();
123 +
        // No change.
124 +
        assert!(text_edits(node, &old, b"a\nb\nc\n").is_empty());
125 +
        // Replace middle line.
126 +
        let e = text_edits(node, &old, b"a\nB\nc\n");
127 +
        assert_eq!(
128 +
            e,
129 +
            vec![
130 +
                Edit::Delete { node, atoms: vec![AtomRange { change: h(1), from: 1, to: 2 }] },
131 +
                Edit::Insert { node, after: AtomRef::Atom { change: h(1), index: 0 }, before: AtomRef::Atom { change: h(1), index: 1 }, lines: vec![b"B\n".to_vec()] },
132 +
            ]
133 +
        );
134 +
        // Prepend and append.
135 +
        let e = text_edits(node, &old, b"top\na\nb\nc\nend\n");
136 +
        assert_eq!(e.len(), 2);
137 +
        assert!(matches!(&e[0], Edit::Insert { after: AtomRef::Start, before: AtomRef::Atom { index: 0, .. }, .. }));
138 +
        assert!(matches!(&e[1], Edit::Insert { after: AtomRef::Atom { index: 2, .. }, before: AtomRef::End, .. }));
139 +
        // Delete everything.
140 +
        let e = text_edits(node, &old, b"");
141 +
        assert_eq!(e, vec![Edit::Delete { node, atoms: vec![AtomRange { change: h(1), from: 0, to: 3 }] }]);
142 +
        // From empty.
143 +
        let e = text_edits(node, &[], b"x\n");
144 +
        assert_eq!(e, vec![Edit::Insert { node, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"x\n".to_vec()] }]);
145 +
    }
146 +
}
added crates/oxy-core/src/graph.rs +587 −0
1 +
//! The per-file atom graph (spec §7.3), its materialization, and text conflict
2 +
//! detection (spec §9.1, §9.5).
3 +
//!
4 +
//! The graph is a pure function of the set of changes applied to it: inserts
5 +
//! add vertices and edges, deletes set a flag, and nothing is ever removed.
6 +
7 +
use crate::hash::ChangeHash;
8 +
use crate::objects::AtomRange;
9 +
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
10 +
11 +
/// `(introducing change, index within that change for this node)`.
12 +
pub type AtomKey = (ChangeHash, u32);
13 +
14 +
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Debug)]
15 +
pub enum Vertex {
16 +
    Start,
17 +
    Atom(AtomKey),
18 +
    End,
19 +
}
20 +
21 +
/// Transitive dependency test between changes; `depends_on(a, a)` is true.
22 +
pub trait DepOracle {
23 +
    fn depends_on(&self, a: &ChangeHash, b: &ChangeHash) -> bool;
24 +
}
25 +
26 +
/// No change depends on any other: every pair commutes.
27 +
pub struct NoDeps;
28 +
impl DepOracle for NoDeps {
29 +
    fn depends_on(&self, a: &ChangeHash, b: &ChangeHash) -> bool {
30 +
        a == b
31 +
    }
32 +
}
33 +
34 +
#[derive(Default, Clone, Debug)]
35 +
struct AtomInfo {
36 +
    /// `None` until the introducing change has been applied.
37 +
    content: Option<Vec<u8>>,
38 +
    deleted_by: BTreeSet<ChangeHash>,
39 +
}
40 +
41 +
#[derive(Clone, Debug)]
42 +
pub struct FileGraph {
43 +
    atoms: HashMap<AtomKey, AtomInfo>,
44 +
    succ: HashMap<Vertex, BTreeSet<Vertex>>,
45 +
    pred: HashMap<Vertex, BTreeSet<Vertex>>,
46 +
}
47 +
48 +
impl Default for FileGraph {
49 +
    fn default() -> Self {
50 +
        Self::new()
51 +
    }
52 +
}
53 +
54 +
/// One piece of a materialized file.
55 +
#[derive(Clone, Debug, PartialEq, Eq)]
56 +
pub enum Segment {
57 +
    Lines(Vec<AtomKey>),
58 +
    /// Order conflict: alternatives that cannot be ordered against each other.
59 +
    Conflict(Vec<Vec<AtomKey>>),
60 +
    /// Delete/insert conflict: `inserted` was anchored to `deleted` by a change
61 +
    /// that commutes with the deletion.
62 +
    Zombie { deleted: Vec<AtomKey>, inserted: Vec<AtomKey> },
63 +
}
64 +
65 +
#[derive(Clone, Debug, PartialEq, Eq, Default)]
66 +
pub struct Materialized {
67 +
    pub segments: Vec<Segment>,
68 +
}
69 +
70 +
impl Materialized {
71 +
    pub fn is_conflicted(&self) -> bool {
72 +
        self.segments.iter().any(|s| !matches!(s, Segment::Lines(_)))
73 +
    }
74 +
    pub fn conflict_count(&self) -> usize {
75 +
        self.segments.iter().filter(|s| !matches!(s, Segment::Lines(_))).count()
76 +
    }
77 +
    /// Every live atom in materialized order, conflict sides concatenated.
78 +
    pub fn live_atoms(&self) -> Vec<AtomKey> {
79 +
        let mut out = Vec::new();
80 +
        for s in &self.segments {
81 +
            match s {
82 +
                Segment::Lines(a) => out.extend(a),
83 +
                Segment::Conflict(sides) => sides.iter().for_each(|x| out.extend(x)),
84 +
                Segment::Zombie { inserted, .. } => out.extend(inserted),
85 +
            }
86 +
        }
87 +
        out
88 +
    }
89 +
}
90 +
91 +
impl FileGraph {
92 +
    pub fn new() -> Self {
93 +
        let mut g = FileGraph { atoms: HashMap::new(), succ: HashMap::new(), pred: HashMap::new() };
94 +
        g.edge(Vertex::Start, Vertex::End);
95 +
        g
96 +
    }
97 +
98 +
    fn edge(&mut self, a: Vertex, b: Vertex) {
99 +
        self.succ.entry(a).or_default().insert(b);
100 +
        self.pred.entry(b).or_default().insert(a);
101 +
        self.succ.entry(b).or_default();
102 +
        self.pred.entry(a).or_default();
103 +
    }
104 +
105 +
    /// Apply an `Insert` by `change` whose first new atom has index `first_index`.
106 +
    pub fn insert(&mut self, change: ChangeHash, first_index: u32, after: Vertex, before: Vertex, lines: &[Vec<u8>]) {
107 +
        let mut prev = after;
108 +
        for (i, line) in lines.iter().enumerate() {
109 +
            let key = (change, first_index + i as u32);
110 +
            self.atoms.entry(key).or_default().content = Some(line.clone());
111 +
            let v = Vertex::Atom(key);
112 +
            self.edge(prev, v);
113 +
            prev = v;
114 +
        }
115 +
        self.edge(prev, before);
116 +
    }
117 +
118 +
    /// Apply a `Delete` of `range` by change `by`.
119 +
    pub fn delete(&mut self, by: ChangeHash, range: &AtomRange) {
120 +
        for i in range.from..range.to {
121 +
            self.atoms.entry((range.change, i)).or_default().deleted_by.insert(by);
122 +
        }
123 +
    }
124 +
125 +
    pub fn is_live(&self, k: &AtomKey) -> bool {
126 +
        self.atoms.get(k).is_some_and(|a| a.content.is_some() && a.deleted_by.is_empty())
127 +
    }
128 +
129 +
    pub fn content(&self, k: &AtomKey) -> Option<&[u8]> {
130 +
        self.atoms.get(k).and_then(|a| a.content.as_deref())
131 +
    }
132 +
133 +
    pub fn deleters(&self, k: &AtomKey) -> impl Iterator<Item = &ChangeHash> {
134 +
        self.atoms.get(k).into_iter().flat_map(|a| a.deleted_by.iter())
135 +
    }
136 +
137 +
    /// Number of atoms introduced by `change` in this graph (for index bookkeeping).
138 +
    pub fn atom_count(&self) -> usize {
139 +
        self.atoms.len()
140 +
    }
141 +
142 +
    fn is_ghost(&self, k: &AtomKey, info: &AtomInfo, deps: &dyn DepOracle) -> bool {
143 +
        // A deleted atom is a ghost when a live direct successor was inserted by
144 +
        // a change that no deleter depends on (spec §9.1, delete/insert).
145 +
        self.succ.get(&Vertex::Atom(*k)).is_some_and(|ss| {
146 +
            ss.iter().any(|s| match s {
147 +
                Vertex::Atom(y) if self.is_live(y) => !info.deleted_by.iter().any(|d| deps.depends_on(d, &y.0)),
148 +
                _ => false,
149 +
            })
150 +
        })
151 +
    }
152 +
153 +
    /// Successors of `v` in the graph with non-kept vertices contracted away.
154 +
    fn reduced_succ(&self, v: Vertex, kept: &HashSet<Vertex>, memo: &mut HashMap<Vertex, BTreeSet<Vertex>>) -> BTreeSet<Vertex> {
155 +
        let mut out = BTreeSet::new();
156 +
        let mut stack: Vec<Vertex> = self.succ.get(&v).map(|s| s.iter().copied().collect()).unwrap_or_default();
157 +
        let mut seen = HashSet::new();
158 +
        while let Some(s) = stack.pop() {
159 +
            if kept.contains(&s) {
160 +
                out.insert(s);
161 +
            } else if seen.insert(s) {
162 +
                if let Some(m) = memo.get(&s) {
163 +
                    out.extend(m.iter().copied());
164 +
                } else if let Some(next) = self.succ.get(&s) {
165 +
                    stack.extend(next.iter().copied());
166 +
                }
167 +
            }
168 +
        }
169 +
        out
170 +
    }
171 +
172 +
    /// Order the live atoms and locate conflicts.
173 +
    pub fn materialize(&self, deps: &dyn DepOracle) -> Materialized {
174 +
        // 1. Vertices that survive contraction: live atoms, ghosts, Start, End.
175 +
        let mut kept: HashSet<Vertex> = HashSet::new();
176 +
        let mut ghosts: HashSet<AtomKey> = HashSet::new();
177 +
        kept.insert(Vertex::Start);
178 +
        kept.insert(Vertex::End);
179 +
        for (k, info) in &self.atoms {
180 +
            if info.content.is_none() {
181 +
                continue;
182 +
            }
183 +
            if info.deleted_by.is_empty() {
184 +
                kept.insert(Vertex::Atom(*k));
185 +
            } else if self.is_ghost(k, info, deps) {
186 +
                kept.insert(Vertex::Atom(*k));
187 +
                ghosts.insert(*k);
188 +
            }
189 +
        }
190 +
191 +
        // 2. Reduced graph over kept vertices.
192 +
        let mut memo = HashMap::new();
193 +
        let mut rsucc: HashMap<Vertex, BTreeSet<Vertex>> = HashMap::new();
194 +
        let mut indeg: HashMap<Vertex, usize> = HashMap::new();
195 +
        for &u in &kept {
196 +
            indeg.entry(u).or_insert(0);
197 +
            let rs = self.reduced_succ(u, &kept, &mut memo);
198 +
            for &w in &rs {
199 +
                *indeg.entry(w).or_insert(0) += 1;
200 +
            }
201 +
            rsucc.insert(u, rs);
202 +
        }
203 +
204 +
        // 3. Kahn's algorithm with side labelling for conflict regions.
205 +
        let mut ready: BTreeSet<Vertex> = indeg.iter().filter(|(_, &d)| d == 0).map(|(v, _)| *v).collect();
206 +
        let mut side: HashMap<Vertex, u32> = HashMap::new();
207 +
        let mut joins: HashSet<Vertex> = HashSet::new();
208 +
        let mut next_side = 0u32;
209 +
        let mut plain: Vec<Vertex> = Vec::new();
210 +
        let mut conflict: Option<BTreeMap<u32, Vec<Vertex>>> = None;
211 +
        let mut segments: Vec<Segment> = Vec::new();
212 +
213 +
        let flush_plain = |plain: &mut Vec<Vertex>, segments: &mut Vec<Segment>| {
214 +
            let atoms: Vec<AtomKey> = plain.iter().filter_map(|v| if let Vertex::Atom(k) = v { Some(*k) } else { None }).collect();
215 +
            plain.clear();
216 +
            if !atoms.is_empty() {
217 +
                segments.extend(split_zombies(atoms, &ghosts));
218 +
            }
219 +
        };
220 +
        let finish_conflict = |c: BTreeMap<u32, Vec<Vertex>>, segments: &mut Vec<Segment>| {
221 +
            let sides: Vec<Vec<AtomKey>> = c
222 +
                .into_values()
223 +
                .map(|vs| {
224 +
                    vs.into_iter()
225 +
                        .filter_map(|v| match v {
226 +
                            Vertex::Atom(k) if !ghosts.contains(&k) => Some(k),
227 +
                            _ => None,
228 +
                        })
229 +
                        .collect::<Vec<_>>()
230 +
                })
231 +
                .filter(|s| !s.is_empty())
232 +
                .collect();
233 +
            match sides.len() {
234 +
                0 => {}
235 +
                1 => segments.push(Segment::Lines(sides.into_iter().next().unwrap())),
236 +
                _ => segments.push(Segment::Conflict(sides)),
237 +
            }
238 +
        };
239 +
240 +
        while !ready.is_empty() {
241 +
            if ready.len() >= 2 {
242 +
                if conflict.is_none() {
243 +
                    flush_plain(&mut plain, &mut segments);
244 +
                    conflict = Some(BTreeMap::new());
245 +
                }
246 +
                for r in &ready {
247 +
                    if !side.contains_key(r) {
248 +
                        side.insert(*r, next_side);
249 +
                        next_side += 1;
250 +
                    }
251 +
                }
252 +
            }
253 +
            let v = ready.pop_first().unwrap();
254 +
            let my_side = side.get(&v).copied();
255 +
            match (&mut conflict, my_side) {
256 +
                (Some(c), Some(s)) => c.entry(s).or_default().push(v),
257 +
                (Some(_), None) => {
258 +
                    let c = conflict.take().unwrap();
259 +
                    finish_conflict(c, &mut segments);
260 +
                    plain.push(v);
261 +
                }
262 +
                (None, _) => plain.push(v),
263 +
            }
264 +
            for &w in &rsucc[&v] {
265 +
                let d = indeg.get_mut(&w).unwrap();
266 +
                *d -= 1;
267 +
                if conflict.is_some() && !joins.contains(&w) {
268 +
                    match (my_side, side.get(&w).copied()) {
269 +
                        (Some(s), None) => {
270 +
                            side.insert(w, s);
271 +
                        }
272 +
                        (Some(s), Some(t)) if s != t => {
273 +
                            side.remove(&w);
274 +
                            joins.insert(w);
275 +
                        }
276 +
                        (None, Some(_)) => {
277 +
                            side.remove(&w);
278 +
                            joins.insert(w);
279 +
                        }
280 +
                        _ => {}
281 +
                    }
282 +
                }
283 +
                if *d == 0 {
284 +
                    ready.insert(w);
285 +
                }
286 +
            }
287 +
        }
288 +
        if let Some(c) = conflict.take() {
289 +
            finish_conflict(c, &mut segments);
290 +
        }
291 +
        flush_plain(&mut plain, &mut segments);
292 +
        Materialized { segments }
293 +
    }
294 +
295 +
    /// Render a materialization to bytes with conflict markers (spec §9.5).
296 +
    /// `label` gives the short description of a change for marker headers.
297 +
    pub fn render(&self, m: &Materialized, label: &dyn Fn(&ChangeHash) -> String) -> Vec<u8> {
298 +
        let total = m.conflict_count();
299 +
        let mut n = 0;
300 +
        let mut out: Vec<u8> = Vec::new();
301 +
        let push_atoms = |out: &mut Vec<u8>, atoms: &[AtomKey]| {
302 +
            for a in atoms {
303 +
                if let Some(c) = self.content(a) {
304 +
                    out.extend_from_slice(c);
305 +
                }
306 +
            }
307 +
        };
308 +
        let newline_if_needed = |out: &mut Vec<u8>| {
309 +
            if !out.is_empty() && *out.last().unwrap() != b'\n' {
310 +
                out.push(b'\n');
311 +
            }
312 +
        };
313 +
        for seg in &m.segments {
314 +
            match seg {
315 +
                Segment::Lines(a) => push_atoms(&mut out, a),
316 +
                Segment::Conflict(sides) => {
317 +
                    n += 1;
318 +
                    let k = sides.len();
319 +
                    for (i, s) in sides.iter().enumerate() {
320 +
                        newline_if_needed(&mut out);
321 +
                        let who = s.first().map(|a| label(&a.0)).unwrap_or_default();
322 +
                        let marker = if i == 0 { "<<<<<<<" } else { "=======" };
323 +
                        out.extend_from_slice(
324 +
                            format!("{marker} oxy conflict {n} of {total}, side {} of {k}: change {who}\n", i + 1).as_bytes(),
325 +
                        );
326 +
                        push_atoms(&mut out, s);
327 +
                    }
328 +
                    newline_if_needed(&mut out);
329 +
                    out.extend_from_slice(format!(">>>>>>> oxy conflict {n} of {total} end\n").as_bytes());
330 +
                }
331 +
                Segment::Zombie { deleted, inserted } => {
332 +
                    n += 1;
333 +
                    newline_if_needed(&mut out);
334 +
                    let deleter = deleted
335 +
                        .first()
336 +
                        .and_then(|a| self.deleters(a).next().copied())
337 +
                        .map(|d| label(&d))
338 +
                        .unwrap_or_default();
339 +
                    let inserter = inserted.first().map(|a| label(&a.0)).unwrap_or_default();
340 +
                    out.extend_from_slice(
341 +
                        format!("<<<<<<< oxy conflict {n} of {total}, side 1 of 2: deleted by change {deleter}\n-------\n").as_bytes(),
342 +
                    );
343 +
                    push_atoms(&mut out, deleted);
344 +
                    newline_if_needed(&mut out);
345 +
                    out.extend_from_slice(format!("======= oxy conflict {n} of {total}, side 2 of 2: change {inserter}\n").as_bytes());
346 +
                    push_atoms(&mut out, inserted);
347 +
                    newline_if_needed(&mut out);
348 +
                    out.extend_from_slice(format!(">>>>>>> oxy conflict {n} of {total} end\n").as_bytes());
349 +
                }
350 +
            }
351 +
        }
352 +
        out
353 +
    }
354 +
}
355 +
356 +
/// Split a plain run into `Lines` and `Zombie` segments around ghost atoms.
357 +
fn split_zombies(atoms: Vec<AtomKey>, ghosts: &HashSet<AtomKey>) -> Vec<Segment> {
358 +
    let mut out = Vec::new();
359 +
    let mut lines: Vec<AtomKey> = Vec::new();
360 +
    let mut i = 0;
361 +
    while i < atoms.len() {
362 +
        if !ghosts.contains(&atoms[i]) {
363 +
            lines.push(atoms[i]);
364 +
            i += 1;
365 +
            continue;
366 +
        }
367 +
        if !lines.is_empty() {
368 +
            out.push(Segment::Lines(std::mem::take(&mut lines)));
369 +
        }
370 +
        let mut deleted = Vec::new();
371 +
        while i < atoms.len() && ghosts.contains(&atoms[i]) {
372 +
            deleted.push(atoms[i]);
373 +
            i += 1;
374 +
        }
375 +
        let mut inserted = Vec::new();
376 +
        if i < atoms.len() {
377 +
            let ch = atoms[i].0;
378 +
            while i < atoms.len() && atoms[i].0 == ch && !ghosts.contains(&atoms[i]) {
379 +
                inserted.push(atoms[i]);
380 +
                i += 1;
381 +
            }
382 +
        }
383 +
        out.push(Segment::Zombie { deleted, inserted });
384 +
    }
385 +
    if !lines.is_empty() {
386 +
        out.push(Segment::Lines(lines));
387 +
    }
388 +
    out
389 +
}
390 +
391 +
#[cfg(test)]
392 +
mod tests {
393 +
    use super::*;
394 +
    use crate::hash::ObjectRef;
395 +
396 +
    fn h(n: u8) -> ChangeHash {
397 +
        ObjectRef([n; 32])
398 +
    }
399 +
    fn lines(v: &[&str]) -> Vec<Vec<u8>> {
400 +
        v.iter().map(|s| s.as_bytes().to_vec()).collect()
401 +
    }
402 +
    fn text(g: &FileGraph, m: &Materialized) -> String {
403 +
        String::from_utf8(g.render(m, &|c| c.short(4))).unwrap()
404 +
    }
405 +
406 +
    struct Deps(HashMap<ChangeHash, Vec<ChangeHash>>);
407 +
    impl DepOracle for Deps {
408 +
        fn depends_on(&self, a: &ChangeHash, b: &ChangeHash) -> bool {
409 +
            if a == b {
410 +
                return true;
411 +
            }
412 +
            let mut stack = vec![*a];
413 +
            let mut seen = HashSet::new();
414 +
            while let Some(x) = stack.pop() {
415 +
                if let Some(ds) = self.0.get(&x) {
416 +
                    for d in ds {
417 +
                        if d == b {
418 +
                            return true;
419 +
                        }
420 +
                        if seen.insert(*d) {
421 +
                            stack.push(*d);
422 +
                        }
423 +
                    }
424 +
                }
425 +
            }
426 +
            false
427 +
        }
428 +
    }
429 +
430 +
    #[test]
431 +
    fn sequential_inserts_and_deletes() {
432 +
        let mut g = FileGraph::new();
433 +
        g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["a\n", "b\n", "c\n"]));
434 +
        let m = g.materialize(&NoDeps);
435 +
        assert!(!m.is_conflicted());
436 +
        assert_eq!(text(&g, &m), "a\nb\nc\n");
437 +
438 +
        // Change 2 depends on 1, inserts between b and c and deletes a.
439 +
        g.insert(h(2), 0, Vertex::Atom((h(1), 1)), Vertex::Atom((h(1), 2)), &lines(&["x\n"]));
440 +
        g.delete(h(2), &AtomRange { change: h(1), from: 0, to: 1 });
441 +
        let deps = Deps([(h(2), vec![h(1)])].into_iter().collect());
442 +
        let m = g.materialize(&deps);
443 +
        assert!(!m.is_conflicted());
444 +
        assert_eq!(text(&g, &m), "b\nx\nc\n");
445 +
        assert_eq!(m.live_atoms(), vec![(h(1), 1), (h(2), 0), (h(1), 2)]);
446 +
    }
447 +
448 +
    #[test]
449 +
    fn concurrent_inserts_at_same_anchor_conflict() {
450 +
        let mut g = FileGraph::new();
451 +
        g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["a\n", "b\n"]));
452 +
        g.insert(h(2), 0, Vertex::Atom((h(1), 0)), Vertex::Atom((h(1), 1)), &lines(&["from-2\n"]));
453 +
        g.insert(h(3), 0, Vertex::Atom((h(1), 0)), Vertex::Atom((h(1), 1)), &lines(&["from-3\n", "more-3\n"]));
454 +
        let deps = Deps([(h(2), vec![h(1)]), (h(3), vec![h(1)])].into_iter().collect());
455 +
        let m = g.materialize(&deps);
456 +
        assert!(m.is_conflicted());
457 +
        assert_eq!(m.conflict_count(), 1);
458 +
        assert_eq!(
459 +
            m.segments,
460 +
            vec![
461 +
                Segment::Lines(vec![(h(1), 0)]),
462 +
                Segment::Conflict(vec![vec![(h(2), 0)], vec![(h(3), 0), (h(3), 1)]]),
463 +
                Segment::Lines(vec![(h(1), 1)]),
464 +
            ]
465 +
        );
466 +
        let t = text(&g, &m);
467 +
        assert!(t.starts_with("a\n<<<<<<< oxy conflict 1 of 1, side 1 of 2: change 0202\nfrom-2\n======= oxy conflict 1 of 1, side 2 of 2: change 0303\nfrom-3\nmore-3\n>>>>>>> oxy conflict 1 of 1 end\nb\n"), "{t}");
468 +
    }
469 +
470 +
    #[test]
471 +
    fn concurrent_inserts_at_different_anchors_do_not_conflict() {
472 +
        let mut g = FileGraph::new();
473 +
        g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["a\n", "b\n", "c\n"]));
474 +
        g.insert(h(2), 0, Vertex::Start, Vertex::Atom((h(1), 0)), &lines(&["top\n"]));
475 +
        g.insert(h(3), 0, Vertex::Atom((h(1), 2)), Vertex::End, &lines(&["bottom\n"]));
476 +
        let deps = Deps([(h(2), vec![h(1)]), (h(3), vec![h(1)])].into_iter().collect());
477 +
        let m = g.materialize(&deps);
478 +
        assert!(!m.is_conflicted());
479 +
        assert_eq!(text(&g, &m), "top\na\nb\nc\nbottom\n");
480 +
    }
481 +
482 +
    #[test]
483 +
    fn concurrent_deletes_of_the_same_line_agree() {
484 +
        let mut g = FileGraph::new();
485 +
        g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["a\n", "b\n"]));
486 +
        g.delete(h(2), &AtomRange { change: h(1), from: 0, to: 1 });
487 +
        g.delete(h(3), &AtomRange { change: h(1), from: 0, to: 1 });
488 +
        let deps = Deps([(h(2), vec![h(1)]), (h(3), vec![h(1)])].into_iter().collect());
489 +
        let m = g.materialize(&deps);
490 +
        assert!(!m.is_conflicted());
491 +
        assert_eq!(text(&g, &m), "b\n");
492 +
    }
493 +
494 +
    #[test]
495 +
    fn delete_insert_conflict_is_a_zombie() {
496 +
        let mut g = FileGraph::new();
497 +
        g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["a\n", "b\n"]));
498 +
        // 2 deletes a; 3 (concurrent) inserts after a.
499 +
        g.delete(h(2), &AtomRange { change: h(1), from: 0, to: 1 });
500 +
        g.insert(h(3), 0, Vertex::Atom((h(1), 0)), Vertex::Atom((h(1), 1)), &lines(&["after-a\n"]));
501 +
        let deps = Deps([(h(2), vec![h(1)]), (h(3), vec![h(1)])].into_iter().collect());
502 +
        let m = g.materialize(&deps);
503 +
        assert!(m.is_conflicted());
504 +
        assert_eq!(
505 +
            m.segments,
506 +
            vec![
507 +
                Segment::Zombie { deleted: vec![(h(1), 0)], inserted: vec![(h(3), 0)] },
508 +
                Segment::Lines(vec![(h(1), 1)]),
509 +
            ]
510 +
        );
511 +
        let t = text(&g, &m);
512 +
        assert!(t.contains("-------\na\n======="), "{t}");
513 +
        assert!(t.contains("after-a\n>>>>>>>"), "{t}");
514 +
515 +
        // If the deleter saw the insert (depends on 3), there is no conflict.
516 +
        let deps = Deps([(h(2), vec![h(1), h(3)]), (h(3), vec![h(1)])].into_iter().collect());
517 +
        let m = g.materialize(&deps);
518 +
        assert!(!m.is_conflicted());
519 +
        assert_eq!(text(&g, &m), "after-a\nb\n");
520 +
    }
521 +
522 +
    #[test]
523 +
    fn resolution_collapses_a_conflict() {
524 +
        let mut g = FileGraph::new();
525 +
        g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["a\n"]));
526 +
        g.insert(h(2), 0, Vertex::Start, Vertex::Atom((h(1), 0)), &lines(&["x\n"]));
527 +
        g.insert(h(3), 0, Vertex::Start, Vertex::Atom((h(1), 0)), &lines(&["y\n"]));
528 +
        let mut deps: HashMap<_, _> = [(h(2), vec![h(1)]), (h(3), vec![h(1)])].into_iter().collect();
529 +
        assert!(g.materialize(&Deps(deps.clone())).is_conflicted());
530 +
        // Resolution keeps y, drops x.
531 +
        g.delete(h(4), &AtomRange { change: h(2), from: 0, to: 1 });
532 +
        deps.insert(h(4), vec![h(2), h(3)]);
533 +
        let m = g.materialize(&Deps(deps));
534 +
        assert!(!m.is_conflicted());
535 +
        assert_eq!(text(&g, &m), "y\na\n");
536 +
    }
537 +
538 +
    #[test]
539 +
    fn empty_and_unterminated_files() {
540 +
        let g = FileGraph::new();
541 +
        let m = g.materialize(&NoDeps);
542 +
        assert_eq!(m.segments, vec![]);
543 +
        assert_eq!(text(&g, &m), "");
544 +
        let mut g = FileGraph::new();
545 +
        g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["no newline"]));
546 +
        assert_eq!(text(&g, &g.materialize(&NoDeps)), "no newline");
547 +
    }
548 +
549 +
    /// Spec §7.3: the graph is a function of the set of changes, not their order.
550 +
    #[test]
551 +
    fn application_order_is_irrelevant() {
552 +
        use rand::seq::SliceRandom;
553 +
        type Op = (ChangeHash, Vec<(u32, Vertex, Vertex, Vec<Vec<u8>>)>, Vec<AtomRange>);
554 +
        let ops: Vec<Op> = vec![
555 +
            (h(1), vec![(0, Vertex::Start, Vertex::End, lines(&["a\n", "b\n", "c\n"]))], vec![]),
556 +
            (h(2), vec![(0, Vertex::Atom((h(1), 0)), Vertex::Atom((h(1), 1)), lines(&["p\n"]))], vec![]),
557 +
            (h(3), vec![(0, Vertex::Atom((h(1), 0)), Vertex::Atom((h(1), 1)), lines(&["q\n", "r\n"]))], vec![AtomRange { change: h(1), from: 2, to: 3 }]),
558 +
            (h(4), vec![(0, Vertex::Atom((h(3), 1)), Vertex::Atom((h(1), 1)), lines(&["s\n"]))], vec![AtomRange { change: h(1), from: 1, to: 2 }]),
559 +
            (h(5), vec![(0, Vertex::Atom((h(1), 2)), Vertex::End, lines(&["z\n"]))], vec![]),
560 +
        ];
561 +
        let deps = Deps(
562 +
            [(h(2), vec![h(1)]), (h(3), vec![h(1)]), (h(4), vec![h(3), h(1)]), (h(5), vec![h(1)])].into_iter().collect(),
563 +
        );
564 +
        let build = |order: &[usize]| {
565 +
            let mut g = FileGraph::new();
566 +
            for &i in order {
567 +
                let (c, ins, dels) = &ops[i];
568 +
                for (idx, a, b, l) in ins {
569 +
                    g.insert(*c, *idx, *a, *b, l);
570 +
                }
571 +
                for d in dels {
572 +
                    g.delete(*c, d);
573 +
                }
574 +
            }
575 +
            let m = g.materialize(&deps);
576 +
            (m.clone(), text(&g, &m))
577 +
        };
578 +
        let reference = build(&[0, 1, 2, 3, 4]);
579 +
        assert!(reference.0.is_conflicted());
580 +
        let mut rng = rand::thread_rng();
581 +
        let mut order: Vec<usize> = (0..ops.len()).collect();
582 +
        for _ in 0..50 {
583 +
            order.shuffle(&mut rng);
584 +
            assert_eq!(build(&order), reference, "order {order:?}");
585 +
        }
586 +
    }
587 +
}
added crates/oxy-core/src/hash.rs +150 −0
1 +
//! Object references: BLAKE3-256 over a domain-separating header and the body
2 +
//! (spec §5.2).
3 +
4 +
use std::fmt;
5 +
6 +
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
7 +
pub struct ObjectRef(pub [u8; 32]);
8 +
9 +
/// A change is referenced by the ObjectRef of its encoding.
10 +
pub type ChangeHash = ObjectRef;
11 +
/// An identity is referenced by the ObjectRef of its encoding.
12 +
pub type KeyId = ObjectRef;
13 +
14 +
impl ObjectRef {
15 +
    /// Reserved: means "this change" inside a change (spec §5.2, §7.4).
16 +
    pub const ZERO: ObjectRef = ObjectRef([0; 32]);
17 +
18 +
    pub fn is_zero(&self) -> bool {
19 +
        *self == Self::ZERO
20 +
    }
21 +
    pub fn to_hex(&self) -> String {
22 +
        hex::encode(self.0)
23 +
    }
24 +
    pub fn from_hex(s: &str) -> Option<ObjectRef> {
25 +
        let v = hex::decode(s).ok()?;
26 +
        let a: [u8; 32] = v.try_into().ok()?;
27 +
        Some(ObjectRef(a))
28 +
    }
29 +
    pub fn from_slice(b: &[u8]) -> Option<ObjectRef> {
30 +
        let a: [u8; 32] = b.try_into().ok()?;
31 +
        Some(ObjectRef(a))
32 +
    }
33 +
    /// First `n` hex characters.
34 +
    pub fn short(&self, n: usize) -> String {
35 +
        let h = self.to_hex();
36 +
        h[..n.min(h.len())].to_string()
37 +
    }
38 +
    pub fn starts_with_hex(&self, prefix: &str) -> bool {
39 +
        self.to_hex().starts_with(prefix)
40 +
    }
41 +
}
42 +
43 +
impl fmt::Display for ObjectRef {
44 +
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 +
        f.write_str(&self.to_hex())
46 +
    }
47 +
}
48 +
49 +
impl fmt::Debug for ObjectRef {
50 +
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51 +
        write!(f, "#{}", self.short(12))
52 +
    }
53 +
}
54 +
55 +
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
56 +
pub enum ObjectType {
57 +
    Chunk,
58 +
    Blob,
59 +
    Change,
60 +
    Checkpoint,
61 +
    Op,
62 +
    Attest,
63 +
    Identity,
64 +
    Policy,
65 +
}
66 +
67 +
impl ObjectType {
68 +
    pub const ALL: [ObjectType; 8] = [
69 +
        ObjectType::Chunk,
70 +
        ObjectType::Blob,
71 +
        ObjectType::Change,
72 +
        ObjectType::Checkpoint,
73 +
        ObjectType::Op,
74 +
        ObjectType::Attest,
75 +
        ObjectType::Identity,
76 +
        ObjectType::Policy,
77 +
    ];
78 +
79 +
    pub fn name(self) -> &'static str {
80 +
        match self {
81 +
            ObjectType::Chunk => "chunk",
82 +
            ObjectType::Blob => "blob",
83 +
            ObjectType::Change => "change",
84 +
            ObjectType::Checkpoint => "checkpoint",
85 +
            ObjectType::Op => "op",
86 +
            ObjectType::Attest => "attest",
87 +
            ObjectType::Identity => "identity",
88 +
            ObjectType::Policy => "policy",
89 +
        }
90 +
    }
91 +
92 +
    pub fn from_name(s: &str) -> Option<ObjectType> {
93 +
        Self::ALL.iter().copied().find(|t| t.name() == s)
94 +
    }
95 +
}
96 +
97 +
/// `ObjectRef = BLAKE3-256("oxy1 " || type || " " || decimal(len) || 0x00 || body)`.
98 +
pub fn hash_object(ty: ObjectType, body: &[u8]) -> ObjectRef {
99 +
    let mut h = blake3::Hasher::new();
100 +
    h.update(&header(ty, body.len()));
101 +
    h.update(body);
102 +
    ObjectRef(*h.finalize().as_bytes())
103 +
}
104 +
105 +
pub fn header(ty: ObjectType, len: usize) -> Vec<u8> {
106 +
    let mut v = Vec::with_capacity(32);
107 +
    v.extend_from_slice(b"oxy1 ");
108 +
    v.extend_from_slice(ty.name().as_bytes());
109 +
    v.push(b' ');
110 +
    v.extend_from_slice(len.to_string().as_bytes());
111 +
    v.push(0);
112 +
    v
113 +
}
114 +
115 +
#[cfg(test)]
116 +
mod tests {
117 +
    use super::*;
118 +
119 +
    #[test]
120 +
    fn header_matches_spec() {
121 +
        assert_eq!(header(ObjectType::Chunk, 5), b"oxy1 chunk 5\0");
122 +
        assert_eq!(header(ObjectType::Change, 1234), b"oxy1 change 1234\0");
123 +
    }
124 +
125 +
    #[test]
126 +
    fn types_are_domain_separated() {
127 +
        let body = b"same bytes";
128 +
        let a = hash_object(ObjectType::Chunk, body);
129 +
        let b = hash_object(ObjectType::Blob, body);
130 +
        assert_ne!(a, b);
131 +
        assert_eq!(a, hash_object(ObjectType::Chunk, body));
132 +
        // Reference vector: pin so the format cannot drift silently.
133 +
        assert_eq!(
134 +
            hash_object(ObjectType::Chunk, b"").to_hex(),
135 +
            blake3::hash(b"oxy1 chunk 0\0").to_hex().as_str()
136 +
        );
137 +
    }
138 +
139 +
    #[test]
140 +
    fn hex_round_trip() {
141 +
        let r = hash_object(ObjectType::Chunk, b"x");
142 +
        assert_eq!(ObjectRef::from_hex(&r.to_hex()), Some(r));
143 +
        assert_eq!(ObjectRef::from_hex("zz"), None);
144 +
        assert!(r.starts_with_hex(&r.short(7)));
145 +
        assert!(ObjectRef::ZERO.is_zero());
146 +
        for t in ObjectType::ALL {
147 +
            assert_eq!(ObjectType::from_name(t.name()), Some(t));
148 +
        }
149 +
    }
150 +
}
added crates/oxy-core/src/ids.rs +93 −0
1 +
//! Random 128-bit identifiers: ChangeId, NodeId, WorkspaceId (spec §6.3, §7.1, §12.2).
2 +
3 +
use rand::RngCore;
4 +
use std::fmt;
5 +
6 +
macro_rules! id128 {
7 +
    ($name:ident, $doc:literal) => {
8 +
        #[doc = $doc]
9 +
        #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
10 +
        pub struct $name(pub [u8; 16]);
11 +
12 +
        impl $name {
13 +
            pub const ZERO: $name = $name([0; 16]);
14 +
15 +
            /// A fresh identifier from the OS CSPRNG.
16 +
            pub fn random() -> $name {
17 +
                let mut b = [0u8; 16];
18 +
                rand::rngs::OsRng.fill_bytes(&mut b);
19 +
                $name(b)
20 +
            }
21 +
            pub fn to_hex(&self) -> String {
22 +
                hex::encode(self.0)
23 +
            }
24 +
            pub fn from_hex(s: &str) -> Option<$name> {
25 +
                let v = hex::decode(s).ok()?;
26 +
                Some($name(v.try_into().ok()?))
27 +
            }
28 +
            pub fn from_slice(b: &[u8]) -> Option<$name> {
29 +
                Some($name(b.try_into().ok()?))
30 +
            }
31 +
            pub fn short(&self, n: usize) -> String {
32 +
                let h = self.to_hex();
33 +
                h[..n.min(h.len())].to_string()
34 +
            }
35 +
            pub fn starts_with_hex(&self, prefix: &str) -> bool {
36 +
                self.to_hex().starts_with(prefix)
37 +
            }
38 +
        }
39 +
40 +
        impl fmt::Display for $name {
41 +
            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 +
                f.write_str(&self.to_hex())
43 +
            }
44 +
        }
45 +
        impl fmt::Debug for $name {
46 +
            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47 +
                write!(f, "{}({})", stringify!($name), self.short(8))
48 +
            }
49 +
        }
50 +
    };
51 +
}
52 +
53 +
id128!(ChangeId, "Stable identity of a change across rewrites (spec §6.2).");
54 +
id128!(NodeId, "Stable identity of a file, directory, or symlink across renames (spec §7.1). `NodeId::ZERO` is the root directory.");
55 +
id128!(WorkspaceId, "Identity of a checkout directory (spec §12.2).");
56 +
57 +
impl NodeId {
58 +
    pub const ROOT: NodeId = NodeId::ZERO;
59 +
    pub fn is_root(&self) -> bool {
60 +
        self.is_zero()
61 +
    }
62 +
}
63 +
64 +
impl ChangeId {
65 +
    pub fn is_zero(&self) -> bool {
66 +
        *self == Self::ZERO
67 +
    }
68 +
}
69 +
impl NodeId {
70 +
    pub fn is_zero(&self) -> bool {
71 +
        *self == Self::ZERO
72 +
    }
73 +
}
74 +
impl WorkspaceId {
75 +
    pub fn is_zero(&self) -> bool {
76 +
        *self == Self::ZERO
77 +
    }
78 +
}
79 +
80 +
#[cfg(test)]
81 +
mod tests {
82 +
    use super::*;
83 +
84 +
    #[test]
85 +
    fn random_ids_differ_and_round_trip() {
86 +
        let a = ChangeId::random();
87 +
        let b = ChangeId::random();
88 +
        assert_ne!(a, b);
89 +
        assert_eq!(ChangeId::from_hex(&a.to_hex()), Some(a));
90 +
        assert!(NodeId::ROOT.is_root());
91 +
        assert!(!NodeId::random().is_root());
92 +
    }
93 +
}
added crates/oxy-core/src/lib.rs +15 −0
1 +
//! Oxygen core: data model, change graph, and storage.
2 +
//!
3 +
//! See `docs/spec.md` for the specification this implements.
4 +
5 +
pub mod cbor;
6 +
pub mod diff;
7 +
pub mod graph;
8 +
pub mod hash;
9 +
pub mod ids;
10 +
pub mod objects;
11 +
pub mod oplog;
12 +
pub mod repo;
13 +
pub mod state;
14 +
pub mod store;
15 +
pub mod tree;
added crates/oxy-core/src/objects.rs +657 −0
1 +
//! Typed objects and their canonical encodings (spec §6, §7, §13.2).
2 +
//!
3 +
//! Encoding conventions not fixed by the spec text are defined here:
4 +
//! - Enum-like items (`TreeOp`, `Edit`) are maps whose key 0 is the variant tag.
5 +
//! - An `OpRef`/atom reference is `[bytes32, uint]`; `AtomRef::Start` is `0`
6 +
//!   and `AtomRef::End` is `1`.
7 +
//! - A `Mode` is an unsigned integer: bit 0 = executable, bit 1 = opaque.
8 +
//! - `NodeKind`: 0 = file, 1 = dir, 2 = symlink.
9 +
10 +
use crate::cbor::{self, Value};
11 +
use crate::hash::{hash_object, ChangeHash, KeyId, ObjectRef, ObjectType};
12 +
use crate::ids::{ChangeId, NodeId};
13 +
use std::collections::BTreeMap;
14 +
15 +
pub const FORMAT_VERSION: u64 = 1;
16 +
17 +
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
18 +
pub enum ObjectError {
19 +
    #[error("cbor: {0}")]
20 +
    Cbor(#[from] cbor::CborError),
21 +
    #[error("schema: {0}")]
22 +
    Schema(String),
23 +
}
24 +
25 +
pub(crate) fn schema<T>(msg: impl Into<String>) -> Result<T, ObjectError> {
26 +
    Err(ObjectError::Schema(msg.into()))
27 +
}
28 +
29 +
pub(crate) type Map = BTreeMap<u64, Value>;
30 +
31 +
pub(crate) fn field<'a>(m: &'a Map, k: u64, what: &str) -> Result<&'a Value, ObjectError> {
32 +
    m.get(&k).ok_or_else(|| ObjectError::Schema(format!("missing field {k} ({what})")))
33 +
}
34 +
pub(crate) fn uint(v: &Value, what: &str) -> Result<u64, ObjectError> {
35 +
    v.as_uint().ok_or_else(|| ObjectError::Schema(format!("{what}: expected unsigned integer")))
36 +
}
37 +
pub(crate) fn u32_(v: &Value, what: &str) -> Result<u32, ObjectError> {
38 +
    u32::try_from(uint(v, what)?).map_err(|_| ObjectError::Schema(format!("{what}: exceeds u32")))
39 +
}
40 +
pub(crate) fn text<'a>(v: &'a Value, what: &str) -> Result<&'a str, ObjectError> {
41 +
    v.as_text().ok_or_else(|| ObjectError::Schema(format!("{what}: expected text")))
42 +
}
43 +
pub(crate) fn array<'a>(v: &'a Value, what: &str) -> Result<&'a [Value], ObjectError> {
44 +
    v.as_array().ok_or_else(|| ObjectError::Schema(format!("{what}: expected array")))
45 +
}
46 +
pub(crate) fn map<'a>(v: &'a Value, what: &str) -> Result<&'a Map, ObjectError> {
47 +
    v.as_map().ok_or_else(|| ObjectError::Schema(format!("{what}: expected map")))
48 +
}
49 +
pub(crate) fn bytes<'a>(v: &'a Value, what: &str) -> Result<&'a [u8], ObjectError> {
50 +
    v.as_bytes().ok_or_else(|| ObjectError::Schema(format!("{what}: expected bytes")))
51 +
}
52 +
pub(crate) fn oref(v: &Value, what: &str) -> Result<ObjectRef, ObjectError> {
53 +
    ObjectRef::from_slice(bytes(v, what)?).ok_or_else(|| ObjectError::Schema(format!("{what}: expected 32 bytes")))
54 +
}
55 +
pub(crate) fn node_id(v: &Value, what: &str) -> Result<NodeId, ObjectError> {
56 +
    NodeId::from_slice(bytes(v, what)?).ok_or_else(|| ObjectError::Schema(format!("{what}: expected 16 bytes")))
57 +
}
58 +
pub(crate) fn check_version(m: &Map) -> Result<(), ObjectError> {
59 +
    let v = uint(field(m, 0, "version")?, "version")?;
60 +
    if v != FORMAT_VERSION {
61 +
        return schema(format!("unsupported format version {v}"));
62 +
    }
63 +
    Ok(())
64 +
}
65 +
66 +
// ---------------------------------------------------------------- Hlc
67 +
68 +
/// Hybrid logical clock (spec §6.4).
69 +
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
70 +
pub struct Hlc {
71 +
    pub wall_ms: u64,
72 +
    pub logical: u32,
73 +
}
74 +
75 +
impl Hlc {
76 +
    pub fn now() -> Hlc {
77 +
        let wall_ms = std::time::SystemTime::now()
78 +
            .duration_since(std::time::UNIX_EPOCH)
79 +
            .map(|d| d.as_millis() as u64)
80 +
            .unwrap_or(0);
81 +
        Hlc { wall_ms, logical: 0 }
82 +
    }
83 +
    pub(crate) fn to_value(self) -> Value {
84 +
        Value::map([(0, Value::Uint(self.wall_ms)), (1, Value::Uint(self.logical as u64))])
85 +
    }
86 +
    pub(crate) fn from_value(v: &Value) -> Result<Hlc, ObjectError> {
87 +
        let m = map(v, "hlc")?;
88 +
        Ok(Hlc {
89 +
            wall_ms: uint(field(m, 0, "hlc.wall_ms")?, "hlc.wall_ms")?,
90 +
            logical: u32_(field(m, 1, "hlc.logical")?, "hlc.logical")?,
91 +
        })
92 +
    }
93 +
}
94 +
95 +
// ---------------------------------------------------------------- tree
96 +
97 +
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
98 +
pub enum NodeKind {
99 +
    File,
100 +
    Dir,
101 +
    Symlink,
102 +
}
103 +
104 +
impl NodeKind {
105 +
    fn tag(self) -> u64 {
106 +
        match self {
107 +
            NodeKind::File => 0,
108 +
            NodeKind::Dir => 1,
109 +
            NodeKind::Symlink => 2,
110 +
        }
111 +
    }
112 +
    fn from_tag(t: u64) -> Result<NodeKind, ObjectError> {
113 +
        Ok(match t {
114 +
            0 => NodeKind::File,
115 +
            1 => NodeKind::Dir,
116 +
            2 => NodeKind::Symlink,
117 +
            _ => return schema(format!("unknown node kind {t}")),
118 +
        })
119 +
    }
120 +
}
121 +
122 +
/// Executable bit plus content mode (spec §7.1, §7.5).
123 +
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, PartialOrd, Ord)]
124 +
pub struct Mode {
125 +
    pub exec: bool,
126 +
    pub opaque: bool,
127 +
}
128 +
129 +
impl Mode {
130 +
    pub const TEXT: Mode = Mode { exec: false, opaque: false };
131 +
    pub const OPAQUE: Mode = Mode { exec: false, opaque: true };
132 +
    fn bits(self) -> u64 {
133 +
        (self.exec as u64) | ((self.opaque as u64) << 1)
134 +
    }
135 +
    fn from_bits(b: u64) -> Result<Mode, ObjectError> {
136 +
        if b > 3 {
137 +
            return schema(format!("unknown mode bits {b}"));
138 +
        }
139 +
        Ok(Mode { exec: b & 1 != 0, opaque: b & 2 != 0 })
140 +
    }
141 +
}
142 +
143 +
/// Reference to a tree operation of some change: the placement or mode atom it
144 +
/// introduced. `change == ObjectRef::ZERO` means this change.
145 +
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
146 +
pub struct OpRef {
147 +
    pub change: ChangeHash,
148 +
    pub index: u32,
149 +
}
150 +
151 +
impl OpRef {
152 +
    fn to_value(self) -> Value {
153 +
        Value::Array(vec![Value::Bytes(self.change.0.to_vec()), Value::Uint(self.index as u64)])
154 +
    }
155 +
    fn from_value(v: &Value, what: &str) -> Result<OpRef, ObjectError> {
156 +
        let a = array(v, what)?;
157 +
        if a.len() != 2 {
158 +
            return schema(format!("{what}: expected [hash, index]"));
159 +
        }
160 +
        Ok(OpRef { change: oref(&a[0], what)?, index: u32_(&a[1], what)? })
161 +
    }
162 +
    /// Replace a self reference with the concrete change hash.
163 +
    pub fn resolve(self, me: ChangeHash) -> OpRef {
164 +
        if self.change.is_zero() { OpRef { change: me, ..self } } else { self }
165 +
    }
166 +
}
167 +
168 +
#[derive(Debug, Clone, PartialEq, Eq)]
169 +
pub enum TreeOp {
170 +
    AddNode { node: NodeId, kind: NodeKind, mode: Mode, parent: NodeId, name: String },
171 +
    RemoveNode { node: NodeId, placement: OpRef },
172 +
    Move { node: NodeId, old: OpRef, parent: NodeId, name: String },
173 +
    SetMode { node: NodeId, old: OpRef, mode: Mode },
174 +
}
175 +
176 +
/// Names must be valid, non-empty, not `.`/`..`, and free of `/` and NUL (spec §7.1).
177 +
pub fn validate_name(name: &str) -> Result<(), ObjectError> {
178 +
    if name.is_empty() || name == "." || name == ".." || name.contains('/') || name.contains('\0') {
179 +
        return schema(format!("invalid placement name {name:?}"));
180 +
    }
181 +
    Ok(())
182 +
}
183 +
184 +
impl TreeOp {
185 +
    pub fn node(&self) -> NodeId {
186 +
        match self {
187 +
            TreeOp::AddNode { node, .. }
188 +
            | TreeOp::RemoveNode { node, .. }
189 +
            | TreeOp::Move { node, .. }
190 +
            | TreeOp::SetMode { node, .. } => *node,
191 +
        }
192 +
    }
193 +
194 +
    fn to_value(&self) -> Value {
195 +
        let nid = |n: &NodeId| Value::Bytes(n.0.to_vec());
196 +
        match self {
197 +
            TreeOp::AddNode { node, kind, mode, parent, name } => Value::map([
198 +
                (0, Value::Uint(0)),
199 +
                (1, nid(node)),
200 +
                (2, Value::Uint(kind.tag())),
201 +
                (3, Value::Uint(mode.bits())),
202 +
                (4, nid(parent)),
203 +
                (5, Value::Text(name.clone())),
204 +
            ]),
205 +
            TreeOp::RemoveNode { node, placement } => {
206 +
                Value::map([(0, Value::Uint(1)), (1, nid(node)), (2, placement.to_value())])
207 +
            }
208 +
            TreeOp::Move { node, old, parent, name } => Value::map([
209 +
                (0, Value::Uint(2)),
210 +
                (1, nid(node)),
211 +
                (2, old.to_value()),
212 +
                (4, nid(parent)),
213 +
                (5, Value::Text(name.clone())),
214 +
            ]),
215 +
            TreeOp::SetMode { node, old, mode } => Value::map([
216 +
                (0, Value::Uint(3)),
217 +
                (1, nid(node)),
218 +
                (2, old.to_value()),
219 +
                (3, Value::Uint(mode.bits())),
220 +
            ]),
221 +
        }
222 +
    }
223 +
224 +
    fn from_value(v: &Value) -> Result<TreeOp, ObjectError> {
225 +
        let m = map(v, "tree_op")?;
226 +
        let tag = uint(field(m, 0, "tree_op tag")?, "tree_op tag")?;
227 +
        let node = node_id(field(m, 1, "tree_op.node")?, "tree_op.node")?;
228 +
        let op = match tag {
229 +
            0 => {
230 +
                let name = text(field(m, 5, "name")?, "name")?.to_owned();
231 +
                validate_name(&name)?;
232 +
                TreeOp::AddNode {
233 +
                    node,
234 +
                    kind: NodeKind::from_tag(uint(field(m, 2, "kind")?, "kind")?)?,
235 +
                    mode: Mode::from_bits(uint(field(m, 3, "mode")?, "mode")?)?,
236 +
                    parent: node_id(field(m, 4, "parent")?, "parent")?,
237 +
                    name,
238 +
                }
239 +
            }
240 +
            1 => TreeOp::RemoveNode { node, placement: OpRef::from_value(field(m, 2, "placement")?, "placement")? },
241 +
            2 => {
242 +
                let name = text(field(m, 5, "name")?, "name")?.to_owned();
243 +
                validate_name(&name)?;
244 +
                TreeOp::Move {
245 +
                    node,
246 +
                    old: OpRef::from_value(field(m, 2, "old")?, "old")?,
247 +
                    parent: node_id(field(m, 4, "parent")?, "parent")?,
248 +
                    name,
249 +
                }
250 +
            }
251 +
            3 => TreeOp::SetMode {
252 +
                node,
253 +
                old: OpRef::from_value(field(m, 2, "old")?, "old")?,
254 +
                mode: Mode::from_bits(uint(field(m, 3, "mode")?, "mode")?)?,
255 +
            },
256 +
            _ => return schema(format!("unknown tree op tag {tag}")),
257 +
        };
258 +
        if node.is_root() {
259 +
            return schema("tree op on the root node");
260 +
        }
261 +
        Ok(op)
262 +
    }
263 +
}
264 +
265 +
// ---------------------------------------------------------------- edits
266 +
267 +
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
268 +
pub enum AtomRef {
269 +
    Start,
270 +
    End,
271 +
    Atom { change: ChangeHash, index: u32 },
272 +
}
273 +
274 +
impl AtomRef {
275 +
    fn to_value(self) -> Value {
276 +
        match self {
277 +
            AtomRef::Start => Value::Uint(0),
278 +
            AtomRef::End => Value::Uint(1),
279 +
            AtomRef::Atom { change, index } => {
280 +
                Value::Array(vec![Value::Bytes(change.0.to_vec()), Value::Uint(index as u64)])
281 +
            }
282 +
        }
283 +
    }
284 +
    fn from_value(v: &Value, what: &str) -> Result<AtomRef, ObjectError> {
285 +
        match v {
286 +
            Value::Uint(0) => Ok(AtomRef::Start),
287 +
            Value::Uint(1) => Ok(AtomRef::End),
288 +
            Value::Array(a) if a.len() == 2 => {
289 +
                Ok(AtomRef::Atom { change: oref(&a[0], what)?, index: u32_(&a[1], what)? })
290 +
            }
291 +
            _ => schema(format!("{what}: expected atom reference")),
292 +
        }
293 +
    }
294 +
    pub fn resolve(self, me: ChangeHash) -> AtomRef {
295 +
        match self {
296 +
            AtomRef::Atom { change, index } if change.is_zero() => AtomRef::Atom { change: me, index },
297 +
            other => other,
298 +
        }
299 +
    }
300 +
}
301 +
302 +
/// Half-open range of atoms introduced by one change.
303 +
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
304 +
pub struct AtomRange {
305 +
    pub change: ChangeHash,
306 +
    pub from: u32,
307 +
    pub to: u32,
308 +
}
309 +
310 +
impl AtomRange {
311 +
    fn to_value(self) -> Value {
312 +
        Value::Array(vec![
313 +
            Value::Bytes(self.change.0.to_vec()),
314 +
            Value::Uint(self.from as u64),
315 +
            Value::Uint(self.to as u64),
316 +
        ])
317 +
    }
318 +
    fn from_value(v: &Value) -> Result<AtomRange, ObjectError> {
319 +
        let a = array(v, "atom range")?;
320 +
        if a.len() != 3 {
321 +
            return schema("atom range: expected [hash, from, to]");
322 +
        }
323 +
        let r = AtomRange { change: oref(&a[0], "range.change")?, from: u32_(&a[1], "range.from")?, to: u32_(&a[2], "range.to")? };
324 +
        if r.from >= r.to {
325 +
            return schema("atom range: empty or inverted");
326 +
        }
327 +
        Ok(r)
328 +
    }
329 +
    pub fn resolve(self, me: ChangeHash) -> AtomRange {
330 +
        if self.change.is_zero() { AtomRange { change: me, ..self } } else { self }
331 +
    }
332 +
}
333 +
334 +
#[derive(Debug, Clone, PartialEq, Eq)]
335 +
pub enum Edit {
336 +
    Insert { node: NodeId, after: AtomRef, before: AtomRef, lines: Vec<Vec<u8>> },
337 +
    Delete { node: NodeId, atoms: Vec<AtomRange> },
338 +
    Replace { node: NodeId, old: Option<ObjectRef>, new: Option<ObjectRef> },
339 +
}
340 +
341 +
impl Edit {
342 +
    pub fn node(&self) -> NodeId {
343 +
        match self {
344 +
            Edit::Insert { node, .. } | Edit::Delete { node, .. } | Edit::Replace { node, .. } => *node,
345 +
        }
346 +
    }
347 +
348 +
    fn to_value(&self) -> Value {
349 +
        let nid = |n: &NodeId| Value::Bytes(n.0.to_vec());
350 +
        let opt = |o: &Option<ObjectRef>| match o {
351 +
            Some(r) => Value::Bytes(r.0.to_vec()),
352 +
            None => Value::Null,
353 +
        };
354 +
        match self {
355 +
            Edit::Insert { node, after, before, lines } => Value::map([
356 +
                (0, Value::Uint(0)),
357 +
                (1, nid(node)),
358 +
                (2, after.to_value()),
359 +
                (3, before.to_value()),
360 +
                (4, Value::Array(lines.iter().map(|l| Value::Bytes(l.clone())).collect())),
361 +
            ]),
362 +
            Edit::Delete { node, atoms } => Value::map([
363 +
                (0, Value::Uint(1)),
364 +
                (1, nid(node)),
365 +
                (2, Value::Array(atoms.iter().map(|r| r.to_value()).collect())),
366 +
            ]),
367 +
            Edit::Replace { node, old, new } => {
368 +
                Value::map([(0, Value::Uint(2)), (1, nid(node)), (2, opt(old)), (3, opt(new))])
369 +
            }
370 +
        }
371 +
    }
372 +
373 +
    fn from_value(v: &Value) -> Result<Edit, ObjectError> {
374 +
        let m = map(v, "edit")?;
375 +
        let tag = uint(field(m, 0, "edit tag")?, "edit tag")?;
376 +
        let node = node_id(field(m, 1, "edit.node")?, "edit.node")?;
377 +
        let opt = |v: &Value, what: &str| -> Result<Option<ObjectRef>, ObjectError> {
378 +
            if v.is_null() { Ok(None) } else { Ok(Some(oref(v, what)?)) }
379 +
        };
380 +
        Ok(match tag {
381 +
            0 => {
382 +
                let lines = array(field(m, 4, "lines")?, "lines")?
383 +
                    .iter()
384 +
                    .map(|l| bytes(l, "line").map(|b| b.to_vec()))
385 +
                    .collect::<Result<Vec<_>, _>>()?;
386 +
                if lines.is_empty() {
387 +
                    return schema("insert with no lines");
388 +
                }
389 +
                Edit::Insert {
390 +
                    node,
391 +
                    after: AtomRef::from_value(field(m, 2, "after")?, "after")?,
392 +
                    before: AtomRef::from_value(field(m, 3, "before")?, "before")?,
393 +
                    lines,
394 +
                }
395 +
            }
396 +
            1 => {
397 +
                let atoms = array(field(m, 2, "atoms")?, "atoms")?
398 +
                    .iter()
399 +
                    .map(AtomRange::from_value)
400 +
                    .collect::<Result<Vec<_>, _>>()?;
401 +
                if atoms.is_empty() {
402 +
                    return schema("delete with no atoms");
403 +
                }
404 +
                Edit::Delete { node, atoms }
405 +
            }
406 +
            2 => Edit::Replace { node, old: opt(field(m, 2, "old")?, "old")?, new: opt(field(m, 3, "new")?, "new")? },
407 +
            _ => return schema(format!("unknown edit tag {tag}")),
408 +
        })
409 +
    }
410 +
}
411 +
412 +
// ---------------------------------------------------------------- change
413 +
414 +
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
415 +
pub enum ChangeKind {
416 +
    Normal,
417 +
    Resolution,
418 +
    WorkingCopy,
419 +
}
420 +
421 +
impl ChangeKind {
422 +
    fn tag(self) -> u64 {
423 +
        match self {
424 +
            ChangeKind::Normal => 0,
425 +
            ChangeKind::Resolution => 1,
426 +
            ChangeKind::WorkingCopy => 2,
427 +
        }
428 +
    }
429 +
    fn from_tag(t: u64) -> Result<ChangeKind, ObjectError> {
430 +
        Ok(match t {
431 +
            0 => ChangeKind::Normal,
432 +
            1 => ChangeKind::Resolution,
433 +
            2 => ChangeKind::WorkingCopy,
434 +
            _ => return schema(format!("unknown change kind {t}")),
435 +
        })
436 +
    }
437 +
}
438 +
439 +
/// A change (spec §6.1). `deps` is kept sorted and unique by `normalize`.
440 +
#[derive(Debug, Clone, PartialEq, Eq)]
441 +
pub struct Change {
442 +
    pub change_id: ChangeId,
443 +
    pub deps: Vec<ChangeHash>,
444 +
    pub author: KeyId,
445 +
    pub hlc: Hlc,
446 +
    pub message: String,
447 +
    pub tree_ops: Vec<TreeOp>,
448 +
    pub edits: Vec<Edit>,
449 +
    pub kind: ChangeKind,
450 +
}
451 +
452 +
impl Change {
453 +
    pub fn normalize(&mut self) {
454 +
        self.deps.sort();
455 +
        self.deps.dedup();
456 +
    }
457 +
458 +
    pub fn to_value(&self) -> Value {
459 +
        Value::map([
460 +
            (0, Value::Uint(FORMAT_VERSION)),
461 +
            (1, Value::Bytes(self.change_id.0.to_vec())),
462 +
            (2, Value::Array(self.deps.iter().map(|d| Value::Bytes(d.0.to_vec())).collect())),
463 +
            (3, Value::Bytes(self.author.0.to_vec())),
464 +
            (4, self.hlc.to_value()),
465 +
            (5, Value::Text(self.message.clone())),
466 +
            (6, Value::Array(self.tree_ops.iter().map(|t| t.to_value()).collect())),
467 +
            (7, Value::Array(self.edits.iter().map(|e| e.to_value()).collect())),
468 +
            (8, Value::Uint(self.kind.tag())),
469 +
            (9, Value::Map(BTreeMap::new())),
470 +
        ])
471 +
    }
472 +
473 +
    pub fn encode(&self) -> Vec<u8> {
474 +
        debug_assert!(self.deps.windows(2).all(|w| w[0] < w[1]), "deps must be normalized");
475 +
        cbor::encode(&self.to_value())
476 +
    }
477 +
478 +
    pub fn hash(&self) -> ChangeHash {
479 +
        hash_object(ObjectType::Change, &self.encode())
480 +
    }
481 +
482 +
    pub fn decode(bytes: &[u8]) -> Result<Change, ObjectError> {
483 +
        let v = cbor::decode(bytes)?;
484 +
        Self::from_value(&v)
485 +
    }
486 +
487 +
    pub fn from_value(v: &Value) -> Result<Change, ObjectError> {
488 +
        let m = map(v, "change")?;
489 +
        check_version(m)?;
490 +
        let change_id = ChangeId::from_slice(bytes(field(m, 1, "change_id")?, "change_id")?)
491 +
            .ok_or_else(|| ObjectError::Schema("change_id: expected 16 bytes".into()))?;
492 +
        let deps = array(field(m, 2, "deps")?, "deps")?
493 +
            .iter()
494 +
            .map(|d| oref(d, "dep"))
495 +
            .collect::<Result<Vec<_>, _>>()?;
496 +
        if !deps.windows(2).all(|w| w[0] < w[1]) {
497 +
            return schema("deps must be sorted and unique");
498 +
        }
499 +
        if deps.iter().any(|d| d.is_zero()) {
500 +
            return schema("deps may not contain the self reference");
501 +
        }
502 +
        let extra = map(field(m, 9, "extra")?, "extra")?;
503 +
        if !extra.is_empty() {
504 +
            return schema("extra must be empty in format version 1");
505 +
        }
506 +
        Ok(Change {
507 +
            change_id,
508 +
            deps,
509 +
            author: oref(field(m, 3, "author")?, "author")?,
510 +
            hlc: Hlc::from_value(field(m, 4, "hlc")?)?,
511 +
            message: text(field(m, 5, "message")?, "message")?.to_owned(),
512 +
            tree_ops: array(field(m, 6, "tree_ops")?, "tree_ops")?
513 +
                .iter()
514 +
                .map(TreeOp::from_value)
515 +
                .collect::<Result<Vec<_>, _>>()?,
516 +
            edits: array(field(m, 7, "edits")?, "edits")?
517 +
                .iter()
518 +
                .map(Edit::from_value)
519 +
                .collect::<Result<Vec<_>, _>>()?,
520 +
            kind: ChangeKind::from_tag(uint(field(m, 8, "kind")?, "kind")?)?,
521 +
        })
522 +
    }
523 +
}
524 +
525 +
// ---------------------------------------------------------------- blob
526 +
527 +
/// File content as an ordered list of chunks (spec §13.2).
528 +
#[derive(Debug, Clone, PartialEq, Eq)]
529 +
pub struct Blob {
530 +
    pub length: u64,
531 +
    pub chunks: Vec<ObjectRef>,
532 +
}
533 +
534 +
impl Blob {
535 +
    pub fn encode(&self) -> Vec<u8> {
536 +
        cbor::encode(&Value::map([
537 +
            (0, Value::Uint(FORMAT_VERSION)),
538 +
            (1, Value::Uint(self.length)),
539 +
            (2, Value::Array(self.chunks.iter().map(|c| Value::Bytes(c.0.to_vec())).collect())),
540 +
        ]))
541 +
    }
542 +
    pub fn hash(&self) -> ObjectRef {
543 +
        hash_object(ObjectType::Blob, &self.encode())
544 +
    }
545 +
    pub fn decode(bytes: &[u8]) -> Result<Blob, ObjectError> {
546 +
        let v = cbor::decode(bytes)?;
547 +
        let m = map(&v, "blob")?;
548 +
        check_version(m)?;
549 +
        Ok(Blob {
550 +
            length: uint(field(m, 1, "length")?, "length")?,
551 +
            chunks: array(field(m, 2, "chunks")?, "chunks")?
552 +
                .iter()
553 +
                .map(|c| oref(c, "chunk"))
554 +
                .collect::<Result<Vec<_>, _>>()?,
555 +
        })
556 +
    }
557 +
}
558 +
559 +
#[cfg(test)]
560 +
pub(crate) mod tests {
561 +
    use super::*;
562 +
563 +
    pub(crate) fn sample_change() -> Change {
564 +
        let n = NodeId([7; 16]);
565 +
        let dep = ObjectRef([1; 32]);
566 +
        let mut c = Change {
567 +
            change_id: ChangeId([9; 16]),
568 +
            deps: vec![ObjectRef([2; 32]), dep],
569 +
            author: ObjectRef([3; 32]),
570 +
            hlc: Hlc { wall_ms: 1_700_000_000_000, logical: 4 },
571 +
            message: "Add greeting".into(),
572 +
            tree_ops: vec![
573 +
                TreeOp::AddNode { node: n, kind: NodeKind::File, mode: Mode::TEXT, parent: NodeId::ROOT, name: "hello.txt".into() },
574 +
                TreeOp::SetMode { node: n, old: OpRef { change: ObjectRef::ZERO, index: 0 }, mode: Mode { exec: true, opaque: false } },
575 +
            ],
576 +
            edits: vec![
577 +
                Edit::Insert { node: n, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"hello\n".to_vec(), b"world\n".to_vec()] },
578 +
                Edit::Delete { node: n, atoms: vec![AtomRange { change: dep, from: 0, to: 2 }] },
579 +
                Edit::Replace { node: n, old: None, new: Some(ObjectRef([5; 32])) },
580 +
            ],
581 +
            kind: ChangeKind::Normal,
582 +
        };
583 +
        c.normalize();
584 +
        c
585 +
    }
586 +
587 +
    #[test]
588 +
    fn change_round_trips_and_hash_is_stable() {
589 +
        let c = sample_change();
590 +
        let bytes = c.encode();
591 +
        let d = Change::decode(&bytes).unwrap();
592 +
        assert_eq!(c, d);
593 +
        assert_eq!(c.hash(), d.hash());
594 +
        assert_eq!(d.encode(), bytes);
595 +
        // Any semantic difference changes the hash.
596 +
        let mut e = c.clone();
597 +
        e.message.push('!');
598 +
        assert_ne!(e.hash(), c.hash());
599 +
    }
600 +
601 +
    #[test]
602 +
    fn decode_rejects_bad_changes() {
603 +
        let c = sample_change();
604 +
        let mut v = c.to_value();
605 +
        if let Value::Map(m) = &mut v {
606 +
            m.insert(0, Value::Uint(2));
607 +
        }
608 +
        assert!(matches!(Change::from_value(&v), Err(ObjectError::Schema(s)) if s.contains("version")));
609 +
610 +
        let mut v = c.to_value();
611 +
        if let Value::Map(m) = &mut v {
612 +
            m.insert(2, Value::Array(vec![Value::Bytes(vec![2; 32]), Value::Bytes(vec![1; 32])]));
613 +
        }
614 +
        assert!(matches!(Change::from_value(&v), Err(ObjectError::Schema(s)) if s.contains("sorted")));
615 +
616 +
        let mut v = c.to_value();
617 +
        if let Value::Map(m) = &mut v {
618 +
            m.insert(9, Value::map([(0, Value::Null)]));
619 +
        }
620 +
        assert!(matches!(Change::from_value(&v), Err(ObjectError::Schema(s)) if s.contains("extra")));
621 +
    }
622 +
623 +
    #[test]
624 +
    fn names_are_validated() {
625 +
        for bad in ["", ".", "..", "a/b", "a\0b"] {
626 +
            assert!(validate_name(bad).is_err(), "{bad:?}");
627 +
        }
628 +
        assert!(validate_name("Foo.c").is_ok());
629 +
        assert!(validate_name("...").is_ok());
630 +
    }
631 +
632 +
    #[test]
633 +
    fn mode_and_kind_tags() {
634 +
        for m in [Mode::TEXT, Mode::OPAQUE, Mode { exec: true, opaque: true }] {
635 +
            assert_eq!(Mode::from_bits(m.bits()).unwrap(), m);
636 +
        }
637 +
        assert!(Mode::from_bits(4).is_err());
638 +
        for k in [NodeKind::File, NodeKind::Dir, NodeKind::Symlink] {
639 +
            assert_eq!(NodeKind::from_tag(k.tag()).unwrap(), k);
640 +
        }
641 +
    }
642 +
643 +
    #[test]
644 +
    fn self_references_resolve() {
645 +
        let me = ObjectRef([8; 32]);
646 +
        assert_eq!(AtomRef::Atom { change: ObjectRef::ZERO, index: 3 }.resolve(me), AtomRef::Atom { change: me, index: 3 });
647 +
        assert_eq!(AtomRef::Start.resolve(me), AtomRef::Start);
648 +
        let other = ObjectRef([1; 32]);
649 +
        assert_eq!(OpRef { change: other, index: 1 }.resolve(me).change, other);
650 +
    }
651 +
652 +
    #[test]
653 +
    fn blob_round_trip() {
654 +
        let b = Blob { length: 10, chunks: vec![ObjectRef([1; 32]), ObjectRef([2; 32])] };
655 +
        assert_eq!(Blob::decode(&b.encode()).unwrap(), b);
656 +
    }
657 +
}
added crates/oxy-core/src/oplog.rs +132 −0
1 +
//! Operation log objects (spec §12.1): every mutation of local repository
2 +
//! state records an `Op` whose `view` is the complete set of references and
3 +
//! workspaces afterwards.
4 +
//!
5 +
//! Encoding: `bookmarks` is an array of `[name, [heads]]` sorted by name and
6 +
//! `workspaces` an array of `[workspace_id, change_hash]` sorted by id, since
7 +
//! canonical maps carry only integer keys.
8 +
9 +
use crate::cbor::{self, Value};
10 +
use crate::hash::{hash_object, ChangeHash, ObjectRef, ObjectType};
11 +
use crate::ids::WorkspaceId;
12 +
use crate::objects::{array, bytes, check_version, field, oref, text, Hlc, ObjectError, FORMAT_VERSION};
13 +
use std::collections::BTreeMap;
14 +
15 +
#[derive(Clone, Debug, Default, PartialEq, Eq)]
16 +
pub struct View {
17 +
    pub bookmarks: BTreeMap<String, Vec<ChangeHash>>,
18 +
    pub workspaces: BTreeMap<WorkspaceId, ChangeHash>,
19 +
}
20 +
21 +
#[derive(Clone, Debug, PartialEq, Eq)]
22 +
pub struct Op {
23 +
    pub parents: Vec<ObjectRef>,
24 +
    pub hlc: Hlc,
25 +
    pub command: String,
26 +
    pub view: View,
27 +
}
28 +
29 +
impl View {
30 +
    fn to_value(&self) -> Value {
31 +
        Value::map([
32 +
            (
33 +
                0,
34 +
                Value::Array(
35 +
                    self.bookmarks
36 +
                        .iter()
37 +
                        .map(|(n, hs)| {
38 +
                            Value::Array(vec![
39 +
                                Value::Text(n.clone()),
40 +
                                Value::Array(hs.iter().map(|h| Value::Bytes(h.0.to_vec())).collect()),
41 +
                            ])
42 +
                        })
43 +
                        .collect(),
44 +
                ),
45 +
            ),
46 +
            (
47 +
                1,
48 +
                Value::Array(
49 +
                    self.workspaces
50 +
                        .iter()
51 +
                        .map(|(w, h)| Value::Array(vec![Value::Bytes(w.0.to_vec()), Value::Bytes(h.0.to_vec())]))
52 +
                        .collect(),
53 +
                ),
54 +
            ),
55 +
        ])
56 +
    }
57 +
58 +
    fn from_value(v: &Value) -> Result<View, ObjectError> {
59 +
        let m = v.as_map().ok_or_else(|| ObjectError::Schema("view: expected map".into()))?;
60 +
        let mut view = View::default();
61 +
        for b in array(field(m, 0, "bookmarks")?, "bookmarks")? {
62 +
            let pair = array(b, "bookmark")?;
63 +
            if pair.len() != 2 {
64 +
                return Err(ObjectError::Schema("bookmark: expected [name, heads]".into()));
65 +
            }
66 +
            let name = text(&pair[0], "bookmark name")?.to_owned();
67 +
            let mut heads = array(&pair[1], "heads")?.iter().map(|h| oref(h, "head")).collect::<Result<Vec<_>, _>>()?;
68 +
            heads.sort();
69 +
            heads.dedup();
70 +
            view.bookmarks.insert(name, heads);
71 +
        }
72 +
        for w in array(field(m, 1, "workspaces")?, "workspaces")? {
73 +
            let pair = array(w, "workspace")?;
74 +
            if pair.len() != 2 {
75 +
                return Err(ObjectError::Schema("workspace: expected [id, change]".into()));
76 +
            }
77 +
            let id = WorkspaceId::from_slice(bytes(&pair[0], "workspace id")?)
78 +
                .ok_or_else(|| ObjectError::Schema("workspace id: expected 16 bytes".into()))?;
79 +
            view.workspaces.insert(id, oref(&pair[1], "workspace change")?);
80 +
        }
81 +
        Ok(view)
82 +
    }
83 +
}
84 +
85 +
impl Op {
86 +
    pub fn to_value(&self) -> Value {
87 +
        Value::map([
88 +
            (0, Value::Uint(FORMAT_VERSION)),
89 +
            (1, Value::Array(self.parents.iter().map(|p| Value::Bytes(p.0.to_vec())).collect())),
90 +
            (2, self.hlc.to_value()),
91 +
            (3, Value::Text(self.command.clone())),
92 +
            (4, self.view.to_value()),
93 +
        ])
94 +
    }
95 +
    pub fn encode(&self) -> Vec<u8> {
96 +
        cbor::encode(&self.to_value())
97 +
    }
98 +
    pub fn hash(&self) -> ObjectRef {
99 +
        hash_object(ObjectType::Op, &self.encode())
100 +
    }
101 +
    pub fn decode(b: &[u8]) -> Result<Op, ObjectError> {
102 +
        let v = cbor::decode(b)?;
103 +
        let m = v.as_map().ok_or_else(|| ObjectError::Schema("op: expected map".into()))?;
104 +
        check_version(m)?;
105 +
        Ok(Op {
106 +
            parents: array(field(m, 1, "parents")?, "parents")?.iter().map(|p| oref(p, "parent")).collect::<Result<Vec<_>, _>>()?,
107 +
            hlc: Hlc::from_value(field(m, 2, "hlc")?)?,
108 +
            command: text(field(m, 3, "command")?, "command")?.to_owned(),
109 +
            view: View::from_value(field(m, 4, "view")?)?,
110 +
        })
111 +
    }
112 +
}
113 +
114 +
#[cfg(test)]
115 +
mod tests {
116 +
    use super::*;
117 +
118 +
    #[test]
119 +
    fn op_round_trip() {
120 +
        let mut view = View::default();
121 +
        view.bookmarks.insert("main".into(), vec![ObjectRef([2; 32]), ObjectRef([1; 32])]);
122 +
        view.bookmarks.insert("dev".into(), vec![]);
123 +
        view.workspaces.insert(WorkspaceId([5; 16]), ObjectRef([9; 32]));
124 +
        let op = Op { parents: vec![ObjectRef([7; 32])], hlc: Hlc { wall_ms: 5, logical: 1 }, command: "oxy commit".into(), view };
125 +
        let d = Op::decode(&op.encode()).unwrap();
126 +
        // Heads are normalized to sorted order on decode.
127 +
        assert_eq!(d.view.bookmarks["main"], vec![ObjectRef([1; 32]), ObjectRef([2; 32])]);
128 +
        assert_eq!(d.command, op.command);
129 +
        assert_eq!(d.parents, op.parents);
130 +
        assert_eq!(d.hash(), Op::decode(&d.encode()).unwrap().hash());
131 +
    }
132 +
}
added crates/oxy-core/src/repo.rs +1080 −0
1 +
//! A repository on disk: the `.oxy` directory, one workspace, the operation
2 +
//! log, and the working-copy change (spec §12, §13.1).
3 +
//!
4 +
//! Not yet implemented from the spec: signatures and identities (the author
5 +
//! is `ObjectRef::ZERO`), packs, content-defined chunking (blobs use fixed
6 +
//! 256 KiB chunks), checkpoints, multiple workspaces, remotes, and resolving
7 +
//! conflicts by editing markers.
8 +
9 +
use crate::diff::{ranges, split_lines, text_edits};
10 +
use crate::hash::{hash_object, ChangeHash, ObjectRef, ObjectType};
11 +
use crate::ids::{ChangeId, NodeId, WorkspaceId};
12 +
use crate::objects::{Blob, Change, ChangeKind, Edit, Hlc, Mode, NodeKind, OpRef, TreeOp};
13 +
use crate::oplog::{Op, View};
14 +
use crate::state::{FileContent, Snapshot, State};
15 +
use crate::store::{Store, StoreError};
16 +
use crate::tree::OpKey;
17 +
use std::collections::{BTreeMap, BTreeSet, HashMap};
18 +
use std::fs;
19 +
use std::io::Write;
20 +
use std::path::{Path, PathBuf};
21 +
22 +
pub const OXY_DIR: &str = ".oxy";
23 +
pub const FORMAT_LINE: &str = "oxy repository format 1\n";
24 +
pub const CHUNK_SIZE: usize = 256 * 1024;
25 +
const BINARY_SNIFF: usize = 8 * 1024;
26 +
27 +
#[derive(Debug, thiserror::Error)]
28 +
pub enum RepoError {
29 +
    #[error("io: {0}")]
30 +
    Io(#[from] std::io::Error),
31 +
    #[error(transparent)]
32 +
    Store(#[from] StoreError),
33 +
    #[error(transparent)]
34 +
    Object(#[from] crate::objects::ObjectError),
35 +
    #[error(transparent)]
36 +
    State(#[from] crate::state::StateError),
37 +
    #[error("{0}")]
38 +
    Msg(String),
39 +
}
40 +
41 +
pub type Result<T> = std::result::Result<T, RepoError>;
42 +
43 +
fn fail<T>(s: impl Into<String>) -> Result<T> {
44 +
    Err(RepoError::Msg(s.into()))
45 +
}
46 +
47 +
/// Repository-local settings (`.oxy/config`, `key = value` lines).
48 +
#[derive(Clone, Debug, PartialEq, Eq)]
49 +
pub struct Config {
50 +
    pub author_name: String,
51 +
    pub author_email: String,
52 +
    pub bookmark: String,
53 +
}
54 +
55 +
impl Config {
56 +
    fn default_for_user() -> Config {
57 +
        let user = std::env::var("USER").unwrap_or_else(|_| "unknown".into());
58 +
        Config {
59 +
            author_name: std::env::var("OXY_AUTHOR_NAME").unwrap_or_else(|_| user.clone()),
60 +
            author_email: std::env::var("OXY_AUTHOR_EMAIL").unwrap_or_else(|_| format!("{user}@localhost")),
61 +
            bookmark: "main".into(),
62 +
        }
63 +
    }
64 +
65 +
    fn load(path: &Path) -> Result<Config> {
66 +
        let mut c = Config::default_for_user();
67 +
        for line in fs::read_to_string(path)?.lines() {
68 +
            let line = line.trim();
69 +
            if line.is_empty() || line.starts_with('#') {
70 +
                continue;
71 +
            }
72 +
            let Some((k, v)) = line.split_once('=') else { continue };
73 +
            let v = v.trim().trim_matches('"').to_string();
74 +
            match k.trim() {
75 +
                "author.name" => c.author_name = v,
76 +
                "author.email" => c.author_email = v,
77 +
                "workspace.bookmark" => c.bookmark = v,
78 +
                _ => {}
79 +
            }
80 +
        }
81 +
        Ok(c)
82 +
    }
83 +
84 +
    fn save(&self, path: &Path) -> Result<()> {
85 +
        let text = format!(
86 +
            "author.name = \"{}\"\nauthor.email = \"{}\"\nworkspace.bookmark = \"{}\"\n",
87 +
            self.author_name, self.author_email, self.bookmark
88 +
        );
89 +
        write_atomic(path, text.as_bytes())
90 +
    }
91 +
}
92 +
93 +
fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
94 +
    let tmp = path.with_extension("tmp");
95 +
    {
96 +
        let mut f = fs::File::create(&tmp)?;
97 +
        f.write_all(data)?;
98 +
        f.sync_all()?;
99 +
    }
100 +
    fs::rename(&tmp, path)?;
101 +
    Ok(())
102 +
}
103 +
104 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105 +
pub enum ChangeStatus {
106 +
    Added,
107 +
    Modified,
108 +
    Removed,
109 +
    ModeChanged,
110 +
    /// The file is conflicted in the base state and was edited in the working copy.
111 +
    ConflictEdited,
112 +
}
113 +
114 +
#[derive(Clone, Debug, PartialEq, Eq)]
115 +
pub struct PathStatus {
116 +
    pub path: String,
117 +
    pub status: ChangeStatus,
118 +
}
119 +
120 +
/// Result of snapshotting the working copy.
121 +
pub struct SnapshotResult {
122 +
    pub wc_hash: ChangeHash,
123 +
    pub wc: Change,
124 +
    /// The base state (closure of the working-copy change's dependencies).
125 +
    pub base: State,
126 +
    pub base_snapshot: Snapshot,
127 +
    pub statuses: Vec<PathStatus>,
128 +
    /// Whether the snapshot rewrote the working-copy change.
129 +
    pub changed: bool,
130 +
}
131 +
132 +
pub struct LogEntry {
133 +
    pub hash: ChangeHash,
134 +
    pub change: Change,
135 +
    pub is_working_copy: bool,
136 +
    pub is_head: bool,
137 +
}
138 +
139 +
struct FsEntry {
140 +
    kind: NodeKind,
141 +
    exec: bool,
142 +
    content: Vec<u8>,
143 +
}
144 +
145 +
pub struct Repo {
146 +
    root: PathBuf,
147 +
    oxy: PathBuf,
148 +
    store: Store,
149 +
    workspace: WorkspaceId,
150 +
    pub config: Config,
151 +
}
152 +
153 +
pub fn is_binary(content: &[u8]) -> bool {
154 +
    content[..content.len().min(BINARY_SNIFF)].contains(&0)
155 +
}
156 +
157 +
impl Repo {
158 +
    // ------------------------------------------------------------ lifecycle
159 +
160 +
    pub fn init(root: &Path) -> Result<Repo> {
161 +
        let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
162 +
        let oxy = root.join(OXY_DIR);
163 +
        if oxy.exists() {
164 +
            return fail(format!("{} is already an oxy repository", root.display()));
165 +
        }
166 +
        fs::create_dir_all(oxy.join("ops"))?;
167 +
        write_atomic(&oxy.join("format"), FORMAT_LINE.as_bytes())?;
168 +
        let config = Config::default_for_user();
169 +
        config.save(&oxy.join("config"))?;
170 +
        let workspace = WorkspaceId::random();
171 +
        write_atomic(&oxy.join("workspace"), workspace.to_hex().as_bytes())?;
172 +
        let store = Store::open(oxy.join("objects"))?;
173 +
        let repo = Repo { root, oxy, store, workspace, config };
174 +
175 +
        let wc = repo.new_working_copy(vec![], "");
176 +
        let wc_hash = repo.store.put_change(&wc)?;
177 +
        let mut view = View::default();
178 +
        view.bookmarks.insert(repo.config.bookmark.clone(), vec![]);
179 +
        view.workspaces.insert(workspace, wc_hash);
180 +
        repo.record_op(&[], "oxy init", view)?;
181 +
        Ok(repo)
182 +
    }
183 +
184 +
    /// Open the repository containing `start` (searching upwards).
185 +
    pub fn open(start: &Path) -> Result<Repo> {
186 +
        let start = start.canonicalize()?;
187 +
        let mut dir: Option<&Path> = Some(&start);
188 +
        while let Some(d) = dir {
189 +
            let oxy = d.join(OXY_DIR);
190 +
            if oxy.is_dir() {
191 +
                let format = fs::read_to_string(oxy.join("format"))?;
192 +
                if format != FORMAT_LINE {
193 +
                    return fail(format!("unsupported repository format: {}", format.trim()));
194 +
                }
195 +
                let config = Config::load(&oxy.join("config"))?;
196 +
                let workspace = WorkspaceId::from_hex(fs::read_to_string(oxy.join("workspace"))?.trim())
197 +
                    .ok_or_else(|| RepoError::Msg("corrupt .oxy/workspace".into()))?;
198 +
                let store = Store::open(oxy.join("objects"))?;
199 +
                return Ok(Repo { root: d.to_path_buf(), oxy, store, workspace, config });
200 +
            }
201 +
            dir = d.parent();
202 +
        }
203 +
        fail(format!("no oxy repository found at or above {}", start.display()))
204 +
    }
205 +
206 +
    pub fn root(&self) -> &Path {
207 +
        &self.root
208 +
    }
209 +
    pub fn store(&self) -> &Store {
210 +
        &self.store
211 +
    }
212 +
    pub fn workspace(&self) -> WorkspaceId {
213 +
        self.workspace
214 +
    }
215 +
216 +
    fn new_working_copy(&self, deps: Vec<ChangeHash>, message: &str) -> Change {
217 +
        let mut c = Change {
218 +
            change_id: ChangeId::random(),
219 +
            deps,
220 +
            author: ObjectRef::ZERO,
221 +
            hlc: Hlc::now(),
222 +
            message: message.into(),
223 +
            tree_ops: vec![],
224 +
            edits: vec![],
225 +
            kind: ChangeKind::WorkingCopy,
226 +
        };
227 +
        c.normalize();
228 +
        c
229 +
    }
230 +
231 +
    // ------------------------------------------------------------ operation log
232 +
233 +
    pub fn head_op(&self) -> Result<(ObjectRef, Op)> {
234 +
        let hex = fs::read_to_string(self.oxy.join("ops").join("head"))?;
235 +
        let r = ObjectRef::from_hex(hex.trim()).ok_or_else(|| RepoError::Msg("corrupt .oxy/ops/head".into()))?;
236 +
        let op = Op::decode(&self.store.get_typed(&r, ObjectType::Op)?)?;
237 +
        Ok((r, op))
238 +
    }
239 +
240 +
    pub fn view(&self) -> Result<View> {
241 +
        Ok(self.head_op()?.1.view)
242 +
    }
243 +
244 +
    fn record_op(&self, parents: &[ObjectRef], command: &str, view: View) -> Result<ObjectRef> {
245 +
        let op = Op { parents: parents.to_vec(), hlc: Hlc::now(), command: command.into(), view };
246 +
        let r = self.store.put(ObjectType::Op, &op.encode())?;
247 +
        write_atomic(&self.oxy.join("ops").join("head"), r.to_hex().as_bytes())?;
248 +
        Ok(r)
249 +
    }
250 +
251 +
    pub fn op_log(&self) -> Result<Vec<(ObjectRef, Op)>> {
252 +
        let mut out = Vec::new();
253 +
        let (mut r, mut op) = self.head_op()?;
254 +
        loop {
255 +
            let next = op.parents.first().copied();
256 +
            out.push((r, op));
257 +
            match next {
258 +
                Some(p) => {
259 +
                    r = p;
260 +
                    op = Op::decode(&self.store.get_typed(&r, ObjectType::Op)?)?;
261 +
                }
262 +
                None => break,
263 +
            }
264 +
        }
265 +
        Ok(out)
266 +
    }
267 +
268 +
    /// Move to the parent operation (spec §12.1).
269 +
    pub fn undo(&self) -> Result<Op> {
270 +
        let (_, op) = self.head_op()?;
271 +
        let Some(parent) = op.parents.first() else {
272 +
            return fail("nothing to undo");
273 +
        };
274 +
        let parent_op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?;
275 +
        write_atomic(&self.oxy.join("ops").join("head"), parent.to_hex().as_bytes())?;
276 +
        Ok(parent_op)
277 +
    }
278 +
279 +
    // ------------------------------------------------------------ states
280 +
281 +
    /// Load the dependency closure of `heads` from the store.
282 +
    pub fn load_state(&self, heads: &[ChangeHash]) -> Result<State> {
283 +
        let mut state = State::new();
284 +
        let mut stack: Vec<ChangeHash> = heads.to_vec();
285 +
        let mut seen: BTreeSet<ChangeHash> = BTreeSet::new();
286 +
        while let Some(h) = stack.pop() {
287 +
            if !seen.insert(h) {
288 +
                continue;
289 +
            }
290 +
            let c = self.store.get_change(&h)?;
291 +
            stack.extend(c.deps.iter().copied());
292 +
            state.add_unchecked(c);
293 +
        }
294 +
        state.validate()?;
295 +
        Ok(state)
296 +
    }
297 +
298 +
    pub fn working_copy(&self) -> Result<(ChangeHash, Change)> {
299 +
        let view = self.view()?;
300 +
        let h = *view
301 +
            .workspaces
302 +
            .get(&self.workspace)
303 +
            .ok_or_else(|| RepoError::Msg("this workspace is not in the current view".into()))?;
304 +
        Ok((h, self.store.get_change(&h)?))
305 +
    }
306 +
307 +
    pub fn bookmark_heads(&self) -> Result<Vec<ChangeHash>> {
308 +
        Ok(self.view()?.bookmarks.get(&self.config.bookmark).cloned().unwrap_or_default())
309 +
    }
310 +
311 +
    // ------------------------------------------------------------ blobs
312 +
313 +
    fn blob_for(content: &[u8]) -> Blob {
314 +
        Blob {
315 +
            length: content.len() as u64,
316 +
            chunks: content.chunks(CHUNK_SIZE).map(|c| hash_object(ObjectType::Chunk, c)).collect(),
317 +
        }
318 +
    }
319 +
320 +
    pub fn blob_ref(content: &[u8]) -> ObjectRef {
321 +
        Self::blob_for(content).hash()
322 +
    }
323 +
324 +
    pub fn store_blob(&self, content: &[u8]) -> Result<ObjectRef> {
325 +
        for c in content.chunks(CHUNK_SIZE) {
326 +
            self.store.put_chunk(c)?;
327 +
        }
328 +
        Ok(self.store.put(ObjectType::Blob, &Self::blob_for(content).encode())?)
329 +
    }
330 +
331 +
    pub fn read_blob(&self, r: &ObjectRef) -> Result<Vec<u8>> {
332 +
        let blob = Blob::decode(&self.store.get_typed(r, ObjectType::Blob)?)?;
333 +
        let mut out = Vec::with_capacity(blob.length as usize);
334 +
        for c in &blob.chunks {
335 +
            out.extend(self.store.get_typed(c, ObjectType::Chunk)?);
336 +
        }
337 +
        Ok(out)
338 +
    }
339 +
340 +
    // ------------------------------------------------------------ working copy
341 +
342 +
    fn walk_working_copy(&self) -> Result<BTreeMap<String, FsEntry>> {
343 +
        let mut wb = ignore::WalkBuilder::new(&self.root);
344 +
        wb.hidden(false)
345 +
            .git_ignore(false)
346 +
            .git_global(false)
347 +
            .git_exclude(false)
348 +
            .require_git(false)
349 +
            .follow_links(false)
350 +
            .add_custom_ignore_filename(".oxyignore");
351 +
        wb.filter_entry(|e| e.file_name() != OXY_DIR);
352 +
        let mut out = BTreeMap::new();
353 +
        for entry in wb.build() {
354 +
            let entry = entry.map_err(|e| RepoError::Msg(e.to_string()))?;
355 +
            let path = entry.path();
356 +
            if path == self.root {
357 +
                continue;
358 +
            }
359 +
            let rel = path.strip_prefix(&self.root).unwrap();
360 +
            let rel: String = rel.components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::<Vec<_>>().join("/");
361 +
            let Some(ft) = entry.file_type() else { continue };
362 +
            if ft.is_dir() {
363 +
                continue;
364 +
            }
365 +
            if ft.is_symlink() {
366 +
                let target = fs::read_link(path)?;
367 +
                out.insert(rel, FsEntry { kind: NodeKind::Symlink, exec: false, content: target.to_string_lossy().as_bytes().to_vec() });
368 +
            } else if ft.is_file() {
369 +
                let exec = is_executable(&entry.metadata().map_err(|e| RepoError::Msg(e.to_string()))?);
370 +
                out.insert(rel, FsEntry { kind: NodeKind::File, exec, content: fs::read(path)? });
371 +
            }
372 +
        }
373 +
        Ok(out)
374 +
    }
375 +
376 +
    /// Snapshot the working copy into the working-copy change (spec §12.3).
377 +
    pub fn snapshot(&self, command: &str) -> Result<SnapshotResult> {
378 +
        let (op_ref, op) = self.head_op()?;
379 +
        let (old_hash, old_wc) = self.working_copy()?;
380 +
        let base = self.load_state(&old_wc.deps)?;
381 +
        let base_snapshot = base.materialize();
382 +
        let fs_entries = self.walk_working_copy()?;
383 +
384 +
        // Reuse the NodeIds the previous working-copy version assigned to new
385 +
        // paths, so an unchanged working copy hashes identically (spec §12.3).
386 +
        let reuse = added_node_paths(&old_wc, &base_snapshot);
387 +
        let mut b = ChangeBuilder::new(&base, &base_snapshot, reuse);
388 +
        for (path, fs_entry) in &fs_entries {
389 +
            match base_snapshot.files.get(path) {
390 +
                None => b.add_path(self, path, fs_entry)?,
391 +
                Some(expected) => b.update_path(self, path, fs_entry, expected)?,
392 +
            }
393 +
        }
394 +
        for (path, expected) in &base_snapshot.files {
395 +
            if fs_entries.contains_key(path) {
396 +
                continue;
397 +
            }
398 +
            let still_needed = expected.kind == NodeKind::Dir && fs_entries.keys().any(|p| p.starts_with(&format!("{path}/")));
399 +
            if !still_needed {
400 +
                b.remove_path(path, expected);
401 +
            }
402 +
        }
403 +
404 +
        let mut wc = Change {
405 +
            change_id: old_wc.change_id,
406 +
            deps: vec![],
407 +
            author: ObjectRef::ZERO,
408 +
            hlc: old_wc.hlc,
409 +
            message: old_wc.message.clone(),
410 +
            tree_ops: b.tree_ops,
411 +
            edits: b.edits,
412 +
            kind: ChangeKind::WorkingCopy,
413 +
        };
414 +
        let base_heads = base.maximal(&old_wc.deps);
415 +
        let mut deps: BTreeSet<ChangeHash> = base_heads.into_iter().collect();
416 +
        deps.extend(referenced_changes(&wc, &base));
417 +
        wc.deps = deps.into_iter().collect();
418 +
        wc.normalize();
419 +
420 +
        let mut statuses = b.statuses;
421 +
        statuses.sort_by(|a, b| a.path.cmp(&b.path));
422 +
        // Empty snapshot: hash the candidate before storing; only record an op on change.
423 +
        let new_hash = wc.hash();
424 +
        let changed = new_hash != old_hash;
425 +
        if changed {
426 +
            self.store.put_change(&wc)?;
427 +
            let mut view = op.view.clone();
428 +
            view.workspaces.insert(self.workspace, new_hash);
429 +
            self.record_op(&[op_ref], &format!("{command} (snapshot)"), view)?;
430 +
        }
431 +
        Ok(SnapshotResult { wc_hash: new_hash, wc, base, base_snapshot, statuses, changed })
432 +
    }
433 +
434 +
    /// Turn the working-copy change into a normal change on the bookmark (spec §12.3).
435 +
    pub fn commit(&self, message: &str, extra_deps: &[ChangeHash]) -> Result<ChangeHash> {
436 +
        let snap = self.snapshot("oxy commit")?;
437 +
        if snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty() {
438 +
            return fail("nothing to commit: the working copy matches its base");
439 +
        }
440 +
        if snap.statuses.iter().any(|s| s.status == ChangeStatus::ConflictEdited) {
441 +
            return fail("a conflicted file was edited; resolving conflicts is not implemented yet");
442 +
        }
443 +
        if message.trim().is_empty() {
444 +
            return fail("a commit message is required");
445 +
        }
446 +
        let (op_ref, op) = self.head_op()?;
447 +
        let mut deps: BTreeSet<ChangeHash> = referenced_changes(&snap.wc, &snap.base);
448 +
        for d in extra_deps {
449 +
            if !snap.base.contains(d) {
450 +
                return fail(format!("--depends-on {d}: not in the working copy's base state"));
451 +
            }
452 +
            deps.insert(*d);
453 +
        }
454 +
        let mut change = Change {
455 +
            change_id: snap.wc.change_id,
456 +
            deps: deps.into_iter().collect(),
457 +
            author: ObjectRef::ZERO,
458 +
            hlc: Hlc::now(),
459 +
            message: message.trim_end().to_string(),
460 +
            tree_ops: snap.wc.tree_ops.clone(),
461 +
            edits: snap.wc.edits.clone(),
462 +
            kind: ChangeKind::Normal,
463 +
        };
464 +
        change.normalize();
465 +
        let hash = self.store.put_change(&change)?;
466 +
467 +
        // New bookmark heads: maximal elements of old heads plus the new change.
468 +
        let old_heads = op.view.bookmarks.get(&self.config.bookmark).cloned().unwrap_or_default();
469 +
        let mut with_new: Vec<ChangeHash> = old_heads.clone();
470 +
        with_new.push(hash);
471 +
        let mut st = self.load_state(&with_new)?;
472 +
        st.add(change)?;
473 +
        let new_heads = st.maximal(&with_new);
474 +
475 +
        let wc = self.new_working_copy(new_heads.clone(), "");
476 +
        let wc_hash = self.store.put_change(&wc)?;
477 +
        let mut view = op.view.clone();
478 +
        view.bookmarks.insert(self.config.bookmark.clone(), new_heads);
479 +
        view.workspaces.insert(self.workspace, wc_hash);
480 +
        self.record_op(&[op_ref], &format!("oxy commit -m {message:?}"), view)?;
481 +
        Ok(hash)
482 +
    }
483 +
484 +
    /// Changes reachable from the bookmark and the working copy, newest first.
485 +
    pub fn log(&self) -> Result<Vec<LogEntry>> {
486 +
        let (wc_hash, _) = self.working_copy()?;
487 +
        let mut heads = self.bookmark_heads()?;
488 +
        heads.push(wc_hash);
489 +
        let state = self.load_state(&heads)?;
490 +
        let head_set: BTreeSet<ChangeHash> = self.bookmark_heads()?.into_iter().collect();
491 +
        let mut entries: Vec<LogEntry> = state
492 +
            .changes()
493 +
            .map(|(h, c)| LogEntry { hash: *h, change: c.clone(), is_working_copy: *h == wc_hash, is_head: head_set.contains(h) })
494 +
            .collect();
495 +
        entries.sort_by(|a, b| b.change.hlc.cmp(&a.change.hlc).then(b.hash.cmp(&a.hash)));
496 +
        Ok(entries)
497 +
    }
498 +
499 +
    /// Resolve a change by ChangeId prefix, `#hash` prefix, `@` (working copy), or bookmark.
500 +
    pub fn resolve_change(&self, spec: &str) -> Result<ChangeHash> {
501 +
        if spec == "@" {
502 +
            return Ok(self.working_copy()?.0);
503 +
        }
504 +
        let entries = self.log()?;
505 +
        let matches: Vec<&LogEntry> = if let Some(hp) = spec.strip_prefix('#') {
506 +
            entries.iter().filter(|e| e.hash.starts_with_hex(hp)).collect()
507 +
        } else {
508 +
            entries.iter().filter(|e| e.change.change_id.starts_with_hex(spec)).collect()
509 +
        };
510 +
        match matches.len() {
511 +
            1 => Ok(matches[0].hash),
512 +
            0 => fail(format!("no change matches {spec:?}")),
513 +
            _ => {
514 +
                let ids: BTreeSet<ChangeId> = matches.iter().map(|e| e.change.change_id).collect();
515 +
                if ids.len() == 1 {
516 +
                    fail(format!("change {spec} is divergent: {} variants", matches.len()))
517 +
                } else {
518 +
                    fail(format!("{spec:?} is ambiguous"))
519 +
                }
520 +
            }
521 +
        }
522 +
    }
523 +
524 +
    /// Human-readable description of what a change does, with line content.
525 +
    pub fn describe_change(&self, hash: &ChangeHash) -> Result<String> {
526 +
        let change = self.store.get_change(hash)?;
527 +
        let base = self.load_state(&change.deps)?;
528 +
        let base_snapshot = base.materialize();
529 +
        Ok(render_change(&change, &base, &base_snapshot))
530 +
    }
531 +
}
532 +
533 +
#[cfg(unix)]
534 +
fn is_executable(m: &fs::Metadata) -> bool {
535 +
    use std::os::unix::fs::PermissionsExt;
536 +
    m.permissions().mode() & 0o111 != 0
537 +
}
538 +
#[cfg(not(unix))]
539 +
fn is_executable(_m: &fs::Metadata) -> bool {
540 +
    false
541 +
}
542 +
543 +
fn op_ref(k: OpKey) -> OpRef {
544 +
    OpRef { change: k.0, index: k.1 }
545 +
}
546 +
547 +
fn split_path(path: &str) -> (&str, &str) {
548 +
    match path.rfind('/') {
549 +
        Some(i) => (&path[..i], &path[i + 1..]),
550 +
        None => ("", path),
551 +
    }
552 +
}
553 +
554 +
/// Builds the tree ops and edits of a working-copy change against a base.
555 +
struct ChangeBuilder<'a> {
556 +
    base: &'a State,
557 +
    base_snapshot: &'a Snapshot,
558 +
    tree_ops: Vec<TreeOp>,
559 +
    edits: Vec<Edit>,
560 +
    statuses: Vec<PathStatus>,
561 +
    dir_nodes: HashMap<String, NodeId>,
562 +
    reuse: HashMap<String, (NodeId, NodeKind)>,
563 +
}
564 +
565 +
impl<'a> ChangeBuilder<'a> {
566 +
    fn new(base: &'a State, base_snapshot: &'a Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>) -> Self {
567 +
        let mut dir_nodes: HashMap<String, NodeId> = HashMap::new();
568 +
        dir_nodes.insert(String::new(), NodeId::ROOT);
569 +
        for (p, f) in &base_snapshot.files {
570 +
            if f.kind == NodeKind::Dir {
571 +
                dir_nodes.insert(p.clone(), f.node);
572 +
            }
573 +
        }
574 +
        ChangeBuilder { base, base_snapshot, tree_ops: vec![], edits: vec![], statuses: vec![], dir_nodes, reuse }
575 +
    }
576 +
577 +
    fn reused_node(&self, path: &str, kind: NodeKind) -> Option<NodeId> {
578 +
        self.reuse.get(path).filter(|(_, k)| *k == kind).map(|(n, _)| *n)
579 +
    }
580 +
581 +
    fn ensure_dir(&mut self, path: &str) -> NodeId {
582 +
        if let Some(n) = self.dir_nodes.get(path) {
583 +
            return *n;
584 +
        }
585 +
        let mut parent = NodeId::ROOT;
586 +
        let mut prefix = String::new();
587 +
        for comp in path.split('/') {
588 +
            if !prefix.is_empty() {
589 +
                prefix.push('/');
590 +
            }
591 +
            prefix.push_str(comp);
592 +
            parent = match self.dir_nodes.get(&prefix) {
593 +
                Some(n) => *n,
594 +
                None => {
595 +
                    let node = self.reused_node(&prefix, NodeKind::Dir).unwrap_or_else(NodeId::random);
596 +
                    self.tree_ops.push(TreeOp::AddNode { node, kind: NodeKind::Dir, mode: Mode::default(), parent, name: comp.to_string() });
597 +
                    self.dir_nodes.insert(prefix.clone(), node);
598 +
                    node
599 +
                }
600 +
            };
601 +
        }
602 +
        parent
603 +
    }
604 +
605 +
    fn add_path(&mut self, repo: &Repo, path: &str, e: &FsEntry) -> Result<()> {
606 +
        let (dir, name) = split_path(path);
607 +
        let parent = self.ensure_dir(dir);
608 +
        let node = self.reused_node(path, e.kind).unwrap_or_else(NodeId::random);
609 +
        let opaque = e.kind == NodeKind::Symlink || is_binary(&e.content);
610 +
        self.tree_ops.push(TreeOp::AddNode { node, kind: e.kind, mode: Mode { exec: e.exec, opaque }, parent, name: name.to_string() });
611 +
        if opaque {
612 +
            let blob = repo.store_blob(&e.content)?;
613 +
            self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
614 +
        } else if !e.content.is_empty() {
615 +
            self.edits.push(Edit::Insert {
616 +
                node,
617 +
                after: crate::objects::AtomRef::Start,
618 +
                before: crate::objects::AtomRef::End,
619 +
                lines: split_lines(&e.content).iter().map(|l| l.to_vec()).collect(),
620 +
            });
621 +
        }
622 +
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Added });
623 +
        Ok(())
624 +
    }
625 +
626 +
    fn remove_path(&mut self, path: &str, expected: &crate::state::FileState) {
627 +
        let entry = &self.base_snapshot.tree.entries[path];
628 +
        self.tree_ops.push(TreeOp::RemoveNode { node: expected.node, placement: op_ref(entry.placement) });
629 +
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Removed });
630 +
    }
631 +
632 +
    fn update_path(&mut self, repo: &Repo, path: &str, e: &FsEntry, expected: &crate::state::FileState) -> Result<()> {
633 +
        if expected.kind != e.kind {
634 +
            self.remove_path(path, expected);
635 +
            self.statuses.pop();
636 +
            self.add_path(repo, path, e)?;
637 +
            self.statuses.pop();
638 +
            self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Modified });
639 +
            return Ok(());
640 +
        }
641 +
        let entry = &self.base_snapshot.tree.entries[path];
642 +
        let node = expected.node;
643 +
        let mode_atom = entry.mode_atom.map(op_ref);
644 +
        let mut status: Option<ChangeStatus> = None;
645 +
        match &expected.content {
646 +
            FileContent::Dir => {}
647 +
            FileContent::Text { bytes, materialized } => {
648 +
                if materialized.is_conflicted() {
649 +
                    if bytes != &e.content {
650 +
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
651 +
                    }
652 +
                    return Ok(());
653 +
                }
654 +
                if bytes != &e.content {
655 +
                    if is_binary(&e.content) {
656 +
                        // Text → opaque (spec §7.5).
657 +
                        if let Some(old) = mode_atom {
658 +
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
659 +
                        }
660 +
                        let live: Vec<_> = repo_live_keys(self.base, &node);
661 +
                        if !live.is_empty() {
662 +
                            self.edits.push(Edit::Delete { node, atoms: ranges(&live) });
663 +
                        }
664 +
                        let blob = repo.store_blob(&e.content)?;
665 +
                        self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
666 +
                        status = Some(ChangeStatus::Modified);
667 +
                    } else {
668 +
                        let old = self.base.live_lines(&node);
669 +
                        let edits = text_edits(node, &old, &e.content);
670 +
                        if !edits.is_empty() {
671 +
                            self.edits.extend(edits);
672 +
                            status = Some(ChangeStatus::Modified);
673 +
                        }
674 +
                    }
675 +
                }
676 +
                if status != Some(ChangeStatus::Modified) || !is_binary(&e.content) {
677 +
                    if e.exec != expected.mode.exec {
678 +
                        if let Some(old) = mode_atom {
679 +
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
680 +
                            status.get_or_insert(ChangeStatus::ModeChanged);
681 +
                        }
682 +
                    }
683 +
                }
684 +
            }
685 +
            FileContent::Opaque { blobs } => {
686 +
                let new_ref = Repo::blob_ref(&e.content);
687 +
                if blobs.len() > 1 {
688 +
                    if !blobs.contains(&new_ref) {
689 +
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
690 +
                    }
691 +
                    return Ok(());
692 +
                }
693 +
                let current = blobs.first().copied();
694 +
                if current != Some(new_ref) {
695 +
                    if e.kind == NodeKind::File && !is_binary(&e.content) {
696 +
                        // Opaque → text (spec §7.5).
697 +
                        if let Some(old) = mode_atom {
698 +
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
699 +
                        }
700 +
                        self.edits.push(Edit::Replace { node, old: current, new: None });
701 +
                        if !e.content.is_empty() {
702 +
                            self.edits.push(Edit::Insert {
703 +
                                node,
704 +
                                after: crate::objects::AtomRef::Start,
705 +
                                before: crate::objects::AtomRef::End,
706 +
                                lines: split_lines(&e.content).iter().map(|l| l.to_vec()).collect(),
707 +
                            });
708 +
                        }
709 +
                    } else {
710 +
                        let blob = repo.store_blob(&e.content)?;
711 +
                        self.edits.push(Edit::Replace { node, old: current, new: Some(blob) });
712 +
                    }
713 +
                    status = Some(ChangeStatus::Modified);
714 +
                } else if e.kind == NodeKind::File && e.exec != expected.mode.exec {
715 +
                    if let Some(old) = mode_atom {
716 +
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
717 +
                        status = Some(ChangeStatus::ModeChanged);
718 +
                    }
719 +
                }
720 +
            }
721 +
        }
722 +
        if let Some(s) = status {
723 +
            self.statuses.push(PathStatus { path: path.to_string(), status: s });
724 +
        }
725 +
        Ok(())
726 +
    }
727 +
}
728 +
729 +
fn repo_live_keys(base: &State, node: &NodeId) -> Vec<crate::graph::AtomKey> {
730 +
    base.live_lines(node).into_iter().map(|(k, _)| k).collect()
731 +
}
732 +
733 +
/// The changes a change's scope references (spec §8.1): introducers of every
734 +
/// atom, placement, and mode it touches, and of every node it operates on.
735 +
pub fn referenced_changes(change: &Change, base: &State) -> BTreeSet<ChangeHash> {
736 +
    let mut out = BTreeSet::new();
737 +
    let opref = |r: &OpRef, out: &mut BTreeSet<ChangeHash>| {
738 +
        if !r.change.is_zero() {
739 +
            out.insert(r.change);
740 +
        }
741 +
    };
742 +
    let mut nodes: Vec<NodeId> = Vec::new();
743 +
    for op in &change.tree_ops {
744 +
        match op {
745 +
            TreeOp::AddNode { parent, .. } => nodes.push(*parent),
746 +
            TreeOp::RemoveNode { node, placement } => {
747 +
                nodes.push(*node);
748 +
                opref(placement, &mut out);
749 +
            }
750 +
            TreeOp::Move { node, old, parent, .. } => {
751 +
                nodes.push(*node);
752 +
                nodes.push(*parent);
753 +
                opref(old, &mut out);
754 +
            }
755 +
            TreeOp::SetMode { node, old, .. } => {
756 +
                nodes.push(*node);
757 +
                opref(old, &mut out);
758 +
            }
759 +
        }
760 +
    }
761 +
    for e in &change.edits {
762 +
        nodes.push(e.node());
763 +
        match e {
764 +
            Edit::Insert { after, before, .. } => {
765 +
                for r in [after, before] {
766 +
                    if let crate::objects::AtomRef::Atom { change, .. } = r {
767 +
                        if !change.is_zero() {
768 +
                            out.insert(*change);
769 +
                        }
770 +
                    }
771 +
                }
772 +
            }
773 +
            Edit::Delete { atoms, .. } => {
774 +
                for r in atoms {
775 +
                    if !r.change.is_zero() {
776 +
                        out.insert(r.change);
777 +
                    }
778 +
                }
779 +
            }
780 +
            Edit::Replace { .. } => {}
781 +
        }
782 +
    }
783 +
    for n in nodes {
784 +
        if let Some(h) = base.tree().introducer(&n) {
785 +
            out.insert(h);
786 +
        }
787 +
    }
788 +
    out
789 +
}
790 +
791 +
/// Paths of the nodes a change adds on top of `base_snapshot`.
792 +
pub fn added_node_paths(change: &Change, base_snapshot: &Snapshot) -> HashMap<String, (NodeId, NodeKind)> {
793 +
    let by_node = base_snapshot.tree.by_node();
794 +
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
795 +
    for op in &change.tree_ops {
796 +
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
797 +
            new_names.insert(*node, (*parent, name.clone(), *kind));
798 +
        }
799 +
    }
800 +
    let mut out = HashMap::new();
801 +
    for (node, (_, _, kind)) in &new_names {
802 +
        if let Some(path) = path_of_node(node, &by_node, &new_names) {
803 +
            out.insert(path, (*node, *kind));
804 +
        }
805 +
    }
806 +
    out
807 +
}
808 +
809 +
fn path_of_node(
810 +
    n: &NodeId,
811 +
    by_node: &HashMap<NodeId, &crate::tree::TreeEntry>,
812 +
    new_names: &HashMap<NodeId, (NodeId, String, NodeKind)>,
813 +
) -> Option<String> {
814 +
    if let Some(e) = by_node.get(n) {
815 +
        return Some(e.path.clone());
816 +
    }
817 +
    let mut parts = Vec::new();
818 +
    let mut cur = *n;
819 +
    loop {
820 +
        if cur.is_root() {
821 +
            break;
822 +
        }
823 +
        if let Some(e) = by_node.get(&cur) {
824 +
            if !e.path.is_empty() {
825 +
                parts.push(e.path.clone());
826 +
            }
827 +
            break;
828 +
        }
829 +
        let (parent, name, _) = new_names.get(&cur)?;
830 +
        parts.push(name.clone());
831 +
        cur = *parent;
832 +
    }
833 +
    parts.reverse();
834 +
    Some(parts.join("/"))
835 +
}
836 +
837 +
/// Render a change's operations with line content, using `base` for deleted lines.
838 +
pub fn render_change(change: &Change, base: &State, base_snapshot: &Snapshot) -> String {
839 +
    let by_node = base_snapshot.tree.by_node();
840 +
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
841 +
    for op in &change.tree_ops {
842 +
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
843 +
            new_names.insert(*node, (*parent, name.clone(), *kind));
844 +
        }
845 +
    }
846 +
    let path_of = |n: &NodeId| -> String { path_of_node(n, &by_node, &new_names).unwrap_or_else(|| format!("<node {}>", n.short(8))) };
847 +
848 +
    let mut out = String::new();
849 +
    for op in &change.tree_ops {
850 +
        match op {
851 +
            TreeOp::AddNode { node, kind, mode, .. } => {
852 +
                out.push_str(&format!("A {} ({:?}{}{})\n", path_of(node), kind, if mode.exec { ", exec" } else { "" }, if mode.opaque { ", opaque" } else { "" }));
853 +
            }
854 +
            TreeOp::RemoveNode { node, .. } => out.push_str(&format!("D {}\n", path_of(node))),
855 +
            TreeOp::Move { node, name, .. } => out.push_str(&format!("R {} -> {}\n", path_of(node), name)),
856 +
            TreeOp::SetMode { node, mode, .. } => {
857 +
                out.push_str(&format!("M {} mode: exec={} opaque={}\n", path_of(node), mode.exec, mode.opaque));
858 +
            }
859 +
        }
860 +
    }
861 +
    let mut last_node: Option<NodeId> = None;
862 +
    for e in &change.edits {
863 +
        let n = e.node();
864 +
        if last_node != Some(n) {
865 +
            out.push_str(&format!("--- {}\n", path_of(&n)));
866 +
            last_node = Some(n);
867 +
        }
868 +
        match e {
869 +
            Edit::Insert { lines, .. } => {
870 +
                for l in lines {
871 +
                    out.push('+');
872 +
                    out.push_str(&String::from_utf8_lossy(l));
873 +
                    if !l.ends_with(b"\n") {
874 +
                        out.push('\n');
875 +
                    }
876 +
                }
877 +
            }
878 +
            Edit::Delete { atoms, .. } => {
879 +
                for r in atoms {
880 +
                    for i in r.from..r.to {
881 +
                        let content = base.file(&n).and_then(|g| g.content(&(r.change, i))).unwrap_or(b"<unknown>\n");
882 +
                        out.push('-');
883 +
                        out.push_str(&String::from_utf8_lossy(content));
884 +
                        if !content.ends_with(b"\n") {
885 +
                            out.push('\n');
886 +
                        }
887 +
                    }
888 +
                }
889 +
            }
890 +
            Edit::Replace { old, new, .. } => {
891 +
                out.push_str(&format!(
892 +
                    "  opaque content {} -> {}\n",
893 +
                    old.map(|o| o.short(12)).unwrap_or_else(|| "none".into()),
894 +
                    new.map(|o| o.short(12)).unwrap_or_else(|| "none".into())
895 +
                ));
896 +
            }
897 +
        }
898 +
    }
899 +
    out
900 +
}
901 +
902 +
#[cfg(test)]
903 +
mod tests {
904 +
    use super::*;
905 +
906 +
    fn write(root: &Path, rel: &str, content: &[u8]) {
907 +
        let p = root.join(rel);
908 +
        fs::create_dir_all(p.parent().unwrap()).unwrap();
909 +
        fs::write(p, content).unwrap();
910 +
    }
911 +
912 +
    fn statuses(s: &SnapshotResult) -> Vec<(String, ChangeStatus)> {
913 +
        s.statuses.iter().map(|p| (p.path.clone(), p.status)).collect()
914 +
    }
915 +
916 +
    #[test]
917 +
    fn init_snapshot_commit_log_undo() {
918 +
        let dir = tempfile::tempdir().unwrap();
919 +
        let root = dir.path();
920 +
        let repo = Repo::init(root).unwrap();
921 +
        assert!(Repo::init(root).is_err());
922 +
        let ops = repo.op_log().unwrap();
923 +
        assert_eq!(ops.len(), 1);
924 +
        assert_eq!(ops[0].1.command, "oxy init");
925 +
926 +
        // Empty working copy: snapshot records no operation (spec §12.3).
927 +
        let s = repo.snapshot("oxy status").unwrap();
928 +
        assert!(!s.changed);
929 +
        assert_eq!(repo.op_log().unwrap().len(), 1);
930 +
931 +
        write(root, "src/main.rs", b"fn main() {}\n");
932 +
        write(root, "README.md", b"# Hello\n");
933 +
        write(root, ".oxyignore", b"/target\n");
934 +
        write(root, "target/junk.o", b"\0\0\0");
935 +
        let s = repo.snapshot("oxy status").unwrap();
936 +
        assert!(s.changed);
937 +
        assert_eq!(
938 +
            statuses(&s),
939 +
            vec![
940 +
                (".oxyignore".into(), ChangeStatus::Added),
941 +
                ("README.md".into(), ChangeStatus::Added),
942 +
                ("src/main.rs".into(), ChangeStatus::Added),
943 +
            ]
944 +
        );
945 +
        assert_eq!(repo.op_log().unwrap().len(), 2);
946 +
        // Snapshotting again with nothing changed records no op.
947 +
        let s2 = repo.snapshot("oxy status").unwrap();
948 +
        assert!(!s2.changed);
949 +
        assert_eq!(s2.wc_hash, s.wc_hash);
950 +
        assert_eq!(repo.op_log().unwrap().len(), 2);
951 +
952 +
        let h1 = repo.commit("Initial import", &[]).unwrap();
953 +
        let c1 = repo.store().get_change(&h1).unwrap();
954 +
        assert_eq!(c1.kind, ChangeKind::Normal);
955 +
        assert!(c1.deps.is_empty(), "first change has nothing to depend on");
956 +
        assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]);
957 +
        let (wc_hash, wc) = repo.working_copy().unwrap();
958 +
        assert_eq!(wc.deps, vec![h1]);
959 +
        assert!(wc.edits.is_empty());
960 +
        assert!(repo.commit("nothing", &[]).is_err());
961 +
962 +
        // Modify one file, remove another, add a binary file.
963 +
        write(root, "src/main.rs", b"fn main() {\n    println!(\"hi\");\n}\n");
964 +
        fs::remove_file(root.join("README.md")).unwrap();
965 +
        write(root, "logo.png", b"\x89PNG\0\0\x01");
966 +
        let s = repo.snapshot("oxy status").unwrap();
967 +
        assert_eq!(
968 +
            statuses(&s),
969 +
            vec![
970 +
                ("README.md".into(), ChangeStatus::Removed),
971 +
                ("logo.png".into(), ChangeStatus::Added),
972 +
                ("src/main.rs".into(), ChangeStatus::Modified),
973 +
            ]
974 +
        );
975 +
        assert_ne!(s.wc_hash, wc_hash);
976 +
        // The rewritten working copy keeps its ChangeId and depends on h1.
977 +
        assert_eq!(s.wc.change_id, wc.change_id);
978 +
        assert!(s.wc.deps.contains(&h1));
979 +
980 +
        let h2 = repo.commit("Second", &[]).unwrap();
981 +
        let c2 = repo.store().get_change(&h2).unwrap();
982 +
        assert_eq!(c2.deps, vec![h1], "everything it touches was introduced by h1");
983 +
        assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]);
984 +
985 +
        // The materialized head state matches the working copy.
986 +
        let st = repo.load_state(&[h2]).unwrap();
987 +
        let snap = st.materialize();
988 +
        assert!(!snap.is_conflicted());
989 +
        assert_eq!(snap.files.keys().cloned().collect::<Vec<_>>(), [".oxyignore", "logo.png", "src", "src/main.rs"]);
990 +
        assert!(matches!(&snap.files["src/main.rs"].content, FileContent::Text { bytes, .. } if bytes == b"fn main() {\n    println!(\"hi\");\n}\n"));
991 +
        assert!(snap.files["logo.png"].mode.opaque);
992 +
        if let FileContent::Opaque { blobs } = &snap.files["logo.png"].content {
993 +
            assert_eq!(repo.read_blob(&blobs[0]).unwrap(), b"\x89PNG\0\0\x01");
994 +
        } else {
995 +
            panic!("logo should be opaque");
996 +
        }
997 +
998 +
        // Log: newest first, working copy included.
999 +
        let log = repo.log().unwrap();
1000 +
        assert_eq!(log.len(), 3);
1001 +
        assert!(log[0].is_working_copy);
1002 +
        assert_eq!(log[1].hash, h2);
1003 +
        assert!(log[1].is_head);
1004 +
        assert_eq!(log[2].hash, h1);
1005 +
        let d = repo.describe_change(&h2).unwrap();
1006 +
        assert!(d.contains("D README.md"), "{d}");
1007 +
        assert!(d.contains("+    println!(\"hi\");"), "{d}");
1008 +
        assert!(d.contains("-fn main() {}"), "{d}");
1009 +
1010 +
        // Resolution by prefix.
1011 +
        assert_eq!(repo.resolve_change(&c2.change_id.short(6)).unwrap(), h2);
1012 +
        assert_eq!(repo.resolve_change(&format!("#{}", h1.short(8))).unwrap(), h1);
1013 +
        assert!(repo.resolve_change("zzzz").is_err());
1014 +
1015 +
        // Undo the commit: bookmark and working copy go back.
1016 +
        repo.undo().unwrap();
1017 +
        assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]);
1018 +
        let s = repo.snapshot("oxy status").unwrap();
1019 +
        assert!(!s.changed, "files on disk still match the pre-commit working copy");
1020 +
        assert_eq!(s.statuses.len(), 3);
1021 +
    }
1022 +
1023 +
    #[test]
1024 +
    fn independent_changes_commute_and_make_multiple_heads() {
1025 +
        let dir = tempfile::tempdir().unwrap();
1026 +
        let root = dir.path();
1027 +
        let repo = Repo::init(root).unwrap();
1028 +
        write(root, "a.txt", b"a\n");
1029 +
        let h1 = repo.commit("a", &[]).unwrap();
1030 +
        write(root, "b.txt", b"b\n");
1031 +
        let h2 = repo.commit("b", &[]).unwrap();
1032 +
        let c2 = repo.store().get_change(&h2).unwrap();
1033 +
        assert!(c2.deps.is_empty(), "b.txt touches nothing from h1");
1034 +
        let mut heads = repo.bookmark_heads().unwrap();
1035 +
        heads.sort();
1036 +
        let mut want = vec![h1, h2];
1037 +
        want.sort();
1038 +
        assert_eq!(heads, want);
1039 +
        // Editing a.txt depends on h1 only.
1040 +
        write(root, "a.txt", b"a\na2\n");
1041 +
        let h3 = repo.commit("a2", &[]).unwrap();
1042 +
        assert_eq!(repo.store().get_change(&h3).unwrap().deps, vec![h1]);
1043 +
        let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect();
1044 +
        assert_eq!(heads, [h2, h3].into_iter().collect());
1045 +
        // Explicit dependency.
1046 +
        write(root, "c.txt", b"c\n");
1047 +
        let h4 = repo.commit("c", &[h2]).unwrap();
1048 +
        assert_eq!(repo.store().get_change(&h4).unwrap().deps, vec![h2]);
1049 +
    }
1050 +
1051 +
    #[test]
1052 +
    fn mode_change_and_text_binary_switch() {
1053 +
        let dir = tempfile::tempdir().unwrap();
1054 +
        let root = dir.path();
1055 +
        let repo = Repo::init(root).unwrap();
1056 +
        write(root, "run.sh", b"#!/bin/sh\n");
1057 +
        repo.commit("add script", &[]).unwrap();
1058 +
        #[cfg(unix)]
1059 +
        {
1060 +
            use std::os::unix::fs::PermissionsExt;
1061 +
            fs::set_permissions(root.join("run.sh"), fs::Permissions::from_mode(0o755)).unwrap();
1062 +
            let s = repo.snapshot("status").unwrap();
1063 +
            assert_eq!(statuses(&s), vec![("run.sh".into(), ChangeStatus::ModeChanged)]);
1064 +
            let h = repo.commit("exec", &[]).unwrap();
1065 +
            let snap = repo.load_state(&[h]).unwrap().materialize();
1066 +
            assert!(snap.files["run.sh"].mode.exec);
1067 +
        }
1068 +
        // Text → binary → text.
1069 +
        write(root, "run.sh", b"\0binary");
1070 +
        let h = repo.commit("binary", &[]).unwrap();
1071 +
        let snap = repo.load_state(&[h]).unwrap().materialize();
1072 +
        assert!(snap.files["run.sh"].mode.opaque);
1073 +
        write(root, "run.sh", b"text again\n");
1074 +
        let h = repo.commit("text", &[]).unwrap();
1075 +
        let snap = repo.load_state(&[h]).unwrap().materialize();
1076 +
        assert!(!snap.files["run.sh"].mode.opaque);
1077 +
        assert!(matches!(&snap.files["run.sh"].content, FileContent::Text { bytes, .. } if bytes == b"text again\n"));
1078 +
        assert!(!snap.is_conflicted());
1079 +
    }
1080 +
}
added crates/oxy-core/src/state.rs +392 −0
1 +
//! A state: a dependency-closed set of changes, applied to a tree graph and
2 +
//! per-node file graphs (spec §8.3), with materialization (spec §9.5, §11).
3 +
4 +
use crate::graph::{AtomKey, DepOracle, FileGraph, Materialized, Vertex};
5 +
use crate::hash::{ChangeHash, ObjectRef};
6 +
use crate::ids::NodeId;
7 +
use crate::objects::{AtomRef, Change, Edit, Mode, NodeKind, TreeOp};
8 +
use crate::tree::{TreeGraph, TreeView};
9 +
use std::cell::RefCell;
10 +
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
11 +
use std::rc::Rc;
12 +
13 +
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
14 +
pub enum StateError {
15 +
    #[error("change {1} depends on {0}, which is not in the state")]
16 +
    MissingDep(ChangeHash, ChangeHash),
17 +
}
18 +
19 +
#[derive(Default)]
20 +
pub struct State {
21 +
    changes: HashMap<ChangeHash, Change>,
22 +
    tree: TreeGraph,
23 +
    files: HashMap<NodeId, FileGraph>,
24 +
    replaces: HashMap<NodeId, Vec<(ChangeHash, Option<ObjectRef>, Option<ObjectRef>)>>,
25 +
    closure: RefCell<HashMap<ChangeHash, Rc<HashSet<ChangeHash>>>>,
26 +
}
27 +
28 +
#[derive(Clone, Debug, PartialEq, Eq)]
29 +
pub enum FileContent {
30 +
    Text { bytes: Vec<u8>, materialized: Materialized },
31 +
    /// Live blobs; more than one is a replace conflict (spec §9.3).
32 +
    Opaque { blobs: Vec<ObjectRef> },
33 +
    Dir,
34 +
}
35 +
36 +
#[derive(Clone, Debug, PartialEq, Eq)]
37 +
pub struct FileState {
38 +
    pub node: NodeId,
39 +
    pub kind: NodeKind,
40 +
    pub mode: Mode,
41 +
    pub content: FileContent,
42 +
}
43 +
44 +
impl FileState {
45 +
    pub fn is_conflicted(&self) -> bool {
46 +
        match &self.content {
47 +
            FileContent::Text { materialized, .. } => materialized.is_conflicted(),
48 +
            FileContent::Opaque { blobs } => blobs.len() > 1,
49 +
            FileContent::Dir => false,
50 +
        }
51 +
    }
52 +
}
53 +
54 +
/// The whole tree of a state, materialized.
55 +
#[derive(Clone, Debug, Default)]
56 +
pub struct Snapshot {
57 +
    pub tree: TreeView,
58 +
    pub files: BTreeMap<String, FileState>,
59 +
}
60 +
61 +
impl Snapshot {
62 +
    pub fn is_conflicted(&self) -> bool {
63 +
        self.tree.is_conflicted() || self.files.values().any(|f| f.is_conflicted())
64 +
    }
65 +
}
66 +
67 +
fn vertex(r: AtomRef) -> Vertex {
68 +
    match r {
69 +
        AtomRef::Start => Vertex::Start,
70 +
        AtomRef::End => Vertex::End,
71 +
        AtomRef::Atom { change, index } => Vertex::Atom((change, index)),
72 +
    }
73 +
}
74 +
75 +
/// Replace self references in a tree op with the concrete change hash.
76 +
pub fn resolve_tree_op(op: &TreeOp, me: ChangeHash) -> TreeOp {
77 +
    match op {
78 +
        TreeOp::RemoveNode { node, placement } => TreeOp::RemoveNode { node: *node, placement: placement.resolve(me) },
79 +
        TreeOp::Move { node, old, parent, name } => {
80 +
            TreeOp::Move { node: *node, old: old.resolve(me), parent: *parent, name: name.clone() }
81 +
        }
82 +
        TreeOp::SetMode { node, old, mode } => TreeOp::SetMode { node: *node, old: old.resolve(me), mode: *mode },
83 +
        add @ TreeOp::AddNode { .. } => add.clone(),
84 +
    }
85 +
}
86 +
87 +
impl State {
88 +
    pub fn new() -> Self {
89 +
        Self::default()
90 +
    }
91 +
92 +
    pub fn len(&self) -> usize {
93 +
        self.changes.len()
94 +
    }
95 +
    pub fn is_empty(&self) -> bool {
96 +
        self.changes.is_empty()
97 +
    }
98 +
    pub fn contains(&self, h: &ChangeHash) -> bool {
99 +
        self.changes.contains_key(h)
100 +
    }
101 +
    pub fn get(&self, h: &ChangeHash) -> Option<&Change> {
102 +
        self.changes.get(h)
103 +
    }
104 +
    pub fn changes(&self) -> impl Iterator<Item = (&ChangeHash, &Change)> {
105 +
        self.changes.iter()
106 +
    }
107 +
    pub fn tree(&self) -> &TreeGraph {
108 +
        &self.tree
109 +
    }
110 +
    pub fn file(&self, node: &NodeId) -> Option<&FileGraph> {
111 +
        self.files.get(node)
112 +
    }
113 +
114 +
    /// Add a change whose dependencies are already present.
115 +
    pub fn add(&mut self, change: Change) -> Result<ChangeHash, StateError> {
116 +
        let h = change.hash();
117 +
        if self.changes.contains_key(&h) {
118 +
            return Ok(h);
119 +
        }
120 +
        for d in &change.deps {
121 +
            if !self.changes.contains_key(d) {
122 +
                return Err(StateError::MissingDep(*d, h));
123 +
            }
124 +
        }
125 +
        Ok(self.add_unchecked(change))
126 +
    }
127 +
128 +
    /// Add a change in any order; call `validate` once all are in.
129 +
    pub fn add_unchecked(&mut self, change: Change) -> ChangeHash {
130 +
        let h = change.hash();
131 +
        if self.changes.contains_key(&h) {
132 +
            return h;
133 +
        }
134 +
        self.apply(h, &change);
135 +
        self.changes.insert(h, change);
136 +
        self.closure.borrow_mut().clear();
137 +
        h
138 +
    }
139 +
140 +
    pub fn validate(&self) -> Result<(), StateError> {
141 +
        for (h, c) in &self.changes {
142 +
            for d in &c.deps {
143 +
                if !self.changes.contains_key(d) {
144 +
                    return Err(StateError::MissingDep(*d, *h));
145 +
                }
146 +
            }
147 +
        }
148 +
        Ok(())
149 +
    }
150 +
151 +
    fn apply(&mut self, h: ChangeHash, c: &Change) {
152 +
        for (i, op) in c.tree_ops.iter().enumerate() {
153 +
            self.tree.apply(h, i as u32, &resolve_tree_op(op, h));
154 +
        }
155 +
        let mut counters: HashMap<NodeId, u32> = HashMap::new();
156 +
        for e in &c.edits {
157 +
            match e {
158 +
                Edit::Insert { node, after, before, lines } => {
159 +
                    let idx = counters.entry(*node).or_default();
160 +
                    let first = *idx;
161 +
                    *idx += lines.len() as u32;
162 +
                    self.files.entry(*node).or_default().insert(h, first, vertex(after.resolve(h)), vertex(before.resolve(h)), lines);
163 +
                }
164 +
                Edit::Delete { node, atoms } => {
165 +
                    let g = self.files.entry(*node).or_default();
166 +
                    for r in atoms {
167 +
                        g.delete(h, &r.resolve(h));
168 +
                    }
169 +
                }
170 +
                Edit::Replace { node, old, new } => {
171 +
                    self.replaces.entry(*node).or_default().push((h, *old, *new));
172 +
                }
173 +
            }
174 +
        }
175 +
    }
176 +
177 +
    /// Transitive dependencies of `h`, excluding `h` itself.
178 +
    pub fn closure(&self, h: &ChangeHash) -> Rc<HashSet<ChangeHash>> {
179 +
        if let Some(c) = self.closure.borrow().get(h) {
180 +
            return c.clone();
181 +
        }
182 +
        let mut out = HashSet::new();
183 +
        let mut stack: Vec<ChangeHash> = self.changes.get(h).map(|c| c.deps.clone()).unwrap_or_default();
184 +
        while let Some(d) = stack.pop() {
185 +
            if out.insert(d) {
186 +
                if let Some(c) = self.changes.get(&d) {
187 +
                    stack.extend(c.deps.iter().copied());
188 +
                }
189 +
            }
190 +
        }
191 +
        let rc = Rc::new(out);
192 +
        self.closure.borrow_mut().insert(*h, rc.clone());
193 +
        rc
194 +
    }
195 +
196 +
    /// Changes that no other change depends on directly, sorted.
197 +
    pub fn heads(&self) -> Vec<ChangeHash> {
198 +
        let depended: HashSet<&ChangeHash> = self.changes.values().flat_map(|c| c.deps.iter()).collect();
199 +
        let mut heads: Vec<ChangeHash> = self.changes.keys().filter(|h| !depended.contains(h)).copied().collect();
200 +
        heads.sort();
201 +
        heads
202 +
    }
203 +
204 +
    /// Maximal elements among `hashes` (those not in the closure of another).
205 +
    pub fn maximal(&self, hashes: &[ChangeHash]) -> Vec<ChangeHash> {
206 +
        let set: BTreeSet<ChangeHash> = hashes.iter().copied().collect();
207 +
        set.iter().filter(|h| !set.iter().any(|o| o != *h && self.closure(o).contains(h))).copied().collect()
208 +
    }
209 +
210 +
    pub fn materialize_file(&self, node: &NodeId) -> Materialized {
211 +
        self.files.get(node).map(|g| g.materialize(self)).unwrap_or_default()
212 +
    }
213 +
214 +
    /// Live lines of a text node in materialized order.
215 +
    pub fn live_lines(&self, node: &NodeId) -> Vec<(AtomKey, Vec<u8>)> {
216 +
        let Some(g) = self.files.get(node) else { return vec![] };
217 +
        let m = g.materialize(self);
218 +
        m.live_atoms().into_iter().map(|a| (a, g.content(&a).unwrap_or_default().to_vec())).collect()
219 +
    }
220 +
221 +
    /// Live blobs of an opaque node: every `new` that no later `Replace` retired.
222 +
    pub fn live_blobs(&self, node: &NodeId) -> Vec<ObjectRef> {
223 +
        let Some(rs) = self.replaces.get(node) else { return vec![] };
224 +
        let olds: HashSet<ObjectRef> = rs.iter().filter_map(|(_, o, _)| *o).collect();
225 +
        let mut live: Vec<ObjectRef> = rs.iter().filter_map(|(_, _, n)| *n).filter(|n| !olds.contains(n)).collect();
226 +
        live.sort();
227 +
        live.dedup();
228 +
        live
229 +
    }
230 +
231 +
    /// Short label for conflict markers: `changeid-prefix "message"`.
232 +
    pub fn label(&self, h: &ChangeHash) -> String {
233 +
        match self.changes.get(h) {
234 +
            Some(c) => format!("{} \"{}\"", c.change_id.short(8), c.message.lines().next().unwrap_or("")),
235 +
            None => format!("#{}", h.short(12)),
236 +
        }
237 +
    }
238 +
239 +
    pub fn render_file(&self, node: &NodeId) -> (Vec<u8>, Materialized) {
240 +
        let Some(g) = self.files.get(node) else { return (Vec::new(), Materialized::default()) };
241 +
        let m = g.materialize(self);
242 +
        let bytes = g.render(&m, &|h| self.label(h));
243 +
        (bytes, m)
244 +
    }
245 +
246 +
    pub fn materialize(&self) -> Snapshot {
247 +
        let tree = self.tree.view();
248 +
        let mut files = BTreeMap::new();
249 +
        for (path, e) in &tree.entries {
250 +
            let content = match e.kind {
251 +
                NodeKind::Dir => FileContent::Dir,
252 +
                NodeKind::Symlink => FileContent::Opaque { blobs: self.live_blobs(&e.node) },
253 +
                NodeKind::File if e.mode.opaque => FileContent::Opaque { blobs: self.live_blobs(&e.node) },
254 +
                NodeKind::File => {
255 +
                    let (bytes, materialized) = self.render_file(&e.node);
256 +
                    FileContent::Text { bytes, materialized }
257 +
                }
258 +
            };
259 +
            files.insert(path.clone(), FileState { node: e.node, kind: e.kind, mode: e.mode, content });
260 +
        }
261 +
        Snapshot { tree, files }
262 +
    }
263 +
}
264 +
265 +
impl DepOracle for State {
266 +
    fn depends_on(&self, a: &ChangeHash, b: &ChangeHash) -> bool {
267 +
        a == b || self.closure(a).contains(b)
268 +
    }
269 +
}
270 +
271 +
#[cfg(test)]
272 +
mod tests {
273 +
    use super::*;
274 +
    use crate::ids::ChangeId;
275 +
    use crate::objects::{AtomRange, ChangeKind, Hlc, OpRef};
276 +
277 +
    fn change(deps: Vec<ChangeHash>, tree_ops: Vec<TreeOp>, edits: Vec<Edit>) -> Change {
278 +
        let mut c = Change {
279 +
            change_id: ChangeId::random(),
280 +
            deps,
281 +
            author: ObjectRef::ZERO,
282 +
            hlc: Hlc { wall_ms: 1, logical: 0 },
283 +
            message: "m".into(),
284 +
            tree_ops,
285 +
            edits,
286 +
            kind: ChangeKind::Normal,
287 +
        };
288 +
        c.normalize();
289 +
        c
290 +
    }
291 +
292 +
    #[test]
293 +
    fn build_edit_and_materialize() {
294 +
        let mut st = State::new();
295 +
        let f = NodeId::random();
296 +
        let c1 = change(
297 +
            vec![],
298 +
            vec![TreeOp::AddNode { node: f, kind: NodeKind::File, mode: Mode::TEXT, parent: NodeId::ROOT, name: "a.txt".into() }],
299 +
            vec![Edit::Insert { node: f, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"one\n".to_vec(), b"two\n".to_vec()] }],
300 +
        );
301 +
        let h1 = st.add(c1).unwrap();
302 +
        // Depends on h1: append a line and delete "one".
303 +
        let c2 = change(
304 +
            vec![h1],
305 +
            vec![],
306 +
            vec![
307 +
                Edit::Delete { node: f, atoms: vec![AtomRange { change: h1, from: 0, to: 1 }] },
308 +
                Edit::Insert { node: f, after: AtomRef::Atom { change: h1, index: 1 }, before: AtomRef::End, lines: vec![b"three\n".to_vec()] },
309 +
            ],
310 +
        );
311 +
        let h2 = st.add(c2).unwrap();
312 +
        assert_eq!(st.heads(), vec![h2]);
313 +
        assert!(st.depends_on(&h2, &h1));
314 +
        assert!(!st.depends_on(&h1, &h2));
315 +
        let snap = st.materialize();
316 +
        assert!(!snap.is_conflicted());
317 +
        let a = &snap.files["a.txt"];
318 +
        assert!(matches!(&a.content, FileContent::Text { bytes, .. } if bytes == b"two\nthree\n"));
319 +
        assert_eq!(st.live_lines(&f).len(), 2);
320 +
    }
321 +
322 +
    #[test]
323 +
    fn missing_dependency_is_rejected() {
324 +
        let mut st = State::new();
325 +
        let c = change(vec![ObjectRef([1; 32])], vec![], vec![]);
326 +
        assert!(matches!(st.add(c), Err(StateError::MissingDep(..))));
327 +
    }
328 +
329 +
    #[test]
330 +
    fn self_references_and_multi_insert_indexing() {
331 +
        let mut st = State::new();
332 +
        let f = NodeId::random();
333 +
        let me = OpRef { change: ObjectRef::ZERO, index: 0 };
334 +
        let c1 = change(
335 +
            vec![],
336 +
            vec![
337 +
                TreeOp::AddNode { node: f, kind: NodeKind::File, mode: Mode::TEXT, parent: NodeId::ROOT, name: "x".into() },
338 +
                TreeOp::SetMode { node: f, old: me, mode: Mode { exec: true, opaque: false } },
339 +
            ],
340 +
            vec![
341 +
                Edit::Insert { node: f, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"a\n".to_vec(), b"c\n".to_vec()] },
342 +
                // Insert between the two lines just inserted, using self references.
343 +
                Edit::Insert {
344 +
                    node: f,
345 +
                    after: AtomRef::Atom { change: ObjectRef::ZERO, index: 0 },
346 +
                    before: AtomRef::Atom { change: ObjectRef::ZERO, index: 1 },
347 +
                    lines: vec![b"b\n".to_vec()],
348 +
                },
349 +
            ],
350 +
        );
351 +
        st.add(c1).unwrap();
352 +
        let snap = st.materialize();
353 +
        let x = &snap.files["x"];
354 +
        assert_eq!(x.mode, Mode { exec: true, opaque: false });
355 +
        assert!(matches!(&x.content, FileContent::Text { bytes, .. } if bytes == b"a\nb\nc\n"));
356 +
    }
357 +
358 +
    #[test]
359 +
    fn opaque_replace_and_conflict() {
360 +
        let mut st = State::new();
361 +
        let f = NodeId::random();
362 +
        let b1 = ObjectRef([1; 32]);
363 +
        let b2 = ObjectRef([2; 32]);
364 +
        let b3 = ObjectRef([3; 32]);
365 +
        let h1 = st.add(change(
366 +
            vec![],
367 +
            vec![TreeOp::AddNode { node: f, kind: NodeKind::File, mode: Mode::OPAQUE, parent: NodeId::ROOT, name: "img".into() }],
368 +
            vec![Edit::Replace { node: f, old: None, new: Some(b1) }],
369 +
        ))
370 +
        .unwrap();
371 +
        assert_eq!(st.live_blobs(&f), vec![b1]);
372 +
        st.add(change(vec![h1], vec![], vec![Edit::Replace { node: f, old: Some(b1), new: Some(b2) }])).unwrap();
373 +
        st.add(change(vec![h1], vec![], vec![Edit::Replace { node: f, old: Some(b1), new: Some(b3) }])).unwrap();
374 +
        let snap = st.materialize();
375 +
        assert!(snap.is_conflicted());
376 +
        assert!(matches!(&snap.files["img"].content, FileContent::Opaque { blobs } if blobs == &vec![b2, b3]));
377 +
    }
378 +
379 +
    #[test]
380 +
    fn maximal_elements() {
381 +
        let mut st = State::new();
382 +
        let h1 = st.add(change(vec![], vec![], vec![])).unwrap();
383 +
        let h2 = st.add(change(vec![h1], vec![], vec![])).unwrap();
384 +
        let h3 = st.add(change(vec![], vec![], vec![])).unwrap();
385 +
        let mut m = st.maximal(&[h1, h2, h3]);
386 +
        m.sort();
387 +
        let mut want = vec![h2, h3];
388 +
        want.sort();
389 +
        assert_eq!(m, want);
390 +
        assert_eq!(st.heads(), want);
391 +
    }
392 +
}
added crates/oxy-core/src/store.rs +177 −0
1 +
//! Loose object store (spec §13.1): one file per object under
2 +
//! `objects/loose/<hh>/<rest>`, written atomically and verified on read.
3 +
//! Packs come later.
4 +
5 +
use crate::hash::{hash_object, header, ObjectRef, ObjectType};
6 +
use crate::objects::{Change, ObjectError};
7 +
use rand::RngCore;
8 +
use std::fs;
9 +
use std::io::{self, Write};
10 +
use std::path::{Path, PathBuf};
11 +
12 +
#[derive(Debug, thiserror::Error)]
13 +
pub enum StoreError {
14 +
    #[error("io: {0}")]
15 +
    Io(#[from] io::Error),
16 +
    #[error("corrupt object {0}: {1}")]
17 +
    Corrupt(ObjectRef, String),
18 +
    #[error("object {0}")]
19 +
    Object(#[from] ObjectError),
20 +
    #[error("object {0} is a {1}, expected {2}")]
21 +
    WrongType(ObjectRef, &'static str, &'static str),
22 +
    #[error("object {0} not found")]
23 +
    NotFound(ObjectRef),
24 +
}
25 +
26 +
pub type Result<T> = std::result::Result<T, StoreError>;
27 +
28 +
#[derive(Debug, Clone)]
29 +
pub struct Store {
30 +
    dir: PathBuf,
31 +
}
32 +
33 +
impl Store {
34 +
    pub fn open(dir: impl Into<PathBuf>) -> io::Result<Store> {
35 +
        let dir = dir.into();
36 +
        fs::create_dir_all(dir.join("loose"))?;
37 +
        Ok(Store { dir })
38 +
    }
39 +
40 +
    pub fn dir(&self) -> &Path {
41 +
        &self.dir
42 +
    }
43 +
44 +
    fn path_for(&self, r: &ObjectRef) -> PathBuf {
45 +
        let h = r.to_hex();
46 +
        self.dir.join("loose").join(&h[..2]).join(&h[2..])
47 +
    }
48 +
49 +
    pub fn has(&self, r: &ObjectRef) -> bool {
50 +
        self.path_for(r).is_file()
51 +
    }
52 +
53 +
    /// Store `body` as an object of type `ty`; idempotent.
54 +
    pub fn put(&self, ty: ObjectType, body: &[u8]) -> Result<ObjectRef> {
55 +
        let r = hash_object(ty, body);
56 +
        let path = self.path_for(&r);
57 +
        if path.is_file() {
58 +
            return Ok(r);
59 +
        }
60 +
        fs::create_dir_all(path.parent().unwrap())?;
61 +
        let mut nonce = [0u8; 8];
62 +
        rand::rngs::OsRng.fill_bytes(&mut nonce);
63 +
        let tmp = self.dir.join("loose").join(format!("tmp-{}", hex::encode(nonce)));
64 +
        {
65 +
            let mut f = fs::File::create(&tmp)?;
66 +
            f.write_all(&header(ty, body.len()))?;
67 +
            f.write_all(body)?;
68 +
            f.sync_all()?;
69 +
        }
70 +
        fs::rename(&tmp, &path)?;
71 +
        Ok(r)
72 +
    }
73 +
74 +
    /// Read and verify an object.
75 +
    pub fn get(&self, r: &ObjectRef) -> Result<(ObjectType, Vec<u8>)> {
76 +
        let path = self.path_for(r);
77 +
        let data = match fs::read(&path) {
78 +
            Ok(d) => d,
79 +
            Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(StoreError::NotFound(*r)),
80 +
            Err(e) => return Err(e.into()),
81 +
        };
82 +
        let nul = data.iter().position(|&b| b == 0).ok_or_else(|| StoreError::Corrupt(*r, "no header".into()))?;
83 +
        let head = std::str::from_utf8(&data[..nul]).map_err(|_| StoreError::Corrupt(*r, "header not UTF-8".into()))?;
84 +
        let mut parts = head.split(' ');
85 +
        let (Some("oxy1"), Some(ty), Some(len), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else {
86 +
            return Err(StoreError::Corrupt(*r, format!("bad header {head:?}")));
87 +
        };
88 +
        let ty = ObjectType::from_name(ty).ok_or_else(|| StoreError::Corrupt(*r, format!("unknown type {ty}")))?;
89 +
        let len: usize = len.parse().map_err(|_| StoreError::Corrupt(*r, "bad length".into()))?;
90 +
        let body = &data[nul + 1..];
91 +
        if body.len() != len {
92 +
            return Err(StoreError::Corrupt(*r, format!("length {} != header {len}", body.len())));
93 +
        }
94 +
        if hash_object(ty, body) != *r {
95 +
            return Err(StoreError::Corrupt(*r, "hash mismatch".into()));
96 +
        }
97 +
        Ok((ty, body.to_vec()))
98 +
    }
99 +
100 +
    pub fn get_typed(&self, r: &ObjectRef, want: ObjectType) -> Result<Vec<u8>> {
101 +
        let (ty, body) = self.get(r)?;
102 +
        if ty != want {
103 +
            return Err(StoreError::WrongType(*r, ty.name(), want.name()));
104 +
        }
105 +
        Ok(body)
106 +
    }
107 +
108 +
    pub fn put_change(&self, c: &Change) -> Result<ObjectRef> {
109 +
        self.put(ObjectType::Change, &c.encode())
110 +
    }
111 +
112 +
    pub fn get_change(&self, r: &ObjectRef) -> Result<Change> {
113 +
        Ok(Change::decode(&self.get_typed(r, ObjectType::Change)?)?)
114 +
    }
115 +
116 +
    pub fn put_chunk(&self, bytes: &[u8]) -> Result<ObjectRef> {
117 +
        self.put(ObjectType::Chunk, bytes)
118 +
    }
119 +
120 +
    /// Every object reference in the store (slow; for gc and tests).
121 +
    pub fn list(&self) -> io::Result<Vec<ObjectRef>> {
122 +
        let mut out = Vec::new();
123 +
        let loose = self.dir.join("loose");
124 +
        for d in fs::read_dir(&loose)? {
125 +
            let d = d?;
126 +
            if !d.file_type()?.is_dir() {
127 +
                continue;
128 +
            }
129 +
            let prefix = d.file_name().to_string_lossy().to_string();
130 +
            for f in fs::read_dir(d.path())? {
131 +
                let f = f?;
132 +
                let name = f.file_name().to_string_lossy().to_string();
133 +
                if let Some(r) = ObjectRef::from_hex(&format!("{prefix}{name}")) {
134 +
                    out.push(r);
135 +
                }
136 +
            }
137 +
        }
138 +
        out.sort();
139 +
        Ok(out)
140 +
    }
141 +
}
142 +
143 +
#[cfg(test)]
144 +
mod tests {
145 +
    use super::*;
146 +
147 +
    #[test]
148 +
    fn put_get_verify() {
149 +
        let dir = tempfile::tempdir().unwrap();
150 +
        let s = Store::open(dir.path().join("objects")).unwrap();
151 +
        let r = s.put(ObjectType::Chunk, b"hello").unwrap();
152 +
        assert!(s.has(&r));
153 +
        assert_eq!(s.get(&r).unwrap(), (ObjectType::Chunk, b"hello".to_vec()));
154 +
        assert_eq!(s.put(ObjectType::Chunk, b"hello").unwrap(), r);
155 +
        assert_eq!(s.list().unwrap(), vec![r]);
156 +
        assert!(matches!(s.get_typed(&r, ObjectType::Blob), Err(StoreError::WrongType(..))));
157 +
        assert!(matches!(s.get(&ObjectRef([9; 32])), Err(StoreError::NotFound(_))));
158 +
159 +
        // Tamper: flip a byte, read must fail.
160 +
        let path = s.path_for(&r);
161 +
        let mut data = fs::read(&path).unwrap();
162 +
        let last = data.len() - 1;
163 +
        data[last] ^= 1;
164 +
        fs::write(&path, data).unwrap();
165 +
        assert!(matches!(s.get(&r), Err(StoreError::Corrupt(..))));
166 +
    }
167 +
168 +
    #[test]
169 +
    fn change_round_trip_through_store() {
170 +
        let dir = tempfile::tempdir().unwrap();
171 +
        let s = Store::open(dir.path()).unwrap();
172 +
        let c = crate::objects::tests::sample_change();
173 +
        let r = s.put_change(&c).unwrap();
174 +
        assert_eq!(r, c.hash());
175 +
        assert_eq!(s.get_change(&r).unwrap(), c);
176 +
    }
177 +
}
added crates/oxy-core/src/tree.rs +372 −0
1 +
//! The tree graph: nodes, placements, and modes as atoms (spec §7.1, §7.2),
2 +
//! with tree conflict detection and path assignment (spec §9.2).
3 +
4 +
use crate::hash::ChangeHash;
5 +
use crate::ids::NodeId;
6 +
use crate::objects::{Mode, NodeKind, OpRef, TreeOp};
7 +
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
8 +
9 +
/// `(change, index into that change's tree_ops)`.
10 +
pub type OpKey = (ChangeHash, u32);
11 +
12 +
#[derive(Clone, Debug, PartialEq, Eq)]
13 +
pub struct Placement {
14 +
    pub node: NodeId,
15 +
    pub parent: NodeId,
16 +
    pub name: String,
17 +
}
18 +
19 +
#[derive(Clone, Debug, Default)]
20 +
pub struct TreeGraph {
21 +
    kinds: HashMap<NodeId, NodeKind>,
22 +
    introduced: HashMap<NodeId, ChangeHash>,
23 +
    placements: HashMap<OpKey, Placement>,
24 +
    modes: HashMap<OpKey, (NodeId, Mode)>,
25 +
    /// Placement atoms retired by `RemoveNode`/`Move`, and mode atoms retired
26 +
    /// by `SetMode`. Kept apart because `AddNode` introduces one of each
27 +
    /// under the same key.
28 +
    deleted_placements: HashMap<OpKey, BTreeSet<ChangeHash>>,
29 +
    deleted_modes: HashMap<OpKey, BTreeSet<ChangeHash>>,
30 +
    placements_of: HashMap<NodeId, BTreeSet<OpKey>>,
31 +
    modes_of: HashMap<NodeId, BTreeSet<OpKey>>,
32 +
}
33 +
34 +
#[derive(Clone, Debug, PartialEq, Eq)]
35 +
pub enum TreeConflict {
36 +
    /// Two live nodes share `(parent, name)`.
37 +
    Name { parent: NodeId, name: String, nodes: Vec<NodeId> },
38 +
    /// One node has several live placements.
39 +
    MultiPlacement { node: NodeId, placements: Vec<OpKey> },
40 +
    /// A live node's parent is not live.
41 +
    Orphan { node: NodeId, parent: NodeId },
42 +
    /// A directory is placed inside its own descendant.
43 +
    Cycle { node: NodeId },
44 +
    /// Several live mode atoms.
45 +
    Mode { node: NodeId, modes: Vec<Mode> },
46 +
}
47 +
48 +
/// A node as it appears in the working copy.
49 +
#[derive(Clone, Debug, PartialEq, Eq)]
50 +
pub struct TreeEntry {
51 +
    pub path: String,
52 +
    pub node: NodeId,
53 +
    pub kind: NodeKind,
54 +
    pub mode: Mode,
55 +
    /// The placement this entry materializes (for building `RemoveNode`/`Move`).
56 +
    pub placement: OpKey,
57 +
    /// The live mode atom (for building `SetMode`).
58 +
    pub mode_atom: Option<OpKey>,
59 +
}
60 +
61 +
#[derive(Clone, Debug, Default, PartialEq, Eq)]
62 +
pub struct TreeView {
63 +
    /// Path → entry, paths use `/` and have no leading slash; conflicted
64 +
    /// entries carry a `.oxy-conflict-<n>` suffix.
65 +
    pub entries: BTreeMap<String, TreeEntry>,
66 +
    pub conflicts: Vec<TreeConflict>,
67 +
}
68 +
69 +
impl TreeView {
70 +
    pub fn is_conflicted(&self) -> bool {
71 +
        !self.conflicts.is_empty()
72 +
    }
73 +
    pub fn by_node(&self) -> HashMap<NodeId, &TreeEntry> {
74 +
        self.entries.values().map(|e| (e.node, e)).collect()
75 +
    }
76 +
}
77 +
78 +
impl TreeGraph {
79 +
    pub fn new() -> Self {
80 +
        Self::default()
81 +
    }
82 +
83 +
    /// Apply tree op `index` of `change`; `OpRef` self references must already be resolved.
84 +
    pub fn apply(&mut self, change: ChangeHash, index: u32, op: &TreeOp) {
85 +
        let key = (change, index);
86 +
        match op {
87 +
            TreeOp::AddNode { node, kind, mode, parent, name } => {
88 +
                self.kinds.insert(*node, *kind);
89 +
                self.introduced.insert(*node, change);
90 +
                self.placements.insert(key, Placement { node: *node, parent: *parent, name: name.clone() });
91 +
                self.placements_of.entry(*node).or_default().insert(key);
92 +
                if *kind != NodeKind::Dir {
93 +
                    self.modes.insert(key, (*node, *mode));
94 +
                    self.modes_of.entry(*node).or_default().insert(key);
95 +
                }
96 +
            }
97 +
            TreeOp::RemoveNode { placement, .. } => {
98 +
                self.deleted_placements.entry(op_key(*placement)).or_default().insert(change);
99 +
            }
100 +
            TreeOp::Move { node, old, parent, name } => {
101 +
                self.deleted_placements.entry(op_key(*old)).or_default().insert(change);
102 +
                self.placements.insert(key, Placement { node: *node, parent: *parent, name: name.clone() });
103 +
                self.placements_of.entry(*node).or_default().insert(key);
104 +
            }
105 +
            TreeOp::SetMode { node, old, mode } => {
106 +
                self.deleted_modes.entry(op_key(*old)).or_default().insert(change);
107 +
                self.modes.insert(key, (*node, *mode));
108 +
                self.modes_of.entry(*node).or_default().insert(key);
109 +
            }
110 +
        }
111 +
    }
112 +
113 +
    fn placement_live(&self, k: &OpKey) -> bool {
114 +
        self.deleted_placements.get(k).is_none_or(|d| d.is_empty())
115 +
    }
116 +
    fn mode_live(&self, k: &OpKey) -> bool {
117 +
        self.deleted_modes.get(k).is_none_or(|d| d.is_empty())
118 +
    }
119 +
120 +
    pub fn kind(&self, node: &NodeId) -> Option<NodeKind> {
121 +
        if node.is_root() { Some(NodeKind::Dir) } else { self.kinds.get(node).copied() }
122 +
    }
123 +
124 +
    /// The change whose `AddNode` introduced `node`.
125 +
    pub fn introducer(&self, node: &NodeId) -> Option<ChangeHash> {
126 +
        self.introduced.get(node).copied()
127 +
    }
128 +
129 +
    pub fn placement(&self, k: &OpKey) -> Option<&Placement> {
130 +
        self.placements.get(k)
131 +
    }
132 +
133 +
    pub fn live_placements(&self, node: &NodeId) -> Vec<(OpKey, &Placement)> {
134 +
        self.placements_of
135 +
            .get(node)
136 +
            .into_iter()
137 +
            .flat_map(|ks| ks.iter())
138 +
            .filter(|k| self.placement_live(k))
139 +
            .filter_map(|k| self.placements.get(k).map(|p| (*k, p)))
140 +
            .collect()
141 +
    }
142 +
143 +
    pub fn live_modes(&self, node: &NodeId) -> Vec<(OpKey, Mode)> {
144 +
        self.modes_of
145 +
            .get(node)
146 +
            .into_iter()
147 +
            .flat_map(|ks| ks.iter())
148 +
            .filter(|k| self.mode_live(k))
149 +
            .filter_map(|k| self.modes.get(k).map(|(_, m)| (*k, *m)))
150 +
            .collect()
151 +
    }
152 +
153 +
    /// Every node that has at least one live placement.
154 +
    pub fn placed_nodes(&self) -> impl Iterator<Item = NodeId> + '_ {
155 +
        self.placements_of.keys().copied().filter(|n| !self.live_placements(n).is_empty())
156 +
    }
157 +
158 +
    /// Resolve the tree: assign paths, find conflicts.
159 +
    pub fn view(&self) -> TreeView {
160 +
        let mut view = TreeView::default();
161 +
162 +
        // Live placements grouped by parent.
163 +
        let mut children: HashMap<NodeId, Vec<(OpKey, &Placement)>> = HashMap::new();
164 +
        for (k, p) in &self.placements {
165 +
            if self.placement_live(k) {
166 +
                children.entry(p.parent).or_default().push((*k, p));
167 +
            }
168 +
        }
169 +
        for c in children.values_mut() {
170 +
            c.sort_by(|a, b| a.1.name.cmp(&b.1.name).then(a.0.cmp(&b.0)));
171 +
        }
172 +
173 +
        // Multi-placement and mode conflicts.
174 +
        let mut nodes: Vec<NodeId> = self.placements_of.keys().copied().collect();
175 +
        nodes.sort();
176 +
        let mut multi: HashSet<NodeId> = HashSet::new();
177 +
        for n in &nodes {
178 +
            let lp = self.live_placements(n);
179 +
            let distinct_places: BTreeSet<(&NodeId, &str)> = lp.iter().map(|(_, p)| (&p.parent, p.name.as_str())).collect();
180 +
            if distinct_places.len() > 1 {
181 +
                multi.insert(*n);
182 +
                view.conflicts.push(TreeConflict::MultiPlacement { node: *n, placements: lp.iter().map(|(k, _)| *k).collect() });
183 +
            }
184 +
            let lm = self.live_modes(n);
185 +
            let distinct_modes: BTreeSet<Mode> = lm.iter().map(|(_, m)| *m).collect();
186 +
            if distinct_modes.len() > 1 {
187 +
                view.conflicts.push(TreeConflict::Mode { node: *n, modes: distinct_modes.into_iter().collect() });
188 +
            }
189 +
        }
190 +
191 +
        // BFS from the root, assigning paths.
192 +
        let mut reached: HashSet<NodeId> = HashSet::new();
193 +
        reached.insert(NodeId::ROOT);
194 +
        let mut queue: Vec<(NodeId, String)> = vec![(NodeId::ROOT, String::new())];
195 +
        let mut on_path: HashSet<NodeId> = HashSet::new();
196 +
        while let Some((dir, dir_path)) = queue.pop() {
197 +
            let Some(kids) = children.get(&dir) else { continue };
198 +
            // Name conflicts within this directory.
199 +
            let mut by_name: BTreeMap<&str, Vec<(OpKey, &Placement)>> = BTreeMap::new();
200 +
            for (k, p) in kids {
201 +
                by_name.entry(p.name.as_str()).or_default().push((*k, p));
202 +
            }
203 +
            for (name, group) in by_name {
204 +
                let distinct: BTreeSet<NodeId> = group.iter().map(|(_, p)| p.node).collect();
205 +
                if distinct.len() > 1 {
206 +
                    view.conflicts.push(TreeConflict::Name { parent: dir, name: name.to_string(), nodes: distinct.iter().copied().collect() });
207 +
                }
208 +
                let suffixed = distinct.len() > 1;
209 +
                for (i, (k, p)) in group.iter().enumerate() {
210 +
                    let node = p.node;
211 +
                    if node == dir || on_path.contains(&node) {
212 +
                        view.conflicts.push(TreeConflict::Cycle { node });
213 +
                        continue;
214 +
                    }
215 +
                    let needs_suffix = suffixed || multi.contains(&node);
216 +
                    let leaf = if needs_suffix { format!("{name}.oxy-conflict-{}", i + 1) } else { name.to_string() };
217 +
                    let path = if dir_path.is_empty() { leaf } else { format!("{dir_path}/{leaf}") };
218 +
                    let kind = self.kind(&node).unwrap_or(NodeKind::File);
219 +
                    let modes = self.live_modes(&node);
220 +
                    let mode_atom = modes.iter().map(|(k, _)| *k).max();
221 +
                    let mode = mode_atom.and_then(|k| self.modes.get(&k).map(|(_, m)| *m)).unwrap_or_default();
222 +
                    view.entries.insert(path.clone(), TreeEntry { path: path.clone(), node, kind, mode, placement: *k, mode_atom });
223 +
                    if kind == NodeKind::Dir && reached.insert(node) {
224 +
                        on_path.insert(dir);
225 +
                        queue.push((node, path));
226 +
                    } else {
227 +
                        reached.insert(node);
228 +
                    }
229 +
                }
230 +
            }
231 +
        }
232 +
233 +
        // Nodes with live placements that were never reached: orphans or cycles.
234 +
        for n in &nodes {
235 +
            if reached.contains(n) {
236 +
                continue;
237 +
            }
238 +
            let lp = self.live_placements(n);
239 +
            if lp.is_empty() {
240 +
                continue;
241 +
            }
242 +
            let mut seen = HashSet::new();
243 +
            let mut cur = *n;
244 +
            let conflict = loop {
245 +
                if !seen.insert(cur) {
246 +
                    break TreeConflict::Cycle { node: *n };
247 +
                }
248 +
                let lp = self.live_placements(&cur);
249 +
                let Some((_, p)) = lp.first() else {
250 +
                    break TreeConflict::Orphan { node: *n, parent: cur };
251 +
                };
252 +
                if reached.contains(&p.parent) {
253 +
                    // Parent reached but this node was not: the parent is not a
254 +
                    // directory, so this is a name/kind problem surfaced as orphan.
255 +
                    break TreeConflict::Orphan { node: *n, parent: p.parent };
256 +
                }
257 +
                cur = p.parent;
258 +
            };
259 +
            if !view.conflicts.contains(&conflict) {
260 +
                view.conflicts.push(conflict);
261 +
            }
262 +
        }
263 +
        view
264 +
    }
265 +
}
266 +
267 +
pub fn op_key(r: OpRef) -> OpKey {
268 +
    (r.change, r.index)
269 +
}
270 +
271 +
#[cfg(test)]
272 +
mod tests {
273 +
    use super::*;
274 +
    use crate::hash::ObjectRef;
275 +
276 +
    fn h(n: u8) -> ChangeHash {
277 +
        ObjectRef([n; 32])
278 +
    }
279 +
    fn nid(n: u8) -> NodeId {
280 +
        NodeId([n; 16])
281 +
    }
282 +
    fn add(g: &mut TreeGraph, c: u8, i: u32, node: u8, kind: NodeKind, parent: NodeId, name: &str) {
283 +
        g.apply(h(c), i, &TreeOp::AddNode { node: nid(node), kind, mode: Mode::TEXT, parent, name: name.into() });
284 +
    }
285 +
    fn paths(v: &TreeView) -> Vec<String> {
286 +
        v.entries.keys().cloned().collect()
287 +
    }
288 +
289 +
    #[test]
290 +
    fn nested_paths_and_rename() {
291 +
        let mut g = TreeGraph::new();
292 +
        add(&mut g, 1, 0, 1, NodeKind::Dir, NodeId::ROOT, "src");
293 +
        add(&mut g, 1, 1, 2, NodeKind::File, nid(1), "main.rs");
294 +
        add(&mut g, 1, 2, 3, NodeKind::File, NodeId::ROOT, "README");
295 +
        let v = g.view();
296 +
        assert_eq!(paths(&v), ["README", "src", "src/main.rs"]);
297 +
        assert!(!v.is_conflicted());
298 +
299 +
        // Rename src/main.rs → src/lib.rs, move src → lib (subtree follows).
300 +
        g.apply(h(2), 0, &TreeOp::Move { node: nid(2), old: OpRef { change: h(1), index: 1 }, parent: nid(1), name: "lib.rs".into() });
301 +
        g.apply(h(2), 1, &TreeOp::Move { node: nid(1), old: OpRef { change: h(1), index: 0 }, parent: NodeId::ROOT, name: "lib".into() });
302 +
        let v = g.view();
303 +
        assert_eq!(paths(&v), ["README", "lib", "lib/lib.rs"]);
304 +
        assert_eq!(v.entries["lib/lib.rs"].placement, (h(2), 0));
305 +
306 +
        g.apply(h(3), 0, &TreeOp::RemoveNode { node: nid(3), placement: OpRef { change: h(1), index: 2 } });
307 +
        assert_eq!(paths(&g.view()), ["lib", "lib/lib.rs"]);
308 +
    }
309 +
310 +
    #[test]
311 +
    fn name_conflict_gets_suffixes() {
312 +
        let mut g = TreeGraph::new();
313 +
        add(&mut g, 1, 0, 1, NodeKind::File, NodeId::ROOT, "a.txt");
314 +
        add(&mut g, 2, 0, 2, NodeKind::File, NodeId::ROOT, "a.txt");
315 +
        let v = g.view();
316 +
        assert_eq!(paths(&v), ["a.txt.oxy-conflict-1", "a.txt.oxy-conflict-2"]);
317 +
        assert_eq!(v.conflicts, vec![TreeConflict::Name { parent: NodeId::ROOT, name: "a.txt".into(), nodes: vec![nid(1), nid(2)] }]);
318 +
    }
319 +
320 +
    #[test]
321 +
    fn concurrent_renames_are_multi_placement() {
322 +
        let mut g = TreeGraph::new();
323 +
        add(&mut g, 1, 0, 1, NodeKind::File, NodeId::ROOT, "a");
324 +
        g.apply(h(2), 0, &TreeOp::Move { node: nid(1), old: OpRef { change: h(1), index: 0 }, parent: NodeId::ROOT, name: "b".into() });
325 +
        g.apply(h(3), 0, &TreeOp::Move { node: nid(1), old: OpRef { change: h(1), index: 0 }, parent: NodeId::ROOT, name: "c".into() });
326 +
        let v = g.view();
327 +
        assert_eq!(paths(&v), ["b.oxy-conflict-1", "c.oxy-conflict-1"]);
328 +
        assert!(matches!(v.conflicts[0], TreeConflict::MultiPlacement { node, .. } if node == nid(1)));
329 +
    }
330 +
331 +
    #[test]
332 +
    fn orphan_and_cycle() {
333 +
        let mut g = TreeGraph::new();
334 +
        add(&mut g, 1, 0, 1, NodeKind::Dir, NodeId::ROOT, "d");
335 +
        add(&mut g, 1, 1, 2, NodeKind::File, nid(1), "f");
336 +
        // Concurrently: 2 removes d, 3 adds d/g.
337 +
        g.apply(h(2), 0, &TreeOp::RemoveNode { node: nid(1), placement: OpRef { change: h(1), index: 0 } });
338 +
        add(&mut g, 3, 0, 3, NodeKind::File, nid(1), "g");
339 +
        let v = g.view();
340 +
        assert_eq!(paths(&v), Vec::<String>::new());
341 +
        assert!(v.conflicts.contains(&TreeConflict::Orphan { node: nid(2), parent: nid(1) }));
342 +
        assert!(v.conflicts.contains(&TreeConflict::Orphan { node: nid(3), parent: nid(1) }));
343 +
344 +
        // Cycle: a/b, then concurrently move a under b and b under a's parent... simplest: move a into b.
345 +
        let mut g = TreeGraph::new();
346 +
        add(&mut g, 1, 0, 1, NodeKind::Dir, NodeId::ROOT, "a");
347 +
        add(&mut g, 1, 1, 2, NodeKind::Dir, nid(1), "b");
348 +
        g.apply(h(2), 0, &TreeOp::Move { node: nid(1), old: OpRef { change: h(1), index: 0 }, parent: nid(2), name: "a".into() });
349 +
        let v = g.view();
350 +
        assert!(v.conflicts.iter().any(|c| matches!(c, TreeConflict::Cycle { .. })), "{:?}", v.conflicts);
351 +
    }
352 +
353 +
    #[test]
354 +
    fn mode_conflict_and_resolution() {
355 +
        let mut g = TreeGraph::new();
356 +
        add(&mut g, 1, 0, 1, NodeKind::File, NodeId::ROOT, "run.sh");
357 +
        g.apply(h(2), 0, &TreeOp::SetMode { node: nid(1), old: OpRef { change: h(1), index: 0 }, mode: Mode { exec: true, opaque: false } });
358 +
        g.apply(h(3), 0, &TreeOp::SetMode { node: nid(1), old: OpRef { change: h(1), index: 0 }, mode: Mode { exec: false, opaque: true } });
359 +
        let v = g.view();
360 +
        assert!(matches!(v.conflicts[0], TreeConflict::Mode { .. }));
361 +
        // Materialized with the greatest mode atom.
362 +
        assert_eq!(v.entries["run.sh"].mode, Mode { exec: false, opaque: true });
363 +
        // The placement introduced by the same AddNode is unaffected.
364 +
        assert_eq!(v.entries["run.sh"].placement, (h(1), 0));
365 +
        // Resolution: retire both live mode atoms; identical live values do not conflict.
366 +
        g.apply(h(4), 0, &TreeOp::SetMode { node: nid(1), old: OpRef { change: h(2), index: 0 }, mode: Mode { exec: true, opaque: false } });
367 +
        g.apply(h(4), 1, &TreeOp::SetMode { node: nid(1), old: OpRef { change: h(3), index: 0 }, mode: Mode { exec: true, opaque: false } });
368 +
        let v = g.view();
369 +
        assert!(!v.is_conflicted(), "{:?}", v.conflicts);
370 +
        assert_eq!(v.entries["run.sh"].mode, Mode { exec: true, opaque: false });
371 +
    }
372 +
}
added docs/spec.md +776 −0
1 +
# Oxygen (`oxy`) — Version Control System Specification
2 +
3 +
| | |
4 +
|---|---|
5 +
| **Status** | Draft 0.4 |
6 +
| **Format version** | 1 |
7 +
| **Date** | 2026-09-26 |
8 +
| **Audience** | Implementers, security reviewers, and contributors |
9 +
10 +
The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as described in RFC 2119.
11 +
12 +
---
13 +
14 +
## Table of contents
15 +
16 +
1. [Overview](#1-overview)
17 +
2. [Goals, non-goals, and lineage](#2-goals-non-goals-and-lineage)
18 +
3. [Threat model](#3-threat-model)
19 +
4. [Terminology](#4-terminology)
20 +
5. [Object model and hashing](#5-object-model-and-hashing)
21 +
6. [The change graph](#6-the-change-graph)
22 +
7. [Files, trees, and atoms](#7-files-trees-and-atoms)
23 +
8. [Dependencies and commutation](#8-dependencies-and-commutation)
24 +
9. [Conflicts](#9-conflicts)
25 +
10. [States, bookmarks, and tags](#10-states-bookmarks-and-tags)
26 +
11. [Checkpoints](#11-checkpoints)
27 +
12. [Operation log, workspaces, and the working copy](#12-operation-log-workspaces-and-the-working-copy)
28 +
13. [Storage engine](#13-storage-engine)
29 +
14. [Identity, signatures, and trust policy](#14-identity-signatures-and-trust-policy)
30 +
15. [Transport](#15-transport)
31 +
16. [Wire protocol](#16-wire-protocol)
32 +
17. [Partial, sparse, and shallow repositories](#17-partial-sparse-and-shallow-repositories)
33 +
18. [Locks for unmergeable files](#18-locks-for-unmergeable-files)
34 +
19. [Repository configuration files](#19-repository-configuration-files)
35 +
20. [Hooks and server policy](#20-hooks-and-server-policy)
36 +
21. [Garbage collection](#21-garbage-collection)
37 +
22. [Client profiles and retro hardware](#22-client-profiles-and-retro-hardware)
38 +
23. [Git interoperability](#23-git-interoperability)
39 +
24. [Command-line interface](#24-command-line-interface)
40 +
25. [Performance targets](#25-performance-targets)
41 +
26. [Conformance and test vectors](#26-conformance-and-test-vectors)
42 +
27. [Open questions](#27-open-questions)
43 +
44 +
---
45 +
46 +
## 1. Overview
47 +
48 +
Oxygen (`oxy`) is an open-source, distributed version control system written in Rust. It is built for multi-gigabyte repositories, monorepos, and high-frequency development loops, and it defines a reduced client profile so that older and low-specification machines, which current version control systems exclude, can still take part.
49 +
50 +
Its distinguishing design choices are:
51 +
52 +
- **History is a set of changes, not a sequence of snapshots.** A change records what it did to identified lines and tree nodes, and which earlier changes it depends on. Changes that do not depend on each other commute: applying them in any order yields the same result.
53 +
- **Every change has two identifiers.** A random, stable *change ID* that survives rewriting, and a content-addressed *change hash* that makes history tamper-evident. Neither is optional.
54 +
- **Conflicts are data, not errors.** A conflicted state can be committed, pushed, fetched, and built on. Resolution is itself a change.
55 +
- **Every mutation of repository state is logged and undoable** through an operation log.
56 +
- **Content is stored in a content-defined-chunked, BLAKE3-addressed store**, so large files and large histories deduplicate naturally and can be fetched lazily.
57 +
- **Every change and every reference update is authenticated** with composite Ed25519 + ML-DSA-65 signatures, either on the change itself or on a push-time seal covering it, and verification is driven by an in-repository trust policy.
58 +
- **Transport is pluggable.** HTTPS is mandatory for every server, Noise is available for peer-to-peer use, and a plaintext transport exists for constrained clients on trusted networks, with integrity provided end to end by the object model rather than by the channel.
59 +
60 +
## 2. Goals, non-goals, and lineage
61 +
62 +
### 2.1 Goals
63 +
64 +
- Correct, deterministic merges of concurrent work without merge commits for independent changes.
65 +
- Stable identifiers for human and tooling use across rebase, amend, and reorder.
66 +
- Tamper-evident history and verifiable authorship, robust against future large quantum computers.
67 +
- Repositories of tens of gigabytes with millions of files, with `status` and `commit` latency independent of repository size.
68 +
- Partial clone by path, depth, and blob size as a first-class mode, not an afterthought.
69 +
- Interoperability with Git repositories in both directions.
70 +
- A documented Thin client profile for machines with a 68000-class CPU and 1 MiB of RAM.
71 +
72 +
### 2.2 Non-goals
73 +
74 +
- Running on machines with less than 512 KiB of RAM.
75 +
- Replacing code review, CI, or issue tracking.
76 +
- Semantic (language-aware) merging. Merging is line-based for text and replace-based for binary.
77 +
- Confidentiality of repository contents at rest in version 1 (see §27).
78 +
79 +
### 2.3 Lineage
80 +
81 +
The change model is the patch theory of Darcs and Pijul. The stable-ID-plus-hash split, the first-class working-copy revision, and the operation log come from Jujutsu. The chunked content store follows bup, restic, and Xet. The signed reference model follows The Update Framework (TUF) and gittuf. Oxygen's contribution is combining these with a partial-fetch-first protocol, composite post-quantum signatures, and a constrained-client profile in a single coherent design.
82 +
83 +
## 3. Threat model
84 +
85 +
### 3.1 Adversaries
86 +
87 +
| Adversary | Capabilities |
88 +
|---|---|
89 +
| **Network attacker** | Observe, modify, drop, delay, and replay traffic between client and server. |
90 +
| **Malicious or compromised server** | Serve arbitrary objects and references, withhold objects, and serve stale references. |
91 +
| **Malicious collaborator** | Hold a valid signing key authorized for some references and attempt to exceed that authorization, forge others' authorship, or introduce colliding identifiers. |
92 +
| **Compromised client host** | Full control of one contributor's machine, including their private keys. |
93 +
94 +
### 3.2 Security goals
95 +
96 +
- **G1 Integrity.** A client that has verified a state's heads can detect any modification to any object reachable from them.
97 +
- **G2 Authenticity.** Every change and every reference update carries a signature verifiable against an identity, and policy decides whether that identity is authorized.
98 +
- **G3 Freshness.** A client can detect when a server presents a reference older than one it has already seen.
99 +
- **G4 Non-downgrade.** An attacker cannot strip the post-quantum half of a composite signature and have it verify.
100 +
- **G5 Availability under adversarial input.** Pathological file contents cannot cause super-linear chunking, hashing, or graph work.
101 +
102 +
### 3.3 Explicitly out of scope
103 +
104 +
- Confidentiality against a malicious server (repository contents are visible to whoever hosts them).
105 +
- Recovery from a compromised signing key beyond revocation and policy update.
106 +
- Protection against a compromised client host altering its own working copy.
107 +
108 +
Every mechanism in §§5, 14, 15, and 16 is annotated with the goal it serves.
109 +
110 +
## 4. Terminology
111 +
112 +
| Term | Meaning |
113 +
|---|---|
114 +
| **Object** | An immutable, content-addressed byte string with a type. |
115 +
| **ObjectRef** | The 32-byte BLAKE3 hash of an object, including its domain-separation header. |
116 +
| **Change** | The unit of history: tree operations, edits, dependencies, and metadata. |
117 +
| **ChangeId** | A 128-bit random identifier that is stable across rewrites of a change. |
118 +
| **ChangeHash** | The ObjectRef of a change. Changes with different content have different hashes. |
119 +
| **Atom** | An identified unit of file content: one line of a text file. |
120 +
| **AtomId** | `(ChangeHash of introducing change, u32 index)`. |
121 +
| **Node** | A file, directory, or symlink, identified by a 128-bit random **NodeId** stable across renames. |
122 +
| **Placement** | The `(parent NodeId, name)` pair that positions a node in the tree. |
123 +
| **State** | A dependency-closed set of changes. Fully determined by its maximal elements (**heads**). |
124 +
| **Bookmark** | A named, mutable pointer to a state. Equivalent to a Git branch. |
125 +
| **Tag** | A named, immutable, signed pointer to a state. |
126 +
| **Checkpoint** | A materialized snapshot of a state, used as a cache. |
127 +
| **Operation** | A recorded mutation of local repository state (bookmarks, workspaces). |
128 +
| **Workspace** | A checkout directory associated with a repository. |
129 +
| **Attestation** | A signed statement about a reference, tag, batch of changes, or key. |
130 +
| **Identity** | An object holding a contributor's public keys and metadata. |
131 +
132 +
## 5. Object model and hashing
133 +
134 +
### 5.1 Encoding
135 +
136 +
All objects are encoded as deterministic CBOR (RFC 8949 §4.2.1): definite lengths, shortest integer encodings, map keys sorted bytewise, no floating-point values, no indefinite-length items. Map keys are small unsigned integers; the tables in this document give the key number in brackets, e.g. `deps [2]`.
137 +
138 +
Every object begins with the field `version [0]`, an unsigned integer. This document defines version 1. Readers MUST reject objects whose version they do not understand.
139 +
140 +
### 5.2 Hashing (G1)
141 +
142 +
```
143 +
ObjectRef = BLAKE3-256( header || body )
144 +
header    = "oxy1 " || type-name || " " || decimal(len(body)) || 0x00
145 +
```
146 +
147 +
`type-name` is one of `chunk`, `blob`, `change`, `checkpoint`, `op`, `attest`, `identity`, `policy`. The header prevents an object of one type from being presented as another. BLAKE3 with a 256-bit output retains 128-bit collision resistance against quantum attackers.
148 +
149 +
The all-zero ObjectRef is reserved and means "self" inside a change (§7.4).
150 +
151 +
### 5.3 Object types
152 +
153 +
| Type | Purpose | Section |
154 +
|---|---|---|
155 +
| `chunk` | Raw bytes, at most 1 MiB. | §13.2 |
156 +
| `blob` | Ordered list of chunk refs plus total length; represents file content. | §13.2 |
157 +
| `change` | A history entry. | §6 |
158 +
| `checkpoint` | Materialized state. | §11 |
159 +
| `op` | Operation log entry. | §12 |
160 +
| `attest` | A signed statement. | §14.4 |
161 +
| `identity` | A contributor's keys. | §14.2 |
162 +
| `policy` | The repository's trust policy. | §14.5 |
163 +
164 +
### 5.4 Signatures are outside the hash
165 +
166 +
Signatures never appear inside a hashed object. A signed object is transported and stored as:
167 +
168 +
```
169 +
Signed = { object [0]: bytes, sigs [1]: [Signature] }
170 +
```
171 +
172 +
Adding a signature, re-signing, or attaching a seal (§14.4) therefore never changes an ObjectRef.
173 +
174 +
## 6. The change graph
175 +
176 +
### 6.1 Change object
177 +
178 +
```
179 +
Change {
180 +
  version   [0]: 1
181 +
  change_id [1]: bytes(16)            // random, CSPRNG; see §6.3
182 +
  deps      [2]: [ChangeHash]         // sorted, unique; see §8
183 +
  author    [3]: KeyId                // ObjectRef of an identity object
184 +
  hlc       [4]: { wall_ms: u64, logical: u32 }
185 +
  message   [5]: text
186 +
  tree_ops  [6]: [TreeOp]             // §7.2
187 +
  edits     [7]: [Edit]               // §7.4
188 +
  kind      [8]: 0 = normal, 1 = resolution, 2 = working-copy
189 +
  extra     [9]: map                  // reserved; MUST be empty in v1
190 +
}
191 +
```
192 +
193 +
### 6.2 Two identifiers
194 +
195 +
- **ChangeHash** is the integrity handle (G1). All references between objects use it. Dependencies, bookmark heads, tags, and checkpoints reference changes by hash only.
196 +
- **ChangeId** is the stable handle. It is what users type, what tooling tracks, and what survives amend, rebase, and reorder. It carries no integrity meaning and is never used alone as a reference in any object.
197 +
198 +
A change may be *rewritten*: a new change object is created with the same ChangeId and a different hash. Changes that depended on the old hash are *orphaned* and must be rewritten in turn (§8.4). A repository may hold several changes with the same ChangeId; when more than one is reachable from the same state the ChangeId is *divergent*, and commands MUST report divergence rather than pick silently.
199 +
200 +
### 6.3 ChangeId generation
201 +
202 +
ChangeIds MUST be produced by a cryptographically secure random generator with 128 bits of entropy on Full and Lite profiles. On the Thin profile they are generated by the proxy (§22). Two changes with equal ChangeId and unequal hash are not an error; they are divergence (§6.2). An implementation MUST NOT infer anything about content from a ChangeId.
203 +
204 +
### 6.4 Timestamps
205 +
206 +
`hlc` is a hybrid logical clock: `wall_ms` is the author's wall clock in milliseconds since the Unix epoch, and `logical` is a counter. On observing a change with a later HLC, a client advances its own clock per the Kulkarni et al. HLC rules. `wall_ms` is untrusted metadata and MUST NOT be used for ordering, security, or freshness decisions. Sorting for display uses `(hlc, ChangeHash)`.
207 +
208 +
**Drift bound.** A change with a far-future `wall_ms` must not be able to drag every peer's clock forward. On observing a change, a client MUST NOT advance its own HLC more than `core.hlc-max-drift` (default 5 minutes) ahead of its local wall clock; the change itself is still accepted and stored, so whether a state is well-formed never depends on the observer's clock. A server MAY reject a push containing a change whose `wall_ms` is more than `core.hlc-max-drift` ahead of the server clock, since the author is present and can correct their clock. `logical` is a `u32`; a client whose counter would overflow MUST refuse to create the change and report clock skew.
209 +
210 +
## 7. Files, trees, and atoms
211 +
212 +
### 7.1 Tree model
213 +
214 +
The repository tree is a set of nodes. Each node has a **NodeId**, a **kind** (`file`, `dir`, `symlink`), a **mode**, and zero or more **placements**. The mode of a file is its executable bit and its **content mode**, `text` or `opaque` (§7.5); a symlink's content is its target path and its content mode is always `opaque`; a directory has no mode. The root directory has the fixed NodeId of 16 zero bytes and no placement.
215 +
216 +
A placement is `(parent NodeId, name)`. Names are byte strings that MUST be valid UTF-8, MUST NOT be empty, `.`, or `..`, and MUST NOT contain `/` or NUL. Placements that a particular file system cannot represent distinctly, such as names differing only by case, are workspace conflicts (§9.7), not errors in the state.
217 +
218 +
Renames and moves are placement changes, so rename tracking is exact and never heuristic.
219 +
220 +
### 7.2 Tree operations
221 +
222 +
Placements are atoms in the sense of §7.3: each is introduced by exactly one change and may be deleted by later changes. Because a placement is defined relative to the parent's NodeId rather than a path, moving a directory moves its whole subtree with a single placement change; no operations are generated for descendants. The cost of this is that two concurrent moves can create a cycle, handled in §9.2.
223 +
224 +
```
225 +
TreeOp = AddNode  { node: NodeId, kind, mode, place: (parent, name) }   // introduces node + placement
226 +
       | RemoveNode { node: NodeId, placement: PlacementRef }            // deletes the placement; node dead when no live placements
227 +
       | Move     { node: NodeId, old: PlacementRef, place: (parent, name) } // deletes old placement, introduces new one; implicitly moves all descendants
228 +
       | SetMode  { node: NodeId, old: ModeRef, mode }                  // mode is an atom too
229 +
PlacementRef = (ChangeHash, u32 index into that change's tree_ops)
230 +
```
231 +
232 +
A node is **live** in a state when it has at least one live placement and its parent is live. A node with more than one live placement, or two live nodes with the same placement, is a conflict (§9.2).
233 +
234 +
### 7.3 Atoms and the file graph
235 +
236 +
The content of a text file is a directed graph whose vertices are atoms. Each atom is one line, including its terminator (bytes up to and including `\n`, or to end of file). Edges express "immediately after". An atom is introduced by exactly one change and has an `AtomId = (ChangeHash, index)`, where `index` counts atoms introduced by that change for that node, starting at 0.
237 +
238 +
Every file graph contains two virtual atoms, `START` and `END`. An atom is **live** unless a change in the state deletes it. Deleted atoms remain in the graph as tombstones; they may still be referenced as anchors.
239 +
240 +
The graph of a file in a state is the union of the atoms and edges introduced by every change in the state, minus nothing: deletion sets a flag, it does not remove vertices. **The graph is a function of the set of changes alone and not of the order they were applied.** Implementations MUST preserve this invariant; the conformance suite (§26) tests it by applying random permutations.
241 +
242 +
### 7.4 Edits
243 +
244 +
```
245 +
Edit = Insert  { node: NodeId, after: AtomRef, before: AtomRef, lines: [bytes] | lines_blob: ObjectRef }
246 +
     | Delete  { node: NodeId, atoms: [AtomRange] }
247 +
     | Replace { node: NodeId, old: ObjectRef | null, new: ObjectRef }     // opaque nodes only
248 +
AtomRef   = START | END | AtomId
249 +
AtomRange = { change: ChangeHash, from: u32, to: u32 }   // half-open
250 +
```
251 +
252 +
- `Insert` introduces `len(lines)` new atoms, indexed consecutively, and edges `after → first`, `last → before`. `after` and `before` MUST be adjacent in the author's view at authoring time; the pair is the insert's **anchor**.
253 +
- Atoms introduced by the same change are referenced with the all-zero "self" hash. This is how a change can insert a line and then insert another after it without knowing its own hash.
254 +
- `lines_blob` MAY replace inline `lines` when the inserted content exceeds 64 KiB; the blob's content is split into atoms by the same line rule.
255 +
- `Replace` is the only edit for `opaque` nodes. `old` is the blob currently expected; two concurrent `Replace` operations with the same `old` and different `new` conflict (§9.3).
256 +
257 +
### 7.5 Opaque nodes
258 +
259 +
A node's content mode is `opaque` if its content is not line-mergeable. A node MUST be opaque if it matches a `binary` pattern in `.oxyattributes`, if it contains a NUL byte in its first 8 KiB, if it exceeds `core.max-text-size` (default 4 MiB), or if it is a symlink. Symlink targets are stored as small blobs and edited only by `Replace`, bypassing the line graph. Opaque content is stored as a blob and versioned by `Replace`.
260 +
261 +
Content mode is part of the node's mode atom, so a switch is a `SetMode`. A change switching a node to `opaque` MUST also `Delete` every live atom of the node and `Replace` with `old = null`; a change switching to `text` MUST also `Replace` with `new = null` and `Insert` the content between `START` and `END`. Concurrent edits made under the other content mode then conflict through the mode atom (§9.2) rather than silently combining.
262 +
263 +
### 7.6 Line endings and encoding
264 +
265 +
Oxygen stores bytes verbatim and never rewrites line endings unless `.oxyattributes` requests `eol=lf` or `eol=crlf`, in which case normalization happens on snapshot and denormalization on checkout, exactly as Git does with `text eol=`. Implementations MUST NOT guess.
266 +
267 +
### 7.7 Checkout safety
268 +
269 +
Checkout writes only inside the workspace directory, whatever the tree contains.
270 +
271 +
- Checkout MUST create a symlink without following it and MUST NOT write any file through a path that contains a symlink, whether that symlink came from the repository or already existed in the workspace. Implementations SHOULD open parent directories with `O_NOFOLLOW` or the platform equivalent and write relative to that handle.
272 +
- A placement named `.oxy` under the root, or any placement that resolves to a path inside `.oxy/`, MUST be refused at snapshot and at checkout.
273 +
- `.oxyignore` and `.oxyattributes` files that are symlinks MUST be treated as absent, with a warning from `oxy status`.
274 +
- On file systems without symlink support, a symlink node is written as a plain file containing the target path, and `oxy status` reports it; snapshot MUST NOT convert such a file back into a content change.
275 +
276 +
## 8. Dependencies and commutation
277 +
278 +
### 8.1 Definition
279 +
280 +
The **scope** of a change `C` is the set of atoms and placements it references: every atom it deletes, every atom used as an anchor, every placement it deletes or moves, and every node it operates on.
281 +
282 +
`deps(C)` MUST contain the ChangeHash of the introducing change of every element of `scope(C)`, excluding `C` itself, START, END, and the root node. 
283 +
284 +
**Explicit (semantic) dependencies.** Changes that share no atoms commute even when they are semantically coupled, for example an API change in one file and the update of its caller in another. To prevent such changes from being separated, `deps(C)` MAY contain additional hashes that the author or tooling chose to require. `oxy commit --depends-on <change>` adds one; the argument is resolved to a ChangeHash as in §24, and a divergent ChangeId is an error. Implementations SHOULD support the `semantic-deps` hook (§20) so that build tools or language servers can inject cross-file dependencies at commit time.
285 +
286 +
An explicit dependency is a hard dependency with every consequence of §8.2 and §8.4: the change no longer commutes with its target, and rewriting the target orphans it. Tooling that injects dependencies MUST inject the minimal set and MUST be enabled per repository by the user, because injecting dependencies liberally turns history back into a linear sequence and forfeits the model's benefits. `deps(C)` MUST NOT be transitively reduced by the writer; readers MAY reduce it.
287 +
288 +
A change with `kind = resolution` MUST additionally depend on every change that contributed an atom or placement to the conflict it resolves.
289 +
290 +
### 8.2 Commutation
291 +
292 +
Two changes `A` and `B` **commute** if neither is in the transitive dependency closure of the other. By §7.3 applying `{A, B}` in either order produces the same graph, so no rewriting, re-hashing, or merge change is needed to reorder them. Commutation is a consequence of the model, not a separate rule to implement; the implementer's obligation is the invariant in §7.3.
293 +
294 +
### 8.3 Well-formedness
295 +
296 +
A state `S` is well-formed iff for every `C ∈ S`, `deps(C) ⊆ S`. Every reference to a state (bookmark, tag, checkpoint) names it by its heads, and the state is the dependency closure of the heads. Implementations MUST refuse to construct a state that is not well-formed and MUST refuse to store a change whose dependencies are unavailable unless the repository is shallow (§17).
297 +
298 +
### 8.4 Rewriting
299 +
300 +
Rewriting a change `C` to `C'` (amend, rebase, split, squash) produces a new object with the same ChangeId. Any change `D` with `hash(C) ∈ deps(D)` is orphaned relative to `C'`. `oxy` MUST automatically rewrite orphaned descendants by translating their AtomRefs and PlacementRefs from `C` to `C'` where a correspondence exists, and MUST record a conflict (§9) where it does not. Changes that do not depend on `C` are untouched. Rewriting is recorded in the operation log (§12) and is undoable.
301 +
302 +
## 9. Conflicts
303 +
304 +
### 9.1 Text conflicts
305 +
306 +
The live atoms of a file graph form a partial order. The file is **conflicted** if that order is not total. Two shapes arise:
307 +
308 +
- **Order conflict.** Two or more chains of live atoms are incomparable (concurrent inserts at the same anchor, or concurrent edits of overlapping regions). The conflict region is the minimal interval of the graph containing all incomparable chains.
309 +
- **Delete/insert conflict.** An atom `x` is deleted by change `D`, and a live atom `y` is anchored after `x` by a change `I`, where `D` and `I` commute. The inserted content has no live neighbour to attach to.
310 +
311 +
### 9.2 Tree conflicts
312 +
313 +
- **Name conflict.** Two live nodes share a placement.
314 +
- **Multi-placement.** One node has two live placements (concurrent renames).
315 +
- **Orphan.** A live node's parent directory is not live (concurrent delete of a directory and modification within it).
316 +
- **Cycle.** A directory is placed inside its own descendant, which arises when two commuting changes each move a directory under the other. Both placements are live and the tree is conflicted. For materialization the placement whose introducing change has the greater `(hlc, ChangeHash)` is treated as absent; resolution deletes one of the two placements.
317 +
- **Kind conflict.** Concurrent `AddNode` of the same placement with different kinds is a name conflict; concurrent `SetMode` with different modes is an order conflict on the mode atom.
318 +
319 +
### 9.3 Opaque conflicts
320 +
321 +
Two live `Replace` edits with the same `old` and different `new` are a **replace conflict**. The state exposes both candidate blobs.
322 +
323 +
### 9.4 Properties
324 +
325 +
- A conflicted state is well-formed and valid. It MAY be committed, pushed, fetched, tagged, and built upon.
326 +
- Conflicts nest and compose automatically because they are graph shapes, not special objects. A resolution that is itself concurrent with another resolution yields a new order conflict between the two resolutions.
327 +
- Servers MAY refuse conflicted heads on protected bookmarks through policy (§20). Clients MUST be able to push conflicted heads to unprotected bookmarks.
328 +
329 +
### 9.5 Materialization in the working copy
330 +
331 +
Conflicted text files are written with markers that identify the conflict and each side so that tools can parse them unambiguously:
332 +
333 +
```
334 +
<<<<<<< oxy conflict 1 of 2, side 1: change k7m2p9 "Add retry loop"
335 +
... lines ...
336 +
======= oxy conflict 1 of 2, side 2: change q3x8w1 "Refactor client"
337 +
... lines ...
338 +
>>>>>>> oxy conflict 1 of 2 end
339 +
```
340 +
341 +
The short identifiers are ChangeId prefixes. Delete/insert conflicts render the deleted context as a `-------` section. Tree conflicts are materialized by writing each candidate with a suffix `.oxy-conflict-<n>` and are listed by `oxy status`. Replace conflicts materialize as `<name>.oxy-conflict-<n>` files.
342 +
343 +
`oxy status` MUST list every conflict, MUST exit with status 2 when any conflict exists in the working-copy state, MUST exit with status 3 when only workspace conflicts (§9.7) exist, and MUST support `--json` output enumerating conflicts by node, kind, and contributing ChangeIds.
344 +
345 +
### 9.6 Resolution
346 +
347 +
A **resolution** is a change with `kind = resolution` whose deletions and insertions leave the affected graphs totally ordered and the tree conflict-free. `oxy resolve` computes it from the edited working copy by parsing the markers; a resolution MAY also be authored by any tool that can produce a change. Because a resolution depends on all contributing changes (§8.1), any peer that fetches it necessarily has the conflict it resolves.
348 +
349 +
### 9.7 Workspace conflicts
350 +
351 +
A state can be conflict-free and still not be representable on a particular file system. These are **workspace conflicts**: the state is valid, peers on other file systems see nothing, and no resolution change is involved.
352 +
353 +
- Two live placements in one directory whose names are equal under the file system's case folding (`Foo.c` and `foo.c` on default Windows and macOS volumes).
354 +
- Two live placements whose names are equal after the file system's Unicode normalization (NFC and NFD forms of the same name on macOS).
355 +
- A name the file system reserves or alters: Windows device names such as `CON` and `NUL`, names ending in a dot or a space, and names containing `:` or other characters the platform forbids.
356 +
- A path longer than the platform limit.
357 +
358 +
Each colliding node MUST be materialized with a `.oxy-conflict-<n>` suffix so that no content is lost, and `oxy status` MUST list the conflict with its kind. A workspace conflict is cleared by any ordinary change that renames one of the nodes; it is never counted by `allow_conflicts` (§14.5) or by server rules that forbid conflicted heads (§20).
359 +
360 +
## 10. States, bookmarks, and tags
361 +
362 +
### 10.1 References
363 +
364 +
References live in a namespace:
365 +
366 +
```
367 +
bookmarks/<name>                 local bookmarks
368 +
remotes/<remote>/bookmarks/<name>  last-seen remote bookmarks
369 +
tags/<name>                      tags (immutable once created)
370 +
policy                           the trust policy head (§14.5)
371 +
```
372 +
373 +
`<name>` is UTF-8, 1–255 bytes, without control characters, leading `-`, or the sequences `..`, `//`, `@{`.
374 +
375 +
A bookmark value is `{ heads: [ChangeHash] }` with one or more heads. Multiple heads are permitted and represent a bookmark whose state has more than one maximal change; this is not a conflict in the §9 sense and is shown by `oxy log` as parallel heads.
376 +
377 +
### 10.2 Reference attestations (G2, G3)
378 +
379 +
Every published bookmark update is accompanied by an attestation (§14.4) of kind `ref` that binds the repository ID, the reference name, the previous heads, the new heads, and a per-reference monotonically increasing `serial`. Servers MUST store the latest attestation per reference and serve it with the reference. Clients MUST verify the attestation against policy before updating a `remotes/` reference and MUST reject an attestation whose `serial` is lower than one they have already recorded for that reference (rollback protection). Clients SHOULD warn when the newest attestation's `hlc.wall_ms` is older than `remote.max-age` (default 30 days) and the remote claims no update.
380 +
381 +
Tags are attestations of kind `tag` and are never updated; a server MUST reject a second attestation for an existing tag name.
382 +
383 +
## 11. Checkpoints
384 +
385 +
Materializing a state by replaying every change is linear in history length. Checkpoints make checkout proportional to state size instead.
386 +
387 +
```
388 +
Checkpoint {
389 +
  version [0]: 1
390 +
  heads   [1]: [ChangeHash]
391 +
  tree    [2]: [ { node, kind, mode, placements: [(parent, name)], content: ObjectRef /* blob */, graph: ObjectRef /* blob */ } ]
392 +
  conflicted [3]: bool
393 +
}
394 +
```
395 +
396 +
`content` is the live content of the node as a blob (with markers if conflicted). `graph` is a blob containing the file graph in run-length form: a sequence of `(ChangeHash, from, to, live: bool)` runs plus explicit edges wherever the graph departs from a single chain. The graph blob is sufficient to apply any further change without consulting history. Tombstones are retained because future changes may anchor to them.
397 +
398 +
A checkpoint is valid only if it is bit-for-bit reproducible from its heads. Implementations MUST create a checkpoint at every tag, MUST create one at least every `core.checkpoint-interval` changes (default 256) along each bookmark, and MAY discard any checkpoint at any time. Clients fetching a state SHOULD request the nearest checkpoint and replay only the difference.
399 +
400 +
## 12. Operation log, workspaces, and the working copy
401 +
402 +
### 12.1 Operations
403 +
404 +
Every command that mutates local repository state (bookmarks, workspaces, the working-copy change, fetched remote references) records an operation:
405 +
406 +
```
407 +
Op {
408 +
  version [0]: 1
409 +
  parents [1]: [ObjectRef]            // usually one; two when merging concurrent operations
410 +
  hlc     [2]: HLC
411 +
  command [3]: text                   // e.g. "oxy rebase -s k7m2p9 -d main"
412 +
  view    [4]: { bookmarks: map, remotes: map, tags: map, workspaces: map<WorkspaceId, ChangeHash> }
413 +
}
414 +
```
415 +
416 +
`view` is the complete state of all references and workspaces after the operation. The operation log is a DAG; concurrent processes each append to their own head, and the next command merges heads by three-way merge of views, with both sides kept when they disagree on a bookmark (the bookmark then has multiple heads).
417 +
418 +
`oxy undo` moves the current operation to its parent's view. `oxy redo` and `oxy op log` expose the DAG. Operations are retained for `core.op-retention` (default 90 days) and then eligible for garbage collection.
419 +
420 +
Because all mutation goes through the operation log and object writes are idempotent, no global repository lock is needed. A short-lived lock is held only while snapshotting a single workspace's working copy.
421 +
422 +
### 12.2 Workspaces
423 +
424 +
A repository has one or more workspaces, each with a `WorkspaceId` (128-bit random) and a checkout directory. Each workspace has exactly one **working-copy change** at any time.
425 +
426 +
### 12.3 The working-copy change
427 +
428 +
The working copy is a real change with `kind = working-copy`. Before running any command that reads repository state, `oxy` snapshots the working copy: it diffs the file system against the working-copy change's materialized state and rewrites the working-copy change to include the difference. The change therefore always describes the working directory. To prevent operation log bloat, a command that snapshots the working copy MUST NOT record a new Operation (§12.1) if the filesystem diff was empty (i.e., the working-copy change's hash did not change).
429 +
430 +
- The working-copy change is unsigned and never pushed while `kind = working-copy`.
431 +
- `oxy commit` (alias `oxy describe`) sets the message, sets `kind = normal`, and starts a fresh working-copy change on top of it. The change is signed immediately if `core.sign = "change"`; with the default `core.sign = "seal"` it is left unsigned locally and covered by a seal at push time (§14.4).
432 +
- `oxy new`, `oxy edit <change>`, and `oxy abandon` move which change the workspace edits.
433 +
- There is no staging area and no stash. Partial commits are made with `oxy split`.
434 +
435 +
### 12.4 Snapshot performance
436 +
437 +
Implementations MUST support a file-system monitor (Watchman or a native inotify/FSEvents/ReadDirectoryChangesW watcher) and MUST fall back to a stat-based crawl with a persistent cache keyed by `(path, size, mtime, inode, ctime)`. Snapshot latency targets are in §25.
438 +
439 +
## 13. Storage engine
440 +
441 +
### 13.1 Layout
442 +
443 +
```
444 +
.oxy/
445 +
  format              "oxy repository format 1\n"
446 +
  config              repository-local configuration (TOML)
447 +
  objects/
448 +
    pack-<id>.pack    append-only pack file
449 +
    pack-<id>.idx     sorted (ObjectRef → offset, length, type) index
450 +
    loose/            objects written since the last pack
451 +
  ops/                operation objects and op heads
452 +
  refs/               current view cache (derived from ops; rebuildable)
453 +
  workspaces/<id>/    snapshot cache, fsmonitor cookie
454 +
  policy/             cached policy and identities
455 +
```
456 +
457 +
Object writes go to a temporary file, are `fsync`ed, and are renamed into place. Pack files are `fsync`ed before their index is written. An interrupted write can never corrupt an existing object. The refs cache is derivable from the operation log and is rebuilt if missing.
458 +
459 +
### 13.2 Chunking and blobs (G5)
460 +
461 +
File content is split with FastCDC using a 32 KiB minimum, 64 KiB target, and 256 KiB maximum chunk size, with a 48-byte gear window and normalized chunking level 2. Files of 32 KiB or less are a single chunk. The minimum and maximum bounds guarantee that adversarial content produces at most `len / 32 KiB` chunks and at least `len / 256 KiB` chunks, bounding both index growth and dedup loss.
462 +
463 +
```
464 +
Blob { version [0]: 1, length [1]: u64, chunks [2]: [ObjectRef] }
465 +
```
466 +
467 +
Chunks and blobs are the only object types that can be large. Every other object type MUST be under 1 MiB after encoding; a change exceeding that MUST use `lines_blob`.
468 +
469 +
### 13.3 Compression
470 +
471 +
Each object in a pack is individually compressed with zstd (level 3 by default). Objects whose compressed size is not smaller are stored raw, with a flag. Chunks matching a `nocompress` attribute pattern (already compressed media) are stored raw.
472 +
473 +
### 13.4 Packing
474 +
475 +
`oxy gc` (and an automatic trigger when loose object count exceeds 4096) writes loose objects into a new pack and merges packs when there are more than 16. Pack indexes MUST support lookup by ObjectRef prefix and MUST be memory-mappable. A repository with two million objects MUST be openable with under 64 MiB of resident index memory on the Full profile.
476 +
477 +
### 13.5 Lazy objects
478 +
479 +
A blob or chunk may be **absent** in a partial repository (§17). The object store records absent refs it has been told about with the remote that promised them, and fetches on first read. Changes and checkpoints are never lazy: a repository that has a change has all of its inline data.
480 +
481 +
## 14. Identity, signatures, and trust policy
482 +
483 +
### 14.1 Algorithms
484 +
485 +
| Purpose | Algorithm |
486 +
|---|---|
487 +
| Hash | BLAKE3-256 |
488 +
| Signature (composite) | Ed25519 **and** ML-DSA-65 (FIPS 204) |
489 +
| Transport AEAD | ChaCha20-Poly1305 |
490 +
| Transport key agreement | X25519, with ML-KEM-768 hybrid where the Noise pattern supports it |
491 +
| Random | OS CSPRNG |
492 +
493 +
ML-DSA-65 is chosen over ML-DSA-44 for NIST security category 3, at the cost of signatures of 3309 bytes.
494 +
495 +
### 14.2 Identity object
496 +
497 +
```
498 +
Identity {
499 +
  version    [0]: 1
500 +
  ed25519_pk [1]: bytes(32)
501 +
  mldsa65_pk [2]: bytes(1952)
502 +
  name       [3]: text
503 +
  email      [4]: text
504 +
  created    [5]: u64 (wall ms)
505 +
  expires    [6]: u64 | null
506 +
  previous   [7]: KeyId | null        // rotation chain
507 +
  delegates  [8]: [KeyId]             // keys allowed to sign or seal on this identity's behalf
508 +
}
509 +
KeyId = ObjectRef of the Identity
510 +
```
511 +
512 +
The two public keys live in one object, so a `KeyId` names the pair. `delegates` lists keys that may authenticate changes whose `author` is this identity: a proxy acting for a Thin client (§22), or a CI signer. Delegation is one level deep and is not transitive. Rotation is a new identity with `previous` set, plus an attestation of kind `rotate` signed by the previous key. Revocation is an attestation of kind `revoke` signed by the revoked key or by a policy root key (§14.5).
513 +
514 +
### 14.3 Composite signature (G2, G4)
515 +
516 +
```
517 +
Signature {
518 +
  key  [0]: KeyId
519 +
  alg  [1]: 1                          // ed25519+mldsa65
520 +
  ed   [2]: bytes(64)
521 +
  ml   [3]: bytes(3309)
522 +
}
523 +
message = "oxy1 sig " || domain || 0x00 || KeyId || ObjectRef-being-signed
524 +
domain  = "change" | "attest"
525 +
```
526 +
527 +
Both signatures are computed over the same `message`, which includes the `KeyId` of the composite pair. Verification MUST require both signatures to verify with the keys of the identity named by `key`. A signature with either half missing, empty, or failing is invalid. This is the same construction as the IETF LAMPS composite signature drafts and prevents stripping the post-quantum half.
528 +
529 +
Profiles that cannot compute ML-DSA (§22) do not sign; they produce changes that a proxy signs.
530 +
531 +
### 14.4 Attestations
532 +
533 +
```
534 +
Attest {
535 +
  version [0]: 1
536 +
  kind    [1]: "ref" | "tag" | "seal" | "rotate" | "revoke" | "lock"
537 +
  repo    [2]: bytes(16)               // repository ID, chosen at init
538 +
  hlc     [3]: HLC
539 +
  body    [4]: map                     // per kind
540 +
}
541 +
ref:    { name, old_heads, new_heads, serial: u64 }
542 +
tag:    { name, heads, message }
543 +
seal:   { hashes: [ChangeHash] }        // sorted, unique, 1..4096 entries
544 +
rotate: { new: KeyId }
545 +
revoke: { key: KeyId, reason }
546 +
lock:   { node: NodeId, path, acquire: bool, expires }
547 +
```
548 +
549 +
A **seal** is one signature over a batch of change hashes. A composite signature is 3373 bytes, which is larger than a typical change, so signing every change individually roughly doubles the size of history in high-frequency workflows. A seal replaces that with one signature per push.
550 +
551 +
**Cadence.** With the default `core.sign = "seal"`, changes are left unsigned locally and `oxy push` creates one seal covering every change in the push that carries no individual signature, immediately before the reference attestation. Seals are created only at push: a seal made earlier would be invalidated by any amend, since rewriting produces a new hash. With `core.sign = "change"`, every change is signed at commit and no seal is needed. Policy (§14.5) decides which the receiving repository accepts.
552 +
553 +
**Verification.** A sealed change `C` is authenticated iff a seal `S` exists such that `hash(C) ∈ S.body.hashes`, `S` carries a valid signature from key `K`, and `K` equals `C.author` or is listed in `C.author`'s identity as a delegate (§14.2). The same author-or-delegate rule applies to individual change signatures. This rule is what stops a collaborator from sealing a change whose `author` field names someone else (§3.1). A seal's signer must additionally be authorized by policy for the reference being pushed.
554 +
555 +
**Handling.** A seal is stored and transported with the changes it covers: `fetch` (§16.1) streams the seals covering every change it returns, `push` (§16.3) MUST include a seal or signature for every change, and a seal covering any reachable change is itself reachable (§21). Seals from different keys MAY overlap; a change needs at least one valid authenticator, and policy thresholds count distinct authenticating keys.
556 +
557 +
### 14.5 Trust policy (G2)
558 +
559 +
The `policy` reference points to a signed `Policy` object:
560 +
561 +
```
562 +
Policy {
563 +
  version   [0]: 1
564 +
  repo      [1]: bytes(16)
565 +
  serial    [2]: u64
566 +
  roots     [3]: { keys: [KeyId], threshold: u32 }
567 +
  rules     [4]: [ { pattern: glob over reference names, keys: [KeyId], threshold: u32,
568 +
                     require: "signed" | "sealed" | "any" /* default */, allow_conflicts: bool } ]
569 +
  identities[5]: [Identity]           // embedded so verification needs no fetch
570 +
  revoked   [6]: [KeyId]
571 +
  expires   [7]: u64
572 +
}
573 +
```
574 +
575 +
- The initial policy is signed by the root keys; every update MUST be signed by `roots.threshold` root keys and have a higher `serial`.
576 +
- A change or attestation is **authorized** for reference `r` if its signer is listed in the first rule whose pattern matches `r`, and the rule's threshold of distinct authorized signers is met across the attestation and each change in the `new_heads` closure not previously in the `old_heads` closure. `require` selects which authenticators count for changes: individual signatures only, seals only, or either. Every change MUST additionally satisfy the author-or-delegate rule of §14.4 regardless of `require`.
577 +
- Clients MUST verify policy on fetch, MUST refuse to update a `remotes/` reference whose attestation is unauthorized, and MUST warn loudly on an expired policy. Servers MUST enforce the same rules on push.
578 +
- A repository without a `policy` reference is **unverified**; every command that talks to a remote MUST print a one-line warning until one is created. `oxy init` creates one by default.
579 +
580 +
## 15. Transport
581 +
582 +
Every transport carries the same wire protocol (§16). The transport provides the channel; integrity and authenticity of history come from the object model regardless of channel.
583 +
584 +
### 15.1 HTTPS (mandatory)
585 +
586 +
Every server MUST offer HTTPS with TLS 1.3 at `https://<host>/<repo>/oxy/v1/`. Authentication is by bearer token, mutual TLS, or HTTP signature over a server-issued challenge with the client's identity key. HTTPS is required because it traverses corporate proxies, load balancers, and CDNs; it is the interoperability baseline.
587 +
588 +
### 15.2 Noise (optional)
589 +
590 +
Peer-to-peer and self-hosted deployments MAY use Noise over TCP or QUIC.
591 +
592 +
- Pattern `Noise_XXhfs_25519+kyber768_ChaChaPoly_BLAKE2s` (hybrid post-quantum handshake) for first contact, or `IKhfs` when the server's static key is known.
593 +
- The client's Noise static key MUST be the X25519 key derived from its Ed25519 identity key (RFC 8032 key clamping via the standard birational map), so the transport identity and the signing identity are the same KeyId.
594 +
- Servers publish their static key fingerprint out of band. Clients pin it on first use and MUST refuse a changed key without explicit `oxy remote trust` action.
595 +
596 +
### 15.3 Zero-Crypto Transport (ZCT, restricted)
597 +
598 +
ZCT is a plaintext, length-prefixed framing of the wire protocol over TCP or serial, for Thin clients that cannot run ChaCha20 or X25519 at usable speed.
599 +
600 +
- ZCT is disabled by default on servers and on clients. A remote MUST be explicitly configured with `transport = "zct"` and clients MUST print a warning on every use.
601 +
- ZCT provides no confidentiality. Over ZCT the client MUST verify every change signature or seal, every reference attestation, and every ObjectRef it receives; a client incapable of verification (Thin profile without Ed25519) MUST only use ZCT through a trusted proxy on a network it trusts, and this MUST be a documented deployment decision.
602 +
- Push over ZCT is permitted only with a reference attestation signed by an authorized key; there is no other authentication. Servers MAY refuse push over ZCT entirely.
603 +
- ZCT MUST NOT be offered on a public network interface by default.
604 +
605 +
### 15.4 Proxy
606 +
607 +
A **proxy** is a Full-profile `oxy` server that fronts one or more remotes for Thin clients, performing verification, chunking, delta computation, and signing on their behalf. Its security properties are those of the Thin client's trust in it.
608 +
609 +
## 16. Wire protocol
610 +
611 +
Messages are deterministic CBOR. Over HTTPS each message is a request body with `Content-Type: application/oxy-cbor` and responses are CBOR sequences (RFC 8742) with `Content-Encoding: zstd` when supported. Over Noise and ZCT, messages are framed as `u32 big-endian length || bytes`.
612 +
613 +
### 16.1 Endpoints
614 +
615 +
| Endpoint | Request | Response |
616 +
|---|---|---|
617 +
| `GET capabilities` | — | `{ versions: [1], transports, features: ["lazy", "checkpoint", "seal", "lock"], max_frame }` |
618 +
| `GET refs` | `{ patterns }` | `[ { name, heads, attestation: Signed<Attest> } ]` plus the current `Signed<Policy>` |
619 +
| `POST negotiate` | `{ have: [ChangeHash], want: [ChangeHash], filter }` | stream of `ChangeHash` the client lacks, in dependency order |
620 +
| `POST fetch` | `{ changes: [ChangeHash], include_blobs: bool, filter }` | stream of `Signed<Change>`, then every `Signed<Attest kind=seal>` covering them, then referenced `blob`/`chunk` objects |
621 +
| `POST objects` | `{ refs: [ObjectRef] }` | stream of objects |
622 +
| `GET checkpoint` | `{ heads, filter }` | nearest `Checkpoint` and its objects, and the list of changes to replay |
623 +
| `POST push` | stream of `Signed<Change>`, objects, seals, then `Signed<Attest kind=ref>` per reference | `{ results: [ { name, ok, reason } ] }` |
624 +
| `POST lock` | `Signed<Attest kind=lock>` | `{ ok, holder, expires }` |
625 +
626 +
### 16.2 Negotiation
627 +
628 +
The client sends the heads it has for the requested references and the heads it wants. Because dependencies form a DAG and every state is its heads' closure, the server computes `closure(want) \ closure(have)` by a reverse traversal, stopping at any hash in `have`. For very large histories a server MAY ask the client for the Merkle root of its known-hash set in 2^16-hash-prefix buckets and skip buckets whose roots match. The response is ordered so that no change precedes any of its dependencies.
629 +
630 +
### 16.3 Push validation
631 +
632 +
On push, the server MUST, in order: verify that every change has a valid signature or seal from a key embedded in policy and satisfies the author-or-delegate rule (§14.4), verify the attestation for each reference, check that `old_heads` matches its current heads (compare-and-swap), check policy rules for the reference including `allow_conflicts`, check that every dependency of every change is either included or already stored, and only then update references atomically. A failure rejects the whole push.
633 +
634 +
## 17. Partial, sparse, and shallow repositories
635 +
636 +
A repository records a **filter** in `.oxy/config`:
637 +
638 +
```
639 +
[filter]
640 +
paths    = ["src/", "docs/"]      # sparse: only these subtrees are materialized and blobs fetched
641 +
max-blob = "1MiB"                 # blobs larger than this are lazy
642 +
depth    = 100                    # shallow: history beyond this many changes from heads is not stored
643 +
```
644 +
645 +
- **Sparse** repositories hold the full change graph but only materialize and fetch blobs under the listed paths. Changes touching other paths are stored and applied to the graph; their content is lazy.
646 +
- **Lazy blobs** are fetched via `objects` on first read. `oxy status` and `oxy commit` MUST NOT need them.
647 +
- **Shallow** repositories store a *shallow boundary*: a set of ChangeHashes whose dependencies are known to be absent. A shallow repository MUST hold a checkpoint at the boundary so that graphs can be reconstructed. Deps into the boundary are accepted as well-formed. Deepening fetches more history and moves the boundary.
648 +
- Thin clients (§22) always use `depth = 0`: a checkpoint plus zero history.
649 +
650 +
Servers MUST honour `filter` on `negotiate`, `fetch`, and `checkpoint`.
651 +
652 +
## 18. Locks for unmergeable files
653 +
654 +
Opaque files (models, textures, binary documents) cannot be merged. Paths matching a `lockable` attribute in `.oxyattributes` are read-only in the working copy until locked.
655 +
656 +
- `oxy lock <path>` sends a `lock` attestation to the remote configured as `lock-server`. The server grants it if unlocked or expired, and records the holder.
657 +
- A push that replaces a lockable node's blob MUST be rejected by the server unless the pushing key holds the lock.
658 +
- Locks expire (default 7 days) and can be broken by a policy root key.
659 +
- Locks are advisory in repositories with no `lock-server`.
660 +
661 +
## 19. Repository configuration files
662 +
663 +
| File | Purpose |
664 +
|---|---|
665 +
| `.oxyignore` | Ignore rules. Gitignore syntax and semantics, per directory, plus `.oxy/info/ignore` and the global ignore file. |
666 +
| `.oxyattributes` | Per-path attributes: `binary`, `eol=lf|crlf`, `nocompress`, `lockable`, `filter=<name>`. Gitattributes syntax. |
667 +
| `.oxy/config` | Repository-local settings, TOML. |
668 +
| `~/.config/oxy/config.toml` | User settings: identity key path, aliases, default profile. |
669 +
670 +
Filters (`filter=<name>`) are clean/smudge programs configured in user or repo config, as in Git. Implementations MUST refuse to run filters defined in `.oxy/config` of a freshly cloned repository until the user approves them.
671 +
672 +
## 20. Hooks and server policy
673 +
674 +
Client hooks live in `.oxy/hooks/` and are never cloned. Events: `pre-commit`, `semantic-deps`, `post-commit`, `pre-push`, `post-fetch`, `post-checkout`. A hook receives its event as JSON on stdin and blocks the operation by non-zero exit for `pre-*` events.
675 +
676 +
The `semantic-deps` hook runs during `oxy commit` after the working copy is snapshotted and before the change is finalized. It receives `{ change_id, touched_paths, candidate_deps: [ { change_id, hash, paths } ] }`, where `candidate_deps` are the changes in the working-copy state that touch any file the new change touches or that it references by symbol if the hook has such information, and it returns `{ deps: [ChangeHash] }` to add to `deps(C)`. It runs only when `hooks.semantic-deps = true` is set in `.oxy/config`, which is never set by clone.
677 +
678 +
Server policy is the trust policy (§14.5) plus server configuration for per-reference rules: protected patterns that additionally require CI status, forbid conflicted heads, forbid multiple heads, or forbid force (non-fast-forward) updates. A force update is a `ref` attestation whose `old_heads` are not all in the closure of `new_heads`.
679 +
680 +
## 21. Garbage collection
681 +
682 +
An object is **reachable** if it is referenced, directly or transitively, from any bookmark, tag, policy, the current operation heads, any operation newer than `core.op-retention`, any workspace's working-copy change, or any checkpoint of a reachable state. `oxy gc` deletes unreachable objects that are older than `core.gc-grace` (default 14 days, to protect concurrent writers) and repacks. Tombstone atoms are never collected independently of their change. Divergent changes are reachable if any of their variants is. A seal is reachable if any change it covers is reachable, and reference attestations are reachable while they are the latest for their reference or are within `core.op-retention`.
683 +
684 +
## 22. Client profiles and retro hardware
685 +
686 +
| | Full | Lite | Thin |
687 +
|---|---|---|---|
688 +
| Minimum hardware | 1 GiB RAM, current desktop or server | 16 MiB RAM (486, 68030, ARMv5, RISC-V RV32) | 68000 @ 8 MHz, 1 MiB RAM, block storage (Amiga 500+, Atari ST, Macintosh Plus/SE, early 386 DOS) |
689 +
| Local history | Full or partial | Partial (shallow + lazy) | None: one checkpoint |
690 +
| Change graph operations | Yes | Yes | No |
691 +
| BLAKE3 verification | Yes | Yes | Yes (chunks and objects received; ~150 KiB/s on 68000) |
692 +
| Ed25519 verification | Yes | Yes | MAY (~2 s per signature on 68000; used for attestations only) |
693 +
| ML-DSA-65 | Sign + verify | Verify; sign MAY | No |
694 +
| Chunking, delta computation, signing | Local | Local | Proxy |
695 +
| Transports | HTTPS, Noise, ZCT | HTTPS, Noise, ZCT | ZCT to proxy; serial or TCP |
696 +
| Working copy | Full | Sparse | Sparse subtree, whole-file edits |
697 +
698 +
A Thin client fetches a checkpoint for a sparse subtree, verifies each object's ObjectRef, and writes files. To commit, it sends the full content of modified files with their previous blob refs to the proxy; the proxy computes edits, assigns ChangeIds, signs or seals with its own key, which the user has listed in `delegates` on their identity (§14.2), and pushes. The threat model for Thin clients is therefore "the proxy is trusted", and `oxy` documentation MUST state this without qualification.
699 +
700 +
Machines under 512 KiB of RAM are out of scope; a serial terminal to a Lite or Full machine is the recommended path.
701 +
702 +
## 23. Git interoperability
703 +
704 +
`oxy git import <repo>` and `oxy git export <repo>` provide a lossless-enough bridge; `oxy git fetch/push` operate against a Git remote directly.
705 +
706 +
- **Import.** Each Git commit becomes one change whose edits are the diff against its first parent; merge commits become a change whose edits are the diff against the merged graph state. Atom identities are assigned by first appearance and cached in `.oxy/git/` so re-import is stable. Renames are detected heuristically (Git similarity ≥ 50%) on import only, then tracked exactly. The Git commit's `Change-Id` trailer, if present and 128 bits, becomes the ChangeId; otherwise one is derived as BLAKE3-128 of the Git commit hash, so re-import is idempotent.
707 +
- **Export.** Each state along a bookmark becomes a Git commit whose tree is the materialized state. The ChangeId is written as a `Change-Id:` trailer and the ChangeHash as `Oxy-Hash:`. Conflicted states are exported with §9.5 markers and an `Oxy-Conflicted: true` trailer. Signatures are not exported; Git commits MAY be additionally signed with the Ed25519 key via SSH signature format.
708 +
- **Round trip.** Import followed by export MUST reproduce every tree hash. Export followed by import MUST reproduce every ChangeId.
709 +
710 +
## 24. Command-line interface
711 +
712 +
Commands mirror Jujutsu where semantics match, so that muscle memory transfers.
713 +
714 +
```
715 +
oxy init [--no-policy]              oxy clone <url> [--sparse PATH..] [--depth N] [--max-blob SIZE]
716 +
oxy status [--json]                 oxy diff [-r CHANGE] [PATH..]
717 +
oxy commit [-m MSG] [--depends-on CHANGE].. [--sign]
718 +
                                    oxy new [PARENT..]           oxy edit CHANGE
719 +
oxy split                           oxy squash                   oxy abandon CHANGE
720 +
oxy rebase -s CHANGE -d DEST        oxy resolve [PATH..]
721 +
oxy log [-r REVSET]                 oxy show CHANGE
722 +
oxy bookmark create|set|delete|list oxy tag create NAME -r CHANGE
723 +
oxy fetch [REMOTE]                  oxy push [REMOTE] [-b BOOKMARK]
724 +
oxy undo                            oxy redo                     oxy op log
725 +
oxy lock PATH                       oxy unlock PATH
726 +
oxy identity create|rotate|revoke   oxy policy edit|sign|show
727 +
oxy remote add|trust|list           oxy gc
728 +
oxy git import|export|fetch|push
729 +
```
730 +
731 +
Change arguments accept a ChangeId prefix, a ChangeHash prefix (with `#`), a bookmark, `@` for the working-copy change, and `@-` for its parent. Ambiguous prefixes are an error.
732 +
733 +
## 25. Performance targets
734 +
735 +
Measured on the Full profile, warm cache, NVMe storage, 8-core x86-64, with a 20 GiB repository of 2 million files and 500 000 changes.
736 +
737 +
| Operation | Target |
738 +
|---|---|
739 +
| `oxy status` (no changes, fsmonitor) | < 50 ms |
740 +
| `oxy status` (no changes, stat crawl) | < 2 s |
741 +
| `oxy commit` touching 10 files | < 100 ms |
742 +
| `oxy log -r main` first page | < 100 ms |
743 +
| Checkout between adjacent checkpoints | proportional to changed bytes, ≥ 500 MiB/s |
744 +
| Full clone over 1 Gbit/s | line rate; chunk verification ≥ 1 GiB/s per core |
745 +
| Sparse clone of a 50 MiB subtree | < 5 s |
746 +
| Rebase of 100 commuting changes | < 200 ms, zero object rewrites |
747 +
| Resident memory, `status` | < 200 MiB |
748 +
749 +
Implementations SHOULD ship a benchmark harness that reproduces this table.
750 +
751 +
## 26. Conformance and test vectors
752 +
753 +
A conforming implementation MUST pass the `oxy-conformance` suite, which includes:
754 +
755 +
- Encoding vectors: canonical CBOR and ObjectRef for every object type.
756 +
- Signature vectors: composite signatures, including stripped-half and wrong-KeyId negatives.
757 +
- Graph invariance: for random change sets, all permutations of application yield identical graphs and checkpoints.
758 +
- Conflict shapes: each shape in §9 with expected materialization and expected `status --json`.
759 +
- Rewrite propagation: amend of a change with dependents, with and without correspondence.
760 +
- Protocol: negotiate/fetch/push against a reference server, including rollback and unauthorized-attestation rejection.
761 +
- Adversarial chunking: inputs with no gear boundaries and inputs with boundaries at every byte.
762 +
- Git round trip on a corpus of public repositories.
763 +
764 +
## 27. Open questions
765 +
766 +
1. **Encryption at rest.** Per-repository XChaCha20-Poly1305 with a key hierarchy is reserved for format version 2; object headers already leave room. Convergent encryption is rejected because it leaks equality.
767 +
2. **Word- or character-granularity atoms** for prose and minified files. Line atoms are fixed for v1.
768 +
3. **Set reconciliation** at the scale of tens of millions of changes may need an IBLT-based negotiator; the Merkle bucket scheme in §16.2 is the v1 answer.
769 +
770 +
---
771 +
772 +
*Changelog.* Draft 0.4 (2026-09-26): overview no longer describes the constrained profile by word size; HLC drift bound replaces timestamp-based rejection; node kinds reduced to file, dir, symlink with a separate content mode and defined mode switching; symlinks always opaque; checkout safety rules (§7.7); workspace conflicts (§9.7) replace the case-folding tree conflict and cover normalization, reserved names, and path length; operation log entries suppressed for empty snapshots.
773 +
774 +
Draft 0.3 (2026-09-26): recursive directory moves with cycle handling; explicit semantic dependencies with the `semantic-deps` hook; seals redefined as push-time batch signatures carrying their hash list, with an author-or-delegate authentication rule and `delegates` on identities; `core.sign` setting; fetch, push, policy, and garbage collection updated for seals.
775 +
776 +
Draft 0.2 (2026-09-26): rewrote from the 0.1 outline (the original `spec.txt`, since removed). Added threat model, two-identifier model, atom coordinate system, tree operations as atoms, conflict taxonomy and materialization, operation log, checkpoints, storage layout, composite signature construction, trust policy, reference attestations, HTTPS-mandatory transport, ZCT restrictions, wire protocol, partial repositories, locks, client profiles with a named hardware floor, Git bridge, CLI, performance targets, and conformance suite.
added src/main.rs +213 −0
1 +
//! `oxy`: command-line interface for Oxygen.
2 +
3 +
use anyhow::{bail, Context, Result};
4 +
use clap::{Parser, Subcommand};
5 +
use oxy_core::repo::{ChangeStatus, Repo};
6 +
use std::path::PathBuf;
7 +
8 +
#[derive(Parser)]
9 +
#[command(name = "oxy", version, about = "Oxygen: breathing fresh air into version control")]
10 +
struct Cli {
11 +
    #[command(subcommand)]
12 +
    cmd: Cmd,
13 +
}
14 +
15 +
#[derive(Subcommand)]
16 +
enum Cmd {
17 +
    /// Create a repository in PATH (default: current directory)
18 +
    Init { path: Option<PathBuf> },
19 +
    /// Show the working copy: changed paths and conflicts
20 +
    Status,
21 +
    /// Record the working copy as a change on the current bookmark
22 +
    Commit {
23 +
        #[arg(short, long)]
24 +
        message: String,
25 +
        /// Extra explicit dependencies (ChangeId prefix, #hash prefix)
26 +
        #[arg(long = "depends-on")]
27 +
        depends_on: Vec<String>,
28 +
    },
29 +
    /// List changes reachable from the bookmark and the working copy
30 +
    Log,
31 +
    /// Show what the working copy changes against its base
32 +
    Diff,
33 +
    /// Show one change (ChangeId prefix, #hash prefix, or @)
34 +
    Show { change: String },
35 +
    /// Undo the last operation
36 +
    Undo,
37 +
    /// List operations
38 +
    #[command(name = "op")]
39 +
    Op {
40 +
        #[command(subcommand)]
41 +
        cmd: OpCmd,
42 +
    },
43 +
}
44 +
45 +
#[derive(Subcommand)]
46 +
enum OpCmd {
47 +
    Log,
48 +
}
49 +
50 +
fn main() {
51 +
    match run() {
52 +
        Ok(code) => std::process::exit(code),
53 +
        Err(e) => {
54 +
            eprintln!("error: {e:#}");
55 +
            std::process::exit(1);
56 +
        }
57 +
    }
58 +
}
59 +
60 +
fn open() -> Result<Repo> {
61 +
    Repo::open(&std::env::current_dir()?).context("opening repository")
62 +
}
63 +
64 +
fn short_id(c: &oxy_core::objects::Change) -> String {
65 +
    c.change_id.short(8)
66 +
}
67 +
68 +
fn when(ms: u64) -> String {
69 +
    let secs = (ms / 1000) as i64;
70 +
    let days = secs.div_euclid(86_400);
71 +
    let rem = secs.rem_euclid(86_400);
72 +
    // Civil-from-days (Howard Hinnant), UTC.
73 +
    let z = days + 719_468;
74 +
    let era = z.div_euclid(146_097);
75 +
    let doe = z - era * 146_097;
76 +
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
77 +
    let y = yoe + era * 400;
78 +
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
79 +
    let mp = (5 * doy + 2) / 153;
80 +
    let d = doy - (153 * mp + 2) / 5 + 1;
81 +
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
82 +
    let y = if m <= 2 { y + 1 } else { y };
83 +
    format!("{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}Z", rem / 3600, (rem % 3600) / 60, rem % 60)
84 +
}
85 +
86 +
fn run() -> Result<i32> {
87 +
    let cli = Cli::parse();
88 +
    match cli.cmd {
89 +
        Cmd::Init { path } => {
90 +
            let path = path.unwrap_or(std::env::current_dir()?);
91 +
            let repo = Repo::init(&path)?;
92 +
            println!("Initialized oxy repository in {}", repo.root().display());
93 +
            println!("Bookmark: {}   Author: {} <{}>", repo.config.bookmark, repo.config.author_name, repo.config.author_email);
94 +
            Ok(0)
95 +
        }
96 +
        Cmd::Status => {
97 +
            let repo = open()?;
98 +
            let snap = repo.snapshot("oxy status")?;
99 +
            let heads = repo.bookmark_heads()?;
100 +
            println!(
101 +
                "Working copy: {} on {} ({} head{})",
102 +
                snap.wc.change_id.short(8),
103 +
                repo.config.bookmark,
104 +
                heads.len(),
105 +
                if heads.len() == 1 { "" } else { "s" }
106 +
            );
107 +
            if snap.statuses.is_empty() {
108 +
                println!("The working copy is clean.");
109 +
            }
110 +
            for s in &snap.statuses {
111 +
                let tag = match s.status {
112 +
                    ChangeStatus::Added => "A",
113 +
                    ChangeStatus::Modified => "M",
114 +
                    ChangeStatus::Removed => "D",
115 +
                    ChangeStatus::ModeChanged => "X",
116 +
                    ChangeStatus::ConflictEdited => "C!",
117 +
                };
118 +
                println!("{tag:2} {}", s.path);
119 +
            }
120 +
            let mut code = 0;
121 +
            if snap.base_snapshot.is_conflicted() {
122 +
                println!("\nConflicts in the base state:");
123 +
                for c in &snap.base_snapshot.tree.conflicts {
124 +
                    println!("  tree: {c:?}");
125 +
                }
126 +
                for (p, f) in &snap.base_snapshot.files {
127 +
                    if f.is_conflicted() {
128 +
                        println!("  {p}");
129 +
                    }
130 +
                }
131 +
                code = 2;
132 +
            }
133 +
            Ok(code)
134 +
        }
135 +
        Cmd::Commit { message, depends_on } => {
136 +
            let repo = open()?;
137 +
            let mut deps = Vec::new();
138 +
            for d in &depends_on {
139 +
                deps.push(repo.resolve_change(d)?);
140 +
            }
141 +
            let h = repo.commit(&message, &deps)?;
142 +
            let c = repo.store().get_change(&h)?;
143 +
            println!("Committed {} #{}: {}", short_id(&c), h.short(12), message.lines().next().unwrap_or(""));
144 +
            Ok(0)
145 +
        }
146 +
        Cmd::Log => {
147 +
            let repo = open()?;
148 +
            repo.snapshot("oxy log")?;
149 +
            for e in repo.log()? {
150 +
                let c = &e.change;
151 +
                let mut flags = Vec::new();
152 +
                if e.is_working_copy {
153 +
                    flags.push("working copy");
154 +
                }
155 +
                if e.is_head {
156 +
                    flags.push("head");
157 +
                }
158 +
                let flags = if flags.is_empty() { String::new() } else { format!(" ({})", flags.join(", ")) };
159 +
                println!("{} #{}{}", short_id(c), e.hash.short(12), flags);
160 +
                println!("    {}", when(c.hlc.wall_ms));
161 +
                if !c.deps.is_empty() {
162 +
                    println!("    deps: {}", c.deps.iter().map(|d| format!("#{}", d.short(12))).collect::<Vec<_>>().join(" "));
163 +
                }
164 +
                let msg = if c.message.is_empty() { "(no description)" } else { &c.message };
165 +
                for line in msg.lines() {
166 +
                    println!("    {line}");
167 +
                }
168 +
                println!();
169 +
            }
170 +
            Ok(0)
171 +
        }
172 +
        Cmd::Diff => {
173 +
            let repo = open()?;
174 +
            let snap = repo.snapshot("oxy diff")?;
175 +
            print!("{}", oxy_core::repo::render_change(&snap.wc, &snap.base, &snap.base_snapshot));
176 +
            Ok(0)
177 +
        }
178 +
        Cmd::Show { change } => {
179 +
            let repo = open()?;
180 +
            repo.snapshot("oxy show")?;
181 +
            let h = repo.resolve_change(&change)?;
182 +
            let c = repo.store().get_change(&h)?;
183 +
            println!("change {} #{}", short_id(&c), h);
184 +
            println!("kind:   {:?}", c.kind);
185 +
            println!("date:   {}", when(c.hlc.wall_ms));
186 +
            println!("deps:   {}", c.deps.iter().map(|d| format!("#{}", d.short(12))).collect::<Vec<_>>().join(" "));
187 +
            println!("\n{}\n", if c.message.is_empty() { "(no description)" } else { &c.message });
188 +
            print!("{}", repo.describe_change(&h)?);
189 +
            Ok(0)
190 +
        }
191 +
        Cmd::Undo => {
192 +
            let repo = open()?;
193 +
            let op = repo.undo()?;
194 +
            println!("Undid the last operation; now at: {}", op.command);
195 +
            Ok(0)
196 +
        }
197 +
        Cmd::Op { cmd: OpCmd::Log } => {
198 +
            let repo = open()?;
199 +
            for (i, (r, op)) in repo.op_log()?.into_iter().enumerate() {
200 +
                println!("{}{} {}  {}", if i == 0 { "@ " } else { "  " }, r.short(12), when(op.hlc.wall_ms), op.command);
201 +
            }
202 +
            Ok(0)
203 +
        }
204 +
    }
205 +
    .map_err(|e: anyhow::Error| e)
206 +
    .or_else(|e| {
207 +
        if e.to_string().is_empty() {
208 +
            bail!("unknown error")
209 +
        } else {
210 +
            Err(e)
211 +
        }
212 +
    })
213 +
}