on master help sign in
CLAUDE.mdnew file+71
since splitsnapshots
unifiedsplit
@@ -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`, … —
27written *in the language* in `config.j`, and you extend it by writing more
27written *in the language* in `config.j`, and you extend it by writing more
28functions.
28functions.
29
29
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+
30---
76---
31
77
32## Why `j`?
78## Why `j`?
@@ -47,7 +93,7 @@ functions.
47### With Nix (recommended)
93### With Nix (recommended)
48
94
49The repository provides a flake with one default binary, `j`. Difftastic is a
95The 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.
51
97
52```
98```
53nix build # builds ./result/bin/j
99nix build # builds ./result/bin/j
@@ -63,22 +109,70 @@ The reference `config.j` is installed alongside the binary
63cargo build --release # produces target/release/j
109cargo build --release # produces target/release/j
64```
110```
65
111
66−You'll want the reference `config.j` (see next section) and, for `review`/`difft`,
112+Run `j setup` to create the reference `config.j` (see next section).
67−[difftastic](https://difftastic.wilfred.me.uk/) on your `PATH`.
113+[Difftastic](https://difftastic.wilfred.me.uk/) on your `PATH` improves
114+`review`/`difft`; without it they use a unified diff.
68
115
69---
116---
70
117
71## Configuration
118## Configuration
72
119
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+
73−`j` reads `~/.config/j/config.j` on every run (or `$XDG_CONFIG_HOME/j/config.j`).
156+Expressions read `~/.config/j/config.j` (or `$XDG_CONFIG_HOME/j/config.j`).
74An expression can only use a name this file declares or defines. The reference
157An 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
158+[`config.j`](config.j) ships with the binary. Commands that execute against a
76−identity:
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:
77
161
78```
162```
79−user = { name = "Your Name", email = "you@example.com" }
163+user = { name = "Ada Lovelace", email = "ada@example.org" }
80```
164```
81
165
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+
82The reference config defines the entire base vocabulary (see
176The reference config defines the entire base vocabulary (see
83[docs/base.md](docs/base.md)). Four definitions are read by `j` itself: `user`
177[docs/base.md](docs/base.md)). Four definitions are read by `j` itself: `user`
84(who commits), `tree` (how a `Repo` prints), `immutable` (which commits may
178(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).
88
182
89## How it works
183## How it works
90
184
91−Every `j` run:
185+Every expression run:
92
186
93−1. Snapshots the working directory into the working-copy commit.
187+1. Snapshots the working directory for evaluation.
94−2. Builds a `Repo` value focused on that commit.
188+2. Builds a `Repo` value focused on the working-copy commit.
953. Evaluates your expression.
1893. Evaluates your expression.
964. If the result is a function, applies it to the `Repo`.
1904. 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
191+5. If the final value is a `Repo` that changed, persists it. An unchanged
98− the value and changes nothing.
192+ `Repo` saves nothing; other values print without saving.
99
193
100So most of what you write is an **edit** — a function `Repo -> Repo`. A result
194So most of what you write is an **edit** — a function `Repo -> Repo`. A result
101that's plain data is printed and persists nothing (a dry run).
195that'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.
102
198
103```
199```
104j 'length . commits . top' -- a number: how many commits? prints, persists nothing
200j '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.
143j 'status'
239j 'status'
144```
240```
145
241
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.
147
247
148```
248```
149−changed 0 items
249+in commit No changes
150−conflicts 0 items
250+conflicts No conflicts
151−id zrkmkkvrvtokumkuzrkmkkvrvtokumku
251+id zrkmkkvrvtokumkuzrkmkkvrvtokumku
152−labels 0 items
252+labels No labels
153−message add a library
253+message add a library
254+unrecorded All edits recorded
154```
255```
155
256
156```
257```
@@ -190,25 +291,33 @@ The same tree with ages and authors in the margin.
190j 'log'
291j 'log'
191```
292```
192
293
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.
194
297
195```
298```
196j 'review'
299j 'review'
197```
300```
198
301
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.
200
304
201### Move around
305### Move around
202
306
307+Navigation checks out the selected commit's files so you can edit them.
308+
203```
309```
204j 'prev' -- to the parent
310j 'prev' -- to the parent
205j 'next' -- to the only child
311j 'next' -- to the only child
206−j 'top' -- to the top of history
207j 'tip' -- to the tip of the current line
312j 'tip' -- to the tip of the current line
208j 'by @wqzt' -- to a specific commit, by id
313j '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
210```
315```
211
316
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+
212### Undo and history of actions
321### Undo and history of actions
213
322
214```
323```
@@ -217,18 +326,25 @@ j redo -- reapply it
217j ops -- the operation log, newest first
326j ops -- the operation log, newest first
218```
327```
219
328
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+
220### A safe dry run
336### A safe dry run
221
337
222−Because a `Text` or `Repo`-returning-but-unchanged result persists nothing,
338+`preview` validates an edit and returns its resulting tree without saving:
223−you can preview any edit by composing it with `tree` (or `validate`):
224
339
225```
340```
226−j 'tree . squash' -- show the tree as squash would leave it
341+j 'preview squash'
227−j 'tree . validate . squash' -- the same, but crash if squash couldn't persist
342+j 'preview (rebase trunk)'
228```
343```
229
344
230−`validate` returns the repo unchanged if persisting would succeed, and crashes
345+This is shorthand for `tree . validate . EDIT`. `tree . EDIT` also previews,
231−with the exact error otherwise — an honest dry run.
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.
232
348
233---
349---
234
350
@@ -330,6 +446,27 @@ j 'push (unlabel "old-feature")' -- delete a bookmark
330j 'push relabel' -- after a rewrite, re-point the remote's bookmarks
446j 'push relabel' -- after a rewrite, re-point the remote's bookmarks
331```
447```
332
448
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+
333---
470---
334
471
335## Advanced examples
472## Advanced examples
@@ -413,9 +550,9 @@ Rebase if you can; leave the repository alone if it crashes.
413Anything added to `config.j` whose value is an `Edit` is a new command:
550Anything added to `config.j` whose value is an `Edit` is a new command:
414
551
415```
552```
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"
417release : Edit
554release : Edit
418−release = describe "release" . (contract everything)
555+release = describe "release" . prev . squash
419```
556```
420
557
421Then `j release` works like any built-in.
558Then `j release` works like any built-in.
@@ -424,20 +561,31 @@ Then `j release` works like any built-in.
424
561
425## Reserved commands
562## Reserved commands
426
563
427−A handful of commands are built in (not expressions): they create or connect
564+A handful of commands are built in (not expressions): they provide help,
428−repositories, move refs, and walk the operation log. None take flags.
565+create or connect repositories, move refs, and walk the operation log.
429
566
430| command | what it does |
567| command | what it does |
431|---|---|
568|---|---|
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 |
432| `j init` | create a colocated `.jj`/`.git` repository here (adopting an existing `.git`) |
572| `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) |
434| `j remote URL` | set `origin`'s URL |
574| `j remote URL` | set `origin`'s URL |
435| `j fetch` | fetch from `origin` |
575| `j fetch` | fetch from `origin` |
436| `j push EXPR` | evaluate `EXPR` to push/drop records and push them |
576| `j push EXPR` | evaluate `EXPR` to push/drop records and push them |
437| `j undo` / `j redo` | walk the operation log |
577| `j undo` / `j redo` | walk the operation log |
438| `j ops` | print the operation log |
578| `j ops` | print the operation log |
439
579
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
440−Everything else is an expression in the language.
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.
441
589
442---
590---
443
591
@@ -449,7 +597,7 @@ Everything else is an expression in the language.
449| **[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. |
597| **[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. |
450| **[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. |
598| **[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. |
451| **[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. |
599| **[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. |
453| **[config.j](config.j)** | The reference configuration: the entire base vocabulary, written in the language. |
601| **[config.j](config.j)** | The reference configuration: the entire base vocabulary, written in the language. |
454
602
455---
603---
@@ -457,11 +605,11 @@ Everything else is an expression in the language.
457## Project layout
605## Project layout
458
606
459```
607```
460−config.j the reference base vocabulary (read on every run)
608+config.j the reference base vocabulary
461src/ the j implementation (Rust, on jj-lib)
609src/ the j implementation (Rust, on jj-lib)
462tests/ unit, property, and CLI end-to-end tests
610tests/ unit, property, and CLI end-to-end tests
463docs/ language.md, base.md, comparison.md
611docs/ language.md, base.md, comparison.md
464−SPEC.md the specification
612+specs/ the language, CLI, repository, and tree specifications
465flake.nix Nix package: one binary, j, with difftastic as a runtime dep
613flake.nix Nix package: one binary, j, with difftastic as a runtime dep
466```
614```
467
615
config.j+107−22
@@ -76,6 +76,10 @@ Drop = { delete : Label } -- delete the remote's bookmark
76------------------------------------------------------------------------------
76------------------------------------------------------------------------------
77
77
78(.) : (b -> c) -> (a -> b) -> a -> c -- composition, right to left
78(.) : (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.
79id : a -> a
83id : a -> a
80const : a -> b -> a
84const : a -> b -> a
81crash : Text -> a -- abort the program with a message
85crash : Text -> a -- abort the program with a message
@@ -137,24 +141,37 @@ splitOn : Text -> Text -> [Text] -- splitOn "/" "a/b" = ["a" "b"]
137------------------------------------------------------------------------------
141------------------------------------------------------------------------------
138
142
139-- Replay a change onto a snapshot: "onto, with the change from `from` to `to`
143-- 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
144+-- applied." Where the change collides with what is already there, the path's
141−-- path's blob is unresolved rather than an error.
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).
142replay : Snapshot -> Change -> Snapshot
148replay : Snapshot -> Change -> Snapshot
143unresolved : Blob -> Bool
149unresolved : 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]
144touchedPaths : Change -> [Path] -- paths whose content differs, one pass;
155touchedPaths : Change -> [Path] -- paths whose content differs, one pass;
145 -- blobs compared by content id, bytes never read
156 -- blobs compared by content id, bytes never read
146blob : Text -> Blob -- a resolved regular file
157blob : Text -> Blob -- a resolved regular file
158+blobHex : Text -> Text -> Blob -- kind (regular/executable/symlink), hex bytes
147text : Blob -> Text -- its content (markers if unresolved)
159text : Blob -> Text -- its content (markers if unresolved)
148by : Id -> Repo -> Repo -- focus the commit with this id; crash if absent
160by : 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
149meta : Id -> Meta -- git hash, author, email, committer time;
165meta : Id -> Meta -- git hash, author, email, committer time;
150 -- crash for a commit not yet persisted
166 -- crash for a commit not yet persisted
151validate : Repo -> Repo -- the repo unchanged, or the crash persisting
167validate : Repo -> Repo -- the repo unchanged, or the crash persisting
152− -- it would produce (shape, labels, immutability,
168+ -- it would produce (shape, root, labels, immutability,
153 -- focus). `tree . validate . rebase trunk` is an
169 -- focus). `tree . validate . rebase trunk` is an
154 -- honest dry run.
170 -- honest dry run.
155diff : Blob -> Blob -> Text -- unified diff from the first to the second
171diff : Blob -> Blob -> Text -- unified diff from the first to the second
156−difft : Path -> Blob -> Blob -> Text -- difftastic's rendering; needs difft on PATH.
172+difft : Path -> Blob -> Blob -> Text -- difftastic when difft is on PATH; otherwise
157− -- Options via DFT_* environment variables.
173+ -- unified diff with path headers and 3 context lines,
174+ -- or a binary-files summary. DFT_* configures difft.
158treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text
175treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text
159 , lanes : Int, author : Bool, date : Bool, files : Bool }
176 , lanes : Int, author : Bool, date : Bool, files : Bool }
160 -> Repo -> Text -- the history as a tree; see the spec
177 -> Repo -> Text -- the history as a tree; see the spec
@@ -194,8 +211,11 @@ top = \repo -> top (up repo) or repo
194
211
195-- Every commit of a subtree (or of the focused subtree of a Repo), in preorder.
212-- Every commit of a subtree (or of the focused subtree of a Repo), in preorder.
196-- Takes anything with `root` and `children`, so the signature is left open.
213-- 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.
197commits : a -> [Commit]
217commits : a -> [Commit]
198−commits = \t -> t.root :: (concat (map commits t.children) or [])
218+commits = subtreeCommits
199
219
200
220
201------------------------------------------------------------------------------
221------------------------------------------------------------------------------
@@ -239,6 +259,9 @@ restrict = \m -> filter (\e -> m e.path)
239select : Fileset -> Snapshot -> Snapshot -> Snapshot -- m from a, the rest from b
259select : Fileset -> Snapshot -> Snapshot -> Snapshot -- m from a, the rest from b
240select = \m a b -> restrict m a ++ restrict (neg m) b
260select = \m a b -> restrict m a ++ restrict (neg m) b
241
261
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`.
242conflicted : Snapshot -> [Path]
265conflicted : Snapshot -> [Path]
243conflicted = \s -> map (.path) (filter (\e -> unresolved e.content) s)
266conflicted = \s -> map (.path) (filter (\e -> unresolved e.content) s)
244
267
@@ -287,7 +310,7 @@ descendants : Revset -- includes the
287descendants = \repo -> map (.id) (commits repo)
310descendants = \repo -> map (.id) (commits repo)
288
311
289ancestors : Revset -- includes the focus
312ancestors : 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
291
314
292siblings : Revset
315siblings : Revset
293siblings = \repo -> filter (\i -> i /= repo.root.id) (kids (up repo)) or []
316siblings = \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
323matching : (Commit -> Bool) -> Revset -> Revset
346matching : (Commit -> Bool) -> Revset -> Revset
324matching = \p rs repo -> filter (\i -> p (commitAt i repo)) (rs repo)
347matching = \p rs repo -> filter (\i -> p (commitAt i repo)) (rs repo)
325
348
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.
326conflicts : Revset
354conflicts : Revset
327conflicts = matching (\c -> not (null (conflicted c.files))) all
355conflicts = matching (\c -> not (null (conflicted c.files))) all
328
356
@@ -331,7 +359,10 @@ firstOf : [Revset] -> Revset
331firstOf = \rss repo -> foldl (\acc rs -> if null acc then rs repo else acc) [] rss
359firstOf = \rss repo -> foldl (\acc rs -> if null acc then rs repo else acc) [] rss
332
360
333-- The main line: whichever of these names the remote has, first one wins.
361-- 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'.
335trunk : Revset
366trunk : Revset
336trunk = firstOf [%main %master %trunk]
367trunk = firstOf [%main %master %trunk]
337
368
@@ -347,21 +378,33 @@ immutable = ancestorsOf trunk
347-- Focusing. `goto` is the only place a revset must have exactly one member.
378-- Focusing. `goto` is the only place a revset must have exactly one member.
348------------------------------------------------------------------------------
379------------------------------------------------------------------------------
349
380
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.
350goto : Revset -> Edit
383goto : Revset -> Edit
351goto = \rs repo ->
384goto = \rs repo ->
352 let is = rs repo
385 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"
353 in if length is == 1
390 in if length is == 1
354 then by (head is) repo
391 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.")
356
395
357commitAt : Id -> Repo -> Commit
396commitAt : Id -> Repo -> Commit
358−commitAt = \i repo -> (by i repo).root
397+commitAt = lookupCommit
359
398
360prev : Edit -- the parent
399prev : Edit -- the parent
361prev = goto parents
400prev = goto parents
362
401
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`.
363−next : Edit -- the only child
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"
364−next = goto kids
407+ else goto kids repo
365
408
366tip : Edit -- end of the line of only-children
409tip : Edit -- end of the line of only-children
367tip = \repo -> tip (next repo) or repo
410tip = \repo -> tip (next repo) or repo
@@ -387,6 +430,8 @@ eachChild = forEach kids
387guard : (Repo -> Bool) -> Edit -- crash unless p holds
430guard : (Repo -> Bool) -> Edit -- crash unless p holds
388guard = \p repo -> if p repo then repo else crash "guard"
431guard = \p repo -> if p repo then repo else crash "guard"
389
432
433+-- Whether the focused commit has no unresolved files. This says nothing
434+-- about unrecorded working-copy edits; inspect those with `j unrecorded`.
390clean : Repo -> Bool
435clean : Repo -> Bool
391clean = \repo -> null (conflicted repo.root.files)
436clean = \repo -> null (conflicted repo.root.files)
392
437
@@ -425,11 +470,28 @@ rewrite = \f repo ->
425-- Commands
470-- Commands
426------------------------------------------------------------------------------
471------------------------------------------------------------------------------
427
472
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+
428-- Set the message of the focused commit.
488-- Set the message of the focused commit.
489+-- Example: j 'describe "fix login"'
429describe : Text -> Edit
490describe : Text -> Edit
430describe = \m -> mapRoot (\c -> c { message = m })
491describe = \m -> mapRoot (\c -> c { message = m })
431
492
432-- Start an empty child of the focus and move to it.
493-- Start an empty child of the focus and move to it.
494+-- Examples: j new; j 'describe "next change" . new'
433new : Edit
495new : Edit
434new = \repo -> addChild (newCommit repo.root.files) repo
496new = \repo -> addChild (newCommit repo.root.files) repo
435
497
@@ -437,6 +499,7 @@ new = \repo -> addChild (newCommit repo.root.files) repo
437-- new empty commit on that parent becomes the focus (the parent may be
499-- new empty commit on that parent becomes the focus (the parent may be
438-- immutable, and the focus never is). Refused for a commit the remote has a
500-- immutable, and the focus never is). Refused for a commit the remote has a
439-- name for: push something else under that name first, or forget the name.
501-- 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'.
440abandon : Edit
503abandon : Edit
441abandon = \repo ->
504abandon = \repo ->
442 let c = repo.root
505 let c = repo.root
@@ -446,17 +509,20 @@ abandon = \repo ->
446 else new (remove (eachChild (replayOnto p) repo))
509 else new (remove (eachChild (replayOnto p) repo))
447
510
448-- Push the files matching m from the focus down into its parent.
511-- Push the files matching m from the focus down into its parent.
512+-- Example: j 'contract (ext "rs")'
449contract : Fileset -> Edit
513contract : Fileset -> Edit
450contract = \m repo -> let a = repo.root
514contract = \m repo -> let a = repo.root
451 in at parents (rewrite (\q -> q { files = select m a.files q.files })) repo
515 in at parents (rewrite (\q -> q { files = select m a.files q.files })) repo
452
516
453-- Fold the focus entirely into its parent; a new empty commit becomes the focus.
517-- Fold the focus entirely into its parent; a new empty commit becomes the focus.
518+-- Example: j squash; preview first with j 'preview squash'.
454squash : Edit
519squash : Edit
455squash = abandon . contract everything
520squash = abandon . contract everything
456
521
457-- Push the files matching m from the focus up into a new child, which becomes
522-- Push the files matching m from the focus up into a new child, which becomes
458-- the focus and inherits the old focus's children. Labels stay with the
523-- the focus and inherits the old focus's children. Labels stay with the
459-- original commit, since they are the remote's.
524-- original commit, since they are the remote's.
525+-- Example: j 'split (ext "rs")'; use (under ./src) to select a directory.
460split : Fileset -> Edit
526split : Fileset -> Edit
461split = \m repo ->
527split = \m repo ->
462 let a = repo.root
528 let a = repo.root
@@ -466,6 +532,7 @@ split = \m repo ->
466 in by x.id (repo { root = a', children = [{ root = x, children = repo.children }] })
532 in by x.id (repo { root = a', children = [{ root = x, children = repo.children }] })
467
533
468-- Move the focused subtree onto another commit. Returns to the moved commit.
534-- Move the focused subtree onto another commit. Returns to the moved commit.
535+-- Example: j 'rebase trunk'; preview with j 'preview (rebase trunk)'.
469rebase : Revset -> Edit
536rebase : Revset -> Edit
470rebase = \rs repo ->
537rebase = \rs repo ->
471 let d = (goto rs repo).root
538 let d = (goto rs repo).root
@@ -476,6 +543,7 @@ rebase = \rs repo ->
476 in by me (attach x.subtree (by d.id x.rest))
543 in by me (attach x.subtree (by d.id x.rest))
477
544
478-- Apply the focused commit's change onto another commit, as a new child there.
545-- Apply the focused commit's change onto another commit, as a new child there.
546+-- Example: j 'pick %main'
479pick : Revset -> Edit
547pick : Revset -> Edit
480pick = \rs repo ->
548pick = \rs repo ->
481 let ch = changeOf repo
549 let ch = changeOf repo
@@ -483,6 +551,7 @@ pick = \rs repo ->
483 in at rs (addChild (newCommit (replay t.files ch))) repo
551 in at rs (addChild (newCommit (replay t.files ch))) repo
484
552
485-- A new child of the focus that undoes the change of the given commit.
553-- A new child of the focus that undoes the change of the given commit.
554+-- Example: j 'backout %feature'
486backout : Revset -> Edit
555backout : Revset -> Edit
487backout = \rs repo ->
556backout = \rs repo ->
488 let ch = changeOf (goto rs repo)
557 let ch = changeOf (goto rs repo)
@@ -528,38 +597,53 @@ entryAt = \p s -> filter (\e -> e.path == p) s
528
597
529-- The content at path p, or an empty file if absent.
598-- The content at path p, or an empty file if absent.
530contentAt : Path -> Snapshot -> Blob
599contentAt : Path -> Snapshot -> Blob
531−contentAt = \p s -> (head (entryAt p s)).content or blob ""
600+contentAt = lookupContent
532
601
533-- The paths a change touches.
602-- The paths a change touches.
534touched : Change -> [Path]
603touched : Change -> [Path]
535touched = touchedPaths
604touched = touchedPaths
536
605
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
538changed : Repo -> [Path]
610changed : Repo -> [Path]
539changed = \repo -> touched (changeOf repo)
611changed = \repo -> touched (changeOf repo)
540
612
541−-- What the focus changes, rendered by difftastic, one record per changed path.
613+-- What the focus changes, one record per changed path. Uses difftastic when
542−-- Replace difft with (\p -> diff) to use the built-in unified diff instead.
614+-- installed; a missing difft falls back to a unified diff with path headers
615+-- and 3 context lines, or a binary-files summary.
543diffs : Repo -> [{ path : Path, diff : Text }]
616diffs : Repo -> [{ path : Path, diff : Text }]
544diffs = \repo ->
617diffs = \repo ->
545 let ch = changeOf repo
618 let ch = changeOf repo
546 in map (\p -> { path = p, diff = difft p (contentAt p ch.from) (contentAt p ch.to) }) (touched ch)
619 in map (\p -> { path = p, diff = difft p (contentAt p ch.from) (contentAt p ch.to) }) (touched ch)
547
620
548-- The same, as one page of text. A Text result prints raw, so `j review` is
621-- 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.
550review : Repo -> Text
627review : Repo -> Text
551−review = \repo -> concat (map (\d -> d.diff) (diffs repo)) or "no changes\n"
628+review = \repo ->
552−
629+ let ds = diffs repo
630+ in if null ds then "no changes\n" else concat (map (\d -> d.diff) ds)
631+
553−-- A summary of the focus.
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
554−status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path] }
636+status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path], unrecorded : [Path] }
555status = \repo ->
637status = \repo ->
556 { id = repo.root.id
638 { id = repo.root.id
557 , message = repo.root.message
639 , message = repo.root.message
558 , labels = repo.root.labels
640 , labels = repo.root.labels
559 , changed = changed repo
641 , changed = changed repo
560− , conflicts = conflicted repo.root.files }
642+ , conflicts = conflicted repo.root.files
643+ , unrecorded = unrecorded repo }
561
644
562-- A summary of every commit, top first, in preorder.
645-- A summary of every commit, top first, in preorder.
646+-- Example: j log
563log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]
647log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]
564log = \repo ->
648log = \repo ->
565 map (\c -> { id = c.id
649 map (\c -> { id = c.id
@@ -572,6 +656,7 @@ log = \repo ->
572-- (The parentheses are required: `f { … }` would be a record update of f.)
656-- (The parentheses are required: `f { … }` would be a record update of f.)
573-- The default `tree` shows every commit, sorted by time (parents always above
657-- The default `tree` shows every commit, sorted by time (parents always above
574-- their children); `treeCompact` folds uninteresting runs and far subtrees.
658-- their children); `treeCompact` folds uninteresting runs and far subtrees.
659+-- Examples: j tree; j 'tree . squash' previews an edit without saving it.
575tree : Repo -> Text
660tree : Repo -> Text
576tree = treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
661tree = treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
577
662
docs/base.md+187−46
@@ -47,6 +47,13 @@ user = { name = "Your Name", email = "you@example.com" }
47
47
48This is a plain value, not a function. Its signature is checked once, at load
48This is a plain value, not a function. Its signature is checked once, at load
49(§4.13): if you write `user = 5`, `j` refuses to start (exit 3).
49(§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.
50
57
51### 1.2 Paths, labels, entries, snapshots
58### 1.2 Paths, labels, entries, snapshots
52
59
@@ -178,6 +185,34 @@ id 42 => 42
178edit or id -- run edit, keep the repository if it crashes
185edit or id -- run edit, keep the repository if it crashes
179```
186```
180
187
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+
181### `const` — constant function
216### `const` — constant function
182
217
183```
218```
@@ -256,12 +291,14 @@ not : Bool -> Bool
256```
291```
257
292
258`&&` and `||` short-circuit (the right operand is not evaluated if the left
293`&&` 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.
260
297
261```
298```
2621 == 1 && 2 == 2 => true
2991 == 1 && 2 == 2 => true
2631 == 2 || 3 == 3 => true
3001 == 2 || 3 == 3 => true
264−not (1 == 2) => false
301+not (1 == 2) => true
265not true => false
302not true => false
266```
303```
267
304
@@ -301,18 +338,24 @@ Defined on `Int` only; crash on anything else. Non-associative (cannot chain:
301show : a -> Text
338show : a -> Text
302```
339```
303
340
304−Renders any value in the language's literal syntax, such that (except for
341+When it succeeds, `show` renders a value in the language's literal syntax.
305−closures and unresolved blobs) the text parses and evaluates back to an equal
342+Except for closures and unresolved blobs, the text parses and evaluates back
306−value. A top-level `Text` prints raw, so `j show …` is how you inspect values.
343+to an equal value. A top-level `Text` prints raw, so `j show …` is how you inspect values.
307See §5.2 of the spec for the exact rendering.
344See §5.2 of the spec for the exact rendering.
308
345
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+
309```
352```
310show 42 => "42"
353show 42 => "42"
311show "hi" => "\"hi\""
354show "hi" => "\"hi\""
312show [1 "a" true] => "[1 \"a\" true]"
355show [1 "a" true] => "[1 \"a\" true]"
313show ({ x = 1 }) => "{ x = 1 }"
356show ({ x = 1 }) => "{ x = 1 }"
314show (describe "wip") => "describe \"wip\""
357show (describe "wip") => "describe \"wip\""
315−show (\x -> x + 1) => "\x -> (x + 1)"
358+show (\x -> x + 1) => "\\x -> x + 1"
316```
359```
317
360
318---
361---
@@ -415,8 +458,14 @@ replay : Snapshot -> Change -> Snapshot
415```
458```
416
459
417`replay onto ch` computes "`onto`, with the change from `ch.from` to `ch.to`
460`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
461+applied." Where the change collides with what is already there, the path's
419−there, the path's blob becomes *unresolved* (a conflict) rather than an error.
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.
420
469
421```
470```
422-- onto has "base", change takes "base" -> "theirs": no collision
471-- onto has "base", change takes "base" -> "theirs": no collision
@@ -435,11 +484,12 @@ unresolved ((head (replay [({ path = ./f, content = blob "onto" })]
435Two laws to internalise it: `replay b { from = b, to = x } = x` and
484Two laws to internalise it: `replay b { from = b, to = x } = x` and
436`replay x { from = b, to = b } = x`.
485`replay x { from = b, to = b } = x`.
437
486
438−### `unresolved`, `blob`, `text` — blob primitives
487+### `unresolved`, `blob`, `blobHex`, `text` — blob primitives
439
488
440```
489```
441unresolved : Blob -> Bool
490unresolved : Blob -> Bool
442blob : Text -> Blob
491blob : Text -> Blob
492+blobHex : Text -> Text -> Blob
443text : Blob -> Text
493text : Blob -> Text
444```
494```
445
495
@@ -447,9 +497,16 @@ text : Blob -> Text
447blob's content (its conflict-marker rendering if unresolved). `unresolved b`
497blob's content (its conflict-marker rendering if unresolved). `unresolved b`
448is `true` iff the blob is a conflict.
498is `true` iff the blob is a conflict.
449
499
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+
450```
506```
451blob "content" => blob "content"
507blob "content" => blob "content"
452text (blob "content") => "content"
508text (blob "content") => "content"
509+text (blobHex "regular" "6869") => "hi"
453unresolved (blob "x") => false
510unresolved (blob "x") => false
454```
511```
455
512
@@ -467,6 +524,24 @@ no such visible commit. Backed by the change-id index. Applied to one argument
467by @wqzt -- an edit: refocus on the commit @wqzt
524by @wqzt -- an edit: refocus on the commit @wqzt
468```
525```
469
526
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+
470### `meta` — commit metadata
545### `meta` — commit metadata
471
546
472```
547```
@@ -482,15 +557,20 @@ meta @wqzt => { hash = "74bee64e…", author = "Test User",
482 email = "test@example.com", time = 1789457265 }
557 email = "test@example.com", time = 1789457265 }
483```
558```
484
559
485−### `validate` — dry-run persistence
560+### `validate` — check whether persistence would succeed
486
561
487```
562```
488validate : Repo -> Repo
563validate : Repo -> Repo
489```
564```
490
565
491Returns the repo unchanged if persisting it would succeed; otherwise crashes
566Returns the repo unchanged if persisting it would succeed; otherwise crashes
492−with exactly the error persistence would produce (shape, labels, immutability,
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.
493−focus). `tree . validate . rebase trunk` is an honest dry run.
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.
494
574
495### `diff` and `difft` — rendering differences
575### `diff` and `difft` — rendering differences
496
576
@@ -508,25 +588,34 @@ diff (blob "a\nb\n") (blob "a\nc\n")
508```
588```
509
589
510`difft p a b` renders the comparison with **difftastic**, treating the input
590`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`
591+as the file `p` (its last component guides language detection), using options
512−on `PATH` (the Nix package provides it); options via `DFT_*` environment
592+from `DFT_*` environment variables. The Nix package provides `difft`.
513−variables. Crashes naming the tool if absent.
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.
514
598
515### `treeWith` — the history tree
599### `treeWith` — the history tree
516
600
517```
601```
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 }
519 -> Repo -> Text
604 -> Repo -> Text
520```
605```
521
606
522Renders the history as a tree with an options record. The reference config
607Renders 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).
524
609
525```
610```
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 })
527 -- a full tree with ages and authors in the margin
612 -- a full tree with ages and authors in the margin
528```
613```
529
614
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+
530### `extract` — every subvalue of a shape
619### `extract` — every subvalue of a shape
531
620
532```
621```
@@ -588,7 +677,9 @@ top = \repo -> top (up repo) or repo
588```
677```
589
678
590Moves the focus all the way to the top of history (where `context = []`). The
679Moves 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.
592
683
593```
684```
594top -- refocus at the very first commit
685top -- refocus at the very first commit
@@ -599,13 +690,16 @@ commits . top -- every commit, preorder
599
690
600```
691```
601commits : a -> [Commit]
692commits : a -> [Commit]
602−commits = \t -> t.root :: (concat (map commits t.children) or [])
693+commits = subtreeCommits
603```
694```
604
695
605Every commit of a subtree (or of the focused subtree of a `Repo`), in
696Every commit of a subtree (or of the focused subtree of a `Repo`), in
606preorder. Takes anything with `root` and `children`, so the signature is left
697preorder. Takes anything with `root` and `children`, so the signature is left
698+open. It is the builtin `subtreeCommits` (§5), which computes
699+`\t -> t.root :: (concat (map commits t.children) or [])` in one pass. (There
607−open. The `or []` handles a leaf (whose `children` is empty, so `concat`
700+the `or []` handles a leaf, whose `children` is empty, so `concat` crashes and
608−crashes and yields `[]`).
701+yields `[]`.) Written in the language, it copies the list below every commit,
702+and takes time quadratic in the length of the history.
609
703
610```
704```
611length . commits . top -- how many commits are visible?
705length . commits . top -- how many commits are visible?
@@ -766,26 +860,28 @@ is `new . goto trunk`, never `goto trunk` alone.
766
860
767**`goto : Revset -> Edit`** — refocus on *the one commit* a revset names. This
861**`goto : Revset -> Edit`** — refocus on *the one commit* a revset names. This
768is the only place a revset must have exactly one member; otherwise it crashes
862is the only place a revset must have exactly one member; otherwise it crashes
769−with `expected one revision, got N`.
863+with `expected one revision, got N` and recovery guidance. An empty selector
770−
864+points to `j tree` and remote/fetch setup; an ambiguous selector shows up to
771−```
865+eight candidate ids and descriptions, with commands to select a mutable commit
772−goto = \rs repo ->
866+or create a child. Candidate ids are complete copyable id literals.
773− let is = rs repo
774− in if length is == 1 then by (head is) repo
775− else crash ("expected one revision, got " ++ show (length is))
776−```
777
867
778```
868```
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
780goto parents -- == prev
871goto parents -- == prev
781```
872```
782
873
783**`commitAt : Id -> Repo -> Commit`** — the commit with a given id.
874**`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`.
784
878
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.
786
882
787−**`next : Edit`** — to the only child (`goto kids`; crashes if the focus has
883+**`next : Edit`** — to the only child. At a tip it suggests `j new`; at a fork
788−other than exactly one child).
884+it lists candidates to choose with `by`.
789
885
790**`tip : Edit`** — the end of the line of only-children (`tip = \repo -> tip
886**`tip : Edit`** — the end of the line of only-children (`tip = \repo -> tip
791(next repo) or repo`).
887(next repo) or repo`).
@@ -969,6 +1065,9 @@ Crashes at the top.
969
1065
970`j push EXPR` evaluates `EXPR` to a list of `Push` and `Drop` records. These
1066`j push EXPR` evaluates `EXPR` to a list of `Push` and `Drop` records. These
971functions build such lists.
1067functions 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.
972
1071
973**`label : Label -> Revset -> Repo -> [Push]`** — label every commit of a
1072**`label : Label -> Revset -> Repo -> [Push]`** — label every commit of a
974revset.
1073revset.
@@ -1001,7 +1100,8 @@ j 'push relabel' -- after rewriting, re-point the remote's bookmarks
1001
1100
1002## 10. Inspection
1101## 10. Inspection
1003
1102
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.
1005
1105
1006**`focus : Repo -> Commit`** — the focused commit.
1106**`focus : Repo -> Commit`** — the focused commit.
1007
1107
@@ -1016,6 +1116,20 @@ j 'files' -- the working-copy commit's files
1016
1116
1017**`contentAt : Path -> Snapshot -> Blob`** — the content at a path, or an
1117**`contentAt : Path -> Snapshot -> Blob`** — the content at a path, or an
1018empty file if absent.
1118empty 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.
1019
1133
1020```
1134```
1021contentAt ./a [({ path = ./a, content = blob "x" })]
1135contentAt ./a [({ path = ./a, content = blob "x" })]
@@ -1028,42 +1142,64 @@ contentAt ./missing [] => blob ""
1028**`changed : Repo -> [Path]`** — paths whose content differs between the focus
1142**`changed : Repo -> [Path]`** — paths whose content differs between the focus
1029and its parent.
1143and its parent.
1030
1144
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+
1031**`diffs : Repo -> [{ path : Path, diff : Text }]`** — what the focus changes,
1151**`diffs : Repo -> [{ path : Path, diff : Text }]`** — what the focus changes,
1032−rendered by difftastic, one record per changed path. (Replace `difft` with
1152+rendered by difftastic, one record per changed path, with unified diffs as
1033−`(\p -> diff)` to use the built-in unified diff instead.)
1153+the fallback when difftastic is missing.
1034
1154
1035−**`review : Repo -> Text`** — the same, as one page of text. A `Text` result
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,
1036−prints raw, so `j review` is readable in the terminal.
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.
1037
1160
1038```
1161```
1039j 'review' -- the focus's changes, as difftastic renders them
1162j 'review' -- the focus's changes, as difftastic renders them
1040```
1163```
1041
1164
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.
1043
1166
1044```
1167```
1045j 'status'
1168j 'status'
1046-- id @wqzt…
1169-- id @wqzt…
1047-- message add the lexer
1170-- message add the lexer
1048-- labels [feature]
1171-- labels [feature]
1049−-- changed [src/lexer.rs]
1172+-- in commit [src/lexer.rs]
1050−-- conflicts []
1173+-- conflicts No conflicts
1174+-- unrecorded All edits recorded
1051```
1175```
1052
1176
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+
1053**`log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]`**
1184**`log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]`**
1054— a summary of every commit, top first, in preorder (`focus` marks the focused
1185— 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.
1056
1189
1057**`tree : Repo -> Text`** and **`treeFull : Repo -> Text`** — the history as a
1190**`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`);
1059`treeFull` shows ages and authors in the margin (`detail = 2`, `margin =
1192`treeFull` shows ages and authors in the margin (`detail = 2`, `margin =
1060true`). Edit these definitions to change what `j tree` shows.
1193true`). Edit these definitions to change what `j tree` shows.
1061
1194
1062```
1195```
1063−tree = treeWith ({ detail = 1, margin = false, elide = true, 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 })
1064−treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
1197+treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
1065```
1198```
1066
1199
1200+Use `treeCompact` to fold uninteresting runs and distant subtrees, or
1201+`treeData` for extra date, files-changed, and author columns.
1202+
1067```
1203```
1068j 'tree'
1204j 'tree'
1069-- ⌂ zzzz
1205-- ⌂ zzzz
@@ -1093,6 +1229,7 @@ j 'tree'
1093| `attach` | `Subtree -> Edit` | §7.1 |
1229| `attach` | `Subtree -> Edit` | §7.1 |
1094| `backout` | `Revset -> Edit` | §8 |
1230| `backout` | `Revset -> Edit` | §8 |
1095| `blob` | `Text -> Blob` | §5 |
1231| `blob` | `Text -> Blob` | §5 |
1232+| `blobHex` | `Text -> Text -> Blob` | §5 |
1096| `both` | `Fileset -> Fileset -> Fileset` | §7.2 |
1233| `both` | `Fileset -> Fileset -> Fileset` | §7.2 |
1097| `by` | `Id -> Repo -> Repo` | §5 |
1234| `by` | `Id -> Repo -> Repo` | §5 |
1098| `changeOf` | `Repo -> Change` | §7.2 |
1235| `changeOf` | `Repo -> Change` | §7.2 |
@@ -1162,6 +1299,7 @@ j 'tree'
1162| `parents` | `Revset` | §7.3 |
1299| `parents` | `Revset` | §7.3 |
1163| `pick` | `Revset -> Edit` | §8 |
1300| `pick` | `Revset -> Edit` | §8 |
1164| `prev` | `Edit` | §7.4 |
1301| `prev` | `Edit` | §7.4 |
1302+| `preview` | `Edit -> Repo -> Text` | §2 |
1165| `range` | `Int -> Int -> [Int]` | §4 |
1303| `range` | `Int -> Int -> [Int]` | §4 |
1166| `rebase` | `Revset -> Edit` | §8 |
1304| `rebase` | `Revset -> Edit` | §8 |
1167| `relabel` | `Repo -> [Push]` | §9 |
1305| `relabel` | `Repo -> [Push]` | §9 |
@@ -1173,6 +1311,7 @@ j 'tree'
1173| `restrict` | `Fileset -> Snapshot -> Snapshot` | §7.2 |
1311| `restrict` | `Fileset -> Snapshot -> Snapshot` | §7.2 |
1174| `review` | `Repo -> Text` | §10 |
1312| `review` | `Repo -> Text` | §10 |
1175| `rewrite` | `(Commit -> Commit) -> Edit` | §8 |
1313| `rewrite` | `(Commit -> Commit) -> Edit` | §8 |
1314+| `save` | `Edit` | §2 |
1176| `select` | `Fileset -> Snapshot -> Snapshot -> Snapshot` | §7.2 |
1315| `select` | `Fileset -> Snapshot -> Snapshot -> Snapshot` | §7.2 |
1177| `show` | `a -> Text` | §3 |
1316| `show` | `a -> Text` | §3 |
1178| `siblings` | `Revset` | §7.3 |
1317| `siblings` | `Revset` | §7.3 |
@@ -1182,6 +1321,7 @@ j 'tree'
1182| `stack` | `Revset` | §7.3 |
1321| `stack` | `Revset` | §7.3 |
1183| `startsWith` | `Text -> Text -> Bool` | §4 |
1322| `startsWith` | `Text -> Text -> Bool` | §4 |
1184| `status` | `Repo -> {…}` | §10 |
1323| `status` | `Repo -> {…}` | §10 |
1324+| `subtreeCommits` | `a -> [Commit]` | §5 |
1185| `tail` | `[a] -> [a]` | §4 |
1325| `tail` | `[a] -> [a]` | §4 |
1186| `take` | `Int -> [a] -> [a]` | §4 |
1326| `take` | `Int -> [a] -> [a]` | §4 |
1187| `text` | `Blob -> Text` | §5 |
1327| `text` | `Blob -> Text` | §5 |
@@ -1195,6 +1335,7 @@ j 'tree'
1195| `under` | `Path -> Fileset` | §7.2 |
1335| `under` | `Path -> Fileset` | §7.2 |
1196| `union` | `Revset -> Revset -> Revset` | §7.3 |
1336| `union` | `Revset -> Revset -> Revset` | §7.3 |
1197| `unlabel` | `Label -> [Drop]` | §9 |
1337| `unlabel` | `Label -> [Drop]` | §9 |
1338+| `unrecorded` | `Repo -> [Path]` | §10 |
1198| `unresolved` | `Blob -> Bool` | §5 |
1339| `unresolved` | `Blob -> Bool` | §5 |
1199| `up` | `Edit` | §6 |
1340| `up` | `Edit` | §6 |
1200| `user` | `{ name : Text, email : Text }` | §1.1 |
1341| `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`,
75`j push` takes an *expression* rather than flags: `j 'push (label "feature"
75`j push` takes an *expression* rather than flags: `j 'push (label "feature"
76here)'` labels the focused commit, `j 'push relabel'` re-points the remote's
76here)'` labels the focused commit, `j 'push relabel'` re-points the remote's
77bookmarks after a rewrite.
77bookmarks 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.
78
80
79### 3.2 Recording work
81### 3.2 Recording work
80
82
81| Task | git | jj | j |
83| Task | git | jj | j |
82|---|---|---|---|
84|---|---|---|---|
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 |
84| Set the message | `git commit -m "…"` | `jj describe -m "…"` or `jj describe` | `j 'describe "…"'` |
86| Set the message | `git commit -m "…"` | `jj describe -m "…"` or `jj describe` | `j 'describe "…"'` |
85| Start the next change | (just keep editing) | `jj new` | `j new` |
87| Start the next change | (just keep editing) | `jj new` | `j new` |
86| Amend the current commit | `git commit --amend` | `jj describe` + edit files in `@` | edit files + `j 'describe "…"'` |
88| Amend the current commit | `git commit --amend` | `jj describe` + edit files in `@` | edit files + `j 'describe "…"'` |
87| See what changed | `git diff` / `git show` | `jj diff` / `jj show` | `j review` (via difftastic) / `j status` |
89| See what changed | `git diff` / `git show` | `jj diff` / `jj show` | `j review` (via difftastic) / `j status` |
88
90
89−In git, "commit" is a deliberate act with a staging step. In jj and `j`, the
91+In git, "commit" is a deliberate act with a staging step. jj snapshots the
90−snapshot is continuous; `describe` sets a message and `new` opens the next
92+working copy automatically. In `j`, expressions see current file edits,
91−change. `j` has one deliberate-snapshot command, `j id`, for when you want to
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
92−record the working directory and nothing else.
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.
93
99
94### 3.3 Looking around
100### 3.3 Looking around
95
101
@@ -116,7 +122,7 @@ zipper with edits.
116|---|---|---|---|
122|---|---|---|---|
117| To the parent | `git checkout HEAD~1` | `jj prev` *(≈, opens a new empty commit there)* | `j prev` |
123| To the parent | `git checkout HEAD~1` | `jj prev` *(≈, opens a new empty commit there)* | `j prev` |
118| To the child | `git checkout <sha>` (manual) | `jj next` *(≈, opens a new empty commit there)* | `j next` |
124| 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'` |
120| To a specific change | `git checkout <sha>` | `jj new <change-id>` | `j 'by @wqzt'` |
126| To a specific change | `git checkout <sha>` | `jj new <change-id>` | `j 'by @wqzt'` |
121| Back to your branch | `git checkout main` | `jj new main` | `j 'new . goto trunk'` |
127| Back to your branch | `git checkout main` | `jj new main` | `j 'new . goto trunk'` |
122
128
@@ -201,14 +207,16 @@ the remote's state explicit and impossible to mutate by accident.
201| When do conflicts appear? | During merge/rebase, blocking it | Recorded *in* the commit, non-blocking | Recorded in the commit (unresolved `Blob`s) |
207| When do conflicts appear? | During merge/rebase, blocking it | Recorded *in* the commit, non-blocking | Recorded in the commit (unresolved `Blob`s) |
202| Where do they live? | The working tree + index | In the commit itself | In the commit's `files`, as conflict blobs |
208| Where do they live? | The working tree + index | In the commit itself | In the commit's `files`, as conflict blobs |
203| How do you see them? | `git status` | `jj st`, `jj resolve` | `j conflicts`, `j status` |
209| 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` |
205
211
206git stops the world on a conflict: a merge or rebase halts until you resolve
212git stops the world on a conflict: a merge or rebase halts until you resolve
207and `git add`. jj and `j` treat a conflict as *data* — a commit can simply
213and `git add`. jj and `j` treat a conflict as *data* — a commit can simply
208*contain* conflicted files, you can keep working, and you resolve whenever you
214*contain* conflicted files, you can keep working, and you resolve whenever you
209like. `j conflicts` lists the commits that have conflicts; checking one out
215like. `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
216+writes the markers to disk; editing the files and running `j save` records
211−resolution.
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.
212
220
213---
221---
214
222
@@ -227,6 +235,12 @@ a first-class, marked operation, so `undo`/`redo` are safe and precise. In
227`j`, undoing a `push` restores the local labels (and leaves the remote
235`j`, undoing a `push` restores the local labels (and leaves the remote
228untouched).
236untouched).
229
237
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+
230---
244---
231
245
232## 9. The interface itself
246## 9. The interface itself
@@ -296,6 +310,8 @@ j new
296# …edit…
310# …edit…
297j 'describe "part two"'
311j 'describe "part two"'
298j squash
312j squash
313+j prev # squash leaves an empty child; return to the combined commit
314+j 'describe "complete feature"'
299j 'rebase %main'
315j 'rebase %main'
300j 'push (label "feature" here)'
316j 'push (label "feature" here)'
301```
317```
docs/language.md+91−25
@@ -3,7 +3,14 @@
3`j` is a small, pure, functional language. Every `j` command evaluates one
3`j` is a small, pure, functional language. Every `j` command evaluates one
4expression in this language against the current repository. The language is
4expression in this language against the current repository. The language is
5the whole interface: there are no subcommands beyond a handful of reserved
5the 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.
7
14
8This document describes the language itself — its values, syntax, semantics,
15This document describes the language itself — its values, syntax, semantics,
9and the builtins — with examples throughout. Every example shows an expression
16and the builtins — with examples throughout. Every example shows an expression
@@ -17,16 +24,17 @@ and what it evaluates to, exactly as `j` prints it.
17
24
18## 1. The big picture
25## 1. The big picture
19
26
20−A run of `j` looks like this:
27+An expression run of `j` looks like this:
21
28
221. Read the expression (from the command line or stdin).
291. 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.
243. Build a value `repo` describing the repository, focused on the
313. Build a value `repo` describing the repository, focused on the
25 working-copy commit.
32 working-copy commit.
264. Evaluate the expression.
334. Evaluate the expression.
275. If the result is a function, apply it to `repo`.
345. If the result is a function, apply it to `repo`.
286. If the final value is a `Repo` that differs from the one loaded, persist
356. 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.
30
38
31So the most common thing you write is an **edit** — a function `Repo -> Repo`
39So the most common thing you write is an **edit** — a function `Repo -> Repo`
32(or a value that becomes one). `describe "wip"` is an edit. `new` is an edit.
40(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 }
172> (\r -> r.name) ({ name = "j" }) -- OK
180> (\r -> r.name) ({ name = "j" }) -- OK
173> ```
181> ```
174>
182>
175−> The same applies inside lists: `[{ root = c, children = [] }]` — write the
183+> The same applies inside lists: a record literal that follows another
176−> braces as a parenthesised element `[(…)]` if it follows another element.
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.
177
197
178### Functions
198### Functions
179
199
@@ -214,7 +234,8 @@ distinct id each time it is evaluated (see §7).
214
234
215A `Blob` is the content of one file, either resolved or unresolved (a
235A `Blob` is the content of one file, either resolved or unresolved (a
216conflict). You rarely write them directly; you get them from a commit's
236conflict). You rarely write them directly; you get them from a commit's
237+`files`. It carries the file kind as well as its bytes. `blob "…"` builds a
217−`files`. `blob "…"` builds a resolved one, `text` reads it back:
238+resolved regular file from text; `text` reads it back:
218
239
219```
240```
220text (blob "file content")
241text (blob "file content")
@@ -265,9 +286,10 @@ in even 10
265> binding column.
286> binding column.
266
287
267Because evaluation is strict (call by value), a non-function binding is
288Because 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
289+evaluated immediately, though after the bindings it needs, whatever order
269−when `a` is evaluated); recursion works through *functions*, whose bodies
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
270−aren't evaluated until applied.
292+*functions*, whose bodies aren't evaluated until applied.
271
293
272### No shadowing
294### No shadowing
273
295
@@ -279,6 +301,12 @@ This is an error, not silent shadowing:
279let x = 1 in let f = \x -> x in f 2 -- parse error: `x` is already bound
301let x = 1 in let f = \x -> x in f 2 -- parse error: `x` is already bound
280```
302```
281
303
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+
282### Lexical scope
310### Lexical scope
283
311
284Names resolve lexically: innermost `let` bindings, then lambda parameters,
312Names resolve lexically: innermost `let` bindings, then lambda parameters,
@@ -354,7 +382,7 @@ A bare `.name` is the accessor function `\x -> x.name`:
354```
382```
355.name ({ name = "j" })
383.name ({ name = "j" })
356 => "j"
384 => "j"
357−map (.id) ([{ id = 1 } { id = 2 }])
385+map (.id) [({ id = 1 }) ({ id = 2 })]
358 => [1 2]
386 => [1 2]
359```
387```
360
388
@@ -533,6 +561,9 @@ crash "my message"
533
561
534Parse errors, configuration errors, and `Id`-resolution failures happen
562Parse errors, configuration errors, and `Id`-resolution failures happen
535*before* evaluation and are **not** catchable.
563*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.
536
567
537---
568---
538
569
@@ -616,6 +647,13 @@ unresolved (blob "x") => false
616`unresolved b` is `true` iff the blob is a conflict (produced by a clashing
647`unresolved b` is `true` iff the blob is a conflict (produced by a clashing
617`replay`). `text` on an unresolved blob yields its conflict-marker rendering.
648`replay`). `text` on an unresolved blob yields its conflict-marker rendering.
618
649
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+
619### Shapes and `extract`
657### Shapes and `extract`
620
658
621A type name in an expression is a `Shape`. `extract S v` returns every
659A 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
647parameter type as it is supplied, and the final result against the result
685parameter type as it is supplied, and the final result against the result
648type.
686type.
649
687
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+
650The check is one level deep — it never inspects inside a list or a record's
692The check is one level deep — it never inspects inside a list or a record's
651field values (that would make every call cost the size of the value).
693field values (that would make every call cost the size of the value).
652
694
@@ -676,11 +718,17 @@ unchecked except through the functions they call.
676
718
677## 12. `show`: rendering values as text
719## 12. `show`: rendering values as text
678
720
679−`show v` renders any value as an expression in the language, such that
721+When it succeeds, `show v` renders a value as an expression in the language.
680−(except for closures and unresolved blobs) the result parses and evaluates
722+Except for closures and unresolved blobs, the result parses and evaluates
681back to an equal value. Since `show` returns a `Text`, and a top-level `Text`
723back to an equal value. Since `show` returns a `Text`, and a top-level `Text`
682prints raw, `j show …` is how you inspect values.
724prints raw, `j show …` is how you inspect values.
683
725
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+
684```
732```
685show 42 => "42"
733show 42 => "42"
686show "hi" => "\"hi\""
734show "hi" => "\"hi\""
@@ -696,7 +744,7 @@ Functions render readably:
696show (map) => "map"
744show (map) => "map"
697show (+) => "(+)"
745show (+) => "(+)"
698show (describe "wip") => "describe \"wip\""
746show (describe "wip") => "describe \"wip\""
699−show (\x -> x + 1) => "\x -> (x + 1)"
747+show (\x -> x + 1) => "\\x -> x + 1"
700```
748```
701
749
702Output longer than 80 columns breaks across lines (one element per line,
750Output longer than 80 columns breaks across lines (one element per line,
@@ -730,7 +778,8 @@ prev -- to the parent (crashes at the top)
730next -- to the only child (crashes if not exactly one)
778next -- to the only child (crashes if not exactly one)
731up -- like prev, the raw zipper move
779up -- like prev, the raw zipper move
732by @wqzt -- refocus on a specific commit
780by @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
734parents -- the focus's parents, as a Revset
783parents -- the focus's parents, as a Revset
735kids -- its children
784kids -- its children
736ancestors -- it and its ancestors
785ancestors -- it and its ancestors
@@ -743,6 +792,7 @@ Edits — functions `Repo -> Repo`:
743
792
744```
793```
745new -- a new empty child of the focus
794new -- a new empty child of the focus
795+save -- record file edits without changing the description
746describe "wip" -- set the focus's message
796describe "wip" -- set the focus's message
747squash -- fold the focus into its parent
797squash -- fold the focus into its parent
748abandon -- drop the focus; children join the parent
798abandon -- drop the focus; children join the parent
@@ -757,7 +807,9 @@ And the inspections:
757
807
758```
808```
759tree -- the history as a tree (a Text)
809tree -- 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
761review -- the focus's changes as one page of diffs
813review -- the focus's changes as one page of diffs
762files -- the focus's snapshot (its files)
814files -- the focus's snapshot (its files)
763commits . top -- every commit, preorder
815commits . top -- every commit, preorder
@@ -782,10 +834,13 @@ j 'tree'
782-- the second commit was a mistake; fold it into the first
834-- the second commit was a mistake; fold it into the first
783j 'squash'
835j 'squash'
784
836
785−-- rename the combined commit
837+-- squash leaves a fresh empty child; move back and rename the combined commit
838+j prev
786j 'describe "add the front end"'
839j 'describe "add the front end"'
787
840
788−-- undo the squash entirely
841+-- undo the rename, the focus move, then the squash
842+j undo
843+j undo
789j undo
844j undo
790
845
791-- move back to the first commit to fix something
846-- move back to the first commit to fix something
@@ -814,17 +869,22 @@ diff (blob "a\nb\n") (blob "a\nc\n")
814 => "@@ -1,2 +1,2 @@\n a\n-b\n+c\n"
869 => "@@ -1,2 +1,2 @@\n a\n-b\n+c\n"
815```
870```
816
871
817−`difft p a b` renders the comparison with **difftastic** (which must be on
872+`difft p a b` renders the comparison with **difftastic** when it is on `PATH`
818−`PATH`; the Nix package provides it). `review` uses `difft` per changed 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.
819
876
820`treeWith options repo` renders the history tree with an options record:
877`treeWith options repo` renders the history tree with an options record:
821
878
822```
879```
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 })
824```
881```
825
882
826−The reference config gives two ready-made versions: `tree` (compact) and
883+The reference config provides `tree`, `treeCompact` (folded runs and distant
827−`treeFull` (with ages and authors in the margin).
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.
828
888
829---
889---
830
890
@@ -900,8 +960,14 @@ the algebra they are.
900
960
901**Text:** `splitOn` `startsWith` `endsWith` `concat` `++`
961**Text:** `splitOn` `startsWith` `endsWith` `concat` `++`
902
962
903−**Blobs/repos:** `blob` `text` `unresolved` `replay` `by` `meta` `extract`
963+**Blobs/repos:** `blob` `blobHex` `text` `unresolved` `replay` `by` `meta` `extract`
904`diff` `difft` `treeWith` `validate`
964`diff` `difft` `treeWith` `validate`
905
965
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`
907`fetch` `push EXPR` `undo` `redo` `ops`.
968`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
4piece — not just *what* to type, but *why it works* — so that by the end you
4piece — not just *what* to type, but *why it works* — so that by the end you
5can compose your own commands instead of memorizing them.
5can compose your own commands instead of memorizing them.
6
6
7−You'll want `j` installed (`nix build` or `cargo build --release`) and a
7+You'll want `j` installed (`nix build` or `cargo build --release`). Set up
8−`config.j` with your identity (see the README's Configuration section). Every
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
9−command shown here is run in a real repository, so you can follow along.
17+repository, so you can follow along.
10
18
11---
19---
12
20
@@ -20,11 +28,11 @@ j init
20```
28```
21
29
22`j init` makes a colocated `.jj`/`.git` repository and checks out a fresh
30`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:
24
33
25```
34```
26echo 'fn main() {}' > main.rs
35echo 'fn main() {}' > main.rs
27−j 'new'
28j 'describe "start the project"'
36j 'describe "start the project"'
29```
37```
30
38
@@ -36,35 +44,48 @@ j 'tree'
36
44
37```
45```
38 ⌂ zzzz
46 ⌂ zzzz
39− └─ ● pnyz ▂
40−▶ └─ ◌ psnq start the project
47+▶ └─ ◉ pnyz ▂ start the project
41```
48```
42
49
43Add a second change on top:
50Add a second change on top:
44
51
45```
52```
46−echo 'lib' > lib.rs
47j 'new'
53j 'new'
54+echo 'lib' > lib.rs
48j 'describe "add a library"'
55j 'describe "add a library"'
49j 'tree'
56j 'tree'
50```
57```
51
58
52```
59```
53 ⌂ zzzz
60 ⌂ zzzz
54− └─ ● pnyz ▂
55− └─ ● psnq ▂ start the project
61+ └─ ● pnyz ▂ start the project
56−▶ └─ ◌ trtq add a library
62+▶ └─ ◉ psnq ▂ add a library
57```
63```
58
64
59−That's the whole basic loop: **edit files, `new`, `describe`.** There is no
65+That's the basic loop: **edit files, describe the current change, then `new`
60−`git add` step — the working directory is always already a commit, and `j`
66+before editing the next change.** `new` opens an empty change with the same
61−snapshots it on every run.
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.
62
70
63−**What just happened, in the model:** each `j` run snapshots your working
71+**What just happened, in the model:** each expression snapshots your working
64−directory into the working-copy commit, builds a `Repo` value focused there,
72+directory for evaluation, builds a `Repo` value focused on that commit,
65evaluates your expression, and — because `new` and `describe` are *edits*
73evaluates your expression, and — because `new` and `describe` are *edits*
66(functions `Repo -> Repo`) — persists the resulting repository.
74(functions `Repo -> Repo`) — persists the resulting repository.
67
75
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+
68---
89---
69
90
70## Part 2 — The one idea everything rests on
91## Part 2 — The one idea everything rests on
@@ -133,22 +154,28 @@ function produces, so don't wrap it.
133Make a few commits to play with:
154Make a few commits to play with:
134
155
135```
156```
136−echo a > a.txt; j 'new'; j 'describe "A"'
157+j new; echo a > a.txt; j 'describe "A"'
137−echo b > b.txt; j 'new'; j 'describe "B"'
158+j new; echo b > b.txt; j 'describe "B"'
138−echo c > c.txt; j 'new'; j 'describe "C"'
159+j new; echo c > c.txt; j 'describe "C"'
139```
160```
140
161
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:
142
164
143```
165```
144j 'prev' -- to the parent
166j 'prev' -- to the parent
145j 'next' -- to the only child
167j 'next' -- to the only child
146−j 'top' -- to the top of history
147j 'tip' -- to the end of the current line
168j 'tip' -- to the end of the current line
148j 'by @vqok' -- to a specific commit, by id (use a real id from j tree)
169j 'by @vqok' -- to a specific commit, by id (use a real id from j tree)
149−j 'goto %main' -- to what the remote calls main
150```
170```
151
171
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+
152After `j 'prev'` from C, the tree shows the focus (▶ and ◉) on B:
179After `j 'prev'` from C, the tree shows the focus (▶ and ◉) on B:
153
180
154```
181```
@@ -164,11 +191,16 @@ To see the focus's data, use the inspection functions:
164```
191```
165j 'files' -- the focus's files
192j 'files' -- the focus's files
166j 'changed' -- paths that differ from the parent
193j '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
168j 'log' -- a table of every commit, ✓ on the focus
195j 'log' -- a table of every commit, ✓ on the focus
169j 'review' -- the focus's changes as diffs (difftastic)
196j 'review' -- the focus's changes as diffs (difftastic)
170```
197```
171
198
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+
172### Composition order, made concrete
204### Composition order, made concrete
173
205
174```
206```
@@ -194,11 +226,10 @@ operates on the repository the previous step produced.
194
226
195## Part 4 — Dry runs: seeing before doing
227## Part 4 — Dry runs: seeing before doing
196
228
197−Because inspections persist nothing, you can preview any edit by composing it
229+Use `preview` to validate an edit and show its resulting tree without saving:
198−with `tree`:
199
230
200```
231```
201−j 'tree . squash'
232+j 'preview squash'
202```
233```
203
234
204From the A → B history, this *shows* the tree as `squash` would leave it (B
235From 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`:
221▶ └─ ◌ nknz B
252▶ └─ ◌ nknz B
222```
253```
223
254
224−For a stronger guarantee, insert `validate`. `validate repo` returns the repo
255+`preview` combines `tree` and `validate`. For an edit that takes arguments,
225−unchanged if persisting would succeed, and crashes with the exact error
256+put that edit in parentheses:
226−otherwise:
227
257
228```
258```
229−j 'tree . validate . rebase %main'
259+j 'preview (rebase %main)'
230```
260```
231
261
232This renders the post-rebase tree **and** crashes (without touching anything)
262This renders the post-rebase tree **and** crashes (without touching anything)
233if that rebase couldn't actually happen — for instance, if `%main` doesn't
263if that rebase couldn't actually happen — for instance, if `%main` doesn't
234exist yet. That's an *honest* dry run.
264exist yet. That's an *honest* dry run.
235
265
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+
236---
270---
237
271
238## Part 5 — `or`: the whole error-handling model
272## Part 5 — `or`: the whole error-handling model
@@ -290,7 +324,7 @@ Two things make these safe to use freely:
290
324
2911. **The change id survives.** A commit keeps its `@wqzt…` id across
3251. **The change id survives.** A commit keeps its `@wqzt…` id across
292 `describe`, `squash`, `rebase`. You're never tracking a moving sha.
326 `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.
294
328
295```
329```
296j undo -- revert the last thing
330j undo -- revert the last thing
@@ -298,6 +332,28 @@ j redo -- reapply it
298j ops -- see the operation log
332j ops -- see the operation log
299```
333```
300
334
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+
301### `split` and `contract`: moving changes by fileset
357### `split` and `contract`: moving changes by fileset
302
358
303A **fileset** is a function `Path -> Bool`. These two are inverses:
359A **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
341the one place a revset must name *exactly one* commit:
397the one place a revset must name *exactly one* commit:
342
398
343```
399```
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
345```
401```
346
402
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+
347Revsets combine like sets:
406Revsets combine like sets:
348
407
349```
408```
@@ -387,9 +446,9 @@ Everything `j` knows is defined in `config.j`. Any definition whose value is
387an `Edit` is a new command. Add this to your `~/.config/j/config.j`:
446an `Edit` is a new command. Add this to your `~/.config/j/config.j`:
388
447
389```
448```
390−-- fold the whole stack into one commit called "release"
449+-- squash into the parent and name the combined commit "release"
391release : Edit
450release : Edit
392−release = describe "release" . (contract everything)
451+release = describe "release" . prev . squash
393```
452```
394
453
395Now `j release` works exactly like `squash` or `new`. The signature (`: Edit`)
454Now `j release` works exactly like `squash` or `new`. The signature (`: Edit`)
@@ -432,8 +491,8 @@ echo 'fn parse() {}' > parser.rs
432j 'describe "add the parser"'
491j 'describe "add the parser"'
433
492
434# second part
493# second part
435−echo 'mod parser;' > lib.rs
436j 'new'
494j 'new'
495+echo 'mod parser;' > lib.rs
437j 'describe "wire up the parser"'
496j 'describe "wire up the parser"'
438
497
439# look at it
498# look at it
@@ -442,17 +501,24 @@ j 'tree'
442# the second commit was really part of the first; fold it in
501# the second commit was really part of the first; fold it in
443j squash
502j squash
444
503
504+# squash leaves an empty child focused; move to the combined commit
505+j prev
506+
445# rename the combined commit
507# rename the combined commit
446j 'describe "add the parser"'
508j 'describe "add the parser"'
447
509
448# preview the rebase, then do it
510# preview the rebase, then do it
449−j 'tree . validate . rebase %main'
511+j 'preview (rebase %main)'
450j 'rebase %main'
512j 'rebase %main'
451
513
452# share it
514# share it
453j 'push (label "parser" here)'
515j 'push (label "parser" here)'
454```
516```
455
517
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+
456And if any step goes wrong:
522And if any step goes wrong:
457
523
458```
524```
specs/main.md+948−198
@@ -9,7 +9,7 @@ Nothing is implicit beyond the language itself. Every shape, builtin, and
9command is declared or defined in one user-owned file, `~/.config/j/config.j`,
9command is declared or defined in one user-owned file, `~/.config/j/config.j`,
10written in the language. The binary provides the language (its syntax, its
10written in the language. The binary provides the language (its syntax, its
11operators and their fixities, its primitive kinds), the implementations of the
11operators 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.
13`j` does not require the `jj` binary; it uses jj's on-disk formats through
13`j` does not require the `jj` binary; it uses jj's on-disk formats through
14jj-lib.
14jj-lib.
15
15
@@ -40,21 +40,37 @@ echo EXPRESSION | j
40 `::`, `or`, `.`, and the literals `%name`, `@id`, `./a/b` are all safe
40 `::`, `or`, `.`, and the literals `%name`, `@id`, `./a/b` are all safe
41 unquoted.
41 unquoted.
423. If arguments are present and rule 1 also applies, exit 2 with
423. If arguments are present and rule 1 also applies, exit 2 with
43− `j: expression given both as arguments and on stdin`.
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.
44−4. If neither is present, print a one-line usage message to stderr and exit 2.
45+4. If neither is present, or the input is only whitespace, print a concise
45−5. `j` accepts no flags. Any argument, including ones beginning with `-`, is
46+ usage and getting-started guide to stderr and exit 2. The guide points to
46− part of the expression.
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.
47
59
48### 1.1 Reserved commands
60### 1.1 Reserved commands
49
61
50Before the expression is parsed, its text is trimmed of leading and trailing
62Before the expression is parsed, its text is trimmed of leading and trailing
51whitespace and split on whitespace into words. If the first word is one of the
63whitespace 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`.
53
66
54| command | operation |
67| command | operation |
55|---|---|
68|---|---|
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 |
56| `init` | create a repository in the current directory; §7.8 |
72| `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 |
58| `remote URL` | set the URL of the remote `origin`, creating it if absent; §7.8 |
74| `remote URL` | set the URL of the remote `origin`, creating it if absent; §7.8 |
59| `fetch` | fetch from `origin`; §7.6 |
75| `fetch` | fetch from `origin`; §7.6 |
60| `push EXPR` | set or delete the remote bookmarks that `EXPR` selects; §7.6 |
76| `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.
62| `redo` | reverse the newest undo; §7.7 |
78| `redo` | reverse the newest undo; §7.7 |
63| `ops` | print the operation log; §7.7 |
79| `ops` | print the operation log; §7.7 |
64
80
65−For every reserved command except `push`, the remaining words are literal
81+For every reserved command except `push` and `commands`, the remaining words
66−arguments (no quoting, no escaping); a wrong number is a usage error (exit 2)
82+are literal arguments, and the language is not involved. When the first
67−and the language is not involved. For `push`, the remainder of the text is an
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
68−expression, evaluated as in §1.2 steps 2–7 but against the repository as
89+remainder of the text is an expression, evaluated as in §1.2 steps 2–7 but
69−recorded, without snapshotting the working directory; its value must be a list
90+against the repository as recorded, without snapshotting the working directory;
70−of push records (§7.6).
91+its value must be a list of push records (§7.6).
71
92
72Matching is on the first word only, so an expression whose first token is one
93Matching is on the first word only, so an expression whose first token is one
73of these words cannot be written on the command line. `(push)` or `x fetch`
94of these words cannot be written on the command line. `(push)` or `x fetch`
74are ordinary expressions. `config.j` may define functions with these names;
95are ordinary expressions. `config.j` may define functions with these names;
75they are reachable only inside larger expressions.
96they are reachable only inside larger expressions.
76
97
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+
77### 1.2 Evaluating the expression
132### 1.2 Evaluating the expression
78
133
791. Locate the repository (§7.1). If none, exit 2.
1341. Locate the repository (§7.1). If none, exit 2.
802. Parse the expression (§3). On parse error, exit 3.
1352. Parse the expression (§3). On parse error, exit 3.
813. Load and validate `config.j` (§6), except for its `Id` literals. If it is
1363. 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.
834. Build the current repository value `r` (§7.2). This includes snapshotting
1394. Build the current repository value `r` (§7.2). This includes snapshotting
84 the working directory into the focused commit (§7.4).
140 the working directory into the focused commit (§7.4).
855. Resolve every `Id` literal in `config.j` and in the expression (§4.10).
1415. Resolve every `Id` literal in `config.j` and in the expression (§4.10).
@@ -90,7 +146,8 @@ they are reachable only inside larger expressions.
907. If `v` is a function, apply it to `r`, once, and let `v` be the result.
1467. If `v` is a function, apply it to `r`, once, and let `v` be the result.
918. If `v` is a `Repo` (§4.4): if it equals the repository value *as loaded,
1478. If `v` is a `Repo` (§4.4): if it equals the repository value *as loaded,
92 before the snapshot of §7.4*, do nothing at all; otherwise persist it
148 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).
94 Exit 0.
151 Exit 0.
959. Otherwise display `v` (§5.1) on stdout. Persist nothing. Exit 0.
1529. Otherwise display `v` (§5.1) on stdout. Persist nothing. Exit 0.
96
153
@@ -100,7 +157,7 @@ persist nothing, leave the working directory untouched, and exit 1.
100Step 7 applies exactly once. If the result of applying a function to `r` is
157Step 7 applies exactly once. If the result of applying a function to `r` is
101again a function, that function is displayed, not applied again.
158again a function, that function is displayed, not applied again.
102
159
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
104unchanged and records it when it is not: the snapshot makes `r` differ from
161unchanged and records it when it is not: the snapshot makes `r` differ from
105the loaded value, and that difference is what gets persisted. The test may be
162the loaded value, and that difference is what gets persisted. The test may be
106implemented by comparing commit and tree hashes rather than values.
163implemented by comparing commit and tree hashes rather than values.
@@ -112,16 +169,34 @@ Two consequences worth knowing:
112 what a rebase would produce without doing it. `validate` in the chain,
169 what a rebase would produce without doing it. `validate` in the chain,
113 `j tree . validate . rebase trunk`, additionally raises whatever
170 `j tree . validate . rebase trunk`, additionally raises whatever
114 persistence would refuse, so the preview is of something that could
171 persistence would refuse, so the preview is of something that could
115− actually be persisted.
172+ actually be persisted. The reference `preview EDIT` is the convenient
116−- **Do, then look.** `j squash` prints nothing. `j squash && j tree` is the
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
117− idiom; there is no flag for it.
176+ terminal. `j squash && j tree` displays the whole tree; there is no flag for it.
118
177
119### 1.3 Interrupts
178### 1.3 Interrupts
120
179
121There is no timeout. A non-terminating expression runs until interrupted.
180There is no timeout. A non-terminating expression runs until interrupted.
122−Interrupting `j` (SIGINT) kills it; because nothing is written until
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
123−evaluation completes, an interrupted run persists nothing and leaves the
183+interrupted before then persists nothing and leaves the working directory
124−working directory untouched.
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.
125
200
126### 1.4 Exit status
201### 1.4 Exit status
127
202
@@ -132,10 +207,67 @@ working directory untouched.
132| 2 | usage error: bad arguments, no repository, unsupported repository (§7.2), another `j` running (§7.7) |
207| 2 | usage error: bad arguments, no repository, unsupported repository (§7.2), another `j` running (§7.7) |
133| 3 | configuration or parse error |
208| 3 | configuration or parse error |
134
209
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+
135Errors are written to stderr as a single line beginning with `j: `. Crash
216Errors are written to stderr as a single line beginning with `j: `. Crash
136messages are the text given to `crash` (or generated by the interpreter),
217messages are the text given to `crash` (or generated by the interpreter),
137prefixed with `j: crash: `, followed on a second line by the innermost base
218prefixed 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.
139
271
140---
272---
141
273
@@ -167,7 +299,8 @@ same value if they have the same field names with equal values.
167
299
168### 3.1 Lexical structure
300### 3.1 Lexical structure
169
301
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.
171- **Identifiers.** `[a-z_][A-Za-z0-9_']*`. Keywords are excluded:
304- **Identifiers.** `[a-z_][A-Za-z0-9_']*`. Keywords are excluded:
172 `let in if then else or true false`.
305 `let in if then else or true false`.
173- **Type names.** `[A-Z][A-Za-z0-9_]*`. Declared as shapes in `config.j`;
306- **Type names.** `[A-Z][A-Za-z0-9_]*`. Declared as shapes in `config.j`;
@@ -223,7 +356,7 @@ keyword and is always available.
223
356
224### 3.3 Layout
357### 3.3 Layout
225
358
226−Two layout rules and nothing else.
359+Three layout rules and nothing else.
227
360
2281. **Top level.** A top-level item begins with a token in column 1. Every
3611. **Top level.** A top-level item begins with a token in column 1. Every
229 following line whose first token is in a column greater than 1 continues
362 following line whose first token is in a column greater than 1 continues
@@ -231,12 +364,22 @@ Two layout rules and nothing else.
2312. **`let` blocks.** After `let`, the column of the first token opens a block.
3642. **`let` blocks.** After `let`, the column of the first token opens a block.
232 A subsequent line whose first token is in exactly that column begins a new
365 A subsequent line whose first token is in exactly that column begins a new
233 binding; a line indented further continues the current binding; the block
366 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
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.
235− line are separated by `;`. Blank and comment-only lines are ignored here
370+ Bindings on a single line are separated by `;`. Blank and comment-only
236− too.
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).
237
380
238An expression given on the command line or on stdin is a single `expr`, not a
381An 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.
240
383
241### 3.4 Grammar
384### 3.4 Grammar
242
385
@@ -262,7 +405,7 @@ binding := ident '=' expr
262
405
263opexpr := app (op app)* -- resolved by the fixity table
406opexpr := app (op app)* -- resolved by the fixity table
264app := postfix+ -- juxtaposition; left-associative
407app := postfix+ -- juxtaposition; left-associative
265−postfix := atom ( SELECTOR | '{' fields '}' )*
408+postfix := atom ( SELECTOR | '{' fields '}' )* -- no SELECTOR or '{' after a line break (§3.3)
266atom := ident | TYPENAME | INT | TEXT | IDLIT | NEWID | LABELLIT | PATHLIT | 'true' | 'false'
409atom := ident | TYPENAME | INT | TEXT | IDLIT | NEWID | LABELLIT | PATHLIT | 'true' | 'false'
267 | '(' expr ')' | '(' op ')' | '(' op expr ')' | '(' expr op ')'
410 | '(' expr ')' | '(' op ')' | '(' op expr ')' | '(' expr op ')'
268 | '[' postfix* ']'
411 | '[' postfix* ']'
@@ -290,9 +433,23 @@ Notes:
290 a `postfix` (an atom with selectors and updates): `[1 2 3]`,
433 a `postfix` (an atom with selectors and updates): `[1 2 3]`,
291 `["src" "lexer.rs"]`, `[{ root = c, children = [] }]`. An application or
434 `["src" "lexer.rs"]`, `[{ root = c, children = [] }]`. An application or
292 operator expression as an element is parenthesised: `[(up repo).root.id]`.
435 operator expression as an element is parenthesised: `[(up repo).root.id]`.
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
293− Records keep commas.
438+ record updated by the second. Records keep commas.
294- Lambda, `if`, `let`, and `or` extend as far right as possible.
439- Lambda, `if`, `let`, and `or` extend as far right as possible.
295- Patterns are variables and `_` only. No literal, list, or record patterns.
440- 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.
296
453
297---
454---
298
455
@@ -308,8 +465,19 @@ refers to outside a lambda. A cycle among such references (two non-lambda
308definitions each needing the other's value) is a configuration error;
465definitions each needing the other's value) is a configuration error;
309references inside lambda bodies never form cycles, since a lambda is a value
466references inside lambda bodies never form cycles, since a lambda is a value
310before it is applied. The bindings of a `let` block are evaluated by the same
467before 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
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
312−application.
480+body is evaluated on each application.
313Recursion is unbounded; the interpreter must handle deep recursion without
481Recursion is unbounded; the interpreter must handle deep recursion without
314stack overflow (or turn exhaustion into a crash).
482stack overflow (or turn exhaustion into a crash).
315
483
@@ -323,9 +491,18 @@ function.
323
491
324There is no shadowing. A `let` binding or lambda parameter may not use a name
492There is no shadowing. A `let` binding or lambda parameter may not use a name
325already bound in an enclosing scope, including top-level definitions and
493already bound in an enclosing scope, including top-level definitions and
326−declared builtins; in `config.j` that is a configuration error (exit 3), on
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
327−the command line a parse error. Defining a top-level name twice, or defining a
504+top-level name twice, or defining a name that is also a builtin, is a
328−name that is also a declared builtin, is a configuration error.
505+configuration error.
329
506
330### 4.3 Records
507### 4.3 Records
331
508
@@ -351,7 +528,9 @@ negation. Ordering operators are defined on `Int` only and crash otherwise.
351
528
352`if`, `or`, `&&`, and `||` do not evaluate all operands. `&&` and `||`
529`if`, `or`, `&&`, and `||` do not evaluate all operands. `&&` and `||`
353short-circuit. The interpreter recognises `&&` and `||` by name; `or` is a
530short-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.
355
534
356`a or b` evaluates `a`. If that crashes, the crash is discarded and `b` is
535`a or b` evaluates `a`. If that crashes, the crash is discarded and `b` is
357evaluated and returned. Otherwise, if the value of `a` is a function, `b` is
536evaluated and returned. Otherwise, if the value of `a` is a function, `b` is
@@ -417,14 +596,19 @@ as in §4.13.
417| `replay onto ch` | replay the change `ch` (`{ from, to }`) onto the snapshot `onto`; §7.3 |
596| `replay onto ch` | replay the change `ch` (`{ from, to }`) onto the snapshot `onto`; §7.3 |
418| `unresolved b` | `true` iff the blob is a conflict |
597| `unresolved b` | `true` iff the blob is a conflict |
419| `blob t` | a resolved regular-file blob with content `t` |
598| `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 |
420| `text b` | the content of a blob as text; for an unresolved blob, its conflict-marker rendering |
600| `text b` | the content of a blob as text; for an unresolved blob, its conflict-marker rendering |
421| `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 |
601| `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 |
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) |
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 |
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 |
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` |
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 |
424| `treeWith o r` | the history rendered as a tree with options record `o`; §7.11 |
608| `treeWith o r` | the history rendered as a tree with options record `o`; §7.11 |
425| `extract S v` | every subvalue of `v` that is an `S`; §4.12 |
609| `extract S v` | every subvalue of `v` that is an `S`; §4.12 |
426| `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) |
610| `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 |
428
612
429### 4.10 Ids: existing and new
613### 4.10 Ids: existing and new
430
614
@@ -452,10 +636,12 @@ renders a minted id like any other.
452
636
453### 4.11 Label literals
637### 4.11 Label literals
454
638
455−`%main` denotes `labelled "main"`, a `Revset`. It is a function, evaluated
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
456−when applied, so it never fails at parse time: a name the remote does not have
642+function, evaluated when applied, so it never fails at parse time: a name the
457−yields `[]`, and `goto %nope` crashes with "expected one revision, got 0" as
643+remote does not have yields `[]`, and `goto %nope` crashes with "expected one
458−any revset would.
644+revision, got 0" as any revset would.
459
645
460### 4.12 Shapes as values, and `extract`
646### 4.12 Shapes as values, and `extract`
461
647
@@ -463,7 +649,8 @@ A type name used in an expression evaluates to a *shape*: for a `typedecl`
463whose type is a record, the set of its field names; for a primitive kind
649whose type is a record, the set of its field names; for a primitive kind
464(`Int`, `Text`, `Bool`, `Id`, `Blob`), that kind; for a `typedecl` that is an
650(`Int`, `Text`, `Bool`, `Id`, `Blob`), that kind; for a `typedecl` that is an
465alias of another type, that type's shape. A shape whose type is a function
651alias 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`).
467
654
468`extract S v` returns every subvalue of `v` that is an `S`, as a list. It
655`extract S v` returns every subvalue of `v` that is an `S`, as a list. It
469walks lists in order and records in ascending field-name order, descending
656walks 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.
488Every application of a function that has a signature — a builtin or a
675Every application of a function that has a signature — a builtin or a
489definition with a signature — is checked against it: each argument as it is
676definition with a signature — is checked against it: each argument as it is
490supplied, against the corresponding parameter type, and the result once the
677supplied, against the corresponding parameter type, and the result once the
678+function's own application completes (its body has run or the builtin has
491−application is complete (yields a non-function), against the result type.
679+all its arguments), against the result type.
492Lambdas without a signature and the command-line expression are unchecked
680Lambdas without a signature and the command-line expression are unchecked
493−except through the functions they call. A signature on a definition that is
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
494−not a function (`user`, `conflicts`) is checked once, against the value, when
697+a definition that is not a function (`user`, `conflicts`) is checked once,
495−the definition is evaluated at load.
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.
496
704
497The check is one level deep and never inspects inside a list or a record's
705The check is one level deep and never inspects inside a list or a record's
498field values:
706field values:
@@ -504,7 +712,7 @@ field values:
504| `{ a : T, … }`, or a shape name | the value is a record with exactly those field names |
712| `{ a : T, … }`, or a shape name | the value is a record with exactly those field names |
505| `A -> B` | the value is a function |
713| `A -> B` | the value is a function |
506| a type variable | anything |
714| 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 |
508
716
509Field values and list elements are not checked because that would make every
717Field values and list elements are not checked because that would make every
510call cost the size of the value and would force lazily backed snapshots and
718call 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
537only when stdout is a terminal; nothing is conveyed by colour alone.
745only when stdout is a terminal; nothing is conveyed by colour alone.
538
746
539Shapes are recognised by their exact field set, as in §4.12: `Commit`, `Entry`,
747Shapes are recognised by their exact field set, as in §4.12: `Commit`, `Entry`,
748+`Subtree`, `Repo`, `Frame`, `Change`, checked in that order using their
749+configured definitions. Additional type aliases do not displace these
540−`Subtree`, `Repo`, `Frame`, `Change`. Any other record is generic.
750+display forms. Any other record is generic.
541
751
542| kind | line form | block form |
752| kind | line form | block form |
543|---|---|---|
753|---|---|---|
@@ -546,7 +756,7 @@ Shapes are recognised by their exact field set, as in §4.12: `Commit`, `Entry`,
546| `Text` | raw, first line, `…` if truncated | raw, all of it, trailing newline added if absent |
756| `Text` | raw, first line, `…` if truncated | raw, all of it, trailing newline added if absent |
547| `Id` | shortest unique prefix (bold), remainder (dim) | same |
757| `Id` | shortest unique prefix (bold), remainder (dim) | same |
548| `Blob` | `‹214 B›`, `‹✖ 3.1 KB›` if unresolved | its content, raw (markers if unresolved) |
758| `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 |
550| `Shape` | its type name | same |
760| `Shape` | its type name | same |
551| `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 |
761| `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 |
552| `Entry` | `path size`, `✖` prefix if unresolved | same |
762| `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`,
562| list of other scalars | `a, b, c` | same, wrapped at the terminal width |
772| list of other scalars | `a, b, c` | same, wrapped at the terminal width |
563| any other list | `n items` | one block per item, separated by a blank line |
773| any other list | `n items` | one block per item, separated by a blank line |
564
774
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+
565An empty list of any kind prints `none` (dim) in block form and `0 …` in
790An empty list of any kind prints `none` (dim) in block form and `0 …` in
566line form. Author and age in a `Commit` block are read from jj's commit
791line form. Author and age in a `Commit` block are read from jj's commit
567metadata by id;
792metadata by id;
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
568−they are not fields of the value. A list of `[Text]` prints as paths because
803+line draws its focus as the focus. A list of `[Text]` prints as paths because
569that is what such a list is in every base function; `show` is available
804that is what such a list is in every base function; `show` is available
570when the literal is wanted. Alignment in tables and columns uses display
805when the literal is wanted. Alignment in tables and columns uses display
571width (East Asian wide characters count two cells, combining marks zero), not
806width (East Asian wide characters count two cells, combining marks zero), not
@@ -575,12 +810,13 @@ Examples:
575
810
576```
811```
577$ j status
812$ j status
578−changed src/lexer.rs
813+in commit src/lexer.rs
579− src/parser.rs
814+ src/parser.rs
580−conflicts src/lexer.rs
815+conflicts src/lexer.rs
581−id wqzt·kpqxmnrvyxskptlmzzzzabcd
816+id wqzt·kpqxmnrvyxskptlmzzzzabcd
582−labels feature
817+labels feature
583−message wip
818+message wip
819+unrecorded All edits recorded
584
820
585$ j conflicts
821$ j conflicts
586⊗ wqzt wip feature
822⊗ wqzt wip feature
@@ -623,10 +859,18 @@ j: crash: expected one revision, got 0
623
859
624### 5.2 `show`
860### 5.2 `show`
625
861
626−`show v` renders `v` as an expression in the language, such that for every
862+When it succeeds, `show v` renders `v` as an expression in the language, such
627−value except closures and unresolved blobs, `show v` parses and evaluates to a
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
628−value equal to `v` under the same `config.j`. It never uses colour and never
865+evaluates to a value equal to `v` under the same `config.j`. It never uses
629−truncates.
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.
630
874
631| kind | rendering |
875| kind | rendering |
632|---|---|
876|---|---|
@@ -636,18 +880,33 @@ truncates.
636| list | `[v1 v2 …]` |
880| list | `[v1 v2 …]` |
637| record | `{ a = v, b = w }`, fields in ascending lexicographic order |
881| record | `{ a = v, b = w }`, fields in ascending lexicographic order |
638| `Id` | `@` followed by the full id, e.g. `@wqztkpqxmnrvyxskptlmzzzzabcd` |
882| `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 |
640| unresolved `Blob` | `{- unresolved -} blob "…"` with the conflict-marker rendering |
885| unresolved `Blob` | `{- unresolved -} blob "…"` with the conflict-marker rendering |
641| builtin | its declared name, operators parenthesised: `map`, `(++)` |
886| builtin | its declared name, operators parenthesised: `map`, `(++)` |
887+| selector | the selector itself: `.id` |
642−| top-level definition | its name |
888+| top-level definition with a signature whose value is a function | its name |
643| revset from a label literal | `%name` |
889| revset from a label literal | `%name` |
644| shape | its type name: `Commit`, `Id` |
890| shape | its type name: `Commit`, `Id` |
645| lambda closure | its source text, exactly as written |
891| lambda closure | its source text, exactly as written |
646| partial application | the function's rendering followed by the arguments' renderings, each parenthesised if not atomic: `describe "wip"`, `at (parents) (describe "x")` |
892| partial application | the function's rendering followed by the arguments' renderings, each parenthesised if not atomic: `describe "wip"`, `at (parents) (describe "x")` |
647
893
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+
648−Line breaking: one line if it fits in 80 columns; otherwise the outermost
906+Line breaking: one line if it fits in 80 columns (display width, as in §5.1);
649−list or record breaks one element per line, two-space indented, with leading
907+otherwise the outermost list or record breaks one element per line, two-space
650−commas for records and nothing between list elements, recursively.
908+indented, with leading commas for records and nothing between list elements,
909+recursively.
651
910
652Since `show` returns a `Text` and a top-level `Text` displays raw,
911Since `show` returns a `Text` and a top-level `Text` displays raw,
653`j show . focus` prints the literal of the focused commit, and
912`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,
660### 6.1 Location and loading
919### 6.1 Location and loading
661
920
662`j` reads `$XDG_CONFIG_HOME/j/config.j`, defaulting to `~/.config/j/config.j`.
921`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
665`clone` need `user`. When there is no repository yet (`init`, `clone`), only
923`clone` need `user`. When there is no repository yet (`init`, `clone`), only
666`user` is evaluated; `Id` literals are not resolved and no other definition
924`user` is evaluated; `Id` literals are not resolved and no other definition
667−is evaluated. If it does not exist, `j` exits 3 with
925+is evaluated. `remote`, `fetch`, `undo`, `redo`, and `ops` do not load configuration.
668−`j: no config at <path>; the reference config.j is installed at <share-path>`.
926+When a command needs configuration and none exists, the binary writes its
669−The binary is distributed with the reference `config.j` (§9) at
927+embedded reference `config.j` (§9) to that path as an editable starting point,
670−`<prefix>/share/j/config.j`; `j` never writes to the config path itself.
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.
671
964
672### 6.2 Validation
965### 6.2 Validation
673
966
@@ -692,6 +985,13 @@ not declared is simply unbound, and using it is a runtime crash.
692
985
693Definitions may reference each other in any order.
986Definitions may reference each other in any order.
694
987
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+
695### 6.3 Definitions the interpreter reads
995### 6.3 Definitions the interpreter reads
696
996
697Four names in `config.j` are read by the interpreter itself, not only by
997Four 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.
720Walk up from the current directory to the first directory containing `.jj`.
1020Walk up from the current directory to the first directory containing `.jj`.
721Only the default workspace is supported; if the working directory belongs to
1021Only the default workspace is supported; if the working directory belongs to
722another workspace, exit 2.
1022another 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`.
723
1027
724### 7.2 Building the `Repo` value
1028### 7.2 Building the `Repo` value
725
1029
@@ -747,11 +1051,16 @@ a value as follows.
747 `fetch` found the remote's `main` pointing at it — the snapshot cannot go
1051 `fetch` found the remote's `main` pointing at it — the snapshot cannot go
748 into it; the focus is instead a new empty child of it holding the snapshot,
1052 into it; the focus is instead a new empty child of it holding the snapshot,
749 as `abandon` would leave it, and persisting records that child. Nothing
1053 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.
751
1057
752The record is the *semantics*. An implementation may back `files`, `children`,
1058The record is the *semantics*. An implementation may back `files`, `children`,
753and `context` with lazily materialised structures, provided every observation
1059and `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.
755
1064
756### 7.3 Files, blobs, and `replay`
1065### 7.3 Files, blobs, and `replay`
757
1066
@@ -759,21 +1068,70 @@ an expression can make agrees with the eager value.
759 the list of path components; components must be valid UTF-8, and a
1068 the list of path components; components must be valid UTF-8, and a
760 repository with a path that is not exits 2. `content` is a `Blob` carrying the file's
1069 repository with a path that is not exits 2. `content` is a `Blob` carrying the file's
761 content and type (regular, executable, symlink) and, for conflicted paths,
1070 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).
763- A path that is deleted and resolved has no entry. A path that is in a
1081- 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.
765- `replay onto { from, to }` is jj's tree merge: for each path present in
1087- `replay onto { from, to }` is jj's tree merge: for each path present in
766 any of the three snapshots, the three-way merge of that path's values with
1088 any of the three snapshots, the three-way merge of that path's values with
767 `from` as the common ancestor, replaying the change from `from` to `to` on
1089 `from` as the common ancestor, replaying the change from `from` to `to` on
768 top of `onto`. Conflicts are kept as unresolved blobs, and resolved where
1090 top of `onto`. Conflicts are kept as unresolved blobs, and resolved where
769 jj's merge would resolve them (identical changes, changes from the current
1091 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
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
771− crashes if any of the three is not a well-formed snapshot (a list of `Entry`
1099+ directory (below). It crashes if any of the three is not a well-formed
772− records with unique paths).
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`.
773−- `unresolved`, `blob`, and `text` are as in §4.9. `blob` produces a regular
1130+- `unresolved`, `blob`, `blobHex`, and `text` are as in §4.9. `blob` produces
774− file. File type (executable, symlink) travels with a `Blob` but cannot be
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
775− changed in the language. `text` of a symlink is its target; `text` of a
1133+ content and file type. `text` of a symlink is its target; `text` of a
776− non-UTF-8 blob crashes.
1134+ non-UTF-8 blob crashes. Persisting a symlink with a non-UTF-8 target crashes.
777
1135
778### 7.4 Working-directory invariant
1136### 7.4 Working-directory invariant
779
1137
@@ -783,18 +1141,100 @@ directory.
783- **Before evaluation**, the working directory is snapshotted into the
1141- **Before evaluation**, the working directory is snapshotted into the
784 working-copy commit, and that is the `files` of the focus in the `Repo`
1142 working-copy commit, and that is the `files` of the focus in the `Repo`
785 value. The snapshot tracks every file not matched by `.gitignore` files,
1143 value. The snapshot tracks every file not matched by `.gitignore` files,
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
786− with no size limit, symlinks as symlinks; these rules are fixed, since `j`
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`);
787− reads no jj configuration. If the snapshot changed the commit, that change
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
788− is part of the operation persisted afterwards, or discarded with the rest if
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
789− nothing is persisted. A crashed or printing program leaves the working copy
1154+ or printing program leaves the working copy and the repository exactly as
790− and the repository exactly as before; `j id` is the way to record the
1155+ before (but see §7.5 step 7); `j id` is the way to record the working
791− working directory and nothing else (§1.2).
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.
792- **After persistence**, the focus of the result is checked out: its files are
1189- **After persistence**, the focus of the result is checked out: its files are
793 written to the working directory, unresolved blobs materialised with jj's
1190 written to the working directory, unresolved blobs materialised with jj's
794 conflict markers, and the workspace's working-copy commit set to the focus.
1191 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.
795
1227
796Reserved commands never snapshot.
1228Reserved commands never snapshot.
797
1229
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+
798### 7.5 Persistence
1238### 7.5 Persistence
799
1239
800Given the loaded value `old` and the result `new`, one jj operation is
1240Given 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).
802Within it:
1242Within it:
803
1243
8041. **Validate shape.** `new` has the shape of a `Repo`; every `id` occurs
12441. **Validate shape.** `new` has the shape of a `Repo`; every `id` occurs
805− exactly once; every snapshot has unique paths. Violations crash with a
1245+ exactly once; every commit's `message` is a `Text`; every snapshot entry
806− message naming the rule.
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.
8072. **Validate labels.** The set of `(id, label)` pairs in `new` equals the set
12662. **Validate labels.** The set of `(id, label)` pairs in `new` equals the set
808 in `old`. Labels are the remote's names (§7.6); a script cannot add, move,
1267 in `old`. Labels are the remote's names (§7.6); a script cannot add, move,
809 or remove one. In particular a commit carrying a label cannot be abandoned.
1268 or remove one. In particular a commit carrying a label cannot be abandoned.
@@ -812,7 +1271,9 @@ Within it:
812 and all of its ancestors. Every id in it must be present in `new` with the
1271 and all of its ancestors. Every id in it must be present in `new` with the
813 same parent id, files, and message. Violations crash naming the commit.
1272 same parent id, files, and message. Violations crash naming the commit.
814 (Ancestors of merges are included because rewriting an ancestor rewrites
1273 (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.
8164. **Walk `new` top-down.** For each commit `c` (root first):
12774. **Walk `new` top-down.** For each commit `c` (root first):
817 - If `c.id` is the root commit's change id, it must be the top of the tree
1278 - If `c.id` is the root commit's change id, it must be the top of the tree
818 and must be unchanged; otherwise crash.
1279 and must be unchanged; otherwise crash.
@@ -834,12 +1295,62 @@ Within it:
834 `new.root.id`. (`abandon` and `squash` end with `new` for this reason, so
1295 `new.root.id`. (`abandon` and `squash` end with `new` for this reason, so
835 they are safe on a commit directly above `main`; navigation onto `main`
1296 they are safe on a commit directly above `main`; navigation onto `main`
836 is written `new . goto trunk`.)
1297 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.
838
1332
839Labels are not written by persistence at all; they are derived from remote
1333Labels are not written by persistence at all; they are derived from remote
840refs, which only `fetch` and `push` change. The interpreter may run any part
1334refs, which only `fetch` and `push` change. The interpreter may run any part
841of the walk through jj-lib's rebase machinery provided the stored result
1335of 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.
843
1354
844### 7.6 Labels and the remote
1355### 7.6 Labels and the remote
845
1356
@@ -851,7 +1362,11 @@ language: `fetch` and `push` are the only things that change them.
851 each remote bookmark `name` is the label `name` on the commit it points at;
1362 each remote bookmark `name` is the label `name` on the commit it points at;
852 labels for bookmarks that no longer exist on the remote are removed; commits
1363 labels for bookmarks that no longer exist on the remote are removed; commits
853 reachable from remote bookmarks become visible. Fetching never moves the
1364 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.
855- **`push EXPR`** evaluates `EXPR` (§1.1); a function result is applied to
1370- **`push EXPR`** evaluates `EXPR` (§1.1); a function result is applied to
856 the recorded repository. The value must be a list whose elements are records
1371 the recorded repository. The value must be a list whose elements are records
857 of two shapes:
1372 of two shapes:
@@ -859,54 +1374,120 @@ language: `fetch` and `push` are the only things that change them.
859 creating it if absent;
1374 creating it if absent;
860 - `{ delete : Text }` — delete the bookmark of that name.
1375 - `{ delete : Text }` — delete the bookmark of that name.
861
1376
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+
862 Duplicates are merged; two records with the same `name` and different
1384 Duplicates are merged; two records with the same `name` and different
863 effects are a crash. It is refused (exit 1, nothing pushed) if:
1385 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,
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
865− or a delete names a bookmark the remote does not have;
1388+ a bookmark the remote does not have;
866 - any commit that would be sent has an unresolved file or an empty
1389 - 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);
868 - the bookmark's current target is in the immutable set (§7.5) and the
1393 - the bookmark's current target is in the immutable set (§7.5) and the
869 record would move it to a commit that does not descend from it, or delete
1394 record would move it to a commit that does not descend from it, or delete
870− it.
1395+ it while no bookmark left on `origin` after the push reaches that target
871−
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.
872 Moving a bookmark whose current target is not immutable to an unrelated
1406 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
874 successful push the labels reflect the new positions. It records one
1425 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.
876
1434
877Only the remote named `origin` is supported in this version; `fetch` and
1435Only 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.
879Authentication is git's: the ssh agent for ssh URLs, git credential helpers
1437Authentication is git's: the ssh agent for ssh URLs, git credential helpers
880for https, as jj-lib's git backend provides them; `j` adds no prompting.
1438for https, as jj-lib's git backend provides them; `j` adds no prompting.
881Bringing work up to date is a separate expression, e.g. `rebase trunk`.
1439Bringing work up to date is a separate expression, e.g. `rebase trunk`.
882
1440
883### 7.7 Operations, undo, redo, and the lock
1441### 7.7 Operations, undo, redo, and the lock
884
1442
885−Every persisting `j` program, and each of `fetch` and `push`, records exactly
1443+Every persisting `j` program, each `fetch`, and each nonempty `push` records
886−one jj operation, described by the expression or command text. Crashes record
1444+exactly one jj operation, described by the expression or command text. A push's
887−nothing.
1445+description includes its `push ` prefix, so an expression named `init` or
888−
1446+`clone` cannot be mistaken for the operation that created the repository.
889−**Kinds.** `j` marks each operation it records, in the operation's metadata,
1447+Crashes record nothing, except as §7.5 step 7 says.
890−as *normal*, *undo* (naming the operation it undid), or *redo* (naming the
1448+
891−undo it reversed). Operations recorded by other tools are treated as normal,
1449+**Kinds.** An operation is *normal*, *undo*, or *redo*, determined by its
892−except that an operation whose description carries jj's own undo or redo
1450+description. New undo and redo records use jj's current markers,
893−marker is treated accordingly, so mixed use chains correctly where the marker
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
894−is present and degrades to "undo the newest operation" where it is not.
1458+to "undo the newest operation" where no marker is present.
895−
1459+
896−**`undo`** finds the operation to undo: starting at the head, while the
1460+For current markers, start at the head and follow restored-operation links
897−current operation is an undo, move to the operation it undid and then to that
1461+while their kind matches the command: undo links for `undo`, redo links for
898−operation's parent; while it is a redo, move past the operation it reapplied.
1462+`redo`. The command then selects that operation's parent. Follow links of
899−The first normal operation reached is undone by recording a new operation
1463+the same kind at the selected target too, and record the final target's id
900−that restores its parent's view, marked as an undo of it. If none is found,
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
901−exit 1 with `j: nothing to undo`.
1470+`j: nothing to undo`.
902−
1471+
903−**`redo`** requires the head to be an undo (possibly reached through redos);
1472+**`redo`** requires the operation reached through redo links to be an undo;
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`.
1473+it restores the state that undo discarded. Otherwise exit 1 with
906−
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+
907−Both refuse, before doing anything, if the working directory differs from the
1482+Both refuse, before doing anything, if the working directory holds a change
908−focused commit's files: `j: working copy has changes not in @; run \`j id\`
1483+the focused commit does not, as a snapshot would find it (§7.4; a stale
909−to record them or discard them`. Both check out the restored focus afterwards.
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.
910Undoing a `push` restores the recorded labels and leaves the remote untouched.
1491Undoing a `push` restores the recorded labels and leaves the remote untouched.
911
1492
912**`ops`** prints the operation log, newest first, one line each: relative
1493**`ops`** prints the operation log, newest first, one line each: relative
@@ -919,7 +1500,8 @@ operation is marked. Rendered in the binary like `tree`.
919duration.
1500duration.
920A second `j` exits 2 with `j: another j is running in this repository`.
1501A second `j` exits 2 with `j: another j is running in this repository`.
921Display-only runs are not distinguishable in advance, so they take the lock
1502Display-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.
923The lock prevents `j` from ever producing two commits with one change id.
1505The lock prevents `j` from ever producing two commits with one change id.
924Other tools operating concurrently are outside `j`'s control; if they produce
1506Other tools operating concurrently are outside `j`'s control; if they produce
925that state, §7.2 applies.
1507that state, §7.2 applies.
@@ -931,49 +1513,112 @@ that state, §7.2 applies.
931 initialised on top of the existing git repository and its history is
1513 initialised on top of the existing git repository and its history is
932 imported (subject to §7.2). Either way a working-copy commit is created as a
1514 imported (subject to §7.2). Either way a working-copy commit is created as a
933 child of the current head (or of the root commit for an empty repository)
1515 child of the current head (or of the root commit for an empty repository)
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
934− and checked out. It exits 2 if the directory is already inside a jj
1520+ operation recorded, as §7.5 step 7 says. It exits 2 if the directory is
935− repository, or if `.git` exists but is not a directory (a git worktree or
1521+ already inside a jj repository, or if `.git` exists but is not a directory
936− submodule). It requires `user` (§7.9).
1522+ (a git worktree or submodule). It requires `user` (§7.9).
937−- **`clone URL [DIR]`** creates `DIR` (exit 2 if it exists and is non-empty),
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),
938 clones `URL` into it as a colocated repository, sets `origin` to `URL`,
1526 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
1527+ fetches, and creates a working-copy commit as a child of the remote's
940− remote's default bookmark target (or of the root commit if the remote has
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
941− none). It requires `user`.
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.
942- **`remote URL`** sets the URL of `origin`, creating the remote if it does not
1544- **`remote URL`** sets the URL of `origin`, creating the remote if it does not
943 exist. It does not fetch.
1545 exist. It does not fetch.
944
1546
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+
945### 7.9 Identity
1568### 7.9 Identity
946
1569
947Commits are authored and committed as `user.name <user.email>` from
1570Commits are authored and committed as `user.name <user.email>` from
948`config.j` (§6.3). `j` reads no other configuration; jj-lib's settings object
1571`config.j` (§6.3). `j` reads no other configuration; jj-lib's settings object
949is constructed by `j` from `user` and fixed defaults. `init`, `clone`, and
1572is constructed by `j` from `user` and fixed defaults. `init`, `clone`, and
950persistence exit 3 if `user` is missing or malformed, since that is a
1573persistence 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.
952
1584
953### 7.10 External diff rendering
1585### 7.10 External diff rendering
954
1586
955`difft p a b` runs difftastic. It is the only builtin that executes another
1587`difft p a b` runs difftastic. It is the only builtin that executes another
956program.
1588program.
957
1589
958−1. Write the content of `a` and `b` to two temporary files whose names end in
1590+1. Write the content of `a` and `b` to files in separate `old` and `new`
959− the last component of `p` (so difftastic's language detection sees the
1591+ subdirectories of a fresh temporary directory. Each file keeps the last
960− real filename), inside a fresh temporary directory.
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`.
9612. Run `difft OLD NEW` with the process's stdin closed and its environment
15952. Run `difft OLD NEW` with the process's stdin closed and its environment
962 inherited unchanged, except: if `DFT_COLOR` is unset and `j`'s stdout is a
1596 inherited unchanged, except: if `DFT_COLOR` is unset and `j`'s stdout is a
963 terminal, set `DFT_COLOR=always`; if `DFT_WIDTH` is unset and `j`'s stdout
1597 terminal, set `DFT_COLOR=always`; if `DFT_WIDTH` is unset and `j`'s stdout
964 is a terminal, set it to the terminal width.
1598 is a terminal, set it to the terminal width.
1599+3. Capture stdout; the program's stderr is `j`'s own, so what it says there
965−3. Capture stdout. Delete the temporary directory.
1600+ reaches the user. Delete the temporary directory.
9664. Return stdout as `Text`. A non-zero exit status is not an error (difftastic
16014. 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
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
968− execute the program is a crash naming it.
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.
969
1613
970All rendering options are difftastic's own, set through its `DFT_*`
1614All rendering options are difftastic's own, set through its `DFT_*`
971environment variables. `j` adds no options of its own. `diff` remains
1615environment variables. `j` adds no options of its own. `diff` remains
972−available as a dependency-free unified diff; the reference `config.j` uses
1616+available as a dependency-free unified diff without path headers; the
973−`difft` for display, so a user without difftastic edits one line.
1617+reference `config.j` uses `difft` for display and receives the automatic
1618+fallback when difftastic is absent.
974
1619
975−Packaging: difftastic is a runtime dependency; on Nix, wrap `j` with `difft`
1620+Packaging: difftastic is an optional runtime enhancement; on Nix, wrap `j`
976−on its `PATH`.
1621+with `difft` on its `PATH`.
977
1622
978### 7.11 Tree rendering
1623### 7.11 Tree rendering
979
1624
@@ -997,7 +1642,8 @@ run — have no metadata: their margin cells are blank, their size bar is
997computed from the value, and they sort last among their siblings. The same
1642computed from the value, and they sort last among their siblings. The same
998holds for a `Commit` block in display (§5.1).
1643holds for a `Commit` block in display (§5.1).
999
1644
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`.
1001If the environment variable `NO_COLOR` is set and non-empty, colour is never
1647If the environment variable `NO_COLOR` is set and non-empty, colour is never
1002used regardless of `color`. `config.j` fixes the defaults by defining `tree`.
1648used regardless of `color`. `config.j` fixes the defaults by defining `tree`.
1003
1649
@@ -1028,8 +1674,9 @@ Columns, left to right:
10284. **Id**: shortest prefix unique among visible commits, minimum 4.
16744. **Id**: shortest prefix unique among visible commits, minimum 4.
10295. **Size bar** (`detail ≥ 1`, focus and its parent and children only; all
16755. **Size bar** (`detail ≥ 1`, focus and its parent and children only; all
1030 commits at `detail = 2`): one of `▁ ▂ ▃ ▅ ▇`, lines added plus removed
1676 commits at `detail = 2`): one of `▁ ▂ ▃ ▅ ▇`, lines added plus removed
1031− against the parent, thresholds 1, 10, 50, 200, 1000. Absent for empty
1677+ by a shortest line diff against the parent, thresholds 1, 10, 50, 200,
1032− commits.
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.
10336. **Message**: first line; nothing if empty.
16806. **Message**: first line; nothing if empty.
10347. **Labels**: right-aligned column, present only if any rendered commit has
16817. **Labels**: right-aligned column, present only if any rendered commit has
1035 a label; names separated by two spaces.
1682 a label; names separated by two spaces.
@@ -1097,7 +1744,7 @@ Messages are never coloured except dim (cousins) and dim italic (empty).
1097
1744
1098These hold for the reference `config.j` and should be property-tested against
1745These hold for the reference `config.j` and should be property-tested against
1099an in-memory implementation of the builtins (§10). `=` means the results are
1746an 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.
1101
1748
1102```
1749```
1103by r.root.id r = r
1750by r.root.id r = r
@@ -1214,6 +1861,10 @@ Drop = { delete : Label } -- delete the remote's bookmark
1214------------------------------------------------------------------------------
1861------------------------------------------------------------------------------
1215
1862
1216(.) : (b -> c) -> (a -> b) -> a -> c -- composition, right to left
1863(.) : (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.
1217id : a -> a
1868id : a -> a
1218const : a -> b -> a
1869const : a -> b -> a
1219crash : Text -> a -- abort the program with a message
1870crash : Text -> a -- abort the program with a message
@@ -1275,23 +1926,39 @@ splitOn : Text -> Text -> [Text] -- splitOn "/" "a/b" = ["a" "b"]
1275------------------------------------------------------------------------------
1926------------------------------------------------------------------------------
1276
1927
1277-- Replay a change onto a snapshot: "onto, with the change from `from` to `to`
1928-- 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
1929+-- applied." Where the change collides with what is already there, the path's
1279−-- path's blob is unresolved rather than an error.
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).
1280replay : Snapshot -> Change -> Snapshot
1933replay : Snapshot -> Change -> Snapshot
1281unresolved : Blob -> Bool
1934unresolved : 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
1282blob : Text -> Blob -- a resolved regular file
1942blob : Text -> Blob -- a resolved regular file
1943+blobHex : Text -> Text -> Blob -- kind (regular/executable/symlink), hex bytes
1283text : Blob -> Text -- its content (markers if unresolved)
1944text : Blob -> Text -- its content (markers if unresolved)
1284by : Id -> Repo -> Repo -- focus the commit with this id; crash if absent
1945by : 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
1285meta : Id -> Meta -- git hash, author, email, committer time;
1950meta : Id -> Meta -- git hash, author, email, committer time;
1286 -- crash for a commit not yet persisted
1951 -- crash for a commit not yet persisted
1287validate : Repo -> Repo -- the repo unchanged, or the crash persisting
1952validate : Repo -> Repo -- the repo unchanged, or the crash persisting
1288− -- it would produce (shape, labels, immutability,
1953+ -- it would produce (shape, root, labels, immutability,
1289 -- focus). `tree . validate . rebase trunk` is an
1954 -- focus). `tree . validate . rebase trunk` is an
1290 -- honest dry run.
1955 -- honest dry run.
1291diff : Blob -> Blob -> Text -- unified diff from the first to the second
1956diff : Blob -> Blob -> Text -- unified diff from the first to the second
1292−difft : Path -> Blob -> Blob -> Text -- difftastic's rendering; needs difft on PATH.
1957+difft : Path -> Blob -> Blob -> Text -- difftastic when difft is on PATH; otherwise
1293− -- Options via DFT_* environment variables.
1958+ -- unified diff with path headers and 3 context lines,
1959+ -- or a binary-files summary. DFT_* configures difft.
1294−treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text }
1960+treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text
1961+ , lanes : Int, author : Bool, date : Bool, files : Bool }
1295 -> Repo -> Text -- the history as a tree; see the spec
1962 -> Repo -> Text -- the history as a tree; see the spec
1296
1963
1297-- Every subvalue of a given shape, however deeply nested. A shape is a type
1964-- 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
1329
1996
1330-- Every commit of a subtree (or of the focused subtree of a Repo), in preorder.
1997-- Every commit of a subtree (or of the focused subtree of a Repo), in preorder.
1331-- Takes anything with `root` and `children`, so the signature is left open.
1998-- 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.
1332commits : a -> [Commit]
2002commits : a -> [Commit]
1333−commits = \t -> t.root :: (concat (map commits t.children) or [])
2003+commits = subtreeCommits
1334
2004
1335
2005
1336------------------------------------------------------------------------------
2006------------------------------------------------------------------------------
@@ -1374,6 +2044,9 @@ restrict = \m -> filter (\e -> m e.path)
1374select : Fileset -> Snapshot -> Snapshot -> Snapshot -- m from a, the rest from b
2044select : Fileset -> Snapshot -> Snapshot -> Snapshot -- m from a, the rest from b
1375select = \m a b -> restrict m a ++ restrict (neg m) b
2045select = \m a b -> restrict m a ++ restrict (neg m) b
1376
2046
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`.
1377conflicted : Snapshot -> [Path]
2050conflicted : Snapshot -> [Path]
1378conflicted = \s -> map (.path) (filter (\e -> unresolved e.content) s)
2051conflicted = \s -> map (.path) (filter (\e -> unresolved e.content) s)
1379
2052
@@ -1422,7 +2095,7 @@ descendants : Revset -- includes the
1422descendants = \repo -> map (.id) (commits repo)
2095descendants = \repo -> map (.id) (commits repo)
1423
2096
1424ancestors : Revset -- includes the focus
2097ancestors : 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
1426
2099
1427siblings : Revset
2100siblings : Revset
1428siblings = \repo -> filter (\i -> i /= repo.root.id) (kids (up repo)) or []
2101siblings = \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
1458matching : (Commit -> Bool) -> Revset -> Revset
2131matching : (Commit -> Bool) -> Revset -> Revset
1459matching = \p rs repo -> filter (\i -> p (commitAt i repo)) (rs repo)
2132matching = \p rs repo -> filter (\i -> p (commitAt i repo)) (rs repo)
1460
2133
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.
1461conflicts : Revset
2139conflicts : Revset
1462conflicts = matching (\c -> not (null (conflicted c.files))) all
2140conflicts = matching (\c -> not (null (conflicted c.files))) all
1463
2141
@@ -1466,7 +2144,10 @@ firstOf : [Revset] -> Revset
1466firstOf = \rss repo -> foldl (\acc rs -> if null acc then rs repo else acc) [] rss
2144firstOf = \rss repo -> foldl (\acc rs -> if null acc then rs repo else acc) [] rss
1467
2145
1468-- The main line: whichever of these names the remote has, first one wins.
2146-- 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'.
1470trunk : Revset
2151trunk : Revset
1471trunk = firstOf [%main %master %trunk]
2152trunk = firstOf [%main %master %trunk]
1472
2153
@@ -1482,21 +2163,33 @@ immutable = ancestorsOf trunk
1482-- Focusing. `goto` is the only place a revset must have exactly one member.
2163-- Focusing. `goto` is the only place a revset must have exactly one member.
1483------------------------------------------------------------------------------
2164------------------------------------------------------------------------------
1484
2165
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.
1485goto : Revset -> Edit
2168goto : Revset -> Edit
1486goto = \rs repo ->
2169goto = \rs repo ->
1487 let is = rs repo
2170 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"
1488 in if length is == 1
2175 in if length is == 1
1489 then by (head is) repo
2176 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.")
1491
2180
1492commitAt : Id -> Repo -> Commit
2181commitAt : Id -> Repo -> Commit
1493−commitAt = \i repo -> (by i repo).root
2182+commitAt = lookupCommit
1494
2183
1495prev : Edit -- the parent
2184prev : Edit -- the parent
1496prev = goto parents
2185prev = goto parents
1497
2186
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`.
1498−next : Edit -- the only child
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"
1499−next = goto kids
2192+ else goto kids repo
1500
2193
1501tip : Edit -- end of the line of only-children
2194tip : Edit -- end of the line of only-children
1502tip = \repo -> tip (next repo) or repo
2195tip = \repo -> tip (next repo) or repo
@@ -1522,6 +2215,8 @@ eachChild = forEach kids
1522guard : (Repo -> Bool) -> Edit -- crash unless p holds
2215guard : (Repo -> Bool) -> Edit -- crash unless p holds
1523guard = \p repo -> if p repo then repo else crash "guard"
2216guard = \p repo -> if p repo then repo else crash "guard"
1524
2217
2218+-- Whether the focused commit has no unresolved files. This says nothing
2219+-- about unrecorded working-copy edits; inspect those with `j unrecorded`.
1525clean : Repo -> Bool
2220clean : Repo -> Bool
1526clean = \repo -> null (conflicted repo.root.files)
2221clean = \repo -> null (conflicted repo.root.files)
1527
2222
@@ -1560,11 +2255,28 @@ rewrite = \f repo ->
1560-- Commands
2255-- Commands
1561------------------------------------------------------------------------------
2256------------------------------------------------------------------------------
1562
2257
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+
1563-- Set the message of the focused commit.
2273-- Set the message of the focused commit.
2274+-- Example: j 'describe "fix login"'
1564describe : Text -> Edit
2275describe : Text -> Edit
1565describe = \m -> mapRoot (\c -> c { message = m })
2276describe = \m -> mapRoot (\c -> c { message = m })
1566
2277
1567-- Start an empty child of the focus and move to it.
2278-- Start an empty child of the focus and move to it.
2279+-- Examples: j new; j 'describe "next change" . new'
1568new : Edit
2280new : Edit
1569new = \repo -> addChild (newCommit repo.root.files) repo
2281new = \repo -> addChild (newCommit repo.root.files) repo
1570
2282
@@ -1572,6 +2284,7 @@ new = \repo -> addChild (newCommit repo.root.files) repo
1572-- new empty commit on that parent becomes the focus (the parent may be
2284-- new empty commit on that parent becomes the focus (the parent may be
1573-- immutable, and the focus never is). Refused for a commit the remote has a
2285-- immutable, and the focus never is). Refused for a commit the remote has a
1574-- name for: push something else under that name first, or forget the name.
2286-- 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'.
1575abandon : Edit
2288abandon : Edit
1576abandon = \repo ->
2289abandon = \repo ->
1577 let c = repo.root
2290 let c = repo.root
@@ -1581,17 +2294,20 @@ abandon = \repo ->
1581 else new (remove (eachChild (replayOnto p) repo))
2294 else new (remove (eachChild (replayOnto p) repo))
1582
2295
1583-- Push the files matching m from the focus down into its parent.
2296-- Push the files matching m from the focus down into its parent.
2297+-- Example: j 'contract (ext "rs")'
1584contract : Fileset -> Edit
2298contract : Fileset -> Edit
1585contract = \m repo -> let a = repo.root
2299contract = \m repo -> let a = repo.root
1586 in at parents (rewrite (\q -> q { files = select m a.files q.files })) repo
2300 in at parents (rewrite (\q -> q { files = select m a.files q.files })) repo
1587
2301
1588-- Fold the focus entirely into its parent; a new empty commit becomes the focus.
2302-- Fold the focus entirely into its parent; a new empty commit becomes the focus.
2303+-- Example: j squash; preview first with j 'preview squash'.
1589squash : Edit
2304squash : Edit
1590squash = abandon . contract everything
2305squash = abandon . contract everything
1591
2306
1592-- Push the files matching m from the focus up into a new child, which becomes
2307-- Push the files matching m from the focus up into a new child, which becomes
1593-- the focus and inherits the old focus's children. Labels stay with the
2308-- the focus and inherits the old focus's children. Labels stay with the
1594-- original commit, since they are the remote's.
2309-- original commit, since they are the remote's.
2310+-- Example: j 'split (ext "rs")'; use (under ./src) to select a directory.
1595split : Fileset -> Edit
2311split : Fileset -> Edit
1596split = \m repo ->
2312split = \m repo ->
1597 let a = repo.root
2313 let a = repo.root
@@ -1601,6 +2317,7 @@ split = \m repo ->
1601 in by x.id (repo { root = a', children = [{ root = x, children = repo.children }] })
2317 in by x.id (repo { root = a', children = [{ root = x, children = repo.children }] })
1602
2318
1603-- Move the focused subtree onto another commit. Returns to the moved commit.
2319-- Move the focused subtree onto another commit. Returns to the moved commit.
2320+-- Example: j 'rebase trunk'; preview with j 'preview (rebase trunk)'.
1604rebase : Revset -> Edit
2321rebase : Revset -> Edit
1605rebase = \rs repo ->
2322rebase = \rs repo ->
1606 let d = (goto rs repo).root
2323 let d = (goto rs repo).root
@@ -1611,6 +2328,7 @@ rebase = \rs repo ->
1611 in by me (attach x.subtree (by d.id x.rest))
2328 in by me (attach x.subtree (by d.id x.rest))
1612
2329
1613-- Apply the focused commit's change onto another commit, as a new child there.
2330-- Apply the focused commit's change onto another commit, as a new child there.
2331+-- Example: j 'pick %main'
1614pick : Revset -> Edit
2332pick : Revset -> Edit
1615pick = \rs repo ->
2333pick = \rs repo ->
1616 let ch = changeOf repo
2334 let ch = changeOf repo
@@ -1618,6 +2336,7 @@ pick = \rs repo ->
1618 in at rs (addChild (newCommit (replay t.files ch))) repo
2336 in at rs (addChild (newCommit (replay t.files ch))) repo
1619
2337
1620-- A new child of the focus that undoes the change of the given commit.
2338-- A new child of the focus that undoes the change of the given commit.
2339+-- Example: j 'backout %feature'
1621backout : Revset -> Edit
2340backout : Revset -> Edit
1622backout = \rs repo ->
2341backout = \rs repo ->
1623 let ch = changeOf (goto rs repo)
2342 let ch = changeOf (goto rs repo)
@@ -1663,40 +2382,53 @@ entryAt = \p s -> filter (\e -> e.path == p) s
1663
2382
1664-- The content at path p, or an empty file if absent.
2383-- The content at path p, or an empty file if absent.
1665contentAt : Path -> Snapshot -> Blob
2384contentAt : Path -> Snapshot -> Blob
1666−contentAt = \p s -> (head (entryAt p s)).content or blob ""
2385+contentAt = lookupContent
1667
2386
1668-- The paths a change touches.
2387-- The paths a change touches.
1669touched : Change -> [Path]
2388touched : Change -> [Path]
1670−touched = \ch ->
2389+touched = touchedPaths
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
1673
2390
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
1675changed : Repo -> [Path]
2395changed : Repo -> [Path]
1676changed = \repo -> touched (changeOf repo)
2396changed = \repo -> touched (changeOf repo)
1677
2397
1678−-- What the focus changes, rendered by difftastic, one record per changed path.
2398+-- What the focus changes, one record per changed path. Uses difftastic when
1679−-- Replace difft with (\p -> diff) to use the built-in unified diff instead.
2399+-- installed; a missing difft falls back to a unified diff with path headers
2400+-- and 3 context lines, or a binary-files summary.
1680diffs : Repo -> [{ path : Path, diff : Text }]
2401diffs : Repo -> [{ path : Path, diff : Text }]
1681diffs = \repo ->
2402diffs = \repo ->
1682 let ch = changeOf repo
2403 let ch = changeOf repo
1683 in map (\p -> { path = p, diff = difft p (contentAt p ch.from) (contentAt p ch.to) }) (touched ch)
2404 in map (\p -> { path = p, diff = difft p (contentAt p ch.from) (contentAt p ch.to) }) (touched ch)
1684
2405
1685-- The same, as one page of text. A Text result prints raw, so `j review` is
2406-- 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.
1687review : Repo -> Text
2412review : Repo -> Text
1688−review = \repo -> concat (map (\d -> d.diff) (diffs repo)) or "no changes\n"
2413+review = \repo ->
1689−
2414+ let ds = diffs repo
2415+ in if null ds then "no changes\n" else concat (map (\d -> d.diff) ds)
2416+
1690−-- A summary of the focus.
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
1691−status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path] }
2421+status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path], unrecorded : [Path] }
1692status = \repo ->
2422status = \repo ->
1693 { id = repo.root.id
2423 { id = repo.root.id
1694 , message = repo.root.message
2424 , message = repo.root.message
1695 , labels = repo.root.labels
2425 , labels = repo.root.labels
1696 , changed = changed repo
2426 , changed = changed repo
1697− , conflicts = conflicted repo.root.files }
2427+ , conflicts = conflicted repo.root.files
2428+ , unrecorded = unrecorded repo }
1698
2429
1699-- A summary of every commit, top first, in preorder.
2430-- A summary of every commit, top first, in preorder.
2431+-- Example: j log
1700log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]
2432log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]
1701log = \repo ->
2433log = \repo ->
1702 map (\c -> { id = c.id
2434 map (\c -> { id = c.id
@@ -1707,11 +2439,21 @@ log = \repo ->
1707
2439
1708-- The history as a tree. Edit these records to change what `j tree` shows.
2440-- The history as a tree. Edit these records to change what `j tree` shows.
1709-- (The parentheses are required: `f { … }` would be a record update of f.)
2441-- (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.
1710tree : Repo -> Text
2445tree : 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 })
1712
2450
1713treeFull : Repo -> Text
2451treeFull : 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 })
1715```
2457```
1716
2458
1717---
2459---
@@ -1719,13 +2461,15 @@ treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false,
1719## 10. Testing
2461## 10. Testing
1720
2462
1721The interpreter must be testable without a jj repository. Provide an in-memory
2463The 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`,
1723`text`, `by`, `extract`, `meta`, `difft` as a stub, and the minting
2465`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
1725conflict is a record of the three sides, and `replay` resolves a path exactly
2467conflict 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
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).
1727−the laws of §8 over randomly generated `Repo` values (bounded depth and width,
2470+Property-test the laws of §8 over randomly generated `Repo` values (bounded
1728−random labels, random file edits) using the reference `config.j` unmodified.
2471+depth and width, random labels, random file edits) using the reference
2472+`config.j` unmodified.
1729
2473
1730The builtin `by` must agree, on every generated repo, with this in-language
2474The builtin `by` must agree, on every generated repo, with this in-language
1731reference definition, which uses positional moves the public base does not
2475reference 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.
1755**Getting a repository**
2499**Getting a repository**
1756
2500
1757```
2501```
1758−$EDITOR ~/.config/j/config.j -- set `user` once
2502+j setup 'Your actual name' you@your-domain.example -- create the full config once
1759j init -- new repository here, or adopt an existing .git
2503j 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
1761j remote git@host:org/repo.git -- attach a remote to an init'd repo
2506j remote git@host:org/repo.git -- attach a remote to an init'd repo
1762```
2507```
1763
2508
@@ -1766,9 +2511,9 @@ j remote git@host:org/repo.git -- attach a remote to an init'd repo
1766```
2511```
1767j tree -- the history as a tree, focus marked
2512j tree -- the history as a tree, focus marked
1768j treeFull -- the same with sizes, ages, authors, changed paths
2513j 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
1770j log -- every commit as a record
2515j 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
1772j review . by @kpq -- the same for another commit, without moving
2517j review . by @kpq -- the same for another commit, without moving
1773j diffs -- the same, one record per path
2518j diffs -- the same, one record per path
1774j here -- @'s id
2519j here -- @'s id
@@ -1799,7 +2544,8 @@ j rebase trunk
1799j guard clean . rebase trunk or id -- only if it comes out clean
2544j guard clean . rebase trunk or id -- only if it comes out clean
1800j pick @kpq
2545j pick @kpq
1801j 'forEach (descendantsOf here) (describe "wip")'
2546j '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
1803j tree . squash -- dry run: show the result, persist nothing
2549j tree . squash -- dry run: show the result, persist nothing
1804j tree . validate . squash -- the same, refusing what persistence would refuse
2550j tree . validate . squash -- the same, refusing what persistence would refuse
1805j squash && j tree -- do, then look
2551j squash && j tree -- do, then look
@@ -1821,7 +2567,10 @@ j rebase trunk -- bring the stack up to date
1821A rebase or fetch that conflicts leaves unresolved files in the affected
2567A rebase or fetch that conflicts leaves unresolved files in the affected
1822commits. `j conflicts` lists them; `j by @…` moves there, which checks the
2568commits. `j conflicts` lists them; `j by @…` moves there, which checks the
1823files out with conflict markers; editing the files and running any persisting
2569files 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.
1825
2574
1826**Mistakes**
2575**Mistakes**
1827
2576
@@ -1836,7 +2585,7 @@ j redo
1836Any definition added to `config.j` whose value is an `Edit` is a command:
2585Any definition added to `config.j` whose value is an `Edit` is a command:
1837
2586
1838```haskell
2587```haskell
1839−pr = \n -> describe n . squash
2588+pr = \n -> describe n . prev . squash
1840```
2589```
1841
2590
1842```
2591```
@@ -1849,8 +2598,9 @@ j 'pr "fix login"' && j 'push (label "fix-login" here)'
1849
2598
1850Second parents of merge commits (merges are placed under their first parent
2599Second parents of merge commits (merges are placed under their first parent
1851and are read-only); hunk-level `split`; conflict resolution inside the
2600and are read-only); hunk-level `split`; conflict resolution inside the
1852−language (blobs are opaque); workspaces other than the default; remotes other
2601+language (blobs are opaque); workspaces other than the default; sparse working
1853−than `origin`; tags; submodules and LFS; ignore rules beyond `.gitignore`;
2602+copies (refused; §7.1); remotes other than `origin`; tags; submodules and LFS;
1854−changing file modes or symlinks from the language; repairing a repository in
2603+ignore rules beyond `.gitignore`; repairing a repository in which one change
1855−which one change id has two visible commits (refused; §7.2); a `--help` flag
2604+id has two visible commits (refused; §7.2); general command flags beyond the
2605+`--help` and `-h` discovery arguments; static typing. None of
1856−or any flag at all; static typing. None of these are precluded by the design.
2606+these are precluded by the design.
specs/tree.md+94−50
@@ -113,6 +113,8 @@ After `n` is placed in lane `ℓ`:
113* If `n` was the last child of `p` and did not inherit `p`'s lane (rule 5 with
113* If `n` was the last child of `p` and did not inherit `p`'s lane (rule 5 with
114 `p` the trunk head, or `p` the root of an empty trunk), `p`'s lane becomes
114 `p` the trunk head, or `p` the root of an empty trunk), `p`'s lane becomes
115 empty below this row: the fork on this row is drawn with `╰` instead of `├`.
115 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 `│`.
116
118
117**Reservations.** When a node `p ∈ T` is placed and has a trunk child `t`,
119**Reservations.** When a node `p ∈ T` is placed and has a trunk child `t`,
118every side child of `p` that comes *after* `t` in row order needs its own
120every 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.
135If rule 5 or a reservation finds no empty lane in `0 < ℓ < lanes`, the
137If rule 5 or a reservation finds no empty lane in `0 < ℓ < lanes`, the
136subtree rooted at `n` is **flattened**: `n` and every descendant of it still
138subtree rooted at `n` is **flattened**: `n` and every descendant of it still
137get their rows, in row order, with their id, message, labels and margin, but
139get 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
140+they take no lane. Their rails area preserves every rail that passes through.
139−and `»` in the last column. Nothing is hidden — the focus in particular is
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
140−always shown — only the shape beyond `lanes` is lost. Widen `lanes` (or
148+— only the shape beyond `lanes` is lost. Widen `lanes` (or enable `elide`) to
141−enable `elide`) to see it.
149+see it.
142
150
143#### Step 4 — drawing a row
151#### Step 4 — drawing a row
144
152
@@ -147,26 +155,34 @@ Columns, left to right, at fixed offsets:
1471. **Gutter**, 2 characters: `▶ ` on the focus row, blank otherwise.
1551. **Gutter**, 2 characters: `▶ ` on the focus row, blank otherwise.
1482. **Rails**, `2 · lanes` characters: lane `i` owns characters `2i` and
1562. **Rails**, `2 · lanes` characters: lane `i` owns characters `2i` and
149 `2i+1`.
157 `2i+1`.
158+3. **Overflow status**, 4 display columns when any rendered node is flattened;
159+ absent otherwise (§Overflow).
150−3. One space.
160+4. One space.
151−4. **Id**: `@` followed by the shortest prefix unique among the commits of the
161+5. **Id**: `@` followed by the shortest prefix unique among the commits of the
152 display tree, minimum 4 — so the rendered id is itself a valid id literal
162 display tree, minimum 4 — so the rendered id is itself a valid id literal
153 (§3.1). Blank on a run row.
163 (§3.1). Blank on a run row.
154−5. **Size bar** (`detail ≥ 1`, focus and its parent and children only; all
164+6. **Size bar** (`detail ≥ 1`, focus and its parent and children only; all
155− commits at `detail = 2`): `▁ ▂ ▃ ▅ ▇`, as before.
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.
156−6. **Message**: first line; nothing if empty; followed by `⋯ n` on a
167+7. **Message**: first line; nothing if empty; followed by `⋯ n` on a
157 collapsed node.
168 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
159 a label; names separated by two spaces.
170 a label; names separated by two spaces.
160−8. **Margin** (`margin = true`): age and author initials, right-aligned, as
171+9. **Margin** (`margin = true`): age and author initials, as before, each
161− before.
172+ right-aligned in its own column (a `9m` ends under a `13m`), so the margin
173+ ends at one column on every row.
162−9. **Data columns** (off unless enabled): `date` shows the commit's absolute
174+10. **Data columns** (off unless enabled): `date` shows the commit's absolute
163 date `YYYY-MM-DD`; `files` shows the number of files changed as `n files`;
175 date `YYYY-MM-DD`; `files` shows the number of files changed as `n files`;
164 `author` shows the full author name. They appear in that order, grey,
176 `author` shows the full author name. They appear in that order, grey,
165 immediately before the margin in the same right-aligned block.
177 immediately before the margin in the same right-aligned block.
166
178
167−Columns 4–8 start at the same offset on every row. When stdout is a terminal
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
168−and a row would exceed its width, the message is cut and ends with `…` so that
184+would exceed its width, the message is cut and ends with `…` so that labels
169−labels and the margin keep their columns; otherwise nothing is cut.
185+and the margin keep their columns; otherwise nothing is cut.
170
186
171**Rails characters.** For lane `i` on the row of node `n` placed in lane
187**Rails characters.** For lane `i` on the row of node `n` placed in lane
172`ℓ`, character `2i` is, first rule that applies:
188`ℓ`, character `2i` is, first rule that applies:
@@ -179,19 +195,27 @@ labels and the margin keep their columns; otherwise nothing is cut.
179| `i` lies between the two ends of a horizontal segment on this row and holds a rail | `┼` |
195| `i` lies between the two ends of a horizontal segment on this row and holds a rail | `┼` |
180| `i` lies between the two ends of a horizontal segment on this row | `─` |
196| `i` lies between the two ends of a horizontal segment on this row | `─` |
181| `i` holds a rail passing through this row | `│` |
197| `i` holds a rail passing through this row | `│` |
182−| flattened rows only: `i = lanes − 1` | `»` |
183| otherwise | space |
198| otherwise | space |
184
199
185Character `2i+1` is `─` if it lies inside a horizontal segment (from a fork
200Character `2i+1` is `─` if it lies inside a horizontal segment (from a fork
186source to its target, or from a node to its rightmost reservation), else a
201source to its target, or from a node to its rightmost reservation), else a
187space. On a run row the count follows the `╎`, written into the rails area
202space. On a run row the count follows the `╎`, written into the rails area
188−and, if needed, into the empty id column.
203+and, if needed, into the empty id column; when lanes to the right of the run
189−
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+
190−**Detail line** (`detail = 2`, focus only): one extra row directly under the
209+**Detail lines** (`detail = 2`, focus only): changed paths appear directly
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.
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.
195
219
196#### Glyphs
220#### Glyphs
197
221
@@ -208,7 +232,13 @@ Unchanged from before:
208| `⌂` | root | `🌱` |
232| `⌂` | root | `🌱` |
209
233
210Precedence: `⌂`, then `⊗`, then `◌`, then `◆`, then the position glyphs.
234Precedence: `⌂`, 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.
212
242
213#### Elision (`elide = true`)
243#### Elision (`elide = true`)
214
244
@@ -232,18 +262,29 @@ drawn with `⌂`.
232
262
233#### Colour
263#### Colour
234
264
235−Glyphs are coloured by meaning (the same precedence as their shape): root bold,
265+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
266+magenta, conflict bright red, empty dim, immutable bright blue, focus bold
237−green, other light grey. Ids keep their existing colours (conflict red,
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 `▶`
238−immutable blue). Messages are bold on the focus row, red on a conflict, and
269+gutter are bold cyan. Messages are bold on the focus row, red on a conflict,
239−dim-italic when empty. Labels are green; the margin and the data columns are
270+and dim-italic when empty. Labels are bold green; the margin and data columns
240−grey with author-hued initials. Rail connectors in lane 0 take the immutable
271+are grey with bright author-hued initials. Size bars progress from cyan
241−colour; connectors in other lanes are dim.
272+through blue and magenta to yellow as their height increases. Rail connectors
242−
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+
243−The whole focus row is highlighted with a background band that spans the full
276+The whole focus row is highlighted with bright text on a dark-blue background
244−terminal width. This is colour only: glyphs, rails, and the `▶` gutter still
277+band that spans the full terminal width. This is colour only: glyphs, rails,
245−carry the meaning, so a colourless rendering loses nothing — the band is a
278+and the `▶` gutter still carry the meaning, so a colourless rendering loses
246−no-op when colour is off.
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.
247
288
248#### Legend
289#### Legend
249
290
@@ -256,8 +297,10 @@ The legend covers the node glyphs present (§Glyphs), the focus gutter `▶`,
256run `╎ n` and collapsed `⋯ n` rows when shown, and the detail-line marks
297run `╎ n` and collapsed `⋯ n` rows when shown, and the detail-line marks
257(`+` added, `~` modified, `−` deleted, `✖` unresolved) when a detail line is
298(`+` added, `~` modified, `−` deleted, `✖` unresolved) when a detail line is
258present. A collapsed node shows `⋯ n` only when `n > 0`, so the `⋯` legend
299present. 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
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
260−structural (§Step 4).
303+are not itemised; they are structural (§Step 4).
261
304
262The legend text is faint (dim). Placement: when stdout is a terminal and the
305The legend text is faint (dim). Placement: when stdout is a terminal and the
263tree's widest line plus the legend fits the terminal width, the legend is set
306tree's widest line plus the legend fits the terminal width, the legend is set
@@ -290,20 +333,21 @@ Rendered:
290
333
291```
334```
292 ╎ 14
335 ╎ 14
293− ◆─╮ @aaaa ▅ add parser 3w mo
336+ ◆─╮ @aaaa ▅ add parser 3w mo
294− ◆ │ @kpqx ▅ release 1.2 main 2w mo
337+ ◆ │ @kpqx ▅ release 1.2 main 2w mo
295− │ ○ @mnrv ▂ fix lexer 1w mo
338+ │ ○ @mnrv ▂ fix lexer 1w mo
296−▶ ├─⊗ @wqzt ▃ wip feature 2d mo
339+▶ ├─⊗ @wqzt ▃ wip feature 2d mo
297 │ │ ✖ src/lexer.rs ~ src/parser.rs + tests/lexer.rs
340 │ │ ✖ src/lexer.rs ~ src/parser.rs + tests/lexer.rs
298− ╰─┼─◌ @ptlm docs ⋯ 3 1d ak
341+ ╰─┼─◌ @ptlm docs ⋯ 3 1d ak
299− ├─○ @qrst ▁ spike 5h mo
342+ ├─○ @qrst ▁ spike 5h mo
300− ○ @yxsk ▃ 1h mo
343+ ○ @yxsk ▃ 1h mo
301```
344```
302
345
303−Column offsets in this example: gutter 0–1, rails 2–7, id from 9. Lane 0 is
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
304−`◆ ◆ │ ├ ╰` from `aaaa` to `ptlm` and then empty; the reservation for `mnrv`
348+marks from 11. Lane 0 is `◆ ◆ │ ├ ╰` from `aaaa` to `ptlm` and then empty;
305−is drawn on `aaaa`'s row as `─╮` and consumed on `mnrv`'s row; `ptlm` forks
349+the reservation for `mnrv` is drawn on `aaaa`'s row as `─╮` and consumed on
306−to lane 2 across the live lane 1, hence `┼`.
350+`mnrv`'s row; `ptlm` forks to lane 2 across the live lane 1, hence `┼`.
307
351
308The reference `config.j` defines `tree` with `detail = 1, margin = false,
352The reference `config.j` defines `tree` with `detail = 1, margin = false,
309elide = false` (every commit, time-sorted), `treeCompact` with `elide = true`
353elide = false` (every commit, time-sorted), `treeCompact` with `elide = true`
src/ast.rs+35−2
@@ -29,7 +29,7 @@ pub enum Expr {
29 LabelLit(String),
29 LabelLit(String),
30 PathLit(Vec<String>),
30 PathLit(Vec<String>),
31 Bool(bool),
31 Bool(bool),
32− Lambda(Vec<Pattern>, Rc<Expr>, String), // params, body, source text
32+ Lambda(Vec<Pattern>, Rc<Expr>, Source), // params, body, source text
33 If(Rc<Expr>, Rc<Expr>, Rc<Expr>),
33 If(Rc<Expr>, Rc<Expr>, Rc<Expr>),
34 Let(Vec<(String, Rc<Expr>)>, Rc<Expr>),
34 Let(Vec<(String, Rc<Expr>)>, Rc<Expr>),
35 App(Rc<Expr>, Rc<Expr>),
35 App(Rc<Expr>, Rc<Expr>),
@@ -45,6 +45,34 @@ pub enum Expr {
45 Crash, // internal marker if needed
45 Crash, // internal marker if needed
46}
46}
47
47
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+
48#[derive(Debug, Clone)]
76#[derive(Debug, Clone)]
49pub enum Item {
77pub enum Item {
50 TypeDecl(String, TypeExpr),
78 TypeDecl(String, TypeExpr),
@@ -61,13 +89,18 @@ impl Expr {
61 out.insert(n.clone());
89 out.insert(n.clone());
62 }
90 }
63 }
91 }
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+ }
64 Expr::TypeName(_)
98 Expr::TypeName(_)
65 | Expr::Int(_)
99 | Expr::Int(_)
66 | Expr::Text(_)
100 | Expr::Text(_)
67 | Expr::IdLit(_)
101 | Expr::IdLit(_)
68 | Expr::Id(_)
102 | Expr::Id(_)
69 | Expr::NewId
103 | Expr::NewId
70− | Expr::LabelLit(_)
71 | Expr::PathLit(_)
104 | Expr::PathLit(_)
72 | Expr::Bool(_)
105 | Expr::Bool(_)
73 | Expr::SelectorFun(_)
106 | Expr::SelectorFun(_)
src/builtins.rs+134−88
@@ -74,8 +74,8 @@ pub fn all_builtins() -> Vec<(String, Value)> {
74 builtin(">=", 2, b_ge),
74 builtin(">=", 2, b_ge),
75 builtin("show", 1, b_show),
75 builtin("show", 1, b_show),
76 builtin("::", 2, b_cons),
76 builtin("::", 2, b_cons),
77− builtin("map", 2, b_map),
77+ builtin("map", 2, on_machine),
78− builtin("filter", 2, b_filter),
78+ builtin("filter", 2, on_machine),
79 builtin("length", 1, b_length),
79 builtin("length", 1, b_length),
80 builtin("null", 1, b_null),
80 builtin("null", 1, b_null),
81 builtin("head", 1, b_head),
81 builtin("head", 1, b_head),
@@ -86,7 +86,7 @@ pub fn all_builtins() -> Vec<(String, Value)> {
86 builtin("drop", 2, b_drop),
86 builtin("drop", 2, b_drop),
87 builtin("member", 2, b_member),
87 builtin("member", 2, b_member),
88 builtin("range", 2, b_range),
88 builtin("range", 2, b_range),
89− builtin("foldl", 3, b_foldl),
89+ builtin("foldl", 3, on_machine),
90 builtin("concat", 1, b_concat),
90 builtin("concat", 1, b_concat),
91 builtin("++", 2, b_append),
91 builtin("++", 2, b_append),
92 builtin("startsWith", 2, b_starts_with),
92 builtin("startsWith", 2, b_starts_with),
@@ -95,9 +95,14 @@ pub fn all_builtins() -> Vec<(String, Value)> {
95 builtin("replay", 2, b_replay),
95 builtin("replay", 2, b_replay),
96 builtin("unresolved", 1, b_unresolved),
96 builtin("unresolved", 1, b_unresolved),
97 builtin("touchedPaths", 1, b_touched_paths),
97 builtin("touchedPaths", 1, b_touched_paths),
98+ builtin("unrecorded", 1, b_unrecorded),
99+ builtin("subtreeCommits", 1, b_subtree_commits),
98 builtin("blob", 1, b_blob),
100 builtin("blob", 1, b_blob),
101+ builtin("blobHex", 2, b_blob_hex),
99 builtin("text", 1, b_text),
102 builtin("text", 1, b_text),
100 builtin("by", 2, b_by),
103 builtin("by", 2, b_by),
104+ builtin("lookupCommit", 2, b_lookup_commit),
105+ builtin("lookupContent", 2, b_lookup_content),
101 builtin("meta", 1, b_meta),
106 builtin("meta", 1, b_meta),
102 builtin("validate", 1, b_validate),
107 builtin("validate", 1, b_validate),
103 builtin("diff", 2, b_diff),
108 builtin("diff", 2, b_diff),
@@ -116,33 +121,13 @@ fn b_compose(_i: &mut Interp, args: &[Value]) -> BResult {
116 compose_values(_i, f, g)
121 compose_values(_i, f, g)
117}
122}
118
123
119−fn compose_apply(i: &mut Interp, args: &[Value]) -> BResult {
124+/// `map`, `filter`, `foldl` and a composition node apply the functions they
120− let f = args[0].clone();
125+/// are given, so the evaluator runs them as steps of its machine
121− let g = args[1].clone();
126+/// (Interp::step_applying): applying them in a nested run put every level of
122− let x = args[2].clone();
127+/// a recursion through one on the native stack (§4.1). This only gives them a
123− // A composed function applied to a function does not run (the operand
128+/// name and an arity here; it is never called.
124− // contracts are about Repo-level values); it composes pointwise so that
129+fn on_machine(_i: &mut Interp, _args: &[Value]) -> BResult {
125− // definitions like `abandon . contract everything` and `tree . squash`
130+ Err(Crash::new("internal: a builtin that applies functions ran off the machine"))
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)
146}
131}
147
132
148/// f . g as a value, with contract propagation
133/// f . g as a value, with contract propagation
@@ -154,6 +139,8 @@ pub fn compose_values(i: &mut Interp, f: Value, g: Value) -> BResult {
154 Box::new((*cf).clone()),
139 Box::new((*cf).clone()),
155 Box::new((*cg).clone()),
140 Box::new((*cg).clone()),
156 )),
141 )),
142+ // named holding `f` and `g`
143+ 2,
157 )),
144 )),
158 _ => None,
145 _ => None,
159 };
146 };
@@ -163,7 +150,7 @@ pub fn compose_values(i: &mut Interp, f: Value, g: Value) -> BResult {
163 name: "(.)".into(),
150 name: "(.)".into(),
164 arity: 3,
151 arity: 3,
165 args: vec![f, g],
152 args: vec![f, g],
166− f: compose_apply,
153+ f: on_machine,
167 pending,
154 pending,
168 })))
155 })))
169}
156}
@@ -182,8 +169,9 @@ pub fn pending_of(v: &Value) -> Option<(String, Rc<crate::shape::ContractExpr>)>
182 ..
169 ..
183 } => Some((n.clone(), Rc::new(crate::shape::ContractExpr::Unknown))),
170 } => Some((n.clone(), Rc::new(crate::shape::ContractExpr::Unknown))),
184 crate::value::FunVal::Builtin {
171 crate::value::FunVal::Builtin {
185− pending: Some(p), ..
172+ pending: Some((n, c, _)),
173+ ..
186− } => Some(p.clone()),
174+ } => Some((n.clone(), c.clone())),
187 crate::value::FunVal::Builtin {
175 crate::value::FunVal::Builtin {
188 name, pending: None, ..
176 name, pending: None, ..
189 } => Some((name.clone(), Rc::new(crate::shape::ContractExpr::Unknown))),
177 } => Some((name.clone(), Rc::new(crate::shape::ContractExpr::Unknown))),
@@ -215,16 +203,26 @@ fn b_ne(_i: &mut Interp, args: &[Value]) -> BResult {
215 Ok(Value::Bool(!value_eq(&args[0], &args[1])?))
203 Ok(Value::Bool(!value_eq(&args[0], &args[1])?))
216}
204}
217
205
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.
218fn b_and(_i: &mut Interp, args: &[Value]) -> BResult {
210fn b_and(_i: &mut Interp, args: &[Value]) -> BResult {
219 want!(args, 0, Value::Bool(_), "Bool");
211 want!(args, 0, Value::Bool(_), "Bool");
220 want!(args, 1, Value::Bool(_), "Bool");
212 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+ }
222}
217}
223
218
224fn b_or(_i: &mut Interp, args: &[Value]) -> BResult {
219fn b_or(_i: &mut Interp, args: &[Value]) -> BResult {
225 want!(args, 0, Value::Bool(_), "Bool");
220 want!(args, 0, Value::Bool(_), "Bool");
226 want!(args, 1, Value::Bool(_), "Bool");
221 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+ }
228}
226}
229
227
230fn b_not(_i: &mut Interp, args: &[Value]) -> BResult {
228fn b_not(_i: &mut Interp, args: &[Value]) -> BResult {
@@ -275,7 +273,7 @@ fn b_ge(_i: &mut Interp, args: &[Value]) -> BResult {
275}
273}
276
274
277fn b_show(i: &mut Interp, args: &[Value]) -> BResult {
275fn 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])?))
279}
277}
280
278
281fn b_cons(_i: &mut Interp, args: &[Value]) -> BResult {
279fn b_cons(_i: &mut Interp, args: &[Value]) -> BResult {
@@ -286,35 +284,6 @@ fn b_cons(_i: &mut Interp, args: &[Value]) -> BResult {
286 Ok(Value::list(v))
284 Ok(Value::list(v))
287}
285}
288
286
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−
318fn b_length(_i: &mut Interp, args: &[Value]) -> BResult {
287fn b_length(_i: &mut Interp, args: &[Value]) -> BResult {
319 Ok(Value::int(args[0].as_list()?.len() as i64))
288 Ok(Value::int(args[0].as_list()?.len() as i64))
320}
289}
@@ -331,11 +300,15 @@ fn b_head(_i: &mut Interp, args: &[Value]) -> BResult {
331}
300}
332
301
333fn b_tail(_i: &mut Interp, args: &[Value]) -> BResult {
302fn b_tail(_i: &mut Interp, args: &[Value]) -> BResult {
334− let xs = args[0].as_list()?;
303+ if args[0].as_list()?.is_empty() {
335− if xs.is_empty() {
336 return Err(Crash::new("tail: empty list"));
304 return Err(Crash::new("tail: empty list"));
337 }
305 }
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+ }
339}
312}
340
313
341fn b_last(_i: &mut Interp, args: &[Value]) -> BResult {
314fn b_last(_i: &mut Interp, args: &[Value]) -> BResult {
@@ -379,9 +352,11 @@ fn b_take(_i: &mut Interp, args: &[Value]) -> BResult {
379}
352}
380
353
381fn b_drop(_i: &mut Interp, args: &[Value]) -> BResult {
354fn 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())?;
355+ let n = clamp(args[0].as_int()?, args[1].as_list()?.len())?;
384− Ok(Value::list(xs[n..].to_vec()))
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+ }
385}
360}
386
361
387fn b_member(_i: &mut Interp, args: &[Value]) -> BResult {
362fn b_member(_i: &mut Interp, args: &[Value]) -> BResult {
@@ -405,17 +380,6 @@ fn b_range(_i: &mut Interp, args: &[Value]) -> BResult {
405 Ok(Value::list(out))
380 Ok(Value::list(out))
406}
381}
407
382
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−
419fn b_concat(_i: &mut Interp, args: &[Value]) -> BResult {
383fn b_concat(_i: &mut Interp, args: &[Value]) -> BResult {
420 let xs = args[0].as_list()?;
384 let xs = args[0].as_list()?;
421 if xs.is_empty() {
385 if xs.is_empty() {
@@ -446,7 +410,7 @@ fn b_concat(_i: &mut Interp, args: &[Value]) -> BResult {
446fn b_append(_i: &mut Interp, args: &[Value]) -> BResult {
410fn b_append(_i: &mut Interp, args: &[Value]) -> BResult {
447 match (&args[0], &args[1]) {
411 match (&args[0], &args[1]) {
448 (Value::List(a), Value::List(b)) => {
412 (Value::List(a), Value::List(b)) => {
449− let mut out = a.as_ref().clone();
413+ let mut out = a.to_vec();
450 out.extend_from_slice(b);
414 out.extend_from_slice(b);
451 Ok(Value::list(out))
415 Ok(Value::list(out))
452 }
416 }
@@ -493,14 +457,11 @@ fn b_split_on(_i: &mut Interp, args: &[Value]) -> BResult {
493// ----------------------------------------------------------------------
457// ----------------------------------------------------------------------
494
458
495fn b_replay(i: &mut Interp, args: &[Value]) -> BResult {
459fn b_replay(i: &mut Interp, args: &[Value]) -> BResult {
496− let onto = args[0].as_list()?;
460+ args[0].as_list()?;
497 let ch = &args[1];
461 let ch = &args[1];
498 let from = ch.field("from")?;
462 let from = ch.field("from")?;
499 let to = ch.field("to")?;
463 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)?;
464+ i.backend.replay_snapshot(&args[0], &from, &to)
503− Ok(Value::list(out))
504}
465}
505
466
506fn b_unresolved(_i: &mut Interp, args: &[Value]) -> BResult {
467fn b_unresolved(_i: &mut Interp, args: &[Value]) -> BResult {
@@ -537,6 +498,53 @@ fn b_touched_paths(_i: &mut Interp, args: &[Value]) -> BResult {
537 Ok(Value::list(out))
498 Ok(Value::list(out))
538}
499}
539
500
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+
540fn b_blob(_i: &mut Interp, args: &[Value]) -> BResult {
548fn b_blob(_i: &mut Interp, args: &[Value]) -> BResult {
541 let t = args[0].as_text()?;
549 let t = args[0].as_text()?;
542 Ok(Value::Blob(Rc::new(BlobVal {
550 Ok(Value::Blob(Rc::new(BlobVal {
@@ -545,6 +553,21 @@ fn b_blob(_i: &mut Interp, args: &[Value]) -> BResult {
545 })))
553 })))
546}
554}
547
555
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+
548fn b_text(_i: &mut Interp, args: &[Value]) -> BResult {
571fn b_text(_i: &mut Interp, args: &[Value]) -> BResult {
549 match &args[0] {
572 match &args[0] {
550 Value::Blob(b) => {
573 Value::Blob(b) => {
@@ -578,6 +601,18 @@ fn b_by(_i: &mut Interp, args: &[Value]) -> BResult {
578 }
601 }
579}
602}
580
603
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+
581fn b_meta(i: &mut Interp, args: &[Value]) -> BResult {
616fn b_meta(i: &mut Interp, args: &[Value]) -> BResult {
582 let id = match &args[0] {
617 let id = match &args[0] {
583 Value::Id(id) => id.to_string(),
618 Value::Id(id) => id.to_string(),
@@ -599,6 +634,11 @@ fn b_meta(i: &mut Interp, args: &[Value]) -> BResult {
599
634
600fn b_validate(i: &mut Interp, args: &[Value]) -> BResult {
635fn b_validate(i: &mut Interp, args: &[Value]) -> BResult {
601 crate::repo::validate_repo(i, &args[0])?;
636 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+ }
602 Ok(args[0].clone())
642 Ok(args[0].clone())
603}
643}
604
644
@@ -651,6 +691,12 @@ fn extract_into(
651 out: &mut Vec<Value>,
691 out: &mut Vec<Value>,
652) -> Result<(), Crash> {
692) -> Result<(), Crash> {
653 use crate::value::{PrimKind, ShapeKind};
693 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;
654 let matches = match (&shape.kind, v) {
700 let matches = match (&shape.kind, v) {
655 (ShapeKind::Prim(PrimKind::Int), Value::Int(_)) => true,
701 (ShapeKind::Prim(PrimKind::Int), Value::Int(_)) => true,
656 (ShapeKind::Prim(PrimKind::Text), Value::Text(_)) => true,
702 (ShapeKind::Prim(PrimKind::Text), Value::Text(_)) => true,
src/config.rs+370−54
@@ -39,8 +39,8 @@ const RESERVED_BUILTINS: &[&str] = &[
39 ".", "id", "const", "crash", "==", "/=", "&&", "||", "not", "+", "-", "*", "<", "<=", ">",
39 ".", "id", "const", "crash", "==", "/=", "&&", "||", "not", "+", "-", "*", "<", "<=", ">",
40 ">=", "show", "::", "map", "filter", "length", "null", "head", "tail", "last", "nth", "take",
40 ">=", "show", "::", "map", "filter", "length", "null", "head", "tail", "last", "nth", "take",
41 "drop", "member", "range", "foldl", "concat", "++", "startsWith", "endsWith", "splitOn",
41 "drop", "member", "range", "foldl", "concat", "++", "startsWith", "endsWith", "splitOn",
42− "replay", "unresolved", "blob", "text", "by", "meta", "validate", "diff", "difft", "treeWith",
42+ "replay", "unresolved", "blob", "blobHex", "text", "by", "meta", "validate", "diff", "difft", "treeWith",
43− "extract", "touchedPaths",
43+ "extract", "touchedPaths", "unrecorded", "subtreeCommits", "lookupCommit", "lookupContent",
44];
44];
45
45
46pub fn is_reserved_builtin(name: &str) -> bool {
46pub fn is_reserved_builtin(name: &str) -> bool {
@@ -53,6 +53,8 @@ pub fn reserved_set() -> BTreeSet<String> {
53
53
54/// Parse and validate config.j (§6.2). Id literals are resolved separately.
54/// Parse and validate config.j (§6.2). Id literals are resolved separately.
55pub fn load_config(src: &str) -> Result<Config, ConfigError> {
55pub 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
56 let outer = Rc::new(reserved_set());
58 let outer = Rc::new(reserved_set());
57 let items = parse_config(src, outer).map_err(ConfigError::Parse)?;
59 let items = parse_config(src, outer).map_err(ConfigError::Parse)?;
58 validate_items(items)
60 validate_items(items)
@@ -95,39 +97,24 @@ fn validate_items(items: Vec<Item>) -> Result<Config, ConfigError> {
95 }
97 }
96
98
97 // pair signatures with definitions; a signature must immediately precede
99 // pair signatures with definitions; a signature must immediately precede
98− // its definition (blank lines/comments fine — items between are not)
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.
99− let mut prev_sig: Option<String> = None;
103+ let mut prev_sig: Option<&String> = None;
100− for item in &items {
104+ for item in items.iter().map(Some).chain([None]) {
101− match item {
105+ if let Some(s) = prev_sig {
102− Item::Signature(name, _, _) => {
103− prev_sig = Some(name.clone());
104− }
105− Item::Definition(name, _, _) => {
106+ let paired = matches!(item, Some(Item::Definition(name, _, _)) if name == s);
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) {
107+ if !paired && def_names.contains(s) {
110− return verr(format!(
108+ return verr(format!(
111− "the signature for `{}` is not immediately followed by its definition",
109+ "the signature for `{}` is not immediately followed by its definition",
112− s
110+ s
113− ));
111+ ));
114− }
115− }
116− }
117− prev_sig = None;
118− }
119− Item::TypeDecl(_, _) => {
120− prev_sig = None;
121 }
112 }
122 }
113 }
123− }
124− if let Some(s) = &prev_sig {
114+ prev_sig = match item {
125− if def_names.contains(s) {
115+ Some(Item::Signature(name, _, _)) => Some(name),
126− return verr(format!(
116+ _ => None,
127− "the signature for `{}` is not immediately followed by its definition",
128− s
129− ));
117+ };
130− }
131 }
118 }
132
119
133 let mut builtin_names: HashSet<String> = HashSet::new();
120 let mut builtin_names: HashSet<String> = HashSet::new();
@@ -179,7 +166,25 @@ fn validate_items(items: Vec<Item>) -> Result<Config, ConfigError> {
179 })
166 })
180}
167}
181
168
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.
183fn dependency_order(
188fn dependency_order(
184 defs: &HashMap<String, Rc<Expr>>,
189 defs: &HashMap<String, Rc<Expr>>,
185) -> Result<Vec<(String, Rc<Expr>)>, ConfigError> {
190) -> Result<Vec<(String, Rc<Expr>)>, ConfigError> {
@@ -194,7 +199,7 @@ fn dependency_order(
194 let mut done: HashSet<String> = HashSet::new();
199 let mut done: HashSet<String> = HashSet::new();
195 let mut names: Vec<String> = defs.keys().cloned().collect();
200 let mut names: Vec<String> = defs.keys().cloned().collect();
196 names.sort();
201 names.sort();
197− loop {
202+ while done.len() < names.len() {
198 let mut progress = false;
203 let mut progress = false;
199 for n in &names {
204 for n in &names {
200 if done.contains(n) {
205 if done.contains(n) {
@@ -206,15 +211,305 @@ fn dependency_order(
206 progress = true;
211 progress = true;
207 }
212 }
208 }
213 }
209− if done.len() == names.len() {
210− return Ok(ordered);
211− }
212 if !progress {
214 if !progress {
213 return Err(ConfigError::Validation(
215 return Err(ConfigError::Validation(
214 "config.j: a cycle among top-level definitions".into(),
216 "config.j: a cycle among top-level definitions".into(),
215 ));
217 ));
216 }
218 }
217 }
219 }
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+ }
218}
513}
219
514
220/// Names a definition's value depends on at load time (§4.1). Lambda bodies
515/// 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>) {
276 load_deps(e, out);
571 load_deps(e, out);
277 }
572 }
278 }
573 }
574+ // `%main` is `labelled "main"` (§4.11), evaluated where it stands
575+ Expr::LabelLit(_) => {
576+ out.insert("labelled".to_string());
577+ }
279 _ => {}
578 _ => {}
280 }
579 }
281}
580}
@@ -304,29 +603,34 @@ pub fn eval_config(interp: &mut Interp, cfg: &Config) -> Result<(), Crash> {
304 }
603 }
305 }
604 }
306 interp.globals = Env::with_globals(globals);
605 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+ }
307 // evaluate definitions in dependency order, through one recursive frame so
610 // evaluate definitions in dependency order, through one recursive frame so
308 // top-level definitions are mutually recursive (§4.1)
611 // top-level definitions are mutually recursive (§4.1)
612+ let names: Rc<[String]> = cfg.defs.iter().map(|(name, _)| name.clone()).collect();
309− let (genv, cell) = interp.globals.extend_rec();
613+ let (genv, cell) = interp.globals.extend_rec(names);
310 for (name, expr) in &cfg.defs {
614 for (name, expr) in &cfg.defs {
311 *interp.current_def.borrow_mut() = Some(name.clone());
615 *interp.current_def.borrow_mut() = Some(name.clone());
312 let v = interp.eval(expr, &genv)?;
616 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)
314 if let Some(ty) = cfg.sigs.get(name) {
621 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) {
622+ if let Err(msg) = crate::shape::check(&interp.shapes, ty, &v) {
318− return Err(Crash::new(format!("contract: {}: {}", name, msg)));
623+ return Err(Crash::new(format!("contract: {}: {}", name, msg)));
319− }
320 }
624 }
321 }
625 }
322− cell.borrow_mut().push((name.clone(), v.clone()));
626+ cell.borrow_mut().push(v.clone());
323 // attach the definition's name and contract to its function value so
627 // attach the definition's name and contract to its function value so
324 // applications are checked (§4.13) and errors name the definition
628 // applications are checked (§4.13) and errors name the definition
325 if let Some(ty) = cfg.sigs.get(name) {
629 if let Some(ty) = cfg.sigs.get(name) {
326 let contract = Rc::new(compile_contract(&interp.shapes, ty));
630 let contract = Rc::new(compile_contract(&interp.shapes, ty));
327 let named = crate::value::attach_pending(&v, name, contract);
631 let named = crate::value::attach_pending(&v, name, contract);
328 let last = cell.borrow_mut().len() - 1;
632 let last = cell.borrow_mut().len() - 1;
329− cell.borrow_mut()[last] = (name.clone(), named);
633+ cell.borrow_mut()[last] = named;
330 }
634 }
331 }
635 }
332 interp.globals = genv;
636 interp.globals = genv;
@@ -464,17 +768,29 @@ fn resolve_ids_rec(
464 }
768 }
465}
769}
466
770
467−/// find Id literals per definition for config validation (§6.2.5)
771+/// Resolve every `@prefix` literal in the config's definitions against the
468−pub fn config_id_failures(
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(
469− cfg: &Config,
781+ cfg: &mut Config,
470 interp: &Interp,
782 interp: &Interp,
471) -> Vec<(String, String, Vec<String>)> {
783) -> Vec<(String, String, Vec<String>)> {
472 let mut out = Vec::new();
784 let mut out = Vec::new();
473− for (name, expr) in &cfg.defs {
785+ for (name, expr) in cfg.defs.iter_mut() {
474 let mut failures = Vec::new();
786 let mut failures = Vec::new();
475− resolve_ids_rec(expr, interp, &mut failures);
787+ let resolved = resolve_ids_rec(expr, interp, &mut failures);
788+ if failures.is_empty() {
789+ *expr = Rc::new(resolved);
790+ } else {
476− for (prefix, matches) in failures {
791+ for (prefix, matches) in failures {
477− out.push((name.clone(), prefix, matches));
792+ out.push((name.clone(), prefix, matches));
793+ }
478 }
794 }
479 }
795 }
480 out
796 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 @@
2//! (§7, §10). Two implementations: an in-memory backend for tests, and the
2//! (§7, §10). Two implementations: an in-memory backend for tests, and the
3//! jj-lib backend.
3//! jj-lib backend.
4
4
5−use crate::value::{BlobContent, BlobKind, BlobVal, Crash, Value};
5+use crate::value::{BlobContent, BlobKind, BlobVal, ConflictSide, Crash, Value};
6use std::collections::{BTreeMap, BTreeSet, HashMap};
6use std::collections::{BTreeMap, BTreeSet, HashMap};
7use std::rc::Rc;
7use std::rc::Rc;
8
8
@@ -29,6 +29,12 @@ pub struct ImmutabilityInfo {
29}
29}
30
30
31pub trait Backend {
31pub 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+
32 /// All visible change ids (full), for resolving `@prefix` literals.
38 /// All visible change ids (full), for resolving `@prefix` literals.
33 fn visible_ids(&self) -> Vec<String>;
39 fn visible_ids(&self) -> Vec<String>;
34 /// All visible ids beginning with `prefix`.
40 /// All visible ids beginning with `prefix`.
@@ -42,19 +48,38 @@ pub trait Backend {
42 fn meta(&self, id: &str) -> Result<MetaInfo, Crash>;
48 fn meta(&self, id: &str) -> Result<MetaInfo, Crash>;
43 /// True if the change id denotes a merge commit (2+ parents).
49 /// True if the change id denotes a merge commit (2+ parents).
44 fn is_merge(&self, id: &str) -> bool;
50 fn is_merge(&self, id: &str) -> bool;
45− /// True if the commit's tree contains a conflict, if the backend can say
51+ /// True if the stored commit's tree contains a conflict, if the backend
46− /// without reading files (jj: O(1)); None means "unknown, compare files".
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.
47 fn has_conflict(&self, _id: &str) -> Option<bool> {
55 fn has_conflict(&self, _id: &str) -> Option<bool> {
48 None
56 None
49 }
57 }
50− /// True if the commit's tree equals its first parent's tree, if the
58+ /// True if the stored commit `id`'s tree equals the stored commit
51− /// backend can say without reading files; None means "compare files".
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.
52− fn is_empty(&self, _id: &str) -> Option<bool> {
62+ fn is_empty(&self, _id: &str, _parent: &str) -> Option<bool> {
53 None
63 None
54 }
64 }
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).
56 fn ancestors_closed(&self, ids: &BTreeSet<String>) -> BTreeSet<String>;
67 fn ancestors_closed(&self, ids: &BTreeSet<String>) -> BTreeSet<String>;
57
68
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+
58 /// jj tree merge (§7.3): replay the change from->to onto `onto`.
83 /// jj tree merge (§7.3): replay the change from->to onto `onto`.
59 fn replay(
84 fn replay(
60 &self,
85 &self,
@@ -63,6 +88,12 @@ pub trait Backend {
63 to: &[Value],
88 to: &[Value],
64 ) -> Result<Vec<Value>, Crash>;
89 ) -> Result<Vec<Value>, Crash>;
65
90
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+
66 /// shortest prefix of `id` unique among visible ids, min length 4
97 /// shortest prefix of `id` unique among visible ids, min length 4
67 fn unique_prefix(&self, id: &str) -> String {
98 fn unique_prefix(&self, id: &str) -> String {
68 let others: Vec<String> = self.visible_ids();
99 let others: Vec<String> = self.visible_ids();
@@ -107,13 +138,56 @@ pub fn snapshot_map(entries: &[Value]) -> Result<BTreeMap<Vec<String>, Value>, C
107 .map_err(|_| Crash::new("replay: not a well-formed snapshot (path component not Text)"))
138 .map_err(|_| Crash::new("replay: not a well-formed snapshot (path component not Text)"))
108 })
139 })
109 .collect::<Result<Vec<String>, Crash>>()?;
140 .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+ }
110 if m.insert(path, content).is_some() {
147 if m.insert(path, content).is_some() {
111 return Err(Crash::new("replay: not a well-formed snapshot (duplicate paths)"));
148 return Err(Crash::new("replay: not a well-formed snapshot (duplicate paths)"));
112 }
149 }
113 }
150 }
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+ }
114 Ok(m)
157 Ok(m)
115}
158}
116
159
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+
117pub fn map_to_snapshot(m: BTreeMap<Vec<String>, Value>) -> Vec<Value> {
191pub fn map_to_snapshot(m: BTreeMap<Vec<String>, Value>) -> Vec<Value> {
118 m.into_iter()
192 m.into_iter()
119 .map(|(path, content)| {
193 .map(|(path, content)| {
@@ -147,7 +221,7 @@ fn empty_blob() -> Value {
147 }))
221 }))
148}
222}
149
223
150−fn conflict_blob(sides: Vec<Rc<Vec<u8>>>) -> Value {
224+fn conflict_blob(sides: Vec<Option<ConflictSide>>) -> Value {
151 Value::Blob(Rc::new(BlobVal {
225 Value::Blob(Rc::new(BlobVal {
152 kind: BlobKind::Regular,
226 kind: BlobKind::Regular,
153 content: BlobContent::Conflict(sides),
227 content: BlobContent::Conflict(sides),
@@ -202,28 +276,40 @@ pub fn simple_replay(
202 } else if !t_present && !o_present {
276 } else if !t_present && !o_present {
203 // both deleted
277 // both deleted
204 } else {
278 } else {
205− // conflict: sides [to, from, onto] (add, remove, add)
279+ // conflict: sides [to, from, onto] (add, remove, add),
280+ // each absent or with its own file type (§7.3)
206− let get_bytes = |v: &Value, present: bool| -> Result<Rc<Vec<u8>>, Crash> {
281+ let side = |v: &Value, present: bool| -> Result<Option<ConflictSide>, Crash> {
207 if !present {
282 if !present {
208− return Ok(Rc::new(Vec::new()));
283+ return Ok(None);
209 }
284 }
210 match v {
285 match v {
286+ Value::Blob(b) => Ok(Some(ConflictSide {
287+ kind: b.kind.clone(),
211− Value::Blob(b) => Ok(Rc::new(b.bytes()?)),
288+ bytes: Rc::new(b.bytes()?),
212− _ => Ok(Rc::new(Vec::new())),
289+ tree: None,
290+ })),
291+ _ => Ok(Some(ConflictSide::regular(&[]))),
213 }
292 }
214 };
293 };
215 out.insert(
294 out.insert(
216 p,
295 p,
217 conflict_blob(vec![
296 conflict_blob(vec![
218− get_bytes(&t, t_present)?,
297+ side(&t, t_present)?,
219− get_bytes(&f, f_present)?,
298+ side(&f, f_present)?,
220− get_bytes(&o, o_present)?,
299+ side(&o, o_present)?,
221 ]),
300 ]),
222 );
301 );
223 }
302 }
224 }
303 }
225 }
304 }
226 }
305 }
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+ }
227 Ok(map_to_snapshot(out))
313 Ok(map_to_snapshot(out))
228}
314}
229
315
@@ -237,17 +323,47 @@ pub struct MemBackend {
237 pub metas: HashMap<String, MetaInfo>,
323 pub metas: HashMap<String, MetaInfo>,
238 /// id -> parent change ids (first parent first)
324 /// id -> parent change ids (first parent first)
239 pub parents: HashMap<String, Vec<String>>,
325 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,
240}
339}
241
340
242impl MemBackend {
341impl MemBackend {
243 pub fn new() -> Self {
342 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 {
244 MemBackend {
348 MemBackend {
245− metas: HashMap::new(),
349+ answers_tree_queries: true,
246− parents: HashMap::new(),
350+ ..MemBackend::default()
247 }
351 }
248 }
352 }
249}
353}
250
354
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+
251impl Backend for MemBackend {
367impl Backend for MemBackend {
252 fn visible_ids(&self) -> Vec<String> {
368 fn visible_ids(&self) -> Vec<String> {
253 let mut ids: Vec<String> = self.metas.keys().cloned().collect();
369 let mut ids: Vec<String> = self.metas.keys().cloned().collect();
@@ -258,11 +374,30 @@ impl Backend for MemBackend {
258 self.metas
374 self.metas
259 .get(id)
375 .get(id)
260 .cloned()
376 .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)))
262 }
378 }
263 fn is_merge(&self, id: &str) -> bool {
379 fn is_merge(&self, id: &str) -> bool {
264 self.parents.get(id).map(|p| p.len() >= 2).unwrap_or(false)
380 self.parents.get(id).map(|p| p.len() >= 2).unwrap_or(false)
265 }
381 }
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+ }
266 fn ancestors_closed(&self, ids: &BTreeSet<String>) -> BTreeSet<String> {
401 fn ancestors_closed(&self, ids: &BTreeSet<String>) -> BTreeSet<String> {
267 let mut seen = BTreeSet::new();
402 let mut seen = BTreeSet::new();
268 let mut stack: Vec<String> = ids.iter().cloned().collect();
403 let mut stack: Vec<String> = ids.iter().cloned().collect();
src/eval.rs+845−304
@@ -4,7 +4,7 @@
4use crate::ast::{Expr, Pattern};
4use crate::ast::{Expr, Pattern};
5use crate::domain::Backend;
5use crate::domain::Backend;
6use crate::shape::{check as contract_check, Contract, Shapes};
6use 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};
8use std::cell::RefCell;
8use std::cell::RefCell;
9use std::collections::HashMap;
9use std::collections::HashMap;
10use std::rc::Rc;
10use std::rc::Rc;
@@ -20,8 +20,76 @@ pub struct Interp {
20 fresh: RefCell<u64>,
20 fresh: RefCell<u64>,
21 /// the currently executing top-level definition (for crash reports)
21 /// the currently executing top-level definition (for crash reports)
22 pub current_def: RefCell<Option<String>>,
22 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>)>,
23 /// the repository as loaded, before snapshotting (for `validate`)
29 /// the repository as loaded, before snapshotting (for `validate`)
24 pub old_repo: RefCell<Option<Value>>,
30 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+ }
25}
93}
26
94
27/// machine state
95/// machine state
@@ -39,26 +107,28 @@ pub(crate) enum Cont {
39 Arg(Rc<Expr>, Env, Rc<Cont>),
107 Arg(Rc<Expr>, Env, Rc<Cont>),
40 Fun(Value, Rc<Cont>),
108 Fun(Value, Rc<Cont>),
41 If(Rc<Expr>, Rc<Expr>, Env, Rc<Cont>),
109 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.
42 LetRest {
112 LetRest {
43− names: Vec<String>,
113+ bindings: Rc<[(String, Rc<Expr>)]>,
44− exprs: Vec<Rc<Expr>>,
114+ at: usize,
45− acc: Vec<(String, Value)>,
46− cell: Rc<RefCell<Vec<(String, Value)>>>,
115+ cell: Option<Rc<RefCell<Vec<Value>>>>,
47 body: Rc<Expr>,
116 body: Rc<Expr>,
48 env: Env,
117 env: Env,
49 cont: Rc<Cont>,
118 cont: Rc<Cont>,
50 },
119 },
51− /// short-circuit ops with the unevaluated rhs
120+ /// `&&` or `||` after applying its first operand normally, so the
52− And(Rc<Expr>, Env, Rc<Cont>),
121+ /// declared contract has checked it; the rhs is still unevaluated
53− Or2(Rc<Expr>, Env, Rc<Cont>),
122+ ShortCircuit {
54− /// check the rhs of && / || evaluated to a Bool
55− ExpectBool(&'static str, Rc<Cont>),
123+ op: &'static str,
124+ rhs: Rc<Expr>,
125+ env: Env,
126+ cont: Rc<Cont>,
127+ },
56 /// `or` after both evaluated as functions: build the lifted function
128 /// `or` after both evaluated as functions: build the lifted function
57 OrBoth(Value, Rc<Cont>),
129 OrBoth(Value, Rc<Cont>),
58 /// a shared continuation (used when both `or` outcomes continue the same way)
130 /// a shared continuation (used when both `or` outcomes continue the same way)
59 Shared(Rc<Cont>),
131 Shared(Rc<Cont>),
60− /// unconditionally crash with a fixed message (deferred-body failure)
61− CrashWith(String),
62 /// run the continuation's computation, catching crashes: on success pass
132 /// run the continuation's computation, catching crashes: on success pass
63 /// the value to `on_ok`, on crash switch to `on_crash` (§4.6)
133 /// the value to `on_ok`, on crash switch to `on_crash` (§4.6)
64 Try {
134 Try {
@@ -66,27 +136,29 @@ pub(crate) enum Cont {
66 snapshot: u64,
136 snapshot: u64,
67 on_crash: Box<State>,
137 on_crash: Box<State>,
68 },
138 },
139+ /// apply the returned function to this argument: the `labelled "name"`
69− /// applying a Labelled: apply inner `labelled` result to the real arg
140+ /// of a Labelled to the real arg
70− LabelledArg(Value, Rc<Cont>),
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.
71− UpdateBase(Vec<(String, Rc<Expr>)>, Env, Rc<Cont>),
145+ UpdateBase(Rc<[(String, Rc<Expr>)]>, Env, Rc<Cont>),
72 UpdateFields {
146 UpdateFields {
73 base: Value,
147 base: Value,
74− names: Vec<String>,
148+ fields: Rc<[(String, Rc<Expr>)]>,
75− exprs: Vec<Rc<Expr>>,
76− acc: Vec<(String, Value)>,
149+ acc: Rc<RefCell<Vec<(String, Value)>>>,
77 env: Env,
150 env: Env,
78 cont: Rc<Cont>,
151 cont: Rc<Cont>,
79 },
152 },
80 RecordFields {
153 RecordFields {
81− names: Vec<String>,
154+ fields: Rc<[(String, Rc<Expr>)]>,
82− exprs: Vec<Rc<Expr>>,
83− acc: Vec<(String, Value)>,
155+ acc: Rc<RefCell<Vec<(String, Value)>>>,
84 env: Env,
156 env: Env,
85 cont: Rc<Cont>,
157 cont: Rc<Cont>,
86 },
158 },
87 ListElems {
159 ListElems {
88− exprs: Vec<Rc<Expr>>,
160+ exprs: Rc<[Rc<Expr>]>,
89− acc: Vec<Value>,
161+ acc: Rc<RefCell<Vec<Value>>>,
90 env: Env,
162 env: Env,
91 cont: Rc<Cont>,
163 cont: Rc<Cont>,
92 },
164 },
@@ -96,6 +168,16 @@ pub(crate) enum Cont {
96 cexpr: Rc<crate::shape::ContractExpr>,
168 cexpr: Rc<crate::shape::ContractExpr>,
97 cont: Rc<Cont>,
169 cont: Rc<Cont>,
98 },
170 },
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+ },
99 /// after applying, check the (partial) result against the contract
181 /// after applying, check the (partial) result against the contract
100 #[allow(dead_code)]
182 #[allow(dead_code)]
101 CheckResult {
183 CheckResult {
@@ -104,6 +186,57 @@ pub(crate) enum Cont {
104 supplied: usize,
186 supplied: usize,
105 cont: Rc<Cont>,
187 cont: Rc<Cont>,
106 },
188 },
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+ },
107}
240}
108
241
109pub type EResult = Result<Value, Crash>;
242pub type EResult = Result<Value, Crash>;
@@ -118,12 +251,13 @@ pub(crate) enum TryOk {
118 env: Env,
251 env: Env,
119 cont: Rc<Cont>,
252 cont: Rc<Cont>,
120 },
253 },
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
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,
126− OrFunLhs { cont: Rc<Cont> },
259+ cont: Rc<Cont>,
260+ },
127}
261}
128
262
129pub(crate) enum Run {
263pub(crate) enum Run {
@@ -135,23 +269,28 @@ pub(crate) enum Run {
135/// the parent continuation Rc of `k`, for iterative drop and unwinding
269/// the parent continuation Rc of `k`, for iterative drop and unwinding
136fn parent_rc(k: &Cont) -> Option<&Rc<Cont>> {
270fn parent_rc(k: &Cont) -> Option<&Rc<Cont>> {
137 match k {
271 match k {
138− Cont::Halt | Cont::CrashWith(_) | Cont::Try { .. } => None,
272+ Cont::Halt | Cont::Try { .. } => None,
139 Cont::Arg(_, _, k) => Some(k),
273 Cont::Arg(_, _, k) => Some(k),
140 Cont::Fun(_, k) => Some(k),
274 Cont::Fun(_, k) => Some(k),
141 Cont::If(_, _, _, k) => Some(k),
275 Cont::If(_, _, _, k) => Some(k),
142 Cont::LetRest { cont, .. } => Some(cont),
276 Cont::LetRest { cont, .. } => Some(cont),
143− Cont::And(_, _, k) => Some(k),
277+ Cont::ShortCircuit { cont, .. } => Some(cont),
144− Cont::Or2(_, _, k) => Some(k),
145− Cont::ExpectBool(_, k) => Some(k),
146 Cont::OrBoth(_, k) => Some(k),
278 Cont::OrBoth(_, k) => Some(k),
147 Cont::Shared(k) => Some(k),
279 Cont::Shared(k) => Some(k),
148− Cont::LabelledArg(_, k) => Some(k),
280+ Cont::ApplyTo(_, k) => Some(k),
149 Cont::UpdateBase(_, _, k) => Some(k),
281 Cont::UpdateBase(_, _, k) => Some(k),
150 Cont::UpdateFields { cont, .. } => Some(cont),
282 Cont::UpdateFields { cont, .. } => Some(cont),
151 Cont::RecordFields { cont, .. } => Some(cont),
283 Cont::RecordFields { cont, .. } => Some(cont),
152 Cont::ListElems { cont, .. } => Some(cont),
284 Cont::ListElems { cont, .. } => Some(cont),
153 Cont::CheckExpr { cont, .. } => Some(cont),
285 Cont::CheckExpr { cont, .. } => Some(cont),
286+ Cont::NamePartial { cont, .. } => Some(cont),
154 Cont::CheckResult { cont, .. } => Some(cont),
287 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),
155 }
294 }
156}
295}
157
296
@@ -160,6 +299,99 @@ pub(crate) fn pop_cont(k: &Cont) -> Option<Cont> {
160 parent_rc(k).map(|r| (**r).clone())
299 parent_rc(k).map(|r| (**r).clone())
161}
300}
162
301
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+
163impl Interp {
395impl Interp {
164 pub fn new(backend: Rc<dyn Backend>, shapes: Shapes, globals: Env) -> Interp {
396 pub fn new(backend: Rc<dyn Backend>, shapes: Shapes, globals: Env) -> Interp {
165 Interp {
397 Interp {
@@ -169,10 +401,56 @@ impl Interp {
169 contracts: HashMap::new(),
401 contracts: HashMap::new(),
170 fresh: RefCell::new(0),
402 fresh: RefCell::new(0),
171 current_def: RefCell::new(None),
403 current_def: RefCell::new(None),
404+ def_bodies: HashMap::new(),
172 old_repo: RefCell::new(None),
405 old_repo: RefCell::new(None),
406+ given_repo: RefCell::new(None),
407+ revsets: RefCell::new(Vec::new()),
408+ sorted_ids: RefCell::new(None),
173 }
409 }
174 }
410 }
175
411
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+
176 /// An interpreter shell used only for recursive rendering; panics if any
454 /// An interpreter shell used only for recursive rendering; panics if any
177 /// backend interaction is attempted.
455 /// backend interaction is attempted.
178 pub fn dummy() -> Interp {
456 pub fn dummy() -> Interp {
@@ -183,7 +461,11 @@ impl Interp {
183 contracts: HashMap::new(),
461 contracts: HashMap::new(),
184 fresh: RefCell::new(0),
462 fresh: RefCell::new(0),
185 current_def: RefCell::new(None),
463 current_def: RefCell::new(None),
464+ def_bodies: HashMap::new(),
186 old_repo: RefCell::new(None),
465 old_repo: RefCell::new(None),
466+ given_repo: RefCell::new(None),
467+ revsets: RefCell::new(Vec::new()),
468+ sorted_ids: RefCell::new(None),
187 }
469 }
188 }
470 }
189
471
@@ -214,6 +496,26 @@ impl Interp {
214 self.globals.clone()
496 self.globals.clone()
215 }
497 }
216
498
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+
217 /// Main entry: evaluate an expression in an environment.
519 /// Main entry: evaluate an expression in an environment.
218 pub fn eval(&mut self, e: &Rc<Expr>, env: &Env) -> EResult {
520 pub fn eval(&mut self, e: &Rc<Expr>, env: &Env) -> EResult {
219 self.run(State::Eval(e.clone(), env.clone(), Cont::Halt))
521 self.run(State::Eval(e.clone(), env.clone(), Cont::Halt))
@@ -230,11 +532,105 @@ impl Interp {
230 c
532 c
231 }
533 }
232
534
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+
233 fn run(&mut self, st: State) -> EResult {
625 fn run(&mut self, st: State) -> EResult {
234− match self.trampoline(st) {
626+ let result = match self.trampoline(st) {
235 Ok(v) => Ok(v),
627 Ok(v) => Ok(v),
236 Err(c) => Err(self.decorate(c)),
628 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
238 }
634 }
239
635
240 fn trampoline(&mut self, st: State) -> EResult {
636 fn trampoline(&mut self, st: State) -> EResult {
@@ -261,26 +657,18 @@ impl Interp {
261 Run::Step(next) => st = next,
657 Run::Step(next) => st = next,
262 Run::Done(v) => return Ok(v),
658 Run::Done(v) => return Ok(v),
263 Run::Crash(c) => {
659 Run::Crash(c) => {
264− // unwind to the nearest Try continuation, restoring the
660+ // Unwind to the nearest Try continuation, restoring the
265 // fresh-id snapshot so a caught crash leaves no trace.
661 // fresh-id snapshot so a caught crash leaves no trace.
266− // A `Shared` continuation links back to a context already
662+ // A Try cannot catch a crash raised by its own handler:
267− // containing a Try, so track visited Try nodes and skip
663+ // every `on_crash` state continues through `Cont::Shared`
268− // ones already unwound past (a caught crash must not be
664+ // holding the continuation *outside* the Try, so the Try
269− // re-caught by the same Try, which would loop forever).
665+ // is no longer on the chain once the handler runs.
270 let mut k = Some(active.clone());
666 let mut k = Some(active.clone());
271− let mut visited: Vec<*const Cont> = Vec::new();
272 let caught = loop {
667 let caught = loop {
273 match k {
668 match k {
274 Some(Cont::Try {
669 Some(Cont::Try {
275 snapshot, on_crash, ..
670 snapshot, on_crash, ..
276 }) => {
671 }) => {
277− let ptr = &*on_crash as *const State as *const Cont;
278− let already = visited.contains(&ptr);
279− visited.push(ptr);
280− if already {
281− k = None;
282− continue;
283− }
284 *self.fresh.borrow_mut() = snapshot;
672 *self.fresh.borrow_mut() = snapshot;
285 break Some(*on_crash);
673 break Some(*on_crash);
286 }
674 }
@@ -288,9 +676,20 @@ impl Interp {
288 None => break None,
676 None => break None,
289 }
677 }
290 };
678 };
679+ // the frames up to the Try, or all of them, are abandoned
291 match caught {
680 match caught {
292− Some(next) => st = next,
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);
293− None => return Err(c),
691+ return Err(c);
692+ }
294 }
693 }
295 }
694 }
296 }
695 }
@@ -341,7 +740,6 @@ impl Interp {
341 params: params.clone(),
740 params: params.clone(),
342 applied: 0,
741 applied: 0,
343 applied_args: Vec::new(),
742 applied_args: Vec::new(),
344− deferred: false,
345 body: body.clone(),
743 body: body.clone(),
346 env,
744 env,
347 src: src.clone(),
745 src: src.clone(),
@@ -358,18 +756,31 @@ impl Interp {
358 if bs.is_empty() {
756 if bs.is_empty() {
359 return Run::Step(State::Eval(body.clone(), env, k));
757 return Run::Step(State::Eval(body.clone(), env, k));
360 }
758 }
361− let names: Vec<String> = bs.iter().map(|(n, _)| n.clone()).collect();
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>)]> =
362− let exprs: Vec<Rc<Expr>> = bs.iter().map(|(_, e)| e.clone()).collect();
766+ plan.order.iter().map(|&i| bs[i].clone()).collect();
363− // one recursive frame shared by every binding and the body, so
767+ // A closure in a recursive block sees later bindings through
364− // the block is mutually recursive (§4.1)
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();
365− let (env2, cell) = env.extend_rec();
773+ let (env, cell) = env.extend_rec(names);
774+ (env, Some(cell))
775+ } else {
776+ (env, None)
777+ };
366 Run::Step(State::Eval(
778 Run::Step(State::Eval(
367− exprs[0].clone(),
779+ bindings[0].1.clone(),
368 env2.clone(),
780 env2.clone(),
369 Cont::LetRest {
781 Cont::LetRest {
370− names,
782+ bindings,
371− exprs,
783+ at: 0,
372− acc: Vec::new(),
373 cell,
784 cell,
374 body: body.clone(),
785 body: body.clone(),
375 env: env2,
786 env: env2,
@@ -383,29 +794,26 @@ impl Interp {
383 Cont::Arg(x.clone(), env, Rc::new(k)),
794 Cont::Arg(x.clone(), env, Rc::new(k)),
384 )),
795 )),
385 Expr::BinOp(op, a, b) => {
796 Expr::BinOp(op, a, b) => {
386− if *op == "&&" {
387− return Run::Step(State::Eval(
388− a.clone(),
389− env.clone(),
390− Cont::And(b.clone(), env, Rc::new(k)),
391− ));
392− }
393− if *op == "||" {
394− return Run::Step(State::Eval(
395− a.clone(),
396− env.clone(),
397− Cont::Or2(b.clone(), env, Rc::new(k)),
398− ));
399− }
400 let fv = match env.lookup(op) {
797 let fv = match env.lookup(op) {
401 Some(v) => v,
798 Some(v) => v,
402 None => return Run::Crash(Crash::new(format!("unbound name `{}`", op))),
799 None => return Run::Crash(Crash::new(format!("unbound name `{}`", op))),
403 };
800 };
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 ||.
405 Run::Step(State::Eval(
813 Run::Step(State::Eval(
406 a.clone(),
814 a.clone(),
407− env.clone(),
815+ env,
408− Cont::Fun(fv, Rc::new(Cont::Arg(b.clone(), env, Rc::new(k)))),
816+ Cont::Fun(fv, Rc::new(next)),
409 ))
817 ))
410 }
818 }
411 Expr::Or(a, b) => {
819 Expr::Or(a, b) => {
@@ -434,22 +842,19 @@ impl Interp {
434 Expr::Update(base, fields) => Run::Step(State::Eval(
842 Expr::Update(base, fields) => Run::Step(State::Eval(
435 base.clone(),
843 base.clone(),
436 env.clone(),
844 env.clone(),
437− Cont::UpdateBase(fields.clone(), env, Rc::new(k)),
845+ Cont::UpdateBase(fields.clone().into(), env, Rc::new(k)),
438 )),
846 )),
439 Expr::Record(fields) => {
847 Expr::Record(fields) => {
440 if fields.is_empty() {
848 if fields.is_empty() {
441 let m = crate::value::RecordMap::new();
849 let m = crate::value::RecordMap::new();
442 return Run::Step(State::Ret(Value::Record(Rc::new(m)), k));
850 return Run::Step(State::Ret(Value::Record(Rc::new(m)), k));
443 }
851 }
444− let names: Vec<String> = fields.iter().map(|(n, _)| n.clone()).collect();
445− let exprs: Vec<Rc<Expr>> = fields.iter().map(|(_, v)| v.clone()).collect();
446 Run::Step(State::Eval(
852 Run::Step(State::Eval(
447− exprs[0].clone(),
853+ fields[0].1.clone(),
448 env.clone(),
854 env.clone(),
449 Cont::RecordFields {
855 Cont::RecordFields {
450− names,
856+ fields: fields.clone().into(),
451− exprs,
857+ acc: Rc::new(RefCell::new(Vec::with_capacity(fields.len()))),
452− acc: Vec::new(),
453 env,
858 env,
454 cont: Rc::new(k),
859 cont: Rc::new(k),
455 },
860 },
@@ -463,8 +868,8 @@ impl Interp {
463 elems[0].clone(),
868 elems[0].clone(),
464 env.clone(),
869 env.clone(),
465 Cont::ListElems {
870 Cont::ListElems {
466− exprs: elems.clone(),
871+ exprs: elems.clone().into(),
467− acc: Vec::new(),
872+ acc: Rc::new(RefCell::new(Vec::with_capacity(elems.len()))),
468 env,
873 env,
469 cont: Rc::new(k),
874 cont: Rc::new(k),
470 },
875 },
@@ -490,29 +895,31 @@ impl Interp {
490 ))),
895 ))),
491 },
896 },
492 Cont::LetRest {
897 Cont::LetRest {
493− names,
898+ bindings,
494− exprs,
899+ at,
495− mut acc,
496 cell,
900 cell,
497 body,
901 body,
498 env,
902 env,
499 cont,
903 cont,
500 } => {
904 } => {
501− let idx = acc.len();
905+ let env = match &cell {
502− let pair = (names[idx].clone(), v);
906+ Some(cell) => {
503− cell.borrow_mut().push(pair.clone());
907+ cell.borrow_mut().push(v);
504− acc.push(pair);
908+ env
505− if acc.len() == names.len() {
909+ }
910+ None => env.extend(vec![(bindings[at].0.clone(), v)]),
911+ };
912+ let at = at + 1;
913+ if at == bindings.len() {
506 Run::Step(State::Eval(body, env, (*cont).clone()))
914 Run::Step(State::Eval(body, env, (*cont).clone()))
507 } else {
915 } else {
508− let next = exprs[acc.len()].clone();
916+ let next = bindings[at].1.clone();
509 Run::Step(State::Eval(
917 Run::Step(State::Eval(
510 next,
918 next,
511 env.clone(),
919 env.clone(),
512 Cont::LetRest {
920 Cont::LetRest {
513− names,
921+ bindings,
514− exprs,
922+ at,
515− acc,
516 cell,
923 cell,
517 body,
924 body,
518 env,
925 env,
@@ -521,24 +928,42 @@ impl Interp {
521 ))
928 ))
522 }
929 }
523 }
930 }
524− Cont::And(b, env, k) => match v {
931+ Cont::ShortCircuit { op, rhs, env, cont } => {
525− Value::Bool(false) => Run::Step(State::Ret(Value::Bool(false), (*k).clone())),
932+ // Operator names are reserved: resolving && or || and
526− Value::Bool(true) => Run::Step(State::Eval(b, env, Cont::ExpectBool("&&", k))),
933+ // applying one operand always gives its builtin partial.
527− _ => Run::Crash(Crash::new(format!("&&: expected Bool, got a {}", v.kind_name()))),
934+ let (lhs, pending) = match &v {
528− },
935+ Value::Fun(f) => match &**f {
529− Cont::Or2(b, env, k) => match v {
936+ FunVal::Builtin { args, pending, .. } if args.len() == 1 => {
530− Value::Bool(true) => Run::Step(State::Ret(Value::Bool(true), (*k).clone())),
937+ (&args[0], pending)
531− Value::Bool(false) => Run::Step(State::Eval(b, env, Cont::ExpectBool("||", k))),
938+ }
532− _ => Run::Crash(Crash::new(format!("||: expected Bool, got a {}", v.kind_name()))),
939+ _ => return Run::Crash(Crash::new("internal: invalid boolean operator")),
940+ },
941+ _ => return Run::Crash(Crash::new("internal: invalid boolean operator")),
533− },
942+ };
534− Cont::ExpectBool(op, k) => match v {
943+ let lhs = match lhs {
535− Value::Bool(_) => Run::Step(State::Ret(v, (*k).clone())),
944+ Value::Bool(b) => *b,
536− _ => Run::Crash(Crash::new(format!(
945+ other => return Run::Crash(Crash::new(format!(
537− "{}: expected Bool, got a {}",
946+ "{}: expected Bool, got a {}", op, other.kind_name()
538− op,
947+ ))),
539− v.kind_name()
948+ };
540− ))),
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+ }
541− },
966+ }
542 Cont::OrBoth(a, k) => {
967 Cont::OrBoth(a, k) => {
543 if matches!(v, Value::Fun(_)) {
968 if matches!(v, Value::Fun(_)) {
544 Run::Step(State::Ret(Value::Fun(Rc::new(FunVal::OrFun(a, v))), (*k).clone()))
969 Run::Step(State::Ret(Value::Fun(Rc::new(FunVal::OrFun(a, v))), (*k).clone()))
@@ -569,16 +994,24 @@ impl Interp {
569 Run::Step(State::Ret(v, Cont::Shared(cont)))
994 Run::Step(State::Ret(v, Cont::Shared(cont)))
570 }
995 }
571 }
996 }
572− Some(TryOk::DeferredBody { arg, cont, .. }) => {
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(_)) {
573− Run::Step(State::Apply(v, arg, (*cont).clone()))
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+ }
574 }
1010 }
575− Some(TryOk::ComposeInner { f, cont }) => Run::Step(State::Apply(f, v, (*cont).clone())),
576− Some(TryOk::OrFunLhs { cont, .. }) => Run::Step(State::Ret(v, (*cont).clone())),
577 None => Run::Crash(Crash::new("internal: Try with no success handler")),
1011 None => Run::Crash(Crash::new("internal: Try with no success handler")),
578 }
1012 }
579 }
1013 }
580− Cont::LabelledArg(real, k) => Run::Step(State::Apply(v, real, (*k).clone())),
1014+ Cont::ApplyTo(real, k) => Run::Step(State::Apply(v, real, (*k).clone())),
581− Cont::CrashWith(msg) => Run::Crash(Crash::new(msg)),
582 Cont::UpdateBase(fields, env, k) => {
1015 Cont::UpdateBase(fields, env, k) => {
583 if !matches!(v, Value::Record(_)) {
1016 if !matches!(v, Value::Record(_)) {
584 return Run::Crash(Crash::new(format!(
1017 return Run::Crash(Crash::new(format!(
@@ -586,19 +1019,16 @@ impl Interp {
586 v.kind_name()
1019 v.kind_name()
587 )));
1020 )));
588 }
1021 }
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() {
592 return Run::Step(State::Ret(v, (*k).clone()));
1023 return Run::Step(State::Ret(v, (*k).clone()));
593 }
1024 }
594 Run::Step(State::Eval(
1025 Run::Step(State::Eval(
595− exprs[0].clone(),
1026+ fields[0].1.clone(),
596 env.clone(),
1027 env.clone(),
597 Cont::UpdateFields {
1028 Cont::UpdateFields {
598 base: v,
1029 base: v,
599− names,
1030+ acc: Rc::new(RefCell::new(Vec::with_capacity(fields.len()))),
600− exprs,
1031+ fields,
601− acc: Vec::new(),
602 env,
1032 env,
603 cont: k,
1033 cont: k,
604 },
1034 },
@@ -606,20 +1036,23 @@ impl Interp {
606 }
1036 }
607 Cont::UpdateFields {
1037 Cont::UpdateFields {
608 base,
1038 base,
609− names,
1039+ fields,
610− exprs,
611− mut acc,
1040+ acc,
612 env,
1041 env,
613 cont,
1042 cont,
614 } => {
1043 } => {
615− let idx = acc.len();
1044+ let filled = {
1045+ let mut values = acc.borrow_mut();
1046+ let idx = values.len();
616− acc.push((names[idx].clone(), v));
1047+ values.push((fields[idx].0.clone(), v));
617− if acc.len() == names.len() {
1048+ values.len()
1049+ };
1050+ if filled == fields.len() {
618 let mut m = match &base {
1051 let mut m = match &base {
619 Value::Record(m) => m.as_ref().clone(),
1052 Value::Record(m) => m.as_ref().clone(),
620 _ => unreachable!(),
1053 _ => unreachable!(),
621 };
1054 };
622− for (n, val) in acc {
1055+ for (n, val) in acc.take() {
623 if !m.contains_key(&n) {
1056 if !m.contains_key(&n) {
624 return Run::Crash(Crash::new(format!(
1057 return Run::Crash(Crash::new(format!(
625 "cannot update: record has no field `{}`",
1058 "cannot update: record has no field `{}`",
@@ -630,14 +1063,13 @@ impl Interp {
630 }
1063 }
631 Run::Step(State::Ret(Value::Record(Rc::new(m)), (*cont).clone()))
1064 Run::Step(State::Ret(Value::Record(Rc::new(m)), (*cont).clone()))
632 } else {
1065 } else {
633− let next = exprs[acc.len()].clone();
1066+ let next = fields[filled].1.clone();
634 Run::Step(State::Eval(
1067 Run::Step(State::Eval(
635 next,
1068 next,
636 env.clone(),
1069 env.clone(),
637 Cont::UpdateFields {
1070 Cont::UpdateFields {
638 base,
1071 base,
639− names,
1072+ fields,
640− exprs,
641 acc,
1073 acc,
642 env,
1074 env,
643 cont,
1075 cont,
@@ -646,25 +1078,27 @@ impl Interp {
646 }
1078 }
647 }
1079 }
648 Cont::RecordFields {
1080 Cont::RecordFields {
649− names,
1081+ fields,
650− exprs,
651− mut acc,
1082+ acc,
652 env,
1083 env,
653 cont,
1084 cont,
654 } => {
1085 } => {
655− let idx = acc.len();
1086+ let filled = {
1087+ let mut values = acc.borrow_mut();
1088+ let idx = values.len();
656− acc.push((names[idx].clone(), v));
1089+ values.push((fields[idx].0.clone(), v));
657− if acc.len() == names.len() {
1090+ values.len()
1091+ };
1092+ if filled == fields.len() {
658− let m: crate::value::RecordMap = acc.into_iter().collect();
1093+ let m: crate::value::RecordMap = acc.take().into_iter().collect();
659 Run::Step(State::Ret(Value::Record(Rc::new(m)), (*cont).clone()))
1094 Run::Step(State::Ret(Value::Record(Rc::new(m)), (*cont).clone()))
660 } else {
1095 } else {
661− let next = exprs[acc.len()].clone();
1096+ let next = fields[filled].1.clone();
662 Run::Step(State::Eval(
1097 Run::Step(State::Eval(
663 next,
1098 next,
664 env.clone(),
1099 env.clone(),
665 Cont::RecordFields {
1100 Cont::RecordFields {
666− names,
1101+ fields,
667− exprs,
668 acc,
1102 acc,
669 env,
1103 env,
670 cont,
1104 cont,
@@ -674,15 +1108,19 @@ impl Interp {
674 }
1108 }
675 Cont::ListElems {
1109 Cont::ListElems {
676 exprs,
1110 exprs,
677− mut acc,
1111+ acc,
678 env,
1112 env,
679 cont,
1113 cont,
680 } => {
1114 } => {
1115+ let filled = {
1116+ let mut values = acc.borrow_mut();
681− acc.push(v);
1117+ values.push(v);
1118+ values.len()
1119+ };
682− if acc.len() == exprs.len() {
1120+ if filled == exprs.len() {
683− Run::Step(State::Ret(Value::list(acc), (*cont).clone()))
1121+ Run::Step(State::Ret(Value::list(acc.take()), (*cont).clone()))
684 } else {
1122 } else {
685− let next = exprs[acc.len()].clone();
1123+ let next = exprs[filled].clone();
686 Run::Step(State::Eval(
1124 Run::Step(State::Eval(
687 next,
1125 next,
688 env.clone(),
1126 env.clone(),
@@ -701,6 +1139,19 @@ impl Interp {
701 }
1139 }
702 Run::Step(State::Ret(v, (*cont).clone()))
1140 Run::Step(State::Ret(v, (*cont).clone()))
703 }
1141 }
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+ }
704 Cont::CheckResult {
1155 Cont::CheckResult {
705 name,
1156 name,
706 contract,
1157 contract,
@@ -725,6 +1176,96 @@ impl Interp {
725 }
1176 }
726 Run::Step(State::Ret(v, (*cont).clone()))
1177 Run::Step(State::Ret(v, (*cont).clone()))
727 }
1178 }
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+ }
728 }
1269 }
729 }
1270 }
730
1271
@@ -740,40 +1281,83 @@ impl Interp {
740 pending,
1281 pending,
741 } => {
1282 } => {
742 let mut all = args.clone();
1283 let mut all = args.clone();
743− let (cname, cexpr) = match pending {
1284+ let (cname, cexpr, named_at) = match pending {
744− Some((n, c)) => (n.clone(), Some(c.clone())),
1285+ Some((n, c, at)) => (n.clone(), Some(c.clone()), *at),
745− None => (name.clone(), None),
1286+ None => (name.clone(), None, 0),
746 };
1287 };
747− // compose nodes handle function arguments pointwise in
748− // compose_apply; do not contract-check them here
749− let skip_check = name == "(.)" && matches!(arg, Value::Fun(_));
750 if let Some(c) = &cexpr {
1288 if let Some(c) = &cexpr {
751− if !skip_check {
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());
752− if let Err(cr) = c.check_arg_at(&self.shapes, &cname, args.len(), &arg) {
1292+ if let Err(cr) = c.check_arg_at(&self.shapes, &cname, at, &arg) {
753− return Run::Crash(cr);
1293+ return Run::Crash(self.blame_builtin(cr, name, &cname));
754− }
755 }
1294 }
756 }
1295 }
757 all.push(arg);
1296 all.push(arg);
758 let advanced = cexpr
1297 let advanced = cexpr
759 .map(|c| Rc::new(crate::shape::ContractExpr::apply_first_rc(c)));
1298 .map(|c| Rc::new(crate::shape::ContractExpr::apply_first_rc(c)));
760 if all.len() == *arity {
1299 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+ }
761 match bf(self, &all) {
1351 match bf(self, &all) {
762 Ok(r) => {
1352 Ok(r) => {
763− // compose applications check results
1353+ if let Some(c) = &check {
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)
1354+ if let Err(cr) = c.check_result(&self.shapes, &cname, &r) {
769− {
770− return Run::Crash(cr);
1355+ return Run::Crash(self.blame_builtin(cr, name, &cname));
771− }
772 }
1356 }
773 }
1357 }
774 Run::Step(State::Ret(r, k))
1358 Run::Step(State::Ret(r, k))
775 }
1359 }
776− Err(c) => Run::Crash(c),
1360+ Err(c) => Run::Crash(self.blame_builtin(c, name, &cname)),
777 }
1361 }
778 } else {
1362 } else {
779 Run::Step(State::Ret(
1363 Run::Step(State::Ret(
@@ -782,7 +1366,7 @@ impl Interp {
782 arity: *arity,
1366 arity: *arity,
783 args: all,
1367 args: all,
784 f: *bf,
1368 f: *bf,
785− pending: advanced.map(|c| (cname, c)),
1369+ pending: advanced.map(|c| (cname, c, named_at)),
786 })),
1370 })),
787 k,
1371 k,
788 ))
1372 ))
@@ -793,77 +1377,25 @@ impl Interp {
793 params,
1377 params,
794 applied,
1378 applied,
795 applied_args,
1379 applied_args,
796− deferred,
797 body,
1380 body,
798 env,
1381 env,
799 src,
1382 src,
800 pending,
1383 pending,
801 } => {
1384 } => {
802− if *deferred {
803− // the body was deferred at the last application:
804− // evaluate it now (catching crashes), then apply
805− let msg = format!(
806− "{}: body crashed when applied",
807− name.clone().unwrap_or_default()
808− );
809− return Run::Step(State::Eval(
810− body.clone(),
811− env.clone(),
812− Cont::Try {
813− on_ok: Some(TryOk::DeferredBody {
814− arg,
815− cont: Rc::new(k),
816− }),
817− snapshot: *self.fresh.borrow(),
818− on_crash: Box::new(State::Ret(
819− Value::Bool(false),
820− Cont::CrashWith(msg),
821− )),
822− },
823− ));
824− }
825 let (cname, cexpr) = match pending {
1385 let (cname, cexpr) = match pending {
826 Some((n, c)) => (Some(n.clone()), Some(c.clone())),
1386 Some((n, c)) => (Some(n.clone()), Some(c.clone())),
827 None => (name.clone(), None),
1387 None => (name.clone(), None),
828 };
1388 };
829− // A signed definition's application to a function value is
1389+ // a function argument is checked like any other: where
830− // tried as a plain application first; if that crashes (as
1390+ // the signature wants a Repo, `new new` is a contract
831− // `abandon (contract everything)` does at load time, since
1391+ // crash, not a composition (§4.13, §4.9)
832− // the definition is a Repo-level function), it is function
833− // composition instead (§4.1 note: the composition is a
834− // value before it is applied to the repository).
835− if *applied == 0 && matches!(arg, Value::Fun(_)) && pending.is_some() {
836− // probe: contract-check the argument; if it fails, the
837− // application is composition instead (§4.1 note)
838− let probe_ok = match &cexpr {
839− Some(c) => {
840− let r = c.check_arg(&self.shapes, "", &arg).is_ok();
841− r
842− }
843− None => true,
844− };
845− if !probe_ok {
846− let f = Value::Fun(Rc::new(FunVal::Closure {
847− name: name.clone(),
848− params: params.clone(),
849− applied: *applied,
850− applied_args: applied_args.clone(),
851− deferred: false,
852− body: body.clone(),
853− env: env.clone(),
854− src: src.clone(),
855− pending: pending.clone(),
856− }));
857− return match crate::builtins::compose_values(self, f, arg) {
858− Ok(composed) => Run::Step(State::Ret(composed, k)),
859− Err(cr) => Run::Crash(cr),
860− };
861− }
862− }
863 if let Some(c) = &cexpr {
1392 if let Some(c) = &cexpr {
864 let fname = cname.clone().unwrap_or_default();
1393 let fname = cname.clone().unwrap_or_default();
1394+ let at = c.position().unwrap_or(applied_args.len());
865− if let Err(cr) = c.check_arg_at(&self.shapes, &fname, applied_args.len(), &arg) {
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`)
866− return Run::Crash(cr);
1398+ return Run::Crash(in_def(cr, &fname));
867 }
1399 }
868 }
1400 }
869 let p = &params[*applied];
1401 let p = &params[*applied];
@@ -874,47 +1406,78 @@ impl Interp {
874 let new_applied = applied + 1;
1406 let new_applied = applied + 1;
875 let advanced = cexpr
1407 let advanced = cexpr
876 .map(|c| Rc::new(crate::shape::ContractExpr::apply_first_rc(c)));
1408 .map(|c| Rc::new(crate::shape::ContractExpr::apply_first_rc(c)));
877− // a signed definition's body is not evaluated until its
878− // contract is exhausted, so partial applications of named
879− // definitions stay named values (§5.2's `describe "wip"`)
880 let contract_exhausted = match &advanced {
1409 let contract_exhausted = match &advanced {
881 Some(c) => c.is_exhausted(),
1410 Some(c) => c.is_exhausted(),
882 None => true,
1411 None => true,
883 };
1412 };
884− if new_applied == params.len() && contract_exhausted {
1413+ if new_applied == params.len() {
885− Run::Step(State::Eval(
1414+ // the definition whose lambda this body is, if any,
886− body.clone(),
1415+ // executes while it runs (§1.4)
887− env2,
1416+ let def = self
888− match (&cname, &advanced) {
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+ };
889− (Some(n), Some(c)) => Cont::CheckExpr {
1458+ Cont::CheckExpr {
890− name: n.clone(),
1459+ name: n,
891− cexpr: c.clone(),
1460+ cexpr: c,
892 cont: Rc::new(k),
1461 cont: Rc::new(k),
893− },
1462+ }
1463+ }
894− _ => k,
1464+ _ => k,
895− },
1465+ };
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
1466+ // inside the check of the definition it was bound to,
900− // partial application (§5.2)
1467+ // which names this one already when it is the same
901− let mut new_args = applied_args.clone();
1468+ let k = match def {
902− new_args.push(arg.clone());
1469+ Some(d)
903− let v = Value::Fun(Rc::new(FunVal::Closure {
1470+ if !matches!(&k, Cont::CheckExpr { name, .. }
904− name: name.clone(),
1471+ if **name == *d) =>
905− params: params.clone(),
1472+ {
906− applied: new_applied,
1473+ Cont::InDef {
907− applied_args: new_args,
1474+ name: d,
908− deferred: true,
1475+ cont: Rc::new(k),
909− body: body.clone(),
1476+ }
910− env: env2,
1477+ }
911− src: src.clone(),
1478+ _ => k,
912− pending: match (&cname, &advanced) {
913− (Some(n), Some(c)) => Some((n.clone(), c.clone())),
914− _ => None,
915− },
916− }));
1479+ };
917− Run::Step(State::Ret(v, k))
1480+ Run::Step(State::Eval(body.clone(), env2, k))
918 } else if contract_exhausted {
1481 } else if contract_exhausted {
919 // lambda params remain but the contract is satisfied;
1482 // lambda params remain but the contract is satisfied;
920 // keep currying (unsigned tail)
1483 // keep currying (unsigned tail)
@@ -925,7 +1488,6 @@ impl Interp {
925 params: params.clone(),
1488 params: params.clone(),
926 applied: new_applied,
1489 applied: new_applied,
927 applied_args: new_args,
1490 applied_args: new_args,
928− deferred: false,
929 body: body.clone(),
1491 body: body.clone(),
930 env: env2,
1492 env: env2,
931 src: src.clone(),
1493 src: src.clone(),
@@ -943,7 +1505,6 @@ impl Interp {
943 params: params.clone(),
1505 params: params.clone(),
944 applied: new_applied,
1506 applied: new_applied,
945 applied_args: new_args,
1507 applied_args: new_args,
946− deferred: false,
947 body: body.clone(),
1508 body: body.clone(),
948 env: env2,
1509 env: env2,
949 src: src.clone(),
1510 src: src.clone(),
@@ -956,34 +1517,10 @@ impl Interp {
956 }
1517 }
957 }
1518 }
958 FunVal::ComposeLazy(f, g) => {
1519 FunVal::ComposeLazy(f, g) => {
959− let f = f.clone();
1520+ // (f . g) x = f (g x) (§4.9), whatever x is: apply g,
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
1521+ // then f to the result; a crash in either keeps its
1522+ // message and unwinds to any enclosing `or` (§4.7)
971− Run::Step(State::Apply(
1523+ Run::Step(State::Apply(g.clone(), arg, Cont::Fun(f.clone(), Rc::new(k))))
972− g,
973− arg,
974− Cont::Try {
975− on_ok: Some(TryOk::ComposeInner {
976− f,
977− cont: Rc::new(k),
978− }),
979− snapshot: *self.fresh.borrow(),
980− on_crash: Box::new(State::Ret(
981− Value::Bool(false),
982− Cont::CrashWith("composition crashed".to_string()),
983− )),
984− },
985− ))
986− }
987 }
1524 }
988 FunVal::OrFun(a, b) => {
1525 FunVal::OrFun(a, b) => {
989 // (f or g) x = f x or g x
1526 // (f or g) x = f x or g x
@@ -994,7 +1531,11 @@ impl Interp {
994 a,
1531 a,
995 arg.clone(),
1532 arg.clone(),
996 Cont::Try {
1533 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+ }),
998 snapshot: *self.fresh.borrow(),
1539 snapshot: *self.fresh.borrow(),
999 on_crash: Box::new(State::Apply(b, arg, Cont::Shared(k))),
1540 on_crash: Box::new(State::Apply(b, arg, Cont::Shared(k))),
1000 },
1541 },
@@ -1006,7 +1547,7 @@ impl Interp {
1006 Run::Step(State::Apply(
1547 Run::Step(State::Apply(
1007 labelled.clone(),
1548 labelled.clone(),
1008 text,
1549 text,
1009− Cont::LabelledArg(arg, Rc::new(k)),
1550+ Cont::ApplyTo(arg, Rc::new(k)),
1010 ))
1551 ))
1011 }
1552 }
1012 },
1553 },
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 @@
6use crate::config::Config;
6use crate::config::Config;
7use crate::domain::{Backend, MetaInfo, ROOT_ID};
7use crate::domain::{Backend, MetaInfo, ROOT_ID};
8use crate::eval::Interp;
8use 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};
10use jj_lib::backend::{CopyId, Signature, Timestamp, TreeValue};
11use jj_lib::backend::{CopyId, Signature, Timestamp, TreeValue};
11use jj_lib::commit::Commit;
12use jj_lib::commit::Commit;
12use jj_lib::config::{ConfigLayer, ConfigSource, StackedConfig};
13use jj_lib::config::{ConfigLayer, ConfigSource, StackedConfig};
14+use jj_lib::conflicts::ConflictMarkerStyle;
13use jj_lib::default_backend_factories::{default_backend_factories, default_working_copy_factories};
15use jj_lib::default_backend_factories::{default_backend_factories, default_working_copy_factories};
14use jj_lib::gitignore::GitIgnoreFile;
16use jj_lib::gitignore::GitIgnoreFile;
17+use jj_lib::local_working_copy::{FileState, FileStates, FileType, LocalWorkingCopy, TreeState, TreeStateSettings};
15−use jj_lib::matchers::{EverythingMatcher, NothingMatcher};
18+use jj_lib::matchers::{DifferenceMatcher, EverythingMatcher, FilesMatcher, NothingMatcher};
16use jj_lib::merge::{Diff, Merge};
19use jj_lib::merge::{Diff, Merge};
17use jj_lib::merged_tree::MergedTree;
20use jj_lib::merged_tree::MergedTree;
18use jj_lib::merged_tree_builder::MergedTreeBuilder;
21use jj_lib::merged_tree_builder::MergedTreeBuilder;
19−use jj_lib::backend::{CommitId, FileId};
22+use jj_lib::backend::{CommitId, FileId, MergedTreeValueExt, TreeId};
20use jj_lib::object_id::ObjectId;
23use jj_lib::object_id::ObjectId;
21use jj_lib::op_store::OperationId;
24use jj_lib::op_store::OperationId;
22use jj_lib::ref_name::{RefName, RemoteName, WorkspaceName, WorkspaceNameBuf};
25use jj_lib::ref_name::{RefName, RemoteName, WorkspaceName, WorkspaceNameBuf};
23use jj_lib::repo::{ReadonlyRepo, Repo};
26use 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};
25use jj_lib::settings::UserSettings;
29use jj_lib::settings::UserSettings;
26use jj_lib::store::Store;
30use jj_lib::store::Store;
31+use jj_lib::transaction::UnpublishedOperation;
27−use jj_lib::working_copy::SnapshotOptions;
32+use jj_lib::working_copy::{LockedWorkingCopy, SnapshotError, SnapshotOptions};
28use jj_lib::workspace::Workspace;
33use jj_lib::workspace::Workspace;
29−use std::cell::RefCell;
34+use std::cell::{Cell, RefCell};
30use std::collections::{BTreeMap, BTreeSet, HashMap};
35use std::collections::{BTreeMap, BTreeSet, HashMap};
31use std::future::Future;
36use std::future::Future;
32use std::os::unix::io::AsRawFd;
37use std::os::unix::io::AsRawFd;
33use std::path::PathBuf;
38use std::path::PathBuf;
34−use std::rc::Rc;
39+use std::rc::{Rc, Weak};
35−use std::sync::{Arc, Mutex};
40+use std::sync::{Arc, Mutex, OnceLock};
36
41
37fn block_on<F: Future>(f: F) -> F::Output {
42fn block_on<F: Future>(f: F) -> F::Output {
38 pollster::block_on(f)
43 pollster::block_on(f)
@@ -40,6 +45,15 @@ fn block_on<F: Future>(f: F) -> F::Output {
40
45
41type OpenError = (u8, String);
46type OpenError = (u8, String);
42
47
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+
43// ----------------------------------------------------------------------
57// ----------------------------------------------------------------------
44// locating and opening the repository (§7.1)
58// locating and opening the repository (§7.1)
45// ----------------------------------------------------------------------
59// ----------------------------------------------------------------------
@@ -64,6 +78,7 @@ fn toml_string(s: &str) -> String {
64 '\n' => out.push_str("\\n"),
78 '\n' => out.push_str("\\n"),
65 '\r' => out.push_str("\\r"),
79 '\r' => out.push_str("\\r"),
66 '\t' => out.push_str("\\t"),
80 '\t' => out.push_str("\\t"),
81+ c if c.is_ascii_control() => out.push_str(&format!("\\u{:04X}", c as u32)),
67 c => out.push(c),
82 c => out.push(c),
68 }
83 }
69 }
84 }
@@ -252,31 +267,1166 @@ pub struct JjInner {
252 repo: Mutex<Arc<ReadonlyRepo>>,
267 repo: Mutex<Arc<ReadonlyRepo>>,
253 workspace_name: WorkspaceNameBuf,
268 workspace_name: WorkspaceNameBuf,
254 /// visible map backing the `Backend` lookups during evaluation; filled by
269 /// 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)
256 visible: Mutex<Option<Arc<VisibleRepo>>>,
272 visible: Mutex<Option<Arc<VisibleRepo>>>,
257 lock_guard: Mutex<Option<FileLock>>,
273 lock_guard: Mutex<Option<FileLock>>,
258− /// the snapshot transaction and working-copy lock, held between
274+ /// the snapshot's unpublished rewrite, kept between build_interp and
259− /// build_interp and persist so the snapshot and the expression's edits
275+ /// persist so the snapshot and the expression's edits form one jj
260− /// form one jj operation (§1.2); dropped uncommitted by printing runs
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
261 pending: Mutex<Option<PendingSnapshot>>,
280 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>>,
262}
287}
263
288
264/// holding the file keeps the flock
289/// holding the file keeps the flock
265pub struct FileLock(#[allow(dead_code)] std::fs::File);
290pub struct FileLock(#[allow(dead_code)] std::fs::File);
266
291
267−#[allow(dead_code)]
268struct PendingSnapshot {
292struct PendingSnapshot {
269 /// repo as loaded at head, before the snapshot
293 /// repo as loaded at head, before the snapshot
270 pre_repo: Arc<ReadonlyRepo>,
294 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
272 /// persisting operation (§1.2 step 8, §7.7)
296 /// persisting operation (§1.2 step 8, §7.7)
273− new_wc_id: CommitId,
274 tree: MergedTree,
297 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+ )
275}
1424}
276
1425
277#[derive(Clone)]
1426#[derive(Clone)]
278pub struct JjBackend {
1427pub struct JjBackend {
279 inner: Arc<JjInner>,
1428 inner: Arc<JjInner>,
1429+ replay_cache: Rc<ReplayCache>,
280}
1430}
281
1431
282impl JjBackend {
1432impl JjBackend {
@@ -284,7 +1434,7 @@ impl JjBackend {
284 let root = find_repo_root().ok_or_else(|| {
1434 let root = find_repo_root().ok_or_else(|| {
285 (
1435 (
286 2,
1436 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(),
288 )
1438 )
289 })?;
1439 })?;
290 let settings = user_settings(cfg);
1440 let settings = user_settings(cfg);
@@ -305,10 +1455,22 @@ impl JjBackend {
305 ),
1455 ),
306 ));
1456 ));
307 }
1457 }
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+ }
308 let workspace_name = workspace.workspace_name().to_owned();
1469 let workspace_name = workspace.workspace_name().to_owned();
309 let repo = block_on(workspace.repo_loader().load_at_head())
1470 let repo = block_on(workspace.repo_loader().load_at_head())
310 .map_err(|e| (2, format!("cannot load the repository at head: {}", e)))?;
1471 .map_err(|e| (2, format!("cannot load the repository at head: {}", e)))?;
311 Ok(JjBackend {
1472 Ok(JjBackend {
1473+ replay_cache: Rc::new(ReplayCache::default()),
312 inner: Arc::new(JjInner {
1474 inner: Arc::new(JjInner {
313 workspace_root: root,
1475 workspace_root: root,
314 workspace: Mutex::new(workspace),
1476 workspace: Mutex::new(workspace),
@@ -317,6 +1479,8 @@ impl JjBackend {
317 visible: Mutex::new(None),
1479 visible: Mutex::new(None),
318 lock_guard: Mutex::new(None),
1480 lock_guard: Mutex::new(None),
319 pending: Mutex::new(None),
1481 pending: Mutex::new(None),
1482+ folds_case: OnceLock::new(),
1483+ long_names: Mutex::new(HashMap::new()),
320 }),
1484 }),
321 })
1485 })
322 }
1486 }
@@ -336,21 +1500,22 @@ impl JjBackend {
336 {
1500 {
337 Ok(f) => f,
1501 Ok(f) => f,
338 Err(e) => {
1502 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));
340 std::process::exit(2);
1504 std::process::exit(2);
341 }
1505 }
342 };
1506 };
343 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
1507 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
344 if rc != 0 {
1508 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"));
346 std::process::exit(2);
1510 std::process::exit(2);
347 }
1511 }
348 *self.inner.lock_guard.lock().unwrap() = Some(FileLock(file));
1512 *self.inner.lock_guard.lock().unwrap() = Some(FileLock(file));
1513+ put_back_left(&self.inner.workspace_root);
349 }
1514 }
350
1515
351 pub fn build_interp(
1516 pub fn build_interp(
352 &self,
1517 &self,
353− cfg: &Config,
1518+ cfg: &mut Config,
354 _text: &str,
1519 _text: &str,
355 snapshot: bool,
1520 snapshot: bool,
356 ) -> Result<(Interp, Value, Value), OpenError> {
1521 ) -> Result<(Interp, Value, Value), OpenError> {
@@ -359,8 +1524,8 @@ impl JjBackend {
359 let head_repo = block_on(self.inner.workspace.lock().unwrap().repo_loader().load_at_head())
1524 let head_repo = block_on(self.inner.workspace.lock().unwrap().repo_loader().load_at_head())
360 .map_err(|e| (2, format!("cannot reload the repository at head: {}", e)))?;
1525 .map_err(|e| (2, format!("cannot reload the repository at head: {}", e)))?;
361 *self.inner.repo.lock().unwrap() = head_repo;
1526 *self.inner.repo.lock().unwrap() = head_repo;
362− let loaded_visible = block_on(read_visible(&self.current_repo()))?;
1527+ let loaded_visible = Arc::new(block_on(read_visible(&self.current_repo()))?);
363− let loaded = build_repo_value(&loaded_visible, &loaded_visible.wc_change_id)?;
1528+ let loaded = build_repo_value(&loaded_visible, &loaded_visible.wc_change_id, &self.replay_cache)?;
364
1529
365 let current = if snapshot {
1530 let current = if snapshot {
366 // §7.4: snapshot the working directory into the wc commit; the
1531 // §7.4: snapshot the working directory into the wc commit; the
@@ -371,19 +1536,19 @@ impl JjBackend {
371 // no working-copy change: the repo is the one already loaded,
1536 // no working-copy change: the repo is the one already loaded,
372 // so reuse its value instead of rebuilding it
1537 // so reuse its value instead of rebuilding it
373 None => {
1538 None => {
374− *self.inner.visible.lock().unwrap() = Some(Arc::new(loaded_visible));
1539+ *self.inner.visible.lock().unwrap() = Some(loaded_visible.clone());
375 loaded.clone()
1540 loaded.clone()
376 }
1541 }
377 Some(pending) => {
1542 Some(pending) => {
378 *self.inner.pending.lock().unwrap() = Some(pending);
1543 *self.inner.pending.lock().unwrap() = Some(pending);
379 let vis = block_on(read_visible(&post))?;
1544 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)?;
381 *self.inner.visible.lock().unwrap() = Some(Arc::new(vis));
1546 *self.inner.visible.lock().unwrap() = Some(Arc::new(vis));
382 cur
1547 cur
383 }
1548 }
384 }
1549 }
385 } else {
1550 } else {
386− *self.inner.visible.lock().unwrap() = Some(Arc::new(loaded_visible));
1551+ *self.inner.visible.lock().unwrap() = Some(loaded_visible.clone());
387 loaded.clone()
1552 loaded.clone()
388 };
1553 };
389
1554
@@ -392,35 +1557,59 @@ impl JjBackend {
392 cfg.shapes.clone(),
1557 cfg.shapes.clone(),
393 crate::value::Env::empty(),
1558 crate::value::Env::empty(),
394 );
1559 );
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+ }
395 // §7.2: if the focused commit is in the immutable set, the snapshot
1573 // §7.2: if the focused commit is in the immutable set, the snapshot
396 // cannot go into it; the focus is a new empty child of it holding the
1574 // cannot go into it; the focus is a new empty child of it holding the
397 // snapshot, and persisting records that child
1575 // snapshot, and persisting records that child
398− match self.focus_if_immutable(&mut interp, cfg, &current)? {
1576+ match self.focus_if_immutable(&mut interp, cfg, &loaded, &current)? {
399− Some(adjusted) => {
1577+ Some((loaded, current)) => {
400− // the persisted child is part of the loaded value too, so a
1578+ // the snapshot's rewrite of the wc commit (and its rebase of
401− // run that changes nothing is a no-op
1579+ // the descendants) is not the repository's: lookups answer
402− Ok((interp, adjusted.clone(), adjusted))
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))
403 }
1587 }
404 None => Ok((interp, loaded, current)),
1588 None => Ok((interp, loaded, current)),
405 }
1589 }
406 }
1590 }
407
1591
408− /// If the focus of `current` is immutable, return a repo refocused on a
1592+ /// If the focus of `current` is immutable, return the loaded and current
409− /// new empty child holding the focus's files (§7.2).
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).
410 fn focus_if_immutable(
1599 fn focus_if_immutable(
411 &self,
1600 &self,
412 interp: &mut Interp,
1601 interp: &mut Interp,
413 cfg: &Config,
1602 cfg: &Config,
1603+ loaded: &Value,
414 current: &Value,
1604 current: &Value,
415− ) -> Result<Option<Value>, OpenError> {
1605+ ) -> Result<Option<(Value, Value)>, OpenError> {
416− // evaluate the config to compute the immutable set
1606+ // Evaluate the config to compute the immutable set. This uses the
417− let mut probe = Interp::new(
1607+ // interpreter the run will go on to use, rather than a throwaway one,
418− Rc::new(self.clone()),
1608+ // so that the `immutable` (and the `trunk` inside it) computed here is
419− cfg.shapes.clone(),
1609+ // reused when rendering or persisting asks for the same Repo value —
420− crate::value::Env::empty(),
1610+ // each is a full interpreted walk of the history.
421− );
422− crate::config::eval_config(&mut probe, cfg).map_err(|c| (3, c.msg))?;
1611+ crate::config::eval_config(interp, cfg).map_err(|c| (3, c.msg))?;
423− let immutable = crate::repo::compute_immutable(&mut probe, current)
1612+ let immutable = crate::repo::compute_immutable(interp, current)
424 .map_err(|c| (1, c.msg))?;
1613 .map_err(|c| (1, c.msg))?;
425 let focus_id = match current.field("root").and_then(|r| r.field("id")) {
1614 let focus_id = match current.field("root").and_then(|r| r.field("id")) {
426 Ok(Value::Id(i)) => i.to_string(),
1615 Ok(Value::Id(i)) => i.to_string(),
@@ -429,90 +1618,101 @@ impl JjBackend {
429 if !immutable.contains(&focus_id) {
1618 if !immutable.contains(&focus_id) {
430 return Ok(None);
1619 return Ok(None);
431 }
1620 }
432− // build: new empty child of the focus with the focus's files, minted id
1621+ // the focus's location in `loaded`, pre-snapshot; the child is its
433− let _ = interp;
1622+ // last child, so every existing child sits to the left of it
434− let root = current.field("root").map_err(|c| (1, c.msg))?;
1623+ let field = |v: &Value, name: &str| v.field(name).map_err(|c| (1, c.msg));
435− let files = root.field("files").map_err(|c| (1, c.msg))?;
1624+ let root = field(loaded, "root")?;
436− let child = crate::value::Value::record(&[
1625+ let frame = Value::record(&[
437− ("files", files),
1626+ ("left", field(loaded, "children")?),
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()),
1627+ ("parent", root.clone()),
447− ("children", crate::value::Value::list(vec![])),
1628+ ("right", Value::list(vec![])),
448 ]);
1629 ]);
449− let mut new_children: Vec<Value> = current
1630+ let mut frames = vec![frame];
450− .field("children")
1631+ frames.extend_from_slice(field(loaded, "context")?.as_list().map_err(|c| (1, c.msg))?);
451− .map_err(|c| (1, c.msg))?
1632+ let context = Value::list(frames);
452− .as_list()
1633+ let id = Value::Id(Rc::new(interp.mint_id()));
453− .map_err(|c| (1, c.msg))?
1634+ let at_child = |files: Value| {
454− .to_vec();
1635+ Value::record(&[
455− new_children.push(child_subtree);
1636+ ("children", Value::list(vec![])),
456− let parent = crate::value::Value::record(&[
1637+ ("context", context.clone()),
457− ("children", crate::value::Value::list(new_children)),
1638+ (
458− ("context", current.field("context").map_err(|c| (1, c.msg))?),
1639+ "root",
459− ("root", root),
1640+ Value::record(&[
460− ]);
1641+ ("files", files),
461− // refocus onto the child (it is the last child)
1642+ ("id", id.clone()),
462− let child_id = match child.field("id").map_err(|c| (1, c.msg))? {
1643+ ("labels", Value::list(vec![])),
463− Value::Id(i) => i.to_string(),
1644+ ("message", Value::text("")),
464− _ => unreachable!(),
1645+ ]),
1646+ ),
1647+ ])
465 };
1648 };
466− crate::repo::by_id(&parent, &child_id)
1649+ let current_at_child = at_child(field(&field(current, "root")?, "files")?);
467− .map_err(|c| (1, c.msg))?
1650+ // with nothing snapshotted `current` is `loaded` itself: share one
468− .ok_or_else(|| (1, "internal: cannot refocus".to_string()))
1651+ // value, so a run that changes nothing is a no-op without comparing
469− .map(Some)
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)))
470 }
1658 }
471
1659
472− /// Snapshot the working copy into the wc commit; returns the repo at the
1660+ /// Snapshot the working directory into the working-copy commit (§7.4);
473− /// snapshot operation (unpublished but written), or the pre-snapshot repo
1661+ /// return the repo at the snapshot's operation, written but not
474− /// if nothing changed.
1662+ /// published, or the repo as loaded when there is nothing to record.
475 async fn snapshot_repo(
1663 async fn snapshot_repo(
476 &self,
1664 &self,
477 ) -> Result<(Arc<ReadonlyRepo>, Option<PendingSnapshot>), OpenError> {
1665 ) -> Result<(Arc<ReadonlyRepo>, Option<PendingSnapshot>), OpenError> {
478 let mut ws_guard = self.inner.workspace.lock().unwrap();
1666 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);
480 let mut locked_ws = ws_guard.start_working_copy_mutation()
1669 let mut locked_ws = ws_guard.start_working_copy_mutation()
481 .await
1670 .await
482 .map_err(|e| (2, format!("cannot lock the working copy: {}", e)))?;
1671 .map_err(|e| (2, format!("cannot lock the working copy: {}", e)))?;
483− let options = SnapshotOptions {
1672+ let old_tree = locked_ws.locked_wc().old_tree().clone();
484− base_ignores: GitIgnoreFile::empty(),
1673+ let scanned = scan(locked_ws.locked_wc(), &root, &old_tree, &saved, &snapshot_options())
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)
493 .await
1674 .await
494 .map_err(|e| (2, format!("cannot snapshot the working copy: {}", e)))?;
1675 .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();
1676+ // the repo build_interp loaded at head from the workspace's own
496− if !changed {
1677+ // loader, whose store the scanned tree lives in
497− locked_ws
1678+ let head = self.current_repo();
498− .finish(self.current_repo().operation().id().clone())
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())
499 .await
1693 .await
500 .map_err(|e| (2, format!("cannot finish the snapshot: {}", e)))?;
1694 .map_err(|e| (2, format!("cannot finish the snapshot: {}", e)))?;
501− return Ok((self.current_repo(), None));
1695+ return Ok((head, None));
502 }
1696 }
503− // the snapshotted tree lives in the workspace's store; drive the
1697+ // a directory come to hold the parent's files, as where it reverts
504− // transaction from the workspace's repo loader so stores match
1698+ // the commit's change, is recorded with the parent's tree (§7.4), as
505− let ws_repo = loader
1699+ // persistence writes a commit with its parent's files (§7.5 step 4):
506− .load_at_head()
1700+ // the snapshot builds on the commit's tree, and so could make
507− .await
1701+ // another tree for the parent's files, one jj takes for a change.
508− .map_err(|e| (2, format!("cannot reload the repository: {}", e)))?;
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+ };
509− let mut tx = ws_repo.start_transaction();
1710+ let mut tx = head.start_transaction();
510 tx.set_is_snapshot(true);
1711 tx.set_is_snapshot(true);
511− let wc_commit = self.wc_commit(&ws_repo).await?;
512 let new_wc = tx
1712 let new_wc = tx
513 .repo_mut()
1713 .repo_mut()
514 .rewrite_commit(&wc_commit)
1714 .rewrite_commit(&wc_commit)
515− .set_tree(new_tree)
1715+ .set_tree(tree)
516 .write()
1716 .write()
517 .await
1717 .await
518 .map_err(|e| (2, format!("cannot write the snapshot commit: {}", e)))?;
1718 .map_err(|e| (2, format!("cannot write the snapshot commit: {}", e)))?;
@@ -529,25 +1729,26 @@ impl JjBackend {
529 .write("snapshot working copy")
1729 .write("snapshot working copy")
530 .await
1730 .await
531 .map_err(|e| (2, format!("cannot write the snapshot operation: {}", e)))?;
1731 .map_err(|e| (2, format!("cannot write the snapshot operation: {}", e)))?;
532− let op_id = unpublished.operation().id().clone();
533 let new_repo = unpublished.leave_unpublished();
1732 let new_repo = unpublished.leave_unpublished();
534− // the working-copy state tracks the snapshot tree, against the
1733+ // Release the lock *without* finishing: `finish` saves the scanned
535− // current (published) head; persist will finish against its own op
1734+ // tree state to disk, which would record the working directory as
536− locked_ws
1735+ // already snapshotted while the published head still has the old wc
537− .finish(self.current_repo().operation().id().clone())
1736+ // tree. The next run's incremental snapshot would then find nothing
538− .await
1737+ // to do and build the Repo from the stale tree — and a persisting run
539− .map_err(|e| (2, format!("cannot finish the snapshot: {}", e)))?;
1738+ // would check that stale tree back out, destroying the edits. The
540− let _ = op_id;
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);
541 let pending = PendingSnapshot {
1743 let pending = PendingSnapshot {
542− pre_repo: self.current_repo(),
1744+ pre_repo: head,
543− new_wc_id: new_wc.id().clone(),
544 tree: new_wc.tree().clone(),
1745 tree: new_wc.tree().clone(),
1746+ fold_into_wc: true,
1747+ scanned,
545 };
1748 };
546 Ok((new_repo, Some(pending)))
1749 Ok((new_repo, Some(pending)))
547 }
1750 }
548
1751
549−
550−
551 async fn wc_commit(&self, repo: &Arc<ReadonlyRepo>) -> Result<Commit, OpenError> {
1752 async fn wc_commit(&self, repo: &Arc<ReadonlyRepo>) -> Result<Commit, OpenError> {
552 let id = repo
1753 let id = repo
553 .view()
1754 .view()
@@ -565,12 +1766,13 @@ impl JjBackend {
565 interp: &mut Interp,
1766 interp: &mut Interp,
566 cfg: &Config,
1767 cfg: &Config,
567 loaded: &Value,
1768 loaded: &Value,
568− _current: &Value,
1769+ current: &Value,
569 new: &Value,
1770 new: &Value,
570 text: &str,
1771 text: &str,
571− ) -> Result<(), Crash> {
1772+ ) -> Result<bool, Crash> {
1773+ // §1.2 step 8: unchanged, up to the order of snapshot entries (§7.3)
572− if crate::value::value_eq(loaded, new)? {
1774+ if crate::repo::same_repo(loaded, new)? {
573− return Ok(());
1775+ return Ok(false);
574 }
1776 }
575 *interp.old_repo.borrow_mut() = Some(loaded.clone());
1777 *interp.old_repo.borrow_mut() = Some(loaded.clone());
576 crate::repo::validate_repo(interp, new)?;
1778 crate::repo::validate_repo(interp, new)?;
@@ -590,7 +1792,7 @@ impl JjBackend {
590 // walk below compares against its output, so the snapshot change and
1792 // walk below compares against its output, so the snapshot change and
591 // the expression's edits become one rewrite
1793 // the expression's edits become one rewrite
592 let mut snapshot_wc: Option<Commit> = None;
1794 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) {
594 let wc_commit = block_on(self.wc_commit(&base)).map_err(|e| Crash::new(e.1))?;
1796 let wc_commit = block_on(self.wc_commit(&base)).map_err(|e| Crash::new(e.1))?;
595 let c = block_on(
1797 let c = block_on(
596 tx.repo_mut()
1798 tx.repo_mut()
@@ -614,6 +1816,9 @@ impl JjBackend {
614 old_stored = Arc::new(vis);
1816 old_stored = Arc::new(vis);
615 }
1817 }
616 let mut written: BTreeMap<String, CommitId> = BTreeMap::new();
1818 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])?;
617 // shared across the whole persist walk so unchanged blobs are inflated once
1822 // shared across the whole persist walk so unchanged blobs are inflated once
618 let cache = BlobCache::default();
1823 let cache = BlobCache::default();
619 let entries = EntryCache::default();
1824 let entries = EntryCache::default();
@@ -637,33 +1842,35 @@ impl JjBackend {
637 }
1842 }
638 let stored_files = block_on(tree_to_files(&store, &root_jj.tree(), &cache, &entries))
1843 let stored_files = block_on(tree_to_files(&store, &root_jj.tree(), &cache, &entries))
639 .map_err(|e| Crash::new(e.msg))?;
1844 .map_err(|e| Crash::new(e.msg))?;
640− if !crate::value::value_eq(
641− &Value::list(stored_files),
1845+ if !crate::repo::snapshot_eq(&Value::list(stored_files), &root_v.field("files")?)? {
642− &Value::list(root_v.field("files")?.as_list()?.to_vec()),
643− )? {
644 return Err(Crash::new("persistence: the root commit cannot be changed"));
1846 return Err(Crash::new("persistence: the root commit cannot be changed"));
645 }
1847 }
646 }
1848 }
647 written.insert(ROOT_ID.to_string(), root_jj.id().clone());
1849 written.insert(ROOT_ID.to_string(), root_jj.id().clone());
648
1850
649 // walk `new` top-down (§7.5 step 4), parents before children
1851 // walk `new` top-down (§7.5 step 4), parents before children
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)> =
650− let mut queue: Vec<(Value, String)> = Vec::new(); // (subtree value, parent change id)
1855+ std::collections::VecDeque::new(); // (subtree value, parent change id)
651 for kid in top.field("children")?.as_list()?.iter() {
1856 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()));
653 }
1858 }
654− while !queue.is_empty() {
655− let (subtree, parent_change) = queue.remove(0);
1859+ while let Some((subtree, parent_change)) = queue.pop_front() {
656 let commit_v = subtree.field("root")?;
1860 let commit_v = subtree.field("root")?;
657 let id = commit_id_of(&commit_v)?;
1861 let id = commit_id_of(&commit_v)?;
658 let parent_jj = written
1862 let parent_jj = written
659 .get(&parent_change)
1863 .get(&parent_change)
660 .cloned()
1864 .cloned()
661 .ok_or_else(|| Crash::new("persistence: internal: parent not yet written"))?;
1865 .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)?;
663 let message = commit_v.field("message")?.as_text()?.to_string();
1869 let message = commit_v.field("message")?.as_text()?.to_string();
664 let new_id = match old_stored.commits.get(&id) {
1870 let new_id = match old_stored.commits.get(&id) {
665 None => {
1871 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))?;
667 let change_id = jj_lib::backend::ChangeId::try_from_reverse_hex(&id).ok_or_else(|| {
1874 let change_id = jj_lib::backend::ChangeId::try_from_reverse_hex(&id).ok_or_else(|| {
668 Crash::new(format!("persistence: `{}` is not a valid change id", id))
1875 Crash::new(format!("persistence: `{}` is not a valid change id", id))
669 })?;
1876 })?;
@@ -682,17 +1889,31 @@ impl JjBackend {
682 Some(stored) => {
1889 Some(stored) => {
683 let stored_commit = &stored.commit;
1890 let stored_commit = &stored.commit;
684 let parent_changed = hex_of(stored_commit.parent_ids().first()) != parent_jj.hex();
1891 let parent_changed = hex_of(stored_commit.parent_ids().first()) != parent_jj.hex();
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
685− let files_changed = {
1896+ let files_changed = crate::repo::stored_tree(&files_v)
1897+ != Some(tree_name(&stored_tree).as_str())
1898+ && {
686− let stored_files =
1899+ let stored_files =
687− block_on(tree_to_files(&store, &stored_commit.tree(), &cache, &entries))?;
1900+ block_on(tree_to_files(&store, &stored_tree, &cache, &entries))?;
688− !crate::value::value_eq(
1901+ // a tree has no order: entries listed differently
1902+ // are not a change to rewrite (§7.3)
689− &Value::list(stored_files),
1903+ !crate::repo::snapshot_eq(&Value::list(stored_files), &files_v)?
690− &Value::list(files_v.as_list()?.to_vec()),
691− )?
692− };
1904+ };
693 let msg_changed = stored_commit.description() != message;
1905 let msg_changed = stored_commit.description() != message;
694 if parent_changed || files_changed || msg_changed {
1906 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+ };
696 let c = block_on(
1917 let c = block_on(
697 tx.repo_mut()
1918 tx.repo_mut()
698 .rewrite_commit(stored_commit)
1919 .rewrite_commit(stored_commit)
@@ -711,7 +1932,7 @@ impl JjBackend {
711 };
1932 };
712 written.insert(id.clone(), new_id);
1933 written.insert(id.clone(), new_id);
713 for kid in subtree.field("children")?.as_list()?.iter() {
1934 for kid in subtree.field("children")?.as_list()?.iter() {
714− queue.push((kid.clone(), id.clone()));
1935+ queue.push_back((kid.clone(), id.clone()));
715 }
1936 }
716 }
1937 }
717
1938
@@ -755,37 +1976,230 @@ impl JjBackend {
755 let desc = truncate_chars(text, 200);
1976 let desc = truncate_chars(text, 200);
756 let unpublished = block_on(tx.write(desc))
1977 let unpublished = block_on(tx.write(desc))
757 .map_err(|e| Crash::new(format!("cannot write the operation: {}", e)))?;
1978 .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)))?;
761
1979
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
763 let focus_commit = block_on(store.get_commit_async(&focus_jj))
1981 let focus_commit = block_on(store.get_commit_async(&focus_jj))
764 .map_err(|e| Crash::new(format!("cannot read the focus commit: {}", e)))?;
1982 .map_err(|e| Crash::new(format!("cannot read the focus commit: {}", e)))?;
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+ };
765− block_on(self.checkout(&focus_commit, op_id))?;
2000+ block_on(self.checkout(&focus_commit, Some(&on_disk), Some(unpublished), Some(", and `j undo` goes back")))?;
766−
767− Ok(())
2001+ Ok(true)
768 }
2002 }
769
2003
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).
770− async fn checkout(&self, commit: &Commit, op_id: OperationId) -> Result<(), Crash> {
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> {
771 let mut ws_guard = self.inner.workspace.lock().unwrap();
2032 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);
772 let mut locked_ws = ws_guard.start_working_copy_mutation()
2035 let mut locked_ws = ws_guard.start_working_copy_mutation()
773 .await
2036 .await
774 .map_err(|e| Crash::new(format!("cannot lock the working copy: {}", e)))?;
2037 .map_err(|e| Crash::new(format!("cannot lock the working copy: {}", e)))?;
775− locked_ws
2038+ // A persisting run's snapshot released its lock without saving what
776− .locked_wc()
2039+ // it scanned, so the state just loaded may still describe the tree
777− .check_out(commit)
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()))
778− .await
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())
779 .map_err(|e| Crash::new(format!("cannot check out the focus: {}", e)))?;
2095 .map_err(|e| Crash::new(format!("cannot check out the focus: {}", e)))?;
780− locked_ws
2096+ let clear = match on_disk {
781− .finish(op_id)
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()
782− .await
2114+ .await
783− .map_err(|e| Crash::new(format!("cannot finish the checkout: {}", e)))?;
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+ }
784 Ok(())
2197 Ok(())
785 }
2198 }
786
2199
787 fn user_signature(&self, cfg: &Config) -> Result<Signature, Crash> {
2200 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))?;
789 Ok(Signature {
2203 Ok(Signature {
790 name,
2204 name,
791 email,
2205 email,
@@ -878,6 +2292,7 @@ impl JjBackend {
878 }
2292 }
879
2293
880 pub fn cmd_remote(&self, url: &str) -> Result<(), OpenError> {
2294 pub fn cmd_remote(&self, url: &str) -> Result<(), OpenError> {
2295+ let url = &origin_url(url)?;
881 self.take_lock();
2296 self.take_lock();
882 let base = self.current_repo();
2297 let base = self.current_repo();
883 let origin = RemoteName::new("origin");
2298 let origin = RemoteName::new("origin");
@@ -895,7 +2310,17 @@ impl JjBackend {
895 .map_err(|e| (1, format!("cannot set the remote: {}", e)))
2310 .map_err(|e| (1, format!("cannot set the remote: {}", e)))
896 }
2311 }
897
2312
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+
898 pub fn cmd_fetch(&self) -> Result<(), OpenError> {
2322 pub fn cmd_fetch(&self) -> Result<(), OpenError> {
2323+ self.enter_workspace_root()?;
899 let origin = RemoteName::new("origin");
2324 let origin = RemoteName::new("origin");
900 let base = self.head_repo()?;
2325 let base = self.head_repo()?;
901 let mut tx = base.start_transaction();
2326 let mut tx = base.start_transaction();
@@ -908,27 +2333,52 @@ impl JjBackend {
908 let _ = block_on(self.publish_tx(tx, "fetch"))?;
2333 let _ = block_on(self.publish_tx(tx, "fetch"))?;
909 Ok(())
2334 Ok(())
910 }
2335 }
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()?;
912 let origin = RemoteName::new("origin");
2338 let origin = RemoteName::new("origin");
913 let base = self.head_repo()?;
2339 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)?;
915 let records = parse_push_records(&value, &vis, &base)?;
2341 let records = parse_push_records(&value, &vis, &base)?;
2342+ if records.is_empty() {
2343+ return Ok(PushOutcome::default());
2344+ }
916− check_push_records(&records, &vis, &base)?;
2345+ let deletes_wait = check_push_records(&records, &base, &vis, &immutable)?;
917− let _ = self.push_to_origin(&base, origin, &records, expr_text)?;
2346+ let mut outcome = PushOutcome::default();
918− Ok(())
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)
919 }
2368 }
920
2369
921 /// evaluate `EXPR` against the recorded repository, without snapshotting
2370 /// 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)
923 fn eval_push_expr(
2373 fn eval_push_expr(
924 &self,
2374 &self,
925− cfg: &Config,
2375+ cfg: &mut Config,
926 expr_text: &str,
2376 expr_text: &str,
927 base: &Arc<ReadonlyRepo>,
2377 base: &Arc<ReadonlyRepo>,
928− ) -> Result<(Value, Arc<VisibleRepo>), OpenError> {
2378+ ) -> Result<(Value, Arc<VisibleRepo>, BTreeSet<String>), OpenError> {
929 let outer = Rc::new(cfg.global_names.clone());
2379 let outer = Rc::new(cfg.global_names.clone());
930 let expr = crate::parse::parse_expr(expr_text, outer)
2380 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)))?;
932 let (mut interp, _loaded, current) = self.build_interp(cfg, expr_text, false)?;
2382 let (mut interp, _loaded, current) = self.build_interp(cfg, expr_text, false)?;
933 let expr = crate::config::resolve_ids(&expr, &interp).map_err(|failures| {
2383 let expr = crate::config::resolve_ids(&expr, &interp).map_err(|failures| {
934 let (prefix, candidates) = &failures[0];
2384 let (prefix, candidates) = &failures[0];
@@ -939,15 +2389,30 @@ impl JjBackend {
939 }
2389 }
940 })?;
2390 })?;
941 crate::config::eval_config(&mut interp, cfg).map_err(|c| (3, format!("config.j: {}", c.msg)))?;
2391 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+ };
942 let env = interp.global_env();
2407 let env = interp.global_env();
943 let expr_rc = Rc::new(expr);
2408 let expr_rc = Rc::new(expr);
944 let mut v = interp
2409 let mut v = interp
945 .eval(&expr_rc, &env)
2410 .eval(&expr_rc, &env)
946− .map_err(|c| (1, format!("crash: {}", c.msg)))?;
2411+ .map_err(&expression_error)?;
947 if matches!(v, Value::Fun(_)) {
2412 if matches!(v, Value::Fun(_)) {
948 v = interp
2413 v = interp
949 .apply(v, current)
2414 .apply(v, current)
950− .map_err(|c| (1, format!("crash: {}", c.msg)))?;
2415+ .map_err(expression_error)?;
951 }
2416 }
952 let _ = base;
2417 let _ = base;
953 let vis = self
2418 let vis = self
@@ -957,19 +2422,28 @@ impl JjBackend {
957 .unwrap()
2422 .unwrap()
958 .clone()
2423 .clone()
959 .ok_or_else(|| (2, "internal: repository value not loaded".to_string()))?;
2424 .ok_or_else(|| (2, "internal: repository value not loaded".to_string()))?;
960− Ok((v, vis))
2425+ Ok((v, vis, immutable))
961 }
2426 }
962
2427
963 /// push the updates to `origin` and record the new remote-bookmark
2428 /// 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
965 fn push_to_origin(
2432 fn push_to_origin(
966 &self,
2433 &self,
967 base: &Arc<ReadonlyRepo>,
2434 base: &Arc<ReadonlyRepo>,
968 origin: &RemoteName,
2435 origin: &RemoteName,
969 records: &[(String, Option<CommitId>)],
2436 records: &[(String, Option<CommitId>)],
2437+ deletes_wait: bool,
970 description: &str,
2438 description: &str,
971 ) -> Result<Arc<ReadonlyRepo>, OpenError> {
2439 ) -> 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+ }
972 let mut targets = jj_lib::git::GitPushRefTargets::default();
2445 let mut targets = jj_lib::git::GitPushRefTargets::default();
2446+ let mut deletes = jj_lib::git::GitPushRefTargets::default();
973 for (name, after) in records {
2447 for (name, after) in records {
974 let before = base
2448 let before = base
975 .view()
2449 .view()
@@ -978,48 +2452,116 @@ impl JjBackend {
978 .as_resolved()
2452 .as_resolved()
979 .cloned()
2453 .cloned()
980 .unwrap_or(None);
2454 .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
982 .bookmarks
2459 .bookmarks
983 .push((jj_lib::ref_name::RefNameBuf::from(name.clone()), Diff::new(before, after.clone())));
2460 .push((jj_lib::ref_name::RefNameBuf::from(name.clone()), Diff::new(before, after.clone())));
984 }
2461 }
985 let mut tx = base.start_transaction();
2462 let mut tx = base.start_transaction();
986 let subprocess_options = self.git_subprocess_options()?;
2463 let subprocess_options = self.git_subprocess_options()?;
987 let mut callback = QuietGitCallback;
2464 let mut callback = QuietGitCallback;
2465+ let mut push = |tx: &mut jj_lib::transaction::Transaction, targets| {
988− let stats = jj_lib::git::push_refs(
2466+ jj_lib::git::push_refs(
989− tx.repo_mut(),
2467+ tx.repo_mut(),
990− subprocess_options,
2468+ subprocess_options.clone(),
991− origin,
2469+ origin,
992− &targets,
2470+ targets,
993− &mut callback,
2471+ &mut callback,
994− &jj_lib::git::GitPushOptions::default(),
2472+ &jj_lib::git::GitPushOptions::default(),
995− )
2473+ )
996− .map_err(|e| match e {
2474+ .map_err(|e| match e {
997− jj_lib::git::GitPushError::NoSuchRemote(_) => (1, "no remote origin".to_string()),
2475+ jj_lib::git::GitPushError::NoSuchRemote(_) => (1, "no remote origin".to_string()),
998− other => (1, format!("push failed: {}", other)),
2476+ other => (1, format!("push failed: {}", other)),
999− })?;
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+ }
1000 if !stats.all_ok() {
2518 if !stats.all_ok() {
1001 let mut names: Vec<String> = Vec::new();
2519 let mut names: Vec<String> = Vec::new();
1002 for (name, _) in stats.rejected.iter().chain(stats.remote_rejected.iter()) {
2520 for (name, _) in stats.rejected.iter().chain(stats.remote_rejected.iter()) {
1003 names.push(name.as_str().to_string());
2521 names.push(name.as_str().to_string());
1004 }
2522 }
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+ }
1005− return Err((1, format!("push rejected: {}", names.join(", "))));
2535+ return Err((1, msg));
1006 }
2536 }
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();
1012 Ok(repo)
2537 Ok(repo)
1013 }
2538 }
1014
2539
1015− pub fn cmd_undo(&self, redo: bool) -> Result<(), OpenError> {
2540+ pub fn cmd_undo(&self, redo: bool) -> Result<String, OpenError> {
1016 let base = self.head_repo()?;
2541 let base = self.head_repo()?;
1017− self.check_wc_clean(&base)?;
2542+ let on_disk = self.check_wc_clean(&base)?;
1018 let loader = self.repo_loader();
2543 let loader = self.repo_loader();
1019 let mut cur = base.operation().clone();
2544 let mut cur = base.operation().clone();
1020 let target_op_id: OperationId;
2545 let target_op_id: OperationId;
1021 loop {
2546 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) {
1023 (None, false) => {
2565 (None, false) => {
1024 let Some(parent) = cur.parent_ids().first().cloned() else {
2566 let Some(parent) = cur.parent_ids().first().cloned() else {
1025 return Err((1, "nothing to undo".to_string()));
2567 return Err((1, "nothing to undo".to_string()));
@@ -1045,11 +2587,25 @@ impl JjBackend {
1045 .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
2587 .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
1046 }
2588 }
1047 (Some(m), true) if m.kind == "undo" => {
2589 (Some(m), true) if m.kind == "undo" => {
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.
1048− target_op_id = m.id;
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+ };
1049 break;
2600 break;
1050 }
2601 }
1051 (Some(m), false) => {
2602 (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.
1053 if !m.authored_by_j {
2609 if !m.authored_by_j {
1054 let Some(parent) = cur.parent_ids().first().cloned() else {
2610 let Some(parent) = cur.parent_ids().first().cloned() else {
1055 return Err((1, "nothing to undo".to_string()));
2611 return Err((1, "nothing to undo".to_string()));
@@ -1057,23 +2613,57 @@ impl JjBackend {
1057 target_op_id = parent;
2613 target_op_id = parent;
1058 break;
2614 break;
1059 }
2615 }
1060− cur = block_on(loader.load_operation(&m.id))
2616+ let undo_op = block_on(loader.load_operation(&m.id))
1061 .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
2617 .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+ };
1062 }
2626 }
1063 (Some(m), true) => {
2627 (Some(m), true) => {
1064− // a redo: reverse the undo it reversed, following further
2628+ // a redo: the undo it reversed is already accounted for,
1065− // redos to the operation ultimately reapplied
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").
1066− target_op_id = if m.authored_by_j {
2632+ if !m.authored_by_j {
1067− block_on(chain_through_redos(&loader, &m.id))?
2633+ target_op_id = m.id;
1068− } else {
2634+ break;
1069− m.id
2635+ }
2636+ let Some(prev) = op_parent_id(&loader, &m.id)? else {
2637+ return Err((1, "nothing to redo".to_string()));
1070 };
2638 };
1071− break;
2639+ cur = block_on(loader.load_operation(&prev))
2640+ .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
1072 }
2641 }
1073 }
2642 }
1074 }
2643 }
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+ }
1075− let target_op = block_on(loader.load_operation(&target_op_id))
2651+ let mut target_op = block_on(loader.load_operation(&target_op_id))
1076 .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
2652 .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);
1077 let target_view = block_on(
2667 let target_view = block_on(
1078 loader
2668 loader
1079 .op_store()
2669 .op_store()
@@ -1097,56 +2687,94 @@ impl JjBackend {
1097 },
2687 },
1098 ));
2688 ));
1099 }
2689 }
2690+ // Use jj's current marker so either CLI can continue this history.
2691+ // The older j and jj marker formats remain readable above.
1100− let marker = if redo {
2692+ let marker = format!(
1101− format!("redo: {}", cur.id().hex())
2693+ "{}: restore to operation {}",
1102− } else {
2694+ if redo { "redo" } else { "undo" },
1103− format!("undo: {}", cur.id().hex())
2695+ target_op_id.hex()
1104− };
2696+ );
1105 let mut tx = base.start_transaction();
2697 let mut tx = base.start_transaction();
1106 tx.repo_mut().set_view(target_view.clone());
2698 tx.repo_mut().set_view(target_view.clone());
1107 let unpublished = block_on(tx.write(&marker))
2699 let unpublished = block_on(tx.write(&marker))
1108 .map_err(|e| (1, format!("cannot write the operation: {}", e)))?;
2700 .map_err(|e| (1, format!("cannot write the operation: {}", e)))?;
1109− let op_id = unpublished.operation().id().clone();
2701+ // the operation is published, then the restored focus checked out
1110− let repo = block_on(unpublished.publish())
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))
1111− .map_err(|e| (1, format!("cannot publish the operation: {}", e)))?;
2705+ .map_err(|e| (2, format!("cannot read the working-copy commit: {}", e)))?;
1112− *self.inner.repo.lock().unwrap() = repo.clone();
2706+ let back = if redo { ", and `j undo` goes back" } else { ", and `j redo` goes back" };
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))?;
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();
1115− Ok(())
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)
1116 }
2735 }
1117
2736
1118− /// refuse if the working directory differs from the focused commit's
2737+ /// Compare a scan with the recorded focus without publishing a snapshot.
1119− /// files (§7.7): reserved commands never snapshot, so compare the locked
2738+ /// Push only inspects: it must leave the saved working-copy state alone
1120− /// working copy's recorded tree with the focus commit's tree
2739+ /// so a later `j id` still sees every unrecorded edit.
2740+ fn inspect_working_copy(
2741+ &self,
1121− fn check_wc_clean(&self, base: &Arc<ReadonlyRepo>) -> Result<(), OpenError> {
2742+ base: &Arc<ReadonlyRepo>,
1122− let dirty = (1, "working copy has changes not in @; run `j id` to record them or discard them".to_string());
2743+ refresh_clean_state: bool,
2744+ ) -> Result<(MergedTree, bool), OpenError> {
1123− let wc_commit = block_on(self.wc_commit(base))?;
2745+ let wc_tree = block_on(self.wc_commit(base))?.tree();
1124− // snapshot the working directory (without persisting anything) and
1125− // compare against the focused commit's files (§7.7)
1126 let mut ws_guard = self.inner.workspace.lock().unwrap();
2746 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);
1127 let mut locked_ws = block_on(ws_guard.start_working_copy_mutation())
2749 let mut locked_ws = block_on(ws_guard.start_working_copy_mutation())
1128 .map_err(|e| (2, format!("cannot lock the working copy: {}", e)))?;
2750 .map_err(|e| (2, format!("cannot lock the working copy: {}", e)))?;
1129− let options = SnapshotOptions {
2751+ let old_tree = locked_ws.locked_wc().old_tree().clone();
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))
2752+ let scanned = block_on(scan(locked_ws.locked_wc(), &root, &old_tree, &saved, &snapshot_options()))
1137 .map_err(|e| (2, format!("cannot read the working copy: {}", e)))?;
2753 .map_err(|e| (2, format!("cannot read the working copy: {}", e)))?;
1138− let same = new_tree.tree_ids() == wc_commit.tree().tree_ids();
2754+ let current = locked_ws.locked_wc().old_operation_id() == base.operation().id();
1139− // discard: finish against the current op so no state changes
2755+ let recorded = match marker_style(base.settings()) {
1140− block_on(locked_ws.finish(base.operation().id().clone()))
2756+ Ok(markers) => block_on(snapshot_tree(&root, markers, &wc_tree, &old_tree, current, &scanned)),
1141− .map_err(|e| (2, format!("cannot finish: {}", e)))?;
2757+ Err(e) => Err(e),
1142− drop(ws_guard);
1143− if !same {
1144− return Err(dirty);
1145 }
2758 }
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))
1147 }
2773 }
1148
2774
2775+ /// The operation log for `ops` (§7.7), one line per operation, newest
2776+ /// first; the caller writes it to stdout.
1149− pub fn cmd_ops(&self) -> Result<(), OpenError> {
2777+ pub fn cmd_ops(&self) -> Result<String, OpenError> {
1150 let base = self.head_repo()?;
2778 let base = self.head_repo()?;
1151 let loader = self.repo_loader();
2779 let loader = self.repo_loader();
1152 let head = base.operation().clone();
2780 let head = base.operation().clone();
@@ -1159,8 +2787,8 @@ impl JjBackend {
1159 let mut tag = "";
2787 let mut tag = "";
1160 match op_kind(op.metadata()) {
2788 match op_kind(op.metadata()) {
1161 OpKind::Normal => {}
2789 OpKind::Normal => {}
1162− OpKind::Undo { .. } => tag = "undo",
2790+ OpKind::Undo => tag = "undo",
1163− OpKind::Redo { .. } => tag = "redo",
2791+ OpKind::Redo => tag = "redo",
1164 }
2792 }
1165 if desc.is_empty() {
2793 if desc.is_empty() {
1166 desc = "(no description)".to_string();
2794 desc = "(no description)".to_string();
@@ -1183,8 +2811,7 @@ impl JjBackend {
1183 None => None,
2811 None => None,
1184 };
2812 };
1185 }
2813 }
1186− print!("{}", out);
1187− Ok(())
2814+ Ok(out)
1188 }
2815 }
1189}
2816}
1190
2817
@@ -1221,6 +2848,7 @@ pub fn cmd_init(cfg: &Config) -> Result<(), OpenError> {
1221 }
2848 }
1222 .map_err(|e| (2, format!("cannot create the repository: {}", e)))?;
2849 .map_err(|e| (2, format!("cannot create the repository: {}", e)))?;
1223 let backend = JjBackend {
2850 let backend = JjBackend {
2851+ replay_cache: Rc::new(ReplayCache::default()),
1224 inner: Arc::new(JjInner {
2852 inner: Arc::new(JjInner {
1225 workspace_root: cwd.clone(),
2853 workspace_root: cwd.clone(),
1226 workspace: Mutex::new(workspace),
2854 workspace: Mutex::new(workspace),
@@ -1229,23 +2857,43 @@ pub fn cmd_init(cfg: &Config) -> Result<(), OpenError> {
1229 visible: Mutex::new(None),
2857 visible: Mutex::new(None),
1230 lock_guard: Mutex::new(None),
2858 lock_guard: Mutex::new(None),
1231 pending: Mutex::new(None),
2859 pending: Mutex::new(None),
2860+ folds_case: OnceLock::new(),
2861+ long_names: Mutex::new(HashMap::new()),
1232 }),
2862 }),
1233 };
2863 };
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();
1234 if had_git {
2867 if had_git {
1235 let import_options = backend.git_import_options()?;
2868 let import_options = backend.git_import_options()?;
1236− let mut tx = backend.current_repo().start_transaction();
1237 block_on(jj_lib::git::import_refs(tx.repo_mut(), &import_options))
2869 block_on(jj_lib::git::import_refs(tx.repo_mut(), &import_options))
1238 .map_err(|e| (2, format!("cannot import the git history: {}", e)))?;
2870 .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)))?;
1239 block_on(tx.repo_mut().rebase_descendants())
2879 block_on(tx.repo_mut().rebase_descendants())
1240 .map_err(|e| (2, format!("cannot rebase descendants: {}", e)))?;
2880 .map_err(|e| (2, format!("cannot rebase descendants: {}", e)))?;
1241− let _ = block_on(backend.publish_tx(tx, "import git refs"))?;
1242 }
2881 }
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();
1243− create_initial_wc_commit(&backend, cfg, "init")?;
2890+ create_initial_wc_commit(&backend, cfg, tx, "init", head, had_git, Some(""))?;
1244 Ok(())
2891 Ok(())
1245}
2892}
1246
2893
1247pub fn cmd_clone(cfg: &Config, url: &str, dir: &str) -> Result<(), OpenError> {
2894pub fn cmd_clone(cfg: &Config, url: &str, dir: &str) -> Result<(), OpenError> {
1248 let settings = user_settings_strict(cfg)?;
2895 let settings = user_settings_strict(cfg)?;
2896+ let url = &origin_url(url)?;
1249 let dir_path = PathBuf::from(dir);
2897 let dir_path = PathBuf::from(dir);
1250 if dir_path.join(".jj").is_dir() {
2898 if dir_path.join(".jj").is_dir() {
1251 return Err((2, format!("{} already contains a jj repository", dir)));
2899 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> {
1259 } else if dir_path.exists() {
2907 } else if dir_path.exists() {
1260 return Err((2, format!("{} already exists and is not empty", dir)));
2908 return Err((2, format!("{} already exists and is not empty", dir)));
1261 }
2909 }
1262− std::fs::create_dir_all(&dir_path)
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+ }
1263− .map_err(|e| (2, format!("cannot create {}: {}", dir, e)))?;
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> {
1264 let (workspace, repo) = block_on(Workspace::init_colocated_git(
3089 let (workspace, repo) = block_on(Workspace::init_colocated_git(
1265− &settings,
3090+ settings,
1266− &dir_path,
3091+ dir_path,
1267 gix::hash::Kind::Sha1,
3092 gix::hash::Kind::Sha1,
1268 ))
3093 ))
1269 .map_err(|e| (2, format!("cannot create the repository: {}", e)))?;
3094 .map_err(|e| (2, format!("cannot create the repository: {}", e)))?;
1270 let backend = JjBackend {
3095 let backend = JjBackend {
3096+ replay_cache: Rc::new(ReplayCache::default()),
1271 inner: Arc::new(JjInner {
3097 inner: Arc::new(JjInner {
1272− workspace_root: dir_path.clone(),
3098+ workspace_root: dir_path.to_path_buf(),
1273 workspace: Mutex::new(workspace),
3099 workspace: Mutex::new(workspace),
1274 repo: Mutex::new(repo),
3100 repo: Mutex::new(repo),
1275 workspace_name: WorkspaceName::DEFAULT.to_owned(),
3101 workspace_name: WorkspaceName::DEFAULT.to_owned(),
1276 visible: Mutex::new(None),
3102 visible: Mutex::new(None),
1277 lock_guard: Mutex::new(None),
3103 lock_guard: Mutex::new(None),
1278 pending: Mutex::new(None),
3104 pending: Mutex::new(None),
3105+ folds_case: OnceLock::new(),
3106+ long_names: Mutex::new(HashMap::new()),
1279 }),
3107 }),
1280 };
3108 };
1281 let origin = RemoteName::new("origin");
3109 let origin = RemoteName::new("origin");
1282 let mut tx = backend.current_repo().start_transaction();
3110 let mut tx = backend.current_repo().start_transaction();
3111+ // a signal to stop fails each step before the working-copy commit's
3112+ // (§1.3)
3113+ unless_interrupted(
1283− jj_lib::git::add_remote(tx.repo_mut(), origin, url, None)
3114+ jj_lib::git::add_remote(tx.repo_mut(), origin, url, None)
1284− .map_err(|e| (1, format!("cannot set the remote: {}", e)))?;
3115+ .map_err(|e| (1, format!("cannot set the remote: {}", e))),
3116+ )?;
1285− backend.git_fetch_refs(tx.repo_mut(), origin)?;
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+ ))?;
1286 let import_options = backend.git_import_options()?;
3122 let import_options = backend.git_import_options()?;
3123+ unless_interrupted(
1287− block_on(jj_lib::git::import_refs(tx.repo_mut(), &import_options))
3124+ block_on(jj_lib::git::import_refs(tx.repo_mut(), &import_options))
1288− .map_err(|e| (1, format!("cannot import the fetched refs: {}", e)))?;
3125+ .map_err(|e| (1, format!("cannot import the fetched refs: {}", e))),
3126+ )?;
3127+ unless_interrupted(
1289− block_on(tx.repo_mut().rebase_descendants())
3128+ block_on(tx.repo_mut().rebase_descendants())
1290− .map_err(|e| (1, format!("cannot rebase descendants: {}", e)))?;
3129+ .map_err(|e| (1, format!("cannot rebase descendants: {}", e))),
1291− let _ = block_on(backend.publish_tx(tx, &format!("clone {}", url)))?;
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)
1292− create_initial_wc_commit(&backend, cfg, &format!("clone {}", url))?;
3144+ create_initial_wc_commit(&backend, cfg, tx, &format!("clone {}", url), head, false, None)?;
1293 Ok(())
3145 Ok(())
1294}
3146}
1295
3147
1296−/// §7.8: after init/clone, create a working-copy commit as a child of the
3148+/// §7.8: after init/clone, create a working-copy commit as a child of
1297−/// current head (a bookmark target if there is one, otherwise the root
3149+/// `head` (the root commit when there is none), record it in `tx` with
1298−/// commit) and check it out.
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.
1299fn create_initial_wc_commit(
3159fn create_initial_wc_commit(
1300 backend: &JjBackend,
3160 backend: &JjBackend,
1301 cfg: &Config,
3161 cfg: &Config,
3162+ mut tx: jj_lib::transaction::Transaction,
1302 description: &str,
3163 description: &str,
3164+ head: Option<CommitId>,
3165+ adopt: bool,
3166+ back: Option<&str>,
1303) -> Result<(), OpenError> {
3167) -> Result<(), OpenError> {
1304− let base = backend.current_repo();
3168+ let store = tx.base_repo().store().clone();
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() {
3169+ let parent = head.unwrap_or_else(|| store.root_commit_id().clone());
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− }
1321 // jj's own init already created a working-copy commit; if it is already
3170 // jj's own init already created a working-copy commit; if it is already
1322 // a child of the target parent, keep it and just check it out
3171 // a child of the target parent, keep it and just check it out
1323 let mut to_abandon: Option<CommitId> = None;
3172 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()) {
1325 if existing.parent_ids().first() == Some(&parent) {
3175 if existing.parent_ids().first() == Some(&parent) {
1326− let op_id = base.operation().id().clone();
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+ };
1327− block_on(backend.checkout(&existing, op_id)).map_err(|c| (1, c.msg))?;
3186+ block_on(backend.checkout(&existing, None, op, back)).map_err(|c| (1, c.msg))?;
1328 return Ok(());
3187 return Ok(());
1329 }
3188 }
1330 if existing.id() != store.root_commit_id() {
3189 if existing.id() != store.root_commit_id() {
1331 to_abandon = Some(existing.id().clone());
3190 to_abandon = Some(existing.id().clone());
1332 }
3191 }
1333 }
3192 }
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))?;
1334 let user_sig = backend
3197 let user_sig = backend
1335 .user_signature(cfg)
3198 .user_signature(cfg)
1336 .map_err(|c| (3, format!("config.j: {}", c.msg)))?;
3199 .map_err(|c| (3, format!("config.j: {}", c.msg)))?;
1337− let mut tx = base.start_transaction();
1338 if let Some(old_wc) = &to_abandon {
3200 if let Some(old_wc) = &to_abandon {
1339 tx.repo_mut().record_abandoned_commit_with_parents(
3201 tx.repo_mut().record_abandoned_commit_with_parents(
1340 old_wc.clone(),
3202 old_wc.clone(),
@@ -1343,7 +3205,7 @@ fn create_initial_wc_commit(
1343 }
3205 }
1344 let wc = block_on(
3206 let wc = block_on(
1345 tx.repo_mut()
3207 tx.repo_mut()
1346− .new_commit(vec![parent], store.empty_merged_tree())
3208+ .new_commit(vec![parent], wc_tree)
1347 .set_author(user_sig.clone())
3209 .set_author(user_sig.clone())
1348 .set_committer(user_sig)
3210 .set_committer(user_sig)
1349 .write(),
3211 .write(),
@@ -1356,19 +3218,49 @@ fn create_initial_wc_commit(
1356 .map_err(|e| (1, format!("cannot rebase descendants: {}", e)))?;
3218 .map_err(|e| (1, format!("cannot rebase descendants: {}", e)))?;
1357 let unpublished = block_on(tx.write(truncate_chars(description, 200)))
3219 let unpublished = block_on(tx.write(truncate_chars(description, 200)))
1358 .map_err(|e| (1, format!("cannot write the operation: {}", e)))?;
3220 .map_err(|e| (1, format!("cannot write the operation: {}", e)))?;
1359− let op_id = unpublished.operation().id().clone();
3221+ // an adopted directory is reset to the commit, not checked out over:
1360− let repo = block_on(unpublished.publish())
3222+ // checking out from the empty tree jj's init recorded would recreate
1361− .map_err(|e| (1, format!("cannot publish the operation: {}", e)))?;
3223+ // the files the user deleted there. Anything else is written, once the
1362− *backend.inner.repo.lock().unwrap() = repo;
3224+ // operation is published (§7.5 step 7).
3225+ let on_disk = if adopt { Some(wc.tree()) } else { None };
1363− block_on(backend.checkout(&wc, op_id)).map_err(|c| (1, c.msg))?;
3226+ block_on(backend.checkout(&wc, on_disk.as_ref(), Some(unpublished), back)).map_err(|c| (1, c.msg))?;
1364 Ok(())
3227 Ok(())
1365}
3228}
1366
3229
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+
1367/// user settings for init/clone: a missing or malformed `user` is a
3260/// user settings for init/clone: a missing or malformed `user` is a
1368/// configuration error (exit 3) (§7.9)
3261/// configuration error (exit 3) (§7.9)
1369fn user_settings_strict(cfg: &Config) -> Result<UserSettings, OpenError> {
3262fn user_settings_strict(cfg: &Config) -> Result<UserSettings, OpenError> {
1370− let (name, email) = crate::config::eval_user_only(&mut Interp::dummy(), cfg)
3263+ let (name, email) = validated_identity(cfg)?;
1371− .map_err(|c| (3, format!("config.j: {}", c.msg)))?;
1372 let mut config = StackedConfig::with_defaults();
3264 let mut config = StackedConfig::with_defaults();
1373 let text = format!(
3265 let text = format!(
1374 "[user]\nname = {}\nemail = {}\n",
3266 "[user]\nname = {}\nemail = {}\n",
@@ -1382,6 +3274,39 @@ fn user_settings_strict(cfg: &Config) -> Result<UserSettings, OpenError> {
1382 .map_err(|e| (3, format!("config.j: invalid user settings: {}", e)))
3274 .map_err(|e| (3, format!("config.j: invalid user settings: {}", e)))
1383}
3275}
1384
3276
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+
1385// ----------------------------------------------------------------------
3310// ----------------------------------------------------------------------
1386// reserved-command helpers
3311// reserved-command helpers
1387// ----------------------------------------------------------------------
3312// ----------------------------------------------------------------------
@@ -1445,41 +3370,72 @@ fn git_fetch_subprocess(git_dir: &std::path::Path, remote: &RemoteName) -> Resul
1445 if out.status.success() {
3370 if out.status.success() {
1446 Ok(())
3371 Ok(())
1447 } else {
3372 } else {
1448− Err((
1449− 1,
1450− format!(
1451− "fetch failed: {}",
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;
1452− String::from_utf8_lossy(&out.stderr).trim()
3381+ let said = String::from_utf8_lossy(&out.stderr).trim().to_string();
1453− ),
3382+ match (said.is_empty(), out.status.signal()) {
1454− ))
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))));
1455 }
3406 }
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+ }))
1456}
3412}
1457
3413
1458enum OpKind {
3414enum OpKind {
1459 Normal,
3415 Normal,
1460− /// the operation this undo discarded the view of
3416+ Undo,
1461− Undo { #[allow(dead_code)] undid: OperationId },
3417+ Redo,
1462− /// the undo this redo reversed
1463− Redo { redid: OperationId },
1464}
3418}
1465
3419
1466/// a marker parsed from an operation description (§7.7 kinds)
3420/// a marker parsed from an operation description (§7.7 kinds)
1467struct OpMarker {
3421struct OpMarker {
1468 kind: &'static str,
3422 kind: &'static str,
1469 id: OperationId,
3423 id: OperationId,
1470− /// whether the description is j's own exact marker (`undo: <id>` /
3424+ /// jj 0.45 names the operation whose view was restored, rather than
1471− /// `redo: <id>`); only those are reliably loadable for the walk
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.
1472 authored_by_j: bool,
3429 authored_by_j: bool,
1473}
3430}
1474
3431
1475−/// §7.7 kinds: `j` marks undo/redo operations as `undo: <opid>` /
3432+/// §7.7 kinds: new records use jj 0.45's restored-view descriptions. Older
1476−/// `redo: <opid>` in the description (0.45's Transaction cannot write
3433+/// j `undo: <id>` / `redo: <id>` and jj `undo operation <id>` /
1477−/// metadata tags); jj's own `undo operation <id>` / `redo operation <id>`
3434+/// `redo operation <id>` descriptions remain readable too.
1478−/// markers are treated the same way.
1479fn op_kind(meta: &jj_lib::op_store::OperationMetadata) -> OpKind {
3435fn op_kind(meta: &jj_lib::op_store::OperationMetadata) -> OpKind {
1480 match op_marker(meta) {
3436 match op_marker(meta) {
1481− Some(OpMarker { kind: "undo", id, .. }) => OpKind::Undo { undid: id },
3437+ Some(OpMarker { kind: "undo", .. }) => OpKind::Undo,
1482− Some(OpMarker { kind: "redo", id, .. }) => OpKind::Redo { redid: id },
3438+ Some(OpMarker { kind: "redo", .. }) => OpKind::Redo,
1483 _ => OpKind::Normal,
3439 _ => OpKind::Normal,
1484 }
3440 }
1485}
3441}
@@ -1487,20 +3443,33 @@ fn op_kind(meta: &jj_lib::op_store::OperationMetadata) -> OpKind {
1487fn op_marker(meta: &jj_lib::op_store::OperationMetadata) -> Option<OpMarker> {
3443fn op_marker(meta: &jj_lib::op_store::OperationMetadata) -> Option<OpMarker> {
1488 let desc = meta.description.trim();
3444 let desc = meta.description.trim();
1489 for kind in ["undo", "redo"] {
3445 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+ }
1490 if let Some(rest) = desc.strip_prefix(&format!("{}:", kind)) {
3451 if let Some(rest) = desc.strip_prefix(&format!("{}:", kind)) {
1491 if let Some(id) = parse_op_id(rest.trim()) {
3452 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 });
1493 }
3454 }
1494 }
3455 }
1495 if let Some(rest) = desc.strip_prefix(&format!("{} operation", kind)) {
3456 if let Some(rest) = desc.strip_prefix(&format!("{} operation", kind)) {
1496 if let Some(id) = parse_op_id(rest.trim()) {
3457 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 });
1498 }
3459 }
1499 }
3460 }
1500 }
3461 }
1501 None
3462 None
1502}
3463}
1503
3464
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+
1504/// a full-length (or prefix) hex operation id
3473/// a full-length (or prefix) hex operation id
1505fn parse_op_id(s: &str) -> Option<OperationId> {
3474fn parse_op_id(s: &str) -> Option<OperationId> {
1506 if s.is_empty() || !s.chars().all(|c| c.is_ascii_hexdigit()) {
3475 if s.is_empty() || !s.chars().all(|c| c.is_ascii_hexdigit()) {
@@ -1526,21 +3495,39 @@ fn op_parent_id(
1526 Ok(op.parent_ids().first().cloned())
3495 Ok(op.parent_ids().first().cloned())
1527}
3496}
1528
3497
1529−/// follow a chain of redos to the operation that was ultimately reapplied
3498+/// A restore operation represents an earlier view. Follow that view to a
1530−async fn chain_through_redos(
3499+/// normal operation for useful confirmation text without changing undo's
3500+/// navigation or its jj-compatible metadata.
3501+fn restored_operation_description(
1531 loader: &jj_lib::repo::RepoLoader,
3502 loader: &jj_lib::repo::RepoLoader,
1532− first: &OperationId,
3503+ op: &jj_lib::operation::Operation,
1533−) -> Result<OperationId, OpenError> {
3504+) -> String {
1534− let mut id = first.clone();
3505+ let mut represented = op.clone();
1535− loop {
3506+ let mut seen = BTreeSet::new();
1536− let op = loader
3507+ while seen.insert(represented.id().clone()) {
1537− .load_operation(&id)
3508+ let Some(marker) = op_marker(represented.metadata()) else { break };
1538− .await
3509+ let target = if marker.restores_view {
1539− .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
3510+ Some(marker.id)
1540− match op_kind(op.metadata()) {
3511+ } else if marker.authored_by_j {
1541− OpKind::Redo { redid } => id = redid,
3512+ // A legacy undo names what it discarded; a legacy redo names
1542− _ => return Ok(id),
3513+ // the undo it reversed. Either restored its target's parent.
1543− }
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)
1544 }
3531 }
1545}
3532}
1546
3533
@@ -1610,6 +3597,11 @@ fn parse_push_records(
1610 let rec = vis.commits.get(&change_id).ok_or_else(|| {
3597 let rec = vis.commits.get(&change_id).ok_or_else(|| {
1611 (1, format!("push: `@{}` is not a visible commit", change_id))
3598 (1, format!("push: `@{}` is not a visible commit", change_id))
1612 })?;
3599 })?;
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+ }
1613 out.push((name, Some(rec.commit.id().clone())));
3605 out.push((name, Some(rec.commit.id().clone())));
1614 }
3606 }
1615 None => {
3607 None => {
@@ -1629,68 +3621,190 @@ fn parse_push_records(
1629 Ok(out)
3621 Ok(out)
1630}
3622}
1631
3623
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
1632−/// §7.6: refuse to send commits with unresolved files or empty descriptions,
3627+/// to send commits with unresolved files or empty descriptions. Returns
1633−/// and refuse to move/delete a bookmark whose current target is immutable
3628+/// whether such a delete is allowed only because a record's new target
1634−/// away from a descendant of that 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
1635fn check_push_records(
3632fn check_push_records(
1636 records: &[(String, Option<CommitId>)],
3633 records: &[(String, Option<CommitId>)],
1637− vis: &VisibleRepo,
1638 repo: &Arc<ReadonlyRepo>,
3634 repo: &Arc<ReadonlyRepo>,
3635+ vis: &VisibleRepo,
3636+ immutable: &BTreeSet<String>,
1639−) -> Result<(), OpenError> {
3637+) -> Result<bool, OpenError> {
1640 let store = repo.store().clone();
3638 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();
1641 for (name, target) in records {
3648 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
1652 .view()
3650 .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))
1654 .target
3652 .target
1655 .as_resolved()
3653 .as_resolved()
1656 .cloned()
3654 .cloned()
1657 .unwrap_or(None)
3655 .unwrap_or(None)
1658− {
3656+ else {
3657+ continue;
3658+ };
1659− let commit = block_on(store.get_commit_async(&current))
3659+ let change_id = block_on(store.get_commit_async(&current))
1660− .map_err(|e| (2, format!("cannot read a commit: {}", e)))?;
3660+ .map_err(|e| (2, format!("cannot read a commit: {}", e)))?
3661+ .change_id()
1661− let change_id = commit.change_id().reverse_hex();
3662+ .reverse_hex();
1662− if vis.commits.contains_key(&change_id) {
3663+ // the immutable set holds visible commits: a target rewritten here
1663− seeds.push(change_id);
3664+ // since the last fetch or push is hidden, and not in it although the
1664− }
3665+ // visible rewrite carries its change id (and its label)
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");
3666+ let visible = vis.commits.get(&change_id).is_some_and(|c| c.commit.id() == &current);
1669− for (_path, value) in rec.commit.tree().entries() {
3667+ if !visible || !immutable.contains(&change_id) {
1670− let value = value.map_err(|e| (2, format!("cannot read a tree: {}", e)))?;
3668+ continue;
1671− if !value.is_resolved() {
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);
1672 return Err((
3752 return Err((
1673 1,
3753 1,
1674 format!(
3754 format!(
1675− "push: commit `@{}` has unresolved files",
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})`"
1676− rec.change_id
1677 ),
3756 ),
1678 ));
3757 ));
1679 }
3758 }
1680 }
3759 }
1681− if rec.commit.description().trim().is_empty() && rec.commit.id() != store.root_commit_id() {
3760+ }
1682− return Err((
3761+ }
1683− 1,
3762+ // the commits that would be sent: ancestors of the new targets that no
1684− format!("push: commit `@{}` has an empty description", rec.change_id),
3763+ // bookmark on the remote already reaches; a delete sends none
1685− ));
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));
1686 }
3794 }
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)));
1687 }
3801 }
1688 }
3802 }
1689− Ok(())
3803+ Ok(deletes_wait)
1690}
3804}
1691
3805
1692/// change-id closure of jj-parents from the seeds (all jj parents, not just
3806/// 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)
1694fn ancestors_of_change_ids(vis: &VisibleRepo, seeds: &[String]) -> BTreeSet<String> {
3808fn ancestors_of_change_ids(vis: &VisibleRepo, seeds: &[String]) -> BTreeSet<String> {
1695 let mut seen = BTreeSet::new();
3809 let mut seen = BTreeSet::new();
1696 let mut stack: Vec<String> = seeds.to_vec();
3810 let mut stack: Vec<String> = seeds.to_vec();
@@ -1711,23 +3825,11 @@ fn ancestors_of_change_ids(vis: &VisibleRepo, seeds: &[String]) -> BTreeSet<Stri
1711
3825
1712/// git-check-ref-format --branch rules
3826/// git-check-ref-format --branch rules
1713fn is_valid_git_branch_name(name: &str) -> bool {
3827fn is_valid_git_branch_name(name: &str) -> bool {
1714− if name.is_empty()
1715− || name.starts_with('-')
3828+ if 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− {
1725 return false;
3829 return false;
1726 }
3830 }
1727− name.chars().all(|c| {
3831+ let full_name = format!("refs/heads/{name}");
1728− !(c.is_ascii_control()
3832+ gix::validate::reference::branch_name(full_name.as_str().into()).is_ok()
1729− || matches!(c, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\'))
1730− })
1731}
3833}
1732
3834
1733// ----------------------------------------------------------------------
3835// ----------------------------------------------------------------------
@@ -1735,6 +3837,12 @@ fn is_valid_git_branch_name(name: &str) -> bool {
1735// ----------------------------------------------------------------------
3837// ----------------------------------------------------------------------
1736
3838
1737impl Backend for JjBackend {
3839impl 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+
1738 fn visible_ids(&self) -> Vec<String> {
3846 fn visible_ids(&self) -> Vec<String> {
1739 match self.inner.visible.lock().unwrap().as_ref() {
3847 match self.inner.visible.lock().unwrap().as_ref() {
1740 Some(v) => v.commits.keys().cloned().collect(),
3848 Some(v) => v.commits.keys().cloned().collect(),
@@ -1769,38 +3877,93 @@ impl Backend for JjBackend {
1769 vis.and_then(|v| v.commits.get(id).map(|r| r.commit.has_conflict()))
3877 vis.and_then(|v| v.commits.get(id).map(|r| r.commit.has_conflict()))
1770 }
3878 }
1771
3879
1772− fn is_empty(&self, id: &str) -> Option<bool> {
3880+ fn is_empty(&self, id: &str, parent: &str) -> Option<bool> {
1773 let vis = self.inner.visible.lock().unwrap().clone();
3881 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
1775 vis.and_then(|v| {
3883 vis.and_then(|v| {
1776 let rec = v.commits.get(id)?;
3884 let rec = v.commits.get(id)?;
1777− let parent = v.commits.get(&rec.first_parent)?;
3885+ let parent = v.commits.get(parent)?;
1778 Some(rec.commit.tree_ids() == parent.commit.tree_ids())
3886 Some(rec.commit.tree_ids() == parent.commit.tree_ids())
1779 })
3887 })
1780 }
3888 }
1781
3889
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+
1782 fn ancestors_closed(&self, ids: &BTreeSet<String>) -> BTreeSet<String> {
3944 fn ancestors_closed(&self, ids: &BTreeSet<String>) -> BTreeSet<String> {
1783 let vis = self.inner.visible.lock().unwrap().clone();
3945 let vis = self.inner.visible.lock().unwrap().clone();
1784 let Some(vis) = vis else {
3946 let Some(vis) = vis else {
1785 return BTreeSet::new();
3947 return BTreeSet::new();
1786 };
3948 };
1787− let mut seen = BTreeSet::new();
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)
1788− let mut stack: Vec<String> = ids.iter().cloned().collect();
3952+ let seeds: Vec<String> = ids.iter().cloned().collect();
1789− while let Some(id) = stack.pop() {
3953+ ancestors_of_change_ids(&vis, &seeds)
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
1799 }
3954 }
1800
3955
1801 fn replay(&self, onto: &[Value], from: &[Value], to: &[Value]) -> Result<Vec<Value>, Crash> {
3956 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> {
1802 let store = self.current_repo().store().clone();
3965 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))
1804 }
3967 }
1805}
3968}
1806
3969
@@ -1808,30 +3971,197 @@ impl Backend for JjBackend {
1808// jj three-way tree merge for `replay` (§7.3)
3971// jj three-way tree merge for `replay` (§7.3)
1809// ----------------------------------------------------------------------
3972// ----------------------------------------------------------------------
1810
3973
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+
1811async fn jj_replay(
4077async fn jj_replay(
1812 store: &Arc<Store>,
4078 store: &Arc<Store>,
4079+ cache: &ReplayCache,
1813− onto: &[Value],
4080+ onto: &Value,
1814− from: &[Value],
4081+ from: &Value,
1815− to: &[Value],
4082+ to: &Value,
1816−) -> Result<Vec<Value>, Crash> {
4083+) -> Result<Value, Crash> {
1817− let onto_tree = build_tree(store, &Value::list(onto.to_vec())).await?;
4084+ let (onto_tree, onto_canonical) = cache.tree_of(store, onto).await?;
1818− let from_tree = build_tree(store, &Value::list(from.to_vec())).await?;
4085+ let (from_tree, from_canonical) = cache.tree_of(store, from).await?;
1819− let to_tree = build_tree(store, &Value::list(to.to_vec())).await?;
4086+ let (to_tree, to_canonical) = cache.tree_of(store, to).await?;
1820− let merge = Merge::from_removes_adds(
4087+ // any of the three may itself be conflicted (a snapshot with unresolved
1821− vec![from_tree.tree_ids().as_resolved().cloned().unwrap()],
4088+ // blobs, §7.3), so merge the trees the way jj's rebase does:
1822− vec![
4089+ // MergedTree::merge flattens conflicted inputs into one merge, simplifies
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
4090+ // it, and resolves file contents line-level with tree_merge::merge_trees,
1828− // unresolvable paths as conflicts (tree_merge.rs:78)
4091+ // keeping unresolvable paths as conflicts. Empty labels leave the result
1829− let merged = jj_lib::tree_merge::merge_trees(store, merge)
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+ ))
1830− .await
4097+ .await
1831− .map_err(|e| Crash::new(format!("replay: {}", e)))?;
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+ }
1832 // conflicts stay as unresolved blobs (§7.3)
4112 // conflicts stay as unresolved blobs (§7.3)
1833− let merged = MergedTree::new(store.clone(), merged, jj_lib::conflict_labels::ConflictLabels::unlabeled());
4113+ cache.files_of(store, &merged).await
1834− tree_to_files(store, &merged, &BlobCache::default(), &EntryCache::default()).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(())
1835}
4165}
1836
4166
1837// ----------------------------------------------------------------------
4167// ----------------------------------------------------------------------
@@ -1852,6 +4182,107 @@ impl ConflictTreeValue {
1852 }
4182 }
1853}
4183}
1854
4184
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+
1855async fn build_tree(store: &Arc<Store>, files: &Value) -> Result<MergedTree, Crash> {
4286async fn build_tree(store: &Arc<Store>, files: &Value) -> Result<MergedTree, Crash> {
1856 let entries = files
4287 let entries = files
1857 .as_list()
4288 .as_list()
@@ -1911,19 +4342,41 @@ async fn blob_to_tree_value(store: &Arc<Store>, content: &Value) -> Result<Merge
1911 return Err(Crash::new("persistence: malformed conflict blob"));
4342 return Err(Crash::new("persistence: malformed conflict blob"));
1912 }
4343 }
1913 // jj order: [add, (remove, add)*] → Merge with interleaved adds
4344 // 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
1915 let mut values: Vec<Option<TreeValue>> = Vec::new();
4348 let mut values: Vec<Option<TreeValue>> = Vec::new();
1916 for side in sides {
4349 for side in sides {
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 {
1917− let mut slice: &[u8] = side.as_ref();
4368+ let mut slice: &[u8] = side.bytes.as_ref();
1918− let id = store
4369+ let id = store
1919− .write_file(&RepoPathBuf::root(), &mut slice)
4370+ .write_file(&RepoPathBuf::root(), &mut slice)
1920− .await
4371+ .await
1921− .map_err(|e| Crash::new(format!("cannot write a file: {}", e)))?;
4372+ .map_err(|e| Crash::new(format!("cannot write a file: {}", e)))?;
1922− values.push(Some(TreeValue::File {
4373+ TreeValue::File {
1923− id,
4374+ id,
1924− executable: b.kind == BlobKind::Executable,
4375+ executable: side.kind == BlobKind::Executable,
1925− copy_id: CopyId::placeholder(),
4376+ copy_id: CopyId::placeholder(),
1926− }));
4377+ }
4378+ };
4379+ values.push(Some(value));
1927 }
4380 }
1928 let mut adds = Vec::new();
4381 let mut adds = Vec::new();
1929 let mut removes = Vec::new();
4382 let mut removes = Vec::new();
@@ -1953,34 +4406,49 @@ fn to_repo_path(path: &[String]) -> Result<RepoPathBuf, Crash> {
1953// jj trees -> values
4406// jj trees -> values
1954// ----------------------------------------------------------------------
4407// ----------------------------------------------------------------------
1955
4408
1956−/// blob contents keyed by file id, shared across the whole repo load: most
4409+/// Blob contents keyed by file id, shared while snapshots hold their bytes.
1957−/// files are unchanged between commits, so inflating each blob once (instead
4410+/// Weak entries let discarded replay results release their conflict sides.
1958−/// of once per commit that references it) keeps loading linear in the
1959−/// repository's unique data rather than commits × files
1960#[derive(Clone, Default)]
4411#[derive(Clone, Default)]
4412+struct BlobCache {
1961−struct BlobCache(Rc<RefCell<HashMap<FileId, Rc<Vec<u8>>>>>);
4413+ bytes: Rc<RefCell<HashMap<FileId, Weak<Vec<u8>>>>>,
4414+ next_sweep: Rc<Cell<usize>>,
4415+}
1962
4416
1963impl BlobCache {
4417impl BlobCache {
1964 fn get(&self, id: &FileId) -> Option<Rc<Vec<u8>>> {
4418 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)
1966 }
4420 }
1967 fn insert(&self, id: FileId, bytes: Rc<Vec<u8>>) {
4421 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+ }
1969 }
4428 }
1970}
4429}
1971
4430
1972−/// whole file *entries* ({path, content}) keyed by (path, blob identity),
4431+/// Whole file entries keyed by (path, blob identity), shared by loaded
1973−/// shared across the whole repo load: an unchanged file is the same Value in
4432+/// snapshots and replays. Weak references let transient replay results go
1974−/// every commit, so it is built once and cloned (an Rc bump) thereafter
4433+/// away while unchanged entries still held by a snapshot are reused.
1975#[derive(Clone, Default)]
4434#[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+}
1977
4439
1978impl EntryCache {
4440impl EntryCache {
1979 fn get(&self, key: &str) -> Option<Value> {
4441 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)
1981 }
4443 }
1982 fn insert(&self, key: String, entry: Value) {
4444 fn insert(&self, key: String, entry: Value) {
4445+ let Value::Record(record) = entry else { return };
1983− self.0.borrow_mut().insert(key, entry);
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+ }
1984 }
4452 }
1985}
4453}
1986
4454
@@ -2008,20 +4476,64 @@ async fn read_file_bytes(
2008 Ok(buf)
4476 Ok(buf)
2009}
4477}
2010
4478
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(
2012 store: &Arc<Store>,
4482 store: &Arc<Store>,
2013 path: &RepoPath,
4483 path: &RepoPath,
2014 v: &Option<TreeValue>,
4484 v: &Option<TreeValue>,
2015 cache: &BlobCache,
4485 cache: &BlobCache,
2016−) -> Result<Rc<Vec<u8>>, Crash> {
4486+) -> Result<Option<ConflictSide>, Crash> {
2017 match v {
4487 match v {
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+ },
2018− Some(TreeValue::File { id, .. }) => read_file_bytes(store, path, id, cache).await,
4495+ bytes: read_file_bytes(store, path, id, cache).await?,
4496+ tree: None,
4497+ })),
2019− Some(TreeValue::Symlink(id)) => store
4498+ Some(TreeValue::Symlink(id)) => {
4499+ let target = store
2020− .read_symlink(path, id)
4500+ .read_symlink(path, id)
2021− .await
4501+ .await
2022− .map(|s| Rc::new(s.into_bytes()))
2023− .map_err(|e| Crash::new(format!("cannot read a symlink: {}", e))),
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,
2024− _ => Ok(Rc::new(Vec::new())),
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),
2025 }
4537 }
2026}
4538}
2027
4539
@@ -2031,6 +4543,9 @@ async fn merged_value_to_blob(
2031 value: &jj_lib::backend::MergedTreeValue,
4543 value: &jj_lib::backend::MergedTreeValue,
2032 cache: &BlobCache,
4544 cache: &BlobCache,
2033) -> Result<Value, Crash> {
4545) -> 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();
2034 if let Some(v) = value.as_resolved() {
4549 if let Some(v) = value.as_resolved() {
2035 return match v {
4550 return match v {
2036 Some(tv) => tree_value_to_blob(store, path, tv).await,
4551 Some(tv) => tree_value_to_blob(store, path, tv).await,
@@ -2041,11 +4556,11 @@ async fn merged_value_to_blob(
2041 };
4556 };
2042 }
4557 }
2043 // conflict sides, jj order: [add, (remove, add)*] (§7.3)
4558 // 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();
2045 for (i, add) in value.adds().enumerate() {
4560 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?);
2047 if let Some(remove) = value.get_remove(i) {
4562 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?);
2049 }
4564 }
2050 }
4565 }
2051 Ok(Value::Blob(Rc::new(BlobVal {
4566 Ok(Value::Blob(Rc::new(BlobVal {
@@ -2097,8 +4612,9 @@ async fn tree_value_to_blob(
2097 content: BlobContent::Resolved(Rc::new(target.into_bytes())),
4612 content: BlobContent::Resolved(Rc::new(target.into_bytes())),
2098 })))
4613 })))
2099 }
4614 }
2100− // a conflicted directory is emitted without its children by
4615+ // MergedTree::entries recurses into a resolved directory, and a
4616+ // directory side of a conflict goes through conflict_side, so this is
2101− // MergedTree::entries; §12 puts such repairs out of scope
4617+ // a submodule, which §12 puts out of scope
2102 TreeValue::Tree(_) | TreeValue::GitSubmodule(_) => Ok(Value::Blob(Rc::new(BlobVal {
4618 TreeValue::Tree(_) | TreeValue::GitSubmodule(_) => Ok(Value::Blob(Rc::new(BlobVal {
2103 kind: BlobKind::Regular,
4619 kind: BlobKind::Regular,
2104 content: BlobContent::Resolved(Rc::new(Vec::new())),
4620 content: BlobContent::Resolved(Rc::new(Vec::new())),
@@ -2112,9 +4628,27 @@ async fn tree_to_files(
2112 cache: &BlobCache,
4628 cache: &BlobCache,
2113 entries: &EntryCache,
4629 entries: &EntryCache,
2114) -> Result<Vec<Value>, Crash> {
4630) -> 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> {
2115 let mut out = Vec::new();
4643 let mut out = Vec::new();
4644+ let mut reusable = tree.tree_ids().is_resolved();
2116 for (path, value) in tree.entries() {
4645 for (path, value) in tree.entries() {
2117 let value = value.map_err(|e| Crash::new(format!("cannot read a tree entry: {}", e)))?;
4646 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+ };
2118 // cache key: the entry is fully determined by its path and the
4652 // cache key: the entry is fully determined by its path and the
2119 // identity of its (resolved) content; conflicts are not cached
4653 // identity of its (resolved) content; conflicts are not cached
2120 let key = match value.as_resolved() {
4654 let key = match value.as_resolved() {
@@ -2145,13 +4679,22 @@ async fn tree_to_files(
2145 };
4679 };
2146 out.push(entry);
4680 out.push(entry);
2147 }
4681 }
2148− Ok(out)
4682+ Ok((out, reusable))
2149}
4683}
2150
4684
2151// ----------------------------------------------------------------------
4685// ----------------------------------------------------------------------
2152// value helpers
4686// value helpers
2153// ----------------------------------------------------------------------
4687// ----------------------------------------------------------------------
2154
4688
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+
2155fn commit_id_of(commit: &Value) -> Result<String, Crash> {
4698fn commit_id_of(commit: &Value) -> Result<String, Crash> {
2156 match commit.field("id")? {
4699 match commit.field("id")? {
2157 Value::Id(i) => Ok(i.to_string()),
4700 Value::Id(i) => Ok(i.to_string()),
@@ -2179,15 +4722,13 @@ fn new_id_set(repo: &Value) -> Result<BTreeSet<String>, Crash> {
2179// building the Repo value from the visible map (§7.2)
4722// building the Repo value from the visible map (§7.2)
2180// ----------------------------------------------------------------------
4723// ----------------------------------------------------------------------
2181
4724
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> {
2183 let root_rec = vis
4726 let root_rec = vis
2184 .commits
4727 .commits
2185 .get(ROOT_ID)
4728 .get(ROOT_ID)
2186 .ok_or_else(|| (2, "no root commit".to_string()))?;
4729 .ok_or_else(|| (2, "no root commit".to_string()))?;
2187 let store = root_rec.commit.store().clone();
4730 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)?;
2191 sort_subtree(&mut subtree, vis);
4732 sort_subtree(&mut subtree, vis);
2192 let top = Value::record(&[
4733 let top = Value::record(&[
2193 ("children", subtree.field("children").map_err(|e| (2, e.msg))?),
4734 ("children", subtree.field("children").map_err(|e| (2, e.msg))?),
@@ -2204,19 +4745,20 @@ fn build_subtree(
2204 vis: &VisibleRepo,
4745 vis: &VisibleRepo,
2205 rec: &StoredCommit,
4746 rec: &StoredCommit,
2206 store: &Arc<Store>,
4747 store: &Arc<Store>,
2207− cache: &BlobCache,
4748+ replay_cache: &Rc<ReplayCache>,
2208− entries: &EntryCache,
2209) -> Result<Value, OpenError> {
4749) -> Result<Value, OpenError> {
2210 // the files list is lazy (§7.2): most commits are never inspected, so
4750 // 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)
2212 let files_thunk = {
4755 let files_thunk = {
2213 let store = store.clone();
4756 let store = store.clone();
2214 let tree = rec.commit.tree();
4757 let tree = rec.commit.tree();
2215− let cache = cache.clone();
4758+ let name = Some(tree_name(&tree));
2216− let entries = entries.clone();
4759+ let replay_cache = replay_cache.clone();
2217− Value::Thunk(Rc::new(crate::value::ThunkVal::new(move || {
4760+ Value::Thunk(Rc::new(crate::value::ThunkVal::stored(rec.change_id.clone(), name, move || {
2218− let files = block_on(tree_to_files(&store, &tree, &cache, &entries))?;
4761+ block_on(replay_cache.files_of(&store, &tree))
2219− Ok(Value::list(files))
2220 })))
4762 })))
2221 };
4763 };
2222 let labels: Vec<Value> = vis
4764 let labels: Vec<Value> = vis
@@ -2237,7 +4779,7 @@ fn build_subtree(
2237 if let Some(kids) = vis.children.get(&rec.change_id) {
4779 if let Some(kids) = vis.children.get(&rec.change_id) {
2238 for kid in kids {
4780 for kid in kids {
2239 if let Some(krec) = vis.commits.get(kid) {
4781 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)?);
2241 }
4783 }
2242 }
4784 }
2243 }
4785 }
@@ -2272,3 +4814,165 @@ fn sort_subtree(subtree: &mut Value, vis: &VisibleRepo) {
2272 let root = subtree.field("root").expect("subtree has a root");
4814 let root = subtree.field("root").expect("subtree has a root");
2273 *subtree = Value::record(&[("children", Value::list(kids)), ("root", root)]);
4815 *subtree = Value::record(&[("children", Value::list(kids)), ("root", root)]);
2274}
4816}
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 {
45 pub tok: Tok,
45 pub tok: Tok,
46 pub line: usize, // 1-based
46 pub line: usize, // 1-based
47 pub col: usize, // 1-based
47 pub col: usize, // 1-based
48+ /// byte offsets of the token in the source: `start..end`
49+ pub start: usize,
50+ pub end: usize,
48}
51}
49
52
50#[derive(Debug)]
53#[derive(Debug)]
@@ -84,6 +87,13 @@ fn valid_label(s: &str) -> bool {
84pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
87pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
85 let mut toks: Vec<SpTok> = Vec::new();
88 let mut toks: Vec<SpTok> = Vec::new();
86 let chars: Vec<char> = src.chars().collect();
89 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();
87 let mut i = 0usize;
97 let mut i = 0usize;
88 let mut line = 1usize;
98 let mut line = 1usize;
89 let mut col = 1usize; // column of next char
99 let mut col = 1usize; // column of next char
@@ -97,13 +107,15 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
97 };
107 };
98 }
108 }
99
109
100− // push helper
110+ // push helper; `$s..$e` are the token's char indices
101 macro_rules! push {
111 macro_rules! push {
102− ($t:expr, $l:expr, $c:expr) => {
112+ ($t:expr, $l:expr, $c:expr, $s:expr, $e:expr) => {
103 toks.push(SpTok {
113 toks.push(SpTok {
104 tok: $t,
114 tok: $t,
105 line: $l,
115 line: $l,
106 col: $c,
116 col: $c,
117+ start: bytes[$s],
118+ end: bytes[$e],
107 })
119 })
108 };
120 };
109 }
121 }
@@ -111,7 +123,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
111 while i < chars.len() {
123 while i < chars.len() {
112 let c = chars[i];
124 let c = chars[i];
113 if c == '\n' {
125 if c == '\n' {
114− push!(Tok::Newline, line, col);
126+ push!(Tok::Newline, line, col, i, i + 1);
115 i += 1;
127 i += 1;
116 line += 1;
128 line += 1;
117 col = 1;
129 col = 1;
@@ -124,6 +136,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
124 }
136 }
125 let start_line = line;
137 let start_line = line;
126 let start_col = col;
138 let start_col = col;
139+ let start = i;
127 // comments
140 // comments
128 if c == '-' && i + 1 < chars.len() && chars[i + 1] == '-' {
141 if c == '-' && i + 1 < chars.len() && chars[i + 1] == '-' {
129 // could be `--` comment, but `->`? no: `->` is dash-gt, distinct.
142 // could be `--` comment, but `->`? no: `->` is dash-gt, distinct.
@@ -147,7 +160,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
147 i += 2;
160 i += 2;
148 col += 2;
161 col += 2;
149 } else if chars[i] == '\n' {
162 } else if chars[i] == '\n' {
150− push!(Tok::Newline, line, col);
163+ push!(Tok::Newline, line, col, i, i + 1);
151 i += 1;
164 i += 1;
152 line += 1;
165 line += 1;
153 col = 1;
166 col = 1;
@@ -159,14 +172,14 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
159 if depth > 0 {
172 if depth > 0 {
160 err!("unterminated block comment");
173 err!("unterminated block comment");
161 }
174 }
162− // a comment spanning lines must not leave later tokens looking
175+ // a comment is whitespace (§3.1): one spanning lines has already
163− // like continuations of the line it started on
176+ // ended the line it started on, with the Newlines pushed above,
164− push!(Tok::Newline, line, col);
177+ // and one that does not must leave its line unbroken
165 continue;
178 continue;
166 }
179 }
167 // arrow `->` (check before operator `-`)
180 // arrow `->` (check before operator `-`)
168 if c == '-' && i + 1 < chars.len() && chars[i + 1] == '>' {
181 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);
170 i += 2;
183 i += 2;
171 col += 2;
184 col += 2;
172 continue;
185 continue;
@@ -202,7 +215,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
202 if !comp.is_empty() {
215 if !comp.is_empty() {
203 comps.push(comp);
216 comps.push(comp);
204 }
217 }
205− push!(Tok::PathLit(comps), start_line, start_col);
218+ push!(Tok::PathLit(comps), start_line, start_col, start, i);
206 continue;
219 continue;
207 }
220 }
208 // selector: `.` immediately followed by identifier-start
221 // selector: `.` immediately followed by identifier-start
@@ -215,13 +228,13 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
215 i += 1;
228 i += 1;
216 col += 1;
229 col += 1;
217 }
230 }
218− push!(Tok::Selector(name), start_line, start_col);
231+ push!(Tok::Selector(name), start_line, start_col, start, i);
219 continue;
232 continue;
220 }
233 }
221 // `@` id literals
234 // `@` id literals
222 if c == '@' {
235 if c == '@' {
223 if i + 1 >= chars.len() || !is_ident_char(chars[i + 1]) {
236 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);
225 i += 1;
238 i += 1;
226 col += 1;
239 col += 1;
227 continue;
240 continue;
@@ -239,7 +252,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
239 if i < chars.len() && is_ident_char(chars[i]) {
252 if i < chars.len() && is_ident_char(chars[i]) {
240 err!("invalid id literal");
253 err!("invalid id literal");
241 }
254 }
242− push!(Tok::IdLit(s), start_line, start_col);
255+ push!(Tok::IdLit(s), start_line, start_col, start, i);
243 continue;
256 continue;
244 }
257 }
245 err!("`@` followed by an identifier character outside k-z is not a valid id literal");
258 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> {
262 if !valid_label(&s) {
275 if !valid_label(&s) {
263 err!("invalid label literal");
276 err!("invalid label literal");
264 }
277 }
265− push!(Tok::LabelLit(s), start_line, start_col);
278+ push!(Tok::LabelLit(s), start_line, start_col, start, i);
266 continue;
279 continue;
267 }
280 }
268 // text literal, double- or single-quoted
281 // text literal, double- or single-quoted
@@ -308,7 +321,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
308 }
321 }
309 }
322 }
310 }
323 }
311− push!(Tok::Text(s), start_line, start_col);
324+ push!(Tok::Text(s), start_line, start_col, start, i);
312 continue;
325 continue;
313 }
326 }
314 // integer
327 // integer
@@ -319,7 +332,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
319 i += 1;
332 i += 1;
320 col += 1;
333 col += 1;
321 }
334 }
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);
323 continue;
336 continue;
324 }
337 }
325 // identifier / keyword
338 // identifier / keyword
@@ -348,7 +361,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
348 }
361 }
349 _ => Tok::Ident(s),
362 _ => Tok::Ident(s),
350 };
363 };
351− push!(t, start_line, start_col);
364+ push!(t, start_line, start_col, start, i);
352 continue;
365 continue;
353 }
366 }
354 // type name
367 // type name
@@ -364,7 +377,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
364 break;
377 break;
365 }
378 }
366 }
379 }
367− push!(Tok::TypeName(s), start_line, start_col);
380+ push!(Tok::TypeName(s), start_line, start_col, start, i);
368 continue;
381 continue;
369 }
382 }
370 // symbolic operators: longest match
383 // symbolic operators: longest match
@@ -377,16 +390,17 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
377 }
390 }
378 }
391 }
379 if matched.is_none() {
392 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
381 for op in SYMBOLIC_OPS {
395 for op in SYMBOLIC_OPS {
382− if op.len() == 1 && *op == one {
396+ if op.len() == 1 && op.starts_with(c) {
383 matched = Some(op);
397 matched = Some(op);
384 break;
398 break;
385 }
399 }
386 }
400 }
387 }
401 }
388 if let Some(op) = matched {
402 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());
390 i += op.len();
404 i += op.len();
391 col += op.len();
405 col += op.len();
392 continue;
406 continue;
@@ -406,11 +420,11 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
406 '=' => Tok::Equals,
420 '=' => Tok::Equals,
407 _ => err!(format!("unexpected character `{}`", c)),
421 _ => err!(format!("unexpected character `{}`", c)),
408 };
422 };
409− push!(t, start_line, start_col);
423+ push!(t, start_line, start_col, i, i + 1);
410 i += 1;
424 i += 1;
411 col += 1;
425 col += 1;
412 }
426 }
413− push!(Tok::Eof, line, col);
427+ push!(Tok::Eof, line, col, i, i);
414 Ok(toks)
428 Ok(toks)
415}
429}
416
430
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+
1pub mod ast;
12pub mod ast;
2pub mod builtins;
13pub mod builtins;
3pub mod config;
14pub mod config;
15+pub mod diagnostics;
4pub mod domain;
16pub mod domain;
5pub mod eval;
17pub mod eval;
6pub mod lex;
18pub mod lex;
19+mod lookup;
7pub mod parse;
20pub mod parse;
8pub mod render;
21pub mod render;
9pub mod repo;
22pub mod repo;
10pub mod shape;
23pub mod shape;
11pub mod show;
24pub mod show;
25+pub mod terminal;
12pub mod value;
26pub mod value;
13
27
14pub mod jj;
28pub 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 @@
1//! j — a functional-programming-centered CLI for jj repositories (§1).
1//! j — a functional-programming-centered CLI for jj repositories (§1).
2
2
3+mod help;
4+mod setup;
5+
3use j::config::{self, Config};
6use j::config::{self, Config};
4−use j::eval::Interp;
7+use j::diagnostics;
5use j::jj::JjBackend;
8use j::jj::JjBackend;
6use j::parse::parse_expr;
9use j::parse::parse_expr;
7use j::value::{Crash, Value};
10use j::value::{Crash, Value};
@@ -10,20 +13,47 @@ use std::io::{IsTerminal, Read};
10use std::process::ExitCode;
13use std::process::ExitCode;
11use std::rc::Rc;
14use std::rc::Rc;
12
15
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";
14
23
15fn err(status: u8, msg: impl std::fmt::Display) -> ExitCode {
24fn err(status: u8, msg: impl std::fmt::Display) -> ExitCode {
16− eprintln!("j: {}", msg);
25+ j::terminal::report_error(format_args!("j: {}", msg));
17 ExitCode::from(status)
26 ExitCode::from(status)
18}
27}
19
28
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+
20−fn crash_err(c: &Crash, expr_text: Option<&str>) -> ExitCode {
49+fn crash_err(c: &Crash, expr_text: &str, cfg: &Config) -> ExitCode {
21− eprintln!("j: crash: {}", c.msg);
50+ j::terminal::report_error(format_args!("j: crash: {}", c.msg));
22− match (&c.def, expr_text) {
51+ match &c.def {
23− (Some(d), Some(e)) => eprintln!(" in {}, from {}", d, e),
52+ Some(d) => j::report!(" in {}, from {}", d, expr_text),
24− (None, Some(e)) => eprintln!(" from {}", e),
53+ None => j::report!(" from {}", expr_text),
25− (Some(d), None) => eprintln!(" in {}", d),
54+ }
26− (None, None) => {}
55+ if let Some(hint) = diagnostics::expression_hint(expr_text, cfg, c) {
56+ j::report!(" {}", hint);
27 }
57 }
28 ExitCode::from(1)
58 ExitCode::from(1)
29}
59}
@@ -42,184 +72,371 @@ fn config_path() -> String {
42/// installed (the share/j/config.j file may not exist next to the binary).
72/// installed (the share/j/config.j file may not exist next to the binary).
43const DEFAULT_CONFIG: &str = include_str!("../config.j");
73const DEFAULT_CONFIG: &str = include_str!("../config.j");
44
74
45−fn load_config_file() -> Result<(Config, String), ExitCode> {
75+fn load_config_file() -> Result<(Config, String), u8> {
46 let path = config_path();
76 let path = config_path();
47 let src = match std::fs::read_to_string(&path) {
77 let src = match std::fs::read_to_string(&path) {
48 Ok(s) => s,
78 Ok(s) => s,
49− Err(_) => {
79+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
50 // No user config yet: materialise the default there, editable by
80 // No user config yet: materialise the default there, editable by
51 // the current user, so they have a starting point to customise.
81 // the current user, so they have a starting point to customise.
52− if let Err(e) = write_default_config(&path) {
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 => {}
53− return Err(err(3, format!("cannot create config at {}: {}", path, e)));
87+ Err(e) => return Err(fail(3, format!("cannot create config at {}: {}", path, e))),
54 }
88 }
55− eprintln!("j: created an editable default config at {}", path);
56 match std::fs::read_to_string(&path) {
89 match std::fs::read_to_string(&path) {
57 Ok(s) => s,
90 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))),
59 }
92 }
60 }
93 }
94+ Err(e) => return Err(fail(3, format!("cannot read config at {}: {}", path, e))),
61 };
95 };
62 match config::load_config(&src) {
96 match config::load_config(&src) {
63 Ok(c) => Ok((c, src)),
97 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+ }
65 }
107 }
66}
108}
67
109
68/// Write the embedded default config to `path`, creating parent directories
110/// Write the embedded default config to `path`, creating parent directories
69/// and making the file readable and writable by the current user.
111/// and making the file readable and writable by the current user.
70fn write_default_config(path: &str) -> std::io::Result<()> {
112fn write_default_config(path: &str) -> std::io::Result<()> {
113+ use std::io::Write;
71 if let Some(parent) = std::path::Path::new(path).parent() {
114 if let Some(parent) = std::path::Path::new(path).parent() {
72 std::fs::create_dir_all(parent)?;
115 std::fs::create_dir_all(parent)?;
73 }
116 }
74− std::fs::write(path, DEFAULT_CONFIG)?;
117+ let mut options = std::fs::OpenOptions::new();
118+ options.write(true).create_new(true);
75 // user-read+write (0o600 on unix): the file is meant to be edited by them
119 // user-read+write (0o600 on unix): the file is meant to be edited by them
76 #[cfg(unix)]
120 #[cfg(unix)]
77 {
121 {
78− use std::os::unix::fs::PermissionsExt;
122+ use std::os::unix::fs::OpenOptionsExt;
79− std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
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))),
80 }
143 }
81− Ok(())
82}
144}
83
145
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+}
85
214
86fn main() -> ExitCode {
215fn main() -> ExitCode {
87 // evaluation builds deep continuation chains (trampolined, but the final
216 // evaluation builds deep continuation chains (trampolined, but the final
88 // continuation still tears down by recursive Drop), so run the real work
217 // continuation still tears down by recursive Drop), so run the real work
89 // on a thread with a large stack to keep very deep histories safe
218 // 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()
91 .stack_size(512 * 1024 * 1024)
220 .stack_size(512 * 1024 * 1024)
92 .spawn(run)
221 .spawn(run)
93 .expect("failed to spawn worker thread")
222 .expect("failed to spawn worker thread")
94 .join()
223 .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 }
96}
241}
97
242
98fn run() -> ExitCode {
243fn run() -> ExitCode {
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)
99− let args: Vec<String> = std::env::args().skip(1).collect();
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+ };
100− // §1: stdin vs arguments
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.
101− let mut stdin_content = String::new();
262+ let mut stdin_content = Vec::new();
102 let stdin_has_data = {
263 let stdin_has_data = {
103 if std::io::stdin().is_terminal() {
264 if std::io::stdin().is_terminal() {
104 false
265 false
105− } else {
266+ } else if args.is_empty() || stdin_is_ready(50) {
267+ let limit = if args.is_empty() { u64::MAX } else { 1 };
106− match std::io::stdin().read_to_string(&mut stdin_content) {
268+ match std::io::stdin().take(limit).read_to_end(&mut stdin_content) {
107 Ok(n) => n > 0,
269 Ok(n) => n > 0,
108 Err(_) => false,
270 Err(_) => false,
109 }
271 }
272+ } else {
273+ false
110 }
274 }
111 };
275 };
112 if stdin_has_data && !args.is_empty() {
276 if stdin_has_data && !args.is_empty() {
113 return err(2, "expression given both as arguments and on stdin");
277 return err(2, "expression given both as arguments and on stdin");
114 }
278 }
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+ }
115 let text = if stdin_has_data {
289 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+ }
117 } else if !args.is_empty() {
294 } else if !args.is_empty() {
118 args.join(" ")
295 args.join(" ")
119 } else {
296 } else {
120− eprintln!("{}", USAGE);
297+ j::report!("{}", USAGE);
121 return ExitCode::from(2);
298 return ExitCode::from(2);
122 };
299 };
123
300
124 // §1.1: reserved commands
301 // §1.1: reserved commands
125 let trimmed = text.trim();
302 let trimmed = text.trim();
303+ if trimmed.is_empty() {
304+ j::report!("{}", USAGE);
305+ return ExitCode::from(2);
306+ }
126 let first = trimmed.split_whitespace().next().unwrap_or("");
307 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+ }
127 if RESERVED.contains(&first) {
318 if RESERVED.contains(&first) {
128− return run_reserved(first, trimmed);
319+ return run_reserved(first, trimmed, &words);
129 }
320 }
130
321
131 run_expression(trimmed, true)
322 run_expression(trimmed, true)
132}
323}
133
324
134−fn run_reserved(cmd: &str, text: &str) -> ExitCode {
325+fn run_reserved(cmd: &str, text: &str, words: &[&str]) -> ExitCode {
135− let words: Vec<&str> = text.split_whitespace().collect();
136 match cmd {
326 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+ },
137 "init" => {
333 "init" => {
138 if words.len() != 1 {
334 if words.len() != 1 {
139− return err(2, "usage: j init");
335+ return command_usage("init", "init");
140 }
336 }
141 let (cfg, _) = or_exit(load_config_file());
337 let (cfg, _) = or_exit(load_config_file());
142− match j::jj::cmd_init(&cfg) {
338+ let identity = or_exit(setup_identity(&cfg));
143− Ok(()) => ExitCode::SUCCESS,
339+ command_result(
144− Err(e) => err(e.0, e.1),
340+ j::terminal::with_progress("Creating repository", || j::jj::cmd_init(&cfg)),
145− }
341+ &format!("Repository created; identity {}", identity),
342+ )
146 }
343 }
147 "clone" => {
344 "clone" => {
148 if words.len() != 2 && words.len() != 3 {
345 if words.len() != 2 && words.len() != 3 {
149− return err(2, "usage: j clone URL [DIR]");
346+ return command_usage("clone URL [DIR]", "clone");
150 }
347 }
151− let (cfg, _) = or_exit(load_config_file());
152 let dir = if words.len() == 3 {
348 let dir = if words.len() == 3 {
153 words[2].to_string()
349 words[2].to_string()
154 } else {
350 } 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+ }
156 };
361 };
362+ let (cfg, _) = or_exit(load_config_file());
363+ let identity = or_exit(setup_identity(&cfg));
364+ command_result(
157− match j::jj::cmd_clone(&cfg, words[1], &dir) {
365+ j::terminal::with_progress("Cloning repository", || j::jj::cmd_clone(&cfg, words[1], &dir)),
158− Ok(()) => ExitCode::SUCCESS,
366+ &format!("Repository cloned into {}; identity {}\n Next: cd {} && j status", dir, identity, shell_word(&dir)),
159− Err(e) => err(e.0, e.1),
367+ )
160− }
161 }
368 }
162 "remote" => {
369 "remote" => {
163 if words.len() != 2 {
370 if words.len() != 2 {
164− return err(2, "usage: j remote URL");
371+ return command_usage("remote URL", "remote");
165 }
372 }
166 let backend = or_exit(open_repo());
373 let backend = or_exit(open_repo());
374+ command_result(
167− match backend.cmd_remote(words[1]) {
375+ j::terminal::with_progress("Updating remote", || backend.cmd_remote(words[1])),
168− Ok(()) => ExitCode::SUCCESS,
376+ "Remote updated",
169− Err(e) => err(e.0, e.1),
377+ )
170− }
171 }
378 }
172 "fetch" => {
379 "fetch" => {
173 if words.len() != 1 {
380 if words.len() != 1 {
174− return err(2, "usage: j fetch");
381+ return command_usage("fetch", "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),
181 }
382 }
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+ )
182 }
388 }
183 "push" => {
389 "push" => {
184 let rest = text.trim_start_matches("push").trim();
390 let rest = text.trim_start_matches("push").trim();
185 if rest.is_empty() {
391 if rest.is_empty() {
186− return err(2, "usage: j push EXPR");
392+ return command_usage("push EXPR", "push");
187 }
393 }
188− let (cfg, _) = or_exit(load_config_file());
394+ let (mut cfg, _) = or_exit(load_config_file());
189 let backend = or_exit(open_repo_locked(&cfg));
395 let backend = or_exit(open_repo_locked(&cfg));
190 // evaluate EXPR against the recorded repository (no snapshot)
396 // evaluate EXPR against the recorded repository (no snapshot)
191− match backend.cmd_push(&cfg, rest) {
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+ }
192− Ok(()) => ExitCode::SUCCESS,
410+ ExitCode::SUCCESS
193− Err(e) => err(e.0, e.1),
411+ }
412+ Err((code, message)) => err(code, message),
194 }
413 }
195 }
414 }
196 "undo" => {
415 "undo" => {
197 if words.len() != 1 {
416 if words.len() != 1 {
198− return err(2, "usage: j undo");
417+ return command_usage("undo", "undo");
199 }
418 }
200 let backend = or_exit(open_repo_noconfig());
419 let backend = or_exit(open_repo_noconfig());
420+ operation_result(
201− match backend.cmd_undo(false) {
421+ j::terminal::with_progress("Undoing operation", || backend.cmd_undo(false)),
202− Ok(()) => ExitCode::SUCCESS,
422+ )
203− Err(e) => err(e.0, e.1),
204− }
205 }
423 }
206 "redo" => {
424 "redo" => {
207 if words.len() != 1 {
425 if words.len() != 1 {
208− return err(2, "usage: j redo");
426+ return command_usage("redo", "redo");
209 }
427 }
210 let backend = or_exit(open_repo_noconfig());
428 let backend = or_exit(open_repo_noconfig());
429+ operation_result(
211− match backend.cmd_undo(true) {
430+ j::terminal::with_progress("Redoing operation", || backend.cmd_undo(true)),
212− Ok(()) => ExitCode::SUCCESS,
431+ )
213− Err(e) => err(e.0, e.1),
214− }
215 }
432 }
216 "ops" => {
433 "ops" => {
217 if words.len() != 1 {
434 if words.len() != 1 {
218− return err(2, "usage: j ops");
435+ return command_usage("ops", "ops");
219 }
436 }
220 let backend = or_exit(open_repo_noconfig());
437 let backend = or_exit(open_repo_noconfig());
221− match backend.cmd_ops() {
438+ match j::terminal::with_progress("Loading operation log", || backend.cmd_ops()) {
222− Ok(()) => ExitCode::SUCCESS,
439+ Ok(log) => print_output(&log),
223 Err(e) => err(e.0, e.1),
440 Err(e) => err(e.0, e.1),
224 }
441 }
225 }
442 }
@@ -227,101 +444,101 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
227 }
444 }
228}
445}
229
446
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.
230−fn default_clone_dir(url: &str) -> String {
451+fn default_clone_dir(url: &str) -> Option<String> {
231− let last = url.trim_end_matches('/').rsplit('/').next().unwrap_or("repo");
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+ };
232− last.strip_suffix(".git").unwrap_or(last).to_string()
460+ let name = last.strip_suffix(".git").unwrap_or(last);
461+ match name {
462+ "" | "." | ".." => None,
463+ name => Some(name.to_string()),
464+ }
233}
465}
234
466
235−fn or_exit<T>(r: Result<T, ExitCode>) -> T {
467+/// A runnable shell word for literal clone destinations, including spaces and
236− match r {
468+/// quotes. Prefix a leading dash so `cd` does not interpret it as an option.
237− Ok(v) => v,
469+fn shell_word(path: &str) -> String {
238− Err(c) => std::process::exit(exit_code_number(c)),
470+ let path = if path.starts_with('-') { format!("./{}", path) } else { path.to_string() };
239− }
471+ format!("'{}'", path.replace('\'', "'\\''"))
240}
472}
241
473
242−/// ExitCode doesn't expose its value; track it alongside.
474+fn or_exit<T>(r: Result<T, u8>) -> T {
243−fn exit_code_number(c: ExitCode) -> i32 {
475+ match r {
244− // ExitCode's Debug is "ExitCode(unix_exit_status(N))" on unix
476+ Ok(v) => v,
245− let s = format!("{:?}", c);
477+ Err(code) => std::process::exit(code as i32),
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− }
252 }
478 }
253− 1
254}
479}
255
480
256−fn open_repo() -> Result<Rc<JjBackend>, ExitCode> {
481+fn open_repo() -> Result<Rc<JjBackend>, u8> {
257 match JjBackend::open(None) {
482 match JjBackend::open(None) {
258 Ok(b) => Ok(Rc::new(b)),
483 Ok(b) => Ok(Rc::new(b)),
259− Err(e) => Err(err(e.0, e.1)),
484+ Err(e) => Err(fail(e.0, e.1)),
260 }
485 }
261}
486}
262
487
263−fn open_repo_noconfig() -> Result<Rc<JjBackend>, ExitCode> {
488+fn open_repo_noconfig() -> Result<Rc<JjBackend>, u8> {
264 // undo/redo/ops do not read the config (§6.1), but take the lock
489 // undo/redo/ops do not read the config (§6.1), but take the lock
265 match JjBackend::open(None) {
490 match JjBackend::open(None) {
266 Ok(b) => {
491 Ok(b) => {
267 b.take_lock();
492 b.take_lock();
268 Ok(Rc::new(b))
493 Ok(Rc::new(b))
269 }
494 }
270− Err(e) => Err(err(e.0, e.1)),
495+ Err(e) => Err(fail(e.0, e.1)),
271 }
496 }
272}
497}
273
498
274−fn open_repo_locked(cfg: &Config) -> Result<Rc<JjBackend>, ExitCode> {
499+fn open_repo_locked(cfg: &Config) -> Result<Rc<JjBackend>, u8> {
275 match JjBackend::open(Some(cfg)) {
500 match JjBackend::open(Some(cfg)) {
276 Ok(b) => {
501 Ok(b) => {
277 b.take_lock();
502 b.take_lock();
278 Ok(Rc::new(b))
503 Ok(Rc::new(b))
279 }
504 }
280− Err(e) => Err(err(e.0, e.1)),
505+ Err(e) => Err(fail(e.0, e.1)),
281 }
506 }
282}
507}
283
508
284fn run_expression(text: &str, snapshot: bool) -> ExitCode {
509fn run_expression(text: &str, snapshot: bool) -> ExitCode {
285 // §1.2.1: locate the repository
510 // §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());
287 let backend = or_exit(open_repo_locked(&cfg));
512 let backend = or_exit(open_repo_locked(&cfg));
288
513
289 // §1.2.2: parse the expression
514 // §1.2.2: parse the expression
290 let outer = Rc::new(cfg.global_names.clone());
515 let outer = Rc::new(cfg.global_names.clone());
291 let mut expr = match parse_expr(text, outer) {
516 let mut expr = match parse_expr(text, outer) {
292 Ok(e) => e,
517 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)),
294 };
519 };
295
520
296 // §1.2.4: build the current repository value (with snapshot)
521 // §1.2.4: build the current repository value (with snapshot)
297 let (mut interp, loaded_repo, current_repo) =
522 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)) {
299 Ok(t) => t,
524 Ok(t) => t,
300 Err((2, m)) => return err(2, m),
525 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: "))),
301 Err((_, m)) => return err(1, m),
527 Err((_, m)) => return err(1, m),
302 };
528 };
303
529
304− // §1.2.5: resolve Id literals in config and expression
530+ // §1.2.5: the config's Id literals were resolved by build_interp, as soon
305− for (def_name, prefix, candidates) in config::config_id_failures(&cfg, &interp) {
531+ // as the visible ids were known; the expression's are resolved here
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− }
315 expr = match config::resolve_ids(&expr, &interp) {
532 expr = match config::resolve_ids(&expr, &interp) {
316 Ok(e) => e,
533 Ok(e) => e,
317 Err(failures) => {
534 Err(failures) => {
318 let (prefix, candidates) = &failures[0];
535 let (prefix, candidates) = &failures[0];
319 if candidates.is_empty() {
536 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));
321 } else {
538 } else {
322− eprintln!("j: crash: `@{}` is ambiguous", prefix);
539+ j::terminal::report_error(format_args!("j: crash: `@{}` is ambiguous", prefix));
323 for c in candidates {
540 for c in candidates {
324− eprintln!(" {}", c);
541+ j::report!(" {}", c);
325 }
542 }
326 }
543 }
327 return ExitCode::from(1);
544 return ExitCode::from(1);
@@ -329,45 +546,140 @@ fn run_expression(text: &str, snapshot: bool) -> ExitCode {
329 };
546 };
330
547
331 // §1.2.6: evaluate config definitions, then the expression
548 // §1.2.6: evaluate config definitions, then the expression
549+ if let Err(c) = j::terminal::with_progress("Evaluating configuration", || {
332− if let Err(c) = config::eval_config(&mut interp, &cfg) {
550+ config::eval_config(&mut interp, &cfg)
551+ }) {
333− return err(3, format!("config.j: {}", c.msg));
552+ return err(3, format!("{}: {}\n See `j help config` for configuration help.", config_path(), c.msg));
334 }
553 }
335 *interp.old_repo.borrow_mut() = Some(loaded_repo.clone());
554 *interp.old_repo.borrow_mut() = Some(loaded_repo.clone());
555+ *interp.given_repo.borrow_mut() = Some(current_repo.clone());
336 let env = interp.global_env();
556 let env = interp.global_env();
337 let expr_rc = Rc::new(expr);
557 let expr_rc = Rc::new(expr);
558+ let v = match j::terminal::with_progress("Evaluating expression", || {
338− let mut v = match interp.eval(&expr_rc, &env) {
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+ }) {
339 Ok(v) => v,
567 Ok(v) => v,
340− Err(c) => return crash_err(&c, Some(text)),
568+ Err(c) => return crash_err(&c, text, &cfg),
341 };
569 };
342
570
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−
351 // §1.2.8: a Repo is persisted (unless unchanged)
571 // §1.2.8: a Repo is persisted (unless unchanged)
352− if is_repo_value(&interp, &v) {
572+ if is_repo_value(&v) {
573+ match j::terminal::with_progress("Saving changes", || {
353− match backend.persist(&mut interp, &cfg, &loaded_repo, &current_repo, &v, text) {
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+ }
354− Ok(()) => ExitCode::SUCCESS,
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+ }
355− Err(c) => crash_err(&c, Some(text)),
589+ Err(c) => crash_err(&c, text, &cfg),
356 }
590 }
357 } else {
591 } else {
358 // §1.2.9: display
592 // §1.2.9: display
359 let color = j::render::color_enabled("auto");
593 let color = j::render::color_enabled("auto");
360− match j::render::display(&mut interp, &v, color) {
594+ match j::terminal::with_progress("Rendering output", || j::render::display(&mut interp, &v, color)) {
361− Ok(s) => {
595+ Ok(s) => print_output(&s),
362− print!("{}", s);
596+ Err(c) => crash_err(&c, text, &cfg),
363− ExitCode::SUCCESS
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));
364 }
622 }
365− Err(c) => crash_err(&c, Some(text)),
366 }
623 }
367 }
624 }
625+ Ok(paths)
368}
626}
369
627
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+
370−fn is_repo_value(interp: &Interp, v: &Value) -> bool {
682+fn is_repo_value(v: &Value) -> bool {
371 // §4.4: shape Repo and root's shape Commit
683 // §4.4: shape Repo and root's shape Commit
372 let fields = match v {
684 let fields = match v {
373 Value::Record(m) => m.keys().cloned().collect::<BTreeSet<String>>(),
685 Value::Record(m) => m.keys().cloned().collect::<BTreeSet<String>>(),
@@ -392,6 +704,18 @@ fn is_repo_value(interp: &Interp, v: &Value) -> bool {
392 .iter()
704 .iter()
393 .map(|s| s.to_string())
705 .map(|s| s.to_string())
394 .collect();
706 .collect();
395− let _ = interp;
396 root_fields == want_root
707 root_fields == want_root
397}
708}
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 @@
1//! Parser for the j language (§3.2–3.4), including the two layout rules.
1//! Parser for the j language (§3.2–3.4), including the two layout rules.
2
2
3−use crate::ast::{Expr, Item, Pattern, TypeExpr};
3+use crate::ast::{Expr, Item, Pattern, Source, TypeExpr};
4use crate::lex::{lex, LexError, SpTok, Tok};
4use crate::lex::{lex, LexError, SpTok, Tok};
5use num_bigint::BigInt;
5use num_bigint::BigInt;
6−use std::collections::BTreeSet;
6+use std::collections::{BTreeSet, HashMap, HashSet};
7use std::rc::Rc;
7use std::rc::Rc;
8
8
9#[derive(Debug)]
9#[derive(Debug)]
@@ -31,15 +31,45 @@ impl From<LexError> for ParseError {
31}
31}
32
32
33pub struct Parser {
33pub struct Parser {
34+ /// the text being parsed, of which each lambda keeps its span (§5.2)
35+ src: Rc<str>,
34 toks: Vec<SpTok>,
36 toks: Vec<SpTok>,
35 pos: usize,
37 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).
37 outer_names: Rc<BTreeSet<String>>,
40 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)>,
38 /// set when an operator expression ends with a trailing operator before
51 /// set when an operator expression ends with a trailing operator before
39 /// `)`, i.e. a left section
52 /// `)`, i.e. a left section
40 section_op: Option<&'static str>,
53 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,
41}
59}
42
60
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+
43/// fixity table (§3.2): (precedence, right-assoc)
73/// fixity table (§3.2): (precedence, right-assoc)
44fn fixity(op: &str) -> Option<(u8, bool)> {
74fn fixity(op: &str) -> Option<(u8, bool)> {
45 Some(match op {
75 Some(match op {
@@ -62,13 +92,51 @@ fn is_nonassoc(op: &str) -> bool {
62impl Parser {
92impl Parser {
63 pub fn new(src: &str, outer_names: Rc<BTreeSet<String>>) -> Result<Self, ParseError> {
93 pub fn new(src: &str, outer_names: Rc<BTreeSet<String>>) -> Result<Self, ParseError> {
64 Ok(Parser {
94 Ok(Parser {
95+ src: Rc::from(src),
65 toks: lex(src)?,
96 toks: lex(src)?,
66 pos: 0,
97 pos: 0,
67 outer_names,
98 outer_names,
99+ locals: HashSet::new(),
100+ binders: 0,
101+ last_binder: HashMap::new(),
68 section_op: None,
102 section_op: None,
103+ depth: 0,
104+ height: 0,
69 })
105 })
70 }
106 }
71
107
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+
72 fn peek(&self) -> &Tok {
140 fn peek(&self) -> &Tok {
73 &self.toks[self.pos].tok
141 &self.toks[self.pos].tok
74 }
142 }
@@ -123,12 +191,15 @@ impl Parser {
123 Ok(items)
191 Ok(items)
124 }
192 }
125
193
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.
126 fn parse_item(&mut self) -> Result<Item, ParseError> {
197 fn parse_item(&mut self) -> Result<Item, ParseError> {
127 match self.peek().clone() {
198 match self.peek().clone() {
128 Tok::TypeName(name) => {
199 Tok::TypeName(name) => {
129 // typedecl: TYPENAME '=' type
200 // typedecl: TYPENAME '=' type
130 self.bump();
201 self.bump();
131− self.skip_newlines();
202+ self.skip_layout_newlines(1);
132 self.expect(&Tok::Equals)?;
203 self.expect(&Tok::Equals)?;
133 let ty = self.parse_type()?;
204 let ty = self.parse_type()?;
134 Ok(Item::TypeDecl(name, ty))
205 Ok(Item::TypeDecl(name, ty))
@@ -136,6 +207,7 @@ impl Parser {
136 Tok::Ident(name) => {
207 Tok::Ident(name) => {
137 let line = self.line();
208 let line = self.line();
138 self.bump();
209 self.bump();
210+ self.skip_layout_newlines(1);
139 match self.peek().clone() {
211 match self.peek().clone() {
140 Tok::Colon => {
212 Tok::Colon => {
141 self.bump();
213 self.bump();
@@ -144,7 +216,8 @@ impl Parser {
144 }
216 }
145 Tok::Equals => {
217 Tok::Equals => {
146 self.bump();
218 self.bump();
219+ // inside nothing, so it opens no level (see `nested`)
147− let e = self.parse_expr(1)?;
220+ let e = self.parse_expr_inner(1)?;
148 Ok(Item::Definition(name, Rc::new(e), line))
221 Ok(Item::Definition(name, Rc::new(e), line))
149 }
222 }
150 other => Err(ParseError::new(
223 other => Err(ParseError::new(
@@ -167,6 +240,7 @@ impl Parser {
167 }
240 }
168 };
241 };
169 self.expect(&Tok::RParen)?;
242 self.expect(&Tok::RParen)?;
243+ self.skip_layout_newlines(1);
170 self.expect(&Tok::Colon)?;
244 self.expect(&Tok::Colon)?;
171 let ty = self.parse_type()?;
245 let ty = self.parse_type()?;
172 Ok(Item::Signature(op.to_string(), ty, line))
246 Ok(Item::Signature(op.to_string(), ty, line))
@@ -182,9 +256,16 @@ impl Parser {
182 // types
256 // types
183 // ------------------------------------------------------------------
257 // ------------------------------------------------------------------
184 fn parse_type(&mut self) -> Result<TypeExpr, ParseError> {
258 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);
186 let lhs = self.parse_atype()?;
267 let lhs = self.parse_atype()?;
187− self.skip_newlines_in_type();
268+ self.skip_layout_newlines(1);
188 if matches!(self.peek(), Tok::Arrow) {
269 if matches!(self.peek(), Tok::Arrow) {
189 self.bump();
270 self.bump();
190 let rhs = self.parse_type()?;
271 let rhs = self.parse_type()?;
@@ -194,38 +275,6 @@ impl Parser {
194 }
275 }
195 }
276 }
196
277
197− /// In types (signatures) we allow newlines before `->` continuation when
198− /// indented; keep it simple: skip newlines if the next token is `->`.
199− fn skip_newlines_in_type(&mut self) {
200− let save = self.pos;
201− loop {
202− if matches!(self.peek(), Tok::Newline) {
203− self.pos += 1;
204− // only continue skipping if Arrow follows after the newlines
205− let _ = &save;
206− let mut p = self.pos;
207− while matches!(self.toks[p].tok, Tok::Newline) {
208− p += 1;
209− }
210− if matches!(self.toks[p].tok, Tok::Arrow) {
211− self.pos = p;
212− return;
213− } else {
214− self.pos = save;
215− // restore to before newlines? we must not consume them if
216− // not followed by Arrow, since layout needs them.
217− // Walk back: find first newline we consumed.
218− while self.pos > 0 && matches!(self.toks[self.pos - 1].tok, Tok::Newline) {
219− self.pos -= 1;
220− }
221− return;
222− }
223− } else {
224− return;
225− }
226− }
227− }
228−
229 fn parse_atype(&mut self) -> Result<TypeExpr, ParseError> {
278 fn parse_atype(&mut self) -> Result<TypeExpr, ParseError> {
230 match self.peek().clone() {
279 match self.peek().clone() {
231 Tok::TypeName(n) => {
280 Tok::TypeName(n) => {
@@ -246,7 +295,7 @@ impl Parser {
246 self.bump();
295 self.bump();
247 let mut fields = Vec::new();
296 let mut fields = Vec::new();
248 loop {
297 loop {
249− self.skip_newlines();
298+ self.skip_layout_newlines(1);
250 if matches!(self.peek(), Tok::RBrace) {
299 if matches!(self.peek(), Tok::RBrace) {
251 self.bump();
300 self.bump();
252 break;
301 break;
@@ -260,10 +309,11 @@ impl Parser {
260 ))
309 ))
261 }
310 }
262 };
311 };
312+ self.skip_layout_newlines(1);
263 self.expect(&Tok::Colon)?;
313 self.expect(&Tok::Colon)?;
264 let t = self.parse_type()?;
314 let t = self.parse_type()?;
265 fields.push((name, Rc::new(t)));
315 fields.push((name, Rc::new(t)));
266− self.skip_newlines();
316+ self.skip_layout_newlines(1);
267 match self.peek() {
317 match self.peek() {
268 Tok::Comma => {
318 Tok::Comma => {
269 self.bump();
319 self.bump();
@@ -303,59 +353,84 @@ impl Parser {
303 /// expression stops at a Newline whose following token is in a column
353 /// expression stops at a Newline whose following token is in a column
304 /// <= min_col (it belongs to an outer construct).
354 /// <= min_col (it belongs to an outer construct).
305 pub fn parse_expr(&mut self, min_col: usize) -> Result<Expr, ParseError> {
355 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> {
306 self.skip_layout_newlines(min_col);
364 self.skip_layout_newlines(min_col);
307 match self.peek().clone() {
365 match self.peek().clone() {
308− Tok::Backslash => {
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> {
309− let src_start = self.toks[self.pos].clone();
374+ let src_start = self.toks[self.pos].clone();
310− self.bump();
375+ self.bump();
311− let mut params = Vec::new();
376+ let mut params = Vec::new();
312− loop {
377+ loop {
313− match self.peek().clone() {
378+ match self.peek().clone() {
314− Tok::Ident(n) => {
379+ Tok::Ident(n) => {
315− self.check_shadow(&n)?;
380+ // in scope from here on, so a later parameter
316− params.push(Pattern::Var(n));
381+ // may not reuse it either
317− self.bump();
382+ self.check_shadow(&n)?;
318− }
383+ self.locals.insert(n.clone());
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(),
384+ self.note_binder(&n, self.line());
334− ))
385+ params.push(Pattern::Var(n));
335− }
386+ self.bump();
336− }
337 }
387 }
338− if params.is_empty() {
388+ Tok::Underscore => {
389+ params.push(Pattern::Wildcard);
390+ self.bump();
391+ }
392+ Tok::Arrow => {
393+ self.bump();
394+ break;
395+ }
396+ other => {
339− return Err(ParseError::new("lambda needs at least one parameter", self.line()));
397+ return Err(ParseError::new(
398+ format!(
399+ "expected a parameter or `->`, found {}",
400+ other.describe()
401+ ),
402+ self.line(),
403+ ))
340 }
404 }
341− let body = self.parse_expr(min_col)?;
342− let src = self.source_slice(&src_start);
343− Ok(Expr::Lambda(params, Rc::new(body), src))
344 }
405 }
345− Tok::If => {
406+ }
346− self.bump();
407+ if params.is_empty() {
347− let c = self.parse_expr(min_col)?;
408+ return Err(ParseError::new("lambda needs at least one parameter", self.line()));
348− self.skip_layout_newlines(min_col);
409+ }
349− self.expect(&Tok::Then)?;
410+ let body = self.parse_expr(min_col)?;
350− let t = self.parse_expr(min_col)?;
411+ self.height = self.within(self.height + 1)?;
351− self.skip_layout_newlines(min_col);
412+ for p in &params {
352− self.expect(&Tok::Else)?;
413+ if let Pattern::Var(n) = p {
353− let e = self.parse_expr(min_col)?;
414+ self.locals.remove(n);
354− Ok(Expr::If(Rc::new(c), Rc::new(t), Rc::new(e)))
355 }
415 }
356− Tok::Let => self.parse_let(min_col),
357− _ => self.parse_or(min_col),
358 }
416 }
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)))
359 }
434 }
360
435
361 /// skip Newlines whose next real token is in a column > min_col
436 /// skip Newlines whose next real token is in a column > min_col
@@ -396,7 +471,9 @@ impl Parser {
396 ));
471 ));
397 }
472 }
398 let mut bindings: Vec<(String, Rc<Expr>)> = Vec::new();
473 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;
400 loop {
477 loop {
401 // binding: ident '=' expr, expr limited to block_col
478 // binding: ident '=' expr, expr limited to block_col
402 let name = match self.peek().clone() {
479 let name = match self.peek().clone() {
@@ -408,17 +485,21 @@ impl Parser {
408 ))
485 ))
409 }
486 }
410 };
487 };
411− self.check_shadow(&name)?;
488+ if names.iter().any(|(n, _)| *n == name) {
412− if names.iter().any(|n| *n == name) {
413 return Err(ParseError::new(
489 return Err(ParseError::new(
414 format!("`{}` is bound twice in this let", name),
490 format!("`{}` is bound twice in this let", name),
415 self.line(),
491 self.line(),
416 ));
492 ));
417 }
493 }
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()));
418 self.bump();
499 self.bump();
419 self.expect(&Tok::Equals)?;
500 self.expect(&Tok::Equals)?;
420 let e = self.parse_expr(block_col)?;
501 let e = self.parse_expr(block_col)?;
421− names.push(name.clone());
502+ h = h.max(self.height);
422 bindings.push((name, Rc::new(e)));
503 bindings.push((name, Rc::new(e)));
423 // separator: `;` or newline at exactly block_col, then `in` ends
504 // separator: `;` or newline at exactly block_col, then `in` ends
424 match self.peek().clone() {
505 match self.peek().clone() {
@@ -443,6 +524,16 @@ impl Parser {
443 }
524 }
444 let next_col = self.toks[q].col;
525 let next_col = self.toks[q].col;
445 if matches!(self.toks[q].tok, Tok::In) {
526 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+ }
446 self.pos = q;
537 self.pos = q;
447 self.bump();
538 self.bump();
448 break;
539 break;
@@ -480,39 +571,66 @@ impl Parser {
480 }
571 }
481 }
572 }
482 }
573 }
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+ }
483 let body = self.parse_expr(min_col)?;
587 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+ }
484 Ok(Expr::Let(bindings, Rc::new(body)))
592 Ok(Expr::Let(bindings, Rc::new(body)))
485 }
593 }
486
594
595+ /// §4.2: a binder may not reuse a top-level or builtin name, nor one
596+ /// bound by an enclosing lambda or `let`.
487 fn check_shadow(&self, name: &str) -> Result<(), ParseError> {
597 fn check_shadow(&self, name: &str) -> Result<(), ParseError> {
488− if self.outer_names.contains(name) {
598+ if self.outer_names.contains(name) || self.locals.contains(name) {
489− return Err(ParseError::new(
599+ return Err(shadowed(name, self.line()));
490− format!("`{}` is already bound and may not be shadowed", name),
491− self.line(),
492− ));
493 }
600 }
494 Ok(())
601 Ok(())
495 }
602 }
496
603
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+
497 fn parse_or(&mut self, min_col: usize) -> Result<Expr, ParseError> {
611 fn parse_or(&mut self, min_col: usize) -> Result<Expr, ParseError> {
498 // `or` is infixl 0 and extends as far right as possible
612 // `or` is infixl 0 and extends as far right as possible
499 let mut lhs = self.parse_opexpr(1, min_col)?;
613 let mut lhs = self.parse_opexpr(1, min_col)?;
614+ let mut h = self.height;
500 loop {
615 loop {
501 self.skip_layout_newlines(min_col);
616 self.skip_layout_newlines(min_col);
502 if matches!(self.peek(), Tok::Or) {
617 if matches!(self.peek(), Tok::Or) {
503 self.bump();
618 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)?;
505 lhs = Expr::Or(Rc::new(lhs), Rc::new(rhs));
621 lhs = Expr::Or(Rc::new(lhs), Rc::new(rhs));
506 } else {
622 } else {
507 break;
623 break;
508 }
624 }
509 }
625 }
626+ self.height = h;
510 Ok(lhs)
627 Ok(lhs)
511 }
628 }
512
629
513 /// precedence-climbing over the fixity table
630 /// precedence-climbing over the fixity table
514 fn parse_opexpr(&mut self, min_prec: u8, min_col: usize) -> Result<Expr, ParseError> {
631 fn parse_opexpr(&mut self, min_prec: u8, min_col: usize) -> Result<Expr, ParseError> {
515 let mut lhs = self.parse_app(min_col)?;
632 let mut lhs = self.parse_app(min_col)?;
633+ let mut h = self.height;
516 loop {
634 loop {
517 self.skip_op_newline(min_col);
635 self.skip_op_newline(min_col);
518 let op: &'static str = match self.peek() {
636 let op: &'static str = match self.peek() {
@@ -530,10 +648,11 @@ impl Parser {
530 // left section: `(e op)` — a trailing operator before `)`
648 // left section: `(e op)` — a trailing operator before `)`
531 if matches!(self.peek(), Tok::RParen) {
649 if matches!(self.peek(), Tok::RParen) {
532 self.section_op = Some(op);
650 self.section_op = Some(op);
651+ self.height = h;
533 return Ok(lhs);
652 return Ok(lhs);
534 }
653 }
535 let next_min = if right { prec } else { prec + 1 };
654 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))?;
537 // non-assoc check: if lhs is the same operator, reject chaining
656 // non-assoc check: if lhs is the same operator, reject chaining
538 if is_nonassoc(op) {
657 if is_nonassoc(op) {
539 // a non-associative operator cannot be chained; explicit
658 // a non-associative operator cannot be chained; explicit
@@ -555,8 +674,10 @@ impl Parser {
555 }
674 }
556 }
675 }
557 }
676 }
677+ h = self.within(h.max(self.height) + 1)?;
558 lhs = Expr::BinOp(op, Rc::new(lhs), Rc::new(rhs));
678 lhs = Expr::BinOp(op, Rc::new(lhs), Rc::new(rhs));
559 }
679 }
680+ self.height = h;
560 Ok(lhs)
681 Ok(lhs)
561 }
682 }
562
683
@@ -596,6 +717,7 @@ impl Parser {
596
717
597 fn parse_app(&mut self, min_col: usize) -> Result<Expr, ParseError> {
718 fn parse_app(&mut self, min_col: usize) -> Result<Expr, ParseError> {
598 let mut e = self.parse_postfix(min_col)?;
719 let mut e = self.parse_postfix(min_col)?;
720+ let mut h = self.height;
599 loop {
721 loop {
600 // application across a newline requires deeper indentation
722 // application across a newline requires deeper indentation
601 if matches!(self.peek(), Tok::Newline) {
723 if matches!(self.peek(), Tok::Newline) {
@@ -611,20 +733,24 @@ impl Parser {
611 }
733 }
612 if Self::starts_atom(self.peek()) {
734 if Self::starts_atom(self.peek()) {
613 let arg = self.parse_postfix(min_col)?;
735 let arg = self.parse_postfix(min_col)?;
736+ h = self.within(h.max(self.height) + 1)?;
614 e = Expr::App(Rc::new(e), Rc::new(arg));
737 e = Expr::App(Rc::new(e), Rc::new(arg));
615 } else {
738 } else {
616 break;
739 break;
617 }
740 }
618 }
741 }
742+ self.height = h;
619 Ok(e)
743 Ok(e)
620 }
744 }
621
745
622 fn parse_postfix(&mut self, min_col: usize) -> Result<Expr, ParseError> {
746 fn parse_postfix(&mut self, min_col: usize) -> Result<Expr, ParseError> {
623 let mut e = self.parse_atom(min_col)?;
747 let mut e = self.parse_atom(min_col)?;
748+ let mut h = self.height;
624 loop {
749 loop {
625 match self.peek().clone() {
750 match self.peek().clone() {
626 Tok::Selector(f) => {
751 Tok::Selector(f) => {
627 self.bump();
752 self.bump();
753+ h = self.within(h + 1)?;
628 e = Expr::Select(Rc::new(e), f);
754 e = Expr::Select(Rc::new(e), f);
629 }
755 }
630 Tok::LBrace => {
756 Tok::LBrace => {
@@ -632,12 +758,18 @@ impl Parser {
632 // postfix binds tighter than application, so `{` after an
758 // postfix binds tighter than application, so `{` after an
633 // atom is an update)
759 // atom is an update)
634 self.bump();
760 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)?;
636 e = Expr::Update(Rc::new(e), fields);
765 e = Expr::Update(Rc::new(e), fields);
637 }
766 }
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)
638 _ => break,
769 _ => break,
639 }
770 }
640 }
771 }
772+ self.height = h;
641 Ok(e)
773 Ok(e)
642 }
774 }
643
775
@@ -651,8 +783,12 @@ impl Parser {
651 self.skip_layout_newlines(min_col);
783 self.skip_layout_newlines(min_col);
652 if matches!(self.peek(), Tok::RBrace) {
784 if matches!(self.peek(), Tok::RBrace) {
653 self.bump();
785 self.bump();
786+ // nothing inside the braces
787+ self.height = 0;
654 return Ok(fields);
788 return Ok(fields);
655 }
789 }
790+ // the height of the highest field
791+ let mut h = 0;
656 loop {
792 loop {
657 let name = match self.bump() {
793 let name = match self.bump() {
658 Tok::Ident(n) => n,
794 Tok::Ident(n) => n,
@@ -671,6 +807,7 @@ impl Parser {
671 }
807 }
672 self.expect(&Tok::Equals)?;
808 self.expect(&Tok::Equals)?;
673 let v = self.parse_expr(min_col)?;
809 let v = self.parse_expr(min_col)?;
810+ h = h.max(self.height);
674 fields.push((name, Rc::new(v)));
811 fields.push((name, Rc::new(v)));
675 self.skip_layout_newlines(min_col);
812 self.skip_layout_newlines(min_col);
676 match self.peek() {
813 match self.peek() {
@@ -690,10 +827,17 @@ impl Parser {
690 }
827 }
691 }
828 }
692 }
829 }
830+ self.height = h;
693 Ok(fields)
831 Ok(fields)
694 }
832 }
695
833
696 fn parse_atom(&mut self, min_col: usize) -> Result<Expr, ParseError> {
834 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;
697 match self.peek().clone() {
841 match self.peek().clone() {
698 Tok::Ident(n) => {
842 Tok::Ident(n) => {
699 self.bump();
843 self.bump();
@@ -739,70 +883,16 @@ impl Parser {
739 self.bump();
883 self.bump();
740 Ok(Expr::SelectorFun(f))
884 Ok(Expr::SelectorFun(f))
741 }
885 }
742− Tok::LBracket => {
886+ Tok::LBracket => self.parse_list(min_col),
743− self.bump();
744− let mut elems = Vec::new();
745− loop {
746− self.skip_layout_newlines(min_col);
747− if matches!(self.peek(), Tok::RBracket) {
748− self.bump();
749− break;
750− }
751− let e = self.parse_postfix(min_col)?;
752− elems.push(Rc::new(e));
753− }
754− Ok(Expr::List(elems))
755− }
756 Tok::LBrace => {
887 Tok::LBrace => {
757 self.bump();
888 self.bump();
758 let fields = self.parse_fields(min_col, false)?;
889 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)?;
759 Ok(Expr::Record(fields))
893 Ok(Expr::Record(fields))
760 }
894 }
761− Tok::LParen => {
895+ Tok::LParen => self.parse_paren(min_col),
762− self.bump();
763− // (op) | (op expr) | (expr op) | (expr)
764− if let Tok::Op(o) = self.peek().clone() {
765− self.bump();
766− self.skip_layout_newlines(min_col);
767− if matches!(self.peek(), Tok::RParen) {
768− self.bump();
769− return Ok(Expr::Var(o.to_string()));
770− }
771− // right section: (op e) = \x -> x op e
772− let e = self.parse_expr(min_col)?;
773− self.expect(&Tok::RParen)?;
774− let var = fresh_var();
775− let body = Expr::BinOp(
776− o,
777− Rc::new(Expr::Var(var.clone())),
778− Rc::new(e),
779− );
780− return Ok(Expr::Lambda(
781− vec![Pattern::Var(var)],
782− Rc::new(body),
783− "section".into(),
784− ));
785− }
786− let first = self.parse_expr(min_col)?;
787− self.skip_layout_newlines(min_col);
788− if let Some(o) = self.section_op.take() {
789− // left section: (e op) = \x -> e op x
790− self.expect(&Tok::RParen)?;
791− let var = fresh_var();
792− let body = Expr::BinOp(
793− o,
794− Rc::new(first),
795− Rc::new(Expr::Var(var.clone())),
796− );
797− return Ok(Expr::Lambda(
798− vec![Pattern::Var(var)],
799− Rc::new(body),
800− "section".into(),
801− ));
802− }
803− self.expect(&Tok::RParen)?;
804− Ok(Expr::Paren(Rc::new(first)))
805− }
806 other => Err(ParseError::new(
896 other => Err(ParseError::new(
807 format!("unexpected {}", other.describe()),
897 format!("unexpected {}", other.describe()),
808 self.line(),
898 self.line(),
@@ -810,15 +900,105 @@ impl Parser {
810 }
900 }
811 }
901 }
812
902
813− fn source_slice(&self, _start: &SpTok) -> String {
903+ fn parse_list(&mut self, min_col: usize) -> Result<Expr, ParseError> {
814− // source text of a lambda for display; we re-render from the AST.
904+ self.bump();
815− String::new()
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)
816 }
995 }
817
996
818 /// Parse a single command-line expression. Layout rule 1 does not apply.
997 /// Parse a single command-line expression. Layout rule 1 does not apply.
819 pub fn parse_cli_expr(&mut self) -> Result<Expr, ParseError> {
998 pub fn parse_cli_expr(&mut self) -> Result<Expr, ParseError> {
820 self.skip_newlines();
999 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)?;
822 self.skip_newlines();
1002 self.skip_newlines();
823 if !matches!(self.peek(), Tok::Eof) {
1003 if !matches!(self.peek(), Tok::Eof) {
824 return Err(ParseError::new(
1004 return Err(ParseError::new(
@@ -830,6 +1010,10 @@ impl Parser {
830 }
1010 }
831}
1011}
832
1012
1013+fn shadowed(name: &str, line: usize) -> ParseError {
1014+ ParseError::new(format!("`{}` is already bound and may not be shadowed", name), line)
1015+}
1016+
833use std::sync::atomic::{AtomicUsize, Ordering};
1017use std::sync::atomic::{AtomicUsize, Ordering};
834static FRESH: AtomicUsize = AtomicUsize::new(0);
1018static FRESH: AtomicUsize = AtomicUsize::new(0);
835fn fresh_var() -> String {
1019fn fresh_var() -> String {
src/render.rs+1737−589
@@ -2,8 +2,11 @@
2
2
3use crate::domain::ROOT_ID;
3use crate::domain::ROOT_ID;
4use crate::eval::Interp;
4use crate::eval::Interp;
5−use crate::value::{Crash, ShapeKind, Value};
5+use crate::value::{Crash, ListVal, RecordMap, ShapeKind, Value};
6−use std::collections::{BTreeMap, BTreeSet};
6+use std::cmp::Reverse;
7+use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
8+use std::ops::Bound;
9+use std::rc::Rc;
7use unicode_width::UnicodeWidthChar;
10use unicode_width::UnicodeWidthChar;
8
11
9// ----------------------------------------------------------------------
12// ----------------------------------------------------------------------
@@ -30,33 +33,46 @@ impl Palette {
30 self.wrap("2", s)
33 self.wrap("2", s)
31 }
34 }
32 pub fn bold_bg(&self, s: &str) -> String {
35 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)
34 }
37 }
35 pub fn dim_italic(&self, s: &str) -> String {
38 pub fn dim_italic(&self, s: &str) -> String {
36 self.wrap("2;3", s)
39 self.wrap("2;3", s)
37 }
40 }
38 pub fn cyan(&self, s: &str) -> String {
41 pub fn cyan(&self, s: &str) -> String {
39− self.wrap("36", s)
42+ self.wrap("96", s)
40 }
43 }
41 pub fn red(&self, s: &str) -> String {
44 pub fn red(&self, s: &str) -> String {
42− self.wrap("31", s)
45+ self.wrap("91", s)
43 }
46 }
44 pub fn blue(&self, s: &str) -> String {
47 pub fn blue(&self, s: &str) -> String {
45− self.wrap("34", s)
48+ self.wrap("94", s)
46 }
49 }
47 pub fn green(&self, s: &str) -> String {
50 pub fn green(&self, s: &str) -> String {
48− self.wrap("32", s)
51+ self.wrap("92", s)
49 }
52 }
50 pub fn yellow(&self, s: &str) -> String {
53 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)
52 }
68 }
53 pub fn grey(&self, bright: u8, s: &str) -> String {
69 pub fn grey(&self, bright: u8, s: &str) -> String {
54 // grey scale: brighter when more recent
70 // grey scale: brighter when more recent
55 self.wrap(&format!("38;5;{}", 245 + bright.min(10)), s)
71 self.wrap(&format!("38;5;{}", 245 + bright.min(10)), s)
56 }
72 }
57 pub fn accent(&self, level: u8, s: &str) -> String {
73 pub fn accent(&self, level: u8, s: &str) -> String {
58− // size bar: one accent colour, brighter with size
74+ // Size remains encoded by the bar's height, with a vivid cool-to-warm ramp.
59− let codes = ["38;5;240", "38;5;36", "38;5;32", "38;5;39", "38;5;87"];
75+ let codes = ["96", "94", "95", "93", "1;93"];
60 self.wrap(codes[level.min(4) as usize], s)
76 self.wrap(codes[level.min(4) as usize], s)
61 }
77 }
62 pub fn author(&self, name: &str, s: &str) -> String {
78 pub fn author(&self, name: &str, s: &str) -> String {
@@ -66,7 +82,7 @@ impl Palette {
66 h = h.wrapping_mul(33).wrapping_add(b as u32);
82 h = h.wrapping_mul(33).wrapping_add(b as u32);
67 }
83 }
68 let hue = (h % 6) as u8;
84 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];
70 self.wrap(code, s)
86 self.wrap(code, s)
71 }
87 }
72 /// colour with a raw ANSI code (used for meaning-coloured glyphs)
88 /// colour with a raw ANSI code (used for meaning-coloured glyphs)
@@ -98,7 +114,8 @@ pub fn color_enabled(mode: &str) -> bool {
98 match mode {
114 match mode {
99 "always" => true,
115 "always" => true,
100 "never" => false,
116 "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(),
102 }
119 }
103}
120}
104
121
@@ -154,11 +171,16 @@ fn field_set(v: &Value) -> Option<BTreeSet<String>> {
154
171
155fn shape_of_record(interp: &Interp, v: &Value) -> Option<String> {
172fn shape_of_record(interp: &Interp, v: &Value) -> Option<String> {
156 let fs = field_set(v)?;
173 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"] {
158 if let Some(s) = interp.shapes.shape_of(name) {
180 if let Some(s) = interp.shapes.shape_of(name) {
159 if let ShapeKind::Record(want) = &s.kind {
181 if let ShapeKind::Record(want) = &s.kind {
160 if want == &fs {
182 if want == &fs {
161− return Some(name.clone());
183+ return Some(name.to_string());
162 }
184 }
163 }
185 }
164 }
186 }
@@ -175,35 +197,89 @@ pub fn is_commit(interp: &Interp, v: &Value) -> bool {
175// ----------------------------------------------------------------------
197// ----------------------------------------------------------------------
176
198
177pub fn display(interp: &mut Interp, v: &Value, color: bool) -> Result<String, Crash> {
199pub 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> {
178 let pal = Palette { on: color };
210 let pal = Palette { on: color };
179 let mut out = String::new();
211 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+ }
181 if !out.ends_with('\n') {
243 if !out.ends_with('\n') {
182 out.push('\n');
244 out.push('\n');
183 }
245 }
184 Ok(out)
246 Ok(out)
185}
247}
186
248
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+
187fn display_id(interp: &Interp, id: &str, pal: &Palette) -> String {
265fn display_id(interp: &Interp, id: &str, pal: &Palette) -> String {
188− let p = interp.backend.unique_prefix(id);
266+ let p = interp.unique_prefix(id);
189 let n = p.len();
267 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..]))
191}
269}
192
270
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> {
194 let v = &v.forced()?;
277 let v = &v.forced()?;
195 match v {
278 match v {
196 Value::Thunk(_) => unreachable!("forced never returns a thunk"),
279 Value::Thunk(_) => unreachable!("forced never returns a thunk"),
197− Value::Int(n) => Ok(n.to_string()),
280+ Value::Int(n) => Ok(pal.yellow(&n.to_string())),
198− Value::Bool(b) => Ok(b.to_string()),
281+ Value::Bool(b) => Ok(if *b { pal.green("true") } else { pal.red("false") }),
199− Value::Text(t) => {
282+ Value::Text(t) => Ok(first_line(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− }
207 Value::Id(id) => Ok(display_id(interp, id, pal)),
283 Value::Id(id) => Ok(display_id(interp, id, pal)),
208 Value::Blob(b) => {
284 Value::Blob(b) => {
209 let size = human_size(b.size());
285 let size = human_size(b.size());
@@ -213,43 +289,19 @@ fn display_line(interp: &Interp, v: &Value, pal: &Palette) -> Result<String, Cra
213 Ok(format!("‹{}›", size))
289 Ok(format!("‹{}›", size))
214 }
290 }
215 }
291 }
216− Value::Fun(f) => Ok(crate::show::show(&Interp::dummy(), &Value::Fun(f.clone()))),
292+ // a lambda renders as its source (§5.2), which may run over several
217− Value::Shape(s) => Ok(s.name.clone()),
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)),
218 Value::Record(_) => {
296 Value::Record(_) => {
219 if let Some(shape) = shape_of_record(interp, v) {
297 if let Some(shape) = shape_of_record(interp, v) {
220 match shape.as_str() {
298 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),
222 "Entry" => return entry_line(interp, v, pal),
300 "Entry" => return entry_line(interp, v, pal),
223− "Subtree" => {
301+ "Subtree" => return line_in(interp, &v.field("root")?, pal, loaded, false),
224− return commit_line(
302+ // its focus's line, drawn as the focus
225− interp,
303+ "Repo" => return line_in(interp, &v.field("root")?, pal, loaded, true),
226− &v.field("root")?,
304+ "Frame" => return line_in(interp, &v.field("parent")?, pal, loaded, false),
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− }
253 "Change" => {
305 "Change" => {
254 let n = touched_paths(v)?.len();
306 let n = touched_paths(v)?.len();
255 return Ok(format!("{} paths", n));
307 return Ok(format!("{} paths", n));
@@ -264,6 +316,18 @@ fn display_line(interp: &Interp, v: &Value, pal: &Palette) -> Result<String, Cra
264 }
316 }
265}
317}
266
318
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+
267fn list_line(_interp: &Interp, xs: &[Value], pal: &Palette) -> Result<String, Crash> {
331fn list_line(_interp: &Interp, xs: &[Value], pal: &Palette) -> Result<String, Crash> {
268 if xs.is_empty() {
332 if xs.is_empty() {
269 return Ok("0 items".into());
333 return Ok("0 items".into());
@@ -300,14 +364,13 @@ fn list_line(_interp: &Interp, xs: &[Value], pal: &Palette) -> Result<String, Cr
300 let parts: Vec<String> = xs
364 let parts: Vec<String> = xs
301 .iter()
365 .iter()
302 .map(|x| match x {
366 .map(|x| match x {
303− Value::Int(n) => n.to_string(),
367+ Value::Int(n) => pal.yellow(&n.to_string()),
304− Value::Bool(b) => b.to_string(),
368+ Value::Bool(b) => if *b { pal.green("true") } else { pal.red("false") },
305 _ => unreachable!(),
369 _ => unreachable!(),
306 })
370 })
307 .collect();
371 .collect();
308 return Ok(parts.join(", "));
372 return Ok(parts.join(", "));
309 }
373 }
310− let _ = pal;
311 Ok(format!("{} items", xs.len()))
374 Ok(format!("{} items", xs.len()))
312}
375}
313
376
@@ -320,9 +383,9 @@ fn entry_line(interp: &Interp, v: &Value, pal: &Palette) -> Result<String, Crash
320 };
383 };
321 let _ = interp;
384 let _ = interp;
322 if unresolved {
385 if unresolved {
323− Ok(format!("{} {} {}", pal.red("✖"), path, size))
386+ Ok(format!("{} {} {}", pal.red("✖"), pal.cyan(&path), pal.red(&size)))
324 } else {
387 } else {
325− Ok(format!(" {} {}", path, size))
388+ Ok(format!(" {} {}", pal.cyan(&path), pal.yellow(&size)))
326 }
389 }
327}
390}
328
391
@@ -335,37 +398,381 @@ fn path_string(p: &Value) -> Result<String, Crash> {
335 Ok(parts.join("/"))
398 Ok(parts.join("/"))
336}
399}
337
400
401+/// entries of one snapshot, keyed by path components
402+type SnapMap = BTreeMap<Vec<String>, Value>;
403+
338fn touched_paths(change: &Value) -> Result<Vec<(String, char, bool)>, Crash> {
404fn touched_paths(change: &Value) -> Result<Vec<(String, char, bool)>, Crash> {
339− // (path, mark, unresolved)
340 let from = snapshot_map_of(&change.field("from")?)?;
405 let from = snapshot_map_of(&change.field("from")?)?;
341 let to = snapshot_map_of(&change.field("to")?)?;
406 let to = snapshot_map_of(&change.field("to")?)?;
342− let mut paths: BTreeSet<Vec<String>> = BTreeSet::new();
407+ Ok(touched_in_maps(&from, &to)?
343− paths.extend(from.keys().cloned());
408+ .into_iter()
344− paths.extend(to.keys().cloned());
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> {
345 let mut out = Vec::new();
418 let mut out = Vec::new();
346− for p in paths {
419+ let mut fi = from.iter().peekable();
347− let f = from.get(&p);
420+ let mut ti = to.iter().peekable();
348− let t = to.get(&p);
421+ loop {
349− let mark = match (f, t) {
422+ let ord = match (fi.peek(), ti.peek()) {
350− (None, Some(_)) => '+',
423+ (None, None) => break,
351− (Some(_), None) => '−',
424+ (Some(_), None) => std::cmp::Ordering::Less,
352− (Some(a), Some(b)) => {
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();
353 if crate::value::value_eq(a, b)? {
440 if crate::value::value_eq(a, b)? {
354 continue;
441 continue;
355 }
442 }
356− '~'
443+ (p, '~', Some(b))
357 }
444 }
358− (None, None) => continue,
359 };
445 };
360 let unresolved = t
446 let unresolved = t
361 .map(|b| matches!(b, Value::Blob(bl) if bl.is_unresolved()))
447 .map(|b| matches!(b, Value::Blob(bl) if bl.is_unresolved()))
362 .unwrap_or(false);
448 .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+ ));
364 }
454 }
365 Ok(out)
455 Ok(out)
366}
456}
367
457
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> {
369 let mut m = BTreeMap::new();
776 let mut m = BTreeMap::new();
370 for e in snap.as_list()?.iter() {
777 for e in snap.as_list()?.iter() {
371 let path = e
778 let path = e
@@ -379,13 +786,24 @@ fn snapshot_map_of(snap: &Value) -> Result<BTreeMap<Vec<String>, Value>, Crash>
379 Ok(m)
786 Ok(m)
380}
787}
381
788
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+
382fn commit_line(
802fn commit_line(
383 interp: &Interp,
803 interp: &Interp,
384 c: &Value,
804 c: &Value,
385 pal: &Palette,
805 pal: &Palette,
386− focus: bool,
806+ standing: &Standing,
387− immutable: &BTreeSet<String>,
388− has_conflicts_hint: bool,
389) -> Result<String, Crash> {
807) -> Result<String, Crash> {
390 let id = match c.field("id")? {
808 let id = match c.field("id")? {
391 Value::Id(i) => i.to_string(),
809 Value::Id(i) => i.to_string(),
@@ -399,14 +817,20 @@ fn commit_line(
399 .iter()
817 .iter()
400 .map(|l| l.as_text().map(|s| s.to_string()))
818 .map(|l| l.as_text().map(|s| s.to_string()))
401 .collect::<Result<_, _>>()?;
819 .collect::<Result<_, _>>()?;
402− let glyph = node_glyph(interp, c, &id, focus, immutable, pal)?;
820+ let glyph = node_glyph(c, &id, standing)?;
403− let id_s = display_id(interp, &id, pal);
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);
404 let labels_s = if labels.is_empty() {
829 let labels_s = if labels.is_empty() {
405 String::new()
830 String::new()
406 } else {
831 } else {
407− pal.green(&labels.join(" "))
832+ pal.label(&labels.join(" "))
408 };
833 };
409− let _ = has_conflicts_hint;
410 Ok(if labels_s.is_empty() {
834 Ok(if labels_s.is_empty() {
411 format!("{} {} {}", glyph, id_s, msg_first)
835 format!("{} {} {}", glyph, id_s, msg_first)
412 } else {
836 } else {
@@ -414,28 +838,23 @@ fn commit_line(
414 })
838 })
415}
839}
416
840
417−fn node_glyph(
841+/// The glyph of a commit line, by `tree`'s precedence (`glyph_for`).
418− interp: &Interp,
842+fn node_glyph(c: &Value, id: &str, standing: &Standing) -> Result<String, Crash> {
419− c: &Value,
420− id: &str,
421− focus: bool,
422− immutable: &BTreeSet<String>,
423− pal: &Palette,
424−) -> Result<String, Crash> {
425 let g = if id == ROOT_ID {
843 let g = if id == ROOT_ID {
426 "⌂"
844 "⌂"
427 } else if has_conflict(c)? {
845 } else if has_conflict(c)? {
428 "⊗"
846 "⊗"
429− } else if is_empty_commit(interp, c)? {
847+ } else if standing.empty {
430 "◌"
848 "◌"
431− } else if immutable.contains(id) {
849+ } else if standing.immutable {
432 "◆"
850 "◆"
433− } else if focus {
851+ } else if standing.focus {
434 "◉"
852 "◉"
853+ } else if standing.ancestor {
854+ "●"
435 } else {
855 } else {
436 "○"
856 "○"
437 };
857 };
438− let _ = pal;
439 Ok(g.to_string())
858 Ok(g.to_string())
440}
859}
441
860
@@ -451,18 +870,17 @@ fn has_conflict(c: &Value) -> Result<bool, Crash> {
451 Ok(false)
870 Ok(false)
452}
871}
453
872
454−/// empty: no change against its parent — needs the parent; we only compute
873+/// `v` in block form, the blocks it holds left on `todo` (see `Block`)
455−/// this meaningfully in tree rendering. In line display, treat as false.
874+#[allow(clippy::too_many_arguments)]
456−fn is_empty_commit(_interp: &Interp, _c: &Value) -> Result<bool, Crash> {
457− Ok(false)
458−}
459−
460fn display_block(
875fn display_block(
461 interp: &mut Interp,
876 interp: &mut Interp,
462 v: &Value,
877 v: &Value,
463 pal: &Palette,
878 pal: &Palette,
464 indent: usize,
879 indent: usize,
465 out: &mut String,
880 out: &mut String,
881+ loaded: &mut Option<Loaded>,
882+ todo: &mut Vec<Block>,
883+ term_w: Option<usize>,
466) -> Result<(), Crash> {
884) -> Result<(), Crash> {
467 let pad = " ".repeat(indent);
885 let pad = " ".repeat(indent);
468 let v = &v.forced()?;
886 let v = &v.forced()?;
@@ -477,18 +895,31 @@ fn display_block(
477 out.push_str(&indent_multiline(&content, indent));
895 out.push_str(&indent_multiline(&content, indent));
478 return Ok(());
896 return Ok(());
479 }
897 }
480− Value::Int(_) | Value::Bool(_) | Value::Id(_) | Value::Fun(_) | Value::Shape(_) => {
898+ Value::Int(_) | Value::Bool(_) | Value::Id(_) | Value::Shape(_) => {
481 out.push_str(&pad);
899 out.push_str(&pad);
482− out.push_str(&display_line(interp, v, pal)?);
900+ out.push_str(&display_line(interp, v, pal, loaded)?);
483 out.push('\n');
901 out.push('\n');
484 return Ok(());
902 return Ok(());
485 }
903 }
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),
487 Value::Record(_) => {}
915 Value::Record(_) => {}
488 }
916 }
489 if let Some(shape) = shape_of_record(interp, v) {
917 if let Some(shape) = shape_of_record(interp, v) {
490 match shape.as_str() {
918 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+ }
492 "Entry" => {
923 "Entry" => {
493 out.push_str(&pad);
924 out.push_str(&pad);
494 out.push_str(&entry_line(interp, v, pal)?);
925 out.push_str(&entry_line(interp, v, pal)?);
@@ -502,7 +933,7 @@ fn display_block(
502 ("children", v.field("children")?),
933 ("children", v.field("children")?),
503 ("context", Value::list(vec![])),
934 ("context", Value::list(vec![])),
504 ]);
935 ]);
505− let text = tree_render(interp, &opts, &repo, pal, false)?;
936+ let text = tree_render(interp, &opts, &repo, pal, false, term_w)?;
506 out.push_str(&indent_multiline(&text, indent));
937 out.push_str(&indent_multiline(&text, indent));
507 return Ok(());
938 return Ok(());
508 }
939 }
@@ -518,30 +949,14 @@ fn display_block(
518 }
949 }
519 "Frame" => {
950 "Frame" => {
520 out.push_str(&pad);
951 out.push_str(&pad);
521− out.push_str(&commit_line(
952+ out.push_str(&line_in(interp, &v.field("parent")?, pal, loaded, false)?);
522− interp,
523− &v.field("parent")?,
524− pal,
525− false,
526− &BTreeSet::new(),
527− false,
528− )?);
529 out.push('\n');
953 out.push('\n');
530 return Ok(());
954 return Ok(());
531 }
955 }
532 "Change" => {
956 "Change" => {
533− for (p, mark, unresolved) in touched_paths(v)? {
957+ for (p, mark, _) in touched_paths(v)? {
534 out.push_str(&pad);
958 out.push_str(&pad);
535− let ms = mark.to_string();
959+ out.push_str(&format!("{} {}\n", colored_mark(mark, pal), p));
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));
545 }
960 }
546 return Ok(());
961 return Ok(());
547 }
962 }
@@ -554,19 +969,68 @@ fn display_block(
554 _ => unreachable!(),
969 _ => unreachable!(),
555 };
970 };
556 let name_w = m.keys().map(|k| width(k)).max().unwrap_or(0);
971 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)) {
558 out.push_str(&pad);
1000 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)));
560 out.push_str(" ");
1007 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+ }
561 // lists print in block form under their key (§5.1)
1023 // lists print in block form under their key (§5.1)
562 if matches!(x, Value::List(xs) if !xs.is_empty()) {
1024 if matches!(x, Value::List(xs) if !xs.is_empty()) {
563 out.push('\n');
1025 out.push('\n');
564− display_block(interp, x, pal, indent + name_w + 2, out)?;
1026+ let after = Some(k.clone());
565− } else {
1027+ todo.push(Block::Fields { m: m.clone(), after, name_w, indent });
566− let line = display_line(interp, x, pal)?;
1028+ todo.push(Block::Of(x.clone(), indent + name_w + 2));
567− out.push_str(&line);
1029+ return Ok(());
568− out.push('\n');
569 }
1030 }
1031+ let line = display_line(interp, x, pal, loaded)?;
1032+ out.push_str(&line);
1033+ out.push('\n');
570 }
1034 }
571 Ok(())
1035 Ok(())
572}
1036}
@@ -583,42 +1047,204 @@ fn indent_multiline(s: &str, indent: usize) -> String {
583 + "\n"
1047 + "\n"
584}
1048}
585
1049
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+
586fn display_commit_block(
1188fn display_commit_block(
587 interp: &mut Interp,
1189 interp: &mut Interp,
588 c: &Value,
1190 c: &Value,
589 pal: &Palette,
1191 pal: &Palette,
590 indent: usize,
1192 indent: usize,
591 out: &mut String,
1193 out: &mut String,
1194+ loaded: &Loaded,
592) -> Result<(), Crash> {
1195) -> Result<(), Crash> {
593 let pad = " ".repeat(indent);
1196 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)
598 let id = match c.field("id")? {
1197 let id = match c.field("id")? {
599 Value::Id(i) => i.to_string(),
1198 Value::Id(i) => i.to_string(),
600 _ => String::new(),
1199 _ => String::new(),
601 };
1200 };
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());
603 if let Ok(meta) = interp.backend.meta(&id) {
1219 if let Ok(meta) = interp.backend.meta(&id) {
604 let age = render_age(meta.time);
1220 let age = render_age(meta.time);
605 out.push_str(&pad);
1221 out.push_str(&pad);
606− out.push_str(&format!(
1222+ out.push_str(&format!(" {} · {} · {} files\n", meta.author, age, n));
607− " {} · {} · {} files\n",
608− meta.author,
609− age,
610− files.len()
611− ));
612 }
1223 }
613− for e in files.iter() {
1224+ if n > 0 {
614− let path = path_string(&e.field("path")?)?;
1225+ out.push('\n');
615− let content = e.field("content")?;
1226+ }
616− let unresolved = matches!(&content, Value::Blob(b) if b.is_unresolved());
1227+ match &touched {
617− out.push_str(&pad);
1228+ Some(touched) => {
618− if unresolved {
1229+ for (path, mark, _) in touched {
619− out.push_str(&format!(" {} {}\n", pal.red("✖"), path));
1230+ out.push_str(&pad);
620− } else {
1231+ out.push_str(&format!(
621− out.push_str(&format!(" {}\n", path));
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+ }
622 }
1248 }
623 }
1249 }
624 Ok(())
1250 Ok(())
@@ -663,19 +1289,25 @@ pub fn render_date(time: i64) -> String {
663 format!("{:04}-{:02}-{:02}", y, m, d)
1289 format!("{:04}-{:02}-{:02}", y, m, d)
664}
1290}
665
1291
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
667fn top_of(interp: &Interp, _x: &Value) -> Result<Value, Crash> {
1294fn top_of(interp: &Interp, _x: &Value) -> Result<Value, Crash> {
668− let old = interp.old_repo.borrow().clone();
1295+ given_repo(interp).ok_or_else(|| Crash::new("display: no repository in context"))
669− old.ok_or_else(|| Crash::new("display: no repository in context"))
670}
1296}
671
1297
1298+/// `list` in block form, the blocks it holds left on `todo` (see `Block`)
1299+#[allow(clippy::too_many_arguments)]
672fn display_list_block(
1300fn display_list_block(
673 interp: &mut Interp,
1301 interp: &mut Interp,
674− xs: &[Value],
1302+ list: &ListVal,
675 pal: &Palette,
1303 pal: &Palette,
676 indent: usize,
1304 indent: usize,
677 out: &mut String,
1305 out: &mut String,
1306+ loaded: &mut Option<Loaded>,
1307+ todo: &mut Vec<Block>,
1308+ term_w: Option<usize>,
678) -> Result<(), Crash> {
1309) -> Result<(), Crash> {
1310+ let xs: &[Value] = list;
679 let pad = " ".repeat(indent);
1311 let pad = " ".repeat(indent);
680 if xs.is_empty() {
1312 if xs.is_empty() {
681 out.push_str(&pad);
1313 out.push_str(&pad);
@@ -685,22 +1317,29 @@ fn display_list_block(
685 }
1317 }
686 // list of Id: commit table (one commit line per id, in list order)
1318 // list of Id: commit table (one commit line per id, in list order)
687 if xs.iter().all(|x| matches!(x, Value::Id(_))) {
1319 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)?;
688 for x in xs {
1330 for x in xs {
689 let id = match x {
1331 let id = match x {
690 Value::Id(i) => i.to_string(),
1332 Value::Id(i) => i.to_string(),
691 _ => unreachable!(),
1333 _ => unreachable!(),
692 };
1334 };
693 out.push_str(&pad);
1335 out.push_str(&pad);
694− let repo = top_of(interp, x)?;
1336+ match commits.get(&id) {
695− match crate::repo::by_id(&repo, &id)? {
1337+ Some(c) => {
696− Some(loc) => {
1338+ let c = c.clone();
697− let c = loc.field("root")?;
1339+ // the glyph of its line form (§5.1)
698− let conflict = has_conflict(&c)?;
1340+ let glyph = node_glyph(&c, &id, &loaded.line_standing(interp, &c)?)?;
699− let glyph = if conflict {
1341+ let conflict = glyph == "⊗";
700− pal.red("⊗")
1342+ let glyph = pal.code(node_ansi(&glyph), &glyph);
701− } else {
702− "○".to_string()
703− };
704 let msg = c.field("message")?.as_text()?.to_string();
1343 let msg = c.field("message")?.as_text()?.to_string();
705 let msg_first = msg.lines().next().unwrap_or("");
1344 let msg_first = msg.lines().next().unwrap_or("");
706 let labels: Vec<String> = c
1345 let labels: Vec<String> = c
@@ -710,7 +1349,7 @@ fn display_list_block(
710 .map(|l| l.as_text().map(|s| s.to_string()))
1349 .map(|l| l.as_text().map(|s| s.to_string()))
711 .collect::<Result<_, _>>()?;
1350 .collect::<Result<_, _>>()?;
712 let id_s = if conflict {
1351 let id_s = if conflict {
713− pal.red(&interp.backend.unique_prefix(&id))
1352+ pal.red(&interp.unique_prefix(&id))
714 } else {
1353 } else {
715 display_id(interp, &id, pal)
1354 display_id(interp, &id, pal)
716 };
1355 };
@@ -722,7 +1361,7 @@ fn display_list_block(
722 glyph,
1361 glyph,
723 id_s,
1362 id_s,
724 msg_first,
1363 msg_first,
725− pal.green(&labels.join(" "))
1364+ pal.label(&labels.join(" "))
726 ));
1365 ));
727 }
1366 }
728 }
1367 }
@@ -781,17 +1420,22 @@ fn display_list_block(
781 _ => (String::new(), false),
1420 _ => (String::new(), false),
782 };
1421 };
783 out.push_str(&pad);
1422 out.push_str(&pad);
784− let mark = if unresolved { "✖" } else { " " };
1423+ let mark = if unresolved { pal.red("✖") } else { " ".into() };
785 let size_s = if unresolved {
1424 let size_s = if unresolved {
786 pal.red(&size)
1425 pal.red(&size)
787 } else {
1426 } else {
788− size
1427+ pal.yellow(&size)
789 };
1428 };
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+ ));
791 }
1435 }
792 return Ok(());
1436 return Ok(());
793 }
1437 }
794− return display_table(interp, xs, pal, indent, out);
1438+ return display_table(interp, xs, pal, indent, out, loaded, term_w);
795 }
1439 }
796 }
1440 }
797 // list of other scalars
1441 // list of other scalars
@@ -804,13 +1448,8 @@ fn display_list_block(
804 out.push('\n');
1448 out.push('\n');
805 return Ok(());
1449 return Ok(());
806 }
1450 }
807− // one block per item, separated by a blank line
1451+ // one block per item, separated by a blank line (see `Block`)
808− for (i, x) in xs.iter().enumerate() {
1452+ todo.push(Block::Items { xs: list.clone(), next: 0, indent });
809− if i > 0 {
810− out.push('\n');
811− }
812− display_block(interp, x, pal, indent, out)?;
813− }
814 Ok(())
1453 Ok(())
815}
1454}
816
1455
@@ -820,18 +1459,38 @@ fn display_table(
820 pal: &Palette,
1459 pal: &Palette,
821 indent: usize,
1460 indent: usize,
822 out: &mut String,
1461 out: &mut String,
1462+ loaded: &mut Option<Loaded>,
1463+ term_w: Option<usize>,
823) -> Result<(), Crash> {
1464) -> Result<(), Crash> {
824 let pad = " ".repeat(indent);
1465 let pad = " ".repeat(indent);
825 let fields: Vec<String> = field_set(&xs[0]).unwrap().into_iter().collect();
1466 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()];
826 // cells
1474 // cells
827 let mut rows: Vec<Vec<String>> = Vec::new();
1475 let mut rows: Vec<Vec<String>> = Vec::new();
828 for x in xs {
1476 for x in xs {
829 let mut row = Vec::new();
1477 let mut row = Vec::new();
830− for f in &fields {
1478+ for (column, f) in fields.iter().enumerate() {
831 let cell = match x.field(f)? {
1479 let cell = match x.field(f)? {
832− Value::Bool(true) => "✓".to_string(),
1480+ Value::Bool(true) => pal.green("✓"),
833 Value::Bool(false) => String::new(),
1481 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)?,
835 };
1494 };
836 row.push(cell);
1495 row.push(cell);
837 }
1496 }
@@ -841,7 +1500,7 @@ fn display_table(
841 let keep: Vec<usize> = (0..fields.len())
1500 let keep: Vec<usize> = (0..fields.len())
842 .filter(|&i| rows.iter().any(|r| !r[i].is_empty()))
1501 .filter(|&i| rows.iter().any(|r| !r[i].is_empty()))
843 .collect();
1502 .collect();
844− let widths: Vec<usize> = keep
1503+ let mut widths: Vec<usize> = keep
845 .iter()
1504 .iter()
846 .map(|&i| {
1505 .map(|&i| {
847 rows.iter()
1506 rows.iter()
@@ -851,6 +1510,40 @@ fn display_table(
851 .max(width(&fields[i]))
1510 .max(width(&fields[i]))
852 })
1511 })
853 .collect();
1512 .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+ }
854 // header
1547 // header
855 out.push_str(&pad);
1548 out.push_str(&pad);
856 let header: Vec<String> = keep
1549 let header: Vec<String> = keep
@@ -858,14 +1551,14 @@ fn display_table(
858 .zip(&widths)
1551 .zip(&widths)
859 .map(|(&i, w)| pad_right(&fields[i], *w))
1552 .map(|(&i, w)| pad_right(&fields[i], *w))
860 .collect();
1553 .collect();
861− out.push_str(&pal.dim(&header.join(" ")));
1554+ out.push_str(&pal.field(&header.join(" ")));
862 out.push('\n');
1555 out.push('\n');
863 for r in &rows {
1556 for r in &rows {
864 out.push_str(&pad);
1557 out.push_str(&pad);
865 let cells: Vec<String> = keep
1558 let cells: Vec<String> = keep
866 .iter()
1559 .iter()
867 .zip(&widths)
1560 .zip(&widths)
868− .map(|(&i, w)| pad_right(&r[i], *w))
1561+ .map(|(&i, w)| pad_right(&cut_to_width(&r[i], *w), *w))
869 .collect();
1562 .collect();
870 out.push_str(&cells.join(" "));
1563 out.push_str(&cells.join(" "));
871 out.push('\n');
1564 out.push('\n');
@@ -906,7 +1599,28 @@ fn default_tree_options(_interp: &Interp) -> Result<TreeOptions, Crash> {
906 })
1599 })
907}
1600}
908
1601
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+
909pub fn tree_with(interp: &mut Interp, opts: &Value, repo: &Value) -> Result<Value, Crash> {
1612pub 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> {
910 let defaults = default_tree_options(interp)?;
1624 let defaults = default_tree_options(interp)?;
911 // a missing option falls back to its default, so an older config that
1625 // a missing option falls back to its default, so an older config that
912 // predates a newer option keeps working; a present option is type-checked
1626 // 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
953 date,
1667 date,
954 files,
1668 files,
955 };
1669 };
956− let text = tree_render(interp, &o, repo, &pal, true)?;
1670+ let text = tree_render(interp, &o, repo, &pal, true, term_w)?;
957 Ok(Value::text(text))
1671 Ok(Value::text(text))
958}
1672}
959
1673
@@ -968,8 +1682,8 @@ struct CommitInfo {
968 is_ancestor_of_focus: bool,
1682 is_ancestor_of_focus: bool,
969 is_child_of_ancestor: bool,
1683 is_child_of_ancestor: bool,
970 meta: Option<crate::domain::MetaInfo>,
1684 meta: Option<crate::domain::MetaInfo>,
971− size: Option<usize>, // lines added+removed against parent
1685+ size: Option<usize>, // lines added+removed against parent, up to SIZE_BAR_TOP
972− nfiles: usize, // number of files in this commit's snapshot
1686+ nfiles: usize, // number of files changed against parent (`files` column)
973 detail_marks: Vec<(String, char)>,
1687 detail_marks: Vec<(String, char)>,
974 children: Vec<CommitInfo>,
1688 children: Vec<CommitInfo>,
975}
1689}
@@ -995,6 +1709,49 @@ impl CommitInfo {
995 }
1709 }
996}
1710}
997
1711
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+
998#[allow(clippy::too_many_arguments)]
1755#[allow(clippy::too_many_arguments)]
999fn build_info(
1756fn build_info(
1000 interp: &mut Interp,
1757 interp: &mut Interp,
@@ -1004,7 +1761,7 @@ fn build_info(
1004 immutable: &BTreeSet<String>,
1761 immutable: &BTreeSet<String>,
1005 focus_id: &str,
1762 focus_id: &str,
1006 anc: &BTreeSet<String>,
1763 anc: &BTreeSet<String>,
1007− parent_files: Option<&Value>,
1764+ parent_files: Option<&ParentSnap<'_>>,
1008 with_focus: bool,
1765 with_focus: bool,
1009 parent_is_ancestor: bool,
1766 parent_is_ancestor: bool,
1010 parent_is_focus: bool,
1767 parent_is_focus: bool,
@@ -1021,9 +1778,13 @@ fn build_info(
1021 .map(|l| l.as_text().map(|s| s.to_string()))
1778 .map(|l| l.as_text().map(|s| s.to_string()))
1022 .collect::<Result<_, _>>()?;
1779 .collect::<Result<_, _>>()?;
1023 // conflict and empty are O(1) backend queries when the backend can answer
1780 // 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
1781+ // them from tree ids (jj), without touching the file list at all. Those
1025− // in-memory backend cannot, so fall back to walking the files
1782+ // answers are about the stored commits, so they are asked only while this
1026− let conflict = match interp.backend.has_conflict(&id) {
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)) {
1027 Some(c) => c,
1788 Some(c) => c,
1028 None => has_conflict(commit)?,
1789 None => has_conflict(commit)?,
1029 };
1790 };
@@ -1036,56 +1797,67 @@ fn build_info(
1036 matches!(c.field("root").and_then(|r| r.field("id")), Ok(Value::Id(i)) if *i == focus_id)
1797 matches!(c.field("root").and_then(|r| r.field("id")), Ok(Value::Id(i)) if *i == focus_id)
1037 });
1798 });
1038 let want_diff = opts.detail >= 2 || is_focus_pre || child_is_focus || parent_is_focus;
1799 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+ };
1040 // the files list is only materialized when something needs it: the diff
1814 // the files list is only materialized when something needs it: the diff
1041 // (detail/focus), the files data column, or the emptiness fallback
1815 // (detail/focus), the files data column, or the emptiness fallback
1042− let need_files = want_diff || opts.files || backend_empty.is_none();
1816+ let (empty, size, detail_marks, nfiles) = match parent_files {
1043− let files = if need_files {
1817+ Some(parent) => {
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) => {
1051 let empty = match backend_empty {
1818 let empty = match backend_empty {
1052 Some(e) => e,
1819 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()?)?,
1054 };
1822 };
1055 if want_diff {
1823 if want_diff {
1056− let files = files.as_ref().expect("files loaded");
1824+ // both maps are built at most once per commit and reused by
1057− let change = Value::record(&[("from", pf.clone()), ("to", files.clone())]);
1825+ // this commit's children, so a diff costs one keying, not four
1058− let marks = touched_paths(&change)?;
1826+ let from_map = parent.map()?;
1059− // size: lines added+removed against the parent
1827+ let to_map = my_snap.map()?;
1060− let from_map = snapshot_map_of(pf)?;
1828+ let marks = touched_in_maps(from_map, to_map)?;
1061− let to_map = snapshot_map_of(files)?;
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.
1062 let mut lines = 0usize;
1833 let mut lines = 0usize;
1063− for (p, _, _) in &marks {
1834+ for (key, _, _) in &marks {
1064− let key: Vec<String> = p.split('/').map(|s| s.to_string()).collect();
1835+ if lines >= SIZE_BAR_TOP {
1065− let count = |m: &BTreeMap<Vec<String>, Value>| -> usize {
1836+ break;
1066− match m.get(&key) {
1837+ }
1067− Some(Value::Blob(b)) => {
1838+ let cap = SIZE_BAR_TOP - lines;
1068− let n = b
1839+ lines += changed_lines(from_map.get(key), to_map.get(key), cap)?.max(1);
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);
1078 }
1840 }
1079 let marks2: Vec<(String, char)> =
1841 let marks2: Vec<(String, char)> =
1080− marks.iter().map(|(p, m, _)| (p.clone(), *m)).collect();
1842+ marks.iter().map(|(p, m, _)| (p.join("/"), *m)).collect();
1081− (marks.is_empty(), Some(lines), marks2)
1843+ (marks.is_empty(), Some(lines), marks2, marks.len())
1082 } else {
1844 } 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)
1084 }
1853 }
1085 }
1854 }
1086 None => {
1855 None => {
1087− let n = nfiles;
1856+ // the top of the history: no parent to compare against, and so no
1088− (n == 0 && id != ROOT_ID, None, Vec::new())
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)
1089 }
1861 }
1090 };
1862 };
1091 let meta = interp.backend.meta(&id).ok();
1863 let meta = interp.backend.meta(&id).ok();
@@ -1101,7 +1873,7 @@ fn build_info(
1101 immutable,
1873 immutable,
1102 focus_id,
1874 focus_id,
1103 anc,
1875 anc,
1104− files.as_ref(),
1876+ Some(&my_snap),
1105 with_focus,
1877 with_focus,
1106 is_ancestor_of_focus,
1878 is_ancestor_of_focus,
1107 is_focus_pre,
1879 is_focus_pre,
@@ -1131,6 +1903,7 @@ fn tree_render(
1131 repo: &Value,
1903 repo: &Value,
1132 pal: &Palette,
1904 pal: &Palette,
1133 with_focus: bool,
1905 with_focus: bool,
1906+ term_w: Option<usize>,
1134) -> Result<String, Crash> {
1907) -> Result<String, Crash> {
1135 // immutable set
1908 // immutable set
1136 let immutable = crate::repo::compute_immutable(interp, repo).unwrap_or_default();
1909 let immutable = crate::repo::compute_immutable(interp, repo).unwrap_or_default();
@@ -1184,39 +1957,37 @@ fn tree_render(
1184 // Step 2 — row order (topological, oldest first)
1957 // Step 2 — row order (topological, oldest first)
1185 let mut flat: Vec<&Display> = Vec::new();
1958 let mut flat: Vec<&Display> = Vec::new();
1186 flatten_display(&droot, &mut flat);
1959 flatten_display(&droot, &mut flat);
1187− let preorder: BTreeMap<usize, usize> =
1960+ // Display uids are dense. Keep the preorder position as the heap's
1188− flat.iter().enumerate().map(|(i, n)| (n.uid(), i)).collect();
1961+ // second key, so a newly ready descendant wins a time tie with a later
1189− let mut ready: Vec<&Display> = vec![&droot];
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))]);
1190 let mut rows: Vec<&Display> = Vec::new();
1968 let mut rows: Vec<&Display> = Vec::new();
1191− while !ready.is_empty() {
1969+ while let Some(Reverse((_, i))) = ready.pop() {
1192− let best = ready
1970+ let n = flat[i];
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);
1199 rows.push(n);
1971 rows.push(n);
1200 for c in n.children() {
1972 for c in n.children() {
1201− ready.push(c);
1973+ ready.push(Reverse((c.time(), preorder[c.uid()])));
1202 }
1974 }
1203 }
1975 }
1204 // Step 3 — lanes
1976 // 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);
1206 // id column: shortest unique prefix among the display tree's commits, min 4
1979 // 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
1208 .iter()
1982 .iter()
1209− .filter_map(|n| n.commit().map(|c| c.id.as_str()))
1983+ .filter_map(|n| n.commit().map(|c| c.id.clone()))
1210 .collect();
1984 .collect();
1985+ ids.sort();
1211 let prefix_len = |id: &str| -> usize {
1986 let prefix_len = |id: &str| -> usize {
1212− let mut n = 4.min(id.len());
1987+ crate::eval::unique_prefix_in(&ids, id).len()
1213− while n < id.len() && ids.iter().any(|o| *o != id && o.starts_with(&id[..n])) {
1214− n += 1;
1215− }
1216− n
1217 };
1988 };
1218 // Step 4 — draw
1989 // 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)
1220}
1991}
1221
1992
1222fn id_of_frame_parent(frame: &Value) -> Result<String, Crash> {
1993fn id_of_frame_parent(frame: &Value) -> Result<String, Crash> {
@@ -1245,11 +2016,7 @@ fn top_id(repo: &Value) -> Result<String, Crash> {
1245fn compute_trunk(interp: &mut Interp, repo: &Value) -> Result<BTreeSet<String>, Crash> {
2016fn compute_trunk(interp: &mut Interp, repo: &Value) -> Result<BTreeSet<String>, Crash> {
1246 let mut set = BTreeSet::new();
2017 let mut set = BTreeSet::new();
1247 set.insert(top_id(repo)?);
2018 set.insert(top_id(repo)?);
1248− let trunk_fn = interp
2019+ let v = interp.apply_cached_revset("trunk", repo)?;
1249− .globals
1250− .lookup("trunk")
1251− .ok_or_else(|| Crash::new("`trunk` is not defined"))?;
1252− let v = interp.apply(trunk_fn, repo.clone())?;
1253 let mut named: Vec<String> = Vec::new();
2020 let mut named: Vec<String> = Vec::new();
1254 for idv in v.as_list()?.iter() {
2021 for idv in v.as_list()?.iter() {
1255 match idv {
2022 match idv {
@@ -1311,12 +2078,16 @@ impl Display {
1311 }
2078 }
1312 }
2079 }
1313
2080
1314− fn time(&self) -> Option<i64> {
2081+ /// The node's time for the row order (Step 1). A node with no stored
1315− match self {
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 {
1316 Display::Commit { info, .. } => info.meta.as_ref().map(|m| m.time),
2086 Display::Commit { info, .. } => info.meta.as_ref().map(|m| m.time),
1317 Display::Run { time, .. } => *time,
2087 Display::Run { time, .. } => *time,
1318 Display::Collapsed { info, .. } => info.meta.as_ref().map(|m| m.time),
2088 Display::Collapsed { info, .. } => info.meta.as_ref().map(|m| m.time),
1319− }
2089+ };
2090+ t.unwrap_or(i64::MAX)
1320 }
2091 }
1321
2092
1322 fn children(&self) -> &[Display] {
2093 fn children(&self) -> &[Display] {
@@ -1477,6 +2248,10 @@ enum Rail {
1477struct Placement {
2248struct Placement {
1478 lane: Option<usize>,
2249 lane: Option<usize>,
1479 fork: Option<(usize, bool)>, // (source lane, source empties below)
2250 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>,
1480 reservations: Vec<(usize, usize)>, // (lane, child uid)
2255 reservations: Vec<(usize, usize)>, // (lane, child uid)
1481 flattened: bool,
2256 flattened: bool,
1482}
2257}
@@ -1488,26 +2263,70 @@ fn mark_subtree(n: &Display, set: &mut BTreeSet<usize>) {
1488 }
2263 }
1489}
2264}
1490
2265
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> {
1492 let mut rails: Vec<Option<Rail>> = vec![None; lanes_n];
2312 let mut rails: Vec<Option<Rail>> = vec![None; lanes_n];
1493 let mut out: Vec<Placement> = Vec::new();
2313 let mut out: Vec<Placement> = Vec::new();
1494 let mut flattened: BTreeSet<usize> = BTreeSet::new();
2314 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;
1495 for (idx, n) in rows.iter().enumerate() {
2318 for (idx, n) in rows.iter().enumerate() {
1496 let in_trunk = n.in_trunk(trunk);
2319 let in_trunk = n.in_trunk(trunk);
1497− let parent = rows[..idx]
2320+ let parent_idx = index.parent[idx];
1498− .iter()
2321+ let parent = parent_idx.map(|i| rows[i]);
1499− .copied()
2322+ let parent_lane = parent_idx.and_then(|i| out[i].lane);
1500− .find(|p| p.children().iter().any(|c| c.uid() == n.uid()));
2323+ let is_last_child = parent_idx
1501− let parent_lane = parent.and_then(|p| out[rows[..idx].iter().position(|x| x.uid() == p.uid()).unwrap()].lane);
2324+ .map(|i| index.last_child[i] == Some(idx))
1502− let is_last_child = parent
1503− .map(|p| {
1504− let cs = p.children();
1505− cs[cs.len() - 1].uid() == n.uid()
1506− })
1507 .unwrap_or(false);
2325 .unwrap_or(false);
1508 let was_flat = flattened.contains(&n.uid());
2326 let was_flat = flattened.contains(&n.uid());
1509 let mut lane: Option<usize> = None;
2327 let mut lane: Option<usize> = None;
1510 let mut fork: Option<(usize, bool)> = None;
2328 let mut fork: Option<(usize, bool)> = None;
2329+ let mut ends: Option<usize> = None;
1511 if !was_flat {
2330 if !was_flat {
1512 if idx == 0 {
2331 if idx == 0 {
1513 lane = Some(0);
2332 lane = Some(0);
@@ -1533,7 +2352,18 @@ fn assign_lanes(rows: &[&Display], trunk: &BTreeSet<String>, lanes_n: usize) ->
1533 fork = Some((pl, false));
2352 fork = Some((pl, false));
1534 }
2353 }
1535 }
2354 }
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+ }
1537 }
2367 }
1538 }
2368 }
1539 } else {
2369 } else {
@@ -1555,8 +2385,13 @@ fn assign_lanes(rows: &[&Display], trunk: &BTreeSet<String>, lanes_n: usize) ->
1555 let mut reservations: Vec<(usize, usize)> = Vec::new();
2385 let mut reservations: Vec<(usize, usize)> = Vec::new();
1556 if !now_flat && in_trunk {
2386 if !now_flat && in_trunk {
1557 let cs = n.children();
2387 let cs = n.children();
1558− if let Some(tpos) = cs.iter().position(|c| c.in_trunk(trunk)) {
2388+ if let Some(t) = cs.iter().find(|c| c.in_trunk(trunk)) {
1559− for c in &cs[tpos + 1..] {
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 {
1560 if flattened.contains(&c.uid()) {
2395 if flattened.contains(&c.uid()) {
1561 continue;
2396 continue;
1562 }
2397 }
@@ -1573,6 +2408,7 @@ fn assign_lanes(rows: &[&Display], trunk: &BTreeSet<String>, lanes_n: usize) ->
1573 out.push(Placement {
2408 out.push(Placement {
1574 lane: if now_flat { None } else { lane },
2409 lane: if now_flat { None } else { lane },
1575 fork: if now_flat { None } else { fork },
2410 fork: if now_flat { None } else { fork },
2411+ ends,
1576 reservations,
2412 reservations,
1577 flattened: now_flat,
2413 flattened: now_flat,
1578 });
2414 });
@@ -1600,27 +2436,20 @@ struct RowText {
1600
2436
1601fn build_row_text(
2437fn build_row_text(
1602 n: &Display,
2438 n: &Display,
1603− rows: &[&Display],
2439+ focus_adjacent: bool,
1604 opts: &TreeOptions,
2440 opts: &TreeOptions,
1605 pal: &Palette,
2441 pal: &Palette,
1606 with_focus: bool,
2442 with_focus: bool,
1607 prefix_len: &dyn Fn(&str) -> usize,
2443 prefix_len: &dyn Fn(&str) -> usize,
1608− run_extra: usize,
1609 id_w: usize,
2444 id_w: usize,
1610) -> RowText {
2445) -> RowText {
1611 let c = n.commit();
2446 let c = n.commit();
1612 let is_focus = c.map(|x| x.is_focus).unwrap_or(false);
2447 let is_focus = c.map(|x| x.is_focus).unwrap_or(false);
1613− let gutter = if is_focus && with_focus { "▶ " } else { " " }.to_string();
2448+ let gutter = if is_focus && with_focus { pal.code("1;96", "▶ ") } else { " ".into() };
1614− // id (on a run row the count follows ╎; only the part that overflows the
2449+ // id (blank on a run row, whose count `rail_row` draws: the part that
1615− // rails area spills into the id column)
2450+ // overflows the rails area runs on into the id column)
1616 let id = match n {
2451 let id = match n {
1617− Display::Run { .. } => {
2452+ Display::Run { .. } => " ".repeat(id_w),
1618− let mut s = String::new();
1619− while width(&s) < run_extra + id_w {
1620− s.push(' ');
1621− }
1622− s
1623− }
1624 _ => match c {
2453 _ => match c {
1625 Some(info) => {
2454 Some(info) => {
1626 let k = prefix_len(&info.id);
2455 let k = prefix_len(&info.id);
@@ -1630,8 +2459,10 @@ fn build_row_text(
1630 pal.red(&format!("@{}", p))
2459 pal.red(&format!("@{}", p))
1631 } else if info.immutable {
2460 } else if info.immutable {
1632 pal.blue(&format!("@{}", p))
2461 pal.blue(&format!("@{}", p))
2462+ } else if info.is_focus {
2463+ pal.code("1;96", &format!("@{}", p))
1633 } else {
2464 } else {
1634− format!("@{}", p)
2465+ pal.id(&format!("@{}", p))
1635 };
2466 };
1636 let plain = k + 1; // +1 for the @
2467 let plain = k + 1; // +1 for the @
1637 let padded = if plain < id_w {
2468 let padded = if plain < id_w {
@@ -1645,23 +2476,8 @@ fn build_row_text(
1645 },
2476 },
1646 };
2477 };
1647 // size bar
2478 // 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);
1663 let show_bar = opts.detail >= 1
2479 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)
1665 .unwrap_or(false);
2481 .unwrap_or(false);
1666 let bar = if show_bar {
2482 let bar = if show_bar {
1667 c.map(|x| size_bar(x.size, pal)).unwrap_or_default()
2483 c.map(|x| size_bar(x.size, pal)).unwrap_or_default()
@@ -1693,22 +2509,20 @@ fn build_row_text(
1693 }
2509 }
1694 _ => (String::new(), String::new(), String::new()),
2510 _ => (String::new(), String::new(), String::new()),
1695 };
2511 };
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
1697 let mut meta_extra: Vec<String> = Vec::new();
2515 let mut meta_extra: Vec<String> = Vec::new();
1698 if let Some(info) = c {
2516 if let Some(info) = c {
1699 if info.id != ROOT_ID {
2517 if info.id != ROOT_ID {
1700 if opts.date {
2518 if opts.date {
1701− if let Some(m) = info.meta.as_ref() {
2519+ meta_extra.push(info.meta.as_ref().map(|m| render_date(m.time)).unwrap_or_default());
1702− meta_extra.push(render_date(m.time));
1703− }
1704 }
2520 }
1705 if opts.files {
2521 if opts.files {
1706 meta_extra.push(format!("{} files", info.nfiles));
2522 meta_extra.push(format!("{} files", info.nfiles));
1707 }
2523 }
1708 if opts.author {
2524 if opts.author {
1709− if let Some(m) = info.meta.as_ref() {
2525+ meta_extra.push(info.meta.as_ref().map(|m| m.author.clone()).unwrap_or_default());
1710− meta_extra.push(m.author.clone());
1711− }
1712 }
2526 }
1713 }
2527 }
1714 }
2528 }
@@ -1728,78 +2542,59 @@ fn build_row_text(
1728#[allow(clippy::too_many_arguments)]
2542#[allow(clippy::too_many_arguments)]
1729fn draw_rows(
2543fn draw_rows(
1730 rows: &[&Display],
2544 rows: &[&Display],
2545+ index: &RowIndex,
1731 placements: &[Placement],
2546 placements: &[Placement],
1732 opts: &TreeOptions,
2547 opts: &TreeOptions,
1733 pal: &Palette,
2548 pal: &Palette,
1734 lanes_n: usize,
2549 lanes_n: usize,
1735 with_focus: bool,
2550 with_focus: bool,
1736 prefix_len: &dyn Fn(&str) -> usize,
2551 prefix_len: &dyn Fn(&str) -> usize,
2552+ term_w: Option<usize>,
1737) -> Result<String, Crash> {
2553) -> Result<String, Crash> {
1738 let rail_chars = 2 * lanes_n;
2554 let rail_chars = 2 * lanes_n;
1739− // id column width: 4-char min prefix plus the `@` literal prefix, and any
2555+ let overflow = placements.iter().any(|p| p.flattened);
1740− // overflow of a run count past the rails area
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
1741 let mut id_w = 5usize;
2571 let mut id_w = 5usize;
1742− for (idx, n) in rows.iter().enumerate() {
2572+ for (chars, _) in &rails {
1743− if let Display::Run { count, .. } = n {
2573+ id_w = id_w.max(chars.len().saturating_sub(rail_chars + 1));
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− }
1754 }
2574 }
1755 for n in rows {
2575 for n in rows {
1756 if let Some(c) = n.commit() {
2576 if let Some(c) = n.commit() {
1757 id_w = id_w.max(prefix_len(&c.id) + 1); // +1 for the `@`
2577 id_w = id_w.max(prefix_len(&c.id) + 1); // +1 for the `@`
1758 }
2578 }
1759 }
2579 }
1760− let text_off = 2 + rail_chars + 1;
1761− let msg_off = text_off + id_w + 2;
1762 let label_col = rows
2580 let label_col = rows
1763 .iter()
2581 .iter()
1764 .any(|n| n.commit().map(|c| !c.labels.is_empty()).unwrap_or(false));
2582 .any(|n| n.commit().map(|c| !c.labels.is_empty()).unwrap_or(false));
1765 // build row texts
2583 // build row texts
1766 let mut texts: Vec<RowText> = Vec::new();
2584 let mut texts: Vec<RowText> = Vec::new();
1767− for (idx, n) in rows.iter().enumerate() {
2585+ for (i, n) in rows.iter().enumerate() {
1768− let run_extra = match (n, placements[idx].lane) {
2586+ texts.push(build_row_text(n, index.focus_adjacent[i], opts, pal, with_focus, prefix_len, id_w));
1769− (Display::Run { count, .. }, Some(l)) => {
2587+ }
1770− let digits = format!("{}", count).len();
2588+ // Columns 5–9 start at the same offset on every row (§Step 4). The size
1771− (2 * l + 1 + digits).saturating_sub(rail_chars)
2589+ // bar's column is there when any row draws a bar — ` ▅`, two columns —
1772− }
2590+ // and blank on the rows that draw none, so a bar never shifts a message.
1773− _ => 0,
2591+ let bar_col = texts.iter().any(|t| !t.bar.is_empty());
1774− };
2592+ // each row up to its message: gutter, rails, id and bar
1775− texts.push(build_row_text(
2593+ let mut heads: Vec<String> = Vec::new();
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();
1799 for (idx, n) in rows.iter().enumerate() {
2594 for (idx, n) in rows.iter().enumerate() {
1800 let pl = &placements[idx];
2595 let pl = &placements[idx];
1801 let t = &texts[idx];
2596 let t = &texts[idx];
1802− let (chars, lane0) = rail_row(rows, placements, idx, lanes_n, opts);
2597+ let (chars, lane0) = &rails[idx];
1803 let mut line = String::new();
2598 let mut line = String::new();
1804 line.push_str(&t.gutter);
2599 line.push_str(&t.gutter);
1805 // the node glyph (on a commit row) is meaning-coloured; rails lane-coloured
2600 // the node glyph (on a commit row) is meaning-coloured; rails lane-coloured
@@ -1807,59 +2602,119 @@ fn draw_rows(
1807 (Display::Commit { info, .. }, Some(l)) => Some((2 * l, glyph_ansi(info))),
2602 (Display::Commit { info, .. }, Some(l)) => Some((2 * l, glyph_ansi(info))),
1808 _ => None,
2603 _ => None,
1809 };
2604 };
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+ }
1811 line.push(' ');
2617 line.push(' ');
1812 line.push_str(&t.id);
2618 line.push_str(&t.id);
1813− if !t.bar.is_empty() {
2619+ if bar_col {
1814 line.push(' ');
2620 line.push(' ');
1815− line.push_str(&t.bar);
2621+ line.push_str(if t.bar.is_empty() { " " } else { &t.bar });
1816 }
2622 }
1817 line.push_str(" ");
2623 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+ }
1818 line.push_str(&t.msg);
2695 line.push_str(&t.msg);
1819 if label_col && !t.labels.is_empty() {
2696 if label_col && !t.labels.is_empty() {
1820− let want = 2 + msg_end_max.saturating_sub(width(&t.msg));
2697+ pad_to(&mut line, lab_off);
1821− line.push_str(&" ".repeat(want));
2698+ line.push_str(&pal.label(&t.labels));
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(" "));
1844 }
2699 }
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(); }
1846 // the whole focus row is highlighted with a background band across the
2706 // the whole focus row is highlighted with a background band across the
1847 // full terminal width (§Colour; colour only — the ▶ gutter still marks
2707 // full terminal width (§Colour; colour only — the ▶ gutter still marks
1848 // the focus when colour is off)
2708 // the focus when colour is off)
1849 let is_focus_row = n.commit().map(|c| c.is_focus).unwrap_or(false);
2709 let is_focus_row = n.commit().map(|c| c.is_focus).unwrap_or(false);
1850 if is_focus_row && pal.on {
2710 if is_focus_row && pal.on {
1851− let target_w = if crate::show::stdout_is_tty() {
2711+ let padded = match term_w {
1852− crate::show::terminal_width()
1853− } else {
1854− None
1855− };
1856− let padded = match target_w {
1857 Some(w) if width(&line) < w => {
2712 Some(w) if width(&line) < w => {
1858 format!("{}{}", line, " ".repeat(w - width(&line)))
2713 format!("{}{}", line, " ".repeat(w - width(&line)))
1859 }
2714 }
1860 _ => line.clone(),
2715 _ => line.clone(),
1861 };
2716 };
1862− lines.push(pal.bg_line("48;5;236", &padded));
2717+ lines.push(pal.bg_line("97;48;5;17", &padded));
1863 } else {
2718 } else {
1864 lines.push(line);
2719 lines.push(line);
1865 }
2720 }
@@ -1869,12 +2724,14 @@ fn draw_rows(
1869 && n.commit().map(|c| c.is_focus && !c.detail_marks.is_empty()).unwrap_or(false)
2724 && n.commit().map(|c| c.is_focus && !c.detail_marks.is_empty()).unwrap_or(false)
1870 {
2725 {
1871 let c = n.commit().unwrap();
2726 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);
1873 let mut dline = String::new();
2728 let mut dline = String::new();
1874 dline.push_str(" ");
2729 dline.push_str(" ");
1875 dline.push_str(&color_rails(&dchars, &dlane0, pal, None));
2730 dline.push_str(&color_rails(&dchars, &dlane0, pal, None));
2731+ if overflow { dline.push_str(" "); }
1876 dline.push(' ');
2732 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(" ");
1878 let marks: Vec<String> = c
2735 let marks: Vec<String> = c
1879 .detail_marks
2736 .detail_marks
1880 .iter()
2737 .iter()
@@ -1890,25 +2747,18 @@ fn draw_rows(
1890 format!("{} {}", colored, p)
2747 format!("{} {}", colored, p)
1891 })
2748 })
1892 .collect();
2749 .collect();
1893− dline.push_str(&marks.join(" "));
2750+ let detail_lines = match term_w {
1894− lines.push(dline);
2751+ Some(w) => wrap_detail_items(&marks, w.saturating_sub(width(&dline)).max(1)),
1895− }
2752+ None => vec![marks.join(" ")],
1896− }
2753+ };
1897− // message truncation to the terminal width (§Step 4)
2754+ for text in detail_lines {
1898− if crate::show::stdout_is_tty() {
2755+ lines.push(format!("{}{}", dline, text));
1899− if let Some(w) = crate::show::terminal_width() {
2756+ }
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();
1907 }
2757 }
1908 }
2758 }
1909 // Legend (§Legend): explain the symbols that actually appear, faintly, on
2759 // Legend (§Legend): explain the symbols that actually appear, faintly, on
1910 // the right of the tree if it fits the terminal width, else at the bottom.
2760 // 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);
1912 let mut out = lines.join("\n");
2762 let mut out = lines.join("\n");
1913 out.push('\n');
2763 out.push('\n');
1914 Ok(out)
2764 Ok(out)
@@ -1921,10 +2771,11 @@ struct UsedSymbols {
1921 run: bool, // a ╎ n run row
2771 run: bool, // a ╎ n run row
1922 collapsed: bool, // a ⋯ n collapsed node
2772 collapsed: bool, // a ⋯ n collapsed node
1923 focus_gutter: bool, // ▶ present (a focus row exists)
2773 focus_gutter: bool, // ▶ present (a focus row exists)
2774+ overflow: bool, // separate » status column
1924 detail_marks: Vec<char>, // + ~ − ✖ present in the focus's detail line
2775 detail_marks: Vec<char>, // + ~ − ✖ present in the focus's detail line
1925}
2776}
1926
2777
1927−fn collect_used(rows: &[&Display], opts: &TreeOptions, icons: bool) -> UsedSymbols {
2778+fn collect_used(rows: &[&Display], opts: &TreeOptions, icons: bool, overflow: bool) -> UsedSymbols {
1928 let mut present: BTreeSet<&'static str> = BTreeSet::new();
2779 let mut present: BTreeSet<&'static str> = BTreeSet::new();
1929 let mut run = false;
2780 let mut run = false;
1930 let mut collapsed = false;
2781 let mut collapsed = false;
@@ -1963,6 +2814,7 @@ fn collect_used(rows: &[&Display], opts: &TreeOptions, icons: bool) -> UsedSymbo
1963 collapsed,
2814 collapsed,
1964 focus_gutter,
2815 focus_gutter,
1965 detail_marks: marks.into_iter().collect(),
2816 detail_marks: marks.into_iter().collect(),
2817+ overflow,
1966 }
2818 }
1967}
2819}
1968
2820
@@ -1998,6 +2850,9 @@ fn legend_lines(used: &UsedSymbols) -> Vec<String> {
1998 if used.focus_gutter {
2850 if used.focus_gutter {
1999 out.push("▶ current commit".to_string());
2851 out.push("▶ current commit".to_string());
2000 }
2852 }
2853+ if used.overflow {
2854+ out.push("» branch exceeds lane limit; increase lanes".to_string());
2855+ }
2001 if used.run {
2856 if used.run {
2002 out.push("╎ n run of n commits".to_string());
2857 out.push("╎ n run of n commits".to_string());
2003 }
2858 }
@@ -2014,8 +2869,15 @@ fn legend_lines(used: &UsedSymbols) -> Vec<String> {
2014/// faint, to the right of a tree row starting one row from the top, past the
2869/// faint, to the right of a tree row starting one row from the top, past the
2015/// widest tree line. If that would exceed the terminal width (or there is no
2870/// widest tree line. If that would exceed the terminal width (or there is no
2016/// terminal), put the legend at the bottom instead.
2871/// terminal), put the legend at the bottom instead.
2017−fn append_legend(lines: &mut Vec<String>, rows: &[&Display], opts: &TreeOptions, pal: &Palette) {
2872+fn append_legend(
2018− let used = collect_used(rows, opts, opts.icons);
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);
2019 let entries = legend_lines(&used);
2881 let entries = legend_lines(&used);
2020 if entries.is_empty() {
2882 if entries.is_empty() {
2021 return;
2883 return;
@@ -2024,11 +2886,6 @@ fn append_legend(lines: &mut Vec<String>, rows: &[&Display], opts: &TreeOptions,
2024 let legend_w = entries.iter().map(|e| width(e)).max().unwrap_or(0);
2886 let legend_w = entries.iter().map(|e| width(e)).max().unwrap_or(0);
2025 let gap = 4;
2887 let gap = 4;
2026 let right_total = tree_w + gap + legend_w;
2888 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− };
2032 let fits_right = tty_width.map_or(false, |w| right_total <= w);
2889 let fits_right = tty_width.map_or(false, |w| right_total <= w);
2033 if fits_right {
2890 if fits_right {
2034 // pad every tree line to tree_w, then append the legend entries dim
2891 // 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,
2056 }
2913 }
2057}
2914}
2058
2915
2059−/// rails state below row `upto` (exclusive): which lanes hold rails, and which
2916+/// Update the rails above a row to the rails below it. Each occupied lane
2060−/// of those are lane-0
2917+/// records whether it belongs to lane 0.
2061−fn rail_state_below(
2918+fn advance_rail_state(state: &mut [Option<bool>], n: &Display, p: &Placement) {
2062− rows: &[&Display],
2919+ if let Some((src, clears)) = p.fork {
2063− placements: &[Placement],
2920+ if clears {
2064− upto: usize,
2921+ state[src] = None;
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);
2084 }
2922 }
2085 }
2923 }
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+ }
2087}
2933}
2088
2934
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).
2090/// Returns the characters and, per character, whether it belongs to lane 0.
2936/// 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.
2091fn rail_row(
2938fn rail_row(
2092− rows: &[&Display],
2939+ n: &Display,
2093− placements: &[Placement],
2940+ pl: &Placement,
2094− idx: usize,
2941+ state: &[Option<bool>],
2095 lanes_n: usize,
2942 lanes_n: usize,
2096 opts: &TreeOptions,
2943 opts: &TreeOptions,
2097) -> (Vec<char>, Vec<bool>) {
2944) -> (Vec<char>, Vec<bool>) {
2098− let n = rows[idx];
2099− let pl = &placements[idx];
2100 let mut chars = vec![' '; 2 * lanes_n];
2945 let mut chars = vec![' '; 2 * lanes_n];
2101 let mut lane0 = vec![false; 2 * lanes_n];
2946 let mut lane0 = vec![false; 2 * lanes_n];
2102− let state = rail_state_below(rows, placements, idx, lanes_n);
2103 // horizontal segments: (start, end) in lane coordinates
2947 // horizontal segments: (start, end) in lane coordinates
2104 let mut segments: Vec<(usize, usize)> = Vec::new();
2948 let mut segments: Vec<(usize, usize)> = Vec::new();
2105 if let Some((src, _)) = pl.fork {
2949 if let Some((src, _)) = pl.fork {
@@ -2143,19 +2987,25 @@ fn rail_row(
2143 }
2987 }
2144 } else if state[i].is_some() {
2988 } else if state[i].is_some() {
2145 '│'
2989 '│'
2146− } else if pl.flattened && i == lanes_n - 1 {
2147− '»'
2148 } else {
2990 } else {
2149 ' '
2991 ' '
2150 };
2992 };
2151 chars[2 * i] = c;
2993 chars[2 * i] = c;
2152 lane0[2 * i] = i == 0;
2994 lane0[2 * i] = i == 0;
2153 }
2995 }
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.
2155 if let (Display::Run { count, .. }, Some(l)) = (n, pl.lane) {
3001 if let (Display::Run { count, .. }, Some(l)) = (n, pl.lane) {
3002+ let from = chars.iter().rposition(|&c| c != ' ').unwrap_or(2 * l) + 1;
2156 for (k, d) in format!(" {}", count).chars().enumerate() {
3003 for (k, d) in format!(" {}", count).chars().enumerate() {
2157− let pos = 2 * l + 1 + k;
3004+ let pos = from + k;
2158− if pos < chars.len() {
3005+ if pos == chars.len() {
3006+ chars.push(d);
3007+ lane0.push(l == 0);
3008+ } else {
2159 chars[pos] = d;
3009 chars[pos] = d;
2160 lane0[pos] = l == 0;
3010 lane0[pos] = l == 0;
2161 }
3011 }
@@ -2173,13 +3023,8 @@ fn rail_row(
2173
3023
2174/// detail line (§Step 4): `│` in every lane that holds a rail below the focus
3024/// detail line (§Step 4): `│` in every lane that holds a rail below the focus
2175/// row, including the focus's own lane if it has children
3025/// row, including the focus's own lane if it has children
2176−fn detail_rail_row(
3026+fn detail_rail_row(state: &[Option<bool>]) -> (Vec<char>, Vec<bool>) {
2177− rows: &[&Display],
3027+ let lanes_n = state.len();
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);
2183 let mut chars = vec![' '; 2 * lanes_n];
3028 let mut chars = vec![' '; 2 * lanes_n];
2184 let mut lane0 = vec![false; 2 * lanes_n];
3029 let mut lane0 = vec![false; 2 * lanes_n];
2185 for i in 0..lanes_n {
3030 for i in 0..lanes_n {
@@ -2192,8 +3037,10 @@ fn detail_rail_row(
2192}
3037}
2193
3038
2194/// Colour the rails. Rail connectors are lane-coloured (lane 0 = immutable
3039/// 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
3040+/// bright blue, others rotating accent hues); the node glyph at `glyph`
2196−/// code) is meaning-coloured.
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).
2197fn color_rails(
3044fn color_rails(
2198 chars: &[char],
3045 chars: &[char],
2199 lane0: &[bool],
3046 lane0: &[bool],
@@ -2201,7 +3048,12 @@ fn color_rails(
2201 glyph: Option<(usize, &str)>,
3048 glyph: Option<(usize, &str)>,
2202) -> String {
3049) -> String {
2203 let mut s = String::new();
3050 let mut s = String::new();
3051+ let mut covered = false;
2204 for (i, c) in chars.iter().enumerate() {
3052 for (i, c) in chars.iter().enumerate() {
3053+ if std::mem::take(&mut covered) {
3054+ continue;
3055+ }
3056+ covered = UnicodeWidthChar::width(*c) == Some(2);
2205 let t = c.to_string();
3057 let t = c.to_string();
2206 if *c == ' ' {
3058 if *c == ' ' {
2207 s.push(*c);
3059 s.push(*c);
@@ -2211,128 +3063,148 @@ fn color_rails(
2211 } else if lane0[i] {
3063 } else if lane0[i] {
2212 s.push_str(&pal.blue(&t));
3064 s.push_str(&pal.blue(&t));
2213 } else {
3065 } else {
2214− s.push_str(&pal.dim(&t));
3066+ s.push_str(&pal.rail(i / 2, &t));
2215 }
3067 }
2216 } else if lane0[i] {
3068 } else if lane0[i] {
2217 s.push_str(&pal.blue(&t));
3069 s.push_str(&pal.blue(&t));
2218 } else {
3070 } else {
2219− s.push_str(&pal.dim(&t));
3071+ s.push_str(&pal.rail(i / 2, &t));
2220 }
3072 }
2221 }
3073 }
2222 s
3074 s
2223}
3075}
2224
3076
2225−/// cut the message so labels and the margin keep their columns (§Step 4)
3077+/// Wrap without dropping text or splitting ANSI sequences. Close and reopen
2226−fn truncate_lines(
3078+/// active styles at row boundaries so continuation prefixes stay unstyled.
2227− lines: &mut [String],
3079+fn wrap_display_text(text: &str, limit: usize) -> Vec<String> {
2228− term_w: usize,
3080+ let limit = limit.max(1);
2229− msg_off: usize,
3081+ let mut lines = Vec::new();
2230− label_col: bool,
3082+ let mut line = String::new();
2231− margin: bool,
3083+ let mut used = 0;
2232−) {
3084+ let mut active = String::new();
2233− for line in lines.iter_mut() {
3085+ let mut chars = text.chars();
2234− if width(line) <= term_w {
3086+ while let Some(c) = chars.next() {
2235− continue;
3087+ if c == '\x1b' {
2236− }
3088+ let mut escape = String::from(c);
2237− // find the message span: it starts at msg_off and ends where the
3089+ for e in chars.by_ref() {
2238− // label/margin padding (2+ spaces) begins
3090+ escape.push(e);
2239− let plain = strip_ansi(line);
3091+ if e == 'm' { break; }
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);
2267 }
3092 }
2268− col += w;
3093+ if escape == "\x1b[0m" { active.clear(); } else { active.push_str(&escape); }
2269− }
3094+ line.push_str(&escape);
2270− if !label_col && !margin {
2271 continue;
3095 continue;
2272 }
3096 }
2273− if let (Some(_cut), Some(_tail)) = (cut_at, tail_start) {
3097+ let cells = UnicodeWidthChar::width(c).unwrap_or(0);
2274− // recompute on the plain string, then rebuild: this path only
3098+ if used > 0 && used + cells > limit {
2275− // matters on a tty, so keep it simple and operate on plain text
3099+ if !active.is_empty() { line.push_str("\x1b[0m"); }
2276− let p = strip_ansi(line);
3100+ lines.push(line);
2277− let pw = width(&p);
3101+ line = active.clone();
2278− if pw <= term_w {
3102+ used = 0;
2279− continue;
3103+ }
2280− }
3104+ line.push(c);
2281− // find message end (last run of 2+ spaces that starts the fixed tail)
3105+ used += cells;
2282− let bytes = p.as_bytes();
3106+ }
2283− let mut tail_bi = p.len();
3107+ if !active.is_empty() { line.push_str("\x1b[0m"); }
2284− let mut i = 0usize;
3108+ lines.push(line);
2285− while i + 1 < bytes.len() {
3109+ lines
2286− if bytes[i] == b' ' && bytes[i + 1] == b' ' {
3110+}
2287− // candidate: is everything after this point the fixed tail?
3111+
2288− tail_bi = i;
3112+/// Keep whole detail items together when possible; split only an item that
2289− }
3113+/// cannot fit on an otherwise empty continuation row.
2290− i += 1;
3114+fn wrap_detail_items(items: &[String], limit: usize) -> Vec<String> {
2291− }
3115+ let mut lines = Vec::new();
2292− let tail = &p[tail_bi..];
3116+ let mut line = String::new();
2293− let tail_w = width(tail);
3117+ for item in items {
2294− let head_budget = term_w.saturating_sub(tail_w + 1);
3118+ if !line.is_empty() && width(&line) + 3 + width(item) <= limit {
2295− let head = &p[..p
3119+ line.push_str(" ");
2296− .char_indices()
3120+ line.push_str(item);
2297− .take_while(|(_, c)| {
3121+ continue;
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);
2315 }
3122 }
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);
2316 }
3127 }
3128+ if !line.is_empty() { lines.push(line); }
3129+ lines
2317}
3130}
2318
3131
2319−fn strip_ansi(s: &str) -> String {
3132+/// `s` cut to display width `w`, ending with `…` when anything is cut (and
2320− let mut out = String::new();
3133+/// there is a column for it): the message on a terminal (§Step 4). Colour
2321− let mut esc = false;
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;
2322 for c in s.chars() {
3146 for c in s.chars() {
2323− if esc {
3147+ if c == '\x1b' {
2324− if c == 'm' {
3148+ in_esc = true;
2325− esc = false;
3149+ }
2326− }
3150+ if in_esc {
3151+ in_esc = c != 'm';
3152+ pending.push(c);
2327 continue;
3153 continue;
2328 }
3154 }
2329− if c == '\x1b' {
3155+ if full {
2330− esc = true;
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;
2331 continue;
3162 continue;
2332 }
3163 }
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));
2334 }
3178 }
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()
2336}
3208}
2337
3209
2338fn glyph_for(info: &CommitInfo, icons: bool) -> &'static str {
3210fn glyph_for(info: &CommitInfo, icons: bool) -> &'static str {
@@ -2372,20 +3244,18 @@ fn glyph_for(info: &CommitInfo, icons: bool) -> &'static str {
2372/// The ANSI colour code for a node glyph, by meaning (§Colour: glyphs carry
3244/// The ANSI colour code for a node glyph, by meaning (§Colour: glyphs carry
2373/// meaning; colour only adds emphasis). Mirrors the precedence of `glyph_for`.
3245/// meaning; colour only adds emphasis). Mirrors the precedence of `glyph_for`.
2374fn glyph_ansi(info: &CommitInfo) -> &'static str {
3246fn glyph_ansi(info: &CommitInfo) -> &'static str {
2375− if info.id == ROOT_ID {
3247+ node_ansi(glyph_for(info, false))
2376− "1" // root: bold
3248+}
2377− } else if info.conflict {
3249+
2378− "31" // conflict: red
3250+fn node_ansi(glyph: &str) -> &'static str {
2379− } else if info.empty {
3251+ match glyph {
2380− "2" // empty: dim
3252+ "⌂" => "1;95", // root: bold magenta
2381− } else if info.immutable {
3253+ "⊗" => "91", // conflict: bright red
2382− "34" // immutable: blue
3254+ "◌" => "2", // empty: dim
2383− } else if info.is_focus {
3255+ "◆" => "94", // immutable: bright blue
2384− "1;36" // focus: bold cyan
3256+ "◉" => "1;96", // focus: bold cyan
2385− } else if info.is_ancestor_of_focus {
3257+ "●" => "92", // ancestor of focus: bright green
2386− "32" // ancestor of focus: green
3258+ _ => "97", // other commit: bright white
2387− } else {
2388− "37" // other commit: light grey
2389 }
3259 }
2390}
3260}
2391
3261
@@ -2393,16 +3263,17 @@ fn size_bar(size: Option<usize>, pal: &Palette) -> String {
2393 match size {
3263 match size {
2394 None | Some(0) => String::new(),
3264 None | Some(0) => String::new(),
2395 Some(n) => {
3265 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 {
2397 ("▇", 4)
3268 ("▇", 4)
2398 } else if n >= 200 {
3269 } else if n >= 200 {
2399− ("▇", 3)
3270+ ("▅", 3)
2400 } else if n >= 50 {
3271 } else if n >= 50 {
2401− ("▅", 2)
3272+ ("▃", 2)
2402 } else if n >= 10 {
3273 } else if n >= 10 {
2403− ("▃", 1)
3274+ ("▂", 1)
2404 } else {
3275 } else {
2405− ("▂", 0)
3276+ ("▁", 0)
2406 };
3277 };
2407 pal.accent(level, bar)
3278 pal.accent(level, bar)
2408 }
3279 }
@@ -2416,3 +3287,280 @@ fn initials(name: &str) -> String {
2416 .collect::<String>()
3287 .collect::<String>()
2417 .to_lowercase()
3288 .to_lowercase()
2418}
3289}
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 @@
1//! Repo-zipper operations, the reference `by` walk (§10), and the
1//! Repo-zipper operations, the reference `by` walk (§10), and the
2//! persistence validation shared by `validate` and the jj backend (§7.5).
2//! persistence validation shared by `validate` and the jj backend (§7.5).
3
3
4−use crate::domain::ROOT_ID;
4+use crate::domain::{Backend, ROOT_ID};
5use crate::eval::Interp;
5use crate::eval::Interp;
6use crate::value::{value_eq, Crash, Value};
6use crate::value::{value_eq, Crash, Value};
7−use std::collections::{BTreeMap, BTreeSet};
7+use std::collections::{BTreeMap, BTreeSet, HashMap};
8
8
9/// navigate: refocus `repo` on the commit with change id `id` (§10 reference).
9/// 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.
10pub fn by_id(repo: &Value, id: &str) -> Result<Option<Value>, Crash> {
16pub fn by_id(repo: &Value, id: &str) -> Result<Option<Value>, Crash> {
11 let top = top_of(repo)?;
17 let top = top_of(repo)?;
12− let mut stack = vec![top];
18+ if id_is(&top.field("root")?, id)? {
13− while let Some(loc) = stack.pop() {
19+ return Ok(Some(top));
14− let root_id = id_of(&loc.field("root")?)?;
20+ }
15− if root_id == id {
21+ let mut path = Vec::new();
16− return Ok(Some(loc));
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);
17 }
60 }
18− let children = loc.field("children").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
61+ let grandchildren = c.field("children")?;
19− // push children as refocused locations; iterate in order
62+ if find_path(grandchildren.as_list()?, id, path)? {
20− for child in children.iter() {
63+ return Ok(true);
21− stack.push(refocus_child(&loc, child)?);
22 }
64 }
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()))),
23 }
112 }
24− Ok(None)
25}
113}
26
114
27fn id_of(commit: &Value) -> Result<String, Crash> {
115fn id_of(commit: &Value) -> Result<String, Crash> {
@@ -31,12 +119,46 @@ fn id_of(commit: &Value) -> Result<String, Crash> {
31 }
119 }
32}
120}
33
121
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+
34/// move a location to the top of history
132/// move a location to the top of history
35fn top_of(repo: &Value) -> Result<Value, Crash> {
133fn 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> {
36 let mut cur = repo.clone();
156 let mut cur = repo.clone();
37 loop {
157 loop {
38− let ctx = cur.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
158+ // ask whether the context is empty without copying it: this runs once
39− if ctx.is_empty() {
159+ // per level, and copying made climbing quadratic in the depth
160+ let ctx = cur.field("context")?;
161+ if ctx.as_list()?.is_empty() {
40 return Ok(cur);
162 return Ok(cur);
41 }
163 }
42 cur = up_of(&cur)?;
164 cur = up_of(&cur)?;
@@ -60,50 +182,18 @@ fn up_of(repo: &Value) -> Result<Value, Crash> {
60 ("children", repo.field("children")?),
182 ("children", repo.field("children")?),
61 ]));
183 ]));
62 children.extend_from_slice(right);
184 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+ };
63 Ok(Value::record(&[
190 Ok(Value::record(&[
64 ("children", Value::list(children)),
191 ("children", Value::list(children)),
65− ("context", Value::list(ctx[1..].to_vec())),
192+ ("context", rest),
66 ("root", parent),
193 ("root", parent),
67 ]))
194 ]))
68}
195}
69
196
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−
107// ----------------------------------------------------------------------
197// ----------------------------------------------------------------------
108// tree traversal helpers over Repo values
198// tree traversal helpers over Repo values
109// ----------------------------------------------------------------------
199// ----------------------------------------------------------------------
@@ -134,18 +224,166 @@ pub fn parent_map(repo: &Value) -> Result<Vec<(String, String)>, Crash> {
134 Ok(out)
224 Ok(out)
135}
225}
136
226
137−fn parent_map_rec(loc: &Value, out: &mut Vec<(String, String)>) -> Result<(), Crash> {
227+/// `tree` is the top location or a subtree below it: only its `root` and
138− let pid = id_of(&loc.field("root")?)?;
228+/// `children` are read. Refocusing on each child instead copied the context
139− let children = loc.field("children")?;
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")?;
140 for c in children.as_list()?.iter() {
234 for c in children.as_list()?.iter() {
141 out.push((id_of(&c.field("root")?)?, pid.clone()));
235 out.push((id_of(&c.field("root")?)?, pid.clone()));
142− parent_map_rec(&refocus_child(loc, c)?, out)?;
236+ parent_map_rec(c, out)?;
143 }
237 }
144 Ok(())
238 Ok(())
145}
239}
146
240
147// ----------------------------------------------------------------------
241// ----------------------------------------------------------------------
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
149// ----------------------------------------------------------------------
387// ----------------------------------------------------------------------
150
388
151#[derive(Debug)]
389#[derive(Debug)]
@@ -171,6 +409,8 @@ pub fn validate_repo(i: &mut Interp, new: &Value) -> Result<Validated, Crash> {
171 if let Some(old) = &old {
409 if let Some(old) = &old {
172 validate_immutable(old, new, &immutable)?;
410 validate_immutable(old, new, &immutable)?;
173 }
411 }
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())?;
174 // focus mutable (§7.5 step 6)
414 // focus mutable (§7.5 step 6)
175 let focus_id = id_of(&new.field("root")?)?;
415 let focus_id = id_of(&new.field("root")?)?;
176 if focus_id == ROOT_ID || immutable.contains(&focus_id) {
416 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> {
178 "the focus must be a mutable commit; compose with new",
418 "the focus must be a mutable commit; compose with new",
179 ));
419 ));
180 }
420 }
421+ validate_root(old.as_ref(), new)?;
181 Ok(Validated { immutable })
422 Ok(Validated { immutable })
182}
423}
183
424
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+
184fn check_repo_shape(v: &Value) -> Result<(), Crash> {
444fn check_repo_shape(v: &Value) -> Result<(), Crash> {
185 let fields = v
445 let fields = v
186 .field_set()
446 .field_set()
@@ -211,8 +471,9 @@ fn validate_ids_and_snapshots(repo: &Value, seen: &mut BTreeSet<String>) -> Resu
211 validate_tree(&top, seen)
471 validate_tree(&top, seen)
212}
472}
213
473
214−fn validate_tree(loc: &Value, seen: &mut BTreeSet<String>) -> Result<(), Crash> {
474+/// `tree` is the top location or a subtree below it, as in `parent_map_rec`
215− let root = loc.field("root")?;
475+fn validate_tree(tree: &Value, seen: &mut BTreeSet<String>) -> Result<(), Crash> {
476+ let root = tree.field("root")?;
216 let id = id_of(&root)?;
477 let id = id_of(&root)?;
217 if !seen.insert(id.clone()) {
478 if !seen.insert(id.clone()) {
218 return Err(Crash::new(format!(
479 return Err(Crash::new(format!(
@@ -220,27 +481,200 @@ fn validate_tree(loc: &Value, seen: &mut BTreeSet<String>) -> Result<(), Crash>
220 id
481 id
221 )));
482 )));
222 }
483 }
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> {
224 let files = files.as_list()?;
505 let files = files.as_list()?;
225 let mut paths = BTreeSet::new();
506 let mut paths = BTreeSet::new();
226 for e in files {
507 for e in files {
227 let p = e.field("path")?;
508 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+ }
228 let p = p.as_list()?;
514 let p = p.as_list()?;
229 let key: Vec<String> = p
515 let key: Vec<String> = p
230 .iter()
516 .iter()
231 .map(|c| c.as_text().map(|s| s.to_string()))
517 .map(|c| c.as_text().map(|s| s.to_string()))
232 .collect::<Result<_, _>>()?;
518 .collect::<Result<_, _>>()?;
519+ if key.is_empty() {
520+ return Err(Crash::new("persistence: a snapshot has an entry at the root path ./"));
521+ }
233 if !paths.insert(key) {
522 if !paths.insert(key) {
234 return Err(Crash::new("persistence: a snapshot has duplicate paths"));
523 return Err(Crash::new("persistence: a snapshot has duplicate paths"));
235 }
524 }
236 }
525 }
237− let children = loc.field("children")?;
526+ if let Some(p) = crate::domain::file_and_directory(&paths) {
238− for c in children.as_list()?.iter() {
527+ return Err(Crash::new(format!(
239− validate_tree(&refocus_child(loc, c)?, seen)?;
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+ }
240 }
643 }
241 Ok(())
644 Ok(())
242}
645}
243
646
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+
244fn label_set(repo: &Value) -> Result<BTreeSet<(String, String)>, Crash> {
678fn label_set(repo: &Value) -> Result<BTreeSet<(String, String)>, Crash> {
245 let mut out = BTreeSet::new();
679 let mut out = BTreeSet::new();
246 for c in all_commits(repo)? {
680 for c in all_commits(repo)? {
@@ -266,11 +700,7 @@ fn validate_labels(old: &Value, new: &Value) -> Result<(), Crash> {
266/// The immutable set: the config's `immutable` revset against `old`, plus
700/// The immutable set: the config's `immutable` revset against `old`, plus
267/// every merge commit and its ancestors (§7.5 step 3).
701/// every merge commit and its ancestors (§7.5 step 3).
268pub fn compute_immutable(i: &mut Interp, old: &Value) -> Result<BTreeSet<String>, Crash> {
702pub fn compute_immutable(i: &mut Interp, old: &Value) -> Result<BTreeSet<String>, Crash> {
269− let immutable_fn = i
703+ let v = i.apply_cached_revset("immutable", old)?;
270− .globals
271− .lookup("immutable")
272− .ok_or_else(|| Crash::new("`immutable` is not defined"))?;
273− let v = i.apply(immutable_fn, old.clone())?;
274 let mut set = BTreeSet::new();
704 let mut set = BTreeSet::new();
275 for idv in v.as_list()?.iter() {
705 for idv in v.as_list()?.iter() {
276 match idv {
706 match idv {
@@ -319,7 +749,7 @@ fn validate_immutable(old: &Value, new: &Value, immutable: &BTreeSet<String>) ->
319 id
749 id
320 )));
750 )));
321 }
751 }
322− if !value_eq(&c.field("files")?, &nc.field("files")?)? {
752+ if !snapshot_eq(&files_of(&c)?, &files_of(nc)?)? {
323 return Err(Crash::new(format!(
753 return Err(Crash::new(format!(
324 "persistence: commit {} is immutable (files changed)",
754 "persistence: commit {} is immutable (files changed)",
325 id
755 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 @@
2
2
3use crate::ast::TypeExpr;
3use crate::ast::TypeExpr;
4use crate::value::{PrimKind, ShapeKind, ShapeVal, Value};
4use crate::value::{PrimKind, ShapeKind, ShapeVal, Value};
5−use std::collections::{BTreeSet, HashMap};
5+use std::collections::{BTreeSet, HashMap, HashSet};
6
6
7#[derive(Default, Clone)]
7#[derive(Default, Clone)]
8pub struct Shapes {
8pub struct Shapes {
9 /// name -> its declared type
9 /// name -> its declared type
10 pub decls: HashMap<String, TypeExpr>,
10 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>>>,
11}
15}
12
16
13impl Shapes {
17impl Shapes {
14 pub fn new() -> Shapes {
18 pub fn new() -> Shapes {
15− Shapes {
19+ Shapes::default()
16− decls: HashMap::new(),
17− }
18 }
20 }
19
21
20 /// Compute the ShapeVal for a type name used as a value (§4.12).
22 /// Compute the ShapeVal for a type name used as a value (§4.12).
21 /// Returns None if the name is undeclared, or its shape is a function,
23 /// Returns None if the name is undeclared, or its shape is a function,
22 /// a list, or otherwise not usable.
24 /// a list, or otherwise not usable.
23 pub fn shape_of(&self, name: &str) -> Option<ShapeVal> {
25 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> {
24 match name {
37 match name {
25 "Int" => Some(ShapeVal {
38 "Int" => Some(ShapeVal {
26 name: name.into(),
39 name: name.into(),
@@ -54,18 +67,13 @@ impl Shapes {
54 }
67 }
55
68
56 fn shape_of_type(&self, ty: &TypeExpr) -> Option<ShapeKind> {
69 fn shape_of_type(&self, ty: &TypeExpr) -> Option<ShapeKind> {
57− match ty {
70+ // an alias takes its type's shape (§4.12); `unfold` is bounded, so a
58− TypeExpr::Con(n) => {
71+ // cycle of aliases (`Self = Self`) ends on a name like any other
59− if is_primitive(n) {
72+ match self.unfold(ty) {
60− let s = self.shape_of(n)?;
73+ TypeExpr::Con(n) if is_primitive(n) => self.shape_of(n).map(|s| s.kind),
61− Some(s.kind)
74+ // undeclared, a type variable, or an alias cycle: not a usable
62− } else if let Some(decl) = self.decls.get(n) {
75+ // shape, and a crash when used (§4.12)
63− // resolve the alias now that all decls are present
76+ TypeExpr::Con(_) => None,
64− self.shape_of_type(decl)
65− } else {
66− Some(ShapeKind::Aliased(n.clone()))
67− }
68− }
69 TypeExpr::Record(fields) => Some(ShapeKind::Record(
77 TypeExpr::Record(fields) => Some(ShapeKind::Record(
70 fields.iter().map(|(n, _)| n.clone()).collect(),
78 fields.iter().map(|(n, _)| n.clone()).collect(),
71 )),
79 )),
@@ -73,21 +81,25 @@ impl Shapes {
73 }
81 }
74 }
82 }
75
83
76− /// Unfold an alias chain, owned, so it works with partially-populated
84+ /// Unfold an alias chain by reference. Contracts are checked at every
77− /// decl maps too.
85+ /// call, so cloning the type each time — a `Vec` of `String`s for a record
78− pub fn unfold_owned(&self, ty: &TypeExpr) -> TypeExpr {
86+ /// type — was a per-application allocation.
79− match ty {
87+ pub fn unfold<'a>(&'a self, ty: &'a TypeExpr) -> &'a TypeExpr {
80− TypeExpr::Con(n) => {
88+ let mut cur = ty;
81− if is_primitive(n) || is_type_var(n) {
89+ // a declaration cycle would otherwise spin here; the bound is far
82− return ty.clone();
90+ // above any real alias chain
83− }
91+ for _ in 0..64 {
84− match self.decls.get(n) {
92+ match cur {
85− Some(decl) => self.unfold_owned(decl),
93+ TypeExpr::Con(n) if !is_primitive(n) && !is_type_var(n) => {
86− None => ty.clone(),
94+ match self.decls.get(n) {
95+ Some(decl) => cur = decl,
96+ None => return cur,
97+ }
87 }
98 }
99+ _ => return cur,
88 }
100 }
89− _ => ty.clone(),
90 }
101 }
102+ cur
91 }
103 }
92}
104}
93
105
@@ -100,24 +112,30 @@ fn is_type_var(n: &str) -> bool {
100}
112}
101
113
102/// A compiled signature: a chain of parameter types and a final result type.
114/// 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.
103#[derive(Clone, Debug)]
117#[derive(Clone, Debug)]
104pub struct Contract {
118pub struct Contract {
105− pub params: Vec<TypeExpr>,
119+ pub params: Vec<Rc2<TypeExpr>>,
106− pub result: TypeExpr,
120+ pub result: Rc2<TypeExpr>,
107}
121}
108
122
109pub fn compile_contract(shapes: &Shapes, ty: &TypeExpr) -> Contract {
123pub fn compile_contract(shapes: &Shapes, ty: &TypeExpr) -> Contract {
110 let mut params = Vec::new();
124 let mut params = Vec::new();
111 let mut cur = ty;
125 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();
112 loop {
130 loop {
113 match cur {
131 match cur {
114 TypeExpr::Fun(a, b) => {
132 TypeExpr::Fun(a, b) => {
115− params.push((**a).clone());
133+ params.push(a.clone());
116 cur = b;
134 cur = b;
117 }
135 }
118 TypeExpr::Con(n) => {
136 TypeExpr::Con(n) => {
119 // alias of a function type is unfolded (§4.13)
137 // 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()) {
121 if let Some(decl) = shapes.decls.get(n) {
139 if let Some(decl) = shapes.decls.get(n) {
122 cur = decl;
140 cur = decl;
123 continue;
141 continue;
@@ -130,14 +148,13 @@ pub fn compile_contract(shapes: &Shapes, ty: &TypeExpr) -> Contract {
130 }
148 }
131 Contract {
149 Contract {
132 params,
150 params,
133− result: cur.clone(),
151+ result: Rc2::new(cur.clone()),
134 }
152 }
135}
153}
136
154
137/// Check one level deep (§4.13). On failure, describe expected and got.
155/// Check one level deep (§4.13). On failure, describe expected and got.
138pub fn check(shapes: &Shapes, ty: &TypeExpr, v: &Value) -> Result<(), String> {
156pub fn check(shapes: &Shapes, ty: &TypeExpr, v: &Value) -> Result<(), String> {
139− let owned = shapes.unfold_owned(ty);
157+ let ty = shapes.unfold(ty);
140− let ty = &owned;
141 match ty {
158 match ty {
142 TypeExpr::Con(n) => {
159 TypeExpr::Con(n) => {
143 if is_type_var(n) {
160 if is_type_var(n) {
@@ -242,7 +259,10 @@ use std::rc::Rc as Rc2;
242pub enum ContractExpr {
259pub enum ContractExpr {
243 /// no contract information; arg checks pass through
260 /// no contract information; arg checks pass through
244 Unknown,
261 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 },
246 /// compose(f, g): (f . g); both contract expressions
266 /// compose(f, g): (f . g); both contract expressions
247 Compose(Box<ContractExpr>, Box<ContractExpr>),
267 Compose(Box<ContractExpr>, Box<ContractExpr>),
248 /// apply e to an argument that checked against e's first parameter
268 /// apply e to an argument that checked against e's first parameter
@@ -251,14 +271,14 @@ pub enum ContractExpr {
251
271
252impl ContractExpr {
272impl ContractExpr {
253 /// The type of the next parameter this function expects, if known.
273 /// 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>> {
255 match self {
275 match self {
256 ContractExpr::Unknown => None,
276 ContractExpr::Unknown => None,
257− ContractExpr::Known(c) => c.params.first().cloned(),
277+ ContractExpr::Known { contract, at } => contract.params.get(*at).cloned(),
258− ContractExpr::Compose(f, g) => g
278+ // (f . g) x = f (g x) (§4.9): the argument is g's, even when
259− .next_param()
279+ // g's contract is unknown or used up, and f's own contract
260− .or_else(|| f.next_param())
280+ // checks what g returns when it is applied to it
261− .or_else(|| f.result_after()),
281+ ContractExpr::Compose(_, g) => g.next_param(),
262 ContractExpr::ApplyFirst(e) => {
282 ContractExpr::ApplyFirst(e) => {
263 let advanced = ContractExpr::apply_first(e.clone());
283 let advanced = ContractExpr::apply_first(e.clone());
264 advanced.next_param()
284 advanced.next_param()
@@ -268,12 +288,12 @@ impl ContractExpr {
268 }
288 }
269
289
270 /// The result type once all parameters are supplied.
290 /// 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>> {
272 match self {
292 match self {
273 ContractExpr::Unknown => None,
293 ContractExpr::Unknown => None,
274− ContractExpr::Known(c) => {
294+ ContractExpr::Known { contract, at } => {
275− if c.params.is_empty() {
295+ if *at >= contract.params.len() {
276− Some(c.result.clone())
296+ Some(contract.result.clone())
277 } else {
297 } else {
278 None
298 None
279 }
299 }
@@ -293,7 +313,7 @@ impl ContractExpr {
293 pub fn is_exhausted(&self) -> bool {
313 pub fn is_exhausted(&self) -> bool {
294 match self {
314 match self {
295 ContractExpr::Unknown => false,
315 ContractExpr::Unknown => false,
296− ContractExpr::Known(c) => c.params.is_empty(),
316+ ContractExpr::Known { contract, at } => *at >= contract.params.len(),
297 ContractExpr::Compose(_, g) => g.is_exhausted(),
317 ContractExpr::Compose(_, g) => g.is_exhausted(),
298 ContractExpr::ApplyFirst(_) => false,
318 ContractExpr::ApplyFirst(_) => false,
299 }
319 }
@@ -301,19 +321,24 @@ impl ContractExpr {
301
321
302 /// Advance after one argument has been supplied and checked.
322 /// Advance after one argument has been supplied and checked.
303 pub fn apply_first_rc(self: Rc2<ContractExpr>) -> ContractExpr {
323 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+ }
305 }
333 }
306
334
307 /// Advance after one argument has been supplied and checked.
335 /// Advance after one argument has been supplied and checked.
308 pub fn apply_first(self: Box<ContractExpr>) -> ContractExpr {
336 pub fn apply_first(self: Box<ContractExpr>) -> ContractExpr {
309 match *self {
337 match *self {
310 ContractExpr::Unknown => ContractExpr::Unknown,
338 ContractExpr::Unknown => ContractExpr::Unknown,
311− ContractExpr::Known(c) => {
339+ ContractExpr::Known { contract, at } => {
312− let mut c2 = (*c).clone();
340+ let at = contract.params.len().min(at + 1);
313− if !c2.params.is_empty() {
341+ ContractExpr::Known { contract, at }
314− c2.params.remove(0);
315− }
316− ContractExpr::Known(Rc2::new(c2))
317 }
342 }
318 ContractExpr::Compose(f, g) => {
343 ContractExpr::Compose(f, g) => {
319 if !g.is_exhausted() {
344 if !g.is_exhausted() {
@@ -330,16 +355,35 @@ impl ContractExpr {
330 /// Number of remaining parameters, if known and not nested.
355 /// Number of remaining parameters, if known and not nested.
331 pub fn params_len(&self) -> usize {
356 pub fn params_len(&self) -> usize {
332 match self {
357 match self {
333− ContractExpr::Known(c) => c.params.len(),
358+ ContractExpr::Known { contract, at } => contract.params.len() - at,
334 _ => 99,
359 _ => 99,
335 }
360 }
336 }
361 }
337
362
338− /// True if the next parameter is definitely a function type.
363+ /// The 0-based position of the next argument among those the signature
339− pub fn next_param_is_function(&self, shapes: &Shapes) -> bool {
364+ /// lists, when known. Messages number arguments by the signature (§4.13:
340− match self.next_param() {
365+ /// "describe expected Text as argument 1"), not by what the function
341− Some(t) => matches!(shapes.unfold_owned(&t), TypeExpr::Fun(_, _)),
366+ /// value already holds: `tree`, which is `treeWith` applied to its
342− None => false,
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,
343 }
387 }
344 }
388 }
345
389
src/show.rs+362−137
@@ -1,103 +1,185 @@
1//! `show` (§5.2), unified diff (§4.9 `diff`), and the difftastic bridge (§7.10).
1//! `show` (§5.2), unified diff (§4.9 `diff`), and the difftastic bridge (§7.10).
2
2
3use crate::eval::Interp;
3use 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;
5
7
6−pub fn show(interp: &Interp, v: &Value) -> String {
8+/// Crashes where `v` holds a lazy part the store cannot read (§7.2), as any
7− let s = render(interp, v);
9+/// other use of it does: a stand-in for the part would be the literal of
8− // line breaking: one line if it fits in 80 columns
10+/// some other value, or would not parse.
9− if s.chars().count() <= 80 {
11+///
10− return s;
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);
11 }
30 }
12− render_wide(interp, v, 0)
31+ s.clear();
32+ render_wide(interp, v, 0, &mut s)?;
33+ Ok(s)
13}
34}
14
35
15−fn render(interp: &Interp, v: &Value) -> String {
36+fn render(interp: &Interp, v: &Value, out: &mut String) -> Result<(), Crash> {
16 match v {
37 match v {
17− Value::Thunk(t) => match t.force() {
38+ Value::Thunk(t) => return render_forced(interp, t, None, out),
18− Ok(v) => render(interp, &v),
39+ Value::Int(n) => render_int(n, out),
19− Err(_) => "<lazy>".to_string(),
40+ Value::Text(t) => push_text_literal(t, out),
20− },
41+ Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
21− Value::Int(n) => {
42+ Value::Id(id) => {
22− if n.sign() == num_bigint::Sign::Minus {
43+ out.push('@');
23− format!("(0 - {})", n.magnitude())
44+ out.push_str(id);
24− } else {
25− n.to_string()
26− }
27 }
45 }
28− Value::Text(t) => text_literal(t),
29− Value::Bool(b) => b.to_string(),
30− Value::Id(id) => format!("@{}", id),
31 Value::List(xs) => {
46 Value::List(xs) => {
32 // list elements must be atoms: parenthesise applications and
47 // list elements must be atoms: parenthesise applications and
33 // operator expressions (§3.4)
48 // operator expressions (§3.4)
34− let parts: Vec<String> = xs.iter().map(|x| render_atom(interp, x)).collect();
49+ out.push('[');
35− format!("[{}]", parts.join(" "))
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(']');
36 }
57 }
37 Value::Record(m) => {
58 Value::Record(m) => {
38− let parts: Vec<String> = m
59+ out.push_str("{ ");
39− .iter()
60+ for (i, (k, x)) in m.iter().enumerate() {
40− .map(|(k, x)| format!("{} = {}", k, render(interp, x)))
61+ if i > 0 {
41− .collect();
62+ out.push_str(", ");
42− format!("{{ {} }}", parts.join(", "))
63+ }
43− }
64+ out.push_str(k);
44− Value::Blob(b) => match &b.content {
65+ out.push_str(" = ");
45− BlobContent::Resolved(bytes) => match String::from_utf8(bytes.as_ref().clone()) {
66+ render(interp, x, out)?;
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))
56 }
67 }
57− },
68+ out.push_str(" }");
58− Value::Shape(s) => s.name.clone(),
69+ }
59− Value::Fun(f) => render_fun(interp, f),
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+ ));
60 }
114 }
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(())
61}
125}
62
126
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> {
64 match f {
141 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+ }
65 FunVal::Builtin { name, args, .. } => {
156 FunVal::Builtin { name, args, .. } => {
66− // operator builtins render parenthesised when bare
157+ // a selector `.name` carries its field as a baked-in first
67− if args.is_empty() {
158+ // argument (builtins::make_selector) and renders as the selector
68− if is_operator_name(name) {
159+ // atom itself (§3.4), not as an application to that field
69− format!("({})", name)
160+ if name.starts_with('.') && name.len() > 1 {
70− } else if name.starts_with('.') && name.len() > 1 {
161+ out.push_str(name);
71− name.clone() // selector .name
162+ return Ok(());
72− } else if name == "(.)" {
163+ }
73− "(.)".into()
164+ // operator builtins render parenthesised; `(.)` is its own name
74− } else {
165+ if is_operator_name(name) {
75− name.clone()
166+ out.push('(');
76− }
167+ out.push_str(name);
168+ out.push(')');
77 } else {
169 } else {
78− // partial application: name followed by applied args
170+ out.push_str(name);
79− let base = if is_operator_name(name) {
171+ }
80− format!("({})", name)
172+ // a partial application: the name followed by the applied args,
81− } else if name == "(.)" {
173+ // which may contain a baked-in value (compose)
82− "(.)".into()
174+ if name == "(.)" && args.len() == 2 {
83− } else {
175+ for a in args {
84− name.clone()
176+ out.push_str(" (");
85− };
177+ render_atom(interp, a, out)?;
86− // args may contain a baked-in value (compose, selectors)
178+ out.push(')');
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− }
100 }
179 }
180+ Ok(())
181+ } else {
182+ render_args(interp, args, out)
101 }
183 }
102 }
184 }
103 FunVal::Closure {
185 FunVal::Closure {
@@ -105,46 +187,67 @@ fn render_fun(interp: &Interp, f: &FunVal) -> String {
105 applied_args,
187 applied_args,
106 ..
188 ..
107 } => {
189 } => {
108− if applied_args.is_empty() {
190+ // a partial application of a named definition: the name followed
109− n.clone()
191+ // by the arguments supplied so far (§5.2)
110− } else {
192+ out.push_str(n);
111− // partial application of a named definition: the name followed
193+ render_args(interp, applied_args, out)
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− }
117 }
194 }
118 FunVal::Closure {
195 FunVal::Closure {
119 name: None,
196 name: None,
120− applied,
197+ applied_args,
121 params,
198 params,
122 body,
199 body,
123 src,
200 src,
124 ..
201 ..
125 } => {
202 } => {
126− if *applied == 0 {
203+ // a lambda or section renders as its source, exactly as written
127− if src.is_empty() || src == "section" {
204+ // (§5.2); only an AST built without source is re-rendered. A
128− crate::parse::render_lambda(params, body)
205+ // partial application parenthesises it, as it is not atomic, and
129− } else {
206+ // follows it with the arguments supplied so far (§5.2)
130− src.clone()
207+ let applied = !applied_args.is_empty();
131− }
208+ if applied {
209+ out.push('(');
210+ }
211+ if src.as_str().is_empty() {
212+ out.push_str(&crate::parse::render_lambda(params, body));
132 } else {
213 } else {
133− let base = if src.is_empty() || src == "section" {
214+ out.push_str(src.as_str());
134− crate::parse::render_lambda(params, body)
135− } else {
136− src.clone()
137− };
138− format!("({}) <applied>", base)
139 }
215 }
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(())
140 }
226 }
141− FunVal::OrFun(a, b) => format!("({} or {})", render_atom(interp, a), render_atom(interp, b)),
227+ FunVal::ComposeLazy(f, g) => render_infix(interp, f, " . ", g, out),
142− FunVal::Labelled(n, _) => format!("%{}", n),
228+ }
143− FunVal::ComposeLazy(f, g) => format!("({} . {})", render_atom(interp, f), render_atom(interp, g)),
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)?;
144 }
246 }
247+ Ok(())
145}
248}
146
249
147−fn render_atom(interp: &Interp, v: &Value) -> String {
250+fn render_atom(interp: &Interp, v: &Value, out: &mut String) -> Result<(), Crash> {
148 // parenthesise if not atomic; a nested list is self-delimiting (a `[`
251 // parenthesise if not atomic; a nested list is self-delimiting (a `[`
149 // cannot begin a postfix), but a record is not: `{ … }` following another
252 // cannot begin a postfix), but a record is not: `{ … }` following another
150 // element would parse as a record *update* on it (§3.4), so records keep
253 // 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 {
152 let atomic = matches!(
255 let atomic = matches!(
153 v,
256 v,
154 Value::Int(_) | Value::Text(_) | Value::Bool(_) | Value::Id(_) | Value::Shape(_) | Value::List(_)
257 Value::Int(_) | Value::Text(_) | Value::Bool(_) | Value::Id(_) | Value::Shape(_) | Value::List(_)
155− );
258+ ) || is_section(v);
156− let s = render(interp, v);
259+ if !atomic {
157− if atomic {
260+ out.push('(');
158− s
261+ }
159− } else {
262+ render(interp, v, out)?;
160− format!("({})", s)
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,
161 }
281 }
162}
282}
163
283
@@ -181,7 +301,13 @@ fn is_operator_name(n: &str) -> bool {
181}
301}
182
302
183pub fn text_literal(s: &str) -> String {
303pub 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('"');
185 for c in s.chars() {
311 for c in s.chars() {
186 match c {
312 match c {
187 '"' => out.push_str("\\\""),
313 '"' => out.push_str("\\\""),
@@ -193,21 +319,22 @@ pub fn text_literal(s: &str) -> String {
193 }
319 }
194 }
320 }
195 out.push('"');
321 out.push('"');
196− out
197}
322}
198
323
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> {
200 match v {
325 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),
201 Value::List(xs) if !xs.is_empty() => {
329 Value::List(xs) if !xs.is_empty() => {
202− let mut out = String::from("[");
330+ out.push('[');
203 for (i, x) in xs.iter().enumerate() {
331 for (i, x) in xs.iter().enumerate() {
204 if i > 0 {
332 if i > 0 {
205 out.push('\n');
333 out.push('\n');
206 out.push_str(&" ".repeat(indent + 2));
334 out.push_str(&" ".repeat(indent + 2));
207 }
335 }
208 // list elements must be atoms (§3.4)
336 // list elements must be atoms (§3.4)
209− let s = render_wide(interp, x, indent + 2);
337+ let atomic = matches!(
210− if matches!(
211 x,
338 x,
212 Value::Int(_)
339 Value::Int(_)
213 | Value::Text(_)
340 | Value::Text(_)
@@ -216,30 +343,34 @@ fn render_wide(interp: &Interp, v: &Value, indent: usize) -> String {
216 | Value::Shape(_)
343 | Value::Shape(_)
217 | Value::List(_)
344 | Value::List(_)
218 | Value::Record(_)
345 | Value::Record(_)
219− ) {
346+ ) || is_section(x);
220− out.push_str(&s);
347+ if !atomic {
221− } else {
348+ out.push('(');
222− out.push_str(&format!("({})", s));
349+ }
350+ render_wide(interp, x, indent + 2, out)?;
351+ if !atomic {
352+ out.push(')');
223 }
353 }
224 }
354 }
225 out.push(']');
355 out.push(']');
226− out
227 }
356 }
228 Value::Record(m) if !m.is_empty() => {
357 Value::Record(m) if !m.is_empty() => {
229− let mut out = String::from("{ ");
358+ out.push_str("{ ");
230 for (i, (k, x)) in m.iter().enumerate() {
359 for (i, (k, x)) in m.iter().enumerate() {
231 if i > 0 {
360 if i > 0 {
232 out.push('\n');
361 out.push('\n');
233 out.push_str(&" ".repeat(indent));
362 out.push_str(&" ".repeat(indent));
234 out.push_str(", ");
363 out.push_str(", ");
235 }
364 }
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)?;
237 }
368 }
238 out.push_str(" }");
369 out.push_str(" }");
239− out
240 }
370 }
241− _ => render(interp, v),
371+ _ => return render(interp, v, out),
242 }
372 }
373+ Ok(())
243}
374}
244
375
245// ----------------------------------------------------------------------
376// ----------------------------------------------------------------------
@@ -247,10 +378,38 @@ fn render_wide(interp: &Interp, v: &Value, indent: usize) -> String {
247// ----------------------------------------------------------------------
378// ----------------------------------------------------------------------
248
379
249pub fn unified_diff(a: &str, b: &str) -> String {
380pub 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);
251 let mut out = String::new();
387 let mut out = String::new();
252 for hunk in diff.unified_diff().context_radius(3).iter_hunks() {
388 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+ ));
254 for change in hunk.iter_changes() {
413 for change in hunk.iter_changes() {
255 let sign = match change.tag() {
414 let sign = match change.tag() {
256 similar::ChangeTag::Delete => "-",
415 similar::ChangeTag::Delete => "-",
@@ -258,19 +417,33 @@ pub fn unified_diff(a: &str, b: &str) -> String {
258 similar::ChangeTag::Equal => " ",
417 similar::ChangeTag::Equal => " ",
259 };
418 };
260 out.push_str(sign);
419 out.push_str(sign);
261− let text = change.to_string();
420+ let text: &str = change.value();
262− // similar yields the line with its terminator when present; the
421+ out.push_str(text);
263− // last line of a file without a trailing newline has none, so add
422+ // a line with no terminator, only ever the last, is ended here
264− // one to keep the diff line-oriented
423+ // and marked, as GNU diff and git do: otherwise dropping the
265− out.push_str(&text);
424+ // final newline shows as the same line removed and re-added,
425+ // alike in both directions
266 if !text.ends_with('\n') {
426 if !text.ends_with('\n') {
267 out.push('\n');
427 out.push('\n');
428+ out.push_str("\\ No newline at end of file\n");
268 }
429 }
269 }
430 }
270 }
431 }
271 out
432 out
272}
433}
273
434
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+
274// ----------------------------------------------------------------------
447// ----------------------------------------------------------------------
275// difftastic (§7.10)
448// difftastic (§7.10)
276// ----------------------------------------------------------------------
449// ----------------------------------------------------------------------
@@ -282,6 +455,13 @@ pub fn difft(path: &Value, a: &Value, b: &Value) -> Result<Value, Crash> {
282 .map(|c| c.as_text().map(|s| s.to_string()))
455 .map(|c| c.as_text().map(|s| s.to_string()))
283 .transpose()?
456 .transpose()?
284 .unwrap_or_else(|| "file".to_string());
457 .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+ }
285 let a_bytes = match a {
465 let a_bytes = match a {
286 Value::Blob(bl) => bl.bytes()?,
466 Value::Blob(bl) => bl.bytes()?,
287 v => return Err(Crash::new(format!("difft: expected a Blob, got a {}", v.kind_name()))),
467 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> {
292 };
472 };
293 let dir = tempfile::tempdir()
473 let dir = tempfile::tempdir()
294 .map_err(|e| Crash::new(format!("difft: cannot create temp dir: {}", e)))?;
474 .map_err(|e| Crash::new(format!("difft: cannot create temp dir: {}", e)))?;
295− let old = dir.path().join(format!("old-{}", last));
475+ // Keep the filename unchanged: adding a prefix can exceed NAME_MAX for
296− let new = dir.path().join(format!("new-{}", last));
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);
297 std::fs::write(&old, &a_bytes).map_err(|e| Crash::new(format!("difft: {}", e)))?;
483 std::fs::write(&old, &a_bytes).map_err(|e| Crash::new(format!("difft: {}", e)))?;
298 std::fs::write(&new, &b_bytes).map_err(|e| Crash::new(format!("difft: {}", e)))?;
484 std::fs::write(&new, &b_bytes).map_err(|e| Crash::new(format!("difft: {}", e)))?;
299 let mut cmd = std::process::Command::new("difft");
485 let mut cmd = std::process::Command::new("difft");
300 cmd.arg(&old).arg(&new);
486 cmd.arg(&old).arg(&new);
301 cmd.stdin(std::process::Stdio::null());
487 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());
302 if std::env::var_os("DFT_COLOR").is_none() && stdout_is_tty() {
491 if std::env::var_os("DFT_COLOR").is_none() && stdout_is_tty() {
303 cmd.env("DFT_COLOR", "always");
492 cmd.env("DFT_COLOR", "always");
304 }
493 }
@@ -307,12 +496,48 @@ pub fn difft(path: &Value, a: &Value, b: &Value) -> Result<Value, Crash> {
307 cmd.env("DFT_WIDTH", w.to_string());
496 cmd.env("DFT_WIDTH", w.to_string());
308 }
497 }
309 }
498 }
310− let out = cmd
499+ crate::terminal::external_stderr();
311− .output()
500+ let out = match cmd.output() {
312− .map_err(|_| Crash::new("difft: cannot execute `difft` (not on PATH)"))?;
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+ };
313 Ok(Value::text(String::from_utf8_lossy(&out.stdout).to_string()))
507 Ok(Value::text(String::from_utf8_lossy(&out.stdout).to_string()))
314}
508}
315
509
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+
316pub fn stdout_is_tty() -> bool {
541pub fn stdout_is_tty() -> bool {
317 unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 }
542 unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 }
318}
543}
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 @@
3use crate::ast::{Expr, Pattern};
3use crate::ast::{Expr, Pattern};
4use num_bigint::BigInt;
4use num_bigint::BigInt;
5use std::collections::{BTreeMap, BTreeSet, HashMap};
5use std::collections::{BTreeMap, BTreeSet, HashMap};
6−use std::rc::Rc;
6+use std::rc::{Rc, Weak};
7
7
8pub type RecordMap = BTreeMap<String, Value>;
8pub type RecordMap = BTreeMap<String, Value>;
9
9
@@ -12,7 +12,7 @@ pub enum Value {
12 Int(BigInt),
12 Int(BigInt),
13 Text(Rc<String>),
13 Text(Rc<String>),
14 Bool(bool),
14 Bool(bool),
15− List(Rc<Vec<Value>>),
15+ List(ListVal),
16 Record(Rc<RecordMap>),
16 Record(Rc<RecordMap>),
17 Fun(Rc<FunVal>),
17 Fun(Rc<FunVal>),
18 Id(Rc<String>),
18 Id(Rc<String>),
@@ -24,20 +24,89 @@ pub enum Value {
24 Thunk(Rc<ThunkVal>),
24 Thunk(Rc<ThunkVal>),
25}
25}
26
26
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+
27pub struct ThunkVal {
76pub struct ThunkVal {
28 state: std::cell::RefCell<ThunkState>,
77 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>,
29}
90}
30
91
31enum ThunkState {
92enum ThunkState {
32 Pending(Box<dyn FnOnce() -> Result<Value, Crash>>),
93 Pending(Box<dyn FnOnce() -> Result<Value, Crash>>),
94+ /// the computation has been taken out of `Pending` and is running
95+ Forcing,
33 Ready(Value),
96 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),
34}
101}
35
102
36impl std::fmt::Debug for ThunkVal {
103impl std::fmt::Debug for ThunkVal {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 match &*self.state.borrow() {
105 match &*self.state.borrow() {
39 ThunkState::Pending(_) => write!(f, "Thunk(pending)"),
106 ThunkState::Pending(_) => write!(f, "Thunk(pending)"),
107+ ThunkState::Forcing => write!(f, "Thunk(forcing)"),
40 ThunkState::Ready(v) => write!(f, "Thunk({:?})", v),
108 ThunkState::Ready(v) => write!(f, "Thunk({:?})", v),
109+ ThunkState::Failed(c) => write!(f, "Thunk(failed: {})", c.msg),
41 }
110 }
42 }
111 }
43}
112}
@@ -46,27 +115,64 @@ impl ThunkVal {
46 pub fn new(f: impl FnOnce() -> Result<Value, Crash> + 'static) -> Self {
115 pub fn new(f: impl FnOnce() -> Result<Value, Crash> + 'static) -> Self {
47 ThunkVal {
116 ThunkVal {
48 state: std::cell::RefCell::new(ThunkState::Pending(Box::new(f))),
117 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,
49 }
152 }
50 }
153 }
51
154
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
53 pub fn force(&self) -> Result<Value, Crash> {
157 pub fn force(&self) -> Result<Value, Crash> {
54− if let ThunkState::Ready(v) = &*self.state.borrow() {
158+ match &*self.state.borrow() {
55− return Ok(v.clone());
159+ ThunkState::Ready(v) => return Ok(v.clone()),
56− }
160+ ThunkState::Failed(c) => return Err(c.clone()),
57− let thunk = {
161+ ThunkState::Forcing => {
58− let mut st = self.state.borrow_mut();
162+ return Err(Crash::new("internal: a lazy value was forced re-entrantly"))
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− }
65 }
163 }
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()),
66 };
174 };
67− let v = thunk()?;
175+ out
68− *self.state.borrow_mut() = ThunkState::Ready(v.clone());
69− Ok(v)
70 }
176 }
71}
177}
72
178
@@ -97,8 +203,31 @@ pub enum BlobContent {
97 Lazy(Rc<LazyBlob>),
203 Lazy(Rc<LazyBlob>),
98 /// Conflict sides, jj order: alternating adds and removes, starting and
204 /// Conflict sides, jj order: alternating adds and removes, starting and
99 /// ending with an add: [add, (remove, add)*]. For the in-memory backend:
205 /// ending with an add: [add, (remove, add)*]. For the in-memory backend:
100− /// [to, from, onto]-style three sides as [add, remove, add].
206+ /// [to, from, onto]-style three sides as [add, remove, add]. `None` is a
101− Conflict(Vec<Rc<Vec<u8>>>),
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+ }
102}
231}
103
232
104/// A blob whose bytes are read from the store only on first use (§7.2):
233/// A blob whose bytes are read from the store only on first use (§7.2):
@@ -111,7 +240,12 @@ pub struct LazyBlob {
111
240
112enum LazyState {
241enum LazyState {
113 Pending(Box<dyn FnOnce() -> Result<Rc<Vec<u8>>, Crash>>),
242 Pending(Box<dyn FnOnce() -> Result<Rc<Vec<u8>>, Crash>>),
243+ /// the read has been taken out of `Pending` and is running
244+ Forcing,
114 Ready(Rc<Vec<u8>>),
245 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),
115}
249}
116
250
117impl std::fmt::Debug for LazyBlob {
251impl std::fmt::Debug for LazyBlob {
@@ -138,25 +272,28 @@ impl LazyBlob {
138 }
272 }
139 }
273 }
140
274
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
142 pub fn force(&self) -> Result<Rc<Vec<u8>>, Crash> {
277 pub fn force(&self) -> Result<Rc<Vec<u8>>, Crash> {
143 // fast path: already forced
278 // fast path: already forced
144− if let LazyState::Ready(b) = &*self.state.borrow() {
279+ match &*self.state.borrow() {
145− return Ok(b.clone());
280+ LazyState::Ready(b) => return Ok(b.clone()),
146− }
281+ LazyState::Failed(c) => return Err(c.clone()),
147− let thunk = {
282+ LazyState::Forcing => {
148− let mut st = self.state.borrow_mut();
283+ return Err(Crash::new("internal: a lazy blob was read re-entrantly"))
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− }
155 }
284 }
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"),
156 };
290 };
157− let bytes = thunk()?;
291+ let out = thunk();
158− *self.state.borrow_mut() = LazyState::Ready(bytes.clone());
292+ *self.state.borrow_mut() = match &out {
159− Ok(bytes)
293+ Ok(b) => LazyState::Ready(b.clone()),
294+ Err(c) => LazyState::Failed(c.clone()),
295+ };
296+ out
160 }
297 }
161}
298}
162
299
@@ -182,7 +319,7 @@ impl BlobVal {
182 match &self.content {
319 match &self.content {
183 BlobContent::Resolved(b) => b.len(),
320 BlobContent::Resolved(b) => b.len(),
184 BlobContent::Lazy(l) => l.force().map(|b| b.len()).unwrap_or(0),
321 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(),
186 }
323 }
187 }
324 }
188 /// content as bytes, forcing a lazy blob; conflicts render with jj-style
325 /// content as bytes, forcing a lazy blob; conflicts render with jj-style
@@ -194,6 +331,28 @@ impl BlobVal {
194 BlobContent::Conflict(sides) => Ok(render_conflict(sides)),
331 BlobContent::Conflict(sides) => Ok(render_conflict(sides)),
195 }
332 }
196 }
333 }
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+ }
197 /// the content hash of a lazy blob, if it is one (used to persist an
356 /// the content hash of a lazy blob, if it is one (used to persist an
198 /// unchanged blob without reading its bytes)
357 /// unchanged blob without reading its bytes)
199 pub fn lazy_id(&self) -> Option<&str> {
358 pub fn lazy_id(&self) -> Option<&str> {
@@ -204,25 +363,22 @@ impl BlobVal {
204 }
363 }
205}
364}
206
365
207−/// Render a conflict with jj-style markers.
366+/// Render a conflict with jj-style markers; a side the path is absent on
208−pub fn render_conflict(sides: &[Rc<Vec<u8>>]) -> Vec<u8> {
367+/// renders as empty content, as jj materialises it.
368+pub fn render_conflict(sides: &[Option<ConflictSide>]) -> Vec<u8> {
209 // sides: [add0, remove0, add1, remove1, ... addN]
369 // sides: [add0, remove0, add1, remove1, ... addN]
210 let mut out = Vec::new();
370 let mut out = Vec::new();
211 out.extend_from_slice(b"<<<<<<<\n");
371 out.extend_from_slice(b"<<<<<<<\n");
212 let mut i = 0;
372 let mut i = 0;
213− let mut first_add = true;
214 while i < sides.len() {
373 while i < sides.len() {
374+ let bytes: &[u8] = sides[i].as_ref().map_or(&[], |s| &s.bytes);
215 if i % 2 == 0 {
375 if i % 2 == 0 {
216− if !first_add {
217− // another add after removes: separate
218− }
219 out.extend_from_slice(b"+++++++\n");
376 out.extend_from_slice(b"+++++++\n");
220− out.extend_from_slice(&sides[i]);
377+ out.extend_from_slice(bytes);
221 ensure_newline(&mut out);
378 ensure_newline(&mut out);
222− first_add = false;
223 } else {
379 } else {
224 out.extend_from_slice(b"%%%%%%%\n");
380 out.extend_from_slice(b"%%%%%%%\n");
225− out.extend_from_slice(&sides[i]);
381+ out.extend_from_slice(bytes);
226 ensure_newline(&mut out);
382 ensure_newline(&mut out);
227 }
383 }
228 i += 1;
384 i += 1;
@@ -262,9 +418,6 @@ impl PrimKind {
262pub enum ShapeKind {
418pub enum ShapeKind {
263 Record(BTreeSet<String>),
419 Record(BTreeSet<String>),
264 Prim(PrimKind),
420 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),
268}
421}
269
422
270#[derive(Clone, Debug)]
423#[derive(Clone, Debug)]
@@ -287,7 +440,11 @@ pub enum FunVal {
287 arity: usize,
440 arity: usize,
288 args: Vec<Value>,
441 args: Vec<Value>,
289 f: BuiltinFn,
442 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)>,
291 },
448 },
292 Closure {
449 Closure {
293 name: Option<String>, // top-level definition name, if any
450 name: Option<String>, // top-level definition name, if any
@@ -295,25 +452,94 @@ pub enum FunVal {
295 applied: usize,
452 applied: usize,
296 /// the arguments supplied so far, for rendering (§5.2)
453 /// the arguments supplied so far, for rendering (§5.2)
297 applied_args: Vec<Value>,
454 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,
301 body: Rc<Expr>,
455 body: Rc<Expr>,
302 env: Env,
456 env: Env,
303− src: String,
457+ /// the lambda's source, which it renders as (§5.2)
458+ src: crate::ast::Source,
304 /// contract of this function as further arguments arrive
459 /// contract of this function as further arguments arrive
305 pending: Option<(String, Rc<crate::shape::ContractExpr>)>,
460 pending: Option<(String, Rc<crate::shape::ContractExpr>)>,
306 },
461 },
307 /// `f or g` lifted pointwise over functions (§4.6)
462 /// `f or g` lifted pointwise over functions (§4.6)
308 OrFun(Value, Value),
463 OrFun(Value, Value),
309− /// unevaluated composition: unfolds only when applied to a non-function
464+ /// a composition `f . g` applied as `f (g x)` (§4.9); `(.)` itself
310− /// (§4.1 note — `abandon . contract everything` is a value before it is
465+ /// builds a builtin node instead (builtins::compose_values)
311− /// applied, and must not run at load)
312 ComposeLazy(Value, Value),
466 ComposeLazy(Value, Value),
313 /// a label literal %name: applies as `labelled "name"`
467 /// a label literal %name: applies as `labelled "name"`
314 Labelled(String, Value),
468 Labelled(String, Value),
315}
469}
316
470
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+
317impl Value {
543impl Value {
318 pub fn kind_name(&self) -> &'static str {
544 pub fn kind_name(&self) -> &'static str {
319 match self {
545 match self {
@@ -364,7 +590,7 @@ impl Value {
364
590
365 pub fn as_list(&self) -> Result<&[Value], Crash> {
591 pub fn as_list(&self) -> Result<&[Value], Crash> {
366 match self {
592 match self {
367− Value::List(xs) => Ok(xs),
593+ Value::List(xs) => Ok(xs.as_slice()),
368 _ => Err(Crash::new(format!("expected a list, got a {}", self.kind_name()))),
594 _ => Err(Crash::new(format!("expected a list, got a {}", self.kind_name()))),
369 }
595 }
370 }
596 }
@@ -392,7 +618,7 @@ impl Value {
392 }
618 }
393
619
394 pub fn list(xs: Vec<Value>) -> Value {
620 pub fn list(xs: Vec<Value>) -> Value {
395− Value::List(Rc::new(xs))
621+ Value::List(ListVal::new(xs))
396 }
622 }
397
623
398 pub fn bool(b: bool) -> Value {
624 pub fn bool(b: bool) -> Value {
@@ -402,17 +628,13 @@ impl Value {
402
628
403/// Attach a definition name and contract to a function value (§4.13).
629/// Attach a definition name and contract to a function value (§4.13).
404pub fn attach_pending(v: &Value, name: &str, contract: Rc<crate::shape::Contract>) -> Value {
630pub fn attach_pending(v: &Value, name: &str, contract: Rc<crate::shape::Contract>) -> Value {
405− let pending = Some((
631+ let cexpr = Rc::new(crate::shape::ContractExpr::Known { contract, at: 0 });
406− name.to_string(),
632+ let pending = Some((name.to_string(), cexpr.clone()));
407− Rc::new(crate::shape::ContractExpr::Known(contract)),
408− ));
409 match v {
633 match v {
410 Value::Fun(fv) => match fv.as_ref() {
634 Value::Fun(fv) => match fv.as_ref() {
411 FunVal::Closure {
635 FunVal::Closure {
412 params,
636 params,
413 applied,
637 applied,
414− applied_args,
415− deferred,
416 body,
638 body,
417 env,
639 env,
418 src,
640 src,
@@ -421,8 +643,10 @@ pub fn attach_pending(v: &Value, name: &str, contract: Rc<crate::shape::Contract
421 name: Some(name.to_string()),
643 name: Some(name.to_string()),
422 params: params.clone(),
644 params: params.clone(),
423 applied: *applied,
645 applied: *applied,
424− applied_args: applied_args.clone(),
646+ // the definition renders as its name, followed only by the
425− deferred: *deferred,
647+ // arguments it is given, not those its value holds: `prev`,
648+ // not `prev (parents)` (§5.2)
649+ applied_args: Vec::new(),
426 body: body.clone(),
650 body: body.clone(),
427 env: env.clone(),
651 env: env.clone(),
428 src: src.clone(),
652 src: src.clone(),
@@ -439,14 +663,81 @@ pub fn attach_pending(v: &Value, name: &str, contract: Rc<crate::shape::Contract
439 arity: *arity,
663 arity: *arity,
440 args: args.clone(),
664 args: args.clone(),
441 f: *f,
665 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())),
443 })),
671 })),
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+ }
445 },
681 },
446 _ => v.clone(),
682 _ => v.clone(),
447 }
683 }
448}
684}
449
685
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+
450/// A crash (§4.7).
741/// A crash (§4.7).
451#[derive(Debug, Clone)]
742#[derive(Debug, Clone)]
452pub struct Crash {
743pub struct Crash {
@@ -473,6 +764,22 @@ pub struct Env {
473 frame: Option<Rc<Frame>>,
764 frame: Option<Rc<Frame>>,
474}
765}
475
766
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+
476pub struct Frame {
783pub struct Frame {
477 kind: FrameKind,
784 kind: FrameKind,
478 parent: Env,
785 parent: Env,
@@ -481,9 +788,10 @@ pub struct Frame {
481enum FrameKind {
788enum FrameKind {
482 Small(Vec<(String, Value)>),
789 Small(Vec<(String, Value)>),
483 Big(HashMap<String, Value>),
790 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
485 /// closures capturing the frame see later bindings (§4.1)
793 /// 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>>>),
487}
795}
488
796
489impl Env {
797impl Env {
@@ -509,18 +817,23 @@ impl Env {
509 }
817 }
510 }
818 }
511
819
512− /// extend with a recursive frame (for `let` blocks)
820+ /// extend with a recursive frame (for `let` blocks) binding `names`,
513− pub fn extend_rec(&self) -> (Env, Rc<std::cell::RefCell<Vec<(String, Value)>>>) {
821+ /// whose values are pushed to the returned cell in the same order
514− let cell = Rc::new(std::cell::RefCell::new(Vec::new()));
822+ pub fn extend_rec(&self, names: Rc<[String]>) -> (Env, Rc<std::cell::RefCell<Vec<Value>>>) {
515− (
823+ // Collect before allocating: `self` and all values the evaluator is
516− Env {
824+ // using are still ordinary strong roots during this check.
517− frame: Some(Rc::new(Frame {
825+ maybe_collect_cycles();
518− kind: FrameKind::Rec(cell.clone()),
826+ let cell = Rc::new(std::cell::RefCell::new(Vec::with_capacity(names.len())));
519− parent: self.clone(),
827+ let frame = Rc::new(Frame {
520− })),
828+ kind: FrameKind::Rec(names, cell.clone()),
521− },
829+ parent: self.clone(),
522− cell,
830+ });
523− )
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)
524 }
837 }
525
838
526 pub fn lookup(&self, name: &str) -> Option<Value> {
839 pub fn lookup(&self, name: &str) -> Option<Value> {
@@ -534,11 +847,13 @@ impl Env {
534 }
847 }
535 }
848 }
536 }
849 }
537− FrameKind::Rec(cell) => {
850+ FrameKind::Rec(names, cell) => {
538− for (n, v) in cell.borrow().iter().rev() {
851+ // the frame binds its names before it holds their values:
539− if n == name {
852+ // one not evaluated yet is unbound here, not a same-named
540− return Some(v.clone());
853+ // definition further out, which config.j's binders may
541− }
854+ // reuse (§4.2)
855+ if let Some(i) = names.iter().rposition(|n| n == name) {
856+ return cell.borrow().get(i).cloned();
542 }
857 }
543 }
858 }
544 FrameKind::Big(m) => {
859 FrameKind::Big(m) => {
@@ -560,7 +875,7 @@ impl Env {
560 while let Some(f) = cur {
875 while let Some(f) = cur {
561 match &f.kind {
876 match &f.kind {
562 FrameKind::Small(_) => small_frames += 1,
877 FrameKind::Small(_) => small_frames += 1,
563− FrameKind::Big(_) | FrameKind::Rec(_) => {}
878+ FrameKind::Big(_) | FrameKind::Rec(..) => {}
564 }
879 }
565 cur = f.parent.frame.as_ref();
880 cur = f.parent.frame.as_ref();
566 }
881 }
@@ -576,8 +891,8 @@ impl Env {
576 out.insert(n.clone());
891 out.insert(n.clone());
577 }
892 }
578 }
893 }
579− FrameKind::Rec(cell) => {
894+ FrameKind::Rec(names, _) => {
580− for (n, _) in cell.borrow().iter() {
895+ for n in names.iter() {
581 out.insert(n.clone());
896 out.insert(n.clone());
582 }
897 }
583 }
898 }
@@ -592,6 +907,302 @@ impl Env {
592 }
907 }
593}
908}
594
909
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+
595// ----------------------------------------------------------------------
1206// ----------------------------------------------------------------------
596// structural equality (§4.5); comparing functions is a crash
1207// structural equality (§4.5); comparing functions is a crash
597// ----------------------------------------------------------------------
1208// ----------------------------------------------------------------------
tests/base.rs+9−2
@@ -211,6 +211,12 @@ fn goto_variants() {
211 assert_eq!(focus_msg(&v), "first");
211 assert_eq!(focus_msg(&v), "first");
212 let m = crash(&mut i, &cfg, "goto (labelled \"nope\")", repo.clone());
212 let m = crash(&mut i, &cfg, "goto (labelled \"nope\")", repo.clone());
213 assert!(m.contains("expected one revision, got 0"), "{}", m);
213 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");
214 let m = crash(&mut i, &cfg, "into @kqqqqqqq", repo.clone());
220 let m = crash(&mut i, &cfg, "into @kqqqqqqq", repo.clone());
215 assert!(m.contains("not a child"), "{}", m);
221 assert!(m.contains("not a child"), "{}", m);
216 // child by predicate: move to the parent, then to the matching child
222 // child by predicate: move to the parent, then to the matching child
@@ -407,10 +413,11 @@ fn inspection_definitions() {
407 // meta of minted crashes
413 // meta of minted crashes
408 let m = crash(&mut i, &cfg, "meta @", repo.clone());
414 let m = crash(&mut i, &cfg, "meta @", repo.clone());
409 assert!(m.contains("stored commit") || m.contains("meta"), "{}", m);
415 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.
411 let r = ev(&mut i, &cfg, "review", repo.clone());
418 let r = ev(&mut i, &cfg, "review", repo.clone());
412 match r {
419 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"),
414 Err(m) => assert!(m.contains("difft"), "{}", m),
421 Err(m) => assert!(m.contains("difft"), "{}", m),
415 }
422 }
416}
423}
tests/builtins.rs+102−6
@@ -36,7 +36,7 @@ fn ok(i: &mut Interp, cfg: &config::Config, src: &str) -> Value {
36
36
37fn crash(i: &mut Interp, cfg: &config::Config, src: &str) -> String {
37fn crash(i: &mut Interp, cfg: &config::Config, src: &str) -> String {
38 match ev(i, cfg, src) {
38 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()),
40 Err(m) => m,
40 Err(m) => m,
41 }
41 }
42}
42}
@@ -48,8 +48,8 @@ macro_rules! check {
48 value_eq(&got, &$want).unwrap_or(false),
48 value_eq(&got, &$want).unwrap_or(false),
49 "{} => {}, want {}",
49 "{} => {}, want {}",
50 $src,
50 $src,
51− j::show::show(&$i, &got),
51+ j::show::show(&$i, &got).unwrap(),
52− j::show::show(&$i, &$want)
52+ j::show::show(&$i, &$want).unwrap()
53 );
53 );
54 }};
54 }};
55}
55}
@@ -81,6 +81,32 @@ fn arithmetic() {
81 check!(i, cfg, "3 >= 4", Value::Bool(false));
81 check!(i, cfg, "3 >= 4", Value::Bool(false));
82}
82}
83
83
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+
84#[test]
110#[test]
85fn identity_and_const() {
111fn identity_and_const() {
86 let (mut i, cfg) = make_interp();
112 let (mut i, cfg) = make_interp();
@@ -208,9 +234,22 @@ fn show_renders_literals() {
208 check!(i, cfg, "show (++)", Value::text("(++)"));
234 check!(i, cfg, "show (++)", Value::text("(++)"));
209 check!(i, cfg, "show Commit", Value::text("Commit"));
235 check!(i, cfg, "show Commit", Value::text("Commit"));
210 check!(i, cfg, "show id", Value::text("id"));
236 check!(i, cfg, "show id", Value::text("id"));
211− // lambda shows its source
237+ // lambda shows its source, exactly as written (§5.2)
212− let v = ok(&mut i, &cfg, "show (\\x -> x + 1)");
238+ check!(i, cfg, "show (\\x -> x + 1)", Value::text("\\x -> x + 1"));
213− assert!(v.as_text().unwrap().contains("x"), "{}", v.as_text().unwrap());
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]));
214}
253}
215
254
216#[test]
255#[test]
@@ -281,3 +320,60 @@ labelled = \_ _ -> []
281 Err(c) => assert!(c.msg.contains("unbound"), "{}", c.msg),
320 Err(c) => assert!(c.msg.contains("unbound"), "{}", c.msg),
282 }
321 }
283}
322}
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.