on master help sign in
58 files changed+27339−2844
since splitsnapshots
unifiedsplit
CLAUDE.md+71 README.md+185−37 config.j+107−22 docs/base.md+187−46 docs/comparison.md+25−9 docs/language.md+91−25 docs/tutorial.md+102−36 specs/main.md+948−198 specs/tree.md+94−50 src/ast.rs+35−2 src/builtins.rs+134−88 src/config.rs+370−54 src/diagnostics.rs+381 src/domain.rs+154−19 src/eval.rs+845−304 src/help.rs+668 src/jj.rs+3176−472 src/lex.rs+36−22 src/lib.rs+14 src/lookup.rs+78 src/main.rs+465−141 src/parse.rs+346−162 src/render.rs+1737−589 src/repo.rs+499−69 src/setup.rs+119 src/shape.rs+104−60 src/show.rs+362−137 src/terminal.rs+276 src/value.rs+700−89 tests/base.rs+9−2 tests/builtins.rs+102−6 tests/cli.rs+4446−67 tests/core.rs+1−1 tests/difft.rs+74 tests/display.rs+974−5 tests/domain.rs+111−1 tests/env_lifetimes.rs+215 tests/eval.rs+1072−9 tests/help.rs+326 tests/interop.rs+173−10 tests/laws.proptest-regressions+1 tests/laws.rs+258−6 tests/lex.rs+24 tests/more.rs+322−1 tests/onboarding.rs+200 tests/onboarding_vocab.rs+153 tests/parse.rs+314 tests/performance_lookup.rs+255 tests/remote.rs+2336−2 tests/review_fallback.rs+110 tests/setup.rs+144 tests/show.rs+587−25 tests/showprop.proptest-regressions+1 tests/showprop.rs+130−5 tests/terminal.rs+621 tests/tree.rs+1150−65 tests/ux_cli.rs+105 tests/validation.rs+816−8
CLAUDE.mdnew file+71
@@ -0,0 +1,71 @@
1+# CLAUDE.md
2+
3+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+## What this is
6+
7+`j` is a CLI for Jujutsu (jj) repositories whose interface is a small, pure, dynamically typed functional language. A repository is a value (`Repo`, a zipper over the commit tree), a command is a function `Repo -> Repo`, and the whole user-facing vocabulary (`new`, `describe`, `squash`, `tree`, …) is written *in the language* in `config.j`. The Rust binary supplies only the language, the builtins `config.j` declares, eleven reserved commands, and the bridge to jj's on-disk format via `jj-lib` (no `jj` binary needed at runtime).
8+
9+## Commands
10+
11+```
12+cargo build # debug build -> target/debug/j
13+cargo test # whole suite, ~20s once compiled
14+cargo test --test tree # one test file (tests/tree.rs)
15+cargo test --test cli squash # tests in one file matching a name
16+nix build # release build + difftastic wrapper -> ./result/bin/j
17+```
18+
19+There is no devShell in `flake.nix`; `cargo`/`rustc` come from the system. The Nix build sets `doCheck = false` because the tests shell out to `git` and need a writable temp dir, so `cargo test` is the only place tests run.
20+
21+- `tests/cli.rs`, `tests/remote.rs`, `tests/interop.rs` run the built binary (`CARGO_BIN_EXE_j`) against real repos in temp dirs, each with its own `XDG_CONFIG_HOME`. They need `git` on `PATH`; `interop.rs` also uses the real `jj` binary and skips itself if it is absent.
22+- Everything else runs the interpreter over the in-memory `MemBackend` — no repository involved.
23+- `tests/laws.rs` and `tests/showprop.rs` are proptest suites; their `*.proptest-regressions` files are checked in on purpose.
24+- `difft` optionally enhances `review`/`difft`; an absent executable falls back to unified diffs with path headers (binary summaries for non-text content). Other launch errors still fail. `J_GIT` overrides the `git` executable used for fetch.
25+
26+## The spec is normative
27+
28+`specs/main.md` is the specification the implementation was written from, and the code cites it everywhere as `§N.M` (e.g. `§7.5` = persistence). When changing behaviour, find the section first, and keep code, spec, and tests agreeing. `specs/tree.md` is a newer, complete replacement for `§7.11` (the "rails" tree layout) and is what `render.rs` implements.
29+
30+`§9` of the spec is a verbatim copy of `config.j`; keep it synchronized when the reference vocabulary changes. **`config.j` is the source of truth** for runnable definitions, and `docs/base.md` documents the same vocabulary.
31+
32+## Architecture
33+
34+One run of `j EXPR` (`src/main.rs::run_expression`, mirroring spec §1.2): load + validate `config.j` → open the repo and take the lock → parse the expression → build the `Repo` value, snapshotting the working directory → resolve `@id` literals → evaluate the config's definitions, then the expression → if the result is a function, apply it to the repo **once** → if the final value is a `Repo`, persist only if it differs from the one loaded; display other values without persisting. Exit codes: 1 crash, 2 usage/no repo/locked, 3 config or parse error. All work happens on a thread with a 512 MB stack (`main`).
35+
36+Language pipeline, all in `src/`:
37+- `lex.rs` → `parse.rs` → `ast.rs`: the parser implements the two layout rules and needs the set of global names (`Config::global_names`) so expressions cannot shadow them.
38+- `eval.rs`: strict, call-by-value, **trampolined** CEK-style machine (`State`/`Cont`) so deep recursion in user code cannot overflow the native stack. Keep new evaluation paths on the trampoline rather than recursing in Rust.
39+- `value.rs`: `Value`. Two kinds of laziness exist purely for large-repo performance and must stay invisible to the language: `Value::Thunk` (a commit's `files` list, forced by `Value::field`/`value_eq`) and `BlobContent::Lazy` (file bytes; lazy blobs compare by content id without being read). Prefer comparing ids/tree hashes over forcing content.
40+ Recursive `let` environments can form ownership cycles through their closures. The cycle collector traces their shared values while preserving externally held values, and runs during scope allocation, after evaluation, and when an interpreter is dropped. Keep its ownership traversal in sync with changes to values and function wrappers; `tests/env_lifetimes.rs` covers reclamation and escaping closures.
41+- `shape.rs`: shapes from `config.j` type declarations, and runtime **contracts** compiled from signatures — checked at every call, one level deep (kind, list-ness, field names). Records are structural: a value *is* a `Commit` iff its field set matches.
42+- `builtins.rs`: native implementations of the names `config.j` declares without defining.
43+- `config.rs`: parses and validates `config.j`, orders definitions by dependency, resolves `@id` literals. Four definitions are read by the binary itself: `user`, `tree`, `immutable`, `labelled`.
44+
45+`help [NAME]` and `commands [QUERY]` are reserved discovery commands. `--help` and `-h` open help as the first word or as separate argv entries after a command, before evaluation; comments within a single expression argument or stdin retain language semantics. Discovery never opens a repository or evaluates config definitions. General help, reserved-command help, and `help config` work without loading config; the custom vocabulary reads config without writing it, using the embedded reference only when no file exists. Keep these paths usable outside a repository and when help is needed to recover from a broken config. Literal reserved-command arguments retain separate shell argument boundaries; a whole command supplied as one string or stdin is whitespace-split.
46+
47+Identity validation rejects blank and shipped placeholder values at init, clone, and commit-authoring boundaries. Inspections remain usable without a configured identity. Push never records the working directory: it warns about unrecorded edits before sending recorded content. Undo/redo refuse dirty working copies and explain that `j id` adds an operation before the previously intended undo. Interactive success messages report saved/no-change status and focus, setup identity, or the affected undo/redo operation; plain terminals keep confirmations, while redirected output and CI remain quiet on success.
48+
49+Repository side:
50+- `setup.rs` (binary): explicit first-run configuration, optionally with a supplied identity, using exclusive atomic publication. It never replaces an existing config or opens a repository.
51+- `domain.rs`: the `Backend` trait — everything repository-shaped the interpreter needs — plus `MemBackend` (the test double required by spec §10) and snapshot helpers shared by both backends.
52+- `jj.rs`: `JjBackend` on `jj-lib`/`gix`: locating the repo, building the `Repo` value, `persist`, the lock, and the reserved commands (`init`, `clone`, `remote`, `fetch`, `push`, `undo`, `redo`, `ops`). Reserved commands are matched on the first word in `main.rs` before any parsing and are not part of the language.
53+- `repo.rs`: zipper navigation (`by`) and the persistence validation shared by the `validate` builtin and `JjBackend::persist`, which is what makes `tree . validate . EDIT` an honest dry run.
54+- `render.rs`: value display (§5.1) and tree rendering. `show.rs`: `show` (must round-trip through the parser — property-tested), unified diff, and the `difft` subprocess bridge.
55+
56+The reference `save` is `id`; `preview edit` is `tree . validate . edit`. Validation alone still returns a `Repo` and can persist changes. `unrecorded : Repo -> [Path]` is a native inspection of the invocation's loaded/snapshotted files, independent of later expression edits; status exposes it alongside `changed` (displayed as `in commit`). Help's default catalog shows everyday tasks and a few custom definitions; `commands all` shows every configured name, and queries recognize common Git/task synonyms without presenting unavailable config names as commands. A push with no selected bookmarks records no operation and reports that nothing was published; nonempty pushes report published/deleted/unchanged counts interactively. Saved edits report conflict recovery guidance even when output is redirected.
57+
58+### `config.j` is code, and it is embedded
59+
60+`config.j` is `include_str!`'d into the binary (written to `~/.config/j/config.j` only when no user config exists) and into most test files, so editing it rebuilds both and every test exercises the reference config unmodified. An existing user config is never updated: a change that the binary depends on (a new builtin declaration, a new required `treeWith` option) breaks existing user configs until they are edited by hand.
61+
62+Adding a builtin touches three places that must agree:
63+1. `all_builtins()` in `src/builtins.rs` — name, arity, implementation.
64+2. `RESERVED_BUILTINS` in `src/config.rs` — otherwise validation rejects the declaration as "declared but is not a builtin and has no definition".
65+3. A signature-only declaration in `config.j` (`name : Type`); the signature becomes the builtin's contract.
66+
67+Prefer defining things in `config.j` in the language; add a native builtin only when it needs the backend or the in-language version is too slow (see `touchedPaths`).
68+
69+## Tests
70+
71+Interpreter-level tests share a pattern: `make_interp()` loads the reference config into an `Interp` over `MemBackend`, and an `ev(src)` helper parses, resolves ids, and evaluates. `Repo` values are built directly as records with small `commit(...)`/`entry(...)` helpers. Law-style properties from spec §8 belong in `tests/laws.rs`; end-to-end behaviour that involves snapshotting, persistence, or the operation log belongs in `tests/cli.rs`.
README.md+185−37
@@ -27,6 +27,52 @@ The whole vocabulary — `new`, `describe`, `squash`, `tree`, `rebase`, … —
2727written *in the language* in `config.j`, and you extend it by writing more
2828functions.
2929
30+## Find a command
31+
32+```
33+j help -- a quick start, grouped commands, and examples
34+j commands -- common tasks and how to do them
35+j commands all -- the full vocabulary in your config
36+j commands rebase -- search names, signatures, and descriptions
37+j help describe -- see a command's signature, documentation, and examples
38+j help push -- learn a reserved command's usage
39+j help config -- find your config and set your identity
40+```
41+
42+`j --help` and `j -h` also open help. A separate trailing help argument, such
43+as `j abandon --help`, opens that command's help without executing it.
44+Help and command discovery work outside a repository and never change your
45+files or configuration. The catalog includes
46+your own definitions, even ones without a signature; when no config exists, it
47+uses the embedded reference config. General help and help for reserved commands
48+are available even if your config is broken.
49+
50+Set up with your own name and email, then choose one starting point:
51+
52+```sh
53+j setup 'Ada Lovelace' ada@example.org
54+
55+# Start a new project
56+mkdir project && cd project
57+j init
58+```
59+
60+```sh
61+# Or join an existing project
62+j clone URL project
63+cd project
64+```
65+
66+`init` and `clone` leave you on a working-copy commit: the commit you are
67+editing now. Edit files, then run `j 'describe "first change"'` to save the
68+files and their description. Use `j save` to save files without changing the
69+description. Run `j new` **before** editing the next change. `j status` shows
70+both changes in the commit and edits that still need saving; `j tree` shows
71+where that commit sits in history. Compose commands right to left:
72+`j 'describe "first change" . new'` creates a commit, then describes it.
73+Preview an edit with `j 'preview squash'`. Search familiar tasks with
74+`j commands commit`, `j commands checkout`, or `j commands branch`.
75+
3076---
3177
3278## Why `j`?
@@ -47,7 +93,7 @@ functions.
4793### With Nix (recommended)
4894
4995The repository provides a flake with one default binary, `j`. Difftastic is a
50−runtime dependency and is wired in automatically.
96+runtime enhancement and is wired in automatically.
5197
5298```
5399nix build # builds ./result/bin/j
@@ -63,22 +109,70 @@ The reference `config.j` is installed alongside the binary
63109cargo build --release # produces target/release/j
64110```
65111
66−You'll want the reference `config.j` (see next section) and, for `review`/`difft`,
67−[difftastic](https://difftastic.wilfred.me.uk/) on your `PATH`.
112+Run `j setup` to create the reference `config.j` (see next section).
113+[Difftastic](https://difftastic.wilfred.me.uk/) on your `PATH` improves
114+`review`/`difft`; without it they use a unified diff.
68115
69116---
70117
71118## Configuration
72119
73−`j` reads `~/.config/j/config.j` on every run (or `$XDG_CONFIG_HOME/j/config.j`).
120+### Terminal colors and motion
121+
122+In an interactive terminal, `j` uses vivid branch colors, a blue focus band,
123+colorful data and status messages, and animated progress indicators while
124+repository operations or expressions run. Each indicator names the current
125+operation and shows a spinner, a moving activity bar, and elapsed time. The
126+bar shows activity, not a completion percentage; on narrow terminals it makes
127+room for the operation's name. Colored trees and tables appear in a quick
128+cascade. Progress starts only when an operation takes a moment; fast commands
129+do not wait for a spinner.
130+
131+To keep the colors but turn off animation:
132+
133+```sh
134+J_ANIMATIONS=never j tree
135+```
136+
137+Set `NO_COLOR=1` for plain, motion-free output. Motion is also disabled when
138+either output stream is redirected, `TERM=dumb`, or `CI` is nonempty. Automatic
139+colors follow the terminal for each stream; piped output stays plain unless
140+you explicitly request `treeWith`'s `color = "always"`. `NO_COLOR` takes
141+precedence even over that setting.
142+
143+Interactive edits report `Saved changes` or `No changes`, followed by the
144+focused commit's short id and description. `NO_COLOR=1` and `TERM=dumb` keep
145+these confirmations in plain text. Success confirmations are suppressed when
146+either output stream is redirected or CI is set; warnings and errors still
147+appear.
148+
149+### User config
150+
151+`j setup 'Your actual name' you@your-domain.example` creates the full reference
152+config with your identity. `j setup` without arguments creates an editable
153+default and tells you where to set the identity. Neither form overwrites an
154+existing file; use `j help config` to find and edit it.
155+
156+Expressions read `~/.config/j/config.j` (or `$XDG_CONFIG_HOME/j/config.j`).
74157An expression can only use a name this file declares or defines. The reference
75−[`config.j`](config.j) ships with the binary — copy it and edit at least the
76−identity:
158+[`config.j`](config.j) ships with the binary. Commands that execute against a
159+repository create an editable copy there when it is missing; help and discovery
160+use the embedded copy without writing it. Edit at least the identity:
77161
78162```
79−user = { name = "Your Name", email = "you@example.com" }
163+user = { name = "Ada Lovelace", email = "ada@example.org" }
80164```
81165
166+Use your own name and email. `j init`, `j clone`, and edits that save changes
167+reject empty fields and the shipped `Your Name` / `you@example.com`
168+placeholders. Interactive setup confirms the identity it used. `j help config`
169+shows the actual config path and remains available when the file is broken.
170+
171+Existing configurations keep their own vocabulary. To adopt new helpers,
172+copy the `save` and `preview` definitions, the `unrecorded` declaration, and
173+the updated `status` definition from the [reference config](config.j), preserving your
174+identity and custom definitions. `j setup` does not upgrade existing files.
175+
82176The reference config defines the entire base vocabulary (see
83177[docs/base.md](docs/base.md)). Four definitions are read by `j` itself: `user`
84178(who commits), `tree` (how a `Repo` prints), `immutable` (which commits may
@@ -88,17 +182,19 @@ never be rewritten), and `labelled` (what a `%name` literal means).
88182
89183## How it works
90184
91−Every `j` run:
185+Every expression run:
92186
93−1. Snapshots the working directory into the working-copy commit.
94−2. Builds a `Repo` value focused on that commit.
187+1. Snapshots the working directory for evaluation.
188+2. Builds a `Repo` value focused on the working-copy commit.
951893. Evaluates your expression.
961904. If the result is a function, applies it to the `Repo`.
97−5. If the final value is a `Repo` that changed, persists it; otherwise prints
98− the value and changes nothing.
191+5. If the final value is a `Repo` that changed, persists it. An unchanged
192+ `Repo` saves nothing; other values print without saving.
99193
100194So most of what you write is an **edit** — a function `Repo -> Repo`. A result
101195that's plain data is printed and persists nothing (a dry run).
196+`j status` sees current file edits but does not record them. Use `j save` to
197+record those edits without changing anything else before pushing.
102198
103199```
104200j 'length . commits . top' -- a number: how many commits? prints, persists nothing
@@ -143,14 +239,19 @@ Opens a new commit *and* describes it, in one step.
143239j 'status'
144240```
145241
146−A summary of the focus: id, message, labels, changed paths, conflicted paths.
242+A summary of the focus: id, message, labels, changed paths, unrecorded edits,
243+and conflicted paths. `in commit` (the `changed` field) compares the commit
244+with its parent;
245+`unrecorded` compares the current files with their last saved state. After
246+`j save`, a commit can still have changed paths while all edits are recorded.
147247
148248```
149−changed 0 items
150−conflicts 0 items
151−id zrkmkkvrvtokumkuzrkmkkvrvtokumku
152−labels 0 items
153−message add a library
249+in commit No changes
250+conflicts No conflicts
251+id zrkmkkvrvtokumkuzrkmkkvrvtokumku
252+labels No labels
253+message add a library
254+unrecorded All edits recorded
154255```
155256
156257```
@@ -190,25 +291,33 @@ The same tree with ages and authors in the margin.
190291j 'log'
191292```
192293
193−A table of every commit, top first, with a `✓` on the focus.
294+A table of every commit, top first, with a `✓` on the focus, short ids, and
295+label names. Tables fit the terminal width, switching to fields on separate
296+lines on very narrow terminals. Redirected output preserves full cell text.
194297
195298```
196299j 'review'
197300```
198301
199−The focus's changes, rendered by difftastic as one page of diffs.
302+The focus's changes as one page of diffs, using difftastic when available
303+and a unified diff otherwise.
200304
201305### Move around
202306
307+Navigation checks out the selected commit's files so you can edit them.
308+
203309```
204310j 'prev' -- to the parent
205311j 'next' -- to the only child
206−j 'top' -- to the top of history
207312j 'tip' -- to the tip of the current line
208313j 'by @wqzt' -- to a specific commit, by id
209−j 'goto %main' -- to what the remote calls main
314+j 'new . goto trunk' -- start a change on top of the protected main line
210315```
211316
317+Use `j 'tree . goto trunk'` to inspect main without moving the working copy.
318+At a fork, use an id from `j tree` with `by`; `next` requires one child.
319+Main must exist on the remote (`j fetch`) before `trunk` can select it.
320+
212321### Undo and history of actions
213322
214323```
@@ -217,18 +326,25 @@ j redo -- reapply it
217326j ops -- the operation log, newest first
218327```
219328
329+Undo and redo require a working copy with no unrecorded edits. If you run
330+`j id` to record edits first, that creates a new operation: the next `j undo`
331+undoes the recording, and another undo reaches the preceding edit. A new
332+recording also clears pending redos. Inspect `j ops` when deciding how far to
333+go back. Interactive confirmations name the affected operation and restored
334+focus.
335+
220336### A safe dry run
221337
222−Because a `Text` or `Repo`-returning-but-unchanged result persists nothing,
223−you can preview any edit by composing it with `tree` (or `validate`):
338+`preview` validates an edit and returns its resulting tree without saving:
224339
225340```
226−j 'tree . squash' -- show the tree as squash would leave it
227−j 'tree . validate . squash' -- the same, but crash if squash couldn't persist
341+j 'preview squash'
342+j 'preview (rebase trunk)'
228343```
229344
230−`validate` returns the repo unchanged if persisting would succeed, and crashes
231−with the exact error otherwise — an honest dry run.
345+This is shorthand for `tree . validate . EDIT`. `tree . EDIT` also previews,
346+but does not check whether the result can be saved. `validate . EDIT` alone
347+**saves** a changed repository; validation is not a dry-run mode.
232348
233349---
234350
@@ -330,6 +446,27 @@ j 'push (unlabel "old-feature")' -- delete a bookmark
330446j 'push relabel' -- after a rewrite, re-point the remote's bookmarks
331447```
332448
449+Push sends recorded content. If files have changed since the last saved edit,
450+it warns that those changes are omitted, then sends the recorded commits.
451+Run `j save` and push again to include them; `j status` alone does not record
452+changes.
453+
454+For the first publication, choose a bookmark name with
455+`j 'push (label "feature" here)'`. `j push relabel` updates existing bookmarks;
456+it does not create the first one. Push reports when no updates were selected,
457+and gives counts of published, deleted, and unchanged bookmarks for a
458+nonempty selection in an interactive terminal.
459+
460+### Resolve conflicts
461+
462+A history edit can succeed with conflicts. Run `j status` to find conflicted
463+files, inspect them with `j review`, and edit the files to remove the conflict
464+markers and keep the intended content. Run `j save`, then `j status` again
465+to check for `No conflicts` and `All edits recorded`. There is no separate
466+continue command. `j conflicts` lists conflicted commits across history;
467+use `j 'by @id'` to work on another mutable commit when needed, and check
468+`j conflicts` again after resolving it.
469+
333470---
334471
335472## Advanced examples
@@ -413,9 +550,9 @@ Rebase if you can; leave the repository alone if it crashes.
413550Anything added to `config.j` whose value is an `Edit` is a new command:
414551
415552```
416−-- in config.j: fold the whole stack into one commit called "release"
553+-- in config.j: squash into the parent and name the combined commit "release"
417554release : Edit
418−release = describe "release" . (contract everything)
555+release = describe "release" . prev . squash
419556```
420557
421558Then `j release` works like any built-in.
@@ -424,20 +561,31 @@ Then `j release` works like any built-in.
424561
425562## Reserved commands
426563
427−A handful of commands are built in (not expressions): they create or connect
428−repositories, move refs, and walk the operation log. None take flags.
564+A handful of commands are built in (not expressions): they provide help,
565+create or connect repositories, move refs, and walk the operation log.
429566
430567| command | what it does |
431568|---|---|
569+| `j help [NAME]` | show a quick start or help for a reserved command or config name |
570+| `j commands [QUERY]` | show common tasks, search by task/name, or use `all` for the full vocabulary |
571+| `j setup [NAME EMAIL]` | create the full reference config, optionally with your identity; never overwrite an existing file |
432572| `j init` | create a colocated `.jj`/`.git` repository here (adopting an existing `.git`) |
433−| `j clone URL [DIR]` | clone `URL` into `DIR` (default: last path component, minus `.git`) |
573+| `j clone URL [DIR]` | clone `URL` into `DIR` (default: the repository's name, as `git clone` picks it) |
434574| `j remote URL` | set `origin`'s URL |
435575| `j fetch` | fetch from `origin` |
436576| `j push EXPR` | evaluate `EXPR` to push/drop records and push them |
437577| `j undo` / `j redo` | walk the operation log |
438578| `j ops` | print the operation log |
439579
440−Everything else is an expression in the language.
580+`--help` and `-h` open help as the first word or as a separate argument after
581+the command: `j --help describe`, `j describe --help`, and `j describe -h`
582+all open help for `describe`. Inside a single expression argument or stdin,
583+language comments still apply: `j 'new --help'` executes `new`. There are no
584+other general command flags. Everything else is an expression in the language.
585+
586+Quote literal reserved-command arguments separately to keep spaces intact:
587+`j clone URL 'my project'` and `j remote '/path/to/shared repo.git'`. A whole
588+reserved command supplied as one string or on stdin is split on whitespace.
441589
442590---
443591
@@ -449,7 +597,7 @@ Everything else is an expression in the language.
449597| **[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. |
450598| **[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. |
451599| **[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. |
452−| **[SPEC.md](SPEC.md)** | The normative specification of the language, the CLI, and repository semantics. |
600+| **[specs/main.md](specs/main.md)** | The normative specification of the language, the CLI, and repository semantics. |
453601| **[config.j](config.j)** | The reference configuration: the entire base vocabulary, written in the language. |
454602
455603---
@@ -457,11 +605,11 @@ Everything else is an expression in the language.
457605## Project layout
458606
459607```
460−config.j the reference base vocabulary (read on every run)
608+config.j the reference base vocabulary
461609src/ the j implementation (Rust, on jj-lib)
462610tests/ unit, property, and CLI end-to-end tests
463611docs/ language.md, base.md, comparison.md
464−SPEC.md the specification
612+specs/ the language, CLI, repository, and tree specifications
465613flake.nix Nix package: one binary, j, with difftastic as a runtime dep
466614```
467615
config.j+107−22
@@ -76,6 +76,10 @@ Drop = { delete : Label } -- delete the remote's bookmark
7676------------------------------------------------------------------------------
7777
7878(.) : (b -> c) -> (a -> b) -> a -> c -- composition, right to left
79+-- Return a value unchanged. As a repository command, `j id` records the
80+-- snapshotted working-copy edits in the focused commit. `j save` is the
81+-- descriptive alias in this reference config; inspections such as `j status`
82+-- and `j tree` do not record those edits.
7983id : a -> a
8084const : a -> b -> a
8185crash : Text -> a -- abort the program with a message
@@ -137,24 +141,37 @@ splitOn : Text -> Text -> [Text] -- splitOn "/" "a/b" = ["a" "b"]
137141------------------------------------------------------------------------------
138142
139143-- 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.
144+-- applied." Where the change collides with what is already there, the path's
145+-- blob is unresolved rather than an error, except that a collision making a
146+-- path a file on one side and a directory on another, as adding ./a/b onto a
147+-- file ./a would, crashes, naming the path (§7.3).
142148replay : Snapshot -> Change -> Snapshot
143149unresolved : Blob -> Bool
150+
151+-- Paths edited in the working copy since the last recorded repository state.
152+-- `j status` includes this list; `j save` records it in the focused commit.
153+-- This measures the working copy, even when composed after an in-memory edit.
154+unrecorded : Repo -> [Path]
144155touchedPaths : Change -> [Path] -- paths whose content differs, one pass;
145156 -- blobs compared by content id, bytes never read
146157blob : Text -> Blob -- a resolved regular file
158+blobHex : Text -> Text -> Blob -- kind (regular/executable/symlink), hex bytes
147159text : Blob -> Text -- its content (markers if unresolved)
148160by : Id -> Repo -> Repo -- focus the commit with this id; crash if absent
161+lookupCommit : Id -> Repo -> Commit -- indexed commit lookup without moving the focus
162+lookupContent : Path -> Snapshot -> Blob -- indexed file content; an empty blob if absent
163+subtreeCommits : a -> [Commit] -- every commit of a tree in preorder, in one
164+ -- pass: what `commits` (below) is
149165meta : Id -> Meta -- git hash, author, email, committer time;
150166 -- crash for a commit not yet persisted
151167validate : Repo -> Repo -- the repo unchanged, or the crash persisting
152− -- it would produce (shape, labels, immutability,
168+ -- it would produce (shape, root, labels, immutability,
153169 -- focus). `tree . validate . rebase trunk` is an
154170 -- honest dry run.
155171diff : Blob -> Blob -> Text -- unified diff from the first to the second
156−difft : Path -> Blob -> Blob -> Text -- difftastic's rendering; needs difft on PATH.
157− -- Options via DFT_* environment variables.
172+difft : Path -> Blob -> Blob -> Text -- difftastic when difft is on PATH; otherwise
173+ -- unified diff with path headers and 3 context lines,
174+ -- or a binary-files summary. DFT_* configures difft.
158175treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text
159176 , lanes : Int, author : Bool, date : Bool, files : Bool }
160177 -> Repo -> Text -- the history as a tree; see the spec
@@ -194,8 +211,11 @@ top = \repo -> top (up repo) or repo
194211
195212-- Every commit of a subtree (or of the focused subtree of a Repo), in preorder.
196213-- Takes anything with `root` and `children`, so the signature is left open.
214+-- The builtin is \t -> t.root :: (concat (map commits t.children) or [])
215+-- in one pass; written that way it copies the list below every commit, and
216+-- so takes time quadratic in the length of the history.
197217commits : a -> [Commit]
198−commits = \t -> t.root :: (concat (map commits t.children) or [])
218+commits = subtreeCommits
199219
200220
201221------------------------------------------------------------------------------
@@ -239,6 +259,9 @@ restrict = \m -> filter (\e -> m e.path)
239259select : Fileset -> Snapshot -> Snapshot -> Snapshot -- m from a, the rest from b
240260select = \m a b -> restrict m a ++ restrict (neg m) b
241261
262+-- Unresolved paths in a snapshot. For the focus, inspect `j status` or
263+-- `j conflicted . files`, edit the listed files to remove conflict markers,
264+-- then run `j save` and inspect `j status` again. See `j help conflicts`.
242265conflicted : Snapshot -> [Path]
243266conflicted = \s -> map (.path) (filter (\e -> unresolved e.content) s)
244267
@@ -287,7 +310,7 @@ descendants : Revset -- includes the
287310descendants = \repo -> map (.id) (commits repo)
288311
289312ancestors : Revset -- includes the focus
290−ancestors = \repo -> repo.root.id :: ((let p = up repo in ancestors p) or [])
313+ancestors = \repo -> repo.root.id :: map (\f -> f.parent.id) repo.context
291314
292315siblings : Revset
293316siblings = \repo -> filter (\i -> i /= repo.root.id) (kids (up repo)) or []
@@ -323,6 +346,11 @@ minus = \a b repo -> let ys = b repo in filter (\i -> not (member i ys)) (a repo
323346matching : (Commit -> Bool) -> Revset -> Revset
324347matching = \p rs repo -> filter (\i -> p (commitAt i repo)) (rs repo)
325348
349+-- Find every commit with unresolved files. `j conflicts` lists candidates;
350+-- use `j by @ID` to focus a mutable one, then `j status` to find its paths.
351+-- Edit each conflicted file, remove the conflict markers, and run `j save`.
352+-- Check `j status` again; `j clean` means the focus has no unresolved files,
353+-- not that the working copy has no unrecorded edits. `j review` shows the diff.
326354conflicts : Revset
327355conflicts = matching (\c -> not (null (conflicted c.files))) all
328356
@@ -331,7 +359,10 @@ firstOf : [Revset] -> Revset
331359firstOf = \rss repo -> foldl (\acc rs -> if null acc then rs repo else acc) [] rss
332360
333361-- The main line: whichever of these names the remote has, first one wins.
334−-- Empty in a repository with no remote, in which case nothing is immutable.
362+-- Empty when no fetched remote label is named main, master, or trunk;
363+-- nothing is then immutable. Configure `j remote URL`, run `j fetch`, and
364+-- inspect `j tree` to see remote labels. A remote with another default label
365+-- needs this definition adjusted. Start work with j 'new . goto trunk'.
335366trunk : Revset
336367trunk = firstOf [%main %master %trunk]
337368
@@ -347,21 +378,33 @@ immutable = ancestorsOf trunk
347378-- Focusing. `goto` is the only place a revset must have exactly one member.
348379------------------------------------------------------------------------------
349380
381+-- Move to the single commit selected by a revset.
382+-- Example: j 'goto %feature'; use j 'new . goto trunk' to start on the main line.
350383goto : Revset -> Edit
351384goto = \rs repo ->
352385 let is = rs repo
386+ candidate = \i ->
387+ let c = commitAt i repo
388+ message = head (splitOn "\n" c.message)
389+ in " " ++ show i ++ " " ++ (if message == "" then "(no description)" else message) ++ "\n"
353390 in if length is == 1
354391 then by (head is) repo
355− else crash ("expected one revision, got " ++ show (length is))
392+ else if null is
393+ then crash "expected one revision, got 0; no matching revision. Run `j tree` to inspect available revisions. For a missing remote label such as %main or trunk, configure `j remote URL`, run `j fetch`, and check the label names in `j tree`."
394+ else crash ("expected one revision, got " ++ show (length is) ++ "\nCandidates:\n" ++ concat (map candidate (take 8 is)) ++ (if length is > 8 then " More candidates: run `j tree`.\n" else "") ++ "Choose a mutable commit with `j by @ID`, or start a child with `j 'new . by @ID'`; replace @ID with a candidate above.")
356395
357396commitAt : Id -> Repo -> Commit
358−commitAt = \i repo -> (by i repo).root
397+commitAt = lookupCommit
359398
360399prev : Edit -- the parent
361400prev = goto parents
362401
363−next : Edit -- the only child
364−next = goto kids
402+-- Move to the only child. At a tip, `j new` starts a child; when there are
403+-- several children, choose one of the candidates with `j by @ID`.
404+next : Edit
405+next = \repo -> if null (kids repo)
406+ then crash "next: this commit has no children; run `j new` to start a new child, or `j tree` to inspect the history"
407+ else goto kids repo
365408
366409tip : Edit -- end of the line of only-children
367410tip = \repo -> tip (next repo) or repo
@@ -387,6 +430,8 @@ eachChild = forEach kids
387430guard : (Repo -> Bool) -> Edit -- crash unless p holds
388431guard = \p repo -> if p repo then repo else crash "guard"
389432
433+-- Whether the focused commit has no unresolved files. This says nothing
434+-- about unrecorded working-copy edits; inspect those with `j unrecorded`.
390435clean : Repo -> Bool
391436clean = \repo -> null (conflicted repo.root.files)
392437
@@ -425,11 +470,28 @@ rewrite = \f repo ->
425470-- Commands
426471------------------------------------------------------------------------------
427472
473+-- Record the current working-copy edits in the focused commit.
474+-- Example: j save; use j status first to inspect unrecorded paths.
475+-- This is id restricted to a Repo: the final Repo result triggers recording.
476+-- It does not publish anything. `j new` starts a child; `j id` records too.
477+save : Edit
478+save = id
479+
480+-- Preview an edit and validate that its result could be recorded, then show
481+-- the resulting tree. This returns Text, so it records nothing.
482+-- Examples: j 'preview squash'; j 'preview (rebase trunk)'.
483+-- This helper is in the reference config. Existing custom configs are not
484+-- upgraded automatically; add it there or use `tree . validate . EDIT`.
485+preview : Edit -> Repo -> Text
486+preview = \edit -> tree . validate . edit
487+
428488-- Set the message of the focused commit.
489+-- Example: j 'describe "fix login"'
429490describe : Text -> Edit
430491describe = \m -> mapRoot (\c -> c { message = m })
431492
432493-- Start an empty child of the focus and move to it.
494+-- Examples: j new; j 'describe "next change" . new'
433495new : Edit
434496new = \repo -> addChild (newCommit repo.root.files) repo
435497
@@ -437,6 +499,7 @@ new = \repo -> addChild (newCommit repo.root.files) repo
437499-- new empty commit on that parent becomes the focus (the parent may be
438500-- immutable, and the focus never is). Refused for a commit the remote has a
439501-- name for: push something else under that name first, or forget the name.
502+-- Example: j abandon; validate a preview first with j 'preview abandon'.
440503abandon : Edit
441504abandon = \repo ->
442505 let c = repo.root
@@ -446,17 +509,20 @@ abandon = \repo ->
446509 else new (remove (eachChild (replayOnto p) repo))
447510
448511-- Push the files matching m from the focus down into its parent.
512+-- Example: j 'contract (ext "rs")'
449513contract : Fileset -> Edit
450514contract = \m repo -> let a = repo.root
451515 in at parents (rewrite (\q -> q { files = select m a.files q.files })) repo
452516
453517-- Fold the focus entirely into its parent; a new empty commit becomes the focus.
518+-- Example: j squash; preview first with j 'preview squash'.
454519squash : Edit
455520squash = abandon . contract everything
456521
457522-- Push the files matching m from the focus up into a new child, which becomes
458523-- the focus and inherits the old focus's children. Labels stay with the
459524-- original commit, since they are the remote's.
525+-- Example: j 'split (ext "rs")'; use (under ./src) to select a directory.
460526split : Fileset -> Edit
461527split = \m repo ->
462528 let a = repo.root
@@ -466,6 +532,7 @@ split = \m repo ->
466532 in by x.id (repo { root = a', children = [{ root = x, children = repo.children }] })
467533
468534-- Move the focused subtree onto another commit. Returns to the moved commit.
535+-- Example: j 'rebase trunk'; preview with j 'preview (rebase trunk)'.
469536rebase : Revset -> Edit
470537rebase = \rs repo ->
471538 let d = (goto rs repo).root
@@ -476,6 +543,7 @@ rebase = \rs repo ->
476543 in by me (attach x.subtree (by d.id x.rest))
477544
478545-- Apply the focused commit's change onto another commit, as a new child there.
546+-- Example: j 'pick %main'
479547pick : Revset -> Edit
480548pick = \rs repo ->
481549 let ch = changeOf repo
@@ -483,6 +551,7 @@ pick = \rs repo ->
483551 in at rs (addChild (newCommit (replay t.files ch))) repo
484552
485553-- A new child of the focus that undoes the change of the given commit.
554+-- Example: j 'backout %feature'
486555backout : Revset -> Edit
487556backout = \rs repo ->
488557 let ch = changeOf (goto rs repo)
@@ -528,38 +597,53 @@ entryAt = \p s -> filter (\e -> e.path == p) s
528597
529598-- The content at path p, or an empty file if absent.
530599contentAt : Path -> Snapshot -> Blob
531−contentAt = \p s -> (head (entryAt p s)).content or blob ""
600+contentAt = lookupContent
532601
533602-- The paths a change touches.
534603touched : Change -> [Path]
535604touched = touchedPaths
536605
537−-- Paths whose content differs between the focus and its parent.
606+-- Paths changed by the focused commit compared with its parent, including
607+-- the working-copy snapshot. These can remain nonempty after `j save`;
608+-- `j unrecorded` shows only working-copy edits not yet recorded.
609+-- Example: j changed
538610changed : Repo -> [Path]
539611changed = \repo -> touched (changeOf repo)
540612
541−-- What the focus changes, rendered by difftastic, one record per changed path.
542−-- Replace difft with (\p -> diff) to use the built-in unified diff instead.
613+-- What the focus changes, one record per changed path. Uses difftastic when
614+-- installed; a missing difft falls back to a unified diff with path headers
615+-- and 3 context lines, or a binary-files summary.
543616diffs : Repo -> [{ path : Path, diff : Text }]
544617diffs = \repo ->
545618 let ch = changeOf repo
546619 in map (\p -> { path = p, diff = difft p (contentAt p ch.from) (contentAt p ch.to) }) (touched ch)
547620
548621-- The same, as one page of text. A Text result prints raw, so `j review` is
549−-- readable in the terminal.
622+-- readable in the terminal. A focus that changes nothing reads "no changes";
623+-- missing difft uses the built-in unified diff (path headers, 3 context lines)
624+-- or a binary-files summary. Other execution errors remain visible; an
625+-- installed difft's output, even nothing, is the page.
626+-- Example: j review; difftastic is optional.
550627review : Repo -> Text
551−review = \repo -> concat (map (\d -> d.diff) (diffs repo)) or "no changes\n"
552−
553−-- A summary of the focus.
554−status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path] }
628+review = \repo ->
629+ let ds = diffs repo
630+ in if null ds then "no changes\n" else concat (map (\d -> d.diff) ds)
631+
632+-- A summary of the focus. `changed` lists changes in the commit compared
633+-- with its parent; `unrecorded` lists working-copy edits not yet recorded.
634+-- Inspection does not record anything. Run `j save` to record those edits.
635+-- Example: j status
636+status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path], unrecorded : [Path] }
555637status = \repo ->
556638 { id = repo.root.id
557639 , message = repo.root.message
558640 , labels = repo.root.labels
559641 , changed = changed repo
560− , conflicts = conflicted repo.root.files }
642+ , conflicts = conflicted repo.root.files
643+ , unrecorded = unrecorded repo }
561644
562645-- A summary of every commit, top first, in preorder.
646+-- Example: j log
563647log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]
564648log = \repo ->
565649 map (\c -> { id = c.id
@@ -572,6 +656,7 @@ log = \repo ->
572656-- (The parentheses are required: `f { … }` would be a record update of f.)
573657-- The default `tree` shows every commit, sorted by time (parents always above
574658-- their children); `treeCompact` folds uninteresting runs and far subtrees.
659+-- Examples: j tree; j 'tree . squash' previews an edit without saving it.
575660tree : Repo -> Text
576661tree = treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
577662
docs/base.md+187−46
@@ -47,6 +47,13 @@ user = { name = "Your Name", email = "you@example.com" }
4747
4848This is a plain value, not a function. Its signature is checked once, at load
4949(§4.13): if you write `user = 5`, `j` refuses to start (exit 3).
50+Creating a repository or saving changes also rejects empty fields and the
51+reference placeholders. Use your own name and email before `j init`,
52+`j clone`, or an edit that saves changes. `j help config` shows your actual
53+config path and an example, even if the config is broken.
54+For a new installation, `j setup 'Your actual name' you@your-domain.example`
55+creates the complete config with that identity. It never overwrites an
56+existing file.
5057
5158### 1.2 Paths, labels, entries, snapshots
5259
@@ -178,6 +185,34 @@ id 42 => 42
178185edit or id -- run edit, keep the repository if it crashes
179186```
180187
188+### `save` — record file edits
189+
190+```
191+save : Edit
192+```
193+
194+The identity edit, with a name for the everyday action: `j save` records the
195+working-directory snapshot without changing the commit's description or
196+focus. It creates no operation when nothing changed. `j id` does the same.
197+
198+### `preview` — validate an edit without saving
199+
200+```
201+preview : Edit -> Repo -> Text
202+```
203+
204+Apply the edit, validate its result, and render the resulting tree. Because
205+the final value is text, no edit or working-directory snapshot is recorded.
206+Validation errors report why the edit could not be saved.
207+
208+```
209+j 'preview squash'
210+j 'preview (rebase trunk)'
211+```
212+
213+This is the convenient form of `tree . validate . EDIT`. `validate . EDIT`
214+alone returns a repository and saves it.
215+
181216### `const` — constant function
182217
183218```
@@ -256,12 +291,14 @@ not : Bool -> Bool
256291```
257292
258293`&&` and `||` short-circuit (the right operand is not evaluated if the left
259−decides the result). Operands must be `Bool`.
294+decides the result). Operands must be `Bool`. Infix use requires their
295+declarations in `config.j` and checks the contracts of evaluated operands
296+and the result; a skipped right operand is not checked.
260297
261298```
2622991 == 1 && 2 == 2 => true
2633001 == 2 || 3 == 3 => true
264−not (1 == 2) => false
301+not (1 == 2) => true
265302not true => false
266303```
267304
@@ -301,18 +338,24 @@ Defined on `Int` only; crash on anything else. Non-associative (cannot chain:
301338show : a -> Text
302339```
303340
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.
341+When it succeeds, `show` renders a value in the language's literal syntax.
342+Except for closures and unresolved blobs, the text parses and evaluates back
343+to an equal value. A top-level `Text` prints raw, so `j show …` is how you inspect values.
307344See §5.2 of the spec for the exact rendering.
308345
346+Regular UTF-8 blobs render as `blob "…"`; binary blobs, executables, and
347+symlinks render as `blobHex "kind" "…"`, preserving bytes and file type with
348+lowercase hexadecimal content. An older custom config without
349+`blobHex : Text -> Text -> Blob` must add that declaration before showing
350+these values; otherwise `show` crashes with instructions to do so.
351+
309352```
310353show 42 => "42"
311354show "hi" => "\"hi\""
312355show [1 "a" true] => "[1 \"a\" true]"
313356show ({ x = 1 }) => "{ x = 1 }"
314357show (describe "wip") => "describe \"wip\""
315−show (\x -> x + 1) => "\x -> (x + 1)"
358+show (\x -> x + 1) => "\\x -> x + 1"
316359```
317360
318361---
@@ -415,8 +458,14 @@ replay : Snapshot -> Change -> Snapshot
415458```
416459
417460`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.
461+applied." Where the change collides with what is already there, the path's
462+blob becomes *unresolved* (a conflict) rather than an error. The one
463+exception is a collision that would make a path a file on one side and a
464+directory on another, as adding `./a/b` onto a file `./a` would: one entry
465+cannot list a directory's entries, so `replay` crashes, naming the path, and
466+`or` catches it. A conflict already at the path in `onto`, `ch.from` or
467+`ch.to` is carried, directory sides and all, so a child that resolves such a
468+conflict can still be squashed into it.
420469
421470```
422471-- onto has "base", change takes "base" -> "theirs": no collision
@@ -435,11 +484,12 @@ unresolved ((head (replay [({ path = ./f, content = blob "onto" })]
435484Two laws to internalise it: `replay b { from = b, to = x } = x` and
436485`replay x { from = b, to = b } = x`.
437486
438−### `unresolved`, `blob`, `text` — blob primitives
487+### `unresolved`, `blob`, `blobHex`, `text` — blob primitives
439488
440489```
441490unresolved : Blob -> Bool
442491blob : Text -> Blob
492+blobHex : Text -> Text -> Blob
443493text : Blob -> Text
444494```
445495
@@ -447,9 +497,16 @@ text : Blob -> Text
447497blob's content (its conflict-marker rendering if unresolved). `unresolved b`
448498is `true` iff the blob is a conflict.
449499
500+`blobHex kind hex` constructs a resolved blob from its file kind and bytes.
501+The kind must be `"regular"`, `"executable"`, or `"symlink"`; the bytes must
502+be an even-length hexadecimal text, using uppercase or lowercase letters.
503+An unknown kind or malformed hex crashes. `text` crashes on non-UTF-8 bytes;
504+persisting a symlink also requires a UTF-8 target.
505+
450506```
451507blob "content" => blob "content"
452508text (blob "content") => "content"
509+text (blobHex "regular" "6869") => "hi"
453510unresolved (blob "x") => false
454511```
455512
@@ -467,6 +524,24 @@ no such visible commit. Backed by the change-id index. Applied to one argument
467524by @wqzt -- an edit: refocus on the commit @wqzt
468525```
469526
527+### `subtreeCommits` — every commit of a tree
528+
529+```
530+subtreeCommits : a -> [Commit]
531+```
532+
533+Every commit of a tree (anything with `root` and `children`, so a `Subtree`
534+or a `Repo`'s focused subtree), in preorder, in one pass. It gives exactly
535+what `\t -> t.root :: (concat (map subtreeCommits t.children) or [])` gives,
536+but in time linear in the size of the tree: written that way, every level
537+copies the list below it. The reference config's `commits` is this builtin
538+(§6).
539+
540+```
541+subtreeCommits ({ root = 1, children = [({ root = 2, children = [] })] })
542+ => [1 2]
543+```
544+
470545### `meta` — commit metadata
471546
472547```
@@ -482,15 +557,20 @@ meta @wqzt => { hash = "74bee64e…", author = "Test User",
482557 email = "test@example.com", time = 1789457265 }
483558```
484559
485−### `validate` — dry-run persistence
560+### `validate` — check whether persistence would succeed
486561
487562```
488563validate : Repo -> Repo
489564```
490565
491566Returns 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.
567+with exactly the error persistence would produce (shape, root, labels, immutability,
568+focus). A changed repository also checks that the author identity is configured;
569+an unchanged repository can still be inspected with the default identity.
570+`tree . validate . rebase trunk` is an honest dry run.
571+`validate . rebase trunk` alone saves the result: `validate` returns the same
572+repository it was given, not the repository from before the edit. Prefer
573+`preview (rebase trunk)` for the complete safe-preview expression.
494574
495575### `diff` and `difft` — rendering differences
496576
@@ -508,25 +588,34 @@ diff (blob "a\nb\n") (blob "a\nc\n")
508588```
509589
510590`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.
591+as the file `p` (its last component guides language detection), using options
592+from `DFT_*` environment variables. The Nix package provides `difft`.
593+When the executable is missing, the builtin returns a unified diff with
594+`a/` and `b/` path headers and three lines of context; binary content receives
595+a difference summary. Other launch failures are crashes naming the tool and
596+OS error. A `difft` that runs and fails is not an error: what it printed,
597+even nothing, is the result, and what it says on stderr reaches the terminal.
514598
515599### `treeWith` — the history tree
516600
517601```
518−treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text }
602+treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text,
603+ lanes : Int, author : Bool, date : Bool, files : Bool }
519604 -> Repo -> Text
520605```
521606
522607Renders the history as a tree with an options record. The reference config
523−provides two ready-made settings, `tree` and `treeFull` (§9).
608+provides ready-made settings, including `tree` and `treeFull` (§10).
524609
525610```
526−treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
611+treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
527612 -- a full tree with ages and authors in the margin
528613```
529614
615+Branches beyond the `lanes` limit retain their state marker beside `»`.
616+Increase `lanes` to display more rails. Long file paths in detail rows wrap
617+to the available terminal width.
618+
530619### `extract` — every subvalue of a shape
531620
532621```
@@ -588,7 +677,9 @@ top = \repo -> top (up repo) or repo
588677```
589678
590679Moves 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.
680+`or repo` is the base case: when `up` crashes at the top, stop. Use it inside
681+an inspection such as `commits . top` or `tree . top`; `j top` alone cannot
682+save a focus on the protected root commit.
592683
593684```
594685top -- refocus at the very first commit
@@ -599,13 +690,16 @@ commits . top -- every commit, preorder
599690
600691```
601692commits : a -> [Commit]
602−commits = \t -> t.root :: (concat (map commits t.children) or [])
693+commits = subtreeCommits
603694```
604695
605696Every commit of a subtree (or of the focused subtree of a `Repo`), in
606697preorder. 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 `[]`).
698+open. It is the builtin `subtreeCommits` (§5), which computes
699+`\t -> t.root :: (concat (map commits t.children) or [])` in one pass. (There
700+the `or []` handles a leaf, whose `children` is empty, so `concat` crashes and
701+yields `[]`.) Written in the language, it copies the list below every commit,
702+and takes time quadratic in the length of the history.
609703
610704```
611705length . commits . top -- how many commits are visible?
@@ -766,26 +860,28 @@ is `new . goto trunk`, never `goto trunk` alone.
766860
767861**`goto : Revset -> Edit`** — refocus on *the one commit* a revset names. This
768862is 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−```
863+with `expected one revision, got N` and recovery guidance. An empty selector
864+points to `j tree` and remote/fetch setup; an ambiguous selector shows up to
865+eight candidate ids and descriptions, with commands to select a mutable commit
866+or create a child. Candidate ids are complete copyable id literals.
777867
778868```
779−goto %main -- refocus on main (crash if it names 0 or 2+ commits)
869+new . goto %main -- start a mutable child on main
870+tree . goto %main -- inspect main without moving the working copy
780871goto parents -- == prev
781872```
782873
783874**`commitAt : Id -> Repo -> Commit`** — the commit with a given id.
875+The reference definition uses `lookupCommit`, which indexes the repository
876+once and retrieves commits without moving the focus. Its result is the same
877+as `(by i repo).root`.
784878
785−**`prev : Edit`** — to the parent (`goto parents`).
879+**`prev : Edit`** — to the parent (`goto parents`). Like every navigation edit,
880+it checks out the selected commit. A protected parent cannot remain focused;
881+inspect it with `tree . prev` or start a new child instead.
786882
787−**`next : Edit`** — to the only child (`goto kids`; crashes if the focus has
788−other than exactly one child).
883+**`next : Edit`** — to the only child. At a tip it suggests `j new`; at a fork
884+it lists candidates to choose with `by`.
789885
790886**`tip : Edit`** — the end of the line of only-children (`tip = \repo -> tip
791887(next repo) or repo`).
@@ -969,6 +1065,9 @@ Crashes at the top.
9691065
9701066`j push EXPR` evaluates `EXPR` to a list of `Push` and `Drop` records. These
9711067functions build such lists.
1068+Push sends recorded commits. Unrecorded file edits produce a warning before
1069+the recorded content is sent. Run `j id`, then push again to include those
1070+edits. Inspection commands such as `j status` do not record them.
9721071
9731072**`label : Label -> Revset -> Repo -> [Push]`** — label every commit of a
9741073revset.
@@ -1001,7 +1100,8 @@ j 'push relabel' -- after rewriting, re-point the remote's bookmarks
10011100
10021101## 10. Inspection
10031102
1004−These return printable values rather than a `Repo`.
1103+These return printable values rather than a `Repo`. They include current
1104+file edits in the displayed snapshot without recording those edits.
10051105
10061106**`focus : Repo -> Commit`** — the focused commit.
10071107
@@ -1016,6 +1116,20 @@ j 'files' -- the working-copy commit's files
10161116
10171117**`contentAt : Path -> Snapshot -> Blob`** — the content at a path, or an
10181118empty file if absent.
1119+The reference definition uses `lookupContent`, which shares a path index
1120+between repeated lookups in each snapshot. Duplicate paths still select the
1121+first entry, as `head (entryAt p s)` does.
1122+
1123+Existing user configurations keep working with their original definitions.
1124+To enable both indexed lookups, add these builtin declarations to your config:
1125+
1126+```
1127+lookupCommit : Id -> Repo -> Commit
1128+lookupContent : Path -> Snapshot -> Blob
1129+```
1130+
1131+Then replace the bodies of `commitAt` and `contentAt` with `lookupCommit` and
1132+`lookupContent`, respectively, keeping their existing signatures.
10191133
10201134```
10211135contentAt ./a [({ path = ./a, content = blob "x" })]
@@ -1028,42 +1142,64 @@ contentAt ./missing [] => blob ""
10281142**`changed : Repo -> [Path]`** — paths whose content differs between the focus
10291143and its parent.
10301144
1145+**`unrecorded : Repo -> [Path]`** — paths changed in the working directory
1146+since the recorded snapshot at the start of this invocation. This builtin
1147+compares the loaded and snapshotted focus files, independently of edits to
1148+the repository value later in the expression. It returns `[]` when no
1149+snapshot context exists, including in a push expression.
1150+
10311151**`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.)
1152+rendered by difftastic, one record per changed path, with unified diffs as
1153+the fallback when difftastic is missing.
10341154
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.
1155+**`review : Repo -> Text`** — the same, as one page of text, or
1156+`"no changes\n"` when the focus changes nothing. A `Text` result prints raw,
1157+so `j review` is readable in the terminal even without difftastic. When
1158+`difft` runs, whatever it prints, even nothing, is the page, and what it says
1159+on stderr reaches the terminal. Other launch errors still fail the review.
10371160
10381161```
10391162j 'review' -- the focus's changes, as difftastic renders them
10401163```
10411164
1042−**`status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path] }`** — a summary of the focus.
1165+**`status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], unrecorded : [Path], conflicts : [Path] }`** — a summary of the focus and unsaved file edits.
10431166
10441167```
10451168j 'status'
10461169-- id @wqzt…
10471170-- message add the lexer
10481171-- labels [feature]
1049−-- changed [src/lexer.rs]
1050−-- conflicts []
1172+-- in commit [src/lexer.rs]
1173+-- conflicts No conflicts
1174+-- unrecorded All edits recorded
10511175```
10521176
1177+Empty changed paths, labels, and descriptions read `No changes`, `No labels`,
1178+and `No description` respectively. The display calls `changed` **in commit**;
1179+the underlying field keeps its name. It compares the focus with its
1180+parent. `unrecorded` lists paths that still need saving and reads
1181+`All edits recorded` when empty. Run `j save` to record them; `changed`
1182+continues to show the commit's changes after saving.
1183+
10531184**`log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]`**
10541185— a summary of every commit, top first, in preorder (`focus` marks the focused
1055−commit).
1186+commit). The display shows short ids and label names, fits columns to the
1187+terminal, and switches to fields on separate lines when necessary. Redirected
1188+output preserves full cell text.
10561189
10571190**`tree : Repo -> Text`** and **`treeFull : Repo -> Text`** — the history as a
1058−tree, built with `treeWith`. `tree` is compact (`detail = 1`, elided);
1191+tree, built with `treeWith`. `tree` shows every commit (`detail = 1`);
10591192`treeFull` shows ages and authors in the margin (`detail = 2`, `margin =
10601193true`). Edit these definitions to change what `j tree` shows.
10611194
10621195```
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" })
1196+tree = treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
1197+treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
10651198```
10661199
1200+Use `treeCompact` to fold uninteresting runs and distant subtrees, or
1201+`treeData` for extra date, files-changed, and author columns.
1202+
10671203```
10681204j 'tree'
10691205-- ⌂ zzzz
@@ -1093,6 +1229,7 @@ j 'tree'
10931229| `attach` | `Subtree -> Edit` | §7.1 |
10941230| `backout` | `Revset -> Edit` | §8 |
10951231| `blob` | `Text -> Blob` | §5 |
1232+| `blobHex` | `Text -> Text -> Blob` | §5 |
10961233| `both` | `Fileset -> Fileset -> Fileset` | §7.2 |
10971234| `by` | `Id -> Repo -> Repo` | §5 |
10981235| `changeOf` | `Repo -> Change` | §7.2 |
@@ -1162,6 +1299,7 @@ j 'tree'
11621299| `parents` | `Revset` | §7.3 |
11631300| `pick` | `Revset -> Edit` | §8 |
11641301| `prev` | `Edit` | §7.4 |
1302+| `preview` | `Edit -> Repo -> Text` | §2 |
11651303| `range` | `Int -> Int -> [Int]` | §4 |
11661304| `rebase` | `Revset -> Edit` | §8 |
11671305| `relabel` | `Repo -> [Push]` | §9 |
@@ -1173,6 +1311,7 @@ j 'tree'
11731311| `restrict` | `Fileset -> Snapshot -> Snapshot` | §7.2 |
11741312| `review` | `Repo -> Text` | §10 |
11751313| `rewrite` | `(Commit -> Commit) -> Edit` | §8 |
1314+| `save` | `Edit` | §2 |
11761315| `select` | `Fileset -> Snapshot -> Snapshot -> Snapshot` | §7.2 |
11771316| `show` | `a -> Text` | §3 |
11781317| `siblings` | `Revset` | §7.3 |
@@ -1182,6 +1321,7 @@ j 'tree'
11821321| `stack` | `Revset` | §7.3 |
11831322| `startsWith` | `Text -> Text -> Bool` | §4 |
11841323| `status` | `Repo -> {…}` | §10 |
1324+| `subtreeCommits` | `a -> [Commit]` | §5 |
11851325| `tail` | `[a] -> [a]` | §4 |
11861326| `take` | `Int -> [a] -> [a]` | §4 |
11871327| `text` | `Blob -> Text` | §5 |
@@ -1195,6 +1335,7 @@ j 'tree'
11951335| `under` | `Path -> Fileset` | §7.2 |
11961336| `union` | `Revset -> Revset -> Revset` | §7.3 |
11971337| `unlabel` | `Label -> [Drop]` | §9 |
1338+| `unrecorded` | `Repo -> [Path]` | §10 |
11981339| `unresolved` | `Blob -> Bool` | §5 |
11991340| `up` | `Edit` | §6 |
12001341| `user` | `{ name : Text, email : Text }` | §1.1 |
docs/comparison.md+25−9
@@ -75,21 +75,27 @@ about: a commit keeps its identity (`@wqzt…`) across `describe`, `squash`,
7575`j push` takes an *expression* rather than flags: `j 'push (label "feature"
7676here)'` labels the focused commit, `j 'push relabel'` re-points the remote's
7777bookmarks after a rewrite.
78+Push sends recorded commits and warns before sending if files have
79+unrecorded edits. Run `j id`, then push again to include those edits.
7880
7981### 3.2 Recording work
8082
8183| Task | git | jj | j |
8284|---|---|---|---|
83−| Save current state | `git add … && git commit` | automatic (`jj st` shows it) | automatic; `j id` to record *only* the snapshot |
85+| Save current state | `git add … && git commit` | automatic (`jj st` shows it) | an edit records the snapshot; `j save` records *only* the snapshot |
8486| Set the message | `git commit -m "…"` | `jj describe -m "…"` or `jj describe` | `j 'describe "…"'` |
8587| Start the next change | (just keep editing) | `jj new` | `j new` |
8688| Amend the current commit | `git commit --amend` | `jj describe` + edit files in `@` | edit files + `j 'describe "…"'` |
8789| See what changed | `git diff` / `git show` | `jj diff` / `jj show` | `j review` (via difftastic) / `j status` |
8890
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.
91+In git, "commit" is a deliberate act with a staging step. jj snapshots the
92+working copy automatically. In `j`, expressions see current file edits,
93+but only a changed `Repo` result records them: `describe` saves a message
94+and `new` opens the next change. `j status` and other inspections save
95+nothing. Use `j save` (an alias for `id`) to record the working directory and
96+nothing else. Status displays the `changed` field as `in commit`, comparing
97+the current commit with its parent; `unrecorded` shows file edits made since
98+the last save.
9399
94100### 3.3 Looking around
95101
@@ -116,7 +122,7 @@ zipper with edits.
116122|---|---|---|---|
117123| To the parent | `git checkout HEAD~1` | `jj prev` *(≈, opens a new empty commit there)* | `j prev` |
118124| 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'` |
125+| Start work on a named commit | `git checkout <branch>` | `jj new <rev>` | `j 'new . goto %main'` |
120126| To a specific change | `git checkout <sha>` | `jj new <change-id>` | `j 'by @wqzt'` |
121127| Back to your branch | `git checkout main` | `jj new main` | `j 'new . goto trunk'` |
122128
@@ -201,14 +207,16 @@ the remote's state explicit and impossible to mutate by accident.
201207| When do conflicts appear? | During merge/rebase, blocking it | Recorded *in* the commit, non-blocking | Recorded in the commit (unresolved `Blob`s) |
202208| Where do they live? | The working tree + index | In the commit itself | In the commit's `files`, as conflict blobs |
203209| 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`) |
210+| How do you resolve? | Edit files, `git add`, continue | Edit files; conflict is gone from `@` | Edit files, then `j save` and check `j status` |
205211
206212git stops the world on a conflict: a merge or rebase halts until you resolve
207213and `git add`. jj and `j` treat a conflict as *data* — a commit can simply
208214*contain* conflicted files, you can keep working, and you resolve whenever you
209215like. `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.
216+writes the markers to disk; editing the files and running `j save` records
217+the resolution. Check `j status` for `No conflicts` and `All edits recorded`.
218+There is no separate continuation command. `j review` uses a unified diff
219+when difftastic is not installed.
212220
213221---
214222
@@ -227,6 +235,12 @@ a first-class, marked operation, so `undo`/`redo` are safe and precise. In
227235`j`, undoing a `push` restores the local labels (and leaves the remote
228236untouched).
229237
238+`j undo` and `j redo` require no unrecorded working-copy edits. Recording
239+edits with `j id` adds an operation, so the next undo removes that recording
240+before another undo reaches the preceding edit. A new recording also clears
241+pending redos. Interactive confirmations identify the affected operation and
242+restored focus; `j ops` shows the history.
243+
230244---
231245
232246## 9. The interface itself
@@ -296,6 +310,8 @@ j new
296310# …edit…
297311j 'describe "part two"'
298312j squash
313+j prev # squash leaves an empty child; return to the combined commit
314+j 'describe "complete feature"'
299315j 'rebase %main'
300316j 'push (label "feature" here)'
301317```
docs/language.md+91−25
@@ -3,7 +3,14 @@
33`j` is a small, pure, functional language. Every `j` command evaluates one
44expression in this language against the current repository. The language is
55the whole interface: there are no subcommands beyond a handful of reserved
6−words, and no flags.
6+words, and no general flags apart from help. `--help` and `-h` open help as
7+the first word or as a separate argument after a command: `j new --help`
8+shows help without executing `new`. Inside a single expression argument or
9+stdin, `--` still starts a language comment, so `j 'new --help'` executes
10+`new`. Use `j help` for a command guide, `j commands` for everyday tasks,
11+and `j commands all` for the complete vocabulary. `j setup NAME EMAIL`
12+creates a new configuration with your identity without replacing an existing
13+file.
714
815This document describes the language itself — its values, syntax, semantics,
916and the builtins — with examples throughout. Every example shows an expression
@@ -17,16 +24,17 @@ and what it evaluates to, exactly as `j` prints it.
1724
1825## 1. The big picture
1926
20−A run of `j` looks like this:
27+An expression run of `j` looks like this:
2128
22291. Read the expression (from the command line or stdin).
23−2. Snapshot the working directory into the working-copy commit.
30+2. Snapshot the working directory for evaluation.
24313. Build a value `repo` describing the repository, focused on the
2532 working-copy commit.
26334. Evaluate the expression.
27345. If the result is a function, apply it to `repo`.
28356. If the final value is a `Repo` that differs from the one loaded, persist
29− the difference; otherwise print the value and change nothing.
36+ the difference. An unchanged `Repo` saves nothing; other values print
37+ without saving.
3038
3139So the most common thing you write is an **edit** — a function `Repo -> Repo`
3240(or a value that becomes one). `describe "wip"` is an edit. `new` is an edit.
@@ -172,8 +180,20 @@ let r = { a = 1, b = 2 } in r { b = 99 }
172180> (\r -> r.name) ({ name = "j" }) -- OK
173181> ```
174182>
175−> The same applies inside lists: `[{ root = c, children = [] }]` — write the
176−> braces as a parenthesised element `[(…)]` if it follows another element.
183+> The same applies inside lists: a record literal that follows another
184+> element is parenthesised. Written side by side, the second record updates
185+> the first, so `[{ id = 1 } { id = 2 }]` is the one-element list
186+> `[{ id = 2 }]`:
187+>
188+> ```
189+> [({ id = 1 }) ({ id = 2 })] -- OK: two records
190+> [{ root = c, children = [] }] -- OK: the only element
191+> ```
192+>
193+> A line break stops this: a `.name` or `{` that starts a line never attaches
194+> to what came before, so `r` with `.a` on the next (indented) line is
195+> `r (.a)`, not `r.a`. Keep a selector or update on the line where the
196+> expression it applies to ends.
177197
178198### Functions
179199
@@ -214,7 +234,8 @@ distinct id each time it is evaluated (see §7).
214234
215235A `Blob` is the content of one file, either resolved or unresolved (a
216236conflict). You rarely write them directly; you get them from a commit's
217−`files`. `blob "…"` builds a resolved one, `text` reads it back:
237+`files`. It carries the file kind as well as its bytes. `blob "…"` builds a
238+resolved regular file from text; `text` reads it back:
218239
219240```
220241text (blob "file content")
@@ -265,9 +286,10 @@ in even 10
265286> binding column.
266287
267288Because 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.
289+evaluated immediately, though after the bindings it needs, whatever order
290+they are written in: `let a = b; b = 5 in a` is 5. Bindings that need each
291+other's values, as in `let a = b; b = a in a`, crash; recursion works through
292+*functions*, whose bodies aren't evaluated until applied.
271293
272294### No shadowing
273295
@@ -279,6 +301,12 @@ This is an error, not silent shadowing:
279301let x = 1 in let f = \x -> x in f 2 -- parse error: `x` is already bound
280302```
281303
304+The one exception is inside `config.j`: a binder there may reuse the name of
305+one of the config's own top-level definitions, as the reference config's
306+`newCommit = \files -> …` does, and within that binder's scope the name means
307+the binder. Builtin names and names bound by an enclosing lambda or `let` are
308+still refused there.
309+
282310### Lexical scope
283311
284312Names resolve lexically: innermost `let` bindings, then lambda parameters,
@@ -354,7 +382,7 @@ A bare `.name` is the accessor function `\x -> x.name`:
354382```
355383.name ({ name = "j" })
356384 => "j"
357−map (.id) ([{ id = 1 } { id = 2 }])
385+map (.id) [({ id = 1 }) ({ id = 2 })]
358386 => [1 2]
359387```
360388
@@ -533,6 +561,9 @@ crash "my message"
533561
534562Parse errors, configuration errors, and `Id`-resolution failures happen
535563*before* evaluation and are **not** catchable.
564+CLI diagnostics identify the source and line for syntax errors and offer
565+quoting, missing-argument, or spelling hints where available. Configuration
566+errors name the file to repair; `j help config` works even with broken config.
536567
537568---
538569
@@ -616,6 +647,13 @@ unresolved (blob "x") => false
616647`unresolved b` is `true` iff the blob is a conflict (produced by a clashing
617648`replay`). `text` on an unresolved blob yields its conflict-marker rendering.
618649
650+`blobHex kind hex` constructs a resolved blob with arbitrary bytes and a
651+file kind: `"regular"`, `"executable"`, or `"symlink"`. Its second argument
652+is an even-length hexadecimal text, accepting either letter case. Unknown
653+kinds and invalid hex crash. For example,
654+`text (blobHex "regular" "6869")` is `"hi"`. `text` crashes on non-UTF-8
655+content; persisting a symlink requires its target to be valid UTF-8 too.
656+
619657### Shapes and `extract`
620658
621659A type name in an expression is a `Shape`. `extract S v` returns every
@@ -647,6 +685,10 @@ A function that has a **signature** (a builtin, or a definition with one in
647685parameter type as it is supplied, and the final result against the result
648686type.
649687
688+Infix `&&` and `||` require their declarations in `config.j` and check the
689+contracts of each evaluated operand and the result. Short-circuiting skips
690+both evaluation and checking of the right operand.
691+
650692The check is one level deep — it never inspects inside a list or a record's
651693field values (that would make every call cost the size of the value).
652694
@@ -676,11 +718,17 @@ unchecked except through the functions they call.
676718
677719## 12. `show`: rendering values as text
678720
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
721+When it succeeds, `show v` renders a value as an expression in the language.
722+Except for closures and unresolved blobs, the result parses and evaluates
681723back to an equal value. Since `show` returns a `Text`, and a top-level `Text`
682724prints raw, `j show …` is how you inspect values.
683725
726+Regular blobs with UTF-8 content render as `blob "…"`. Other resolved blobs
727+render as `blobHex "kind" "…"` with lowercase hexadecimal bytes, preserving
728+binary content and executable or symlink kinds. If an older custom config
729+lacks `blobHex : Text -> Text -> Blob`, `show` crashes with instructions to
730+add it when such a value is encountered. Existing configs are not modified.
731+
684732```
685733show 42 => "42"
686734show "hi" => "\"hi\""
@@ -696,7 +744,7 @@ Functions render readably:
696744show (map) => "map"
697745show (+) => "(+)"
698746show (describe "wip") => "describe \"wip\""
699−show (\x -> x + 1) => "\x -> (x + 1)"
747+show (\x -> x + 1) => "\\x -> x + 1"
700748```
701749
702750Output longer than 80 columns breaks across lines (one element per line,
@@ -730,7 +778,8 @@ prev -- to the parent (crashes at the top)
730778next -- to the only child (crashes if not exactly one)
731779up -- like prev, the raw zipper move
732780by @wqzt -- refocus on a specific commit
733−goto %main -- refocus on what the remote calls main
781+tree . goto %main -- inspect main without moving the working copy
782+new . goto %main -- start a mutable child on main
734783parents -- the focus's parents, as a Revset
735784kids -- its children
736785ancestors -- it and its ancestors
@@ -743,6 +792,7 @@ Edits — functions `Repo -> Repo`:
743792
744793```
745794new -- a new empty child of the focus
795+save -- record file edits without changing the description
746796describe "wip" -- set the focus's message
747797squash -- fold the focus into its parent
748798abandon -- drop the focus; children join the parent
@@ -757,7 +807,9 @@ And the inspections:
757807
758808```
759809tree -- the history as a tree (a Text)
760−status -- { id, message, labels, changed, conflicts }
810+status -- { id, message, labels, changed, conflicts, unrecorded }
811+unrecorded -- paths edited since the last recording
812+preview squash -- validate and show the resulting tree without saving
761813review -- the focus's changes as one page of diffs
762814files -- the focus's snapshot (its files)
763815commits . top -- every commit, preorder
@@ -782,10 +834,13 @@ j 'tree'
782834-- the second commit was a mistake; fold it into the first
783835j 'squash'
784836
785−-- rename the combined commit
837+-- squash leaves a fresh empty child; move back and rename the combined commit
838+j prev
786839j 'describe "add the front end"'
787840
788−-- undo the squash entirely
841+-- undo the rename, the focus move, then the squash
842+j undo
843+j undo
789844j undo
790845
791846-- move back to the first commit to fix something
@@ -814,17 +869,22 @@ diff (blob "a\nb\n") (blob "a\nc\n")
814869 => "@@ -1,2 +1,2 @@\n a\n-b\n+c\n"
815870```
816871
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.
872+`difft p a b` renders the comparison with **difftastic** when it is on `PATH`
873+(the Nix package provides it). When it is absent, the builtin returns a
874+unified diff with path headers, or a summary for binary changes. `review`
875+uses `difft` per changed path; other errors starting the tool still fail.
819876
820877`treeWith options repo` renders the history tree with an options record:
821878
822879```
823−treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
880+treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
824881```
825882
826−The reference config gives two ready-made versions: `tree` (compact) and
827−`treeFull` (with ages and authors in the margin).
883+The reference config provides `tree`, `treeCompact` (folded runs and distant
884+subtrees), `treeFull` (ages and authors in the margin), and `treeData` (extra
885+date, files-changed, and author columns). Branches beyond `lanes` keep their
886+state marker beside `»`; increase `lanes` to show more rails. Long file paths
887+in detail rows wrap to fit the terminal.
828888
829889---
830890
@@ -900,8 +960,14 @@ the algebra they are.
900960
901961**Text:** `splitOn` `startsWith` `endsWith` `concat` `++`
902962
903−**Blobs/repos:** `blob` `text` `unresolved` `replay` `by` `meta` `extract`
963+**Blobs/repos:** `blob` `blobHex` `text` `unresolved` `replay` `by` `meta` `extract`
904964`diff` `difft` `treeWith` `validate`
905965
906−**Reserved commands** (not expressions): `init` `clone URL [DIR]` `remote URL`
966+**Reserved commands** (not expressions): `help [NAME]` `commands [QUERY]`
967+`setup [NAME EMAIL]` `init` `clone URL [DIR]` `remote URL`
907968`fetch` `push EXPR` `undo` `redo` `ops`.
969+
970+Literal reserved-command arguments preserve shell quoting boundaries, as in
971+`j clone URL 'my project'`. A whole reserved command passed as one string or
972+on stdin is split on whitespace. Expression arguments still use language
973+syntax: quote the whole expression, as in `j 'describe "my change"'`.
docs/tutorial.md+102−36
@@ -4,9 +4,17 @@ This is a guided first hour with `j`. It builds the mental model piece by
44piece — not just *what* to type, but *why it works* — so that by the end you
55can compose your own commands instead of memorizing them.
66
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.
7+You'll want `j` installed (`nix build` or `cargo build --release`). Set up
8+your configuration with your own name and email:
9+
10+```
11+j setup 'Ada Lovelace' ada@example.org
12+```
13+
14+This creates the full reference configuration without overwriting an existing
15+file. If you already have a config, `j help config` shows its location so you
16+can edit the `user` definition. The remaining commands run in a real
17+repository, so you can follow along.
1018
1119---
1220
@@ -20,11 +28,11 @@ j init
2028```
2129
2230`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:
31+working-copy commit: the commit you are currently editing. It already exists,
32+so write a file and describe that commit:
2433
2534```
2635echo 'fn main() {}' > main.rs
27−j 'new'
2836j 'describe "start the project"'
2937```
3038
@@ -36,35 +44,48 @@ j 'tree'
3644
3745```
3846 ⌂ zzzz
39− └─ ● pnyz ▂
40−▶ └─ ◌ psnq start the project
47+▶ └─ ◉ pnyz ▂ start the project
4148```
4249
4350Add a second change on top:
4451
4552```
46−echo 'lib' > lib.rs
4753j 'new'
54+echo 'lib' > lib.rs
4855j 'describe "add a library"'
4956j 'tree'
5057```
5158
5259```
5360 ⌂ zzzz
54− └─ ● pnyz ▂
55− └─ ● psnq ▂ start the project
56−▶ └─ ◌ trtq add a library
61+ └─ ● pnyz ▂ start the project
62+▶ └─ ◉ psnq ▂ add a library
5763```
5864
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.
65+That's the basic loop: **edit files, describe the current change, then `new`
66+before editing the next change.** `new` opens an empty change with the same
67+files as its parent; it does not move existing edits into that new change.
68+There is no `git add` step. `describe` saves both the current files and their
69+description; `j save` records file edits without changing the description.
6270
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,
71+**What just happened, in the model:** each expression snapshots your working
72+directory for evaluation, builds a `Repo` value focused on that commit,
6573evaluates your expression, and — because `new` and `describe` are *edits*
6674(functions `Repo -> Repo`) — persists the resulting repository.
6775
76+`j status` has two useful lists: `in commit` compares the current commit with
77+its parent, while `unrecorded` lists file edits made since the last save.
78+After `describe` or `save`, `unrecorded` is empty; `in commit` still shows the
79+work in the commit. Inspecting status does not save anything.
80+
81+To join an existing project instead, clone it and enter its directory:
82+
83+```
84+j clone URL project
85+cd project
86+j status
87+```
88+
6889---
6990
7091## Part 2 — The one idea everything rests on
@@ -133,22 +154,28 @@ function produces, so don't wrap it.
133154Make a few commits to play with:
134155
135156```
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"'
157+j new; echo a > a.txt; j 'describe "A"'
158+j new; echo b > b.txt; j 'describe "B"'
159+j new; echo c > c.txt; j 'describe "C"'
139160```
140161
141−Navigation is edits too:
162+Navigation changes the working-copy commit and checks out its files. Use
163+these commands to edit another mutable commit:
142164
143165```
144166j 'prev' -- to the parent
145167j 'next' -- to the only child
146−j 'top' -- to the top of history
147168j 'tip' -- to the end of the current line
148169j '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
150170```
151171
172+At a fork, choose an id from `j tree` with `j 'by @id'`; `next` only works
173+when there is one child. Main and the root commit are protected from editing.
174+Use `j 'new . goto trunk'` to start work on main, or
175+`j 'tree . goto trunk'` to inspect it without moving your working copy.
176+In a new local repository with no main bookmark, keep working from `j init`'s
177+commit instead.
178+
152179After `j 'prev'` from C, the tree shows the focus (▶ and ◉) on B:
153180
154181```
@@ -164,11 +191,16 @@ To see the focus's data, use the inspection functions:
164191```
165192j 'files' -- the focus's files
166193j 'changed' -- paths that differ from the parent
167−j 'status' -- id, message, labels, changed, conflicts
194+j 'status' -- focus, changes in the commit, unrecorded edits, conflicts
168195j 'log' -- a table of every commit, ✓ on the focus
169196j 'review' -- the focus's changes as diffs (difftastic)
170197```
171198
199+These inspections include current file edits without recording them. Use
200+`j save` to record file edits on their own (`id` is the same edit). `status` spells out empty states
201+such as `No changes` and `No conflicts`; `log` shows label names and short
202+ids and adapts to narrow terminals.
203+
172204### Composition order, made concrete
173205
174206```
@@ -194,11 +226,10 @@ operates on the repository the previous step produced.
194226
195227## Part 4 — Dry runs: seeing before doing
196228
197−Because inspections persist nothing, you can preview any edit by composing it
198−with `tree`:
229+Use `preview` to validate an edit and show its resulting tree without saving:
199230
200231```
201−j 'tree . squash'
232+j 'preview squash'
202233```
203234
204235From the A → B history, this *shows* the tree as `squash` would leave it (B
@@ -221,18 +252,21 @@ because `tree` returns a `Text`, not a `Repo`:
221252▶ └─ ◌ nknz B
222253```
223254
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:
255+`preview` combines `tree` and `validate`. For an edit that takes arguments,
256+put that edit in parentheses:
227257
228258```
229−j 'tree . validate . rebase %main'
259+j 'preview (rebase %main)'
230260```
231261
232262This renders the post-rebase tree **and** crashes (without touching anything)
233263if that rebase couldn't actually happen — for instance, if `%main` doesn't
234264exist yet. That's an *honest* dry run.
235265
266+The longer form is `j 'tree . validate . rebase %main'`. `validate` alone
267+does not make an edit a dry run: `j 'validate . squash'` returns a repository
268+and saves it. The final inspection (`tree`) is what prevents persistence.
269+
236270---
237271
238272## Part 5 — `or`: the whole error-handling model
@@ -290,7 +324,7 @@ Two things make these safe to use freely:
290324
2913251. **The change id survives.** A commit keeps its `@wqzt…` id across
292326 `describe`, `squash`, `rebase`. You're never tracking a moving sha.
293−2. **`j undo` always works.** Every operation is recorded.
327+2. **Saved operations are undoable.** Use `j undo` and `j redo` to walk them.
294328
295329```
296330j undo -- revert the last thing
@@ -298,6 +332,28 @@ j redo -- reapply it
298332j ops -- see the operation log
299333```
300334
335+Undo and redo stop when files have unrecorded edits. Recording those edits
336+with `j id` adds an operation, so the next undo removes that recording;
337+another undo reaches the preceding edit. Recording also clears pending
338+redos. Read `j ops` to choose how far to go back. Successful interactive
339+undo/redo messages identify the affected operation and restored focus.
340+
341+### Resolve a conflict
342+
343+A successful rebase can leave conflicts in the working-copy commit. The edit
344+is saved; there is no separate continuation command. Resolve it in four steps:
345+
346+1. Run `j status` to find conflicted paths, and `j review` to inspect changes.
347+2. Edit those files, choosing the intended content and removing conflict markers.
348+3. Run `j save` to record the resolution.
349+4. Run `j status` again and check for `No conflicts` and `All edits recorded`.
350+
351+`j conflicts` lists conflicted commits throughout the repository. If a
352+conflict is on another mutable commit, use its id from `j tree` with
353+`j 'by @id'`, then follow the same steps. Run `j conflicts` again to check
354+the whole history. `review` uses difftastic when
355+installed and falls back to a unified diff when it is unavailable.
356+
301357### `split` and `contract`: moving changes by fileset
302358
303359A **fileset** is a function `Path -> Bool`. These two are inverses:
@@ -341,9 +397,12 @@ Most commands that take a "which commits" argument take a revset. `goto` is
341397the one place a revset must name *exactly one* commit:
342398
343399```
344−j 'goto %main' -- crash if %main names 0 or 2+ commits
400+j 'new . goto %main' -- select one main commit and start a mutable child
345401```
346402
403+A missing label reports how to fetch and inspect remote labels. An ambiguous
404+selector lists candidate ids and descriptions so you can choose with `by`.
405+
347406Revsets combine like sets:
348407
349408```
@@ -387,9 +446,9 @@ Everything `j` knows is defined in `config.j`. Any definition whose value is
387446an `Edit` is a new command. Add this to your `~/.config/j/config.j`:
388447
389448```
390−-- fold the whole stack into one commit called "release"
449+-- squash into the parent and name the combined commit "release"
391450release : Edit
392−release = describe "release" . (contract everything)
451+release = describe "release" . prev . squash
393452```
394453
395454Now `j release` works exactly like `squash` or `new`. The signature (`: Edit`)
@@ -432,8 +491,8 @@ echo 'fn parse() {}' > parser.rs
432491j 'describe "add the parser"'
433492
434493# second part
435−echo 'mod parser;' > lib.rs
436494j 'new'
495+echo 'mod parser;' > lib.rs
437496j 'describe "wire up the parser"'
438497
439498# look at it
@@ -442,17 +501,24 @@ j 'tree'
442501# the second commit was really part of the first; fold it in
443502j squash
444503
504+# squash leaves an empty child focused; move to the combined commit
505+j prev
506+
445507# rename the combined commit
446508j 'describe "add the parser"'
447509
448510# preview the rebase, then do it
449−j 'tree . validate . rebase %main'
511+j 'preview (rebase %main)'
450512j 'rebase %main'
451513
452514# share it
453515j 'push (label "parser" here)'
454516```
455517
518+Push sends recorded commits. If you edit files after the last saved edit,
519+push warns that those edits are omitted. Run `j save`, then push again to
520+include them. Looking at `j status` does not save the edits.
521+
456522And if any step goes wrong:
457523
458524```
specs/main.md+948−198
@@ -9,7 +9,7 @@ Nothing is implicit beyond the language itself. Every shape, builtin, and
99command is declared or defined in one user-owned file, `~/.config/j/config.j`,
1010written in the language. The binary provides the language (its syntax, its
1111operators and their fixities, its primitive kinds), the implementations of the
12−declared builtins, eight reserved words, and the bridge to jj repositories.
12+declared builtins, eleven reserved words, and the bridge to jj repositories.
1313`j` does not require the `jj` binary; it uses jj's on-disk formats through
1414jj-lib.
1515
@@ -40,21 +40,37 @@ echo EXPRESSION | j
4040 `::`, `or`, `.`, and the literals `%name`, `@id`, `./a/b` are all safe
4141 unquoted.
42423. 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.
43+ `j: expression given both as arguments and on stdin`. One byte is
44+ sufficient to reject the invocation; the pipe need not reach EOF.
45+4. If neither is present, or the input is only whitespace, print a concise
46+ usage and getting-started guide to stderr and exit 2. The guide points to
47+ `j help`, `j commands`, and commands for starting or inspecting a repository.
48+ It does not read or create configuration or open a repository.
49+5. `--help` and `-h` as the first word alias `help` (§1.1). A separate
50+ argument equal to either flag after the first argument requests help for
51+ the first word, before repository access or evaluation: `j abandon --help`
52+ is `j help abandon`. An initial `help`, `--help`, or `-h` followed by
53+ another help flag opens general help. Other flags are not supported.
54+ Language comments embedded within a single argument or stdin are unchanged:
55+ `j 'new --help'` executes `new`, and `j '"--help"'` prints text.
56+6. The expression is text: an argument that is not valid UTF-8 is a usage
57+ error (exit 2) naming it, and so is an expression on standard input that
58+ is not.
4759
4860### 1.1 Reserved commands
4961
5062Before the expression is parsed, its text is trimmed of leading and trailing
5163whitespace 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.
64+eleven reserved words below, the whole text is a reserved command. A first word
65+of `--help` or `-h` is treated as `help`.
5366
5467| command | operation |
5568|---|---|
69+| `help [NAME]` | show the command guide, or help for a reserved command or config name; §1.1.1 |
70+| `commands [QUERY]` | show common tasks, search the vocabulary, or use `all` for the full catalog; §1.1.1 |
71+| `setup [NAME EMAIL]` | create the complete reference configuration, optionally setting identity; §6.1.1 |
5672| `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 |
73+| `clone URL [DIR]` | clone `URL` into `DIR` (default: the repository's name in `URL`); §7.8 |
5874| `remote URL` | set the URL of the remote `origin`, creating it if absent; §7.8 |
5975| `fetch` | fetch from `origin`; §7.6 |
6076| `push EXPR` | set or delete the remote bookmarks that `EXPR` selects; §7.6 |
@@ -62,24 +78,64 @@ eight reserved words below, the whole text is a reserved command.
6278| `redo` | reverse the newest undo; §7.7 |
6379| `ops` | print the operation log; §7.7 |
6480
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).
81+For every reserved command except `push` and `commands`, the remaining words
82+are literal arguments, and the language is not involved. When the first
83+shell argument is the command name, the remaining shell argument boundaries
84+are preserved, so `j clone '/path/to/source repo' 'my project'` accepts both
85+paths. A command supplied as one string or on stdin is split on whitespace;
86+it has no additional quote or escape syntax. A wrong argument count is a
87+usage error (exit 2). `commands` takes the remaining
88+trimmed text as its optional search query, including spaces. For `push`, the
89+remainder of the text is an expression, evaluated as in §1.2 steps 2–7 but
90+against the repository as recorded, without snapshotting the working directory;
91+its value must be a list of push records (§7.6).
7192
7293Matching is on the first word only, so an expression whose first token is one
7394of these words cannot be written on the command line. `(push)` or `x fetch`
7495are ordinary expressions. `config.j` may define functions with these names;
7596they are reachable only inside larger expressions.
7697
98+#### 1.1.1 Help and command discovery
99+
100+`help` without a name prints a getting-started guide to stdout: repository
101+setup, common commands grouped by task, examples, expression quoting and
102+composition, dry runs, and how to find more commands. `help NAME` prints help
103+for that reserved command or for a name in the current configuration, including
104+its signature when present and available documentation. `--help [NAME]` and
105+`-h [NAME]` are equivalent to `help [NAME]`. These successful requests exit 0.
106+An unknown name is a usage error (exit 2) with guidance to use `j commands`.
107+`j help config` is a built-in topic showing the configuration path, identity
108+setup, and repair instructions. It is available even with invalid configuration.
109+
110+`commands` shows a concise catalog of common tasks and their commands.
111+`commands all` lists the reserved commands and vocabulary actually declared
112+or defined in the current configuration, including custom definitions without
113+signatures. The concise catalog includes a bounded selection of custom
114+definitions and points to the full list. `commands QUERY` searches the catalog case-insensitively by name,
115+signature, documentation comments, and common task synonyms such as `commit`,
116+`checkout`, and `branch`, ranking direct matches and everyday commands first.
117+The `stash` query explains the save/new workflow when no custom `stash`
118+definition exists. The catalog does not present an
119+undeclared builtin as available merely because the binary implements it.
120+
121+General help and help for reserved commands do not depend on configuration and
122+remain available if it is unreadable or invalid. Discovery of configuration
123+names reads the configured file (§6.1) without creating or modifying it. If the
124+file is missing, it uses the embedded reference configuration; other read or
125+parse errors exit 3 rather than silently substituting a different catalog.
126+The config is never evaluated for discovery, so its definitions can refer to
127+repository state without preventing their documentation from being read.
128+
129+Neither command locates or opens a repository, takes a repository lock,
130+resolves commit ids, snapshots the working directory, or records operations.
131+
77132### 1.2 Evaluating the expression
78133
791341. Locate the repository (§7.1). If none, exit 2.
801352. Parse the expression (§3). On parse error, exit 3.
811363. Load and validate `config.j` (§6), except for its `Id` literals. If it is
82− missing or invalid, exit 3.
137+ missing, create the reference configuration (§6.1); if loading or
138+ validation fails, exit 3.
831394. Build the current repository value `r` (§7.2). This includes snapshotting
84140 the working directory into the focused commit (§7.4).
851415. Resolve every `Id` literal in `config.j` and in the expression (§4.10).
@@ -90,7 +146,8 @@ they are reachable only inside larger expressions.
901467. If `v` is a function, apply it to `r`, once, and let `v` be the result.
911478. If `v` is a `Repo` (§4.4): if it equals the repository value *as loaded,
92148 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.
149+ (§7.5) and check out its focused commit's files (§7.4). Print nothing to stdout;
150+ interactive stderr reports the outcome and focus (§1.5).
94151 Exit 0.
951529. Otherwise display `v` (§5.1) on stdout. Persist nothing. Exit 0.
96153
@@ -100,7 +157,7 @@ persist nothing, leave the working directory untouched, and exit 1.
100157Step 7 applies exactly once. If the result of applying a function to `r` is
101158again a function, that function is displayed, not applied again.
102159
103−Step 8's equality test means `j id` is a no-op when the working directory is
160+Step 8's equality test means `j save` (the reference alias for `id`) is a no-op when the working directory is
104161unchanged and records it when it is not: the snapshot makes `r` differ from
105162the loaded value, and that difference is what gets persisted. The test may be
106163implemented by comparing commit and tree hashes rather than values.
@@ -112,16 +169,34 @@ Two consequences worth knowing:
112169 what a rebase would produce without doing it. `validate` in the chain,
113170 `j tree . validate . rebase trunk`, additionally raises whatever
114171 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.
172+ actually be persisted. The reference `preview EDIT` is the convenient
173+ form of `tree . validate . EDIT`: `j 'preview (rebase trunk)'`. Validation
174+ alone returns a `Repo`, so `validate . EDIT` persists a changed result.
175+- **Do, then look.** `j squash` reports the resulting focus on an interactive
176+ terminal. `j squash && j tree` displays the whole tree; there is no flag for it.
118177
119178### 1.3 Interrupts
120179
121180There 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.
181+Interrupting `j` (SIGINT) kills it, as SIGTERM, SIGHUP and SIGQUIT do.
182+Nothing is written until the operation is recorded (§7.5 step 7), so a run
183+interrupted before then persists nothing and leaves the working directory
184+untouched. From the recording until the focus is checked out and the
185+working copy's state is saved, those four signals wait: the checkout
186+completes, the run reports what it did, and then the signal takes effect as
187+it would have. A run that ends in that window regardless (SIGKILL, a crash,
188+a power loss) leaves the operation recorded and the working directory
189+partly updated, as a checkout that fails does: the next run carries on from
190+there (§7.4), and `j undo` goes back.
191+
192+`clone` (§7.8) writes its directory from the start, so it holds the four
193+signals off from creating it. One that comes while it creates the
194+repository, fetches and imports lets the step in progress end (the git it
195+runs, which a Ctrl-C at the terminal stops too), then fails the clone, which
196+removes what it made, and then takes effect; one that comes later, once it
197+makes the working-copy commit, waits for the clone to complete. A signal `j`
198+was started ignoring (`nohup`, a background job) stays ignored, by the git
199+it runs too.
125200
126201### 1.4 Exit status
127202
@@ -132,10 +207,67 @@ working directory untouched.
132207| 2 | usage error: bad arguments, no repository, unsupported repository (§7.2), another `j` running (§7.7) |
133208| 3 | configuration or parse error |
134209
210+If standard output is a pipe whose reader has exited (`j tree | head -1`), the
211+rest of the output is dropped and `j` exits 0; any other failure to write
212+output is an error, status 1. A message that cannot be written to stderr, for
213+whatever reason, is dropped and the run keeps its status:
214+`j 'crash "boom"' 2>&1 | true` still exits 1.
215+
135216Errors are written to stderr as a single line beginning with `j: `. Crash
136217messages are the text given to `crash` (or generated by the interpreter),
137218prefixed with `j: crash: `, followed on a second line by the innermost base
138−definition that was executing and the top-level expression.
219+definition that was executing and the top-level expression. A definition is
220+executing while the body of its lambda runs, whether it has a signature or
221+not, and one with a signature also while its value runs when that is a
222+builtin (`squash`) and while an argument or result of it is checked (§4.13),
223+so `describe 3` is in `describe`. A lambda's body is its own definition's
224+under any other name: `prev = goto parents` runs goto's body, so `prev . top`
225+is in `goto`. A builtin is not a definition, so a crash in one is in
226+whichever definition applied it, and with none executing the line is only
227+` from <expression>`.
228+
229+### 1.5 Terminal presentation
230+
231+Automatic colors are used only on a terminal, and disabled by `TERM=dumb`
232+or a nonempty `NO_COLOR`. Diagnostics use the stderr terminal; displayed
233+values use stdout. Error prefixes are bright red, informational prefixes
234+bright cyan, and interactive successful commands have a green checkmark.
235+Color adds emphasis without replacing the text of errors or values.
236+Successful edits report `Saved changes` or `No changes`, followed by the
237+resulting focus's copyable, unique `@` prefix and first description line
238+(`(no description)` when empty). Setup reports the effective author identity.
239+Undo and redo identify the affected operation and restored focus.
240+After a saved edit, conflicts newly introduced anywhere in the history and
241+conflicts in the checked-out focus produce a warning with counts, paths, and
242+resolution guidance. This warning remains visible when output is redirected;
243+the operation still succeeds. Guidance identifies `j conflicts`, editing
244+conflict markers, recording the resolution with `j save` or `j id`, and
245+checking again. For another commit, navigate with `by` first.
246+
247+When both stdout and stderr are capable terminals, repository operations,
248+configuration evaluation, and expression evaluation (including implicit application)
249+may show a temporary, color-cycling progress line on stderr. It starts after
250+140 ms, advances every 75 ms, and names the current operation alongside a
251+spinner, a bouncing activity bar, and elapsed time since that operation began.
252+The bar is indeterminate and does not estimate completion. Each frame fits
253+the current stderr terminal width with a spare column, omitting the bar and
254+then the time before truncating the operation's name by display width. The
255+line is cleared before the command reports its result.
256+Fast operations never wait for a progress frame. It does not hide
257+the cursor or change terminal modes. Signal behavior remains as in §1.3.
258+Before an external renderer inherits stderr, progress is cleared and stopped
259+for the remainder of the run so even an unterminated diagnostic stays visible.
260+
261+Colored multiline output is revealed in at most seven batches, with at
262+most 96 ms of total pacing. Its bytes and row order are unchanged. Plain
263+text is written directly. `J_ANIMATIONS=never` (also `off`, `false`, or `0`,
264+case-insensitive) disables all motion while retaining colors. Motion is
265+also disabled by nonempty `NO_COLOR` or `CI`, `TERM=dumb`, or either output
266+stream being redirected. Success messages are omitted only when `CI` is
267+nonempty or either output stream is redirected. `NO_COLOR` and `TERM=dumb`
268+retain plain-text success messages on terminals; `J_ANIMATIONS` alone does
269+not suppress them.
270+Broken pipes and failed writes keep the behavior in §1.4.
139271
140272---
141273
@@ -167,7 +299,8 @@ same value if they have the same field names with equal values.
167299
168300### 3.1 Lexical structure
169301
170−- **Comments.** `--` to end of line. `{-` … `-}`, nesting.
302+- **Comments.** `--` to end of line. `{-` … `-}`, nesting. A comment is
303+ whitespace: a block comment ends a line (§3.3) only by containing a line break.
171304- **Identifiers.** `[a-z_][A-Za-z0-9_']*`. Keywords are excluded:
172305 `let in if then else or true false`.
173306- **Type names.** `[A-Z][A-Za-z0-9_]*`. Declared as shapes in `config.j`;
@@ -223,7 +356,7 @@ keyword and is always available.
223356
224357### 3.3 Layout
225358
226−Two layout rules and nothing else.
359+Three layout rules and nothing else.
227360
2283611. **Top level.** A top-level item begins with a token in column 1. Every
229362 following line whose first token is in a column greater than 1 continues
@@ -231,12 +364,22 @@ Two layout rules and nothing else.
2313642. **`let` blocks.** After `let`, the column of the first token opens a block.
232365 A subsequent line whose first token is in exactly that column begins a new
233366 binding; a line indented further continues the current binding; the block
234− ends at `in` or at the first token in a smaller column. Bindings on a single
235− line are separated by `;`. Blank and comment-only lines are ignored here
236− too.
367+ ends at `in` or at the first token in a smaller column. An `in` that begins
368+ a line obeys the enclosing layout like any other token: in an item it must
369+ be past column 1, and in an outer block's binding past that block's column.
370+ Bindings on a single line are separated by `;`. Blank and comment-only
371+ lines are ignored here too.
372+3. **Selectors and updates.** A `SELECTOR` or `{` that is the first token of
373+ a line never selects from or updates what precedes it. On a continuation
374+ line it begins a new `postfix` (§3.4), which after another `postfix` is
375+ the next application argument or list element: `r` followed by a line
376+ ` .a` is `r (.a)`, and followed by ` { a = 2 }` it is `r ({ a = 2 })`.
377+ So `r.a` and `r { a = 2 }` need the `.a` or `{` on the line `r` ends on.
378+ `show` relies on this to break a list of records one per line with nothing
379+ between them (§5.2).
237380
238381An expression given on the command line or on stdin is a single `expr`, not a
239−list of items, so rule 1 does not apply to it; rule 2 does.
382+list of items, so rule 1 does not apply to it; rules 2 and 3 do.
240383
241384### 3.4 Grammar
242385
@@ -262,7 +405,7 @@ binding := ident '=' expr
262405
263406opexpr := app (op app)* -- resolved by the fixity table
264407app := postfix+ -- juxtaposition; left-associative
265−postfix := atom ( SELECTOR | '{' fields '}' )*
408+postfix := atom ( SELECTOR | '{' fields '}' )* -- no SELECTOR or '{' after a line break (§3.3)
266409atom := ident | TYPENAME | INT | TEXT | IDLIT | NEWID | LABELLIT | PATHLIT | 'true' | 'false'
267410 | '(' expr ')' | '(' op ')' | '(' op expr ')' | '(' expr op ')'
268411 | '[' postfix* ']'
@@ -290,9 +433,23 @@ Notes:
290433 a `postfix` (an atom with selectors and updates): `[1 2 3]`,
291434 `["src" "lexer.rs"]`, `[{ root = c, children = [] }]`. An application or
292435 operator expression as an element is parenthesised: `[(up repo).root.id]`.
293− Records keep commas.
436+ So is a record literal after another element, `[({ a = 1 }) ({ a = 2 })]`:
437+ `[{ a = 1 } { a = 2 }]` is the one-element list `[{ a = 2 }]`, the first
438+ record updated by the second. Records keep commas.
294439- Lambda, `if`, `let`, and `or` extend as far right as possible.
295440- Patterns are variables and `_` only. No literal, list, or record patterns.
441+- Nesting is bounded: an expression more than 40,000 levels deep is a parse
442+ error (§1.4), never a stack overflow. A name, literal, `(op)` or `.name`
443+ accessor is one level; a parenthesis or section, and the braces of a record
444+ or update, are two levels above what they enclose; every other construct
445+ is one level above its highest part. A chain `a + b + c …`, `f x y …` or
446+ `r.a.b …` is thus one level deeper per link, as are right-associative
447+ operators, `or`, and `\`/`if`/`let` bodies. Lists nest 40,000 deep,
448+ parentheses and records about 20,000, and a `Subtree` as `show` renders it
449+ (§5.2) about 13,000: three levels a commit, the braces of its record and
450+ the list around them, as a list broken over lines holds records without
451+ parentheses (§3.3). A type in `config.j` is bounded the same way: one
452+ level, plus one for each bracket, parenthesis, field or `->` around it.
296453
297454---
298455
@@ -308,8 +465,19 @@ refers to outside a lambda. A cycle among such references (two non-lambda
308465definitions each needing the other's value) is a configuration error;
309466references inside lambda bodies never form cycles, since a lambda is a value
310467before it is applied. The bindings of a `let` block are evaluated by the same
311−rule, with a cycle being a crash. A lambda body is evaluated on each
312−application.
468+rule, with a cycle being a crash. A top-level definition is moreover evaluated
469+after every definition that it reaches through a chain of references of
470+either kind but that does not reach it back. Among definitions that do reach
471+each other, one that refers to none of the others outside a lambda, and whose
472+every lambda outside other lambdas that refers to one of them is stored, is
473+evaluated before the rest, since it cannot apply their functions; an
474+expression is stored when it is the whole definition, or a record field, list
475+element, `if` branch or `let` body of a stored expression. So a function
476+applied at load finds the definitions its body refers to, save one that
477+reaches back to the definition applying the function and is not evaluated
478+before the rest; the order among the rest is otherwise unspecified. A `let`
479+binding is ordered the same way among the bindings of its block. A lambda
480+body is evaluated on each application.
313481Recursion is unbounded; the interpreter must handle deep recursion without
314482stack overflow (or turn exhaustion into a crash).
315483
@@ -323,9 +491,18 @@ function.
323491
324492There is no shadowing. A `let` binding or lambda parameter may not use a name
325493already bound in an enclosing scope, including top-level definitions and
326−declared builtins; in `config.j` that is a configuration error (exit 3), on
327−the command line a parse error. Defining a top-level name twice, or defining a
328−name that is also a declared builtin, is a configuration error.
494+builtins (reserved whether or not declared, §6.2); in `config.j` that is a
495+configuration error (exit 3), on the command line a parse error. A lambda's
496+earlier parameters enclose its later ones, and all bindings of a `let` block
497+enclose each of its expressions, since they are mutually recursive (§4.1), so
498+`\x x -> x` and `let a = \b -> b; b = 2 in a 1` are errors too. The one
499+exception is within `config.j`, whose binders may reuse the names of its own
500+top-level definitions, as the reference config's `\files ->` and
501+`\old new repo ->` do: within the binder's scope the name means the binder,
502+for evaluation order (§4.1) as for lookup, so a `let` binding used before it
503+is evaluated is a crash there as anywhere, never the definition. Defining a
504+top-level name twice, or defining a name that is also a builtin, is a
505+configuration error.
329506
330507### 4.3 Records
331508
@@ -351,7 +528,9 @@ negation. Ordering operators are defined on `Int` only and crash otherwise.
351528
352529`if`, `or`, `&&`, and `||` do not evaluate all operands. `&&` and `||`
353530short-circuit. The interpreter recognises `&&` and `||` by name; `or` is a
354−keyword.
531+keyword. Like other builtins, `&&` and `||` must be declared. Their contracts
532+check each evaluated operand and the final result; a skipped operand is
533+neither evaluated nor checked.
355534
356535`a or b` evaluates `a`. If that crashes, the crash is discarded and `b` is
357536evaluated and returned. Otherwise, if the value of `a` is a function, `b` is
@@ -417,14 +596,19 @@ as in §4.13.
417596| `replay onto ch` | replay the change `ch` (`{ from, to }`) onto the snapshot `onto`; §7.3 |
418597| `unresolved b` | `true` iff the blob is a conflict |
419598| `blob t` | a resolved regular-file blob with content `t` |
599+| `blobHex kind hex` | a resolved blob of kind `"regular"`, `"executable"`, or `"symlink"`, with bytes decoded from an even-length hexadecimal text; accepts either letter case, crashes for an unknown kind or invalid hex |
420600| `text b` | the content of a blob as text; for an unresolved blob, its conflict-marker rendering |
421601| `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 |
422−| `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 |
423−| `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` |
602+| `lookupCommit i repo` | `(by i repo).root`, indexed once per repository value without constructing a location for each lookup. Duplicate ids select the first preorder match; malformed trees retain `by`'s traversal behavior |
603+| `lookupContent p s` | `(head (filter (\e -> e.path == p) s)).content or blob ""`, with an index for ordinary paths. The full filter's errors and first-match behavior are preserved; the reference `contentAt` uses this builtin |
604+| `subtreeCommits t` | every commit of the tree `t` (anything with `root` and `children`) in preorder, in time linear in its size: the value of `t.root :: (concat (map subtreeCommits t.children) or [])`. So it crashes only if `t` has no `root`, and a node whose `children` is not a list of values that have a `root` contributes only its own. The reference `commits` is this builtin (§9) |
605+| `diff a b` | a unified diff from the text of `a` to the text of `b`, split into lines after each `\n` (not at a lone `\r`), no `---`/`+++` header lines, three lines of context, each hunk headed `@@ -s,n +s,n @@` with the first line and the number of lines it shows of each side, as `diff -u` writes it, a line that has no trailing newline followed by `\ No newline at end of file`; `""` if equal; crashes if either is not UTF-8 |
606+| `difft p a b` | the output of difftastic comparing `a` to `b` as the file `p`, with a unified-diff fallback when the executable is absent; §7.10 |
607+| `unrecorded repo` | paths changed between the invocation's loaded and snapshotted focus files; §7.4. Returns `[]` without snapshot context, including in a push expression |
424608| `treeWith o r` | the history rendered as a tree with options record `o`; §7.11 |
425609| `extract S v` | every subvalue of `v` that is an `S`; §4.12 |
426610| `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) |
427−| `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 |
611+| `validate repo` | `repo` unchanged if persisting it would succeed; otherwise the crash persistence would raise (§7.5 steps 1–3, the root invariant of 4, and 6). A changed repository also checks the backend's author identity (§7.9). Persistence runs the same checks |
428612
429613### 4.10 Ids: existing and new
430614
@@ -452,10 +636,12 @@ renders a minted id like any other.
452636
453637### 4.11 Label literals
454638
455−`%main` denotes `labelled "main"`, a `Revset`. It is a function, evaluated
456−when applied, so it never fails at parse time: a name the remote does not have
457−yields `[]`, and `goto %nope` crashes with "expected one revision, got 0" as
458−any revset would.
639+`%main` denotes `labelled "main"`, a `Revset`, as if written out where it
640+stands: in the scope of a `config.j` binder named `labelled` (§4.2) it applies
641+the binder, and it is a reference to it for evaluation order (§4.1). It is a
642+function, evaluated when applied, so it never fails at parse time: a name the
643+remote does not have yields `[]`, and `goto %nope` crashes with "expected one
644+revision, got 0" as any revset would.
459645
460646### 4.12 Shapes as values, and `extract`
461647
@@ -463,7 +649,8 @@ A type name used in an expression evaluates to a *shape*: for a `typedecl`
463649whose type is a record, the set of its field names; for a primitive kind
464650(`Int`, `Text`, `Bool`, `Id`, `Blob`), that kind; for a `typedecl` that is an
465651alias of another type, that type's shape. A shape whose type is a function
466−(`Edit`, `Revset`), a list, or undeclared is a crash when used.
652+(`Edit`, `Revset`), a list, or undeclared is a crash when used, as is an
653+alias that never reaches a type (`A = B` with `B = A`).
467654
468655`extract S v` returns every subvalue of `v` that is an `S`, as a list. It
469656walks lists in order and records in ascending field-name order, descending
@@ -488,11 +675,32 @@ its `root`. Where order matters, `commits (top repo)` is preorder.
488675Every application of a function that has a signature — a builtin or a
489676definition with a signature — is checked against it: each argument as it is
490677supplied, against the corresponding parameter type, and the result once the
491−application is complete (yields a non-function), against the result type.
678+function's own application completes (its body has run or the builtin has
679+all its arguments), against the result type.
492680Lambdas without a signature and the command-line expression are unchecked
493−except through the functions they call. A signature on a definition that is
494−not a function (`user`, `conflicts`) is checked once, against the value, when
495−the definition is evaluated at load.
681+except through the functions they call. A signature covers all the arguments
682+it lists, however the definition's value is curried: `describe : Text -> Edit`
683+is `\m -> mapRoot …`, and the `Repo` that `describe m` is then applied to is
684+checked as its second argument, and the result against `Repo`; with
685+`addT : Int -> Int -> Text` and `addT = (+) . (\x -> x + 1)`, the `5` of
686+`addT 1 5` is checked as its second argument, and the `7` against `Text`. A
687+signature only adds checks and never changes when a body is evaluated
688+(§4.1): `describe m` evaluates `mapRoot …` at once, so an `or` around it
689+catches a crash there (§4.6); the value must be a function, since the
690+signature lists a further argument, and shows as `describe m` (§5.2);
691+`addT 1` likewise shows as `addT 1`, not as the `(+) 2` it holds. Arguments
692+are numbered as the signature lists them, even when the definition's value is
693+a partial application or a composition holding arguments of its own (`tree 5`
694+is a violation at argument 1); when the value is an `or` of functions (§4.6),
695+the check is around the lifted function, so a result that violates the
696+signature is a crash, not a reason to try the other side. A signature on
697+a definition that is not a function (`user`, `conflicts`) is checked once,
698+against the value, when the definition is evaluated at load.
699+
700+Infix `&&` and `||` must be declared like other builtins. Their contracts
701+check each operand that is evaluated and the result, including a result
702+decided by the left operand alone. A skipped right operand is neither
703+evaluated nor checked.
496704
497705The check is one level deep and never inspects inside a list or a record's
498706field values:
@@ -504,7 +712,7 @@ field values:
504712| `{ a : T, … }`, or a shape name | the value is a record with exactly those field names |
505713| `A -> B` | the value is a function |
506714| a type variable | anything |
507−| 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) |
715+| 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); an alias the unfolding has already unfolded is not unfolded again, so `Stream = Int -> Stream` checks one argument and a function result |
508716
509717Field values and list elements are not checked because that would make every
510718call cost the size of the value and would force lazily backed snapshots and
@@ -537,7 +745,9 @@ indented under their key or bullet. Colour follows §7.11's rules and is used
537745only when stdout is a terminal; nothing is conveyed by colour alone.
538746
539747Shapes are recognised by their exact field set, as in §4.12: `Commit`, `Entry`,
540−`Subtree`, `Repo`, `Frame`, `Change`. Any other record is generic.
748+`Subtree`, `Repo`, `Frame`, `Change`, checked in that order using their
749+configured definitions. Additional type aliases do not displace these
750+display forms. Any other record is generic.
541751
542752| kind | line form | block form |
543753|---|---|---|
@@ -546,7 +756,7 @@ Shapes are recognised by their exact field set, as in §4.12: `Commit`, `Entry`,
546756| `Text` | raw, first line, `…` if truncated | raw, all of it, trailing newline added if absent |
547757| `Id` | shortest unique prefix (bold), remainder (dim) | same |
548758| `Blob` | `‹214 B›`, `‹✖ 3.1 KB›` if unresolved | its content, raw (markers if unresolved) |
549−| function | name, lambda source, or `f arg …` | same |
759+| function | name, lambda source, or `f arg …`; first line, `…` if truncated | same, all of it |
550760| `Shape` | its type name | same |
551761| `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 |
552762| `Entry` | `path size`, `✖` prefix if unresolved | same |
@@ -562,10 +772,35 @@ Shapes are recognised by their exact field set, as in §4.12: `Commit`, `Entry`,
562772| list of other scalars | `a, b, c` | same, wrapped at the terminal width |
563773| any other list | `n items` | one block per item, separated by a blank line |
564774
775+Column tables use copyable shortest unique `@` prefixes for Id cells and
776+show the actual contents of Text-list cells (such as bookmark names), not
777+item counts. Empty Text-list cells are blank. On a terminal, columns are
778+fitted to the available width; if minimum column widths cannot fit, rows
779+are shown vertically with wrapped values. Piped tables retain untruncated
780+cell contents. These presentation choices do not change `show` or values.
781+
782+The reference status record includes `unrecorded : [Path]` alongside
783+`changed : [Path]`. The display labels `changed` as `in commit` to distinguish
784+changes against the commit's parent from file edits since the last recording.
785+Its empty fields read `No changes`, `No conflicts`, `No labels`,
786+`No description`, and `All edits recorded` for empty `unrecorded`.
787+Nonempty paths and labels remain visible. Presentation does not rename the
788+underlying `changed` field. Older five-field status records remain displayable.
789+
565790An empty list of any kind prints `none` (dim) in block form and `0 …` in
566791line form. Author and age in a `Commit` block are read from jj's commit
567792metadata by id;
568−they are not fields of the value. A list of `[Text]` prints as paths because
793+they are not fields of the value. Nor is where it stands, which is read by id
794+from `r`, the repository the expression was given (§1.2 step 4, after the
795+snapshot): a block's paths are those it changes against its parent's files
796+there, and its glyph, in either form, is the one `tree` draws for it there,
797+with `⊗` and `◌` judged by the value's own files. What a commit with no
798+parent there (the root, or one the expression made) changes is not known:
799+its block lists its files unmarked (but for `✖` on an unresolved one), and
800+its glyph claims no `◌`. One the expression made has no metadata either
801+(§7.11), so its block has no `author · age · n files` line. One the
802+expression moved is still compared with its parent there. A `Repo`'s
803+line draws its focus as the focus. A list of `[Text]` prints as paths because
569804that is what such a list is in every base function; `show` is available
570805when the literal is wanted. Alignment in tables and columns uses display
571806width (East Asian wide characters count two cells, combining marks zero), not
@@ -575,12 +810,13 @@ Examples:
575810
576811```
577812$ j status
578−changed src/lexer.rs
579− src/parser.rs
580−conflicts src/lexer.rs
581−id wqzt·kpqxmnrvyxskptlmzzzzabcd
582−labels feature
583−message wip
813+in commit src/lexer.rs
814+ src/parser.rs
815+conflicts src/lexer.rs
816+id wqzt·kpqxmnrvyxskptlmzzzzabcd
817+labels feature
818+message wip
819+unrecorded All edits recorded
584820
585821$ j conflicts
586822⊗ wqzt wip feature
@@ -623,10 +859,18 @@ j: crash: expected one revision, got 0
623859
624860### 5.2 `show`
625861
626−`show v` renders `v` as an expression in the language, such that for every
627−value except closures and unresolved blobs, `show v` parses and evaluates to a
628−value equal to `v` under the same `config.j`. It never uses colour and never
629−truncates.
862+When it succeeds, `show v` renders `v` as an expression in the language, such
863+that for every value except closures, unresolved blobs, and values nested past the parser's
864+bound (§3.4: a list 40,000 deep, a record about 20,000), `show v` parses and
865+evaluates to a value equal to `v` under the same `config.j`. It never uses
866+colour and never truncates. A value holding a part the store cannot supply (a
867+`files` list or a file's content, §7.2) crashes `show` as it crashes any other
868+use: `show` writes no stand-in for the part.
869+
870+Binary blobs and non-regular file kinds require the declaration
871+`blobHex : Text -> Text -> Blob` in `config.j`; if it is missing, `show`
872+crashes with instructions to add it. Existing custom configs are not
873+modified automatically.
630874
631875| kind | rendering |
632876|---|---|
@@ -636,18 +880,33 @@ truncates.
636880| list | `[v1 v2 …]` |
637881| record | `{ a = v, b = w }`, fields in ascending lexicographic order |
638882| `Id` | `@` followed by the full id, e.g. `@wqztkpqxmnrvyxskptlmzzzzabcd` |
639−| resolved `Blob` | `blob "…"` |
883+| resolved regular `Blob` with valid UTF-8 content | `blob "…"` |
884+| other resolved `Blob` | `blobHex "kind" "…"`, preserving file kind and bytes, with lowercase hexadecimal content |
640885| unresolved `Blob` | `{- unresolved -} blob "…"` with the conflict-marker rendering |
641886| builtin | its declared name, operators parenthesised: `map`, `(++)` |
642−| top-level definition | its name |
887+| selector | the selector itself: `.id` |
888+| top-level definition with a signature whose value is a function | its name |
643889| revset from a label literal | `%name` |
644890| shape | its type name: `Commit`, `Id` |
645891| lambda closure | its source text, exactly as written |
646892| partial application | the function's rendering followed by the arguments' renderings, each parenthesised if not atomic: `describe "wip"`, `at (parents) (describe "x")` |
647893
648−Line breaking: one line if it fits in 80 columns; otherwise the outermost
649−list or record breaks one element per line, two-space indented, with leading
650−commas for records and nothing between list elements, recursively.
894+A lambda's source text runs from its `\` to the last token of its body,
895+comments within it included, and a section's is the section itself, which
896+unlike a lambda is an atom: `map (+ 1)`, `(\x y -> x + y) 1`.
897+
898+A top-level definition with a signature whose value is a function renders as
899+its name whatever that function is built from, and its partial application
900+as its name followed by the arguments it was given, never by those its value
901+holds: `prev`, not `prev (parents)` (with `prev = goto parents`), `tree`, not
902+`treeWith ({ … })`, and `addT 1` (§4.13). Any other definition renders as its
903+value: `user` as its record, and without signatures `inc = \x -> x + 1` as
904+`\x -> x + 1` and `incAll = map inc` as `map (\x -> x + 1)`.
905+
906+Line breaking: one line if it fits in 80 columns (display width, as in §5.1);
907+otherwise the outermost list or record breaks one element per line, two-space
908+indented, with leading commas for records and nothing between list elements,
909+recursively.
651910
652911Since `show` returns a `Text` and a top-level `Text` displays raw,
653912`j show . focus` prints the literal of the focused commit, and
@@ -660,14 +919,48 @@ Since `show` returns a `Text` and a top-level `Text` displays raw,
660919### 6.1 Location and loading
661920
662921`j` reads `$XDG_CONFIG_HOME/j/config.j`, defaulting to `~/.config/j/config.j`.
663−It is read by every invocation except `remote`, `fetch`, `undo`, `redo`, and
664−`ops`: expression evaluation and `push` need its definitions, `init` and
922+Expression evaluation and `push` load the configuration; `init` and
665923`clone` need `user`. When there is no repository yet (`init`, `clone`), only
666924`user` is evaluated; `Id` literals are not resolved and no other definition
667−is evaluated. If it does not exist, `j` exits 3 with
668−`j: no config at <path>; the reference config.j is installed at <share-path>`.
669−The binary is distributed with the reference `config.j` (§9) at
670−`<prefix>/share/j/config.j`; `j` never writes to the config path itself.
925+is evaluated. `remote`, `fetch`, `undo`, `redo`, and `ops` do not load configuration.
926+When a command needs configuration and none exists, the binary writes its
927+embedded reference `config.j` (§9) to that path as an editable starting point,
928+readable and writable by its owner, and reports its path. An existing file is
929+never overwritten, including when it cannot be read or contains invalid
930+UTF-8; such errors exit 3.
931+Configuration diagnostics identify the actual file; parse errors include the
932+line number and a bounded source excerpt, with a pointer to `j help config`.
933+Excerpts escape terminal controls and are capped at 160 display columns and
934+characters, with an ellipsis when truncated. Expression parse errors, including
935+push expressions, likewise identify their source line. Runtime diagnostics may
936+add typo suggestions from the actual vocabulary, quoting examples for Text
937+arguments, or missing-argument guidance derived from the command signature;
938+they never retry an expression or change its meaning or exit code.
939+An existing user configuration is not updated when the binary changes. The
940+reference file is also distributed at `<prefix>/share/j/config.j`.
941+
942+Help and command discovery are read-only exceptions (§1.1.1): general help and
943+reserved-command help need no config, while the catalog and help for config
944+names read it without evaluation. If it is missing they use the embedded
945+reference in memory without writing a file. The empty-invocation guide does
946+not read or write configuration either.
947+
948+### 6.1.1 Explicit setup
949+
950+`setup` creates the full embedded configuration at the configured path without
951+opening a repository. With no arguments, it creates an editable default and
952+reports the path and instructions to set `user`. With `NAME EMAIL`, it checks
953+the identity rules of §6.2 and writes those values into the complete reference
954+configuration, serialized as language text literals. Names containing spaces
955+are supplied as a separately quoted shell argument.
956+
957+Setup never overwrites an existing file. Without identity arguments, an
958+existing file reports its effective identity when valid, or guidance to
959+edit it otherwise. With identity arguments, an
960+existing file is a usage error (exit 2), with instructions to edit manually.
961+Argument counts other than zero or two are usage errors. Creating the file
962+also creates missing parent directories; exclusive creation preserves a file
963+created concurrently by another invocation. No repository state is changed.
671964
672965### 6.2 Validation
673966
@@ -692,6 +985,13 @@ not declared is simply unbound, and using it is a runtime crash.
692985
693986Definitions may reference each other in any order.
694987
988+Before authoring commits (`init`, `clone`, or an expression that persists a
989+change), the effective identity must also have nonblank name/email and must
990+not retain the shipped `Your Name` or `you@example.com` placeholders. Such
991+identity failures are configuration errors (exit 3) and do not persist the
992+edit. Inspection and unchanged expressions remain usable with placeholder
993+identity, so users can inspect and repair existing repositories.
994+
695995### 6.3 Definitions the interpreter reads
696996
697997Four names in `config.j` are read by the interpreter itself, not only by
@@ -720,6 +1020,10 @@ but nothing here requires the `jj` binary to be installed.
7201020Walk up from the current directory to the first directory containing `.jj`.
7211021Only the default workspace is supported; if the working directory belongs to
7221022another workspace, exit 2.
1023+The working copy must also include the whole repository: sparse patterns
1024+must contain the root path. Otherwise exit 2 without changing the patterns,
1025+files, or operations, with instructions to run
1026+`jj sparse set --clear --add .` before using `j`.
7231027
7241028### 7.2 Building the `Repo` value
7251029
@@ -747,11 +1051,16 @@ a value as follows.
7471051 `fetch` found the remote's `main` pointing at it — the snapshot cannot go
7481052 into it; the focus is instead a new empty child of it holding the snapshot,
7491053 as `abandon` would leave it, and persisting records that child. Nothing
750− else is written by loading.
1054+ else is written by loading. The value as loaded (§1.2 step 8) has the same
1055+ child holding the commit's own files, so `j id` records the child exactly
1056+ when the working directory differs from that commit.
7511057
7521058The record is the *semantics*. An implementation may back `files`, `children`,
7531059and `context` with lazily materialised structures, provided every observation
754−an expression can make agrees with the eager value.
1060+an expression can make agrees with the eager value. If materialising one of
1061+them, or a file's content, fails (an object missing from the store), every
1062+later use of it fails the same way: a crash caught by `or` (§4.7) never leaves
1063+some other value in its place.
7551064
7561065### 7.3 Files, blobs, and `replay`
7571066
@@ -759,21 +1068,70 @@ an expression can make agrees with the eager value.
7591068 the list of path components; components must be valid UTF-8, and a
7601069 repository with a path that is not exits 2. `content` is a `Blob` carrying the file's
7611070 content and type (regular, executable, symlink) and, for conflicted paths,
762− jj's conflict value for that path.
1071+ jj's conflict value for that path, less each pair of sides that cancel (a
1072+ side added and one removed that are the same), as jj drops them to write
1073+ a file's conflict out. jj keeps such pairs in the conflicts of the trees
1074+ it merges; dropped here, a conflict a commit inherits unchanged reads as
1075+ its parent's. A conflict whose sides left would all be directories keeps
1076+ every side, as jj keeps it as one conflict at the path rather than one
1077+ between directories. The entries' order carries no meaning,
1078+ as a tree has none: persistence (§1.2 step 8, §7.5), the laws of §8 and
1079+ the tree's empty mark compare snapshots path by path, though `==` compares
1080+ them as lists (§4.5).
7631081- A path that is deleted and resolved has no entry. A path that is in a
764− deletion conflict has an entry whose blob is unresolved.
1082+ deletion conflict has an entry whose blob is unresolved. Each side of a
1083+ conflict is either absent or content with its own file type, so a side
1084+ that deleted the path is not an empty file. In a conflict between a file
1085+ and a directory, which jj keeps as one path, a directory side reads as
1086+ empty content but is kept whole, and is written back as that directory.
7651087- `replay onto { from, to }` is jj's tree merge: for each path present in
7661088 any of the three snapshots, the three-way merge of that path's values with
7671089 `from` as the common ancestor, replaying the change from `from` to `to` on
7681090 top of `onto`. Conflicts are kept as unresolved blobs, and resolved where
7691091 jj's merge would resolve them (identical changes, changes from the current
770− content, non-overlapping line-level hunks). It never crashes on content. It
771− crashes if any of the three is not a well-formed snapshot (a list of `Entry`
772− records with unique paths).
773−- `unresolved`, `blob`, and `text` are as in §4.9. `blob` produces a regular
774− file. File type (executable, symlink) travels with a `Blob` but cannot be
775− changed in the language. `text` of a symlink is its target; `text` of a
776− non-UTF-8 blob crashes.
1092+ content, non-overlapping line-level hunks). A path unresolved in any of the
1093+ three takes part with all its sides, as when jj rebases a conflicted commit,
1094+ so sides that cancel drop out and a replay can resolve a conflict as well
1095+ as keep one. A conflict it keeps has no pair of sides that cancel
1096+ (above), so in a stack replayed commit by commit, a conflict each commit
1097+ inherits is its parent's, whatever conflicts the commits below added. It
1098+ never crashes on content, except where a file meets a
1099+ directory (below). It crashes if any of the three is not a well-formed
1100+ snapshot (a list of `Entry` records with unique paths, none of them the
1101+ root `./`, and none both a file and a directory as `./a` beside `./a/b`
1102+ would be).
1103+- A path that is a file in one of the three snapshots and a directory in
1104+ another is merged as one value, as jj does, the directory with everything
1105+ below it. When at most one of `onto` and `to` changed it from `from`, that
1106+ value is taken whole. When both did and none of the three holds a
1107+ conflict at or below the path, the result is their value taken whole if
1108+ they made the same change, and otherwise a conflict at the path, its
1109+ directory sides kept whole. A conflict one of the three holds there takes
1110+ part with all its sides, as above, so the result is a conflict at the path
1111+ unless sides cancel, even where both made the same change: a child that
1112+ resolves a conflict at `./a` into `./a/b`, replayed with `from` that
1113+ conflict onto the same resolution, keeps a conflict at `./a`. `replay`
1114+ crashes, naming the path, when its result would be a conflict at the path
1115+ with a file on one side and on another a directory that `onto` or `to`
1116+ holds, as replaying the addition of `./a/b` onto a file `./a` would: one
1117+ entry cannot list the directory's entries. It does not crash where one of
1118+ the three already holds a conflict at that path (an unresolved blob there,
1119+ not one below it): the sides came in with that conflict and are carried
1120+ like any others, so a child that resolves such a conflict can be squashed
1121+ into it. The in-memory backend of §10 merges such a path file by file,
1122+ like any other, keeps no directory as a side, and crashes when its result
1123+ would hold both `./a` and `./a/b`. So here, as where jj merges lines, the
1124+ two backends may differ: it resolves some merges that jj keeps as a
1125+ conflict or refuses (a change from a file `./a` to `./a/b` replayed onto a
1126+ snapshot without `./a` gives `./a/b`, and so does one from a conflict at
1127+ `./a` to `./a/b` replayed onto `./a/b`; the deletion of `./a/z` replayed
1128+ onto a snapshot where `./a` is a file gives that file), and it may crash
1129+ where jj carries a conflict that one of the three held at `./a`.
1130+- `unresolved`, `blob`, `blobHex`, and `text` are as in §4.9. `blob` produces
1131+ a regular file from text. `blobHex` constructs a regular file, executable,
1132+ or symlink from its kind and hexadecimal bytes, so `show` can preserve both
1133+ content and file type. `text` of a symlink is its target; `text` of a
1134+ non-UTF-8 blob crashes. Persisting a symlink with a non-UTF-8 target crashes.
7771135
7781136### 7.4 Working-directory invariant
7791137
@@ -783,18 +1141,100 @@ directory.
7831141- **Before evaluation**, the working directory is snapshotted into the
7841142 working-copy commit, and that is the `files` of the focus in the `Repo`
7851143 value. The snapshot tracks every file not matched by `.gitignore` files,
786− with no size limit, symlinks as symlinks; these rules are fixed, since `j`
787− reads no jj configuration. If the snapshot changed the commit, that change
788− is part of the operation persisted afterwards, or discarded with the rest if
789− nothing is persisted. A crashed or printing program leaves the working copy
790− and the repository exactly as before; `j id` is the way to record the
791− working directory and nothing else (§1.2).
1144+ and each file it tracked before that a `.gitignore` has come to match
1145+ (deleted once it is gone, even where a file has replaced a directory
1146+ above it), with no size limit, symlinks as symlinks, and nothing inside a
1147+ nested repository (a directory below the root holding `.git` or `.jj`);
1148+ these rules are fixed, since `j` reads no jj configuration. If the snapshot
1149+ changed the commit, that change is part of the operation persisted
1150+ afterwards, or discarded with the rest if nothing is persisted; a change
1151+ that leaves the commit with its parent's files, as where the directory
1152+ reverts what the commit changed, gives it the parent's tree, as step 4
1153+ of §7.5 writes such a commit, so that jj takes it for empty. A crashed
1154+ or printing program leaves the working copy and the repository exactly as
1155+ before (but see §7.5 step 7); `j id` is the way to record the working
1156+ directory and nothing else (§1.2).
1157+- **A stale working copy.** The working copy is stale when the files last
1158+ checked out are not the working-copy commit's: jj changed the commit
1159+ without updating the working directory (a command run with
1160+ `--ignore-working-copy`, or one in another workspace that rewrites this
1161+ workspace's commit), `fetch` rebased it (§7.6), or a checkout failed or
1162+ was cut short after its operation was recorded (§7.5 step 7). The
1163+ working copy's record of the files last checked out names the operation
1164+ it was saved at, and one saved at the head operation is current whatever
1165+ files it records, as jj takes it: where the record was lost, and jj made
1166+ it again recording no file, the snapshot records the directory as it
1167+ is. Over a stale working copy, the directory started from the files last
1168+ checked out, and what has changed in it since is a change to those,
1169+ which the snapshot replays onto the commit: a three-way merge with the
1170+ files last checked out as the base, in which a path that the directory
1171+ and the commit both changed, differently, is a conflict (an edit to a
1172+ file the commit deletes, say), and one where the directory holds what
1173+ the commit does is the commit's (what a checkout cut short had written).
1174+ A path that both changed where that conflict would set a directory
1175+ against a file, a symlink or nothing (a file the commit changes that the
1176+ user replaced by a directory, say), which `replay` refuses as one entry
1177+ cannot list the directory's entries (§7.3), takes what the working
1178+ directory holds there instead, as over a working copy that is not stale:
1179+ the commit's change there is left in the operation before. A conflict of
1180+ that kind that the commit itself has (one made by a `fetch` that rebased
1181+ it, say) stays where the directory has not changed the path, which is
1182+ the commit's like any other. Either way, where the record does not hold
1183+ one of the commit's conflicts, a file holding what a checkout writes for
1184+ it is read back as that conflict: exactly its markers, or a description
1185+ of the same sides, whatever their labels and less any side it both
1186+ removes and adds. With no change there is nothing to snapshot, and a
1187+ program that persists writes the focus over the files last checked out
1188+ as usual. A stale working copy refuses no program.
7921189- **After persistence**, the focus of the result is checked out: its files are
7931190 written to the working directory, unresolved blobs materialised with jj's
7941191 conflict markers, and the workspace's working-copy commit set to the focus.
1192+ A checkout writes nothing over what stands where the focus has a file (or
1193+ a symlink, or a conflict) and the directory has no tracked one. It first
1194+ removes each tree of empty directories there, which no snapshot sees (a
1195+ tool may leave one, and a failed checkout does, for a path it did not
1196+ reach), and moves an untracked file holding just what it writes there
1197+ (as a checkout cut short leaves at an ignored path) aside into `.jj`,
1198+ which no snapshot reads, looking through no symlink, so nothing outside
1199+ the working directory is touched. Once the checkout returns, each file
1200+ moved aside is put back where nothing stands, as where a failed checkout
1201+ did not get to write, and, where the checkout failed (a full disk), over
1202+ a file holding a start of it, less than all of it (an empty file, for a
1203+ symlink), as a write cut short leaves, completing the write; the rest
1204+ are removed, each holding just what the focus has there, which the
1205+ repository keeps, and what stands there, what the checkout wrote or
1206+ something put there since, stays. What a run killed meanwhile leaves in
1207+ `.jj`, the next run puts back as after a failed checkout, before it
1208+ looks at the working directory. A file that cannot be moved stays, and the
1209+ checkout, which skips its path, fails, naming it and why (§7.5 step 7).
1210+ Anything else untracked there, or in place of a directory above the path
1211+ (an ignored file, a symlink, a directory holding one), crashes the program
1212+ before its operation is recorded, as git refuses to write over an
1213+ untracked file, naming each such thing (the first ten, and how many more
1214+ there are), a directory by its own path and an untracked file in it:
1215+ each is to be moved aside.
1216+ A later snapshot reads such a file back as the conflict its markers
1217+ describe, edits included, while they parse at the length they were written
1218+ with (longer than 7 when a side holds a marker-like line); once they do
1219+ not, the file is resolved to its text. A conflict with a side that is no
1220+ file (a directory, a symlink) has no markers: it is written as jj's
1221+ description of its sides, and read back as that conflict while the file
1222+ is left as written (the working copy records each file's size and
1223+ modification time) or describes the same sides, whatever their labels
1224+ and less any side it both removes and adds (a snapshot may drop such a
1225+ pair from the conflict without writing the file again); a file written
1226+ with anything else is resolved to its text.
7951227
7961228Reserved commands never snapshot.
7971229
1230+The `unrecorded : Repo -> [Path]` builtin compares the invocation's loaded
1231+focus files with its snapshotted focus files, using the same path/content
1232+comparison as `changed`. The result describes the original working-copy
1233+edits, independently of changes an expression makes to its `Repo` argument.
1234+It returns `[]` when either snapshot context is absent, including reserved
1235+push evaluation. Read-only status and preview calls do not record these paths;
1236+`save = id` persists their snapshot through the normal expression path.
1237+
7981238### 7.5 Persistence
7991239
8001240Given the loaded value `old` and the result `new`, one jj operation is
@@ -802,8 +1242,27 @@ recorded, described by the expression text (truncated to 200 characters).
8021242Within it:
8031243
80412441. **Validate shape.** `new` has the shape of a `Repo`; every `id` occurs
805− exactly once; every snapshot has unique paths. Violations crash with a
806− message naming the rule.
1245+ exactly once; every commit's `message` is a `Text`; every snapshot entry
1246+ has a `path` list of texts and a `content` that is a `Blob`; every snapshot
1247+ has unique paths, none of them the root `./` and none both a file and a
1248+ directory (§7.3); every path
1249+ component of the focus, of every other commit that is not in `old`, and
1250+ of every path any other commit holds that it did not hold in `old`
1251+ (§7.3), is a name a checkout can create: not empty, `.`, `..`, `.git` or
1252+ `.jj`, without `/`
1253+ or NUL, and a name the workspace's filesystem can hold. A name the working
1254+ directory holds (§7.4) is one. A name longer than 255 bytes, the limit of
1255+ ext4, is tried on the filesystem, since exFAT, NTFS and APFS count 255
1256+ UTF-16 units or characters instead; where a filesystem's limit is lower,
1257+ or a whole path outgrows the system's limit, the checkout of step 7
1258+ refuses it instead. Where the working directory's filesystem folds case
1259+ (`.JJ` names its `.jj`), `.git` and `.jj` are refused in any letter case.
1260+ (Any other path is neither checked out nor written with a name jj has not
1261+ already stored, and a fetched branch can hold any name git can, even one
1262+ above the focus that a snapshot rebases.) Violations crash with a message
1263+ naming the rule. Files as loaded from a stored tree (§7.2) are such a
1264+ snapshot already, as a jj tree holds each name once and each path as a
1265+ file or as a directory, and are not checked again.
80712662. **Validate labels.** The set of `(id, label)` pairs in `new` equals the set
8081267 in `old`. Labels are the remote's names (§7.6); a script cannot add, move,
8091268 or remove one. In particular a commit carrying a label cannot be abandoned.
@@ -812,7 +1271,9 @@ Within it:
8121271 and all of its ancestors. Every id in it must be present in `new` with the
8131272 same parent id, files, and message. Violations crash naming the commit.
8141273 (Ancestors of merges are included because rewriting an ancestor rewrites
815− the merge, whose second parent the model cannot represent.)
1274+ the merge, whose second parent the model cannot represent.) They are the
1275+ merge's jj ancestors through every parent, not only the first, so a
1276+ commit reached only through a second parent is immutable too.
81612774. **Walk `new` top-down.** For each commit `c` (root first):
8171278 - If `c.id` is the root commit's change id, it must be the top of the tree
8181279 and must be unchanged; otherwise crash.
@@ -834,12 +1295,62 @@ Within it:
8341295 `new.root.id`. (`abandon` and `squash` end with `new` for this reason, so
8351296 they are safe on a commit directly above `main`; navigation onto `main`
8361297 is written `new . goto trunk`.)
837−7. Commit the operation, then perform the checkout of §7.4.
1298+7. Record the operation, then perform the checkout of §7.4, as jj does. A
1299+ signal to stop that comes meanwhile waits until the checkout and its
1300+ record are saved (§1.3). An operation that cannot be recorded, a
1301+ checkout that would write over something untracked (§7.4), and one that
1302+ would write over a change made to the working directory while the
1303+ program ran (a file saved, created or removed after the run's snapshot,
1304+ at a path where the focus differs from what that snapshot read), which
1305+ it names, crash before the checkout, so nothing is recorded or written.
1306+ A checkout that fails (a path the filesystem cannot hold, a full disk)
1307+ crashes with the operation recorded and the working directory partly
1308+ updated, and says so; so does one that completes but whose record of
1309+ what it wrote cannot be saved. Nothing it wrote is taken back: the
1310+ working copy is left stale (§7.4), recording the files the directory
1311+ held when the checkout began, so the next run takes what the checkout
1312+ wrote for the focus's own and what else has changed since for a change
1313+ to the focus, the next program that persists writes the rest of the
1314+ focus, and `j undo` goes back. A file saved meanwhile at a path after
1315+ the checkout wrote there, or at a path the focus adds before the
1316+ checkout got there, and a file a full disk cut short where no file was
1317+ moved aside for it, are conflicts with the focus's content there, so
1318+ neither is lost (at an ignored path, a file a full disk cut short is not
1319+ read: the next checkout names it as in its way, and `j undo` leaves it);
1320+ a file the focus changes or removes, saved while the checkout runs but
1321+ before it reaches that path, is written over, as in jj. The checkout
1322+ reads no file into the repository and removes none it does not replace:
1323+ a file it moved aside to write again (§7.4) is put back where it did not
1324+ get to, and over the start of it that it wrote where it was cut short,
1325+ and where anything else stands is removed, holding just the focus's
1326+ content there; an ignored file saved meanwhile where the focus adds one,
1327+ before the checkout reached it, stays, holding what it held, and the
1328+ checkout fails, naming it, so that no run reads it; anything inside a
1329+ nested repository stays too. No uncommitted edit present at the run's
1330+ snapshot is lost where the checkout writes over it, as the operation
1331+ holds that snapshot (§7.4). This is the one crash that records anything.
8381332
8391333Labels are not written by persistence at all; they are derived from remote
8401334refs, which only `fetch` and `push` change. The interpreter may run any part
8411335of the walk through jj-lib's rebase machinery provided the stored result
842−equals what the steps above produce.
1336+equals what the steps above produce. As in §1.2 step 8, steps 3 and 4 may
1337+compare tree hashes rather than values: files still as loaded from a stored
1338+tree are that tree's. Step 4 writes a commit whose files are still as
1339+loaded from a stored tree, its own or another's (`new` gives the child its
1340+parent's), with that tree as jj stored it, pairs of sides that cancel and
1341+conflict labels included (§7.3), as jj's own `new` and `describe` keep a
1342+tree: one built from the files could be another tree for the same files.
1343+For the same reason a commit step 4 writes whose files are its parent's is
1344+written with the parent's tree, labels included, whatever tree its files
1345+were loaded from, as jj's own `squash` or `rebase` leaves a commit it
1346+empties: jj takes a commit for empty only where its tree is its parent's.
1347+`squash`, `rebase` or `abandon` can leave such a commit by building its
1348+files anew, or by moving it, its files unchanged, onto another tree for
1349+the same files: an empty child of a commit squashed into its parent, or
1350+an empty commit rebased onto one holding its parent's files. A persisting
1351+run then reads the files of the focus, of the commits it writes with
1352+other files, and of those whose files it compares with a tree they were
1353+not loaded from, but not those of the rest of the history.
8431354
8441355### 7.6 Labels and the remote
8451356
@@ -851,7 +1362,11 @@ language: `fetch` and `push` are the only things that change them.
8511362 each remote bookmark `name` is the label `name` on the commit it points at;
8521363 labels for bookmarks that no longer exist on the remote are removed; commits
8531364 reachable from remote bookmarks become visible. Fetching never moves the
854− focus or touches the working directory. It records one operation.
1365+ focus or touches the working directory. It records one operation. When
1366+ the import rewrites or abandons the focus or a commit below it, the focus
1367+ is rebased as jj rebases descendants, and the working copy is then stale
1368+ (§7.4): the next run records what has changed in the directory onto the
1369+ rebased focus, and the next program that persists writes its files.
8551370- **`push EXPR`** evaluates `EXPR` (§1.1); a function result is applied to
8561371 the recorded repository. The value must be a list whose elements are records
8571372 of two shapes:
@@ -859,54 +1374,120 @@ language: `fetch` and `push` are the only things that change them.
8591374 creating it if absent;
8601375 - `{ delete : Text }` — delete the bookmark of that name.
8611376
1377+ Before sending a valid push, inspect the working directory without saving
1378+ a snapshot or scan state. If it has unrecorded edits, print a warning to
1379+ stderr (also when piped or colors are disabled) explaining that push sends
1380+ recorded content only, and that `j id` followed by another push includes
1381+ those edits. The warning does not change the requested push's targets or
1382+ prevent deliberate publication of recorded commits.
1383+
8621384 Duplicates are merged; two records with the same `name` and different
8631385 effects are a crash. It is refused (exit 1, nothing pushed) if:
864− - a `name` is not a valid git branch name, an `id` is not a visible commit,
865− or a delete names a bookmark the remote does not have;
1386+ - a `name` is not a valid git branch name, an `id` is not a visible commit
1387+ or names the synthetic root (which has no Git commit), or a delete names
1388+ a bookmark the remote does not have;
8661389 - any commit that would be sent has an unresolved file or an empty
867− description;
1390+ description (the commits that would be sent are the records' `id`s and
1391+ their ancestors that no bookmark on `origin` reaches as of the last
1392+ `fetch` or `push`; a delete sends none);
8681393 - the bookmark's current target is in the immutable set (§7.5) and the
8691394 record would move it to a commit that does not descend from it, or delete
870− it.
871−
1395+ it while no bookmark left on `origin` after the push reaches that target
1396+ (those left are the bookmarks no record names, where they are as of the
1397+ last `fetch` or `push`, and the records' new targets);
1398+ - the deletes would wait (below) and a bookmark it sets and one it deletes
1399+ are a name and a name under it, such as `master` and `master/legacy`
1400+ (not `master2`): git cannot hold both, so the first git push could not
1401+ succeed.
1402+
1403+ Targets and descent are commits as the remote has them: a target rewritten
1404+ here since the last `fetch` or `push` is hidden, and not in the immutable
1405+ set although the visible rewrite carries its change id and its label.
8721406 Moving a bookmark whose current target is not immutable to an unrelated
873− commit is allowed; that is how a rewritten stack is re-pushed. After a
1407+ commit is allowed; that is how a rewritten stack is re-pushed. Deleting a
1408+ bookmark on an immutable commit that another bookmark still reaches is how
1409+ a merged feature's name is removed. It loses no history as long as that
1410+ other bookmark is still where the last `fetch` or `push` left it: `push`
1411+ does not ask the remote, so it does not notice a bookmark moved or deleted
1412+ there since. The remote applies each update of a git push on its own, so
1413+ when only records' new targets reach the commit, as when
1414+ `rename "master" "main"` renames trunk's only bookmark, the deletes wait:
1415+ the other updates are sent first, and the deletes in a second git push
1416+ only once the remote has accepted every one of them. To rename trunk's
1417+ only bookmark to a name under it, push a temporary label on its commit
1418+ first, `push (label "tmp" (labelled "master"))`: with it on `origin`
1419+ nothing waits, and `rename "master" "master/legacy"` goes as one git
1420+ push; then `push (unlabel "tmp")`. The refusal gives these steps with the
1421+ first of `tmp`, `tmp-1`, `tmp-2`, … that no bookmark, on `origin` or in
1422+ the repository, and no record names, and that is not a name under one of
1423+ those or one they are under: following them moves none of the user's
1424+ bookmarks, and the refused push does not touch the label. After a
8741425 successful push the labels reflect the new positions. It records one
875− operation.
1426+ operation, even when it sends two git pushes. An empty list sends nothing
1427+ to the remote, records no operation, and always reports that no updates
1428+ were selected, with guidance for selecting a first bookmark. A nonempty
1429+ successful push reports published, deleted, and unchanged bookmark counts
1430+ on an interactive terminal, including an entirely unchanged selection.
1431+ If the remote accepts some of the updates and rejects others, that
1432+ operation records the accepted ones, whose labels move, and `push` exits 1
1433+ naming the rejected bookmarks and any deletes it did not send.
8761434
8771435Only the remote named `origin` is supported in this version; `fetch` and
878−`push` exit 1 with `j: no remote origin` when it is not configured.
1436+nonempty `push` exit 1 with `j: no remote origin` when it is not configured.
8791437Authentication is git's: the ssh agent for ssh URLs, git credential helpers
8801438for https, as jj-lib's git backend provides them; `j` adds no prompting.
8811439Bringing work up to date is a separate expression, e.g. `rebase trunk`.
8821440
8831441### 7.7 Operations, undo, redo, and the lock
8841442
885−Every persisting `j` program, and each of `fetch` and `push`, records exactly
886−one jj operation, described by the expression or command text. Crashes record
887−nothing.
888−
889−**Kinds.** `j` marks each operation it records, in the operation's metadata,
890−as *normal*, *undo* (naming the operation it undid), or *redo* (naming the
891−undo it reversed). Operations recorded by other tools are treated as normal,
892−except that an operation whose description carries jj's own undo or redo
893−marker is treated accordingly, so mixed use chains correctly where the marker
894−is present and degrades to "undo the newest operation" where it is not.
895−
896−**`undo`** finds the operation to undo: starting at the head, while the
897−current operation is an undo, move to the operation it undid and then to that
898−operation's parent; while it is a redo, move past the operation it reapplied.
899−The first normal operation reached is undone by recording a new operation
900−that restores its parent's view, marked as an undo of it. If none is found,
901−exit 1 with `j: nothing to undo`.
902−
903−**`redo`** requires the head to be an undo (possibly reached through redos);
904−it records an operation restoring the view of the operation that undo
905−discarded, marked as a redo. Otherwise exit 1 with `j: nothing to redo`.
906−
907−Both refuse, before doing anything, if the working directory differs from the
908−focused commit's files: `j: working copy has changes not in @; run \`j id\`
909−to record them or discard them`. Both check out the restored focus afterwards.
1443+Every persisting `j` program, each `fetch`, and each nonempty `push` records
1444+exactly one jj operation, described by the expression or command text. A push's
1445+description includes its `push ` prefix, so an expression named `init` or
1446+`clone` cannot be mistaken for the operation that created the repository.
1447+Crashes record nothing, except as §7.5 step 7 says.
1448+
1449+**Kinds.** An operation is *normal*, *undo*, or *redo*, determined by its
1450+description. New undo and redo records use jj's current markers,
1451+`undo: restore to operation <id>` and `redo: restore to operation <id>`.
1452+The id names the exact operation whose view was restored, retaining the
1453+navigation point for later undo and redo commands.
1454+Older `j` markers (`undo: <id>` naming the discarded operation and
1455+`redo: <id>` naming the reversed undo) and jj's legacy `undo operation <id>` /
1456+`redo operation <id>` markers remain readable with their original meanings.
1457+An operation without a recognized marker is normal, so mixed use degrades
1458+to "undo the newest operation" where no marker is present.
1459+
1460+For current markers, start at the head and follow restored-operation links
1461+while their kind matches the command: undo links for `undo`, redo links for
1462+`redo`. The command then selects that operation's parent. Follow links of
1463+the same kind at the selected target too, and record the final target's id
1464+and view. Reversing direction therefore restores the opposite marker's
1465+parent, and either CLI can continue the other's undo/redo chain.
1466+
1467+**`undo`** restores the state before the operation reached by that walk.
1468+If no parent exists, or the operation reached is the `init` or `clone`
1469+operation (§7.8), before which there was no repository, exit 1 with
1470+`j: nothing to undo`.
1471+
1472+**`redo`** requires the operation reached through redo links to be an undo;
1473+it restores the state that undo discarded. Otherwise exit 1 with
1474+`j: nothing to redo`.
1475+
1476+For older `j` markers, undo follows an undo's discarded operation to its
1477+parent, or a redo's reversed undo to the operation it discarded. Redo
1478+follows a redo's reversed undo to its parent until it reaches an undo,
1479+then restores that undo's parent. This preserves pending redos when a
1480+history crosses from older markers into current ones.
1481+
1482+Both refuse, before doing anything, if the working directory holds a change
1483+the focused commit does not, as a snapshot would find it (§7.4; a stale
1484+working copy holds none where the directory holds what was last checked out
1485+or what the commit has). The refusal explains that `j id` preserves those
1486+edits in a new operation, and the next undo reverses that recording before
1487+reaching the earlier operation; users should inspect `j ops` and can recover
1488+the recording with redo. Both record their operation and then check
1489+out the restored focus, as persistence does (§7.5 step 7): a checkout that
1490+fails leaves the operation recorded, and `redo` or `undo` goes back.
9101491Undoing a `push` restores the recorded labels and leaves the remote untouched.
9111492
9121493**`ops`** prints the operation log, newest first, one line each: relative
@@ -919,7 +1500,8 @@ operation is marked. Rendered in the binary like `tree`.
9191500duration.
9201501A second `j` exits 2 with `j: another j is running in this repository`.
9211502Display-only runs are not distinguishable in advance, so they take the lock
922−too; only reserved commands that need no repository (`init`, `clone`) do not.
1503+too; reserved commands that need no repository (`help`, `commands`, `init`,
1504+`clone`) do not.
9231505The lock prevents `j` from ever producing two commits with one change id.
9241506Other tools operating concurrently are outside `j`'s control; if they produce
9251507that state, §7.2 applies.
@@ -931,49 +1513,112 @@ that state, §7.2 applies.
9311513 initialised on top of the existing git repository and its history is
9321514 imported (subject to §7.2). Either way a working-copy commit is created as a
9331515 child of the current head (or of the root commit for an empty repository)
934− and checked out. It exits 2 if the directory is already inside a jj
935− repository, or if `.git` exists but is not a directory (a git worktree or
936− submodule). It requires `user` (§7.9).
937−- **`clone URL [DIR]`** creates `DIR` (exit 2 if it exists and is non-empty),
1516+ and checked out; the current head is git's `HEAD`, also when it is
1517+ detached. Over an existing git repository the checkout writes no file: the
1518+ working directory is taken as it is, and what it holds beyond the head
1519+ becomes the working-copy commit's change. A checkout that fails leaves the
1520+ operation recorded, as §7.5 step 7 says. It exits 2 if the directory is
1521+ already inside a jj repository, or if `.git` exists but is not a directory
1522+ (a git worktree or submodule). It requires `user` (§7.9).
1523+- **`clone URL [DIR]`** creates `DIR` and its missing parents (exit 2 if
1524+ `DIR` exists and is non-empty, or cannot be created, which includes a `DIR`
1525+ such as `new/..` that names an existing directory once its parents exist),
9381526 clones `URL` into it as a colocated repository, sets `origin` to `URL`,
939− fetches, and creates an empty working-copy commit as a child of the
940− remote's default bookmark target (or of the root commit if the remote has
941− none). It requires `user`.
1527+ fetches, and creates a working-copy commit as a child of the remote's
1528+ default bookmark target (or of the root commit if the remote has none),
1529+ holding that target's files (see below), and checks it out. The default
1530+ bookmark is the branch the remote's `HEAD` names. A clone that fails, its
1531+ checkout included, or that is interrupted while it fetches (§1.3), records
1532+ nothing and removes the `DIR` it created and each parent it created that is
1533+ then empty, or empties the `DIR` it found empty, and nothing else. It
1534+ removes or empties each only while its path still leads to that directory,
1535+ so a symlink put in its place, or in place of a directory above it, is not
1536+ followed: a path that leads elsewhere is left, and the error names it.
1537+ Without `DIR`, it is the repository's name in `URL`, as `git clone` names
1538+ it: the last component of `URL`'s path, a final `.git` component skipped,
1539+ minus `.git` (`repo` for `/srv/repo/.git`, `host:repo.git` and
1540+ `https://host/team/repo`); a `URL` that leaves no name, such as `/` or
1541+ `..`, is a usage error asking for `DIR`. It requires `user`. Interactive
1542+ confirmation includes a shell-quoted command to enter the clone directory
1543+ and run `j status`; cloning cannot change the caller's working directory.
9421544- **`remote URL`** sets the URL of `origin`, creating the remote if it does not
9431545 exist. It does not fetch.
9441546
1547+`clone` and `remote` store a `URL` that is a local path (one with no scheme
1548+and no `host:` before its first `/`) as the absolute path it names from the
1549+current directory, symlinks resolved, as `jj git clone` does (`git clone`
1550+stores it absolute too), so that `fetch` and `push` reach it from wherever
1551+they run. A local path that begins with `~` (`~/x.git`, `~user/x.git`) is
1552+stored as given, since git expands it to that home directory on every
1553+`fetch` and `push`; `./~/x.git` names a directory called `~`. Any other
1554+`URL` is stored unresolved. A `URL` that cannot be parsed, or a path that
1555+cannot be resolved or whose absolute path is not UTF-8, is a usage error
1556+(exit 2) that changes nothing. `fetch` and `push` run git from the top of
1557+the workspace, as git runs from the top of its working tree, so a relative
1558+local path stored some other way (by `git remote add`, say) is resolved
1559+from there, whichever directory they run in.
1560+
1561+Besides jj's own record of the new workspace, `init` and `clone` each record
1562+at most one operation, described `init` and `clone URL` (`URL` as given, a
1563+local path as stored), holding what they imported and the working-copy
1564+commit; `undo` never undoes it (§7.7). That commit holds its parent's files
1565+less any at a path a checkout cannot create (§7.5 step 1), such as a
1566+committed `.jj` directory: their removal is its change.
1567+
9451568### 7.9 Identity
9461569
9471570Commits are authored and committed as `user.name <user.email>` from
9481571`config.j` (§6.3). `j` reads no other configuration; jj-lib's settings object
9491572is constructed by `j` from `user` and fixed defaults. `init`, `clone`, and
9501573persistence exit 3 if `user` is missing or malformed, since that is a
951−configuration error.
1574+configuration error. Authoring also refuses blank fields or the shipped
1575+`Your Name` / `you@example.com` placeholders (§6.2), with instructions to
1576+edit the actual config file. Read-only expressions and unchanged results
1577+remain available with placeholder identity. Successful interactive setup
1578+reports the configured identity; terminal control characters are escaped
1579+in confirmations without modifying the identity stored in commits.
1580+`validate` checks the author identity when its argument differs from the
1581+loaded repository, so validated previews report the same identity refusal
1582+before saving. That refusal is a catchable crash, like other `validate`
1583+failures. An unchanged `validate` and previews without `validate` remain usable.
9521584
9531585### 7.10 External diff rendering
9541586
9551587`difft p a b` runs difftastic. It is the only builtin that executes another
9561588program.
9571589
958−1. Write the content of `a` and `b` to two temporary files whose names end in
959− the last component of `p` (so difftastic's language detection sees the
960− real filename), inside a fresh temporary directory.
1590+1. Write the content of `a` and `b` to files in separate `old` and `new`
1591+ subdirectories of a fresh temporary directory. Each file keeps the last
1592+ component of `p` unchanged, so valid names stay within the filesystem's
1593+ length limit and difftastic sees the real filename. A last component
1594+ that is not a filename is a crash; an empty path uses `file`.
96115952. Run `difft OLD NEW` with the process's stdin closed and its environment
9621596 inherited unchanged, except: if `DFT_COLOR` is unset and `j`'s stdout is a
9631597 terminal, set `DFT_COLOR=always`; if `DFT_WIDTH` is unset and `j`'s stdout
9641598 is a terminal, set it to the terminal width.
965−3. Capture stdout. Delete the temporary directory.
1599+3. Capture stdout; the program's stderr is `j`'s own, so what it says there
1600+ reaches the user. Delete the temporary directory.
96616014. Return stdout as `Text`. A non-zero exit status is not an error (difftastic
967− uses it to signal "differences found" under some options); a failure to
968− execute the program is a crash naming it.
1602+ uses it to signal "differences found" under some options), so what a
1603+ `difft` that fails printed, even nothing, is the result; a failure to
1604+ execute the program because it is absent uses the fallback below. Other
1605+ launch failures are crashes naming the tool and the OS error.
1606+
1607+If the executable is absent (`NotFound`), return a unified diff with three
1608+lines of context and `--- a/PATH` / `+++ b/PATH` headers using the complete
1609+path `p`. Paths containing whitespace or control characters are quoted.
1610+Content that is not UTF-8 or contains NUL receives a binary-difference summary.
1611+Equal bytes produce empty output. This fallback is usable without installing
1612+another program or changing configuration.
9691613
9701614All rendering options are difftastic's own, set through its `DFT_*`
9711615environment variables. `j` adds no options of its own. `diff` remains
972−available as a dependency-free unified diff; the reference `config.j` uses
973−`difft` for display, so a user without difftastic edits one line.
1616+available as a dependency-free unified diff without path headers; the
1617+reference `config.j` uses `difft` for display and receives the automatic
1618+fallback when difftastic is absent.
9741619
975−Packaging: difftastic is a runtime dependency; on Nix, wrap `j` with `difft`
976−on its `PATH`.
1620+Packaging: difftastic is an optional runtime enhancement; on Nix, wrap `j`
1621+with `difft` on its `PATH`.
9771622
9781623### 7.11 Tree rendering
9791624
@@ -997,7 +1642,8 @@ run — have no metadata: their margin cells are blank, their size bar is
9971642computed from the value, and they sort last among their siblings. The same
9981643holds for a `Commit` block in display (§5.1).
9991644
1000−Missing fields crash. `color = "auto"` means colour iff stdout is a terminal.
1645+Missing fields crash. `color = "auto"` means colour iff stdout is a terminal
1646+and `TERM` is not `dumb`.
10011647If the environment variable `NO_COLOR` is set and non-empty, colour is never
10021648used regardless of `color`. `config.j` fixes the defaults by defining `tree`.
10031649
@@ -1028,8 +1674,9 @@ Columns, left to right:
102816744. **Id**: shortest prefix unique among visible commits, minimum 4.
102916755. **Size bar** (`detail ≥ 1`, focus and its parent and children only; all
10301676 commits at `detail = 2`): one of `▁ ▂ ▃ ▅ ▇`, lines added plus removed
1031− against the parent, thresholds 1, 10, 50, 200, 1000. Absent for empty
1032− commits.
1677+ by a shortest line diff against the parent, thresholds 1, 10, 50, 200,
1678+ 1000. Absent for empty commits. A changed path with no line added or
1679+ removed (an empty file, a new file type) counts as one line.
103316806. **Message**: first line; nothing if empty.
103416817. **Labels**: right-aligned column, present only if any rendered commit has
10351682 a label; names separated by two spaces.
@@ -1097,7 +1744,7 @@ Messages are never coloured except dim (cousins) and dim italic (empty).
10971744
10981745These hold for the reference `config.j` and should be property-tested against
10991746an in-memory implementation of the builtins (§10). `=` means the results are
1100−equal, or both crash.
1747+equal, with snapshots compared path by path (§7.3), or both crash.
11011748
11021749```
11031750by r.root.id r = r
@@ -1214,6 +1861,10 @@ Drop = { delete : Label } -- delete the remote's bookmark
12141861------------------------------------------------------------------------------
12151862
12161863(.) : (b -> c) -> (a -> b) -> a -> c -- composition, right to left
1864+-- Return a value unchanged. As a repository command, `j id` records the
1865+-- snapshotted working-copy edits in the focused commit. `j save` is the
1866+-- descriptive alias in this reference config; inspections such as `j status`
1867+-- and `j tree` do not record those edits.
12171868id : a -> a
12181869const : a -> b -> a
12191870crash : Text -> a -- abort the program with a message
@@ -1275,23 +1926,39 @@ splitOn : Text -> Text -> [Text] -- splitOn "/" "a/b" = ["a" "b"]
12751926------------------------------------------------------------------------------
12761927
12771928-- Replay a change onto a snapshot: "onto, with the change from `from` to `to`
1278−-- applied." Total: where the change collides with what is already there, the
1279−-- path's blob is unresolved rather than an error.
1929+-- applied." Where the change collides with what is already there, the path's
1930+-- blob is unresolved rather than an error, except that a collision making a
1931+-- path a file on one side and a directory on another, as adding ./a/b onto a
1932+-- file ./a would, crashes, naming the path (§7.3).
12801933replay : Snapshot -> Change -> Snapshot
12811934unresolved : Blob -> Bool
1935+
1936+-- Paths edited in the working copy since the last recorded repository state.
1937+-- `j status` includes this list; `j save` records it in the focused commit.
1938+-- This measures the working copy, even when composed after an in-memory edit.
1939+unrecorded : Repo -> [Path]
1940+touchedPaths : Change -> [Path] -- paths whose content differs, one pass;
1941+ -- blobs compared by content id, bytes never read
12821942blob : Text -> Blob -- a resolved regular file
1943+blobHex : Text -> Text -> Blob -- kind (regular/executable/symlink), hex bytes
12831944text : Blob -> Text -- its content (markers if unresolved)
12841945by : Id -> Repo -> Repo -- focus the commit with this id; crash if absent
1946+lookupCommit : Id -> Repo -> Commit -- indexed commit lookup without moving the focus
1947+lookupContent : Path -> Snapshot -> Blob -- indexed file content; an empty blob if absent
1948+subtreeCommits : a -> [Commit] -- every commit of a tree in preorder, in one
1949+ -- pass: what `commits` (below) is
12851950meta : Id -> Meta -- git hash, author, email, committer time;
12861951 -- crash for a commit not yet persisted
12871952validate : Repo -> Repo -- the repo unchanged, or the crash persisting
1288− -- it would produce (shape, labels, immutability,
1953+ -- it would produce (shape, root, labels, immutability,
12891954 -- focus). `tree . validate . rebase trunk` is an
12901955 -- honest dry run.
12911956diff : Blob -> Blob -> Text -- unified diff from the first to the second
1292−difft : Path -> Blob -> Blob -> Text -- difftastic's rendering; needs difft on PATH.
1293− -- Options via DFT_* environment variables.
1294−treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text }
1957+difft : Path -> Blob -> Blob -> Text -- difftastic when difft is on PATH; otherwise
1958+ -- unified diff with path headers and 3 context lines,
1959+ -- or a binary-files summary. DFT_* configures difft.
1960+treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text
1961+ , lanes : Int, author : Bool, date : Bool, files : Bool }
12951962 -> Repo -> Text -- the history as a tree; see the spec
12961963
12971964-- Every subvalue of a given shape, however deeply nested. A shape is a type
@@ -1329,8 +1996,11 @@ top = \repo -> top (up repo) or repo
13291996
13301997-- Every commit of a subtree (or of the focused subtree of a Repo), in preorder.
13311998-- Takes anything with `root` and `children`, so the signature is left open.
1999+-- The builtin is \t -> t.root :: (concat (map commits t.children) or [])
2000+-- in one pass; written that way it copies the list below every commit, and
2001+-- so takes time quadratic in the length of the history.
13322002commits : a -> [Commit]
1333−commits = \t -> t.root :: (concat (map commits t.children) or [])
2003+commits = subtreeCommits
13342004
13352005
13362006------------------------------------------------------------------------------
@@ -1374,6 +2044,9 @@ restrict = \m -> filter (\e -> m e.path)
13742044select : Fileset -> Snapshot -> Snapshot -> Snapshot -- m from a, the rest from b
13752045select = \m a b -> restrict m a ++ restrict (neg m) b
13762046
2047+-- Unresolved paths in a snapshot. For the focus, inspect `j status` or
2048+-- `j conflicted . files`, edit the listed files to remove conflict markers,
2049+-- then run `j save` and inspect `j status` again. See `j help conflicts`.
13772050conflicted : Snapshot -> [Path]
13782051conflicted = \s -> map (.path) (filter (\e -> unresolved e.content) s)
13792052
@@ -1422,7 +2095,7 @@ descendants : Revset -- includes the
14222095descendants = \repo -> map (.id) (commits repo)
14232096
14242097ancestors : Revset -- includes the focus
1425−ancestors = \repo -> repo.root.id :: ((let p = up repo in ancestors p) or [])
2098+ancestors = \repo -> repo.root.id :: map (\f -> f.parent.id) repo.context
14262099
14272100siblings : Revset
14282101siblings = \repo -> filter (\i -> i /= repo.root.id) (kids (up repo)) or []
@@ -1458,6 +2131,11 @@ minus = \a b repo -> let ys = b repo in filter (\i -> not (member i ys)) (a repo
14582131matching : (Commit -> Bool) -> Revset -> Revset
14592132matching = \p rs repo -> filter (\i -> p (commitAt i repo)) (rs repo)
14602133
2134+-- Find every commit with unresolved files. `j conflicts` lists candidates;
2135+-- use `j by @ID` to focus a mutable one, then `j status` to find its paths.
2136+-- Edit each conflicted file, remove the conflict markers, and run `j save`.
2137+-- Check `j status` again; `j clean` means the focus has no unresolved files,
2138+-- not that the working copy has no unrecorded edits. `j review` shows the diff.
14612139conflicts : Revset
14622140conflicts = matching (\c -> not (null (conflicted c.files))) all
14632141
@@ -1466,7 +2144,10 @@ firstOf : [Revset] -> Revset
14662144firstOf = \rss repo -> foldl (\acc rs -> if null acc then rs repo else acc) [] rss
14672145
14682146-- The main line: whichever of these names the remote has, first one wins.
1469−-- Empty in a repository with no remote, in which case nothing is immutable.
2147+-- Empty when no fetched remote label is named main, master, or trunk;
2148+-- nothing is then immutable. Configure `j remote URL`, run `j fetch`, and
2149+-- inspect `j tree` to see remote labels. A remote with another default label
2150+-- needs this definition adjusted. Start work with j 'new . goto trunk'.
14702151trunk : Revset
14712152trunk = firstOf [%main %master %trunk]
14722153
@@ -1482,21 +2163,33 @@ immutable = ancestorsOf trunk
14822163-- Focusing. `goto` is the only place a revset must have exactly one member.
14832164------------------------------------------------------------------------------
14842165
2166+-- Move to the single commit selected by a revset.
2167+-- Example: j 'goto %feature'; use j 'new . goto trunk' to start on the main line.
14852168goto : Revset -> Edit
14862169goto = \rs repo ->
14872170 let is = rs repo
2171+ candidate = \i ->
2172+ let c = commitAt i repo
2173+ message = head (splitOn "\n" c.message)
2174+ in " " ++ show i ++ " " ++ (if message == "" then "(no description)" else message) ++ "\n"
14882175 in if length is == 1
14892176 then by (head is) repo
1490− else crash ("expected one revision, got " ++ show (length is))
2177+ else if null is
2178+ then crash "expected one revision, got 0; no matching revision. Run `j tree` to inspect available revisions. For a missing remote label such as %main or trunk, configure `j remote URL`, run `j fetch`, and check the label names in `j tree`."
2179+ else crash ("expected one revision, got " ++ show (length is) ++ "\nCandidates:\n" ++ concat (map candidate (take 8 is)) ++ (if length is > 8 then " More candidates: run `j tree`.\n" else "") ++ "Choose a mutable commit with `j by @ID`, or start a child with `j 'new . by @ID'`; replace @ID with a candidate above.")
14912180
14922181commitAt : Id -> Repo -> Commit
1493−commitAt = \i repo -> (by i repo).root
2182+commitAt = lookupCommit
14942183
14952184prev : Edit -- the parent
14962185prev = goto parents
14972186
1498−next : Edit -- the only child
1499−next = goto kids
2187+-- Move to the only child. At a tip, `j new` starts a child; when there are
2188+-- several children, choose one of the candidates with `j by @ID`.
2189+next : Edit
2190+next = \repo -> if null (kids repo)
2191+ then crash "next: this commit has no children; run `j new` to start a new child, or `j tree` to inspect the history"
2192+ else goto kids repo
15002193
15012194tip : Edit -- end of the line of only-children
15022195tip = \repo -> tip (next repo) or repo
@@ -1522,6 +2215,8 @@ eachChild = forEach kids
15222215guard : (Repo -> Bool) -> Edit -- crash unless p holds
15232216guard = \p repo -> if p repo then repo else crash "guard"
15242217
2218+-- Whether the focused commit has no unresolved files. This says nothing
2219+-- about unrecorded working-copy edits; inspect those with `j unrecorded`.
15252220clean : Repo -> Bool
15262221clean = \repo -> null (conflicted repo.root.files)
15272222
@@ -1560,11 +2255,28 @@ rewrite = \f repo ->
15602255-- Commands
15612256------------------------------------------------------------------------------
15622257
2258+-- Record the current working-copy edits in the focused commit.
2259+-- Example: j save; use j status first to inspect unrecorded paths.
2260+-- This is id restricted to a Repo: the final Repo result triggers recording.
2261+-- It does not publish anything. `j new` starts a child; `j id` records too.
2262+save : Edit
2263+save = id
2264+
2265+-- Preview an edit and validate that its result could be recorded, then show
2266+-- the resulting tree. This returns Text, so it records nothing.
2267+-- Examples: j 'preview squash'; j 'preview (rebase trunk)'.
2268+-- This helper is in the reference config. Existing custom configs are not
2269+-- upgraded automatically; add it there or use `tree . validate . EDIT`.
2270+preview : Edit -> Repo -> Text
2271+preview = \edit -> tree . validate . edit
2272+
15632273-- Set the message of the focused commit.
2274+-- Example: j 'describe "fix login"'
15642275describe : Text -> Edit
15652276describe = \m -> mapRoot (\c -> c { message = m })
15662277
15672278-- Start an empty child of the focus and move to it.
2279+-- Examples: j new; j 'describe "next change" . new'
15682280new : Edit
15692281new = \repo -> addChild (newCommit repo.root.files) repo
15702282
@@ -1572,6 +2284,7 @@ new = \repo -> addChild (newCommit repo.root.files) repo
15722284-- new empty commit on that parent becomes the focus (the parent may be
15732285-- immutable, and the focus never is). Refused for a commit the remote has a
15742286-- name for: push something else under that name first, or forget the name.
2287+-- Example: j abandon; validate a preview first with j 'preview abandon'.
15752288abandon : Edit
15762289abandon = \repo ->
15772290 let c = repo.root
@@ -1581,17 +2294,20 @@ abandon = \repo ->
15812294 else new (remove (eachChild (replayOnto p) repo))
15822295
15832296-- Push the files matching m from the focus down into its parent.
2297+-- Example: j 'contract (ext "rs")'
15842298contract : Fileset -> Edit
15852299contract = \m repo -> let a = repo.root
15862300 in at parents (rewrite (\q -> q { files = select m a.files q.files })) repo
15872301
15882302-- Fold the focus entirely into its parent; a new empty commit becomes the focus.
2303+-- Example: j squash; preview first with j 'preview squash'.
15892304squash : Edit
15902305squash = abandon . contract everything
15912306
15922307-- Push the files matching m from the focus up into a new child, which becomes
15932308-- the focus and inherits the old focus's children. Labels stay with the
15942309-- original commit, since they are the remote's.
2310+-- Example: j 'split (ext "rs")'; use (under ./src) to select a directory.
15952311split : Fileset -> Edit
15962312split = \m repo ->
15972313 let a = repo.root
@@ -1601,6 +2317,7 @@ split = \m repo ->
16012317 in by x.id (repo { root = a', children = [{ root = x, children = repo.children }] })
16022318
16032319-- Move the focused subtree onto another commit. Returns to the moved commit.
2320+-- Example: j 'rebase trunk'; preview with j 'preview (rebase trunk)'.
16042321rebase : Revset -> Edit
16052322rebase = \rs repo ->
16062323 let d = (goto rs repo).root
@@ -1611,6 +2328,7 @@ rebase = \rs repo ->
16112328 in by me (attach x.subtree (by d.id x.rest))
16122329
16132330-- Apply the focused commit's change onto another commit, as a new child there.
2331+-- Example: j 'pick %main'
16142332pick : Revset -> Edit
16152333pick = \rs repo ->
16162334 let ch = changeOf repo
@@ -1618,6 +2336,7 @@ pick = \rs repo ->
16182336 in at rs (addChild (newCommit (replay t.files ch))) repo
16192337
16202338-- A new child of the focus that undoes the change of the given commit.
2339+-- Example: j 'backout %feature'
16212340backout : Revset -> Edit
16222341backout = \rs repo ->
16232342 let ch = changeOf (goto rs repo)
@@ -1663,40 +2382,53 @@ entryAt = \p s -> filter (\e -> e.path == p) s
16632382
16642383-- The content at path p, or an empty file if absent.
16652384contentAt : Path -> Snapshot -> Blob
1666−contentAt = \p s -> (head (entryAt p s)).content or blob ""
2385+contentAt = lookupContent
16672386
16682387-- The paths a change touches.
16692388touched : Change -> [Path]
1670−touched = \ch ->
1671− let paths = map (.path) ch.to ++ filter (\p -> not (member p (map (.path) ch.to))) (map (.path) ch.from)
1672− in filter (\p -> entryAt p ch.to /= entryAt p ch.from) paths
2389+touched = touchedPaths
16732390
1674−-- Paths whose content differs between the focus and its parent.
2391+-- Paths changed by the focused commit compared with its parent, including
2392+-- the working-copy snapshot. These can remain nonempty after `j save`;
2393+-- `j unrecorded` shows only working-copy edits not yet recorded.
2394+-- Example: j changed
16752395changed : Repo -> [Path]
16762396changed = \repo -> touched (changeOf repo)
16772397
1678−-- What the focus changes, rendered by difftastic, one record per changed path.
1679−-- Replace difft with (\p -> diff) to use the built-in unified diff instead.
2398+-- What the focus changes, one record per changed path. Uses difftastic when
2399+-- installed; a missing difft falls back to a unified diff with path headers
2400+-- and 3 context lines, or a binary-files summary.
16802401diffs : Repo -> [{ path : Path, diff : Text }]
16812402diffs = \repo ->
16822403 let ch = changeOf repo
16832404 in map (\p -> { path = p, diff = difft p (contentAt p ch.from) (contentAt p ch.to) }) (touched ch)
16842405
16852406-- The same, as one page of text. A Text result prints raw, so `j review` is
1686−-- readable in the terminal.
2407+-- readable in the terminal. A focus that changes nothing reads "no changes";
2408+-- missing difft uses the built-in unified diff (path headers, 3 context lines)
2409+-- or a binary-files summary. Other execution errors remain visible; an
2410+-- installed difft's output, even nothing, is the page.
2411+-- Example: j review; difftastic is optional.
16872412review : Repo -> Text
1688−review = \repo -> concat (map (\d -> d.diff) (diffs repo)) or "no changes\n"
1689−
1690−-- A summary of the focus.
1691−status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path] }
2413+review = \repo ->
2414+ let ds = diffs repo
2415+ in if null ds then "no changes\n" else concat (map (\d -> d.diff) ds)
2416+
2417+-- A summary of the focus. `changed` lists changes in the commit compared
2418+-- with its parent; `unrecorded` lists working-copy edits not yet recorded.
2419+-- Inspection does not record anything. Run `j save` to record those edits.
2420+-- Example: j status
2421+status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path], unrecorded : [Path] }
16922422status = \repo ->
16932423 { id = repo.root.id
16942424 , message = repo.root.message
16952425 , labels = repo.root.labels
16962426 , changed = changed repo
1697− , conflicts = conflicted repo.root.files }
2427+ , conflicts = conflicted repo.root.files
2428+ , unrecorded = unrecorded repo }
16982429
16992430-- A summary of every commit, top first, in preorder.
2431+-- Example: j log
17002432log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]
17012433log = \repo ->
17022434 map (\c -> { id = c.id
@@ -1707,11 +2439,21 @@ log = \repo ->
17072439
17082440-- The history as a tree. Edit these records to change what `j tree` shows.
17092441-- (The parentheses are required: `f { … }` would be a record update of f.)
2442+-- The default `tree` shows every commit, sorted by time (parents always above
2443+-- their children); `treeCompact` folds uninteresting runs and far subtrees.
2444+-- Examples: j tree; j 'tree . squash' previews an edit without saving it.
17102445tree : Repo -> Text
1711−tree = treeWith ({ detail = 1, margin = false, elide = true, icons = false, color = "auto" })
2446+tree = treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
2447+
2448+treeCompact : Repo -> Text
2449+treeCompact = treeWith ({ detail = 1, margin = false, elide = true, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
17122450
17132451treeFull : Repo -> Text
1714−treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
2452+treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
2453+
2454+-- The full tree plus the extra data columns: date, files changed, author.
2455+treeData : Repo -> Text
2456+treeData = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = true, date = true, files = true })
17152457```
17162458
17172459---
@@ -1719,13 +2461,15 @@ treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false,
17192461## 10. Testing
17202462
17212463The interpreter must be testable without a jj repository. Provide an in-memory
1722−implementation of the domain builtins (`replay`, `unresolved`, `blob`,
2464+implementation of the domain builtins (`replay`, `unresolved`, `blob`, `blobHex`,
17232465`text`, `by`, `extract`, `meta`, `difft` as a stub, and the minting
1724−of `@`) in which a blob is text, a
2466+of `@`) in which a resolved blob carries bytes and a file kind, a
17252467conflict is a record of the three sides, and `replay` resolves a path exactly
1726−when at most one side changed it or both made the same change. Property-test
1727−the laws of §8 over randomly generated `Repo` values (bounded depth and width,
1728−random labels, random file edits) using the reference `config.j` unmodified.
2468+when at most one side changed it or both made the same change, path by path
2469+even where a file meets a directory, which jj merges as one value (§7.3).
2470+Property-test the laws of §8 over randomly generated `Repo` values (bounded
2471+depth and width, random labels, random file edits) using the reference
2472+`config.j` unmodified.
17292473
17302474The builtin `by` must agree, on every generated repo, with this in-language
17312475reference definition, which uses positional moves the public base does not
@@ -1755,9 +2499,10 @@ here is out of scope (§12). Nothing here uses the `jj` binary.
17552499**Getting a repository**
17562500
17572501```
1758−$EDITOR ~/.config/j/config.j -- set `user` once
2502+j setup 'Your actual name' you@your-domain.example -- create the full config once
17592503j init -- new repository here, or adopt an existing .git
1760−j clone git@host:org/repo.git -- or an existing one
2504+j clone git@host:org/repo.git project -- or clone an existing one
2505+cd project -- after clone, enter its directory
17612506j remote git@host:org/repo.git -- attach a remote to an init'd repo
17622507```
17632508
@@ -1766,9 +2511,9 @@ j remote git@host:org/repo.git -- attach a remote to an init'd repo
17662511```
17672512j tree -- the history as a tree, focus marked
17682513j treeFull -- the same with sizes, ages, authors, changed paths
1769−j status -- id, message, labels, changed paths, conflicts of @
2514+j status -- focus, in-commit changes, unrecorded edits, conflicts
17702515j log -- every commit as a record
1771−j review -- difftastic rendering of @ against its parent
2516+j review -- difftastic or unified diff of @ against its parent
17722517j review . by @kpq -- the same for another commit, without moving
17732518j diffs -- the same, one record per path
17742519j here -- @'s id
@@ -1799,7 +2544,8 @@ j rebase trunk
17992544j guard clean . rebase trunk or id -- only if it comes out clean
18002545j pick @kpq
18012546j 'forEach (descendantsOf here) (describe "wip")'
1802−j id -- record the working directory, change nothing else
2547+j save -- record the working directory, change nothing else
2548+j 'preview squash' -- validate and show the result without saving
18032549j tree . squash -- dry run: show the result, persist nothing
18042550j tree . validate . squash -- the same, refusing what persistence would refuse
18052551j squash && j tree -- do, then look
@@ -1821,7 +2567,10 @@ j rebase trunk -- bring the stack up to date
18212567A rebase or fetch that conflicts leaves unresolved files in the affected
18222568commits. `j conflicts` lists them; `j by @…` moves there, which checks the
18232569files out with conflict markers; editing the files and running any persisting
1824−expression (`j id` at minimum) records the resolution.
2570+expression (`j save` or `j id` at minimum) records the resolution. Run
2571+`j status` to verify `No conflicts` and `All edits recorded` in the focus,
2572+and `j conflicts` to check the rest of the history. No continue command is
2573+needed.
18252574
18262575**Mistakes**
18272576
@@ -1836,7 +2585,7 @@ j redo
18362585Any definition added to `config.j` whose value is an `Edit` is a command:
18372586
18382587```haskell
1839−pr = \n -> describe n . squash
2588+pr = \n -> describe n . prev . squash
18402589```
18412590
18422591```
@@ -1849,8 +2598,9 @@ j 'pr "fix login"' && j 'push (label "fix-login" here)'
18492598
18502599Second parents of merge commits (merges are placed under their first parent
18512600and are read-only); hunk-level `split`; conflict resolution inside the
1852−language (blobs are opaque); workspaces other than the default; remotes other
1853−than `origin`; tags; submodules and LFS; ignore rules beyond `.gitignore`;
1854−changing file modes or symlinks from the language; repairing a repository in
1855−which one change id has two visible commits (refused; §7.2); a `--help` flag
1856−or any flag at all; static typing. None of these are precluded by the design.
2601+language (blobs are opaque); workspaces other than the default; sparse working
2602+copies (refused; §7.1); remotes other than `origin`; tags; submodules and LFS;
2603+ignore rules beyond `.gitignore`; repairing a repository in which one change
2604+id has two visible commits (refused; §7.2); general command flags beyond the
2605+`--help` and `-h` discovery arguments; static typing. None of
2606+these are precluded by the design.
specs/tree.md+94−50
@@ -113,6 +113,8 @@ After `n` is placed in lane `ℓ`:
113113* If `n` was the last child of `p` and did not inherit `p`'s lane (rule 5 with
114114 `p` the trunk head, or `p` the root of an empty trunk), `p`'s lane becomes
115115 empty below this row: the fork on this row is drawn with `╰` instead of `├`.
116+ When `n` finds no lane (§Overflow) no fork is drawn, but `p`'s lane empties
117+ all the same; the rail that reaches `n`'s row is drawn there as `│`.
116118
117119**Reservations.** When a node `p ∈ T` is placed and has a trunk child `t`,
118120every side child of `p` that comes *after* `t` in row order needs its own
@@ -135,10 +137,16 @@ always to the right of its source.
135137If rule 5 or a reservation finds no empty lane in `0 < ℓ < lanes`, the
136138subtree rooted at `n` is **flattened**: `n` and every descendant of it still
137139get their rows, in row order, with their id, message, labels and margin, but
138−they take no lane. Their rails area shows only the rails that pass through
139−and `»` in the last column. Nothing is hidden — the focus in particular is
140−always shown — only the shape beyond `lanes` is lost. Widen `lanes` (or
141−enable `elide`) to see it.
140+they take no lane. Their rails area preserves every rail that passes through.
141+If any rendered node is flattened, a separate four-column status area
142+is reserved between the rails and the id separator on every row. Flattened
143+rows show `»`; on a commit row this is followed by a space and its normal
144+node glyph (§Glyphs), padded to four display columns. Non-flattened rows
145+leave the area blank. Neither the overflow marker nor a node glyph replaces
146+a live rail. Nothing is hidden —
147+the focus, conflicts, and empty commits in particular are always identifiable
148+— only the shape beyond `lanes` is lost. Widen `lanes` (or enable `elide`) to
149+see it.
142150
143151#### Step 4 — drawing a row
144152
@@ -147,26 +155,34 @@ Columns, left to right, at fixed offsets:
1471551. **Gutter**, 2 characters: `▶ ` on the focus row, blank otherwise.
1481562. **Rails**, `2 · lanes` characters: lane `i` owns characters `2i` and
149157 `2i+1`.
150−3. One space.
151−4. **Id**: `@` followed by the shortest prefix unique among the commits of the
158+3. **Overflow status**, 4 display columns when any rendered node is flattened;
159+ absent otherwise (§Overflow).
160+4. One space.
161+5. **Id**: `@` followed by the shortest prefix unique among the commits of the
152162 display tree, minimum 4 — so the rendered id is itself a valid id literal
153163 (§3.1). Blank on a run row.
154−5. **Size bar** (`detail ≥ 1`, focus and its parent and children only; all
155− commits at `detail = 2`): `▁ ▂ ▃ ▅ ▇`, as before.
156−6. **Message**: first line; nothing if empty; followed by `⋯ n` on a
164+6. **Size bar** (`detail ≥ 1`, focus and its parent and children only; all
165+ commits at `detail = 2`): `▁ ▂ ▃ ▅ ▇`, as before. A row without a bar
166+ keeps the column blank; it is left out only when no row has a bar.
167+7. **Message**: first line; nothing if empty; followed by `⋯ n` on a
157168 collapsed node.
158−7. **Labels**: right-aligned column, present only if any rendered commit has
169+8. **Labels**: left-aligned column, present only if any rendered commit has
159170 a label; names separated by two spaces.
160−8. **Margin** (`margin = true`): age and author initials, right-aligned, as
161− before.
162−9. **Data columns** (off unless enabled): `date` shows the commit's absolute
171+9. **Margin** (`margin = true`): age and author initials, as before, each
172+ right-aligned in its own column (a `9m` ends under a `13m`), so the margin
173+ ends at one column on every row.
174+10. **Data columns** (off unless enabled): `date` shows the commit's absolute
163175 date `YYYY-MM-DD`; `files` shows the number of files changed as `n files`;
164176 `author` shows the full author name. They appear in that order, grey,
165177 immediately before the margin in the same right-aligned block.
166178
167−Columns 4–8 start at the same offset on every row. When stdout is a terminal
168−and a row would exceed its width, the message is cut and ends with `…` so that
169−labels and the margin keep their columns; otherwise nothing is cut.
179+Columns 5–9 start at the same offset on every row. Labels start two columns
180+past the widest message, and the data columns and the margin start two
181+columns past the widest label (past the widest message when there is no
182+label column). Each data column, the age and the initials are as wide as
183+their widest entry and two columns apart. When stdout is a terminal and a row
184+would exceed its width, the message is cut and ends with `…` so that labels
185+and the margin keep their columns; otherwise nothing is cut.
170186
171187**Rails characters.** For lane `i` on the row of node `n` placed in lane
172188`ℓ`, character `2i` is, first rule that applies:
@@ -179,19 +195,27 @@ labels and the margin keep their columns; otherwise nothing is cut.
179195| `i` lies between the two ends of a horizontal segment on this row and holds a rail | `┼` |
180196| `i` lies between the two ends of a horizontal segment on this row | `─` |
181197| `i` holds a rail passing through this row | `│` |
182−| flattened rows only: `i = lanes − 1` | `»` |
183198| otherwise | space |
184199
185200Character `2i+1` is `─` if it lies inside a horizontal segment (from a fork
186201source to its target, or from a node to its rightmost reservation), else a
187202space. On a run row the count follows the `╎`, written into the rails area
188−and, if needed, into the empty id column.
189−
190−**Detail line** (`detail = 2`, focus only): one extra row directly under the
191−focus. Its rails show `│` in every lane that holds a rail below the focus row
192−(including the focus's own lane if it has children); the text starts two
193−characters past the id column and lists the changed paths with a mark each:
194−`+` added, `~` modified, `−` deleted, `✖` unresolved.
203+and, if needed, into the empty id column; when lanes to the right of the run
204+hold rails, it follows the rightmost of them instead, so that it covers no
205+rail. The count is never cut: what does not fit the rails area runs on,
206+unbroken, through the space after it into the id column, which is widened if
207+it cannot hold the rest.
208+
209+**Detail lines** (`detail = 2`, focus only): changed paths appear directly
210+under the focus, with a mark each: `+` added, `~` modified, `−` deleted,
211+`✖` unresolved. Every detail row shows `│` in every lane that holds a rail
212+below the focus row (including the focus's own lane if it has children), and
213+leaves any overflow status column blank. The text starts two columns into the
214+id column, under the focus's id, at the same offset on every continuation row.
215+When stdout is a terminal, paths wrap onto continuation rows within the width
216+available after this prefix. A path longer than that width may split across
217+rows; wrapping does not discard any path content. Redirected output keeps
218+the changed paths on a single detail row.
195219
196220#### Glyphs
197221
@@ -208,7 +232,13 @@ Unchanged from before:
208232| `⌂` | root | `🌱` |
209233
210234Precedence: `⌂`, then `⊗`, then `◌`, then `◆`, then the position glyphs.
211−Rails use `│ ├ ╰ ─ ┼ ╮ ┬ ╎ »`.
235+`⊗` and `◌` are properties of the history being drawn — a commit's files, and
236+its parent's in that history — not of the stored commit with the same id, so
237+a dry run (§1.2) draws them as the edit would leave them.
238+An `icons = true` glyph is two columns wide, so it fills both characters of
239+its lane: character `2ℓ+1` of its row is not drawn.
240+Rails use `│ ├ ╰ ─ ┼ ╮ ┬ ╎`; `»` belongs to the separate overflow status
241+column.
212242
213243#### Elision (`elide = true`)
214244
@@ -232,18 +262,29 @@ drawn with `⌂`.
232262
233263#### Colour
234264
235−Glyphs are coloured by meaning (the same precedence as their shape): root bold,
236−conflict red, empty dim, immutable blue, focus bold cyan, ancestor of focus
237−green, other light grey. Ids keep their existing colours (conflict red,
238−immutable blue). Messages are bold on the focus row, red on a conflict, and
239−dim-italic when empty. Labels are green; the margin and the data columns are
240−grey with author-hued initials. Rail connectors in lane 0 take the immutable
241−colour; connectors in other lanes are dim.
242−
243−The whole focus row is highlighted with a background band that spans the full
244−terminal width. This is colour only: glyphs, rails, and the `▶` gutter still
245−carry the meaning, so a colourless rendering loses nothing — the band is a
246−no-op when colour is off.
265+Glyphs are coloured by meaning (the same precedence as their shape): root bold
266+magenta, conflict bright red, empty dim, immutable bright blue, focus bold
267+cyan, ancestor of focus bright green, other bright white. Ids are bold magenta,
268+with conflict red and immutable blue taking precedence; the focus id and `▶`
269+gutter are bold cyan. Messages are bold on the focus row, red on a conflict,
270+and dim-italic when empty. Labels are bold green; the margin and data columns
271+are grey with bright author-hued initials. Size bars progress from cyan
272+through blue and magenta to yellow as their height increases. Rail connectors
273+in lane 0 take the immutable colour; other lanes cycle through magenta, cyan,
274+yellow, and green by lane position. Glyphs always keep their semantic colour.
275+
276+The whole focus row is highlighted with bright text on a dark-blue background
277+band that spans the full terminal width. This is colour only: glyphs, rails,
278+and the `▶` gutter still carry the meaning, so a colourless rendering loses
279+nothing — the band is a no-op when colour is off.
280+
281+Structured value display shares this palette: id prefixes are bold magenta,
282+field names and table headers dim cyan, paths cyan, integers and file sizes
283+yellow, and booleans green for true and red for false. Raw text and blob
284+contents are never styled. `color = "auto"` requires a terminal whose `TERM`
285+is not `dumb`; `color = "always"` explicitly enables colour and `"never"`
286+disables it. A non-empty `NO_COLOR` disables colour in every mode. Redirected
287+output remains plain in auto mode.
247288
248289#### Legend
249290
@@ -256,8 +297,10 @@ The legend covers the node glyphs present (§Glyphs), the focus gutter `▶`,
256297run `╎ n` and collapsed `⋯ n` rows when shown, and the detail-line marks
257298(`+` added, `~` modified, `−` deleted, `✖` unresolved) when a detail line is
258299present. A collapsed node shows `⋯ n` only when `n > 0`, so the `⋯` legend
259−entry appears only then. The rails characters are not itemised; they are
260−structural (§Step 4).
300+entry appears only then. When any rendered node is flattened, it also includes
301+`» branch exceeds lane limit; increase lanes`. Node glyphs in the overflow
302+status column contribute their normal legend entries. The rails characters
303+are not itemised; they are structural (§Step 4).
261304
262305The legend text is faint (dim). Placement: when stdout is a terminal and the
263306tree's widest line plus the legend fits the terminal width, the legend is set
@@ -290,20 +333,21 @@ Rendered:
290333
291334```
292335 ╎ 14
293− ◆─╮ @aaaa ▅ add parser 3w mo
294− ◆ │ @kpqx ▅ release 1.2 main 2w mo
295− │ ○ @mnrv ▂ fix lexer 1w mo
296−▶ ├─⊗ @wqzt ▃ wip feature 2d mo
336+ ◆─╮ @aaaa ▅ add parser 3w mo
337+ ◆ │ @kpqx ▅ release 1.2 main 2w mo
338+ │ ○ @mnrv ▂ fix lexer 1w mo
339+▶ ├─⊗ @wqzt ▃ wip feature 2d mo
297340 │ │ ✖ src/lexer.rs ~ src/parser.rs + tests/lexer.rs
298− ╰─┼─◌ @ptlm docs ⋯ 3 1d ak
299− ├─○ @qrst ▁ spike 5h mo
300− ○ @yxsk ▃ 1h mo
341+ ╰─┼─◌ @ptlm docs ⋯ 3 1d ak
342+ ├─○ @qrst ▁ spike 5h mo
343+ ○ @yxsk ▃ 1h mo
301344```
302345
303−Column offsets in this example: gutter 0–1, rails 2–7, id from 9. Lane 0 is
304−`◆ ◆ │ ├ ╰` from `aaaa` to `ptlm` and then empty; the reservation for `mnrv`
305−is drawn on `aaaa`'s row as `─╮` and consumed on `mnrv`'s row; `ptlm` forks
306−to lane 2 across the live lane 1, hence `┼`.
346+Column offsets in this example: gutter 0–1, rails 2–7, id from 9, size bar
347+15, message from 18, labels from 31, margin from 40, and the detail line's
348+marks from 11. Lane 0 is `◆ ◆ │ ├ ╰` from `aaaa` to `ptlm` and then empty;
349+the reservation for `mnrv` is drawn on `aaaa`'s row as `─╮` and consumed on
350+`mnrv`'s row; `ptlm` forks to lane 2 across the live lane 1, hence `┼`.
307351
308352The reference `config.j` defines `tree` with `detail = 1, margin = false,
309353elide = false` (every commit, time-sorted), `treeCompact` with `elide = true`
src/ast.rs+35−2
@@ -29,7 +29,7 @@ pub enum Expr {
2929 LabelLit(String),
3030 PathLit(Vec<String>),
3131 Bool(bool),
32− Lambda(Vec<Pattern>, Rc<Expr>, String), // params, body, source text
32+ Lambda(Vec<Pattern>, Rc<Expr>, Source), // params, body, source text
3333 If(Rc<Expr>, Rc<Expr>, Rc<Expr>),
3434 Let(Vec<(String, Rc<Expr>)>, Rc<Expr>),
3535 App(Rc<Expr>, Rc<Expr>),
@@ -45,6 +45,34 @@ pub enum Expr {
4545 Crash, // internal marker if needed
4646}
4747
48+/// The source text of a lambda or section, exactly as written, which is how
49+/// its closure renders (§5.2): a span of the program text it was parsed
50+/// from. The lambdas of one program share that text instead of each holding
51+/// a copy, so nested lambdas cost a span each, not their text again.
52+#[derive(Clone)]
53+pub struct Source {
54+ text: Rc<str>,
55+ start: usize,
56+ end: usize,
57+}
58+
59+impl Source {
60+ /// The bytes `start..end` of `text`, which must fall on char boundaries.
61+ pub fn new(text: Rc<str>, start: usize, end: usize) -> Self {
62+ Source { text, start, end }
63+ }
64+
65+ pub fn as_str(&self) -> &str {
66+ &self.text[self.start..self.end]
67+ }
68+}
69+
70+impl std::fmt::Debug for Source {
71+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72+ write!(f, "{:?}", self.as_str())
73+ }
74+}
75+
4876#[derive(Debug, Clone)]
4977pub enum Item {
5078 TypeDecl(String, TypeExpr),
@@ -61,13 +89,18 @@ impl Expr {
6189 out.insert(n.clone());
6290 }
6391 }
92+ // `%main` is `labelled "main"` (§4.11)
93+ Expr::LabelLit(_) => {
94+ if !bound.iter().any(|b| b == "labelled") {
95+ out.insert("labelled".to_string());
96+ }
97+ }
6498 Expr::TypeName(_)
6599 | Expr::Int(_)
66100 | Expr::Text(_)
67101 | Expr::IdLit(_)
68102 | Expr::Id(_)
69103 | Expr::NewId
70− | Expr::LabelLit(_)
71104 | Expr::PathLit(_)
72105 | Expr::Bool(_)
73106 | Expr::SelectorFun(_)
src/builtins.rs+134−88
@@ -74,8 +74,8 @@ pub fn all_builtins() -> Vec<(String, Value)> {
7474 builtin(">=", 2, b_ge),
7575 builtin("show", 1, b_show),
7676 builtin("::", 2, b_cons),
77− builtin("map", 2, b_map),
78− builtin("filter", 2, b_filter),
77+ builtin("map", 2, on_machine),
78+ builtin("filter", 2, on_machine),
7979 builtin("length", 1, b_length),
8080 builtin("null", 1, b_null),
8181 builtin("head", 1, b_head),
@@ -86,7 +86,7 @@ pub fn all_builtins() -> Vec<(String, Value)> {
8686 builtin("drop", 2, b_drop),
8787 builtin("member", 2, b_member),
8888 builtin("range", 2, b_range),
89− builtin("foldl", 3, b_foldl),
89+ builtin("foldl", 3, on_machine),
9090 builtin("concat", 1, b_concat),
9191 builtin("++", 2, b_append),
9292 builtin("startsWith", 2, b_starts_with),
@@ -95,9 +95,14 @@ pub fn all_builtins() -> Vec<(String, Value)> {
9595 builtin("replay", 2, b_replay),
9696 builtin("unresolved", 1, b_unresolved),
9797 builtin("touchedPaths", 1, b_touched_paths),
98+ builtin("unrecorded", 1, b_unrecorded),
99+ builtin("subtreeCommits", 1, b_subtree_commits),
98100 builtin("blob", 1, b_blob),
101+ builtin("blobHex", 2, b_blob_hex),
99102 builtin("text", 1, b_text),
100103 builtin("by", 2, b_by),
104+ builtin("lookupCommit", 2, b_lookup_commit),
105+ builtin("lookupContent", 2, b_lookup_content),
101106 builtin("meta", 1, b_meta),
102107 builtin("validate", 1, b_validate),
103108 builtin("diff", 2, b_diff),
@@ -116,33 +121,13 @@ fn b_compose(_i: &mut Interp, args: &[Value]) -> BResult {
116121 compose_values(_i, f, g)
117122}
118123
119−fn compose_apply(i: &mut Interp, args: &[Value]) -> BResult {
120− let f = args[0].clone();
121− let g = args[1].clone();
122− let x = args[2].clone();
123− // A composed function applied to a function does not run (the operand
124− // contracts are about Repo-level values); it composes pointwise so that
125− // definitions like `abandon . contract everything` and `tree . squash`
126− // are functions waiting for the repository (§4.1 note, §1.2 step 7).
127− if matches!(x, Value::Fun(_)) {
128− let next = apply_or_compose(i, g, x)?;
129− return apply_or_compose(i, f, next);
130− }
131− let gx = i.apply(g, x)?;
132− i.apply(f, gx)
133−}
134−
135−/// Apply a signed function to an argument, composing instead when the
136−/// argument is a function but the contract's next parameter is not.
137−pub fn apply_or_compose(i: &mut Interp, f: Value, x: Value) -> BResult {
138− if matches!(x, Value::Fun(_)) {
139− if let Some((_, cexpr)) = pending_of(&f) {
140− if !cexpr.next_param_is_function(&i.shapes) {
141− return compose_values(i, f, x);
142− }
143− }
144− }
145− i.apply(f, x)
124+/// `map`, `filter`, `foldl` and a composition node apply the functions they
125+/// are given, so the evaluator runs them as steps of its machine
126+/// (Interp::step_applying): applying them in a nested run put every level of
127+/// a recursion through one on the native stack (§4.1). This only gives them a
128+/// name and an arity here; it is never called.
129+fn on_machine(_i: &mut Interp, _args: &[Value]) -> BResult {
130+ Err(Crash::new("internal: a builtin that applies functions ran off the machine"))
146131}
147132
148133/// f . g as a value, with contract propagation
@@ -154,6 +139,8 @@ pub fn compose_values(i: &mut Interp, f: Value, g: Value) -> BResult {
154139 Box::new((*cf).clone()),
155140 Box::new((*cg).clone()),
156141 )),
142+ // named holding `f` and `g`
143+ 2,
157144 )),
158145 _ => None,
159146 };
@@ -163,7 +150,7 @@ pub fn compose_values(i: &mut Interp, f: Value, g: Value) -> BResult {
163150 name: "(.)".into(),
164151 arity: 3,
165152 args: vec![f, g],
166− f: compose_apply,
153+ f: on_machine,
167154 pending,
168155 })))
169156}
@@ -182,8 +169,9 @@ pub fn pending_of(v: &Value) -> Option<(String, Rc<crate::shape::ContractExpr>)>
182169 ..
183170 } => Some((n.clone(), Rc::new(crate::shape::ContractExpr::Unknown))),
184171 crate::value::FunVal::Builtin {
185− pending: Some(p), ..
186− } => Some(p.clone()),
172+ pending: Some((n, c, _)),
173+ ..
174+ } => Some((n.clone(), c.clone())),
187175 crate::value::FunVal::Builtin {
188176 name, pending: None, ..
189177 } => Some((name.clone(), Rc::new(crate::shape::ContractExpr::Unknown))),
@@ -215,16 +203,26 @@ fn b_ne(_i: &mut Interp, args: &[Value]) -> BResult {
215203 Ok(Value::Bool(!value_eq(&args[0], &args[1])?))
216204}
217205
206+// `&&` and `||` short-circuit when written infix (the evaluator handles
207+// `Expr::BinOp` directly). These are the same operators used as *values* —
208+// `foldl (&&) true xs`, `map ((||) b) xs` — where both arguments are already
209+// evaluated, so they must still combine them rather than return the second.
218210fn b_and(_i: &mut Interp, args: &[Value]) -> BResult {
219211 want!(args, 0, Value::Bool(_), "Bool");
220212 want!(args, 1, Value::Bool(_), "Bool");
221− Ok(args[1].clone())
213+ match (&args[0], &args[1]) {
214+ (Value::Bool(a), Value::Bool(b)) => Ok(Value::Bool(*a && *b)),
215+ _ => unreachable!("checked above"),
216+ }
222217}
223218
224219fn b_or(_i: &mut Interp, args: &[Value]) -> BResult {
225220 want!(args, 0, Value::Bool(_), "Bool");
226221 want!(args, 1, Value::Bool(_), "Bool");
227− Ok(args[1].clone())
222+ match (&args[0], &args[1]) {
223+ (Value::Bool(a), Value::Bool(b)) => Ok(Value::Bool(*a || *b)),
224+ _ => unreachable!("checked above"),
225+ }
228226}
229227
230228fn b_not(_i: &mut Interp, args: &[Value]) -> BResult {
@@ -275,7 +273,7 @@ fn b_ge(_i: &mut Interp, args: &[Value]) -> BResult {
275273}
276274
277275fn b_show(i: &mut Interp, args: &[Value]) -> BResult {
278− Ok(Value::text(crate::show::show(i, &args[0])))
276+ Ok(Value::text(crate::show::show(i, &args[0])?))
279277}
280278
281279fn b_cons(_i: &mut Interp, args: &[Value]) -> BResult {
@@ -286,35 +284,6 @@ fn b_cons(_i: &mut Interp, args: &[Value]) -> BResult {
286284 Ok(Value::list(v))
287285}
288286
289−fn b_map(i: &mut Interp, args: &[Value]) -> BResult {
290− want!(args, 0, Value::Fun(_), "a function");
291− let xs = args[1].as_list()?;
292− let mut out = Vec::with_capacity(xs.len());
293− for x in xs.iter() {
294− out.push(i.apply(args[0].clone(), x.clone())?);
295− }
296− Ok(Value::list(out))
297−}
298−
299−fn b_filter(i: &mut Interp, args: &[Value]) -> BResult {
300− want!(args, 0, Value::Fun(_), "a function");
301− let xs = args[1].as_list()?;
302− let mut out = Vec::new();
303− for x in xs.iter() {
304− match i.apply(args[0].clone(), x.clone())? {
305− Value::Bool(true) => out.push(x.clone()),
306− Value::Bool(false) => (),
307− v => {
308− return Err(Crash::new(format!(
309− "filter: predicate returned a {}",
310− v.kind_name()
311− )))
312− }
313− }
314− }
315− Ok(Value::list(out))
316−}
317−
318287fn b_length(_i: &mut Interp, args: &[Value]) -> BResult {
319288 Ok(Value::int(args[0].as_list()?.len() as i64))
320289}
@@ -331,11 +300,15 @@ fn b_head(_i: &mut Interp, args: &[Value]) -> BResult {
331300}
332301
333302fn b_tail(_i: &mut Interp, args: &[Value]) -> BResult {
334− let xs = args[0].as_list()?;
335− if xs.is_empty() {
303+ if args[0].as_list()?.is_empty() {
336304 return Err(Crash::new("tail: empty list"));
337305 }
338− Ok(Value::list(xs[1..].to_vec()))
306+ // share the elements rather than copying them: `up` is `tail
307+ // repo.context`, so a copying tail made every zipper walk quadratic
308+ match &args[0] {
309+ Value::List(xs) => Ok(Value::List(xs.skip(1))),
310+ v => Err(Crash::new(format!("expected a list, got a {}", v.kind_name()))),
311+ }
339312}
340313
341314fn b_last(_i: &mut Interp, args: &[Value]) -> BResult {
@@ -379,9 +352,11 @@ fn b_take(_i: &mut Interp, args: &[Value]) -> BResult {
379352}
380353
381354fn b_drop(_i: &mut Interp, args: &[Value]) -> BResult {
382− let xs = args[1].as_list()?;
383− let n = clamp(args[0].as_int()?, xs.len())?;
384− Ok(Value::list(xs[n..].to_vec()))
355+ let n = clamp(args[0].as_int()?, args[1].as_list()?.len())?;
356+ match &args[1] {
357+ Value::List(xs) => Ok(Value::List(xs.skip(n))),
358+ v => Err(Crash::new(format!("expected a list, got a {}", v.kind_name()))),
359+ }
385360}
386361
387362fn b_member(_i: &mut Interp, args: &[Value]) -> BResult {
@@ -405,17 +380,6 @@ fn b_range(_i: &mut Interp, args: &[Value]) -> BResult {
405380 Ok(Value::list(out))
406381}
407382
408−fn b_foldl(i: &mut Interp, args: &[Value]) -> BResult {
409− want!(args, 0, Value::Fun(_), "a function");
410− let xs = args[2].as_list()?;
411− let mut acc = args[1].clone();
412− for x in xs.iter() {
413− let f = i.apply(args[0].clone(), acc)?;
414− acc = i.apply(f, x.clone())?;
415− }
416− Ok(acc)
417−}
418−
419383fn b_concat(_i: &mut Interp, args: &[Value]) -> BResult {
420384 let xs = args[0].as_list()?;
421385 if xs.is_empty() {
@@ -446,7 +410,7 @@ fn b_concat(_i: &mut Interp, args: &[Value]) -> BResult {
446410fn b_append(_i: &mut Interp, args: &[Value]) -> BResult {
447411 match (&args[0], &args[1]) {
448412 (Value::List(a), Value::List(b)) => {
449− let mut out = a.as_ref().clone();
413+ let mut out = a.to_vec();
450414 out.extend_from_slice(b);
451415 Ok(Value::list(out))
452416 }
@@ -493,14 +457,11 @@ fn b_split_on(_i: &mut Interp, args: &[Value]) -> BResult {
493457// ----------------------------------------------------------------------
494458
495459fn b_replay(i: &mut Interp, args: &[Value]) -> BResult {
496− let onto = args[0].as_list()?;
460+ args[0].as_list()?;
497461 let ch = &args[1];
498462 let from = ch.field("from")?;
499463 let to = ch.field("to")?;
500− let from = from.as_list()?;
501− let to = to.as_list()?;
502− let out = i.backend.replay(onto, from, to)?;
503− Ok(Value::list(out))
464+ i.backend.replay_snapshot(&args[0], &from, &to)
504465}
505466
506467fn b_unresolved(_i: &mut Interp, args: &[Value]) -> BResult {
@@ -537,6 +498,53 @@ fn b_touched_paths(_i: &mut Interp, args: &[Value]) -> BResult {
537498 Ok(Value::list(out))
538499}
539500
501+/// Working-directory edits captured at the start of this invocation, compared
502+/// with the recorded focus. Inspecting another commit or a hypothetical edit
503+/// must not change this answer. No disk scan or persistence happens here.
504+fn b_unrecorded(i: &mut Interp, _args: &[Value]) -> BResult {
505+ let old = i.old_repo.borrow().clone();
506+ let given = i.given_repo.borrow().clone();
507+ let (Some(old), Some(given)) = (old, given) else {
508+ // In-memory evaluations and reserved push expressions have no
509+ // working-directory snapshot.
510+ return Ok(Value::list(Vec::new()));
511+ };
512+ if matches!((&old, &given), (Value::Record(a), Value::Record(b)) if Rc::ptr_eq(a, b)) {
513+ return Ok(Value::list(Vec::new()));
514+ }
515+ let from = old.field("root")?.field("files")?;
516+ let to = given.field("root")?.field("files")?;
517+ b_touched_paths(i, &[Value::record(&[
518+ ("from", from),
519+ ("to", to),
520+ ])])
521+}
522+
523+/// every commit of a tree in preorder (§4.9): the value of the recursive
524+/// `\t -> t.root :: (concat (map subtreeCommits t.children) or [])`, which
525+/// the reference config's `commits` was. `::` and `concat` build new lists,
526+/// so that definition copied the list below every node and took time
527+/// quadratic in the length of the history, which the binary walks this way
528+/// on every run (`immutable`, §7.2). Its `or []` decides malformed trees:
529+/// only a top without `root` crashes, and a node whose `children` is not a
530+/// list of values with a `root` contributes its own root alone.
531+fn b_subtree_commits(_i: &mut Interp, args: &[Value]) -> BResult {
532+ let mut out = Vec::new();
533+ // the nodes still to visit, each with its root, the next one last
534+ let mut todo = vec![(args[0].field("root")?, args[0].clone())];
535+ while let Some((root, node)) = todo.pop() {
536+ out.push(root);
537+ let Ok(Value::List(kids)) = node.field("children") else {
538+ continue;
539+ };
540+ let Ok(roots) = kids.iter().map(|k| k.field("root")).collect::<Result<Vec<_>, _>>() else {
541+ continue;
542+ };
543+ todo.extend(roots.into_iter().zip(kids.iter().cloned()).rev());
544+ }
545+ Ok(Value::list(out))
546+}
547+
540548fn b_blob(_i: &mut Interp, args: &[Value]) -> BResult {
541549 let t = args[0].as_text()?;
542550 Ok(Value::Blob(Rc::new(BlobVal {
@@ -545,6 +553,21 @@ fn b_blob(_i: &mut Interp, args: &[Value]) -> BResult {
545553 })))
546554}
547555
556+fn b_blob_hex(_i: &mut Interp, args: &[Value]) -> BResult {
557+ let kind = match args[0].as_text()? {
558+ "regular" => BlobKind::Regular,
559+ "executable" => BlobKind::Executable,
560+ "symlink" => BlobKind::Symlink,
561+ _ => return Err(Crash::new("blobHex: kind must be regular, executable, or symlink")),
562+ };
563+ let bytes = hex::decode(args[1].as_text()?)
564+ .map_err(|e| Crash::new(format!("blobHex: invalid hexadecimal content: {}", e)))?;
565+ Ok(Value::Blob(Rc::new(BlobVal {
566+ kind,
567+ content: BlobContent::Resolved(Rc::new(bytes)),
568+ })))
569+}
570+
548571fn b_text(_i: &mut Interp, args: &[Value]) -> BResult {
549572 match &args[0] {
550573 Value::Blob(b) => {
@@ -578,6 +601,18 @@ fn b_by(_i: &mut Interp, args: &[Value]) -> BResult {
578601 }
579602}
580603
604+fn b_lookup_commit(_i: &mut Interp, args: &[Value]) -> BResult {
605+ let id = match &args[0] {
606+ Value::Id(id) => id.as_str(),
607+ v => return Err(Crash::new(format!("by: expected an Id, got a {}", v.kind_name()))),
608+ };
609+ crate::repo::commit_by_id(&args[1], id)?.ok_or_else(|| Crash::new("by: no such commit"))
610+}
611+
612+fn b_lookup_content(_i: &mut Interp, args: &[Value]) -> BResult {
613+ crate::lookup::content_at(&args[0], &args[1])
614+}
615+
581616fn b_meta(i: &mut Interp, args: &[Value]) -> BResult {
582617 let id = match &args[0] {
583618 Value::Id(id) => id.to_string(),
@@ -599,6 +634,11 @@ fn b_meta(i: &mut Interp, args: &[Value]) -> BResult {
599634
600635fn b_validate(i: &mut Interp, args: &[Value]) -> BResult {
601636 crate::repo::validate_repo(i, &args[0])?;
637+ if let Some(loaded) = i.old_repo.borrow().as_ref() {
638+ if !crate::repo::same_repo(loaded, &args[0])? {
639+ i.backend.validate_authoring()?;
640+ }
641+ }
602642 Ok(args[0].clone())
603643}
604644
@@ -651,6 +691,12 @@ fn extract_into(
651691 out: &mut Vec<Value>,
652692) -> Result<(), Crash> {
653693 use crate::value::{PrimKind, ShapeKind};
694+ // a commit's `files` is a thunk until something asks for it; the language
695+ // must not see the difference, so force before matching and recursing —
696+ // otherwise `extract Entry repo` finds nothing on a real repository while
697+ // finding everything on the in-memory backend
698+ let forced = v.forced()?;
699+ let v = &forced;
654700 let matches = match (&shape.kind, v) {
655701 (ShapeKind::Prim(PrimKind::Int), Value::Int(_)) => true,
656702 (ShapeKind::Prim(PrimKind::Text), Value::Text(_)) => true,
src/config.rs+370−54
@@ -39,8 +39,8 @@ const RESERVED_BUILTINS: &[&str] = &[
3939 ".", "id", "const", "crash", "==", "/=", "&&", "||", "not", "+", "-", "*", "<", "<=", ">",
4040 ">=", "show", "::", "map", "filter", "length", "null", "head", "tail", "last", "nth", "take",
4141 "drop", "member", "range", "foldl", "concat", "++", "startsWith", "endsWith", "splitOn",
42− "replay", "unresolved", "blob", "text", "by", "meta", "validate", "diff", "difft", "treeWith",
43− "extract", "touchedPaths",
42+ "replay", "unresolved", "blob", "blobHex", "text", "by", "meta", "validate", "diff", "difft", "treeWith",
43+ "extract", "touchedPaths", "unrecorded", "subtreeCommits", "lookupCommit", "lookupContent",
4444];
4545
4646pub fn is_reserved_builtin(name: &str) -> bool {
@@ -53,6 +53,8 @@ pub fn reserved_set() -> BTreeSet<String> {
5353
5454/// Parse and validate config.j (§6.2). Id literals are resolved separately.
5555pub fn load_config(src: &str) -> Result<Config, ConfigError> {
56+ // the builtins only: config.j's binders may reuse its own top-level
57+ // names (§4.2), as the reference config's `\files ->` does
5658 let outer = Rc::new(reserved_set());
5759 let items = parse_config(src, outer).map_err(ConfigError::Parse)?;
5860 validate_items(items)
@@ -95,39 +97,24 @@ fn validate_items(items: Vec<Item>) -> Result<Config, ConfigError> {
9597 }
9698
9799 // 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;
100+ // its definition (blank lines/comments fine — items between are not, a
101+ // typedecl or another signature included). A builtin's signature has no
102+ // definition, so any item, or the end of the file, may follow it.
103+ let mut prev_sig: Option<&String> = None;
104+ for item in items.iter().map(Some).chain([None]) {
105+ if let Some(s) = prev_sig {
106+ let paired = matches!(item, Some(Item::Definition(name, _, _)) if name == s);
107+ if !paired && def_names.contains(s) {
108+ return verr(format!(
109+ "the signature for `{}` is not immediately followed by its definition",
110+ s
111+ ));
121112 }
122113 }
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− }
114+ prev_sig = match item {
115+ Some(Item::Signature(name, _, _)) => Some(name),
116+ _ => None,
117+ };
131118 }
132119
133120 let mut builtin_names: HashSet<String> = HashSet::new();
@@ -179,7 +166,25 @@ fn validate_items(items: Vec<Item>) -> Result<Config, ConfigError> {
179166 })
180167}
181168
182−/// topological order by references outside lambdas (§4.1)
169+/// Evaluation order of the definitions (§4.1).
170+///
171+/// References outside lambdas decide it, and only they can form a cycle;
172+/// ready definitions are taken in name order. That alone can apply a
173+/// function at load before a definition its body refers to (`opts = mk 1`
174+/// with `mk = \d -> { lanes = lanes }` and `lanes` still waiting), an unbound
175+/// name with no cycle anywhere. So the definitions are then grouped into the
176+/// strongly connected components of all references, lambda bodies included,
177+/// and the groups evaluated referenced first, the members of each in the
178+/// first order, save that a member which cannot apply a function of its group
179+/// at load goes first: one that refers to the group only inside lambdas it
180+/// stores (`env = { lanes = 3, header = \_ -> out }`, with `out = render 1`
181+/// and `render = \x -> env.lanes`). Such a member looks up only earlier
182+/// groups and builtins, and every other definition keeps its place among the
183+/// rest, so each still follows its references outside lambdas; and since
184+/// evaluating one looks up only names it reaches by references, whatever the
185+/// first order had bound in time still is (a lookup that came too early, its
186+/// crash caught by `or`, may now find its name). Among members that can apply
187+/// a function of their group, the first order may still apply one too early.
183188fn dependency_order(
184189 defs: &HashMap<String, Rc<Expr>>,
185190) -> Result<Vec<(String, Rc<Expr>)>, ConfigError> {
@@ -194,7 +199,7 @@ fn dependency_order(
194199 let mut done: HashSet<String> = HashSet::new();
195200 let mut names: Vec<String> = defs.keys().cloned().collect();
196201 names.sort();
197− loop {
202+ while done.len() < names.len() {
198203 let mut progress = false;
199204 for n in &names {
200205 if done.contains(n) {
@@ -206,15 +211,305 @@ fn dependency_order(
206211 progress = true;
207212 }
208213 }
209− if done.len() == names.len() {
210− return Ok(ordered);
211− }
212214 if !progress {
213215 return Err(ConfigError::Validation(
214216 "config.j: a cycle among top-level definitions".into(),
215217 ));
216218 }
217219 }
220+ let position: HashMap<&str, usize> =
221+ ordered.iter().enumerate().map(|(i, (n, _))| (n.as_str(), i)).collect();
222+ let edges: Vec<Vec<usize>> = ordered
223+ .iter()
224+ .map(|(n, e)| {
225+ let mut free = deps[n].clone();
226+ e.free_vars(&mut Vec::new(), &mut free);
227+ free.iter().filter_map(|n| position.get(n.as_str()).copied()).collect()
228+ })
229+ .collect();
230+ let order = group_order(&edges, |group, p| {
231+ let (n, e) = &ordered[p];
232+ let mut eager = deps[n].clone();
233+ applied_refs(e, true, &mut Vec::new(), &mut eager);
234+ !eager
235+ .iter()
236+ .filter_map(|d| position.get(d.as_str()))
237+ .any(|q| group.binary_search(q).is_ok())
238+ });
239+ Ok(order.into_iter().map(|p| ordered[p].clone()).collect())
240+}
241+
242+/// Positions in a first order regrouped for evaluation (§4.1): the strongly
243+/// connected components of `edges`, each after every one it has an edge
244+/// into, and inside a component of several, first the members for which
245+/// `early` holds, given the component (sorted) and the member, then the
246+/// others, each part in the first order.
247+fn group_order(edges: &[Vec<usize>], early: impl Fn(&[usize], usize) -> bool) -> Vec<usize> {
248+ let mut out = Vec::with_capacity(edges.len());
249+ for mut group in components(edges) {
250+ group.sort_unstable();
251+ if group.len() > 1 {
252+ let (front, rest): (Vec<usize>, Vec<usize>) =
253+ group.iter().partition(|&&p| early(&group, p));
254+ group = front;
255+ group.extend(rest);
256+ }
257+ out.extend(group);
258+ }
259+ out
260+}
261+
262+/// The strongly connected components of a graph given as adjacency lists,
263+/// each listed after every component it has an edge into (Tarjan's
264+/// algorithm, on an explicit stack so a long chain of definitions cannot
265+/// overflow the native one).
266+fn components(edges: &[Vec<usize>]) -> Vec<Vec<usize>> {
267+ const UNSEEN: usize = usize::MAX;
268+ let n = edges.len();
269+ let (mut index, mut low, mut on_stack) = (vec![UNSEEN; n], vec![0; n], vec![false; n]);
270+ let (mut stack, mut out, mut next) = (Vec::new(), Vec::new(), 0);
271+ for root in 0..n {
272+ if index[root] != UNSEEN {
273+ continue;
274+ }
275+ // (vertex, how many of its edges have been followed)
276+ let mut work = vec![(root, 0)];
277+ while let Some(&(v, i)) = work.last() {
278+ if i == 0 {
279+ index[v] = next;
280+ low[v] = next;
281+ next += 1;
282+ stack.push(v);
283+ on_stack[v] = true;
284+ }
285+ if let Some(&w) = edges[v].get(i) {
286+ work.last_mut().unwrap().1 = i + 1;
287+ if index[w] == UNSEEN {
288+ work.push((w, 0));
289+ } else if on_stack[w] {
290+ low[v] = low[v].min(index[w]);
291+ }
292+ continue;
293+ }
294+ work.pop();
295+ if let Some(&(u, _)) = work.last() {
296+ low[u] = low[u].min(low[v]);
297+ }
298+ if low[v] == index[v] {
299+ let mut group = Vec::new();
300+ loop {
301+ let w = stack.pop().unwrap();
302+ on_stack[w] = false;
303+ group.push(w);
304+ if w == v {
305+ break;
306+ }
307+ }
308+ out.push(group);
309+ }
310+ }
311+ }
312+ out
313+}
314+
315+pub(crate) struct LetPlan {
316+ pub order: Vec<usize>,
317+ /// Whether a closure needs to see its own or a later binding. Otherwise
318+ /// completed bindings can be held in immutable, acyclic environments.
319+ pub recursive: bool,
320+}
321+
322+/// Plan the bindings of a `let` block (§4.1), or return `None` when references
323+/// outside lambdas form a cycle.
324+///
325+/// The rule is `dependency_order`'s, with ready bindings taken in source
326+/// order: a binding follows every binding it refers to outside a lambda; the
327+/// bindings are then grouped by all their references, and inside a group
328+/// those that cannot apply a function of it go first, so that a function
329+/// applied in a binding finds the bindings its body refers to. That order is
330+/// the source order when every reference is to an earlier binding, or from
331+/// inside a lambda to its own, as in nearly every block, which is so taken
332+/// without the sorting: this runs each time a block is evaluated.
333+pub(crate) fn let_plan(bs: &[(String, Rc<Expr>)]) -> Option<LetPlan> {
334+ let refs: Vec<Vec<(usize, bool)>> = bs
335+ .iter()
336+ .map(|(_, e)| {
337+ let mut out = Vec::new();
338+ binding_refs(e, bs, false, &mut out);
339+ out
340+ })
341+ .collect();
342+ let order = let_order(bs, &refs)?;
343+ let mut position = vec![0; bs.len()];
344+ for (p, &i) in order.iter().enumerate() {
345+ position[i] = p;
346+ }
347+ let recursive = refs.iter().enumerate().any(|(i, refs)| {
348+ refs.iter().any(|&(j, _)| position[j] >= position[i])
349+ });
350+ Some(LetPlan { order, recursive })
351+}
352+
353+fn let_order(bs: &[(String, Rc<Expr>)], refs: &[Vec<(usize, bool)>]) -> Option<Vec<usize>> {
354+ let in_order =
355+ |i: usize, r: &[(usize, bool)]| r.iter().all(|&(j, outside)| j < i || (j == i && !outside));
356+ if refs.iter().enumerate().all(|(i, r)| in_order(i, r)) {
357+ return Some((0..bs.len()).collect());
358+ }
359+ let mut ordered = Vec::with_capacity(bs.len());
360+ let mut done = vec![false; bs.len()];
361+ while ordered.len() < bs.len() {
362+ let mut progress = false;
363+ for i in 0..bs.len() {
364+ if !done[i] && refs[i].iter().all(|&(j, outside)| !outside || done[j]) {
365+ done[i] = true;
366+ ordered.push(i);
367+ progress = true;
368+ }
369+ }
370+ if !progress {
371+ return None;
372+ }
373+ }
374+ let mut position = vec![0; bs.len()];
375+ for (p, &i) in ordered.iter().enumerate() {
376+ position[i] = p;
377+ }
378+ let edges: Vec<Vec<usize>> = ordered
379+ .iter()
380+ .map(|&i| refs[i].iter().map(|&(j, _)| position[j]).collect())
381+ .collect();
382+ let order = group_order(&edges, |group, p| {
383+ let i = ordered[p];
384+ let mut applied = BTreeSet::new();
385+ applied_refs(&bs[i].1, true, &mut Vec::new(), &mut applied);
386+ let in_group = |j: usize| group.binary_search(&position[j]).is_ok();
387+ !refs[i].iter().any(|&(j, outside)| outside && in_group(j))
388+ && !bs.iter().enumerate().any(|(j, (b, _))| applied.contains(b) && in_group(j))
389+ });
390+ Some(order.into_iter().map(|p| ordered[p]).collect())
391+}
392+
393+/// Record the references in `e` to the names a `let` block binds, as indices
394+/// into its bindings `bs`, each with whether it lies outside every lambda,
395+/// where evaluating `e` needs that binding's value (§4.1). No binder inside
396+/// the block may reuse one of its names (§4.2), so every occurrence is one.
397+fn binding_refs(
398+ e: &Expr,
399+ bs: &[(String, Rc<Expr>)],
400+ in_lambda: bool,
401+ out: &mut Vec<(usize, bool)>,
402+) {
403+ let mut sub = |e: &Expr| binding_refs(e, bs, in_lambda, out);
404+ match e {
405+ Expr::Var(n) => {
406+ if let Some(j) = bs.iter().position(|(b, _)| b == n) {
407+ out.push((j, !in_lambda));
408+ }
409+ }
410+ // `%main` is `labelled "main"` (§4.11), and in config.j a block may
411+ // bind `labelled` (§4.2)
412+ Expr::LabelLit(_) => {
413+ if let Some(j) = bs.iter().position(|(b, _)| b == "labelled") {
414+ out.push((j, !in_lambda));
415+ }
416+ }
417+ Expr::Lambda(_, body, _) => binding_refs(body, bs, true, out),
418+ Expr::If(a, b, c) => {
419+ sub(a);
420+ sub(b);
421+ sub(c);
422+ }
423+ Expr::Let(inner, body) => {
424+ for (_, e) in inner {
425+ sub(e);
426+ }
427+ sub(body);
428+ }
429+ Expr::App(a, b) | Expr::BinOp(_, a, b) | Expr::Or(a, b) => {
430+ sub(a);
431+ sub(b);
432+ }
433+ Expr::Select(a, _) | Expr::Paren(a) => sub(a),
434+ Expr::Update(a, fs) => {
435+ sub(a);
436+ for (_, v) in fs {
437+ sub(v);
438+ }
439+ }
440+ Expr::Record(fs) => {
441+ for (_, v) in fs {
442+ sub(v);
443+ }
444+ }
445+ Expr::List(es) => {
446+ for e in es {
447+ sub(e);
448+ }
449+ }
450+ Expr::TypeName(_)
451+ | Expr::Int(_)
452+ | Expr::Text(_)
453+ | Expr::IdLit(_)
454+ | Expr::Id(_)
455+ | Expr::NewId
456+ | Expr::PathLit(_)
457+ | Expr::Bool(_)
458+ | Expr::SelectorFun(_)
459+ | Expr::Crash => {}
460+ }
461+}
462+
463+/// Record the free names of the lambdas that evaluating `e` may apply
464+/// (§4.1): every lambda outside the others, save where its value is only
465+/// stored, which is `e` itself when `stored`, and a record field, list
466+/// element, `if` branch or `let` body of such a place. `bound` holds the
467+/// `let` names in scope, which in config.j may reuse a top-level name (§4.2).
468+fn applied_refs(e: &Expr, stored: bool, bound: &mut Vec<String>, out: &mut BTreeSet<String>) {
469+ match e {
470+ Expr::Lambda(_, _, _) => {
471+ if !stored {
472+ e.free_vars(bound, out);
473+ }
474+ }
475+ Expr::Paren(a) => applied_refs(a, stored, bound, out),
476+ Expr::If(c, a, b) => {
477+ applied_refs(c, false, bound, out);
478+ applied_refs(a, stored, bound, out);
479+ applied_refs(b, stored, bound, out);
480+ }
481+ Expr::Let(bs, body) => {
482+ let start = bound.len();
483+ bound.extend(bs.iter().map(|(n, _)| n.clone()));
484+ for (_, b) in bs {
485+ applied_refs(b, false, bound, out);
486+ }
487+ applied_refs(body, stored, bound, out);
488+ bound.truncate(start);
489+ }
490+ Expr::App(a, b) | Expr::BinOp(_, a, b) | Expr::Or(a, b) => {
491+ applied_refs(a, false, bound, out);
492+ applied_refs(b, false, bound, out);
493+ }
494+ Expr::Select(a, _) => applied_refs(a, false, bound, out),
495+ Expr::Update(a, fs) => {
496+ applied_refs(a, false, bound, out);
497+ for (_, v) in fs {
498+ applied_refs(v, stored, bound, out);
499+ }
500+ }
501+ Expr::Record(fs) => {
502+ for (_, v) in fs {
503+ applied_refs(v, stored, bound, out);
504+ }
505+ }
506+ Expr::List(es) => {
507+ for e in es {
508+ applied_refs(e, stored, bound, out);
509+ }
510+ }
511+ _ => {}
512+ }
218513}
219514
220515/// Names a definition's value depends on at load time (§4.1). Lambda bodies
@@ -276,6 +571,10 @@ fn load_deps(e: &Expr, out: &mut BTreeSet<String>) {
276571 load_deps(e, out);
277572 }
278573 }
574+ // `%main` is `labelled "main"` (§4.11), evaluated where it stands
575+ Expr::LabelLit(_) => {
576+ out.insert("labelled".to_string());
577+ }
279578 _ => {}
280579 }
281580}
@@ -304,29 +603,34 @@ pub fn eval_config(interp: &mut Interp, cfg: &Config) -> Result<(), Crash> {
304603 }
305604 }
306605 interp.globals = Env::with_globals(globals);
606+ // a definition executes while its lambda's body runs (§1.4)
607+ for (name, expr) in &cfg.defs {
608+ interp.name_bodies(name, expr);
609+ }
307610 // evaluate definitions in dependency order, through one recursive frame so
308611 // top-level definitions are mutually recursive (§4.1)
309− let (genv, cell) = interp.globals.extend_rec();
612+ let names: Rc<[String]> = cfg.defs.iter().map(|(name, _)| name.clone()).collect();
613+ let (genv, cell) = interp.globals.extend_rec(names);
310614 for (name, expr) in &cfg.defs {
311615 *interp.current_def.borrow_mut() = Some(name.clone());
312616 let v = interp.eval(expr, &genv)?;
313− // value-level contract for non-function definitions
617+ // the value itself, one level deep, against the whole signature
618+ // (§4.13): under a function type, including an alias of one, it must
619+ // be a function, however it was built (`conflicts : Revset` is a
620+ // partial application, `myEdit : Edit` with `myEdit = 5` is refused)
314621 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− }
622+ if let Err(msg) = crate::shape::check(&interp.shapes, ty, &v) {
623+ return Err(Crash::new(format!("contract: {}: {}", name, msg)));
320624 }
321625 }
322− cell.borrow_mut().push((name.clone(), v.clone()));
626+ cell.borrow_mut().push(v.clone());
323627 // attach the definition's name and contract to its function value so
324628 // applications are checked (§4.13) and errors name the definition
325629 if let Some(ty) = cfg.sigs.get(name) {
326630 let contract = Rc::new(compile_contract(&interp.shapes, ty));
327631 let named = crate::value::attach_pending(&v, name, contract);
328632 let last = cell.borrow_mut().len() - 1;
329− cell.borrow_mut()[last] = (name.clone(), named);
633+ cell.borrow_mut()[last] = named;
330634 }
331635 }
332636 interp.globals = genv;
@@ -464,17 +768,29 @@ fn resolve_ids_rec(
464768 }
465769}
466770
467−/// find Id literals per definition for config validation (§6.2.5)
468−pub fn config_id_failures(
469− cfg: &Config,
771+/// Resolve every `@prefix` literal in the config's definitions against the
772+/// visible ids, in place (§1.2.5: "in `config.j`, every literal is resolved at
773+/// load"). Returns the ones that did not resolve to exactly one commit, as
774+/// (definition, prefix, candidates); those definitions are left untouched.
775+///
776+/// Checking without rewriting is not enough: an unresolved literal reaching
777+/// the evaluator crashes with `internal: unresolved id literal`, and because a
778+/// lambda body is not evaluated at load, it does so only when the definition
779+/// is eventually used.
780+pub fn resolve_config_ids(
781+ cfg: &mut Config,
470782 interp: &Interp,
471783) -> Vec<(String, String, Vec<String>)> {
472784 let mut out = Vec::new();
473− for (name, expr) in &cfg.defs {
785+ for (name, expr) in cfg.defs.iter_mut() {
474786 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));
787+ let resolved = resolve_ids_rec(expr, interp, &mut failures);
788+ if failures.is_empty() {
789+ *expr = Rc::new(resolved);
790+ } else {
791+ for (prefix, matches) in failures {
792+ out.push((name.clone(), prefix, matches));
793+ }
478794 }
479795 }
480796 out
src/diagnostics.rsnew file+381
@@ -0,0 +1,381 @@
1+//! Read-only guidance for common CLI mistakes. The original error remains the
2+//! authority: these helpers neither retry an expression nor change its meaning.
3+
4+use crate::ast::{Expr, TypeExpr};
5+use crate::config::Config;
6+use crate::parse::parse_expr;
7+use crate::shape::{compile_contract, type_name};
8+use crate::value::Crash;
9+use std::collections::BTreeSet;
10+use std::rc::Rc;
11+use unicode_width::UnicodeWidthChar;
12+
13+/// Add a location and the offending source line without inventing a column
14+/// that the parser does not currently retain.
15+pub fn format_source_error(label: &str, src: &str, line: usize, msg: &str) -> String {
16+ let mut output = format!("{}: line {}: {}", label, line, msg);
17+ if let Some(source_line) = line.checked_sub(1).and_then(|n| src.split('\n').nth(n)) {
18+ output.push_str(&format!("\n {} | {}", line, source_excerpt(source_line.trim_end_matches('\r'))));
19+ }
20+ output
21+}
22+
23+/// Leave room for an ellipsis, count wide characters, and render terminal
24+/// controls as visible escapes. Charge even zero-width characters one column
25+/// so a malformed line of combining marks cannot produce unlimited output.
26+fn source_excerpt(line: &str) -> String {
27+ const MAX_COLUMNS: usize = 160;
28+ let mut excerpt = String::new();
29+ let mut columns = 0;
30+ for character in line.chars() {
31+ let visible = if character.is_control() {
32+ character.escape_default().to_string()
33+ } else {
34+ character.to_string()
35+ };
36+ let width: usize = visible.chars().map(|c| c.width().unwrap_or(0).max(1)).sum();
37+ if columns + width > MAX_COLUMNS - 1 {
38+ excerpt.push('…');
39+ break;
40+ }
41+ excerpt.push_str(&visible);
42+ columns += width;
43+ }
44+ excerpt
45+}
46+
47+/// Suggest a next step only when the reported failure agrees with the user's
48+/// expression. In particular, an error inside a config definition must not be
49+/// misdiagnosed as a missing argument on the command line.
50+pub fn expression_hint(text: &str, cfg: &Config, crash: &Crash) -> Option<String> {
51+ expression_hint_for(text, cfg, crash, Invocation::Expression)
52+}
53+
54+/// Diagnose the expression following `push`, preserving that reserved command
55+/// in copyable examples so following a hint cannot turn a push into a local edit.
56+pub fn push_expression_hint(text: &str, cfg: &Config, crash: &Crash) -> Option<String> {
57+ expression_hint_for(text, cfg, crash, Invocation::Push)
58+}
59+
60+#[derive(Clone, Copy)]
61+enum Invocation {
62+ Expression,
63+ Push,
64+}
65+
66+fn expression_hint_for(text: &str, cfg: &Config, crash: &Crash, invocation: Invocation) -> Option<String> {
67+ let expr = parse_expr(text, Rc::new(cfg.global_names.clone())).ok()?;
68+ let names = vocabulary(cfg);
69+ if let Some(name) = crash.msg.strip_prefix("unbound name `").and_then(|s| s.strip_suffix('`')) {
70+ if crash.def.is_some() {
71+ return None;
72+ }
73+ let mut free = BTreeSet::new();
74+ expr.free_vars(&mut Vec::new(), &mut free);
75+ if !free.contains(name) {
76+ return None;
77+ }
78+ if let Some(hint) = text_argument_hint(&expr, cfg, &names, name, invocation) {
79+ return Some(hint);
80+ }
81+ let mut suggestions: Vec<_> = names
82+ .iter()
83+ .filter(|candidate| candidate.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_'))
84+ .filter_map(|candidate| {
85+ let limit = if name.chars().count() > 4 { 2 } else { 1 };
86+ let distance = edit_distance(name, candidate, limit)?;
87+ (distance > 0).then_some((distance, *candidate))
88+ })
89+ .collect();
90+ suggestions.sort_unstable();
91+ if let Some((distance, best)) = suggestions.first().copied() {
92+ let close: Vec<_> = suggestions
93+ .iter()
94+ .take_while(|(d, _)| *d == distance)
95+ .take(3)
96+ .map(|(_, n)| format!("`{}`", n))
97+ .collect();
98+ return Some(if close.len() == 1 {
99+ format!("Did you mean `{}`? See `{}`.", best, help_command(best))
100+ } else {
101+ format!("Did you mean {}? Use `j help NAME` for details.", close.join(" or "))
102+ });
103+ }
104+ return Some("Run `j commands` to browse the names available in your config.".into());
105+ }
106+
107+ let (name, args) = application(&expr)?;
108+ let signature = cfg.sigs.get(name)?;
109+ let contract = compile_contract(&cfg.shapes, signature);
110+ let parameter = contract.params.get(args.len())?;
111+ // A record supplied explicitly fails at an earlier argument. Requiring the
112+ // next argument to match confines this hint to the implicit repo argument.
113+ if !crash.msg.starts_with(&format!("contract: {} expected ", name))
114+ || !crash.msg.ends_with(&format!(" as argument {}, got record", args.len() + 1))
115+ || matches!(cfg.shapes.unfold(parameter), TypeExpr::Record(_))
116+ {
117+ return None;
118+ }
119+ let expected = type_name(parameter);
120+ Some(format!(
121+ "`{}` needs an argument of type {} before it can receive the repository. See `{}`.",
122+ name, expected, help_command(name)
123+ ))
124+}
125+
126+/// `global_names` also reserves every implemented builtin for shadowing checks.
127+/// Only names actually supplied by this config are useful typo suggestions.
128+fn vocabulary(cfg: &Config) -> BTreeSet<&str> {
129+ cfg.defs
130+ .iter()
131+ .map(|(name, _)| name.as_str())
132+ .chain(cfg.builtin_names.iter().map(String::as_str))
133+ .collect()
134+}
135+
136+fn unparen(mut expr: &Expr) -> &Expr {
137+ while let Expr::Paren(inner) = expr {
138+ expr = inner;
139+ }
140+ expr
141+}
142+
143+fn application(expr: &Expr) -> Option<(&str, Vec<&Expr>)> {
144+ let mut head = unparen(expr);
145+ let mut args = Vec::new();
146+ while let Expr::App(function, argument) = head {
147+ args.push(unparen(argument));
148+ head = unparen(function);
149+ }
150+ args.reverse();
151+ match head {
152+ Expr::Var(name) => Some((name, args)),
153+ _ => None,
154+ }
155+}
156+
157+fn text_argument_hint(
158+ expr: &Expr,
159+ cfg: &Config,
160+ names: &BTreeSet<&str>,
161+ missing: &str,
162+ invocation: Invocation,
163+) -> Option<String> {
164+ let (name, args) = application(expr)?;
165+ let contract = compile_contract(&cfg.shapes, cfg.sigs.get(name)?);
166+ let index = args.iter().position(|arg| matches!(arg, Expr::Var(n) if n == missing))?;
167+ let parameter = contract.params.get(index)?;
168+ if !matches!(cfg.shapes.unfold(parameter), TypeExpr::Con(n) if n == "Text") {
169+ return None;
170+ }
171+
172+ // For a single Text argument, shell-stripped words can be shown in a
173+ // complete, copyable example. Do not guess how to group multiple Text
174+ // parameters or how to rewrite a larger expression.
175+ let only_text_argument = index == 0
176+ && contract.params.iter().skip(1).all(|ty| matches!(ty.as_ref(), TypeExpr::Con(n) if n == "Repo"));
177+ let words: Option<Vec<_>> = args
178+ .iter()
179+ .map(|arg| match arg {
180+ Expr::Var(n) if !names.contains(n.as_str()) => Some(n.as_str()),
181+ _ => None,
182+ })
183+ .collect();
184+ let prefix = match invocation {
185+ Invocation::Expression => "",
186+ Invocation::Push => "push ",
187+ };
188+ match words.filter(|_| only_text_argument) {
189+ Some(words) => Some(format!(
190+ "If you meant text, preserve its double quotes by quoting the whole expression: j {}. See `{}`.",
191+ shell_quote(&format!("{}{} \"{}\"", prefix, name, words.join(" "))), help_command(name)
192+ )),
193+ None => Some(format!(
194+ "If you meant text, use double quotes for the text inside shell single quotes around the expression. See `{}`.",
195+ help_command(name)
196+ )),
197+ }
198+}
199+
200+fn shell_quote(text: &str) -> String {
201+ format!("'{}'", text.replace('\'', "'\\''"))
202+}
203+
204+fn help_command(name: &str) -> String {
205+ let argument = if name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
206+ name.to_string()
207+ } else {
208+ shell_quote(name)
209+ };
210+ format!("j help {}", argument)
211+}
212+
213+/// Bounded Damerau-Levenshtein distance catches adjacent transpositions as well
214+/// as inserted, removed, and replaced letters. Long identifiers are skipped so
215+/// error reporting cannot become expensive for generated input.
216+fn edit_distance(a: &str, b: &str, limit: usize) -> Option<usize> {
217+ let a: Vec<_> = a.chars().take(129).collect();
218+ let b: Vec<_> = b.chars().take(129).collect();
219+ if a.len() > 128 || b.len() > 128 || a.len().abs_diff(b.len()) > limit {
220+ return None;
221+ }
222+ let mut older = vec![0; b.len() + 1];
223+ let mut previous: Vec<_> = (0..=b.len()).collect();
224+ let mut current = vec![0; b.len() + 1];
225+ for (i, letter) in a.iter().enumerate() {
226+ current[0] = i + 1;
227+ for (k, other) in b.iter().enumerate() {
228+ current[k + 1] = (previous[k + 1] + 1)
229+ .min(current[k] + 1)
230+ .min(previous[k] + usize::from(letter != other));
231+ if i > 0 && k > 0 && *letter == b[k - 1] && a[i - 1] == *other {
232+ current[k + 1] = current[k + 1].min(older[k - 1] + 1);
233+ }
234+ }
235+ std::mem::swap(&mut older, &mut previous);
236+ std::mem::swap(&mut previous, &mut current);
237+ }
238+ (previous[b.len()] <= limit).then_some(previous[b.len()])
239+}
240+
241+#[cfg(test)]
242+mod tests {
243+ use super::*;
244+ use crate::config::{eval_config, load_config};
245+ use crate::eval::Interp;
246+ use crate::value::Value;
247+
248+ fn reference() -> Config {
249+ load_config(include_str!("../config.j")).unwrap()
250+ }
251+
252+ fn failure(cfg: &Config, text: &str) -> Crash {
253+ let mut interp = Interp::dummy();
254+ interp.shapes = cfg.shapes.clone();
255+ eval_config(&mut interp, cfg).unwrap();
256+ let expr = Rc::new(parse_expr(text, Rc::new(cfg.global_names.clone())).unwrap());
257+ let result = interp.eval(&expr, &interp.global_env()).and_then(|value| match value {
258+ Value::Fun(_) => interp.apply(value, Value::record(&[])),
259+ value => Ok(value),
260+ });
261+ result.expect_err("test expression must fail")
262+ }
263+
264+ #[test]
265+ fn suggests_real_commands_and_transposed_names() {
266+ let cfg = reference();
267+ for (expression, expected) in [("neww", "j help new"), ("desrcibe", "j help describe")] {
268+ let hint = expression_hint(expression, &cfg, &failure(&cfg, expression)).unwrap();
269+ assert!(hint.contains(expected), "{}", hint);
270+ }
271+ }
272+
273+ #[test]
274+ fn suggests_custom_definitions_but_not_undeclared_builtins() {
275+ let cfg = load_config("user = { name = \"A\", email = \"a@b\" }\ntree = \\r -> \"\"\nimmutable = \\r -> []\nlabelled = \\n r -> []\nship = \\r -> r\n").unwrap();
276+ let hint = expression_hint("shipp", &cfg, &failure(&cfg, "shipp")).unwrap();
277+ assert!(hint.contains("j help ship"));
278+ let hint = expression_hint("repla", &cfg, &failure(&cfg, "repla")).unwrap();
279+ assert!(!hint.contains("replay"));
280+ assert!(hint.contains("j commands"));
281+ }
282+
283+ #[test]
284+ fn explains_shell_quoting_only_for_text_parameters() {
285+ let cfg = reference();
286+ for expression in ["describe hello", "describe first change"] {
287+ let hint = expression_hint(expression, &cfg, &failure(&cfg, expression)).unwrap();
288+ assert!(hint.contains("j 'describe \""), "{}", hint);
289+ assert!(hint.contains("j help describe"));
290+ }
291+ let hint = expression_hint("rebase helloo", &cfg, &failure(&cfg, "rebase helloo")).unwrap();
292+ assert!(!hint.contains("quotes"));
293+ }
294+
295+ #[test]
296+ fn push_quoting_examples_preserve_the_reserved_command() {
297+ let cfg = reference();
298+ for (expression, corrected) in [
299+ ("unlabel feature", "unlabel \"feature\""),
300+ ("describe hello", "describe \"hello\""),
301+ ] {
302+ let crash = failure(&cfg, expression);
303+ let hint = push_expression_hint(expression, &cfg, &crash).unwrap();
304+ assert!(hint.contains(&format!("j 'push {}'", corrected)), "{}", hint);
305+ assert!(!hint.contains(&format!("j '{}'", corrected)), "{}", hint);
306+
307+ let ordinary = expression_hint(expression, &cfg, &crash).unwrap();
308+ assert!(ordinary.contains(&format!("j '{}'", corrected)), "{}", ordinary);
309+ assert!(!ordinary.contains("j 'push "), "{}", ordinary);
310+ }
311+ }
312+
313+ #[test]
314+ fn explains_implicit_repo_application_but_not_explicit_type_errors() {
315+ let cfg = reference();
316+ for expression in ["describe", "rebase", "split"] {
317+ let hint = expression_hint(expression, &cfg, &failure(&cfg, expression)).unwrap();
318+ assert!(hint.contains("before it can receive the repository"), "{}", hint);
319+ assert!(hint.contains(&format!("j help {}", expression)));
320+ }
321+ assert!(expression_hint("describe {}", &cfg, &failure(&cfg, "describe {}")).is_none());
322+ assert!(expression_hint("describe 42", &cfg, &failure(&cfg, "describe 42")).is_none());
323+ }
324+
325+ #[test]
326+ fn custom_signatures_drive_hints_after_partial_application() {
327+ let src = format!("{}\ncustom : Text -> Int -> Edit\ncustom = \\message count repo -> repo\n", include_str!("../config.j"));
328+ let cfg = load_config(&src).unwrap();
329+ let expression = "custom \"message\"";
330+ let hint = expression_hint(expression, &cfg, &failure(&cfg, expression)).unwrap();
331+ assert!(hint.contains("needs an argument of type Int"), "{}", hint);
332+ assert!(hint.contains("j help custom"));
333+ let expression = "custom \"message\" {}";
334+ assert!(expression_hint(expression, &cfg, &failure(&cfg, expression)).is_none());
335+ }
336+
337+ #[test]
338+ fn quote_examples_preserve_apostrophes_in_valid_identifiers() {
339+ let cfg = reference();
340+ let expression = "describe don't";
341+ let hint = expression_hint(expression, &cfg, &failure(&cfg, expression)).unwrap();
342+ assert!(hint.contains("j 'describe \"don'\\''t\"'"), "{}", hint);
343+ }
344+
345+ #[test]
346+ fn does_not_blame_the_cli_for_unbound_names_inside_definitions() {
347+ let cfg = reference();
348+ assert!(expression_hint("new", &cfg, &Crash::new("unbound name `neww`")).is_none());
349+ assert!(expression_hint("\\x -> x", &cfg, &Crash::new("unbound name `x`")).is_none());
350+ }
351+
352+ #[test]
353+ fn source_errors_name_the_file_and_preserve_line_numbers() {
354+ let output = format_source_error("/tmp/config.j", "first\nbroken =\n", 2, "unexpected newline");
355+ assert_eq!(output, "/tmp/config.j: line 2: unexpected newline\n 2 | broken =");
356+ assert_eq!(format_source_error("expression", "bad\n", 2, "unexpected end"), "expression: line 2: unexpected end\n 2 | ");
357+ assert_eq!(format_source_error("expression", "bad", 8, "unexpected end"), "expression: line 8: unexpected end");
358+ }
359+
360+ #[test]
361+ fn huge_unicode_source_lines_have_bounded_excerpts() {
362+ use unicode_width::UnicodeWidthStr;
363+
364+ for source in ["(".repeat(200_000), "界é🙂\u{301}".repeat(50_000), "\u{301}".repeat(200_000)] {
365+ let output = format_source_error("expression", &source, 1, "nested too deeply");
366+ let excerpt = output.split_once(" 1 | ").unwrap().1;
367+ assert!(excerpt.ends_with('…'));
368+ assert!(excerpt.width() <= 160, "excerpt exceeds display width budget");
369+ assert!(excerpt.chars().count() <= 160, "zero-width characters must also be bounded");
370+ assert!(output.len() < 700, "diagnostic must not repeat the entire huge line");
371+ }
372+ }
373+
374+ #[test]
375+ fn source_excerpts_escape_terminal_controls() {
376+ let output = format_source_error("expression", "\tfirst\u{1b}[31m\rsecond\u{7}", 1, "invalid input");
377+ let excerpt = output.split_once(" 1 | ").unwrap().1;
378+ assert_eq!(excerpt, "\\tfirst\\u{1b}[31m\\rsecond\\u{7}");
379+ assert!(!excerpt.chars().any(char::is_control));
380+ }
381+}
src/domain.rs+154−19
@@ -2,7 +2,7 @@
22//! (§7, §10). Two implementations: an in-memory backend for tests, and the
33//! jj-lib backend.
44
5−use crate::value::{BlobContent, BlobKind, BlobVal, Crash, Value};
5+use crate::value::{BlobContent, BlobKind, BlobVal, ConflictSide, Crash, Value};
66use std::collections::{BTreeMap, BTreeSet, HashMap};
77use std::rc::Rc;
88
@@ -29,6 +29,12 @@ pub struct ImmutabilityInfo {
2929}
3030
3131pub trait Backend {
32+ /// Refuse an edit that cannot be authored with the backend's current
33+ /// configuration. In-memory repositories have no authoring requirement.
34+ fn validate_authoring(&self) -> Result<(), Crash> {
35+ Ok(())
36+ }
37+
3238 /// All visible change ids (full), for resolving `@prefix` literals.
3339 fn visible_ids(&self) -> Vec<String>;
3440 /// All visible ids beginning with `prefix`.
@@ -42,19 +48,38 @@ pub trait Backend {
4248 fn meta(&self, id: &str) -> Result<MetaInfo, Crash>;
4349 /// True if the change id denotes a merge commit (2+ parents).
4450 fn is_merge(&self, id: &str) -> bool;
45− /// True if the commit's tree contains a conflict, if the backend can say
46− /// without reading files (jj: O(1)); None means "unknown, compare files".
51+ /// True if the stored commit's tree contains a conflict, if the backend
52+ /// can say without reading files (jj: O(1)); None means "unknown, compare
53+ /// files". It describes a commit value only while that value still holds
54+ /// the stored files (`ThunkVal::origin`), which is when the renderer asks.
4755 fn has_conflict(&self, _id: &str) -> Option<bool> {
4856 None
4957 }
50− /// True if the commit's tree equals its first parent's tree, if the
51− /// backend can say without reading files; None means "compare files".
52− fn is_empty(&self, _id: &str) -> Option<bool> {
58+ /// True if the stored commit `id`'s tree equals the stored commit
59+ /// `parent`'s, if the backend can say without reading files; None means
60+ /// "compare files". Asked, like `has_conflict`, only for values that still
61+ /// hold both stored trees.
62+ fn is_empty(&self, _id: &str, _parent: &str) -> Option<bool> {
5363 None
5464 }
55− /// All ancestors (inclusive) of the given change ids.
65+ /// All ancestors (inclusive) of the given change ids, through every
66+ /// parent of a merge, not only the first (§7.5 step 3).
5667 fn ancestors_closed(&self, ids: &BTreeSet<String>) -> BTreeSet<String>;
5768
69+ /// True if the working directory's filesystem folds case, so `.GIT`
70+ /// names `.git` and jj's checkout refuses it (§7.5 step 1).
71+ fn folds_case(&self) -> bool {
72+ false
73+ }
74+
75+ /// True if the working directory's filesystem holds a file named
76+ /// `name`, a path component longer than `repo::NAME_MAX` bytes that a
77+ /// checkout would create (§7.5 step 1). A filesystem that counts its
78+ /// limit in bytes, as ext4 does, holds none.
79+ fn name_fits(&self, name: &str) -> bool {
80+ name.len() <= crate::repo::NAME_MAX
81+ }
82+
5883 /// jj tree merge (§7.3): replay the change from->to onto `onto`.
5984 fn replay(
6085 &self,
@@ -63,6 +88,12 @@ pub trait Backend {
6388 to: &[Value],
6489 ) -> Result<Vec<Value>, Crash>;
6590
91+ /// Keep snapshot identity available to backends that can reuse stored
92+ /// trees. Backends without a tree cache retain their slice-based merge.
93+ fn replay_snapshot(&self, onto: &Value, from: &Value, to: &Value) -> Result<Value, Crash> {
94+ Ok(Value::list(self.replay(onto.as_list()?, from.as_list()?, to.as_list()?)?))
95+ }
96+
6697 /// shortest prefix of `id` unique among visible ids, min length 4
6798 fn unique_prefix(&self, id: &str) -> String {
6899 let others: Vec<String> = self.visible_ids();
@@ -107,13 +138,56 @@ pub fn snapshot_map(entries: &[Value]) -> Result<BTreeMap<Vec<String>, Value>, C
107138 .map_err(|_| Crash::new("replay: not a well-formed snapshot (path component not Text)"))
108139 })
109140 .collect::<Result<Vec<String>, Crash>>()?;
141+ // `./` is the root directory, which no entry can be
142+ if path.is_empty() {
143+ return Err(Crash::new(
144+ "replay: not a well-formed snapshot (an entry at the root path ./)",
145+ ));
146+ }
110147 if m.insert(path, content).is_some() {
111148 return Err(Crash::new("replay: not a well-formed snapshot (duplicate paths)"));
112149 }
113150 }
151+ if let Some(p) = file_and_directory(m.keys()) {
152+ return Err(Crash::new(format!(
153+ "replay: not a well-formed snapshot (`{}` is both a file and a directory)",
154+ p.join("/")
155+ )));
156+ }
114157 Ok(m)
115158}
116159
160+/// The first of `sorted` paths that is also a directory of another: a tree
161+/// holds `a` as a file or as a directory, never both (§7.3). A path's
162+/// descendants sort directly after it (`a` < `a/b` < `a.txt`), so comparing
163+/// each path with the next finds every clash.
164+pub fn file_and_directory<'a>(
165+ sorted: impl IntoIterator<Item = &'a Vec<String>>,
166+) -> Option<&'a [String]> {
167+ let mut prev: Option<&'a Vec<String>> = None;
168+ for p in sorted {
169+ if let Some(q) = prev {
170+ if p.len() > q.len() && p.starts_with(q) {
171+ return Some(q);
172+ }
173+ }
174+ prev = Some(p);
175+ }
176+ None
177+}
178+
179+/// The crash of a replay whose result would make `path` a file on one side
180+/// and a directory on another, naming the path: one entry cannot list the
181+/// directory's entries (§7.3). The jj backend merges such a path as one
182+/// value and the in-memory one path by path, so they need not refuse the
183+/// same merges (§10).
184+pub fn file_directory_clash(path: &[String]) -> Crash {
185+ Crash::new(format!(
186+ "replay: `{}` would be a file on one side and a directory on another",
187+ path.join("/")
188+ ))
189+}
190+
117191pub fn map_to_snapshot(m: BTreeMap<Vec<String>, Value>) -> Vec<Value> {
118192 m.into_iter()
119193 .map(|(path, content)| {
@@ -147,7 +221,7 @@ fn empty_blob() -> Value {
147221 }))
148222}
149223
150−fn conflict_blob(sides: Vec<Rc<Vec<u8>>>) -> Value {
224+fn conflict_blob(sides: Vec<Option<ConflictSide>>) -> Value {
151225 Value::Blob(Rc::new(BlobVal {
152226 kind: BlobKind::Regular,
153227 content: BlobContent::Conflict(sides),
@@ -202,28 +276,40 @@ pub fn simple_replay(
202276 } else if !t_present && !o_present {
203277 // both deleted
204278 } else {
205− // conflict: sides [to, from, onto] (add, remove, add)
206− let get_bytes = |v: &Value, present: bool| -> Result<Rc<Vec<u8>>, Crash> {
279+ // conflict: sides [to, from, onto] (add, remove, add),
280+ // each absent or with its own file type (§7.3)
281+ let side = |v: &Value, present: bool| -> Result<Option<ConflictSide>, Crash> {
207282 if !present {
208− return Ok(Rc::new(Vec::new()));
283+ return Ok(None);
209284 }
210285 match v {
211− Value::Blob(b) => Ok(Rc::new(b.bytes()?)),
212− _ => Ok(Rc::new(Vec::new())),
286+ Value::Blob(b) => Ok(Some(ConflictSide {
287+ kind: b.kind.clone(),
288+ bytes: Rc::new(b.bytes()?),
289+ tree: None,
290+ })),
291+ _ => Ok(Some(ConflictSide::regular(&[]))),
213292 }
214293 };
215294 out.insert(
216295 p,
217296 conflict_blob(vec![
218− get_bytes(&t, t_present)?,
219− get_bytes(&f, f_present)?,
220− get_bytes(&o, o_present)?,
297+ side(&t, t_present)?,
298+ side(&f, f_present)?,
299+ side(&o, o_present)?,
221300 ]),
222301 );
223302 }
224303 }
225304 }
226305 }
306+ // a file at a path and entries below it cannot be one snapshot. jj
307+ // keeps the path as one conflict with the directory as a side, and
308+ // refuses that unless one of the three held a conflict there; this
309+ // merge keeps no directory as a side, so it always refuses (§7.3, §10)
310+ if let Some(p) = file_and_directory(out.keys()) {
311+ return Err(file_directory_clash(p));
312+ }
227313 Ok(map_to_snapshot(out))
228314}
229315
@@ -237,17 +323,47 @@ pub struct MemBackend {
237323 pub metas: HashMap<String, MetaInfo>,
238324 /// id -> parent change ids (first parent first)
239325 pub parents: HashMap<String, Vec<String>>,
326+ /// Answer `has_conflict` and `is_empty` from the two maps below instead of
327+ /// returning None, the way the jj backend answers them from tree ids. A
328+ /// backend that answers them takes different paths through rendering and
329+ /// never forces a commit's file list, so tests that only ever used the
330+ /// None-answering default could not reach those paths at all.
331+ pub answers_tree_queries: bool,
332+ /// id -> the commit's tree holds a conflict
333+ pub conflicts: HashMap<String, bool>,
334+ /// id -> the commit's tree equals its first parent's
335+ pub empties: HashMap<String, bool>,
336+ /// what `folds_case` answers: a working directory on a case-folding
337+ /// filesystem, which no test machine need have
338+ pub folds_case: bool,
240339}
241340
242341impl MemBackend {
243342 pub fn new() -> Self {
343+ MemBackend::default()
344+ }
345+
346+ /// A backend that answers the tree queries, like the jj backend does.
347+ pub fn answering() -> Self {
244348 MemBackend {
245− metas: HashMap::new(),
246− parents: HashMap::new(),
349+ answers_tree_queries: true,
350+ ..MemBackend::default()
247351 }
248352 }
249353}
250354
355+/// A snapshot that is only materialised on first use, the way the jj backend
356+/// builds a commit's `files` (§7.2): tagged as the stored tree of commit `id`,
357+/// so a backend's answers about that commit apply while it holds this list.
358+/// The laziness must stay invisible to the language, which is exactly what is
359+/// easy to get wrong, so tests need to be able to build repos this way. The
360+/// tree has no name (`ThunkVal::tree`): nothing here makes `entries` a tree.
361+pub fn lazy_files(id: &str, entries: Vec<Value>) -> Value {
362+ Value::Thunk(Rc::new(crate::value::ThunkVal::stored(id.to_string(), None, move || {
363+ Ok(Value::list(entries))
364+ })))
365+}
366+
251367impl Backend for MemBackend {
252368 fn visible_ids(&self) -> Vec<String> {
253369 let mut ids: Vec<String> = self.metas.keys().cloned().collect();
@@ -258,11 +374,30 @@ impl Backend for MemBackend {
258374 self.metas
259375 .get(id)
260376 .cloned()
261− .ok_or_else(|| Crash::new(format!("meta: no stored commit for id")))
377+ .ok_or_else(|| Crash::new(format!("meta: no stored commit for `{}`", id)))
262378 }
263379 fn is_merge(&self, id: &str) -> bool {
264380 self.parents.get(id).map(|p| p.len() >= 2).unwrap_or(false)
265381 }
382+ fn has_conflict(&self, id: &str) -> Option<bool> {
383+ if !self.answers_tree_queries {
384+ return None;
385+ }
386+ Some(self.conflicts.get(id).copied().unwrap_or(false))
387+ }
388+ fn is_empty(&self, id: &str, parent: &str) -> Option<bool> {
389+ if !self.answers_tree_queries {
390+ return None;
391+ }
392+ // `empties` records each commit against its first parent only
393+ if self.parents.get(id)?.first()? != parent {
394+ return None;
395+ }
396+ Some(self.empties.get(id).copied().unwrap_or(false))
397+ }
398+ fn folds_case(&self) -> bool {
399+ self.folds_case
400+ }
266401 fn ancestors_closed(&self, ids: &BTreeSet<String>) -> BTreeSet<String> {
267402 let mut seen = BTreeSet::new();
268403 let mut stack: Vec<String> = ids.iter().cloned().collect();
src/eval.rs+845−304
@@ -4,7 +4,7 @@
44use crate::ast::{Expr, Pattern};
55use crate::domain::Backend;
66use crate::shape::{check as contract_check, Contract, Shapes};
7−use crate::value::{Crash, Env, FunVal, Value};
7+use crate::value::{Crash, Env, FunVal, ListVal, Value};
88use std::cell::RefCell;
99use std::collections::HashMap;
1010use std::rc::Rc;
@@ -20,8 +20,76 @@ pub struct Interp {
2020 fresh: RefCell<u64>,
2121 /// the currently executing top-level definition (for crash reports)
2222 pub current_def: RefCell<Option<String>>,
23+ /// The definitions written as lambdas, by the address of the lambda's
24+ /// body, which every closure made from it shares: while that body runs,
25+ /// the definition is executing (§1.4), whatever name its closure has
26+ /// been bound to since (`prev = goto parents` runs goto's body). The body
27+ /// is held so its address cannot be reused.
28+ def_bodies: HashMap<*const Expr, (Rc<str>, Rc<Expr>)>,
2329 /// the repository as loaded, before snapshotting (for `validate`)
2430 pub old_repo: RefCell<Option<Value>>,
31+ /// the repository the expression was given (§1.2 step 4, after the
32+ /// snapshot), where displaying a `Commit` reads where it stands (§5.1);
33+ /// `old_repo` when unset
34+ pub given_repo: RefCell<Option<Value>>,
35+ /// Results of applying a config revset the binary itself calls (`trunk`,
36+ /// `immutable`), keyed by the identity of the Repo record they were
37+ /// applied to. Both are pure and both are asked for more than once per
38+ /// run — `immutable` by the focus check and again by rendering or
39+ /// persisting — and each evaluation is a full interpreted walk of the
40+ /// history. The `Rc` is kept so the address cannot be reused while it is
41+ /// a key (§4.1: definitions are pure, so the result cannot change).
42+ revsets: RefCell<Vec<(&'static str, Rc<crate::value::RecordMap>, Value)>>,
43+ /// visible ids in sorted order, built on first use. The backend's
44+ /// `visible_ids` allocates every id afresh, and a shortest-unique-prefix
45+ /// needs the whole set, so computing one per displayed commit was
46+ /// quadratic in both allocations and comparisons.
47+ sorted_ids: RefCell<Option<Rc<Vec<String>>>>,
48+}
49+
50+impl Drop for Interp {
51+ fn drop(&mut self) {
52+ // These are the interpreter's language-value roots. Release them
53+ // before collecting, so config definitions and cached closures do
54+ // not keep their recursive environments alive between invocations.
55+ self.globals = Env::empty();
56+ self.old_repo.get_mut().take();
57+ self.given_repo.get_mut().take();
58+ self.revsets.get_mut().clear();
59+ crate::value::collect_cycles();
60+ }
61+}
62+
63+/// length of the common leading run of two ids (ids are ASCII, so bytes)
64+fn common_prefix_len(a: &str, b: &str) -> usize {
65+ a.bytes()
66+ .zip(b.bytes())
67+ .take_while(|(x, y)| x == y)
68+ .count()
69+}
70+
71+/// The shortest prefix of `id` that no other id in `sorted` shares, at least
72+/// four characters long. Only the neighbours of `id` in sorted order can share
73+/// its longest prefix, so two lookups settle it.
74+pub fn unique_prefix_in(sorted: &[String], id: &str) -> String {
75+ let pos = sorted.partition_point(|o| o.as_str() < id);
76+ let mut shared = 0usize;
77+ if pos > 0 {
78+ shared = shared.max(common_prefix_len(&sorted[pos - 1], id));
79+ }
80+ let mut next = pos;
81+ while next < sorted.len() && sorted[next] == id {
82+ next += 1;
83+ }
84+ if next < sorted.len() {
85+ shared = shared.max(common_prefix_len(&sorted[next], id));
86+ }
87+ let n = 4.max(shared + 1);
88+ if n >= id.len() {
89+ id.to_string()
90+ } else {
91+ id[..n].to_string()
92+ }
2593}
2694
2795/// machine state
@@ -39,26 +107,28 @@ pub(crate) enum Cont {
39107 Arg(Rc<Expr>, Env, Rc<Cont>),
40108 Fun(Value, Rc<Cont>),
41109 If(Rc<Expr>, Rc<Expr>, Env, Rc<Cont>),
110+ /// A block's bindings in evaluation order. Only recursive blocks need a
111+ /// cell; acyclic ones extend the environment after each value is ready.
42112 LetRest {
43− names: Vec<String>,
44− exprs: Vec<Rc<Expr>>,
45− acc: Vec<(String, Value)>,
46− cell: Rc<RefCell<Vec<(String, Value)>>>,
113+ bindings: Rc<[(String, Rc<Expr>)]>,
114+ at: usize,
115+ cell: Option<Rc<RefCell<Vec<Value>>>>,
47116 body: Rc<Expr>,
48117 env: Env,
49118 cont: Rc<Cont>,
50119 },
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>),
120+ /// `&&` or `||` after applying its first operand normally, so the
121+ /// declared contract has checked it; the rhs is still unevaluated
122+ ShortCircuit {
123+ op: &'static str,
124+ rhs: Rc<Expr>,
125+ env: Env,
126+ cont: Rc<Cont>,
127+ },
56128 /// `or` after both evaluated as functions: build the lifted function
57129 OrBoth(Value, Rc<Cont>),
58130 /// a shared continuation (used when both `or` outcomes continue the same way)
59131 Shared(Rc<Cont>),
60− /// unconditionally crash with a fixed message (deferred-body failure)
61− CrashWith(String),
62132 /// run the continuation's computation, catching crashes: on success pass
63133 /// the value to `on_ok`, on crash switch to `on_crash` (§4.6)
64134 Try {
@@ -66,27 +136,29 @@ pub(crate) enum Cont {
66136 snapshot: u64,
67137 on_crash: Box<State>,
68138 },
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>),
139+ /// apply the returned function to this argument: the `labelled "name"`
140+ /// of a Labelled to the real arg
141+ ApplyTo(Value, Rc<Cont>),
142+ // Literal/update inputs and accumulators are shared across continuation
143+ // clones. The trampoline keeps an active clone for crash unwinding; deep
144+ // copying these buffers at every element made flat literals quadratic.
145+ UpdateBase(Rc<[(String, Rc<Expr>)]>, Env, Rc<Cont>),
72146 UpdateFields {
73147 base: Value,
74− names: Vec<String>,
75− exprs: Vec<Rc<Expr>>,
76− acc: Vec<(String, Value)>,
148+ fields: Rc<[(String, Rc<Expr>)]>,
149+ acc: Rc<RefCell<Vec<(String, Value)>>>,
77150 env: Env,
78151 cont: Rc<Cont>,
79152 },
80153 RecordFields {
81− names: Vec<String>,
82− exprs: Vec<Rc<Expr>>,
83− acc: Vec<(String, Value)>,
154+ fields: Rc<[(String, Rc<Expr>)]>,
155+ acc: Rc<RefCell<Vec<(String, Value)>>>,
84156 env: Env,
85157 cont: Rc<Cont>,
86158 },
87159 ListElems {
88− exprs: Vec<Rc<Expr>>,
89− acc: Vec<Value>,
160+ exprs: Rc<[Rc<Expr>]>,
161+ acc: Rc<RefCell<Vec<Value>>>,
90162 env: Env,
91163 cont: Rc<Cont>,
92164 },
@@ -96,6 +168,16 @@ pub(crate) enum Cont {
96168 cexpr: Rc<crate::shape::ContractExpr>,
97169 cont: Rc<Cont>,
98170 },
171+ /// the function a signed definition's value returned when it ran out of
172+ /// parameters before its signature (a lambda's body, or a builtin or a
173+ /// composition given its last argument): make it the named partial
174+ /// application of `args` (§5.2), checked against the rest of `cexpr`
175+ NamePartial {
176+ name: String,
177+ cexpr: Rc<crate::shape::ContractExpr>,
178+ args: Vec<Value>,
179+ cont: Rc<Cont>,
180+ },
99181 /// after applying, check the (partial) result against the contract
100182 #[allow(dead_code)]
101183 CheckResult {
@@ -104,6 +186,57 @@ pub(crate) enum Cont {
104186 supplied: usize,
105187 cont: Rc<Cont>,
106188 },
189+ /// the body of a definition's lambda is running (§1.4); passes its value
190+ /// on unchanged
191+ InDef {
192+ name: Rc<str>,
193+ cont: Rc<Cont>,
194+ },
195+ // The builtins that apply functions they are given run as steps of this
196+ // machine, not in a nested run, so a recursion through them takes no
197+ // native stack (§4.1; Interp::step_applying). Each frame below receives
198+ // one value, so `acc` is pushed to once per element and shared, not
199+ // copied, when the trampoline clones the continuation.
200+ /// `map f xs`, receiving `f` of the element at `idx`
201+ MapNext {
202+ f: Value,
203+ xs: ListVal,
204+ idx: usize,
205+ acc: Rc<RefCell<Vec<Value>>>,
206+ cont: Rc<Cont>,
207+ },
208+ /// `filter p xs`, receiving `p` of the element at `idx`
209+ FilterNext {
210+ p: Value,
211+ xs: ListVal,
212+ idx: usize,
213+ acc: Rc<RefCell<Vec<Value>>>,
214+ cont: Rc<Cont>,
215+ },
216+ /// `foldl f z xs`, receiving `f` applied to the accumulator, to be
217+ /// applied to the element at `idx`
218+ FoldlArg {
219+ f: Value,
220+ xs: ListVal,
221+ idx: usize,
222+ cont: Rc<Cont>,
223+ },
224+ /// `foldl f z xs`, receiving the accumulator after the element at `idx`
225+ FoldlAcc {
226+ f: Value,
227+ xs: ListVal,
228+ idx: usize,
229+ cont: Rc<Cont>,
230+ },
231+ /// the result of such a builtin, checked against its contract once the
232+ /// functions it applies have run (§4.13); `def` is the signed definition
233+ /// the builtin is the value of, which a crash under this frame is in (§1.4)
234+ BuiltinResult {
235+ cname: String,
236+ check: Option<Rc<crate::shape::ContractExpr>>,
237+ def: Option<String>,
238+ cont: Rc<Cont>,
239+ },
107240}
108241
109242pub type EResult = Result<Value, Crash>;
@@ -118,12 +251,13 @@ pub(crate) enum TryOk {
118251 env: Env,
119252 cont: Rc<Cont>,
120253 },
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> },
254+ /// the lhs of a lifted `or` applied to an argument: like `OrLhs`, with
255+ /// the rhs function still to be applied to the same argument
256+ OrFunLhs {
257+ rhs: Value,
258+ arg: Value,
259+ cont: Rc<Cont>,
260+ },
127261}
128262
129263pub(crate) enum Run {
@@ -135,23 +269,28 @@ pub(crate) enum Run {
135269/// the parent continuation Rc of `k`, for iterative drop and unwinding
136270fn parent_rc(k: &Cont) -> Option<&Rc<Cont>> {
137271 match k {
138− Cont::Halt | Cont::CrashWith(_) | Cont::Try { .. } => None,
272+ Cont::Halt | Cont::Try { .. } => None,
139273 Cont::Arg(_, _, k) => Some(k),
140274 Cont::Fun(_, k) => Some(k),
141275 Cont::If(_, _, _, k) => Some(k),
142276 Cont::LetRest { cont, .. } => Some(cont),
143− Cont::And(_, _, k) => Some(k),
144− Cont::Or2(_, _, k) => Some(k),
145− Cont::ExpectBool(_, k) => Some(k),
277+ Cont::ShortCircuit { cont, .. } => Some(cont),
146278 Cont::OrBoth(_, k) => Some(k),
147279 Cont::Shared(k) => Some(k),
148− Cont::LabelledArg(_, k) => Some(k),
280+ Cont::ApplyTo(_, k) => Some(k),
149281 Cont::UpdateBase(_, _, k) => Some(k),
150282 Cont::UpdateFields { cont, .. } => Some(cont),
151283 Cont::RecordFields { cont, .. } => Some(cont),
152284 Cont::ListElems { cont, .. } => Some(cont),
153285 Cont::CheckExpr { cont, .. } => Some(cont),
286+ Cont::NamePartial { cont, .. } => Some(cont),
154287 Cont::CheckResult { cont, .. } => Some(cont),
288+ Cont::InDef { cont, .. } => Some(cont),
289+ Cont::MapNext { cont, .. } => Some(cont),
290+ Cont::FilterNext { cont, .. } => Some(cont),
291+ Cont::FoldlArg { cont, .. } => Some(cont),
292+ Cont::FoldlAcc { cont, .. } => Some(cont),
293+ Cont::BuiltinResult { cont, .. } => Some(cont),
155294 }
156295}
157296
@@ -160,6 +299,99 @@ pub(crate) fn pop_cont(k: &Cont) -> Option<Cont> {
160299 parent_rc(k).map(|r| (**r).clone())
161300}
162301
302+/// Drop `k`, a continuation a crash abandons, one frame at a time. A frame
303+/// drops its parent with it, so dropping a chain as long as the recursion a
304+/// crash ended took native stack per frame, and a crash at the bottom of a
305+/// deep recursion aborted the process instead of being reported or caught
306+/// (§4.1, §4.7). The walk stops at a frame another chain still holds.
307+fn drop_chain(k: Cont) {
308+ let mut parent = parent_rc(&k).cloned();
309+ drop(k);
310+ while let Some(rc) = parent {
311+ parent = match Rc::try_unwrap(rc) {
312+ Ok(frame) => parent_rc(&frame).cloned(),
313+ Err(_) => None,
314+ };
315+ }
316+}
317+
318+/// The innermost definition whose application `k` continues (§1.4): a
319+/// signed definition's application runs under the `CheckExpr` of its result,
320+/// the body of a definition's lambda under an `InDef` naming it unless that
321+/// `CheckExpr` names it already, and the functions a builtin applies as a
322+/// signed definition's value under a `BuiltinResult` naming it; each stays on
323+/// the chain until the body has returned. Only used on a crash no `Try`
324+/// caught, so there is no `Try` on the chain to stop the walk early.
325+fn innermost_def(k: &Cont) -> Option<String> {
326+ let mut k = k;
327+ loop {
328+ match k {
329+ Cont::CheckExpr { name, .. }
330+ | Cont::BuiltinResult {
331+ def: Some(name), ..
332+ } => return Some(name.clone()),
333+ Cont::InDef { name, .. } => return Some(name.to_string()),
334+ _ => {}
335+ }
336+ k = parent_rc(k)?;
337+ }
338+}
339+
340+/// True when `k` starts with the frames a body of the signed definition
341+/// `name` pushes when `cexpr` is what remains of its signature: the check of
342+/// its value and, while `cexpr` lists further arguments, the naming of that
343+/// value as the definition's partial application. A body applied under them
344+/// is a call in tail position of the same definition (`shortS (n - 1)` in
345+/// `shortS`'s body), and its own frames would check the same value against
346+/// the same contract under the same name once more and name it afresh, only
347+/// for the outer naming to replace that name (NamePartial). The outer frames
348+/// serve for both, so a signed tail recursion keeps its continuation
349+/// constant in size, as an unsigned one does (§4.1, §4.13).
350+fn checks_already(k: &Cont, name: &str, cexpr: &crate::shape::ContractExpr) -> bool {
351+ match k {
352+ Cont::CheckExpr { name: n, cexpr: c, cont } if n == name && c.same_as(cexpr) => {
353+ cexpr.is_exhausted()
354+ || matches!(&**cont, Cont::NamePartial { name: m, cexpr: d, .. }
355+ if m == name && d.same_as(cexpr))
356+ }
357+ _ => false,
358+ }
359+}
360+
361+/// the builtins that apply the functions they are given; they run as steps
362+/// of the machine (Interp::step_applying)
363+fn applies_functions(builtin: &str) -> bool {
364+ matches!(builtin, "map" | "filter" | "foldl" | "(.)")
365+}
366+
367+/// a builtin's function argument
368+fn function_arg(v: &Value) -> Result<Value, Crash> {
369+ match v {
370+ Value::Fun(_) => Ok(v.clone()),
371+ v => Err(Crash::new(format!(
372+ "expected a function, got a {}",
373+ v.kind_name()
374+ ))),
375+ }
376+}
377+
378+/// a builtin's list argument, sharing its elements
379+fn list_arg(v: &Value) -> Result<ListVal, Crash> {
380+ match v {
381+ Value::List(xs) => Ok(xs.clone()),
382+ v => Err(Crash::new(format!("expected a list, got a {}", v.kind_name()))),
383+ }
384+}
385+
386+/// `c`, raised while applying the definition `def`, is in it unless
387+/// something nearer, a nested run, already named one (§1.4)
388+fn in_def(mut c: Crash, def: &str) -> Crash {
389+ if c.def.is_none() {
390+ c.def = Some(def.to_string());
391+ }
392+ c
393+}
394+
163395impl Interp {
164396 pub fn new(backend: Rc<dyn Backend>, shapes: Shapes, globals: Env) -> Interp {
165397 Interp {
@@ -169,10 +401,56 @@ impl Interp {
169401 contracts: HashMap::new(),
170402 fresh: RefCell::new(0),
171403 current_def: RefCell::new(None),
404+ def_bodies: HashMap::new(),
172405 old_repo: RefCell::new(None),
406+ given_repo: RefCell::new(None),
407+ revsets: RefCell::new(Vec::new()),
408+ sorted_ids: RefCell::new(None),
173409 }
174410 }
175411
412+ /// Shortest prefix of `id` unique among the visible ids, minimum four
413+ /// (§5.1). The sorted id list is built once per run.
414+ pub fn unique_prefix(&self, id: &str) -> String {
415+ let cached = self.sorted_ids.borrow().clone();
416+ let sorted = match cached {
417+ Some(s) => s,
418+ None => {
419+ let mut ids = self.backend.visible_ids();
420+ ids.sort();
421+ let rc = Rc::new(ids);
422+ *self.sorted_ids.borrow_mut() = Some(rc.clone());
423+ rc
424+ }
425+ };
426+ unique_prefix_in(&sorted, id)
427+ }
428+
429+ /// Apply one of the config revsets the binary calls itself, reusing the
430+ /// result when the same Repo value has already been asked (§7.5).
431+ pub fn apply_cached_revset(&mut self, name: &'static str, repo: &Value) -> EResult {
432+ let key = match repo {
433+ Value::Record(m) => Some(m.clone()),
434+ _ => None,
435+ };
436+ if let Some(k) = &key {
437+ for (n, r, v) in self.revsets.borrow().iter() {
438+ if *n == name && Rc::ptr_eq(r, k) {
439+ return Ok(v.clone());
440+ }
441+ }
442+ }
443+ let f = self
444+ .globals
445+ .lookup(name)
446+ .ok_or_else(|| Crash::new(format!("`{}` is not defined", name)))?;
447+ let v = self.apply(f, repo.clone())?;
448+ if let Some(k) = key {
449+ self.revsets.borrow_mut().push((name, k, v.clone()));
450+ }
451+ Ok(v)
452+ }
453+
176454 /// An interpreter shell used only for recursive rendering; panics if any
177455 /// backend interaction is attempted.
178456 pub fn dummy() -> Interp {
@@ -183,7 +461,11 @@ impl Interp {
183461 contracts: HashMap::new(),
184462 fresh: RefCell::new(0),
185463 current_def: RefCell::new(None),
464+ def_bodies: HashMap::new(),
186465 old_repo: RefCell::new(None),
466+ given_repo: RefCell::new(None),
467+ revsets: RefCell::new(Vec::new()),
468+ sorted_ids: RefCell::new(None),
187469 }
188470 }
189471
@@ -214,6 +496,26 @@ impl Interp {
214496 self.globals.clone()
215497 }
216498
499+ /// Record `expr`, the expression of the definition `name`: the body of
500+ /// the lambda it is written as runs as `name`'s (§1.4), and so does the
501+ /// body of each lambda that body is in turn (`\a -> \b -> e`). A lambda
502+ /// in parentheses or ending a `let` is the definition's lambda too.
503+ pub fn name_bodies(&mut self, name: &str, expr: &Rc<Expr>) {
504+ let name: Rc<str> = Rc::from(name);
505+ let mut e = expr;
506+ loop {
507+ match &**e {
508+ Expr::Paren(inner) | Expr::Let(_, inner) => e = inner,
509+ Expr::Lambda(_, body, _) => {
510+ self.def_bodies
511+ .insert(Rc::as_ptr(body), (name.clone(), body.clone()));
512+ e = body;
513+ }
514+ _ => break,
515+ }
516+ }
517+ }
518+
217519 /// Main entry: evaluate an expression in an environment.
218520 pub fn eval(&mut self, e: &Rc<Expr>, env: &Env) -> EResult {
219521 self.run(State::Eval(e.clone(), env.clone(), Cont::Halt))
@@ -230,11 +532,105 @@ impl Interp {
230532 c
231533 }
232534
535+ /// `c`, raised while applying the builtin `builtin` under the contract
536+ /// named `cname`, is in that definition when the builtin is a signed
537+ /// definition's value (`squash = abandon . …`). A builtin's own name, or
538+ /// a composition's made from its operands', names no definition, and the
539+ /// crash is in whichever definition applied it (§1.4).
540+ fn blame_builtin(&self, c: Crash, builtin: &str, cname: &str) -> Crash {
541+ match self.builtin_def(builtin, cname) {
542+ Some(def) => in_def(c, &def),
543+ None => c,
544+ }
545+ }
546+
547+ /// the definition a crash while applying `builtin` under the contract
548+ /// named `cname` is in, as blame_builtin decides, and which the builtin
549+ /// renders as (§5.2)
550+ pub(crate) fn builtin_def(&self, builtin: &str, cname: &str) -> Option<String> {
551+ if cname != builtin && self.contracts.contains_key(cname) {
552+ Some(cname.to_string())
553+ } else {
554+ None
555+ }
556+ }
557+
558+ /// `map`, `filter`, `foldl` or a composition node given all its
559+ /// arguments, continuing in `k`. They apply the functions they are given,
560+ /// so they run as steps of this machine: applying them in a nested run
561+ /// put every level of a recursion through one of them on the native
562+ /// stack, which overflowed and aborted where plain recursion runs (§4.1).
563+ /// Evaluation stays left to right, element by element (§4).
564+ fn step_applying(&mut self, builtin: &str, args: &[Value], k: Cont) -> Result<Run, Crash> {
565+ match builtin {
566+ // (f . g) x = f (g x) (§4.9), whatever x is: a function argument
567+ // is applied like any other, and g's contract checks it (§4.13).
568+ // `squash = abandon . contract everything` waits for the
569+ // repository because it is this node, not because anything
570+ // composes on a function argument.
571+ "(.)" => Ok(Run::Step(State::Apply(
572+ args[1].clone(),
573+ args[2].clone(),
574+ Cont::Fun(args[0].clone(), Rc::new(k)),
575+ ))),
576+ "map" | "filter" => {
577+ let f = function_arg(&args[0])?;
578+ let xs = list_arg(&args[1])?;
579+ if xs.is_empty() {
580+ return Ok(Run::Step(State::Ret(Value::list(vec![]), k)));
581+ }
582+ let x = xs[0].clone();
583+ let acc = Rc::new(RefCell::new(Vec::new()));
584+ let cont = Rc::new(k);
585+ let next = if builtin == "map" {
586+ Cont::MapNext {
587+ f: f.clone(),
588+ xs,
589+ idx: 0,
590+ acc,
591+ cont,
592+ }
593+ } else {
594+ Cont::FilterNext {
595+ p: f.clone(),
596+ xs,
597+ idx: 0,
598+ acc,
599+ cont,
600+ }
601+ };
602+ Ok(Run::Step(State::Apply(f, x, next)))
603+ }
604+ "foldl" => {
605+ let f = function_arg(&args[0])?;
606+ let xs = list_arg(&args[2])?;
607+ if xs.is_empty() {
608+ return Ok(Run::Step(State::Ret(args[1].clone(), k)));
609+ }
610+ let next = Cont::FoldlArg {
611+ f: f.clone(),
612+ xs,
613+ idx: 0,
614+ cont: Rc::new(k),
615+ };
616+ Ok(Run::Step(State::Apply(f, args[1].clone(), next)))
617+ }
618+ _ => Err(Crash::new(format!(
619+ "internal: `{}` does not apply functions",
620+ builtin
621+ ))),
622+ }
623+ }
624+
233625 fn run(&mut self, st: State) -> EResult {
234− match self.trampoline(st) {
626+ let result = match self.trampoline(st) {
235627 Ok(v) => Ok(v),
236628 Err(c) => Err(self.decorate(c)),
237− }
629+ };
630+ // Keep a returned closure alive as a root while reclaiming local
631+ // recursive environments abandoned by the completed evaluation.
632+ crate::value::collect_cycles();
633+ result
238634 }
239635
240636 fn trampoline(&mut self, st: State) -> EResult {
@@ -261,26 +657,18 @@ impl Interp {
261657 Run::Step(next) => st = next,
262658 Run::Done(v) => return Ok(v),
263659 Run::Crash(c) => {
264− // unwind to the nearest Try continuation, restoring the
660+ // Unwind to the nearest Try continuation, restoring the
265661 // 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).
662+ // A Try cannot catch a crash raised by its own handler:
663+ // every `on_crash` state continues through `Cont::Shared`
664+ // holding the continuation *outside* the Try, so the Try
665+ // is no longer on the chain once the handler runs.
270666 let mut k = Some(active.clone());
271− let mut visited: Vec<*const Cont> = Vec::new();
272667 let caught = loop {
273668 match k {
274669 Some(Cont::Try {
275670 snapshot, on_crash, ..
276671 }) => {
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− }
284672 *self.fresh.borrow_mut() = snapshot;
285673 break Some(*on_crash);
286674 }
@@ -288,9 +676,20 @@ impl Interp {
288676 None => break None,
289677 }
290678 };
679+ // the frames up to the Try, or all of them, are abandoned
291680 match caught {
292− Some(next) => st = next,
293− None => return Err(c),
681+ Some(next) => {
682+ drop_chain(active);
683+ st = next
684+ }
685+ None => {
686+ let mut c = c;
687+ if c.def.is_none() {
688+ c.def = innermost_def(&active);
689+ }
690+ drop_chain(active);
691+ return Err(c);
692+ }
294693 }
295694 }
296695 }
@@ -341,7 +740,6 @@ impl Interp {
341740 params: params.clone(),
342741 applied: 0,
343742 applied_args: Vec::new(),
344− deferred: false,
345743 body: body.clone(),
346744 env,
347745 src: src.clone(),
@@ -358,18 +756,31 @@ impl Interp {
358756 if bs.is_empty() {
359757 return Run::Step(State::Eval(body.clone(), env, k));
360758 }
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();
759+ // the bindings are evaluated in dependency order, not source
760+ // order, and a cycle among them is a crash (§4.1)
761+ let plan = match crate::config::let_plan(bs) {
762+ Some(plan) => plan,
763+ None => return Run::Crash(Crash::new("let: a cycle among the bindings")),
764+ };
765+ let bindings: Rc<[(String, Rc<Expr>)]> =
766+ plan.order.iter().map(|&i| bs[i].clone()).collect();
767+ // A closure in a recursive block sees later bindings through
768+ // the shared cell (§4.1). Acyclic bindings need only earlier
769+ // values: immutable frames avoid a cell -> closure -> frame
770+ // cycle for ordinary local helper functions.
771+ let (env2, cell) = if plan.recursive {
772+ let names = bindings.iter().map(|(n, _)| n.clone()).collect();
773+ let (env, cell) = env.extend_rec(names);
774+ (env, Some(cell))
775+ } else {
776+ (env, None)
777+ };
366778 Run::Step(State::Eval(
367− exprs[0].clone(),
779+ bindings[0].1.clone(),
368780 env2.clone(),
369781 Cont::LetRest {
370− names,
371− exprs,
372− acc: Vec::new(),
782+ bindings,
783+ at: 0,
373784 cell,
374785 body: body.clone(),
375786 env: env2,
@@ -383,29 +794,26 @@ impl Interp {
383794 Cont::Arg(x.clone(), env, Rc::new(k)),
384795 )),
385796 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− }
400797 let fv = match env.lookup(op) {
401798 Some(v) => v,
402799 None => return Run::Crash(Crash::new(format!("unbound name `{}`", op))),
403800 };
404− // eval a, apply the operator to it, then apply the result to b
801+ let next = if *op == "&&" || *op == "||" {
802+ Cont::ShortCircuit {
803+ op,
804+ rhs: b.clone(),
805+ env: env.clone(),
806+ cont: Rc::new(k),
807+ }
808+ } else {
809+ Cont::Arg(b.clone(), env.clone(), Rc::new(k))
810+ };
811+ // Every operator must be declared, and its first operand
812+ // is checked by ordinary application, including && and ||.
405813 Run::Step(State::Eval(
406814 a.clone(),
407− env.clone(),
408− Cont::Fun(fv, Rc::new(Cont::Arg(b.clone(), env, Rc::new(k)))),
815+ env,
816+ Cont::Fun(fv, Rc::new(next)),
409817 ))
410818 }
411819 Expr::Or(a, b) => {
@@ -434,22 +842,19 @@ impl Interp {
434842 Expr::Update(base, fields) => Run::Step(State::Eval(
435843 base.clone(),
436844 env.clone(),
437− Cont::UpdateBase(fields.clone(), env, Rc::new(k)),
845+ Cont::UpdateBase(fields.clone().into(), env, Rc::new(k)),
438846 )),
439847 Expr::Record(fields) => {
440848 if fields.is_empty() {
441849 let m = crate::value::RecordMap::new();
442850 return Run::Step(State::Ret(Value::Record(Rc::new(m)), k));
443851 }
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();
446852 Run::Step(State::Eval(
447− exprs[0].clone(),
853+ fields[0].1.clone(),
448854 env.clone(),
449855 Cont::RecordFields {
450− names,
451− exprs,
452− acc: Vec::new(),
856+ fields: fields.clone().into(),
857+ acc: Rc::new(RefCell::new(Vec::with_capacity(fields.len()))),
453858 env,
454859 cont: Rc::new(k),
455860 },
@@ -463,8 +868,8 @@ impl Interp {
463868 elems[0].clone(),
464869 env.clone(),
465870 Cont::ListElems {
466− exprs: elems.clone(),
467− acc: Vec::new(),
871+ exprs: elems.clone().into(),
872+ acc: Rc::new(RefCell::new(Vec::with_capacity(elems.len()))),
468873 env,
469874 cont: Rc::new(k),
470875 },
@@ -490,29 +895,31 @@ impl Interp {
490895 ))),
491896 },
492897 Cont::LetRest {
493− names,
494− exprs,
495− mut acc,
898+ bindings,
899+ at,
496900 cell,
497901 body,
498902 env,
499903 cont,
500904 } => {
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() {
905+ let env = match &cell {
906+ Some(cell) => {
907+ cell.borrow_mut().push(v);
908+ env
909+ }
910+ None => env.extend(vec![(bindings[at].0.clone(), v)]),
911+ };
912+ let at = at + 1;
913+ if at == bindings.len() {
506914 Run::Step(State::Eval(body, env, (*cont).clone()))
507915 } else {
508− let next = exprs[acc.len()].clone();
916+ let next = bindings[at].1.clone();
509917 Run::Step(State::Eval(
510918 next,
511919 env.clone(),
512920 Cont::LetRest {
513− names,
514− exprs,
515− acc,
921+ bindings,
922+ at,
516923 cell,
517924 body,
518925 env,
@@ -521,24 +928,42 @@ impl Interp {
521928 ))
522929 }
523930 }
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− },
931+ Cont::ShortCircuit { op, rhs, env, cont } => {
932+ // Operator names are reserved: resolving && or || and
933+ // applying one operand always gives its builtin partial.
934+ let (lhs, pending) = match &v {
935+ Value::Fun(f) => match &**f {
936+ FunVal::Builtin { args, pending, .. } if args.len() == 1 => {
937+ (&args[0], pending)
938+ }
939+ _ => return Run::Crash(Crash::new("internal: invalid boolean operator")),
940+ },
941+ _ => return Run::Crash(Crash::new("internal: invalid boolean operator")),
942+ };
943+ let lhs = match lhs {
944+ Value::Bool(b) => *b,
945+ other => return Run::Crash(Crash::new(format!(
946+ "{}: expected Bool, got a {}", op, other.kind_name()
947+ ))),
948+ };
949+ if lhs == (op == "||") {
950+ let result = Value::Bool(lhs);
951+ if let Some((name, c, _)) = pending {
952+ // Skipping an operand must not evaluate or check
953+ // it, but the operator still returns a checked
954+ // result after its two parameter positions (§4.13).
955+ let c = crate::shape::ContractExpr::apply_first_rc(c.clone());
956+ if let Err(cr) = c.check_result(&self.shapes, name, &result) {
957+ return Run::Crash(self.blame_builtin(cr, op, name));
958+ }
959+ }
960+ Run::Step(State::Ret(result, (*cont).clone()))
961+ } else {
962+ // The normal application path checks the rhs and the
963+ // builtin's result, with the same argument numbering.
964+ Run::Step(State::Eval(rhs, env, Cont::Fun(v, cont)))
965+ }
966+ }
542967 Cont::OrBoth(a, k) => {
543968 if matches!(v, Value::Fun(_)) {
544969 Run::Step(State::Ret(Value::Fun(Rc::new(FunVal::OrFun(a, v))), (*k).clone()))
@@ -569,16 +994,24 @@ impl Interp {
569994 Run::Step(State::Ret(v, Cont::Shared(cont)))
570995 }
571996 }
572− Some(TryOk::DeferredBody { arg, cont, .. }) => {
573− Run::Step(State::Apply(v, arg, (*cont).clone()))
997+ Some(TryOk::OrFunLhs { rhs, arg, cont }) => {
998+ // `f x or g x` (§4.6): when `f x` is itself a
999+ // function, apply `g` too and lift again, so the
1000+ // fallback keeps guarding the later arguments
1001+ if matches!(v, Value::Fun(_)) {
1002+ Run::Step(State::Apply(
1003+ rhs,
1004+ arg,
1005+ Cont::OrBoth(v, Rc::new(Cont::Shared(cont))),
1006+ ))
1007+ } else {
1008+ Run::Step(State::Ret(v, (*cont).clone()))
1009+ }
5741010 }
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())),
5771011 None => Run::Crash(Crash::new("internal: Try with no success handler")),
5781012 }
5791013 }
580− Cont::LabelledArg(real, k) => Run::Step(State::Apply(v, real, (*k).clone())),
581− Cont::CrashWith(msg) => Run::Crash(Crash::new(msg)),
1014+ Cont::ApplyTo(real, k) => Run::Step(State::Apply(v, real, (*k).clone())),
5821015 Cont::UpdateBase(fields, env, k) => {
5831016 if !matches!(v, Value::Record(_)) {
5841017 return Run::Crash(Crash::new(format!(
@@ -586,19 +1019,16 @@ impl Interp {
5861019 v.kind_name()
5871020 )));
5881021 }
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() {
1022+ if fields.is_empty() {
5921023 return Run::Step(State::Ret(v, (*k).clone()));
5931024 }
5941025 Run::Step(State::Eval(
595− exprs[0].clone(),
1026+ fields[0].1.clone(),
5961027 env.clone(),
5971028 Cont::UpdateFields {
5981029 base: v,
599− names,
600− exprs,
601− acc: Vec::new(),
1030+ acc: Rc::new(RefCell::new(Vec::with_capacity(fields.len()))),
1031+ fields,
6021032 env,
6031033 cont: k,
6041034 },
@@ -606,20 +1036,23 @@ impl Interp {
6061036 }
6071037 Cont::UpdateFields {
6081038 base,
609− names,
610− exprs,
611− mut acc,
1039+ fields,
1040+ acc,
6121041 env,
6131042 cont,
6141043 } => {
615− let idx = acc.len();
616− acc.push((names[idx].clone(), v));
617− if acc.len() == names.len() {
1044+ let filled = {
1045+ let mut values = acc.borrow_mut();
1046+ let idx = values.len();
1047+ values.push((fields[idx].0.clone(), v));
1048+ values.len()
1049+ };
1050+ if filled == fields.len() {
6181051 let mut m = match &base {
6191052 Value::Record(m) => m.as_ref().clone(),
6201053 _ => unreachable!(),
6211054 };
622− for (n, val) in acc {
1055+ for (n, val) in acc.take() {
6231056 if !m.contains_key(&n) {
6241057 return Run::Crash(Crash::new(format!(
6251058 "cannot update: record has no field `{}`",
@@ -630,14 +1063,13 @@ impl Interp {
6301063 }
6311064 Run::Step(State::Ret(Value::Record(Rc::new(m)), (*cont).clone()))
6321065 } else {
633− let next = exprs[acc.len()].clone();
1066+ let next = fields[filled].1.clone();
6341067 Run::Step(State::Eval(
6351068 next,
6361069 env.clone(),
6371070 Cont::UpdateFields {
6381071 base,
639− names,
640− exprs,
1072+ fields,
6411073 acc,
6421074 env,
6431075 cont,
@@ -646,25 +1078,27 @@ impl Interp {
6461078 }
6471079 }
6481080 Cont::RecordFields {
649− names,
650− exprs,
651− mut acc,
1081+ fields,
1082+ acc,
6521083 env,
6531084 cont,
6541085 } => {
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();
1086+ let filled = {
1087+ let mut values = acc.borrow_mut();
1088+ let idx = values.len();
1089+ values.push((fields[idx].0.clone(), v));
1090+ values.len()
1091+ };
1092+ if filled == fields.len() {
1093+ let m: crate::value::RecordMap = acc.take().into_iter().collect();
6591094 Run::Step(State::Ret(Value::Record(Rc::new(m)), (*cont).clone()))
6601095 } else {
661− let next = exprs[acc.len()].clone();
1096+ let next = fields[filled].1.clone();
6621097 Run::Step(State::Eval(
6631098 next,
6641099 env.clone(),
6651100 Cont::RecordFields {
666− names,
667− exprs,
1101+ fields,
6681102 acc,
6691103 env,
6701104 cont,
@@ -674,15 +1108,19 @@ impl Interp {
6741108 }
6751109 Cont::ListElems {
6761110 exprs,
677− mut acc,
1111+ acc,
6781112 env,
6791113 cont,
6801114 } => {
681− acc.push(v);
682− if acc.len() == exprs.len() {
683− Run::Step(State::Ret(Value::list(acc), (*cont).clone()))
1115+ let filled = {
1116+ let mut values = acc.borrow_mut();
1117+ values.push(v);
1118+ values.len()
1119+ };
1120+ if filled == exprs.len() {
1121+ Run::Step(State::Ret(Value::list(acc.take()), (*cont).clone()))
6841122 } else {
685− let next = exprs[acc.len()].clone();
1123+ let next = exprs[filled].clone();
6861124 Run::Step(State::Eval(
6871125 next,
6881126 env.clone(),
@@ -701,6 +1139,19 @@ impl Interp {
7011139 }
7021140 Run::Step(State::Ret(v, (*cont).clone()))
7031141 }
1142+ Cont::NamePartial {
1143+ name,
1144+ cexpr,
1145+ args,
1146+ cont,
1147+ } => {
1148+ // a value the same definition named already, by a recursive
1149+ // call that was no tail call (`let r = f (n - 1) in r`), is
1150+ // named afresh rather than wrapped once more per call
1151+ let v = crate::value::named_inner(&v, &name, &cexpr).unwrap_or(v);
1152+ let named = crate::value::named_apply(v, args, &name, cexpr);
1153+ Run::Step(State::Ret(named, (*cont).clone()))
1154+ }
7041155 Cont::CheckResult {
7051156 name,
7061157 contract,
@@ -725,6 +1176,96 @@ impl Interp {
7251176 }
7261177 Run::Step(State::Ret(v, (*cont).clone()))
7271178 }
1179+ Cont::InDef { cont, .. } => Run::Step(State::Ret(v, (*cont).clone())),
1180+ Cont::MapNext {
1181+ f,
1182+ xs,
1183+ idx,
1184+ acc,
1185+ cont,
1186+ } => {
1187+ acc.borrow_mut().push(v);
1188+ let idx = idx + 1;
1189+ if idx < xs.len() {
1190+ let x = xs[idx].clone();
1191+ let next = Cont::MapNext {
1192+ f: f.clone(),
1193+ xs,
1194+ idx,
1195+ acc,
1196+ cont,
1197+ };
1198+ Run::Step(State::Apply(f, x, next))
1199+ } else {
1200+ Run::Step(State::Ret(Value::list(acc.take()), (*cont).clone()))
1201+ }
1202+ }
1203+ Cont::FilterNext {
1204+ p,
1205+ xs,
1206+ idx,
1207+ acc,
1208+ cont,
1209+ } => {
1210+ match v {
1211+ Value::Bool(true) => acc.borrow_mut().push(xs[idx].clone()),
1212+ Value::Bool(false) => (),
1213+ v => {
1214+ return Run::Crash(Crash::new(format!(
1215+ "filter: predicate returned a {}",
1216+ v.kind_name()
1217+ )))
1218+ }
1219+ }
1220+ let idx = idx + 1;
1221+ if idx < xs.len() {
1222+ let x = xs[idx].clone();
1223+ let next = Cont::FilterNext {
1224+ p: p.clone(),
1225+ xs,
1226+ idx,
1227+ acc,
1228+ cont,
1229+ };
1230+ Run::Step(State::Apply(p, x, next))
1231+ } else {
1232+ Run::Step(State::Ret(Value::list(acc.take()), (*cont).clone()))
1233+ }
1234+ }
1235+ Cont::FoldlArg { f, xs, idx, cont } => {
1236+ let x = xs[idx].clone();
1237+ Run::Step(State::Apply(v, x, Cont::FoldlAcc { f, xs, idx, cont }))
1238+ }
1239+ Cont::FoldlAcc { f, xs, idx, cont } => {
1240+ let idx = idx + 1;
1241+ if idx < xs.len() {
1242+ let next = Cont::FoldlArg {
1243+ f: f.clone(),
1244+ xs,
1245+ idx,
1246+ cont,
1247+ };
1248+ Run::Step(State::Apply(f, v, next))
1249+ } else {
1250+ Run::Step(State::Ret(v, (*cont).clone()))
1251+ }
1252+ }
1253+ Cont::BuiltinResult {
1254+ cname,
1255+ check,
1256+ def,
1257+ cont,
1258+ } => {
1259+ if let Some(c) = &check {
1260+ if let Err(cr) = c.check_result(&self.shapes, &cname, &v) {
1261+ return Run::Crash(match &def {
1262+ Some(d) => in_def(cr, d),
1263+ None => cr,
1264+ });
1265+ }
1266+ }
1267+ Run::Step(State::Ret(v, (*cont).clone()))
1268+ }
7281269 }
7291270 }
7301271
@@ -740,40 +1281,83 @@ impl Interp {
7401281 pending,
7411282 } => {
7421283 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),
1284+ let (cname, cexpr, named_at) = match pending {
1285+ Some((n, c, at)) => (n.clone(), Some(c.clone()), *at),
1286+ None => (name.clone(), None, 0),
7461287 };
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(_));
7501288 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− }
1289+ // numbered by the signature, not by the arguments
1290+ // baked into a partial application (§4.13)
1291+ let at = c.position().unwrap_or(args.len());
1292+ if let Err(cr) = c.check_arg_at(&self.shapes, &cname, at, &arg) {
1293+ return Run::Crash(self.blame_builtin(cr, name, &cname));
7551294 }
7561295 }
7571296 all.push(arg);
7581297 let advanced = cexpr
7591298 .map(|c| Rc::new(crate::shape::ContractExpr::apply_first_rc(c)));
7601299 if all.len() == *arity {
1300+ // a composition's contract derived from its operands
1301+ // leaves results to theirs, checked as the
1302+ // composition runs; a signature attached to it is
1303+ // checked like any other
1304+ let check = advanced.filter(|c| {
1305+ name != "(.)"
1306+ || matches!(**c, crate::shape::ContractExpr::Known { .. })
1307+ });
1308+ // a signed definition's builtin or composition that
1309+ // consumes fewer arguments than its signature lists
1310+ // (`addT = (+) . inc` under `Int -> Int -> Text`)
1311+ // returns a function, which becomes the named partial
1312+ // application `addT 1` under the rest of the
1313+ // signature, as a lambda that runs out of parameters
1314+ // first does (§4.13, §5.2)
1315+ let k = match &check {
1316+ Some(c)
1317+ if !c.is_exhausted()
1318+ && self.builtin_def(name, &cname).is_some() =>
1319+ {
1320+ // the arguments the definition was given, not
1321+ // those baked into its value
1322+ Cont::NamePartial {
1323+ name: cname.clone(),
1324+ cexpr: c.clone(),
1325+ args: all[named_at.min(all.len())..].to_vec(),
1326+ cont: Rc::new(k),
1327+ }
1328+ }
1329+ _ => k,
1330+ };
1331+ if applies_functions(name) {
1332+ // the result is checked once the functions have
1333+ // run, and a crash while they run is blamed as
1334+ // one in the builtin itself
1335+ let def = self.builtin_def(name, &cname);
1336+ let k = if check.is_some() || def.is_some() {
1337+ Cont::BuiltinResult {
1338+ cname: cname.clone(),
1339+ check,
1340+ def,
1341+ cont: Rc::new(k),
1342+ }
1343+ } else {
1344+ k
1345+ };
1346+ return match self.step_applying(name, &all, k) {
1347+ Ok(run) => run,
1348+ Err(c) => Run::Crash(self.blame_builtin(c, name, &cname)),
1349+ };
1350+ }
7611351 match bf(self, &all) {
7621352 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− }
1353+ if let Some(c) = &check {
1354+ if let Err(cr) = c.check_result(&self.shapes, &cname, &r) {
1355+ return Run::Crash(self.blame_builtin(cr, name, &cname));
7721356 }
7731357 }
7741358 Run::Step(State::Ret(r, k))
7751359 }
776− Err(c) => Run::Crash(c),
1360+ Err(c) => Run::Crash(self.blame_builtin(c, name, &cname)),
7771361 }
7781362 } else {
7791363 Run::Step(State::Ret(
@@ -782,7 +1366,7 @@ impl Interp {
7821366 arity: *arity,
7831367 args: all,
7841368 f: *bf,
785− pending: advanced.map(|c| (cname, c)),
1369+ pending: advanced.map(|c| (cname, c, named_at)),
7861370 })),
7871371 k,
7881372 ))
@@ -793,77 +1377,25 @@ impl Interp {
7931377 params,
7941378 applied,
7951379 applied_args,
796− deferred,
7971380 body,
7981381 env,
7991382 src,
8001383 pending,
8011384 } => {
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− }
8251385 let (cname, cexpr) = match pending {
8261386 Some((n, c)) => (Some(n.clone()), Some(c.clone())),
8271387 None => (name.clone(), None),
8281388 };
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− }
1389+ // a function argument is checked like any other: where
1390+ // the signature wants a Repo, `new new` is a contract
1391+ // crash, not a composition (§4.13, §4.9)
8631392 if let Some(c) = &cexpr {
8641393 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);
1394+ let at = c.position().unwrap_or(applied_args.len());
1395+ if let Err(cr) = c.check_arg_at(&self.shapes, &fname, at, &arg) {
1396+ // a violation is in the definition checked
1397+ // (§1.4, §4.13's `describe 3`)
1398+ return Run::Crash(in_def(cr, &fname));
8671399 }
8681400 }
8691401 let p = &params[*applied];
@@ -874,47 +1406,78 @@ impl Interp {
8741406 let new_applied = applied + 1;
8751407 let advanced = cexpr
8761408 .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"`)
8801409 let contract_exhausted = match &advanced {
8811410 Some(c) => c.is_exhausted(),
8821411 None => true,
8831412 };
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(),
1413+ if new_applied == params.len() {
1414+ // the definition whose lambda this body is, if any,
1415+ // executes while it runs (§1.4)
1416+ let def = self
1417+ .def_bodies
1418+ .get(&Rc::as_ptr(body))
1419+ .map(|(d, _)| d.clone());
1420+ // a call in tail position of a definition's body
1421+ // leaves that definition nothing to do but pass this
1422+ // body's value on, so its frame gives way to the one
1423+ // this body runs under, and unsigned tail recursion
1424+ // keeps its continuation constant in size
1425+ let signed = cname.is_some() && advanced.is_some();
1426+ let k = match k {
1427+ Cont::InDef { cont, .. } if signed || def.is_some() => {
1428+ (*cont).clone()
1429+ }
1430+ k => k,
1431+ };
1432+ // the body is evaluated now, as at any application
1433+ // (§4.1): a signature adds checks and never changes
1434+ // when a body runs, or whether an `or` around the
1435+ // application catches its crash (§4.6, §4.13)
1436+ let k = match (cname, advanced) {
1437+ // a tail call of the signed definition whose
1438+ // body `k` continues: its frames serve this one
1439+ (Some(n), Some(c)) if checks_already(&k, &n, &c) => k,
1440+ (Some(n), Some(c)) => {
1441+ // a lambda that runs out of parameters before
1442+ // its signature (`describe = \m -> mapRoot …`)
1443+ // returns a function, which becomes the named
1444+ // partial application `describe "wip"` (§5.2)
1445+ // under the rest of the signature
1446+ let k = if c.is_exhausted() {
1447+ k
1448+ } else {
1449+ let mut args = applied_args.clone();
1450+ args.push(arg);
1451+ Cont::NamePartial {
1452+ name: n.clone(),
1453+ cexpr: c.clone(),
1454+ args,
1455+ cont: Rc::new(k),
1456+ }
1457+ };
1458+ Cont::CheckExpr {
1459+ name: n,
1460+ cexpr: c,
8921461 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))
1462+ }
1463+ }
1464+ _ => k,
1465+ };
1466+ // inside the check of the definition it was bound to,
1467+ // which names this one already when it is the same
1468+ let k = match def {
1469+ Some(d)
1470+ if !matches!(&k, Cont::CheckExpr { name, .. }
1471+ if **name == *d) =>
1472+ {
1473+ Cont::InDef {
1474+ name: d,
1475+ cont: Rc::new(k),
1476+ }
1477+ }
1478+ _ => k,
1479+ };
1480+ Run::Step(State::Eval(body.clone(), env2, k))
9181481 } else if contract_exhausted {
9191482 // lambda params remain but the contract is satisfied;
9201483 // keep currying (unsigned tail)
@@ -925,7 +1488,6 @@ impl Interp {
9251488 params: params.clone(),
9261489 applied: new_applied,
9271490 applied_args: new_args,
928− deferred: false,
9291491 body: body.clone(),
9301492 env: env2,
9311493 src: src.clone(),
@@ -943,7 +1505,6 @@ impl Interp {
9431505 params: params.clone(),
9441506 applied: new_applied,
9451507 applied_args: new_args,
946− deferred: false,
9471508 body: body.clone(),
9481509 env: env2,
9491510 src: src.clone(),
@@ -956,34 +1517,10 @@ impl Interp {
9561517 }
9571518 }
9581519 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− }
1520+ // (f . g) x = f (g x) (§4.9), whatever x is: apply g,
1521+ // then f to the result; a crash in either keeps its
1522+ // message and unwinds to any enclosing `or` (§4.7)
1523+ Run::Step(State::Apply(g.clone(), arg, Cont::Fun(f.clone(), Rc::new(k))))
9871524 }
9881525 FunVal::OrFun(a, b) => {
9891526 // (f or g) x = f x or g x
@@ -994,7 +1531,11 @@ impl Interp {
9941531 a,
9951532 arg.clone(),
9961533 Cont::Try {
997− on_ok: Some(TryOk::OrFunLhs { cont: k.clone() }),
1534+ on_ok: Some(TryOk::OrFunLhs {
1535+ rhs: b.clone(),
1536+ arg: arg.clone(),
1537+ cont: k.clone(),
1538+ }),
9981539 snapshot: *self.fresh.borrow(),
9991540 on_crash: Box::new(State::Apply(b, arg, Cont::Shared(k))),
10001541 },
@@ -1006,7 +1547,7 @@ impl Interp {
10061547 Run::Step(State::Apply(
10071548 labelled.clone(),
10081549 text,
1009− Cont::LabelledArg(arg, Rc::new(k)),
1550+ Cont::ApplyTo(arg, Rc::new(k)),
10101551 ))
10111552 }
10121553 },
src/help.rsnew file+668
@@ -0,0 +1,668 @@
1+//! Read-only command discovery, built from the user's unevaluated config.
2+
3+use j::ast::{Item, TypeExpr};
4+use j::config;
5+use j::lex::{lex, Tok};
6+use j::parse::parse_config;
7+use std::collections::BTreeMap;
8+use std::rc::Rc;
9+
10+pub const OVERVIEW: &str = r#"j — a functional CLI for Jujutsu repositories
11+
12+Usage:
13+ j EXPRESSION… Evaluate an expression
14+ printf '%s\n' 'tree' | j Read an expression from stdin
15+ j help [NAME] Explain a command or config name
16+ j commands [QUERY] Find everyday commands by task
17+ j commands all Browse the complete vocabulary
18+ j help config Set your identity or repair configuration
19+
20+Start a repository:
21+ j setup 'Ada Lovelace' ada@example.org Create config with your identity
22+ j init Create a repository in the current directory
23+ j clone URL [DIR] Clone a repository
24+
25+Inspect (default config):
26+ j tree Show the history and current focus
27+ j status Show the focused commit and changed paths
28+ j review Show changes (unified diff if difft is absent)
29+ j log List commits
30+
31+Edit (default config):
32+ j save Record current file edits
33+ j 'describe "add a feature"' Set the focused commit's message
34+ j new Start an empty child and move to it
35+ j prev Move to the parent commit
36+ j 'new . goto trunk' Start a new commit on the main line
37+ j squash Fold the focus into its parent
38+ j 'rebase trunk' Move the focused subtree onto the main line
39+
40+Preview and recover:
41+ j 'preview squash' Check and preview a squash without saving
42+ j undo Undo the last operation
43+ j redo Reapply an undone operation
44+ j ops Show the operation log
45+
46+Sync:
47+ j remote URL Set the origin remote
48+ j fetch Fetch from origin
49+ j 'push (label "feature" here)' Publish the focus as a remote bookmark
50+ j push relabel Update existing remote bookmarks
51+
52+Push sends recorded commits. After editing files, run `j save` to record them
53+before pushing; `j status` and other inspections do not save those edits.
54+
55+Expressions compose right to left: `describe "wip" . new` runs new first.
56+A function result receives the current repository. A Repo result saves the
57+edit; other results print. Quote the whole expression with shell single
58+quotes to preserve text's double quotes and operators such as *, <, and >.
59+
60+Your config.j defines the vocabulary: edits (Repo -> Repo), inspections,
61+revsets, filesets, types, and custom names. Search with `j commands rebase`
62+or `j commands Edit`; use `j help squash` or `j help commands` for details.
63+"#;
64+
65+pub const CONFIG_HELP: &str = r#"Configuration and identity
66+
67+For a new installation, create the complete config with your identity:
68+
69+ j setup 'Ada Lovelace' ada@example.org
70+
71+Use your own name and email. Then run `j init` or `j clone URL`.
72+To create the complete default and edit identity yourself, run `j setup`.
73+Setup never replaces an existing config.
74+
75+If the file already exists, edit its existing `user` definition:
76+
77+ user = { name = "Ada Lovelace", email = "ada@example.org" }
78+
79+Keep the rest of the file: it defines your commands. The example above is
80+one definition, not a complete config. Set both fields before saving changes.
81+The shipped "Your Name" / "you@example.com" placeholders cannot author commits.
82+An existing configuration is never overwritten or upgraded automatically.
83+
84+If configuration is broken, repair the reported line in this file. General
85+help and reserved-command help remain available without loading config.
86+`j commands` checks the vocabulary without evaluating it or changing a repo.
87+
88+Configuration lives at $XDG_CONFIG_HOME/j/config.j when XDG_CONFIG_HOME is
89+set, otherwise at ~/.config/j/config.j. `j setup` creates an editable default;
90+help never creates or changes files.
91+"#;
92+
93+struct Reserved {
94+ name: &'static str,
95+ usage: &'static str,
96+ summary: &'static str,
97+ detail: &'static str,
98+}
99+
100+const RESERVED: &[Reserved] = &[
101+ Reserved {
102+ name: "clone",
103+ usage: "j clone URL [DIR]",
104+ summary: "Clone a repository.",
105+ detail: "Clone URL into DIR, or into a directory named after the repository.\nFor first-time setup, run `j setup NAME EMAIL`. Shell-quote paths containing spaces.\nCloning does not change your shell's directory; enter the new folder afterward.\n\nExamples:\n j clone https://example.com/team/project.git project\n cd project\n j status\n\n j clone '/path/to/source repo' 'my project'\n cd 'my project'\n",
106+ },
107+ Reserved {
108+ name: "commands",
109+ usage: "j commands [QUERY]",
110+ summary: "Find everyday commands by task, or browse the full vocabulary.",
111+ detail: "Without a query, show common tasks and a few custom definitions.\n`j commands all` lists every configured definition, builtin, operator, and type.\nSearch by task (save, record, commit, branch, checkout), name, signature, or\nsource comments. Search ignores case and puts direct matches and everyday\ncommands first. Use `j help NAME` for examples and types.\n\nThe config is parsed and validated without evaluating definitions or opening\na repository. No config file is created; when absent, defaults are shown.\n\nExamples:\n j commands\n j commands save\n j commands rebase\n j commands Edit\n j commands all\n",
112+ },
113+ Reserved {
114+ name: "fetch",
115+ usage: "j fetch",
116+ summary: "Fetch commits and bookmarks from origin.",
117+ detail: "Fetch from the origin remote and import its commits and bookmarks.\nSet the remote with `j remote URL`.\n\nExample:\n j fetch\n",
118+ },
119+ Reserved {
120+ name: "help",
121+ usage: "j help [NAME]",
122+ summary: "Show getting-started help or explain a name.",
123+ detail: "With no NAME, show common commands and the expression model. With NAME,\nshow CLI usage or a config name's signature, documentation, and source line.\nConfig help reads declarations without evaluating them or opening a repository.\n\nExamples:\n j help\n j help push\n j help squash\n j help Edit\n j help '(.)'\n\nUse `j commands QUERY` to find names by purpose or signature.\n",
124+ },
125+ Reserved {
126+ name: "init",
127+ usage: "j init",
128+ summary: "Create a repository in the current directory.",
129+ detail: "Create a colocated .jj/.git repository and a working-copy commit. An\nexisting .git repository is adopted. On a new installation, first run\n`j setup NAME EMAIL`; use `j help config` to update an existing identity.\nThe shipped placeholder identity cannot author commits.\n\nExample:\n j setup 'Ada Lovelace' ada@example.org\n mkdir project\n cd project\n j init\n",
130+ },
131+ Reserved {
132+ name: "ops",
133+ usage: "j ops",
134+ summary: "Show the operation log.",
135+ detail: "Print the operation history, newest first, with the current operation\nmarked by *. Undo and redo operations are labelled. This reads repository\nhistory without loading config.j.\n\nExample:\n j ops\n\nRelated: j help undo, j help redo\n",
136+ },
137+ Reserved {
138+ name: "push",
139+ usage: "j push EXPRESSION",
140+ summary: "Publish or delete bookmarks on origin.",
141+ detail: "Evaluate EXPRESSION to a list of Push and Drop records, then update origin.\nA function result receives the recorded repository; working-copy changes\nare not snapshotted by push. Unrecorded edits produce a warning before sending.\nRun `j id` to record them, then push again to include them; `j status` does not\nrecord edits. The default config provides label, unlabel, rename, and relabel.\n\nExamples (default config):\n j 'push (label \"feature\" here)'\n j push relabel\n j 'push (unlabel \"feature\")'\n j 'push (rename \"old\" \"new\")'\n\nThe unlabel example deletes the remote bookmark.\n",
142+ },
143+ Reserved {
144+ name: "redo",
145+ usage: "j redo",
146+ summary: "Reapply an undone operation.",
147+ detail: "Reapply the operation removed by undo. Refuses if there is nothing to redo\nor if restoring the working copy would overwrite unsnapshotted changes.\nRecording new edits with `j id` creates an operation and clears pending redos.\nInspect `j ops` before choosing how to preserve or discard those edits.\n\nExample:\n j redo\n\nRelated: j help undo, j help ops\n",
148+ },
149+ Reserved {
150+ name: "remote",
151+ usage: "j remote URL",
152+ summary: "Set the origin remote URL.",
153+ detail: "Add origin or replace its URL. Fetch and push use this remote.\n\nExample:\n j remote https://example.com/team/project.git\n j fetch\n",
154+ },
155+ Reserved {
156+ name: "setup",
157+ usage: "j setup [NAME EMAIL]",
158+ summary: "Create your complete config before starting a repository.",
159+ detail: "With NAME and EMAIL, create the full default config with your identity.\nWithout arguments, create the default and print the exact definition to edit.\nAn existing config is never overwritten: edit its `user` definition directly.\nSetup does not create or open a repository.\n\nExamples:\n j setup 'Ada Lovelace' ada@example.org\n j init\n\n j setup\n j help config\n",
160+ },
161+ Reserved {
162+ name: "undo",
163+ usage: "j undo",
164+ summary: "Undo the last operation.",
165+ detail: "Restore the repository state before the last operation. Repeat to walk\nback through history. Refuses if restoring the working copy would overwrite\nunsnapshotted changes. `j id` preserves edits as a new operation: the next\nundo reverses that recording before reaching the earlier operation. Use\n`j ops` to inspect history; `j redo` can restore the undone recording.\nUndo does not reverse changes already sent to a remote.\n\nExample:\n j undo\n\nRelated: j help redo, j help ops\n",
166+ },
167+];
168+
169+pub fn reserved_help(name: &str) -> Option<String> {
170+ RESERVED
171+ .iter()
172+ .find(|cmd| cmd.name == name)
173+ .map(|cmd| format!("Usage: {}\n\n{}\n\n{}", cmd.usage, cmd.summary, cmd.detail))
174+}
175+
176+#[derive(Clone, Copy)]
177+enum Kind {
178+ Definition,
179+ Builtin,
180+ Type,
181+}
182+
183+struct Entry {
184+ name: String,
185+ kind: Kind,
186+ signature: Option<TypeExpr>,
187+ line: usize,
188+ docs: Vec<String>,
189+}
190+
191+impl Entry {
192+ fn declaration(&self) -> String {
193+ let name = if config::is_reserved_builtin(&self.name)
194+ && !self.name.chars().next().unwrap_or('_').is_alphabetic()
195+ {
196+ format!("({})", self.name)
197+ } else {
198+ self.name.clone()
199+ };
200+ match (&self.signature, self.kind) {
201+ (Some(ty), Kind::Type) => format!("{} = {}", name, type_text(ty)),
202+ (Some(ty), _) => format!("{} : {}", name, type_text(ty)),
203+ (None, _) => format!("{} (definition; no signature)", name),
204+ }
205+ }
206+}
207+
208+struct Task {
209+ group: &'static str,
210+ name: &'static str,
211+ usage: &'static str,
212+ summary: &'static str,
213+}
214+
215+const TASKS: &[Task] = &[
216+ Task { group: "Start", name: "setup", usage: "j setup NAME EMAIL", summary: "Set your name and email." },
217+ Task { group: "Start", name: "init", usage: "j init", summary: "Create a repository here." },
218+ Task { group: "Start", name: "clone", usage: "j clone URL [DIR]", summary: "Clone a repository, then cd into it." },
219+ Task { group: "Inspect", name: "status", usage: "j status", summary: "See your current change and files." },
220+ Task { group: "Inspect", name: "tree", usage: "j tree", summary: "See history and your current commit." },
221+ Task { group: "Inspect", name: "review", usage: "j review", summary: "Review changed file contents." },
222+ Task { group: "Work", name: "save", usage: "j save", summary: "Record current file edits." },
223+ Task { group: "Work", name: "id", usage: "j id", summary: "Record current file edits." },
224+ Task { group: "Work", name: "describe", usage: "j 'describe \"message\"'", summary: "Name the current commit." },
225+ Task { group: "Work", name: "new", usage: "j new", summary: "Start the next commit before editing." },
226+ Task { group: "Work", name: "preview", usage: "j 'preview squash'", summary: "Check an edit without saving it." },
227+ Task { group: "Work", name: "prev", usage: "j prev", summary: "Move to the parent commit." },
228+ Task { group: "Share", name: "remote", usage: "j remote URL", summary: "Set your remote repository." },
229+ Task { group: "Share", name: "fetch", usage: "j fetch", summary: "Get remote commits and bookmarks." },
230+ Task { group: "Share", name: "push", usage: "j help push", summary: "Learn how to publish a bookmark." },
231+ Task { group: "Recover", name: "undo", usage: "j undo", summary: "Undo the last operation." },
232+ Task { group: "Recover", name: "redo", usage: "j redo", summary: "Reapply an undone operation." },
233+ Task { group: "Recover", name: "ops", usage: "j ops", summary: "Inspect the operation history." },
234+];
235+
236+fn everyday_list(entries: &[Entry]) -> String {
237+ let mut out = String::from("Everyday commands:\n");
238+ let reference = config::load_config(include_str!("../config.j")).expect("embedded config is valid");
239+ let mut group = "";
240+ for task in TASKS {
241+ let entry = entries.iter().find(|entry| entry.name == task.name);
242+ let reserved = RESERVED.iter().any(|command| command.name == task.name);
243+ if !reserved && entry.is_none() {
244+ continue;
245+ }
246+ if task.name == "id" && entries.iter().any(|entry| entry.name == "save") {
247+ continue;
248+ }
249+ if task.group != group {
250+ out.push_str(&format!("\n{}:\n", task.group));
251+ group = task.group;
252+ }
253+ let custom_summary;
254+ let summary = match entry {
255+ Some(entry) if !entry.docs.is_empty() => entry.docs[0].as_str(),
256+ Some(entry) if entry.signature.is_none() && entry.docs.is_empty() => {
257+ custom_summary = format!("Custom definition; see `j help {}`.", entry.name);
258+ custom_summary.as_str()
259+ }
260+ _ => task.summary,
261+ };
262+ let changed_signature = entry.is_some_and(|entry| entry.signature.as_ref() != reference.sigs.get(task.name));
263+ let usage = if changed_signature || (task.name == "preview" && !entries.iter().any(|entry| entry.name == "squash")) {
264+ format!("j help {}", task.name)
265+ } else {
266+ task.usage.to_string()
267+ };
268+ out.push_str(&format!(" {:<29} {}\n", usage, truncate(summary, 47)));
269+ }
270+ // Surface a few additions without letting a large custom vocabulary turn
271+ // the starting guide into the complete catalog again.
272+ let custom: Vec<_> = entries.iter().filter(|entry| {
273+ matches!(entry.kind, Kind::Definition)
274+ && !reference.defs.iter().any(|(name, _)| name == &entry.name)
275+ }).collect();
276+ if !custom.is_empty() {
277+ out.push_str("\nYour custom definitions:\n");
278+ for entry in custom.iter().take(6) {
279+ let docs = entry.docs.first().map(String::as_str).unwrap_or("Use j help for details.");
280+ out.push_str(&format!(" {:<29} {}\n", entry.name, truncate(docs, 55)));
281+ }
282+ if custom.len() > 6 {
283+ out.push_str(" More custom definitions are listed by `j commands all`.\n");
284+ }
285+ }
286+ out.push_str("\nUse `j help NAME` for examples. Search by task: `j commands save`.\nUse `j commands all` for every definition, builtin, operator, and type.\n");
287+ out
288+}
289+
290+fn intent_names(query: &str) -> &'static [&'static str] {
291+ match query {
292+ "save" | "record" | "snapshot" | "save changes" | "record changes" => &["save", "id"],
293+ "commit" | "git commit" => &["describe", "new", "save", "id"],
294+ "branch" | "git branch" => &["new", "label", "push", "goto"],
295+ "checkout" | "switch" | "switch branch" | "git checkout" => &["goto", "prev", "next"],
296+ "diff" | "changes" | "git diff" => &["review", "status", "changed", "diff"],
297+ _ => &[],
298+ }
299+}
300+
301+pub fn command_list(src: &str, query: &str) -> Result<String, String> {
302+ let entries = entries(src)?;
303+ let query = query.trim().to_lowercase();
304+ if query.is_empty() {
305+ return Ok(everyday_list(&entries));
306+ }
307+ let all = query == "all";
308+ let intents = intent_names(&query);
309+ let matches = |text: &str| all || text.to_lowercase().contains(&query);
310+ let mut results = Vec::new();
311+ for cmd in RESERVED {
312+ let intent = intents.iter().position(|name| *name == cmd.name);
313+ if matches(&format!("{} {} {}", cmd.usage, cmd.summary, cmd.detail)) || intent.is_some() {
314+ let rank = if all || query == cmd.name { 0 } else { intent.map(|i| i + 1).unwrap_or(40) };
315+ results.push((rank, cmd.name.to_string(), format!(" {:<24} {}\n", cmd.usage, cmd.summary)));
316+ }
317+ }
318+ for entry in &entries {
319+ let declaration = entry.declaration();
320+ let intent = intents.iter().position(|name| *name == entry.name);
321+ if !matches(&format!("{} {}", declaration, entry.docs.join(" "))) && intent.is_none() {
322+ continue;
323+ }
324+ let everyday = TASKS.iter().any(|task| task.name == entry.name);
325+ let exact = query == entry.name.to_lowercase() && (!matches!(entry.kind, Kind::Type) || intents.is_empty());
326+ let rank = if all { 1 } else if exact { 0 }
327+ else { intent.map(|i| i + 1).unwrap_or(if everyday { 20 } else { 30 }) };
328+ let mut description = format!(" {}\n", declaration);
329+ if let Some(doc) = entry.docs.first() {
330+ description.push_str(&format!(" {}\n", truncate(doc, 110)));
331+ }
332+ results.push((rank, entry.name.clone(), description));
333+ }
334+ results.sort_by(|a, b| (&a.0, a.1.to_lowercase(), &a.1).cmp(&(&b.0, b.1.to_lowercase(), &b.1)));
335+ let mut out = String::new();
336+ if matches!(query.as_str(), "stash" | "git stash") && !entries.iter().any(|entry| entry.name == "stash") {
337+ out.push_str("There is no direct stash command in this config.\n");
338+ let save = if entries.iter().any(|entry| entry.name == "save") { Some("save") }
339+ else if entries.iter().any(|entry| entry.name == "id") { Some("id") } else { None };
340+ if let Some(save) = save {
341+ out.push_str(&format!("`j {}` records file edits in the current commit.\n", save));
342+ }
343+ if entries.iter().any(|entry| entry.name == "new") {
344+ out.push_str("`j new` starts a child commit; the recorded work remains in its parent.\n");
345+ }
346+ out.push_str("\n");
347+ }
348+ if results.is_empty() {
349+ out.push_str("No matching commands or config names. Try `j commands` for common tasks or `j commands all` for every name.\n");
350+ } else {
351+ out.push_str(if all { "All commands and config vocabulary:\n" } else { "Matching commands and config names:\n" });
352+ for (_, _, description) in results {
353+ out.push_str(&description);
354+ }
355+ out.push_str("\nUse `j help NAME` for examples and details; `j commands QUERY` to search.\n");
356+ }
357+ Ok(out)
358+}
359+
360+pub fn command_help(src: &str, name: &str) -> Result<Option<String>, String> {
361+ let name = name.trim();
362+ let name = name
363+ .strip_prefix('(')
364+ .and_then(|n| n.strip_suffix(')'))
365+ .unwrap_or(name)
366+ .trim();
367+ let entry = entries(src)?.into_iter().find(|entry| entry.name == name);
368+ Ok(entry.map(|entry| {
369+ let kind = match entry.kind {
370+ Kind::Definition => "Config definition",
371+ Kind::Builtin => "Builtin declared in config",
372+ Kind::Type => "Type declared in config",
373+ };
374+ let mut out = String::new();
375+ if !entry.docs.is_empty() {
376+ for doc in entry.docs.iter().take(24) {
377+ out.push_str(doc);
378+ out.push('\n');
379+ }
380+ if entry.docs.len() > 24 {
381+ out.push_str("… See the source for the remaining comments.\n");
382+ }
383+ out.push('\n');
384+ }
385+ out.push_str(&format!("{}\n\n{}\nSource: line {}\n", entry.declaration(), kind, entry.line));
386+ out
387+ }))
388+}
389+
390+fn entries(src: &str) -> Result<Vec<Entry>, String> {
391+ let cfg = config::load_config(src).map_err(|err| err.message())?;
392+ let items = parse_config(src, Rc::new(config::reserved_set()))
393+ .map_err(|err| format!("line {}: {}", err.line, err.msg))?;
394+ let tokens = lex(src).map_err(|err| format!("line {}: {}", err.line, err.msg))?;
395+ let starts: Vec<_> = tokens
396+ .iter()
397+ .enumerate()
398+ .filter(|(_, token)| token.col == 1 && !matches!(token.tok, Tok::Newline | Tok::Eof))
399+ .map(|(index, _)| index)
400+ .collect();
401+ let comments = comments(src, &tokens);
402+ let mut entries = BTreeMap::<String, Entry>::new();
403+ for (index, item) in items.into_iter().enumerate() {
404+ let first = &tokens[starts[index]];
405+ let end = starts.get(index + 1).copied().unwrap_or(tokens.len());
406+ let last = tokens[starts[index]..end]
407+ .iter()
408+ .rev()
409+ .find(|token| !matches!(token.tok, Tok::Newline | Tok::Eof))
410+ .unwrap();
411+ let next_start = starts
412+ .get(index + 1)
413+ .map(|&i| tokens[i].start)
414+ .unwrap_or(src.len());
415+ let docs = item_docs(src, first.line, last.end, next_start, &comments);
416+ let (name, signature, kind) = match item {
417+ Item::TypeDecl(name, ty) => (name, Some(ty), Kind::Type),
418+ Item::Signature(name, ty, _) => {
419+ let kind = if cfg.builtin_names.contains(&name) {
420+ Kind::Builtin
421+ } else {
422+ Kind::Definition
423+ };
424+ (name, Some(ty), kind)
425+ }
426+ Item::Definition(name, _, _) => (name, None, Kind::Definition),
427+ };
428+ let entry = entries.entry(name.clone()).or_insert(Entry {
429+ name,
430+ signature,
431+ kind,
432+ line: first.line,
433+ docs: Vec::new(),
434+ });
435+ for doc in docs {
436+ if !entry.docs.contains(&doc) {
437+ entry.docs.push(doc);
438+ }
439+ }
440+ }
441+ let mut entries: Vec<_> = entries.into_values().collect();
442+ entries.sort_by_cached_key(|entry| (entry.name.to_lowercase(), entry.name.clone()));
443+ Ok(entries)
444+}
445+
446+fn type_text(ty: &TypeExpr) -> String {
447+ match ty {
448+ TypeExpr::Con(name) => name.clone(),
449+ TypeExpr::List(item) => format!("[{}]", type_text(item)),
450+ TypeExpr::Record(fields) => format!(
451+ "{{ {} }}",
452+ fields
453+ .iter()
454+ .map(|(name, ty)| format!("{} : {}", name, type_text(ty)))
455+ .collect::<Vec<_>>()
456+ .join(", ")
457+ ),
458+ TypeExpr::Fun(arg, result) => {
459+ let arg_text = type_text(arg);
460+ let arg_text = if matches!(arg.as_ref(), TypeExpr::Fun(_, _)) {
461+ format!("({})", arg_text)
462+ } else {
463+ arg_text
464+ };
465+ format!("{} -> {}", arg_text, type_text(result))
466+ }
467+ }
468+}
469+
470+fn truncate(text: &str, length: usize) -> String {
471+ let mut chars = text.chars();
472+ let prefix: String = chars.by_ref().take(length).collect();
473+ if chars.next().is_some() {
474+ format!("{}…", prefix)
475+ } else {
476+ prefix
477+ }
478+}
479+
480+struct Comment {
481+ start: usize,
482+ first_line: usize,
483+ last_line: usize,
484+ standalone: bool,
485+ continuation: bool,
486+ text: Vec<String>,
487+}
488+
489+/// Comments occupy gaps between lexer tokens, so text such as "-- not a doc"
490+/// inside string literals cannot be mistaken for documentation.
491+fn comments(src: &str, tokens: &[j::lex::SpTok]) -> Vec<Comment> {
492+ let mut comments = Vec::<Comment>::new();
493+ let mut cursor = 0;
494+ for token in tokens
495+ .iter()
496+ .filter(|token| !matches!(token.tok, Tok::Newline))
497+ {
498+ let mut rest = &src[cursor..token.start];
499+ while !rest.trim_start().is_empty() {
500+ rest = rest.trim_start();
501+ let start = token.start - rest.len();
502+ let (raw, consumed) = if let Some(body) = rest.strip_prefix("--") {
503+ let length = body.find('\n').unwrap_or(body.len());
504+ (&body[..length], length + 2)
505+ } else if rest.starts_with("{-") {
506+ let mut depth = 1;
507+ let mut offset = 2;
508+ while depth > 0 {
509+ if rest[offset..].starts_with("{-") {
510+ depth += 1;
511+ offset += 2;
512+ } else if rest[offset..].starts_with("-}") {
513+ depth -= 1;
514+ offset += 2;
515+ } else {
516+ offset += rest[offset..].chars().next().unwrap().len_utf8();
517+ }
518+ }
519+ (&rest[2..offset - 2], offset)
520+ } else {
521+ break;
522+ };
523+ let first_line = src[..start].bytes().filter(|&b| b == b'\n').count() + 1;
524+ let last_line = first_line + rest[..consumed].bytes().filter(|&b| b == b'\n').count();
525+ let line_start = src[..start].rfind('\n').map_or(0, |i| i + 1);
526+ let standalone = src[line_start..start].trim().is_empty();
527+ let continuation = standalone
528+ && start > line_start
529+ && comments.last().is_some_and(|previous| {
530+ previous.last_line + 1 == first_line
531+ && (!previous.standalone || previous.continuation)
532+ });
533+ comments.push(Comment {
534+ start,
535+ first_line,
536+ last_line,
537+ standalone,
538+ continuation,
539+ text: raw.lines().map(|line| line.trim().to_string()).collect(),
540+ });
541+ rest = &rest[consumed..];
542+ }
543+ cursor = token.end;
544+ }
545+ comments
546+}
547+
548+fn item_docs(
549+ src: &str,
550+ first_line: usize,
551+ end: usize,
552+ next_start: usize,
553+ comments: &[Comment],
554+) -> Vec<String> {
555+ let mut preceding = Vec::new();
556+ let mut line = first_line.saturating_sub(1);
557+ while let Some(comment) = comments
558+ .iter()
559+ .rev()
560+ .find(|comment| comment.last_line == line && comment.standalone && !comment.continuation)
561+ {
562+ if comment
563+ .text
564+ .iter()
565+ .all(|text| text.chars().all(|c| "-=_ ".contains(c)))
566+ {
567+ break;
568+ }
569+ preceding.push(comment);
570+ line = comment.first_line.saturating_sub(1);
571+ }
572+ preceding.reverse();
573+ let last_line = src[..end].bytes().filter(|&b| b == b'\n').count() + 1;
574+ let inline = comments.iter().filter(|comment| {
575+ comment.first_line >= first_line
576+ && comment.start < next_start
577+ && (comment.first_line <= last_line || comment.continuation)
578+ });
579+ preceding
580+ .into_iter()
581+ .chain(inline)
582+ .flat_map(|comment| comment.text.iter())
583+ .filter(|text| !text.is_empty() && !text.chars().all(|c| "-=_ ".contains(c)))
584+ .cloned()
585+ .collect()
586+}
587+
588+#[cfg(test)]
589+mod tests {
590+ use super::*;
591+
592+ const BASE: &str = "user = {}\nimmutable = []\ntree = 0\nlabelled = 0\n";
593+
594+ #[test]
595+ fn lists_custom_names_types_and_docs_without_evaluation() {
596+ let src = format!("{BASE}-- A custom greeting.\nhello = crash \"not evaluated\"\nAlias = Text -- a useful type\n");
597+ let list = command_list(&src, "CUSTOM GREETING").unwrap();
598+ assert!(list.contains("hello (definition; no signature)"));
599+ assert!(!list.contains("immutable"));
600+ let help = command_help(&src, "Alias").unwrap().unwrap();
601+ assert!(help.contains("Alias = Text"));
602+ assert!(help.contains("a useful type"));
603+ assert!(help.contains("Source: line 7"));
604+ }
605+
606+ #[test]
607+ fn signatures_keep_function_argument_parentheses() {
608+ let src = format!("{BASE}(.) : (b -> c) -> (a -> b) -> a -> c -- compose\n");
609+ let help = command_help(&src, "(.)").unwrap().unwrap();
610+ assert!(help.contains("(.) : (b -> c) -> (a -> b) -> a -> c"));
611+ assert!(help.contains("Builtin declared in config"));
612+ assert!(help.contains("compose"));
613+ }
614+
615+ #[test]
616+ fn comments_handle_strings_blocks_and_multiline_types() {
617+ let src = format!("{BASE}message = \"-- not a doc\"\n\n{{- Useful\n block docs. -}}\nShape = {{ value : Text\n , count : Int }} -- shape details\n -- continued details\n\n-- Different docs.\nother = 1\n");
618+ let message = command_help(&src, "message").unwrap().unwrap();
619+ assert!(!message.contains("not a doc"));
620+ let shape = command_help(&src, "Shape").unwrap().unwrap();
621+ assert!(shape.contains("Useful\nblock docs."));
622+ assert!(shape.contains("shape details\ncontinued details"));
623+ assert!(!shape.contains("Different docs"));
624+ assert!(shape.contains("Source: line 9"));
625+ }
626+
627+ #[test]
628+ fn inline_continuations_belong_to_the_preceding_declaration() {
629+ let src = format!("{BASE}first = 1 -- first comment\n -- first continuation\nsecond = 2\n\n -- third comment\nthird = 3\n");
630+ let first = command_help(&src, "first").unwrap().unwrap();
631+ assert!(first.contains("first comment\nfirst continuation"));
632+ let second = command_help(&src, "second").unwrap().unwrap();
633+ assert!(!second.contains("first continuation"));
634+ assert!(!second.contains("third comment"));
635+ assert!(command_help(&src, "third")
636+ .unwrap()
637+ .unwrap()
638+ .contains("third comment"));
639+ }
640+
641+ #[test]
642+ fn validates_config_and_does_not_resolve_ids() {
643+ assert!(command_list("broken =", "").is_err());
644+ assert!(command_help("x = 1", "x")
645+ .unwrap_err()
646+ .contains("must define"));
647+ let src = format!("{BASE}revision = @zzzz\n");
648+ assert!(command_help(&src, "revision").unwrap().is_some());
649+ assert!(command_help(&src, "missing").unwrap().is_none());
650+ assert!(command_list(&src, "no-such-query")
651+ .unwrap()
652+ .contains("No matching"));
653+ }
654+
655+ #[test]
656+ fn default_config_documents_real_names() {
657+ let src = include_str!("../config.j");
658+ let help = command_help(src, "squash").unwrap().unwrap();
659+ assert!(help.contains("squash : Edit"));
660+ assert!(help.contains("Fold the focus entirely into its parent"));
661+ let list = command_list(src, "edit").unwrap();
662+ assert!(list.contains("squash : Edit"));
663+ assert!(list.contains("describe : Text -> Edit"));
664+ assert!(reserved_help("push")
665+ .unwrap()
666+ .contains("j 'push (label \"feature\" here)'"));
667+ }
668+}
src/jj.rs+3176−472
@@ -6,33 +6,38 @@
66use crate::config::Config;
77use crate::domain::{Backend, MetaInfo, ROOT_ID};
88use crate::eval::Interp;
9−use crate::value::{BlobContent, BlobKind, BlobVal, Crash, LazyBlob, Value};
9+use crate::value::{BlobContent, BlobKind, BlobVal, ConflictSide, Crash, LazyBlob, ListVal, ThunkVal, Value};
10+use futures::{StreamExt, TryStreamExt};
1011use jj_lib::backend::{CopyId, Signature, Timestamp, TreeValue};
1112use jj_lib::commit::Commit;
1213use jj_lib::config::{ConfigLayer, ConfigSource, StackedConfig};
14+use jj_lib::conflicts::ConflictMarkerStyle;
1315use jj_lib::default_backend_factories::{default_backend_factories, default_working_copy_factories};
1416use jj_lib::gitignore::GitIgnoreFile;
15−use jj_lib::matchers::{EverythingMatcher, NothingMatcher};
17+use jj_lib::local_working_copy::{FileState, FileStates, FileType, LocalWorkingCopy, TreeState, TreeStateSettings};
18+use jj_lib::matchers::{DifferenceMatcher, EverythingMatcher, FilesMatcher, NothingMatcher};
1619use jj_lib::merge::{Diff, Merge};
1720use jj_lib::merged_tree::MergedTree;
1821use jj_lib::merged_tree_builder::MergedTreeBuilder;
19−use jj_lib::backend::{CommitId, FileId};
22+use jj_lib::backend::{CommitId, FileId, MergedTreeValueExt, TreeId};
2023use jj_lib::object_id::ObjectId;
2124use jj_lib::op_store::OperationId;
2225use jj_lib::ref_name::{RefName, RemoteName, WorkspaceName, WorkspaceNameBuf};
2326use jj_lib::repo::{ReadonlyRepo, Repo};
24−use jj_lib::repo_path::{RepoPath, RepoPathBuf, RepoPathComponentBuf};
27+use jj_lib::repo_path::{RepoPath, RepoPathBuf, RepoPathComponent, RepoPathComponentBuf};
28+use jj_lib::revset::{ResolvedRevsetExpression, RevsetStreamExt};
2529use jj_lib::settings::UserSettings;
2630use jj_lib::store::Store;
27−use jj_lib::working_copy::SnapshotOptions;
31+use jj_lib::transaction::UnpublishedOperation;
32+use jj_lib::working_copy::{LockedWorkingCopy, SnapshotError, SnapshotOptions};
2833use jj_lib::workspace::Workspace;
29−use std::cell::RefCell;
34+use std::cell::{Cell, RefCell};
3035use std::collections::{BTreeMap, BTreeSet, HashMap};
3136use std::future::Future;
3237use std::os::unix::io::AsRawFd;
3338use std::path::PathBuf;
34−use std::rc::Rc;
35−use std::sync::{Arc, Mutex};
39+use std::rc::{Rc, Weak};
40+use std::sync::{Arc, Mutex, OnceLock};
3641
3742fn block_on<F: Future>(f: F) -> F::Output {
3843 pollster::block_on(f)
@@ -40,6 +45,15 @@ fn block_on<F: Future>(f: F) -> F::Output {
4045
4146type OpenError = (u8, String);
4247
48+/// The successful effects of a push, after duplicate requests are merged.
49+/// All zeroes means no bookmarks were selected and nothing was sent or saved.
50+#[derive(Clone, Debug, Default, PartialEq, Eq)]
51+pub struct PushOutcome {
52+ pub published: usize,
53+ pub deleted: usize,
54+ pub unchanged: usize,
55+}
56+
4357// ----------------------------------------------------------------------
4458// locating and opening the repository (§7.1)
4559// ----------------------------------------------------------------------
@@ -64,6 +78,7 @@ fn toml_string(s: &str) -> String {
6478 '\n' => out.push_str("\\n"),
6579 '\r' => out.push_str("\\r"),
6680 '\t' => out.push_str("\\t"),
81+ c if c.is_ascii_control() => out.push_str(&format!("\\u{:04X}", c as u32)),
6782 c => out.push(c),
6883 }
6984 }
@@ -252,31 +267,1166 @@ pub struct JjInner {
252267 repo: Mutex<Arc<ReadonlyRepo>>,
253268 workspace_name: WorkspaceNameBuf,
254269 /// visible map backing the `Backend` lookups during evaluation; filled by
255− /// build_interp with the post-snapshot map
270+ /// build_interp with the post-snapshot map, or the loaded one when the
271+ /// snapshot goes into a new child of an immutable focus (§7.2)
256272 visible: Mutex<Option<Arc<VisibleRepo>>>,
257273 lock_guard: Mutex<Option<FileLock>>,
258− /// the snapshot transaction and working-copy lock, held between
259− /// build_interp and persist so the snapshot and the expression's edits
260− /// form one jj operation (§1.2); dropped uncommitted by printing runs
274+ /// the snapshot's unpublished rewrite, kept between build_interp and
275+ /// persist so the snapshot and the expression's edits form one jj
276+ /// operation (§1.2); dropped uncommitted by printing runs. The
277+ /// working-copy lock is not held: its saved state is still the tree
278+ /// recorded before the snapshot until `persist`'s checkout scans the
279+ /// directory again
261280 pending: Mutex<Option<PendingSnapshot>>,
281+ /// whether the workspace's filesystem folds case, probed on first use
282+ /// (`Backend::folds_case`)
283+ folds_case: OnceLock<bool>,
284+ /// whether the workspace's filesystem holds each name longer than
285+ /// `NAME_MAX` asked about so far (`Backend::name_fits`)
286+ long_names: Mutex<HashMap<String, bool>>,
262287}
263288
264289/// holding the file keeps the flock
265290pub struct FileLock(#[allow(dead_code)] std::fs::File);
266291
267−#[allow(dead_code)]
268292struct PendingSnapshot {
269293 /// repo as loaded at head, before the snapshot
270294 pre_repo: Arc<ReadonlyRepo>,
271− /// the snapshot's rewritten wc commit and tree, to be folded into the
295+ /// the snapshot's tree for the wc commit, to be folded into the
272296 /// persisting operation (§1.2 step 8, §7.7)
273− new_wc_id: CommitId,
274297 tree: MergedTree,
298+ /// false when the wc commit is immutable: the snapshot then belongs to
299+ /// the focus's new child (§7.2), and the wc commit is kept as stored
300+ fold_into_wc: bool,
301+ /// the tree the scan found in the working directory, which `persist`'s
302+ /// checkout starts from: `tree` itself, unless the working copy was
303+ /// stale and `tree` merges the directory's changes onto the commit
304+ /// (`snapshot_tree`)
305+ scanned: MergedTree,
306+}
307+
308+/// How every scan of the working directory reads it (§7.4): each file
309+/// not ignored by the directory's own `.gitignore`s is tracked, whatever
310+/// its size. `persist`'s checkout rescans with the same options and
311+/// relies on getting the snapshot's tree back.
312+fn snapshot_options() -> SnapshotOptions<'static> {
313+ SnapshotOptions {
314+ base_ignores: GitIgnoreFile::empty(),
315+ progress: None,
316+ start_tracking_matcher: &EverythingMatcher,
317+ force_tracking_matcher: &NothingMatcher,
318+ max_new_file_size: u64::MAX,
319+ }
320+}
321+
322+/// Scan the working directory with `options`, as `wc.snapshot` does, where
323+/// `base` is the tree the working copy's state records, as `saved` holds
324+/// it; return the tree scanned. jj's scan loses a regular file found where
325+/// `base` has a conflict it cannot write a file into, one with a side that
326+/// is no file, and keeps the conflict, so the file reaches no tree (§7.4):
327+///
328+/// - at a conflicted path, where a checkout wrote jj's description of the
329+/// sides and a file written since holds something else
330+/// (`FilesOnConflicts::at`). The state is first reset to a resolved file
331+/// there, so that the scan reads the file whole, as it reads any tracked
332+/// file; the path stays tracked where a `.gitignore` has come to match it.
333+/// - above one, where `base` has a directory holding a conflict, whose
334+/// differing sides the scan keeps at the path while it removes the
335+/// entries below (and a debug build fails an assertion). Such a file is
336+/// first left untracked, which scans it as if it had been moved away and
337+/// records the directory's removal; a second scan then records the file
338+/// where nothing is, as a later run would once it was put back.
339+async fn scan(
340+ wc: &mut dyn LockedWorkingCopy,
341+ root: &std::path::Path,
342+ base: &MergedTree,
343+ saved: &SavedState,
344+ options: &SnapshotOptions<'_>,
345+) -> Result<MergedTree, String> {
346+ let files = files_on_conflicts(root, base, Some(saved))?;
347+ let mut state = base.clone();
348+ if !files.at.is_empty() {
349+ // the empty file: a reset records the file at each path it changes
350+ // as an empty one, not executable, modified at the epoch, so the
351+ // scan reads any other file there, and keeps this one for such a file
352+ let store = base.store();
353+ let id = store.write_file(RepoPath::root(), &mut &[][..]).await.map_err(|e| e.to_string())?;
354+ let copy_id = CopyId::placeholder();
355+ let empty = Merge::normal(TreeValue::File { id, executable: false, copy_id });
356+ state = reset_paths(wc, &state, files.at.into_iter().map(|path| (path, empty.clone()))).await?;
357+ }
358+ if !files.over.is_empty() {
359+ let untracked = FilesMatcher::new(&files.over);
360+ let start = DifferenceMatcher::new(options.start_tracking_matcher, &untracked);
361+ let first = SnapshotOptions { start_tracking_matcher: &start, ..options.clone() };
362+ state = scan_pass(wc, root, &state, &first).await?;
363+ // a state that agrees with its tree tracks every entry below the
364+ // directory, so none is left; were a conflict left below, the
365+ // second scan would lose the file after all
366+ if let Some(path) = files_on_conflicts(root, &state, None)?.over.first() {
367+ return Err(format!(
368+ "cannot record the file `{}` in place of a directory holding a conflict",
369+ path.as_internal_file_string()
370+ ));
371+ }
372+ }
373+ scan_pass(wc, root, &state, options).await
374+}
375+
376+/// `wc.snapshot(options)`, where `state` is the tree the working copy's
377+/// state records; return the tree scanned. jj's scan reads a directory a
378+/// `.gitignore` ignores only at the paths the state tracks there, and where
379+/// it cannot look at one of them because a directory above it has been
380+/// replaced by a file, it fails instead of taking the path for deleted, as
381+/// it takes one it does not find. The paths below a file are then dropped
382+/// from the state, which records them as deleted, and the directory is
383+/// scanned again (§7.4). A scan fails so while it walks the directory,
384+/// before it changes anything in the state.
385+async fn scan_pass(
386+ wc: &mut dyn LockedWorkingCopy,
387+ root: &std::path::Path,
388+ state: &MergedTree,
389+ options: &SnapshotOptions<'_>,
390+) -> Result<MergedTree, String> {
391+ let err = match wc.snapshot(options).await {
392+ Ok((scanned, _stats)) => return Ok(scanned),
393+ Err(err) => err,
394+ };
395+ let not_a_dir = |e: &std::io::Error| e.kind() == std::io::ErrorKind::NotADirectory;
396+ let SnapshotError::Other { err: source, .. } = &err else {
397+ return Err(err.to_string());
398+ };
399+ if !source.downcast_ref::<std::io::Error>().is_some_and(not_a_dir) {
400+ return Err(err.to_string());
401+ }
402+ // one `lstat` per path the state tracks, only once a scan has failed
403+ let mut gone = Vec::new();
404+ for (path, value) in state.entries() {
405+ value.map_err(|e| e.to_string())?;
406+ let Ok(disk) = path.to_fs_path(root) else {
407+ continue;
408+ };
409+ if disk.symlink_metadata().is_err_and(|e| not_a_dir(&e)) {
410+ gone.push((path, Merge::absent()));
411+ }
412+ }
413+ if gone.is_empty() {
414+ return Err(err.to_string());
415+ }
416+ reset_paths(wc, state, gone).await?;
417+ let (scanned, _stats) = wc.snapshot(options).await.map_err(|e| e.to_string())?;
418+ Ok(scanned)
419+}
420+
421+/// Give the working copy's state, which records `state`, the tree `state`
422+/// with `values` at their paths, and return it; nothing in the working
423+/// directory is read or written. A reset takes each path it changes for one
424+/// the next scan is to read again.
425+async fn reset_paths(
426+ wc: &mut dyn LockedWorkingCopy,
427+ state: &MergedTree,
428+ values: impl IntoIterator<Item = (RepoPathBuf, jj_lib::backend::MergedTreeValue)>,
429+) -> Result<MergedTree, String> {
430+ let mut builder = MergedTreeBuilder::new(state.clone());
431+ for (path, value) in values {
432+ builder.set_or_remove(path, value);
433+ }
434+ let tree = builder.write_tree().await.map_err(|e| e.to_string())?;
435+ // a reset reads only the commit's tree
436+ let commit = with_tree(&state.store().root_commit(), tree.clone());
437+ wc.reset(&commit).await.map_err(|e| e.to_string())?;
438+ Ok(tree)
439+}
440+
441+/// Where the working directory holds a regular file that jj's scan reads
442+/// into no tree, as `tree` has a conflict there it cannot write a file into
443+struct FilesOnConflicts {
444+ /// the conflicted paths with a side that is no file (a directory, a
445+ /// symlink, a submodule), which a checkout writes as jj's description of
446+ /// the sides, whose file was written since and describes no such
447+ /// conflict
448+ at: Vec<RepoPathBuf>,
449+ /// the directories holding a conflict, that is those above a conflicted
450+ /// path: a directory's sides differ exactly when a conflict is below
451+ /// it, as jj writes no tree whose sides differ where every path resolves
452+ over: Vec<RepoPathBuf>,
453+}
454+
455+/// Where the working copy saves its state, to read back what the state a
456+/// scan's lock loaded records of a file (`files_on_conflicts`). Nothing is
457+/// read until a scan asks.
458+struct SavedState {
459+ store: Arc<Store>,
460+ root: PathBuf,
461+ path: PathBuf,
462+ settings: UserSettings,
463+}
464+
465+impl SavedState {
466+ /// Where `ws` saves its working copy's state; taken before the lock,
467+ /// which borrows `ws`
468+ fn of(ws: &Workspace) -> SavedState {
469+ let root = ws.workspace_root().to_owned();
470+ let path = match ws.working_copy().downcast_ref::<LocalWorkingCopy>() {
471+ Some(wc) => wc.state_path().to_owned(),
472+ None => root.join(".jj").join("working_copy"),
473+ };
474+ SavedState { store: ws.repo_loader().store().clone(), root, path, settings: ws.settings().clone() }
475+ }
476+
477+ /// The state as saved, where it records `tree`, with the time it was
478+ /// saved; None where it cannot be read or records another tree. A state
479+ /// file that is not there is not read, as jj would write one to read.
480+ fn load(&self, tree: &MergedTree) -> Option<(TreeState, i64)> {
481+ let meta = self.path.join("tree_state").symlink_metadata().ok()?;
482+ let saved_at = millis(meta.modified().ok()?)?;
483+ let settings = TreeStateSettings::try_from_user_settings(&self.settings).ok()?;
484+ let state = TreeState::load(self.store.clone(), self.root.clone(), self.path.clone(), &settings).ok()?;
485+ (state.current_tree().tree_ids() == tree.tree_ids()).then_some((state, saved_at))
486+ }
487+}
488+
489+/// A modification time in milliseconds since the epoch, as jj's working
490+/// copy records one
491+fn millis(time: std::time::SystemTime) -> Option<i64> {
492+ match time.duration_since(std::time::UNIX_EPOCH) {
493+ Ok(d) => i64::try_from(d.as_millis()).ok(),
494+ Err(e) => i64::try_from(e.duration().as_millis()).ok().map(|m| -m),
495+ }
496+}
497+
498+/// Whether the working copy's state, saved at `saved_at`, records `state`
499+/// for the regular file `meta` describes as it is now: jj's own test of a
500+/// file left as it was (`FileState::is_clean`, the executable bit aside),
501+/// under which it reads nothing. A file modified in the millisecond the
502+/// state was saved, or later, may have been written after it was.
503+fn left_as_recorded(state: &FileState, meta: &std::fs::Metadata, saved_at: i64) -> bool {
504+ matches!(state.file_type, FileType::Normal { .. })
505+ && state.size == meta.len()
506+ && meta.modified().ok().and_then(millis) == Some(state.mtime.0)
507+ && state.mtime.0 < saved_at
508+}
509+
510+/// The terms of a conflict that `text`, jj's description of one, names:
511+/// those removed and those added, each without the label a checkout may
512+/// write after it, with each removed that is also added cancelled against
513+/// it, as simplifying the conflict does, sorted; None for text that is no
514+/// description. Two descriptions naming the same terms describe one
515+/// conflict, whatever its labels or the sides it has lost since.
516+fn described_terms(text: &[u8]) -> Option<(Vec<&str>, Vec<&str>)> {
517+ let lines = std::str::from_utf8(text).ok()?.strip_prefix("Conflict:\n")?;
518+ if !lines.is_empty() && !lines.ends_with('\n') {
519+ return None;
520+ }
521+ let (mut removes, mut adds) = (Vec::new(), Vec::new());
522+ for line in lines.split_terminator('\n') {
523+ let (terms, term) = match (line.strip_prefix(" Removing "), line.strip_prefix(" Adding ")) {
524+ (Some(term), _) => (&mut removes, term),
525+ (_, Some(term)) => (&mut adds, term),
526+ _ => return None,
527+ };
528+ // a term ends with its object's id, which holds no space
529+ let id = term.find(" with id ")? + " with id ".len();
530+ let end = term[id..].find(' ').map_or(term.len(), |n| id + n);
531+ let (term, label) = term.split_at(end);
532+ if !label.is_empty() && !(label.starts_with(" (") && label.ends_with(')')) {
533+ return None;
534+ }
535+ terms.push(term);
536+ }
537+ removes.retain(|term| match adds.iter().position(|t| t == term) {
538+ Some(n) => {
539+ adds.swap_remove(n);
540+ false
541+ }
542+ None => true,
543+ });
544+ removes.sort_unstable();
545+ adds.sort_unstable();
546+ Some((removes, adds))
547+}
548+
549+/// Look on disk at `tree`'s conflicts for `FilesOnConflicts`; with no
550+/// `saved`, for `over` only. Nothing is looked at when `tree` is resolved;
551+/// each directory above a conflict at most once, with one `lstat`; and a
552+/// conflict with a side that is no file below directories, with one more,
553+/// and a read where its file is as long as its description. Only a file
554+/// that holds something else is looked up in the saved state, which is read
555+/// once, and read itself where the state records it otherwise.
556+fn files_on_conflicts(
557+ root: &std::path::Path,
558+ tree: &MergedTree,
559+ saved: Option<&SavedState>,
560+) -> Result<FilesOnConflicts, String> {
561+ let mut files = FilesOnConflicts { at: Vec::new(), over: Vec::new() };
562+ // whether each directory looked at so far is one on disk
563+ let mut dirs: HashMap<RepoPathBuf, bool> = HashMap::new();
564+ // the state as saved, read once a file differs from its description,
565+ // and what it records of the files, taken once
566+ let loaded: std::cell::OnceCell<Option<(TreeState, i64)>> = std::cell::OnceCell::new();
567+ let recorded: std::cell::OnceCell<Option<(FileStates<'_>, i64)>> = std::cell::OnceCell::new();
568+ for (path, value) in tree.conflicts() {
569+ let value = value.map_err(|e| e.to_string())?;
570+ let parent = path.parent().expect("a conflicted path has a parent");
571+ let above: Vec<&RepoPath> = parent.ancestors().take_while(|d| !d.is_root()).collect();
572+ // from the top down, stopping at the first that is not a directory
573+ let below_dirs = above.into_iter().rev().all(|dir| match dirs.get(dir) {
574+ Some(&is_dir) => is_dir,
575+ None => {
576+ let meta = dir.to_fs_path(root).ok().and_then(|p| p.symlink_metadata().ok());
577+ if meta.as_ref().is_some_and(|m| m.is_file()) {
578+ files.over.push(dir.to_owned());
579+ }
580+ let is_dir = meta.is_some_and(|m| m.is_dir());
581+ dirs.insert(dir.to_owned(), is_dir);
582+ is_dir
583+ }
584+ });
585+ let Some(saved) = saved.filter(|_| below_dirs && value.to_file_merge().is_none()) else {
586+ continue;
587+ };
588+ let Some((disk, meta)) = path.to_fs_path(root).ok().and_then(|disk| {
589+ let meta = disk.symlink_metadata().ok()?;
590+ meta.is_file().then_some((disk, meta))
591+ }) else {
592+ continue;
593+ };
594+ // what jj's checkout writes for it (§7.4)
595+ let written = value.describe(tree.labels());
596+ let mut bytes = None;
597+ if meta.len() == written.len() as u64 {
598+ match std::fs::read(&disk) {
599+ Ok(read) if read == written.as_bytes() => continue,
600+ Ok(read) => bytes = Some(read),
601+ Err(_) => continue,
602+ }
603+ }
604+ // the file a checkout wrote, left as it was: it describes the
605+ // conflict as the checkout had it, which the state may since
606+ // record otherwise with no file written, as a scan that drops a
607+ // pair of sides that cancel, or a reset that only relabels, does
608+ let states = recorded.get_or_init(|| {
609+ let (state, saved_at) = loaded.get_or_init(|| saved.load(tree)).as_ref()?;
610+ Some((state.file_states(), *saved_at))
611+ });
612+ if states.as_ref().is_some_and(|(file_states, saved_at)| {
613+ file_states.get(&path).is_some_and(|state| left_as_recorded(&state, &meta, *saved_at))
614+ }) {
615+ continue;
616+ }
617+ // written since, or not known to be left: the conflict is kept
618+ // while the file describes it, as when an editor saves it unchanged
619+ let Some(bytes) = bytes.or_else(|| std::fs::read(&disk).ok()) else {
620+ continue;
621+ };
622+ let unlabelled = value.describe(&jj_lib::conflict_labels::ConflictLabels::unlabeled());
623+ let terms = described_terms(unlabelled.as_bytes());
624+ if terms.is_none() || described_terms(&bytes) != terms {
625+ files.at.push(path);
626+ }
627+ }
628+ Ok(files)
629+}
630+
631+/// The tree a snapshot records in the working-copy commit, whose tree is
632+/// `wc`, where the working copy's state records `state` and a scan of the
633+/// working directory found `scanned` (§7.4): `scanned` itself where the
634+/// state is the commit's, and also where it is `current`, saved at the
635+/// operation the repository is at, which jj takes for the files last
636+/// checked out whatever it records: one lost, which jj makes again
637+/// recording no file, or one put in its place. Such a state may record none
638+/// of the commit's conflicts, so a file holding what a checkout wrote for
639+/// one is read back as it there (`conflicts_written`). Otherwise the state
640+/// is stale (a checkout that failed or was cut short after its operation
641+/// was recorded, a `fetch` that rebased the commit, jj run with
642+/// `--ignore-working-copy` or from another workspace), and what the
643+/// directory holds is a change to `state` rather than to the commit: that
644+/// change is replayed onto the commit, a three-way merge with `state` as
645+/// the base, in which a path both changed differently is a conflict, but
646+/// for one setting a directory against anything else, which takes what the
647+/// working directory holds there, as where the state is current. What a
648+/// checkout cut short wrote is the commit's own content, which the merge
649+/// takes as it is: where the directory holds what the commit does, the base
650+/// is taken to hold it too, so that a conflict the state records there,
651+/// whose sides would not cancel against the commit's, does not make one of
652+/// a change both made. That includes a conflict the checkout wrote, which
653+/// the scan read as its text (`conflicts_written`).
654+async fn snapshot_tree(
655+ root: &std::path::Path,
656+ markers: ConflictMarkerStyle,
657+ wc: &MergedTree,
658+ state: &MergedTree,
659+ current: bool,
660+ scanned: &MergedTree,
661+) -> Result<MergedTree, String> {
662+ if state.tree_ids() == wc.tree_ids() {
663+ return Ok(scanned.clone());
664+ }
665+ if current {
666+ return conflicts_written(root, markers, wc, state, scanned).await;
667+ }
668+ if scanned.tree_ids() == state.tree_ids() {
669+ return Ok(wc.clone());
670+ }
671+ let scanned = &conflicts_written(root, markers, wc, state, scanned).await?;
672+ let err = |e: jj_lib::backend::BackendError| e.to_string();
673+ // the paths where the directory holds something other than the commit
674+ let mut apart = std::collections::HashSet::new();
675+ let mut diff = wc.diff_stream(scanned, &EverythingMatcher);
676+ while let Some(entry) = diff.next().await {
677+ entry.values.map_err(err)?;
678+ apart.insert(entry.path);
679+ }
680+ // the base: the state, but for what the directory changed as the
681+ // commit did
682+ let mut base = MergedTreeBuilder::new(state.clone());
683+ let mut same = false;
684+ let mut diff = state.diff_stream(scanned, &EverythingMatcher);
685+ while let Some(entry) = diff.next().await {
686+ let Diff { after, .. } = entry.values.map_err(err)?;
687+ if !apart.contains(&entry.path) {
688+ base.set_or_remove(entry.path, after);
689+ same = true;
690+ }
691+ }
692+ let base = if same { base.write_tree().await.map_err(err)? } else { state.clone() };
693+ let merged = MergedTree::merge(Merge::from_removes_adds(
694+ vec![(base.clone(), "last checkout".to_string())],
695+ vec![
696+ (wc.clone(), "working-copy commit".to_string()),
697+ (scanned.clone(), "working directory".to_string()),
698+ ],
699+ ))
700+ .await
701+ .map_err(err)?;
702+ // A conflict setting a directory against a file, a symlink or nothing,
703+ // which jj keeps at the path itself, cannot be a blob that lists the
704+ // directory's entries (§7.3), and a checkout would write jj's
705+ // description of it in place of what the directory holds there,
706+ // removing a directory's files: where the directory changed the path,
707+ // take what it holds, as a snapshot over a current state does. The
708+ // commit's change there is left in the operation before. Where the
709+ // directory holds what the base does, the merge took the commit's value
710+ // as it is, so such a conflict is one the commit already has, and it
711+ // stays. A tree with no conflict is not walked.
712+ let mut kept = MergedTreeBuilder::new(merged.clone());
713+ let mut mixed = false;
714+ for (path, value) in merged.conflicts() {
715+ if value.map_err(err)?.iter().any(|term| matches!(term, Some(TreeValue::Tree(_)))) {
716+ let held = scanned.path_value(&path).await.map_err(err)?;
717+ if held == base.path_value(&path).await.map_err(err)? {
718+ continue;
719+ }
720+ kept.set_or_remove(path, held);
721+ mixed = true;
722+ }
723+ }
724+ if !mixed {
725+ return Ok(merged);
726+ }
727+ kept.write_tree().await.map_err(err)
728+}
729+
730+/// `scanned` with the commit's conflict at each path the working-copy state
731+/// records something else at, where the file holds what jj's checkout
732+/// writes for that conflict (`holds_written`): its markers, or its
733+/// description where a side is no file (§7.4). A checkout records the
734+/// conflict in the state as it writes the file, so a scan reads such a file
735+/// back as the conflict; one cut short, or one over a stale state, records
736+/// nothing, nor does a state made again where it was lost, and the scan
737+/// reads the file as the text it holds. Each of the commit's conflicts is
738+/// looked at, and a file read, only where neither the state nor the scan
739+/// has the conflict.
740+async fn conflicts_written(
741+ root: &std::path::Path,
742+ markers: ConflictMarkerStyle,
743+ wc: &MergedTree,
744+ state: &MergedTree,
745+ scanned: &MergedTree,
746+) -> Result<MergedTree, String> {
747+ let err = |e: jj_lib::backend::BackendError| e.to_string();
748+ let mut tree = MergedTreeBuilder::new(scanned.clone());
749+ let mut found = false;
750+ for (path, value) in wc.conflicts() {
751+ let value = value.map_err(err)?;
752+ if state.path_value(&path).await.map_err(err)? == value
753+ || scanned.path_value(&path).await.map_err(err)? == value
754+ {
755+ continue;
756+ }
757+ let Ok(disk) = path.to_fs_path(root) else {
758+ continue;
759+ };
760+ if holds_written(wc.store(), &path, value.clone(), wc.labels(), markers, &disk).await? {
761+ tree.set_or_remove(path, value);
762+ found = true;
763+ }
764+ }
765+ if !found {
766+ return Ok(scanned.clone());
767+ }
768+ tree.write_tree().await.map_err(err)
769+}
770+
771+/// Whether `disk`, the entry at `path`, holds what a checkout writes there
772+/// for `value`: a file's content or a symlink's target, whatever its
773+/// executable bit, or, for a conflict, exactly its markers, compared byte
774+/// for byte (j's settings convert no line endings), or, where a side is no
775+/// file, a description of the same sides, whatever their labels and less
776+/// any it both removes and adds, as a scan reads one back
777+/// (`files_on_conflicts`); nothing is written to the store
778+async fn holds_written(
779+ store: &Arc<Store>,
780+ path: &RepoPath,
781+ value: jj_lib::backend::MergedTreeValue,
782+ labels: &jj_lib::conflict_labels::ConflictLabels,
783+ markers: ConflictMarkerStyle,
784+ disk: &std::path::Path,
785+) -> Result<bool, String> {
786+ use jj_lib::conflicts::{self, MaterializedTreeValue};
787+ let symlink = matches!(value.as_normal(), Some(TreeValue::Symlink(_)));
788+ let Some(meta) = disk.symlink_metadata().ok().filter(|m| if symlink { m.is_symlink() } else { m.is_file() })
789+ else {
790+ return Ok(false);
791+ };
792+ let written = conflicts::materialize_tree_value(store, path, value, labels)
793+ .await
794+ .map_err(|e| e.to_string())?;
795+ match written {
796+ MaterializedTreeValue::Symlink { target, .. } => {
797+ Ok(std::fs::read_link(disk).is_ok_and(|t| t.as_os_str() == target.as_str()))
798+ }
799+ MaterializedTreeValue::File(mut file) => {
800+ let bytes = file.read_all(path).await.map_err(|e| e.to_string())?;
801+ Ok(meta.len() == bytes.len() as u64 && std::fs::read(disk).is_ok_and(|b| b == bytes))
802+ }
803+ MaterializedTreeValue::FileConflict(file) => {
804+ let options = conflicts::ConflictMaterializeOptions {
805+ marker_style: markers,
806+ marker_len: Some(conflicts::choose_materialized_conflict_marker_len(&file.contents)),
807+ merge: store.merge_options().clone(),
808+ };
809+ let bytes: Vec<u8> =
810+ conflicts::materialize_merge_result_to_bytes(&file.contents, &file.labels, &options).into();
811+ Ok(meta.len() == bytes.len() as u64 && std::fs::read(disk).is_ok_and(|b| b == bytes))
812+ }
813+ MaterializedTreeValue::OtherConflict { id, .. } => {
814+ let unlabelled = id.describe(&jj_lib::conflict_labels::ConflictLabels::unlabeled());
815+ let terms = described_terms(unlabelled.as_bytes());
816+ Ok(terms.is_some() && std::fs::read(disk).is_ok_and(|b| described_terms(&b) == terms))
817+ }
818+ _ => Ok(false),
819+ }
820+}
821+
822+/// How the working copy writes a conflict, as `settings` say
823+fn marker_style(settings: &UserSettings) -> Result<ConflictMarkerStyle, String> {
824+ Ok(TreeStateSettings::try_from_user_settings(settings).map_err(|e| e.to_string())?.conflict_marker_style)
825+}
826+
827+/// The first of the directories between the root and `path`, from the top
828+/// down, that is no directory on disk, with the type of what stands there
829+/// (none where nothing does, or it cannot be looked at), or none if each
830+/// is: a symlink is not one, and is not followed, as no directory below
831+/// one is looked at. Each directory not looked at before costs one
832+/// `lstat`, and what it found is kept in `dirs`.
833+fn first_not_a_dir(
834+ root: &std::path::Path,
835+ path: &RepoPath,
836+ dirs: &mut HashMap<RepoPathBuf, Option<std::fs::FileType>>,
837+) -> Option<(RepoPathBuf, Option<std::fs::FileType>)> {
838+ let parent = path.parent()?;
839+ let above: Vec<&RepoPath> = parent.ancestors().take_while(|d| !d.is_root()).collect();
840+ for dir in above.into_iter().rev() {
841+ let stands = match dirs.get(dir) {
842+ Some(&stands) => stands,
843+ None => {
844+ let meta = dir.to_fs_path(root).ok().and_then(|p| p.symlink_metadata().ok());
845+ let stands = meta.map(|m| m.file_type());
846+ dirs.insert(dir.to_owned(), stands);
847+ stands
848+ }
849+ };
850+ if !stands.is_some_and(|t| t.is_dir()) {
851+ return Some((dir.to_owned(), stands));
852+ }
853+ }
854+ None
855+}
856+
857+/// The first path where the working directory, scanned again as
858+/// `rescanned`, no longer holds what it held as `on_disk`, the run's
859+/// snapshot of it, and a checkout from `on_disk` to `to` writes or removes
860+/// a file: a change made while the program ran, which no snapshot holds and
861+/// jj's checkout would write over without looking (§7.5 step 7). Paths are
862+/// compared file by file, as a directory is by what it holds; one lookup
863+/// in `to` per path the directory changed at.
864+async fn written_over(
865+ on_disk: &MergedTree,
866+ rescanned: &MergedTree,
867+ to: &MergedTree,
868+) -> Result<Option<RepoPathBuf>, String> {
869+ let err = |e: jj_lib::backend::BackendError| e.to_string();
870+ let mut diff = on_disk.diff_stream(rescanned, &EverythingMatcher);
871+ while let Some(entry) = diff.next().await {
872+ let Diff { before, .. } = entry.values.map_err(err)?;
873+ // a diff has no tree on either side
874+ let focus = to.path_value(&entry.path).await.map_err(err)?;
875+ let focus = if focus.is_tree() { Merge::absent() } else { focus };
876+ if focus != before {
877+ return Ok(Some(entry.path));
878+ }
879+ }
880+ Ok(None)
881+}
882+
883+/// What stands in the way of a path a checkout adds: `holder`, in the
884+/// working directory, which does not track it, at `path`, above it, or in
885+/// a directory at it
886+struct InTheWay {
887+ path: RepoPathBuf,
888+ holder: RepoPathBuf,
889+}
890+
891+impl InTheWay {
892+ /// Whether it is a directory at `path`, holding `holder`
893+ fn is_dir(&self) -> bool {
894+ self.holder != self.path && self.holder.starts_with(&self.path)
895+ }
896+
897+ /// What a refused checkout says of it among others: a directory by its
898+ /// own path, with what it holds, anything else by its path
899+ fn named(&self) -> String {
900+ if self.is_dir() {
901+ format!(
902+ "`{}` (a directory holding untracked files, such as `{}`)",
903+ self.path.as_internal_file_string(),
904+ self.holder.as_internal_file_string()
905+ )
906+ } else {
907+ format!("`{}`", self.holder.as_internal_file_string())
908+ }
909+ }
910+}
911+
912+/// Why a checkout is refused over `in_the_way`, which is not empty, as
913+/// git names each untracked file in the way: one with the path the focus
914+/// has there, more by listing the first ten and counting the rest
915+fn in_the_way_of(in_the_way: &[InTheWay]) -> String {
916+ const LISTED: usize = 10;
917+ match in_the_way {
918+ [one] if one.is_dir() => format!(
919+ "it has `{0}`, and `{0}` in the working directory is a directory holding untracked files, such as `{1}`; move it aside",
920+ one.path.as_internal_file_string(),
921+ one.holder.as_internal_file_string()
922+ ),
923+ [one] => format!(
924+ "it has `{}`, and an untracked `{}` in the working directory is in its way; move it aside",
925+ one.path.as_internal_file_string(),
926+ one.holder.as_internal_file_string()
927+ ),
928+ _ => {
929+ let mut named: Vec<String> = in_the_way.iter().take(LISTED).map(InTheWay::named).collect();
930+ let last = match in_the_way.len() - named.len() {
931+ 0 => named.pop().unwrap_or_default(),
932+ more => format!("{} more", more),
933+ };
934+ format!(
935+ "untracked files in the working directory are in the way of files it has: {} and {}; move them aside",
936+ named.join(", "),
937+ last
938+ )
939+ }
940+ }
941+}
942+
943+/// What stands in the working directory where a checkout from `from`, the
944+/// tree the directory holds as scanned, to `to` adds a file (or a symlink,
945+/// or a conflict): at each path where `to` has one and `from` has none.
946+/// jj's checkout writes nothing over anything already there, and skips
947+/// the path without failing, recording it as written, so the next scan
948+/// would read what stands there into the focus, an ignored file's content
949+/// in place of the focus's, or take the focus's file for deleted (§7.4).
950+/// What `from` tracks there the checkout removes first; trees of empty
951+/// directories, which no scan sees, and a file holding just what the
952+/// checkout writes there (`holds_written`), as a checkout cut short leaves
953+/// at an ignored path, are cleared for it (`clear_the_way`). The result is
954+/// the paths where nothing else stands, or, walking the whole diff all the
955+/// same, each thing standing where anything else does (`InTheWay`), in
956+/// path order, what stands above several paths once. No symlink is
957+/// followed. Each path costs one `lstat`, and each directory above one not
958+/// looked at before one more; a file standing at a path is read, and a
959+/// directory read until something untracked is found in it, each entry in
960+/// it that is no directory looked up in `from`.
961+async fn look_where_added(
962+ root: &std::path::Path,
963+ markers: ConflictMarkerStyle,
964+ from: &MergedTree,
965+ to: &MergedTree,
966+) -> Result<Result<Vec<RepoPathBuf>, Vec<InTheWay>>, String> {
967+ let err = |e: jj_lib::backend::BackendError| e.to_string();
968+ let mut dirs = HashMap::new();
969+ // each thing that is no directory looked up in `from`, above a path
970+ let mut looked_up = std::collections::HashSet::new();
971+ let mut clear = Vec::new();
972+ let mut in_the_way = Vec::new();
973+ let mut diff = from.diff_stream(to, &EverythingMatcher);
974+ while let Some(entry) = diff.next().await {
975+ let Diff { before, after } = entry.values.map_err(err)?;
976+ // a submodule's directory is the submodule's own, which jj keeps
977+ if before.is_present() || after.is_absent() || matches!(after.as_normal(), Some(TreeValue::GitSubmodule(_))) {
978+ continue;
979+ }
980+ let path = entry.path;
981+ if let Some((dir, stands)) = first_not_a_dir(root, &path, &mut dirs) {
982+ // where nothing stands above the path, or what `from` tracks
983+ // and the checkout removes, nothing stands at it
984+ if stands.is_some() && looked_up.insert(dir.clone()) {
985+ let is_tracked = from.path_value(&dir).await.map_err(err)?.is_present();
986+ if !is_tracked {
987+ in_the_way.push(InTheWay { path, holder: dir });
988+ }
989+ }
990+ continue;
991+ }
992+ let Ok(disk) = path.to_fs_path(root) else {
993+ continue;
994+ };
995+ let Ok(meta) = disk.symlink_metadata() else {
996+ continue;
997+ };
998+ let holder = if meta.is_dir() {
999+ untracked_below(&disk, &path, from)
1000+ } else if holds_written(to.store(), &path, after, to.labels(), markers, &disk).await? {
1001+ None
1002+ } else {
1003+ Some(path.clone())
1004+ };
1005+ match holder {
1006+ Some(holder) => in_the_way.push(InTheWay { path, holder }),
1007+ None => clear.push(path),
1008+ }
1009+ }
1010+ if in_the_way.is_empty() {
1011+ Ok(Ok(clear))
1012+ } else {
1013+ Ok(Err(in_the_way))
1014+ }
1015+}
1016+
1017+/// Something that is no directory in the directory `disk`, standing at
1018+/// `path`, or in a directory in it, that `from` does not track, or a
1019+/// directory that cannot be read; none where it holds nothing else. Entries
1020+/// are told apart without following a symlink, and `from` is read below
1021+/// `path` only once there is something to look up.
1022+fn untracked_below(disk: &std::path::Path, path: &RepoPath, from: &MergedTree) -> Option<RepoPathBuf> {
1023+ let mut tracked: Option<std::collections::HashSet<RepoPathBuf>> = None;
1024+ let mut dirs = vec![(disk.to_owned(), path.to_owned())];
1025+ while let Some((dir, at)) = dirs.pop() {
1026+ let Ok(entries) = std::fs::read_dir(&dir) else {
1027+ return Some(at);
1028+ };
1029+ for entry in entries {
1030+ let Ok(entry) = entry else {
1031+ return Some(at);
1032+ };
1033+ let name = entry.file_name();
1034+ // a name no path of a tree holds
1035+ let Some(below) = name.to_str().and_then(|n| RepoPathComponent::new(n).ok()).map(|c| at.join(c)) else {
1036+ return Some(at);
1037+ };
1038+ if entry.file_type().is_ok_and(|t| t.is_dir()) {
1039+ dirs.push((entry.path(), below));
1040+ continue;
1041+ }
1042+ let tracked = tracked.get_or_insert_with(|| {
1043+ let under = jj_lib::matchers::PrefixMatcher::new([path]);
1044+ from.entries_matching(&under).filter(|(_, value)| value.is_ok()).map(|(p, _)| p).collect()
1045+ });
1046+ if !tracked.contains(&below) {
1047+ return Some(below);
1048+ }
1049+ }
1050+ }
1051+ None
1052+}
1053+
1054+/// Clear each of `paths`, where a checkout from the tree the working
1055+/// directory holds to `to` adds a file (`look_where_added`): a file there
1056+/// is moved aside while it holds what the checkout writes
1057+/// (`holds_written`), and in a directory there every directory holding no
1058+/// file is removed, deepest first, and it too once empty. A path below
1059+/// anything that is no directory on disk, a symlink included, is left
1060+/// alone, and no symlink in one is followed; a directory is removed only
1061+/// while empty, so a file put there meanwhile stays, as does one changed
1062+/// meanwhile, and the checkout skips the path (§7.5 step 7). So does one
1063+/// that cannot be moved, which the result names.
1064+async fn clear_the_way(
1065+ root: &std::path::Path,
1066+ markers: ConflictMarkerStyle,
1067+ to: &MergedTree,
1068+ paths: &[RepoPathBuf],
1069+) -> Aside {
1070+ fn clear(dir: &std::path::Path) {
1071+ if let Ok(entries) = std::fs::read_dir(dir) {
1072+ for entry in entries.flatten() {
1073+ if entry.file_type().is_ok_and(|t| t.is_dir()) {
1074+ clear(&entry.path());
1075+ }
1076+ }
1077+ }
1078+ let _ = std::fs::remove_dir(dir);
1079+ }
1080+ let mut aside = Aside { root: root.to_owned(), dir: None, stuck: None, returned: false };
1081+ let mut dirs = HashMap::new();
1082+ for path in paths {
1083+ if first_not_a_dir(root, path, &mut dirs).is_some() {
1084+ continue;
1085+ }
1086+ let Ok(disk) = path.to_fs_path(root) else {
1087+ continue;
1088+ };
1089+ let Ok(meta) = disk.symlink_metadata() else {
1090+ continue;
1091+ };
1092+ if meta.is_dir() {
1093+ clear(&disk);
1094+ continue;
1095+ }
1096+ let Ok(value) = to.path_value(path).await else {
1097+ continue;
1098+ };
1099+ if holds_written(to.store(), path, value, to.labels(), markers, &disk).await.unwrap_or(false) {
1100+ if let Err(e) = aside.take(path, &disk) {
1101+ aside.stuck.get_or_insert((path.clone(), e));
1102+ }
1103+ }
1104+ }
1105+ aside
1106+}
1107+
1108+/// The start of the name of each directory in `.jj` that `Aside` moves
1109+/// files into
1110+const ASIDE: &str = "aside";
1111+
1112+/// The files `clear_the_way` moved out of a checkout's way, each holding
1113+/// what the checkout writes where it stood, in a directory in `.jj` that
1114+/// holds each at its path in the working directory `root`, which no scan
1115+/// reads; the first file it could not move, with why; and whether the
1116+/// checkout returned rather than failing, and so wrote in full each file
1117+/// it wrote. Dropped, once the checkout has written what it could, it puts
1118+/// each back (`put_back`) where nothing stands now, as where a checkout
1119+/// that failed or skipped paths did not get to write, and, unless the
1120+/// checkout returned, where a start of it does, as where it was cut short
1121+/// writing it, and removes the directory with the rest: each holds just
1122+/// what the focus has at its path, which the repository keeps, and what
1123+/// stands there, what the checkout wrote or something put there since,
1124+/// stays (§7.5 step 7). One a run killed meanwhile left, the next puts
1125+/// back (`put_back_left`).
1126+struct Aside {
1127+ root: std::path::PathBuf,
1128+ dir: Option<tempfile::TempDir>,
1129+ stuck: Option<(RepoPathBuf, std::io::Error)>,
1130+ returned: bool,
1131+}
1132+
1133+impl Aside {
1134+ /// Move `disk`, the file at `path`, into the directory, made on first
1135+ /// use; renamed, it is neither copied nor followed if a symlink
1136+ fn take(&mut self, path: &RepoPath, disk: &std::path::Path) -> std::io::Result<()> {
1137+ let dir = match &mut self.dir {
1138+ Some(dir) => dir,
1139+ none => none.insert(tempfile::Builder::new().prefix(ASIDE).tempdir_in(self.root.join(".jj"))?),
1140+ };
1141+ let to = path.to_fs_path(dir.path()).map_err(std::io::Error::other)?;
1142+ if let Some(parent) = to.parent() {
1143+ std::fs::create_dir_all(parent)?;
1144+ }
1145+ std::fs::rename(disk, to)
1146+ }
1147+}
1148+
1149+impl Drop for Aside {
1150+ fn drop(&mut self) {
1151+ if let Some(dir) = self.dir.take() {
1152+ put_back(&self.root, dir.path(), !self.returned);
1153+ }
1154+ }
1155+}
1156+
1157+/// Put each file in `aside`, where `Aside` moved it, back at its path in
1158+/// the working directory `root` where nothing stands, making the
1159+/// directories above it that are missing, and, where the checkout may have
1160+/// been `cut` short, where a start of it stands, less than all of it, as a
1161+/// checkout cut short while writing it leaves (`cut_short`), which it
1162+/// replaces; one that cannot be put back, as something else stands there
1163+/// or in place of a directory above it, stays in `aside`. No symlink is
1164+/// followed.
1165+fn put_back(root: &std::path::Path, aside: &std::path::Path, cut: bool) {
1166+ let missing = |p: &std::path::Path| {
1167+ matches!(p.symlink_metadata(), Err(e) if e.kind() == std::io::ErrorKind::NotFound)
1168+ };
1169+ // whether each directory above `path` in `root` is one, or is made
1170+ let dirs_above = |path: &std::path::Path| {
1171+ let mut at = root.to_owned();
1172+ for name in path.parent().into_iter().flat_map(|p| p.components()) {
1173+ at.push(name);
1174+ let made = match at.symlink_metadata() {
1175+ Ok(meta) => meta.is_dir(),
1176+ Err(e) => e.kind() == std::io::ErrorKind::NotFound && std::fs::create_dir(&at).is_ok(),
1177+ };
1178+ if !made {
1179+ return false;
1180+ }
1181+ }
1182+ true
1183+ };
1184+ let mut dirs = vec![std::path::PathBuf::new()];
1185+ while let Some(dir) = dirs.pop() {
1186+ let Ok(entries) = std::fs::read_dir(aside.join(&dir)) else {
1187+ continue;
1188+ };
1189+ for entry in entries.flatten() {
1190+ let path = dir.join(entry.file_name());
1191+ if entry.file_type().is_ok_and(|t| t.is_dir()) {
1192+ dirs.push(path);
1193+ continue;
1194+ }
1195+ let disk = root.join(&path);
1196+ // the directories above are looked at first, so that nothing
1197+ // is looked at through a symlink
1198+ if dirs_above(&path) && (missing(&disk) || (cut && cut_short(&disk, &entry.path()))) {
1199+ let _ = std::fs::rename(entry.path(), disk);
1200+ }
1201+ }
1202+ }
1203+}
1204+
1205+/// Whether `disk` is a file holding a start of `whole`, a file moved
1206+/// aside, and less than all of it, or, where `whole` is a symlink, an
1207+/// empty file: what jj's checkout leaves where it was cut short (a full
1208+/// disk, a run killed) while it wrote `whole` there, as it makes a file
1209+/// empty before it writes it, and tries a symlink's path with an empty
1210+/// file first. Reads no more of either than `disk` holds. No symlink is
1211+/// followed.
1212+fn cut_short(disk: &std::path::Path, whole: &std::path::Path) -> bool {
1213+ use std::io::Read;
1214+ use std::os::unix::fs::OpenOptionsExt;
1215+ let (Ok(stands), Ok(copy)) = (disk.symlink_metadata(), whole.symlink_metadata()) else {
1216+ return false;
1217+ };
1218+ if !stands.is_file() {
1219+ return false;
1220+ }
1221+ if copy.is_symlink() {
1222+ return stands.len() == 0;
1223+ }
1224+ if !copy.is_file() || stands.len() >= copy.len() {
1225+ return false;
1226+ }
1227+ let stands_file = std::fs::OpenOptions::new().read(true).custom_flags(libc::O_NOFOLLOW).open(disk);
1228+ let (Ok(mut stands_file), Ok(mut copy_file)) = (stands_file, std::fs::File::open(whole)) else {
1229+ return false;
1230+ };
1231+ let (mut a, mut b) = (vec![0; 1 << 16], vec![0; 1 << 16]);
1232+ let mut rest = stands.len();
1233+ while rest > 0 {
1234+ let n = rest.min(a.len() as u64) as usize;
1235+ let same = stands_file.read_exact(&mut a[..n]).is_ok()
1236+ && copy_file.read_exact(&mut b[..n]).is_ok()
1237+ && a[..n] == b[..n];
1238+ if !same {
1239+ return false;
1240+ }
1241+ rest -= n as u64;
1242+ }
1243+ true
1244+}
1245+
1246+/// Put back what a run killed while its checkout ran left moved aside in
1247+/// `.jj` (`Aside`), over a write the kill may have cut short too, and
1248+/// remove the directory it left there. A run does this once it holds the
1249+/// repository's lock, before it looks at the working directory, so no
1250+/// other run is using such a directory.
1251+fn put_back_left(root: &std::path::Path) {
1252+ let Ok(entries) = std::fs::read_dir(root.join(".jj")) else {
1253+ return;
1254+ };
1255+ for entry in entries.flatten() {
1256+ let named = entry.file_name().to_str().is_some_and(|n| n.starts_with(ASIDE));
1257+ if named && entry.file_type().is_ok_and(|t| t.is_dir()) {
1258+ put_back(root, &entry.path(), true);
1259+ let _ = std::fs::remove_dir_all(entry.path());
1260+ }
1261+ }
1262+}
1263+
1264+/// The first path a checkout from `from` to `to` that skipped some left
1265+/// holding something other than what it writes there: with no directory
1266+/// above it, or a directory at it, or, where it adds the path, anything but
1267+/// what it writes (`holds_written`); none if no such path is found. Only
1268+/// once a checkout has skipped a path is the directory looked at again,
1269+/// reading the files it added until one differs.
1270+async fn first_skipped(
1271+ root: &std::path::Path,
1272+ markers: ConflictMarkerStyle,
1273+ from: &MergedTree,
1274+ to: &MergedTree,
1275+) -> Result<Option<RepoPathBuf>, String> {
1276+ let mut dirs = HashMap::new();
1277+ let mut diff = from.diff_stream(to, &EverythingMatcher);
1278+ while let Some(entry) = diff.next().await {
1279+ let Diff { before, after } = entry.values.map_err(|e| e.to_string())?;
1280+ if after.is_absent() || matches!(after.as_normal(), Some(TreeValue::GitSubmodule(_))) {
1281+ continue;
1282+ }
1283+ let path = entry.path;
1284+ if let Some((_, stands)) = first_not_a_dir(root, &path, &mut dirs) {
1285+ if stands.is_some() {
1286+ return Ok(Some(path));
1287+ }
1288+ continue;
1289+ }
1290+ let Ok(disk) = path.to_fs_path(root) else {
1291+ continue;
1292+ };
1293+ let Ok(meta) = disk.symlink_metadata() else {
1294+ continue;
1295+ };
1296+ if meta.is_dir()
1297+ || (before.is_absent() && !holds_written(to.store(), &path, after, to.labels(), markers, &disk).await?)
1298+ {
1299+ return Ok(Some(path));
1300+ }
1301+ }
1302+ Ok(None)
1303+}
1304+
1305+/// Save the working copy's state as a scan that found `scanned` left it:
1306+/// at `head`, the operation the repository is at, where that is the
1307+/// working-copy commit's tree `wc`; otherwise at the operation it was
1308+/// saved at before, so that it stays stale for jj too, which takes a state
1309+/// saved at the head for a current one and would record the directory
1310+/// into the commit whole (§7.4)
1311+async fn save_scan(
1312+ mut locked_ws: jj_lib::workspace::LockedWorkspace<'_>,
1313+ scanned: &MergedTree,
1314+ wc: &MergedTree,
1315+ head: &OperationId,
1316+) -> Result<(), String> {
1317+ let op_id = if scanned.tree_ids() == wc.tree_ids() {
1318+ head.clone()
1319+ } else {
1320+ locked_ws.locked_wc().old_operation_id().clone()
1321+ };
1322+ locked_ws.finish(op_id).await.map_err(|e| e.to_string())
1323+}
1324+
1325+/// The signal that asked the run to stop while its checkout was not to be
1326+/// cut short (`Deferral`), or 0
1327+static DEFERRED: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
1328+
1329+extern "C" fn defer(sig: libc::c_int) {
1330+ DEFERRED.store(sig, std::sync::atomic::Ordering::SeqCst);
1331+}
1332+
1333+/// While it lives, SIGINT, SIGTERM, SIGHUP and SIGQUIT, whichever thread
1334+/// gets them, only note that they came: it lives from the operation's
1335+/// publishing until the working copy's state is saved, so that a checkout
1336+/// the user interrupts completes, leaving the working copy current, and
1337+/// through a clone, which one that comes before the working-copy commit is
1338+/// made fails (`unless_interrupted`), so that it removes what it made
1339+/// (§1.3). A signal the process ignores stays ignored, as it does for the
1340+/// git a clone runs (`nohup j clone`, a background job). Dropping it gives
1341+/// each signal back what it did before, and `raise_deferred` then acts on
1342+/// one that came.
1343+struct Deferral {
1344+ previous: Vec<(libc::c_int, libc::sigaction)>,
1345+}
1346+
1347+impl Deferral {
1348+ fn start() -> Deferral {
1349+ let mut previous = Vec::new();
1350+ for sig in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP, libc::SIGQUIT] {
1351+ // SAFETY: the handler only stores to an atomic, which is
1352+ // async-signal-safe; both actions are initialised before use
1353+ unsafe {
1354+ let mut old: libc::sigaction = std::mem::zeroed();
1355+ if libc::sigaction(sig, std::ptr::null(), &mut old) != 0
1356+ || old.sa_sigaction == libc::SIG_IGN
1357+ {
1358+ continue;
1359+ }
1360+ let mut action: libc::sigaction = std::mem::zeroed();
1361+ action.sa_sigaction = defer as extern "C" fn(libc::c_int) as libc::sighandler_t;
1362+ action.sa_flags = libc::SA_RESTART;
1363+ libc::sigemptyset(&mut action.sa_mask);
1364+ if libc::sigaction(sig, &action, std::ptr::null_mut()) == 0 {
1365+ previous.push((sig, old));
1366+ }
1367+ }
1368+ }
1369+ Deferral { previous }
1370+ }
1371+}
1372+
1373+impl Drop for Deferral {
1374+ fn drop(&mut self) {
1375+ for (sig, old) in &self.previous {
1376+ // SAFETY: `old` is the action `sigaction` gave back for `sig`
1377+ unsafe { libc::sigaction(*sig, old, std::ptr::null_mut()) };
1378+ }
1379+ }
1380+}
1381+
1382+/// Act on a signal a checkout or a clone deferred (§1.3) as it would have
1383+/// acted then: with its default action, stopping the process (`Deferral`
1384+/// defers no signal the process ignores). Called once the run has reported
1385+/// what it did.
1386+pub fn raise_deferred() {
1387+ let sig = DEFERRED.swap(0, std::sync::atomic::Ordering::SeqCst);
1388+ if sig != 0 {
1389+ // a process a signal stops does not flush what it printed
1390+ use std::io::Write;
1391+ let _ = std::io::stdout().flush();
1392+ // SAFETY: raising a signal whose action `Deferral` gave back
1393+ unsafe { libc::raise(sig) };
1394+ }
1395+}
1396+
1397+/// `done`, the outcome of a step of a clone, unless a signal to stop came
1398+/// while `cmd_clone`'s `Deferral` held it off: the clone then fails, before
1399+/// it records anything, so that it removes what it made (§1.3, §7.8). A
1400+/// Ctrl-C at the terminal stops the git the step runs too, which fails the
1401+/// step itself: `j` may take the signal only after that, and then reports
1402+/// the step's failure.
1403+fn unless_interrupted<T>(done: Result<T, OpenError>) -> Result<T, OpenError> {
1404+ if DEFERRED.load(std::sync::atomic::Ordering::SeqCst) != 0 {
1405+ Err((1, "clone interrupted".to_string()))
1406+ } else {
1407+ done
1408+ }
1409+}
1410+
1411+/// `commit` with `tree` in place of its own, for the working copy to read:
1412+/// a checkout and a reset read only a commit's tree. It is never written.
1413+fn with_tree(commit: &Commit, tree: MergedTree) -> Commit {
1414+ let (tree_ids, labels) = tree.into_tree_ids_and_labels();
1415+ Commit::new(
1416+ commit.store().clone(),
1417+ commit.id().clone(),
1418+ Arc::new(jj_lib::backend::Commit {
1419+ root_tree: tree_ids,
1420+ conflict_labels: labels.into_merge(),
1421+ ..commit.store_commit().as_ref().clone()
1422+ }),
1423+ )
2751424}
2761425
2771426#[derive(Clone)]
2781427pub struct JjBackend {
2791428 inner: Arc<JjInner>,
1429+ replay_cache: Rc<ReplayCache>,
2801430}
2811431
2821432impl JjBackend {
@@ -284,7 +1434,7 @@ impl JjBackend {
2841434 let root = find_repo_root().ok_or_else(|| {
2851435 (
2861436 2,
287− "no jj repository found in this directory or any parent".to_string(),
1437+ "no jj repository found in this directory or any parent; run `j init` here, `j clone URL`, or `j help`".to_string(),
2881438 )
2891439 })?;
2901440 let settings = user_settings(cfg);
@@ -305,10 +1455,22 @@ impl JjBackend {
3051455 ),
3061456 ));
3071457 }
1458+ // Snapshot and checkout both apply patterns saved by `jj sparse`,
1459+ // independently of our SnapshotOptions. Accepting a partial working
1460+ // copy would silently omit disk edits and skip files during checkout.
1461+ let sparse = workspace.working_copy().sparse_patterns()
1462+ .map_err(|e| (2, format!("cannot read the working copy's sparse patterns: {}", e)))?;
1463+ if !sparse.iter().any(|path| path.is_root()) {
1464+ return Err((
1465+ 2,
1466+ "sparse working copies are not supported; run `jj sparse set --clear --add .` to include all files before running j".to_string(),
1467+ ));
1468+ }
3081469 let workspace_name = workspace.workspace_name().to_owned();
3091470 let repo = block_on(workspace.repo_loader().load_at_head())
3101471 .map_err(|e| (2, format!("cannot load the repository at head: {}", e)))?;
3111472 Ok(JjBackend {
1473+ replay_cache: Rc::new(ReplayCache::default()),
3121474 inner: Arc::new(JjInner {
3131475 workspace_root: root,
3141476 workspace: Mutex::new(workspace),
@@ -317,6 +1479,8 @@ impl JjBackend {
3171479 visible: Mutex::new(None),
3181480 lock_guard: Mutex::new(None),
3191481 pending: Mutex::new(None),
1482+ folds_case: OnceLock::new(),
1483+ long_names: Mutex::new(HashMap::new()),
3201484 }),
3211485 })
3221486 }
@@ -336,21 +1500,22 @@ impl JjBackend {
3361500 {
3371501 Ok(f) => f,
3381502 Err(e) => {
339− eprintln!("j: cannot create {}: {}", lock_path.display(), e);
1503+ crate::terminal::report_error(format_args!("j: cannot create {}: {}", lock_path.display(), e));
3401504 std::process::exit(2);
3411505 }
3421506 };
3431507 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
3441508 if rc != 0 {
345− eprintln!("j: another j is running in this repository");
1509+ crate::terminal::report_error(format_args!("j: another j is running in this repository"));
3461510 std::process::exit(2);
3471511 }
3481512 *self.inner.lock_guard.lock().unwrap() = Some(FileLock(file));
1513+ put_back_left(&self.inner.workspace_root);
3491514 }
3501515
3511516 pub fn build_interp(
3521517 &self,
353− cfg: &Config,
1518+ cfg: &mut Config,
3541519 _text: &str,
3551520 snapshot: bool,
3561521 ) -> Result<(Interp, Value, Value), OpenError> {
@@ -359,8 +1524,8 @@ impl JjBackend {
3591524 let head_repo = block_on(self.inner.workspace.lock().unwrap().repo_loader().load_at_head())
3601525 .map_err(|e| (2, format!("cannot reload the repository at head: {}", e)))?;
3611526 *self.inner.repo.lock().unwrap() = head_repo;
362− let loaded_visible = block_on(read_visible(&self.current_repo()))?;
363− let loaded = build_repo_value(&loaded_visible, &loaded_visible.wc_change_id)?;
1527+ let loaded_visible = Arc::new(block_on(read_visible(&self.current_repo()))?);
1528+ let loaded = build_repo_value(&loaded_visible, &loaded_visible.wc_change_id, &self.replay_cache)?;
3641529
3651530 let current = if snapshot {
3661531 // §7.4: snapshot the working directory into the wc commit; the
@@ -371,19 +1536,19 @@ impl JjBackend {
3711536 // no working-copy change: the repo is the one already loaded,
3721537 // so reuse its value instead of rebuilding it
3731538 None => {
374− *self.inner.visible.lock().unwrap() = Some(Arc::new(loaded_visible));
1539+ *self.inner.visible.lock().unwrap() = Some(loaded_visible.clone());
3751540 loaded.clone()
3761541 }
3771542 Some(pending) => {
3781543 *self.inner.pending.lock().unwrap() = Some(pending);
3791544 let vis = block_on(read_visible(&post))?;
380− let cur = build_repo_value(&vis, &vis.wc_change_id)?;
1545+ let cur = build_repo_value(&vis, &vis.wc_change_id, &self.replay_cache)?;
3811546 *self.inner.visible.lock().unwrap() = Some(Arc::new(vis));
3821547 cur
3831548 }
3841549 }
3851550 } else {
386− *self.inner.visible.lock().unwrap() = Some(Arc::new(loaded_visible));
1551+ *self.inner.visible.lock().unwrap() = Some(loaded_visible.clone());
3871552 loaded.clone()
3881553 };
3891554
@@ -392,35 +1557,59 @@ impl JjBackend {
3921557 cfg.shapes.clone(),
3931558 crate::value::Env::empty(),
3941559 );
1560+ // §1.2.5: resolve the config's `@prefix` literals now that the visible
1561+ // ids are known, and before anything evaluates a definition
1562+ if let Some((def, prefix, _)) =
1563+ crate::config::resolve_config_ids(cfg, &interp).into_iter().next()
1564+ {
1565+ return Err((
1566+ 3,
1567+ format!(
1568+ "config.j: `@{}` in `{}` does not resolve to a unique commit",
1569+ prefix, def
1570+ ),
1571+ ));
1572+ }
3951573 // §7.2: if the focused commit is in the immutable set, the snapshot
3961574 // cannot go into it; the focus is a new empty child of it holding the
3971575 // snapshot, and persisting records that child
398− match self.focus_if_immutable(&mut interp, cfg, &current)? {
399− Some(adjusted) => {
400− // the persisted child is part of the loaded value too, so a
401− // run that changes nothing is a no-op
402− Ok((interp, adjusted.clone(), adjusted))
1576+ match self.focus_if_immutable(&mut interp, cfg, &loaded, &current)? {
1577+ Some((loaded, current)) => {
1578+ // the snapshot's rewrite of the wc commit (and its rebase of
1579+ // the descendants) is not the repository's: lookups answer
1580+ // from the stored commits, and persisting leaves the wc
1581+ // commit as stored rather than folding the snapshot into it
1582+ *self.inner.visible.lock().unwrap() = Some(loaded_visible);
1583+ if let Some(p) = self.inner.pending.lock().unwrap().as_mut() {
1584+ p.fold_into_wc = false;
1585+ }
1586+ Ok((interp, loaded, current))
4031587 }
4041588 None => Ok((interp, loaded, current)),
4051589 }
4061590 }
4071591
408− /// If the focus of `current` is immutable, return a repo refocused on a
409− /// new empty child holding the focus's files (§7.2).
1592+ /// If the focus of `current` is immutable, return the loaded and current
1593+ /// repos refocused on a new empty child of it (§7.2). Both are `loaded`
1594+ /// with that one child added: the snapshot goes into neither the focus
1595+ /// nor, through a rebase, its descendants. The child holds the focus's
1596+ /// stored files in the loaded repo and the snapshot in the current one,
1597+ /// so a dirty `j id` differs from the loaded value and records the child
1598+ /// (§1.2 step 8).
4101599 fn focus_if_immutable(
4111600 &self,
4121601 interp: &mut Interp,
4131602 cfg: &Config,
1603+ loaded: &Value,
4141604 current: &Value,
415− ) -> Result<Option<Value>, OpenError> {
416− // evaluate the config to compute the immutable set
417− let mut probe = Interp::new(
418− Rc::new(self.clone()),
419− cfg.shapes.clone(),
420− crate::value::Env::empty(),
421− );
422− crate::config::eval_config(&mut probe, cfg).map_err(|c| (3, c.msg))?;
423− let immutable = crate::repo::compute_immutable(&mut probe, current)
1605+ ) -> Result<Option<(Value, Value)>, OpenError> {
1606+ // Evaluate the config to compute the immutable set. This uses the
1607+ // interpreter the run will go on to use, rather than a throwaway one,
1608+ // so that the `immutable` (and the `trunk` inside it) computed here is
1609+ // reused when rendering or persisting asks for the same Repo value —
1610+ // each is a full interpreted walk of the history.
1611+ crate::config::eval_config(interp, cfg).map_err(|c| (3, c.msg))?;
1612+ let immutable = crate::repo::compute_immutable(interp, current)
4241613 .map_err(|c| (1, c.msg))?;
4251614 let focus_id = match current.field("root").and_then(|r| r.field("id")) {
4261615 Ok(Value::Id(i)) => i.to_string(),
@@ -429,90 +1618,101 @@ impl JjBackend {
4291618 if !immutable.contains(&focus_id) {
4301619 return Ok(None);
4311620 }
432− // build: new empty child of the focus with the focus's files, minted id
433− let _ = interp;
434− let root = current.field("root").map_err(|c| (1, c.msg))?;
435− let files = root.field("files").map_err(|c| (1, c.msg))?;
436− let child = crate::value::Value::record(&[
437− ("files", files),
438− ("message", crate::value::Value::text("")),
439− ("labels", crate::value::Value::list(vec![])),
440− (
441− "id",
442− crate::value::Value::Id(Rc::new(probe.mint_id())),
443− ),
444− ]);
445− let child_subtree = crate::value::Value::record(&[
446− ("root", child.clone()),
447− ("children", crate::value::Value::list(vec![])),
1621+ // the focus's location in `loaded`, pre-snapshot; the child is its
1622+ // last child, so every existing child sits to the left of it
1623+ let field = |v: &Value, name: &str| v.field(name).map_err(|c| (1, c.msg));
1624+ let root = field(loaded, "root")?;
1625+ let frame = Value::record(&[
1626+ ("left", field(loaded, "children")?),
1627+ ("parent", root.clone()),
1628+ ("right", Value::list(vec![])),
4481629 ]);
449− let mut new_children: Vec<Value> = current
450− .field("children")
451− .map_err(|c| (1, c.msg))?
452− .as_list()
453− .map_err(|c| (1, c.msg))?
454− .to_vec();
455− new_children.push(child_subtree);
456− let parent = crate::value::Value::record(&[
457− ("children", crate::value::Value::list(new_children)),
458− ("context", current.field("context").map_err(|c| (1, c.msg))?),
459− ("root", root),
460− ]);
461− // refocus onto the child (it is the last child)
462− let child_id = match child.field("id").map_err(|c| (1, c.msg))? {
463− Value::Id(i) => i.to_string(),
464− _ => unreachable!(),
1630+ let mut frames = vec![frame];
1631+ frames.extend_from_slice(field(loaded, "context")?.as_list().map_err(|c| (1, c.msg))?);
1632+ let context = Value::list(frames);
1633+ let id = Value::Id(Rc::new(interp.mint_id()));
1634+ let at_child = |files: Value| {
1635+ Value::record(&[
1636+ ("children", Value::list(vec![])),
1637+ ("context", context.clone()),
1638+ (
1639+ "root",
1640+ Value::record(&[
1641+ ("files", files),
1642+ ("id", id.clone()),
1643+ ("labels", Value::list(vec![])),
1644+ ("message", Value::text("")),
1645+ ]),
1646+ ),
1647+ ])
4651648 };
466− crate::repo::by_id(&parent, &child_id)
467− .map_err(|c| (1, c.msg))?
468− .ok_or_else(|| (1, "internal: cannot refocus".to_string()))
469− .map(Some)
1649+ let current_at_child = at_child(field(&field(current, "root")?, "files")?);
1650+ // with nothing snapshotted `current` is `loaded` itself: share one
1651+ // value, so a run that changes nothing is a no-op without comparing
1652+ // the whole repository
1653+ let loaded_at_child = match (loaded, current) {
1654+ (Value::Record(a), Value::Record(b)) if Rc::ptr_eq(a, b) => current_at_child.clone(),
1655+ _ => at_child(field(&root, "files")?),
1656+ };
1657+ Ok(Some((loaded_at_child, current_at_child)))
4701658 }
4711659
472− /// Snapshot the working copy into the wc commit; returns the repo at the
473− /// snapshot operation (unpublished but written), or the pre-snapshot repo
474− /// if nothing changed.
1660+ /// Snapshot the working directory into the working-copy commit (§7.4);
1661+ /// return the repo at the snapshot's operation, written but not
1662+ /// published, or the repo as loaded when there is nothing to record.
4751663 async fn snapshot_repo(
4761664 &self,
4771665 ) -> Result<(Arc<ReadonlyRepo>, Option<PendingSnapshot>), OpenError> {
4781666 let mut ws_guard = self.inner.workspace.lock().unwrap();
479− let loader = ws_guard.repo_loader().clone();
1667+ let root = ws_guard.workspace_root().to_owned();
1668+ let saved = SavedState::of(&ws_guard);
4801669 let mut locked_ws = ws_guard.start_working_copy_mutation()
4811670 .await
4821671 .map_err(|e| (2, format!("cannot lock the working copy: {}", e)))?;
483− let options = SnapshotOptions {
484− base_ignores: GitIgnoreFile::empty(),
485− progress: None,
486− start_tracking_matcher: &EverythingMatcher,
487− force_tracking_matcher: &NothingMatcher,
488− max_new_file_size: u64::MAX,
489− };
490− let (new_tree, _stats) = locked_ws
491− .locked_wc()
492− .snapshot(&options)
1672+ let old_tree = locked_ws.locked_wc().old_tree().clone();
1673+ let scanned = scan(locked_ws.locked_wc(), &root, &old_tree, &saved, &snapshot_options())
4931674 .await
4941675 .map_err(|e| (2, format!("cannot snapshot the working copy: {}", e)))?;
495− let changed = new_tree.tree_ids() != locked_ws.locked_wc().old_tree().tree_ids();
496− if !changed {
497− locked_ws
498− .finish(self.current_repo().operation().id().clone())
1676+ // the repo build_interp loaded at head from the workspace's own
1677+ // loader, whose store the scanned tree lives in
1678+ let head = self.current_repo();
1679+ let wc_commit = self.wc_commit(&head).await?;
1680+ let current = locked_ws.locked_wc().old_operation_id() == head.operation().id();
1681+ let tree = match marker_style(head.settings()) {
1682+ Ok(markers) => snapshot_tree(&root, markers, &wc_commit.tree(), &old_tree, current, &scanned).await,
1683+ Err(e) => Err(e),
1684+ }
1685+ .map_err(|e| (2, format!("cannot snapshot the working copy: {}", e)))?;
1686+ if tree.tree_ids() == wc_commit.tree().tree_ids() {
1687+ // nothing to record: the directory holds the commit's files, or,
1688+ // where the state is stale, the state's, or changes to them the
1689+ // commit already has, such as what a checkout cut short wrote.
1690+ // What was scanned is saved, at the operation the state is
1691+ // stale since where it still is.
1692+ save_scan(locked_ws, &scanned, &wc_commit.tree(), head.operation().id())
4991693 .await
5001694 .map_err(|e| (2, format!("cannot finish the snapshot: {}", e)))?;
501− return Ok((self.current_repo(), None));
1695+ return Ok((head, None));
5021696 }
503− // the snapshotted tree lives in the workspace's store; drive the
504− // transaction from the workspace's repo loader so stores match
505− let ws_repo = loader
506− .load_at_head()
507− .await
508− .map_err(|e| (2, format!("cannot reload the repository: {}", e)))?;
509− let mut tx = ws_repo.start_transaction();
1697+ // a directory come to hold the parent's files, as where it reverts
1698+ // the commit's change, is recorded with the parent's tree (§7.4), as
1699+ // persistence writes a commit with its parent's files (§7.5 step 4):
1700+ // the snapshot builds on the commit's tree, and so could make
1701+ // another tree for the parent's files, one jj takes for a change.
1702+ // Not before the check above, or a commit already holding such a
1703+ // tree would be rewritten by a directory that has not changed. A
1704+ // merge is immutable, so never folded into (§7.2), and its parents'
1705+ // trees are not merged for it
1706+ let tree = match wc_commit.parent_ids() {
1707+ [_] => parents_tree_if_same(head.as_ref(), wc_commit.parent_ids(), tree).await,
1708+ _ => tree,
1709+ };
1710+ let mut tx = head.start_transaction();
5101711 tx.set_is_snapshot(true);
511− let wc_commit = self.wc_commit(&ws_repo).await?;
5121712 let new_wc = tx
5131713 .repo_mut()
5141714 .rewrite_commit(&wc_commit)
515− .set_tree(new_tree)
1715+ .set_tree(tree)
5161716 .write()
5171717 .await
5181718 .map_err(|e| (2, format!("cannot write the snapshot commit: {}", e)))?;
@@ -529,25 +1729,26 @@ impl JjBackend {
5291729 .write("snapshot working copy")
5301730 .await
5311731 .map_err(|e| (2, format!("cannot write the snapshot operation: {}", e)))?;
532− let op_id = unpublished.operation().id().clone();
5331732 let new_repo = unpublished.leave_unpublished();
534− // the working-copy state tracks the snapshot tree, against the
535− // current (published) head; persist will finish against its own op
536− locked_ws
537− .finish(self.current_repo().operation().id().clone())
538− .await
539− .map_err(|e| (2, format!("cannot finish the snapshot: {}", e)))?;
540− let _ = op_id;
1733+ // Release the lock *without* finishing: `finish` saves the scanned
1734+ // tree state to disk, which would record the working directory as
1735+ // already snapshotted while the published head still has the old wc
1736+ // tree. The next run's incremental snapshot would then find nothing
1737+ // to do and build the Repo from the stale tree — and a persisting run
1738+ // would check that stale tree back out, destroying the edits. The
1739+ // state is advanced only by `persist`'s checkout, which first scans
1740+ // the directory again and finishes against the operation that
1741+ // actually recorded the tree (§7.4/§7.7).
1742+ drop(locked_ws);
5411743 let pending = PendingSnapshot {
542− pre_repo: self.current_repo(),
543− new_wc_id: new_wc.id().clone(),
1744+ pre_repo: head,
5441745 tree: new_wc.tree().clone(),
1746+ fold_into_wc: true,
1747+ scanned,
5451748 };
5461749 Ok((new_repo, Some(pending)))
5471750 }
5481751
549−
550−
5511752 async fn wc_commit(&self, repo: &Arc<ReadonlyRepo>) -> Result<Commit, OpenError> {
5521753 let id = repo
5531754 .view()
@@ -565,12 +1766,13 @@ impl JjBackend {
5651766 interp: &mut Interp,
5661767 cfg: &Config,
5671768 loaded: &Value,
568− _current: &Value,
1769+ current: &Value,
5691770 new: &Value,
5701771 text: &str,
571− ) -> Result<(), Crash> {
572− if crate::value::value_eq(loaded, new)? {
573− return Ok(());
1772+ ) -> Result<bool, Crash> {
1773+ // §1.2 step 8: unchanged, up to the order of snapshot entries (§7.3)
1774+ if crate::repo::same_repo(loaded, new)? {
1775+ return Ok(false);
5741776 }
5751777 *interp.old_repo.borrow_mut() = Some(loaded.clone());
5761778 crate::repo::validate_repo(interp, new)?;
@@ -590,7 +1792,7 @@ impl JjBackend {
5901792 // walk below compares against its output, so the snapshot change and
5911793 // the expression's edits become one rewrite
5921794 let mut snapshot_wc: Option<Commit> = None;
593− if let Some(p) = &pending {
1795+ if let Some(p) = pending.as_ref().filter(|p| p.fold_into_wc) {
5941796 let wc_commit = block_on(self.wc_commit(&base)).map_err(|e| Crash::new(e.1))?;
5951797 let c = block_on(
5961798 tx.repo_mut()
@@ -614,6 +1816,9 @@ impl JjBackend {
6141816 old_stored = Arc::new(vis);
6151817 }
6161818 let mut written: BTreeMap<String, CommitId> = BTreeMap::new();
1819+ // the stored trees the program's commits were loaded with
1820+ let visible = self.inner.visible.lock().unwrap().clone();
1821+ let loaded_trees = LoadedTrees::new(visible.as_deref(), &[current, new])?;
6171822 // shared across the whole persist walk so unchanged blobs are inflated once
6181823 let cache = BlobCache::default();
6191824 let entries = EntryCache::default();
@@ -637,33 +1842,35 @@ impl JjBackend {
6371842 }
6381843 let stored_files = block_on(tree_to_files(&store, &root_jj.tree(), &cache, &entries))
6391844 .map_err(|e| Crash::new(e.msg))?;
640− if !crate::value::value_eq(
641− &Value::list(stored_files),
642− &Value::list(root_v.field("files")?.as_list()?.to_vec()),
643− )? {
1845+ if !crate::repo::snapshot_eq(&Value::list(stored_files), &root_v.field("files")?)? {
6441846 return Err(Crash::new("persistence: the root commit cannot be changed"));
6451847 }
6461848 }
6471849 written.insert(ROOT_ID.to_string(), root_jj.id().clone());
6481850
6491851 // walk `new` top-down (§7.5 step 4), parents before children
650− let mut queue: Vec<(Value, String)> = Vec::new(); // (subtree value, parent change id)
1852+ // a deque, not a Vec: `remove(0)` shifts everything after it, which
1853+ // is quadratic in the number of commits for a wide history
1854+ let mut queue: std::collections::VecDeque<(Value, String)> =
1855+ std::collections::VecDeque::new(); // (subtree value, parent change id)
6511856 for kid in top.field("children")?.as_list()?.iter() {
652− queue.push((kid.clone(), ROOT_ID.to_string()));
1857+ queue.push_back((kid.clone(), ROOT_ID.to_string()));
6531858 }
654− while !queue.is_empty() {
655− let (subtree, parent_change) = queue.remove(0);
1859+ while let Some((subtree, parent_change)) = queue.pop_front() {
6561860 let commit_v = subtree.field("root")?;
6571861 let id = commit_id_of(&commit_v)?;
6581862 let parent_jj = written
6591863 .get(&parent_change)
6601864 .cloned()
6611865 .ok_or_else(|| Crash::new("persistence: internal: parent not yet written"))?;
662− let files_v = commit_v.field("files")?;
1866+ // not read unless compared or written: most commits keep the
1867+ // stored tree they were loaded with
1868+ let files_v = crate::repo::files_of(&commit_v)?;
6631869 let message = commit_v.field("message")?.as_text()?.to_string();
6641870 let new_id = match old_stored.commits.get(&id) {
6651871 None => {
666− let tree = block_on(build_tree(&store, &files_v))?;
1872+ let tree =
1873+ loaded_trees.tree_to_write(tx.repo(), &files_v, std::slice::from_ref(&parent_jj))?;
6671874 let change_id = jj_lib::backend::ChangeId::try_from_reverse_hex(&id).ok_or_else(|| {
6681875 Crash::new(format!("persistence: `{}` is not a valid change id", id))
6691876 })?;
@@ -682,17 +1889,31 @@ impl JjBackend {
6821889 Some(stored) => {
6831890 let stored_commit = &stored.commit;
6841891 let parent_changed = hex_of(stored_commit.parent_ids().first()) != parent_jj.hex();
685− let files_changed = {
686− let stored_files =
687− block_on(tree_to_files(&store, &stored_commit.tree(), &cache, &entries))?;
688− !crate::value::value_eq(
689− &Value::list(stored_files),
690− &Value::list(files_v.as_list()?.to_vec()),
691− )?
692− };
1892+ let stored_tree = stored_commit.tree();
1893+ // loaded from the stored tree, the files are unchanged:
1894+ // reading every commit's to compare them made each
1895+ // persist read the files of the whole history
1896+ let files_changed = crate::repo::stored_tree(&files_v)
1897+ != Some(tree_name(&stored_tree).as_str())
1898+ && {
1899+ let stored_files =
1900+ block_on(tree_to_files(&store, &stored_tree, &cache, &entries))?;
1901+ // a tree has no order: entries listed differently
1902+ // are not a change to rewrite (§7.3)
1903+ !crate::repo::snapshot_eq(&Value::list(stored_files), &files_v)?
1904+ };
6931905 let msg_changed = stored_commit.description() != message;
6941906 if parent_changed || files_changed || msg_changed {
695− let tree = block_on(build_tree(&store, &files_v))?;
1907+ // files the stored tree lists are written as it is,
1908+ // but where they are the parent's, as the files of a
1909+ // commit moved onto another tree for them come to be,
1910+ // with the parent's tree (§7.5 step 4)
1911+ let parents = std::slice::from_ref(&parent_jj);
1912+ let tree = if files_changed {
1913+ loaded_trees.tree_to_write(tx.repo(), &files_v, parents)?
1914+ } else {
1915+ block_on(parents_tree_if_same(tx.repo(), parents, stored_tree))
1916+ };
6961917 let c = block_on(
6971918 tx.repo_mut()
6981919 .rewrite_commit(stored_commit)
@@ -711,7 +1932,7 @@ impl JjBackend {
7111932 };
7121933 written.insert(id.clone(), new_id);
7131934 for kid in subtree.field("children")?.as_list()?.iter() {
714− queue.push((kid.clone(), id.clone()));
1935+ queue.push_back((kid.clone(), id.clone()));
7151936 }
7161937 }
7171938
@@ -755,37 +1976,230 @@ impl JjBackend {
7551976 let desc = truncate_chars(text, 200);
7561977 let unpublished = block_on(tx.write(desc))
7571978 .map_err(|e| Crash::new(format!("cannot write the operation: {}", e)))?;
758− let op_id = unpublished.operation().id().clone();
759− let _new_repo = block_on(unpublished.publish())
760− .map_err(|e| Crash::new(format!("cannot publish the operation: {}", e)))?;
7611979
762− // §7.4/§7.5 step 7: check out the focus to the working directory
1980+ // §7.4/§7.5 step 7: publish the operation, then check the focus out
7631981 let focus_commit = block_on(store.get_commit_async(&focus_jj))
7641982 .map_err(|e| Crash::new(format!("cannot read the focus commit: {}", e)))?;
765− block_on(self.checkout(&focus_commit, op_id))?;
766−
767− Ok(())
1983+ // the checkout starts from what the working directory holds: the
1984+ // tree the run's snapshot scanned, or, where it had nothing to
1985+ // record, the tree the working-copy state records, as
1986+ // `snapshot_repo` left it. That is the focus as loaded unless the
1987+ // state is stale (§7.4), when the checkout writes the focus over it;
1988+ // taking the directory to hold the focus instead, it would find it
1989+ // did not, describe the focus without writing it (a reset), and the
1990+ // next run would record the stale files into the focus.
1991+ let on_disk = match pending {
1992+ Some(p) => p.scanned,
1993+ None => {
1994+ let state = self.inner.workspace.lock().unwrap().working_copy().tree().cloned();
1995+ // a state that cannot be read fails the checkout, which
1996+ // reads it again when it locks
1997+ state.or_else(|_| block_on(self.wc_commit(&base)).map(|c| c.tree()).map_err(|e| Crash::new(e.1)))?
1998+ }
1999+ };
2000+ block_on(self.checkout(&focus_commit, Some(&on_disk), Some(unpublished), Some(", and `j undo` goes back")))?;
2001+ Ok(true)
7682002 }
7692003
770− async fn checkout(&self, commit: &Commit, op_id: OperationId) -> Result<(), Crash> {
2004+ /// Check `commit` out: write its files to the working directory and
2005+ /// save the working copy's state as holding them (§7.4). `op`, the
2006+ /// operation that makes it the working-copy commit, is published first,
2007+ /// as jj does (§7.5 step 7); with none, `commit` is the head's own.
2008+ /// `on_disk` is the tree the directory holds, which the checkout starts
2009+ /// from: what a persisting run's snapshot scanned, or the tree the
2010+ /// working-copy state records where it scanned nothing to record, or
2011+ /// what `undo`'s look found; with none, the state as it is. A change
2012+ /// made to the directory since `on_disk` was scanned, where the
2013+ /// checkout writes, refuses the checkout before `op` is published
2014+ /// (`written_over`); one made once it has begun is written over, as by
2015+ /// jj. Where the commit adds a file, something `on_disk` does not track
2016+ /// standing there, but for empty directories and what the checkout
2017+ /// writes, refuses the checkout before `op` is published
2018+ /// (`look_where_added`), and one put there since fails it after, as it
2019+ /// makes jj skip the path. A checkout that fails, or is cut short, after
2020+ /// `op` is published leaves `op` recorded and the state stale, recording
2021+ /// `on_disk`: the next run takes what the checkout wrote for the
2022+ /// commit's own and carries on (`snapshot_tree`), as the crash says,
2023+ /// with `back` naming the way back; with no `back` it says nothing of
2024+ /// the operation (a clone that fails removes it).
2025+ async fn checkout(
2026+ &self,
2027+ commit: &Commit,
2028+ on_disk: Option<&MergedTree>,
2029+ op: Option<UnpublishedOperation>,
2030+ back: Option<&str>,
2031+ ) -> Result<(), Crash> {
7712032 let mut ws_guard = self.inner.workspace.lock().unwrap();
2033+ let root = ws_guard.workspace_root().to_owned();
2034+ let saved = SavedState::of(&ws_guard);
7722035 let mut locked_ws = ws_guard.start_working_copy_mutation()
7732036 .await
7742037 .map_err(|e| Crash::new(format!("cannot lock the working copy: {}", e)))?;
775− locked_ws
776− .locked_wc()
777− .check_out(commit)
778− .await
2038+ // A persisting run's snapshot released its lock without saving what
2039+ // it scanned, so the state just loaded may still describe the tree
2040+ // recorded before the edits, and jj checks out by diffing that tree
2041+ // against the new one: a path whose recorded content equals the
2042+ // focus's would keep the user's edit, a file created since would
2043+ // stay, and a file replaced by a directory (or back) would leave
2044+ // state contradicting the tree. Scan the directory again first, as
2045+ // the snapshot did, so the checkout diffs from what is actually on
2046+ // disk (§7.4). A scan keeps what the state knows of each file where
2047+ // a reset to the snapshot's tree would forget it: a conflict whose
2048+ // sides hold marker-like lines is materialized with longer markers,
2049+ // and a forgotten length makes the next snapshot read those markers
2050+ // back at 7 characters, fail to parse them and record the edited
2051+ // conflict as resolved text, markers and all (§7.4).
2052+ if let Some(on_disk) = on_disk {
2053+ let old_tree = locked_ws.locked_wc().old_tree().clone();
2054+ let reset = match scan(locked_ws.locked_wc(), &root, &old_tree, &saved, &snapshot_options()).await {
2055+ Ok(scanned) if scanned.tree_ids() == on_disk.tree_ids() => false,
2056+ // the directory changed while the program ran: where the
2057+ // checkout would write the focus over such a change, which
2058+ // no snapshot holds and jj's checkout does not look for,
2059+ // the run crashes before anything is recorded or written
2060+ // (§7.5 step 7)
2061+ Ok(scanned) => match written_over(on_disk, &scanned, &commit.tree()).await {
2062+ Ok(None) => true,
2063+ Ok(Some(path)) => {
2064+ return Err(Crash::new(format!(
2065+ "cannot check out the focus: `{}` changed in the working directory while the program ran, and the checkout would write over it; nothing was recorded, so run the command again",
2066+ path.as_internal_file_string()
2067+ )));
2068+ }
2069+ Err(e) => return Err(Crash::new(format!("cannot check out the focus: {}", e))),
2070+ },
2071+ // or it can no longer be scanned (an entry appeared,
2072+ // vanished or cannot be read; a failed scan leaves the state
2073+ // as it was)
2074+ Err(_) => true,
2075+ };
2076+ // Otherwise the state describes `on_disk` instead. A reset
2077+ // writes no file; the paths it changes are re-read by the next
2078+ // run.
2079+ if reset {
2080+ locked_ws
2081+ .locked_wc()
2082+ .reset(&with_tree(commit, on_disk.clone()))
2083+ .await
2084+ .map_err(|e| Crash::new(format!("cannot reset the working copy: {}", e)))?;
2085+ }
2086+ }
2087+ // Where the focus adds a file and something the directory does not
2088+ // track stands (an ignored file, a symlink, a directory holding
2089+ // one), jj's checkout would skip the path, and the next run read
2090+ // what stands there into the focus: refuse before anything is
2091+ // recorded or written, as git does (§7.5 step 7). The trees of
2092+ // empty directories found there, and the files holding what the
2093+ // checkout writes, are cleared once `op` is published.
2094+ let markers = marker_style(self.current_repo().settings())
7792095 .map_err(|e| Crash::new(format!("cannot check out the focus: {}", e)))?;
780− locked_ws
781− .finish(op_id)
782− .await
783− .map_err(|e| Crash::new(format!("cannot finish the checkout: {}", e)))?;
2096+ let clear = match on_disk {
2097+ None => Vec::new(),
2098+ Some(on_disk) => match look_where_added(&root, markers, on_disk, &commit.tree()).await {
2099+ Ok(Ok(clear)) => clear,
2100+ Ok(Err(in_the_way)) => {
2101+ return Err(Crash::new(format!("cannot check out the focus: {}", in_the_way_of(&in_the_way))));
2102+ }
2103+ Err(e) => return Err(Crash::new(format!("cannot check out the focus: {}", e))),
2104+ },
2105+ };
2106+ // from here until the state is saved, a signal to stop waits
2107+ let _deferral = Deferral::start();
2108+ let published = op.is_some();
2109+ let op_id = match op {
2110+ None => self.current_repo().operation().id().clone(),
2111+ Some(op) => {
2112+ let repo = op
2113+ .publish()
2114+ .await
2115+ .map_err(|e| Crash::new(format!("cannot publish the operation: {}", e)))?;
2116+ let op_id = repo.operation().id().clone();
2117+ *self.inner.repo.lock().unwrap() = repo;
2118+ op_id
2119+ }
2120+ };
2121+ // what a crash from here on says: the state is left stale, which the
2122+ // next run carries on from (§7.5 step 7)
2123+ let carry_on = |what: &str| match back {
2124+ None => String::new(),
2125+ Some(back) if published => {
2126+ format!("; the operation is recorded, but {}: the next command carries on from there{}", what, back)
2127+ }
2128+ Some(_) => format!("; {}: the next command carries on from there", what),
2129+ };
2130+ // A checkout cut short leaves the directory holding `on_disk` but
2131+ // where it wrote, and the next run takes what it holds for changes
2132+ // to the state (`snapshot_tree`). So the state records `on_disk`
2133+ // first, at the operation it was saved at, which leaves it stale
2134+ // until the checkout completes: were it left recording the tree
2135+ // before the run's snapshot, the snapshot's changes, now in the
2136+ // operation, would be taken for new ones and replayed onto the
2137+ // focus too. It is saved only once `op` is published: a state
2138+ // recording the snapshot, beside a head that does not, would lose
2139+ // it (§7.4).
2140+ if on_disk.is_some_and(|t| t.tree_ids() != locked_ws.locked_wc().old_tree().tree_ids()) {
2141+ let old_op = locked_ws.locked_wc().old_operation_id().clone();
2142+ let saved = match locked_ws.finish(old_op).await {
2143+ Ok(()) => ws_guard.start_working_copy_mutation().await.map_err(|e| e.to_string()),
2144+ Err(e) => Err(e.to_string()),
2145+ };
2146+ locked_ws = match saved {
2147+ Ok(locked) => locked,
2148+ Err(e) => {
2149+ let what = "the working directory was not updated";
2150+ return Err(Crash::new(format!("cannot save the working copy's state: {}{}", e, carry_on(what))));
2151+ }
2152+ };
2153+ }
2154+ // what is moved aside is put back where the checkout does not write,
2155+ // or cuts the write short, once it returns, however it returns
2156+ let mut aside = clear_the_way(&root, markers, &commit.tree(), &clear).await;
2157+ let from = locked_ws.locked_wc().old_tree().clone();
2158+ let what = "the working directory was only partly updated";
2159+ let stats = match locked_ws.locked_wc().check_out(commit).await {
2160+ Ok(stats) => stats,
2161+ Err(e) => return Err(Crash::new(format!("cannot check out the focus: {}{}", e, carry_on(what)))),
2162+ };
2163+ // it cut no write short, so a start of a file moved aside, standing
2164+ // at its path, was put there meanwhile
2165+ aside.returned = true;
2166+ // Something the directory does not track, put where the focus adds a
2167+ // file since it was looked at, or a file holding what the checkout
2168+ // writes there that could not be moved aside, made jj skip the
2169+ // path, which it records as written. The state is left stale, as by
2170+ // a checkout that fails, so that no run reads what stands there
2171+ // (§7.5 step 7).
2172+ if stats.skipped_files > 0 {
2173+ let skipped = first_skipped(&root, markers, &from, &commit.tree()).await.ok().flatten();
2174+ let why = match (skipped, &aside.stuck) {
2175+ (Some(path), _) => format!(
2176+ "`{}` was not written, as something untracked in the working directory was in its way (move it aside)",
2177+ path.as_internal_file_string()
2178+ ),
2179+ (None, Some((path, e))) => format!(
2180+ "`{}` was not written, as the untracked file there, holding what it writes, could not be moved out of its way: {}",
2181+ path.as_internal_file_string(),
2182+ e
2183+ ),
2184+ (None, None) => format!(
2185+ "{} of its files were not written, as something untracked in the working directory was in their way",
2186+ stats.skipped_files
2187+ ),
2188+ };
2189+ return Err(Crash::new(format!("cannot check out the focus: {}{}", why, carry_on(what))));
2190+ }
2191+ // the state names the operation it was checked out at, so it is
2192+ // saved only once that operation is published
2193+ if let Err(e) = locked_ws.finish(op_id).await {
2194+ let what = "the working copy's record of what the directory holds was not updated";
2195+ return Err(Crash::new(format!("cannot save the working copy's state: {}{}", e, carry_on(what))));
2196+ }
7842197 Ok(())
7852198 }
7862199
7872200 fn user_signature(&self, cfg: &Config) -> Result<Signature, Crash> {
788− let (name, email) = crate::config::eval_user_only(&mut Interp::dummy(), cfg)?;
2201+ let (name, email) = validated_identity(cfg)
2202+ .map_err(|(_, message)| Crash::new(message))?;
7892203 Ok(Signature {
7902204 name,
7912205 email,
@@ -878,6 +2292,7 @@ impl JjBackend {
8782292 }
8792293
8802294 pub fn cmd_remote(&self, url: &str) -> Result<(), OpenError> {
2295+ let url = &origin_url(url)?;
8812296 self.take_lock();
8822297 let base = self.current_repo();
8832298 let origin = RemoteName::new("origin");
@@ -895,7 +2310,17 @@ impl JjBackend {
8952310 .map_err(|e| (1, format!("cannot set the remote: {}", e)))
8962311 }
8972312
2313+ /// Run from the top of the workspace, as git runs from the top of its
2314+ /// working tree: git resolves a relative local URL (one stored by git,
2315+ /// or by `j` before §7.8 resolved them) against the directory it runs
2316+ /// in, and jj-lib's git and ours inherit this process's
2317+ fn enter_workspace_root(&self) -> Result<(), OpenError> {
2318+ std::env::set_current_dir(&self.inner.workspace_root)
2319+ .map_err(|e| (1, format!("cannot enter {}: {}", self.inner.workspace_root.display(), e)))
2320+ }
2321+
8982322 pub fn cmd_fetch(&self) -> Result<(), OpenError> {
2323+ self.enter_workspace_root()?;
8992324 let origin = RemoteName::new("origin");
9002325 let base = self.head_repo()?;
9012326 let mut tx = base.start_transaction();
@@ -908,27 +2333,52 @@ impl JjBackend {
9082333 let _ = block_on(self.publish_tx(tx, "fetch"))?;
9092334 Ok(())
9102335 }
911− pub fn cmd_push(&self, cfg: &Config, expr_text: &str) -> Result<(), OpenError> {
2336+ pub fn cmd_push(&self, cfg: &mut Config, expr_text: &str) -> Result<PushOutcome, OpenError> {
2337+ self.enter_workspace_root()?;
9122338 let origin = RemoteName::new("origin");
9132339 let base = self.head_repo()?;
914− let (value, vis) = self.eval_push_expr(cfg, expr_text, &base)?;
2340+ let (value, vis, immutable) = self.eval_push_expr(cfg, expr_text, &base)?;
9152341 let records = parse_push_records(&value, &vis, &base)?;
916− check_push_records(&records, &vis, &base)?;
917− let _ = self.push_to_origin(&base, origin, &records, expr_text)?;
918− Ok(())
2342+ if records.is_empty() {
2343+ return Ok(PushOutcome::default());
2344+ }
2345+ let deletes_wait = check_push_records(&records, &base, &vis, &immutable)?;
2346+ let mut outcome = PushOutcome::default();
2347+ for (name, target) in &records {
2348+ let before = base.view()
2349+ .get_remote_bookmark(RefName::new(name).to_remote_symbol(origin))
2350+ .target.as_resolved().cloned().unwrap_or(None);
2351+ if &before == target {
2352+ outcome.unchanged += 1;
2353+ } else if target.is_none() {
2354+ outcome.deleted += 1;
2355+ } else {
2356+ outcome.published += 1;
2357+ }
2358+ }
2359+ let (_, clean) = self.inspect_working_copy(&base, false)?;
2360+ if !clean {
2361+ crate::terminal::report(format_args!(
2362+ "j: warning: working copy has unrecorded changes; push sends recorded content only, so these edits will not be sent. Run `j id` to record them, then push again to include them."
2363+ ));
2364+ }
2365+ let description = format!("push {}", expr_text);
2366+ let _ = self.push_to_origin(&base, origin, &records, deletes_wait, &description)?;
2367+ Ok(outcome)
9192368 }
9202369
9212370 /// evaluate `EXPR` against the recorded repository, without snapshotting
922− /// (§1.1), apply a function result to the repo value once (§7.6)
2371+ /// (§1.1), apply a function result to the repo value once (§7.6); also
2372+ /// return the immutable set of that repository (§7.5 step 3)
9232373 fn eval_push_expr(
9242374 &self,
925− cfg: &Config,
2375+ cfg: &mut Config,
9262376 expr_text: &str,
9272377 base: &Arc<ReadonlyRepo>,
928− ) -> Result<(Value, Arc<VisibleRepo>), OpenError> {
2378+ ) -> Result<(Value, Arc<VisibleRepo>, BTreeSet<String>), OpenError> {
9292379 let outer = Rc::new(cfg.global_names.clone());
9302380 let expr = crate::parse::parse_expr(expr_text, outer)
931− .map_err(|p| (3, format!("line {}: {}", p.line, p.msg)))?;
2381+ .map_err(|p| (3, crate::diagnostics::format_source_error("expression", expr_text, p.line, &p.msg)))?;
9322382 let (mut interp, _loaded, current) = self.build_interp(cfg, expr_text, false)?;
9332383 let expr = crate::config::resolve_ids(&expr, &interp).map_err(|failures| {
9342384 let (prefix, candidates) = &failures[0];
@@ -939,15 +2389,30 @@ impl JjBackend {
9392389 }
9402390 })?;
9412391 crate::config::eval_config(&mut interp, cfg).map_err(|c| (3, format!("config.j: {}", c.msg)))?;
2392+ // the set a push may not move a bookmark out of (§7.6); build_interp
2393+ // evaluated it for this value to place the focus, so it is cached
2394+ let immutable = crate::repo::compute_immutable(&mut interp, &current)
2395+ .map_err(|c| (1, format!("crash: {}", c.msg)))?;
2396+ let expression_error = |crash: Crash| {
2397+ let mut message = format!("crash: {}", crash.msg);
2398+ match &crash.def {
2399+ Some(name) => message.push_str(&format!("\n in {}, from push {}", name, expr_text)),
2400+ None => message.push_str(&format!("\n from push {}", expr_text)),
2401+ }
2402+ if let Some(hint) = crate::diagnostics::push_expression_hint(expr_text, cfg, &crash) {
2403+ message.push_str(&format!("\n {}", hint));
2404+ }
2405+ (1, message)
2406+ };
9422407 let env = interp.global_env();
9432408 let expr_rc = Rc::new(expr);
9442409 let mut v = interp
9452410 .eval(&expr_rc, &env)
946− .map_err(|c| (1, format!("crash: {}", c.msg)))?;
2411+ .map_err(&expression_error)?;
9472412 if matches!(v, Value::Fun(_)) {
9482413 v = interp
9492414 .apply(v, current)
950− .map_err(|c| (1, format!("crash: {}", c.msg)))?;
2415+ .map_err(expression_error)?;
9512416 }
9522417 let _ = base;
9532418 let vis = self
@@ -957,19 +2422,28 @@ impl JjBackend {
9572422 .unwrap()
9582423 .clone()
9592424 .ok_or_else(|| (2, "internal: repository value not loaded".to_string()))?;
960− Ok((v, vis))
2425+ Ok((v, vis, immutable))
9612426 }
9622427
9632428 /// push the updates to `origin` and record the new remote-bookmark
964− /// positions as one operation (§7.6)
2429+ /// positions as one operation (§7.6); when `deletes_wait`, the deletes
2430+ /// go in a second git push, sent only once the remote has accepted every
2431+ /// other update
9652432 fn push_to_origin(
9662433 &self,
9672434 base: &Arc<ReadonlyRepo>,
9682435 origin: &RemoteName,
9692436 records: &[(String, Option<CommitId>)],
2437+ deletes_wait: bool,
9702438 description: &str,
9712439 ) -> Result<Arc<ReadonlyRepo>, OpenError> {
2440+ // Never hand an empty refspec list to git, whose push.default could
2441+ // select branches the expression did not request.
2442+ if records.is_empty() {
2443+ return Ok(base.clone());
2444+ }
9722445 let mut targets = jj_lib::git::GitPushRefTargets::default();
2446+ let mut deletes = jj_lib::git::GitPushRefTargets::default();
9732447 for (name, after) in records {
9742448 let before = base
9752449 .view()
@@ -978,48 +2452,116 @@ impl JjBackend {
9782452 .as_resolved()
9792453 .cloned()
9802454 .unwrap_or(None);
981− targets
2455+ // check_push_records sets `deletes_wait` only when some record
2456+ // sets a bookmark, so the first push is never empty
2457+ let batch = if deletes_wait && after.is_none() { &mut deletes } else { &mut targets };
2458+ batch
9822459 .bookmarks
9832460 .push((jj_lib::ref_name::RefNameBuf::from(name.clone()), Diff::new(before, after.clone())));
9842461 }
9852462 let mut tx = base.start_transaction();
9862463 let subprocess_options = self.git_subprocess_options()?;
9872464 let mut callback = QuietGitCallback;
988− let stats = jj_lib::git::push_refs(
989− tx.repo_mut(),
990− subprocess_options,
991− origin,
992− &targets,
993− &mut callback,
994− &jj_lib::git::GitPushOptions::default(),
995− )
996− .map_err(|e| match e {
997− jj_lib::git::GitPushError::NoSuchRemote(_) => (1, "no remote origin".to_string()),
998− other => (1, format!("push failed: {}", other)),
999− })?;
2465+ let mut push = |tx: &mut jj_lib::transaction::Transaction, targets| {
2466+ jj_lib::git::push_refs(
2467+ tx.repo_mut(),
2468+ subprocess_options.clone(),
2469+ origin,
2470+ targets,
2471+ &mut callback,
2472+ &jj_lib::git::GitPushOptions::default(),
2473+ )
2474+ .map_err(|e| match e {
2475+ jj_lib::git::GitPushError::NoSuchRemote(_) => (1, "no remote origin".to_string()),
2476+ other => (1, format!("push failed: {}", other)),
2477+ })
2478+ };
2479+ let mut stats = push(&mut tx, &targets)?;
2480+ // git push is not atomic: the remote may accept some updates and
2481+ // reject others. When only a bookmark this push sets keeps a deleted
2482+ // bookmark's immutable commit reachable, the deletes go in a second
2483+ // push once the remote has accepted every update, and are not sent
2484+ // otherwise (§7.6)
2485+ let mut not_sent: Vec<String> = Vec::new();
2486+ let mut failed = None;
2487+ if !deletes.bookmarks.is_empty() {
2488+ if stats.all_ok() {
2489+ match push(&mut tx, &deletes) {
2490+ Ok(more) => {
2491+ stats.pushed.extend(more.pushed);
2492+ stats.rejected.extend(more.rejected);
2493+ stats.remote_rejected.extend(more.remote_rejected);
2494+ stats.unexported_bookmarks.extend(more.unexported_bookmarks);
2495+ }
2496+ Err(e) => failed = Some(e),
2497+ }
2498+ } else {
2499+ for (name, _) in &deletes.bookmarks {
2500+ not_sent.push(format!("refs/heads/{}", name.as_str()));
2501+ }
2502+ }
2503+ }
2504+ // push_refs has set the accepted updates in `tx`, and they are on the
2505+ // remote, so they are recorded before the rest is reported; a push
2506+ // the remote refused entirely records nothing
2507+ let mut repo = base.clone();
2508+ if stats.all_ok() || stats.some_exported() {
2509+ let unpublished = block_on(tx.write(truncate_chars(description, 200)))
2510+ .map_err(|e| (1, format!("cannot write the operation: {}", e)))?;
2511+ repo = block_on(unpublished.publish())
2512+ .map_err(|e| (1, format!("cannot publish the operation: {}", e)))?;
2513+ *self.inner.repo.lock().unwrap() = repo.clone();
2514+ }
2515+ if let Some((code, msg)) = failed {
2516+ return Err((code, format!("{}; the other bookmarks were pushed and recorded", msg)));
2517+ }
10002518 if !stats.all_ok() {
10012519 let mut names: Vec<String> = Vec::new();
10022520 for (name, _) in stats.rejected.iter().chain(stats.remote_rejected.iter()) {
10032521 names.push(name.as_str().to_string());
10042522 }
1005− return Err((1, format!("push rejected: {}", names.join(", "))));
2523+ // pushed, but not exported to the colocated git repo, so jj-lib
2524+ // left them out of `tx`
2525+ for (symbol, _) in &stats.unexported_bookmarks {
2526+ names.push(format!("{} (pushed but not recorded)", symbol.name.as_str()));
2527+ }
2528+ let mut msg = format!("push rejected: {}", names.join(", "));
2529+ if !not_sent.is_empty() {
2530+ msg.push_str(&format!("; deletes not sent: {}", not_sent.join(", ")));
2531+ }
2532+ if stats.some_exported() {
2533+ msg.push_str("; the other bookmarks were pushed and recorded");
2534+ }
2535+ return Err((1, msg));
10062536 }
1007− let unpublished = block_on(tx.write(truncate_chars(description, 200)))
1008− .map_err(|e| (1, format!("cannot write the operation: {}", e)))?;
1009− let repo = block_on(unpublished.publish())
1010− .map_err(|e| (1, format!("cannot publish the operation: {}", e)))?;
1011− *self.inner.repo.lock().unwrap() = repo.clone();
10122537 Ok(repo)
10132538 }
10142539
1015− pub fn cmd_undo(&self, redo: bool) -> Result<(), OpenError> {
2540+ pub fn cmd_undo(&self, redo: bool) -> Result<String, OpenError> {
10162541 let base = self.head_repo()?;
1017− self.check_wc_clean(&base)?;
2542+ let on_disk = self.check_wc_clean(&base)?;
10182543 let loader = self.repo_loader();
10192544 let mut cur = base.operation().clone();
10202545 let target_op_id: OperationId;
10212546 loop {
1022− match (op_marker(cur.metadata()), redo) {
2547+ let marker = op_marker(cur.metadata());
2548+ if let Some(m) = marker.as_ref().filter(|m| m.restores_view) {
2549+ // jj 0.45 names the operation whose view was restored.
2550+ // Repeating the same direction follows that target; reversing
2551+ // direction restores the marker's parent. The target may
2552+ // itself be an undo or redo, not just a normal operation.
2553+ if (m.kind == "redo") == redo {
2554+ cur = block_on(loader.load_operation(&m.id))
2555+ .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
2556+ continue;
2557+ }
2558+ let Some(parent) = cur.parent_ids().first().cloned() else {
2559+ return Err((1, if redo { "nothing to redo" } else { "nothing to undo" }.to_string()));
2560+ };
2561+ target_op_id = parent;
2562+ break;
2563+ }
2564+ match (marker, redo) {
10232565 (None, false) => {
10242566 let Some(parent) = cur.parent_ids().first().cloned() else {
10252567 return Err((1, "nothing to undo".to_string()));
@@ -1045,11 +2587,25 @@ impl JjBackend {
10452587 .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
10462588 }
10472589 (Some(m), true) if m.kind == "undo" => {
1048− target_op_id = m.id;
2590+ // An old j marker names the discarded normal operation,
2591+ // but its parent has the same view and retains any earlier
2592+ // undos. Modern redo markers must keep that navigation
2593+ // point so another redo can continue across the upgrade.
2594+ target_op_id = if m.authored_by_j {
2595+ cur.parent_ids().first().cloned()
2596+ .ok_or_else(|| (1, "nothing to redo".to_string()))?
2597+ } else {
2598+ m.id
2599+ };
10492600 break;
10502601 }
10512602 (Some(m), false) => {
1052− // a redo: move past the undo it reversed
2603+ // a redo: "move past the operation it reapplied" (§7.7).
2604+ // The redo marker names the *undo* it reversed; the
2605+ // operation that undo discarded is the one this redo put
2606+ // back, so that is where the walk continues. Stopping at
2607+ // the undo instead would make the loop treat it as an undo
2608+ // to follow and step back one operation too far.
10532609 if !m.authored_by_j {
10542610 let Some(parent) = cur.parent_ids().first().cloned() else {
10552611 return Err((1, "nothing to undo".to_string()));
@@ -1057,23 +2613,57 @@ impl JjBackend {
10572613 target_op_id = parent;
10582614 break;
10592615 }
1060− cur = block_on(loader.load_operation(&m.id))
2616+ let undo_op = block_on(loader.load_operation(&m.id))
10612617 .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
2618+ cur = match op_marker(undo_op.metadata()) {
2619+ Some(u) if u.kind == "undo" && u.authored_by_j => {
2620+ block_on(loader.load_operation(&u.id))
2621+ .map_err(|e| (2, format!("cannot load an operation: {}", e)))?
2622+ }
2623+ // cannot follow the marker: degrade to the undo itself
2624+ _ => undo_op,
2625+ };
10622626 }
10632627 (Some(m), true) => {
1064− // a redo: reverse the undo it reversed, following further
1065− // redos to the operation ultimately reapplied
1066− target_op_id = if m.authored_by_j {
1067− block_on(chain_through_redos(&loader, &m.id))?
1068− } else {
1069− m.id
2628+ // a redo: the undo it reversed is already accounted for,
2629+ // so keep looking outward from that undo's parent for an
2630+ // undo that has not been reversed yet (§7.7: the head must
2631+ // be an undo, "possibly reached through redos").
2632+ if !m.authored_by_j {
2633+ target_op_id = m.id;
2634+ break;
2635+ }
2636+ let Some(prev) = op_parent_id(&loader, &m.id)? else {
2637+ return Err((1, "nothing to redo".to_string()));
10702638 };
1071− break;
2639+ cur = block_on(loader.load_operation(&prev))
2640+ .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
10722641 }
10732642 }
10742643 }
1075− let target_op = block_on(loader.load_operation(&target_op_id))
2644+ // before the operation init or clone recorded there was no
2645+ // repository (§7.7); the view jj recorded then has an empty child of
2646+ // the root as the working-copy commit, and checking it out would
2647+ // delete every file of the head
2648+ if !redo && op_created_repo(cur.metadata()) {
2649+ return Err((1, "nothing to undo".to_string()));
2650+ }
2651+ let mut target_op = block_on(loader.load_operation(&target_op_id))
10762652 .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
2653+ // jj also follows markers in the selected direction at the target:
2654+ // undoing a redo whose parent is an undo restores that undo's target,
2655+ // and conversely for redo. Keep the same canonical navigation point.
2656+ while let Some(m) = op_marker(target_op.metadata())
2657+ .filter(|m| m.restores_view && (m.kind == "redo") == redo)
2658+ {
2659+ target_op = block_on(loader.load_operation(&m.id))
2660+ .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
2661+ }
2662+ let target_op_id = target_op.id().clone();
2663+ // Report the expression whose state is being reversed or restored,
2664+ // following restore markers left by repeated undo/redo commands.
2665+ let affected = if redo { &target_op } else { &cur };
2666+ let affected_description = restored_operation_description(&loader, affected);
10772667 let target_view = block_on(
10782668 loader
10792669 .op_store()
@@ -1097,56 +2687,94 @@ impl JjBackend {
10972687 },
10982688 ));
10992689 }
1100− let marker = if redo {
1101− format!("redo: {}", cur.id().hex())
1102− } else {
1103− format!("undo: {}", cur.id().hex())
1104− };
2690+ // Use jj's current marker so either CLI can continue this history.
2691+ // The older j and jj marker formats remain readable above.
2692+ let marker = format!(
2693+ "{}: restore to operation {}",
2694+ if redo { "redo" } else { "undo" },
2695+ target_op_id.hex()
2696+ );
11052697 let mut tx = base.start_transaction();
11062698 tx.repo_mut().set_view(target_view.clone());
11072699 let unpublished = block_on(tx.write(&marker))
11082700 .map_err(|e| (1, format!("cannot write the operation: {}", e)))?;
1109− let op_id = unpublished.operation().id().clone();
1110− let repo = block_on(unpublished.publish())
1111− .map_err(|e| (1, format!("cannot publish the operation: {}", e)))?;
1112− *self.inner.repo.lock().unwrap() = repo.clone();
1113− let wc_commit = block_on(self.wc_commit(&repo))?;
1114− block_on(self.checkout(&wc_commit, op_id)).map_err(|c| (1, c.msg))?;
1115− Ok(())
2701+ // the operation is published, then the restored focus checked out
2702+ // from what the directory holds (§7.7)
2703+ let wc_id = &target_view.wc_commit_ids[WorkspaceName::DEFAULT];
2704+ let wc_commit = block_on(base.store().get_commit_async(wc_id))
2705+ .map_err(|e| (2, format!("cannot read the working-copy commit: {}", e)))?;
2706+ let back = if redo { ", and `j undo` goes back" } else { ", and `j redo` goes back" };
2707+ block_on(self.checkout(&wc_commit, Some(&on_disk), Some(unpublished), Some(back)))
2708+ .map_err(|c| (1, c.msg))?;
2709+ let focus_message = wc_commit.description().lines().next().unwrap_or("").trim();
2710+ Ok(format!(
2711+ "{} `{}`; restored focus @{} {}",
2712+ if redo { "Redid" } else { "Undid" },
2713+ affected_description,
2714+ wc_commit.change_id().reverse_hex(),
2715+ terminal_text(if focus_message.is_empty() { "(no description)" } else { focus_message }),
2716+ ))
2717+ }
2718+
2719+ /// Refuse, as `undo` and `redo` do before anything else (§7.7), when the
2720+ /// working directory holds changes the working-copy commit does not:
2721+ /// what a snapshot would record (§7.4), which their checkout would write
2722+ /// over. Otherwise return the tree the directory holds, as scanned,
2723+ /// which that checkout starts from: the commit's, but for the text
2724+ /// written for a conflict the state does not record, as one lost and
2725+ /// made again records none, or, where the working-copy state is stale,
2726+ /// the state's with any changes to it the commit already has, such as
2727+ /// what a checkout cut short wrote. Reserved commands never snapshot,
2728+ /// so nothing is recorded either way.
2729+ fn check_wc_clean(&self, base: &Arc<ReadonlyRepo>) -> Result<MergedTree, OpenError> {
2730+ let (scanned, clean) = self.inspect_working_copy(base, true)?;
2731+ if !clean {
2732+ return Err((1, "working copy has changes not in @; run `j id` to record them or discard them. Recording creates a new operation: the next `j undo` undoes that recording, not the operation you originally wanted to undo; a new recording also clears pending redos. Use `j ops` to inspect the history".to_string()));
2733+ }
2734+ Ok(scanned)
11162735 }
11172736
1118− /// refuse if the working directory differs from the focused commit's
1119− /// files (§7.7): reserved commands never snapshot, so compare the locked
1120− /// working copy's recorded tree with the focus commit's tree
1121− fn check_wc_clean(&self, base: &Arc<ReadonlyRepo>) -> Result<(), OpenError> {
1122− let dirty = (1, "working copy has changes not in @; run `j id` to record them or discard them".to_string());
1123− let wc_commit = block_on(self.wc_commit(base))?;
1124− // snapshot the working directory (without persisting anything) and
1125− // compare against the focused commit's files (§7.7)
2737+ /// Compare a scan with the recorded focus without publishing a snapshot.
2738+ /// Push only inspects: it must leave the saved working-copy state alone
2739+ /// so a later `j id` still sees every unrecorded edit.
2740+ fn inspect_working_copy(
2741+ &self,
2742+ base: &Arc<ReadonlyRepo>,
2743+ refresh_clean_state: bool,
2744+ ) -> Result<(MergedTree, bool), OpenError> {
2745+ let wc_tree = block_on(self.wc_commit(base))?.tree();
11262746 let mut ws_guard = self.inner.workspace.lock().unwrap();
2747+ let root = ws_guard.workspace_root().to_owned();
2748+ let saved = SavedState::of(&ws_guard);
11272749 let mut locked_ws = block_on(ws_guard.start_working_copy_mutation())
11282750 .map_err(|e| (2, format!("cannot lock the working copy: {}", e)))?;
1129− let options = SnapshotOptions {
1130− base_ignores: GitIgnoreFile::empty(),
1131− progress: None,
1132− start_tracking_matcher: &EverythingMatcher,
1133− force_tracking_matcher: &NothingMatcher,
1134− max_new_file_size: u64::MAX,
1135− };
1136− let (new_tree, _stats) = block_on(locked_ws.locked_wc().snapshot(&options))
2751+ let old_tree = locked_ws.locked_wc().old_tree().clone();
2752+ let scanned = block_on(scan(locked_ws.locked_wc(), &root, &old_tree, &saved, &snapshot_options()))
11372753 .map_err(|e| (2, format!("cannot read the working copy: {}", e)))?;
1138− let same = new_tree.tree_ids() == wc_commit.tree().tree_ids();
1139− // discard: finish against the current op so no state changes
1140− block_on(locked_ws.finish(base.operation().id().clone()))
1141− .map_err(|e| (2, format!("cannot finish: {}", e)))?;
1142− drop(ws_guard);
1143− if !same {
1144− return Err(dirty);
2754+ let current = locked_ws.locked_wc().old_operation_id() == base.operation().id();
2755+ let recorded = match marker_style(base.settings()) {
2756+ Ok(markers) => block_on(snapshot_tree(&root, markers, &wc_tree, &old_tree, current, &scanned)),
2757+ Err(e) => Err(e),
11452758 }
1146− Ok(())
2759+ .map_err(|e| (2, format!("cannot read the working copy: {}", e)))?;
2760+ if recorded.tree_ids() != wc_tree.tree_ids() {
2761+ // drop the lock without saving, or the state would claim the
2762+ // directory was snapshotted and hide the changes from the next
2763+ // run (§7.7)
2764+ return Ok((scanned, false));
2765+ }
2766+ // saving the state only refreshes what it knows of each file, or
2767+ // takes in what the commit already has
2768+ if refresh_clean_state {
2769+ block_on(save_scan(locked_ws, &scanned, &wc_tree, base.operation().id()))
2770+ .map_err(|e| (2, format!("cannot finish: {}", e)))?;
2771+ }
2772+ Ok((scanned, true))
11472773 }
11482774
1149− pub fn cmd_ops(&self) -> Result<(), OpenError> {
2775+ /// The operation log for `ops` (§7.7), one line per operation, newest
2776+ /// first; the caller writes it to stdout.
2777+ pub fn cmd_ops(&self) -> Result<String, OpenError> {
11502778 let base = self.head_repo()?;
11512779 let loader = self.repo_loader();
11522780 let head = base.operation().clone();
@@ -1159,8 +2787,8 @@ impl JjBackend {
11592787 let mut tag = "";
11602788 match op_kind(op.metadata()) {
11612789 OpKind::Normal => {}
1162− OpKind::Undo { .. } => tag = "undo",
1163− OpKind::Redo { .. } => tag = "redo",
2790+ OpKind::Undo => tag = "undo",
2791+ OpKind::Redo => tag = "redo",
11642792 }
11652793 if desc.is_empty() {
11662794 desc = "(no description)".to_string();
@@ -1183,8 +2811,7 @@ impl JjBackend {
11832811 None => None,
11842812 };
11852813 }
1186− print!("{}", out);
1187− Ok(())
2814+ Ok(out)
11882815 }
11892816}
11902817
@@ -1221,6 +2848,7 @@ pub fn cmd_init(cfg: &Config) -> Result<(), OpenError> {
12212848 }
12222849 .map_err(|e| (2, format!("cannot create the repository: {}", e)))?;
12232850 let backend = JjBackend {
2851+ replay_cache: Rc::new(ReplayCache::default()),
12242852 inner: Arc::new(JjInner {
12252853 workspace_root: cwd.clone(),
12262854 workspace: Mutex::new(workspace),
@@ -1229,23 +2857,43 @@ pub fn cmd_init(cfg: &Config) -> Result<(), OpenError> {
12292857 visible: Mutex::new(None),
12302858 lock_guard: Mutex::new(None),
12312859 pending: Mutex::new(None),
2860+ folds_case: OnceLock::new(),
2861+ long_names: Mutex::new(HashMap::new()),
12322862 }),
12332863 };
2864+ // the import and the working-copy commit are one operation, the one
2865+ // undo never undoes (§7.7)
2866+ let mut tx = backend.current_repo().start_transaction();
12342867 if had_git {
12352868 let import_options = backend.git_import_options()?;
1236− let mut tx = backend.current_repo().start_transaction();
12372869 block_on(jj_lib::git::import_refs(tx.repo_mut(), &import_options))
12382870 .map_err(|e| (2, format!("cannot import the git history: {}", e)))?;
2871+ // HEAD too: it is the head the working-copy commit starts on, and a
2872+ // detached HEAD's commits are reachable from no ref
2873+ block_on(jj_lib::git::import_head(
2874+ tx.repo_mut(),
2875+ &backend.inner.workspace_name,
2876+ &cwd,
2877+ ))
2878+ .map_err(|e| (2, format!("cannot import the git HEAD: {}", e)))?;
12392879 block_on(tx.repo_mut().rebase_descendants())
12402880 .map_err(|e| (2, format!("cannot rebase descendants: {}", e)))?;
1241− let _ = block_on(backend.publish_tx(tx, "import git refs"))?;
12422881 }
1243− create_initial_wc_commit(&backend, cfg, "init")?;
2882+ // §7.8: the current head is git's HEAD (none on an unborn branch)
2883+ let head = tx
2884+ .repo()
2885+ .view()
2886+ .git_head(&backend.inner.workspace_name)
2887+ .as_resolved()
2888+ .cloned()
2889+ .flatten();
2890+ create_initial_wc_commit(&backend, cfg, tx, "init", head, had_git, Some(""))?;
12442891 Ok(())
12452892}
12462893
12472894pub fn cmd_clone(cfg: &Config, url: &str, dir: &str) -> Result<(), OpenError> {
12482895 let settings = user_settings_strict(cfg)?;
2896+ let url = &origin_url(url)?;
12492897 let dir_path = PathBuf::from(dir);
12502898 if dir_path.join(".jj").is_dir() {
12512899 return Err((2, format!("{} already contains a jj repository", dir)));
@@ -1259,82 +2907,296 @@ pub fn cmd_clone(cfg: &Config, url: &str, dir: &str) -> Result<(), OpenError> {
12592907 } else if dir_path.exists() {
12602908 return Err((2, format!("{} already exists and is not empty", dir)));
12612909 }
1262− std::fs::create_dir_all(&dir_path)
1263− .map_err(|e| (2, format!("cannot create {}: {}", dir, e)))?;
2910+ let existed = dir_path.is_dir();
2911+ // the directory an existing DIR leads to, through a symlink DIR may be:
2912+ // a clone that fails empties it only while DIR still leads there (§7.8)
2913+ let found = if existed { dir_identity(&dir_path, true) } else { None };
2914+ // the directories this run creates, in order, each with its device and
2915+ // inode: a clone that fails removes DIR and those of its parents left
2916+ // empty, or an existing DIR's entries, and nothing else (§7.8).
2917+ // Each is made by `create_dir`, whose success proves it new. The checks
2918+ // above read DIR as written, before its missing parents exist; once
2919+ // they do, `new/..` or `new/../keep` names a directory that was there,
2920+ // so DIR's own `create_dir` failing, AlreadyExists included, refuses it
2921+ // as `git clone` does
2922+ let mut created: Vec<(&std::path::Path, Option<(u64, u64)>)> = Vec::new();
2923+ // those of them a failed clone leaves, as their paths no longer lead
2924+ // to them
2925+ let mut left: Vec<&std::path::Path> = Vec::new();
2926+ // from here until the clone has removed what it made or completed, a
2927+ // signal to stop waits, and fails a clone that has not yet made its
2928+ // working-copy commit (`unless_interrupted`, §1.3)
2929+ let _held = Deferral::start();
2930+ if !existed {
2931+ // DIR and its missing parents, DIR first ("" is the current
2932+ // directory, which exists)
2933+ let missing: Vec<&std::path::Path> = dir_path
2934+ .ancestors()
2935+ .enumerate()
2936+ .take_while(|(i, p)| *i == 0 || !(p.as_os_str().is_empty() || p.exists()))
2937+ .map(|(_, p)| p)
2938+ .collect();
2939+ for p in missing.into_iter().rev() {
2940+ match std::fs::create_dir(p) {
2941+ Ok(()) => created.push((p, dir_identity(p, false))),
2942+ // a parent reached again through `..`
2943+ Err(e)
2944+ if e.kind() == std::io::ErrorKind::AlreadyExists
2945+ && p != dir_path
2946+ && p.is_dir() => {}
2947+ Err(e) => {
2948+ for &(p, id) in created.iter().rev() {
2949+ if !remove_created_dir(p, id, false) {
2950+ left.push(p);
2951+ }
2952+ }
2953+ let msg = format!("cannot create {}: {}", dir, e);
2954+ return Err((2, msg + &left_note(&left)));
2955+ }
2956+ }
2957+ }
2958+ }
2959+ let cloned = clone_into(cfg, &settings, url, &dir_path);
2960+ let Err((code, msg)) = cloned else {
2961+ return Ok(());
2962+ };
2963+ // like `git clone`, a clone that fails leaves nothing behind: its
2964+ // working-copy commit would claim files the checkout never wrote, and a
2965+ // later clone would refuse the directory (§7.8)
2966+ if !existed {
2967+ // DIR, the last one created, goes whole; a parent holds nothing of
2968+ // the clone's but DIR, so it goes only if left empty: what else was
2969+ // put there meanwhile, a sibling clone say, stays
2970+ for (i, &(p, id)) in created.iter().enumerate().rev() {
2971+ if !remove_created_dir(p, id, i + 1 == created.len()) {
2972+ left.push(p);
2973+ }
2974+ }
2975+ Err((code, msg + &left_note(&left)))
2976+ } else if found.is_some() && dir_identity(&dir_path, true) == found {
2977+ if let Ok(entries) = std::fs::read_dir(&dir_path) {
2978+ for entry in entries.flatten() {
2979+ let _ = match entry.file_type() {
2980+ Ok(t) if t.is_dir() => std::fs::remove_dir_all(entry.path()),
2981+ _ => std::fs::remove_file(entry.path()),
2982+ };
2983+ }
2984+ }
2985+ Err((code, msg))
2986+ } else if dir_path.symlink_metadata().is_ok() {
2987+ // a symlink put in place of DIR, or of a directory above it, would
2988+ // have `read_dir` list what that names
2989+ Err((
2990+ code,
2991+ format!(
2992+ "{}; left what `{}` holds, as it is no longer the directory this clone found empty",
2993+ msg, dir
2994+ ),
2995+ ))
2996+ } else {
2997+ Err((code, msg))
2998+ }
2999+}
3000+
3001+/// The device and inode of the directory at `path`, following a symlink
3002+/// there only when `follow` says so; `None` when `path` holds no directory
3003+fn dir_identity(path: &std::path::Path, follow: bool) -> Option<(u64, u64)> {
3004+ use std::os::unix::fs::MetadataExt;
3005+ let meta = if follow {
3006+ std::fs::metadata(path)
3007+ } else {
3008+ std::fs::symlink_metadata(path)
3009+ };
3010+ meta.ok().filter(|m| m.is_dir()).map(|m| (m.dev(), m.ino()))
3011+}
3012+
3013+/// Remove the directory a failed clone created at `path`, `whole` or only
3014+/// if empty, while `path` still leads to it: the directory whose device and
3015+/// inode `id` were read on creating it (§7.8). A symlink put in its place,
3016+/// or in place of a directory above it, would have the removal follow it
3017+/// and delete what that names, so a path leading anywhere else is left, and
3018+/// the result is false. One leading nowhere holds nothing to remove.
3019+fn remove_created_dir(path: &std::path::Path, id: Option<(u64, u64)>, whole: bool) -> bool {
3020+ if id.is_some() && dir_identity(path, false) == id {
3021+ let _ = if whole {
3022+ std::fs::remove_dir_all(path)
3023+ } else {
3024+ std::fs::remove_dir(path)
3025+ };
3026+ true
3027+ } else {
3028+ path.symlink_metadata().is_err()
3029+ }
3030+}
3031+
3032+/// What a failed clone's error adds for the directories it created and
3033+/// `left` (§7.8): nothing when it left none
3034+fn left_note(left: &[&std::path::Path]) -> String {
3035+ let named = |ps: &[&std::path::Path]| {
3036+ ps.iter().map(|p| format!("`{}`", p.display())).collect::<Vec<_>>().join(", ")
3037+ };
3038+ match left {
3039+ [] => String::new(),
3040+ [one] => format!(
3041+ "; left {}, as it is no longer the directory this clone created",
3042+ named(&[one])
3043+ ),
3044+ [init @ .., last] => format!(
3045+ "; left {} and {}, as they are no longer the directories this clone created",
3046+ named(init),
3047+ named(&[last])
3048+ ),
3049+ }
3050+}
3051+
3052+/// `url` as `origin` holds it (§7.8): a local path resolved against the
3053+/// current directory, as `jj git clone` stores it, for git resolves a
3054+/// relative one against the directory each later `fetch` or `push` runs
3055+/// in; one under a home directory (`~/…`, `~user/…`) and any other URL, a
3056+/// `file://` one included, as given. One that does not parse, or a path
3057+/// that does not resolve to UTF-8, is a usage error.
3058+fn origin_url(url: &str) -> Result<String, OpenError> {
3059+ let mut parsed = gix::url::parse(url).map_err(|e| (2, format!("invalid URL `{}`: {}", url, e)))?;
3060+ // a bare path is a file location in the alternative form
3061+ if parsed.scheme != gix::url::Scheme::File || !parsed.serialize_alternative_form {
3062+ return Ok(url.to_string());
3063+ }
3064+ // git expands a leading `~` to the home directory on every fetch and
3065+ // push, from wherever it runs; resolved here, it would name a directory
3066+ // called `~` (`./~/…` still does)
3067+ if parsed.path.starts_with(b"~") {
3068+ return Ok(url.to_string());
3069+ }
3070+ let cwd = std::env::current_dir()
3071+ .map_err(|e| (2, format!("cannot read the current directory: {}", e)))?;
3072+ parsed
3073+ .canonicalize(&cwd)
3074+ .map_err(|e| (2, format!("cannot resolve `{}`: {}", url, e)))?;
3075+ // `add_remote` takes UTF-8, which only the current directory's name or
3076+ // a symlink's target can break; stored as given, the path would reach
3077+ // the remote only from where it was typed
3078+ String::from_utf8(parsed.to_bstring().into())
3079+ .map_err(|_| (2, format!("cannot resolve `{}`: the path it names is not UTF-8", url)))
3080+}
3081+
3082+/// `clone`'s work once `dir_path` exists and is empty (§7.8)
3083+fn clone_into(
3084+ cfg: &Config,
3085+ settings: &UserSettings,
3086+ url: &str,
3087+ dir_path: &std::path::Path,
3088+) -> Result<(), OpenError> {
12643089 let (workspace, repo) = block_on(Workspace::init_colocated_git(
1265− &settings,
1266− &dir_path,
3090+ settings,
3091+ dir_path,
12673092 gix::hash::Kind::Sha1,
12683093 ))
12693094 .map_err(|e| (2, format!("cannot create the repository: {}", e)))?;
12703095 let backend = JjBackend {
3096+ replay_cache: Rc::new(ReplayCache::default()),
12713097 inner: Arc::new(JjInner {
1272− workspace_root: dir_path.clone(),
3098+ workspace_root: dir_path.to_path_buf(),
12733099 workspace: Mutex::new(workspace),
12743100 repo: Mutex::new(repo),
12753101 workspace_name: WorkspaceName::DEFAULT.to_owned(),
12763102 visible: Mutex::new(None),
12773103 lock_guard: Mutex::new(None),
12783104 pending: Mutex::new(None),
3105+ folds_case: OnceLock::new(),
3106+ long_names: Mutex::new(HashMap::new()),
12793107 }),
12803108 };
12813109 let origin = RemoteName::new("origin");
12823110 let mut tx = backend.current_repo().start_transaction();
1283− jj_lib::git::add_remote(tx.repo_mut(), origin, url, None)
1284− .map_err(|e| (1, format!("cannot set the remote: {}", e)))?;
1285− backend.git_fetch_refs(tx.repo_mut(), origin)?;
3111+ // a signal to stop fails each step before the working-copy commit's
3112+ // (§1.3)
3113+ unless_interrupted(
3114+ jj_lib::git::add_remote(tx.repo_mut(), origin, url, None)
3115+ .map_err(|e| (1, format!("cannot set the remote: {}", e))),
3116+ )?;
3117+ unless_interrupted(backend.git_fetch_refs(tx.repo_mut(), origin))?;
3118+ let default_branch = unless_interrupted(git_default_branch(
3119+ git_backend(tx.repo().store())?.git_repo_path(),
3120+ origin,
3121+ ))?;
12863122 let import_options = backend.git_import_options()?;
1287− block_on(jj_lib::git::import_refs(tx.repo_mut(), &import_options))
1288− .map_err(|e| (1, format!("cannot import the fetched refs: {}", e)))?;
1289− block_on(tx.repo_mut().rebase_descendants())
1290− .map_err(|e| (1, format!("cannot rebase descendants: {}", e)))?;
1291− let _ = block_on(backend.publish_tx(tx, &format!("clone {}", url)))?;
1292− create_initial_wc_commit(&backend, cfg, &format!("clone {}", url))?;
3123+ unless_interrupted(
3124+ block_on(jj_lib::git::import_refs(tx.repo_mut(), &import_options))
3125+ .map_err(|e| (1, format!("cannot import the fetched refs: {}", e))),
3126+ )?;
3127+ unless_interrupted(
3128+ block_on(tx.repo_mut().rebase_descendants())
3129+ .map_err(|e| (1, format!("cannot rebase descendants: {}", e))),
3130+ )?;
3131+ // §7.8: the default bookmark's target (none when the remote's HEAD names
3132+ // no branch, or a branch it does not have)
3133+ let head = default_branch.and_then(|name| {
3134+ tx.repo()
3135+ .view()
3136+ .get_remote_bookmark(RefName::new(&name).to_remote_symbol(origin))
3137+ .target
3138+ .as_resolved()
3139+ .cloned()
3140+ .flatten()
3141+ });
3142+ // the fetch and the working-copy commit are one operation, the one undo
3143+ // never undoes (§7.7)
3144+ create_initial_wc_commit(&backend, cfg, tx, &format!("clone {}", url), head, false, None)?;
12933145 Ok(())
12943146}
12953147
1296−/// §7.8: after init/clone, create a working-copy commit as a child of the
1297−/// current head (a bookmark target if there is one, otherwise the root
1298−/// commit) and check it out.
3148+/// §7.8: after init/clone, create a working-copy commit as a child of
3149+/// `head` (the root commit when there is none), record it in `tx` with
3150+/// what the command has imported, and check it out. The commit holds its
3151+/// parent's files less any a checkout cannot create (`checkout_tree`), so
3152+/// it is empty unless the parent has one. `adopt` says the working
3153+/// directory already holds a checkout of the parent (init over an existing
3154+/// git repository): the working copy is then reset to the commit instead of
3155+/// written, so no file is touched and what the directory holds beyond the
3156+/// parent is the next snapshot's change. `back` is what a checkout that
3157+/// fails says (`JjBackend::checkout`): none for a clone, which then removes
3158+/// what it made.
12993159fn create_initial_wc_commit(
13003160 backend: &JjBackend,
13013161 cfg: &Config,
3162+ mut tx: jj_lib::transaction::Transaction,
13023163 description: &str,
3164+ head: Option<CommitId>,
3165+ adopt: bool,
3166+ back: Option<&str>,
13033167) -> Result<(), OpenError> {
1304− let base = backend.current_repo();
1305− let store = base.store().clone();
1306− let mut parent = store.root_commit_id().clone();
1307− 'outer: for (_name, target) in base.view().local_bookmarks() {
1308− if let Some(id) = target.as_resolved().and_then(|t| t.clone()) {
1309− parent = id;
1310− break 'outer;
1311− }
1312− }
1313− if parent == *store.root_commit_id() {
1314− for (_name, remote_ref) in base.view().remote_bookmarks(RemoteName::new("origin")) {
1315− if let Some(id) = remote_ref.target.as_resolved().and_then(|t| t.clone()) {
1316− parent = id;
1317− break;
1318− }
1319− }
1320− }
3168+ let store = tx.base_repo().store().clone();
3169+ let parent = head.unwrap_or_else(|| store.root_commit_id().clone());
13213170 // jj's own init already created a working-copy commit; if it is already
13223171 // a child of the target parent, keep it and just check it out
13233172 let mut to_abandon: Option<CommitId> = None;
1324− if let Ok(existing) = block_on(backend.wc_commit(&base)) {
3173+ let existing = tx.repo().view().get_wc_commit_id(&backend.inner.workspace_name).cloned();
3174+ if let Some(existing) = existing.and_then(|id| block_on(store.get_commit_async(&id)).ok()) {
13253175 if existing.parent_ids().first() == Some(&parent) {
1326− let op_id = base.operation().id().clone();
1327− block_on(backend.checkout(&existing, op_id)).map_err(|c| (1, c.msg))?;
3176+ // jj's commit is an empty child of the root; record only what
3177+ // was imported, if anything
3178+ let op = if tx.repo().has_changes() {
3179+ Some(
3180+ block_on(tx.write(truncate_chars(description, 200)))
3181+ .map_err(|e| (1, format!("cannot write the operation: {}", e)))?,
3182+ )
3183+ } else {
3184+ None
3185+ };
3186+ block_on(backend.checkout(&existing, None, op, back)).map_err(|c| (1, c.msg))?;
13283187 return Ok(());
13293188 }
13303189 if existing.id() != store.root_commit_id() {
13313190 to_abandon = Some(existing.id().clone());
13323191 }
13333192 }
3193+ let parent_tree = block_on(store.get_commit_async(&parent))
3194+ .map_err(|e| (1, format!("cannot read the parent commit: {}", e)))?
3195+ .tree();
3196+ let wc_tree = block_on(checkout_tree(parent_tree, backend))?;
13343197 let user_sig = backend
13353198 .user_signature(cfg)
13363199 .map_err(|c| (3, format!("config.j: {}", c.msg)))?;
1337− let mut tx = base.start_transaction();
13383200 if let Some(old_wc) = &to_abandon {
13393201 tx.repo_mut().record_abandoned_commit_with_parents(
13403202 old_wc.clone(),
@@ -1343,7 +3205,7 @@ fn create_initial_wc_commit(
13433205 }
13443206 let wc = block_on(
13453207 tx.repo_mut()
1346− .new_commit(vec![parent], store.empty_merged_tree())
3208+ .new_commit(vec![parent], wc_tree)
13473209 .set_author(user_sig.clone())
13483210 .set_committer(user_sig)
13493211 .write(),
@@ -1356,19 +3218,49 @@ fn create_initial_wc_commit(
13563218 .map_err(|e| (1, format!("cannot rebase descendants: {}", e)))?;
13573219 let unpublished = block_on(tx.write(truncate_chars(description, 200)))
13583220 .map_err(|e| (1, format!("cannot write the operation: {}", e)))?;
1359− let op_id = unpublished.operation().id().clone();
1360− let repo = block_on(unpublished.publish())
1361− .map_err(|e| (1, format!("cannot publish the operation: {}", e)))?;
1362− *backend.inner.repo.lock().unwrap() = repo;
1363− block_on(backend.checkout(&wc, op_id)).map_err(|c| (1, c.msg))?;
3221+ // an adopted directory is reset to the commit, not checked out over:
3222+ // checking out from the empty tree jj's init recorded would recreate
3223+ // the files the user deleted there. Anything else is written, once the
3224+ // operation is published (§7.5 step 7).
3225+ let on_disk = if adopt { Some(wc.tree()) } else { None };
3226+ block_on(backend.checkout(&wc, on_disk.as_ref(), Some(unpublished), back)).map_err(|c| (1, c.msg))?;
13643227 Ok(())
13653228}
13663229
3230+/// `tree` without the files at a path a checkout cannot create (§7.5 step
3231+/// 1), which a git branch can hold: a `.jj` directory committed by mistake,
3232+/// a name the workspace's filesystem does not hold. jj's checkout stops at
3233+/// the first such path, failing the whole clone, so the working-copy commit
3234+/// init or clone starts on leaves them out, and their removal is its change
3235+/// (§7.8).
3236+async fn checkout_tree(tree: MergedTree, backend: &JjBackend) -> Result<MergedTree, OpenError> {
3237+ let folds_case = backend.folds_case();
3238+ let refused: Vec<RepoPathBuf> = tree
3239+ .entries()
3240+ .map(|(path, _)| path)
3241+ .filter(|path| {
3242+ path.components().any(|c| {
3243+ crate::repo::checkout_refuses(c.as_internal_str(), folds_case, |name| backend.name_fits(name))
3244+ })
3245+ })
3246+ .collect();
3247+ if refused.is_empty() {
3248+ return Ok(tree);
3249+ }
3250+ let mut builder = MergedTreeBuilder::new(tree);
3251+ for path in refused {
3252+ builder.set_or_remove(path, Merge::absent());
3253+ }
3254+ builder
3255+ .write_tree()
3256+ .await
3257+ .map_err(|e| (1, format!("cannot write a tree: {}", e)))
3258+}
3259+
13673260/// user settings for init/clone: a missing or malformed `user` is a
13683261/// configuration error (exit 3) (§7.9)
13693262fn user_settings_strict(cfg: &Config) -> Result<UserSettings, OpenError> {
1370− let (name, email) = crate::config::eval_user_only(&mut Interp::dummy(), cfg)
1371− .map_err(|c| (3, format!("config.j: {}", c.msg)))?;
3263+ let (name, email) = validated_identity(cfg)?;
13723264 let mut config = StackedConfig::with_defaults();
13733265 let text = format!(
13743266 "[user]\nname = {}\nemail = {}\n",
@@ -1382,6 +3274,39 @@ fn user_settings_strict(cfg: &Config) -> Result<UserSettings, OpenError> {
13823274 .map_err(|e| (3, format!("config.j: invalid user settings: {}", e)))
13833275}
13843276
3277+/// The identity shown during repository setup. Validation is deliberately
3278+/// confined to authoring boundaries so inspection and recovery still work
3279+/// with an unchanged reference configuration.
3280+pub fn configured_identity(cfg: &Config) -> Result<String, OpenError> {
3281+ let (name, email) = validated_identity(cfg)?;
3282+ Ok(format!("{} <{}>", terminal_text(&name), terminal_text(&email)))
3283+}
3284+
3285+fn terminal_text(text: &str) -> String {
3286+ text.chars().map(|c| {
3287+ if c.is_control() { c.escape_debug().to_string() } else { c.to_string() }
3288+ }).collect()
3289+}
3290+
3291+fn validated_identity(cfg: &Config) -> Result<(String, String), OpenError> {
3292+ let (name, email) = crate::config::eval_user_only(&mut Interp::dummy(), cfg)
3293+ .map_err(|c| (3, format!("config.j: {}", c.msg)))?;
3294+ check_identity(&name, &email)?;
3295+ Ok((name, email))
3296+}
3297+
3298+fn check_identity(name: &str, email: &str) -> Result<(), OpenError> {
3299+ if name.trim().is_empty() || email.trim().is_empty()
3300+ || name.trim() == "Your Name" || email.trim().eq_ignore_ascii_case("you@example.com")
3301+ {
3302+ return Err((3, format!(
3303+ "config.j: user identity is not configured ({} <{}>); set `user.name` and `user.email` in the `user` record before creating or recording commits",
3304+ terminal_text(name), terminal_text(email),
3305+ )));
3306+ }
3307+ Ok(())
3308+}
3309+
13853310// ----------------------------------------------------------------------
13863311// reserved-command helpers
13873312// ----------------------------------------------------------------------
@@ -1445,41 +3370,72 @@ fn git_fetch_subprocess(git_dir: &std::path::Path, remote: &RemoteName) -> Resul
14453370 if out.status.success() {
14463371 Ok(())
14473372 } else {
1448− Err((
1449− 1,
1450− format!(
1451− "fetch failed: {}",
1452− String::from_utf8_lossy(&out.stderr).trim()
1453− ),
1454− ))
3373+ Err((1, format!("fetch failed: {}", git_failure(&out))))
3374+ }
3375+}
3376+
3377+/// What a git that failed said, or how it ended when it said nothing (a
3378+/// Ctrl-C stops it silently)
3379+fn git_failure(out: &std::process::Output) -> String {
3380+ use std::os::unix::process::ExitStatusExt;
3381+ let said = String::from_utf8_lossy(&out.stderr).trim().to_string();
3382+ match (said.is_empty(), out.status.signal()) {
3383+ (false, _) => said,
3384+ (true, Some(sig)) => format!("git was stopped by signal {}", sig),
3385+ (true, None) => format!("git exited with status {}", out.status.code().unwrap_or(-1)),
3386+ }
3387+}
3388+
3389+/// the branch the remote's HEAD names, its default bookmark (§7.8), asked of
3390+/// the remote with `git ls-remote --symref` (the git `git_fetch_subprocess`
3391+/// runs); `None` when HEAD names no branch (a detached HEAD)
3392+fn git_default_branch(
3393+ git_dir: &std::path::Path,
3394+ remote: &RemoteName,
3395+) -> Result<Option<String>, OpenError> {
3396+ let exe = std::env::var("J_GIT").unwrap_or_else(|_| "git".to_string());
3397+ let out = std::process::Command::new(exe)
3398+ .arg("--git-dir")
3399+ .arg(git_dir)
3400+ .args(["ls-remote", "--symref", remote.as_str(), "HEAD"])
3401+ .stdin(std::process::Stdio::null())
3402+ .output()
3403+ .map_err(|e| (1, format!("cannot run git ls-remote: {}", e)))?;
3404+ if !out.status.success() {
3405+ return Err((1, format!("cannot read the remote's default branch: {}", git_failure(&out))));
14553406 }
3407+ // `ref: refs/heads/<branch>\tHEAD`
3408+ Ok(String::from_utf8_lossy(&out.stdout).lines().find_map(|line| {
3409+ let branch = line.strip_prefix("ref: refs/heads/")?.strip_suffix("\tHEAD")?;
3410+ Some(branch.to_string())
3411+ }))
14563412}
14573413
14583414enum OpKind {
14593415 Normal,
1460− /// the operation this undo discarded the view of
1461− Undo { #[allow(dead_code)] undid: OperationId },
1462− /// the undo this redo reversed
1463− Redo { redid: OperationId },
3416+ Undo,
3417+ Redo,
14643418}
14653419
14663420/// a marker parsed from an operation description (§7.7 kinds)
14673421struct OpMarker {
14683422 kind: &'static str,
14693423 id: OperationId,
1470− /// whether the description is j's own exact marker (`undo: <id>` /
1471− /// `redo: <id>`); only those are reliably loadable for the walk
3424+ /// jj 0.45 names the operation whose view was restored, rather than
3425+ /// the operation undone or the undo reversed.
3426+ restores_view: bool,
3427+ /// Whether a legacy marker is j's exact `undo: <id>` / `redo: <id>`
3428+ /// format, whose discarded-operation links the walk can follow.
14723429 authored_by_j: bool,
14733430}
14743431
1475−/// §7.7 kinds: `j` marks undo/redo operations as `undo: <opid>` /
1476−/// `redo: <opid>` in the description (0.45's Transaction cannot write
1477−/// metadata tags); jj's own `undo operation <id>` / `redo operation <id>`
1478−/// markers are treated the same way.
3432+/// §7.7 kinds: new records use jj 0.45's restored-view descriptions. Older
3433+/// j `undo: <id>` / `redo: <id>` and jj `undo operation <id>` /
3434+/// `redo operation <id>` descriptions remain readable too.
14793435fn op_kind(meta: &jj_lib::op_store::OperationMetadata) -> OpKind {
14803436 match op_marker(meta) {
1481− Some(OpMarker { kind: "undo", id, .. }) => OpKind::Undo { undid: id },
1482− Some(OpMarker { kind: "redo", id, .. }) => OpKind::Redo { redid: id },
3437+ Some(OpMarker { kind: "undo", .. }) => OpKind::Undo,
3438+ Some(OpMarker { kind: "redo", .. }) => OpKind::Redo,
14833439 _ => OpKind::Normal,
14843440 }
14853441}
@@ -1487,20 +3443,33 @@ fn op_kind(meta: &jj_lib::op_store::OperationMetadata) -> OpKind {
14873443fn op_marker(meta: &jj_lib::op_store::OperationMetadata) -> Option<OpMarker> {
14883444 let desc = meta.description.trim();
14893445 for kind in ["undo", "redo"] {
3446+ if let Some(rest) = desc.strip_prefix(&format!("{}: restore to operation ", kind)) {
3447+ if let Some(id) = parse_op_id(rest.trim()) {
3448+ return Some(OpMarker { kind, id, restores_view: true, authored_by_j: false });
3449+ }
3450+ }
14903451 if let Some(rest) = desc.strip_prefix(&format!("{}:", kind)) {
14913452 if let Some(id) = parse_op_id(rest.trim()) {
1492− return Some(OpMarker { kind, id, authored_by_j: true });
3453+ return Some(OpMarker { kind, id, restores_view: false, authored_by_j: true });
14933454 }
14943455 }
14953456 if let Some(rest) = desc.strip_prefix(&format!("{} operation", kind)) {
14963457 if let Some(id) = parse_op_id(rest.trim()) {
1497− return Some(OpMarker { kind, id, authored_by_j: false });
3458+ return Some(OpMarker { kind, id, restores_view: false, authored_by_j: false });
14983459 }
14993460 }
15003461 }
15013462 None
15023463}
15033464
3465+/// Whether `meta` is the operation `init` or `clone` recorded (§7.7), by its
3466+/// description, `init` or `clone URL`: no expression is described so, both
3467+/// words being reserved (§1.1).
3468+fn op_created_repo(meta: &jj_lib::op_store::OperationMetadata) -> bool {
3469+ let desc = meta.description.trim();
3470+ desc == "init" || desc.starts_with("clone ")
3471+}
3472+
15043473/// a full-length (or prefix) hex operation id
15053474fn parse_op_id(s: &str) -> Option<OperationId> {
15063475 if s.is_empty() || !s.chars().all(|c| c.is_ascii_hexdigit()) {
@@ -1526,21 +3495,39 @@ fn op_parent_id(
15263495 Ok(op.parent_ids().first().cloned())
15273496}
15283497
1529−/// follow a chain of redos to the operation that was ultimately reapplied
1530−async fn chain_through_redos(
3498+/// A restore operation represents an earlier view. Follow that view to a
3499+/// normal operation for useful confirmation text without changing undo's
3500+/// navigation or its jj-compatible metadata.
3501+fn restored_operation_description(
15313502 loader: &jj_lib::repo::RepoLoader,
1532− first: &OperationId,
1533−) -> Result<OperationId, OpenError> {
1534− let mut id = first.clone();
1535− loop {
1536− let op = loader
1537− .load_operation(&id)
1538− .await
1539− .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
1540− match op_kind(op.metadata()) {
1541− OpKind::Redo { redid } => id = redid,
1542− _ => return Ok(id),
1543− }
3503+ op: &jj_lib::operation::Operation,
3504+) -> String {
3505+ let mut represented = op.clone();
3506+ let mut seen = BTreeSet::new();
3507+ while seen.insert(represented.id().clone()) {
3508+ let Some(marker) = op_marker(represented.metadata()) else { break };
3509+ let target = if marker.restores_view {
3510+ Some(marker.id)
3511+ } else if marker.authored_by_j {
3512+ // A legacy undo names what it discarded; a legacy redo names
3513+ // the undo it reversed. Either restored its target's parent.
3514+ op_parent_id(loader, &marker.id).ok().flatten()
3515+ } else {
3516+ // Unknown legacy jj history is best identified by its actual
3517+ // description instead of guessing which operation it applied.
3518+ None
3519+ };
3520+ let Some(target) = target else { break };
3521+ // Missing historical metadata must not prevent an otherwise valid
3522+ // recovery operation merely because its summary cannot be expanded.
3523+ let Ok(target_op) = block_on(loader.load_operation(&target)) else { break };
3524+ represented = target_op;
3525+ }
3526+ let description = represented.metadata().description.lines().next().unwrap_or("").trim();
3527+ if description.is_empty() {
3528+ format!("operation {}", truncate_chars(&represented.id().hex(), 12))
3529+ } else {
3530+ terminal_text(description)
15443531 }
15453532}
15463533
@@ -1610,6 +3597,11 @@ fn parse_push_records(
16103597 let rec = vis.commits.get(&change_id).ok_or_else(|| {
16113598 (1, format!("push: `@{}` is not a visible commit", change_id))
16123599 })?;
3600+ // jj's root has no Git object: its all-zero commit id is
3601+ // interpreted by `git push` as a deletion, not a target.
3602+ if rec.commit.id() == repo.store().root_commit_id() {
3603+ return Err((1, "push: the root commit cannot be pushed".to_string()));
3604+ }
16133605 out.push((name, Some(rec.commit.id().clone())));
16143606 }
16153607 None => {
@@ -1629,68 +3621,190 @@ fn parse_push_records(
16293621 Ok(out)
16303622}
16313623
1632−/// §7.6: refuse to send commits with unresolved files or empty descriptions,
1633−/// and refuse to move/delete a bookmark whose current target is immutable
1634−/// away from a descendant of that target
3624+/// §7.6: refuse to move a bookmark whose current target is immutable to a
3625+/// commit that does not descend from that target, or to delete it while no
3626+/// bookmark left on `origin` after the push reaches that target; and refuse
3627+/// to send commits with unresolved files or empty descriptions. Returns
3628+/// whether such a delete is allowed only because a record's new target
3629+/// reaches it, so the deletes must wait for the updates (push_to_origin),
3630+/// and refuses a push whose deletes would wait for a bookmark git cannot
3631+/// create while one of them is still there
16353632fn check_push_records(
16363633 records: &[(String, Option<CommitId>)],
1637− vis: &VisibleRepo,
16383634 repo: &Arc<ReadonlyRepo>,
1639−) -> Result<(), OpenError> {
3635+ vis: &VisibleRepo,
3636+ immutable: &BTreeSet<String>,
3637+) -> Result<bool, OpenError> {
16403638 let store = repo.store().clone();
3639+ let origin = RemoteName::new("origin");
3640+ // the records' new targets, which the push sends and leaves on `origin`
3641+ let new_targets: Vec<CommitId> = records.iter().filter_map(|(_, t)| t.clone()).collect();
3642+ // the targets of the other bookmarks left on `origin` after the push:
3643+ // those no record names, where they are as of the last fetch or push;
3644+ // computed for the first delete that needs them
3645+ let mut unnamed: Option<Vec<CommitId>> = None;
3646+ // the deletes that only records' new targets keep reached
3647+ let mut waiting: Vec<&str> = Vec::new();
16413648 for (name, target) in records {
1642− let mut seeds: Vec<String> = Vec::new();
1643− if let Some(commit_id) = target {
1644− let commit = block_on(store.get_commit_async(commit_id))
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− if let Some(current) = repo
3649+ let Some(current) = repo
16523650 .view()
1653− .get_remote_bookmark(RefName::new(name).to_remote_symbol(RemoteName::new("origin")))
3651+ .get_remote_bookmark(RefName::new(name).to_remote_symbol(origin))
16543652 .target
16553653 .as_resolved()
16563654 .cloned()
16573655 .unwrap_or(None)
1658− {
1659− let commit = block_on(store.get_commit_async(&current))
1660− .map_err(|e| (2, format!("cannot read a commit: {}", e)))?;
1661− let change_id = commit.change_id().reverse_hex();
1662− if vis.commits.contains_key(&change_id) {
1663− seeds.push(change_id);
1664− }
1665− }
1666− let ancestors = ancestors_of_change_ids(vis, &seeds);
1667− for change_id in &ancestors {
1668− let rec = vis.commits.get(change_id).expect("ancestors are stored");
1669− for (_path, value) in rec.commit.tree().entries() {
1670− let value = value.map_err(|e| (2, format!("cannot read a tree: {}", e)))?;
1671− if !value.is_resolved() {
3656+ else {
3657+ continue;
3658+ };
3659+ let change_id = block_on(store.get_commit_async(&current))
3660+ .map_err(|e| (2, format!("cannot read a commit: {}", e)))?
3661+ .change_id()
3662+ .reverse_hex();
3663+ // the immutable set holds visible commits: a target rewritten here
3664+ // since the last fetch or push is hidden, and not in it although the
3665+ // visible rewrite carries its change id (and its label)
3666+ let visible = vis.commits.get(&change_id).is_some_and(|c| c.commit.id() == &current);
3667+ if !visible || !immutable.contains(&change_id) {
3668+ continue;
3669+ }
3670+ match target {
3671+ None => {
3672+ // a delete loses no history while another bookmark still
3673+ // reaches the commit: a merged feature, a second name. When
3674+ // only a bookmark this push sets reaches it (`rename`), the
3675+ // remote may reject that update and accept the delete, so
3676+ // the delete waits for the update to be accepted
3677+ let unnamed = unnamed.get_or_insert_with(|| {
3678+ let named: BTreeSet<&str> = records.iter().map(|(n, _)| n.as_str()).collect();
3679+ repo.view()
3680+ .remote_bookmarks(origin)
3681+ .filter(|(n, _)| !named.contains(n.as_str()))
3682+ .flat_map(|(_, remote_ref)| remote_ref.target.added_ids())
3683+ .cloned()
3684+ .collect()
3685+ });
3686+ let reached = |heads: &[CommitId]| {
3687+ ResolvedRevsetExpression::commits(vec![current.clone()])
3688+ .intersection(&ResolvedRevsetExpression::commits(heads.to_vec()).ancestors())
3689+ .evaluate(repo.as_ref())
3690+ .and_then(|r| r.is_empty())
3691+ .map(|empty| !empty)
3692+ .map_err(|e| (2, format!("cannot read the index: {}", e)))
3693+ };
3694+ if !reached(unnamed)? {
3695+ if !reached(&new_targets)? {
3696+ return Err((
3697+ 1,
3698+ format!(
3699+ "push: `{}` is on the immutable commit `@{}` and cannot be deleted while no bookmark left on origin reaches it",
3700+ name, change_id
3701+ ),
3702+ ));
3703+ }
3704+ waiting.push(name);
3705+ }
3706+ }
3707+ Some(new) => {
3708+ let descends = block_on(repo.index().is_ancestor(&current, new))
3709+ .map_err(|e| (2, format!("cannot read the index: {}", e)))?;
3710+ if !descends {
3711+ return Err((
3712+ 1,
3713+ format!(
3714+ "push: `{}` is on the immutable commit `@{}` and can only move to a descendant of it",
3715+ name, change_id
3716+ ),
3717+ ));
3718+ }
3719+ }
3720+ }
3721+ }
3722+ // git cannot hold a bookmark and one under its name at once (`master`
3723+ // and `master/legacy`), so while the deletes wait, a first push that
3724+ // creates a name nesting with a deleted one is rejected and the deletes
3725+ // are never sent. With a bookmark no record names on the waiting
3726+ // delete's commit, nothing waits and it is one git push (§7.6)
3727+ let deletes_wait = !waiting.is_empty();
3728+ if deletes_wait {
3729+ let nests = |a: &str, b: &str| b.strip_prefix(a).is_some_and(|rest| rest.starts_with('/'));
3730+ for (deleted, _) in records.iter().filter(|(_, t)| t.is_none()) {
3731+ for (set, _) in records.iter().filter(|(_, t)| t.is_some()) {
3732+ if nests(deleted, set) || nests(set, deleted) {
3733+ let keep = if waiting.contains(&deleted.as_str()) { deleted.as_str() } else { waiting[0] };
3734+ let literal = crate::show::text_literal(keep);
3735+ // the temporary label: the first of `tmp`, `tmp-1`, ...
3736+ // that is no bookmark, here or on origin, no record
3737+ // names, and nests with none of those, so pushing and
3738+ // deleting it moves none of the user's bookmarks, and
3739+ // this push, which names nothing nesting with it, no
3740+ // longer waits on `keep`'s commit
3741+ let taken: Vec<&str> = repo
3742+ .view()
3743+ .bookmarks()
3744+ .map(|(n, _)| n.as_str())
3745+ .chain(records.iter().map(|(n, _)| n.as_str()))
3746+ .collect();
3747+ let tmp = std::iter::once("tmp".to_string())
3748+ .chain((1..).map(|i| format!("tmp-{i}")))
3749+ .find(|t| !taken.iter().any(|n| n == t || nests(n, t) || nests(t, n)))
3750+ .expect("finitely many names are taken");
3751+ let tmp = crate::show::text_literal(&tmp);
16723752 return Err((
16733753 1,
16743754 format!(
1675− "push: commit `@{}` has unresolved files",
1676− rec.change_id
3755+ "push: `{deleted}` must stay on origin until `{set}` is accepted, and git cannot create `{set}` while `{deleted}` is there; first push a temporary label on `{keep}`'s commit, `push (label {tmp} (labelled {literal}))`, then this push, then `push (unlabel {tmp})`"
16773756 ),
16783757 ));
16793758 }
16803759 }
1681− if rec.commit.description().trim().is_empty() && rec.commit.id() != store.root_commit_id() {
1682− return Err((
1683− 1,
1684− format!("push: commit `@{}` has an empty description", rec.change_id),
1685− ));
3760+ }
3761+ }
3762+ // the commits that would be sent: ancestors of the new targets that no
3763+ // bookmark on the remote already reaches; a delete sends none
3764+ if new_targets.is_empty() {
3765+ return Ok(deletes_wait);
3766+ }
3767+ let on_remote: Vec<CommitId> = repo
3768+ .view()
3769+ .remote_bookmarks(origin)
3770+ .flat_map(|(_, remote_ref)| remote_ref.target.added_ids())
3771+ .cloned()
3772+ .collect();
3773+ let sent = ResolvedRevsetExpression::commits(on_remote)
3774+ .range(&ResolvedRevsetExpression::commits(new_targets))
3775+ .evaluate(repo.as_ref())
3776+ .map_err(|e| (2, format!("cannot list the commits to send: {}", e)))?;
3777+ let sent: Vec<Commit> = block_on(sent.stream().commits(&store).try_collect())
3778+ .map_err(|e| (2, format!("cannot list the commits to send: {}", e)))?;
3779+ for commit in &sent {
3780+ let change_id = commit.change_id().reverse_hex();
3781+ let mut unresolved_paths = Vec::new();
3782+ for (path, value) in commit.tree().entries() {
3783+ let value = value.map_err(|e| (2, format!("cannot read a tree: {}", e)))?;
3784+ if !value.is_resolved() {
3785+ unresolved_paths.push(path.as_internal_file_string().to_string());
3786+ }
3787+ }
3788+ if !unresolved_paths.is_empty() {
3789+ let mut paths = unresolved_paths.iter().take(5)
3790+ .map(|path| format!("`{}`", terminal_text(path)))
3791+ .collect::<Vec<_>>().join(", ");
3792+ if unresolved_paths.len() > 5 {
3793+ paths.push_str(&format!(" and {} more", unresolved_paths.len() - 5));
16863794 }
3795+ return Err((1, format!(
3796+ "push: commit `@{change_id}` has unresolved files: {paths}; run `j conflicts` to inspect conflicts, then `j by @{change_id}` to edit this commit. Resolve the listed files, run `j id` to record the resolution, and check `j conflicts` before pushing again"
3797+ )));
3798+ }
3799+ if commit.description().trim().is_empty() && commit.id() != store.root_commit_id() {
3800+ return Err((1, format!("push: commit `@{}` has an empty description", change_id)));
16873801 }
16883802 }
1689− Ok(())
3803+ Ok(deletes_wait)
16903804}
16913805
16923806/// change-id closure of jj-parents from the seeds (all jj parents, not just
1693−/// the first), for the push content checks
3807+/// the first), for the immutable set (§7.5)
16943808fn ancestors_of_change_ids(vis: &VisibleRepo, seeds: &[String]) -> BTreeSet<String> {
16953809 let mut seen = BTreeSet::new();
16963810 let mut stack: Vec<String> = seeds.to_vec();
@@ -1711,23 +3825,11 @@ fn ancestors_of_change_ids(vis: &VisibleRepo, seeds: &[String]) -> BTreeSet<Stri
17113825
17123826/// git-check-ref-format --branch rules
17133827fn is_valid_git_branch_name(name: &str) -> bool {
1714− if name.is_empty()
1715− || name.starts_with('-')
1716− || name.starts_with('.')
1717− || name.ends_with('.')
1718− || name.ends_with('/')
1719− || name.ends_with(".lock")
1720− || name.contains("..")
1721− || name.contains("//")
1722− || name.contains("@{")
1723− || name == "@"
1724− {
3828+ if name.starts_with('-') {
17253829 return false;
17263830 }
1727− name.chars().all(|c| {
1728− !(c.is_ascii_control()
1729− || matches!(c, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\'))
1730− })
3831+ let full_name = format!("refs/heads/{name}");
3832+ gix::validate::reference::branch_name(full_name.as_str().into()).is_ok()
17313833}
17323834
17333835// ----------------------------------------------------------------------
@@ -1735,6 +3837,12 @@ fn is_valid_git_branch_name(name: &str) -> bool {
17353837// ----------------------------------------------------------------------
17363838
17373839impl Backend for JjBackend {
3840+ fn validate_authoring(&self) -> Result<(), Crash> {
3841+ let repo = self.current_repo();
3842+ check_identity(repo.settings().user_name(), repo.settings().user_email())
3843+ .map_err(|(_, message)| Crash::new(message))
3844+ }
3845+
17383846 fn visible_ids(&self) -> Vec<String> {
17393847 match self.inner.visible.lock().unwrap().as_ref() {
17403848 Some(v) => v.commits.keys().cloned().collect(),
@@ -1769,38 +3877,93 @@ impl Backend for JjBackend {
17693877 vis.and_then(|v| v.commits.get(id).map(|r| r.commit.has_conflict()))
17703878 }
17713879
1772− fn is_empty(&self, id: &str) -> Option<bool> {
3880+ fn is_empty(&self, id: &str, parent: &str) -> Option<bool> {
17733881 let vis = self.inner.visible.lock().unwrap().clone();
1774− // O(1): empty iff the tree id equals the first parent's tree id
3882+ // O(1): empty iff the tree id equals the parent's tree id
17753883 vis.and_then(|v| {
17763884 let rec = v.commits.get(id)?;
1777− let parent = v.commits.get(&rec.first_parent)?;
3885+ let parent = v.commits.get(parent)?;
17783886 Some(rec.commit.tree_ids() == parent.commit.tree_ids())
17793887 })
17803888 }
17813889
3890+ fn folds_case(&self) -> bool {
3891+ // jj's checkout refuses a name whose file identity is that of
3892+ // `.git` or `.jj` in the same directory (local_working_copy.rs), so
3893+ // where `.JJ` is the workspace's own `.jj` it refuses `.GIT` too.
3894+ // Where each spelling of a name gets its own inode number
3895+ // (exfat-fuse) the identities differ and the checkout writes
3896+ // `.GIT/x` into `.git`, so folding is told by how names resolve:
3897+ // `.JJ` resolves, but the directory does not list it under that
3898+ // spelling. A directory that cannot be listed is taken to fold
3899+ // case, the answer that refuses more.
3900+ *self.inner.folds_case.get_or_init(|| {
3901+ use jj_lib::file_util::FileIdentity;
3902+ let root = &self.inner.workspace_root;
3903+ let Ok(upper) = FileIdentity::from_symlink_path(root.join(".JJ")) else {
3904+ return false;
3905+ };
3906+ FileIdentity::from_symlink_path(root.join(".jj")).is_ok_and(|lower| lower == upper)
3907+ || std::fs::read_dir(root).map_or(true, |mut entries| {
3908+ !entries.any(|e| e.is_ok_and(|e| e.file_name() == ".JJ"))
3909+ })
3910+ })
3911+ }
3912+
3913+ fn name_fits(&self, name: &str) -> bool {
3914+ if name.len() <= crate::repo::NAME_MAX {
3915+ return true;
3916+ }
3917+ // a component, never a path: one with `/` would be created elsewhere
3918+ if name.contains(['/', '\0']) {
3919+ return false;
3920+ }
3921+ // no call tells whether the limit counts bytes (ext4) or UTF-16
3922+ // units or characters (exFAT, NTFS, APFS), so the name is tried:
3923+ // created in a directory of its own under `.jj`, which is then
3924+ // removed, once per name and run
3925+ let mut known = self.inner.long_names.lock().unwrap();
3926+ if let Some(fits) = known.get(name) {
3927+ return *fits;
3928+ }
3929+ let fits = match tempfile::Builder::new()
3930+ .prefix("name-")
3931+ .tempdir_in(self.inner.workspace_root.join(".jj"))
3932+ {
3933+ Ok(dir) => {
3934+ let fits = std::fs::File::create(dir.path().join(name)).is_ok();
3935+ drop(dir);
3936+ fits
3937+ }
3938+ Err(_) => false,
3939+ };
3940+ known.insert(name.to_string(), fits);
3941+ fits
3942+ }
3943+
17823944 fn ancestors_closed(&self, ids: &BTreeSet<String>) -> BTreeSet<String> {
17833945 let vis = self.inner.visible.lock().unwrap().clone();
17843946 let Some(vis) = vis else {
17853947 return BTreeSet::new();
17863948 };
1787− let mut seen = BTreeSet::new();
1788− let mut stack: Vec<String> = ids.iter().cloned().collect();
1789− while let Some(id) = stack.pop() {
1790− if seen.insert(id.clone()) {
1791− if let Some(rec) = vis.commits.get(&id) {
1792− if !rec.first_parent.is_empty() {
1793− stack.push(rec.first_parent.clone());
1794− }
1795− }
1796− }
1797− }
1798− seen
3949+ // every jj parent, not just the first: rewriting a commit reached
3950+ // only through a merge's second parent rewrites the merge too
3951+ // (§7.5 step 3)
3952+ let seeds: Vec<String> = ids.iter().cloned().collect();
3953+ ancestors_of_change_ids(&vis, &seeds)
17993954 }
18003955
18013956 fn replay(&self, onto: &[Value], from: &[Value], to: &[Value]) -> Result<Vec<Value>, Crash> {
3957+ self.replay_snapshot(
3958+ &Value::list(onto.to_vec()),
3959+ &Value::list(from.to_vec()),
3960+ &Value::list(to.to_vec()),
3961+ )?.as_list().map(<[Value]>::to_vec)
3962+ }
3963+
3964+ fn replay_snapshot(&self, onto: &Value, from: &Value, to: &Value) -> Result<Value, Crash> {
18023965 let store = self.current_repo().store().clone();
1803− block_on(jj_replay(&store, onto, from, to))
3966+ block_on(jj_replay(&store, &self.replay_cache, onto, from, to))
18043967 }
18053968}
18063969
@@ -1808,30 +3971,197 @@ impl Backend for JjBackend {
18083971// jj three-way tree merge for `replay` (§7.3)
18093972// ----------------------------------------------------------------------
18103973
3974+/// Replay sees the same lists repeatedly: a parent's old and new files
3975+/// become each child's bases. Retain their validated trees by list identity,
3976+/// including its offset, without keeping discarded snapshots alive.
3977+#[derive(Default)]
3978+struct ReplayCache {
3979+ trees: RefCell<HashMap<(*const Vec<Value>, usize), ReplayTree>>,
3980+ empty_subtrees: RefCell<HashMap<TreeId, bool>>,
3981+ next_sweep: Cell<usize>,
3982+ blobs: BlobCache,
3983+ entries: EntryCache,
3984+}
3985+
3986+struct ReplayTree {
3987+ storage: Weak<Vec<Value>>,
3988+ tree: MergedTree,
3989+ /// A list read from a resolved tree, already in canonical path order.
3990+ canonical: bool,
3991+}
3992+
3993+impl ReplayCache {
3994+ fn remember(&self, list: &ListVal, tree: MergedTree, canonical: bool) {
3995+ let mut trees = self.trees.borrow_mut();
3996+ trees.insert(list.identity(), ReplayTree {
3997+ storage: list.weak_storage(),
3998+ tree,
3999+ canonical,
4000+ });
4001+ if trees.len() >= self.next_sweep.get().max(256) {
4002+ trees.retain(|_, t| t.storage.strong_count() != 0);
4003+ self.next_sweep.set((trees.len() * 2).max(256));
4004+ }
4005+ }
4006+
4007+ async fn tree_of(&self, store: &Arc<Store>, files: &Value) -> Result<(MergedTree, bool), Crash> {
4008+ let Value::List(list) = files else {
4009+ files.as_list()?;
4010+ unreachable!();
4011+ };
4012+ if let Some(cached) = self.trees.borrow().get(&list.identity()) {
4013+ // The weak reference keeps its allocation reserved, so another
4014+ // list cannot reuse this address while the key is cached.
4015+ if cached.storage.strong_count() != 0 {
4016+ return Ok((cached.tree.clone(), cached.canonical));
4017+ }
4018+ }
4019+ // Validate every new list before caching it, even when from == to:
4020+ // an unchanged change must still reject malformed snapshots.
4021+ let tree = build_tree(store, files).await?;
4022+ self.remember(list, tree.clone(), false);
4023+ Ok((tree, false))
4024+ }
4025+
4026+ async fn files_of(&self, store: &Arc<Store>, tree: &MergedTree) -> Result<Value, Crash> {
4027+ let (files, reusable) = tree_to_files_inner(store, tree, &self.blobs, &self.entries).await?;
4028+ let list = ListVal::new(files);
4029+ if reusable && !self.has_empty_subtrees(tree).await? {
4030+ self.remember(&list, tree.clone(), true);
4031+ }
4032+ Ok(Value::List(list))
4033+ }
4034+
4035+ /// Flattening a Git tree omits empty directories. Reusing such a tree
4036+ /// would let a replay conflict with a directory absent from its input
4037+ /// snapshot. Inspect directories once per tree, without rebuilding files.
4038+ async fn has_empty_subtrees(&self, tree: &MergedTree) -> Result<bool, Crash> {
4039+ let Some(root) = tree.tree_ids().as_resolved() else { return Ok(true) };
4040+ if let Some(found) = self.empty_subtrees.borrow().get(root) {
4041+ return Ok(*found);
4042+ }
4043+ let store = tree.store();
4044+ let mut pending = vec![(RepoPathBuf::root(), root.clone())];
4045+ let mut visited = BTreeSet::new();
4046+ while let Some((dir, id)) = pending.pop() {
4047+ if !visited.insert(id.clone()) {
4048+ continue;
4049+ }
4050+ let known = self.empty_subtrees.borrow().get(&id).copied();
4051+ if let Some(found) = known {
4052+ if found {
4053+ self.empty_subtrees.borrow_mut().insert(root.clone(), true);
4054+ return Ok(true);
4055+ }
4056+ continue;
4057+ }
4058+ let loaded = store.get_tree(dir.clone(), &id).await
4059+ .map_err(|e| Crash::new(format!("cannot read a tree: {}", e)))?;
4060+ for entry in loaded.entries_non_recursive() {
4061+ if let TreeValue::Tree(child) = entry.value() {
4062+ if child == store.empty_tree_id() {
4063+ self.empty_subtrees.borrow_mut().insert(root.clone(), true);
4064+ return Ok(true);
4065+ }
4066+ pending.push((dir.join(entry.name()), child.clone()));
4067+ }
4068+ }
4069+ }
4070+ // Every subtree visited was nonempty all the way down. Sharing this
4071+ // answer also avoids rescanning unchanged directories of later trees.
4072+ self.empty_subtrees.borrow_mut().extend(visited.into_iter().map(|id| (id, false)));
4073+ Ok(false)
4074+ }
4075+}
4076+
18114077async fn jj_replay(
18124078 store: &Arc<Store>,
1813− onto: &[Value],
1814− from: &[Value],
1815− to: &[Value],
1816−) -> Result<Vec<Value>, Crash> {
1817− let onto_tree = build_tree(store, &Value::list(onto.to_vec())).await?;
1818− let from_tree = build_tree(store, &Value::list(from.to_vec())).await?;
1819− let to_tree = build_tree(store, &Value::list(to.to_vec())).await?;
1820− let merge = Merge::from_removes_adds(
1821− vec![from_tree.tree_ids().as_resolved().cloned().unwrap()],
1822− vec![
1823− onto_tree.tree_ids().as_resolved().cloned().unwrap(),
1824− to_tree.tree_ids().as_resolved().cloned().unwrap(),
1825− ],
1826− );
1827− // tree_merge::merge_trees resolves file contents line-level and keeps
1828− // unresolvable paths as conflicts (tree_merge.rs:78)
1829− let merged = jj_lib::tree_merge::merge_trees(store, merge)
1830− .await
1831− .map_err(|e| Crash::new(format!("replay: {}", e)))?;
4079+ cache: &ReplayCache,
4080+ onto: &Value,
4081+ from: &Value,
4082+ to: &Value,
4083+) -> Result<Value, Crash> {
4084+ let (onto_tree, onto_canonical) = cache.tree_of(store, onto).await?;
4085+ let (from_tree, from_canonical) = cache.tree_of(store, from).await?;
4086+ let (to_tree, to_canonical) = cache.tree_of(store, to).await?;
4087+ // any of the three may itself be conflicted (a snapshot with unresolved
4088+ // blobs, §7.3), so merge the trees the way jj's rebase does:
4089+ // MergedTree::merge flattens conflicted inputs into one merge, simplifies
4090+ // it, and resolves file contents line-level with tree_merge::merge_trees,
4091+ // keeping unresolvable paths as conflicts. Empty labels leave the result
4092+ // unlabeled; the labels would not survive tree_to_files anyway.
4093+ let merged = MergedTree::merge(Merge::from_removes_adds(
4094+ vec![(from_tree.clone(), String::new())],
4095+ vec![(onto_tree.clone(), String::new()), (to_tree.clone(), String::new())],
4096+ ))
4097+ .await
4098+ .map_err(|e| Crash::new(format!("replay: {}", e)))?;
4099+ refuse_file_directory_clash(&merged, &onto_tree, &from_tree, &to_tree).await?;
4100+ // Reusing a resolved tree's list preserves replay's sorted output. A
4101+ // constructed list may have another order, and conflicts may simplify
4102+ // on reading, so neither can take this shortcut.
4103+ for (files, tree, canonical) in [
4104+ (onto, &onto_tree, onto_canonical),
4105+ (to, &to_tree, to_canonical),
4106+ (from, &from_tree, from_canonical),
4107+ ] {
4108+ if canonical && merged.tree_ids() == tree.tree_ids() {
4109+ return Ok(files.clone());
4110+ }
4111+ }
18324112 // conflicts stay as unresolved blobs (§7.3)
1833− let merged = MergedTree::new(store.clone(), merged, jj_lib::conflict_labels::ConflictLabels::unlabeled());
1834− tree_to_files(store, &merged, &BlobCache::default(), &EntryCache::default()).await
4113+ cache.files_of(store, &merged).await
4114+}
4115+
4116+/// Crash if the merge left a conflict with a file on one side and, on
4117+/// another, a directory `onto` or `to` holds at that path: one entry cannot
4118+/// list that directory's entries (§7.3). A conflict that `onto`, `from` or
4119+/// `to` already holds at the path is not made here: its sides, a directory
4120+/// side jj made earlier included, came in as an unresolved blob, and replay
4121+/// carries them beside the others, as when the child resolving that conflict
4122+/// is squashed into it.
4123+async fn refuse_file_directory_clash(
4124+ merged: &MergedTree,
4125+ onto: &MergedTree,
4126+ from: &MergedTree,
4127+ to: &MergedTree,
4128+) -> Result<(), Crash> {
4129+ let value_at = async |tree: &MergedTree, path: &RepoPath| {
4130+ tree.path_value(path).await.map_err(|e| Crash::new(format!("replay: {}", e)))
4131+ };
4132+ // walks only the unresolved part of the tree: nothing when it is resolved
4133+ for (path, value) in merged.conflicts() {
4134+ let value = value.map_err(|e| Crash::new(format!("replay: {}", e)))?;
4135+ // sides that cancel are no sides
4136+ let value = value.simplify();
4137+ let has = |dir: bool| {
4138+ value
4139+ .adds()
4140+ .any(|v| matches!(v, Some(t) if matches!(t, TreeValue::Tree(_)) == dir))
4141+ };
4142+ if !has(false) || !has(true) {
4143+ continue;
4144+ }
4145+ let held = [
4146+ value_at(onto, &path).await?,
4147+ value_at(from, &path).await?,
4148+ value_at(to, &path).await?,
4149+ ];
4150+ // an input's conflict at the path, as opposed to one inside the
4151+ // directory there, which leaves the path a directory in the input
4152+ if held.iter().any(|h| !h.is_resolved() && !h.is_tree()) {
4153+ continue;
4154+ }
4155+ let side = |dir: &MergedTreeValueT| {
4156+ dir.is_tree() && value.adds().any(|v| v.is_some() && dir.iter().any(|h| h == v))
4157+ };
4158+ if side(&held[0]) || side(&held[2]) {
4159+ let comps: Vec<String> =
4160+ path.components().map(|c| c.as_internal_str().to_string()).collect();
4161+ return Err(crate::domain::file_directory_clash(&comps));
4162+ }
4163+ }
4164+ Ok(())
18354165}
18364166
18374167// ----------------------------------------------------------------------
@@ -1852,6 +4182,107 @@ impl ConflictTreeValue {
18524182 }
18534183}
18544184
4185+/// The stored trees a program's commits may still hold the files of, to
4186+/// write each such commit with the tree itself (§7.5 step 4). A list holds
4187+/// each conflict simplified (`merged_value_to_blob`), and the tree it was
4188+/// loaded from need not, so a tree built from the list could differ from
4189+/// the stored one where the files do not: a child `new` made would not be
4190+/// empty, as jj's own `new` leaves one.
4191+struct LoadedTrees<'a> {
4192+ /// the stored commits the program's repo was loaded from
4193+ visible: Option<&'a VisibleRepo>,
4194+ /// the lists the lazy `files` of the program's commits have loaded, by
4195+ /// identity: `c.files` takes the list out, so a commit given it, as
4196+ /// `new` gives its child its parent's, holds the list itself
4197+ lists: HashMap<(*const Vec<Value>, usize), Rc<ThunkVal>>,
4198+}
4199+
4200+impl<'a> LoadedTrees<'a> {
4201+ /// The trees `repos`' commits were loaded with; nothing is read
4202+ fn new(visible: Option<&'a VisibleRepo>, repos: &[&Value]) -> Result<LoadedTrees<'a>, Crash> {
4203+ let mut lists = HashMap::new();
4204+ for repo in repos {
4205+ for commit in crate::repo::all_commits(repo)? {
4206+ if let Value::Thunk(t) = crate::repo::files_of(&commit)? {
4207+ if let (Some(_), Some(Value::List(list))) = (t.tree(), t.peek()) {
4208+ lists.insert(list.identity(), t);
4209+ }
4210+ }
4211+ }
4212+ }
4213+ Ok(LoadedTrees { visible, lists })
4214+ }
4215+
4216+ /// The tree to write for a commit on `parents` whose files are `files`:
4217+ /// where they are still a list loaded from a stored tree, that tree as
4218+ /// jj stored it, and otherwise the one `build_tree` makes of them; but
4219+ /// where either lists the parents' files, the parents' tree
4220+ fn tree_to_write(&self, repo: &dyn Repo, files: &Value, parents: &[CommitId]) -> Result<MergedTree, Crash> {
4221+ let tree = self.tree_of(repo.store(), files)?;
4222+ Ok(block_on(parents_tree_if_same(repo, parents, tree)))
4223+ }
4224+
4225+ fn tree_of(&self, store: &Arc<Store>, files: &Value) -> Result<MergedTree, Crash> {
4226+ let loaded = match files {
4227+ Value::Thunk(t) => Some(t),
4228+ Value::List(list) => self.lists.get(&list.identity()),
4229+ _ => None,
4230+ };
4231+ if let Some(t) = loaded {
4232+ let stored = t.origin().zip(self.visible).and_then(|(id, vis)| vis.commits.get(id));
4233+ if let Some(tree) = stored.map(|rec| rec.commit.tree()) {
4234+ if t.tree() == Some(tree_name(&tree).as_str()) {
4235+ return Ok(tree);
4236+ }
4237+ }
4238+ }
4239+ block_on(build_tree(store, &files.forced()?))
4240+ }
4241+}
4242+
4243+/// The tree of `parents`, merged where there are several, as jj's emptiness
4244+/// takes it, where it lists the files `tree` lists, and otherwise `tree`
4245+/// (§7.5 step 4). A list holds each conflict less the sides that cancel
4246+/// (§7.3), and a tree built from it holds no labels, where a tree jj merged
4247+/// keeps both; so a commit left with its parent's files by `squash`,
4248+/// `rebase` or `abandon`, which build the files anew, got another tree for
4249+/// them: jj took it for a change at each conflicted path, the tree's empty
4250+/// mark (`is_empty`) did not show, and the checkout wrote the conflicts
4251+/// again with generic labels. So did a commit moved onto another tree for
4252+/// its files, which kept its own, and a snapshot that reverted the commit's
4253+/// change, which builds on the commit's tree (§7.4). Files with no conflict
4254+/// make one tree, so a resolved parents' tree is not compared, nor one of
4255+/// the same ids, and the comparison stops at the first path where the two
4256+/// differ. It only chooses between two trees for the same files, so where
4257+/// the parents' cannot be read, it is `tree`.
4258+async fn parents_tree_if_same(repo: &dyn Repo, parents: &[CommitId], tree: MergedTree) -> MergedTree {
4259+ let mut commits = Vec::with_capacity(parents.len());
4260+ for id in parents {
4261+ match repo.store().get_commit_async(id).await {
4262+ Ok(commit) => commits.push(commit),
4263+ Err(_) => return tree,
4264+ }
4265+ }
4266+ let Ok(parents_tree) = jj_lib::rewrite::merge_commit_trees(repo, &commits).await else {
4267+ return tree;
4268+ };
4269+ // the same terms: the parents' tree, with jj's labels for them
4270+ if parents_tree.tree_ids() == tree.tree_ids() {
4271+ return parents_tree;
4272+ }
4273+ if parents_tree.tree_ids().is_resolved() {
4274+ return tree;
4275+ }
4276+ let mut diff = parents_tree.diff_stream(&tree, &EverythingMatcher);
4277+ while let Some(entry) = diff.next().await {
4278+ match entry.values {
4279+ Ok(Diff { before, after }) if value_as_read(&before) == value_as_read(&after) => {}
4280+ _ => return tree,
4281+ }
4282+ }
4283+ parents_tree
4284+}
4285+
18554286async fn build_tree(store: &Arc<Store>, files: &Value) -> Result<MergedTree, Crash> {
18564287 let entries = files
18574288 .as_list()
@@ -1911,19 +4342,41 @@ async fn blob_to_tree_value(store: &Arc<Store>, content: &Value) -> Result<Merge
19114342 return Err(Crash::new("persistence: malformed conflict blob"));
19124343 }
19134344 // jj order: [add, (remove, add)*] → Merge with interleaved adds
1914− // and removes
4345+ // and removes; each side keeps its absence, its own file type or
4346+ // its directory (§7.3), so the tree written is the one the
4347+ // conflict was read from
19154348 let mut values: Vec<Option<TreeValue>> = Vec::new();
19164349 for side in sides {
1917− let mut slice: &[u8] = side.as_ref();
1918− let id = store
1919− .write_file(&RepoPathBuf::root(), &mut slice)
1920− .await
1921− .map_err(|e| Crash::new(format!("cannot write a file: {}", e)))?;
1922− values.push(Some(TreeValue::File {
1923− id,
1924− executable: b.kind == BlobKind::Executable,
1925− copy_id: CopyId::placeholder(),
1926− }));
4350+ let Some(side) = side else {
4351+ values.push(None);
4352+ continue;
4353+ };
4354+ let value = if let Some(hex) = &side.tree {
4355+ TreeValue::Tree(
4356+ TreeId::try_from_hex(hex)
4357+ .ok_or_else(|| Crash::new("persistence: bad tree id"))?,
4358+ )
4359+ } else if side.kind == BlobKind::Symlink {
4360+ let target = std::str::from_utf8(&side.bytes)
4361+ .map_err(|_| Crash::new("persistence: symlink target is not UTF-8"))?;
4362+ let id = store
4363+ .write_symlink(&RepoPathBuf::root(), target)
4364+ .await
4365+ .map_err(|e| Crash::new(format!("cannot write a symlink: {}", e)))?;
4366+ TreeValue::Symlink(id)
4367+ } else {
4368+ let mut slice: &[u8] = side.bytes.as_ref();
4369+ let id = store
4370+ .write_file(&RepoPathBuf::root(), &mut slice)
4371+ .await
4372+ .map_err(|e| Crash::new(format!("cannot write a file: {}", e)))?;
4373+ TreeValue::File {
4374+ id,
4375+ executable: side.kind == BlobKind::Executable,
4376+ copy_id: CopyId::placeholder(),
4377+ }
4378+ };
4379+ values.push(Some(value));
19274380 }
19284381 let mut adds = Vec::new();
19294382 let mut removes = Vec::new();
@@ -1953,34 +4406,49 @@ fn to_repo_path(path: &[String]) -> Result<RepoPathBuf, Crash> {
19534406// jj trees -> values
19544407// ----------------------------------------------------------------------
19554408
1956−/// blob contents keyed by file id, shared across the whole repo load: most
1957−/// files are unchanged between commits, so inflating each blob once (instead
1958−/// of once per commit that references it) keeps loading linear in the
1959−/// repository's unique data rather than commits × files
4409+/// Blob contents keyed by file id, shared while snapshots hold their bytes.
4410+/// Weak entries let discarded replay results release their conflict sides.
19604411#[derive(Clone, Default)]
1961−struct BlobCache(Rc<RefCell<HashMap<FileId, Rc<Vec<u8>>>>>);
4412+struct BlobCache {
4413+ bytes: Rc<RefCell<HashMap<FileId, Weak<Vec<u8>>>>>,
4414+ next_sweep: Rc<Cell<usize>>,
4415+}
19624416
19634417impl BlobCache {
19644418 fn get(&self, id: &FileId) -> Option<Rc<Vec<u8>>> {
1965− self.0.borrow().get(id).cloned()
4419+ self.bytes.borrow().get(id).and_then(Weak::upgrade)
19664420 }
19674421 fn insert(&self, id: FileId, bytes: Rc<Vec<u8>>) {
1968− self.0.borrow_mut().insert(id, bytes);
4422+ let mut cached = self.bytes.borrow_mut();
4423+ cached.insert(id, Rc::downgrade(&bytes));
4424+ if cached.len() >= self.next_sweep.get().max(256) {
4425+ cached.retain(|_, bytes| bytes.strong_count() != 0);
4426+ self.next_sweep.set((cached.len() * 2).max(256));
4427+ }
19694428 }
19704429}
19714430
1972−/// whole file *entries* ({path, content}) keyed by (path, blob identity),
1973−/// shared across the whole repo load: an unchanged file is the same Value in
1974−/// every commit, so it is built once and cloned (an Rc bump) thereafter
4431+/// Whole file entries keyed by (path, blob identity), shared by loaded
4432+/// snapshots and replays. Weak references let transient replay results go
4433+/// away while unchanged entries still held by a snapshot are reused.
19754434#[derive(Clone, Default)]
1976−struct EntryCache(Rc<RefCell<HashMap<String, Value>>>);
4435+struct EntryCache {
4436+ entries: Rc<RefCell<HashMap<String, Weak<crate::value::RecordMap>>>>,
4437+ next_sweep: Rc<Cell<usize>>,
4438+}
19774439
19784440impl EntryCache {
19794441 fn get(&self, key: &str) -> Option<Value> {
1980− self.0.borrow().get(key).cloned()
4442+ self.entries.borrow().get(key).and_then(Weak::upgrade).map(Value::Record)
19814443 }
19824444 fn insert(&self, key: String, entry: Value) {
1983− self.0.borrow_mut().insert(key, entry);
4445+ let Value::Record(record) = entry else { return };
4446+ let mut entries = self.entries.borrow_mut();
4447+ entries.insert(key, Rc::downgrade(&record));
4448+ if entries.len() >= self.next_sweep.get().max(256) {
4449+ entries.retain(|_, entry| entry.strong_count() != 0);
4450+ self.next_sweep.set((entries.len() * 2).max(256));
4451+ }
19844452 }
19854453}
19864454
@@ -2008,20 +4476,64 @@ async fn read_file_bytes(
20084476 Ok(buf)
20094477}
20104478
2011−async fn side_to_bytes(
4479+/// one side of a conflict as jj stores it: absent (`None`, a deletion
4480+/// conflict), or content with that side's own file type (§7.3)
4481+async fn conflict_side(
20124482 store: &Arc<Store>,
20134483 path: &RepoPath,
20144484 v: &Option<TreeValue>,
20154485 cache: &BlobCache,
2016−) -> Result<Rc<Vec<u8>>, Crash> {
4486+) -> Result<Option<ConflictSide>, Crash> {
20174487 match v {
2018− Some(TreeValue::File { id, .. }) => read_file_bytes(store, path, id, cache).await,
2019− Some(TreeValue::Symlink(id)) => store
2020− .read_symlink(path, id)
2021− .await
2022− .map(|s| Rc::new(s.into_bytes()))
2023− .map_err(|e| Crash::new(format!("cannot read a symlink: {}", e))),
2024− _ => Ok(Rc::new(Vec::new())),
4488+ None => Ok(None),
4489+ Some(TreeValue::File { id, executable, .. }) => Ok(Some(ConflictSide {
4490+ kind: if *executable {
4491+ BlobKind::Executable
4492+ } else {
4493+ BlobKind::Regular
4494+ },
4495+ bytes: read_file_bytes(store, path, id, cache).await?,
4496+ tree: None,
4497+ })),
4498+ Some(TreeValue::Symlink(id)) => {
4499+ let target = store
4500+ .read_symlink(path, id)
4501+ .await
4502+ .map_err(|e| Crash::new(format!("cannot read a symlink: {}", e)))?;
4503+ Ok(Some(ConflictSide {
4504+ kind: BlobKind::Symlink,
4505+ bytes: Rc::new(target.into_bytes()),
4506+ tree: None,
4507+ }))
4508+ }
4509+ // a directory side, in a conflict between a file and a directory,
4510+ // reads as empty content but keeps its tree, so writing the conflict
4511+ // back cannot put an empty file where the directory was
4512+ Some(TreeValue::Tree(id)) => Ok(Some(ConflictSide {
4513+ kind: BlobKind::Regular,
4514+ bytes: Rc::new(Vec::new()),
4515+ tree: Some(id.hex()),
4516+ })),
4517+ // a submodule side reads as an empty file, as a resolved one does in
4518+ // tree_value_to_blob (§12)
4519+ Some(TreeValue::GitSubmodule(_)) => Ok(Some(ConflictSide::regular(&[]))),
4520+ }
4521+}
4522+
4523+/// The value a list reads where a tree holds `value` (§7.3): a conflict less
4524+/// each pair of sides that cancel, one added and one removed the same, as jj
4525+/// simplifies a file conflict to write it out. jj keeps every term of a
4526+/// merge it cannot resolve, pairs that cancel included, so a replayed
4527+/// commit's conflict inherited from its parent was not the parent's, and
4528+/// each commit a rebase replayed above took two more sides for it. Where
4529+/// only directory sides would be left, the value is kept whole: jj keeps it
4530+/// as one conflict at the path, and the simplified one would be a conflict
4531+/// between directories, which jj merges entry by entry. Two values read
4532+/// the same are the same entry of a list.
4533+fn value_as_read(value: &MergedTreeValueT) -> std::borrow::Cow<'_, MergedTreeValueT> {
4534+ match (!value.is_resolved()).then(|| value.simplify()).filter(|v| !v.is_tree()) {
4535+ Some(simplified) => std::borrow::Cow::Owned(simplified),
4536+ None => std::borrow::Cow::Borrowed(value),
20254537 }
20264538}
20274539
@@ -2031,6 +4543,9 @@ async fn merged_value_to_blob(
20314543 value: &jj_lib::backend::MergedTreeValue,
20324544 cache: &BlobCache,
20334545) -> Result<Value, Crash> {
4546+ // the conflict less the sides that cancel (§7.3)
4547+ let read = value_as_read(value);
4548+ let value = read.as_ref();
20344549 if let Some(v) = value.as_resolved() {
20354550 return match v {
20364551 Some(tv) => tree_value_to_blob(store, path, tv).await,
@@ -2041,11 +4556,11 @@ async fn merged_value_to_blob(
20414556 };
20424557 }
20434558 // conflict sides, jj order: [add, (remove, add)*] (§7.3)
2044− let mut sides: Vec<Rc<Vec<u8>>> = Vec::new();
4559+ let mut sides: Vec<Option<ConflictSide>> = Vec::new();
20454560 for (i, add) in value.adds().enumerate() {
2046− sides.push(side_to_bytes(store, path, add, cache).await?);
4561+ sides.push(conflict_side(store, path, add, cache).await?);
20474562 if let Some(remove) = value.get_remove(i) {
2048− sides.push(side_to_bytes(store, path, remove, cache).await?);
4563+ sides.push(conflict_side(store, path, remove, cache).await?);
20494564 }
20504565 }
20514566 Ok(Value::Blob(Rc::new(BlobVal {
@@ -2097,8 +4612,9 @@ async fn tree_value_to_blob(
20974612 content: BlobContent::Resolved(Rc::new(target.into_bytes())),
20984613 })))
20994614 }
2100− // a conflicted directory is emitted without its children by
2101− // MergedTree::entries; §12 puts such repairs out of scope
4615+ // MergedTree::entries recurses into a resolved directory, and a
4616+ // directory side of a conflict goes through conflict_side, so this is
4617+ // a submodule, which §12 puts out of scope
21024618 TreeValue::Tree(_) | TreeValue::GitSubmodule(_) => Ok(Value::Blob(Rc::new(BlobVal {
21034619 kind: BlobKind::Regular,
21044620 content: BlobContent::Resolved(Rc::new(Vec::new())),
@@ -2112,9 +4628,27 @@ async fn tree_to_files(
21124628 cache: &BlobCache,
21134629 entries: &EntryCache,
21144630) -> Result<Vec<Value>, Crash> {
4631+ tree_to_files_inner(store, tree, cache, entries).await.map(|(files, _)| files)
4632+}
4633+
4634+/// Whether these files can reuse the original tree in replay. Reading a
4635+/// conflict simplifies it, and an unsupported git submodule reads as an
4636+/// empty file, so their original trees cannot represent the resulting list.
4637+async fn tree_to_files_inner(
4638+ store: &Arc<Store>,
4639+ tree: &MergedTree,
4640+ cache: &BlobCache,
4641+ entries: &EntryCache,
4642+) -> Result<(Vec<Value>, bool), Crash> {
21154643 let mut out = Vec::new();
4644+ let mut reusable = tree.tree_ids().is_resolved();
21164645 for (path, value) in tree.entries() {
21174646 let value = value.map_err(|e| Crash::new(format!("cannot read a tree entry: {}", e)))?;
4647+ reusable &= match value.as_resolved() {
4648+ Some(Some(TreeValue::File { copy_id, .. })) => *copy_id == CopyId::placeholder(),
4649+ Some(Some(TreeValue::Symlink(_))) => true,
4650+ _ => false,
4651+ };
21184652 // cache key: the entry is fully determined by its path and the
21194653 // identity of its (resolved) content; conflicts are not cached
21204654 let key = match value.as_resolved() {
@@ -2145,13 +4679,22 @@ async fn tree_to_files(
21454679 };
21464680 out.push(entry);
21474681 }
2148− Ok(out)
4682+ Ok((out, reusable))
21494683}
21504684
21514685// ----------------------------------------------------------------------
21524686// value helpers
21534687// ----------------------------------------------------------------------
21544688
4689+/// The name of a stored tree that a commit's lazy `files` carry
4690+/// (`ThunkVal::tree`): its tree ids, one per term of a conflicted tree. The
4691+/// list `tree_to_files` makes depends on nothing else, so two trees of one
4692+/// name list the same entries.
4693+fn tree_name(tree: &MergedTree) -> String {
4694+ let ids: Vec<String> = tree.tree_ids().iter().map(|id| id.hex()).collect();
4695+ ids.join(",")
4696+}
4697+
21554698fn commit_id_of(commit: &Value) -> Result<String, Crash> {
21564699 match commit.field("id")? {
21574700 Value::Id(i) => Ok(i.to_string()),
@@ -2179,15 +4722,13 @@ fn new_id_set(repo: &Value) -> Result<BTreeSet<String>, Crash> {
21794722// building the Repo value from the visible map (§7.2)
21804723// ----------------------------------------------------------------------
21814724
2182−fn build_repo_value(vis: &VisibleRepo, focus_change: &str) -> Result<Value, OpenError> {
4725+fn build_repo_value(vis: &VisibleRepo, focus_change: &str, replay_cache: &Rc<ReplayCache>) -> Result<Value, OpenError> {
21834726 let root_rec = vis
21844727 .commits
21854728 .get(ROOT_ID)
21864729 .ok_or_else(|| (2, "no root commit".to_string()))?;
21874730 let store = root_rec.commit.store().clone();
2188− let cache = BlobCache::default();
2189− let entries = EntryCache::default();
2190− let mut subtree = build_subtree(vis, root_rec, &store, &cache, &entries)?;
4731+ let mut subtree = build_subtree(vis, root_rec, &store, replay_cache)?;
21914732 sort_subtree(&mut subtree, vis);
21924733 let top = Value::record(&[
21934734 ("children", subtree.field("children").map_err(|e| (2, e.msg))?),
@@ -2204,19 +4745,20 @@ fn build_subtree(
22044745 vis: &VisibleRepo,
22054746 rec: &StoredCommit,
22064747 store: &Arc<Store>,
2207− cache: &BlobCache,
2208− entries: &EntryCache,
4748+ replay_cache: &Rc<ReplayCache>,
22094749) -> Result<Value, OpenError> {
22104750 // the files list is lazy (§7.2): most commits are never inspected, so
2211− // their file entries are materialized only on first `field("files")`
4751+ // their file entries are materialized only on first `field("files")`.
4752+ // Tagged with the change id, it tells the renderer when the O(1)
4753+ // `has_conflict`/`is_empty` answers still describe the commit's files;
4754+ // named by its tree, it lets persistence compare it unread (§7.5)
22124755 let files_thunk = {
22134756 let store = store.clone();
22144757 let tree = rec.commit.tree();
2215− let cache = cache.clone();
2216− let entries = entries.clone();
2217− Value::Thunk(Rc::new(crate::value::ThunkVal::new(move || {
2218− let files = block_on(tree_to_files(&store, &tree, &cache, &entries))?;
2219− Ok(Value::list(files))
4758+ let name = Some(tree_name(&tree));
4759+ let replay_cache = replay_cache.clone();
4760+ Value::Thunk(Rc::new(crate::value::ThunkVal::stored(rec.change_id.clone(), name, move || {
4761+ block_on(replay_cache.files_of(&store, &tree))
22204762 })))
22214763 };
22224764 let labels: Vec<Value> = vis
@@ -2237,7 +4779,7 @@ fn build_subtree(
22374779 if let Some(kids) = vis.children.get(&rec.change_id) {
22384780 for kid in kids {
22394781 if let Some(krec) = vis.commits.get(kid) {
2240− children.push(build_subtree(vis, krec, store, cache, entries)?);
4782+ children.push(build_subtree(vis, krec, store, replay_cache)?);
22414783 }
22424784 }
22434785 }
@@ -2272,3 +4814,165 @@ fn sort_subtree(subtree: &mut Value, vis: &VisibleRepo) {
22724814 let root = subtree.field("root").expect("subtree has a root");
22734815 *subtree = Value::record(&[("children", Value::list(kids)), ("root", root)]);
22744816}
4817+
4818+#[cfg(test)]
4819+mod tests {
4820+ use super::*;
4821+ use jj_lib::backend::SymlinkId;
4822+ use jj_lib::conflict_labels::ConflictLabels;
4823+
4824+ fn replay_store() -> (tempfile::TempDir, Arc<Store>) {
4825+ let dir = tempfile::tempdir().unwrap();
4826+ let (_, repo) = block_on(Workspace::init_colocated_git(
4827+ &user_settings(None), dir.path(), gix::hash::Kind::Sha1,
4828+ )).unwrap();
4829+ (dir, repo.store().clone())
4830+ }
4831+
4832+ fn file(path: &str, content: &str) -> Value {
4833+ Value::record(&[
4834+ ("path", Value::list(vec![Value::text(path)])),
4835+ ("content", BlobVal::text_blob(content)),
4836+ ])
4837+ }
4838+
4839+ fn loaded_files(store: &Arc<Store>, cache: &ReplayCache, files: Vec<Value>) -> Value {
4840+ let tree = block_on(build_tree(store, &Value::list(files))).unwrap();
4841+ block_on(cache.files_of(store, &tree)).unwrap()
4842+ }
4843+
4844+ #[test]
4845+ fn replay_reuses_loaded_snapshots_and_unchanged_entries() {
4846+ let (_dir, store) = replay_store();
4847+ let cache = ReplayCache::default();
4848+ let base = loaded_files(&store, &cache, vec![file("a", "base a"), file("b", "base b")]);
4849+ let same = block_on(jj_replay(&store, &cache, &base, &base, &base)).unwrap();
4850+ let (Value::List(before), Value::List(after)) = (&base, &same) else { panic!() };
4851+ assert_eq!(before.identity(), after.identity(), "no-op replay must retain the snapshot");
4852+
4853+ let onto = loaded_files(&store, &cache, vec![file("a", "onto"), file("b", "base b")]);
4854+ let to = loaded_files(&store, &cache, vec![file("a", "base a"), file("b", "to")]);
4855+ let merged = block_on(jj_replay(&store, &cache, &onto, &base, &to)).unwrap();
4856+ let expected = Value::list(vec![file("a", "onto"), file("b", "to")]);
4857+ assert!(crate::value::value_eq(&merged, &expected).unwrap());
4858+ for (result, input) in [
4859+ (&merged.as_list().unwrap()[0], &onto.as_list().unwrap()[0]),
4860+ (&merged.as_list().unwrap()[1], &to.as_list().unwrap()[1]),
4861+ ] {
4862+ let (Value::Record(result), Value::Record(input)) = (result, input) else { panic!() };
4863+ assert!(Rc::ptr_eq(result, input), "unchanged file entries must be shared");
4864+ }
4865+ }
4866+
4867+ #[test]
4868+ fn replay_cache_preserves_validation_ordering_and_list_offsets() {
4869+ let (_dir, store) = replay_store();
4870+ let cache = ReplayCache::default();
4871+ let unsorted = Value::list(vec![file("b", "b"), file("a", "a")]);
4872+ let empty = Value::list(vec![]);
4873+ let sorted = block_on(jj_replay(&store, &cache, &unsorted, &empty, &empty)).unwrap();
4874+ assert!(crate::value::value_eq(&sorted, &Value::list(vec![file("a", "a"), file("b", "b")])).unwrap());
4875+
4876+ let Value::List(list) = &sorted else { panic!() };
4877+ let tail = Value::List(list.skip(1));
4878+ let replayed_tail = block_on(jj_replay(&store, &cache, &tail, &empty, &empty)).unwrap();
4879+ assert!(crate::value::value_eq(&tail, &replayed_tail).unwrap());
4880+ assert_eq!(replayed_tail.as_list().unwrap().len(), 1);
4881+
4882+ let malformed = Value::list(vec![file("a", "one"), file("a", "two")]);
4883+ let err = block_on(jj_replay(&store, &cache, &sorted, &malformed, &malformed)).unwrap_err();
4884+ assert!(err.msg.contains("duplicate paths"), "{}", err.msg);
4885+ }
4886+
4887+ #[test]
4888+ fn replay_ignores_empty_directories_not_represented_by_snapshots() {
4889+ let (_dir, store) = replay_store();
4890+ let cache = ReplayCache::default();
4891+ let entry = |id| jj_lib::backend::Tree::from_sorted_entries(vec![
4892+ (RepoPathComponentBuf::new("d".to_string()).unwrap(), TreeValue::Tree(id)),
4893+ ]);
4894+ let direct = block_on(store.write_tree(RepoPath::root(), entry(store.empty_tree_id().clone()))).unwrap();
4895+ let nested = block_on(store.write_tree(RepoPath::root(), entry(direct.id().clone()))).unwrap();
4896+ let empty = Value::list(vec![]);
4897+ let to = Value::list(vec![file("d", "file")]);
4898+ for tree in [direct, nested] {
4899+ let tree = MergedTree::resolved(store.clone(), tree.id().clone());
4900+ let onto = block_on(cache.files_of(&store, &tree)).unwrap();
4901+ assert!(onto.as_list().unwrap().is_empty());
4902+ let result = block_on(jj_replay(&store, &cache, &onto, &empty, &to)).unwrap();
4903+ assert!(crate::value::value_eq(&result, &to).unwrap());
4904+ }
4905+ }
4906+
4907+ #[test]
4908+ fn replay_cache_does_not_keep_discarded_snapshots_alive() {
4909+ let (_dir, store) = replay_store();
4910+ let cache = ReplayCache::default();
4911+ let files = loaded_files(&store, &cache, vec![file("a", "a")]);
4912+ let Value::List(list) = &files else { panic!() };
4913+ let storage = list.weak_storage();
4914+ let Value::Record(entry) = &files.as_list().unwrap()[0] else { panic!() };
4915+ let entry = Rc::downgrade(entry);
4916+ drop(files);
4917+ assert!(storage.upgrade().is_none());
4918+ assert!(entry.upgrade().is_none());
4919+
4920+ let bytes = Rc::new(vec![1, 2, 3]);
4921+ let weak_bytes = Rc::downgrade(&bytes);
4922+ let id = FileId::new(vec![1; 20]);
4923+ cache.blobs.insert(id.clone(), bytes.clone());
4924+ assert!(Rc::ptr_eq(&cache.blobs.get(&id).unwrap(), &bytes));
4925+ drop(bytes);
4926+ assert!(weak_bytes.upgrade().is_none());
4927+ assert!(cache.blobs.get(&id).is_none());
4928+ }
4929+
4930+ #[test]
4931+ fn user_settings_preserve_every_ascii_control_character() {
4932+ let identity: String = (0u8..=127).map(char::from).collect();
4933+ let text = format!(
4934+ "[user]\nname = {}\nemail = {}\n",
4935+ toml_string(&identity),
4936+ toml_string(&identity)
4937+ );
4938+ let mut config = StackedConfig::with_defaults();
4939+ config.add_layer(ConfigLayer::parse(ConfigSource::User, &text).unwrap());
4940+ let settings = UserSettings::from_config(config).unwrap();
4941+ assert_eq!(settings.user_name(), identity);
4942+ assert_eq!(settings.user_email(), identity);
4943+ }
4944+
4945+ #[test]
4946+ fn a_description_names_its_terms_whatever_their_labels_and_the_sides_that_cancel() {
4947+ // what a checkout writes for a symlink/file conflict of five terms,
4948+ // and for the three it simplifies to, which a snapshot may write
4949+ // without writing the file again (§7.4)
4950+ let link = Some(TreeValue::Symlink(SymlinkId::new(vec![1; 20])));
4951+ let file = |n: u8| {
4952+ let id = FileId::new(vec![n; 20]);
4953+ Some(TreeValue::File { id, executable: false, copy_id: CopyId::placeholder() })
4954+ };
4955+ let five = Merge::from_vec(vec![link.clone(), link.clone(), file(2), file(1), link.clone()]);
4956+ let three = five.simplify();
4957+ assert_eq!(three.num_sides(), 2);
4958+ let unlabeled = ConflictLabels::unlabeled();
4959+ let written = five.describe(&unlabeled);
4960+ let simpler = three.describe(&unlabeled);
4961+ assert_ne!(written, simpler);
4962+ let terms = described_terms(simpler.as_bytes());
4963+ assert!(terms.is_some());
4964+ assert_eq!(described_terms(written.as_bytes()), terms);
4965+ let names = ["left (one)", "base", "middle", "base 2", "right"];
4966+ let labels = ConflictLabels::from_vec(names.iter().map(|n| n.to_string()).collect());
4967+ let labelled = five.describe(&labels);
4968+ assert!(labelled.contains(" (left (one))\n"), "{}", labelled);
4969+ assert_eq!(described_terms(labelled.as_bytes()), terms);
4970+ // other sides, and text that is no description, are no such conflict
4971+ let other = Merge::from_vec(vec![link.clone(), file(1), file(3)]).describe(&unlabeled);
4972+ assert!(described_terms(other.as_bytes()).is_some_and(|t| Some(t) != terms));
4973+ assert_eq!(described_terms(b"resolved\n"), None);
4974+ assert_eq!(described_terms(written.replace("Conflict:", "CONFLICT:").as_bytes()), None);
4975+ assert_eq!(described_terms(written.trim_end().as_bytes()), None);
4976+ assert_eq!(described_terms(format!("{} Adding file with id 0101 and more\n", written).as_bytes()), None);
4977+ }
4978+}
src/lex.rs+36−22
@@ -45,6 +45,9 @@ pub struct SpTok {
4545 pub tok: Tok,
4646 pub line: usize, // 1-based
4747 pub col: usize, // 1-based
48+ /// byte offsets of the token in the source: `start..end`
49+ pub start: usize,
50+ pub end: usize,
4851}
4952
5053#[derive(Debug)]
@@ -84,6 +87,13 @@ fn valid_label(s: &str) -> bool {
8487pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
8588 let mut toks: Vec<SpTok> = Vec::new();
8689 let chars: Vec<char> = src.chars().collect();
90+ // byte offset of each char, and of the end: tokens carry byte spans so
91+ // the parser can keep a lambda's source text (§5.2)
92+ let bytes: Vec<usize> = src
93+ .char_indices()
94+ .map(|(b, _)| b)
95+ .chain(std::iter::once(src.len()))
96+ .collect();
8797 let mut i = 0usize;
8898 let mut line = 1usize;
8999 let mut col = 1usize; // column of next char
@@ -97,13 +107,15 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
97107 };
98108 }
99109
100− // push helper
110+ // push helper; `$s..$e` are the token's char indices
101111 macro_rules! push {
102− ($t:expr, $l:expr, $c:expr) => {
112+ ($t:expr, $l:expr, $c:expr, $s:expr, $e:expr) => {
103113 toks.push(SpTok {
104114 tok: $t,
105115 line: $l,
106116 col: $c,
117+ start: bytes[$s],
118+ end: bytes[$e],
107119 })
108120 };
109121 }
@@ -111,7 +123,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
111123 while i < chars.len() {
112124 let c = chars[i];
113125 if c == '\n' {
114− push!(Tok::Newline, line, col);
126+ push!(Tok::Newline, line, col, i, i + 1);
115127 i += 1;
116128 line += 1;
117129 col = 1;
@@ -124,6 +136,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
124136 }
125137 let start_line = line;
126138 let start_col = col;
139+ let start = i;
127140 // comments
128141 if c == '-' && i + 1 < chars.len() && chars[i + 1] == '-' {
129142 // could be `--` comment, but `->`? no: `->` is dash-gt, distinct.
@@ -147,7 +160,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
147160 i += 2;
148161 col += 2;
149162 } else if chars[i] == '\n' {
150− push!(Tok::Newline, line, col);
163+ push!(Tok::Newline, line, col, i, i + 1);
151164 i += 1;
152165 line += 1;
153166 col = 1;
@@ -159,14 +172,14 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
159172 if depth > 0 {
160173 err!("unterminated block comment");
161174 }
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);
175+ // a comment is whitespace (§3.1): one spanning lines has already
176+ // ended the line it started on, with the Newlines pushed above,
177+ // and one that does not must leave its line unbroken
165178 continue;
166179 }
167180 // arrow `->` (check before operator `-`)
168181 if c == '-' && i + 1 < chars.len() && chars[i + 1] == '>' {
169− push!(Tok::Arrow, start_line, start_col);
182+ push!(Tok::Arrow, start_line, start_col, i, i + 2);
170183 i += 2;
171184 col += 2;
172185 continue;
@@ -202,7 +215,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
202215 if !comp.is_empty() {
203216 comps.push(comp);
204217 }
205− push!(Tok::PathLit(comps), start_line, start_col);
218+ push!(Tok::PathLit(comps), start_line, start_col, start, i);
206219 continue;
207220 }
208221 // selector: `.` immediately followed by identifier-start
@@ -215,13 +228,13 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
215228 i += 1;
216229 col += 1;
217230 }
218− push!(Tok::Selector(name), start_line, start_col);
231+ push!(Tok::Selector(name), start_line, start_col, start, i);
219232 continue;
220233 }
221234 // `@` id literals
222235 if c == '@' {
223236 if i + 1 >= chars.len() || !is_ident_char(chars[i + 1]) {
224− push!(Tok::NewId, start_line, start_col);
237+ push!(Tok::NewId, start_line, start_col, i, i + 1);
225238 i += 1;
226239 col += 1;
227240 continue;
@@ -239,7 +252,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
239252 if i < chars.len() && is_ident_char(chars[i]) {
240253 err!("invalid id literal");
241254 }
242− push!(Tok::IdLit(s), start_line, start_col);
255+ push!(Tok::IdLit(s), start_line, start_col, start, i);
243256 continue;
244257 }
245258 err!("`@` followed by an identifier character outside k-z is not a valid id literal");
@@ -262,7 +275,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
262275 if !valid_label(&s) {
263276 err!("invalid label literal");
264277 }
265− push!(Tok::LabelLit(s), start_line, start_col);
278+ push!(Tok::LabelLit(s), start_line, start_col, start, i);
266279 continue;
267280 }
268281 // text literal, double- or single-quoted
@@ -308,7 +321,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
308321 }
309322 }
310323 }
311− push!(Tok::Text(s), start_line, start_col);
324+ push!(Tok::Text(s), start_line, start_col, start, i);
312325 continue;
313326 }
314327 // integer
@@ -319,7 +332,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
319332 i += 1;
320333 col += 1;
321334 }
322− push!(Tok::Int(s.parse::<BigInt>().unwrap()), start_line, start_col);
335+ push!(Tok::Int(s.parse::<BigInt>().unwrap()), start_line, start_col, start, i);
323336 continue;
324337 }
325338 // identifier / keyword
@@ -348,7 +361,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
348361 }
349362 _ => Tok::Ident(s),
350363 };
351− push!(t, start_line, start_col);
364+ push!(t, start_line, start_col, start, i);
352365 continue;
353366 }
354367 // type name
@@ -364,7 +377,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
364377 break;
365378 }
366379 }
367− push!(Tok::TypeName(s), start_line, start_col);
380+ push!(Tok::TypeName(s), start_line, start_col, start, i);
368381 continue;
369382 }
370383 // symbolic operators: longest match
@@ -377,16 +390,17 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
377390 }
378391 }
379392 if matched.is_none() {
380− let one = &rest[..1];
393+ // compare by character: `rest` is built from chars, so slicing it
394+ // by byte would panic on any non-ASCII character reaching here
381395 for op in SYMBOLIC_OPS {
382− if op.len() == 1 && *op == one {
396+ if op.len() == 1 && op.starts_with(c) {
383397 matched = Some(op);
384398 break;
385399 }
386400 }
387401 }
388402 if let Some(op) = matched {
389− push!(Tok::Op(op), start_line, start_col);
403+ push!(Tok::Op(op), start_line, start_col, i, i + op.len());
390404 i += op.len();
391405 col += op.len();
392406 continue;
@@ -406,11 +420,11 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
406420 '=' => Tok::Equals,
407421 _ => err!(format!("unexpected character `{}`", c)),
408422 };
409− push!(t, start_line, start_col);
423+ push!(t, start_line, start_col, i, i + 1);
410424 i += 1;
411425 col += 1;
412426 }
413− push!(Tok::Eof, line, col);
427+ push!(Tok::Eof, line, col, i, i);
414428 Ok(toks)
415429}
416430
src/lib.rs+14
@@ -1,14 +1,28 @@
1+/// `eprintln!` for `j`'s own messages (§1.4). A stderr that cannot take the
2+/// line (its reader has exited, as in `j … 2>&1 | head`, or the device is
3+/// full) loses it; `eprintln!` would panic instead, and the run would end
4+/// with the runtime's status 101 rather than the one it reports.
5+#[macro_export]
6+macro_rules! report {
7+ ($($arg:tt)*) => {{
8+ $crate::terminal::report(format_args!($($arg)*));
9+ }};
10+}
11+
112pub mod ast;
213pub mod builtins;
314pub mod config;
15+pub mod diagnostics;
416pub mod domain;
517pub mod eval;
618pub mod lex;
19+mod lookup;
720pub mod parse;
821pub mod render;
922pub mod repo;
1023pub mod shape;
1124pub mod show;
25+pub mod terminal;
1226pub mod value;
1327
1428pub mod jj;
src/lookup.rsnew file+78
@@ -0,0 +1,78 @@
1+//! Indexed snapshot lookups used by the reference configuration.
2+
3+use crate::value::{value_eq, BlobVal, Crash, ListVal, Value};
4+use std::cell::RefCell;
5+use std::collections::{HashMap, VecDeque};
6+use std::rc::Weak;
7+
8+struct SnapshotIndex {
9+ identity: (*const Vec<Value>, usize),
10+ storage: Weak<Vec<Value>>,
11+ // None means a non-path was encountered: use structural equality, as
12+ // the language's entryAt does, including errors in later entries.
13+ paths: Option<HashMap<Vec<String>, usize>>,
14+}
15+
16+thread_local! {
17+ // `diffs` alternates the before/after snapshots. Weak keys and integer
18+ // positions keep neither snapshot nor its blobs alive after the caller.
19+ static SNAPSHOTS: RefCell<VecDeque<SnapshotIndex>> = const { RefCell::new(VecDeque::new()) };
20+}
21+
22+fn path_key(path: &Value) -> Option<Vec<String>> {
23+ path.as_list().ok()?.iter()
24+ .map(|v| v.as_text().ok().map(str::to_owned))
25+ .collect()
26+}
27+
28+fn indexed_entry(path: &[String], entries: &ListVal) -> Option<Option<usize>> {
29+ SNAPSHOTS.with(|cache| {
30+ let mut cache = cache.borrow_mut();
31+ let identity = entries.identity();
32+ if let Some(pos) = cache.iter().position(|c| {
33+ c.identity == identity && c.storage.strong_count() > 0
34+ }) {
35+ let entry = cache.remove(pos).unwrap();
36+ cache.push_front(entry);
37+ } else {
38+ let paths = (|| {
39+ let mut paths = HashMap::new();
40+ for (i, entry) in entries.iter().enumerate() {
41+ let path = path_key(&entry.field("path").ok()?)?;
42+ paths.entry(path).or_insert(i);
43+ }
44+ Some(paths)
45+ })();
46+ cache.push_front(SnapshotIndex {
47+ identity,
48+ storage: entries.weak_storage(),
49+ paths,
50+ });
51+ cache.truncate(2);
52+ }
53+ cache.front().unwrap().paths.as_ref()
54+ .map(|paths| paths.get(path).copied())
55+ })
56+}
57+
58+/// `(head (filter (\e -> e.path == path) files)).content or blob ""`.
59+/// Index ordinary paths; preserve the full filter's errors and first-match
60+/// behavior for arbitrary values the shallow Snapshot contract also admits.
61+pub(crate) fn content_at(path: &Value, files: &Value) -> Result<Value, Crash> {
62+ let lookup = || -> Result<Value, Crash> {
63+ if let (Some(key), Value::List(entries)) = (path_key(path), files) {
64+ if let Some(found) = indexed_entry(&key, entries) {
65+ return found.ok_or_else(|| Crash::new("head: empty list"))
66+ .and_then(|i| entries[i].field("content"));
67+ }
68+ }
69+ let mut first = None;
70+ for entry in files.as_list()? {
71+ if value_eq(&entry.field("path")?, path)? && first.is_none() {
72+ first = Some(entry);
73+ }
74+ }
75+ first.ok_or_else(|| Crash::new("head: empty list"))?.field("content")
76+ };
77+ Ok(lookup().unwrap_or_else(|_| BlobVal::text_blob("")))
78+}
src/main.rs+465−141
@@ -1,7 +1,10 @@
11//! j — a functional-programming-centered CLI for jj repositories (§1).
22
3+mod help;
4+mod setup;
5+
36use j::config::{self, Config};
4−use j::eval::Interp;
7+use j::diagnostics;
58use j::jj::JjBackend;
69use j::parse::parse_expr;
710use j::value::{Crash, Value};
@@ -10,20 +13,47 @@ use std::io::{IsTerminal, Read};
1013use std::process::ExitCode;
1114use std::rc::Rc;
1215
13−const USAGE: &str = "usage: j EXPRESSION… (or echo EXPRESSION | j)";
16+const USAGE: &str = "usage: j EXPRESSION… (or echo EXPRESSION | j)
17+
18+Configure identity: j setup
19+Start a repository: j init or j clone URL
20+Inspect your work: j status or j tree
21+Discover commands: j help or j commands
22+Command details: j help NAME";
1423
1524fn err(status: u8, msg: impl std::fmt::Display) -> ExitCode {
16− eprintln!("j: {}", msg);
25+ j::terminal::report_error(format_args!("j: {}", msg));
1726 ExitCode::from(status)
1827}
1928
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) => {}
29+/// Report a failure that ends the process, keeping the status as a number so
30+/// callers do not have to recover it from an `ExitCode`.
31+fn fail(status: u8, msg: impl std::fmt::Display) -> u8 {
32+ j::terminal::report_error(format_args!("j: {}", msg));
33+ status
34+}
35+
36+/// Write a run's output to stdout (§1.2.9, `ops`). A reader that stops early
37+/// (`j tree | head`) closes the pipe; that is not a failure (§1.4), so the
38+/// rest is dropped and the run still succeeds. Any other write failure is an
39+/// error line. `print!` would panic on either.
40+fn print_output(s: &str) -> ExitCode {
41+ let mut out = std::io::stdout().lock();
42+ match j::terminal::write_output(&mut out, s) {
43+ Ok(()) => ExitCode::SUCCESS,
44+ Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
45+ Err(e) => err(1, format!("cannot write output: {}", e)),
46+ }
47+}
48+
49+fn crash_err(c: &Crash, expr_text: &str, cfg: &Config) -> ExitCode {
50+ j::terminal::report_error(format_args!("j: crash: {}", c.msg));
51+ match &c.def {
52+ Some(d) => j::report!(" in {}, from {}", d, expr_text),
53+ None => j::report!(" from {}", expr_text),
54+ }
55+ if let Some(hint) = diagnostics::expression_hint(expr_text, cfg, c) {
56+ j::report!(" {}", hint);
2757 }
2858 ExitCode::from(1)
2959}
@@ -42,184 +72,371 @@ fn config_path() -> String {
4272/// installed (the share/j/config.j file may not exist next to the binary).
4373const DEFAULT_CONFIG: &str = include_str!("../config.j");
4474
45−fn load_config_file() -> Result<(Config, String), ExitCode> {
75+fn load_config_file() -> Result<(Config, String), u8> {
4676 let path = config_path();
4777 let src = match std::fs::read_to_string(&path) {
4878 Ok(s) => s,
49− Err(_) => {
79+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
5080 // No user config yet: materialise the default there, editable by
5181 // the current user, so they have a starting point to customise.
52− if let Err(e) = write_default_config(&path) {
53− return Err(err(3, format!("cannot create config at {}: {}", path, e)));
82+ match write_default_config(&path) {
83+ Ok(()) => j::report!("j: created an editable default config at {}", path),
84+ // Another invocation may have created it after our read.
85+ // Read that file below, without overwriting it.
86+ Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
87+ Err(e) => return Err(fail(3, format!("cannot create config at {}: {}", path, e))),
5488 }
55− eprintln!("j: created an editable default config at {}", path);
5689 match std::fs::read_to_string(&path) {
5790 Ok(s) => s,
58− Err(e) => return Err(err(3, format!("cannot read config at {}: {}", path, e))),
91+ Err(e) => return Err(fail(3, format!("cannot read config at {}: {}", path, e))),
5992 }
6093 }
94+ Err(e) => return Err(fail(3, format!("cannot read config at {}: {}", path, e))),
6195 };
6296 match config::load_config(&src) {
6397 Ok(c) => Ok((c, src)),
64− Err(e) => Err(err(3, e.message())),
98+ Err(e) => {
99+ let message = match e {
100+ config::ConfigError::Parse(p) => {
101+ diagnostics::format_source_error(&path, &src, p.line, &p.msg)
102+ }
103+ config::ConfigError::Validation(message) => format!("{}: {}", path, message),
104+ };
105+ Err(fail(3, format!("{}\n Edit this file to repair your config; see `j help config`.", message)))
106+ }
65107 }
66108}
67109
68110/// Write the embedded default config to `path`, creating parent directories
69111/// and making the file readable and writable by the current user.
70112fn write_default_config(path: &str) -> std::io::Result<()> {
113+ use std::io::Write;
71114 if let Some(parent) = std::path::Path::new(path).parent() {
72115 std::fs::create_dir_all(parent)?;
73116 }
74− std::fs::write(path, DEFAULT_CONFIG)?;
117+ let mut options = std::fs::OpenOptions::new();
118+ options.write(true).create_new(true);
75119 // user-read+write (0o600 on unix): the file is meant to be edited by them
76120 #[cfg(unix)]
77121 {
78− use std::os::unix::fs::PermissionsExt;
79− std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
122+ use std::os::unix::fs::OpenOptionsExt;
123+ options.mode(0o600);
124+ }
125+ options.open(path)?.write_all(DEFAULT_CONFIG.as_bytes())
126+}
127+
128+const RESERVED: &[&str] = &[
129+ "init", "clone", "remote", "fetch", "push", "undo", "redo", "ops", "help", "commands", "setup",
130+];
131+
132+/// Discovery reads the user's vocabulary without creating or evaluating it.
133+/// A missing file uses the embedded reference; other read errors stay visible.
134+fn read_help_config() -> Result<(String, String), u8> {
135+ let path = config_path();
136+ match std::fs::read_to_string(&path) {
137+ Ok(src) => Ok((src, path)),
138+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok((
139+ DEFAULT_CONFIG.to_string(),
140+ format!("embedded default config.j (no user config at {})", path),
141+ )),
142+ Err(e) => Err(fail(3, format!("cannot read config at {}: {}", path, e))),
80143 }
81− Ok(())
82144}
83145
84−const RESERVED: &[&str] = &["init", "clone", "remote", "fetch", "push", "undo", "redo", "ops"];
146+fn run_help(words: &[&str]) -> ExitCode {
147+ match words {
148+ [_] => print_output(help::OVERVIEW),
149+ [_, name] => {
150+ if *name == "config" {
151+ return print_output(&format!("{}\nConfig: {}\n", help::CONFIG_HELP, config_path()));
152+ }
153+ if let Some(output) = help::reserved_help(name) {
154+ return print_output(&output);
155+ }
156+ let (src, path) = or_exit(read_help_config());
157+ match help::command_help(&src, name) {
158+ Ok(Some(output)) => print_output(&format!("{}\nConfig: {}\n", output, path)),
159+ Ok(None) => err(
160+ 2,
161+ format!("unknown help topic `{}`; run `j commands` to browse or `j commands {}` to search", name, name),
162+ ),
163+ Err(message) => err(3, format!("{}: {}", path, message)),
164+ }
165+ }
166+ _ => err(
167+ 2,
168+ "usage: j help [NAME]; run `j commands` to search available names",
169+ ),
170+ }
171+}
172+
173+fn run_commands(text: &str) -> ExitCode {
174+ let query = text.strip_prefix("commands").unwrap_or("").trim();
175+ let (src, path) = or_exit(read_help_config());
176+ match help::command_list(&src, query) {
177+ Ok(output) => print_output(&format!("{}\nConfig: {}\n", output, path)),
178+ Err(message) => err(3, format!("{}: {}", path, message)),
179+ }
180+}
181+
182+fn command_usage(usage: &str, command: &str) -> ExitCode {
183+ err(
184+ 2,
185+ format!("usage: j {}; see `j help {}` for examples", usage, command),
186+ )
187+}
188+
189+fn command_result(result: Result<(), (u8, String)>, message: &str) -> ExitCode {
190+ match result {
191+ Ok(()) => {
192+ j::terminal::success(message);
193+ ExitCode::SUCCESS
194+ }
195+ Err(e) => err(e.0, e.1),
196+ }
197+}
198+
199+fn setup_identity(cfg: &Config) -> Result<String, u8> {
200+ j::jj::configured_identity(cfg).map_err(|(_, message)| {
201+ fail(3, format!("{}: {}\n Edit `user` in this file; see `j help config`.", config_path(), message.trim_start_matches("config.j: ")))
202+ })
203+}
204+
205+fn operation_result(result: Result<String, (u8, String)>) -> ExitCode {
206+ match result {
207+ Ok(message) => {
208+ j::terminal::success(&message);
209+ ExitCode::SUCCESS
210+ }
211+ Err((code, message)) => err(code, message),
212+ }
213+}
85214
86215fn main() -> ExitCode {
87216 // evaluation builds deep continuation chains (trampolined, but the final
88217 // continuation still tears down by recursive Drop), so run the real work
89218 // on a thread with a large stack to keep very deep histories safe
90− std::thread::Builder::new()
219+ let code = std::thread::Builder::new()
91220 .stack_size(512 * 1024 * 1024)
92221 .spawn(run)
93222 .expect("failed to spawn worker thread")
94223 .join()
95− .expect("worker thread panicked")
224+ .expect("worker thread panicked");
225+ // §1.3: a signal to stop that came while the working copy was checked
226+ // out takes effect now, once the run has said what it had to
227+ j::jj::raise_deferred();
228+ code
229+}
230+
231+/// True when stdin has input (or end-of-input) waiting within `ms`
232+/// milliseconds. A closed or exhausted stdin reports ready at once, so the
233+/// wait is only paid for a pipe that is open but idle.
234+fn stdin_is_ready(ms: i32) -> bool {
235+ let mut fds = libc::pollfd {
236+ fd: libc::STDIN_FILENO,
237+ events: libc::POLLIN,
238+ revents: 0,
239+ };
240+ unsafe { libc::poll(&mut fds, 1, ms) > 0 }
96241}
97242
98243fn run() -> ExitCode {
99− let args: Vec<String> = std::env::args().skip(1).collect();
100− // §1: stdin vs arguments
101− let mut stdin_content = String::new();
244+ // §1: an expression is text, so an argument that is not UTF-8 is a
245+ // usage error (`std::env::args` would panic on it)
246+ let args: Vec<String> = match std::env::args_os()
247+ .skip(1)
248+ .map(|a| a.into_string())
249+ .collect()
250+ {
251+ Ok(args) => args,
252+ Err(a) => return err(2, format!("argument is not valid UTF-8: {:?}", a)),
253+ };
254+ // §1: stdin vs arguments. With no arguments the expression comes from
255+ // stdin, so reading to end-of-input is the whole point. With arguments,
256+ // stdin is only read to report the "both" error — and reading it blindly
257+ // hangs whenever the process inherited a pipe nobody is writing to (a
258+ // shell loop, a CI step), so wait only briefly for input to appear.
259+ // One byte already proves the "both" error: do not wait for the pipe's
260+ // writer to close it. Bytes that are not UTF-8 are input too, and an
261+ // error only once they are the expression.
262+ let mut stdin_content = Vec::new();
102263 let stdin_has_data = {
103264 if std::io::stdin().is_terminal() {
104265 false
105− } else {
106− match std::io::stdin().read_to_string(&mut stdin_content) {
266+ } else if args.is_empty() || stdin_is_ready(50) {
267+ let limit = if args.is_empty() { u64::MAX } else { 1 };
268+ match std::io::stdin().take(limit).read_to_end(&mut stdin_content) {
107269 Ok(n) => n > 0,
108270 Err(_) => false,
109271 }
272+ } else {
273+ false
110274 }
111275 };
112276 if stdin_has_data && !args.is_empty() {
113277 return err(2, "expression given both as arguments and on stdin");
114278 }
279+ // An argv help flag is a request for help, never an expression comment.
280+ // Comments embedded in a single expression argument or stdin remain part
281+ // of the language, including `j 'new --help'`.
282+ if args.iter().skip(1).any(|arg| matches!(arg.as_str(), "--help" | "-h")) {
283+ let topic = args.first().and_then(|arg| arg.split_whitespace().next());
284+ return match topic {
285+ Some("--help" | "-h" | "help") | None => run_help(&["help"]),
286+ Some(name) => run_help(&["help", name]),
287+ };
288+ }
115289 let text = if stdin_has_data {
116− stdin_content
290+ match String::from_utf8(stdin_content) {
291+ Ok(text) => text,
292+ Err(_) => return err(2, "the expression on stdin is not valid UTF-8"),
293+ }
117294 } else if !args.is_empty() {
118295 args.join(" ")
119296 } else {
120− eprintln!("{}", USAGE);
297+ j::report!("{}", USAGE);
121298 return ExitCode::from(2);
122299 };
123300
124301 // §1.1: reserved commands
125302 let trimmed = text.trim();
303+ if trimmed.is_empty() {
304+ j::report!("{}", USAGE);
305+ return ExitCode::from(2);
306+ }
126307 let first = trimmed.split_whitespace().next().unwrap_or("");
308+ // Preserve shell argument boundaries for literal reserved-command
309+ // arguments. Single quoted commands and stdin still use text words.
310+ let words: Vec<&str> = if args.first().is_some_and(|arg| arg == first) {
311+ args.iter().map(String::as_str).collect()
312+ } else {
313+ trimmed.split_whitespace().collect()
314+ };
315+ if matches!(first, "--help" | "-h") {
316+ return run_help(&words);
317+ }
127318 if RESERVED.contains(&first) {
128− return run_reserved(first, trimmed);
319+ return run_reserved(first, trimmed, &words);
129320 }
130321
131322 run_expression(trimmed, true)
132323}
133324
134−fn run_reserved(cmd: &str, text: &str) -> ExitCode {
135− let words: Vec<&str> = text.split_whitespace().collect();
325+fn run_reserved(cmd: &str, text: &str, words: &[&str]) -> ExitCode {
136326 match cmd {
327+ "help" => run_help(words),
328+ "commands" => run_commands(text),
329+ "setup" => match setup::run(std::path::Path::new(&config_path()), DEFAULT_CONFIG, &words[1..]) {
330+ Ok(output) => print_output(&output),
331+ Err((code, message)) => err(code, message),
332+ },
137333 "init" => {
138334 if words.len() != 1 {
139− return err(2, "usage: j init");
335+ return command_usage("init", "init");
140336 }
141337 let (cfg, _) = or_exit(load_config_file());
142− match j::jj::cmd_init(&cfg) {
143− Ok(()) => ExitCode::SUCCESS,
144− Err(e) => err(e.0, e.1),
145− }
338+ let identity = or_exit(setup_identity(&cfg));
339+ command_result(
340+ j::terminal::with_progress("Creating repository", || j::jj::cmd_init(&cfg)),
341+ &format!("Repository created; identity {}", identity),
342+ )
146343 }
147344 "clone" => {
148345 if words.len() != 2 && words.len() != 3 {
149− return err(2, "usage: j clone URL [DIR]");
346+ return command_usage("clone URL [DIR]", "clone");
150347 }
151− let (cfg, _) = or_exit(load_config_file());
152348 let dir = if words.len() == 3 {
153349 words[2].to_string()
154350 } else {
155− default_clone_dir(words[1])
351+ match default_clone_dir(words[1]) {
352+ Some(dir) => dir,
353+ None => {
354+ let msg = format!(
355+ "`{}` names no directory to clone into; give one: j clone URL DIR",
356+ words[1]
357+ );
358+ return err(2, msg);
359+ }
360+ }
156361 };
157− match j::jj::cmd_clone(&cfg, words[1], &dir) {
158− Ok(()) => ExitCode::SUCCESS,
159− Err(e) => err(e.0, e.1),
160− }
362+ let (cfg, _) = or_exit(load_config_file());
363+ let identity = or_exit(setup_identity(&cfg));
364+ command_result(
365+ j::terminal::with_progress("Cloning repository", || j::jj::cmd_clone(&cfg, words[1], &dir)),
366+ &format!("Repository cloned into {}; identity {}\n Next: cd {} && j status", dir, identity, shell_word(&dir)),
367+ )
161368 }
162369 "remote" => {
163370 if words.len() != 2 {
164− return err(2, "usage: j remote URL");
371+ return command_usage("remote URL", "remote");
165372 }
166373 let backend = or_exit(open_repo());
167− match backend.cmd_remote(words[1]) {
168− Ok(()) => ExitCode::SUCCESS,
169− Err(e) => err(e.0, e.1),
170− }
374+ command_result(
375+ j::terminal::with_progress("Updating remote", || backend.cmd_remote(words[1])),
376+ "Remote updated",
377+ )
171378 }
172379 "fetch" => {
173380 if words.len() != 1 {
174− return err(2, "usage: j fetch");
175− }
176− let (cfg, _) = or_exit(load_config_file());
177− let backend = or_exit(open_repo_locked(&cfg));
178− match backend.cmd_fetch() {
179− Ok(()) => ExitCode::SUCCESS,
180− Err(e) => err(e.0, e.1),
381+ return command_usage("fetch", "fetch");
181382 }
383+ let backend = or_exit(open_repo_noconfig());
384+ command_result(
385+ j::terminal::with_progress("Fetching changes", || backend.cmd_fetch()),
386+ "Fetch complete",
387+ )
182388 }
183389 "push" => {
184390 let rest = text.trim_start_matches("push").trim();
185391 if rest.is_empty() {
186− return err(2, "usage: j push EXPR");
392+ return command_usage("push EXPR", "push");
187393 }
188− let (cfg, _) = or_exit(load_config_file());
394+ let (mut cfg, _) = or_exit(load_config_file());
189395 let backend = or_exit(open_repo_locked(&cfg));
190396 // evaluate EXPR against the recorded repository (no snapshot)
191− match backend.cmd_push(&cfg, rest) {
192− Ok(()) => ExitCode::SUCCESS,
193− Err(e) => err(e.0, e.1),
397+ match j::terminal::with_progress("Pushing changes", || backend.cmd_push(&mut cfg, rest)) {
398+ Err((3, message)) if message.starts_with("config.j:") => {
399+ err(3, format!("{}: {}\n See `j help config` for configuration help.", config_path(), message.trim_start_matches("config.j: ")))
400+ }
401+ Ok(outcome) => {
402+ if outcome.published + outcome.deleted + outcome.unchanged == 0 {
403+ j::report!("j: No bookmarks selected; nothing published.\n First publication: j 'push (label \"feature\" here)'\n Inspect available commits with `j tree` and check your selector.");
404+ } else {
405+ j::terminal::success(&format!(
406+ "Push complete: {} published, {} deleted, {} unchanged bookmark(s)",
407+ outcome.published, outcome.deleted, outcome.unchanged,
408+ ));
409+ }
410+ ExitCode::SUCCESS
411+ }
412+ Err((code, message)) => err(code, message),
194413 }
195414 }
196415 "undo" => {
197416 if words.len() != 1 {
198− return err(2, "usage: j undo");
417+ return command_usage("undo", "undo");
199418 }
200419 let backend = or_exit(open_repo_noconfig());
201− match backend.cmd_undo(false) {
202− Ok(()) => ExitCode::SUCCESS,
203− Err(e) => err(e.0, e.1),
204− }
420+ operation_result(
421+ j::terminal::with_progress("Undoing operation", || backend.cmd_undo(false)),
422+ )
205423 }
206424 "redo" => {
207425 if words.len() != 1 {
208− return err(2, "usage: j redo");
426+ return command_usage("redo", "redo");
209427 }
210428 let backend = or_exit(open_repo_noconfig());
211− match backend.cmd_undo(true) {
212− Ok(()) => ExitCode::SUCCESS,
213− Err(e) => err(e.0, e.1),
214− }
429+ operation_result(
430+ j::terminal::with_progress("Redoing operation", || backend.cmd_undo(true)),
431+ )
215432 }
216433 "ops" => {
217434 if words.len() != 1 {
218− return err(2, "usage: j ops");
435+ return command_usage("ops", "ops");
219436 }
220437 let backend = or_exit(open_repo_noconfig());
221− match backend.cmd_ops() {
222− Ok(()) => ExitCode::SUCCESS,
438+ match j::terminal::with_progress("Loading operation log", || backend.cmd_ops()) {
439+ Ok(log) => print_output(&log),
223440 Err(e) => err(e.0, e.1),
224441 }
225442 }
@@ -227,101 +444,101 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
227444 }
228445}
229446
230−fn default_clone_dir(url: &str) -> String {
231− let last = url.trim_end_matches('/').rsplit('/').next().unwrap_or("repo");
232− last.strip_suffix(".git").unwrap_or(last).to_string()
447+/// `clone`'s default DIR (§1.1): the last component of `url`'s path, a
448+/// final `.git` component skipped, minus `.git`, as `git clone` names it;
449+/// in an scp-style `host:repo.git`, what follows the `:`. None when that
450+/// leaves no name.
451+fn default_clone_dir(url: &str) -> Option<String> {
452+ let mut path = url.trim_end_matches('/');
453+ if let Some(repo) = path.strip_suffix("/.git") {
454+ path = repo.trim_end_matches('/');
455+ }
456+ let last = match path.rfind('/') {
457+ Some(i) => &path[i + 1..],
458+ None => path.rsplit(':').next().unwrap_or(path),
459+ };
460+ let name = last.strip_suffix(".git").unwrap_or(last);
461+ match name {
462+ "" | "." | ".." => None,
463+ name => Some(name.to_string()),
464+ }
233465}
234466
235−fn or_exit<T>(r: Result<T, ExitCode>) -> T {
236− match r {
237− Ok(v) => v,
238− Err(c) => std::process::exit(exit_code_number(c)),
239− }
467+/// A runnable shell word for literal clone destinations, including spaces and
468+/// quotes. Prefix a leading dash so `cd` does not interpret it as an option.
469+fn shell_word(path: &str) -> String {
470+ let path = if path.starts_with('-') { format!("./{}", path) } else { path.to_string() };
471+ format!("'{}'", path.replace('\'', "'\\''"))
240472}
241473
242−/// ExitCode doesn't expose its value; track it alongside.
243−fn exit_code_number(c: ExitCode) -> i32 {
244− // ExitCode's Debug is "ExitCode(unix_exit_status(N))" on unix
245− let s = format!("{:?}", c);
246− for tok in s.split(|ch: char| !ch.is_ascii_digit()) {
247− if !tok.is_empty() {
248− if let Ok(n) = tok.parse() {
249− return n;
250− }
251− }
474+fn or_exit<T>(r: Result<T, u8>) -> T {
475+ match r {
476+ Ok(v) => v,
477+ Err(code) => std::process::exit(code as i32),
252478 }
253− 1
254479}
255480
256−fn open_repo() -> Result<Rc<JjBackend>, ExitCode> {
481+fn open_repo() -> Result<Rc<JjBackend>, u8> {
257482 match JjBackend::open(None) {
258483 Ok(b) => Ok(Rc::new(b)),
259− Err(e) => Err(err(e.0, e.1)),
484+ Err(e) => Err(fail(e.0, e.1)),
260485 }
261486}
262487
263−fn open_repo_noconfig() -> Result<Rc<JjBackend>, ExitCode> {
488+fn open_repo_noconfig() -> Result<Rc<JjBackend>, u8> {
264489 // undo/redo/ops do not read the config (§6.1), but take the lock
265490 match JjBackend::open(None) {
266491 Ok(b) => {
267492 b.take_lock();
268493 Ok(Rc::new(b))
269494 }
270− Err(e) => Err(err(e.0, e.1)),
495+ Err(e) => Err(fail(e.0, e.1)),
271496 }
272497}
273498
274−fn open_repo_locked(cfg: &Config) -> Result<Rc<JjBackend>, ExitCode> {
499+fn open_repo_locked(cfg: &Config) -> Result<Rc<JjBackend>, u8> {
275500 match JjBackend::open(Some(cfg)) {
276501 Ok(b) => {
277502 b.take_lock();
278503 Ok(Rc::new(b))
279504 }
280− Err(e) => Err(err(e.0, e.1)),
505+ Err(e) => Err(fail(e.0, e.1)),
281506 }
282507}
283508
284509fn run_expression(text: &str, snapshot: bool) -> ExitCode {
285510 // §1.2.1: locate the repository
286− let (cfg, _cfg_src) = or_exit(load_config_file());
511+ let (mut cfg, _cfg_src) = or_exit(load_config_file());
287512 let backend = or_exit(open_repo_locked(&cfg));
288513
289514 // §1.2.2: parse the expression
290515 let outer = Rc::new(cfg.global_names.clone());
291516 let mut expr = match parse_expr(text, outer) {
292517 Ok(e) => e,
293− Err(p) => return err(3, format!("line {}: {}", p.line, p.msg)),
518+ Err(p) => return err(3, diagnostics::format_source_error("expression", text, p.line, &p.msg)),
294519 };
295520
296521 // §1.2.4: build the current repository value (with snapshot)
297522 let (mut interp, loaded_repo, current_repo) =
298− match backend.build_interp(&cfg, text, snapshot) {
523+ match j::terminal::with_progress("Reading repository", || backend.build_interp(&mut cfg, text, snapshot)) {
299524 Ok(t) => t,
300525 Err((2, m)) => return err(2, m),
526+ Err((3, m)) => return err(3, format!("{}: {}\n See `j help config` for configuration help.", config_path(), m.trim_start_matches("config.j: "))),
301527 Err((_, m)) => return err(1, m),
302528 };
303529
304− // §1.2.5: resolve Id literals in config and expression
305− for (def_name, prefix, candidates) in config::config_id_failures(&cfg, &interp) {
306− let _ = candidates;
307− return err(
308− 3,
309− format!(
310− "config.j: `@{}` in `{}` does not resolve to a unique commit",
311− prefix, def_name
312− ),
313− );
314− }
530+ // §1.2.5: the config's Id literals were resolved by build_interp, as soon
531+ // as the visible ids were known; the expression's are resolved here
315532 expr = match config::resolve_ids(&expr, &interp) {
316533 Ok(e) => e,
317534 Err(failures) => {
318535 let (prefix, candidates) = &failures[0];
319536 if candidates.is_empty() {
320− eprintln!("j: crash: `@{}` matches no commit", prefix);
537+ j::terminal::report_error(format_args!("j: crash: `@{}` matches no commit", prefix));
321538 } else {
322− eprintln!("j: crash: `@{}` is ambiguous", prefix);
539+ j::terminal::report_error(format_args!("j: crash: `@{}` is ambiguous", prefix));
323540 for c in candidates {
324− eprintln!(" {}", c);
541+ j::report!(" {}", c);
325542 }
326543 }
327544 return ExitCode::from(1);
@@ -329,45 +546,140 @@ fn run_expression(text: &str, snapshot: bool) -> ExitCode {
329546 };
330547
331548 // §1.2.6: evaluate config definitions, then the expression
332− if let Err(c) = config::eval_config(&mut interp, &cfg) {
333− return err(3, format!("config.j: {}", c.msg));
549+ if let Err(c) = j::terminal::with_progress("Evaluating configuration", || {
550+ config::eval_config(&mut interp, &cfg)
551+ }) {
552+ return err(3, format!("{}: {}\n See `j help config` for configuration help.", config_path(), c.msg));
334553 }
335554 *interp.old_repo.borrow_mut() = Some(loaded_repo.clone());
555+ *interp.given_repo.borrow_mut() = Some(current_repo.clone());
336556 let env = interp.global_env();
337557 let expr_rc = Rc::new(expr);
338− let mut v = match interp.eval(&expr_rc, &env) {
558+ let v = match j::terminal::with_progress("Evaluating expression", || {
559+ let v = interp.eval(&expr_rc, &env)?;
560+ // §1.2.7: apply a function result to the repo, once.
561+ if matches!(v, Value::Fun(_)) {
562+ interp.apply(v, current_repo.clone())
563+ } else {
564+ Ok(v)
565+ }
566+ }) {
339567 Ok(v) => v,
340− Err(c) => return crash_err(&c, Some(text)),
568+ Err(c) => return crash_err(&c, text, &cfg),
341569 };
342570
343− // §1.2.7: apply a function result to the repo, once
344− if matches!(v, Value::Fun(_)) {
345− v = match interp.apply(v, current_repo.clone()) {
346− Ok(v) => v,
347− Err(c) => return crash_err(&c, Some(text)),
348− };
349− }
350−
351571 // §1.2.8: a Repo is persisted (unless unchanged)
352− if is_repo_value(&interp, &v) {
353− match backend.persist(&mut interp, &cfg, &loaded_repo, &current_repo, &v, text) {
354− Ok(()) => ExitCode::SUCCESS,
355− Err(c) => crash_err(&c, Some(text)),
572+ if is_repo_value(&v) {
573+ match j::terminal::with_progress("Saving changes", || {
574+ backend.persist(&mut interp, &cfg, &loaded_repo, &current_repo, &v, text)
575+ }) {
576+ Ok(changed) => {
577+ if std::io::stdout().is_terminal() && std::io::stderr().is_terminal() {
578+ let outcome = if changed { "Saved changes" } else { "No changes" };
579+ j::terminal::success(&format!("{}; {}", outcome, focus_summary(&v)));
580+ }
581+ if changed {
582+ report_conflicts(&interp, &loaded_repo, &v);
583+ }
584+ ExitCode::SUCCESS
585+ }
586+ Err(c) if c.msg.starts_with("config.j: user identity") => {
587+ err(3, format!("{}: {}\n Edit `user` in this file; see `j help config`.", config_path(), c.msg.trim_start_matches("config.j: ")))
588+ }
589+ Err(c) => crash_err(&c, text, &cfg),
356590 }
357591 } else {
358592 // §1.2.9: display
359593 let color = j::render::color_enabled("auto");
360− match j::render::display(&mut interp, &v, color) {
361− Ok(s) => {
362− print!("{}", s);
363− ExitCode::SUCCESS
594+ match j::terminal::with_progress("Rendering output", || j::render::display(&mut interp, &v, color)) {
595+ Ok(s) => print_output(&s),
596+ Err(c) => crash_err(&c, text, &cfg),
597+ }
598+ }
599+}
600+
601+/// Report newly introduced conflicts and conflicts in the checked-out focus.
602+/// Stored, resolved trees are skipped without loading their entries or blobs.
603+fn conflict_paths(interp: &j::eval::Interp, repo: &Value) -> Result<BTreeSet<(String, String)>, Crash> {
604+ let mut paths = BTreeSet::new();
605+ for commit in j::repo::all_commits(repo)? {
606+ let Value::Id(id) = commit.field("id")? else { continue };
607+ let Value::Record(fields) = &commit else { continue };
608+ if let Some(Value::Thunk(files)) = fields.get("files") {
609+ // `new` can inherit an unchanged snapshot from another commit.
610+ // Its stored origin still answers for those files even when the
611+ // new commit has a different id.
612+ if files.origin().and_then(|origin| interp.backend.has_conflict(origin)) == Some(false) {
613+ continue;
614+ }
615+ }
616+ for entry in commit.field("files")?.as_list()? {
617+ if matches!(entry.field("content")?, Value::Blob(blob) if blob.is_unresolved()) {
618+ let path = entry.field("path")?.as_list()?.iter()
619+ .map(|part| part.as_text().map(str::to_owned))
620+ .collect::<Result<Vec<_>, _>>()?.join("/");
621+ paths.insert((id.to_string(), path));
364622 }
365− Err(c) => crash_err(&c, Some(text)),
366623 }
367624 }
625+ Ok(paths)
368626}
369627
370−fn is_repo_value(interp: &Interp, v: &Value) -> bool {
628+fn report_conflicts(interp: &j::eval::Interp, before: &Value, after: &Value) {
629+ let summary = || -> Result<(), Crash> {
630+ let old = conflict_paths(interp, before)?;
631+ let new = conflict_paths(interp, after)?;
632+ let Value::Id(focus) = after.field("root")?.field("id")? else { return Ok(()) };
633+ let relevant: Vec<_> = new.iter()
634+ .filter(|(id, path)| id == focus.as_str() || !old.contains(&(id.clone(), path.clone())))
635+ .collect();
636+ if relevant.is_empty() {
637+ return Ok(());
638+ }
639+ j::report!("j: warning: {} unresolved file(s) remain:", relevant.len());
640+ for (id, path) in relevant.iter().take(10) {
641+ j::report!(" @{} {}", interp.unique_prefix(id), path.escape_debug());
642+ }
643+ if relevant.len() > 10 {
644+ j::report!(" … and {} more; inspect with `j conflicts`.", relevant.len() - 10);
645+ }
646+ j::report!(" Run `j conflicts` to inspect affected commits. In the focused commit, edit the conflict markers, run `j id` to record the resolution, then `j conflicts` to verify.\n For another commit, first use `j by @ID`. See `j help conflicts`.");
647+ Ok(())
648+ };
649+ // Saving has succeeded: an optional diagnostic must not turn that into a
650+ // failed operation that the user might retry.
651+ if let Err(c) = summary() {
652+ j::report!("j: changes saved, but conflict details could not be read: {}", c.msg);
653+ }
654+}
655+
656+/// Summaries inspect only commit metadata, never force file blobs. Use the
657+/// resulting tree's ids, including any commits created by this expression.
658+fn focus_summary(repo: &Value) -> String {
659+ let summary = || -> Result<String, Crash> {
660+ let focus = repo.field("root")?;
661+ let Value::Id(id) = focus.field("id")? else {
662+ return Err(Crash::new("focus has no id"));
663+ };
664+ let mut ids: Vec<String> = j::repo::all_commits(repo)?
665+ .into_iter()
666+ .filter_map(|commit| match commit.field("id").ok()? {
667+ Value::Id(id) => Some(id.to_string()),
668+ _ => None,
669+ })
670+ .collect();
671+ ids.sort();
672+ let prefix = j::eval::unique_prefix_in(&ids, &id);
673+ let message = focus.field("message")?.as_text()?.to_string();
674+ let title: String = message.lines().next().unwrap_or("").chars()
675+ .map(|ch| if ch.is_control() { ' ' } else { ch }).take(72).collect();
676+ let title = if title.trim().is_empty() { "(no description)" } else { title.trim() };
677+ Ok(format!("focus @{} {}", prefix, title))
678+ };
679+ summary().unwrap_or_else(|_| "repository saved".to_string())
680+}
681+
682+fn is_repo_value(v: &Value) -> bool {
371683 // §4.4: shape Repo and root's shape Commit
372684 let fields = match v {
373685 Value::Record(m) => m.keys().cloned().collect::<BTreeSet<String>>(),
@@ -392,6 +704,18 @@ fn is_repo_value(interp: &Interp, v: &Value) -> bool {
392704 .iter()
393705 .map(|s| s.to_string())
394706 .collect();
395− let _ = interp;
396707 root_fields == want_root
397708}
709+
710+#[cfg(test)]
711+mod tests {
712+ #[test]
713+ fn default_config_does_not_replace_a_file_created_since_the_read() {
714+ let dir = tempfile::tempdir().unwrap();
715+ let path = dir.path().join("config.j");
716+ std::fs::write(&path, "user-owned configuration").unwrap();
717+ let error = super::write_default_config(path.to_str().unwrap()).unwrap_err();
718+ assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
719+ assert_eq!(std::fs::read_to_string(path).unwrap(), "user-owned configuration");
720+ }
721+}
src/parse.rs+346−162
@@ -1,9 +1,9 @@
11//! Parser for the j language (§3.2–3.4), including the two layout rules.
22
3−use crate::ast::{Expr, Item, Pattern, TypeExpr};
3+use crate::ast::{Expr, Item, Pattern, Source, TypeExpr};
44use crate::lex::{lex, LexError, SpTok, Tok};
55use num_bigint::BigInt;
6−use std::collections::BTreeSet;
6+use std::collections::{BTreeSet, HashMap, HashSet};
77use std::rc::Rc;
88
99#[derive(Debug)]
@@ -31,15 +31,45 @@ impl From<LexError> for ParseError {
3131}
3232
3333pub struct Parser {
34+ /// the text being parsed, of which each lambda keeps its span (§5.2)
35+ src: Rc<str>,
3436 toks: Vec<SpTok>,
3537 pos: usize,
36− /// Names in scope (top-level + builtins) for the no-shadowing rule.
38+ /// Names in scope (top-level + builtins) for the no-shadowing rule;
39+ /// in config.j the builtins alone (§4.2).
3740 outer_names: Rc<BTreeSet<String>>,
41+ /// Names bound by the lambdas and `let` blocks around the current
42+ /// position, for the same rule (§4.2). Since shadowing is refused, no
43+ /// name is in here twice, and a construct removes the names it added
44+ /// when its scope ends.
45+ locals: HashSet<String>,
46+ /// How many local binders have been parsed, and for each name the
47+ /// ordinal and line of the latest one: the `let` check that `locals`
48+ /// cannot make (see `parse_let`).
49+ binders: usize,
50+ last_binder: HashMap<String, (usize, usize)>,
3851 /// set when an operator expression ends with a trailing operator before
3952 /// `)`, i.e. a left section
4053 section_op: Option<&'static str>,
54+ /// levels of recursive descent currently open, bounded by `MAX_DEPTH`
55+ depth: usize,
56+ /// how many levels high (see `MAX_DEPTH`) the expression the last parse
57+ /// function returned is, which each leaves here for its caller
58+ height: usize,
4159}
4260
61+/// How many levels deep an expression may be (§3.4): a leaf is one level, and
62+/// any other node one more than its highest part, or two more for a
63+/// parenthesis (or section) and for the braces of a record or update. Deeper
64+/// input is a parse error (§1.4) instead of an overflow of the worker
65+/// thread's stack (main.rs). The recursive descent opens at most one level of
66+/// its own (`nested`) per level of the expression around the point it has
67+/// reached, each taking up to about 6 KB of stack in a debug build (a
68+/// parenthesis takes two) and 1.3 KB in a release build, so the deepest parse
69+/// takes under half of the worker's 512 MB. The passes over the tree after the
70+/// parse, and dropping it, recurse on its height at about 1.2 KB a level.
71+const MAX_DEPTH: usize = 40_000;
72+
4373/// fixity table (§3.2): (precedence, right-assoc)
4474fn fixity(op: &str) -> Option<(u8, bool)> {
4575 Some(match op {
@@ -62,13 +92,51 @@ fn is_nonassoc(op: &str) -> bool {
6292impl Parser {
6393 pub fn new(src: &str, outer_names: Rc<BTreeSet<String>>) -> Result<Self, ParseError> {
6494 Ok(Parser {
95+ src: Rc::from(src),
6596 toks: lex(src)?,
6697 pos: 0,
6798 outer_names,
99+ locals: HashSet::new(),
100+ binders: 0,
101+ last_binder: HashMap::new(),
68102 section_op: None,
103+ depth: 0,
104+ height: 0,
69105 })
70106 }
71107
108+ /// Run `f` one level deeper in the recursive descent. Every cycle of
109+ /// mutually recursive parse functions passes through a call made here,
110+ /// which is what bounds the parser's stack use. Each call is matched by a
111+ /// level of the expression around what `f` parses, so on input that
112+ /// parses, this bound is reached no sooner than `within`'s: it is what
113+ /// stops input that never closes, like `((((…`.
114+ fn nested<T>(
115+ &mut self,
116+ f: impl FnOnce(&mut Self) -> Result<T, ParseError>,
117+ ) -> Result<T, ParseError> {
118+ if self.depth >= MAX_DEPTH {
119+ return Err(ParseError::new("nested too deeply", self.line()));
120+ }
121+ self.depth += 1;
122+ let r = f(self);
123+ self.depth -= 1;
124+ r
125+ }
126+
127+ /// `h`, the height of an expression just built, while it is within
128+ /// `MAX_DEPTH`. Every node is checked here as it is built, since a loop
129+ /// builds a left-associative chain (`a + b + …`, `f x y …`, `r.a.b …`,
130+ /// `r { … } { … }`) as tall as it is long without recursing, and the
131+ /// passes over the tree after the parse, and dropping it, recurse on its
132+ /// height.
133+ fn within(&self, h: usize) -> Result<usize, ParseError> {
134+ if h > MAX_DEPTH {
135+ return Err(ParseError::new("nested too deeply", self.line()));
136+ }
137+ Ok(h)
138+ }
139+
72140 fn peek(&self) -> &Tok {
73141 &self.toks[self.pos].tok
74142 }
@@ -123,12 +191,15 @@ impl Parser {
123191 Ok(items)
124192 }
125193
194+ /// One item. Under rule 1 (§3.3) a line starting past column 1 continues
195+ /// it, so a line break before its `=` or `:` is skipped when the next
196+ /// line starts past column 1, like one inside its type or expression.
126197 fn parse_item(&mut self) -> Result<Item, ParseError> {
127198 match self.peek().clone() {
128199 Tok::TypeName(name) => {
129200 // typedecl: TYPENAME '=' type
130201 self.bump();
131− self.skip_newlines();
202+ self.skip_layout_newlines(1);
132203 self.expect(&Tok::Equals)?;
133204 let ty = self.parse_type()?;
134205 Ok(Item::TypeDecl(name, ty))
@@ -136,6 +207,7 @@ impl Parser {
136207 Tok::Ident(name) => {
137208 let line = self.line();
138209 self.bump();
210+ self.skip_layout_newlines(1);
139211 match self.peek().clone() {
140212 Tok::Colon => {
141213 self.bump();
@@ -144,7 +216,8 @@ impl Parser {
144216 }
145217 Tok::Equals => {
146218 self.bump();
147− let e = self.parse_expr(1)?;
219+ // inside nothing, so it opens no level (see `nested`)
220+ let e = self.parse_expr_inner(1)?;
148221 Ok(Item::Definition(name, Rc::new(e), line))
149222 }
150223 other => Err(ParseError::new(
@@ -167,6 +240,7 @@ impl Parser {
167240 }
168241 };
169242 self.expect(&Tok::RParen)?;
243+ self.skip_layout_newlines(1);
170244 self.expect(&Tok::Colon)?;
171245 let ty = self.parse_type()?;
172246 Ok(Item::Signature(op.to_string(), ty, line))
@@ -182,9 +256,16 @@ impl Parser {
182256 // types
183257 // ------------------------------------------------------------------
184258 fn parse_type(&mut self) -> Result<TypeExpr, ParseError> {
185− self.skip_newlines_in_type();
259+ self.nested(Self::parse_type_inner)
260+ }
261+
262+ /// Types occur only in top-level items, so their layout limit is always
263+ /// rule 1's column 1 (§3.3): a line break may fall anywhere in a type,
264+ /// before or after `->` alike, when the next line starts past column 1.
265+ fn parse_type_inner(&mut self) -> Result<TypeExpr, ParseError> {
266+ self.skip_layout_newlines(1);
186267 let lhs = self.parse_atype()?;
187− self.skip_newlines_in_type();
268+ self.skip_layout_newlines(1);
188269 if matches!(self.peek(), Tok::Arrow) {
189270 self.bump();
190271 let rhs = self.parse_type()?;
@@ -194,38 +275,6 @@ impl Parser {
194275 }
195276 }
196277
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−
229278 fn parse_atype(&mut self) -> Result<TypeExpr, ParseError> {
230279 match self.peek().clone() {
231280 Tok::TypeName(n) => {
@@ -246,7 +295,7 @@ impl Parser {
246295 self.bump();
247296 let mut fields = Vec::new();
248297 loop {
249− self.skip_newlines();
298+ self.skip_layout_newlines(1);
250299 if matches!(self.peek(), Tok::RBrace) {
251300 self.bump();
252301 break;
@@ -260,10 +309,11 @@ impl Parser {
260309 ))
261310 }
262311 };
312+ self.skip_layout_newlines(1);
263313 self.expect(&Tok::Colon)?;
264314 let t = self.parse_type()?;
265315 fields.push((name, Rc::new(t)));
266− self.skip_newlines();
316+ self.skip_layout_newlines(1);
267317 match self.peek() {
268318 Tok::Comma => {
269319 self.bump();
@@ -303,59 +353,84 @@ impl Parser {
303353 /// expression stops at a Newline whose following token is in a column
304354 /// <= min_col (it belongs to an outer construct).
305355 pub fn parse_expr(&mut self, min_col: usize) -> Result<Expr, ParseError> {
356+ self.nested(|p| p.parse_expr_inner(min_col))
357+ }
358+
359+ /// Each construct here and in `parse_atom_inner` is parsed by a function
360+ /// of its own: nested input stacks a frame of every function on its path
361+ /// for each level (see `MAX_DEPTH`), and in a debug build a function's
362+ /// frame holds the temporaries of all its arms.
363+ fn parse_expr_inner(&mut self, min_col: usize) -> Result<Expr, ParseError> {
306364 self.skip_layout_newlines(min_col);
307365 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− }
366+ Tok::Backslash => self.parse_lambda(min_col),
367+ Tok::If => self.parse_if(min_col),
368+ Tok::Let => self.parse_let(min_col),
369+ _ => self.parse_or(min_col),
370+ }
371+ }
372+
373+ fn parse_lambda(&mut self, min_col: usize) -> Result<Expr, ParseError> {
374+ let src_start = self.toks[self.pos].clone();
375+ self.bump();
376+ let mut params = Vec::new();
377+ loop {
378+ match self.peek().clone() {
379+ Tok::Ident(n) => {
380+ // in scope from here on, so a later parameter
381+ // may not reuse it either
382+ self.check_shadow(&n)?;
383+ self.locals.insert(n.clone());
384+ self.note_binder(&n, self.line());
385+ params.push(Pattern::Var(n));
386+ self.bump();
337387 }
338− if params.is_empty() {
339− return Err(ParseError::new("lambda needs at least one parameter", self.line()));
388+ Tok::Underscore => {
389+ params.push(Pattern::Wildcard);
390+ self.bump();
391+ }
392+ Tok::Arrow => {
393+ self.bump();
394+ break;
395+ }
396+ other => {
397+ return Err(ParseError::new(
398+ format!(
399+ "expected a parameter or `->`, found {}",
400+ other.describe()
401+ ),
402+ self.line(),
403+ ))
340404 }
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))
344405 }
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)))
406+ }
407+ if params.is_empty() {
408+ return Err(ParseError::new("lambda needs at least one parameter", self.line()));
409+ }
410+ let body = self.parse_expr(min_col)?;
411+ self.height = self.within(self.height + 1)?;
412+ for p in &params {
413+ if let Pattern::Var(n) = p {
414+ self.locals.remove(n);
355415 }
356− Tok::Let => self.parse_let(min_col),
357− _ => self.parse_or(min_col),
358416 }
417+ let src = self.source_slice(&src_start);
418+ Ok(Expr::Lambda(params, Rc::new(body), src))
419+ }
420+
421+ fn parse_if(&mut self, min_col: usize) -> Result<Expr, ParseError> {
422+ self.bump();
423+ let c = self.parse_expr(min_col)?;
424+ let mut h = self.height;
425+ self.skip_layout_newlines(min_col);
426+ self.expect(&Tok::Then)?;
427+ let t = self.parse_expr(min_col)?;
428+ h = h.max(self.height);
429+ self.skip_layout_newlines(min_col);
430+ self.expect(&Tok::Else)?;
431+ let e = self.parse_expr(min_col)?;
432+ self.height = self.within(h.max(self.height) + 1)?;
433+ Ok(Expr::If(Rc::new(c), Rc::new(t), Rc::new(e)))
359434 }
360435
361436 /// skip Newlines whose next real token is in a column > min_col
@@ -396,7 +471,9 @@ impl Parser {
396471 ));
397472 }
398473 let mut bindings: Vec<(String, Rc<Expr>)> = Vec::new();
399− let mut names: Vec<String> = Vec::new();
474+ let mut names: Vec<(String, usize)> = Vec::new();
475+ let first_binder = self.binders;
476+ let mut h = 0;
400477 loop {
401478 // binding: ident '=' expr, expr limited to block_col
402479 let name = match self.peek().clone() {
@@ -408,17 +485,21 @@ impl Parser {
408485 ))
409486 }
410487 };
411− self.check_shadow(&name)?;
412− if names.iter().any(|n| *n == name) {
488+ if names.iter().any(|(n, _)| *n == name) {
413489 return Err(ParseError::new(
414490 format!("`{}` is bound twice in this let", name),
415491 self.line(),
416492 ));
417493 }
494+ self.check_shadow(&name)?;
495+ // in scope from here on: in its own expression, the later ones
496+ // and the body (§4.1)
497+ self.locals.insert(name.clone());
498+ names.push((name.clone(), self.line()));
418499 self.bump();
419500 self.expect(&Tok::Equals)?;
420501 let e = self.parse_expr(block_col)?;
421− names.push(name.clone());
502+ h = h.max(self.height);
422503 bindings.push((name, Rc::new(e)));
423504 // separator: `;` or newline at exactly block_col, then `in` ends
424505 match self.peek().clone() {
@@ -443,6 +524,16 @@ impl Parser {
443524 }
444525 let next_col = self.toks[q].col;
445526 if matches!(self.toks[q].tok, Tok::In) {
527+ // like any token that begins a line, an `in` at or
528+ // left of the limit belongs to an outer construct
529+ // (§3.3): a new top-level item or an outer let's
530+ // binding line, never this let
531+ if next_col <= min_col {
532+ return Err(ParseError::new(
533+ format!("`in` must be indented past column {}", min_col),
534+ self.toks[q].line,
535+ ));
536+ }
446537 self.pos = q;
447538 self.bump();
448539 break;
@@ -480,39 +571,66 @@ impl Parser {
480571 }
481572 }
482573 }
574+ // A binding is in scope in the expressions before it too (§4.1), but
575+ // it was not yet in `locals` while they were parsed: a binder in one
576+ // of them that reuses its name is caught here instead.
577+ for (n, _) in &names {
578+ if let Some(&(k, line)) = self.last_binder.get(n) {
579+ if k >= first_binder {
580+ return Err(shadowed(n, line));
581+ }
582+ }
583+ }
584+ for (n, line) in &names {
585+ self.note_binder(n, *line);
586+ }
483587 let body = self.parse_expr(min_col)?;
588+ self.height = self.within(h.max(self.height) + 1)?;
589+ for (n, _) in &names {
590+ self.locals.remove(n);
591+ }
484592 Ok(Expr::Let(bindings, Rc::new(body)))
485593 }
486594
595+ /// §4.2: a binder may not reuse a top-level or builtin name, nor one
596+ /// bound by an enclosing lambda or `let`.
487597 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− ));
598+ if self.outer_names.contains(name) || self.locals.contains(name) {
599+ return Err(shadowed(name, self.line()));
493600 }
494601 Ok(())
495602 }
496603
604+ /// Record a local binder for the check at the end of each enclosing
605+ /// `let` block's bindings.
606+ fn note_binder(&mut self, name: &str, line: usize) {
607+ self.last_binder.insert(name.to_string(), (self.binders, line));
608+ self.binders += 1;
609+ }
610+
497611 fn parse_or(&mut self, min_col: usize) -> Result<Expr, ParseError> {
498612 // `or` is infixl 0 and extends as far right as possible
499613 let mut lhs = self.parse_opexpr(1, min_col)?;
614+ let mut h = self.height;
500615 loop {
501616 self.skip_layout_newlines(min_col);
502617 if matches!(self.peek(), Tok::Or) {
503618 self.bump();
504− let rhs = self.parse_or(min_col)?;
619+ let rhs = self.nested(|p| p.parse_or(min_col))?;
620+ h = self.within(h.max(self.height) + 1)?;
505621 lhs = Expr::Or(Rc::new(lhs), Rc::new(rhs));
506622 } else {
507623 break;
508624 }
509625 }
626+ self.height = h;
510627 Ok(lhs)
511628 }
512629
513630 /// precedence-climbing over the fixity table
514631 fn parse_opexpr(&mut self, min_prec: u8, min_col: usize) -> Result<Expr, ParseError> {
515632 let mut lhs = self.parse_app(min_col)?;
633+ let mut h = self.height;
516634 loop {
517635 self.skip_op_newline(min_col);
518636 let op: &'static str = match self.peek() {
@@ -530,10 +648,11 @@ impl Parser {
530648 // left section: `(e op)` — a trailing operator before `)`
531649 if matches!(self.peek(), Tok::RParen) {
532650 self.section_op = Some(op);
651+ self.height = h;
533652 return Ok(lhs);
534653 }
535654 let next_min = if right { prec } else { prec + 1 };
536− let rhs = self.parse_opexpr(next_min, min_col)?;
655+ let rhs = self.nested(|p| p.parse_opexpr(next_min, min_col))?;
537656 // non-assoc check: if lhs is the same operator, reject chaining
538657 if is_nonassoc(op) {
539658 // a non-associative operator cannot be chained; explicit
@@ -555,8 +674,10 @@ impl Parser {
555674 }
556675 }
557676 }
677+ h = self.within(h.max(self.height) + 1)?;
558678 lhs = Expr::BinOp(op, Rc::new(lhs), Rc::new(rhs));
559679 }
680+ self.height = h;
560681 Ok(lhs)
561682 }
562683
@@ -596,6 +717,7 @@ impl Parser {
596717
597718 fn parse_app(&mut self, min_col: usize) -> Result<Expr, ParseError> {
598719 let mut e = self.parse_postfix(min_col)?;
720+ let mut h = self.height;
599721 loop {
600722 // application across a newline requires deeper indentation
601723 if matches!(self.peek(), Tok::Newline) {
@@ -611,20 +733,24 @@ impl Parser {
611733 }
612734 if Self::starts_atom(self.peek()) {
613735 let arg = self.parse_postfix(min_col)?;
736+ h = self.within(h.max(self.height) + 1)?;
614737 e = Expr::App(Rc::new(e), Rc::new(arg));
615738 } else {
616739 break;
617740 }
618741 }
742+ self.height = h;
619743 Ok(e)
620744 }
621745
622746 fn parse_postfix(&mut self, min_col: usize) -> Result<Expr, ParseError> {
623747 let mut e = self.parse_atom(min_col)?;
748+ let mut h = self.height;
624749 loop {
625750 match self.peek().clone() {
626751 Tok::Selector(f) => {
627752 self.bump();
753+ h = self.within(h + 1)?;
628754 e = Expr::Select(Rc::new(e), f);
629755 }
630756 Tok::LBrace => {
@@ -632,12 +758,18 @@ impl Parser {
632758 // postfix binds tighter than application, so `{` after an
633759 // atom is an update)
634760 self.bump();
635− let fields = self.parse_fields(min_col, true)?;
761+ // two levels, like a record literal's braces, so a second
762+ // level of the descent to match (see `MAX_DEPTH`)
763+ let fields = self.nested(|p| p.parse_fields(min_col, true))?;
764+ h = self.within(h.max(self.height) + 2)?;
636765 e = Expr::Update(Rc::new(e), fields);
637766 }
767+ // a Newline ends the chain too: a selector or `{` that
768+ // begins a line is the next postfix, not this one's (§3.3)
638769 _ => break,
639770 }
640771 }
772+ self.height = h;
641773 Ok(e)
642774 }
643775
@@ -651,8 +783,12 @@ impl Parser {
651783 self.skip_layout_newlines(min_col);
652784 if matches!(self.peek(), Tok::RBrace) {
653785 self.bump();
786+ // nothing inside the braces
787+ self.height = 0;
654788 return Ok(fields);
655789 }
790+ // the height of the highest field
791+ let mut h = 0;
656792 loop {
657793 let name = match self.bump() {
658794 Tok::Ident(n) => n,
@@ -671,6 +807,7 @@ impl Parser {
671807 }
672808 self.expect(&Tok::Equals)?;
673809 let v = self.parse_expr(min_col)?;
810+ h = h.max(self.height);
674811 fields.push((name, Rc::new(v)));
675812 self.skip_layout_newlines(min_col);
676813 match self.peek() {
@@ -690,10 +827,17 @@ impl Parser {
690827 }
691828 }
692829 }
830+ self.height = h;
693831 Ok(fields)
694832 }
695833
696834 fn parse_atom(&mut self, min_col: usize) -> Result<Expr, ParseError> {
835+ self.nested(|p| p.parse_atom_inner(min_col))
836+ }
837+
838+ fn parse_atom_inner(&mut self, min_col: usize) -> Result<Expr, ParseError> {
839+ // one level, unless an arm below builds more around what it parses
840+ self.height = 1;
697841 match self.peek().clone() {
698842 Tok::Ident(n) => {
699843 self.bump();
@@ -739,70 +883,16 @@ impl Parser {
739883 self.bump();
740884 Ok(Expr::SelectorFun(f))
741885 }
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− }
886+ Tok::LBracket => self.parse_list(min_col),
756887 Tok::LBrace => {
757888 self.bump();
758889 let fields = self.parse_fields(min_col, false)?;
890+ // the braces are two levels, as they open two of the
891+ // descent: this atom and the field (see `MAX_DEPTH`)
892+ self.height = self.within(self.height + 2)?;
759893 Ok(Expr::Record(fields))
760894 }
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− }
895+ Tok::LParen => self.parse_paren(min_col),
806896 other => Err(ParseError::new(
807897 format!("unexpected {}", other.describe()),
808898 self.line(),
@@ -810,15 +900,105 @@ impl Parser {
810900 }
811901 }
812902
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()
903+ fn parse_list(&mut self, min_col: usize) -> Result<Expr, ParseError> {
904+ self.bump();
905+ let mut elems = Vec::new();
906+ let mut h = 0;
907+ loop {
908+ self.skip_layout_newlines(min_col);
909+ if matches!(self.peek(), Tok::RBracket) {
910+ self.bump();
911+ break;
912+ }
913+ let e = self.parse_postfix(min_col)?;
914+ h = h.max(self.height);
915+ elems.push(Rc::new(e));
916+ }
917+ self.height = self.within(h + 1)?;
918+ Ok(Expr::List(elems))
919+ }
920+
921+ fn parse_paren(&mut self, min_col: usize) -> Result<Expr, ParseError> {
922+ let open = self.toks[self.pos].clone();
923+ self.bump();
924+ // (op) | (op expr) | (expr op) | (expr)
925+ if let Tok::Op(o) = self.peek().clone() {
926+ self.bump();
927+ self.skip_layout_newlines(min_col);
928+ if matches!(self.peek(), Tok::RParen) {
929+ self.bump();
930+ return Ok(Expr::Var(o.to_string()));
931+ }
932+ // right section: (op e) = \x -> x op e
933+ let e = self.parse_expr(min_col)?;
934+ // `(op e op)` is no production (§3.4); unchecked, the
935+ // trailing operator would be dropped here and left set
936+ // for the next `(e)` to take as a left section
937+ if let Some(o2) = self.section_op.take() {
938+ return Err(ParseError::new(
939+ format!(
940+ "a section cannot have operators on both sides (`{}` and `{}`)",
941+ o, o2
942+ ),
943+ self.line(),
944+ ));
945+ }
946+ self.expect(&Tok::RParen)?;
947+ // a lambda around an operator: two levels
948+ self.height = self.within(self.height + 2)?;
949+ let var = fresh_var();
950+ let body = Expr::BinOp(
951+ o,
952+ Rc::new(Expr::Var(var.clone())),
953+ Rc::new(e),
954+ );
955+ return Ok(Expr::Lambda(
956+ vec![Pattern::Var(var)],
957+ Rc::new(body),
958+ self.source_slice(&open),
959+ ));
960+ }
961+ let first = self.parse_expr(min_col)?;
962+ // a parenthesis is two levels, as is a section's lambda
963+ // around its operator
964+ self.height = self.within(self.height + 2)?;
965+ self.skip_layout_newlines(min_col);
966+ if let Some(o) = self.section_op.take() {
967+ // left section: (e op) = \x -> e op x
968+ self.expect(&Tok::RParen)?;
969+ let var = fresh_var();
970+ let body = Expr::BinOp(
971+ o,
972+ Rc::new(first),
973+ Rc::new(Expr::Var(var.clone())),
974+ );
975+ return Ok(Expr::Lambda(
976+ vec![Pattern::Var(var)],
977+ Rc::new(body),
978+ self.source_slice(&open),
979+ ));
980+ }
981+ self.expect(&Tok::RParen)?;
982+ Ok(Expr::Paren(Rc::new(first)))
983+ }
984+
985+ /// The source of a lambda or section, from its first token `start` to
986+ /// the last token consumed, for its rendering (§5.2: exactly as
987+ /// written). Line breaks the layout rules skipped looking for more of it
988+ /// are not part of it, nor is a comment after its last token.
989+ fn source_slice(&self, start: &SpTok) -> Source {
990+ let mut last = self.pos - 1;
991+ while matches!(self.toks[last].tok, Tok::Newline) {
992+ last -= 1;
993+ }
994+ Source::new(self.src.clone(), start.start, self.toks[last].end)
816995 }
817996
818997 /// Parse a single command-line expression. Layout rule 1 does not apply.
819998 pub fn parse_cli_expr(&mut self) -> Result<Expr, ParseError> {
820999 self.skip_newlines();
821− let e = self.parse_expr(0)?;
1000+ // inside nothing, so it opens no level (see `nested`)
1001+ let e = self.parse_expr_inner(0)?;
8221002 self.skip_newlines();
8231003 if !matches!(self.peek(), Tok::Eof) {
8241004 return Err(ParseError::new(
@@ -830,6 +1010,10 @@ impl Parser {
8301010 }
8311011}
8321012
1013+fn shadowed(name: &str, line: usize) -> ParseError {
1014+ ParseError::new(format!("`{}` is already bound and may not be shadowed", name), line)
1015+}
1016+
8331017use std::sync::atomic::{AtomicUsize, Ordering};
8341018static FRESH: AtomicUsize = AtomicUsize::new(0);
8351019fn fresh_var() -> String {
src/render.rs+1737−589
@@ -2,8 +2,11 @@
22
33use crate::domain::ROOT_ID;
44use crate::eval::Interp;
5−use crate::value::{Crash, ShapeKind, Value};
6−use std::collections::{BTreeMap, BTreeSet};
5+use crate::value::{Crash, ListVal, RecordMap, ShapeKind, Value};
6+use std::cmp::Reverse;
7+use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
8+use std::ops::Bound;
9+use std::rc::Rc;
710use unicode_width::UnicodeWidthChar;
811
912// ----------------------------------------------------------------------
@@ -30,33 +33,46 @@ impl Palette {
3033 self.wrap("2", s)
3134 }
3235 pub fn bold_bg(&self, s: &str) -> String {
33− self.wrap("1;48;5;236", s)
36+ self.wrap("1;97;48;5;17", s)
3437 }
3538 pub fn dim_italic(&self, s: &str) -> String {
3639 self.wrap("2;3", s)
3740 }
3841 pub fn cyan(&self, s: &str) -> String {
39− self.wrap("36", s)
42+ self.wrap("96", s)
4043 }
4144 pub fn red(&self, s: &str) -> String {
42− self.wrap("31", s)
45+ self.wrap("91", s)
4346 }
4447 pub fn blue(&self, s: &str) -> String {
45− self.wrap("34", s)
48+ self.wrap("94", s)
4649 }
4750 pub fn green(&self, s: &str) -> String {
48− self.wrap("32", s)
51+ self.wrap("92", s)
4952 }
5053 pub fn yellow(&self, s: &str) -> String {
51− self.wrap("33", s)
54+ self.wrap("93", s)
55+ }
56+ fn id(&self, s: &str) -> String {
57+ self.wrap("1;95", s)
58+ }
59+ fn label(&self, s: &str) -> String {
60+ self.wrap("1;92", s)
61+ }
62+ fn field(&self, s: &str) -> String {
63+ self.wrap("2;96", s)
64+ }
65+ fn rail(&self, lane: usize, s: &str) -> String {
66+ let codes = ["95", "96", "93", "92"];
67+ self.wrap(codes[lane.saturating_sub(1) % codes.len()], s)
5268 }
5369 pub fn grey(&self, bright: u8, s: &str) -> String {
5470 // grey scale: brighter when more recent
5571 self.wrap(&format!("38;5;{}", 245 + bright.min(10)), s)
5672 }
5773 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"];
74+ // Size remains encoded by the bar's height, with a vivid cool-to-warm ramp.
75+ let codes = ["96", "94", "95", "93", "1;93"];
6076 self.wrap(codes[level.min(4) as usize], s)
6177 }
6278 pub fn author(&self, name: &str, s: &str) -> String {
@@ -66,7 +82,7 @@ impl Palette {
6682 h = h.wrapping_mul(33).wrapping_add(b as u32);
6783 }
6884 let hue = (h % 6) as u8;
69− let code = ["31", "32", "33", "34", "35", "36"][hue as usize];
85+ let code = ["91", "92", "93", "94", "95", "96"][hue as usize];
7086 self.wrap(code, s)
7187 }
7288 /// colour with a raw ANSI code (used for meaning-coloured glyphs)
@@ -98,7 +114,8 @@ pub fn color_enabled(mode: &str) -> bool {
98114 match mode {
99115 "always" => true,
100116 "never" => false,
101− _ => crate::show::stdout_is_tty(),
117+ _ => std::env::var_os("TERM").map(|t| t != "dumb").unwrap_or(true)
118+ && crate::show::stdout_is_tty(),
102119 }
103120}
104121
@@ -154,11 +171,16 @@ fn field_set(v: &Value) -> Option<BTreeSet<String>> {
154171
155172fn shape_of_record(interp: &Interp, v: &Value) -> Option<String> {
156173 let fs = field_set(v)?;
157− for (name, _) in &interp.shapes.decls {
174+ // Only these named shapes have special display forms (§5.1). Searching
175+ // every declaration let a custom alias win in HashMap iteration order,
176+ // randomly turning Commit, Entry, etc. values into generic records.
177+ // Resolve each configured shape so aliases and custom field sets still
178+ // determine which records match.
179+ for name in ["Commit", "Entry", "Subtree", "Repo", "Frame", "Change"] {
158180 if let Some(s) = interp.shapes.shape_of(name) {
159181 if let ShapeKind::Record(want) = &s.kind {
160182 if want == &fs {
161− return Some(name.clone());
183+ return Some(name.to_string());
162184 }
163185 }
164186 }
@@ -175,35 +197,89 @@ pub fn is_commit(interp: &Interp, v: &Value) -> bool {
175197// ----------------------------------------------------------------------
176198
177199pub fn display(interp: &mut Interp, v: &Value, color: bool) -> Result<String, Crash> {
200+ display_with_width(interp, v, color, tty_width())
201+}
202+
203+/// Display column tables as if stdout had this width; `None` preserves full cells.
204+pub fn display_with_width(
205+ interp: &mut Interp,
206+ v: &Value,
207+ color: bool,
208+ term_w: Option<usize>,
209+) -> Result<String, Crash> {
178210 let pal = Palette { on: color };
179211 let mut out = String::new();
180− display_block(interp, v, &pal, 0, &mut out)?;
212+ let mut loaded = None;
213+ let mut todo = vec![Block::Of(v.clone(), 0)];
214+ while let Some(block) = todo.pop() {
215+ match block {
216+ Block::Of(v, indent) => {
217+ display_block(interp, &v, &pal, indent, &mut out, &mut loaded, &mut todo, term_w)?
218+ }
219+ Block::Items { xs, next, indent } => {
220+ if let Some(x) = xs.get(next).cloned() {
221+ // separated by a blank line
222+ if next > 0 {
223+ out.push('\n');
224+ }
225+ let next = next + 1;
226+ todo.push(Block::Items { xs, next, indent });
227+ todo.push(Block::Of(x, indent));
228+ }
229+ }
230+ Block::Fields { m, after, name_w, indent } => display_fields(
231+ interp,
232+ &m,
233+ after.as_deref(),
234+ name_w,
235+ &pal,
236+ indent,
237+ &mut out,
238+ &mut loaded,
239+ &mut todo,
240+ )?,
241+ }
242+ }
181243 if !out.ends_with('\n') {
182244 out.push('\n');
183245 }
184246 Ok(out)
185247}
186248
249+/// A block still to be displayed, or the rest of one that holds blocks
250+/// (§5.1): a list shown one block per item, and a generic record, whose list
251+/// fields print in block form under their key. Blocks nest as deep as the
252+/// value's lists do, so `display` keeps them on a stack of its own instead of
253+/// recursing: at about 14 KB of native stack a level in a debug build, a list
254+/// nested as deep as the parser allows (§3.4) overflowed the worker's 512 MB,
255+/// and one built at run time is not bounded at all.
256+enum Block {
257+ /// a value in block form, at an indent
258+ Of(Value, usize),
259+ /// a list's items from the `next`th on, one block each
260+ Items { xs: ListVal, next: usize, indent: usize },
261+ /// a generic record's fields after the field `after`, or all of them
262+ Fields { m: Rc<RecordMap>, after: Option<String>, name_w: usize, indent: usize },
263+}
264+
187265fn display_id(interp: &Interp, id: &str, pal: &Palette) -> String {
188− let p = interp.backend.unique_prefix(id);
266+ let p = interp.unique_prefix(id);
189267 let n = p.len();
190− format!("{}{}", pal.bold(p.as_str()), pal.dim(&id[n..]))
268+ format!("{}{}", pal.id(p.as_str()), pal.dim(&id[n..]))
191269}
192270
193−fn display_line(interp: &Interp, v: &Value, pal: &Palette) -> Result<String, Crash> {
271+fn display_line(
272+ interp: &mut Interp,
273+ v: &Value,
274+ pal: &Palette,
275+ loaded: &mut Option<Loaded>,
276+) -> Result<String, Crash> {
194277 let v = &v.forced()?;
195278 match v {
196279 Value::Thunk(_) => unreachable!("forced never returns a thunk"),
197− Value::Int(n) => Ok(n.to_string()),
198− Value::Bool(b) => Ok(b.to_string()),
199− Value::Text(t) => {
200− let first = t.lines().next().unwrap_or("");
201− if t.lines().count() > 1 {
202− Ok(format!("{}…", first))
203− } else {
204− Ok(first.to_string())
205− }
206− }
280+ Value::Int(n) => Ok(pal.yellow(&n.to_string())),
281+ Value::Bool(b) => Ok(if *b { pal.green("true") } else { pal.red("false") }),
282+ Value::Text(t) => Ok(first_line(t)),
207283 Value::Id(id) => Ok(display_id(interp, id, pal)),
208284 Value::Blob(b) => {
209285 let size = human_size(b.size());
@@ -213,43 +289,19 @@ fn display_line(interp: &Interp, v: &Value, pal: &Palette) -> Result<String, Cra
213289 Ok(format!("‹{}›", size))
214290 }
215291 }
216− Value::Fun(f) => Ok(crate::show::show(&Interp::dummy(), &Value::Fun(f.clone()))),
217− Value::Shape(s) => Ok(s.name.clone()),
292+ // a lambda renders as its source (§5.2), which may run over several
293+ // lines and hold comments; the line form keeps only the first
294+ Value::Fun(_) => Ok(first_line(&crate::show::show(interp, v)?)),
295+ Value::Shape(s) => Ok(pal.cyan(&s.name)),
218296 Value::Record(_) => {
219297 if let Some(shape) = shape_of_record(interp, v) {
220298 match shape.as_str() {
221− "Commit" => return commit_line(interp, v, pal, false, &BTreeSet::new(), false),
299+ "Commit" => return line_in(interp, v, pal, loaded, false),
222300 "Entry" => return entry_line(interp, v, pal),
223− "Subtree" => {
224− return commit_line(
225− interp,
226− &v.field("root")?,
227− pal,
228− false,
229− &BTreeSet::new(),
230− false,
231− )
232− }
233− "Repo" => {
234− return commit_line(
235− interp,
236− &v.field("root")?,
237− pal,
238− true,
239− &BTreeSet::new(),
240− false,
241− )
242− }
243− "Frame" => {
244− return commit_line(
245− interp,
246− &v.field("parent")?,
247− pal,
248− false,
249− &BTreeSet::new(),
250− false,
251− )
252− }
301+ "Subtree" => return line_in(interp, &v.field("root")?, pal, loaded, false),
302+ // its focus's line, drawn as the focus
303+ "Repo" => return line_in(interp, &v.field("root")?, pal, loaded, true),
304+ "Frame" => return line_in(interp, &v.field("parent")?, pal, loaded, false),
253305 "Change" => {
254306 let n = touched_paths(v)?.len();
255307 return Ok(format!("{} paths", n));
@@ -264,6 +316,18 @@ fn display_line(interp: &Interp, v: &Value, pal: &Palette) -> Result<String, Cra
264316 }
265317}
266318
319+/// The line form of text that may run over several lines (§5.1): its first
320+/// line, `…` if there are more.
321+fn first_line(s: &str) -> String {
322+ let mut lines = s.lines();
323+ let first = lines.next().unwrap_or("");
324+ if lines.next().is_some() {
325+ format!("{}…", first)
326+ } else {
327+ first.to_string()
328+ }
329+}
330+
267331fn list_line(_interp: &Interp, xs: &[Value], pal: &Palette) -> Result<String, Crash> {
268332 if xs.is_empty() {
269333 return Ok("0 items".into());
@@ -300,14 +364,13 @@ fn list_line(_interp: &Interp, xs: &[Value], pal: &Palette) -> Result<String, Cr
300364 let parts: Vec<String> = xs
301365 .iter()
302366 .map(|x| match x {
303− Value::Int(n) => n.to_string(),
304− Value::Bool(b) => b.to_string(),
367+ Value::Int(n) => pal.yellow(&n.to_string()),
368+ Value::Bool(b) => if *b { pal.green("true") } else { pal.red("false") },
305369 _ => unreachable!(),
306370 })
307371 .collect();
308372 return Ok(parts.join(", "));
309373 }
310− let _ = pal;
311374 Ok(format!("{} items", xs.len()))
312375}
313376
@@ -320,9 +383,9 @@ fn entry_line(interp: &Interp, v: &Value, pal: &Palette) -> Result<String, Crash
320383 };
321384 let _ = interp;
322385 if unresolved {
323− Ok(format!("{} {} {}", pal.red("✖"), path, size))
386+ Ok(format!("{} {} {}", pal.red("✖"), pal.cyan(&path), pal.red(&size)))
324387 } else {
325− Ok(format!(" {} {}", path, size))
388+ Ok(format!(" {} {}", pal.cyan(&path), pal.yellow(&size)))
326389 }
327390}
328391
@@ -335,37 +398,381 @@ fn path_string(p: &Value) -> Result<String, Crash> {
335398 Ok(parts.join("/"))
336399}
337400
401+/// entries of one snapshot, keyed by path components
402+type SnapMap = BTreeMap<Vec<String>, Value>;
403+
338404fn touched_paths(change: &Value) -> Result<Vec<(String, char, bool)>, Crash> {
339− // (path, mark, unresolved)
340405 let from = snapshot_map_of(&change.field("from")?)?;
341406 let to = snapshot_map_of(&change.field("to")?)?;
342− let mut paths: BTreeSet<Vec<String>> = BTreeSet::new();
343− paths.extend(from.keys().cloned());
344− paths.extend(to.keys().cloned());
407+ Ok(touched_in_maps(&from, &to)?
408+ .into_iter()
409+ .map(|(p, mark, unresolved)| (p.join("/"), mark, unresolved))
410+ .collect())
411+}
412+
413+/// The changed paths between two snapshots that have already been keyed, as
414+/// (path components, mark, unresolved). Both maps are sorted, so the union is
415+/// a merge — the previous version collected every key of both sides into a
416+/// `BTreeSet`, cloning each one.
417+fn touched_in_maps(from: &SnapMap, to: &SnapMap) -> Result<Vec<(Vec<String>, char, bool)>, Crash> {
345418 let mut out = Vec::new();
346− for p in paths {
347− let f = from.get(&p);
348− let t = to.get(&p);
349− let mark = match (f, t) {
350− (None, Some(_)) => '+',
351− (Some(_), None) => '−',
352− (Some(a), Some(b)) => {
419+ let mut fi = from.iter().peekable();
420+ let mut ti = to.iter().peekable();
421+ loop {
422+ let ord = match (fi.peek(), ti.peek()) {
423+ (None, None) => break,
424+ (Some(_), None) => std::cmp::Ordering::Less,
425+ (None, Some(_)) => std::cmp::Ordering::Greater,
426+ (Some((fp, _)), Some((tp, _))) => fp.cmp(tp),
427+ };
428+ let (path, mark, t) = match ord {
429+ std::cmp::Ordering::Less => {
430+ let (p, _) = fi.next().unwrap();
431+ (p, '−', None)
432+ }
433+ std::cmp::Ordering::Greater => {
434+ let (p, v) = ti.next().unwrap();
435+ (p, '+', Some(v))
436+ }
437+ std::cmp::Ordering::Equal => {
438+ let (p, a) = fi.next().unwrap();
439+ let (_, b) = ti.next().unwrap();
353440 if crate::value::value_eq(a, b)? {
354441 continue;
355442 }
356− '~'
443+ (p, '~', Some(b))
357444 }
358− (None, None) => continue,
359445 };
360446 let unresolved = t
361447 .map(|b| matches!(b, Value::Blob(bl) if bl.is_unresolved()))
362448 .unwrap_or(false);
363− out.push((p.join("/"), if unresolved { '✖' } else { mark }, unresolved));
449+ out.push((
450+ path.clone(),
451+ if unresolved { '✖' } else { mark },
452+ unresolved,
453+ ));
364454 }
365455 Ok(out)
366456}
367457
368−fn snapshot_map_of(snap: &Value) -> Result<BTreeMap<Vec<String>, Value>, Crash> {
458+/// The line count from which the size bar draws its last glyph, `▇` (§7.11
459+/// column 5): no count past it changes the bar, so none is computed.
460+const SIZE_BAR_TOP: usize = 1000;
461+
462+/// Lines added plus removed between two versions of one path (§7.11 size
463+/// bar), either possibly absent, counted up to `cap`: the length of a
464+/// shortest line diff, or `cap` if that is at least `cap`. Lines are split
465+/// after each `\n` as `line_count` counts them. A conflict counts as its
466+/// materialized text, as it is checked out.
467+///
468+/// A line diff costs the product of the file's length and the size of the
469+/// change, so a plain one of a rewritten or reordered long file takes
470+/// seconds. The search here stops at the cap rather than at a clock, so the
471+/// same two versions always give the same count. Every step before it keeps
472+/// the count exact: the common prefix and suffix are equal lines of some
473+/// shortest diff, and the difference in length is a lower bound. A small
474+/// change is then settled by a short search over the lines as they are.
475+/// Otherwise a line that does not occur on the other side at all cannot be
476+/// part of any common subsequence, so, as git's xdiff does, it counts as
477+/// changed up front, and the rest is searched unless `reorder_bound` already
478+/// puts it past the cap. That search (`line_distance`) costs at most about
479+/// twice what a bit-parallel search of `cap / 64 + 2` machine words per line
480+/// does, whatever the lines are: Myers' search soon stops on most content,
481+/// but on a long file of a few lines repeating it can make a pass over the
482+/// lines for each of the `2 × cap + 1` diagonals it reaches, so once it has
483+/// cost half that the bit-parallel search counts instead. A million lines of
484+/// two alternating, with 480 pairs of neighbours swapped, took Myers' search
485+/// alone some 5 × 10⁸ comparisons.
486+fn changed_lines(from: Option<&Value>, to: Option<&Value>, cap: usize) -> Result<usize, Crash> {
487+ use std::collections::HashMap;
488+ // the rounds of the first, short search
489+ const QUICK: usize = 64;
490+ let text = |v: Option<&Value>| -> Result<Vec<u8>, Crash> {
491+ match v {
492+ Some(Value::Blob(b)) => b.bytes(),
493+ _ => Ok(Vec::new()),
494+ }
495+ };
496+ let (a, b) = (text(from)?, text(to)?);
497+ let a: Vec<&[u8]> = a.split_inclusive(|c| *c == b'\n').collect();
498+ let b: Vec<&[u8]> = b.split_inclusive(|c| *c == b'\n').collect();
499+ let (a, b) = trim_common(&a, &b);
500+ if a.len().abs_diff(b.len()) >= cap {
501+ return Ok(cap);
502+ }
503+ // the short search's few rounds bound what it costs, so no step limit
504+ let quick = cap.min(QUICK);
505+ match edit_distance(a, b, quick, usize::MAX, &mut 0) {
506+ Some(d) if d < quick || quick == cap => return Ok(d),
507+ _ => {}
508+ }
509+ // number each distinct line, so the rest compares numbers; the lines
510+ // numbered while reading `a` are those that occur in it
511+ fn number<'l>(ids: &mut HashMap<&'l [u8], usize>, lines: &[&'l [u8]]) -> Vec<usize> {
512+ lines
513+ .iter()
514+ .map(|l| {
515+ let next = ids.len();
516+ *ids.entry(*l).or_insert(next)
517+ })
518+ .collect()
519+ }
520+ let mut ids = HashMap::new();
521+ let a = number(&mut ids, a);
522+ let in_a = ids.len();
523+ let b = number(&mut ids, b);
524+ let mut in_b = vec![false; ids.len()];
525+ for l in &b {
526+ in_b[*l] = true;
527+ }
528+ let a_common: Vec<usize> = a.iter().copied().filter(|l| in_b[*l]).collect();
529+ let b_common: Vec<usize> = b.iter().copied().filter(|l| *l < in_a).collect();
530+ let unmatched = (a.len() - a_common.len()) + (b.len() - b_common.len());
531+ if unmatched >= cap {
532+ return Ok(cap);
533+ }
534+ let (a, b) = trim_common(&a_common, &b_common);
535+ let max = cap - unmatched;
536+ if reorder_bound(a, b, ids.len()) >= max {
537+ return Ok(cap);
538+ }
539+ Ok(unmatched + line_distance(a, b, ids.len(), max, &mut 0))
540+}
541+
542+/// `a` and `b` without the lines they start and end with in common.
543+fn trim_common<'s, T: PartialEq>(a: &'s [T], b: &'s [T]) -> (&'s [T], &'s [T]) {
544+ let pre = a.iter().zip(b).take_while(|(x, y)| x == y).count();
545+ let (a, b) = (&a[pre..], &b[pre..]);
546+ let suf = a.iter().rev().zip(b.iter().rev()).take_while(|(x, y)| x == y).count();
547+ (&a[..a.len() - suf], &b[..b.len() - suf])
548+}
549+
550+/// The number of lines deleted plus inserted by a shortest diff of `a` to
551+/// `b`, or `max` if that is at least `max`: Myers' greedy search, which
552+/// reaches the end on round `d` exactly when the shortest diff has `d` steps,
553+/// run for `max` rounds at most. A round extends each of its diagonals along
554+/// equal lines from where the round before left off, so the work is at most
555+/// `max` squared diagonals plus one pass over the lines per diagonal. Each
556+/// pair of lines compared along a diagonal adds a step to `steps`, and each
557+/// diagonal reached `DIAGONAL` steps; `None` once they pass `limit`.
558+fn edit_distance<T: PartialEq>(
559+ a: &[T],
560+ b: &[T],
561+ max: usize,
562+ limit: usize,
563+ steps: &mut usize,
564+) -> Option<usize> {
565+ let (n, m) = (a.len() as isize, b.len() as isize);
566+ // the furthest `x` reached on diagonal `k = x - y`, at `v[k + max]`;
567+ // round `d` writes diagonals `-d..=d` from those round `d - 1` wrote, and
568+ // round 0 starts from the zero on diagonal 1
569+ let mut v = vec![0isize; 2 * max + 1];
570+ let at = |k: isize| (k + max as isize) as usize;
571+ for d in 0..max as isize {
572+ for k in (-d..=d).step_by(2) {
573+ // from diagonal `k + 1` by inserting a line of `b`, or from
574+ // `k - 1` by deleting one of `a`, whichever gets further
575+ let from = if k == -d || (k != d && v[at(k - 1)] < v[at(k + 1)]) {
576+ v[at(k + 1)]
577+ } else {
578+ v[at(k - 1)] + 1
579+ };
580+ let (mut x, mut y) = (from, from - k);
581+ while x < n && y < m && a[x as usize] == b[y as usize] {
582+ x += 1;
583+ y += 1;
584+ }
585+ *steps += DIAGONAL + (x - from) as usize;
586+ if *steps > limit {
587+ return None;
588+ }
589+ v[at(k)] = x;
590+ if x >= n && y >= m {
591+ return Some(d as usize);
592+ }
593+ }
594+ }
595+ Some(max)
596+}
597+
598+/// What reaching a diagonal costs `edit_distance`, in comparisons of two
599+/// lines along one, the unit both searches count their steps in: measured at
600+/// about twelve in a debug build and seven in release.
601+const DIAGONAL: usize = 8;
602+
603+/// What a word costs `banded_distance`, in the same comparisons: measured at
604+/// about six in a debug build and three to four in release. Weighted so,
605+/// `line_distance` gives up Myers' search once it has cost about what the
606+/// other search will; counted alike, it gave it up on a long file of a few
607+/// lines repeating where finishing would have taken half the time or less.
608+const WORD: usize = 4;
609+
610+/// `edit_distance` of lines numbered below `ids`, in at most twice the steps
611+/// `banded_distance` takes and one diagonal with its run, whatever the lines
612+/// are. Myers' search soon stops on most content, but on a long file of a
613+/// few lines repeating it runs along equal lines on many diagonals, a pass
614+/// over the file for each, so it may take only as many steps as
615+/// `banded_distance` takes at most, and that counts instead if it has not
616+/// finished by then. Steps are weighted by what they cost (`DIAGONAL`,
617+/// `WORD`), so that is when it has cost about as much as the other search
618+/// will. Both find the same count; `steps` adds up the steps of both.
619+fn line_distance(a: &[usize], b: &[usize], ids: usize, max: usize, steps: &mut usize) -> usize {
620+ let limit = *steps + banded_steps(a.len(), b.len(), max);
621+ match edit_distance(a, b, max, limit, steps) {
622+ Some(d) => d,
623+ None => banded_distance(a, b, ids, max, steps),
624+ }
625+}
626+
627+/// The diagonals `x - y` from `-ins` to `del` that a diff of `n` lines to
628+/// `m` stays on, keeping line `x` of the one with line `y` of the other, when
629+/// it deletes plus inserts fewer than `max` lines, or `None` if none does:
630+/// it deletes `n - m` more lines than it inserts, so at most `del` and
631+/// inserts at most `ins`.
632+fn band(n: usize, m: usize, max: usize) -> Option<(usize, usize)> {
633+ (n.abs_diff(m) < max).then(|| ((max - 1 + n - m) / 2, (max - 1 + m - n) / 2))
634+}
635+
636+/// The most steps `banded_distance` takes on `n` lines and `m` under `max`:
637+/// `WORD` per line of the first, to find where its lines are, and per word
638+/// of it the band crosses, at most `(del + ins) / 64 + 2`, per line of the
639+/// second.
640+fn banded_steps(n: usize, m: usize, max: usize) -> usize {
641+ match band(n, m, max) {
642+ Some((del, ins)) if n > 0 && m > 0 => WORD * (n + m * ((del + ins) / 64 + 2)),
643+ _ => 0,
644+ }
645+}
646+
647+/// `edit_distance` of lines numbered below `ids`, found a machine word of `a`
648+/// at a time for each line of `b` (Hyyrö's bit-parallel longest common
649+/// subsequence), over only the band of diagonals a diff counting less than
650+/// `max` stays on, as Ukkonen bounds the search. Its steps, which it adds to
651+/// `steps`, are `WORD` per line of `a` and per word of `a` the band crosses
652+/// per line of `b`, whatever the lines are.
653+///
654+/// After each line of `b`, the zero bits of `a`'s words up to a place in
655+/// `a` count a longest common subsequence of `a` up to there and `b` so far.
656+/// Only the words the band crosses change: one above the band keeps what it
657+/// last had, and one below it still has the ones it started with, as if the
658+/// length grew no further down. Neither counts more than a common
659+/// subsequence has, and inside the band the counts are exact whenever a diff
660+/// of fewer than `max` lines exists, since such a diff keeps inside the band;
661+/// otherwise the count is `max` whatever they are.
662+fn banded_distance(a: &[usize], b: &[usize], ids: usize, max: usize, steps: &mut usize) -> usize {
663+ let (n, m) = (a.len(), b.len());
664+ let Some((del, ins)) = band(n, m, max) else {
665+ return max;
666+ };
667+ if n == 0 || m == 0 {
668+ return n + m;
669+ }
670+ *steps += WORD * n;
671+ // per line, from `start[l]` to `start[l + 1]`: the words of `a` it occurs
672+ // in, in order, with a bit where it occurs in each
673+ let mut start = vec![0usize; ids + 1];
674+ let mut last = vec![usize::MAX; ids];
675+ for (i, l) in a.iter().enumerate() {
676+ if last[*l] != i / 64 {
677+ last[*l] = i / 64;
678+ start[*l + 1] += 1;
679+ }
680+ }
681+ drop(last);
682+ for l in 0..ids {
683+ start[l + 1] += start[l];
684+ }
685+ let (mut word, mut bits) = (vec![0usize; start[ids]], vec![0u64; start[ids]]);
686+ let mut next = start[..ids].to_vec();
687+ for (i, l) in a.iter().enumerate() {
688+ let e = next[*l];
689+ if e == start[*l] || word[e - 1] != i / 64 {
690+ word[e] = i / 64;
691+ next[*l] += 1;
692+ }
693+ bits[next[*l] - 1] |= 1 << (i % 64);
694+ }
695+ // before any line of `b` nothing is common; from here on `next[l]` is the
696+ // first word line `l` occurs in that is not above the band
697+ let mut v = vec![u64::MAX; n.div_ceil(64)];
698+ next.copy_from_slice(&start[..ids]);
699+ // as slices, which a debug build indexes without a call
700+ let (v, next) = (&mut v[..], &mut next[..]);
701+ let (start, word, bits) = (&start[..], &word[..], &bits[..]);
702+ for (j, l) in b.iter().enumerate() {
703+ // line `j` of `b` can only be kept with lines `j - ins` to `j + del`
704+ // of `a`
705+ let (lo, hi) = (j.saturating_sub(ins) / 64, (j + del).min(n - 1) / 64);
706+ *steps += WORD * (hi + 1 - lo);
707+ let end = start[*l + 1];
708+ let mut e = next[*l];
709+ while e < end && word[e] < lo {
710+ e += 1;
711+ }
712+ next[*l] = e;
713+ // the words above the band do not change, so nothing carries in
714+ let mut carry = false;
715+ for (w, v) in v[lo..=hi].iter_mut().enumerate() {
716+ let here = if e < end && word[e] == lo + w {
717+ e += 1;
718+ bits[e - 1]
719+ } else {
720+ 0
721+ };
722+ let x = *v;
723+ let (sum, c1) = x.overflowing_add(x & here);
724+ let (sum, c2) = sum.overflowing_add(carry as u64);
725+ carry = c1 || c2;
726+ *v = sum | (x & !here);
727+ }
728+ }
729+ let common: usize = v.iter().map(|w| w.count_zeros() as usize).sum();
730+ (n + m - 2 * common).min(max)
731+}
732+
733+/// A lower bound on the lines deleted plus inserted by any diff of `a` to
734+/// `b` (lines numbered below `ids`), cheap where the search is dearest: a
735+/// long file whose lines were reordered, or one of a few lines repeating
736+/// that now occur a different number of times. A line that occurs once on
737+/// each side can only be kept by pairing those two, and the pairs kept keep
738+/// their order on both sides, so at most the longest sequence of them in the
739+/// same order on both sides is kept (patience sorting finds its length); any
740+/// other line is kept at most as many times as it occurs on the side where
741+/// it occurs fewer times.
742+fn reorder_bound(a: &[usize], b: &[usize], ids: usize) -> usize {
743+ // per line: its occurrences in `a` and in `b`, and where it is in `a`
744+ let mut seen = vec![(0usize, 0usize, 0usize); ids];
745+ for (i, l) in a.iter().enumerate() {
746+ seen[*l].0 += 1;
747+ seen[*l].2 = i;
748+ }
749+ for l in b {
750+ seen[*l].1 += 1;
751+ }
752+ // over the lines once on each side, in `b`'s order, by where they are in
753+ // `a`: `ends[i]` is the least last place of an increasing sequence of
754+ // `i + 1` of them
755+ let mut ends: Vec<usize> = Vec::new();
756+ for l in b {
757+ if let (1, 1, i) = seen[*l] {
758+ let at = ends.partition_point(|e| *e < i);
759+ if at == ends.len() {
760+ ends.push(i);
761+ } else {
762+ ends[at] = i;
763+ }
764+ }
765+ }
766+ let others: usize = seen
767+ .iter()
768+ .filter(|(in_a, in_b, _)| (*in_a, *in_b) != (1, 1))
769+ .map(|(in_a, in_b, _)| in_a.min(in_b))
770+ .sum();
771+ let kept = ends.len() + others;
772+ a.len() + b.len() - 2 * kept
773+}
774+
775+fn snapshot_map_of(snap: &Value) -> Result<SnapMap, Crash> {
369776 let mut m = BTreeMap::new();
370777 for e in snap.as_list()?.iter() {
371778 let path = e
@@ -379,13 +786,24 @@ fn snapshot_map_of(snap: &Value) -> Result<BTreeMap<Vec<String>, Value>, Crash>
379786 Ok(m)
380787}
381788
789+/// What a commit's glyph says about where it stands in a history
790+/// (specs/tree.md §Glyphs), beyond what its own files say. A `Commit` value
791+/// records none of it, so both its forms read it from the repository the
792+/// expression was given (`Loaded`).
793+struct Standing {
794+ focus: bool,
795+ /// an ancestor of the focus
796+ ancestor: bool,
797+ immutable: bool,
798+ /// no change against its parent
799+ empty: bool,
800+}
801+
382802fn commit_line(
383803 interp: &Interp,
384804 c: &Value,
385805 pal: &Palette,
386− focus: bool,
387− immutable: &BTreeSet<String>,
388− has_conflicts_hint: bool,
806+ standing: &Standing,
389807) -> Result<String, Crash> {
390808 let id = match c.field("id")? {
391809 Value::Id(i) => i.to_string(),
@@ -399,14 +817,20 @@ fn commit_line(
399817 .iter()
400818 .map(|l| l.as_text().map(|s| s.to_string()))
401819 .collect::<Result<_, _>>()?;
402− let glyph = node_glyph(interp, c, &id, focus, immutable, pal)?;
403− let id_s = display_id(interp, &id, pal);
820+ let glyph = node_glyph(c, &id, standing)?;
821+ let id_s = if glyph == "⊗" {
822+ pal.red(&display_id(interp, &id, &Palette { on: false }))
823+ } else if standing.immutable {
824+ pal.blue(&display_id(interp, &id, &Palette { on: false }))
825+ } else {
826+ display_id(interp, &id, pal)
827+ };
828+ let glyph = pal.code(node_ansi(&glyph), &glyph);
404829 let labels_s = if labels.is_empty() {
405830 String::new()
406831 } else {
407− pal.green(&labels.join(" "))
832+ pal.label(&labels.join(" "))
408833 };
409− let _ = has_conflicts_hint;
410834 Ok(if labels_s.is_empty() {
411835 format!("{} {} {}", glyph, id_s, msg_first)
412836 } else {
@@ -414,28 +838,23 @@ fn commit_line(
414838 })
415839}
416840
417−fn node_glyph(
418− interp: &Interp,
419− c: &Value,
420− id: &str,
421− focus: bool,
422− immutable: &BTreeSet<String>,
423− pal: &Palette,
424−) -> Result<String, Crash> {
841+/// The glyph of a commit line, by `tree`'s precedence (`glyph_for`).
842+fn node_glyph(c: &Value, id: &str, standing: &Standing) -> Result<String, Crash> {
425843 let g = if id == ROOT_ID {
426844 "⌂"
427845 } else if has_conflict(c)? {
428846 "⊗"
429− } else if is_empty_commit(interp, c)? {
847+ } else if standing.empty {
430848 "◌"
431− } else if immutable.contains(id) {
849+ } else if standing.immutable {
432850 "◆"
433− } else if focus {
851+ } else if standing.focus {
434852 "◉"
853+ } else if standing.ancestor {
854+ "●"
435855 } else {
436856 "○"
437857 };
438− let _ = pal;
439858 Ok(g.to_string())
440859}
441860
@@ -451,18 +870,17 @@ fn has_conflict(c: &Value) -> Result<bool, Crash> {
451870 Ok(false)
452871}
453872
454−/// empty: no change against its parent — needs the parent; we only compute
455−/// this meaningfully in tree rendering. In line display, treat as false.
456−fn is_empty_commit(_interp: &Interp, _c: &Value) -> Result<bool, Crash> {
457− Ok(false)
458−}
459−
873+/// `v` in block form, the blocks it holds left on `todo` (see `Block`)
874+#[allow(clippy::too_many_arguments)]
460875fn display_block(
461876 interp: &mut Interp,
462877 v: &Value,
463878 pal: &Palette,
464879 indent: usize,
465880 out: &mut String,
881+ loaded: &mut Option<Loaded>,
882+ todo: &mut Vec<Block>,
883+ term_w: Option<usize>,
466884) -> Result<(), Crash> {
467885 let pad = " ".repeat(indent);
468886 let v = &v.forced()?;
@@ -477,18 +895,31 @@ fn display_block(
477895 out.push_str(&indent_multiline(&content, indent));
478896 return Ok(());
479897 }
480− Value::Int(_) | Value::Bool(_) | Value::Id(_) | Value::Fun(_) | Value::Shape(_) => {
898+ Value::Int(_) | Value::Bool(_) | Value::Id(_) | Value::Shape(_) => {
481899 out.push_str(&pad);
482− out.push_str(&display_line(interp, v, pal)?);
900+ out.push_str(&display_line(interp, v, pal, loaded)?);
483901 out.push('\n');
484902 return Ok(());
485903 }
486− Value::List(xs) => return display_list_block(interp, xs, pal, indent, out),
904+ // all of a lambda's source, of which the line form keeps the first
905+ // line (§5.1), each line under the block's indent
906+ Value::Fun(_) => {
907+ for l in crate::show::show(interp, v)?.lines() {
908+ out.push_str(&pad);
909+ out.push_str(l);
910+ out.push('\n');
911+ }
912+ return Ok(());
913+ }
914+ Value::List(xs) => return display_list_block(interp, xs, pal, indent, out, loaded, todo, term_w),
487915 Value::Record(_) => {}
488916 }
489917 if let Some(shape) = shape_of_record(interp, v) {
490918 match shape.as_str() {
491− "Commit" => return display_commit_block(interp, v, pal, indent, out),
919+ "Commit" => {
920+ let loaded = Loaded::cached(interp, loaded)?;
921+ return display_commit_block(interp, v, pal, indent, out, loaded);
922+ }
492923 "Entry" => {
493924 out.push_str(&pad);
494925 out.push_str(&entry_line(interp, v, pal)?);
@@ -502,7 +933,7 @@ fn display_block(
502933 ("children", v.field("children")?),
503934 ("context", Value::list(vec![])),
504935 ]);
505− let text = tree_render(interp, &opts, &repo, pal, false)?;
936+ let text = tree_render(interp, &opts, &repo, pal, false, term_w)?;
506937 out.push_str(&indent_multiline(&text, indent));
507938 return Ok(());
508939 }
@@ -518,30 +949,14 @@ fn display_block(
518949 }
519950 "Frame" => {
520951 out.push_str(&pad);
521− out.push_str(&commit_line(
522− interp,
523− &v.field("parent")?,
524− pal,
525− false,
526− &BTreeSet::new(),
527− false,
528− )?);
952+ out.push_str(&line_in(interp, &v.field("parent")?, pal, loaded, false)?);
529953 out.push('\n');
530954 return Ok(());
531955 }
532956 "Change" => {
533− for (p, mark, unresolved) in touched_paths(v)? {
957+ for (p, mark, _) in touched_paths(v)? {
534958 out.push_str(&pad);
535− let ms = mark.to_string();
536− let colored = match mark {
537− '+' => pal.green(&ms),
538− '−' => pal.red(&ms),
539− '~' => pal.yellow(&ms),
540− '✖' => pal.red(&ms),
541− _ => ms,
542− };
543− let _ = unresolved;
544− out.push_str(&format!("{} {}\n", colored, p));
959+ out.push_str(&format!("{} {}\n", colored_mark(mark, pal), p));
545960 }
546961 return Ok(());
547962 }
@@ -554,19 +969,68 @@ fn display_block(
554969 _ => unreachable!(),
555970 };
556971 let name_w = m.keys().map(|k| width(k)).max().unwrap_or(0);
557− for (k, x) in m.iter() {
972+ display_fields(interp, m, None, name_w, pal, indent, out, loaded, todo)
973+}
974+
975+/// A generic record's fields after the field `after`, or all of them, one
976+/// per line under names `name_w` wide, up to the first that is a non-empty
977+/// list: its block is left on `todo` to be displayed next, and the fields
978+/// after it to follow.
979+#[allow(clippy::too_many_arguments)]
980+fn display_fields(
981+ interp: &mut Interp,
982+ m: &Rc<RecordMap>,
983+ after: Option<&str>,
984+ name_w: usize,
985+ pal: &Palette,
986+ indent: usize,
987+ out: &mut String,
988+ loaded: &mut Option<Loaded>,
989+ todo: &mut Vec<Block>,
990+) -> Result<(), Crash> {
991+ let pad = " ".repeat(indent);
992+ let status = (m.len() == 5
993+ || (m.len() == 6 && matches!(m.get("unrecorded"), Some(Value::List(_)))))
994+ && matches!(m.get("id"), Some(Value::Id(_)))
995+ && matches!(m.get("message"), Some(Value::Text(_)))
996+ && ["changed", "conflicts", "labels"].iter()
997+ .all(|key| matches!(m.get(*key), Some(Value::List(_))));
998+ let from = after.map_or(Bound::Unbounded, Bound::Excluded);
999+ for (k, x) in m.range::<str, _>((from, Bound::Unbounded)) {
5581000 out.push_str(&pad);
559− out.push_str(&pal.dim(&pad_right(k, name_w)));
1001+ let label = if status && m.contains_key("unrecorded") && k == "changed" {
1002+ "in commit"
1003+ } else {
1004+ k.as_str()
1005+ };
1006+ out.push_str(&pal.field(&pad_right(label, name_w)));
5601007 out.push_str(" ");
1008+ if status {
1009+ let empty = match (k.as_str(), x) {
1010+ ("changed", Value::List(xs)) if xs.is_empty() => Some("No changes"),
1011+ ("unrecorded", Value::List(xs)) if xs.is_empty() => Some("All edits recorded"),
1012+ ("conflicts", Value::List(xs)) if xs.is_empty() => Some("No conflicts"),
1013+ ("labels", Value::List(xs)) if xs.is_empty() => Some("No labels"),
1014+ ("message", Value::Text(s)) if s.is_empty() => Some("No description"),
1015+ _ => None,
1016+ };
1017+ if let Some(text) = empty {
1018+ out.push_str(&pal.dim(text));
1019+ out.push('\n');
1020+ continue;
1021+ }
1022+ }
5611023 // lists print in block form under their key (§5.1)
5621024 if matches!(x, Value::List(xs) if !xs.is_empty()) {
5631025 out.push('\n');
564− display_block(interp, x, pal, indent + name_w + 2, out)?;
565− } else {
566− let line = display_line(interp, x, pal)?;
567− out.push_str(&line);
568− out.push('\n');
1026+ let after = Some(k.clone());
1027+ todo.push(Block::Fields { m: m.clone(), after, name_w, indent });
1028+ todo.push(Block::Of(x.clone(), indent + name_w + 2));
1029+ return Ok(());
5691030 }
1031+ let line = display_line(interp, x, pal, loaded)?;
1032+ out.push_str(&line);
1033+ out.push('\n');
5701034 }
5711035 Ok(())
5721036}
@@ -583,42 +1047,204 @@ fn indent_multiline(s: &str, indent: usize) -> String {
5831047 + "\n"
5841048}
5851049
1050+/// A change mark (`+ ~ − ✖`), coloured by what it means.
1051+fn colored_mark(mark: char, pal: &Palette) -> String {
1052+ let ms = mark.to_string();
1053+ match mark {
1054+ '+' => pal.green(&ms),
1055+ '−' | '✖' => pal.red(&ms),
1056+ '~' => pal.yellow(&ms),
1057+ _ => ms,
1058+ }
1059+}
1060+
1061+/// The repository the expression was given (§1.2 step 4, after the
1062+/// snapshot), or, where only the loaded one is set (the interpreter tests),
1063+/// that one.
1064+fn given_repo(interp: &Interp) -> Option<Value> {
1065+ let given = interp.given_repo.borrow().clone();
1066+ given.or_else(|| interp.old_repo.borrow().clone())
1067+}
1068+
1069+/// The repository the expression was given (`given_repo`), indexed for
1070+/// displaying `Commit` values. A commit value records neither its parent nor
1071+/// where it stands, so both its forms read them from the commit with the
1072+/// same id there, as its block reads author and age (§5.1). Not the
1073+/// repository as loaded: the snapshot has rebased every descendant of the
1074+/// focus since, and the values displayed come from after it. Indexed once
1075+/// per displayed value, not once per commit: a table of commits would
1076+/// otherwise walk the history once for each row.
1077+struct Loaded {
1078+ /// the parent of every commit that has one
1079+ parents: BTreeMap<String, Value>,
1080+ focus: String,
1081+ /// the focus's ancestors, the focus excluded
1082+ ancestors: BTreeSet<String>,
1083+ immutable: BTreeSet<String>,
1084+}
1085+
1086+impl Loaded {
1087+ /// `cache`, indexed when a display first draws a commit
1088+ fn cached<'a>(interp: &mut Interp, cache: &'a mut Option<Loaded>) -> Result<&'a Loaded, Crash> {
1089+ if cache.is_none() {
1090+ *cache = Some(Loaded::of(interp)?);
1091+ }
1092+ Ok(cache.as_ref().expect("just set"))
1093+ }
1094+
1095+ fn of(interp: &mut Interp) -> Result<Loaded, Crash> {
1096+ let mut loaded = Loaded {
1097+ parents: BTreeMap::new(),
1098+ focus: String::new(),
1099+ ancestors: BTreeSet::new(),
1100+ immutable: BTreeSet::new(),
1101+ };
1102+ let Some(repo) = given_repo(interp) else {
1103+ return Ok(loaded);
1104+ };
1105+ if let Value::Id(i) = repo.field("root")?.field("id")? {
1106+ loaded.focus = i.to_string();
1107+ }
1108+ for frame in repo.field("context")?.as_list()?.iter() {
1109+ loaded.ancestors.insert(id_of_frame_parent(frame)?);
1110+ }
1111+ // as `tree_render` does: a config whose `immutable` crashes draws no `◆`
1112+ loaded.immutable = crate::repo::compute_immutable(interp, &repo).unwrap_or_default();
1113+ // every commit's parent, walking down from the top of the history
1114+ let top = crate::repo::by_id(&repo, &top_id(&repo)?)?.unwrap_or_else(|| repo.clone());
1115+ let mut todo = vec![(top.field("root")?, top.field("children")?)];
1116+ while let Some((parent, children)) = todo.pop() {
1117+ for c in children.as_list()?.iter() {
1118+ let root = c.field("root")?;
1119+ if let Value::Id(i) = root.field("id")? {
1120+ loaded.parents.insert(i.to_string(), parent.clone());
1121+ }
1122+ todo.push((root, c.field("children")?));
1123+ }
1124+ }
1125+ Ok(loaded)
1126+ }
1127+
1128+ /// Where the commit with id `id` stands here, given whether it changes
1129+ /// nothing against its parent here.
1130+ fn standing(&self, id: &str, empty: bool) -> Standing {
1131+ Standing {
1132+ focus: id == self.focus,
1133+ ancestor: self.ancestors.contains(id),
1134+ immutable: self.immutable.contains(id),
1135+ empty,
1136+ }
1137+ }
1138+
1139+ /// Where the commit value `c` stands here, for its line form: `◌` judged
1140+ /// as the block judges it, by its files against its parent's here. While
1141+ /// both still hold their stored trees the backend answers from tree ids,
1142+ /// as for `tree` (`build_info`), so a table of commits reads no parent's
1143+ /// files. A commit with no parent here claims no `◌`.
1144+ fn line_standing(&self, interp: &Interp, c: &Value) -> Result<Standing, Crash> {
1145+ let id = match c.field("id")? {
1146+ Value::Id(i) => i.to_string(),
1147+ _ => String::new(),
1148+ };
1149+ let empty = match self.parents.get(&id) {
1150+ None => false,
1151+ Some(parent) => {
1152+ let parent_id = match parent.field("id")? {
1153+ Value::Id(i) => i.to_string(),
1154+ _ => String::new(),
1155+ };
1156+ let me = stored_origin(c).filter(|o| *o == id);
1157+ let stored_parent = stored_origin(parent).filter(|o| *o == parent_id);
1158+ let answer = match (me, stored_parent) {
1159+ (Some(me), Some(parent)) => interp.backend.is_empty(me, parent),
1160+ _ => None,
1161+ };
1162+ match answer {
1163+ Some(e) => e,
1164+ // in any order: a snapshot stands for a tree (§7.3)
1165+ None => crate::repo::snapshot_eq(&c.field("files")?, &parent.field("files")?)?,
1166+ }
1167+ }
1168+ };
1169+ Ok(self.standing(&id, empty))
1170+ }
1171+}
1172+
1173+/// A commit's line form, drawn where it stands in the repository the
1174+/// expression was given (§5.1); `as_focus` draws it as a `Repo`'s focus.
1175+fn line_in(
1176+ interp: &mut Interp,
1177+ c: &Value,
1178+ pal: &Palette,
1179+ loaded: &mut Option<Loaded>,
1180+ as_focus: bool,
1181+) -> Result<String, Crash> {
1182+ let loaded = Loaded::cached(interp, loaded)?;
1183+ let mut standing = loaded.line_standing(interp, c)?;
1184+ standing.focus |= as_focus;
1185+ commit_line(interp, c, pal, &standing)
1186+}
1187+
5861188fn display_commit_block(
5871189 interp: &mut Interp,
5881190 c: &Value,
5891191 pal: &Palette,
5901192 indent: usize,
5911193 out: &mut String,
1194+ loaded: &Loaded,
5921195) -> Result<(), Crash> {
5931196 let pad = " ".repeat(indent);
594− out.push_str(&pad);
595− out.push_str(&commit_line(interp, c, pal, true, &BTreeSet::new(), false)?);
596− out.push('\n');
597− // author · age · n files — from metadata by id (§5.1)
5981197 let id = match c.field("id")? {
5991198 Value::Id(i) => i.to_string(),
6001199 _ => String::new(),
6011200 };
602− let files = c.field("files").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
1201+ let own = snapshot_map_of(&c.field("files")?)?;
1202+ // the paths it changes against its parent in the given repository; with
1203+ // no parent there — the root, or a commit the expression made, whose
1204+ // parent the value does not record — what it changes is not known
1205+ let touched = match loaded.parents.get(&id) {
1206+ Some(parent) => Some(touched_in_maps(
1207+ &snapshot_map_of(&parent.field("files")?)?,
1208+ &own,
1209+ )?),
1210+ None => None,
1211+ };
1212+ // the line `tree` draws for it (§5.1: "a `tree` line without rails")
1213+ let empty = touched.as_ref().is_some_and(|t| t.is_empty());
1214+ out.push_str(&pad);
1215+ out.push_str(&commit_line(interp, c, pal, &loaded.standing(&id, empty))?);
1216+ out.push('\n');
1217+ // author · age · n files — from metadata by id (§5.1)
1218+ let n = touched.as_ref().map_or(own.len(), |t| t.len());
6031219 if let Ok(meta) = interp.backend.meta(&id) {
6041220 let age = render_age(meta.time);
6051221 out.push_str(&pad);
606− out.push_str(&format!(
607− " {} · {} · {} files\n",
608− meta.author,
609− age,
610− files.len()
611− ));
1222+ out.push_str(&format!(" {} · {} · {} files\n", meta.author, age, n));
6121223 }
613− for e in files.iter() {
614− let path = path_string(&e.field("path")?)?;
615− let content = e.field("content")?;
616− let unresolved = matches!(&content, Value::Blob(b) if b.is_unresolved());
617− out.push_str(&pad);
618− if unresolved {
619− out.push_str(&format!(" {} {}\n", pal.red("✖"), path));
620− } else {
621− out.push_str(&format!(" {}\n", path));
1224+ if n > 0 {
1225+ out.push('\n');
1226+ }
1227+ match &touched {
1228+ Some(touched) => {
1229+ for (path, mark, _) in touched {
1230+ out.push_str(&pad);
1231+ out.push_str(&format!(
1232+ " {} {}\n",
1233+ colored_mark(*mark, pal),
1234+ path.join("/")
1235+ ));
1236+ }
1237+ }
1238+ // its files, unmarked but for `✖`
1239+ None => {
1240+ for (path, content) in &own {
1241+ out.push_str(&pad);
1242+ if matches!(content, Value::Blob(b) if b.is_unresolved()) {
1243+ out.push_str(&format!(" {} {}\n", pal.red("✖"), path.join("/")));
1244+ } else {
1245+ out.push_str(&format!(" {}\n", path.join("/")));
1246+ }
1247+ }
6221248 }
6231249 }
6241250 Ok(())
@@ -663,19 +1289,25 @@ pub fn render_date(time: i64) -> String {
6631289 format!("{:04}-{:02}-{:02}", y, m, d)
6641290}
6651291
666−/// the repo value in context (the loaded repo), for resolving ids to commits
1292+/// the repo value in context (the one the expression was given), for
1293+/// resolving ids to commits
6671294fn top_of(interp: &Interp, _x: &Value) -> Result<Value, Crash> {
668− let old = interp.old_repo.borrow().clone();
669− old.ok_or_else(|| Crash::new("display: no repository in context"))
1295+ given_repo(interp).ok_or_else(|| Crash::new("display: no repository in context"))
6701296}
6711297
1298+/// `list` in block form, the blocks it holds left on `todo` (see `Block`)
1299+#[allow(clippy::too_many_arguments)]
6721300fn display_list_block(
6731301 interp: &mut Interp,
674− xs: &[Value],
1302+ list: &ListVal,
6751303 pal: &Palette,
6761304 indent: usize,
6771305 out: &mut String,
1306+ loaded: &mut Option<Loaded>,
1307+ todo: &mut Vec<Block>,
1308+ term_w: Option<usize>,
6781309) -> Result<(), Crash> {
1310+ let xs: &[Value] = list;
6791311 let pad = " ".repeat(indent);
6801312 if xs.is_empty() {
6811313 out.push_str(&pad);
@@ -685,22 +1317,29 @@ fn display_list_block(
6851317 }
6861318 // list of Id: commit table (one commit line per id, in list order)
6871319 if xs.iter().all(|x| matches!(x, Value::Id(_))) {
1320+ // index the history once: a `by_id` walk per id made printing a
1321+ // revset quadratic in the size of the history
1322+ let repo = top_of(interp, &xs[0])?;
1323+ let mut commits: BTreeMap<String, Value> = BTreeMap::new();
1324+ for c in crate::repo::all_commits(&repo)? {
1325+ if let Ok(Value::Id(i)) = c.field("id") {
1326+ commits.insert(i.to_string(), c);
1327+ }
1328+ }
1329+ let loaded = Loaded::cached(interp, loaded)?;
6881330 for x in xs {
6891331 let id = match x {
6901332 Value::Id(i) => i.to_string(),
6911333 _ => unreachable!(),
6921334 };
6931335 out.push_str(&pad);
694− let repo = top_of(interp, x)?;
695− match crate::repo::by_id(&repo, &id)? {
696− Some(loc) => {
697− let c = loc.field("root")?;
698− let conflict = has_conflict(&c)?;
699− let glyph = if conflict {
700− pal.red("⊗")
701− } else {
702− "○".to_string()
703− };
1336+ match commits.get(&id) {
1337+ Some(c) => {
1338+ let c = c.clone();
1339+ // the glyph of its line form (§5.1)
1340+ let glyph = node_glyph(&c, &id, &loaded.line_standing(interp, &c)?)?;
1341+ let conflict = glyph == "⊗";
1342+ let glyph = pal.code(node_ansi(&glyph), &glyph);
7041343 let msg = c.field("message")?.as_text()?.to_string();
7051344 let msg_first = msg.lines().next().unwrap_or("");
7061345 let labels: Vec<String> = c
@@ -710,7 +1349,7 @@ fn display_list_block(
7101349 .map(|l| l.as_text().map(|s| s.to_string()))
7111350 .collect::<Result<_, _>>()?;
7121351 let id_s = if conflict {
713− pal.red(&interp.backend.unique_prefix(&id))
1352+ pal.red(&interp.unique_prefix(&id))
7141353 } else {
7151354 display_id(interp, &id, pal)
7161355 };
@@ -722,7 +1361,7 @@ fn display_list_block(
7221361 glyph,
7231362 id_s,
7241363 msg_first,
725− pal.green(&labels.join(" "))
1364+ pal.label(&labels.join(" "))
7261365 ));
7271366 }
7281367 }
@@ -781,17 +1420,22 @@ fn display_list_block(
7811420 _ => (String::new(), false),
7821421 };
7831422 out.push_str(&pad);
784− let mark = if unresolved { "✖" } else { " " };
1423+ let mark = if unresolved { pal.red("✖") } else { " ".into() };
7851424 let size_s = if unresolved {
7861425 pal.red(&size)
7871426 } else {
788− size
1427+ pal.yellow(&size)
7891428 };
790− out.push_str(&format!("{} {:width$} {}\n", mark, path, size_s, width = width_max));
1429+ // pad by display width, not by character count: `{:n$}`
1430+ // pads to n *chars*, so a wide (East Asian) path pushes
1431+ // the size column out of line
1432+ out.push_str(&format!(
1433+ "{} {} {}\n", mark, pal.cyan(&pad_right(&path, width_max)), size_s
1434+ ));
7911435 }
7921436 return Ok(());
7931437 }
794− return display_table(interp, xs, pal, indent, out);
1438+ return display_table(interp, xs, pal, indent, out, loaded, term_w);
7951439 }
7961440 }
7971441 // list of other scalars
@@ -804,13 +1448,8 @@ fn display_list_block(
8041448 out.push('\n');
8051449 return Ok(());
8061450 }
807− // one block per item, separated by a blank line
808− for (i, x) in xs.iter().enumerate() {
809− if i > 0 {
810− out.push('\n');
811− }
812− display_block(interp, x, pal, indent, out)?;
813− }
1451+ // one block per item, separated by a blank line (see `Block`)
1452+ todo.push(Block::Items { xs: list.clone(), next: 0, indent });
8141453 Ok(())
8151454}
8161455
@@ -820,18 +1459,38 @@ fn display_table(
8201459 pal: &Palette,
8211460 indent: usize,
8221461 out: &mut String,
1462+ loaded: &mut Option<Loaded>,
1463+ term_w: Option<usize>,
8231464) -> Result<(), Crash> {
8241465 let pad = " ".repeat(indent);
8251466 let fields: Vec<String> = field_set(&xs[0]).unwrap().into_iter().collect();
1467+ // IDs must distinguish both stored commits and IDs created by this expression.
1468+ let mut ids: Vec<String> = xs.iter().flat_map(|x| fields.iter().filter_map(|f| {
1469+ match x.field(f) { Ok(Value::Id(id)) => Some(id.to_string()), _ => None }
1470+ })).collect();
1471+ ids.sort();
1472+ ids.dedup();
1473+ let mut fixed = vec![false; fields.len()];
8261474 // cells
8271475 let mut rows: Vec<Vec<String>> = Vec::new();
8281476 for x in xs {
8291477 let mut row = Vec::new();
830− for f in &fields {
1478+ for (column, f) in fields.iter().enumerate() {
8311479 let cell = match x.field(f)? {
832− Value::Bool(true) => "✓".to_string(),
1480+ Value::Bool(true) => pal.green("✓"),
8331481 Value::Bool(false) => String::new(),
834− other => display_line(interp, &other, pal)?,
1482+ Value::Id(id) => {
1483+ fixed[column] = true;
1484+ let stored = interp.unique_prefix(&id);
1485+ let shown = crate::eval::unique_prefix_in(&ids, &id);
1486+ let prefix = if stored.len() > shown.len() { stored } else { shown };
1487+ pal.id(&format!("@{}", prefix))
1488+ }
1489+ Value::List(values) if values.iter().all(|v| matches!(v, Value::Text(_))) => {
1490+ values.iter().map(|v| v.as_text().map(first_line))
1491+ .collect::<Result<Vec<_>, _>>()?.join(", ")
1492+ }
1493+ other => display_line(interp, &other, pal, loaded)?,
8351494 };
8361495 row.push(cell);
8371496 }
@@ -841,7 +1500,7 @@ fn display_table(
8411500 let keep: Vec<usize> = (0..fields.len())
8421501 .filter(|&i| rows.iter().any(|r| !r[i].is_empty()))
8431502 .collect();
844− let widths: Vec<usize> = keep
1503+ let mut widths: Vec<usize> = keep
8451504 .iter()
8461505 .map(|&i| {
8471506 rows.iter()
@@ -851,6 +1510,40 @@ fn display_table(
8511510 .max(width(&fields[i]))
8521511 })
8531512 .collect();
1513+ if let Some(terminal) = term_w {
1514+ let available = terminal.saturating_sub(indent);
1515+ let gaps = keep.len().saturating_sub(1) * 3;
1516+ let minimum: Vec<usize> = keep.iter().zip(&widths).map(|(&i, &w)| {
1517+ if fixed[i] { w } else { w.min(width(&fields[i]).max(6)) }
1518+ }).collect();
1519+ if minimum.iter().sum::<usize>() + gaps > available {
1520+ // A narrow terminal cannot hold the headers and identifiers side by
1521+ // side. Keep a small nesting inset below the parent key, instead of
1522+ // letting a long parent field name consume the entire line width.
1523+ let inset = indent.min(2).min(terminal / 4);
1524+ let pad = " ".repeat(inset);
1525+ let available = terminal.saturating_sub(inset);
1526+ for (n, row) in rows.iter().enumerate() {
1527+ if n > 0 { out.push('\n'); }
1528+ for &i in &keep {
1529+ let text = format!("{}: {}", pal.field(&fields[i]), row[i]);
1530+ for line in wrap_display_text(&text, available.max(1)) {
1531+ out.push_str(&pad);
1532+ out.push_str(&line);
1533+ out.push('\n');
1534+ }
1535+ }
1536+ }
1537+ return Ok(());
1538+ }
1539+ let mut excess = (widths.iter().sum::<usize>() + gaps).saturating_sub(available);
1540+ while excess > 0 {
1541+ let column = (0..widths.len()).filter(|&i| widths[i] > minimum[i])
1542+ .max_by_key(|&i| widths[i]).expect("minimum widths fit");
1543+ widths[column] -= 1;
1544+ excess -= 1;
1545+ }
1546+ }
8541547 // header
8551548 out.push_str(&pad);
8561549 let header: Vec<String> = keep
@@ -858,14 +1551,14 @@ fn display_table(
8581551 .zip(&widths)
8591552 .map(|(&i, w)| pad_right(&fields[i], *w))
8601553 .collect();
861− out.push_str(&pal.dim(&header.join(" ")));
1554+ out.push_str(&pal.field(&header.join(" ")));
8621555 out.push('\n');
8631556 for r in &rows {
8641557 out.push_str(&pad);
8651558 let cells: Vec<String> = keep
8661559 .iter()
8671560 .zip(&widths)
868− .map(|(&i, w)| pad_right(&r[i], *w))
1561+ .map(|(&i, w)| pad_right(&cut_to_width(&r[i], *w), *w))
8691562 .collect();
8701563 out.push_str(&cells.join(" "));
8711564 out.push('\n');
@@ -906,7 +1599,28 @@ fn default_tree_options(_interp: &Interp) -> Result<TreeOptions, Crash> {
9061599 })
9071600}
9081601
1602+/// The terminal's width when stdout is one — what rows are cut to and the
1603+/// legend is placed by (specs/tree.md Step 4, §Legend) — else `None`.
1604+fn tty_width() -> Option<usize> {
1605+ if crate::show::stdout_is_tty() {
1606+ crate::show::terminal_width()
1607+ } else {
1608+ None
1609+ }
1610+}
1611+
9091612pub fn tree_with(interp: &mut Interp, opts: &Value, repo: &Value) -> Result<Value, Crash> {
1613+ tree_with_width(interp, opts, repo, tty_width())
1614+}
1615+
1616+/// `treeWith` as if stdout were a terminal `term_w` columns wide (`None`: not
1617+/// a terminal). Public so the tests can drive truncation without a pty.
1618+pub fn tree_with_width(
1619+ interp: &mut Interp,
1620+ opts: &Value,
1621+ repo: &Value,
1622+ term_w: Option<usize>,
1623+) -> Result<Value, Crash> {
9101624 let defaults = default_tree_options(interp)?;
9111625 // a missing option falls back to its default, so an older config that
9121626 // predates a newer option keeps working; a present option is type-checked
@@ -953,7 +1667,7 @@ pub fn tree_with(interp: &mut Interp, opts: &Value, repo: &Value) -> Result<Valu
9531667 date,
9541668 files,
9551669 };
956− let text = tree_render(interp, &o, repo, &pal, true)?;
1670+ let text = tree_render(interp, &o, repo, &pal, true, term_w)?;
9571671 Ok(Value::text(text))
9581672}
9591673
@@ -968,8 +1682,8 @@ struct CommitInfo {
9681682 is_ancestor_of_focus: bool,
9691683 is_child_of_ancestor: bool,
9701684 meta: Option<crate::domain::MetaInfo>,
971− size: Option<usize>, // lines added+removed against parent
972− nfiles: usize, // number of files in this commit's snapshot
1685+ size: Option<usize>, // lines added+removed against parent, up to SIZE_BAR_TOP
1686+ nfiles: usize, // number of files changed against parent (`files` column)
9731687 detail_marks: Vec<(String, char)>,
9741688 children: Vec<CommitInfo>,
9751689}
@@ -995,6 +1709,49 @@ impl CommitInfo {
9951709 }
9961710}
9971711
1712+/// A parent commit's snapshot as seen by its children: the files value,
1713+/// loaded on first use — by the parent itself or by whichever child needs to
1714+/// compare against it, since only the child knows that it does — plus its
1715+/// path-keyed map built at most once and shared by every child's diff.
1716+/// Keying a snapshot allocates a `Vec<String>` per entry, so rebuilding it for
1717+/// each child dominated `treeFull`/`treeData` on a large history.
1718+struct ParentSnap<'a> {
1719+ commit: &'a Value,
1720+ files: &'a std::cell::OnceCell<Value>,
1721+ map: &'a std::cell::OnceCell<SnapMap>,
1722+ /// the commit's id when its files are still the stored tree
1723+ stored: Option<&'a str>,
1724+}
1725+
1726+impl ParentSnap<'_> {
1727+ fn files(&self) -> Result<&Value, Crash> {
1728+ if self.files.get().is_none() {
1729+ let _ = self.files.set(self.commit.field("files")?);
1730+ }
1731+ Ok(self.files.get().expect("just set"))
1732+ }
1733+
1734+ fn map(&self) -> Result<&SnapMap, Crash> {
1735+ if self.map.get().is_none() {
1736+ let _ = self.map.set(snapshot_map_of(self.files()?)?);
1737+ }
1738+ Ok(self.map.get().expect("just set"))
1739+ }
1740+}
1741+
1742+/// The id of the stored commit whose tree `commit`'s files are, read without
1743+/// loading them: the tag on the backend's lazy list (`ThunkVal::origin`). An
1744+/// edit that changes the files replaces that list, so this is `None` for them.
1745+fn stored_origin(commit: &Value) -> Option<&str> {
1746+ match commit {
1747+ Value::Record(m) => match m.get("files") {
1748+ Some(Value::Thunk(t)) => t.origin(),
1749+ _ => None,
1750+ },
1751+ _ => None,
1752+ }
1753+}
1754+
9981755#[allow(clippy::too_many_arguments)]
9991756fn build_info(
10001757 interp: &mut Interp,
@@ -1004,7 +1761,7 @@ fn build_info(
10041761 immutable: &BTreeSet<String>,
10051762 focus_id: &str,
10061763 anc: &BTreeSet<String>,
1007− parent_files: Option<&Value>,
1764+ parent_files: Option<&ParentSnap<'_>>,
10081765 with_focus: bool,
10091766 parent_is_ancestor: bool,
10101767 parent_is_focus: bool,
@@ -1021,9 +1778,13 @@ fn build_info(
10211778 .map(|l| l.as_text().map(|s| s.to_string()))
10221779 .collect::<Result<_, _>>()?;
10231780 // conflict and empty are O(1) backend queries when the backend can answer
1024− // them from tree ids (jj), without touching the file list at all; the
1025− // in-memory backend cannot, so fall back to walking the files
1026− let conflict = match interp.backend.has_conflict(&id) {
1781+ // them from tree ids (jj), without touching the file list at all. Those
1782+ // answers are about the stored commits, so they are asked only while this
1783+ // commit — and, for empty, its parent here — still holds its stored files:
1784+ // a dry run (§1.2) renders edits that keep their ids but not their files.
1785+ // Otherwise, as on the in-memory backend, fall back to walking the files.
1786+ let stored = stored_origin(commit).filter(|o| *o == id);
1787+ let conflict = match stored.and_then(|s| interp.backend.has_conflict(s)) {
10271788 Some(c) => c,
10281789 None => has_conflict(commit)?,
10291790 };
@@ -1036,56 +1797,67 @@ fn build_info(
10361797 matches!(c.field("root").and_then(|r| r.field("id")), Ok(Value::Id(i)) if *i == focus_id)
10371798 });
10381799 let want_diff = opts.detail >= 2 || is_focus_pre || child_is_focus || parent_is_focus;
1039− let backend_empty = interp.backend.is_empty(&id);
1800+ let backend_empty = match (stored, parent_files.and_then(|p| p.stored)) {
1801+ (Some(me), Some(parent)) => interp.backend.is_empty(me, parent),
1802+ _ => None,
1803+ };
1804+ // this commit's own snapshot and its map, each built on first use — here
1805+ // or by a child that compares against it — and shared with every child
1806+ let my_files: std::cell::OnceCell<Value> = std::cell::OnceCell::new();
1807+ let my_map: std::cell::OnceCell<SnapMap> = std::cell::OnceCell::new();
1808+ let my_snap = ParentSnap {
1809+ commit,
1810+ files: &my_files,
1811+ map: &my_map,
1812+ stored,
1813+ };
10401814 // the files list is only materialized when something needs it: the diff
10411815 // (detail/focus), the files data column, or the emptiness fallback
1042− let need_files = want_diff || opts.files || backend_empty.is_none();
1043− let files = if need_files {
1044− Some(commit.field("files")?)
1045− } else {
1046− None
1047− };
1048− let nfiles = files.as_ref().map(|f| f.as_list().map(|l| l.len())).transpose()?.unwrap_or(0);
1049− let (empty, size, detail_marks) = match parent_files {
1050− Some(pf) => {
1816+ let (empty, size, detail_marks, nfiles) = match parent_files {
1817+ Some(parent) => {
10511818 let empty = match backend_empty {
10521819 Some(e) => e,
1053− None => crate::value::value_eq(files.as_ref().expect("files loaded"), pf)?,
1820+ // in any order: a snapshot stands for a tree (§7.3)
1821+ None => crate::repo::snapshot_eq(my_snap.files()?, parent.files()?)?,
10541822 };
10551823 if want_diff {
1056− let files = files.as_ref().expect("files loaded");
1057− let change = Value::record(&[("from", pf.clone()), ("to", files.clone())]);
1058− let marks = touched_paths(&change)?;
1059− // size: lines added+removed against the parent
1060− let from_map = snapshot_map_of(pf)?;
1061− let to_map = snapshot_map_of(files)?;
1824+ // both maps are built at most once per commit and reused by
1825+ // this commit's children, so a diff costs one keying, not four
1826+ let from_map = parent.map()?;
1827+ let to_map = my_snap.map()?;
1828+ let marks = touched_in_maps(from_map, to_map)?;
1829+ // size: lines added plus removed against the parent (§7.11
1830+ // column 5); a path changed without a changed line (an empty
1831+ // file, a new file type) counts one, so only an empty commit
1832+ // has no bar. The count stops where the bar does.
10621833 let mut lines = 0usize;
1063− for (p, _, _) in &marks {
1064− let key: Vec<String> = p.split('/').map(|s| s.to_string()).collect();
1065− let count = |m: &BTreeMap<Vec<String>, Value>| -> usize {
1066− match m.get(&key) {
1067− Some(Value::Blob(b)) => {
1068− let n = b
1069− .bytes()
1070− .map(|v| String::from_utf8_lossy(&v).lines().count())
1071− .unwrap_or(1);
1072− n.max(1)
1073− }
1074− _ => 1,
1075− }
1076− };
1077− lines += count(&from_map) + count(&to_map);
1834+ for (key, _, _) in &marks {
1835+ if lines >= SIZE_BAR_TOP {
1836+ break;
1837+ }
1838+ let cap = SIZE_BAR_TOP - lines;
1839+ lines += changed_lines(from_map.get(key), to_map.get(key), cap)?.max(1);
10781840 }
10791841 let marks2: Vec<(String, char)> =
1080− marks.iter().map(|(p, m, _)| (p.clone(), *m)).collect();
1081− (marks.is_empty(), Some(lines), marks2)
1842+ marks.iter().map(|(p, m, _)| (p.join("/"), *m)).collect();
1843+ (marks.is_empty(), Some(lines), marks2, marks.len())
10821844 } else {
1083− (empty, None, Vec::new())
1845+ // the files column is the number of paths changed against the
1846+ // parent (specs/tree.md Step 4), so it diffs even without a bar
1847+ let nfiles = if opts.files && !empty {
1848+ touched_in_maps(parent.map()?, my_snap.map()?)?.len()
1849+ } else {
1850+ 0
1851+ };
1852+ (empty, None, Vec::new(), nfiles)
10841853 }
10851854 }
10861855 None => {
1087− let n = nfiles;
1088− (n == 0 && id != ROOT_ID, None, Vec::new())
1856+ // the top of the history: no parent to compare against, and so no
1857+ // backend answer either — it is empty iff it has no files (the
1858+ // root never counts as empty), and every file it has is added
1859+ let nfiles = my_snap.files()?.as_list()?.len();
1860+ (nfiles == 0 && id != ROOT_ID, None, Vec::new(), nfiles)
10891861 }
10901862 };
10911863 let meta = interp.backend.meta(&id).ok();
@@ -1101,7 +1873,7 @@ fn build_info(
11011873 immutable,
11021874 focus_id,
11031875 anc,
1104− files.as_ref(),
1876+ Some(&my_snap),
11051877 with_focus,
11061878 is_ancestor_of_focus,
11071879 is_focus_pre,
@@ -1131,6 +1903,7 @@ fn tree_render(
11311903 repo: &Value,
11321904 pal: &Palette,
11331905 with_focus: bool,
1906+ term_w: Option<usize>,
11341907) -> Result<String, Crash> {
11351908 // immutable set
11361909 let immutable = crate::repo::compute_immutable(interp, repo).unwrap_or_default();
@@ -1184,39 +1957,37 @@ fn tree_render(
11841957 // Step 2 — row order (topological, oldest first)
11851958 let mut flat: Vec<&Display> = Vec::new();
11861959 flatten_display(&droot, &mut flat);
1187− let preorder: BTreeMap<usize, usize> =
1188− flat.iter().enumerate().map(|(i, n)| (n.uid(), i)).collect();
1189− let mut ready: Vec<&Display> = vec![&droot];
1960+ // Display uids are dense. Keep the preorder position as the heap's
1961+ // second key, so a newly ready descendant wins a time tie with a later
1962+ // sibling just as it does in the specification's ready set.
1963+ let mut preorder = vec![0; flat.len()];
1964+ for (i, n) in flat.iter().enumerate() {
1965+ preorder[n.uid()] = i;
1966+ }
1967+ let mut ready = BinaryHeap::from([Reverse((droot.time(), 0usize))]);
11901968 let mut rows: Vec<&Display> = Vec::new();
1191− while !ready.is_empty() {
1192− let best = ready
1193− .iter()
1194− .enumerate()
1195− .min_by_key(|(_, n)| (n.time(), preorder[&n.uid()]))
1196− .map(|(i, _)| i)
1197− .unwrap();
1198− let n = ready.remove(best);
1969+ while let Some(Reverse((_, i))) = ready.pop() {
1970+ let n = flat[i];
11991971 rows.push(n);
12001972 for c in n.children() {
1201− ready.push(c);
1973+ ready.push(Reverse((c.time(), preorder[c.uid()])));
12021974 }
12031975 }
12041976 // Step 3 — lanes
1205− let placements = assign_lanes(&rows, &trunk, lanes_n);
1977+ let index = RowIndex::new(&rows);
1978+ let placements = assign_lanes(&rows, &index, &trunk, lanes_n);
12061979 // id column: shortest unique prefix among the display tree's commits, min 4
1207− let ids: Vec<&str> = rows
1980+ // (sorted once, so each row costs a lookup rather than a scan of them all)
1981+ let mut ids: Vec<String> = rows
12081982 .iter()
1209− .filter_map(|n| n.commit().map(|c| c.id.as_str()))
1983+ .filter_map(|n| n.commit().map(|c| c.id.clone()))
12101984 .collect();
1985+ ids.sort();
12111986 let prefix_len = |id: &str| -> usize {
1212− let mut n = 4.min(id.len());
1213− while n < id.len() && ids.iter().any(|o| *o != id && o.starts_with(&id[..n])) {
1214− n += 1;
1215− }
1216− n
1987+ crate::eval::unique_prefix_in(&ids, id).len()
12171988 };
12181989 // Step 4 — draw
1219− draw_rows(&rows, &placements, opts, pal, lanes_n, with_focus, &prefix_len)
1990+ draw_rows(&rows, &index, &placements, opts, pal, lanes_n, with_focus, &prefix_len, term_w)
12201991}
12211992
12221993fn id_of_frame_parent(frame: &Value) -> Result<String, Crash> {
@@ -1245,11 +2016,7 @@ fn top_id(repo: &Value) -> Result<String, Crash> {
12452016fn compute_trunk(interp: &mut Interp, repo: &Value) -> Result<BTreeSet<String>, Crash> {
12462017 let mut set = BTreeSet::new();
12472018 set.insert(top_id(repo)?);
1248− let trunk_fn = interp
1249− .globals
1250− .lookup("trunk")
1251− .ok_or_else(|| Crash::new("`trunk` is not defined"))?;
1252− let v = interp.apply(trunk_fn, repo.clone())?;
2019+ let v = interp.apply_cached_revset("trunk", repo)?;
12532020 let mut named: Vec<String> = Vec::new();
12542021 for idv in v.as_list()?.iter() {
12552022 match idv {
@@ -1311,12 +2078,16 @@ impl Display {
13112078 }
13122079 }
13132080
1314− fn time(&self) -> Option<i64> {
1315− match self {
2081+ /// The node's time for the row order (Step 1). A node with no stored
2082+ /// counterpart (minted in this program) has time ∞: it sorts after every
2083+ /// stored one, not first as a `None` would.
2084+ fn time(&self) -> i64 {
2085+ let t = match self {
13162086 Display::Commit { info, .. } => info.meta.as_ref().map(|m| m.time),
13172087 Display::Run { time, .. } => *time,
13182088 Display::Collapsed { info, .. } => info.meta.as_ref().map(|m| m.time),
1319− }
2089+ };
2090+ t.unwrap_or(i64::MAX)
13202091 }
13212092
13222093 fn children(&self) -> &[Display] {
@@ -1477,6 +2248,10 @@ enum Rail {
14772248struct Placement {
14782249 lane: Option<usize>,
14792250 fork: Option<(usize, bool)>, // (source lane, source empties below)
2251+ // on a flattened last child of the trunk head (or of the root of an empty
2252+ // trunk): its parent's lane, which empties below this row though no fork
2253+ // is drawn (Step 3, §Overflow)
2254+ ends: Option<usize>,
14802255 reservations: Vec<(usize, usize)>, // (lane, child uid)
14812256 flattened: bool,
14822257}
@@ -1488,26 +2263,70 @@ fn mark_subtree(n: &Display, set: &mut BTreeSet<usize>) {
14882263 }
14892264}
14902265
1491−fn assign_lanes(rows: &[&Display], trunk: &BTreeSet<String>, lanes_n: usize) -> Vec<Placement> {
2266+/// Relations in display-row order, indexed once for lane assignment and
2267+/// size bars. Finding a parent or the focus afresh on every row made even a
2268+/// single line of history take quadratic time.
2269+struct RowIndex {
2270+ row_of: Vec<usize>,
2271+ parent: Vec<Option<usize>>,
2272+ last_child: Vec<Option<usize>>,
2273+ focus_adjacent: Vec<bool>,
2274+}
2275+
2276+impl RowIndex {
2277+ fn new(rows: &[&Display]) -> Self {
2278+ let mut index = RowIndex {
2279+ row_of: vec![0; rows.len()],
2280+ parent: vec![None; rows.len()],
2281+ last_child: vec![None; rows.len()],
2282+ focus_adjacent: vec![false; rows.len()],
2283+ };
2284+ for (i, n) in rows.iter().enumerate() {
2285+ index.row_of[n.uid()] = i;
2286+ }
2287+ for (i, n) in rows.iter().enumerate() {
2288+ for c in n.children() {
2289+ let child = index.row_of[c.uid()];
2290+ index.parent[child] = Some(i);
2291+ index.last_child[i] = Some(index.last_child[i].map_or(child, |last| last.max(child)));
2292+ }
2293+ }
2294+ if let Some(focus) = rows.iter().position(|n| n.commit().is_some_and(|c| c.is_focus)) {
2295+ if let Some(parent) = index.parent[focus] {
2296+ index.focus_adjacent[parent] = true;
2297+ }
2298+ for c in rows[focus].children() {
2299+ index.focus_adjacent[index.row_of[c.uid()]] = true;
2300+ }
2301+ }
2302+ index
2303+ }
2304+}
2305+
2306+fn assign_lanes(
2307+ rows: &[&Display],
2308+ index: &RowIndex,
2309+ trunk: &BTreeSet<String>,
2310+ lanes_n: usize,
2311+) -> Vec<Placement> {
14922312 let mut rails: Vec<Option<Rail>> = vec![None; lanes_n];
14932313 let mut out: Vec<Placement> = Vec::new();
14942314 let mut flattened: BTreeSet<usize> = BTreeSet::new();
2315+ // each node's row: "last child" and "after t" are in row order, which is
2316+ // not sibling order once an edit appends a child (`new`, `rebase`)
2317+ let row_of = &index.row_of;
14952318 for (idx, n) in rows.iter().enumerate() {
14962319 let in_trunk = n.in_trunk(trunk);
1497− let parent = rows[..idx]
1498− .iter()
1499− .copied()
1500− .find(|p| p.children().iter().any(|c| c.uid() == n.uid()));
1501− let parent_lane = parent.and_then(|p| out[rows[..idx].iter().position(|x| x.uid() == p.uid()).unwrap()].lane);
1502− let is_last_child = parent
1503− .map(|p| {
1504− let cs = p.children();
1505− cs[cs.len() - 1].uid() == n.uid()
1506− })
2320+ let parent_idx = index.parent[idx];
2321+ let parent = parent_idx.map(|i| rows[i]);
2322+ let parent_lane = parent_idx.and_then(|i| out[i].lane);
2323+ let is_last_child = parent_idx
2324+ .map(|i| index.last_child[i] == Some(idx))
15072325 .unwrap_or(false);
15082326 let was_flat = flattened.contains(&n.uid());
15092327 let mut lane: Option<usize> = None;
15102328 let mut fork: Option<(usize, bool)> = None;
2329+ let mut ends: Option<usize> = None;
15112330 if !was_flat {
15122331 if idx == 0 {
15132332 lane = Some(0);
@@ -1533,7 +2352,18 @@ fn assign_lanes(rows: &[&Display], trunk: &BTreeSet<String>, lanes_n: usize) ->
15332352 fork = Some((pl, false));
15342353 }
15352354 }
1536− None => mark_subtree(n, &mut flattened),
2355+ None => {
2356+ // overflow is rule 5 too: when n is p's last
2357+ // child (p is then the trunk head or the root of
2358+ // an empty trunk, as any other trunk commit
2359+ // reserves lanes for its later side children),
2360+ // p's lane empties below this row (Step 3)
2361+ mark_subtree(n, &mut flattened);
2362+ if is_last_child {
2363+ rails[pl] = None;
2364+ ends = Some(pl);
2365+ }
2366+ }
15372367 }
15382368 }
15392369 } else {
@@ -1555,8 +2385,13 @@ fn assign_lanes(rows: &[&Display], trunk: &BTreeSet<String>, lanes_n: usize) ->
15552385 let mut reservations: Vec<(usize, usize)> = Vec::new();
15562386 if !now_flat && in_trunk {
15572387 let cs = n.children();
1558− if let Some(tpos) = cs.iter().position(|c| c.in_trunk(trunk)) {
1559− for c in &cs[tpos + 1..] {
2388+ if let Some(t) = cs.iter().find(|c| c.in_trunk(trunk)) {
2389+ let mut after: Vec<&Display> = cs
2390+ .iter()
2391+ .filter(|c| row_of[c.uid()] > row_of[t.uid()])
2392+ .collect();
2393+ after.sort_by_key(|c| row_of[c.uid()]);
2394+ for c in after {
15602395 if flattened.contains(&c.uid()) {
15612396 continue;
15622397 }
@@ -1573,6 +2408,7 @@ fn assign_lanes(rows: &[&Display], trunk: &BTreeSet<String>, lanes_n: usize) ->
15732408 out.push(Placement {
15742409 lane: if now_flat { None } else { lane },
15752410 fork: if now_flat { None } else { fork },
2411+ ends,
15762412 reservations,
15772413 flattened: now_flat,
15782414 });
@@ -1600,27 +2436,20 @@ struct RowText {
16002436
16012437fn build_row_text(
16022438 n: &Display,
1603− rows: &[&Display],
2439+ focus_adjacent: bool,
16042440 opts: &TreeOptions,
16052441 pal: &Palette,
16062442 with_focus: bool,
16072443 prefix_len: &dyn Fn(&str) -> usize,
1608− run_extra: usize,
16092444 id_w: usize,
16102445) -> RowText {
16112446 let c = n.commit();
16122447 let is_focus = c.map(|x| x.is_focus).unwrap_or(false);
1613− let gutter = if is_focus && with_focus { "▶ " } else { " " }.to_string();
1614− // id (on a run row the count follows ╎; only the part that overflows the
1615− // rails area spills into the id column)
2448+ let gutter = if is_focus && with_focus { pal.code("1;96", "▶ ") } else { " ".into() };
2449+ // id (blank on a run row, whose count `rail_row` draws: the part that
2450+ // overflows the rails area runs on into the id column)
16162451 let id = match n {
1617− Display::Run { .. } => {
1618− let mut s = String::new();
1619− while width(&s) < run_extra + id_w {
1620− s.push(' ');
1621− }
1622− s
1623− }
2452+ Display::Run { .. } => " ".repeat(id_w),
16242453 _ => match c {
16252454 Some(info) => {
16262455 let k = prefix_len(&info.id);
@@ -1630,8 +2459,10 @@ fn build_row_text(
16302459 pal.red(&format!("@{}", p))
16312460 } else if info.immutable {
16322461 pal.blue(&format!("@{}", p))
2462+ } else if info.is_focus {
2463+ pal.code("1;96", &format!("@{}", p))
16332464 } else {
1634− format!("@{}", p)
2465+ pal.id(&format!("@{}", p))
16352466 };
16362467 let plain = k + 1; // +1 for the @
16372468 let padded = if plain < id_w {
@@ -1645,23 +2476,8 @@ fn build_row_text(
16452476 },
16462477 };
16472478 // size bar
1648− let focus_idx = rows
1649− .iter()
1650− .position(|m| m.commit().map(|x| x.is_focus).unwrap_or(false));
1651− let is_focus_parent = focus_idx
1652− .map(|fi| {
1653− rows[..fi].iter().any(|p| {
1654− p.children()
1655− .iter()
1656− .any(|ch| ch.uid() == rows[fi].uid()) && p.uid() == n.uid()
1657− })
1658− })
1659− .unwrap_or(false);
1660− let is_focus_child = focus_idx
1661− .map(|fi| rows[fi].children().iter().any(|ch| ch.uid() == n.uid()))
1662− .unwrap_or(false);
16632479 let show_bar = opts.detail >= 1
1664− && c.map(|x| opts.detail >= 2 || x.is_focus || is_focus_parent || is_focus_child)
2480+ && c.map(|x| opts.detail >= 2 || x.is_focus || focus_adjacent)
16652481 .unwrap_or(false);
16662482 let bar = if show_bar {
16672483 c.map(|x| size_bar(x.size, pal)).unwrap_or_default()
@@ -1693,22 +2509,20 @@ fn build_row_text(
16932509 }
16942510 _ => (String::new(), String::new(), String::new()),
16952511 };
1696− // extra columns (§Step 4): date, files changed, full author name
2512+ // extra columns (§Step 4): date, files changed, full author name — one
2513+ // entry per enabled column, blank without metadata (a minted commit), so
2514+ // entry k is the same column on every row
16972515 let mut meta_extra: Vec<String> = Vec::new();
16982516 if let Some(info) = c {
16992517 if info.id != ROOT_ID {
17002518 if opts.date {
1701− if let Some(m) = info.meta.as_ref() {
1702− meta_extra.push(render_date(m.time));
1703− }
2519+ meta_extra.push(info.meta.as_ref().map(|m| render_date(m.time)).unwrap_or_default());
17042520 }
17052521 if opts.files {
17062522 meta_extra.push(format!("{} files", info.nfiles));
17072523 }
17082524 if opts.author {
1709− if let Some(m) = info.meta.as_ref() {
1710− meta_extra.push(m.author.clone());
1711− }
2525+ meta_extra.push(info.meta.as_ref().map(|m| m.author.clone()).unwrap_or_default());
17122526 }
17132527 }
17142528 }
@@ -1728,78 +2542,59 @@ fn build_row_text(
17282542#[allow(clippy::too_many_arguments)]
17292543fn draw_rows(
17302544 rows: &[&Display],
2545+ index: &RowIndex,
17312546 placements: &[Placement],
17322547 opts: &TreeOptions,
17332548 pal: &Palette,
17342549 lanes_n: usize,
17352550 with_focus: bool,
17362551 prefix_len: &dyn Fn(&str) -> usize,
2552+ term_w: Option<usize>,
17372553) -> Result<String, Crash> {
17382554 let rail_chars = 2 * lanes_n;
1739− // id column width: 4-char min prefix plus the `@` literal prefix, and any
1740− // overflow of a run count past the rails area
2555+ let overflow = placements.iter().any(|p| p.flattened);
2556+ // each row's rails, drawn once; a run row's go on past the rails area
2557+ // when its count does not fit
2558+ let mut rail_state = vec![None; lanes_n];
2559+ let rails: Vec<(Vec<char>, Vec<bool>)> = rows
2560+ .iter()
2561+ .zip(placements)
2562+ .map(|(n, p)| {
2563+ let row = rail_row(n, p, &rail_state, lanes_n, opts);
2564+ advance_rail_state(&mut rail_state, n, p);
2565+ row
2566+ })
2567+ .collect();
2568+ // id column width: 4-char min prefix plus the `@` literal prefix, and
2569+ // what a run count writes into it past the rails area and the space
2570+ // after it
17412571 let mut id_w = 5usize;
1742− for (idx, n) in rows.iter().enumerate() {
1743− if let Display::Run { count, .. } = n {
1744− if let Some(l) = placements[idx].lane {
1745− let digits = format!("{}", count).len();
1746− let extra = (2 * l + 1 + digits).saturating_sub(rail_chars);
1747− // digits written from 2l+1; rails hold rail_chars; overflow
1748− // goes into the id column
1749− if extra > 0 {
1750− id_w = id_w.max(4 + extra);
1751− }
1752− }
1753− }
2572+ for (chars, _) in &rails {
2573+ id_w = id_w.max(chars.len().saturating_sub(rail_chars + 1));
17542574 }
17552575 for n in rows {
17562576 if let Some(c) = n.commit() {
17572577 id_w = id_w.max(prefix_len(&c.id) + 1); // +1 for the `@`
17582578 }
17592579 }
1760− let text_off = 2 + rail_chars + 1;
1761− let msg_off = text_off + id_w + 2;
17622580 let label_col = rows
17632581 .iter()
17642582 .any(|n| n.commit().map(|c| !c.labels.is_empty()).unwrap_or(false));
17652583 // build row texts
17662584 let mut texts: Vec<RowText> = Vec::new();
1767− for (idx, n) in rows.iter().enumerate() {
1768− let run_extra = match (n, placements[idx].lane) {
1769− (Display::Run { count, .. }, Some(l)) => {
1770− let digits = format!("{}", count).len();
1771− (2 * l + 1 + digits).saturating_sub(rail_chars)
1772− }
1773− _ => 0,
1774− };
1775− texts.push(build_row_text(
1776− n, rows, opts, pal, with_focus, prefix_len, run_extra, id_w,
1777− ));
1778− }
1779− // right edges for the label and margin columns
1780− let mut msg_end_max = 0usize;
1781− let mut lab_end_max = 0usize;
1782− for (idx, n) in rows.iter().enumerate() {
1783− let t = &texts[idx];
1784− if n.commit().is_none() {
1785− continue;
1786− }
1787− let mut pos = msg_off;
1788− if !t.bar.is_empty() {
1789− pos += 1; // the bar takes one extra column before the message
1790− }
1791− pos += width(&t.msg);
1792− if !t.labels.is_empty() {
1793− pos += 2 + width(&t.labels);
1794− }
1795− msg_end_max = msg_end_max.max(pos - if t.labels.is_empty() { 0 } else { 2 + width(&t.labels) });
1796− lab_end_max = lab_end_max.max(pos);
1797− }
1798− let mut lines: Vec<String> = Vec::new();
2585+ for (i, n) in rows.iter().enumerate() {
2586+ texts.push(build_row_text(n, index.focus_adjacent[i], opts, pal, with_focus, prefix_len, id_w));
2587+ }
2588+ // Columns 5–9 start at the same offset on every row (§Step 4). The size
2589+ // bar's column is there when any row draws a bar — ` ▅`, two columns —
2590+ // and blank on the rows that draw none, so a bar never shifts a message.
2591+ let bar_col = texts.iter().any(|t| !t.bar.is_empty());
2592+ // each row up to its message: gutter, rails, id and bar
2593+ let mut heads: Vec<String> = Vec::new();
17992594 for (idx, n) in rows.iter().enumerate() {
18002595 let pl = &placements[idx];
18012596 let t = &texts[idx];
1802− let (chars, lane0) = rail_row(rows, placements, idx, lanes_n, opts);
2597+ let (chars, lane0) = &rails[idx];
18032598 let mut line = String::new();
18042599 line.push_str(&t.gutter);
18052600 // the node glyph (on a commit row) is meaning-coloured; rails lane-coloured
@@ -1807,59 +2602,119 @@ fn draw_rows(
18072602 (Display::Commit { info, .. }, Some(l)) => Some((2 * l, glyph_ansi(info))),
18082603 _ => None,
18092604 };
1810− line.push_str(&color_rails(&chars, &lane0, pal, glyph));
2605+ line.push_str(&color_rails(chars, lane0, pal, glyph));
2606+ if overflow {
2607+ let status = if pl.flattened {
2608+ let glyph = n.commit().map(|c| {
2609+ pal.code(glyph_ansi(c), glyph_for(c, opts.icons))
2610+ }).unwrap_or_default();
2611+ format!("» {}", glyph)
2612+ } else {
2613+ String::new()
2614+ };
2615+ line.push_str(&pad_right(&status, 4));
2616+ }
18112617 line.push(' ');
18122618 line.push_str(&t.id);
1813− if !t.bar.is_empty() {
2619+ if bar_col {
18142620 line.push(' ');
1815− line.push_str(&t.bar);
2621+ line.push_str(if t.bar.is_empty() { " " } else { &t.bar });
18162622 }
18172623 line.push_str(" ");
2624+ heads.push(line);
2625+ }
2626+ // the columns are placed by display width as drawn (an `icons` glyph is
2627+ // two columns wide), from the message column on
2628+ let msg_col = rows
2629+ .iter()
2630+ .zip(&heads)
2631+ .filter(|(n, _)| n.commit().is_some())
2632+ .map(|(_, h)| width(h))
2633+ .max()
2634+ .unwrap_or(0);
2635+ let lab_w = texts.iter().map(|t| width(&t.labels)).max().unwrap_or(0);
2636+ // the data columns, each as wide as its widest entry, so that the margin
2637+ // after them starts at one offset too
2638+ let mut extra_w: Vec<usize> = Vec::new();
2639+ for t in &texts {
2640+ for (k, e) in t.meta_extra.iter().enumerate() {
2641+ if k == extra_w.len() {
2642+ extra_w.push(0);
2643+ }
2644+ extra_w[k] = extra_w[k].max(width(e));
2645+ }
2646+ }
2647+ // the margin's age and initials, each as wide as its widest entry, so
2648+ // that a `13m` among `9m`s or a single initial moves nothing
2649+ let margin_w = (
2650+ texts.iter().map(|t| width(&t.age)).max().unwrap_or(0),
2651+ texts.iter().map(|t| width(&t.initials)).max().unwrap_or(0),
2652+ );
2653+ let metas: Vec<String> = texts
2654+ .iter()
2655+ .map(|t| meta_block(t, &extra_w, margin_w, opts, pal))
2656+ .collect();
2657+ // message truncation to the terminal width (§Step 4): the widest part
2658+ // right of the message column (labels, data, margin) sets the room left
2659+ // for every message, and only messages are cut to it, so the label and
2660+ // margin columns hold
2661+ if let Some(w) = term_w {
2662+ let right = texts
2663+ .iter()
2664+ .zip(&metas)
2665+ .map(|(t, m)| {
2666+ if !m.is_empty() {
2667+ (if label_col { 2 + lab_w } else { 0 }) + 2 + width(m)
2668+ } else if !t.labels.is_empty() {
2669+ 2 + width(&t.labels)
2670+ } else {
2671+ 0
2672+ }
2673+ })
2674+ .max()
2675+ .unwrap_or(0);
2676+ let room = w.saturating_sub(msg_col + right);
2677+ for t in texts.iter_mut() {
2678+ t.msg = cut_to_width(&t.msg, room);
2679+ }
2680+ }
2681+ let msg_w = texts.iter().map(|t| width(&t.msg)).max().unwrap_or(0);
2682+ let lab_off = msg_col + msg_w + 2;
2683+ let meta_off = if label_col { lab_off + lab_w + 2 } else { lab_off };
2684+ let mut lines: Vec<String> = Vec::new();
2685+ // Detail lines use the state below their row. Advance it once per row,
2686+ // as above, rather than replaying the preceding history.
2687+ rail_state.fill(None);
2688+ for (idx, n) in rows.iter().enumerate() {
2689+ advance_rail_state(&mut rail_state, n, &placements[idx]);
2690+ let t = &texts[idx];
2691+ let mut line = std::mem::take(&mut heads[idx]);
2692+ if n.commit().is_some() {
2693+ pad_to(&mut line, msg_col);
2694+ }
18182695 line.push_str(&t.msg);
18192696 if label_col && !t.labels.is_empty() {
1820− let want = 2 + msg_end_max.saturating_sub(width(&t.msg));
1821− line.push_str(&" ".repeat(want));
1822− line.push_str(&pal.green(&pad_right(&t.labels, width(&t.labels))));
1823− }
1824− // metadata block: extra columns (date / files / author) then the margin
1825− // (age + initials), right-aligned together past the message+labels edge
1826− let has_margin = opts.margin && !t.age.is_empty();
1827− if !t.meta_extra.is_empty() || has_margin {
1828− let cur = if label_col && !t.labels.is_empty() {
1829− msg_end_max + 2 + width(&t.labels)
1830− } else {
1831− width(&t.msg)
1832− };
1833− let want = 2 + lab_end_max.saturating_sub(cur);
1834− line.push_str(&" ".repeat(want));
1835− let mut meta: Vec<String> = Vec::new();
1836− for e in &t.meta_extra {
1837− meta.push(pal.grey(4, e));
1838− }
1839− if has_margin {
1840− meta.push(pal.grey(4, &t.age));
1841− meta.push(pal.author(&t.initials_author, &t.initials));
1842− }
1843− line.push_str(&meta.join(" "));
2697+ pad_to(&mut line, lab_off);
2698+ line.push_str(&pal.label(&t.labels));
18442699 }
1845− let _ = pl;
2700+ if !metas[idx].is_empty() {
2701+ pad_to(&mut line, meta_off);
2702+ line.push_str(&metas[idx]);
2703+ }
2704+ // Remove layout padding, while preserving spaces in detail paths below.
2705+ while line.ends_with(' ') { line.pop(); }
18462706 // the whole focus row is highlighted with a background band across the
18472707 // full terminal width (§Colour; colour only — the ▶ gutter still marks
18482708 // the focus when colour is off)
18492709 let is_focus_row = n.commit().map(|c| c.is_focus).unwrap_or(false);
18502710 if is_focus_row && pal.on {
1851− let target_w = if crate::show::stdout_is_tty() {
1852− crate::show::terminal_width()
1853− } else {
1854− None
1855− };
1856− let padded = match target_w {
2711+ let padded = match term_w {
18572712 Some(w) if width(&line) < w => {
18582713 format!("{}{}", line, " ".repeat(w - width(&line)))
18592714 }
18602715 _ => line.clone(),
18612716 };
1862− lines.push(pal.bg_line("48;5;236", &padded));
2717+ lines.push(pal.bg_line("97;48;5;17", &padded));
18632718 } else {
18642719 lines.push(line);
18652720 }
@@ -1869,12 +2724,14 @@ fn draw_rows(
18692724 && n.commit().map(|c| c.is_focus && !c.detail_marks.is_empty()).unwrap_or(false)
18702725 {
18712726 let c = n.commit().unwrap();
1872− let (dchars, dlane0) = detail_rail_row(rows, placements, idx, lanes_n);
2727+ let (dchars, dlane0) = detail_rail_row(&rail_state);
18732728 let mut dline = String::new();
18742729 dline.push_str(" ");
18752730 dline.push_str(&color_rails(&dchars, &dlane0, pal, None));
2731+ if overflow { dline.push_str(" "); }
18762732 dline.push(' ');
1877− dline.push_str(&" ".repeat(id_w + 2));
2733+ // the marks start two columns into the id column (§Step 4)
2734+ dline.push_str(" ");
18782735 let marks: Vec<String> = c
18792736 .detail_marks
18802737 .iter()
@@ -1890,25 +2747,18 @@ fn draw_rows(
18902747 format!("{} {}", colored, p)
18912748 })
18922749 .collect();
1893− dline.push_str(&marks.join(" "));
1894− lines.push(dline);
1895− }
1896− }
1897− // message truncation to the terminal width (§Step 4)
1898− if crate::show::stdout_is_tty() {
1899− if let Some(w) = crate::show::terminal_width() {
1900− truncate_lines(&mut lines, w, msg_off, label_col, opts.margin);
1901− }
1902− }
1903− // no trailing whitespace on any row
1904− for l in lines.iter_mut() {
1905− while l.ends_with(' ') {
1906− l.pop();
2750+ let detail_lines = match term_w {
2751+ Some(w) => wrap_detail_items(&marks, w.saturating_sub(width(&dline)).max(1)),
2752+ None => vec![marks.join(" ")],
2753+ };
2754+ for text in detail_lines {
2755+ lines.push(format!("{}{}", dline, text));
2756+ }
19072757 }
19082758 }
19092759 // Legend (§Legend): explain the symbols that actually appear, faintly, on
19102760 // the right of the tree if it fits the terminal width, else at the bottom.
1911− append_legend(&mut lines, rows, opts, pal);
2761+ append_legend(&mut lines, rows, opts, pal, term_w, overflow);
19122762 let mut out = lines.join("\n");
19132763 out.push('\n');
19142764 Ok(out)
@@ -1921,10 +2771,11 @@ struct UsedSymbols {
19212771 run: bool, // a ╎ n run row
19222772 collapsed: bool, // a ⋯ n collapsed node
19232773 focus_gutter: bool, // ▶ present (a focus row exists)
2774+ overflow: bool, // separate » status column
19242775 detail_marks: Vec<char>, // + ~ − ✖ present in the focus's detail line
19252776}
19262777
1927−fn collect_used(rows: &[&Display], opts: &TreeOptions, icons: bool) -> UsedSymbols {
2778+fn collect_used(rows: &[&Display], opts: &TreeOptions, icons: bool, overflow: bool) -> UsedSymbols {
19282779 let mut present: BTreeSet<&'static str> = BTreeSet::new();
19292780 let mut run = false;
19302781 let mut collapsed = false;
@@ -1963,6 +2814,7 @@ fn collect_used(rows: &[&Display], opts: &TreeOptions, icons: bool) -> UsedSymbo
19632814 collapsed,
19642815 focus_gutter,
19652816 detail_marks: marks.into_iter().collect(),
2817+ overflow,
19662818 }
19672819}
19682820
@@ -1998,6 +2850,9 @@ fn legend_lines(used: &UsedSymbols) -> Vec<String> {
19982850 if used.focus_gutter {
19992851 out.push("▶ current commit".to_string());
20002852 }
2853+ if used.overflow {
2854+ out.push("» branch exceeds lane limit; increase lanes".to_string());
2855+ }
20012856 if used.run {
20022857 out.push("╎ n run of n commits".to_string());
20032858 }
@@ -2014,8 +2869,15 @@ fn legend_lines(used: &UsedSymbols) -> Vec<String> {
20142869/// faint, to the right of a tree row starting one row from the top, past the
20152870/// widest tree line. If that would exceed the terminal width (or there is no
20162871/// terminal), put the legend at the bottom instead.
2017−fn append_legend(lines: &mut Vec<String>, rows: &[&Display], opts: &TreeOptions, pal: &Palette) {
2018− let used = collect_used(rows, opts, opts.icons);
2872+fn append_legend(
2873+ lines: &mut Vec<String>,
2874+ rows: &[&Display],
2875+ opts: &TreeOptions,
2876+ pal: &Palette,
2877+ tty_width: Option<usize>,
2878+ overflow: bool,
2879+) {
2880+ let used = collect_used(rows, opts, opts.icons, overflow);
20192881 let entries = legend_lines(&used);
20202882 if entries.is_empty() {
20212883 return;
@@ -2024,11 +2886,6 @@ fn append_legend(lines: &mut Vec<String>, rows: &[&Display], opts: &TreeOptions,
20242886 let legend_w = entries.iter().map(|e| width(e)).max().unwrap_or(0);
20252887 let gap = 4;
20262888 let right_total = tree_w + gap + legend_w;
2027− let tty_width = if crate::show::stdout_is_tty() {
2028− crate::show::terminal_width()
2029− } else {
2030− None
2031− };
20322889 let fits_right = tty_width.map_or(false, |w| right_total <= w);
20332890 if fits_right {
20342891 // pad every tree line to tree_w, then append the legend entries dim
@@ -2056,50 +2913,37 @@ fn append_legend(lines: &mut Vec<String>, rows: &[&Display], opts: &TreeOptions,
20562913 }
20572914}
20582915
2059−/// rails state below row `upto` (exclusive): which lanes hold rails, and which
2060−/// of those are lane-0
2061−fn rail_state_below(
2062− rows: &[&Display],
2063− placements: &[Placement],
2064− upto: usize,
2065− lanes_n: usize,
2066−) -> Vec<Option<bool>> {
2067− let mut state: Vec<Option<bool>> = vec![None; lanes_n]; // Some(lane==0)
2068− for (j, m) in rows.iter().enumerate().take(upto) {
2069− let p = &placements[j];
2070− if let Some((src, clears)) = p.fork {
2071− if clears {
2072− state[src] = None;
2073− }
2074− }
2075− if let Some(l) = p.lane {
2076− state[l] = if m.children().is_empty() {
2077− None
2078− } else {
2079− Some(l == 0)
2080− };
2081− }
2082− for &(l, _) in &p.reservations {
2083− state[l] = Some(false);
2916+/// Update the rails above a row to the rails below it. Each occupied lane
2917+/// records whether it belongs to lane 0.
2918+fn advance_rail_state(state: &mut [Option<bool>], n: &Display, p: &Placement) {
2919+ if let Some((src, clears)) = p.fork {
2920+ if clears {
2921+ state[src] = None;
20842922 }
20852923 }
2086− state
2924+ if let Some(l) = p.ends {
2925+ state[l] = None;
2926+ }
2927+ if let Some(l) = p.lane {
2928+ state[l] = if n.children().is_empty() { None } else { Some(l == 0) };
2929+ }
2930+ for &(l, _) in &p.reservations {
2931+ state[l] = Some(false);
2932+ }
20872933}
20882934
2089−/// rails characters for the row of node `idx` (§Step 4, the character table).
2935+/// Rails characters for one node's row (§Step 4, the character table).
20902936/// Returns the characters and, per character, whether it belongs to lane 0.
2937+/// On a run row they go on past the rails area when its count does not fit.
20912938fn rail_row(
2092− rows: &[&Display],
2093− placements: &[Placement],
2094− idx: usize,
2939+ n: &Display,
2940+ pl: &Placement,
2941+ state: &[Option<bool>],
20952942 lanes_n: usize,
20962943 opts: &TreeOptions,
20972944) -> (Vec<char>, Vec<bool>) {
2098− let n = rows[idx];
2099− let pl = &placements[idx];
21002945 let mut chars = vec![' '; 2 * lanes_n];
21012946 let mut lane0 = vec![false; 2 * lanes_n];
2102− let state = rail_state_below(rows, placements, idx, lanes_n);
21032947 // horizontal segments: (start, end) in lane coordinates
21042948 let mut segments: Vec<(usize, usize)> = Vec::new();
21052949 if let Some((src, _)) = pl.fork {
@@ -2143,19 +2987,25 @@ fn rail_row(
21432987 }
21442988 } else if state[i].is_some() {
21452989 '│'
2146− } else if pl.flattened && i == lanes_n - 1 {
2147− '»'
21482990 } else {
21492991 ' '
21502992 };
21512993 chars[2 * i] = c;
21522994 lane0[2 * i] = i == 0;
21532995 }
2154− // on a run row the count follows ╎, written into the rails area
2996+ // on a run row the count follows ╎, or the rightmost rail drawn right of
2997+ // it, so that it covers none; it is written into the rails area and, if
2998+ // needed, on through the space after it into the blank id column. A run
2999+ // reserves no lane and any fork on its row comes from its left, so the
3000+ // rightmost character drawn so far is ╎ or such a rail.
21553001 if let (Display::Run { count, .. }, Some(l)) = (n, pl.lane) {
3002+ let from = chars.iter().rposition(|&c| c != ' ').unwrap_or(2 * l) + 1;
21563003 for (k, d) in format!(" {}", count).chars().enumerate() {
2157− let pos = 2 * l + 1 + k;
2158− if pos < chars.len() {
3004+ let pos = from + k;
3005+ if pos == chars.len() {
3006+ chars.push(d);
3007+ lane0.push(l == 0);
3008+ } else {
21593009 chars[pos] = d;
21603010 lane0[pos] = l == 0;
21613011 }
@@ -2173,13 +3023,8 @@ fn rail_row(
21733023
21743024/// detail line (§Step 4): `│` in every lane that holds a rail below the focus
21753025/// row, including the focus's own lane if it has children
2176−fn detail_rail_row(
2177− rows: &[&Display],
2178− placements: &[Placement],
2179− idx: usize,
2180− lanes_n: usize,
2181−) -> (Vec<char>, Vec<bool>) {
2182− let state = rail_state_below(rows, placements, idx + 1, lanes_n);
3026+fn detail_rail_row(state: &[Option<bool>]) -> (Vec<char>, Vec<bool>) {
3027+ let lanes_n = state.len();
21833028 let mut chars = vec![' '; 2 * lanes_n];
21843029 let mut lane0 = vec![false; 2 * lanes_n];
21853030 for i in 0..lanes_n {
@@ -2192,8 +3037,10 @@ fn detail_rail_row(
21923037}
21933038
21943039/// Colour the rails. Rail connectors are lane-coloured (lane 0 = immutable
2195−/// blue, others dim); the node glyph at `glyph` (its lane position and ANSI
2196−/// code) is meaning-coloured.
3040+/// bright blue, others rotating accent hues); the node glyph at `glyph`
3041+/// (its lane position and ANSI code) is meaning-coloured. A glyph two
3042+/// columns wide (`icons`) fills both
3043+/// characters of its lane, so the one after it is not drawn (§Glyphs).
21973044fn color_rails(
21983045 chars: &[char],
21993046 lane0: &[bool],
@@ -2201,7 +3048,12 @@ fn color_rails(
22013048 glyph: Option<(usize, &str)>,
22023049) -> String {
22033050 let mut s = String::new();
3051+ let mut covered = false;
22043052 for (i, c) in chars.iter().enumerate() {
3053+ if std::mem::take(&mut covered) {
3054+ continue;
3055+ }
3056+ covered = UnicodeWidthChar::width(*c) == Some(2);
22053057 let t = c.to_string();
22063058 if *c == ' ' {
22073059 s.push(*c);
@@ -2211,128 +3063,148 @@ fn color_rails(
22113063 } else if lane0[i] {
22123064 s.push_str(&pal.blue(&t));
22133065 } else {
2214− s.push_str(&pal.dim(&t));
3066+ s.push_str(&pal.rail(i / 2, &t));
22153067 }
22163068 } else if lane0[i] {
22173069 s.push_str(&pal.blue(&t));
22183070 } else {
2219− s.push_str(&pal.dim(&t));
3071+ s.push_str(&pal.rail(i / 2, &t));
22203072 }
22213073 }
22223074 s
22233075}
22243076
2225−/// cut the message so labels and the margin keep their columns (§Step 4)
2226−fn truncate_lines(
2227− lines: &mut [String],
2228− term_w: usize,
2229− msg_off: usize,
2230− label_col: bool,
2231− margin: bool,
2232−) {
2233− for line in lines.iter_mut() {
2234− if width(line) <= term_w {
2235− continue;
2236− }
2237− // find the message span: it starts at msg_off and ends where the
2238− // label/margin padding (2+ spaces) begins
2239− let plain = strip_ansi(line);
2240− if width(&plain) <= term_w {
2241− continue;
2242− }
2243− // walk characters, tracking display column
2244− let mut col = 0usize;
2245− let mut cut_at: Option<usize> = None;
2246− let mut tail_start: Option<usize> = None;
2247− let mut prev_two_spaces = 0usize;
2248− for (bi, ch) in line.char_indices() {
2249− if ch == '\x1b' {
2250− // skip escape sequence
2251− continue;
2252− }
2253− let _ = bi;
2254− let w = UnicodeWidthChar::width(ch).unwrap_or(0);
2255− if col >= msg_off {
2256− if ch == ' ' {
2257− prev_two_spaces += 1;
2258− } else {
2259− prev_two_spaces = 0;
2260− }
2261− if prev_two_spaces == 2 && tail_start.is_none() {
2262− tail_start = Some(bi);
2263− }
2264− }
2265− if col + w >= term_w && cut_at.is_none() {
2266− cut_at = Some(bi);
3077+/// Wrap without dropping text or splitting ANSI sequences. Close and reopen
3078+/// active styles at row boundaries so continuation prefixes stay unstyled.
3079+fn wrap_display_text(text: &str, limit: usize) -> Vec<String> {
3080+ let limit = limit.max(1);
3081+ let mut lines = Vec::new();
3082+ let mut line = String::new();
3083+ let mut used = 0;
3084+ let mut active = String::new();
3085+ let mut chars = text.chars();
3086+ while let Some(c) = chars.next() {
3087+ if c == '\x1b' {
3088+ let mut escape = String::from(c);
3089+ for e in chars.by_ref() {
3090+ escape.push(e);
3091+ if e == 'm' { break; }
22673092 }
2268− col += w;
2269− }
2270− if !label_col && !margin {
3093+ if escape == "\x1b[0m" { active.clear(); } else { active.push_str(&escape); }
3094+ line.push_str(&escape);
22713095 continue;
22723096 }
2273− if let (Some(_cut), Some(_tail)) = (cut_at, tail_start) {
2274− // recompute on the plain string, then rebuild: this path only
2275− // matters on a tty, so keep it simple and operate on plain text
2276− let p = strip_ansi(line);
2277− let pw = width(&p);
2278− if pw <= term_w {
2279− continue;
2280− }
2281− // find message end (last run of 2+ spaces that starts the fixed tail)
2282− let bytes = p.as_bytes();
2283− let mut tail_bi = p.len();
2284− let mut i = 0usize;
2285− while i + 1 < bytes.len() {
2286− if bytes[i] == b' ' && bytes[i + 1] == b' ' {
2287− // candidate: is everything after this point the fixed tail?
2288− tail_bi = i;
2289− }
2290− i += 1;
2291− }
2292− let tail = &p[tail_bi..];
2293− let tail_w = width(tail);
2294− let head_budget = term_w.saturating_sub(tail_w + 1);
2295− let head = &p[..p
2296− .char_indices()
2297− .take_while(|(_, c)| {
2298− let _ = c;
2299− true
2300− })
2301− .count()
2302− .min(p.len())];
2303− let mut head_w = 0usize;
2304− let mut head_end = 0usize;
2305− for (bi, ch) in head.char_indices() {
2306− let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
2307− if head_w + cw > head_budget {
2308− break;
2309− }
2310− head_w += cw;
2311− head_end = bi + ch.len_utf8();
2312− }
2313− let head = head[..head_end].trim_end();
2314− *line = format!("{}…{}", head, tail);
3097+ let cells = UnicodeWidthChar::width(c).unwrap_or(0);
3098+ if used > 0 && used + cells > limit {
3099+ if !active.is_empty() { line.push_str("\x1b[0m"); }
3100+ lines.push(line);
3101+ line = active.clone();
3102+ used = 0;
3103+ }
3104+ line.push(c);
3105+ used += cells;
3106+ }
3107+ if !active.is_empty() { line.push_str("\x1b[0m"); }
3108+ lines.push(line);
3109+ lines
3110+}
3111+
3112+/// Keep whole detail items together when possible; split only an item that
3113+/// cannot fit on an otherwise empty continuation row.
3114+fn wrap_detail_items(items: &[String], limit: usize) -> Vec<String> {
3115+ let mut lines = Vec::new();
3116+ let mut line = String::new();
3117+ for item in items {
3118+ if !line.is_empty() && width(&line) + 3 + width(item) <= limit {
3119+ line.push_str(" ");
3120+ line.push_str(item);
3121+ continue;
23153122 }
3123+ if !line.is_empty() { lines.push(std::mem::take(&mut line)); }
3124+ let mut parts = wrap_display_text(item, limit);
3125+ line = parts.pop().unwrap_or_default();
3126+ lines.extend(parts);
23163127 }
3128+ if !line.is_empty() { lines.push(line); }
3129+ lines
23173130}
23183131
2319−fn strip_ansi(s: &str) -> String {
2320− let mut out = String::new();
2321− let mut esc = false;
3132+/// `s` cut to display width `w`, ending with `…` when anything is cut (and
3133+/// there is a column for it): the message on a terminal (§Step 4). Colour
3134+/// escapes are kept whole and not counted, and those past the cut still
3135+/// follow it, so every style `s` opens is closed.
3136+fn cut_to_width(s: &str, w: usize) -> String {
3137+ if width(s) <= w {
3138+ return s.to_string();
3139+ }
3140+ let mut kept = String::new();
3141+ // escapes not yet followed by a kept character
3142+ let mut pending = String::new();
3143+ let mut used = 0usize;
3144+ let mut in_esc = false;
3145+ let mut full = false;
23223146 for c in s.chars() {
2323− if esc {
2324− if c == 'm' {
2325− esc = false;
2326− }
3147+ if c == '\x1b' {
3148+ in_esc = true;
3149+ }
3150+ if in_esc {
3151+ in_esc = c != 'm';
3152+ pending.push(c);
23273153 continue;
23283154 }
2329− if c == '\x1b' {
2330− esc = true;
3155+ if full {
3156+ continue;
3157+ }
3158+ // one column is left for the `…`
3159+ let cw = UnicodeWidthChar::width(c).unwrap_or(0);
3160+ if used + cw >= w {
3161+ full = true;
23313162 continue;
23323163 }
2333− out.push(c);
3164+ kept.push_str(&pending);
3165+ pending.clear();
3166+ kept.push(c);
3167+ used += cw;
3168+ }
3169+ let ellipsis = if w == 0 { "" } else { "…" };
3170+ format!("{}{}{}", kept.trim_end_matches(' '), ellipsis, pending)
3171+}
3172+
3173+/// Pad `line` with spaces to display column `col`.
3174+fn pad_to(line: &mut String, col: usize) {
3175+ let w = width(line);
3176+ if w < col {
3177+ line.push_str(&" ".repeat(col - w));
23343178 }
2335− out
3179+}
3180+
3181+/// A row's metadata block (§Step 4): the data columns (date / files /
3182+/// author), each padded to its column's width in `extra_w`, then the margin
3183+/// (age and initials), each right-aligned in its width in `margin_w`. Empty
3184+/// when the row shows none of them.
3185+fn meta_block(
3186+ t: &RowText,
3187+ extra_w: &[usize],
3188+ margin_w: (usize, usize),
3189+ opts: &TreeOptions,
3190+ pal: &Palette,
3191+) -> String {
3192+ let mut meta: Vec<String> = Vec::new();
3193+ for (e, w) in t.meta_extra.iter().zip(extra_w) {
3194+ let mut s = if e.is_empty() { String::new() } else { pal.grey(4, e) };
3195+ s.push_str(&" ".repeat(w - width(e)));
3196+ meta.push(s);
3197+ }
3198+ if opts.margin && !t.age.is_empty() {
3199+ let (age_w, init_w) = margin_w;
3200+ let mut age = " ".repeat(age_w - width(&t.age));
3201+ age.push_str(&pal.grey(4, &t.age));
3202+ meta.push(age);
3203+ let mut init = " ".repeat(init_w - width(&t.initials));
3204+ init.push_str(&pal.author(&t.initials_author, &t.initials));
3205+ meta.push(init);
3206+ }
3207+ meta.join(" ").trim_end().to_string()
23363208}
23373209
23383210fn glyph_for(info: &CommitInfo, icons: bool) -> &'static str {
@@ -2372,20 +3244,18 @@ fn glyph_for(info: &CommitInfo, icons: bool) -> &'static str {
23723244/// The ANSI colour code for a node glyph, by meaning (§Colour: glyphs carry
23733245/// meaning; colour only adds emphasis). Mirrors the precedence of `glyph_for`.
23743246fn glyph_ansi(info: &CommitInfo) -> &'static str {
2375− if info.id == ROOT_ID {
2376− "1" // root: bold
2377− } else if info.conflict {
2378− "31" // conflict: red
2379− } else if info.empty {
2380− "2" // empty: dim
2381− } else if info.immutable {
2382− "34" // immutable: blue
2383− } else if info.is_focus {
2384− "1;36" // focus: bold cyan
2385− } else if info.is_ancestor_of_focus {
2386− "32" // ancestor of focus: green
2387− } else {
2388− "37" // other commit: light grey
3247+ node_ansi(glyph_for(info, false))
3248+}
3249+
3250+fn node_ansi(glyph: &str) -> &'static str {
3251+ match glyph {
3252+ "⌂" => "1;95", // root: bold magenta
3253+ "⊗" => "91", // conflict: bright red
3254+ "◌" => "2", // empty: dim
3255+ "◆" => "94", // immutable: bright blue
3256+ "◉" => "1;96", // focus: bold cyan
3257+ "●" => "92", // ancestor of focus: bright green
3258+ _ => "97", // other commit: bright white
23893259 }
23903260}
23913261
@@ -2393,16 +3263,17 @@ fn size_bar(size: Option<usize>, pal: &Palette) -> String {
23933263 match size {
23943264 None | Some(0) => String::new(),
23953265 Some(n) => {
2396− let (bar, level) = if n >= 1000 {
3266+ // one glyph per threshold 1, 10, 50, 200, 1000 (§7.11 column 5)
3267+ let (bar, level) = if n >= SIZE_BAR_TOP {
23973268 ("▇", 4)
23983269 } else if n >= 200 {
2399− ("▇", 3)
3270+ ("▅", 3)
24003271 } else if n >= 50 {
2401− ("▅", 2)
3272+ ("▃", 2)
24023273 } else if n >= 10 {
2403− ("▃", 1)
3274+ ("▂", 1)
24043275 } else {
2405− ("▂", 0)
3276+ ("▁", 0)
24063277 };
24073278 pal.accent(level, bar)
24083279 }
@@ -2416,3 +3287,280 @@ fn initials(name: &str) -> String {
24163287 .collect::<String>()
24173288 .to_lowercase()
24183289}
3290+
3291+#[cfg(test)]
3292+mod tests {
3293+ use super::*;
3294+ use crate::value::BlobVal;
3295+ use std::collections::HashMap;
3296+
3297+ /// Lines deleted plus inserted by a shortest diff of `a` to `b`, uncapped
3298+ /// and found without anything `changed_lines` uses: both lengths less
3299+ /// twice a longest common subsequence, which Hyyrö's bit-parallel LCS
3300+ /// finds a machine word of `a` at a time.
3301+ fn exact(a: &[String], b: &[String]) -> usize {
3302+ let words = a.len().div_ceil(64);
3303+ // per line of `a`, a bit at each place it occurs in `a`
3304+ let mut masks: HashMap<&str, Vec<u64>> = HashMap::new();
3305+ for (i, l) in a.iter().enumerate() {
3306+ masks.entry(l.as_str()).or_insert_with(|| vec![0; words])[i / 64] |= 1 << (i % 64);
3307+ }
3308+ let absent = vec![0; words];
3309+ // after each line of `b`, the zero bits of `v` up to a place in `a`
3310+ // count a longest common subsequence of `a` up to there and `b` so
3311+ // far; the bits past the end of `a` stay ones
3312+ let mut v = vec![u64::MAX; words];
3313+ for l in b {
3314+ let m = masks.get(l.as_str()).unwrap_or(&absent);
3315+ let mut carry = false;
3316+ for (v, m) in v.iter_mut().zip(m) {
3317+ let (sum, c1) = v.overflowing_add(*v & m);
3318+ let (sum, c2) = sum.overflowing_add(carry as u64);
3319+ carry = c1 || c2;
3320+ *v = sum | (*v & !m);
3321+ }
3322+ }
3323+ let common: usize = v.iter().map(|w| w.count_zeros() as usize).sum();
3324+ a.len() + b.len() - 2 * common
3325+ }
3326+
3327+ /// `changed_lines` from `a` to `b` and back is the exact count, or the
3328+ /// cap if that is less: under the size bar's cap, caps on either side of
3329+ /// the first search's 64 rounds, `cap` if given, and the exact count plus
3330+ /// one, which leaves no room: a bound or a search stopped short that
3331+ /// overcounts at all returns the cap there.
3332+ fn check(a: &[String], b: &[String], cap: Option<usize>) {
3333+ let (va, vb) = (BlobVal::text_blob(&a.concat()), BlobVal::text_blob(&b.concat()));
3334+ let want = exact(a, b);
3335+ assert_eq!(want, exact(b, a));
3336+ let mut caps = vec![1, 64, 65, want + 1, SIZE_BAR_TOP];
3337+ caps.extend(cap);
3338+ caps.retain(|c| *c <= SIZE_BAR_TOP);
3339+ for cap in caps {
3340+ for (from, to) in [(&va, &vb), (&vb, &va)] {
3341+ let got = changed_lines(Some(from), Some(to), cap).unwrap();
3342+ assert_eq!(got, want.min(cap), "cap {}, {} and {} lines", cap, a.len(), b.len());
3343+ }
3344+ }
3345+ }
3346+
3347+ /// xorshift64*: the same numbers on every run
3348+ struct Rng(u64);
3349+
3350+ impl Rng {
3351+ fn below(&mut self, n: usize) -> usize {
3352+ self.0 ^= self.0 >> 12;
3353+ self.0 ^= self.0 << 25;
3354+ self.0 ^= self.0 >> 27;
3355+ (self.0.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 32) as usize % n
3356+ }
3357+ }
3358+
3359+ #[test]
3360+ fn changed_lines_is_exact_up_to_its_cap() {
3361+ // the size bar is drawn from this count (§7.11 column 5), and no
3362+ // shortcut may change it, whatever the machine or the content.
3363+ // A few lines repeating, disjoint adjacent pairs of them swapped, or
3364+ // lines a whole number of periods apart each replaced by the line
3365+ // after it (two lines each): no line occurs once on either side, and
3366+ // every diagonal the period divides runs along equal lines from one
3367+ // change to the next. Swapping leaves how many times each line
3368+ // occurs as it was; replacing always turns the same line into the
3369+ // same other one, so the counts alone bound it by its size
3370+ for period in [2, 3] {
3371+ let lines: Vec<String> = (0..3000).map(|i| format!("l{}\n", i % period)).collect();
3372+ for changes in [1, 33, 499, 501] {
3373+ let (mut swapped, mut replaced) = (lines.clone(), lines.clone());
3374+ let step = 2990 / changes;
3375+ let periods = step / period * period;
3376+ for k in 0..changes {
3377+ let at = 3 + step * k;
3378+ swapped.swap(at, at + 1);
3379+ let at = 3 + periods * k;
3380+ replaced[at] = lines[at + 1].clone();
3381+ }
3382+ check(&lines, &swapped, None);
3383+ check(&lines, &replaced, None);
3384+ }
3385+ }
3386+ // runs of one line swapped: a shortest diff keeps the longer run
3387+ let runs = |x: &str, n: usize, y: &str, m: usize| -> Vec<String> {
3388+ let run = |l: &str, n| std::iter::repeat_n(format!("{}\n", l), n);
3389+ run(x, n).chain(run(y, m)).collect()
3390+ };
3391+ check(&runs("a", 499, "b", 501), &runs("b", 501, "a", 500), None);
3392+ check(&runs("a", 500, "b", 501), &runs("b", 501, "a", 500), None);
3393+ // distinct lines, blocks moved past as many others: two lines per line
3394+ // moved, where only the lines that occur once on each side bound it
3395+ let lines: Vec<String> = (0..4000).map(|i| format!("line {}\n", i)).collect();
3396+ for lens in [&[90; 5][..], &[100, 100, 100, 100, 99], &[100; 5], &[250, 251], &[600]] {
3397+ let mut moved = lines.clone();
3398+ for (j, len) in lens.iter().enumerate() {
3399+ let at = 100 + 700 * j;
3400+ let block: Vec<String> = moved.drain(at..at + len).collect();
3401+ moved.splice(at + len..at + len, block);
3402+ }
3403+ check(&lines, &moved, None);
3404+ }
3405+ // random edits of random files, of two lines up to mostly distinct
3406+ // ones: lines inserted, removed, replaced, and blocks moved
3407+ let mut rng = Rng(0x9e37_79b9_7f4a_7c15);
3408+ for _ in 0..40 {
3409+ let distinct = [2, 5, 50, 1 << 20][rng.below(4)];
3410+ let line = |rng: &mut Rng| format!("{}\n", rng.below(distinct));
3411+ let a: Vec<String> = (0..rng.below(800)).map(|_| line(&mut rng)).collect();
3412+ let mut b = a.clone();
3413+ for _ in 0..rng.below(300) {
3414+ let at = rng.below(b.len() + 1);
3415+ match rng.below(4) {
3416+ 0 => b.insert(at, line(&mut rng)),
3417+ 1 if at < b.len() => {
3418+ b.remove(at);
3419+ }
3420+ 2 if at < b.len() => b[at] = line(&mut rng),
3421+ _ => {
3422+ let end = (at + rng.below(40)).min(b.len());
3423+ let block: Vec<String> = b.drain(at..end).collect();
3424+ let to = rng.below(b.len() + 1);
3425+ b.splice(to..to, block);
3426+ }
3427+ }
3428+ }
3429+ check(&a, &b, Some(1 + rng.below(SIZE_BAR_TOP)));
3430+ }
3431+ }
3432+
3433+ #[test]
3434+ fn banded_distance_is_exact_under_its_max() {
3435+ // the search `line_distance` falls back on, checked on its own,
3436+ // since `changed_lines` only reaches it where Myers' search would
3437+ // take long. Under the exact count plus one the band has no room to
3438+ // spare, so a word wrongly updated or left alone at its edges
3439+ // overcounts there. Files of a few words, random or repeating a few
3440+ // lines, with a band narrower than them under most caps
3441+ let text = |ls: &[usize]| -> Vec<String> {
3442+ ls.iter().map(|l| format!("{}\n", l)).collect()
3443+ };
3444+ let mut rng = Rng(0x2545_f491_4f6c_dd1d);
3445+ for _ in 0..200 {
3446+ let distinct = [1, 2, 3, 50][rng.below(4)];
3447+ let most = [70, 300, 700][rng.below(3)];
3448+ let len = rng.below(most);
3449+ let periodic = rng.below(2) == 0;
3450+ let a: Vec<usize> = (0..len)
3451+ .map(|i| if periodic { i % distinct } else { rng.below(distinct) })
3452+ .collect();
3453+ let mut b = a.clone();
3454+ for _ in 0..rng.below(60) {
3455+ let at = rng.below(b.len() + 1);
3456+ match rng.below(5) {
3457+ 0 => b.insert(at, rng.below(distinct)),
3458+ 1 if at < b.len() => {
3459+ b.remove(at);
3460+ }
3461+ 2 if at < b.len() => b[at] = rng.below(distinct),
3462+ 3 if at + 1 < b.len() => b.swap(at, at + 1),
3463+ _ => {
3464+ let end = (at + rng.below(40)).min(b.len());
3465+ let block: Vec<usize> = b.drain(at..end).collect();
3466+ let to = rng.below(b.len() + 1);
3467+ b.splice(to..to, block);
3468+ }
3469+ }
3470+ }
3471+ let want = exact(&text(&a), &text(&b));
3472+ for max in [1, 2, want + 1, want + 2, 64, 65, SIZE_BAR_TOP, 1 + rng.below(want + 2)] {
3473+ for (x, y) in [(&a, &b), (&b, &a)] {
3474+ let mut steps = 0;
3475+ let got = banded_distance(x, y, distinct, max, &mut steps);
3476+ assert_eq!(got, want.min(max), "max {}, {:?} and {:?}", max, x, y);
3477+ assert!(steps <= banded_steps(x.len(), y.len(), max));
3478+ }
3479+ }
3480+ }
3481+ }
3482+
3483+ #[test]
3484+ fn line_distance_takes_steps_by_the_band_not_by_the_file() {
3485+ // two lines alternating, 480 pairs of neighbours swapped: 960 lines.
3486+ // Myers' search runs along equal lines on every other diagonal from
3487+ // one swap to the next, a pass over the file for each of about 500
3488+ // diagonals, and on such a file of a million lines drawing the size
3489+ // bar took seconds. `line_distance` gives it up once it has taken as
3490+ // many steps as the bit-parallel search takes at most, 18 words per
3491+ // line under the size bar's cap, so it takes at most twice that and
3492+ // one diagonal with its run, whatever the lines are
3493+ let a: Vec<usize> = (0..100_000).map(|i| i % 2).collect();
3494+ let mut b = a.clone();
3495+ for k in 0..480 {
3496+ b.swap(1000 + 200 * k, 1001 + 200 * k);
3497+ }
3498+ let (mut plain, mut steps) = (0, 0);
3499+ assert_eq!(edit_distance(&a, &b, SIZE_BAR_TOP, usize::MAX, &mut plain), Some(960));
3500+ assert_eq!(line_distance(&a, &b, 2, SIZE_BAR_TOP, &mut steps), 960);
3501+ let most = banded_steps(a.len(), b.len(), SIZE_BAR_TOP);
3502+ assert!(most <= 18 * WORD * a.len(), "{} steps at most", most);
3503+ assert!(steps <= 2 * most + DIAGONAL + a.len(), "{} steps", steps);
3504+ assert!(plain > 400 * a.len(), "{} steps", plain);
3505+ }
3506+
3507+ #[test]
3508+ fn line_distance_gives_up_where_the_bit_parallel_search_costs_less() {
3509+ // the hand-over is where it saves time, not where the two searches
3510+ // have taken as many steps. Two lines alternating, 32 pairs of
3511+ // neighbours swapped far apart: Myers' search compares about twice
3512+ // as many pairs of lines as `banded_distance` takes word steps, but
3513+ // a word step costs several comparisons, so it is the cheaper search
3514+ // and finishes. Giving it up there made the search over a million
3515+ // such lines take three times as long in a debug build
3516+ let a: Vec<usize> = (0..100_000).map(|i| i % 2).collect();
3517+ let mut b = a.clone();
3518+ for k in 0..32 {
3519+ b.swap(1001 + 3000 * k, 1002 + 3000 * k);
3520+ }
3521+ let (mut plain, mut steps) = (0, 0);
3522+ assert_eq!(edit_distance(&a, &b, SIZE_BAR_TOP, usize::MAX, &mut plain), Some(64));
3523+ assert_eq!(line_distance(&a, &b, 2, SIZE_BAR_TOP, &mut steps), 64);
3524+ assert_eq!(steps, plain);
3525+ // distinct lines, a block of 400 moved past 10000 others: Myers'
3526+ // search reaches some 320000 diagonals, on most of which no line is
3527+ // equal, and reaching one costs about twice a word step, so it gives
3528+ // up and the two searches together take fewer steps than it alone
3529+ let a: Vec<usize> = (0..10_400).collect();
3530+ let b: Vec<usize> = (400..10_400).chain(0..400).collect();
3531+ let (mut plain, mut steps) = (0, 0);
3532+ assert_eq!(edit_distance(&a, &b, SIZE_BAR_TOP, usize::MAX, &mut plain), Some(800));
3533+ assert_eq!(line_distance(&a, &b, a.len(), SIZE_BAR_TOP, &mut steps), 800);
3534+ assert!(steps < plain, "{} steps, {} alone", steps, plain);
3535+ }
3536+
3537+ #[test]
3538+ fn reorder_bound_keeps_no_line_more_often_than_either_side_has_it() {
3539+ // the bound only spares `changed_lines` a search: the exact counts
3540+ // above hold with any lower bound, however loose, so only this pins
3541+ // how tight it is. Lines that occur once on each side keep at most a
3542+ // longest sequence of them in the same order on both: ten reversed
3543+ // keep one
3544+ let distinct: Vec<usize> = (0..10).collect();
3545+ let reversed: Vec<usize> = (0..10).rev().collect();
3546+ assert_eq!(reorder_bound(&distinct, &reversed, 10), 18);
3547+ // any other line is kept at most as many times as the side with
3548+ // fewer of it has it: two lines alternating with 501 of the 1500
3549+ // `0`s turned into `1`s keep 999 `0`s and 1500 `1`s. Counting them
3550+ // only up to the shorter side kept all 3000, a bound of 0, and the
3551+ // search ran over the whole file
3552+ let alternating: Vec<usize> = (0..3000).map(|i| i % 2).collect();
3553+ let mut replaced = alternating.clone();
3554+ for k in 0..501 {
3555+ replaced[2 * k] = 1;
3556+ }
3557+ assert_eq!(reorder_bound(&alternating, &replaced, 2), 1002);
3558+ assert_eq!(reorder_bound(&replaced, &alternating, 2), 1002);
3559+ // the two kinds side by side add up
3560+ let then = |once: &[usize], others: &[usize]| -> Vec<usize> {
3561+ once.iter().copied().chain(others.iter().map(|l| l + 10)).collect()
3562+ };
3563+ let (a, b) = (then(&distinct, &alternating), then(&reversed, &replaced));
3564+ assert_eq!(reorder_bound(&a, &b, 12), 1020);
3565+ }
3566+}
src/repo.rs+499−69
@@ -1,27 +1,115 @@
11//! Repo-zipper operations, the reference `by` walk (§10), and the
22//! persistence validation shared by `validate` and the jj backend (§7.5).
33
4−use crate::domain::ROOT_ID;
4+use crate::domain::{Backend, ROOT_ID};
55use crate::eval::Interp;
66use crate::value::{value_eq, Crash, Value};
7−use std::collections::{BTreeMap, BTreeSet};
7+use std::collections::{BTreeMap, BTreeSet, HashMap};
88
99/// navigate: refocus `repo` on the commit with change id `id` (§10 reference).
10+///
11+/// Finding the commit and refocusing onto it are separate walks: refocusing
12+/// builds a new location — a record, with the siblings split around a hole —
13+/// so doing it for every node visited on the way cost an allocation per
14+/// commit. Only the commits on the path to the target need one, and there are
15+/// as many of those as the history is deep.
1016pub fn by_id(repo: &Value, id: &str) -> Result<Option<Value>, Crash> {
1117 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));
18+ if id_is(&top.field("root")?, id)? {
19+ return Ok(Some(top));
20+ }
21+ let mut path = Vec::new();
22+ let children = top.field("children")?;
23+ if !find_path(children.as_list()?, id, &mut path)? {
24+ return Ok(None);
25+ }
26+ // Descend the path collecting one frame per level and assemble the
27+ // location once. Refocusing step by step would rebuild the context at
28+ // every level, which is quadratic in the depth on its own.
29+ let mut root = top.field("root")?;
30+ let mut children = top.field("children")?;
31+ let mut frames: Vec<Value> = Vec::with_capacity(path.len());
32+ for idx in path {
33+ let kids = children.as_list()?;
34+ let child = kids[idx].clone();
35+ frames.push(Value::record(&[
36+ ("left", Value::list(kids[..idx].to_vec())),
37+ ("parent", root),
38+ ("right", Value::list(kids[idx + 1..].to_vec())),
39+ ]));
40+ root = child.field("root")?;
41+ children = child.field("children")?;
42+ }
43+ // innermost frame first: `up` reads context.first() as the parent
44+ frames.reverse();
45+ // `top_of` leaves no context above, but do not depend on that here
46+ frames.extend_from_slice(top.field("context")?.as_list()?);
47+ Ok(Some(Value::record(&[
48+ ("children", children),
49+ ("context", Value::list(frames)),
50+ ("root", root),
51+ ])))
52+}
53+
54+/// indices of the children to descend through to reach `id`, if it is there
55+fn find_path(children: &[Value], id: &str, path: &mut Vec<usize>) -> Result<bool, Crash> {
56+ for (i, c) in children.iter().enumerate() {
57+ path.push(i);
58+ if id_is(&c.field("root")?, id)? {
59+ return Ok(true);
1760 }
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)?);
61+ let grandchildren = c.field("children")?;
62+ if find_path(grandchildren.as_list()?, id, path)? {
63+ return Ok(true);
2264 }
65+ path.pop();
66+ }
67+ Ok(false)
68+}
69+
70+thread_local! {
71+ // One repository value, held while it is the key so its address cannot
72+ // be reused. Edits create a different record and invalidate the index.
73+ static COMMIT_INDEX: std::cell::RefCell<Option<(
74+ std::rc::Rc<crate::value::RecordMap>, Option<HashMap<String, Value>>,
75+ )>> = const { std::cell::RefCell::new(None) };
76+}
77+
78+/// The root of `by_id`, without allocating a zipper for every queried id.
79+/// A malformed tree uses the original walk: a commit found before a bad
80+/// sibling must still be readable, and duplicate ids keep their first match.
81+pub fn commit_by_id(repo: &Value, id: &str) -> Result<Option<Value>, Crash> {
82+ if let Value::Record(key) = repo {
83+ let indexed = COMMIT_INDEX.with(|cache| {
84+ let mut cache = cache.borrow_mut();
85+ if !cache.as_ref().is_some_and(|(k, _)| std::rc::Rc::ptr_eq(k, key)) {
86+ let index = (|| -> Result<HashMap<String, Value>, Crash> {
87+ let mut index = HashMap::new();
88+ let mut pending = vec![top_of(repo)?];
89+ while let Some(tree) = pending.pop() {
90+ let commit = tree.field("root")?;
91+ index.entry(id_of(&commit)?).or_insert(commit);
92+ pending.extend(tree.field("children")?.as_list()?.iter().rev().cloned());
93+ }
94+ Ok(index)
95+ })().ok();
96+ *cache = Some((key.clone(), index));
97+ }
98+ cache.as_ref().unwrap().1.as_ref().map(|index| index.get(id).cloned())
99+ });
100+ if let Some(found) = indexed {
101+ return Ok(found);
102+ }
103+ }
104+ by_id(repo, id)?.map(|r| r.field("root")).transpose()
105+}
106+
107+/// compare a commit's id without copying it out
108+fn id_is(commit: &Value, id: &str) -> Result<bool, Crash> {
109+ match commit.field("id")? {
110+ Value::Id(i) => Ok(i.as_str() == id),
111+ v => Err(Crash::new(format!("expected an Id, got a {}", v.kind_name()))),
23112 }
24− Ok(None)
25113}
26114
27115fn id_of(commit: &Value) -> Result<String, Crash> {
@@ -31,12 +119,46 @@ fn id_of(commit: &Value) -> Result<String, Crash> {
31119 }
32120}
33121
122+thread_local! {
123+ /// The last location climbed to the top, and the value it was climbed
124+ /// from. A revset walk applies `by` to the same repo once per id, and
125+ /// each climb rebuilds a location per level of depth; the answer only
126+ /// depends on the value, which cannot change. Holding the `Rc` keeps the
127+ /// address from being reused while it is the key.
128+ static TOP_MEMO: std::cell::RefCell<Option<(std::rc::Rc<crate::value::RecordMap>, Value)>> =
129+ const { std::cell::RefCell::new(None) };
130+}
131+
34132/// move a location to the top of history
35133fn top_of(repo: &Value) -> Result<Value, Crash> {
134+ let key = match repo {
135+ Value::Record(m) => Some(m.clone()),
136+ _ => None,
137+ };
138+ if let Some(k) = &key {
139+ let hit = TOP_MEMO.with(|c| {
140+ c.borrow().as_ref().and_then(|(cached, top)| {
141+ std::rc::Rc::ptr_eq(cached, k).then(|| top.clone())
142+ })
143+ });
144+ if let Some(top) = hit {
145+ return Ok(top);
146+ }
147+ }
148+ let top = top_of_uncached(repo)?;
149+ if let Some(k) = key {
150+ TOP_MEMO.with(|c| *c.borrow_mut() = Some((k, top.clone())));
151+ }
152+ Ok(top)
153+}
154+
155+fn top_of_uncached(repo: &Value) -> Result<Value, Crash> {
36156 let mut cur = repo.clone();
37157 loop {
38− let ctx = cur.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
39− if ctx.is_empty() {
158+ // ask whether the context is empty without copying it: this runs once
159+ // per level, and copying made climbing quadratic in the depth
160+ let ctx = cur.field("context")?;
161+ if ctx.as_list()?.is_empty() {
40162 return Ok(cur);
41163 }
42164 cur = up_of(&cur)?;
@@ -60,50 +182,18 @@ fn up_of(repo: &Value) -> Result<Value, Crash> {
60182 ("children", repo.field("children")?),
61183 ]));
62184 children.extend_from_slice(right);
185+ // the remaining context shares the frames rather than copying them
186+ let rest = match repo.field("context")? {
187+ Value::List(xs) => Value::List(xs.skip(1)),
188+ v => return Err(Crash::new(format!("expected a list, got a {}", v.kind_name()))),
189+ };
63190 Ok(Value::record(&[
64191 ("children", Value::list(children)),
65− ("context", Value::list(ctx[1..].to_vec())),
192+ ("context", rest),
66193 ("root", parent),
67194 ]))
68195}
69196
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− // match by the child's root id: ids are unique per repo (§7.5 validates
77− // this at persist time), and a deep value_eq here would compare the whole
78− // subtree — files included — making by_id quadratic in history size
79− let want_id = id_of(&child.field("root")?).ok();
80− for c in children.iter() {
81− let same = match (&want_id, id_of(&c.field("root")?).ok()) {
82− (Some(w), Some(cid)) => *w == cid,
83− _ => value_eq(c, child)?,
84− };
85− if !found && same {
86− found = true;
87− } else if !found {
88− left.push(c.clone());
89− } else {
90− right.push(c.clone());
91− }
92− }
93− let frame = Value::record(&[
94− ("left", Value::list(left)),
95− ("parent", repo.field("root")?),
96− ("right", Value::list(right)),
97− ]);
98− let mut ctx = repo.field("context")?.as_list()?.to_vec();
99− ctx.insert(0, frame);
100− Ok(Value::record(&[
101− ("children", child.field("children")?),
102− ("context", Value::list(ctx)),
103− ("root", child.field("root")?),
104− ]))
105−}
106−
107197// ----------------------------------------------------------------------
108198// tree traversal helpers over Repo values
109199// ----------------------------------------------------------------------
@@ -134,18 +224,166 @@ pub fn parent_map(repo: &Value) -> Result<Vec<(String, String)>, Crash> {
134224 Ok(out)
135225}
136226
137−fn parent_map_rec(loc: &Value, out: &mut Vec<(String, String)>) -> Result<(), Crash> {
138− let pid = id_of(&loc.field("root")?)?;
139− let children = loc.field("children")?;
227+/// `tree` is the top location or a subtree below it: only its `root` and
228+/// `children` are read. Refocusing on each child instead copied the context
229+/// at every level, which is quadratic in the depth of the history, in time
230+/// and in what the walk holds at once.
231+fn parent_map_rec(tree: &Value, out: &mut Vec<(String, String)>) -> Result<(), Crash> {
232+ let pid = id_of(&tree.field("root")?)?;
233+ let children = tree.field("children")?;
140234 for c in children.as_list()?.iter() {
141235 out.push((id_of(&c.field("root")?)?, pid.clone()));
142− parent_map_rec(&refocus_child(loc, c)?, out)?;
236+ parent_map_rec(c, out)?;
143237 }
144238 Ok(())
145239}
146240
147241// ----------------------------------------------------------------------
148−// validation (§7.5 steps 1–3 and 6), shared by `validate` and persistence
242+// equality up to the order of snapshot entries (§7.3)
243+// ----------------------------------------------------------------------
244+
245+/// Whether two snapshots hold the same entries, in whatever order. A
246+/// snapshot stands for a tree, which has no order (§7.3): `select` lists the
247+/// entries a fileset matches first, so a `contract m` that moves nothing
248+/// still reorders the parent's files, and comparing them as lists called
249+/// that a change. Entries compare with `value_eq`, so lazy blobs still
250+/// compare by content id without being read, and two lists loaded from the
251+/// same stored tree (`stored_tree`) are the same without either being read.
252+pub fn snapshot_eq(a: &Value, b: &Value) -> Result<bool, Crash> {
253+ if let (Some(x), Some(y)) = (stored_tree(a), stored_tree(b)) {
254+ if x == y {
255+ return Ok(true);
256+ }
257+ }
258+ let (a, b) = (a.forced()?, b.forced()?);
259+ // not lists: no snapshots, left to validation to name (§7.5 step 1)
260+ let (Value::List(xs), Value::List(ys)) = (&a, &b) else {
261+ return value_eq(&a, &b);
262+ };
263+ if xs.len() != ys.len() {
264+ return Ok(false);
265+ }
266+ // the usual case, both in path order: one pass, nothing allocated
267+ let mut k = 0;
268+ while k < xs.len() && value_eq(&xs[k], &ys[k])? {
269+ k += 1;
270+ }
271+ if k == xs.len() {
272+ return Ok(true);
273+ }
274+ // a tree has one listing in path order, the one it is loaded in and
275+ // `replay` returns, so two such listings that differ are different trees
276+ if in_path_order(xs) && in_path_order(ys) {
277+ return Ok(false);
278+ }
279+ // the rest by path; an entry with no readable path, or a path listed
280+ // twice, is not a tree, and compares unequal as it would as a list
281+ let mut rest: BTreeMap<Vec<String>, &Value> = BTreeMap::new();
282+ for x in &xs[k..] {
283+ let Some(p) = entry_path(x) else {
284+ return Ok(false);
285+ };
286+ if rest.insert(p, x).is_some() {
287+ return Ok(false);
288+ }
289+ }
290+ for y in &ys[k..] {
291+ match entry_path(y).and_then(|p| rest.remove(&p)) {
292+ Some(x) if value_eq(x, y)? => {}
293+ _ => return Ok(false),
294+ }
295+ }
296+ Ok(true)
297+}
298+
299+/// A commit's `files` as it holds them: a lazy list is not read.
300+pub fn files_of(commit: &Value) -> Result<Value, Crash> {
301+ if let Value::Record(m) = commit {
302+ if let Some(files) = m.get("files") {
303+ return Ok(files.clone());
304+ }
305+ }
306+ commit.field("files")
307+}
308+
309+/// The name of the stored tree `files` loads, read or not, while they are
310+/// the list the backend built from it (`ThunkVal::tree`); `None` for a list
311+/// the program built, even one with the same entries.
312+pub fn stored_tree(files: &Value) -> Option<&str> {
313+ match files {
314+ Value::Thunk(t) => t.tree(),
315+ _ => None,
316+ }
317+}
318+
319+fn entry_path(entry: &Value) -> Option<Vec<String>> {
320+ let path = entry.field("path").ok()?;
321+ let comps = path.as_list().ok()?;
322+ comps.iter().map(|c| c.as_text().ok().map(str::to_string)).collect()
323+}
324+
325+/// Every entry's path strictly after the one before, component by component
326+/// (`a/b` < `a.txt`); false if a path is unreadable.
327+fn in_path_order(entries: &[Value]) -> bool {
328+ let before = |a: &Value, b: &Value| -> Option<bool> {
329+ let (pa, pb) = (a.field("path").ok()?, b.field("path").ok()?);
330+ let (ca, cb) = (pa.as_list().ok()?, pb.as_list().ok()?);
331+ for (x, y) in ca.iter().zip(cb.iter()) {
332+ match x.as_text().ok()?.cmp(y.as_text().ok()?) {
333+ std::cmp::Ordering::Equal => {}
334+ o => return Some(o.is_lt()),
335+ }
336+ }
337+ Some(ca.len() < cb.len())
338+ };
339+ entries.windows(2).all(|w| before(&w[0], &w[1]) == Some(true))
340+}
341+
342+/// Whether `b` is the repository `a` (§1.2 step 8): equal as values, except
343+/// that each commit's `files` compare as snapshots (`snapshot_eq`), so a
344+/// result that only reorders entries persists nothing. A record `b` shares
345+/// with `a` is equal without being walked.
346+pub fn same_repo(a: &Value, b: &Value) -> Result<bool, Crash> {
347+ match (a, b) {
348+ (Value::Record(x), Value::Record(y)) => {
349+ if std::rc::Rc::ptr_eq(x, y) {
350+ return Ok(true);
351+ }
352+ if x.len() != y.len() {
353+ return Ok(false);
354+ }
355+ for ((k1, v1), (k2, v2)) in x.iter().zip(y.iter()) {
356+ // only a Commit has a `files` field in a Repo value
357+ let same = k1 == k2
358+ && if k1 == "files" {
359+ snapshot_eq(v1, v2)?
360+ } else {
361+ same_repo(v1, v2)?
362+ };
363+ if !same {
364+ return Ok(false);
365+ }
366+ }
367+ Ok(true)
368+ }
369+ (Value::List(xs), Value::List(ys)) => {
370+ if xs.len() != ys.len() {
371+ return Ok(false);
372+ }
373+ for (u, v) in xs.iter().zip(ys.iter()) {
374+ if !same_repo(u, v)? {
375+ return Ok(false);
376+ }
377+ }
378+ Ok(true)
379+ }
380+ _ => value_eq(a, b),
381+ }
382+}
383+
384+// ----------------------------------------------------------------------
385+// validation (§7.5 steps 1–3, the root invariant of 4, and 6), shared by
386+// `validate` and persistence
149387// ----------------------------------------------------------------------
150388
151389#[derive(Debug)]
@@ -171,6 +409,8 @@ pub fn validate_repo(i: &mut Interp, new: &Value) -> Result<Validated, Crash> {
171409 if let Some(old) = &old {
172410 validate_immutable(old, new, &immutable)?;
173411 }
412+ let given = i.given_repo.borrow().clone().or_else(|| old.clone());
413+ validate_path_names(old.as_ref(), given.as_ref(), new, &immutable, i.backend.as_ref())?;
174414 // focus mutable (§7.5 step 6)
175415 let focus_id = id_of(&new.field("root")?)?;
176416 if focus_id == ROOT_ID || immutable.contains(&focus_id) {
@@ -178,9 +418,29 @@ pub fn validate_repo(i: &mut Interp, new: &Value) -> Result<Validated, Crash> {
178418 "the focus must be a mutable commit; compose with new",
179419 ));
180420 }
421+ validate_root(old.as_ref(), new)?;
181422 Ok(Validated { immutable })
182423}
183424
425+/// The root is fixed even when the configured immutable set is empty.
426+/// Compare with the loaded root, preserving synthetic backends' contents
427+/// and the lazy files of a root that has not changed.
428+fn validate_root(old: Option<&Value>, new: &Value) -> Result<(), Crash> {
429+ let root = top_of(new)?.field("root")?;
430+ if id_of(&root)? != ROOT_ID {
431+ return Err(Crash::new("persistence: the root commit must be the top"));
432+ }
433+ if let Some(old) = old {
434+ let stored = top_of(old)?.field("root")?;
435+ if !value_eq(&stored.field("message")?, &root.field("message")?)?
436+ || !same_files(&stored, &root)?
437+ {
438+ return Err(Crash::new("persistence: the root commit cannot be changed"));
439+ }
440+ }
441+ Ok(())
442+}
443+
184444fn check_repo_shape(v: &Value) -> Result<(), Crash> {
185445 let fields = v
186446 .field_set()
@@ -211,8 +471,9 @@ fn validate_ids_and_snapshots(repo: &Value, seen: &mut BTreeSet<String>) -> Resu
211471 validate_tree(&top, seen)
212472}
213473
214−fn validate_tree(loc: &Value, seen: &mut BTreeSet<String>) -> Result<(), Crash> {
215− let root = loc.field("root")?;
474+/// `tree` is the top location or a subtree below it, as in `parent_map_rec`
475+fn validate_tree(tree: &Value, seen: &mut BTreeSet<String>) -> Result<(), Crash> {
476+ let root = tree.field("root")?;
216477 let id = id_of(&root)?;
217478 if !seen.insert(id.clone()) {
218479 return Err(Crash::new(format!(
@@ -220,27 +481,200 @@ fn validate_tree(loc: &Value, seen: &mut BTreeSet<String>) -> Result<(), Crash>
220481 id
221482 )));
222483 }
223− let files = root.field("files")?;
484+ // Persistence reads every description as Text, even when its files
485+ // stay backed by a stored tree. A dry run must reject the same value.
486+ root.field("message")?.as_text()?;
487+ // a list loaded from a stored tree is a snapshot already: jj's tree
488+ // holds each name once, and lists a path as a file or as a directory.
489+ // Reading every commit's to check it made each persist read the files
490+ // of the whole history
491+ let files = files_of(&root)?;
492+ if stored_tree(&files).is_none() {
493+ validate_snapshot(&files.forced()?)?;
494+ }
495+ let children = tree.field("children")?;
496+ for c in children.as_list()?.iter() {
497+ validate_tree(c, seen)?;
498+ }
499+ Ok(())
500+}
501+
502+/// Entries have Blob contents and unique paths, none of them the root `./`
503+/// or both a file and a directory (§7.3, §7.5 step 1).
504+fn validate_snapshot(files: &Value) -> Result<(), Crash> {
224505 let files = files.as_list()?;
225506 let mut paths = BTreeSet::new();
226507 for e in files {
227508 let p = e.field("path")?;
509+ // Inspect the value's kind only: binary and unresolved blobs are
510+ // valid, and a lazy blob need not read its bytes to pass validation.
511+ if !matches!(e.field("content")?, Value::Blob(_)) {
512+ return Err(Crash::new("persistence: snapshot content is not a Blob"));
513+ }
228514 let p = p.as_list()?;
229515 let key: Vec<String> = p
230516 .iter()
231517 .map(|c| c.as_text().map(|s| s.to_string()))
232518 .collect::<Result<_, _>>()?;
519+ if key.is_empty() {
520+ return Err(Crash::new("persistence: a snapshot has an entry at the root path ./"));
521+ }
233522 if !paths.insert(key) {
234523 return Err(Crash::new("persistence: a snapshot has duplicate paths"));
235524 }
236525 }
237− let children = loc.field("children")?;
238− for c in children.as_list()?.iter() {
239− validate_tree(&refocus_child(loc, c)?, seen)?;
526+ if let Some(p) = crate::domain::file_and_directory(&paths) {
527+ return Err(Crash::new(format!(
528+ "persistence: a snapshot has `{}` as both a file and a directory",
529+ p.join("/")
530+ )));
531+ }
532+ Ok(())
533+}
534+
535+/// The name limit of ext4, xfs, btrfs and tmpfs (NAME_MAX), in bytes.
536+/// exFAT, NTFS, HFS+ and APFS count 255 UTF-16 units or characters instead,
537+/// so they hold longer names, and pathconf answers 255 on each: whether a
538+/// longer name fits is asked of the backend (`Backend::name_fits`). A
539+/// filesystem with a lower limit (encfs, eCryptfs) refuses a shorter name
540+/// at checkout, which then records nothing (§7.5 step 7).
541+pub const NAME_MAX: usize = 255;
542+
543+/// Whether a checkout cannot create the path component `comp` (§7.5 step
544+/// 1). jj stores any name that is not empty and has no `/`, but its
545+/// checkout refuses `.`, `..`, `.git` and `.jj`, and the filesystem a name
546+/// it cannot hold, part way through writing the focus; a git tree cannot
547+/// hold a NUL. Where the filesystem folds case, `.GIT` is the file `.git`,
548+/// which the checkout refuses by its file identity or, where that does not
549+/// show it, writes into. `fits` says whether a name longer than `NAME_MAX`
550+/// bytes is one the filesystem holds.
551+pub fn checkout_refuses(comp: &str, folds_case: bool, fits: impl FnOnce(&str) -> bool) -> bool {
552+ matches!(comp, "" | "." | ".." | ".git" | ".jj")
553+ || comp.contains(['/', '\0'])
554+ || (folds_case && (comp.eq_ignore_ascii_case(".git") || comp.eq_ignore_ascii_case(".jj")))
555+ || (comp.len() > NAME_MAX && !fits(comp))
556+}
557+
558+/// Path components (§7.5 step 1) of every commit persisting writes anew or
559+/// checks out must be ones a checkout can create (`checkout_refuses`).
560+///
561+/// A commit that keeps the files it has in `old` is checked only as the
562+/// focus: persisting at most rewrites it with the tree jj already stored and
563+/// never checks it out, and a fetched branch can hold `.jj`, so refusing its
564+/// names would stop every persist for a commit the script did not touch.
565+/// Immutable commits are such commits (step 3). One whose files differ,
566+/// such as a descendant a snapshot of the focus rebases, is checked only at
567+/// the paths it did not hold there: jj stored the others' names already.
568+///
569+/// A name longer than `NAME_MAX` is one the filesystem holds if the
570+/// working directory holds it, as the focus of `given` (its snapshot, §7.4)
571+/// shows: the checkout, which diffs from what is on disk, does not create
572+/// such a file again. Any other is asked of the backend.
573+fn validate_path_names(
574+ old: Option<&Value>,
575+ given: Option<&Value>,
576+ new: &Value,
577+ immutable: &BTreeSet<String>,
578+ backend: &dyn Backend,
579+) -> Result<(), Crash> {
580+ let folds_case = backend.folds_case();
581+ // the long names the working directory holds, read on first need: the
582+ // snapshot's file list is the whole tree
583+ let mut held: Option<BTreeSet<String>> = None;
584+ let focus = id_of(&new.field("root")?)?;
585+ let stored: BTreeMap<String, Value> = match old {
586+ Some(old) => all_commits(old)?
587+ .into_iter()
588+ .map(|c| id_of(&c).map(|id| (id, c)))
589+ .collect::<Result<_, _>>()?,
590+ None => BTreeMap::new(),
591+ };
592+ for c in all_commits(new)? {
593+ let id = id_of(&c)?;
594+ if immutable.contains(&id) {
595+ continue;
596+ }
597+ // the commit as stored, when its files differ: a path it held there
598+ // is not checked again
599+ let mut kept = None;
600+ if id != focus {
601+ if let Some(o) = stored.get(&id) {
602+ if same_files(o, &c)? {
603+ continue;
604+ }
605+ kept = Some(o);
606+ }
607+ }
608+ // the paths `kept` holds, read on first need: most commits hold no
609+ // name a checkout refuses
610+ let mut kept_paths: Option<BTreeSet<Vec<String>>> = None;
611+ let files = c.field("files")?;
612+ for e in files.as_list()? {
613+ let path = e.field("path")?;
614+ for comp in path.as_list()? {
615+ let comp = comp.as_text()?;
616+ if comp.len() > NAME_MAX && held.is_none() {
617+ held = Some(match given {
618+ Some(given) => long_names(&given.field("root")?.field("files")?)?,
619+ None => BTreeSet::new(),
620+ });
621+ }
622+ let on_disk = |c: &str| held.as_ref().is_some_and(|h| h.contains(c));
623+ if !checkout_refuses(comp, folds_case, |c| on_disk(c) || backend.name_fits(c)) {
624+ continue;
625+ }
626+ if let Some(o) = kept {
627+ if kept_paths.is_none() {
628+ let entries = o.field("files")?;
629+ kept_paths = Some(entries.as_list()?.iter().filter_map(entry_path).collect());
630+ }
631+ if let (Some(k), Some(p)) = (&kept_paths, entry_path(e)) {
632+ if k.contains(&p) {
633+ break;
634+ }
635+ }
636+ }
637+ return Err(Crash::new(format!(
638+ "persistence: a snapshot has the path component {:?}, which a checkout cannot create",
639+ comp
640+ )));
641+ }
642+ }
240643 }
241644 Ok(())
242645}
243646
647+/// The path components of snapshot `files` longer than `NAME_MAX` bytes.
648+fn long_names(files: &Value) -> Result<BTreeSet<String>, Crash> {
649+ let mut out = BTreeSet::new();
650+ for e in files.as_list()? {
651+ for comp in e.field("path")?.as_list()? {
652+ let comp = comp.as_text()?;
653+ if comp.len() > NAME_MAX {
654+ out.insert(comp.to_string());
655+ }
656+ }
657+ }
658+ Ok(out)
659+}
660+
661+/// Whether commit `new` holds the files commit `old` does. A commit record,
662+/// or a lazy `files`, the two share, or lazy lists of one stored tree, are
663+/// the same without the tree being read.
664+fn same_files(old: &Value, new: &Value) -> Result<bool, Crash> {
665+ if let (Value::Record(a), Value::Record(b)) = (old, new) {
666+ if std::rc::Rc::ptr_eq(a, b) {
667+ return Ok(true);
668+ }
669+ if let (Some(Value::Thunk(x)), Some(Value::Thunk(y))) = (a.get("files"), b.get("files")) {
670+ if std::rc::Rc::ptr_eq(x, y) {
671+ return Ok(true);
672+ }
673+ }
674+ }
675+ snapshot_eq(&files_of(old)?, &files_of(new)?)
676+}
677+
244678fn label_set(repo: &Value) -> Result<BTreeSet<(String, String)>, Crash> {
245679 let mut out = BTreeSet::new();
246680 for c in all_commits(repo)? {
@@ -266,11 +700,7 @@ fn validate_labels(old: &Value, new: &Value) -> Result<(), Crash> {
266700/// The immutable set: the config's `immutable` revset against `old`, plus
267701/// every merge commit and its ancestors (§7.5 step 3).
268702pub fn compute_immutable(i: &mut Interp, old: &Value) -> Result<BTreeSet<String>, Crash> {
269− let immutable_fn = i
270− .globals
271− .lookup("immutable")
272− .ok_or_else(|| Crash::new("`immutable` is not defined"))?;
273− let v = i.apply(immutable_fn, old.clone())?;
703+ let v = i.apply_cached_revset("immutable", old)?;
274704 let mut set = BTreeSet::new();
275705 for idv in v.as_list()?.iter() {
276706 match idv {
@@ -319,7 +749,7 @@ fn validate_immutable(old: &Value, new: &Value, immutable: &BTreeSet<String>) ->
319749 id
320750 )));
321751 }
322− if !value_eq(&c.field("files")?, &nc.field("files")?)? {
752+ if !snapshot_eq(&files_of(&c)?, &files_of(nc)?)? {
323753 return Err(Crash::new(format!(
324754 "persistence: commit {} is immutable (files changed)",
325755 id
src/setup.rsnew file+119
@@ -0,0 +1,119 @@
1+//! Explicit first-run configuration, independent of repository discovery.
2+
3+use j::ast::Item;
4+use j::config;
5+use j::eval::Interp;
6+use j::lex::{lex, Tok};
7+use j::parse::parse_config;
8+use j::show::show;
9+use j::value::Value;
10+use std::io::Write;
11+use std::path::Path;
12+use std::rc::Rc;
13+
14+const IDENTITY_EXAMPLE: &str = "user = { name = \"Ada Lovelace\", email = \"ada@example.org\" }";
15+
16+/// `args` contains only the arguments after `setup`. Existing files, including
17+/// invalid configs and symlinks, are never replaced. A new file is published
18+/// atomically so concurrent setup commands cannot overwrite one another.
19+pub fn run(path: &Path, default: &str, args: &[&str]) -> Result<String, (u8, String)> {
20+ if !matches!(args.len(), 0 | 2) {
21+ return Err((2, "usage: j setup [NAME EMAIL]; for example: j setup 'Ada Lovelace' ada@example.org; see `j help setup`".into()));
22+ }
23+ if path.symlink_metadata().is_ok() {
24+ return existing(path, args);
25+ }
26+ let source = match args {
27+ [name, email] => with_identity(default, name, email)?,
28+ [] => default.to_string(),
29+ _ => unreachable!(),
30+ };
31+ let parent = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or_else(|| Path::new("."));
32+ std::fs::create_dir_all(parent)
33+ .map_err(|error| (3, format!("cannot create config directory {}: {}", parent.display(), error)))?;
34+ let mut temporary = tempfile::NamedTempFile::new_in(parent)
35+ .map_err(|error| (3, format!("cannot prepare config at {}: {}", path.display(), error)))?;
36+ temporary.write_all(source.as_bytes())
37+ .map_err(|error| (3, format!("cannot write config at {}: {}", path.display(), error)))?;
38+ match temporary.persist_noclobber(path) {
39+ Ok(_) => {}
40+ Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => return existing(path, args),
41+ Err(error) => return Err((3, format!("cannot create config at {}: {}", path.display(), error.error))),
42+ }
43+ if args.is_empty() {
44+ Ok(format!(
45+ "Created the complete default config at {}.\n\nEdit the existing `user` definition in this file, replacing both example values with your identity:\n {}\nKeep the rest of the file: it defines your commands.\n\nThen run `j init` in your project directory, or `j clone URL`.\n",
46+ path.display(), IDENTITY_EXAMPLE,
47+ ))
48+ } else {
49+ Ok(format!(
50+ "Configuration ready at {}.\nIdentity: {}\n\nNext: run `j init` in your project directory, or `j clone URL`.\n",
51+ path.display(), identity_line(&source)?,
52+ ))
53+ }
54+}
55+
56+fn existing(path: &Path, args: &[&str]) -> Result<String, (u8, String)> {
57+ if !args.is_empty() {
58+ return Err((2, format!(
59+ "config already exists at {}; it was not changed. Edit its existing `user` definition to change your identity; see `j help config`.",
60+ path.display(),
61+ )));
62+ }
63+ let problem = match std::fs::read_to_string(path) {
64+ Ok(source) => match identity_line(&source) {
65+ Ok(identity) => return Ok(format!(
66+ "Configuration already exists at {} and is ready.\nIdentity: {}\n\nNext: run `j init` in your project directory, or `j clone URL`.\n",
67+ path.display(), identity,
68+ )),
69+ Err((_, message)) => message,
70+ },
71+ Err(error) => format!("cannot read this config: {}", error),
72+ };
73+ Ok(format!(
74+ "Configuration already exists at {}; it was not changed.\nNeeds attention: {}\n\nTo set your identity, edit the existing `user` definition:\n {}\nKeep the rest of the file and repair the problem above.\nSee `j help config`; then retry `j setup`.\n",
75+ path.display(), problem, IDENTITY_EXAMPLE,
76+ ))
77+}
78+
79+fn identity_line(source: &str) -> Result<String, (u8, String)> {
80+ let cfg = config::load_config(source).map_err(|error| (3, error.message()))?;
81+ j::jj::configured_identity(&cfg)
82+}
83+
84+fn with_identity(default: &str, name: &str, email: &str) -> Result<String, (u8, String)> {
85+ let items = parse_config(default, Rc::new(config::reserved_set()))
86+ .map_err(|error| (3, format!("cannot prepare default config: {}", error.msg)))?;
87+ let line = items.iter().find_map(|item| match item {
88+ Item::Definition(name, _, line) if name == "user" => Some(*line),
89+ _ => None,
90+ }).ok_or_else(|| (3, "default config has no `user` definition".into()))?;
91+ let tokens = lex(default).map_err(|error| (3, error.msg))?;
92+ let start = tokens.iter().position(|token| token.line == line && token.col == 1)
93+ .ok_or_else(|| (3, "cannot locate the default `user` definition".into()))?;
94+ let mut depth = 0usize;
95+ let mut end = tokens[start].end;
96+ for token in &tokens[start..] {
97+ if matches!(token.tok, Tok::Eof)
98+ || (token.line > line && token.col == 1 && depth == 0 && !matches!(token.tok, Tok::Newline))
99+ {
100+ break;
101+ }
102+ match token.tok {
103+ Tok::LParen | Tok::LBracket | Tok::LBrace => depth += 1,
104+ Tok::RParen | Tok::RBracket | Tok::RBrace => depth = depth.saturating_sub(1),
105+ Tok::Newline => continue,
106+ _ => {}
107+ }
108+ end = token.end;
109+ }
110+ let interp = Interp::dummy();
111+ let name = show(&interp, &Value::text(name)).map_err(|error| (3, error.msg))?;
112+ let email = show(&interp, &Value::text(email)).map_err(|error| (3, error.msg))?;
113+ let definition = format!("user = {{ name = {}, email = {} }}", name, email);
114+ let mut source = default.to_string();
115+ source.replace_range(tokens[start].start..end, &definition);
116+ // Reuse the same identity requirements as init/clone before writing a file.
117+ identity_line(&source)?;
118+ Ok(source)
119+}
src/shape.rs+104−60
@@ -2,25 +2,38 @@
22
33use crate::ast::TypeExpr;
44use crate::value::{PrimKind, ShapeKind, ShapeVal, Value};
5−use std::collections::{BTreeSet, HashMap};
5+use std::collections::{BTreeSet, HashMap, HashSet};
66
77#[derive(Default, Clone)]
88pub struct Shapes {
99 /// name -> its declared type
1010 pub decls: HashMap<String, TypeExpr>,
11+ /// Memo for `shape_of`. A contract check against a record type asks for
12+ /// the shape on every call, and computing it builds the field set afresh;
13+ /// the declarations never change once the config is loaded.
14+ memo: std::cell::RefCell<HashMap<String, Option<ShapeVal>>>,
1115}
1216
1317impl Shapes {
1418 pub fn new() -> Shapes {
15− Shapes {
16− decls: HashMap::new(),
17− }
19+ Shapes::default()
1820 }
1921
2022 /// Compute the ShapeVal for a type name used as a value (§4.12).
2123 /// Returns None if the name is undeclared, or its shape is a function,
2224 /// a list, or otherwise not usable.
2325 pub fn shape_of(&self, name: &str) -> Option<ShapeVal> {
26+ if let Some(hit) = self.memo.borrow().get(name) {
27+ return hit.clone();
28+ }
29+ let computed = self.shape_of_uncached(name);
30+ self.memo
31+ .borrow_mut()
32+ .insert(name.to_string(), computed.clone());
33+ computed
34+ }
35+
36+ fn shape_of_uncached(&self, name: &str) -> Option<ShapeVal> {
2437 match name {
2538 "Int" => Some(ShapeVal {
2639 name: name.into(),
@@ -54,18 +67,13 @@ impl Shapes {
5467 }
5568
5669 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− }
70+ // an alias takes its type's shape (§4.12); `unfold` is bounded, so a
71+ // cycle of aliases (`Self = Self`) ends on a name like any other
72+ match self.unfold(ty) {
73+ TypeExpr::Con(n) if is_primitive(n) => self.shape_of(n).map(|s| s.kind),
74+ // undeclared, a type variable, or an alias cycle: not a usable
75+ // shape, and a crash when used (§4.12)
76+ TypeExpr::Con(_) => None,
6977 TypeExpr::Record(fields) => Some(ShapeKind::Record(
7078 fields.iter().map(|(n, _)| n.clone()).collect(),
7179 )),
@@ -73,21 +81,25 @@ impl Shapes {
7381 }
7482 }
7583
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(),
84+ /// Unfold an alias chain by reference. Contracts are checked at every
85+ /// call, so cloning the type each time — a `Vec` of `String`s for a record
86+ /// type — was a per-application allocation.
87+ pub fn unfold<'a>(&'a self, ty: &'a TypeExpr) -> &'a TypeExpr {
88+ let mut cur = ty;
89+ // a declaration cycle would otherwise spin here; the bound is far
90+ // above any real alias chain
91+ for _ in 0..64 {
92+ match cur {
93+ TypeExpr::Con(n) if !is_primitive(n) && !is_type_var(n) => {
94+ match self.decls.get(n) {
95+ Some(decl) => cur = decl,
96+ None => return cur,
97+ }
8798 }
99+ _ => return cur,
88100 }
89− _ => ty.clone(),
90101 }
102+ cur
91103 }
92104}
93105
@@ -100,24 +112,30 @@ fn is_type_var(n: &str) -> bool {
100112}
101113
102114/// A compiled signature: a chain of parameter types and a final result type.
115+/// The types are shared, so handing one out is a refcount bump rather than a
116+/// deep copy — contracts are consulted on every call.
103117#[derive(Clone, Debug)]
104118pub struct Contract {
105− pub params: Vec<TypeExpr>,
106− pub result: TypeExpr,
119+ pub params: Vec<Rc2<TypeExpr>>,
120+ pub result: Rc2<TypeExpr>,
107121}
108122
109123pub fn compile_contract(shapes: &Shapes, ty: &TypeExpr) -> Contract {
110124 let mut params = Vec::new();
111125 let mut cur = ty;
126+ // an alias met again on this walk refers to itself (`Stream = Int ->
127+ // Stream`, or `A = B` with `B = A`) and would unfold forever; the walk
128+ // stops there, and the result is checked as that alias
129+ let mut unfolded: HashSet<&str> = HashSet::new();
112130 loop {
113131 match cur {
114132 TypeExpr::Fun(a, b) => {
115− params.push((**a).clone());
133+ params.push(a.clone());
116134 cur = b;
117135 }
118136 TypeExpr::Con(n) => {
119137 // alias of a function type is unfolded (§4.13)
120− if !is_primitive(n) && !is_type_var(n) {
138+ if !is_primitive(n) && !is_type_var(n) && unfolded.insert(n.as_str()) {
121139 if let Some(decl) = shapes.decls.get(n) {
122140 cur = decl;
123141 continue;
@@ -130,14 +148,13 @@ pub fn compile_contract(shapes: &Shapes, ty: &TypeExpr) -> Contract {
130148 }
131149 Contract {
132150 params,
133− result: cur.clone(),
151+ result: Rc2::new(cur.clone()),
134152 }
135153}
136154
137155/// Check one level deep (§4.13). On failure, describe expected and got.
138156pub fn check(shapes: &Shapes, ty: &TypeExpr, v: &Value) -> Result<(), String> {
139− let owned = shapes.unfold_owned(ty);
140− let ty = &owned;
157+ let ty = shapes.unfold(ty);
141158 match ty {
142159 TypeExpr::Con(n) => {
143160 if is_type_var(n) {
@@ -242,7 +259,10 @@ use std::rc::Rc as Rc2;
242259pub enum ContractExpr {
243260 /// no contract information; arg checks pass through
244261 Unknown,
245− Known(Rc2<Contract>),
262+ /// a signature with its first `at` parameters already supplied; advancing
263+ /// it is an index bump, where rebuilding the shortened parameter list
264+ /// copied every remaining type on every application
265+ Known { contract: Rc2<Contract>, at: usize },
246266 /// compose(f, g): (f . g); both contract expressions
247267 Compose(Box<ContractExpr>, Box<ContractExpr>),
248268 /// apply e to an argument that checked against e's first parameter
@@ -251,14 +271,14 @@ pub enum ContractExpr {
251271
252272impl ContractExpr {
253273 /// The type of the next parameter this function expects, if known.
254− pub fn next_param(&self) -> Option<TypeExpr> {
274+ pub fn next_param(&self) -> Option<Rc2<TypeExpr>> {
255275 match self {
256276 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()),
277+ ContractExpr::Known { contract, at } => contract.params.get(*at).cloned(),
278+ // (f . g) x = f (g x) (§4.9): the argument is g's, even when
279+ // g's contract is unknown or used up, and f's own contract
280+ // checks what g returns when it is applied to it
281+ ContractExpr::Compose(_, g) => g.next_param(),
262282 ContractExpr::ApplyFirst(e) => {
263283 let advanced = ContractExpr::apply_first(e.clone());
264284 advanced.next_param()
@@ -268,12 +288,12 @@ impl ContractExpr {
268288 }
269289
270290 /// The result type once all parameters are supplied.
271− pub fn result_after(&self) -> Option<TypeExpr> {
291+ pub fn result_after(&self) -> Option<Rc2<TypeExpr>> {
272292 match self {
273293 ContractExpr::Unknown => None,
274− ContractExpr::Known(c) => {
275− if c.params.is_empty() {
276− Some(c.result.clone())
294+ ContractExpr::Known { contract, at } => {
295+ if *at >= contract.params.len() {
296+ Some(contract.result.clone())
277297 } else {
278298 None
279299 }
@@ -293,7 +313,7 @@ impl ContractExpr {
293313 pub fn is_exhausted(&self) -> bool {
294314 match self {
295315 ContractExpr::Unknown => false,
296− ContractExpr::Known(c) => c.params.is_empty(),
316+ ContractExpr::Known { contract, at } => *at >= contract.params.len(),
297317 ContractExpr::Compose(_, g) => g.is_exhausted(),
298318 ContractExpr::ApplyFirst(_) => false,
299319 }
@@ -301,19 +321,24 @@ impl ContractExpr {
301321
302322 /// Advance after one argument has been supplied and checked.
303323 pub fn apply_first_rc(self: Rc2<ContractExpr>) -> ContractExpr {
304− ContractExpr::apply_first(Box::new((*self).clone()))
324+ // the common case advances without allocating anything
325+ match &*self {
326+ ContractExpr::Unknown => ContractExpr::Unknown,
327+ ContractExpr::Known { contract, at } => ContractExpr::Known {
328+ contract: contract.clone(),
329+ at: contract.params.len().min(at + 1),
330+ },
331+ _ => ContractExpr::apply_first(Box::new((*self).clone())),
332+ }
305333 }
306334
307335 /// Advance after one argument has been supplied and checked.
308336 pub fn apply_first(self: Box<ContractExpr>) -> ContractExpr {
309337 match *self {
310338 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))
339+ ContractExpr::Known { contract, at } => {
340+ let at = contract.params.len().min(at + 1);
341+ ContractExpr::Known { contract, at }
317342 }
318343 ContractExpr::Compose(f, g) => {
319344 if !g.is_exhausted() {
@@ -330,16 +355,35 @@ impl ContractExpr {
330355 /// Number of remaining parameters, if known and not nested.
331356 pub fn params_len(&self) -> usize {
332357 match self {
333− ContractExpr::Known(c) => c.params.len(),
358+ ContractExpr::Known { contract, at } => contract.params.len() - at,
334359 _ => 99,
335360 }
336361 }
337362
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,
363+ /// The 0-based position of the next argument among those the signature
364+ /// lists, when known. Messages number arguments by the signature (§4.13:
365+ /// "describe expected Text as argument 1"), not by what the function
366+ /// value already holds: `tree`, which is `treeWith` applied to its
367+ /// options, takes its argument 1, and so does `squash`, a composition.
368+ pub fn position(&self) -> Option<usize> {
369+ match self {
370+ ContractExpr::Known { at, .. } => Some(*at),
371+ ContractExpr::Compose(_, g) => g.position(),
372+ _ => None,
373+ }
374+ }
375+
376+ /// True when both are the same signature at the same position, so that
377+ /// checking against one after the other repeats every check: the same
378+ /// definition's contract, advanced as far. Only a known signature is
379+ /// compared; any other is never the same.
380+ pub fn same_as(&self, other: &ContractExpr) -> bool {
381+ match (self, other) {
382+ (
383+ ContractExpr::Known { contract: a, at: i },
384+ ContractExpr::Known { contract: b, at: j },
385+ ) => Rc2::ptr_eq(a, b) && i == j,
386+ _ => false,
343387 }
344388 }
345389
src/show.rs+362−137
@@ -1,103 +1,185 @@
11//! `show` (§5.2), unified diff (§4.9 `diff`), and the difftastic bridge (§7.10).
22
33use crate::eval::Interp;
4−use crate::value::{BlobContent, Crash, FunVal, Value};
4+use crate::value::{BlobContent, BlobKind, BlobVal, Crash, FunVal, ThunkVal, Value};
5+use num_bigint::BigInt;
6+use unicode_width::UnicodeWidthChar;
57
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;
8+/// Crashes where `v` holds a lazy part the store cannot read (§7.2), as any
9+/// other use of it does: a stand-in for the part would be the literal of
10+/// some other value, or would not parse.
11+///
12+/// Every level of `v` appends to the one string: a level that built its own
13+/// and copied its child's rendering into it cost the rendering's size times
14+/// its depth, and a Repo whose focus has a long line of descendants nests
15+/// them hundreds of levels deep, in megabytes of indentation. The renderers
16+/// recurse natively, once per level, so they also keep their frames small:
17+/// plain loops, a call's result returned as theirs without a `?` where it can
18+/// be, and the arms that end the recursion or seldom carry it on (a leaf, a
19+/// lazy part, a function) out of line.
20+pub fn show(interp: &Interp, v: &Value) -> Result<String, Crash> {
21+ let mut s = String::new();
22+ render(interp, v, &mut s)?;
23+ // line breaking: one line if it fits in 80 columns, counted in display
24+ // cells as everywhere else (§5.1), not code points. Not by
25+ // `render::width`, which skips colour codes: `show` writes none, and an
26+ // ESC from the data would hide everything up to the next `m`
27+ let w: usize = s.chars().map(|c| UnicodeWidthChar::width(c).unwrap_or(0)).sum();
28+ if w <= 80 {
29+ return Ok(s);
1130 }
12− render_wide(interp, v, 0)
31+ s.clear();
32+ render_wide(interp, v, 0, &mut s)?;
33+ Ok(s)
1334}
1435
15−fn render(interp: &Interp, v: &Value) -> String {
36+fn render(interp: &Interp, v: &Value, out: &mut String) -> Result<(), Crash> {
1637 match v {
17− Value::Thunk(t) => match t.force() {
18− Ok(v) => render(interp, &v),
19− Err(_) => "<lazy>".to_string(),
20− },
21− Value::Int(n) => {
22− if n.sign() == num_bigint::Sign::Minus {
23− format!("(0 - {})", n.magnitude())
24− } else {
25− n.to_string()
26− }
38+ Value::Thunk(t) => return render_forced(interp, t, None, out),
39+ Value::Int(n) => render_int(n, out),
40+ Value::Text(t) => push_text_literal(t, out),
41+ Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
42+ Value::Id(id) => {
43+ out.push('@');
44+ out.push_str(id);
2745 }
28− Value::Text(t) => text_literal(t),
29− Value::Bool(b) => b.to_string(),
30− Value::Id(id) => format!("@{}", id),
3146 Value::List(xs) => {
3247 // list elements must be atoms: parenthesise applications and
3348 // operator expressions (§3.4)
34− let parts: Vec<String> = xs.iter().map(|x| render_atom(interp, x)).collect();
35− format!("[{}]", parts.join(" "))
49+ out.push('[');
50+ for (i, x) in xs.iter().enumerate() {
51+ if i > 0 {
52+ out.push(' ');
53+ }
54+ render_atom(interp, x, out)?;
55+ }
56+ out.push(']');
3657 }
3758 Value::Record(m) => {
38− let parts: Vec<String> = m
39− .iter()
40− .map(|(k, x)| format!("{} = {}", k, render(interp, x)))
41− .collect();
42− format!("{{ {} }}", parts.join(", "))
43− }
44− Value::Blob(b) => match &b.content {
45− BlobContent::Resolved(bytes) => match String::from_utf8(bytes.as_ref().clone()) {
46− Ok(s) => format!("blob {}", text_literal(&s)),
47− Err(_) => "blob \"<binary>\"".into(),
48− },
49− BlobContent::Lazy(_) | BlobContent::Conflict(_) => {
50− let s = b
51− .bytes()
52− .map(|v| String::from_utf8_lossy(&v).to_string())
53− .unwrap_or_else(|_| "<unreadable>".into());
54− let marker = if b.is_unresolved() { "{- unresolved -} " } else { "" };
55− format!("{}blob {}", marker, text_literal(&s))
59+ out.push_str("{ ");
60+ for (i, (k, x)) in m.iter().enumerate() {
61+ if i > 0 {
62+ out.push_str(", ");
63+ }
64+ out.push_str(k);
65+ out.push_str(" = ");
66+ render(interp, x, out)?;
5667 }
57− },
58− Value::Shape(s) => s.name.clone(),
59− Value::Fun(f) => render_fun(interp, f),
68+ out.push_str(" }");
69+ }
70+ Value::Blob(b) => return render_blob(interp, b, out),
71+ Value::Shape(s) => out.push_str(&s.name),
72+ Value::Fun(f) => return render_fun(interp, f, out),
73+ }
74+ Ok(())
75+}
76+
77+#[inline(never)]
78+fn render_int(n: &BigInt, out: &mut String) {
79+ if n.sign() == num_bigint::Sign::Minus {
80+ out.push_str(&format!("(0 - {})", n.magnitude()));
81+ } else {
82+ out.push_str(&n.to_string());
83+ }
84+}
85+
86+#[inline(never)]
87+fn render_blob(interp: &Interp, b: &BlobVal, out: &mut String) -> Result<(), Crash> {
88+ // Unresolved blobs render as marker text, explicitly outside the
89+ // round-trip guarantee. Resolved blobs must preserve bytes and kind.
90+ if b.is_unresolved() {
91+ let bytes = b.bytes()?;
92+ out.push_str("{- unresolved -} blob ");
93+ push_text_literal(&String::from_utf8_lossy(&bytes), out);
94+ return Ok(());
95+ }
96+ let bytes = match &b.content {
97+ BlobContent::Resolved(bytes) => bytes.clone(),
98+ BlobContent::Lazy(lazy) => lazy.force()?,
99+ BlobContent::Conflict(_) => unreachable!("handled above"),
100+ };
101+ if b.kind == BlobKind::Regular {
102+ if let Ok(text) = std::str::from_utf8(&bytes) {
103+ out.push_str("blob ");
104+ push_text_literal(text, out);
105+ return Ok(());
106+ }
107+ }
108+ // Existing user configs are never upgraded automatically. Do not emit
109+ // an expression they cannot read back when they lack the new builtin.
110+ if interp.globals.lookup("blobHex").is_none() {
111+ return Err(Crash::new(
112+ "show: binary, executable, and symlink blobs require `blobHex : Text -> Text -> Blob` in config.j",
113+ ));
60114 }
115+ let kind = match b.kind {
116+ BlobKind::Regular => "regular",
117+ BlobKind::Executable => "executable",
118+ BlobKind::Symlink => "symlink",
119+ };
120+ out.push_str("blobHex ");
121+ push_text_literal(kind, out);
122+ out.push(' ');
123+ push_text_literal(&hex::encode(bytes.as_ref()), out);
124+ Ok(())
61125}
62126
63−fn render_fun(interp: &Interp, f: &FunVal) -> String {
127+/// A lazy part (a commit's `files`) renders as the value it forces to: on
128+/// one line, or broken as that value would be at the indentation `wide`
129+/// gives.
130+#[inline(never)]
131+fn render_forced(interp: &Interp, t: &ThunkVal, wide: Option<usize>, out: &mut String) -> Result<(), Crash> {
132+ let v = t.force()?;
133+ match wide {
134+ None => render(interp, &v, out),
135+ Some(indent) => render_wide(interp, &v, indent, out),
136+ }
137+}
138+
139+#[inline(never)]
140+fn render_fun(interp: &Interp, f: &FunVal, out: &mut String) -> Result<(), Crash> {
64141 match f {
142+ // a signed definition whose value is a builtin's partial application
143+ // or a composition (`tree = treeWith { … }`) renders as its name,
144+ // followed only by the arguments it is given (§5.2), not those baked
145+ // into its value: all it was given since it was named, which its
146+ // signature stops counting at its end (`k3 ((+)) 0`, `k3 = foldl`)
147+ FunVal::Builtin {
148+ name,
149+ args,
150+ pending: Some((cname, _, named_at)),
151+ ..
152+ } if interp.builtin_def(name, cname).is_some() => {
153+ out.push_str(cname);
154+ render_args(interp, &args[(*named_at).min(args.len())..], out)
155+ }
65156 FunVal::Builtin { name, args, .. } => {
66− // operator builtins render parenthesised when bare
67− if args.is_empty() {
68− if is_operator_name(name) {
69− format!("({})", name)
70− } else if name.starts_with('.') && name.len() > 1 {
71− name.clone() // selector .name
72− } else if name == "(.)" {
73− "(.)".into()
74− } else {
75− name.clone()
76− }
157+ // a selector `.name` carries its field as a baked-in first
158+ // argument (builtins::make_selector) and renders as the selector
159+ // atom itself (§3.4), not as an application to that field
160+ if name.starts_with('.') && name.len() > 1 {
161+ out.push_str(name);
162+ return Ok(());
163+ }
164+ // operator builtins render parenthesised; `(.)` is its own name
165+ if is_operator_name(name) {
166+ out.push('(');
167+ out.push_str(name);
168+ out.push(')');
77169 } else {
78− // partial application: name followed by applied args
79− let base = if is_operator_name(name) {
80− format!("({})", name)
81− } else if name == "(.)" {
82− "(.)".into()
83− } else {
84− name.clone()
85− };
86− // args may contain a baked-in value (compose, selectors)
87− match name.as_str() {
88− "(.)" if args.len() == 2 => {
89− format!(
90− "(.) ({}) ({})",
91− render_atom(interp, &args[0]),
92− render_atom(interp, &args[1])
93− )
94− }
95− _ => {
96− let shown: Vec<String> =
97− args.iter().map(|a| render_atom(interp, a)).collect();
98− format!("{} {}", base, shown.join(" "))
99− }
170+ out.push_str(name);
171+ }
172+ // a partial application: the name followed by the applied args,
173+ // which may contain a baked-in value (compose)
174+ if name == "(.)" && args.len() == 2 {
175+ for a in args {
176+ out.push_str(" (");
177+ render_atom(interp, a, out)?;
178+ out.push(')');
100179 }
180+ Ok(())
181+ } else {
182+ render_args(interp, args, out)
101183 }
102184 }
103185 FunVal::Closure {
@@ -105,46 +187,67 @@ fn render_fun(interp: &Interp, f: &FunVal) -> String {
105187 applied_args,
106188 ..
107189 } => {
108− if applied_args.is_empty() {
109− n.clone()
110− } else {
111− // partial application of a named definition: the name followed
112− // by the arguments supplied so far (§5.2)
113− let args: Vec<String> =
114− applied_args.iter().map(|a| render_atom(interp, a)).collect();
115− format!("{} {}", n, args.join(" "))
116− }
190+ // a partial application of a named definition: the name followed
191+ // by the arguments supplied so far (§5.2)
192+ out.push_str(n);
193+ render_args(interp, applied_args, out)
117194 }
118195 FunVal::Closure {
119196 name: None,
120− applied,
197+ applied_args,
121198 params,
122199 body,
123200 src,
124201 ..
125202 } => {
126− if *applied == 0 {
127− if src.is_empty() || src == "section" {
128− crate::parse::render_lambda(params, body)
129− } else {
130− src.clone()
131− }
203+ // a lambda or section renders as its source, exactly as written
204+ // (§5.2); only an AST built without source is re-rendered. A
205+ // partial application parenthesises it, as it is not atomic, and
206+ // follows it with the arguments supplied so far (§5.2)
207+ let applied = !applied_args.is_empty();
208+ if applied {
209+ out.push('(');
210+ }
211+ if src.as_str().is_empty() {
212+ out.push_str(&crate::parse::render_lambda(params, body));
132213 } else {
133− let base = if src.is_empty() || src == "section" {
134− crate::parse::render_lambda(params, body)
135− } else {
136− src.clone()
137− };
138− format!("({}) <applied>", base)
214+ out.push_str(src.as_str());
139215 }
216+ if applied {
217+ out.push(')');
218+ }
219+ render_args(interp, applied_args, out)
220+ }
221+ FunVal::OrFun(a, b) => render_infix(interp, a, " or ", b, out),
222+ FunVal::Labelled(n, _) => {
223+ out.push('%');
224+ out.push_str(n);
225+ Ok(())
140226 }
141− FunVal::OrFun(a, b) => format!("({} or {})", render_atom(interp, a), render_atom(interp, b)),
142− FunVal::Labelled(n, _) => format!("%{}", n),
143− FunVal::ComposeLazy(f, g) => format!("({} . {})", render_atom(interp, f), render_atom(interp, g)),
227+ FunVal::ComposeLazy(f, g) => render_infix(interp, f, " . ", g, out),
228+ }
229+}
230+
231+/// `(a op b)`, for the functions an `or` or a composition builds
232+fn render_infix(interp: &Interp, a: &Value, op: &str, b: &Value, out: &mut String) -> Result<(), Crash> {
233+ out.push('(');
234+ render_atom(interp, a, out)?;
235+ out.push_str(op);
236+ render_atom(interp, b, out)?;
237+ out.push(')');
238+ Ok(())
239+}
240+
241+/// The arguments of a partial application, each after a space.
242+fn render_args(interp: &Interp, args: &[Value], out: &mut String) -> Result<(), Crash> {
243+ for a in args {
244+ out.push(' ');
245+ render_atom(interp, a, out)?;
144246 }
247+ Ok(())
145248}
146249
147−fn render_atom(interp: &Interp, v: &Value) -> String {
250+fn render_atom(interp: &Interp, v: &Value, out: &mut String) -> Result<(), Crash> {
148251 // parenthesise if not atomic; a nested list is self-delimiting (a `[`
149252 // cannot begin a postfix), but a record is not: `{ … }` following another
150253 // element would parse as a record *update* on it (§3.4), so records keep
@@ -152,12 +255,29 @@ fn render_atom(interp: &Interp, v: &Value) -> String {
152255 let atomic = matches!(
153256 v,
154257 Value::Int(_) | Value::Text(_) | Value::Bool(_) | Value::Id(_) | Value::Shape(_) | Value::List(_)
155− );
156− let s = render(interp, v);
157− if atomic {
158− s
159− } else {
160− format!("({})", s)
258+ ) || is_section(v);
259+ if !atomic {
260+ out.push('(');
261+ }
262+ render(interp, v, out)?;
263+ if !atomic {
264+ out.push(')');
265+ }
266+ Ok(())
267+}
268+
269+/// A section renders as its source, `(op e)` or `(e op)` (§5.2), which is
270+/// already an atom (§3.4) and takes no parentheses of its own. A lambda's
271+/// source begins with its `\` instead, and a section, taking one argument,
272+/// is never partially applied.
273+fn is_section(v: &Value) -> bool {
274+ match v {
275+ Value::Fun(f) => matches!(
276+ f.as_ref(),
277+ FunVal::Closure { name: None, applied_args, src, .. }
278+ if applied_args.is_empty() && src.as_str().starts_with('(')
279+ ),
280+ _ => false,
161281 }
162282}
163283
@@ -181,7 +301,13 @@ fn is_operator_name(n: &str) -> bool {
181301}
182302
183303pub fn text_literal(s: &str) -> String {
184− let mut out = String::from("\"");
304+ let mut out = String::new();
305+ push_text_literal(s, &mut out);
306+ out
307+}
308+
309+fn push_text_literal(s: &str, out: &mut String) {
310+ out.push('"');
185311 for c in s.chars() {
186312 match c {
187313 '"' => out.push_str("\\\""),
@@ -193,21 +319,22 @@ pub fn text_literal(s: &str) -> String {
193319 }
194320 }
195321 out.push('"');
196− out
197322}
198323
199−fn render_wide(interp: &Interp, v: &Value, indent: usize) -> String {
324+fn render_wide(interp: &Interp, v: &Value, indent: usize, out: &mut String) -> Result<(), Crash> {
200325 match v {
326+ // see through a lazy `files` list as `render` does, so that it breaks
327+ // like the equal forced list instead of staying on one line
328+ Value::Thunk(t) => return render_forced(interp, t, Some(indent), out),
201329 Value::List(xs) if !xs.is_empty() => {
202− let mut out = String::from("[");
330+ out.push('[');
203331 for (i, x) in xs.iter().enumerate() {
204332 if i > 0 {
205333 out.push('\n');
206334 out.push_str(&" ".repeat(indent + 2));
207335 }
208336 // list elements must be atoms (§3.4)
209− let s = render_wide(interp, x, indent + 2);
210− if matches!(
337+ let atomic = matches!(
211338 x,
212339 Value::Int(_)
213340 | Value::Text(_)
@@ -216,30 +343,34 @@ fn render_wide(interp: &Interp, v: &Value, indent: usize) -> String {
216343 | Value::Shape(_)
217344 | Value::List(_)
218345 | Value::Record(_)
219− ) {
220− out.push_str(&s);
221− } else {
222− out.push_str(&format!("({})", s));
346+ ) || is_section(x);
347+ if !atomic {
348+ out.push('(');
349+ }
350+ render_wide(interp, x, indent + 2, out)?;
351+ if !atomic {
352+ out.push(')');
223353 }
224354 }
225355 out.push(']');
226− out
227356 }
228357 Value::Record(m) if !m.is_empty() => {
229− let mut out = String::from("{ ");
358+ out.push_str("{ ");
230359 for (i, (k, x)) in m.iter().enumerate() {
231360 if i > 0 {
232361 out.push('\n');
233362 out.push_str(&" ".repeat(indent));
234363 out.push_str(", ");
235364 }
236− out.push_str(&format!("{} = {}", k, render_wide(interp, x, indent + 2)));
365+ out.push_str(k);
366+ out.push_str(" = ");
367+ render_wide(interp, x, indent + 2, out)?;
237368 }
238369 out.push_str(" }");
239− out
240370 }
241− _ => render(interp, v),
371+ _ => return render(interp, v, out),
242372 }
373+ Ok(())
243374}
244375
245376// ----------------------------------------------------------------------
@@ -247,10 +378,38 @@ fn render_wide(interp: &Interp, v: &Value, indent: usize) -> String {
247378// ----------------------------------------------------------------------
248379
249380pub fn unified_diff(a: &str, b: &str) -> String {
250− let diff = similar::TextDiff::from_lines(a, b);
381+ // lines end after each `\n` and nowhere else, as for GNU diff and git:
382+ // `similar`'s own line splitting also ends one at a lone `\r`, so a last
383+ // line `b\r` counted as terminated and got no marker below
384+ let old: Vec<&str> = a.split_inclusive('\n').collect();
385+ let new: Vec<&str> = b.split_inclusive('\n').collect();
386+ let diff = similar::TextDiff::configure().diff_slices(&old, &new);
251387 let mut out = String::new();
252388 for hunk in diff.unified_diff().context_radius(3).iter_hunks() {
253− out.push_str(&format!("{}\n", hunk.header()));
389+ // each side's range is the lines of it the hunk shows. `hunk.header()`
390+ // took a side's start from the hunk's first op and its end from the
391+ // last, but `similar`'s compaction moves ops past one another without
392+ // updating the index each keeps on the side it does not touch, so the
393+ // counts could disagree with the body ("@@ -1 +2,2 @@" over two old
394+ // lines and three new), which `patch` and `git apply` refuse. A
395+ // change's index on its own side is right, and each side's lines
396+ // come in order.
397+ let (mut old_first, mut old_len, mut new_first, mut new_len) = (None, 0, None, 0);
398+ for change in hunk.iter_changes() {
399+ if let Some(i) = change.old_index() {
400+ old_first.get_or_insert(i);
401+ old_len += 1;
402+ }
403+ if let Some(i) = change.new_index() {
404+ new_first.get_or_insert(i);
405+ new_len += 1;
406+ }
407+ }
408+ out.push_str(&format!(
409+ "@@ -{} +{} @@\n",
410+ hunk_range(old_first, old_len),
411+ hunk_range(new_first, new_len)
412+ ));
254413 for change in hunk.iter_changes() {
255414 let sign = match change.tag() {
256415 similar::ChangeTag::Delete => "-",
@@ -258,19 +417,33 @@ pub fn unified_diff(a: &str, b: &str) -> String {
258417 similar::ChangeTag::Equal => " ",
259418 };
260419 out.push_str(sign);
261− let text = change.to_string();
262− // similar yields the line with its terminator when present; the
263− // last line of a file without a trailing newline has none, so add
264− // one to keep the diff line-oriented
265− out.push_str(&text);
420+ let text: &str = change.value();
421+ out.push_str(text);
422+ // a line with no terminator, only ever the last, is ended here
423+ // and marked, as GNU diff and git do: otherwise dropping the
424+ // final newline shows as the same line removed and re-added,
425+ // alike in both directions
266426 if !text.ends_with('\n') {
267427 out.push('\n');
428+ out.push_str("\\ No newline at end of file\n");
268429 }
269430 }
270431 }
271432 out
272433}
273434
435+/// One side's range in a hunk header as `diff -u` writes it, from the index
436+/// of the side's first line in the hunk and how many it has: `s` for one
437+/// line, `s,n` otherwise. With context around every change a hunk shows no
438+/// line of a side only when that side has none, the empty range `0,0`.
439+fn hunk_range(first: Option<usize>, len: usize) -> String {
440+ match first {
441+ Some(i) if len == 1 => format!("{}", i + 1),
442+ Some(i) => format!("{},{}", i + 1, len),
443+ None => "0,0".to_string(),
444+ }
445+}
446+
274447// ----------------------------------------------------------------------
275448// difftastic (§7.10)
276449// ----------------------------------------------------------------------
@@ -282,6 +455,13 @@ pub fn difft(path: &Value, a: &Value, b: &Value) -> Result<Value, Crash> {
282455 .map(|c| c.as_text().map(|s| s.to_string()))
283456 .transpose()?
284457 .unwrap_or_else(|| "file".to_string());
458+ // A Path value can be built directly in the language. Only a basename
459+ // may be joined below the temporary directories: an absolute path or
460+ // a component containing separators must not escape them.
461+ let name = std::path::Path::new(&last);
462+ if name.file_name() != Some(name.as_os_str()) {
463+ return Err(Crash::new("difft: the last path component must be a filename"));
464+ }
285465 let a_bytes = match a {
286466 Value::Blob(bl) => bl.bytes()?,
287467 v => return Err(Crash::new(format!("difft: expected a Blob, got a {}", v.kind_name()))),
@@ -292,13 +472,22 @@ pub fn difft(path: &Value, a: &Value, b: &Value) -> Result<Value, Crash> {
292472 };
293473 let dir = tempfile::tempdir()
294474 .map_err(|e| Crash::new(format!("difft: cannot create temp dir: {}", e)))?;
295− let old = dir.path().join(format!("old-{}", last));
296− let new = dir.path().join(format!("new-{}", last));
475+ // Keep the filename unchanged: adding a prefix can exceed NAME_MAX for
476+ // a valid tracked path, and some languages are detected by exact name.
477+ let old_dir = dir.path().join("old");
478+ let new_dir = dir.path().join("new");
479+ std::fs::create_dir(&old_dir).map_err(|e| Crash::new(format!("difft: {}", e)))?;
480+ std::fs::create_dir(&new_dir).map_err(|e| Crash::new(format!("difft: {}", e)))?;
481+ let old = old_dir.join(name);
482+ let new = new_dir.join(name);
297483 std::fs::write(&old, &a_bytes).map_err(|e| Crash::new(format!("difft: {}", e)))?;
298484 std::fs::write(&new, &b_bytes).map_err(|e| Crash::new(format!("difft: {}", e)))?;
299485 let mut cmd = std::process::Command::new("difft");
300486 cmd.arg(&old).arg(&new);
301487 cmd.stdin(std::process::Stdio::null());
488+ // stdout is the rendering; what difft says on stderr is j's, so one
489+ // that fails explains itself rather than leaving a blank page (§7.10)
490+ cmd.stderr(std::process::Stdio::inherit());
302491 if std::env::var_os("DFT_COLOR").is_none() && stdout_is_tty() {
303492 cmd.env("DFT_COLOR", "always");
304493 }
@@ -307,12 +496,48 @@ pub fn difft(path: &Value, a: &Value, b: &Value) -> Result<Value, Crash> {
307496 cmd.env("DFT_WIDTH", w.to_string());
308497 }
309498 }
310− let out = cmd
311− .output()
312− .map_err(|_| Crash::new("difft: cannot execute `difft` (not on PATH)"))?;
499+ crate::terminal::external_stderr();
500+ let out = match cmd.output() {
501+ Ok(out) => out,
502+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
503+ return fallback_diff(comps, &a_bytes, &b_bytes);
504+ }
505+ Err(error) => return Err(Crash::new(format!("difft: cannot execute `difft`: {}", error))),
506+ };
313507 Ok(Value::text(String::from_utf8_lossy(&out.stdout).to_string()))
314508}
315509
510+/// Keep review usable without an optional renderer. Include the full path so
511+/// concatenating several fallback diffs never loses which file each belongs to.
512+fn fallback_diff(path: &[Value], a: &[u8], b: &[u8]) -> Result<Value, Crash> {
513+ if a == b {
514+ return Ok(Value::text(""));
515+ }
516+ let path = if path.is_empty() {
517+ "file".to_string()
518+ } else {
519+ path.iter().map(|part| part.as_text().map(|s| s.to_string()))
520+ .collect::<Result<Vec<_>, _>>()?.join("/")
521+ };
522+ let label = |prefix: &str| {
523+ let label = format!("{prefix}/{path}");
524+ if label.chars().any(|c| c.is_whitespace() || c.is_control() || matches!(c, '"' | '\\')) {
525+ format!("{label:?}")
526+ } else {
527+ label
528+ }
529+ };
530+ let old = label("a");
531+ let new = label("b");
532+ let body = match (std::str::from_utf8(a), std::str::from_utf8(b)) {
533+ (Ok(a), Ok(b)) if !a.contains('\0') && !b.contains('\0') => {
534+ format!("--- {old}\n+++ {new}\n{}", unified_diff(a, b))
535+ }
536+ _ => format!("Binary files {old} and {new} differ\n"),
537+ };
538+ Ok(Value::text(body))
539+}
540+
316541pub fn stdout_is_tty() -> bool {
317542 unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 }
318543}
src/terminal.rsnew file+276
@@ -0,0 +1,276 @@
1+//! Interactive presentation. Rendered values stay free of cursor controls;
2+//! motion belongs only to the CLI's final output and temporary status line.
3+
4+use std::fmt;
5+use std::io::{self, IsTerminal, Write};
6+use std::sync::atomic::{AtomicBool, Ordering};
7+use std::sync::mpsc::{self, Sender};
8+use std::sync::{Arc, Mutex, Weak};
9+use std::thread::{self, JoinHandle};
10+use std::time::{Duration, Instant};
11+
12+use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
13+
14+static ACTIVE_PROGRESS: Mutex<Option<Weak<ProgressWorker>>> = Mutex::new(None);
15+static EXTERNAL_STDERR: AtomicBool = AtomicBool::new(false);
16+
17+fn stop_progress() {
18+ let worker = ACTIVE_PROGRESS.lock().unwrap().as_ref().and_then(Weak::upgrade);
19+ if let Some(worker) = worker {
20+ worker.finish();
21+ }
22+}
23+
24+/// A subprocess may write an unterminated diagnostic. Clear our status before
25+/// it starts, and leave stderr alone for the remainder of this run.
26+pub fn external_stderr() {
27+ EXTERNAL_STDERR.store(true, Ordering::Relaxed);
28+ stop_progress();
29+}
30+
31+fn nonempty_env(name: &str) -> bool {
32+ std::env::var_os(name).is_some_and(|value| !value.is_empty())
33+}
34+
35+fn capable_terminal() -> bool {
36+ !nonempty_env("NO_COLOR") && std::env::var("TERM").as_deref() != Ok("dumb")
37+}
38+
39+fn interactive() -> bool {
40+ io::stdout().is_terminal()
41+ && io::stderr().is_terminal()
42+ && !nonempty_env("CI")
43+}
44+
45+fn motion_enabled() -> bool {
46+ interactive()
47+ && capable_terminal()
48+ && !matches!(
49+ std::env::var("J_ANIMATIONS")
50+ .unwrap_or_default()
51+ .to_ascii_lowercase()
52+ .as_str(),
53+ "never" | "off" | "false" | "0"
54+ )
55+}
56+
57+/// Write a diagnostic without turning a closed stderr into a panic (§1.4).
58+pub fn report(args: fmt::Arguments<'_>) {
59+ report_with_color(args, "1;96");
60+}
61+
62+pub fn report_error(args: fmt::Arguments<'_>) {
63+ report_with_color(args, "1;91");
64+}
65+
66+fn report_with_color(args: fmt::Arguments<'_>, code: &str) {
67+ stop_progress();
68+ let mut out = io::stderr().lock();
69+ if out.is_terminal() && capable_terminal() {
70+ let message = args.to_string();
71+ if let Some(rest) = message.strip_prefix("j: ") {
72+ let _ = writeln!(out, "\x1b[{code}mj:\x1b[0m {rest}");
73+ } else {
74+ let _ = writeln!(out, "\x1b[2m{message}\x1b[0m");
75+ }
76+ } else {
77+ let _ = writeln!(out, "{args}");
78+ }
79+}
80+
81+/// Short confirmation for commands that otherwise have no terminal output.
82+/// Pipes and unattended runs keep their existing, quiet behavior.
83+pub fn success(message: &str) {
84+ stop_progress();
85+ if interactive() {
86+ let mut out = io::stderr().lock();
87+ if capable_terminal() {
88+ let _ = writeln!(out, "\x1b[1;92m✓\x1b[0m \x1b[1;96mj:\x1b[0m {message}");
89+ } else {
90+ let _ = writeln!(out, "j: {message}");
91+ }
92+ }
93+}
94+
95+/// A delayed spinner, activity bar, and elapsed time. The bar is deliberately
96+/// indeterminate: elapsed time cannot tell us how much work remains.
97+/// Stopping wakes the worker immediately, so short operations have no extra
98+/// delay. Never hide or move the cursor
99+/// between rows: even an abrupt signal leaves the terminal usable.
100+struct Progress {
101+ worker: Option<Arc<ProgressWorker>>,
102+}
103+
104+struct ProgressWorker {
105+ stop: Sender<()>,
106+ thread: Mutex<Option<JoinHandle<()>>>,
107+}
108+
109+impl ProgressWorker {
110+ fn finish(&self) {
111+ let _ = self.stop.send(());
112+ if let Some(worker) = self.thread.lock().unwrap().take() {
113+ let _ = worker.join();
114+ }
115+ }
116+}
117+
118+impl Progress {
119+ fn start(label: &'static str) -> Self {
120+ if !motion_enabled() || EXTERNAL_STDERR.load(Ordering::Relaxed) {
121+ return Self { worker: None };
122+ }
123+ stop_progress();
124+ let (send, receive) = mpsc::channel();
125+ let started = Instant::now();
126+ let worker = thread::Builder::new().name("j-progress".into()).spawn(move || {
127+ if receive.recv_timeout(Duration::from_millis(140)) != Err(mpsc::RecvTimeoutError::Timeout) {
128+ return;
129+ }
130+ let colors = ["95", "96", "93", "92", "94"];
131+ let mut frame = 0;
132+ loop {
133+ // Fit each new frame to the current terminal width, including
134+ // after a resize. Leave one column spare at the edge.
135+ let width = stderr_width().saturating_sub(1);
136+ let line = progress_line(label, frame, started.elapsed(), width);
137+ {
138+ let mut out = io::stderr().lock();
139+ if write!(
140+ out,
141+ "\r\x1b[2K\x1b[1;{}m{}\x1b[0m",
142+ colors[frame % colors.len()],
143+ line
144+ )
145+ .and_then(|()| out.flush())
146+ .is_err()
147+ {
148+ break;
149+ }
150+ }
151+ frame += 1;
152+ if receive.recv_timeout(Duration::from_millis(75)) != Err(mpsc::RecvTimeoutError::Timeout) {
153+ break;
154+ }
155+ }
156+ let mut out = io::stderr().lock();
157+ let _ = write!(out, "\r\x1b[2K").and_then(|()| out.flush());
158+ });
159+ let worker = worker.ok().map(|worker| Arc::new(ProgressWorker {
160+ stop: send,
161+ thread: Mutex::new(Some(worker)),
162+ }));
163+ *ACTIVE_PROGRESS.lock().unwrap() = worker.as_ref().map(Arc::downgrade);
164+ Self { worker }
165+ }
166+}
167+
168+impl Drop for Progress {
169+ fn drop(&mut self) {
170+ if let Some(worker) = self.worker.take() {
171+ worker.finish();
172+ }
173+ }
174+}
175+
176+/// Finish the temporary line before the caller reports success or failure.
177+pub fn with_progress<T>(label: &'static str, operation: impl FnOnce() -> T) -> T {
178+ let _progress = Progress::start(label);
179+ operation()
180+}
181+
182+fn progress_line(label: &str, frame: usize, elapsed: Duration, width: usize) -> String {
183+ const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
184+ let mut line = format!("{} j · {}", FRAMES[frame % FRAMES.len()], label);
185+ let seconds = elapsed.as_secs();
186+ let time = if seconds < 60 {
187+ format!("{}.{}s", seconds, elapsed.subsec_millis() / 100)
188+ } else {
189+ format!("{}m {:02}s", seconds / 60, seconds % 60)
190+ };
191+ // Bounce a three-cell pulse across a ten-cell track. It never fills up
192+ // or suggests a completion percentage for work with no known total.
193+ let step = frame % 14;
194+ let start = step.min(14 - step);
195+ let bar: String = (0..10)
196+ .map(|cell| if (start..start + 3).contains(&cell) { '━' } else { '·' })
197+ .collect();
198+ let details = format!(" [{bar}] {time}");
199+ if line.width() + details.width() <= width {
200+ line.push_str(&details);
201+ } else if line.width() + 1 + time.width() <= width {
202+ // On small terminals, keep the operation's name ahead of decoration.
203+ line.push(' ');
204+ line.push_str(&time);
205+ }
206+ let mut used = 0;
207+ line.chars()
208+ .take_while(|c| {
209+ used += c.width().unwrap_or(0);
210+ used <= width
211+ })
212+ .collect()
213+}
214+
215+fn stderr_width() -> usize {
216+ // The two streams can be attached to different terminals. Measure the
217+ // one that receives the progress line, rather than the tree's stdout.
218+ unsafe {
219+ let mut size: libc::winsize = std::mem::zeroed();
220+ if libc::ioctl(libc::STDERR_FILENO, libc::TIOCGWINSZ, &mut size) == 0
221+ && size.ws_col > 0
222+ {
223+ size.ws_col as usize
224+ } else {
225+ 80
226+ }
227+ }
228+}
229+
230+/// Reveal colored, multiline terminal output in a few quick batches. Never
231+/// split a line (and therefore an ANSI style), alter its bytes, or animate
232+/// plain text. The total pacing is at most 96 ms, even for very large trees.
233+pub fn write_output(out: &mut impl Write, text: &str) -> io::Result<()> {
234+ if !motion_enabled() || !text.contains("\x1b[") {
235+ return out.write_all(text.as_bytes()).and_then(|()| out.flush());
236+ }
237+ let lines = text.bytes().filter(|&byte| byte == b'\n').count()
238+ + usize::from(!text.ends_with('\n'));
239+ let batch = lines.div_ceil(7).max(1);
240+ let mut start = 0;
241+ let mut end = 0;
242+ for (index, line) in text.split_inclusive('\n').enumerate() {
243+ end += line.len();
244+ if (index + 1) % batch == 0 && index + 1 < lines {
245+ out.write_all(text[start..end].as_bytes())?;
246+ out.flush()?;
247+ start = end;
248+ thread::sleep(Duration::from_millis(16));
249+ }
250+ }
251+ out.write_all(text[start..].as_bytes())?;
252+ out.flush()
253+}
254+
255+#[cfg(test)]
256+mod tests {
257+ use super::*;
258+
259+ #[test]
260+ fn progress_fits_terminal_cells_and_keeps_the_operation_visible() {
261+ let label = "Reading 仓库";
262+ for width in 0..80 {
263+ for frame in 0..20 {
264+ let line = progress_line(label, frame, Duration::from_millis(1234), width);
265+ assert!(line.width() <= width, "{width} columns: {line:?}");
266+ if width >= 18 {
267+ assert!(line.contains(label), "operation name lost: {line:?}");
268+ }
269+ }
270+ }
271+ let line = progress_line("Reading repository", 0, Duration::from_millis(1234), 80);
272+ assert!(line.contains("[━━━·······] 1.2s"), "{line:?}");
273+ assert!(progress_line("Reading repository", 0, Duration::from_secs(65), 80)
274+ .ends_with("1m 05s"));
275+ }
276+}
src/value.rs+700−89
@@ -3,7 +3,7 @@
33use crate::ast::{Expr, Pattern};
44use num_bigint::BigInt;
55use std::collections::{BTreeMap, BTreeSet, HashMap};
6−use std::rc::Rc;
6+use std::rc::{Rc, Weak};
77
88pub type RecordMap = BTreeMap<String, Value>;
99
@@ -12,7 +12,7 @@ pub enum Value {
1212 Int(BigInt),
1313 Text(Rc<String>),
1414 Bool(bool),
15− List(Rc<Vec<Value>>),
15+ List(ListVal),
1616 Record(Rc<RecordMap>),
1717 Fun(Rc<FunVal>),
1818 Id(Rc<String>),
@@ -24,20 +24,89 @@ pub enum Value {
2424 Thunk(Rc<ThunkVal>),
2525}
2626
27+/// A list: shared elements plus the offset of its first one, so dropping a
28+/// prefix (`tail`, `drop`) shares the storage instead of copying it. The
29+/// zipper walks — `up` is `tail repo.context`, and `top`/`ancestors` apply it
30+/// once per level — made a copying `tail` quadratic in the depth of history.
31+/// A tail keeps the elements before it alive; `j` is a single short run, so
32+/// holding them costs nothing.
33+#[derive(Clone)]
34+pub struct ListVal {
35+ items: Rc<Vec<Value>>,
36+ start: usize,
37+}
38+
39+impl ListVal {
40+ pub fn new(items: Vec<Value>) -> ListVal {
41+ ListVal {
42+ items: Rc::new(items),
43+ start: 0,
44+ }
45+ }
46+ /// the list without its first `n` elements, sharing the storage
47+ pub fn skip(&self, n: usize) -> ListVal {
48+ ListVal {
49+ items: self.items.clone(),
50+ start: self.items.len().min(self.start.saturating_add(n)),
51+ }
52+ }
53+ pub fn as_slice(&self) -> &[Value] {
54+ &self.items[self.start..]
55+ }
56+ /// which list this is, while it is held: two lists of one identity
57+ /// share their elements from the same first one, so are the same list
58+ /// without either being compared
59+ pub fn identity(&self) -> (*const Vec<Value>, usize) {
60+ (Rc::as_ptr(&self.items), self.start)
61+ }
62+
63+ /// Observe the shared storage without keeping a cached list alive.
64+ pub(crate) fn weak_storage(&self) -> std::rc::Weak<Vec<Value>> {
65+ Rc::downgrade(&self.items)
66+ }
67+}
68+
69+impl std::ops::Deref for ListVal {
70+ type Target = [Value];
71+ fn deref(&self) -> &[Value] {
72+ self.as_slice()
73+ }
74+}
75+
2776pub struct ThunkVal {
2877 state: std::cell::RefCell<ThunkState>,
78+ /// for a commit's `files` as the backend built them: the change id of the
79+ /// stored commit whose tree this loads. An edit that changes the files
80+ /// replaces the thunk, so a commit still holding the one tagged with its
81+ /// own id holds exactly the stored tree — the only case in which the
82+ /// backend's answers about that tree describe the value (§7.2).
83+ origin: Option<String>,
84+ /// for a commit's `files` as the backend built them: a name for the
85+ /// stored tree this loads. Two lists loaded under the same name are
86+ /// equal, and a list the backend loads from a tree is a snapshot (§7.3),
87+ /// so persisting compares and validates a commit the program left as
88+ /// loaded without reading its files (§7.5).
89+ tree: Option<String>,
2990}
3091
3192enum ThunkState {
3293 Pending(Box<dyn FnOnce() -> Result<Value, Crash>>),
94+ /// the computation has been taken out of `Pending` and is running
95+ Forcing,
3396 Ready(Value),
97+ /// the computation crashed (a store read failed). Every later force
98+ /// crashes the same way (§7.2): a crash caught by `or`, or swallowed by
99+ /// rendering, must not leave some other value behind.
100+ Failed(Crash),
34101}
35102
36103impl std::fmt::Debug for ThunkVal {
37104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38105 match &*self.state.borrow() {
39106 ThunkState::Pending(_) => write!(f, "Thunk(pending)"),
107+ ThunkState::Forcing => write!(f, "Thunk(forcing)"),
40108 ThunkState::Ready(v) => write!(f, "Thunk({:?})", v),
109+ ThunkState::Failed(c) => write!(f, "Thunk(failed: {})", c.msg),
41110 }
42111 }
43112}
@@ -46,27 +115,64 @@ impl ThunkVal {
46115 pub fn new(f: impl FnOnce() -> Result<Value, Crash> + 'static) -> Self {
47116 ThunkVal {
48117 state: std::cell::RefCell::new(ThunkState::Pending(Box::new(f))),
118+ origin: None,
119+ tree: None,
120+ }
121+ }
122+
123+ /// the lazy `files` of the stored commit with change id `origin`, which
124+ /// load the stored tree named `tree`, if the backend names its trees
125+ pub fn stored(
126+ origin: String,
127+ tree: Option<String>,
128+ f: impl FnOnce() -> Result<Value, Crash> + 'static,
129+ ) -> Self {
130+ ThunkVal {
131+ origin: Some(origin),
132+ tree,
133+ ..ThunkVal::new(f)
134+ }
135+ }
136+
137+ /// the change id of the stored commit whose tree this loads, if any
138+ pub fn origin(&self) -> Option<&str> {
139+ self.origin.as_deref()
140+ }
141+
142+ /// the name of the stored tree this loads, if the backend gave one
143+ pub fn tree(&self) -> Option<&str> {
144+ self.tree.as_deref()
145+ }
146+
147+ /// the value, where it has been computed; nothing is computed here
148+ pub fn peek(&self) -> Option<Value> {
149+ match &*self.state.borrow() {
150+ ThunkState::Ready(v) => Some(v.clone()),
151+ _ => None,
49152 }
50153 }
51154
52− /// the value, computing it on first call and memoizing
155+ /// the value, computing it on first call and memoizing the outcome, a
156+ /// crash included
53157 pub fn force(&self) -> Result<Value, Crash> {
54− if let ThunkState::Ready(v) = &*self.state.borrow() {
55− return Ok(v.clone());
56− }
57− let thunk = {
58− let mut st = self.state.borrow_mut();
59− match std::mem::replace(&mut *st, ThunkState::Ready(Value::Bool(false))) {
60− ThunkState::Pending(t) => t,
61− ThunkState::Ready(v) => {
62− *st = ThunkState::Ready(v.clone());
63− return Ok(v);
64− }
158+ match &*self.state.borrow() {
159+ ThunkState::Ready(v) => return Ok(v.clone()),
160+ ThunkState::Failed(c) => return Err(c.clone()),
161+ ThunkState::Forcing => {
162+ return Err(Crash::new("internal: a lazy value was forced re-entrantly"))
65163 }
164+ ThunkState::Pending(_) => {}
165+ }
166+ let thunk = match std::mem::replace(&mut *self.state.borrow_mut(), ThunkState::Forcing) {
167+ ThunkState::Pending(t) => t,
168+ _ => unreachable!("the state was Pending just above"),
169+ };
170+ let out = thunk();
171+ *self.state.borrow_mut() = match &out {
172+ Ok(v) => ThunkState::Ready(v.clone()),
173+ Err(c) => ThunkState::Failed(c.clone()),
66174 };
67− let v = thunk()?;
68− *self.state.borrow_mut() = ThunkState::Ready(v.clone());
69− Ok(v)
175+ out
70176 }
71177}
72178
@@ -97,8 +203,31 @@ pub enum BlobContent {
97203 Lazy(Rc<LazyBlob>),
98204 /// Conflict sides, jj order: alternating adds and removes, starting and
99205 /// ending with an add: [add, (remove, add)*]. For the in-memory backend:
100− /// [to, from, onto]-style three sides as [add, remove, add].
101− Conflict(Vec<Rc<Vec<u8>>>),
206+ /// [to, from, onto]-style three sides as [add, remove, add]. `None` is a
207+ /// side on which the path is absent (a deletion conflict, §7.3).
208+ Conflict(Vec<Option<ConflictSide>>),
209+}
210+
211+/// One present side of a conflict: its content and its own file type, which
212+/// jj keeps per side (§7.3)
213+#[derive(Clone, Debug, PartialEq, Eq)]
214+pub struct ConflictSide {
215+ pub kind: BlobKind,
216+ pub bytes: Rc<Vec<u8>>,
217+ /// a side on which jj holds a directory, in a conflict between a file
218+ /// and a directory: the hex id of that tree, kept opaque so the side is
219+ /// written back as the directory it was. Its `bytes` are empty (§7.3).
220+ pub tree: Option<String>,
221+}
222+
223+impl ConflictSide {
224+ pub fn regular(bytes: &[u8]) -> ConflictSide {
225+ ConflictSide {
226+ kind: BlobKind::Regular,
227+ bytes: Rc::new(bytes.to_vec()),
228+ tree: None,
229+ }
230+ }
102231}
103232
104233/// A blob whose bytes are read from the store only on first use (§7.2):
@@ -111,7 +240,12 @@ pub struct LazyBlob {
111240
112241enum LazyState {
113242 Pending(Box<dyn FnOnce() -> Result<Rc<Vec<u8>>, Crash>>),
243+ /// the read has been taken out of `Pending` and is running
244+ Forcing,
114245 Ready(Rc<Vec<u8>>),
246+ /// the read failed: every later force fails the same way, so the blob
247+ /// never reads as empty (§7.2), whoever caught or swallowed the crash
248+ Failed(Crash),
115249}
116250
117251impl std::fmt::Debug for LazyBlob {
@@ -138,25 +272,28 @@ impl LazyBlob {
138272 }
139273 }
140274
141− /// the bytes, reading from the store on first call and memoizing
275+ /// the bytes, reading from the store on first call and memoizing the
276+ /// outcome, a failed read included
142277 pub fn force(&self) -> Result<Rc<Vec<u8>>, Crash> {
143278 // fast path: already forced
144− if let LazyState::Ready(b) = &*self.state.borrow() {
145− return Ok(b.clone());
146− }
147− let thunk = {
148− let mut st = self.state.borrow_mut();
149− match std::mem::replace(&mut *st, LazyState::Ready(Rc::new(Vec::new()))) {
150− LazyState::Pending(t) => t,
151− LazyState::Ready(b) => {
152− *st = LazyState::Ready(b.clone());
153− return Ok(b);
154− }
279+ match &*self.state.borrow() {
280+ LazyState::Ready(b) => return Ok(b.clone()),
281+ LazyState::Failed(c) => return Err(c.clone()),
282+ LazyState::Forcing => {
283+ return Err(Crash::new("internal: a lazy blob was read re-entrantly"))
155284 }
285+ LazyState::Pending(_) => {}
286+ }
287+ let thunk = match std::mem::replace(&mut *self.state.borrow_mut(), LazyState::Forcing) {
288+ LazyState::Pending(t) => t,
289+ _ => unreachable!("the state was Pending just above"),
156290 };
157− let bytes = thunk()?;
158− *self.state.borrow_mut() = LazyState::Ready(bytes.clone());
159− Ok(bytes)
291+ let out = thunk();
292+ *self.state.borrow_mut() = match &out {
293+ Ok(b) => LazyState::Ready(b.clone()),
294+ Err(c) => LazyState::Failed(c.clone()),
295+ };
296+ out
160297 }
161298}
162299
@@ -182,7 +319,7 @@ impl BlobVal {
182319 match &self.content {
183320 BlobContent::Resolved(b) => b.len(),
184321 BlobContent::Lazy(l) => l.force().map(|b| b.len()).unwrap_or(0),
185− BlobContent::Conflict(sides) => sides.iter().map(|s| s.len()).sum(),
322+ BlobContent::Conflict(sides) => sides.iter().flatten().map(|s| s.bytes.len()).sum(),
186323 }
187324 }
188325 /// content as bytes, forcing a lazy blob; conflicts render with jj-style
@@ -194,6 +331,28 @@ impl BlobVal {
194331 BlobContent::Conflict(sides) => Ok(render_conflict(sides)),
195332 }
196333 }
334+ /// number of lines, counted over the bytes in place. Equivalent to
335+ /// `String::from_utf8_lossy(&self.bytes()?).lines().count()` — a `\n`
336+ /// byte is always a `\n` character in UTF-8, and lossy replacement never
337+ /// introduces one — but without copying the content out.
338+ pub fn line_count(&self) -> usize {
339+ fn count(b: &[u8]) -> usize {
340+ let nl = bytecount(b);
341+ if b.is_empty() || b.ends_with(b"\n") {
342+ nl
343+ } else {
344+ nl + 1
345+ }
346+ }
347+ fn bytecount(b: &[u8]) -> usize {
348+ b.iter().filter(|c| **c == b'\n').count()
349+ }
350+ match &self.content {
351+ BlobContent::Resolved(b) => count(b),
352+ BlobContent::Lazy(l) => l.force().map(|b| count(&b)).unwrap_or(1),
353+ BlobContent::Conflict(sides) => count(&render_conflict(sides)),
354+ }
355+ }
197356 /// the content hash of a lazy blob, if it is one (used to persist an
198357 /// unchanged blob without reading its bytes)
199358 pub fn lazy_id(&self) -> Option<&str> {
@@ -204,25 +363,22 @@ impl BlobVal {
204363 }
205364}
206365
207−/// Render a conflict with jj-style markers.
208−pub fn render_conflict(sides: &[Rc<Vec<u8>>]) -> Vec<u8> {
366+/// Render a conflict with jj-style markers; a side the path is absent on
367+/// renders as empty content, as jj materialises it.
368+pub fn render_conflict(sides: &[Option<ConflictSide>]) -> Vec<u8> {
209369 // sides: [add0, remove0, add1, remove1, ... addN]
210370 let mut out = Vec::new();
211371 out.extend_from_slice(b"<<<<<<<\n");
212372 let mut i = 0;
213− let mut first_add = true;
214373 while i < sides.len() {
374+ let bytes: &[u8] = sides[i].as_ref().map_or(&[], |s| &s.bytes);
215375 if i % 2 == 0 {
216− if !first_add {
217− // another add after removes: separate
218− }
219376 out.extend_from_slice(b"+++++++\n");
220− out.extend_from_slice(&sides[i]);
377+ out.extend_from_slice(bytes);
221378 ensure_newline(&mut out);
222− first_add = false;
223379 } else {
224380 out.extend_from_slice(b"%%%%%%%\n");
225− out.extend_from_slice(&sides[i]);
381+ out.extend_from_slice(bytes);
226382 ensure_newline(&mut out);
227383 }
228384 i += 1;
@@ -262,9 +418,6 @@ impl PrimKind {
262418pub enum ShapeKind {
263419 Record(BTreeSet<String>),
264420 Prim(PrimKind),
265− /// an alias that could not be resolved to a record shape yet (function,
266− /// list, or a typedecl processed later); resolved lazily at check time
267− Aliased(String),
268421}
269422
270423#[derive(Clone, Debug)]
@@ -287,7 +440,11 @@ pub enum FunVal {
287440 arity: usize,
288441 args: Vec<Value>,
289442 f: BuiltinFn,
290− pending: Option<(String, Rc<crate::shape::ContractExpr>)>,
443+ /// the name this function is under, its contract as further
444+ /// arguments arrive, and how many of `args` it held when it was
445+ /// given that name, which are its value's, not the arguments a
446+ /// signed definition was given (§5.2)
447+ pending: Option<(String, Rc<crate::shape::ContractExpr>, usize)>,
291448 },
292449 Closure {
293450 name: Option<String>, // top-level definition name, if any
@@ -295,25 +452,94 @@ pub enum FunVal {
295452 applied: usize,
296453 /// the arguments supplied so far, for rendering (§5.2)
297454 applied_args: Vec<Value>,
298− /// the body has not been evaluated yet (the definition's contract is
299− /// not exhausted): the next application evaluates it, then applies
300− deferred: bool,
301455 body: Rc<Expr>,
302456 env: Env,
303− src: String,
457+ /// the lambda's source, which it renders as (§5.2)
458+ src: crate::ast::Source,
304459 /// contract of this function as further arguments arrive
305460 pending: Option<(String, Rc<crate::shape::ContractExpr>)>,
306461 },
307462 /// `f or g` lifted pointwise over functions (§4.6)
308463 OrFun(Value, Value),
309− /// unevaluated composition: unfolds only when applied to a non-function
310− /// (§4.1 note — `abandon . contract everything` is a value before it is
311− /// applied, and must not run at load)
464+ /// a composition `f . g` applied as `f (g x)` (§4.9); `(.)` itself
465+ /// builds a builtin node instead (builtins::compose_values)
312466 ComposeLazy(Value, Value),
313467 /// a label literal %name: applies as `labelled "name"`
314468 Labelled(String, Value),
315469}
316470
471+/// What a function holds that may hold further functions: a closure's
472+/// environment and arguments, a builtin's arguments, the operands of an
473+/// `or` or a composition, a label's `labelled`. It is only ever dropped.
474+#[allow(dead_code)]
475+enum Held {
476+ Closure(Env, Vec<Value>),
477+ Args(Vec<Value>),
478+ Two(Value, Value),
479+ One(Value),
480+}
481+
482+/// How many function drops run inside one another before the next one's
483+/// contents are put off to the outermost.
484+const INLINE_FUN_DROPS: usize = 64;
485+
486+struct FunDrops {
487+ depth: std::cell::Cell<usize>,
488+ put_off: std::cell::RefCell<Vec<Held>>,
489+}
490+
491+thread_local! {
492+ static FUN_DROPS: FunDrops = const {
493+ FunDrops {
494+ depth: std::cell::Cell::new(0),
495+ put_off: std::cell::RefCell::new(Vec::new()),
496+ }
497+ };
498+}
499+
500+/// A function is dropped with what it holds, which may be a function holding
501+/// a function, and so on: a closure over a closure, a composition of
502+/// compositions, a named wrapper around another (named_apply). Dropped the
503+/// usual way, such a chain recursed once per link on the native stack, and a
504+/// chain a recursion had built a million links long aborted the process
505+/// (§4.1). Past a small depth, what a function holds is put off, and the
506+/// outermost function drop drops it, so the stack a drop takes is bounded.
507+impl Drop for FunVal {
508+ fn drop(&mut self) {
509+ let held = match self {
510+ FunVal::Closure {
511+ env, applied_args, ..
512+ } => Held::Closure(std::mem::replace(env, Env::empty()), std::mem::take(applied_args)),
513+ FunVal::Builtin { args, .. } => Held::Args(std::mem::take(args)),
514+ FunVal::OrFun(a, b) | FunVal::ComposeLazy(a, b) => Held::Two(
515+ std::mem::replace(a, Value::Bool(false)),
516+ std::mem::replace(b, Value::Bool(false)),
517+ ),
518+ FunVal::Labelled(_, v) => Held::One(std::mem::replace(v, Value::Bool(false))),
519+ };
520+ // once the thread's locals are gone, `held` simply drops here
521+ let _ = FUN_DROPS.try_with(move |d| {
522+ let depth = d.depth.get();
523+ if depth >= INLINE_FUN_DROPS {
524+ d.put_off.borrow_mut().push(held);
525+ return;
526+ }
527+ d.depth.set(depth + 1);
528+ drop(held);
529+ if depth == 0 {
530+ loop {
531+ let next = d.put_off.borrow_mut().pop();
532+ match next {
533+ Some(h) => drop(h),
534+ None => break,
535+ }
536+ }
537+ }
538+ d.depth.set(depth);
539+ });
540+ }
541+}
542+
317543impl Value {
318544 pub fn kind_name(&self) -> &'static str {
319545 match self {
@@ -364,7 +590,7 @@ impl Value {
364590
365591 pub fn as_list(&self) -> Result<&[Value], Crash> {
366592 match self {
367− Value::List(xs) => Ok(xs),
593+ Value::List(xs) => Ok(xs.as_slice()),
368594 _ => Err(Crash::new(format!("expected a list, got a {}", self.kind_name()))),
369595 }
370596 }
@@ -392,7 +618,7 @@ impl Value {
392618 }
393619
394620 pub fn list(xs: Vec<Value>) -> Value {
395− Value::List(Rc::new(xs))
621+ Value::List(ListVal::new(xs))
396622 }
397623
398624 pub fn bool(b: bool) -> Value {
@@ -402,17 +628,13 @@ impl Value {
402628
403629/// Attach a definition name and contract to a function value (§4.13).
404630pub fn attach_pending(v: &Value, name: &str, contract: Rc<crate::shape::Contract>) -> Value {
405− let pending = Some((
406− name.to_string(),
407− Rc::new(crate::shape::ContractExpr::Known(contract)),
408− ));
631+ let cexpr = Rc::new(crate::shape::ContractExpr::Known { contract, at: 0 });
632+ let pending = Some((name.to_string(), cexpr.clone()));
409633 match v {
410634 Value::Fun(fv) => match fv.as_ref() {
411635 FunVal::Closure {
412636 params,
413637 applied,
414− applied_args,
415− deferred,
416638 body,
417639 env,
418640 src,
@@ -421,8 +643,10 @@ pub fn attach_pending(v: &Value, name: &str, contract: Rc<crate::shape::Contract
421643 name: Some(name.to_string()),
422644 params: params.clone(),
423645 applied: *applied,
424− applied_args: applied_args.clone(),
425− deferred: *deferred,
646+ // the definition renders as its name, followed only by the
647+ // arguments it is given, not those its value holds: `prev`,
648+ // not `prev (parents)` (§5.2)
649+ applied_args: Vec::new(),
426650 body: body.clone(),
427651 env: env.clone(),
428652 src: src.clone(),
@@ -439,14 +663,81 @@ pub fn attach_pending(v: &Value, name: &str, contract: Rc<crate::shape::Contract
439663 arity: *arity,
440664 args: args.clone(),
441665 f: *f,
442− pending,
666+ // the arguments it holds now are its value's, and those
667+ // after them the definition's own: `k3 ((+)) 0` with
668+ // `k3 = foldl`, past a signature that ends in a type
669+ // variable and so stops counting (§5.2)
670+ pending: Some((name.to_string(), cexpr, args.len())),
443671 })),
444− FunVal::OrFun(_, _) | FunVal::ComposeLazy(_, _) | FunVal::Labelled(_, _) => v.clone(),
672+ // `f or g` and `%name` have nowhere to hold a contract, so the
673+ // definition becomes `\x -> v x` under its name, which the
674+ // closure machinery checks at every argument the signature lists
675+ // and at the result. The check is around the lifted `or` as a
676+ // whole (§4.6): a result that violates the signature is a crash,
677+ // not a reason to try the other side.
678+ FunVal::OrFun(_, _) | FunVal::ComposeLazy(_, _) | FunVal::Labelled(_, _) => {
679+ named_apply(v.clone(), Vec::new(), name, cexpr)
680+ }
445681 },
446682 _ => v.clone(),
447683 }
448684}
449685
686+/// `\x -> f x` under a definition's name and contract: it renders as the
687+/// name followed by `applied_args` (§5.2), and its further arguments and its
688+/// result are checked against the contract (§4.13)
689+pub fn named_apply(
690+ f: Value,
691+ applied_args: Vec<Value>,
692+ name: &str,
693+ cexpr: Rc<crate::shape::ContractExpr>,
694+) -> Value {
695+ Value::Fun(Rc::new(FunVal::Closure {
696+ name: Some(name.to_string()),
697+ params: vec![Pattern::Var("x".to_string())],
698+ applied: 0,
699+ applied_args,
700+ body: NAMED_APPLY_BODY.with(Rc::clone),
701+ env: Env::empty().extend(vec![("f".to_string(), f)]),
702+ // a named closure renders by its name, not its source (§5.2)
703+ src: crate::ast::Source::new(Rc::from(""), 0, 0),
704+ pending: Some((name.to_string(), cexpr)),
705+ }))
706+}
707+
708+thread_local! {
709+ /// `f x`, the body every named_apply wrapper shares, by which one is
710+ /// told from a lambda written in the language
711+ static NAMED_APPLY_BODY: Rc<Expr> = {
712+ let var = |n: &str| Rc::new(Expr::Var(n.to_string()));
713+ Rc::new(Expr::App(var("f"), var("x")))
714+ };
715+}
716+
717+/// The function `v` wraps when it is a named_apply wrapper under `name` and a
718+/// contract the same as `cexpr`. Naming that function afresh under them
719+/// checks all the wrapper would, so a definition that names the value of its
720+/// own recursive call wraps it once, not once per call (§4.13).
721+pub fn named_inner(v: &Value, name: &str, cexpr: &crate::shape::ContractExpr) -> Option<Value> {
722+ match v {
723+ Value::Fun(fv) => match fv.as_ref() {
724+ FunVal::Closure {
725+ body,
726+ env,
727+ pending: Some((n, c)),
728+ ..
729+ } if n == name
730+ && c.same_as(cexpr)
731+ && NAMED_APPLY_BODY.with(|b| Rc::ptr_eq(b, body)) =>
732+ {
733+ env.lookup("f")
734+ }
735+ _ => None,
736+ },
737+ _ => None,
738+ }
739+}
740+
450741/// A crash (§4.7).
451742#[derive(Debug, Clone)]
452743pub struct Crash {
@@ -473,6 +764,22 @@ pub struct Env {
473764 frame: Option<Rc<Frame>>,
474765}
475766
767+/// A large acyclic let or a collected recursive cycle can release a long
768+/// chain of parent frames. Release unique frames iteratively, keeping the
769+/// next parent alive while its child's values drop, so closures capturing
770+/// earlier frames cannot recurse down that chain either (§4.1).
771+impl Drop for Env {
772+ fn drop(&mut self) {
773+ let mut next = self.frame.take();
774+ while let Some(frame) = next {
775+ match Rc::try_unwrap(frame) {
776+ Ok(mut frame) => next = frame.parent.frame.take(),
777+ Err(_) => break,
778+ }
779+ }
780+ }
781+}
782+
476783pub struct Frame {
477784 kind: FrameKind,
478785 parent: Env,
@@ -481,9 +788,10 @@ pub struct Frame {
481788enum FrameKind {
482789 Small(Vec<(String, Value)>),
483790 Big(HashMap<String, Value>),
484− /// recursive let bindings: filled incrementally, read through the cell so
791+ /// recursive let bindings: the names the block binds, and their values
792+ /// in that order, filled incrementally and read through the cell so
485793 /// closures capturing the frame see later bindings (§4.1)
486− Rec(Rc<std::cell::RefCell<Vec<(String, Value)>>>),
794+ Rec(Rc<[String]>, Rc<std::cell::RefCell<Vec<Value>>>),
487795}
488796
489797impl Env {
@@ -509,18 +817,23 @@ impl Env {
509817 }
510818 }
511819
512− /// extend with a recursive frame (for `let` blocks)
513− pub fn extend_rec(&self) -> (Env, Rc<std::cell::RefCell<Vec<(String, Value)>>>) {
514− let cell = Rc::new(std::cell::RefCell::new(Vec::new()));
515− (
516− Env {
517− frame: Some(Rc::new(Frame {
518− kind: FrameKind::Rec(cell.clone()),
519− parent: self.clone(),
520− })),
521− },
522− cell,
523− )
820+ /// extend with a recursive frame (for `let` blocks) binding `names`,
821+ /// whose values are pushed to the returned cell in the same order
822+ pub fn extend_rec(&self, names: Rc<[String]>) -> (Env, Rc<std::cell::RefCell<Vec<Value>>>) {
823+ // Collect before allocating: `self` and all values the evaluator is
824+ // using are still ordinary strong roots during this check.
825+ maybe_collect_cycles();
826+ let cell = Rc::new(std::cell::RefCell::new(Vec::with_capacity(names.len())));
827+ let frame = Rc::new(Frame {
828+ kind: FrameKind::Rec(names, cell.clone()),
829+ parent: self.clone(),
830+ });
831+ RECURSIVE_FRAMES.with(|registry| {
832+ let mut registry = registry.borrow_mut();
833+ registry.frames.push(Rc::downgrade(&frame));
834+ registry.allocations += 1;
835+ });
836+ (Env { frame: Some(frame) }, cell)
524837 }
525838
526839 pub fn lookup(&self, name: &str) -> Option<Value> {
@@ -534,11 +847,13 @@ impl Env {
534847 }
535848 }
536849 }
537− FrameKind::Rec(cell) => {
538− for (n, v) in cell.borrow().iter().rev() {
539− if n == name {
540− return Some(v.clone());
541− }
850+ FrameKind::Rec(names, cell) => {
851+ // the frame binds its names before it holds their values:
852+ // one not evaluated yet is unbound here, not a same-named
853+ // definition further out, which config.j's binders may
854+ // reuse (§4.2)
855+ if let Some(i) = names.iter().rposition(|n| n == name) {
856+ return cell.borrow().get(i).cloned();
542857 }
543858 }
544859 FrameKind::Big(m) => {
@@ -560,7 +875,7 @@ impl Env {
560875 while let Some(f) = cur {
561876 match &f.kind {
562877 FrameKind::Small(_) => small_frames += 1,
563− FrameKind::Big(_) | FrameKind::Rec(_) => {}
878+ FrameKind::Big(_) | FrameKind::Rec(..) => {}
564879 }
565880 cur = f.parent.frame.as_ref();
566881 }
@@ -576,8 +891,8 @@ impl Env {
576891 out.insert(n.clone());
577892 }
578893 }
579− FrameKind::Rec(cell) => {
580− for (n, _) in cell.borrow().iter() {
894+ FrameKind::Rec(names, _) => {
895+ for n in names.iter() {
581896 out.insert(n.clone());
582897 }
583898 }
@@ -592,6 +907,302 @@ impl Env {
592907 }
593908}
594909
910+// A recursive environment owns its bindings, and a closure in those bindings
911+// may own the environment again. Weak captures would lose an escaping
912+// closure's bindings, so these are real strong references. Collect their
913+// unreachable cycles by subtracting the references inside their owned graph
914+// from each node's reference count. References from outside that graph are
915+// roots: returned values, the interpreter, and the running machine's states
916+// and continuations. No explicit evaluator root stack is needed.
917+//
918+// Only recursive frames can introduce cycles in language values. The
919+// registry is weak, and all traversal and marking below use explicit work
920+// lists, including through shared lists, records, and function wrappers.
921+#[derive(Default)]
922+struct RecursiveFrames {
923+ frames: Vec<Weak<Frame>>,
924+ allocations: usize,
925+ next_collection: usize,
926+ without_environments: HashMap<(u8, usize), EnvironmentFree>,
927+ cached_since_pruning: usize,
928+ collecting: bool,
929+}
930+
931+thread_local! {
932+ static RECURSIVE_FRAMES: std::cell::RefCell<RecursiveFrames> =
933+ std::cell::RefCell::new(RecursiveFrames::default());
934+}
935+
936+const MIN_CYCLE_BATCH: usize = 256;
937+
938+fn maybe_collect_cycles() {
939+ let due = RECURSIVE_FRAMES.with(|registry| {
940+ let registry = registry.borrow();
941+ !registry.collecting
942+ && registry.allocations >= registry.next_collection.max(MIN_CYCLE_BATCH)
943+ });
944+ if due {
945+ collect_cycles();
946+ }
947+}
948+
949+/// Reclaim dead recursive environments. The caller must retain any result
950+/// it wants to return while this runs, as an ordinary strong reference.
951+pub(crate) fn collect_cycles() {
952+ RECURSIVE_FRAMES.with(|shared| {
953+ let mut registry = {
954+ let mut registry = shared.borrow_mut();
955+ if registry.collecting { return; }
956+ let previous = std::mem::take(&mut *registry);
957+ registry.collecting = true;
958+ previous
959+ };
960+ // Do not hold the registry borrow while dropping values. Opaque
961+ // Rust backend captures can run destructors that allocate frames or
962+ // drop another interpreter; such nested collections are deferred.
963+ let mut graph = CycleGraph::default();
964+ for frame in &registry.frames {
965+ if let Some(frame) = frame.upgrade() {
966+ graph.add(CycleNode::Frame(frame));
967+ }
968+ }
969+ graph.trace(&registry.without_environments);
970+ registry.cached_since_pruning += graph.cache_environment_free(&mut registry.without_environments);
971+ graph.collect();
972+ // Release the collector's temporary roots before pruning the weak
973+ // registry. Live recursive frames set the next batch size so a
974+ // growing set of escaping closures costs amortized linear work.
975+ drop(graph);
976+ registry.frames.retain(|frame| frame.strong_count() != 0);
977+ registry.allocations = 0;
978+ registry.next_collection = registry.frames.len().max(MIN_CYCLE_BATCH);
979+ // Immutable repository trees usually hold no functions. Remember
980+ // that once, without keeping their values alive. Pruning is batched
981+ // too: a large, live tree must not be scanned on every collection.
982+ if registry.frames.is_empty() {
983+ registry.without_environments.clear();
984+ registry.cached_since_pruning = 0;
985+ } else if registry.cached_since_pruning >=
986+ (registry.without_environments.len() / 2).max(MIN_CYCLE_BATCH)
987+ {
988+ registry.without_environments.retain(|_, cached| cached.is_alive());
989+ registry.cached_since_pruning = 0;
990+ }
991+ let mut pending = shared.borrow_mut();
992+ registry.frames.append(&mut pending.frames);
993+ registry.allocations += pending.allocations;
994+ *pending = registry;
995+ });
996+}
997+
998+// These containers are immutable while shared. Weak references both avoid
999+// extending their lives and prevent address reuse from giving a false hit.
1000+// Rc::make_mut dissociates weak pointers before it can change a container.
1001+enum EnvironmentFree {
1002+ List(Weak<Vec<Value>>),
1003+ Record(Weak<RecordMap>),
1004+}
1005+
1006+impl EnvironmentFree {
1007+ fn is_alive(&self) -> bool {
1008+ match self {
1009+ Self::List(v) => v.strong_count() != 0,
1010+ Self::Record(v) => v.strong_count() != 0,
1011+ }
1012+ }
1013+}
1014+
1015+enum CycleNode {
1016+ Frame(Rc<Frame>),
1017+ Bindings(Rc<std::cell::RefCell<Vec<Value>>>),
1018+ Function(Rc<FunVal>),
1019+ List(Rc<Vec<Value>>),
1020+ Record(Rc<RecordMap>),
1021+}
1022+
1023+impl CycleNode {
1024+ fn key(&self) -> (u8, usize) {
1025+ match self {
1026+ Self::Frame(v) => (0, Rc::as_ptr(v) as usize),
1027+ Self::Bindings(v) => (1, Rc::as_ptr(v) as usize),
1028+ Self::Function(v) => (2, Rc::as_ptr(v) as usize),
1029+ Self::List(v) => (3, Rc::as_ptr(v) as usize),
1030+ Self::Record(v) => (4, Rc::as_ptr(v) as usize),
1031+ }
1032+ }
1033+
1034+ fn strong_count(&self) -> usize {
1035+ match self {
1036+ Self::Frame(v) => Rc::strong_count(v),
1037+ Self::Bindings(v) => Rc::strong_count(v),
1038+ Self::Function(v) => Rc::strong_count(v),
1039+ Self::List(v) => Rc::strong_count(v),
1040+ Self::Record(v) => Rc::strong_count(v),
1041+ }
1042+ }
1043+
1044+ fn children(&self) -> Vec<CycleNode> {
1045+ let mut out = Vec::new();
1046+ let mut value = |v: &Value| match v {
1047+ Value::Fun(f) => out.push(Self::Function(f.clone())),
1048+ // A list view owns its whole backing vector, including the
1049+ // prefix that `tail`/`drop` no longer exposes.
1050+ Value::List(xs) => out.push(Self::List(xs.items.clone())),
1051+ Value::Record(fields) => out.push(Self::Record(fields.clone())),
1052+ // Backend thunks are opaque Rust closures. Their references,
1053+ // if any, remain external roots; never guess what they own.
1054+ _ => {}
1055+ };
1056+ match self {
1057+ Self::Frame(frame) => {
1058+ match &frame.kind {
1059+ FrameKind::Small(bindings) => {
1060+ for (_, v) in bindings { value(v); }
1061+ }
1062+ FrameKind::Big(bindings) => {
1063+ for v in bindings.values() { value(v); }
1064+ }
1065+ FrameKind::Rec(_, bindings) => {
1066+ out.push(Self::Bindings(bindings.clone()));
1067+ }
1068+ }
1069+ if let Some(parent) = &frame.parent.frame {
1070+ out.push(Self::Frame(parent.clone()));
1071+ }
1072+ }
1073+ Self::Bindings(bindings) => {
1074+ for v in bindings.borrow().iter() { value(v); }
1075+ }
1076+ Self::Function(f) => {
1077+ match f.as_ref() {
1078+ FunVal::Closure { env, applied_args, .. } => {
1079+ for v in applied_args { value(v); }
1080+ if let Some(frame) = &env.frame {
1081+ out.push(Self::Frame(frame.clone()));
1082+ }
1083+ }
1084+ FunVal::Builtin { args, .. } => {
1085+ for v in args { value(v); }
1086+ }
1087+ FunVal::OrFun(a, b) | FunVal::ComposeLazy(a, b) => {
1088+ value(a);
1089+ value(b);
1090+ }
1091+ FunVal::Labelled(_, f) => value(f),
1092+ }
1093+ }
1094+ Self::List(items) => {
1095+ for v in items.iter() { value(v); }
1096+ }
1097+ Self::Record(fields) => {
1098+ for v in fields.values() { value(v); }
1099+ }
1100+ }
1101+ out
1102+ }
1103+}
1104+
1105+#[derive(Default)]
1106+struct CycleGraph {
1107+ // Exactly one temporary strong reference per node. All temporary edge
1108+ // clones are consumed by `add` before reference counts are examined.
1109+ nodes: Vec<CycleNode>,
1110+ indices: HashMap<(u8, usize), usize>,
1111+ edges: Vec<Vec<usize>>,
1112+ incoming: Vec<usize>,
1113+}
1114+
1115+impl CycleGraph {
1116+ fn add(&mut self, node: CycleNode) -> usize {
1117+ let key = node.key();
1118+ if let Some(&index) = self.indices.get(&key) {
1119+ return index;
1120+ }
1121+ let index = self.nodes.len();
1122+ self.indices.insert(key, index);
1123+ self.nodes.push(node);
1124+ self.edges.push(Vec::new());
1125+ self.incoming.push(0);
1126+ index
1127+ }
1128+
1129+ fn trace(&mut self, without_environments: &HashMap<(u8, usize), EnvironmentFree>) {
1130+ let mut index = 0;
1131+ while index < self.nodes.len() {
1132+ for child in self.nodes[index].children() {
1133+ if without_environments.contains_key(&child.key()) {
1134+ continue;
1135+ }
1136+ let next = self.add(child);
1137+ self.edges[index].push(next);
1138+ self.incoming[next] += 1;
1139+ }
1140+ index += 1;
1141+ }
1142+ }
1143+
1144+ fn cache_environment_free(&self, cache: &mut HashMap<(u8, usize), EnvironmentFree>) -> usize {
1145+ let mut parents = vec![Vec::new(); self.nodes.len()];
1146+ for (index, edges) in self.edges.iter().enumerate() {
1147+ for &next in edges { parents[next].push(index); }
1148+ }
1149+ let mut reaches_frame = vec![false; self.nodes.len()];
1150+ let mut work = Vec::new();
1151+ for (index, node) in self.nodes.iter().enumerate() {
1152+ if matches!(node, CycleNode::Frame(_)) {
1153+ reaches_frame[index] = true;
1154+ work.push(index);
1155+ }
1156+ }
1157+ while let Some(index) = work.pop() {
1158+ for &parent in &parents[index] {
1159+ if !reaches_frame[parent] {
1160+ reaches_frame[parent] = true;
1161+ work.push(parent);
1162+ }
1163+ }
1164+ }
1165+ let mut added = 0;
1166+ for (index, node) in self.nodes.iter().enumerate() {
1167+ if !reaches_frame[index] {
1168+ let cached = match node {
1169+ CycleNode::List(v) => EnvironmentFree::List(Rc::downgrade(v)),
1170+ CycleNode::Record(v) => EnvironmentFree::Record(Rc::downgrade(v)),
1171+ _ => continue,
1172+ };
1173+ if cache.insert(node.key(), cached).is_none() { added += 1; }
1174+ }
1175+ }
1176+ added
1177+ }
1178+
1179+ fn collect(&self) {
1180+ let mut reachable = vec![false; self.nodes.len()];
1181+ let mut work = Vec::new();
1182+ for (index, node) in self.nodes.iter().enumerate() {
1183+ if node.strong_count() > self.incoming[index] + 1 {
1184+ reachable[index] = true;
1185+ work.push(index);
1186+ }
1187+ }
1188+ while let Some(index) = work.pop() {
1189+ for &next in &self.edges[index] {
1190+ if !reachable[next] {
1191+ reachable[next] = true;
1192+ work.push(next);
1193+ }
1194+ }
1195+ }
1196+ for (index, node) in self.nodes.iter().enumerate() {
1197+ if !reachable[index] {
1198+ if let CycleNode::Bindings(bindings) = node {
1199+ bindings.borrow_mut().clear();
1200+ }
1201+ }
1202+ }
1203+ }
1204+}
1205+
5951206// ----------------------------------------------------------------------
5961207// structural equality (§4.5); comparing functions is a crash
5971208// ----------------------------------------------------------------------
tests/base.rs+9−2
@@ -211,6 +211,12 @@ fn goto_variants() {
211211 assert_eq!(focus_msg(&v), "first");
212212 let m = crash(&mut i, &cfg, "goto (labelled \"nope\")", repo.clone());
213213 assert!(m.contains("expected one revision, got 0"), "{}", m);
214+ // a lifted `or` over the curried goto still guards the repo argument
215+ // (§4.6): goto applied to the revset is a function, so the lift goes on
216+ let v = ok(&mut i, &cfg, "(goto or (\\_ -> id)) %nope", repo.clone());
217+ assert_eq!(focus_id(&v), focus_id(&repo));
218+ let v = ok(&mut i, &cfg, "(goto or (\\_ -> id)) %feat", repo.clone());
219+ assert_eq!(focus_msg(&v), "first");
214220 let m = crash(&mut i, &cfg, "into @kqqqqqqq", repo.clone());
215221 assert!(m.contains("not a child"), "{}", m);
216222 // child by predicate: move to the parent, then to the matching child
@@ -407,10 +413,11 @@ fn inspection_definitions() {
407413 // meta of minted crashes
408414 let m = crash(&mut i, &cfg, "meta @", repo.clone());
409415 assert!(m.contains("stored commit") || m.contains("meta"), "{}", m);
410− // review with no difft falls to crash (difft not on PATH in tests)
416+ // review renders changed paths with difft or the built-in fallback;
417+ // "no changes" is for a focus that changes nothing.
411418 let r = ev(&mut i, &cfg, "review", repo.clone());
412419 match r {
413− Ok(v) => assert_eq!(v.as_text().unwrap(), "no changes\n"),
420+ Ok(v) => assert_ne!(v.as_text().unwrap(), "no changes\n"),
414421 Err(m) => assert!(m.contains("difft"), "{}", m),
415422 }
416423}
tests/builtins.rs+102−6
@@ -36,7 +36,7 @@ fn ok(i: &mut Interp, cfg: &config::Config, src: &str) -> Value {
3636
3737fn crash(i: &mut Interp, cfg: &config::Config, src: &str) -> String {
3838 match ev(i, cfg, src) {
39− Ok(v) => panic!("{} unexpectedly succeeded: {}", src, j::show::show(i, &v)),
39+ Ok(v) => panic!("{} unexpectedly succeeded: {}", src, j::show::show(i, &v).unwrap()),
4040 Err(m) => m,
4141 }
4242}
@@ -48,8 +48,8 @@ macro_rules! check {
4848 value_eq(&got, &$want).unwrap_or(false),
4949 "{} => {}, want {}",
5050 $src,
51− j::show::show(&$i, &got),
52− j::show::show(&$i, &$want)
51+ j::show::show(&$i, &got).unwrap(),
52+ j::show::show(&$i, &$want).unwrap()
5353 );
5454 }};
5555}
@@ -81,6 +81,32 @@ fn arithmetic() {
8181 check!(i, cfg, "3 >= 4", Value::Bool(false));
8282}
8383
84+#[test]
85+fn blob_hex_decodes_exact_bytes_and_file_type() {
86+ use j::value::BlobKind;
87+ let (mut i, cfg) = make_interp();
88+ for (name, kind) in [
89+ ("regular", BlobKind::Regular),
90+ ("executable", BlobKind::Executable),
91+ ("symlink", BlobKind::Symlink),
92+ ] {
93+ for (hex, bytes) in [("", vec![]), ("00ff80A5", vec![0, 255, 128, 165])] {
94+ let v = ok(&mut i, &cfg, &format!("blobHex \"{}\" \"{}\"", name, hex));
95+ let Value::Blob(b) = v else { panic!("expected Blob") };
96+ assert_eq!(b.kind, kind);
97+ assert_eq!(b.bytes().unwrap(), bytes);
98+ assert!(!b.is_unresolved());
99+ }
100+ }
101+ for hex in ["f", "xyz", "0g", "é", "00 ff"] {
102+ let msg = crash(&mut i, &cfg, &format!("blobHex \"regular\" \"{}\"", hex));
103+ assert!(msg.contains("invalid hexadecimal content"), "{}", msg);
104+ }
105+ assert!(crash(&mut i, &cfg, "blobHex \"directory\" \"\"").contains("kind must be"));
106+ assert!(crash(&mut i, &cfg, "blobHex 1 \"00\"").contains("expected Text"));
107+ assert!(crash(&mut i, &cfg, "blobHex \"regular\" 1").contains("expected Text"));
108+}
109+
84110#[test]
85111fn identity_and_const() {
86112 let (mut i, cfg) = make_interp();
@@ -208,9 +234,22 @@ fn show_renders_literals() {
208234 check!(i, cfg, "show (++)", Value::text("(++)"));
209235 check!(i, cfg, "show Commit", Value::text("Commit"));
210236 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());
237+ // lambda shows its source, exactly as written (§5.2)
238+ check!(i, cfg, "show (\\x -> x + 1)", Value::text("\\x -> x + 1"));
239+}
240+
241+#[test]
242+fn show_renders_selectors() {
243+ // a selector is the SELECTOR atom `.name` (§3.4), not the accessor
244+ // applied to the field name it carries internally (§5.2)
245+ let (mut i, cfg) = make_interp();
246+ check!(i, cfg, "show (.id)", Value::text(".id"));
247+ check!(i, cfg, "show [(.id)]", Value::text("[(.id)]"));
248+ check!(i, cfg, "show (map (.id))", Value::text("map (.id)"));
249+ // and the rendering evaluates back to the same selector
250+ let shown = ok(&mut i, &cfg, "show (map (.id))");
251+ let src = format!("({}) [{{ id = 5 }}]", shown.as_text().unwrap());
252+ check!(i, cfg, &src, ints(&[5]));
214253}
215254
216255#[test]
@@ -281,3 +320,60 @@ labelled = \_ _ -> []
281320 Err(c) => assert!(c.msg.contains("unbound"), "{}", c.msg),
282321 }
283322}
323+
324+#[test]
325+fn boolean_operators_as_values() {
326+ // `&&` and `||` short-circuit as infix operators, which the evaluator
327+ // handles directly. Used as values — a section, or passed to foldl/map —
328+ // both arguments are already evaluated, and the builtin must still
329+ // combine them rather than return the second one.
330+ let (mut i, cfg) = make_interp();
331+ check!(i, cfg, "foldl (&&) true [true true]", Value::Bool(true));
332+ check!(i, cfg, "foldl (&&) true [true false]", Value::Bool(false));
333+ check!(i, cfg, "foldl (&&) true [false true]", Value::Bool(false));
334+ check!(i, cfg, "foldl (||) false [false false]", Value::Bool(false));
335+ check!(i, cfg, "foldl (||) false [false true]", Value::Bool(true));
336+ check!(i, cfg, "foldl (||) false [true false]", Value::Bool(true));
337+ // partially applied sections
338+ check!(i, cfg, "map ((&&) false) [true true]", Value::list(vec![Value::Bool(false); 2]));
339+ check!(i, cfg, "map ((||) true) [false false]", Value::list(vec![Value::Bool(true); 2]));
340+ // infix still short-circuits: the rhs is never evaluated
341+ check!(i, cfg, "false && (crash \"boom\")", Value::Bool(false));
342+ check!(i, cfg, "true || (crash \"boom\")", Value::Bool(true));
343+}
344+
345+#[test]
346+fn tail_and_drop_share_storage_without_changing_semantics() {
347+ // `tail`/`drop` now return a view over the same elements instead of
348+ // copying them (a copying `tail` made every zipper walk quadratic, since
349+ // `up` is `tail repo.context`). The results must still be ordinary lists.
350+ let (mut i, cfg) = make_interp();
351+ check!(i, cfg, "tail [1 2 3]", ints(&[2, 3]));
352+ check!(i, cfg, "tail (tail [1 2 3])", ints(&[3]));
353+ check!(i, cfg, "tail (tail (tail [1 2 3]))", ints(&[]));
354+ check!(i, cfg, "drop 2 [1 2 3 4]", ints(&[3, 4]));
355+ check!(i, cfg, "drop 0 [1 2]", ints(&[1, 2]));
356+ check!(i, cfg, "drop 99 [1 2]", ints(&[]));
357+ check!(i, cfg, "drop 1 (drop 1 [1 2 3 4])", ints(&[3, 4]));
358+ // a view is equal to the same list built directly
359+ check!(i, cfg, "tail [1 2 3] == [2 3]", Value::Bool(true));
360+ check!(i, cfg, "drop 1 (tail [1 2 3 4]) == [3 4]", Value::Bool(true));
361+ // and behaves like one everywhere else
362+ check!(i, cfg, "length (tail [1 2 3])", Value::int(2));
363+ check!(i, cfg, "head (tail [1 2 3])", Value::int(2));
364+ check!(i, cfg, "last (tail [1 2 3])", Value::int(3));
365+ check!(i, cfg, "nth 0 (tail [1 2 3])", Value::int(2));
366+ check!(i, cfg, "tail [1 2 3] ++ [9]", ints(&[2, 3, 9]));
367+ check!(i, cfg, "0 :: tail [1 2 3]", ints(&[0, 2, 3]));
368+ check!(i, cfg, "take 1 (tail [1 2 3])", ints(&[2]));
369+ check!(i, cfg, "map (\\x -> x * 2) (tail [1 2 3])", ints(&[4, 6]));
370+ check!(i, cfg, "member 2 (tail [1 2 3])", Value::Bool(true));
371+ check!(i, cfg, "member 1 (tail [1 2 3])", Value::Bool(false));
372+ check!(i, cfg, "concat [(tail [1 2]) (tail [3 4])]", ints(&[2, 4]));
373+ check!(i, cfg, "null (tail [1])", Value::Bool(true));
374+ // the original is untouched by taking a tail of it
375+ check!(i, cfg, "let xs = [1 2 3] in tail xs ++ xs", ints(&[2, 3, 1, 2, 3]));
376+ // show round-trips a view
377+ check!(i, cfg, "show (tail [1 2 3])", Value::text("[2 3]"));
378+ assert!(crash(&mut i, &cfg, "tail []").contains("empty"));
379+}
tests/cli.rs+4446−67

This diff is too big to show here.

tests/core.rs+1−1

This diff is too big to show here.

tests/difft.rsnew file+74

This diff is too big to show here.

tests/display.rs+974−5

This diff is too big to show here.

tests/domain.rs+111−1

This diff is too big to show here.

tests/env_lifetimes.rsnew file+215

This diff is too big to show here.

tests/eval.rs+1072−9

This diff is too big to show here.

tests/help.rsnew file+326

This diff is too big to show here.

tests/interop.rs+173−10

This diff is too big to show here.

tests/laws.proptest-regressions+1

This diff is too big to show here.

tests/laws.rs+258−6

This diff is too big to show here.

tests/lex.rs+24

This diff is too big to show here.

tests/more.rs+322−1

This diff is too big to show here.

tests/onboarding.rsnew file+200

This diff is too big to show here.

tests/onboarding_vocab.rsnew file+153

This diff is too big to show here.

tests/parse.rs+314

This diff is too big to show here.

tests/performance_lookup.rsnew file+255

This diff is too big to show here.

tests/remote.rs+2336−2

This diff is too big to show here.

tests/review_fallback.rsnew file+110

This diff is too big to show here.

tests/setup.rsnew file+144

This diff is too big to show here.

tests/show.rs+587−25

This diff is too big to show here.

tests/showprop.proptest-regressions+1

This diff is too big to show here.

tests/showprop.rs+130−5

This diff is too big to show here.

tests/terminal.rsnew file+621

This diff is too big to show here.

tests/tree.rs+1150−65

This diff is too big to show here.

tests/ux_cli.rsnew file+105

This diff is too big to show here.

tests/validation.rs+816−8

This diff is too big to show here.