on d1806bc help sign in
58 files changed+2845−27340
since splitsnapshots
unifiedsplit
CLAUDE.md−71 README.md+37−185 config.j+22−107 docs/base.md+46−187 docs/comparison.md+9−25 docs/language.md+25−91 docs/tutorial.md+36−102 specs/main.md+198−948 specs/tree.md+50−94 src/ast.rs+2−35 src/builtins.rs+88−134 src/config.rs+54−370 src/diagnostics.rs−381 src/domain.rs+19−154 src/eval.rs+304−845 src/help.rs−668 src/jj.rs+473−3177 src/lex.rs+22−36 src/lib.rs−14 src/lookup.rs−78 src/main.rs+141−465 src/parse.rs+162−346 src/render.rs+589−1737 src/repo.rs+69−499 src/setup.rs−119 src/shape.rs+60−104 src/show.rs+137−362 src/terminal.rs−276 src/value.rs+89−700 tests/base.rs+2−9 tests/builtins.rs+6−102 tests/cli.rs+67−4446 tests/core.rs+1−1 tests/difft.rs−74 tests/display.rs+5−974 tests/domain.rs+1−111 tests/env_lifetimes.rs−215 tests/eval.rs+9−1072 tests/help.rs−326 tests/interop.rs+10−173 tests/laws.proptest-regressions−1 tests/laws.rs+6−258 tests/lex.rs−24 tests/more.rs+1−322 tests/onboarding.rs−200 tests/onboarding_vocab.rs−153 tests/parse.rs−314 tests/performance_lookup.rs−255 tests/remote.rs+2−2336 tests/review_fallback.rs−110 tests/setup.rs−144 tests/show.rs+25−587 tests/showprop.proptest-regressions−1 tests/showprop.rs+5−130 tests/terminal.rs−621 tests/tree.rs+65−1150 tests/ux_cli.rs−105 tests/validation.rs+8−816
CLAUDE.mddeleted−71
@@ -1,71 +0,0 @@
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+37−185
@@ -27,52 +27,6 @@ The whole vocabulary — `new`, `describe`, `squash`, `tree`, `rebase`, … —
2727written *in the language* in `config.j`, and you extend it by writing more
2828functions.
2929
30−## Find a command
31−
32−```
33−j help -- a quick start, grouped commands, and examples
34−j commands -- common tasks and how to do them
35−j commands all -- the full vocabulary in your config
36−j commands rebase -- search names, signatures, and descriptions
37−j help describe -- see a command's signature, documentation, and examples
38−j help push -- learn a reserved command's usage
39−j help config -- find your config and set your identity
40−```
41−
42−`j --help` and `j -h` also open help. A separate trailing help argument, such
43−as `j abandon --help`, opens that command's help without executing it.
44−Help and command discovery work outside a repository and never change your
45−files or configuration. The catalog includes
46−your own definitions, even ones without a signature; when no config exists, it
47−uses the embedded reference config. General help and help for reserved commands
48−are available even if your config is broken.
49−
50−Set up with your own name and email, then choose one starting point:
51−
52−```sh
53−j setup 'Ada Lovelace' ada@example.org
54−
55−# Start a new project
56−mkdir project && cd project
57−j init
58−```
59−
60−```sh
61−# Or join an existing project
62−j clone URL project
63−cd project
64−```
65−
66−`init` and `clone` leave you on a working-copy commit: the commit you are
67−editing now. Edit files, then run `j 'describe "first change"'` to save the
68−files and their description. Use `j save` to save files without changing the
69−description. Run `j new` **before** editing the next change. `j status` shows
70−both changes in the commit and edits that still need saving; `j tree` shows
71−where that commit sits in history. Compose commands right to left:
72−`j 'describe "first change" . new'` creates a commit, then describes it.
73−Preview an edit with `j 'preview squash'`. Search familiar tasks with
74−`j commands commit`, `j commands checkout`, or `j commands branch`.
75−
7630---
7731
7832## Why `j`?
@@ -93,7 +47,7 @@ Preview an edit with `j 'preview squash'`. Search familiar tasks with
9347### With Nix (recommended)
9448
9549The repository provides a flake with one default binary, `j`. Difftastic is a
96−runtime enhancement and is wired in automatically.
50+runtime dependency and is wired in automatically.
9751
9852```
9953nix build # builds ./result/bin/j
@@ -109,70 +63,22 @@ The reference `config.j` is installed alongside the binary
10963cargo build --release # produces target/release/j
11064```
11165
112−Run `j setup` to create the reference `config.j` (see next section).
113−[Difftastic](https://difftastic.wilfred.me.uk/) on your `PATH` improves
114−`review`/`difft`; without it they use a unified diff.
66+You'll want the reference `config.j` (see next section) and, for `review`/`difft`,
67+[difftastic](https://difftastic.wilfred.me.uk/) on your `PATH`.
11568
11669---
11770
11871## Configuration
11972
120−### Terminal colors and motion
121−
122−In an interactive terminal, `j` uses vivid branch colors, a blue focus band,
123−colorful data and status messages, and animated progress indicators while
124−repository operations or expressions run. Each indicator names the current
125−operation and shows a spinner, a moving activity bar, and elapsed time. The
126−bar shows activity, not a completion percentage; on narrow terminals it makes
127−room for the operation's name. Colored trees and tables appear in a quick
128−cascade. Progress starts only when an operation takes a moment; fast commands
129−do not wait for a spinner.
130−
131−To keep the colors but turn off animation:
132−
133−```sh
134−J_ANIMATIONS=never j tree
135−```
136−
137−Set `NO_COLOR=1` for plain, motion-free output. Motion is also disabled when
138−either output stream is redirected, `TERM=dumb`, or `CI` is nonempty. Automatic
139−colors follow the terminal for each stream; piped output stays plain unless
140−you explicitly request `treeWith`'s `color = "always"`. `NO_COLOR` takes
141−precedence even over that setting.
142−
143−Interactive edits report `Saved changes` or `No changes`, followed by the
144−focused commit's short id and description. `NO_COLOR=1` and `TERM=dumb` keep
145−these confirmations in plain text. Success confirmations are suppressed when
146−either output stream is redirected or CI is set; warnings and errors still
147−appear.
148−
149−### User config
150−
151−`j setup 'Your actual name' you@your-domain.example` creates the full reference
152−config with your identity. `j setup` without arguments creates an editable
153−default and tells you where to set the identity. Neither form overwrites an
154−existing file; use `j help config` to find and edit it.
155−
156−Expressions read `~/.config/j/config.j` (or `$XDG_CONFIG_HOME/j/config.j`).
73+`j` reads `~/.config/j/config.j` on every run (or `$XDG_CONFIG_HOME/j/config.j`).
15774An expression can only use a name this file declares or defines. The reference
158−[`config.j`](config.j) ships with the binary. Commands that execute against a
159−repository create an editable copy there when it is missing; help and discovery
160−use the embedded copy without writing it. Edit at least the identity:
75+[`config.j`](config.j) ships with the binary — copy it and edit at least the
76+identity:
16177
16278```
163−user = { name = "Ada Lovelace", email = "ada@example.org" }
79+user = { name = "Your Name", email = "you@example.com" }
16480```
16581
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−
17682The reference config defines the entire base vocabulary (see
17783[docs/base.md](docs/base.md)). Four definitions are read by `j` itself: `user`
17884(who commits), `tree` (how a `Repo` prints), `immutable` (which commits may
@@ -182,19 +88,17 @@ never be rewritten), and `labelled` (what a `%name` literal means).
18288
18389## How it works
18490
185−Every expression run:
91+Every `j` run:
18692
187−1. Snapshots the working directory for evaluation.
188−2. Builds a `Repo` value focused on the working-copy commit.
93+1. Snapshots the working directory into the working-copy commit.
94+2. Builds a `Repo` value focused on that commit.
189953. Evaluates your expression.
190964. If the result is a function, applies it to the `Repo`.
191−5. If the final value is a `Repo` that changed, persists it. An unchanged
192− `Repo` saves nothing; other values print without saving.
97+5. If the final value is a `Repo` that changed, persists it; otherwise prints
98+ the value and changes nothing.
19399
194100So most of what you write is an **edit** — a function `Repo -> Repo`. A result
195101that'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.
198102
199103```
200104j 'length . commits . top' -- a number: how many commits? prints, persists nothing
@@ -239,19 +143,14 @@ Opens a new commit *and* describes it, in one step.
239143j 'status'
240144```
241145
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.
146+A summary of the focus: id, message, labels, changed paths, conflicted paths.
247147
248148```
249−in commit No changes
250−conflicts No conflicts
251−id zrkmkkvrvtokumkuzrkmkkvrvtokumku
252−labels No labels
253−message add a library
254−unrecorded All edits recorded
149+changed 0 items
150+conflicts 0 items
151+id zrkmkkvrvtokumkuzrkmkkvrvtokumku
152+labels 0 items
153+message add a library
255154```
256155
257156```
@@ -291,33 +190,25 @@ The same tree with ages and authors in the margin.
291190j 'log'
292191```
293192
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.
193+A table of every commit, top first, with a `✓` on the focus.
297194
298195```
299196j 'review'
300197```
301198
302−The focus's changes as one page of diffs, using difftastic when available
303−and a unified diff otherwise.
199+The focus's changes, rendered by difftastic as one page of diffs.
304200
305201### Move around
306202
307−Navigation checks out the selected commit's files so you can edit them.
308−
309203```
310204j 'prev' -- to the parent
311205j 'next' -- to the only child
206+j 'top' -- to the top of history
312207j 'tip' -- to the tip of the current line
313208j 'by @wqzt' -- to a specific commit, by id
314−j 'new . goto trunk' -- start a change on top of the protected main line
209+j 'goto %main' -- to what the remote calls main
315210```
316211
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−
321212### Undo and history of actions
322213
323214```
@@ -326,25 +217,18 @@ j redo -- reapply it
326217j ops -- the operation log, newest first
327218```
328219
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−
336220### A safe dry run
337221
338−`preview` validates an edit and returns its resulting tree without saving:
222+Because a `Text` or `Repo`-returning-but-unchanged result persists nothing,
223+you can preview any edit by composing it with `tree` (or `validate`):
339224
340225```
341−j 'preview squash'
342−j 'preview (rebase trunk)'
226+j 'tree . squash' -- show the tree as squash would leave it
227+j 'tree . validate . squash' -- the same, but crash if squash couldn't persist
343228```
344229
345−This is shorthand for `tree . validate . EDIT`. `tree . EDIT` also previews,
346−but does not check whether the result can be saved. `validate . EDIT` alone
347−**saves** a changed repository; validation is not a dry-run mode.
230+`validate` returns the repo unchanged if persisting would succeed, and crashes
231+with the exact error otherwise — an honest dry run.
348232
349233---
350234
@@ -446,27 +330,6 @@ j 'push (unlabel "old-feature")' -- delete a bookmark
446330j 'push relabel' -- after a rewrite, re-point the remote's bookmarks
447331```
448332
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−
470333---
471334
472335## Advanced examples
@@ -550,9 +413,9 @@ Rebase if you can; leave the repository alone if it crashes.
550413Anything added to `config.j` whose value is an `Edit` is a new command:
551414
552415```
553−-- in config.j: squash into the parent and name the combined commit "release"
416+-- in config.j: fold the whole stack into one commit called "release"
554417release : Edit
555−release = describe "release" . prev . squash
418+release = describe "release" . (contract everything)
556419```
557420
558421Then `j release` works like any built-in.
@@ -561,31 +424,20 @@ Then `j release` works like any built-in.
561424
562425## Reserved commands
563426
564−A handful of commands are built in (not expressions): they provide help,
565−create or connect repositories, move refs, and walk the operation log.
427+A handful of commands are built in (not expressions): they create or connect
428+repositories, move refs, and walk the operation log. None take flags.
566429
567430| command | what it does |
568431|---|---|
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 |
572432| `j init` | create a colocated `.jj`/`.git` repository here (adopting an existing `.git`) |
573−| `j clone URL [DIR]` | clone `URL` into `DIR` (default: the repository's name, as `git clone` picks it) |
433+| `j clone URL [DIR]` | clone `URL` into `DIR` (default: last path component, minus `.git`) |
574434| `j remote URL` | set `origin`'s URL |
575435| `j fetch` | fetch from `origin` |
576436| `j push EXPR` | evaluate `EXPR` to push/drop records and push them |
577437| `j undo` / `j redo` | walk the operation log |
578438| `j ops` | print the operation log |
579439
580−`--help` and `-h` open help as the first word or as a separate argument after
581−the command: `j --help describe`, `j describe --help`, and `j describe -h`
582−all open help for `describe`. Inside a single expression argument or stdin,
583−language comments still apply: `j 'new --help'` executes `new`. There are no
584−other general command flags. Everything else is an expression in the language.
585−
586−Quote literal reserved-command arguments separately to keep spaces intact:
587−`j clone URL 'my project'` and `j remote '/path/to/shared repo.git'`. A whole
588−reserved command supplied as one string or on stdin is split on whitespace.
440+Everything else is an expression in the language.
589441
590442---
591443
@@ -597,7 +449,7 @@ reserved command supplied as one string or on stdin is split on whitespace.
597449| **[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. |
598450| **[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. |
599451| **[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. |
600−| **[specs/main.md](specs/main.md)** | The normative specification of the language, the CLI, and repository semantics. |
452+| **[SPEC.md](SPEC.md)** | The normative specification of the language, the CLI, and repository semantics. |
601453| **[config.j](config.j)** | The reference configuration: the entire base vocabulary, written in the language. |
602454
603455---
@@ -605,11 +457,11 @@ reserved command supplied as one string or on stdin is split on whitespace.
605457## Project layout
606458
607459```
608−config.j the reference base vocabulary
460+config.j the reference base vocabulary (read on every run)
609461src/ the j implementation (Rust, on jj-lib)
610462tests/ unit, property, and CLI end-to-end tests
611463docs/ language.md, base.md, comparison.md
612−specs/ the language, CLI, repository, and tree specifications
464+SPEC.md the specification
613465flake.nix Nix package: one binary, j, with difftastic as a runtime dep
614466```
615467
config.j+22−107
@@ -76,10 +76,6 @@ Drop = { delete : Label } -- delete the remote's bookmark
7676------------------------------------------------------------------------------
7777
7878(.) : (b -> c) -> (a -> b) -> a -> c -- composition, right to left
79−-- Return a value unchanged. As a repository command, `j id` records the
80−-- snapshotted working-copy edits in the focused commit. `j save` is the
81−-- descriptive alias in this reference config; inspections such as `j status`
82−-- and `j tree` do not record those edits.
8379id : a -> a
8480const : a -> b -> a
8581crash : Text -> a -- abort the program with a message
@@ -141,37 +137,24 @@ splitOn : Text -> Text -> [Text] -- splitOn "/" "a/b" = ["a" "b"]
141137------------------------------------------------------------------------------
142138
143139-- Replay a change onto a snapshot: "onto, with the change from `from` to `to`
144−-- applied." Where the change collides with what is already there, the path's
145−-- blob is unresolved rather than an error, except that a collision making a
146−-- path a file on one side and a directory on another, as adding ./a/b onto a
147−-- file ./a would, crashes, naming the path (§7.3).
140+-- applied." Total: where the change collides with what is already there, the
141+-- path's blob is unresolved rather than an error.
148142replay : Snapshot -> Change -> Snapshot
149143unresolved : 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]
155144touchedPaths : Change -> [Path] -- paths whose content differs, one pass;
156145 -- blobs compared by content id, bytes never read
157146blob : Text -> Blob -- a resolved regular file
158−blobHex : Text -> Text -> Blob -- kind (regular/executable/symlink), hex bytes
159147text : Blob -> Text -- its content (markers if unresolved)
160148by : 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
165149meta : Id -> Meta -- git hash, author, email, committer time;
166150 -- crash for a commit not yet persisted
167151validate : Repo -> Repo -- the repo unchanged, or the crash persisting
168− -- it would produce (shape, root, labels, immutability,
152+ -- it would produce (shape, labels, immutability,
169153 -- focus). `tree . validate . rebase trunk` is an
170154 -- honest dry run.
171155diff : Blob -> Blob -> Text -- unified diff from the first to the second
172−difft : Path -> Blob -> Blob -> Text -- difftastic when difft is on PATH; otherwise
173− -- unified diff with path headers and 3 context lines,
174− -- or a binary-files summary. DFT_* configures difft.
156+difft : Path -> Blob -> Blob -> Text -- difftastic's rendering; needs difft on PATH.
157+ -- Options via DFT_* environment variables.
175158treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text
176159 , lanes : Int, author : Bool, date : Bool, files : Bool }
177160 -> Repo -> Text -- the history as a tree; see the spec
@@ -211,11 +194,8 @@ top = \repo -> top (up repo) or repo
211194
212195-- Every commit of a subtree (or of the focused subtree of a Repo), in preorder.
213196-- 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.
217197commits : a -> [Commit]
218−commits = subtreeCommits
198+commits = \t -> t.root :: (concat (map commits t.children) or [])
219199
220200
221201------------------------------------------------------------------------------
@@ -259,9 +239,6 @@ restrict = \m -> filter (\e -> m e.path)
259239select : Fileset -> Snapshot -> Snapshot -> Snapshot -- m from a, the rest from b
260240select = \m a b -> restrict m a ++ restrict (neg m) b
261241
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`.
265242conflicted : Snapshot -> [Path]
266243conflicted = \s -> map (.path) (filter (\e -> unresolved e.content) s)
267244
@@ -310,7 +287,7 @@ descendants : Revset -- includes the
310287descendants = \repo -> map (.id) (commits repo)
311288
312289ancestors : Revset -- includes the focus
313−ancestors = \repo -> repo.root.id :: map (\f -> f.parent.id) repo.context
290+ancestors = \repo -> repo.root.id :: ((let p = up repo in ancestors p) or [])
314291
315292siblings : Revset
316293siblings = \repo -> filter (\i -> i /= repo.root.id) (kids (up repo)) or []
@@ -346,11 +323,6 @@ minus = \a b repo -> let ys = b repo in filter (\i -> not (member i ys)) (a repo
346323matching : (Commit -> Bool) -> Revset -> Revset
347324matching = \p rs repo -> filter (\i -> p (commitAt i repo)) (rs repo)
348325
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.
354326conflicts : Revset
355327conflicts = matching (\c -> not (null (conflicted c.files))) all
356328
@@ -359,10 +331,7 @@ firstOf : [Revset] -> Revset
359331firstOf = \rss repo -> foldl (\acc rs -> if null acc then rs repo else acc) [] rss
360332
361333-- The main line: whichever of these names the remote has, first one wins.
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'.
334+-- Empty in a repository with no remote, in which case nothing is immutable.
366335trunk : Revset
367336trunk = firstOf [%main %master %trunk]
368337
@@ -378,33 +347,21 @@ immutable = ancestorsOf trunk
378347-- Focusing. `goto` is the only place a revset must have exactly one member.
379348------------------------------------------------------------------------------
380349
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.
383350goto : Revset -> Edit
384351goto = \rs repo ->
385352 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"
390353 in if length is == 1
391354 then by (head is) repo
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.")
355+ else crash ("expected one revision, got " ++ show (length is))
395356
396357commitAt : Id -> Repo -> Commit
397−commitAt = lookupCommit
358+commitAt = \i repo -> (by i repo).root
398359
399360prev : Edit -- the parent
400361prev = goto parents
401362
402−-- Move to the only child. At a tip, `j new` starts a child; when there are
403−-- several children, choose one of the candidates with `j by @ID`.
404−next : Edit
405−next = \repo -> if null (kids repo)
406− then crash "next: this commit has no children; run `j new` to start a new child, or `j tree` to inspect the history"
407− else goto kids repo
363+next : Edit -- the only child
364+next = goto kids
408365
409366tip : Edit -- end of the line of only-children
410367tip = \repo -> tip (next repo) or repo
@@ -430,8 +387,6 @@ eachChild = forEach kids
430387guard : (Repo -> Bool) -> Edit -- crash unless p holds
431388guard = \p repo -> if p repo then repo else crash "guard"
432389
433−-- Whether the focused commit has no unresolved files. This says nothing
434−-- about unrecorded working-copy edits; inspect those with `j unrecorded`.
435390clean : Repo -> Bool
436391clean = \repo -> null (conflicted repo.root.files)
437392
@@ -470,28 +425,11 @@ rewrite = \f repo ->
470425-- Commands
471426------------------------------------------------------------------------------
472427
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−
488428-- Set the message of the focused commit.
489−-- Example: j 'describe "fix login"'
490429describe : Text -> Edit
491430describe = \m -> mapRoot (\c -> c { message = m })
492431
493432-- Start an empty child of the focus and move to it.
494−-- Examples: j new; j 'describe "next change" . new'
495433new : Edit
496434new = \repo -> addChild (newCommit repo.root.files) repo
497435
@@ -499,7 +437,6 @@ new = \repo -> addChild (newCommit repo.root.files) repo
499437-- new empty commit on that parent becomes the focus (the parent may be
500438-- immutable, and the focus never is). Refused for a commit the remote has a
501439-- 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'.
503440abandon : Edit
504441abandon = \repo ->
505442 let c = repo.root
@@ -509,20 +446,17 @@ abandon = \repo ->
509446 else new (remove (eachChild (replayOnto p) repo))
510447
511448-- Push the files matching m from the focus down into its parent.
512−-- Example: j 'contract (ext "rs")'
513449contract : Fileset -> Edit
514450contract = \m repo -> let a = repo.root
515451 in at parents (rewrite (\q -> q { files = select m a.files q.files })) repo
516452
517453-- Fold the focus entirely into its parent; a new empty commit becomes the focus.
518−-- Example: j squash; preview first with j 'preview squash'.
519454squash : Edit
520455squash = abandon . contract everything
521456
522457-- Push the files matching m from the focus up into a new child, which becomes
523458-- the focus and inherits the old focus's children. Labels stay with the
524459-- original commit, since they are the remote's.
525−-- Example: j 'split (ext "rs")'; use (under ./src) to select a directory.
526460split : Fileset -> Edit
527461split = \m repo ->
528462 let a = repo.root
@@ -532,7 +466,6 @@ split = \m repo ->
532466 in by x.id (repo { root = a', children = [{ root = x, children = repo.children }] })
533467
534468-- Move the focused subtree onto another commit. Returns to the moved commit.
535−-- Example: j 'rebase trunk'; preview with j 'preview (rebase trunk)'.
536469rebase : Revset -> Edit
537470rebase = \rs repo ->
538471 let d = (goto rs repo).root
@@ -543,7 +476,6 @@ rebase = \rs repo ->
543476 in by me (attach x.subtree (by d.id x.rest))
544477
545478-- Apply the focused commit's change onto another commit, as a new child there.
546−-- Example: j 'pick %main'
547479pick : Revset -> Edit
548480pick = \rs repo ->
549481 let ch = changeOf repo
@@ -551,7 +483,6 @@ pick = \rs repo ->
551483 in at rs (addChild (newCommit (replay t.files ch))) repo
552484
553485-- A new child of the focus that undoes the change of the given commit.
554−-- Example: j 'backout %feature'
555486backout : Revset -> Edit
556487backout = \rs repo ->
557488 let ch = changeOf (goto rs repo)
@@ -597,53 +528,38 @@ entryAt = \p s -> filter (\e -> e.path == p) s
597528
598529-- The content at path p, or an empty file if absent.
599530contentAt : Path -> Snapshot -> Blob
600−contentAt = lookupContent
531+contentAt = \p s -> (head (entryAt p s)).content or blob ""
601532
602533-- The paths a change touches.
603534touched : Change -> [Path]
604535touched = touchedPaths
605536
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
537+-- Paths whose content differs between the focus and its parent.
610538changed : Repo -> [Path]
611539changed = \repo -> touched (changeOf repo)
612540
613−-- What the focus changes, one record per changed path. Uses difftastic when
614−-- installed; a missing difft falls back to a unified diff with path headers
615−-- and 3 context lines, or a binary-files summary.
541+-- What the focus changes, rendered by difftastic, one record per changed path.
542+-- Replace difft with (\p -> diff) to use the built-in unified diff instead.
616543diffs : Repo -> [{ path : Path, diff : Text }]
617544diffs = \repo ->
618545 let ch = changeOf repo
619546 in map (\p -> { path = p, diff = difft p (contentAt p ch.from) (contentAt p ch.to) }) (touched ch)
620547
621548-- The same, as one page of text. A Text result prints raw, so `j review` is
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.
549+-- readable in the terminal.
627550review : Repo -> Text
628−review = \repo ->
629− let ds = diffs repo
630− in if null ds then "no changes\n" else concat (map (\d -> d.diff) ds)
631−
632−-- A summary of the focus. `changed` lists changes in the commit compared
633−-- with its parent; `unrecorded` lists working-copy edits not yet recorded.
634−-- Inspection does not record anything. Run `j save` to record those edits.
635−-- Example: j status
636−status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path], unrecorded : [Path] }
551+review = \repo -> concat (map (\d -> d.diff) (diffs repo)) or "no changes\n"
552+
553+-- A summary of the focus.
554+status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path] }
637555status = \repo ->
638556 { id = repo.root.id
639557 , message = repo.root.message
640558 , labels = repo.root.labels
641559 , changed = changed repo
642− , conflicts = conflicted repo.root.files
643− , unrecorded = unrecorded repo }
560+ , conflicts = conflicted repo.root.files }
644561
645562-- A summary of every commit, top first, in preorder.
646−-- Example: j log
647563log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]
648564log = \repo ->
649565 map (\c -> { id = c.id
@@ -656,7 +572,6 @@ log = \repo ->
656572-- (The parentheses are required: `f { … }` would be a record update of f.)
657573-- The default `tree` shows every commit, sorted by time (parents always above
658574-- their children); `treeCompact` folds uninteresting runs and far subtrees.
659−-- Examples: j tree; j 'tree . squash' previews an edit without saving it.
660575tree : Repo -> Text
661576tree = treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
662577
docs/base.md+46−187
@@ -47,13 +47,6 @@ user = { name = "Your Name", email = "you@example.com" }
4747
4848This is a plain value, not a function. Its signature is checked once, at load
4949(§4.13): if you write `user = 5`, `j` refuses to start (exit 3).
50−Creating a repository or saving changes also rejects empty fields and the
51−reference placeholders. Use your own name and email before `j init`,
52−`j clone`, or an edit that saves changes. `j help config` shows your actual
53−config path and an example, even if the config is broken.
54−For a new installation, `j setup 'Your actual name' you@your-domain.example`
55−creates the complete config with that identity. It never overwrites an
56−existing file.
5750
5851### 1.2 Paths, labels, entries, snapshots
5952
@@ -185,34 +178,6 @@ id 42 => 42
185178edit or id -- run edit, keep the repository if it crashes
186179```
187180
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−
216181### `const` — constant function
217182
218183```
@@ -291,14 +256,12 @@ not : Bool -> Bool
291256```
292257
293258`&&` and `||` short-circuit (the right operand is not evaluated if the left
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.
259+decides the result). Operands must be `Bool`.
297260
298261```
2992621 == 1 && 2 == 2 => true
3002631 == 2 || 3 == 3 => true
301−not (1 == 2) => true
264+not (1 == 2) => false
302265not true => false
303266```
304267
@@ -338,24 +301,18 @@ Defined on `Int` only; crash on anything else. Non-associative (cannot chain:
338301show : a -> Text
339302```
340303
341−When it succeeds, `show` renders a value in the language's literal syntax.
342−Except for closures and unresolved blobs, the text parses and evaluates back
343−to an equal value. A top-level `Text` prints raw, so `j show …` is how you inspect values.
304+Renders any value in the language's literal syntax, such that (except for
305+closures and unresolved blobs) the text parses and evaluates back to an equal
306+value. A top-level `Text` prints raw, so `j show …` is how you inspect values.
344307See §5.2 of the spec for the exact rendering.
345308
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−
352309```
353310show 42 => "42"
354311show "hi" => "\"hi\""
355312show [1 "a" true] => "[1 \"a\" true]"
356313show ({ x = 1 }) => "{ x = 1 }"
357314show (describe "wip") => "describe \"wip\""
358−show (\x -> x + 1) => "\\x -> x + 1"
315+show (\x -> x + 1) => "\x -> (x + 1)"
359316```
360317
361318---
@@ -458,14 +415,8 @@ replay : Snapshot -> Change -> Snapshot
458415```
459416
460417`replay onto ch` computes "`onto`, with the change from `ch.from` to `ch.to`
461−applied." Where the change collides with what is already there, the path's
462−blob becomes *unresolved* (a conflict) rather than an error. The one
463−exception is a collision that would make a path a file on one side and a
464−directory on another, as adding `./a/b` onto a file `./a` would: one entry
465−cannot list a directory's entries, so `replay` crashes, naming the path, and
466−`or` catches it. A conflict already at the path in `onto`, `ch.from` or
467−`ch.to` is carried, directory sides and all, so a child that resolves such a
468−conflict can still be squashed into it.
418+applied." It is **total**: where the change collides with what is already
419+there, the path's blob becomes *unresolved* (a conflict) rather than an error.
469420
470421```
471422-- onto has "base", change takes "base" -> "theirs": no collision
@@ -484,12 +435,11 @@ unresolved ((head (replay [({ path = ./f, content = blob "onto" })]
484435Two laws to internalise it: `replay b { from = b, to = x } = x` and
485436`replay x { from = b, to = b } = x`.
486437
487−### `unresolved`, `blob`, `blobHex`, `text` — blob primitives
438+### `unresolved`, `blob`, `text` — blob primitives
488439
489440```
490441unresolved : Blob -> Bool
491442blob : Text -> Blob
492−blobHex : Text -> Text -> Blob
493443text : Blob -> Text
494444```
495445
@@ -497,16 +447,9 @@ text : Blob -> Text
497447blob's content (its conflict-marker rendering if unresolved). `unresolved b`
498448is `true` iff the blob is a conflict.
499449
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−
506450```
507451blob "content" => blob "content"
508452text (blob "content") => "content"
509−text (blobHex "regular" "6869") => "hi"
510453unresolved (blob "x") => false
511454```
512455
@@ -524,24 +467,6 @@ no such visible commit. Backed by the change-id index. Applied to one argument
524467by @wqzt -- an edit: refocus on the commit @wqzt
525468```
526469
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−
545470### `meta` — commit metadata
546471
547472```
@@ -557,20 +482,15 @@ meta @wqzt => { hash = "74bee64e…", author = "Test User",
557482 email = "test@example.com", time = 1789457265 }
558483```
559484
560−### `validate` — check whether persistence would succeed
485+### `validate` — dry-run persistence
561486
562487```
563488validate : Repo -> Repo
564489```
565490
566491Returns the repo unchanged if persisting it would succeed; otherwise crashes
567−with exactly the error persistence would produce (shape, root, labels, immutability,
568−focus). A changed repository also checks that the author identity is configured;
569−an unchanged repository can still be inspected with the default identity.
570−`tree . validate . rebase trunk` is an honest dry run.
571−`validate . rebase trunk` alone saves the result: `validate` returns the same
572−repository it was given, not the repository from before the edit. Prefer
573−`preview (rebase trunk)` for the complete safe-preview expression.
492+with exactly the error persistence would produce (shape, labels, immutability,
493+focus). `tree . validate . rebase trunk` is an honest dry run.
574494
575495### `diff` and `difft` — rendering differences
576496
@@ -588,34 +508,25 @@ diff (blob "a\nb\n") (blob "a\nc\n")
588508```
589509
590510`difft p a b` renders the comparison with **difftastic**, treating the input
591−as the file `p` (its last component guides language detection), using options
592−from `DFT_*` environment variables. The Nix package provides `difft`.
593−When the executable is missing, the builtin returns a unified diff with
594−`a/` and `b/` path headers and three lines of context; binary content receives
595−a difference summary. Other launch failures are crashes naming the tool and
596−OS error. A `difft` that runs and fails is not an error: what it printed,
597−even nothing, is the result, and what it says on stderr reaches the terminal.
511+as the file `p` (its last component guides language detection). Needs `difft`
512+on `PATH` (the Nix package provides it); options via `DFT_*` environment
513+variables. Crashes naming the tool if absent.
598514
599515### `treeWith` — the history tree
600516
601517```
602−treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text,
603− lanes : Int, author : Bool, date : Bool, files : Bool }
518+treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text }
604519 -> Repo -> Text
605520```
606521
607522Renders the history as a tree with an options record. The reference config
608−provides ready-made settings, including `tree` and `treeFull` (§10).
523+provides two ready-made settings, `tree` and `treeFull` (§9).
609524
610525```
611−treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
526+treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
612527 -- a full tree with ages and authors in the margin
613528```
614529
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−
619530### `extract` — every subvalue of a shape
620531
621532```
@@ -677,9 +588,7 @@ top = \repo -> top (up repo) or repo
677588```
678589
679590Moves the focus all the way to the top of history (where `context = []`). The
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.
591+`or repo` is the base case: when `up` crashes at the top, stop.
683592
684593```
685594top -- refocus at the very first commit
@@ -690,16 +599,13 @@ commits . top -- every commit, preorder
690599
691600```
692601commits : a -> [Commit]
693−commits = subtreeCommits
602+commits = \t -> t.root :: (concat (map commits t.children) or [])
694603```
695604
696605Every commit of a subtree (or of the focused subtree of a `Repo`), in
697606preorder. 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
700−the `or []` handles a leaf, whose `children` is empty, so `concat` crashes and
701−yields `[]`.) Written in the language, it copies the list below every commit,
702−and takes time quadratic in the length of the history.
607+open. The `or []` handles a leaf (whose `children` is empty, so `concat`
608+crashes and yields `[]`).
703609
704610```
705611length . commits . top -- how many commits are visible?
@@ -860,28 +766,26 @@ is `new . goto trunk`, never `goto trunk` alone.
860766
861767**`goto : Revset -> Edit`** — refocus on *the one commit* a revset names. This
862768is the only place a revset must have exactly one member; otherwise it crashes
863−with `expected one revision, got N` and recovery guidance. An empty selector
864−points to `j tree` and remote/fetch setup; an ambiguous selector shows up to
865−eight candidate ids and descriptions, with commands to select a mutable commit
866−or create a child. Candidate ids are complete copyable id literals.
769+with `expected one revision, got N`.
770+
771+```
772+goto = \rs repo ->
773+ let is = rs repo
774+ in if length is == 1 then by (head is) repo
775+ else crash ("expected one revision, got " ++ show (length is))
776+```
867777
868778```
869−new . goto %main -- start a mutable child on main
870−tree . goto %main -- inspect main without moving the working copy
779+goto %main -- refocus on main (crash if it names 0 or 2+ commits)
871780goto parents -- == prev
872781```
873782
874783**`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`.
878784
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.
785+**`prev : Edit`** — to the parent (`goto parents`).
882786
883−**`next : Edit`** — to the only child. At a tip it suggests `j new`; at a fork
884−it lists candidates to choose with `by`.
787+**`next : Edit`** — to the only child (`goto kids`; crashes if the focus has
788+other than exactly one child).
885789
886790**`tip : Edit`** — the end of the line of only-children (`tip = \repo -> tip
887791(next repo) or repo`).
@@ -1065,9 +969,6 @@ Crashes at the top.
1065969
1066970`j push EXPR` evaluates `EXPR` to a list of `Push` and `Drop` records. These
1067971functions 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.
1071972
1072973**`label : Label -> Revset -> Repo -> [Push]`** — label every commit of a
1073974revset.
@@ -1100,8 +1001,7 @@ j 'push relabel' -- after rewriting, re-point the remote's bookmarks
11001001
11011002## 10. Inspection
11021003
1103−These return printable values rather than a `Repo`. They include current
1104−file edits in the displayed snapshot without recording those edits.
1004+These return printable values rather than a `Repo`.
11051005
11061006**`focus : Repo -> Commit`** — the focused commit.
11071007
@@ -1116,20 +1016,6 @@ j 'files' -- the working-copy commit's files
11161016
11171017**`contentAt : Path -> Snapshot -> Blob`** — the content at a path, or an
11181018empty 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.
11331019
11341020```
11351021contentAt ./a [({ path = ./a, content = blob "x" })]
@@ -1142,64 +1028,42 @@ contentAt ./missing [] => blob ""
11421028**`changed : Repo -> [Path]`** — paths whose content differs between the focus
11431029and its parent.
11441030
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−
11511031**`diffs : Repo -> [{ path : Path, diff : Text }]`** — what the focus changes,
1152−rendered by difftastic, one record per changed path, with unified diffs as
1153−the fallback when difftastic is missing.
1032+rendered by difftastic, one record per changed path. (Replace `difft` with
1033+`(\p -> diff)` to use the built-in unified diff instead.)
11541034
1155−**`review : Repo -> Text`** — the same, as one page of text, or
1156−`"no changes\n"` when the focus changes nothing. A `Text` result prints raw,
1157−so `j review` is readable in the terminal even without difftastic. When
1158−`difft` runs, whatever it prints, even nothing, is the page, and what it says
1159−on stderr reaches the terminal. Other launch errors still fail the review.
1035+**`review : Repo -> Text`** — the same, as one page of text. A `Text` result
1036+prints raw, so `j review` is readable in the terminal.
11601037
11611038```
11621039j 'review' -- the focus's changes, as difftastic renders them
11631040```
11641041
1165−**`status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], unrecorded : [Path], conflicts : [Path] }`** — a summary of the focus and unsaved file edits.
1042+**`status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path] }`** — a summary of the focus.
11661043
11671044```
11681045j 'status'
11691046-- id @wqzt…
11701047-- message add the lexer
11711048-- labels [feature]
1172−-- in commit [src/lexer.rs]
1173−-- conflicts No conflicts
1174−-- unrecorded All edits recorded
1049+-- changed [src/lexer.rs]
1050+-- conflicts []
11751051```
11761052
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−
11841053**`log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]`**
11851054— a summary of every commit, top first, in preorder (`focus` marks the focused
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.
1055+commit).
11891056
11901057**`tree : Repo -> Text`** and **`treeFull : Repo -> Text`** — the history as a
1191−tree, built with `treeWith`. `tree` shows every commit (`detail = 1`);
1058+tree, built with `treeWith`. `tree` is compact (`detail = 1`, elided);
11921059`treeFull` shows ages and authors in the margin (`detail = 2`, `margin =
11931060true`). Edit these definitions to change what `j tree` shows.
11941061
11951062```
1196−tree = treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
1197−treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
1063+tree = treeWith ({ detail = 1, margin = false, elide = true, icons = false, color = "auto" })
1064+treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
11981065```
11991066
1200−Use `treeCompact` to fold uninteresting runs and distant subtrees, or
1201−`treeData` for extra date, files-changed, and author columns.
1202−
12031067```
12041068j 'tree'
12051069-- ⌂ zzzz
@@ -1229,7 +1093,6 @@ j 'tree'
12291093| `attach` | `Subtree -> Edit` | §7.1 |
12301094| `backout` | `Revset -> Edit` | §8 |
12311095| `blob` | `Text -> Blob` | §5 |
1232−| `blobHex` | `Text -> Text -> Blob` | §5 |
12331096| `both` | `Fileset -> Fileset -> Fileset` | §7.2 |
12341097| `by` | `Id -> Repo -> Repo` | §5 |
12351098| `changeOf` | `Repo -> Change` | §7.2 |
@@ -1299,7 +1162,6 @@ j 'tree'
12991162| `parents` | `Revset` | §7.3 |
13001163| `pick` | `Revset -> Edit` | §8 |
13011164| `prev` | `Edit` | §7.4 |
1302−| `preview` | `Edit -> Repo -> Text` | §2 |
13031165| `range` | `Int -> Int -> [Int]` | §4 |
13041166| `rebase` | `Revset -> Edit` | §8 |
13051167| `relabel` | `Repo -> [Push]` | §9 |
@@ -1311,7 +1173,6 @@ j 'tree'
13111173| `restrict` | `Fileset -> Snapshot -> Snapshot` | §7.2 |
13121174| `review` | `Repo -> Text` | §10 |
13131175| `rewrite` | `(Commit -> Commit) -> Edit` | §8 |
1314−| `save` | `Edit` | §2 |
13151176| `select` | `Fileset -> Snapshot -> Snapshot -> Snapshot` | §7.2 |
13161177| `show` | `a -> Text` | §3 |
13171178| `siblings` | `Revset` | §7.3 |
@@ -1321,7 +1182,6 @@ j 'tree'
13211182| `stack` | `Revset` | §7.3 |
13221183| `startsWith` | `Text -> Text -> Bool` | §4 |
13231184| `status` | `Repo -> {…}` | §10 |
1324−| `subtreeCommits` | `a -> [Commit]` | §5 |
13251185| `tail` | `[a] -> [a]` | §4 |
13261186| `take` | `Int -> [a] -> [a]` | §4 |
13271187| `text` | `Blob -> Text` | §5 |
@@ -1335,7 +1195,6 @@ j 'tree'
13351195| `under` | `Path -> Fileset` | §7.2 |
13361196| `union` | `Revset -> Revset -> Revset` | §7.3 |
13371197| `unlabel` | `Label -> [Drop]` | §9 |
1338−| `unrecorded` | `Repo -> [Path]` | §10 |
13391198| `unresolved` | `Blob -> Bool` | §5 |
13401199| `up` | `Edit` | §6 |
13411200| `user` | `{ name : Text, email : Text }` | §1.1 |
docs/comparison.md+9−25
@@ -75,27 +75,21 @@ about: a commit keeps its identity (`@wqzt…`) across `describe`, `squash`,
7575`j push` takes an *expression* rather than flags: `j 'push (label "feature"
7676here)'` labels the focused commit, `j 'push relabel'` re-points the remote's
7777bookmarks after a rewrite.
78−Push sends recorded commits and warns before sending if files have
79−unrecorded edits. Run `j id`, then push again to include those edits.
8078
8179### 3.2 Recording work
8280
8381| Task | git | jj | j |
8482|---|---|---|---|
85−| Save current state | `git add … && git commit` | automatic (`jj st` shows it) | an edit records the snapshot; `j save` records *only* the snapshot |
83+| Save current state | `git add … && git commit` | automatic (`jj st` shows it) | automatic; `j id` to record *only* the snapshot |
8684| Set the message | `git commit -m "…"` | `jj describe -m "…"` or `jj describe` | `j 'describe "…"'` |
8785| Start the next change | (just keep editing) | `jj new` | `j new` |
8886| Amend the current commit | `git commit --amend` | `jj describe` + edit files in `@` | edit files + `j 'describe "…"'` |
8987| See what changed | `git diff` / `git show` | `jj diff` / `jj show` | `j review` (via difftastic) / `j status` |
9088
91−In git, "commit" is a deliberate act with a staging step. jj snapshots the
92−working copy automatically. In `j`, expressions see current file edits,
93−but only a changed `Repo` result records them: `describe` saves a message
94−and `new` opens the next change. `j status` and other inspections save
95−nothing. Use `j save` (an alias for `id`) to record the working directory and
96−nothing else. Status displays the `changed` field as `in commit`, comparing
97−the current commit with its parent; `unrecorded` shows file edits made since
98−the last save.
89+In git, "commit" is a deliberate act with a staging step. In jj and `j`, the
90+snapshot is continuous; `describe` sets a message and `new` opens the next
91+change. `j` has one deliberate-snapshot command, `j id`, for when you want to
92+record the working directory and nothing else.
9993
10094### 3.3 Looking around
10195
@@ -122,7 +116,7 @@ zipper with edits.
122116|---|---|---|---|
123117| To the parent | `git checkout HEAD~1` | `jj prev` *(≈, opens a new empty commit there)* | `j prev` |
124118| To the child | `git checkout <sha>` (manual) | `jj next` *(≈, opens a new empty commit there)* | `j next` |
125−| Start work on a named commit | `git checkout <branch>` | `jj new <rev>` | `j 'new . goto %main'` |
119+| To a named commit | `git checkout <branch>` | `jj new <rev>` | `j 'goto %main'` |
126120| To a specific change | `git checkout <sha>` | `jj new <change-id>` | `j 'by @wqzt'` |
127121| Back to your branch | `git checkout main` | `jj new main` | `j 'new . goto trunk'` |
128122
@@ -207,16 +201,14 @@ the remote's state explicit and impossible to mutate by accident.
207201| When do conflicts appear? | During merge/rebase, blocking it | Recorded *in* the commit, non-blocking | Recorded in the commit (unresolved `Blob`s) |
208202| Where do they live? | The working tree + index | In the commit itself | In the commit's `files`, as conflict blobs |
209203| How do you see them? | `git status` | `jj st`, `jj resolve` | `j conflicts`, `j status` |
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` |
204+| How do you resolve? | Edit files, `git add`, continue | Edit files; conflict is gone from `@` | Edit files, then any persisting expression (`j id`) |
211205
212206git stops the world on a conflict: a merge or rebase halts until you resolve
213207and `git add`. jj and `j` treat a conflict as *data* — a commit can simply
214208*contain* conflicted files, you can keep working, and you resolve whenever you
215209like. `j conflicts` lists the commits that have conflicts; checking one out
216−writes the markers to disk; editing the files and running `j save` records
217−the resolution. Check `j status` for `No conflicts` and `All edits recorded`.
218−There is no separate continuation command. `j review` uses a unified diff
219−when difftastic is not installed.
210+writes the markers to disk; editing the files and running `j id` records the
211+resolution.
220212
221213---
222214
@@ -235,12 +227,6 @@ a first-class, marked operation, so `undo`/`redo` are safe and precise. In
235227`j`, undoing a `push` restores the local labels (and leaves the remote
236228untouched).
237229
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−
244230---
245231
246232## 9. The interface itself
@@ -310,8 +296,6 @@ j new
310296# …edit…
311297j 'describe "part two"'
312298j squash
313−j prev # squash leaves an empty child; return to the combined commit
314−j 'describe "complete feature"'
315299j 'rebase %main'
316300j 'push (label "feature" here)'
317301```
docs/language.md+25−91
@@ -3,14 +3,7 @@
33`j` is a small, pure, functional language. Every `j` command evaluates one
44expression in this language against the current repository. The language is
55the whole interface: there are no subcommands beyond a handful of reserved
6−words, and no 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.
6+words, and no flags.
147
158This document describes the language itself — its values, syntax, semantics,
169and the builtins — with examples throughout. Every example shows an expression
@@ -24,17 +17,16 @@ and what it evaluates to, exactly as `j` prints it.
2417
2518## 1. The big picture
2619
27−An expression run of `j` looks like this:
20+A run of `j` looks like this:
2821
29221. Read the expression (from the command line or stdin).
30−2. Snapshot the working directory for evaluation.
23+2. Snapshot the working directory into the working-copy commit.
31243. Build a value `repo` describing the repository, focused on the
3225 working-copy commit.
33264. Evaluate the expression.
34275. If the result is a function, apply it to `repo`.
35286. If the final value is a `Repo` that differs from the one loaded, persist
36− the difference. An unchanged `Repo` saves nothing; other values print
37− without saving.
29+ the difference; otherwise print the value and change nothing.
3830
3931So the most common thing you write is an **edit** — a function `Repo -> Repo`
4032(or a value that becomes one). `describe "wip"` is an edit. `new` is an edit.
@@ -180,20 +172,8 @@ let r = { a = 1, b = 2 } in r { b = 99 }
180172> (\r -> r.name) ({ name = "j" }) -- OK
181173> ```
182174>
183−> The same applies inside lists: a record literal that follows another
184−> element is parenthesised. Written side by side, the second record updates
185−> the first, so `[{ id = 1 } { id = 2 }]` is the one-element list
186−> `[{ id = 2 }]`:
187−>
188−> ```
189−> [({ id = 1 }) ({ id = 2 })] -- OK: two records
190−> [{ root = c, children = [] }] -- OK: the only element
191−> ```
192−>
193−> A line break stops this: a `.name` or `{` that starts a line never attaches
194−> to what came before, so `r` with `.a` on the next (indented) line is
195−> `r (.a)`, not `r.a`. Keep a selector or update on the line where the
196−> expression it applies to ends.
175+> The same applies inside lists: `[{ root = c, children = [] }]` — write the
176+> braces as a parenthesised element `[(…)]` if it follows another element.
197177
198178### Functions
199179
@@ -234,8 +214,7 @@ distinct id each time it is evaluated (see §7).
234214
235215A `Blob` is the content of one file, either resolved or unresolved (a
236216conflict). 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
238−resolved regular file from text; `text` reads it back:
217+`files`. `blob "…"` builds a resolved one, `text` reads it back:
239218
240219```
241220text (blob "file content")
@@ -286,10 +265,9 @@ in even 10
286265> binding column.
287266
288267Because evaluation is strict (call by value), a non-function binding is
289−evaluated immediately, though after the bindings it needs, whatever order
290−they are written in: `let a = b; b = 5 in a` is 5. Bindings that need each
291−other's values, as in `let a = b; b = a in a`, crash; recursion works through
292−*functions*, whose bodies aren't evaluated until applied.
268+evaluated immediately. `let a = b; b = 5 in a` crashes (`b` isn't bound yet
269+when `a` is evaluated); recursion works through *functions*, whose bodies
270+aren't evaluated until applied.
293271
294272### No shadowing
295273
@@ -301,12 +279,6 @@ This is an error, not silent shadowing:
301279let x = 1 in let f = \x -> x in f 2 -- parse error: `x` is already bound
302280```
303281
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−
310282### Lexical scope
311283
312284Names resolve lexically: innermost `let` bindings, then lambda parameters,
@@ -382,7 +354,7 @@ A bare `.name` is the accessor function `\x -> x.name`:
382354```
383355.name ({ name = "j" })
384356 => "j"
385−map (.id) [({ id = 1 }) ({ id = 2 })]
357+map (.id) ([{ id = 1 } { id = 2 }])
386358 => [1 2]
387359```
388360
@@ -561,9 +533,6 @@ crash "my message"
561533
562534Parse errors, configuration errors, and `Id`-resolution failures happen
563535*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.
567536
568537---
569538
@@ -647,13 +616,6 @@ unresolved (blob "x") => false
647616`unresolved b` is `true` iff the blob is a conflict (produced by a clashing
648617`replay`). `text` on an unresolved blob yields its conflict-marker rendering.
649618
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−
657619### Shapes and `extract`
658620
659621A type name in an expression is a `Shape`. `extract S v` returns every
@@ -685,10 +647,6 @@ A function that has a **signature** (a builtin, or a definition with one in
685647parameter type as it is supplied, and the final result against the result
686648type.
687649
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−
692650The check is one level deep — it never inspects inside a list or a record's
693651field values (that would make every call cost the size of the value).
694652
@@ -718,17 +676,11 @@ unchecked except through the functions they call.
718676
719677## 12. `show`: rendering values as text
720678
721−When it succeeds, `show v` renders a value as an expression in the language.
722−Except for closures and unresolved blobs, the result parses and evaluates
679+`show v` renders any value as an expression in the language, such that
680+(except for closures and unresolved blobs) the result parses and evaluates
723681back to an equal value. Since `show` returns a `Text`, and a top-level `Text`
724682prints raw, `j show …` is how you inspect values.
725683
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−
732684```
733685show 42 => "42"
734686show "hi" => "\"hi\""
@@ -744,7 +696,7 @@ Functions render readably:
744696show (map) => "map"
745697show (+) => "(+)"
746698show (describe "wip") => "describe \"wip\""
747−show (\x -> x + 1) => "\\x -> x + 1"
699+show (\x -> x + 1) => "\x -> (x + 1)"
748700```
749701
750702Output longer than 80 columns breaks across lines (one element per line,
@@ -778,8 +730,7 @@ prev -- to the parent (crashes at the top)
778730next -- to the only child (crashes if not exactly one)
779731up -- like prev, the raw zipper move
780732by @wqzt -- refocus on a specific commit
781−tree . goto %main -- inspect main without moving the working copy
782−new . goto %main -- start a mutable child on main
733+goto %main -- refocus on what the remote calls main
783734parents -- the focus's parents, as a Revset
784735kids -- its children
785736ancestors -- it and its ancestors
@@ -792,7 +743,6 @@ Edits — functions `Repo -> Repo`:
792743
793744```
794745new -- a new empty child of the focus
795−save -- record file edits without changing the description
796746describe "wip" -- set the focus's message
797747squash -- fold the focus into its parent
798748abandon -- drop the focus; children join the parent
@@ -807,9 +757,7 @@ And the inspections:
807757
808758```
809759tree -- the history as a tree (a Text)
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
760+status -- { id, message, labels, changed, conflicts }
813761review -- the focus's changes as one page of diffs
814762files -- the focus's snapshot (its files)
815763commits . top -- every commit, preorder
@@ -834,13 +782,10 @@ j 'tree'
834782-- the second commit was a mistake; fold it into the first
835783j 'squash'
836784
837−-- squash leaves a fresh empty child; move back and rename the combined commit
838−j prev
785+-- rename the combined commit
839786j 'describe "add the front end"'
840787
841−-- undo the rename, the focus move, then the squash
842−j undo
843−j undo
788+-- undo the squash entirely
844789j undo
845790
846791-- move back to the first commit to fix something
@@ -869,22 +814,17 @@ diff (blob "a\nb\n") (blob "a\nc\n")
869814 => "@@ -1,2 +1,2 @@\n a\n-b\n+c\n"
870815```
871816
872−`difft p a b` renders the comparison with **difftastic** when it is on `PATH`
873−(the Nix package provides it). When it is absent, the builtin returns a
874−unified diff with path headers, or a summary for binary changes. `review`
875−uses `difft` per changed path; other errors starting the tool still fail.
817+`difft p a b` renders the comparison with **difftastic** (which must be on
818+`PATH`; the Nix package provides it). `review` uses `difft` per changed path.
876819
877820`treeWith options repo` renders the history tree with an options record:
878821
879822```
880−treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
823+treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
881824```
882825
883−The reference config provides `tree`, `treeCompact` (folded runs and distant
884−subtrees), `treeFull` (ages and authors in the margin), and `treeData` (extra
885−date, files-changed, and author columns). Branches beyond `lanes` keep their
886−state marker beside `»`; increase `lanes` to show more rails. Long file paths
887−in detail rows wrap to fit the terminal.
826+The reference config gives two ready-made versions: `tree` (compact) and
827+`treeFull` (with ages and authors in the margin).
888828
889829---
890830
@@ -960,14 +900,8 @@ the algebra they are.
960900
961901**Text:** `splitOn` `startsWith` `endsWith` `concat` `++`
962902
963−**Blobs/repos:** `blob` `blobHex` `text` `unresolved` `replay` `by` `meta` `extract`
903+**Blobs/repos:** `blob` `text` `unresolved` `replay` `by` `meta` `extract`
964904`diff` `difft` `treeWith` `validate`
965905
966−**Reserved commands** (not expressions): `help [NAME]` `commands [QUERY]`
967−`setup [NAME EMAIL]` `init` `clone URL [DIR]` `remote URL`
906+**Reserved commands** (not expressions): `init` `clone URL [DIR]` `remote URL`
968907`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+36−102
@@ -4,17 +4,9 @@ This is a guided first hour with `j`. It builds the mental model piece by
44piece — not just *what* to type, but *why it works* — so that by the end you
55can compose your own commands instead of memorizing them.
66
7−You'll want `j` installed (`nix build` or `cargo build --release`). Set up
8−your configuration with your own name and email:
9−
10−```
11−j setup 'Ada Lovelace' ada@example.org
12−```
13−
14−This creates the full reference configuration without overwriting an existing
15−file. If you already have a config, `j help config` shows its location so you
16−can edit the `user` definition. The remaining commands run in a real
17−repository, so you can follow along.
7+You'll want `j` installed (`nix build` or `cargo build --release`) and a
8+`config.j` with your identity (see the README's Configuration section). Every
9+command shown here is run in a real repository, so you can follow along.
1810
1911---
2012
@@ -28,11 +20,11 @@ j init
2820```
2921
3022`j init` makes a colocated `.jj`/`.git` repository and checks out a fresh
31−working-copy commit: the commit you are currently editing. It already exists,
32−so write a file and describe that commit:
23+working-copy commit. Now write a file and start your first change:
3324
3425```
3526echo 'fn main() {}' > main.rs
27+j 'new'
3628j 'describe "start the project"'
3729```
3830
@@ -44,48 +36,35 @@ j 'tree'
4436
4537```
4638 ⌂ zzzz
47−▶ └─ ◉ pnyz ▂ start the project
39+ └─ ● pnyz ▂
40+▶ └─ ◌ psnq start the project
4841```
4942
5043Add a second change on top:
5144
5245```
53−j 'new'
5446echo 'lib' > lib.rs
47+j 'new'
5548j 'describe "add a library"'
5649j 'tree'
5750```
5851
5952```
6053 ⌂ zzzz
61− └─ ● pnyz ▂ start the project
62−▶ └─ ◉ psnq ▂ add a library
54+ └─ ● pnyz ▂
55+ └─ ● psnq ▂ start the project
56+▶ └─ ◌ trtq add a library
6357```
6458
65−That's the basic loop: **edit files, describe the current change, then `new`
66−before editing the next change.** `new` opens an empty change with the same
67−files as its parent; it does not move existing edits into that new change.
68−There is no `git add` step. `describe` saves both the current files and their
69−description; `j save` records file edits without changing the description.
59+That's the whole basic loop: **edit files, `new`, `describe`.** There is no
60+`git add` step — the working directory is always already a commit, and `j`
61+snapshots it on every run.
7062
71−**What just happened, in the model:** each expression snapshots your working
72−directory for evaluation, builds a `Repo` value focused on that commit,
63+**What just happened, in the model:** each `j` run snapshots your working
64+directory into the working-copy commit, builds a `Repo` value focused there,
7365evaluates your expression, and — because `new` and `describe` are *edits*
7466(functions `Repo -> Repo`) — persists the resulting repository.
7567
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−
8968---
9069
9170## Part 2 — The one idea everything rests on
@@ -154,28 +133,22 @@ function produces, so don't wrap it.
154133Make a few commits to play with:
155134
156135```
157−j new; echo a > a.txt; j 'describe "A"'
158−j new; echo b > b.txt; j 'describe "B"'
159−j new; echo c > c.txt; j 'describe "C"'
136+echo a > a.txt; j 'new'; j 'describe "A"'
137+echo b > b.txt; j 'new'; j 'describe "B"'
138+echo c > c.txt; j 'new'; j 'describe "C"'
160139```
161140
162−Navigation changes the working-copy commit and checks out its files. Use
163−these commands to edit another mutable commit:
141+Navigation is edits too:
164142
165143```
166144j 'prev' -- to the parent
167145j 'next' -- to the only child
146+j 'top' -- to the top of history
168147j 'tip' -- to the end of the current line
169148j '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
170150```
171151
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−
179152After `j 'prev'` from C, the tree shows the focus (▶ and ◉) on B:
180153
181154```
@@ -191,16 +164,11 @@ To see the focus's data, use the inspection functions:
191164```
192165j 'files' -- the focus's files
193166j 'changed' -- paths that differ from the parent
194−j 'status' -- focus, changes in the commit, unrecorded edits, conflicts
167+j 'status' -- id, message, labels, changed, conflicts
195168j 'log' -- a table of every commit, ✓ on the focus
196169j 'review' -- the focus's changes as diffs (difftastic)
197170```
198171
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−
204172### Composition order, made concrete
205173
206174```
@@ -226,10 +194,11 @@ operates on the repository the previous step produced.
226194
227195## Part 4 — Dry runs: seeing before doing
228196
229−Use `preview` to validate an edit and show its resulting tree without saving:
197+Because inspections persist nothing, you can preview any edit by composing it
198+with `tree`:
230199
231200```
232−j 'preview squash'
201+j 'tree . squash'
233202```
234203
235204From the A → B history, this *shows* the tree as `squash` would leave it (B
@@ -252,21 +221,18 @@ because `tree` returns a `Text`, not a `Repo`:
252221▶ └─ ◌ nknz B
253222```
254223
255−`preview` combines `tree` and `validate`. For an edit that takes arguments,
256−put that edit in parentheses:
224+For a stronger guarantee, insert `validate`. `validate repo` returns the repo
225+unchanged if persisting would succeed, and crashes with the exact error
226+otherwise:
257227
258228```
259−j 'preview (rebase %main)'
229+j 'tree . validate . rebase %main'
260230```
261231
262232This renders the post-rebase tree **and** crashes (without touching anything)
263233if that rebase couldn't actually happen — for instance, if `%main` doesn't
264234exist yet. That's an *honest* dry run.
265235
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−
270236---
271237
272238## Part 5 — `or`: the whole error-handling model
@@ -324,7 +290,7 @@ Two things make these safe to use freely:
324290
3252911. **The change id survives.** A commit keeps its `@wqzt…` id across
326292 `describe`, `squash`, `rebase`. You're never tracking a moving sha.
327−2. **Saved operations are undoable.** Use `j undo` and `j redo` to walk them.
293+2. **`j undo` always works.** Every operation is recorded.
328294
329295```
330296j undo -- revert the last thing
@@ -332,28 +298,6 @@ j redo -- reapply it
332298j ops -- see the operation log
333299```
334300
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−
357301### `split` and `contract`: moving changes by fileset
358302
359303A **fileset** is a function `Path -> Bool`. These two are inverses:
@@ -397,12 +341,9 @@ Most commands that take a "which commits" argument take a revset. `goto` is
397341the one place a revset must name *exactly one* commit:
398342
399343```
400−j 'new . goto %main' -- select one main commit and start a mutable child
344+j 'goto %main' -- crash if %main names 0 or 2+ commits
401345```
402346
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−
406347Revsets combine like sets:
407348
408349```
@@ -446,9 +387,9 @@ Everything `j` knows is defined in `config.j`. Any definition whose value is
446387an `Edit` is a new command. Add this to your `~/.config/j/config.j`:
447388
448389```
449−-- squash into the parent and name the combined commit "release"
390+-- fold the whole stack into one commit called "release"
450391release : Edit
451−release = describe "release" . prev . squash
392+release = describe "release" . (contract everything)
452393```
453394
454395Now `j release` works exactly like `squash` or `new`. The signature (`: Edit`)
@@ -491,8 +432,8 @@ echo 'fn parse() {}' > parser.rs
491432j 'describe "add the parser"'
492433
493434# second part
494−j 'new'
495435echo 'mod parser;' > lib.rs
436+j 'new'
496437j 'describe "wire up the parser"'
497438
498439# look at it
@@ -501,24 +442,17 @@ j 'tree'
501442# the second commit was really part of the first; fold it in
502443j squash
503444
504−# squash leaves an empty child focused; move to the combined commit
505−j prev
506−
507445# rename the combined commit
508446j 'describe "add the parser"'
509447
510448# preview the rebase, then do it
511−j 'preview (rebase %main)'
449+j 'tree . validate . rebase %main'
512450j 'rebase %main'
513451
514452# share it
515453j 'push (label "parser" here)'
516454```
517455
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−
522456And if any step goes wrong:
523457
524458```
specs/main.md+198−948
@@ -9,7 +9,7 @@ Nothing is implicit beyond the language itself. Every shape, builtin, and
99command is declared or defined in one user-owned file, `~/.config/j/config.j`,
1010written in the language. The binary provides the language (its syntax, its
1111operators and their fixities, its primitive kinds), the implementations of the
12−declared builtins, eleven reserved words, and the bridge to jj repositories.
12+declared builtins, eight reserved words, and the bridge to jj repositories.
1313`j` does not require the `jj` binary; it uses jj's on-disk formats through
1414jj-lib.
1515
@@ -40,37 +40,21 @@ echo EXPRESSION | j
4040 `::`, `or`, `.`, and the literals `%name`, `@id`, `./a/b` are all safe
4141 unquoted.
42423. If arguments are present and rule 1 also applies, exit 2 with
43− `j: expression given both as arguments and on stdin`. One byte is
44− sufficient to reject the invocation; the pipe need not reach EOF.
45−4. If neither is present, or the input is only whitespace, print a concise
46− usage and getting-started guide to stderr and exit 2. The guide points to
47− `j help`, `j commands`, and commands for starting or inspecting a repository.
48− It does not read or create configuration or open a repository.
49−5. `--help` and `-h` as the first word alias `help` (§1.1). A separate
50− argument equal to either flag after the first argument requests help for
51− the first word, before repository access or evaluation: `j abandon --help`
52− is `j help abandon`. An initial `help`, `--help`, or `-h` followed by
53− another help flag opens general help. Other flags are not supported.
54− Language comments embedded within a single argument or stdin are unchanged:
55− `j 'new --help'` executes `new`, and `j '"--help"'` prints text.
56−6. The expression is text: an argument that is not valid UTF-8 is a usage
57− error (exit 2) naming it, and so is an expression on standard input that
58− is not.
43+ `j: expression given both as arguments and on stdin`.
44+4. If neither is present, print a one-line usage message to stderr and exit 2.
45+5. `j` accepts no flags. Any argument, including ones beginning with `-`, is
46+ part of the expression.
5947
6048### 1.1 Reserved commands
6149
6250Before the expression is parsed, its text is trimmed of leading and trailing
6351whitespace and split on whitespace into words. If the first word is one of the
64−eleven reserved words below, the whole text is a reserved command. A first word
65−of `--help` or `-h` is treated as `help`.
52+eight reserved words below, the whole text is a reserved command.
6653
6754| command | operation |
6855|---|---|
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 |
7256| `init` | create a repository in the current directory; §7.8 |
73−| `clone URL [DIR]` | clone `URL` into `DIR` (default: the repository's name in `URL`); §7.8 |
57+| `clone URL [DIR]` | clone `URL` into `DIR` (default: last path component of `URL`, minus `.git`); §7.8 |
7458| `remote URL` | set the URL of the remote `origin`, creating it if absent; §7.8 |
7559| `fetch` | fetch from `origin`; §7.6 |
7660| `push EXPR` | set or delete the remote bookmarks that `EXPR` selects; §7.6 |
@@ -78,64 +62,24 @@ of `--help` or `-h` is treated as `help`.
7862| `redo` | reverse the newest undo; §7.7 |
7963| `ops` | print the operation log; §7.7 |
8064
81−For every reserved command except `push` and `commands`, the remaining words
82−are literal arguments, and the language is not involved. When the first
83−shell argument is the command name, the remaining shell argument boundaries
84−are preserved, so `j clone '/path/to/source repo' 'my project'` accepts both
85−paths. A command supplied as one string or on stdin is split on whitespace;
86−it has no additional quote or escape syntax. A wrong argument count is a
87−usage error (exit 2). `commands` takes the remaining
88−trimmed text as its optional search query, including spaces. For `push`, the
89−remainder of the text is an expression, evaluated as in §1.2 steps 2–7 but
90−against the repository as recorded, without snapshotting the working directory;
91−its value must be a list of push records (§7.6).
65+For every reserved command except `push`, the remaining words are literal
66+arguments (no quoting, no escaping); a wrong number is a usage error (exit 2)
67+and the language is not involved. For `push`, the remainder of the text is an
68+expression, evaluated as in §1.2 steps 2–7 but against the repository as
69+recorded, without snapshotting the working directory; its value must be a list
70+of push records (§7.6).
9271
9372Matching is on the first word only, so an expression whose first token is one
9473of these words cannot be written on the command line. `(push)` or `x fetch`
9574are ordinary expressions. `config.j` may define functions with these names;
9675they are reachable only inside larger expressions.
9776
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−
13277### 1.2 Evaluating the expression
13378
134791. Locate the repository (§7.1). If none, exit 2.
135802. Parse the expression (§3). On parse error, exit 3.
136813. Load and validate `config.j` (§6), except for its `Id` literals. If it is
137− missing, create the reference configuration (§6.1); if loading or
138− validation fails, exit 3.
82+ missing or invalid, exit 3.
139834. Build the current repository value `r` (§7.2). This includes snapshotting
14084 the working directory into the focused commit (§7.4).
141855. Resolve every `Id` literal in `config.j` and in the expression (§4.10).
@@ -146,8 +90,7 @@ resolves commit ids, snapshots the working directory, or records operations.
146907. If `v` is a function, apply it to `r`, once, and let `v` be the result.
147918. If `v` is a `Repo` (§4.4): if it equals the repository value *as loaded,
14892 before the snapshot of §7.4*, do nothing at all; otherwise persist it
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).
93+ (§7.5) and check out its focused commit's files (§7.4). Print nothing.
15194 Exit 0.
152959. Otherwise display `v` (§5.1) on stdout. Persist nothing. Exit 0.
15396
@@ -157,7 +100,7 @@ persist nothing, leave the working directory untouched, and exit 1.
157100Step 7 applies exactly once. If the result of applying a function to `r` is
158101again a function, that function is displayed, not applied again.
159102
160−Step 8's equality test means `j save` (the reference alias for `id`) is a no-op when the working directory is
103+Step 8's equality test means `j id` is a no-op when the working directory is
161104unchanged and records it when it is not: the snapshot makes `r` differ from
162105the loaded value, and that difference is what gets persisted. The test may be
163106implemented by comparing commit and tree hashes rather than values.
@@ -169,34 +112,16 @@ Two consequences worth knowing:
169112 what a rebase would produce without doing it. `validate` in the chain,
170113 `j tree . validate . rebase trunk`, additionally raises whatever
171114 persistence would refuse, so the preview is of something that could
172− actually be persisted. The reference `preview EDIT` is the convenient
173− form of `tree . validate . EDIT`: `j 'preview (rebase trunk)'`. Validation
174− alone returns a `Repo`, so `validate . EDIT` persists a changed result.
175−- **Do, then look.** `j squash` reports the resulting focus on an interactive
176− terminal. `j squash && j tree` displays the whole tree; there is no flag for it.
115+ actually be persisted.
116+- **Do, then look.** `j squash` prints nothing. `j squash && j tree` is the
117+ idiom; there is no flag for it.
177118
178119### 1.3 Interrupts
179120
180121There is no timeout. A non-terminating expression runs until interrupted.
181−Interrupting `j` (SIGINT) kills it, as SIGTERM, SIGHUP and SIGQUIT do.
182−Nothing is written until the operation is recorded (§7.5 step 7), so a run
183−interrupted before then persists nothing and leaves the working directory
184−untouched. From the recording until the focus is checked out and the
185−working copy's state is saved, those four signals wait: the checkout
186−completes, the run reports what it did, and then the signal takes effect as
187−it would have. A run that ends in that window regardless (SIGKILL, a crash,
188−a power loss) leaves the operation recorded and the working directory
189−partly updated, as a checkout that fails does: the next run carries on from
190−there (§7.4), and `j undo` goes back.
191−
192−`clone` (§7.8) writes its directory from the start, so it holds the four
193−signals off from creating it. One that comes while it creates the
194−repository, fetches and imports lets the step in progress end (the git it
195−runs, which a Ctrl-C at the terminal stops too), then fails the clone, which
196−removes what it made, and then takes effect; one that comes later, once it
197−makes the working-copy commit, waits for the clone to complete. A signal `j`
198−was started ignoring (`nohup`, a background job) stays ignored, by the git
199−it runs too.
122+Interrupting `j` (SIGINT) kills it; because nothing is written until
123+evaluation completes, an interrupted run persists nothing and leaves the
124+working directory untouched.
200125
201126### 1.4 Exit status
202127
@@ -207,67 +132,10 @@ it runs too.
207132| 2 | usage error: bad arguments, no repository, unsupported repository (§7.2), another `j` running (§7.7) |
208133| 3 | configuration or parse error |
209134
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−
216135Errors are written to stderr as a single line beginning with `j: `. Crash
217136messages are the text given to `crash` (or generated by the interpreter),
218137prefixed with `j: crash: `, followed on a second line by the innermost base
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.
138+definition that was executing and the top-level expression.
271139
272140---
273141
@@ -299,8 +167,7 @@ same value if they have the same field names with equal values.
299167
300168### 3.1 Lexical structure
301169
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.
170+- **Comments.** `--` to end of line. `{-` … `-}`, nesting.
304171- **Identifiers.** `[a-z_][A-Za-z0-9_']*`. Keywords are excluded:
305172 `let in if then else or true false`.
306173- **Type names.** `[A-Z][A-Za-z0-9_]*`. Declared as shapes in `config.j`;
@@ -356,7 +223,7 @@ keyword and is always available.
356223
357224### 3.3 Layout
358225
359−Three layout rules and nothing else.
226+Two layout rules and nothing else.
360227
3612281. **Top level.** A top-level item begins with a token in column 1. Every
362229 following line whose first token is in a column greater than 1 continues
@@ -364,22 +231,12 @@ Three layout rules and nothing else.
3642312. **`let` blocks.** After `let`, the column of the first token opens a block.
365232 A subsequent line whose first token is in exactly that column begins a new
366233 binding; a line indented further continues the current binding; the block
367− ends at `in` or at the first token in a smaller column. An `in` that begins
368− a line obeys the enclosing layout like any other token: in an item it must
369− be past column 1, and in an outer block's binding past that block's column.
370− Bindings on a single line are separated by `;`. Blank and comment-only
371− lines are ignored here too.
372−3. **Selectors and updates.** A `SELECTOR` or `{` that is the first token of
373− a line never selects from or updates what precedes it. On a continuation
374− line it begins a new `postfix` (§3.4), which after another `postfix` is
375− the next application argument or list element: `r` followed by a line
376− ` .a` is `r (.a)`, and followed by ` { a = 2 }` it is `r ({ a = 2 })`.
377− So `r.a` and `r { a = 2 }` need the `.a` or `{` on the line `r` ends on.
378− `show` relies on this to break a list of records one per line with nothing
379− between them (§5.2).
234+ ends at `in` or at the first token in a smaller column. Bindings on a single
235+ line are separated by `;`. Blank and comment-only lines are ignored here
236+ too.
380237
381238An expression given on the command line or on stdin is a single `expr`, not a
382−list of items, so rule 1 does not apply to it; rules 2 and 3 do.
239+list of items, so rule 1 does not apply to it; rule 2 does.
383240
384241### 3.4 Grammar
385242
@@ -405,7 +262,7 @@ binding := ident '=' expr
405262
406263opexpr := app (op app)* -- resolved by the fixity table
407264app := postfix+ -- juxtaposition; left-associative
408−postfix := atom ( SELECTOR | '{' fields '}' )* -- no SELECTOR or '{' after a line break (§3.3)
265+postfix := atom ( SELECTOR | '{' fields '}' )*
409266atom := ident | TYPENAME | INT | TEXT | IDLIT | NEWID | LABELLIT | PATHLIT | 'true' | 'false'
410267 | '(' expr ')' | '(' op ')' | '(' op expr ')' | '(' expr op ')'
411268 | '[' postfix* ']'
@@ -433,23 +290,9 @@ Notes:
433290 a `postfix` (an atom with selectors and updates): `[1 2 3]`,
434291 `["src" "lexer.rs"]`, `[{ root = c, children = [] }]`. An application or
435292 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
438− record updated by the second. Records keep commas.
293+ Records keep commas.
439294- Lambda, `if`, `let`, and `or` extend as far right as possible.
440295- 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.
453296
454297---
455298
@@ -465,19 +308,8 @@ refers to outside a lambda. A cycle among such references (two non-lambda
465308definitions each needing the other's value) is a configuration error;
466309references inside lambda bodies never form cycles, since a lambda is a value
467310before it is applied. The bindings of a `let` block are evaluated by the same
468−rule, with a cycle being a crash. A top-level definition is moreover evaluated
469−after every definition that it reaches through a chain of references of
470−either kind but that does not reach it back. Among definitions that do reach
471−each other, one that refers to none of the others outside a lambda, and whose
472−every lambda outside other lambdas that refers to one of them is stored, is
473−evaluated before the rest, since it cannot apply their functions; an
474−expression is stored when it is the whole definition, or a record field, list
475−element, `if` branch or `let` body of a stored expression. So a function
476−applied at load finds the definitions its body refers to, save one that
477−reaches back to the definition applying the function and is not evaluated
478−before the rest; the order among the rest is otherwise unspecified. A `let`
479−binding is ordered the same way among the bindings of its block. A lambda
480−body is evaluated on each application.
311+rule, with a cycle being a crash. A lambda body is evaluated on each
312+application.
481313Recursion is unbounded; the interpreter must handle deep recursion without
482314stack overflow (or turn exhaustion into a crash).
483315
@@ -491,18 +323,9 @@ function.
491323
492324There is no shadowing. A `let` binding or lambda parameter may not use a name
493325already bound in an enclosing scope, including top-level definitions and
494−builtins (reserved whether or not declared, §6.2); in `config.j` that is a
495−configuration error (exit 3), on the command line a parse error. A lambda's
496−earlier parameters enclose its later ones, and all bindings of a `let` block
497−enclose each of its expressions, since they are mutually recursive (§4.1), so
498−`\x x -> x` and `let a = \b -> b; b = 2 in a 1` are errors too. The one
499−exception is within `config.j`, whose binders may reuse the names of its own
500−top-level definitions, as the reference config's `\files ->` and
501−`\old new repo ->` do: within the binder's scope the name means the binder,
502−for evaluation order (§4.1) as for lookup, so a `let` binding used before it
503−is evaluated is a crash there as anywhere, never the definition. Defining a
504−top-level name twice, or defining a name that is also a builtin, is a
505−configuration error.
326+declared builtins; in `config.j` that is a configuration error (exit 3), on
327+the command line a parse error. Defining a top-level name twice, or defining a
328+name that is also a declared builtin, is a configuration error.
506329
507330### 4.3 Records
508331
@@ -528,9 +351,7 @@ negation. Ordering operators are defined on `Int` only and crash otherwise.
528351
529352`if`, `or`, `&&`, and `||` do not evaluate all operands. `&&` and `||`
530353short-circuit. The interpreter recognises `&&` and `||` by name; `or` is a
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.
354+keyword.
534355
535356`a or b` evaluates `a`. If that crashes, the crash is discarded and `b` is
536357evaluated and returned. Otherwise, if the value of `a` is a function, `b` is
@@ -596,19 +417,14 @@ as in §4.13.
596417| `replay onto ch` | replay the change `ch` (`{ from, to }`) onto the snapshot `onto`; §7.3 |
597418| `unresolved b` | `true` iff the blob is a conflict |
598419| `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 |
600420| `text b` | the content of a blob as text; for an unresolved blob, its conflict-marker rendering |
601421| `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) |
605−| `diff a b` | a unified diff from the text of `a` to the text of `b`, split into lines after each `\n` (not at a lone `\r`), no `---`/`+++` header lines, three lines of context, each hunk headed `@@ -s,n +s,n @@` with the first line and the number of lines it shows of each side, as `diff -u` writes it, a line that has no trailing newline followed by `\ No newline at end of file`; `""` if equal; crashes if either is not UTF-8 |
606−| `difft p a b` | the output of difftastic comparing `a` to `b` as the file `p`, with a unified-diff fallback when the executable is absent; §7.10 |
607−| `unrecorded repo` | paths changed between the invocation's loaded and snapshotted focus files; §7.4. Returns `[]` without snapshot context, including in a push expression |
422+| `diff a b` | a unified diff from the text of `a` to the text of `b`, no header lines, three lines of context; `""` if equal; crashes if either is not UTF-8 |
423+| `difft p a b` | the output of difftastic comparing `a` to `b` as the file `p`; §7.10. Crashes if `difft` is not on `PATH` |
608424| `treeWith o r` | the history rendered as a tree with options record `o`; §7.11 |
609425| `extract S v` | every subvalue of `v` that is an `S`; §4.12 |
610426| `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) |
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 |
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 |
612428
613429### 4.10 Ids: existing and new
614430
@@ -636,12 +452,10 @@ renders a minted id like any other.
636452
637453### 4.11 Label literals
638454
639−`%main` denotes `labelled "main"`, a `Revset`, as if written out where it
640−stands: in the scope of a `config.j` binder named `labelled` (§4.2) it applies
641−the binder, and it is a reference to it for evaluation order (§4.1). It is a
642−function, evaluated when applied, so it never fails at parse time: a name the
643−remote does not have yields `[]`, and `goto %nope` crashes with "expected one
644−revision, got 0" as any revset would.
455+`%main` denotes `labelled "main"`, a `Revset`. It is a function, evaluated
456+when applied, so it never fails at parse time: a name the remote does not have
457+yields `[]`, and `goto %nope` crashes with "expected one revision, got 0" as
458+any revset would.
645459
646460### 4.12 Shapes as values, and `extract`
647461
@@ -649,8 +463,7 @@ A type name used in an expression evaluates to a *shape*: for a `typedecl`
649463whose type is a record, the set of its field names; for a primitive kind
650464(`Int`, `Text`, `Bool`, `Id`, `Blob`), that kind; for a `typedecl` that is an
651465alias of another type, that type's shape. A shape whose type is a function
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`).
466+(`Edit`, `Revset`), a list, or undeclared is a crash when used.
654467
655468`extract S v` returns every subvalue of `v` that is an `S`, as a list. It
656469walks lists in order and records in ascending field-name order, descending
@@ -675,32 +488,11 @@ its `root`. Where order matters, `commits (top repo)` is preorder.
675488Every application of a function that has a signature — a builtin or a
676489definition with a signature — is checked against it: each argument as it is
677490supplied, against the corresponding parameter type, and the result once the
678−function's own application completes (its body has run or the builtin has
679−all its arguments), against the result type.
491+application is complete (yields a non-function), against the result type.
680492Lambdas without a signature and the command-line expression are unchecked
681−except through the functions they call. A signature covers all the arguments
682−it lists, however the definition's value is curried: `describe : Text -> Edit`
683−is `\m -> mapRoot …`, and the `Repo` that `describe m` is then applied to is
684−checked as its second argument, and the result against `Repo`; with
685−`addT : Int -> Int -> Text` and `addT = (+) . (\x -> x + 1)`, the `5` of
686−`addT 1 5` is checked as its second argument, and the `7` against `Text`. A
687−signature only adds checks and never changes when a body is evaluated
688−(§4.1): `describe m` evaluates `mapRoot …` at once, so an `or` around it
689−catches a crash there (§4.6); the value must be a function, since the
690−signature lists a further argument, and shows as `describe m` (§5.2);
691−`addT 1` likewise shows as `addT 1`, not as the `(+) 2` it holds. Arguments
692−are numbered as the signature lists them, even when the definition's value is
693−a partial application or a composition holding arguments of its own (`tree 5`
694−is a violation at argument 1); when the value is an `or` of functions (§4.6),
695−the check is around the lifted function, so a result that violates the
696−signature is a crash, not a reason to try the other side. A signature on
697−a definition that is not a function (`user`, `conflicts`) is checked once,
698−against the value, when the definition is evaluated at load.
699−
700−Infix `&&` and `||` must be declared like other builtins. Their contracts
701−check each operand that is evaluated and the result, including a result
702−decided by the left operand alone. A skipped right operand is neither
703−evaluated nor checked.
493+except through the functions they call. A signature on a definition that is
494+not a function (`user`, `conflicts`) is checked once, against the value, when
495+the definition is evaluated at load.
704496
705497The check is one level deep and never inspects inside a list or a record's
706498field values:
@@ -712,7 +504,7 @@ field values:
712504| `{ a : T, … }`, or a shape name | the value is a record with exactly those field names |
713505| `A -> B` | the value is a function |
714506| a type variable | anything |
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 |
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) |
716508
717509Field values and list elements are not checked because that would make every
718510call cost the size of the value and would force lazily backed snapshots and
@@ -745,9 +537,7 @@ indented under their key or bullet. Colour follows §7.11's rules and is used
745537only when stdout is a terminal; nothing is conveyed by colour alone.
746538
747539Shapes 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
750−display forms. Any other record is generic.
540+`Subtree`, `Repo`, `Frame`, `Change`. Any other record is generic.
751541
752542| kind | line form | block form |
753543|---|---|---|
@@ -756,7 +546,7 @@ display forms. Any other record is generic.
756546| `Text` | raw, first line, `…` if truncated | raw, all of it, trailing newline added if absent |
757547| `Id` | shortest unique prefix (bold), remainder (dim) | same |
758548| `Blob` | `‹214 B›`, `‹✖ 3.1 KB›` if unresolved | its content, raw (markers if unresolved) |
759−| function | name, lambda source, or `f arg …`; first line, `…` if truncated | same, all of it |
549+| function | name, lambda source, or `f arg …` | same |
760550| `Shape` | its type name | same |
761551| `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 |
762552| `Entry` | `path size`, `✖` prefix if unresolved | same |
@@ -772,35 +562,10 @@ display forms. Any other record is generic.
772562| list of other scalars | `a, b, c` | same, wrapped at the terminal width |
773563| any other list | `n items` | one block per item, separated by a blank line |
774564
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−
790565An empty list of any kind prints `none` (dim) in block form and `0 …` in
791566line form. Author and age in a `Commit` block are read from jj's commit
792567metadata 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
803−line draws its focus as the focus. A list of `[Text]` prints as paths because
568+they are not fields of the value. A list of `[Text]` prints as paths because
804569that is what such a list is in every base function; `show` is available
805570when the literal is wanted. Alignment in tables and columns uses display
806571width (East Asian wide characters count two cells, combining marks zero), not
@@ -810,13 +575,12 @@ Examples:
810575
811576```
812577$ j status
813−in commit src/lexer.rs
814− src/parser.rs
815−conflicts src/lexer.rs
816−id wqzt·kpqxmnrvyxskptlmzzzzabcd
817−labels feature
818−message wip
819−unrecorded All edits recorded
578+changed src/lexer.rs
579+ src/parser.rs
580+conflicts src/lexer.rs
581+id wqzt·kpqxmnrvyxskptlmzzzzabcd
582+labels feature
583+message wip
820584
821585$ j conflicts
822586⊗ wqzt wip feature
@@ -859,18 +623,10 @@ j: crash: expected one revision, got 0
859623
860624### 5.2 `show`
861625
862−When it succeeds, `show v` renders `v` as an expression in the language, such
863−that for every value except closures, unresolved blobs, and values nested past the parser's
864−bound (§3.4: a list 40,000 deep, a record about 20,000), `show v` parses and
865−evaluates to a value equal to `v` under the same `config.j`. It never uses
866−colour and never truncates. A value holding a part the store cannot supply (a
867−`files` list or a file's content, §7.2) crashes `show` as it crashes any other
868−use: `show` writes no stand-in for the part.
869−
870−Binary blobs and non-regular file kinds require the declaration
871−`blobHex : Text -> Text -> Blob` in `config.j`; if it is missing, `show`
872−crashes with instructions to add it. Existing custom configs are not
873−modified automatically.
626+`show v` renders `v` as an expression in the language, such that for every
627+value except closures and unresolved blobs, `show v` parses and evaluates to a
628+value equal to `v` under the same `config.j`. It never uses colour and never
629+truncates.
874630
875631| kind | rendering |
876632|---|---|
@@ -880,33 +636,18 @@ modified automatically.
880636| list | `[v1 v2 …]` |
881637| record | `{ a = v, b = w }`, fields in ascending lexicographic order |
882638| `Id` | `@` followed by the full id, e.g. `@wqztkpqxmnrvyxskptlmzzzzabcd` |
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 |
639+| resolved `Blob` | `blob "…"` |
885640| unresolved `Blob` | `{- unresolved -} blob "…"` with the conflict-marker rendering |
886641| builtin | its declared name, operators parenthesised: `map`, `(++)` |
887−| selector | the selector itself: `.id` |
888−| top-level definition with a signature whose value is a function | its name |
642+| top-level definition | its name |
889643| revset from a label literal | `%name` |
890644| shape | its type name: `Commit`, `Id` |
891645| lambda closure | its source text, exactly as written |
892646| partial application | the function's rendering followed by the arguments' renderings, each parenthesised if not atomic: `describe "wip"`, `at (parents) (describe "x")` |
893647
894−A lambda's source text runs from its `\` to the last token of its body,
895−comments within it included, and a section's is the section itself, which
896−unlike a lambda is an atom: `map (+ 1)`, `(\x y -> x + y) 1`.
897−
898−A top-level definition with a signature whose value is a function renders as
899−its name whatever that function is built from, and its partial application
900−as its name followed by the arguments it was given, never by those its value
901−holds: `prev`, not `prev (parents)` (with `prev = goto parents`), `tree`, not
902−`treeWith ({ … })`, and `addT 1` (§4.13). Any other definition renders as its
903−value: `user` as its record, and without signatures `inc = \x -> x + 1` as
904−`\x -> x + 1` and `incAll = map inc` as `map (\x -> x + 1)`.
905−
906−Line breaking: one line if it fits in 80 columns (display width, as in §5.1);
907−otherwise the outermost list or record breaks one element per line, two-space
908−indented, with leading commas for records and nothing between list elements,
909−recursively.
648+Line breaking: one line if it fits in 80 columns; otherwise the outermost
649+list or record breaks one element per line, two-space indented, with leading
650+commas for records and nothing between list elements, recursively.
910651
911652Since `show` returns a `Text` and a top-level `Text` displays raw,
912653`j show . focus` prints the literal of the focused commit, and
@@ -919,48 +660,14 @@ Since `show` returns a `Text` and a top-level `Text` displays raw,
919660### 6.1 Location and loading
920661
921662`j` reads `$XDG_CONFIG_HOME/j/config.j`, defaulting to `~/.config/j/config.j`.
922−Expression evaluation and `push` load the configuration; `init` and
663+It is read by every invocation except `remote`, `fetch`, `undo`, `redo`, and
664+`ops`: expression evaluation and `push` need its definitions, `init` and
923665`clone` need `user`. When there is no repository yet (`init`, `clone`), only
924666`user` is evaluated; `Id` literals are not resolved and no other definition
925−is evaluated. `remote`, `fetch`, `undo`, `redo`, and `ops` do not load configuration.
926−When a command needs configuration and none exists, the binary writes its
927−embedded reference `config.j` (§9) to that path as an editable starting point,
928−readable and writable by its owner, and reports its path. An existing file is
929−never overwritten, including when it cannot be read or contains invalid
930−UTF-8; such errors exit 3.
931−Configuration diagnostics identify the actual file; parse errors include the
932−line number and a bounded source excerpt, with a pointer to `j help config`.
933−Excerpts escape terminal controls and are capped at 160 display columns and
934−characters, with an ellipsis when truncated. Expression parse errors, including
935−push expressions, likewise identify their source line. Runtime diagnostics may
936−add typo suggestions from the actual vocabulary, quoting examples for Text
937−arguments, or missing-argument guidance derived from the command signature;
938−they never retry an expression or change its meaning or exit code.
939−An existing user configuration is not updated when the binary changes. The
940−reference file is also distributed at `<prefix>/share/j/config.j`.
941−
942−Help and command discovery are read-only exceptions (§1.1.1): general help and
943−reserved-command help need no config, while the catalog and help for config
944−names read it without evaluation. If it is missing they use the embedded
945−reference in memory without writing a file. The empty-invocation guide does
946−not read or write configuration either.
947−
948−### 6.1.1 Explicit setup
949−
950−`setup` creates the full embedded configuration at the configured path without
951−opening a repository. With no arguments, it creates an editable default and
952−reports the path and instructions to set `user`. With `NAME EMAIL`, it checks
953−the identity rules of §6.2 and writes those values into the complete reference
954−configuration, serialized as language text literals. Names containing spaces
955−are supplied as a separately quoted shell argument.
956−
957−Setup never overwrites an existing file. Without identity arguments, an
958−existing file reports its effective identity when valid, or guidance to
959−edit it otherwise. With identity arguments, an
960−existing file is a usage error (exit 2), with instructions to edit manually.
961−Argument counts other than zero or two are usage errors. Creating the file
962−also creates missing parent directories; exclusive creation preserves a file
963−created concurrently by another invocation. No repository state is changed.
667+is evaluated. If it does not exist, `j` exits 3 with
668+`j: no config at <path>; the reference config.j is installed at <share-path>`.
669+The binary is distributed with the reference `config.j` (§9) at
670+`<prefix>/share/j/config.j`; `j` never writes to the config path itself.
964671
965672### 6.2 Validation
966673
@@ -985,13 +692,6 @@ not declared is simply unbound, and using it is a runtime crash.
985692
986693Definitions may reference each other in any order.
987694
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−
995695### 6.3 Definitions the interpreter reads
996696
997697Four names in `config.j` are read by the interpreter itself, not only by
@@ -1020,10 +720,6 @@ but nothing here requires the `jj` binary to be installed.
1020720Walk up from the current directory to the first directory containing `.jj`.
1021721Only the default workspace is supported; if the working directory belongs to
1022722another 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`.
1027723
1028724### 7.2 Building the `Repo` value
1029725
@@ -1051,16 +747,11 @@ a value as follows.
1051747 `fetch` found the remote's `main` pointing at it — the snapshot cannot go
1052748 into it; the focus is instead a new empty child of it holding the snapshot,
1053749 as `abandon` would leave it, and persisting records that child. Nothing
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.
750+ else is written by loading.
1057751
1058752The record is the *semantics*. An implementation may back `files`, `children`,
1059753and `context` with lazily materialised structures, provided every observation
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.
754+an expression can make agrees with the eager value.
1064755
1065756### 7.3 Files, blobs, and `replay`
1066757
@@ -1068,70 +759,21 @@ some other value in its place.
1068759 the list of path components; components must be valid UTF-8, and a
1069760 repository with a path that is not exits 2. `content` is a `Blob` carrying the file's
1070761 content and type (regular, executable, symlink) and, for conflicted paths,
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).
762+ jj's conflict value for that path.
1081763- A path that is deleted and resolved has no entry. A path that is in a
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.
764+ deletion conflict has an entry whose blob is unresolved.
1087765- `replay onto { from, to }` is jj's tree merge: for each path present in
1088766 any of the three snapshots, the three-way merge of that path's values with
1089767 `from` as the common ancestor, replaying the change from `from` to `to` on
1090768 top of `onto`. Conflicts are kept as unresolved blobs, and resolved where
1091769 jj's merge would resolve them (identical changes, changes from the current
1092− content, non-overlapping line-level hunks). A path unresolved in any of the
1093− three takes part with all its sides, as when jj rebases a conflicted commit,
1094− so sides that cancel drop out and a replay can resolve a conflict as well
1095− as keep one. A conflict it keeps has no pair of sides that cancel
1096− (above), so in a stack replayed commit by commit, a conflict each commit
1097− inherits is its parent's, whatever conflicts the commits below added. It
1098− never crashes on content, except where a file meets a
1099− directory (below). It crashes if any of the three is not a well-formed
1100− snapshot (a list of `Entry` records with unique paths, none of them the
1101− root `./`, and none both a file and a directory as `./a` beside `./a/b`
1102− would be).
1103−- A path that is a file in one of the three snapshots and a directory in
1104− another is merged as one value, as jj does, the directory with everything
1105− below it. When at most one of `onto` and `to` changed it from `from`, that
1106− value is taken whole. When both did and none of the three holds a
1107− conflict at or below the path, the result is their value taken whole if
1108− they made the same change, and otherwise a conflict at the path, its
1109− directory sides kept whole. A conflict one of the three holds there takes
1110− part with all its sides, as above, so the result is a conflict at the path
1111− unless sides cancel, even where both made the same change: a child that
1112− resolves a conflict at `./a` into `./a/b`, replayed with `from` that
1113− conflict onto the same resolution, keeps a conflict at `./a`. `replay`
1114− crashes, naming the path, when its result would be a conflict at the path
1115− with a file on one side and on another a directory that `onto` or `to`
1116− holds, as replaying the addition of `./a/b` onto a file `./a` would: one
1117− entry cannot list the directory's entries. It does not crash where one of
1118− the three already holds a conflict at that path (an unresolved blob there,
1119− not one below it): the sides came in with that conflict and are carried
1120− like any others, so a child that resolves such a conflict can be squashed
1121− into it. The in-memory backend of §10 merges such a path file by file,
1122− like any other, keeps no directory as a side, and crashes when its result
1123− would hold both `./a` and `./a/b`. So here, as where jj merges lines, the
1124− two backends may differ: it resolves some merges that jj keeps as a
1125− conflict or refuses (a change from a file `./a` to `./a/b` replayed onto a
1126− snapshot without `./a` gives `./a/b`, and so does one from a conflict at
1127− `./a` to `./a/b` replayed onto `./a/b`; the deletion of `./a/z` replayed
1128− onto a snapshot where `./a` is a file gives that file), and it may crash
1129− where jj carries a conflict that one of the three held at `./a`.
1130−- `unresolved`, `blob`, `blobHex`, and `text` are as in §4.9. `blob` produces
1131− a regular file from text. `blobHex` constructs a regular file, executable,
1132− or symlink from its kind and hexadecimal bytes, so `show` can preserve both
1133− content and file type. `text` of a symlink is its target; `text` of a
1134− non-UTF-8 blob crashes. Persisting a symlink with a non-UTF-8 target crashes.
770+ content, non-overlapping line-level hunks). It never crashes on content. It
771+ crashes if any of the three is not a well-formed snapshot (a list of `Entry`
772+ records with unique paths).
773+- `unresolved`, `blob`, and `text` are as in §4.9. `blob` produces a regular
774+ file. File type (executable, symlink) travels with a `Blob` but cannot be
775+ changed in the language. `text` of a symlink is its target; `text` of a
776+ non-UTF-8 blob crashes.
1135777
1136778### 7.4 Working-directory invariant
1137779
@@ -1141,100 +783,18 @@ directory.
1141783- **Before evaluation**, the working directory is snapshotted into the
1142784 working-copy commit, and that is the `files` of the focus in the `Repo`
1143785 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
1146− above it), with no size limit, symlinks as symlinks, and nothing inside a
1147− nested repository (a directory below the root holding `.git` or `.jj`);
1148− these rules are fixed, since `j` reads no jj configuration. If the snapshot
1149− changed the commit, that change is part of the operation persisted
1150− afterwards, or discarded with the rest if nothing is persisted; a change
1151− that leaves the commit with its parent's files, as where the directory
1152− reverts what the commit changed, gives it the parent's tree, as step 4
1153− of §7.5 writes such a commit, so that jj takes it for empty. A crashed
1154− or printing program leaves the working copy and the repository exactly as
1155− before (but see §7.5 step 7); `j id` is the way to record the working
1156− directory and nothing else (§1.2).
1157−- **A stale working copy.** The working copy is stale when the files last
1158− checked out are not the working-copy commit's: jj changed the commit
1159− without updating the working directory (a command run with
1160− `--ignore-working-copy`, or one in another workspace that rewrites this
1161− workspace's commit), `fetch` rebased it (§7.6), or a checkout failed or
1162− was cut short after its operation was recorded (§7.5 step 7). The
1163− working copy's record of the files last checked out names the operation
1164− it was saved at, and one saved at the head operation is current whatever
1165− files it records, as jj takes it: where the record was lost, and jj made
1166− it again recording no file, the snapshot records the directory as it
1167− is. Over a stale working copy, the directory started from the files last
1168− checked out, and what has changed in it since is a change to those,
1169− which the snapshot replays onto the commit: a three-way merge with the
1170− files last checked out as the base, in which a path that the directory
1171− and the commit both changed, differently, is a conflict (an edit to a
1172− file the commit deletes, say), and one where the directory holds what
1173− the commit does is the commit's (what a checkout cut short had written).
1174− A path that both changed where that conflict would set a directory
1175− against a file, a symlink or nothing (a file the commit changes that the
1176− user replaced by a directory, say), which `replay` refuses as one entry
1177− cannot list the directory's entries (§7.3), takes what the working
1178− directory holds there instead, as over a working copy that is not stale:
1179− the commit's change there is left in the operation before. A conflict of
1180− that kind that the commit itself has (one made by a `fetch` that rebased
1181− it, say) stays where the directory has not changed the path, which is
1182− the commit's like any other. Either way, where the record does not hold
1183− one of the commit's conflicts, a file holding what a checkout writes for
1184− it is read back as that conflict: exactly its markers, or a description
1185− of the same sides, whatever their labels and less any side it both
1186− removes and adds. With no change there is nothing to snapshot, and a
1187− program that persists writes the focus over the files last checked out
1188− as usual. A stale working copy refuses no program.
786+ with no size limit, symlinks as symlinks; these rules are fixed, since `j`
787+ reads no jj configuration. If the snapshot changed the commit, that change
788+ is part of the operation persisted afterwards, or discarded with the rest if
789+ nothing is persisted. A crashed or printing program leaves the working copy
790+ and the repository exactly as before; `j id` is the way to record the
791+ working directory and nothing else (§1.2).
1189792- **After persistence**, the focus of the result is checked out: its files are
1190793 written to the working directory, unresolved blobs materialised with jj's
1191794 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.
1227795
1228796Reserved commands never snapshot.
1229797
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−
1238798### 7.5 Persistence
1239799
1240800Given the loaded value `old` and the result `new`, one jj operation is
@@ -1242,27 +802,8 @@ recorded, described by the expression text (truncated to 200 characters).
1242802Within it:
1243803
12448041. **Validate shape.** `new` has the shape of a `Repo`; every `id` occurs
1245− exactly once; every commit's `message` is a `Text`; every snapshot entry
1246− has a `path` list of texts and a `content` that is a `Blob`; every snapshot
1247− has unique paths, none of them the root `./` and none both a file and a
1248− directory (§7.3); every path
1249− component of the focus, of every other commit that is not in `old`, and
1250− of every path any other commit holds that it did not hold in `old`
1251− (§7.3), is a name a checkout can create: not empty, `.`, `..`, `.git` or
1252− `.jj`, without `/`
1253− or NUL, and a name the workspace's filesystem can hold. A name the working
1254− directory holds (§7.4) is one. A name longer than 255 bytes, the limit of
1255− ext4, is tried on the filesystem, since exFAT, NTFS and APFS count 255
1256− UTF-16 units or characters instead; where a filesystem's limit is lower,
1257− or a whole path outgrows the system's limit, the checkout of step 7
1258− refuses it instead. Where the working directory's filesystem folds case
1259− (`.JJ` names its `.jj`), `.git` and `.jj` are refused in any letter case.
1260− (Any other path is neither checked out nor written with a name jj has not
1261− already stored, and a fetched branch can hold any name git can, even one
1262− above the focus that a snapshot rebases.) Violations crash with a message
1263− naming the rule. Files as loaded from a stored tree (§7.2) are such a
1264− snapshot already, as a jj tree holds each name once and each path as a
1265− file or as a directory, and are not checked again.
805+ exactly once; every snapshot has unique paths. Violations crash with a
806+ message naming the rule.
12668072. **Validate labels.** The set of `(id, label)` pairs in `new` equals the set
1267808 in `old`. Labels are the remote's names (§7.6); a script cannot add, move,
1268809 or remove one. In particular a commit carrying a label cannot be abandoned.
@@ -1271,9 +812,7 @@ Within it:
1271812 and all of its ancestors. Every id in it must be present in `new` with the
1272813 same parent id, files, and message. Violations crash naming the commit.
1273814 (Ancestors of merges are included because rewriting an ancestor rewrites
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.
815+ the merge, whose second parent the model cannot represent.)
12778164. **Walk `new` top-down.** For each commit `c` (root first):
1278817 - If `c.id` is the root commit's change id, it must be the top of the tree
1279818 and must be unchanged; otherwise crash.
@@ -1295,62 +834,12 @@ Within it:
1295834 `new.root.id`. (`abandon` and `squash` end with `new` for this reason, so
1296835 they are safe on a commit directly above `main`; navigation onto `main`
1297836 is written `new . goto trunk`.)
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.
837+7. Commit the operation, then perform the checkout of §7.4.
1332838
1333839Labels are not written by persistence at all; they are derived from remote
1334840refs, which only `fetch` and `push` change. The interpreter may run any part
1335841of the walk through jj-lib's rebase machinery provided the stored result
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.
842+equals what the steps above produce.
1354843
1355844### 7.6 Labels and the remote
1356845
@@ -1362,11 +851,7 @@ language: `fetch` and `push` are the only things that change them.
1362851 each remote bookmark `name` is the label `name` on the commit it points at;
1363852 labels for bookmarks that no longer exist on the remote are removed; commits
1364853 reachable from remote bookmarks become visible. Fetching never moves the
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.
854+ focus or touches the working directory. It records one operation.
1370855- **`push EXPR`** evaluates `EXPR` (§1.1); a function result is applied to
1371856 the recorded repository. The value must be a list whose elements are records
1372857 of two shapes:
@@ -1374,120 +859,54 @@ language: `fetch` and `push` are the only things that change them.
1374859 creating it if absent;
1375860 - `{ delete : Text }` — delete the bookmark of that name.
1376861
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−
1384862 Duplicates are merged; two records with the same `name` and different
1385863 effects are a crash. It is refused (exit 1, nothing pushed) if:
1386− - a `name` is not a valid git branch name, an `id` is not a visible commit
1387− or names the synthetic root (which has no Git commit), or a delete names
1388− a bookmark the remote does not have;
864+ - a `name` is not a valid git branch name, an `id` is not a visible commit,
865+ or a delete names a bookmark the remote does not have;
1389866 - any commit that would be sent has an unresolved file or an empty
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);
867+ description;
1393868 - the bookmark's current target is in the immutable set (§7.5) and the
1394869 record would move it to a commit that does not descend from it, or delete
1395− it while no bookmark left on `origin` after the push reaches that target
1396− (those left are the bookmarks no record names, where they are as of the
1397− last `fetch` or `push`, and the records' new targets);
1398− - the deletes would wait (below) and a bookmark it sets and one it deletes
1399− are a name and a name under it, such as `master` and `master/legacy`
1400− (not `master2`): git cannot hold both, so the first git push could not
1401− succeed.
1402−
1403− Targets and descent are commits as the remote has them: a target rewritten
1404− here since the last `fetch` or `push` is hidden, and not in the immutable
1405− set although the visible rewrite carries its change id and its label.
870+ it.
871+
1406872 Moving a bookmark whose current target is not immutable to an unrelated
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
873+ commit is allowed; that is how a rewritten stack is re-pushed. After a
1425874 successful push the labels reflect the new positions. It records one
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.
875+ operation.
1434876
1435877Only the remote named `origin` is supported in this version; `fetch` and
1436−nonempty `push` exit 1 with `j: no remote origin` when it is not configured.
878+`push` exit 1 with `j: no remote origin` when it is not configured.
1437879Authentication is git's: the ssh agent for ssh URLs, git credential helpers
1438880for https, as jj-lib's git backend provides them; `j` adds no prompting.
1439881Bringing work up to date is a separate expression, e.g. `rebase trunk`.
1440882
1441883### 7.7 Operations, undo, redo, and the lock
1442884
1443−Every persisting `j` program, each `fetch`, and each nonempty `push` records
1444−exactly one jj operation, described by the expression or command text. A push's
1445−description includes its `push ` prefix, so an expression named `init` or
1446−`clone` cannot be mistaken for the operation that created the repository.
1447−Crashes record nothing, except as §7.5 step 7 says.
1448−
1449−**Kinds.** An operation is *normal*, *undo*, or *redo*, determined by its
1450−description. New undo and redo records use jj's current markers,
1451−`undo: restore to operation <id>` and `redo: restore to operation <id>`.
1452−The id names the exact operation whose view was restored, retaining the
1453−navigation point for later undo and redo commands.
1454−Older `j` markers (`undo: <id>` naming the discarded operation and
1455−`redo: <id>` naming the reversed undo) and jj's legacy `undo operation <id>` /
1456−`redo operation <id>` markers remain readable with their original meanings.
1457−An operation without a recognized marker is normal, so mixed use degrades
1458−to "undo the newest operation" where no marker is present.
1459−
1460−For current markers, start at the head and follow restored-operation links
1461−while their kind matches the command: undo links for `undo`, redo links for
1462−`redo`. The command then selects that operation's parent. Follow links of
1463−the same kind at the selected target too, and record the final target's id
1464−and view. Reversing direction therefore restores the opposite marker's
1465−parent, and either CLI can continue the other's undo/redo chain.
1466−
1467−**`undo`** restores the state before the operation reached by that walk.
1468−If no parent exists, or the operation reached is the `init` or `clone`
1469−operation (§7.8), before which there was no repository, exit 1 with
1470−`j: nothing to undo`.
1471−
1472−**`redo`** requires the operation reached through redo links to be an undo;
1473−it restores the state that undo discarded. Otherwise exit 1 with
1474−`j: nothing to redo`.
1475−
1476−For older `j` markers, undo follows an undo's discarded operation to its
1477−parent, or a redo's reversed undo to the operation it discarded. Redo
1478−follows a redo's reversed undo to its parent until it reaches an undo,
1479−then restores that undo's parent. This preserves pending redos when a
1480−history crosses from older markers into current ones.
1481−
1482−Both refuse, before doing anything, if the working directory holds a change
1483−the focused commit does not, as a snapshot would find it (§7.4; a stale
1484−working copy holds none where the directory holds what was last checked out
1485−or what the commit has). The refusal explains that `j id` preserves those
1486−edits in a new operation, and the next undo reverses that recording before
1487−reaching the earlier operation; users should inspect `j ops` and can recover
1488−the recording with redo. Both record their operation and then check
1489−out the restored focus, as persistence does (§7.5 step 7): a checkout that
1490−fails leaves the operation recorded, and `redo` or `undo` goes back.
885+Every persisting `j` program, and each of `fetch` and `push`, records exactly
886+one jj operation, described by the expression or command text. Crashes record
887+nothing.
888+
889+**Kinds.** `j` marks each operation it records, in the operation's metadata,
890+as *normal*, *undo* (naming the operation it undid), or *redo* (naming the
891+undo it reversed). Operations recorded by other tools are treated as normal,
892+except that an operation whose description carries jj's own undo or redo
893+marker is treated accordingly, so mixed use chains correctly where the marker
894+is present and degrades to "undo the newest operation" where it is not.
895+
896+**`undo`** finds the operation to undo: starting at the head, while the
897+current operation is an undo, move to the operation it undid and then to that
898+operation's parent; while it is a redo, move past the operation it reapplied.
899+The first normal operation reached is undone by recording a new operation
900+that restores its parent's view, marked as an undo of it. If none is found,
901+exit 1 with `j: nothing to undo`.
902+
903+**`redo`** requires the head to be an undo (possibly reached through redos);
904+it records an operation restoring the view of the operation that undo
905+discarded, marked as a redo. Otherwise exit 1 with `j: nothing to redo`.
906+
907+Both refuse, before doing anything, if the working directory differs from the
908+focused commit's files: `j: working copy has changes not in @; run \`j id\`
909+to record them or discard them`. Both check out the restored focus afterwards.
1491910Undoing a `push` restores the recorded labels and leaves the remote untouched.
1492911
1493912**`ops`** prints the operation log, newest first, one line each: relative
@@ -1500,8 +919,7 @@ operation is marked. Rendered in the binary like `tree`.
1500919duration.
1501920A second `j` exits 2 with `j: another j is running in this repository`.
1502921Display-only runs are not distinguishable in advance, so they take the lock
1503−too; reserved commands that need no repository (`help`, `commands`, `init`,
1504−`clone`) do not.
922+too; only reserved commands that need no repository (`init`, `clone`) do not.
1505923The lock prevents `j` from ever producing two commits with one change id.
1506924Other tools operating concurrently are outside `j`'s control; if they produce
1507925that state, §7.2 applies.
@@ -1513,112 +931,49 @@ that state, §7.2 applies.
1513931 initialised on top of the existing git repository and its history is
1514932 imported (subject to §7.2). Either way a working-copy commit is created as a
1515933 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
1520− operation recorded, as §7.5 step 7 says. It exits 2 if the directory is
1521− already inside a jj repository, or if `.git` exists but is not a directory
1522− (a git worktree or submodule). It requires `user` (§7.9).
1523−- **`clone URL [DIR]`** creates `DIR` and its missing parents (exit 2 if
1524− `DIR` exists and is non-empty, or cannot be created, which includes a `DIR`
1525− such as `new/..` that names an existing directory once its parents exist),
934+ and checked out. It exits 2 if the directory is already inside a jj
935+ repository, or if `.git` exists but is not a directory (a git worktree or
936+ submodule). It requires `user` (§7.9).
937+- **`clone URL [DIR]`** creates `DIR` (exit 2 if it exists and is non-empty),
1526938 clones `URL` into it as a colocated repository, sets `origin` to `URL`,
1527− fetches, and creates a working-copy commit as a child of the remote's
1528− default bookmark target (or of the root commit if the remote has none),
1529− holding that target's files (see below), and checks it out. The default
1530− bookmark is the branch the remote's `HEAD` names. A clone that fails, its
1531− checkout included, or that is interrupted while it fetches (§1.3), records
1532− nothing and removes the `DIR` it created and each parent it created that is
1533− then empty, or empties the `DIR` it found empty, and nothing else. It
1534− removes or empties each only while its path still leads to that directory,
1535− so a symlink put in its place, or in place of a directory above it, is not
1536− followed: a path that leads elsewhere is left, and the error names it.
1537− Without `DIR`, it is the repository's name in `URL`, as `git clone` names
1538− it: the last component of `URL`'s path, a final `.git` component skipped,
1539− minus `.git` (`repo` for `/srv/repo/.git`, `host:repo.git` and
1540− `https://host/team/repo`); a `URL` that leaves no name, such as `/` or
1541− `..`, is a usage error asking for `DIR`. It requires `user`. Interactive
1542− confirmation includes a shell-quoted command to enter the clone directory
1543− and run `j status`; cloning cannot change the caller's working directory.
939+ fetches, and creates an empty working-copy commit as a child of the
940+ remote's default bookmark target (or of the root commit if the remote has
941+ none). It requires `user`.
1544942- **`remote URL`** sets the URL of `origin`, creating the remote if it does not
1545943 exist. It does not fetch.
1546944
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−
1568945### 7.9 Identity
1569946
1570947Commits are authored and committed as `user.name <user.email>` from
1571948`config.j` (§6.3). `j` reads no other configuration; jj-lib's settings object
1572949is constructed by `j` from `user` and fixed defaults. `init`, `clone`, and
1573950persistence exit 3 if `user` is missing or malformed, since that is a
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.
951+configuration error.
1584952
1585953### 7.10 External diff rendering
1586954
1587955`difft p a b` runs difftastic. It is the only builtin that executes another
1588956program.
1589957
1590−1. Write the content of `a` and `b` to files in separate `old` and `new`
1591− subdirectories of a fresh temporary directory. Each file keeps the last
1592− component of `p` unchanged, so valid names stay within the filesystem's
1593− length limit and difftastic sees the real filename. A last component
1594− that is not a filename is a crash; an empty path uses `file`.
958+1. Write the content of `a` and `b` to two temporary files whose names end in
959+ the last component of `p` (so difftastic's language detection sees the
960+ real filename), inside a fresh temporary directory.
15959612. Run `difft OLD NEW` with the process's stdin closed and its environment
1596962 inherited unchanged, except: if `DFT_COLOR` is unset and `j`'s stdout is a
1597963 terminal, set `DFT_COLOR=always`; if `DFT_WIDTH` is unset and `j`'s stdout
1598964 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
1600− reaches the user. Delete the temporary directory.
965+3. Capture stdout. Delete the temporary directory.
16019664. Return stdout as `Text`. A non-zero exit status is not an error (difftastic
1602− uses it to signal "differences found" under some options), so what a
1603− `difft` that fails printed, even nothing, is the result; a failure to
1604− execute the program because it is absent uses the fallback below. Other
1605− launch failures are crashes naming the tool and the OS error.
1606−
1607−If the executable is absent (`NotFound`), return a unified diff with three
1608−lines of context and `--- a/PATH` / `+++ b/PATH` headers using the complete
1609−path `p`. Paths containing whitespace or control characters are quoted.
1610−Content that is not UTF-8 or contains NUL receives a binary-difference summary.
1611−Equal bytes produce empty output. This fallback is usable without installing
1612−another program or changing configuration.
967+ uses it to signal "differences found" under some options); a failure to
968+ execute the program is a crash naming it.
1613969
1614970All rendering options are difftastic's own, set through its `DFT_*`
1615971environment variables. `j` adds no options of its own. `diff` remains
1616−available as a dependency-free unified diff without path headers; the
1617−reference `config.j` uses `difft` for display and receives the automatic
1618−fallback when difftastic is absent.
972+available as a dependency-free unified diff; the reference `config.j` uses
973+`difft` for display, so a user without difftastic edits one line.
1619974
1620−Packaging: difftastic is an optional runtime enhancement; on Nix, wrap `j`
1621−with `difft` on its `PATH`.
975+Packaging: difftastic is a runtime dependency; on Nix, wrap `j` with `difft`
976+on its `PATH`.
1622977
1623978### 7.11 Tree rendering
1624979
@@ -1642,8 +997,7 @@ run — have no metadata: their margin cells are blank, their size bar is
1642997computed from the value, and they sort last among their siblings. The same
1643998holds for a `Commit` block in display (§5.1).
1644999
1645−Missing fields crash. `color = "auto"` means colour iff stdout is a terminal
1646−and `TERM` is not `dumb`.
1000+Missing fields crash. `color = "auto"` means colour iff stdout is a terminal.
16471001If the environment variable `NO_COLOR` is set and non-empty, colour is never
16481002used regardless of `color`. `config.j` fixes the defaults by defining `tree`.
16491003
@@ -1674,9 +1028,8 @@ Columns, left to right:
167410284. **Id**: shortest prefix unique among visible commits, minimum 4.
167510295. **Size bar** (`detail ≥ 1`, focus and its parent and children only; all
16761030 commits at `detail = 2`): one of `▁ ▂ ▃ ▅ ▇`, lines added plus removed
1677− by a shortest line diff against the parent, thresholds 1, 10, 50, 200,
1678− 1000. Absent for empty commits. A changed path with no line added or
1679− removed (an empty file, a new file type) counts as one line.
1031+ against the parent, thresholds 1, 10, 50, 200, 1000. Absent for empty
1032+ commits.
168010336. **Message**: first line; nothing if empty.
168110347. **Labels**: right-aligned column, present only if any rendered commit has
16821035 a label; names separated by two spaces.
@@ -1744,7 +1097,7 @@ Messages are never coloured except dim (cousins) and dim italic (empty).
17441097
17451098These hold for the reference `config.j` and should be property-tested against
17461099an in-memory implementation of the builtins (§10). `=` means the results are
1747−equal, with snapshots compared path by path (§7.3), or both crash.
1100+equal, or both crash.
17481101
17491102```
17501103by r.root.id r = r
@@ -1861,10 +1214,6 @@ Drop = { delete : Label } -- delete the remote's bookmark
18611214------------------------------------------------------------------------------
18621215
18631216(.) : (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.
18681217id : a -> a
18691218const : a -> b -> a
18701219crash : Text -> a -- abort the program with a message
@@ -1926,39 +1275,23 @@ splitOn : Text -> Text -> [Text] -- splitOn "/" "a/b" = ["a" "b"]
19261275------------------------------------------------------------------------------
19271276
19281277-- Replay a change onto a snapshot: "onto, with the change from `from` to `to`
1929−-- applied." Where the change collides with what is already there, the path's
1930−-- blob is unresolved rather than an error, except that a collision making a
1931−-- path a file on one side and a directory on another, as adding ./a/b onto a
1932−-- file ./a would, crashes, naming the path (§7.3).
1278+-- applied." Total: where the change collides with what is already there, the
1279+-- path's blob is unresolved rather than an error.
19331280replay : Snapshot -> Change -> Snapshot
19341281unresolved : 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
19421282blob : Text -> Blob -- a resolved regular file
1943−blobHex : Text -> Text -> Blob -- kind (regular/executable/symlink), hex bytes
19441283text : Blob -> Text -- its content (markers if unresolved)
19451284by : 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
19501285meta : Id -> Meta -- git hash, author, email, committer time;
19511286 -- crash for a commit not yet persisted
19521287validate : Repo -> Repo -- the repo unchanged, or the crash persisting
1953− -- it would produce (shape, root, labels, immutability,
1288+ -- it would produce (shape, labels, immutability,
19541289 -- focus). `tree . validate . rebase trunk` is an
19551290 -- honest dry run.
19561291diff : Blob -> Blob -> Text -- unified diff from the first to the second
1957−difft : Path -> Blob -> Blob -> Text -- difftastic when difft is on PATH; otherwise
1958− -- unified diff with path headers and 3 context lines,
1959− -- or a binary-files summary. DFT_* configures difft.
1960−treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text
1961− , lanes : Int, author : Bool, date : Bool, files : Bool }
1292+difft : Path -> Blob -> Blob -> Text -- difftastic's rendering; needs difft on PATH.
1293+ -- Options via DFT_* environment variables.
1294+treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text }
19621295 -> Repo -> Text -- the history as a tree; see the spec
19631296
19641297-- Every subvalue of a given shape, however deeply nested. A shape is a type
@@ -1996,11 +1329,8 @@ top = \repo -> top (up repo) or repo
19961329
19971330-- Every commit of a subtree (or of the focused subtree of a Repo), in preorder.
19981331-- 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.
20021332commits : a -> [Commit]
2003−commits = subtreeCommits
1333+commits = \t -> t.root :: (concat (map commits t.children) or [])
20041334
20051335
20061336------------------------------------------------------------------------------
@@ -2044,9 +1374,6 @@ restrict = \m -> filter (\e -> m e.path)
20441374select : Fileset -> Snapshot -> Snapshot -> Snapshot -- m from a, the rest from b
20451375select = \m a b -> restrict m a ++ restrict (neg m) b
20461376
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`.
20501377conflicted : Snapshot -> [Path]
20511378conflicted = \s -> map (.path) (filter (\e -> unresolved e.content) s)
20521379
@@ -2095,7 +1422,7 @@ descendants : Revset -- includes the
20951422descendants = \repo -> map (.id) (commits repo)
20961423
20971424ancestors : Revset -- includes the focus
2098−ancestors = \repo -> repo.root.id :: map (\f -> f.parent.id) repo.context
1425+ancestors = \repo -> repo.root.id :: ((let p = up repo in ancestors p) or [])
20991426
21001427siblings : Revset
21011428siblings = \repo -> filter (\i -> i /= repo.root.id) (kids (up repo)) or []
@@ -2131,11 +1458,6 @@ minus = \a b repo -> let ys = b repo in filter (\i -> not (member i ys)) (a repo
21311458matching : (Commit -> Bool) -> Revset -> Revset
21321459matching = \p rs repo -> filter (\i -> p (commitAt i repo)) (rs repo)
21331460
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.
21391461conflicts : Revset
21401462conflicts = matching (\c -> not (null (conflicted c.files))) all
21411463
@@ -2144,10 +1466,7 @@ firstOf : [Revset] -> Revset
21441466firstOf = \rss repo -> foldl (\acc rs -> if null acc then rs repo else acc) [] rss
21451467
21461468-- The main line: whichever of these names the remote has, first one wins.
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'.
1469+-- Empty in a repository with no remote, in which case nothing is immutable.
21511470trunk : Revset
21521471trunk = firstOf [%main %master %trunk]
21531472
@@ -2163,33 +1482,21 @@ immutable = ancestorsOf trunk
21631482-- Focusing. `goto` is the only place a revset must have exactly one member.
21641483------------------------------------------------------------------------------
21651484
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.
21681485goto : Revset -> Edit
21691486goto = \rs repo ->
21701487 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"
21751488 in if length is == 1
21761489 then by (head is) repo
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.")
1490+ else crash ("expected one revision, got " ++ show (length is))
21801491
21811492commitAt : Id -> Repo -> Commit
2182−commitAt = lookupCommit
1493+commitAt = \i repo -> (by i repo).root
21831494
21841495prev : Edit -- the parent
21851496prev = goto parents
21861497
2187−-- Move to the only child. At a tip, `j new` starts a child; when there are
2188−-- several children, choose one of the candidates with `j by @ID`.
2189−next : Edit
2190−next = \repo -> if null (kids repo)
2191− then crash "next: this commit has no children; run `j new` to start a new child, or `j tree` to inspect the history"
2192− else goto kids repo
1498+next : Edit -- the only child
1499+next = goto kids
21931500
21941501tip : Edit -- end of the line of only-children
21951502tip = \repo -> tip (next repo) or repo
@@ -2215,8 +1522,6 @@ eachChild = forEach kids
22151522guard : (Repo -> Bool) -> Edit -- crash unless p holds
22161523guard = \p repo -> if p repo then repo else crash "guard"
22171524
2218−-- Whether the focused commit has no unresolved files. This says nothing
2219−-- about unrecorded working-copy edits; inspect those with `j unrecorded`.
22201525clean : Repo -> Bool
22211526clean = \repo -> null (conflicted repo.root.files)
22221527
@@ -2255,28 +1560,11 @@ rewrite = \f repo ->
22551560-- Commands
22561561------------------------------------------------------------------------------
22571562
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−
22731563-- Set the message of the focused commit.
2274−-- Example: j 'describe "fix login"'
22751564describe : Text -> Edit
22761565describe = \m -> mapRoot (\c -> c { message = m })
22771566
22781567-- Start an empty child of the focus and move to it.
2279−-- Examples: j new; j 'describe "next change" . new'
22801568new : Edit
22811569new = \repo -> addChild (newCommit repo.root.files) repo
22821570
@@ -2284,7 +1572,6 @@ new = \repo -> addChild (newCommit repo.root.files) repo
22841572-- new empty commit on that parent becomes the focus (the parent may be
22851573-- immutable, and the focus never is). Refused for a commit the remote has a
22861574-- 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'.
22881575abandon : Edit
22891576abandon = \repo ->
22901577 let c = repo.root
@@ -2294,20 +1581,17 @@ abandon = \repo ->
22941581 else new (remove (eachChild (replayOnto p) repo))
22951582
22961583-- Push the files matching m from the focus down into its parent.
2297−-- Example: j 'contract (ext "rs")'
22981584contract : Fileset -> Edit
22991585contract = \m repo -> let a = repo.root
23001586 in at parents (rewrite (\q -> q { files = select m a.files q.files })) repo
23011587
23021588-- Fold the focus entirely into its parent; a new empty commit becomes the focus.
2303−-- Example: j squash; preview first with j 'preview squash'.
23041589squash : Edit
23051590squash = abandon . contract everything
23061591
23071592-- Push the files matching m from the focus up into a new child, which becomes
23081593-- the focus and inherits the old focus's children. Labels stay with the
23091594-- original commit, since they are the remote's.
2310−-- Example: j 'split (ext "rs")'; use (under ./src) to select a directory.
23111595split : Fileset -> Edit
23121596split = \m repo ->
23131597 let a = repo.root
@@ -2317,7 +1601,6 @@ split = \m repo ->
23171601 in by x.id (repo { root = a', children = [{ root = x, children = repo.children }] })
23181602
23191603-- Move the focused subtree onto another commit. Returns to the moved commit.
2320−-- Example: j 'rebase trunk'; preview with j 'preview (rebase trunk)'.
23211604rebase : Revset -> Edit
23221605rebase = \rs repo ->
23231606 let d = (goto rs repo).root
@@ -2328,7 +1611,6 @@ rebase = \rs repo ->
23281611 in by me (attach x.subtree (by d.id x.rest))
23291612
23301613-- Apply the focused commit's change onto another commit, as a new child there.
2331−-- Example: j 'pick %main'
23321614pick : Revset -> Edit
23331615pick = \rs repo ->
23341616 let ch = changeOf repo
@@ -2336,7 +1618,6 @@ pick = \rs repo ->
23361618 in at rs (addChild (newCommit (replay t.files ch))) repo
23371619
23381620-- A new child of the focus that undoes the change of the given commit.
2339−-- Example: j 'backout %feature'
23401621backout : Revset -> Edit
23411622backout = \rs repo ->
23421623 let ch = changeOf (goto rs repo)
@@ -2382,53 +1663,40 @@ entryAt = \p s -> filter (\e -> e.path == p) s
23821663
23831664-- The content at path p, or an empty file if absent.
23841665contentAt : Path -> Snapshot -> Blob
2385−contentAt = lookupContent
1666+contentAt = \p s -> (head (entryAt p s)).content or blob ""
23861667
23871668-- The paths a change touches.
23881669touched : Change -> [Path]
2389−touched = touchedPaths
1670+touched = \ch ->
1671+ let paths = map (.path) ch.to ++ filter (\p -> not (member p (map (.path) ch.to))) (map (.path) ch.from)
1672+ in filter (\p -> entryAt p ch.to /= entryAt p ch.from) paths
23901673
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
1674+-- Paths whose content differs between the focus and its parent.
23951675changed : Repo -> [Path]
23961676changed = \repo -> touched (changeOf repo)
23971677
2398−-- What the focus changes, one record per changed path. Uses difftastic when
2399−-- installed; a missing difft falls back to a unified diff with path headers
2400−-- and 3 context lines, or a binary-files summary.
1678+-- What the focus changes, rendered by difftastic, one record per changed path.
1679+-- Replace difft with (\p -> diff) to use the built-in unified diff instead.
24011680diffs : Repo -> [{ path : Path, diff : Text }]
24021681diffs = \repo ->
24031682 let ch = changeOf repo
24041683 in map (\p -> { path = p, diff = difft p (contentAt p ch.from) (contentAt p ch.to) }) (touched ch)
24051684
24061685-- The same, as one page of text. A Text result prints raw, so `j review` is
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.
1686+-- readable in the terminal.
24121687review : Repo -> Text
2413−review = \repo ->
2414− let ds = diffs repo
2415− in if null ds then "no changes\n" else concat (map (\d -> d.diff) ds)
2416−
2417−-- A summary of the focus. `changed` lists changes in the commit compared
2418−-- with its parent; `unrecorded` lists working-copy edits not yet recorded.
2419−-- Inspection does not record anything. Run `j save` to record those edits.
2420−-- Example: j status
2421−status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path], unrecorded : [Path] }
1688+review = \repo -> concat (map (\d -> d.diff) (diffs repo)) or "no changes\n"
1689+
1690+-- A summary of the focus.
1691+status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path] }
24221692status = \repo ->
24231693 { id = repo.root.id
24241694 , message = repo.root.message
24251695 , labels = repo.root.labels
24261696 , changed = changed repo
2427− , conflicts = conflicted repo.root.files
2428− , unrecorded = unrecorded repo }
1697+ , conflicts = conflicted repo.root.files }
24291698
24301699-- A summary of every commit, top first, in preorder.
2431−-- Example: j log
24321700log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]
24331701log = \repo ->
24341702 map (\c -> { id = c.id
@@ -2439,21 +1707,11 @@ log = \repo ->
24391707
24401708-- The history as a tree. Edit these records to change what `j tree` shows.
24411709-- (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.
24451710tree : Repo -> Text
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 })
1711+tree = treeWith ({ detail = 1, margin = false, elide = true, icons = false, color = "auto" })
24501712
24511713treeFull : Repo -> Text
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 })
1714+treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
24571715```
24581716
24591717---
@@ -2461,15 +1719,13 @@ treeData = treeWith ({ detail = 2, margin = true, elide = false, icons = false,
24611719## 10. Testing
24621720
24631721The interpreter must be testable without a jj repository. Provide an in-memory
2464−implementation of the domain builtins (`replay`, `unresolved`, `blob`, `blobHex`,
1722+implementation of the domain builtins (`replay`, `unresolved`, `blob`,
24651723`text`, `by`, `extract`, `meta`, `difft` as a stub, and the minting
2466−of `@`) in which a resolved blob carries bytes and a file kind, a
1724+of `@`) in which a blob is text, a
24671725conflict is a record of the three sides, and `replay` resolves a path exactly
2468−when at most one side changed it or both made the same change, path by path
2469−even where a file meets a directory, which jj merges as one value (§7.3).
2470−Property-test the laws of §8 over randomly generated `Repo` values (bounded
2471−depth and width, random labels, random file edits) using the reference
2472−`config.j` unmodified.
1726+when at most one side changed it or both made the same change. Property-test
1727+the laws of §8 over randomly generated `Repo` values (bounded depth and width,
1728+random labels, random file edits) using the reference `config.j` unmodified.
24731729
24741730The builtin `by` must agree, on every generated repo, with this in-language
24751731reference definition, which uses positional moves the public base does not
@@ -2499,10 +1755,9 @@ here is out of scope (§12). Nothing here uses the `jj` binary.
24991755**Getting a repository**
25001756
25011757```
2502−j setup 'Your actual name' you@your-domain.example -- create the full config once
1758+$EDITOR ~/.config/j/config.j -- set `user` once
25031759j init -- new repository here, or adopt an existing .git
2504−j clone git@host:org/repo.git project -- or clone an existing one
2505−cd project -- after clone, enter its directory
1760+j clone git@host:org/repo.git -- or an existing one
25061761j remote git@host:org/repo.git -- attach a remote to an init'd repo
25071762```
25081763
@@ -2511,9 +1766,9 @@ j remote git@host:org/repo.git -- attach a remote to an init'd repo
25111766```
25121767j tree -- the history as a tree, focus marked
25131768j treeFull -- the same with sizes, ages, authors, changed paths
2514−j status -- focus, in-commit changes, unrecorded edits, conflicts
1769+j status -- id, message, labels, changed paths, conflicts of @
25151770j log -- every commit as a record
2516−j review -- difftastic or unified diff of @ against its parent
1771+j review -- difftastic rendering of @ against its parent
25171772j review . by @kpq -- the same for another commit, without moving
25181773j diffs -- the same, one record per path
25191774j here -- @'s id
@@ -2544,8 +1799,7 @@ j rebase trunk
25441799j guard clean . rebase trunk or id -- only if it comes out clean
25451800j pick @kpq
25461801j 'forEach (descendantsOf here) (describe "wip")'
2547−j save -- record the working directory, change nothing else
2548−j 'preview squash' -- validate and show the result without saving
1802+j id -- record the working directory, change nothing else
25491803j tree . squash -- dry run: show the result, persist nothing
25501804j tree . validate . squash -- the same, refusing what persistence would refuse
25511805j squash && j tree -- do, then look
@@ -2567,10 +1821,7 @@ j rebase trunk -- bring the stack up to date
25671821A rebase or fetch that conflicts leaves unresolved files in the affected
25681822commits. `j conflicts` lists them; `j by @…` moves there, which checks the
25691823files out with conflict markers; editing the files and running any persisting
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.
1824+expression (`j id` at minimum) records the resolution.
25741825
25751826**Mistakes**
25761827
@@ -2585,7 +1836,7 @@ j redo
25851836Any definition added to `config.j` whose value is an `Edit` is a command:
25861837
25871838```haskell
2588−pr = \n -> describe n . prev . squash
1839+pr = \n -> describe n . squash
25891840```
25901841
25911842```
@@ -2598,9 +1849,8 @@ j 'pr "fix login"' && j 'push (label "fix-login" here)'
25981849
25991850Second parents of merge commits (merges are placed under their first parent
26001851and are read-only); hunk-level `split`; conflict resolution inside the
2601−language (blobs are opaque); workspaces other than the default; sparse working
2602−copies (refused; §7.1); remotes other than `origin`; tags; submodules and LFS;
2603−ignore rules beyond `.gitignore`; repairing a repository in which one change
2604−id has two visible commits (refused; §7.2); general command flags beyond the
2605−`--help` and `-h` discovery arguments; static typing. None of
2606−these are precluded by the design.
1852+language (blobs are opaque); workspaces other than the default; remotes other
1853+than `origin`; tags; submodules and LFS; ignore rules beyond `.gitignore`;
1854+changing file modes or symlinks from the language; repairing a repository in
1855+which one change id has two visible commits (refused; §7.2); a `--help` flag
1856+or any flag at all; static typing. None of these are precluded by the design.
specs/tree.md+50−94
@@ -113,8 +113,6 @@ After `n` is placed in lane `ℓ`:
113113* If `n` was the last child of `p` and did not inherit `p`'s lane (rule 5 with
114114 `p` the trunk head, or `p` the root of an empty trunk), `p`'s lane becomes
115115 empty below this row: the fork on this row is drawn with `╰` instead of `├`.
116− When `n` finds no lane (§Overflow) no fork is drawn, but `p`'s lane empties
117− all the same; the rail that reaches `n`'s row is drawn there as `│`.
118116
119117**Reservations.** When a node `p ∈ T` is placed and has a trunk child `t`,
120118every side child of `p` that comes *after* `t` in row order needs its own
@@ -137,16 +135,10 @@ always to the right of its source.
137135If rule 5 or a reservation finds no empty lane in `0 < ℓ < lanes`, the
138136subtree rooted at `n` is **flattened**: `n` and every descendant of it still
139137get their rows, in row order, with their id, message, labels and margin, but
140−they take no lane. Their rails area preserves every rail that passes through.
141−If any rendered node is flattened, a separate four-column status area
142−is reserved between the rails and the id separator on every row. Flattened
143−rows show `»`; on a commit row this is followed by a space and its normal
144−node glyph (§Glyphs), padded to four display columns. Non-flattened rows
145−leave the area blank. Neither the overflow marker nor a node glyph replaces
146−a live rail. Nothing is hidden —
147−the focus, conflicts, and empty commits in particular are always identifiable
148−— only the shape beyond `lanes` is lost. Widen `lanes` (or enable `elide`) to
149−see it.
138+they take no lane. Their rails area shows only the rails that pass through
139+and `»` in the last column. Nothing is hidden — the focus in particular is
140+always shown — only the shape beyond `lanes` is lost. Widen `lanes` (or
141+enable `elide`) to see it.
150142
151143#### Step 4 — drawing a row
152144
@@ -155,34 +147,26 @@ Columns, left to right, at fixed offsets:
1551471. **Gutter**, 2 characters: `▶ ` on the focus row, blank otherwise.
1561482. **Rails**, `2 · lanes` characters: lane `i` owns characters `2i` and
157149 `2i+1`.
158−3. **Overflow status**, 4 display columns when any rendered node is flattened;
159− absent otherwise (§Overflow).
160−4. One space.
161−5. **Id**: `@` followed by the shortest prefix unique among the commits of the
150+3. One space.
151+4. **Id**: `@` followed by the shortest prefix unique among the commits of the
162152 display tree, minimum 4 — so the rendered id is itself a valid id literal
163153 (§3.1). Blank on a run row.
164−6. **Size bar** (`detail ≥ 1`, focus and its parent and children only; all
165− commits at `detail = 2`): `▁ ▂ ▃ ▅ ▇`, as before. A row without a bar
166− keeps the column blank; it is left out only when no row has a bar.
167−7. **Message**: first line; nothing if empty; followed by `⋯ n` on a
154+5. **Size bar** (`detail ≥ 1`, focus and its parent and children only; all
155+ commits at `detail = 2`): `▁ ▂ ▃ ▅ ▇`, as before.
156+6. **Message**: first line; nothing if empty; followed by `⋯ n` on a
168157 collapsed node.
169−8. **Labels**: left-aligned column, present only if any rendered commit has
158+7. **Labels**: right-aligned column, present only if any rendered commit has
170159 a label; names separated by two spaces.
171−9. **Margin** (`margin = true`): age and author initials, as before, each
172− right-aligned in its own column (a `9m` ends under a `13m`), so the margin
173− ends at one column on every row.
174−10. **Data columns** (off unless enabled): `date` shows the commit's absolute
160+8. **Margin** (`margin = true`): age and author initials, right-aligned, as
161+ before.
162+9. **Data columns** (off unless enabled): `date` shows the commit's absolute
175163 date `YYYY-MM-DD`; `files` shows the number of files changed as `n files`;
176164 `author` shows the full author name. They appear in that order, grey,
177165 immediately before the margin in the same right-aligned block.
178166
179−Columns 5–9 start at the same offset on every row. Labels start two columns
180−past the widest message, and the data columns and the margin start two
181−columns past the widest label (past the widest message when there is no
182−label column). Each data column, the age and the initials are as wide as
183−their widest entry and two columns apart. When stdout is a terminal and a row
184−would exceed its width, the message is cut and ends with `…` so that labels
185−and the margin keep their columns; otherwise nothing is cut.
167+Columns 4–8 start at the same offset on every row. When stdout is a terminal
168+and a row would exceed its width, the message is cut and ends with `…` so that
169+labels and the margin keep their columns; otherwise nothing is cut.
186170
187171**Rails characters.** For lane `i` on the row of node `n` placed in lane
188172`ℓ`, character `2i` is, first rule that applies:
@@ -195,27 +179,19 @@ and the margin keep their columns; otherwise nothing is cut.
195179| `i` lies between the two ends of a horizontal segment on this row and holds a rail | `┼` |
196180| `i` lies between the two ends of a horizontal segment on this row | `─` |
197181| `i` holds a rail passing through this row | `│` |
182+| flattened rows only: `i = lanes − 1` | `»` |
198183| otherwise | space |
199184
200185Character `2i+1` is `─` if it lies inside a horizontal segment (from a fork
201186source to its target, or from a node to its rightmost reservation), else a
202187space. On a run row the count follows the `╎`, written into the rails area
203−and, if needed, into the empty id column; when lanes to the right of the run
204−hold rails, it follows the rightmost of them instead, so that it covers no
205−rail. The count is never cut: what does not fit the rails area runs on,
206−unbroken, through the space after it into the id column, which is widened if
207−it cannot hold the rest.
208−
209−**Detail lines** (`detail = 2`, focus only): changed paths appear directly
210−under the focus, with a mark each: `+` added, `~` modified, `−` deleted,
211−`✖` unresolved. Every detail row shows `│` in every lane that holds a rail
212−below the focus row (including the focus's own lane if it has children), and
213−leaves any overflow status column blank. The text starts two columns into the
214−id column, under the focus's id, at the same offset on every continuation row.
215−When stdout is a terminal, paths wrap onto continuation rows within the width
216−available after this prefix. A path longer than that width may split across
217−rows; wrapping does not discard any path content. Redirected output keeps
218−the changed paths on a single detail row.
188+and, if needed, into the empty id column.
189+
190+**Detail line** (`detail = 2`, focus only): one extra row directly under the
191+focus. Its rails show `│` in every lane that holds a rail below the focus row
192+(including the focus's own lane if it has children); the text starts two
193+characters past the id column and lists the changed paths with a mark each:
194+`+` added, `~` modified, `−` deleted, `✖` unresolved.
219195
220196#### Glyphs
221197
@@ -232,13 +208,7 @@ Unchanged from before:
232208| `⌂` | root | `🌱` |
233209
234210Precedence: `⌂`, then `⊗`, then `◌`, then `◆`, then the position glyphs.
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.
211+Rails use `│ ├ ╰ ─ ┼ ╮ ┬ ╎ »`.
242212
243213#### Elision (`elide = true`)
244214
@@ -262,29 +232,18 @@ drawn with `⌂`.
262232
263233#### Colour
264234
265−Glyphs are coloured by meaning (the same precedence as their shape): root bold
266−magenta, conflict bright red, empty dim, immutable bright blue, focus bold
267−cyan, ancestor of focus bright green, other bright white. Ids are bold magenta,
268−with conflict red and immutable blue taking precedence; the focus id and `▶`
269−gutter are bold cyan. Messages are bold on the focus row, red on a conflict,
270−and dim-italic when empty. Labels are bold green; the margin and data columns
271−are grey with bright author-hued initials. Size bars progress from cyan
272−through blue and magenta to yellow as their height increases. Rail connectors
273−in lane 0 take the immutable colour; other lanes cycle through magenta, cyan,
274−yellow, and green by lane position. Glyphs always keep their semantic colour.
275−
276−The whole focus row is highlighted with bright text on a dark-blue background
277−band that spans the full terminal width. This is colour only: glyphs, rails,
278−and the `▶` gutter still carry the meaning, so a colourless rendering loses
279−nothing — the band is a no-op when colour is off.
280−
281−Structured value display shares this palette: id prefixes are bold magenta,
282−field names and table headers dim cyan, paths cyan, integers and file sizes
283−yellow, and booleans green for true and red for false. Raw text and blob
284−contents are never styled. `color = "auto"` requires a terminal whose `TERM`
285−is not `dumb`; `color = "always"` explicitly enables colour and `"never"`
286−disables it. A non-empty `NO_COLOR` disables colour in every mode. Redirected
287−output remains plain in auto mode.
235+Glyphs are coloured by meaning (the same precedence as their shape): root bold,
236+conflict red, empty dim, immutable blue, focus bold cyan, ancestor of focus
237+green, other light grey. Ids keep their existing colours (conflict red,
238+immutable blue). Messages are bold on the focus row, red on a conflict, and
239+dim-italic when empty. Labels are green; the margin and the data columns are
240+grey with author-hued initials. Rail connectors in lane 0 take the immutable
241+colour; connectors in other lanes are dim.
242+
243+The whole focus row is highlighted with a background band that spans the full
244+terminal width. This is colour only: glyphs, rails, and the `▶` gutter still
245+carry the meaning, so a colourless rendering loses nothing — the band is a
246+no-op when colour is off.
288247
289248#### Legend
290249
@@ -297,10 +256,8 @@ The legend covers the node glyphs present (§Glyphs), the focus gutter `▶`,
297256run `╎ n` and collapsed `⋯ n` rows when shown, and the detail-line marks
298257(`+` added, `~` modified, `−` deleted, `✖` unresolved) when a detail line is
299258present. A collapsed node shows `⋯ n` only when `n > 0`, so the `⋯` legend
300−entry appears only then. When any rendered node is flattened, it also includes
301−`» branch exceeds lane limit; increase lanes`. Node glyphs in the overflow
302−status column contribute their normal legend entries. The rails characters
303−are not itemised; they are structural (§Step 4).
259+entry appears only then. The rails characters are not itemised; they are
260+structural (§Step 4).
304261
305262The legend text is faint (dim). Placement: when stdout is a terminal and the
306263tree's widest line plus the legend fits the terminal width, the legend is set
@@ -333,21 +290,20 @@ Rendered:
333290
334291```
335292 ╎ 14
336− ◆─╮ @aaaa ▅ add parser 3w mo
337− ◆ │ @kpqx ▅ release 1.2 main 2w mo
338− │ ○ @mnrv ▂ fix lexer 1w mo
339−▶ ├─⊗ @wqzt ▃ wip feature 2d mo
293+ ◆─╮ @aaaa ▅ add parser 3w mo
294+ ◆ │ @kpqx ▅ release 1.2 main 2w mo
295+ │ ○ @mnrv ▂ fix lexer 1w mo
296+▶ ├─⊗ @wqzt ▃ wip feature 2d mo
340297 │ │ ✖ src/lexer.rs ~ src/parser.rs + tests/lexer.rs
341− ╰─┼─◌ @ptlm docs ⋯ 3 1d ak
342− ├─○ @qrst ▁ spike 5h mo
343− ○ @yxsk ▃ 1h mo
298+ ╰─┼─◌ @ptlm docs ⋯ 3 1d ak
299+ ├─○ @qrst ▁ spike 5h mo
300+ ○ @yxsk ▃ 1h mo
344301```
345302
346−Column offsets in this example: gutter 0–1, rails 2–7, id from 9, size bar
347−15, message from 18, labels from 31, margin from 40, and the detail line's
348−marks from 11. Lane 0 is `◆ ◆ │ ├ ╰` from `aaaa` to `ptlm` and then empty;
349−the reservation for `mnrv` is drawn on `aaaa`'s row as `─╮` and consumed on
350−`mnrv`'s row; `ptlm` forks to lane 2 across the live lane 1, hence `┼`.
303+Column offsets in this example: gutter 0–1, rails 2–7, id from 9. Lane 0 is
304+`◆ ◆ │ ├ ╰` from `aaaa` to `ptlm` and then empty; the reservation for `mnrv`
305+is drawn on `aaaa`'s row as `─╮` and consumed on `mnrv`'s row; `ptlm` forks
306+to lane 2 across the live lane 1, hence `┼`.
351307
352308The reference `config.j` defines `tree` with `detail = 1, margin = false,
353309elide = false` (every commit, time-sorted), `treeCompact` with `elide = true`
src/ast.rs+2−35
@@ -29,7 +29,7 @@ pub enum Expr {
2929 LabelLit(String),
3030 PathLit(Vec<String>),
3131 Bool(bool),
32− Lambda(Vec<Pattern>, Rc<Expr>, Source), // params, body, source text
32+ Lambda(Vec<Pattern>, Rc<Expr>, String), // params, body, source text
3333 If(Rc<Expr>, Rc<Expr>, Rc<Expr>),
3434 Let(Vec<(String, Rc<Expr>)>, Rc<Expr>),
3535 App(Rc<Expr>, Rc<Expr>),
@@ -45,34 +45,6 @@ pub enum Expr {
4545 Crash, // internal marker if needed
4646}
4747
48−/// The source text of a lambda or section, exactly as written, which is how
49−/// its closure renders (§5.2): a span of the program text it was parsed
50−/// from. The lambdas of one program share that text instead of each holding
51−/// a copy, so nested lambdas cost a span each, not their text again.
52−#[derive(Clone)]
53−pub struct Source {
54− text: Rc<str>,
55− start: usize,
56− end: usize,
57−}
58−
59−impl Source {
60− /// The bytes `start..end` of `text`, which must fall on char boundaries.
61− pub fn new(text: Rc<str>, start: usize, end: usize) -> Self {
62− Source { text, start, end }
63− }
64−
65− pub fn as_str(&self) -> &str {
66− &self.text[self.start..self.end]
67− }
68−}
69−
70−impl std::fmt::Debug for Source {
71− fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72− write!(f, "{:?}", self.as_str())
73− }
74−}
75−
7648#[derive(Debug, Clone)]
7749pub enum Item {
7850 TypeDecl(String, TypeExpr),
@@ -89,18 +61,13 @@ impl Expr {
8961 out.insert(n.clone());
9062 }
9163 }
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− }
9864 Expr::TypeName(_)
9965 | Expr::Int(_)
10066 | Expr::Text(_)
10167 | Expr::IdLit(_)
10268 | Expr::Id(_)
10369 | Expr::NewId
70+ | Expr::LabelLit(_)
10471 | Expr::PathLit(_)
10572 | Expr::Bool(_)
10673 | Expr::SelectorFun(_)
src/builtins.rs+88−134
@@ -74,8 +74,8 @@ pub fn all_builtins() -> Vec<(String, Value)> {
7474 builtin(">=", 2, b_ge),
7575 builtin("show", 1, b_show),
7676 builtin("::", 2, b_cons),
77− builtin("map", 2, on_machine),
78− builtin("filter", 2, on_machine),
77+ builtin("map", 2, b_map),
78+ builtin("filter", 2, b_filter),
7979 builtin("length", 1, b_length),
8080 builtin("null", 1, b_null),
8181 builtin("head", 1, b_head),
@@ -86,7 +86,7 @@ pub fn all_builtins() -> Vec<(String, Value)> {
8686 builtin("drop", 2, b_drop),
8787 builtin("member", 2, b_member),
8888 builtin("range", 2, b_range),
89− builtin("foldl", 3, on_machine),
89+ builtin("foldl", 3, b_foldl),
9090 builtin("concat", 1, b_concat),
9191 builtin("++", 2, b_append),
9292 builtin("startsWith", 2, b_starts_with),
@@ -95,14 +95,9 @@ pub fn all_builtins() -> Vec<(String, Value)> {
9595 builtin("replay", 2, b_replay),
9696 builtin("unresolved", 1, b_unresolved),
9797 builtin("touchedPaths", 1, b_touched_paths),
98− builtin("unrecorded", 1, b_unrecorded),
99− builtin("subtreeCommits", 1, b_subtree_commits),
10098 builtin("blob", 1, b_blob),
101− builtin("blobHex", 2, b_blob_hex),
10299 builtin("text", 1, b_text),
103100 builtin("by", 2, b_by),
104− builtin("lookupCommit", 2, b_lookup_commit),
105− builtin("lookupContent", 2, b_lookup_content),
106101 builtin("meta", 1, b_meta),
107102 builtin("validate", 1, b_validate),
108103 builtin("diff", 2, b_diff),
@@ -121,13 +116,33 @@ fn b_compose(_i: &mut Interp, args: &[Value]) -> BResult {
121116 compose_values(_i, f, g)
122117}
123118
124−/// `map`, `filter`, `foldl` and a composition node apply the functions they
125−/// are given, so the evaluator runs them as steps of its machine
126−/// (Interp::step_applying): applying them in a nested run put every level of
127−/// a recursion through one on the native stack (§4.1). This only gives them a
128−/// name and an arity here; it is never called.
129−fn on_machine(_i: &mut Interp, _args: &[Value]) -> BResult {
130− Err(Crash::new("internal: a builtin that applies functions ran off the machine"))
119+fn compose_apply(i: &mut Interp, args: &[Value]) -> BResult {
120+ let f = args[0].clone();
121+ let g = args[1].clone();
122+ let x = args[2].clone();
123+ // A composed function applied to a function does not run (the operand
124+ // contracts are about Repo-level values); it composes pointwise so that
125+ // definitions like `abandon . contract everything` and `tree . squash`
126+ // are functions waiting for the repository (§4.1 note, §1.2 step 7).
127+ if matches!(x, Value::Fun(_)) {
128+ let next = apply_or_compose(i, g, x)?;
129+ return apply_or_compose(i, f, next);
130+ }
131+ let gx = i.apply(g, x)?;
132+ i.apply(f, gx)
133+}
134+
135+/// Apply a signed function to an argument, composing instead when the
136+/// argument is a function but the contract's next parameter is not.
137+pub fn apply_or_compose(i: &mut Interp, f: Value, x: Value) -> BResult {
138+ if matches!(x, Value::Fun(_)) {
139+ if let Some((_, cexpr)) = pending_of(&f) {
140+ if !cexpr.next_param_is_function(&i.shapes) {
141+ return compose_values(i, f, x);
142+ }
143+ }
144+ }
145+ i.apply(f, x)
131146}
132147
133148/// f . g as a value, with contract propagation
@@ -139,8 +154,6 @@ pub fn compose_values(i: &mut Interp, f: Value, g: Value) -> BResult {
139154 Box::new((*cf).clone()),
140155 Box::new((*cg).clone()),
141156 )),
142− // named holding `f` and `g`
143− 2,
144157 )),
145158 _ => None,
146159 };
@@ -150,7 +163,7 @@ pub fn compose_values(i: &mut Interp, f: Value, g: Value) -> BResult {
150163 name: "(.)".into(),
151164 arity: 3,
152165 args: vec![f, g],
153− f: on_machine,
166+ f: compose_apply,
154167 pending,
155168 })))
156169}
@@ -169,9 +182,8 @@ pub fn pending_of(v: &Value) -> Option<(String, Rc<crate::shape::ContractExpr>)>
169182 ..
170183 } => Some((n.clone(), Rc::new(crate::shape::ContractExpr::Unknown))),
171184 crate::value::FunVal::Builtin {
172− pending: Some((n, c, _)),
173− ..
174− } => Some((n.clone(), c.clone())),
185+ pending: Some(p), ..
186+ } => Some(p.clone()),
175187 crate::value::FunVal::Builtin {
176188 name, pending: None, ..
177189 } => Some((name.clone(), Rc::new(crate::shape::ContractExpr::Unknown))),
@@ -203,26 +215,16 @@ fn b_ne(_i: &mut Interp, args: &[Value]) -> BResult {
203215 Ok(Value::Bool(!value_eq(&args[0], &args[1])?))
204216}
205217
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.
210218fn b_and(_i: &mut Interp, args: &[Value]) -> BResult {
211219 want!(args, 0, Value::Bool(_), "Bool");
212220 want!(args, 1, Value::Bool(_), "Bool");
213− match (&args[0], &args[1]) {
214− (Value::Bool(a), Value::Bool(b)) => Ok(Value::Bool(*a && *b)),
215− _ => unreachable!("checked above"),
216− }
221+ Ok(args[1].clone())
217222}
218223
219224fn b_or(_i: &mut Interp, args: &[Value]) -> BResult {
220225 want!(args, 0, Value::Bool(_), "Bool");
221226 want!(args, 1, Value::Bool(_), "Bool");
222− match (&args[0], &args[1]) {
223− (Value::Bool(a), Value::Bool(b)) => Ok(Value::Bool(*a || *b)),
224− _ => unreachable!("checked above"),
225− }
227+ Ok(args[1].clone())
226228}
227229
228230fn b_not(_i: &mut Interp, args: &[Value]) -> BResult {
@@ -273,7 +275,7 @@ fn b_ge(_i: &mut Interp, args: &[Value]) -> BResult {
273275}
274276
275277fn b_show(i: &mut Interp, args: &[Value]) -> BResult {
276− Ok(Value::text(crate::show::show(i, &args[0])?))
278+ Ok(Value::text(crate::show::show(i, &args[0])))
277279}
278280
279281fn b_cons(_i: &mut Interp, args: &[Value]) -> BResult {
@@ -284,6 +286,35 @@ fn b_cons(_i: &mut Interp, args: &[Value]) -> BResult {
284286 Ok(Value::list(v))
285287}
286288
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+
287318fn b_length(_i: &mut Interp, args: &[Value]) -> BResult {
288319 Ok(Value::int(args[0].as_list()?.len() as i64))
289320}
@@ -300,15 +331,11 @@ fn b_head(_i: &mut Interp, args: &[Value]) -> BResult {
300331}
301332
302333fn b_tail(_i: &mut Interp, args: &[Value]) -> BResult {
303− if args[0].as_list()?.is_empty() {
334+ let xs = args[0].as_list()?;
335+ if xs.is_empty() {
304336 return Err(Crash::new("tail: empty list"));
305337 }
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− }
338+ Ok(Value::list(xs[1..].to_vec()))
312339}
313340
314341fn b_last(_i: &mut Interp, args: &[Value]) -> BResult {
@@ -352,11 +379,9 @@ fn b_take(_i: &mut Interp, args: &[Value]) -> BResult {
352379}
353380
354381fn b_drop(_i: &mut Interp, args: &[Value]) -> BResult {
355− let n = clamp(args[0].as_int()?, args[1].as_list()?.len())?;
356− match &args[1] {
357− Value::List(xs) => Ok(Value::List(xs.skip(n))),
358− v => Err(Crash::new(format!("expected a list, got a {}", v.kind_name()))),
359− }
382+ let xs = args[1].as_list()?;
383+ let n = clamp(args[0].as_int()?, xs.len())?;
384+ Ok(Value::list(xs[n..].to_vec()))
360385}
361386
362387fn b_member(_i: &mut Interp, args: &[Value]) -> BResult {
@@ -380,6 +405,17 @@ fn b_range(_i: &mut Interp, args: &[Value]) -> BResult {
380405 Ok(Value::list(out))
381406}
382407
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+
383419fn b_concat(_i: &mut Interp, args: &[Value]) -> BResult {
384420 let xs = args[0].as_list()?;
385421 if xs.is_empty() {
@@ -410,7 +446,7 @@ fn b_concat(_i: &mut Interp, args: &[Value]) -> BResult {
410446fn b_append(_i: &mut Interp, args: &[Value]) -> BResult {
411447 match (&args[0], &args[1]) {
412448 (Value::List(a), Value::List(b)) => {
413− let mut out = a.to_vec();
449+ let mut out = a.as_ref().clone();
414450 out.extend_from_slice(b);
415451 Ok(Value::list(out))
416452 }
@@ -457,11 +493,14 @@ fn b_split_on(_i: &mut Interp, args: &[Value]) -> BResult {
457493// ----------------------------------------------------------------------
458494
459495fn b_replay(i: &mut Interp, args: &[Value]) -> BResult {
460− args[0].as_list()?;
496+ let onto = args[0].as_list()?;
461497 let ch = &args[1];
462498 let from = ch.field("from")?;
463499 let to = ch.field("to")?;
464− i.backend.replay_snapshot(&args[0], &from, &to)
500+ let from = from.as_list()?;
501+ let to = to.as_list()?;
502+ let out = i.backend.replay(onto, from, to)?;
503+ Ok(Value::list(out))
465504}
466505
467506fn b_unresolved(_i: &mut Interp, args: &[Value]) -> BResult {
@@ -498,53 +537,6 @@ fn b_touched_paths(_i: &mut Interp, args: &[Value]) -> BResult {
498537 Ok(Value::list(out))
499538}
500539
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−
548540fn b_blob(_i: &mut Interp, args: &[Value]) -> BResult {
549541 let t = args[0].as_text()?;
550542 Ok(Value::Blob(Rc::new(BlobVal {
@@ -553,21 +545,6 @@ fn b_blob(_i: &mut Interp, args: &[Value]) -> BResult {
553545 })))
554546}
555547
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−
571548fn b_text(_i: &mut Interp, args: &[Value]) -> BResult {
572549 match &args[0] {
573550 Value::Blob(b) => {
@@ -601,18 +578,6 @@ fn b_by(_i: &mut Interp, args: &[Value]) -> BResult {
601578 }
602579}
603580
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−
616581fn b_meta(i: &mut Interp, args: &[Value]) -> BResult {
617582 let id = match &args[0] {
618583 Value::Id(id) => id.to_string(),
@@ -634,11 +599,6 @@ fn b_meta(i: &mut Interp, args: &[Value]) -> BResult {
634599
635600fn b_validate(i: &mut Interp, args: &[Value]) -> BResult {
636601 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− }
642602 Ok(args[0].clone())
643603}
644604
@@ -691,12 +651,6 @@ fn extract_into(
691651 out: &mut Vec<Value>,
692652) -> Result<(), Crash> {
693653 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;
700654 let matches = match (&shape.kind, v) {
701655 (ShapeKind::Prim(PrimKind::Int), Value::Int(_)) => true,
702656 (ShapeKind::Prim(PrimKind::Text), Value::Text(_)) => true,
src/config.rs+54−370
@@ -39,8 +39,8 @@ const RESERVED_BUILTINS: &[&str] = &[
3939 ".", "id", "const", "crash", "==", "/=", "&&", "||", "not", "+", "-", "*", "<", "<=", ">",
4040 ">=", "show", "::", "map", "filter", "length", "null", "head", "tail", "last", "nth", "take",
4141 "drop", "member", "range", "foldl", "concat", "++", "startsWith", "endsWith", "splitOn",
42− "replay", "unresolved", "blob", "blobHex", "text", "by", "meta", "validate", "diff", "difft", "treeWith",
43− "extract", "touchedPaths", "unrecorded", "subtreeCommits", "lookupCommit", "lookupContent",
42+ "replay", "unresolved", "blob", "text", "by", "meta", "validate", "diff", "difft", "treeWith",
43+ "extract", "touchedPaths",
4444];
4545
4646pub fn is_reserved_builtin(name: &str) -> bool {
@@ -53,8 +53,6 @@ pub fn reserved_set() -> BTreeSet<String> {
5353
5454/// Parse and validate config.j (§6.2). Id literals are resolved separately.
5555pub fn load_config(src: &str) -> Result<Config, ConfigError> {
56− // the builtins only: config.j's binders may reuse its own top-level
57− // names (§4.2), as the reference config's `\files ->` does
5856 let outer = Rc::new(reserved_set());
5957 let items = parse_config(src, outer).map_err(ConfigError::Parse)?;
6058 validate_items(items)
@@ -97,24 +95,39 @@ fn validate_items(items: Vec<Item>) -> Result<Config, ConfigError> {
9795 }
9896
9997 // pair signatures with definitions; a signature must immediately precede
100− // its definition (blank lines/comments fine — items between are not, a
101− // typedecl or another signature included). A builtin's signature has no
102− // definition, so any item, or the end of the file, may follow it.
103− let mut prev_sig: Option<&String> = None;
104− for item in items.iter().map(Some).chain([None]) {
105− if let Some(s) = prev_sig {
106− let paired = matches!(item, Some(Item::Definition(name, _, _)) if name == s);
107− if !paired && def_names.contains(s) {
108− return verr(format!(
109− "the signature for `{}` is not immediately followed by its definition",
110− s
111− ));
98+ // its definition (blank lines/comments fine — items between are not)
99+ let mut prev_sig: Option<String> = None;
100+ for item in &items {
101+ match item {
102+ Item::Signature(name, _, _) => {
103+ prev_sig = Some(name.clone());
104+ }
105+ Item::Definition(name, _, _) => {
106+ if let Some(s) = &prev_sig {
107+ if s != name {
108+ // signature not immediately followed by its definition
109+ if def_names.contains(s) {
110+ return verr(format!(
111+ "the signature for `{}` is not immediately followed by its definition",
112+ s
113+ ));
114+ }
115+ }
116+ }
117+ prev_sig = None;
118+ }
119+ Item::TypeDecl(_, _) => {
120+ prev_sig = None;
112121 }
113122 }
114− prev_sig = match item {
115− Some(Item::Signature(name, _, _)) => Some(name),
116− _ => None,
117− };
123+ }
124+ if let Some(s) = &prev_sig {
125+ if def_names.contains(s) {
126+ return verr(format!(
127+ "the signature for `{}` is not immediately followed by its definition",
128+ s
129+ ));
130+ }
118131 }
119132
120133 let mut builtin_names: HashSet<String> = HashSet::new();
@@ -166,25 +179,7 @@ fn validate_items(items: Vec<Item>) -> Result<Config, ConfigError> {
166179 })
167180}
168181
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.
182+/// topological order by references outside lambdas (§4.1)
188183fn dependency_order(
189184 defs: &HashMap<String, Rc<Expr>>,
190185) -> Result<Vec<(String, Rc<Expr>)>, ConfigError> {
@@ -199,7 +194,7 @@ fn dependency_order(
199194 let mut done: HashSet<String> = HashSet::new();
200195 let mut names: Vec<String> = defs.keys().cloned().collect();
201196 names.sort();
202− while done.len() < names.len() {
197+ loop {
203198 let mut progress = false;
204199 for n in &names {
205200 if done.contains(n) {
@@ -211,305 +206,15 @@ fn dependency_order(
211206 progress = true;
212207 }
213208 }
209+ if done.len() == names.len() {
210+ return Ok(ordered);
211+ }
214212 if !progress {
215213 return Err(ConfigError::Validation(
216214 "config.j: a cycle among top-level definitions".into(),
217215 ));
218216 }
219217 }
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− }
513218}
514219
515220/// Names a definition's value depends on at load time (§4.1). Lambda bodies
@@ -571,10 +276,6 @@ fn load_deps(e: &Expr, out: &mut BTreeSet<String>) {
571276 load_deps(e, out);
572277 }
573278 }
574− // `%main` is `labelled "main"` (§4.11), evaluated where it stands
575− Expr::LabelLit(_) => {
576− out.insert("labelled".to_string());
577− }
578279 _ => {}
579280 }
580281}
@@ -603,34 +304,29 @@ pub fn eval_config(interp: &mut Interp, cfg: &Config) -> Result<(), Crash> {
603304 }
604305 }
605306 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− }
610307 // evaluate definitions in dependency order, through one recursive frame so
611308 // top-level definitions are mutually recursive (§4.1)
612− let names: Rc<[String]> = cfg.defs.iter().map(|(name, _)| name.clone()).collect();
613− let (genv, cell) = interp.globals.extend_rec(names);
309+ let (genv, cell) = interp.globals.extend_rec();
614310 for (name, expr) in &cfg.defs {
615311 *interp.current_def.borrow_mut() = Some(name.clone());
616312 let v = interp.eval(expr, &genv)?;
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)
313+ // value-level contract for non-function definitions
621314 if let Some(ty) = cfg.sigs.get(name) {
622− if let Err(msg) = crate::shape::check(&interp.shapes, ty, &v) {
623− return Err(Crash::new(format!("contract: {}: {}", name, msg)));
315+ let c = compile_contract(&interp.shapes, ty);
316+ if c.params.is_empty() {
317+ if let Err(msg) = crate::shape::check(&interp.shapes, &c.result, &v) {
318+ return Err(Crash::new(format!("contract: {}: {}", name, msg)));
319+ }
624320 }
625321 }
626− cell.borrow_mut().push(v.clone());
322+ cell.borrow_mut().push((name.clone(), v.clone()));
627323 // attach the definition's name and contract to its function value so
628324 // applications are checked (§4.13) and errors name the definition
629325 if let Some(ty) = cfg.sigs.get(name) {
630326 let contract = Rc::new(compile_contract(&interp.shapes, ty));
631327 let named = crate::value::attach_pending(&v, name, contract);
632328 let last = cell.borrow_mut().len() - 1;
633− cell.borrow_mut()[last] = named;
329+ cell.borrow_mut()[last] = (name.clone(), named);
634330 }
635331 }
636332 interp.globals = genv;
@@ -768,29 +464,17 @@ fn resolve_ids_rec(
768464 }
769465}
770466
771−/// Resolve every `@prefix` literal in the config's definitions against the
772−/// visible ids, in place (§1.2.5: "in `config.j`, every literal is resolved at
773−/// load"). Returns the ones that did not resolve to exactly one commit, as
774−/// (definition, prefix, candidates); those definitions are left untouched.
775−///
776−/// Checking without rewriting is not enough: an unresolved literal reaching
777−/// the evaluator crashes with `internal: unresolved id literal`, and because a
778−/// lambda body is not evaluated at load, it does so only when the definition
779−/// is eventually used.
780−pub fn resolve_config_ids(
781− cfg: &mut Config,
467+/// find Id literals per definition for config validation (§6.2.5)
468+pub fn config_id_failures(
469+ cfg: &Config,
782470 interp: &Interp,
783471) -> Vec<(String, String, Vec<String>)> {
784472 let mut out = Vec::new();
785− for (name, expr) in cfg.defs.iter_mut() {
473+ for (name, expr) in &cfg.defs {
786474 let mut failures = Vec::new();
787− let resolved = resolve_ids_rec(expr, interp, &mut failures);
788− if failures.is_empty() {
789− *expr = Rc::new(resolved);
790− } else {
791− for (prefix, matches) in failures {
792− out.push((name.clone(), prefix, matches));
793− }
475+ resolve_ids_rec(expr, interp, &mut failures);
476+ for (prefix, matches) in failures {
477+ out.push((name.clone(), prefix, matches));
794478 }
795479 }
796480 out
src/diagnostics.rsdeleted−381
@@ -1,381 +0,0 @@
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+19−154
@@ -2,7 +2,7 @@
22//! (§7, §10). Two implementations: an in-memory backend for tests, and the
33//! jj-lib backend.
44
5−use crate::value::{BlobContent, BlobKind, BlobVal, ConflictSide, Crash, Value};
5+use crate::value::{BlobContent, BlobKind, BlobVal, Crash, Value};
66use std::collections::{BTreeMap, BTreeSet, HashMap};
77use std::rc::Rc;
88
@@ -29,12 +29,6 @@ pub struct ImmutabilityInfo {
2929}
3030
3131pub trait Backend {
32− /// Refuse an edit that cannot be authored with the backend's current
33− /// configuration. In-memory repositories have no authoring requirement.
34− fn validate_authoring(&self) -> Result<(), Crash> {
35− Ok(())
36− }
37−
3832 /// All visible change ids (full), for resolving `@prefix` literals.
3933 fn visible_ids(&self) -> Vec<String>;
4034 /// All visible ids beginning with `prefix`.
@@ -48,38 +42,19 @@ pub trait Backend {
4842 fn meta(&self, id: &str) -> Result<MetaInfo, Crash>;
4943 /// True if the change id denotes a merge commit (2+ parents).
5044 fn is_merge(&self, id: &str) -> bool;
51− /// True if the stored commit's tree contains a conflict, if the backend
52− /// can say without reading files (jj: O(1)); None means "unknown, compare
53− /// files". It describes a commit value only while that value still holds
54− /// the stored files (`ThunkVal::origin`), which is when the renderer asks.
45+ /// True if the commit's tree contains a conflict, if the backend can say
46+ /// without reading files (jj: O(1)); None means "unknown, compare files".
5547 fn has_conflict(&self, _id: &str) -> Option<bool> {
5648 None
5749 }
58− /// True if the stored commit `id`'s tree equals the stored commit
59− /// `parent`'s, if the backend can say without reading files; None means
60− /// "compare files". Asked, like `has_conflict`, only for values that still
61− /// hold both stored trees.
62− fn is_empty(&self, _id: &str, _parent: &str) -> Option<bool> {
50+ /// True if the commit's tree equals its first parent's tree, if the
51+ /// backend can say without reading files; None means "compare files".
52+ fn is_empty(&self, _id: &str) -> Option<bool> {
6353 None
6454 }
65− /// All ancestors (inclusive) of the given change ids, through every
66− /// parent of a merge, not only the first (§7.5 step 3).
55+ /// All ancestors (inclusive) of the given change ids.
6756 fn ancestors_closed(&self, ids: &BTreeSet<String>) -> BTreeSet<String>;
6857
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−
8358 /// jj tree merge (§7.3): replay the change from->to onto `onto`.
8459 fn replay(
8560 &self,
@@ -88,12 +63,6 @@ pub trait Backend {
8863 to: &[Value],
8964 ) -> Result<Vec<Value>, Crash>;
9065
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−
9766 /// shortest prefix of `id` unique among visible ids, min length 4
9867 fn unique_prefix(&self, id: &str) -> String {
9968 let others: Vec<String> = self.visible_ids();
@@ -138,56 +107,13 @@ pub fn snapshot_map(entries: &[Value]) -> Result<BTreeMap<Vec<String>, Value>, C
138107 .map_err(|_| Crash::new("replay: not a well-formed snapshot (path component not Text)"))
139108 })
140109 .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− }
147110 if m.insert(path, content).is_some() {
148111 return Err(Crash::new("replay: not a well-formed snapshot (duplicate paths)"));
149112 }
150113 }
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− }
157114 Ok(m)
158115}
159116
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−
191117pub fn map_to_snapshot(m: BTreeMap<Vec<String>, Value>) -> Vec<Value> {
192118 m.into_iter()
193119 .map(|(path, content)| {
@@ -221,7 +147,7 @@ fn empty_blob() -> Value {
221147 }))
222148}
223149
224−fn conflict_blob(sides: Vec<Option<ConflictSide>>) -> Value {
150+fn conflict_blob(sides: Vec<Rc<Vec<u8>>>) -> Value {
225151 Value::Blob(Rc::new(BlobVal {
226152 kind: BlobKind::Regular,
227153 content: BlobContent::Conflict(sides),
@@ -276,40 +202,28 @@ pub fn simple_replay(
276202 } else if !t_present && !o_present {
277203 // both deleted
278204 } else {
279− // conflict: sides [to, from, onto] (add, remove, add),
280− // each absent or with its own file type (§7.3)
281− let side = |v: &Value, present: bool| -> Result<Option<ConflictSide>, Crash> {
205+ // conflict: sides [to, from, onto] (add, remove, add)
206+ let get_bytes = |v: &Value, present: bool| -> Result<Rc<Vec<u8>>, Crash> {
282207 if !present {
283− return Ok(None);
208+ return Ok(Rc::new(Vec::new()));
284209 }
285210 match v {
286− Value::Blob(b) => Ok(Some(ConflictSide {
287− kind: b.kind.clone(),
288− bytes: Rc::new(b.bytes()?),
289− tree: None,
290− })),
291− _ => Ok(Some(ConflictSide::regular(&[]))),
211+ Value::Blob(b) => Ok(Rc::new(b.bytes()?)),
212+ _ => Ok(Rc::new(Vec::new())),
292213 }
293214 };
294215 out.insert(
295216 p,
296217 conflict_blob(vec![
297− side(&t, t_present)?,
298− side(&f, f_present)?,
299− side(&o, o_present)?,
218+ get_bytes(&t, t_present)?,
219+ get_bytes(&f, f_present)?,
220+ get_bytes(&o, o_present)?,
300221 ]),
301222 );
302223 }
303224 }
304225 }
305226 }
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− }
313227 Ok(map_to_snapshot(out))
314228}
315229
@@ -323,47 +237,17 @@ pub struct MemBackend {
323237 pub metas: HashMap<String, MetaInfo>,
324238 /// id -> parent change ids (first parent first)
325239 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,
339240}
340241
341242impl MemBackend {
342243 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 {
348244 MemBackend {
349− answers_tree_queries: true,
350− ..MemBackend::default()
245+ metas: HashMap::new(),
246+ parents: HashMap::new(),
351247 }
352248 }
353249}
354250
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−
367251impl Backend for MemBackend {
368252 fn visible_ids(&self) -> Vec<String> {
369253 let mut ids: Vec<String> = self.metas.keys().cloned().collect();
@@ -374,30 +258,11 @@ impl Backend for MemBackend {
374258 self.metas
375259 .get(id)
376260 .cloned()
377− .ok_or_else(|| Crash::new(format!("meta: no stored commit for `{}`", id)))
261+ .ok_or_else(|| Crash::new(format!("meta: no stored commit for id")))
378262 }
379263 fn is_merge(&self, id: &str) -> bool {
380264 self.parents.get(id).map(|p| p.len() >= 2).unwrap_or(false)
381265 }
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− }
401266 fn ancestors_closed(&self, ids: &BTreeSet<String>) -> BTreeSet<String> {
402267 let mut seen = BTreeSet::new();
403268 let mut stack: Vec<String> = ids.iter().cloned().collect();
src/eval.rs+304−845
@@ -4,7 +4,7 @@
44use crate::ast::{Expr, Pattern};
55use crate::domain::Backend;
66use crate::shape::{check as contract_check, Contract, Shapes};
7−use crate::value::{Crash, Env, FunVal, ListVal, Value};
7+use crate::value::{Crash, Env, FunVal, Value};
88use std::cell::RefCell;
99use std::collections::HashMap;
1010use std::rc::Rc;
@@ -20,76 +20,8 @@ pub struct Interp {
2020 fresh: RefCell<u64>,
2121 /// the currently executing top-level definition (for crash reports)
2222 pub current_def: RefCell<Option<String>>,
23− /// The definitions written as lambdas, by the address of the lambda's
24− /// body, which every closure made from it shares: while that body runs,
25− /// the definition is executing (§1.4), whatever name its closure has
26− /// been bound to since (`prev = goto parents` runs goto's body). The body
27− /// is held so its address cannot be reused.
28− def_bodies: HashMap<*const Expr, (Rc<str>, Rc<Expr>)>,
2923 /// the repository as loaded, before snapshotting (for `validate`)
3024 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− }
9325}
9426
9527/// machine state
@@ -107,28 +39,26 @@ pub(crate) enum Cont {
10739 Arg(Rc<Expr>, Env, Rc<Cont>),
10840 Fun(Value, Rc<Cont>),
10941 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.
11242 LetRest {
113− bindings: Rc<[(String, Rc<Expr>)]>,
114− at: usize,
115− cell: Option<Rc<RefCell<Vec<Value>>>>,
43+ names: Vec<String>,
44+ exprs: Vec<Rc<Expr>>,
45+ acc: Vec<(String, Value)>,
46+ cell: Rc<RefCell<Vec<(String, Value)>>>,
11647 body: Rc<Expr>,
11748 env: Env,
11849 cont: Rc<Cont>,
11950 },
120− /// `&&` or `||` after applying its first operand normally, so the
121− /// declared contract has checked it; the rhs is still unevaluated
122− ShortCircuit {
123− op: &'static str,
124− rhs: Rc<Expr>,
125− env: Env,
126− cont: Rc<Cont>,
127− },
51+ /// short-circuit ops with the unevaluated rhs
52+ And(Rc<Expr>, Env, Rc<Cont>),
53+ Or2(Rc<Expr>, Env, Rc<Cont>),
54+ /// check the rhs of && / || evaluated to a Bool
55+ ExpectBool(&'static str, Rc<Cont>),
12856 /// `or` after both evaluated as functions: build the lifted function
12957 OrBoth(Value, Rc<Cont>),
13058 /// a shared continuation (used when both `or` outcomes continue the same way)
13159 Shared(Rc<Cont>),
60+ /// unconditionally crash with a fixed message (deferred-body failure)
61+ CrashWith(String),
13262 /// run the continuation's computation, catching crashes: on success pass
13363 /// the value to `on_ok`, on crash switch to `on_crash` (§4.6)
13464 Try {
@@ -136,29 +66,27 @@ pub(crate) enum Cont {
13666 snapshot: u64,
13767 on_crash: Box<State>,
13868 },
139− /// apply the returned function to this argument: the `labelled "name"`
140− /// of a Labelled to the real arg
141− ApplyTo(Value, Rc<Cont>),
142− // Literal/update inputs and accumulators are shared across continuation
143− // clones. The trampoline keeps an active clone for crash unwinding; deep
144− // copying these buffers at every element made flat literals quadratic.
145− UpdateBase(Rc<[(String, Rc<Expr>)]>, Env, Rc<Cont>),
69+ /// applying a Labelled: apply inner `labelled` result to the real arg
70+ LabelledArg(Value, Rc<Cont>),
71+ UpdateBase(Vec<(String, Rc<Expr>)>, Env, Rc<Cont>),
14672 UpdateFields {
14773 base: Value,
148− fields: Rc<[(String, Rc<Expr>)]>,
149− acc: Rc<RefCell<Vec<(String, Value)>>>,
74+ names: Vec<String>,
75+ exprs: Vec<Rc<Expr>>,
76+ acc: Vec<(String, Value)>,
15077 env: Env,
15178 cont: Rc<Cont>,
15279 },
15380 RecordFields {
154− fields: Rc<[(String, Rc<Expr>)]>,
155− acc: Rc<RefCell<Vec<(String, Value)>>>,
81+ names: Vec<String>,
82+ exprs: Vec<Rc<Expr>>,
83+ acc: Vec<(String, Value)>,
15684 env: Env,
15785 cont: Rc<Cont>,
15886 },
15987 ListElems {
160− exprs: Rc<[Rc<Expr>]>,
161− acc: Rc<RefCell<Vec<Value>>>,
88+ exprs: Vec<Rc<Expr>>,
89+ acc: Vec<Value>,
16290 env: Env,
16391 cont: Rc<Cont>,
16492 },
@@ -168,16 +96,6 @@ pub(crate) enum Cont {
16896 cexpr: Rc<crate::shape::ContractExpr>,
16997 cont: Rc<Cont>,
17098 },
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− },
18199 /// after applying, check the (partial) result against the contract
182100 #[allow(dead_code)]
183101 CheckResult {
@@ -186,57 +104,6 @@ pub(crate) enum Cont {
186104 supplied: usize,
187105 cont: Rc<Cont>,
188106 },
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− },
240107}
241108
242109pub type EResult = Result<Value, Crash>;
@@ -251,13 +118,12 @@ pub(crate) enum TryOk {
251118 env: Env,
252119 cont: Rc<Cont>,
253120 },
254− /// the lhs of a lifted `or` applied to an argument: like `OrLhs`, with
255− /// the rhs function still to be applied to the same argument
256− OrFunLhs {
257− rhs: Value,
258− arg: Value,
259− cont: Rc<Cont>,
260− },
121+ /// the deferred body of a signed definition: apply it to the pending arg
122+ DeferredBody { arg: Value, cont: Rc<Cont> },
123+ /// the inner leg of a lazy composition: apply the outer function
124+ ComposeInner { f: Value, cont: Rc<Cont> },
125+ /// the lhs of a lifted `or` applied to an argument
126+ OrFunLhs { cont: Rc<Cont> },
261127}
262128
263129pub(crate) enum Run {
@@ -269,28 +135,23 @@ pub(crate) enum Run {
269135/// the parent continuation Rc of `k`, for iterative drop and unwinding
270136fn parent_rc(k: &Cont) -> Option<&Rc<Cont>> {
271137 match k {
272− Cont::Halt | Cont::Try { .. } => None,
138+ Cont::Halt | Cont::CrashWith(_) | Cont::Try { .. } => None,
273139 Cont::Arg(_, _, k) => Some(k),
274140 Cont::Fun(_, k) => Some(k),
275141 Cont::If(_, _, _, k) => Some(k),
276142 Cont::LetRest { cont, .. } => Some(cont),
277− Cont::ShortCircuit { cont, .. } => Some(cont),
143+ Cont::And(_, _, k) => Some(k),
144+ Cont::Or2(_, _, k) => Some(k),
145+ Cont::ExpectBool(_, k) => Some(k),
278146 Cont::OrBoth(_, k) => Some(k),
279147 Cont::Shared(k) => Some(k),
280− Cont::ApplyTo(_, k) => Some(k),
148+ Cont::LabelledArg(_, k) => Some(k),
281149 Cont::UpdateBase(_, _, k) => Some(k),
282150 Cont::UpdateFields { cont, .. } => Some(cont),
283151 Cont::RecordFields { cont, .. } => Some(cont),
284152 Cont::ListElems { cont, .. } => Some(cont),
285153 Cont::CheckExpr { cont, .. } => Some(cont),
286− Cont::NamePartial { cont, .. } => Some(cont),
287154 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),
294155 }
295156}
296157
@@ -299,99 +160,6 @@ pub(crate) fn pop_cont(k: &Cont) -> Option<Cont> {
299160 parent_rc(k).map(|r| (**r).clone())
300161}
301162
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−
395163impl Interp {
396164 pub fn new(backend: Rc<dyn Backend>, shapes: Shapes, globals: Env) -> Interp {
397165 Interp {
@@ -401,56 +169,10 @@ impl Interp {
401169 contracts: HashMap::new(),
402170 fresh: RefCell::new(0),
403171 current_def: RefCell::new(None),
404− def_bodies: HashMap::new(),
405172 old_repo: RefCell::new(None),
406− given_repo: RefCell::new(None),
407− revsets: RefCell::new(Vec::new()),
408− sorted_ids: RefCell::new(None),
409173 }
410174 }
411175
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−
454176 /// An interpreter shell used only for recursive rendering; panics if any
455177 /// backend interaction is attempted.
456178 pub fn dummy() -> Interp {
@@ -461,11 +183,7 @@ impl Interp {
461183 contracts: HashMap::new(),
462184 fresh: RefCell::new(0),
463185 current_def: RefCell::new(None),
464− def_bodies: HashMap::new(),
465186 old_repo: RefCell::new(None),
466− given_repo: RefCell::new(None),
467− revsets: RefCell::new(Vec::new()),
468− sorted_ids: RefCell::new(None),
469187 }
470188 }
471189
@@ -496,26 +214,6 @@ impl Interp {
496214 self.globals.clone()
497215 }
498216
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−
519217 /// Main entry: evaluate an expression in an environment.
520218 pub fn eval(&mut self, e: &Rc<Expr>, env: &Env) -> EResult {
521219 self.run(State::Eval(e.clone(), env.clone(), Cont::Halt))
@@ -532,105 +230,11 @@ impl Interp {
532230 c
533231 }
534232
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−
625233 fn run(&mut self, st: State) -> EResult {
626− let result = match self.trampoline(st) {
234+ match self.trampoline(st) {
627235 Ok(v) => Ok(v),
628236 Err(c) => Err(self.decorate(c)),
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
237+ }
634238 }
635239
636240 fn trampoline(&mut self, st: State) -> EResult {
@@ -657,18 +261,26 @@ impl Interp {
657261 Run::Step(next) => st = next,
658262 Run::Done(v) => return Ok(v),
659263 Run::Crash(c) => {
660− // Unwind to the nearest Try continuation, restoring the
264+ // unwind to the nearest Try continuation, restoring the
661265 // fresh-id snapshot so a caught crash leaves no trace.
662− // A Try cannot catch a crash raised by its own handler:
663− // every `on_crash` state continues through `Cont::Shared`
664− // holding the continuation *outside* the Try, so the Try
665− // is no longer on the chain once the handler runs.
266+ // A `Shared` continuation links back to a context already
267+ // containing a Try, so track visited Try nodes and skip
268+ // ones already unwound past (a caught crash must not be
269+ // re-caught by the same Try, which would loop forever).
666270 let mut k = Some(active.clone());
271+ let mut visited: Vec<*const Cont> = Vec::new();
667272 let caught = loop {
668273 match k {
669274 Some(Cont::Try {
670275 snapshot, on_crash, ..
671276 }) => {
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+ }
672284 *self.fresh.borrow_mut() = snapshot;
673285 break Some(*on_crash);
674286 }
@@ -676,20 +288,9 @@ impl Interp {
676288 None => break None,
677289 }
678290 };
679− // the frames up to the Try, or all of them, are abandoned
680291 match caught {
681− Some(next) => {
682− drop_chain(active);
683− st = next
684− }
685− None => {
686− let mut c = c;
687− if c.def.is_none() {
688− c.def = innermost_def(&active);
689− }
690− drop_chain(active);
691− return Err(c);
692− }
292+ Some(next) => st = next,
293+ None => return Err(c),
693294 }
694295 }
695296 }
@@ -740,6 +341,7 @@ impl Interp {
740341 params: params.clone(),
741342 applied: 0,
742343 applied_args: Vec::new(),
344+ deferred: false,
743345 body: body.clone(),
744346 env,
745347 src: src.clone(),
@@ -756,31 +358,18 @@ impl Interp {
756358 if bs.is_empty() {
757359 return Run::Step(State::Eval(body.clone(), env, k));
758360 }
759− // the bindings are evaluated in dependency order, not source
760− // order, and a cycle among them is a crash (§4.1)
761− let plan = match crate::config::let_plan(bs) {
762− Some(plan) => plan,
763− None => return Run::Crash(Crash::new("let: a cycle among the bindings")),
764− };
765− let bindings: Rc<[(String, Rc<Expr>)]> =
766− plan.order.iter().map(|&i| bs[i].clone()).collect();
767− // A closure in a recursive block sees later bindings through
768− // the shared cell (§4.1). Acyclic bindings need only earlier
769− // values: immutable frames avoid a cell -> closure -> frame
770− // cycle for ordinary local helper functions.
771− let (env2, cell) = if plan.recursive {
772− let names = bindings.iter().map(|(n, _)| n.clone()).collect();
773− let (env, cell) = env.extend_rec(names);
774− (env, Some(cell))
775− } else {
776− (env, None)
777− };
361+ let names: Vec<String> = bs.iter().map(|(n, _)| n.clone()).collect();
362+ let exprs: Vec<Rc<Expr>> = bs.iter().map(|(_, e)| e.clone()).collect();
363+ // one recursive frame shared by every binding and the body, so
364+ // the block is mutually recursive (§4.1)
365+ let (env2, cell) = env.extend_rec();
778366 Run::Step(State::Eval(
779− bindings[0].1.clone(),
367+ exprs[0].clone(),
780368 env2.clone(),
781369 Cont::LetRest {
782− bindings,
783− at: 0,
370+ names,
371+ exprs,
372+ acc: Vec::new(),
784373 cell,
785374 body: body.clone(),
786375 env: env2,
@@ -794,26 +383,29 @@ impl Interp {
794383 Cont::Arg(x.clone(), env, Rc::new(k)),
795384 )),
796385 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+ }
797400 let fv = match env.lookup(op) {
798401 Some(v) => v,
799402 None => return Run::Crash(Crash::new(format!("unbound name `{}`", op))),
800403 };
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 ||.
404+ // eval a, apply the operator to it, then apply the result to b
813405 Run::Step(State::Eval(
814406 a.clone(),
815− env,
816− Cont::Fun(fv, Rc::new(next)),
407+ env.clone(),
408+ Cont::Fun(fv, Rc::new(Cont::Arg(b.clone(), env, Rc::new(k)))),
817409 ))
818410 }
819411 Expr::Or(a, b) => {
@@ -842,19 +434,22 @@ impl Interp {
842434 Expr::Update(base, fields) => Run::Step(State::Eval(
843435 base.clone(),
844436 env.clone(),
845− Cont::UpdateBase(fields.clone().into(), env, Rc::new(k)),
437+ Cont::UpdateBase(fields.clone(), env, Rc::new(k)),
846438 )),
847439 Expr::Record(fields) => {
848440 if fields.is_empty() {
849441 let m = crate::value::RecordMap::new();
850442 return Run::Step(State::Ret(Value::Record(Rc::new(m)), k));
851443 }
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();
852446 Run::Step(State::Eval(
853− fields[0].1.clone(),
447+ exprs[0].clone(),
854448 env.clone(),
855449 Cont::RecordFields {
856− fields: fields.clone().into(),
857− acc: Rc::new(RefCell::new(Vec::with_capacity(fields.len()))),
450+ names,
451+ exprs,
452+ acc: Vec::new(),
858453 env,
859454 cont: Rc::new(k),
860455 },
@@ -868,8 +463,8 @@ impl Interp {
868463 elems[0].clone(),
869464 env.clone(),
870465 Cont::ListElems {
871− exprs: elems.clone().into(),
872− acc: Rc::new(RefCell::new(Vec::with_capacity(elems.len()))),
466+ exprs: elems.clone(),
467+ acc: Vec::new(),
873468 env,
874469 cont: Rc::new(k),
875470 },
@@ -895,31 +490,29 @@ impl Interp {
895490 ))),
896491 },
897492 Cont::LetRest {
898− bindings,
899− at,
493+ names,
494+ exprs,
495+ mut acc,
900496 cell,
901497 body,
902498 env,
903499 cont,
904500 } => {
905− let env = match &cell {
906− Some(cell) => {
907− cell.borrow_mut().push(v);
908− env
909− }
910− None => env.extend(vec![(bindings[at].0.clone(), v)]),
911− };
912− let at = at + 1;
913− if at == bindings.len() {
501+ let idx = acc.len();
502+ let pair = (names[idx].clone(), v);
503+ cell.borrow_mut().push(pair.clone());
504+ acc.push(pair);
505+ if acc.len() == names.len() {
914506 Run::Step(State::Eval(body, env, (*cont).clone()))
915507 } else {
916− let next = bindings[at].1.clone();
508+ let next = exprs[acc.len()].clone();
917509 Run::Step(State::Eval(
918510 next,
919511 env.clone(),
920512 Cont::LetRest {
921− bindings,
922− at,
513+ names,
514+ exprs,
515+ acc,
923516 cell,
924517 body,
925518 env,
@@ -928,42 +521,24 @@ impl Interp {
928521 ))
929522 }
930523 }
931− Cont::ShortCircuit { op, rhs, env, cont } => {
932− // Operator names are reserved: resolving && or || and
933− // applying one operand always gives its builtin partial.
934− let (lhs, pending) = match &v {
935− Value::Fun(f) => match &**f {
936− FunVal::Builtin { args, pending, .. } if args.len() == 1 => {
937− (&args[0], pending)
938− }
939− _ => return Run::Crash(Crash::new("internal: invalid boolean operator")),
940− },
941− _ => return Run::Crash(Crash::new("internal: invalid boolean operator")),
942− };
943− let lhs = match lhs {
944− Value::Bool(b) => *b,
945− other => return Run::Crash(Crash::new(format!(
946− "{}: expected Bool, got a {}", op, other.kind_name()
947− ))),
948− };
949− if lhs == (op == "||") {
950− let result = Value::Bool(lhs);
951− if let Some((name, c, _)) = pending {
952− // Skipping an operand must not evaluate or check
953− // it, but the operator still returns a checked
954− // result after its two parameter positions (§4.13).
955− let c = crate::shape::ContractExpr::apply_first_rc(c.clone());
956− if let Err(cr) = c.check_result(&self.shapes, name, &result) {
957− return Run::Crash(self.blame_builtin(cr, op, name));
958− }
959− }
960− Run::Step(State::Ret(result, (*cont).clone()))
961− } else {
962− // The normal application path checks the rhs and the
963− // builtin's result, with the same argument numbering.
964− Run::Step(State::Eval(rhs, env, Cont::Fun(v, cont)))
965− }
966− }
524+ Cont::And(b, env, k) => match v {
525+ Value::Bool(false) => Run::Step(State::Ret(Value::Bool(false), (*k).clone())),
526+ Value::Bool(true) => Run::Step(State::Eval(b, env, Cont::ExpectBool("&&", k))),
527+ _ => Run::Crash(Crash::new(format!("&&: expected Bool, got a {}", v.kind_name()))),
528+ },
529+ Cont::Or2(b, env, k) => match v {
530+ Value::Bool(true) => Run::Step(State::Ret(Value::Bool(true), (*k).clone())),
531+ Value::Bool(false) => Run::Step(State::Eval(b, env, Cont::ExpectBool("||", k))),
532+ _ => Run::Crash(Crash::new(format!("||: expected Bool, got a {}", v.kind_name()))),
533+ },
534+ Cont::ExpectBool(op, k) => match v {
535+ Value::Bool(_) => Run::Step(State::Ret(v, (*k).clone())),
536+ _ => Run::Crash(Crash::new(format!(
537+ "{}: expected Bool, got a {}",
538+ op,
539+ v.kind_name()
540+ ))),
541+ },
967542 Cont::OrBoth(a, k) => {
968543 if matches!(v, Value::Fun(_)) {
969544 Run::Step(State::Ret(Value::Fun(Rc::new(FunVal::OrFun(a, v))), (*k).clone()))
@@ -994,24 +569,16 @@ impl Interp {
994569 Run::Step(State::Ret(v, Cont::Shared(cont)))
995570 }
996571 }
997− Some(TryOk::OrFunLhs { rhs, arg, cont }) => {
998− // `f x or g x` (§4.6): when `f x` is itself a
999− // function, apply `g` too and lift again, so the
1000− // fallback keeps guarding the later arguments
1001− if matches!(v, Value::Fun(_)) {
1002− Run::Step(State::Apply(
1003− rhs,
1004− arg,
1005− Cont::OrBoth(v, Rc::new(Cont::Shared(cont))),
1006− ))
1007− } else {
1008− Run::Step(State::Ret(v, (*cont).clone()))
1009− }
572+ Some(TryOk::DeferredBody { arg, cont, .. }) => {
573+ Run::Step(State::Apply(v, arg, (*cont).clone()))
1010574 }
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())),
1011577 None => Run::Crash(Crash::new("internal: Try with no success handler")),
1012578 }
1013579 }
1014− Cont::ApplyTo(real, k) => Run::Step(State::Apply(v, real, (*k).clone())),
580+ Cont::LabelledArg(real, k) => Run::Step(State::Apply(v, real, (*k).clone())),
581+ Cont::CrashWith(msg) => Run::Crash(Crash::new(msg)),
1015582 Cont::UpdateBase(fields, env, k) => {
1016583 if !matches!(v, Value::Record(_)) {
1017584 return Run::Crash(Crash::new(format!(
@@ -1019,16 +586,19 @@ impl Interp {
1019586 v.kind_name()
1020587 )));
1021588 }
1022− if fields.is_empty() {
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() {
1023592 return Run::Step(State::Ret(v, (*k).clone()));
1024593 }
1025594 Run::Step(State::Eval(
1026− fields[0].1.clone(),
595+ exprs[0].clone(),
1027596 env.clone(),
1028597 Cont::UpdateFields {
1029598 base: v,
1030− acc: Rc::new(RefCell::new(Vec::with_capacity(fields.len()))),
1031− fields,
599+ names,
600+ exprs,
601+ acc: Vec::new(),
1032602 env,
1033603 cont: k,
1034604 },
@@ -1036,23 +606,20 @@ impl Interp {
1036606 }
1037607 Cont::UpdateFields {
1038608 base,
1039− fields,
1040− acc,
609+ names,
610+ exprs,
611+ mut acc,
1041612 env,
1042613 cont,
1043614 } => {
1044− let filled = {
1045− let mut values = acc.borrow_mut();
1046− let idx = values.len();
1047− values.push((fields[idx].0.clone(), v));
1048− values.len()
1049− };
1050− if filled == fields.len() {
615+ let idx = acc.len();
616+ acc.push((names[idx].clone(), v));
617+ if acc.len() == names.len() {
1051618 let mut m = match &base {
1052619 Value::Record(m) => m.as_ref().clone(),
1053620 _ => unreachable!(),
1054621 };
1055− for (n, val) in acc.take() {
622+ for (n, val) in acc {
1056623 if !m.contains_key(&n) {
1057624 return Run::Crash(Crash::new(format!(
1058625 "cannot update: record has no field `{}`",
@@ -1063,13 +630,14 @@ impl Interp {
1063630 }
1064631 Run::Step(State::Ret(Value::Record(Rc::new(m)), (*cont).clone()))
1065632 } else {
1066− let next = fields[filled].1.clone();
633+ let next = exprs[acc.len()].clone();
1067634 Run::Step(State::Eval(
1068635 next,
1069636 env.clone(),
1070637 Cont::UpdateFields {
1071638 base,
1072− fields,
639+ names,
640+ exprs,
1073641 acc,
1074642 env,
1075643 cont,
@@ -1078,27 +646,25 @@ impl Interp {
1078646 }
1079647 }
1080648 Cont::RecordFields {
1081− fields,
1082− acc,
649+ names,
650+ exprs,
651+ mut acc,
1083652 env,
1084653 cont,
1085654 } => {
1086− let filled = {
1087− let mut values = acc.borrow_mut();
1088− let idx = values.len();
1089− values.push((fields[idx].0.clone(), v));
1090− values.len()
1091− };
1092− if filled == fields.len() {
1093− let m: crate::value::RecordMap = acc.take().into_iter().collect();
655+ let idx = acc.len();
656+ acc.push((names[idx].clone(), v));
657+ if acc.len() == names.len() {
658+ let m: crate::value::RecordMap = acc.into_iter().collect();
1094659 Run::Step(State::Ret(Value::Record(Rc::new(m)), (*cont).clone()))
1095660 } else {
1096− let next = fields[filled].1.clone();
661+ let next = exprs[acc.len()].clone();
1097662 Run::Step(State::Eval(
1098663 next,
1099664 env.clone(),
1100665 Cont::RecordFields {
1101− fields,
666+ names,
667+ exprs,
1102668 acc,
1103669 env,
1104670 cont,
@@ -1108,19 +674,15 @@ impl Interp {
1108674 }
1109675 Cont::ListElems {
1110676 exprs,
1111− acc,
677+ mut acc,
1112678 env,
1113679 cont,
1114680 } => {
1115− let filled = {
1116− let mut values = acc.borrow_mut();
1117− values.push(v);
1118− values.len()
1119− };
1120− if filled == exprs.len() {
1121− Run::Step(State::Ret(Value::list(acc.take()), (*cont).clone()))
681+ acc.push(v);
682+ if acc.len() == exprs.len() {
683+ Run::Step(State::Ret(Value::list(acc), (*cont).clone()))
1122684 } else {
1123− let next = exprs[filled].clone();
685+ let next = exprs[acc.len()].clone();
1124686 Run::Step(State::Eval(
1125687 next,
1126688 env.clone(),
@@ -1139,19 +701,6 @@ impl Interp {
1139701 }
1140702 Run::Step(State::Ret(v, (*cont).clone()))
1141703 }
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− }
1155704 Cont::CheckResult {
1156705 name,
1157706 contract,
@@ -1176,96 +725,6 @@ impl Interp {
1176725 }
1177726 Run::Step(State::Ret(v, (*cont).clone()))
1178727 }
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− }
1269728 }
1270729 }
1271730
@@ -1281,83 +740,40 @@ impl Interp {
1281740 pending,
1282741 } => {
1283742 let mut all = args.clone();
1284− let (cname, cexpr, named_at) = match pending {
1285− Some((n, c, at)) => (n.clone(), Some(c.clone()), *at),
1286− None => (name.clone(), None, 0),
743+ let (cname, cexpr) = match pending {
744+ Some((n, c)) => (n.clone(), Some(c.clone())),
745+ None => (name.clone(), None),
1287746 };
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(_));
1288750 if let Some(c) = &cexpr {
1289− // numbered by the signature, not by the arguments
1290− // baked into a partial application (§4.13)
1291− let at = c.position().unwrap_or(args.len());
1292− if let Err(cr) = c.check_arg_at(&self.shapes, &cname, at, &arg) {
1293− return Run::Crash(self.blame_builtin(cr, name, &cname));
751+ if !skip_check {
752+ if let Err(cr) = c.check_arg_at(&self.shapes, &cname, args.len(), &arg) {
753+ return Run::Crash(cr);
754+ }
1294755 }
1295756 }
1296757 all.push(arg);
1297758 let advanced = cexpr
1298759 .map(|c| Rc::new(crate::shape::ContractExpr::apply_first_rc(c)));
1299760 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− }
1351761 match bf(self, &all) {
1352762 Ok(r) => {
1353− if let Some(c) = &check {
1354− if let Err(cr) = c.check_result(&self.shapes, &cname, &r) {
1355− return Run::Crash(self.blame_builtin(cr, name, &cname));
763+ // compose applications check results
764+ // incrementally as the composition runs
765+ if let Some(c) = &advanced {
766+ if name != "(.)" {
767+ if let Err(cr) =
768+ c.check_result(&self.shapes, &cname, &r)
769+ {
770+ return Run::Crash(cr);
771+ }
1356772 }
1357773 }
1358774 Run::Step(State::Ret(r, k))
1359775 }
1360− Err(c) => Run::Crash(self.blame_builtin(c, name, &cname)),
776+ Err(c) => Run::Crash(c),
1361777 }
1362778 } else {
1363779 Run::Step(State::Ret(
@@ -1366,7 +782,7 @@ impl Interp {
1366782 arity: *arity,
1367783 args: all,
1368784 f: *bf,
1369− pending: advanced.map(|c| (cname, c, named_at)),
785+ pending: advanced.map(|c| (cname, c)),
1370786 })),
1371787 k,
1372788 ))
@@ -1377,25 +793,77 @@ impl Interp {
1377793 params,
1378794 applied,
1379795 applied_args,
796+ deferred,
1380797 body,
1381798 env,
1382799 src,
1383800 pending,
1384801 } => {
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+ }
1385825 let (cname, cexpr) = match pending {
1386826 Some((n, c)) => (Some(n.clone()), Some(c.clone())),
1387827 None => (name.clone(), None),
1388828 };
1389− // a function argument is checked like any other: where
1390− // the signature wants a Repo, `new new` is a contract
1391− // crash, not a composition (§4.13, §4.9)
829+ // A signed definition's application to a function value is
830+ // tried as a plain application first; if that crashes (as
831+ // `abandon (contract everything)` does at load time, since
832+ // the definition is a Repo-level function), it is function
833+ // composition instead (§4.1 note: the composition is a
834+ // value before it is applied to the repository).
835+ if *applied == 0 && matches!(arg, Value::Fun(_)) && pending.is_some() {
836+ // probe: contract-check the argument; if it fails, the
837+ // application is composition instead (§4.1 note)
838+ let probe_ok = match &cexpr {
839+ Some(c) => {
840+ let r = c.check_arg(&self.shapes, "", &arg).is_ok();
841+ r
842+ }
843+ None => true,
844+ };
845+ if !probe_ok {
846+ let f = Value::Fun(Rc::new(FunVal::Closure {
847+ name: name.clone(),
848+ params: params.clone(),
849+ applied: *applied,
850+ applied_args: applied_args.clone(),
851+ deferred: false,
852+ body: body.clone(),
853+ env: env.clone(),
854+ src: src.clone(),
855+ pending: pending.clone(),
856+ }));
857+ return match crate::builtins::compose_values(self, f, arg) {
858+ Ok(composed) => Run::Step(State::Ret(composed, k)),
859+ Err(cr) => Run::Crash(cr),
860+ };
861+ }
862+ }
1392863 if let Some(c) = &cexpr {
1393864 let fname = cname.clone().unwrap_or_default();
1394− let at = c.position().unwrap_or(applied_args.len());
1395− if let Err(cr) = c.check_arg_at(&self.shapes, &fname, at, &arg) {
1396− // a violation is in the definition checked
1397− // (§1.4, §4.13's `describe 3`)
1398− return Run::Crash(in_def(cr, &fname));
865+ if let Err(cr) = c.check_arg_at(&self.shapes, &fname, applied_args.len(), &arg) {
866+ return Run::Crash(cr);
1399867 }
1400868 }
1401869 let p = &params[*applied];
@@ -1406,78 +874,47 @@ impl Interp {
1406874 let new_applied = applied + 1;
1407875 let advanced = cexpr
1408876 .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"`)
1409880 let contract_exhausted = match &advanced {
1410881 Some(c) => c.is_exhausted(),
1411882 None => true,
1412883 };
1413− if new_applied == params.len() {
1414− // the definition whose lambda this body is, if any,
1415− // executes while it runs (§1.4)
1416− let def = self
1417− .def_bodies
1418− .get(&Rc::as_ptr(body))
1419− .map(|(d, _)| d.clone());
1420− // a call in tail position of a definition's body
1421− // leaves that definition nothing to do but pass this
1422− // body's value on, so its frame gives way to the one
1423− // this body runs under, and unsigned tail recursion
1424− // keeps its continuation constant in size
1425− let signed = cname.is_some() && advanced.is_some();
1426− let k = match k {
1427− Cont::InDef { cont, .. } if signed || def.is_some() => {
1428− (*cont).clone()
1429− }
1430− k => k,
1431− };
1432− // the body is evaluated now, as at any application
1433− // (§4.1): a signature adds checks and never changes
1434− // when a body runs, or whether an `or` around the
1435− // application catches its crash (§4.6, §4.13)
1436− let k = match (cname, advanced) {
1437− // a tail call of the signed definition whose
1438− // body `k` continues: its frames serve this one
1439− (Some(n), Some(c)) if checks_already(&k, &n, &c) => k,
1440− (Some(n), Some(c)) => {
1441− // a lambda that runs out of parameters before
1442− // its signature (`describe = \m -> mapRoot …`)
1443− // returns a function, which becomes the named
1444− // partial application `describe "wip"` (§5.2)
1445− // under the rest of the signature
1446− let k = if c.is_exhausted() {
1447− k
1448− } else {
1449− let mut args = applied_args.clone();
1450− args.push(arg);
1451− Cont::NamePartial {
1452− name: n.clone(),
1453− cexpr: c.clone(),
1454− args,
1455− cont: Rc::new(k),
1456− }
1457− };
1458− Cont::CheckExpr {
1459− name: n,
1460− cexpr: c,
1461− cont: Rc::new(k),
1462− }
1463− }
1464− _ => k,
1465− };
1466− // inside the check of the definition it was bound to,
1467− // which names this one already when it is the same
1468− let k = match def {
1469− Some(d)
1470− if !matches!(&k, Cont::CheckExpr { name, .. }
1471− if **name == *d) =>
1472− {
1473− Cont::InDef {
1474− name: d,
884+ if new_applied == params.len() && contract_exhausted {
885+ Run::Step(State::Eval(
886+ body.clone(),
887+ env2,
888+ match (&cname, &advanced) {
889+ (Some(n), Some(c)) => Cont::CheckExpr {
890+ name: n.clone(),
891+ cexpr: c.clone(),
1475892 cont: Rc::new(k),
1476− }
1477− }
1478− _ => k,
1479− };
1480− Run::Step(State::Eval(body.clone(), env2, k))
893+ },
894+ _ => k,
895+ },
896+ ))
897+ } else if new_applied == params.len() {
898+ // the lambda is exhausted but the contract is not:
899+ // defer the body so the definition stays a named
900+ // partial application (§5.2)
901+ let mut new_args = applied_args.clone();
902+ new_args.push(arg.clone());
903+ let v = Value::Fun(Rc::new(FunVal::Closure {
904+ name: name.clone(),
905+ params: params.clone(),
906+ applied: new_applied,
907+ applied_args: new_args,
908+ deferred: true,
909+ body: body.clone(),
910+ env: env2,
911+ src: src.clone(),
912+ pending: match (&cname, &advanced) {
913+ (Some(n), Some(c)) => Some((n.clone(), c.clone())),
914+ _ => None,
915+ },
916+ }));
917+ Run::Step(State::Ret(v, k))
1481918 } else if contract_exhausted {
1482919 // lambda params remain but the contract is satisfied;
1483920 // keep currying (unsigned tail)
@@ -1488,6 +925,7 @@ impl Interp {
1488925 params: params.clone(),
1489926 applied: new_applied,
1490927 applied_args: new_args,
928+ deferred: false,
1491929 body: body.clone(),
1492930 env: env2,
1493931 src: src.clone(),
@@ -1505,6 +943,7 @@ impl Interp {
1505943 params: params.clone(),
1506944 applied: new_applied,
1507945 applied_args: new_args,
946+ deferred: false,
1508947 body: body.clone(),
1509948 env: env2,
1510949 src: src.clone(),
@@ -1517,10 +956,34 @@ impl Interp {
1517956 }
1518957 }
1519958 FunVal::ComposeLazy(f, g) => {
1520− // (f . g) x = f (g x) (§4.9), whatever x is: apply g,
1521− // then f to the result; a crash in either keeps its
1522− // message and unwinds to any enclosing `or` (§4.7)
1523− Run::Step(State::Apply(g.clone(), arg, Cont::Fun(f.clone(), Rc::new(k))))
959+ let f = f.clone();
960+ let g = g.clone();
961+ if matches!(arg, Value::Fun(_)) {
962+ // pointwise: (f . g) h = f (g h) — but g h is not
963+ // evaluated yet either; compose lazily
964+ let inner = Value::Fun(Rc::new(FunVal::ComposeLazy(g, arg)));
965+ Run::Step(State::Ret(
966+ Value::Fun(Rc::new(FunVal::ComposeLazy(f, inner))),
967+ k,
968+ ))
969+ } else {
970+ // apply g, catching crashes, then apply f to the result
971+ Run::Step(State::Apply(
972+ g,
973+ arg,
974+ Cont::Try {
975+ on_ok: Some(TryOk::ComposeInner {
976+ f,
977+ cont: Rc::new(k),
978+ }),
979+ snapshot: *self.fresh.borrow(),
980+ on_crash: Box::new(State::Ret(
981+ Value::Bool(false),
982+ Cont::CrashWith("composition crashed".to_string()),
983+ )),
984+ },
985+ ))
986+ }
1524987 }
1525988 FunVal::OrFun(a, b) => {
1526989 // (f or g) x = f x or g x
@@ -1531,11 +994,7 @@ impl Interp {
1531994 a,
1532995 arg.clone(),
1533996 Cont::Try {
1534− on_ok: Some(TryOk::OrFunLhs {
1535− rhs: b.clone(),
1536− arg: arg.clone(),
1537− cont: k.clone(),
1538− }),
997+ on_ok: Some(TryOk::OrFunLhs { cont: k.clone() }),
1539998 snapshot: *self.fresh.borrow(),
1540999 on_crash: Box::new(State::Apply(b, arg, Cont::Shared(k))),
15411000 },
@@ -1547,7 +1006,7 @@ impl Interp {
15471006 Run::Step(State::Apply(
15481007 labelled.clone(),
15491008 text,
1550− Cont::ApplyTo(arg, Rc::new(k)),
1009+ Cont::LabelledArg(arg, Rc::new(k)),
15511010 ))
15521011 }
15531012 },
src/help.rsdeleted−668
@@ -1,668 +0,0 @@
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+473−3177
@@ -6,38 +6,33 @@
66use crate::config::Config;
77use crate::domain::{Backend, MetaInfo, ROOT_ID};
88use crate::eval::Interp;
9−use crate::value::{BlobContent, BlobKind, BlobVal, ConflictSide, Crash, LazyBlob, ListVal, ThunkVal, Value};
10−use futures::{StreamExt, TryStreamExt};
9+use crate::value::{BlobContent, BlobKind, BlobVal, Crash, LazyBlob, Value};
1110use jj_lib::backend::{CopyId, Signature, Timestamp, TreeValue};
1211use jj_lib::commit::Commit;
1312use jj_lib::config::{ConfigLayer, ConfigSource, StackedConfig};
14−use jj_lib::conflicts::ConflictMarkerStyle;
1513use jj_lib::default_backend_factories::{default_backend_factories, default_working_copy_factories};
1614use jj_lib::gitignore::GitIgnoreFile;
17−use jj_lib::local_working_copy::{FileState, FileStates, FileType, LocalWorkingCopy, TreeState, TreeStateSettings};
18−use jj_lib::matchers::{DifferenceMatcher, EverythingMatcher, FilesMatcher, NothingMatcher};
15+use jj_lib::matchers::{EverythingMatcher, NothingMatcher};
1916use jj_lib::merge::{Diff, Merge};
2017use jj_lib::merged_tree::MergedTree;
2118use jj_lib::merged_tree_builder::MergedTreeBuilder;
22−use jj_lib::backend::{CommitId, FileId, MergedTreeValueExt, TreeId};
19+use jj_lib::backend::{CommitId, FileId};
2320use jj_lib::object_id::ObjectId;
2421use jj_lib::op_store::OperationId;
2522use jj_lib::ref_name::{RefName, RemoteName, WorkspaceName, WorkspaceNameBuf};
2623use jj_lib::repo::{ReadonlyRepo, Repo};
27−use jj_lib::repo_path::{RepoPath, RepoPathBuf, RepoPathComponent, RepoPathComponentBuf};
28−use jj_lib::revset::{ResolvedRevsetExpression, RevsetStreamExt};
24+use jj_lib::repo_path::{RepoPath, RepoPathBuf, RepoPathComponentBuf};
2925use jj_lib::settings::UserSettings;
3026use jj_lib::store::Store;
31−use jj_lib::transaction::UnpublishedOperation;
32−use jj_lib::working_copy::{LockedWorkingCopy, SnapshotError, SnapshotOptions};
27+use jj_lib::working_copy::SnapshotOptions;
3328use jj_lib::workspace::Workspace;
34−use std::cell::{Cell, RefCell};
29+use std::cell::RefCell;
3530use std::collections::{BTreeMap, BTreeSet, HashMap};
3631use std::future::Future;
3732use std::os::unix::io::AsRawFd;
3833use std::path::PathBuf;
39−use std::rc::{Rc, Weak};
40−use std::sync::{Arc, Mutex, OnceLock};
34+use std::rc::Rc;
35+use std::sync::{Arc, Mutex};
4136
4237fn block_on<F: Future>(f: F) -> F::Output {
4338 pollster::block_on(f)
@@ -45,15 +40,6 @@ fn block_on<F: Future>(f: F) -> F::Output {
4540
4641type OpenError = (u8, String);
4742
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−
5743// ----------------------------------------------------------------------
5844// locating and opening the repository (§7.1)
5945// ----------------------------------------------------------------------
@@ -78,7 +64,6 @@ fn toml_string(s: &str) -> String {
7864 '\n' => out.push_str("\\n"),
7965 '\r' => out.push_str("\\r"),
8066 '\t' => out.push_str("\\t"),
81− c if c.is_ascii_control() => out.push_str(&format!("\\u{:04X}", c as u32)),
8267 c => out.push(c),
8368 }
8469 }
@@ -267,1166 +252,31 @@ pub struct JjInner {
267252 repo: Mutex<Arc<ReadonlyRepo>>,
268253 workspace_name: WorkspaceNameBuf,
269254 /// visible map backing the `Backend` lookups during evaluation; filled by
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)
255+ /// build_interp with the post-snapshot map
272256 visible: Mutex<Option<Arc<VisibleRepo>>>,
273257 lock_guard: Mutex<Option<FileLock>>,
274− /// the snapshot's unpublished rewrite, kept between build_interp and
275− /// persist so the snapshot and the expression's edits form one jj
276− /// operation (§1.2); dropped uncommitted by printing runs. The
277− /// working-copy lock is not held: its saved state is still the tree
278− /// recorded before the snapshot until `persist`'s checkout scans the
279− /// directory again
258+ /// the snapshot transaction and working-copy lock, held between
259+ /// build_interp and persist so the snapshot and the expression's edits
260+ /// form one jj operation (§1.2); dropped uncommitted by printing runs
280261 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>>,
287262}
288263
289264/// holding the file keeps the flock
290265pub struct FileLock(#[allow(dead_code)] std::fs::File);
291266
267+#[allow(dead_code)]
292268struct PendingSnapshot {
293269 /// repo as loaded at head, before the snapshot
294270 pre_repo: Arc<ReadonlyRepo>,
295− /// the snapshot's tree for the wc commit, to be folded into the
271+ /// the snapshot's rewritten wc commit and tree, to be folded into the
296272 /// persisting operation (§1.2 step 8, §7.7)
273+ new_wc_id: CommitId,
297274 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− )
1424275}
1425276
1426277#[derive(Clone)]
1427278pub struct JjBackend {
1428279 inner: Arc<JjInner>,
1429− replay_cache: Rc<ReplayCache>,
1430280}
1431281
1432282impl JjBackend {
@@ -1434,7 +284,7 @@ impl JjBackend {
1434284 let root = find_repo_root().ok_or_else(|| {
1435285 (
1436286 2,
1437− "no jj repository found in this directory or any parent; run `j init` here, `j clone URL`, or `j help`".to_string(),
287+ "no jj repository found in this directory or any parent".to_string(),
1438288 )
1439289 })?;
1440290 let settings = user_settings(cfg);
@@ -1455,22 +305,10 @@ impl JjBackend {
1455305 ),
1456306 ));
1457307 }
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− }
1469308 let workspace_name = workspace.workspace_name().to_owned();
1470309 let repo = block_on(workspace.repo_loader().load_at_head())
1471310 .map_err(|e| (2, format!("cannot load the repository at head: {}", e)))?;
1472311 Ok(JjBackend {
1473− replay_cache: Rc::new(ReplayCache::default()),
1474312 inner: Arc::new(JjInner {
1475313 workspace_root: root,
1476314 workspace: Mutex::new(workspace),
@@ -1479,8 +317,6 @@ impl JjBackend {
1479317 visible: Mutex::new(None),
1480318 lock_guard: Mutex::new(None),
1481319 pending: Mutex::new(None),
1482− folds_case: OnceLock::new(),
1483− long_names: Mutex::new(HashMap::new()),
1484320 }),
1485321 })
1486322 }
@@ -1500,22 +336,21 @@ impl JjBackend {
1500336 {
1501337 Ok(f) => f,
1502338 Err(e) => {
1503− crate::terminal::report_error(format_args!("j: cannot create {}: {}", lock_path.display(), e));
339+ eprintln!("j: cannot create {}: {}", lock_path.display(), e);
1504340 std::process::exit(2);
1505341 }
1506342 };
1507343 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
1508344 if rc != 0 {
1509− crate::terminal::report_error(format_args!("j: another j is running in this repository"));
345+ eprintln!("j: another j is running in this repository");
1510346 std::process::exit(2);
1511347 }
1512348 *self.inner.lock_guard.lock().unwrap() = Some(FileLock(file));
1513− put_back_left(&self.inner.workspace_root);
1514349 }
1515350
1516351 pub fn build_interp(
1517352 &self,
1518− cfg: &mut Config,
353+ cfg: &Config,
1519354 _text: &str,
1520355 snapshot: bool,
1521356 ) -> Result<(Interp, Value, Value), OpenError> {
@@ -1524,8 +359,8 @@ impl JjBackend {
1524359 let head_repo = block_on(self.inner.workspace.lock().unwrap().repo_loader().load_at_head())
1525360 .map_err(|e| (2, format!("cannot reload the repository at head: {}", e)))?;
1526361 *self.inner.repo.lock().unwrap() = head_repo;
1527− let loaded_visible = Arc::new(block_on(read_visible(&self.current_repo()))?);
1528− let loaded = build_repo_value(&loaded_visible, &loaded_visible.wc_change_id, &self.replay_cache)?;
362+ let loaded_visible = block_on(read_visible(&self.current_repo()))?;
363+ let loaded = build_repo_value(&loaded_visible, &loaded_visible.wc_change_id)?;
1529364
1530365 let current = if snapshot {
1531366 // §7.4: snapshot the working directory into the wc commit; the
@@ -1536,19 +371,19 @@ impl JjBackend {
1536371 // no working-copy change: the repo is the one already loaded,
1537372 // so reuse its value instead of rebuilding it
1538373 None => {
1539− *self.inner.visible.lock().unwrap() = Some(loaded_visible.clone());
374+ *self.inner.visible.lock().unwrap() = Some(Arc::new(loaded_visible));
1540375 loaded.clone()
1541376 }
1542377 Some(pending) => {
1543378 *self.inner.pending.lock().unwrap() = Some(pending);
1544379 let vis = block_on(read_visible(&post))?;
1545− let cur = build_repo_value(&vis, &vis.wc_change_id, &self.replay_cache)?;
380+ let cur = build_repo_value(&vis, &vis.wc_change_id)?;
1546381 *self.inner.visible.lock().unwrap() = Some(Arc::new(vis));
1547382 cur
1548383 }
1549384 }
1550385 } else {
1551− *self.inner.visible.lock().unwrap() = Some(loaded_visible.clone());
386+ *self.inner.visible.lock().unwrap() = Some(Arc::new(loaded_visible));
1552387 loaded.clone()
1553388 };
1554389
@@ -1557,59 +392,35 @@ impl JjBackend {
1557392 cfg.shapes.clone(),
1558393 crate::value::Env::empty(),
1559394 );
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− }
1573395 // §7.2: if the focused commit is in the immutable set, the snapshot
1574396 // cannot go into it; the focus is a new empty child of it holding the
1575397 // snapshot, and persisting records that child
1576− match self.focus_if_immutable(&mut interp, cfg, &loaded, &current)? {
1577− Some((loaded, current)) => {
1578− // the snapshot's rewrite of the wc commit (and its rebase of
1579− // the descendants) is not the repository's: lookups answer
1580− // from the stored commits, and persisting leaves the wc
1581− // commit as stored rather than folding the snapshot into it
1582− *self.inner.visible.lock().unwrap() = Some(loaded_visible);
1583− if let Some(p) = self.inner.pending.lock().unwrap().as_mut() {
1584− p.fold_into_wc = false;
1585− }
1586− Ok((interp, loaded, current))
398+ match self.focus_if_immutable(&mut interp, cfg, &current)? {
399+ Some(adjusted) => {
400+ // the persisted child is part of the loaded value too, so a
401+ // run that changes nothing is a no-op
402+ Ok((interp, adjusted.clone(), adjusted))
1587403 }
1588404 None => Ok((interp, loaded, current)),
1589405 }
1590406 }
1591407
1592− /// If the focus of `current` is immutable, return the loaded and current
1593− /// repos refocused on a new empty child of it (§7.2). Both are `loaded`
1594− /// with that one child added: the snapshot goes into neither the focus
1595− /// nor, through a rebase, its descendants. The child holds the focus's
1596− /// stored files in the loaded repo and the snapshot in the current one,
1597− /// so a dirty `j id` differs from the loaded value and records the child
1598− /// (§1.2 step 8).
408+ /// If the focus of `current` is immutable, return a repo refocused on a
409+ /// new empty child holding the focus's files (§7.2).
1599410 fn focus_if_immutable(
1600411 &self,
1601412 interp: &mut Interp,
1602413 cfg: &Config,
1603− loaded: &Value,
1604414 current: &Value,
1605− ) -> Result<Option<(Value, Value)>, OpenError> {
1606− // Evaluate the config to compute the immutable set. This uses the
1607− // interpreter the run will go on to use, rather than a throwaway one,
1608− // so that the `immutable` (and the `trunk` inside it) computed here is
1609− // reused when rendering or persisting asks for the same Repo value —
1610− // each is a full interpreted walk of the history.
1611− crate::config::eval_config(interp, cfg).map_err(|c| (3, c.msg))?;
1612− let immutable = crate::repo::compute_immutable(interp, current)
415+ ) -> Result<Option<Value>, OpenError> {
416+ // evaluate the config to compute the immutable set
417+ let mut probe = Interp::new(
418+ Rc::new(self.clone()),
419+ cfg.shapes.clone(),
420+ crate::value::Env::empty(),
421+ );
422+ crate::config::eval_config(&mut probe, cfg).map_err(|c| (3, c.msg))?;
423+ let immutable = crate::repo::compute_immutable(&mut probe, current)
1613424 .map_err(|c| (1, c.msg))?;
1614425 let focus_id = match current.field("root").and_then(|r| r.field("id")) {
1615426 Ok(Value::Id(i)) => i.to_string(),
@@ -1618,101 +429,90 @@ impl JjBackend {
1618429 if !immutable.contains(&focus_id) {
1619430 return Ok(None);
1620431 }
1621− // the focus's location in `loaded`, pre-snapshot; the child is its
1622− // last child, so every existing child sits to the left of it
1623− let field = |v: &Value, name: &str| v.field(name).map_err(|c| (1, c.msg));
1624− let root = field(loaded, "root")?;
1625− let frame = Value::record(&[
1626− ("left", field(loaded, "children")?),
1627− ("parent", root.clone()),
1628− ("right", Value::list(vec![])),
432+ // build: new empty child of the focus with the focus's files, minted id
433+ let _ = interp;
434+ let root = current.field("root").map_err(|c| (1, c.msg))?;
435+ let files = root.field("files").map_err(|c| (1, c.msg))?;
436+ let child = crate::value::Value::record(&[
437+ ("files", files),
438+ ("message", crate::value::Value::text("")),
439+ ("labels", crate::value::Value::list(vec![])),
440+ (
441+ "id",
442+ crate::value::Value::Id(Rc::new(probe.mint_id())),
443+ ),
1629444 ]);
1630− let mut frames = vec![frame];
1631− frames.extend_from_slice(field(loaded, "context")?.as_list().map_err(|c| (1, c.msg))?);
1632− let context = Value::list(frames);
1633− let id = Value::Id(Rc::new(interp.mint_id()));
1634− let at_child = |files: Value| {
1635− Value::record(&[
1636− ("children", Value::list(vec![])),
1637− ("context", context.clone()),
1638− (
1639− "root",
1640− Value::record(&[
1641− ("files", files),
1642− ("id", id.clone()),
1643− ("labels", Value::list(vec![])),
1644− ("message", Value::text("")),
1645− ]),
1646− ),
1647− ])
1648− };
1649− let current_at_child = at_child(field(&field(current, "root")?, "files")?);
1650− // with nothing snapshotted `current` is `loaded` itself: share one
1651− // value, so a run that changes nothing is a no-op without comparing
1652− // the whole repository
1653− let loaded_at_child = match (loaded, current) {
1654− (Value::Record(a), Value::Record(b)) if Rc::ptr_eq(a, b) => current_at_child.clone(),
1655− _ => at_child(field(&root, "files")?),
445+ let child_subtree = crate::value::Value::record(&[
446+ ("root", child.clone()),
447+ ("children", crate::value::Value::list(vec![])),
448+ ]);
449+ let mut new_children: Vec<Value> = current
450+ .field("children")
451+ .map_err(|c| (1, c.msg))?
452+ .as_list()
453+ .map_err(|c| (1, c.msg))?
454+ .to_vec();
455+ new_children.push(child_subtree);
456+ let parent = crate::value::Value::record(&[
457+ ("children", crate::value::Value::list(new_children)),
458+ ("context", current.field("context").map_err(|c| (1, c.msg))?),
459+ ("root", root),
460+ ]);
461+ // refocus onto the child (it is the last child)
462+ let child_id = match child.field("id").map_err(|c| (1, c.msg))? {
463+ Value::Id(i) => i.to_string(),
464+ _ => unreachable!(),
1656465 };
1657− Ok(Some((loaded_at_child, current_at_child)))
466+ crate::repo::by_id(&parent, &child_id)
467+ .map_err(|c| (1, c.msg))?
468+ .ok_or_else(|| (1, "internal: cannot refocus".to_string()))
469+ .map(Some)
1658470 }
1659471
1660− /// Snapshot the working directory into the working-copy commit (§7.4);
1661− /// return the repo at the snapshot's operation, written but not
1662− /// published, or the repo as loaded when there is nothing to record.
472+ /// Snapshot the working copy into the wc commit; returns the repo at the
473+ /// snapshot operation (unpublished but written), or the pre-snapshot repo
474+ /// if nothing changed.
1663475 async fn snapshot_repo(
1664476 &self,
1665477 ) -> Result<(Arc<ReadonlyRepo>, Option<PendingSnapshot>), OpenError> {
1666478 let mut ws_guard = self.inner.workspace.lock().unwrap();
1667− let root = ws_guard.workspace_root().to_owned();
1668− let saved = SavedState::of(&ws_guard);
479+ let loader = ws_guard.repo_loader().clone();
1669480 let mut locked_ws = ws_guard.start_working_copy_mutation()
1670481 .await
1671482 .map_err(|e| (2, format!("cannot lock the working copy: {}", e)))?;
1672− let old_tree = locked_ws.locked_wc().old_tree().clone();
1673− let scanned = scan(locked_ws.locked_wc(), &root, &old_tree, &saved, &snapshot_options())
483+ let options = SnapshotOptions {
484+ base_ignores: GitIgnoreFile::empty(),
485+ progress: None,
486+ start_tracking_matcher: &EverythingMatcher,
487+ force_tracking_matcher: &NothingMatcher,
488+ max_new_file_size: u64::MAX,
489+ };
490+ let (new_tree, _stats) = locked_ws
491+ .locked_wc()
492+ .snapshot(&options)
1674493 .await
1675494 .map_err(|e| (2, format!("cannot snapshot the working copy: {}", e)))?;
1676− // the repo build_interp loaded at head from the workspace's own
1677− // loader, whose store the scanned tree lives in
1678− let head = self.current_repo();
1679− let wc_commit = self.wc_commit(&head).await?;
1680− let current = locked_ws.locked_wc().old_operation_id() == head.operation().id();
1681− let tree = match marker_style(head.settings()) {
1682− Ok(markers) => snapshot_tree(&root, markers, &wc_commit.tree(), &old_tree, current, &scanned).await,
1683− Err(e) => Err(e),
1684− }
1685− .map_err(|e| (2, format!("cannot snapshot the working copy: {}", e)))?;
1686− if tree.tree_ids() == wc_commit.tree().tree_ids() {
1687− // nothing to record: the directory holds the commit's files, or,
1688− // where the state is stale, the state's, or changes to them the
1689− // commit already has, such as what a checkout cut short wrote.
1690− // What was scanned is saved, at the operation the state is
1691− // stale since where it still is.
1692− save_scan(locked_ws, &scanned, &wc_commit.tree(), head.operation().id())
495+ let changed = new_tree.tree_ids() != locked_ws.locked_wc().old_tree().tree_ids();
496+ if !changed {
497+ locked_ws
498+ .finish(self.current_repo().operation().id().clone())
1693499 .await
1694500 .map_err(|e| (2, format!("cannot finish the snapshot: {}", e)))?;
1695− return Ok((head, None));
501+ return Ok((self.current_repo(), None));
1696502 }
1697− // a directory come to hold the parent's files, as where it reverts
1698− // the commit's change, is recorded with the parent's tree (§7.4), as
1699− // persistence writes a commit with its parent's files (§7.5 step 4):
1700− // the snapshot builds on the commit's tree, and so could make
1701− // another tree for the parent's files, one jj takes for a change.
1702− // Not before the check above, or a commit already holding such a
1703− // tree would be rewritten by a directory that has not changed. A
1704− // merge is immutable, so never folded into (§7.2), and its parents'
1705− // trees are not merged for it
1706− let tree = match wc_commit.parent_ids() {
1707− [_] => parents_tree_if_same(head.as_ref(), wc_commit.parent_ids(), tree).await,
1708− _ => tree,
1709− };
1710− let mut tx = head.start_transaction();
503+ // the snapshotted tree lives in the workspace's store; drive the
504+ // transaction from the workspace's repo loader so stores match
505+ let ws_repo = loader
506+ .load_at_head()
507+ .await
508+ .map_err(|e| (2, format!("cannot reload the repository: {}", e)))?;
509+ let mut tx = ws_repo.start_transaction();
1711510 tx.set_is_snapshot(true);
511+ let wc_commit = self.wc_commit(&ws_repo).await?;
1712512 let new_wc = tx
1713513 .repo_mut()
1714514 .rewrite_commit(&wc_commit)
1715− .set_tree(tree)
515+ .set_tree(new_tree)
1716516 .write()
1717517 .await
1718518 .map_err(|e| (2, format!("cannot write the snapshot commit: {}", e)))?;
@@ -1729,26 +529,25 @@ impl JjBackend {
1729529 .write("snapshot working copy")
1730530 .await
1731531 .map_err(|e| (2, format!("cannot write the snapshot operation: {}", e)))?;
532+ let op_id = unpublished.operation().id().clone();
1732533 let new_repo = unpublished.leave_unpublished();
1733− // Release the lock *without* finishing: `finish` saves the scanned
1734− // tree state to disk, which would record the working directory as
1735− // already snapshotted while the published head still has the old wc
1736− // tree. The next run's incremental snapshot would then find nothing
1737− // to do and build the Repo from the stale tree — and a persisting run
1738− // would check that stale tree back out, destroying the edits. The
1739− // state is advanced only by `persist`'s checkout, which first scans
1740− // the directory again and finishes against the operation that
1741− // actually recorded the tree (§7.4/§7.7).
1742− drop(locked_ws);
534+ // the working-copy state tracks the snapshot tree, against the
535+ // current (published) head; persist will finish against its own op
536+ locked_ws
537+ .finish(self.current_repo().operation().id().clone())
538+ .await
539+ .map_err(|e| (2, format!("cannot finish the snapshot: {}", e)))?;
540+ let _ = op_id;
1743541 let pending = PendingSnapshot {
1744− pre_repo: head,
542+ pre_repo: self.current_repo(),
543+ new_wc_id: new_wc.id().clone(),
1745544 tree: new_wc.tree().clone(),
1746− fold_into_wc: true,
1747− scanned,
1748545 };
1749546 Ok((new_repo, Some(pending)))
1750547 }
1751548
549+
550+
1752551 async fn wc_commit(&self, repo: &Arc<ReadonlyRepo>) -> Result<Commit, OpenError> {
1753552 let id = repo
1754553 .view()
@@ -1766,13 +565,12 @@ impl JjBackend {
1766565 interp: &mut Interp,
1767566 cfg: &Config,
1768567 loaded: &Value,
1769− current: &Value,
568+ _current: &Value,
1770569 new: &Value,
1771570 text: &str,
1772− ) -> Result<bool, Crash> {
1773− // §1.2 step 8: unchanged, up to the order of snapshot entries (§7.3)
1774− if crate::repo::same_repo(loaded, new)? {
1775− return Ok(false);
571+ ) -> Result<(), Crash> {
572+ if crate::value::value_eq(loaded, new)? {
573+ return Ok(());
1776574 }
1777575 *interp.old_repo.borrow_mut() = Some(loaded.clone());
1778576 crate::repo::validate_repo(interp, new)?;
@@ -1792,7 +590,7 @@ impl JjBackend {
1792590 // walk below compares against its output, so the snapshot change and
1793591 // the expression's edits become one rewrite
1794592 let mut snapshot_wc: Option<Commit> = None;
1795− if let Some(p) = pending.as_ref().filter(|p| p.fold_into_wc) {
593+ if let Some(p) = &pending {
1796594 let wc_commit = block_on(self.wc_commit(&base)).map_err(|e| Crash::new(e.1))?;
1797595 let c = block_on(
1798596 tx.repo_mut()
@@ -1816,9 +614,6 @@ impl JjBackend {
1816614 old_stored = Arc::new(vis);
1817615 }
1818616 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])?;
1822617 // shared across the whole persist walk so unchanged blobs are inflated once
1823618 let cache = BlobCache::default();
1824619 let entries = EntryCache::default();
@@ -1842,35 +637,33 @@ impl JjBackend {
1842637 }
1843638 let stored_files = block_on(tree_to_files(&store, &root_jj.tree(), &cache, &entries))
1844639 .map_err(|e| Crash::new(e.msg))?;
1845− if !crate::repo::snapshot_eq(&Value::list(stored_files), &root_v.field("files")?)? {
640+ if !crate::value::value_eq(
641+ &Value::list(stored_files),
642+ &Value::list(root_v.field("files")?.as_list()?.to_vec()),
643+ )? {
1846644 return Err(Crash::new("persistence: the root commit cannot be changed"));
1847645 }
1848646 }
1849647 written.insert(ROOT_ID.to_string(), root_jj.id().clone());
1850648
1851649 // 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)> =
1855− std::collections::VecDeque::new(); // (subtree value, parent change id)
650+ let mut queue: Vec<(Value, String)> = Vec::new(); // (subtree value, parent change id)
1856651 for kid in top.field("children")?.as_list()?.iter() {
1857− queue.push_back((kid.clone(), ROOT_ID.to_string()));
652+ queue.push((kid.clone(), ROOT_ID.to_string()));
1858653 }
1859− while let Some((subtree, parent_change)) = queue.pop_front() {
654+ while !queue.is_empty() {
655+ let (subtree, parent_change) = queue.remove(0);
1860656 let commit_v = subtree.field("root")?;
1861657 let id = commit_id_of(&commit_v)?;
1862658 let parent_jj = written
1863659 .get(&parent_change)
1864660 .cloned()
1865661 .ok_or_else(|| Crash::new("persistence: internal: parent not yet written"))?;
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)?;
662+ let files_v = commit_v.field("files")?;
1869663 let message = commit_v.field("message")?.as_text()?.to_string();
1870664 let new_id = match old_stored.commits.get(&id) {
1871665 None => {
1872− let tree =
1873− loaded_trees.tree_to_write(tx.repo(), &files_v, std::slice::from_ref(&parent_jj))?;
666+ let tree = block_on(build_tree(&store, &files_v))?;
1874667 let change_id = jj_lib::backend::ChangeId::try_from_reverse_hex(&id).ok_or_else(|| {
1875668 Crash::new(format!("persistence: `{}` is not a valid change id", id))
1876669 })?;
@@ -1889,31 +682,17 @@ impl JjBackend {
1889682 Some(stored) => {
1890683 let stored_commit = &stored.commit;
1891684 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
1896− let files_changed = crate::repo::stored_tree(&files_v)
1897− != Some(tree_name(&stored_tree).as_str())
1898− && {
1899− let stored_files =
1900− block_on(tree_to_files(&store, &stored_tree, &cache, &entries))?;
1901− // a tree has no order: entries listed differently
1902− // are not a change to rewrite (§7.3)
1903− !crate::repo::snapshot_eq(&Value::list(stored_files), &files_v)?
1904− };
685+ let files_changed = {
686+ let stored_files =
687+ block_on(tree_to_files(&store, &stored_commit.tree(), &cache, &entries))?;
688+ !crate::value::value_eq(
689+ &Value::list(stored_files),
690+ &Value::list(files_v.as_list()?.to_vec()),
691+ )?
692+ };
1905693 let msg_changed = stored_commit.description() != message;
1906694 if parent_changed || files_changed || msg_changed {
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− };
695+ let tree = block_on(build_tree(&store, &files_v))?;
1917696 let c = block_on(
1918697 tx.repo_mut()
1919698 .rewrite_commit(stored_commit)
@@ -1932,7 +711,7 @@ impl JjBackend {
1932711 };
1933712 written.insert(id.clone(), new_id);
1934713 for kid in subtree.field("children")?.as_list()?.iter() {
1935− queue.push_back((kid.clone(), id.clone()));
714+ queue.push((kid.clone(), id.clone()));
1936715 }
1937716 }
1938717
@@ -1976,230 +755,37 @@ impl JjBackend {
1976755 let desc = truncate_chars(text, 200);
1977756 let unpublished = block_on(tx.write(desc))
1978757 .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)))?;
1979761
1980− // §7.4/§7.5 step 7: publish the operation, then check the focus out
762+ // §7.4/§7.5 step 7: check out the focus to the working directory
1981763 let focus_commit = block_on(store.get_commit_async(&focus_jj))
1982764 .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− };
2000− block_on(self.checkout(&focus_commit, Some(&on_disk), Some(unpublished), Some(", and `j undo` goes back")))?;
2001− Ok(true)
765+ block_on(self.checkout(&focus_commit, op_id))?;
766+
767+ Ok(())
2002768 }
2003769
2004− /// Check `commit` out: write its files to the working directory and
2005− /// save the working copy's state as holding them (§7.4). `op`, the
2006− /// operation that makes it the working-copy commit, is published first,
2007− /// as jj does (§7.5 step 7); with none, `commit` is the head's own.
2008− /// `on_disk` is the tree the directory holds, which the checkout starts
2009− /// from: what a persisting run's snapshot scanned, or the tree the
2010− /// working-copy state records where it scanned nothing to record, or
2011− /// what `undo`'s look found; with none, the state as it is. A change
2012− /// made to the directory since `on_disk` was scanned, where the
2013− /// checkout writes, refuses the checkout before `op` is published
2014− /// (`written_over`); one made once it has begun is written over, as by
2015− /// jj. Where the commit adds a file, something `on_disk` does not track
2016− /// standing there, but for empty directories and what the checkout
2017− /// writes, refuses the checkout before `op` is published
2018− /// (`look_where_added`), and one put there since fails it after, as it
2019− /// makes jj skip the path. A checkout that fails, or is cut short, after
2020− /// `op` is published leaves `op` recorded and the state stale, recording
2021− /// `on_disk`: the next run takes what the checkout wrote for the
2022− /// commit's own and carries on (`snapshot_tree`), as the crash says,
2023− /// with `back` naming the way back; with no `back` it says nothing of
2024− /// the operation (a clone that fails removes it).
2025− async fn checkout(
2026− &self,
2027− commit: &Commit,
2028− on_disk: Option<&MergedTree>,
2029− op: Option<UnpublishedOperation>,
2030− back: Option<&str>,
2031− ) -> Result<(), Crash> {
770+ async fn checkout(&self, commit: &Commit, op_id: OperationId) -> Result<(), Crash> {
2032771 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);
2035772 let mut locked_ws = ws_guard.start_working_copy_mutation()
2036773 .await
2037774 .map_err(|e| Crash::new(format!("cannot lock the working copy: {}", e)))?;
2038− // A persisting run's snapshot released its lock without saving what
2039− // it scanned, so the state just loaded may still describe the tree
2040− // recorded before the edits, and jj checks out by diffing that tree
2041− // against the new one: a path whose recorded content equals the
2042− // focus's would keep the user's edit, a file created since would
2043− // stay, and a file replaced by a directory (or back) would leave
2044− // state contradicting the tree. Scan the directory again first, as
2045− // the snapshot did, so the checkout diffs from what is actually on
2046− // disk (§7.4). A scan keeps what the state knows of each file where
2047− // a reset to the snapshot's tree would forget it: a conflict whose
2048− // sides hold marker-like lines is materialized with longer markers,
2049− // and a forgotten length makes the next snapshot read those markers
2050− // back at 7 characters, fail to parse them and record the edited
2051− // conflict as resolved text, markers and all (§7.4).
2052− if let Some(on_disk) = on_disk {
2053− let old_tree = locked_ws.locked_wc().old_tree().clone();
2054− let reset = match scan(locked_ws.locked_wc(), &root, &old_tree, &saved, &snapshot_options()).await {
2055− Ok(scanned) if scanned.tree_ids() == on_disk.tree_ids() => false,
2056− // the directory changed while the program ran: where the
2057− // checkout would write the focus over such a change, which
2058− // no snapshot holds and jj's checkout does not look for,
2059− // the run crashes before anything is recorded or written
2060− // (§7.5 step 7)
2061− Ok(scanned) => match written_over(on_disk, &scanned, &commit.tree()).await {
2062− Ok(None) => true,
2063− Ok(Some(path)) => {
2064− return Err(Crash::new(format!(
2065− "cannot check out the focus: `{}` changed in the working directory while the program ran, and the checkout would write over it; nothing was recorded, so run the command again",
2066− path.as_internal_file_string()
2067− )));
2068− }
2069− Err(e) => return Err(Crash::new(format!("cannot check out the focus: {}", e))),
2070− },
2071− // or it can no longer be scanned (an entry appeared,
2072− // vanished or cannot be read; a failed scan leaves the state
2073− // as it was)
2074− Err(_) => true,
2075− };
2076− // Otherwise the state describes `on_disk` instead. A reset
2077− // writes no file; the paths it changes are re-read by the next
2078− // run.
2079− if reset {
2080− locked_ws
2081− .locked_wc()
2082− .reset(&with_tree(commit, on_disk.clone()))
2083− .await
2084− .map_err(|e| Crash::new(format!("cannot reset the working copy: {}", e)))?;
2085− }
2086− }
2087− // Where the focus adds a file and something the directory does not
2088− // track stands (an ignored file, a symlink, a directory holding
2089− // one), jj's checkout would skip the path, and the next run read
2090− // what stands there into the focus: refuse before anything is
2091− // recorded or written, as git does (§7.5 step 7). The trees of
2092− // empty directories found there, and the files holding what the
2093− // checkout writes, are cleared once `op` is published.
2094− let markers = marker_style(self.current_repo().settings())
775+ locked_ws
776+ .locked_wc()
777+ .check_out(commit)
778+ .await
2095779 .map_err(|e| Crash::new(format!("cannot check out the focus: {}", e)))?;
2096− let clear = match on_disk {
2097− None => Vec::new(),
2098− Some(on_disk) => match look_where_added(&root, markers, on_disk, &commit.tree()).await {
2099− Ok(Ok(clear)) => clear,
2100− Ok(Err(in_the_way)) => {
2101− return Err(Crash::new(format!("cannot check out the focus: {}", in_the_way_of(&in_the_way))));
2102− }
2103− Err(e) => return Err(Crash::new(format!("cannot check out the focus: {}", e))),
2104− },
2105− };
2106− // from here until the state is saved, a signal to stop waits
2107− let _deferral = Deferral::start();
2108− let published = op.is_some();
2109− let op_id = match op {
2110− None => self.current_repo().operation().id().clone(),
2111− Some(op) => {
2112− let repo = op
2113− .publish()
2114− .await
2115− .map_err(|e| Crash::new(format!("cannot publish the operation: {}", e)))?;
2116− let op_id = repo.operation().id().clone();
2117− *self.inner.repo.lock().unwrap() = repo;
2118− op_id
2119− }
2120− };
2121− // what a crash from here on says: the state is left stale, which the
2122− // next run carries on from (§7.5 step 7)
2123− let carry_on = |what: &str| match back {
2124− None => String::new(),
2125− Some(back) if published => {
2126− format!("; the operation is recorded, but {}: the next command carries on from there{}", what, back)
2127− }
2128− Some(_) => format!("; {}: the next command carries on from there", what),
2129− };
2130− // A checkout cut short leaves the directory holding `on_disk` but
2131− // where it wrote, and the next run takes what it holds for changes
2132− // to the state (`snapshot_tree`). So the state records `on_disk`
2133− // first, at the operation it was saved at, which leaves it stale
2134− // until the checkout completes: were it left recording the tree
2135− // before the run's snapshot, the snapshot's changes, now in the
2136− // operation, would be taken for new ones and replayed onto the
2137− // focus too. It is saved only once `op` is published: a state
2138− // recording the snapshot, beside a head that does not, would lose
2139− // it (§7.4).
2140− if on_disk.is_some_and(|t| t.tree_ids() != locked_ws.locked_wc().old_tree().tree_ids()) {
2141− let old_op = locked_ws.locked_wc().old_operation_id().clone();
2142− let saved = match locked_ws.finish(old_op).await {
2143− Ok(()) => ws_guard.start_working_copy_mutation().await.map_err(|e| e.to_string()),
2144− Err(e) => Err(e.to_string()),
2145− };
2146− locked_ws = match saved {
2147− Ok(locked) => locked,
2148− Err(e) => {
2149− let what = "the working directory was not updated";
2150− return Err(Crash::new(format!("cannot save the working copy's state: {}{}", e, carry_on(what))));
2151− }
2152− };
2153− }
2154− // what is moved aside is put back where the checkout does not write,
2155− // or cuts the write short, once it returns, however it returns
2156− let mut aside = clear_the_way(&root, markers, &commit.tree(), &clear).await;
2157− let from = locked_ws.locked_wc().old_tree().clone();
2158− let what = "the working directory was only partly updated";
2159− let stats = match locked_ws.locked_wc().check_out(commit).await {
2160− Ok(stats) => stats,
2161− Err(e) => return Err(Crash::new(format!("cannot check out the focus: {}{}", e, carry_on(what)))),
2162− };
2163− // it cut no write short, so a start of a file moved aside, standing
2164− // at its path, was put there meanwhile
2165− aside.returned = true;
2166− // Something the directory does not track, put where the focus adds a
2167− // file since it was looked at, or a file holding what the checkout
2168− // writes there that could not be moved aside, made jj skip the
2169− // path, which it records as written. The state is left stale, as by
2170− // a checkout that fails, so that no run reads what stands there
2171− // (§7.5 step 7).
2172− if stats.skipped_files > 0 {
2173− let skipped = first_skipped(&root, markers, &from, &commit.tree()).await.ok().flatten();
2174− let why = match (skipped, &aside.stuck) {
2175− (Some(path), _) => format!(
2176− "`{}` was not written, as something untracked in the working directory was in its way (move it aside)",
2177− path.as_internal_file_string()
2178− ),
2179− (None, Some((path, e))) => format!(
2180− "`{}` was not written, as the untracked file there, holding what it writes, could not be moved out of its way: {}",
2181− path.as_internal_file_string(),
2182− e
2183− ),
2184− (None, None) => format!(
2185− "{} of its files were not written, as something untracked in the working directory was in their way",
2186− stats.skipped_files
2187− ),
2188− };
2189− return Err(Crash::new(format!("cannot check out the focus: {}{}", why, carry_on(what))));
2190− }
2191− // the state names the operation it was checked out at, so it is
2192− // saved only once that operation is published
2193− if let Err(e) = locked_ws.finish(op_id).await {
2194− let what = "the working copy's record of what the directory holds was not updated";
2195− return Err(Crash::new(format!("cannot save the working copy's state: {}{}", e, carry_on(what))));
2196− }
780+ locked_ws
781+ .finish(op_id)
782+ .await
783+ .map_err(|e| Crash::new(format!("cannot finish the checkout: {}", e)))?;
2197784 Ok(())
2198785 }
2199786
2200787 fn user_signature(&self, cfg: &Config) -> Result<Signature, Crash> {
2201− let (name, email) = validated_identity(cfg)
2202− .map_err(|(_, message)| Crash::new(message))?;
788+ let (name, email) = crate::config::eval_user_only(&mut Interp::dummy(), cfg)?;
2203789 Ok(Signature {
2204790 name,
2205791 email,
@@ -2292,7 +878,6 @@ impl JjBackend {
2292878 }
2293879
2294880 pub fn cmd_remote(&self, url: &str) -> Result<(), OpenError> {
2295− let url = &origin_url(url)?;
2296881 self.take_lock();
2297882 let base = self.current_repo();
2298883 let origin = RemoteName::new("origin");
@@ -2310,17 +895,7 @@ impl JjBackend {
2310895 .map_err(|e| (1, format!("cannot set the remote: {}", e)))
2311896 }
2312897
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−
2322898 pub fn cmd_fetch(&self) -> Result<(), OpenError> {
2323− self.enter_workspace_root()?;
2324899 let origin = RemoteName::new("origin");
2325900 let base = self.head_repo()?;
2326901 let mut tx = base.start_transaction();
@@ -2333,52 +908,27 @@ impl JjBackend {
2333908 let _ = block_on(self.publish_tx(tx, "fetch"))?;
2334909 Ok(())
2335910 }
2336− pub fn cmd_push(&self, cfg: &mut Config, expr_text: &str) -> Result<PushOutcome, OpenError> {
2337− self.enter_workspace_root()?;
911+ pub fn cmd_push(&self, cfg: &Config, expr_text: &str) -> Result<(), OpenError> {
2338912 let origin = RemoteName::new("origin");
2339913 let base = self.head_repo()?;
2340− let (value, vis, immutable) = self.eval_push_expr(cfg, expr_text, &base)?;
914+ let (value, vis) = self.eval_push_expr(cfg, expr_text, &base)?;
2341915 let records = parse_push_records(&value, &vis, &base)?;
2342− if records.is_empty() {
2343− return Ok(PushOutcome::default());
2344− }
2345− let deletes_wait = check_push_records(&records, &base, &vis, &immutable)?;
2346− let mut outcome = PushOutcome::default();
2347− for (name, target) in &records {
2348− let before = base.view()
2349− .get_remote_bookmark(RefName::new(name).to_remote_symbol(origin))
2350− .target.as_resolved().cloned().unwrap_or(None);
2351− if &before == target {
2352− outcome.unchanged += 1;
2353− } else if target.is_none() {
2354− outcome.deleted += 1;
2355− } else {
2356− outcome.published += 1;
2357− }
2358− }
2359− let (_, clean) = self.inspect_working_copy(&base, false)?;
2360− if !clean {
2361− crate::terminal::report(format_args!(
2362− "j: warning: working copy has unrecorded changes; push sends recorded content only, so these edits will not be sent. Run `j id` to record them, then push again to include them."
2363− ));
2364− }
2365− let description = format!("push {}", expr_text);
2366− let _ = self.push_to_origin(&base, origin, &records, deletes_wait, &description)?;
2367− Ok(outcome)
916+ check_push_records(&records, &vis, &base)?;
917+ let _ = self.push_to_origin(&base, origin, &records, expr_text)?;
918+ Ok(())
2368919 }
2369920
2370921 /// evaluate `EXPR` against the recorded repository, without snapshotting
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)
922+ /// (§1.1), apply a function result to the repo value once (§7.6)
2373923 fn eval_push_expr(
2374924 &self,
2375− cfg: &mut Config,
925+ cfg: &Config,
2376926 expr_text: &str,
2377927 base: &Arc<ReadonlyRepo>,
2378− ) -> Result<(Value, Arc<VisibleRepo>, BTreeSet<String>), OpenError> {
928+ ) -> Result<(Value, Arc<VisibleRepo>), OpenError> {
2379929 let outer = Rc::new(cfg.global_names.clone());
2380930 let expr = crate::parse::parse_expr(expr_text, outer)
2381− .map_err(|p| (3, crate::diagnostics::format_source_error("expression", expr_text, p.line, &p.msg)))?;
931+ .map_err(|p| (3, format!("line {}: {}", p.line, p.msg)))?;
2382932 let (mut interp, _loaded, current) = self.build_interp(cfg, expr_text, false)?;
2383933 let expr = crate::config::resolve_ids(&expr, &interp).map_err(|failures| {
2384934 let (prefix, candidates) = &failures[0];
@@ -2389,30 +939,15 @@ impl JjBackend {
2389939 }
2390940 })?;
2391941 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− };
2407942 let env = interp.global_env();
2408943 let expr_rc = Rc::new(expr);
2409944 let mut v = interp
2410945 .eval(&expr_rc, &env)
2411− .map_err(&expression_error)?;
946+ .map_err(|c| (1, format!("crash: {}", c.msg)))?;
2412947 if matches!(v, Value::Fun(_)) {
2413948 v = interp
2414949 .apply(v, current)
2415− .map_err(expression_error)?;
950+ .map_err(|c| (1, format!("crash: {}", c.msg)))?;
2416951 }
2417952 let _ = base;
2418953 let vis = self
@@ -2422,28 +957,19 @@ impl JjBackend {
2422957 .unwrap()
2423958 .clone()
2424959 .ok_or_else(|| (2, "internal: repository value not loaded".to_string()))?;
2425− Ok((v, vis, immutable))
960+ Ok((v, vis))
2426961 }
2427962
2428963 /// push the updates to `origin` and record the new remote-bookmark
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
964+ /// positions as one operation (§7.6)
2432965 fn push_to_origin(
2433966 &self,
2434967 base: &Arc<ReadonlyRepo>,
2435968 origin: &RemoteName,
2436969 records: &[(String, Option<CommitId>)],
2437− deletes_wait: bool,
2438970 description: &str,
2439971 ) -> 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− }
2445972 let mut targets = jj_lib::git::GitPushRefTargets::default();
2446− let mut deletes = jj_lib::git::GitPushRefTargets::default();
2447973 for (name, after) in records {
2448974 let before = base
2449975 .view()
@@ -2452,116 +978,48 @@ impl JjBackend {
2452978 .as_resolved()
2453979 .cloned()
2454980 .unwrap_or(None);
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
981+ targets
2459982 .bookmarks
2460983 .push((jj_lib::ref_name::RefNameBuf::from(name.clone()), Diff::new(before, after.clone())));
2461984 }
2462985 let mut tx = base.start_transaction();
2463986 let subprocess_options = self.git_subprocess_options()?;
2464987 let mut callback = QuietGitCallback;
2465− let mut push = |tx: &mut jj_lib::transaction::Transaction, targets| {
2466− jj_lib::git::push_refs(
2467− tx.repo_mut(),
2468− subprocess_options.clone(),
2469− origin,
2470− targets,
2471− &mut callback,
2472− &jj_lib::git::GitPushOptions::default(),
2473− )
2474− .map_err(|e| match e {
2475− jj_lib::git::GitPushError::NoSuchRemote(_) => (1, "no remote origin".to_string()),
2476− other => (1, format!("push failed: {}", other)),
2477− })
2478− };
2479− let mut stats = push(&mut tx, &targets)?;
2480− // git push is not atomic: the remote may accept some updates and
2481− // reject others. When only a bookmark this push sets keeps a deleted
2482− // bookmark's immutable commit reachable, the deletes go in a second
2483− // push once the remote has accepted every update, and are not sent
2484− // otherwise (§7.6)
2485− let mut not_sent: Vec<String> = Vec::new();
2486− let mut failed = None;
2487− if !deletes.bookmarks.is_empty() {
2488− if stats.all_ok() {
2489− match push(&mut tx, &deletes) {
2490− Ok(more) => {
2491− stats.pushed.extend(more.pushed);
2492− stats.rejected.extend(more.rejected);
2493− stats.remote_rejected.extend(more.remote_rejected);
2494− stats.unexported_bookmarks.extend(more.unexported_bookmarks);
2495− }
2496− Err(e) => failed = Some(e),
2497− }
2498− } else {
2499− for (name, _) in &deletes.bookmarks {
2500− not_sent.push(format!("refs/heads/{}", name.as_str()));
2501− }
2502− }
2503− }
2504− // push_refs has set the accepted updates in `tx`, and they are on the
2505− // remote, so they are recorded before the rest is reported; a push
2506− // the remote refused entirely records nothing
2507− let mut repo = base.clone();
2508− if stats.all_ok() || stats.some_exported() {
2509− let unpublished = block_on(tx.write(truncate_chars(description, 200)))
2510− .map_err(|e| (1, format!("cannot write the operation: {}", e)))?;
2511− repo = block_on(unpublished.publish())
2512− .map_err(|e| (1, format!("cannot publish the operation: {}", e)))?;
2513− *self.inner.repo.lock().unwrap() = repo.clone();
2514− }
2515− if let Some((code, msg)) = failed {
2516− return Err((code, format!("{}; the other bookmarks were pushed and recorded", msg)));
2517− }
988+ let stats = jj_lib::git::push_refs(
989+ tx.repo_mut(),
990+ subprocess_options,
991+ origin,
992+ &targets,
993+ &mut callback,
994+ &jj_lib::git::GitPushOptions::default(),
995+ )
996+ .map_err(|e| match e {
997+ jj_lib::git::GitPushError::NoSuchRemote(_) => (1, "no remote origin".to_string()),
998+ other => (1, format!("push failed: {}", other)),
999+ })?;
25181000 if !stats.all_ok() {
25191001 let mut names: Vec<String> = Vec::new();
25201002 for (name, _) in stats.rejected.iter().chain(stats.remote_rejected.iter()) {
25211003 names.push(name.as_str().to_string());
25221004 }
2523− // pushed, but not exported to the colocated git repo, so jj-lib
2524− // left them out of `tx`
2525− for (symbol, _) in &stats.unexported_bookmarks {
2526− names.push(format!("{} (pushed but not recorded)", symbol.name.as_str()));
2527− }
2528− let mut msg = format!("push rejected: {}", names.join(", "));
2529− if !not_sent.is_empty() {
2530− msg.push_str(&format!("; deletes not sent: {}", not_sent.join(", ")));
2531− }
2532− if stats.some_exported() {
2533− msg.push_str("; the other bookmarks were pushed and recorded");
2534− }
2535− return Err((1, msg));
1005+ return Err((1, format!("push rejected: {}", names.join(", "))));
25361006 }
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();
25371012 Ok(repo)
25381013 }
25391014
2540− pub fn cmd_undo(&self, redo: bool) -> Result<String, OpenError> {
1015+ pub fn cmd_undo(&self, redo: bool) -> Result<(), OpenError> {
25411016 let base = self.head_repo()?;
2542− let on_disk = self.check_wc_clean(&base)?;
1017+ self.check_wc_clean(&base)?;
25431018 let loader = self.repo_loader();
25441019 let mut cur = base.operation().clone();
25451020 let target_op_id: OperationId;
25461021 loop {
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) {
1022+ match (op_marker(cur.metadata()), redo) {
25651023 (None, false) => {
25661024 let Some(parent) = cur.parent_ids().first().cloned() else {
25671025 return Err((1, "nothing to undo".to_string()));
@@ -2587,25 +1045,11 @@ impl JjBackend {
25871045 .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
25881046 }
25891047 (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.
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− };
1048+ target_op_id = m.id;
26001049 break;
26011050 }
26021051 (Some(m), false) => {
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.
1052+ // a redo: move past the undo it reversed
26091053 if !m.authored_by_j {
26101054 let Some(parent) = cur.parent_ids().first().cloned() else {
26111055 return Err((1, "nothing to undo".to_string()));
@@ -2613,57 +1057,23 @@ impl JjBackend {
26131057 target_op_id = parent;
26141058 break;
26151059 }
2616− let undo_op = block_on(loader.load_operation(&m.id))
1060+ cur = block_on(loader.load_operation(&m.id))
26171061 .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− };
26261062 }
26271063 (Some(m), true) => {
2628− // a redo: the undo it reversed is already accounted for,
2629− // so keep looking outward from that undo's parent for an
2630− // undo that has not been reversed yet (§7.7: the head must
2631− // be an undo, "possibly reached through redos").
2632− if !m.authored_by_j {
2633− target_op_id = m.id;
2634− break;
2635− }
2636− let Some(prev) = op_parent_id(&loader, &m.id)? else {
2637− return Err((1, "nothing to redo".to_string()));
1064+ // a redo: reverse the undo it reversed, following further
1065+ // redos to the operation ultimately reapplied
1066+ target_op_id = if m.authored_by_j {
1067+ block_on(chain_through_redos(&loader, &m.id))?
1068+ } else {
1069+ m.id
26381070 };
2639− cur = block_on(loader.load_operation(&prev))
2640− .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
1071+ break;
26411072 }
26421073 }
26431074 }
2644− // before the operation init or clone recorded there was no
2645− // repository (§7.7); the view jj recorded then has an empty child of
2646− // the root as the working-copy commit, and checking it out would
2647− // delete every file of the head
2648− if !redo && op_created_repo(cur.metadata()) {
2649− return Err((1, "nothing to undo".to_string()));
2650− }
2651− let mut target_op = block_on(loader.load_operation(&target_op_id))
1075+ let target_op = block_on(loader.load_operation(&target_op_id))
26521076 .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);
26671077 let target_view = block_on(
26681078 loader
26691079 .op_store()
@@ -2687,94 +1097,56 @@ impl JjBackend {
26871097 },
26881098 ));
26891099 }
2690− // Use jj's current marker so either CLI can continue this history.
2691− // The older j and jj marker formats remain readable above.
2692− let marker = format!(
2693− "{}: restore to operation {}",
2694− if redo { "redo" } else { "undo" },
2695− target_op_id.hex()
2696− );
1100+ let marker = if redo {
1101+ format!("redo: {}", cur.id().hex())
1102+ } else {
1103+ format!("undo: {}", cur.id().hex())
1104+ };
26971105 let mut tx = base.start_transaction();
26981106 tx.repo_mut().set_view(target_view.clone());
26991107 let unpublished = block_on(tx.write(&marker))
27001108 .map_err(|e| (1, format!("cannot write the operation: {}", e)))?;
2701− // the operation is published, then the restored focus checked out
2702− // from what the directory holds (§7.7)
2703− let wc_id = &target_view.wc_commit_ids[WorkspaceName::DEFAULT];
2704− let wc_commit = block_on(base.store().get_commit_async(wc_id))
2705− .map_err(|e| (2, format!("cannot read the working-copy commit: {}", e)))?;
2706− let back = if redo { ", and `j undo` goes back" } else { ", and `j redo` goes back" };
2707− block_on(self.checkout(&wc_commit, Some(&on_disk), Some(unpublished), Some(back)))
2708− .map_err(|c| (1, c.msg))?;
2709− let focus_message = wc_commit.description().lines().next().unwrap_or("").trim();
2710− Ok(format!(
2711− "{} `{}`; restored focus @{} {}",
2712− if redo { "Redid" } else { "Undid" },
2713− affected_description,
2714− wc_commit.change_id().reverse_hex(),
2715− terminal_text(if focus_message.is_empty() { "(no description)" } else { focus_message }),
2716− ))
2717− }
2718−
2719− /// Refuse, as `undo` and `redo` do before anything else (§7.7), when the
2720− /// working directory holds changes the working-copy commit does not:
2721− /// what a snapshot would record (§7.4), which their checkout would write
2722− /// over. Otherwise return the tree the directory holds, as scanned,
2723− /// which that checkout starts from: the commit's, but for the text
2724− /// written for a conflict the state does not record, as one lost and
2725− /// made again records none, or, where the working-copy state is stale,
2726− /// the state's with any changes to it the commit already has, such as
2727− /// what a checkout cut short wrote. Reserved commands never snapshot,
2728− /// so nothing is recorded either way.
2729− fn check_wc_clean(&self, base: &Arc<ReadonlyRepo>) -> Result<MergedTree, OpenError> {
2730− let (scanned, clean) = self.inspect_working_copy(base, true)?;
2731− if !clean {
2732− return Err((1, "working copy has changes not in @; run `j id` to record them or discard them. Recording creates a new operation: the next `j undo` undoes that recording, not the operation you originally wanted to undo; a new recording also clears pending redos. Use `j ops` to inspect the history".to_string()));
2733− }
2734− Ok(scanned)
1109+ let op_id = unpublished.operation().id().clone();
1110+ let repo = block_on(unpublished.publish())
1111+ .map_err(|e| (1, format!("cannot publish the operation: {}", e)))?;
1112+ *self.inner.repo.lock().unwrap() = repo.clone();
1113+ let wc_commit = block_on(self.wc_commit(&repo))?;
1114+ block_on(self.checkout(&wc_commit, op_id)).map_err(|c| (1, c.msg))?;
1115+ Ok(())
27351116 }
27361117
2737− /// Compare a scan with the recorded focus without publishing a snapshot.
2738− /// Push only inspects: it must leave the saved working-copy state alone
2739− /// so a later `j id` still sees every unrecorded edit.
2740− fn inspect_working_copy(
2741− &self,
2742− base: &Arc<ReadonlyRepo>,
2743− refresh_clean_state: bool,
2744− ) -> Result<(MergedTree, bool), OpenError> {
2745− let wc_tree = block_on(self.wc_commit(base))?.tree();
1118+ /// refuse if the working directory differs from the focused commit's
1119+ /// files (§7.7): reserved commands never snapshot, so compare the locked
1120+ /// working copy's recorded tree with the focus commit's tree
1121+ fn check_wc_clean(&self, base: &Arc<ReadonlyRepo>) -> Result<(), OpenError> {
1122+ let dirty = (1, "working copy has changes not in @; run `j id` to record them or discard them".to_string());
1123+ let wc_commit = block_on(self.wc_commit(base))?;
1124+ // snapshot the working directory (without persisting anything) and
1125+ // compare against the focused commit's files (§7.7)
27461126 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);
27491127 let mut locked_ws = block_on(ws_guard.start_working_copy_mutation())
27501128 .map_err(|e| (2, format!("cannot lock the working copy: {}", e)))?;
2751− let old_tree = locked_ws.locked_wc().old_tree().clone();
2752− let scanned = block_on(scan(locked_ws.locked_wc(), &root, &old_tree, &saved, &snapshot_options()))
1129+ let options = SnapshotOptions {
1130+ base_ignores: GitIgnoreFile::empty(),
1131+ progress: None,
1132+ start_tracking_matcher: &EverythingMatcher,
1133+ force_tracking_matcher: &NothingMatcher,
1134+ max_new_file_size: u64::MAX,
1135+ };
1136+ let (new_tree, _stats) = block_on(locked_ws.locked_wc().snapshot(&options))
27531137 .map_err(|e| (2, format!("cannot read the working copy: {}", e)))?;
2754− let current = locked_ws.locked_wc().old_operation_id() == base.operation().id();
2755− let recorded = match marker_style(base.settings()) {
2756− Ok(markers) => block_on(snapshot_tree(&root, markers, &wc_tree, &old_tree, current, &scanned)),
2757− Err(e) => Err(e),
2758− }
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)))?;
1138+ let same = new_tree.tree_ids() == wc_commit.tree().tree_ids();
1139+ // discard: finish against the current op so no state changes
1140+ block_on(locked_ws.finish(base.operation().id().clone()))
1141+ .map_err(|e| (2, format!("cannot finish: {}", e)))?;
1142+ drop(ws_guard);
1143+ if !same {
1144+ return Err(dirty);
27711145 }
2772− Ok((scanned, true))
1146+ Ok(())
27731147 }
27741148
2775− /// The operation log for `ops` (§7.7), one line per operation, newest
2776− /// first; the caller writes it to stdout.
2777− pub fn cmd_ops(&self) -> Result<String, OpenError> {
1149+ pub fn cmd_ops(&self) -> Result<(), OpenError> {
27781150 let base = self.head_repo()?;
27791151 let loader = self.repo_loader();
27801152 let head = base.operation().clone();
@@ -2787,8 +1159,8 @@ impl JjBackend {
27871159 let mut tag = "";
27881160 match op_kind(op.metadata()) {
27891161 OpKind::Normal => {}
2790− OpKind::Undo => tag = "undo",
2791− OpKind::Redo => tag = "redo",
1162+ OpKind::Undo { .. } => tag = "undo",
1163+ OpKind::Redo { .. } => tag = "redo",
27921164 }
27931165 if desc.is_empty() {
27941166 desc = "(no description)".to_string();
@@ -2811,7 +1183,8 @@ impl JjBackend {
28111183 None => None,
28121184 };
28131185 }
2814− Ok(out)
1186+ print!("{}", out);
1187+ Ok(())
28151188 }
28161189}
28171190
@@ -2848,7 +1221,6 @@ pub fn cmd_init(cfg: &Config) -> Result<(), OpenError> {
28481221 }
28491222 .map_err(|e| (2, format!("cannot create the repository: {}", e)))?;
28501223 let backend = JjBackend {
2851− replay_cache: Rc::new(ReplayCache::default()),
28521224 inner: Arc::new(JjInner {
28531225 workspace_root: cwd.clone(),
28541226 workspace: Mutex::new(workspace),
@@ -2857,43 +1229,23 @@ pub fn cmd_init(cfg: &Config) -> Result<(), OpenError> {
28571229 visible: Mutex::new(None),
28581230 lock_guard: Mutex::new(None),
28591231 pending: Mutex::new(None),
2860− folds_case: OnceLock::new(),
2861− long_names: Mutex::new(HashMap::new()),
28621232 }),
28631233 };
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();
28671234 if had_git {
28681235 let import_options = backend.git_import_options()?;
1236+ let mut tx = backend.current_repo().start_transaction();
28691237 block_on(jj_lib::git::import_refs(tx.repo_mut(), &import_options))
28701238 .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)))?;
28791239 block_on(tx.repo_mut().rebase_descendants())
28801240 .map_err(|e| (2, format!("cannot rebase descendants: {}", e)))?;
1241+ let _ = block_on(backend.publish_tx(tx, "import git refs"))?;
28811242 }
2882− // §7.8: the current head is git's HEAD (none on an unborn branch)
2883− let head = tx
2884− .repo()
2885− .view()
2886− .git_head(&backend.inner.workspace_name)
2887− .as_resolved()
2888− .cloned()
2889− .flatten();
2890− create_initial_wc_commit(&backend, cfg, tx, "init", head, had_git, Some(""))?;
1243+ create_initial_wc_commit(&backend, cfg, "init")?;
28911244 Ok(())
28921245}
28931246
28941247pub fn cmd_clone(cfg: &Config, url: &str, dir: &str) -> Result<(), OpenError> {
28951248 let settings = user_settings_strict(cfg)?;
2896− let url = &origin_url(url)?;
28971249 let dir_path = PathBuf::from(dir);
28981250 if dir_path.join(".jj").is_dir() {
28991251 return Err((2, format!("{} already contains a jj repository", dir)));
@@ -2907,296 +1259,82 @@ pub fn cmd_clone(cfg: &Config, url: &str, dir: &str) -> Result<(), OpenError> {
29071259 } else if dir_path.exists() {
29081260 return Err((2, format!("{} already exists and is not empty", dir)));
29091261 }
2910− let existed = dir_path.is_dir();
2911− // the directory an existing DIR leads to, through a symlink DIR may be:
2912− // a clone that fails empties it only while DIR still leads there (§7.8)
2913− let found = if existed { dir_identity(&dir_path, true) } else { None };
2914− // the directories this run creates, in order, each with its device and
2915− // inode: a clone that fails removes DIR and those of its parents left
2916− // empty, or an existing DIR's entries, and nothing else (§7.8).
2917− // Each is made by `create_dir`, whose success proves it new. The checks
2918− // above read DIR as written, before its missing parents exist; once
2919− // they do, `new/..` or `new/../keep` names a directory that was there,
2920− // so DIR's own `create_dir` failing, AlreadyExists included, refuses it
2921− // as `git clone` does
2922− let mut created: Vec<(&std::path::Path, Option<(u64, u64)>)> = Vec::new();
2923− // those of them a failed clone leaves, as their paths no longer lead
2924− // to them
2925− let mut left: Vec<&std::path::Path> = Vec::new();
2926− // from here until the clone has removed what it made or completed, a
2927− // signal to stop waits, and fails a clone that has not yet made its
2928− // working-copy commit (`unless_interrupted`, §1.3)
2929− let _held = Deferral::start();
2930− if !existed {
2931− // DIR and its missing parents, DIR first ("" is the current
2932− // directory, which exists)
2933− let missing: Vec<&std::path::Path> = dir_path
2934− .ancestors()
2935− .enumerate()
2936− .take_while(|(i, p)| *i == 0 || !(p.as_os_str().is_empty() || p.exists()))
2937− .map(|(_, p)| p)
2938− .collect();
2939− for p in missing.into_iter().rev() {
2940− match std::fs::create_dir(p) {
2941− Ok(()) => created.push((p, dir_identity(p, false))),
2942− // a parent reached again through `..`
2943− Err(e)
2944− if e.kind() == std::io::ErrorKind::AlreadyExists
2945− && p != dir_path
2946− && p.is_dir() => {}
2947− Err(e) => {
2948− for &(p, id) in created.iter().rev() {
2949− if !remove_created_dir(p, id, false) {
2950− left.push(p);
2951− }
2952− }
2953− let msg = format!("cannot create {}: {}", dir, e);
2954− return Err((2, msg + &left_note(&left)));
2955− }
2956− }
2957− }
2958− }
2959− let cloned = clone_into(cfg, &settings, url, &dir_path);
2960− let Err((code, msg)) = cloned else {
2961− return Ok(());
2962− };
2963− // like `git clone`, a clone that fails leaves nothing behind: its
2964− // working-copy commit would claim files the checkout never wrote, and a
2965− // later clone would refuse the directory (§7.8)
2966− if !existed {
2967− // DIR, the last one created, goes whole; a parent holds nothing of
2968− // the clone's but DIR, so it goes only if left empty: what else was
2969− // put there meanwhile, a sibling clone say, stays
2970− for (i, &(p, id)) in created.iter().enumerate().rev() {
2971− if !remove_created_dir(p, id, i + 1 == created.len()) {
2972− left.push(p);
2973− }
2974− }
2975− Err((code, msg + &left_note(&left)))
2976− } else if found.is_some() && dir_identity(&dir_path, true) == found {
2977− if let Ok(entries) = std::fs::read_dir(&dir_path) {
2978− for entry in entries.flatten() {
2979− let _ = match entry.file_type() {
2980− Ok(t) if t.is_dir() => std::fs::remove_dir_all(entry.path()),
2981− _ => std::fs::remove_file(entry.path()),
2982− };
2983− }
2984− }
2985− Err((code, msg))
2986− } else if dir_path.symlink_metadata().is_ok() {
2987− // a symlink put in place of DIR, or of a directory above it, would
2988− // have `read_dir` list what that names
2989− Err((
2990− code,
2991− format!(
2992− "{}; left what `{}` holds, as it is no longer the directory this clone found empty",
2993− msg, dir
2994− ),
2995− ))
2996− } else {
2997− Err((code, msg))
2998− }
2999−}
3000−
3001−/// The device and inode of the directory at `path`, following a symlink
3002−/// there only when `follow` says so; `None` when `path` holds no directory
3003−fn dir_identity(path: &std::path::Path, follow: bool) -> Option<(u64, u64)> {
3004− use std::os::unix::fs::MetadataExt;
3005− let meta = if follow {
3006− std::fs::metadata(path)
3007− } else {
3008− std::fs::symlink_metadata(path)
3009− };
3010− meta.ok().filter(|m| m.is_dir()).map(|m| (m.dev(), m.ino()))
3011−}
3012−
3013−/// Remove the directory a failed clone created at `path`, `whole` or only
3014−/// if empty, while `path` still leads to it: the directory whose device and
3015−/// inode `id` were read on creating it (§7.8). A symlink put in its place,
3016−/// or in place of a directory above it, would have the removal follow it
3017−/// and delete what that names, so a path leading anywhere else is left, and
3018−/// the result is false. One leading nowhere holds nothing to remove.
3019−fn remove_created_dir(path: &std::path::Path, id: Option<(u64, u64)>, whole: bool) -> bool {
3020− if id.is_some() && dir_identity(path, false) == id {
3021− let _ = if whole {
3022− std::fs::remove_dir_all(path)
3023− } else {
3024− std::fs::remove_dir(path)
3025− };
3026− true
3027− } else {
3028− path.symlink_metadata().is_err()
3029− }
3030−}
3031−
3032−/// What a failed clone's error adds for the directories it created and
3033−/// `left` (§7.8): nothing when it left none
3034−fn left_note(left: &[&std::path::Path]) -> String {
3035− let named = |ps: &[&std::path::Path]| {
3036− ps.iter().map(|p| format!("`{}`", p.display())).collect::<Vec<_>>().join(", ")
3037− };
3038− match left {
3039− [] => String::new(),
3040− [one] => format!(
3041− "; left {}, as it is no longer the directory this clone created",
3042− named(&[one])
3043− ),
3044− [init @ .., last] => format!(
3045− "; left {} and {}, as they are no longer the directories this clone created",
3046− named(init),
3047− named(&[last])
3048− ),
3049− }
3050−}
3051−
3052−/// `url` as `origin` holds it (§7.8): a local path resolved against the
3053−/// current directory, as `jj git clone` stores it, for git resolves a
3054−/// relative one against the directory each later `fetch` or `push` runs
3055−/// in; one under a home directory (`~/…`, `~user/…`) and any other URL, a
3056−/// `file://` one included, as given. One that does not parse, or a path
3057−/// that does not resolve to UTF-8, is a usage error.
3058−fn origin_url(url: &str) -> Result<String, OpenError> {
3059− let mut parsed = gix::url::parse(url).map_err(|e| (2, format!("invalid URL `{}`: {}", url, e)))?;
3060− // a bare path is a file location in the alternative form
3061− if parsed.scheme != gix::url::Scheme::File || !parsed.serialize_alternative_form {
3062− return Ok(url.to_string());
3063− }
3064− // git expands a leading `~` to the home directory on every fetch and
3065− // push, from wherever it runs; resolved here, it would name a directory
3066− // called `~` (`./~/…` still does)
3067− if parsed.path.starts_with(b"~") {
3068− return Ok(url.to_string());
3069− }
3070− let cwd = std::env::current_dir()
3071− .map_err(|e| (2, format!("cannot read the current directory: {}", e)))?;
3072− parsed
3073− .canonicalize(&cwd)
3074− .map_err(|e| (2, format!("cannot resolve `{}`: {}", url, e)))?;
3075− // `add_remote` takes UTF-8, which only the current directory's name or
3076− // a symlink's target can break; stored as given, the path would reach
3077− // the remote only from where it was typed
3078− String::from_utf8(parsed.to_bstring().into())
3079− .map_err(|_| (2, format!("cannot resolve `{}`: the path it names is not UTF-8", url)))
3080−}
3081−
3082−/// `clone`'s work once `dir_path` exists and is empty (§7.8)
3083−fn clone_into(
3084− cfg: &Config,
3085− settings: &UserSettings,
3086− url: &str,
3087− dir_path: &std::path::Path,
3088−) -> Result<(), OpenError> {
1262+ std::fs::create_dir_all(&dir_path)
1263+ .map_err(|e| (2, format!("cannot create {}: {}", dir, e)))?;
30891264 let (workspace, repo) = block_on(Workspace::init_colocated_git(
3090− settings,
3091− dir_path,
1265+ &settings,
1266+ &dir_path,
30921267 gix::hash::Kind::Sha1,
30931268 ))
30941269 .map_err(|e| (2, format!("cannot create the repository: {}", e)))?;
30951270 let backend = JjBackend {
3096− replay_cache: Rc::new(ReplayCache::default()),
30971271 inner: Arc::new(JjInner {
3098− workspace_root: dir_path.to_path_buf(),
1272+ workspace_root: dir_path.clone(),
30991273 workspace: Mutex::new(workspace),
31001274 repo: Mutex::new(repo),
31011275 workspace_name: WorkspaceName::DEFAULT.to_owned(),
31021276 visible: Mutex::new(None),
31031277 lock_guard: Mutex::new(None),
31041278 pending: Mutex::new(None),
3105− folds_case: OnceLock::new(),
3106− long_names: Mutex::new(HashMap::new()),
31071279 }),
31081280 };
31091281 let origin = RemoteName::new("origin");
31101282 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(
3114− jj_lib::git::add_remote(tx.repo_mut(), origin, url, None)
3115− .map_err(|e| (1, format!("cannot set the remote: {}", e))),
3116− )?;
3117− unless_interrupted(backend.git_fetch_refs(tx.repo_mut(), origin))?;
3118− let default_branch = unless_interrupted(git_default_branch(
3119− git_backend(tx.repo().store())?.git_repo_path(),
3120− origin,
3121− ))?;
1283+ jj_lib::git::add_remote(tx.repo_mut(), origin, url, None)
1284+ .map_err(|e| (1, format!("cannot set the remote: {}", e)))?;
1285+ backend.git_fetch_refs(tx.repo_mut(), origin)?;
31221286 let import_options = backend.git_import_options()?;
3123− unless_interrupted(
3124− block_on(jj_lib::git::import_refs(tx.repo_mut(), &import_options))
3125− .map_err(|e| (1, format!("cannot import the fetched refs: {}", e))),
3126− )?;
3127− unless_interrupted(
3128− block_on(tx.repo_mut().rebase_descendants())
3129− .map_err(|e| (1, format!("cannot rebase descendants: {}", e))),
3130− )?;
3131− // §7.8: the default bookmark's target (none when the remote's HEAD names
3132− // no branch, or a branch it does not have)
3133− let head = default_branch.and_then(|name| {
3134− tx.repo()
3135− .view()
3136− .get_remote_bookmark(RefName::new(&name).to_remote_symbol(origin))
3137− .target
3138− .as_resolved()
3139− .cloned()
3140− .flatten()
3141− });
3142− // the fetch and the working-copy commit are one operation, the one undo
3143− // never undoes (§7.7)
3144− create_initial_wc_commit(&backend, cfg, tx, &format!("clone {}", url), head, false, None)?;
1287+ block_on(jj_lib::git::import_refs(tx.repo_mut(), &import_options))
1288+ .map_err(|e| (1, format!("cannot import the fetched refs: {}", e)))?;
1289+ block_on(tx.repo_mut().rebase_descendants())
1290+ .map_err(|e| (1, format!("cannot rebase descendants: {}", e)))?;
1291+ let _ = block_on(backend.publish_tx(tx, &format!("clone {}", url)))?;
1292+ create_initial_wc_commit(&backend, cfg, &format!("clone {}", url))?;
31451293 Ok(())
31461294}
31471295
3148−/// §7.8: after init/clone, create a working-copy commit as a child of
3149−/// `head` (the root commit when there is none), record it in `tx` with
3150−/// what the command has imported, and check it out. The commit holds its
3151−/// parent's files less any a checkout cannot create (`checkout_tree`), so
3152−/// it is empty unless the parent has one. `adopt` says the working
3153−/// directory already holds a checkout of the parent (init over an existing
3154−/// git repository): the working copy is then reset to the commit instead of
3155−/// written, so no file is touched and what the directory holds beyond the
3156−/// parent is the next snapshot's change. `back` is what a checkout that
3157−/// fails says (`JjBackend::checkout`): none for a clone, which then removes
3158−/// what it made.
1296+/// §7.8: after init/clone, create a working-copy commit as a child of the
1297+/// current head (a bookmark target if there is one, otherwise the root
1298+/// commit) and check it out.
31591299fn create_initial_wc_commit(
31601300 backend: &JjBackend,
31611301 cfg: &Config,
3162− mut tx: jj_lib::transaction::Transaction,
31631302 description: &str,
3164− head: Option<CommitId>,
3165− adopt: bool,
3166− back: Option<&str>,
31671303) -> Result<(), OpenError> {
3168− let store = tx.base_repo().store().clone();
3169− let parent = head.unwrap_or_else(|| store.root_commit_id().clone());
1304+ let base = backend.current_repo();
1305+ let store = base.store().clone();
1306+ let mut parent = store.root_commit_id().clone();
1307+ 'outer: for (_name, target) in base.view().local_bookmarks() {
1308+ if let Some(id) = target.as_resolved().and_then(|t| t.clone()) {
1309+ parent = id;
1310+ break 'outer;
1311+ }
1312+ }
1313+ if parent == *store.root_commit_id() {
1314+ for (_name, remote_ref) in base.view().remote_bookmarks(RemoteName::new("origin")) {
1315+ if let Some(id) = remote_ref.target.as_resolved().and_then(|t| t.clone()) {
1316+ parent = id;
1317+ break;
1318+ }
1319+ }
1320+ }
31701321 // jj's own init already created a working-copy commit; if it is already
31711322 // a child of the target parent, keep it and just check it out
31721323 let mut to_abandon: Option<CommitId> = None;
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()) {
3175− if existing.parent_ids().first() == Some(&parent) {
3176− // jj's commit is an empty child of the root; record only what
3177− // was imported, if anything
3178− let op = if tx.repo().has_changes() {
3179− Some(
3180− block_on(tx.write(truncate_chars(description, 200)))
3181− .map_err(|e| (1, format!("cannot write the operation: {}", e)))?,
3182− )
3183− } else {
3184− None
3185− };
3186− block_on(backend.checkout(&existing, None, op, back)).map_err(|c| (1, c.msg))?;
1324+ if let Ok(existing) = block_on(backend.wc_commit(&base)) {
1325+ if existing.parent_ids().first() == Some(&parent) {
1326+ let op_id = base.operation().id().clone();
1327+ block_on(backend.checkout(&existing, op_id)).map_err(|c| (1, c.msg))?;
31871328 return Ok(());
31881329 }
31891330 if existing.id() != store.root_commit_id() {
31901331 to_abandon = Some(existing.id().clone());
31911332 }
31921333 }
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))?;
31971334 let user_sig = backend
31981335 .user_signature(cfg)
31991336 .map_err(|c| (3, format!("config.j: {}", c.msg)))?;
1337+ let mut tx = base.start_transaction();
32001338 if let Some(old_wc) = &to_abandon {
32011339 tx.repo_mut().record_abandoned_commit_with_parents(
32021340 old_wc.clone(),
@@ -3205,7 +1343,7 @@ fn create_initial_wc_commit(
32051343 }
32061344 let wc = block_on(
32071345 tx.repo_mut()
3208− .new_commit(vec![parent], wc_tree)
1346+ .new_commit(vec![parent], store.empty_merged_tree())
32091347 .set_author(user_sig.clone())
32101348 .set_committer(user_sig)
32111349 .write(),
@@ -3218,49 +1356,19 @@ fn create_initial_wc_commit(
32181356 .map_err(|e| (1, format!("cannot rebase descendants: {}", e)))?;
32191357 let unpublished = block_on(tx.write(truncate_chars(description, 200)))
32201358 .map_err(|e| (1, format!("cannot write the operation: {}", e)))?;
3221− // an adopted directory is reset to the commit, not checked out over:
3222− // checking out from the empty tree jj's init recorded would recreate
3223− // the files the user deleted there. Anything else is written, once the
3224− // operation is published (§7.5 step 7).
3225− let on_disk = if adopt { Some(wc.tree()) } else { None };
3226− block_on(backend.checkout(&wc, on_disk.as_ref(), Some(unpublished), back)).map_err(|c| (1, c.msg))?;
1359+ let op_id = unpublished.operation().id().clone();
1360+ let repo = block_on(unpublished.publish())
1361+ .map_err(|e| (1, format!("cannot publish the operation: {}", e)))?;
1362+ *backend.inner.repo.lock().unwrap() = repo;
1363+ block_on(backend.checkout(&wc, op_id)).map_err(|c| (1, c.msg))?;
32271364 Ok(())
32281365}
32291366
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−
32601367/// user settings for init/clone: a missing or malformed `user` is a
32611368/// configuration error (exit 3) (§7.9)
32621369fn user_settings_strict(cfg: &Config) -> Result<UserSettings, OpenError> {
3263− let (name, email) = validated_identity(cfg)?;
1370+ let (name, email) = crate::config::eval_user_only(&mut Interp::dummy(), cfg)
1371+ .map_err(|c| (3, format!("config.j: {}", c.msg)))?;
32641372 let mut config = StackedConfig::with_defaults();
32651373 let text = format!(
32661374 "[user]\nname = {}\nemail = {}\n",
@@ -3274,39 +1382,6 @@ fn user_settings_strict(cfg: &Config) -> Result<UserSettings, OpenError> {
32741382 .map_err(|e| (3, format!("config.j: invalid user settings: {}", e)))
32751383}
32761384
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−
33101385// ----------------------------------------------------------------------
33111386// reserved-command helpers
33121387// ----------------------------------------------------------------------
@@ -3370,72 +1445,41 @@ fn git_fetch_subprocess(git_dir: &std::path::Path, remote: &RemoteName) -> Resul
33701445 if out.status.success() {
33711446 Ok(())
33721447 } else {
3373− Err((1, format!("fetch failed: {}", git_failure(&out))))
3374− }
3375−}
3376−
3377−/// What a git that failed said, or how it ended when it said nothing (a
3378−/// Ctrl-C stops it silently)
3379−fn git_failure(out: &std::process::Output) -> String {
3380− use std::os::unix::process::ExitStatusExt;
3381− let said = String::from_utf8_lossy(&out.stderr).trim().to_string();
3382− match (said.is_empty(), out.status.signal()) {
3383− (false, _) => said,
3384− (true, Some(sig)) => format!("git was stopped by signal {}", sig),
3385− (true, None) => format!("git exited with status {}", out.status.code().unwrap_or(-1)),
3386− }
3387−}
3388−
3389−/// the branch the remote's HEAD names, its default bookmark (§7.8), asked of
3390−/// the remote with `git ls-remote --symref` (the git `git_fetch_subprocess`
3391−/// runs); `None` when HEAD names no branch (a detached HEAD)
3392−fn git_default_branch(
3393− git_dir: &std::path::Path,
3394− remote: &RemoteName,
3395−) -> Result<Option<String>, OpenError> {
3396− let exe = std::env::var("J_GIT").unwrap_or_else(|_| "git".to_string());
3397− let out = std::process::Command::new(exe)
3398− .arg("--git-dir")
3399− .arg(git_dir)
3400− .args(["ls-remote", "--symref", remote.as_str(), "HEAD"])
3401− .stdin(std::process::Stdio::null())
3402− .output()
3403− .map_err(|e| (1, format!("cannot run git ls-remote: {}", e)))?;
3404− if !out.status.success() {
3405− return Err((1, format!("cannot read the remote's default branch: {}", git_failure(&out))));
1448+ Err((
1449+ 1,
1450+ format!(
1451+ "fetch failed: {}",
1452+ String::from_utf8_lossy(&out.stderr).trim()
1453+ ),
1454+ ))
34061455 }
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− }))
34121456}
34131457
34141458enum OpKind {
34151459 Normal,
3416− Undo,
3417− Redo,
1460+ /// the operation this undo discarded the view of
1461+ Undo { #[allow(dead_code)] undid: OperationId },
1462+ /// the undo this redo reversed
1463+ Redo { redid: OperationId },
34181464}
34191465
34201466/// a marker parsed from an operation description (§7.7 kinds)
34211467struct OpMarker {
34221468 kind: &'static str,
34231469 id: OperationId,
3424− /// jj 0.45 names the operation whose view was restored, rather than
3425− /// the operation undone or the undo reversed.
3426− restores_view: bool,
3427− /// Whether a legacy marker is j's exact `undo: <id>` / `redo: <id>`
3428− /// format, whose discarded-operation links the walk can follow.
1470+ /// whether the description is j's own exact marker (`undo: <id>` /
1471+ /// `redo: <id>`); only those are reliably loadable for the walk
34291472 authored_by_j: bool,
34301473}
34311474
3432−/// §7.7 kinds: new records use jj 0.45's restored-view descriptions. Older
3433−/// j `undo: <id>` / `redo: <id>` and jj `undo operation <id>` /
3434−/// `redo operation <id>` descriptions remain readable too.
1475+/// §7.7 kinds: `j` marks undo/redo operations as `undo: <opid>` /
1476+/// `redo: <opid>` in the description (0.45's Transaction cannot write
1477+/// metadata tags); jj's own `undo operation <id>` / `redo operation <id>`
1478+/// markers are treated the same way.
34351479fn op_kind(meta: &jj_lib::op_store::OperationMetadata) -> OpKind {
34361480 match op_marker(meta) {
3437− Some(OpMarker { kind: "undo", .. }) => OpKind::Undo,
3438− Some(OpMarker { kind: "redo", .. }) => OpKind::Redo,
1481+ Some(OpMarker { kind: "undo", id, .. }) => OpKind::Undo { undid: id },
1482+ Some(OpMarker { kind: "redo", id, .. }) => OpKind::Redo { redid: id },
34391483 _ => OpKind::Normal,
34401484 }
34411485}
@@ -3443,33 +1487,20 @@ fn op_kind(meta: &jj_lib::op_store::OperationMetadata) -> OpKind {
34431487fn op_marker(meta: &jj_lib::op_store::OperationMetadata) -> Option<OpMarker> {
34441488 let desc = meta.description.trim();
34451489 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− }
34511490 if let Some(rest) = desc.strip_prefix(&format!("{}:", kind)) {
34521491 if let Some(id) = parse_op_id(rest.trim()) {
3453− return Some(OpMarker { kind, id, restores_view: false, authored_by_j: true });
1492+ return Some(OpMarker { kind, id, authored_by_j: true });
34541493 }
34551494 }
34561495 if let Some(rest) = desc.strip_prefix(&format!("{} operation", kind)) {
34571496 if let Some(id) = parse_op_id(rest.trim()) {
3458− return Some(OpMarker { kind, id, restores_view: false, authored_by_j: false });
1497+ return Some(OpMarker { kind, id, authored_by_j: false });
34591498 }
34601499 }
34611500 }
34621501 None
34631502}
34641503
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−
34731504/// a full-length (or prefix) hex operation id
34741505fn parse_op_id(s: &str) -> Option<OperationId> {
34751506 if s.is_empty() || !s.chars().all(|c| c.is_ascii_hexdigit()) {
@@ -3495,39 +1526,21 @@ fn op_parent_id(
34951526 Ok(op.parent_ids().first().cloned())
34961527}
34971528
3498−/// A restore operation represents an earlier view. Follow that view to a
3499−/// normal operation for useful confirmation text without changing undo's
3500−/// navigation or its jj-compatible metadata.
3501−fn restored_operation_description(
1529+/// follow a chain of redos to the operation that was ultimately reapplied
1530+async fn chain_through_redos(
35021531 loader: &jj_lib::repo::RepoLoader,
3503− op: &jj_lib::operation::Operation,
3504−) -> String {
3505− let mut represented = op.clone();
3506− let mut seen = BTreeSet::new();
3507− while seen.insert(represented.id().clone()) {
3508− let Some(marker) = op_marker(represented.metadata()) else { break };
3509− let target = if marker.restores_view {
3510− Some(marker.id)
3511− } else if marker.authored_by_j {
3512− // A legacy undo names what it discarded; a legacy redo names
3513− // the undo it reversed. Either restored its target's parent.
3514− op_parent_id(loader, &marker.id).ok().flatten()
3515− } else {
3516− // Unknown legacy jj history is best identified by its actual
3517− // description instead of guessing which operation it applied.
3518− None
3519− };
3520− let Some(target) = target else { break };
3521− // Missing historical metadata must not prevent an otherwise valid
3522− // recovery operation merely because its summary cannot be expanded.
3523− let Ok(target_op) = block_on(loader.load_operation(&target)) else { break };
3524− represented = target_op;
3525− }
3526− let description = represented.metadata().description.lines().next().unwrap_or("").trim();
3527− if description.is_empty() {
3528− format!("operation {}", truncate_chars(&represented.id().hex(), 12))
3529− } else {
3530− terminal_text(description)
1532+ first: &OperationId,
1533+) -> Result<OperationId, OpenError> {
1534+ let mut id = first.clone();
1535+ loop {
1536+ let op = loader
1537+ .load_operation(&id)
1538+ .await
1539+ .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
1540+ match op_kind(op.metadata()) {
1541+ OpKind::Redo { redid } => id = redid,
1542+ _ => return Ok(id),
1543+ }
35311544 }
35321545}
35331546
@@ -3597,11 +1610,6 @@ fn parse_push_records(
35971610 let rec = vis.commits.get(&change_id).ok_or_else(|| {
35981611 (1, format!("push: `@{}` is not a visible commit", change_id))
35991612 })?;
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− }
36051613 out.push((name, Some(rec.commit.id().clone())));
36061614 }
36071615 None => {
@@ -3621,190 +1629,68 @@ fn parse_push_records(
36211629 Ok(out)
36221630}
36231631
3624−/// §7.6: refuse to move a bookmark whose current target is immutable to a
3625−/// commit that does not descend from that target, or to delete it while no
3626−/// bookmark left on `origin` after the push reaches that target; and refuse
3627−/// to send commits with unresolved files or empty descriptions. Returns
3628−/// whether such a delete is allowed only because a record's new target
3629−/// reaches it, so the deletes must wait for the updates (push_to_origin),
3630−/// and refuses a push whose deletes would wait for a bookmark git cannot
3631−/// create while one of them is still there
1632+/// §7.6: refuse to send commits with unresolved files or empty descriptions,
1633+/// and refuse to move/delete a bookmark whose current target is immutable
1634+/// away from a descendant of that target
36321635fn check_push_records(
36331636 records: &[(String, Option<CommitId>)],
3634− repo: &Arc<ReadonlyRepo>,
36351637 vis: &VisibleRepo,
3636− immutable: &BTreeSet<String>,
3637−) -> Result<bool, OpenError> {
1638+ repo: &Arc<ReadonlyRepo>,
1639+) -> Result<(), OpenError> {
36381640 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();
36481641 for (name, target) in records {
3649− let Some(current) = repo
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
36501652 .view()
3651− .get_remote_bookmark(RefName::new(name).to_remote_symbol(origin))
1653+ .get_remote_bookmark(RefName::new(name).to_remote_symbol(RemoteName::new("origin")))
36521654 .target
36531655 .as_resolved()
36541656 .cloned()
36551657 .unwrap_or(None)
3656− else {
3657− continue;
3658− };
3659− let change_id = block_on(store.get_commit_async(&current))
3660− .map_err(|e| (2, format!("cannot read a commit: {}", e)))?
3661− .change_id()
3662− .reverse_hex();
3663− // the immutable set holds visible commits: a target rewritten here
3664− // since the last fetch or push is hidden, and not in it although the
3665− // visible rewrite carries its change id (and its label)
3666− let visible = vis.commits.get(&change_id).is_some_and(|c| c.commit.id() == &current);
3667− if !visible || !immutable.contains(&change_id) {
3668− continue;
3669− }
3670− match target {
3671− None => {
3672− // a delete loses no history while another bookmark still
3673− // reaches the commit: a merged feature, a second name. When
3674− // only a bookmark this push sets reaches it (`rename`), the
3675− // remote may reject that update and accept the delete, so
3676− // the delete waits for the update to be accepted
3677− let unnamed = unnamed.get_or_insert_with(|| {
3678− let named: BTreeSet<&str> = records.iter().map(|(n, _)| n.as_str()).collect();
3679− repo.view()
3680− .remote_bookmarks(origin)
3681− .filter(|(n, _)| !named.contains(n.as_str()))
3682− .flat_map(|(_, remote_ref)| remote_ref.target.added_ids())
3683− .cloned()
3684− .collect()
3685− });
3686− let reached = |heads: &[CommitId]| {
3687− ResolvedRevsetExpression::commits(vec![current.clone()])
3688− .intersection(&ResolvedRevsetExpression::commits(heads.to_vec()).ancestors())
3689− .evaluate(repo.as_ref())
3690− .and_then(|r| r.is_empty())
3691− .map(|empty| !empty)
3692− .map_err(|e| (2, format!("cannot read the index: {}", e)))
3693− };
3694− if !reached(unnamed)? {
3695− if !reached(&new_targets)? {
3696− return Err((
3697− 1,
3698− format!(
3699− "push: `{}` is on the immutable commit `@{}` and cannot be deleted while no bookmark left on origin reaches it",
3700− name, change_id
3701− ),
3702− ));
3703− }
3704− waiting.push(name);
3705− }
3706− }
3707− Some(new) => {
3708− let descends = block_on(repo.index().is_ancestor(&current, new))
3709− .map_err(|e| (2, format!("cannot read the index: {}", e)))?;
3710− if !descends {
3711− return Err((
3712− 1,
3713− format!(
3714− "push: `{}` is on the immutable commit `@{}` and can only move to a descendant of it",
3715− name, change_id
3716− ),
3717− ));
3718− }
3719− }
3720− }
3721− }
3722− // git cannot hold a bookmark and one under its name at once (`master`
3723− // and `master/legacy`), so while the deletes wait, a first push that
3724− // creates a name nesting with a deleted one is rejected and the deletes
3725− // are never sent. With a bookmark no record names on the waiting
3726− // delete's commit, nothing waits and it is one git push (§7.6)
3727− let deletes_wait = !waiting.is_empty();
3728− if deletes_wait {
3729− let nests = |a: &str, b: &str| b.strip_prefix(a).is_some_and(|rest| rest.starts_with('/'));
3730− for (deleted, _) in records.iter().filter(|(_, t)| t.is_none()) {
3731− for (set, _) in records.iter().filter(|(_, t)| t.is_some()) {
3732− if nests(deleted, set) || nests(set, deleted) {
3733− let keep = if waiting.contains(&deleted.as_str()) { deleted.as_str() } else { waiting[0] };
3734− let literal = crate::show::text_literal(keep);
3735− // the temporary label: the first of `tmp`, `tmp-1`, ...
3736− // that is no bookmark, here or on origin, no record
3737− // names, and nests with none of those, so pushing and
3738− // deleting it moves none of the user's bookmarks, and
3739− // this push, which names nothing nesting with it, no
3740− // longer waits on `keep`'s commit
3741− let taken: Vec<&str> = repo
3742− .view()
3743− .bookmarks()
3744− .map(|(n, _)| n.as_str())
3745− .chain(records.iter().map(|(n, _)| n.as_str()))
3746− .collect();
3747− let tmp = std::iter::once("tmp".to_string())
3748− .chain((1..).map(|i| format!("tmp-{i}")))
3749− .find(|t| !taken.iter().any(|n| n == t || nests(n, t) || nests(t, n)))
3750− .expect("finitely many names are taken");
3751− let tmp = crate::show::text_literal(&tmp);
1658+ {
1659+ let commit = block_on(store.get_commit_async(&current))
1660+ .map_err(|e| (2, format!("cannot read a commit: {}", e)))?;
1661+ let change_id = commit.change_id().reverse_hex();
1662+ if vis.commits.contains_key(&change_id) {
1663+ seeds.push(change_id);
1664+ }
1665+ }
1666+ let ancestors = ancestors_of_change_ids(vis, &seeds);
1667+ for change_id in &ancestors {
1668+ let rec = vis.commits.get(change_id).expect("ancestors are stored");
1669+ for (_path, value) in rec.commit.tree().entries() {
1670+ let value = value.map_err(|e| (2, format!("cannot read a tree: {}", e)))?;
1671+ if !value.is_resolved() {
37521672 return Err((
37531673 1,
37541674 format!(
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})`"
1675+ "push: commit `@{}` has unresolved files",
1676+ rec.change_id
37561677 ),
37571678 ));
37581679 }
37591680 }
3760− }
3761− }
3762− // the commits that would be sent: ancestors of the new targets that no
3763− // bookmark on the remote already reaches; a delete sends none
3764− if new_targets.is_empty() {
3765− return Ok(deletes_wait);
3766− }
3767− let on_remote: Vec<CommitId> = repo
3768− .view()
3769− .remote_bookmarks(origin)
3770− .flat_map(|(_, remote_ref)| remote_ref.target.added_ids())
3771− .cloned()
3772− .collect();
3773− let sent = ResolvedRevsetExpression::commits(on_remote)
3774− .range(&ResolvedRevsetExpression::commits(new_targets))
3775− .evaluate(repo.as_ref())
3776− .map_err(|e| (2, format!("cannot list the commits to send: {}", e)))?;
3777− let sent: Vec<Commit> = block_on(sent.stream().commits(&store).try_collect())
3778− .map_err(|e| (2, format!("cannot list the commits to send: {}", e)))?;
3779− for commit in &sent {
3780− let change_id = commit.change_id().reverse_hex();
3781− let mut unresolved_paths = Vec::new();
3782− for (path, value) in commit.tree().entries() {
3783− let value = value.map_err(|e| (2, format!("cannot read a tree: {}", e)))?;
3784− if !value.is_resolved() {
3785− unresolved_paths.push(path.as_internal_file_string().to_string());
3786− }
3787− }
3788− if !unresolved_paths.is_empty() {
3789− let mut paths = unresolved_paths.iter().take(5)
3790− .map(|path| format!("`{}`", terminal_text(path)))
3791− .collect::<Vec<_>>().join(", ");
3792− if unresolved_paths.len() > 5 {
3793− paths.push_str(&format!(" and {} more", unresolved_paths.len() - 5));
1681+ if rec.commit.description().trim().is_empty() && rec.commit.id() != store.root_commit_id() {
1682+ return Err((
1683+ 1,
1684+ format!("push: commit `@{}` has an empty description", rec.change_id),
1685+ ));
37941686 }
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)));
38011687 }
38021688 }
3803− Ok(deletes_wait)
1689+ Ok(())
38041690}
38051691
38061692/// change-id closure of jj-parents from the seeds (all jj parents, not just
3807−/// the first), for the immutable set (§7.5)
1693+/// the first), for the push content checks
38081694fn ancestors_of_change_ids(vis: &VisibleRepo, seeds: &[String]) -> BTreeSet<String> {
38091695 let mut seen = BTreeSet::new();
38101696 let mut stack: Vec<String> = seeds.to_vec();
@@ -3825,11 +1711,23 @@ fn ancestors_of_change_ids(vis: &VisibleRepo, seeds: &[String]) -> BTreeSet<Stri
38251711
38261712/// git-check-ref-format --branch rules
38271713fn is_valid_git_branch_name(name: &str) -> bool {
3828− if name.starts_with('-') {
1714+ if name.is_empty()
1715+ || name.starts_with('-')
1716+ || name.starts_with('.')
1717+ || name.ends_with('.')
1718+ || name.ends_with('/')
1719+ || name.ends_with(".lock")
1720+ || name.contains("..")
1721+ || name.contains("//")
1722+ || name.contains("@{")
1723+ || name == "@"
1724+ {
38291725 return false;
38301726 }
3831− let full_name = format!("refs/heads/{name}");
3832− gix::validate::reference::branch_name(full_name.as_str().into()).is_ok()
1727+ name.chars().all(|c| {
1728+ !(c.is_ascii_control()
1729+ || matches!(c, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\'))
1730+ })
38331731}
38341732
38351733// ----------------------------------------------------------------------
@@ -3837,12 +1735,6 @@ fn is_valid_git_branch_name(name: &str) -> bool {
38371735// ----------------------------------------------------------------------
38381736
38391737impl 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−
38461738 fn visible_ids(&self) -> Vec<String> {
38471739 match self.inner.visible.lock().unwrap().as_ref() {
38481740 Some(v) => v.commits.keys().cloned().collect(),
@@ -3877,93 +1769,38 @@ impl Backend for JjBackend {
38771769 vis.and_then(|v| v.commits.get(id).map(|r| r.commit.has_conflict()))
38781770 }
38791771
3880− fn is_empty(&self, id: &str, parent: &str) -> Option<bool> {
1772+ fn is_empty(&self, id: &str) -> Option<bool> {
38811773 let vis = self.inner.visible.lock().unwrap().clone();
3882− // O(1): empty iff the tree id equals the parent's tree id
1774+ // O(1): empty iff the tree id equals the first parent's tree id
38831775 vis.and_then(|v| {
38841776 let rec = v.commits.get(id)?;
3885− let parent = v.commits.get(parent)?;
1777+ let parent = v.commits.get(&rec.first_parent)?;
38861778 Some(rec.commit.tree_ids() == parent.commit.tree_ids())
38871779 })
38881780 }
38891781
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−
39441782 fn ancestors_closed(&self, ids: &BTreeSet<String>) -> BTreeSet<String> {
39451783 let vis = self.inner.visible.lock().unwrap().clone();
39461784 let Some(vis) = vis else {
39471785 return BTreeSet::new();
39481786 };
3949− // every jj parent, not just the first: rewriting a commit reached
3950− // only through a merge's second parent rewrites the merge too
3951− // (§7.5 step 3)
3952− let seeds: Vec<String> = ids.iter().cloned().collect();
3953− ancestors_of_change_ids(&vis, &seeds)
1787+ let mut seen = BTreeSet::new();
1788+ let mut stack: Vec<String> = ids.iter().cloned().collect();
1789+ while let Some(id) = stack.pop() {
1790+ if seen.insert(id.clone()) {
1791+ if let Some(rec) = vis.commits.get(&id) {
1792+ if !rec.first_parent.is_empty() {
1793+ stack.push(rec.first_parent.clone());
1794+ }
1795+ }
1796+ }
1797+ }
1798+ seen
39541799 }
39551800
39561801 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> {
39651802 let store = self.current_repo().store().clone();
3966− block_on(jj_replay(&store, &self.replay_cache, onto, from, to))
1803+ block_on(jj_replay(&store, onto, from, to))
39671804 }
39681805}
39691806
@@ -3971,197 +1808,30 @@ impl Backend for JjBackend {
39711808// jj three-way tree merge for `replay` (§7.3)
39721809// ----------------------------------------------------------------------
39731810
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−
40771811async fn jj_replay(
40781812 store: &Arc<Store>,
4079− cache: &ReplayCache,
4080− onto: &Value,
4081− from: &Value,
4082− to: &Value,
4083−) -> Result<Value, Crash> {
4084− let (onto_tree, onto_canonical) = cache.tree_of(store, onto).await?;
4085− let (from_tree, from_canonical) = cache.tree_of(store, from).await?;
4086− let (to_tree, to_canonical) = cache.tree_of(store, to).await?;
4087− // any of the three may itself be conflicted (a snapshot with unresolved
4088− // blobs, §7.3), so merge the trees the way jj's rebase does:
4089− // MergedTree::merge flattens conflicted inputs into one merge, simplifies
4090− // it, and resolves file contents line-level with tree_merge::merge_trees,
4091− // keeping unresolvable paths as conflicts. Empty labels leave the result
4092− // unlabeled; the labels would not survive tree_to_files anyway.
4093− let merged = MergedTree::merge(Merge::from_removes_adds(
4094− vec![(from_tree.clone(), String::new())],
4095− vec![(onto_tree.clone(), String::new()), (to_tree.clone(), String::new())],
4096− ))
4097− .await
4098− .map_err(|e| Crash::new(format!("replay: {}", e)))?;
4099− refuse_file_directory_clash(&merged, &onto_tree, &from_tree, &to_tree).await?;
4100− // Reusing a resolved tree's list preserves replay's sorted output. A
4101− // constructed list may have another order, and conflicts may simplify
4102− // on reading, so neither can take this shortcut.
4103− for (files, tree, canonical) in [
4104− (onto, &onto_tree, onto_canonical),
4105− (to, &to_tree, to_canonical),
4106− (from, &from_tree, from_canonical),
4107− ] {
4108− if canonical && merged.tree_ids() == tree.tree_ids() {
4109− return Ok(files.clone());
4110− }
4111− }
1813+ onto: &[Value],
1814+ from: &[Value],
1815+ to: &[Value],
1816+) -> Result<Vec<Value>, Crash> {
1817+ let onto_tree = build_tree(store, &Value::list(onto.to_vec())).await?;
1818+ let from_tree = build_tree(store, &Value::list(from.to_vec())).await?;
1819+ let to_tree = build_tree(store, &Value::list(to.to_vec())).await?;
1820+ let merge = Merge::from_removes_adds(
1821+ vec![from_tree.tree_ids().as_resolved().cloned().unwrap()],
1822+ vec![
1823+ onto_tree.tree_ids().as_resolved().cloned().unwrap(),
1824+ to_tree.tree_ids().as_resolved().cloned().unwrap(),
1825+ ],
1826+ );
1827+ // tree_merge::merge_trees resolves file contents line-level and keeps
1828+ // unresolvable paths as conflicts (tree_merge.rs:78)
1829+ let merged = jj_lib::tree_merge::merge_trees(store, merge)
1830+ .await
1831+ .map_err(|e| Crash::new(format!("replay: {}", e)))?;
41121832 // conflicts stay as unresolved blobs (§7.3)
4113− cache.files_of(store, &merged).await
4114−}
4115−
4116−/// Crash if the merge left a conflict with a file on one side and, on
4117−/// another, a directory `onto` or `to` holds at that path: one entry cannot
4118−/// list that directory's entries (§7.3). A conflict that `onto`, `from` or
4119−/// `to` already holds at the path is not made here: its sides, a directory
4120−/// side jj made earlier included, came in as an unresolved blob, and replay
4121−/// carries them beside the others, as when the child resolving that conflict
4122−/// is squashed into it.
4123−async fn refuse_file_directory_clash(
4124− merged: &MergedTree,
4125− onto: &MergedTree,
4126− from: &MergedTree,
4127− to: &MergedTree,
4128−) -> Result<(), Crash> {
4129− let value_at = async |tree: &MergedTree, path: &RepoPath| {
4130− tree.path_value(path).await.map_err(|e| Crash::new(format!("replay: {}", e)))
4131− };
4132− // walks only the unresolved part of the tree: nothing when it is resolved
4133− for (path, value) in merged.conflicts() {
4134− let value = value.map_err(|e| Crash::new(format!("replay: {}", e)))?;
4135− // sides that cancel are no sides
4136− let value = value.simplify();
4137− let has = |dir: bool| {
4138− value
4139− .adds()
4140− .any(|v| matches!(v, Some(t) if matches!(t, TreeValue::Tree(_)) == dir))
4141− };
4142− if !has(false) || !has(true) {
4143− continue;
4144− }
4145− let held = [
4146− value_at(onto, &path).await?,
4147− value_at(from, &path).await?,
4148− value_at(to, &path).await?,
4149− ];
4150− // an input's conflict at the path, as opposed to one inside the
4151− // directory there, which leaves the path a directory in the input
4152− if held.iter().any(|h| !h.is_resolved() && !h.is_tree()) {
4153− continue;
4154− }
4155− let side = |dir: &MergedTreeValueT| {
4156− dir.is_tree() && value.adds().any(|v| v.is_some() && dir.iter().any(|h| h == v))
4157− };
4158− if side(&held[0]) || side(&held[2]) {
4159− let comps: Vec<String> =
4160− path.components().map(|c| c.as_internal_str().to_string()).collect();
4161− return Err(crate::domain::file_directory_clash(&comps));
4162− }
4163− }
4164− Ok(())
1833+ let merged = MergedTree::new(store.clone(), merged, jj_lib::conflict_labels::ConflictLabels::unlabeled());
1834+ tree_to_files(store, &merged, &BlobCache::default(), &EntryCache::default()).await
41651835}
41661836
41671837// ----------------------------------------------------------------------
@@ -4182,107 +1852,6 @@ impl ConflictTreeValue {
41821852 }
41831853}
41841854
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−
42861855async fn build_tree(store: &Arc<Store>, files: &Value) -> Result<MergedTree, Crash> {
42871856 let entries = files
42881857 .as_list()
@@ -4342,41 +1911,19 @@ async fn blob_to_tree_value(store: &Arc<Store>, content: &Value) -> Result<Merge
43421911 return Err(Crash::new("persistence: malformed conflict blob"));
43431912 }
43441913 // jj order: [add, (remove, add)*] → Merge with interleaved adds
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
1914+ // and removes
43481915 let mut values: Vec<Option<TreeValue>> = Vec::new();
43491916 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 {
4368− let mut slice: &[u8] = side.bytes.as_ref();
4369− let id = store
4370− .write_file(&RepoPathBuf::root(), &mut slice)
4371− .await
4372− .map_err(|e| Crash::new(format!("cannot write a file: {}", e)))?;
4373− TreeValue::File {
4374− id,
4375− executable: side.kind == BlobKind::Executable,
4376− copy_id: CopyId::placeholder(),
4377− }
4378− };
4379− values.push(Some(value));
1917+ let mut slice: &[u8] = side.as_ref();
1918+ let id = store
1919+ .write_file(&RepoPathBuf::root(), &mut slice)
1920+ .await
1921+ .map_err(|e| Crash::new(format!("cannot write a file: {}", e)))?;
1922+ values.push(Some(TreeValue::File {
1923+ id,
1924+ executable: b.kind == BlobKind::Executable,
1925+ copy_id: CopyId::placeholder(),
1926+ }));
43801927 }
43811928 let mut adds = Vec::new();
43821929 let mut removes = Vec::new();
@@ -4406,49 +1953,34 @@ fn to_repo_path(path: &[String]) -> Result<RepoPathBuf, Crash> {
44061953// jj trees -> values
44071954// ----------------------------------------------------------------------
44081955
4409−/// Blob contents keyed by file id, shared while snapshots hold their bytes.
4410−/// Weak entries let discarded replay results release their conflict sides.
1956+/// blob contents keyed by file id, shared across the whole repo load: most
1957+/// files are unchanged between commits, so inflating each blob once (instead
1958+/// of once per commit that references it) keeps loading linear in the
1959+/// repository's unique data rather than commits × files
44111960#[derive(Clone, Default)]
4412−struct BlobCache {
4413− bytes: Rc<RefCell<HashMap<FileId, Weak<Vec<u8>>>>>,
4414− next_sweep: Rc<Cell<usize>>,
4415−}
1961+struct BlobCache(Rc<RefCell<HashMap<FileId, Rc<Vec<u8>>>>>);
44161962
44171963impl BlobCache {
44181964 fn get(&self, id: &FileId) -> Option<Rc<Vec<u8>>> {
4419− self.bytes.borrow().get(id).and_then(Weak::upgrade)
1965+ self.0.borrow().get(id).cloned()
44201966 }
44211967 fn insert(&self, id: FileId, bytes: Rc<Vec<u8>>) {
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− }
1968+ self.0.borrow_mut().insert(id, bytes);
44281969 }
44291970}
44301971
4431−/// Whole file entries keyed by (path, blob identity), shared by loaded
4432−/// snapshots and replays. Weak references let transient replay results go
4433−/// away while unchanged entries still held by a snapshot are reused.
1972+/// whole file *entries* ({path, content}) keyed by (path, blob identity),
1973+/// shared across the whole repo load: an unchanged file is the same Value in
1974+/// every commit, so it is built once and cloned (an Rc bump) thereafter
44341975#[derive(Clone, Default)]
4435−struct EntryCache {
4436− entries: Rc<RefCell<HashMap<String, Weak<crate::value::RecordMap>>>>,
4437− next_sweep: Rc<Cell<usize>>,
4438−}
1976+struct EntryCache(Rc<RefCell<HashMap<String, Value>>>);
44391977
44401978impl EntryCache {
44411979 fn get(&self, key: &str) -> Option<Value> {
4442− self.entries.borrow().get(key).and_then(Weak::upgrade).map(Value::Record)
1980+ self.0.borrow().get(key).cloned()
44431981 }
44441982 fn insert(&self, key: String, entry: Value) {
4445− let Value::Record(record) = entry else { return };
4446− let mut entries = self.entries.borrow_mut();
4447− entries.insert(key, Rc::downgrade(&record));
4448− if entries.len() >= self.next_sweep.get().max(256) {
4449− entries.retain(|_, entry| entry.strong_count() != 0);
4450− self.next_sweep.set((entries.len() * 2).max(256));
4451− }
1983+ self.0.borrow_mut().insert(key, entry);
44521984 }
44531985}
44541986
@@ -4476,64 +2008,20 @@ async fn read_file_bytes(
44762008 Ok(buf)
44772009}
44782010
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(
2011+async fn side_to_bytes(
44822012 store: &Arc<Store>,
44832013 path: &RepoPath,
44842014 v: &Option<TreeValue>,
44852015 cache: &BlobCache,
4486−) -> Result<Option<ConflictSide>, Crash> {
2016+) -> Result<Rc<Vec<u8>>, Crash> {
44872017 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− },
4495− bytes: read_file_bytes(store, path, id, cache).await?,
4496− tree: None,
4497− })),
4498− Some(TreeValue::Symlink(id)) => {
4499− let target = store
4500− .read_symlink(path, id)
4501− .await
4502− .map_err(|e| Crash::new(format!("cannot read a symlink: {}", e)))?;
4503− Ok(Some(ConflictSide {
4504− kind: BlobKind::Symlink,
4505− bytes: Rc::new(target.into_bytes()),
4506− tree: None,
4507− }))
4508− }
4509− // a directory side, in a conflict between a file and a directory,
4510− // reads as empty content but keeps its tree, so writing the conflict
4511− // back cannot put an empty file where the directory was
4512− Some(TreeValue::Tree(id)) => Ok(Some(ConflictSide {
4513− kind: BlobKind::Regular,
4514− bytes: Rc::new(Vec::new()),
4515− tree: Some(id.hex()),
4516− })),
4517− // a submodule side reads as an empty file, as a resolved one does in
4518− // tree_value_to_blob (§12)
4519− Some(TreeValue::GitSubmodule(_)) => Ok(Some(ConflictSide::regular(&[]))),
4520− }
4521−}
4522−
4523−/// The value a list reads where a tree holds `value` (§7.3): a conflict less
4524−/// each pair of sides that cancel, one added and one removed the same, as jj
4525−/// simplifies a file conflict to write it out. jj keeps every term of a
4526−/// merge it cannot resolve, pairs that cancel included, so a replayed
4527−/// commit's conflict inherited from its parent was not the parent's, and
4528−/// each commit a rebase replayed above took two more sides for it. Where
4529−/// only directory sides would be left, the value is kept whole: jj keeps it
4530−/// as one conflict at the path, and the simplified one would be a conflict
4531−/// between directories, which jj merges entry by entry. Two values read
4532−/// the same are the same entry of a list.
4533−fn value_as_read(value: &MergedTreeValueT) -> std::borrow::Cow<'_, MergedTreeValueT> {
4534− match (!value.is_resolved()).then(|| value.simplify()).filter(|v| !v.is_tree()) {
4535− Some(simplified) => std::borrow::Cow::Owned(simplified),
4536− None => std::borrow::Cow::Borrowed(value),
2018+ Some(TreeValue::File { id, .. }) => read_file_bytes(store, path, id, cache).await,
2019+ Some(TreeValue::Symlink(id)) => store
2020+ .read_symlink(path, id)
2021+ .await
2022+ .map(|s| Rc::new(s.into_bytes()))
2023+ .map_err(|e| Crash::new(format!("cannot read a symlink: {}", e))),
2024+ _ => Ok(Rc::new(Vec::new())),
45372025 }
45382026}
45392027
@@ -4543,9 +2031,6 @@ async fn merged_value_to_blob(
45432031 value: &jj_lib::backend::MergedTreeValue,
45442032 cache: &BlobCache,
45452033) -> 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();
45492034 if let Some(v) = value.as_resolved() {
45502035 return match v {
45512036 Some(tv) => tree_value_to_blob(store, path, tv).await,
@@ -4556,11 +2041,11 @@ async fn merged_value_to_blob(
45562041 };
45572042 }
45582043 // conflict sides, jj order: [add, (remove, add)*] (§7.3)
4559− let mut sides: Vec<Option<ConflictSide>> = Vec::new();
2044+ let mut sides: Vec<Rc<Vec<u8>>> = Vec::new();
45602045 for (i, add) in value.adds().enumerate() {
4561− sides.push(conflict_side(store, path, add, cache).await?);
2046+ sides.push(side_to_bytes(store, path, add, cache).await?);
45622047 if let Some(remove) = value.get_remove(i) {
4563− sides.push(conflict_side(store, path, remove, cache).await?);
2048+ sides.push(side_to_bytes(store, path, remove, cache).await?);
45642049 }
45652050 }
45662051 Ok(Value::Blob(Rc::new(BlobVal {
@@ -4612,9 +2097,8 @@ async fn tree_value_to_blob(
46122097 content: BlobContent::Resolved(Rc::new(target.into_bytes())),
46132098 })))
46142099 }
4615− // MergedTree::entries recurses into a resolved directory, and a
4616− // directory side of a conflict goes through conflict_side, so this is
4617− // a submodule, which §12 puts out of scope
2100+ // a conflicted directory is emitted without its children by
2101+ // MergedTree::entries; §12 puts such repairs out of scope
46182102 TreeValue::Tree(_) | TreeValue::GitSubmodule(_) => Ok(Value::Blob(Rc::new(BlobVal {
46192103 kind: BlobKind::Regular,
46202104 content: BlobContent::Resolved(Rc::new(Vec::new())),
@@ -4628,27 +2112,9 @@ async fn tree_to_files(
46282112 cache: &BlobCache,
46292113 entries: &EntryCache,
46302114) -> 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> {
46432115 let mut out = Vec::new();
4644− let mut reusable = tree.tree_ids().is_resolved();
46452116 for (path, value) in tree.entries() {
46462117 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− };
46522118 // cache key: the entry is fully determined by its path and the
46532119 // identity of its (resolved) content; conflicts are not cached
46542120 let key = match value.as_resolved() {
@@ -4679,22 +2145,13 @@ async fn tree_to_files_inner(
46792145 };
46802146 out.push(entry);
46812147 }
4682− Ok((out, reusable))
2148+ Ok(out)
46832149}
46842150
46852151// ----------------------------------------------------------------------
46862152// value helpers
46872153// ----------------------------------------------------------------------
46882154
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−
46982155fn commit_id_of(commit: &Value) -> Result<String, Crash> {
46992156 match commit.field("id")? {
47002157 Value::Id(i) => Ok(i.to_string()),
@@ -4722,13 +2179,15 @@ fn new_id_set(repo: &Value) -> Result<BTreeSet<String>, Crash> {
47222179// building the Repo value from the visible map (§7.2)
47232180// ----------------------------------------------------------------------
47242181
4725−fn build_repo_value(vis: &VisibleRepo, focus_change: &str, replay_cache: &Rc<ReplayCache>) -> Result<Value, OpenError> {
2182+fn build_repo_value(vis: &VisibleRepo, focus_change: &str) -> Result<Value, OpenError> {
47262183 let root_rec = vis
47272184 .commits
47282185 .get(ROOT_ID)
47292186 .ok_or_else(|| (2, "no root commit".to_string()))?;
47302187 let store = root_rec.commit.store().clone();
4731− let mut subtree = build_subtree(vis, root_rec, &store, replay_cache)?;
2188+ let cache = BlobCache::default();
2189+ let entries = EntryCache::default();
2190+ let mut subtree = build_subtree(vis, root_rec, &store, &cache, &entries)?;
47322191 sort_subtree(&mut subtree, vis);
47332192 let top = Value::record(&[
47342193 ("children", subtree.field("children").map_err(|e| (2, e.msg))?),
@@ -4745,20 +2204,19 @@ fn build_subtree(
47452204 vis: &VisibleRepo,
47462205 rec: &StoredCommit,
47472206 store: &Arc<Store>,
4748− replay_cache: &Rc<ReplayCache>,
2207+ cache: &BlobCache,
2208+ entries: &EntryCache,
47492209) -> Result<Value, OpenError> {
47502210 // the files list is lazy (§7.2): most commits are never inspected, so
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)
2211+ // their file entries are materialized only on first `field("files")`
47552212 let files_thunk = {
47562213 let store = store.clone();
47572214 let tree = rec.commit.tree();
4758− let name = Some(tree_name(&tree));
4759− let replay_cache = replay_cache.clone();
4760− Value::Thunk(Rc::new(crate::value::ThunkVal::stored(rec.change_id.clone(), name, move || {
4761− block_on(replay_cache.files_of(&store, &tree))
2215+ let cache = cache.clone();
2216+ let entries = entries.clone();
2217+ Value::Thunk(Rc::new(crate::value::ThunkVal::new(move || {
2218+ let files = block_on(tree_to_files(&store, &tree, &cache, &entries))?;
2219+ Ok(Value::list(files))
47622220 })))
47632221 };
47642222 let labels: Vec<Value> = vis
@@ -4779,7 +2237,7 @@ fn build_subtree(
47792237 if let Some(kids) = vis.children.get(&rec.change_id) {
47802238 for kid in kids {
47812239 if let Some(krec) = vis.commits.get(kid) {
4782− children.push(build_subtree(vis, krec, store, replay_cache)?);
2240+ children.push(build_subtree(vis, krec, store, cache, entries)?);
47832241 }
47842242 }
47852243 }
@@ -4814,165 +2272,3 @@ fn sort_subtree(subtree: &mut Value, vis: &VisibleRepo) {
48142272 let root = subtree.field("root").expect("subtree has a root");
48152273 *subtree = Value::record(&[("children", Value::list(kids)), ("root", root)]);
48162274}
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+22−36
@@ -45,9 +45,6 @@ pub struct SpTok {
4545 pub tok: Tok,
4646 pub line: usize, // 1-based
4747 pub col: usize, // 1-based
48− /// byte offsets of the token in the source: `start..end`
49− pub start: usize,
50− pub end: usize,
5148}
5249
5350#[derive(Debug)]
@@ -87,13 +84,6 @@ fn valid_label(s: &str) -> bool {
8784pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
8885 let mut toks: Vec<SpTok> = Vec::new();
8986 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();
9787 let mut i = 0usize;
9888 let mut line = 1usize;
9989 let mut col = 1usize; // column of next char
@@ -107,15 +97,13 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
10797 };
10898 }
10999
110− // push helper; `$s..$e` are the token's char indices
100+ // push helper
111101 macro_rules! push {
112− ($t:expr, $l:expr, $c:expr, $s:expr, $e:expr) => {
102+ ($t:expr, $l:expr, $c:expr) => {
113103 toks.push(SpTok {
114104 tok: $t,
115105 line: $l,
116106 col: $c,
117− start: bytes[$s],
118− end: bytes[$e],
119107 })
120108 };
121109 }
@@ -123,7 +111,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
123111 while i < chars.len() {
124112 let c = chars[i];
125113 if c == '\n' {
126− push!(Tok::Newline, line, col, i, i + 1);
114+ push!(Tok::Newline, line, col);
127115 i += 1;
128116 line += 1;
129117 col = 1;
@@ -136,7 +124,6 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
136124 }
137125 let start_line = line;
138126 let start_col = col;
139− let start = i;
140127 // comments
141128 if c == '-' && i + 1 < chars.len() && chars[i + 1] == '-' {
142129 // could be `--` comment, but `->`? no: `->` is dash-gt, distinct.
@@ -160,7 +147,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
160147 i += 2;
161148 col += 2;
162149 } else if chars[i] == '\n' {
163− push!(Tok::Newline, line, col, i, i + 1);
150+ push!(Tok::Newline, line, col);
164151 i += 1;
165152 line += 1;
166153 col = 1;
@@ -172,14 +159,14 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
172159 if depth > 0 {
173160 err!("unterminated block comment");
174161 }
175− // a comment is whitespace (§3.1): one spanning lines has already
176− // ended the line it started on, with the Newlines pushed above,
177− // and one that does not must leave its line unbroken
162+ // a comment spanning lines must not leave later tokens looking
163+ // like continuations of the line it started on
164+ push!(Tok::Newline, line, col);
178165 continue;
179166 }
180167 // arrow `->` (check before operator `-`)
181168 if c == '-' && i + 1 < chars.len() && chars[i + 1] == '>' {
182− push!(Tok::Arrow, start_line, start_col, i, i + 2);
169+ push!(Tok::Arrow, start_line, start_col);
183170 i += 2;
184171 col += 2;
185172 continue;
@@ -215,7 +202,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
215202 if !comp.is_empty() {
216203 comps.push(comp);
217204 }
218− push!(Tok::PathLit(comps), start_line, start_col, start, i);
205+ push!(Tok::PathLit(comps), start_line, start_col);
219206 continue;
220207 }
221208 // selector: `.` immediately followed by identifier-start
@@ -228,13 +215,13 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
228215 i += 1;
229216 col += 1;
230217 }
231− push!(Tok::Selector(name), start_line, start_col, start, i);
218+ push!(Tok::Selector(name), start_line, start_col);
232219 continue;
233220 }
234221 // `@` id literals
235222 if c == '@' {
236223 if i + 1 >= chars.len() || !is_ident_char(chars[i + 1]) {
237− push!(Tok::NewId, start_line, start_col, i, i + 1);
224+ push!(Tok::NewId, start_line, start_col);
238225 i += 1;
239226 col += 1;
240227 continue;
@@ -252,7 +239,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
252239 if i < chars.len() && is_ident_char(chars[i]) {
253240 err!("invalid id literal");
254241 }
255− push!(Tok::IdLit(s), start_line, start_col, start, i);
242+ push!(Tok::IdLit(s), start_line, start_col);
256243 continue;
257244 }
258245 err!("`@` followed by an identifier character outside k-z is not a valid id literal");
@@ -275,7 +262,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
275262 if !valid_label(&s) {
276263 err!("invalid label literal");
277264 }
278− push!(Tok::LabelLit(s), start_line, start_col, start, i);
265+ push!(Tok::LabelLit(s), start_line, start_col);
279266 continue;
280267 }
281268 // text literal, double- or single-quoted
@@ -321,7 +308,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
321308 }
322309 }
323310 }
324− push!(Tok::Text(s), start_line, start_col, start, i);
311+ push!(Tok::Text(s), start_line, start_col);
325312 continue;
326313 }
327314 // integer
@@ -332,7 +319,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
332319 i += 1;
333320 col += 1;
334321 }
335− push!(Tok::Int(s.parse::<BigInt>().unwrap()), start_line, start_col, start, i);
322+ push!(Tok::Int(s.parse::<BigInt>().unwrap()), start_line, start_col);
336323 continue;
337324 }
338325 // identifier / keyword
@@ -361,7 +348,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
361348 }
362349 _ => Tok::Ident(s),
363350 };
364− push!(t, start_line, start_col, start, i);
351+ push!(t, start_line, start_col);
365352 continue;
366353 }
367354 // type name
@@ -377,7 +364,7 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
377364 break;
378365 }
379366 }
380− push!(Tok::TypeName(s), start_line, start_col, start, i);
367+ push!(Tok::TypeName(s), start_line, start_col);
381368 continue;
382369 }
383370 // symbolic operators: longest match
@@ -390,17 +377,16 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
390377 }
391378 }
392379 if matched.is_none() {
393− // compare by character: `rest` is built from chars, so slicing it
394− // by byte would panic on any non-ASCII character reaching here
380+ let one = &rest[..1];
395381 for op in SYMBOLIC_OPS {
396− if op.len() == 1 && op.starts_with(c) {
382+ if op.len() == 1 && *op == one {
397383 matched = Some(op);
398384 break;
399385 }
400386 }
401387 }
402388 if let Some(op) = matched {
403− push!(Tok::Op(op), start_line, start_col, i, i + op.len());
389+ push!(Tok::Op(op), start_line, start_col);
404390 i += op.len();
405391 col += op.len();
406392 continue;
@@ -420,11 +406,11 @@ pub fn lex(src: &str) -> Result<Vec<SpTok>, LexError> {
420406 '=' => Tok::Equals,
421407 _ => err!(format!("unexpected character `{}`", c)),
422408 };
423− push!(t, start_line, start_col, i, i + 1);
409+ push!(t, start_line, start_col);
424410 i += 1;
425411 col += 1;
426412 }
427− push!(Tok::Eof, line, col, i, i);
413+ push!(Tok::Eof, line, col);
428414 Ok(toks)
429415}
430416
src/lib.rs−14
@@ -1,28 +1,14 @@
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−
121pub mod ast;
132pub mod builtins;
143pub mod config;
15−pub mod diagnostics;
164pub mod domain;
175pub mod eval;
186pub mod lex;
19−mod lookup;
207pub mod parse;
218pub mod render;
229pub mod repo;
2310pub mod shape;
2411pub mod show;
25−pub mod terminal;
2612pub mod value;
2713
2814pub mod jj;
src/lookup.rsdeleted−78
@@ -1,78 +0,0 @@
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+141−465
@@ -1,10 +1,7 @@
11//! j — a functional-programming-centered CLI for jj repositories (§1).
22
3−mod help;
4−mod setup;
5−
63use j::config::{self, Config};
7−use j::diagnostics;
4+use j::eval::Interp;
85use j::jj::JjBackend;
96use j::parse::parse_expr;
107use j::value::{Crash, Value};
@@ -13,47 +10,20 @@ use std::io::{IsTerminal, Read};
1310use std::process::ExitCode;
1411use std::rc::Rc;
1512
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";
13+const USAGE: &str = "usage: j EXPRESSION… (or echo EXPRESSION | j)";
2314
2415fn err(status: u8, msg: impl std::fmt::Display) -> ExitCode {
25− j::terminal::report_error(format_args!("j: {}", msg));
16+ eprintln!("j: {}", msg);
2617 ExitCode::from(status)
2718}
2819
29−/// Report a failure that ends the process, keeping the status as a number so
30−/// callers do not have to recover it from an `ExitCode`.
31−fn fail(status: u8, msg: impl std::fmt::Display) -> u8 {
32− j::terminal::report_error(format_args!("j: {}", msg));
33− status
34−}
35−
36−/// Write a run's output to stdout (§1.2.9, `ops`). A reader that stops early
37−/// (`j tree | head`) closes the pipe; that is not a failure (§1.4), so the
38−/// rest is dropped and the run still succeeds. Any other write failure is an
39−/// error line. `print!` would panic on either.
40−fn print_output(s: &str) -> ExitCode {
41− let mut out = std::io::stdout().lock();
42− match j::terminal::write_output(&mut out, s) {
43− Ok(()) => ExitCode::SUCCESS,
44− Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
45− Err(e) => err(1, format!("cannot write output: {}", e)),
46− }
47−}
48−
49−fn crash_err(c: &Crash, expr_text: &str, cfg: &Config) -> ExitCode {
50− j::terminal::report_error(format_args!("j: crash: {}", c.msg));
51− match &c.def {
52− Some(d) => j::report!(" in {}, from {}", d, expr_text),
53− None => j::report!(" from {}", expr_text),
54− }
55− if let Some(hint) = diagnostics::expression_hint(expr_text, cfg, c) {
56− j::report!(" {}", hint);
20+fn crash_err(c: &Crash, expr_text: Option<&str>) -> ExitCode {
21+ eprintln!("j: crash: {}", c.msg);
22+ match (&c.def, expr_text) {
23+ (Some(d), Some(e)) => eprintln!(" in {}, from {}", d, e),
24+ (None, Some(e)) => eprintln!(" from {}", e),
25+ (Some(d), None) => eprintln!(" in {}", d),
26+ (None, None) => {}
5727 }
5828 ExitCode::from(1)
5929}
@@ -72,371 +42,184 @@ fn config_path() -> String {
7242/// installed (the share/j/config.j file may not exist next to the binary).
7343const DEFAULT_CONFIG: &str = include_str!("../config.j");
7444
75−fn load_config_file() -> Result<(Config, String), u8> {
45+fn load_config_file() -> Result<(Config, String), ExitCode> {
7646 let path = config_path();
7747 let src = match std::fs::read_to_string(&path) {
7848 Ok(s) => s,
79− Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
49+ Err(_) => {
8050 // No user config yet: materialise the default there, editable by
8151 // the current user, so they have a starting point to customise.
82− match write_default_config(&path) {
83− Ok(()) => j::report!("j: created an editable default config at {}", path),
84− // Another invocation may have created it after our read.
85− // Read that file below, without overwriting it.
86− Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
87− Err(e) => return Err(fail(3, format!("cannot create config at {}: {}", path, e))),
52+ if let Err(e) = write_default_config(&path) {
53+ return Err(err(3, format!("cannot create config at {}: {}", path, e)));
8854 }
55+ eprintln!("j: created an editable default config at {}", path);
8956 match std::fs::read_to_string(&path) {
9057 Ok(s) => s,
91− Err(e) => return Err(fail(3, format!("cannot read config at {}: {}", path, e))),
58+ Err(e) => return Err(err(3, format!("cannot read config at {}: {}", path, e))),
9259 }
9360 }
94− Err(e) => return Err(fail(3, format!("cannot read config at {}: {}", path, e))),
9561 };
9662 match config::load_config(&src) {
9763 Ok(c) => Ok((c, src)),
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− }
64+ Err(e) => Err(err(3, e.message())),
10765 }
10866}
10967
11068/// Write the embedded default config to `path`, creating parent directories
11169/// and making the file readable and writable by the current user.
11270fn write_default_config(path: &str) -> std::io::Result<()> {
113− use std::io::Write;
11471 if let Some(parent) = std::path::Path::new(path).parent() {
11572 std::fs::create_dir_all(parent)?;
11673 }
117− let mut options = std::fs::OpenOptions::new();
118− options.write(true).create_new(true);
74+ std::fs::write(path, DEFAULT_CONFIG)?;
11975 // user-read+write (0o600 on unix): the file is meant to be edited by them
12076 #[cfg(unix)]
12177 {
122− use std::os::unix::fs::OpenOptionsExt;
123− options.mode(0o600);
124− }
125− options.open(path)?.write_all(DEFAULT_CONFIG.as_bytes())
126−}
127−
128−const RESERVED: &[&str] = &[
129− "init", "clone", "remote", "fetch", "push", "undo", "redo", "ops", "help", "commands", "setup",
130−];
131−
132−/// Discovery reads the user's vocabulary without creating or evaluating it.
133−/// A missing file uses the embedded reference; other read errors stay visible.
134−fn read_help_config() -> Result<(String, String), u8> {
135− let path = config_path();
136− match std::fs::read_to_string(&path) {
137− Ok(src) => Ok((src, path)),
138− Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok((
139− DEFAULT_CONFIG.to_string(),
140− format!("embedded default config.j (no user config at {})", path),
141− )),
142− Err(e) => Err(fail(3, format!("cannot read config at {}: {}", path, e))),
78+ use std::os::unix::fs::PermissionsExt;
79+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
14380 }
81+ Ok(())
14482}
14583
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−}
84+const RESERVED: &[&str] = &["init", "clone", "remote", "fetch", "push", "undo", "redo", "ops"];
21485
21586fn main() -> ExitCode {
21687 // evaluation builds deep continuation chains (trampolined, but the final
21788 // continuation still tears down by recursive Drop), so run the real work
21889 // on a thread with a large stack to keep very deep histories safe
219− let code = std::thread::Builder::new()
90+ std::thread::Builder::new()
22091 .stack_size(512 * 1024 * 1024)
22192 .spawn(run)
22293 .expect("failed to spawn worker thread")
22394 .join()
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 }
95+ .expect("worker thread panicked")
24196}
24297
24398fn 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)
246− let args: Vec<String> = match std::env::args_os()
247− .skip(1)
248− .map(|a| a.into_string())
249− .collect()
250− {
251− Ok(args) => args,
252− Err(a) => return err(2, format!("argument is not valid UTF-8: {:?}", a)),
253− };
254− // §1: stdin vs arguments. With no arguments the expression comes from
255− // stdin, so reading to end-of-input is the whole point. With arguments,
256− // stdin is only read to report the "both" error — and reading it blindly
257− // hangs whenever the process inherited a pipe nobody is writing to (a
258− // shell loop, a CI step), so wait only briefly for input to appear.
259− // One byte already proves the "both" error: do not wait for the pipe's
260− // writer to close it. Bytes that are not UTF-8 are input too, and an
261− // error only once they are the expression.
262− let mut stdin_content = Vec::new();
99+ let args: Vec<String> = std::env::args().skip(1).collect();
100+ // §1: stdin vs arguments
101+ let mut stdin_content = String::new();
263102 let stdin_has_data = {
264103 if std::io::stdin().is_terminal() {
265104 false
266− } else if args.is_empty() || stdin_is_ready(50) {
267− let limit = if args.is_empty() { u64::MAX } else { 1 };
268− match std::io::stdin().take(limit).read_to_end(&mut stdin_content) {
105+ } else {
106+ match std::io::stdin().read_to_string(&mut stdin_content) {
269107 Ok(n) => n > 0,
270108 Err(_) => false,
271109 }
272− } else {
273− false
274110 }
275111 };
276112 if stdin_has_data && !args.is_empty() {
277113 return err(2, "expression given both as arguments and on stdin");
278114 }
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− }
289115 let text = if stdin_has_data {
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− }
116+ stdin_content
294117 } else if !args.is_empty() {
295118 args.join(" ")
296119 } else {
297− j::report!("{}", USAGE);
120+ eprintln!("{}", USAGE);
298121 return ExitCode::from(2);
299122 };
300123
301124 // §1.1: reserved commands
302125 let trimmed = text.trim();
303− if trimmed.is_empty() {
304− j::report!("{}", USAGE);
305− return ExitCode::from(2);
306− }
307126 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− }
318127 if RESERVED.contains(&first) {
319− return run_reserved(first, trimmed, &words);
128+ return run_reserved(first, trimmed);
320129 }
321130
322131 run_expression(trimmed, true)
323132}
324133
325−fn run_reserved(cmd: &str, text: &str, words: &[&str]) -> ExitCode {
134+fn run_reserved(cmd: &str, text: &str) -> ExitCode {
135+ let words: Vec<&str> = text.split_whitespace().collect();
326136 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− },
333137 "init" => {
334138 if words.len() != 1 {
335− return command_usage("init", "init");
139+ return err(2, "usage: j init");
336140 }
337141 let (cfg, _) = or_exit(load_config_file());
338− let identity = or_exit(setup_identity(&cfg));
339− command_result(
340− j::terminal::with_progress("Creating repository", || j::jj::cmd_init(&cfg)),
341− &format!("Repository created; identity {}", identity),
342− )
142+ match j::jj::cmd_init(&cfg) {
143+ Ok(()) => ExitCode::SUCCESS,
144+ Err(e) => err(e.0, e.1),
145+ }
343146 }
344147 "clone" => {
345148 if words.len() != 2 && words.len() != 3 {
346− return command_usage("clone URL [DIR]", "clone");
149+ return err(2, "usage: j clone URL [DIR]");
347150 }
151+ let (cfg, _) = or_exit(load_config_file());
348152 let dir = if words.len() == 3 {
349153 words[2].to_string()
350154 } else {
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− }
155+ default_clone_dir(words[1])
361156 };
362− let (cfg, _) = or_exit(load_config_file());
363− let identity = or_exit(setup_identity(&cfg));
364− command_result(
365− j::terminal::with_progress("Cloning repository", || j::jj::cmd_clone(&cfg, words[1], &dir)),
366− &format!("Repository cloned into {}; identity {}\n Next: cd {} && j status", dir, identity, shell_word(&dir)),
367− )
157+ match j::jj::cmd_clone(&cfg, words[1], &dir) {
158+ Ok(()) => ExitCode::SUCCESS,
159+ Err(e) => err(e.0, e.1),
160+ }
368161 }
369162 "remote" => {
370163 if words.len() != 2 {
371− return command_usage("remote URL", "remote");
164+ return err(2, "usage: j remote URL");
372165 }
373166 let backend = or_exit(open_repo());
374− command_result(
375− j::terminal::with_progress("Updating remote", || backend.cmd_remote(words[1])),
376− "Remote updated",
377− )
167+ match backend.cmd_remote(words[1]) {
168+ Ok(()) => ExitCode::SUCCESS,
169+ Err(e) => err(e.0, e.1),
170+ }
378171 }
379172 "fetch" => {
380173 if words.len() != 1 {
381− return command_usage("fetch", "fetch");
174+ return err(2, "usage: j fetch");
175+ }
176+ let (cfg, _) = or_exit(load_config_file());
177+ let backend = or_exit(open_repo_locked(&cfg));
178+ match backend.cmd_fetch() {
179+ Ok(()) => ExitCode::SUCCESS,
180+ Err(e) => err(e.0, e.1),
382181 }
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− )
388182 }
389183 "push" => {
390184 let rest = text.trim_start_matches("push").trim();
391185 if rest.is_empty() {
392− return command_usage("push EXPR", "push");
186+ return err(2, "usage: j push EXPR");
393187 }
394− let (mut cfg, _) = or_exit(load_config_file());
188+ let (cfg, _) = or_exit(load_config_file());
395189 let backend = or_exit(open_repo_locked(&cfg));
396190 // evaluate EXPR against the recorded repository (no snapshot)
397− match j::terminal::with_progress("Pushing changes", || backend.cmd_push(&mut cfg, rest)) {
398− Err((3, message)) if message.starts_with("config.j:") => {
399− err(3, format!("{}: {}\n See `j help config` for configuration help.", config_path(), message.trim_start_matches("config.j: ")))
400− }
401− Ok(outcome) => {
402− if outcome.published + outcome.deleted + outcome.unchanged == 0 {
403− j::report!("j: No bookmarks selected; nothing published.\n First publication: j 'push (label \"feature\" here)'\n Inspect available commits with `j tree` and check your selector.");
404− } else {
405− j::terminal::success(&format!(
406− "Push complete: {} published, {} deleted, {} unchanged bookmark(s)",
407− outcome.published, outcome.deleted, outcome.unchanged,
408− ));
409− }
410− ExitCode::SUCCESS
411− }
412− Err((code, message)) => err(code, message),
191+ match backend.cmd_push(&cfg, rest) {
192+ Ok(()) => ExitCode::SUCCESS,
193+ Err(e) => err(e.0, e.1),
413194 }
414195 }
415196 "undo" => {
416197 if words.len() != 1 {
417− return command_usage("undo", "undo");
198+ return err(2, "usage: j undo");
418199 }
419200 let backend = or_exit(open_repo_noconfig());
420− operation_result(
421− j::terminal::with_progress("Undoing operation", || backend.cmd_undo(false)),
422− )
201+ match backend.cmd_undo(false) {
202+ Ok(()) => ExitCode::SUCCESS,
203+ Err(e) => err(e.0, e.1),
204+ }
423205 }
424206 "redo" => {
425207 if words.len() != 1 {
426− return command_usage("redo", "redo");
208+ return err(2, "usage: j redo");
427209 }
428210 let backend = or_exit(open_repo_noconfig());
429− operation_result(
430− j::terminal::with_progress("Redoing operation", || backend.cmd_undo(true)),
431− )
211+ match backend.cmd_undo(true) {
212+ Ok(()) => ExitCode::SUCCESS,
213+ Err(e) => err(e.0, e.1),
214+ }
432215 }
433216 "ops" => {
434217 if words.len() != 1 {
435− return command_usage("ops", "ops");
218+ return err(2, "usage: j ops");
436219 }
437220 let backend = or_exit(open_repo_noconfig());
438− match j::terminal::with_progress("Loading operation log", || backend.cmd_ops()) {
439− Ok(log) => print_output(&log),
221+ match backend.cmd_ops() {
222+ Ok(()) => ExitCode::SUCCESS,
440223 Err(e) => err(e.0, e.1),
441224 }
442225 }
@@ -444,101 +227,101 @@ fn run_reserved(cmd: &str, text: &str, words: &[&str]) -> ExitCode {
444227 }
445228}
446229
447−/// `clone`'s default DIR (§1.1): the last component of `url`'s path, a
448−/// final `.git` component skipped, minus `.git`, as `git clone` names it;
449−/// in an scp-style `host:repo.git`, what follows the `:`. None when that
450−/// leaves no name.
451−fn default_clone_dir(url: &str) -> Option<String> {
452− let mut path = url.trim_end_matches('/');
453− if let Some(repo) = path.strip_suffix("/.git") {
454− path = repo.trim_end_matches('/');
455− }
456− let last = match path.rfind('/') {
457− Some(i) => &path[i + 1..],
458− None => path.rsplit(':').next().unwrap_or(path),
459− };
460− let name = last.strip_suffix(".git").unwrap_or(last);
461− match name {
462− "" | "." | ".." => None,
463− name => Some(name.to_string()),
464− }
230+fn default_clone_dir(url: &str) -> String {
231+ let last = url.trim_end_matches('/').rsplit('/').next().unwrap_or("repo");
232+ last.strip_suffix(".git").unwrap_or(last).to_string()
465233}
466234
467−/// A runnable shell word for literal clone destinations, including spaces and
468−/// quotes. Prefix a leading dash so `cd` does not interpret it as an option.
469−fn shell_word(path: &str) -> String {
470− let path = if path.starts_with('-') { format!("./{}", path) } else { path.to_string() };
471− format!("'{}'", path.replace('\'', "'\\''"))
472−}
473−
474−fn or_exit<T>(r: Result<T, u8>) -> T {
235+fn or_exit<T>(r: Result<T, ExitCode>) -> T {
475236 match r {
476237 Ok(v) => v,
477− Err(code) => std::process::exit(code as i32),
238+ Err(c) => std::process::exit(exit_code_number(c)),
239+ }
240+}
241+
242+/// ExitCode doesn't expose its value; track it alongside.
243+fn exit_code_number(c: ExitCode) -> i32 {
244+ // ExitCode's Debug is "ExitCode(unix_exit_status(N))" on unix
245+ let s = format!("{:?}", c);
246+ for tok in s.split(|ch: char| !ch.is_ascii_digit()) {
247+ if !tok.is_empty() {
248+ if let Ok(n) = tok.parse() {
249+ return n;
250+ }
251+ }
478252 }
253+ 1
479254}
480255
481−fn open_repo() -> Result<Rc<JjBackend>, u8> {
256+fn open_repo() -> Result<Rc<JjBackend>, ExitCode> {
482257 match JjBackend::open(None) {
483258 Ok(b) => Ok(Rc::new(b)),
484− Err(e) => Err(fail(e.0, e.1)),
259+ Err(e) => Err(err(e.0, e.1)),
485260 }
486261}
487262
488−fn open_repo_noconfig() -> Result<Rc<JjBackend>, u8> {
263+fn open_repo_noconfig() -> Result<Rc<JjBackend>, ExitCode> {
489264 // undo/redo/ops do not read the config (§6.1), but take the lock
490265 match JjBackend::open(None) {
491266 Ok(b) => {
492267 b.take_lock();
493268 Ok(Rc::new(b))
494269 }
495− Err(e) => Err(fail(e.0, e.1)),
270+ Err(e) => Err(err(e.0, e.1)),
496271 }
497272}
498273
499−fn open_repo_locked(cfg: &Config) -> Result<Rc<JjBackend>, u8> {
274+fn open_repo_locked(cfg: &Config) -> Result<Rc<JjBackend>, ExitCode> {
500275 match JjBackend::open(Some(cfg)) {
501276 Ok(b) => {
502277 b.take_lock();
503278 Ok(Rc::new(b))
504279 }
505− Err(e) => Err(fail(e.0, e.1)),
280+ Err(e) => Err(err(e.0, e.1)),
506281 }
507282}
508283
509284fn run_expression(text: &str, snapshot: bool) -> ExitCode {
510285 // §1.2.1: locate the repository
511− let (mut cfg, _cfg_src) = or_exit(load_config_file());
286+ let (cfg, _cfg_src) = or_exit(load_config_file());
512287 let backend = or_exit(open_repo_locked(&cfg));
513288
514289 // §1.2.2: parse the expression
515290 let outer = Rc::new(cfg.global_names.clone());
516291 let mut expr = match parse_expr(text, outer) {
517292 Ok(e) => e,
518− Err(p) => return err(3, diagnostics::format_source_error("expression", text, p.line, &p.msg)),
293+ Err(p) => return err(3, format!("line {}: {}", p.line, p.msg)),
519294 };
520295
521296 // §1.2.4: build the current repository value (with snapshot)
522297 let (mut interp, loaded_repo, current_repo) =
523− match j::terminal::with_progress("Reading repository", || backend.build_interp(&mut cfg, text, snapshot)) {
298+ match backend.build_interp(&cfg, text, snapshot) {
524299 Ok(t) => t,
525300 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: "))),
527301 Err((_, m)) => return err(1, m),
528302 };
529303
530− // §1.2.5: the config's Id literals were resolved by build_interp, as soon
531− // as the visible ids were known; the expression's are resolved here
304+ // §1.2.5: resolve Id literals in config and expression
305+ for (def_name, prefix, candidates) in config::config_id_failures(&cfg, &interp) {
306+ let _ = candidates;
307+ return err(
308+ 3,
309+ format!(
310+ "config.j: `@{}` in `{}` does not resolve to a unique commit",
311+ prefix, def_name
312+ ),
313+ );
314+ }
532315 expr = match config::resolve_ids(&expr, &interp) {
533316 Ok(e) => e,
534317 Err(failures) => {
535318 let (prefix, candidates) = &failures[0];
536319 if candidates.is_empty() {
537− j::terminal::report_error(format_args!("j: crash: `@{}` matches no commit", prefix));
320+ eprintln!("j: crash: `@{}` matches no commit", prefix);
538321 } else {
539− j::terminal::report_error(format_args!("j: crash: `@{}` is ambiguous", prefix));
322+ eprintln!("j: crash: `@{}` is ambiguous", prefix);
540323 for c in candidates {
541− j::report!(" {}", c);
324+ eprintln!(" {}", c);
542325 }
543326 }
544327 return ExitCode::from(1);
@@ -546,140 +329,45 @@ fn run_expression(text: &str, snapshot: bool) -> ExitCode {
546329 };
547330
548331 // §1.2.6: evaluate config definitions, then the expression
549− if let Err(c) = j::terminal::with_progress("Evaluating configuration", || {
550− config::eval_config(&mut interp, &cfg)
551− }) {
552− return err(3, format!("{}: {}\n See `j help config` for configuration help.", config_path(), c.msg));
332+ if let Err(c) = config::eval_config(&mut interp, &cfg) {
333+ return err(3, format!("config.j: {}", c.msg));
553334 }
554335 *interp.old_repo.borrow_mut() = Some(loaded_repo.clone());
555− *interp.given_repo.borrow_mut() = Some(current_repo.clone());
556336 let env = interp.global_env();
557337 let expr_rc = Rc::new(expr);
558− let v = match j::terminal::with_progress("Evaluating expression", || {
559− let v = interp.eval(&expr_rc, &env)?;
560− // §1.2.7: apply a function result to the repo, once.
561− if matches!(v, Value::Fun(_)) {
562− interp.apply(v, current_repo.clone())
563− } else {
564− Ok(v)
565− }
566− }) {
338+ let mut v = match interp.eval(&expr_rc, &env) {
567339 Ok(v) => v,
568− Err(c) => return crash_err(&c, text, &cfg),
340+ Err(c) => return crash_err(&c, Some(text)),
569341 };
570342
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+
571351 // §1.2.8: a Repo is persisted (unless unchanged)
572− if is_repo_value(&v) {
573− match j::terminal::with_progress("Saving changes", || {
574− backend.persist(&mut interp, &cfg, &loaded_repo, &current_repo, &v, text)
575− }) {
576− Ok(changed) => {
577− if std::io::stdout().is_terminal() && std::io::stderr().is_terminal() {
578− let outcome = if changed { "Saved changes" } else { "No changes" };
579− j::terminal::success(&format!("{}; {}", outcome, focus_summary(&v)));
580− }
581− if changed {
582− report_conflicts(&interp, &loaded_repo, &v);
583− }
584− ExitCode::SUCCESS
585− }
586− Err(c) if c.msg.starts_with("config.j: user identity") => {
587− err(3, format!("{}: {}\n Edit `user` in this file; see `j help config`.", config_path(), c.msg.trim_start_matches("config.j: ")))
588− }
589− Err(c) => crash_err(&c, text, &cfg),
352+ if is_repo_value(&interp, &v) {
353+ match backend.persist(&mut interp, &cfg, &loaded_repo, &current_repo, &v, text) {
354+ Ok(()) => ExitCode::SUCCESS,
355+ Err(c) => crash_err(&c, Some(text)),
590356 }
591357 } else {
592358 // §1.2.9: display
593359 let color = j::render::color_enabled("auto");
594− match j::terminal::with_progress("Rendering output", || j::render::display(&mut interp, &v, color)) {
595− Ok(s) => print_output(&s),
596− Err(c) => crash_err(&c, text, &cfg),
597− }
598− }
599−}
600−
601−/// Report newly introduced conflicts and conflicts in the checked-out focus.
602−/// Stored, resolved trees are skipped without loading their entries or blobs.
603−fn conflict_paths(interp: &j::eval::Interp, repo: &Value) -> Result<BTreeSet<(String, String)>, Crash> {
604− let mut paths = BTreeSet::new();
605− for commit in j::repo::all_commits(repo)? {
606− let Value::Id(id) = commit.field("id")? else { continue };
607− let Value::Record(fields) = &commit else { continue };
608− if let Some(Value::Thunk(files)) = fields.get("files") {
609− // `new` can inherit an unchanged snapshot from another commit.
610− // Its stored origin still answers for those files even when the
611− // new commit has a different id.
612− if files.origin().and_then(|origin| interp.backend.has_conflict(origin)) == Some(false) {
613− continue;
614− }
615− }
616− for entry in commit.field("files")?.as_list()? {
617− if matches!(entry.field("content")?, Value::Blob(blob) if blob.is_unresolved()) {
618− let path = entry.field("path")?.as_list()?.iter()
619− .map(|part| part.as_text().map(str::to_owned))
620− .collect::<Result<Vec<_>, _>>()?.join("/");
621− paths.insert((id.to_string(), path));
360+ match j::render::display(&mut interp, &v, color) {
361+ Ok(s) => {
362+ print!("{}", s);
363+ ExitCode::SUCCESS
622364 }
365+ Err(c) => crash_err(&c, Some(text)),
623366 }
624367 }
625− Ok(paths)
626368}
627369
628−fn report_conflicts(interp: &j::eval::Interp, before: &Value, after: &Value) {
629− let summary = || -> Result<(), Crash> {
630− let old = conflict_paths(interp, before)?;
631− let new = conflict_paths(interp, after)?;
632− let Value::Id(focus) = after.field("root")?.field("id")? else { return Ok(()) };
633− let relevant: Vec<_> = new.iter()
634− .filter(|(id, path)| id == focus.as_str() || !old.contains(&(id.clone(), path.clone())))
635− .collect();
636− if relevant.is_empty() {
637− return Ok(());
638− }
639− j::report!("j: warning: {} unresolved file(s) remain:", relevant.len());
640− for (id, path) in relevant.iter().take(10) {
641− j::report!(" @{} {}", interp.unique_prefix(id), path.escape_debug());
642− }
643− if relevant.len() > 10 {
644− j::report!(" … and {} more; inspect with `j conflicts`.", relevant.len() - 10);
645− }
646− j::report!(" Run `j conflicts` to inspect affected commits. In the focused commit, edit the conflict markers, run `j id` to record the resolution, then `j conflicts` to verify.\n For another commit, first use `j by @ID`. See `j help conflicts`.");
647− Ok(())
648− };
649− // Saving has succeeded: an optional diagnostic must not turn that into a
650− // failed operation that the user might retry.
651− if let Err(c) = summary() {
652− j::report!("j: changes saved, but conflict details could not be read: {}", c.msg);
653− }
654−}
655−
656−/// Summaries inspect only commit metadata, never force file blobs. Use the
657−/// resulting tree's ids, including any commits created by this expression.
658−fn focus_summary(repo: &Value) -> String {
659− let summary = || -> Result<String, Crash> {
660− let focus = repo.field("root")?;
661− let Value::Id(id) = focus.field("id")? else {
662− return Err(Crash::new("focus has no id"));
663− };
664− let mut ids: Vec<String> = j::repo::all_commits(repo)?
665− .into_iter()
666− .filter_map(|commit| match commit.field("id").ok()? {
667− Value::Id(id) => Some(id.to_string()),
668− _ => None,
669− })
670− .collect();
671− ids.sort();
672− let prefix = j::eval::unique_prefix_in(&ids, &id);
673− let message = focus.field("message")?.as_text()?.to_string();
674− let title: String = message.lines().next().unwrap_or("").chars()
675− .map(|ch| if ch.is_control() { ' ' } else { ch }).take(72).collect();
676− let title = if title.trim().is_empty() { "(no description)" } else { title.trim() };
677− Ok(format!("focus @{} {}", prefix, title))
678− };
679− summary().unwrap_or_else(|_| "repository saved".to_string())
680−}
681−
682−fn is_repo_value(v: &Value) -> bool {
370+fn is_repo_value(interp: &Interp, v: &Value) -> bool {
683371 // §4.4: shape Repo and root's shape Commit
684372 let fields = match v {
685373 Value::Record(m) => m.keys().cloned().collect::<BTreeSet<String>>(),
@@ -704,18 +392,6 @@ fn is_repo_value(v: &Value) -> bool {
704392 .iter()
705393 .map(|s| s.to_string())
706394 .collect();
395+ let _ = interp;
707396 root_fields == want_root
708397}
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+162−346
@@ -1,9 +1,9 @@
11//! Parser for the j language (§3.2–3.4), including the two layout rules.
22
3−use crate::ast::{Expr, Item, Pattern, Source, TypeExpr};
3+use crate::ast::{Expr, Item, Pattern, TypeExpr};
44use crate::lex::{lex, LexError, SpTok, Tok};
55use num_bigint::BigInt;
6−use std::collections::{BTreeSet, HashMap, HashSet};
6+use std::collections::BTreeSet;
77use std::rc::Rc;
88
99#[derive(Debug)]
@@ -31,45 +31,15 @@ impl From<LexError> for ParseError {
3131}
3232
3333pub struct Parser {
34− /// the text being parsed, of which each lambda keeps its span (§5.2)
35− src: Rc<str>,
3634 toks: Vec<SpTok>,
3735 pos: usize,
38− /// Names in scope (top-level + builtins) for the no-shadowing rule;
39− /// in config.j the builtins alone (§4.2).
36+ /// Names in scope (top-level + builtins) for the no-shadowing rule.
4037 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)>,
5138 /// set when an operator expression ends with a trailing operator before
5239 /// `)`, i.e. a left section
5340 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,
5941}
6042
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−
7343/// fixity table (§3.2): (precedence, right-assoc)
7444fn fixity(op: &str) -> Option<(u8, bool)> {
7545 Some(match op {
@@ -92,51 +62,13 @@ fn is_nonassoc(op: &str) -> bool {
9262impl Parser {
9363 pub fn new(src: &str, outer_names: Rc<BTreeSet<String>>) -> Result<Self, ParseError> {
9464 Ok(Parser {
95− src: Rc::from(src),
9665 toks: lex(src)?,
9766 pos: 0,
9867 outer_names,
99− locals: HashSet::new(),
100− binders: 0,
101− last_binder: HashMap::new(),
10268 section_op: None,
103− depth: 0,
104− height: 0,
10569 })
10670 }
10771
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−
14072 fn peek(&self) -> &Tok {
14173 &self.toks[self.pos].tok
14274 }
@@ -191,15 +123,12 @@ impl Parser {
191123 Ok(items)
192124 }
193125
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.
197126 fn parse_item(&mut self) -> Result<Item, ParseError> {
198127 match self.peek().clone() {
199128 Tok::TypeName(name) => {
200129 // typedecl: TYPENAME '=' type
201130 self.bump();
202− self.skip_layout_newlines(1);
131+ self.skip_newlines();
203132 self.expect(&Tok::Equals)?;
204133 let ty = self.parse_type()?;
205134 Ok(Item::TypeDecl(name, ty))
@@ -207,7 +136,6 @@ impl Parser {
207136 Tok::Ident(name) => {
208137 let line = self.line();
209138 self.bump();
210− self.skip_layout_newlines(1);
211139 match self.peek().clone() {
212140 Tok::Colon => {
213141 self.bump();
@@ -216,8 +144,7 @@ impl Parser {
216144 }
217145 Tok::Equals => {
218146 self.bump();
219− // inside nothing, so it opens no level (see `nested`)
220− let e = self.parse_expr_inner(1)?;
147+ let e = self.parse_expr(1)?;
221148 Ok(Item::Definition(name, Rc::new(e), line))
222149 }
223150 other => Err(ParseError::new(
@@ -240,7 +167,6 @@ impl Parser {
240167 }
241168 };
242169 self.expect(&Tok::RParen)?;
243− self.skip_layout_newlines(1);
244170 self.expect(&Tok::Colon)?;
245171 let ty = self.parse_type()?;
246172 Ok(Item::Signature(op.to_string(), ty, line))
@@ -256,16 +182,9 @@ impl Parser {
256182 // types
257183 // ------------------------------------------------------------------
258184 fn parse_type(&mut self) -> Result<TypeExpr, ParseError> {
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);
185+ self.skip_newlines_in_type();
267186 let lhs = self.parse_atype()?;
268− self.skip_layout_newlines(1);
187+ self.skip_newlines_in_type();
269188 if matches!(self.peek(), Tok::Arrow) {
270189 self.bump();
271190 let rhs = self.parse_type()?;
@@ -275,6 +194,38 @@ impl Parser {
275194 }
276195 }
277196
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+
278229 fn parse_atype(&mut self) -> Result<TypeExpr, ParseError> {
279230 match self.peek().clone() {
280231 Tok::TypeName(n) => {
@@ -295,7 +246,7 @@ impl Parser {
295246 self.bump();
296247 let mut fields = Vec::new();
297248 loop {
298− self.skip_layout_newlines(1);
249+ self.skip_newlines();
299250 if matches!(self.peek(), Tok::RBrace) {
300251 self.bump();
301252 break;
@@ -309,11 +260,10 @@ impl Parser {
309260 ))
310261 }
311262 };
312− self.skip_layout_newlines(1);
313263 self.expect(&Tok::Colon)?;
314264 let t = self.parse_type()?;
315265 fields.push((name, Rc::new(t)));
316− self.skip_layout_newlines(1);
266+ self.skip_newlines();
317267 match self.peek() {
318268 Tok::Comma => {
319269 self.bump();
@@ -353,84 +303,59 @@ impl Parser {
353303 /// expression stops at a Newline whose following token is in a column
354304 /// <= min_col (it belongs to an outer construct).
355305 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> {
364306 self.skip_layout_newlines(min_col);
365307 match self.peek().clone() {
366− Tok::Backslash => self.parse_lambda(min_col),
367− Tok::If => self.parse_if(min_col),
368− Tok::Let => self.parse_let(min_col),
369− _ => self.parse_or(min_col),
370− }
371− }
372−
373− fn parse_lambda(&mut self, min_col: usize) -> Result<Expr, ParseError> {
374− let src_start = self.toks[self.pos].clone();
375− self.bump();
376− let mut params = Vec::new();
377− loop {
378− match self.peek().clone() {
379− Tok::Ident(n) => {
380− // in scope from here on, so a later parameter
381− // may not reuse it either
382− self.check_shadow(&n)?;
383− self.locals.insert(n.clone());
384− self.note_binder(&n, self.line());
385− params.push(Pattern::Var(n));
386− self.bump();
387− }
388− Tok::Underscore => {
389− params.push(Pattern::Wildcard);
390− self.bump();
391− }
392− Tok::Arrow => {
393− self.bump();
394− break;
308+ Tok::Backslash => {
309+ let src_start = self.toks[self.pos].clone();
310+ self.bump();
311+ let mut params = Vec::new();
312+ loop {
313+ match self.peek().clone() {
314+ Tok::Ident(n) => {
315+ self.check_shadow(&n)?;
316+ params.push(Pattern::Var(n));
317+ self.bump();
318+ }
319+ Tok::Underscore => {
320+ params.push(Pattern::Wildcard);
321+ self.bump();
322+ }
323+ Tok::Arrow => {
324+ self.bump();
325+ break;
326+ }
327+ other => {
328+ return Err(ParseError::new(
329+ format!(
330+ "expected a parameter or `->`, found {}",
331+ other.describe()
332+ ),
333+ self.line(),
334+ ))
335+ }
336+ }
395337 }
396− other => {
397− return Err(ParseError::new(
398− format!(
399− "expected a parameter or `->`, found {}",
400− other.describe()
401− ),
402− self.line(),
403− ))
338+ if params.is_empty() {
339+ return Err(ParseError::new("lambda needs at least one parameter", self.line()));
404340 }
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))
405344 }
406− }
407− if params.is_empty() {
408− return Err(ParseError::new("lambda needs at least one parameter", self.line()));
409− }
410− let body = self.parse_expr(min_col)?;
411− self.height = self.within(self.height + 1)?;
412− for p in &params {
413− if let Pattern::Var(n) = p {
414− self.locals.remove(n);
345+ Tok::If => {
346+ self.bump();
347+ let c = self.parse_expr(min_col)?;
348+ self.skip_layout_newlines(min_col);
349+ self.expect(&Tok::Then)?;
350+ let t = self.parse_expr(min_col)?;
351+ self.skip_layout_newlines(min_col);
352+ self.expect(&Tok::Else)?;
353+ let e = self.parse_expr(min_col)?;
354+ Ok(Expr::If(Rc::new(c), Rc::new(t), Rc::new(e)))
415355 }
356+ Tok::Let => self.parse_let(min_col),
357+ _ => self.parse_or(min_col),
416358 }
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)))
434359 }
435360
436361 /// skip Newlines whose next real token is in a column > min_col
@@ -471,9 +396,7 @@ impl Parser {
471396 ));
472397 }
473398 let mut bindings: Vec<(String, Rc<Expr>)> = Vec::new();
474− let mut names: Vec<(String, usize)> = Vec::new();
475− let first_binder = self.binders;
476− let mut h = 0;
399+ let mut names: Vec<String> = Vec::new();
477400 loop {
478401 // binding: ident '=' expr, expr limited to block_col
479402 let name = match self.peek().clone() {
@@ -485,21 +408,17 @@ impl Parser {
485408 ))
486409 }
487410 };
488− if names.iter().any(|(n, _)| *n == name) {
411+ self.check_shadow(&name)?;
412+ if names.iter().any(|n| *n == name) {
489413 return Err(ParseError::new(
490414 format!("`{}` is bound twice in this let", name),
491415 self.line(),
492416 ));
493417 }
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()));
499418 self.bump();
500419 self.expect(&Tok::Equals)?;
501420 let e = self.parse_expr(block_col)?;
502− h = h.max(self.height);
421+ names.push(name.clone());
503422 bindings.push((name, Rc::new(e)));
504423 // separator: `;` or newline at exactly block_col, then `in` ends
505424 match self.peek().clone() {
@@ -524,16 +443,6 @@ impl Parser {
524443 }
525444 let next_col = self.toks[q].col;
526445 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− }
537446 self.pos = q;
538447 self.bump();
539448 break;
@@ -571,66 +480,39 @@ impl Parser {
571480 }
572481 }
573482 }
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− }
587483 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− }
592484 Ok(Expr::Let(bindings, Rc::new(body)))
593485 }
594486
595− /// §4.2: a binder may not reuse a top-level or builtin name, nor one
596− /// bound by an enclosing lambda or `let`.
597487 fn check_shadow(&self, name: &str) -> Result<(), ParseError> {
598− if self.outer_names.contains(name) || self.locals.contains(name) {
599− return Err(shadowed(name, self.line()));
488+ if self.outer_names.contains(name) {
489+ return Err(ParseError::new(
490+ format!("`{}` is already bound and may not be shadowed", name),
491+ self.line(),
492+ ));
600493 }
601494 Ok(())
602495 }
603496
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−
611497 fn parse_or(&mut self, min_col: usize) -> Result<Expr, ParseError> {
612498 // `or` is infixl 0 and extends as far right as possible
613499 let mut lhs = self.parse_opexpr(1, min_col)?;
614− let mut h = self.height;
615500 loop {
616501 self.skip_layout_newlines(min_col);
617502 if matches!(self.peek(), Tok::Or) {
618503 self.bump();
619− let rhs = self.nested(|p| p.parse_or(min_col))?;
620− h = self.within(h.max(self.height) + 1)?;
504+ let rhs = self.parse_or(min_col)?;
621505 lhs = Expr::Or(Rc::new(lhs), Rc::new(rhs));
622506 } else {
623507 break;
624508 }
625509 }
626− self.height = h;
627510 Ok(lhs)
628511 }
629512
630513 /// precedence-climbing over the fixity table
631514 fn parse_opexpr(&mut self, min_prec: u8, min_col: usize) -> Result<Expr, ParseError> {
632515 let mut lhs = self.parse_app(min_col)?;
633− let mut h = self.height;
634516 loop {
635517 self.skip_op_newline(min_col);
636518 let op: &'static str = match self.peek() {
@@ -648,11 +530,10 @@ impl Parser {
648530 // left section: `(e op)` — a trailing operator before `)`
649531 if matches!(self.peek(), Tok::RParen) {
650532 self.section_op = Some(op);
651− self.height = h;
652533 return Ok(lhs);
653534 }
654535 let next_min = if right { prec } else { prec + 1 };
655− let rhs = self.nested(|p| p.parse_opexpr(next_min, min_col))?;
536+ let rhs = self.parse_opexpr(next_min, min_col)?;
656537 // non-assoc check: if lhs is the same operator, reject chaining
657538 if is_nonassoc(op) {
658539 // a non-associative operator cannot be chained; explicit
@@ -674,10 +555,8 @@ impl Parser {
674555 }
675556 }
676557 }
677− h = self.within(h.max(self.height) + 1)?;
678558 lhs = Expr::BinOp(op, Rc::new(lhs), Rc::new(rhs));
679559 }
680− self.height = h;
681560 Ok(lhs)
682561 }
683562
@@ -717,7 +596,6 @@ impl Parser {
717596
718597 fn parse_app(&mut self, min_col: usize) -> Result<Expr, ParseError> {
719598 let mut e = self.parse_postfix(min_col)?;
720− let mut h = self.height;
721599 loop {
722600 // application across a newline requires deeper indentation
723601 if matches!(self.peek(), Tok::Newline) {
@@ -733,24 +611,20 @@ impl Parser {
733611 }
734612 if Self::starts_atom(self.peek()) {
735613 let arg = self.parse_postfix(min_col)?;
736− h = self.within(h.max(self.height) + 1)?;
737614 e = Expr::App(Rc::new(e), Rc::new(arg));
738615 } else {
739616 break;
740617 }
741618 }
742− self.height = h;
743619 Ok(e)
744620 }
745621
746622 fn parse_postfix(&mut self, min_col: usize) -> Result<Expr, ParseError> {
747623 let mut e = self.parse_atom(min_col)?;
748− let mut h = self.height;
749624 loop {
750625 match self.peek().clone() {
751626 Tok::Selector(f) => {
752627 self.bump();
753− h = self.within(h + 1)?;
754628 e = Expr::Select(Rc::new(e), f);
755629 }
756630 Tok::LBrace => {
@@ -758,18 +632,12 @@ impl Parser {
758632 // postfix binds tighter than application, so `{` after an
759633 // atom is an update)
760634 self.bump();
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)?;
635+ let fields = self.parse_fields(min_col, true)?;
765636 e = Expr::Update(Rc::new(e), fields);
766637 }
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)
769638 _ => break,
770639 }
771640 }
772− self.height = h;
773641 Ok(e)
774642 }
775643
@@ -783,12 +651,8 @@ impl Parser {
783651 self.skip_layout_newlines(min_col);
784652 if matches!(self.peek(), Tok::RBrace) {
785653 self.bump();
786− // nothing inside the braces
787− self.height = 0;
788654 return Ok(fields);
789655 }
790− // the height of the highest field
791− let mut h = 0;
792656 loop {
793657 let name = match self.bump() {
794658 Tok::Ident(n) => n,
@@ -807,7 +671,6 @@ impl Parser {
807671 }
808672 self.expect(&Tok::Equals)?;
809673 let v = self.parse_expr(min_col)?;
810− h = h.max(self.height);
811674 fields.push((name, Rc::new(v)));
812675 self.skip_layout_newlines(min_col);
813676 match self.peek() {
@@ -827,17 +690,10 @@ impl Parser {
827690 }
828691 }
829692 }
830− self.height = h;
831693 Ok(fields)
832694 }
833695
834696 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;
841697 match self.peek().clone() {
842698 Tok::Ident(n) => {
843699 self.bump();
@@ -883,16 +739,70 @@ impl Parser {
883739 self.bump();
884740 Ok(Expr::SelectorFun(f))
885741 }
886− Tok::LBracket => self.parse_list(min_col),
742+ Tok::LBracket => {
743+ self.bump();
744+ let mut elems = Vec::new();
745+ loop {
746+ self.skip_layout_newlines(min_col);
747+ if matches!(self.peek(), Tok::RBracket) {
748+ self.bump();
749+ break;
750+ }
751+ let e = self.parse_postfix(min_col)?;
752+ elems.push(Rc::new(e));
753+ }
754+ Ok(Expr::List(elems))
755+ }
887756 Tok::LBrace => {
888757 self.bump();
889758 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)?;
893759 Ok(Expr::Record(fields))
894760 }
895− Tok::LParen => self.parse_paren(min_col),
761+ Tok::LParen => {
762+ self.bump();
763+ // (op) | (op expr) | (expr op) | (expr)
764+ if let Tok::Op(o) = self.peek().clone() {
765+ self.bump();
766+ self.skip_layout_newlines(min_col);
767+ if matches!(self.peek(), Tok::RParen) {
768+ self.bump();
769+ return Ok(Expr::Var(o.to_string()));
770+ }
771+ // right section: (op e) = \x -> x op e
772+ let e = self.parse_expr(min_col)?;
773+ self.expect(&Tok::RParen)?;
774+ let var = fresh_var();
775+ let body = Expr::BinOp(
776+ o,
777+ Rc::new(Expr::Var(var.clone())),
778+ Rc::new(e),
779+ );
780+ return Ok(Expr::Lambda(
781+ vec![Pattern::Var(var)],
782+ Rc::new(body),
783+ "section".into(),
784+ ));
785+ }
786+ let first = self.parse_expr(min_col)?;
787+ self.skip_layout_newlines(min_col);
788+ if let Some(o) = self.section_op.take() {
789+ // left section: (e op) = \x -> e op x
790+ self.expect(&Tok::RParen)?;
791+ let var = fresh_var();
792+ let body = Expr::BinOp(
793+ o,
794+ Rc::new(first),
795+ Rc::new(Expr::Var(var.clone())),
796+ );
797+ return Ok(Expr::Lambda(
798+ vec![Pattern::Var(var)],
799+ Rc::new(body),
800+ "section".into(),
801+ ));
802+ }
803+ self.expect(&Tok::RParen)?;
804+ Ok(Expr::Paren(Rc::new(first)))
805+ }
896806 other => Err(ParseError::new(
897807 format!("unexpected {}", other.describe()),
898808 self.line(),
@@ -900,105 +810,15 @@ impl Parser {
900810 }
901811 }
902812
903− fn parse_list(&mut self, min_col: usize) -> Result<Expr, ParseError> {
904− self.bump();
905− let mut elems = Vec::new();
906− let mut h = 0;
907− loop {
908− self.skip_layout_newlines(min_col);
909− if matches!(self.peek(), Tok::RBracket) {
910− self.bump();
911− break;
912− }
913− let e = self.parse_postfix(min_col)?;
914− h = h.max(self.height);
915− elems.push(Rc::new(e));
916− }
917− self.height = self.within(h + 1)?;
918− Ok(Expr::List(elems))
919− }
920−
921− fn parse_paren(&mut self, min_col: usize) -> Result<Expr, ParseError> {
922− let open = self.toks[self.pos].clone();
923− self.bump();
924− // (op) | (op expr) | (expr op) | (expr)
925− if let Tok::Op(o) = self.peek().clone() {
926− self.bump();
927− self.skip_layout_newlines(min_col);
928− if matches!(self.peek(), Tok::RParen) {
929− self.bump();
930− return Ok(Expr::Var(o.to_string()));
931− }
932− // right section: (op e) = \x -> x op e
933− let e = self.parse_expr(min_col)?;
934− // `(op e op)` is no production (§3.4); unchecked, the
935− // trailing operator would be dropped here and left set
936− // for the next `(e)` to take as a left section
937− if let Some(o2) = self.section_op.take() {
938− return Err(ParseError::new(
939− format!(
940− "a section cannot have operators on both sides (`{}` and `{}`)",
941− o, o2
942− ),
943− self.line(),
944− ));
945− }
946− self.expect(&Tok::RParen)?;
947− // a lambda around an operator: two levels
948− self.height = self.within(self.height + 2)?;
949− let var = fresh_var();
950− let body = Expr::BinOp(
951− o,
952− Rc::new(Expr::Var(var.clone())),
953− Rc::new(e),
954− );
955− return Ok(Expr::Lambda(
956− vec![Pattern::Var(var)],
957− Rc::new(body),
958− self.source_slice(&open),
959− ));
960− }
961− let first = self.parse_expr(min_col)?;
962− // a parenthesis is two levels, as is a section's lambda
963− // around its operator
964− self.height = self.within(self.height + 2)?;
965− self.skip_layout_newlines(min_col);
966− if let Some(o) = self.section_op.take() {
967− // left section: (e op) = \x -> e op x
968− self.expect(&Tok::RParen)?;
969− let var = fresh_var();
970− let body = Expr::BinOp(
971− o,
972− Rc::new(first),
973− Rc::new(Expr::Var(var.clone())),
974− );
975− return Ok(Expr::Lambda(
976− vec![Pattern::Var(var)],
977− Rc::new(body),
978− self.source_slice(&open),
979− ));
980− }
981− self.expect(&Tok::RParen)?;
982− Ok(Expr::Paren(Rc::new(first)))
983− }
984−
985− /// The source of a lambda or section, from its first token `start` to
986− /// the last token consumed, for its rendering (§5.2: exactly as
987− /// written). Line breaks the layout rules skipped looking for more of it
988− /// are not part of it, nor is a comment after its last token.
989− fn source_slice(&self, start: &SpTok) -> Source {
990− let mut last = self.pos - 1;
991− while matches!(self.toks[last].tok, Tok::Newline) {
992− last -= 1;
993− }
994− Source::new(self.src.clone(), start.start, self.toks[last].end)
813+ fn source_slice(&self, _start: &SpTok) -> String {
814+ // source text of a lambda for display; we re-render from the AST.
815+ String::new()
995816 }
996817
997818 /// Parse a single command-line expression. Layout rule 1 does not apply.
998819 pub fn parse_cli_expr(&mut self) -> Result<Expr, ParseError> {
999820 self.skip_newlines();
1000− // inside nothing, so it opens no level (see `nested`)
1001− let e = self.parse_expr_inner(0)?;
821+ let e = self.parse_expr(0)?;
1002822 self.skip_newlines();
1003823 if !matches!(self.peek(), Tok::Eof) {
1004824 return Err(ParseError::new(
@@ -1010,10 +830,6 @@ impl Parser {
1010830 }
1011831}
1012832
1013−fn shadowed(name: &str, line: usize) -> ParseError {
1014− ParseError::new(format!("`{}` is already bound and may not be shadowed", name), line)
1015−}
1016−
1017833use std::sync::atomic::{AtomicUsize, Ordering};
1018834static FRESH: AtomicUsize = AtomicUsize::new(0);
1019835fn fresh_var() -> String {
src/render.rs+589−1737
@@ -2,11 +2,8 @@
22
33use crate::domain::ROOT_ID;
44use crate::eval::Interp;
5−use crate::value::{Crash, ListVal, RecordMap, ShapeKind, Value};
6−use std::cmp::Reverse;
7−use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
8−use std::ops::Bound;
9−use std::rc::Rc;
5+use crate::value::{Crash, ShapeKind, Value};
6+use std::collections::{BTreeMap, BTreeSet};
107use unicode_width::UnicodeWidthChar;
118
129// ----------------------------------------------------------------------
@@ -33,46 +30,33 @@ impl Palette {
3330 self.wrap("2", s)
3431 }
3532 pub fn bold_bg(&self, s: &str) -> String {
36− self.wrap("1;97;48;5;17", s)
33+ self.wrap("1;48;5;236", s)
3734 }
3835 pub fn dim_italic(&self, s: &str) -> String {
3936 self.wrap("2;3", s)
4037 }
4138 pub fn cyan(&self, s: &str) -> String {
42− self.wrap("96", s)
39+ self.wrap("36", s)
4340 }
4441 pub fn red(&self, s: &str) -> String {
45− self.wrap("91", s)
42+ self.wrap("31", s)
4643 }
4744 pub fn blue(&self, s: &str) -> String {
48− self.wrap("94", s)
45+ self.wrap("34", s)
4946 }
5047 pub fn green(&self, s: &str) -> String {
51− self.wrap("92", s)
48+ self.wrap("32", s)
5249 }
5350 pub fn yellow(&self, s: &str) -> String {
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)
51+ self.wrap("33", s)
6852 }
6953 pub fn grey(&self, bright: u8, s: &str) -> String {
7054 // grey scale: brighter when more recent
7155 self.wrap(&format!("38;5;{}", 245 + bright.min(10)), s)
7256 }
7357 pub fn accent(&self, level: u8, s: &str) -> String {
74− // Size remains encoded by the bar's height, with a vivid cool-to-warm ramp.
75− let codes = ["96", "94", "95", "93", "1;93"];
58+ // size bar: one accent colour, brighter with size
59+ let codes = ["38;5;240", "38;5;36", "38;5;32", "38;5;39", "38;5;87"];
7660 self.wrap(codes[level.min(4) as usize], s)
7761 }
7862 pub fn author(&self, name: &str, s: &str) -> String {
@@ -82,7 +66,7 @@ impl Palette {
8266 h = h.wrapping_mul(33).wrapping_add(b as u32);
8367 }
8468 let hue = (h % 6) as u8;
85− let code = ["91", "92", "93", "94", "95", "96"][hue as usize];
69+ let code = ["31", "32", "33", "34", "35", "36"][hue as usize];
8670 self.wrap(code, s)
8771 }
8872 /// colour with a raw ANSI code (used for meaning-coloured glyphs)
@@ -114,8 +98,7 @@ pub fn color_enabled(mode: &str) -> bool {
11498 match mode {
11599 "always" => true,
116100 "never" => false,
117− _ => std::env::var_os("TERM").map(|t| t != "dumb").unwrap_or(true)
118− && crate::show::stdout_is_tty(),
101+ _ => crate::show::stdout_is_tty(),
119102 }
120103}
121104
@@ -171,16 +154,11 @@ fn field_set(v: &Value) -> Option<BTreeSet<String>> {
171154
172155fn shape_of_record(interp: &Interp, v: &Value) -> Option<String> {
173156 let fs = field_set(v)?;
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"] {
157+ for (name, _) in &interp.shapes.decls {
180158 if let Some(s) = interp.shapes.shape_of(name) {
181159 if let ShapeKind::Record(want) = &s.kind {
182160 if want == &fs {
183− return Some(name.to_string());
161+ return Some(name.clone());
184162 }
185163 }
186164 }
@@ -197,89 +175,35 @@ pub fn is_commit(interp: &Interp, v: &Value) -> bool {
197175// ----------------------------------------------------------------------
198176
199177pub 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> {
210178 let pal = Palette { on: color };
211179 let mut out = String::new();
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− }
180+ display_block(interp, v, &pal, 0, &mut out)?;
243181 if !out.ends_with('\n') {
244182 out.push('\n');
245183 }
246184 Ok(out)
247185}
248186
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−
265187fn display_id(interp: &Interp, id: &str, pal: &Palette) -> String {
266− let p = interp.unique_prefix(id);
188+ let p = interp.backend.unique_prefix(id);
267189 let n = p.len();
268− format!("{}{}", pal.id(p.as_str()), pal.dim(&id[n..]))
190+ format!("{}{}", pal.bold(p.as_str()), pal.dim(&id[n..]))
269191}
270192
271−fn display_line(
272− interp: &mut Interp,
273− v: &Value,
274− pal: &Palette,
275− loaded: &mut Option<Loaded>,
276−) -> Result<String, Crash> {
193+fn display_line(interp: &Interp, v: &Value, pal: &Palette) -> Result<String, Crash> {
277194 let v = &v.forced()?;
278195 match v {
279196 Value::Thunk(_) => unreachable!("forced never returns a thunk"),
280− Value::Int(n) => Ok(pal.yellow(&n.to_string())),
281− Value::Bool(b) => Ok(if *b { pal.green("true") } else { pal.red("false") }),
282− Value::Text(t) => Ok(first_line(t)),
197+ Value::Int(n) => Ok(n.to_string()),
198+ Value::Bool(b) => Ok(b.to_string()),
199+ Value::Text(t) => {
200+ let first = t.lines().next().unwrap_or("");
201+ if t.lines().count() > 1 {
202+ Ok(format!("{}…", first))
203+ } else {
204+ Ok(first.to_string())
205+ }
206+ }
283207 Value::Id(id) => Ok(display_id(interp, id, pal)),
284208 Value::Blob(b) => {
285209 let size = human_size(b.size());
@@ -289,19 +213,43 @@ fn display_line(
289213 Ok(format!("‹{}›", size))
290214 }
291215 }
292− // a lambda renders as its source (§5.2), which may run over several
293− // lines and hold comments; the line form keeps only the first
294− Value::Fun(_) => Ok(first_line(&crate::show::show(interp, v)?)),
295− Value::Shape(s) => Ok(pal.cyan(&s.name)),
216+ Value::Fun(f) => Ok(crate::show::show(&Interp::dummy(), &Value::Fun(f.clone()))),
217+ Value::Shape(s) => Ok(s.name.clone()),
296218 Value::Record(_) => {
297219 if let Some(shape) = shape_of_record(interp, v) {
298220 match shape.as_str() {
299− "Commit" => return line_in(interp, v, pal, loaded, false),
221+ "Commit" => return commit_line(interp, v, pal, false, &BTreeSet::new(), false),
300222 "Entry" => return entry_line(interp, v, pal),
301− "Subtree" => return line_in(interp, &v.field("root")?, pal, loaded, false),
302− // its focus's line, drawn as the focus
303− "Repo" => return line_in(interp, &v.field("root")?, pal, loaded, true),
304− "Frame" => return line_in(interp, &v.field("parent")?, pal, loaded, false),
223+ "Subtree" => {
224+ return commit_line(
225+ interp,
226+ &v.field("root")?,
227+ pal,
228+ false,
229+ &BTreeSet::new(),
230+ false,
231+ )
232+ }
233+ "Repo" => {
234+ return commit_line(
235+ interp,
236+ &v.field("root")?,
237+ pal,
238+ true,
239+ &BTreeSet::new(),
240+ false,
241+ )
242+ }
243+ "Frame" => {
244+ return commit_line(
245+ interp,
246+ &v.field("parent")?,
247+ pal,
248+ false,
249+ &BTreeSet::new(),
250+ false,
251+ )
252+ }
305253 "Change" => {
306254 let n = touched_paths(v)?.len();
307255 return Ok(format!("{} paths", n));
@@ -316,18 +264,6 @@ fn display_line(
316264 }
317265}
318266
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−
331267fn list_line(_interp: &Interp, xs: &[Value], pal: &Palette) -> Result<String, Crash> {
332268 if xs.is_empty() {
333269 return Ok("0 items".into());
@@ -364,13 +300,14 @@ fn list_line(_interp: &Interp, xs: &[Value], pal: &Palette) -> Result<String, Cr
364300 let parts: Vec<String> = xs
365301 .iter()
366302 .map(|x| match x {
367− Value::Int(n) => pal.yellow(&n.to_string()),
368− Value::Bool(b) => if *b { pal.green("true") } else { pal.red("false") },
303+ Value::Int(n) => n.to_string(),
304+ Value::Bool(b) => b.to_string(),
369305 _ => unreachable!(),
370306 })
371307 .collect();
372308 return Ok(parts.join(", "));
373309 }
310+ let _ = pal;
374311 Ok(format!("{} items", xs.len()))
375312}
376313
@@ -383,9 +320,9 @@ fn entry_line(interp: &Interp, v: &Value, pal: &Palette) -> Result<String, Crash
383320 };
384321 let _ = interp;
385322 if unresolved {
386− Ok(format!("{} {} {}", pal.red("✖"), pal.cyan(&path), pal.red(&size)))
323+ Ok(format!("{} {} {}", pal.red("✖"), path, size))
387324 } else {
388− Ok(format!(" {} {}", pal.cyan(&path), pal.yellow(&size)))
325+ Ok(format!(" {} {}", path, size))
389326 }
390327}
391328
@@ -398,381 +335,37 @@ fn path_string(p: &Value) -> Result<String, Crash> {
398335 Ok(parts.join("/"))
399336}
400337
401−/// entries of one snapshot, keyed by path components
402−type SnapMap = BTreeMap<Vec<String>, Value>;
403−
404338fn touched_paths(change: &Value) -> Result<Vec<(String, char, bool)>, Crash> {
339+ // (path, mark, unresolved)
405340 let from = snapshot_map_of(&change.field("from")?)?;
406341 let to = snapshot_map_of(&change.field("to")?)?;
407− Ok(touched_in_maps(&from, &to)?
408− .into_iter()
409− .map(|(p, mark, unresolved)| (p.join("/"), mark, unresolved))
410− .collect())
411−}
412−
413−/// The changed paths between two snapshots that have already been keyed, as
414−/// (path components, mark, unresolved). Both maps are sorted, so the union is
415−/// a merge — the previous version collected every key of both sides into a
416−/// `BTreeSet`, cloning each one.
417−fn touched_in_maps(from: &SnapMap, to: &SnapMap) -> Result<Vec<(Vec<String>, char, bool)>, Crash> {
342+ let mut paths: BTreeSet<Vec<String>> = BTreeSet::new();
343+ paths.extend(from.keys().cloned());
344+ paths.extend(to.keys().cloned());
418345 let mut out = Vec::new();
419− let mut fi = from.iter().peekable();
420− let mut ti = to.iter().peekable();
421− loop {
422− let ord = match (fi.peek(), ti.peek()) {
423− (None, None) => break,
424− (Some(_), None) => std::cmp::Ordering::Less,
425− (None, Some(_)) => std::cmp::Ordering::Greater,
426− (Some((fp, _)), Some((tp, _))) => fp.cmp(tp),
427− };
428− let (path, mark, t) = match ord {
429− std::cmp::Ordering::Less => {
430− let (p, _) = fi.next().unwrap();
431− (p, '−', None)
432− }
433− std::cmp::Ordering::Greater => {
434− let (p, v) = ti.next().unwrap();
435− (p, '+', Some(v))
436− }
437− std::cmp::Ordering::Equal => {
438− let (p, a) = fi.next().unwrap();
439− let (_, b) = ti.next().unwrap();
346+ for p in paths {
347+ let f = from.get(&p);
348+ let t = to.get(&p);
349+ let mark = match (f, t) {
350+ (None, Some(_)) => '+',
351+ (Some(_), None) => '−',
352+ (Some(a), Some(b)) => {
440353 if crate::value::value_eq(a, b)? {
441354 continue;
442355 }
443− (p, '~', Some(b))
356+ '~'
444357 }
358+ (None, None) => continue,
445359 };
446360 let unresolved = t
447361 .map(|b| matches!(b, Value::Blob(bl) if bl.is_unresolved()))
448362 .unwrap_or(false);
449− out.push((
450− path.clone(),
451− if unresolved { '✖' } else { mark },
452− unresolved,
453− ));
363+ out.push((p.join("/"), if unresolved { '✖' } else { mark }, unresolved));
454364 }
455365 Ok(out)
456366}
457367
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> {
368+fn snapshot_map_of(snap: &Value) -> Result<BTreeMap<Vec<String>, Value>, Crash> {
776369 let mut m = BTreeMap::new();
777370 for e in snap.as_list()?.iter() {
778371 let path = e
@@ -786,24 +379,13 @@ fn snapshot_map_of(snap: &Value) -> Result<SnapMap, Crash> {
786379 Ok(m)
787380}
788381
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−
802382fn commit_line(
803383 interp: &Interp,
804384 c: &Value,
805385 pal: &Palette,
806− standing: &Standing,
386+ focus: bool,
387+ immutable: &BTreeSet<String>,
388+ has_conflicts_hint: bool,
807389) -> Result<String, Crash> {
808390 let id = match c.field("id")? {
809391 Value::Id(i) => i.to_string(),
@@ -817,20 +399,14 @@ fn commit_line(
817399 .iter()
818400 .map(|l| l.as_text().map(|s| s.to_string()))
819401 .collect::<Result<_, _>>()?;
820− let glyph = node_glyph(c, &id, standing)?;
821− let id_s = if glyph == "⊗" {
822− pal.red(&display_id(interp, &id, &Palette { on: false }))
823− } else if standing.immutable {
824− pal.blue(&display_id(interp, &id, &Palette { on: false }))
825− } else {
826− display_id(interp, &id, pal)
827− };
828− let glyph = pal.code(node_ansi(&glyph), &glyph);
402+ let glyph = node_glyph(interp, c, &id, focus, immutable, pal)?;
403+ let id_s = display_id(interp, &id, pal);
829404 let labels_s = if labels.is_empty() {
830405 String::new()
831406 } else {
832− pal.label(&labels.join(" "))
407+ pal.green(&labels.join(" "))
833408 };
409+ let _ = has_conflicts_hint;
834410 Ok(if labels_s.is_empty() {
835411 format!("{} {} {}", glyph, id_s, msg_first)
836412 } else {
@@ -838,23 +414,28 @@ fn commit_line(
838414 })
839415}
840416
841−/// The glyph of a commit line, by `tree`'s precedence (`glyph_for`).
842−fn node_glyph(c: &Value, id: &str, standing: &Standing) -> Result<String, Crash> {
417+fn node_glyph(
418+ interp: &Interp,
419+ c: &Value,
420+ id: &str,
421+ focus: bool,
422+ immutable: &BTreeSet<String>,
423+ pal: &Palette,
424+) -> Result<String, Crash> {
843425 let g = if id == ROOT_ID {
844426 "⌂"
845427 } else if has_conflict(c)? {
846428 "⊗"
847− } else if standing.empty {
429+ } else if is_empty_commit(interp, c)? {
848430 "◌"
849− } else if standing.immutable {
431+ } else if immutable.contains(id) {
850432 "◆"
851− } else if standing.focus {
433+ } else if focus {
852434 "◉"
853− } else if standing.ancestor {
854− "●"
855435 } else {
856436 "○"
857437 };
438+ let _ = pal;
858439 Ok(g.to_string())
859440}
860441
@@ -870,17 +451,18 @@ fn has_conflict(c: &Value) -> Result<bool, Crash> {
870451 Ok(false)
871452}
872453
873−/// `v` in block form, the blocks it holds left on `todo` (see `Block`)
874−#[allow(clippy::too_many_arguments)]
454+/// empty: no change against its parent — needs the parent; we only compute
455+/// this meaningfully in tree rendering. In line display, treat as false.
456+fn is_empty_commit(_interp: &Interp, _c: &Value) -> Result<bool, Crash> {
457+ Ok(false)
458+}
459+
875460fn display_block(
876461 interp: &mut Interp,
877462 v: &Value,
878463 pal: &Palette,
879464 indent: usize,
880465 out: &mut String,
881− loaded: &mut Option<Loaded>,
882− todo: &mut Vec<Block>,
883− term_w: Option<usize>,
884466) -> Result<(), Crash> {
885467 let pad = " ".repeat(indent);
886468 let v = &v.forced()?;
@@ -895,31 +477,18 @@ fn display_block(
895477 out.push_str(&indent_multiline(&content, indent));
896478 return Ok(());
897479 }
898− Value::Int(_) | Value::Bool(_) | Value::Id(_) | Value::Shape(_) => {
480+ Value::Int(_) | Value::Bool(_) | Value::Id(_) | Value::Fun(_) | Value::Shape(_) => {
899481 out.push_str(&pad);
900− out.push_str(&display_line(interp, v, pal, loaded)?);
482+ out.push_str(&display_line(interp, v, pal)?);
901483 out.push('\n');
902484 return Ok(());
903485 }
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),
486+ Value::List(xs) => return display_list_block(interp, xs, pal, indent, out),
915487 Value::Record(_) => {}
916488 }
917489 if let Some(shape) = shape_of_record(interp, v) {
918490 match shape.as_str() {
919− "Commit" => {
920− let loaded = Loaded::cached(interp, loaded)?;
921− return display_commit_block(interp, v, pal, indent, out, loaded);
922− }
491+ "Commit" => return display_commit_block(interp, v, pal, indent, out),
923492 "Entry" => {
924493 out.push_str(&pad);
925494 out.push_str(&entry_line(interp, v, pal)?);
@@ -933,7 +502,7 @@ fn display_block(
933502 ("children", v.field("children")?),
934503 ("context", Value::list(vec![])),
935504 ]);
936− let text = tree_render(interp, &opts, &repo, pal, false, term_w)?;
505+ let text = tree_render(interp, &opts, &repo, pal, false)?;
937506 out.push_str(&indent_multiline(&text, indent));
938507 return Ok(());
939508 }
@@ -949,14 +518,30 @@ fn display_block(
949518 }
950519 "Frame" => {
951520 out.push_str(&pad);
952− out.push_str(&line_in(interp, &v.field("parent")?, pal, loaded, false)?);
521+ out.push_str(&commit_line(
522+ interp,
523+ &v.field("parent")?,
524+ pal,
525+ false,
526+ &BTreeSet::new(),
527+ false,
528+ )?);
953529 out.push('\n');
954530 return Ok(());
955531 }
956532 "Change" => {
957− for (p, mark, _) in touched_paths(v)? {
533+ for (p, mark, unresolved) in touched_paths(v)? {
958534 out.push_str(&pad);
959− out.push_str(&format!("{} {}\n", colored_mark(mark, pal), p));
535+ let ms = mark.to_string();
536+ let colored = match mark {
537+ '+' => pal.green(&ms),
538+ '−' => pal.red(&ms),
539+ '~' => pal.yellow(&ms),
540+ '✖' => pal.red(&ms),
541+ _ => ms,
542+ };
543+ let _ = unresolved;
544+ out.push_str(&format!("{} {}\n", colored, p));
960545 }
961546 return Ok(());
962547 }
@@ -969,68 +554,19 @@ fn display_block(
969554 _ => unreachable!(),
970555 };
971556 let name_w = m.keys().map(|k| width(k)).max().unwrap_or(0);
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)) {
557+ for (k, x) in m.iter() {
1000558 out.push_str(&pad);
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)));
559+ out.push_str(&pal.dim(&pad_right(k, name_w)));
1007560 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− }
1023561 // lists print in block form under their key (§5.1)
1024562 if matches!(x, Value::List(xs) if !xs.is_empty()) {
1025563 out.push('\n');
1026− let after = Some(k.clone());
1027− todo.push(Block::Fields { m: m.clone(), after, name_w, indent });
1028− todo.push(Block::Of(x.clone(), indent + name_w + 2));
1029− return Ok(());
564+ display_block(interp, x, pal, indent + name_w + 2, out)?;
565+ } else {
566+ let line = display_line(interp, x, pal)?;
567+ out.push_str(&line);
568+ out.push('\n');
1030569 }
1031− let line = display_line(interp, x, pal, loaded)?;
1032− out.push_str(&line);
1033− out.push('\n');
1034570 }
1035571 Ok(())
1036572}
@@ -1047,204 +583,42 @@ fn indent_multiline(s: &str, indent: usize) -> String {
1047583 + "\n"
1048584}
1049585
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−
1188586fn display_commit_block(
1189587 interp: &mut Interp,
1190588 c: &Value,
1191589 pal: &Palette,
1192590 indent: usize,
1193591 out: &mut String,
1194− loaded: &Loaded,
1195592) -> Result<(), Crash> {
1196593 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)
1197598 let id = match c.field("id")? {
1198599 Value::Id(i) => i.to_string(),
1199600 _ => String::new(),
1200601 };
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());
602+ let files = c.field("files").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
1219603 if let Ok(meta) = interp.backend.meta(&id) {
1220604 let age = render_age(meta.time);
1221605 out.push_str(&pad);
1222− out.push_str(&format!(" {} · {} · {} files\n", meta.author, age, n));
1223− }
1224− if n > 0 {
1225− out.push('\n');
606+ out.push_str(&format!(
607+ " {} · {} · {} files\n",
608+ meta.author,
609+ age,
610+ files.len()
611+ ));
1226612 }
1227− match &touched {
1228− Some(touched) => {
1229− for (path, mark, _) in touched {
1230− out.push_str(&pad);
1231− out.push_str(&format!(
1232− " {} {}\n",
1233− colored_mark(*mark, pal),
1234− path.join("/")
1235− ));
1236− }
1237− }
1238− // its files, unmarked but for `✖`
1239− None => {
1240− for (path, content) in &own {
1241− out.push_str(&pad);
1242− if matches!(content, Value::Blob(b) if b.is_unresolved()) {
1243− out.push_str(&format!(" {} {}\n", pal.red("✖"), path.join("/")));
1244− } else {
1245− out.push_str(&format!(" {}\n", path.join("/")));
1246− }
1247− }
613+ for e in files.iter() {
614+ let path = path_string(&e.field("path")?)?;
615+ let content = e.field("content")?;
616+ let unresolved = matches!(&content, Value::Blob(b) if b.is_unresolved());
617+ out.push_str(&pad);
618+ if unresolved {
619+ out.push_str(&format!(" {} {}\n", pal.red("✖"), path));
620+ } else {
621+ out.push_str(&format!(" {}\n", path));
1248622 }
1249623 }
1250624 Ok(())
@@ -1289,25 +663,19 @@ pub fn render_date(time: i64) -> String {
1289663 format!("{:04}-{:02}-{:02}", y, m, d)
1290664}
1291665
1292−/// the repo value in context (the one the expression was given), for
1293−/// resolving ids to commits
666+/// the repo value in context (the loaded repo), for resolving ids to commits
1294667fn top_of(interp: &Interp, _x: &Value) -> Result<Value, Crash> {
1295− given_repo(interp).ok_or_else(|| Crash::new("display: no repository in context"))
668+ let old = interp.old_repo.borrow().clone();
669+ old.ok_or_else(|| Crash::new("display: no repository in context"))
1296670}
1297671
1298−/// `list` in block form, the blocks it holds left on `todo` (see `Block`)
1299−#[allow(clippy::too_many_arguments)]
1300672fn display_list_block(
1301673 interp: &mut Interp,
1302− list: &ListVal,
674+ xs: &[Value],
1303675 pal: &Palette,
1304676 indent: usize,
1305677 out: &mut String,
1306− loaded: &mut Option<Loaded>,
1307− todo: &mut Vec<Block>,
1308− term_w: Option<usize>,
1309678) -> Result<(), Crash> {
1310− let xs: &[Value] = list;
1311679 let pad = " ".repeat(indent);
1312680 if xs.is_empty() {
1313681 out.push_str(&pad);
@@ -1317,29 +685,22 @@ fn display_list_block(
1317685 }
1318686 // list of Id: commit table (one commit line per id, in list order)
1319687 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)?;
1330688 for x in xs {
1331689 let id = match x {
1332690 Value::Id(i) => i.to_string(),
1333691 _ => unreachable!(),
1334692 };
1335693 out.push_str(&pad);
1336− match commits.get(&id) {
1337− Some(c) => {
1338− let c = c.clone();
1339− // the glyph of its line form (§5.1)
1340− let glyph = node_glyph(&c, &id, &loaded.line_standing(interp, &c)?)?;
1341− let conflict = glyph == "⊗";
1342− let glyph = pal.code(node_ansi(&glyph), &glyph);
694+ let repo = top_of(interp, x)?;
695+ match crate::repo::by_id(&repo, &id)? {
696+ Some(loc) => {
697+ let c = loc.field("root")?;
698+ let conflict = has_conflict(&c)?;
699+ let glyph = if conflict {
700+ pal.red("⊗")
701+ } else {
702+ "○".to_string()
703+ };
1343704 let msg = c.field("message")?.as_text()?.to_string();
1344705 let msg_first = msg.lines().next().unwrap_or("");
1345706 let labels: Vec<String> = c
@@ -1349,7 +710,7 @@ fn display_list_block(
1349710 .map(|l| l.as_text().map(|s| s.to_string()))
1350711 .collect::<Result<_, _>>()?;
1351712 let id_s = if conflict {
1352− pal.red(&interp.unique_prefix(&id))
713+ pal.red(&interp.backend.unique_prefix(&id))
1353714 } else {
1354715 display_id(interp, &id, pal)
1355716 };
@@ -1361,7 +722,7 @@ fn display_list_block(
1361722 glyph,
1362723 id_s,
1363724 msg_first,
1364− pal.label(&labels.join(" "))
725+ pal.green(&labels.join(" "))
1365726 ));
1366727 }
1367728 }
@@ -1420,22 +781,17 @@ fn display_list_block(
1420781 _ => (String::new(), false),
1421782 };
1422783 out.push_str(&pad);
1423− let mark = if unresolved { pal.red("✖") } else { " ".into() };
784+ let mark = if unresolved { "✖" } else { " " };
1424785 let size_s = if unresolved {
1425786 pal.red(&size)
1426787 } else {
1427− pal.yellow(&size)
788+ size
1428789 };
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− ));
790+ out.push_str(&format!("{} {:width$} {}\n", mark, path, size_s, width = width_max));
1435791 }
1436792 return Ok(());
1437793 }
1438− return display_table(interp, xs, pal, indent, out, loaded, term_w);
794+ return display_table(interp, xs, pal, indent, out);
1439795 }
1440796 }
1441797 // list of other scalars
@@ -1448,8 +804,13 @@ fn display_list_block(
1448804 out.push('\n');
1449805 return Ok(());
1450806 }
1451− // one block per item, separated by a blank line (see `Block`)
1452− todo.push(Block::Items { xs: list.clone(), next: 0, indent });
807+ // one block per item, separated by a blank line
808+ for (i, x) in xs.iter().enumerate() {
809+ if i > 0 {
810+ out.push('\n');
811+ }
812+ display_block(interp, x, pal, indent, out)?;
813+ }
1453814 Ok(())
1454815}
1455816
@@ -1459,38 +820,18 @@ fn display_table(
1459820 pal: &Palette,
1460821 indent: usize,
1461822 out: &mut String,
1462− loaded: &mut Option<Loaded>,
1463− term_w: Option<usize>,
1464823) -> Result<(), Crash> {
1465824 let pad = " ".repeat(indent);
1466825 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()];
1474826 // cells
1475827 let mut rows: Vec<Vec<String>> = Vec::new();
1476828 for x in xs {
1477829 let mut row = Vec::new();
1478− for (column, f) in fields.iter().enumerate() {
830+ for f in &fields {
1479831 let cell = match x.field(f)? {
1480− Value::Bool(true) => pal.green("✓"),
832+ Value::Bool(true) => "✓".to_string(),
1481833 Value::Bool(false) => String::new(),
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)?,
834+ other => display_line(interp, &other, pal)?,
1494835 };
1495836 row.push(cell);
1496837 }
@@ -1500,7 +841,7 @@ fn display_table(
1500841 let keep: Vec<usize> = (0..fields.len())
1501842 .filter(|&i| rows.iter().any(|r| !r[i].is_empty()))
1502843 .collect();
1503− let mut widths: Vec<usize> = keep
844+ let widths: Vec<usize> = keep
1504845 .iter()
1505846 .map(|&i| {
1506847 rows.iter()
@@ -1510,40 +851,6 @@ fn display_table(
1510851 .max(width(&fields[i]))
1511852 })
1512853 .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− }
1547854 // header
1548855 out.push_str(&pad);
1549856 let header: Vec<String> = keep
@@ -1551,14 +858,14 @@ fn display_table(
1551858 .zip(&widths)
1552859 .map(|(&i, w)| pad_right(&fields[i], *w))
1553860 .collect();
1554− out.push_str(&pal.field(&header.join(" ")));
861+ out.push_str(&pal.dim(&header.join(" ")));
1555862 out.push('\n');
1556863 for r in &rows {
1557864 out.push_str(&pad);
1558865 let cells: Vec<String> = keep
1559866 .iter()
1560867 .zip(&widths)
1561− .map(|(&i, w)| pad_right(&cut_to_width(&r[i], *w), *w))
868+ .map(|(&i, w)| pad_right(&r[i], *w))
1562869 .collect();
1563870 out.push_str(&cells.join(" "));
1564871 out.push('\n');
@@ -1599,28 +906,7 @@ fn default_tree_options(_interp: &Interp) -> Result<TreeOptions, Crash> {
1599906 })
1600907}
1601908
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−
1612909pub 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> {
1624910 let defaults = default_tree_options(interp)?;
1625911 // a missing option falls back to its default, so an older config that
1626912 // predates a newer option keeps working; a present option is type-checked
@@ -1667,7 +953,7 @@ pub fn tree_with_width(
1667953 date,
1668954 files,
1669955 };
1670− let text = tree_render(interp, &o, repo, &pal, true, term_w)?;
956+ let text = tree_render(interp, &o, repo, &pal, true)?;
1671957 Ok(Value::text(text))
1672958}
1673959
@@ -1682,8 +968,8 @@ struct CommitInfo {
1682968 is_ancestor_of_focus: bool,
1683969 is_child_of_ancestor: bool,
1684970 meta: Option<crate::domain::MetaInfo>,
1685− size: Option<usize>, // lines added+removed against parent, up to SIZE_BAR_TOP
1686− nfiles: usize, // number of files changed against parent (`files` column)
971+ size: Option<usize>, // lines added+removed against parent
972+ nfiles: usize, // number of files in this commit's snapshot
1687973 detail_marks: Vec<(String, char)>,
1688974 children: Vec<CommitInfo>,
1689975}
@@ -1709,49 +995,6 @@ impl CommitInfo {
1709995 }
1710996}
1711997
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−
1755998#[allow(clippy::too_many_arguments)]
1756999fn build_info(
17571000 interp: &mut Interp,
@@ -1761,7 +1004,7 @@ fn build_info(
17611004 immutable: &BTreeSet<String>,
17621005 focus_id: &str,
17631006 anc: &BTreeSet<String>,
1764− parent_files: Option<&ParentSnap<'_>>,
1007+ parent_files: Option<&Value>,
17651008 with_focus: bool,
17661009 parent_is_ancestor: bool,
17671010 parent_is_focus: bool,
@@ -1778,13 +1021,9 @@ fn build_info(
17781021 .map(|l| l.as_text().map(|s| s.to_string()))
17791022 .collect::<Result<_, _>>()?;
17801023 // conflict and empty are O(1) backend queries when the backend can answer
1781− // them from tree ids (jj), without touching the file list at all. Those
1782− // answers are about the stored commits, so they are asked only while this
1783− // commit — and, for empty, its parent here — still holds its stored files:
1784− // a dry run (§1.2) renders edits that keep their ids but not their files.
1785− // Otherwise, as on the in-memory backend, fall back to walking the files.
1786− let stored = stored_origin(commit).filter(|o| *o == id);
1787− let conflict = match stored.and_then(|s| interp.backend.has_conflict(s)) {
1024+ // them from tree ids (jj), without touching the file list at all; the
1025+ // in-memory backend cannot, so fall back to walking the files
1026+ let conflict = match interp.backend.has_conflict(&id) {
17881027 Some(c) => c,
17891028 None => has_conflict(commit)?,
17901029 };
@@ -1797,67 +1036,56 @@ fn build_info(
17971036 matches!(c.field("root").and_then(|r| r.field("id")), Ok(Value::Id(i)) if *i == focus_id)
17981037 });
17991038 let want_diff = opts.detail >= 2 || is_focus_pre || child_is_focus || parent_is_focus;
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− };
1039+ let backend_empty = interp.backend.is_empty(&id);
18141040 // the files list is only materialized when something needs it: the diff
18151041 // (detail/focus), the files data column, or the emptiness fallback
1816− let (empty, size, detail_marks, nfiles) = match parent_files {
1817− Some(parent) => {
1042+ let need_files = want_diff || opts.files || backend_empty.is_none();
1043+ let files = if need_files {
1044+ Some(commit.field("files")?)
1045+ } else {
1046+ None
1047+ };
1048+ let nfiles = files.as_ref().map(|f| f.as_list().map(|l| l.len())).transpose()?.unwrap_or(0);
1049+ let (empty, size, detail_marks) = match parent_files {
1050+ Some(pf) => {
18181051 let empty = match backend_empty {
18191052 Some(e) => e,
1820− // in any order: a snapshot stands for a tree (§7.3)
1821− None => crate::repo::snapshot_eq(my_snap.files()?, parent.files()?)?,
1053+ None => crate::value::value_eq(files.as_ref().expect("files loaded"), pf)?,
18221054 };
18231055 if want_diff {
1824− // both maps are built at most once per commit and reused by
1825− // this commit's children, so a diff costs one keying, not four
1826− let from_map = parent.map()?;
1827− let to_map = my_snap.map()?;
1828− let marks = touched_in_maps(from_map, to_map)?;
1829− // size: lines added plus removed against the parent (§7.11
1830− // column 5); a path changed without a changed line (an empty
1831− // file, a new file type) counts one, so only an empty commit
1832− // has no bar. The count stops where the bar does.
1056+ let files = files.as_ref().expect("files loaded");
1057+ let change = Value::record(&[("from", pf.clone()), ("to", files.clone())]);
1058+ let marks = touched_paths(&change)?;
1059+ // size: lines added+removed against the parent
1060+ let from_map = snapshot_map_of(pf)?;
1061+ let to_map = snapshot_map_of(files)?;
18331062 let mut lines = 0usize;
1834− for (key, _, _) in &marks {
1835− if lines >= SIZE_BAR_TOP {
1836− break;
1837− }
1838− let cap = SIZE_BAR_TOP - lines;
1839− lines += changed_lines(from_map.get(key), to_map.get(key), cap)?.max(1);
1063+ for (p, _, _) in &marks {
1064+ let key: Vec<String> = p.split('/').map(|s| s.to_string()).collect();
1065+ let count = |m: &BTreeMap<Vec<String>, Value>| -> usize {
1066+ match m.get(&key) {
1067+ Some(Value::Blob(b)) => {
1068+ let n = b
1069+ .bytes()
1070+ .map(|v| String::from_utf8_lossy(&v).lines().count())
1071+ .unwrap_or(1);
1072+ n.max(1)
1073+ }
1074+ _ => 1,
1075+ }
1076+ };
1077+ lines += count(&from_map) + count(&to_map);
18401078 }
18411079 let marks2: Vec<(String, char)> =
1842− marks.iter().map(|(p, m, _)| (p.join("/"), *m)).collect();
1843− (marks.is_empty(), Some(lines), marks2, marks.len())
1080+ marks.iter().map(|(p, m, _)| (p.clone(), *m)).collect();
1081+ (marks.is_empty(), Some(lines), marks2)
18441082 } else {
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)
1083+ (empty, None, Vec::new())
18531084 }
18541085 }
18551086 None => {
1856− // the top of the history: no parent to compare against, and so no
1857− // backend answer either — it is empty iff it has no files (the
1858− // root never counts as empty), and every file it has is added
1859− let nfiles = my_snap.files()?.as_list()?.len();
1860− (nfiles == 0 && id != ROOT_ID, None, Vec::new(), nfiles)
1087+ let n = nfiles;
1088+ (n == 0 && id != ROOT_ID, None, Vec::new())
18611089 }
18621090 };
18631091 let meta = interp.backend.meta(&id).ok();
@@ -1873,7 +1101,7 @@ fn build_info(
18731101 immutable,
18741102 focus_id,
18751103 anc,
1876− Some(&my_snap),
1104+ files.as_ref(),
18771105 with_focus,
18781106 is_ancestor_of_focus,
18791107 is_focus_pre,
@@ -1903,7 +1131,6 @@ fn tree_render(
19031131 repo: &Value,
19041132 pal: &Palette,
19051133 with_focus: bool,
1906− term_w: Option<usize>,
19071134) -> Result<String, Crash> {
19081135 // immutable set
19091136 let immutable = crate::repo::compute_immutable(interp, repo).unwrap_or_default();
@@ -1957,37 +1184,39 @@ fn tree_render(
19571184 // Step 2 — row order (topological, oldest first)
19581185 let mut flat: Vec<&Display> = Vec::new();
19591186 flatten_display(&droot, &mut flat);
1960− // Display uids are dense. Keep the preorder position as the heap's
1961− // second key, so a newly ready descendant wins a time tie with a later
1962− // sibling just as it does in the specification's ready set.
1963− let mut preorder = vec![0; flat.len()];
1964− for (i, n) in flat.iter().enumerate() {
1965− preorder[n.uid()] = i;
1966− }
1967− let mut ready = BinaryHeap::from([Reverse((droot.time(), 0usize))]);
1187+ let preorder: BTreeMap<usize, usize> =
1188+ flat.iter().enumerate().map(|(i, n)| (n.uid(), i)).collect();
1189+ let mut ready: Vec<&Display> = vec![&droot];
19681190 let mut rows: Vec<&Display> = Vec::new();
1969− while let Some(Reverse((_, i))) = ready.pop() {
1970− let n = flat[i];
1191+ while !ready.is_empty() {
1192+ let best = ready
1193+ .iter()
1194+ .enumerate()
1195+ .min_by_key(|(_, n)| (n.time(), preorder[&n.uid()]))
1196+ .map(|(i, _)| i)
1197+ .unwrap();
1198+ let n = ready.remove(best);
19711199 rows.push(n);
19721200 for c in n.children() {
1973− ready.push(Reverse((c.time(), preorder[c.uid()])));
1201+ ready.push(c);
19741202 }
19751203 }
19761204 // Step 3 — lanes
1977− let index = RowIndex::new(&rows);
1978− let placements = assign_lanes(&rows, &index, &trunk, lanes_n);
1205+ let placements = assign_lanes(&rows, &trunk, lanes_n);
19791206 // id column: shortest unique prefix among the display tree's commits, min 4
1980− // (sorted once, so each row costs a lookup rather than a scan of them all)
1981− let mut ids: Vec<String> = rows
1207+ let ids: Vec<&str> = rows
19821208 .iter()
1983− .filter_map(|n| n.commit().map(|c| c.id.clone()))
1209+ .filter_map(|n| n.commit().map(|c| c.id.as_str()))
19841210 .collect();
1985− ids.sort();
19861211 let prefix_len = |id: &str| -> usize {
1987− crate::eval::unique_prefix_in(&ids, id).len()
1212+ let mut n = 4.min(id.len());
1213+ while n < id.len() && ids.iter().any(|o| *o != id && o.starts_with(&id[..n])) {
1214+ n += 1;
1215+ }
1216+ n
19881217 };
19891218 // Step 4 — draw
1990− draw_rows(&rows, &index, &placements, opts, pal, lanes_n, with_focus, &prefix_len, term_w)
1219+ draw_rows(&rows, &placements, opts, pal, lanes_n, with_focus, &prefix_len)
19911220}
19921221
19931222fn id_of_frame_parent(frame: &Value) -> Result<String, Crash> {
@@ -2016,7 +1245,11 @@ fn top_id(repo: &Value) -> Result<String, Crash> {
20161245fn compute_trunk(interp: &mut Interp, repo: &Value) -> Result<BTreeSet<String>, Crash> {
20171246 let mut set = BTreeSet::new();
20181247 set.insert(top_id(repo)?);
2019− let v = interp.apply_cached_revset("trunk", repo)?;
1248+ let trunk_fn = interp
1249+ .globals
1250+ .lookup("trunk")
1251+ .ok_or_else(|| Crash::new("`trunk` is not defined"))?;
1252+ let v = interp.apply(trunk_fn, repo.clone())?;
20201253 let mut named: Vec<String> = Vec::new();
20211254 for idv in v.as_list()?.iter() {
20221255 match idv {
@@ -2078,16 +1311,12 @@ impl Display {
20781311 }
20791312 }
20801313
2081− /// The node's time for the row order (Step 1). A node with no stored
2082− /// counterpart (minted in this program) has time ∞: it sorts after every
2083− /// stored one, not first as a `None` would.
2084− fn time(&self) -> i64 {
2085− let t = match self {
1314+ fn time(&self) -> Option<i64> {
1315+ match self {
20861316 Display::Commit { info, .. } => info.meta.as_ref().map(|m| m.time),
20871317 Display::Run { time, .. } => *time,
20881318 Display::Collapsed { info, .. } => info.meta.as_ref().map(|m| m.time),
2089− };
2090− t.unwrap_or(i64::MAX)
1319+ }
20911320 }
20921321
20931322 fn children(&self) -> &[Display] {
@@ -2248,10 +1477,6 @@ enum Rail {
22481477struct Placement {
22491478 lane: Option<usize>,
22501479 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>,
22551480 reservations: Vec<(usize, usize)>, // (lane, child uid)
22561481 flattened: bool,
22571482}
@@ -2263,70 +1488,26 @@ fn mark_subtree(n: &Display, set: &mut BTreeSet<usize>) {
22631488 }
22641489}
22651490
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> {
1491+fn assign_lanes(rows: &[&Display], trunk: &BTreeSet<String>, lanes_n: usize) -> Vec<Placement> {
23121492 let mut rails: Vec<Option<Rail>> = vec![None; lanes_n];
23131493 let mut out: Vec<Placement> = Vec::new();
23141494 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;
23181495 for (idx, n) in rows.iter().enumerate() {
23191496 let in_trunk = n.in_trunk(trunk);
2320− let parent_idx = index.parent[idx];
2321− let parent = parent_idx.map(|i| rows[i]);
2322− let parent_lane = parent_idx.and_then(|i| out[i].lane);
2323− let is_last_child = parent_idx
2324− .map(|i| index.last_child[i] == Some(idx))
1497+ let parent = rows[..idx]
1498+ .iter()
1499+ .copied()
1500+ .find(|p| p.children().iter().any(|c| c.uid() == n.uid()));
1501+ let parent_lane = parent.and_then(|p| out[rows[..idx].iter().position(|x| x.uid() == p.uid()).unwrap()].lane);
1502+ let is_last_child = parent
1503+ .map(|p| {
1504+ let cs = p.children();
1505+ cs[cs.len() - 1].uid() == n.uid()
1506+ })
23251507 .unwrap_or(false);
23261508 let was_flat = flattened.contains(&n.uid());
23271509 let mut lane: Option<usize> = None;
23281510 let mut fork: Option<(usize, bool)> = None;
2329− let mut ends: Option<usize> = None;
23301511 if !was_flat {
23311512 if idx == 0 {
23321513 lane = Some(0);
@@ -2352,18 +1533,7 @@ fn assign_lanes(
23521533 fork = Some((pl, false));
23531534 }
23541535 }
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− }
1536+ None => mark_subtree(n, &mut flattened),
23671537 }
23681538 }
23691539 } else {
@@ -2385,13 +1555,8 @@ fn assign_lanes(
23851555 let mut reservations: Vec<(usize, usize)> = Vec::new();
23861556 if !now_flat && in_trunk {
23871557 let cs = n.children();
2388− if let Some(t) = cs.iter().find(|c| c.in_trunk(trunk)) {
2389− let mut after: Vec<&Display> = cs
2390− .iter()
2391− .filter(|c| row_of[c.uid()] > row_of[t.uid()])
2392− .collect();
2393− after.sort_by_key(|c| row_of[c.uid()]);
2394− for c in after {
1558+ if let Some(tpos) = cs.iter().position(|c| c.in_trunk(trunk)) {
1559+ for c in &cs[tpos + 1..] {
23951560 if flattened.contains(&c.uid()) {
23961561 continue;
23971562 }
@@ -2408,7 +1573,6 @@ fn assign_lanes(
24081573 out.push(Placement {
24091574 lane: if now_flat { None } else { lane },
24101575 fork: if now_flat { None } else { fork },
2411− ends,
24121576 reservations,
24131577 flattened: now_flat,
24141578 });
@@ -2436,20 +1600,27 @@ struct RowText {
24361600
24371601fn build_row_text(
24381602 n: &Display,
2439− focus_adjacent: bool,
1603+ rows: &[&Display],
24401604 opts: &TreeOptions,
24411605 pal: &Palette,
24421606 with_focus: bool,
24431607 prefix_len: &dyn Fn(&str) -> usize,
1608+ run_extra: usize,
24441609 id_w: usize,
24451610) -> RowText {
24461611 let c = n.commit();
24471612 let is_focus = c.map(|x| x.is_focus).unwrap_or(false);
2448− let gutter = if is_focus && with_focus { pal.code("1;96", "▶ ") } else { " ".into() };
2449− // id (blank on a run row, whose count `rail_row` draws: the part that
2450− // overflows the rails area runs on into the id column)
1613+ let gutter = if is_focus && with_focus { "▶ " } else { " " }.to_string();
1614+ // id (on a run row the count follows ╎; only the part that overflows the
1615+ // rails area spills into the id column)
24511616 let id = match n {
2452− Display::Run { .. } => " ".repeat(id_w),
1617+ Display::Run { .. } => {
1618+ let mut s = String::new();
1619+ while width(&s) < run_extra + id_w {
1620+ s.push(' ');
1621+ }
1622+ s
1623+ }
24531624 _ => match c {
24541625 Some(info) => {
24551626 let k = prefix_len(&info.id);
@@ -2459,10 +1630,8 @@ fn build_row_text(
24591630 pal.red(&format!("@{}", p))
24601631 } else if info.immutable {
24611632 pal.blue(&format!("@{}", p))
2462− } else if info.is_focus {
2463− pal.code("1;96", &format!("@{}", p))
24641633 } else {
2465− pal.id(&format!("@{}", p))
1634+ format!("@{}", p)
24661635 };
24671636 let plain = k + 1; // +1 for the @
24681637 let padded = if plain < id_w {
@@ -2476,8 +1645,23 @@ fn build_row_text(
24761645 },
24771646 };
24781647 // 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);
24791663 let show_bar = opts.detail >= 1
2480− && c.map(|x| opts.detail >= 2 || x.is_focus || focus_adjacent)
1664+ && c.map(|x| opts.detail >= 2 || x.is_focus || is_focus_parent || is_focus_child)
24811665 .unwrap_or(false);
24821666 let bar = if show_bar {
24831667 c.map(|x| size_bar(x.size, pal)).unwrap_or_default()
@@ -2509,20 +1693,22 @@ fn build_row_text(
25091693 }
25101694 _ => (String::new(), String::new(), String::new()),
25111695 };
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
1696+ // extra columns (§Step 4): date, files changed, full author name
25151697 let mut meta_extra: Vec<String> = Vec::new();
25161698 if let Some(info) = c {
25171699 if info.id != ROOT_ID {
25181700 if opts.date {
2519− meta_extra.push(info.meta.as_ref().map(|m| render_date(m.time)).unwrap_or_default());
1701+ if let Some(m) = info.meta.as_ref() {
1702+ meta_extra.push(render_date(m.time));
1703+ }
25201704 }
25211705 if opts.files {
25221706 meta_extra.push(format!("{} files", info.nfiles));
25231707 }
25241708 if opts.author {
2525− meta_extra.push(info.meta.as_ref().map(|m| m.author.clone()).unwrap_or_default());
1709+ if let Some(m) = info.meta.as_ref() {
1710+ meta_extra.push(m.author.clone());
1711+ }
25261712 }
25271713 }
25281714 }
@@ -2542,59 +1728,78 @@ fn build_row_text(
25421728#[allow(clippy::too_many_arguments)]
25431729fn draw_rows(
25441730 rows: &[&Display],
2545− index: &RowIndex,
25461731 placements: &[Placement],
25471732 opts: &TreeOptions,
25481733 pal: &Palette,
25491734 lanes_n: usize,
25501735 with_focus: bool,
25511736 prefix_len: &dyn Fn(&str) -> usize,
2552− term_w: Option<usize>,
25531737) -> Result<String, Crash> {
25541738 let rail_chars = 2 * lanes_n;
2555− let overflow = placements.iter().any(|p| p.flattened);
2556− // each row's rails, drawn once; a run row's go on past the rails area
2557− // when its count does not fit
2558− let mut rail_state = vec![None; lanes_n];
2559− let rails: Vec<(Vec<char>, Vec<bool>)> = rows
2560− .iter()
2561− .zip(placements)
2562− .map(|(n, p)| {
2563− let row = rail_row(n, p, &rail_state, lanes_n, opts);
2564− advance_rail_state(&mut rail_state, n, p);
2565− row
2566− })
2567− .collect();
2568− // id column width: 4-char min prefix plus the `@` literal prefix, and
2569− // what a run count writes into it past the rails area and the space
2570− // after it
1739+ // id column width: 4-char min prefix plus the `@` literal prefix, and any
1740+ // overflow of a run count past the rails area
25711741 let mut id_w = 5usize;
2572− for (chars, _) in &rails {
2573− id_w = id_w.max(chars.len().saturating_sub(rail_chars + 1));
1742+ for (idx, n) in rows.iter().enumerate() {
1743+ if let Display::Run { count, .. } = n {
1744+ if let Some(l) = placements[idx].lane {
1745+ let digits = format!("{}", count).len();
1746+ let extra = (2 * l + 1 + digits).saturating_sub(rail_chars);
1747+ // digits written from 2l+1; rails hold rail_chars; overflow
1748+ // goes into the id column
1749+ if extra > 0 {
1750+ id_w = id_w.max(4 + extra);
1751+ }
1752+ }
1753+ }
25741754 }
25751755 for n in rows {
25761756 if let Some(c) = n.commit() {
25771757 id_w = id_w.max(prefix_len(&c.id) + 1); // +1 for the `@`
25781758 }
25791759 }
1760+ let text_off = 2 + rail_chars + 1;
1761+ let msg_off = text_off + id_w + 2;
25801762 let label_col = rows
25811763 .iter()
25821764 .any(|n| n.commit().map(|c| !c.labels.is_empty()).unwrap_or(false));
25831765 // build row texts
25841766 let mut texts: Vec<RowText> = Vec::new();
2585− for (i, n) in rows.iter().enumerate() {
2586− texts.push(build_row_text(n, index.focus_adjacent[i], opts, pal, with_focus, prefix_len, id_w));
2587− }
2588− // Columns 5–9 start at the same offset on every row (§Step 4). The size
2589− // bar's column is there when any row draws a bar — ` ▅`, two columns —
2590− // and blank on the rows that draw none, so a bar never shifts a message.
2591− let bar_col = texts.iter().any(|t| !t.bar.is_empty());
2592− // each row up to its message: gutter, rails, id and bar
2593− let mut heads: Vec<String> = Vec::new();
1767+ for (idx, n) in rows.iter().enumerate() {
1768+ let run_extra = match (n, placements[idx].lane) {
1769+ (Display::Run { count, .. }, Some(l)) => {
1770+ let digits = format!("{}", count).len();
1771+ (2 * l + 1 + digits).saturating_sub(rail_chars)
1772+ }
1773+ _ => 0,
1774+ };
1775+ texts.push(build_row_text(
1776+ n, rows, opts, pal, with_focus, prefix_len, run_extra, id_w,
1777+ ));
1778+ }
1779+ // right edges for the label and margin columns
1780+ let mut msg_end_max = 0usize;
1781+ let mut lab_end_max = 0usize;
1782+ for (idx, n) in rows.iter().enumerate() {
1783+ let t = &texts[idx];
1784+ if n.commit().is_none() {
1785+ continue;
1786+ }
1787+ let mut pos = msg_off;
1788+ if !t.bar.is_empty() {
1789+ pos += 1; // the bar takes one extra column before the message
1790+ }
1791+ pos += width(&t.msg);
1792+ if !t.labels.is_empty() {
1793+ pos += 2 + width(&t.labels);
1794+ }
1795+ msg_end_max = msg_end_max.max(pos - if t.labels.is_empty() { 0 } else { 2 + width(&t.labels) });
1796+ lab_end_max = lab_end_max.max(pos);
1797+ }
1798+ let mut lines: Vec<String> = Vec::new();
25941799 for (idx, n) in rows.iter().enumerate() {
25951800 let pl = &placements[idx];
25961801 let t = &texts[idx];
2597− let (chars, lane0) = &rails[idx];
1802+ let (chars, lane0) = rail_row(rows, placements, idx, lanes_n, opts);
25981803 let mut line = String::new();
25991804 line.push_str(&t.gutter);
26001805 // the node glyph (on a commit row) is meaning-coloured; rails lane-coloured
@@ -2602,119 +1807,59 @@ fn draw_rows(
26021807 (Display::Commit { info, .. }, Some(l)) => Some((2 * l, glyph_ansi(info))),
26031808 _ => None,
26041809 };
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− }
1810+ line.push_str(&color_rails(&chars, &lane0, pal, glyph));
26171811 line.push(' ');
26181812 line.push_str(&t.id);
2619− if bar_col {
1813+ if !t.bar.is_empty() {
26201814 line.push(' ');
2621− line.push_str(if t.bar.is_empty() { " " } else { &t.bar });
1815+ line.push_str(&t.bar);
26221816 }
26231817 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− }
26951818 line.push_str(&t.msg);
26961819 if label_col && !t.labels.is_empty() {
2697− pad_to(&mut line, lab_off);
2698− line.push_str(&pal.label(&t.labels));
2699− }
2700− if !metas[idx].is_empty() {
2701− pad_to(&mut line, meta_off);
2702− line.push_str(&metas[idx]);
1820+ let want = 2 + msg_end_max.saturating_sub(width(&t.msg));
1821+ line.push_str(&" ".repeat(want));
1822+ line.push_str(&pal.green(&pad_right(&t.labels, width(&t.labels))));
1823+ }
1824+ // metadata block: extra columns (date / files / author) then the margin
1825+ // (age + initials), right-aligned together past the message+labels edge
1826+ let has_margin = opts.margin && !t.age.is_empty();
1827+ if !t.meta_extra.is_empty() || has_margin {
1828+ let cur = if label_col && !t.labels.is_empty() {
1829+ msg_end_max + 2 + width(&t.labels)
1830+ } else {
1831+ width(&t.msg)
1832+ };
1833+ let want = 2 + lab_end_max.saturating_sub(cur);
1834+ line.push_str(&" ".repeat(want));
1835+ let mut meta: Vec<String> = Vec::new();
1836+ for e in &t.meta_extra {
1837+ meta.push(pal.grey(4, e));
1838+ }
1839+ if has_margin {
1840+ meta.push(pal.grey(4, &t.age));
1841+ meta.push(pal.author(&t.initials_author, &t.initials));
1842+ }
1843+ line.push_str(&meta.join(" "));
27031844 }
2704− // Remove layout padding, while preserving spaces in detail paths below.
2705− while line.ends_with(' ') { line.pop(); }
1845+ let _ = pl;
27061846 // the whole focus row is highlighted with a background band across the
27071847 // full terminal width (§Colour; colour only — the ▶ gutter still marks
27081848 // the focus when colour is off)
27091849 let is_focus_row = n.commit().map(|c| c.is_focus).unwrap_or(false);
27101850 if is_focus_row && pal.on {
2711− let padded = match term_w {
1851+ let target_w = if crate::show::stdout_is_tty() {
1852+ crate::show::terminal_width()
1853+ } else {
1854+ None
1855+ };
1856+ let padded = match target_w {
27121857 Some(w) if width(&line) < w => {
27131858 format!("{}{}", line, " ".repeat(w - width(&line)))
27141859 }
27151860 _ => line.clone(),
27161861 };
2717− lines.push(pal.bg_line("97;48;5;17", &padded));
1862+ lines.push(pal.bg_line("48;5;236", &padded));
27181863 } else {
27191864 lines.push(line);
27201865 }
@@ -2724,14 +1869,12 @@ fn draw_rows(
27241869 && n.commit().map(|c| c.is_focus && !c.detail_marks.is_empty()).unwrap_or(false)
27251870 {
27261871 let c = n.commit().unwrap();
2727− let (dchars, dlane0) = detail_rail_row(&rail_state);
1872+ let (dchars, dlane0) = detail_rail_row(rows, placements, idx, lanes_n);
27281873 let mut dline = String::new();
27291874 dline.push_str(" ");
27301875 dline.push_str(&color_rails(&dchars, &dlane0, pal, None));
2731− if overflow { dline.push_str(" "); }
27321876 dline.push(' ');
2733− // the marks start two columns into the id column (§Step 4)
2734− dline.push_str(" ");
1877+ dline.push_str(&" ".repeat(id_w + 2));
27351878 let marks: Vec<String> = c
27361879 .detail_marks
27371880 .iter()
@@ -2747,18 +1890,25 @@ fn draw_rows(
27471890 format!("{} {}", colored, p)
27481891 })
27491892 .collect();
2750− let detail_lines = match term_w {
2751− Some(w) => wrap_detail_items(&marks, w.saturating_sub(width(&dline)).max(1)),
2752− None => vec![marks.join(" ")],
2753− };
2754− for text in detail_lines {
2755− lines.push(format!("{}{}", dline, text));
2756− }
1893+ dline.push_str(&marks.join(" "));
1894+ lines.push(dline);
1895+ }
1896+ }
1897+ // message truncation to the terminal width (§Step 4)
1898+ if crate::show::stdout_is_tty() {
1899+ if let Some(w) = crate::show::terminal_width() {
1900+ truncate_lines(&mut lines, w, msg_off, label_col, opts.margin);
1901+ }
1902+ }
1903+ // no trailing whitespace on any row
1904+ for l in lines.iter_mut() {
1905+ while l.ends_with(' ') {
1906+ l.pop();
27571907 }
27581908 }
27591909 // Legend (§Legend): explain the symbols that actually appear, faintly, on
27601910 // the right of the tree if it fits the terminal width, else at the bottom.
2761− append_legend(&mut lines, rows, opts, pal, term_w, overflow);
1911+ append_legend(&mut lines, rows, opts, pal);
27621912 let mut out = lines.join("\n");
27631913 out.push('\n');
27641914 Ok(out)
@@ -2771,11 +1921,10 @@ struct UsedSymbols {
27711921 run: bool, // a ╎ n run row
27721922 collapsed: bool, // a ⋯ n collapsed node
27731923 focus_gutter: bool, // ▶ present (a focus row exists)
2774− overflow: bool, // separate » status column
27751924 detail_marks: Vec<char>, // + ~ − ✖ present in the focus's detail line
27761925}
27771926
2778−fn collect_used(rows: &[&Display], opts: &TreeOptions, icons: bool, overflow: bool) -> UsedSymbols {
1927+fn collect_used(rows: &[&Display], opts: &TreeOptions, icons: bool) -> UsedSymbols {
27791928 let mut present: BTreeSet<&'static str> = BTreeSet::new();
27801929 let mut run = false;
27811930 let mut collapsed = false;
@@ -2814,7 +1963,6 @@ fn collect_used(rows: &[&Display], opts: &TreeOptions, icons: bool, overflow: bo
28141963 collapsed,
28151964 focus_gutter,
28161965 detail_marks: marks.into_iter().collect(),
2817− overflow,
28181966 }
28191967}
28201968
@@ -2850,9 +1998,6 @@ fn legend_lines(used: &UsedSymbols) -> Vec<String> {
28501998 if used.focus_gutter {
28511999 out.push("▶ current commit".to_string());
28522000 }
2853− if used.overflow {
2854− out.push("» branch exceeds lane limit; increase lanes".to_string());
2855− }
28562001 if used.run {
28572002 out.push("╎ n run of n commits".to_string());
28582003 }
@@ -2869,15 +2014,8 @@ fn legend_lines(used: &UsedSymbols) -> Vec<String> {
28692014/// faint, to the right of a tree row starting one row from the top, past the
28702015/// widest tree line. If that would exceed the terminal width (or there is no
28712016/// terminal), put the legend at the bottom instead.
2872−fn append_legend(
2873− lines: &mut Vec<String>,
2874− rows: &[&Display],
2875− opts: &TreeOptions,
2876− pal: &Palette,
2877− tty_width: Option<usize>,
2878− overflow: bool,
2879−) {
2880− let used = collect_used(rows, opts, opts.icons, overflow);
2017+fn append_legend(lines: &mut Vec<String>, rows: &[&Display], opts: &TreeOptions, pal: &Palette) {
2018+ let used = collect_used(rows, opts, opts.icons);
28812019 let entries = legend_lines(&used);
28822020 if entries.is_empty() {
28832021 return;
@@ -2886,6 +2024,11 @@ fn append_legend(
28862024 let legend_w = entries.iter().map(|e| width(e)).max().unwrap_or(0);
28872025 let gap = 4;
28882026 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+ };
28892032 let fits_right = tty_width.map_or(false, |w| right_total <= w);
28902033 if fits_right {
28912034 // pad every tree line to tree_w, then append the legend entries dim
@@ -2913,37 +2056,50 @@ fn append_legend(
29132056 }
29142057}
29152058
2916−/// Update the rails above a row to the rails below it. Each occupied lane
2917−/// records whether it belongs to lane 0.
2918−fn advance_rail_state(state: &mut [Option<bool>], n: &Display, p: &Placement) {
2919− if let Some((src, clears)) = p.fork {
2920− if clears {
2921− state[src] = None;
2059+/// rails state below row `upto` (exclusive): which lanes hold rails, and which
2060+/// of those are lane-0
2061+fn rail_state_below(
2062+ rows: &[&Display],
2063+ placements: &[Placement],
2064+ upto: usize,
2065+ lanes_n: usize,
2066+) -> Vec<Option<bool>> {
2067+ let mut state: Vec<Option<bool>> = vec![None; lanes_n]; // Some(lane==0)
2068+ for (j, m) in rows.iter().enumerate().take(upto) {
2069+ let p = &placements[j];
2070+ if let Some((src, clears)) = p.fork {
2071+ if clears {
2072+ state[src] = None;
2073+ }
2074+ }
2075+ if let Some(l) = p.lane {
2076+ state[l] = if m.children().is_empty() {
2077+ None
2078+ } else {
2079+ Some(l == 0)
2080+ };
2081+ }
2082+ for &(l, _) in &p.reservations {
2083+ state[l] = Some(false);
29222084 }
29232085 }
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− }
2086+ state
29332087}
29342088
2935−/// Rails characters for one node's row (§Step 4, the character table).
2089+/// rails characters for the row of node `idx` (§Step 4, the character table).
29362090/// 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.
29382091fn rail_row(
2939− n: &Display,
2940− pl: &Placement,
2941− state: &[Option<bool>],
2092+ rows: &[&Display],
2093+ placements: &[Placement],
2094+ idx: usize,
29422095 lanes_n: usize,
29432096 opts: &TreeOptions,
29442097) -> (Vec<char>, Vec<bool>) {
2098+ let n = rows[idx];
2099+ let pl = &placements[idx];
29452100 let mut chars = vec![' '; 2 * lanes_n];
29462101 let mut lane0 = vec![false; 2 * lanes_n];
2102+ let state = rail_state_below(rows, placements, idx, lanes_n);
29472103 // horizontal segments: (start, end) in lane coordinates
29482104 let mut segments: Vec<(usize, usize)> = Vec::new();
29492105 if let Some((src, _)) = pl.fork {
@@ -2987,25 +2143,19 @@ fn rail_row(
29872143 }
29882144 } else if state[i].is_some() {
29892145 '│'
2146+ } else if pl.flattened && i == lanes_n - 1 {
2147+ '»'
29902148 } else {
29912149 ' '
29922150 };
29932151 chars[2 * i] = c;
29942152 lane0[2 * i] = i == 0;
29952153 }
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.
2154+ // on a run row the count follows ╎, written into the rails area
30012155 if let (Display::Run { count, .. }, Some(l)) = (n, pl.lane) {
3002− let from = chars.iter().rposition(|&c| c != ' ').unwrap_or(2 * l) + 1;
30032156 for (k, d) in format!(" {}", count).chars().enumerate() {
3004− let pos = from + k;
3005− if pos == chars.len() {
3006− chars.push(d);
3007− lane0.push(l == 0);
3008− } else {
2157+ let pos = 2 * l + 1 + k;
2158+ if pos < chars.len() {
30092159 chars[pos] = d;
30102160 lane0[pos] = l == 0;
30112161 }
@@ -3023,8 +2173,13 @@ fn rail_row(
30232173
30242174/// detail line (§Step 4): `│` in every lane that holds a rail below the focus
30252175/// row, including the focus's own lane if it has children
3026−fn detail_rail_row(state: &[Option<bool>]) -> (Vec<char>, Vec<bool>) {
3027− let lanes_n = state.len();
2176+fn detail_rail_row(
2177+ rows: &[&Display],
2178+ placements: &[Placement],
2179+ idx: usize,
2180+ lanes_n: usize,
2181+) -> (Vec<char>, Vec<bool>) {
2182+ let state = rail_state_below(rows, placements, idx + 1, lanes_n);
30282183 let mut chars = vec![' '; 2 * lanes_n];
30292184 let mut lane0 = vec![false; 2 * lanes_n];
30302185 for i in 0..lanes_n {
@@ -3037,10 +2192,8 @@ fn detail_rail_row(state: &[Option<bool>]) -> (Vec<char>, Vec<bool>) {
30372192}
30382193
30392194/// Colour the rails. Rail connectors are lane-coloured (lane 0 = immutable
3040−/// bright blue, others rotating accent hues); the node glyph at `glyph`
3041−/// (its lane position and ANSI code) is meaning-coloured. A glyph two
3042−/// columns wide (`icons`) fills both
3043−/// characters of its lane, so the one after it is not drawn (§Glyphs).
2195+/// blue, others dim); the node glyph at `glyph` (its lane position and ANSI
2196+/// code) is meaning-coloured.
30442197fn color_rails(
30452198 chars: &[char],
30462199 lane0: &[bool],
@@ -3048,12 +2201,7 @@ fn color_rails(
30482201 glyph: Option<(usize, &str)>,
30492202) -> String {
30502203 let mut s = String::new();
3051− let mut covered = false;
30522204 for (i, c) in chars.iter().enumerate() {
3053− if std::mem::take(&mut covered) {
3054− continue;
3055− }
3056− covered = UnicodeWidthChar::width(*c) == Some(2);
30572205 let t = c.to_string();
30582206 if *c == ' ' {
30592207 s.push(*c);
@@ -3063,148 +2211,128 @@ fn color_rails(
30632211 } else if lane0[i] {
30642212 s.push_str(&pal.blue(&t));
30652213 } else {
3066− s.push_str(&pal.rail(i / 2, &t));
2214+ s.push_str(&pal.dim(&t));
30672215 }
30682216 } else if lane0[i] {
30692217 s.push_str(&pal.blue(&t));
30702218 } else {
3071− s.push_str(&pal.rail(i / 2, &t));
2219+ s.push_str(&pal.dim(&t));
30722220 }
30732221 }
30742222 s
30752223}
30762224
3077−/// Wrap without dropping text or splitting ANSI sequences. Close and reopen
3078−/// active styles at row boundaries so continuation prefixes stay unstyled.
3079−fn wrap_display_text(text: &str, limit: usize) -> Vec<String> {
3080− let limit = limit.max(1);
3081− let mut lines = Vec::new();
3082− let mut line = String::new();
3083− let mut used = 0;
3084− let mut active = String::new();
3085− let mut chars = text.chars();
3086− while let Some(c) = chars.next() {
3087− if c == '\x1b' {
3088− let mut escape = String::from(c);
3089− for e in chars.by_ref() {
3090− escape.push(e);
3091− if e == 'm' { break; }
3092− }
3093− if escape == "\x1b[0m" { active.clear(); } else { active.push_str(&escape); }
3094− line.push_str(&escape);
2225+/// cut the message so labels and the margin keep their columns (§Step 4)
2226+fn truncate_lines(
2227+ lines: &mut [String],
2228+ term_w: usize,
2229+ msg_off: usize,
2230+ label_col: bool,
2231+ margin: bool,
2232+) {
2233+ for line in lines.iter_mut() {
2234+ if width(line) <= term_w {
30952235 continue;
30962236 }
3097− let cells = UnicodeWidthChar::width(c).unwrap_or(0);
3098− if used > 0 && used + cells > limit {
3099− if !active.is_empty() { line.push_str("\x1b[0m"); }
3100− lines.push(line);
3101− line = active.clone();
3102− used = 0;
3103− }
3104− line.push(c);
3105− used += cells;
3106− }
3107− if !active.is_empty() { line.push_str("\x1b[0m"); }
3108− lines.push(line);
3109− lines
3110−}
3111−
3112−/// Keep whole detail items together when possible; split only an item that
3113−/// cannot fit on an otherwise empty continuation row.
3114−fn wrap_detail_items(items: &[String], limit: usize) -> Vec<String> {
3115− let mut lines = Vec::new();
3116− let mut line = String::new();
3117− for item in items {
3118− if !line.is_empty() && width(&line) + 3 + width(item) <= limit {
3119− line.push_str(" ");
3120− line.push_str(item);
2237+ // find the message span: it starts at msg_off and ends where the
2238+ // label/margin padding (2+ spaces) begins
2239+ let plain = strip_ansi(line);
2240+ if width(&plain) <= term_w {
31212241 continue;
31222242 }
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);
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+ }
2268+ col += w;
2269+ }
2270+ if !label_col && !margin {
2271+ continue;
2272+ }
2273+ if let (Some(_cut), Some(_tail)) = (cut_at, tail_start) {
2274+ // recompute on the plain string, then rebuild: this path only
2275+ // matters on a tty, so keep it simple and operate on plain text
2276+ let p = strip_ansi(line);
2277+ let pw = width(&p);
2278+ if pw <= term_w {
2279+ continue;
2280+ }
2281+ // find message end (last run of 2+ spaces that starts the fixed tail)
2282+ let bytes = p.as_bytes();
2283+ let mut tail_bi = p.len();
2284+ let mut i = 0usize;
2285+ while i + 1 < bytes.len() {
2286+ if bytes[i] == b' ' && bytes[i + 1] == b' ' {
2287+ // candidate: is everything after this point the fixed tail?
2288+ tail_bi = i;
2289+ }
2290+ i += 1;
2291+ }
2292+ let tail = &p[tail_bi..];
2293+ let tail_w = width(tail);
2294+ let head_budget = term_w.saturating_sub(tail_w + 1);
2295+ let head = &p[..p
2296+ .char_indices()
2297+ .take_while(|(_, c)| {
2298+ let _ = c;
2299+ true
2300+ })
2301+ .count()
2302+ .min(p.len())];
2303+ let mut head_w = 0usize;
2304+ let mut head_end = 0usize;
2305+ for (bi, ch) in head.char_indices() {
2306+ let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
2307+ if head_w + cw > head_budget {
2308+ break;
2309+ }
2310+ head_w += cw;
2311+ head_end = bi + ch.len_utf8();
2312+ }
2313+ let head = head[..head_end].trim_end();
2314+ *line = format!("{}…{}", head, tail);
2315+ }
31272316 }
3128− if !line.is_empty() { lines.push(line); }
3129− lines
31302317}
31312318
3132−/// `s` cut to display width `w`, ending with `…` when anything is cut (and
3133−/// there is a column for it): the message on a terminal (§Step 4). Colour
3134−/// escapes are kept whole and not counted, and those past the cut still
3135−/// follow it, so every style `s` opens is closed.
3136−fn cut_to_width(s: &str, w: usize) -> String {
3137− if width(s) <= w {
3138− return s.to_string();
3139− }
3140− let mut kept = String::new();
3141− // escapes not yet followed by a kept character
3142− let mut pending = String::new();
3143− let mut used = 0usize;
3144− let mut in_esc = false;
3145− let mut full = false;
2319+fn strip_ansi(s: &str) -> String {
2320+ let mut out = String::new();
2321+ let mut esc = false;
31462322 for c in s.chars() {
3147− if c == '\x1b' {
3148− in_esc = true;
3149− }
3150− if in_esc {
3151− in_esc = c != 'm';
3152− pending.push(c);
3153− continue;
3154− }
3155− if full {
2323+ if esc {
2324+ if c == 'm' {
2325+ esc = false;
2326+ }
31562327 continue;
31572328 }
3158− // one column is left for the `…`
3159− let cw = UnicodeWidthChar::width(c).unwrap_or(0);
3160− if used + cw >= w {
3161− full = true;
2329+ if c == '\x1b' {
2330+ esc = true;
31622331 continue;
31632332 }
3164− kept.push_str(&pending);
3165− pending.clear();
3166− kept.push(c);
3167− used += cw;
2333+ out.push(c);
31682334 }
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));
3178− }
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()
2335+ out
32082336}
32092337
32102338fn glyph_for(info: &CommitInfo, icons: bool) -> &'static str {
@@ -3244,18 +2372,20 @@ fn glyph_for(info: &CommitInfo, icons: bool) -> &'static str {
32442372/// The ANSI colour code for a node glyph, by meaning (§Colour: glyphs carry
32452373/// meaning; colour only adds emphasis). Mirrors the precedence of `glyph_for`.
32462374fn glyph_ansi(info: &CommitInfo) -> &'static str {
3247− node_ansi(glyph_for(info, false))
3248−}
3249−
3250−fn node_ansi(glyph: &str) -> &'static str {
3251− match glyph {
3252− "⌂" => "1;95", // root: bold magenta
3253− "⊗" => "91", // conflict: bright red
3254− "◌" => "2", // empty: dim
3255− "◆" => "94", // immutable: bright blue
3256− "◉" => "1;96", // focus: bold cyan
3257− "●" => "92", // ancestor of focus: bright green
3258− _ => "97", // other commit: bright white
2375+ if info.id == ROOT_ID {
2376+ "1" // root: bold
2377+ } else if info.conflict {
2378+ "31" // conflict: red
2379+ } else if info.empty {
2380+ "2" // empty: dim
2381+ } else if info.immutable {
2382+ "34" // immutable: blue
2383+ } else if info.is_focus {
2384+ "1;36" // focus: bold cyan
2385+ } else if info.is_ancestor_of_focus {
2386+ "32" // ancestor of focus: green
2387+ } else {
2388+ "37" // other commit: light grey
32592389 }
32602390}
32612391
@@ -3263,17 +2393,16 @@ fn size_bar(size: Option<usize>, pal: &Palette) -> String {
32632393 match size {
32642394 None | Some(0) => String::new(),
32652395 Some(n) => {
3266− // one glyph per threshold 1, 10, 50, 200, 1000 (§7.11 column 5)
3267− let (bar, level) = if n >= SIZE_BAR_TOP {
2396+ let (bar, level) = if n >= 1000 {
32682397 ("▇", 4)
32692398 } else if n >= 200 {
3270− ("▅", 3)
2399+ ("▇", 3)
32712400 } else if n >= 50 {
3272− ("▃", 2)
2401+ ("▅", 2)
32732402 } else if n >= 10 {
3274− ("▂", 1)
2403+ ("▃", 1)
32752404 } else {
3276− ("▁", 0)
2405+ ("▂", 0)
32772406 };
32782407 pal.accent(level, bar)
32792408 }
@@ -3287,280 +2416,3 @@ fn initials(name: &str) -> String {
32872416 .collect::<String>()
32882417 .to_lowercase()
32892418}
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+69−499
@@ -1,115 +1,27 @@
11//! Repo-zipper operations, the reference `by` walk (§10), and the
22//! persistence validation shared by `validate` and the jj backend (§7.5).
33
4−use crate::domain::{Backend, ROOT_ID};
4+use crate::domain::ROOT_ID;
55use crate::eval::Interp;
66use crate::value::{value_eq, Crash, Value};
7−use std::collections::{BTreeMap, BTreeSet, HashMap};
7+use std::collections::{BTreeMap, BTreeSet};
88
99/// navigate: refocus `repo` on the commit with change id `id` (§10 reference).
10−///
11−/// Finding the commit and refocusing onto it are separate walks: refocusing
12−/// builds a new location — a record, with the siblings split around a hole —
13−/// so doing it for every node visited on the way cost an allocation per
14−/// commit. Only the commits on the path to the target need one, and there are
15−/// as many of those as the history is deep.
1610pub fn by_id(repo: &Value, id: &str) -> Result<Option<Value>, Crash> {
1711 let top = top_of(repo)?;
18− if id_is(&top.field("root")?, id)? {
19− return Ok(Some(top));
20− }
21− let mut path = Vec::new();
22− let children = top.field("children")?;
23− if !find_path(children.as_list()?, id, &mut path)? {
24− return Ok(None);
25− }
26− // Descend the path collecting one frame per level and assemble the
27− // location once. Refocusing step by step would rebuild the context at
28− // every level, which is quadratic in the depth on its own.
29− let mut root = top.field("root")?;
30− let mut children = top.field("children")?;
31− let mut frames: Vec<Value> = Vec::with_capacity(path.len());
32− for idx in path {
33− let kids = children.as_list()?;
34− let child = kids[idx].clone();
35− frames.push(Value::record(&[
36− ("left", Value::list(kids[..idx].to_vec())),
37− ("parent", root),
38− ("right", Value::list(kids[idx + 1..].to_vec())),
39− ]));
40− root = child.field("root")?;
41− children = child.field("children")?;
42− }
43− // innermost frame first: `up` reads context.first() as the parent
44− frames.reverse();
45− // `top_of` leaves no context above, but do not depend on that here
46− frames.extend_from_slice(top.field("context")?.as_list()?);
47− Ok(Some(Value::record(&[
48− ("children", children),
49− ("context", Value::list(frames)),
50− ("root", root),
51− ])))
52−}
53−
54−/// indices of the children to descend through to reach `id`, if it is there
55−fn find_path(children: &[Value], id: &str, path: &mut Vec<usize>) -> Result<bool, Crash> {
56− for (i, c) in children.iter().enumerate() {
57− path.push(i);
58− if id_is(&c.field("root")?, id)? {
59− return Ok(true);
12+ let mut stack = vec![top];
13+ while let Some(loc) = stack.pop() {
14+ let root_id = id_of(&loc.field("root")?)?;
15+ if root_id == id {
16+ return Ok(Some(loc));
6017 }
61− let grandchildren = c.field("children")?;
62− if find_path(grandchildren.as_list()?, id, path)? {
63− return Ok(true);
18+ let children = loc.field("children").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
19+ // push children as refocused locations; iterate in order
20+ for child in children.iter() {
21+ stack.push(refocus_child(&loc, child)?);
6422 }
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()))),
11223 }
24+ Ok(None)
11325}
11426
11527fn id_of(commit: &Value) -> Result<String, Crash> {
@@ -119,46 +31,12 @@ fn id_of(commit: &Value) -> Result<String, Crash> {
11931 }
12032}
12133
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−
13234/// move a location to the top of history
13335fn 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> {
15636 let mut cur = repo.clone();
15737 loop {
158− // ask whether the context is empty without copying it: this runs once
159− // per level, and copying made climbing quadratic in the depth
160− let ctx = cur.field("context")?;
161− if ctx.as_list()?.is_empty() {
38+ let ctx = cur.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
39+ if ctx.is_empty() {
16240 return Ok(cur);
16341 }
16442 cur = up_of(&cur)?;
@@ -182,18 +60,50 @@ fn up_of(repo: &Value) -> Result<Value, Crash> {
18260 ("children", repo.field("children")?),
18361 ]));
18462 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− };
19063 Ok(Value::record(&[
19164 ("children", Value::list(children)),
192− ("context", rest),
65+ ("context", Value::list(ctx[1..].to_vec())),
19366 ("root", parent),
19467 ]))
19568}
19669
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+
197107// ----------------------------------------------------------------------
198108// tree traversal helpers over Repo values
199109// ----------------------------------------------------------------------
@@ -224,166 +134,18 @@ pub fn parent_map(repo: &Value) -> Result<Vec<(String, String)>, Crash> {
224134 Ok(out)
225135}
226136
227−/// `tree` is the top location or a subtree below it: only its `root` and
228−/// `children` are read. Refocusing on each child instead copied the context
229−/// at every level, which is quadratic in the depth of the history, in time
230−/// and in what the walk holds at once.
231−fn parent_map_rec(tree: &Value, out: &mut Vec<(String, String)>) -> Result<(), Crash> {
232− let pid = id_of(&tree.field("root")?)?;
233− let children = tree.field("children")?;
137+fn parent_map_rec(loc: &Value, out: &mut Vec<(String, String)>) -> Result<(), Crash> {
138+ let pid = id_of(&loc.field("root")?)?;
139+ let children = loc.field("children")?;
234140 for c in children.as_list()?.iter() {
235141 out.push((id_of(&c.field("root")?)?, pid.clone()));
236− parent_map_rec(c, out)?;
142+ parent_map_rec(&refocus_child(loc, c)?, out)?;
237143 }
238144 Ok(())
239145}
240146
241147// ----------------------------------------------------------------------
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
148+// validation (§7.5 steps 1–3 and 6), shared by `validate` and persistence
387149// ----------------------------------------------------------------------
388150
389151#[derive(Debug)]
@@ -409,8 +171,6 @@ pub fn validate_repo(i: &mut Interp, new: &Value) -> Result<Validated, Crash> {
409171 if let Some(old) = &old {
410172 validate_immutable(old, new, &immutable)?;
411173 }
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())?;
414174 // focus mutable (§7.5 step 6)
415175 let focus_id = id_of(&new.field("root")?)?;
416176 if focus_id == ROOT_ID || immutable.contains(&focus_id) {
@@ -418,29 +178,9 @@ pub fn validate_repo(i: &mut Interp, new: &Value) -> Result<Validated, Crash> {
418178 "the focus must be a mutable commit; compose with new",
419179 ));
420180 }
421− validate_root(old.as_ref(), new)?;
422181 Ok(Validated { immutable })
423182}
424183
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−
444184fn check_repo_shape(v: &Value) -> Result<(), Crash> {
445185 let fields = v
446186 .field_set()
@@ -471,9 +211,8 @@ fn validate_ids_and_snapshots(repo: &Value, seen: &mut BTreeSet<String>) -> Resu
471211 validate_tree(&top, seen)
472212}
473213
474−/// `tree` is the top location or a subtree below it, as in `parent_map_rec`
475−fn validate_tree(tree: &Value, seen: &mut BTreeSet<String>) -> Result<(), Crash> {
476− let root = tree.field("root")?;
214+fn validate_tree(loc: &Value, seen: &mut BTreeSet<String>) -> Result<(), Crash> {
215+ let root = loc.field("root")?;
477216 let id = id_of(&root)?;
478217 if !seen.insert(id.clone()) {
479218 return Err(Crash::new(format!(
@@ -481,200 +220,27 @@ fn validate_tree(tree: &Value, seen: &mut BTreeSet<String>) -> Result<(), Crash>
481220 id
482221 )));
483222 }
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> {
223+ let files = root.field("files")?;
505224 let files = files.as_list()?;
506225 let mut paths = BTreeSet::new();
507226 for e in files {
508227 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− }
514228 let p = p.as_list()?;
515229 let key: Vec<String> = p
516230 .iter()
517231 .map(|c| c.as_text().map(|s| s.to_string()))
518232 .collect::<Result<_, _>>()?;
519− if key.is_empty() {
520− return Err(Crash::new("persistence: a snapshot has an entry at the root path ./"));
521− }
522233 if !paths.insert(key) {
523234 return Err(Crash::new("persistence: a snapshot has duplicate paths"));
524235 }
525236 }
526− if let Some(p) = crate::domain::file_and_directory(&paths) {
527− return Err(Crash::new(format!(
528− "persistence: a snapshot has `{}` as both a file and a directory",
529− p.join("/")
530− )));
531− }
532− Ok(())
533−}
534−
535−/// The name limit of ext4, xfs, btrfs and tmpfs (NAME_MAX), in bytes.
536−/// exFAT, NTFS, HFS+ and APFS count 255 UTF-16 units or characters instead,
537−/// so they hold longer names, and pathconf answers 255 on each: whether a
538−/// longer name fits is asked of the backend (`Backend::name_fits`). A
539−/// filesystem with a lower limit (encfs, eCryptfs) refuses a shorter name
540−/// at checkout, which then records nothing (§7.5 step 7).
541−pub const NAME_MAX: usize = 255;
542−
543−/// Whether a checkout cannot create the path component `comp` (§7.5 step
544−/// 1). jj stores any name that is not empty and has no `/`, but its
545−/// checkout refuses `.`, `..`, `.git` and `.jj`, and the filesystem a name
546−/// it cannot hold, part way through writing the focus; a git tree cannot
547−/// hold a NUL. Where the filesystem folds case, `.GIT` is the file `.git`,
548−/// which the checkout refuses by its file identity or, where that does not
549−/// show it, writes into. `fits` says whether a name longer than `NAME_MAX`
550−/// bytes is one the filesystem holds.
551−pub fn checkout_refuses(comp: &str, folds_case: bool, fits: impl FnOnce(&str) -> bool) -> bool {
552− matches!(comp, "" | "." | ".." | ".git" | ".jj")
553− || comp.contains(['/', '\0'])
554− || (folds_case && (comp.eq_ignore_ascii_case(".git") || comp.eq_ignore_ascii_case(".jj")))
555− || (comp.len() > NAME_MAX && !fits(comp))
556−}
557−
558−/// Path components (§7.5 step 1) of every commit persisting writes anew or
559−/// checks out must be ones a checkout can create (`checkout_refuses`).
560−///
561−/// A commit that keeps the files it has in `old` is checked only as the
562−/// focus: persisting at most rewrites it with the tree jj already stored and
563−/// never checks it out, and a fetched branch can hold `.jj`, so refusing its
564−/// names would stop every persist for a commit the script did not touch.
565−/// Immutable commits are such commits (step 3). One whose files differ,
566−/// such as a descendant a snapshot of the focus rebases, is checked only at
567−/// the paths it did not hold there: jj stored the others' names already.
568−///
569−/// A name longer than `NAME_MAX` is one the filesystem holds if the
570−/// working directory holds it, as the focus of `given` (its snapshot, §7.4)
571−/// shows: the checkout, which diffs from what is on disk, does not create
572−/// such a file again. Any other is asked of the backend.
573−fn validate_path_names(
574− old: Option<&Value>,
575− given: Option<&Value>,
576− new: &Value,
577− immutable: &BTreeSet<String>,
578− backend: &dyn Backend,
579−) -> Result<(), Crash> {
580− let folds_case = backend.folds_case();
581− // the long names the working directory holds, read on first need: the
582− // snapshot's file list is the whole tree
583− let mut held: Option<BTreeSet<String>> = None;
584− let focus = id_of(&new.field("root")?)?;
585− let stored: BTreeMap<String, Value> = match old {
586− Some(old) => all_commits(old)?
587− .into_iter()
588− .map(|c| id_of(&c).map(|id| (id, c)))
589− .collect::<Result<_, _>>()?,
590− None => BTreeMap::new(),
591− };
592− for c in all_commits(new)? {
593− let id = id_of(&c)?;
594− if immutable.contains(&id) {
595− continue;
596− }
597− // the commit as stored, when its files differ: a path it held there
598− // is not checked again
599− let mut kept = None;
600− if id != focus {
601− if let Some(o) = stored.get(&id) {
602− if same_files(o, &c)? {
603− continue;
604− }
605− kept = Some(o);
606− }
607− }
608− // the paths `kept` holds, read on first need: most commits hold no
609− // name a checkout refuses
610− let mut kept_paths: Option<BTreeSet<Vec<String>>> = None;
611− let files = c.field("files")?;
612− for e in files.as_list()? {
613− let path = e.field("path")?;
614− for comp in path.as_list()? {
615− let comp = comp.as_text()?;
616− if comp.len() > NAME_MAX && held.is_none() {
617− held = Some(match given {
618− Some(given) => long_names(&given.field("root")?.field("files")?)?,
619− None => BTreeSet::new(),
620− });
621− }
622− let on_disk = |c: &str| held.as_ref().is_some_and(|h| h.contains(c));
623− if !checkout_refuses(comp, folds_case, |c| on_disk(c) || backend.name_fits(c)) {
624− continue;
625− }
626− if let Some(o) = kept {
627− if kept_paths.is_none() {
628− let entries = o.field("files")?;
629− kept_paths = Some(entries.as_list()?.iter().filter_map(entry_path).collect());
630− }
631− if let (Some(k), Some(p)) = (&kept_paths, entry_path(e)) {
632− if k.contains(&p) {
633− break;
634− }
635− }
636− }
637− return Err(Crash::new(format!(
638− "persistence: a snapshot has the path component {:?}, which a checkout cannot create",
639− comp
640− )));
641− }
642− }
237+ let children = loc.field("children")?;
238+ for c in children.as_list()?.iter() {
239+ validate_tree(&refocus_child(loc, c)?, seen)?;
643240 }
644241 Ok(())
645242}
646243
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−
678244fn label_set(repo: &Value) -> Result<BTreeSet<(String, String)>, Crash> {
679245 let mut out = BTreeSet::new();
680246 for c in all_commits(repo)? {
@@ -700,7 +266,11 @@ fn validate_labels(old: &Value, new: &Value) -> Result<(), Crash> {
700266/// The immutable set: the config's `immutable` revset against `old`, plus
701267/// every merge commit and its ancestors (§7.5 step 3).
702268pub fn compute_immutable(i: &mut Interp, old: &Value) -> Result<BTreeSet<String>, Crash> {
703− let v = i.apply_cached_revset("immutable", old)?;
269+ let immutable_fn = i
270+ .globals
271+ .lookup("immutable")
272+ .ok_or_else(|| Crash::new("`immutable` is not defined"))?;
273+ let v = i.apply(immutable_fn, old.clone())?;
704274 let mut set = BTreeSet::new();
705275 for idv in v.as_list()?.iter() {
706276 match idv {
@@ -749,7 +319,7 @@ fn validate_immutable(old: &Value, new: &Value, immutable: &BTreeSet<String>) ->
749319 id
750320 )));
751321 }
752− if !snapshot_eq(&files_of(&c)?, &files_of(nc)?)? {
322+ if !value_eq(&c.field("files")?, &nc.field("files")?)? {
753323 return Err(Crash::new(format!(
754324 "persistence: commit {} is immutable (files changed)",
755325 id
src/setup.rsdeleted−119
@@ -1,119 +0,0 @@
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+60−104
@@ -2,38 +2,25 @@
22
33use crate::ast::TypeExpr;
44use crate::value::{PrimKind, ShapeKind, ShapeVal, Value};
5−use std::collections::{BTreeSet, HashMap, HashSet};
5+use std::collections::{BTreeSet, HashMap};
66
77#[derive(Default, Clone)]
88pub struct Shapes {
99 /// name -> its declared type
1010 pub decls: HashMap<String, TypeExpr>,
11− /// Memo for `shape_of`. A contract check against a record type asks for
12− /// the shape on every call, and computing it builds the field set afresh;
13− /// the declarations never change once the config is loaded.
14− memo: std::cell::RefCell<HashMap<String, Option<ShapeVal>>>,
1511}
1612
1713impl Shapes {
1814 pub fn new() -> Shapes {
19− Shapes::default()
15+ Shapes {
16+ decls: HashMap::new(),
17+ }
2018 }
2119
2220 /// Compute the ShapeVal for a type name used as a value (§4.12).
2321 /// Returns None if the name is undeclared, or its shape is a function,
2422 /// a list, or otherwise not usable.
2523 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> {
3724 match name {
3825 "Int" => Some(ShapeVal {
3926 name: name.into(),
@@ -67,13 +54,18 @@ impl Shapes {
6754 }
6855
6956 fn shape_of_type(&self, ty: &TypeExpr) -> Option<ShapeKind> {
70− // an alias takes its type's shape (§4.12); `unfold` is bounded, so a
71− // cycle of aliases (`Self = Self`) ends on a name like any other
72− match self.unfold(ty) {
73− TypeExpr::Con(n) if is_primitive(n) => self.shape_of(n).map(|s| s.kind),
74− // undeclared, a type variable, or an alias cycle: not a usable
75− // shape, and a crash when used (§4.12)
76− TypeExpr::Con(_) => None,
57+ match ty {
58+ TypeExpr::Con(n) => {
59+ if is_primitive(n) {
60+ let s = self.shape_of(n)?;
61+ Some(s.kind)
62+ } else if let Some(decl) = self.decls.get(n) {
63+ // resolve the alias now that all decls are present
64+ self.shape_of_type(decl)
65+ } else {
66+ Some(ShapeKind::Aliased(n.clone()))
67+ }
68+ }
7769 TypeExpr::Record(fields) => Some(ShapeKind::Record(
7870 fields.iter().map(|(n, _)| n.clone()).collect(),
7971 )),
@@ -81,25 +73,21 @@ impl Shapes {
8173 }
8274 }
8375
84− /// Unfold an alias chain by reference. Contracts are checked at every
85− /// call, so cloning the type each time — a `Vec` of `String`s for a record
86− /// type — was a per-application allocation.
87− pub fn unfold<'a>(&'a self, ty: &'a TypeExpr) -> &'a TypeExpr {
88− let mut cur = ty;
89− // a declaration cycle would otherwise spin here; the bound is far
90− // above any real alias chain
91− for _ in 0..64 {
92− match cur {
93− TypeExpr::Con(n) if !is_primitive(n) && !is_type_var(n) => {
94− match self.decls.get(n) {
95− Some(decl) => cur = decl,
96− None => return cur,
97− }
76+ /// Unfold an alias chain, owned, so it works with partially-populated
77+ /// decl maps too.
78+ pub fn unfold_owned(&self, ty: &TypeExpr) -> TypeExpr {
79+ match ty {
80+ TypeExpr::Con(n) => {
81+ if is_primitive(n) || is_type_var(n) {
82+ return ty.clone();
83+ }
84+ match self.decls.get(n) {
85+ Some(decl) => self.unfold_owned(decl),
86+ None => ty.clone(),
9887 }
99− _ => return cur,
10088 }
89+ _ => ty.clone(),
10190 }
102− cur
10391 }
10492}
10593
@@ -112,30 +100,24 @@ fn is_type_var(n: &str) -> bool {
112100}
113101
114102/// 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.
117103#[derive(Clone, Debug)]
118104pub struct Contract {
119− pub params: Vec<Rc2<TypeExpr>>,
120− pub result: Rc2<TypeExpr>,
105+ pub params: Vec<TypeExpr>,
106+ pub result: TypeExpr,
121107}
122108
123109pub fn compile_contract(shapes: &Shapes, ty: &TypeExpr) -> Contract {
124110 let mut params = Vec::new();
125111 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();
130112 loop {
131113 match cur {
132114 TypeExpr::Fun(a, b) => {
133− params.push(a.clone());
115+ params.push((**a).clone());
134116 cur = b;
135117 }
136118 TypeExpr::Con(n) => {
137119 // alias of a function type is unfolded (§4.13)
138− if !is_primitive(n) && !is_type_var(n) && unfolded.insert(n.as_str()) {
120+ if !is_primitive(n) && !is_type_var(n) {
139121 if let Some(decl) = shapes.decls.get(n) {
140122 cur = decl;
141123 continue;
@@ -148,13 +130,14 @@ pub fn compile_contract(shapes: &Shapes, ty: &TypeExpr) -> Contract {
148130 }
149131 Contract {
150132 params,
151− result: Rc2::new(cur.clone()),
133+ result: cur.clone(),
152134 }
153135}
154136
155137/// Check one level deep (§4.13). On failure, describe expected and got.
156138pub fn check(shapes: &Shapes, ty: &TypeExpr, v: &Value) -> Result<(), String> {
157− let ty = shapes.unfold(ty);
139+ let owned = shapes.unfold_owned(ty);
140+ let ty = &owned;
158141 match ty {
159142 TypeExpr::Con(n) => {
160143 if is_type_var(n) {
@@ -259,10 +242,7 @@ use std::rc::Rc as Rc2;
259242pub enum ContractExpr {
260243 /// no contract information; arg checks pass through
261244 Unknown,
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 },
245+ Known(Rc2<Contract>),
266246 /// compose(f, g): (f . g); both contract expressions
267247 Compose(Box<ContractExpr>, Box<ContractExpr>),
268248 /// apply e to an argument that checked against e's first parameter
@@ -271,14 +251,14 @@ pub enum ContractExpr {
271251
272252impl ContractExpr {
273253 /// The type of the next parameter this function expects, if known.
274− pub fn next_param(&self) -> Option<Rc2<TypeExpr>> {
254+ pub fn next_param(&self) -> Option<TypeExpr> {
275255 match self {
276256 ContractExpr::Unknown => None,
277− ContractExpr::Known { contract, at } => contract.params.get(*at).cloned(),
278− // (f . g) x = f (g x) (§4.9): the argument is g's, even when
279− // g's contract is unknown or used up, and f's own contract
280− // checks what g returns when it is applied to it
281− ContractExpr::Compose(_, g) => g.next_param(),
257+ ContractExpr::Known(c) => c.params.first().cloned(),
258+ ContractExpr::Compose(f, g) => g
259+ .next_param()
260+ .or_else(|| f.next_param())
261+ .or_else(|| f.result_after()),
282262 ContractExpr::ApplyFirst(e) => {
283263 let advanced = ContractExpr::apply_first(e.clone());
284264 advanced.next_param()
@@ -288,12 +268,12 @@ impl ContractExpr {
288268 }
289269
290270 /// The result type once all parameters are supplied.
291− pub fn result_after(&self) -> Option<Rc2<TypeExpr>> {
271+ pub fn result_after(&self) -> Option<TypeExpr> {
292272 match self {
293273 ContractExpr::Unknown => None,
294− ContractExpr::Known { contract, at } => {
295− if *at >= contract.params.len() {
296− Some(contract.result.clone())
274+ ContractExpr::Known(c) => {
275+ if c.params.is_empty() {
276+ Some(c.result.clone())
297277 } else {
298278 None
299279 }
@@ -313,7 +293,7 @@ impl ContractExpr {
313293 pub fn is_exhausted(&self) -> bool {
314294 match self {
315295 ContractExpr::Unknown => false,
316− ContractExpr::Known { contract, at } => *at >= contract.params.len(),
296+ ContractExpr::Known(c) => c.params.is_empty(),
317297 ContractExpr::Compose(_, g) => g.is_exhausted(),
318298 ContractExpr::ApplyFirst(_) => false,
319299 }
@@ -321,24 +301,19 @@ impl ContractExpr {
321301
322302 /// Advance after one argument has been supplied and checked.
323303 pub fn apply_first_rc(self: Rc2<ContractExpr>) -> ContractExpr {
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− }
304+ ContractExpr::apply_first(Box::new((*self).clone()))
333305 }
334306
335307 /// Advance after one argument has been supplied and checked.
336308 pub fn apply_first(self: Box<ContractExpr>) -> ContractExpr {
337309 match *self {
338310 ContractExpr::Unknown => ContractExpr::Unknown,
339− ContractExpr::Known { contract, at } => {
340− let at = contract.params.len().min(at + 1);
341− ContractExpr::Known { contract, at }
311+ ContractExpr::Known(c) => {
312+ let mut c2 = (*c).clone();
313+ if !c2.params.is_empty() {
314+ c2.params.remove(0);
315+ }
316+ ContractExpr::Known(Rc2::new(c2))
342317 }
343318 ContractExpr::Compose(f, g) => {
344319 if !g.is_exhausted() {
@@ -355,35 +330,16 @@ impl ContractExpr {
355330 /// Number of remaining parameters, if known and not nested.
356331 pub fn params_len(&self) -> usize {
357332 match self {
358− ContractExpr::Known { contract, at } => contract.params.len() - at,
333+ ContractExpr::Known(c) => c.params.len(),
359334 _ => 99,
360335 }
361336 }
362337
363− /// The 0-based position of the next argument among those the signature
364− /// lists, when known. Messages number arguments by the signature (§4.13:
365− /// "describe expected Text as argument 1"), not by what the function
366− /// value already holds: `tree`, which is `treeWith` applied to its
367− /// options, takes its argument 1, and so does `squash`, a composition.
368− pub fn position(&self) -> Option<usize> {
369− match self {
370− ContractExpr::Known { at, .. } => Some(*at),
371− ContractExpr::Compose(_, g) => g.position(),
372− _ => None,
373− }
374− }
375−
376− /// True when both are the same signature at the same position, so that
377− /// checking against one after the other repeats every check: the same
378− /// definition's contract, advanced as far. Only a known signature is
379− /// compared; any other is never the same.
380− pub fn same_as(&self, other: &ContractExpr) -> bool {
381− match (self, other) {
382− (
383− ContractExpr::Known { contract: a, at: i },
384− ContractExpr::Known { contract: b, at: j },
385− ) => Rc2::ptr_eq(a, b) && i == j,
386− _ => false,
338+ /// True if the next parameter is definitely a function type.
339+ pub fn next_param_is_function(&self, shapes: &Shapes) -> bool {
340+ match self.next_param() {
341+ Some(t) => matches!(shapes.unfold_owned(&t), TypeExpr::Fun(_, _)),
342+ None => false,
387343 }
388344 }
389345
src/show.rs+137−362
@@ -1,185 +1,103 @@
11//! `show` (§5.2), unified diff (§4.9 `diff`), and the difftastic bridge (§7.10).
22
33use crate::eval::Interp;
4−use crate::value::{BlobContent, BlobKind, BlobVal, Crash, FunVal, ThunkVal, Value};
5−use num_bigint::BigInt;
6−use unicode_width::UnicodeWidthChar;
4+use crate::value::{BlobContent, Crash, FunVal, Value};
75
8−/// Crashes where `v` holds a lazy part the store cannot read (§7.2), as any
9−/// other use of it does: a stand-in for the part would be the literal of
10−/// some other value, or would not parse.
11−///
12−/// Every level of `v` appends to the one string: a level that built its own
13−/// and copied its child's rendering into it cost the rendering's size times
14−/// its depth, and a Repo whose focus has a long line of descendants nests
15−/// them hundreds of levels deep, in megabytes of indentation. The renderers
16−/// recurse natively, once per level, so they also keep their frames small:
17−/// plain loops, a call's result returned as theirs without a `?` where it can
18−/// be, and the arms that end the recursion or seldom carry it on (a leaf, a
19−/// lazy part, a function) out of line.
20−pub fn show(interp: &Interp, v: &Value) -> Result<String, Crash> {
21− let mut s = String::new();
22− render(interp, v, &mut s)?;
23− // line breaking: one line if it fits in 80 columns, counted in display
24− // cells as everywhere else (§5.1), not code points. Not by
25− // `render::width`, which skips colour codes: `show` writes none, and an
26− // ESC from the data would hide everything up to the next `m`
27− let w: usize = s.chars().map(|c| UnicodeWidthChar::width(c).unwrap_or(0)).sum();
28− if w <= 80 {
29− return Ok(s);
6+pub fn show(interp: &Interp, v: &Value) -> String {
7+ let s = render(interp, v);
8+ // line breaking: one line if it fits in 80 columns
9+ if s.chars().count() <= 80 {
10+ return s;
3011 }
31− s.clear();
32− render_wide(interp, v, 0, &mut s)?;
33− Ok(s)
12+ render_wide(interp, v, 0)
3413}
3514
36−fn render(interp: &Interp, v: &Value, out: &mut String) -> Result<(), Crash> {
15+fn render(interp: &Interp, v: &Value) -> String {
3716 match v {
38− Value::Thunk(t) => return render_forced(interp, t, None, out),
39− Value::Int(n) => render_int(n, out),
40− Value::Text(t) => push_text_literal(t, out),
41− Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
42− Value::Id(id) => {
43− out.push('@');
44− out.push_str(id);
17+ Value::Thunk(t) => match t.force() {
18+ Ok(v) => render(interp, &v),
19+ Err(_) => "<lazy>".to_string(),
20+ },
21+ Value::Int(n) => {
22+ if n.sign() == num_bigint::Sign::Minus {
23+ format!("(0 - {})", n.magnitude())
24+ } else {
25+ n.to_string()
26+ }
4527 }
28+ Value::Text(t) => text_literal(t),
29+ Value::Bool(b) => b.to_string(),
30+ Value::Id(id) => format!("@{}", id),
4631 Value::List(xs) => {
4732 // list elements must be atoms: parenthesise applications and
4833 // operator expressions (§3.4)
49− out.push('[');
50− for (i, x) in xs.iter().enumerate() {
51− if i > 0 {
52− out.push(' ');
53− }
54− render_atom(interp, x, out)?;
55− }
56− out.push(']');
34+ let parts: Vec<String> = xs.iter().map(|x| render_atom(interp, x)).collect();
35+ format!("[{}]", parts.join(" "))
5736 }
5837 Value::Record(m) => {
59− out.push_str("{ ");
60− for (i, (k, x)) in m.iter().enumerate() {
61− if i > 0 {
62− out.push_str(", ");
63− }
64− out.push_str(k);
65− out.push_str(" = ");
66− render(interp, x, out)?;
67− }
68− out.push_str(" }");
69− }
70− Value::Blob(b) => return render_blob(interp, b, out),
71− Value::Shape(s) => out.push_str(&s.name),
72− Value::Fun(f) => return render_fun(interp, f, out),
73− }
74− Ok(())
75−}
76−
77−#[inline(never)]
78−fn render_int(n: &BigInt, out: &mut String) {
79− if n.sign() == num_bigint::Sign::Minus {
80− out.push_str(&format!("(0 - {})", n.magnitude()));
81− } else {
82− out.push_str(&n.to_string());
83− }
84−}
85−
86−#[inline(never)]
87−fn render_blob(interp: &Interp, b: &BlobVal, out: &mut String) -> Result<(), Crash> {
88− // Unresolved blobs render as marker text, explicitly outside the
89− // round-trip guarantee. Resolved blobs must preserve bytes and kind.
90− if b.is_unresolved() {
91− let bytes = b.bytes()?;
92− out.push_str("{- unresolved -} blob ");
93− push_text_literal(&String::from_utf8_lossy(&bytes), out);
94− return Ok(());
95− }
96− let bytes = match &b.content {
97− BlobContent::Resolved(bytes) => bytes.clone(),
98− BlobContent::Lazy(lazy) => lazy.force()?,
99− BlobContent::Conflict(_) => unreachable!("handled above"),
100− };
101− if b.kind == BlobKind::Regular {
102− if let Ok(text) = std::str::from_utf8(&bytes) {
103− out.push_str("blob ");
104− push_text_literal(text, out);
105− return Ok(());
38+ let parts: Vec<String> = m
39+ .iter()
40+ .map(|(k, x)| format!("{} = {}", k, render(interp, x)))
41+ .collect();
42+ format!("{{ {} }}", parts.join(", "))
10643 }
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− ));
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(())
125−}
126−
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),
44+ Value::Blob(b) => match &b.content {
45+ BlobContent::Resolved(bytes) => match String::from_utf8(bytes.as_ref().clone()) {
46+ Ok(s) => format!("blob {}", text_literal(&s)),
47+ Err(_) => "blob \"<binary>\"".into(),
48+ },
49+ BlobContent::Lazy(_) | BlobContent::Conflict(_) => {
50+ let s = b
51+ .bytes()
52+ .map(|v| String::from_utf8_lossy(&v).to_string())
53+ .unwrap_or_else(|_| "<unreadable>".into());
54+ let marker = if b.is_unresolved() { "{- unresolved -} " } else { "" };
55+ format!("{}blob {}", marker, text_literal(&s))
56+ }
57+ },
58+ Value::Shape(s) => s.name.clone(),
59+ Value::Fun(f) => render_fun(interp, f),
13660 }
13761}
13862
139−#[inline(never)]
140−fn render_fun(interp: &Interp, f: &FunVal, out: &mut String) -> Result<(), Crash> {
63+fn render_fun(interp: &Interp, f: &FunVal) -> String {
14164 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− }
15665 FunVal::Builtin { name, args, .. } => {
157− // a selector `.name` carries its field as a baked-in first
158− // argument (builtins::make_selector) and renders as the selector
159− // atom itself (§3.4), not as an application to that field
160− if name.starts_with('.') && name.len() > 1 {
161− out.push_str(name);
162− return Ok(());
163− }
164− // operator builtins render parenthesised; `(.)` is its own name
165− if is_operator_name(name) {
166− out.push('(');
167− out.push_str(name);
168− out.push(')');
169− } else {
170− out.push_str(name);
171− }
172− // a partial application: the name followed by the applied args,
173− // which may contain a baked-in value (compose)
174− if name == "(.)" && args.len() == 2 {
175− for a in args {
176− out.push_str(" (");
177− render_atom(interp, a, out)?;
178− out.push(')');
66+ // operator builtins render parenthesised when bare
67+ if args.is_empty() {
68+ if is_operator_name(name) {
69+ format!("({})", name)
70+ } else if name.starts_with('.') && name.len() > 1 {
71+ name.clone() // selector .name
72+ } else if name == "(.)" {
73+ "(.)".into()
74+ } else {
75+ name.clone()
17976 }
180− Ok(())
18177 } else {
182− render_args(interp, args, out)
78+ // partial application: name followed by applied args
79+ let base = if is_operator_name(name) {
80+ format!("({})", name)
81+ } else if name == "(.)" {
82+ "(.)".into()
83+ } else {
84+ name.clone()
85+ };
86+ // args may contain a baked-in value (compose, selectors)
87+ match name.as_str() {
88+ "(.)" if args.len() == 2 => {
89+ format!(
90+ "(.) ({}) ({})",
91+ render_atom(interp, &args[0]),
92+ render_atom(interp, &args[1])
93+ )
94+ }
95+ _ => {
96+ let shown: Vec<String> =
97+ args.iter().map(|a| render_atom(interp, a)).collect();
98+ format!("{} {}", base, shown.join(" "))
99+ }
100+ }
183101 }
184102 }
185103 FunVal::Closure {
@@ -187,67 +105,46 @@ fn render_fun(interp: &Interp, f: &FunVal, out: &mut String) -> Result<(), Crash
187105 applied_args,
188106 ..
189107 } => {
190− // a partial application of a named definition: the name followed
191− // by the arguments supplied so far (§5.2)
192− out.push_str(n);
193− render_args(interp, applied_args, out)
108+ if applied_args.is_empty() {
109+ n.clone()
110+ } else {
111+ // partial application of a named definition: the name followed
112+ // by the arguments supplied so far (§5.2)
113+ let args: Vec<String> =
114+ applied_args.iter().map(|a| render_atom(interp, a)).collect();
115+ format!("{} {}", n, args.join(" "))
116+ }
194117 }
195118 FunVal::Closure {
196119 name: None,
197− applied_args,
120+ applied,
198121 params,
199122 body,
200123 src,
201124 ..
202125 } => {
203− // a lambda or section renders as its source, exactly as written
204− // (§5.2); only an AST built without source is re-rendered. A
205− // partial application parenthesises it, as it is not atomic, and
206− // follows it with the arguments supplied so far (§5.2)
207− let applied = !applied_args.is_empty();
208− if applied {
209− out.push('(');
210− }
211− if src.as_str().is_empty() {
212− out.push_str(&crate::parse::render_lambda(params, body));
126+ if *applied == 0 {
127+ if src.is_empty() || src == "section" {
128+ crate::parse::render_lambda(params, body)
129+ } else {
130+ src.clone()
131+ }
213132 } else {
214− out.push_str(src.as_str());
133+ let base = if src.is_empty() || src == "section" {
134+ crate::parse::render_lambda(params, body)
135+ } else {
136+ src.clone()
137+ };
138+ format!("({}) <applied>", base)
215139 }
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(())
226140 }
227− FunVal::ComposeLazy(f, g) => render_infix(interp, f, " . ", g, out),
228− }
229−}
230−
231−/// `(a op b)`, for the functions an `or` or a composition builds
232−fn render_infix(interp: &Interp, a: &Value, op: &str, b: &Value, out: &mut String) -> Result<(), Crash> {
233− out.push('(');
234− render_atom(interp, a, out)?;
235− out.push_str(op);
236− render_atom(interp, b, out)?;
237− out.push(')');
238− Ok(())
239−}
240−
241−/// The arguments of a partial application, each after a space.
242−fn render_args(interp: &Interp, args: &[Value], out: &mut String) -> Result<(), Crash> {
243− for a in args {
244− out.push(' ');
245− render_atom(interp, a, out)?;
141+ FunVal::OrFun(a, b) => format!("({} or {})", render_atom(interp, a), render_atom(interp, b)),
142+ FunVal::Labelled(n, _) => format!("%{}", n),
143+ FunVal::ComposeLazy(f, g) => format!("({} . {})", render_atom(interp, f), render_atom(interp, g)),
246144 }
247− Ok(())
248145}
249146
250−fn render_atom(interp: &Interp, v: &Value, out: &mut String) -> Result<(), Crash> {
147+fn render_atom(interp: &Interp, v: &Value) -> String {
251148 // parenthesise if not atomic; a nested list is self-delimiting (a `[`
252149 // cannot begin a postfix), but a record is not: `{ … }` following another
253150 // element would parse as a record *update* on it (§3.4), so records keep
@@ -255,29 +152,12 @@ fn render_atom(interp: &Interp, v: &Value, out: &mut String) -> Result<(), Crash
255152 let atomic = matches!(
256153 v,
257154 Value::Int(_) | Value::Text(_) | Value::Bool(_) | Value::Id(_) | Value::Shape(_) | Value::List(_)
258− ) || is_section(v);
259− if !atomic {
260− out.push('(');
261− }
262− render(interp, v, out)?;
263− if !atomic {
264− out.push(')');
265− }
266− Ok(())
267−}
268−
269−/// A section renders as its source, `(op e)` or `(e op)` (§5.2), which is
270−/// already an atom (§3.4) and takes no parentheses of its own. A lambda's
271−/// source begins with its `\` instead, and a section, taking one argument,
272−/// is never partially applied.
273−fn is_section(v: &Value) -> bool {
274− match v {
275− Value::Fun(f) => matches!(
276− f.as_ref(),
277− FunVal::Closure { name: None, applied_args, src, .. }
278− if applied_args.is_empty() && src.as_str().starts_with('(')
279− ),
280− _ => false,
155+ );
156+ let s = render(interp, v);
157+ if atomic {
158+ s
159+ } else {
160+ format!("({})", s)
281161 }
282162}
283163
@@ -301,13 +181,7 @@ fn is_operator_name(n: &str) -> bool {
301181}
302182
303183pub fn text_literal(s: &str) -> String {
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('"');
184+ let mut out = String::from("\"");
311185 for c in s.chars() {
312186 match c {
313187 '"' => out.push_str("\\\""),
@@ -319,22 +193,21 @@ fn push_text_literal(s: &str, out: &mut String) {
319193 }
320194 }
321195 out.push('"');
196+ out
322197}
323198
324−fn render_wide(interp: &Interp, v: &Value, indent: usize, out: &mut String) -> Result<(), Crash> {
199+fn render_wide(interp: &Interp, v: &Value, indent: usize) -> String {
325200 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),
329201 Value::List(xs) if !xs.is_empty() => {
330− out.push('[');
202+ let mut out = String::from("[");
331203 for (i, x) in xs.iter().enumerate() {
332204 if i > 0 {
333205 out.push('\n');
334206 out.push_str(&" ".repeat(indent + 2));
335207 }
336208 // list elements must be atoms (§3.4)
337− let atomic = matches!(
209+ let s = render_wide(interp, x, indent + 2);
210+ if matches!(
338211 x,
339212 Value::Int(_)
340213 | Value::Text(_)
@@ -343,34 +216,30 @@ fn render_wide(interp: &Interp, v: &Value, indent: usize, out: &mut String) -> R
343216 | Value::Shape(_)
344217 | Value::List(_)
345218 | Value::Record(_)
346− ) || is_section(x);
347− if !atomic {
348− out.push('(');
349− }
350− render_wide(interp, x, indent + 2, out)?;
351− if !atomic {
352− out.push(')');
219+ ) {
220+ out.push_str(&s);
221+ } else {
222+ out.push_str(&format!("({})", s));
353223 }
354224 }
355225 out.push(']');
226+ out
356227 }
357228 Value::Record(m) if !m.is_empty() => {
358− out.push_str("{ ");
229+ let mut out = String::from("{ ");
359230 for (i, (k, x)) in m.iter().enumerate() {
360231 if i > 0 {
361232 out.push('\n');
362233 out.push_str(&" ".repeat(indent));
363234 out.push_str(", ");
364235 }
365− out.push_str(k);
366− out.push_str(" = ");
367− render_wide(interp, x, indent + 2, out)?;
236+ out.push_str(&format!("{} = {}", k, render_wide(interp, x, indent + 2)));
368237 }
369238 out.push_str(" }");
239+ out
370240 }
371− _ => return render(interp, v, out),
241+ _ => render(interp, v),
372242 }
373− Ok(())
374243}
375244
376245// ----------------------------------------------------------------------
@@ -378,38 +247,10 @@ fn render_wide(interp: &Interp, v: &Value, indent: usize, out: &mut String) -> R
378247// ----------------------------------------------------------------------
379248
380249pub fn unified_diff(a: &str, b: &str) -> String {
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);
250+ let diff = similar::TextDiff::from_lines(a, b);
387251 let mut out = String::new();
388252 for hunk in diff.unified_diff().context_radius(3).iter_hunks() {
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− ));
253+ out.push_str(&format!("{}\n", hunk.header()));
413254 for change in hunk.iter_changes() {
414255 let sign = match change.tag() {
415256 similar::ChangeTag::Delete => "-",
@@ -417,33 +258,19 @@ pub fn unified_diff(a: &str, b: &str) -> String {
417258 similar::ChangeTag::Equal => " ",
418259 };
419260 out.push_str(sign);
420− let text: &str = change.value();
421− out.push_str(text);
422− // a line with no terminator, only ever the last, is ended here
423− // and marked, as GNU diff and git do: otherwise dropping the
424− // final newline shows as the same line removed and re-added,
425− // alike in both directions
261+ let text = change.to_string();
262+ // similar yields the line with its terminator when present; the
263+ // last line of a file without a trailing newline has none, so add
264+ // one to keep the diff line-oriented
265+ out.push_str(&text);
426266 if !text.ends_with('\n') {
427267 out.push('\n');
428− out.push_str("\\ No newline at end of file\n");
429268 }
430269 }
431270 }
432271 out
433272}
434273
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−
447274// ----------------------------------------------------------------------
448275// difftastic (§7.10)
449276// ----------------------------------------------------------------------
@@ -455,13 +282,6 @@ pub fn difft(path: &Value, a: &Value, b: &Value) -> Result<Value, Crash> {
455282 .map(|c| c.as_text().map(|s| s.to_string()))
456283 .transpose()?
457284 .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− }
465285 let a_bytes = match a {
466286 Value::Blob(bl) => bl.bytes()?,
467287 v => return Err(Crash::new(format!("difft: expected a Blob, got a {}", v.kind_name()))),
@@ -472,22 +292,13 @@ pub fn difft(path: &Value, a: &Value, b: &Value) -> Result<Value, Crash> {
472292 };
473293 let dir = tempfile::tempdir()
474294 .map_err(|e| Crash::new(format!("difft: cannot create temp dir: {}", e)))?;
475− // Keep the filename unchanged: adding a prefix can exceed NAME_MAX for
476− // a valid tracked path, and some languages are detected by exact name.
477− let old_dir = dir.path().join("old");
478− let new_dir = dir.path().join("new");
479− std::fs::create_dir(&old_dir).map_err(|e| Crash::new(format!("difft: {}", e)))?;
480− std::fs::create_dir(&new_dir).map_err(|e| Crash::new(format!("difft: {}", e)))?;
481− let old = old_dir.join(name);
482− let new = new_dir.join(name);
295+ let old = dir.path().join(format!("old-{}", last));
296+ let new = dir.path().join(format!("new-{}", last));
483297 std::fs::write(&old, &a_bytes).map_err(|e| Crash::new(format!("difft: {}", e)))?;
484298 std::fs::write(&new, &b_bytes).map_err(|e| Crash::new(format!("difft: {}", e)))?;
485299 let mut cmd = std::process::Command::new("difft");
486300 cmd.arg(&old).arg(&new);
487301 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());
491302 if std::env::var_os("DFT_COLOR").is_none() && stdout_is_tty() {
492303 cmd.env("DFT_COLOR", "always");
493304 }
@@ -496,48 +307,12 @@ pub fn difft(path: &Value, a: &Value, b: &Value) -> Result<Value, Crash> {
496307 cmd.env("DFT_WIDTH", w.to_string());
497308 }
498309 }
499− crate::terminal::external_stderr();
500− let out = match cmd.output() {
501− Ok(out) => out,
502− Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
503− return fallback_diff(comps, &a_bytes, &b_bytes);
504− }
505− Err(error) => return Err(Crash::new(format!("difft: cannot execute `difft`: {}", error))),
506− };
310+ let out = cmd
311+ .output()
312+ .map_err(|_| Crash::new("difft: cannot execute `difft` (not on PATH)"))?;
507313 Ok(Value::text(String::from_utf8_lossy(&out.stdout).to_string()))
508314}
509315
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−
541316pub fn stdout_is_tty() -> bool {
542317 unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 }
543318}
src/terminal.rsdeleted−276
@@ -1,276 +0,0 @@
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+89−700
@@ -3,7 +3,7 @@
33use crate::ast::{Expr, Pattern};
44use num_bigint::BigInt;
55use std::collections::{BTreeMap, BTreeSet, HashMap};
6−use std::rc::{Rc, Weak};
6+use std::rc::Rc;
77
88pub type RecordMap = BTreeMap<String, Value>;
99
@@ -12,7 +12,7 @@ pub enum Value {
1212 Int(BigInt),
1313 Text(Rc<String>),
1414 Bool(bool),
15− List(ListVal),
15+ List(Rc<Vec<Value>>),
1616 Record(Rc<RecordMap>),
1717 Fun(Rc<FunVal>),
1818 Id(Rc<String>),
@@ -24,89 +24,20 @@ pub enum Value {
2424 Thunk(Rc<ThunkVal>),
2525}
2626
27−/// A list: shared elements plus the offset of its first one, so dropping a
28−/// prefix (`tail`, `drop`) shares the storage instead of copying it. The
29−/// zipper walks — `up` is `tail repo.context`, and `top`/`ancestors` apply it
30−/// once per level — made a copying `tail` quadratic in the depth of history.
31−/// A tail keeps the elements before it alive; `j` is a single short run, so
32−/// holding them costs nothing.
33−#[derive(Clone)]
34−pub struct ListVal {
35− items: Rc<Vec<Value>>,
36− start: usize,
37−}
38−
39−impl ListVal {
40− pub fn new(items: Vec<Value>) -> ListVal {
41− ListVal {
42− items: Rc::new(items),
43− start: 0,
44− }
45− }
46− /// the list without its first `n` elements, sharing the storage
47− pub fn skip(&self, n: usize) -> ListVal {
48− ListVal {
49− items: self.items.clone(),
50− start: self.items.len().min(self.start.saturating_add(n)),
51− }
52− }
53− pub fn as_slice(&self) -> &[Value] {
54− &self.items[self.start..]
55− }
56− /// which list this is, while it is held: two lists of one identity
57− /// share their elements from the same first one, so are the same list
58− /// without either being compared
59− pub fn identity(&self) -> (*const Vec<Value>, usize) {
60− (Rc::as_ptr(&self.items), self.start)
61− }
62−
63− /// Observe the shared storage without keeping a cached list alive.
64− pub(crate) fn weak_storage(&self) -> std::rc::Weak<Vec<Value>> {
65− Rc::downgrade(&self.items)
66− }
67−}
68−
69−impl std::ops::Deref for ListVal {
70− type Target = [Value];
71− fn deref(&self) -> &[Value] {
72− self.as_slice()
73− }
74−}
75−
7627pub struct ThunkVal {
7728 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>,
9029}
9130
9231enum ThunkState {
9332 Pending(Box<dyn FnOnce() -> Result<Value, Crash>>),
94− /// the computation has been taken out of `Pending` and is running
95− Forcing,
9633 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),
10134}
10235
10336impl std::fmt::Debug for ThunkVal {
10437 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10538 match &*self.state.borrow() {
10639 ThunkState::Pending(_) => write!(f, "Thunk(pending)"),
107− ThunkState::Forcing => write!(f, "Thunk(forcing)"),
10840 ThunkState::Ready(v) => write!(f, "Thunk({:?})", v),
109− ThunkState::Failed(c) => write!(f, "Thunk(failed: {})", c.msg),
11041 }
11142 }
11243}
@@ -115,64 +46,27 @@ impl ThunkVal {
11546 pub fn new(f: impl FnOnce() -> Result<Value, Crash> + 'static) -> Self {
11647 ThunkVal {
11748 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,
15249 }
15350 }
15451
155− /// the value, computing it on first call and memoizing the outcome, a
156− /// crash included
52+ /// the value, computing it on first call and memoizing
15753 pub fn force(&self) -> Result<Value, Crash> {
158− match &*self.state.borrow() {
159− ThunkState::Ready(v) => return Ok(v.clone()),
160− ThunkState::Failed(c) => return Err(c.clone()),
161− ThunkState::Forcing => {
162− return Err(Crash::new("internal: a lazy value was forced re-entrantly"))
54+ if let ThunkState::Ready(v) = &*self.state.borrow() {
55+ return Ok(v.clone());
56+ }
57+ let thunk = {
58+ let mut st = self.state.borrow_mut();
59+ match std::mem::replace(&mut *st, ThunkState::Ready(Value::Bool(false))) {
60+ ThunkState::Pending(t) => t,
61+ ThunkState::Ready(v) => {
62+ *st = ThunkState::Ready(v.clone());
63+ return Ok(v);
64+ }
16365 }
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()),
17466 };
175− out
67+ let v = thunk()?;
68+ *self.state.borrow_mut() = ThunkState::Ready(v.clone());
69+ Ok(v)
17670 }
17771}
17872
@@ -203,31 +97,8 @@ pub enum BlobContent {
20397 Lazy(Rc<LazyBlob>),
20498 /// Conflict sides, jj order: alternating adds and removes, starting and
20599 /// ending with an add: [add, (remove, add)*]. For the in-memory backend:
206− /// [to, from, onto]-style three sides as [add, remove, add]. `None` is a
207− /// side on which the path is absent (a deletion conflict, §7.3).
208− Conflict(Vec<Option<ConflictSide>>),
209−}
210−
211−/// One present side of a conflict: its content and its own file type, which
212−/// jj keeps per side (§7.3)
213−#[derive(Clone, Debug, PartialEq, Eq)]
214−pub struct ConflictSide {
215− pub kind: BlobKind,
216− pub bytes: Rc<Vec<u8>>,
217− /// a side on which jj holds a directory, in a conflict between a file
218− /// and a directory: the hex id of that tree, kept opaque so the side is
219− /// written back as the directory it was. Its `bytes` are empty (§7.3).
220− pub tree: Option<String>,
221−}
222−
223−impl ConflictSide {
224− pub fn regular(bytes: &[u8]) -> ConflictSide {
225− ConflictSide {
226− kind: BlobKind::Regular,
227− bytes: Rc::new(bytes.to_vec()),
228− tree: None,
229− }
230− }
100+ /// [to, from, onto]-style three sides as [add, remove, add].
101+ Conflict(Vec<Rc<Vec<u8>>>),
231102}
232103
233104/// A blob whose bytes are read from the store only on first use (§7.2):
@@ -240,12 +111,7 @@ pub struct LazyBlob {
240111
241112enum LazyState {
242113 Pending(Box<dyn FnOnce() -> Result<Rc<Vec<u8>>, Crash>>),
243− /// the read has been taken out of `Pending` and is running
244− Forcing,
245114 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),
249115}
250116
251117impl std::fmt::Debug for LazyBlob {
@@ -272,28 +138,25 @@ impl LazyBlob {
272138 }
273139 }
274140
275− /// the bytes, reading from the store on first call and memoizing the
276− /// outcome, a failed read included
141+ /// the bytes, reading from the store on first call and memoizing
277142 pub fn force(&self) -> Result<Rc<Vec<u8>>, Crash> {
278143 // fast path: already forced
279− match &*self.state.borrow() {
280− LazyState::Ready(b) => return Ok(b.clone()),
281− LazyState::Failed(c) => return Err(c.clone()),
282− LazyState::Forcing => {
283− return Err(Crash::new("internal: a lazy blob was read re-entrantly"))
144+ if let LazyState::Ready(b) = &*self.state.borrow() {
145+ return Ok(b.clone());
146+ }
147+ let thunk = {
148+ let mut st = self.state.borrow_mut();
149+ match std::mem::replace(&mut *st, LazyState::Ready(Rc::new(Vec::new()))) {
150+ LazyState::Pending(t) => t,
151+ LazyState::Ready(b) => {
152+ *st = LazyState::Ready(b.clone());
153+ return Ok(b);
154+ }
284155 }
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"),
290156 };
291− let out = thunk();
292− *self.state.borrow_mut() = match &out {
293− Ok(b) => LazyState::Ready(b.clone()),
294− Err(c) => LazyState::Failed(c.clone()),
295− };
296− out
157+ let bytes = thunk()?;
158+ *self.state.borrow_mut() = LazyState::Ready(bytes.clone());
159+ Ok(bytes)
297160 }
298161}
299162
@@ -319,7 +182,7 @@ impl BlobVal {
319182 match &self.content {
320183 BlobContent::Resolved(b) => b.len(),
321184 BlobContent::Lazy(l) => l.force().map(|b| b.len()).unwrap_or(0),
322− BlobContent::Conflict(sides) => sides.iter().flatten().map(|s| s.bytes.len()).sum(),
185+ BlobContent::Conflict(sides) => sides.iter().map(|s| s.len()).sum(),
323186 }
324187 }
325188 /// content as bytes, forcing a lazy blob; conflicts render with jj-style
@@ -331,28 +194,6 @@ impl BlobVal {
331194 BlobContent::Conflict(sides) => Ok(render_conflict(sides)),
332195 }
333196 }
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− }
356197 /// the content hash of a lazy blob, if it is one (used to persist an
357198 /// unchanged blob without reading its bytes)
358199 pub fn lazy_id(&self) -> Option<&str> {
@@ -363,22 +204,25 @@ impl BlobVal {
363204 }
364205}
365206
366−/// Render a conflict with jj-style markers; a side the path is absent on
367−/// renders as empty content, as jj materialises it.
368−pub fn render_conflict(sides: &[Option<ConflictSide>]) -> Vec<u8> {
207+/// Render a conflict with jj-style markers.
208+pub fn render_conflict(sides: &[Rc<Vec<u8>>]) -> Vec<u8> {
369209 // sides: [add0, remove0, add1, remove1, ... addN]
370210 let mut out = Vec::new();
371211 out.extend_from_slice(b"<<<<<<<\n");
372212 let mut i = 0;
213+ let mut first_add = true;
373214 while i < sides.len() {
374− let bytes: &[u8] = sides[i].as_ref().map_or(&[], |s| &s.bytes);
375215 if i % 2 == 0 {
216+ if !first_add {
217+ // another add after removes: separate
218+ }
376219 out.extend_from_slice(b"+++++++\n");
377− out.extend_from_slice(bytes);
220+ out.extend_from_slice(&sides[i]);
378221 ensure_newline(&mut out);
222+ first_add = false;
379223 } else {
380224 out.extend_from_slice(b"%%%%%%%\n");
381− out.extend_from_slice(bytes);
225+ out.extend_from_slice(&sides[i]);
382226 ensure_newline(&mut out);
383227 }
384228 i += 1;
@@ -418,6 +262,9 @@ impl PrimKind {
418262pub enum ShapeKind {
419263 Record(BTreeSet<String>),
420264 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),
421268}
422269
423270#[derive(Clone, Debug)]
@@ -440,11 +287,7 @@ pub enum FunVal {
440287 arity: usize,
441288 args: Vec<Value>,
442289 f: BuiltinFn,
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)>,
290+ pending: Option<(String, Rc<crate::shape::ContractExpr>)>,
448291 },
449292 Closure {
450293 name: Option<String>, // top-level definition name, if any
@@ -452,94 +295,25 @@ pub enum FunVal {
452295 applied: usize,
453296 /// the arguments supplied so far, for rendering (§5.2)
454297 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,
455301 body: Rc<Expr>,
456302 env: Env,
457− /// the lambda's source, which it renders as (§5.2)
458− src: crate::ast::Source,
303+ src: String,
459304 /// contract of this function as further arguments arrive
460305 pending: Option<(String, Rc<crate::shape::ContractExpr>)>,
461306 },
462307 /// `f or g` lifted pointwise over functions (§4.6)
463308 OrFun(Value, Value),
464− /// a composition `f . g` applied as `f (g x)` (§4.9); `(.)` itself
465− /// builds a builtin node instead (builtins::compose_values)
309+ /// unevaluated composition: unfolds only when applied to a non-function
310+ /// (§4.1 note — `abandon . contract everything` is a value before it is
311+ /// applied, and must not run at load)
466312 ComposeLazy(Value, Value),
467313 /// a label literal %name: applies as `labelled "name"`
468314 Labelled(String, Value),
469315}
470316
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−
543317impl Value {
544318 pub fn kind_name(&self) -> &'static str {
545319 match self {
@@ -590,7 +364,7 @@ impl Value {
590364
591365 pub fn as_list(&self) -> Result<&[Value], Crash> {
592366 match self {
593− Value::List(xs) => Ok(xs.as_slice()),
367+ Value::List(xs) => Ok(xs),
594368 _ => Err(Crash::new(format!("expected a list, got a {}", self.kind_name()))),
595369 }
596370 }
@@ -618,7 +392,7 @@ impl Value {
618392 }
619393
620394 pub fn list(xs: Vec<Value>) -> Value {
621− Value::List(ListVal::new(xs))
395+ Value::List(Rc::new(xs))
622396 }
623397
624398 pub fn bool(b: bool) -> Value {
@@ -628,13 +402,17 @@ impl Value {
628402
629403/// Attach a definition name and contract to a function value (§4.13).
630404pub fn attach_pending(v: &Value, name: &str, contract: Rc<crate::shape::Contract>) -> Value {
631− let cexpr = Rc::new(crate::shape::ContractExpr::Known { contract, at: 0 });
632− let pending = Some((name.to_string(), cexpr.clone()));
405+ let pending = Some((
406+ name.to_string(),
407+ Rc::new(crate::shape::ContractExpr::Known(contract)),
408+ ));
633409 match v {
634410 Value::Fun(fv) => match fv.as_ref() {
635411 FunVal::Closure {
636412 params,
637413 applied,
414+ applied_args,
415+ deferred,
638416 body,
639417 env,
640418 src,
@@ -643,10 +421,8 @@ pub fn attach_pending(v: &Value, name: &str, contract: Rc<crate::shape::Contract
643421 name: Some(name.to_string()),
644422 params: params.clone(),
645423 applied: *applied,
646− // the definition renders as its name, followed only by the
647− // arguments it is given, not those its value holds: `prev`,
648− // not `prev (parents)` (§5.2)
649− applied_args: Vec::new(),
424+ applied_args: applied_args.clone(),
425+ deferred: *deferred,
650426 body: body.clone(),
651427 env: env.clone(),
652428 src: src.clone(),
@@ -663,81 +439,14 @@ pub fn attach_pending(v: &Value, name: &str, contract: Rc<crate::shape::Contract
663439 arity: *arity,
664440 args: args.clone(),
665441 f: *f,
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())),
442+ pending,
671443 })),
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− }
444+ FunVal::OrFun(_, _) | FunVal::ComposeLazy(_, _) | FunVal::Labelled(_, _) => v.clone(),
681445 },
682446 _ => v.clone(),
683447 }
684448}
685449
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−
741450/// A crash (§4.7).
742451#[derive(Debug, Clone)]
743452pub struct Crash {
@@ -764,22 +473,6 @@ pub struct Env {
764473 frame: Option<Rc<Frame>>,
765474}
766475
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−
783476pub struct Frame {
784477 kind: FrameKind,
785478 parent: Env,
@@ -788,10 +481,9 @@ pub struct Frame {
788481enum FrameKind {
789482 Small(Vec<(String, Value)>),
790483 Big(HashMap<String, Value>),
791− /// recursive let bindings: the names the block binds, and their values
792− /// in that order, filled incrementally and read through the cell so
484+ /// recursive let bindings: filled incrementally, read through the cell so
793485 /// closures capturing the frame see later bindings (§4.1)
794− Rec(Rc<[String]>, Rc<std::cell::RefCell<Vec<Value>>>),
486+ Rec(Rc<std::cell::RefCell<Vec<(String, Value)>>>),
795487}
796488
797489impl Env {
@@ -817,23 +509,18 @@ impl Env {
817509 }
818510 }
819511
820− /// extend with a recursive frame (for `let` blocks) binding `names`,
821− /// whose values are pushed to the returned cell in the same order
822− pub fn extend_rec(&self, names: Rc<[String]>) -> (Env, Rc<std::cell::RefCell<Vec<Value>>>) {
823− // Collect before allocating: `self` and all values the evaluator is
824− // using are still ordinary strong roots during this check.
825− maybe_collect_cycles();
826− let cell = Rc::new(std::cell::RefCell::new(Vec::with_capacity(names.len())));
827− let frame = Rc::new(Frame {
828− kind: FrameKind::Rec(names, cell.clone()),
829− parent: self.clone(),
830− });
831− RECURSIVE_FRAMES.with(|registry| {
832− let mut registry = registry.borrow_mut();
833− registry.frames.push(Rc::downgrade(&frame));
834− registry.allocations += 1;
835− });
836− (Env { frame: Some(frame) }, cell)
512+ /// extend with a recursive frame (for `let` blocks)
513+ pub fn extend_rec(&self) -> (Env, Rc<std::cell::RefCell<Vec<(String, Value)>>>) {
514+ let cell = Rc::new(std::cell::RefCell::new(Vec::new()));
515+ (
516+ Env {
517+ frame: Some(Rc::new(Frame {
518+ kind: FrameKind::Rec(cell.clone()),
519+ parent: self.clone(),
520+ })),
521+ },
522+ cell,
523+ )
837524 }
838525
839526 pub fn lookup(&self, name: &str) -> Option<Value> {
@@ -847,13 +534,11 @@ impl Env {
847534 }
848535 }
849536 }
850− FrameKind::Rec(names, cell) => {
851− // the frame binds its names before it holds their values:
852− // one not evaluated yet is unbound here, not a same-named
853− // definition further out, which config.j's binders may
854− // reuse (§4.2)
855− if let Some(i) = names.iter().rposition(|n| n == name) {
856− return cell.borrow().get(i).cloned();
537+ FrameKind::Rec(cell) => {
538+ for (n, v) in cell.borrow().iter().rev() {
539+ if n == name {
540+ return Some(v.clone());
541+ }
857542 }
858543 }
859544 FrameKind::Big(m) => {
@@ -875,7 +560,7 @@ impl Env {
875560 while let Some(f) = cur {
876561 match &f.kind {
877562 FrameKind::Small(_) => small_frames += 1,
878− FrameKind::Big(_) | FrameKind::Rec(..) => {}
563+ FrameKind::Big(_) | FrameKind::Rec(_) => {}
879564 }
880565 cur = f.parent.frame.as_ref();
881566 }
@@ -891,8 +576,8 @@ impl Env {
891576 out.insert(n.clone());
892577 }
893578 }
894− FrameKind::Rec(names, _) => {
895− for n in names.iter() {
579+ FrameKind::Rec(cell) => {
580+ for (n, _) in cell.borrow().iter() {
896581 out.insert(n.clone());
897582 }
898583 }
@@ -907,302 +592,6 @@ impl Env {
907592 }
908593}
909594
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−
1206595// ----------------------------------------------------------------------
1207596// structural equality (§4.5); comparing functions is a crash
1208597// ----------------------------------------------------------------------
tests/base.rs+2−9
@@ -211,12 +211,6 @@ fn goto_variants() {
211211 assert_eq!(focus_msg(&v), "first");
212212 let m = crash(&mut i, &cfg, "goto (labelled \"nope\")", repo.clone());
213213 assert!(m.contains("expected one revision, got 0"), "{}", m);
214− // a lifted `or` over the curried goto still guards the repo argument
215− // (§4.6): goto applied to the revset is a function, so the lift goes on
216− let v = ok(&mut i, &cfg, "(goto or (\\_ -> id)) %nope", repo.clone());
217− assert_eq!(focus_id(&v), focus_id(&repo));
218− let v = ok(&mut i, &cfg, "(goto or (\\_ -> id)) %feat", repo.clone());
219− assert_eq!(focus_msg(&v), "first");
220214 let m = crash(&mut i, &cfg, "into @kqqqqqqq", repo.clone());
221215 assert!(m.contains("not a child"), "{}", m);
222216 // child by predicate: move to the parent, then to the matching child
@@ -413,11 +407,10 @@ fn inspection_definitions() {
413407 // meta of minted crashes
414408 let m = crash(&mut i, &cfg, "meta @", repo.clone());
415409 assert!(m.contains("stored commit") || m.contains("meta"), "{}", m);
416− // review renders changed paths with difft or the built-in fallback;
417− // "no changes" is for a focus that changes nothing.
410+ // review with no difft falls to crash (difft not on PATH in tests)
418411 let r = ev(&mut i, &cfg, "review", repo.clone());
419412 match r {
420− Ok(v) => assert_ne!(v.as_text().unwrap(), "no changes\n"),
413+ Ok(v) => assert_eq!(v.as_text().unwrap(), "no changes\n"),
421414 Err(m) => assert!(m.contains("difft"), "{}", m),
422415 }
423416}
tests/builtins.rs+6−102
@@ -36,7 +36,7 @@ fn ok(i: &mut Interp, cfg: &config::Config, src: &str) -> Value {
3636
3737fn crash(i: &mut Interp, cfg: &config::Config, src: &str) -> String {
3838 match ev(i, cfg, src) {
39− Ok(v) => panic!("{} unexpectedly succeeded: {}", src, j::show::show(i, &v).unwrap()),
39+ Ok(v) => panic!("{} unexpectedly succeeded: {}", src, j::show::show(i, &v)),
4040 Err(m) => m,
4141 }
4242}
@@ -48,8 +48,8 @@ macro_rules! check {
4848 value_eq(&got, &$want).unwrap_or(false),
4949 "{} => {}, want {}",
5050 $src,
51− j::show::show(&$i, &got).unwrap(),
52− j::show::show(&$i, &$want).unwrap()
51+ j::show::show(&$i, &got),
52+ j::show::show(&$i, &$want)
5353 );
5454 }};
5555}
@@ -81,32 +81,6 @@ fn arithmetic() {
8181 check!(i, cfg, "3 >= 4", Value::Bool(false));
8282}
8383
84−#[test]
85−fn blob_hex_decodes_exact_bytes_and_file_type() {
86− use j::value::BlobKind;
87− let (mut i, cfg) = make_interp();
88− for (name, kind) in [
89− ("regular", BlobKind::Regular),
90− ("executable", BlobKind::Executable),
91− ("symlink", BlobKind::Symlink),
92− ] {
93− for (hex, bytes) in [("", vec![]), ("00ff80A5", vec![0, 255, 128, 165])] {
94− let v = ok(&mut i, &cfg, &format!("blobHex \"{}\" \"{}\"", name, hex));
95− let Value::Blob(b) = v else { panic!("expected Blob") };
96− assert_eq!(b.kind, kind);
97− assert_eq!(b.bytes().unwrap(), bytes);
98− assert!(!b.is_unresolved());
99− }
100− }
101− for hex in ["f", "xyz", "0g", "é", "00 ff"] {
102− let msg = crash(&mut i, &cfg, &format!("blobHex \"regular\" \"{}\"", hex));
103− assert!(msg.contains("invalid hexadecimal content"), "{}", msg);
104− }
105− assert!(crash(&mut i, &cfg, "blobHex \"directory\" \"\"").contains("kind must be"));
106− assert!(crash(&mut i, &cfg, "blobHex 1 \"00\"").contains("expected Text"));
107− assert!(crash(&mut i, &cfg, "blobHex \"regular\" 1").contains("expected Text"));
108−}
109−
11084#[test]
11185fn identity_and_const() {
11286 let (mut i, cfg) = make_interp();
@@ -234,22 +208,9 @@ fn show_renders_literals() {
234208 check!(i, cfg, "show (++)", Value::text("(++)"));
235209 check!(i, cfg, "show Commit", Value::text("Commit"));
236210 check!(i, cfg, "show id", Value::text("id"));
237− // lambda shows its source, exactly as written (§5.2)
238− check!(i, cfg, "show (\\x -> x + 1)", Value::text("\\x -> x + 1"));
239−}
240−
241−#[test]
242−fn show_renders_selectors() {
243− // a selector is the SELECTOR atom `.name` (§3.4), not the accessor
244− // applied to the field name it carries internally (§5.2)
245− let (mut i, cfg) = make_interp();
246− check!(i, cfg, "show (.id)", Value::text(".id"));
247− check!(i, cfg, "show [(.id)]", Value::text("[(.id)]"));
248− check!(i, cfg, "show (map (.id))", Value::text("map (.id)"));
249− // and the rendering evaluates back to the same selector
250− let shown = ok(&mut i, &cfg, "show (map (.id))");
251− let src = format!("({}) [{{ id = 5 }}]", shown.as_text().unwrap());
252− check!(i, cfg, &src, ints(&[5]));
211+ // lambda shows its source
212+ let v = ok(&mut i, &cfg, "show (\\x -> x + 1)");
213+ assert!(v.as_text().unwrap().contains("x"), "{}", v.as_text().unwrap());
253214}
254215
255216#[test]
@@ -320,60 +281,3 @@ labelled = \_ _ -> []
320281 Err(c) => assert!(c.msg.contains("unbound"), "{}", c.msg),
321282 }
322283}
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+67−4446

This diff is too big to show here.

tests/core.rs+1−1

This diff is too big to show here.

tests/difft.rsdeleted−74

This diff is too big to show here.

tests/display.rs+5−974

This diff is too big to show here.

tests/domain.rs+1−111

This diff is too big to show here.

tests/env_lifetimes.rsdeleted−215

This diff is too big to show here.

tests/eval.rs+9−1072

This diff is too big to show here.

tests/help.rsdeleted−326

This diff is too big to show here.

tests/interop.rs+10−173

This diff is too big to show here.

tests/laws.proptest-regressions−1

This diff is too big to show here.

tests/laws.rs+6−258

This diff is too big to show here.

tests/lex.rs−24

This diff is too big to show here.

tests/more.rs+1−322

This diff is too big to show here.

tests/onboarding.rsdeleted−200

This diff is too big to show here.

tests/onboarding_vocab.rsdeleted−153

This diff is too big to show here.

tests/parse.rs−314

This diff is too big to show here.

tests/performance_lookup.rsdeleted−255

This diff is too big to show here.

tests/remote.rs+2−2336

This diff is too big to show here.

tests/review_fallback.rsdeleted−110

This diff is too big to show here.

tests/setup.rsdeleted−144

This diff is too big to show here.

tests/show.rs+25−587

This diff is too big to show here.

tests/showprop.proptest-regressions−1

This diff is too big to show here.

tests/showprop.rs+5−130

This diff is too big to show here.

tests/terminal.rsdeleted−621

This diff is too big to show here.

tests/tree.rs+65−1150

This diff is too big to show here.

tests/ux_cli.rsdeleted−105

This diff is too big to show here.

tests/validation.rs+8−816

This diff is too big to show here.