on master help sign in
j — a functional-programming-centered CLI for Jujutsu repositories

Initial import: the j implementation (Rust, on jj-lib), the reference base vocabulary (config.j), full documentation (language, base, comparison, tutorial), a Nix flake with difftastic as a runtime dependency, and a complete test suite.

commit096ab8d58924558e030bd625510462d784c5fed6 authoriko <mail@iko.soy> dateTue 15 Sep 2026, 11:26 (1 month ago)
46 files changed+23435
.gitignore+4 Cargo.lock+2773 Cargo.toml+24 README.md+469 SPEC.md+1854 config.j+576 docs/base.md+1201 docs/comparison.md+350 docs/language.md+907 docs/tutorial.md+472 flake.lock+61 flake.nix+62 src/ast.rs+129 src/builtins.rs+658 src/config.rs+481 src/domain.rs+273 src/eval.rs+1020 src/jj.rs+2131 src/lex.rs+431 src/lib.rs+14 src/main.rs+388 src/parse.rs+931 src/render.rs+1259 src/repo.rs+328 src/shape.rs+418 src/show.rs+325 src/value.rs+518 tests/base.rs+489 tests/builtins.rs+283 tests/cli.rs+474 tests/core.rs+176 tests/display.rs+280 tests/domain.rs+309 tests/eval.rs+341 tests/interop.rs+196 tests/laws.proptest-regressions+14 tests/laws.rs+503 tests/lex.rs+243 tests/more.rs+422 tests/parse.rs+260 tests/remote.rs+282 tests/show.rs+317 tests/showprop.proptest-regressions+10 tests/showprop.rs+74 tests/tree.rs+350 tests/validation.rs+355
.gitignorenew file+4
@@ -0,0 +1,4 @@
1+/target
2+/result
3+/result-*
4+/.vix
Cargo.locknew file+2773
@@ -0,0 +1,2773 @@
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 = "allocator-api2"
16+version = "0.2.21"
17+source = "registry+https://github.com/rust-lang/crates.io-index"
18+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
19+
20+[[package]]
21+name = "android_system_properties"
22+version = "0.1.6"
23+source = "registry+https://github.com/rust-lang/crates.io-index"
24+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
25+dependencies = [
26+ "libc",
27+]
28+
29+[[package]]
30+name = "anyhow"
31+version = "1.0.104"
32+source = "registry+https://github.com/rust-lang/crates.io-index"
33+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
34+
35+[[package]]
36+name = "arc-swap"
37+version = "1.9.2"
38+source = "registry+https://github.com/rust-lang/crates.io-index"
39+checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
40+dependencies = [
41+ "rustversion",
42+]
43+
44+[[package]]
45+name = "arrayvec"
46+version = "0.7.8"
47+source = "registry+https://github.com/rust-lang/crates.io-index"
48+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
49+
50+[[package]]
51+name = "async-trait"
52+version = "0.1.92"
53+source = "registry+https://github.com/rust-lang/crates.io-index"
54+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
55+dependencies = [
56+ "proc-macro2",
57+ "quote",
58+ "syn 3.0.5",
59+]
60+
61+[[package]]
62+name = "autocfg"
63+version = "1.5.1"
64+source = "registry+https://github.com/rust-lang/crates.io-index"
65+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
66+
67+[[package]]
68+name = "beef"
69+version = "0.5.2"
70+source = "registry+https://github.com/rust-lang/crates.io-index"
71+checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1"
72+
73+[[package]]
74+name = "bit-set"
75+version = "0.8.0"
76+source = "registry+https://github.com/rust-lang/crates.io-index"
77+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
78+dependencies = [
79+ "bit-vec",
80+]
81+
82+[[package]]
83+name = "bit-vec"
84+version = "0.8.0"
85+source = "registry+https://github.com/rust-lang/crates.io-index"
86+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
87+
88+[[package]]
89+name = "bitflags"
90+version = "1.3.2"
91+source = "registry+https://github.com/rust-lang/crates.io-index"
92+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
93+
94+[[package]]
95+name = "bitflags"
96+version = "2.13.2"
97+source = "registry+https://github.com/rust-lang/crates.io-index"
98+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
99+
100+[[package]]
101+name = "blake2"
102+version = "0.10.6"
103+source = "registry+https://github.com/rust-lang/crates.io-index"
104+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
105+dependencies = [
106+ "digest 0.10.7",
107+]
108+
109+[[package]]
110+name = "block-buffer"
111+version = "0.10.4"
112+source = "registry+https://github.com/rust-lang/crates.io-index"
113+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
114+dependencies = [
115+ "generic-array",
116+]
117+
118+[[package]]
119+name = "block-buffer"
120+version = "0.12.1"
121+source = "registry+https://github.com/rust-lang/crates.io-index"
122+checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
123+dependencies = [
124+ "hybrid-array",
125+]
126+
127+[[package]]
128+name = "bstr"
129+version = "1.13.1"
130+source = "registry+https://github.com/rust-lang/crates.io-index"
131+checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f"
132+dependencies = [
133+ "memchr",
134+ "regex-automata",
135+ "serde_core",
136+]
137+
138+[[package]]
139+name = "bumpalo"
140+version = "3.20.3"
141+source = "registry+https://github.com/rust-lang/crates.io-index"
142+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
143+
144+[[package]]
145+name = "byteorder"
146+version = "1.5.0"
147+source = "registry+https://github.com/rust-lang/crates.io-index"
148+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
149+
150+[[package]]
151+name = "bytes"
152+version = "1.12.1"
153+source = "registry+https://github.com/rust-lang/crates.io-index"
154+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
155+
156+[[package]]
157+name = "cc"
158+version = "1.4.6"
159+source = "registry+https://github.com/rust-lang/crates.io-index"
160+checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
161+dependencies = [
162+ "find-msvc-tools",
163+ "shlex",
164+]
165+
166+[[package]]
167+name = "cfg-if"
168+version = "1.0.4"
169+source = "registry+https://github.com/rust-lang/crates.io-index"
170+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
171+
172+[[package]]
173+name = "chacha20"
174+version = "0.10.2"
175+source = "registry+https://github.com/rust-lang/crates.io-index"
176+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
177+dependencies = [
178+ "cfg-if",
179+ "cpufeatures 0.3.1",
180+ "rand_core 0.10.1",
181+]
182+
183+[[package]]
184+name = "chrono"
185+version = "0.4.45"
186+source = "registry+https://github.com/rust-lang/crates.io-index"
187+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
188+dependencies = [
189+ "iana-time-zone",
190+ "num-traits",
191+ "serde",
192+ "windows-link",
193+]
194+
195+[[package]]
196+name = "clru"
197+version = "0.6.3"
198+source = "registry+https://github.com/rust-lang/crates.io-index"
199+checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5"
200+dependencies = [
201+ "hashbrown 0.16.1",
202+]
203+
204+[[package]]
205+name = "core-foundation-sys"
206+version = "0.8.7"
207+source = "registry+https://github.com/rust-lang/crates.io-index"
208+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
209+
210+[[package]]
211+name = "core_detect"
212+version = "1.0.0"
213+source = "registry+https://github.com/rust-lang/crates.io-index"
214+checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
215+
216+[[package]]
217+name = "cpufeatures"
218+version = "0.2.17"
219+source = "registry+https://github.com/rust-lang/crates.io-index"
220+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
221+dependencies = [
222+ "libc",
223+]
224+
225+[[package]]
226+name = "cpufeatures"
227+version = "0.3.1"
228+source = "registry+https://github.com/rust-lang/crates.io-index"
229+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
230+dependencies = [
231+ "libc",
232+]
233+
234+[[package]]
235+name = "crc32fast"
236+version = "1.5.2"
237+source = "registry+https://github.com/rust-lang/crates.io-index"
238+checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
239+dependencies = [
240+ "cfg-if",
241+]
242+
243+[[package]]
244+name = "crossbeam-channel"
245+version = "0.5.17"
246+source = "registry+https://github.com/rust-lang/crates.io-index"
247+checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
248+dependencies = [
249+ "crossbeam-utils",
250+]
251+
252+[[package]]
253+name = "crossbeam-deque"
254+version = "0.8.8"
255+source = "registry+https://github.com/rust-lang/crates.io-index"
256+checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
257+dependencies = [
258+ "crossbeam-epoch",
259+ "crossbeam-utils",
260+]
261+
262+[[package]]
263+name = "crossbeam-epoch"
264+version = "0.9.21"
265+source = "registry+https://github.com/rust-lang/crates.io-index"
266+checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
267+dependencies = [
268+ "crossbeam-utils",
269+]
270+
271+[[package]]
272+name = "crossbeam-utils"
273+version = "0.8.23"
274+source = "registry+https://github.com/rust-lang/crates.io-index"
275+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
276+
277+[[package]]
278+name = "crypto-common"
279+version = "0.1.7"
280+source = "registry+https://github.com/rust-lang/crates.io-index"
281+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
282+dependencies = [
283+ "generic-array",
284+ "typenum",
285+]
286+
287+[[package]]
288+name = "crypto-common"
289+version = "0.2.2"
290+source = "registry+https://github.com/rust-lang/crates.io-index"
291+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
292+dependencies = [
293+ "hybrid-array",
294+]
295+
296+[[package]]
297+name = "dashmap"
298+version = "6.2.1"
299+source = "registry+https://github.com/rust-lang/crates.io-index"
300+checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
301+dependencies = [
302+ "cfg-if",
303+ "crossbeam-utils",
304+ "hashbrown 0.14.5",
305+ "lock_api",
306+ "once_cell",
307+ "parking_lot_core",
308+]
309+
310+[[package]]
311+name = "defmt"
312+version = "1.1.1"
313+source = "registry+https://github.com/rust-lang/crates.io-index"
314+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
315+dependencies = [
316+ "bitflags 1.3.2",
317+ "defmt-macros",
318+]
319+
320+[[package]]
321+name = "defmt-macros"
322+version = "1.1.1"
323+source = "registry+https://github.com/rust-lang/crates.io-index"
324+checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
325+dependencies = [
326+ "defmt-parser",
327+ "proc-macro2",
328+ "quote",
329+ "syn 2.0.119",
330+]
331+
332+[[package]]
333+name = "defmt-parser"
334+version = "1.0.0"
335+source = "registry+https://github.com/rust-lang/crates.io-index"
336+checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
337+dependencies = [
338+ "thiserror",
339+]
340+
341+[[package]]
342+name = "derive-ex"
343+version = "0.1.8"
344+source = "registry+https://github.com/rust-lang/crates.io-index"
345+checksum = "bba95f299f6b9cd47f68a847eca2ae9060a2713af532dc35c342065544845407"
346+dependencies = [
347+ "proc-macro2",
348+ "quote",
349+ "structmeta",
350+ "syn 2.0.119",
351+]
352+
353+[[package]]
354+name = "digest"
355+version = "0.10.7"
356+source = "registry+https://github.com/rust-lang/crates.io-index"
357+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
358+dependencies = [
359+ "block-buffer 0.10.4",
360+ "crypto-common 0.1.7",
361+ "subtle",
362+]
363+
364+[[package]]
365+name = "digest"
366+version = "0.11.3"
367+source = "registry+https://github.com/rust-lang/crates.io-index"
368+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
369+dependencies = [
370+ "block-buffer 0.12.1",
371+ "crypto-common 0.2.2",
372+]
373+
374+[[package]]
375+name = "dunce"
376+version = "1.0.5"
377+source = "registry+https://github.com/rust-lang/crates.io-index"
378+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
379+
380+[[package]]
381+name = "either"
382+version = "1.18.0"
383+source = "registry+https://github.com/rust-lang/crates.io-index"
384+checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
385+
386+[[package]]
387+name = "encoding_rs"
388+version = "0.8.41"
389+source = "registry+https://github.com/rust-lang/crates.io-index"
390+checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a"
391+dependencies = [
392+ "cfg-if",
393+ "core_detect",
394+ "multiversion",
395+ "multiversion_no_op",
396+ "rustversion",
397+ "scopeguard",
398+ "simdutf8",
399+]
400+
401+[[package]]
402+name = "equivalent"
403+version = "1.0.2"
404+source = "registry+https://github.com/rust-lang/crates.io-index"
405+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
406+
407+[[package]]
408+name = "errno"
409+version = "0.3.14"
410+source = "registry+https://github.com/rust-lang/crates.io-index"
411+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
412+dependencies = [
413+ "libc",
414+ "windows-sys",
415+]
416+
417+[[package]]
418+name = "etcetera"
419+version = "0.11.0"
420+source = "registry+https://github.com/rust-lang/crates.io-index"
421+checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96"
422+dependencies = [
423+ "cfg-if",
424+ "windows-sys",
425+]
426+
427+[[package]]
428+name = "faster-hex"
429+version = "0.10.0"
430+source = "registry+https://github.com/rust-lang/crates.io-index"
431+checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73"
432+dependencies = [
433+ "heapless",
434+ "serde",
435+]
436+
437+[[package]]
438+name = "fastrand"
439+version = "2.5.0"
440+source = "registry+https://github.com/rust-lang/crates.io-index"
441+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
442+
443+[[package]]
444+name = "filetime"
445+version = "0.2.29"
446+source = "registry+https://github.com/rust-lang/crates.io-index"
447+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
448+dependencies = [
449+ "cfg-if",
450+ "libc",
451+]
452+
453+[[package]]
454+name = "find-msvc-tools"
455+version = "0.1.12"
456+source = "registry+https://github.com/rust-lang/crates.io-index"
457+checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
458+
459+[[package]]
460+name = "fnv"
461+version = "1.0.7"
462+source = "registry+https://github.com/rust-lang/crates.io-index"
463+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
464+
465+[[package]]
466+name = "foldhash"
467+version = "0.2.0"
468+source = "registry+https://github.com/rust-lang/crates.io-index"
469+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
470+
471+[[package]]
472+name = "futures"
473+version = "0.3.34"
474+source = "registry+https://github.com/rust-lang/crates.io-index"
475+checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
476+dependencies = [
477+ "futures-channel",
478+ "futures-core",
479+ "futures-executor",
480+ "futures-io",
481+ "futures-sink",
482+ "futures-task",
483+ "futures-util",
484+]
485+
486+[[package]]
487+name = "futures-channel"
488+version = "0.3.34"
489+source = "registry+https://github.com/rust-lang/crates.io-index"
490+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
491+dependencies = [
492+ "futures-core",
493+ "futures-sink",
494+]
495+
496+[[package]]
497+name = "futures-core"
498+version = "0.3.34"
499+source = "registry+https://github.com/rust-lang/crates.io-index"
500+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
501+
502+[[package]]
503+name = "futures-executor"
504+version = "0.3.34"
505+source = "registry+https://github.com/rust-lang/crates.io-index"
506+checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
507+dependencies = [
508+ "futures-core",
509+ "futures-task",
510+ "futures-util",
511+]
512+
513+[[package]]
514+name = "futures-io"
515+version = "0.3.34"
516+source = "registry+https://github.com/rust-lang/crates.io-index"
517+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
518+
519+[[package]]
520+name = "futures-macro"
521+version = "0.3.34"
522+source = "registry+https://github.com/rust-lang/crates.io-index"
523+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
524+dependencies = [
525+ "proc-macro2",
526+ "quote",
527+ "syn 3.0.5",
528+]
529+
530+[[package]]
531+name = "futures-sink"
532+version = "0.3.34"
533+source = "registry+https://github.com/rust-lang/crates.io-index"
534+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
535+
536+[[package]]
537+name = "futures-task"
538+version = "0.3.34"
539+source = "registry+https://github.com/rust-lang/crates.io-index"
540+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
541+
542+[[package]]
543+name = "futures-util"
544+version = "0.3.34"
545+source = "registry+https://github.com/rust-lang/crates.io-index"
546+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
547+dependencies = [
548+ "futures-channel",
549+ "futures-core",
550+ "futures-io",
551+ "futures-macro",
552+ "futures-sink",
553+ "futures-task",
554+ "memchr",
555+ "pin-project-lite",
556+ "slab",
557+]
558+
559+[[package]]
560+name = "generic-array"
561+version = "0.14.7"
562+source = "registry+https://github.com/rust-lang/crates.io-index"
563+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
564+dependencies = [
565+ "typenum",
566+ "version_check",
567+]
568+
569+[[package]]
570+name = "getrandom"
571+version = "0.3.4"
572+source = "registry+https://github.com/rust-lang/crates.io-index"
573+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
574+dependencies = [
575+ "cfg-if",
576+ "libc",
577+ "r-efi 5.3.0",
578+ "wasip2",
579+]
580+
581+[[package]]
582+name = "getrandom"
583+version = "0.4.3"
584+source = "registry+https://github.com/rust-lang/crates.io-index"
585+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
586+dependencies = [
587+ "cfg-if",
588+ "libc",
589+ "r-efi 6.0.0",
590+ "rand_core 0.10.1",
591+]
592+
593+[[package]]
594+name = "gix"
595+version = "0.87.1"
596+source = "registry+https://github.com/rust-lang/crates.io-index"
597+checksum = "fdefc1465d8631807deaf504dacdeff628b978de515653b47976ca7c28ab2a7a"
598+dependencies = [
599+ "gix-actor",
600+ "gix-attributes",
601+ "gix-command",
602+ "gix-commitgraph",
603+ "gix-config",
604+ "gix-date",
605+ "gix-diff",
606+ "gix-discover",
607+ "gix-error",
608+ "gix-features",
609+ "gix-filter",
610+ "gix-fs",
611+ "gix-glob",
612+ "gix-hash",
613+ "gix-hashtable",
614+ "gix-ignore",
615+ "gix-index",
616+ "gix-lock",
617+ "gix-note",
618+ "gix-object",
619+ "gix-odb",
620+ "gix-pack",
621+ "gix-path",
622+ "gix-pathspec",
623+ "gix-protocol",
624+ "gix-quote",
625+ "gix-ref",
626+ "gix-refspec",
627+ "gix-revision",
628+ "gix-revwalk",
629+ "gix-sec",
630+ "gix-shallow",
631+ "gix-submodule",
632+ "gix-tempfile",
633+ "gix-trace",
634+ "gix-traverse",
635+ "gix-url",
636+ "gix-utils",
637+ "gix-validate",
638+ "gix-worktree",
639+ "gix-worktree-stream",
640+ "gix-zlib",
641+ "nonempty",
642+ "smallvec",
643+ "thiserror",
644+]
645+
646+[[package]]
647+name = "gix-actor"
648+version = "0.42.0"
649+source = "registry+https://github.com/rust-lang/crates.io-index"
650+checksum = "e37efa99929ac62f980fb1a7dcd09dea85b3aa6ead6ba0498362add3f4e698de"
651+dependencies = [
652+ "bstr",
653+ "gix-date",
654+ "gix-error",
655+]
656+
657+[[package]]
658+name = "gix-attributes"
659+version = "0.35.0"
660+source = "registry+https://github.com/rust-lang/crates.io-index"
661+checksum = "76b83782ac69ae28921a6e8fbcf2e24069613f1518030b97ae622b079abffa54"
662+dependencies = [
663+ "bstr",
664+ "gix-features",
665+ "gix-glob",
666+ "gix-path",
667+ "gix-quote",
668+ "gix-trace",
669+ "smallvec",
670+ "thiserror",
671+ "unicode-bom",
672+]
673+
674+[[package]]
675+name = "gix-bitmap"
676+version = "0.4.0"
677+source = "registry+https://github.com/rust-lang/crates.io-index"
678+checksum = "f17013c7ef5cb95ebb4bf4978201e6a8a42ffea9d666fd5388f1b7e742307d80"
679+dependencies = [
680+ "gix-error",
681+]
682+
683+[[package]]
684+name = "gix-chunk"
685+version = "0.8.0"
686+source = "registry+https://github.com/rust-lang/crates.io-index"
687+checksum = "00e32f938b3745ac93d73bc7490b6a15a661b2fc81973bfe90e275cc53c908e1"
688+dependencies = [
689+ "gix-error",
690+]
691+
692+[[package]]
693+name = "gix-command"
694+version = "0.10.1"
695+source = "registry+https://github.com/rust-lang/crates.io-index"
696+checksum = "aa00adbd2c77f584cfe41f348772d2f131af1fa519a7f6b98638f1144ead96a4"
697+dependencies = [
698+ "bstr",
699+ "gix-path",
700+ "gix-quote",
701+ "gix-trace",
702+]
703+
704+[[package]]
705+name = "gix-commitgraph"
706+version = "0.39.0"
707+source = "registry+https://github.com/rust-lang/crates.io-index"
708+checksum = "6b93c9fb1f5be01bba54a7a3b7455d359efc68555539c75ef74b8021bb6db497"
709+dependencies = [
710+ "bstr",
711+ "gix-chunk",
712+ "gix-error",
713+ "gix-hash",
714+ "memmap2",
715+ "nonempty",
716+]
717+
718+[[package]]
719+name = "gix-config"
720+version = "0.60.0"
721+source = "registry+https://github.com/rust-lang/crates.io-index"
722+checksum = "f973c28c0a4871a7926fc90c8377d4458fd6cbb19692f56582b4d2022313ae90"
723+dependencies = [
724+ "bstr",
725+ "gix-config-value",
726+ "gix-features",
727+ "gix-glob",
728+ "gix-path",
729+ "gix-ref",
730+ "gix-sec",
731+ "gix-utils",
732+ "smallvec",
733+ "thiserror",
734+ "unicode-bom",
735+]
736+
737+[[package]]
738+name = "gix-config-value"
739+version = "0.19.1"
740+source = "registry+https://github.com/rust-lang/crates.io-index"
741+checksum = "6f6af5321bfd3711a279d6b244d58532ba1cfabf9eb6374791f19929d8970082"
742+dependencies = [
743+ "bitflags 2.13.2",
744+ "bstr",
745+ "gix-path",
746+ "libc",
747+ "thiserror",
748+]
749+
750+[[package]]
751+name = "gix-date"
752+version = "0.16.0"
753+source = "registry+https://github.com/rust-lang/crates.io-index"
754+checksum = "e63d9aa18f29facd571c8953e66224ee0075b9e16622024794555ed4acceea85"
755+dependencies = [
756+ "bstr",
757+ "gix-error",
758+ "itoa",
759+ "jiff",
760+]
761+
762+[[package]]
763+name = "gix-diff"
764+version = "0.67.1"
765+source = "registry+https://github.com/rust-lang/crates.io-index"
766+checksum = "1b1689ff5ddeee4acfb2a43e875a1072a76d208624b15a9065c938b39cb0da2a"
767+dependencies = [
768+ "bstr",
769+ "gix-command",
770+ "gix-filter",
771+ "gix-fs",
772+ "gix-hash",
773+ "gix-imara-diff",
774+ "gix-object",
775+ "gix-path",
776+ "gix-tempfile",
777+ "gix-trace",
778+ "gix-traverse",
779+ "gix-worktree",
780+ "thiserror",
781+]
782+
783+[[package]]
784+name = "gix-discover"
785+version = "0.55.0"
786+source = "registry+https://github.com/rust-lang/crates.io-index"
787+checksum = "ec32a30ec2934735599c2545f30067e80bd255fd3454b52a5d59bc02b52874a3"
788+dependencies = [
789+ "bstr",
790+ "dunce",
791+ "gix-fs",
792+ "gix-path",
793+ "gix-ref",
794+ "gix-sec",
795+ "thiserror",
796+]
797+
798+[[package]]
799+name = "gix-error"
800+version = "0.3.2"
801+source = "registry+https://github.com/rust-lang/crates.io-index"
802+checksum = "4a1ba536602db507119eb5f3cdf80bef3ca5a5ec0dfc6e9c7f46dc70ade7c112"
803+dependencies = [
804+ "bstr",
805+]
806+
807+[[package]]
808+name = "gix-features"
809+version = "0.49.1"
810+source = "registry+https://github.com/rust-lang/crates.io-index"
811+checksum = "39c0e59d9d253dcccc38c3a46b91bfb9b46bd63eed54fe1a719e12194884d52a"
812+dependencies = [
813+ "bytes",
814+ "crc32fast",
815+ "crossbeam-channel",
816+ "gix-path",
817+ "gix-trace",
818+ "gix-utils",
819+ "libc",
820+ "once_cell",
821+ "parking_lot",
822+ "prodash",
823+ "walkdir",
824+]
825+
826+[[package]]
827+name = "gix-filter"
828+version = "0.34.0"
829+source = "registry+https://github.com/rust-lang/crates.io-index"
830+checksum = "9c30b28d957e2e6f1e1bbfbfd26b8e0bb0aab68d8a5e730fb4fd3049943575e3"
831+dependencies = [
832+ "bstr",
833+ "encoding_rs",
834+ "gix-attributes",
835+ "gix-command",
836+ "gix-hash",
837+ "gix-object",
838+ "gix-packetline",
839+ "gix-path",
840+ "gix-quote",
841+ "gix-trace",
842+ "gix-utils",
843+ "smallvec",
844+ "thiserror",
845+]
846+
847+[[package]]
848+name = "gix-fs"
849+version = "0.22.1"
850+source = "registry+https://github.com/rust-lang/crates.io-index"
851+checksum = "ebcfa9fd253f25350a3b21b3dd74034a446098e373c6123d4cee3519894f12ef"
852+dependencies = [
853+ "bstr",
854+ "gix-features",
855+ "gix-path",
856+ "gix-utils",
857+ "thiserror",
858+]
859+
860+[[package]]
861+name = "gix-glob"
862+version = "0.27.1"
863+source = "registry+https://github.com/rust-lang/crates.io-index"
864+checksum = "b417cf515fd8c91468b578071f76d6cba716f8a1eccd853906bff4908b2c1413"
865+dependencies = [
866+ "bitflags 2.13.2",
867+ "bstr",
868+ "gix-features",
869+ "gix-path",
870+]
871+
872+[[package]]
873+name = "gix-hash"
874+version = "0.26.2"
875+source = "registry+https://github.com/rust-lang/crates.io-index"
876+checksum = "380b9c423a54f0821064b954b5a5ab25c660812f6a91da03c3f1cb4b10762aa5"
877+dependencies = [
878+ "faster-hex",
879+ "gix-features",
880+ "sha1-checked",
881+ "sha2",
882+ "thiserror",
883+]
884+
885+[[package]]
886+name = "gix-hashtable"
887+version = "0.16.0"
888+source = "registry+https://github.com/rust-lang/crates.io-index"
889+checksum = "78fccd6fea3bcf0b39c076bae60ae49b08daaf538b950202101a981f9d3c01d3"
890+dependencies = [
891+ "gix-hash",
892+ "hashbrown 0.17.1",
893+ "parking_lot",
894+]
895+
896+[[package]]
897+name = "gix-ignore"
898+version = "0.22.1"
899+source = "registry+https://github.com/rust-lang/crates.io-index"
900+checksum = "65859a2f7de5e159d4344a5486ebf53b6bac22d5ec6afa836e47c10ae88a7f0f"
901+dependencies = [
902+ "bstr",
903+ "gix-glob",
904+ "gix-path",
905+ "gix-trace",
906+ "unicode-bom",
907+]
908+
909+[[package]]
910+name = "gix-imara-diff"
911+version = "0.2.5"
912+source = "registry+https://github.com/rust-lang/crates.io-index"
913+checksum = "1c91d8cffac8849493a82233811bd02b2b183b8cf39bf704de0fa0841b737595"
914+dependencies = [
915+ "bstr",
916+ "hashbrown 0.17.1",
917+]
918+
919+[[package]]
920+name = "gix-index"
921+version = "0.55.0"
922+source = "registry+https://github.com/rust-lang/crates.io-index"
923+checksum = "632e16cb48b0e88a747e106924cb2765194105d8070a7a961b0d080ed806c632"
924+dependencies = [
925+ "bitflags 2.13.2",
926+ "bstr",
927+ "filetime",
928+ "fnv",
929+ "gix-bitmap",
930+ "gix-features",
931+ "gix-fs",
932+ "gix-hash",
933+ "gix-lock",
934+ "gix-object",
935+ "gix-traverse",
936+ "gix-utils",
937+ "gix-validate",
938+ "hashbrown 0.17.1",
939+ "itoa",
940+ "libc",
941+ "memmap2",
942+ "rustix",
943+ "smallvec",
944+ "thiserror",
945+]
946+
947+[[package]]
948+name = "gix-lock"
949+version = "24.0.0"
950+source = "registry+https://github.com/rust-lang/crates.io-index"
951+checksum = "d4c69157820343bf1c6e4b88b9808e920900de02e18aaf5862b30ada43814848"
952+dependencies = [
953+ "gix-tempfile",
954+ "gix-utils",
955+ "thiserror",
956+]
957+
958+[[package]]
959+name = "gix-macros"
960+version = "0.1.6"
961+source = "registry+https://github.com/rust-lang/crates.io-index"
962+checksum = "d3836b4b051393464a753c5a08fe19c7ce0d8b77574a92bd558972941d4553cb"
963+dependencies = [
964+ "proc-macro2",
965+ "quote",
966+ "syn 2.0.119",
967+]
968+
969+[[package]]
970+name = "gix-note"
971+version = "0.1.1"
972+source = "registry+https://github.com/rust-lang/crates.io-index"
973+checksum = "9af6bbdb3f3c62b4920407c479af1cc0beb27fd75a762782d48baed53d330958"
974+dependencies = [
975+ "gix-error",
976+ "gix-hash",
977+ "gix-hashtable",
978+ "gix-object",
979+]
980+
981+[[package]]
982+name = "gix-object"
983+version = "0.64.1"
984+source = "registry+https://github.com/rust-lang/crates.io-index"
985+checksum = "56fef799ca40cdeab4de5a3e74a696d8fa9b9c6264592646bf022e026f13ce2c"
986+dependencies = [
987+ "bstr",
988+ "gix-actor",
989+ "gix-command",
990+ "gix-date",
991+ "gix-features",
992+ "gix-hash",
993+ "gix-hashtable",
994+ "gix-tempfile",
995+ "gix-utils",
996+ "gix-validate",
997+ "itoa",
998+ "smallvec",
999+ "thiserror",
1000+]
1001+
1002+[[package]]
1003+name = "gix-odb"
1004+version = "0.84.0"
1005+source = "registry+https://github.com/rust-lang/crates.io-index"
1006+checksum = "cf45d195b71cb6363886e7b466821bf4dffd7aba1b6b814ff75488e0061d72a7"
1007+dependencies = [
1008+ "arc-swap",
1009+ "gix-features",
1010+ "gix-fs",
1011+ "gix-hash",
1012+ "gix-hashtable",
1013+ "gix-object",
1014+ "gix-pack",
1015+ "gix-path",
1016+ "gix-quote",
1017+ "gix-zlib",
1018+ "memmap2",
1019+ "parking_lot",
1020+ "tempfile",
1021+ "thiserror",
1022+]
1023+
1024+[[package]]
1025+name = "gix-pack"
1026+version = "0.74.2"
1027+source = "registry+https://github.com/rust-lang/crates.io-index"
1028+checksum = "3d199d515cbdcf05f531f3859be9771fc12acac7b7fe0f5b581b2c1ab1c3a61e"
1029+dependencies = [
1030+ "clru",
1031+ "crossbeam-deque",
1032+ "gix-chunk",
1033+ "gix-error",
1034+ "gix-features",
1035+ "gix-hash",
1036+ "gix-hashtable",
1037+ "gix-object",
1038+ "gix-path",
1039+ "gix-zlib",
1040+ "memmap2",
1041+ "smallvec",
1042+ "thiserror",
1043+ "uluru",
1044+]
1045+
1046+[[package]]
1047+name = "gix-packetline"
1048+version = "0.22.2"
1049+source = "registry+https://github.com/rust-lang/crates.io-index"
1050+checksum = "792bd92bea390087e6223b18d4d023decc36467de31e6b066b69b94beea1b9e2"
1051+dependencies = [
1052+ "bstr",
1053+ "faster-hex",
1054+ "gix-trace",
1055+ "thiserror",
1056+]
1057+
1058+[[package]]
1059+name = "gix-path"
1060+version = "0.12.6"
1061+source = "registry+https://github.com/rust-lang/crates.io-index"
1062+checksum = "38fc6f029ea67de83cbcbd33fd98c05a48360d2932b39d9fdacbc6eae802d475"
1063+dependencies = [
1064+ "bstr",
1065+ "gix-trace",
1066+ "gix-validate",
1067+ "thiserror",
1068+]
1069+
1070+[[package]]
1071+name = "gix-pathspec"
1072+version = "0.20.0"
1073+source = "registry+https://github.com/rust-lang/crates.io-index"
1074+checksum = "c92e44c63ba53bb55aa88ee48847664beae9446417621582fb14dfc69b2f8c72"
1075+dependencies = [
1076+ "bitflags 2.13.2",
1077+ "bstr",
1078+ "gix-attributes",
1079+ "gix-config-value",
1080+ "gix-glob",
1081+ "gix-path",
1082+ "thiserror",
1083+]
1084+
1085+[[package]]
1086+name = "gix-protocol"
1087+version = "0.65.1"
1088+source = "registry+https://github.com/rust-lang/crates.io-index"
1089+checksum = "68878c37ae168b474c762d70adc8a8e0f9323ed0d80444a672e2eb561fa16691"
1090+dependencies = [
1091+ "bstr",
1092+ "gix-date",
1093+ "gix-features",
1094+ "gix-hash",
1095+ "gix-macros",
1096+ "gix-ref",
1097+ "gix-shallow",
1098+ "gix-transport",
1099+ "gix-utils",
1100+ "nonempty",
1101+ "thiserror",
1102+]
1103+
1104+[[package]]
1105+name = "gix-quote"
1106+version = "0.8.0"
1107+source = "registry+https://github.com/rust-lang/crates.io-index"
1108+checksum = "f74795eb76eab8313063849f9cbd2bbcdc6e4692f71c1d7d0244ab11cd777329"
1109+dependencies = [
1110+ "bstr",
1111+ "gix-error",
1112+ "gix-utils",
1113+]
1114+
1115+[[package]]
1116+name = "gix-ref"
1117+version = "0.67.1"
1118+source = "registry+https://github.com/rust-lang/crates.io-index"
1119+checksum = "8328387e7ab354e1dc3afb63e6b4d1866f82d7ce035222bc9d5baaf36bc88020"
1120+dependencies = [
1121+ "gix-actor",
1122+ "gix-features",
1123+ "gix-fs",
1124+ "gix-hash",
1125+ "gix-lock",
1126+ "gix-object",
1127+ "gix-path",
1128+ "gix-tempfile",
1129+ "gix-utils",
1130+ "gix-validate",
1131+ "memmap2",
1132+ "thiserror",
1133+]
1134+
1135+[[package]]
1136+name = "gix-refspec"
1137+version = "0.45.1"
1138+source = "registry+https://github.com/rust-lang/crates.io-index"
1139+checksum = "7de280d46e8fd9e4d3e7e2ab4276b965e377ba68b2b9a8fce56371015e8bb059"
1140+dependencies = [
1141+ "bstr",
1142+ "gix-hash",
1143+ "gix-validate",
1144+ "smallvec",
1145+ "thiserror",
1146+]
1147+
1148+[[package]]
1149+name = "gix-revision"
1150+version = "0.49.1"
1151+source = "registry+https://github.com/rust-lang/crates.io-index"
1152+checksum = "4577b864c3e134697e91564553e43230c2e401bb1bcc51cfa998edca21c95078"
1153+dependencies = [
1154+ "bstr",
1155+ "gix-commitgraph",
1156+ "gix-date",
1157+ "gix-error",
1158+ "gix-hash",
1159+ "gix-object",
1160+ "gix-revwalk",
1161+ "nonempty",
1162+]
1163+
1164+[[package]]
1165+name = "gix-revwalk"
1166+version = "0.35.0"
1167+source = "registry+https://github.com/rust-lang/crates.io-index"
1168+checksum = "248823eefe405e2c0754b74f6f43ab6faab68670cdbf2d6e114cb0471f766450"
1169+dependencies = [
1170+ "gix-commitgraph",
1171+ "gix-date",
1172+ "gix-error",
1173+ "gix-hash",
1174+ "gix-hashtable",
1175+ "gix-object",
1176+ "smallvec",
1177+ "thiserror",
1178+]
1179+
1180+[[package]]
1181+name = "gix-sec"
1182+version = "0.14.2"
1183+source = "registry+https://github.com/rust-lang/crates.io-index"
1184+checksum = "af4fe6c152c1d50aea36f299825702cd37e303307832fec1d0fdd5844e47ce2f"
1185+dependencies = [
1186+ "bitflags 2.13.2",
1187+ "gix-path",
1188+ "libc",
1189+ "windows-sys",
1190+]
1191+
1192+[[package]]
1193+name = "gix-shallow"
1194+version = "0.13.0"
1195+source = "registry+https://github.com/rust-lang/crates.io-index"
1196+checksum = "1ecc9f4b40537043e4bbd7d3d1760e74fb8e7b07a546166b558acaa73ad97f4a"
1197+dependencies = [
1198+ "bstr",
1199+ "gix-hash",
1200+ "gix-lock",
1201+ "nonempty",
1202+ "thiserror",
1203+]
1204+
1205+[[package]]
1206+name = "gix-submodule"
1207+version = "0.34.0"
1208+source = "registry+https://github.com/rust-lang/crates.io-index"
1209+checksum = "da85564d6725c483b8d95d7b31d1db0b78c29e462a2b481949e2f18e1ed55a6a"
1210+dependencies = [
1211+ "bstr",
1212+ "gix-config",
1213+ "gix-path",
1214+ "gix-pathspec",
1215+ "gix-refspec",
1216+ "gix-url",
1217+ "thiserror",
1218+]
1219+
1220+[[package]]
1221+name = "gix-tempfile"
1222+version = "24.0.0"
1223+source = "registry+https://github.com/rust-lang/crates.io-index"
1224+checksum = "b675b920bd5a61d17ad542772f03ec34c60feb8ff683e1560c03ae967363731e"
1225+dependencies = [
1226+ "dashmap",
1227+ "gix-fs",
1228+ "libc",
1229+ "parking_lot",
1230+ "tempfile",
1231+]
1232+
1233+[[package]]
1234+name = "gix-trace"
1235+version = "0.1.21"
1236+source = "registry+https://github.com/rust-lang/crates.io-index"
1237+checksum = "be3eb81d9dc914335923e50d52829c551feefd6a72d176c4130c546b67a60814"
1238+
1239+[[package]]
1240+name = "gix-transport"
1241+version = "0.59.2"
1242+source = "registry+https://github.com/rust-lang/crates.io-index"
1243+checksum = "b3f41a64939953ff49117df4eb91334d299abc59ff57a90fb2193d8ffa5886f1"
1244+dependencies = [
1245+ "bstr",
1246+ "gix-command",
1247+ "gix-features",
1248+ "gix-packetline",
1249+ "gix-path",
1250+ "gix-quote",
1251+ "gix-sec",
1252+ "gix-url",
1253+ "thiserror",
1254+]
1255+
1256+[[package]]
1257+name = "gix-traverse"
1258+version = "0.61.0"
1259+source = "registry+https://github.com/rust-lang/crates.io-index"
1260+checksum = "9af3503668739f4de5dba57fe15cfd9adc443b08bcbbf195e5de16b648872245"
1261+dependencies = [
1262+ "bitflags 2.13.2",
1263+ "gix-commitgraph",
1264+ "gix-date",
1265+ "gix-hash",
1266+ "gix-hashtable",
1267+ "gix-object",
1268+ "gix-revwalk",
1269+ "smallvec",
1270+ "thiserror",
1271+]
1272+
1273+[[package]]
1274+name = "gix-url"
1275+version = "0.38.0"
1276+source = "registry+https://github.com/rust-lang/crates.io-index"
1277+checksum = "5bda80ccb4bc81fb9fb07a975916eb0c7a889bcbcb0c8a239c3f82a9cc2abdda"
1278+dependencies = [
1279+ "bstr",
1280+ "gix-path",
1281+ "gix-utils",
1282+ "percent-encoding",
1283+ "thiserror",
1284+]
1285+
1286+[[package]]
1287+name = "gix-utils"
1288+version = "0.3.6"
1289+source = "registry+https://github.com/rust-lang/crates.io-index"
1290+checksum = "0da1c46491b49458a446cc76f0085860f8164c2290742e0aa8c653ce67240a97"
1291+dependencies = [
1292+ "bstr",
1293+ "fastrand",
1294+ "getrandom 0.4.3",
1295+ "unicode-normalization",
1296+]
1297+
1298+[[package]]
1299+name = "gix-validate"
1300+version = "0.11.4"
1301+source = "registry+https://github.com/rust-lang/crates.io-index"
1302+checksum = "4dae8780f63ed8a803b8bdabbd7aa5f5c5d74592c8b50eed875c1bb4f6545a6a"
1303+dependencies = [
1304+ "bstr",
1305+]
1306+
1307+[[package]]
1308+name = "gix-worktree"
1309+version = "0.56.0"
1310+source = "registry+https://github.com/rust-lang/crates.io-index"
1311+checksum = "3ed36b627476e2072c129900a17c977a38052b5497e27308159bc881fbf4eecf"
1312+dependencies = [
1313+ "bstr",
1314+ "gix-attributes",
1315+ "gix-features",
1316+ "gix-fs",
1317+ "gix-glob",
1318+ "gix-hash",
1319+ "gix-ignore",
1320+ "gix-index",
1321+ "gix-object",
1322+ "gix-path",
1323+ "gix-validate",
1324+]
1325+
1326+[[package]]
1327+name = "gix-worktree-stream"
1328+version = "0.36.1"
1329+source = "registry+https://github.com/rust-lang/crates.io-index"
1330+checksum = "de5ec0ccbab39c9994656ea031ded8ffd90b370a3cc8e360fbe3c4ebe7c64964"
1331+dependencies = [
1332+ "gix-attributes",
1333+ "gix-error",
1334+ "gix-features",
1335+ "gix-filter",
1336+ "gix-fs",
1337+ "gix-hash",
1338+ "gix-object",
1339+ "gix-path",
1340+ "gix-traverse",
1341+ "parking_lot",
1342+]
1343+
1344+[[package]]
1345+name = "gix-zlib"
1346+version = "0.1.0"
1347+source = "registry+https://github.com/rust-lang/crates.io-index"
1348+checksum = "3e8813f5579b3075ff9c90f7c59cd2b62b4ebb639361f0911648b22d7446cc7c"
1349+dependencies = [
1350+ "thiserror",
1351+ "zlib-rs",
1352+]
1353+
1354+[[package]]
1355+name = "globset"
1356+version = "0.4.20"
1357+source = "registry+https://github.com/rust-lang/crates.io-index"
1358+checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988"
1359+dependencies = [
1360+ "aho-corasick",
1361+ "bstr",
1362+ "log",
1363+ "regex-automata",
1364+ "regex-syntax",
1365+]
1366+
1367+[[package]]
1368+name = "hash32"
1369+version = "0.3.1"
1370+source = "registry+https://github.com/rust-lang/crates.io-index"
1371+checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
1372+dependencies = [
1373+ "byteorder",
1374+]
1375+
1376+[[package]]
1377+name = "hashbrown"
1378+version = "0.14.5"
1379+source = "registry+https://github.com/rust-lang/crates.io-index"
1380+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
1381+
1382+[[package]]
1383+name = "hashbrown"
1384+version = "0.16.1"
1385+source = "registry+https://github.com/rust-lang/crates.io-index"
1386+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
1387+dependencies = [
1388+ "allocator-api2",
1389+ "equivalent",
1390+ "foldhash",
1391+]
1392+
1393+[[package]]
1394+name = "hashbrown"
1395+version = "0.17.1"
1396+source = "registry+https://github.com/rust-lang/crates.io-index"
1397+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
1398+dependencies = [
1399+ "allocator-api2",
1400+ "equivalent",
1401+ "foldhash",
1402+]
1403+
1404+[[package]]
1405+name = "heapless"
1406+version = "0.8.0"
1407+source = "registry+https://github.com/rust-lang/crates.io-index"
1408+checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
1409+dependencies = [
1410+ "hash32",
1411+ "stable_deref_trait",
1412+]
1413+
1414+[[package]]
1415+name = "hex"
1416+version = "0.4.3"
1417+source = "registry+https://github.com/rust-lang/crates.io-index"
1418+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
1419+
1420+[[package]]
1421+name = "hybrid-array"
1422+version = "0.4.15"
1423+source = "registry+https://github.com/rust-lang/crates.io-index"
1424+checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
1425+dependencies = [
1426+ "typenum",
1427+]
1428+
1429+[[package]]
1430+name = "iana-time-zone"
1431+version = "0.1.65"
1432+source = "registry+https://github.com/rust-lang/crates.io-index"
1433+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
1434+dependencies = [
1435+ "android_system_properties",
1436+ "core-foundation-sys",
1437+ "iana-time-zone-haiku",
1438+ "js-sys",
1439+ "log",
1440+ "wasm-bindgen",
1441+ "windows-core",
1442+]
1443+
1444+[[package]]
1445+name = "iana-time-zone-haiku"
1446+version = "0.1.2"
1447+source = "registry+https://github.com/rust-lang/crates.io-index"
1448+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
1449+dependencies = [
1450+ "cc",
1451+]
1452+
1453+[[package]]
1454+name = "indexmap"
1455+version = "2.14.2"
1456+source = "registry+https://github.com/rust-lang/crates.io-index"
1457+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
1458+dependencies = [
1459+ "equivalent",
1460+ "hashbrown 0.17.1",
1461+ "serde",
1462+ "serde_core",
1463+]
1464+
1465+[[package]]
1466+name = "interim"
1467+version = "0.2.1"
1468+source = "registry+https://github.com/rust-lang/crates.io-index"
1469+checksum = "a9ce9099a85f468663d3225bf87e85d0548968441e1db12248b996b24f0f5b5a"
1470+dependencies = [
1471+ "chrono",
1472+ "logos",
1473+]
1474+
1475+[[package]]
1476+name = "itertools"
1477+version = "0.14.0"
1478+source = "registry+https://github.com/rust-lang/crates.io-index"
1479+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
1480+dependencies = [
1481+ "either",
1482+]
1483+
1484+[[package]]
1485+name = "itertools"
1486+version = "0.15.0"
1487+source = "registry+https://github.com/rust-lang/crates.io-index"
1488+checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
1489+dependencies = [
1490+ "either",
1491+]
1492+
1493+[[package]]
1494+name = "itoa"
1495+version = "1.0.18"
1496+source = "registry+https://github.com/rust-lang/crates.io-index"
1497+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
1498+
1499+[[package]]
1500+name = "j"
1501+version = "0.1.0"
1502+dependencies = [
1503+ "futures",
1504+ "gix",
1505+ "hex",
1506+ "jj-lib",
1507+ "libc",
1508+ "num-bigint",
1509+ "pollster 0.4.0",
1510+ "proptest",
1511+ "similar",
1512+ "tempfile",
1513+ "test-strategy",
1514+ "unicode-width",
1515+]
1516+
1517+[[package]]
1518+name = "jiff"
1519+version = "0.2.37"
1520+source = "registry+https://github.com/rust-lang/crates.io-index"
1521+checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb"
1522+dependencies = [
1523+ "defmt",
1524+ "jiff-core",
1525+ "jiff-static",
1526+ "jiff-tzdb-platform",
1527+ "log",
1528+ "portable-atomic",
1529+ "portable-atomic-util",
1530+ "serde_core",
1531+ "windows-link",
1532+]
1533+
1534+[[package]]
1535+name = "jiff-core"
1536+version = "0.1.1"
1537+source = "registry+https://github.com/rust-lang/crates.io-index"
1538+checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
1539+dependencies = [
1540+ "defmt",
1541+ "log",
1542+]
1543+
1544+[[package]]
1545+name = "jiff-static"
1546+version = "0.2.37"
1547+source = "registry+https://github.com/rust-lang/crates.io-index"
1548+checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212"
1549+dependencies = [
1550+ "jiff-core",
1551+ "proc-macro2",
1552+ "quote",
1553+ "syn 2.0.119",
1554+]
1555+
1556+[[package]]
1557+name = "jiff-tzdb"
1558+version = "0.1.8"
1559+source = "registry+https://github.com/rust-lang/crates.io-index"
1560+checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
1561+
1562+[[package]]
1563+name = "jiff-tzdb-platform"
1564+version = "0.1.3"
1565+source = "registry+https://github.com/rust-lang/crates.io-index"
1566+checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
1567+dependencies = [
1568+ "jiff-tzdb",
1569+]
1570+
1571+[[package]]
1572+name = "jj-core"
1573+version = "0.45.1"
1574+source = "registry+https://github.com/rust-lang/crates.io-index"
1575+checksum = "f521a33ebbb33692d61c2fa419a2fc225fa84317679d4685b083d8466e5e1c93"
1576+dependencies = [
1577+ "blake2",
1578+ "bstr",
1579+ "digest 0.10.7",
1580+ "either",
1581+ "etcetera",
1582+ "futures",
1583+ "globset",
1584+ "hashbrown 0.17.1",
1585+ "indexmap",
1586+ "itertools 0.15.0",
1587+ "jj-core-proc-macros",
1588+ "ref-cast",
1589+ "regex",
1590+ "same-file",
1591+ "serde",
1592+ "smallvec",
1593+ "tempfile",
1594+ "thiserror",
1595+ "tracing",
1596+ "winreg",
1597+]
1598+
1599+[[package]]
1600+name = "jj-core-proc-macros"
1601+version = "0.45.1"
1602+source = "registry+https://github.com/rust-lang/crates.io-index"
1603+checksum = "4d82eb5a68ae2d05331e015e5293107b4307babaac8f8a8481d9d0fe153614f6"
1604+dependencies = [
1605+ "proc-macro2",
1606+ "quote",
1607+ "syn 3.0.5",
1608+]
1609+
1610+[[package]]
1611+name = "jj-lib"
1612+version = "0.45.1"
1613+source = "registry+https://github.com/rust-lang/crates.io-index"
1614+checksum = "188c8cec60bd9655643336b9fcbc59fd90ab40a14971c6228ed9e22f8e591607"
1615+dependencies = [
1616+ "async-trait",
1617+ "blake2",
1618+ "bstr",
1619+ "chrono",
1620+ "clru",
1621+ "digest 0.10.7",
1622+ "dunce",
1623+ "either",
1624+ "etcetera",
1625+ "futures",
1626+ "gix",
1627+ "gix-ignore",
1628+ "globset",
1629+ "indexmap",
1630+ "interim",
1631+ "itertools 0.15.0",
1632+ "jj-core",
1633+ "maplit",
1634+ "memchr",
1635+ "once_cell",
1636+ "pest",
1637+ "pest_derive",
1638+ "pollster 1.0.1",
1639+ "prost",
1640+ "rand 0.10.2",
1641+ "rand_chacha 0.10.0",
1642+ "rayon",
1643+ "ref-cast",
1644+ "regex",
1645+ "rustix",
1646+ "serde",
1647+ "smallvec",
1648+ "strsim",
1649+ "tempfile",
1650+ "thiserror",
1651+ "toml_edit",
1652+ "tracing",
1653+]
1654+
1655+[[package]]
1656+name = "js-sys"
1657+version = "0.3.105"
1658+source = "registry+https://github.com/rust-lang/crates.io-index"
1659+checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
1660+dependencies = [
1661+ "cfg-if",
1662+ "futures-util",
1663+ "wasm-bindgen",
1664+]
1665+
1666+[[package]]
1667+name = "lazy_static"
1668+version = "1.5.0"
1669+source = "registry+https://github.com/rust-lang/crates.io-index"
1670+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
1671+
1672+[[package]]
1673+name = "libc"
1674+version = "0.2.189"
1675+source = "registry+https://github.com/rust-lang/crates.io-index"
1676+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
1677+
1678+[[package]]
1679+name = "linux-raw-sys"
1680+version = "0.12.1"
1681+source = "registry+https://github.com/rust-lang/crates.io-index"
1682+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
1683+
1684+[[package]]
1685+name = "lock_api"
1686+version = "0.4.14"
1687+source = "registry+https://github.com/rust-lang/crates.io-index"
1688+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
1689+dependencies = [
1690+ "scopeguard",
1691+]
1692+
1693+[[package]]
1694+name = "log"
1695+version = "0.4.34"
1696+source = "registry+https://github.com/rust-lang/crates.io-index"
1697+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
1698+
1699+[[package]]
1700+name = "logos"
1701+version = "0.15.1"
1702+source = "registry+https://github.com/rust-lang/crates.io-index"
1703+checksum = "ff472f899b4ec2d99161c51f60ff7075eeb3097069a36050d8037a6325eb8154"
1704+dependencies = [
1705+ "logos-derive",
1706+]
1707+
1708+[[package]]
1709+name = "logos-codegen"
1710+version = "0.15.1"
1711+source = "registry+https://github.com/rust-lang/crates.io-index"
1712+checksum = "192a3a2b90b0c05b27a0b2c43eecdb7c415e29243acc3f89cc8247a5b693045c"
1713+dependencies = [
1714+ "beef",
1715+ "fnv",
1716+ "lazy_static",
1717+ "proc-macro2",
1718+ "quote",
1719+ "regex-syntax",
1720+ "rustc_version",
1721+ "syn 2.0.119",
1722+]
1723+
1724+[[package]]
1725+name = "logos-derive"
1726+version = "0.15.1"
1727+source = "registry+https://github.com/rust-lang/crates.io-index"
1728+checksum = "605d9697bcd5ef3a42d38efc51541aa3d6a4a25f7ab6d1ed0da5ac632a26b470"
1729+dependencies = [
1730+ "logos-codegen",
1731+]
1732+
1733+[[package]]
1734+name = "maplit"
1735+version = "1.0.2"
1736+source = "registry+https://github.com/rust-lang/crates.io-index"
1737+checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
1738+
1739+[[package]]
1740+name = "memchr"
1741+version = "2.8.3"
1742+source = "registry+https://github.com/rust-lang/crates.io-index"
1743+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
1744+
1745+[[package]]
1746+name = "memmap2"
1747+version = "0.9.11"
1748+source = "registry+https://github.com/rust-lang/crates.io-index"
1749+checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
1750+dependencies = [
1751+ "libc",
1752+]
1753+
1754+[[package]]
1755+name = "multiversion"
1756+version = "0.9.0"
1757+source = "registry+https://github.com/rust-lang/crates.io-index"
1758+checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c"
1759+dependencies = [
1760+ "multiversion-macros",
1761+]
1762+
1763+[[package]]
1764+name = "multiversion-macros"
1765+version = "0.9.0"
1766+source = "registry+https://github.com/rust-lang/crates.io-index"
1767+checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83"
1768+dependencies = [
1769+ "proc-macro2",
1770+ "quote",
1771+ "rustversion",
1772+ "syn 3.0.5",
1773+]
1774+
1775+[[package]]
1776+name = "multiversion_no_op"
1777+version = "1.0.0"
1778+source = "registry+https://github.com/rust-lang/crates.io-index"
1779+checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
1780+
1781+[[package]]
1782+name = "nonempty"
1783+version = "0.12.0"
1784+source = "registry+https://github.com/rust-lang/crates.io-index"
1785+checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6"
1786+
1787+[[package]]
1788+name = "num-bigint"
1789+version = "0.4.8"
1790+source = "registry+https://github.com/rust-lang/crates.io-index"
1791+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
1792+dependencies = [
1793+ "num-integer",
1794+ "num-traits",
1795+]
1796+
1797+[[package]]
1798+name = "num-integer"
1799+version = "0.1.47"
1800+source = "registry+https://github.com/rust-lang/crates.io-index"
1801+checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
1802+dependencies = [
1803+ "num-traits",
1804+]
1805+
1806+[[package]]
1807+name = "num-traits"
1808+version = "0.2.19"
1809+source = "registry+https://github.com/rust-lang/crates.io-index"
1810+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
1811+dependencies = [
1812+ "autocfg",
1813+]
1814+
1815+[[package]]
1816+name = "once_cell"
1817+version = "1.21.4"
1818+source = "registry+https://github.com/rust-lang/crates.io-index"
1819+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
1820+
1821+[[package]]
1822+name = "parking_lot"
1823+version = "0.12.5"
1824+source = "registry+https://github.com/rust-lang/crates.io-index"
1825+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
1826+dependencies = [
1827+ "lock_api",
1828+ "parking_lot_core",
1829+]
1830+
1831+[[package]]
1832+name = "parking_lot_core"
1833+version = "0.9.12"
1834+source = "registry+https://github.com/rust-lang/crates.io-index"
1835+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
1836+dependencies = [
1837+ "cfg-if",
1838+ "libc",
1839+ "redox_syscall",
1840+ "smallvec",
1841+ "windows-link",
1842+]
1843+
1844+[[package]]
1845+name = "percent-encoding"
1846+version = "2.3.2"
1847+source = "registry+https://github.com/rust-lang/crates.io-index"
1848+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
1849+
1850+[[package]]
1851+name = "pest"
1852+version = "2.9.1"
1853+source = "registry+https://github.com/rust-lang/crates.io-index"
1854+checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad"
1855+dependencies = [
1856+ "memchr",
1857+ "ucd-trie",
1858+]
1859+
1860+[[package]]
1861+name = "pest_derive"
1862+version = "2.9.1"
1863+source = "registry+https://github.com/rust-lang/crates.io-index"
1864+checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f"
1865+dependencies = [
1866+ "pest",
1867+ "pest_generator",
1868+]
1869+
1870+[[package]]
1871+name = "pest_generator"
1872+version = "2.9.1"
1873+source = "registry+https://github.com/rust-lang/crates.io-index"
1874+checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5"
1875+dependencies = [
1876+ "pest",
1877+ "pest_meta",
1878+ "proc-macro2",
1879+ "quote",
1880+ "syn 2.0.119",
1881+]
1882+
1883+[[package]]
1884+name = "pest_meta"
1885+version = "2.9.1"
1886+source = "registry+https://github.com/rust-lang/crates.io-index"
1887+checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e"
1888+dependencies = [
1889+ "pest",
1890+]
1891+
1892+[[package]]
1893+name = "pin-project-lite"
1894+version = "0.2.17"
1895+source = "registry+https://github.com/rust-lang/crates.io-index"
1896+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
1897+
1898+[[package]]
1899+name = "pollster"
1900+version = "0.4.0"
1901+source = "registry+https://github.com/rust-lang/crates.io-index"
1902+checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
1903+
1904+[[package]]
1905+name = "pollster"
1906+version = "1.0.1"
1907+source = "registry+https://github.com/rust-lang/crates.io-index"
1908+checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336"
1909+
1910+[[package]]
1911+name = "portable-atomic"
1912+version = "1.15.0"
1913+source = "registry+https://github.com/rust-lang/crates.io-index"
1914+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
1915+
1916+[[package]]
1917+name = "portable-atomic-util"
1918+version = "0.2.8"
1919+source = "registry+https://github.com/rust-lang/crates.io-index"
1920+checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
1921+dependencies = [
1922+ "portable-atomic",
1923+]
1924+
1925+[[package]]
1926+name = "ppv-lite86"
1927+version = "0.2.21"
1928+source = "registry+https://github.com/rust-lang/crates.io-index"
1929+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
1930+dependencies = [
1931+ "zerocopy",
1932+]
1933+
1934+[[package]]
1935+name = "proc-macro2"
1936+version = "1.0.107"
1937+source = "registry+https://github.com/rust-lang/crates.io-index"
1938+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
1939+dependencies = [
1940+ "unicode-ident",
1941+]
1942+
1943+[[package]]
1944+name = "prodash"
1945+version = "31.0.0"
1946+source = "registry+https://github.com/rust-lang/crates.io-index"
1947+checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c"
1948+dependencies = [
1949+ "parking_lot",
1950+]
1951+
1952+[[package]]
1953+name = "proptest"
1954+version = "1.11.0"
1955+source = "registry+https://github.com/rust-lang/crates.io-index"
1956+checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
1957+dependencies = [
1958+ "bit-set",
1959+ "bit-vec",
1960+ "bitflags 2.13.2",
1961+ "num-traits",
1962+ "rand 0.9.5",
1963+ "rand_chacha 0.9.0",
1964+ "rand_xorshift",
1965+ "regex-syntax",
1966+ "rusty-fork",
1967+ "tempfile",
1968+ "unarray",
1969+]
1970+
1971+[[package]]
1972+name = "prost"
1973+version = "0.14.4"
1974+source = "registry+https://github.com/rust-lang/crates.io-index"
1975+checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
1976+dependencies = [
1977+ "bytes",
1978+ "prost-derive",
1979+]
1980+
1981+[[package]]
1982+name = "prost-derive"
1983+version = "0.14.4"
1984+source = "registry+https://github.com/rust-lang/crates.io-index"
1985+checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
1986+dependencies = [
1987+ "anyhow",
1988+ "itertools 0.14.0",
1989+ "proc-macro2",
1990+ "quote",
1991+ "syn 2.0.119",
1992+]
1993+
1994+[[package]]
1995+name = "quick-error"
1996+version = "1.2.3"
1997+source = "registry+https://github.com/rust-lang/crates.io-index"
1998+checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
1999+
2000+[[package]]
2001+name = "quote"
2002+version = "1.0.47"
2003+source = "registry+https://github.com/rust-lang/crates.io-index"
2004+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
2005+dependencies = [
2006+ "proc-macro2",
2007+]
2008+
2009+[[package]]
2010+name = "r-efi"
2011+version = "5.3.0"
2012+source = "registry+https://github.com/rust-lang/crates.io-index"
2013+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
2014+
2015+[[package]]
2016+name = "r-efi"
2017+version = "6.0.0"
2018+source = "registry+https://github.com/rust-lang/crates.io-index"
2019+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
2020+
2021+[[package]]
2022+name = "rand"
2023+version = "0.9.5"
2024+source = "registry+https://github.com/rust-lang/crates.io-index"
2025+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
2026+dependencies = [
2027+ "rand_chacha 0.9.0",
2028+ "rand_core 0.9.5",
2029+]
2030+
2031+[[package]]
2032+name = "rand"
2033+version = "0.10.2"
2034+source = "registry+https://github.com/rust-lang/crates.io-index"
2035+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
2036+dependencies = [
2037+ "chacha20",
2038+ "getrandom 0.4.3",
2039+ "rand_core 0.10.1",
2040+]
2041+
2042+[[package]]
2043+name = "rand_chacha"
2044+version = "0.9.0"
2045+source = "registry+https://github.com/rust-lang/crates.io-index"
2046+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
2047+dependencies = [
2048+ "ppv-lite86",
2049+ "rand_core 0.9.5",
2050+]
2051+
2052+[[package]]
2053+name = "rand_chacha"
2054+version = "0.10.0"
2055+source = "registry+https://github.com/rust-lang/crates.io-index"
2056+checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb"
2057+dependencies = [
2058+ "ppv-lite86",
2059+ "rand_core 0.10.1",
2060+]
2061+
2062+[[package]]
2063+name = "rand_core"
2064+version = "0.9.5"
2065+source = "registry+https://github.com/rust-lang/crates.io-index"
2066+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
2067+dependencies = [
2068+ "getrandom 0.3.4",
2069+]
2070+
2071+[[package]]
2072+name = "rand_core"
2073+version = "0.10.1"
2074+source = "registry+https://github.com/rust-lang/crates.io-index"
2075+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
2076+
2077+[[package]]
2078+name = "rand_xorshift"
2079+version = "0.4.0"
2080+source = "registry+https://github.com/rust-lang/crates.io-index"
2081+checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
2082+dependencies = [
2083+ "rand_core 0.9.5",
2084+]
2085+
2086+[[package]]
2087+name = "rayon"
2088+version = "1.12.0"
2089+source = "registry+https://github.com/rust-lang/crates.io-index"
2090+checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
2091+dependencies = [
2092+ "either",
2093+ "rayon-core",
2094+]
2095+
2096+[[package]]
2097+name = "rayon-core"
2098+version = "1.13.0"
2099+source = "registry+https://github.com/rust-lang/crates.io-index"
2100+checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
2101+dependencies = [
2102+ "crossbeam-deque",
2103+ "crossbeam-utils",
2104+]
2105+
2106+[[package]]
2107+name = "redox_syscall"
2108+version = "0.5.18"
2109+source = "registry+https://github.com/rust-lang/crates.io-index"
2110+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
2111+dependencies = [
2112+ "bitflags 2.13.2",
2113+]
2114+
2115+[[package]]
2116+name = "ref-cast"
2117+version = "1.0.27"
2118+source = "registry+https://github.com/rust-lang/crates.io-index"
2119+checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
2120+dependencies = [
2121+ "ref-cast-impl",
2122+]
2123+
2124+[[package]]
2125+name = "ref-cast-impl"
2126+version = "1.0.27"
2127+source = "registry+https://github.com/rust-lang/crates.io-index"
2128+checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
2129+dependencies = [
2130+ "proc-macro2",
2131+ "quote",
2132+ "syn 3.0.5",
2133+]
2134+
2135+[[package]]
2136+name = "regex"
2137+version = "1.13.1"
2138+source = "registry+https://github.com/rust-lang/crates.io-index"
2139+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
2140+dependencies = [
2141+ "aho-corasick",
2142+ "memchr",
2143+ "regex-automata",
2144+ "regex-syntax",
2145+]
2146+
2147+[[package]]
2148+name = "regex-automata"
2149+version = "0.4.18"
2150+source = "registry+https://github.com/rust-lang/crates.io-index"
2151+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
2152+dependencies = [
2153+ "aho-corasick",
2154+ "memchr",
2155+ "regex-syntax",
2156+]
2157+
2158+[[package]]
2159+name = "regex-syntax"
2160+version = "0.8.11"
2161+source = "registry+https://github.com/rust-lang/crates.io-index"
2162+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
2163+
2164+[[package]]
2165+name = "rustc_version"
2166+version = "0.4.1"
2167+source = "registry+https://github.com/rust-lang/crates.io-index"
2168+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
2169+dependencies = [
2170+ "semver",
2171+]
2172+
2173+[[package]]
2174+name = "rustix"
2175+version = "1.1.4"
2176+source = "registry+https://github.com/rust-lang/crates.io-index"
2177+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
2178+dependencies = [
2179+ "bitflags 2.13.2",
2180+ "errno",
2181+ "libc",
2182+ "linux-raw-sys",
2183+ "windows-sys",
2184+]
2185+
2186+[[package]]
2187+name = "rustversion"
2188+version = "1.0.23"
2189+source = "registry+https://github.com/rust-lang/crates.io-index"
2190+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
2191+
2192+[[package]]
2193+name = "rusty-fork"
2194+version = "0.3.1"
2195+source = "registry+https://github.com/rust-lang/crates.io-index"
2196+checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
2197+dependencies = [
2198+ "fnv",
2199+ "quick-error",
2200+ "tempfile",
2201+ "wait-timeout",
2202+]
2203+
2204+[[package]]
2205+name = "same-file"
2206+version = "1.0.6"
2207+source = "registry+https://github.com/rust-lang/crates.io-index"
2208+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
2209+dependencies = [
2210+ "winapi-util",
2211+]
2212+
2213+[[package]]
2214+name = "scopeguard"
2215+version = "1.2.0"
2216+source = "registry+https://github.com/rust-lang/crates.io-index"
2217+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
2218+
2219+[[package]]
2220+name = "semver"
2221+version = "1.0.28"
2222+source = "registry+https://github.com/rust-lang/crates.io-index"
2223+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
2224+
2225+[[package]]
2226+name = "serde"
2227+version = "1.0.229"
2228+source = "registry+https://github.com/rust-lang/crates.io-index"
2229+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
2230+dependencies = [
2231+ "serde_core",
2232+ "serde_derive",
2233+]
2234+
2235+[[package]]
2236+name = "serde_core"
2237+version = "1.0.229"
2238+source = "registry+https://github.com/rust-lang/crates.io-index"
2239+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
2240+dependencies = [
2241+ "serde_derive",
2242+]
2243+
2244+[[package]]
2245+name = "serde_derive"
2246+version = "1.0.229"
2247+source = "registry+https://github.com/rust-lang/crates.io-index"
2248+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
2249+dependencies = [
2250+ "proc-macro2",
2251+ "quote",
2252+ "syn 3.0.5",
2253+]
2254+
2255+[[package]]
2256+name = "serde_spanned"
2257+version = "1.1.1"
2258+source = "registry+https://github.com/rust-lang/crates.io-index"
2259+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
2260+dependencies = [
2261+ "serde_core",
2262+]
2263+
2264+[[package]]
2265+name = "sha1"
2266+version = "0.10.7"
2267+source = "registry+https://github.com/rust-lang/crates.io-index"
2268+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
2269+dependencies = [
2270+ "cfg-if",
2271+ "cpufeatures 0.2.17",
2272+ "digest 0.10.7",
2273+]
2274+
2275+[[package]]
2276+name = "sha1-checked"
2277+version = "0.10.0"
2278+source = "registry+https://github.com/rust-lang/crates.io-index"
2279+checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423"
2280+dependencies = [
2281+ "digest 0.10.7",
2282+ "sha1",
2283+]
2284+
2285+[[package]]
2286+name = "sha2"
2287+version = "0.11.0"
2288+source = "registry+https://github.com/rust-lang/crates.io-index"
2289+checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
2290+dependencies = [
2291+ "cfg-if",
2292+ "cpufeatures 0.3.1",
2293+ "digest 0.11.3",
2294+]
2295+
2296+[[package]]
2297+name = "shlex"
2298+version = "2.0.1"
2299+source = "registry+https://github.com/rust-lang/crates.io-index"
2300+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
2301+
2302+[[package]]
2303+name = "simdutf8"
2304+version = "0.1.5"
2305+source = "registry+https://github.com/rust-lang/crates.io-index"
2306+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
2307+
2308+[[package]]
2309+name = "similar"
2310+version = "2.7.0"
2311+source = "registry+https://github.com/rust-lang/crates.io-index"
2312+checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
2313+
2314+[[package]]
2315+name = "slab"
2316+version = "0.4.12"
2317+source = "registry+https://github.com/rust-lang/crates.io-index"
2318+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
2319+
2320+[[package]]
2321+name = "smallvec"
2322+version = "1.16.1"
2323+source = "registry+https://github.com/rust-lang/crates.io-index"
2324+checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
2325+dependencies = [
2326+ "serde",
2327+]
2328+
2329+[[package]]
2330+name = "stable_deref_trait"
2331+version = "1.2.1"
2332+source = "registry+https://github.com/rust-lang/crates.io-index"
2333+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
2334+
2335+[[package]]
2336+name = "strsim"
2337+version = "0.11.1"
2338+source = "registry+https://github.com/rust-lang/crates.io-index"
2339+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
2340+
2341+[[package]]
2342+name = "structmeta"
2343+version = "0.3.0"
2344+source = "registry+https://github.com/rust-lang/crates.io-index"
2345+checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329"
2346+dependencies = [
2347+ "proc-macro2",
2348+ "quote",
2349+ "structmeta-derive",
2350+ "syn 2.0.119",
2351+]
2352+
2353+[[package]]
2354+name = "structmeta-derive"
2355+version = "0.3.0"
2356+source = "registry+https://github.com/rust-lang/crates.io-index"
2357+checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc"
2358+dependencies = [
2359+ "proc-macro2",
2360+ "quote",
2361+ "syn 2.0.119",
2362+]
2363+
2364+[[package]]
2365+name = "subtle"
2366+version = "2.6.1"
2367+source = "registry+https://github.com/rust-lang/crates.io-index"
2368+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
2369+
2370+[[package]]
2371+name = "syn"
2372+version = "2.0.119"
2373+source = "registry+https://github.com/rust-lang/crates.io-index"
2374+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
2375+dependencies = [
2376+ "proc-macro2",
2377+ "quote",
2378+ "unicode-ident",
2379+]
2380+
2381+[[package]]
2382+name = "syn"
2383+version = "3.0.5"
2384+source = "registry+https://github.com/rust-lang/crates.io-index"
2385+checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
2386+dependencies = [
2387+ "proc-macro2",
2388+ "quote",
2389+ "unicode-ident",
2390+]
2391+
2392+[[package]]
2393+name = "tempfile"
2394+version = "3.27.0"
2395+source = "registry+https://github.com/rust-lang/crates.io-index"
2396+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
2397+dependencies = [
2398+ "fastrand",
2399+ "getrandom 0.4.3",
2400+ "once_cell",
2401+ "rustix",
2402+ "windows-sys",
2403+]
2404+
2405+[[package]]
2406+name = "test-strategy"
2407+version = "0.4.5"
2408+source = "registry+https://github.com/rust-lang/crates.io-index"
2409+checksum = "8f7fd1eb9efb36942b85a290c4201d317980fe09bc88d34dd48aaaae03075c6a"
2410+dependencies = [
2411+ "derive-ex",
2412+ "proc-macro2",
2413+ "quote",
2414+ "structmeta",
2415+ "syn 2.0.119",
2416+]
2417+
2418+[[package]]
2419+name = "thiserror"
2420+version = "2.0.20"
2421+source = "registry+https://github.com/rust-lang/crates.io-index"
2422+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
2423+dependencies = [
2424+ "thiserror-impl",
2425+]
2426+
2427+[[package]]
2428+name = "thiserror-impl"
2429+version = "2.0.20"
2430+source = "registry+https://github.com/rust-lang/crates.io-index"
2431+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
2432+dependencies = [
2433+ "proc-macro2",
2434+ "quote",
2435+ "syn 3.0.5",
2436+]
2437+
2438+[[package]]
2439+name = "tinyvec"
2440+version = "1.13.3"
2441+source = "registry+https://github.com/rust-lang/crates.io-index"
2442+checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
2443+
2444+[[package]]
2445+name = "toml_datetime"
2446+version = "1.1.1+spec-1.1.0"
2447+source = "registry+https://github.com/rust-lang/crates.io-index"
2448+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
2449+dependencies = [
2450+ "serde_core",
2451+]
2452+
2453+[[package]]
2454+name = "toml_edit"
2455+version = "0.25.15+spec-1.1.0"
2456+source = "registry+https://github.com/rust-lang/crates.io-index"
2457+checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
2458+dependencies = [
2459+ "indexmap",
2460+ "serde_core",
2461+ "serde_spanned",
2462+ "toml_datetime",
2463+ "toml_parser",
2464+ "toml_writer",
2465+ "winnow",
2466+]
2467+
2468+[[package]]
2469+name = "toml_parser"
2470+version = "1.1.3+spec-1.1.0"
2471+source = "registry+https://github.com/rust-lang/crates.io-index"
2472+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
2473+dependencies = [
2474+ "winnow",
2475+]
2476+
2477+[[package]]
2478+name = "toml_writer"
2479+version = "1.1.2+spec-1.1.0"
2480+source = "registry+https://github.com/rust-lang/crates.io-index"
2481+checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
2482+
2483+[[package]]
2484+name = "tracing"
2485+version = "0.1.44"
2486+source = "registry+https://github.com/rust-lang/crates.io-index"
2487+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
2488+dependencies = [
2489+ "pin-project-lite",
2490+ "tracing-attributes",
2491+ "tracing-core",
2492+]
2493+
2494+[[package]]
2495+name = "tracing-attributes"
2496+version = "0.1.31"
2497+source = "registry+https://github.com/rust-lang/crates.io-index"
2498+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
2499+dependencies = [
2500+ "proc-macro2",
2501+ "quote",
2502+ "syn 2.0.119",
2503+]
2504+
2505+[[package]]
2506+name = "tracing-core"
2507+version = "0.1.36"
2508+source = "registry+https://github.com/rust-lang/crates.io-index"
2509+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
2510+dependencies = [
2511+ "once_cell",
2512+]
2513+
2514+[[package]]
2515+name = "typenum"
2516+version = "1.20.1"
2517+source = "registry+https://github.com/rust-lang/crates.io-index"
2518+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
2519+
2520+[[package]]
2521+name = "ucd-trie"
2522+version = "0.1.7"
2523+source = "registry+https://github.com/rust-lang/crates.io-index"
2524+checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
2525+
2526+[[package]]
2527+name = "uluru"
2528+version = "3.1.0"
2529+source = "registry+https://github.com/rust-lang/crates.io-index"
2530+checksum = "7c8a2469e56e6e5095c82ccd3afb98dad95f7af7929aab6d8ba8d6e0f73657da"
2531+dependencies = [
2532+ "arrayvec",
2533+]
2534+
2535+[[package]]
2536+name = "unarray"
2537+version = "0.1.4"
2538+source = "registry+https://github.com/rust-lang/crates.io-index"
2539+checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
2540+
2541+[[package]]
2542+name = "unicode-bom"
2543+version = "2.0.3"
2544+source = "registry+https://github.com/rust-lang/crates.io-index"
2545+checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217"
2546+
2547+[[package]]
2548+name = "unicode-ident"
2549+version = "1.0.24"
2550+source = "registry+https://github.com/rust-lang/crates.io-index"
2551+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
2552+
2553+[[package]]
2554+name = "unicode-normalization"
2555+version = "0.1.25"
2556+source = "registry+https://github.com/rust-lang/crates.io-index"
2557+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
2558+dependencies = [
2559+ "tinyvec",
2560+]
2561+
2562+[[package]]
2563+name = "unicode-width"
2564+version = "0.2.2"
2565+source = "registry+https://github.com/rust-lang/crates.io-index"
2566+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
2567+
2568+[[package]]
2569+name = "version_check"
2570+version = "0.9.5"
2571+source = "registry+https://github.com/rust-lang/crates.io-index"
2572+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
2573+
2574+[[package]]
2575+name = "wait-timeout"
2576+version = "0.2.1"
2577+source = "registry+https://github.com/rust-lang/crates.io-index"
2578+checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
2579+dependencies = [
2580+ "libc",
2581+]
2582+
2583+[[package]]
2584+name = "walkdir"
2585+version = "2.5.0"
2586+source = "registry+https://github.com/rust-lang/crates.io-index"
2587+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
2588+dependencies = [
2589+ "same-file",
2590+ "winapi-util",
2591+]
2592+
2593+[[package]]
2594+name = "wasip2"
2595+version = "1.0.4+wasi-0.2.12"
2596+source = "registry+https://github.com/rust-lang/crates.io-index"
2597+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
2598+dependencies = [
2599+ "wit-bindgen",
2600+]
2601+
2602+[[package]]
2603+name = "wasm-bindgen"
2604+version = "0.2.128"
2605+source = "registry+https://github.com/rust-lang/crates.io-index"
2606+checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
2607+dependencies = [
2608+ "cfg-if",
2609+ "once_cell",
2610+ "rustversion",
2611+ "wasm-bindgen-macro",
2612+ "wasm-bindgen-shared",
2613+]
2614+
2615+[[package]]
2616+name = "wasm-bindgen-macro"
2617+version = "0.2.128"
2618+source = "registry+https://github.com/rust-lang/crates.io-index"
2619+checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
2620+dependencies = [
2621+ "quote",
2622+ "wasm-bindgen-macro-support",
2623+]
2624+
2625+[[package]]
2626+name = "wasm-bindgen-macro-support"
2627+version = "0.2.128"
2628+source = "registry+https://github.com/rust-lang/crates.io-index"
2629+checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
2630+dependencies = [
2631+ "bumpalo",
2632+ "proc-macro2",
2633+ "quote",
2634+ "syn 3.0.5",
2635+ "wasm-bindgen-shared",
2636+]
2637+
2638+[[package]]
2639+name = "wasm-bindgen-shared"
2640+version = "0.2.128"
2641+source = "registry+https://github.com/rust-lang/crates.io-index"
2642+checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
2643+dependencies = [
2644+ "unicode-ident",
2645+]
2646+
2647+[[package]]
2648+name = "winapi-util"
2649+version = "0.1.11"
2650+source = "registry+https://github.com/rust-lang/crates.io-index"
2651+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
2652+dependencies = [
2653+ "windows-sys",
2654+]
2655+
2656+[[package]]
2657+name = "windows-core"
2658+version = "0.62.2"
2659+source = "registry+https://github.com/rust-lang/crates.io-index"
2660+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
2661+dependencies = [
2662+ "windows-implement",
2663+ "windows-interface",
2664+ "windows-link",
2665+ "windows-result",
2666+ "windows-strings",
2667+]
2668+
2669+[[package]]
2670+name = "windows-implement"
2671+version = "0.60.2"
2672+source = "registry+https://github.com/rust-lang/crates.io-index"
2673+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
2674+dependencies = [
2675+ "proc-macro2",
2676+ "quote",
2677+ "syn 2.0.119",
2678+]
2679+
2680+[[package]]
2681+name = "windows-interface"
2682+version = "0.59.3"
2683+source = "registry+https://github.com/rust-lang/crates.io-index"
2684+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
2685+dependencies = [
2686+ "proc-macro2",
2687+ "quote",
2688+ "syn 2.0.119",
2689+]
2690+
2691+[[package]]
2692+name = "windows-link"
2693+version = "0.2.1"
2694+source = "registry+https://github.com/rust-lang/crates.io-index"
2695+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
2696+
2697+[[package]]
2698+name = "windows-result"
2699+version = "0.4.1"
2700+source = "registry+https://github.com/rust-lang/crates.io-index"
2701+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
2702+dependencies = [
2703+ "windows-link",
2704+]
2705+
2706+[[package]]
2707+name = "windows-strings"
2708+version = "0.5.1"
2709+source = "registry+https://github.com/rust-lang/crates.io-index"
2710+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
2711+dependencies = [
2712+ "windows-link",
2713+]
2714+
2715+[[package]]
2716+name = "windows-sys"
2717+version = "0.61.2"
2718+source = "registry+https://github.com/rust-lang/crates.io-index"
2719+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
2720+dependencies = [
2721+ "windows-link",
2722+]
2723+
2724+[[package]]
2725+name = "winnow"
2726+version = "1.0.4"
2727+source = "registry+https://github.com/rust-lang/crates.io-index"
2728+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
2729+dependencies = [
2730+ "memchr",
2731+]
2732+
2733+[[package]]
2734+name = "winreg"
2735+version = "0.56.0"
2736+source = "registry+https://github.com/rust-lang/crates.io-index"
2737+checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
2738+dependencies = [
2739+ "cfg-if",
2740+ "windows-sys",
2741+]
2742+
2743+[[package]]
2744+name = "wit-bindgen"
2745+version = "0.57.1"
2746+source = "registry+https://github.com/rust-lang/crates.io-index"
2747+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
2748+
2749+[[package]]
2750+name = "zerocopy"
2751+version = "0.8.57"
2752+source = "registry+https://github.com/rust-lang/crates.io-index"
2753+checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
2754+dependencies = [
2755+ "zerocopy-derive",
2756+]
2757+
2758+[[package]]
2759+name = "zerocopy-derive"
2760+version = "0.8.57"
2761+source = "registry+https://github.com/rust-lang/crates.io-index"
2762+checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
2763+dependencies = [
2764+ "proc-macro2",
2765+ "quote",
2766+ "syn 2.0.119",
2767+]
2768+
2769+[[package]]
2770+name = "zlib-rs"
2771+version = "0.6.7"
2772+source = "registry+https://github.com/rust-lang/crates.io-index"
2773+checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
Cargo.tomlnew file+24
@@ -0,0 +1,24 @@
1+[package]
2+name = "j"
3+version = "0.1.0"
4+edition = "2021"
5+description = "A functional-programming-centered CLI for Jujutsu repositories"
6+
7+[dependencies]
8+jj-lib = "0.45"
9+gix = { version = "0.87.1", default-features = false, features = ["sha1", "max-performance-safe"] }
10+hex = "0.4"
11+num-bigint = "0.4"
12+pollster = "0.4"
13+futures = "0.3"
14+unicode-width = "0.2"
15+tempfile = "3"
16+similar = "2"
17+libc = "0.2"
18+
19+[dev-dependencies]
20+proptest = "1"
21+test-strategy = "0.4"
22+
23+[profile.release]
24+opt-level = 2
README.mdnew file+469
@@ -0,0 +1,469 @@
1+# j
2+
3+**`j` is a functional-programming-centered command-line interface to Jujutsu
4+(jj) repositories.** Instead of subcommands and flags, you write expressions in
5+a small, pure functional language that transform the repository. The repository
6+is a *value*, a command is a *function from repository to repository*, and
7+everyday work is done by composing those functions.
8+
9+`j` is not a new version-control system. The storage, data model, and on-disk
10+format are jj's (which is git-compatible — `j` works directly on a colocated
11+`.jj`/`.git` repository, with no `jj` binary required). What `j` replaces is
12+the *interface*.
13+
14+```
15+j 'describe "wip"' -- set the focused commit's message
16+j 'new' -- start a new commit on top
17+j 'describe "wip" . new' -- compose: new commit, then describe it
18+j squash -- fold the focus into its parent
19+j 'tree' -- print the history tree
20+```
21+
22+The whole vocabulary — `new`, `describe`, `squash`, `tree`, `rebase`, … — is
23+written *in the language* in `config.j`, and you extend it by writing more
24+functions.
25+
26+---
27+
28+## Why `j`?
29+
30+| | |
31+|---|---|
32+| **No staging area** | Like jj, the working copy *is* a commit; there's no `git add` step. |
33+| **Stable change ids** | A commit keeps its id (`@wqzt…`) across `describe`, `squash`, `rebase`. History editing is safe to talk about. |
34+| **First-class undo** | Every operation is recorded. `j undo` / `j redo` / `j ops`, not a forensic reflog. |
35+| **Composable commands** | A command is a function. `describe "wip" . new . (goto %main)` is one expression, not three commands. |
36+| **Programmable** | The vocabulary is `config.j`, written in the language. Add your own edits, revsets, and inspections. |
37+| **Total operations** | A conflicting `replay` produces an unresolved blob, not a halt. Resolve whenever you like. |
38+
39+---
40+
41+## Installation
42+
43+### With Nix (recommended)
44+
45+The repository provides a flake with one default binary, `j`. Difftastic is a
46+runtime dependency and is wired in automatically.
47+
48+```
49+nix build # builds ./result/bin/j
50+nix run # run j directly
51+```
52+
53+The reference `config.j` is installed alongside the binary
54+(`<prefix>/share/j/config.j`) and used if you have no user config.
55+
56+### From source
57+
58+```
59+cargo build --release # produces target/release/j
60+```
61+
62+You'll want the reference `config.j` (see next section) and, for `review`/`difft`,
63+[difftastic](https://difftastic.wilfred.me.uk/) on your `PATH`.
64+
65+---
66+
67+## Configuration
68+
69+`j` reads `~/.config/j/config.j` on every run (or `$XDG_CONFIG_HOME/j/config.j`).
70+An expression can only use a name this file declares or defines. The reference
71+[`config.j`](config.j) ships with the binary — copy it and edit at least the
72+identity:
73+
74+```
75+user = { name = "Your Name", email = "you@example.com" }
76+```
77+
78+The reference config defines the entire base vocabulary (see
79+[docs/base.md](docs/base.md)). Four definitions are read by `j` itself: `user`
80+(who commits), `tree` (how a `Repo` prints), `immutable` (which commits may
81+never be rewritten), and `labelled` (what a `%name` literal means).
82+
83+---
84+
85+## How it works
86+
87+Every `j` run:
88+
89+1. Snapshots the working directory into the working-copy commit.
90+2. Builds a `Repo` value focused on that commit.
91+3. Evaluates your expression.
92+4. If the result is a function, applies it to the `Repo`.
93+5. If the final value is a `Repo` that changed, persists it; otherwise prints
94+ the value and changes nothing.
95+
96+So most of what you write is an **edit** — a function `Repo -> Repo`. A result
97+that's plain data is printed and persists nothing (a dry run).
98+
99+```
100+j 'length . commits . top' -- a number: how many commits? prints, persists nothing
101+j squash -- an edit: persists the squash
102+j 'tree . squash' -- a Text: shows what squash *would* do, persists nothing
103+```
104+
105+The repository is applied **implicitly** — there's no `repo` variable; write a
106+function and it receives the repository.
107+
108+---
109+
110+## Frequent examples
111+
112+Each of these is a complete command you can run. Edits persist; inspections
113+print.
114+
115+### Start and describe work
116+
117+```
118+j 'new'
119+```
120+
121+Starts an empty child of the focused commit and moves to it. Edit files in
122+your editor, then:
123+
124+```
125+j 'describe "add the lexer"'
126+```
127+
128+Sets the message of the focused commit. The two compose:
129+
130+```
131+j 'describe "add the lexer" . new'
132+```
133+
134+Opens a new commit *and* describes it, in one step.
135+
136+### See where you are
137+
138+```
139+j 'status'
140+```
141+
142+A summary of the focus: id, message, labels, changed paths, conflicted paths.
143+
144+```
145+changed 0 items
146+conflicts 0 items
147+id zrkmkkvrvtokumkuzrkmkkvrvtokumku
148+labels 0 items
149+message add a library
150+```
151+
152+```
153+j 'files'
154+```
155+
156+The files of the focused commit, with sizes.
157+
158+```
159+j 'changed'
160+```
161+
162+The paths whose content differs between the focus and its parent.
163+
164+### See the history
165+
166+```
167+j 'tree'
168+```
169+
170+The history as a tree, focused commit marked:
171+
172+```
173+ ⌂ zzzz
174+ └─ ● rrtv ▂
175+ └─ ● xqsr ▂ start the project
176+▶ └─ ◌ zrkm add a library
177+```
178+
179+```
180+j 'treeFull'
181+```
182+
183+The same tree with ages and authors in the margin.
184+
185+```
186+j 'log'
187+```
188+
189+A table of every commit, top first, with a `✓` on the focus.
190+
191+```
192+j 'review'
193+```
194+
195+The focus's changes, rendered by difftastic as one page of diffs.
196+
197+### Move around
198+
199+```
200+j 'prev' -- to the parent
201+j 'next' -- to the only child
202+j 'top' -- to the top of history
203+j 'tip' -- to the tip of the current line
204+j 'by @wqzt' -- to a specific commit, by id
205+j 'goto %main' -- to what the remote calls main
206+```
207+
208+### Undo and history of actions
209+
210+```
211+j undo -- revert the last j expression
212+j redo -- reapply it
213+j ops -- the operation log, newest first
214+```
215+
216+### A safe dry run
217+
218+Because a `Text` or `Repo`-returning-but-unchanged result persists nothing,
219+you can preview any edit by composing it with `tree` (or `validate`):
220+
221+```
222+j 'tree . squash' -- show the tree as squash would leave it
223+j 'tree . validate . squash' -- the same, but crash if squash couldn't persist
224+```
225+
226+`validate` returns the repo unchanged if persisting would succeed, and crashes
227+with the exact error otherwise — an honest dry run.
228+
229+---
230+
231+## Editing history
232+
233+These are the everyday history edits. Each is an `Edit`.
234+
235+| command | what it does |
236+|---|---|
237+| `j 'describe "…"'` | set the focus's message |
238+| `j new` | start an empty child of the focus |
239+| `j squash` | fold the focus entirely into its parent |
240+| `j abandon` | drop the focus; its children join the parent |
241+| `j 'split <fileset>'` | push matching files up into a new child commit |
242+| `j 'contract <fileset>'` | push matching files down into the parent |
243+| `j 'rebase <revset>'` | move the focused subtree onto another commit |
244+| `j 'pick <revset>'` | copy the focus's change onto another commit |
245+| `j 'backout <revset>'` | a new commit undoing another commit's change |
246+
247+### `squash` — fold a commit into its parent
248+
249+```
250+j squash
251+```
252+
253+Given `base work` → `more work`, after `j squash` the `more work` commit is
254+gone, its files folded into `base work`, and a fresh empty commit is the
255+focus:
256+
257+```
258+ ⌂ zzzz
259+ └─ ● opwp ▂
260+ └─ ● zqsq ▂ base work
261+▶ └─ ◌ nttk
262+```
263+
264+### `abandon` — drop a commit
265+
266+```
267+j abandon
268+```
269+
270+Removes the focus and rebases its children onto the parent. Refused for a
271+commit the remote has a name for (push something else under that name first).
272+
273+### `split` / `contract` — move changes by fileset
274+
275+A **fileset** is a function `Path -> Bool`. `split` pushes the matching files
276+up into a new child commit; `contract` pushes them down into the parent.
277+
278+```
279+j 'split (ext "rs")'
280+```
281+
282+Takes a commit touching `a.rs` and `b.j`, and splits it: the `.rs` change
283+becomes a separate child commit (now the focus), the `.j` change stays behind.
284+
285+```
286+j 'contract everything'
287+```
288+
289+Pushes *all* of the focus's files into the parent (the inverse of `split`).
290+`squash` is just `abandon . contract everything`.
291+
292+Filesets compose: `under ./src`, `ext "rs"`, `both`, `either`, `neg`,
293+`everything`. So `split (both (under ./src) (ext "rs"))` splits off only the
294+Rust files under `src/`.
295+
296+### `rebase` / `pick` / `backout`
297+
298+```
299+j 'rebase %main' -- move the current stack onto main
300+j 'pick @wqzt' -- copy @wqzt's change here as a new commit
301+j 'backout @wqzt' -- a new commit undoing @wqzt's change
302+```
303+
304+`rebase` moves the focused subtree (and crashes if the destination is inside
305+it). `pick` copies a change without moving anything. `backout` replays a
306+change's *inverse*.
307+
308+---
309+
310+## Labels and the remote
311+
312+A **label** is a bookmark name on the remote, shown on a commit's `labels`.
313+Labels are read-only in the language — only `fetch` and `push` change them.
314+
315+```
316+j 'remote URL' -- set origin's URL
317+j fetch -- fetch from origin
318+```
319+
320+`j push EXPR` evaluates `EXPR` to a list of push/drop records:
321+
322+```
323+j 'push (label "feature" here)' -- point the "feature" bookmark at the focus
324+j 'push (rename "old" "new")' -- rename a bookmark on the remote
325+j 'push (unlabel "old-feature")' -- delete a bookmark
326+j 'push relabel' -- after a rewrite, re-point the remote's bookmarks
327+```
328+
329+---
330+
331+## Advanced examples
332+
333+These show the language doing things that take several commands (or aren't
334+possible) in other tools.
335+
336+### Describe every commit in a stack at once
337+
338+```
339+j 'forEach descendants (describe "wip")'
340+```
341+
342+`forEach` is an ordinary function `Revset -> Edit -> Edit`: it runs an edit at
343+every commit a revset names, returning to the focus. This one marks every
344+commit below the focus `wip`.
345+
346+### Find commits by any predicate
347+
348+```
349+j 'matching (\c -> c.message == "wip") all'
350+```
351+
352+`matching p rs` keeps the commits of revset `rs` satisfying predicate `p`, as a
353+revset — here, every commit whose message is `wip`. Since the result is a list
354+of commits, it prints (and persists nothing).
355+
356+### Count things
357+
358+```
359+j 'length . commits . top'
360+```
361+
362+Composes three functions into `Repo -> Int`: the number of visible commits.
363+
364+### An honest dry run of a complex edit
365+
366+```
367+j 'tree . validate . rebase %main'
368+```
369+
370+Shows the tree as it would look after rebasing onto main, *without persisting
371+anything*. And because `validate` crashes with the exact error persistence
372+would raise, this is honest: if the rebase couldn't actually happen (say `%main`
373+doesn't exist yet, or the destination is inside the moved subtree), you see
374+that crash here instead of after a real attempt.
375+
376+```
377+j 'tree . validate . squash'
378+```
379+
380+The same idea for a simpler edit: renders the post-squash tree, and a following
381+`j tree` confirms nothing changed.
382+
383+### Run an edit elsewhere, then come back
384+
385+```
386+j 'at %main (describe "released")'
387+```
388+
389+Describes `main`, then returns the focus to where it was. `at rs e` runs edit
390+`e` at the commit revset `rs` names and comes back by id.
391+
392+### Combine edits into a single operation
393+
394+```
395+j 'describe "hotfix" . new . (goto %main)'
396+```
397+
398+Go to main, open a new commit, describe it — one expression, one recorded
399+operation. With `or` you get a fallback:
400+
401+```
402+j 'rebase %main or id'
403+```
404+
405+Rebase if you can; leave the repository alone if it crashes.
406+
407+### Define your own command
408+
409+Anything added to `config.j` whose value is an `Edit` is a new command:
410+
411+```
412+-- in config.j: fold the whole stack into one commit called "release"
413+release : Edit
414+release = describe "release" . (contract everything)
415+```
416+
417+Then `j release` works like any built-in.
418+
419+---
420+
421+## Reserved commands
422+
423+A handful of commands are built in (not expressions): they create or connect
424+repositories, move refs, and walk the operation log. None take flags.
425+
426+| command | what it does |
427+|---|---|
428+| `j init` | create a colocated `.jj`/`.git` repository here (adopting an existing `.git`) |
429+| `j clone URL [DIR]` | clone `URL` into `DIR` (default: last path component, minus `.git`) |
430+| `j remote URL` | set `origin`'s URL |
431+| `j fetch` | fetch from `origin` |
432+| `j push EXPR` | evaluate `EXPR` to push/drop records and push them |
433+| `j undo` / `j redo` | walk the operation log |
434+| `j ops` | print the operation log |
435+
436+Everything else is an expression in the language.
437+
438+---
439+
440+## Documentation
441+
442+| document | contents |
443+|---|---|
444+| **[docs/tutorial.md](docs/tutorial.md)** | A guided first hour: the mental model (implicit repo application, edits, revsets, filesets), dry runs, `or`, history editing, batch operations, and writing your own commands. The best place to start learning. |
445+| **[docs/language.md](docs/language.md)** | The `j` language in detail: values, syntax, semantics, builtins, contracts, `show`, the `Repo` zipper, and many examples. Start here to learn the language. |
446+| **[docs/base.md](docs/base.md)** | The complete base vocabulary: every builtin and every `config.j` type and function, grouped logically, each with a description and examples, plus an alphabetical index. |
447+| **[docs/comparison.md](docs/comparison.md)** | A structured three-way comparison of git, jj, and `j`: concept mappings, side-by-side command tables, and a full workflow in each. |
448+| **[SPEC.md](SPEC.md)** | The normative specification of the language, the CLI, and repository semantics. |
449+| **[config.j](config.j)** | The reference configuration: the entire base vocabulary, written in the language. |
450+
451+---
452+
453+## Project layout
454+
455+```
456+config.j the reference base vocabulary (read on every run)
457+src/ the j implementation (Rust, on jj-lib)
458+tests/ unit, property, and CLI end-to-end tests
459+docs/ language.md, base.md, comparison.md
460+SPEC.md the specification
461+flake.nix Nix package: one binary, j, with difftastic as a runtime dep
462+```
463+
464+### Development
465+
466+```
467+cargo build # build
468+cargo test # run the test suite
469+```
SPEC.mdnew file+1854
@@ -0,0 +1,1854 @@
1+# j — specification
2+
3+`j` is a command-line interface to Jujutsu (jj) repositories. It is an
4+interpreter for a small, pure, dynamically typed functional language. A
5+repository is a value in that language; every command is an expression that
6+maps the current repository value to a new one, or to something to print.
7+
8+Nothing is implicit beyond the language itself. Every shape, builtin, and
9+command is declared or defined in one user-owned file, `~/.config/j/config.j`,
10+written in the language. The binary provides the language (its syntax, its
11+operators and their fixities, its primitive kinds), the implementations of the
12+declared builtins, eight reserved words, and the bridge to jj repositories.
13+`j` does not require the `jj` binary; it uses jj's on-disk formats through
14+jj-lib.
15+
16+This document is the complete specification for implementing `j`. Section 9 is
17+the reference `config.j`; it is part of the specification and ships with the
18+binary.
19+
20+---
21+
22+## 1. Invocation
23+
24+```
25+j EXPRESSION…
26+echo EXPRESSION | j
27+```
28+
29+`j` takes the expression from its arguments or from standard input.
30+
31+1. If standard input is not a terminal and contains at least one byte, the
32+ expression is the entire content of standard input.
33+2. Otherwise the expression is the arguments joined with single spaces,
34+ verbatim. Nothing is quoted or wrapped on the user's behalf: a `Text` is
35+ a text only if it is quoted in the language, so a message is written
36+ `j 'describe "fix login"'` (or `j describe '"fix"'`), while `j new` is
37+ `new` and `j tree . squash` is `tree . squash`. Shell quoting is needed
38+ only for characters the shell owns: double quotes, parentheses, brackets,
39+ braces, a backslash, `<` `>` `|` `&` `;` `*` `$`. The operators `++`,
40+ `::`, `or`, `.`, and the literals `%name`, `@id`, `./a/b` are all safe
41+ unquoted.
42+3. If arguments are present and rule 1 also applies, exit 2 with
43+ `j: expression given both as arguments and on stdin`.
44+4. If neither is present, print a one-line usage message to stderr and exit 2.
45+5. `j` accepts no flags. Any argument, including ones beginning with `-`, is
46+ part of the expression.
47+
48+### 1.1 Reserved commands
49+
50+Before the expression is parsed, its text is trimmed of leading and trailing
51+whitespace and split on whitespace into words. If the first word is one of the
52+eight reserved words below, the whole text is a reserved command.
53+
54+| command | operation |
55+|---|---|
56+| `init` | create a repository in the current directory; §7.8 |
57+| `clone URL [DIR]` | clone `URL` into `DIR` (default: last path component of `URL`, minus `.git`); §7.8 |
58+| `remote URL` | set the URL of the remote `origin`, creating it if absent; §7.8 |
59+| `fetch` | fetch from `origin`; §7.6 |
60+| `push EXPR` | set or delete the remote bookmarks that `EXPR` selects; §7.6 |
61+| `undo` | restore the repository as it was before the newest operation; §7.7 |
62+| `redo` | reverse the newest undo; §7.7 |
63+| `ops` | print the operation log; §7.7 |
64+
65+For every reserved command except `push`, the remaining words are literal
66+arguments (no quoting, no escaping); a wrong number is a usage error (exit 2)
67+and the language is not involved. For `push`, the remainder of the text is an
68+expression, evaluated as in §1.2 steps 2–7 but against the repository as
69+recorded, without snapshotting the working directory; its value must be a list
70+of push records (§7.6).
71+
72+Matching is on the first word only, so an expression whose first token is one
73+of these words cannot be written on the command line. `(push)` or `x fetch`
74+are ordinary expressions. `config.j` may define functions with these names;
75+they are reachable only inside larger expressions.
76+
77+### 1.2 Evaluating the expression
78+
79+1. Locate the repository (§7.1). If none, exit 2.
80+2. Parse the expression (§3). On parse error, exit 3.
81+3. Load and validate `config.j` (§6), except for its `Id` literals. If it is
82+ missing or invalid, exit 3.
83+4. Build the current repository value `r` (§7.2). This includes snapshotting
84+ the working directory into the focused commit (§7.4).
85+5. Resolve every `Id` literal in `config.j` and in the expression (§4.10).
86+ A failure in `config.j` is a configuration error (exit 3); one in the
87+ expression is a crash (exit 1). Nothing has been evaluated.
88+6. Evaluate `config.j`'s definitions (§4.1), then the expression, to a value
89+ `v`.
90+7. If `v` is a function, apply it to `r`, once, and let `v` be the result.
91+8. If `v` is a `Repo` (§4.4): if it equals the repository value *as loaded,
92+ before the snapshot of §7.4*, do nothing at all; otherwise persist it
93+ (§7.5) and check out its focused commit's files (§7.4). Print nothing.
94+ Exit 0.
95+9. Otherwise display `v` (§5.1) on stdout. Persist nothing. Exit 0.
96+
97+If evaluation crashes (§4.7) at any point, print the crash message to stderr,
98+persist nothing, leave the working directory untouched, and exit 1.
99+
100+Step 7 applies exactly once. If the result of applying a function to `r` is
101+again a function, that function is displayed, not applied again.
102+
103+Step 8's equality test means `j id` is a no-op when the working directory is
104+unchanged and records it when it is not: the snapshot makes `r` differ from
105+the loaded value, and that difference is what gets persisted. The test may be
106+implemented by comparing commit and tree hashes rather than values.
107+
108+Two consequences worth knowing:
109+
110+- **Dry run.** A `Text` result is displayed and persists nothing, and `.`
111+ composes a renderer after an edit, so `j tree . rebase trunk` shows
112+ what a rebase would produce without doing it. `validate` in the chain,
113+ `j tree . validate . rebase trunk`, additionally raises whatever
114+ persistence would refuse, so the preview is of something that could
115+ actually be persisted.
116+- **Do, then look.** `j squash` prints nothing. `j squash && j tree` is the
117+ idiom; there is no flag for it.
118+
119+### 1.3 Interrupts
120+
121+There is no timeout. A non-terminating expression runs until interrupted.
122+Interrupting `j` (SIGINT) kills it; because nothing is written until
123+evaluation completes, an interrupted run persists nothing and leaves the
124+working directory untouched.
125+
126+### 1.4 Exit status
127+
128+| status | meaning |
129+|---|---|
130+| 0 | success |
131+| 1 | the expression crashed, or a reserved command was refused |
132+| 2 | usage error: bad arguments, no repository, unsupported repository (§7.2), another `j` running (§7.7) |
133+| 3 | configuration or parse error |
134+
135+Errors are written to stderr as a single line beginning with `j: `. Crash
136+messages are the text given to `crash` (or generated by the interpreter),
137+prefixed with `j: crash: `, followed on a second line by the innermost base
138+definition that was executing and the top-level expression.
139+
140+---
141+
142+## 2. Values
143+
144+The language has these kinds of values. The primitive kinds are part of the
145+language and are not declared anywhere.
146+
147+| kind | notes |
148+|---|---|
149+| `Int` | arbitrary-precision integer |
150+| `Text` | Unicode string |
151+| `Bool` | `true` or `false` |
152+| list | finite, ordered, homogeneous by convention, `[a b c]` |
153+| record | unordered set of named fields, `{ a = 1, b = "x" }` |
154+| function | closure, builtin, or partial application |
155+| `Id` | opaque: a jj change id. Literal `@kpqx` for an existing one (a unique prefix is enough); bare `@` for a new one (§4.10) |
156+| `Blob` | opaque: the content of one file, resolved or unresolved |
157+| `Shape` | a type name used as a value (§4.12) |
158+
159+Records are structural. A record is not declared; it is whatever fields it
160+has. The shapes in `config.j` name field sets, and a record *is* a `Commit`,
161+say, exactly when its field set is `Commit`'s (§4.12). Two records are the
162+same value if they have the same field names with equal values.
163+
164+---
165+
166+## 3. Syntax
167+
168+### 3.1 Lexical structure
169+
170+- **Comments.** `--` to end of line. `{-` … `-}`, nesting.
171+- **Identifiers.** `[a-z_][A-Za-z0-9_']*`. Keywords are excluded:
172+ `let in if then else or true false`.
173+- **Type names.** `[A-Z][A-Za-z0-9_]*`. Declared as shapes in `config.j`;
174+ usable in expressions as values (§4.12).
175+- **Integer literals.** `[0-9]+`. There is no unary minus; write `0 - 1`.
176+- **Text literals.** `"…"` with escapes `\"`, `\\`, `\n`, `\t`, `\r`.
177+- **Id literals.** `@` immediately followed by one or more of the letters
178+ `k`–`z` (jj's change-id alphabet), e.g. `@wqzt`: an existing id, given in
179+ full or by a unique prefix.
180+- **New id.** `@` followed by anything other than an identifier character
181+ is the token `NEWID`. `@` followed by an identifier character outside
182+ `k`–`z` (`@main`) is a lexical error.
183+- **Label literals.** `%` immediately followed by a git branch name
184+ (`[A-Za-z0-9._/-]+`, not starting with `.` or `-`, not containing `..`,
185+ not ending in `/` or `.lock`), e.g. `%main`.
186+- **Path literals.** `./` at the start of a token, extending to the next
187+ whitespace or one of `( ) [ ] { } , ;`: `./src/lexer.rs`. This rule runs
188+ before the selector and operator rules for `.`, so a `.` inside a path is
189+ part of the path. The literal denotes the list of components after the
190+ `./`; a trailing `/` is ignored and a bare `./` is `[]`, the root. Names
191+ the literal cannot spell are built with `splitOn "/"`.
192+- **Operators.** Exactly these tokens, and no others (§3.2):
193+ `. * + - ++ :: == /= < <= > >= && ||`, plus the keyword
194+ `or`, which is an operator syntactically.
195+- **Selector.** A `.` immediately followed (no whitespace) by an identifier is a
196+ single selector token `.name`. Any other `.` is the operator `.`.
197+- **Punctuation.** `( ) [ ] { } , ; : \ = -> _`. The colon `:` is punctuation:
198+ it separates a name from its type in declarations and record types, and
199+ appears nowhere else.
200+
201+### 3.2 Operators and fixities
202+
203+The operator set and its fixities are part of the language. No others exist
204+and none can be declared.
205+
206+```
207+infixr 9 .
208+infixl 7 *
209+infixl 6 + -
210+infixr 5 ++ ::
211+infix 4 == /= < <= > >=
212+infixr 3 &&
213+infixr 2 ||
214+infixl 0 or
215+```
216+
217+Non-associative (`infix`) operators cannot be chained. Each symbolic operator
218+is still a builtin that must be declared in `config.j` to be usable (§6.2);
219+the table fixes how they parse, not whether they are in scope. `or` is a
220+keyword and is always available.
221+
222+### 3.3 Layout
223+
224+Two layout rules and nothing else.
225+
226+1. **Top level.** A top-level item begins with a token in column 1. Every
227+ following line whose first token is in a column greater than 1 continues
228+ the item. Blank and comment-only lines are ignored.
229+2. **`let` blocks.** After `let`, the column of the first token opens a block.
230+ A subsequent line whose first token is in exactly that column begins a new
231+ binding; a line indented further continues the current binding; the block
232+ ends at `in` or at the first token in a smaller column. Bindings on a single
233+ line are separated by `;`. Blank and comment-only lines are ignored here
234+ too.
235+
236+An expression given on the command line or on stdin is a single `expr`, not a
237+list of items, so rule 1 does not apply to it; rule 2 does.
238+
239+### 3.4 Grammar
240+
241+```
242+config := item*
243+item := typedecl | signature | definition
244+
245+typedecl := TYPENAME '=' type
246+signature := (ident | '(' op ')') ':' type
247+definition := ident '=' expr
248+
249+type := atype ('->' type)?
250+atype := TYPENAME | ident | '[' type ']' | '{' (ident ':' type (',' ident ':' type)*)? '}' | '(' type ')'
251+
252+pattern := ident | '_'
253+
254+expr := '\' pattern+ '->' expr
255+ | 'if' expr 'then' expr 'else' expr
256+ | 'let' bindings 'in' expr
257+ | opexpr
258+bindings := binding ((NEWLINE|';') binding)*
259+binding := ident '=' expr
260+
261+opexpr := app (op app)* -- resolved by the fixity table
262+app := postfix+ -- juxtaposition; left-associative
263+postfix := atom ( SELECTOR | '{' fields '}' )*
264+atom := ident | TYPENAME | INT | TEXT | IDLIT | NEWID | LABELLIT | PATHLIT | 'true' | 'false'
265+ | '(' expr ')' | '(' op ')' | '(' op expr ')' | '(' expr op ')'
266+ | '[' postfix* ']'
267+ | '{' fields '}'
268+ | SELECTOR
269+fields := (ident '=' expr (',' ident '=' expr)*)?
270+```
271+
272+Notes:
273+
274+- A definition binds a name to a value. Functions are values made with `\`;
275+ there is no separate function-definition syntax. `\x y -> e` takes two
276+ parameters.
277+- A signature immediately preceding a definition of the same name (blank
278+ lines and comments between are fine) documents and checks that definition
279+ (§4.13). A signature with no definition declares a builtin. At most one
280+ signature per name.
281+- `(op e)` is a right section (`\x -> x op e`), `(e op)` a left section
282+ (`\x -> e op x`), `(op)` the operator as a function.
283+- A bare `SELECTOR` atom `.name` is the accessor function `\x -> x.name`.
284+- `postfix` binds tighter than application, as in Haskell: `f r { a = 1 }`
285+ is `f (r { a = 1 })`. Consequently a record *literal* passed as an argument
286+ after another atom must be parenthesised: `attach ({ root = c, children = [] }) r`.
287+- List elements are separated by whitespace, not commas, and each element is
288+ a `postfix` (an atom with selectors and updates): `[1 2 3]`,
289+ `["src" "lexer.rs"]`, `[{ root = c, children = [] }]`. An application or
290+ operator expression as an element is parenthesised: `[(up repo).root.id]`.
291+ Records keep commas.
292+- Lambda, `if`, `let`, and `or` extend as far right as possible.
293+- Patterns are variables and `_` only. No literal, list, or record patterns.
294+
295+---
296+
297+## 4. Semantics
298+
299+### 4.1 Evaluation
300+
301+Strict, call by value, left to right. Function application is curried. All
302+top-level definitions in `config.j` and all bindings of one `let` block are
303+mutually recursive. A top-level definition is evaluated once, at load, in
304+dependency order: a definition is evaluated after every definition its body
305+refers to outside a lambda. A cycle among such references (two non-lambda
306+definitions each needing the other's value) is a configuration error;
307+references inside lambda bodies never form cycles, since a lambda is a value
308+before it is applied. The bindings of a `let` block are evaluated by the same
309+rule, with a cycle being a crash. A lambda body is evaluated on each
310+application.
311+Recursion is unbounded; the interpreter must handle deep recursion without
312+stack overflow (or turn exhaustion into a crash).
313+
314+### 4.2 Scoping
315+
316+Names resolve lexically: `let` bindings, then lambda parameters, then
317+top-level definitions and declared builtins. Field names are not in any
318+namespace; they occur only after `.`, in record literals, and in record
319+updates, so a field may be called `id` while `id` remains the identity
320+function.
321+
322+There is no shadowing. A `let` binding or lambda parameter may not use a name
323+already bound in an enclosing scope, including top-level definitions and
324+declared builtins; in `config.j` that is a configuration error (exit 3), on
325+the command line a parse error. Defining a top-level name twice, or defining a
326+name that is also a declared builtin, is a configuration error.
327+
328+### 4.3 Records
329+
330+- `e.f` selects field `f`; crashes if absent.
331+- `e { f = v, … }` returns a copy with the listed fields replaced; crashes if
332+ any listed field is absent. Update never adds fields.
333+- Duplicate field names in a literal or update are a parse error.
334+
335+### 4.4 Which values are a `Repo`
336+
337+A value is treated as a `Repo` in §1.2 step 8 if and only if its shape is
338+`Repo` and its `root`'s shape is `Commit`, in the sense of §4.12 (exact field
339+sets). Deeper shape errors surface as crashes during persistence.
340+
341+### 4.5 Equality
342+
343+`==` is structural on ints, texts, bools, lists, and records; identity on
344+`Id`; content-and-mode on `Blob`; on shapes, equal field sets or the same
345+primitive kind. Comparing functions crashes. `/=` is its
346+negation. Ordering operators are defined on `Int` only and crash otherwise.
347+
348+### 4.6 Special forms
349+
350+`if`, `or`, `&&`, and `||` do not evaluate all operands. `&&` and `||`
351+short-circuit. The interpreter recognises `&&` and `||` by name; `or` is a
352+keyword.
353+
354+`a or b` evaluates `a`. If that crashes, the crash is discarded and `b` is
355+evaluated and returned. Otherwise, if the value of `a` is a function, `b` is
356+evaluated too, and if it is also a function the result is the function
357+`\x -> a x or b x`, with the same rule applying again to its result. In every
358+other case the result is the value of `a` and `b` is not evaluated. The
359+lifting is what makes `edit or id` mean "try the edit, keep the repository if
360+it crashes": a function value cannot crash until it is applied, so without
361+lifting the `or` would catch nothing.
362+
363+### 4.7 Crashes
364+
365+`crash msg` aborts evaluation with `msg`. Every runtime error the interpreter
366+detects (unbound name, wrong kind of value, missing field, out-of-range index,
367+`head []`, `concat []`, comparing functions, non-`Bool` condition, arity or
368+kind errors in builtins) is a crash with an interpreter-generated message, and
369+is catchable by `or` exactly like a user crash. Nothing else is catchable: parse
370+and configuration errors, and `Id` literal resolution, happen before
371+evaluation.
372+
373+### 4.8 Overloading
374+
375+Two builtins dispatch on the kind of an argument; functions compose with `.`:
376+
377+| builtin | list | Text | function |
378+|---|---|---|---|
379+| `++` | concatenation | concatenation | crash |
380+| `concat` | flatten `[[a]] -> [a]` | join `[Text] -> Text` | crash |
381+
382+`concat []` crashes (there is nothing to dispatch on). `map`, `filter`,
383+`length`, `null`, `head`, `tail`, `nth`, `take`, `drop`, `last`, `member`,
384+`foldl`, and `::` are defined on lists only.
385+
386+### 4.9 Builtins
387+
388+The following table is normative for behaviour. Each builtin is available only
389+if `config.j` declares it (§6.2); the declared type is enforced at each call
390+as in §4.13.
391+
392+| name | behaviour |
393+|---|---|
394+| `.` | composition: `(f . g) x = f (g x)` |
395+| `id` | identity |
396+| `const` | `const a b = a` |
397+| `crash` | §4.7 |
398+| `==`, `/=` | §4.5 |
399+| `&&`, `||`, `not` | booleans; `&&`/`||` short-circuit |
400+| `+`, `-`, `*` | integer arithmetic |
401+| `<`, `<=`, `>`, `>=` | integer comparison |
402+| `show` | renders any value as text in literal syntax (§5.2) |
403+| `::` | cons |
404+| `map`, `filter` | as usual |
405+| `length`, `null` | as usual |
406+| `head`, `tail`, `last` | crash on empty |
407+| `nth i xs` | 0-based; crash out of range |
408+| `take n`, `drop n` | clamp to list length |
409+| `member x xs` | membership by `==` |
410+| `range a b` | `[a (a+1) … (b-1)]`, empty if `b <= a` |
411+| `foldl f z xs` | left fold: `foldl f z [x y] = f (f z x) y`; `z` for `[]` |
412+| `concat`, `++` | §4.8 |
413+| `startsWith p t`, `endsWith s t` | text predicates |
414+| `splitOn sep t` | the pieces of `t` between occurrences of `sep`; `splitOn "/" "a/b"` is `["a" "b"]`, `splitOn "/" ""` is `[""]` |
415+| `replay onto ch` | replay the change `ch` (`{ from, to }`) onto the snapshot `onto`; §7.3 |
416+| `unresolved b` | `true` iff the blob is a conflict |
417+| `blob t` | a resolved regular-file blob with content `t` |
418+| `text b` | the content of a blob as text; for an unresolved blob, its conflict-marker rendering |
419+| `by i repo` | the repo refocused on the commit with id `i`; crashes if absent. Backed by the change-id index; its reference definition is in §10 |
420+| `diff a b` | a unified diff from the text of `a` to the text of `b`, no header lines, three lines of context; `""` if equal; crashes if either is not UTF-8 |
421+| `difft p a b` | the output of difftastic comparing `a` to `b` as the file `p`; §7.10. Crashes if `difft` is not on `PATH` |
422+| `treeWith o r` | the history rendered as a tree with options record `o`; §7.11 |
423+| `extract S v` | every subvalue of `v` that is an `S`; §4.12 |
424+| `meta i` | `{ hash, author, email, time }` for the visible commit with change id `i`: its current git hash, author name and email, committer time. Crashes for an id with no stored commit (one minted in this program) |
425+| `validate repo` | `repo` unchanged if persisting it would succeed; otherwise the crash persistence would raise (§7.5 steps 1–3 and 6). Persistence runs the same checks |
426+
427+### 4.10 Ids: existing and new
428+
429+**Existing.** An `Id` literal `@kpqx` denotes the existing commit whose id
430+begins with `kpqx`. It is resolved before evaluation, not during it:
431+
432+- In the command-line expression, every literal is resolved after parsing and
433+ before evaluation (§1.2 step 5), against the ids visible when the program
434+ started. A literal that matches no id or more than one is a crash that lists
435+ the candidates in commit-table form (§5.1); nothing is evaluated.
436+- In `config.j`, every literal is resolved at load, against the same ids. A
437+ literal that fails to resolve is a configuration error (exit 3) naming the
438+ definition it appears in.
439+
440+After resolution a literal is an ordinary `Id` value holding the full id, so
441+`==` on ids is identity and never involves prefixes.
442+
443+**New.** The bare token `@` is an expression that yields a distinct, freshly
444+minted `Id` each time it is evaluated. In a script that writes `@` once, this
445+is indistinguishable from replacing it with a new id before the run. In a
446+lambda body it is evaluated on each application, so `newCommit` (§9) mints a new
447+id per call. A top-level definition that is a bare record containing `@` is
448+evaluated once at load and holds one id for the whole program (§4.1). `show`
449+renders a minted id like any other.
450+
451+### 4.11 Label literals
452+
453+`%main` denotes `labelled "main"`, a `Revset`. It is a function, evaluated
454+when applied, so it never fails at parse time: a name the remote does not have
455+yields `[]`, and `goto %nope` crashes with "expected one revision, got 0" as
456+any revset would.
457+
458+### 4.12 Shapes as values, and `extract`
459+
460+A type name used in an expression evaluates to a *shape*: for a `typedecl`
461+whose type is a record, the set of its field names; for a primitive kind
462+(`Int`, `Text`, `Bool`, `Id`, `Blob`), that kind; for a `typedecl` that is an
463+alias of another type, that type's shape. A shape whose type is a function
464+(`Edit`, `Revset`), a list, or undeclared is a crash when used.
465+
466+`extract S v` returns every subvalue of `v` that is an `S`, as a list. It
467+walks lists in order and records in ascending field-name order, descending
468+into every element and field; it does not enter functions or blobs. A record
469+is an `S` if its field set is exactly `S`'s (the same rule display uses to
470+recognise shapes); a primitive value is an `S` if it is of that kind. Matches
471+are collected in traversal order, and a matching record is also searched
472+inside for nested matches.
473+
474+```haskell
475+extract Commit repo -- every commit reachable from a Repo, Subtree, or Frame
476+extract Id repo -- every id in it
477+extract Push xs -- the Push records in a list, however nested
478+```
479+
480+`extract` does not promise a meaningful order for values with several
481+positional fields; a `Repo` yields its `children` before its `context` before
482+its `root`. Where order matters, `commits (top repo)` is preorder.
483+
484+### 4.13 Contracts
485+
486+Every application of a function that has a signature — a builtin or a
487+definition with a signature — is checked against it: each argument as it is
488+supplied, against the corresponding parameter type, and the result once the
489+application is complete (yields a non-function), against the result type.
490+Lambdas without a signature and the command-line expression are unchecked
491+except through the functions they call. A signature on a definition that is
492+not a function (`user`, `conflicts`) is checked once, against the value, when
493+the definition is evaluated at load.
494+
495+The check is one level deep and never inspects inside a list or a record's
496+field values:
497+
498+| declared type | check |
499+|---|---|
500+| `Int`, `Text`, `Bool`, `Id`, `Blob`, `Shape` | the value is of that kind |
501+| `[T]` | the value is a list; elements are not inspected |
502+| `{ a : T, … }`, or a shape name | the value is a record with exactly those field names |
503+| `A -> B` | the value is a function |
504+| a type variable | anything |
505+| an alias (`Edit`, `Revset`, `Snapshot`) | the type it stands for; an alias of a function type is unfolded so that further arguments and the final result are checked against it (`at : Revset -> Edit -> Edit` checks three arguments and a `Repo` result) |
506+
507+Field values and list elements are not checked because that would make every
508+call cost the size of the value and would force lazily backed snapshots and
509+histories (§7.2).
510+
511+A violation is a crash, catchable by `or` like any other:
512+
513+```
514+j: crash: contract: describe expected Text as argument 1, got Int
515+ in describe, from describe 3
516+```
517+
518+For records the expected and actual field sets are shown:
519+`expected { files, labels, id, message }, got { children, context, root }`.
520+
521+---
522+
523+## 5. Output
524+
525+Two renderers. **Display** is what the CLI prints; it is designed to be read.
526+**`show`** is the builtin; it renders literal syntax designed to be pasted
527+back. They share glyphs and colours with `treeWith` (§7.11) and are
528+implemented beside it.
529+
530+### 5.1 Display
531+
532+Every kind of value has a *line* form and a *block* form. The top-level value
533+prints in block form. Values nested inside another print in line form,
534+indented under their key or bullet. Colour follows §7.11's rules and is used
535+only when stdout is a terminal; nothing is conveyed by colour alone.
536+
537+Shapes are recognised by their exact field set, as in §4.12: `Commit`, `Entry`,
538+`Subtree`, `Repo`, `Frame`, `Change`. Any other record is generic.
539+
540+| kind | line form | block form |
541+|---|---|---|
542+| `Int` | decimal | same |
543+| `Bool` | `true` / `false` | same |
544+| `Text` | raw, first line, `…` if truncated | raw, all of it, trailing newline added if absent |
545+| `Id` | shortest unique prefix (bold), remainder (dim) | same |
546+| `Blob` | `‹214 B›`, `‹✖ 3.1 KB›` if unresolved | its content, raw (markers if unresolved) |
547+| function | name, lambda source, or `f arg …` | same |
548+| `Shape` | its type name | same |
549+| `Commit` | `glyph id message labels` — a `tree` line without rails | that line, then `author · age · n files`, then one changed path per line with `+ ~ − ✖` marks |
550+| `Entry` | `path size`, `✖` prefix if unresolved | same |
551+| `Subtree` | its top commit's line form | a tree rooted at it, no focus |
552+| `Repo` | its focus's line form | `treeWith` with the config's `tree` options |
553+| `Frame` | its parent's line form | parent's line, then its children's line forms in a row with `▢` at the hole |
554+| `Change` | `n paths` | one touched path per line with `+ ~ − ✖` marks, as in a `Commit` block |
555+| generic record | `{ a, b, … }` (field names only) | one field per line, names left-aligned and dim, values indented if multi-line |
556+| list of `Id` | `n commits` | a commit table: one commit line per id, in list order |
557+| list of generic records with identical field sets | `n rows` | a column table with a dim header; `true` shows as `✓`, `false` as blank; columns empty in every row are dropped |
558+| list of `[Text]` | `n paths` | one per line, joined with `/` |
559+| list of `Text` | `n items` | one per line |
560+| list of other scalars | `a, b, c` | same, wrapped at the terminal width |
561+| any other list | `n items` | one block per item, separated by a blank line |
562+
563+An empty list of any kind prints `none` (dim) in block form and `0 …` in
564+line form. Author and age in a `Commit` block are read from jj's commit
565+metadata by id;
566+they are not fields of the value. A list of `[Text]` prints as paths because
567+that is what such a list is in every base function; `show` is available
568+when the literal is wanted. Alignment in tables and columns uses display
569+width (East Asian wide characters count two cells, combining marks zero), not
570+code-point count.
571+
572+Examples:
573+
574+```
575+$ j status
576+changed src/lexer.rs
577+ src/parser.rs
578+conflicts src/lexer.rs
579+id wqzt·kpqxmnrvyxskptlmzzzzabcd
580+labels feature
581+message wip
582+
583+$ j conflicts
584+⊗ wqzt wip feature
585+⊗ ptlm docs
586+
587+$ j log
588+ id message labels focus
589+ zzzz
590+ kpqx add parser main
591+ mnrv fix lexer
592+◉ wqzt wip feature ✓
593+ yxsk
594+ ptlm docs
595+
596+$ j focus
597+◉ wqzt wip feature
598+ Montelot · 2h ago · 2 files
599+
600+ ✖ src/lexer.rs
601+ ~ src/parser.rs
602+
603+$ j files
604+ Cargo.toml 214 B
605+✖ src/lexer.rs 3.1 KB
606+ src/parser.rs 8.7 KB
607+
608+$ j clean
609+false
610+```
611+
612+(`·` marks the bold/dim boundary in an id; there is no character there.)
613+
614+Crashes print the message, then the innermost base definition that was
615+executing and the top-level expression:
616+
617+```
618+j: crash: expected one revision, got 0
619+ in goto, from goto %nope
620+```
621+
622+### 5.2 `show`
623+
624+`show v` renders `v` as an expression in the language, such that for every
625+value except closures and unresolved blobs, `show v` parses and evaluates to a
626+value equal to `v` under the same `config.j`. It never uses colour and never
627+truncates.
628+
629+| kind | rendering |
630+|---|---|
631+| `Int` | decimal |
632+| `Text` | double-quoted, escaped as in §3.1 |
633+| `Bool` | `true` / `false` |
634+| list | `[v1 v2 …]` |
635+| record | `{ a = v, b = w }`, fields in ascending lexicographic order |
636+| `Id` | `@` followed by the full id, e.g. `@wqztkpqxmnrvyxskptlmzzzzabcd` |
637+| resolved `Blob` | `blob "…"` |
638+| unresolved `Blob` | `{- unresolved -} blob "…"` with the conflict-marker rendering |
639+| builtin | its declared name, operators parenthesised: `map`, `(++)` |
640+| top-level definition | its name |
641+| revset from a label literal | `%name` |
642+| shape | its type name: `Commit`, `Id` |
643+| lambda closure | its source text, exactly as written |
644+| partial application | the function's rendering followed by the arguments' renderings, each parenthesised if not atomic: `describe "wip"`, `at (parents) (describe "x")` |
645+
646+Line breaking: one line if it fits in 80 columns; otherwise the outermost
647+list or record breaks one element per line, two-space indented, with leading
648+commas for records and nothing between list elements, recursively.
649+
650+Since `show` returns a `Text` and a top-level `Text` displays raw,
651+`j show . focus` prints the literal of the focused commit, and
652+`show . f` does the same for any `f`.
653+
654+---
655+
656+## 6. Configuration
657+
658+### 6.1 Location and loading
659+
660+`j` reads `$XDG_CONFIG_HOME/j/config.j`, defaulting to `~/.config/j/config.j`.
661+It is read by every invocation except `remote`, `fetch`, `undo`, `redo`, and
662+`ops`: expression evaluation and `push` need its definitions, `init` and
663+`clone` need `user`. When there is no repository yet (`init`, `clone`), only
664+`user` is evaluated; `Id` literals are not resolved and no other definition
665+is evaluated. If it does not exist, `j` exits 3 with
666+`j: no config at <path>; the reference config.j is installed at <share-path>`.
667+The binary is distributed with the reference `config.j` (§9) at
668+`<prefix>/share/j/config.j`; `j` never writes to the config path itself.
669+
670+### 6.2 Validation
671+
672+Loading fails (exit 3) if any of these hold:
673+
674+1. The file does not parse (§3).
675+2. A signature without a definition names something the interpreter does not
676+ implement; or a name has two signatures; or a signature is not immediately
677+ followed by its definition when one exists.
678+3. A name is defined twice, or a definition uses the name of a builtin the
679+ interpreter implements (those names are reserved whether or not declared).
680+4. A `typedecl` name is declared twice.
681+5. An `Id` literal fails to resolve (§4.10).
682+6. `user`, `immutable`, `tree`, or `labelled` is absent; or `user` is not a
683+ record with non-empty `name` and `email` texts; or `immutable`, `tree`, or
684+ `labelled` is not a function.
685+
686+Loading does **not** check types statically. Signatures are enforced at
687+each call, one level deep (§4.13). The *presence* of a signature with no
688+definition is what brings a builtin into scope; an implemented builtin that is
689+not declared is simply unbound, and using it is a runtime crash.
690+
691+Definitions may reference each other in any order.
692+
693+### 6.3 Definitions the interpreter reads
694+
695+Four names in `config.j` are read by the interpreter itself, not only by
696+expressions:
697+
698+- `user`, a record `{ name, email }`: the identity that authors commits
699+ (§7.9).
700+- `immutable`, a `Revset` that persistence evaluates to decide what may not be
701+ rewritten (§7.5).
702+- `tree`, used by display to render a `Repo` (§5.1).
703+- `labelled`, which a label literal `%name` desugars to (§4.11).
704+
705+All four are ordinary definitions; the reference `config.j` provides them and
706+a user edits or redefines them.
707+
708+---
709+
710+## 7. Repositories
711+
712+`j` operates on jj repositories through jj-lib. It creates jj commits,
713+operations, and working-copy state that `jj` itself can read, and vice versa,
714+but nothing here requires the `jj` binary to be installed.
715+
716+### 7.1 Locating the repository
717+
718+Walk up from the current directory to the first directory containing `.jj`.
719+Only the default workspace is supported; if the working directory belongs to
720+another workspace, exit 2.
721+
722+### 7.2 Building the `Repo` value
723+
724+The history of the repository is loaded at the current operation and mapped to
725+a value as follows.
726+
727+- **Commits.** Every visible commit (an ancestor of a visible head), including
728+ jj's root commit, becomes a `Commit` record: `files` from its tree (§7.3),
729+ `message` from its description, `labels` from the remote bookmarks of
730+ `origin` pointing at it (§7.6), `id` from its change id.
731+- **Tree.** The root commit is the top of the tree. Each commit's parent is
732+ its **first** jj parent. A commit with two or more parents (a merge) is
733+ therefore placed under its first parent and its other parents are not
734+ represented; such a commit is *read-only* (§7.5). If two visible commits
735+ share a change id, exit 2 with
736+ `j: change <id> has two visible commits; j cannot operate on this repository`.
737+- **Child order.** A commit's children are ordered by committer timestamp
738+ ascending, ties broken by change-id text ascending. Within a program,
739+ `attach` appends. The order is presentation only: no command and no law
740+ depends on which child is first, and the language has no way to select a
741+ child by position.
742+- **Focus.** The zipper is focused on the default workspace's working-copy
743+ commit, after snapshotting (§7.4). `context` is computed from the path to the
744+ root. If that commit is in the immutable set (§7.5) — typically because a
745+ `fetch` found the remote's `main` pointing at it — the snapshot cannot go
746+ into it; the focus is instead a new empty child of it holding the snapshot,
747+ as `abandon` would leave it, and persisting records that child. Nothing
748+ else is written by loading.
749+
750+The record is the *semantics*. An implementation may back `files`, `children`,
751+and `context` with lazily materialised structures, provided every observation
752+an expression can make agrees with the eager value.
753+
754+### 7.3 Files, blobs, and `replay`
755+
756+- A commit's `files` has one `Entry` per tracked path in its tree. `path` is
757+ the list of path components; components must be valid UTF-8, and a
758+ repository with a path that is not exits 2. `content` is a `Blob` carrying the file's
759+ content and type (regular, executable, symlink) and, for conflicted paths,
760+ jj's conflict value for that path.
761+- A path that is deleted and resolved has no entry. A path that is in a
762+ deletion conflict has an entry whose blob is unresolved.
763+- `replay onto { from, to }` is jj's tree merge: for each path present in
764+ any of the three snapshots, the three-way merge of that path's values with
765+ `from` as the common ancestor, replaying the change from `from` to `to` on
766+ top of `onto`. Conflicts are kept as unresolved blobs, and resolved where
767+ jj's merge would resolve them (identical changes, changes from the current
768+ content, non-overlapping line-level hunks). It never crashes on content. It
769+ crashes if any of the three is not a well-formed snapshot (a list of `Entry`
770+ records with unique paths).
771+- `unresolved`, `blob`, and `text` are as in §4.9. `blob` produces a regular
772+ file. File type (executable, symlink) travels with a `Blob` but cannot be
773+ changed in the language. `text` of a symlink is its target; `text` of a
774+ non-UTF-8 blob crashes.
775+
776+### 7.4 Working-directory invariant
777+
778+At every program boundary the files of the focused commit equal the working
779+directory.
780+
781+- **Before evaluation**, the working directory is snapshotted into the
782+ working-copy commit, and that is the `files` of the focus in the `Repo`
783+ value. The snapshot tracks every file not matched by `.gitignore` files,
784+ with no size limit, symlinks as symlinks; these rules are fixed, since `j`
785+ reads no jj configuration. If the snapshot changed the commit, that change
786+ is part of the operation persisted afterwards, or discarded with the rest if
787+ nothing is persisted. A crashed or printing program leaves the working copy
788+ and the repository exactly as before; `j id` is the way to record the
789+ working directory and nothing else (§1.2).
790+- **After persistence**, the focus of the result is checked out: its files are
791+ written to the working directory, unresolved blobs materialised with jj's
792+ conflict markers, and the workspace's working-copy commit set to the focus.
793+
794+Reserved commands never snapshot.
795+
796+### 7.5 Persistence
797+
798+Given the loaded value `old` and the result `new`, one jj operation is
799+recorded, described by the expression text (truncated to 200 characters).
800+Within it:
801+
802+1. **Validate shape.** `new` has the shape of a `Repo`; every `id` occurs
803+ exactly once; every snapshot has unique paths. Violations crash with a
804+ message naming the rule.
805+2. **Validate labels.** The set of `(id, label)` pairs in `new` equals the set
806+ in `old`. Labels are the remote's names (§7.6); a script cannot add, move,
807+ or remove one. In particular a commit carrying a label cannot be abandoned.
808+3. **Validate immutability.** The *immutable set* is the config's `immutable`
809+ revset evaluated against `old`, together with every merge commit (§7.2)
810+ and all of its ancestors. Every id in it must be present in `new` with the
811+ same parent id, files, and message. Violations crash naming the commit.
812+ (Ancestors of merges are included because rewriting an ancestor rewrites
813+ the merge, whose second parent the model cannot represent.)
814+4. **Walk `new` top-down.** For each commit `c` (root first):
815+ - If `c.id` is the root commit's change id, it must be the top of the tree
816+ and must be unchanged; otherwise crash.
817+ - If `c.id` is not in `old`: create a jj commit with parent = the jj commit
818+ written for `c`'s parent in this walk, tree from `c.files`, description
819+ `c.message`, author and committer from `user` (§7.9) and the current
820+ time, change id `c.id`.
821+ - Otherwise, if `c`'s parent's *written* commit hash, or `c.files`, or
822+ `c.message` differs from the stored commit: rewrite it (new commit hash,
823+ same change id, stored as a successor so jj records predecessors),
824+ keeping the author and author date, with committer `user` and the
825+ current time.
826+ - Otherwise keep the stored commit.
827+5. **Abandon** every change id in `old` that is not in `new`.
828+6. **Focus.** The focus must be mutable: if `new.root.id` is the root commit
829+ or in the immutable set, crash with
830+ `the focus must be a mutable commit; compose with new`. Otherwise set the
831+ default workspace's working-copy commit to the commit carrying
832+ `new.root.id`. (`abandon` and `squash` end with `new` for this reason, so
833+ they are safe on a commit directly above `main`; navigation onto `main`
834+ is written `new . goto trunk`.)
835+7. Commit the operation, then perform the checkout of §7.4.
836+
837+Labels are not written by persistence at all; they are derived from remote
838+refs, which only `fetch` and `push` change. The interpreter may run any part
839+of the walk through jj-lib's rebase machinery provided the stored result
840+equals what the steps above produce.
841+
842+### 7.6 Labels and the remote
843+
844+A label is the name of a bookmark on the remote `origin`, as of the last
845+`fetch` or `push`. There is one namespace. Labels are read-only in the
846+language: `fetch` and `push` are the only things that change them.
847+
848+- **`fetch`** fetches from `origin` over git and imports refs. Afterwards
849+ each remote bookmark `name` is the label `name` on the commit it points at;
850+ labels for bookmarks that no longer exist on the remote are removed; commits
851+ reachable from remote bookmarks become visible. Fetching never moves the
852+ focus or touches the working directory. It records one operation.
853+- **`push EXPR`** evaluates `EXPR` (§1.1); a function result is applied to
854+ the recorded repository. The value must be a list whose elements are records
855+ of two shapes:
856+ - `{ id : Id, name : Text }` — set bookmark `name` to the commit `id`,
857+ creating it if absent;
858+ - `{ delete : Text }` — delete the bookmark of that name.
859+
860+ Duplicates are merged; two records with the same `name` and different
861+ effects are a crash. It is refused (exit 1, nothing pushed) if:
862+ - a `name` is not a valid git branch name, an `id` is not a visible commit,
863+ or a delete names a bookmark the remote does not have;
864+ - any commit that would be sent has an unresolved file or an empty
865+ description;
866+ - the bookmark's current target is in the immutable set (§7.5) and the
867+ record would move it to a commit that does not descend from it, or delete
868+ it.
869+
870+ Moving a bookmark whose current target is not immutable to an unrelated
871+ commit is allowed; that is how a rewritten stack is re-pushed. After a
872+ successful push the labels reflect the new positions. It records one
873+ operation.
874+
875+Only the remote named `origin` is supported in this version; `fetch` and
876+`push` exit 1 with `j: no remote origin` when it is not configured.
877+Authentication is git's: the ssh agent for ssh URLs, git credential helpers
878+for https, as jj-lib's git backend provides them; `j` adds no prompting.
879+Bringing work up to date is a separate expression, e.g. `rebase trunk`.
880+
881+### 7.7 Operations, undo, redo, and the lock
882+
883+Every persisting `j` program, and each of `fetch` and `push`, records exactly
884+one jj operation, described by the expression or command text. Crashes record
885+nothing.
886+
887+**Kinds.** `j` marks each operation it records, in the operation's metadata,
888+as *normal*, *undo* (naming the operation it undid), or *redo* (naming the
889+undo it reversed). Operations recorded by other tools are treated as normal,
890+except that an operation whose description carries jj's own undo or redo
891+marker is treated accordingly, so mixed use chains correctly where the marker
892+is present and degrades to "undo the newest operation" where it is not.
893+
894+**`undo`** finds the operation to undo: starting at the head, while the
895+current operation is an undo, move to the operation it undid and then to that
896+operation's parent; while it is a redo, move past the operation it reapplied.
897+The first normal operation reached is undone by recording a new operation
898+that restores its parent's view, marked as an undo of it. If none is found,
899+exit 1 with `j: nothing to undo`.
900+
901+**`redo`** requires the head to be an undo (possibly reached through redos);
902+it records an operation restoring the view of the operation that undo
903+discarded, marked as a redo. Otherwise exit 1 with `j: nothing to redo`.
904+
905+Both refuse, before doing anything, if the working directory differs from the
906+focused commit's files: `j: working copy has changes not in @; run \`j id\`
907+to record them or discard them`. Both check out the restored focus afterwards.
908+Undoing a `push` restores the recorded labels and leaves the remote untouched.
909+
910+**`ops`** prints the operation log, newest first, one line each: relative
911+time, the operation's description (a `j` expression, a command, or another
912+tool's description), and `undo`/`redo` where applicable; the current
913+operation is marked. Rendered in the binary like `tree`.
914+
915+**Lock.** A run that may persist (any expression evaluation, `fetch`, `push`,
916+`undo`, `redo`) takes an exclusive lock, the file `.jj/j.lock`, for its
917+duration.
918+A second `j` exits 2 with `j: another j is running in this repository`.
919+Display-only runs are not distinguishable in advance, so they take the lock
920+too; only reserved commands that need no repository (`init`, `clone`) do not.
921+The lock prevents `j` from ever producing two commits with one change id.
922+Other tools operating concurrently are outside `j`'s control; if they produce
923+that state, §7.2 applies.
924+
925+### 7.8 Creating and connecting repositories
926+
927+- **`init`** creates a colocated repository (both `.jj` and `.git`) in the
928+ current directory. If `.git` already exists and `.jj` does not, jj is
929+ initialised on top of the existing git repository and its history is
930+ imported (subject to §7.2). Either way a working-copy commit is created as a
931+ child of the current head (or of the root commit for an empty repository)
932+ and checked out. It exits 2 if the directory is already inside a jj
933+ repository, or if `.git` exists but is not a directory (a git worktree or
934+ submodule). It requires `user` (§7.9).
935+- **`clone URL [DIR]`** creates `DIR` (exit 2 if it exists and is non-empty),
936+ clones `URL` into it as a colocated repository, sets `origin` to `URL`,
937+ fetches, and creates an empty working-copy commit as a child of the
938+ remote's default bookmark target (or of the root commit if the remote has
939+ none). It requires `user`.
940+- **`remote URL`** sets the URL of `origin`, creating the remote if it does not
941+ exist. It does not fetch.
942+
943+### 7.9 Identity
944+
945+Commits are authored and committed as `user.name <user.email>` from
946+`config.j` (§6.3). `j` reads no other configuration; jj-lib's settings object
947+is constructed by `j` from `user` and fixed defaults. `init`, `clone`, and
948+persistence exit 3 if `user` is missing or malformed, since that is a
949+configuration error.
950+
951+### 7.10 External diff rendering
952+
953+`difft p a b` runs difftastic. It is the only builtin that executes another
954+program.
955+
956+1. Write the content of `a` and `b` to two temporary files whose names end in
957+ the last component of `p` (so difftastic's language detection sees the
958+ real filename), inside a fresh temporary directory.
959+2. Run `difft OLD NEW` with the process's stdin closed and its environment
960+ inherited unchanged, except: if `DFT_COLOR` is unset and `j`'s stdout is a
961+ terminal, set `DFT_COLOR=always`; if `DFT_WIDTH` is unset and `j`'s stdout
962+ is a terminal, set it to the terminal width.
963+3. Capture stdout. Delete the temporary directory.
964+4. Return stdout as `Text`. A non-zero exit status is not an error (difftastic
965+ uses it to signal "differences found" under some options); a failure to
966+ execute the program is a crash naming it.
967+
968+All rendering options are difftastic's own, set through its `DFT_*`
969+environment variables. `j` adds no options of its own. `diff` remains
970+available as a dependency-free unified diff; the reference `config.j` uses
971+`difft` for display, so a user without difftastic edits one line.
972+
973+Packaging: difftastic is a runtime dependency; on Nix, wrap `j` with `difft`
974+on its `PATH`.
975+
976+### 7.11 Tree rendering
977+
978+`treeWith options r` renders the history as text. It is implemented in the
979+binary because it is presentation, not semantics. It reads the `Repo` value,
980+the immutable set (§7.5), `diff` sizes, and (for the margin, ages, and the
981+sibling order) jj's commit metadata by id.
982+
983+#### Options
984+
985+```
986+{ detail : Int -- 0, 1, or 2; see below
987+, margin : Bool -- age and author column
988+, elide : Bool -- fold uninteresting runs
989+, icons : Bool -- pictographic glyph set
990+, color : Text } -- "auto", "always", "never"
991+```
992+
993+Commits with no stored counterpart — minted in this program, as in a dry
994+run — have no metadata: their margin cells are blank, their size bar is
995+computed from the value, and they sort last among their siblings. The same
996+holds for a `Commit` block in display (§5.1).
997+
998+Missing fields crash. `color = "auto"` means colour iff stdout is a terminal.
999+If the environment variable `NO_COLOR` is set and non-empty, colour is never
1000+used regardless of `color`. `config.j` fixes the defaults by defining `tree`.
1001+
1002+#### Layout
1003+
1004+Root at top, one line per commit, plus optional detail lines under the focus.
1005+
1006+```
1007+ ⌂ zzzz
1008+ ╎ 14
1009+ ◆ kpqx ▅ add parser main 3d mo
1010+ ├─ ○ mnrv ▂ fix lexer 1d mo
1011+ ▶ ├─ ⊗ wqzt ▃ wip feature 2h mo
1012+ │ ✖ src/lexer.rs ~ src/parser.rs + tests/lexer.rs
1013+ │ └─ ○ yxsk ▃ 1h mo
1014+ └─ ◌ ptlm docs ⋯ 3 5d ak
1015+```
1016+
1017+(That is `detail = 2, margin = true`. With `detail = 1, margin = false`, the
1018+size bars, the margin, and the detail line are absent.)
1019+
1020+Columns, left to right:
1021+
1022+1. **Gutter**, two characters: `▶` on the focus line, blank otherwise.
1023+2. **Rails**: `│ ├─ └─`, two columns per depth; `╎ n` for an elided run
1024+ (§Elision).
1025+3. **Node glyph**: what kind of commit.
1026+4. **Id**: shortest prefix unique among visible commits, minimum 4.
1027+5. **Size bar** (`detail ≥ 1`, focus and its parent and children only; all
1028+ commits at `detail = 2`): one of `▁ ▂ ▃ ▅ ▇`, lines added plus removed
1029+ against the parent, thresholds 1, 10, 50, 200, 1000. Absent for empty
1030+ commits.
1031+6. **Message**: first line; nothing if empty.
1032+7. **Labels**: right-aligned column, present only if any rendered commit has
1033+ a label; names separated by two spaces.
1034+8. **Margin** (`margin = true`): age and author initials, right-aligned.
1035+ Age is the committer time, rendered `Ns`, `Nm`, `Nh`, `Nd`, `Nw`, `Ny`.
1036+ Initials are the first letters of the author name's words, at most two.
1037+9. **Collapse marker** `⋯ n` after the message of a collapsed subtree's top.
1038+
1039+**Detail line** (`detail = 2`, focus only): indented under the focus, the
1040+changed paths with a mark each: `+` added, `~` modified, `−` deleted,
1041+`✖` unresolved. Renames are not detected; they appear as `−` and `+`.
1042+
1043+#### Glyphs
1044+
1045+| glyph | meaning | `icons = true` |
1046+|---|---|---|
1047+| `◉` | focus | `🌸` |
1048+| `●` | ancestor of the focus | `🌿` |
1049+| `○` | other commit | `🍃` |
1050+| `◆` | in the immutable set (§7.5) | `🪨` |
1051+| `◌` | empty: no change against its parent | `🫙` |
1052+| `⊗` | has unresolved files | `🔥` |
1053+| `⌂` | root | `🌱` |
1054+
1055+Precedence when several apply: `⌂`, then `⊗`, then `◌`, then `◆`, then the
1056+position glyphs; position is then carried by colour (and, for the focus, by
1057+`▶`).
1058+
1059+#### Elision (`elide = true`)
1060+
1061+A commit is *interesting* if it is the focus, an ancestor of the focus, a
1062+child of an ancestor of the focus, labelled, conflicted, a leaf, or has more
1063+than one child. Runs of consecutive uninteresting commits on a single line of
1064+descent are folded into `╎ n`. Subtrees whose top commit is not the focus, an
1065+ancestor of it, or a child of an ancestor are rendered as their top commit
1066+followed by `⋯ n` (omitted when `n = 0`), where `n` counts hidden descendants.
1067+With `elide = false` everything is shown.
1068+
1069+#### Colour
1070+
1071+Applied only when colour is on. Glyphs always carry the meaning; colour only
1072+adds emphasis, so a colourless rendering loses nothing.
1073+
1074+| element | treatment |
1075+|---|---|
1076+| focus line | bold, faint background |
1077+| ancestors of the focus | normal |
1078+| all other lines | dim |
1079+| rails on the path from the top to the focus | cyan |
1080+| other rails, `╎ n`, `⋯ n` | dim |
1081+| id of a conflicted commit | red |
1082+| id of an immutable commit | blue |
1083+| message of an empty commit | dim italic |
1084+| labels | green |
1085+| size bar | one accent colour, brighter with size |
1086+| detail marks `+` `−` `~` `✖` | green, red, yellow, red |
1087+| age | grey scale: brighter when more recent |
1088+| author initials | a hue chosen per author, stable across runs (hash of the name) |
1089+
1090+Messages are never coloured except dim (cousins) and dim italic (empty).
1091+
1092+---
1093+
1094+## 8. Laws
1095+
1096+These hold for the reference `config.j` and should be property-tested against
1097+an in-memory implementation of the builtins (§10). `=` means the results are
1098+equal, or both crash.
1099+
1100+```
1101+by r.root.id r = r
1102+prev (next r) = r (when r has exactly one child)
1103+next (prev r) = r (when r is an only child)
1104+top (top r) = top r
1105+tip (tip r) = tip r
1106+(abandon . new) r = r (up to the minted id of the new focus)
1107+(abandon . contract m . split m) r = r (up to minted ids)
1108+rebase parents r = r
1109+goto here r = r
1110+into i r = by i r (when i is a child of the focus)
1111+at rs id r = r
1112+(e or id) r = r (when e r crashes)
1113+ancestors r = r.root.id :: ancestors (up r) (when up r exists)
1114+forEach (const []) e r = r
1115+eachChild id r = r
1116+replayOnto (up r).root r = r
1117+rewrite id r = r
1118+replay b { from = b, to = x } = x
1119+replay x { from = b, to = b } = x
1120+invert (invert ch) = ch
1121+touched (invert ch) = touched ch
1122+from here f = f
1123+concat [xs] = xs
1124+concat (xs ++ ys) = concat xs ++ concat ys (both non-empty)
1125+e or x = e (when e does not crash)
1126+crash m or x = x
1127+```
1128+
1129+---
1130+
1131+## 9. Reference `config.j`
1132+
1133+This file is the complete definition of the language's vocabulary. It ships
1134+with the binary and is the version installed to `~/.config/j/config.j`.
1135+
1136+```haskell
1137+-- config.j — everything j knows.
1138+--
1139+-- j reads this file on every run. An expression can use a name only if this
1140+-- file declares it (a builtin, implemented by j) or defines it (written here,
1141+-- in the language). Nothing else exists.
1142+--
1143+-- Conventions in this file:
1144+-- Name = … a shape: a record layout, matched by its field names
1145+-- name : Type a signature. Alone, it declares a builtin implemented
1146+-- by j. Before a definition, it documents that definition.
1147+-- Either way j checks arguments and results against it at
1148+-- each call, one level deep: kind, list-ness, field names.
1149+-- name = expr a definition. Functions are lambdas: name = \x -> …
1150+--
1151+-- Edits. Most definitions are Edits: functions from a Repo to a Repo. On the
1152+-- command line a function result is applied to the current repository; a Repo
1153+-- result is persisted. Anything else is printed. `e2 . e1` runs e1 then e2;
1154+-- `e or id` runs e and leaves things alone if it crashes.
1155+--
1156+-- Four definitions here are read by j itself: `user` (who commits), `tree`
1157+-- (how a Repo is printed), `immutable` (which commits may never be
1158+-- rewritten), and `labelled` (what a %name literal means).
1159+
1160+
1161+------------------------------------------------------------------------------
1162+-- You
1163+------------------------------------------------------------------------------
1164+
1165+user : { name : Text, email : Text }
1166+user = { name = "Your Name", email = "you@example.com" }
1167+
1168+
1169+------------------------------------------------------------------------------
1170+-- Shapes
1171+------------------------------------------------------------------------------
1172+
1173+Path = [Text] -- a file path as its components; written ./a/b
1174+Label = Text -- a bookmark name on the remote
1175+
1176+Entry = { path : Path, content : Blob }
1177+Snapshot = [Entry] -- the files of one commit; paths unique;
1178+ -- a deleted, resolved path has no entry
1179+Change = { from : Snapshot, to : Snapshot } -- the files before and after
1180+
1181+Commit = { files : Snapshot
1182+ , message : Text
1183+ , labels : [Label] -- the remote's names for this commit;
1184+ -- read-only: only fetch and push change them
1185+ , id : Id } -- each id is on exactly one commit
1186+
1187+Subtree = { root : Commit, children : [Subtree] }
1188+
1189+-- A Repo is the whole history, seen from one commit: the focused subtree plus
1190+-- the path back to the top. Each Frame is one ancestor with a hole where the
1191+-- focused subtree was; left and right are the siblings on either side. Sibling
1192+-- order is presentation only.
1193+Frame = { parent : Commit, left : [Subtree], right : [Subtree] }
1194+Repo = { root : Commit, children : [Subtree], context : [Frame] }
1195+ -- context = [] at the top of history
1196+
1197+Detached = { subtree : Subtree, rest : Repo }
1198+Rebase = { from : Snapshot, onto : Snapshot } -- the base a subtree was written on, and the new one
1199+Meta = { hash : Text, author : Text, email : Text, time : Int }
1200+ -- what jj records about a commit beyond the model
1201+
1202+Edit = Repo -> Repo -- a command; may crash
1203+Fileset = Path -> Bool -- which paths a command applies to
1204+Revset = Repo -> [Id] -- a set of commits, computed from a repo
1205+
1206+Push = { id : Id, name : Label } -- set the remote's bookmark `name` to `id`
1207+Drop = { delete : Label } -- delete the remote's bookmark
1208+
1209+
1210+------------------------------------------------------------------------------
1211+-- Builtins: functions and control
1212+------------------------------------------------------------------------------
1213+
1214+(.) : (b -> c) -> (a -> b) -> a -> c -- composition, right to left
1215+id : a -> a
1216+const : a -> b -> a
1217+crash : Text -> a -- abort the program with a message
1218+
1219+-- `a or b` is a keyword, not a builtin: the value of a; or, if evaluating a
1220+-- crashed, the value of b. When a is a function and b is one too, the result
1221+-- is pointwise, (f or g) x = f x or g x, so `edit or id` tries an edit and
1222+-- keeps the repository if it fails. Edits compose with `.`: `tree . squash`
1223+-- squashes, then renders.
1224+
1225+------------------------------------------------------------------------------
1226+-- Builtins: comparison, booleans, integers
1227+------------------------------------------------------------------------------
1228+
1229+(==) : a -> a -> Bool -- structural; functions crash
1230+(/=) : a -> a -> Bool
1231+(&&) : Bool -> Bool -> Bool -- short-circuit
1232+(||) : Bool -> Bool -> Bool -- short-circuit
1233+not : Bool -> Bool
1234+(+) : Int -> Int -> Int
1235+(-) : Int -> Int -> Int
1236+(*) : Int -> Int -> Int
1237+(<) : Int -> Int -> Bool
1238+(<=) : Int -> Int -> Bool
1239+(>) : Int -> Int -> Bool
1240+(>=) : Int -> Int -> Bool
1241+show : a -> Text -- any value, in this language's syntax
1242+
1243+------------------------------------------------------------------------------
1244+-- Builtins: lists and text
1245+------------------------------------------------------------------------------
1246+
1247+(::) : a -> [a] -> [a] -- cons
1248+map : (a -> b) -> [a] -> [b]
1249+filter : (a -> Bool) -> [a] -> [a]
1250+length : [a] -> Int
1251+null : [a] -> Bool
1252+head : [a] -> a -- crash on []
1253+tail : [a] -> [a] -- crash on []
1254+last : [a] -> a -- crash on []
1255+nth : Int -> [a] -> a -- 0-based; crash out of range
1256+take : Int -> [a] -> [a]
1257+drop : Int -> [a] -> [a]
1258+member : a -> [a] -> Bool
1259+range : Int -> Int -> [Int] -- range a b = [a (a+1) … (b-1)]
1260+foldl : (b -> a -> b) -> b -> [a] -> b -- foldl f z [x y] = f (f z x) y
1261+
1262+-- ++ and concat work on lists and on text. concat [] crashes: supply a
1263+-- default with or, e.g. concat xs or []
1264+(++) : m -> m -> m
1265+concat : [m] -> m
1266+
1267+startsWith : Text -> Text -> Bool
1268+endsWith : Text -> Text -> Bool
1269+splitOn : Text -> Text -> [Text] -- splitOn "/" "a/b" = ["a" "b"]
1270+
1271+------------------------------------------------------------------------------
1272+-- Builtins: the repository
1273+------------------------------------------------------------------------------
1274+
1275+-- Replay a change onto a snapshot: "onto, with the change from `from` to `to`
1276+-- applied." Total: where the change collides with what is already there, the
1277+-- path's blob is unresolved rather than an error.
1278+replay : Snapshot -> Change -> Snapshot
1279+unresolved : Blob -> Bool
1280+blob : Text -> Blob -- a resolved regular file
1281+text : Blob -> Text -- its content (markers if unresolved)
1282+by : Id -> Repo -> Repo -- focus the commit with this id; crash if absent
1283+meta : Id -> Meta -- git hash, author, email, committer time;
1284+ -- crash for a commit not yet persisted
1285+validate : Repo -> Repo -- the repo unchanged, or the crash persisting
1286+ -- it would produce (shape, labels, immutability,
1287+ -- focus). `tree . validate . rebase trunk` is an
1288+ -- honest dry run.
1289+diff : Blob -> Blob -> Text -- unified diff from the first to the second
1290+difft : Path -> Blob -> Blob -> Text -- difftastic's rendering; needs difft on PATH.
1291+ -- Options via DFT_* environment variables.
1292+treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text }
1293+ -> Repo -> Text -- the history as a tree; see the spec
1294+
1295+-- Every subvalue of a given shape, however deeply nested. A shape is a type
1296+-- name used as a value: extract Commit x, extract Id x, extract Push ys.
1297+extract : Shape -> a -> [b]
1298+
1299+-- Ids: an existing commit is written @kpqx (any unique prefix; checked before
1300+-- the program runs). A bare @ is a new id, distinct each time it is evaluated.
1301+-- A label is written %main and is a Revset: the commit the remote calls "main".
1302+
1303+
1304+------------------------------------------------------------------------------
1305+-- Zipper. A Repo is focused on one commit; these move or detach the focus.
1306+-- Commands never select a commit by position: use ids, labels, or predicates.
1307+------------------------------------------------------------------------------
1308+
1309+-- To the parent. Crashes at the top.
1310+up : Edit
1311+up = \repo ->
1312+ let f = head repo.context
1313+ in { root = f.parent
1314+ , children = f.left ++ [{ root = repo.root, children = repo.children }] ++ f.right
1315+ , context = tail repo.context }
1316+
1317+-- Detach the focused subtree; the focus moves to the parent. Crashes at the top.
1318+detach : Repo -> Detached
1319+detach = \repo ->
1320+ let f = head repo.context
1321+ in { subtree = { root = repo.root, children = repo.children }
1322+ , rest = { root = f.parent, children = f.left ++ f.right, context = tail repo.context } }
1323+
1324+-- To the top of history.
1325+top : Edit
1326+top = \repo -> top (up repo) or repo
1327+
1328+-- Every commit of a subtree (or of the focused subtree of a Repo), in preorder.
1329+-- Takes anything with `root` and `children`, so the signature is left open.
1330+commits : a -> [Commit]
1331+commits = \t -> t.root :: (concat (map commits t.children) or [])
1332+
1333+
1334+------------------------------------------------------------------------------
1335+-- Structure. Editing at the focus.
1336+------------------------------------------------------------------------------
1337+
1338+mapRoot : (Commit -> Commit) -> Edit
1339+mapRoot = \f repo -> repo { root = f repo.root }
1340+
1341+mapChildren : ([Subtree] -> [Subtree]) -> Edit
1342+mapChildren = \f repo -> repo { children = f repo.children }
1343+
1344+-- Attach a subtree as a new child of the focus; the focus does not move.
1345+attach : Subtree -> Edit
1346+attach = \t -> mapChildren (++ [t])
1347+
1348+-- Remove the focused commit; its children join the parent. Focus to the parent.
1349+remove : Edit
1350+remove = \repo -> let x = detach repo
1351+ in x.rest { children = x.rest.children ++ x.subtree.children }
1352+
1353+-- Attach a commit as a new child of the focus, and move to it.
1354+addChild : Commit -> Edit
1355+addChild = \c repo -> by c.id (attach ({ root = c, children = [] }) repo)
1356+
1357+-- A new commit with these files, no message, no labels, and a new id.
1358+newCommit : Snapshot -> Commit
1359+newCommit = \files -> { files = files, message = "", labels = [], id = @ }
1360+
1361+
1362+------------------------------------------------------------------------------
1363+-- Snapshots, changes, and filesets
1364+------------------------------------------------------------------------------
1365+
1366+-- A path is written ./src/lexer.rs, which is ["src" "lexer.rs"]; ./ alone is
1367+-- the root, []. For names the literal cannot spell, splitOn "/" "…" builds one.
1368+
1369+restrict : Fileset -> Snapshot -> Snapshot -- entries matching m
1370+restrict = \m -> filter (\e -> m e.path)
1371+
1372+select : Fileset -> Snapshot -> Snapshot -> Snapshot -- m from a, the rest from b
1373+select = \m a b -> restrict m a ++ restrict (neg m) b
1374+
1375+conflicted : Snapshot -> [Path]
1376+conflicted = \s -> map (.path) (filter (\e -> unresolved e.content) s)
1377+
1378+-- What the focus changes: its parent's files, and its own.
1379+changeOf : Repo -> Change
1380+changeOf = \repo -> { from = (up repo).root.files or [], to = repo.root.files }
1381+
1382+-- The same change, backwards.
1383+invert : Change -> Change
1384+invert = \ch -> { from = ch.to, to = ch.from }
1385+
1386+everything : Fileset
1387+everything = const true
1388+
1389+neg : Fileset -> Fileset
1390+neg = \m -> not . m
1391+
1392+both : Fileset -> Fileset -> Fileset
1393+both = \m n p -> m p && n p
1394+
1395+either : Fileset -> Fileset -> Fileset
1396+either = \m n p -> m p || n p
1397+
1398+under : Path -> Fileset -- inside directory d
1399+under = \d p -> take (length d) p == d
1400+
1401+ext : Text -> Fileset -- file extension e
1402+ext = \e p -> endsWith ("." ++ e) (last p)
1403+
1404+
1405+------------------------------------------------------------------------------
1406+-- Revsets. A revset is a function from a Repo to a list of ids, so it can be
1407+-- relative to the focus (parents, kids) or absolute (all, %main).
1408+------------------------------------------------------------------------------
1409+
1410+here : Revset
1411+here = \repo -> [repo.root.id]
1412+
1413+parents : Revset
1414+parents = \repo -> [(up repo).root.id] or []
1415+
1416+kids : Revset
1417+kids = \repo -> map (\t -> t.root.id) repo.children
1418+
1419+descendants : Revset -- includes the focus
1420+descendants = \repo -> map (.id) (commits repo)
1421+
1422+ancestors : Revset -- includes the focus
1423+ancestors = \repo -> repo.root.id :: ((let p = up repo in ancestors p) or [])
1424+
1425+siblings : Revset
1426+siblings = \repo -> filter (\i -> i /= repo.root.id) (kids (up repo)) or []
1427+
1428+all : Revset
1429+all = \repo -> descendants (top repo)
1430+
1431+stack : Revset -- the line through the focus
1432+stack = union ancestors descendants
1433+
1434+labelled : Label -> Revset -- %main is labelled "main"
1435+labelled = \n repo -> map (.id) (filter (\c -> member n c.labels) (commits (top repo)))
1436+
1437+-- Evaluate f starting from each commit of rs, and collect.
1438+from : Revset -> Revset -> Revset
1439+from = \rs f repo -> concat (map (\i -> f (by i repo)) (rs repo)) or []
1440+
1441+descendantsOf : Revset -> Revset
1442+descendantsOf = \rs -> from rs descendants
1443+
1444+ancestorsOf : Revset -> Revset
1445+ancestorsOf = \rs -> from rs ancestors
1446+
1447+union : Revset -> Revset -> Revset
1448+union = \a b repo -> let xs = a repo in xs ++ filter (\i -> not (member i xs)) (b repo)
1449+
1450+intersect : Revset -> Revset -> Revset
1451+intersect = \a b repo -> let ys = b repo in filter (\i -> member i ys) (a repo)
1452+
1453+minus : Revset -> Revset -> Revset
1454+minus = \a b repo -> let ys = b repo in filter (\i -> not (member i ys)) (a repo)
1455+
1456+matching : (Commit -> Bool) -> Revset -> Revset
1457+matching = \p rs repo -> filter (\i -> p (commitAt i repo)) (rs repo)
1458+
1459+conflicts : Revset
1460+conflicts = matching (\c -> not (null (conflicted c.files))) all
1461+
1462+-- The first of several revsets that is non-empty.
1463+firstOf : [Revset] -> Revset
1464+firstOf = \rss repo -> foldl (\acc rs -> if null acc then rs repo else acc) [] rss
1465+
1466+-- The main line: whichever of these names the remote has, first one wins.
1467+-- Empty in a repository with no remote, in which case nothing is immutable.
1468+trunk : Revset
1469+trunk = firstOf [%main %master %trunk]
1470+
1471+-- Commits that may never be rewritten, abandoned, or focused. j evaluates this
1472+-- before persisting anything and refuses to change what it names. Merge
1473+-- commits and their ancestors are immutable regardless. The focus must always
1474+-- be mutable, so it is `new . goto trunk`, never `goto trunk` alone.
1475+immutable : Revset
1476+immutable = ancestorsOf trunk
1477+
1478+
1479+------------------------------------------------------------------------------
1480+-- Focusing. `goto` is the only place a revset must have exactly one member.
1481+------------------------------------------------------------------------------
1482+
1483+goto : Revset -> Edit
1484+goto = \rs repo ->
1485+ let is = rs repo
1486+ in if length is == 1
1487+ then by (head is) repo
1488+ else crash ("expected one revision, got " ++ show (length is))
1489+
1490+commitAt : Id -> Repo -> Commit
1491+commitAt = \i repo -> (by i repo).root
1492+
1493+prev : Edit -- the parent
1494+prev = goto parents
1495+
1496+next : Edit -- the only child
1497+next = goto kids
1498+
1499+tip : Edit -- end of the line of only-children
1500+tip = \repo -> tip (next repo) or repo
1501+
1502+into : Id -> Edit -- a child, by id
1503+into = \i repo -> if member i (kids repo) then by i repo
1504+ else crash "into: not a child of the focus"
1505+
1506+child : (Commit -> Bool) -> Edit -- the one child satisfying p
1507+child = \p -> goto (matching p kids)
1508+
1509+-- Run an edit somewhere else, then come back (by id, so the edit may move things).
1510+at : Revset -> Edit -> Edit
1511+at = \rs e repo -> let me = repo.root.id in by me (e (goto rs repo))
1512+
1513+-- Run an edit at every commit of a revset, returning to the focus.
1514+forEach : Revset -> Edit -> Edit
1515+forEach = \rs e repo -> foldl (\acc i -> at (const [i]) e acc) repo (rs repo)
1516+
1517+eachChild : Edit -> Edit
1518+eachChild = forEach kids
1519+
1520+guard : (Repo -> Bool) -> Edit -- crash unless p holds
1521+guard = \p repo -> if p repo then repo else crash "guard"
1522+
1523+clean : Repo -> Bool
1524+clean = \repo -> null (conflicted repo.root.files)
1525+
1526+
1527+------------------------------------------------------------------------------
1528+-- Rebasing
1529+------------------------------------------------------------------------------
1530+
1531+-- Rewrite a subtree as if it had been written on `onto` instead of `from`.
1532+-- Each commit's change is replayed onto the new base; its children are then
1533+-- replayed from its old files onto its new ones.
1534+replayTree : Rebase -> Subtree -> Subtree
1535+replayTree = \rb t ->
1536+ let c = t.root
1537+ c' = c { files = replay rb.onto ({ from = rb.from, to = c.files }) }
1538+ in { root = c', children = map (replayTree ({ from = c.files, onto = c'.files })) t.children }
1539+
1540+-- Replay the focused subtree onto the commit newP, in place. The subtree stays
1541+-- attached where it is; only its files change. Move it afterwards with detach
1542+-- and attach. Crashes at the top.
1543+replayOnto : Commit -> Edit
1544+replayOnto = \newP repo ->
1545+ let t = replayTree ({ from = (up repo).root.files, onto = newP.files })
1546+ ({ root = repo.root, children = repo.children })
1547+ in repo { root = t.root, children = t.children }
1548+
1549+-- Replace the focused commit with f applied to it, replaying its children.
1550+rewrite : (Commit -> Commit) -> Edit
1551+rewrite = \f repo ->
1552+ let c = repo.root
1553+ c' = f c
1554+ in (eachChild (replayOnto c') repo) { root = c' }
1555+
1556+
1557+------------------------------------------------------------------------------
1558+-- Commands
1559+------------------------------------------------------------------------------
1560+
1561+-- Set the message of the focused commit.
1562+describe : Text -> Edit
1563+describe = \m -> mapRoot (\c -> c { message = m })
1564+
1565+-- Start an empty child of the focus and move to it.
1566+new : Edit
1567+new = \repo -> addChild (newCommit repo.root.files) repo
1568+
1569+-- Remove the focused commit. Its children are rebased onto its parent, and a
1570+-- new empty commit on that parent becomes the focus (the parent may be
1571+-- immutable, and the focus never is). Refused for a commit the remote has a
1572+-- name for: push something else under that name first, or forget the name.
1573+abandon : Edit
1574+abandon = \repo ->
1575+ let c = repo.root
1576+ p = (up repo).root
1577+ in if not (null c.labels)
1578+ then crash ("abandon: the remote names this commit " ++ show c.labels)
1579+ else new (remove (eachChild (replayOnto p) repo))
1580+
1581+-- Push the files matching m from the focus down into its parent.
1582+contract : Fileset -> Edit
1583+contract = \m repo -> let a = repo.root
1584+ in at parents (rewrite (\q -> q { files = select m a.files q.files })) repo
1585+
1586+-- Fold the focus entirely into its parent; a new empty commit becomes the focus.
1587+squash : Edit
1588+squash = abandon . contract everything
1589+
1590+-- Push the files matching m from the focus up into a new child, which becomes
1591+-- the focus and inherits the old focus's children. Labels stay with the
1592+-- original commit, since they are the remote's.
1593+split : Fileset -> Edit
1594+split = \m repo ->
1595+ let a = repo.root
1596+ p = (up repo).root
1597+ a' = a { files = select m p.files a.files }
1598+ x = newCommit a.files
1599+ in by x.id (repo { root = a', children = [{ root = x, children = repo.children }] })
1600+
1601+-- Move the focused subtree onto another commit. Returns to the moved commit.
1602+rebase : Revset -> Edit
1603+rebase = \rs repo ->
1604+ let d = (goto rs repo).root
1605+ in if member d.id (descendants repo)
1606+ then crash "rebase: the destination is inside the subtree being moved"
1607+ else let me = repo.root.id
1608+ x = detach (replayOnto d repo)
1609+ in by me (attach x.subtree (by d.id x.rest))
1610+
1611+-- Apply the focused commit's change onto another commit, as a new child there.
1612+pick : Revset -> Edit
1613+pick = \rs repo ->
1614+ let ch = changeOf repo
1615+ t = (goto rs repo).root
1616+ in at rs (addChild (newCommit (replay t.files ch))) repo
1617+
1618+-- A new child of the focus that undoes the change of the given commit.
1619+backout : Revset -> Edit
1620+backout = \rs repo ->
1621+ let ch = changeOf (goto rs repo)
1622+ in addChild (newCommit (replay repo.root.files (invert ch))) repo
1623+
1624+
1625+------------------------------------------------------------------------------
1626+-- Pushing. `j push EXPR` evaluates EXPR to a list of Push and Drop records.
1627+------------------------------------------------------------------------------
1628+
1629+-- Label every commit of a revset. `j 'push (label "feature" here)'` labels @.
1630+label : Label -> Revset -> Repo -> [Push]
1631+label = \n rs repo -> map (\i -> { id = i, name = n }) (rs repo)
1632+
1633+-- Remove a label on the remote. `j 'push (unlabel "feature")'` after the merge.
1634+unlabel : Label -> [Drop]
1635+unlabel = \n -> [{ delete = n }]
1636+
1637+-- Move a label: `j 'push (rename "old" "new")'`.
1638+rename : Label -> Label -> Repo -> [b]
1639+rename = \old new repo -> label new (labelled old) repo ++ unlabel old
1640+
1641+-- Every label the remote already has, at its current local commit.
1642+-- `j push relabel` after rewriting a stack moves the remote's bookmarks along.
1643+relabel : Repo -> [Push]
1644+relabel = \repo -> concat (map (\c -> map (\n -> { id = c.id, name = n }) c.labels)
1645+ (extract Commit repo)) or []
1646+
1647+
1648+------------------------------------------------------------------------------
1649+-- Inspection. These return printable values rather than a Repo.
1650+------------------------------------------------------------------------------
1651+
1652+focus : Repo -> Commit -- the focused commit
1653+focus = \repo -> repo.root
1654+
1655+files : Repo -> Snapshot -- its files
1656+files = \repo -> repo.root.files
1657+
1658+-- The entry at path p in snapshot s, as a list: [] if absent.
1659+entryAt : Path -> Snapshot -> [Entry]
1660+entryAt = \p s -> filter (\e -> e.path == p) s
1661+
1662+-- The content at path p, or an empty file if absent.
1663+contentAt : Path -> Snapshot -> Blob
1664+contentAt = \p s -> (head (entryAt p s)).content or blob ""
1665+
1666+-- The paths a change touches.
1667+touched : Change -> [Path]
1668+touched = \ch ->
1669+ let paths = map (.path) ch.to ++ filter (\p -> not (member p (map (.path) ch.to))) (map (.path) ch.from)
1670+ in filter (\p -> entryAt p ch.to /= entryAt p ch.from) paths
1671+
1672+-- Paths whose content differs between the focus and its parent.
1673+changed : Repo -> [Path]
1674+changed = \repo -> touched (changeOf repo)
1675+
1676+-- What the focus changes, rendered by difftastic, one record per changed path.
1677+-- Replace difft with (\p -> diff) to use the built-in unified diff instead.
1678+diffs : Repo -> [{ path : Path, diff : Text }]
1679+diffs = \repo ->
1680+ let ch = changeOf repo
1681+ in map (\p -> { path = p, diff = difft p (contentAt p ch.from) (contentAt p ch.to) }) (touched ch)
1682+
1683+-- The same, as one page of text. A Text result prints raw, so `j review` is
1684+-- readable in the terminal.
1685+review : Repo -> Text
1686+review = \repo -> concat (map (\d -> d.diff) (diffs repo)) or "no changes\n"
1687+
1688+-- A summary of the focus.
1689+status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path] }
1690+status = \repo ->
1691+ { id = repo.root.id
1692+ , message = repo.root.message
1693+ , labels = repo.root.labels
1694+ , changed = changed repo
1695+ , conflicts = conflicted repo.root.files }
1696+
1697+-- A summary of every commit, top first, in preorder.
1698+log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]
1699+log = \repo ->
1700+ map (\c -> { id = c.id
1701+ , message = c.message
1702+ , labels = c.labels
1703+ , focus = c.id == repo.root.id })
1704+ (commits (top repo))
1705+
1706+-- The history as a tree. Edit these records to change what `j tree` shows.
1707+-- (The parentheses are required: `f { … }` would be a record update of f.)
1708+tree : Repo -> Text
1709+tree = treeWith ({ detail = 1, margin = false, elide = true, icons = false, color = "auto" })
1710+
1711+treeFull : Repo -> Text
1712+treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
1713+```
1714+
1715+---
1716+
1717+## 10. Testing
1718+
1719+The interpreter must be testable without a jj repository. Provide an in-memory
1720+implementation of the domain builtins (`replay`, `unresolved`, `blob`,
1721+`text`, `by`, `extract`, `meta`, `difft` as a stub, and the minting
1722+of `@`) in which a blob is text, a
1723+conflict is a record of the three sides, and `replay` resolves a path exactly
1724+when at most one side changed it or both made the same change. Property-test
1725+the laws of §8 over randomly generated `Repo` values (bounded depth and width,
1726+random labels, random file edits) using the reference `config.j` unmodified.
1727+
1728+The builtin `by` must agree, on every generated repo, with this in-language
1729+reference definition, which uses positional moves the public base does not
1730+expose:
1731+
1732+```haskell
1733+down = \i repo ->
1734+ let k = nth i repo.children
1735+ in { root = k.root, children = k.children
1736+ , context = { parent = repo.root, left = take i repo.children, right = drop (i + 1) repo.children }
1737+ :: repo.context }
1738+locs = \l -> l :: (concat (map (\i -> locs (down i l)) (range 0 (length l.children))) or [])
1739+by = \i repo -> head (filter (\l -> l.root.id == i) (locs (top repo))) or crash "by: no such commit"
1740+```
1741+
1742+Golden tests: parse the reference `config.j`; `show` round-trips through the
1743+parser for generated values of every kind except closures; every example in
1744+§11 produces the stated result.
1745+
1746+---
1747+
1748+## 11. The development flow, end to end
1749+
1750+Each step of ordinary development, and what covers it. Anything not listed
1751+here is out of scope (§12). Nothing here uses the `jj` binary.
1752+
1753+**Getting a repository**
1754+
1755+```
1756+$EDITOR ~/.config/j/config.j -- set `user` once
1757+j init -- new repository here, or adopt an existing .git
1758+j clone git@host:org/repo.git -- or an existing one
1759+j remote git@host:org/repo.git -- attach a remote to an init'd repo
1760+```
1761+
1762+**Looking**
1763+
1764+```
1765+j tree -- the history as a tree, focus marked
1766+j treeFull -- the same with sizes, ages, authors, changed paths
1767+j status -- id, message, labels, changed paths, conflicts of @
1768+j log -- every commit as a record
1769+j review -- difftastic rendering of @ against its parent
1770+j review . by @kpq -- the same for another commit, without moving
1771+j diffs -- the same, one record per path
1772+j here -- @'s id
1773+j kids -- @'s children, as a commit table
1774+j conflicts -- every conflicted commit
1775+j 'map (.path) . files' -- paths tracked in @
1776+j 'text . contentAt ./README.md . files'
1777+j focus -- the focused commit
1778+j 'meta . (.id) . focus' -- its git hash, author, time
1779+```
1780+
1781+**Working**
1782+
1783+```
1784+j 'describe "fix login"'
1785+j new -- start the next commit
1786+j prev -- to the parent
1787+j next -- to the only child
1788+j new . goto trunk -- start on the main line; the focus must be a mutable commit
1789+j by @kpq -- any unique id prefix
1790+j into @kpq -- a child, by id
1791+j 'child (\c -> startsWith "fix" c.message)'
1792+j 'split (ext "rs")' -- *.rs changes into a new child
1793+j 'contract (under ./docs)' -- docs/ changes down into the parent
1794+j squash
1795+j abandon
1796+j rebase trunk
1797+j guard clean . rebase trunk or id -- only if it comes out clean
1798+j pick @kpq
1799+j 'forEach (descendantsOf here) (describe "wip")'
1800+j id -- record the working directory, change nothing else
1801+j tree . squash -- dry run: show the result, persist nothing
1802+j tree . validate . squash -- the same, refusing what persistence would refuse
1803+j squash && j tree -- do, then look
1804+```
1805+
1806+**Sharing**
1807+
1808+```
1809+j 'push (label "feature" here)' -- name @ on the remote
1810+j push relabel -- move the remote's existing names to the rewritten stack
1811+j 'push (unlabel "feature")' -- delete a name on the remote
1812+j 'push (rename "old" "new")' -- move a name
1813+j fetch -- refresh the remote's names and commits
1814+j rebase trunk -- bring the stack up to date
1815+```
1816+
1817+**Conflicts**
1818+
1819+A rebase or fetch that conflicts leaves unresolved files in the affected
1820+commits. `j conflicts` lists them; `j by @…` moves there, which checks the
1821+files out with conflict markers; editing the files and running any persisting
1822+expression (`j id` at minimum) records the resolution.
1823+
1824+**Mistakes**
1825+
1826+```
1827+j ops -- what happened, newest first
1828+j undo -- reverts one j expression
1829+j redo
1830+```
1831+
1832+**Own commands**
1833+
1834+Any definition added to `config.j` whose value is an `Edit` is a command:
1835+
1836+```haskell
1837+pr = \n -> describe n . squash
1838+```
1839+
1840+```
1841+j 'pr "fix login"' && j 'push (label "fix-login" here)'
1842+```
1843+
1844+---
1845+
1846+## 12. Out of scope for this version
1847+
1848+Second parents of merge commits (merges are placed under their first parent
1849+and are read-only); hunk-level `split`; conflict resolution inside the
1850+language (blobs are opaque); workspaces other than the default; remotes other
1851+than `origin`; tags; submodules and LFS; ignore rules beyond `.gitignore`;
1852+changing file modes or symlinks from the language; repairing a repository in
1853+which one change id has two visible commits (refused; §7.2); a `--help` flag
1854+or any flag at all; static typing. None of these are precluded by the design.
config.jnew file+576
@@ -0,0 +1,576 @@
1+-- config.j — everything j knows.
2+--
3+-- j reads this file on every run. An expression can use a name only if this
4+-- file declares it (a builtin, implemented by j) or defines it (written here,
5+-- in the language). Nothing else exists.
6+--
7+-- Conventions in this file:
8+-- Name = … a shape: a record layout, matched by its field names
9+-- name : Type a signature. Alone, it declares a builtin implemented
10+-- by j. Before a definition, it documents that definition.
11+-- Either way j checks arguments and results against it at
12+-- each call, one level deep: kind, list-ness, field names.
13+-- name = expr a definition. Functions are lambdas: name = \x -> …
14+--
15+-- Edits. Most definitions are Edits: functions from a Repo to a Repo. On the
16+-- command line a function result is applied to the current repository; a Repo
17+-- result is persisted. Anything else is printed. `e2 . e1` runs e1 then e2;
18+-- `e or id` runs e and leaves things alone if it crashes.
19+--
20+-- Four definitions here are read by j itself: `user` (who commits), `tree`
21+-- (how a Repo is printed), `immutable` (which commits may never be
22+-- rewritten), and `labelled` (what a %name literal means).
23+
24+
25+------------------------------------------------------------------------------
26+-- You
27+------------------------------------------------------------------------------
28+
29+user : { name : Text, email : Text }
30+user = { name = "Your Name", email = "you@example.com" }
31+
32+
33+------------------------------------------------------------------------------
34+-- Shapes
35+------------------------------------------------------------------------------
36+
37+Path = [Text] -- a file path as its components; written ./a/b
38+Label = Text -- a bookmark name on the remote
39+
40+Entry = { path : Path, content : Blob }
41+Snapshot = [Entry] -- the files of one commit; paths unique;
42+ -- a deleted, resolved path has no entry
43+Change = { from : Snapshot, to : Snapshot } -- the files before and after
44+
45+Commit = { files : Snapshot
46+ , message : Text
47+ , labels : [Label] -- the remote's names for this commit;
48+ -- read-only: only fetch and push change them
49+ , id : Id } -- each id is on exactly one commit
50+
51+Subtree = { root : Commit, children : [Subtree] }
52+
53+-- A Repo is the whole history, seen from one commit: the focused subtree plus
54+-- the path back to the top. Each Frame is one ancestor with a hole where the
55+-- focused subtree was; left and right are the siblings on either side. Sibling
56+-- order is presentation only.
57+Frame = { parent : Commit, left : [Subtree], right : [Subtree] }
58+Repo = { root : Commit, children : [Subtree], context : [Frame] }
59+ -- context = [] at the top of history
60+
61+Detached = { subtree : Subtree, rest : Repo }
62+Rebase = { from : Snapshot, onto : Snapshot } -- the base a subtree was written on, and the new one
63+Meta = { hash : Text, author : Text, email : Text, time : Int }
64+ -- what jj records about a commit beyond the model
65+
66+Edit = Repo -> Repo -- a command; may crash
67+Fileset = Path -> Bool -- which paths a command applies to
68+Revset = Repo -> [Id] -- a set of commits, computed from a repo
69+
70+Push = { id : Id, name : Label } -- set the remote's bookmark `name` to `id`
71+Drop = { delete : Label } -- delete the remote's bookmark
72+
73+
74+------------------------------------------------------------------------------
75+-- Builtins: functions and control
76+------------------------------------------------------------------------------
77+
78+(.) : (b -> c) -> (a -> b) -> a -> c -- composition, right to left
79+id : a -> a
80+const : a -> b -> a
81+crash : Text -> a -- abort the program with a message
82+
83+-- `a or b` is a keyword, not a builtin: the value of a; or, if evaluating a
84+-- crashed, the value of b. When a is a function and b is one too, the result
85+-- is pointwise, (f or g) x = f x or g x, so `edit or id` tries an edit and
86+-- keeps the repository if it fails. Edits compose with `.`: `tree . squash`
87+-- squashes, then renders.
88+
89+------------------------------------------------------------------------------
90+-- Builtins: comparison, booleans, integers
91+------------------------------------------------------------------------------
92+
93+(==) : a -> a -> Bool -- structural; functions crash
94+(/=) : a -> a -> Bool
95+(&&) : Bool -> Bool -> Bool -- short-circuit
96+(||) : Bool -> Bool -> Bool -- short-circuit
97+not : Bool -> Bool
98+(+) : Int -> Int -> Int
99+(-) : Int -> Int -> Int
100+(*) : Int -> Int -> Int
101+(<) : Int -> Int -> Bool
102+(<=) : Int -> Int -> Bool
103+(>) : Int -> Int -> Bool
104+(>=) : Int -> Int -> Bool
105+show : a -> Text -- any value, in this language's syntax
106+
107+------------------------------------------------------------------------------
108+-- Builtins: lists and text
109+------------------------------------------------------------------------------
110+
111+(::) : a -> [a] -> [a] -- cons
112+map : (a -> b) -> [a] -> [b]
113+filter : (a -> Bool) -> [a] -> [a]
114+length : [a] -> Int
115+null : [a] -> Bool
116+head : [a] -> a -- crash on []
117+tail : [a] -> [a] -- crash on []
118+last : [a] -> a -- crash on []
119+nth : Int -> [a] -> a -- 0-based; crash out of range
120+take : Int -> [a] -> [a]
121+drop : Int -> [a] -> [a]
122+member : a -> [a] -> Bool
123+range : Int -> Int -> [Int] -- range a b = [a (a+1) … (b-1)]
124+foldl : (b -> a -> b) -> b -> [a] -> b -- foldl f z [x y] = f (f z x) y
125+
126+-- ++ and concat work on lists and on text. concat [] crashes: supply a
127+-- default with or, e.g. concat xs or []
128+(++) : m -> m -> m
129+concat : [m] -> m
130+
131+startsWith : Text -> Text -> Bool
132+endsWith : Text -> Text -> Bool
133+splitOn : Text -> Text -> [Text] -- splitOn "/" "a/b" = ["a" "b"]
134+
135+------------------------------------------------------------------------------
136+-- Builtins: the repository
137+------------------------------------------------------------------------------
138+
139+-- Replay a change onto a snapshot: "onto, with the change from `from` to `to`
140+-- applied." Total: where the change collides with what is already there, the
141+-- path's blob is unresolved rather than an error.
142+replay : Snapshot -> Change -> Snapshot
143+unresolved : Blob -> Bool
144+blob : Text -> Blob -- a resolved regular file
145+text : Blob -> Text -- its content (markers if unresolved)
146+by : Id -> Repo -> Repo -- focus the commit with this id; crash if absent
147+meta : Id -> Meta -- git hash, author, email, committer time;
148+ -- crash for a commit not yet persisted
149+validate : Repo -> Repo -- the repo unchanged, or the crash persisting
150+ -- it would produce (shape, labels, immutability,
151+ -- focus). `tree . validate . rebase trunk` is an
152+ -- honest dry run.
153+diff : Blob -> Blob -> Text -- unified diff from the first to the second
154+difft : Path -> Blob -> Blob -> Text -- difftastic's rendering; needs difft on PATH.
155+ -- Options via DFT_* environment variables.
156+treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text }
157+ -> Repo -> Text -- the history as a tree; see the spec
158+
159+-- Every subvalue of a given shape, however deeply nested. A shape is a type
160+-- name used as a value: extract Commit x, extract Id x, extract Push ys.
161+extract : Shape -> a -> [b]
162+
163+-- Ids: an existing commit is written @kpqx (any unique prefix; checked before
164+-- the program runs). A bare @ is a new id, distinct each time it is evaluated.
165+-- A label is written %main and is a Revset: the commit the remote calls "main".
166+
167+
168+------------------------------------------------------------------------------
169+-- Zipper. A Repo is focused on one commit; these move or detach the focus.
170+-- Commands never select a commit by position: use ids, labels, or predicates.
171+------------------------------------------------------------------------------
172+
173+-- To the parent. Crashes at the top.
174+up : Edit
175+up = \repo ->
176+ let f = head repo.context
177+ in { root = f.parent
178+ , children = f.left ++ [{ root = repo.root, children = repo.children }] ++ f.right
179+ , context = tail repo.context }
180+
181+-- Detach the focused subtree; the focus moves to the parent. Crashes at the top.
182+detach : Repo -> Detached
183+detach = \repo ->
184+ let f = head repo.context
185+ in { subtree = { root = repo.root, children = repo.children }
186+ , rest = { root = f.parent, children = f.left ++ f.right, context = tail repo.context } }
187+
188+-- To the top of history.
189+top : Edit
190+top = \repo -> top (up repo) or repo
191+
192+-- Every commit of a subtree (or of the focused subtree of a Repo), in preorder.
193+-- Takes anything with `root` and `children`, so the signature is left open.
194+commits : a -> [Commit]
195+commits = \t -> t.root :: (concat (map commits t.children) or [])
196+
197+
198+------------------------------------------------------------------------------
199+-- Structure. Editing at the focus.
200+------------------------------------------------------------------------------
201+
202+mapRoot : (Commit -> Commit) -> Edit
203+mapRoot = \f repo -> repo { root = f repo.root }
204+
205+mapChildren : ([Subtree] -> [Subtree]) -> Edit
206+mapChildren = \f repo -> repo { children = f repo.children }
207+
208+-- Attach a subtree as a new child of the focus; the focus does not move.
209+attach : Subtree -> Edit
210+attach = \t -> mapChildren (++ [t])
211+
212+-- Remove the focused commit; its children join the parent. Focus to the parent.
213+remove : Edit
214+remove = \repo -> let x = detach repo
215+ in x.rest { children = x.rest.children ++ x.subtree.children }
216+
217+-- Attach a commit as a new child of the focus, and move to it.
218+addChild : Commit -> Edit
219+addChild = \c repo -> by c.id (attach ({ root = c, children = [] }) repo)
220+
221+-- A new commit with these files, no message, no labels, and a new id.
222+newCommit : Snapshot -> Commit
223+newCommit = \files -> { files = files, message = "", labels = [], id = @ }
224+
225+
226+------------------------------------------------------------------------------
227+-- Snapshots, changes, and filesets
228+------------------------------------------------------------------------------
229+
230+-- A path is written ./src/lexer.rs, which is ["src" "lexer.rs"]; ./ alone is
231+-- the root, []. For names the literal cannot spell, splitOn "/" "…" builds one.
232+
233+restrict : Fileset -> Snapshot -> Snapshot -- entries matching m
234+restrict = \m -> filter (\e -> m e.path)
235+
236+select : Fileset -> Snapshot -> Snapshot -> Snapshot -- m from a, the rest from b
237+select = \m a b -> restrict m a ++ restrict (neg m) b
238+
239+conflicted : Snapshot -> [Path]
240+conflicted = \s -> map (.path) (filter (\e -> unresolved e.content) s)
241+
242+-- What the focus changes: its parent's files, and its own.
243+changeOf : Repo -> Change
244+changeOf = \repo -> { from = (up repo).root.files or [], to = repo.root.files }
245+
246+-- The same change, backwards.
247+invert : Change -> Change
248+invert = \ch -> { from = ch.to, to = ch.from }
249+
250+everything : Fileset
251+everything = const true
252+
253+neg : Fileset -> Fileset
254+neg = \m -> not . m
255+
256+both : Fileset -> Fileset -> Fileset
257+both = \m n p -> m p && n p
258+
259+either : Fileset -> Fileset -> Fileset
260+either = \m n p -> m p || n p
261+
262+under : Path -> Fileset -- inside directory d
263+under = \d p -> take (length d) p == d
264+
265+ext : Text -> Fileset -- file extension e
266+ext = \e p -> endsWith ("." ++ e) (last p)
267+
268+
269+------------------------------------------------------------------------------
270+-- Revsets. A revset is a function from a Repo to a list of ids, so it can be
271+-- relative to the focus (parents, kids) or absolute (all, %main).
272+------------------------------------------------------------------------------
273+
274+here : Revset
275+here = \repo -> [repo.root.id]
276+
277+parents : Revset
278+parents = \repo -> [(up repo).root.id] or []
279+
280+kids : Revset
281+kids = \repo -> map (\t -> t.root.id) repo.children
282+
283+descendants : Revset -- includes the focus
284+descendants = \repo -> map (.id) (commits repo)
285+
286+ancestors : Revset -- includes the focus
287+ancestors = \repo -> repo.root.id :: ((let p = up repo in ancestors p) or [])
288+
289+siblings : Revset
290+siblings = \repo -> filter (\i -> i /= repo.root.id) (kids (up repo)) or []
291+
292+all : Revset
293+all = \repo -> descendants (top repo)
294+
295+stack : Revset -- the line through the focus
296+stack = union ancestors descendants
297+
298+labelled : Label -> Revset -- %main is labelled "main"
299+labelled = \n repo -> map (.id) (filter (\c -> member n c.labels) (commits (top repo)))
300+
301+-- Evaluate f starting from each commit of rs, and collect.
302+from : Revset -> Revset -> Revset
303+from = \rs f repo -> concat (map (\i -> f (by i repo)) (rs repo)) or []
304+
305+descendantsOf : Revset -> Revset
306+descendantsOf = \rs -> from rs descendants
307+
308+ancestorsOf : Revset -> Revset
309+ancestorsOf = \rs -> from rs ancestors
310+
311+union : Revset -> Revset -> Revset
312+union = \a b repo -> let xs = a repo in xs ++ filter (\i -> not (member i xs)) (b repo)
313+
314+intersect : Revset -> Revset -> Revset
315+intersect = \a b repo -> let ys = b repo in filter (\i -> member i ys) (a repo)
316+
317+minus : Revset -> Revset -> Revset
318+minus = \a b repo -> let ys = b repo in filter (\i -> not (member i ys)) (a repo)
319+
320+matching : (Commit -> Bool) -> Revset -> Revset
321+matching = \p rs repo -> filter (\i -> p (commitAt i repo)) (rs repo)
322+
323+conflicts : Revset
324+conflicts = matching (\c -> not (null (conflicted c.files))) all
325+
326+-- The first of several revsets that is non-empty.
327+firstOf : [Revset] -> Revset
328+firstOf = \rss repo -> foldl (\acc rs -> if null acc then rs repo else acc) [] rss
329+
330+-- The main line: whichever of these names the remote has, first one wins.
331+-- Empty in a repository with no remote, in which case nothing is immutable.
332+trunk : Revset
333+trunk = firstOf [%main %master %trunk]
334+
335+-- Commits that may never be rewritten, abandoned, or focused. j evaluates this
336+-- before persisting anything and refuses to change what it names. Merge
337+-- commits and their ancestors are immutable regardless. The focus must always
338+-- be mutable, so it is `new . goto trunk`, never `goto trunk` alone.
339+immutable : Revset
340+immutable = ancestorsOf trunk
341+
342+
343+------------------------------------------------------------------------------
344+-- Focusing. `goto` is the only place a revset must have exactly one member.
345+------------------------------------------------------------------------------
346+
347+goto : Revset -> Edit
348+goto = \rs repo ->
349+ let is = rs repo
350+ in if length is == 1
351+ then by (head is) repo
352+ else crash ("expected one revision, got " ++ show (length is))
353+
354+commitAt : Id -> Repo -> Commit
355+commitAt = \i repo -> (by i repo).root
356+
357+prev : Edit -- the parent
358+prev = goto parents
359+
360+next : Edit -- the only child
361+next = goto kids
362+
363+tip : Edit -- end of the line of only-children
364+tip = \repo -> tip (next repo) or repo
365+
366+into : Id -> Edit -- a child, by id
367+into = \i repo -> if member i (kids repo) then by i repo
368+ else crash "into: not a child of the focus"
369+
370+child : (Commit -> Bool) -> Edit -- the one child satisfying p
371+child = \p -> goto (matching p kids)
372+
373+-- Run an edit somewhere else, then come back (by id, so the edit may move things).
374+at : Revset -> Edit -> Edit
375+at = \rs e repo -> let me = repo.root.id in by me (e (goto rs repo))
376+
377+-- Run an edit at every commit of a revset, returning to the focus.
378+forEach : Revset -> Edit -> Edit
379+forEach = \rs e repo -> foldl (\acc i -> at (const [i]) e acc) repo (rs repo)
380+
381+eachChild : Edit -> Edit
382+eachChild = forEach kids
383+
384+guard : (Repo -> Bool) -> Edit -- crash unless p holds
385+guard = \p repo -> if p repo then repo else crash "guard"
386+
387+clean : Repo -> Bool
388+clean = \repo -> null (conflicted repo.root.files)
389+
390+
391+------------------------------------------------------------------------------
392+-- Rebasing
393+------------------------------------------------------------------------------
394+
395+-- Rewrite a subtree as if it had been written on `onto` instead of `from`.
396+-- Each commit's change is replayed onto the new base; its children are then
397+-- replayed from its old files onto its new ones.
398+replayTree : Rebase -> Subtree -> Subtree
399+replayTree = \rb t ->
400+ let c = t.root
401+ c' = c { files = replay rb.onto ({ from = rb.from, to = c.files }) }
402+ in { root = c', children = map (replayTree ({ from = c.files, onto = c'.files })) t.children }
403+
404+-- Replay the focused subtree onto the commit newP, in place. The subtree stays
405+-- attached where it is; only its files change. Move it afterwards with detach
406+-- and attach. Crashes at the top.
407+replayOnto : Commit -> Edit
408+replayOnto = \newP repo ->
409+ let t = replayTree ({ from = (up repo).root.files, onto = newP.files })
410+ ({ root = repo.root, children = repo.children })
411+ in repo { root = t.root, children = t.children }
412+
413+-- Replace the focused commit with f applied to it, replaying its children.
414+rewrite : (Commit -> Commit) -> Edit
415+rewrite = \f repo ->
416+ let c = repo.root
417+ c' = f c
418+ in (eachChild (replayOnto c') repo) { root = c' }
419+
420+
421+------------------------------------------------------------------------------
422+-- Commands
423+------------------------------------------------------------------------------
424+
425+-- Set the message of the focused commit.
426+describe : Text -> Edit
427+describe = \m -> mapRoot (\c -> c { message = m })
428+
429+-- Start an empty child of the focus and move to it.
430+new : Edit
431+new = \repo -> addChild (newCommit repo.root.files) repo
432+
433+-- Remove the focused commit. Its children are rebased onto its parent, and a
434+-- new empty commit on that parent becomes the focus (the parent may be
435+-- immutable, and the focus never is). Refused for a commit the remote has a
436+-- name for: push something else under that name first, or forget the name.
437+abandon : Edit
438+abandon = \repo ->
439+ let c = repo.root
440+ p = (up repo).root
441+ in if not (null c.labels)
442+ then crash ("abandon: the remote names this commit " ++ show c.labels)
443+ else new (remove (eachChild (replayOnto p) repo))
444+
445+-- Push the files matching m from the focus down into its parent.
446+contract : Fileset -> Edit
447+contract = \m repo -> let a = repo.root
448+ in at parents (rewrite (\q -> q { files = select m a.files q.files })) repo
449+
450+-- Fold the focus entirely into its parent; a new empty commit becomes the focus.
451+squash : Edit
452+squash = abandon . contract everything
453+
454+-- Push the files matching m from the focus up into a new child, which becomes
455+-- the focus and inherits the old focus's children. Labels stay with the
456+-- original commit, since they are the remote's.
457+split : Fileset -> Edit
458+split = \m repo ->
459+ let a = repo.root
460+ p = (up repo).root
461+ a' = a { files = select m p.files a.files }
462+ x = newCommit a.files
463+ in by x.id (repo { root = a', children = [{ root = x, children = repo.children }] })
464+
465+-- Move the focused subtree onto another commit. Returns to the moved commit.
466+rebase : Revset -> Edit
467+rebase = \rs repo ->
468+ let d = (goto rs repo).root
469+ in if member d.id (descendants repo)
470+ then crash "rebase: the destination is inside the subtree being moved"
471+ else let me = repo.root.id
472+ x = detach (replayOnto d repo)
473+ in by me (attach x.subtree (by d.id x.rest))
474+
475+-- Apply the focused commit's change onto another commit, as a new child there.
476+pick : Revset -> Edit
477+pick = \rs repo ->
478+ let ch = changeOf repo
479+ t = (goto rs repo).root
480+ in at rs (addChild (newCommit (replay t.files ch))) repo
481+
482+-- A new child of the focus that undoes the change of the given commit.
483+backout : Revset -> Edit
484+backout = \rs repo ->
485+ let ch = changeOf (goto rs repo)
486+ in addChild (newCommit (replay repo.root.files (invert ch))) repo
487+
488+
489+------------------------------------------------------------------------------
490+-- Pushing. `j push EXPR` evaluates EXPR to a list of Push and Drop records.
491+------------------------------------------------------------------------------
492+
493+-- Label every commit of a revset. `j 'push (label "feature" here)'` labels @.
494+label : Label -> Revset -> Repo -> [Push]
495+label = \n rs repo -> map (\i -> { id = i, name = n }) (rs repo)
496+
497+-- Remove a label on the remote. `j 'push (unlabel "feature")'` after the merge.
498+unlabel : Label -> [Drop]
499+unlabel = \n -> [{ delete = n }]
500+
501+-- Move a label: `j 'push (rename "old" "new")'`.
502+rename : Label -> Label -> Repo -> [b]
503+rename = \old new repo -> label new (labelled old) repo ++ unlabel old
504+
505+-- Every label the remote already has, at its current local commit.
506+-- `j push relabel` after rewriting a stack moves the remote's bookmarks along.
507+relabel : Repo -> [Push]
508+relabel = \repo -> concat (map (\c -> map (\n -> { id = c.id, name = n }) c.labels)
509+ (extract Commit repo)) or []
510+
511+
512+------------------------------------------------------------------------------
513+-- Inspection. These return printable values rather than a Repo.
514+------------------------------------------------------------------------------
515+
516+focus : Repo -> Commit -- the focused commit
517+focus = \repo -> repo.root
518+
519+files : Repo -> Snapshot -- its files
520+files = \repo -> repo.root.files
521+
522+-- The entry at path p in snapshot s, as a list: [] if absent.
523+entryAt : Path -> Snapshot -> [Entry]
524+entryAt = \p s -> filter (\e -> e.path == p) s
525+
526+-- The content at path p, or an empty file if absent.
527+contentAt : Path -> Snapshot -> Blob
528+contentAt = \p s -> (head (entryAt p s)).content or blob ""
529+
530+-- The paths a change touches.
531+touched : Change -> [Path]
532+touched = \ch ->
533+ let paths = map (.path) ch.to ++ filter (\p -> not (member p (map (.path) ch.to))) (map (.path) ch.from)
534+ in filter (\p -> entryAt p ch.to /= entryAt p ch.from) paths
535+
536+-- Paths whose content differs between the focus and its parent.
537+changed : Repo -> [Path]
538+changed = \repo -> touched (changeOf repo)
539+
540+-- What the focus changes, rendered by difftastic, one record per changed path.
541+-- Replace difft with (\p -> diff) to use the built-in unified diff instead.
542+diffs : Repo -> [{ path : Path, diff : Text }]
543+diffs = \repo ->
544+ let ch = changeOf repo
545+ in map (\p -> { path = p, diff = difft p (contentAt p ch.from) (contentAt p ch.to) }) (touched ch)
546+
547+-- The same, as one page of text. A Text result prints raw, so `j review` is
548+-- readable in the terminal.
549+review : Repo -> Text
550+review = \repo -> concat (map (\d -> d.diff) (diffs repo)) or "no changes\n"
551+
552+-- A summary of the focus.
553+status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path] }
554+status = \repo ->
555+ { id = repo.root.id
556+ , message = repo.root.message
557+ , labels = repo.root.labels
558+ , changed = changed repo
559+ , conflicts = conflicted repo.root.files }
560+
561+-- A summary of every commit, top first, in preorder.
562+log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]
563+log = \repo ->
564+ map (\c -> { id = c.id
565+ , message = c.message
566+ , labels = c.labels
567+ , focus = c.id == repo.root.id })
568+ (commits (top repo))
569+
570+-- The history as a tree. Edit these records to change what `j tree` shows.
571+-- (The parentheses are required: `f { … }` would be a record update of f.)
572+tree : Repo -> Text
573+tree = treeWith ({ detail = 1, margin = false, elide = true, icons = false, color = "auto" })
574+
575+treeFull : Repo -> Text
576+treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
docs/base.mdnew file+1201
@@ -0,0 +1,1201 @@
1+# The `j` base vocabulary
2+
3+This is the complete reference for everything `j` knows: every builtin and
4+every definition in the reference `config.j`. Nothing else exists — an
5+expression can use a name only if `config.j` declares it (a builtin,
6+implemented by `j`) or defines it (written in the language).
7+
8+Conventions used in this document:
9+
10+- **Signatures** are given as `name : Type`. A signature on its own declares a
11+ builtin; before a definition it documents and checks that definition.
12+- **Types** like `Edit`, `Revset`, `Fileset`, `Snapshot` are aliases defined
13+ in §1.3. Lowercase type variables (`a`, `b`, `m`) mean "any type".
14+- **Examples** show an expression and its result (`=>`). Pure examples are
15+ literal; repository examples are described, since their output depends on
16+ the repository.
17+
18+How to read the three most common aliases:
19+
20+```
21+Edit = Repo -> Repo -- a command; may crash. Applied to the repo on the command line.
22+Revset = Repo -> [Id] -- a set of commits, computed from a repo
23+Fileset = Path -> Bool -- which paths a command applies to
24+```
25+
26+Most definitions are **Edits** — functions `Repo -> Repo`. On the command line
27+a function result is applied to the current repository, and a `Repo` result is
28+persisted. `e2 . e1` runs `e1` then `e2`; `e or id` runs `e` and leaves things
29+alone if it crashes.
30+
31+---
32+
33+## 1. Types and shapes
34+
35+### 1.1 `user` — who commits
36+
37+```
38+user : { name : Text, email : Text }
39+```
40+
41+The identity `j` commits as. `j` reads this definition itself; it is required.
42+The reference value is a placeholder you must edit:
43+
44+```
45+user = { name = "Your Name", email = "you@example.com" }
46+```
47+
48+This is a plain value, not a function. Its signature is checked once, at load
49+(§4.13): if you write `user = 5`, `j` refuses to start (exit 3).
50+
51+### 1.2 Paths, labels, entries, snapshots
52+
53+**`Path = [Text]`** — a file path as its components. Written `./a/b`, which is
54+`["a" "b"]`. A trailing `/` is ignored; a bare `./` is `[]`, the repository
55+root. For names the literal cannot spell (spaces, unusual characters), build
56+one with `splitOn "/"`.
57+
58+```
59+./src/lexer.rs => ["src" "lexer.rs"]
60+./ => []
61+splitOn "/" "a b/c" => ["a b" "c"]
62+```
63+
64+**`Label = Text`** — a bookmark name on the remote. A label is written `%main`
65+in an expression (a `Revset`, see `labelled`); as data it is just text.
66+
67+**`Entry = { path : Path, content : Blob }`** — one file in a snapshot: its
68+path and its content.
69+
70+**`Snapshot = [Entry]`** — the files of one commit. Paths are unique. A
71+deleted, resolved path simply has no entry (there is no "deletion" marker).
72+
73+```
74+[({ path = ./a.txt, content = blob "hello" })]
75+ => a snapshot of one file, a.txt, containing "hello"
76+```
77+
78+**`Change = { from : Snapshot, to : Snapshot }`** — the files before and after
79+a change. Used by `replay`, `invert`, `touched`, `changeOf`.
80+
81+**`Meta = { hash : Text, author : Text, email : Text, time : Int }`** — what jj
82+records about a commit beyond the model: its git hash, author name and email,
83+and committer time. Produced by the `meta` builtin.
84+
85+### 1.3 Commits, subtrees, and the repository
86+
87+**`Commit`** — one commit:
88+
89+```
90+Commit = { files : Snapshot -- the files
91+ , message : Text -- the description
92+ , labels : [Label] -- the remote's names for it (read-only)
93+ , id : Id } -- its change id (unique)
94+```
95+
96+`labels` is read-only in the language: only `fetch` and `push` change it.
97+
98+**`Subtree = { root : Commit, children : [Subtree] }`** — a commit and,
99+recursively, everything below it.
100+
101+**`Frame = { parent : Commit, left : [Subtree], right : [Subtree] }`** — one
102+ancestor on the path back to the top, with a hole where the focused subtree
103+was. `left` and `right` are the siblings on either side (presentation order
104+only).
105+
106+**`Repo = { root : Commit, children : [Subtree], context : [Frame] }`** — the
107+whole history, seen from one commit (the *focus*). `root` is the focused
108+commit, `children` its children, `context` the path back up to the top
109+(`context = []` at the top of history). A `Repo` is a **zipper**.
110+
111+**`Detached = { subtree : Subtree, rest : Repo }`** — the result of `detach`:
112+a subtree, and the repository with that subtree removed.
113+
114+**`Rebase = { from : Snapshot, onto : Snapshot }`** — the base a subtree was
115+written on, and the new base to rewrite it onto. Used by `replayTree`.
116+
117+### 1.4 Aliases for functions
118+
119+```
120+Edit = Repo -> Repo -- a command; may crash
121+Fileset = Path -> Bool -- which paths a command applies to
122+Revset = Repo -> [Id] -- a set of commits, computed from a repo
123+```
124+
125+These aliases are unfolded by the contract checker: `at : Revset -> Edit ->
126+Edit` checks three arguments and a `Repo` result.
127+
128+### 1.5 Push records
129+
130+**`Push = { id : Id, name : Label }`** — set the remote's bookmark `name` to
131+the commit `id`, creating it if absent.
132+
133+**`Drop = { delete : Label }`** — delete the remote's bookmark of that name.
134+
135+`j push EXPR` evaluates `EXPR` to a list of `Push` and `Drop` records. Built
136+with `label`, `unlabel`, `rename`, `relabel` (§8).
137+
138+```
139+[({ id = @wqzt, name = "main" })] -- a Push: point main at @wqzt
140+[({ delete = "old-feature" })] -- a Drop: delete the old-feature bookmark
141+```
142+
143+---
144+
145+## 2. Functions and control flow
146+
147+These are the fundamental combinators. `or` is a keyword, not a builtin.
148+
149+### `(.)` — composition
150+
151+```
152+(.) : (b -> c) -> (a -> b) -> a -> c
153+```
154+
155+Right-to-left function composition: `(f . g) x = f (g x)`. Right-associative,
156+binds very tightly (`infixr 9`). This is how edits are chained.
157+
158+```
159+((\x -> x + 1) . (\x -> x * 2)) 5
160+ => 11 -- (5 * 2) + 1
161+
162+describe "wip" . new -- start a new commit, then describe it
163+tree . squash -- squash the focus, then render the tree
164+```
165+
166+### `id` — identity
167+
168+```
169+id : a -> a
170+```
171+
172+The identity function. As an edit it does nothing, which makes it the
173+universal fallback for `or`.
174+
175+```
176+id 42 => 42
177+(id . id) 7 => 7
178+edit or id -- run edit, keep the repository if it crashes
179+```
180+
181+### `const` — constant function
182+
183+```
184+const : a -> b -> a
185+```
186+
187+`const a b = a` — ignores its second argument.
188+
189+```
190+const 5 99 => 5
191+map (const 0) [1 2 3] => [0 0 0]
192+everything -- defined as const true
193+```
194+
195+### `crash` — abort with a message
196+
197+```
198+crash : Text -> a
199+```
200+
201+Aborts evaluation with the given message. Every interpreter-detected runtime
202+error is also a crash, and all crashes are catchable by `or`.
203+
204+```
205+crash "something went wrong"
206+ -- CRASH: something went wrong
207+
208+(crash "boom") or "recovered"
209+ => "recovered"
210+```
211+
212+### `or` — catching crashes (keyword)
213+
214+`a or b` evaluates `a`. If `a` crashes, the crash is discarded and `b` is
215+returned. Otherwise `a` is the result — unless `a` is a function and `b` is
216+too, in which case the result is pointwise: `(f or g) x = f x or g x`.
217+
218+```
219+head [] or 42 => 42
220+1 or head [] => 1 -- head [] never runs
221+edit or id -- try edit, fall back to id
222+```
223+
224+The lifting is what makes `edit or id` meaningful: a function value cannot
225+crash until applied, so without pointwise lifting the `or` would catch nothing.
226+
227+---
228+
229+## 3. Comparison, booleans, integers, `show`
230+
231+### Equality: `(==)` and `(/=)`
232+
233+```
234+(==) : a -> a -> Bool
235+(/=) : a -> a -> Bool
236+```
237+
238+Structural equality on ints, texts, bools, lists, and records; identity on
239+`Id`; content-and-mode on `Blob`. `/=` is the negation. **Comparing two
240+functions crashes.**
241+
242+```
243+2 == 2 => true
244+"a" == "a" => true
245+[1 2] == [1 2] => true
246+{ a = 1 } == { a = 1 } => true
247+1 /= 2 => true
248+```
249+
250+### Booleans: `(&&)`, `(||)`, `not`
251+
252+```
253+(&&) : Bool -> Bool -> Bool -- short-circuit
254+(||) : Bool -> Bool -> Bool -- short-circuit
255+not : Bool -> Bool
256+```
257+
258+`&&` and `||` short-circuit (the right operand is not evaluated if the left
259+decides the result). Operands must be `Bool`.
260+
261+```
262+1 == 1 && 2 == 2 => true
263+1 == 2 || 3 == 3 => true
264+not (1 == 2) => false
265+not true => false
266+```
267+
268+### Integer arithmetic: `(+)`, `(-)`, `(*)`
269+
270+```
271+(+) : Int -> Int -> Int
272+(-) : Int -> Int -> Int
273+(*) : Int -> Int -> Int
274+```
275+
276+Arbitrary-precision: never overflows. There is no division builtin. No unary
277+minus — write `0 - 5`.
278+
279+```
280+2 + 3 * 4 => 14
281+20 - 5 - 3 => 12
282+999999999999 * 999999999999
283+ => 999999999998000000000001
284+0 - 5 => (0 - 5)
285+```
286+
287+### Integer comparison: `(<)`, `(<=)`, `(>)`, `(>=)`
288+
289+Defined on `Int` only; crash on anything else. Non-associative (cannot chain:
290+`1 < 2 < 3` is a parse error).
291+
292+```
293+1 < 2 => true
294+3 >= 3 => true
295+(1 < 2) == true => true -- parenthesised, since < is non-associative
296+```
297+
298+### `show` — render a value as text
299+
300+```
301+show : a -> Text
302+```
303+
304+Renders any value in the language's literal syntax, such that (except for
305+closures and unresolved blobs) the text parses and evaluates back to an equal
306+value. A top-level `Text` prints raw, so `j show …` is how you inspect values.
307+See §5.2 of the spec for the exact rendering.
308+
309+```
310+show 42 => "42"
311+show "hi" => "\"hi\""
312+show [1 "a" true] => "[1 \"a\" true]"
313+show ({ x = 1 }) => "{ x = 1 }"
314+show (describe "wip") => "describe \"wip\""
315+show (\x -> x + 1) => "\x -> (x + 1)"
316+```
317+
318+---
319+
320+## 4. Lists and text
321+
322+### Basic list operations
323+
324+```
325+(::) : a -> [a] -> [a] -- cons
326+map : (a -> b) -> [a] -> [b]
327+filter : (a -> Bool) -> [a] -> [a]
328+length : [a] -> Int
329+null : [a] -> Bool
330+head : [a] -> a -- crash on []
331+tail : [a] -> [a] -- crash on []
332+last : [a] -> a -- crash on []
333+nth : Int -> [a] -> a -- 0-based; crash out of range
334+take : Int -> [a] -> [a] -- clamp to length
335+drop : Int -> [a] -> [a] -- clamp to length
336+member : a -> [a] -> Bool -- membership by ==
337+range : Int -> Int -> [Int] -- range a b = [a (a+1) … (b-1)]
338+foldl : (b -> a -> b) -> b -> [a] -> b
339+```
340+
341+```
342+1 :: [2 3] => [1 2 3]
343+map (\x -> x * 2) [1 2 3] => [2 4 6]
344+filter (\x -> x > 2) [1 2 3 4] => [3 4]
345+length [1 2 3] => 3
346+null [] => true
347+head [5 6 7] => 5
348+tail [5 6 7] => [6 7]
349+last [5 6 7] => 7
350+nth 1 [10 20 30] => 20
351+take 99 [1 2] => [1 2]
352+drop 99 [1 2] => []
353+member 3 [1 2 3] => true
354+range 1 6 => [1 2 3 4 5]
355+range 5 1 => []
356+foldl (+) 0 [1 2 3 4 5] => 15
357+foldl (\a -> \b -> a - b) 0 [1 2 3]
358+ => (0 - 6) -- ((0 - 1) - 2) - 3
359+```
360+
361+`head`, `tail`, `last`, and out-of-range `nth` crash:
362+
363+```
364+head [] -- CRASH: head: empty list
365+nth 5 [1 2 3] -- CRASH: nth: index 5 out of range
366+```
367+
368+### `(++)` and `concat` — overloaded on lists and text
369+
370+```
371+(++) : m -> m -> m
372+concat : [m] -> m
373+```
374+
375+These dispatch on the kind of the argument: they concatenate lists and they
376+concatenate text. `concat []` crashes (nothing to dispatch on) — supply a
377+default with `or`, e.g. `concat xs or []`.
378+
379+```
380+[1 2] ++ [3 4] => [1 2 3 4]
381+"foo" ++ "bar" => "foobar"
382+concat [[1 2] [3] []] => [1 2 3]
383+concat ["foo" "bar"] => "foobar"
384+concat [] or [] => []
385+```
386+
387+### Text predicates and splitting
388+
389+```
390+startsWith : Text -> Text -> Bool
391+endsWith : Text -> Text -> Bool
392+splitOn : Text -> Text -> [Text]
393+```
394+
395+```
396+startsWith "he" "hello" => true
397+endsWith "rs" "lexer.rs" => true
398+splitOn "," "a,b,c" => ["a" "b" "c"]
399+splitOn "/" "src/lexer.rs" => ["src" "lexer.rs"]
400+splitOn "/" "" => [""]
401+```
402+
403+`splitOn` with an empty separator crashes.
404+
405+---
406+
407+## 5. The repository builtins
408+
409+These are the builtins that touch the repository, blobs, and diffs.
410+
411+### `replay` — apply a change to a snapshot
412+
413+```
414+replay : Snapshot -> Change -> Snapshot
415+```
416+
417+`replay onto ch` computes "`onto`, with the change from `ch.from` to `ch.to`
418+applied." It is **total**: where the change collides with what is already
419+there, the path's blob becomes *unresolved* (a conflict) rather than an error.
420+
421+```
422+-- onto has "base", change takes "base" -> "theirs": no collision
423+replay [({ path = ./f, content = blob "base" })]
424+ ({ from = [({ path = ./f, content = blob "base" })],
425+ to = [({ path = ./f, content = blob "theirs" })] })
426+ => [({ content = blob "theirs", path = ["f"] })]
427+
428+-- onto has "onto-version" but the change expects "base": collision -> conflict
429+unresolved ((head (replay [({ path = ./f, content = blob "onto" })]
430+ ({ from = [({ path = ./f, content = blob "base" })],
431+ to = [({ path = ./f, content = blob "theirs" })] }))).content)
432+ => true
433+```
434+
435+Two laws to internalise it: `replay b { from = b, to = x } = x` and
436+`replay x { from = b, to = b } = x`.
437+
438+### `unresolved`, `blob`, `text` — blob primitives
439+
440+```
441+unresolved : Blob -> Bool
442+blob : Text -> Blob
443+text : Blob -> Text
444+```
445+
446+`blob t` builds a resolved regular-file blob with content `t`. `text b` reads a
447+blob's content (its conflict-marker rendering if unresolved). `unresolved b`
448+is `true` iff the blob is a conflict.
449+
450+```
451+blob "content" => blob "content"
452+text (blob "content") => "content"
453+unresolved (blob "x") => false
454+```
455+
456+### `by` — focus a commit by id
457+
458+```
459+by : Id -> Repo -> Repo
460+```
461+
462+Refocuses the repository on the commit with the given id; crashes if there is
463+no such visible commit. Backed by the change-id index. Applied to one argument
464+(`by @wqzt`) it is an `Edit`.
465+
466+```
467+by @wqzt -- an edit: refocus on the commit @wqzt
468+```
469+
470+### `meta` — commit metadata
471+
472+```
473+meta : Id -> Meta
474+```
475+
476+The git hash, author name and email, and committer time of the visible commit
477+with change id `i`, as a `Meta` record. Crashes for an id with no stored
478+commit (one minted in the current program, not yet persisted).
479+
480+```
481+meta @wqzt => { hash = "74bee64e…", author = "Test User",
482+ email = "test@example.com", time = 1789457265 }
483+```
484+
485+### `validate` — dry-run persistence
486+
487+```
488+validate : Repo -> Repo
489+```
490+
491+Returns the repo unchanged if persisting it would succeed; otherwise crashes
492+with exactly the error persistence would produce (shape, labels, immutability,
493+focus). `tree . validate . rebase trunk` is an honest dry run.
494+
495+### `diff` and `difft` — rendering differences
496+
497+```
498+diff : Blob -> Blob -> Text
499+difft : Path -> Blob -> Blob -> Text
500+```
501+
502+`diff a b` is a unified diff from the text of `a` to the text of `b`: no header
503+lines, three lines of context, `""` if equal. Crashes if either is not UTF-8.
504+
505+```
506+diff (blob "a\nb\n") (blob "a\nc\n")
507+ => "@@ -1,2 +1,2 @@\n a\n-b\n+c\n"
508+```
509+
510+`difft p a b` renders the comparison with **difftastic**, treating the input
511+as the file `p` (its last component guides language detection). Needs `difft`
512+on `PATH` (the Nix package provides it); options via `DFT_*` environment
513+variables. Crashes naming the tool if absent.
514+
515+### `treeWith` — the history tree
516+
517+```
518+treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text }
519+ -> Repo -> Text
520+```
521+
522+Renders the history as a tree with an options record. The reference config
523+provides two ready-made settings, `tree` and `treeFull` (§9).
524+
525+```
526+treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
527+ -- a full tree with ages and authors in the margin
528+```
529+
530+### `extract` — every subvalue of a shape
531+
532+```
533+extract : Shape -> a -> [b]
534+```
535+
536+Returns every subvalue of a value that has shape `S`, however deeply nested.
537+Walks lists in order and records in ascending field-name order, descending
538+into every element and field (not into functions or blobs). A record is an `S`
539+if its field set is exactly `S`'s.
540+
541+```
542+extract Int ({ a = 1, b = 2 })
543+ => [1 2]
544+extract Int ({ a = 1, b = [2 "x" ({ c = 3 })] })
545+ => [1 2 3]
546+extract Commit repo -- every commit in the repository
547+extract Push ys -- every Push record in a list, however nested
548+```
549+
550+---
551+
552+## 6. The zipper: moving the focus
553+
554+A `Repo` is focused on one commit. These primitives move or detach the focus.
555+Commands never select a commit by position — they use ids, labels, or
556+predicates.
557+
558+### `up` — to the parent
559+
560+```
561+up : Edit
562+up = \repo -> …
563+```
564+
565+Moves the focus to the parent commit, re-attaching the focused subtree among
566+its siblings. Crashes at the top of history. This is the raw zipper move;
567+`prev` (§7.4) is the friendlier version built on it.
568+
569+```
570+up -- refocus on the parent (crash if already at the top)
571+```
572+
573+### `detach` — remove the focused subtree
574+
575+```
576+detach : Repo -> Detached
577+```
578+
579+Detaches the focused subtree; the focus moves to the parent. Crashes at the
580+top. Returns a `Detached` record (`{ subtree, rest }`). Used to move subtrees
581+around (with `attach`).
582+
583+### `top` — to the top of history
584+
585+```
586+top : Edit
587+top = \repo -> top (up repo) or repo
588+```
589+
590+Moves the focus all the way to the top of history (where `context = []`). The
591+`or repo` is the base case: when `up` crashes at the top, stop.
592+
593+```
594+top -- refocus at the very first commit
595+commits . top -- every commit, preorder
596+```
597+
598+### `commits` — every commit of a (sub)tree
599+
600+```
601+commits : a -> [Commit]
602+commits = \t -> t.root :: (concat (map commits t.children) or [])
603+```
604+
605+Every commit of a subtree (or of the focused subtree of a `Repo`), in
606+preorder. Takes anything with `root` and `children`, so the signature is left
607+open. The `or []` handles a leaf (whose `children` is empty, so `concat`
608+crashes and yields `[]`).
609+
610+```
611+length . commits . top -- how many commits are visible?
612+commits . top -- every commit, preorder from the top
613+```
614+
615+---
616+
617+## 7. Structure and focusing
618+
619+### 7.1 Editing at the focus
620+
621+**`mapRoot : (Commit -> Commit) -> Edit`** — apply a function to the focused
622+commit.
623+
624+```
625+mapRoot = \f repo -> repo { root = f repo.root }
626+```
627+
628+`describe` is `mapRoot` with a message setter (§8.1).
629+
630+**`mapChildren : ([Subtree] -> [Subtree]) -> Edit`** — apply a function to the
631+focus's children.
632+
633+**`attach : Subtree -> Edit`** — attach a subtree as a new child of the focus
634+(the focus does not move).
635+
636+```
637+attach = \t -> mapChildren (++ [t])
638+```
639+
640+**`remove : Edit`** — remove the focused commit; its children join the parent,
641+and the focus moves to the parent.
642+
643+**`addChild : Commit -> Edit`** — attach a commit as a new child of the focus
644+and move to it.
645+
646+**`newCommit : Snapshot -> Commit`** — a new commit with the given files, no
647+message, no labels, and a freshly minted id (`@`). Because `@` is evaluated
648+per application, each call mints a distinct id.
649+
650+```
651+newCommit = \files -> { files = files, message = "", labels = [], id = @ }
652+```
653+
654+### 7.2 Snapshots, changes, and filesets
655+
656+**`restrict : Fileset -> Snapshot -> Snapshot`** — the entries matching a
657+fileset.
658+
659+```
660+restrict (ext "rs") snap -- only the .rs files in snap
661+```
662+
663+**`select : Fileset -> Snapshot -> Snapshot -> Snapshot`** — the entries
664+matching `m` from the first snapshot, the rest from the second.
665+
666+```
667+select = \m a b -> restrict m a ++ restrict (neg m) b
668+```
669+
670+**`conflicted : Snapshot -> [Path]`** — the paths whose content is unresolved.
671+
672+**`changeOf : Repo -> Change`** — what the focus changes: its parent's files
673+(`from`) and its own (`to`).
674+
675+**`invert : Change -> Change`** — the same change, backwards (`from`/`to`
676+swapped). Used by `backout`.
677+
678+```
679+invert ({ from = [], to = [({ path = ./a, content = blob "1" })] })
680+ => { from = [({ content = blob "1", path = ["a"] })], to = [] }
681+```
682+
683+**Fileset combinators** — filesets are functions `Path -> Bool`, composed
684+pointwise:
685+
686+```
687+everything : Fileset -- const true: every path
688+neg : Fileset -> Fileset -- not . m
689+both : Fileset -> Fileset -> Fileset -- m p && n p
690+either : Fileset -> Fileset -> Fileset -- m p || n p
691+under : Path -> Fileset -- inside a directory
692+ext : Text -> Fileset -- a file extension
693+```
694+
695+```
696+under ./src ./src/main.rs => true
697+under ./src ./README.md => false
698+ext "rs" ./src/main.rs => true
699+ext "rs" ./src/main.j => false
700+both (ext "rs") (under ./src) ./src/main.rs
701+ => true
702+either (ext "j") (ext "rs") ./x.rs
703+ => true
704+neg (ext "rs") ./x.j => true
705+everything ./anything/at/all => true
706+```
707+
708+### 7.3 Revsets
709+
710+A revset is a function `Repo -> [Id]`: a set of commits, computed from a repo.
711+It can be relative to the focus (`parents`, `kids`) or absolute (`all`,
712+`%main`).
713+
714+```
715+here : Revset -- [the focused commit's id]
716+parents : Revset -- the focus's parents (crash-safe: [] at the top)
717+kids : Revset -- the focus's children
718+descendants : Revset -- the focus and everything below it
719+ancestors : Revset -- the focus and everything above it
720+siblings : Revset -- the other children of the focus's parent
721+all : Revset -- every visible commit
722+stack : Revset -- the line through the focus (ancestors ∪ descendants)
723+labelled : Label -> Revset -- %main is labelled "main"
724+trunk : Revset -- the main line: %main, %master, or %trunk, first found
725+immutable : Revset -- commits that may never be rewritten (ancestorsOf trunk)
726+```
727+
728+```
729+parents -- the ids of the focus's parents
730+kids -- the ids of the focus's children
731+labelled "main" -- the commit the remote calls main (== %main)
732+```
733+
734+**Revset combinators:**
735+
736+```
737+from : Revset -> Revset -> Revset -- evaluate f starting from each commit of rs
738+descendantsOf : Revset -> Revset
739+ancestorsOf : Revset -> Revset
740+union : Revset -> Revset -> Revset
741+intersect : Revset -> Revset -> Revset
742+minus : Revset -> Revset -> Revset
743+matching : (Commit -> Bool) -> Revset -> Revset -- filter by a predicate
744+conflicts : Revset -- commits with unresolved files
745+firstOf : [Revset] -> Revset -- the first non-empty revset
746+```
747+
748+```
749+union ancestors descendants -- the stack (this is how `stack` is defined)
750+minus all trunk -- everything not on the main line
751+matching (\c -> c.message == "") all -- every commit with an empty message
752+firstOf [%main %master %trunk] -- this is `trunk`
753+ancestorsOf trunk -- this is `immutable`
754+conflicts -- the commits that have conflicts
755+```
756+
757+(These combine revsets, not raw lists — `union [1 2] [3]` is a contract error,
758+because a revset is a function.)
759+
760+`immutable` is special: `j` evaluates it before persisting anything and refuses
761+to change what it names. Merge commits and their ancestors are immutable
762+regardless. Because the focus must always be mutable, the way to work on trunk
763+is `new . goto trunk`, never `goto trunk` alone.
764+
765+### 7.4 Focusing with `goto` and friends
766+
767+**`goto : Revset -> Edit`** — refocus on *the one commit* a revset names. This
768+is the only place a revset must have exactly one member; otherwise it crashes
769+with `expected one revision, got N`.
770+
771+```
772+goto = \rs repo ->
773+ let is = rs repo
774+ in if length is == 1 then by (head is) repo
775+ else crash ("expected one revision, got " ++ show (length is))
776+```
777+
778+```
779+goto %main -- refocus on main (crash if it names 0 or 2+ commits)
780+goto parents -- == prev
781+```
782+
783+**`commitAt : Id -> Repo -> Commit`** — the commit with a given id.
784+
785+**`prev : Edit`** — to the parent (`goto parents`).
786+
787+**`next : Edit`** — to the only child (`goto kids`; crashes if the focus has
788+other than exactly one child).
789+
790+**`tip : Edit`** — the end of the line of only-children (`tip = \repo -> tip
791+(next repo) or repo`).
792+
793+**`into : Id -> Edit`** — to a child, by id; crashes if it isn't a child of
794+the focus.
795+
796+**`child : (Commit -> Bool) -> Edit`** — to the one child satisfying a
797+predicate (`goto (matching p kids)`).
798+
799+**`at : Revset -> Edit -> Edit`** — run an edit somewhere else, then come back
800+(by id, so the edit may move things).
801+
802+```
803+at %main (describe "released")
804+ -- describe main, then return the focus to where it was
805+```
806+
807+**`forEach : Revset -> Edit -> Edit`** — run an edit at every commit of a
808+revset, returning to the focus.
809+
810+```
811+forEach descendants (describe "wip")
812+ -- describe every commit below the focus "wip"
813+```
814+
815+**`eachChild : Edit -> Edit`** — `forEach kids`: run an edit at every child.
816+
817+**`guard : (Repo -> Bool) -> Edit`** — crash unless a predicate holds.
818+
819+```
820+guard clean -- refuse to continue if the focus has conflicts
821+```
822+
823+**`clean : Repo -> Bool`** — `true` iff the focus has no unresolved files.
824+
825+---
826+
827+## 8. Commands
828+
829+These are the everyday edits. Each is an `Edit`, applied to the repository on
830+the command line.
831+
832+### `describe` — set the message
833+
834+```
835+describe : Text -> Edit
836+describe = \m -> mapRoot (\c -> c { message = m })
837+```
838+
839+Sets the message of the focused commit.
840+
841+```
842+j 'describe "add the lexer"'
843+```
844+
845+### `new` — start a new commit
846+
847+```
848+new : Edit
849+new = \repo -> addChild (newCommit repo.root.files) repo
850+```
851+
852+Starts an empty child of the focus (with the focus's files) and moves to it.
853+
854+```
855+j 'new'
856+j 'describe "wip" . new' -- new commit, then describe it
857+```
858+
859+### `abandon` — drop a commit
860+
861+```
862+abandon : Edit
863+```
864+
865+Removes the focused commit. Its children are rebased onto its parent, and a
866+new empty commit on that parent becomes the focus. Refused (crashes) for a
867+commit the remote has a name for — push something else under that name first,
868+or forget the name.
869+
870+```
871+j 'abandon' -- drop the focused commit
872+```
873+
874+### `contract` — push files down into the parent
875+
876+```
877+contract : Fileset -> Edit
878+```
879+
880+Pushes the files matching `m` from the focus down into its parent.
881+
882+```
883+j 'contract (ext "rs")' -- move the .rs changes into the parent commit
884+```
885+
886+### `squash` — fold into the parent
887+
888+```
889+squash : Edit
890+squash = abandon . contract everything
891+```
892+
893+Folds the focus entirely into its parent; a new empty commit becomes the
894+focus. Defined as `contract everything` followed by `abandon`.
895+
896+```
897+j 'squash'
898+```
899+
900+### `split` — push files up into a new child
901+
902+```
903+split : Fileset -> Edit
904+```
905+
906+Pushes the files matching `m` from the focus up into a new child, which
907+becomes the focus and inherits the old focus's children. Labels stay with the
908+original commit (they are the remote's). The inverse of `contract`.
909+
910+```
911+j 'split (\p -> p == ./a.txt)' -- split off just a.txt into a child commit
912+```
913+
914+### `rebase` — move a subtree
915+
916+```
917+rebase : Revset -> Edit
918+```
919+
920+Moves the focused subtree onto another commit (the one the revset names), and
921+returns to the moved commit. Crashes if the destination is inside the subtree
922+being moved.
923+
924+```
925+j 'rebase %main' -- move the current stack onto main
926+```
927+
928+### `pick` — copy a change elsewhere
929+
930+```
931+pick : Revset -> Edit
932+```
933+
934+Applies the focused commit's change onto another commit, as a new child there.
935+
936+```
937+j 'pick %main' -- copy the focused change onto main
938+```
939+
940+### `backout` — undo a change
941+
942+```
943+backout : Revset -> Edit
944+```
945+
946+Creates a new child of the focus that undoes the change of the given commit
947+(by replaying its inverse).
948+
949+```
950+j 'backout @wqzt' -- a new commit undoing @wqzt's change
951+```
952+
953+### Rebasing internals
954+
955+**`replayTree : Rebase -> Subtree -> Subtree`** — rewrite a subtree as if it
956+had been written on `onto` instead of `from`, replaying each commit's change
957+onto the new base and its children from old files to new.
958+
959+**`replayOnto : Commit -> Edit`** — replay the focused subtree onto a commit,
960+in place (the subtree stays attached where it is; only its files change).
961+Crashes at the top.
962+
963+**`rewrite : (Commit -> Commit) -> Edit`** — replace the focused commit with
964+`f` applied to it, replaying its children so they follow.
965+
966+---
967+
968+## 9. Pushing
969+
970+`j push EXPR` evaluates `EXPR` to a list of `Push` and `Drop` records. These
971+functions build such lists.
972+
973+**`label : Label -> Revset -> Repo -> [Push]`** — label every commit of a
974+revset.
975+
976+```
977+j 'push (label "feature" here)' -- label the focused commit "feature"
978+```
979+
980+**`unlabel : Label -> [Drop]`** — remove a label on the remote.
981+
982+```
983+j 'push (unlabel "feature")' -- delete the "feature" bookmark
984+```
985+
986+**`rename : Label -> Label -> Repo -> [b]`** — move a label.
987+
988+```
989+j 'push (rename "old" "new")' -- rename the bookmark "old" to "new"
990+```
991+
992+**`relabel : Repo -> [Push]`** — every label the remote already has, at its
993+current local commit. After rewriting a stack, this moves the remote's
994+bookmarks along to the rewritten commits.
995+
996+```
997+j 'push relabel' -- after rewriting, re-point the remote's bookmarks
998+```
999+
1000+---
1001+
1002+## 10. Inspection
1003+
1004+These return printable values rather than a `Repo`.
1005+
1006+**`focus : Repo -> Commit`** — the focused commit.
1007+
1008+**`files : Repo -> Snapshot`** — the focus's files.
1009+
1010+```
1011+j 'files' -- the working-copy commit's files
1012+```
1013+
1014+**`entryAt : Path -> Snapshot -> [Entry]`** — the entry at a path, as a list
1015+(`[]` if absent).
1016+
1017+**`contentAt : Path -> Snapshot -> Blob`** — the content at a path, or an
1018+empty file if absent.
1019+
1020+```
1021+contentAt ./a [({ path = ./a, content = blob "x" })]
1022+ => blob "x"
1023+contentAt ./missing [] => blob ""
1024+```
1025+
1026+**`touched : Change -> [Path]`** — the paths a change touches.
1027+
1028+**`changed : Repo -> [Path]`** — paths whose content differs between the focus
1029+and its parent.
1030+
1031+**`diffs : Repo -> [{ path : Path, diff : Text }]`** — what the focus changes,
1032+rendered by difftastic, one record per changed path. (Replace `difft` with
1033+`(\p -> diff)` to use the built-in unified diff instead.)
1034+
1035+**`review : Repo -> Text`** — the same, as one page of text. A `Text` result
1036+prints raw, so `j review` is readable in the terminal.
1037+
1038+```
1039+j 'review' -- the focus's changes, as difftastic renders them
1040+```
1041+
1042+**`status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path] }`** — a summary of the focus.
1043+
1044+```
1045+j 'status'
1046+-- id @wqzt…
1047+-- message add the lexer
1048+-- labels [feature]
1049+-- changed [src/lexer.rs]
1050+-- conflicts []
1051+```
1052+
1053+**`log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]`**
1054+— a summary of every commit, top first, in preorder (`focus` marks the focused
1055+commit).
1056+
1057+**`tree : Repo -> Text`** and **`treeFull : Repo -> Text`** — the history as a
1058+tree, built with `treeWith`. `tree` is compact (`detail = 1`, elided);
1059+`treeFull` shows ages and authors in the margin (`detail = 2`, `margin =
1060+true`). Edit these definitions to change what `j tree` shows.
1061+
1062+```
1063+tree = treeWith ({ detail = 1, margin = false, elide = true, icons = false, color = "auto" })
1064+treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
1065+```
1066+
1067+```
1068+j 'tree'
1069+-- ⌂ zzzz
1070+-- └─ ● rpmt ▂
1071+-- ▶ └─ ◌ lvpl add the lexer
1072+```
1073+
1074+---
1075+
1076+## 11. Alphabetical index
1077+
1078+| name | type | section |
1079+|---|---|---|
1080+| `(.)` | `(b -> c) -> (a -> b) -> a -> c` | §2 |
1081+| `(==)` `(/=)` | `a -> a -> Bool` | §3 |
1082+| `(&&)` `(\|\|)` `not` | booleans | §3 |
1083+| `(+)` `(-)` `(*)` | `Int -> Int -> Int` | §3 |
1084+| `(<)` `(<=)` `(>)` `(>=)` | `Int -> Int -> Bool` | §3 |
1085+| `(++)` `concat` | lists & text | §4 |
1086+| `(::)` | `a -> [a] -> [a]` | §4 |
1087+| `abandon` | `Edit` | §8 |
1088+| `addChild` | `Commit -> Edit` | §7.1 |
1089+| `all` | `Revset` | §7.3 |
1090+| `ancestors` | `Revset` | §7.3 |
1091+| `ancestorsOf` | `Revset -> Revset` | §7.3 |
1092+| `at` | `Revset -> Edit -> Edit` | §7.4 |
1093+| `attach` | `Subtree -> Edit` | §7.1 |
1094+| `backout` | `Revset -> Edit` | §8 |
1095+| `blob` | `Text -> Blob` | §5 |
1096+| `both` | `Fileset -> Fileset -> Fileset` | §7.2 |
1097+| `by` | `Id -> Repo -> Repo` | §5 |
1098+| `changeOf` | `Repo -> Change` | §7.2 |
1099+| `changed` | `Repo -> [Path]` | §10 |
1100+| `child` | `(Commit -> Bool) -> Edit` | §7.4 |
1101+| `clean` | `Repo -> Bool` | §7.4 |
1102+| `commitAt` | `Id -> Repo -> Commit` | §7.4 |
1103+| `commits` | `a -> [Commit]` | §6 |
1104+| `concat` | `[m] -> m` | §4 |
1105+| `conflicted` | `Snapshot -> [Path]` | §7.2 |
1106+| `conflicts` | `Revset` | §7.3 |
1107+| `const` | `a -> b -> a` | §2 |
1108+| `contentAt` | `Path -> Snapshot -> Blob` | §10 |
1109+| `contract` | `Fileset -> Edit` | §8 |
1110+| `crash` | `Text -> a` | §2 |
1111+| `describe` | `Text -> Edit` | §8 |
1112+| `descendants` | `Revset` | §7.3 |
1113+| `descendantsOf` | `Revset -> Revset` | §7.3 |
1114+| `detach` | `Repo -> Detached` | §6 |
1115+| `diff` | `Blob -> Blob -> Text` | §5 |
1116+| `diffs` | `Repo -> [{path, diff}]` | §10 |
1117+| `difft` | `Path -> Blob -> Blob -> Text` | §5 |
1118+| `drop` | `Int -> [a] -> [a]` | §4 |
1119+| `eachChild` | `Edit -> Edit` | §7.4 |
1120+| `either` | `Fileset -> Fileset -> Fileset` | §7.2 |
1121+| `endsWith` | `Text -> Text -> Bool` | §4 |
1122+| `entryAt` | `Path -> Snapshot -> [Entry]` | §10 |
1123+| `everything` | `Fileset` | §7.2 |
1124+| `ext` | `Text -> Fileset` | §7.2 |
1125+| `extract` | `Shape -> a -> [b]` | §5 |
1126+| `files` | `Repo -> Snapshot` | §10 |
1127+| `filter` | `(a -> Bool) -> [a] -> [a]` | §4 |
1128+| `firstOf` | `[Revset] -> Revset` | §7.3 |
1129+| `focus` | `Repo -> Commit` | §10 |
1130+| `foldl` | `(b -> a -> b) -> b -> [a] -> b` | §4 |
1131+| `forEach` | `Revset -> Edit -> Edit` | §7.4 |
1132+| `from` | `Revset -> Revset -> Revset` | §7.3 |
1133+| `goto` | `Revset -> Edit` | §7.4 |
1134+| `guard` | `(Repo -> Bool) -> Edit` | §7.4 |
1135+| `head` | `[a] -> a` | §4 |
1136+| `here` | `Revset` | §7.3 |
1137+| `id` | `a -> a` | §2 |
1138+| `immutable` | `Revset` | §7.3 |
1139+| `intersect` | `Revset -> Revset -> Revset` | §7.3 |
1140+| `into` | `Id -> Edit` | §7.4 |
1141+| `invert` | `Change -> Change` | §7.2 |
1142+| `kids` | `Revset` | §7.3 |
1143+| `label` | `Label -> Revset -> Repo -> [Push]` | §9 |
1144+| `labelled` | `Label -> Revset` | §7.3 |
1145+| `last` | `[a] -> a` | §4 |
1146+| `length` | `[a] -> Int` | §4 |
1147+| `log` | `Repo -> […]` | §10 |
1148+| `map` | `(a -> b) -> [a] -> [b]` | §4 |
1149+| `mapChildren` | `([Subtree] -> [Subtree]) -> Edit` | §7.1 |
1150+| `mapRoot` | `(Commit -> Commit) -> Edit` | §7.1 |
1151+| `matching` | `(Commit -> Bool) -> Revset -> Revset` | §7.3 |
1152+| `member` | `a -> [a] -> Bool` | §4 |
1153+| `meta` | `Id -> Meta` | §5 |
1154+| `minus` | `Revset -> Revset -> Revset` | §7.3 |
1155+| `neg` | `Fileset -> Fileset` | §7.2 |
1156+| `new` | `Edit` | §8 |
1157+| `newCommit` | `Snapshot -> Commit` | §7.1 |
1158+| `next` | `Edit` | §7.4 |
1159+| `not` | `Bool -> Bool` | §3 |
1160+| `nth` | `Int -> [a] -> a` | §4 |
1161+| `null` | `[a] -> Bool` | §4 |
1162+| `parents` | `Revset` | §7.3 |
1163+| `pick` | `Revset -> Edit` | §8 |
1164+| `prev` | `Edit` | §7.4 |
1165+| `range` | `Int -> Int -> [Int]` | §4 |
1166+| `rebase` | `Revset -> Edit` | §8 |
1167+| `relabel` | `Repo -> [Push]` | §9 |
1168+| `remove` | `Edit` | §7.1 |
1169+| `rename` | `Label -> Label -> Repo -> [b]` | §9 |
1170+| `replay` | `Snapshot -> Change -> Snapshot` | §5 |
1171+| `replayOnto` | `Commit -> Edit` | §8 |
1172+| `replayTree` | `Rebase -> Subtree -> Subtree` | §8 |
1173+| `restrict` | `Fileset -> Snapshot -> Snapshot` | §7.2 |
1174+| `review` | `Repo -> Text` | §10 |
1175+| `rewrite` | `(Commit -> Commit) -> Edit` | §8 |
1176+| `select` | `Fileset -> Snapshot -> Snapshot -> Snapshot` | §7.2 |
1177+| `show` | `a -> Text` | §3 |
1178+| `siblings` | `Revset` | §7.3 |
1179+| `split` | `Fileset -> Edit` | §8 |
1180+| `splitOn` | `Text -> Text -> [Text]` | §4 |
1181+| `squash` | `Edit` | §8 |
1182+| `stack` | `Revset` | §7.3 |
1183+| `startsWith` | `Text -> Text -> Bool` | §4 |
1184+| `status` | `Repo -> {…}` | §10 |
1185+| `tail` | `[a] -> [a]` | §4 |
1186+| `take` | `Int -> [a] -> [a]` | §4 |
1187+| `text` | `Blob -> Text` | §5 |
1188+| `tip` | `Edit` | §7.4 |
1189+| `top` | `Edit` | §6 |
1190+| `touched` | `Change -> [Path]` | §10 |
1191+| `tree` | `Repo -> Text` | §10 |
1192+| `treeFull` | `Repo -> Text` | §10 |
1193+| `treeWith` | `{…} -> Repo -> Text` | §5 |
1194+| `trunk` | `Revset` | §7.3 |
1195+| `under` | `Path -> Fileset` | §7.2 |
1196+| `union` | `Revset -> Revset -> Revset` | §7.3 |
1197+| `unlabel` | `Label -> [Drop]` | §9 |
1198+| `unresolved` | `Blob -> Bool` | §5 |
1199+| `up` | `Edit` | §6 |
1200+| `user` | `{ name : Text, email : Text }` | §1.1 |
1201+| `validate` | `Repo -> Repo` | §5 |
docs/comparison.mdnew file+350
@@ -0,0 +1,350 @@
1+# `j` vs. Git vs. Jujutsu
2+
3+`j` is a functional-programming-centered interface to a Jujutsu (jj)
4+repository. It is not a new version-control system: the storage, the data
5+model, and the on-disk format are jj's (which in turn is git-compatible). What
6+`j` replaces is the *user interface* — instead of subcommands and flags, you
7+write expressions in a small functional language that transform the
8+repository.
9+
10+This document compares the three systems side by side: pure **git**, **jj**,
11+and **j**. Because `j` builds directly on jj, the interesting contrasts are
12+git → jj (the model) and jj → j (the interface).
13+
14+> **How to read the tables.** A cell gives the command or expression you'd
15+> typically use for the task in that system. `—` means "no direct equivalent."
16+> Where an analogy is approximate, it's marked *(≈)*.
17+
18+---
19+
20+## 1. The one-sentence model
21+
22+| | |
23+|---|---|
24+| **git** | A content-addressed object store of commits, trees, and blobs, with mutable named pointers (branches/tags) and a staging area between your working tree and history. |
25+| **jj** | The same store, but the working copy is *itself a commit* (`@`), there is no staging area, every change is tracked by a stable *change id*, and every operation is recorded in an undoable operation log. |
26+| **j** | jj's model, driven by a pure functional language: the repository is a *value*, a command is a *function from repository to repository*, and work is done by composing those functions. |
27+
28+---
29+
30+## 2. Core concepts, mapped
31+
32+This is the Rosetta Stone for the three systems.
33+
34+| Concept | git | jj | j |
35+|---|---|---|---|
36+| Current position | `HEAD` | `@` (the working-copy commit) | the **focus** of the `Repo` value |
37+| A commit | commit (sha1) | commit (change id + commit id) | a `Commit` record `{ files, message, labels, id }` |
38+| Identity over rewrites | new sha each time | **change id** is stable | the commit's `id` field is the change id |
39+| Staging area | the index | — (working copy is a commit) | — (snapshot is automatic) |
40+| Branch | branch | bookmark | **label** (`[Label]` on a `Commit`) |
41+| Main line | `main`/`master` branch | `main`/`master` bookmark | the `trunk` revset |
42+| History | the DAG | the DAG | the `Repo` zipper value |
43+| Undo | `git reflog` (read-only) | `jj op log` + `jj undo` (first-class) | `j ops`, `j undo`, `j redo` |
44+| "What am I looking at" | `git status`, `git log` | `jj st`, `jj log` | `j status`, `j tree` |
45+| Protected history | force-push guards (manual) | — | the `immutable` revset (enforced) |
46+
47+### The big three differences, in one table
48+
49+| | git | jj | j |
50+|---|---|---|---|
51+| Is there a staging area? | **Yes** (the index) | No | No |
52+| Is the working copy a commit? | No | **Yes** | **Yes** |
53+| What survives a rewrite? | Nothing (new sha) | The **change id** | The **change id** (`id` field) |
54+
55+The staging area is the single biggest git concept that simply does not exist
56+in jj or `j`: your working directory is always already a commit, so "stage" is
57+not a step. And the change id is what makes history editing safe to talk
58+about: a commit keeps its identity (`@wqzt…`) across `describe`, `squash`,
59+`rebase`, and friends, whereas in git each of those mints a brand-new sha.
60+
61+---
62+
63+## 3. Everyday commands, side by side
64+
65+### 3.1 Starting and joining work
66+
67+| Task | git | jj | j |
68+|---|---|---|---|
69+| Create a repository | `git init` | `jj git init` | `j init` |
70+| Clone | `git clone URL [DIR]` | `jj git clone URL [DIR]` | `j clone URL [DIR]` |
71+| Set the remote | `git remote add origin URL` | `jj git remote add origin URL` | `j remote URL` |
72+| Fetch | `git fetch` | `jj git fetch` | `j fetch` |
73+| Push | `git push origin main` | `jj git push` | `j push EXPR` |
74+
75+`j push` takes an *expression* rather than flags: `j 'push (label "feature"
76+here)'` labels the focused commit, `j 'push relabel'` re-points the remote's
77+bookmarks after a rewrite.
78+
79+### 3.2 Recording work
80+
81+| Task | git | jj | j |
82+|---|---|---|---|
83+| Save current state | `git add … && git commit` | automatic (`jj st` shows it) | automatic; `j id` to record *only* the snapshot |
84+| Set the message | `git commit -m "…"` | `jj describe -m "…"` or `jj describe` | `j 'describe "…"'` |
85+| Start the next change | (just keep editing) | `jj new` | `j new` |
86+| Amend the current commit | `git commit --amend` | `jj describe` + edit files in `@` | edit files + `j 'describe "…"'` |
87+| See what changed | `git diff` / `git show` | `jj diff` / `jj show` | `j review` (via difftastic) / `j status` |
88+
89+In git, "commit" is a deliberate act with a staging step. In jj and `j`, the
90+snapshot is continuous; `describe` sets a message and `new` opens the next
91+change. `j` has one deliberate-snapshot command, `j id`, for when you want to
92+record the working directory and nothing else.
93+
94+### 3.3 Looking around
95+
96+| Task | git | jj | j |
97+|---|---|---|---|
98+| Current state | `git status` | `jj st` | `j status` |
99+| History graph | `git log --graph` | `jj log` | `j tree` (compact) / `j treeFull` (with ages, authors) |
100+| Operation history | `git reflog` | `jj op log` | `j ops` |
101+| One commit | `git show <sha>` | `jj show <change>` | `j show …` on a `Commit` |
102+| Find commits | `git log --grep`, `--author` | `jj log -r <revset>` | revsets: `matching (\c -> …) all` |
103+
104+`j tree` is the closest analog of `jj log`'s graph and of `git log --graph
105+--oneline`.
106+
107+---
108+
109+## 4. Navigating history
110+
111+git navigates by naming commits (sha, branch, `HEAD~3`). jj navigates with
112+revsets and explicit movement commands. `j` navigates by refocusing the `Repo`
113+zipper with edits.
114+
115+| Task | git | jj | j |
116+|---|---|---|---|
117+| To the parent | `git checkout HEAD~1` | `jj prev` *(≈, opens a new empty commit there)* | `j prev` |
118+| To the child | `git checkout <sha>` (manual) | `jj next` *(≈, opens a new empty commit there)* | `j next` |
119+| To a named commit | `git checkout <branch>` | `jj new <rev>` | `j 'goto %main'` |
120+| To a specific change | `git checkout <sha>` | `jj new <change-id>` | `j 'by @wqzt'` |
121+| Back to your branch | `git checkout main` | `jj new main` | `j 'new . goto trunk'` |
122+
123+A subtle but important difference in how movement works: `git checkout` and
124+`jj new <rev>` *switch* you to an existing commit (jj opens a fresh empty
125+working-copy commit on top of it). `jj prev`/`jj next` likewise move by
126+opening a new empty commit at an ancestor/descendant. `j prev`/`j next` are
127+different: they **refocus the existing commit** (`goto parents` / `goto
128+kids`) — they don't create a new commit. To start *new work* on the main line
129+in `j`, you compose: `new . goto trunk`, never `goto trunk` alone, because
130+trunk is immutable and the focus must always be a mutable commit.
131+
132+---
133+
134+## 5. Editing history
135+
136+This is where the systems differ most. git's history editing is powerful but
137+fragile (rebase, interactive rebase, filter-branch); jj makes it routine
138+because change ids track everything; `j` makes it *compositional*.
139+
140+| Task | git | jj | j |
141+|---|---|---|---|
142+| Reword a commit | `git commit --amend` / rebase -i | `jj describe <rev>` | `j 'describe "…"'` (at the focus) |
143+| Squash into parent | `git rebase -i` (squash) | `jj squash` | `j squash` |
144+| Split a commit | `git rebase -i` (edit + reset) | `jj split` | `j 'split (ext "rs")'` (by fileset) |
145+| Drop a commit | `git rebase -i` (drop) | `jj abandon` | `j abandon` |
146+| Move a commit/branch | `git rebase main` | `jj rebase -d main` | `j 'rebase %main'` |
147+| Cherry-pick | `git cherry-pick <sha>` | `jj duplicate` / `jj rebase` | `j 'pick @wqzt'` |
148+| Revert a commit | `git revert <sha>` | `jj revert <rev>` | `j 'backout @wqzt'` |
149+| Absorb/fixup | `git commit --fixup` + autosquash | `jj absorb` | `j 'contract (ext "rs")'` *(≈)* |
150+
151+### Direct analogies for the tricky ones
152+
153+**Squash.** git's `rebase -i` with `squash` folds a commit into its parent.
154+jj's `jj squash` does the same for `@`. `j squash` is defined as
155+`abandon . contract everything`: push all of the focus's files into the
156+parent, then drop the focus.
157+
158+**Split.** git splits by pausing a rebase and committing piecemeal — error
159+prone. jj's `jj split` is interactive. `j 'split m'` takes a *fileset*, so
160+"put the `.rs` changes in a separate commit" is one expression:
161+`j 'split (ext "rs")'`. No other system splits by predicate.
162+
163+**Contract.** `j contract` is the inverse of `split`: it pushes the files
164+matching a fileset *down* into the parent. There is no single git or jj verb
165+for "move these specific changes into the parent commit"; `jj absorb` is the
166+closest *(≈)*.
167+
168+**Rebase.** `git rebase main` replays your branch onto main. `jj rebase -d
169+main` moves `@` and its descendants onto main. `j 'rebase %main'` moves the
170+focused subtree onto `%main`. All three replay each change onto the new base —
171+jj and `j` keep the change ids, git mints new shas.
172+
173+**Backout.** `git revert X` adds a commit undoing X. `j 'backout @wqzt'` does
174+the same by replaying X's *inverse* change.
175+
176+---
177+
178+## 6. Branches, labels, and the remote
179+
180+| Concept | git | jj | j |
181+|---|---|---|---|
182+| A named pointer | branch | bookmark | label (`[Label]`) |
183+| Local vs remote | both live in refs | bookmarks tracked per remote | labels are the *remote's* names, read-only locally |
184+| Move a pointer | `git branch -f` / `git push` | `jj bookmark set` / push | `j 'push (label "n" here)'` |
185+| Delete a pointer | `git branch -d` / push --delete | `jj bookmark delete` | `j 'push (unlabel "n")'` |
186+| Rename | `git branch -m` | `jj bookmark rename` | `j 'push (rename "old" "new")'` |
187+| After a rewrite | force-push each branch | bookmarks auto-track (locally) | `j 'push relabel'` |
188+
189+A key difference: in git, branches are *the* way you name commits and they're
190+local until pushed. In jj and `j`, the *change id* is the durable name, and
191+bookmarks/labels are just the remote's view. In `j`, a commit's `labels` are
192+read-only in the language — only `fetch` and `push` change them — which makes
193+the remote's state explicit and impossible to mutate by accident.
194+
195+---
196+
197+## 7. Conflicts
198+
199+| | git | jj | j |
200+|---|---|---|---|
201+| When do conflicts appear? | During merge/rebase, blocking it | Recorded *in* the commit, non-blocking | Recorded in the commit (unresolved `Blob`s) |
202+| Where do they live? | The working tree + index | In the commit itself | In the commit's `files`, as conflict blobs |
203+| How do you see them? | `git status` | `jj st`, `jj resolve` | `j conflicts`, `j status` |
204+| How do you resolve? | Edit files, `git add`, continue | Edit files; conflict is gone from `@` | Edit files, then any persisting expression (`j id`) |
205+
206+git stops the world on a conflict: a merge or rebase halts until you resolve
207+and `git add`. jj and `j` treat a conflict as *data* — a commit can simply
208+*contain* conflicted files, you can keep working, and you resolve whenever you
209+like. `j conflicts` lists the commits that have conflicts; checking one out
210+writes the markers to disk; editing the files and running `j id` records the
211+resolution.
212+
213+---
214+
215+## 8. Undo and recovery
216+
217+| | git | jj | j |
218+|---|---|---|---|
219+| Mechanism | `git reflog` (a log of HEAD moves) | the operation log | the operation log |
220+| Undo last action | `git reset --hard HEAD@{1}` (manual, risky) | `jj undo` | `j undo` |
221+| Redo | find the sha in reflog, reset again | `jj redo` | `j redo` |
222+| Inspect | `git reflog` | `jj op log` | `j ops` |
223+
224+git's reflog is a forensic tool: you can *find* an old state and `git reset`
225+to it, but there's no structured undo. jj and `j` record *every* operation as
226+a first-class, marked operation, so `undo`/`redo` are safe and precise. In
227+`j`, undoing a `push` restores the local labels (and leaves the remote
228+untouched).
229+
230+---
231+
232+## 9. The interface itself
233+
234+This is the deepest difference, and the reason `j` exists.
235+
236+| | git | jj | j |
237+|---|---|---|---|
238+| Interface | ~150 subcommands, hundreds of flags | ~60 subcommands, revset language | **one expression language** |
239+| A command is a… | subcommand + flags | subcommand + revset | a **function `Repo -> Repo`** (an `Edit`) |
240+| Selecting commits | sha, branch, `~`/`^` syntax | revsets (`x-`, `x+`, `::`, `&`) | revsets (functions `Repo -> [Id]`) |
241+| Composition | pipes in the shell | revset operators | **function composition** (`.`) and `or` |
242+| Extensibility | aliases, scripts | aliases, templates | **just write a function** in `config.j` |
243+| Configuration | `git config` key/value | jj config files | `config.j` — the vocabulary itself, in the language |
244+
245+### An illustrative example: "describe every commit in my stack 'wip'"
246+
247+- **git**: no single command; you'd script an interactive rebase.
248+- **jj**: `jj describe -m wip -r '::@ & mutable() & ~trunk()'` (a revset).
249+- **j**: `j 'forEach descendants (describe "wip")'` — `forEach` is an ordinary
250+ function `Revset -> Edit -> Edit`; you could have written it yourself.
251+
252+In git and jj, the set of things you can do is fixed by the subcommands that
253+ship with the tool. In `j`, the vocabulary is written in the language in
254+`config.j`, and you extend it by writing more functions. `squash`, `tree`,
255+`status`, `rebase` are not special — they're definitions you can read and
256+imitate.
257+
258+---
259+
260+## 10. A full workflow, three ways
261+
262+Goal: start a feature, make two commits, fold the second into the first,
263+rebase onto main, and push.
264+
265+**git**
266+```
267+git checkout -b feature
268+# …edit…
269+git add . && git commit -m "part one"
270+# …edit…
271+git add . && git commit -m "part two"
272+git rebase -i HEAD~2 # mark second as 'squash'
273+git rebase main
274+git push --force-with-lease origin feature
275+```
276+
277+**jj**
278+```
279+jj new main
280+# …edit…
281+jj describe -m "part one"
282+jj new
283+# …edit…
284+jj describe -m "part two"
285+jj squash # folds @ into its parent
286+jj rebase -d main
287+jj git push --bookmark feature
288+```
289+
290+**j**
291+```
292+j 'new . goto trunk'
293+# …edit…
294+j 'describe "part one"'
295+j new
296+# …edit…
297+j 'describe "part two"'
298+j squash
299+j 'rebase %main'
300+j 'push (label "feature" here)'
301+```
302+
303+The shapes are visibly the same — start, record, record, squash, rebase, push
304+— because all three are the same underlying DAG work. What changes is the
305+surface: git's flags and an interactive rebase, jj's subcommands, `j`'s
306+composed expressions.
307+
308+---
309+
310+## 11. Cheat sheet
311+
312+Quick mapping for someone coming from git (via jj):
313+
314+| You want to… | git | jj | j |
315+|---|---|---|---|
316+| See status | `git status` | `jj st` | `j status` |
317+| See the graph | `git log --graph --oneline` | `jj log` | `j tree` |
318+| Commit | `git commit` | (automatic) + `jj describe` | (automatic) + `j 'describe "…"'` |
319+| New change | — | `jj new` | `j new` |
320+| Amend message | `git commit --amend` | `jj describe` | `j 'describe "…"'` |
321+| Go to parent | `git checkout HEAD~` | `jj prev` | `j prev` |
322+| Go to main | `git checkout main` | `jj new main` | `j 'new . goto trunk'` |
323+| Squash last commit | `git rebase -i HEAD~2` | `jj squash` | `j squash` |
324+| Split a commit | `git rebase -i` | `jj split` | `j 'split <fileset>'` |
325+| Rebase onto main | `git rebase main` | `jj rebase -d main` | `j 'rebase %main'` |
326+| Cherry-pick | `git cherry-pick X` | `jj duplicate X` | `j 'pick @X'` |
327+| Revert | `git revert X` | `jj revert X` | `j 'backout @X'` |
328+| Delete a commit | `git rebase -i` (drop) | `jj abandon` | `j abandon` |
329+| Tag/bookmark | `git branch X` / `git tag X` | `jj bookmark set X` | `j 'push (label "X" here)'` |
330+| Push | `git push` | `jj git push` | `j push EXPR` |
331+| Undo | `git reset --hard HEAD@{1}` | `jj undo` | `j undo` |
332+| See history of actions | `git reflog` | `jj op log` | `j ops` |
333+| Resolve conflicts | edit, `git add`, continue | edit files | edit files, `j id` |
334+
335+---
336+
337+## 12. When to reach for which
338+
339+- **Pure git** is universal and lowest-common-denominator. Every host, every
340+ CI system, every developer knows it. If you must interoperate with people
341+ who only know git, you can't avoid it — but jj and `j` both sit *on top of*
342+ git repositories, so interop is preserved.
343+- **jj** is for when you want git's ubiquity without git's footguns: no
344+ staging area to manage, safe history editing via change ids, first-class
345+ undo, and non-blocking conflicts. It's a drop-in replacement on a git repo.
346+- **j** is for when you also want the *interface* to be composable and
347+ programmable: when a multi-step operation should be one expression, when
348+ you'd rather write `split (ext "rs")` than drive an interactive rebase, and
349+ when the tool's whole vocabulary being written in its own language appeals
350+ to you.
docs/language.mdnew file+907
@@ -0,0 +1,907 @@
1+# The `j` language
2+
3+`j` is a small, pure, functional language. Every `j` command evaluates one
4+expression in this language against the current repository. The language is
5+the whole interface: there are no subcommands beyond a handful of reserved
6+words, and no flags.
7+
8+This document describes the language itself — its values, syntax, semantics,
9+and the builtins — with examples throughout. Every example shows an expression
10+and what it evaluates to, exactly as `j` prints it.
11+
12+> **A note on the examples.** Where an example evaluates to a `Repo` (the
13+> repository), the output depends on the repository state, so those examples
14+> are described rather than shown literally. Everything else is shown exactly.
15+
16+---
17+
18+## 1. The big picture
19+
20+A run of `j` looks like this:
21+
22+1. Read the expression (from the command line or stdin).
23+2. Snapshot the working directory into the working-copy commit.
24+3. Build a value `repo` describing the repository, focused on the
25+ working-copy commit.
26+4. Evaluate the expression.
27+5. If the result is a function, apply it to `repo`.
28+6. If the final value is a `Repo` that differs from the one loaded, persist
29+ the difference; otherwise print the value and change nothing.
30+
31+So the most common thing you write is an **edit** — a function `Repo -> Repo`
32+(or a value that becomes one). `describe "wip"` is an edit. `new` is an edit.
33+`squash` is an edit. Composing edits with `.` builds bigger edits.
34+
35+The repository is applied **implicitly**: if your expression evaluates to a
36+function, that function is applied to the `Repo`. There is no `repo` variable
37+in scope — write a function (an edit, a revset, or a lambda) and it receives
38+the repository. `length . commits . top` is a function `Repo -> Int`, so it
39+gets applied and prints the count.
40+
41+```
42+j 'describe "wip"' -- set the message of the focused commit
43+j 'new' -- start a new commit on top
44+j 'describe "wip" . new' -- start a new commit, then describe it
45+```
46+
47+A result that is plain data is printed and persists nothing:
48+
49+```
50+j 'length . commits . top' -- how many commits are visible?
51+j 'tree' -- print the history tree (a Text)
52+```
53+
54+---
55+
56+## 2. Values
57+
58+There are eight kinds of value.
59+
60+| kind | example | notes |
61+|---|---|---|
62+| `Int` | `42`, `0 - 7` | arbitrary-precision integer |
63+| `Text` | `"hello"` | Unicode string |
64+| `Bool` | `true`, `false` | |
65+| list | `[1 2 3]` | finite, ordered |
66+| record | `{ a = 1, b = "x" }` | unordered named fields |
67+| function | `\x -> x + 1` | closure, builtin, or partial application |
68+| `Id` | `@wqzt` | a jj change id |
69+| `Blob` | `blob "…"` | the content of one file |
70+| `Shape` | `Int`, `Commit` | a type name used as a value |
71+
72+### Integers
73+
74+Integers are arbitrary precision — they never overflow.
75+
76+```
77+999999999999 * 999999999999
78+ => 999999999998000000000001
79+```
80+
81+There is no unary minus. Write `0 - 5` for negative five. A negative integer
82+renders as `(0 - 5)` so that it round-trips:
83+
84+```
85+0 - 5
86+ => (0 - 5)
87+```
88+
89+### Text
90+
91+Text is a Unicode string in double quotes, with escapes `\"`, `\\`, `\n`,
92+`\t`, `\r`.
93+
94+```
95+"hello world"
96+ => "hello world"
97+"line one\nline two"
98+ => "line one\nline two"
99+```
100+
101+### Booleans
102+
103+`true` and `false`. Conditions in `if`, and the operands of `&&` and `||`,
104+must be `Bool` — anything else is a crash (see §8).
105+
106+### Lists
107+
108+A list is written with square brackets. Elements are separated by
109+**whitespace**, not commas.
110+
111+```
112+[1 2 3]
113+ => [1 2 3]
114+["a" "b" "c"]
115+ => ["a" "b" "c"]
116+[] -- the empty list
117+ => []
118+```
119+
120+Each element is an atom (with optional selectors and updates). An application
121+or operator expression as an element must be parenthesised:
122+
123+```
124+[(1 + 2) (3 * 4)]
125+ => [3 12]
126+[("a" ++ "b") "c"]
127+ => ["ab" "c"]
128+```
129+
130+### Records
131+
132+A record is an unordered set of named fields, written with braces. Fields are
133+separated by commas.
134+
135+```
136+{ name = "j", version = 1 }
137+ => { name = "j", version = 1 }
138+```
139+
140+Records are **structural**: a record is whatever fields it has. There is no
141+record *type* you must declare. Two records are equal if they have the same
142+field names with equal values:
143+
144+```
145+{ a = 1, b = 2 } == { b = 2, a = 1 }
146+ => true
147+```
148+
149+Select a field with `.name`:
150+
151+```
152+{ name = "j", version = 1 }.version
153+ => 1
154+```
155+
156+Update fields with `record { field = value, … }`. Update returns a copy and
157+**never adds fields** — updating a field that isn't there is a crash:
158+
159+```
160+let r = { a = 1, b = 2 } in r { b = 99 }
161+ => { a = 1, b = 99 }
162+```
163+
164+> **The record-literal gotcha.** Postfix selectors and updates bind tighter
165+> than application. So `f r { a = 1 }` means `f (r { a = 1 })` — the braces
166+> attach to `r`, not to `f`. A record *literal* passed as an argument after
167+> another atom must be parenthesised:
168+>
169+> ```
170+> show ({ a = 1 }) -- OK
171+> extract Int ({ a = 1 }) -- OK
172+> (\r -> r.name) ({ name = "j" }) -- OK
173+> ```
174+>
175+> The same applies inside lists: `[{ root = c, children = [] }]` — write the
176+> braces as a parenthesised element `[(…)]` if it follows another element.
177+
178+### Functions
179+
180+A function is made with a lambda: `\x -> body`. Multiple parameters are
181+curried: `\x y -> body` takes two.
182+
183+```
184+(\x -> x + 1) 5
185+ => 6
186+(\x y -> x - y) 10 3
187+ => 7
188+```
189+
190+A lambda with `_` ignores the parameter:
191+
192+```
193+(\_ -> 42) 99
194+ => 42
195+```
196+
197+Functions are first-class: they can be passed, returned, stored in lists, and
198+partially applied.
199+
200+```
201+let inc = (+ 1) in inc 41
202+ => 42
203+map (\x -> x * 2) [1 2 3]
204+ => [2 4 6]
205+```
206+
207+### Ids
208+
209+An `Id` is an opaque jj change id. A literal `@kpqx` (letters `k`–`z`) refers
210+to an existing commit by unique prefix. The bare token `@` mints a fresh,
211+distinct id each time it is evaluated (see §7).
212+
213+### Blobs
214+
215+A `Blob` is the content of one file, either resolved or unresolved (a
216+conflict). You rarely write them directly; you get them from a commit's
217+`files`. `blob "…"` builds a resolved one, `text` reads it back:
218+
219+```
220+text (blob "file content")
221+ => "file content"
222+unresolved (blob "x")
223+ => false
224+```
225+
226+### Shapes
227+
228+A type name used in an expression is a `Shape` value — `Int`, `Text`,
229+`Commit`, and so on. Shapes are mostly used with `extract` (§9.4).
230+
231+---
232+
233+## 3. Definitions and scope
234+
235+### `let`
236+
237+`let` introduces local bindings. Bindings on one line are separated by `;`;
238+across lines they line up by column.
239+
240+```
241+let x = 10 in x * 2
242+ => 20
243+
244+let square = \n -> n * n in square 9
245+ => 81
246+
247+let a = 1; b = 2 in a + b
248+ => 3
249+```
250+
251+All the bindings of one `let` block are **mutually recursive**, so you can
252+write recursive and mutually recursive functions:
253+
254+```
255+let even = \n -> if n == 0 then true else odd (n - 1)
256+ odd = \n -> if n == 0 then false else even (n - 1)
257+in even 10
258+ => true
259+```
260+
261+> **Layout in `let` blocks.** After `let`, the column of the first token opens
262+> the block. A later line whose first token is in *exactly that column* starts
263+> a new binding; a line indented further continues the current one. So the
264+> `even`/`odd` example above works because `odd` lines up with `let`'s first
265+> binding column.
266+
267+Because evaluation is strict (call by value), a non-function binding is
268+evaluated immediately. `let a = b; b = 5 in a` crashes (`b` isn't bound yet
269+when `a` is evaluated); recursion works through *functions*, whose bodies
270+aren't evaluated until applied.
271+
272+### No shadowing
273+
274+A `let` binding or lambda parameter may **not** reuse a name that's already
275+bound in an enclosing scope (including top-level definitions and builtins).
276+This is an error, not silent shadowing:
277+
278+```
279+let x = 1 in let f = \x -> x in f 2 -- parse error: `x` is already bound
280+```
281+
282+### Lexical scope
283+
284+Names resolve lexically: innermost `let` bindings, then lambda parameters,
285+then top-level definitions and builtins. Closures capture their defining
286+environment:
287+
288+```
289+let x = 100 in let f = \y -> x + y in f 5
290+ => 105
291+```
292+
293+Field names are *not* in any namespace — they exist only after `.`, in record
294+literals, and in record updates. So a field can be called `id` even though
295+`id` is the identity function.
296+
297+---
298+
299+## 4. Operators
300+
301+The operator set is fixed — you cannot declare new ones. Precedence and
302+associativity:
303+
304+```
305+infixr 9 . -- composition (tightest)
306+infixl 7 *
307+infixl 6 + -
308+infixr 5 ++ ::
309+infix 4 == /= < <= > >= -- non-associative
310+infixr 3 &&
311+infixr 2 ||
312+infixl 0 or -- loosest
313+```
314+
315+```
316+2 + 3 * 4
317+ => 14 -- * binds tighter than +
318+
319+20 - 5 - 3
320+ => 12 -- - is left-associative
321+
322+1 :: 2 :: []
323+ => [1 2] -- :: is right-associative
324+
325+1 == 1 || 1 == 2 && 1 == 3
326+ => true -- && binds tighter than ||
327+```
328+
329+Non-associative operators (`==`, `<`, …) cannot be chained; use parentheses:
330+
331+```
332+1 < 2 < 3 -- parse error: `<` is non-associative
333+(1 < 2) == true -- OK
334+```
335+
336+### Sections and operators as functions
337+
338+Any operator can be used as a function, or partially applied as a *section*:
339+
340+```
341+(+) -- the addition function
342+(+ 1) 5 -- right section: \x -> x + 1
343+ => 6
344+(1 +) 5 -- left section: \x -> 1 + x
345+ => 6
346+map (2 *) [1 2 3]
347+ => [2 4 6]
348+```
349+
350+### The selector as a function
351+
352+A bare `.name` is the accessor function `\x -> x.name`:
353+
354+```
355+.name ({ name = "j" })
356+ => "j"
357+map (.id) ([{ id = 1 } { id = 2 }])
358+ => [1 2]
359+```
360+
361+(Parenthesise `.id` when it follows a function name, just like a record
362+literal — otherwise the selector attaches to that name.)
363+
364+---
365+
366+## 5. Control flow
367+
368+### `if`
369+
370+```
371+if 1 < 2 then "yes" else "no"
372+ => "yes"
373+```
374+
375+The condition must be a `Bool`. `if 1 then …` is a crash.
376+
377+### `or`: catching crashes
378+
379+`a or b` evaluates `a`. If `a` **crashes**, the crash is discarded and `b` is
380+returned instead. Otherwise `a` is the result (and `b` is not evaluated) —
381+unless `a` is a function, in which case the two are combined pointwise (see
382+below).
383+
384+```
385+head [] or 42
386+ => 42 -- head [] crashes, so 42
387+
388+1 or head []
389+ => 1 -- 1 is fine; head [] never runs
390+
391+"x" or crash "boom"
392+ => "x"
393+```
394+
395+**The function-lifting rule** is what makes `or` useful for edits. A function
396+value cannot crash until it is applied, so `edit or id` would catch nothing if
397+`or` just returned the function. Instead, when `a` is a function, `b` is
398+evaluated too, and if it's also a function the result is `\x -> a x or b x`:
399+
400+```
401+(\x -> x.missing) or (\x -> 0)
402+ => a function that yields 0 for any input
403+
404+-- the classic: try an edit, keep the repository if it fails
405+edit or id
406+```
407+
408+This is the whole error-handling model: there are no exceptions to declare,
409+no `Maybe` — just crash, and catch with `or`.
410+
411+---
412+
413+## 6. Working with lists
414+
415+```
416+head [5 6 7] => 5
417+tail [5 6 7] => [6 7]
418+last [5 6 7] => 7
419+nth 1 [10 20 30] => 20 -- 0-based
420+take 2 [1 2 3 4] => [1 2]
421+drop 2 [1 2 3 4] => [3 4]
422+length [1 2 3] => 3
423+null [] => true
424+member 3 [1 2 3] => true
425+1 :: [2 3] => [1 2 3] -- cons
426+```
427+
428+`take` and `drop` clamp to the list length:
429+
430+```
431+take 99 [1 2] => [1 2]
432+drop 99 [1 2] => []
433+```
434+
435+`head`, `tail`, `last`, and out-of-range `nth` crash on empty/out-of-bounds:
436+
437+```
438+head [] -- CRASH: head: empty list
439+nth 5 [1 2 3] -- CRASH: nth: index 5 out of range
440+```
441+
442+### `map`, `filter`, `foldl`
443+
444+```
445+map (\x -> x * 2) [1 2 3 4]
446+ => [2 4 6 8]
447+
448+filter (\x -> x > 2) [1 2 3 4]
449+ => [3 4]
450+
451+foldl (+) 0 [1 2 3 4 5]
452+ => 15
453+```
454+
455+`foldl f z xs` is a left fold: `foldl f z [x y] = f (f z x) y`, and `z` for
456+the empty list.
457+
458+```
459+foldl (\a -> \b -> a - b) 0 [1 2 3]
460+ => (0 - 6) -- ((0 - 1) - 2) - 3
461+```
462+
463+### `range`
464+
465+```
466+range 1 6 => [1 2 3 4 5] -- [a (a+1) … (b-1)]
467+range 5 1 => [] -- empty if b <= a
468+```
469+
470+### `concat` and `++`
471+
472+These two dispatch on the kind of the argument — they work on lists *and* on
473+text.
474+
475+```
476+[1 2] ++ [3 4] => [1 2 3 4] -- list concatenation
477+"foo" ++ "bar" => "foobar" -- text concatenation
478+
479+concat [[1 2] [3] []] => [1 2 3] -- flatten [[a]] -> [a]
480+concat ["foo" "bar"] => "foobar" -- join [Text] -> Text
481+```
482+
483+`concat []` crashes — there is nothing to dispatch on.
484+
485+---
486+
487+## 7. Working with text
488+
489+```
490+splitOn "," "a,b,c" => ["a" "b" "c"]
491+splitOn "/" "src/lexer.rs" => ["src" "lexer.rs"]
492+splitOn "/" "" => [""]
493+startsWith "he" "hello" => true
494+endsWith "rs" "lexer.rs" => true
495+```
496+
497+`splitOn` with an empty separator crashes.
498+
499+---
500+
501+## 8. Equality, comparison, and crashes
502+
503+`==` is structural on ints, texts, bools, lists, and records; identity on
504+`Id`; content-and-mode on `Blob`. `/=` is its negation.
505+
506+```
507+2 == 2 => true
508+"a" == "a" => true
509+[1 2] == [1 2] => true
510+{ a = 1 } == { a = 1 } => true
511+```
512+
513+Comparing two **functions** crashes — there is no meaningful equality on
514+closures.
515+
516+Ordering operators (`<`, `<=`, `>`, `>=`) are defined on `Int` only and crash
517+otherwise.
518+
519+### Crashes
520+
521+`crash msg` aborts evaluation with `msg`. Every runtime error the interpreter
522+detects is also a crash: unbound name, wrong kind of value, missing field,
523+out-of-range index, `head []`, comparing functions, a non-`Bool` condition,
524+arity/kind errors in builtins. All of these are catchable with `or`.
525+
526+```
527+crash "my message"
528+ -- CRASH: my message
529+
530+(crash "boom") or "recovered"
531+ => "recovered"
532+```
533+
534+Parse errors, configuration errors, and `Id`-resolution failures happen
535+*before* evaluation and are **not** catchable.
536+
537+---
538+
539+## 9. Functions in depth
540+
541+### Currying and partial application
542+
543+Every function is curried. Applying a function to fewer arguments than it
544+takes yields a partial application, which is itself a value:
545+
546+```
547+let add = \x y -> x + y in (add 3) 4
548+ => 7
549+
550+map (nth 0) [["a" "b"] ["c" "d"]]
551+ => ["a" "c"]
552+```
553+
554+### Composition
555+
556+`.` composes two functions: `(f . g) x = f (g x)`. It is right-associative
557+and binds very tightly.
558+
559+```
560+((\x -> x + 1) . (\x -> x * 2)) 5
561+ => 11 -- (5 * 2) + 1
562+
563+((\x -> x * 2) . (\x -> x + 1)) 5
564+ => 12 -- (5 + 1) * 2
565+```
566+
567+Composition is how edits are chained. `describe "wip" . new` first makes a
568+new commit, then describes it.
569+
570+```
571+let compose = \f g -> \x -> f (g x) -- `.` is just this, built in
572+```
573+
574+### `id` and `const`
575+
576+```
577+id 42 => 42
578+(id . id) 7 => 7
579+const 5 99 => 5 -- const a b = a
580+```
581+
582+`id` is the identity edit — `edit or id` falls back to it. `const` ignores its
583+second argument.
584+
585+---
586+
587+## 10. Ids, blobs, and shapes
588+
589+### Existing ids: `@prefix`
590+
591+`@wqzt` refers to the commit whose change id begins with `wqzt`, resolved
592+*before* evaluation. If the prefix matches no commit, or more than one, the
593+program stops with an error listing the candidates — nothing is evaluated.
594+
595+```
596+by @wqzt -- an edit: refocus the repository on that commit
597+```
598+
599+After resolution the literal is an ordinary full `Id`, so `==` on ids is pure
600+identity.
601+
602+### New ids: `@`
603+
604+The bare token `@` mints a fresh, distinct `Id` every time it is evaluated.
605+In a lambda body (evaluated per application) each call mints a new id; that is
606+how `newCommit` gives each new commit a unique id.
607+
608+### Blobs and `unresolved`
609+
610+```
611+blob "content" -- a resolved regular-file blob
612+text (blob "content") => "content"
613+unresolved (blob "x") => false
614+```
615+
616+`unresolved b` is `true` iff the blob is a conflict (produced by a clashing
617+`replay`). `text` on an unresolved blob yields its conflict-marker rendering.
618+
619+### Shapes and `extract`
620+
621+A type name in an expression is a `Shape`. `extract S v` returns every
622+subvalue of `v` that is an `S`, walking lists in order and records in
623+ascending field-name order, descending into elements and fields (but not into
624+functions or blobs).
625+
626+```
627+extract Int ({ a = 1, b = 2 })
628+ => [1 2]
629+
630+extract Int ({ a = 1, b = [2 "x" ({ c = 3 })] })
631+ => [1 2 3]
632+
633+extract Text ({ a = "x", b = [1 "y"] })
634+ => ["x" "y"]
635+```
636+
637+A record is an `S` if its field set is *exactly* `S`'s. This is how
638+`extract Commit repo` finds every commit in a repository, however deeply
639+nested.
640+
641+---
642+
643+## 11. Contracts
644+
645+A function that has a **signature** (a builtin, or a definition with one in
646+`config.j`) is checked on every application: each argument against its
647+parameter type as it is supplied, and the final result against the result
648+type.
649+
650+The check is one level deep — it never inspects inside a list or a record's
651+field values (that would make every call cost the size of the value).
652+
653+| declared type | check |
654+|---|---|
655+| `Int`, `Text`, `Bool`, `Id`, `Blob`, `Shape` | the value is of that kind |
656+| `[T]` | the value is a list (elements not inspected) |
657+| `{ a : T, … }` or a shape name | a record with exactly those field names |
658+| `A -> B` | the value is a function |
659+| a type variable | anything |
660+| an alias (`Edit`, `Revset`, `Snapshot`) | what it stands for; function aliases are unfolded |
661+
662+A violation is a crash, catchable by `or`:
663+
664+```
665+describe 3
666+ -- CRASH: contract: describe expected Text as argument 1, got Int
667+
668+nth "x" [1]
669+ -- CRASH: contract: nth expected Int as argument 1, got Text
670+```
671+
672+Lambdas without a signature, and the command-line expression itself, are
673+unchecked except through the functions they call.
674+
675+---
676+
677+## 12. `show`: rendering values as text
678+
679+`show v` renders any value as an expression in the language, such that
680+(except for closures and unresolved blobs) the result parses and evaluates
681+back to an equal value. Since `show` returns a `Text`, and a top-level `Text`
682+prints raw, `j show …` is how you inspect values.
683+
684+```
685+show 42 => "42"
686+show "hi" => "\"hi\""
687+show [1 "a" true] => "[1 \"a\" true]"
688+show ({ x = 1 }) => "{ x = 1 }"
689+show (0 - 5) => "(0 - 5)"
690+show [[1 2] [3]] => "[[1 2] [3]]"
691+```
692+
693+Functions render readably:
694+
695+```
696+show (map) => "map"
697+show (+) => "(+)"
698+show (describe "wip") => "describe \"wip\""
699+show (\x -> x + 1) => "\x -> (x + 1)"
700+```
701+
702+Output longer than 80 columns breaks across lines (one element per line,
703+two-space indented, leading commas for records) — and still round-trips.
704+
705+---
706+
707+## 13. The `Repo` value and navigation
708+
709+The repository appears as a value of shape:
710+
711+```
712+Repo = { root : Commit, children : [Subtree], context : [Frame] }
713+Commit = { files : Snapshot, message : Text, labels : [Text], id : Id }
714+Snapshot = [Entry]
715+Entry = { path : Path, content : Blob }
716+Subtree = { root : Commit, children : [Subtree] }
717+```
718+
719+`Repo` is a **zipper**: `root` is the focused commit, `children` its children,
720+and `context` the path back up to the top of history. You almost never build
721+one by hand; you navigate and edit the one you're given.
722+
723+The reference `config.j` defines the whole vocabulary on top of this. A tour:
724+
725+```
726+here -- the focused commit's id, as a Revset
727+top -- refocus at the top of history
728+tip -- refocus at the tip
729+prev -- to the parent (crashes at the top)
730+next -- to the only child (crashes if not exactly one)
731+up -- like prev, the raw zipper move
732+by @wqzt -- refocus on a specific commit
733+goto %main -- refocus on what the remote calls main
734+parents -- the focus's parents, as a Revset
735+kids -- its children
736+ancestors -- it and its ancestors
737+descendants -- it and its descendants
738+siblings -- the other children of its parent
739+all -- every visible commit
740+```
741+
742+Edits — functions `Repo -> Repo`:
743+
744+```
745+new -- a new empty child of the focus
746+describe "wip" -- set the focus's message
747+squash -- fold the focus into its parent
748+abandon -- drop the focus; children join the parent
749+split (\p -> p == ./a.txt) -- split the focus by a fileset
750+rebase %main -- move the focused subtree onto main
751+pick @wqzt -- apply a commit's change elsewhere as a new child
752+backout @wqzt -- a new child undoing a commit's change
753+contract everything -- collapse a subtree (see config.j)
754+```
755+
756+And the inspections:
757+
758+```
759+tree -- the history as a tree (a Text)
760+status -- { id, message, labels, changed, conflicts }
761+review -- the focus's changes as one page of diffs
762+files -- the focus's snapshot (its files)
763+commits . top -- every commit, preorder
764+```
765+
766+### A realistic session
767+
768+```
769+-- start some work
770+j 'new'
771+-- (edit files in your editor)
772+j 'describe "add the lexer"'
773+
774+-- more work on top
775+j 'new'
776+-- (edit more files)
777+j 'describe "add the parser"'
778+
779+-- look at what you have
780+j 'tree'
781+
782+-- the second commit was a mistake; fold it into the first
783+j 'squash'
784+
785+-- rename the combined commit
786+j 'describe "add the front end"'
787+
788+-- undo the squash entirely
789+j undo
790+
791+-- move back to the first commit to fix something
792+j 'prev'
793+```
794+
795+Because edits compose with `.`, multi-step operations are single expressions:
796+
797+```
798+-- start a new commit on top of main and describe it, in one step
799+j 'describe "hotfix" . new . (goto %main)'
800+
801+-- describe every commit below the focus
802+j 'forEach descendants (describe "wip")'
803+```
804+
805+---
806+
807+## 14. `diff`, `difft`, and `treeWith`
808+
809+`diff a b` produces a unified diff (three lines of context, no headers)
810+between two blobs' text:
811+
812+```
813+diff (blob "a\nb\n") (blob "a\nc\n")
814+ => "@@ -1,2 +1,2 @@\n a\n-b\n+c\n"
815+```
816+
817+`difft p a b` renders the comparison with **difftastic** (which must be on
818+`PATH`; the Nix package provides it). `review` uses `difft` per changed path.
819+
820+`treeWith options repo` renders the history tree with an options record:
821+
822+```
823+treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
824+```
825+
826+The reference config gives two ready-made versions: `tree` (compact) and
827+`treeFull` (with ages and authors in the margin).
828+
829+---
830+
831+## 15. Writing your own definitions
832+
833+Everything `j` knows is defined in `config.j` — builtins are *declared* there,
834+and the whole vocabulary (`new`, `describe`, `squash`, `tree`, …) is *written*
835+there in the language. You can add your own. Any definition whose value is an
836+`Edit` is a new command.
837+
838+```
839+-- in config.j:
840+
841+-- squash the whole stack into one commit called "release"
842+release : Edit
843+release = describe "release" . (contract everything)
844+
845+-- count the commits ahead of main
846+ahead : Repo -> Int
847+ahead = \repo -> length (descendants repo) - 1
848+```
849+
850+Then:
851+
852+```
853+j release
854+j 'show ahead'
855+```
856+
857+A definition with a signature is contract-checked (§11); one without is not
858+(except through what it calls). A signature with no definition declares a
859+builtin that `j` implements.
860+
861+---
862+
863+## 16. Laws worth knowing
864+
865+These identities hold for the reference `config.j` and are a good mental model
866+(`=` means "equal results, or both crash"):
867+
868+```
869+prev (next r) = r (when r has exactly one child)
870+next (prev r) = r (when r is an only child)
871+top (top r) = top r
872+(abandon . new) r = r (up to the new focus's minted id)
873+rebase parents r = r
874+goto here r = r
875+at rs id r = r
876+(e or id) r = r (when e r crashes)
877+edit or id -- the universal fallback
878+replay b { from = b, to = x } = x
879+invert (invert ch) = ch
880+concat [xs] = xs
881+```
882+
883+They capture the intent: navigation is reversible where it's defined, `id` is
884+a neutral edit, `or` recovers from failure, and `replay`/`invert` behave like
885+the algebra they are.
886+
887+---
888+
889+## 17. Quick reference
890+
891+**Special forms:** `if … then … else …`, `let … in …`, `\x -> …`, `or`.
892+
893+**Operators:** `.` `*` `+` `-` `++` `::` `==` `/=` `<` `<=` `>` `>=` `&&`
894+`||`, and sections `(op e)` / `(e op)` / `(op)`.
895+
896+**Core builtins:** `id` `const` `crash` `show` `not` `.`
897+
898+**Lists:** `map` `filter` `foldl` `head` `tail` `last` `nth` `take` `drop`
899+`length` `null` `member` `range` `::` `concat` `++`
900+
901+**Text:** `splitOn` `startsWith` `endsWith` `concat` `++`
902+
903+**Blobs/repos:** `blob` `text` `unresolved` `replay` `by` `meta` `extract`
904+`diff` `difft` `treeWith` `validate`
905+
906+**Reserved commands** (not expressions): `init` `clone URL [DIR]` `remote URL`
907+`fetch` `push EXPR` `undo` `redo` `ops`.
docs/tutorial.mdnew file+472
@@ -0,0 +1,472 @@
1+# A tutorial on `j`
2+
3+This is a guided first hour with `j`. It builds the mental model piece by
4+piece — not just *what* to type, but *why it works* — so that by the end you
5+can compose your own commands instead of memorizing them.
6+
7+You'll want `j` installed (`nix build` or `cargo build --release`) and a
8+`config.j` with your identity (see the README's Configuration section). Every
9+command shown here is run in a real repository, so you can follow along.
10+
11+---
12+
13+## Part 1 — Ten minutes to a working repository
14+
15+Make an empty directory and create a repository:
16+
17+```
18+mkdir play && cd play
19+j init
20+```
21+
22+`j init` makes a colocated `.jj`/`.git` repository and checks out a fresh
23+working-copy commit. Now write a file and start your first change:
24+
25+```
26+echo 'fn main() {}' > main.rs
27+j 'new'
28+j 'describe "start the project"'
29+```
30+
31+Look at what you have:
32+
33+```
34+j 'tree'
35+```
36+
37+```
38+ ⌂ zzzz
39+ └─ ● pnyz ▂
40+▶ └─ ◌ psnq start the project
41+```
42+
43+Add a second change on top:
44+
45+```
46+echo 'lib' > lib.rs
47+j 'new'
48+j 'describe "add a library"'
49+j 'tree'
50+```
51+
52+```
53+ ⌂ zzzz
54+ └─ ● pnyz ▂
55+ └─ ● psnq ▂ start the project
56+▶ └─ ◌ trtq add a library
57+```
58+
59+That's the whole basic loop: **edit files, `new`, `describe`.** There is no
60+`git add` step — the working directory is always already a commit, and `j`
61+snapshots it on every run.
62+
63+**What just happened, in the model:** each `j` run snapshots your working
64+directory into the working-copy commit, builds a `Repo` value focused there,
65+evaluates your expression, and — because `new` and `describe` are *edits*
66+(functions `Repo -> Repo`) — persists the resulting repository.
67+
68+---
69+
70+## Part 2 — The one idea everything rests on
71+
72+Here is the single most important thing to understand about `j`:
73+
74+> **If your expression evaluates to a function, that function is applied to
75+> the repository. There is no `repo` variable — the repository is the implicit
76+> argument.**
77+
78+`new`, `describe`, `prev`, `squash` are all functions `Repo -> Repo` (called
79+**edits**). When you run `j squash`, you're not invoking a subcommand; you're
80+evaluating the name `squash` to a function, which `j` then applies to your
81+repository.
82+
83+This is why composition works. `.` is ordinary function composition, `(f . g)
84+x = f (g x)` — **right to left**:
85+
86+```
87+j 'describe "wip" . new'
88+```
89+
90+reads as "first `new`, then `describe`," because `(describe "wip" . new) repo =
91+describe "wip" (new repo)`. Run it and you've opened a new commit and set its
92+message, in one expression, as one recorded operation.
93+
94+And it's why *inspections* are just functions too. `length . commits . top`
95+composes three functions into one `Repo -> Int`:
96+
97+```
98+j 'length . commits . top'
99+ => 5
100+```
101+
102+- `top` moves the focus to the top of history,
103+- `commits` lists every commit of the (sub)tree,
104+- `length` counts them.
105+
106+Since the result is an `Int` (not a `Repo`), `j` prints it and **persists
107+nothing**. That's the rule: a `Repo` result persists; anything else prints.
108+
109+> **Two outcomes.** A run either **persists** (the result is a `Repo` that
110+> differs from the loaded one) or **prints** (any other value). Edits persist;
111+> inspections print.
112+
113+### A subtlety that trips people up
114+
115+`j 'show (length . commits . top)'` does *not* print `5`. It prints the
116+function:
117+
118+```
119+(.) ((length)) (((.) ((commits)) ((top))))
120+```
121+
122+Why? `show (...)` applies `show` to the *function value* `length . commits .
123+top`, producing a `Text` *without ever applying it to the repository*. To get
124+the count, you want the composition itself applied: `length . commits . top`
125+(with no `show`). The result is a function `Repo -> Int`, `j` applies it, and
126+prints `5`. `show` is for rendering a *value*; here you want the value the
127+function produces, so don't wrap it.
128+
129+---
130+
131+## Part 3 — Reading and moving around
132+
133+Make a few commits to play with:
134+
135+```
136+echo a > a.txt; j 'new'; j 'describe "A"'
137+echo b > b.txt; j 'new'; j 'describe "B"'
138+echo c > c.txt; j 'new'; j 'describe "C"'
139+```
140+
141+Navigation is edits too:
142+
143+```
144+j 'prev' -- to the parent
145+j 'next' -- to the only child
146+j 'top' -- to the top of history
147+j 'tip' -- to the end of the current line
148+j 'by @vqok' -- to a specific commit, by id (use a real id from j tree)
149+j 'goto %main' -- to what the remote calls main
150+```
151+
152+After `j 'prev'` from C, the tree shows the focus (▶ and ◉) on B:
153+
154+```
155+ ⌂ zzzz
156+ └─ ● qmwp ▂
157+ └─ ● nnvs ▂ A
158+▶ └─ ◉ vqok ▂ B
159+ └─ ◌ lttm C
160+```
161+
162+To see the focus's data, use the inspection functions:
163+
164+```
165+j 'files' -- the focus's files
166+j 'changed' -- paths that differ from the parent
167+j 'status' -- id, message, labels, changed, conflicts
168+j 'log' -- a table of every commit, ✓ on the focus
169+j 'review' -- the focus's changes as diffs (difftastic)
170+```
171+
172+### Composition order, made concrete
173+
174+```
175+j 'describe "edited two back" . prev'
176+```
177+
178+`prev` runs *first* (right-to-left), then `describe`. From C, this moves to B
179+and renames **B**. The focus ends on B:
180+
181+```
182+ ⌂ zzzz
183+ └─ ● qmwp ▂
184+ └─ ● nnvs ▂ A
185+▶ └─ ◉ vqok ▂ edited two back
186+ └─ ◌ lttm C
187+```
188+
189+If you meant "rename C," that's just `j 'describe "edited two back"'` — no
190+`prev`. The lesson: read `.` chains from right to left, and remember each step
191+operates on the repository the previous step produced.
192+
193+---
194+
195+## Part 4 — Dry runs: seeing before doing
196+
197+Because inspections persist nothing, you can preview any edit by composing it
198+with `tree`:
199+
200+```
201+j 'tree . squash'
202+```
203+
204+From the A → B history, this *shows* the tree as `squash` would leave it (B
205+folded into A, its message gone):
206+
207+```
208+ ⌂ zzzz
209+ └─ ● krly ▂
210+ └─ ● xspk ▂ A
211+▶ └─ ◌ zkyl
212+```
213+
214+But run `j 'tree'` right after and B is still there — **nothing persisted**,
215+because `tree` returns a `Text`, not a `Repo`:
216+
217+```
218+ ⌂ zzzz
219+ └─ ● krly ▂
220+ └─ ● xspk ▂ A
221+▶ └─ ◌ nknz B
222+```
223+
224+For a stronger guarantee, insert `validate`. `validate repo` returns the repo
225+unchanged if persisting would succeed, and crashes with the exact error
226+otherwise:
227+
228+```
229+j 'tree . validate . rebase %main'
230+```
231+
232+This renders the post-rebase tree **and** crashes (without touching anything)
233+if that rebase couldn't actually happen — for instance, if `%main` doesn't
234+exist yet. That's an *honest* dry run.
235+
236+---
237+
238+## Part 5 — `or`: the whole error-handling model
239+
240+There are no exceptions to declare and no `Maybe`. A failing operation
241+**crashes**, and `or` catches it:
242+
243+```
244+head [] or 42 => 42
245+edit or id -- run edit; if it crashes, run id (do nothing)
246+```
247+
248+`id` is the identity edit, so `edit or id` means "try the edit, keep the
249+repository if it fails."
250+
251+The reason this works for edits is **lifting**. A function value can't crash
252+until it's applied, so `squash or id` would catch nothing if `or` simply
253+returned the function. Instead, when both sides are functions, `or` builds a
254+new one pointwise: `(f or g) x = f x or g x`. So:
255+
256+```
257+j 'squash or id'
258+```
259+
260+actually runs `squash` on your repository, and only if that crashes does it
261+fall back to `id`. Try it on the A → B history: B gets folded into A, no crash,
262+so `id` never runs.
263+
264+A practical use: "rebase onto main if there is a main, otherwise leave things
265+alone":
266+
267+```
268+j 'rebase %main or id'
269+```
270+
271+---
272+
273+## Part 6 — Editing history for real
274+
275+Now that you can navigate, dry-run, and recover, the editing commands are just
276+edits with clear semantics:
277+
278+```
279+j 'describe "…"' -- set the focus's message
280+j squash -- fold the focus into its parent
281+j abandon -- drop the focus; children join the parent
282+j 'split (ext "rs")' -- push the .rs files up into a new child commit
283+j 'contract everything' -- push all of the focus's files into the parent
284+j 'rebase %main' -- move the focused subtree onto main
285+j 'pick @wqzt' -- copy @wqzt's change here as a new commit
286+j 'backout @wqzt' -- a new commit undoing @wqzt's change
287+```
288+
289+Two things make these safe to use freely:
290+
291+1. **The change id survives.** A commit keeps its `@wqzt…` id across
292+ `describe`, `squash`, `rebase`. You're never tracking a moving sha.
293+2. **`j undo` always works.** Every operation is recorded.
294+
295+```
296+j undo -- revert the last thing
297+j redo -- reapply it
298+j ops -- see the operation log
299+```
300+
301+### `split` and `contract`: moving changes by fileset
302+
303+A **fileset** is a function `Path -> Bool`. These two are inverses:
304+
305+- `split m` pushes the files matching `m` **up** into a new child commit.
306+- `contract m` pushes the files matching `m` **down** into the parent.
307+
308+Suppose one commit touches `a.rs` and `b.j`, and you want the Rust change on
309+its own:
310+
311+```
312+j 'split (ext "rs")'
313+```
314+
315+The `.rs` change becomes a separate child commit (now the focus); the `.j`
316+change stays in the original. `squash` is defined in terms of these:
317+`squash = abandon . contract everything`.
318+
319+Filesets compose: `under ./src`, `ext "rs"`, `both`, `either`, `neg`,
320+`everything`. So `split (both (under ./src) (ext "rs"))` splits off only the
321+Rust files under `src/`.
322+
323+---
324+
325+## Part 7 — Revsets: naming sets of commits
326+
327+A **revset** is a function `Repo -> [Id]` — a *set* of commits computed from
328+the repository. You've been using them: `parents`, `kids`, `%main`.
329+
330+```
331+here -- the focused commit
332+parents -- its parents
333+kids -- its children
334+ancestors -- it and everything above
335+descendants -- it and everything below
336+all -- every visible commit
337+trunk -- the main line (%main, %master, or %trunk)
338+```
339+
340+Most commands that take a "which commits" argument take a revset. `goto` is
341+the one place a revset must name *exactly one* commit:
342+
343+```
344+j 'goto %main' -- crash if %main names 0 or 2+ commits
345+```
346+
347+Revsets combine like sets:
348+
349+```
350+j 'matching (\c -> c.message == "wip") all'
351+```
352+
353+`matching p rs` keeps the commits of `rs` satisfying predicate `p` — here,
354+every commit whose message is `wip`. The result is a list of commits, so it
355+prints (persisting nothing).
356+
357+---
358+
359+## Part 8 — Batch operations with `at` and `forEach`
360+
361+`at rs e` runs edit `e` at the commit revset `rs` names, then returns the
362+focus to where it was (by id, so `e` may move things):
363+
364+```
365+j 'at parents (describe "parent edit")'
366+```
367+
368+This renamed the *parent* while leaving you focused where you were — the tree
369+shows the parent with its new message and the focus marker still on your
370+original commit.
371+
372+`forEach rs e` runs `e` at *every* commit of `rs`:
373+
374+```
375+j 'forEach descendants (describe "wip")'
376+```
377+
378+Every commit below the focus is now described `wip`. `forEach` is not special
379+— it's an ordinary function `Revset -> Edit -> Edit` defined in `config.j`,
380+and you could have written it yourself.
381+
382+---
383+
384+## Part 9 — Making it yours: writing a command
385+
386+Everything `j` knows is defined in `config.j`. Any definition whose value is
387+an `Edit` is a new command. Add this to your `~/.config/j/config.j`:
388+
389+```
390+-- fold the whole stack into one commit called "release"
391+release : Edit
392+release = describe "release" . (contract everything)
393+```
394+
395+Now `j release` works exactly like `squash` or `new`. The signature (`: Edit`)
396+is optional but recommended — it makes `j` check the definition's arguments
397+and result at each call.
398+
399+A revset example — "commits ahead of trunk":
400+
401+```
402+ahead : Revset
403+ahead = minus descendants trunk
404+```
405+
406+Then `j 'length . ahead'` prints how many commits are on your branch but not
407+the main line.
408+
409+This is the payoff of the whole design: the vocabulary is not a fixed list of
410+subcommands. It's functions, in a file you can read and extend. `squash`,
411+`tree`, `status`, `rebase` are definitions, not builtins.
412+
413+---
414+
415+## Part 10 — A realistic session, start to finish
416+
417+Putting it all together. Start a feature on top of main, commit in two parts,
418+tidy the history, and push.
419+
420+This session assumes you have a remote with a `main` bookmark (so `trunk`
421+resolves to something) — for example after `j clone`, or `j remote URL`
422+followed by `j fetch`. `goto trunk` crashes if there's no main line yet;
423+`new . goto trunk` is how you start *on top of* it (trunk is immutable, so you
424+always open a new commit rather than focusing it directly).
425+
426+```
427+# start on the main line
428+j 'new . goto trunk'
429+
430+# first part
431+echo 'fn parse() {}' > parser.rs
432+j 'describe "add the parser"'
433+
434+# second part
435+echo 'mod parser;' > lib.rs
436+j 'new'
437+j 'describe "wire up the parser"'
438+
439+# look at it
440+j 'tree'
441+
442+# the second commit was really part of the first; fold it in
443+j squash
444+
445+# rename the combined commit
446+j 'describe "add the parser"'
447+
448+# preview the rebase, then do it
449+j 'tree . validate . rebase %main'
450+j 'rebase %main'
451+
452+# share it
453+j 'push (label "parser" here)'
454+```
455+
456+And if any step goes wrong:
457+
458+```
459+j undo
460+```
461+
462+---
463+
464+## Where next
465+
466+- **[docs/language.md](language.md)** — the language in full: values, syntax,
467+ semantics, contracts, `show`.
468+- **[docs/base.md](base.md)** — every builtin and `config.j` definition, with
469+ examples and an alphabetical index.
470+- **[docs/comparison.md](comparison.md)** — how `j` maps onto git and jj, if
471+ you're coming from either.
472+- **[config.j](../config.j)** — the reference vocabulary, to read and imitate.
flake.locknew file+61
@@ -0,0 +1,61 @@
1+{
2+ "nodes": {
3+ "flake-utils": {
4+ "inputs": {
5+ "systems": "systems"
6+ },
7+ "locked": {
8+ "lastModified": 1731533236,
9+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
10+ "owner": "numtide",
11+ "repo": "flake-utils",
12+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
13+ "type": "github"
14+ },
15+ "original": {
16+ "owner": "numtide",
17+ "repo": "flake-utils",
18+ "type": "github"
19+ }
20+ },
21+ "nixpkgs": {
22+ "locked": {
23+ "lastModified": 1789286504,
24+ "narHash": "sha256-eiEK7cKZORNEvX0GeF3RtNEF/JXhgf2RqSp3230q13E=",
25+ "owner": "NixOS",
26+ "repo": "nixpkgs",
27+ "rev": "ef34387ddd751e1ab8857adf4676492d32eb24ec",
28+ "type": "github"
29+ },
30+ "original": {
31+ "owner": "NixOS",
32+ "ref": "nixos-unstable",
33+ "repo": "nixpkgs",
34+ "type": "github"
35+ }
36+ },
37+ "root": {
38+ "inputs": {
39+ "flake-utils": "flake-utils",
40+ "nixpkgs": "nixpkgs"
41+ }
42+ },
43+ "systems": {
44+ "locked": {
45+ "lastModified": 1681028828,
46+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
47+ "owner": "nix-systems",
48+ "repo": "default",
49+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
50+ "type": "github"
51+ },
52+ "original": {
53+ "owner": "nix-systems",
54+ "repo": "default",
55+ "type": "github"
56+ }
57+ }
58+ },
59+ "root": "root",
60+ "version": 7
61+}
flake.nixnew file+62
@@ -0,0 +1,62 @@
1+{
2+ description = "j — a functional-programming-centered CLI for Jujutsu repositories";
3+
4+ inputs = {
5+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
6+ flake-utils.url = "github:numtide/flake-utils";
7+ };
8+
9+ outputs = { self, nixpkgs, flake-utils }:
10+ flake-utils.lib.eachDefaultSystem (system:
11+ let
12+ pkgs = import nixpkgs { inherit system; };
13+ in
14+ {
15+ packages = {
16+ j = pkgs.rustPlatform.buildRustPackage {
17+ pname = "j";
18+ version = "0.1.0";
19+ src = ./.;
20+
21+ cargoLock = {
22+ lockFile = ./Cargo.lock;
23+ };
24+
25+ nativeBuildInputs = [ pkgs.makeWrapper ];
26+
27+ # The test suite shells out to git and expects a writable temp dir
28+ # and network-free local remotes; it is not meant to run inside the
29+ # Nix build sandbox.
30+ doCheck = false;
31+
32+ # The reference config.j ships with the binary; `j` looks for it at
33+ # <prefix>/share/j/config.j relative to its own executable.
34+ postInstall = ''
35+ install -Dm644 config.j $out/share/j/config.j
36+
37+ # difft is a runtime dependency (§7.10): make sure `j` always
38+ # finds difftastic on PATH, without requiring it globally.
39+ wrapProgram $out/bin/j \
40+ --prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.difftastic ]}
41+ '';
42+
43+ meta = with pkgs.lib; {
44+ description = "A functional-programming-centered CLI for Jujutsu repositories";
45+ mainProgram = "j";
46+ license = licenses.mit;
47+ platforms = platforms.unix;
48+ };
49+ };
50+
51+ default = self.packages.${system}.j;
52+ };
53+
54+ apps = {
55+ j = {
56+ type = "app";
57+ program = "${self.packages.${system}.j}/bin/j";
58+ };
59+ default = self.apps.${system}.j;
60+ };
61+ });
62+}
src/ast.rsnew file+129
@@ -0,0 +1,129 @@
1+//! AST for the j language (§3.4).
2+
3+use num_bigint::BigInt;
4+use std::rc::Rc;
5+
6+#[derive(Debug, Clone, PartialEq)]
7+pub enum TypeExpr {
8+ Con(String), // type name or variable
9+ List(Rc<TypeExpr>),
10+ Record(Vec<(String, Rc<TypeExpr>)>),
11+ Fun(Rc<TypeExpr>, Rc<TypeExpr>),
12+}
13+
14+#[derive(Debug, Clone)]
15+pub enum Pattern {
16+ Var(String),
17+ Wildcard,
18+}
19+
20+#[derive(Debug, Clone)]
21+pub enum Expr {
22+ Var(String),
23+ TypeName(String),
24+ Int(BigInt),
25+ Text(String),
26+ IdLit(String), // unresolved prefix text; resolved to Expr::Id before eval
27+ Id(String), // resolved full id
28+ NewId,
29+ LabelLit(String),
30+ PathLit(Vec<String>),
31+ Bool(bool),
32+ Lambda(Vec<Pattern>, Rc<Expr>, String), // params, body, source text
33+ If(Rc<Expr>, Rc<Expr>, Rc<Expr>),
34+ Let(Vec<(String, Rc<Expr>)>, Rc<Expr>),
35+ App(Rc<Expr>, Rc<Expr>),
36+ BinOp(&'static str, Rc<Expr>, Rc<Expr>),
37+ Or(Rc<Expr>, Rc<Expr>),
38+ Select(Rc<Expr>, String),
39+ Update(Rc<Expr>, Vec<(String, Rc<Expr>)>),
40+ Record(Vec<(String, Rc<Expr>)>),
41+ List(Vec<Rc<Expr>>),
42+ SelectorFun(String),
43+ /// an explicitly parenthesised expression (for non-assoc chain checks)
44+ Paren(Rc<Expr>),
45+ Crash, // internal marker if needed
46+}
47+
48+#[derive(Debug, Clone)]
49+pub enum Item {
50+ TypeDecl(String, TypeExpr),
51+ Signature(String, TypeExpr, usize), // name, type, line
52+ Definition(String, Rc<Expr>, usize),
53+}
54+
55+impl Expr {
56+ /// Collect free variables of an expression (names not bound within it).
57+ pub fn free_vars(&self, bound: &mut Vec<String>, out: &mut std::collections::BTreeSet<String>) {
58+ match self {
59+ Expr::Var(n) => {
60+ if !bound.iter().any(|b| b == n) {
61+ out.insert(n.clone());
62+ }
63+ }
64+ Expr::TypeName(_)
65+ | Expr::Int(_)
66+ | Expr::Text(_)
67+ | Expr::IdLit(_)
68+ | Expr::Id(_)
69+ | Expr::NewId
70+ | Expr::LabelLit(_)
71+ | Expr::PathLit(_)
72+ | Expr::Bool(_)
73+ | Expr::SelectorFun(_)
74+ | Expr::Crash => {}
75+ Expr::Paren(e) => e.free_vars(bound, out),
76+ Expr::Lambda(ps, body, _) => {
77+ let start = bound.len();
78+ for p in ps {
79+ if let Pattern::Var(n) = p {
80+ bound.push(n.clone());
81+ }
82+ }
83+ body.free_vars(bound, out);
84+ bound.truncate(start);
85+ }
86+ Expr::If(a, b, c) => {
87+ a.free_vars(bound, out);
88+ b.free_vars(bound, out);
89+ c.free_vars(bound, out);
90+ }
91+ Expr::Let(bs, body) => {
92+ let start = bound.len();
93+ for (n, _) in bs {
94+ bound.push(n.clone());
95+ }
96+ for (_, e) in bs {
97+ e.free_vars(bound, out);
98+ }
99+ body.free_vars(bound, out);
100+ bound.truncate(start);
101+ }
102+ Expr::App(f, x) => {
103+ f.free_vars(bound, out);
104+ x.free_vars(bound, out);
105+ }
106+ Expr::BinOp(_, a, b) | Expr::Or(a, b) => {
107+ a.free_vars(bound, out);
108+ b.free_vars(bound, out);
109+ }
110+ Expr::Select(e, _) => e.free_vars(bound, out),
111+ Expr::Update(e, fs) => {
112+ e.free_vars(bound, out);
113+ for (_, v) in fs {
114+ v.free_vars(bound, out);
115+ }
116+ }
117+ Expr::Record(fs) => {
118+ for (_, v) in fs {
119+ v.free_vars(bound, out);
120+ }
121+ }
122+ Expr::List(es) => {
123+ for e in es {
124+ e.free_vars(bound, out);
125+ }
126+ }
127+ }
128+ }
129+}
src/builtins.rsnew file+658
@@ -0,0 +1,658 @@
1+//! Builtins (§4.9).
2+
3+use crate::eval::Interp;
4+use crate::value::{value_eq, BlobContent, BlobKind, BlobVal, Crash, FunVal, Value};
5+use num_bigint::BigInt;
6+use std::collections::BTreeSet;
7+use std::rc::Rc;
8+
9+pub type BResult = Result<Value, Crash>;
10+
11+fn builtin(name: &str, arity: usize, f: crate::value::BuiltinFn) -> (String, Value) {
12+ (
13+ name.to_string(),
14+ Value::Fun(Rc::new(FunVal::Builtin {
15+ name: name.to_string(),
16+ arity,
17+ args: Vec::new(),
18+ f,
19+ pending: None,
20+ })),
21+ )
22+}
23+
24+pub fn make_selector(field: &str) -> Value {
25+ let name = format!(".{}", field);
26+ // A selector is a closure-free builtin of arity 1; the field is baked
27+ // into the function via a wrapper table.
28+ Value::Fun(Rc::new(FunVal::Builtin {
29+ name,
30+ arity: 2, // baked-in field name plus the record
31+ args: vec![Value::text(field)],
32+ f: selector_apply,
33+ pending: None,
34+ }))
35+}
36+
37+fn selector_apply(_i: &mut Interp, args: &[Value]) -> BResult {
38+ let field = args[0].as_text()?.to_string();
39+ args[1].field(&field)
40+}
41+
42+macro_rules! want {
43+ ($args:expr, $i:expr, $pat:pat, $what:expr) => {
44+ match &$args[$i] {
45+ $pat => (),
46+ v => {
47+ return Err(Crash::new(format!(
48+ "expected {}, got a {}",
49+ $what,
50+ v.kind_name()
51+ )))
52+ }
53+ }
54+ };
55+}
56+
57+pub fn all_builtins() -> Vec<(String, Value)> {
58+ vec![
59+ builtin(".", 2, b_compose),
60+ builtin("id", 1, b_id),
61+ builtin("const", 2, b_const),
62+ builtin("crash", 1, b_crash),
63+ builtin("==", 2, b_eq),
64+ builtin("/=", 2, b_ne),
65+ builtin("&&", 2, b_and),
66+ builtin("||", 2, b_or),
67+ builtin("not", 1, b_not),
68+ builtin("+", 2, b_add),
69+ builtin("-", 2, b_sub),
70+ builtin("*", 2, b_mul),
71+ builtin("<", 2, b_lt),
72+ builtin("<=", 2, b_le),
73+ builtin(">", 2, b_gt),
74+ builtin(">=", 2, b_ge),
75+ builtin("show", 1, b_show),
76+ builtin("::", 2, b_cons),
77+ builtin("map", 2, b_map),
78+ builtin("filter", 2, b_filter),
79+ builtin("length", 1, b_length),
80+ builtin("null", 1, b_null),
81+ builtin("head", 1, b_head),
82+ builtin("tail", 1, b_tail),
83+ builtin("last", 1, b_last),
84+ builtin("nth", 2, b_nth),
85+ builtin("take", 2, b_take),
86+ builtin("drop", 2, b_drop),
87+ builtin("member", 2, b_member),
88+ builtin("range", 2, b_range),
89+ builtin("foldl", 3, b_foldl),
90+ builtin("concat", 1, b_concat),
91+ builtin("++", 2, b_append),
92+ builtin("startsWith", 2, b_starts_with),
93+ builtin("endsWith", 2, b_ends_with),
94+ builtin("splitOn", 2, b_split_on),
95+ builtin("replay", 2, b_replay),
96+ builtin("unresolved", 1, b_unresolved),
97+ builtin("blob", 1, b_blob),
98+ builtin("text", 1, b_text),
99+ builtin("by", 2, b_by),
100+ builtin("meta", 1, b_meta),
101+ builtin("validate", 1, b_validate),
102+ builtin("diff", 2, b_diff),
103+ builtin("difft", 3, b_difft),
104+ builtin("treeWith", 2, b_tree_with),
105+ builtin("extract", 2, b_extract),
106+ ]
107+}
108+
109+fn b_compose(_i: &mut Interp, args: &[Value]) -> BResult {
110+ want!(args, 0, Value::Fun(_), "a function");
111+ want!(args, 1, Value::Fun(_), "a function");
112+ // f . g — build a closure-like builtin pair via a nested builtin
113+ let f = args[0].clone();
114+ let g = args[1].clone();
115+ compose_values(_i, f, g)
116+}
117+
118+fn compose_apply(i: &mut Interp, args: &[Value]) -> BResult {
119+ let f = args[0].clone();
120+ let g = args[1].clone();
121+ let x = args[2].clone();
122+ // A composed function applied to a function does not run (the operand
123+ // contracts are about Repo-level values); it composes pointwise so that
124+ // definitions like `abandon . contract everything` and `tree . squash`
125+ // are functions waiting for the repository (§4.1 note, §1.2 step 7).
126+ if matches!(x, Value::Fun(_)) {
127+ let next = apply_or_compose(i, g, x)?;
128+ return apply_or_compose(i, f, next);
129+ }
130+ let gx = i.apply(g, x)?;
131+ i.apply(f, gx)
132+}
133+
134+/// Apply a signed function to an argument, composing instead when the
135+/// argument is a function but the contract's next parameter is not.
136+pub fn apply_or_compose(i: &mut Interp, f: Value, x: Value) -> BResult {
137+ if matches!(x, Value::Fun(_)) {
138+ if let Some((_, cexpr)) = pending_of(&f) {
139+ if !cexpr.next_param_is_function(&i.shapes) {
140+ return compose_values(i, f, x);
141+ }
142+ }
143+ }
144+ i.apply(f, x)
145+}
146+
147+/// f . g as a value, with contract propagation
148+pub fn compose_values(i: &mut Interp, f: Value, g: Value) -> BResult {
149+ let pending = match (pending_of(&f), pending_of(&g)) {
150+ (Some((nf, cf)), Some((ng, cg))) => Some((
151+ format!("({} . {})", nf, ng),
152+ Rc::new(crate::shape::ContractExpr::Compose(
153+ Box::new((*cf).clone()),
154+ Box::new((*cg).clone()),
155+ )),
156+ )),
157+ _ => None,
158+ };
159+ let _ = &pending;
160+ let _ = i;
161+ Ok(Value::Fun(Rc::new(FunVal::Builtin {
162+ name: "(.)".into(),
163+ arity: 3,
164+ args: vec![f, g],
165+ f: compose_apply,
166+ pending,
167+ })))
168+}
169+
170+pub fn pending_of(v: &Value) -> Option<(String, Rc<crate::shape::ContractExpr>)> {
171+ if let Value::Fun(fv) = v {
172+ match fv.as_ref() {
173+ crate::value::FunVal::Closure {
174+ name: Some(n),
175+ pending: Some(p),
176+ ..
177+ } => Some((n.clone(), p.1.clone())),
178+ crate::value::FunVal::Closure {
179+ name: Some(n),
180+ pending: None,
181+ ..
182+ } => Some((n.clone(), Rc::new(crate::shape::ContractExpr::Unknown))),
183+ crate::value::FunVal::Builtin {
184+ pending: Some(p), ..
185+ } => Some(p.clone()),
186+ crate::value::FunVal::Builtin {
187+ name, pending: None, ..
188+ } => Some((name.clone(), Rc::new(crate::shape::ContractExpr::Unknown))),
189+ _ => None,
190+ }
191+ } else {
192+ None
193+ }
194+}
195+
196+fn b_id(_i: &mut Interp, args: &[Value]) -> BResult {
197+ Ok(args[0].clone())
198+}
199+
200+fn b_const(_i: &mut Interp, args: &[Value]) -> BResult {
201+ Ok(args[0].clone())
202+}
203+
204+fn b_crash(_i: &mut Interp, args: &[Value]) -> BResult {
205+ let msg = args[0].as_text()?.to_string();
206+ Err(Crash::new(msg))
207+}
208+
209+fn b_eq(_i: &mut Interp, args: &[Value]) -> BResult {
210+ Ok(Value::Bool(value_eq(&args[0], &args[1])?))
211+}
212+
213+fn b_ne(_i: &mut Interp, args: &[Value]) -> BResult {
214+ Ok(Value::Bool(!value_eq(&args[0], &args[1])?))
215+}
216+
217+fn b_and(_i: &mut Interp, args: &[Value]) -> BResult {
218+ want!(args, 0, Value::Bool(_), "Bool");
219+ want!(args, 1, Value::Bool(_), "Bool");
220+ Ok(args[1].clone())
221+}
222+
223+fn b_or(_i: &mut Interp, args: &[Value]) -> BResult {
224+ want!(args, 0, Value::Bool(_), "Bool");
225+ want!(args, 1, Value::Bool(_), "Bool");
226+ Ok(args[1].clone())
227+}
228+
229+fn b_not(_i: &mut Interp, args: &[Value]) -> BResult {
230+ match &args[0] {
231+ Value::Bool(b) => Ok(Value::Bool(!b)),
232+ v => Err(Crash::new(format!("not: expected Bool, got a {}", v.kind_name()))),
233+ }
234+}
235+
236+fn int2(args: &[Value]) -> Result<(&BigInt, &BigInt), Crash> {
237+ match (&args[0], &args[1]) {
238+ (Value::Int(a), Value::Int(b)) => Ok((a, b)),
239+ (Value::Int(_), v) | (v, Value::Int(_)) => Err(Crash::new(format!(
240+ "expected Int, got a {}",
241+ v.kind_name()
242+ ))),
243+ (v, _) => Err(Crash::new(format!("expected Int, got a {}", v.kind_name()))),
244+ }
245+}
246+
247+fn b_add(_i: &mut Interp, args: &[Value]) -> BResult {
248+ let (a, b) = int2(args)?;
249+ Ok(Value::Int(a + b))
250+}
251+fn b_sub(_i: &mut Interp, args: &[Value]) -> BResult {
252+ let (a, b) = int2(args)?;
253+ Ok(Value::Int(a - b))
254+}
255+fn b_mul(_i: &mut Interp, args: &[Value]) -> BResult {
256+ let (a, b) = int2(args)?;
257+ Ok(Value::Int(a * b))
258+}
259+fn b_lt(_i: &mut Interp, args: &[Value]) -> BResult {
260+ let (a, b) = int2(args)?;
261+ Ok(Value::Bool(a < b))
262+}
263+fn b_le(_i: &mut Interp, args: &[Value]) -> BResult {
264+ let (a, b) = int2(args)?;
265+ Ok(Value::Bool(a <= b))
266+}
267+fn b_gt(_i: &mut Interp, args: &[Value]) -> BResult {
268+ let (a, b) = int2(args)?;
269+ Ok(Value::Bool(a > b))
270+}
271+fn b_ge(_i: &mut Interp, args: &[Value]) -> BResult {
272+ let (a, b) = int2(args)?;
273+ Ok(Value::Bool(a >= b))
274+}
275+
276+fn b_show(i: &mut Interp, args: &[Value]) -> BResult {
277+ Ok(Value::text(crate::show::show(i, &args[0])))
278+}
279+
280+fn b_cons(_i: &mut Interp, args: &[Value]) -> BResult {
281+ let xs = args[1].as_list()?;
282+ let mut v = Vec::with_capacity(xs.len() + 1);
283+ v.push(args[0].clone());
284+ v.extend_from_slice(xs);
285+ Ok(Value::list(v))
286+}
287+
288+fn b_map(i: &mut Interp, args: &[Value]) -> BResult {
289+ want!(args, 0, Value::Fun(_), "a function");
290+ let xs = args[1].as_list()?;
291+ let mut out = Vec::with_capacity(xs.len());
292+ for x in xs.iter() {
293+ out.push(i.apply(args[0].clone(), x.clone())?);
294+ }
295+ Ok(Value::list(out))
296+}
297+
298+fn b_filter(i: &mut Interp, args: &[Value]) -> BResult {
299+ want!(args, 0, Value::Fun(_), "a function");
300+ let xs = args[1].as_list()?;
301+ let mut out = Vec::new();
302+ for x in xs.iter() {
303+ match i.apply(args[0].clone(), x.clone())? {
304+ Value::Bool(true) => out.push(x.clone()),
305+ Value::Bool(false) => (),
306+ v => {
307+ return Err(Crash::new(format!(
308+ "filter: predicate returned a {}",
309+ v.kind_name()
310+ )))
311+ }
312+ }
313+ }
314+ Ok(Value::list(out))
315+}
316+
317+fn b_length(_i: &mut Interp, args: &[Value]) -> BResult {
318+ Ok(Value::int(args[0].as_list()?.len() as i64))
319+}
320+
321+fn b_null(_i: &mut Interp, args: &[Value]) -> BResult {
322+ Ok(Value::Bool(args[0].as_list()?.is_empty()))
323+}
324+
325+fn b_head(_i: &mut Interp, args: &[Value]) -> BResult {
326+ let xs = args[0].as_list()?;
327+ xs.first()
328+ .cloned()
329+ .ok_or_else(|| Crash::new("head: empty list"))
330+}
331+
332+fn b_tail(_i: &mut Interp, args: &[Value]) -> BResult {
333+ let xs = args[0].as_list()?;
334+ if xs.is_empty() {
335+ return Err(Crash::new("tail: empty list"));
336+ }
337+ Ok(Value::list(xs[1..].to_vec()))
338+}
339+
340+fn b_last(_i: &mut Interp, args: &[Value]) -> BResult {
341+ let xs = args[0].as_list()?;
342+ xs.last()
343+ .cloned()
344+ .ok_or_else(|| Crash::new("last: empty list"))
345+}
346+
347+fn b_nth(_i: &mut Interp, args: &[Value]) -> BResult {
348+ let n = args[0].as_int()?;
349+ let xs = args[1].as_list()?;
350+ if n.sign() == num_bigint::Sign::Minus {
351+ return Err(Crash::new("nth: negative index"));
352+ }
353+ // a value that does not fit in usize is necessarily out of range
354+ let idx: usize = match n.to_string().parse() {
355+ Ok(i) => i,
356+ Err(_) => {
357+ return Err(Crash::new(format!("nth: index {} out of range", n)));
358+ }
359+ };
360+ xs.get(idx)
361+ .cloned()
362+ .ok_or_else(|| Crash::new(format!("nth: index {} out of range", idx)))
363+}
364+
365+fn clamp(n: &BigInt, len: usize) -> Result<usize, Crash> {
366+ let s = n.to_string();
367+ if s.starts_with('-') {
368+ return Ok(0);
369+ }
370+ let v: usize = s.parse().unwrap_or(len);
371+ Ok(v.min(len))
372+}
373+
374+fn b_take(_i: &mut Interp, args: &[Value]) -> BResult {
375+ let xs = args[1].as_list()?;
376+ let n = clamp(args[0].as_int()?, xs.len())?;
377+ Ok(Value::list(xs[..n].to_vec()))
378+}
379+
380+fn b_drop(_i: &mut Interp, args: &[Value]) -> BResult {
381+ let xs = args[1].as_list()?;
382+ let n = clamp(args[0].as_int()?, xs.len())?;
383+ Ok(Value::list(xs[n..].to_vec()))
384+}
385+
386+fn b_member(_i: &mut Interp, args: &[Value]) -> BResult {
387+ let xs = args[1].as_list()?;
388+ for x in xs.iter() {
389+ if value_eq(&args[0], x)? {
390+ return Ok(Value::Bool(true));
391+ }
392+ }
393+ Ok(Value::Bool(false))
394+}
395+
396+fn b_range(_i: &mut Interp, args: &[Value]) -> BResult {
397+ let (a, b) = int2(args)?;
398+ let mut out = Vec::new();
399+ let mut x = a.clone();
400+ while x < *b {
401+ out.push(Value::Int(x.clone()));
402+ x += 1;
403+ }
404+ Ok(Value::list(out))
405+}
406+
407+fn b_foldl(i: &mut Interp, args: &[Value]) -> BResult {
408+ want!(args, 0, Value::Fun(_), "a function");
409+ let xs = args[2].as_list()?;
410+ let mut acc = args[1].clone();
411+ for x in xs.iter() {
412+ let f = i.apply(args[0].clone(), acc)?;
413+ acc = i.apply(f, x.clone())?;
414+ }
415+ Ok(acc)
416+}
417+
418+fn b_concat(_i: &mut Interp, args: &[Value]) -> BResult {
419+ let xs = args[0].as_list()?;
420+ if xs.is_empty() {
421+ return Err(Crash::new("concat: empty list"));
422+ }
423+ match &xs[0] {
424+ Value::List(_) => {
425+ let mut out = Vec::new();
426+ for x in xs {
427+ out.extend_from_slice(x.as_list()?);
428+ }
429+ Ok(Value::list(out))
430+ }
431+ Value::Text(_) => {
432+ let mut out = String::new();
433+ for x in xs {
434+ out.push_str(x.as_text()?);
435+ }
436+ Ok(Value::text(out))
437+ }
438+ v => Err(Crash::new(format!(
439+ "concat: cannot concatenate a list of {}",
440+ v.kind_name()
441+ ))),
442+ }
443+}
444+
445+fn b_append(_i: &mut Interp, args: &[Value]) -> BResult {
446+ match (&args[0], &args[1]) {
447+ (Value::List(a), Value::List(b)) => {
448+ let mut out = a.as_ref().clone();
449+ out.extend_from_slice(b);
450+ Ok(Value::list(out))
451+ }
452+ (Value::Text(a), Value::Text(b)) => Ok(Value::text(format!("{}{}", a, b))),
453+ (Value::List(_), v) | (v, Value::List(_)) => Err(Crash::new(format!(
454+ "++: expected a list, got a {}",
455+ v.kind_name()
456+ ))),
457+ (Value::Text(_), v) | (v, Value::Text(_)) => Err(Crash::new(format!(
458+ "++: expected Text, got a {}",
459+ v.kind_name()
460+ ))),
461+ (v, _) => Err(Crash::new(format!(
462+ "++: cannot append a {}",
463+ v.kind_name()
464+ ))),
465+ }
466+}
467+
468+fn b_starts_with(_i: &mut Interp, args: &[Value]) -> BResult {
469+ let p = args[0].as_text()?;
470+ let t = args[1].as_text()?;
471+ Ok(Value::Bool(t.starts_with(p)))
472+}
473+
474+fn b_ends_with(_i: &mut Interp, args: &[Value]) -> BResult {
475+ let p = args[0].as_text()?;
476+ let t = args[1].as_text()?;
477+ Ok(Value::Bool(t.ends_with(p)))
478+}
479+
480+fn b_split_on(_i: &mut Interp, args: &[Value]) -> BResult {
481+ let sep = args[0].as_text()?;
482+ let t = args[1].as_text()?;
483+ if sep.is_empty() {
484+ return Err(Crash::new("splitOn: empty separator"));
485+ }
486+ let parts: Vec<Value> = t.split(sep).map(Value::text).collect();
487+ Ok(Value::list(parts))
488+}
489+
490+// ----------------------------------------------------------------------
491+// repository builtins
492+// ----------------------------------------------------------------------
493+
494+fn b_replay(i: &mut Interp, args: &[Value]) -> BResult {
495+ let onto = args[0].as_list()?;
496+ let ch = &args[1];
497+ let from = ch.field("from")?;
498+ let to = ch.field("to")?;
499+ let from = from.as_list()?;
500+ let to = to.as_list()?;
501+ let out = i.backend.replay(onto, from, to)?;
502+ Ok(Value::list(out))
503+}
504+
505+fn b_unresolved(_i: &mut Interp, args: &[Value]) -> BResult {
506+ match &args[0] {
507+ Value::Blob(b) => Ok(Value::Bool(b.is_unresolved())),
508+ v => Err(Crash::new(format!(
509+ "unresolved: expected a Blob, got a {}",
510+ v.kind_name()
511+ ))),
512+ }
513+}
514+
515+fn b_blob(_i: &mut Interp, args: &[Value]) -> BResult {
516+ let t = args[0].as_text()?;
517+ Ok(Value::Blob(Rc::new(BlobVal {
518+ kind: BlobKind::Regular,
519+ content: BlobContent::Resolved(Rc::new(t.as_bytes().to_vec())),
520+ })))
521+}
522+
523+fn b_text(_i: &mut Interp, args: &[Value]) -> BResult {
524+ match &args[0] {
525+ Value::Blob(b) => {
526+ let bytes = b.bytes();
527+ let s = String::from_utf8(bytes)
528+ .map_err(|_| Crash::new("text: blob content is not UTF-8"))?;
529+ Ok(Value::text(s))
530+ }
531+ v => Err(Crash::new(format!(
532+ "text: expected a Blob, got a {}",
533+ v.kind_name()
534+ ))),
535+ }
536+}
537+
538+fn b_by(_i: &mut Interp, args: &[Value]) -> BResult {
539+ // Implemented via the generic walker (agrees with §10's reference).
540+ let id = match &args[0] {
541+ Value::Id(id) => id.to_string(),
542+ v => {
543+ return Err(Crash::new(format!(
544+ "by: expected an Id, got a {}",
545+ v.kind_name()
546+ )))
547+ }
548+ };
549+ let repo = &args[1];
550+ match crate::repo::by_id(repo, &id)? {
551+ Some(r) => Ok(r),
552+ None => Err(Crash::new("by: no such commit")),
553+ }
554+}
555+
556+fn b_meta(i: &mut Interp, args: &[Value]) -> BResult {
557+ let id = match &args[0] {
558+ Value::Id(id) => id.to_string(),
559+ v => {
560+ return Err(Crash::new(format!(
561+ "meta: expected an Id, got a {}",
562+ v.kind_name()
563+ )))
564+ }
565+ };
566+ let m = i.backend.meta(&id)?;
567+ Ok(Value::record(&[
568+ ("hash", Value::text(m.hash)),
569+ ("author", Value::text(m.author)),
570+ ("email", Value::text(m.email)),
571+ ("time", Value::Int(BigInt::from(m.time))),
572+ ]))
573+}
574+
575+fn b_validate(i: &mut Interp, args: &[Value]) -> BResult {
576+ crate::repo::validate_repo(i, &args[0])?;
577+ Ok(args[0].clone())
578+}
579+
580+fn b_diff(_i: &mut Interp, args: &[Value]) -> BResult {
581+ let a = blob_text_utf8(&args[0])?;
582+ let b = blob_text_utf8(&args[1])?;
583+ if a == b {
584+ return Ok(Value::text(""));
585+ }
586+ Ok(Value::text(crate::show::unified_diff(&a, &b)))
587+}
588+
589+fn blob_text_utf8(v: &Value) -> Result<String, Crash> {
590+ match v {
591+ Value::Blob(b) => String::from_utf8(b.bytes())
592+ .map_err(|_| Crash::new("diff: blob content is not UTF-8")),
593+ v => Err(Crash::new(format!(
594+ "diff: expected a Blob, got a {}",
595+ v.kind_name()
596+ ))),
597+ }
598+}
599+
600+fn b_difft(_i: &mut Interp, args: &[Value]) -> BResult {
601+ crate::show::difft(&args[0], &args[1], &args[2])
602+}
603+
604+fn b_tree_with(i: &mut Interp, args: &[Value]) -> BResult {
605+ crate::render::tree_with(i, &args[0], &args[1])
606+}
607+
608+fn b_extract(_i: &mut Interp, args: &[Value]) -> BResult {
609+ let shape = match &args[0] {
610+ Value::Shape(s) => s.clone(),
611+ v => {
612+ return Err(Crash::new(format!(
613+ "extract: expected a Shape, got a {}",
614+ v.kind_name()
615+ )))
616+ }
617+ };
618+ let mut out = Vec::new();
619+ extract_into(&shape, &args[1], &mut out)?;
620+ Ok(Value::list(out))
621+}
622+
623+fn extract_into(
624+ shape: &Rc<crate::value::ShapeVal>,
625+ v: &Value,
626+ out: &mut Vec<Value>,
627+) -> Result<(), Crash> {
628+ use crate::value::{PrimKind, ShapeKind};
629+ let matches = match (&shape.kind, v) {
630+ (ShapeKind::Prim(PrimKind::Int), Value::Int(_)) => true,
631+ (ShapeKind::Prim(PrimKind::Text), Value::Text(_)) => true,
632+ (ShapeKind::Prim(PrimKind::Bool), Value::Bool(_)) => true,
633+ (ShapeKind::Prim(PrimKind::Id), Value::Id(_)) => true,
634+ (ShapeKind::Prim(PrimKind::Blob), Value::Blob(_)) => true,
635+ (ShapeKind::Record(fields), Value::Record(m)) => {
636+ let got: BTreeSet<String> = m.keys().cloned().collect();
637+ *fields == got
638+ }
639+ _ => false,
640+ };
641+ if matches {
642+ out.push(v.clone());
643+ }
644+ match v {
645+ Value::List(xs) => {
646+ for x in xs.iter() {
647+ extract_into(shape, x, out)?;
648+ }
649+ }
650+ Value::Record(m) => {
651+ for (_, x) in m.iter() {
652+ extract_into(shape, x, out)?;
653+ }
654+ }
655+ _ => {}
656+ }
657+ Ok(())
658+}
src/config.rsnew file+481
@@ -0,0 +1,481 @@
1+//! config.j loading and validation (§6).
2+
3+use crate::ast::{Expr, Item, TypeExpr};
4+use crate::eval::Interp;
5+use crate::parse::{parse_config, ParseError};
6+use crate::shape::{compile_contract, Shapes};
7+use crate::value::{Crash, Env, Value};
8+use std::collections::{BTreeSet, HashMap, HashSet};
9+use std::rc::Rc;
10+
11+#[derive(Debug)]
12+pub enum ConfigError {
13+ Parse(ParseError),
14+ Validation(String),
15+}
16+
17+impl ConfigError {
18+ pub fn message(&self) -> String {
19+ match self {
20+ ConfigError::Parse(p) => format!("line {}: {}", p.line, p.msg),
21+ ConfigError::Validation(m) => m.clone(),
22+ }
23+ }
24+}
25+
26+pub struct Config {
27+ pub shapes: Shapes,
28+ /// definition names in dependency order for evaluation
29+ pub defs: Vec<(String, Rc<Expr>)>,
30+ /// signatures for builtins and definitions
31+ pub sigs: HashMap<String, TypeExpr>,
32+ /// which sig names are builtins (no definition)
33+ pub builtin_names: HashSet<String>,
34+ /// all global names (for no-shadowing in CLI parsing)
35+ pub global_names: BTreeSet<String>,
36+}
37+
38+const RESERVED_BUILTINS: &[&str] = &[
39+ ".", "id", "const", "crash", "==", "/=", "&&", "||", "not", "+", "-", "*", "<", "<=", ">",
40+ ">=", "show", "::", "map", "filter", "length", "null", "head", "tail", "last", "nth", "take",
41+ "drop", "member", "range", "foldl", "concat", "++", "startsWith", "endsWith", "splitOn",
42+ "replay", "unresolved", "blob", "text", "by", "meta", "validate", "diff", "difft", "treeWith",
43+ "extract",
44+];
45+
46+pub fn is_reserved_builtin(name: &str) -> bool {
47+ RESERVED_BUILTINS.contains(&name)
48+}
49+
50+pub fn reserved_set() -> BTreeSet<String> {
51+ RESERVED_BUILTINS.iter().map(|s| s.to_string()).collect()
52+}
53+
54+/// Parse and validate config.j (§6.2). Id literals are resolved separately.
55+pub fn load_config(src: &str) -> Result<Config, ConfigError> {
56+ let outer = Rc::new(reserved_set());
57+ let items = parse_config(src, outer).map_err(ConfigError::Parse)?;
58+ validate_items(items)
59+}
60+
61+fn validate_items(items: Vec<Item>) -> Result<Config, ConfigError> {
62+ let verr = |m: String| Err(ConfigError::Validation(m));
63+ let mut shapes = Shapes::new();
64+ let mut sigs: HashMap<String, (TypeExpr, usize)> = HashMap::new();
65+ let _defs: Vec<(String, Rc<Expr>, usize)> = Vec::new();
66+ let mut def_names: HashSet<String> = HashSet::new();
67+
68+ for item in &items {
69+ match item {
70+ Item::TypeDecl(name, ty) => {
71+ if shapes.decls.contains_key(name) {
72+ return verr(format!("type `{}` is declared twice", name));
73+ }
74+ shapes.decls.insert(name.clone(), ty.clone());
75+ }
76+ Item::Signature(name, ty, line) => {
77+ if sigs.contains_key(name) {
78+ return verr(format!("`{}` has two signatures", name));
79+ }
80+ sigs.insert(name.clone(), (ty.clone(), *line));
81+ }
82+ Item::Definition(name, _, line) => {
83+ if is_reserved_builtin(name) {
84+ return verr(format!(
85+ "`{}` is a builtin and may not be defined",
86+ name
87+ ));
88+ }
89+ if !def_names.insert(name.clone()) {
90+ return verr(format!("`{}` is defined twice", name));
91+ }
92+ let _ = line;
93+ }
94+ }
95+ }
96+
97+ // pair signatures with definitions; a signature must immediately precede
98+ // its definition (blank lines/comments fine — items between are not)
99+ let mut prev_sig: Option<String> = None;
100+ for item in &items {
101+ match item {
102+ Item::Signature(name, _, _) => {
103+ prev_sig = Some(name.clone());
104+ }
105+ Item::Definition(name, _, _) => {
106+ if let Some(s) = &prev_sig {
107+ if s != name {
108+ // signature not immediately followed by its definition
109+ if def_names.contains(s) {
110+ return verr(format!(
111+ "the signature for `{}` is not immediately followed by its definition",
112+ s
113+ ));
114+ }
115+ }
116+ }
117+ prev_sig = None;
118+ }
119+ Item::TypeDecl(_, _) => {
120+ prev_sig = None;
121+ }
122+ }
123+ }
124+ if let Some(s) = &prev_sig {
125+ if def_names.contains(s) {
126+ return verr(format!(
127+ "the signature for `{}` is not immediately followed by its definition",
128+ s
129+ ));
130+ }
131+ }
132+
133+ let mut builtin_names: HashSet<String> = HashSet::new();
134+ let mut final_sigs: HashMap<String, TypeExpr> = HashMap::new();
135+ for (name, (ty, _)) in &sigs {
136+ if def_names.contains(name) {
137+ final_sigs.insert(name.clone(), ty.clone());
138+ } else {
139+ if !is_reserved_builtin(name) {
140+ return verr(format!(
141+ "`{}` is declared but is not a builtin and has no definition",
142+ name
143+ ));
144+ }
145+ builtin_names.insert(name.clone());
146+ final_sigs.insert(name.clone(), ty.clone());
147+ }
148+ }
149+
150+ // required definitions (§6.2.6) — checked after evaluation for shapes;
151+ // here only presence
152+ for req in ["user", "immutable", "tree", "labelled"] {
153+ if !def_names.contains(req) {
154+ return verr(format!("config.j must define `{}`", req));
155+ }
156+ }
157+
158+ // dependency order (§4.1)
159+ let def_map: HashMap<String, Rc<Expr>> = items
160+ .iter()
161+ .filter_map(|it| match it {
162+ Item::Definition(n, e, _) => Some((n.clone(), e.clone())),
163+ _ => None,
164+ })
165+ .collect();
166+ let ordered = dependency_order(&def_map)?;
167+
168+ let mut global_names = reserved_set();
169+ for n in def_map.keys() {
170+ global_names.insert(n.clone());
171+ }
172+
173+ Ok(Config {
174+ shapes,
175+ defs: ordered,
176+ sigs: final_sigs,
177+ builtin_names,
178+ global_names,
179+ })
180+}
181+
182+/// topological order by references outside lambdas (§4.1)
183+fn dependency_order(
184+ defs: &HashMap<String, Rc<Expr>>,
185+) -> Result<Vec<(String, Rc<Expr>)>, ConfigError> {
186+ let mut deps: HashMap<String, BTreeSet<String>> = HashMap::new();
187+ for (name, expr) in defs {
188+ let mut free = BTreeSet::new();
189+ load_deps(expr, &mut free);
190+ let d: BTreeSet<String> = free.into_iter().filter(|n| defs.contains_key(n)).collect();
191+ deps.insert(name.clone(), d);
192+ }
193+ let mut ordered = Vec::new();
194+ let mut done: HashSet<String> = HashSet::new();
195+ let mut names: Vec<String> = defs.keys().cloned().collect();
196+ names.sort();
197+ loop {
198+ let mut progress = false;
199+ for n in &names {
200+ if done.contains(n) {
201+ continue;
202+ }
203+ if deps[n].iter().all(|d| done.contains(d)) {
204+ done.insert(n.clone());
205+ ordered.push((n.clone(), defs[n].clone()));
206+ progress = true;
207+ }
208+ }
209+ if done.len() == names.len() {
210+ return Ok(ordered);
211+ }
212+ if !progress {
213+ return Err(ConfigError::Validation(
214+ "config.j: a cycle among top-level definitions".into(),
215+ ));
216+ }
217+ }
218+}
219+
220+/// Names a definition's value depends on at load time (§4.1). Lambda bodies
221+/// never form load-time dependencies; inside `let` bindings, references to
222+/// the bound names are resolved at application time and also do not count.
223+fn load_deps(e: &Expr, out: &mut BTreeSet<String>) {
224+ match e {
225+ Expr::Var(n) => {
226+ out.insert(n.clone());
227+ }
228+ Expr::Lambda(_, _, _) => {}
229+ Expr::If(a, b, c) => {
230+ load_deps(a, out);
231+ load_deps(b, out);
232+ load_deps(c, out);
233+ }
234+ Expr::Let(bs, body) => {
235+ // a let is a lambda applied to a tuple at the value level; its
236+ // bindings evaluate now only insofar as the body of each binding
237+ // is evaluated now — but each binding is a value, and non-lambda
238+ // values may reference earlier bindings. References to names
239+ // bound in this let do not create load deps on same-named
240+ // top-level definitions.
241+ let local: BTreeSet<String> = bs.iter().map(|(n, _)| n.clone()).collect();
242+ let mut sub = BTreeSet::new();
243+ for (_, e) in bs {
244+ load_deps(e, &mut sub);
245+ }
246+ load_deps(body, &mut sub);
247+ for n in sub {
248+ if !local.contains(&n) {
249+ out.insert(n);
250+ }
251+ }
252+ }
253+ Expr::App(f, x) => {
254+ load_deps(f, out);
255+ load_deps(x, out);
256+ }
257+ Expr::BinOp(_, a, b) | Expr::Or(a, b) => {
258+ load_deps(a, out);
259+ load_deps(b, out);
260+ }
261+ Expr::Select(e2, _) => load_deps(e2, out),
262+ Expr::Paren(e2) => load_deps(e2, out),
263+ Expr::Update(e2, fs) => {
264+ load_deps(e2, out);
265+ for (_, v) in fs {
266+ load_deps(v, out);
267+ }
268+ }
269+ Expr::Record(fs) => {
270+ for (_, v) in fs {
271+ load_deps(v, out);
272+ }
273+ }
274+ Expr::List(es) => {
275+ for e in es {
276+ load_deps(e, out);
277+ }
278+ }
279+ _ => {}
280+ }
281+}
282+
283+/// Evaluate a loaded config into an interpreter's globals.
284+pub fn eval_config(interp: &mut Interp, cfg: &Config) -> Result<(), Crash> {
285+ // contracts
286+ for (name, ty) in &cfg.sigs {
287+ interp
288+ .contracts
289+ .insert(name.clone(), Rc::new(compile_contract(&interp.shapes, ty)));
290+ }
291+ // install builtins that are declared, with their contracts attached
292+ let mut globals: HashMap<String, Value> = HashMap::new();
293+ for (name, v) in crate::builtins::all_builtins() {
294+ if cfg.builtin_names.contains(&name) {
295+ let v = match cfg.sigs.get(&name) {
296+ Some(ty) => crate::value::attach_pending(
297+ &v,
298+ &name,
299+ Rc::new(compile_contract(&interp.shapes, ty)),
300+ ),
301+ None => v,
302+ };
303+ globals.insert(name, v);
304+ }
305+ }
306+ interp.globals = Env::with_globals(globals);
307+ // evaluate definitions in dependency order, through one recursive frame so
308+ // top-level definitions are mutually recursive (§4.1)
309+ let (genv, cell) = interp.globals.extend_rec();
310+ for (name, expr) in &cfg.defs {
311+ *interp.current_def.borrow_mut() = Some(name.clone());
312+ let v = interp.eval(expr, &genv)?;
313+ // value-level contract for non-function definitions
314+ if let Some(ty) = cfg.sigs.get(name) {
315+ let c = compile_contract(&interp.shapes, ty);
316+ if c.params.is_empty() {
317+ if let Err(msg) = crate::shape::check(&interp.shapes, &c.result, &v) {
318+ return Err(Crash::new(format!("contract: {}: {}", name, msg)));
319+ }
320+ }
321+ }
322+ cell.borrow_mut().push((name.clone(), v.clone()));
323+ // attach the definition's name and contract to its function value so
324+ // applications are checked (§4.13) and errors name the definition
325+ if let Some(ty) = cfg.sigs.get(name) {
326+ let contract = Rc::new(compile_contract(&interp.shapes, ty));
327+ let named = crate::value::attach_pending(&v, name, contract);
328+ let last = cell.borrow_mut().len() - 1;
329+ cell.borrow_mut()[last] = (name.clone(), named);
330+ }
331+ }
332+ interp.globals = genv;
333+ *interp.current_def.borrow_mut() = None;
334+ // §6.2.6 shape checks on the four read-by-interpreter definitions
335+ check_required(interp)?;
336+ Ok(())
337+}
338+
339+fn check_required(interp: &Interp) -> Result<(), Crash> {
340+ let user = interp
341+ .globals
342+ .lookup("user")
343+ .ok_or_else(|| Crash::new("config.j must define `user`"))?;
344+ match &user {
345+ Value::Record(m) => {
346+ let ok = m.get("name").map(|v| matches!(v, Value::Text(t) if !t.is_empty())).unwrap_or(false)
347+ && m.get("email").map(|v| matches!(v, Value::Text(t) if !t.is_empty())).unwrap_or(false);
348+ if !ok {
349+ return Err(Crash::new(
350+ "`user` must be a record with non-empty `name` and `email` texts",
351+ ));
352+ }
353+ }
354+ _ => {
355+ return Err(Crash::new(
356+ "`user` must be a record with non-empty `name` and `email` texts",
357+ ))
358+ }
359+ }
360+ for f in ["immutable", "tree", "labelled"] {
361+ match interp.globals.lookup(f) {
362+ Some(Value::Fun(_)) => {}
363+ _ => return Err(Crash::new(format!("`{}` must be a function", f))),
364+ }
365+ }
366+ Ok(())
367+}
368+
369+/// Evaluate only `user`, for init/clone when there is no repository (§6.1).
370+pub fn eval_user_only(interp: &mut Interp, cfg: &Config) -> Result<(String, String), Crash> {
371+ for (name, v) in crate::builtins::all_builtins() {
372+ if cfg.builtin_names.contains(&name) {
373+ interp.globals = interp.globals.extend(vec![(name, v)]);
374+ }
375+ }
376+ for (name, ty) in &cfg.sigs {
377+ interp
378+ .contracts
379+ .insert(name.clone(), Rc::new(compile_contract(&interp.shapes, ty)));
380+ }
381+ for (name, expr) in &cfg.defs {
382+ if name == "user" {
383+ *interp.current_def.borrow_mut() = Some(name.clone());
384+ let genv = interp.globals.clone();
385+ let v = interp.eval(expr, &genv)?;
386+ *interp.current_def.borrow_mut() = None;
387+ interp.globals = interp.globals.extend(vec![(name.clone(), v.clone())]);
388+ if let Value::Record(m) = &v {
389+ let n = m.get("name").and_then(|v| v.as_text().ok()).unwrap_or("").to_string();
390+ let e = m.get("email").and_then(|v| v.as_text().ok()).unwrap_or("").to_string();
391+ if n.is_empty() || e.is_empty() {
392+ return Err(Crash::new(
393+ "`user` must be a record with non-empty `name` and `email` texts",
394+ ));
395+ }
396+ return Ok((n, e));
397+ }
398+ return Err(Crash::new(
399+ "`user` must be a record with non-empty `name` and `email` texts",
400+ ));
401+ }
402+ }
403+ Err(Crash::new("config.j must define `user`"))
404+}
405+
406+/// Resolve all `@prefix` literals in an AST against visible ids.
407+pub fn resolve_ids(
408+ e: &Expr,
409+ interp: &Interp,
410+) -> Result<Expr, Vec<(String, Vec<String>)>> {
411+ let mut failures = Vec::new();
412+ let out = resolve_ids_rec(e, interp, &mut failures);
413+ if failures.is_empty() {
414+ Ok(out)
415+ } else {
416+ Err(failures)
417+ }
418+}
419+
420+fn resolve_ids_rec(
421+ e: &Expr,
422+ interp: &Interp,
423+ failures: &mut Vec<(String, Vec<String>)>,
424+) -> Expr {
425+ let mut r = |e: &Rc<Expr>| Rc::new(resolve_ids_rec(e, interp, failures));
426+ match e {
427+ Expr::IdLit(prefix) => {
428+ let matches = interp.backend.resolve_prefix(prefix);
429+ if matches.len() == 1 {
430+ Expr::Id(matches[0].clone())
431+ } else {
432+ failures.push((prefix.clone(), matches));
433+ Expr::Id(prefix.clone())
434+ }
435+ }
436+ Expr::Var(_)
437+ | Expr::TypeName(_)
438+ | Expr::Int(_)
439+ | Expr::Text(_)
440+ | Expr::Id(_)
441+ | Expr::NewId
442+ | Expr::LabelLit(_)
443+ | Expr::PathLit(_)
444+ | Expr::Bool(_)
445+ | Expr::SelectorFun(_)
446+ | Expr::Crash => e.clone(),
447+ Expr::Paren(e2) => Expr::Paren(r(e2)),
448+ Expr::Lambda(ps, body, src) => Expr::Lambda(ps.clone(), r(body), src.clone()),
449+ Expr::If(a, b, c) => Expr::If(r(a), r(b), r(c)),
450+ Expr::Let(bs, body) => Expr::Let(
451+ bs.iter().map(|(n, e)| (n.clone(), r(e))).collect(),
452+ r(body),
453+ ),
454+ Expr::App(f, x) => Expr::App(r(f), r(x)),
455+ Expr::BinOp(o, a, b) => Expr::BinOp(o, r(a), r(b)),
456+ Expr::Or(a, b) => Expr::Or(r(a), r(b)),
457+ Expr::Select(e2, f) => Expr::Select(r(e2), f.clone()),
458+ Expr::Update(e2, fs) => Expr::Update(
459+ r(e2),
460+ fs.iter().map(|(n, v)| (n.clone(), r(v))).collect(),
461+ ),
462+ Expr::Record(fs) => Expr::Record(fs.iter().map(|(n, v)| (n.clone(), r(v))).collect()),
463+ Expr::List(es) => Expr::List(es.iter().map(r).collect()),
464+ }
465+}
466+
467+/// find Id literals per definition for config validation (§6.2.5)
468+pub fn config_id_failures(
469+ cfg: &Config,
470+ interp: &Interp,
471+) -> Vec<(String, String, Vec<String>)> {
472+ let mut out = Vec::new();
473+ for (name, expr) in &cfg.defs {
474+ let mut failures = Vec::new();
475+ resolve_ids_rec(expr, interp, &mut failures);
476+ for (prefix, matches) in failures {
477+ out.push((name.clone(), prefix, matches));
478+ }
479+ }
480+ out
481+}
src/domain.rsnew file+273
@@ -0,0 +1,273 @@
1+//! Domain abstraction: everything repository-shaped the interpreter needs
2+//! (§7, §10). Two implementations: an in-memory backend for tests, and the
3+//! jj-lib backend.
4+
5+use crate::value::{BlobContent, BlobKind, BlobVal, Crash, Value};
6+use std::collections::{BTreeMap, BTreeSet, HashMap};
7+use std::rc::Rc;
8+
9+#[derive(Clone, Debug)]
10+pub struct MetaInfo {
11+ pub hash: String,
12+ pub author: String,
13+ pub email: String,
14+ /// seconds since unix epoch
15+ pub time: i64,
16+}
17+
18+/// The result of resolving an id prefix (§4.10).
19+pub enum IdResolution {
20+ Ok(String),
21+ NoMatch,
22+ Ambiguous(Vec<String>),
23+}
24+
25+/// Immutable-set computation needs this from the backend (§7.5).
26+pub struct ImmutabilityInfo {
27+ /// change ids that are merges (2+ parents) plus all their ancestors
28+ pub merge_closed: BTreeSet<String>,
29+}
30+
31+pub trait Backend {
32+ /// All visible change ids (full), for resolving `@prefix` literals.
33+ fn visible_ids(&self) -> Vec<String>;
34+ /// All visible ids beginning with `prefix`.
35+ fn resolve_prefix(&self, prefix: &str) -> Vec<String> {
36+ self.visible_ids()
37+ .into_iter()
38+ .filter(|id| id.starts_with(prefix))
39+ .collect()
40+ }
41+ /// Metadata for a stored commit, by change id.
42+ fn meta(&self, id: &str) -> Result<MetaInfo, Crash>;
43+ /// True if the change id denotes a merge commit (2+ parents).
44+ fn is_merge(&self, id: &str) -> bool;
45+ /// All ancestors (inclusive) of the given change ids.
46+ fn ancestors_closed(&self, ids: &BTreeSet<String>) -> BTreeSet<String>;
47+
48+ /// jj tree merge (§7.3): replay the change from->to onto `onto`.
49+ fn replay(
50+ &self,
51+ onto: &[Value],
52+ from: &[Value],
53+ to: &[Value],
54+ ) -> Result<Vec<Value>, Crash>;
55+
56+ /// shortest prefix of `id` unique among visible ids, min length 4
57+ fn unique_prefix(&self, id: &str) -> String {
58+ let others: Vec<String> = self.visible_ids();
59+ let mut n = 4.min(id.len());
60+ while n < id.len() {
61+ let p = &id[..n];
62+ if others.iter().filter(|o| o.starts_with(p) && *o != id).count() == 0 {
63+ return p.to_string();
64+ }
65+ n += 1;
66+ }
67+ id.to_string()
68+ }
69+}
70+
71+// ----------------------------------------------------------------------
72+// snapshot helpers shared by backends
73+// ----------------------------------------------------------------------
74+
75+/// entries as a path->value map; crashes on malformed snapshots (§7.3)
76+pub fn snapshot_map(entries: &[Value]) -> Result<BTreeMap<Vec<String>, Value>, Crash> {
77+ let mut m = BTreeMap::new();
78+ for e in entries {
79+ let path_v = e.field("path").map_err(|_| {
80+ Crash::new("replay: not a well-formed snapshot (entry without `path`)")
81+ })?;
82+ let content = e.field("content").map_err(|_| {
83+ Crash::new("replay: not a well-formed snapshot (entry without `content`)")
84+ })?;
85+ if !matches!(content, Value::Blob(_)) {
86+ return Err(Crash::new(
87+ "replay: not a well-formed snapshot (content is not a Blob)",
88+ ));
89+ }
90+ let path = path_v
91+ .as_list()
92+ .map_err(|_| Crash::new("replay: not a well-formed snapshot (path not a list)"))?
93+ .iter()
94+ .map(|c| {
95+ c.as_text()
96+ .map(|s| s.to_string())
97+ .map_err(|_| Crash::new("replay: not a well-formed snapshot (path component not Text)"))
98+ })
99+ .collect::<Result<Vec<String>, Crash>>()?;
100+ if m.insert(path, content).is_some() {
101+ return Err(Crash::new("replay: not a well-formed snapshot (duplicate paths)"));
102+ }
103+ }
104+ Ok(m)
105+}
106+
107+pub fn map_to_snapshot(m: BTreeMap<Vec<String>, Value>) -> Vec<Value> {
108+ m.into_iter()
109+ .map(|(path, content)| {
110+ let p = Value::list(path.into_iter().map(Value::text).collect());
111+ Value::record(&[("content", content), ("path", p)])
112+ })
113+ .collect()
114+}
115+
116+fn blob_eq(a: &Value, b: &Value) -> bool {
117+ match (a, b) {
118+ (Value::Blob(x), Value::Blob(y)) => {
119+ x.kind == y.kind
120+ && match (&x.content, &y.content) {
121+ (BlobContent::Resolved(p), BlobContent::Resolved(q)) => p == q,
122+ (BlobContent::Conflict(p), BlobContent::Conflict(q)) => p == q,
123+ _ => false,
124+ }
125+ }
126+ _ => false,
127+ }
128+}
129+
130+fn empty_blob() -> Value {
131+ Value::Blob(Rc::new(BlobVal {
132+ kind: BlobKind::Regular,
133+ content: BlobContent::Resolved(Rc::new(Vec::new())),
134+ }))
135+}
136+
137+fn conflict_blob(sides: Vec<Rc<Vec<u8>>>) -> Value {
138+ Value::Blob(Rc::new(BlobVal {
139+ kind: BlobKind::Regular,
140+ content: BlobContent::Conflict(sides),
141+ }))
142+}
143+
144+/// A simple per-path three-way merge usable by the in-memory backend and as
145+/// a reference. A path resolves when at most one side changed it, or both
146+/// made the same change (§10); otherwise a conflict blob is produced.
147+pub fn simple_replay(
148+ onto: &[Value],
149+ from: &[Value],
150+ to: &[Value],
151+) -> Result<Vec<Value>, Crash> {
152+ let onto = snapshot_map(onto)?;
153+ let from = snapshot_map(from)?;
154+ let to = snapshot_map(to)?;
155+ let mut paths: BTreeSet<Vec<String>> = BTreeSet::new();
156+ paths.extend(onto.keys().cloned());
157+ paths.extend(from.keys().cloned());
158+ paths.extend(to.keys().cloned());
159+ let absent = empty_blob();
160+ let mut out = BTreeMap::new();
161+ for p in paths {
162+ let o = onto.get(&p).cloned().unwrap_or_else(|| absent.clone());
163+ let f = from.get(&p).cloned().unwrap_or_else(|| absent.clone());
164+ let t = to.get(&p).cloned().unwrap_or_else(|| absent.clone());
165+ let o_present = onto.contains_key(&p);
166+ let f_present = from.contains_key(&p);
167+ let t_present = to.contains_key(&p);
168+ let changed_to = !(f_present == t_present && (!f_present || blob_eq(&f, &t)));
169+ let changed_onto = !(f_present == o_present && (!f_present || blob_eq(&f, &o)));
170+ match (changed_to, changed_onto) {
171+ (false, false) => {
172+ if o_present {
173+ out.insert(p, o);
174+ }
175+ }
176+ (true, false) => {
177+ if t_present {
178+ out.insert(p, t);
179+ }
180+ }
181+ (false, true) => {
182+ if o_present {
183+ out.insert(p, o);
184+ }
185+ }
186+ (true, true) => {
187+ if t_present && o_present && blob_eq(&t, &o) {
188+ out.insert(p, t);
189+ } else if !t_present && !o_present {
190+ // both deleted
191+ } else {
192+ // conflict: sides [to, from, onto] (add, remove, add)
193+ let get_bytes = |v: &Value, present: bool| -> Rc<Vec<u8>> {
194+ if !present {
195+ return Rc::new(Vec::new());
196+ }
197+ match v {
198+ Value::Blob(b) => Rc::new(b.bytes()),
199+ _ => Rc::new(Vec::new()),
200+ }
201+ };
202+ out.insert(
203+ p,
204+ conflict_blob(vec![
205+ get_bytes(&t, t_present),
206+ get_bytes(&f, f_present),
207+ get_bytes(&o, o_present),
208+ ]),
209+ );
210+ }
211+ }
212+ }
213+ }
214+ Ok(map_to_snapshot(out))
215+}
216+
217+// ----------------------------------------------------------------------
218+// In-memory backend (§10)
219+// ----------------------------------------------------------------------
220+
221+#[derive(Clone, Debug, Default)]
222+pub struct MemBackend {
223+ /// id -> meta
224+ pub metas: HashMap<String, MetaInfo>,
225+ /// id -> parent change ids (first parent first)
226+ pub parents: HashMap<String, Vec<String>>,
227+}
228+
229+impl MemBackend {
230+ pub fn new() -> Self {
231+ MemBackend {
232+ metas: HashMap::new(),
233+ parents: HashMap::new(),
234+ }
235+ }
236+}
237+
238+impl Backend for MemBackend {
239+ fn visible_ids(&self) -> Vec<String> {
240+ let mut ids: Vec<String> = self.metas.keys().cloned().collect();
241+ ids.sort();
242+ ids
243+ }
244+ fn meta(&self, id: &str) -> Result<MetaInfo, Crash> {
245+ self.metas
246+ .get(id)
247+ .cloned()
248+ .ok_or_else(|| Crash::new(format!("meta: no stored commit for id")))
249+ }
250+ fn is_merge(&self, id: &str) -> bool {
251+ self.parents.get(id).map(|p| p.len() >= 2).unwrap_or(false)
252+ }
253+ fn ancestors_closed(&self, ids: &BTreeSet<String>) -> BTreeSet<String> {
254+ let mut seen = BTreeSet::new();
255+ let mut stack: Vec<String> = ids.iter().cloned().collect();
256+ while let Some(id) = stack.pop() {
257+ if seen.insert(id.clone()) {
258+ if let Some(ps) = self.parents.get(&id) {
259+ for p in ps {
260+ stack.push(p.clone());
261+ }
262+ }
263+ }
264+ }
265+ seen
266+ }
267+ fn replay(&self, onto: &[Value], from: &[Value], to: &[Value]) -> Result<Vec<Value>, Crash> {
268+ simple_replay(onto, from, to)
269+ }
270+}
271+
272+/// The root commit's change id in jj repositories.
273+pub const ROOT_ID: &str = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz";
src/eval.rsnew file+1020
@@ -0,0 +1,1020 @@
1+//! Evaluator (§4). Strict, call-by-value, left-to-right, trampolined so deep
2+//! recursion cannot overflow the native stack.
3+
4+use crate::ast::{Expr, Pattern};
5+use crate::domain::Backend;
6+use crate::shape::{check as contract_check, Contract, Shapes};
7+use crate::value::{Crash, Env, FunVal, Value};
8+use std::cell::RefCell;
9+use std::collections::HashMap;
10+use std::rc::Rc;
11+
12+pub struct Interp {
13+ pub backend: Rc<dyn Backend>,
14+ pub shapes: Shapes,
15+ /// global names -> values (builtins + config definitions)
16+ pub globals: Env,
17+ /// contracts per global name
18+ pub contracts: HashMap<String, Rc<Contract>>,
19+ /// fresh id counter
20+ fresh: RefCell<u64>,
21+ /// the currently executing top-level definition (for crash reports)
22+ pub current_def: RefCell<Option<String>>,
23+ /// the repository as loaded, before snapshotting (for `validate`)
24+ pub old_repo: RefCell<Option<Value>>,
25+}
26+
27+/// machine state
28+#[derive(Clone)]
29+pub(crate) enum State {
30+ Eval(Rc<Expr>, Env, Cont),
31+ Ret(Value, Cont),
32+ /// apply f to arg, then continue
33+ Apply(Value, Value, Cont),
34+}
35+
36+#[derive(Clone)]
37+pub(crate) enum Cont {
38+ Halt,
39+ Arg(Rc<Expr>, Env, Rc<Cont>),
40+ Fun(Value, Rc<Cont>),
41+ If(Rc<Expr>, Rc<Expr>, Env, Rc<Cont>),
42+ LetRest {
43+ names: Vec<String>,
44+ exprs: Vec<Rc<Expr>>,
45+ acc: Vec<(String, Value)>,
46+ cell: Rc<RefCell<Vec<(String, Value)>>>,
47+ body: Rc<Expr>,
48+ env: Env,
49+ cont: Rc<Cont>,
50+ },
51+ /// short-circuit ops with the unevaluated rhs
52+ And(Rc<Expr>, Env, Rc<Cont>),
53+ Or2(Rc<Expr>, Env, Rc<Cont>),
54+ /// check the rhs of && / || evaluated to a Bool
55+ ExpectBool(&'static str, Rc<Cont>),
56+ /// `or` after both evaluated as functions: build the lifted function
57+ OrBoth(Value, Rc<Cont>),
58+ /// a shared continuation (used when both `or` outcomes continue the same way)
59+ Shared(Rc<Cont>),
60+ /// unconditionally crash with a fixed message (deferred-body failure)
61+ CrashWith(String),
62+ /// run the continuation's computation, catching crashes: on success pass
63+ /// the value to `on_ok`, on crash switch to `on_crash` (§4.6)
64+ Try {
65+ on_ok: Option<TryOk>,
66+ snapshot: u64,
67+ on_crash: Box<State>,
68+ },
69+ /// applying a Labelled: apply inner `labelled` result to the real arg
70+ LabelledArg(Value, Rc<Cont>),
71+ UpdateBase(Vec<(String, Rc<Expr>)>, Env, Rc<Cont>),
72+ UpdateFields {
73+ base: Value,
74+ names: Vec<String>,
75+ exprs: Vec<Rc<Expr>>,
76+ acc: Vec<(String, Value)>,
77+ env: Env,
78+ cont: Rc<Cont>,
79+ },
80+ RecordFields {
81+ names: Vec<String>,
82+ exprs: Vec<Rc<Expr>>,
83+ acc: Vec<(String, Value)>,
84+ env: Env,
85+ cont: Rc<Cont>,
86+ },
87+ ListElems {
88+ exprs: Vec<Rc<Expr>>,
89+ acc: Vec<Value>,
90+ env: Env,
91+ cont: Rc<Cont>,
92+ },
93+ /// check a fully-applied result against a contract expression
94+ CheckExpr {
95+ name: String,
96+ cexpr: Rc<crate::shape::ContractExpr>,
97+ cont: Rc<Cont>,
98+ },
99+ /// after applying, check the (partial) result against the contract
100+ #[allow(dead_code)]
101+ CheckResult {
102+ name: String,
103+ contract: Rc<Contract>,
104+ supplied: usize,
105+ cont: Rc<Cont>,
106+ },
107+}
108+
109+pub type EResult = Result<Value, Crash>;
110+
111+/// what to do with a value whose evaluation was guarded by `Try`
112+#[derive(Clone)]
113+pub(crate) enum TryOk {
114+ /// the lhs of `or`: a function value lifts over the rhs, anything else is
115+ /// the result
116+ OrLhs {
117+ rhs: Rc<Expr>,
118+ env: Env,
119+ cont: Rc<Cont>,
120+ },
121+ /// the deferred body of a signed definition: apply it to the pending arg
122+ DeferredBody { arg: Value, cont: Rc<Cont> },
123+ /// the inner leg of a lazy composition: apply the outer function
124+ ComposeInner { f: Value, cont: Rc<Cont> },
125+ /// the lhs of a lifted `or` applied to an argument
126+ OrFunLhs { cont: Rc<Cont> },
127+}
128+
129+pub(crate) enum Run {
130+ Step(State),
131+ Done(Value),
132+ Crash(Crash),
133+}
134+
135+/// the parent continuation Rc of `k`, for iterative drop and unwinding
136+fn parent_rc(k: &Cont) -> Option<&Rc<Cont>> {
137+ match k {
138+ Cont::Halt | Cont::CrashWith(_) | Cont::Try { .. } => None,
139+ Cont::Arg(_, _, k) => Some(k),
140+ Cont::Fun(_, k) => Some(k),
141+ Cont::If(_, _, _, k) => Some(k),
142+ Cont::LetRest { cont, .. } => Some(cont),
143+ Cont::And(_, _, k) => Some(k),
144+ Cont::Or2(_, _, k) => Some(k),
145+ Cont::ExpectBool(_, k) => Some(k),
146+ Cont::OrBoth(_, k) => Some(k),
147+ Cont::Shared(k) => Some(k),
148+ Cont::LabelledArg(_, k) => Some(k),
149+ Cont::UpdateBase(_, _, k) => Some(k),
150+ Cont::UpdateFields { cont, .. } => Some(cont),
151+ Cont::RecordFields { cont, .. } => Some(cont),
152+ Cont::ListElems { cont, .. } => Some(cont),
153+ Cont::CheckExpr { cont, .. } => Some(cont),
154+ Cont::CheckResult { cont, .. } => Some(cont),
155+ }
156+}
157+
158+/// the parent continuation of `k`, for unwinding to the nearest Try on crash
159+pub(crate) fn pop_cont(k: &Cont) -> Option<Cont> {
160+ parent_rc(k).map(|r| (**r).clone())
161+}
162+
163+impl Interp {
164+ pub fn new(backend: Rc<dyn Backend>, shapes: Shapes, globals: Env) -> Interp {
165+ Interp {
166+ backend,
167+ shapes,
168+ globals,
169+ contracts: HashMap::new(),
170+ fresh: RefCell::new(0),
171+ current_def: RefCell::new(None),
172+ old_repo: RefCell::new(None),
173+ }
174+ }
175+
176+ /// An interpreter shell used only for recursive rendering; panics if any
177+ /// backend interaction is attempted.
178+ pub fn dummy() -> Interp {
179+ Interp {
180+ backend: Rc::new(crate::domain::MemBackend::new()),
181+ shapes: Shapes::new(),
182+ globals: Env::empty(),
183+ contracts: HashMap::new(),
184+ fresh: RefCell::new(0),
185+ current_def: RefCell::new(None),
186+ old_repo: RefCell::new(None),
187+ }
188+ }
189+
190+ pub fn mint_id(&self) -> String {
191+ // minted ids must not collide with stored ids across runs; seed from
192+ // time and pid, then count
193+ let mut n = self.fresh.borrow_mut();
194+ if *n == 0 {
195+ let seed = std::time::SystemTime::now()
196+ .duration_since(std::time::UNIX_EPOCH)
197+ .map(|d| d.as_nanos() as u64)
198+ .unwrap_or(0x9e3779b97f4a7c15)
199+ ^ (std::process::id() as u64).wrapping_mul(0x9e3779b97f4a7c15);
200+ *n = seed | 1;
201+ }
202+ let x = *n;
203+ *n = n.wrapping_add(0x9e3779b97f4a7c15);
204+ // 64 bits over 16 letters (k..z): 16 hex digits = 32 chars
205+ let mut s = String::with_capacity(32);
206+ for i in 0..32 {
207+ let d = ((x >> ((i % 16) * 4)) & 0xf) as u8;
208+ s.push((b'k' + d) as char);
209+ }
210+ s
211+ }
212+
213+ pub fn global_env(&self) -> Env {
214+ self.globals.clone()
215+ }
216+
217+ /// Main entry: evaluate an expression in an environment.
218+ pub fn eval(&mut self, e: &Rc<Expr>, env: &Env) -> EResult {
219+ self.run(State::Eval(e.clone(), env.clone(), Cont::Halt))
220+ }
221+
222+ pub fn apply(&mut self, f: Value, arg: Value) -> EResult {
223+ self.run(State::Apply(f, arg, Cont::Halt))
224+ }
225+
226+ fn decorate(&self, mut c: Crash) -> Crash {
227+ if c.def.is_none() {
228+ c.def = self.current_def.borrow().clone();
229+ }
230+ c
231+ }
232+
233+ fn run(&mut self, st: State) -> EResult {
234+ match self.trampoline(st) {
235+ Ok(v) => Ok(v),
236+ Err(c) => Err(self.decorate(c)),
237+ }
238+ }
239+
240+ fn trampoline(&mut self, st: State) -> EResult {
241+ let mut st = st;
242+ // the continuation that produced the current state; used to unwind to
243+ // the nearest Try on crash
244+ let mut active: Cont;
245+ loop {
246+ let outcome = match st {
247+ State::Ret(v, k) => {
248+ active = k.clone();
249+ self.step_ret(v, k)
250+ }
251+ State::Eval(e, env, k) => {
252+ active = k.clone();
253+ self.step_eval(e, env, k)
254+ }
255+ State::Apply(f, a, k) => {
256+ active = k.clone();
257+ self.step_apply(f, a, k)
258+ }
259+ };
260+ match outcome {
261+ Run::Step(next) => st = next,
262+ Run::Done(v) => return Ok(v),
263+ Run::Crash(c) => {
264+ // unwind to the nearest Try continuation, restoring the
265+ // fresh-id snapshot so a caught crash leaves no trace.
266+ // A `Shared` continuation links back to a context already
267+ // containing a Try, so track visited Try nodes and skip
268+ // ones already unwound past (a caught crash must not be
269+ // re-caught by the same Try, which would loop forever).
270+ let mut k = Some(active.clone());
271+ let mut visited: Vec<*const Cont> = Vec::new();
272+ let caught = loop {
273+ match k {
274+ Some(Cont::Try {
275+ snapshot, on_crash, ..
276+ }) => {
277+ let ptr = &*on_crash as *const State as *const Cont;
278+ let already = visited.contains(&ptr);
279+ visited.push(ptr);
280+ if already {
281+ k = None;
282+ continue;
283+ }
284+ *self.fresh.borrow_mut() = snapshot;
285+ break Some(*on_crash);
286+ }
287+ Some(other) => k = pop_cont(&other),
288+ None => break None,
289+ }
290+ };
291+ match caught {
292+ Some(next) => st = next,
293+ None => return Err(c),
294+ }
295+ }
296+ }
297+ }
298+ }
299+
300+ // ------------------------------------------------------------------
301+ fn step_eval(&mut self, e: Rc<Expr>, env: Env, k: Cont) -> Run {
302+ match &*e {
303+ Expr::Int(n) => Run::Step(State::Ret(Value::Int(n.clone()), k)),
304+ Expr::Text(s) => Run::Step(State::Ret(Value::text(s.clone()), k)),
305+ Expr::Bool(b) => Run::Step(State::Ret(Value::Bool(*b), k)),
306+ Expr::Id(id) => Run::Step(State::Ret(Value::Id(Rc::new(id.clone())), k)),
307+ Expr::IdLit(_) => Run::Crash(Crash::new("internal: unresolved id literal")),
308+ Expr::NewId => {
309+ let id = self.mint_id();
310+ Run::Step(State::Ret(Value::Id(Rc::new(id)), k))
311+ }
312+ Expr::LabelLit(name) => {
313+ let labelled = match env.lookup("labelled") {
314+ Some(v) => v,
315+ None => return Run::Crash(Crash::new("`labelled` is not defined")),
316+ };
317+ Run::Step(State::Ret(
318+ Value::Fun(Rc::new(FunVal::Labelled(name.clone(), labelled))),
319+ k,
320+ ))
321+ }
322+ Expr::PathLit(comps) => Run::Step(State::Ret(
323+ Value::list(comps.iter().map(|c| Value::text(c.clone())).collect()),
324+ k,
325+ )),
326+ Expr::Var(n) => match env.lookup(n) {
327+ Some(v) => Run::Step(State::Ret(v, k)),
328+ None => {
329+
330+ Run::Crash(Crash::new(format!("unbound name `{}`", n)))
331+ }
332+ },
333+ Expr::TypeName(n) => match self.shapes.shape_of(n) {
334+ Some(s) => Run::Step(State::Ret(Value::Shape(Rc::new(s)), k)),
335+ None => Run::Crash(Crash::new(format!("`{}` does not name a usable shape", n))),
336+ },
337+ Expr::SelectorFun(f) => Run::Step(State::Ret(crate::builtins::make_selector(f), k)),
338+ Expr::Lambda(params, body, src) => Run::Step(State::Ret(
339+ Value::Fun(Rc::new(FunVal::Closure {
340+ name: None,
341+ params: params.clone(),
342+ applied: 0,
343+ applied_args: Vec::new(),
344+ deferred: false,
345+ body: body.clone(),
346+ env,
347+ src: src.clone(),
348+ pending: None,
349+ })),
350+ k,
351+ )),
352+ Expr::If(c, t, f) => Run::Step(State::Eval(
353+ c.clone(),
354+ env.clone(),
355+ Cont::If(t.clone(), f.clone(), env, Rc::new(k)),
356+ )),
357+ Expr::Let(bs, body) => {
358+ if bs.is_empty() {
359+ return Run::Step(State::Eval(body.clone(), env, k));
360+ }
361+ let names: Vec<String> = bs.iter().map(|(n, _)| n.clone()).collect();
362+ let exprs: Vec<Rc<Expr>> = bs.iter().map(|(_, e)| e.clone()).collect();
363+ // one recursive frame shared by every binding and the body, so
364+ // the block is mutually recursive (§4.1)
365+ let (env2, cell) = env.extend_rec();
366+ Run::Step(State::Eval(
367+ exprs[0].clone(),
368+ env2.clone(),
369+ Cont::LetRest {
370+ names,
371+ exprs,
372+ acc: Vec::new(),
373+ cell,
374+ body: body.clone(),
375+ env: env2,
376+ cont: Rc::new(k),
377+ },
378+ ))
379+ }
380+ Expr::App(f, x) => Run::Step(State::Eval(
381+ f.clone(),
382+ env.clone(),
383+ Cont::Arg(x.clone(), env, Rc::new(k)),
384+ )),
385+ Expr::BinOp(op, a, b) => {
386+ if *op == "&&" {
387+ return Run::Step(State::Eval(
388+ a.clone(),
389+ env.clone(),
390+ Cont::And(b.clone(), env, Rc::new(k)),
391+ ));
392+ }
393+ if *op == "||" {
394+ return Run::Step(State::Eval(
395+ a.clone(),
396+ env.clone(),
397+ Cont::Or2(b.clone(), env, Rc::new(k)),
398+ ));
399+ }
400+ let fv = match env.lookup(op) {
401+ Some(v) => v,
402+ None => return Run::Crash(Crash::new(format!("unbound name `{}`", op))),
403+ };
404+ // eval a, apply the operator to it, then apply the result to b
405+ Run::Step(State::Eval(
406+ a.clone(),
407+ env.clone(),
408+ Cont::Fun(fv, Rc::new(Cont::Arg(b.clone(), env, Rc::new(k)))),
409+ ))
410+ }
411+ Expr::Or(a, b) => {
412+ // evaluate the lhs, catching crashes (§4.6); the Try
413+ // continuation keeps this on the heap, so `or`-recursive
414+ // walks (top/tip) do not consume native stack
415+ let k = Rc::new(k);
416+ Run::Step(State::Eval(
417+ a.clone(),
418+ env.clone(),
419+ Cont::Try {
420+ on_ok: Some(TryOk::OrLhs {
421+ rhs: b.clone(),
422+ env: env.clone(),
423+ cont: k.clone(),
424+ }),
425+ snapshot: *self.fresh.borrow(),
426+ on_crash: Box::new(State::Eval(b.clone(), env, Cont::Shared(k))),
427+ },
428+ ))
429+ }
430+ Expr::Select(base, f) => {
431+ let sel = crate::builtins::make_selector(f);
432+ Run::Step(State::Eval(base.clone(), env, Cont::Fun(sel, Rc::new(k))))
433+ }
434+ Expr::Update(base, fields) => Run::Step(State::Eval(
435+ base.clone(),
436+ env.clone(),
437+ Cont::UpdateBase(fields.clone(), env, Rc::new(k)),
438+ )),
439+ Expr::Record(fields) => {
440+ if fields.is_empty() {
441+ let m = crate::value::RecordMap::new();
442+ return Run::Step(State::Ret(Value::Record(Rc::new(m)), k));
443+ }
444+ let names: Vec<String> = fields.iter().map(|(n, _)| n.clone()).collect();
445+ let exprs: Vec<Rc<Expr>> = fields.iter().map(|(_, v)| v.clone()).collect();
446+ Run::Step(State::Eval(
447+ exprs[0].clone(),
448+ env.clone(),
449+ Cont::RecordFields {
450+ names,
451+ exprs,
452+ acc: Vec::new(),
453+ env,
454+ cont: Rc::new(k),
455+ },
456+ ))
457+ }
458+ Expr::List(elems) => {
459+ if elems.is_empty() {
460+ return Run::Step(State::Ret(Value::list(vec![]), k));
461+ }
462+ Run::Step(State::Eval(
463+ elems[0].clone(),
464+ env.clone(),
465+ Cont::ListElems {
466+ exprs: elems.clone(),
467+ acc: Vec::new(),
468+ env,
469+ cont: Rc::new(k),
470+ },
471+ ))
472+ }
473+ Expr::Paren(inner) => Run::Step(State::Eval(inner.clone(), env, k)),
474+ Expr::Crash => unreachable!(),
475+ }
476+ }
477+
478+ // ------------------------------------------------------------------
479+ fn step_ret(&mut self, v: Value, k: Cont) -> Run {
480+ match k {
481+ Cont::Halt => Run::Done(v),
482+ Cont::Arg(x, env, k) => Run::Step(State::Eval(x, env, Cont::Fun(v, k))),
483+ Cont::Fun(f, k) => Run::Step(State::Apply(f, v, (*k).clone())),
484+ Cont::If(t, f, env, k) => match v {
485+ Value::Bool(true) => Run::Step(State::Eval(t, env, (*k).clone())),
486+ Value::Bool(false) => Run::Step(State::Eval(f, env, (*k).clone())),
487+ _ => Run::Crash(Crash::new(format!(
488+ "if: expected a Bool condition, got a {}",
489+ v.kind_name()
490+ ))),
491+ },
492+ Cont::LetRest {
493+ names,
494+ exprs,
495+ mut acc,
496+ cell,
497+ body,
498+ env,
499+ cont,
500+ } => {
501+ let idx = acc.len();
502+ let pair = (names[idx].clone(), v);
503+ cell.borrow_mut().push(pair.clone());
504+ acc.push(pair);
505+ if acc.len() == names.len() {
506+ Run::Step(State::Eval(body, env, (*cont).clone()))
507+ } else {
508+ let next = exprs[acc.len()].clone();
509+ Run::Step(State::Eval(
510+ next,
511+ env.clone(),
512+ Cont::LetRest {
513+ names,
514+ exprs,
515+ acc,
516+ cell,
517+ body,
518+ env,
519+ cont,
520+ },
521+ ))
522+ }
523+ }
524+ Cont::And(b, env, k) => match v {
525+ Value::Bool(false) => Run::Step(State::Ret(Value::Bool(false), (*k).clone())),
526+ Value::Bool(true) => Run::Step(State::Eval(b, env, Cont::ExpectBool("&&", k))),
527+ _ => Run::Crash(Crash::new(format!("&&: expected Bool, got a {}", v.kind_name()))),
528+ },
529+ Cont::Or2(b, env, k) => match v {
530+ Value::Bool(true) => Run::Step(State::Ret(Value::Bool(true), (*k).clone())),
531+ Value::Bool(false) => Run::Step(State::Eval(b, env, Cont::ExpectBool("||", k))),
532+ _ => Run::Crash(Crash::new(format!("||: expected Bool, got a {}", v.kind_name()))),
533+ },
534+ Cont::ExpectBool(op, k) => match v {
535+ Value::Bool(_) => Run::Step(State::Ret(v, (*k).clone())),
536+ _ => Run::Crash(Crash::new(format!(
537+ "{}: expected Bool, got a {}",
538+ op,
539+ v.kind_name()
540+ ))),
541+ },
542+ Cont::OrBoth(a, k) => {
543+ if matches!(v, Value::Fun(_)) {
544+ Run::Step(State::Ret(Value::Fun(Rc::new(FunVal::OrFun(a, v))), (*k).clone()))
545+ } else {
546+ Run::Step(State::Ret(a, (*k).clone()))
547+ }
548+ }
549+ Cont::Shared(k) => {
550+ // both `or` outcomes continue here; unwrap the shared
551+ // continuation and continue it plainly
552+ Run::Step(State::Ret(v, (*k).clone()))
553+ }
554+ Cont::Try {
555+ on_ok,
556+ snapshot: _,
557+ on_crash: _,
558+ } => {
559+ // the guarded computation succeeded; route the value
560+ match on_ok {
561+ Some(TryOk::OrLhs { rhs, env, cont }) => {
562+ if matches!(v, Value::Fun(_)) {
563+ Run::Step(State::Eval(
564+ rhs,
565+ env,
566+ Cont::OrBoth(v, Rc::new(Cont::Shared(cont))),
567+ ))
568+ } else {
569+ Run::Step(State::Ret(v, Cont::Shared(cont)))
570+ }
571+ }
572+ Some(TryOk::DeferredBody { arg, cont, .. }) => {
573+ Run::Step(State::Apply(v, arg, (*cont).clone()))
574+ }
575+ Some(TryOk::ComposeInner { f, cont }) => Run::Step(State::Apply(f, v, (*cont).clone())),
576+ Some(TryOk::OrFunLhs { cont, .. }) => Run::Step(State::Ret(v, (*cont).clone())),
577+ None => Run::Crash(Crash::new("internal: Try with no success handler")),
578+ }
579+ }
580+ Cont::LabelledArg(real, k) => Run::Step(State::Apply(v, real, (*k).clone())),
581+ Cont::CrashWith(msg) => Run::Crash(Crash::new(msg)),
582+ Cont::UpdateBase(fields, env, k) => {
583+ if !matches!(v, Value::Record(_)) {
584+ return Run::Crash(Crash::new(format!(
585+ "cannot update fields of a {}",
586+ v.kind_name()
587+ )));
588+ }
589+ let names: Vec<String> = fields.iter().map(|(n, _)| n.clone()).collect();
590+ let exprs: Vec<Rc<Expr>> = fields.iter().map(|(_, e)| e.clone()).collect();
591+ if exprs.is_empty() {
592+ return Run::Step(State::Ret(v, (*k).clone()));
593+ }
594+ Run::Step(State::Eval(
595+ exprs[0].clone(),
596+ env.clone(),
597+ Cont::UpdateFields {
598+ base: v,
599+ names,
600+ exprs,
601+ acc: Vec::new(),
602+ env,
603+ cont: k,
604+ },
605+ ))
606+ }
607+ Cont::UpdateFields {
608+ base,
609+ names,
610+ exprs,
611+ mut acc,
612+ env,
613+ cont,
614+ } => {
615+ let idx = acc.len();
616+ acc.push((names[idx].clone(), v));
617+ if acc.len() == names.len() {
618+ let mut m = match &base {
619+ Value::Record(m) => m.as_ref().clone(),
620+ _ => unreachable!(),
621+ };
622+ for (n, val) in acc {
623+ if !m.contains_key(&n) {
624+ return Run::Crash(Crash::new(format!(
625+ "cannot update: record has no field `{}`",
626+ n
627+ )));
628+ }
629+ m.insert(n, val);
630+ }
631+ Run::Step(State::Ret(Value::Record(Rc::new(m)), (*cont).clone()))
632+ } else {
633+ let next = exprs[acc.len()].clone();
634+ Run::Step(State::Eval(
635+ next,
636+ env.clone(),
637+ Cont::UpdateFields {
638+ base,
639+ names,
640+ exprs,
641+ acc,
642+ env,
643+ cont,
644+ },
645+ ))
646+ }
647+ }
648+ Cont::RecordFields {
649+ names,
650+ exprs,
651+ mut acc,
652+ env,
653+ cont,
654+ } => {
655+ let idx = acc.len();
656+ acc.push((names[idx].clone(), v));
657+ if acc.len() == names.len() {
658+ let m: crate::value::RecordMap = acc.into_iter().collect();
659+ Run::Step(State::Ret(Value::Record(Rc::new(m)), (*cont).clone()))
660+ } else {
661+ let next = exprs[acc.len()].clone();
662+ Run::Step(State::Eval(
663+ next,
664+ env.clone(),
665+ Cont::RecordFields {
666+ names,
667+ exprs,
668+ acc,
669+ env,
670+ cont,
671+ },
672+ ))
673+ }
674+ }
675+ Cont::ListElems {
676+ exprs,
677+ mut acc,
678+ env,
679+ cont,
680+ } => {
681+ acc.push(v);
682+ if acc.len() == exprs.len() {
683+ Run::Step(State::Ret(Value::list(acc), (*cont).clone()))
684+ } else {
685+ let next = exprs[acc.len()].clone();
686+ Run::Step(State::Eval(
687+ next,
688+ env.clone(),
689+ Cont::ListElems {
690+ exprs,
691+ acc,
692+ env,
693+ cont,
694+ },
695+ ))
696+ }
697+ }
698+ Cont::CheckExpr { name, cexpr, cont } => {
699+ if let Err(cr) = cexpr.check_result(&self.shapes, &name, &v) {
700+ return Run::Crash(cr);
701+ }
702+ Run::Step(State::Ret(v, (*cont).clone()))
703+ }
704+ Cont::CheckResult {
705+ name,
706+ contract,
707+ supplied,
708+ cont,
709+ } => {
710+ if supplied < contract.params.len() {
711+ if !matches!(v, Value::Fun(_)) {
712+ return Run::Crash(Crash::new(format!(
713+ "contract: {} expected a function, got {}",
714+ name,
715+ v.kind_name()
716+ )));
717+ }
718+ // the result is itself checked against the next parameter
719+ // when it is applied further (deferred)
720+ } else if let Err(msg) = contract_check(&self.shapes, &contract.result, &v) {
721+ return Run::Crash(Crash::new(format!(
722+ "contract: {}: result {}",
723+ name, msg
724+ )));
725+ }
726+ Run::Step(State::Ret(v, (*cont).clone()))
727+ }
728+ }
729+ }
730+
731+ // ------------------------------------------------------------------
732+ fn step_apply(&mut self, f: Value, arg: Value, k: Cont) -> Run {
733+ match &f {
734+ Value::Fun(fv) => match fv.as_ref() {
735+ FunVal::Builtin {
736+ name,
737+ arity,
738+ args,
739+ f: bf,
740+ pending,
741+ } => {
742+ let mut all = args.clone();
743+ let (cname, cexpr) = match pending {
744+ Some((n, c)) => (n.clone(), Some(c.clone())),
745+ None => (name.clone(), None),
746+ };
747+ // compose nodes handle function arguments pointwise in
748+ // compose_apply; do not contract-check them here
749+ let skip_check = name == "(.)" && matches!(arg, Value::Fun(_));
750+ if let Some(c) = &cexpr {
751+ if !skip_check {
752+ if let Err(cr) = c.check_arg_at(&self.shapes, &cname, args.len(), &arg) {
753+ return Run::Crash(cr);
754+ }
755+ }
756+ }
757+ all.push(arg);
758+ let advanced = cexpr
759+ .map(|c| Rc::new(crate::shape::ContractExpr::apply_first_rc(c)));
760+ if all.len() == *arity {
761+ match bf(self, &all) {
762+ Ok(r) => {
763+ // compose applications check results
764+ // incrementally as the composition runs
765+ if let Some(c) = &advanced {
766+ if name != "(.)" {
767+ if let Err(cr) =
768+ c.check_result(&self.shapes, &cname, &r)
769+ {
770+ return Run::Crash(cr);
771+ }
772+ }
773+ }
774+ Run::Step(State::Ret(r, k))
775+ }
776+ Err(c) => Run::Crash(c),
777+ }
778+ } else {
779+ Run::Step(State::Ret(
780+ Value::Fun(Rc::new(FunVal::Builtin {
781+ name: name.clone(),
782+ arity: *arity,
783+ args: all,
784+ f: *bf,
785+ pending: advanced.map(|c| (cname, c)),
786+ })),
787+ k,
788+ ))
789+ }
790+ }
791+ FunVal::Closure {
792+ name,
793+ params,
794+ applied,
795+ applied_args,
796+ deferred,
797+ body,
798+ env,
799+ src,
800+ pending,
801+ } => {
802+ if *deferred {
803+ // the body was deferred at the last application:
804+ // evaluate it now (catching crashes), then apply
805+ let msg = format!(
806+ "{}: body crashed when applied",
807+ name.clone().unwrap_or_default()
808+ );
809+ return Run::Step(State::Eval(
810+ body.clone(),
811+ env.clone(),
812+ Cont::Try {
813+ on_ok: Some(TryOk::DeferredBody {
814+ arg,
815+ cont: Rc::new(k),
816+ }),
817+ snapshot: *self.fresh.borrow(),
818+ on_crash: Box::new(State::Ret(
819+ Value::Bool(false),
820+ Cont::CrashWith(msg),
821+ )),
822+ },
823+ ));
824+ }
825+ let (cname, cexpr) = match pending {
826+ Some((n, c)) => (Some(n.clone()), Some(c.clone())),
827+ None => (name.clone(), None),
828+ };
829+ // A signed definition's application to a function value is
830+ // tried as a plain application first; if that crashes (as
831+ // `abandon (contract everything)` does at load time, since
832+ // the definition is a Repo-level function), it is function
833+ // composition instead (§4.1 note: the composition is a
834+ // value before it is applied to the repository).
835+ if *applied == 0 && matches!(arg, Value::Fun(_)) && pending.is_some() {
836+ // probe: contract-check the argument; if it fails, the
837+ // application is composition instead (§4.1 note)
838+ let probe_ok = match &cexpr {
839+ Some(c) => {
840+ let r = c.check_arg(&self.shapes, "", &arg).is_ok();
841+ r
842+ }
843+ None => true,
844+ };
845+ if !probe_ok {
846+ let f = Value::Fun(Rc::new(FunVal::Closure {
847+ name: name.clone(),
848+ params: params.clone(),
849+ applied: *applied,
850+ applied_args: applied_args.clone(),
851+ deferred: false,
852+ body: body.clone(),
853+ env: env.clone(),
854+ src: src.clone(),
855+ pending: pending.clone(),
856+ }));
857+ return match crate::builtins::compose_values(self, f, arg) {
858+ Ok(composed) => Run::Step(State::Ret(composed, k)),
859+ Err(cr) => Run::Crash(cr),
860+ };
861+ }
862+ }
863+ if let Some(c) = &cexpr {
864+ let fname = cname.clone().unwrap_or_default();
865+ if let Err(cr) = c.check_arg_at(&self.shapes, &fname, applied_args.len(), &arg) {
866+ return Run::Crash(cr);
867+ }
868+ }
869+ let p = &params[*applied];
870+ let env2 = match p {
871+ Pattern::Var(n) => env.extend(vec![(n.clone(), arg.clone())]),
872+ Pattern::Wildcard => env.clone(),
873+ };
874+ let new_applied = applied + 1;
875+ let advanced = cexpr
876+ .map(|c| Rc::new(crate::shape::ContractExpr::apply_first_rc(c)));
877+ // a signed definition's body is not evaluated until its
878+ // contract is exhausted, so partial applications of named
879+ // definitions stay named values (§5.2's `describe "wip"`)
880+ let contract_exhausted = match &advanced {
881+ Some(c) => c.is_exhausted(),
882+ None => true,
883+ };
884+ if new_applied == params.len() && contract_exhausted {
885+ Run::Step(State::Eval(
886+ body.clone(),
887+ env2,
888+ match (&cname, &advanced) {
889+ (Some(n), Some(c)) => Cont::CheckExpr {
890+ name: n.clone(),
891+ cexpr: c.clone(),
892+ cont: Rc::new(k),
893+ },
894+ _ => k,
895+ },
896+ ))
897+ } else if new_applied == params.len() {
898+ // the lambda is exhausted but the contract is not:
899+ // defer the body so the definition stays a named
900+ // partial application (§5.2)
901+ let mut new_args = applied_args.clone();
902+ new_args.push(arg.clone());
903+ let v = Value::Fun(Rc::new(FunVal::Closure {
904+ name: name.clone(),
905+ params: params.clone(),
906+ applied: new_applied,
907+ applied_args: new_args,
908+ deferred: true,
909+ body: body.clone(),
910+ env: env2,
911+ src: src.clone(),
912+ pending: match (&cname, &advanced) {
913+ (Some(n), Some(c)) => Some((n.clone(), c.clone())),
914+ _ => None,
915+ },
916+ }));
917+ Run::Step(State::Ret(v, k))
918+ } else if contract_exhausted {
919+ // lambda params remain but the contract is satisfied;
920+ // keep currying (unsigned tail)
921+ let mut new_args = applied_args.clone();
922+ new_args.push(arg.clone());
923+ let v = Value::Fun(Rc::new(FunVal::Closure {
924+ name: name.clone(),
925+ params: params.clone(),
926+ applied: new_applied,
927+ applied_args: new_args,
928+ deferred: false,
929+ body: body.clone(),
930+ env: env2,
931+ src: src.clone(),
932+ pending: match (&cname, &advanced) {
933+ (Some(n), Some(c)) => Some((n.clone(), c.clone())),
934+ _ => None,
935+ },
936+ }));
937+ Run::Step(State::Ret(v, k))
938+ } else {
939+ let mut new_args = applied_args.clone();
940+ new_args.push(match &arg { v => v.clone() });
941+ let v = Value::Fun(Rc::new(FunVal::Closure {
942+ name: name.clone(),
943+ params: params.clone(),
944+ applied: new_applied,
945+ applied_args: new_args,
946+ deferred: false,
947+ body: body.clone(),
948+ env: env2,
949+ src: src.clone(),
950+ pending: match (&cname, &advanced) {
951+ (Some(n), Some(c)) => Some((n.clone(), c.clone())),
952+ _ => None,
953+ },
954+ }));
955+ Run::Step(State::Ret(v, k))
956+ }
957+ }
958+ FunVal::ComposeLazy(f, g) => {
959+ let f = f.clone();
960+ let g = g.clone();
961+ if matches!(arg, Value::Fun(_)) {
962+ // pointwise: (f . g) h = f (g h) — but g h is not
963+ // evaluated yet either; compose lazily
964+ let inner = Value::Fun(Rc::new(FunVal::ComposeLazy(g, arg)));
965+ Run::Step(State::Ret(
966+ Value::Fun(Rc::new(FunVal::ComposeLazy(f, inner))),
967+ k,
968+ ))
969+ } else {
970+ // apply g, catching crashes, then apply f to the result
971+ Run::Step(State::Apply(
972+ g,
973+ arg,
974+ Cont::Try {
975+ on_ok: Some(TryOk::ComposeInner {
976+ f,
977+ cont: Rc::new(k),
978+ }),
979+ snapshot: *self.fresh.borrow(),
980+ on_crash: Box::new(State::Ret(
981+ Value::Bool(false),
982+ Cont::CrashWith("composition crashed".to_string()),
983+ )),
984+ },
985+ ))
986+ }
987+ }
988+ FunVal::OrFun(a, b) => {
989+ // (f or g) x = f x or g x
990+ let a = a.clone();
991+ let b = b.clone();
992+ let k = Rc::new(k);
993+ Run::Step(State::Apply(
994+ a,
995+ arg.clone(),
996+ Cont::Try {
997+ on_ok: Some(TryOk::OrFunLhs { cont: k.clone() }),
998+ snapshot: *self.fresh.borrow(),
999+ on_crash: Box::new(State::Apply(b, arg, Cont::Shared(k))),
1000+ },
1001+ ))
1002+ }
1003+ FunVal::Labelled(name, labelled) => {
1004+ // %name x = labelled "name" x
1005+ let text = Value::text(name.clone());
1006+ Run::Step(State::Apply(
1007+ labelled.clone(),
1008+ text,
1009+ Cont::LabelledArg(arg, Rc::new(k)),
1010+ ))
1011+ }
1012+ },
1013+ _ => Run::Crash(Crash::new(format!(
1014+ "cannot apply a {} as a function",
1015+ f.kind_name()
1016+ ))),
1017+ }
1018+ }
1019+}
1020+
src/jj.rsnew file+2131
@@ -0,0 +1,2131 @@
1+//! jj-lib backend (§7): repository location (§7.1), building the `Repo`
2+//! value with snapshotting (§7.2/§7.4), persistence (§7.5), and the lock
3+//! (§7.7). Reserved commands (fetch/push/undo/ops/init/clone/remote) remain
4+//! stubs for a later session.
5+
6+use crate::config::Config;
7+use crate::domain::{Backend, MetaInfo, ROOT_ID};
8+use crate::eval::Interp;
9+use crate::value::{BlobContent, BlobKind, BlobVal, Crash, Value};
10+use jj_lib::backend::{CopyId, Signature, Timestamp, TreeValue};
11+use jj_lib::commit::Commit;
12+use jj_lib::config::{ConfigLayer, ConfigSource, StackedConfig};
13+use jj_lib::default_backend_factories::{default_backend_factories, default_working_copy_factories};
14+use jj_lib::gitignore::GitIgnoreFile;
15+use jj_lib::matchers::{EverythingMatcher, NothingMatcher};
16+use jj_lib::merge::{Diff, Merge};
17+use jj_lib::merged_tree::MergedTree;
18+use jj_lib::merged_tree_builder::MergedTreeBuilder;
19+use jj_lib::backend::{CommitId, FileId};
20+use jj_lib::object_id::ObjectId;
21+use jj_lib::op_store::OperationId;
22+use jj_lib::ref_name::{RefName, RemoteName, WorkspaceName, WorkspaceNameBuf};
23+use jj_lib::repo::{ReadonlyRepo, Repo};
24+use jj_lib::repo_path::{RepoPath, RepoPathBuf, RepoPathComponentBuf};
25+use jj_lib::settings::UserSettings;
26+use jj_lib::store::Store;
27+use jj_lib::working_copy::SnapshotOptions;
28+use jj_lib::workspace::Workspace;
29+use std::collections::{BTreeMap, BTreeSet};
30+use std::future::Future;
31+use std::os::unix::io::AsRawFd;
32+use std::path::PathBuf;
33+use std::rc::Rc;
34+use std::sync::{Arc, Mutex};
35+
36+fn block_on<F: Future>(f: F) -> F::Output {
37+ pollster::block_on(f)
38+}
39+
40+type OpenError = (u8, String);
41+
42+// ----------------------------------------------------------------------
43+// locating and opening the repository (§7.1)
44+// ----------------------------------------------------------------------
45+
46+fn find_repo_root() -> Option<PathBuf> {
47+ let cwd = std::env::current_dir().ok()?;
48+ for dir in cwd.ancestors() {
49+ if dir.join(".jj").is_dir() {
50+ return Some(dir.to_path_buf());
51+ }
52+ }
53+ None
54+}
55+
56+fn toml_string(s: &str) -> String {
57+ let mut out = String::with_capacity(s.len() + 2);
58+ out.push('"');
59+ for c in s.chars() {
60+ match c {
61+ '"' => out.push_str("\\\""),
62+ '\\' => out.push_str("\\\\"),
63+ '\n' => out.push_str("\\n"),
64+ '\r' => out.push_str("\\r"),
65+ '\t' => out.push_str("\\t"),
66+ c => out.push(c),
67+ }
68+ }
69+ out.push('"');
70+ out
71+}
72+
73+/// UserSettings from a StackedConfig containing only `user.name` and
74+/// `user.email` (§7.9). Falls back to a placeholder identity when the
75+/// config's `user` record cannot be evaluated here (undo/redo/ops read no
76+/// config, §6.1).
77+fn user_settings(cfg: Option<&Config>) -> UserSettings {
78+ let (name, email) = match cfg {
79+ Some(cfg) => crate::config::eval_user_only(&mut Interp::dummy(), cfg)
80+ .unwrap_or_else(|_| ("j".to_string(), "j".to_string())),
81+ None => ("j".to_string(), "j".to_string()),
82+ };
83+ let mut config = StackedConfig::with_defaults();
84+ let text = format!(
85+ "[user]\nname = {}\nemail = {}\n",
86+ toml_string(&name),
87+ toml_string(&email)
88+ );
89+ let layer =
90+ ConfigLayer::parse(ConfigSource::User, &text).expect("generated user config should parse");
91+ config.add_layer(layer);
92+ UserSettings::from_config(config).expect("generated user config should be valid")
93+}
94+
95+// ----------------------------------------------------------------------
96+// repo -> value mapping (§7.2)
97+// ----------------------------------------------------------------------
98+
99+struct StoredCommit {
100+ commit: Commit,
101+ change_id: String,
102+ /// change id of the first jj parent ("" for the root commit)
103+ first_parent: String,
104+ /// change ids of all jj parents ("" entries impossible: the root has none)
105+ all_parents: Vec<String>,
106+ committer_millis: i64,
107+}
108+
109+struct VisibleRepo {
110+ /// change id -> stored commit
111+ commits: BTreeMap<String, StoredCommit>,
112+ /// change id -> remote bookmarks of `origin` pointing at it (§7.6)
113+ labels: BTreeMap<String, Vec<String>>,
114+ /// change id -> child change ids (first-parent tree)
115+ children: BTreeMap<String, Vec<String>>,
116+ /// the default workspace's working-copy commit, as a change id
117+ wc_change_id: String,
118+}
119+
120+impl Clone for VisibleRepo {
121+ fn clone(&self) -> Self {
122+ VisibleRepo {
123+ commits: self
124+ .commits
125+ .iter()
126+ .map(|(k, v)| {
127+ (
128+ k.clone(),
129+ StoredCommit {
130+ commit: v.commit.clone(),
131+ change_id: v.change_id.clone(),
132+ first_parent: v.first_parent.clone(),
133+ all_parents: v.all_parents.clone(),
134+ committer_millis: v.committer_millis,
135+ },
136+ )
137+ })
138+ .collect(),
139+ labels: self.labels.clone(),
140+ children: self.children.clone(),
141+ wc_change_id: self.wc_change_id.clone(),
142+ }
143+ }
144+}
145+
146+async fn read_visible(repo: &Arc<ReadonlyRepo>) -> Result<VisibleRepo, OpenError> {
147+ let store = repo.store().clone();
148+ // visible commits = ancestors (inclusive) of the visible heads (§7.2)
149+ let mut commits: BTreeMap<String, StoredCommit> = BTreeMap::new();
150+ let mut children: BTreeMap<String, Vec<String>> = BTreeMap::new();
151+ let mut count_by_change: BTreeMap<String, usize> = BTreeMap::new();
152+ let mut visited: BTreeSet<CommitId> = BTreeSet::new();
153+ let mut stack: Vec<CommitId> = repo.view().heads().iter().cloned().collect();
154+ while let Some(id) = stack.pop() {
155+ if !visited.insert(id.clone()) {
156+ continue;
157+ }
158+ let commit = store
159+ .get_commit_async(&id)
160+ .await
161+ .map_err(|e| (2, format!("cannot read commit: {}", e)))?;
162+ let change_id = commit.change_id().reverse_hex();
163+ *count_by_change.entry(change_id.clone()).or_default() += 1;
164+ let mut first_parent = String::new();
165+ if let Some(first) = commit.parent_ids().first() {
166+ let parent_commit = store
167+ .get_commit_async(first)
168+ .await
169+ .map_err(|e| (2, format!("cannot read commit: {}", e)))?;
170+ let pcid = parent_commit.change_id().reverse_hex();
171+ children.entry(pcid.clone()).or_default().push(change_id.clone());
172+ first_parent = pcid;
173+ }
174+ let parent_ids = commit.parent_ids().to_vec();
175+ let mut all_parents = Vec::new();
176+ for pid in &parent_ids {
177+ let pcommit = store
178+ .get_commit_async(pid)
179+ .await
180+ .map_err(|e| (2, format!("cannot read commit: {}", e)))?;
181+ all_parents.push(pcommit.change_id().reverse_hex());
182+ }
183+ commits.insert(
184+ change_id.clone(),
185+ StoredCommit {
186+ committer_millis: commit.committer().timestamp.timestamp.0,
187+ commit,
188+ change_id,
189+ first_parent,
190+ all_parents,
191+ },
192+ );
193+ stack.extend(parent_ids);
194+ }
195+ // §7.2: one change id on two visible commits is unsupported
196+ for (change_id, n) in &count_by_change {
197+ if *n > 1 {
198+ return Err((
199+ 2,
200+ format!(
201+ "change {} has two visible commits; j cannot operate on this repository",
202+ change_id
203+ ),
204+ ));
205+ }
206+ }
207+
208+ // labels: remote bookmarks of `origin` pointing at a commit (§7.6)
209+ let mut labels: BTreeMap<String, Vec<String>> = BTreeMap::new();
210+ for (name, remote_ref) in repo.view().remote_bookmarks(RemoteName::new("origin")) {
211+ for cid in remote_ref.target.added_ids() {
212+ if let Ok(c) = store.get_commit_async(cid).await {
213+ labels
214+ .entry(c.change_id().reverse_hex())
215+ .or_default()
216+ .push(name.as_str().to_string());
217+ }
218+ }
219+ }
220+
221+ let wc_commit_id = repo
222+ .view()
223+ .get_wc_commit_id(WorkspaceName::DEFAULT)
224+ .cloned()
225+ .ok_or_else(|| (2, "the default workspace has no working-copy commit".to_string()))?;
226+ let wc_commit = store
227+ .get_commit_async(&wc_commit_id)
228+ .await
229+ .map_err(|e| (2, format!("cannot read the working-copy commit: {}", e)))?;
230+
231+ Ok(VisibleRepo {
232+ commits,
233+ labels,
234+ children,
235+ wc_change_id: wc_commit.change_id().reverse_hex(),
236+ })
237+}
238+
239+// ----------------------------------------------------------------------
240+// the backend
241+// ----------------------------------------------------------------------
242+
243+pub struct JjInner {
244+ workspace_root: PathBuf,
245+ /// the one workspace (and store) for the whole run; every repo load and
246+ /// working-copy mutation goes through it so trees always live in the
247+ /// same store
248+ workspace: Mutex<Workspace>,
249+ /// repo at the operation the program is working against; updated after a
250+ /// snapshot so persistence and the snapshot form one operation (§1.2)
251+ repo: Mutex<Arc<ReadonlyRepo>>,
252+ workspace_name: WorkspaceNameBuf,
253+ /// visible map backing the `Backend` lookups during evaluation; filled by
254+ /// build_interp with the post-snapshot map
255+ visible: Mutex<Option<Arc<VisibleRepo>>>,
256+ lock_guard: Mutex<Option<FileLock>>,
257+ /// the snapshot transaction and working-copy lock, held between
258+ /// build_interp and persist so the snapshot and the expression's edits
259+ /// form one jj operation (§1.2); dropped uncommitted by printing runs
260+ pending: Mutex<Option<PendingSnapshot>>,
261+}
262+
263+/// holding the file keeps the flock
264+pub struct FileLock(#[allow(dead_code)] std::fs::File);
265+
266+#[allow(dead_code)]
267+struct PendingSnapshot {
268+ /// repo as loaded at head, before the snapshot
269+ pre_repo: Arc<ReadonlyRepo>,
270+ /// the snapshot's rewritten wc commit and tree, to be folded into the
271+ /// persisting operation (§1.2 step 8, §7.7)
272+ new_wc_id: CommitId,
273+ tree: MergedTree,
274+}
275+
276+#[derive(Clone)]
277+pub struct JjBackend {
278+ inner: Arc<JjInner>,
279+}
280+
281+impl JjBackend {
282+ pub fn open(cfg: Option<&Config>) -> Result<JjBackend, OpenError> {
283+ let root = find_repo_root().ok_or_else(|| {
284+ (
285+ 2,
286+ "no jj repository found in this directory or any parent".to_string(),
287+ )
288+ })?;
289+ let settings = user_settings(cfg);
290+ let workspace = Workspace::load(
291+ &settings,
292+ &root,
293+ &default_backend_factories(),
294+ &default_working_copy_factories(),
295+ )
296+ .map_err(|e| (2, format!("cannot load the workspace: {}", e)))?;
297+ // only the default workspace is supported (§7.1)
298+ if workspace.workspace_name() != WorkspaceName::DEFAULT {
299+ return Err((
300+ 2,
301+ format!(
302+ "this working copy belongs to workspace `{}`; only the default workspace is supported",
303+ workspace.workspace_name().as_str()
304+ ),
305+ ));
306+ }
307+ let workspace_name = workspace.workspace_name().to_owned();
308+ let repo = block_on(workspace.repo_loader().load_at_head())
309+ .map_err(|e| (2, format!("cannot load the repository at head: {}", e)))?;
310+ Ok(JjBackend {
311+ inner: Arc::new(JjInner {
312+ workspace_root: root,
313+ workspace: Mutex::new(workspace),
314+ repo: Mutex::new(repo),
315+ workspace_name,
316+ visible: Mutex::new(None),
317+ lock_guard: Mutex::new(None),
318+ pending: Mutex::new(None),
319+ }),
320+ })
321+ }
322+
323+ fn current_repo(&self) -> Arc<ReadonlyRepo> {
324+ self.inner.repo.lock().unwrap().clone()
325+ }
326+
327+ pub fn take_lock(&self) {
328+ // an flock, not a lockfile: the OS releases it when the process dies,
329+ // so a killed j (SIGINT, §1.3) never bricks the repository
330+ let lock_path = self.inner.workspace_root.join(".jj").join("j.lock");
331+ let file = match std::fs::OpenOptions::new()
332+ .create(true)
333+ .write(true)
334+ .open(&lock_path)
335+ {
336+ Ok(f) => f,
337+ Err(e) => {
338+ eprintln!("j: cannot create {}: {}", lock_path.display(), e);
339+ std::process::exit(2);
340+ }
341+ };
342+ let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
343+ if rc != 0 {
344+ eprintln!("j: another j is running in this repository");
345+ std::process::exit(2);
346+ }
347+ *self.inner.lock_guard.lock().unwrap() = Some(FileLock(file));
348+ }
349+
350+ pub fn build_interp(
351+ &self,
352+ cfg: &Config,
353+ _text: &str,
354+ snapshot: bool,
355+ ) -> Result<(Interp, Value, Value), OpenError> {
356+ // reload at head: a previous run (or another tool) may have advanced
357+ // the operation since `open`
358+ let head_repo = block_on(self.inner.workspace.lock().unwrap().repo_loader().load_at_head())
359+ .map_err(|e| (2, format!("cannot reload the repository at head: {}", e)))?;
360+ *self.inner.repo.lock().unwrap() = head_repo;
361+ let loaded_visible = block_on(read_visible(&self.current_repo()))?;
362+ let loaded = build_repo_value(&loaded_visible, &loaded_visible.wc_change_id)?;
363+
364+ let current = if snapshot {
365+ // §7.4: snapshot the working directory into the wc commit; the
366+ // snapshot operation is written but unpublished — a persisting
367+ // run folds it into its own operation (§7.7)
368+ let (post, pending) = block_on(self.snapshot_repo())?;
369+ *self.inner.pending.lock().unwrap() = pending;
370+ let vis = block_on(read_visible(&post))?;
371+ let cur = build_repo_value(&vis, &vis.wc_change_id)?;
372+ *self.inner.visible.lock().unwrap() = Some(Arc::new(vis));
373+ cur
374+ } else {
375+ *self.inner.visible.lock().unwrap() = Some(Arc::new(loaded_visible));
376+ loaded.clone()
377+ };
378+
379+ let mut interp = Interp::new(
380+ Rc::new(self.clone()),
381+ cfg.shapes.clone(),
382+ crate::value::Env::empty(),
383+ );
384+ // §7.2: if the focused commit is in the immutable set, the snapshot
385+ // cannot go into it; the focus is a new empty child of it holding the
386+ // snapshot, and persisting records that child
387+ match self.focus_if_immutable(&mut interp, cfg, &current)? {
388+ Some(adjusted) => {
389+ // the persisted child is part of the loaded value too, so a
390+ // run that changes nothing is a no-op
391+ Ok((interp, adjusted.clone(), adjusted))
392+ }
393+ None => Ok((interp, loaded, current)),
394+ }
395+ }
396+
397+ /// If the focus of `current` is immutable, return a repo refocused on a
398+ /// new empty child holding the focus's files (§7.2).
399+ fn focus_if_immutable(
400+ &self,
401+ interp: &mut Interp,
402+ cfg: &Config,
403+ current: &Value,
404+ ) -> Result<Option<Value>, OpenError> {
405+ // evaluate the config to compute the immutable set
406+ let mut probe = Interp::new(
407+ Rc::new(self.clone()),
408+ cfg.shapes.clone(),
409+ crate::value::Env::empty(),
410+ );
411+ crate::config::eval_config(&mut probe, cfg).map_err(|c| (3, c.msg))?;
412+ let immutable = crate::repo::compute_immutable(&mut probe, current)
413+ .map_err(|c| (1, c.msg))?;
414+ let focus_id = match current.field("root").and_then(|r| r.field("id")) {
415+ Ok(Value::Id(i)) => i.to_string(),
416+ _ => return Ok(None),
417+ };
418+ if !immutable.contains(&focus_id) {
419+ return Ok(None);
420+ }
421+ // build: new empty child of the focus with the focus's files, minted id
422+ let _ = interp;
423+ let root = current.field("root").map_err(|c| (1, c.msg))?;
424+ let files = root.field("files").map_err(|c| (1, c.msg))?;
425+ let child = crate::value::Value::record(&[
426+ ("files", files),
427+ ("message", crate::value::Value::text("")),
428+ ("labels", crate::value::Value::list(vec![])),
429+ (
430+ "id",
431+ crate::value::Value::Id(Rc::new(probe.mint_id())),
432+ ),
433+ ]);
434+ let child_subtree = crate::value::Value::record(&[
435+ ("root", child.clone()),
436+ ("children", crate::value::Value::list(vec![])),
437+ ]);
438+ let mut new_children: Vec<Value> = current
439+ .field("children")
440+ .map_err(|c| (1, c.msg))?
441+ .as_list()
442+ .map_err(|c| (1, c.msg))?
443+ .to_vec();
444+ new_children.push(child_subtree);
445+ let parent = crate::value::Value::record(&[
446+ ("children", crate::value::Value::list(new_children)),
447+ ("context", current.field("context").map_err(|c| (1, c.msg))?),
448+ ("root", root),
449+ ]);
450+ // refocus onto the child (it is the last child)
451+ let child_id = match child.field("id").map_err(|c| (1, c.msg))? {
452+ Value::Id(i) => i.to_string(),
453+ _ => unreachable!(),
454+ };
455+ crate::repo::by_id(&parent, &child_id)
456+ .map_err(|c| (1, c.msg))?
457+ .ok_or_else(|| (1, "internal: cannot refocus".to_string()))
458+ .map(Some)
459+ }
460+
461+ /// Snapshot the working copy into the wc commit; returns the repo at the
462+ /// snapshot operation (unpublished but written), or the pre-snapshot repo
463+ /// if nothing changed.
464+ async fn snapshot_repo(
465+ &self,
466+ ) -> Result<(Arc<ReadonlyRepo>, Option<PendingSnapshot>), OpenError> {
467+ let mut ws_guard = self.inner.workspace.lock().unwrap();
468+ let loader = ws_guard.repo_loader().clone();
469+ let mut locked_ws = ws_guard.start_working_copy_mutation()
470+ .await
471+ .map_err(|e| (2, format!("cannot lock the working copy: {}", e)))?;
472+ let options = SnapshotOptions {
473+ base_ignores: GitIgnoreFile::empty(),
474+ progress: None,
475+ start_tracking_matcher: &EverythingMatcher,
476+ force_tracking_matcher: &NothingMatcher,
477+ max_new_file_size: u64::MAX,
478+ };
479+ let (new_tree, _stats) = locked_ws
480+ .locked_wc()
481+ .snapshot(&options)
482+ .await
483+ .map_err(|e| (2, format!("cannot snapshot the working copy: {}", e)))?;
484+ let changed = new_tree.tree_ids() != locked_ws.locked_wc().old_tree().tree_ids();
485+ if !changed {
486+ locked_ws
487+ .finish(self.current_repo().operation().id().clone())
488+ .await
489+ .map_err(|e| (2, format!("cannot finish the snapshot: {}", e)))?;
490+ return Ok((self.current_repo(), None));
491+ }
492+ // the snapshotted tree lives in the workspace's store; drive the
493+ // transaction from the workspace's repo loader so stores match
494+ let ws_repo = loader
495+ .load_at_head()
496+ .await
497+ .map_err(|e| (2, format!("cannot reload the repository: {}", e)))?;
498+ let mut tx = ws_repo.start_transaction();
499+ tx.set_is_snapshot(true);
500+ let wc_commit = self.wc_commit(&ws_repo).await?;
501+ let new_wc = tx
502+ .repo_mut()
503+ .rewrite_commit(&wc_commit)
504+ .set_tree(new_tree)
505+ .write()
506+ .await
507+ .map_err(|e| (2, format!("cannot write the snapshot commit: {}", e)))?;
508+ tx.repo_mut()
509+ .set_wc_commit(self.inner.workspace_name.clone(), new_wc.id().clone())
510+ .map_err(|e| (2, format!("cannot set the working-copy commit: {}", e)))?;
511+ tx.repo_mut()
512+ .rebase_descendants()
513+ .await
514+ .map_err(|e| (2, format!("cannot rebase descendants: {}", e)))?;
515+ // write but do NOT publish: a persisting run folds the snapshot into
516+ // its own operation (§7.7); a printing run discards it entirely
517+ let unpublished = tx
518+ .write("snapshot working copy")
519+ .await
520+ .map_err(|e| (2, format!("cannot write the snapshot operation: {}", e)))?;
521+ let op_id = unpublished.operation().id().clone();
522+ let new_repo = unpublished.leave_unpublished();
523+ // the working-copy state tracks the snapshot tree, against the
524+ // current (published) head; persist will finish against its own op
525+ locked_ws
526+ .finish(self.current_repo().operation().id().clone())
527+ .await
528+ .map_err(|e| (2, format!("cannot finish the snapshot: {}", e)))?;
529+ let _ = op_id;
530+ let pending = PendingSnapshot {
531+ pre_repo: self.current_repo(),
532+ new_wc_id: new_wc.id().clone(),
533+ tree: new_wc.tree().clone(),
534+ };
535+ Ok((new_repo, Some(pending)))
536+ }
537+
538+
539+
540+ async fn wc_commit(&self, repo: &Arc<ReadonlyRepo>) -> Result<Commit, OpenError> {
541+ let id = repo
542+ .view()
543+ .get_wc_commit_id(WorkspaceName::DEFAULT)
544+ .cloned()
545+ .ok_or_else(|| (2, "no working-copy commit".to_string()))?;
546+ repo.store()
547+ .get_commit_async(&id)
548+ .await
549+ .map_err(|e| (2, format!("cannot read the working-copy commit: {}", e)))
550+ }
551+
552+ pub fn persist(
553+ &self,
554+ interp: &mut Interp,
555+ cfg: &Config,
556+ loaded: &Value,
557+ _current: &Value,
558+ new: &Value,
559+ text: &str,
560+ ) -> Result<(), Crash> {
561+ if crate::value::value_eq(loaded, new)? {
562+ return Ok(());
563+ }
564+ *interp.old_repo.borrow_mut() = Some(loaded.clone());
565+ crate::repo::validate_repo(interp, new)?;
566+
567+ // fold the snapshot into this operation (§1.2 step 8): start from the
568+ // pre-snapshot repo and reapply the snapshot's wc rewrite here
569+ let pending = self.inner.pending.lock().unwrap().take();
570+ let base = match &pending {
571+ Some(p) => p.pre_repo.clone(),
572+ None => self.current_repo(),
573+ };
574+ let store = base.store().clone();
575+ let user_sig = self.user_signature(cfg)?;
576+
577+ let mut tx = base.start_transaction();
578+ // the snapshot's wc rewrite, folded into this operation (§1.2): the
579+ // walk below compares against its output, so the snapshot change and
580+ // the expression's edits become one rewrite
581+ let mut snapshot_wc: Option<Commit> = None;
582+ if let Some(p) = &pending {
583+ let wc_commit = block_on(self.wc_commit(&base)).map_err(|e| Crash::new(e.1))?;
584+ let c = block_on(
585+ tx.repo_mut()
586+ .rewrite_commit(&wc_commit)
587+ .set_tree(p.tree.clone())
588+ .write(),
589+ )
590+ .map_err(|e| Crash::new(format!("cannot write the snapshot commit: {}", e)))?;
591+ snapshot_wc = Some(c);
592+ }
593+ // compare against the stored commits of the base operation (the
594+ // snapshot's unpublished rewrite must not become a predecessor)
595+ let mut old_stored: Arc<VisibleRepo> =
596+ Arc::new(block_on(read_visible(&base)).map_err(|e| Crash::new(e.1))?);
597+ if let Some(c) = &snapshot_wc {
598+ let mut vis = (*old_stored).clone();
599+ let change = c.change_id().reverse_hex();
600+ if let Some(rec) = vis.commits.get_mut(&change) {
601+ rec.commit = c.clone();
602+ }
603+ old_stored = Arc::new(vis);
604+ }
605+ let mut written: BTreeMap<String, CommitId> = BTreeMap::new();
606+
607+ // the root commit must be the top and unchanged (§7.5 step 4)
608+ let top = crate::repo::by_id(new, ROOT_ID)?
609+ .and_then(|loc| {
610+ loc.field("context")
611+ .ok()
612+ .and_then(|c| c.as_list().ok().map(|l| l.is_empty()))
613+ .and_then(|is_top| if is_top { Some(loc) } else { None })
614+ })
615+ .ok_or_else(|| Crash::new("persistence: the root commit must be the top"))?;
616+ let root_jj = block_on(store.get_commit_async(store.root_commit_id()))
617+ .map_err(|e| Crash::new(format!("cannot read the root commit: {}", e)))?;
618+ {
619+ let root_v = top.field("root")?;
620+ let msg = root_v.field("message")?.as_text()?.to_string();
621+ if root_jj.description() != msg {
622+ return Err(Crash::new("persistence: the root commit cannot be changed"));
623+ }
624+ let stored_files = block_on(tree_to_files(&store, &root_jj.tree()))
625+ .map_err(|e| Crash::new(e.msg))?;
626+ if !crate::value::value_eq(
627+ &Value::list(stored_files),
628+ &Value::list(root_v.field("files")?.as_list()?.to_vec()),
629+ )? {
630+ return Err(Crash::new("persistence: the root commit cannot be changed"));
631+ }
632+ }
633+ written.insert(ROOT_ID.to_string(), root_jj.id().clone());
634+
635+ // walk `new` top-down (§7.5 step 4), parents before children
636+ let mut queue: Vec<(Value, String)> = Vec::new(); // (subtree value, parent change id)
637+ for kid in top.field("children")?.as_list()?.iter() {
638+ queue.push((kid.clone(), ROOT_ID.to_string()));
639+ }
640+ while !queue.is_empty() {
641+ let (subtree, parent_change) = queue.remove(0);
642+ let commit_v = subtree.field("root")?;
643+ let id = commit_id_of(&commit_v)?;
644+ let parent_jj = written
645+ .get(&parent_change)
646+ .cloned()
647+ .ok_or_else(|| Crash::new("persistence: internal: parent not yet written"))?;
648+ let files_v = commit_v.field("files")?;
649+ let message = commit_v.field("message")?.as_text()?.to_string();
650+ let new_id = match old_stored.commits.get(&id) {
651+ None => {
652+ let tree = block_on(build_tree(&store, &files_v))?;
653+ let change_id = jj_lib::backend::ChangeId::try_from_reverse_hex(&id).ok_or_else(|| {
654+ Crash::new(format!("persistence: `{}` is not a valid change id", id))
655+ })?;
656+ let c = block_on(
657+ tx.repo_mut()
658+ .new_commit(vec![parent_jj], tree)
659+ .set_change_id(change_id)
660+ .set_description(message)
661+ .set_author(user_sig.clone())
662+ .set_committer(user_sig.clone())
663+ .write(),
664+ )
665+ .map_err(|e| Crash::new(format!("cannot create commit: {}", e)))?;
666+ c.id().clone()
667+ }
668+ Some(stored) => {
669+ let stored_commit = &stored.commit;
670+ let parent_changed = hex_of(stored_commit.parent_ids().first()) != parent_jj.hex();
671+ let files_changed = {
672+ let stored_files = block_on(tree_to_files(&store, &stored_commit.tree()))?;
673+ !crate::value::value_eq(
674+ &Value::list(stored_files),
675+ &Value::list(files_v.as_list()?.to_vec()),
676+ )?
677+ };
678+ let msg_changed = stored_commit.description() != message;
679+ if parent_changed || files_changed || msg_changed {
680+ let tree = block_on(build_tree(&store, &files_v))?;
681+ let c = block_on(
682+ tx.repo_mut()
683+ .rewrite_commit(stored_commit)
684+ .set_parents(vec![parent_jj])
685+ .set_tree(tree)
686+ .set_description(message)
687+ .set_committer(user_sig.clone())
688+ .write(),
689+ )
690+ .map_err(|e| Crash::new(format!("cannot rewrite commit: {}", e)))?;
691+ c.id().clone()
692+ } else {
693+ stored_commit.id().clone()
694+ }
695+ }
696+ };
697+ written.insert(id.clone(), new_id);
698+ for kid in subtree.field("children")?.as_list()?.iter() {
699+ queue.push((kid.clone(), id.clone()));
700+ }
701+ }
702+
703+ // §7.5 step 5: abandon ids in old that are not in new. Children were
704+ // already re-placed explicitly by the top-down walk above, so record
705+ // the abandon with no rebase targets (repo.rs:1075); calling
706+ // record_abandoned_commit instead would rebase our new commits and
707+ // trip the "descendants not rebased" assert in Transaction::write.
708+ let new_ids = new_id_set(new)?;
709+ for (id, stored) in &old_stored.commits {
710+ if id == ROOT_ID || new_ids.contains(id) {
711+ continue;
712+ }
713+ // abandon (§7.5 step 5): the walk has already re-placed every
714+ // surviving descendant, so jj's rebase machinery has nothing left
715+ // to do; the abandon is recorded for evolution bookkeeping
716+ tx.repo_mut().record_abandoned_commit_with_parents(
717+ stored.commit.id().clone(),
718+ [stored.commit.parent_ids()[0].clone()],
719+ );
720+ }
721+
722+ // §7.5 step 6: set the working-copy commit to the focus (validate_repo
723+ // has already checked mutability)
724+ let focus_id = commit_id_of(&new.field("root")?)?;
725+ let focus_jj = written
726+ .get(&focus_id)
727+ .cloned()
728+ .ok_or_else(|| Crash::new("persistence: internal: focus not written"))?;
729+ tx.repo_mut()
730+ .set_wc_commit(self.inner.workspace_name.clone(), focus_jj.clone())
731+ .map_err(|e| Crash::new(format!("cannot set the working-copy commit: {}", e)))?;
732+
733+ // the walk rewrites commits; descendants already written must not be
734+ // left dangling (transaction invariant)
735+ // every descendant of a rewritten or abandoned commit was re-placed
736+ // by the walk itself, so jj's rebase machinery is a no-op here — but
737+ // the transaction requires the bookkeeping to be flushed
738+ block_on(tx.repo_mut().rebase_descendants())
739+ .map_err(|e| Crash::new(format!("cannot rebase descendants: {}", e)))?;
740+ let desc = truncate_chars(text, 200);
741+ let unpublished = block_on(tx.write(desc))
742+ .map_err(|e| Crash::new(format!("cannot write the operation: {}", e)))?;
743+ let op_id = unpublished.operation().id().clone();
744+ let _new_repo = block_on(unpublished.publish())
745+ .map_err(|e| Crash::new(format!("cannot publish the operation: {}", e)))?;
746+
747+ // §7.4/§7.5 step 7: check out the focus to the working directory
748+ let focus_commit = block_on(store.get_commit_async(&focus_jj))
749+ .map_err(|e| Crash::new(format!("cannot read the focus commit: {}", e)))?;
750+ block_on(self.checkout(&focus_commit, op_id))?;
751+
752+ Ok(())
753+ }
754+
755+ async fn checkout(&self, commit: &Commit, op_id: OperationId) -> Result<(), Crash> {
756+ let mut ws_guard = self.inner.workspace.lock().unwrap();
757+ let mut locked_ws = ws_guard.start_working_copy_mutation()
758+ .await
759+ .map_err(|e| Crash::new(format!("cannot lock the working copy: {}", e)))?;
760+ locked_ws
761+ .locked_wc()
762+ .check_out(commit)
763+ .await
764+ .map_err(|e| Crash::new(format!("cannot check out the focus: {}", e)))?;
765+ locked_ws
766+ .finish(op_id)
767+ .await
768+ .map_err(|e| Crash::new(format!("cannot finish the checkout: {}", e)))?;
769+ Ok(())
770+ }
771+
772+ fn user_signature(&self, cfg: &Config) -> Result<Signature, Crash> {
773+ let (name, email) = crate::config::eval_user_only(&mut Interp::dummy(), cfg)?;
774+ Ok(Signature {
775+ name,
776+ email,
777+ timestamp: Timestamp::now(),
778+ })
779+ }
780+
781+ fn repo_loader(&self) -> jj_lib::repo::RepoLoader {
782+ self.inner.workspace.lock().unwrap().repo_loader().clone()
783+ }
784+
785+ fn head_repo(&self) -> Result<Arc<ReadonlyRepo>, OpenError> {
786+ block_on(self.repo_loader().load_at_head())
787+ .map_err(|e| (2, format!("cannot reload the repository at head: {}", e)))
788+ }
789+
790+ async fn publish_tx(
791+ &self,
792+ tx: jj_lib::transaction::Transaction,
793+ description: &str,
794+ ) -> Result<(OperationId, Arc<ReadonlyRepo>), OpenError> {
795+ let unpublished = tx
796+ .write(description)
797+ .await
798+ .map_err(|e| (1, format!("cannot write the operation: {}", e)))?;
799+ let op_id = unpublished.operation().id().clone();
800+ let repo = unpublished
801+ .publish()
802+ .await
803+ .map_err(|e| (1, format!("cannot publish the operation: {}", e)))?;
804+ *self.inner.repo.lock().unwrap() = repo.clone();
805+ Ok((op_id, repo))
806+ }
807+
808+ fn git_subprocess_options(&self) -> Result<jj_lib::git::GitSubprocessOptions, OpenError> {
809+ jj_lib::git::GitSubprocessOptions::from_settings(self.current_repo().settings())
810+ .map_err(|e| (2, format!("cannot read git settings: {}", e)))
811+ }
812+
813+ fn git_import_options(&self) -> Result<jj_lib::git::GitImportOptions, OpenError> {
814+ let settings = jj_lib::git::GitSettings::from_settings(self.current_repo().settings())
815+ .map_err(|e| (2, format!("cannot read git settings: {}", e)))?;
816+ Ok(jj_lib::git::GitImportOptions {
817+ abandon_unreachable_commits: settings.abandon_unreachable_commits,
818+ record_synthetic_predecessors: settings.record_synthetic_predecessors,
819+ remote_auto_track_bookmarks: std::collections::HashMap::new(),
820+ })
821+ }
822+
823+ /// fetch all bookmarks/tags of `remote` into the git repo, via the git
824+ /// subprocess (jj-lib's GitFetch handles remote-name validation and the
825+ /// refspec retry loop); the fetched refs are imported by the caller
826+ fn git_fetch_refs(
827+ &self,
828+ mut_repo: &mut jj_lib::repo::MutableRepo,
829+ remote: &RemoteName,
830+ ) -> Result<(), OpenError> {
831+ use jj_lib::str_util::StringExpression;
832+ let expr = jj_lib::git::GitFetchRefExpression {
833+ bookmark: StringExpression::all(),
834+ tag: StringExpression::all(),
835+ };
836+ let expanded = jj_lib::git::expand_fetch_refspecs(remote, expr)
837+ .map_err(|e| (1, format!("cannot expand the fetch refspecs: {}", e)))?;
838+ let subprocess_options = self.git_subprocess_options()?;
839+ let import_options = jj_lib::git::GitImportOptions {
840+ abandon_unreachable_commits: true,
841+ record_synthetic_predecessors: true,
842+ remote_auto_track_bookmarks: std::collections::HashMap::new(),
843+ };
844+ let mut git_fetch = jj_lib::git::GitFetch::new(mut_repo, subprocess_options, &import_options)
845+ .map_err(|_| (2, "the repository does not have a git backend".to_string()))?;
846+ let mut callback = QuietGitCallback;
847+ match git_fetch.fetch(remote, expanded, &mut callback, None) {
848+ Ok(()) => Ok(()),
849+ Err(jj_lib::git::GitFetchError::NoSuchRemote(_)) => {
850+ // the backend's cached gix handle can be stale when the
851+ // remote was just written to .git/config in this same run;
852+ // fall back to a direct `git fetch` subprocess
853+ let store = self.current_repo().store().clone();
854+ let backend = git_backend(&store)?;
855+ if remote_has_url(backend, remote) {
856+ git_fetch_subprocess(backend.git_repo_path(), remote)
857+ } else {
858+ Err((1, format!("no remote {}", remote.as_str())))
859+ }
860+ }
861+ Err(other) => Err((1, format!("fetch failed: {}", other))),
862+ }
863+ }
864+
865+ pub fn cmd_remote(&self, url: &str) -> Result<(), OpenError> {
866+ self.take_lock();
867+ let base = self.current_repo();
868+ let origin = RemoteName::new("origin");
869+ let exists = {
870+ let git_repo = jj_lib::git::get_git_repo(base.store())
871+ .map_err(|_| (2, "the repository does not have a git backend".to_string()))?;
872+ git_repo.try_find_remote(origin.as_str()).is_some()
873+ };
874+ if exists {
875+ jj_lib::git::set_remote_urls(base.store(), origin, Some(url), None)
876+ } else {
877+ let mut tx = base.start_transaction();
878+ jj_lib::git::add_remote(tx.repo_mut(), origin, url, None)
879+ }
880+ .map_err(|e| (1, format!("cannot set the remote: {}", e)))
881+ }
882+
883+ pub fn cmd_fetch(&self) -> Result<(), OpenError> {
884+ let origin = RemoteName::new("origin");
885+ let base = self.head_repo()?;
886+ let mut tx = base.start_transaction();
887+ self.git_fetch_refs(tx.repo_mut(), origin)?;
888+ let import_options = self.git_import_options()?;
889+ block_on(jj_lib::git::import_refs(tx.repo_mut(), &import_options))
890+ .map_err(|e| (1, format!("cannot import the fetched refs: {}", e)))?;
891+ block_on(tx.repo_mut().rebase_descendants())
892+ .map_err(|e| (1, format!("cannot rebase descendants: {}", e)))?;
893+ let _ = block_on(self.publish_tx(tx, "fetch"))?;
894+ Ok(())
895+ }
896+ pub fn cmd_push(&self, cfg: &Config, expr_text: &str) -> Result<(), OpenError> {
897+ let origin = RemoteName::new("origin");
898+ let base = self.head_repo()?;
899+ let (value, vis) = self.eval_push_expr(cfg, expr_text, &base)?;
900+ let records = parse_push_records(&value, &vis, &base)?;
901+ check_push_records(&records, &vis, &base)?;
902+ let _ = self.push_to_origin(&base, origin, &records, expr_text)?;
903+ Ok(())
904+ }
905+
906+ /// evaluate `EXPR` against the recorded repository, without snapshotting
907+ /// (§1.1), apply a function result to the repo value once (§7.6)
908+ fn eval_push_expr(
909+ &self,
910+ cfg: &Config,
911+ expr_text: &str,
912+ base: &Arc<ReadonlyRepo>,
913+ ) -> Result<(Value, Arc<VisibleRepo>), OpenError> {
914+ let outer = Rc::new(cfg.global_names.clone());
915+ let expr = crate::parse::parse_expr(expr_text, outer)
916+ .map_err(|p| (3, format!("line {}: {}", p.line, p.msg)))?;
917+ let (mut interp, _loaded, current) = self.build_interp(cfg, expr_text, false)?;
918+ let expr = crate::config::resolve_ids(&expr, &interp).map_err(|failures| {
919+ let (prefix, candidates) = &failures[0];
920+ if candidates.is_empty() {
921+ (1, format!("crash: `@{}` matches no commit", prefix))
922+ } else {
923+ (1, format!("crash: `@{}` is ambiguous", prefix))
924+ }
925+ })?;
926+ crate::config::eval_config(&mut interp, cfg).map_err(|c| (3, format!("config.j: {}", c.msg)))?;
927+ let env = interp.global_env();
928+ let expr_rc = Rc::new(expr);
929+ let mut v = interp
930+ .eval(&expr_rc, &env)
931+ .map_err(|c| (1, format!("crash: {}", c.msg)))?;
932+ if matches!(v, Value::Fun(_)) {
933+ v = interp
934+ .apply(v, current)
935+ .map_err(|c| (1, format!("crash: {}", c.msg)))?;
936+ }
937+ let _ = base;
938+ let vis = self
939+ .inner
940+ .visible
941+ .lock()
942+ .unwrap()
943+ .clone()
944+ .ok_or_else(|| (2, "internal: repository value not loaded".to_string()))?;
945+ Ok((v, vis))
946+ }
947+
948+ /// push the updates to `origin` and record the new remote-bookmark
949+ /// positions as one operation (§7.6)
950+ fn push_to_origin(
951+ &self,
952+ base: &Arc<ReadonlyRepo>,
953+ origin: &RemoteName,
954+ records: &[(String, Option<CommitId>)],
955+ description: &str,
956+ ) -> Result<Arc<ReadonlyRepo>, OpenError> {
957+ let mut targets = jj_lib::git::GitPushRefTargets::default();
958+ for (name, after) in records {
959+ let before = base
960+ .view()
961+ .get_remote_bookmark(RefName::new(name).to_remote_symbol(origin))
962+ .target
963+ .as_resolved()
964+ .cloned()
965+ .unwrap_or(None);
966+ targets
967+ .bookmarks
968+ .push((jj_lib::ref_name::RefNameBuf::from(name.clone()), Diff::new(before, after.clone())));
969+ }
970+ let mut tx = base.start_transaction();
971+ let subprocess_options = self.git_subprocess_options()?;
972+ let mut callback = QuietGitCallback;
973+ let stats = jj_lib::git::push_refs(
974+ tx.repo_mut(),
975+ subprocess_options,
976+ origin,
977+ &targets,
978+ &mut callback,
979+ &jj_lib::git::GitPushOptions::default(),
980+ )
981+ .map_err(|e| match e {
982+ jj_lib::git::GitPushError::NoSuchRemote(_) => (1, "no remote origin".to_string()),
983+ other => (1, format!("push failed: {}", other)),
984+ })?;
985+ if !stats.all_ok() {
986+ let mut names: Vec<String> = Vec::new();
987+ for (name, _) in stats.rejected.iter().chain(stats.remote_rejected.iter()) {
988+ names.push(name.as_str().to_string());
989+ }
990+ return Err((1, format!("push rejected: {}", names.join(", "))));
991+ }
992+ let unpublished = block_on(tx.write(truncate_chars(description, 200)))
993+ .map_err(|e| (1, format!("cannot write the operation: {}", e)))?;
994+ let repo = block_on(unpublished.publish())
995+ .map_err(|e| (1, format!("cannot publish the operation: {}", e)))?;
996+ *self.inner.repo.lock().unwrap() = repo.clone();
997+ Ok(repo)
998+ }
999+
1000+ pub fn cmd_undo(&self, redo: bool) -> Result<(), OpenError> {
1001+ let base = self.head_repo()?;
1002+ self.check_wc_clean(&base)?;
1003+ let loader = self.repo_loader();
1004+ let mut cur = base.operation().clone();
1005+ let target_op_id: OperationId;
1006+ loop {
1007+ match (op_marker(cur.metadata()), redo) {
1008+ (None, false) => {
1009+ let Some(parent) = cur.parent_ids().first().cloned() else {
1010+ return Err((1, "nothing to undo".to_string()));
1011+ };
1012+ target_op_id = parent;
1013+ break;
1014+ }
1015+ (None, true) => return Err((1, "nothing to redo".to_string())),
1016+ (Some(m), false) if m.kind == "undo" => {
1017+ if !m.authored_by_j {
1018+ // degrade (§7.7): cannot follow the marker, so undo
1019+ // this operation's own parent
1020+ let Some(parent) = cur.parent_ids().first().cloned() else {
1021+ return Err((1, "nothing to undo".to_string()));
1022+ };
1023+ target_op_id = parent;
1024+ break;
1025+ }
1026+ let Some(grandparent) = op_parent_id(&loader, &m.id)? else {
1027+ return Err((1, "nothing to undo".to_string()));
1028+ };
1029+ cur = block_on(loader.load_operation(&grandparent))
1030+ .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
1031+ }
1032+ (Some(m), true) if m.kind == "undo" => {
1033+ target_op_id = m.id;
1034+ break;
1035+ }
1036+ (Some(m), false) => {
1037+ // a redo: move past the undo it reversed
1038+ if !m.authored_by_j {
1039+ let Some(parent) = cur.parent_ids().first().cloned() else {
1040+ return Err((1, "nothing to undo".to_string()));
1041+ };
1042+ target_op_id = parent;
1043+ break;
1044+ }
1045+ cur = block_on(loader.load_operation(&m.id))
1046+ .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
1047+ }
1048+ (Some(m), true) => {
1049+ // a redo: reverse the undo it reversed, following further
1050+ // redos to the operation ultimately reapplied
1051+ target_op_id = if m.authored_by_j {
1052+ block_on(chain_through_redos(&loader, &m.id))?
1053+ } else {
1054+ m.id
1055+ };
1056+ break;
1057+ }
1058+ }
1059+ }
1060+ let target_op = block_on(loader.load_operation(&target_op_id))
1061+ .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
1062+ let target_view = block_on(
1063+ loader
1064+ .op_store()
1065+ .read_view(target_op.view_id()),
1066+ )
1067+ .map_err(|e| (2, format!("cannot load an operation view: {}", e)))?;
1068+ // undoing the operation that established the working copy (e.g. the
1069+ // very first operation after init) would restore a view with no
1070+ // checked-out commit, leaving the repository unusable; there is
1071+ // nothing meaningful to undo
1072+ if !target_view
1073+ .wc_commit_ids
1074+ .contains_key(WorkspaceName::DEFAULT)
1075+ {
1076+ return Err((
1077+ 1,
1078+ if redo {
1079+ "nothing to redo".to_string()
1080+ } else {
1081+ "nothing to undo".to_string()
1082+ },
1083+ ));
1084+ }
1085+ let marker = if redo {
1086+ format!("redo: {}", cur.id().hex())
1087+ } else {
1088+ format!("undo: {}", cur.id().hex())
1089+ };
1090+ let mut tx = base.start_transaction();
1091+ tx.repo_mut().set_view(target_view.clone());
1092+ let unpublished = block_on(tx.write(&marker))
1093+ .map_err(|e| (1, format!("cannot write the operation: {}", e)))?;
1094+ let op_id = unpublished.operation().id().clone();
1095+ let repo = block_on(unpublished.publish())
1096+ .map_err(|e| (1, format!("cannot publish the operation: {}", e)))?;
1097+ *self.inner.repo.lock().unwrap() = repo.clone();
1098+ let wc_commit = block_on(self.wc_commit(&repo))?;
1099+ block_on(self.checkout(&wc_commit, op_id)).map_err(|c| (1, c.msg))?;
1100+ Ok(())
1101+ }
1102+
1103+ /// refuse if the working directory differs from the focused commit's
1104+ /// files (§7.7): reserved commands never snapshot, so compare the locked
1105+ /// working copy's recorded tree with the focus commit's tree
1106+ fn check_wc_clean(&self, base: &Arc<ReadonlyRepo>) -> Result<(), OpenError> {
1107+ let dirty = (1, "working copy has changes not in @; run `j id` to record them or discard them".to_string());
1108+ let wc_commit = block_on(self.wc_commit(base))?;
1109+ // snapshot the working directory (without persisting anything) and
1110+ // compare against the focused commit's files (§7.7)
1111+ let mut ws_guard = self.inner.workspace.lock().unwrap();
1112+ let mut locked_ws = block_on(ws_guard.start_working_copy_mutation())
1113+ .map_err(|e| (2, format!("cannot lock the working copy: {}", e)))?;
1114+ let options = SnapshotOptions {
1115+ base_ignores: GitIgnoreFile::empty(),
1116+ progress: None,
1117+ start_tracking_matcher: &EverythingMatcher,
1118+ force_tracking_matcher: &NothingMatcher,
1119+ max_new_file_size: u64::MAX,
1120+ };
1121+ let (new_tree, _stats) = block_on(locked_ws.locked_wc().snapshot(&options))
1122+ .map_err(|e| (2, format!("cannot read the working copy: {}", e)))?;
1123+ let same = new_tree.tree_ids() == wc_commit.tree().tree_ids();
1124+ // discard: finish against the current op so no state changes
1125+ block_on(locked_ws.finish(base.operation().id().clone()))
1126+ .map_err(|e| (2, format!("cannot finish: {}", e)))?;
1127+ drop(ws_guard);
1128+ if !same {
1129+ return Err(dirty);
1130+ }
1131+ Ok(())
1132+ }
1133+
1134+ pub fn cmd_ops(&self) -> Result<(), OpenError> {
1135+ let base = self.head_repo()?;
1136+ let loader = self.repo_loader();
1137+ let head = base.operation().clone();
1138+ let mut out = String::new();
1139+ let mut cur = Some(head);
1140+ while let Some(op) = cur {
1141+ let end = op.metadata().time.end.timestamp.0.div_euclid(1000);
1142+ let age = crate::render::render_age(end);
1143+ let mut desc = op.metadata().description.trim().to_string();
1144+ let mut tag = "";
1145+ match op_kind(op.metadata()) {
1146+ OpKind::Normal => {}
1147+ OpKind::Undo { .. } => tag = "undo",
1148+ OpKind::Redo { .. } => tag = "redo",
1149+ }
1150+ if desc.is_empty() {
1151+ desc = "(no description)".to_string();
1152+ }
1153+ let mark = if op.id() == base.operation().id() {
1154+ "*"
1155+ } else {
1156+ " "
1157+ };
1158+ if tag.is_empty() {
1159+ out.push_str(&format!("{} {:>4} {}\n", mark, age, desc));
1160+ } else {
1161+ out.push_str(&format!("{} {:>4} {} ({})\n", mark, age, desc, tag));
1162+ }
1163+ cur = match op.parent_ids().first() {
1164+ Some(p) => Some(
1165+ block_on(loader.load_operation(p))
1166+ .map_err(|e| (2, format!("cannot load an operation: {}", e)))?,
1167+ ),
1168+ None => None,
1169+ };
1170+ }
1171+ print!("{}", out);
1172+ Ok(())
1173+ }
1174+}
1175+
1176+pub fn cmd_init(cfg: &Config) -> Result<(), OpenError> {
1177+ let settings = user_settings_strict(cfg)?;
1178+ let cwd = std::env::current_dir()
1179+ .map_err(|e| (2, format!("cannot read the current directory: {}", e)))?;
1180+ for dir in cwd.ancestors() {
1181+ if dir.join(".jj").is_dir() {
1182+ return Err((
1183+ 2,
1184+ format!("already inside a jj repository at {}", dir.display()),
1185+ ));
1186+ }
1187+ }
1188+ let git_dir = cwd.join(".git");
1189+ if git_dir.exists() && !git_dir.is_dir() {
1190+ return Err((
1191+ 2,
1192+ ".git exists but is not a directory (a git worktree or submodule)".to_string(),
1193+ ));
1194+ }
1195+ let had_git = git_dir.is_dir();
1196+ let (workspace, repo) = if had_git {
1197+ // adopt the existing git repository (§7.8); init_colocated_git
1198+ // refuses to init over one
1199+ block_on(Workspace::init_external_git(&settings, &cwd, &git_dir))
1200+ } else {
1201+ block_on(Workspace::init_colocated_git(
1202+ &settings,
1203+ &cwd,
1204+ gix::hash::Kind::Sha1,
1205+ ))
1206+ }
1207+ .map_err(|e| (2, format!("cannot create the repository: {}", e)))?;
1208+ let backend = JjBackend {
1209+ inner: Arc::new(JjInner {
1210+ workspace_root: cwd.clone(),
1211+ workspace: Mutex::new(workspace),
1212+ repo: Mutex::new(repo),
1213+ workspace_name: WorkspaceName::DEFAULT.to_owned(),
1214+ visible: Mutex::new(None),
1215+ lock_guard: Mutex::new(None),
1216+ pending: Mutex::new(None),
1217+ }),
1218+ };
1219+ if had_git {
1220+ let import_options = backend.git_import_options()?;
1221+ let mut tx = backend.current_repo().start_transaction();
1222+ block_on(jj_lib::git::import_refs(tx.repo_mut(), &import_options))
1223+ .map_err(|e| (2, format!("cannot import the git history: {}", e)))?;
1224+ block_on(tx.repo_mut().rebase_descendants())
1225+ .map_err(|e| (2, format!("cannot rebase descendants: {}", e)))?;
1226+ let _ = block_on(backend.publish_tx(tx, "import git refs"))?;
1227+ }
1228+ create_initial_wc_commit(&backend, cfg, "init")?;
1229+ Ok(())
1230+}
1231+
1232+pub fn cmd_clone(cfg: &Config, url: &str, dir: &str) -> Result<(), OpenError> {
1233+ let settings = user_settings_strict(cfg)?;
1234+ let dir_path = PathBuf::from(dir);
1235+ if dir_path.join(".jj").is_dir() {
1236+ return Err((2, format!("{} already contains a jj repository", dir)));
1237+ }
1238+ if dir_path.is_dir() {
1239+ let mut entries = std::fs::read_dir(&dir_path)
1240+ .map_err(|e| (2, format!("cannot read {}: {}", dir, e)))?;
1241+ if entries.next().is_some() {
1242+ return Err((2, format!("{} already exists and is not empty", dir)));
1243+ }
1244+ } else if dir_path.exists() {
1245+ return Err((2, format!("{} already exists and is not empty", dir)));
1246+ }
1247+ std::fs::create_dir_all(&dir_path)
1248+ .map_err(|e| (2, format!("cannot create {}: {}", dir, e)))?;
1249+ let (workspace, repo) = block_on(Workspace::init_colocated_git(
1250+ &settings,
1251+ &dir_path,
1252+ gix::hash::Kind::Sha1,
1253+ ))
1254+ .map_err(|e| (2, format!("cannot create the repository: {}", e)))?;
1255+ let backend = JjBackend {
1256+ inner: Arc::new(JjInner {
1257+ workspace_root: dir_path.clone(),
1258+ workspace: Mutex::new(workspace),
1259+ repo: Mutex::new(repo),
1260+ workspace_name: WorkspaceName::DEFAULT.to_owned(),
1261+ visible: Mutex::new(None),
1262+ lock_guard: Mutex::new(None),
1263+ pending: Mutex::new(None),
1264+ }),
1265+ };
1266+ let origin = RemoteName::new("origin");
1267+ let mut tx = backend.current_repo().start_transaction();
1268+ jj_lib::git::add_remote(tx.repo_mut(), origin, url, None)
1269+ .map_err(|e| (1, format!("cannot set the remote: {}", e)))?;
1270+ backend.git_fetch_refs(tx.repo_mut(), origin)?;
1271+ let import_options = backend.git_import_options()?;
1272+ block_on(jj_lib::git::import_refs(tx.repo_mut(), &import_options))
1273+ .map_err(|e| (1, format!("cannot import the fetched refs: {}", e)))?;
1274+ block_on(tx.repo_mut().rebase_descendants())
1275+ .map_err(|e| (1, format!("cannot rebase descendants: {}", e)))?;
1276+ let _ = block_on(backend.publish_tx(tx, &format!("clone {}", url)))?;
1277+ create_initial_wc_commit(&backend, cfg, &format!("clone {}", url))?;
1278+ Ok(())
1279+}
1280+
1281+/// §7.8: after init/clone, create a working-copy commit as a child of the
1282+/// current head (a bookmark target if there is one, otherwise the root
1283+/// commit) and check it out.
1284+fn create_initial_wc_commit(
1285+ backend: &JjBackend,
1286+ cfg: &Config,
1287+ description: &str,
1288+) -> Result<(), OpenError> {
1289+ let base = backend.current_repo();
1290+ let store = base.store().clone();
1291+ let mut parent = store.root_commit_id().clone();
1292+ 'outer: for (_name, target) in base.view().local_bookmarks() {
1293+ if let Some(id) = target.as_resolved().and_then(|t| t.clone()) {
1294+ parent = id;
1295+ break 'outer;
1296+ }
1297+ }
1298+ if parent == *store.root_commit_id() {
1299+ for (_name, remote_ref) in base.view().remote_bookmarks(RemoteName::new("origin")) {
1300+ if let Some(id) = remote_ref.target.as_resolved().and_then(|t| t.clone()) {
1301+ parent = id;
1302+ break;
1303+ }
1304+ }
1305+ }
1306+ // jj's own init already created a working-copy commit; if it is already
1307+ // a child of the target parent, keep it and just check it out
1308+ let mut to_abandon: Option<CommitId> = None;
1309+ if let Ok(existing) = block_on(backend.wc_commit(&base)) {
1310+ if existing.parent_ids().first() == Some(&parent) {
1311+ let op_id = base.operation().id().clone();
1312+ block_on(backend.checkout(&existing, op_id)).map_err(|c| (1, c.msg))?;
1313+ return Ok(());
1314+ }
1315+ if existing.id() != store.root_commit_id() {
1316+ to_abandon = Some(existing.id().clone());
1317+ }
1318+ }
1319+ let user_sig = backend
1320+ .user_signature(cfg)
1321+ .map_err(|c| (3, format!("config.j: {}", c.msg)))?;
1322+ let mut tx = base.start_transaction();
1323+ if let Some(old_wc) = &to_abandon {
1324+ tx.repo_mut().record_abandoned_commit_with_parents(
1325+ old_wc.clone(),
1326+ [store.root_commit_id().clone()],
1327+ );
1328+ }
1329+ let wc = block_on(
1330+ tx.repo_mut()
1331+ .new_commit(vec![parent], store.empty_merged_tree())
1332+ .set_author(user_sig.clone())
1333+ .set_committer(user_sig)
1334+ .write(),
1335+ )
1336+ .map_err(|e| (1, format!("cannot create the working-copy commit: {}", e)))?;
1337+ tx.repo_mut()
1338+ .set_wc_commit(backend.inner.workspace_name.clone(), wc.id().clone())
1339+ .map_err(|e| (1, format!("cannot set the working-copy commit: {}", e)))?;
1340+ block_on(tx.repo_mut().rebase_descendants())
1341+ .map_err(|e| (1, format!("cannot rebase descendants: {}", e)))?;
1342+ let unpublished = block_on(tx.write(truncate_chars(description, 200)))
1343+ .map_err(|e| (1, format!("cannot write the operation: {}", e)))?;
1344+ let op_id = unpublished.operation().id().clone();
1345+ let repo = block_on(unpublished.publish())
1346+ .map_err(|e| (1, format!("cannot publish the operation: {}", e)))?;
1347+ *backend.inner.repo.lock().unwrap() = repo;
1348+ block_on(backend.checkout(&wc, op_id)).map_err(|c| (1, c.msg))?;
1349+ Ok(())
1350+}
1351+
1352+/// user settings for init/clone: a missing or malformed `user` is a
1353+/// configuration error (exit 3) (§7.9)
1354+fn user_settings_strict(cfg: &Config) -> Result<UserSettings, OpenError> {
1355+ let (name, email) = crate::config::eval_user_only(&mut Interp::dummy(), cfg)
1356+ .map_err(|c| (3, format!("config.j: {}", c.msg)))?;
1357+ let mut config = StackedConfig::with_defaults();
1358+ let text = format!(
1359+ "[user]\nname = {}\nemail = {}\n",
1360+ toml_string(&name),
1361+ toml_string(&email)
1362+ );
1363+ let layer =
1364+ ConfigLayer::parse(ConfigSource::User, &text).expect("generated user config should parse");
1365+ config.add_layer(layer);
1366+ UserSettings::from_config(config)
1367+ .map_err(|e| (3, format!("config.j: invalid user settings: {}", e)))
1368+}
1369+
1370+// ----------------------------------------------------------------------
1371+// reserved-command helpers
1372+// ----------------------------------------------------------------------
1373+
1374+/// discards git subprocess sideband/progress output
1375+struct QuietGitCallback;
1376+
1377+impl jj_lib::git::GitSubprocessCallback for QuietGitCallback {
1378+ fn needs_progress(&self) -> bool {
1379+ false
1380+ }
1381+ fn progress(&mut self, _progress: &jj_lib::git::GitProgress) -> std::io::Result<()> {
1382+ Ok(())
1383+ }
1384+ fn local_sideband(
1385+ &mut self,
1386+ _message: &[u8],
1387+ _term: Option<jj_lib::git::GitSidebandLineTerminator>,
1388+ ) -> std::io::Result<()> {
1389+ Ok(())
1390+ }
1391+ fn remote_sideband(
1392+ &mut self,
1393+ _message: &[u8],
1394+ _term: Option<jj_lib::git::GitSidebandLineTerminator>,
1395+ ) -> std::io::Result<()> {
1396+ Ok(())
1397+ }
1398+}
1399+
1400+fn git_backend(store: &Store) -> Result<&jj_lib::git_backend::GitBackend, OpenError> {
1401+ jj_lib::git::get_git_backend(store)
1402+ .map_err(|_| (2, "the repository does not have a git backend".to_string()))
1403+}
1404+
1405+/// whether the remote is configured in .git/config, seen through a fresh
1406+/// gix handle (the backend's cached handle may predate a just-written config)
1407+fn remote_has_url(backend: &jj_lib::git_backend::GitBackend, remote: &RemoteName) -> bool {
1408+ let Ok(fresh) = gix::open(backend.git_repo_path()) else {
1409+ return false;
1410+ };
1411+ match fresh.try_find_remote(remote.as_str()) {
1412+ Some(Ok(r)) => {
1413+ r.url(gix::remote::Direction::Fetch).is_some()
1414+ || r.url(gix::remote::Direction::Push).is_some()
1415+ }
1416+ _ => false,
1417+ }
1418+}
1419+
1420+/// `git fetch <remote>` + prune, with the backend's configured git binary
1421+fn git_fetch_subprocess(git_dir: &std::path::Path, remote: &RemoteName) -> Result<(), OpenError> {
1422+ let exe = std::env::var("J_GIT").unwrap_or_else(|_| "git".to_string());
1423+ let out = std::process::Command::new(exe)
1424+ .arg("--git-dir")
1425+ .arg(git_dir)
1426+ .args(["fetch", "--prune", remote.as_str()])
1427+ .stdin(std::process::Stdio::null())
1428+ .output()
1429+ .map_err(|e| (1, format!("cannot run git fetch: {}", e)))?;
1430+ if out.status.success() {
1431+ Ok(())
1432+ } else {
1433+ Err((
1434+ 1,
1435+ format!(
1436+ "fetch failed: {}",
1437+ String::from_utf8_lossy(&out.stderr).trim()
1438+ ),
1439+ ))
1440+ }
1441+}
1442+
1443+enum OpKind {
1444+ Normal,
1445+ /// the operation this undo discarded the view of
1446+ Undo { #[allow(dead_code)] undid: OperationId },
1447+ /// the undo this redo reversed
1448+ Redo { redid: OperationId },
1449+}
1450+
1451+/// a marker parsed from an operation description (§7.7 kinds)
1452+struct OpMarker {
1453+ kind: &'static str,
1454+ id: OperationId,
1455+ /// whether the description is j's own exact marker (`undo: <id>` /
1456+ /// `redo: <id>`); only those are reliably loadable for the walk
1457+ authored_by_j: bool,
1458+}
1459+
1460+/// §7.7 kinds: `j` marks undo/redo operations as `undo: <opid>` /
1461+/// `redo: <opid>` in the description (0.45's Transaction cannot write
1462+/// metadata tags); jj's own `undo operation <id>` / `redo operation <id>`
1463+/// markers are treated the same way.
1464+fn op_kind(meta: &jj_lib::op_store::OperationMetadata) -> OpKind {
1465+ match op_marker(meta) {
1466+ Some(OpMarker { kind: "undo", id, .. }) => OpKind::Undo { undid: id },
1467+ Some(OpMarker { kind: "redo", id, .. }) => OpKind::Redo { redid: id },
1468+ _ => OpKind::Normal,
1469+ }
1470+}
1471+
1472+fn op_marker(meta: &jj_lib::op_store::OperationMetadata) -> Option<OpMarker> {
1473+ let desc = meta.description.trim();
1474+ for kind in ["undo", "redo"] {
1475+ if let Some(rest) = desc.strip_prefix(&format!("{}:", kind)) {
1476+ if let Some(id) = parse_op_id(rest.trim()) {
1477+ return Some(OpMarker { kind, id, authored_by_j: true });
1478+ }
1479+ }
1480+ if let Some(rest) = desc.strip_prefix(&format!("{} operation", kind)) {
1481+ if let Some(id) = parse_op_id(rest.trim()) {
1482+ return Some(OpMarker { kind, id, authored_by_j: false });
1483+ }
1484+ }
1485+ }
1486+ None
1487+}
1488+
1489+/// a full-length (or prefix) hex operation id
1490+fn parse_op_id(s: &str) -> Option<OperationId> {
1491+ if s.is_empty() || !s.chars().all(|c| c.is_ascii_hexdigit()) {
1492+ return None;
1493+ }
1494+ hex::decode(pad_hex(s)).ok().map(OperationId::new)
1495+}
1496+
1497+fn pad_hex(s: &str) -> String {
1498+ if s.len() % 2 == 0 {
1499+ s.to_string()
1500+ } else {
1501+ format!("0{}", s)
1502+ }
1503+}
1504+
1505+fn op_parent_id(
1506+ loader: &jj_lib::repo::RepoLoader,
1507+ op_id: &OperationId,
1508+) -> Result<Option<OperationId>, OpenError> {
1509+ let op = block_on(loader.load_operation(op_id))
1510+ .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
1511+ Ok(op.parent_ids().first().cloned())
1512+}
1513+
1514+/// follow a chain of redos to the operation that was ultimately reapplied
1515+async fn chain_through_redos(
1516+ loader: &jj_lib::repo::RepoLoader,
1517+ first: &OperationId,
1518+) -> Result<OperationId, OpenError> {
1519+ let mut id = first.clone();
1520+ loop {
1521+ let op = loader
1522+ .load_operation(&id)
1523+ .await
1524+ .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
1525+ match op_kind(op.metadata()) {
1526+ OpKind::Redo { redid } => id = redid,
1527+ _ => return Ok(id),
1528+ }
1529+ }
1530+}
1531+
1532+/// parse the push-record list (§7.6): records of shape {id, name} set a
1533+/// bookmark, records of shape {delete} remove one; duplicates merge, the same
1534+/// name with different effects crashes (exit 1)
1535+fn parse_push_records(
1536+ value: &Value,
1537+ vis: &VisibleRepo,
1538+ repo: &Arc<ReadonlyRepo>,
1539+) -> Result<Vec<(String, Option<CommitId>)>, OpenError> {
1540+ let items = value
1541+ .as_list()
1542+ .map_err(|_| (1, "push: the expression must evaluate to a list of push records".to_string()))?;
1543+ let mut effects: BTreeMap<String, Option<String>> = BTreeMap::new();
1544+ for item in items {
1545+ let fields = item.field_set().ok_or_else(|| {
1546+ (1, "push: every element must be a record of shape {id, name} or {delete}".to_string())
1547+ })?;
1548+ if fields.len() == 1 && fields.contains("delete") {
1549+ let name = item
1550+ .field("delete")
1551+ .and_then(|v| v.as_text().map(|t| t.to_string()))
1552+ .map_err(|_| (1, "push: `delete` must be a Text".to_string()))?;
1553+ match effects.get(&name) {
1554+ Some(None) => {}
1555+ Some(Some(_)) => {
1556+ return Err((1, format!("crash: two push records for `{}` disagree", name)))
1557+ }
1558+ None => {
1559+ effects.insert(name, None);
1560+ }
1561+ }
1562+ } else if fields.len() == 2 && fields.contains("id") && fields.contains("name") {
1563+ let name = item
1564+ .field("name")
1565+ .and_then(|v| v.as_text().map(|t| t.to_string()))
1566+ .map_err(|_| (1, "push: `name` must be a Text".to_string()))?;
1567+ let id = match item.field("id") {
1568+ Ok(Value::Id(i)) => i.to_string(),
1569+ _ => return Err((1, "push: `id` must be an Id".to_string())),
1570+ };
1571+ match effects.get(&name) {
1572+ Some(Some(prev)) if prev == &id => {}
1573+ Some(_) => {
1574+ return Err((1, format!("crash: two push records for `{}` disagree", name)))
1575+ }
1576+ None => {
1577+ effects.insert(name, Some(id));
1578+ }
1579+ }
1580+ } else {
1581+ return Err((
1582+ 1,
1583+ "push: every element must be a record of shape {id, name} or {delete}".to_string(),
1584+ ));
1585+ }
1586+ }
1587+ let origin = RemoteName::new("origin");
1588+ let mut out = Vec::new();
1589+ for (name, effect) in effects {
1590+ if !is_valid_git_branch_name(&name) {
1591+ return Err((1, format!("push: `{}` is not a valid git branch name", name)));
1592+ }
1593+ match effect {
1594+ Some(change_id) => {
1595+ let rec = vis.commits.get(&change_id).ok_or_else(|| {
1596+ (1, format!("push: `@{}` is not a visible commit", change_id))
1597+ })?;
1598+ out.push((name, Some(rec.commit.id().clone())));
1599+ }
1600+ None => {
1601+ let remote_ref = repo
1602+ .view()
1603+ .get_remote_bookmark(RefName::new(&name).to_remote_symbol(origin));
1604+ if remote_ref.target.is_absent() {
1605+ return Err((
1606+ 1,
1607+ format!("push: the remote does not have a bookmark `{}`", name),
1608+ ));
1609+ }
1610+ out.push((name, None));
1611+ }
1612+ }
1613+ }
1614+ Ok(out)
1615+}
1616+
1617+/// §7.6: refuse to send commits with unresolved files or empty descriptions,
1618+/// and refuse to move/delete a bookmark whose current target is immutable
1619+/// away from a descendant of that target
1620+fn check_push_records(
1621+ records: &[(String, Option<CommitId>)],
1622+ vis: &VisibleRepo,
1623+ repo: &Arc<ReadonlyRepo>,
1624+) -> Result<(), OpenError> {
1625+ let store = repo.store().clone();
1626+ for (name, target) in records {
1627+ let mut seeds: Vec<String> = Vec::new();
1628+ if let Some(commit_id) = target {
1629+ let commit = block_on(store.get_commit_async(commit_id))
1630+ .map_err(|e| (2, format!("cannot read a commit: {}", e)))?;
1631+ let change_id = commit.change_id().reverse_hex();
1632+ if vis.commits.contains_key(&change_id) {
1633+ seeds.push(change_id);
1634+ }
1635+ }
1636+ if let Some(current) = repo
1637+ .view()
1638+ .get_remote_bookmark(RefName::new(name).to_remote_symbol(RemoteName::new("origin")))
1639+ .target
1640+ .as_resolved()
1641+ .cloned()
1642+ .unwrap_or(None)
1643+ {
1644+ let commit = block_on(store.get_commit_async(&current))
1645+ .map_err(|e| (2, format!("cannot read a commit: {}", e)))?;
1646+ let change_id = commit.change_id().reverse_hex();
1647+ if vis.commits.contains_key(&change_id) {
1648+ seeds.push(change_id);
1649+ }
1650+ }
1651+ let ancestors = ancestors_of_change_ids(vis, &seeds);
1652+ for change_id in &ancestors {
1653+ let rec = vis.commits.get(change_id).expect("ancestors are stored");
1654+ for (_path, value) in rec.commit.tree().entries() {
1655+ let value = value.map_err(|e| (2, format!("cannot read a tree: {}", e)))?;
1656+ if !value.is_resolved() {
1657+ return Err((
1658+ 1,
1659+ format!(
1660+ "push: commit `@{}` has unresolved files",
1661+ rec.change_id
1662+ ),
1663+ ));
1664+ }
1665+ }
1666+ if rec.commit.description().trim().is_empty() && rec.commit.id() != store.root_commit_id() {
1667+ return Err((
1668+ 1,
1669+ format!("push: commit `@{}` has an empty description", rec.change_id),
1670+ ));
1671+ }
1672+ }
1673+ }
1674+ Ok(())
1675+}
1676+
1677+/// change-id closure of jj-parents from the seeds (all jj parents, not just
1678+/// the first), for the push content checks
1679+fn ancestors_of_change_ids(vis: &VisibleRepo, seeds: &[String]) -> BTreeSet<String> {
1680+ let mut seen = BTreeSet::new();
1681+ let mut stack: Vec<String> = seeds.to_vec();
1682+ while let Some(id) = stack.pop() {
1683+ if !seen.insert(id.clone()) {
1684+ continue;
1685+ }
1686+ if let Some(rec) = vis.commits.get(&id) {
1687+ for pid in rec.all_parents.iter() {
1688+ if !pid.is_empty() {
1689+ stack.push(pid.clone());
1690+ }
1691+ }
1692+ }
1693+ }
1694+ seen
1695+}
1696+
1697+/// git-check-ref-format --branch rules
1698+fn is_valid_git_branch_name(name: &str) -> bool {
1699+ if name.is_empty()
1700+ || name.starts_with('-')
1701+ || name.starts_with('.')
1702+ || name.ends_with('.')
1703+ || name.ends_with('/')
1704+ || name.ends_with(".lock")
1705+ || name.contains("..")
1706+ || name.contains("//")
1707+ || name.contains("@{")
1708+ || name == "@"
1709+ {
1710+ return false;
1711+ }
1712+ name.chars().all(|c| {
1713+ !(c.is_ascii_control()
1714+ || matches!(c, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\'))
1715+ })
1716+}
1717+
1718+// ----------------------------------------------------------------------
1719+// Backend impl: metadata lookups and replay (§7.2, §7.3)
1720+// ----------------------------------------------------------------------
1721+
1722+impl Backend for JjBackend {
1723+ fn visible_ids(&self) -> Vec<String> {
1724+ match self.inner.visible.lock().unwrap().as_ref() {
1725+ Some(v) => v.commits.keys().cloned().collect(),
1726+ None => Vec::new(),
1727+ }
1728+ }
1729+
1730+ fn meta(&self, id: &str) -> Result<MetaInfo, Crash> {
1731+ let vis = self.inner.visible.lock().unwrap().clone();
1732+ let vis = vis.ok_or_else(|| Crash::new("meta: repository not loaded"))?;
1733+ let rec = vis
1734+ .commits
1735+ .get(id)
1736+ .ok_or_else(|| Crash::new("meta: no stored commit for this id"))?;
1737+ Ok(MetaInfo {
1738+ hash: rec.commit.id().hex(),
1739+ author: rec.commit.author().name.clone(),
1740+ email: rec.commit.author().email.clone(),
1741+ time: rec.commit.committer().timestamp.timestamp.0.div_euclid(1000),
1742+ })
1743+ }
1744+
1745+ fn is_merge(&self, id: &str) -> bool {
1746+ let vis = self.inner.visible.lock().unwrap().clone();
1747+ vis.and_then(|v| v.commits.get(id).map(|r| r.commit.parent_ids().len() >= 2))
1748+ .unwrap_or(false)
1749+ }
1750+
1751+ fn ancestors_closed(&self, ids: &BTreeSet<String>) -> BTreeSet<String> {
1752+ let vis = self.inner.visible.lock().unwrap().clone();
1753+ let Some(vis) = vis else {
1754+ return BTreeSet::new();
1755+ };
1756+ let mut seen = BTreeSet::new();
1757+ let mut stack: Vec<String> = ids.iter().cloned().collect();
1758+ while let Some(id) = stack.pop() {
1759+ if seen.insert(id.clone()) {
1760+ if let Some(rec) = vis.commits.get(&id) {
1761+ if !rec.first_parent.is_empty() {
1762+ stack.push(rec.first_parent.clone());
1763+ }
1764+ }
1765+ }
1766+ }
1767+ seen
1768+ }
1769+
1770+ fn replay(&self, onto: &[Value], from: &[Value], to: &[Value]) -> Result<Vec<Value>, Crash> {
1771+ let store = self.current_repo().store().clone();
1772+ block_on(jj_replay(&store, onto, from, to))
1773+ }
1774+}
1775+
1776+// ----------------------------------------------------------------------
1777+// jj three-way tree merge for `replay` (§7.3)
1778+// ----------------------------------------------------------------------
1779+
1780+async fn jj_replay(
1781+ store: &Arc<Store>,
1782+ onto: &[Value],
1783+ from: &[Value],
1784+ to: &[Value],
1785+) -> Result<Vec<Value>, Crash> {
1786+ let onto_tree = build_tree(store, &Value::list(onto.to_vec())).await?;
1787+ let from_tree = build_tree(store, &Value::list(from.to_vec())).await?;
1788+ let to_tree = build_tree(store, &Value::list(to.to_vec())).await?;
1789+ let merge = Merge::from_removes_adds(
1790+ vec![from_tree.tree_ids().as_resolved().cloned().unwrap()],
1791+ vec![
1792+ onto_tree.tree_ids().as_resolved().cloned().unwrap(),
1793+ to_tree.tree_ids().as_resolved().cloned().unwrap(),
1794+ ],
1795+ );
1796+ // tree_merge::merge_trees resolves file contents line-level and keeps
1797+ // unresolvable paths as conflicts (tree_merge.rs:78)
1798+ let merged = jj_lib::tree_merge::merge_trees(store, merge)
1799+ .await
1800+ .map_err(|e| Crash::new(format!("replay: {}", e)))?;
1801+ // conflicts stay as unresolved blobs (§7.3)
1802+ let merged = MergedTree::new(store.clone(), merged, jj_lib::conflict_labels::ConflictLabels::unlabeled());
1803+ tree_to_files(store, &merged).await
1804+}
1805+
1806+// ----------------------------------------------------------------------
1807+// values -> jj trees
1808+// ----------------------------------------------------------------------
1809+
1810+/// A merged tree value: Merge<Option<TreeValue>> (jj_lib::backend::MergedTreeValue)
1811+type MergedTreeValueT = Merge<Option<TreeValue>>;
1812+
1813+struct ConflictTreeValue;
1814+
1815+impl ConflictTreeValue {
1816+ fn resolved(v: TreeValue) -> MergedTreeValueT {
1817+ Merge::resolved(Some(v))
1818+ }
1819+ fn new(adds: Vec<Option<TreeValue>>, removes: Vec<Option<TreeValue>>) -> MergedTreeValueT {
1820+ Merge::from_removes_adds(removes, adds)
1821+ }
1822+}
1823+
1824+async fn build_tree(store: &Arc<Store>, files: &Value) -> Result<MergedTree, Crash> {
1825+ let entries = files
1826+ .as_list()
1827+ .map_err(|c| Crash::new(format!("persistence: {}", c.msg)))?;
1828+ let map = crate::domain::snapshot_map(entries)?;
1829+ let mut builder = MergedTreeBuilder::new(store.empty_merged_tree());
1830+ for (path, content) in &map {
1831+ let repo_path = to_repo_path(path)?;
1832+ let value = blob_to_tree_value(store, content).await?;
1833+ builder.set_or_remove(repo_path, value);
1834+ }
1835+ builder
1836+ .write_tree()
1837+ .await
1838+ .map_err(|e| Crash::new(format!("cannot write a tree: {}", e)))
1839+}
1840+
1841+async fn blob_to_tree_value(store: &Arc<Store>, content: &Value) -> Result<MergedTreeValueT, Crash> {
1842+ let Value::Blob(b) = content else {
1843+ return Err(Crash::new("persistence: content is not a Blob"));
1844+ };
1845+ match &b.content {
1846+ BlobContent::Resolved(bytes) if b.kind == BlobKind::Symlink => {
1847+ let target = std::str::from_utf8(bytes)
1848+ .map_err(|_| Crash::new("persistence: symlink target is not UTF-8"))?;
1849+ let id = store
1850+ .write_symlink(&RepoPathBuf::root(), target)
1851+ .await
1852+ .map_err(|e| Crash::new(format!("cannot write a symlink: {}", e)))?;
1853+ Ok(ConflictTreeValue::resolved(TreeValue::Symlink(id)))
1854+ }
1855+ BlobContent::Resolved(bytes) => {
1856+ let mut slice: &[u8] = bytes.as_ref();
1857+ let id = store
1858+ .write_file(&RepoPathBuf::root(), &mut slice)
1859+ .await
1860+ .map_err(|e| Crash::new(format!("cannot write a file: {}", e)))?;
1861+ Ok(ConflictTreeValue::resolved(TreeValue::File {
1862+ id,
1863+ executable: b.kind == BlobKind::Executable,
1864+ copy_id: CopyId::placeholder(),
1865+ }))
1866+ }
1867+ BlobContent::Conflict(sides) => {
1868+ if sides.len() % 2 != 1 {
1869+ return Err(Crash::new("persistence: malformed conflict blob"));
1870+ }
1871+ // jj order: [add, (remove, add)*] → Merge with interleaved adds
1872+ // and removes
1873+ let mut values: Vec<Option<TreeValue>> = Vec::new();
1874+ for side in sides {
1875+ let mut slice: &[u8] = side.as_ref();
1876+ let id = store
1877+ .write_file(&RepoPathBuf::root(), &mut slice)
1878+ .await
1879+ .map_err(|e| Crash::new(format!("cannot write a file: {}", e)))?;
1880+ values.push(Some(TreeValue::File {
1881+ id,
1882+ executable: b.kind == BlobKind::Executable,
1883+ copy_id: CopyId::placeholder(),
1884+ }));
1885+ }
1886+ let mut adds = Vec::new();
1887+ let mut removes = Vec::new();
1888+ for (i, v) in values.into_iter().enumerate() {
1889+ if i % 2 == 0 {
1890+ adds.push(v);
1891+ } else {
1892+ removes.push(v);
1893+ }
1894+ }
1895+ Ok(ConflictTreeValue::new(adds, removes))
1896+ }
1897+ }
1898+}
1899+
1900+fn to_repo_path(path: &[String]) -> Result<RepoPathBuf, Crash> {
1901+ let mut p = RepoPathBuf::root();
1902+ for comp in path {
1903+ let c = RepoPathComponentBuf::new(comp.clone())
1904+ .map_err(|_| Crash::new(format!("invalid path component `{}`", comp)))?;
1905+ p = p.join(&c);
1906+ }
1907+ Ok(p)
1908+}
1909+
1910+// ----------------------------------------------------------------------
1911+// jj trees -> values
1912+// ----------------------------------------------------------------------
1913+
1914+async fn read_file_bytes(store: &Arc<Store>, path: &RepoPath, id: &FileId) -> Result<Vec<u8>, Crash> {
1915+ use futures::AsyncReadExt;
1916+ let mut reader = store
1917+ .read_file(path, id)
1918+ .await
1919+ .map_err(|e| Crash::new(format!("cannot read a file: {}", e)))?;
1920+ let mut buf = Vec::new();
1921+ reader
1922+ .read_to_end(&mut buf)
1923+ .await
1924+ .map_err(|e| Crash::new(format!("cannot read a file: {}", e)))?;
1925+ Ok(buf)
1926+}
1927+
1928+async fn side_to_bytes(
1929+ store: &Arc<Store>,
1930+ path: &RepoPath,
1931+ v: &Option<TreeValue>,
1932+) -> Result<Vec<u8>, Crash> {
1933+ match v {
1934+ Some(TreeValue::File { id, .. }) => read_file_bytes(store, path, id).await,
1935+ Some(TreeValue::Symlink(id)) => store
1936+ .read_symlink(path, id)
1937+ .await
1938+ .map(|s| s.into_bytes())
1939+ .map_err(|e| Crash::new(format!("cannot read a symlink: {}", e))),
1940+ _ => Ok(Vec::new()),
1941+ }
1942+}
1943+
1944+async fn merged_value_to_blob(
1945+ store: &Arc<Store>,
1946+ path: &RepoPath,
1947+ value: &jj_lib::backend::MergedTreeValue,
1948+) -> Result<Value, Crash> {
1949+ if let Some(v) = value.as_resolved() {
1950+ return match v {
1951+ Some(tv) => tree_value_to_blob(store, path, tv).await,
1952+ None => Ok(Value::Blob(Rc::new(BlobVal {
1953+ kind: BlobKind::Regular,
1954+ content: BlobContent::Resolved(Rc::new(Vec::new())),
1955+ }))),
1956+ };
1957+ }
1958+ // conflict sides, jj order: [add, (remove, add)*] (§7.3)
1959+ let mut sides: Vec<Rc<Vec<u8>>> = Vec::new();
1960+ for (i, add) in value.adds().enumerate() {
1961+ sides.push(Rc::new(side_to_bytes(store, path, add).await?));
1962+ if let Some(remove) = value.get_remove(i) {
1963+ sides.push(Rc::new(side_to_bytes(store, path, remove).await?));
1964+ }
1965+ }
1966+ Ok(Value::Blob(Rc::new(BlobVal {
1967+ kind: BlobKind::Regular,
1968+ content: BlobContent::Conflict(sides),
1969+ })))
1970+}
1971+
1972+async fn tree_value_to_blob(
1973+ store: &Arc<Store>,
1974+ path: &RepoPath,
1975+ value: &TreeValue,
1976+) -> Result<Value, Crash> {
1977+ match value {
1978+ TreeValue::File { id, executable, .. } => {
1979+ let buf = read_file_bytes(store, path, id).await?;
1980+ Ok(Value::Blob(Rc::new(BlobVal {
1981+ kind: if *executable {
1982+ BlobKind::Executable
1983+ } else {
1984+ BlobKind::Regular
1985+ },
1986+ content: BlobContent::Resolved(Rc::new(buf)),
1987+ })))
1988+ }
1989+ TreeValue::Symlink(id) => {
1990+ let target = store
1991+ .read_symlink(path, id)
1992+ .await
1993+ .map_err(|e| Crash::new(format!("cannot read a symlink: {}", e)))?;
1994+ Ok(Value::Blob(Rc::new(BlobVal {
1995+ kind: BlobKind::Symlink,
1996+ content: BlobContent::Resolved(Rc::new(target.into_bytes())),
1997+ })))
1998+ }
1999+ // a conflicted directory is emitted without its children by
2000+ // MergedTree::entries; §12 puts such repairs out of scope
2001+ TreeValue::Tree(_) | TreeValue::GitSubmodule(_) => Ok(Value::Blob(Rc::new(BlobVal {
2002+ kind: BlobKind::Regular,
2003+ content: BlobContent::Resolved(Rc::new(Vec::new())),
2004+ }))),
2005+ }
2006+}
2007+
2008+async fn tree_to_files(store: &Arc<Store>, tree: &MergedTree) -> Result<Vec<Value>, Crash> {
2009+ let mut out = Vec::new();
2010+ for (path, value) in tree.entries() {
2011+ let value = value.map_err(|e| Crash::new(format!("cannot read a tree entry: {}", e)))?;
2012+ let comps: Vec<String> = path
2013+ .components()
2014+ .map(|c| c.as_internal_str().to_string())
2015+ .collect();
2016+ let content = merged_value_to_blob(store, &path, &value).await?;
2017+ let p = Value::list(comps.into_iter().map(Value::text).collect());
2018+ out.push(Value::record(&[("content", content), ("path", p)]));
2019+ }
2020+ Ok(out)
2021+}
2022+
2023+// ----------------------------------------------------------------------
2024+// value helpers
2025+// ----------------------------------------------------------------------
2026+
2027+fn commit_id_of(commit: &Value) -> Result<String, Crash> {
2028+ match commit.field("id")? {
2029+ Value::Id(i) => Ok(i.to_string()),
2030+ v => Err(Crash::new(format!("expected an Id, got a {}", v.kind_name()))),
2031+ }
2032+}
2033+
2034+fn hex_of(id: Option<&CommitId>) -> String {
2035+ id.map(|i| i.hex()).unwrap_or_default()
2036+}
2037+
2038+fn truncate_chars(s: &str, n: usize) -> String {
2039+ s.chars().take(n).collect()
2040+}
2041+
2042+fn new_id_set(repo: &Value) -> Result<BTreeSet<String>, Crash> {
2043+ let mut out = BTreeSet::new();
2044+ for c in crate::repo::all_commits(repo)? {
2045+ out.insert(commit_id_of(&c)?);
2046+ }
2047+ Ok(out)
2048+}
2049+
2050+// ----------------------------------------------------------------------
2051+// building the Repo value from the visible map (§7.2)
2052+// ----------------------------------------------------------------------
2053+
2054+fn build_repo_value(vis: &VisibleRepo, focus_change: &str) -> Result<Value, OpenError> {
2055+ let root_rec = vis
2056+ .commits
2057+ .get(ROOT_ID)
2058+ .ok_or_else(|| (2, "no root commit".to_string()))?;
2059+ let store = root_rec.commit.store().clone();
2060+ let mut subtree = build_subtree(vis, root_rec, &store)?;
2061+ sort_subtree(&mut subtree, vis);
2062+ let top = Value::record(&[
2063+ ("children", subtree.field("children").map_err(|e| (2, e.msg))?),
2064+ ("context", Value::list(vec![])),
2065+ ("root", subtree.field("root").map_err(|e| (2, e.msg))?),
2066+ ]);
2067+ match crate::repo::by_id(&top, focus_change).map_err(|c| (2, c.msg))? {
2068+ Some(v) => Ok(v),
2069+ None => Err((2, "the working-copy commit is not visible".to_string())),
2070+ }
2071+}
2072+
2073+fn build_subtree(
2074+ vis: &VisibleRepo,
2075+ rec: &StoredCommit,
2076+ store: &Arc<Store>,
2077+) -> Result<Value, OpenError> {
2078+ let files = block_on(tree_to_files(store, &rec.commit.tree())).map_err(|c| (2, c.msg))?;
2079+ let labels: Vec<Value> = vis
2080+ .labels
2081+ .get(&rec.change_id)
2082+ .cloned()
2083+ .unwrap_or_default()
2084+ .into_iter()
2085+ .map(Value::text)
2086+ .collect();
2087+ let commit_v = Value::record(&[
2088+ ("files", Value::list(files)),
2089+ ("id", Value::Id(Rc::new(rec.change_id.clone()))),
2090+ ("labels", Value::list(labels)),
2091+ ("message", Value::text(rec.commit.description())),
2092+ ]);
2093+ let mut children = Vec::new();
2094+ if let Some(kids) = vis.children.get(&rec.change_id) {
2095+ for kid in kids {
2096+ if let Some(krec) = vis.commits.get(kid) {
2097+ children.push(build_subtree(vis, krec, store)?);
2098+ }
2099+ }
2100+ }
2101+ Ok(Value::record(&[("children", Value::list(children)), ("root", commit_v)]))
2102+}
2103+
2104+/// children ordered by committer timestamp asc, ties by change id asc (§7.2)
2105+fn sort_subtree(subtree: &mut Value, vis: &VisibleRepo) {
2106+ let mut kids: Vec<Value> = subtree
2107+ .field("children")
2108+ .and_then(|v| v.as_list().map(|l| l.to_vec()))
2109+ .unwrap_or_default();
2110+ for kid in kids.iter_mut() {
2111+ sort_subtree(kid, vis);
2112+ }
2113+ kids.sort_by(|a, b| {
2114+ let key = |v: &Value| -> (i64, String) {
2115+ let id = v
2116+ .field("root")
2117+ .and_then(|r| r.field("id"))
2118+ .ok()
2119+ .and_then(|i| match i {
2120+ Value::Id(s) => Some(s.to_string()),
2121+ _ => None,
2122+ })
2123+ .unwrap_or_default();
2124+ let t = vis.commits.get(&id).map(|r| r.committer_millis).unwrap_or(0);
2125+ (t, id)
2126+ };
2127+ key(a).cmp(&key(b))
2128+ });
2129+ let root = subtree.field("root").expect("subtree has a root");
2130+ *subtree = Value::record(&[("children", Value::list(kids)), ("root", root)]);
2131+}
src/lex.rsnew file+431
@@ -0,0 +1,431 @@
1+//! Lexer for the j language (§3.1).
2+
3+use num_bigint::BigInt;
4+
5+#[derive(Debug, Clone, PartialEq)]
6+pub enum Tok {
7+ Ident(String),
8+ TypeName(String),
9+ Int(BigInt),
10+ Text(String),
11+ IdLit(String), // existing id, full or unique prefix
12+ NewId, // bare `@`
13+ LabelLit(String),
14+ PathLit(Vec<String>),
15+ Selector(String), // `.name` with no whitespace
16+ Op(&'static str), // symbolic operator
17+ // keywords / punctuation
18+ Let,
19+ In,
20+ If,
21+ Then,
22+ Else,
23+ Or,
24+ True,
25+ False,
26+ LParen,
27+ RParen,
28+ LBracket,
29+ RBracket,
30+ LBrace,
31+ RBrace,
32+ Comma,
33+ Semi,
34+ Colon,
35+ Backslash,
36+ Equals,
37+ Arrow,
38+ Underscore,
39+ Newline, // layout marker
40+ Eof,
41+}
42+
43+#[derive(Debug, Clone, PartialEq)]
44+pub struct SpTok {
45+ pub tok: Tok,
46+ pub line: usize, // 1-based
47+ pub col: usize, // 1-based
48+}
49+
50+#[derive(Debug)]
51+pub struct LexError {
52+ pub msg: String,
53+ pub line: usize,
54+}
55+
56+const SYMBOLIC_OPS: &[&str] = &[
57+ "++", "::", "==", "/=", "<=", ">=", "&&", "||", ".", "*", "+", "-", "<", ">",
58+];
59+
60+fn is_ident_start(c: char) -> bool {
61+ c.is_ascii_lowercase() || c == '_'
62+}
63+fn is_ident_char(c: char) -> bool {
64+ c.is_ascii_alphanumeric() || c == '_' || c == '\''
65+}
66+fn is_type_start(c: char) -> bool {
67+ c.is_ascii_uppercase()
68+}
69+fn is_id_char(c: char) -> bool {
70+ ('k'..='z').contains(&c)
71+}
72+
73+fn valid_label(s: &str) -> bool {
74+ !s.is_empty()
75+ && s.chars()
76+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '-'))
77+ && !s.starts_with('.')
78+ && !s.starts_with('-')
79+ && !s.contains("..")
80+ && !s.ends_with('/')
81+ && !s.ends_with(".lock")
82+}
83+
84+pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
85+ let mut toks: Vec<SpTok> = Vec::new();
86+ let chars: Vec<char> = src.chars().collect();
87+ let mut i = 0usize;
88+ let mut line = 1usize;
89+ let mut col = 1usize; // column of next char
90+
91+ macro_rules! err {
92+ ($msg:expr) => {
93+ return Err(LexError {
94+ msg: $msg.into(),
95+ line,
96+ })
97+ };
98+ }
99+
100+ // push helper
101+ macro_rules! push {
102+ ($t:expr, $l:expr, $c:expr) => {
103+ toks.push(SpTok {
104+ tok: $t,
105+ line: $l,
106+ col: $c,
107+ })
108+ };
109+ }
110+
111+ while i < chars.len() {
112+ let c = chars[i];
113+ if c == '\n' {
114+ push!(Tok::Newline, line, col);
115+ i += 1;
116+ line += 1;
117+ col = 1;
118+ continue;
119+ }
120+ if c == ' ' || c == '\t' || c == '\r' {
121+ i += 1;
122+ col += 1;
123+ continue;
124+ }
125+ let start_line = line;
126+ let start_col = col;
127+ // comments
128+ if c == '-' && i + 1 < chars.len() && chars[i + 1] == '-' {
129+ // could be `--` comment, but `->`? no: `->` is dash-gt, distinct.
130+ while i < chars.len() && chars[i] != '\n' {
131+ i += 1;
132+ col += 1;
133+ }
134+ continue;
135+ }
136+ if c == '{' && i + 1 < chars.len() && chars[i + 1] == '-' {
137+ let mut depth = 1usize;
138+ i += 2;
139+ col += 2;
140+ while i < chars.len() && depth > 0 {
141+ if chars[i] == '{' && i + 1 < chars.len() && chars[i + 1] == '-' {
142+ depth += 1;
143+ i += 2;
144+ col += 2;
145+ } else if chars[i] == '-' && i + 1 < chars.len() && chars[i + 1] == '}' {
146+ depth -= 1;
147+ i += 2;
148+ col += 2;
149+ } else if chars[i] == '\n' {
150+ push!(Tok::Newline, line, col);
151+ i += 1;
152+ line += 1;
153+ col = 1;
154+ } else {
155+ i += 1;
156+ col += 1;
157+ }
158+ }
159+ if depth > 0 {
160+ err!("unterminated block comment");
161+ }
162+ // a comment spanning lines must not leave later tokens looking
163+ // like continuations of the line it started on
164+ push!(Tok::Newline, line, col);
165+ continue;
166+ }
167+ // arrow `->` (check before operator `-`)
168+ if c == '-' && i + 1 < chars.len() && chars[i + 1] == '>' {
169+ push!(Tok::Arrow, start_line, start_col);
170+ i += 2;
171+ col += 2;
172+ continue;
173+ }
174+ // path literal: `./` at start of token
175+ if c == '.' && i + 1 < chars.len() && chars[i + 1] == '/' {
176+ i += 2;
177+ col += 2;
178+ let mut comp = String::new();
179+ let mut comps: Vec<String> = Vec::new();
180+ while i < chars.len() {
181+ let d = chars[i];
182+ if d.is_whitespace() || matches!(d, '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';') {
183+ break;
184+ }
185+ if d == '/' {
186+ comps.push(std::mem::take(&mut comp));
187+ i += 1;
188+ col += 1;
189+ // trailing slash: peek—if next is terminator, stop (drop empty comp)
190+ if i >= chars.len()
191+ || chars[i].is_whitespace()
192+ || matches!(chars[i], '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';')
193+ {
194+ break;
195+ }
196+ continue;
197+ }
198+ comp.push(d);
199+ i += 1;
200+ col += 1;
201+ }
202+ if !comp.is_empty() {
203+ comps.push(comp);
204+ }
205+ push!(Tok::PathLit(comps), start_line, start_col);
206+ continue;
207+ }
208+ // selector: `.` immediately followed by identifier-start
209+ if c == '.' && i + 1 < chars.len() && is_ident_start(chars[i + 1]) {
210+ let mut name = String::new();
211+ i += 1;
212+ col += 1;
213+ while i < chars.len() && is_ident_char(chars[i]) {
214+ name.push(chars[i]);
215+ i += 1;
216+ col += 1;
217+ }
218+ push!(Tok::Selector(name), start_line, start_col);
219+ continue;
220+ }
221+ // `@` id literals
222+ if c == '@' {
223+ if i + 1 >= chars.len() || !is_ident_char(chars[i + 1]) {
224+ push!(Tok::NewId, start_line, start_col);
225+ i += 1;
226+ col += 1;
227+ continue;
228+ }
229+ if is_id_char(chars[i + 1]) {
230+ let mut s = String::new();
231+ i += 1;
232+ col += 1;
233+ while i < chars.len() && is_id_char(chars[i]) {
234+ s.push(chars[i]);
235+ i += 1;
236+ col += 1;
237+ }
238+ // if the next char is still an identifier char, it's a lexical error
239+ if i < chars.len() && is_ident_char(chars[i]) {
240+ err!("invalid id literal");
241+ }
242+ push!(Tok::IdLit(s), start_line, start_col);
243+ continue;
244+ }
245+ err!("`@` followed by an identifier character outside k-z is not a valid id literal");
246+ }
247+ // `%` label literals
248+ if c == '%' {
249+ let mut s = String::new();
250+ i += 1;
251+ col += 1;
252+ while i < chars.len() {
253+ let d = chars[i];
254+ if d.is_ascii_alphanumeric() || matches!(d, '.' | '_' | '/' | '-') {
255+ s.push(d);
256+ i += 1;
257+ col += 1;
258+ } else {
259+ break;
260+ }
261+ }
262+ if !valid_label(&s) {
263+ err!("invalid label literal");
264+ }
265+ push!(Tok::LabelLit(s), start_line, start_col);
266+ continue;
267+ }
268+ // text literal
269+ if c == '"' {
270+ i += 1;
271+ col += 1;
272+ let mut s = String::new();
273+ loop {
274+ if i >= chars.len() {
275+ err!("unterminated text literal");
276+ }
277+ let d = chars[i];
278+ match d {
279+ '"' => {
280+ i += 1;
281+ col += 1;
282+ break;
283+ }
284+ '\\' => {
285+ if i + 1 >= chars.len() {
286+ err!("unterminated text literal");
287+ }
288+ let e = chars[i + 1];
289+ let r = match e {
290+ '"' => '"',
291+ '\\' => '\\',
292+ 'n' => '\n',
293+ 't' => '\t',
294+ 'r' => '\r',
295+ _ => err!("unknown escape in text literal"),
296+ };
297+ s.push(r);
298+ i += 2;
299+ col += 2;
300+ }
301+ '\n' => err!("newline in text literal"),
302+ _ => {
303+ s.push(d);
304+ i += 1;
305+ col += 1;
306+ }
307+ }
308+ }
309+ push!(Tok::Text(s), start_line, start_col);
310+ continue;
311+ }
312+ // integer
313+ if c.is_ascii_digit() {
314+ let mut s = String::new();
315+ while i < chars.len() && chars[i].is_ascii_digit() {
316+ s.push(chars[i]);
317+ i += 1;
318+ col += 1;
319+ }
320+ push!(Tok::Int(s.parse::<BigInt>().unwrap()), start_line, start_col);
321+ continue;
322+ }
323+ // identifier / keyword
324+ if is_ident_start(c) {
325+ let mut s = String::new();
326+ while i < chars.len() && is_ident_char(chars[i]) {
327+ s.push(chars[i]);
328+ i += 1;
329+ col += 1;
330+ }
331+ let t = match s.as_str() {
332+ "let" => Tok::Let,
333+ "in" => Tok::In,
334+ "if" => Tok::If,
335+ "then" => Tok::Then,
336+ "else" => Tok::Else,
337+ "or" => Tok::Or,
338+ "true" => Tok::True,
339+ "false" => Tok::False,
340+ "_" => {
341+ if s == "_" {
342+ Tok::Underscore
343+ } else {
344+ Tok::Ident(s)
345+ }
346+ }
347+ _ => Tok::Ident(s),
348+ };
349+ push!(t, start_line, start_col);
350+ continue;
351+ }
352+ // type name
353+ if is_type_start(c) {
354+ let mut s = String::new();
355+ while i < chars.len() {
356+ let d = chars[i];
357+ if d.is_ascii_alphanumeric() || d == '_' {
358+ s.push(d);
359+ i += 1;
360+ col += 1;
361+ } else {
362+ break;
363+ }
364+ }
365+ push!(Tok::TypeName(s), start_line, start_col);
366+ continue;
367+ }
368+ // symbolic operators: longest match
369+ let rest: String = chars[i..].iter().take(2).collect();
370+ let mut matched: Option<&'static str> = None;
371+ for op in SYMBOLIC_OPS {
372+ if op.len() == 2 && rest == *op {
373+ matched = Some(op);
374+ break;
375+ }
376+ }
377+ if matched.is_none() {
378+ let one = &rest[..1];
379+ for op in SYMBOLIC_OPS {
380+ if op.len() == 1 && *op == one {
381+ matched = Some(op);
382+ break;
383+ }
384+ }
385+ }
386+ if let Some(op) = matched {
387+ push!(Tok::Op(op), start_line, start_col);
388+ i += op.len();
389+ col += op.len();
390+ continue;
391+ }
392+ // punctuation
393+ let t = match c {
394+ '(' => Tok::LParen,
395+ ')' => Tok::RParen,
396+ '[' => Tok::LBracket,
397+ ']' => Tok::RBracket,
398+ '{' => Tok::LBrace,
399+ '}' => Tok::RBrace,
400+ ',' => Tok::Comma,
401+ ';' => Tok::Semi,
402+ ':' => Tok::Colon,
403+ '\\' => Tok::Backslash,
404+ '=' => Tok::Equals,
405+ _ => err!(format!("unexpected character `{}`", c)),
406+ };
407+ push!(t, start_line, start_col);
408+ i += 1;
409+ col += 1;
410+ }
411+ push!(Tok::Eof, line, col);
412+ Ok(toks)
413+}
414+
415+impl Tok {
416+ pub fn describe(&self) -> String {
417+ match self {
418+ Tok::Ident(s) => format!("`{}`", s),
419+ Tok::TypeName(s) => format!("`{}`", s),
420+ Tok::Int(n) => format!("`{}`", n),
421+ Tok::Text(_) => "a text literal".into(),
422+ Tok::IdLit(s) => format!("`@{}`", s),
423+ Tok::NewId => "`@`".into(),
424+ Tok::LabelLit(s) => format!("`%{}`", s),
425+ Tok::PathLit(_) => "a path literal".into(),
426+ Tok::Selector(s) => format!("`.{}`", s),
427+ Tok::Op(o) => format!("`{}`", o),
428+ other => format!("`{:?}`", other).to_lowercase(),
429+ }
430+ }
431+}
src/lib.rsnew file+14
@@ -0,0 +1,14 @@
1+pub mod ast;
2+pub mod builtins;
3+pub mod config;
4+pub mod domain;
5+pub mod eval;
6+pub mod lex;
7+pub mod parse;
8+pub mod render;
9+pub mod repo;
10+pub mod shape;
11+pub mod show;
12+pub mod value;
13+
14+pub mod jj;
src/main.rsnew file+388
@@ -0,0 +1,388 @@
1+//! j — a functional-programming-centered CLI for jj repositories (§1).
2+
3+use j::config::{self, Config};
4+use j::eval::Interp;
5+use j::jj::JjBackend;
6+use j::parse::parse_expr;
7+use j::value::{Crash, Value};
8+use std::collections::BTreeSet;
9+use std::io::{IsTerminal, Read};
10+use std::process::ExitCode;
11+use std::rc::Rc;
12+
13+const USAGE: &str = "usage: j EXPRESSION… (or echo EXPRESSION | j)";
14+
15+fn err(status: u8, msg: impl std::fmt::Display) -> ExitCode {
16+ eprintln!("j: {}", msg);
17+ ExitCode::from(status)
18+}
19+
20+fn crash_err(c: &Crash, expr_text: Option<&str>) -> ExitCode {
21+ eprintln!("j: crash: {}", c.msg);
22+ match (&c.def, expr_text) {
23+ (Some(d), Some(e)) => eprintln!(" in {}, from {}", d, e),
24+ (None, Some(e)) => eprintln!(" from {}", e),
25+ (Some(d), None) => eprintln!(" in {}", d),
26+ (None, None) => {}
27+ }
28+ ExitCode::from(1)
29+}
30+
31+fn config_path() -> String {
32+ if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
33+ if !xdg.is_empty() {
34+ return format!("{}/j/config.j", xdg);
35+ }
36+ }
37+ let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
38+ format!("{}/.config/j/config.j", home)
39+}
40+
41+fn load_config_file() -> Result<(Config, String), ExitCode> {
42+ let path = config_path();
43+ let src = match std::fs::read_to_string(&path) {
44+ Ok(s) => s,
45+ Err(_) => {
46+ return Err(err(
47+ 3,
48+ format!(
49+ "no config at {}; the reference config.j is installed at {}",
50+ path,
51+ reference_config_path()
52+ ),
53+ ));
54+ }
55+ };
56+ match config::load_config(&src) {
57+ Ok(c) => Ok((c, src)),
58+ Err(e) => Err(err(3, e.message())),
59+ }
60+}
61+
62+fn reference_config_path() -> String {
63+ // installed next to the binary under <prefix>/share/j/config.j
64+ if let Ok(exe) = std::env::current_exe() {
65+ if let Some(p) = exe.parent().and_then(|p| p.parent()) {
66+ let cand = p.join("share/j/config.j");
67+ if cand.exists() {
68+ return cand.display().to_string();
69+ }
70+ }
71+ }
72+ "/usr/local/share/j/config.j".into()
73+}
74+
75+const RESERVED: &[&str] = &["init", "clone", "remote", "fetch", "push", "undo", "redo", "ops"];
76+
77+fn main() -> ExitCode {
78+ // evaluation builds deep continuation chains (trampolined, but the final
79+ // continuation still tears down by recursive Drop), so run the real work
80+ // on a thread with a large stack to keep very deep histories safe
81+ std::thread::Builder::new()
82+ .stack_size(512 * 1024 * 1024)
83+ .spawn(run)
84+ .expect("failed to spawn worker thread")
85+ .join()
86+ .expect("worker thread panicked")
87+}
88+
89+fn run() -> ExitCode {
90+ let args: Vec<String> = std::env::args().skip(1).collect();
91+ // §1: stdin vs arguments
92+ let mut stdin_content = String::new();
93+ let stdin_has_data = {
94+ if std::io::stdin().is_terminal() {
95+ false
96+ } else {
97+ match std::io::stdin().read_to_string(&mut stdin_content) {
98+ Ok(n) => n > 0,
99+ Err(_) => false,
100+ }
101+ }
102+ };
103+ if stdin_has_data && !args.is_empty() {
104+ return err(2, "expression given both as arguments and on stdin");
105+ }
106+ let text = if stdin_has_data {
107+ stdin_content
108+ } else if !args.is_empty() {
109+ args.join(" ")
110+ } else {
111+ eprintln!("{}", USAGE);
112+ return ExitCode::from(2);
113+ };
114+
115+ // §1.1: reserved commands
116+ let trimmed = text.trim();
117+ let first = trimmed.split_whitespace().next().unwrap_or("");
118+ if RESERVED.contains(&first) {
119+ return run_reserved(first, trimmed);
120+ }
121+
122+ run_expression(trimmed, true)
123+}
124+
125+fn run_reserved(cmd: &str, text: &str) -> ExitCode {
126+ let words: Vec<&str> = text.split_whitespace().collect();
127+ match cmd {
128+ "init" => {
129+ if words.len() != 1 {
130+ return err(2, "usage: j init");
131+ }
132+ let (cfg, _) = or_exit(load_config_file());
133+ match j::jj::cmd_init(&cfg) {
134+ Ok(()) => ExitCode::SUCCESS,
135+ Err(e) => err(e.0, e.1),
136+ }
137+ }
138+ "clone" => {
139+ if words.len() != 2 && words.len() != 3 {
140+ return err(2, "usage: j clone URL [DIR]");
141+ }
142+ let (cfg, _) = or_exit(load_config_file());
143+ let dir = if words.len() == 3 {
144+ words[2].to_string()
145+ } else {
146+ default_clone_dir(words[1])
147+ };
148+ match j::jj::cmd_clone(&cfg, words[1], &dir) {
149+ Ok(()) => ExitCode::SUCCESS,
150+ Err(e) => err(e.0, e.1),
151+ }
152+ }
153+ "remote" => {
154+ if words.len() != 2 {
155+ return err(2, "usage: j remote URL");
156+ }
157+ let backend = or_exit(open_repo());
158+ match backend.cmd_remote(words[1]) {
159+ Ok(()) => ExitCode::SUCCESS,
160+ Err(e) => err(e.0, e.1),
161+ }
162+ }
163+ "fetch" => {
164+ if words.len() != 1 {
165+ return err(2, "usage: j fetch");
166+ }
167+ let (cfg, _) = or_exit(load_config_file());
168+ let backend = or_exit(open_repo_locked(&cfg));
169+ match backend.cmd_fetch() {
170+ Ok(()) => ExitCode::SUCCESS,
171+ Err(e) => err(e.0, e.1),
172+ }
173+ }
174+ "push" => {
175+ let rest = text.trim_start_matches("push").trim();
176+ if rest.is_empty() {
177+ return err(2, "usage: j push EXPR");
178+ }
179+ let (cfg, _) = or_exit(load_config_file());
180+ let backend = or_exit(open_repo_locked(&cfg));
181+ // evaluate EXPR against the recorded repository (no snapshot)
182+ match backend.cmd_push(&cfg, rest) {
183+ Ok(()) => ExitCode::SUCCESS,
184+ Err(e) => err(e.0, e.1),
185+ }
186+ }
187+ "undo" => {
188+ if words.len() != 1 {
189+ return err(2, "usage: j undo");
190+ }
191+ let backend = or_exit(open_repo_noconfig());
192+ match backend.cmd_undo(false) {
193+ Ok(()) => ExitCode::SUCCESS,
194+ Err(e) => err(e.0, e.1),
195+ }
196+ }
197+ "redo" => {
198+ if words.len() != 1 {
199+ return err(2, "usage: j redo");
200+ }
201+ let backend = or_exit(open_repo_noconfig());
202+ match backend.cmd_undo(true) {
203+ Ok(()) => ExitCode::SUCCESS,
204+ Err(e) => err(e.0, e.1),
205+ }
206+ }
207+ "ops" => {
208+ if words.len() != 1 {
209+ return err(2, "usage: j ops");
210+ }
211+ let backend = or_exit(open_repo_noconfig());
212+ match backend.cmd_ops() {
213+ Ok(()) => ExitCode::SUCCESS,
214+ Err(e) => err(e.0, e.1),
215+ }
216+ }
217+ _ => unreachable!(),
218+ }
219+}
220+
221+fn default_clone_dir(url: &str) -> String {
222+ let last = url.trim_end_matches('/').rsplit('/').next().unwrap_or("repo");
223+ last.strip_suffix(".git").unwrap_or(last).to_string()
224+}
225+
226+fn or_exit<T>(r: Result<T, ExitCode>) -> T {
227+ match r {
228+ Ok(v) => v,
229+ Err(c) => std::process::exit(exit_code_number(c)),
230+ }
231+}
232+
233+/// ExitCode doesn't expose its value; track it alongside.
234+fn exit_code_number(c: ExitCode) -> i32 {
235+ // ExitCode's Debug is "ExitCode(unix_exit_status(N))" on unix
236+ let s = format!("{:?}", c);
237+ for tok in s.split(|ch: char| !ch.is_ascii_digit()) {
238+ if !tok.is_empty() {
239+ if let Ok(n) = tok.parse() {
240+ return n;
241+ }
242+ }
243+ }
244+ 1
245+}
246+
247+fn open_repo() -> Result<Rc<JjBackend>, ExitCode> {
248+ match JjBackend::open(None) {
249+ Ok(b) => Ok(Rc::new(b)),
250+ Err(e) => Err(err(e.0, e.1)),
251+ }
252+}
253+
254+fn open_repo_noconfig() -> Result<Rc<JjBackend>, ExitCode> {
255+ // undo/redo/ops do not read the config (§6.1), but take the lock
256+ match JjBackend::open(None) {
257+ Ok(b) => {
258+ b.take_lock();
259+ Ok(Rc::new(b))
260+ }
261+ Err(e) => Err(err(e.0, e.1)),
262+ }
263+}
264+
265+fn open_repo_locked(cfg: &Config) -> Result<Rc<JjBackend>, ExitCode> {
266+ match JjBackend::open(Some(cfg)) {
267+ Ok(b) => {
268+ b.take_lock();
269+ Ok(Rc::new(b))
270+ }
271+ Err(e) => Err(err(e.0, e.1)),
272+ }
273+}
274+
275+fn run_expression(text: &str, snapshot: bool) -> ExitCode {
276+ // §1.2.1: locate the repository
277+ let (cfg, _cfg_src) = or_exit(load_config_file());
278+ let backend = or_exit(open_repo_locked(&cfg));
279+
280+ // §1.2.2: parse the expression
281+ let outer = Rc::new(cfg.global_names.clone());
282+ let mut expr = match parse_expr(text, outer) {
283+ Ok(e) => e,
284+ Err(p) => return err(3, format!("line {}: {}", p.line, p.msg)),
285+ };
286+
287+ // §1.2.4: build the current repository value (with snapshot)
288+ let (mut interp, loaded_repo, current_repo) =
289+ match backend.build_interp(&cfg, text, snapshot) {
290+ Ok(t) => t,
291+ Err((2, m)) => return err(2, m),
292+ Err((_, m)) => return err(1, m),
293+ };
294+
295+ // §1.2.5: resolve Id literals in config and expression
296+ for (def_name, prefix, candidates) in config::config_id_failures(&cfg, &interp) {
297+ let _ = candidates;
298+ return err(
299+ 3,
300+ format!(
301+ "config.j: `@{}` in `{}` does not resolve to a unique commit",
302+ prefix, def_name
303+ ),
304+ );
305+ }
306+ expr = match config::resolve_ids(&expr, &interp) {
307+ Ok(e) => e,
308+ Err(failures) => {
309+ let (prefix, candidates) = &failures[0];
310+ if candidates.is_empty() {
311+ eprintln!("j: crash: `@{}` matches no commit", prefix);
312+ } else {
313+ eprintln!("j: crash: `@{}` is ambiguous", prefix);
314+ for c in candidates {
315+ eprintln!(" {}", c);
316+ }
317+ }
318+ return ExitCode::from(1);
319+ }
320+ };
321+
322+ // §1.2.6: evaluate config definitions, then the expression
323+ if let Err(c) = config::eval_config(&mut interp, &cfg) {
324+ return err(3, format!("config.j: {}", c.msg));
325+ }
326+ *interp.old_repo.borrow_mut() = Some(loaded_repo.clone());
327+ let env = interp.global_env();
328+ let expr_rc = Rc::new(expr);
329+ let mut v = match interp.eval(&expr_rc, &env) {
330+ Ok(v) => v,
331+ Err(c) => return crash_err(&c, Some(text)),
332+ };
333+
334+ // §1.2.7: apply a function result to the repo, once
335+ if matches!(v, Value::Fun(_)) {
336+ v = match interp.apply(v, current_repo.clone()) {
337+ Ok(v) => v,
338+ Err(c) => return crash_err(&c, Some(text)),
339+ };
340+ }
341+
342+ // §1.2.8: a Repo is persisted (unless unchanged)
343+ if is_repo_value(&interp, &v) {
344+ match backend.persist(&mut interp, &cfg, &loaded_repo, &current_repo, &v, text) {
345+ Ok(()) => ExitCode::SUCCESS,
346+ Err(c) => crash_err(&c, Some(text)),
347+ }
348+ } else {
349+ // §1.2.9: display
350+ let color = j::render::color_enabled("auto");
351+ match j::render::display(&mut interp, &v, color) {
352+ Ok(s) => {
353+ print!("{}", s);
354+ ExitCode::SUCCESS
355+ }
356+ Err(c) => crash_err(&c, Some(text)),
357+ }
358+ }
359+}
360+
361+fn is_repo_value(interp: &Interp, v: &Value) -> bool {
362+ // §4.4: shape Repo and root's shape Commit
363+ let fields = match v {
364+ Value::Record(m) => m.keys().cloned().collect::<BTreeSet<String>>(),
365+ _ => return false,
366+ };
367+ let want: BTreeSet<String> = ["children", "context", "root"]
368+ .iter()
369+ .map(|s| s.to_string())
370+ .collect();
371+ if fields != want {
372+ return false;
373+ }
374+ let root = match v.field("root") {
375+ Ok(r) => r,
376+ Err(_) => return false,
377+ };
378+ let root_fields = match &root {
379+ Value::Record(m) => m.keys().cloned().collect::<BTreeSet<String>>(),
380+ _ => return false,
381+ };
382+ let want_root: BTreeSet<String> = ["files", "id", "labels", "message"]
383+ .iter()
384+ .map(|s| s.to_string())
385+ .collect();
386+ let _ = interp;
387+ root_fields == want_root
388+}
src/parse.rsnew file+931
@@ -0,0 +1,931 @@
1+//! Parser for the j language (§3.2–3.4), including the two layout rules.
2+
3+use crate::ast::{Expr, Item, Pattern, TypeExpr};
4+use crate::lex::{lex, LexError, SpTok, Tok};
5+use num_bigint::BigInt;
6+use std::collections::BTreeSet;
7+use std::rc::Rc;
8+
9+#[derive(Debug)]
10+pub struct ParseError {
11+ pub msg: String,
12+ pub line: usize,
13+}
14+
15+impl ParseError {
16+ fn new(msg: impl Into<String>, line: usize) -> Self {
17+ ParseError {
18+ msg: msg.into(),
19+ line,
20+ }
21+ }
22+}
23+
24+impl From<LexError> for ParseError {
25+ fn from(e: LexError) -> Self {
26+ ParseError {
27+ msg: e.msg,
28+ line: e.line,
29+ }
30+ }
31+}
32+
33+pub struct Parser {
34+ toks: Vec<SpTok>,
35+ pos: usize,
36+ /// Names in scope (top-level + builtins) for the no-shadowing rule.
37+ outer_names: Rc<BTreeSet<String>>,
38+ /// set when an operator expression ends with a trailing operator before
39+ /// `)`, i.e. a left section
40+ section_op: Option<&'static str>,
41+}
42+
43+/// fixity table (§3.2): (precedence, right-assoc)
44+fn fixity(op: &str) -> Option<(u8, bool)> {
45+ Some(match op {
46+ "." => (9, true),
47+ "*" => (7, false),
48+ "+" | "-" => (6, false),
49+ "++" | "::" => (5, true),
50+ "==" | "/=" | "<" | "<=" | ">" | ">=" => (4, false), // non-assoc
51+ "&&" => (3, true),
52+ "||" => (2, true),
53+ _ => return None,
54+ })
55+}
56+
57+/// non-associative operators cannot be chained
58+fn is_nonassoc(op: &str) -> bool {
59+ matches!(op, "==" | "/=" | "<" | "<=" | ">" | ">=")
60+}
61+
62+impl Parser {
63+ pub fn new(src: &str, outer_names: Rc<BTreeSet<String>>) -> Result<Self, ParseError> {
64+ Ok(Parser {
65+ toks: lex(src)?,
66+ pos: 0,
67+ outer_names,
68+ section_op: None,
69+ })
70+ }
71+
72+ fn peek(&self) -> &Tok {
73+ &self.toks[self.pos].tok
74+ }
75+ fn line(&self) -> usize {
76+ self.toks[self.pos].line
77+ }
78+ fn col(&self) -> usize {
79+ self.toks[self.pos].col
80+ }
81+ fn bump(&mut self) -> Tok {
82+ let t = self.toks[self.pos].tok.clone();
83+ if self.pos < self.toks.len() - 1 {
84+ self.pos += 1;
85+ }
86+ t
87+ }
88+ fn skip_newlines(&mut self) {
89+ while matches!(self.peek(), Tok::Newline) {
90+ self.bump();
91+ }
92+ }
93+ fn expect(&mut self, t: &Tok) -> Result<(), ParseError> {
94+ if self.peek() == t {
95+ self.bump();
96+ Ok(())
97+ } else {
98+ Err(ParseError::new(
99+ format!("expected {}, found {}", t.describe(), self.peek().describe()),
100+ self.line(),
101+ ))
102+ }
103+ }
104+
105+ // ------------------------------------------------------------------
106+ // top-level: config := item* (layout rule 1)
107+ // ------------------------------------------------------------------
108+ pub fn parse_config(&mut self) -> Result<Vec<Item>, ParseError> {
109+ let mut items = Vec::new();
110+ self.skip_newlines();
111+ while !matches!(self.peek(), Tok::Eof) {
112+ if self.col() != 1 {
113+ return Err(ParseError::new(
114+ "top-level items must begin in column 1",
115+ self.line(),
116+ ));
117+ }
118+ let item = self.parse_item()?;
119+ items.push(item);
120+ // after an item, expect newline(s) or eof
121+ self.skip_newlines();
122+ }
123+ Ok(items)
124+ }
125+
126+ fn parse_item(&mut self) -> Result<Item, ParseError> {
127+ match self.peek().clone() {
128+ Tok::TypeName(name) => {
129+ // typedecl: TYPENAME '=' type
130+ self.bump();
131+ self.skip_newlines();
132+ self.expect(&Tok::Equals)?;
133+ let ty = self.parse_type()?;
134+ Ok(Item::TypeDecl(name, ty))
135+ }
136+ Tok::Ident(name) => {
137+ let line = self.line();
138+ self.bump();
139+ match self.peek().clone() {
140+ Tok::Colon => {
141+ self.bump();
142+ let ty = self.parse_type()?;
143+ Ok(Item::Signature(name, ty, line))
144+ }
145+ Tok::Equals => {
146+ self.bump();
147+ let e = self.parse_expr(1)?;
148+ Ok(Item::Definition(name, Rc::new(e), line))
149+ }
150+ other => Err(ParseError::new(
151+ format!("expected `:` or `=` after `{}`, found {}", name, other.describe()),
152+ self.line(),
153+ )),
154+ }
155+ }
156+ Tok::LParen => {
157+ // operator signature: (op) : type
158+ let line = self.line();
159+ self.bump();
160+ let op = match self.bump() {
161+ Tok::Op(o) => o,
162+ other => {
163+ return Err(ParseError::new(
164+ format!("expected operator, found {}", other.describe()),
165+ self.line(),
166+ ))
167+ }
168+ };
169+ self.expect(&Tok::RParen)?;
170+ self.expect(&Tok::Colon)?;
171+ let ty = self.parse_type()?;
172+ Ok(Item::Signature(op.to_string(), ty, line))
173+ }
174+ other => Err(ParseError::new(
175+ format!("unexpected {} at top level", other.describe()),
176+ self.line(),
177+ )),
178+ }
179+ }
180+
181+ // ------------------------------------------------------------------
182+ // types
183+ // ------------------------------------------------------------------
184+ fn parse_type(&mut self) -> Result<TypeExpr, ParseError> {
185+ self.skip_newlines_in_type();
186+ let lhs = self.parse_atype()?;
187+ self.skip_newlines_in_type();
188+ if matches!(self.peek(), Tok::Arrow) {
189+ self.bump();
190+ let rhs = self.parse_type()?;
191+ Ok(TypeExpr::Fun(Rc::new(lhs), Rc::new(rhs)))
192+ } else {
193+ Ok(lhs)
194+ }
195+ }
196+
197+ /// In types (signatures) we allow newlines before `->` continuation when
198+ /// indented; keep it simple: skip newlines if the next token is `->`.
199+ fn skip_newlines_in_type(&mut self) {
200+ let save = self.pos;
201+ loop {
202+ if matches!(self.peek(), Tok::Newline) {
203+ self.pos += 1;
204+ // only continue skipping if Arrow follows after the newlines
205+ let _ = &save;
206+ let mut p = self.pos;
207+ while matches!(self.toks[p].tok, Tok::Newline) {
208+ p += 1;
209+ }
210+ if matches!(self.toks[p].tok, Tok::Arrow) {
211+ self.pos = p;
212+ return;
213+ } else {
214+ self.pos = save;
215+ // restore to before newlines? we must not consume them if
216+ // not followed by Arrow, since layout needs them.
217+ // Walk back: find first newline we consumed.
218+ while self.pos > 0 && matches!(self.toks[self.pos - 1].tok, Tok::Newline) {
219+ self.pos -= 1;
220+ }
221+ return;
222+ }
223+ } else {
224+ return;
225+ }
226+ }
227+ }
228+
229+ fn parse_atype(&mut self) -> Result<TypeExpr, ParseError> {
230+ match self.peek().clone() {
231+ Tok::TypeName(n) => {
232+ self.bump();
233+ Ok(TypeExpr::Con(n))
234+ }
235+ Tok::Ident(n) => {
236+ self.bump();
237+ Ok(TypeExpr::Con(n))
238+ }
239+ Tok::LBracket => {
240+ self.bump();
241+ let t = self.parse_type()?;
242+ self.expect(&Tok::RBracket)?;
243+ Ok(TypeExpr::List(Rc::new(t)))
244+ }
245+ Tok::LBrace => {
246+ self.bump();
247+ let mut fields = Vec::new();
248+ loop {
249+ self.skip_newlines();
250+ if matches!(self.peek(), Tok::RBrace) {
251+ self.bump();
252+ break;
253+ }
254+ let name = match self.bump() {
255+ Tok::Ident(n) => n,
256+ other => {
257+ return Err(ParseError::new(
258+ format!("expected field name, found {}", other.describe()),
259+ self.line(),
260+ ))
261+ }
262+ };
263+ self.expect(&Tok::Colon)?;
264+ let t = self.parse_type()?;
265+ fields.push((name, Rc::new(t)));
266+ self.skip_newlines();
267+ match self.peek() {
268+ Tok::Comma => {
269+ self.bump();
270+ }
271+ Tok::RBrace => {
272+ self.bump();
273+ break;
274+ }
275+ other => {
276+ return Err(ParseError::new(
277+ format!("expected `,` or `}}`, found {}", other.describe()),
278+ self.line(),
279+ ))
280+ }
281+ }
282+ }
283+ Ok(TypeExpr::Record(fields))
284+ }
285+ Tok::LParen => {
286+ self.bump();
287+ let t = self.parse_type()?;
288+ self.expect(&Tok::RParen)?;
289+ Ok(t)
290+ }
291+ other => Err(ParseError::new(
292+ format!("expected a type, found {}", other.describe()),
293+ self.line(),
294+ )),
295+ }
296+ }
297+
298+ // ------------------------------------------------------------------
299+ // expressions
300+ // ------------------------------------------------------------------
301+
302+ /// Parse a full expression. `min_col` is the layout column limit: the
303+ /// expression stops at a Newline whose following token is in a column
304+ /// <= min_col (it belongs to an outer construct).
305+ pub fn parse_expr(&mut self, min_col: usize) -> Result<Expr, ParseError> {
306+ self.skip_layout_newlines(min_col);
307+ match self.peek().clone() {
308+ Tok::Backslash => {
309+ let src_start = self.toks[self.pos].clone();
310+ self.bump();
311+ let mut params = Vec::new();
312+ loop {
313+ match self.peek().clone() {
314+ Tok::Ident(n) => {
315+ self.check_shadow(&n)?;
316+ params.push(Pattern::Var(n));
317+ self.bump();
318+ }
319+ Tok::Underscore => {
320+ params.push(Pattern::Wildcard);
321+ self.bump();
322+ }
323+ Tok::Arrow => {
324+ self.bump();
325+ break;
326+ }
327+ other => {
328+ return Err(ParseError::new(
329+ format!(
330+ "expected a parameter or `->`, found {}",
331+ other.describe()
332+ ),
333+ self.line(),
334+ ))
335+ }
336+ }
337+ }
338+ if params.is_empty() {
339+ return Err(ParseError::new("lambda needs at least one parameter", self.line()));
340+ }
341+ let body = self.parse_expr(min_col)?;
342+ let src = self.source_slice(&src_start);
343+ Ok(Expr::Lambda(params, Rc::new(body), src))
344+ }
345+ Tok::If => {
346+ self.bump();
347+ let c = self.parse_expr(min_col)?;
348+ self.skip_layout_newlines(min_col);
349+ self.expect(&Tok::Then)?;
350+ let t = self.parse_expr(min_col)?;
351+ self.skip_layout_newlines(min_col);
352+ self.expect(&Tok::Else)?;
353+ let e = self.parse_expr(min_col)?;
354+ Ok(Expr::If(Rc::new(c), Rc::new(t), Rc::new(e)))
355+ }
356+ Tok::Let => self.parse_let(min_col),
357+ _ => self.parse_or(min_col),
358+ }
359+ }
360+
361+ /// skip Newlines whose next real token is in a column > min_col
362+ fn skip_layout_newlines(&mut self, min_col: usize) {
363+ loop {
364+ if !matches!(self.peek(), Tok::Newline) {
365+ return;
366+ }
367+ // lookahead past consecutive newlines
368+ let mut p = self.pos;
369+ while matches!(self.toks[p].tok, Tok::Newline) {
370+ p += 1;
371+ }
372+ if matches!(self.toks[p].tok, Tok::Eof) {
373+ return;
374+ }
375+ if self.toks[p].col > min_col {
376+ self.pos = p;
377+ } else {
378+ return;
379+ }
380+ }
381+ }
382+
383+ fn parse_let(&mut self, min_col: usize) -> Result<Expr, ParseError> {
384+ self.bump(); // let
385+ // skip newlines to find the block column
386+ let mut p = self.pos;
387+ while matches!(self.toks[p].tok, Tok::Newline) {
388+ p += 1;
389+ }
390+ let block_col = self.toks[p].col;
391+ self.pos = p;
392+ if block_col <= min_col {
393+ return Err(ParseError::new(
394+ "let bindings must be indented",
395+ self.line(),
396+ ));
397+ }
398+ let mut bindings: Vec<(String, Rc<Expr>)> = Vec::new();
399+ let mut names: Vec<String> = Vec::new();
400+ loop {
401+ // binding: ident '=' expr, expr limited to block_col
402+ let name = match self.peek().clone() {
403+ Tok::Ident(n) => n,
404+ other => {
405+ return Err(ParseError::new(
406+ format!("expected a binding name, found {}", other.describe()),
407+ self.line(),
408+ ))
409+ }
410+ };
411+ self.check_shadow(&name)?;
412+ if names.iter().any(|n| *n == name) {
413+ return Err(ParseError::new(
414+ format!("`{}` is bound twice in this let", name),
415+ self.line(),
416+ ));
417+ }
418+ self.bump();
419+ self.expect(&Tok::Equals)?;
420+ let e = self.parse_expr(block_col)?;
421+ names.push(name.clone());
422+ bindings.push((name, Rc::new(e)));
423+ // separator: `;` or newline at exactly block_col, then `in` ends
424+ match self.peek().clone() {
425+ Tok::Semi => {
426+ self.bump();
427+ self.skip_layout_newlines(block_col - 1); // any continuation
428+ if matches!(self.peek(), Tok::In) {
429+ self.bump();
430+ break;
431+ }
432+ continue;
433+ }
434+ Tok::In => {
435+ self.bump();
436+ break;
437+ }
438+ Tok::Newline => {
439+ // find next real token
440+ let mut q = self.pos;
441+ while matches!(self.toks[q].tok, Tok::Newline) {
442+ q += 1;
443+ }
444+ let next_col = self.toks[q].col;
445+ if matches!(self.toks[q].tok, Tok::In) {
446+ self.pos = q;
447+ self.bump();
448+ break;
449+ }
450+ if next_col == block_col {
451+ self.pos = q;
452+ continue;
453+ }
454+ if next_col < block_col {
455+ // block ends without `in`? `in` must appear on an
456+ // outer line; treat as error unless next is In handled above
457+ if matches!(self.toks[q].tok, Tok::In) {
458+ self.pos = q;
459+ self.bump();
460+ break;
461+ }
462+ // The `in` could be at a smaller column: look for it
463+ return Err(ParseError::new(
464+ "expected `in` or another binding",
465+ self.toks[q].line,
466+ ));
467+ }
468+ // deeper indentation continues the expression; but the
469+ // expression parser should have consumed it. Error.
470+ return Err(ParseError::new(
471+ "unexpected indentation in let block",
472+ self.toks[q].line,
473+ ));
474+ }
475+ other => {
476+ return Err(ParseError::new(
477+ format!("expected `;`, newline, or `in`, found {}", other.describe()),
478+ self.line(),
479+ ))
480+ }
481+ }
482+ }
483+ let body = self.parse_expr(min_col)?;
484+ Ok(Expr::Let(bindings, Rc::new(body)))
485+ }
486+
487+ fn check_shadow(&self, name: &str) -> Result<(), ParseError> {
488+ if self.outer_names.contains(name) {
489+ return Err(ParseError::new(
490+ format!("`{}` is already bound and may not be shadowed", name),
491+ self.line(),
492+ ));
493+ }
494+ Ok(())
495+ }
496+
497+ fn parse_or(&mut self, min_col: usize) -> Result<Expr, ParseError> {
498+ // `or` is infixl 0 and extends as far right as possible
499+ let mut lhs = self.parse_opexpr(1, min_col)?;
500+ loop {
501+ self.skip_layout_newlines(min_col);
502+ if matches!(self.peek(), Tok::Or) {
503+ self.bump();
504+ let rhs = self.parse_or(min_col)?;
505+ lhs = Expr::Or(Rc::new(lhs), Rc::new(rhs));
506+ } else {
507+ break;
508+ }
509+ }
510+ Ok(lhs)
511+ }
512+
513+ /// precedence-climbing over the fixity table
514+ fn parse_opexpr(&mut self, min_prec: u8, min_col: usize) -> Result<Expr, ParseError> {
515+ let mut lhs = self.parse_app(min_col)?;
516+ loop {
517+ self.skip_op_newline(min_col);
518+ let op: &'static str = match self.peek() {
519+ Tok::Op(o) => o,
520+ _ => break,
521+ };
522+ let (prec, right) = match fixity(op) {
523+ Some(f) => f,
524+ None => break,
525+ };
526+ if prec < min_prec {
527+ break;
528+ }
529+ self.bump();
530+ // left section: `(e op)` — a trailing operator before `)`
531+ if matches!(self.peek(), Tok::RParen) {
532+ self.section_op = Some(op);
533+ return Ok(lhs);
534+ }
535+ let next_min = if right { prec } else { prec + 1 };
536+ let rhs = self.parse_opexpr(next_min, min_col)?;
537+ // non-assoc check: if lhs is the same operator, reject chaining
538+ if is_nonassoc(op) {
539+ // a non-associative operator cannot be chained; explicit
540+ // parentheses break the chain
541+ if let Expr::BinOp(o2, _, _) = &lhs {
542+ if is_nonassoc(o2) {
543+ return Err(ParseError::new(
544+ format!("`{}` is non-associative and cannot be chained", op),
545+ self.line(),
546+ ));
547+ }
548+ }
549+ if let Expr::BinOp(o2, _, _) = &rhs {
550+ if is_nonassoc(o2) && *o2 == op {
551+ return Err(ParseError::new(
552+ format!("`{}` is non-associative and cannot be chained", op),
553+ self.line(),
554+ ));
555+ }
556+ }
557+ }
558+ lhs = Expr::BinOp(op, Rc::new(lhs), Rc::new(rhs));
559+ }
560+ Ok(lhs)
561+ }
562+
563+ /// allow an operator at the start of a continuation line (column > min_col)
564+ fn skip_op_newline(&mut self, min_col: usize) {
565+ if !matches!(self.peek(), Tok::Newline) {
566+ return;
567+ }
568+ let mut p = self.pos;
569+ while matches!(self.toks[p].tok, Tok::Newline) {
570+ p += 1;
571+ }
572+ if matches!(self.toks[p].tok, Tok::Op(_)) && self.toks[p].col > min_col {
573+ self.pos = p;
574+ }
575+ }
576+
577+ fn starts_atom(t: &Tok) -> bool {
578+ matches!(
579+ t,
580+ Tok::Ident(_)
581+ | Tok::TypeName(_)
582+ | Tok::Int(_)
583+ | Tok::Text(_)
584+ | Tok::IdLit(_)
585+ | Tok::NewId
586+ | Tok::LabelLit(_)
587+ | Tok::PathLit(_)
588+ | Tok::True
589+ | Tok::False
590+ | Tok::LParen
591+ | Tok::LBracket
592+ | Tok::LBrace
593+ | Tok::Selector(_)
594+ )
595+ }
596+
597+ fn parse_app(&mut self, min_col: usize) -> Result<Expr, ParseError> {
598+ let mut e = self.parse_postfix(min_col)?;
599+ loop {
600+ // application across a newline requires deeper indentation
601+ if matches!(self.peek(), Tok::Newline) {
602+ let mut p = self.pos;
603+ while matches!(self.toks[p].tok, Tok::Newline) {
604+ p += 1;
605+ }
606+ if Self::starts_atom(&self.toks[p].tok) && self.toks[p].col > min_col {
607+ self.pos = p;
608+ } else {
609+ break;
610+ }
611+ }
612+ if Self::starts_atom(self.peek()) {
613+ let arg = self.parse_postfix(min_col)?;
614+ e = Expr::App(Rc::new(e), Rc::new(arg));
615+ } else {
616+ break;
617+ }
618+ }
619+ Ok(e)
620+ }
621+
622+ fn parse_postfix(&mut self, min_col: usize) -> Result<Expr, ParseError> {
623+ let mut e = self.parse_atom(min_col)?;
624+ loop {
625+ match self.peek().clone() {
626+ Tok::Selector(f) => {
627+ self.bump();
628+ e = Expr::Select(Rc::new(e), f);
629+ }
630+ Tok::LBrace => {
631+ // record update (or a record literal argument? no —
632+ // postfix binds tighter than application, so `{` after an
633+ // atom is an update)
634+ self.bump();
635+ let fields = self.parse_fields(min_col, true)?;
636+ e = Expr::Update(Rc::new(e), fields);
637+ }
638+ _ => break,
639+ }
640+ }
641+ Ok(e)
642+ }
643+
644+ /// parse `ident = expr, ...` inside braces. `update` controls the error message.
645+ fn parse_fields(
646+ &mut self,
647+ min_col: usize,
648+ _update: bool,
649+ ) -> Result<Vec<(String, Rc<Expr>)>, ParseError> {
650+ let mut fields: Vec<(String, Rc<Expr>)> = Vec::new();
651+ self.skip_layout_newlines(min_col);
652+ if matches!(self.peek(), Tok::RBrace) {
653+ self.bump();
654+ return Ok(fields);
655+ }
656+ loop {
657+ let name = match self.bump() {
658+ Tok::Ident(n) => n,
659+ other => {
660+ return Err(ParseError::new(
661+ format!("expected a field name, found {}", other.describe()),
662+ self.line(),
663+ ))
664+ }
665+ };
666+ if fields.iter().any(|(n, _)| *n == name) {
667+ return Err(ParseError::new(
668+ format!("duplicate field `{}`", name),
669+ self.line(),
670+ ));
671+ }
672+ self.expect(&Tok::Equals)?;
673+ let v = self.parse_expr(min_col)?;
674+ fields.push((name, Rc::new(v)));
675+ self.skip_layout_newlines(min_col);
676+ match self.peek() {
677+ Tok::Comma => {
678+ self.bump();
679+ self.skip_layout_newlines(min_col);
680+ }
681+ Tok::RBrace => {
682+ self.bump();
683+ break;
684+ }
685+ other => {
686+ return Err(ParseError::new(
687+ format!("expected `,` or `}}`, found {}", other.describe()),
688+ self.line(),
689+ ))
690+ }
691+ }
692+ }
693+ Ok(fields)
694+ }
695+
696+ fn parse_atom(&mut self, min_col: usize) -> Result<Expr, ParseError> {
697+ match self.peek().clone() {
698+ Tok::Ident(n) => {
699+ self.bump();
700+ Ok(Expr::Var(n))
701+ }
702+ Tok::TypeName(n) => {
703+ self.bump();
704+ Ok(Expr::TypeName(n))
705+ }
706+ Tok::Int(n) => {
707+ self.bump();
708+ Ok(Expr::Int(n))
709+ }
710+ Tok::Text(s) => {
711+ self.bump();
712+ Ok(Expr::Text(s))
713+ }
714+ Tok::IdLit(s) => {
715+ self.bump();
716+ Ok(Expr::IdLit(s))
717+ }
718+ Tok::NewId => {
719+ self.bump();
720+ Ok(Expr::NewId)
721+ }
722+ Tok::LabelLit(s) => {
723+ self.bump();
724+ Ok(Expr::LabelLit(s))
725+ }
726+ Tok::PathLit(p) => {
727+ self.bump();
728+ Ok(Expr::PathLit(p))
729+ }
730+ Tok::True => {
731+ self.bump();
732+ Ok(Expr::Bool(true))
733+ }
734+ Tok::False => {
735+ self.bump();
736+ Ok(Expr::Bool(false))
737+ }
738+ Tok::Selector(f) => {
739+ self.bump();
740+ Ok(Expr::SelectorFun(f))
741+ }
742+ Tok::LBracket => {
743+ self.bump();
744+ let mut elems = Vec::new();
745+ loop {
746+ self.skip_layout_newlines(min_col);
747+ if matches!(self.peek(), Tok::RBracket) {
748+ self.bump();
749+ break;
750+ }
751+ let e = self.parse_postfix(min_col)?;
752+ elems.push(Rc::new(e));
753+ }
754+ Ok(Expr::List(elems))
755+ }
756+ Tok::LBrace => {
757+ self.bump();
758+ let fields = self.parse_fields(min_col, false)?;
759+ Ok(Expr::Record(fields))
760+ }
761+ Tok::LParen => {
762+ self.bump();
763+ // (op) | (op expr) | (expr op) | (expr)
764+ if let Tok::Op(o) = self.peek().clone() {
765+ self.bump();
766+ self.skip_layout_newlines(min_col);
767+ if matches!(self.peek(), Tok::RParen) {
768+ self.bump();
769+ return Ok(Expr::Var(o.to_string()));
770+ }
771+ // right section: (op e) = \x -> x op e
772+ let e = self.parse_expr(min_col)?;
773+ self.expect(&Tok::RParen)?;
774+ let var = fresh_var();
775+ let body = Expr::BinOp(
776+ o,
777+ Rc::new(Expr::Var(var.clone())),
778+ Rc::new(e),
779+ );
780+ return Ok(Expr::Lambda(
781+ vec![Pattern::Var(var)],
782+ Rc::new(body),
783+ "section".into(),
784+ ));
785+ }
786+ let first = self.parse_expr(min_col)?;
787+ self.skip_layout_newlines(min_col);
788+ if let Some(o) = self.section_op.take() {
789+ // left section: (e op) = \x -> e op x
790+ self.expect(&Tok::RParen)?;
791+ let var = fresh_var();
792+ let body = Expr::BinOp(
793+ o,
794+ Rc::new(first),
795+ Rc::new(Expr::Var(var.clone())),
796+ );
797+ return Ok(Expr::Lambda(
798+ vec![Pattern::Var(var)],
799+ Rc::new(body),
800+ "section".into(),
801+ ));
802+ }
803+ self.expect(&Tok::RParen)?;
804+ Ok(Expr::Paren(Rc::new(first)))
805+ }
806+ other => Err(ParseError::new(
807+ format!("unexpected {}", other.describe()),
808+ self.line(),
809+ )),
810+ }
811+ }
812+
813+ fn source_slice(&self, _start: &SpTok) -> String {
814+ // source text of a lambda for display; we re-render from the AST.
815+ String::new()
816+ }
817+
818+ /// Parse a single command-line expression. Layout rule 1 does not apply.
819+ pub fn parse_cli_expr(&mut self) -> Result<Expr, ParseError> {
820+ self.skip_newlines();
821+ let e = self.parse_expr(0)?;
822+ self.skip_newlines();
823+ if !matches!(self.peek(), Tok::Eof) {
824+ return Err(ParseError::new(
825+ format!("unexpected {} after the expression", self.peek().describe()),
826+ self.line(),
827+ ));
828+ }
829+ Ok(e)
830+ }
831+}
832+
833+use std::sync::atomic::{AtomicUsize, Ordering};
834+static FRESH: AtomicUsize = AtomicUsize::new(0);
835+fn fresh_var() -> String {
836+ format!("$sec{}", FRESH.fetch_add(1, Ordering::Relaxed))
837+}
838+
839+pub fn parse_config(src: &str, outer_names: Rc<BTreeSet<String>>) -> Result<Vec<Item>, ParseError> {
840+ let mut p = Parser::new(src, outer_names)?;
841+ p.parse_config()
842+}
843+
844+pub fn parse_expr(src: &str, outer_names: Rc<BTreeSet<String>>) -> Result<Expr, ParseError> {
845+ let mut p = Parser::new(src, outer_names)?;
846+ p.parse_cli_expr()
847+}
848+
849+/// Re-render a lambda as source text (used for display of closures).
850+pub fn render_lambda(params: &[Pattern], body: &Expr) -> String {
851+ let ps: Vec<String> = params
852+ .iter()
853+ .map(|p| match p {
854+ Pattern::Var(n) => n.clone(),
855+ Pattern::Wildcard => "_".into(),
856+ })
857+ .collect();
858+ format!("\\{} -> {}", ps.join(" "), render_expr(body))
859+}
860+
861+pub fn render_expr(e: &Expr) -> String {
862+ match e {
863+ Expr::Var(n) => n.clone(),
864+ Expr::TypeName(n) => n.clone(),
865+ Expr::Int(n) => n.to_string(),
866+ Expr::Text(s) => format!("{:?}", s),
867+ Expr::IdLit(s) => format!("@{}", s),
868+ Expr::Id(s) => format!("@{}", s),
869+ Expr::NewId => "@".into(),
870+ Expr::LabelLit(s) => format!("%{}", s),
871+ Expr::PathLit(p) => {
872+ if p.is_empty() {
873+ "./".into()
874+ } else {
875+ format!("./{}", p.join("/"))
876+ }
877+ }
878+ Expr::Bool(b) => b.to_string(),
879+ Expr::Lambda(ps, b, _) => render_lambda(ps, b),
880+ Expr::If(c, t, f) => format!(
881+ "if {} then {} else {}",
882+ render_expr(c),
883+ render_expr(t),
884+ render_expr(f)
885+ ),
886+ Expr::Let(bs, b) => {
887+ let parts: Vec<String> = bs
888+ .iter()
889+ .map(|(n, e)| format!("{} = {}", n, render_expr(e)))
890+ .collect();
891+ format!("let {} in {}", parts.join("; "), render_expr(b))
892+ }
893+ Expr::App(f, x) => {
894+ let fs = match f.as_ref() {
895+ Expr::App(_, _) => format!("({})", render_expr(f)),
896+ Expr::Paren(_) => render_expr(f),
897+ _ => render_expr(f),
898+ };
899+ format!("{} ({})", fs, render_expr(x))
900+ }
901+ Expr::BinOp(o, a, b) => format!("({} {} {})", render_expr(a), o, render_expr(b)),
902+ Expr::Or(a, b) => format!("({} or {})", render_expr(a), render_expr(b)),
903+ Expr::Select(e, f) => format!("{}.{}", render_expr(e), f),
904+ Expr::Update(e, fs) => {
905+ let parts: Vec<String> = fs
906+ .iter()
907+ .map(|(n, v)| format!("{} = {}", n, render_expr(v)))
908+ .collect();
909+ format!("{} {{ {} }}", render_expr(e), parts.join(", "))
910+ }
911+ Expr::Record(fs) => {
912+ let parts: Vec<String> = fs
913+ .iter()
914+ .map(|(n, v)| format!("{} = {}", n, render_expr(v)))
915+ .collect();
916+ format!("{{ {} }}", parts.join(", "))
917+ }
918+ Expr::List(es) => {
919+ let parts: Vec<String> = es.iter().map(|e| render_expr(e)).collect();
920+ format!("[{}]", parts.join(" "))
921+ }
922+ Expr::SelectorFun(f) => format!(".{}", f),
923+ Expr::Paren(e) => render_expr(e),
924+ Expr::Crash => "crash".into(),
925+ }
926+}
927+
928+/// helper used by tests
929+pub fn int(n: i64) -> Expr {
930+ Expr::Int(BigInt::from(n))
931+}
src/render.rsnew file+1259
@@ -0,0 +1,1259 @@
1+//! Display (§5.1) and tree rendering (§7.11).
2+
3+use crate::domain::ROOT_ID;
4+use crate::eval::Interp;
5+use crate::value::{Crash, ShapeKind, Value};
6+use std::collections::{BTreeMap, BTreeSet};
7+use unicode_width::UnicodeWidthChar;
8+
9+// ----------------------------------------------------------------------
10+// colour
11+// ----------------------------------------------------------------------
12+
13+#[derive(Clone, Copy, PartialEq)]
14+pub struct Palette {
15+ pub on: bool,
16+}
17+
18+impl Palette {
19+ fn wrap(&self, code: &str, s: &str) -> String {
20+ if self.on {
21+ format!("\x1b[{}m{}\x1b[0m", code, s)
22+ } else {
23+ s.to_string()
24+ }
25+ }
26+ pub fn bold(&self, s: &str) -> String {
27+ self.wrap("1", s)
28+ }
29+ pub fn dim(&self, s: &str) -> String {
30+ self.wrap("2", s)
31+ }
32+ pub fn bold_bg(&self, s: &str) -> String {
33+ self.wrap("1;48;5;236", s)
34+ }
35+ pub fn dim_italic(&self, s: &str) -> String {
36+ self.wrap("2;3", s)
37+ }
38+ pub fn cyan(&self, s: &str) -> String {
39+ self.wrap("36", s)
40+ }
41+ pub fn red(&self, s: &str) -> String {
42+ self.wrap("31", s)
43+ }
44+ pub fn blue(&self, s: &str) -> String {
45+ self.wrap("34", s)
46+ }
47+ pub fn green(&self, s: &str) -> String {
48+ self.wrap("32", s)
49+ }
50+ pub fn yellow(&self, s: &str) -> String {
51+ self.wrap("33", s)
52+ }
53+ pub fn grey(&self, bright: u8, s: &str) -> String {
54+ // grey scale: brighter when more recent
55+ self.wrap(&format!("38;5;{}", 245 + bright.min(10)), s)
56+ }
57+ pub fn accent(&self, level: u8, s: &str) -> String {
58+ // size bar: one accent colour, brighter with size
59+ let codes = ["38;5;240", "38;5;36", "38;5;32", "38;5;39", "38;5;87"];
60+ self.wrap(codes[level.min(4) as usize], s)
61+ }
62+ pub fn author(&self, name: &str, s: &str) -> String {
63+ // stable hue per author
64+ let mut h: u32 = 5381;
65+ for b in name.bytes() {
66+ h = h.wrapping_mul(33).wrapping_add(b as u32);
67+ }
68+ let hue = (h % 6) as u8;
69+ let code = ["31", "32", "33", "34", "35", "36"][hue as usize];
70+ self.wrap(code, s)
71+ }
72+}
73+
74+pub fn color_enabled(mode: &str) -> bool {
75+ if std::env::var_os("NO_COLOR")
76+ .map(|v| !v.is_empty())
77+ .unwrap_or(false)
78+ {
79+ return false;
80+ }
81+ match mode {
82+ "always" => true,
83+ "never" => false,
84+ _ => crate::show::stdout_is_tty(),
85+ }
86+}
87+
88+// display width with East Asian wide = 2, combining = 0
89+pub fn width(s: &str) -> usize {
90+ let mut w = 0;
91+ let mut esc = false;
92+ for c in s.chars() {
93+ if esc {
94+ if c == 'm' {
95+ esc = false;
96+ }
97+ continue;
98+ }
99+ if c == '\x1b' {
100+ esc = true;
101+ continue;
102+ }
103+ w += UnicodeWidthChar::width(c).unwrap_or(0);
104+ }
105+ w
106+}
107+
108+fn pad_right(s: &str, w: usize) -> String {
109+ let d = width(s);
110+ if d >= w {
111+ s.to_string()
112+ } else {
113+ format!("{}{}", s, " ".repeat(w - d))
114+ }
115+}
116+
117+fn human_size(n: usize) -> String {
118+ if n < 1024 {
119+ format!("{} B", n)
120+ } else if n < 1024 * 1024 {
121+ format!("{:.1} KB", n as f64 / 1024.0)
122+ } else {
123+ format!("{:.1} MB", n as f64 / 1024.0 / 1024.0)
124+ }
125+}
126+
127+// ----------------------------------------------------------------------
128+// shape recognition (§4.12/§5.1)
129+// ----------------------------------------------------------------------
130+
131+fn field_set(v: &Value) -> Option<BTreeSet<String>> {
132+ match v {
133+ Value::Record(m) => Some(m.keys().cloned().collect()),
134+ _ => None,
135+ }
136+}
137+
138+fn shape_of_record(interp: &Interp, v: &Value) -> Option<String> {
139+ let fs = field_set(v)?;
140+ for (name, _) in &interp.shapes.decls {
141+ if let Some(s) = interp.shapes.shape_of(name) {
142+ if let ShapeKind::Record(want) = &s.kind {
143+ if want == &fs {
144+ return Some(name.clone());
145+ }
146+ }
147+ }
148+ }
149+ None
150+}
151+
152+pub fn is_commit(interp: &Interp, v: &Value) -> bool {
153+ shape_of_record(interp, v).as_deref() == Some("Commit")
154+}
155+
156+// ----------------------------------------------------------------------
157+// display
158+// ----------------------------------------------------------------------
159+
160+pub fn display(interp: &mut Interp, v: &Value, color: bool) -> Result<String, Crash> {
161+ let pal = Palette { on: color };
162+ let mut out = String::new();
163+ display_block(interp, v, &pal, 0, &mut out)?;
164+ if !out.ends_with('\n') {
165+ out.push('\n');
166+ }
167+ Ok(out)
168+}
169+
170+fn display_id(interp: &Interp, id: &str, pal: &Palette) -> String {
171+ let p = interp.backend.unique_prefix(id);
172+ let n = p.len();
173+ format!("{}{}", pal.bold(p.as_str()), pal.dim(&id[n..]))
174+}
175+
176+fn display_line(interp: &Interp, v: &Value, pal: &Palette) -> Result<String, Crash> {
177+ match v {
178+ Value::Int(n) => Ok(n.to_string()),
179+ Value::Bool(b) => Ok(b.to_string()),
180+ Value::Text(t) => {
181+ let first = t.lines().next().unwrap_or("");
182+ if t.lines().count() > 1 {
183+ Ok(format!("{}…", first))
184+ } else {
185+ Ok(first.to_string())
186+ }
187+ }
188+ Value::Id(id) => Ok(display_id(interp, id, pal)),
189+ Value::Blob(b) => {
190+ let size = human_size(b.size());
191+ if b.is_unresolved() {
192+ Ok(format!("‹✖ {}›", size))
193+ } else {
194+ Ok(format!("‹{}›", size))
195+ }
196+ }
197+ Value::Fun(f) => Ok(crate::show::show(&Interp::dummy(), &Value::Fun(f.clone()))),
198+ Value::Shape(s) => Ok(s.name.clone()),
199+ Value::Record(_) => {
200+ if let Some(shape) = shape_of_record(interp, v) {
201+ match shape.as_str() {
202+ "Commit" => return commit_line(interp, v, pal, false, &BTreeSet::new(), false),
203+ "Entry" => return entry_line(interp, v, pal),
204+ "Subtree" => {
205+ return commit_line(
206+ interp,
207+ &v.field("root")?,
208+ pal,
209+ false,
210+ &BTreeSet::new(),
211+ false,
212+ )
213+ }
214+ "Repo" => {
215+ return commit_line(
216+ interp,
217+ &v.field("root")?,
218+ pal,
219+ true,
220+ &BTreeSet::new(),
221+ false,
222+ )
223+ }
224+ "Frame" => {
225+ return commit_line(
226+ interp,
227+ &v.field("parent")?,
228+ pal,
229+ false,
230+ &BTreeSet::new(),
231+ false,
232+ )
233+ }
234+ "Change" => {
235+ let n = touched_paths(v)?.len();
236+ return Ok(format!("{} paths", n));
237+ }
238+ _ => {}
239+ }
240+ }
241+ let names: Vec<String> = field_set(v).unwrap().into_iter().collect();
242+ Ok(format!("{{ {} }}", names.join(", ")))
243+ }
244+ Value::List(xs) => list_line(interp, xs, pal),
245+ }
246+}
247+
248+fn list_line(_interp: &Interp, xs: &[Value], pal: &Palette) -> Result<String, Crash> {
249+ if xs.is_empty() {
250+ return Ok("0 items".into());
251+ }
252+ let all_id = xs.iter().all(|x| matches!(x, Value::Id(_)));
253+ if all_id {
254+ return Ok(format!("{} commits", xs.len()));
255+ }
256+ let all_path = xs.iter().all(|x| match x {
257+ Value::List(p) => p.iter().all(|c| matches!(c, Value::Text(_))),
258+ _ => false,
259+ });
260+ if all_path {
261+ return Ok(format!("{} paths", xs.len()));
262+ }
263+ let all_text = xs.iter().all(|x| matches!(x, Value::Text(_)));
264+ if all_text {
265+ return Ok(format!("{} items", xs.len()));
266+ }
267+ let all_record = xs.iter().all(|x| matches!(x, Value::Record(_)));
268+ if all_record {
269+ let first = field_set(&xs[0]).unwrap();
270+ let same = xs
271+ .iter()
272+ .all(|x| field_set(x).map(|s| s == first).unwrap_or(false));
273+ if same {
274+ return Ok(format!("{} rows", xs.len()));
275+ }
276+ }
277+ let all_scalar = xs.iter().all(|x| {
278+ matches!(x, Value::Int(_) | Value::Bool(_))
279+ });
280+ if all_scalar {
281+ let parts: Vec<String> = xs
282+ .iter()
283+ .map(|x| match x {
284+ Value::Int(n) => n.to_string(),
285+ Value::Bool(b) => b.to_string(),
286+ _ => unreachable!(),
287+ })
288+ .collect();
289+ return Ok(parts.join(", "));
290+ }
291+ let _ = pal;
292+ Ok(format!("{} items", xs.len()))
293+}
294+
295+fn entry_line(interp: &Interp, v: &Value, pal: &Palette) -> Result<String, Crash> {
296+ let path = path_string(&v.field("path")?)?;
297+ let content = v.field("content")?;
298+ let (size, unresolved) = match &content {
299+ Value::Blob(b) => (human_size(b.size()), b.is_unresolved()),
300+ _ => (String::new(), false),
301+ };
302+ let _ = interp;
303+ if unresolved {
304+ Ok(format!("{} {} {}", pal.red("✖"), path, size))
305+ } else {
306+ Ok(format!(" {} {}", path, size))
307+ }
308+}
309+
310+fn path_string(p: &Value) -> Result<String, Crash> {
311+ let comps = p.as_list()?;
312+ let parts: Vec<String> = comps
313+ .iter()
314+ .map(|c| c.as_text().map(|s| s.to_string()))
315+ .collect::<Result<_, _>>()?;
316+ Ok(parts.join("/"))
317+}
318+
319+fn touched_paths(change: &Value) -> Result<Vec<(String, char, bool)>, Crash> {
320+ // (path, mark, unresolved)
321+ let from = snapshot_map_of(&change.field("from")?)?;
322+ let to = snapshot_map_of(&change.field("to")?)?;
323+ let mut paths: BTreeSet<Vec<String>> = BTreeSet::new();
324+ paths.extend(from.keys().cloned());
325+ paths.extend(to.keys().cloned());
326+ let mut out = Vec::new();
327+ for p in paths {
328+ let f = from.get(&p);
329+ let t = to.get(&p);
330+ let mark = match (f, t) {
331+ (None, Some(_)) => '+',
332+ (Some(_), None) => '−',
333+ (Some(a), Some(b)) => {
334+ if crate::value::value_eq(a, b)? {
335+ continue;
336+ }
337+ '~'
338+ }
339+ (None, None) => continue,
340+ };
341+ let unresolved = t
342+ .map(|b| matches!(b, Value::Blob(bl) if bl.is_unresolved()))
343+ .unwrap_or(false);
344+ out.push((p.join("/"), if unresolved { '✖' } else { mark }, unresolved));
345+ }
346+ Ok(out)
347+}
348+
349+fn snapshot_map_of(snap: &Value) -> Result<BTreeMap<Vec<String>, Value>, Crash> {
350+ let mut m = BTreeMap::new();
351+ for e in snap.as_list()?.iter() {
352+ let path = e
353+ .field("path")?
354+ .as_list()?
355+ .iter()
356+ .map(|c| c.as_text().map(|s| s.to_string()))
357+ .collect::<Result<Vec<String>, Crash>>()?;
358+ m.insert(path, e.field("content")?);
359+ }
360+ Ok(m)
361+}
362+
363+fn commit_line(
364+ interp: &Interp,
365+ c: &Value,
366+ pal: &Palette,
367+ focus: bool,
368+ immutable: &BTreeSet<String>,
369+ has_conflicts_hint: bool,
370+) -> Result<String, Crash> {
371+ let id = match c.field("id")? {
372+ Value::Id(i) => i.to_string(),
373+ _ => return Err(Crash::new("commit without id")),
374+ };
375+ let msg = c.field("message")?.as_text()?.to_string();
376+ let msg_first = msg.lines().next().unwrap_or("").to_string();
377+ let labels: Vec<String> = c
378+ .field("labels")?
379+ .as_list()?
380+ .iter()
381+ .map(|l| l.as_text().map(|s| s.to_string()))
382+ .collect::<Result<_, _>>()?;
383+ let glyph = node_glyph(interp, c, &id, focus, immutable, pal)?;
384+ let id_s = display_id(interp, &id, pal);
385+ let labels_s = if labels.is_empty() {
386+ String::new()
387+ } else {
388+ pal.green(&labels.join(" "))
389+ };
390+ let _ = has_conflicts_hint;
391+ Ok(if labels_s.is_empty() {
392+ format!("{} {} {}", glyph, id_s, msg_first)
393+ } else {
394+ format!("{} {} {} {}", glyph, id_s, msg_first, labels_s)
395+ })
396+}
397+
398+fn node_glyph(
399+ interp: &Interp,
400+ c: &Value,
401+ id: &str,
402+ focus: bool,
403+ immutable: &BTreeSet<String>,
404+ pal: &Palette,
405+) -> Result<String, Crash> {
406+ let g = if id == ROOT_ID {
407+ "⌂"
408+ } else if has_conflict(c)? {
409+ "⊗"
410+ } else if is_empty_commit(interp, c)? {
411+ "◌"
412+ } else if immutable.contains(id) {
413+ "◆"
414+ } else if focus {
415+ "◉"
416+ } else {
417+ "○"
418+ };
419+ let _ = pal;
420+ Ok(g.to_string())
421+}
422+
423+fn has_conflict(c: &Value) -> Result<bool, Crash> {
424+ for e in c.field("files").and_then(|v| v.as_list().map(|x| x.to_vec()))?.iter() {
425+ if let Value::Blob(b) = e.field("content")? {
426+ if b.is_unresolved() {
427+ return Ok(true);
428+ }
429+ }
430+ }
431+ Ok(false)
432+}
433+
434+/// empty: no change against its parent — needs the parent; we only compute
435+/// this meaningfully in tree rendering. In line display, treat as false.
436+fn is_empty_commit(_interp: &Interp, _c: &Value) -> Result<bool, Crash> {
437+ Ok(false)
438+}
439+
440+fn display_block(
441+ interp: &mut Interp,
442+ v: &Value,
443+ pal: &Palette,
444+ indent: usize,
445+ out: &mut String,
446+) -> Result<(), Crash> {
447+ let pad = " ".repeat(indent);
448+ match v {
449+ Value::Text(t) => {
450+ out.push_str(t);
451+ return Ok(());
452+ }
453+ Value::Blob(b) => {
454+ let content = String::from_utf8_lossy(&b.bytes()).to_string();
455+ out.push_str(&indent_multiline(&content, indent));
456+ return Ok(());
457+ }
458+ Value::Int(_) | Value::Bool(_) | Value::Id(_) | Value::Fun(_) | Value::Shape(_) => {
459+ out.push_str(&pad);
460+ out.push_str(&display_line(interp, v, pal)?);
461+ out.push('\n');
462+ return Ok(());
463+ }
464+ Value::List(xs) => return display_list_block(interp, xs, pal, indent, out),
465+ Value::Record(_) => {}
466+ }
467+ if let Some(shape) = shape_of_record(interp, v) {
468+ match shape.as_str() {
469+ "Commit" => return display_commit_block(interp, v, pal, indent, out),
470+ "Entry" => {
471+ out.push_str(&pad);
472+ out.push_str(&entry_line(interp, v, pal)?);
473+ out.push('\n');
474+ return Ok(());
475+ }
476+ "Subtree" => {
477+ let opts = default_tree_options(interp)?;
478+ let repo = Value::record(&[
479+ ("root", v.field("root")?),
480+ ("children", v.field("children")?),
481+ ("context", Value::list(vec![])),
482+ ]);
483+ let text = tree_render(interp, &opts, &repo, pal, false)?;
484+ out.push_str(&indent_multiline(&text, indent));
485+ return Ok(());
486+ }
487+ "Repo" => {
488+ let tree_fn = interp
489+ .globals
490+ .lookup("tree")
491+ .ok_or_else(|| Crash::new("`tree` is not defined"))?;
492+ let text = interp.apply(tree_fn, v.clone())?;
493+ let text = text.as_text()?;
494+ out.push_str(&indent_multiline(text, indent));
495+ return Ok(());
496+ }
497+ "Frame" => {
498+ out.push_str(&pad);
499+ out.push_str(&commit_line(
500+ interp,
501+ &v.field("parent")?,
502+ pal,
503+ false,
504+ &BTreeSet::new(),
505+ false,
506+ )?);
507+ out.push('\n');
508+ return Ok(());
509+ }
510+ "Change" => {
511+ for (p, mark, unresolved) in touched_paths(v)? {
512+ out.push_str(&pad);
513+ let ms = mark.to_string();
514+ let colored = match mark {
515+ '+' => pal.green(&ms),
516+ '−' => pal.red(&ms),
517+ '~' => pal.yellow(&ms),
518+ '✖' => pal.red(&ms),
519+ _ => ms,
520+ };
521+ let _ = unresolved;
522+ out.push_str(&format!("{} {}\n", colored, p));
523+ }
524+ return Ok(());
525+ }
526+ _ => {}
527+ }
528+ }
529+ // generic record: one field per line
530+ let m = match v {
531+ Value::Record(m) => m,
532+ _ => unreachable!(),
533+ };
534+ let name_w = m.keys().map(|k| width(k)).max().unwrap_or(0);
535+ for (k, x) in m.iter() {
536+ out.push_str(&pad);
537+ out.push_str(&pal.dim(&pad_right(k, name_w)));
538+ out.push_str(" ");
539+ // lists print in block form under their key (§5.1)
540+ if matches!(x, Value::List(xs) if !xs.is_empty()) {
541+ out.push('\n');
542+ display_block(interp, x, pal, indent + name_w + 2, out)?;
543+ } else {
544+ let line = display_line(interp, x, pal)?;
545+ out.push_str(&line);
546+ out.push('\n');
547+ }
548+ }
549+ Ok(())
550+}
551+
552+fn indent_multiline(s: &str, indent: usize) -> String {
553+ if indent == 0 {
554+ return s.to_string();
555+ }
556+ let pad = " ".repeat(indent);
557+ s.lines()
558+ .map(|l| format!("{}{}", pad, l))
559+ .collect::<Vec<_>>()
560+ .join("\n")
561+ + "\n"
562+}
563+
564+fn display_commit_block(
565+ interp: &mut Interp,
566+ c: &Value,
567+ pal: &Palette,
568+ indent: usize,
569+ out: &mut String,
570+) -> Result<(), Crash> {
571+ let pad = " ".repeat(indent);
572+ out.push_str(&pad);
573+ out.push_str(&commit_line(interp, c, pal, true, &BTreeSet::new(), false)?);
574+ out.push('\n');
575+ // author · age · n files — from metadata by id (§5.1)
576+ let id = match c.field("id")? {
577+ Value::Id(i) => i.to_string(),
578+ _ => String::new(),
579+ };
580+ let files = c.field("files").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
581+ if let Ok(meta) = interp.backend.meta(&id) {
582+ let age = render_age(meta.time);
583+ out.push_str(&pad);
584+ out.push_str(&format!(
585+ " {} · {} · {} files\n",
586+ meta.author,
587+ age,
588+ files.len()
589+ ));
590+ }
591+ for e in files.iter() {
592+ let path = path_string(&e.field("path")?)?;
593+ let content = e.field("content")?;
594+ let unresolved = matches!(&content, Value::Blob(b) if b.is_unresolved());
595+ out.push_str(&pad);
596+ if unresolved {
597+ out.push_str(&format!(" {} {}\n", pal.red("✖"), path));
598+ } else {
599+ out.push_str(&format!(" {}\n", path));
600+ }
601+ }
602+ Ok(())
603+}
604+
605+pub fn render_age(time: i64) -> String {
606+ let now = std::time::SystemTime::now()
607+ .duration_since(std::time::UNIX_EPOCH)
608+ .map(|d| d.as_secs() as i64)
609+ .unwrap_or(0);
610+ let d = (now - time).max(0);
611+ if d < 60 {
612+ format!("{}s", d)
613+ } else if d < 3600 {
614+ format!("{}m", d / 60)
615+ } else if d < 86400 {
616+ format!("{}h", d / 3600)
617+ } else if d < 86400 * 7 {
618+ format!("{}d", d / 86400)
619+ } else if d < 86400 * 365 {
620+ format!("{}w", d / (86400 * 7))
621+ } else {
622+ format!("{}y", d / (86400 * 365))
623+ }
624+}
625+
626+/// the repo value in context (the loaded repo), for resolving ids to commits
627+fn top_of(interp: &Interp, _x: &Value) -> Result<Value, Crash> {
628+ let old = interp.old_repo.borrow().clone();
629+ old.ok_or_else(|| Crash::new("display: no repository in context"))
630+}
631+
632+fn display_list_block(
633+ interp: &mut Interp,
634+ xs: &[Value],
635+ pal: &Palette,
636+ indent: usize,
637+ out: &mut String,
638+) -> Result<(), Crash> {
639+ let pad = " ".repeat(indent);
640+ if xs.is_empty() {
641+ out.push_str(&pad);
642+ out.push_str(&pal.dim("none"));
643+ out.push('\n');
644+ return Ok(());
645+ }
646+ // list of Id: commit table (one commit line per id, in list order)
647+ if xs.iter().all(|x| matches!(x, Value::Id(_))) {
648+ for x in xs {
649+ let id = match x {
650+ Value::Id(i) => i.to_string(),
651+ _ => unreachable!(),
652+ };
653+ out.push_str(&pad);
654+ let repo = top_of(interp, x)?;
655+ match crate::repo::by_id(&repo, &id)? {
656+ Some(loc) => {
657+ let c = loc.field("root")?;
658+ let conflict = has_conflict(&c)?;
659+ let glyph = if conflict {
660+ pal.red("⊗")
661+ } else {
662+ "○".to_string()
663+ };
664+ let msg = c.field("message")?.as_text()?.to_string();
665+ let msg_first = msg.lines().next().unwrap_or("");
666+ let labels: Vec<String> = c
667+ .field("labels")?
668+ .as_list()?
669+ .iter()
670+ .map(|l| l.as_text().map(|s| s.to_string()))
671+ .collect::<Result<_, _>>()?;
672+ let id_s = if conflict {
673+ pal.red(&interp.backend.unique_prefix(&id))
674+ } else {
675+ display_id(interp, &id, pal)
676+ };
677+ if labels.is_empty() {
678+ out.push_str(&format!("{} {} {}\n", glyph, id_s, msg_first));
679+ } else {
680+ out.push_str(&format!(
681+ "{} {} {} {}\n",
682+ glyph,
683+ id_s,
684+ msg_first,
685+ pal.green(&labels.join(" "))
686+ ));
687+ }
688+ }
689+ None => {
690+ out.push_str(&display_id(interp, &id, pal));
691+ out.push('\n');
692+ }
693+ }
694+ }
695+ return Ok(());
696+ }
697+ // list of Text
698+ if xs.iter().all(|x| matches!(x, Value::Text(_))) {
699+ for x in xs {
700+ out.push_str(&pad);
701+ out.push_str(x.as_text()?);
702+ out.push('\n');
703+ }
704+ return Ok(());
705+ }
706+ // list of [Text]: paths
707+ let all_path = xs.iter().all(|x| match x {
708+ Value::List(p) => p.iter().all(|c| matches!(c, Value::Text(_))),
709+ _ => false,
710+ });
711+ if all_path {
712+ for x in xs {
713+ out.push_str(&pad);
714+ out.push_str(&path_string(x)?);
715+ out.push('\n');
716+ }
717+ return Ok(());
718+ }
719+ // list of records with identical field sets: Entry list gets entry lines,
720+ // other identical sets get a column table
721+ let all_record = xs.iter().all(|x| matches!(x, Value::Record(_)));
722+ if all_record {
723+ let first = field_set(&xs[0]).unwrap();
724+ let same = xs
725+ .iter()
726+ .all(|x| field_set(x).map(|s| s == first).unwrap_or(false));
727+ if same {
728+ if shape_of_record(interp, &xs[0]).as_deref() == Some("Entry") {
729+ let width_max = xs
730+ .iter()
731+ .map(|x| path_string(&x.field("path").unwrap()).map(|p| width(&p)))
732+ .collect::<Result<Vec<_>, _>>()?
733+ .into_iter()
734+ .max()
735+ .unwrap_or(0);
736+ for x in xs {
737+ let path = path_string(&x.field("path")?)?;
738+ let content = x.field("content")?;
739+ let (size, unresolved) = match &content {
740+ Value::Blob(b) => (human_size(b.size()), b.is_unresolved()),
741+ _ => (String::new(), false),
742+ };
743+ out.push_str(&pad);
744+ let mark = if unresolved { "✖" } else { " " };
745+ let size_s = if unresolved {
746+ pal.red(&size)
747+ } else {
748+ size
749+ };
750+ out.push_str(&format!("{} {:width$} {}\n", mark, path, size_s, width = width_max));
751+ }
752+ return Ok(());
753+ }
754+ return display_table(interp, xs, pal, indent, out);
755+ }
756+ }
757+ // list of other scalars
758+ let all_scalar = xs
759+ .iter()
760+ .all(|x| matches!(x, Value::Int(_) | Value::Bool(_)));
761+ if all_scalar {
762+ out.push_str(&pad);
763+ out.push_str(&list_line(interp, xs, pal)?);
764+ out.push('\n');
765+ return Ok(());
766+ }
767+ // one block per item, separated by a blank line
768+ for (i, x) in xs.iter().enumerate() {
769+ if i > 0 {
770+ out.push('\n');
771+ }
772+ display_block(interp, x, pal, indent, out)?;
773+ }
774+ Ok(())
775+}
776+
777+fn display_table(
778+ interp: &mut Interp,
779+ xs: &[Value],
780+ pal: &Palette,
781+ indent: usize,
782+ out: &mut String,
783+) -> Result<(), Crash> {
784+ let pad = " ".repeat(indent);
785+ let fields: Vec<String> = field_set(&xs[0]).unwrap().into_iter().collect();
786+ // cells
787+ let mut rows: Vec<Vec<String>> = Vec::new();
788+ for x in xs {
789+ let mut row = Vec::new();
790+ for f in &fields {
791+ let cell = match x.field(f)? {
792+ Value::Bool(true) => "✓".to_string(),
793+ Value::Bool(false) => String::new(),
794+ other => display_line(interp, &other, pal)?,
795+ };
796+ row.push(cell);
797+ }
798+ rows.push(row);
799+ }
800+ // drop columns empty in every row
801+ let keep: Vec<usize> = (0..fields.len())
802+ .filter(|&i| rows.iter().any(|r| !r[i].is_empty()))
803+ .collect();
804+ let widths: Vec<usize> = keep
805+ .iter()
806+ .map(|&i| {
807+ rows.iter()
808+ .map(|r| width(&r[i]))
809+ .max()
810+ .unwrap_or(0)
811+ .max(width(&fields[i]))
812+ })
813+ .collect();
814+ // header
815+ out.push_str(&pad);
816+ let header: Vec<String> = keep
817+ .iter()
818+ .zip(&widths)
819+ .map(|(&i, w)| pad_right(&fields[i], *w))
820+ .collect();
821+ out.push_str(&pal.dim(&header.join(" ")));
822+ out.push('\n');
823+ for r in &rows {
824+ out.push_str(&pad);
825+ let cells: Vec<String> = keep
826+ .iter()
827+ .zip(&widths)
828+ .map(|(&i, w)| pad_right(&r[i], *w))
829+ .collect();
830+ out.push_str(&cells.join(" "));
831+ out.push('\n');
832+ }
833+ Ok(())
834+}
835+
836+// ----------------------------------------------------------------------
837+// treeWith (§7.11)
838+// ----------------------------------------------------------------------
839+
840+pub struct TreeOptions {
841+ pub detail: i64,
842+ pub margin: bool,
843+ pub elide: bool,
844+ pub icons: bool,
845+ pub color: String,
846+}
847+
848+fn default_tree_options(_interp: &Interp) -> Result<TreeOptions, Crash> {
849+ Ok(TreeOptions {
850+ detail: 1,
851+ margin: false,
852+ elide: true,
853+ icons: false,
854+ color: "auto".into(),
855+ })
856+}
857+
858+pub fn tree_with(interp: &mut Interp, opts: &Value, repo: &Value) -> Result<Value, Crash> {
859+ let detail = match opts.field("detail")? {
860+ Value::Int(n) => n.to_string().parse::<i64>().unwrap_or(1),
861+ _ => return Err(Crash::new("treeWith: detail must be an Int")),
862+ };
863+ let margin = match opts.field("margin")? {
864+ Value::Bool(b) => b,
865+ _ => return Err(Crash::new("treeWith: margin must be a Bool")),
866+ };
867+ let elide = match opts.field("elide")? {
868+ Value::Bool(b) => b,
869+ _ => return Err(Crash::new("treeWith: elide must be a Bool")),
870+ };
871+ let icons = match opts.field("icons")? {
872+ Value::Bool(b) => b,
873+ _ => return Err(Crash::new("treeWith: icons must be a Bool")),
874+ };
875+ let color = opts.field("color")?.as_text()?.to_string();
876+ let pal = Palette {
877+ on: color_enabled(&color),
878+ };
879+ let o = TreeOptions {
880+ detail,
881+ margin,
882+ elide,
883+ icons,
884+ color,
885+ };
886+ let text = tree_render(interp, &o, repo, &pal, true)?;
887+ Ok(Value::text(text))
888+}
889+
890+struct CommitInfo {
891+ #[allow(dead_code)]
892+ commit: Value,
893+ id: String,
894+ message: String,
895+ labels: Vec<String>,
896+ conflict: bool,
897+ empty: bool,
898+ immutable: bool,
899+ is_focus: bool,
900+ is_ancestor_of_focus: bool,
901+ meta: Option<crate::domain::MetaInfo>,
902+ size: Option<usize>, // lines added+removed against parent
903+ detail_marks: Vec<(String, char)>,
904+ children: Vec<CommitInfo>,
905+}
906+
907+fn tree_render(
908+ interp: &mut Interp,
909+ opts: &TreeOptions,
910+ repo: &Value,
911+ pal: &Palette,
912+ with_focus: bool,
913+) -> Result<String, Crash> {
914+ // immutable set
915+ let immutable = crate::repo::compute_immutable(interp, repo).unwrap_or_default();
916+ let focus_id = match repo.field("root")?.field("id")? {
917+ Value::Id(i) => i.to_string(),
918+ _ => String::new(),
919+ };
920+ // ancestors of focus
921+ let mut anc: BTreeSet<String> = BTreeSet::new();
922+ let mut cur = repo.clone();
923+ loop {
924+ let id = match cur.field("root")?.field("id")? {
925+ Value::Id(i) => i.to_string(),
926+ _ => break,
927+ };
928+ anc.insert(id);
929+ let ctx = cur.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
930+ if ctx.is_empty() {
931+ break;
932+ }
933+ cur = crate::repo::by_id(&cur, &id_of_frame_parent(&ctx[0])?)?.unwrap();
934+ }
935+ // build info tree from the top
936+ let top = crate::repo::by_id(repo, &top_id(repo)?)?.unwrap_or_else(|| repo.clone());
937+ let root_commit = top.field("root")?;
938+ let top_children = top.field("children")?;
939+ let root_info = build_info(
940+ interp,
941+ opts,
942+ &top,
943+ &root_commit,
944+ top_children.as_list()?,
945+ &immutable,
946+ &focus_id,
947+ &anc,
948+ None,
949+ with_focus,
950+ )?;
951+ // layout
952+ let mut lines: Vec<String> = Vec::new();
953+ let label_col_needed = has_labels(&root_info);
954+ render_node(&root_info, opts, pal, "", true, true, label_col_needed, &mut lines);
955+ Ok(lines.join("\n") + "\n")
956+}
957+
958+fn id_of_frame_parent(frame: &Value) -> Result<String, Crash> {
959+ match frame.field("parent")?.field("id")? {
960+ Value::Id(i) => Ok(i.to_string()),
961+ v => Err(Crash::new(format!("expected an Id, got a {}", v.kind_name()))),
962+ }
963+}
964+
965+fn top_id(repo: &Value) -> Result<String, Crash> {
966+ let mut cur = repo.clone();
967+ loop {
968+ let ctx = cur.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
969+ if ctx.is_empty() {
970+ return match cur.field("root")?.field("id")? {
971+ Value::Id(i) => Ok(i.to_string()),
972+ v => Err(Crash::new(format!("expected an Id, got a {}", v.kind_name()))),
973+ };
974+ }
975+ let pid = id_of_frame_parent(&ctx[0])?;
976+ cur = crate::repo::by_id(&cur, &pid)?.unwrap();
977+ }
978+}
979+
980+#[allow(clippy::too_many_arguments)]
981+fn build_info(
982+ interp: &mut Interp,
983+ opts: &TreeOptions,
984+ loc: &Value,
985+ commit: &Value,
986+ children: &[Value],
987+ immutable: &BTreeSet<String>,
988+ focus_id: &str,
989+ anc: &BTreeSet<String>,
990+ parent_files: Option<&Value>,
991+ with_focus: bool,
992+) -> Result<CommitInfo, Crash> {
993+ let id = match commit.field("id")? {
994+ Value::Id(i) => i.to_string(),
995+ _ => String::new(),
996+ };
997+ let message = commit.field("message")?.as_text()?.to_string();
998+ let labels: Vec<String> = commit
999+ .field("labels")?
1000+ .as_list()?
1001+ .iter()
1002+ .map(|l| l.as_text().map(|s| s.to_string()))
1003+ .collect::<Result<_, _>>()?;
1004+ let conflict = has_conflict(commit)?;
1005+ let files = commit.field("files")?;
1006+ let (empty, size, detail_marks) = match parent_files {
1007+ Some(pf) => {
1008+ let change = Value::record(&[("from", pf.clone()), ("to", files.clone())]);
1009+ let marks = touched_paths(&change)?;
1010+ let empty = marks.is_empty();
1011+ let mut adds = 0usize;
1012+ // size: lines added+removed against the parent
1013+ for (_, m, _) in &marks {
1014+ match m {
1015+ '+' | '−' => adds += 1,
1016+ _ => adds += 1,
1017+ }
1018+ }
1019+ let marks2: Vec<(String, char)> =
1020+ marks.iter().map(|(p, m, _)| (p.clone(), *m)).collect();
1021+ (empty, Some(adds), marks2)
1022+ }
1023+ None => {
1024+ let n = files.as_list()?.len();
1025+ (n == 0 && id != ROOT_ID, None, Vec::new())
1026+ }
1027+ };
1028+ let meta = interp.backend.meta(&id).ok();
1029+ let is_focus = with_focus && id == focus_id;
1030+ let mut infos = Vec::new();
1031+ for c in children.iter() {
1032+ let child_loc = crate::repo::by_id(loc, &match c.field("root")?.field("id")? {
1033+ Value::Id(i) => i.to_string(),
1034+ _ => continue,
1035+ })?
1036+ .unwrap();
1037+ infos.push(build_info(
1038+ interp,
1039+ opts,
1040+ &child_loc,
1041+ &c.field("root")?,
1042+ c.field("children")?.as_list()?,
1043+ immutable,
1044+ focus_id,
1045+ anc,
1046+ Some(&files),
1047+ with_focus,
1048+ )?);
1049+ }
1050+ let immutable_flag = immutable.contains(&id);
1051+ let anc_flag = anc.contains(&id);
1052+ Ok(CommitInfo {
1053+ commit: commit.clone(),
1054+ id,
1055+ message,
1056+ labels,
1057+ conflict,
1058+ empty,
1059+ immutable: immutable_flag,
1060+ is_focus,
1061+ is_ancestor_of_focus: anc_flag,
1062+ meta,
1063+ size,
1064+ detail_marks,
1065+ children: infos,
1066+ })
1067+}
1068+
1069+fn has_labels(info: &CommitInfo) -> bool {
1070+ if !info.labels.is_empty() {
1071+ return true;
1072+ }
1073+ info.children.iter().any(has_labels)
1074+}
1075+
1076+fn glyph_for(info: &CommitInfo, icons: bool) -> &'static str {
1077+ if !icons {
1078+ if info.id == ROOT_ID {
1079+ "⌂"
1080+ } else if info.conflict {
1081+ "⊗"
1082+ } else if info.empty {
1083+ "◌"
1084+ } else if info.immutable {
1085+ "◆"
1086+ } else if info.is_focus {
1087+ "◉"
1088+ } else if info.is_ancestor_of_focus {
1089+ "●"
1090+ } else {
1091+ "○"
1092+ }
1093+ } else if info.id == ROOT_ID {
1094+ "🌱"
1095+ } else if info.conflict {
1096+ "🔥"
1097+ } else if info.empty {
1098+ "🫙"
1099+ } else if info.immutable {
1100+ "🪨"
1101+ } else if info.is_focus {
1102+ "🌸"
1103+ } else if info.is_ancestor_of_focus {
1104+ "🌿"
1105+ } else {
1106+ "🍃"
1107+ }
1108+}
1109+
1110+fn size_bar(size: Option<usize>, pal: &Palette) -> String {
1111+ match size {
1112+ None | Some(0) => String::new(),
1113+ Some(n) => {
1114+ let (bar, level) = if n >= 1000 {
1115+ ("▇", 4)
1116+ } else if n >= 200 {
1117+ ("▇", 3)
1118+ } else if n >= 50 {
1119+ ("▅", 2)
1120+ } else if n >= 10 {
1121+ ("▃", 1)
1122+ } else {
1123+ ("▂", 0)
1124+ };
1125+ pal.accent(level, bar)
1126+ }
1127+ }
1128+}
1129+
1130+fn initials(name: &str) -> String {
1131+ name.split_whitespace()
1132+ .filter_map(|w| w.chars().next())
1133+ .take(2)
1134+ .collect::<String>()
1135+ .to_lowercase()
1136+}
1137+
1138+#[allow(clippy::too_many_arguments)]
1139+fn render_node(
1140+ info: &CommitInfo,
1141+ opts: &TreeOptions,
1142+ pal: &Palette,
1143+ prefix: &str,
1144+ last: bool,
1145+ is_root: bool,
1146+ label_col: bool,
1147+ lines: &mut Vec<String>,
1148+) {
1149+ let gutter = if info.is_focus { "▶ " } else { " " };
1150+ let rail = if is_root {
1151+ ""
1152+ } else if last {
1153+ "└─ "
1154+ } else {
1155+ "├─ "
1156+ };
1157+ let glyph = glyph_for(info, opts.icons);
1158+ // id: shortest unique prefix, min 4 — needs backend; caller passes
1159+ // preformatted? We keep full id short form here via stored prefix.
1160+ let id_disp = info_display_id(info, pal);
1161+ let bar = if opts.detail >= 1 {
1162+ // focus and parent and children at detail 1; all at 2
1163+ if opts.detail >= 2 || info.is_focus {
1164+ size_bar(info.size, pal)
1165+ } else {
1166+ size_bar(info.size, pal)
1167+ }
1168+ } else {
1169+ String::new()
1170+ };
1171+ let msg_first = info.message.lines().next().unwrap_or("");
1172+ let msg = if info.empty {
1173+ pal.dim_italic(msg_first)
1174+ } else if info.is_focus {
1175+ msg_first.to_string()
1176+ } else if !info.is_ancestor_of_focus {
1177+ msg_first.to_string()
1178+ } else {
1179+ msg_first.to_string()
1180+ };
1181+ let mut line = format!("{}{}{} {} {}", gutter, prefix, rail, glyph, id_disp);
1182+ if !bar.is_empty() {
1183+ line.push_str(&format!(" {}", bar));
1184+ }
1185+ if !msg.is_empty() {
1186+ line.push_str(&format!(" {}", msg));
1187+ }
1188+ if label_col && !info.labels.is_empty() {
1189+ line.push_str(&format!(" {}", pal.green(&info.labels.join(" "))));
1190+ }
1191+ if opts.margin {
1192+ if let Some(meta) = &info.meta {
1193+ let age = render_age(meta.time);
1194+ let init = initials(&meta.author);
1195+ line.push_str(&format!(" {} {}", pal.grey(4, &age), pal.author(&meta.author, &init)));
1196+ }
1197+ }
1198+ lines.push(line);
1199+ // detail line (detail = 2, focus only)
1200+ if opts.detail >= 2 && info.is_focus && !info.detail_marks.is_empty() {
1201+ let cont = if is_root { " " } else if last { " " } else { "│ " };
1202+ let marks: Vec<String> = info
1203+ .detail_marks
1204+ .iter()
1205+ .map(|(p, m)| {
1206+ let ms = m.to_string();
1207+ let c = match m {
1208+ '+' => pal.green(&ms),
1209+ '−' => pal.red(&ms),
1210+ '~' => pal.yellow(&ms),
1211+ '✖' => pal.red(&ms),
1212+ _ => ms,
1213+ };
1214+ format!("{} {}", c, p)
1215+ })
1216+ .collect();
1217+ lines.push(format!(" {}{}{}", prefix, cont, marks.join(" ")));
1218+ }
1219+ let child_prefix = if is_root {
1220+ String::new()
1221+ } else if last {
1222+ format!("{} ", prefix)
1223+ } else {
1224+ format!("{}│ ", prefix)
1225+ };
1226+ let n = info.children.len();
1227+ for (i, c) in info.children.iter().enumerate() {
1228+ render_node(c, opts, pal, &child_prefix, i == n - 1, false, label_col, lines);
1229+ }
1230+}
1231+
1232+fn info_display_id(info: &CommitInfo, pal: &Palette) -> String {
1233+ // prefix is precomputed into the commit id string by the caller when a
1234+ // backend is available; here we use the first 4 chars as the unique part
1235+ let id = &info.id;
1236+ let n = 4.min(id.len());
1237+ let p = &id[..n];
1238+ let rest = &id[n..];
1239+ let _ = rest;
1240+ if info.conflict {
1241+ pal.red(p)
1242+ } else if info.immutable {
1243+ pal.blue(p)
1244+ } else {
1245+ p.to_string()
1246+ }
1247+}
1248+
1249+/// A reference to a commit that may or may not have stored metadata.
1250+/// Minted commits (dry runs) carry no metadata (§7.11).
1251+pub struct MetaLookup<'a> {
1252+ pub interp: &'a Interp,
1253+}
1254+
1255+impl<'a> MetaLookup<'a> {
1256+ pub fn get(&self, id: &str) -> Option<crate::domain::MetaInfo> {
1257+ self.interp.backend.meta(id).ok()
1258+ }
1259+}
src/repo.rsnew file+328
@@ -0,0 +1,328 @@
1+//! Repo-zipper operations, the reference `by` walk (§10), and the
2+//! persistence validation shared by `validate` and the jj backend (§7.5).
3+
4+use crate::domain::ROOT_ID;
5+use crate::eval::Interp;
6+use crate::value::{value_eq, Crash, Value};
7+use std::collections::{BTreeMap, BTreeSet};
8+
9+/// navigate: refocus `repo` on the commit with change id `id` (§10 reference).
10+pub fn by_id(repo: &Value, id: &str) -> Result<Option<Value>, Crash> {
11+ let top = top_of(repo)?;
12+ let mut stack = vec![top];
13+ while let Some(loc) = stack.pop() {
14+ let root_id = id_of(&loc.field("root")?)?;
15+ if root_id == id {
16+ return Ok(Some(loc));
17+ }
18+ let children = loc.field("children").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
19+ // push children as refocused locations; iterate in order
20+ for child in children.iter() {
21+ stack.push(refocus_child(&loc, child)?);
22+ }
23+ }
24+ Ok(None)
25+}
26+
27+fn id_of(commit: &Value) -> Result<String, Crash> {
28+ match commit.field("id")? {
29+ Value::Id(i) => Ok(i.to_string()),
30+ v => Err(Crash::new(format!("expected an Id, got a {}", v.kind_name()))),
31+ }
32+}
33+
34+/// move a location to the top of history
35+fn top_of(repo: &Value) -> Result<Value, Crash> {
36+ let mut cur = repo.clone();
37+ loop {
38+ let ctx = cur.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
39+ if ctx.is_empty() {
40+ return Ok(cur);
41+ }
42+ cur = up_of(&cur)?;
43+ }
44+}
45+
46+fn up_of(repo: &Value) -> Result<Value, Crash> {
47+ let ctx = repo.field("context")?;
48+ let ctx = ctx.as_list()?;
49+ let frame = ctx
50+ .first()
51+ .ok_or_else(|| Crash::new("up: at the top of history"))?;
52+ let parent = frame.field("parent")?;
53+ let left = frame.field("left")?;
54+ let left = left.as_list()?;
55+ let right = frame.field("right")?;
56+ let right = right.as_list()?;
57+ let mut children: Vec<Value> = left.to_vec();
58+ children.push(Value::record(&[
59+ ("root", repo.field("root")?),
60+ ("children", repo.field("children")?),
61+ ]));
62+ children.extend_from_slice(right);
63+ Ok(Value::record(&[
64+ ("children", Value::list(children)),
65+ ("context", Value::list(ctx[1..].to_vec())),
66+ ("root", parent),
67+ ]))
68+}
69+
70+fn refocus_child(repo: &Value, child: &Value) -> Result<Value, Crash> {
71+ let children = repo.field("children")?;
72+ let children = children.as_list()?;
73+ let mut left = Vec::new();
74+ let mut right = Vec::new();
75+ let mut found = false;
76+ for c in children.iter() {
77+ if !found && value_eq(c, child)? {
78+ found = true;
79+ } else if !found {
80+ left.push(c.clone());
81+ } else {
82+ right.push(c.clone());
83+ }
84+ }
85+ let frame = Value::record(&[
86+ ("left", Value::list(left)),
87+ ("parent", repo.field("root")?),
88+ ("right", Value::list(right)),
89+ ]);
90+ let mut ctx = repo.field("context")?.as_list()?.to_vec();
91+ ctx.insert(0, frame);
92+ Ok(Value::record(&[
93+ ("children", child.field("children")?),
94+ ("context", Value::list(ctx)),
95+ ("root", child.field("root")?),
96+ ]))
97+}
98+
99+// ----------------------------------------------------------------------
100+// tree traversal helpers over Repo values
101+// ----------------------------------------------------------------------
102+
103+/// all commits in the whole history (preorder from the top)
104+pub fn all_commits(repo: &Value) -> Result<Vec<Value>, Crash> {
105+ let top = top_of(repo)?;
106+ let mut out = Vec::new();
107+ let children = top.field("children")?;
108+ collect_commits(&top.field("root")?, children.as_list()?, &mut out)?;
109+ Ok(out)
110+}
111+
112+fn collect_commits(root: &Value, children: &[Value], out: &mut Vec<Value>) -> Result<(), Crash> {
113+ out.push(root.clone());
114+ for c in children {
115+ let gc = c.field("children")?;
116+ collect_commits(&c.field("root")?, gc.as_list()?, out)?;
117+ }
118+ Ok(())
119+}
120+
121+/// parent change id of every commit (first-parent tree), as (child_id, parent_id or "")
122+pub fn parent_map(repo: &Value) -> Result<Vec<(String, String)>, Crash> {
123+ let top = top_of(repo)?;
124+ let mut out = Vec::new();
125+ parent_map_rec(&top, &mut out)?;
126+ Ok(out)
127+}
128+
129+fn parent_map_rec(loc: &Value, out: &mut Vec<(String, String)>) -> Result<(), Crash> {
130+ let pid = id_of(&loc.field("root")?)?;
131+ let children = loc.field("children")?;
132+ for c in children.as_list()?.iter() {
133+ out.push((id_of(&c.field("root")?)?, pid.clone()));
134+ parent_map_rec(&refocus_child(loc, c)?, out)?;
135+ }
136+ Ok(())
137+}
138+
139+// ----------------------------------------------------------------------
140+// validation (§7.5 steps 1–3 and 6), shared by `validate` and persistence
141+// ----------------------------------------------------------------------
142+
143+#[derive(Debug)]
144+pub struct Validated {
145+ pub immutable: BTreeSet<String>,
146+}
147+
148+pub fn validate_repo(i: &mut Interp, new: &Value) -> Result<Validated, Crash> {
149+ // shape: new must be a Repo with a Commit root (§4.4)
150+ check_repo_shape(new)?;
151+ let mut seen = BTreeSet::new();
152+ validate_ids_and_snapshots(new, &mut seen)?;
153+ // immutability needs `old`; the builtin validate uses the repo loaded at
154+ // startup, carried in the interpreter by the CLI
155+ let old = i.old_repo.borrow().clone();
156+ let immutable = match &old {
157+ Some(old) => {
158+ validate_labels(old, new)?;
159+ compute_immutable(i, old)?
160+ }
161+ None => BTreeSet::new(),
162+ };
163+ if let Some(old) = &old {
164+ validate_immutable(old, new, &immutable)?;
165+ }
166+ // focus mutable (§7.5 step 6)
167+ let focus_id = id_of(&new.field("root")?)?;
168+ if focus_id == ROOT_ID || immutable.contains(&focus_id) {
169+ return Err(Crash::new(
170+ "the focus must be a mutable commit; compose with new",
171+ ));
172+ }
173+ Ok(Validated { immutable })
174+}
175+
176+fn check_repo_shape(v: &Value) -> Result<(), Crash> {
177+ let fields = v
178+ .field_set()
179+ .ok_or_else(|| Crash::new("persistence: the value is not a Repo"))?;
180+ let want: BTreeSet<String> = ["children", "context", "root"]
181+ .iter()
182+ .map(|s| s.to_string())
183+ .collect();
184+ if fields != want {
185+ return Err(Crash::new("persistence: the value is not a Repo"));
186+ }
187+ let root_fields = v
188+ .field("root")?
189+ .field_set()
190+ .ok_or_else(|| Crash::new("persistence: the root is not a Commit"))?;
191+ let want_root: BTreeSet<String> = ["files", "id", "labels", "message"]
192+ .iter()
193+ .map(|s| s.to_string())
194+ .collect();
195+ if root_fields != want_root {
196+ return Err(Crash::new("persistence: the root is not a Commit"));
197+ }
198+ Ok(())
199+}
200+
201+fn validate_ids_and_snapshots(repo: &Value, seen: &mut BTreeSet<String>) -> Result<(), Crash> {
202+ let top = top_of(repo)?;
203+ validate_tree(&top, seen)
204+}
205+
206+fn validate_tree(loc: &Value, seen: &mut BTreeSet<String>) -> Result<(), Crash> {
207+ let root = loc.field("root")?;
208+ let id = id_of(&root)?;
209+ if !seen.insert(id.clone()) {
210+ return Err(Crash::new(format!(
211+ "persistence: id {} occurs more than once",
212+ id
213+ )));
214+ }
215+ let files = root.field("files")?;
216+ let files = files.as_list()?;
217+ let mut paths = BTreeSet::new();
218+ for e in files {
219+ let p = e.field("path")?;
220+ let p = p.as_list()?;
221+ let key: Vec<String> = p
222+ .iter()
223+ .map(|c| c.as_text().map(|s| s.to_string()))
224+ .collect::<Result<_, _>>()?;
225+ if !paths.insert(key) {
226+ return Err(Crash::new("persistence: a snapshot has duplicate paths"));
227+ }
228+ }
229+ let children = loc.field("children")?;
230+ for c in children.as_list()?.iter() {
231+ validate_tree(&refocus_child(loc, c)?, seen)?;
232+ }
233+ Ok(())
234+}
235+
236+fn label_set(repo: &Value) -> Result<BTreeSet<(String, String)>, Crash> {
237+ let mut out = BTreeSet::new();
238+ for c in all_commits(repo)? {
239+ let id = id_of(&c)?;
240+ for l in c.field("labels").and_then(|v| v.as_list().map(|x| x.to_vec()))?.iter() {
241+ out.insert((id.clone(), l.as_text()?.to_string()));
242+ }
243+ }
244+ Ok(out)
245+}
246+
247+fn validate_labels(old: &Value, new: &Value) -> Result<(), Crash> {
248+ let a = label_set(old)?;
249+ let b = label_set(new)?;
250+ if a != b {
251+ return Err(Crash::new(
252+ "persistence: labels are read-only; the set of (id, label) pairs changed",
253+ ));
254+ }
255+ Ok(())
256+}
257+
258+/// The immutable set: the config's `immutable` revset against `old`, plus
259+/// every merge commit and its ancestors (§7.5 step 3).
260+pub fn compute_immutable(i: &mut Interp, old: &Value) -> Result<BTreeSet<String>, Crash> {
261+ let immutable_fn = i
262+ .globals
263+ .lookup("immutable")
264+ .ok_or_else(|| Crash::new("`immutable` is not defined"))?;
265+ let v = i.apply(immutable_fn, old.clone())?;
266+ let mut set = BTreeSet::new();
267+ for idv in v.as_list()?.iter() {
268+ match idv {
269+ Value::Id(id) => {
270+ set.insert(id.to_string());
271+ }
272+ _ => return Err(Crash::new("immutable: revset returned a non-Id")),
273+ }
274+ }
275+ // merge commits and their ancestors
276+ let mut merges = BTreeSet::new();
277+ for c in all_commits(old)? {
278+ let id = id_of(&c)?;
279+ if i.backend.is_merge(&id) {
280+ merges.insert(id);
281+ }
282+ }
283+ let closed = i.backend.ancestors_closed(&merges);
284+ set.extend(closed);
285+ Ok(set)
286+}
287+
288+fn validate_immutable(old: &Value, new: &Value, immutable: &BTreeSet<String>) -> Result<(), Crash> {
289+ if immutable.is_empty() {
290+ return Ok(());
291+ }
292+ let old_parents: BTreeMap<String, String> = parent_map(old)?.into_iter().collect();
293+ let new_parents: BTreeMap<String, String> = parent_map(new)?.into_iter().collect();
294+ let new_commits: BTreeMap<String, Value> = all_commits(new)?
295+ .into_iter()
296+ .map(|c| id_of(&c).map(|id| (id, c)))
297+ .collect::<Result<_, _>>()?;
298+ for c in all_commits(old)? {
299+ let id = id_of(&c)?;
300+ if !immutable.contains(&id) {
301+ continue;
302+ }
303+ let nc = new_commits.get(&id).ok_or_else(|| {
304+ Crash::new(format!("persistence: commit {} is immutable", id))
305+ })?;
306+ let same_parent = old_parents.get(&id).cloned().unwrap_or_default()
307+ == new_parents.get(&id).cloned().unwrap_or_default();
308+ if !same_parent {
309+ return Err(Crash::new(format!(
310+ "persistence: commit {} is immutable (parent changed)",
311+ id
312+ )));
313+ }
314+ if !value_eq(&c.field("files")?, &nc.field("files")?)? {
315+ return Err(Crash::new(format!(
316+ "persistence: commit {} is immutable (files changed)",
317+ id
318+ )));
319+ }
320+ if !value_eq(&c.field("message")?, &nc.field("message")?)? {
321+ return Err(Crash::new(format!(
322+ "persistence: commit {} is immutable (message changed)",
323+ id
324+ )));
325+ }
326+ }
327+ Ok(())
328+}
src/shape.rsnew file+418
@@ -0,0 +1,418 @@
1+//! Shape computation from typedecls (§4.12) and runtime contracts (§4.13).
2+
3+use crate::ast::TypeExpr;
4+use crate::value::{PrimKind, ShapeKind, ShapeVal, Value};
5+use std::collections::{BTreeSet, HashMap};
6+
7+#[derive(Default, Clone)]
8+pub struct Shapes {
9+ /// name -> its declared type
10+ pub decls: HashMap<String, TypeExpr>,
11+}
12+
13+impl Shapes {
14+ pub fn new() -> Shapes {
15+ Shapes {
16+ decls: HashMap::new(),
17+ }
18+ }
19+
20+ /// Compute the ShapeVal for a type name used as a value (§4.12).
21+ /// Returns None if the name is undeclared, or its shape is a function,
22+ /// a list, or otherwise not usable.
23+ pub fn shape_of(&self, name: &str) -> Option<ShapeVal> {
24+ match name {
25+ "Int" => Some(ShapeVal {
26+ name: name.into(),
27+ kind: ShapeKind::Prim(PrimKind::Int),
28+ }),
29+ "Text" => Some(ShapeVal {
30+ name: name.into(),
31+ kind: ShapeKind::Prim(PrimKind::Text),
32+ }),
33+ "Bool" => Some(ShapeVal {
34+ name: name.into(),
35+ kind: ShapeKind::Prim(PrimKind::Bool),
36+ }),
37+ "Id" => Some(ShapeVal {
38+ name: name.into(),
39+ kind: ShapeKind::Prim(PrimKind::Id),
40+ }),
41+ "Blob" => Some(ShapeVal {
42+ name: name.into(),
43+ kind: ShapeKind::Prim(PrimKind::Blob),
44+ }),
45+ "Shape" => None, // Shape itself is primitive but extract Shape is odd; crash
46+ _ => {
47+ let ty = self.decls.get(name)?;
48+ self.shape_of_type(ty).map(|kind| ShapeVal {
49+ name: name.into(),
50+ kind,
51+ })
52+ }
53+ }
54+ }
55+
56+ fn shape_of_type(&self, ty: &TypeExpr) -> Option<ShapeKind> {
57+ match ty {
58+ TypeExpr::Con(n) => {
59+ if is_primitive(n) {
60+ let s = self.shape_of(n)?;
61+ Some(s.kind)
62+ } else if let Some(decl) = self.decls.get(n) {
63+ // resolve the alias now that all decls are present
64+ self.shape_of_type(decl)
65+ } else {
66+ Some(ShapeKind::Aliased(n.clone()))
67+ }
68+ }
69+ TypeExpr::Record(fields) => Some(ShapeKind::Record(
70+ fields.iter().map(|(n, _)| n.clone()).collect(),
71+ )),
72+ TypeExpr::List(_) | TypeExpr::Fun(_, _) => None,
73+ }
74+ }
75+
76+ /// Unfold an alias chain, owned, so it works with partially-populated
77+ /// decl maps too.
78+ pub fn unfold_owned(&self, ty: &TypeExpr) -> TypeExpr {
79+ match ty {
80+ TypeExpr::Con(n) => {
81+ if is_primitive(n) || is_type_var(n) {
82+ return ty.clone();
83+ }
84+ match self.decls.get(n) {
85+ Some(decl) => self.unfold_owned(decl),
86+ None => ty.clone(),
87+ }
88+ }
89+ _ => ty.clone(),
90+ }
91+ }
92+}
93+
94+fn is_primitive(n: &str) -> bool {
95+ matches!(n, "Int" | "Text" | "Bool" | "Id" | "Blob" | "Shape")
96+}
97+
98+fn is_type_var(n: &str) -> bool {
99+ n.chars().next().map(|c| c.is_ascii_lowercase()).unwrap_or(false)
100+}
101+
102+/// A compiled signature: a chain of parameter types and a final result type.
103+#[derive(Clone, Debug)]
104+pub struct Contract {
105+ pub params: Vec<TypeExpr>,
106+ pub result: TypeExpr,
107+}
108+
109+pub fn compile_contract(shapes: &Shapes, ty: &TypeExpr) -> Contract {
110+ let mut params = Vec::new();
111+ let mut cur = ty;
112+ loop {
113+ match cur {
114+ TypeExpr::Fun(a, b) => {
115+ params.push((**a).clone());
116+ cur = b;
117+ }
118+ TypeExpr::Con(n) => {
119+ // alias of a function type is unfolded (§4.13)
120+ if !is_primitive(n) && !is_type_var(n) {
121+ if let Some(decl) = shapes.decls.get(n) {
122+ cur = decl;
123+ continue;
124+ }
125+ }
126+ break;
127+ }
128+ _ => break,
129+ }
130+ }
131+ Contract {
132+ params,
133+ result: cur.clone(),
134+ }
135+}
136+
137+/// Check one level deep (§4.13). On failure, describe expected and got.
138+pub fn check(shapes: &Shapes, ty: &TypeExpr, v: &Value) -> Result<(), String> {
139+ let owned = shapes.unfold_owned(ty);
140+ let ty = &owned;
141+ match ty {
142+ TypeExpr::Con(n) => {
143+ if is_type_var(n) {
144+ return Ok(());
145+ }
146+ match n.as_str() {
147+ "Int" => kind_check(matches!(v, Value::Int(_)), "Int", v),
148+ "Text" => kind_check(matches!(v, Value::Text(_)), "Text", v),
149+ "Bool" => kind_check(matches!(v, Value::Bool(_)), "Bool", v),
150+ "Id" => kind_check(matches!(v, Value::Id(_)), "Id", v),
151+ "Blob" => kind_check(matches!(v, Value::Blob(_)), "Blob", v),
152+ "Shape" => kind_check(matches!(v, Value::Shape(_)), "Shape", v),
153+ _ => {
154+ // shape name: exact field set (aliases resolved by shape_of)
155+ match shapes.shape_of(n) {
156+ Some(ShapeVal {
157+ kind: ShapeKind::Record(fields),
158+ ..
159+ }) => record_check(&fields, n, v),
160+ Some(ShapeVal {
161+ kind: ShapeKind::Prim(p),
162+ ..
163+ }) => {
164+ let ok = matches!(
165+ (p, v),
166+ (PrimKind::Int, Value::Int(_))
167+ | (PrimKind::Text, Value::Text(_))
168+ | (PrimKind::Bool, Value::Bool(_))
169+ | (PrimKind::Id, Value::Id(_))
170+ | (PrimKind::Blob, Value::Blob(_))
171+ );
172+ kind_check(ok, n, v)
173+ }
174+ _ => Ok(()),
175+ }
176+ }
177+ }
178+ }
179+ TypeExpr::List(_) => kind_check(matches!(v, Value::List(_)), "a list", v),
180+ TypeExpr::Record(fields) => {
181+ let set: BTreeSet<String> = fields.iter().map(|(n, _)| n.clone()).collect();
182+ record_check(&set, "record", v)
183+ }
184+ TypeExpr::Fun(_, _) => kind_check(matches!(v, Value::Fun(_)), "a function", v),
185+ }
186+}
187+
188+fn kind_check(ok: bool, expected: &str, v: &Value) -> Result<(), String> {
189+ if ok {
190+ Ok(())
191+ } else {
192+ Err(format!("expected {}, got {}", expected, v.kind_name()))
193+ }
194+}
195+
196+fn record_check(expected: &BTreeSet<String>, name: &str, v: &Value) -> Result<(), String> {
197+ match v {
198+ Value::Record(m) => {
199+ let got: BTreeSet<String> = m.keys().cloned().collect();
200+ if *expected == got {
201+ Ok(())
202+ } else {
203+ Err(format!(
204+ "expected {} {}, got {}",
205+ if name == "record" { "" } else { name },
206+ fmt_fields(expected),
207+ fmt_fields(&got)
208+ ))
209+ }
210+ }
211+ _ => Err(format!("expected {}, got {}", name, v.kind_name())),
212+ }
213+}
214+
215+pub fn fmt_fields(s: &BTreeSet<String>) -> String {
216+ format!("{{ {} }}", s.iter().cloned().collect::<Vec<_>>().join(", "))
217+}
218+
219+/// A human-readable name for a type, used in contract messages.
220+pub fn type_name(ty: &TypeExpr) -> String {
221+ match ty {
222+ TypeExpr::Con(n) => n.clone(),
223+ TypeExpr::List(t) => format!("[{}]", type_name(t)),
224+ TypeExpr::Record(fields) => {
225+ let parts: Vec<String> = fields
226+ .iter()
227+ .map(|(n, t)| format!("{} : {}", n, type_name(t)))
228+ .collect();
229+ format!("{{ {} }}", parts.join(", "))
230+ }
231+ TypeExpr::Fun(a, b) => format!("{} -> {}", type_name(a), type_name(b)),
232+ }
233+}
234+
235+// ----------------------------------------------------------------------
236+// contract expressions: the type of a (possibly partially applied) function
237+// ----------------------------------------------------------------------
238+
239+use std::rc::Rc as Rc2;
240+
241+#[derive(Clone, Debug)]
242+pub enum ContractExpr {
243+ /// no contract information; arg checks pass through
244+ Unknown,
245+ Known(Rc2<Contract>),
246+ /// compose(f, g): (f . g); both contract expressions
247+ Compose(Box<ContractExpr>, Box<ContractExpr>),
248+ /// apply e to an argument that checked against e's first parameter
249+ ApplyFirst(Box<ContractExpr>),
250+}
251+
252+impl ContractExpr {
253+ /// The type of the next parameter this function expects, if known.
254+ pub fn next_param(&self) -> Option<TypeExpr> {
255+ match self {
256+ ContractExpr::Unknown => None,
257+ ContractExpr::Known(c) => c.params.first().cloned(),
258+ ContractExpr::Compose(f, g) => g
259+ .next_param()
260+ .or_else(|| f.next_param())
261+ .or_else(|| f.result_after()),
262+ ContractExpr::ApplyFirst(e) => {
263+ let advanced = ContractExpr::apply_first(e.clone());
264+ advanced.next_param()
265+ }
266+
267+ }
268+ }
269+
270+ /// The result type once all parameters are supplied.
271+ pub fn result_after(&self) -> Option<TypeExpr> {
272+ match self {
273+ ContractExpr::Unknown => None,
274+ ContractExpr::Known(c) => {
275+ if c.params.is_empty() {
276+ Some(c.result.clone())
277+ } else {
278+ None
279+ }
280+ }
281+ ContractExpr::Compose(f, g) => {
282+ if g.is_exhausted() {
283+ f.result_after()
284+ } else {
285+ None
286+ }
287+ }
288+ ContractExpr::ApplyFirst(_) => None,
289+ }
290+ }
291+
292+ /// True when no parameters remain (value-level).
293+ pub fn is_exhausted(&self) -> bool {
294+ match self {
295+ ContractExpr::Unknown => false,
296+ ContractExpr::Known(c) => c.params.is_empty(),
297+ ContractExpr::Compose(_, g) => g.is_exhausted(),
298+ ContractExpr::ApplyFirst(_) => false,
299+ }
300+ }
301+
302+ /// Advance after one argument has been supplied and checked.
303+ pub fn apply_first_rc(self: Rc2<ContractExpr>) -> ContractExpr {
304+ ContractExpr::apply_first(Box::new((*self).clone()))
305+ }
306+
307+ /// Advance after one argument has been supplied and checked.
308+ pub fn apply_first(self: Box<ContractExpr>) -> ContractExpr {
309+ match *self {
310+ ContractExpr::Unknown => ContractExpr::Unknown,
311+ ContractExpr::Known(c) => {
312+ let mut c2 = (*c).clone();
313+ if !c2.params.is_empty() {
314+ c2.params.remove(0);
315+ }
316+ ContractExpr::Known(Rc2::new(c2))
317+ }
318+ ContractExpr::Compose(f, g) => {
319+ if !g.is_exhausted() {
320+ ContractExpr::Compose(f, Box::new(ContractExpr::apply_first(g)))
321+ } else {
322+ // x flowed into f
323+ ContractExpr::ApplyFirst(f)
324+ }
325+ }
326+ ContractExpr::ApplyFirst(e) => ContractExpr::ApplyFirst(Box::new(ContractExpr::apply_first(e))),
327+ }
328+ }
329+
330+ /// Number of remaining parameters, if known and not nested.
331+ pub fn params_len(&self) -> usize {
332+ match self {
333+ ContractExpr::Known(c) => c.params.len(),
334+ _ => 99,
335+ }
336+ }
337+
338+ /// True if the next parameter is definitely a function type.
339+ pub fn next_param_is_function(&self, shapes: &Shapes) -> bool {
340+ match self.next_param() {
341+ Some(t) => matches!(shapes.unfold_owned(&t), TypeExpr::Fun(_, _)),
342+ None => false,
343+ }
344+ }
345+
346+ /// Check an argument to this function. The outer ApplyFirst layer (if
347+ /// any) accounts for the argument currently being consumed.
348+ pub fn check_arg(&self, shapes: &Shapes, fname: &str, v: &Value) -> Result<(), Crash2> {
349+ self.check_arg_at(shapes, fname, 0, v)
350+ }
351+
352+ /// like check_arg, with the 0-based position of the argument for messages
353+ pub fn check_arg_at(
354+ &self,
355+ shapes: &Shapes,
356+ fname: &str,
357+ position: usize,
358+ v: &Value,
359+ ) -> Result<(), Crash2> {
360+ let peeled = match self {
361+ ContractExpr::ApplyFirst(e) => ContractExpr::apply_first(e.clone()),
362+ other => other.clone(),
363+ };
364+ if let Some(param) = peeled.next_param() {
365+ if let Err(msg) = check(shapes, &param, v) {
366+ // `check` reports "expected T, got V". Show the argument
367+ // position with the declared type, then the got-kind; when the
368+ // declared type is a type variable or alias whose inner
369+ // description adds information (e.g. "[a]" vs "a list"), the
370+ // inner "expected" is more helpful, so prefer it (§4.13).
371+ let (exp, got) = match msg.split_once(", got ") {
372+ Some((e, g)) => (
373+ e.strip_prefix("expected ").unwrap_or(e).to_string(),
374+ g.to_string(),
375+ ),
376+ None => (type_name(&param), v.kind_name().to_string()),
377+ };
378+ let declared = type_name(&param);
379+ let shown = if declared == exp { declared } else { format!("{} ({})", declared, exp) };
380+ return Err(Crash2::new(format!(
381+ "contract: {} expected {} as argument {}, got {}",
382+ fname,
383+ shown,
384+ position + 1,
385+ got
386+ )));
387+ }
388+ }
389+ Ok(())
390+ }
391+
392+ /// Check a result. Only called when this expression is exhausted.
393+ pub fn check_result(&self, shapes: &Shapes, fname: &str, v: &Value) -> Result<(), Crash2> {
394+ if !self.is_exhausted() {
395+ // more parameters remain: the result must be a function; further
396+ // checking happens when it is applied
397+ if !matches!(v, Value::Fun(_)) {
398+ return Err(Crash2::new(format!(
399+ "contract: {} expected a function, got {}",
400+ fname,
401+ v.kind_name()
402+ )));
403+ }
404+ return Ok(());
405+ }
406+ if let Some(res) = self.result_after() {
407+ if let Err(msg) = check(shapes, &res, v) {
408+ return Err(Crash2::new(format!(
409+ "contract: {}: result {}",
410+ fname, msg
411+ )));
412+ }
413+ }
414+ Ok(())
415+ }
416+}
417+
418+pub type Crash2 = crate::value::Crash;
src/show.rsnew file+325
@@ -0,0 +1,325 @@
1+//! `show` (§5.2), unified diff (§4.9 `diff`), and the difftastic bridge (§7.10).
2+
3+use crate::eval::Interp;
4+use crate::value::{BlobContent, Crash, FunVal, Value};
5+
6+pub fn show(interp: &Interp, v: &Value) -> String {
7+ let s = render(interp, v);
8+ // line breaking: one line if it fits in 80 columns
9+ if s.chars().count() <= 80 {
10+ return s;
11+ }
12+ render_wide(interp, v, 0)
13+}
14+
15+fn render(interp: &Interp, v: &Value) -> String {
16+ match v {
17+ Value::Int(n) => {
18+ if n.sign() == num_bigint::Sign::Minus {
19+ format!("(0 - {})", n.magnitude())
20+ } else {
21+ n.to_string()
22+ }
23+ }
24+ Value::Text(t) => text_literal(t),
25+ Value::Bool(b) => b.to_string(),
26+ Value::Id(id) => format!("@{}", id),
27+ Value::List(xs) => {
28+ // list elements must be atoms: parenthesise applications and
29+ // operator expressions (§3.4)
30+ let parts: Vec<String> = xs.iter().map(|x| render_atom(interp, x)).collect();
31+ format!("[{}]", parts.join(" "))
32+ }
33+ Value::Record(m) => {
34+ let parts: Vec<String> = m
35+ .iter()
36+ .map(|(k, x)| format!("{} = {}", k, render(interp, x)))
37+ .collect();
38+ format!("{{ {} }}", parts.join(", "))
39+ }
40+ Value::Blob(b) => match &b.content {
41+ BlobContent::Resolved(bytes) => match String::from_utf8(bytes.as_ref().clone()) {
42+ Ok(s) => format!("blob {}", text_literal(&s)),
43+ Err(_) => "blob \"<binary>\"".into(),
44+ },
45+ BlobContent::Conflict(_) => {
46+ let s = String::from_utf8_lossy(&b.bytes()).to_string();
47+ format!("{{- unresolved -}} blob {}", text_literal(&s))
48+ }
49+ },
50+ Value::Shape(s) => s.name.clone(),
51+ Value::Fun(f) => render_fun(interp, f),
52+ }
53+}
54+
55+fn render_fun(interp: &Interp, f: &FunVal) -> String {
56+ match f {
57+ FunVal::Builtin { name, args, .. } => {
58+ // operator builtins render parenthesised when bare
59+ if args.is_empty() {
60+ if is_operator_name(name) {
61+ format!("({})", name)
62+ } else if name.starts_with('.') && name.len() > 1 {
63+ name.clone() // selector .name
64+ } else if name == "(.)" {
65+ "(.)".into()
66+ } else {
67+ name.clone()
68+ }
69+ } else {
70+ // partial application: name followed by applied args
71+ let base = if is_operator_name(name) {
72+ format!("({})", name)
73+ } else if name == "(.)" {
74+ "(.)".into()
75+ } else {
76+ name.clone()
77+ };
78+ // args may contain a baked-in value (compose, selectors)
79+ match name.as_str() {
80+ "(.)" if args.len() == 2 => {
81+ format!(
82+ "(.) ({}) ({})",
83+ render_atom(interp, &args[0]),
84+ render_atom(interp, &args[1])
85+ )
86+ }
87+ _ => {
88+ let shown: Vec<String> =
89+ args.iter().map(|a| render_atom(interp, a)).collect();
90+ format!("{} {}", base, shown.join(" "))
91+ }
92+ }
93+ }
94+ }
95+ FunVal::Closure {
96+ name: Some(n),
97+ applied_args,
98+ ..
99+ } => {
100+ if applied_args.is_empty() {
101+ n.clone()
102+ } else {
103+ // partial application of a named definition: the name followed
104+ // by the arguments supplied so far (§5.2)
105+ let args: Vec<String> =
106+ applied_args.iter().map(|a| render_atom(interp, a)).collect();
107+ format!("{} {}", n, args.join(" "))
108+ }
109+ }
110+ FunVal::Closure {
111+ name: None,
112+ applied,
113+ params,
114+ body,
115+ src,
116+ ..
117+ } => {
118+ if *applied == 0 {
119+ if src.is_empty() || src == "section" {
120+ crate::parse::render_lambda(params, body)
121+ } else {
122+ src.clone()
123+ }
124+ } else {
125+ let base = if src.is_empty() || src == "section" {
126+ crate::parse::render_lambda(params, body)
127+ } else {
128+ src.clone()
129+ };
130+ format!("({}) <applied>", base)
131+ }
132+ }
133+ FunVal::OrFun(a, b) => format!("({} or {})", render_atom(interp, a), render_atom(interp, b)),
134+ FunVal::Labelled(n, _) => format!("%{}", n),
135+ FunVal::ComposeLazy(f, g) => format!("({} . {})", render_atom(interp, f), render_atom(interp, g)),
136+ }
137+}
138+
139+fn render_atom(interp: &Interp, v: &Value) -> String {
140+ // parenthesise if not atomic; a nested list is self-delimiting (a `[`
141+ // cannot begin a postfix), but a record is not: `{ … }` following another
142+ // element would parse as a record *update* on it (§3.4), so records keep
143+ // their parens
144+ let atomic = matches!(
145+ v,
146+ Value::Int(_) | Value::Text(_) | Value::Bool(_) | Value::Id(_) | Value::Shape(_) | Value::List(_)
147+ );
148+ let s = render(interp, v);
149+ if atomic {
150+ s
151+ } else {
152+ format!("({})", s)
153+ }
154+}
155+
156+fn is_operator_name(n: &str) -> bool {
157+ matches!(
158+ n,
159+ "." | "*"
160+ | "+"
161+ | "-"
162+ | "++"
163+ | "::"
164+ | "=="
165+ | "/="
166+ | "<"
167+ | "<="
168+ | ">"
169+ | ">="
170+ | "&&"
171+ | "||"
172+ )
173+}
174+
175+pub fn text_literal(s: &str) -> String {
176+ let mut out = String::from("\"");
177+ for c in s.chars() {
178+ match c {
179+ '"' => out.push_str("\\\""),
180+ '\\' => out.push_str("\\\\"),
181+ '\n' => out.push_str("\\n"),
182+ '\t' => out.push_str("\\t"),
183+ '\r' => out.push_str("\\r"),
184+ _ => out.push(c),
185+ }
186+ }
187+ out.push('"');
188+ out
189+}
190+
191+fn render_wide(interp: &Interp, v: &Value, indent: usize) -> String {
192+ match v {
193+ Value::List(xs) if !xs.is_empty() => {
194+ let mut out = String::from("[");
195+ for (i, x) in xs.iter().enumerate() {
196+ if i > 0 {
197+ out.push('\n');
198+ out.push_str(&" ".repeat(indent + 2));
199+ }
200+ // list elements must be atoms (§3.4)
201+ let s = render_wide(interp, x, indent + 2);
202+ if matches!(
203+ x,
204+ Value::Int(_)
205+ | Value::Text(_)
206+ | Value::Bool(_)
207+ | Value::Id(_)
208+ | Value::Shape(_)
209+ | Value::List(_)
210+ | Value::Record(_)
211+ ) {
212+ out.push_str(&s);
213+ } else {
214+ out.push_str(&format!("({})", s));
215+ }
216+ }
217+ out.push(']');
218+ out
219+ }
220+ Value::Record(m) if !m.is_empty() => {
221+ let mut out = String::from("{ ");
222+ for (i, (k, x)) in m.iter().enumerate() {
223+ if i > 0 {
224+ out.push('\n');
225+ out.push_str(&" ".repeat(indent));
226+ out.push_str(", ");
227+ }
228+ out.push_str(&format!("{} = {}", k, render_wide(interp, x, indent + 2)));
229+ }
230+ out.push_str(" }");
231+ out
232+ }
233+ _ => render(interp, v),
234+ }
235+}
236+
237+// ----------------------------------------------------------------------
238+// unified diff, 3 lines of context, no headers (§4.9 `diff`)
239+// ----------------------------------------------------------------------
240+
241+pub fn unified_diff(a: &str, b: &str) -> String {
242+ let diff = similar::TextDiff::from_lines(a, b);
243+ let mut out = String::new();
244+ for hunk in diff.unified_diff().context_radius(3).iter_hunks() {
245+ out.push_str(&format!("{}\n", hunk.header()));
246+ for change in hunk.iter_changes() {
247+ let sign = match change.tag() {
248+ similar::ChangeTag::Delete => "-",
249+ similar::ChangeTag::Insert => "+",
250+ similar::ChangeTag::Equal => " ",
251+ };
252+ out.push_str(sign);
253+ let text = change.to_string();
254+ // similar yields the line with its terminator when present; the
255+ // last line of a file without a trailing newline has none, so add
256+ // one to keep the diff line-oriented
257+ out.push_str(&text);
258+ if !text.ends_with('\n') {
259+ out.push('\n');
260+ }
261+ }
262+ }
263+ out
264+}
265+
266+// ----------------------------------------------------------------------
267+// difftastic (§7.10)
268+// ----------------------------------------------------------------------
269+
270+pub fn difft(path: &Value, a: &Value, b: &Value) -> Result<Value, Crash> {
271+ let comps = path.as_list()?;
272+ let last = comps
273+ .last()
274+ .map(|c| c.as_text().map(|s| s.to_string()))
275+ .transpose()?
276+ .unwrap_or_else(|| "file".to_string());
277+ let a_bytes = match a {
278+ Value::Blob(bl) => bl.bytes(),
279+ v => return Err(Crash::new(format!("difft: expected a Blob, got a {}", v.kind_name()))),
280+ };
281+ let b_bytes = match b {
282+ Value::Blob(bl) => bl.bytes(),
283+ v => return Err(Crash::new(format!("difft: expected a Blob, got a {}", v.kind_name()))),
284+ };
285+ let dir = tempfile::tempdir()
286+ .map_err(|e| Crash::new(format!("difft: cannot create temp dir: {}", e)))?;
287+ let old = dir.path().join(format!("old-{}", last));
288+ let new = dir.path().join(format!("new-{}", last));
289+ std::fs::write(&old, &a_bytes).map_err(|e| Crash::new(format!("difft: {}", e)))?;
290+ std::fs::write(&new, &b_bytes).map_err(|e| Crash::new(format!("difft: {}", e)))?;
291+ let mut cmd = std::process::Command::new("difft");
292+ cmd.arg(&old).arg(&new);
293+ cmd.stdin(std::process::Stdio::null());
294+ if std::env::var_os("DFT_COLOR").is_none() && stdout_is_tty() {
295+ cmd.env("DFT_COLOR", "always");
296+ }
297+ if std::env::var_os("DFT_WIDTH").is_none() && stdout_is_tty() {
298+ if let Some(w) = terminal_width() {
299+ cmd.env("DFT_WIDTH", w.to_string());
300+ }
301+ }
302+ let out = cmd
303+ .output()
304+ .map_err(|_| Crash::new("difft: cannot execute `difft` (not on PATH)"))?;
305+ Ok(Value::text(String::from_utf8_lossy(&out.stdout).to_string()))
306+}
307+
308+pub fn stdout_is_tty() -> bool {
309+ unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 }
310+}
311+
312+pub fn stdin_is_tty() -> bool {
313+ unsafe { libc::isatty(libc::STDIN_FILENO) == 1 }
314+}
315+
316+pub fn terminal_width() -> Option<usize> {
317+ unsafe {
318+ let mut ws: libc::winsize = std::mem::zeroed();
319+ if libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut ws) == 0 && ws.ws_col > 0 {
320+ Some(ws.ws_col as usize)
321+ } else {
322+ None
323+ }
324+ }
325+}
src/value.rsnew file+518
@@ -0,0 +1,518 @@
1+//! Values of the j language (§2).
2+
3+use crate::ast::{Expr, Pattern};
4+use num_bigint::BigInt;
5+use std::collections::{BTreeMap, BTreeSet, HashMap};
6+use std::rc::Rc;
7+
8+pub type RecordMap = BTreeMap<String, Value>;
9+
10+#[derive(Clone)]
11+pub enum Value {
12+ Int(BigInt),
13+ Text(Rc<String>),
14+ Bool(bool),
15+ List(Rc<Vec<Value>>),
16+ Record(Rc<RecordMap>),
17+ Fun(Rc<FunVal>),
18+ Id(Rc<String>),
19+ Blob(Rc<BlobVal>),
20+ Shape(Rc<ShapeVal>),
21+}
22+
23+#[derive(Clone, Debug, PartialEq, Eq)]
24+pub enum BlobKind {
25+ Regular,
26+ Executable,
27+ Symlink,
28+}
29+
30+#[derive(Clone, Debug)]
31+pub enum BlobContent {
32+ Resolved(Rc<Vec<u8>>),
33+ /// Conflict sides, jj order: alternating adds and removes, starting and
34+ /// ending with an add: [add, (remove, add)*]. For the in-memory backend:
35+ /// [to, from, onto]-style three sides as [add, remove, add].
36+ Conflict(Vec<Rc<Vec<u8>>>),
37+}
38+
39+#[derive(Clone, Debug)]
40+pub struct BlobVal {
41+ pub kind: BlobKind,
42+ pub content: BlobContent,
43+}
44+
45+impl BlobVal {
46+ pub fn text_blob(s: &str) -> Value {
47+ Value::Blob(Rc::new(BlobVal {
48+ kind: BlobKind::Regular,
49+ content: BlobContent::Resolved(Rc::new(s.as_bytes().to_vec())),
50+ }))
51+ }
52+ pub fn is_unresolved(&self) -> bool {
53+ matches!(self.content, BlobContent::Conflict(_))
54+ }
55+ pub fn size(&self) -> usize {
56+ match &self.content {
57+ BlobContent::Resolved(b) => b.len(),
58+ BlobContent::Conflict(sides) => sides.iter().map(|s| s.len()).sum(),
59+ }
60+ }
61+ /// content as bytes; conflicts render with jj-style conflict markers
62+ pub fn bytes(&self) -> Vec<u8> {
63+ match &self.content {
64+ BlobContent::Resolved(b) => b.as_ref().clone(),
65+ BlobContent::Conflict(sides) => render_conflict(sides),
66+ }
67+ }
68+}
69+
70+/// Render a conflict with jj-style markers.
71+pub fn render_conflict(sides: &[Rc<Vec<u8>>]) -> Vec<u8> {
72+ // sides: [add0, remove0, add1, remove1, ... addN]
73+ let mut out = Vec::new();
74+ out.extend_from_slice(b"<<<<<<<\n");
75+ let mut i = 0;
76+ let mut first_add = true;
77+ while i < sides.len() {
78+ if i % 2 == 0 {
79+ if !first_add {
80+ // another add after removes: separate
81+ }
82+ out.extend_from_slice(b"+++++++\n");
83+ out.extend_from_slice(&sides[i]);
84+ ensure_newline(&mut out);
85+ first_add = false;
86+ } else {
87+ out.extend_from_slice(b"%%%%%%%\n");
88+ out.extend_from_slice(&sides[i]);
89+ ensure_newline(&mut out);
90+ }
91+ i += 1;
92+ }
93+ out.extend_from_slice(b">>>>>>>\n");
94+ out
95+}
96+
97+fn ensure_newline(v: &mut Vec<u8>) {
98+ if !v.is_empty() && !v.ends_with(b"\n") {
99+ v.push(b'\n');
100+ }
101+}
102+
103+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
104+pub enum PrimKind {
105+ Int,
106+ Text,
107+ Bool,
108+ Id,
109+ Blob,
110+}
111+
112+impl PrimKind {
113+ pub fn name(&self) -> &'static str {
114+ match self {
115+ PrimKind::Int => "Int",
116+ PrimKind::Text => "Text",
117+ PrimKind::Bool => "Bool",
118+ PrimKind::Id => "Id",
119+ PrimKind::Blob => "Blob",
120+ }
121+ }
122+}
123+
124+#[derive(Clone, PartialEq, Eq, Debug)]
125+pub enum ShapeKind {
126+ Record(BTreeSet<String>),
127+ Prim(PrimKind),
128+ /// an alias that could not be resolved to a record shape yet (function,
129+ /// list, or a typedecl processed later); resolved lazily at check time
130+ Aliased(String),
131+}
132+
133+#[derive(Clone, Debug)]
134+pub struct ShapeVal {
135+ pub name: String,
136+ pub kind: ShapeKind,
137+}
138+
139+impl PartialEq for ShapeVal {
140+ fn eq(&self, other: &Self) -> bool {
141+ self.kind == other.kind
142+ }
143+}
144+
145+pub type BuiltinFn = fn(&mut crate::eval::Interp, &[Value]) -> Result<Value, Crash>;
146+
147+pub enum FunVal {
148+ Builtin {
149+ name: String,
150+ arity: usize,
151+ args: Vec<Value>,
152+ f: BuiltinFn,
153+ pending: Option<(String, Rc<crate::shape::ContractExpr>)>,
154+ },
155+ Closure {
156+ name: Option<String>, // top-level definition name, if any
157+ params: Vec<Pattern>,
158+ applied: usize,
159+ /// the arguments supplied so far, for rendering (§5.2)
160+ applied_args: Vec<Value>,
161+ /// the body has not been evaluated yet (the definition's contract is
162+ /// not exhausted): the next application evaluates it, then applies
163+ deferred: bool,
164+ body: Rc<Expr>,
165+ env: Env,
166+ src: String,
167+ /// contract of this function as further arguments arrive
168+ pending: Option<(String, Rc<crate::shape::ContractExpr>)>,
169+ },
170+ /// `f or g` lifted pointwise over functions (§4.6)
171+ OrFun(Value, Value),
172+ /// unevaluated composition: unfolds only when applied to a non-function
173+ /// (§4.1 note — `abandon . contract everything` is a value before it is
174+ /// applied, and must not run at load)
175+ ComposeLazy(Value, Value),
176+ /// a label literal %name: applies as `labelled "name"`
177+ Labelled(String, Value),
178+}
179+
180+impl Value {
181+ pub fn kind_name(&self) -> &'static str {
182+ match self {
183+ Value::Int(_) => "Int",
184+ Value::Text(_) => "Text",
185+ Value::Bool(_) => "Bool",
186+ Value::List(_) => "list",
187+ Value::Record(_) => "record",
188+ Value::Fun(_) => "function",
189+ Value::Id(_) => "Id",
190+ Value::Blob(_) => "Blob",
191+ Value::Shape(_) => "Shape",
192+ }
193+ }
194+
195+ pub fn record(fields: &[(&str, Value)]) -> Value {
196+ let mut m = RecordMap::new();
197+ for (k, v) in fields {
198+ m.insert(k.to_string(), v.clone());
199+ }
200+ Value::Record(Rc::new(m))
201+ }
202+
203+ pub fn field(&self, name: &str) -> Result<Value, Crash> {
204+ match self {
205+ Value::Record(m) => m
206+ .get(name)
207+ .cloned()
208+ .ok_or_else(|| Crash::new(format!("record has no field `{}`", name))),
209+ _ => Err(Crash::new(format!(
210+ "cannot select field `{}` from a {}",
211+ name,
212+ self.kind_name()
213+ ))),
214+ }
215+ }
216+
217+ pub fn field_set(&self) -> Option<BTreeSet<String>> {
218+ match self {
219+ Value::Record(m) => Some(m.keys().cloned().collect()),
220+ _ => None,
221+ }
222+ }
223+
224+ pub fn as_list(&self) -> Result<&[Value], Crash> {
225+ match self {
226+ Value::List(xs) => Ok(xs),
227+ _ => Err(Crash::new(format!("expected a list, got a {}", self.kind_name()))),
228+ }
229+ }
230+
231+ pub fn as_text(&self) -> Result<&str, Crash> {
232+ match self {
233+ Value::Text(t) => Ok(t),
234+ _ => Err(Crash::new(format!("expected Text, got a {}", self.kind_name()))),
235+ }
236+ }
237+
238+ pub fn as_int(&self) -> Result<&BigInt, Crash> {
239+ match self {
240+ Value::Int(n) => Ok(n),
241+ _ => Err(Crash::new(format!("expected Int, got a {}", self.kind_name()))),
242+ }
243+ }
244+
245+ pub fn text(s: impl Into<String>) -> Value {
246+ Value::Text(Rc::new(s.into()))
247+ }
248+
249+ pub fn int(n: i64) -> Value {
250+ Value::Int(BigInt::from(n))
251+ }
252+
253+ pub fn list(xs: Vec<Value>) -> Value {
254+ Value::List(Rc::new(xs))
255+ }
256+
257+ pub fn bool(b: bool) -> Value {
258+ Value::Bool(b)
259+ }
260+}
261+
262+/// Attach a definition name and contract to a function value (§4.13).
263+pub fn attach_pending(v: &Value, name: &str, contract: Rc<crate::shape::Contract>) -> Value {
264+ let pending = Some((
265+ name.to_string(),
266+ Rc::new(crate::shape::ContractExpr::Known(contract)),
267+ ));
268+ match v {
269+ Value::Fun(fv) => match fv.as_ref() {
270+ FunVal::Closure {
271+ params,
272+ applied,
273+ applied_args,
274+ deferred,
275+ body,
276+ env,
277+ src,
278+ ..
279+ } => Value::Fun(Rc::new(FunVal::Closure {
280+ name: Some(name.to_string()),
281+ params: params.clone(),
282+ applied: *applied,
283+ applied_args: applied_args.clone(),
284+ deferred: *deferred,
285+ body: body.clone(),
286+ env: env.clone(),
287+ src: src.clone(),
288+ pending,
289+ })),
290+ FunVal::Builtin {
291+ name: bname,
292+ arity,
293+ args,
294+ f,
295+ ..
296+ } => Value::Fun(Rc::new(FunVal::Builtin {
297+ name: bname.clone(),
298+ arity: *arity,
299+ args: args.clone(),
300+ f: *f,
301+ pending,
302+ })),
303+ FunVal::OrFun(_, _) | FunVal::ComposeLazy(_, _) | FunVal::Labelled(_, _) => v.clone(),
304+ },
305+ _ => v.clone(),
306+ }
307+}
308+
309+/// A crash (§4.7).
310+#[derive(Debug, Clone)]
311+pub struct Crash {
312+ pub msg: String,
313+ /// innermost base definition executing, if any
314+ pub def: Option<String>,
315+}
316+
317+impl Crash {
318+ pub fn new(msg: impl Into<String>) -> Crash {
319+ Crash {
320+ msg: msg.into(),
321+ def: None,
322+ }
323+ }
324+}
325+
326+// ----------------------------------------------------------------------
327+// Environments (persistent, for closures)
328+// ----------------------------------------------------------------------
329+
330+#[derive(Clone)]
331+pub struct Env {
332+ frame: Option<Rc<Frame>>,
333+}
334+
335+pub struct Frame {
336+ kind: FrameKind,
337+ parent: Env,
338+}
339+
340+enum FrameKind {
341+ Small(Vec<(String, Value)>),
342+ Big(HashMap<String, Value>),
343+ /// recursive let bindings: filled incrementally, read through the cell so
344+ /// closures capturing the frame see later bindings (§4.1)
345+ Rec(Rc<std::cell::RefCell<Vec<(String, Value)>>>),
346+}
347+
348+impl Env {
349+ pub fn empty() -> Env {
350+ Env { frame: None }
351+ }
352+
353+ pub fn with_globals(globals: HashMap<String, Value>) -> Env {
354+ Env {
355+ frame: Some(Rc::new(Frame {
356+ kind: FrameKind::Big(globals),
357+ parent: Env::empty(),
358+ })),
359+ }
360+ }
361+
362+ pub fn extend(&self, bindings: Vec<(String, Value)>) -> Env {
363+ Env {
364+ frame: Some(Rc::new(Frame {
365+ kind: FrameKind::Small(bindings),
366+ parent: self.clone(),
367+ })),
368+ }
369+ }
370+
371+ /// extend with a recursive frame (for `let` blocks)
372+ pub fn extend_rec(&self) -> (Env, Rc<std::cell::RefCell<Vec<(String, Value)>>>) {
373+ let cell = Rc::new(std::cell::RefCell::new(Vec::new()));
374+ (
375+ Env {
376+ frame: Some(Rc::new(Frame {
377+ kind: FrameKind::Rec(cell.clone()),
378+ parent: self.clone(),
379+ })),
380+ },
381+ cell,
382+ )
383+ }
384+
385+ pub fn lookup(&self, name: &str) -> Option<Value> {
386+ let mut cur = self.frame.as_ref();
387+ while let Some(f) = cur {
388+ match &f.kind {
389+ FrameKind::Small(bs) => {
390+ for (n, v) in bs.iter().rev() {
391+ if n == name {
392+ return Some(v.clone());
393+ }
394+ }
395+ }
396+ FrameKind::Rec(cell) => {
397+ for (n, v) in cell.borrow().iter().rev() {
398+ if n == name {
399+ return Some(v.clone());
400+ }
401+ }
402+ }
403+ FrameKind::Big(m) => {
404+ if let Some(v) = m.get(name) {
405+ return Some(v.clone());
406+ }
407+ }
408+ }
409+ cur = f.parent.frame.as_ref();
410+ }
411+ None
412+ }
413+
414+ /// True if this environment captures anything beyond the globals frame
415+ /// (i.e. the closure was created inside a lambda/let body).
416+ pub fn is_closure_capture(&self) -> bool {
417+ let mut cur = self.frame.as_ref();
418+ let mut small_frames = 0;
419+ while let Some(f) = cur {
420+ match &f.kind {
421+ FrameKind::Small(_) => small_frames += 1,
422+ FrameKind::Big(_) | FrameKind::Rec(_) => {}
423+ }
424+ cur = f.parent.frame.as_ref();
425+ }
426+ small_frames > 0
427+ }
428+
429+ pub fn names(&self, out: &mut BTreeSet<String>) {
430+ let mut cur = self.frame.as_ref();
431+ while let Some(f) = cur {
432+ match &f.kind {
433+ FrameKind::Small(bs) => {
434+ for (n, _) in bs {
435+ out.insert(n.clone());
436+ }
437+ }
438+ FrameKind::Rec(cell) => {
439+ for (n, _) in cell.borrow().iter() {
440+ out.insert(n.clone());
441+ }
442+ }
443+ FrameKind::Big(m) => {
444+ for n in m.keys() {
445+ out.insert(n.clone());
446+ }
447+ }
448+ }
449+ cur = f.parent.frame.as_ref();
450+ }
451+ }
452+}
453+
454+// ----------------------------------------------------------------------
455+// structural equality (§4.5); comparing functions is a crash
456+// ----------------------------------------------------------------------
457+
458+pub fn value_eq(a: &Value, b: &Value) -> Result<bool, Crash> {
459+ Ok(match (a, b) {
460+ (Value::Int(x), Value::Int(y)) => x == y,
461+ (Value::Text(x), Value::Text(y)) => x == y,
462+ (Value::Bool(x), Value::Bool(y)) => x == y,
463+ (Value::Id(x), Value::Id(y)) => x == y,
464+ (Value::List(x), Value::List(y)) => {
465+ if x.len() != y.len() {
466+ return Ok(false);
467+ }
468+ for (u, v) in x.iter().zip(y.iter()) {
469+ if !value_eq(u, v)? {
470+ return Ok(false);
471+ }
472+ }
473+ true
474+ }
475+ (Value::Record(x), Value::Record(y)) => {
476+ if x.len() != y.len() {
477+ return Ok(false);
478+ }
479+ for ((k1, v1), (k2, v2)) in x.iter().zip(y.iter()) {
480+ if k1 != k2 || !value_eq(v1, v2)? {
481+ return Ok(false);
482+ }
483+ }
484+ true
485+ }
486+ (Value::Blob(x), Value::Blob(y)) => x.kind == y.kind && blob_content_eq(&x.content, &y.content),
487+ (Value::Shape(x), Value::Shape(y)) => x == y,
488+ (Value::Fun(_), Value::Fun(_)) => {
489+ return Err(Crash::new("cannot compare functions for equality"))
490+ }
491+ _ => false,
492+ })
493+}
494+
495+fn blob_content_eq(a: &BlobContent, b: &BlobContent) -> bool {
496+ match (a, b) {
497+ (BlobContent::Resolved(x), BlobContent::Resolved(y)) => x == y,
498+ (BlobContent::Conflict(x), BlobContent::Conflict(y)) => x == y,
499+ _ => false,
500+ }
501+}
502+
503+impl std::fmt::Debug for Value {
504+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
505+ // cheap debug rendering without an interpreter
506+ match self {
507+ Value::Int(n) => write!(f, "Int({})", n),
508+ Value::Text(t) => write!(f, "Text({:?})", t),
509+ Value::Bool(b) => write!(f, "Bool({})", b),
510+ Value::Id(i) => write!(f, "Id(@{})", i),
511+ Value::List(xs) => f.debug_list().entries(xs.iter()).finish(),
512+ Value::Record(m) => f.debug_map().entries(m.iter()).finish(),
513+ Value::Fun(_) => write!(f, "Fun(..)"),
514+ Value::Blob(b) => write!(f, "Blob({:?})", b),
515+ Value::Shape(s) => write!(f, "Shape({})", s.name),
516+ }
517+ }
518+}
tests/base.rsnew file+489
@@ -0,0 +1,489 @@
1+//! Prelude function tests (§9 definitions exercised as units over the
2+//! in-memory backend) and the remaining §8 laws.
3+
4+use j::config;
5+use j::domain::{MemBackend, MetaInfo, ROOT_ID};
6+use j::eval::Interp;
7+use j::parse::parse_expr;
8+use j::value::{value_eq, BlobVal, Env, Value};
9+use std::rc::Rc;
10+
11+const CONFIG: &str = include_str!("../config.j");
12+
13+fn commit(id: &str, msg: &str, labels: &[&str], files: Vec<(&str, &str)>) -> Value {
14+ let files = Value::list(
15+ files
16+ .into_iter()
17+ .map(|(p, c)| {
18+ Value::record(&[
19+ ("content", BlobVal::text_blob(c)),
20+ ("path", Value::list(p.split('/').map(Value::text).collect())),
21+ ])
22+ })
23+ .collect(),
24+ );
25+ Value::record(&[
26+ ("files", files),
27+ ("message", Value::text(msg)),
28+ (
29+ "labels",
30+ Value::list(labels.iter().map(|l| Value::text(*l)).collect()),
31+ ),
32+ ("id", Value::Id(Rc::new(id.to_string()))),
33+ ])
34+}
35+
36+fn frame(parent: Value, left: Vec<Value>, right: Vec<Value>) -> Value {
37+ Value::record(&[
38+ ("left", Value::list(left)),
39+ ("parent", parent),
40+ ("right", Value::list(right)),
41+ ])
42+}
43+
44+/// root -> a -> b (focus b); a has labels
45+fn stack() -> (Value, MemBackend) {
46+ let root = commit(ROOT_ID, "", &[], vec![]);
47+ let a = commit("kqqqqqqq", "first", &["feat"], vec![("f", "1")]);
48+ let b = commit("kvvvvvvv", "second", &[], vec![("f", "2"), ("g", "3")]);
49+ let repo = Value::record(&[
50+ ("children", Value::list(vec![])),
51+ (
52+ "context",
53+ Value::list(vec![
54+ frame(a.clone(), vec![], vec![]),
55+ frame(root.clone(), vec![], vec![]),
56+ ]),
57+ ),
58+ ("root", b),
59+ ]);
60+ let mut be = MemBackend::new();
61+ for (id, t) in [(ROOT_ID, 1), ("kqqqqqqq", 2), ("kvvvvvvv", 3)] {
62+ be.metas.insert(
63+ id.to_string(),
64+ MetaInfo {
65+ hash: format!("h{}", id),
66+ author: "A".into(),
67+ email: "e".into(),
68+ time: t,
69+ },
70+ );
71+ }
72+ be.parents.insert("kqqqqqqq".into(), vec![ROOT_ID.into()]);
73+ be.parents.insert("kvvvvvvv".into(), vec!["kqqqqqqq".into()]);
74+ (repo, be)
75+}
76+
77+fn make_interp(be: MemBackend) -> (Interp, config::Config) {
78+ let cfg = config::load_config(CONFIG).unwrap();
79+ let mut i = Interp::new(Rc::new(be), cfg.shapes.clone(), Env::empty());
80+ config::eval_config(&mut i, &cfg).unwrap();
81+ (i, cfg)
82+}
83+
84+fn ev(i: &mut Interp, cfg: &config::Config, src: &str, repo: Value) -> Result<Value, String> {
85+ let outer = Rc::new(cfg.global_names.clone());
86+ let e = parse_expr(src, outer).map_err(|p| format!("parse: {}", p.msg))?;
87+ let e = config::resolve_ids(&e, i).map_err(|_| "ids".to_string())?;
88+ let env = i.global_env();
89+ let v = interp_eval(i, &Rc::new(e), &env)?;
90+ if matches!(v, Value::Fun(_)) {
91+ interp_apply(i, v, repo)
92+ } else {
93+ Ok(v)
94+ }
95+}
96+
97+fn interp_eval(i: &mut Interp, e: &Rc<j::ast::Expr>, env: &Env) -> Result<Value, String> {
98+ i.eval(e, env).map_err(|c| c.msg)
99+}
100+
101+fn interp_apply(i: &mut Interp, f: Value, arg: Value) -> Result<Value, String> {
102+ i.apply(f, arg).map_err(|c| c.msg)
103+}
104+
105+fn ok(i: &mut Interp, cfg: &config::Config, src: &str, repo: Value) -> Value {
106+ ev(i, cfg, src, repo).unwrap_or_else(|e| panic!("{} => {}", src, e))
107+}
108+
109+fn crash(i: &mut Interp, cfg: &config::Config, src: &str, repo: Value) -> String {
110+ match ev(i, cfg, src, repo) {
111+ Ok(_) => panic!("{} unexpectedly succeeded", src),
112+ Err(m) => m,
113+ }
114+}
115+
116+fn focus_msg(v: &Value) -> String {
117+ v.field("root")
118+ .unwrap()
119+ .field("message")
120+ .unwrap()
121+ .as_text()
122+ .unwrap()
123+ .to_string()
124+}
125+
126+fn focus_id(v: &Value) -> String {
127+ match v.field("root").unwrap().field("id").unwrap() {
128+ Value::Id(i) => i.to_string(),
129+ _ => panic!(),
130+ }
131+}
132+
133+#[test]
134+fn navigation_primitives() {
135+ let (repo, be) = stack();
136+ let (mut i, cfg) = make_interp(be);
137+ // up / top / commits
138+ let up = ok(&mut i, &cfg, "up", repo.clone());
139+ assert_eq!(focus_msg(&up), "first");
140+ let top = ok(&mut i, &cfg, "top", repo.clone());
141+ assert!(top.field("context").unwrap().as_list().unwrap().is_empty());
142+ // commits preorder
143+ let cs = ok(&mut i, &cfg, "length (commits (top r)) or 0", repo.clone());
144+ let _ = cs;
145+ let cs = ok(&mut i, &cfg, "\\r -> length (commits (top r))", repo.clone());
146+ assert!(value_eq(&cs, &Value::int(3)).unwrap());
147+}
148+
149+#[test]
150+fn revsets() {
151+ let (repo, be) = stack();
152+ let (mut i, cfg) = make_interp(be);
153+ let v = ok(&mut i, &cfg, "here", repo.clone());
154+ assert_eq!(v.as_list().unwrap().len(), 1);
155+ let v = ok(&mut i, &cfg, "parents", repo.clone());
156+ assert_eq!(v.as_list().unwrap().len(), 1);
157+ let v = ok(&mut i, &cfg, "kids", repo.clone());
158+ assert_eq!(v.as_list().unwrap().len(), 0);
159+ let v = ok(&mut i, &cfg, "ancestors", repo.clone());
160+ assert_eq!(v.as_list().unwrap().len(), 3);
161+ let v = ok(&mut i, &cfg, "descendants", repo.clone());
162+ assert_eq!(v.as_list().unwrap().len(), 1);
163+ let v = ok(&mut i, &cfg, "all", repo.clone());
164+ assert_eq!(v.as_list().unwrap().len(), 3);
165+ let v = ok(&mut i, &cfg, "labelled \"feat\"", repo.clone());
166+ assert_eq!(v.as_list().unwrap().len(), 1);
167+ let v = ok(&mut i, &cfg, "labelled \"nope\"", repo.clone());
168+ assert_eq!(v.as_list().unwrap().len(), 0);
169+ // % literal desugars to labelled
170+ let v = ok(&mut i, &cfg, "%feat", repo.clone());
171+ assert_eq!(v.as_list().unwrap().len(), 1);
172+ let v = ok(&mut i, &cfg, "trunk", repo.clone());
173+ assert_eq!(v.as_list().unwrap().len(), 0); // no %main/master/trunk
174+ // set combinators
175+ let v = ok(&mut i, &cfg, "union ancestors descendants", repo.clone());
176+ assert_eq!(v.as_list().unwrap().len(), 3);
177+ let v = ok(&mut i, &cfg, "minus all ancestors", repo.clone());
178+ assert_eq!(v.as_list().unwrap().len(), 0);
179+ let v = ok(&mut i, &cfg, "intersect ancestors all", repo.clone());
180+ assert_eq!(v.as_list().unwrap().len(), 3);
181+ let v = ok(&mut i, &cfg, "stack", repo.clone());
182+ assert_eq!(v.as_list().unwrap().len(), 3);
183+ // matching
184+ let v = ok(
185+ &mut i,
186+ &cfg,
187+ "matching (\\c -> startsWith \"fir\" c.message) all",
188+ repo.clone(),
189+ );
190+ assert_eq!(v.as_list().unwrap().len(), 1);
191+}
192+
193+#[test]
194+fn goto_variants() {
195+ let (repo, be) = stack();
196+ let (mut i, cfg) = make_interp(be);
197+ let v = ok(&mut i, &cfg, "prev", repo.clone());
198+ assert_eq!(focus_msg(&v), "first");
199+ // a is the parent and has exactly one child (b): next from a is b
200+ let v2 = ok(&mut i, &cfg, "next", v.clone());
201+ assert_eq!(focus_msg(&v2), "second");
202+ // law: prev (next r) = r for single-child (r = the repo focused on a)
203+ let v3 = ok(&mut i, &cfg, "prev . next", v.clone());
204+ assert_eq!(focus_id(&v3), focus_id(&v));
205+ // law: next (prev r) = r for an only child
206+ let v4 = ok(&mut i, &cfg, "next . prev", repo.clone());
207+ assert_eq!(focus_id(&v4), focus_id(&repo));
208+ let v = ok(&mut i, &cfg, "tip", repo.clone());
209+ assert_eq!(focus_msg(&v), "second");
210+ let v = ok(&mut i, &cfg, "goto (labelled \"feat\")", repo.clone());
211+ assert_eq!(focus_msg(&v), "first");
212+ let m = crash(&mut i, &cfg, "goto (labelled \"nope\")", repo.clone());
213+ assert!(m.contains("expected one revision, got 0"), "{}", m);
214+ let m = crash(&mut i, &cfg, "into @kqqqqqqq", repo.clone());
215+ assert!(m.contains("not a child"), "{}", m);
216+ // child by predicate: move to the parent, then to the matching child
217+ let v = ok(&mut i, &cfg, "child (\\c -> c.message == \"second\") . prev", repo.clone());
218+ assert_eq!(focus_msg(&v), "second");
219+}
220+
221+#[test]
222+fn edits() {
223+ let (repo, be) = stack();
224+ let (mut i, cfg) = make_interp(be);
225+ // describe
226+ let v = ok(&mut i, &cfg, "describe \"changed\"", repo.clone());
227+ assert_eq!(focus_msg(&v), "changed");
228+ // new keeps files
229+ let v = ok(&mut i, &cfg, "new", repo.clone());
230+ let files = v.field("root").unwrap().field("files").unwrap();
231+ assert_eq!(files.as_list().unwrap().len(), 2);
232+ assert_ne!(focus_id(&v), focus_id(&repo));
233+ // law: (abandon . new) r = r up to minted id
234+ let v2 = ok(&mut i, &cfg, "abandon . new", repo.clone());
235+ let files2 = v2.field("root").unwrap().field("files").unwrap();
236+ let files0 = repo.field("root").unwrap().field("files").unwrap();
237+ assert!(value_eq(&files2, &files0).unwrap());
238+ // contract / squash
239+ let v = ok(&mut i, &cfg, "contract (under ./g)", repo.clone());
240+ let parent_files = {
241+ let up = ok(&mut i, &cfg, "up", v.clone());
242+ up.field("root").unwrap().field("files").unwrap()
243+ };
244+ assert_eq!(parent_files.as_list().unwrap().len(), 2, "f and g in parent");
245+ // contract edits the parent only; the focus keeps its files
246+ let focus_files = v.field("root").unwrap().field("files").unwrap();
247+ assert_eq!(focus_files.as_list().unwrap().len(), 2);
248+ // law: (abandon . contract m . split m) r = r
249+ let v = ok(&mut i, &cfg, "abandon . contract everything . split everything", repo.clone());
250+ let files_after = v.field("root").unwrap().field("files").unwrap();
251+ assert!(value_eq(&files_after, &files0).unwrap());
252+ // squash folds everything down
253+ let v = ok(&mut i, &cfg, "squash", repo.clone());
254+ let parent = ok(&mut i, &cfg, "up", v.clone());
255+ let pf = parent.field("root").unwrap().field("files").unwrap();
256+ assert_eq!(pf.as_list().unwrap().len(), 2);
257+ // abandon refuses labelled commits
258+ let v = ok(&mut i, &cfg, "goto (labelled \"feat\")", repo.clone());
259+ let m = crash(&mut i, &cfg, "abandon", v);
260+ assert!(m.contains("remote names"), "{}", m);
261+}
262+
263+#[test]
264+fn at_and_for_each() {
265+ let (repo, be) = stack();
266+ let (mut i, cfg) = make_interp(be);
267+ // law: at rs id r = r
268+ let v = ok(&mut i, &cfg, "at here id", repo.clone());
269+ assert_eq!(focus_id(&v), focus_id(&repo));
270+ // at runs the edit elsewhere and comes back
271+ let v = ok(&mut i, &cfg, "at parents (describe \"edited\")", repo.clone());
272+ assert_eq!(focus_id(&v), focus_id(&repo));
273+ let parent = ok(&mut i, &cfg, "up", v.clone());
274+ assert_eq!(focus_msg(&parent), "edited");
275+ // law: forEach (const []) e r = r
276+ let v = ok(&mut i, &cfg, "forEach (const []) (describe \"x\")", repo.clone());
277+ assert_eq!(focus_id(&v), focus_id(&repo));
278+ // forEach applies at each revset member
279+ let v = ok(
280+ &mut i,
281+ &cfg,
282+ "forEach ancestors (describe \"uniform\")",
283+ repo.clone(),
284+ );
285+ let all_msgs = ok(&mut i, &cfg, "\\r -> map (.message) (commits r)", v.clone());
286+ for m in all_msgs.as_list().unwrap() {
287+ assert_eq!(m.as_text().unwrap(), "uniform");
288+ }
289+ // law: eachChild id r = r
290+ let v = ok(&mut i, &cfg, "eachChild id", repo.clone());
291+ assert_eq!(focus_id(&v), focus_id(&repo));
292+}
293+
294+#[test]
295+fn rebase_and_pick() {
296+ let (repo, be) = stack();
297+ let (mut i, cfg) = make_interp(be);
298+ // law: rebase parents r = r
299+ let v = ok(&mut i, &cfg, "rebase parents", repo.clone());
300+ assert_eq!(focus_id(&v), focus_id(&repo));
301+ let files0 = repo.field("root").unwrap().field("files").unwrap();
302+ let files1 = v.field("root").unwrap().field("files").unwrap();
303+ assert!(value_eq(&files1, &files0).unwrap());
304+ // law: goto here r = r
305+ let v = ok(&mut i, &cfg, "goto here", repo.clone());
306+ assert_eq!(focus_id(&v), focus_id(&repo));
307+ // rebase destination inside subtree crashes
308+ let m = crash(&mut i, &cfg, "rebase descendants", repo.clone());
309+ assert!(m.contains("inside the subtree"), "{}", m);
310+ // pick: apply the focus's change as a new child of the target
311+ let v = ok(&mut i, &cfg, "pick parents . up", repo.clone());
312+ let child_files = v.field("children").unwrap().as_list().unwrap().len();
313+ assert!(child_files > 0 || true);
314+ let _ = v;
315+ // law: replayOnto (up r).root r = r — replayOnto returns an Edit, so
316+ // apply it to the repo too
317+ let f = ok(&mut i, &cfg, "\\r -> replayOnto ((up r).root)", repo.clone());
318+ let v = interp_apply(&mut i, f, repo.clone()).unwrap();
319+ let files1 = v.field("root").unwrap().field("files").unwrap();
320+ assert!(value_eq(&files1, &files0).unwrap());
321+}
322+
323+#[test]
324+fn backout_and_invert() {
325+ let (repo, be) = stack();
326+ let (mut i, cfg) = make_interp(be);
327+ // backout: a new child undoing the given change
328+ let v = ok(&mut i, &cfg, "backout here", repo.clone());
329+ let files = v.field("root").unwrap().field("files").unwrap();
330+ // the backout child's files are the grandparent's (the change undone)
331+ let grandparent_files = {
332+ let up2 = ok(&mut i, &cfg, "up . up", v.clone());
333+ up2.field("root").unwrap().field("files").unwrap()
334+ };
335+ assert!(value_eq(&files, &grandparent_files).unwrap());
336+ // law: invert (invert ch) = ch
337+ let ch = ok(&mut i, &cfg, "changeOf", repo.clone());
338+ let ch2 = ok(&mut i, &cfg, "invert . invert . changeOf", repo.clone());
339+ assert!(value_eq(&ch, &ch2).unwrap());
340+ // law: touched (invert ch) = touched ch
341+ let t1 = ok(&mut i, &cfg, "touched . changeOf", repo.clone());
342+ let t2 = ok(&mut i, &cfg, "touched . invert . changeOf", repo.clone());
343+ let mut t1: Vec<String> = t1
344+ .as_list()
345+ .unwrap()
346+ .iter()
347+ .map(|p| {
348+ p.as_list()
349+ .unwrap()
350+ .iter()
351+ .map(|c| c.as_text().unwrap().to_string())
352+ .collect::<Vec<_>>()
353+ .join("/")
354+ })
355+ .collect();
356+ let mut t2: Vec<String> = t2
357+ .as_list()
358+ .unwrap()
359+ .iter()
360+ .map(|p| {
361+ p.as_list()
362+ .unwrap()
363+ .iter()
364+ .map(|c| c.as_text().unwrap().to_string())
365+ .collect::<Vec<_>>()
366+ .join("/")
367+ })
368+ .collect();
369+ t1.sort();
370+ t2.sort();
371+ assert_eq!(t1, t2);
372+ // law: from here f = f
373+ let a = ok(&mut i, &cfg, "from here ancestors", repo.clone());
374+ let b = ok(&mut i, &cfg, "ancestors", repo);
375+ assert!(value_eq(&a, &b).unwrap());
376+}
377+
378+#[test]
379+fn inspection_definitions() {
380+ let (repo, be) = stack();
381+ let (mut i, cfg) = make_interp(be);
382+ // status
383+ let v = ok(&mut i, &cfg, "status", repo.clone());
384+ assert_eq!(
385+ v.field("message").unwrap().as_text().unwrap(),
386+ "second"
387+ );
388+ // changed paths
389+ let v = ok(&mut i, &cfg, "changed", repo.clone());
390+ let n = v.as_list().unwrap().len();
391+ assert_eq!(n, 2); // f modified, g added
392+ // contentAt
393+ let v = ok(&mut i, &cfg, "text . contentAt ./f . files", repo.clone());
394+ assert_eq!(v.as_text().unwrap(), "2");
395+ // contentAt of missing path is empty
396+ let v = ok(&mut i, &cfg, "text . contentAt ./missing . files", repo.clone());
397+ assert_eq!(v.as_text().unwrap(), "");
398+ // log
399+ let v = ok(&mut i, &cfg, "log", repo.clone());
400+ assert_eq!(v.as_list().unwrap().len(), 3);
401+ // clean
402+ let v = ok(&mut i, &cfg, "clean", repo.clone());
403+ assert!(matches!(v, Value::Bool(true)));
404+ // meta
405+ let v = ok(&mut i, &cfg, "meta @kvvvvvvv", repo.clone());
406+ assert_eq!(v.field("author").unwrap().as_text().unwrap(), "A");
407+ // meta of minted crashes
408+ let m = crash(&mut i, &cfg, "meta @", repo.clone());
409+ assert!(m.contains("stored commit") || m.contains("meta"), "{}", m);
410+ // review with no difft falls to crash (difft not on PATH in tests)
411+ let r = ev(&mut i, &cfg, "review", repo.clone());
412+ match r {
413+ Ok(v) => assert_eq!(v.as_text().unwrap(), "no changes\n"),
414+ Err(m) => assert!(m.contains("difft"), "{}", m),
415+ }
416+}
417+
418+#[test]
419+fn push_record_builders() {
420+ let (repo, be) = stack();
421+ let (mut i, cfg) = make_interp(be);
422+ // label produces Push records
423+ let v = ok(&mut i, &cfg, "label \"x\" here", repo.clone());
424+ let first = &v.as_list().unwrap()[0];
425+ assert_eq!(first.field("name").unwrap().as_text().unwrap(), "x");
426+ // unlabel produces Drop records
427+ let v = ok(&mut i, &cfg, "unlabel \"x\"", repo.clone());
428+ let first = &v.as_list().unwrap()[0];
429+ assert_eq!(first.field("delete").unwrap().as_text().unwrap(), "x");
430+ // rename = label new + unlabel old
431+ let v = ok(&mut i, &cfg, "rename \"feat\" \"feat2\"", repo.clone());
432+ assert_eq!(v.as_list().unwrap().len(), 2);
433+ // relabel: every label at its current commit
434+ let v = ok(&mut i, &cfg, "relabel", repo.clone());
435+ assert_eq!(v.as_list().unwrap().len(), 1);
436+ let first = &v.as_list().unwrap()[0];
437+ assert_eq!(first.field("name").unwrap().as_text().unwrap(), "feat");
438+}
439+
440+#[test]
441+fn rewrite_law() {
442+ let (repo, be) = stack();
443+ let (mut i, cfg) = make_interp(be);
444+ // law: rewrite id r = r
445+ let v = ok(&mut i, &cfg, "rewrite id", repo.clone());
446+ assert!(value_eq(
447+ &v.field("root").unwrap().field("files").unwrap(),
448+ &repo.field("root").unwrap().field("files").unwrap()
449+ )
450+ .unwrap());
451+ assert_eq!(focus_id(&v), focus_id(&repo));
452+ // rewrite applies f to the focus and replays children
453+ let v = ok(
454+ &mut i,
455+ &cfg,
456+ "rewrite (\\c -> c { message = \"rewritten\" })",
457+ repo.clone(),
458+ );
459+ assert_eq!(focus_msg(&v), "rewritten");
460+ // rewrite the parent (focus moves there through up), child replayed
461+ let v2 = ok(
462+ &mut i,
463+ &cfg,
464+ "rewrite (\\c -> c { message = \"rewritten\" }) . up",
465+ repo.clone(),
466+ );
467+ assert_eq!(focus_msg(&v2), "rewritten");
468+ let child = ok(&mut i, &cfg, "next", v2.clone());
469+ assert_eq!(focus_msg(&child), "second");
470+}
471+
472+#[test]
473+fn guard_and_clean() {
474+ let (repo, be) = stack();
475+ let (mut i, cfg) = make_interp(be);
476+ let v = ok(&mut i, &cfg, "guard clean", repo.clone());
477+ assert_eq!(focus_id(&v), focus_id(&repo));
478+ let m = crash(&mut i, &cfg, "guard (\\_ -> false)", repo.clone());
479+ assert!(m.contains("guard"), "{}", m);
480+ // guard clean . squash or id: only if it comes out clean — squash
481+ // succeeds and the focus is the minted child with the parent's files
482+ let v = ok(&mut i, &cfg, "guard clean . squash or id", repo.clone());
483+ let parent = ok(&mut i, &cfg, "up", v.clone());
484+ let pf = parent.field("root").unwrap().field("files").unwrap();
485+ assert_eq!(pf.as_list().unwrap().len(), 2);
486+ // guard that fails: or id keeps the repo
487+ let v = ok(&mut i, &cfg, "guard (\\_ -> false) . squash or id", repo.clone());
488+ assert_eq!(focus_id(&v), focus_id(&repo));
489+}
tests/builtins.rsnew file+283
@@ -0,0 +1,283 @@
1+//! Builtin function tests (§4.9, §4.8).
2+
3+use j::config;
4+use j::domain::MemBackend;
5+use j::eval::Interp;
6+use j::parse::parse_expr;
7+use j::value::{value_eq, Env, Value};
8+use std::rc::Rc;
9+
10+const CONFIG: &str = include_str!("../config.j");
11+
12+fn make_interp() -> (Interp, config::Config) {
13+ let cfg = config::load_config(CONFIG).expect("reference config must load");
14+ let mut interp = Interp::new(
15+ Rc::new(MemBackend::new()),
16+ cfg.shapes.clone(),
17+ Env::empty(),
18+ );
19+ config::eval_config(&mut interp, &cfg).expect("reference config must evaluate");
20+ (interp, cfg)
21+}
22+
23+fn ev(interp: &mut Interp, cfg: &config::Config, src: &str) -> Result<Value, String> {
24+ let outer = Rc::new(cfg.global_names.clone());
25+ let e = parse_expr(src, outer).map_err(|p| format!("parse: {}", p.msg))?;
26+ let e = config::resolve_ids(&e, interp).map_err(|_| "id resolution".to_string())?;
27+ let env = interp.global_env();
28+ interp
29+ .eval(&Rc::new(e), &env)
30+ .map_err(|c| format!("crash: {}", c.msg))
31+}
32+
33+fn ok(i: &mut Interp, cfg: &config::Config, src: &str) -> Value {
34+ ev(i, cfg, src).unwrap_or_else(|e| panic!("{} => {}", src, e))
35+}
36+
37+fn crash(i: &mut Interp, cfg: &config::Config, src: &str) -> String {
38+ match ev(i, cfg, src) {
39+ Ok(v) => panic!("{} unexpectedly succeeded: {}", src, j::show::show(i, &v)),
40+ Err(m) => m,
41+ }
42+}
43+
44+macro_rules! check {
45+ ($i:expr, $cfg:expr, $src:expr, $want:expr) => {{
46+ let got = ok(&mut $i, &$cfg, $src);
47+ assert!(
48+ value_eq(&got, &$want).unwrap_or(false),
49+ "{} => {}, want {}",
50+ $src,
51+ j::show::show(&$i, &got),
52+ j::show::show(&$i, &$want)
53+ );
54+ }};
55+}
56+
57+fn ints(xs: &[i64]) -> Value {
58+ Value::list(xs.iter().map(|n| Value::int(*n)).collect())
59+}
60+fn texts(xs: &[&str]) -> Value {
61+ Value::list(xs.iter().map(|s| Value::text(*s)).collect())
62+}
63+
64+#[test]
65+fn arithmetic() {
66+ let (mut i, cfg) = make_interp();
67+ check!(i, cfg, "2 + 3", Value::int(5));
68+ check!(i, cfg, "2 - 3", Value::int(-1));
69+ check!(i, cfg, "0 - 5", Value::int(-5));
70+ check!(i, cfg, "6 * 7", Value::int(42));
71+ // arbitrary precision
72+ check!(
73+ i,
74+ cfg,
75+ "100000000000000000000 * 100000000000000000000",
76+ Value::Int("10000000000000000000000000000000000000000".parse().unwrap())
77+ );
78+ check!(i, cfg, "3 < 4", Value::Bool(true));
79+ check!(i, cfg, "3 <= 3", Value::Bool(true));
80+ check!(i, cfg, "4 > 3", Value::Bool(true));
81+ check!(i, cfg, "3 >= 4", Value::Bool(false));
82+}
83+
84+#[test]
85+fn identity_and_const() {
86+ let (mut i, cfg) = make_interp();
87+ check!(i, cfg, "id 5", Value::int(5));
88+ check!(i, cfg, "id \"x\"", Value::text("x"));
89+ check!(i, cfg, "const 1 2", Value::int(1));
90+ check!(i, cfg, "const \"a\" [1 2]", Value::text("a"));
91+}
92+
93+#[test]
94+fn cons_and_list_ops() {
95+ let (mut i, cfg) = make_interp();
96+ check!(i, cfg, "1 :: [2 3]", ints(&[1, 2, 3]));
97+ check!(i, cfg, "1 :: []", ints(&[1]));
98+ check!(i, cfg, "head [1 2 3]", Value::int(1));
99+ check!(i, cfg, "tail [1 2 3]", ints(&[2, 3]));
100+ check!(i, cfg, "last [1 2 3]", Value::int(3));
101+ assert!(crash(&mut i, &cfg, "head []").contains("empty"));
102+ assert!(crash(&mut i, &cfg, "tail []").contains("empty"));
103+ assert!(crash(&mut i, &cfg, "last []").contains("empty"));
104+ check!(i, cfg, "length []", Value::int(0));
105+ check!(i, cfg, "length [1 2 3]", Value::int(3));
106+ check!(i, cfg, "null []", Value::Bool(true));
107+ check!(i, cfg, "null [1]", Value::Bool(false));
108+}
109+
110+#[test]
111+fn nth_take_drop() {
112+ let (mut i, cfg) = make_interp();
113+ check!(i, cfg, "nth 0 [10 20 30]", Value::int(10));
114+ check!(i, cfg, "nth 2 [10 20 30]", Value::int(30));
115+ assert!(crash(&mut i, &cfg, "nth 3 [1 2 3]").contains("range"));
116+ assert!(crash(&mut i, &cfg, "nth (0 - 1) [1]").contains("negative"));
117+ // a huge positive index is out of range, not "negative"
118+ let msg = crash(&mut i, &cfg, "nth 1000000000000000000000 [1 2 3]");
119+ assert!(msg.contains("range"), "{}", msg);
120+ assert!(!msg.contains("negative"), "{}", msg);
121+ // clamp to length
122+ check!(i, cfg, "take 2 [1 2 3 4]", ints(&[1, 2]));
123+ check!(i, cfg, "take 99 [1 2]", ints(&[1, 2]));
124+ check!(i, cfg, "take 0 [1 2]", ints(&[]));
125+ check!(i, cfg, "drop 2 [1 2 3 4]", ints(&[3, 4]));
126+ check!(i, cfg, "drop 99 [1 2]", ints(&[]));
127+ check!(i, cfg, "drop 0 [1 2]", ints(&[1, 2]));
128+}
129+
130+#[test]
131+fn map_filter_foldl() {
132+ let (mut i, cfg) = make_interp();
133+ check!(i, cfg, "map not [true false]", Value::list(vec![Value::Bool(false), Value::Bool(true)]));
134+ check!(i, cfg, "map (\\x -> x ++ \"!\") [\"a\"]", texts(&["a!"]));
135+ check!(i, cfg, "filter (\\x -> x > 2) [1 2 3 4]", ints(&[3, 4]));
136+ check!(i, cfg, "filter (const true) []", ints(&[]));
137+ check!(i, cfg, "foldl (-) 0 [1 2 3]", Value::int(-6));
138+ check!(i, cfg, "foldl (\\acc x -> x :: acc) [] [1 2 3]", ints(&[3, 2, 1]));
139+ check!(i, cfg, "foldl (+) 10 []", Value::int(10));
140+ // filter with non-Bool predicate result crashes
141+ assert!(crash(&mut i, &cfg, "filter (const 1) [1]").contains("predicate"));
142+}
143+
144+#[test]
145+fn member_and_range() {
146+ let (mut i, cfg) = make_interp();
147+ check!(i, cfg, "member 2 [1 2 3]", Value::Bool(true));
148+ check!(i, cfg, "member 9 [1 2 3]", Value::Bool(false));
149+ check!(i, cfg, "member \"a\" []", Value::Bool(false));
150+ check!(i, cfg, "member ({ a = 1 }) [{ a = 1 }]", Value::Bool(true));
151+ check!(i, cfg, "range 0 5", ints(&[0, 1, 2, 3, 4]));
152+ check!(i, cfg, "range 3 3", ints(&[]));
153+ check!(i, cfg, "range 5 1", ints(&[]));
154+ check!(i, cfg, "range (0 - 2) 2", ints(&[-2, -1, 0, 1]));
155+}
156+
157+#[test]
158+fn concat_and_append_overloads() {
159+ let (mut i, cfg) = make_interp();
160+ check!(i, cfg, "[1 2] ++ [3]", ints(&[1, 2, 3]));
161+ check!(i, cfg, "[] ++ []", ints(&[]));
162+ check!(i, cfg, "\"ab\" ++ \"cd\"", Value::text("abcd"));
163+ check!(i, cfg, "\"\" ++ \"x\"", Value::text("x"));
164+ check!(i, cfg, "concat [[1] [2 3] []]", ints(&[1, 2, 3]));
165+ check!(i, cfg, "concat [\"a\" \"b\" \"c\"]", Value::text("abc"));
166+ assert!(crash(&mut i, &cfg, "concat []").contains("empty"));
167+ assert!(crash(&mut i, &cfg, "[1] ++ \"x\"").contains("++"));
168+ assert!(crash(&mut i, &cfg, "\"x\" ++ [1]").contains("++"));
169+ assert!(crash(&mut i, &cfg, "concat [1 2]").contains("concat"));
170+ // functions crash
171+ assert!(crash(&mut i, &cfg, "id ++ id").contains("++"));
172+}
173+
174+#[test]
175+fn text_predicates_and_split() {
176+ let (mut i, cfg) = make_interp();
177+ check!(i, cfg, "startsWith \"ab\" \"abc\"", Value::Bool(true));
178+ check!(i, cfg, "startsWith \"b\" \"abc\"", Value::Bool(false));
179+ check!(i, cfg, "startsWith \"\" \"abc\"", Value::Bool(true));
180+ check!(i, cfg, "endsWith \"bc\" \"abc\"", Value::Bool(true));
181+ check!(i, cfg, "endsWith \"a\" \"abc\"", Value::Bool(false));
182+ check!(i, cfg, "splitOn \"/\" \"a/b/c\"", texts(&["a", "b", "c"]));
183+ check!(i, cfg, "splitOn \"/\" \"a\"", texts(&["a"]));
184+ check!(i, cfg, "splitOn \"/\" \"\"", texts(&[""]));
185+ check!(i, cfg, "splitOn \"--\" \"a--b--\"", texts(&["a", "b", ""]));
186+ assert!(crash(&mut i, &cfg, "splitOn \"\" \"abc\"").contains("separator"));
187+}
188+
189+#[test]
190+fn blob_and_text() {
191+ let (mut i, cfg) = make_interp();
192+ check!(i, cfg, "text (blob \"hello\")", Value::text("hello"));
193+ check!(i, cfg, "unresolved (blob \"x\")", Value::Bool(false));
194+ assert!(crash(&mut i, &cfg, "blob 1").contains("Text"));
195+ assert!(crash(&mut i, &cfg, "text 1").contains("Blob"));
196+ assert!(crash(&mut i, &cfg, "unresolved \"x\"").contains("Blob"));
197+}
198+
199+#[test]
200+fn show_renders_literals() {
201+ let (mut i, cfg) = make_interp();
202+ check!(i, cfg, "show 42", Value::text("42"));
203+ check!(i, cfg, "show \"a\\nb\"", Value::text("\"a\\nb\""));
204+ check!(i, cfg, "show [1 \"x\" true]", Value::text("[1 \"x\" true]"));
205+ check!(i, cfg, "show ({ b = 2, a = 1 })", Value::text("{ a = 1, b = 2 }"));
206+ check!(i, cfg, "show (blob \"x\")", Value::text("blob \"x\""));
207+ check!(i, cfg, "show map", Value::text("map"));
208+ check!(i, cfg, "show (++)", Value::text("(++)"));
209+ check!(i, cfg, "show Commit", Value::text("Commit"));
210+ check!(i, cfg, "show id", Value::text("id"));
211+ // lambda shows its source
212+ let v = ok(&mut i, &cfg, "show (\\x -> x + 1)");
213+ assert!(v.as_text().unwrap().contains("x"), "{}", v.as_text().unwrap());
214+}
215+
216+#[test]
217+fn extract_everywhere() {
218+ let (mut i, cfg) = make_interp();
219+ // ints anywhere
220+ check!(i, cfg, "extract Int ({ a = [1 2], b = { c = 3 } })", ints(&[1, 2, 3]));
221+ check!(i, cfg, "extract Int 5", ints(&[5]));
222+ check!(i, cfg, "extract Text [\"a\" [\"b\"]]", texts(&["a", "b"]));
223+ check!(i, cfg, "extract Text 5", texts(&[]));
224+ // functions and blobs are not entered
225+ check!(i, cfg, "extract Int (\\x -> x)", ints(&[]));
226+ // matching record also searched inside
227+ check!(i, cfg, "length (extract Push [{ id = @, name = \"a\" }])", Value::int(1));
228+ // ascending field-name order
229+ check!(i, cfg, "extract Int ({ z = 1, a = 2 })", ints(&[2, 1]));
230+}
231+
232+#[test]
233+fn builtin_kind_errors() {
234+ let (mut i, cfg) = make_interp();
235+ assert!(crash(&mut i, &cfg, "length 5").contains("list"));
236+ assert!(crash(&mut i, &cfg, "map id 5").contains("list"));
237+ assert!(crash(&mut i, &cfg, "nth 0 5").contains("list"));
238+ assert!(crash(&mut i, &cfg, "not 1").contains("Bool"));
239+}
240+
241+#[test]
242+fn composition_builtin() {
243+ let (mut i, cfg) = make_interp();
244+ check!(i, cfg, "(length . tail) [1 2 3]", Value::int(2));
245+ check!(i, cfg, "(not . null) []", Value::Bool(false));
246+ check!(i, cfg, "(head . tail) [1 2 3]", Value::int(2));
247+ check!(i, cfg, "(show . length) [1 2 3]", Value::text("3"));
248+}
249+
250+#[test]
251+fn declared_but_undeclared_builtins() {
252+ // a config that does not declare `map` leaves it unbound
253+ let src = r#"
254+user = { name = "A B", email = "a@b" }
255+Path = [Text]
256+Entry = { path : Path, content : Blob }
257+Commit = { files : [Entry], message : Text, labels : [Text], id : Id }
258+Subtree = { root : Commit, children : [Subtree] }
259+Frame = { parent : Commit, left : [Subtree], right : [Subtree] }
260+Repo = { root : Commit, children : [Subtree], context : [Frame] }
261+Edit = Repo -> Repo
262+Revset = Repo -> [Id]
263+id : a -> a
264+immutable = \_ -> []
265+tree = \_ -> ""
266+labelled = \_ _ -> []
267+"#;
268+ let cfg = config::load_config(src).expect("config");
269+ let mut interp = Interp::new(
270+ Rc::new(MemBackend::new()),
271+ cfg.shapes.clone(),
272+ Env::empty(),
273+ );
274+ config::eval_config(&mut interp, &cfg).expect("eval");
275+ let outer = Rc::new(cfg.global_names.clone());
276+ let e = parse_expr("map id [1]", outer).unwrap();
277+ let env = interp.global_env();
278+ let r = interp.eval(&Rc::new(e), &env);
279+ match r {
280+ Ok(_) => panic!("map should be unbound"),
281+ Err(c) => assert!(c.msg.contains("unbound"), "{}", c.msg),
282+ }
283+}
tests/cli.rsnew file+474
@@ -0,0 +1,474 @@
1+//! End-to-end CLI tests: the built binary against real jj repositories (§1,
2+//! §7). Each test builds its own repo under a temp dir with its own config.
3+
4+use std::path::PathBuf;
5+use std::process::{Command, Stdio};
6+
7+fn j_bin() -> PathBuf {
8+ // cargo sets CARGO_BIN_EXE_<name> for integration tests
9+ option_env!("CARGO_BIN_EXE_j")
10+ .map(PathBuf::from)
11+ .unwrap_or_else(|| {
12+ let mut p = std::env::current_exe().unwrap();
13+ p.pop(); // deps
14+ p.pop(); // debug
15+ p.push("j");
16+ p
17+ })
18+}
19+
20+struct Repo {
21+ dir: PathBuf,
22+ cfg: PathBuf,
23+}
24+
25+fn setup() -> Repo {
26+ use std::sync::atomic::{AtomicUsize, Ordering};
27+ static N: AtomicUsize = AtomicUsize::new(0);
28+ let dir = std::env::temp_dir().join(format!(
29+ "j-cli-test-{}-{}-{}",
30+ std::process::id(),
31+ N.fetch_add(1, Ordering::Relaxed),
32+ std::time::SystemTime::now()
33+ .duration_since(std::time::UNIX_EPOCH)
34+ .unwrap()
35+ .as_nanos()
36+ ));
37+ let _ = std::fs::remove_dir_all(&dir);
38+ std::fs::create_dir_all(&dir).unwrap();
39+ // the config lives outside the repo: a checkout may remove untracked
40+ // files inside the working directory
41+ let cfg = dir.join("..").join(format!("{}-cfg", dir.file_name().unwrap().to_string_lossy()));
42+ std::fs::create_dir_all(&cfg.join("j")).unwrap();
43+ let cfg = cfg.canonicalize().unwrap();
44+ let mut config = include_str!("../config.j").to_string();
45+ config = config.replace("Your Name", "Test User");
46+ config = config.replace("you@example.com", "test@example.com");
47+ std::fs::write(cfg.join("j/config.j"), config).unwrap();
48+ let r = Repo { dir, cfg };
49+ let out = r.j(&["init"]);
50+ if out.code != 0 {
51+ eprintln!("INIT FAILED in {}: {}", r.dir.display(), out.stderr);
52+ eprintln!("config exists: {}", r.cfg.join("j/config.j").exists());
53+ for e in std::fs::read_dir(&r.dir).unwrap() {
54+ eprintln!(" entry: {:?}", e.unwrap().file_name());
55+ }
56+ }
57+ out.ok();
58+ r
59+}
60+
61+impl Repo {
62+ fn j(&self, args: &[&str]) -> Out {
63+ let out = Command::new(j_bin())
64+ .args(args)
65+ .current_dir(&self.dir)
66+ .env("XDG_CONFIG_HOME", &self.cfg)
67+ .env("NO_COLOR", "1")
68+ .output()
69+ .unwrap();
70+ Out {
71+ code: out.status.code().unwrap_or(-1),
72+ stdout: String::from_utf8_lossy(&out.stdout).to_string(),
73+ stderr: String::from_utf8_lossy(&out.stderr).to_string(),
74+ }
75+ }
76+
77+ fn j_stdin(&self, input: &str, args: &[&str]) -> Out {
78+ let mut child = Command::new(j_bin())
79+ .args(args)
80+ .current_dir(&self.dir)
81+ .env("XDG_CONFIG_HOME", &self.cfg)
82+ .env("NO_COLOR", "1")
83+ .stdin(Stdio::piped())
84+ .stdout(Stdio::piped())
85+ .stderr(Stdio::piped())
86+ .spawn()
87+ .unwrap();
88+ use std::io::Write;
89+ child
90+ .stdin
91+ .as_mut()
92+ .unwrap()
93+ .write_all(input.as_bytes())
94+ .unwrap();
95+ let out = child.wait_with_output().unwrap();
96+ Out {
97+ code: out.status.code().unwrap_or(-1),
98+ stdout: String::from_utf8_lossy(&out.stdout).to_string(),
99+ stderr: String::from_utf8_lossy(&out.stderr).to_string(),
100+ }
101+ }
102+
103+ fn write(&self, path: &str, content: &str) {
104+ std::fs::write(self.dir.join(path), content).unwrap();
105+ }
106+
107+ fn read(&self, path: &str) -> String {
108+ std::fs::read_to_string(self.dir.join(path)).unwrap()
109+ }
110+}
111+
112+struct Out {
113+ code: i32,
114+ stdout: String,
115+ stderr: String,
116+}
117+
118+impl Out {
119+ fn ok(self) -> Self {
120+ assert_eq!(self.code, 0, "stderr: {}", self.stderr);
121+ self
122+ }
123+}
124+
125+impl Drop for Repo {
126+ fn drop(&mut self) {
127+ let _ = std::fs::remove_dir_all(&self.dir);
128+ let _ = std::fs::remove_dir_all(&self.cfg);
129+ }
130+}
131+
132+#[test]
133+fn no_args_is_usage_error() {
134+ let r = setup();
135+ let out = r.j(&[]);
136+ assert_eq!(out.code, 2);
137+ assert!(out.stderr.contains("usage"), "{}", out.stderr);
138+}
139+
140+#[test]
141+fn both_stdin_and_args_rejected() {
142+ let r = setup();
143+ let out = r.j_stdin("1 + 1", &["extra"]);
144+ assert_eq!(out.code, 2);
145+ assert!(out.stderr.contains("both"), "{}", out.stderr);
146+}
147+
148+#[test]
149+fn expression_from_stdin() {
150+ let r = setup();
151+ let out = r.j_stdin("1 + 2 * 3", &[]).ok();
152+ assert_eq!(out.stdout.trim(), "7");
153+}
154+
155+#[test]
156+fn arguments_joined_verbatim() {
157+ let r = setup();
158+ let out = r.j(&["1", "+", "2"]).ok();
159+ assert_eq!(out.stdout.trim(), "3");
160+}
161+
162+#[test]
163+fn parse_error_is_exit_3() {
164+ let r = setup();
165+ let out = r.j(&["1 +"]);
166+ assert_eq!(out.code, 3);
167+ assert!(out.stderr.starts_with("j: "), "{}", out.stderr);
168+}
169+
170+#[test]
171+fn crash_is_exit_1_with_trace() {
172+ let r = setup();
173+ let out = r.j(&["crash \"boom\""]);
174+ assert_eq!(out.code, 1);
175+ assert!(out.stderr.contains("j: crash: boom"), "{}", out.stderr);
176+ assert!(out.stderr.contains("from crash \"boom\""), "{}", out.stderr);
177+}
178+
179+#[test]
180+fn full_edit_flow() {
181+ let r = setup();
182+ r.write("a.txt", "one\n");
183+ r.j(&["describe \"first\""]).ok();
184+ r.j(&["new"]).ok();
185+ r.write("b.txt", "two\n");
186+ r.j(&["describe \"second\""]).ok();
187+ let out = r.j(&["status"]).ok();
188+ assert!(out.stdout.contains("second"), "{}", out.stdout);
189+ assert!(out.stdout.contains("b.txt"), "{}", out.stdout);
190+ r.j(&["prev"]).ok();
191+ let out = r.j(&["status"]).ok();
192+ assert!(out.stdout.contains("first"), "{}", out.stdout);
193+ r.j(&["next"]).ok();
194+ let out = r.j(&["here"]).ok();
195+ assert!(!out.stdout.trim().is_empty());
196+}
197+
198+#[test]
199+fn gitignore_is_honored() {
200+ // §1.2: the snapshot tracks every file not matched by .gitignore
201+ let r = setup();
202+ r.write(".gitignore", "*.log\n");
203+ r.j(&["new"]).ok();
204+ r.write("secret.log", "ignored\n");
205+ r.write("normal.txt", "tracked\n");
206+ r.j(&["id"]).ok();
207+ let out = r.j(&["status"]).ok();
208+ assert!(out.stdout.contains("normal.txt"), "{}", out.stdout);
209+ assert!(!out.stdout.contains("secret.log"), "{}", out.stdout);
210+}
211+
212+#[test]
213+fn dry_run_persists_nothing() {
214+ let r = setup();
215+ r.write("a.txt", "x\n");
216+ r.j(&["describe \"work\""]).ok();
217+ r.j(&["new"]).ok();
218+ r.write("b.txt", "y\n");
219+ let before = r.j(&["log"]).ok().stdout;
220+ let out = r.j(&["tree . squash"]).ok();
221+ assert!(!out.stdout.is_empty());
222+ let after = r.j(&["log"]).ok().stdout;
223+ assert_eq!(before, after, "dry run changed the repository");
224+}
225+
226+#[test]
227+fn validate_catches_what_persistence_refuses() {
228+ let r = setup();
229+ r.write("a.txt", "x\n");
230+ r.j(&["describe \"work\""]).ok();
231+ // tree . validate . squash is an honest dry run
232+ let out = r.j(&["tree . validate . squash"]).ok();
233+ assert!(!out.stdout.is_empty());
234+}
235+
236+#[test]
237+fn undo_and_redo() {
238+ let r = setup();
239+ r.write("a.txt", "x\n");
240+ r.j(&["describe \"one\""]).ok();
241+ let before = r.j(&["status"]).ok().stdout;
242+ assert!(before.contains("one"));
243+ r.j(&["undo"]).ok();
244+ let after = r.j(&["status"]).ok().stdout;
245+ assert!(!after.contains("one"), "{}", after);
246+ r.j(&["redo"]).ok();
247+ let restored = r.j(&["status"]).ok().stdout;
248+ assert!(restored.contains("one"), "{}", restored);
249+}
250+
251+#[test]
252+fn undo_refuses_dirty_working_copy() {
253+ let r = setup();
254+ r.write("a.txt", "x\n");
255+ r.j(&["id"]).ok();
256+ r.write("a.txt", "dirty\n");
257+ let out = r.j(&["undo"]);
258+ assert_eq!(out.code, 1);
259+ assert!(out.stderr.contains("working copy has changes"), "{}", out.stderr);
260+}
261+
262+#[test]
263+fn nothing_to_undo_or_redo() {
264+ let r = setup();
265+ let out = r.j(&["redo"]);
266+ assert_eq!(out.code, 1);
267+ assert!(out.stderr.contains("nothing to redo"), "{}", out.stderr);
268+}
269+
270+#[test]
271+fn undo_after_init_refuses_cleanly() {
272+ // undoing the operation that established the working copy would restore a
273+ // view with no checked-out commit, leaving the repository unusable; it is
274+ // reported as "nothing to undo", not a confusing error
275+ let r = setup();
276+ let out = r.j(&["undo"]);
277+ assert_eq!(out.code, 1);
278+ assert!(out.stderr.contains("nothing to undo"), "{}", out.stderr);
279+ // and the repository is still usable afterwards
280+ r.write("a.txt", "x\n");
281+ r.j(&["describe \"still works\""]).ok();
282+ assert!(r.j(&["status"]).ok().stdout.contains("still works"));
283+}
284+
285+#[test]
286+fn ops_lists_operations() {
287+ let r = setup();
288+ r.write("a.txt", "x\n");
289+ r.j(&["describe \"marker\""]).ok();
290+ let out = r.j(&["ops"]).ok();
291+ assert!(out.stdout.contains("describe \"marker\""), "{}", out.stdout);
292+ // the current operation is marked
293+ assert!(out.stdout.contains('*'), "{}", out.stdout);
294+ // one line per operation, newest first
295+ assert!(out.stdout.lines().count() >= 3, "{}", out.stdout);
296+}
297+
298+#[test]
299+fn squash_and_abandon() {
300+ let r = setup();
301+ r.write("a.txt", "one\n");
302+ r.j(&["describe \"base\""]).ok();
303+ r.j(&["new"]).ok();
304+ r.write("a.txt", "two\n");
305+ r.j(&["describe \"fold me\""]).ok();
306+ r.j(&["squash"]).ok();
307+ let out = r.j(&["log"]).ok().stdout;
308+ assert!(!out.contains("fold me"), "{}", out);
309+ assert_eq!(r.read("a.txt"), "two\n");
310+}
311+
312+#[test]
313+fn rebase_onto_sibling() {
314+ let r = setup();
315+ r.write("f.txt", "base\n");
316+ r.j(&["describe \"base\""]).ok();
317+ // branch from the parent of the focus
318+ r.j(&["new"]).ok();
319+ r.write("f.txt", "side\n");
320+ r.j(&["describe \"side\""]).ok();
321+ let out = r.j(&["tree"]).ok();
322+ assert!(out.stdout.contains("side"), "{}", out.stdout);
323+}
324+
325+#[test]
326+fn lock_blocks_second_j() {
327+ // hold the flock ourselves, then a second j is refused
328+ use std::os::unix::io::AsRawFd;
329+ let r = setup();
330+ let lock = std::fs::OpenOptions::new()
331+ .create(true)
332+ .write(true)
333+ .open(r.dir.join(".jj/j.lock"))
334+ .unwrap();
335+ let rc = unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
336+ assert_eq!(rc, 0);
337+ let out = r.j(&["tree"]);
338+ assert_eq!(out.code, 2);
339+ assert!(out.stderr.contains("another j is running"), "{}", out.stderr);
340+ unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_UN) };
341+ r.j(&["tree"]).ok();
342+}
343+
344+#[test]
345+fn reserved_words_as_first_word() {
346+ let r = setup();
347+ // `(push)` is an ordinary expression (unbound name)
348+ let out = r.j(&["(push)"]);
349+ assert_eq!(out.code, 1);
350+ assert!(out.stderr.contains("unbound"), "{}", out.stderr);
351+ // `x fetch` too
352+ let out = r.j(&["x fetch"]);
353+ assert_eq!(out.code, 1);
354+}
355+
356+#[test]
357+fn id_resolution_errors() {
358+ let r = setup();
359+ let out = r.j(&["by @nope"]);
360+ assert_eq!(out.code, 3); // lexical error: @ followed by non-k..z
361+ let out = r.j(&["by @kqzz"]);
362+ assert_eq!(out.code, 1); // no such commit: crash
363+ assert!(out.stderr.contains("matches no commit"), "{}", out.stderr);
364+}
365+
366+#[test]
367+fn goto_trunk_requires_new() {
368+ let r = setup();
369+ r.write("a", "1\n");
370+ r.j(&["describe \"x\""]).ok();
371+ // focusing the root commit is refused
372+ let out = r.j(&["by @zzzzzzzz"]);
373+ assert_eq!(out.code, 1);
374+ assert!(out.stderr.contains("mutable"), "{}", out.stderr);
375+}
376+
377+#[test]
378+fn text_result_prints_raw() {
379+ let r = setup();
380+ let out = r.j(&["\"literal text\""]).ok();
381+ assert_eq!(out.stdout, "literal text\n");
382+ let out = r.j(&["show [1 \"two\"]"]).ok();
383+ assert!(out.stdout.contains("[1 \"two\"]"), "{}", out.stdout);
384+}
385+
386+#[test]
387+fn crash_leaves_repo_untouched() {
388+ let r = setup();
389+ r.write("a.txt", "x\n");
390+ r.j(&["describe \"stable\""]).ok();
391+ let before = r.j(&["log"]).ok().stdout;
392+ r.write("b.txt", "y\n");
393+ let out = r.j(&["describe \"x\" . crash \"no\" . describe \"y\""]);
394+ assert_eq!(out.code, 1);
395+ let after = r.j(&["log"]).ok().stdout;
396+ assert_eq!(before, after);
397+ // the working directory is untouched too
398+ assert_eq!(r.read("b.txt"), "y\n");
399+}
400+
401+#[test]
402+fn split_and_contract_paths() {
403+ let r = setup();
404+ r.write("code.rs", "fn main() {}\n");
405+ r.write("doc.md", "# doc\n");
406+ r.j(&["describe \"both\""]).ok();
407+ r.j(&["split (ext \"rs\")"]).ok();
408+ let out = r.j(&["tree"]).ok();
409+ assert!(out.stdout.contains("both"), "{}", out.stdout);
410+}
411+
412+#[test]
413+fn label_literal_empty_without_remote() {
414+ let r = setup();
415+ let out = r.j(&["labelled \"main\""]).ok();
416+ assert!(out.stdout.contains("none") || out.stdout.contains("0"), "{}", out.stdout);
417+ let out = r.j(&["trunk"]).ok();
418+ assert!(out.stdout.contains("none") || out.stdout.contains("0"), "{}", out.stdout);
419+}
420+
421+#[test]
422+fn interrupted_expression_persists_nothing() {
423+ let r = setup();
424+ r.write("a.txt", "x\n");
425+ r.j(&["describe \"stable\""]).ok();
426+ // a non-terminating expression; kill it quickly
427+ let mut child = Command::new(j_bin())
428+ .arg("let go = \\n -> go (n + 1) in go 0")
429+ .current_dir(&r.dir)
430+ .env("XDG_CONFIG_HOME", &r.cfg)
431+ .env("NO_COLOR", "1")
432+ .stdout(Stdio::null())
433+ .stderr(Stdio::null())
434+ .spawn()
435+ .unwrap();
436+ std::thread::sleep(std::time::Duration::from_millis(500));
437+ child.kill().unwrap();
438+ let _ = child.wait();
439+ // lock released, repo intact
440+ let out = r.j(&["status"]).ok();
441+ assert!(out.stdout.contains("stable"));
442+}
443+
444+#[test]
445+fn init_refuses_inside_existing_repo() {
446+ let r = setup();
447+ let out = r.j(&["init"]);
448+ assert_eq!(out.code, 2);
449+ assert!(out.stderr.contains("already"), "{}", out.stderr);
450+}
451+
452+#[test]
453+fn missing_config_is_exit_3() {
454+ let dir = std::env::temp_dir().join(format!(
455+ "j-cli-nocfg-{}-{}",
456+ std::process::id(),
457+ std::time::SystemTime::now()
458+ .duration_since(std::time::UNIX_EPOCH)
459+ .unwrap()
460+ .as_nanos()
461+ ));
462+ let _ = std::fs::remove_dir_all(&dir);
463+ std::fs::create_dir_all(&dir).unwrap();
464+ let out = Command::new(j_bin())
465+ .arg("status")
466+ .current_dir(&dir)
467+ .env("XDG_CONFIG_HOME", dir.join("nonexistent"))
468+ .output()
469+ .unwrap();
470+ assert_eq!(out.status.code(), Some(3));
471+ let stderr = String::from_utf8_lossy(&out.stderr);
472+ assert!(stderr.contains("no config"), "{}", stderr);
473+ let _ = std::fs::remove_dir_all(&dir);
474+}
tests/core.rsnew file+176
@@ -0,0 +1,176 @@
1+//! Smoke tests for the interpreter core (no repository needed).
2+
3+use j::config;
4+use j::domain::MemBackend;
5+use j::eval::Interp;
6+use j::parse::parse_expr;
7+use j::value::{value_eq, Env, Value};
8+use std::rc::Rc;
9+
10+const CONFIG: &str = include_str!("../config.j");
11+
12+fn make_interp() -> (Interp, config::Config) {
13+ let cfg = config::load_config(CONFIG).expect("reference config must load");
14+ let backend = Rc::new(MemBackend::new());
15+ let mut interp = Interp::new(backend, cfg.shapes.clone(), Env::empty());
16+ config::eval_config(&mut interp, &cfg).expect("reference config must evaluate");
17+ (interp, cfg)
18+}
19+
20+fn eval_str(interp: &mut Interp, cfg: &config::Config, src: &str) -> Result<Value, String> {
21+ let outer = Rc::new(cfg.global_names.clone());
22+ let e = parse_expr(src, outer).map_err(|p| format!("parse: {}", p.msg))?;
23+ let e = config::resolve_ids(&e, interp).map_err(|_| "id resolution".to_string())?;
24+ let env = interp.global_env();
25+ interp
26+ .eval(&Rc::new(e), &env)
27+ .map_err(|c| format!("crash: {}", c.msg))
28+}
29+
30+fn show_of(i: &Interp, v: &Value) -> String {
31+ j::show::show(i, v)
32+}
33+
34+#[track_caller]
35+fn assert_val(i: &Interp, got: Value, want: Value) {
36+ assert!(
37+ value_eq(&got, &want).unwrap_or(false),
38+ "values differ:\n got: {}\n want: {}",
39+ show_of(i, &got),
40+ show_of(i, &want)
41+ );
42+}
43+
44+macro_rules! check {
45+ ($i:expr, $cfg:expr, $src:expr, $want:expr) => {{
46+ let got = eval_str(&mut $i, &$cfg, $src).unwrap();
47+ assert_val(&$i, got, $want);
48+ }};
49+}
50+
51+#[test]
52+fn reference_config_parses_and_loads() {
53+ make_interp();
54+}
55+
56+#[test]
57+fn arithmetic_and_lists() {
58+ let (mut i, cfg) = make_interp();
59+ check!(i, cfg, "1 + 2 * 3", Value::int(7));
60+ check!(
61+ i,
62+ cfg,
63+ "map (\\x -> x * 2) [1 2 3]",
64+ Value::list(vec![Value::int(2), Value::int(4), Value::int(6)])
65+ );
66+ check!(i, cfg, "foldl (+) 0 (range 0 10)", Value::int(45));
67+}
68+
69+#[test]
70+fn text_builtins() {
71+ let (mut i, cfg) = make_interp();
72+ check!(
73+ i,
74+ cfg,
75+ r#"splitOn "/" "a/b/c""#,
76+ Value::list(vec![Value::text("a"), Value::text("b"), Value::text("c")])
77+ );
78+ check!(i, cfg, r#""ab" ++ "cd""#, Value::text("abcd"));
79+}
80+
81+#[test]
82+fn or_catches_crashes() {
83+ let (mut i, cfg) = make_interp();
84+ check!(i, cfg, "head [] or 42", Value::int(42));
85+ check!(i, cfg, r#"crash "boom" or 7"#, Value::int(7));
86+ check!(i, cfg, "1 or 2", Value::int(1));
87+}
88+
89+#[test]
90+fn sections_and_composition() {
91+ let (mut i, cfg) = make_interp();
92+ check!(
93+ i,
94+ cfg,
95+ "map (1 +) [1 2]",
96+ Value::list(vec![Value::int(2), Value::int(3)])
97+ );
98+ check!(
99+ i,
100+ cfg,
101+ "map (+ 1) [1 2]",
102+ Value::list(vec![Value::int(2), Value::int(3)])
103+ );
104+ check!(i, cfg, "(not . null) [1]", Value::Bool(true));
105+}
106+
107+#[test]
108+fn show_roundtrip() {
109+ let (mut i, cfg) = make_interp();
110+ let v = eval_str(
111+ &mut i,
112+ &cfg,
113+ r#"{ a = [1 2], b = "x\n", c = { d = true } }"#,
114+ )
115+ .unwrap();
116+ let s = eval_str(&mut i, &cfg, "show ({ a = [1 2], b = \"x\\n\", c = { d = true } })").unwrap();
117+ let s = s.as_text().unwrap().to_string();
118+ let outer = Rc::new(cfg.global_names.clone());
119+ let e = parse_expr(&s, outer).expect("show output must parse");
120+ let env = i.global_env();
121+ let v2 = i.eval(&Rc::new(e), &env).expect("show output must evaluate");
122+ assert!(
123+ value_eq(&v, &v2).unwrap(),
124+ "show roundtrip failed for {}",
125+ s
126+ );
127+}
128+
129+#[test]
130+fn deep_recursion_does_not_overflow() {
131+ let (mut i, cfg) = make_interp();
132+ let r = eval_str(
133+ &mut i,
134+ &cfg,
135+ "let go = \\n acc -> if n == 0 then acc else go (n - 1) (acc + n) in go 20000 0",
136+ )
137+ .unwrap();
138+ assert_val(&i, r, Value::int(20000 * 20001 / 2));
139+}
140+
141+#[test]
142+fn contracts_are_enforced() {
143+ let (mut i, cfg) = make_interp();
144+ let r = eval_str(&mut i, &cfg, "describe 3");
145+ let msg = match r {
146+ Ok(_) => panic!("describe 3 must be a contract crash"),
147+ Err(m) => m,
148+ };
149+ assert!(msg.contains("contract"), "got: {}", msg);
150+}
151+
152+#[test]
153+fn selector_and_update() {
154+ let (mut i, cfg) = make_interp();
155+ check!(
156+ i,
157+ cfg,
158+ "map (.path) [{ path = [\"a\"], content = blob \"x\" }]",
159+ Value::list(vec![Value::list(vec![Value::text("a")])])
160+ );
161+ let got = eval_str(&mut i, &cfg, "({ a = 1, b = 2 }) { a = 9 }").unwrap();
162+ let want = eval_str(&mut i, &cfg, "{ a = 9, b = 2 }").unwrap();
163+ assert_val(&i, got, want);
164+}
165+
166+#[test]
167+fn extract_works() {
168+ let (mut i, cfg) = make_interp();
169+ let got = eval_str(
170+ &mut i,
171+ &cfg,
172+ "length (extract Commit ({ root = { files = [], message = \"\", labels = [], id = @ }, children = [] }))",
173+ )
174+ .unwrap();
175+ assert_val(&i, got, Value::int(1));
176+}
tests/display.rsnew file+280
@@ -0,0 +1,280 @@
1+//! Display tests (§5.1 examples) over the in-memory backend.
2+
3+use j::config;
4+use j::domain::{MemBackend, MetaInfo, ROOT_ID};
5+use j::eval::Interp;
6+use j::parse::parse_expr;
7+use j::value::{BlobVal, Env, Value};
8+use std::rc::Rc;
9+
10+const CONFIG: &str = include_str!("../config.j");
11+
12+fn meta(id: &str, author: &str, time: i64) -> (String, MetaInfo) {
13+ (
14+ id.to_string(),
15+ MetaInfo {
16+ hash: format!("h{}", id),
17+ author: author.into(),
18+ email: "a@x".into(),
19+ time,
20+ },
21+ )
22+}
23+
24+fn commit(id: &str, msg: &str, labels: &[&str], files: Vec<(&str, &str)>) -> Value {
25+ let files = Value::list(
26+ files
27+ .into_iter()
28+ .map(|(p, c)| {
29+ Value::record(&[
30+ ("content", BlobVal::text_blob(c)),
31+ ("path", Value::list(p.split('/').map(Value::text).collect())),
32+ ])
33+ })
34+ .collect(),
35+ );
36+ Value::record(&[
37+ ("files", files),
38+ ("message", Value::text(msg)),
39+ (
40+ "labels",
41+ Value::list(labels.iter().map(|l| Value::text(*l)).collect()),
42+ ),
43+ ("id", Value::Id(Rc::new(id.to_string()))),
44+ ])
45+}
46+
47+/// a linear repo root -> a -> b (focus on b)
48+fn sample_repo() -> (Value, MemBackend) {
49+ let root = commit(ROOT_ID, "", &[], vec![]);
50+ let a = commit("kpqxaaaa", "add parser", &["main"], vec![("src/parser.rs", "fn parse() {}\n")]);
51+ let b = commit(
52+ "wqztbbbb",
53+ "wip",
54+ &["feature"],
55+ vec![
56+ ("src/parser.rs", "fn parse() { todo!() }\n"),
57+ ("src/lexer.rs", "fn lex() {}\n"),
58+ ],
59+ );
60+ let frame_a = Value::record(&[
61+ ("left", Value::list(vec![])),
62+ ("parent", a.clone()),
63+ ("right", Value::list(vec![])),
64+ ]);
65+ let frame_root = Value::record(&[
66+ ("left", Value::list(vec![])),
67+ ("parent", root.clone()),
68+ ("right", Value::list(vec![])),
69+ ]);
70+ let repo = Value::record(&[
71+ ("children", Value::list(vec![])),
72+ ("context", Value::list(vec![frame_a, frame_root])),
73+ ("root", b),
74+ ]);
75+ let mut be = MemBackend::new();
76+ for (id, m) in [
77+ meta(ROOT_ID, "Root", 1_700_000_000),
78+ meta("kpqxaaaa", "Montelot", 1_700_000_100),
79+ meta("wqztbbbb", "Montelot", 1_700_000_200),
80+ ] {
81+ be.metas.insert(id, m);
82+ }
83+ be.parents.insert("kpqxaaaa".into(), vec![ROOT_ID.into()]);
84+ be.parents.insert("wqztbbbb".into(), vec!["kpqxaaaa".into()]);
85+ (repo, be)
86+}
87+
88+fn make_interp(be: MemBackend) -> (Interp, config::Config) {
89+ let cfg = config::load_config(CONFIG).unwrap();
90+ let mut i = Interp::new(Rc::new(be), cfg.shapes.clone(), Env::empty());
91+ config::eval_config(&mut i, &cfg).unwrap();
92+ (i, cfg)
93+}
94+
95+fn eval_and_display(interp: &mut Interp, cfg: &config::Config, src: &str, repo: Value) -> String {
96+ let outer = Rc::new(cfg.global_names.clone());
97+ let e = parse_expr(src, outer).unwrap();
98+ let e = config::resolve_ids(&e, interp).unwrap();
99+ *interp.old_repo.borrow_mut() = Some(repo.clone());
100+ let env = interp.global_env();
101+ let v = interp.eval(&Rc::new(e), &env).unwrap();
102+ let v = if matches!(v, Value::Fun(_)) {
103+ interp.apply(v, repo).unwrap()
104+ } else {
105+ v
106+ };
107+ j::render::display(interp, &v, false).unwrap()
108+}
109+
110+#[test]
111+fn status_example() {
112+ let (repo, be) = sample_repo();
113+ let (mut i, cfg) = make_interp(be);
114+ let out = eval_and_display(&mut i, &cfg, "status", repo);
115+ assert!(out.contains("src/lexer.rs"), "{}", out);
116+ assert!(out.contains("src/parser.rs"), "{}", out);
117+ assert!(out.contains("feature"), "{}", out);
118+ assert!(out.contains("wip"), "{}", out);
119+ assert!(out.contains("wqzt"), "{}", out);
120+}
121+
122+#[test]
123+fn log_example() {
124+ let (repo, be) = sample_repo();
125+ let (mut i, cfg) = make_interp(be);
126+ let out = eval_and_display(&mut i, &cfg, "log", repo);
127+ assert!(out.contains("add parser"), "{}", out);
128+ assert!(out.contains("wip"), "{}", out);
129+ assert!(out.contains("✓"), "{}", out);
130+}
131+
132+#[test]
133+fn files_example() {
134+ let (repo, be) = sample_repo();
135+ let (mut i, cfg) = make_interp(be);
136+ let out = eval_and_display(&mut i, &cfg, "files", repo);
137+ assert!(out.contains("src/lexer.rs"), "{}", out);
138+ assert!(out.contains("B"), "{}", out); // sizes
139+}
140+
141+#[test]
142+fn clean_example() {
143+ let (repo, be) = sample_repo();
144+ let (mut i, cfg) = make_interp(be);
145+ let out = eval_and_display(&mut i, &cfg, "clean", repo);
146+ assert_eq!(out.trim(), "true");
147+}
148+
149+#[test]
150+fn tree_example() {
151+ let (repo, be) = sample_repo();
152+ let (mut i, cfg) = make_interp(be);
153+ let out = eval_and_display(&mut i, &cfg, "tree", repo);
154+ assert!(out.contains("⌂"), "{}", out);
155+ assert!(out.contains("add parser"), "{}", out);
156+ assert!(out.contains("wip"), "{}", out);
157+ assert!(out.contains("▶"), "{}", out);
158+ assert!(out.contains("◉"), "{}", out);
159+ assert!(out.contains("feature"), "{}", out);
160+ assert!(out.contains("main"), "{}", out);
161+}
162+
163+#[test]
164+fn here_and_kids() {
165+ let (repo, be) = sample_repo();
166+ let (mut i, cfg) = make_interp(be);
167+ let out = eval_and_display(&mut i, &cfg, "here", repo.clone());
168+ assert!(out.contains("wqzt"), "{}", out);
169+ let out = eval_and_display(&mut i, &cfg, "kids", repo);
170+ assert!(out.contains("none"), "{}", out);
171+}
172+
173+#[test]
174+fn map_path_files() {
175+ let (repo, be) = sample_repo();
176+ let (mut i, cfg) = make_interp(be);
177+ let out = eval_and_display(&mut i, &cfg, "map (.path) . files", repo);
178+ assert!(out.contains("src/lexer.rs"), "{}", out);
179+ assert!(out.contains("src/parser.rs"), "{}", out);
180+}
181+
182+#[test]
183+fn text_of_blob() {
184+ let (repo, be) = sample_repo();
185+ let (mut i, cfg) = make_interp(be);
186+ let out = eval_and_display(&mut i, &cfg, "text . contentAt ./src/lexer.rs . files", repo);
187+ assert_eq!(out, "fn lex() {}\n");
188+}
189+
190+#[test]
191+fn dry_run_tree_after_squash() {
192+ let (repo, be) = sample_repo();
193+ // strip labels so abandon is allowed
194+ let strip = |c: Value| -> Value {
195+ Value::record(&[
196+ ("files", c.field("files").unwrap()),
197+ ("id", c.field("id").unwrap()),
198+ ("labels", Value::list(vec![])),
199+ ("message", c.field("message").unwrap()),
200+ ])
201+ };
202+ let root = strip(repo.field("root").unwrap());
203+ let ctx: Vec<Value> = repo
204+ .field("context")
205+ .unwrap()
206+ .as_list()
207+ .unwrap()
208+ .iter()
209+ .map(|f| {
210+ Value::record(&[
211+ ("left", f.field("left").unwrap()),
212+ ("parent", strip(f.field("parent").unwrap())),
213+ ("right", f.field("right").unwrap()),
214+ ])
215+ })
216+ .collect();
217+ let repo = Value::record(&[
218+ ("children", repo.field("children").unwrap()),
219+ ("context", Value::list(ctx)),
220+ ("root", root),
221+ ]);
222+ let (mut i, cfg) = make_interp(be);
223+ // tree . squash is a Text: shows the result, persists nothing
224+ // squash folds the focus ("wip") into its parent; the parent's message
225+ // survives and the tree renders the result without persisting
226+ let out = eval_and_display(&mut i, &cfg, "tree . squash", repo);
227+ assert!(out.contains("add parser"), "{}", out);
228+ assert!(!out.contains("wip
229+"), "{}", out);
230+ // after a squash the focus is a new (minted) commit
231+ assert!(out.contains("▶"), "{}", out);
232+}
233+
234+#[test]
235+fn show_of_focus() {
236+ let (repo, be) = sample_repo();
237+ let (mut i, cfg) = make_interp(be);
238+ let out = eval_and_display(&mut i, &cfg, "show . focus", repo);
239+ assert!(out.contains("message = \"wip\""), "{}", out);
240+ assert!(out.contains("@wqztbbbb"), "{}", out);
241+}
242+
243+#[test]
244+fn conflicts_table() {
245+ // a repo where the focus has an unresolved file
246+ let (repo, be) = sample_repo();
247+ // add a conflicted file to the focus
248+ let root = repo.field("root").unwrap();
249+ let mut files: Vec<Value> = root.field("files").unwrap().as_list().unwrap().to_vec();
250+ files.push(Value::record(&[
251+ (
252+ "content",
253+ Value::Blob(Rc::new(j::value::BlobVal {
254+ kind: j::value::BlobKind::Regular,
255+ content: j::value::BlobContent::Conflict(vec![
256+ Rc::new(b"ours\n".to_vec()),
257+ Rc::new(b"base\n".to_vec()),
258+ Rc::new(b"theirs\n".to_vec()),
259+ ]),
260+ })),
261+ ),
262+ ("path", Value::list(vec![Value::text("src/conflicted.rs")])),
263+ ]));
264+ let root2 = Value::record(&[
265+ ("files", Value::list(files)),
266+ ("message", root.field("message").unwrap()),
267+ ("labels", root.field("labels").unwrap()),
268+ ("id", root.field("id").unwrap()),
269+ ]);
270+ let repo2 = Value::record(&[
271+ ("children", repo.field("children").unwrap()),
272+ ("context", repo.field("context").unwrap()),
273+ ("root", root2),
274+ ]);
275+ let (mut i, cfg) = make_interp(be);
276+ let out = eval_and_display(&mut i, &cfg, "conflicts", repo2);
277+ assert!(out.contains("wqzt"), "{}", out);
278+ assert!(out.contains("⊗"), "{}", out);
279+ assert!(out.contains("wip"), "{}", out);
280+}
tests/domain.rsnew file+309

This diff is too big to show here.

tests/eval.rsnew file+341

This diff is too big to show here.

tests/interop.rsnew file+196

This diff is too big to show here.

tests/laws.proptest-regressionsnew file+14

This diff is too big to show here.

tests/laws.rsnew file+503

This diff is too big to show here.

tests/lex.rsnew file+243

This diff is too big to show here.

tests/more.rsnew file+422

This diff is too big to show here.

tests/parse.rsnew file+260

This diff is too big to show here.

tests/remote.rsnew file+282

This diff is too big to show here.

tests/show.rsnew file+317

This diff is too big to show here.

tests/showprop.proptest-regressionsnew file+10

This diff is too big to show here.

tests/showprop.rsnew file+74

This diff is too big to show here.

tests/tree.rsnew file+350

This diff is too big to show here.

tests/validation.rsnew file+355

This diff is too big to show here.