on master help sign in
44 files changed+6854−741
since splitsnapshots
unifiedsplit
CLAUDE.md+11−6 README.md+159−38 config.j+75−19 docs/base.md+133−42 docs/comparison.md+25−9 docs/language.md+65−19 docs/tutorial.md+102−36 specs/main.md+408−121 specs/tree.md+59−34 src/builtins.rs+44 src/config.rs+2−2 src/diagnostics.rs+381 src/domain.rs+6 src/eval.rs+78−45 src/help.rs+189−41 src/jj.rs+288−72 src/lib.rs+3−2 src/main.rs+280−84 src/render.rs+268−74 src/repo.rs+32−3 src/setup.rs+119 src/show.rs+90−22 src/terminal.rs+276 src/value.rs+314−14 tests/base.rs+2−2 tests/builtins.rs+26 tests/cli.rs+241−10 tests/difft.rs+74 tests/display.rs+248−1 tests/env_lifetimes.rs+215 tests/eval.rs+146−3 tests/help.rs+121−5 tests/interop.rs+173−10 tests/onboarding.rs+200 tests/onboarding_vocab.rs+153 tests/remote.rs+462−10 tests/review_fallback.rs+110 tests/setup.rs+144 tests/show.rs+147−1 tests/showprop.rs+8−2 tests/terminal.rs+621 tests/tree.rs+79−10 tests/ux_cli.rs+105 tests/validation.rs+172−4
CLAUDE.md+11−6
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
44
55## What this is
66
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, ten reserved commands, and the bridge to jj's on-disk format via `jj-lib` (no `jj` binary needed at runtime).
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).
88
99## Commands
1010
@@ -21,35 +21,40 @@ There is no devShell in `flake.nix`; `cargo`/`rustc` come from the system. The N
2121- `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.
2222- Everything else runs the interpreter over the in-memory `MemBackend` — no repository involved.
2323- `tests/laws.rs` and `tests/showprop.rs` are proptest suites; their `*.proptest-regressions` files are checked in on purpose.
24−- `difft` is only needed at runtime for `review`/`difft`. `J_GIT` overrides the `git` executable used for fetch.
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.
2525
2626## The spec is normative
2727
2828`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.
2929
30−Known drift to be aware of (not to copy):
31−- `§9` of the spec is a verbatim copy of `config.j`, but `config.j` is ahead of it (`touchedPaths`, the extra `treeWith` options, `treeCompact`, `treeData`). **`config.j` is the source of truth**; `docs/base.md` lags the same way.
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.
3231
3332## Architecture
3433
35−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` that differs from the one loaded, persist it; otherwise display it and persist nothing. 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`).
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`).
3635
3736Language pipeline, all in `src/`:
3837- `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.
3938- `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.
4039- `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.
4141- `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.
4242- `builtins.rs`: native implementations of the names `config.j` declares without defining.
4343- `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`.
4444
45−`help [NAME]` and `commands [QUERY]` are reserved discovery commands; `--help` and `-h` alias `help` only as the first word. They never open a repository or evaluate config definitions. General and reserved-command help work without 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 general help is needed to recover from a broken config.
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.
4648
4749Repository 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.
4851- `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.
4952- `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.
5053- `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.
5154- `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.
5255
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+
5358### `config.j` is code, and it is embedded
5459
5560`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.
README.md+159−38
@@ -31,22 +31,47 @@ functions.
3131
3232```
3333j help -- a quick start, grouped commands, and examples
34−j commands -- browse the vocabulary in your config
34+j commands -- common tasks and how to do them
35+j commands all -- the full vocabulary in your config
3536j commands rebase -- search names, signatures, and descriptions
3637j help describe -- see a command's signature, documentation, and examples
3738j help push -- learn a reserved command's usage
39+j help config -- find your config and set your identity
3840```
3941
40−`j --help` and `j -h` also open help. Help and command discovery work outside
41−a repository and never change your files or configuration. The catalog includes
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
4246your own definitions, even ones without a signature; when no config exists, it
4347uses the embedded reference config. General help and help for reserved commands
4448are available even if your config is broken.
4549
46−To start working, run `j init` in a directory or `j clone URL`, then try
47−`j status` and `j tree`. Compose commands right to left:
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:
4872`j 'describe "first change" . new'` creates a commit, then describes it.
49−Preview an edit with `j 'tree . validate . squash'`.
73+Preview an edit with `j 'preview squash'`. Search familiar tasks with
74+`j commands commit`, `j commands checkout`, or `j commands branch`.
5075
5176---
5277
@@ -68,7 +93,7 @@ Preview an edit with `j 'tree . validate . squash'`.
6893### With Nix (recommended)
6994
7095The repository provides a flake with one default binary, `j`. Difftastic is a
71−runtime dependency and is wired in automatically.
96+runtime enhancement and is wired in automatically.
7297
7398```
7499nix build # builds ./result/bin/j
@@ -84,13 +109,50 @@ The reference `config.j` is installed alongside the binary
84109cargo build --release # produces target/release/j
85110```
86111
87−You'll want the reference `config.j` (see next section) and, for `review`/`difft`,
88−[difftastic](https://difftastic.wilfred.me.uk/) on your `PATH`.
112+Run `j setup` to create the reference `config.j` (see next section).
113+[Difftastic](https://difftastic.wilfred.me.uk/) on your `PATH` improves
114+`review`/`difft`; without it they use a unified diff.
89115
90116---
91117
92118## Configuration
93119
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+
94156Expressions read `~/.config/j/config.j` (or `$XDG_CONFIG_HOME/j/config.j`).
95157An expression can only use a name this file declares or defines. The reference
96158[`config.j`](config.j) ships with the binary. Commands that execute against a
@@ -98,9 +160,19 @@ repository create an editable copy there when it is missing; help and discovery
98160use the embedded copy without writing it. Edit at least the identity:
99161
100162```
101−user = { name = "Your Name", email = "you@example.com" }
163+user = { name = "Ada Lovelace", email = "ada@example.org" }
102164```
103165
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+
104176The reference config defines the entire base vocabulary (see
105177[docs/base.md](docs/base.md)). Four definitions are read by `j` itself: `user`
106178(who commits), `tree` (how a `Repo` prints), `immutable` (which commits may
@@ -110,17 +182,19 @@ never be rewritten), and `labelled` (what a `%name` literal means).
110182
111183## How it works
112184
113−Every `j` run:
185+Every expression run:
114186
115−1. Snapshots the working directory into the working-copy commit.
116−2. Builds a `Repo` value focused on that commit.
187+1. Snapshots the working directory for evaluation.
188+2. Builds a `Repo` value focused on the working-copy commit.
1171893. Evaluates your expression.
1181904. If the result is a function, applies it to the `Repo`.
119−5. If the final value is a `Repo` that changed, persists it; otherwise prints
120− the value and changes nothing.
191+5. If the final value is a `Repo` that changed, persists it. An unchanged
192+ `Repo` saves nothing; other values print without saving.
121193
122194So most of what you write is an **edit** — a function `Repo -> Repo`. A result
123195that'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.
124198
125199```
126200j 'length . commits . top' -- a number: how many commits? prints, persists nothing
@@ -165,14 +239,19 @@ Opens a new commit *and* describes it, in one step.
165239j 'status'
166240```
167241
168−A summary of the focus: id, message, labels, changed paths, conflicted paths.
242+A summary of the focus: id, message, labels, changed paths, unrecorded edits,
243+and conflicted paths. `in commit` (the `changed` field) compares the commit
244+with its parent;
245+`unrecorded` compares the current files with their last saved state. After
246+`j save`, a commit can still have changed paths while all edits are recorded.
169247
170248```
171−changed 0 items
172−conflicts 0 items
173−id zrkmkkvrvtokumkuzrkmkkvrvtokumku
174−labels 0 items
175−message add a library
249+in commit No changes
250+conflicts No conflicts
251+id zrkmkkvrvtokumkuzrkmkkvrvtokumku
252+labels No labels
253+message add a library
254+unrecorded All edits recorded
176255```
177256
178257```
@@ -212,25 +291,33 @@ The same tree with ages and authors in the margin.
212291j 'log'
213292```
214293
215−A table of every commit, top first, with a `✓` on the focus.
294+A table of every commit, top first, with a `✓` on the focus, short ids, and
295+label names. Tables fit the terminal width, switching to fields on separate
296+lines on very narrow terminals. Redirected output preserves full cell text.
216297
217298```
218299j 'review'
219300```
220301
221−The focus's changes, rendered by difftastic as one page of diffs.
302+The focus's changes as one page of diffs, using difftastic when available
303+and a unified diff otherwise.
222304
223305### Move around
224306
307+Navigation checks out the selected commit's files so you can edit them.
308+
225309```
226310j 'prev' -- to the parent
227311j 'next' -- to the only child
228−j 'top' -- to the top of history
229312j 'tip' -- to the tip of the current line
230313j 'by @wqzt' -- to a specific commit, by id
231−j 'goto %main' -- to what the remote calls main
314+j 'new . goto trunk' -- start a change on top of the protected main line
232315```
233316
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+
234321### Undo and history of actions
235322
236323```
@@ -239,18 +326,25 @@ j redo -- reapply it
239326j ops -- the operation log, newest first
240327```
241328
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+
242336### A safe dry run
243337
244−Because a `Text` or `Repo`-returning-but-unchanged result persists nothing,
245−you can preview any edit by composing it with `tree` (or `validate`):
338+`preview` validates an edit and returns its resulting tree without saving:
246339
247340```
248−j 'tree . squash' -- show the tree as squash would leave it
249−j 'tree . validate . squash' -- the same, but crash if squash couldn't persist
341+j 'preview squash'
342+j 'preview (rebase trunk)'
250343```
251344
252−`validate` returns the repo unchanged if persisting would succeed, and crashes
253−with the exact error otherwise — an honest dry run.
345+This is shorthand for `tree . validate . EDIT`. `tree . EDIT` also previews,
346+but does not check whether the result can be saved. `validate . EDIT` alone
347+**saves** a changed repository; validation is not a dry-run mode.
254348
255349---
256350
@@ -352,6 +446,27 @@ j 'push (unlabel "old-feature")' -- delete a bookmark
352446j 'push relabel' -- after a rewrite, re-point the remote's bookmarks
353447```
354448
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+
355470---
356471
357472## Advanced examples
@@ -435,9 +550,9 @@ Rebase if you can; leave the repository alone if it crashes.
435550Anything added to `config.j` whose value is an `Edit` is a new command:
436551
437552```
438−-- in config.j: fold the whole stack into one commit called "release"
553+-- in config.j: squash into the parent and name the combined commit "release"
439554release : Edit
440−release = describe "release" . (contract everything)
555+release = describe "release" . prev . squash
441556```
442557
443558Then `j release` works like any built-in.
@@ -452,7 +567,8 @@ create or connect repositories, move refs, and walk the operation log.
452567| command | what it does |
453568|---|---|
454569| `j help [NAME]` | show a quick start or help for a reserved command or config name |
455−| `j commands [QUERY]` | list the current vocabulary, optionally searching names, signatures, and comments |
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 |
456572| `j init` | create a colocated `.jj`/`.git` repository here (adopting an existing `.git`) |
457573| `j clone URL [DIR]` | clone `URL` into `DIR` (default: the repository's name, as `git clone` picks it) |
458574| `j remote URL` | set `origin`'s URL |
@@ -461,10 +577,15 @@ create or connect repositories, move refs, and walk the operation log.
461577| `j undo` / `j redo` | walk the operation log |
462578| `j ops` | print the operation log |
463579
464−`--help` and `-h` are aliases for `help` only as the first word. For example,
465−`j --help describe` opens help for `describe`; use `j help new` to learn about
466−`new`. There are no general command flags: `j 'new --help'` remains an expression
467−with a language comment. Everything else is an expression in the language.
580+`--help` and `-h` open help as the first word or as a separate argument after
581+the command: `j --help describe`, `j describe --help`, and `j describe -h`
582+all open help for `describe`. Inside a single expression argument or stdin,
583+language comments still apply: `j 'new --help'` executes `new`. There are no
584+other general command flags. Everything else is an expression in the language.
585+
586+Quote literal reserved-command arguments separately to keep spaces intact:
587+`j clone URL 'my project'` and `j remote '/path/to/shared repo.git'`. A whole
588+reserved command supplied as one string or on stdin is split on whitespace.
468589
469590---
470591
config.j+75−19
@@ -76,6 +76,10 @@ Drop = { delete : Label } -- delete the remote's bookmark
7676------------------------------------------------------------------------------
7777
7878(.) : (b -> c) -> (a -> b) -> a -> c -- composition, right to left
79+-- Return a value unchanged. As a repository command, `j id` records the
80+-- snapshotted working-copy edits in the focused commit. `j save` is the
81+-- descriptive alias in this reference config; inspections such as `j status`
82+-- and `j tree` do not record those edits.
7983id : a -> a
8084const : a -> b -> a
8185crash : Text -> a -- abort the program with a message
@@ -143,9 +147,15 @@ splitOn : Text -> Text -> [Text] -- splitOn "/" "a/b" = ["a" "b"]
143147-- file ./a would, crashes, naming the path (§7.3).
144148replay : Snapshot -> Change -> Snapshot
145149unresolved : 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]
146155touchedPaths : Change -> [Path] -- paths whose content differs, one pass;
147156 -- blobs compared by content id, bytes never read
148157blob : Text -> Blob -- a resolved regular file
158+blobHex : Text -> Text -> Blob -- kind (regular/executable/symlink), hex bytes
149159text : Blob -> Text -- its content (markers if unresolved)
150160by : Id -> Repo -> Repo -- focus the commit with this id; crash if absent
151161lookupCommit : Id -> Repo -> Commit -- indexed commit lookup without moving the focus
@@ -155,12 +165,13 @@ subtreeCommits : a -> [Commit] -- every commit of a tree in preorder
155165meta : Id -> Meta -- git hash, author, email, committer time;
156166 -- crash for a commit not yet persisted
157167validate : Repo -> Repo -- the repo unchanged, or the crash persisting
158− -- it would produce (shape, labels, immutability,
168+ -- it would produce (shape, root, labels, immutability,
159169 -- focus). `tree . validate . rebase trunk` is an
160170 -- honest dry run.
161171diff : Blob -> Blob -> Text -- unified diff from the first to the second
162−difft : Path -> Blob -> Blob -> Text -- difftastic's rendering; needs difft on PATH.
163− -- Options via DFT_* environment variables.
172+difft : Path -> Blob -> Blob -> Text -- difftastic when difft is on PATH; otherwise
173+ -- unified diff with path headers and 3 context lines,
174+ -- or a binary-files summary. DFT_* configures difft.
164175treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text
165176 , lanes : Int, author : Bool, date : Bool, files : Bool }
166177 -> Repo -> Text -- the history as a tree; see the spec
@@ -248,6 +259,9 @@ restrict = \m -> filter (\e -> m e.path)
248259select : Fileset -> Snapshot -> Snapshot -> Snapshot -- m from a, the rest from b
249260select = \m a b -> restrict m a ++ restrict (neg m) b
250261
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`.
251265conflicted : Snapshot -> [Path]
252266conflicted = \s -> map (.path) (filter (\e -> unresolved e.content) s)
253267
@@ -332,6 +346,11 @@ minus = \a b repo -> let ys = b repo in filter (\i -> not (member i ys)) (a repo
332346matching : (Commit -> Bool) -> Revset -> Revset
333347matching = \p rs repo -> filter (\i -> p (commitAt i repo)) (rs repo)
334348
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.
335354conflicts : Revset
336355conflicts = matching (\c -> not (null (conflicted c.files))) all
337356
@@ -340,7 +359,10 @@ firstOf : [Revset] -> Revset
340359firstOf = \rss repo -> foldl (\acc rs -> if null acc then rs repo else acc) [] rss
341360
342361-- The main line: whichever of these names the remote has, first one wins.
343−-- Empty in a repository with no remote, in which case nothing is immutable.
362+-- Empty when no fetched remote label is named main, master, or trunk;
363+-- nothing is then immutable. Configure `j remote URL`, run `j fetch`, and
364+-- inspect `j tree` to see remote labels. A remote with another default label
365+-- needs this definition adjusted. Start work with j 'new . goto trunk'.
344366trunk : Revset
345367trunk = firstOf [%main %master %trunk]
346368
@@ -361,9 +383,15 @@ immutable = ancestorsOf trunk
361383goto : Revset -> Edit
362384goto = \rs repo ->
363385 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"
364390 in if length is == 1
365391 then by (head is) repo
366− else crash ("expected one revision, got " ++ show (length is))
392+ else if null is
393+ then crash "expected one revision, got 0; no matching revision. Run `j tree` to inspect available revisions. For a missing remote label such as %main or trunk, configure `j remote URL`, run `j fetch`, and check the label names in `j tree`."
394+ else crash ("expected one revision, got " ++ show (length is) ++ "\nCandidates:\n" ++ concat (map candidate (take 8 is)) ++ (if length is > 8 then " More candidates: run `j tree`.\n" else "") ++ "Choose a mutable commit with `j by @ID`, or start a child with `j 'new . by @ID'`; replace @ID with a candidate above.")
367395
368396commitAt : Id -> Repo -> Commit
369397commitAt = lookupCommit
@@ -371,8 +399,12 @@ commitAt = lookupCommit
371399prev : Edit -- the parent
372400prev = goto parents
373401
374−next : Edit -- the only child
375−next = goto kids
402+-- Move to the only child. At a tip, `j new` starts a child; when there are
403+-- several children, choose one of the candidates with `j by @ID`.
404+next : Edit
405+next = \repo -> if null (kids repo)
406+ then crash "next: this commit has no children; run `j new` to start a new child, or `j tree` to inspect the history"
407+ else goto kids repo
376408
377409tip : Edit -- end of the line of only-children
378410tip = \repo -> tip (next repo) or repo
@@ -398,6 +430,8 @@ eachChild = forEach kids
398430guard : (Repo -> Bool) -> Edit -- crash unless p holds
399431guard = \p repo -> if p repo then repo else crash "guard"
400432
433+-- Whether the focused commit has no unresolved files. This says nothing
434+-- about unrecorded working-copy edits; inspect those with `j unrecorded`.
401435clean : Repo -> Bool
402436clean = \repo -> null (conflicted repo.root.files)
403437
@@ -436,6 +470,21 @@ rewrite = \f repo ->
436470-- Commands
437471------------------------------------------------------------------------------
438472
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+
439488-- Set the message of the focused commit.
440489-- Example: j 'describe "fix login"'
441490describe : Text -> Edit
@@ -450,7 +499,7 @@ new = \repo -> addChild (newCommit repo.root.files) repo
450499-- new empty commit on that parent becomes the focus (the parent may be
451500-- immutable, and the focus never is). Refused for a commit the remote has a
452501-- name for: push something else under that name first, or forget the name.
453−-- Example: j abandon; preview first with j 'tree . abandon'.
502+-- Example: j abandon; validate a preview first with j 'preview abandon'.
454503abandon : Edit
455504abandon = \repo ->
456505 let c = repo.root
@@ -466,7 +515,7 @@ contract = \m repo -> let a = repo.root
466515 in at parents (rewrite (\q -> q { files = select m a.files q.files })) repo
467516
468517-- Fold the focus entirely into its parent; a new empty commit becomes the focus.
469−-- Example: j squash; preview first with j 'tree . validate . squash'.
518+-- Example: j squash; preview first with j 'preview squash'.
470519squash : Edit
471520squash = abandon . contract everything
472521
@@ -483,7 +532,7 @@ split = \m repo ->
483532 in by x.id (repo { root = a', children = [{ root = x, children = repo.children }] })
484533
485534-- Move the focused subtree onto another commit. Returns to the moved commit.
486−-- Example: j 'rebase trunk'; preview with j 'tree . validate . rebase trunk'.
535+-- Example: j 'rebase trunk'; preview with j 'preview (rebase trunk)'.
487536rebase : Revset -> Edit
488537rebase = \rs repo ->
489538 let d = (goto rs repo).root
@@ -554,13 +603,16 @@ contentAt = lookupContent
554603touched : Change -> [Path]
555604touched = touchedPaths
556605
557−-- Paths whose content differs between the focus and its parent.
606+-- Paths changed by the focused commit compared with its parent, including
607+-- the working-copy snapshot. These can remain nonempty after `j save`;
608+-- `j unrecorded` shows only working-copy edits not yet recorded.
558609-- Example: j changed
559610changed : Repo -> [Path]
560611changed = \repo -> touched (changeOf repo)
561612
562−-- What the focus changes, rendered by difftastic, one record per changed path.
563−-- Replace difft with (\p -> diff) to use the built-in unified diff instead.
613+-- What the focus changes, one record per changed path. Uses difftastic when
614+-- installed; a missing difft falls back to a unified diff with path headers
615+-- and 3 context lines, or a binary-files summary.
564616diffs : Repo -> [{ path : Path, diff : Text }]
565617diffs = \repo ->
566618 let ch = changeOf repo
@@ -568,23 +620,27 @@ diffs = \repo ->
568620
569621-- The same, as one page of text. A Text result prints raw, so `j review` is
570622-- readable in the terminal. A focus that changes nothing reads "no changes";
571−-- a difft that cannot be run (not on PATH) is a crash saying so, and
572−-- otherwise whatever difft prints, even nothing, is the page.
573−-- Example: j review (requires difftastic's difft on PATH).
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.
574627review : Repo -> Text
575628review = \repo ->
576629 let ds = diffs repo
577630 in if null ds then "no changes\n" else concat (map (\d -> d.diff) ds)
578631
579−-- A summary of the focus.
632+-- A summary of the focus. `changed` lists changes in the commit compared
633+-- with its parent; `unrecorded` lists working-copy edits not yet recorded.
634+-- Inspection does not record anything. Run `j save` to record those edits.
580635-- Example: j status
581−status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path] }
636+status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path], unrecorded : [Path] }
582637status = \repo ->
583638 { id = repo.root.id
584639 , message = repo.root.message
585640 , labels = repo.root.labels
586641 , changed = changed repo
587− , conflicts = conflicted repo.root.files }
642+ , conflicts = conflicted repo.root.files
643+ , unrecorded = unrecorded repo }
588644
589645-- A summary of every commit, top first, in preorder.
590646-- Example: j log
docs/base.md+133−42
@@ -47,6 +47,13 @@ user = { name = "Your Name", email = "you@example.com" }
4747
4848This is a plain value, not a function. Its signature is checked once, at load
4949(§4.13): if you write `user = 5`, `j` refuses to start (exit 3).
50+Creating a repository or saving changes also rejects empty fields and the
51+reference placeholders. Use your own name and email before `j init`,
52+`j clone`, or an edit that saves changes. `j help config` shows your actual
53+config path and an example, even if the config is broken.
54+For a new installation, `j setup 'Your actual name' you@your-domain.example`
55+creates the complete config with that identity. It never overwrites an
56+existing file.
5057
5158### 1.2 Paths, labels, entries, snapshots
5259
@@ -178,6 +185,34 @@ id 42 => 42
178185edit or id -- run edit, keep the repository if it crashes
179186```
180187
188+### `save` — record file edits
189+
190+```
191+save : Edit
192+```
193+
194+The identity edit, with a name for the everyday action: `j save` records the
195+working-directory snapshot without changing the commit's description or
196+focus. It creates no operation when nothing changed. `j id` does the same.
197+
198+### `preview` — validate an edit without saving
199+
200+```
201+preview : Edit -> Repo -> Text
202+```
203+
204+Apply the edit, validate its result, and render the resulting tree. Because
205+the final value is text, no edit or working-directory snapshot is recorded.
206+Validation errors report why the edit could not be saved.
207+
208+```
209+j 'preview squash'
210+j 'preview (rebase trunk)'
211+```
212+
213+This is the convenient form of `tree . validate . EDIT`. `validate . EDIT`
214+alone returns a repository and saves it.
215+
181216### `const` — constant function
182217
183218```
@@ -256,7 +291,9 @@ not : Bool -> Bool
256291```
257292
258293`&&` and `||` short-circuit (the right operand is not evaluated if the left
259−decides the result). Operands must be `Bool`.
294+decides the result). Operands must be `Bool`. Infix use requires their
295+declarations in `config.j` and checks the contracts of evaluated operands
296+and the result; a skipped right operand is not checked.
260297
261298```
2622991 == 1 && 2 == 2 => true
@@ -301,11 +338,17 @@ Defined on `Int` only; crash on anything else. Non-associative (cannot chain:
301338show : a -> Text
302339```
303340
304−Renders any value in the language's literal syntax, such that (except for
305−closures and unresolved blobs) the text parses and evaluates back to an equal
306−value. A top-level `Text` prints raw, so `j show …` is how you inspect values.
341+When it succeeds, `show` renders a value in the language's literal syntax.
342+Except for closures and unresolved blobs, the text parses and evaluates back
343+to an equal value. A top-level `Text` prints raw, so `j show …` is how you inspect values.
307344See §5.2 of the spec for the exact rendering.
308345
346+Regular UTF-8 blobs render as `blob "…"`; binary blobs, executables, and
347+symlinks render as `blobHex "kind" "…"`, preserving bytes and file type with
348+lowercase hexadecimal content. An older custom config without
349+`blobHex : Text -> Text -> Blob` must add that declaration before showing
350+these values; otherwise `show` crashes with instructions to do so.
351+
309352```
310353show 42 => "42"
311354show "hi" => "\"hi\""
@@ -441,11 +484,12 @@ unresolved ((head (replay [({ path = ./f, content = blob "onto" })]
441484Two laws to internalise it: `replay b { from = b, to = x } = x` and
442485`replay x { from = b, to = b } = x`.
443486
444−### `unresolved`, `blob`, `text` — blob primitives
487+### `unresolved`, `blob`, `blobHex`, `text` — blob primitives
445488
446489```
447490unresolved : Blob -> Bool
448491blob : Text -> Blob
492+blobHex : Text -> Text -> Blob
449493text : Blob -> Text
450494```
451495
@@ -453,9 +497,16 @@ text : Blob -> Text
453497blob's content (its conflict-marker rendering if unresolved). `unresolved b`
454498is `true` iff the blob is a conflict.
455499
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+
456506```
457507blob "content" => blob "content"
458508text (blob "content") => "content"
509+text (blobHex "regular" "6869") => "hi"
459510unresolved (blob "x") => false
460511```
461512
@@ -506,15 +557,20 @@ meta @wqzt => { hash = "74bee64e…", author = "Test User",
506557 email = "test@example.com", time = 1789457265 }
507558```
508559
509−### `validate` — dry-run persistence
560+### `validate` — check whether persistence would succeed
510561
511562```
512563validate : Repo -> Repo
513564```
514565
515566Returns the repo unchanged if persisting it would succeed; otherwise crashes
516−with exactly the error persistence would produce (shape, labels, immutability,
517−focus). `tree . validate . rebase trunk` is an honest dry run.
567+with exactly the error persistence would produce (shape, root, labels, immutability,
568+focus). A changed repository also checks that the author identity is configured;
569+an unchanged repository can still be inspected with the default identity.
570+`tree . validate . rebase trunk` is an honest dry run.
571+`validate . rebase trunk` alone saves the result: `validate` returns the same
572+repository it was given, not the repository from before the edit. Prefer
573+`preview (rebase trunk)` for the complete safe-preview expression.
518574
519575### `diff` and `difft` — rendering differences
520576
@@ -532,27 +588,34 @@ diff (blob "a\nb\n") (blob "a\nc\n")
532588```
533589
534590`difft p a b` renders the comparison with **difftastic**, treating the input
535−as the file `p` (its last component guides language detection). Needs `difft`
536−on `PATH` (the Nix package provides it); options via `DFT_*` environment
537−variables. Crashes naming the tool if absent. A `difft` that runs and fails
538−is not an error: what it printed, even nothing, is the result, and what it
539−says on stderr reaches the terminal.
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.
540598
541599### `treeWith` — the history tree
542600
543601```
544−treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text }
602+treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text,
603+ lanes : Int, author : Bool, date : Bool, files : Bool }
545604 -> Repo -> Text
546605```
547606
548607Renders the history as a tree with an options record. The reference config
549−provides two ready-made settings, `tree` and `treeFull` (§9).
608+provides ready-made settings, including `tree` and `treeFull` (§10).
550609
551610```
552−treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
611+treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
553612 -- a full tree with ages and authors in the margin
554613```
555614
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+
556619### `extract` — every subvalue of a shape
557620
558621```
@@ -614,7 +677,9 @@ top = \repo -> top (up repo) or repo
614677```
615678
616679Moves the focus all the way to the top of history (where `context = []`). The
617−`or repo` is the base case: when `up` crashes at the top, stop.
680+`or repo` is the base case: when `up` crashes at the top, stop. Use it inside
681+an inspection such as `commits . top` or `tree . top`; `j top` alone cannot
682+save a focus on the protected root commit.
618683
619684```
620685top -- refocus at the very first commit
@@ -795,17 +860,14 @@ is `new . goto trunk`, never `goto trunk` alone.
795860
796861**`goto : Revset -> Edit`** — refocus on *the one commit* a revset names. This
797862is the only place a revset must have exactly one member; otherwise it crashes
798−with `expected one revision, got N`.
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.
799867
800868```
801−goto = \rs repo ->
802− let is = rs repo
803− in if length is == 1 then by (head is) repo
804− else crash ("expected one revision, got " ++ show (length is))
805−```
806−
807−```
808−goto %main -- refocus on main (crash if it names 0 or 2+ commits)
869+new . goto %main -- start a mutable child on main
870+tree . goto %main -- inspect main without moving the working copy
809871goto parents -- == prev
810872```
811873
@@ -814,10 +876,12 @@ The reference definition uses `lookupCommit`, which indexes the repository
814876once and retrieves commits without moving the focus. Its result is the same
815877as `(by i repo).root`.
816878
817−**`prev : Edit`** — to the parent (`goto parents`).
879+**`prev : Edit`** — to the parent (`goto parents`). Like every navigation edit,
880+it checks out the selected commit. A protected parent cannot remain focused;
881+inspect it with `tree . prev` or start a new child instead.
818882
819−**`next : Edit`** — to the only child (`goto kids`; crashes if the focus has
820−other than exactly one child).
883+**`next : Edit`** — to the only child. At a tip it suggests `j new`; at a fork
884+it lists candidates to choose with `by`.
821885
822886**`tip : Edit`** — the end of the line of only-children (`tip = \repo -> tip
823887(next repo) or repo`).
@@ -1001,6 +1065,9 @@ Crashes at the top.
10011065
10021066`j push EXPR` evaluates `EXPR` to a list of `Push` and `Drop` records. These
10031067functions 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.
10041071
10051072**`label : Label -> Revset -> Repo -> [Push]`** — label every commit of a
10061073revset.
@@ -1033,7 +1100,8 @@ j 'push relabel' -- after rewriting, re-point the remote's bookmarks
10331100
10341101## 10. Inspection
10351102
1036−These return printable values rather than a `Repo`.
1103+These return printable values rather than a `Repo`. They include current
1104+file edits in the displayed snapshot without recording those edits.
10371105
10381106**`focus : Repo -> Commit`** — the focused commit.
10391107
@@ -1074,45 +1142,64 @@ contentAt ./missing [] => blob ""
10741142**`changed : Repo -> [Path]`** — paths whose content differs between the focus
10751143and its parent.
10761144
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+
10771151**`diffs : Repo -> [{ path : Path, diff : Text }]`** — what the focus changes,
1078−rendered by difftastic, one record per changed path. (Replace `difft` with
1079−`(\p -> diff)` to use the built-in unified diff instead.)
1152+rendered by difftastic, one record per changed path, with unified diffs as
1153+the fallback when difftastic is missing.
10801154
10811155**`review : Repo -> Text`** — the same, as one page of text, or
10821156`"no changes\n"` when the focus changes nothing. A `Text` result prints raw,
1083−so `j review` is readable in the terminal. Like `diffs`, it crashes when
1084−`difft` cannot be run (not on `PATH`); otherwise whatever `difft` prints,
1085−even nothing, is the page, and what it says on stderr reaches the terminal.
1157+so `j review` is readable in the terminal even without difftastic. When
1158+`difft` runs, whatever it prints, even nothing, is the page, and what it says
1159+on stderr reaches the terminal. Other launch errors still fail the review.
10861160
10871161```
10881162j 'review' -- the focus's changes, as difftastic renders them
10891163```
10901164
1091−**`status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path] }`** — a summary of the focus.
1165+**`status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], unrecorded : [Path], conflicts : [Path] }`** — a summary of the focus and unsaved file edits.
10921166
10931167```
10941168j 'status'
10951169-- id @wqzt…
10961170-- message add the lexer
10971171-- labels [feature]
1098−-- changed [src/lexer.rs]
1099−-- conflicts []
1172+-- in commit [src/lexer.rs]
1173+-- conflicts No conflicts
1174+-- unrecorded All edits recorded
11001175```
11011176
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+
11021184**`log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]`**
11031185— a summary of every commit, top first, in preorder (`focus` marks the focused
1104−commit).
1186+commit). The display shows short ids and label names, fits columns to the
1187+terminal, and switches to fields on separate lines when necessary. Redirected
1188+output preserves full cell text.
11051189
11061190**`tree : Repo -> Text`** and **`treeFull : Repo -> Text`** — the history as a
1107−tree, built with `treeWith`. `tree` is compact (`detail = 1`, elided);
1191+tree, built with `treeWith`. `tree` shows every commit (`detail = 1`);
11081192`treeFull` shows ages and authors in the margin (`detail = 2`, `margin =
11091193true`). Edit these definitions to change what `j tree` shows.
11101194
11111195```
1112−tree = treeWith ({ detail = 1, margin = false, elide = true, icons = false, color = "auto" })
1113−treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
1196+tree = treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
1197+treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
11141198```
11151199
1200+Use `treeCompact` to fold uninteresting runs and distant subtrees, or
1201+`treeData` for extra date, files-changed, and author columns.
1202+
11161203```
11171204j 'tree'
11181205-- ⌂ zzzz
@@ -1142,6 +1229,7 @@ j 'tree'
11421229| `attach` | `Subtree -> Edit` | §7.1 |
11431230| `backout` | `Revset -> Edit` | §8 |
11441231| `blob` | `Text -> Blob` | §5 |
1232+| `blobHex` | `Text -> Text -> Blob` | §5 |
11451233| `both` | `Fileset -> Fileset -> Fileset` | §7.2 |
11461234| `by` | `Id -> Repo -> Repo` | §5 |
11471235| `changeOf` | `Repo -> Change` | §7.2 |
@@ -1211,6 +1299,7 @@ j 'tree'
12111299| `parents` | `Revset` | §7.3 |
12121300| `pick` | `Revset -> Edit` | §8 |
12131301| `prev` | `Edit` | §7.4 |
1302+| `preview` | `Edit -> Repo -> Text` | §2 |
12141303| `range` | `Int -> Int -> [Int]` | §4 |
12151304| `rebase` | `Revset -> Edit` | §8 |
12161305| `relabel` | `Repo -> [Push]` | §9 |
@@ -1222,6 +1311,7 @@ j 'tree'
12221311| `restrict` | `Fileset -> Snapshot -> Snapshot` | §7.2 |
12231312| `review` | `Repo -> Text` | §10 |
12241313| `rewrite` | `(Commit -> Commit) -> Edit` | §8 |
1314+| `save` | `Edit` | §2 |
12251315| `select` | `Fileset -> Snapshot -> Snapshot -> Snapshot` | §7.2 |
12261316| `show` | `a -> Text` | §3 |
12271317| `siblings` | `Revset` | §7.3 |
@@ -1245,6 +1335,7 @@ j 'tree'
12451335| `under` | `Path -> Fileset` | §7.2 |
12461336| `union` | `Revset -> Revset -> Revset` | §7.3 |
12471337| `unlabel` | `Label -> [Drop]` | §9 |
1338+| `unrecorded` | `Repo -> [Path]` | §10 |
12481339| `unresolved` | `Blob -> Bool` | §5 |
12491340| `up` | `Edit` | §6 |
12501341| `user` | `{ name : Text, email : Text }` | §1.1 |
docs/comparison.md+25−9
@@ -75,21 +75,27 @@ about: a commit keeps its identity (`@wqzt…`) across `describe`, `squash`,
7575`j push` takes an *expression* rather than flags: `j 'push (label "feature"
7676here)'` labels the focused commit, `j 'push relabel'` re-points the remote's
7777bookmarks after a rewrite.
78+Push sends recorded commits and warns before sending if files have
79+unrecorded edits. Run `j id`, then push again to include those edits.
7880
7981### 3.2 Recording work
8082
8183| Task | git | jj | j |
8284|---|---|---|---|
83−| Save current state | `git add … && git commit` | automatic (`jj st` shows it) | automatic; `j id` to record *only* the snapshot |
85+| Save current state | `git add … && git commit` | automatic (`jj st` shows it) | an edit records the snapshot; `j save` records *only* the snapshot |
8486| Set the message | `git commit -m "…"` | `jj describe -m "…"` or `jj describe` | `j 'describe "…"'` |
8587| Start the next change | (just keep editing) | `jj new` | `j new` |
8688| Amend the current commit | `git commit --amend` | `jj describe` + edit files in `@` | edit files + `j 'describe "…"'` |
8789| See what changed | `git diff` / `git show` | `jj diff` / `jj show` | `j review` (via difftastic) / `j status` |
8890
89−In git, "commit" is a deliberate act with a staging step. In jj and `j`, the
90−snapshot is continuous; `describe` sets a message and `new` opens the next
91−change. `j` has one deliberate-snapshot command, `j id`, for when you want to
92−record the working directory and nothing else.
91+In git, "commit" is a deliberate act with a staging step. jj snapshots the
92+working copy automatically. In `j`, expressions see current file edits,
93+but only a changed `Repo` result records them: `describe` saves a message
94+and `new` opens the next change. `j status` and other inspections save
95+nothing. Use `j save` (an alias for `id`) to record the working directory and
96+nothing else. Status displays the `changed` field as `in commit`, comparing
97+the current commit with its parent; `unrecorded` shows file edits made since
98+the last save.
9399
94100### 3.3 Looking around
95101
@@ -116,7 +122,7 @@ zipper with edits.
116122|---|---|---|---|
117123| To the parent | `git checkout HEAD~1` | `jj prev` *(≈, opens a new empty commit there)* | `j prev` |
118124| To the child | `git checkout <sha>` (manual) | `jj next` *(≈, opens a new empty commit there)* | `j next` |
119−| To a named commit | `git checkout <branch>` | `jj new <rev>` | `j 'goto %main'` |
125+| Start work on a named commit | `git checkout <branch>` | `jj new <rev>` | `j 'new . goto %main'` |
120126| To a specific change | `git checkout <sha>` | `jj new <change-id>` | `j 'by @wqzt'` |
121127| Back to your branch | `git checkout main` | `jj new main` | `j 'new . goto trunk'` |
122128
@@ -201,14 +207,16 @@ the remote's state explicit and impossible to mutate by accident.
201207| When do conflicts appear? | During merge/rebase, blocking it | Recorded *in* the commit, non-blocking | Recorded in the commit (unresolved `Blob`s) |
202208| Where do they live? | The working tree + index | In the commit itself | In the commit's `files`, as conflict blobs |
203209| How do you see them? | `git status` | `jj st`, `jj resolve` | `j conflicts`, `j status` |
204−| How do you resolve? | Edit files, `git add`, continue | Edit files; conflict is gone from `@` | Edit files, then any persisting expression (`j id`) |
210+| How do you resolve? | Edit files, `git add`, continue | Edit files; conflict is gone from `@` | Edit files, then `j save` and check `j status` |
205211
206212git stops the world on a conflict: a merge or rebase halts until you resolve
207213and `git add`. jj and `j` treat a conflict as *data* — a commit can simply
208214*contain* conflicted files, you can keep working, and you resolve whenever you
209215like. `j conflicts` lists the commits that have conflicts; checking one out
210−writes the markers to disk; editing the files and running `j id` records the
211−resolution.
216+writes the markers to disk; editing the files and running `j save` records
217+the resolution. Check `j status` for `No conflicts` and `All edits recorded`.
218+There is no separate continuation command. `j review` uses a unified diff
219+when difftastic is not installed.
212220
213221---
214222
@@ -227,6 +235,12 @@ a first-class, marked operation, so `undo`/`redo` are safe and precise. In
227235`j`, undoing a `push` restores the local labels (and leaves the remote
228236untouched).
229237
238+`j undo` and `j redo` require no unrecorded working-copy edits. Recording
239+edits with `j id` adds an operation, so the next undo removes that recording
240+before another undo reaches the preceding edit. A new recording also clears
241+pending redos. Interactive confirmations identify the affected operation and
242+restored focus; `j ops` shows the history.
243+
230244---
231245
232246## 9. The interface itself
@@ -296,6 +310,8 @@ j new
296310# …edit…
297311j 'describe "part two"'
298312j squash
313+j prev # squash leaves an empty child; return to the combined commit
314+j 'describe "complete feature"'
299315j 'rebase %main'
300316j 'push (label "feature" here)'
301317```
docs/language.md+65−19
@@ -3,8 +3,14 @@
33`j` is a small, pure, functional language. Every `j` command evaluates one
44expression in this language against the current repository. The language is
55the whole interface: there are no subcommands beyond a handful of reserved
6−words, and no general flags. As the first word, `--help` and `-h` are aliases
7−for `help`; use `j help` for a command guide or `j commands` to browse names.
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.
814
915This document describes the language itself — its values, syntax, semantics,
1016and the builtins — with examples throughout. Every example shows an expression
@@ -18,16 +24,17 @@ and what it evaluates to, exactly as `j` prints it.
1824
1925## 1. The big picture
2026
21−A run of `j` looks like this:
27+An expression run of `j` looks like this:
2228
23291. Read the expression (from the command line or stdin).
24−2. Snapshot the working directory into the working-copy commit.
30+2. Snapshot the working directory for evaluation.
25313. Build a value `repo` describing the repository, focused on the
2632 working-copy commit.
27334. Evaluate the expression.
28345. If the result is a function, apply it to `repo`.
29356. If the final value is a `Repo` that differs from the one loaded, persist
30− the difference; otherwise print the value and change nothing.
36+ the difference. An unchanged `Repo` saves nothing; other values print
37+ without saving.
3138
3239So the most common thing you write is an **edit** — a function `Repo -> Repo`
3340(or a value that becomes one). `describe "wip"` is an edit. `new` is an edit.
@@ -227,7 +234,8 @@ distinct id each time it is evaluated (see §7).
227234
228235A `Blob` is the content of one file, either resolved or unresolved (a
229236conflict). You rarely write them directly; you get them from a commit's
230−`files`. `blob "…"` builds a resolved one, `text` reads it back:
237+`files`. It carries the file kind as well as its bytes. `blob "…"` builds a
238+resolved regular file from text; `text` reads it back:
231239
232240```
233241text (blob "file content")
@@ -553,6 +561,9 @@ crash "my message"
553561
554562Parse errors, configuration errors, and `Id`-resolution failures happen
555563*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.
556567
557568---
558569
@@ -636,6 +647,13 @@ unresolved (blob "x") => false
636647`unresolved b` is `true` iff the blob is a conflict (produced by a clashing
637648`replay`). `text` on an unresolved blob yields its conflict-marker rendering.
638649
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+
639657### Shapes and `extract`
640658
641659A type name in an expression is a `Shape`. `extract S v` returns every
@@ -667,6 +685,10 @@ A function that has a **signature** (a builtin, or a definition with one in
667685parameter type as it is supplied, and the final result against the result
668686type.
669687
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+
670692The check is one level deep — it never inspects inside a list or a record's
671693field values (that would make every call cost the size of the value).
672694
@@ -696,11 +718,17 @@ unchecked except through the functions they call.
696718
697719## 12. `show`: rendering values as text
698720
699−`show v` renders any value as an expression in the language, such that
700−(except for closures and unresolved blobs) the result parses and evaluates
721+When it succeeds, `show v` renders a value as an expression in the language.
722+Except for closures and unresolved blobs, the result parses and evaluates
701723back to an equal value. Since `show` returns a `Text`, and a top-level `Text`
702724prints raw, `j show …` is how you inspect values.
703725
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+
704732```
705733show 42 => "42"
706734show "hi" => "\"hi\""
@@ -750,7 +778,8 @@ prev -- to the parent (crashes at the top)
750778next -- to the only child (crashes if not exactly one)
751779up -- like prev, the raw zipper move
752780by @wqzt -- refocus on a specific commit
753−goto %main -- refocus on what the remote calls main
781+tree . goto %main -- inspect main without moving the working copy
782+new . goto %main -- start a mutable child on main
754783parents -- the focus's parents, as a Revset
755784kids -- its children
756785ancestors -- it and its ancestors
@@ -763,6 +792,7 @@ Edits — functions `Repo -> Repo`:
763792
764793```
765794new -- a new empty child of the focus
795+save -- record file edits without changing the description
766796describe "wip" -- set the focus's message
767797squash -- fold the focus into its parent
768798abandon -- drop the focus; children join the parent
@@ -777,7 +807,9 @@ And the inspections:
777807
778808```
779809tree -- the history as a tree (a Text)
780−status -- { id, message, labels, changed, conflicts }
810+status -- { id, message, labels, changed, conflicts, unrecorded }
811+unrecorded -- paths edited since the last recording
812+preview squash -- validate and show the resulting tree without saving
781813review -- the focus's changes as one page of diffs
782814files -- the focus's snapshot (its files)
783815commits . top -- every commit, preorder
@@ -802,10 +834,13 @@ j 'tree'
802834-- the second commit was a mistake; fold it into the first
803835j 'squash'
804836
805−-- rename the combined commit
837+-- squash leaves a fresh empty child; move back and rename the combined commit
838+j prev
806839j 'describe "add the front end"'
807840
808−-- undo the squash entirely
841+-- undo the rename, the focus move, then the squash
842+j undo
843+j undo
809844j undo
810845
811846-- move back to the first commit to fix something
@@ -834,17 +869,22 @@ diff (blob "a\nb\n") (blob "a\nc\n")
834869 => "@@ -1,2 +1,2 @@\n a\n-b\n+c\n"
835870```
836871
837−`difft p a b` renders the comparison with **difftastic** (which must be on
838−`PATH`; the Nix package provides it). `review` uses `difft` per changed path.
872+`difft p a b` renders the comparison with **difftastic** when it is on `PATH`
873+(the Nix package provides it). When it is absent, the builtin returns a
874+unified diff with path headers, or a summary for binary changes. `review`
875+uses `difft` per changed path; other errors starting the tool still fail.
839876
840877`treeWith options repo` renders the history tree with an options record:
841878
842879```
843−treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
880+treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
844881```
845882
846−The reference config gives two ready-made versions: `tree` (compact) and
847−`treeFull` (with ages and authors in the margin).
883+The reference config provides `tree`, `treeCompact` (folded runs and distant
884+subtrees), `treeFull` (ages and authors in the margin), and `treeData` (extra
885+date, files-changed, and author columns). Branches beyond `lanes` keep their
886+state marker beside `»`; increase `lanes` to show more rails. Long file paths
887+in detail rows wrap to fit the terminal.
848888
849889---
850890
@@ -920,8 +960,14 @@ the algebra they are.
920960
921961**Text:** `splitOn` `startsWith` `endsWith` `concat` `++`
922962
923−**Blobs/repos:** `blob` `text` `unresolved` `replay` `by` `meta` `extract`
963+**Blobs/repos:** `blob` `blobHex` `text` `unresolved` `replay` `by` `meta` `extract`
924964`diff` `difft` `treeWith` `validate`
925965
926−**Reserved commands** (not expressions): `init` `clone URL [DIR]` `remote URL`
966+**Reserved commands** (not expressions): `help [NAME]` `commands [QUERY]`
967+`setup [NAME EMAIL]` `init` `clone URL [DIR]` `remote URL`
927968`fetch` `push EXPR` `undo` `redo` `ops`.
969+
970+Literal reserved-command arguments preserve shell quoting boundaries, as in
971+`j clone URL 'my project'`. A whole reserved command passed as one string or
972+on stdin is split on whitespace. Expression arguments still use language
973+syntax: quote the whole expression, as in `j 'describe "my change"'`.
docs/tutorial.md+102−36
@@ -4,9 +4,17 @@ This is a guided first hour with `j`. It builds the mental model piece by
44piece — not just *what* to type, but *why it works* — so that by the end you
55can compose your own commands instead of memorizing them.
66
7−You'll want `j` installed (`nix build` or `cargo build --release`) and a
8−`config.j` with your identity (see the README's Configuration section). Every
9−command shown here is run in a real repository, so you can follow along.
7+You'll want `j` installed (`nix build` or `cargo build --release`). Set up
8+your configuration with your own name and email:
9+
10+```
11+j setup 'Ada Lovelace' ada@example.org
12+```
13+
14+This creates the full reference configuration without overwriting an existing
15+file. If you already have a config, `j help config` shows its location so you
16+can edit the `user` definition. The remaining commands run in a real
17+repository, so you can follow along.
1018
1119---
1220
@@ -20,11 +28,11 @@ j init
2028```
2129
2230`j init` makes a colocated `.jj`/`.git` repository and checks out a fresh
23−working-copy commit. Now write a file and start your first change:
31+working-copy commit: the commit you are currently editing. It already exists,
32+so write a file and describe that commit:
2433
2534```
2635echo 'fn main() {}' > main.rs
27−j 'new'
2836j 'describe "start the project"'
2937```
3038
@@ -36,35 +44,48 @@ j 'tree'
3644
3745```
3846 ⌂ zzzz
39− └─ ● pnyz ▂
40−▶ └─ ◌ psnq start the project
47+▶ └─ ◉ pnyz ▂ start the project
4148```
4249
4350Add a second change on top:
4451
4552```
46−echo 'lib' > lib.rs
4753j 'new'
54+echo 'lib' > lib.rs
4855j 'describe "add a library"'
4956j 'tree'
5057```
5158
5259```
5360 ⌂ zzzz
54− └─ ● pnyz ▂
55− └─ ● psnq ▂ start the project
56−▶ └─ ◌ trtq add a library
61+ └─ ● pnyz ▂ start the project
62+▶ └─ ◉ psnq ▂ add a library
5763```
5864
59−That's the whole basic loop: **edit files, `new`, `describe`.** There is no
60−`git add` step — the working directory is always already a commit, and `j`
61−snapshots it on every run.
65+That's the basic loop: **edit files, describe the current change, then `new`
66+before editing the next change.** `new` opens an empty change with the same
67+files as its parent; it does not move existing edits into that new change.
68+There is no `git add` step. `describe` saves both the current files and their
69+description; `j save` records file edits without changing the description.
6270
63−**What just happened, in the model:** each `j` run snapshots your working
64−directory into the working-copy commit, builds a `Repo` value focused there,
71+**What just happened, in the model:** each expression snapshots your working
72+directory for evaluation, builds a `Repo` value focused on that commit,
6573evaluates your expression, and — because `new` and `describe` are *edits*
6674(functions `Repo -> Repo`) — persists the resulting repository.
6775
76+`j status` has two useful lists: `in commit` compares the current commit with
77+its parent, while `unrecorded` lists file edits made since the last save.
78+After `describe` or `save`, `unrecorded` is empty; `in commit` still shows the
79+work in the commit. Inspecting status does not save anything.
80+
81+To join an existing project instead, clone it and enter its directory:
82+
83+```
84+j clone URL project
85+cd project
86+j status
87+```
88+
6889---
6990
7091## Part 2 — The one idea everything rests on
@@ -133,22 +154,28 @@ function produces, so don't wrap it.
133154Make a few commits to play with:
134155
135156```
136−echo a > a.txt; j 'new'; j 'describe "A"'
137−echo b > b.txt; j 'new'; j 'describe "B"'
138−echo c > c.txt; j 'new'; j 'describe "C"'
157+j new; echo a > a.txt; j 'describe "A"'
158+j new; echo b > b.txt; j 'describe "B"'
159+j new; echo c > c.txt; j 'describe "C"'
139160```
140161
141−Navigation is edits too:
162+Navigation changes the working-copy commit and checks out its files. Use
163+these commands to edit another mutable commit:
142164
143165```
144166j 'prev' -- to the parent
145167j 'next' -- to the only child
146−j 'top' -- to the top of history
147168j 'tip' -- to the end of the current line
148169j 'by @vqok' -- to a specific commit, by id (use a real id from j tree)
149−j 'goto %main' -- to what the remote calls main
150170```
151171
172+At a fork, choose an id from `j tree` with `j 'by @id'`; `next` only works
173+when there is one child. Main and the root commit are protected from editing.
174+Use `j 'new . goto trunk'` to start work on main, or
175+`j 'tree . goto trunk'` to inspect it without moving your working copy.
176+In a new local repository with no main bookmark, keep working from `j init`'s
177+commit instead.
178+
152179After `j 'prev'` from C, the tree shows the focus (▶ and ◉) on B:
153180
154181```
@@ -164,11 +191,16 @@ To see the focus's data, use the inspection functions:
164191```
165192j 'files' -- the focus's files
166193j 'changed' -- paths that differ from the parent
167−j 'status' -- id, message, labels, changed, conflicts
194+j 'status' -- focus, changes in the commit, unrecorded edits, conflicts
168195j 'log' -- a table of every commit, ✓ on the focus
169196j 'review' -- the focus's changes as diffs (difftastic)
170197```
171198
199+These inspections include current file edits without recording them. Use
200+`j save` to record file edits on their own (`id` is the same edit). `status` spells out empty states
201+such as `No changes` and `No conflicts`; `log` shows label names and short
202+ids and adapts to narrow terminals.
203+
172204### Composition order, made concrete
173205
174206```
@@ -194,11 +226,10 @@ operates on the repository the previous step produced.
194226
195227## Part 4 — Dry runs: seeing before doing
196228
197−Because inspections persist nothing, you can preview any edit by composing it
198−with `tree`:
229+Use `preview` to validate an edit and show its resulting tree without saving:
199230
200231```
201−j 'tree . squash'
232+j 'preview squash'
202233```
203234
204235From the A → B history, this *shows* the tree as `squash` would leave it (B
@@ -221,18 +252,21 @@ because `tree` returns a `Text`, not a `Repo`:
221252▶ └─ ◌ nknz B
222253```
223254
224−For a stronger guarantee, insert `validate`. `validate repo` returns the repo
225−unchanged if persisting would succeed, and crashes with the exact error
226−otherwise:
255+`preview` combines `tree` and `validate`. For an edit that takes arguments,
256+put that edit in parentheses:
227257
228258```
229−j 'tree . validate . rebase %main'
259+j 'preview (rebase %main)'
230260```
231261
232262This renders the post-rebase tree **and** crashes (without touching anything)
233263if that rebase couldn't actually happen — for instance, if `%main` doesn't
234264exist yet. That's an *honest* dry run.
235265
266+The longer form is `j 'tree . validate . rebase %main'`. `validate` alone
267+does not make an edit a dry run: `j 'validate . squash'` returns a repository
268+and saves it. The final inspection (`tree`) is what prevents persistence.
269+
236270---
237271
238272## Part 5 — `or`: the whole error-handling model
@@ -290,7 +324,7 @@ Two things make these safe to use freely:
290324
2913251. **The change id survives.** A commit keeps its `@wqzt…` id across
292326 `describe`, `squash`, `rebase`. You're never tracking a moving sha.
293−2. **`j undo` always works.** Every operation is recorded.
327+2. **Saved operations are undoable.** Use `j undo` and `j redo` to walk them.
294328
295329```
296330j undo -- revert the last thing
@@ -298,6 +332,28 @@ j redo -- reapply it
298332j ops -- see the operation log
299333```
300334
335+Undo and redo stop when files have unrecorded edits. Recording those edits
336+with `j id` adds an operation, so the next undo removes that recording;
337+another undo reaches the preceding edit. Recording also clears pending
338+redos. Read `j ops` to choose how far to go back. Successful interactive
339+undo/redo messages identify the affected operation and restored focus.
340+
341+### Resolve a conflict
342+
343+A successful rebase can leave conflicts in the working-copy commit. The edit
344+is saved; there is no separate continuation command. Resolve it in four steps:
345+
346+1. Run `j status` to find conflicted paths, and `j review` to inspect changes.
347+2. Edit those files, choosing the intended content and removing conflict markers.
348+3. Run `j save` to record the resolution.
349+4. Run `j status` again and check for `No conflicts` and `All edits recorded`.
350+
351+`j conflicts` lists conflicted commits throughout the repository. If a
352+conflict is on another mutable commit, use its id from `j tree` with
353+`j 'by @id'`, then follow the same steps. Run `j conflicts` again to check
354+the whole history. `review` uses difftastic when
355+installed and falls back to a unified diff when it is unavailable.
356+
301357### `split` and `contract`: moving changes by fileset
302358
303359A **fileset** is a function `Path -> Bool`. These two are inverses:
@@ -341,9 +397,12 @@ Most commands that take a "which commits" argument take a revset. `goto` is
341397the one place a revset must name *exactly one* commit:
342398
343399```
344−j 'goto %main' -- crash if %main names 0 or 2+ commits
400+j 'new . goto %main' -- select one main commit and start a mutable child
345401```
346402
403+A missing label reports how to fetch and inspect remote labels. An ambiguous
404+selector lists candidate ids and descriptions so you can choose with `by`.
405+
347406Revsets combine like sets:
348407
349408```
@@ -387,9 +446,9 @@ Everything `j` knows is defined in `config.j`. Any definition whose value is
387446an `Edit` is a new command. Add this to your `~/.config/j/config.j`:
388447
389448```
390−-- fold the whole stack into one commit called "release"
449+-- squash into the parent and name the combined commit "release"
391450release : Edit
392−release = describe "release" . (contract everything)
451+release = describe "release" . prev . squash
393452```
394453
395454Now `j release` works exactly like `squash` or `new`. The signature (`: Edit`)
@@ -432,8 +491,8 @@ echo 'fn parse() {}' > parser.rs
432491j 'describe "add the parser"'
433492
434493# second part
435−echo 'mod parser;' > lib.rs
436494j 'new'
495+echo 'mod parser;' > lib.rs
437496j 'describe "wire up the parser"'
438497
439498# look at it
@@ -442,17 +501,24 @@ j 'tree'
442501# the second commit was really part of the first; fold it in
443502j squash
444503
504+# squash leaves an empty child focused; move to the combined commit
505+j prev
506+
445507# rename the combined commit
446508j 'describe "add the parser"'
447509
448510# preview the rebase, then do it
449−j 'tree . validate . rebase %main'
511+j 'preview (rebase %main)'
450512j 'rebase %main'
451513
452514# share it
453515j 'push (label "parser" here)'
454516```
455517
518+Push sends recorded commits. If you edit files after the last saved edit,
519+push warns that those edits are omitted. Run `j save`, then push again to
520+include them. Looking at `j status` does not save the edits.
521+
456522And if any step goes wrong:
457523
458524```
specs/main.md+408−121
@@ -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, ten reserved words, and the bridge to jj repositories.
12+declared builtins, eleven reserved words, and the bridge to jj repositories.
1313`j` does not require the `jj` binary; it uses jj's on-disk formats through
1414jj-lib.
1515
@@ -40,15 +40,19 @@ 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`.
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.
44454. If neither is present, or the input is only whitespace, print a concise
4546 usage and getting-started guide to stderr and exit 2. The guide points to
4647 `j help`, `j commands`, and commands for starting or inspecting a repository.
4748 It does not read or create configuration or open a repository.
48−5. `--help` and `-h` are aliases for the reserved command `help` only when
49− they are the first whitespace-separated word (§1.1). There are no other
50− flags or trailing help flags: `j 'new --help'` is an expression whose
51− `--help` is a language comment, and `j '"--help"'` prints text.
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.
52566. The expression is text: an argument that is not valid UTF-8 is a usage
5357 error (exit 2) naming it, and so is an expression on standard input that
5458 is not.
@@ -57,13 +61,14 @@ echo EXPRESSION | j
5761
5862Before the expression is parsed, its text is trimmed of leading and trailing
5963whitespace and split on whitespace into words. If the first word is one of the
60−ten reserved words below, the whole text is a reserved command. A first word
64+eleven reserved words below, the whole text is a reserved command. A first word
6165of `--help` or `-h` is treated as `help`.
6266
6367| command | operation |
6468|---|---|
6569| `help [NAME]` | show the command guide, or help for a reserved command or config name; §1.1.1 |
66−| `commands [QUERY]` | list the vocabulary, optionally searching names, signatures, and comments; §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 |
6772| `init` | create a repository in the current directory; §7.8 |
6873| `clone URL [DIR]` | clone `URL` into `DIR` (default: the repository's name in `URL`); §7.8 |
6974| `remote URL` | set the URL of the remote `origin`, creating it if absent; §7.8 |
@@ -74,8 +79,12 @@ of `--help` or `-h` is treated as `help`.
7479| `ops` | print the operation log; §7.7 |
7580
7681For every reserved command except `push` and `commands`, the remaining words
77−are literal arguments (no quoting, no escaping); a wrong number is a usage
78−error (exit 2) and the language is not involved. `commands` takes the remaining
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
7988trimmed text as its optional search query, including spaces. For `push`, the
8089remainder of the text is an expression, evaluated as in §1.2 steps 2–7 but
8190against the repository as recorded, without snapshotting the working directory;
@@ -95,11 +104,18 @@ for that reserved command or for a name in the current configuration, including
95104its signature when present and available documentation. `--help [NAME]` and
96105`-h [NAME]` are equivalent to `help [NAME]`. These successful requests exit 0.
97106An unknown name is a usage error (exit 2) with guidance to use `j commands`.
98−
99−`commands` lists the reserved commands and the vocabulary actually declared or
100−defined in the current configuration, including custom definitions without
101−signatures. `commands QUERY` searches the catalog case-insensitively by name,
102−signature, and documentation comments. The catalog does not present an
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
103119undeclared builtin as available merely because the binary implements it.
104120
105121General help and help for reserved commands do not depend on configuration and
@@ -130,7 +146,8 @@ resolves commit ids, snapshots the working directory, or records operations.
1301467. If `v` is a function, apply it to `r`, once, and let `v` be the result.
1311478. If `v` is a `Repo` (§4.4): if it equals the repository value *as loaded,
132148 before the snapshot of §7.4*, do nothing at all; otherwise persist it
133− (§7.5) and check out its focused commit's files (§7.4). Print nothing.
149+ (§7.5) and check out its focused commit's files (§7.4). Print nothing to stdout;
150+ interactive stderr reports the outcome and focus (§1.5).
134151 Exit 0.
1351529. Otherwise display `v` (§5.1) on stdout. Persist nothing. Exit 0.
136153
@@ -140,7 +157,7 @@ persist nothing, leave the working directory untouched, and exit 1.
140157Step 7 applies exactly once. If the result of applying a function to `r` is
141158again a function, that function is displayed, not applied again.
142159
143−Step 8's equality test means `j id` is a no-op when the working directory is
160+Step 8's equality test means `j save` (the reference alias for `id`) is a no-op when the working directory is
144161unchanged and records it when it is not: the snapshot makes `r` differ from
145162the loaded value, and that difference is what gets persisted. The test may be
146163implemented by comparing commit and tree hashes rather than values.
@@ -152,9 +169,11 @@ Two consequences worth knowing:
152169 what a rebase would produce without doing it. `validate` in the chain,
153170 `j tree . validate . rebase trunk`, additionally raises whatever
154171 persistence would refuse, so the preview is of something that could
155− actually be persisted.
156−- **Do, then look.** `j squash` prints nothing. `j squash && j tree` is the
157− idiom; there is no flag for it.
172+ actually be persisted. The reference `preview EDIT` is the convenient
173+ form of `tree . validate . EDIT`: `j 'preview (rebase trunk)'`. Validation
174+ alone returns a `Repo`, so `validate . EDIT` persists a changed result.
175+- **Do, then look.** `j squash` reports the resulting focus on an interactive
176+ terminal. `j squash && j tree` displays the whole tree; there is no flag for it.
158177
159178### 1.3 Interrupts
160179
@@ -207,6 +226,49 @@ is in `goto`. A builtin is not a definition, so a crash in one is in
207226whichever definition applied it, and with none executing the line is only
208227` from <expression>`.
209228
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.
271+
210272---
211273
212274## 2. Values
@@ -466,7 +528,9 @@ negation. Ordering operators are defined on `Int` only and crash otherwise.
466528
467529`if`, `or`, `&&`, and `||` do not evaluate all operands. `&&` and `||`
468530short-circuit. The interpreter recognises `&&` and `||` by name; `or` is a
469−keyword.
531+keyword. Like other builtins, `&&` and `||` must be declared. Their contracts
532+check each evaluated operand and the final result; a skipped operand is
533+neither evaluated nor checked.
470534
471535`a or b` evaluates `a`. If that crashes, the crash is discarded and `b` is
472536evaluated and returned. Otherwise, if the value of `a` is a function, `b` is
@@ -532,17 +596,19 @@ as in §4.13.
532596| `replay onto ch` | replay the change `ch` (`{ from, to }`) onto the snapshot `onto`; §7.3 |
533597| `unresolved b` | `true` iff the blob is a conflict |
534598| `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 |
535600| `text b` | the content of a blob as text; for an unresolved blob, its conflict-marker rendering |
536601| `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 |
537602| `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 |
538603| `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 |
539604| `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) |
540605| `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 |
541−| `difft p a b` | the output of difftastic comparing `a` to `b` as the file `p`; §7.10. Crashes if `difft` is not on `PATH` |
606+| `difft p a b` | the output of difftastic comparing `a` to `b` as the file `p`, with a unified-diff fallback when the executable is absent; §7.10 |
607+| `unrecorded repo` | paths changed between the invocation's loaded and snapshotted focus files; §7.4. Returns `[]` without snapshot context, including in a push expression |
542608| `treeWith o r` | the history rendered as a tree with options record `o`; §7.11 |
543609| `extract S v` | every subvalue of `v` that is an `S`; §4.12 |
544610| `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) |
545−| `validate repo` | `repo` unchanged if persisting it would succeed; otherwise the crash persistence would raise (§7.5 steps 1–3 and 6). Persistence runs the same checks |
611+| `validate repo` | `repo` unchanged if persisting it would succeed; otherwise the crash persistence would raise (§7.5 steps 1–3, the root invariant of 4, and 6). A changed repository also checks the backend's author identity (§7.9). Persistence runs the same checks |
546612
547613### 4.10 Ids: existing and new
548614
@@ -631,6 +697,11 @@ signature is a crash, not a reason to try the other side. A signature on
631697a definition that is not a function (`user`, `conflicts`) is checked once,
632698against the value, when the definition is evaluated at load.
633699
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.
704+
634705The check is one level deep and never inspects inside a list or a record's
635706field values:
636707
@@ -674,7 +745,9 @@ indented under their key or bullet. Colour follows §7.11's rules and is used
674745only when stdout is a terminal; nothing is conveyed by colour alone.
675746
676747Shapes are recognised by their exact field set, as in §4.12: `Commit`, `Entry`,
677−`Subtree`, `Repo`, `Frame`, `Change`. Any other record is generic.
748+`Subtree`, `Repo`, `Frame`, `Change`, checked in that order using their
749+configured definitions. Additional type aliases do not displace these
750+display forms. Any other record is generic.
678751
679752| kind | line form | block form |
680753|---|---|---|
@@ -699,6 +772,21 @@ Shapes are recognised by their exact field set, as in §4.12: `Commit`, `Entry`,
699772| list of other scalars | `a, b, c` | same, wrapped at the terminal width |
700773| any other list | `n items` | one block per item, separated by a blank line |
701774
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+
702790An empty list of any kind prints `none` (dim) in block form and `0 …` in
703791line form. Author and age in a `Commit` block are read from jj's commit
704792metadata by id;
@@ -722,12 +810,13 @@ Examples:
722810
723811```
724812$ j status
725−changed src/lexer.rs
726− src/parser.rs
727−conflicts src/lexer.rs
728−id wqzt·kpqxmnrvyxskptlmzzzzabcd
729−labels feature
730−message wip
813+in commit src/lexer.rs
814+ src/parser.rs
815+conflicts src/lexer.rs
816+id wqzt·kpqxmnrvyxskptlmzzzzabcd
817+labels feature
818+message wip
819+unrecorded All edits recorded
731820
732821$ j conflicts
733822⊗ wqzt wip feature
@@ -770,14 +859,19 @@ j: crash: expected one revision, got 0
770859
771860### 5.2 `show`
772861
773−`show v` renders `v` as an expression in the language, such that for every
774−value except closures, unresolved blobs, and values nested past the parser's
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
775864bound (§3.4: a list 40,000 deep, a record about 20,000), `show v` parses and
776865evaluates to a value equal to `v` under the same `config.j`. It never uses
777866colour and never truncates. A value holding a part the store cannot supply (a
778867`files` list or a file's content, §7.2) crashes `show` as it crashes any other
779868use: `show` writes no stand-in for the part.
780869
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.
874+
781875| kind | rendering |
782876|---|---|
783877| `Int` | decimal |
@@ -786,7 +880,8 @@ use: `show` writes no stand-in for the part.
786880| list | `[v1 v2 …]` |
787881| record | `{ a = v, b = w }`, fields in ascending lexicographic order |
788882| `Id` | `@` followed by the full id, e.g. `@wqztkpqxmnrvyxskptlmzzzzabcd` |
789−| resolved `Blob` | `blob "…"` |
883+| resolved regular `Blob` with valid UTF-8 content | `blob "…"` |
884+| other resolved `Blob` | `blobHex "kind" "…"`, preserving file kind and bytes, with lowercase hexadecimal content |
790885| unresolved `Blob` | `{- unresolved -} blob "…"` with the conflict-marker rendering |
791886| builtin | its declared name, operators parenthesised: `map`, `(++)` |
792887| selector | the selector itself: `.id` |
@@ -824,12 +919,23 @@ Since `show` returns a `Text` and a top-level `Text` displays raw,
824919### 6.1 Location and loading
825920
826921`j` reads `$XDG_CONFIG_HOME/j/config.j`, defaulting to `~/.config/j/config.j`.
827−Expression evaluation, `push`, and `fetch` load the configuration; `init` and
922+Expression evaluation and `push` load the configuration; `init` and
828923`clone` need `user`. When there is no repository yet (`init`, `clone`), only
829924`user` is evaluated; `Id` literals are not resolved and no other definition
830−is evaluated. `remote`, `undo`, `redo`, and `ops` do not load configuration.
925+is evaluated. `remote`, `fetch`, `undo`, `redo`, and `ops` do not load configuration.
831926When a command needs configuration and none exists, the binary writes its
832−embedded reference `config.j` (§9) to that path as an editable starting point.
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.
833939An existing user configuration is not updated when the binary changes. The
834940reference file is also distributed at `<prefix>/share/j/config.j`.
835941
@@ -839,6 +945,23 @@ names read it without evaluation. If it is missing they use the embedded
839945reference in memory without writing a file. The empty-invocation guide does
840946not read or write configuration either.
841947
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.
964+
842965### 6.2 Validation
843966
844967Loading fails (exit 3) if any of these hold:
@@ -862,6 +985,13 @@ not declared is simply unbound, and using it is a runtime crash.
862985
863986Definitions may reference each other in any order.
864987
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+
865995### 6.3 Definitions the interpreter reads
866996
867997Four names in `config.j` are read by the interpreter itself, not only by
@@ -890,6 +1020,10 @@ but nothing here requires the `jj` binary to be installed.
8901020Walk up from the current directory to the first directory containing `.jj`.
8911021Only the default workspace is supported; if the working directory belongs to
8921022another 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`.
8931027
8941028### 7.2 Building the `Repo` value
8951029
@@ -993,10 +1127,11 @@ some other value in its place.
9931127 `./a` to `./a/b` replayed onto `./a/b`; the deletion of `./a/z` replayed
9941128 onto a snapshot where `./a` is a file gives that file), and it may crash
9951129 where jj carries a conflict that one of the three held at `./a`.
996−- `unresolved`, `blob`, and `text` are as in §4.9. `blob` produces a regular
997− file. File type (executable, symlink) travels with a `Blob` but cannot be
998− changed in the language. `text` of a symlink is its target; `text` of a
999− non-UTF-8 blob crashes.
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.
10001135
10011136### 7.4 Working-directory invariant
10021137
@@ -1092,6 +1227,14 @@ directory.
10921227
10931228Reserved commands never snapshot.
10941229
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+
10951238### 7.5 Persistence
10961239
10971240Given the loaded value `old` and the result `new`, one jj operation is
@@ -1099,11 +1242,14 @@ recorded, described by the expression text (truncated to 200 characters).
10991242Within it:
11001243
110112441. **Validate shape.** `new` has the shape of a `Repo`; every `id` occurs
1102− exactly once; every snapshot has unique paths, none of them the root `./`
1103− and none both a file and a directory (§7.3); every path component of the
1104− focus, of every other commit that is not in `old`, and of every path any
1105− other commit holds that it did not hold in `old` (§7.3), is a name a
1106− checkout can create: not empty, `.`, `..`, `.git` or `.jj`, without `/`
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 `/`
11071253 or NUL, and a name the workspace's filesystem can hold. A name the working
11081254 directory holds (§7.4) is one. A name longer than 255 bytes, the limit of
11091255 ext4, is tried on the filesystem, since exFAT, NTFS and APFS count 255
@@ -1228,10 +1374,18 @@ language: `fetch` and `push` are the only things that change them.
12281374 creating it if absent;
12291375 - `{ delete : Text }` — delete the bookmark of that name.
12301376
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+
12311384 Duplicates are merged; two records with the same `name` and different
12321385 effects are a crash. It is refused (exit 1, nothing pushed) if:
1233− - a `name` is not a valid git branch name, an `id` is not a visible commit,
1234− or a delete names a bookmark the remote does not have;
1386+ - a `name` is not a valid git branch name, an `id` is not a visible commit
1387+ or names the synthetic root (which has no Git commit), or a delete names
1388+ a bookmark the remote does not have;
12351389 - any commit that would be sent has an unresolved file or an empty
12361390 description (the commits that would be sent are the records' `id`s and
12371391 their ancestors that no bookmark on `origin` reaches as of the last
@@ -1270,47 +1424,68 @@ language: `fetch` and `push` are the only things that change them.
12701424 bookmarks, and the refused push does not touch the label. After a
12711425 successful push the labels reflect the new positions. It records one
12721426 operation, even when it sends two git pushes. An empty list sends nothing
1273− to the remote.
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.
12741431 If the remote accepts some of the updates and rejects others, that
12751432 operation records the accepted ones, whose labels move, and `push` exits 1
12761433 naming the rejected bookmarks and any deletes it did not send.
12771434
12781435Only the remote named `origin` is supported in this version; `fetch` and
1279−`push` exit 1 with `j: no remote origin` when it is not configured.
1436+nonempty `push` exit 1 with `j: no remote origin` when it is not configured.
12801437Authentication is git's: the ssh agent for ssh URLs, git credential helpers
12811438for https, as jj-lib's git backend provides them; `j` adds no prompting.
12821439Bringing work up to date is a separate expression, e.g. `rebase trunk`.
12831440
12841441### 7.7 Operations, undo, redo, and the lock
12851442
1286−Every persisting `j` program, and each of `fetch` and `push`, records exactly
1287−one jj operation, described by the expression or command text. Crashes record
1288−nothing, except as §7.5 step 7 says.
1289−
1290−**Kinds.** `j` marks each operation it records, in the operation's metadata,
1291−as *normal*, *undo* (naming the operation it undid), or *redo* (naming the
1292−undo it reversed). Operations recorded by other tools are treated as normal,
1293−except that an operation whose description carries jj's own undo or redo
1294−marker is treated accordingly, so mixed use chains correctly where the marker
1295−is present and degrades to "undo the newest operation" where it is not.
1296−
1297−**`undo`** finds the operation to undo: starting at the head, while the
1298−current operation is an undo, move to the operation it undid and then to that
1299−operation's parent; while it is a redo, move past the operation it reapplied.
1300−The first normal operation reached is undone by recording a new operation
1301−that restores its parent's view, marked as an undo of it. If none is found,
1302−or the one found is the operation `init` or `clone` recorded (§7.8), before
1303−which there was no repository, exit 1 with `j: nothing to undo`.
1304−
1305−**`redo`** requires the head to be an undo (possibly reached through redos);
1306−it records an operation restoring the view of the operation that undo
1307−discarded, marked as a redo. Otherwise exit 1 with `j: nothing to redo`.
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.
13081481
13091482Both refuse, before doing anything, if the working directory holds a change
13101483the focused commit does not, as a snapshot would find it (§7.4; a stale
13111484working copy holds none where the directory holds what was last checked out
1312−or what the commit has): `j: working copy has changes not in @; run \`j id\`
1313−to record them or discard them`. Both record their operation and then check
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
13141489out the restored focus, as persistence does (§7.5 step 7): a checkout that
13151490fails leaves the operation recorded, and `redo` or `undo` goes back.
13161491Undoing a `push` restores the recorded labels and leaves the remote untouched.
@@ -1363,7 +1538,9 @@ that state, §7.2 applies.
13631538 it: the last component of `URL`'s path, a final `.git` component skipped,
13641539 minus `.git` (`repo` for `/srv/repo/.git`, `host:repo.git` and
13651540 `https://host/team/repo`); a `URL` that leaves no name, such as `/` or
1366− `..`, is a usage error asking for `DIR`. It requires `user`.
1541+ `..`, is a usage error asking for `DIR`. It requires `user`. Interactive
1542+ confirmation includes a shell-quoted command to enter the clone directory
1543+ and run `j status`; cloning cannot change the caller's working directory.
13671544- **`remote URL`** sets the URL of `origin`, creating the remote if it does not
13681545 exist. It does not fetch.
13691546
@@ -1394,16 +1571,27 @@ Commits are authored and committed as `user.name <user.email>` from
13941571`config.j` (§6.3). `j` reads no other configuration; jj-lib's settings object
13951572is constructed by `j` from `user` and fixed defaults. `init`, `clone`, and
13961573persistence exit 3 if `user` is missing or malformed, since that is a
1397−configuration error.
1574+configuration error. Authoring also refuses blank fields or the shipped
1575+`Your Name` / `you@example.com` placeholders (§6.2), with instructions to
1576+edit the actual config file. Read-only expressions and unchanged results
1577+remain available with placeholder identity. Successful interactive setup
1578+reports the configured identity; terminal control characters are escaped
1579+in confirmations without modifying the identity stored in commits.
1580+`validate` checks the author identity when its argument differs from the
1581+loaded repository, so validated previews report the same identity refusal
1582+before saving. That refusal is a catchable crash, like other `validate`
1583+failures. An unchanged `validate` and previews without `validate` remain usable.
13981584
13991585### 7.10 External diff rendering
14001586
14011587`difft p a b` runs difftastic. It is the only builtin that executes another
14021588program.
14031589
1404−1. Write the content of `a` and `b` to two temporary files whose names end in
1405− the last component of `p` (so difftastic's language detection sees the
1406− real filename), inside a fresh temporary directory.
1590+1. Write the content of `a` and `b` to files in separate `old` and `new`
1591+ subdirectories of a fresh temporary directory. Each file keeps the last
1592+ component of `p` unchanged, so valid names stay within the filesystem's
1593+ length limit and difftastic sees the real filename. A last component
1594+ that is not a filename is a crash; an empty path uses `file`.
140715952. Run `difft OLD NEW` with the process's stdin closed and its environment
14081596 inherited unchanged, except: if `DFT_COLOR` is unset and `j`'s stdout is a
14091597 terminal, set `DFT_COLOR=always`; if `DFT_WIDTH` is unset and `j`'s stdout
@@ -1413,15 +1601,24 @@ program.
141316014. Return stdout as `Text`. A non-zero exit status is not an error (difftastic
14141602 uses it to signal "differences found" under some options), so what a
14151603 `difft` that fails printed, even nothing, is the result; a failure to
1416− execute the program is a crash naming it.
1604+ execute the program because it is absent uses the fallback below. Other
1605+ launch failures are crashes naming the tool and the OS error.
1606+
1607+If the executable is absent (`NotFound`), return a unified diff with three
1608+lines of context and `--- a/PATH` / `+++ b/PATH` headers using the complete
1609+path `p`. Paths containing whitespace or control characters are quoted.
1610+Content that is not UTF-8 or contains NUL receives a binary-difference summary.
1611+Equal bytes produce empty output. This fallback is usable without installing
1612+another program or changing configuration.
14171613
14181614All rendering options are difftastic's own, set through its `DFT_*`
14191615environment variables. `j` adds no options of its own. `diff` remains
1420−available as a dependency-free unified diff; the reference `config.j` uses
1421−`difft` for display, so a user without difftastic edits one line.
1616+available as a dependency-free unified diff without path headers; the
1617+reference `config.j` uses `difft` for display and receives the automatic
1618+fallback when difftastic is absent.
14221619
1423−Packaging: difftastic is a runtime dependency; on Nix, wrap `j` with `difft`
1424−on its `PATH`.
1620+Packaging: difftastic is an optional runtime enhancement; on Nix, wrap `j`
1621+with `difft` on its `PATH`.
14251622
14261623### 7.11 Tree rendering
14271624
@@ -1445,7 +1642,8 @@ run — have no metadata: their margin cells are blank, their size bar is
14451642computed from the value, and they sort last among their siblings. The same
14461643holds for a `Commit` block in display (§5.1).
14471644
1448−Missing fields crash. `color = "auto"` means colour iff stdout is a terminal.
1645+Missing fields crash. `color = "auto"` means colour iff stdout is a terminal
1646+and `TERM` is not `dumb`.
14491647If the environment variable `NO_COLOR` is set and non-empty, colour is never
14501648used regardless of `color`. `config.j` fixes the defaults by defining `tree`.
14511649
@@ -1663,6 +1861,10 @@ Drop = { delete : Label } -- delete the remote's bookmark
16631861------------------------------------------------------------------------------
16641862
16651863(.) : (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.
16661868id : a -> a
16671869const : a -> b -> a
16681870crash : Text -> a -- abort the program with a message
@@ -1730,21 +1932,33 @@ splitOn : Text -> Text -> [Text] -- splitOn "/" "a/b" = ["a" "b"]
17301932-- file ./a would, crashes, naming the path (§7.3).
17311933replay : Snapshot -> Change -> Snapshot
17321934unresolved : 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
17331942blob : Text -> Blob -- a resolved regular file
1943+blobHex : Text -> Text -> Blob -- kind (regular/executable/symlink), hex bytes
17341944text : Blob -> Text -- its content (markers if unresolved)
17351945by : 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
17361948subtreeCommits : a -> [Commit] -- every commit of a tree in preorder, in one
17371949 -- pass: what `commits` (below) is
17381950meta : Id -> Meta -- git hash, author, email, committer time;
17391951 -- crash for a commit not yet persisted
17401952validate : Repo -> Repo -- the repo unchanged, or the crash persisting
1741− -- it would produce (shape, labels, immutability,
1953+ -- it would produce (shape, root, labels, immutability,
17421954 -- focus). `tree . validate . rebase trunk` is an
17431955 -- honest dry run.
17441956diff : Blob -> Blob -> Text -- unified diff from the first to the second
1745−difft : Path -> Blob -> Blob -> Text -- difftastic's rendering; needs difft on PATH.
1746− -- Options via DFT_* environment variables.
1747−treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text }
1957+difft : Path -> Blob -> Blob -> Text -- difftastic when difft is on PATH; otherwise
1958+ -- unified diff with path headers and 3 context lines,
1959+ -- or a binary-files summary. DFT_* configures difft.
1960+treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text
1961+ , lanes : Int, author : Bool, date : Bool, files : Bool }
17481962 -> Repo -> Text -- the history as a tree; see the spec
17491963
17501964-- Every subvalue of a given shape, however deeply nested. A shape is a type
@@ -1830,6 +2044,9 @@ restrict = \m -> filter (\e -> m e.path)
18302044select : Fileset -> Snapshot -> Snapshot -> Snapshot -- m from a, the rest from b
18312045select = \m a b -> restrict m a ++ restrict (neg m) b
18322046
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`.
18332050conflicted : Snapshot -> [Path]
18342051conflicted = \s -> map (.path) (filter (\e -> unresolved e.content) s)
18352052
@@ -1914,6 +2131,11 @@ minus = \a b repo -> let ys = b repo in filter (\i -> not (member i ys)) (a repo
19142131matching : (Commit -> Bool) -> Revset -> Revset
19152132matching = \p rs repo -> filter (\i -> p (commitAt i repo)) (rs repo)
19162133
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.
19172139conflicts : Revset
19182140conflicts = matching (\c -> not (null (conflicted c.files))) all
19192141
@@ -1922,7 +2144,10 @@ firstOf : [Revset] -> Revset
19222144firstOf = \rss repo -> foldl (\acc rs -> if null acc then rs repo else acc) [] rss
19232145
19242146-- The main line: whichever of these names the remote has, first one wins.
1925−-- Empty in a repository with no remote, in which case nothing is immutable.
2147+-- Empty when no fetched remote label is named main, master, or trunk;
2148+-- nothing is then immutable. Configure `j remote URL`, run `j fetch`, and
2149+-- inspect `j tree` to see remote labels. A remote with another default label
2150+-- needs this definition adjusted. Start work with j 'new . goto trunk'.
19262151trunk : Revset
19272152trunk = firstOf [%main %master %trunk]
19282153
@@ -1938,21 +2163,33 @@ immutable = ancestorsOf trunk
19382163-- Focusing. `goto` is the only place a revset must have exactly one member.
19392164------------------------------------------------------------------------------
19402165
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.
19412168goto : Revset -> Edit
19422169goto = \rs repo ->
19432170 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"
19442175 in if length is == 1
19452176 then by (head is) repo
1946− else crash ("expected one revision, got " ++ show (length is))
2177+ else if null is
2178+ then crash "expected one revision, got 0; no matching revision. Run `j tree` to inspect available revisions. For a missing remote label such as %main or trunk, configure `j remote URL`, run `j fetch`, and check the label names in `j tree`."
2179+ else crash ("expected one revision, got " ++ show (length is) ++ "\nCandidates:\n" ++ concat (map candidate (take 8 is)) ++ (if length is > 8 then " More candidates: run `j tree`.\n" else "") ++ "Choose a mutable commit with `j by @ID`, or start a child with `j 'new . by @ID'`; replace @ID with a candidate above.")
19472180
19482181commitAt : Id -> Repo -> Commit
1949−commitAt = \i repo -> (by i repo).root
2182+commitAt = lookupCommit
19502183
19512184prev : Edit -- the parent
19522185prev = goto parents
19532186
1954−next : Edit -- the only child
1955−next = goto kids
2187+-- Move to the only child. At a tip, `j new` starts a child; when there are
2188+-- several children, choose one of the candidates with `j by @ID`.
2189+next : Edit
2190+next = \repo -> if null (kids repo)
2191+ then crash "next: this commit has no children; run `j new` to start a new child, or `j tree` to inspect the history"
2192+ else goto kids repo
19562193
19572194tip : Edit -- end of the line of only-children
19582195tip = \repo -> tip (next repo) or repo
@@ -1978,6 +2215,8 @@ eachChild = forEach kids
19782215guard : (Repo -> Bool) -> Edit -- crash unless p holds
19792216guard = \p repo -> if p repo then repo else crash "guard"
19802217
2218+-- Whether the focused commit has no unresolved files. This says nothing
2219+-- about unrecorded working-copy edits; inspect those with `j unrecorded`.
19812220clean : Repo -> Bool
19822221clean = \repo -> null (conflicted repo.root.files)
19832222
@@ -2016,11 +2255,28 @@ rewrite = \f repo ->
20162255-- Commands
20172256------------------------------------------------------------------------------
20182257
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+
20192273-- Set the message of the focused commit.
2274+-- Example: j 'describe "fix login"'
20202275describe : Text -> Edit
20212276describe = \m -> mapRoot (\c -> c { message = m })
20222277
20232278-- Start an empty child of the focus and move to it.
2279+-- Examples: j new; j 'describe "next change" . new'
20242280new : Edit
20252281new = \repo -> addChild (newCommit repo.root.files) repo
20262282
@@ -2028,6 +2284,7 @@ new = \repo -> addChild (newCommit repo.root.files) repo
20282284-- new empty commit on that parent becomes the focus (the parent may be
20292285-- immutable, and the focus never is). Refused for a commit the remote has a
20302286-- 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'.
20312288abandon : Edit
20322289abandon = \repo ->
20332290 let c = repo.root
@@ -2037,17 +2294,20 @@ abandon = \repo ->
20372294 else new (remove (eachChild (replayOnto p) repo))
20382295
20392296-- Push the files matching m from the focus down into its parent.
2297+-- Example: j 'contract (ext "rs")'
20402298contract : Fileset -> Edit
20412299contract = \m repo -> let a = repo.root
20422300 in at parents (rewrite (\q -> q { files = select m a.files q.files })) repo
20432301
20442302-- Fold the focus entirely into its parent; a new empty commit becomes the focus.
2303+-- Example: j squash; preview first with j 'preview squash'.
20452304squash : Edit
20462305squash = abandon . contract everything
20472306
20482307-- Push the files matching m from the focus up into a new child, which becomes
20492308-- the focus and inherits the old focus's children. Labels stay with the
20502309-- original commit, since they are the remote's.
2310+-- Example: j 'split (ext "rs")'; use (under ./src) to select a directory.
20512311split : Fileset -> Edit
20522312split = \m repo ->
20532313 let a = repo.root
@@ -2057,6 +2317,7 @@ split = \m repo ->
20572317 in by x.id (repo { root = a', children = [{ root = x, children = repo.children }] })
20582318
20592319-- Move the focused subtree onto another commit. Returns to the moved commit.
2320+-- Example: j 'rebase trunk'; preview with j 'preview (rebase trunk)'.
20602321rebase : Revset -> Edit
20612322rebase = \rs repo ->
20622323 let d = (goto rs repo).root
@@ -2067,6 +2328,7 @@ rebase = \rs repo ->
20672328 in by me (attach x.subtree (by d.id x.rest))
20682329
20692330-- Apply the focused commit's change onto another commit, as a new child there.
2331+-- Example: j 'pick %main'
20702332pick : Revset -> Edit
20712333pick = \rs repo ->
20722334 let ch = changeOf repo
@@ -2074,6 +2336,7 @@ pick = \rs repo ->
20742336 in at rs (addChild (newCommit (replay t.files ch))) repo
20752337
20762338-- A new child of the focus that undoes the change of the given commit.
2339+-- Example: j 'backout %feature'
20772340backout : Revset -> Edit
20782341backout = \rs repo ->
20792342 let ch = changeOf (goto rs repo)
@@ -2119,20 +2382,22 @@ entryAt = \p s -> filter (\e -> e.path == p) s
21192382
21202383-- The content at path p, or an empty file if absent.
21212384contentAt : Path -> Snapshot -> Blob
2122−contentAt = \p s -> (head (entryAt p s)).content or blob ""
2385+contentAt = lookupContent
21232386
21242387-- The paths a change touches.
21252388touched : Change -> [Path]
2126−touched = \ch ->
2127− let paths = map (.path) ch.to ++ filter (\p -> not (member p (map (.path) ch.to))) (map (.path) ch.from)
2128− in filter (\p -> entryAt p ch.to /= entryAt p ch.from) paths
2389+touched = touchedPaths
21292390
2130−-- Paths whose content differs between the focus and its parent.
2391+-- Paths changed by the focused commit compared with its parent, including
2392+-- the working-copy snapshot. These can remain nonempty after `j save`;
2393+-- `j unrecorded` shows only working-copy edits not yet recorded.
2394+-- Example: j changed
21312395changed : Repo -> [Path]
21322396changed = \repo -> touched (changeOf repo)
21332397
2134−-- What the focus changes, rendered by difftastic, one record per changed path.
2135−-- Replace difft with (\p -> diff) to use the built-in unified diff instead.
2398+-- What the focus changes, one record per changed path. Uses difftastic when
2399+-- installed; a missing difft falls back to a unified diff with path headers
2400+-- and 3 context lines, or a binary-files summary.
21362401diffs : Repo -> [{ path : Path, diff : Text }]
21372402diffs = \repo ->
21382403 let ch = changeOf repo
@@ -2140,23 +2405,30 @@ diffs = \repo ->
21402405
21412406-- The same, as one page of text. A Text result prints raw, so `j review` is
21422407-- readable in the terminal. A focus that changes nothing reads "no changes";
2143−-- a difft that cannot be run (not on PATH) is a crash saying so, and
2144−-- otherwise whatever difft prints, even nothing, is the page.
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.
21452412review : Repo -> Text
21462413review = \repo ->
21472414 let ds = diffs repo
21482415 in if null ds then "no changes\n" else concat (map (\d -> d.diff) ds)
21492416
2150−-- A summary of the focus.
2151−status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path] }
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] }
21522422status = \repo ->
21532423 { id = repo.root.id
21542424 , message = repo.root.message
21552425 , labels = repo.root.labels
21562426 , changed = changed repo
2157− , conflicts = conflicted repo.root.files }
2427+ , conflicts = conflicted repo.root.files
2428+ , unrecorded = unrecorded repo }
21582429
21592430-- A summary of every commit, top first, in preorder.
2431+-- Example: j log
21602432log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]
21612433log = \repo ->
21622434 map (\c -> { id = c.id
@@ -2167,11 +2439,21 @@ log = \repo ->
21672439
21682440-- The history as a tree. Edit these records to change what `j tree` shows.
21692441-- (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.
21702445tree : Repo -> Text
2171−tree = treeWith ({ detail = 1, margin = false, elide = true, icons = false, color = "auto" })
2446+tree = treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
2447+
2448+treeCompact : Repo -> Text
2449+treeCompact = treeWith ({ detail = 1, margin = false, elide = true, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
21722450
21732451treeFull : Repo -> Text
2174−treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
2452+treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
2453+
2454+-- The full tree plus the extra data columns: date, files changed, author.
2455+treeData : Repo -> Text
2456+treeData = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = true, date = true, files = true })
21752457```
21762458
21772459---
@@ -2179,9 +2461,9 @@ treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false,
21792461## 10. Testing
21802462
21812463The interpreter must be testable without a jj repository. Provide an in-memory
2182−implementation of the domain builtins (`replay`, `unresolved`, `blob`,
2464+implementation of the domain builtins (`replay`, `unresolved`, `blob`, `blobHex`,
21832465`text`, `by`, `extract`, `meta`, `difft` as a stub, and the minting
2184−of `@`) in which a blob is text, a
2466+of `@`) in which a resolved blob carries bytes and a file kind, a
21852467conflict is a record of the three sides, and `replay` resolves a path exactly
21862468when at most one side changed it or both made the same change, path by path
21872469even where a file meets a directory, which jj merges as one value (§7.3).
@@ -2217,9 +2499,10 @@ here is out of scope (§12). Nothing here uses the `jj` binary.
22172499**Getting a repository**
22182500
22192501```
2220−$EDITOR ~/.config/j/config.j -- set `user` once
2502+j setup 'Your actual name' you@your-domain.example -- create the full config once
22212503j init -- new repository here, or adopt an existing .git
2222−j clone git@host:org/repo.git -- or an existing one
2504+j clone git@host:org/repo.git project -- or clone an existing one
2505+cd project -- after clone, enter its directory
22232506j remote git@host:org/repo.git -- attach a remote to an init'd repo
22242507```
22252508
@@ -2228,9 +2511,9 @@ j remote git@host:org/repo.git -- attach a remote to an init'd repo
22282511```
22292512j tree -- the history as a tree, focus marked
22302513j treeFull -- the same with sizes, ages, authors, changed paths
2231−j status -- id, message, labels, changed paths, conflicts of @
2514+j status -- focus, in-commit changes, unrecorded edits, conflicts
22322515j log -- every commit as a record
2233−j review -- difftastic rendering of @ against its parent
2516+j review -- difftastic or unified diff of @ against its parent
22342517j review . by @kpq -- the same for another commit, without moving
22352518j diffs -- the same, one record per path
22362519j here -- @'s id
@@ -2261,7 +2544,8 @@ j rebase trunk
22612544j guard clean . rebase trunk or id -- only if it comes out clean
22622545j pick @kpq
22632546j 'forEach (descendantsOf here) (describe "wip")'
2264−j id -- record the working directory, change nothing else
2547+j save -- record the working directory, change nothing else
2548+j 'preview squash' -- validate and show the result without saving
22652549j tree . squash -- dry run: show the result, persist nothing
22662550j tree . validate . squash -- the same, refusing what persistence would refuse
22672551j squash && j tree -- do, then look
@@ -2283,7 +2567,10 @@ j rebase trunk -- bring the stack up to date
22832567A rebase or fetch that conflicts leaves unresolved files in the affected
22842568commits. `j conflicts` lists them; `j by @…` moves there, which checks the
22852569files out with conflict markers; editing the files and running any persisting
2286−expression (`j id` at minimum) records the resolution.
2570+expression (`j save` or `j id` at minimum) records the resolution. Run
2571+`j status` to verify `No conflicts` and `All edits recorded` in the focus,
2572+and `j conflicts` to check the rest of the history. No continue command is
2573+needed.
22872574
22882575**Mistakes**
22892576
@@ -2298,7 +2585,7 @@ j redo
22982585Any definition added to `config.j` whose value is an `Edit` is a command:
22992586
23002587```haskell
2301−pr = \n -> describe n . squash
2588+pr = \n -> describe n . prev . squash
23022589```
23032590
23042591```
@@ -2311,9 +2598,9 @@ j 'pr "fix login"' && j 'push (label "fix-login" here)'
23112598
23122599Second parents of merge commits (merges are placed under their first parent
23132600and are read-only); hunk-level `split`; conflict resolution inside the
2314−language (blobs are opaque); workspaces other than the default; remotes other
2315−than `origin`; tags; submodules and LFS; ignore rules beyond `.gitignore`;
2316−changing file modes or symlinks from the language; repairing a repository in
2317−which one change id has two visible commits (refused; §7.2); general command
2318−flags beyond the first-word `--help` and `-h` aliases; static typing. None of
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
23192606these are precluded by the design.
specs/tree.md+59−34
@@ -137,10 +137,16 @@ always to the right of its source.
137137If rule 5 or a reservation finds no empty lane in `0 < ℓ < lanes`, the
138138subtree rooted at `n` is **flattened**: `n` and every descendant of it still
139139get their rows, in row order, with their id, message, labels and margin, but
140−they take no lane. Their rails area shows only the rails that pass through
141−and `»` in the last column. Nothing is hidden — the focus in particular is
142−always shown — only the shape beyond `lanes` is lost. Widen `lanes` (or
143−enable `elide`) to see it.
140+they take no lane. Their rails area preserves every rail that passes through.
141+If any rendered node is flattened, a separate four-column status area
142+is reserved between the rails and the id separator on every row. Flattened
143+rows show `»`; on a commit row this is followed by a space and its normal
144+node glyph (§Glyphs), padded to four display columns. Non-flattened rows
145+leave the area blank. Neither the overflow marker nor a node glyph replaces
146+a live rail. Nothing is hidden —
147+the focus, conflicts, and empty commits in particular are always identifiable
148+— only the shape beyond `lanes` is lost. Widen `lanes` (or enable `elide`) to
149+see it.
144150
145151#### Step 4 — drawing a row
146152
@@ -149,26 +155,28 @@ Columns, left to right, at fixed offsets:
1491551. **Gutter**, 2 characters: `▶ ` on the focus row, blank otherwise.
1501562. **Rails**, `2 · lanes` characters: lane `i` owns characters `2i` and
151157 `2i+1`.
152−3. One space.
153−4. **Id**: `@` followed by the shortest prefix unique among the commits of the
158+3. **Overflow status**, 4 display columns when any rendered node is flattened;
159+ absent otherwise (§Overflow).
160+4. One space.
161+5. **Id**: `@` followed by the shortest prefix unique among the commits of the
154162 display tree, minimum 4 — so the rendered id is itself a valid id literal
155163 (§3.1). Blank on a run row.
156−5. **Size bar** (`detail ≥ 1`, focus and its parent and children only; all
164+6. **Size bar** (`detail ≥ 1`, focus and its parent and children only; all
157165 commits at `detail = 2`): `▁ ▂ ▃ ▅ ▇`, as before. A row without a bar
158166 keeps the column blank; it is left out only when no row has a bar.
159−6. **Message**: first line; nothing if empty; followed by `⋯ n` on a
167+7. **Message**: first line; nothing if empty; followed by `⋯ n` on a
160168 collapsed node.
161−7. **Labels**: left-aligned column, present only if any rendered commit has
169+8. **Labels**: left-aligned column, present only if any rendered commit has
162170 a label; names separated by two spaces.
163−8. **Margin** (`margin = true`): age and author initials, as before, each
171+9. **Margin** (`margin = true`): age and author initials, as before, each
164172 right-aligned in its own column (a `9m` ends under a `13m`), so the margin
165173 ends at one column on every row.
166−9. **Data columns** (off unless enabled): `date` shows the commit's absolute
174+10. **Data columns** (off unless enabled): `date` shows the commit's absolute
167175 date `YYYY-MM-DD`; `files` shows the number of files changed as `n files`;
168176 `author` shows the full author name. They appear in that order, grey,
169177 immediately before the margin in the same right-aligned block.
170178
171−Columns 4–8 start at the same offset on every row. Labels start two columns
179+Columns 5–9 start at the same offset on every row. Labels start two columns
172180past the widest message, and the data columns and the margin start two
173181columns past the widest label (past the widest message when there is no
174182label column). Each data column, the age and the initials are as wide as
@@ -187,7 +195,6 @@ and the margin keep their columns; otherwise nothing is cut.
187195| `i` lies between the two ends of a horizontal segment on this row and holds a rail | `┼` |
188196| `i` lies between the two ends of a horizontal segment on this row | `─` |
189197| `i` holds a rail passing through this row | `│` |
190−| flattened rows only: `i = lanes − 1` | `»` |
191198| otherwise | space |
192199
193200Character `2i+1` is `─` if it lies inside a horizontal segment (from a fork
@@ -199,12 +206,16 @@ rail. The count is never cut: what does not fit the rails area runs on,
199206unbroken, through the space after it into the id column, which is widened if
200207it cannot hold the rest.
201208
202−**Detail line** (`detail = 2`, focus only): one extra row directly under the
203−focus. Its rails show `│` in every lane that holds a rail below the focus row
204−(including the focus's own lane if it has children); the text starts two
205−columns into the id column, under the focus's id, and lists the changed paths
206−with a mark each:
207−`+` added, `~` modified, `−` deleted, `✖` unresolved.
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.
208219
209220#### Glyphs
210221
@@ -226,7 +237,8 @@ its parent's in that history — not of the stored commit with the same id, so
226237a dry run (§1.2) draws them as the edit would leave them.
227238An `icons = true` glyph is two columns wide, so it fills both characters of
228239its lane: character `2ℓ+1` of its row is not drawn.
229−Rails use `│ ├ ╰ ─ ┼ ╮ ┬ ╎ »`.
240+Rails use `│ ├ ╰ ─ ┼ ╮ ┬ ╎`; `»` belongs to the separate overflow status
241+column.
230242
231243#### Elision (`elide = true`)
232244
@@ -250,18 +262,29 @@ drawn with `⌂`.
250262
251263#### Colour
252264
253−Glyphs are coloured by meaning (the same precedence as their shape): root bold,
254−conflict red, empty dim, immutable blue, focus bold cyan, ancestor of focus
255−green, other light grey. Ids keep their existing colours (conflict red,
256−immutable blue). Messages are bold on the focus row, red on a conflict, and
257−dim-italic when empty. Labels are green; the margin and the data columns are
258−grey with author-hued initials. Rail connectors in lane 0 take the immutable
259−colour; connectors in other lanes are dim.
260−
261−The whole focus row is highlighted with a background band that spans the full
262−terminal width. This is colour only: glyphs, rails, and the `▶` gutter still
263−carry the meaning, so a colourless rendering loses nothing — the band is a
264−no-op when colour is off.
265+Glyphs are coloured by meaning (the same precedence as their shape): root bold
266+magenta, conflict bright red, empty dim, immutable bright blue, focus bold
267+cyan, ancestor of focus bright green, other bright white. Ids are bold magenta,
268+with conflict red and immutable blue taking precedence; the focus id and `▶`
269+gutter are bold cyan. Messages are bold on the focus row, red on a conflict,
270+and dim-italic when empty. Labels are bold green; the margin and data columns
271+are grey with bright author-hued initials. Size bars progress from cyan
272+through blue and magenta to yellow as their height increases. Rail connectors
273+in lane 0 take the immutable colour; other lanes cycle through magenta, cyan,
274+yellow, and green by lane position. Glyphs always keep their semantic colour.
275+
276+The whole focus row is highlighted with bright text on a dark-blue background
277+band that spans the full terminal width. This is colour only: glyphs, rails,
278+and the `▶` gutter still carry the meaning, so a colourless rendering loses
279+nothing — the band is a no-op when colour is off.
280+
281+Structured value display shares this palette: id prefixes are bold magenta,
282+field names and table headers dim cyan, paths cyan, integers and file sizes
283+yellow, and booleans green for true and red for false. Raw text and blob
284+contents are never styled. `color = "auto"` requires a terminal whose `TERM`
285+is not `dumb`; `color = "always"` explicitly enables colour and `"never"`
286+disables it. A non-empty `NO_COLOR` disables colour in every mode. Redirected
287+output remains plain in auto mode.
265288
266289#### Legend
267290
@@ -274,8 +297,10 @@ The legend covers the node glyphs present (§Glyphs), the focus gutter `▶`,
274297run `╎ n` and collapsed `⋯ n` rows when shown, and the detail-line marks
275298(`+` added, `~` modified, `−` deleted, `✖` unresolved) when a detail line is
276299present. A collapsed node shows `⋯ n` only when `n > 0`, so the `⋯` legend
277−entry appears only then. The rails characters are not itemised; they are
278−structural (§Step 4).
300+entry appears only then. When any rendered node is flattened, it also includes
301+`» branch exceeds lane limit; increase lanes`. Node glyphs in the overflow
302+status column contribute their normal legend entries. The rails characters
303+are not itemised; they are structural (§Step 4).
279304
280305The legend text is faint (dim). Placement: when stdout is a terminal and the
281306tree's widest line plus the legend fits the terminal width, the legend is set
src/builtins.rs+44
@@ -95,8 +95,10 @@ 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),
9899 builtin("subtreeCommits", 1, b_subtree_commits),
99100 builtin("blob", 1, b_blob),
101+ builtin("blobHex", 2, b_blob_hex),
100102 builtin("text", 1, b_text),
101103 builtin("by", 2, b_by),
102104 builtin("lookupCommit", 2, b_lookup_commit),
@@ -496,6 +498,28 @@ fn b_touched_paths(_i: &mut Interp, args: &[Value]) -> BResult {
496498 Ok(Value::list(out))
497499}
498500
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+
499523/// every commit of a tree in preorder (§4.9): the value of the recursive
500524/// `\t -> t.root :: (concat (map subtreeCommits t.children) or [])`, which
501525/// the reference config's `commits` was. `::` and `concat` build new lists,
@@ -529,6 +553,21 @@ fn b_blob(_i: &mut Interp, args: &[Value]) -> BResult {
529553 })))
530554}
531555
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+
532571fn b_text(_i: &mut Interp, args: &[Value]) -> BResult {
533572 match &args[0] {
534573 Value::Blob(b) => {
@@ -595,6 +634,11 @@ fn b_meta(i: &mut Interp, args: &[Value]) -> BResult {
595634
596635fn b_validate(i: &mut Interp, args: &[Value]) -> BResult {
597636 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+ }
598642 Ok(args[0].clone())
599643}
600644
src/config.rs+2−2
@@ -39,8 +39,8 @@ const RESERVED_BUILTINS: &[&str] = &[
3939 ".", "id", "const", "crash", "==", "/=", "&&", "||", "not", "+", "-", "*", "<", "<=", ">",
4040 ">=", "show", "::", "map", "filter", "length", "null", "head", "tail", "last", "nth", "take",
4141 "drop", "member", "range", "foldl", "concat", "++", "startsWith", "endsWith", "splitOn",
42− "replay", "unresolved", "blob", "text", "by", "meta", "validate", "diff", "difft", "treeWith",
43− "extract", "touchedPaths", "subtreeCommits", "lookupCommit", "lookupContent",
42+ "replay", "unresolved", "blob", "blobHex", "text", "by", "meta", "validate", "diff", "difft", "treeWith",
43+ "extract", "touchedPaths", "unrecorded", "subtreeCommits", "lookupCommit", "lookupContent",
4444];
4545
4646pub fn is_reserved_builtin(name: &str) -> bool {
src/diagnostics.rsnew file+381
@@ -0,0 +1,381 @@
1+//! Read-only guidance for common CLI mistakes. The original error remains the
2+//! authority: these helpers neither retry an expression nor change its meaning.
3+
4+use crate::ast::{Expr, TypeExpr};
5+use crate::config::Config;
6+use crate::parse::parse_expr;
7+use crate::shape::{compile_contract, type_name};
8+use crate::value::Crash;
9+use std::collections::BTreeSet;
10+use std::rc::Rc;
11+use unicode_width::UnicodeWidthChar;
12+
13+/// Add a location and the offending source line without inventing a column
14+/// that the parser does not currently retain.
15+pub fn format_source_error(label: &str, src: &str, line: usize, msg: &str) -> String {
16+ let mut output = format!("{}: line {}: {}", label, line, msg);
17+ if let Some(source_line) = line.checked_sub(1).and_then(|n| src.split('\n').nth(n)) {
18+ output.push_str(&format!("\n {} | {}", line, source_excerpt(source_line.trim_end_matches('\r'))));
19+ }
20+ output
21+}
22+
23+/// Leave room for an ellipsis, count wide characters, and render terminal
24+/// controls as visible escapes. Charge even zero-width characters one column
25+/// so a malformed line of combining marks cannot produce unlimited output.
26+fn source_excerpt(line: &str) -> String {
27+ const MAX_COLUMNS: usize = 160;
28+ let mut excerpt = String::new();
29+ let mut columns = 0;
30+ for character in line.chars() {
31+ let visible = if character.is_control() {
32+ character.escape_default().to_string()
33+ } else {
34+ character.to_string()
35+ };
36+ let width: usize = visible.chars().map(|c| c.width().unwrap_or(0).max(1)).sum();
37+ if columns + width > MAX_COLUMNS - 1 {
38+ excerpt.push('…');
39+ break;
40+ }
41+ excerpt.push_str(&visible);
42+ columns += width;
43+ }
44+ excerpt
45+}
46+
47+/// Suggest a next step only when the reported failure agrees with the user's
48+/// expression. In particular, an error inside a config definition must not be
49+/// misdiagnosed as a missing argument on the command line.
50+pub fn expression_hint(text: &str, cfg: &Config, crash: &Crash) -> Option<String> {
51+ expression_hint_for(text, cfg, crash, Invocation::Expression)
52+}
53+
54+/// Diagnose the expression following `push`, preserving that reserved command
55+/// in copyable examples so following a hint cannot turn a push into a local edit.
56+pub fn push_expression_hint(text: &str, cfg: &Config, crash: &Crash) -> Option<String> {
57+ expression_hint_for(text, cfg, crash, Invocation::Push)
58+}
59+
60+#[derive(Clone, Copy)]
61+enum Invocation {
62+ Expression,
63+ Push,
64+}
65+
66+fn expression_hint_for(text: &str, cfg: &Config, crash: &Crash, invocation: Invocation) -> Option<String> {
67+ let expr = parse_expr(text, Rc::new(cfg.global_names.clone())).ok()?;
68+ let names = vocabulary(cfg);
69+ if let Some(name) = crash.msg.strip_prefix("unbound name `").and_then(|s| s.strip_suffix('`')) {
70+ if crash.def.is_some() {
71+ return None;
72+ }
73+ let mut free = BTreeSet::new();
74+ expr.free_vars(&mut Vec::new(), &mut free);
75+ if !free.contains(name) {
76+ return None;
77+ }
78+ if let Some(hint) = text_argument_hint(&expr, cfg, &names, name, invocation) {
79+ return Some(hint);
80+ }
81+ let mut suggestions: Vec<_> = names
82+ .iter()
83+ .filter(|candidate| candidate.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_'))
84+ .filter_map(|candidate| {
85+ let limit = if name.chars().count() > 4 { 2 } else { 1 };
86+ let distance = edit_distance(name, candidate, limit)?;
87+ (distance > 0).then_some((distance, *candidate))
88+ })
89+ .collect();
90+ suggestions.sort_unstable();
91+ if let Some((distance, best)) = suggestions.first().copied() {
92+ let close: Vec<_> = suggestions
93+ .iter()
94+ .take_while(|(d, _)| *d == distance)
95+ .take(3)
96+ .map(|(_, n)| format!("`{}`", n))
97+ .collect();
98+ return Some(if close.len() == 1 {
99+ format!("Did you mean `{}`? See `{}`.", best, help_command(best))
100+ } else {
101+ format!("Did you mean {}? Use `j help NAME` for details.", close.join(" or "))
102+ });
103+ }
104+ return Some("Run `j commands` to browse the names available in your config.".into());
105+ }
106+
107+ let (name, args) = application(&expr)?;
108+ let signature = cfg.sigs.get(name)?;
109+ let contract = compile_contract(&cfg.shapes, signature);
110+ let parameter = contract.params.get(args.len())?;
111+ // A record supplied explicitly fails at an earlier argument. Requiring the
112+ // next argument to match confines this hint to the implicit repo argument.
113+ if !crash.msg.starts_with(&format!("contract: {} expected ", name))
114+ || !crash.msg.ends_with(&format!(" as argument {}, got record", args.len() + 1))
115+ || matches!(cfg.shapes.unfold(parameter), TypeExpr::Record(_))
116+ {
117+ return None;
118+ }
119+ let expected = type_name(parameter);
120+ Some(format!(
121+ "`{}` needs an argument of type {} before it can receive the repository. See `{}`.",
122+ name, expected, help_command(name)
123+ ))
124+}
125+
126+/// `global_names` also reserves every implemented builtin for shadowing checks.
127+/// Only names actually supplied by this config are useful typo suggestions.
128+fn vocabulary(cfg: &Config) -> BTreeSet<&str> {
129+ cfg.defs
130+ .iter()
131+ .map(|(name, _)| name.as_str())
132+ .chain(cfg.builtin_names.iter().map(String::as_str))
133+ .collect()
134+}
135+
136+fn unparen(mut expr: &Expr) -> &Expr {
137+ while let Expr::Paren(inner) = expr {
138+ expr = inner;
139+ }
140+ expr
141+}
142+
143+fn application(expr: &Expr) -> Option<(&str, Vec<&Expr>)> {
144+ let mut head = unparen(expr);
145+ let mut args = Vec::new();
146+ while let Expr::App(function, argument) = head {
147+ args.push(unparen(argument));
148+ head = unparen(function);
149+ }
150+ args.reverse();
151+ match head {
152+ Expr::Var(name) => Some((name, args)),
153+ _ => None,
154+ }
155+}
156+
157+fn text_argument_hint(
158+ expr: &Expr,
159+ cfg: &Config,
160+ names: &BTreeSet<&str>,
161+ missing: &str,
162+ invocation: Invocation,
163+) -> Option<String> {
164+ let (name, args) = application(expr)?;
165+ let contract = compile_contract(&cfg.shapes, cfg.sigs.get(name)?);
166+ let index = args.iter().position(|arg| matches!(arg, Expr::Var(n) if n == missing))?;
167+ let parameter = contract.params.get(index)?;
168+ if !matches!(cfg.shapes.unfold(parameter), TypeExpr::Con(n) if n == "Text") {
169+ return None;
170+ }
171+
172+ // For a single Text argument, shell-stripped words can be shown in a
173+ // complete, copyable example. Do not guess how to group multiple Text
174+ // parameters or how to rewrite a larger expression.
175+ let only_text_argument = index == 0
176+ && contract.params.iter().skip(1).all(|ty| matches!(ty.as_ref(), TypeExpr::Con(n) if n == "Repo"));
177+ let words: Option<Vec<_>> = args
178+ .iter()
179+ .map(|arg| match arg {
180+ Expr::Var(n) if !names.contains(n.as_str()) => Some(n.as_str()),
181+ _ => None,
182+ })
183+ .collect();
184+ let prefix = match invocation {
185+ Invocation::Expression => "",
186+ Invocation::Push => "push ",
187+ };
188+ match words.filter(|_| only_text_argument) {
189+ Some(words) => Some(format!(
190+ "If you meant text, preserve its double quotes by quoting the whole expression: j {}. See `{}`.",
191+ shell_quote(&format!("{}{} \"{}\"", prefix, name, words.join(" "))), help_command(name)
192+ )),
193+ None => Some(format!(
194+ "If you meant text, use double quotes for the text inside shell single quotes around the expression. See `{}`.",
195+ help_command(name)
196+ )),
197+ }
198+}
199+
200+fn shell_quote(text: &str) -> String {
201+ format!("'{}'", text.replace('\'', "'\\''"))
202+}
203+
204+fn help_command(name: &str) -> String {
205+ let argument = if name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
206+ name.to_string()
207+ } else {
208+ shell_quote(name)
209+ };
210+ format!("j help {}", argument)
211+}
212+
213+/// Bounded Damerau-Levenshtein distance catches adjacent transpositions as well
214+/// as inserted, removed, and replaced letters. Long identifiers are skipped so
215+/// error reporting cannot become expensive for generated input.
216+fn edit_distance(a: &str, b: &str, limit: usize) -> Option<usize> {
217+ let a: Vec<_> = a.chars().take(129).collect();
218+ let b: Vec<_> = b.chars().take(129).collect();
219+ if a.len() > 128 || b.len() > 128 || a.len().abs_diff(b.len()) > limit {
220+ return None;
221+ }
222+ let mut older = vec![0; b.len() + 1];
223+ let mut previous: Vec<_> = (0..=b.len()).collect();
224+ let mut current = vec![0; b.len() + 1];
225+ for (i, letter) in a.iter().enumerate() {
226+ current[0] = i + 1;
227+ for (k, other) in b.iter().enumerate() {
228+ current[k + 1] = (previous[k + 1] + 1)
229+ .min(current[k] + 1)
230+ .min(previous[k] + usize::from(letter != other));
231+ if i > 0 && k > 0 && *letter == b[k - 1] && a[i - 1] == *other {
232+ current[k + 1] = current[k + 1].min(older[k - 1] + 1);
233+ }
234+ }
235+ std::mem::swap(&mut older, &mut previous);
236+ std::mem::swap(&mut previous, &mut current);
237+ }
238+ (previous[b.len()] <= limit).then_some(previous[b.len()])
239+}
240+
241+#[cfg(test)]
242+mod tests {
243+ use super::*;
244+ use crate::config::{eval_config, load_config};
245+ use crate::eval::Interp;
246+ use crate::value::Value;
247+
248+ fn reference() -> Config {
249+ load_config(include_str!("../config.j")).unwrap()
250+ }
251+
252+ fn failure(cfg: &Config, text: &str) -> Crash {
253+ let mut interp = Interp::dummy();
254+ interp.shapes = cfg.shapes.clone();
255+ eval_config(&mut interp, cfg).unwrap();
256+ let expr = Rc::new(parse_expr(text, Rc::new(cfg.global_names.clone())).unwrap());
257+ let result = interp.eval(&expr, &interp.global_env()).and_then(|value| match value {
258+ Value::Fun(_) => interp.apply(value, Value::record(&[])),
259+ value => Ok(value),
260+ });
261+ result.expect_err("test expression must fail")
262+ }
263+
264+ #[test]
265+ fn suggests_real_commands_and_transposed_names() {
266+ let cfg = reference();
267+ for (expression, expected) in [("neww", "j help new"), ("desrcibe", "j help describe")] {
268+ let hint = expression_hint(expression, &cfg, &failure(&cfg, expression)).unwrap();
269+ assert!(hint.contains(expected), "{}", hint);
270+ }
271+ }
272+
273+ #[test]
274+ fn suggests_custom_definitions_but_not_undeclared_builtins() {
275+ let cfg = load_config("user = { name = \"A\", email = \"a@b\" }\ntree = \\r -> \"\"\nimmutable = \\r -> []\nlabelled = \\n r -> []\nship = \\r -> r\n").unwrap();
276+ let hint = expression_hint("shipp", &cfg, &failure(&cfg, "shipp")).unwrap();
277+ assert!(hint.contains("j help ship"));
278+ let hint = expression_hint("repla", &cfg, &failure(&cfg, "repla")).unwrap();
279+ assert!(!hint.contains("replay"));
280+ assert!(hint.contains("j commands"));
281+ }
282+
283+ #[test]
284+ fn explains_shell_quoting_only_for_text_parameters() {
285+ let cfg = reference();
286+ for expression in ["describe hello", "describe first change"] {
287+ let hint = expression_hint(expression, &cfg, &failure(&cfg, expression)).unwrap();
288+ assert!(hint.contains("j 'describe \""), "{}", hint);
289+ assert!(hint.contains("j help describe"));
290+ }
291+ let hint = expression_hint("rebase helloo", &cfg, &failure(&cfg, "rebase helloo")).unwrap();
292+ assert!(!hint.contains("quotes"));
293+ }
294+
295+ #[test]
296+ fn push_quoting_examples_preserve_the_reserved_command() {
297+ let cfg = reference();
298+ for (expression, corrected) in [
299+ ("unlabel feature", "unlabel \"feature\""),
300+ ("describe hello", "describe \"hello\""),
301+ ] {
302+ let crash = failure(&cfg, expression);
303+ let hint = push_expression_hint(expression, &cfg, &crash).unwrap();
304+ assert!(hint.contains(&format!("j 'push {}'", corrected)), "{}", hint);
305+ assert!(!hint.contains(&format!("j '{}'", corrected)), "{}", hint);
306+
307+ let ordinary = expression_hint(expression, &cfg, &crash).unwrap();
308+ assert!(ordinary.contains(&format!("j '{}'", corrected)), "{}", ordinary);
309+ assert!(!ordinary.contains("j 'push "), "{}", ordinary);
310+ }
311+ }
312+
313+ #[test]
314+ fn explains_implicit_repo_application_but_not_explicit_type_errors() {
315+ let cfg = reference();
316+ for expression in ["describe", "rebase", "split"] {
317+ let hint = expression_hint(expression, &cfg, &failure(&cfg, expression)).unwrap();
318+ assert!(hint.contains("before it can receive the repository"), "{}", hint);
319+ assert!(hint.contains(&format!("j help {}", expression)));
320+ }
321+ assert!(expression_hint("describe {}", &cfg, &failure(&cfg, "describe {}")).is_none());
322+ assert!(expression_hint("describe 42", &cfg, &failure(&cfg, "describe 42")).is_none());
323+ }
324+
325+ #[test]
326+ fn custom_signatures_drive_hints_after_partial_application() {
327+ let src = format!("{}\ncustom : Text -> Int -> Edit\ncustom = \\message count repo -> repo\n", include_str!("../config.j"));
328+ let cfg = load_config(&src).unwrap();
329+ let expression = "custom \"message\"";
330+ let hint = expression_hint(expression, &cfg, &failure(&cfg, expression)).unwrap();
331+ assert!(hint.contains("needs an argument of type Int"), "{}", hint);
332+ assert!(hint.contains("j help custom"));
333+ let expression = "custom \"message\" {}";
334+ assert!(expression_hint(expression, &cfg, &failure(&cfg, expression)).is_none());
335+ }
336+
337+ #[test]
338+ fn quote_examples_preserve_apostrophes_in_valid_identifiers() {
339+ let cfg = reference();
340+ let expression = "describe don't";
341+ let hint = expression_hint(expression, &cfg, &failure(&cfg, expression)).unwrap();
342+ assert!(hint.contains("j 'describe \"don'\\''t\"'"), "{}", hint);
343+ }
344+
345+ #[test]
346+ fn does_not_blame_the_cli_for_unbound_names_inside_definitions() {
347+ let cfg = reference();
348+ assert!(expression_hint("new", &cfg, &Crash::new("unbound name `neww`")).is_none());
349+ assert!(expression_hint("\\x -> x", &cfg, &Crash::new("unbound name `x`")).is_none());
350+ }
351+
352+ #[test]
353+ fn source_errors_name_the_file_and_preserve_line_numbers() {
354+ let output = format_source_error("/tmp/config.j", "first\nbroken =\n", 2, "unexpected newline");
355+ assert_eq!(output, "/tmp/config.j: line 2: unexpected newline\n 2 | broken =");
356+ assert_eq!(format_source_error("expression", "bad\n", 2, "unexpected end"), "expression: line 2: unexpected end\n 2 | ");
357+ assert_eq!(format_source_error("expression", "bad", 8, "unexpected end"), "expression: line 8: unexpected end");
358+ }
359+
360+ #[test]
361+ fn huge_unicode_source_lines_have_bounded_excerpts() {
362+ use unicode_width::UnicodeWidthStr;
363+
364+ for source in ["(".repeat(200_000), "界é🙂\u{301}".repeat(50_000), "\u{301}".repeat(200_000)] {
365+ let output = format_source_error("expression", &source, 1, "nested too deeply");
366+ let excerpt = output.split_once(" 1 | ").unwrap().1;
367+ assert!(excerpt.ends_with('…'));
368+ assert!(excerpt.width() <= 160, "excerpt exceeds display width budget");
369+ assert!(excerpt.chars().count() <= 160, "zero-width characters must also be bounded");
370+ assert!(output.len() < 700, "diagnostic must not repeat the entire huge line");
371+ }
372+ }
373+
374+ #[test]
375+ fn source_excerpts_escape_terminal_controls() {
376+ let output = format_source_error("expression", "\tfirst\u{1b}[31m\rsecond\u{7}", 1, "invalid input");
377+ let excerpt = output.split_once(" 1 | ").unwrap().1;
378+ assert_eq!(excerpt, "\\tfirst\\u{1b}[31m\\rsecond\\u{7}");
379+ assert!(!excerpt.chars().any(char::is_control));
380+ }
381+}
src/domain.rs+6
@@ -29,6 +29,12 @@ pub struct ImmutabilityInfo {
2929}
3030
3131pub trait Backend {
32+ /// Refuse an edit that cannot be authored with the backend's current
33+ /// configuration. In-memory repositories have no authoring requirement.
34+ fn validate_authoring(&self) -> Result<(), Crash> {
35+ Ok(())
36+ }
37+
3238 /// All visible change ids (full), for resolving `@prefix` literals.
3339 fn visible_ids(&self) -> Vec<String>;
3440 /// All visible ids beginning with `prefix`.
src/eval.rs+78−45
@@ -47,6 +47,19 @@ pub struct Interp {
4747 sorted_ids: RefCell<Option<Rc<Vec<String>>>>,
4848}
4949
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+
5063/// length of the common leading run of two ids (ids are ASCII, so bytes)
5164fn common_prefix_len(a: &str, b: &str) -> usize {
5265 a.bytes()
@@ -104,11 +117,14 @@ pub(crate) enum Cont {
104117 env: Env,
105118 cont: Rc<Cont>,
106119 },
107− /// short-circuit ops with the unevaluated rhs
108− And(Rc<Expr>, Env, Rc<Cont>),
109− Or2(Rc<Expr>, Env, Rc<Cont>),
110− /// check the rhs of && / || evaluated to a Bool
111− ExpectBool(&'static str, Rc<Cont>),
120+ /// `&&` or `||` after applying its first operand normally, so the
121+ /// declared contract has checked it; the rhs is still unevaluated
122+ ShortCircuit {
123+ op: &'static str,
124+ rhs: Rc<Expr>,
125+ env: Env,
126+ cont: Rc<Cont>,
127+ },
112128 /// `or` after both evaluated as functions: build the lifted function
113129 OrBoth(Value, Rc<Cont>),
114130 /// a shared continuation (used when both `or` outcomes continue the same way)
@@ -258,9 +274,7 @@ fn parent_rc(k: &Cont) -> Option<&Rc<Cont>> {
258274 Cont::Fun(_, k) => Some(k),
259275 Cont::If(_, _, _, k) => Some(k),
260276 Cont::LetRest { cont, .. } => Some(cont),
261− Cont::And(_, _, k) => Some(k),
262− Cont::Or2(_, _, k) => Some(k),
263− Cont::ExpectBool(_, k) => Some(k),
277+ Cont::ShortCircuit { cont, .. } => Some(cont),
264278 Cont::OrBoth(_, k) => Some(k),
265279 Cont::Shared(k) => Some(k),
266280 Cont::ApplyTo(_, k) => Some(k),
@@ -609,10 +623,14 @@ impl Interp {
609623 }
610624
611625 fn run(&mut self, st: State) -> EResult {
612− match self.trampoline(st) {
626+ let result = match self.trampoline(st) {
613627 Ok(v) => Ok(v),
614628 Err(c) => Err(self.decorate(c)),
615− }
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
616634 }
617635
618636 fn trampoline(&mut self, st: State) -> EResult {
@@ -776,29 +794,26 @@ impl Interp {
776794 Cont::Arg(x.clone(), env, Rc::new(k)),
777795 )),
778796 Expr::BinOp(op, a, b) => {
779− if *op == "&&" {
780− return Run::Step(State::Eval(
781− a.clone(),
782− env.clone(),
783− Cont::And(b.clone(), env, Rc::new(k)),
784− ));
785− }
786− if *op == "||" {
787− return Run::Step(State::Eval(
788− a.clone(),
789− env.clone(),
790− Cont::Or2(b.clone(), env, Rc::new(k)),
791− ));
792− }
793797 let fv = match env.lookup(op) {
794798 Some(v) => v,
795799 None => return Run::Crash(Crash::new(format!("unbound name `{}`", op))),
796800 };
797− // eval a, apply the operator to it, then apply the result to b
801+ let next = if *op == "&&" || *op == "||" {
802+ Cont::ShortCircuit {
803+ op,
804+ rhs: b.clone(),
805+ env: env.clone(),
806+ cont: Rc::new(k),
807+ }
808+ } else {
809+ Cont::Arg(b.clone(), env.clone(), Rc::new(k))
810+ };
811+ // Every operator must be declared, and its first operand
812+ // is checked by ordinary application, including && and ||.
798813 Run::Step(State::Eval(
799814 a.clone(),
800− env.clone(),
801− Cont::Fun(fv, Rc::new(Cont::Arg(b.clone(), env, Rc::new(k)))),
815+ env,
816+ Cont::Fun(fv, Rc::new(next)),
802817 ))
803818 }
804819 Expr::Or(a, b) => {
@@ -913,24 +928,42 @@ impl Interp {
913928 ))
914929 }
915930 }
916− Cont::And(b, env, k) => match v {
917− Value::Bool(false) => Run::Step(State::Ret(Value::Bool(false), (*k).clone())),
918− Value::Bool(true) => Run::Step(State::Eval(b, env, Cont::ExpectBool("&&", k))),
919− _ => Run::Crash(Crash::new(format!("&&: expected Bool, got a {}", v.kind_name()))),
920− },
921− Cont::Or2(b, env, k) => match v {
922− Value::Bool(true) => Run::Step(State::Ret(Value::Bool(true), (*k).clone())),
923− Value::Bool(false) => Run::Step(State::Eval(b, env, Cont::ExpectBool("||", k))),
924− _ => Run::Crash(Crash::new(format!("||: expected Bool, got a {}", v.kind_name()))),
925− },
926− Cont::ExpectBool(op, k) => match v {
927− Value::Bool(_) => Run::Step(State::Ret(v, (*k).clone())),
928− _ => Run::Crash(Crash::new(format!(
929− "{}: expected Bool, got a {}",
930− op,
931− v.kind_name()
932− ))),
933− },
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+ }
934967 Cont::OrBoth(a, k) => {
935968 if matches!(v, Value::Fun(_)) {
936969 Run::Step(State::Ret(Value::Fun(Rc::new(FunVal::OrFun(a, v))), (*k).clone()))
src/help.rs+189−41
@@ -13,19 +13,23 @@ Usage:
1313 j EXPRESSION… Evaluate an expression
1414 printf '%s\n' 'tree' | j Read an expression from stdin
1515 j help [NAME] Explain a command or config name
16− j commands [QUERY] Search names, signatures, and documentation
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
1719
1820Start a repository:
21+ j setup 'Ada Lovelace' ada@example.org Create config with your identity
1922 j init Create a repository in the current directory
2023 j clone URL [DIR] Clone a repository
2124
2225Inspect (default config):
2326 j tree Show the history and current focus
2427 j status Show the focused commit and changed paths
25− j review Show changes with difftastic (difft on PATH)
28+ j review Show changes (unified diff if difft is absent)
2629 j log List commits
2730
2831Edit (default config):
32+ j save Record current file edits
2933 j 'describe "add a feature"' Set the focused commit's message
3034 j new Start an empty child and move to it
3135 j prev Move to the parent commit
@@ -34,7 +38,7 @@ Edit (default config):
3438 j 'rebase trunk' Move the focused subtree onto the main line
3539
3640Preview and recover:
37− j 'tree . squash' Preview a squash without saving the edit
41+ j 'preview squash' Check and preview a squash without saving
3842 j undo Undo the last operation
3943 j redo Reapply an undone operation
4044 j ops Show the operation log
@@ -45,6 +49,9 @@ Sync:
4549 j 'push (label "feature" here)' Publish the focus as a remote bookmark
4650 j push relabel Update existing remote bookmarks
4751
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+
4855Expressions compose right to left: `describe "wip" . new` runs new first.
4956A function result receives the current repository. A Repo result saves the
5057edit; other results print. Quote the whole expression with shell single
@@ -55,6 +62,34 @@ revsets, filesets, types, and custom names. Search with `j commands rebase`
5562or `j commands Edit`; use `j help squash` or `j help commands` for details.
5663"#;
5764
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+
5893struct Reserved {
5994 name: &'static str,
6095 usage: &'static str,
@@ -67,13 +102,13 @@ const RESERVED: &[Reserved] = &[
67102 name: "clone",
68103 usage: "j clone URL [DIR]",
69104 summary: "Clone a repository.",
70− detail: "Clone URL into DIR, or into a directory named after the repository.\n\nExample:\n j clone https://example.com/team/project.git project\n",
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",
71106 },
72107 Reserved {
73108 name: "commands",
74109 usage: "j commands [QUERY]",
75− summary: "List and search the commands and config vocabulary.",
76− detail: "List CLI commands and the names declared or defined in config.j, including\ncustom definitions, builtins, operators, and types. QUERY matches names,\nsignatures, and source comments, ignoring case.\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 rebase\n j commands Edit\n j commands 'remote bookmark'\n j help squash\n",
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",
77112 },
78113 Reserved {
79114 name: "fetch",
@@ -91,7 +126,7 @@ const RESERVED: &[Reserved] = &[
91126 name: "init",
92127 usage: "j init",
93128 summary: "Create a repository in the current directory.",
94− detail: "Create a colocated .jj/.git repository and a working-copy commit. An\nexisting .git repository is adopted. Set your identity in config.j's `user`\ndefinition before creating commits.\n\nExample:\n mkdir project\n cd project\n j init\n",
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",
95130 },
96131 Reserved {
97132 name: "ops",
@@ -103,13 +138,13 @@ const RESERVED: &[Reserved] = &[
103138 name: "push",
104139 usage: "j push EXPRESSION",
105140 summary: "Publish or delete bookmarks on origin.",
106− 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. The default config provides label, unlabel,\nrename, and relabel helpers.\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",
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",
107142 },
108143 Reserved {
109144 name: "redo",
110145 usage: "j redo",
111146 summary: "Reapply an undone operation.",
112− detail: "Reapply the operation removed by undo. Refuses if there is nothing to redo\nor if restoring the working copy would overwrite unsnapshotted changes.\n\nExample:\n j redo\n\nRelated: j help undo, j help ops\n",
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",
113148 },
114149 Reserved {
115150 name: "remote",
@@ -117,11 +152,17 @@ const RESERVED: &[Reserved] = &[
117152 summary: "Set the origin remote URL.",
118153 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",
119154 },
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+ },
120161 Reserved {
121162 name: "undo",
122163 usage: "j undo",
123164 summary: "Undo the last operation.",
124− 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. Undo does not reverse changes already sent to a remote.\n\nExample:\n j undo\n\nRelated: j help redo, j help ops\n",
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",
125166 },
126167];
127168
@@ -164,43 +205,154 @@ impl Entry {
164205 }
165206}
166207
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+
167301pub fn command_list(src: &str, query: &str) -> Result<String, String> {
168302 let entries = entries(src)?;
169303 let query = query.trim().to_lowercase();
170− let matches = |text: &str| text.to_lowercase().contains(&query);
171− let mut out = String::new();
172− let mut count = 0;
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();
173311 for cmd in RESERVED {
174− if matches(&format!("{} {} {}", cmd.usage, cmd.summary, cmd.detail)) {
175− if count == 0 {
176− out.push_str("CLI commands:\n");
177− }
178− out.push_str(&format!(" {:<24} {}\n", cmd.usage, cmd.summary));
179− count += 1;
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)));
180316 }
181317 }
182− let mut config_count = 0;
183− for entry in entries {
318+ for entry in &entries {
184319 let declaration = entry.declaration();
185− if !matches(&format!("{} {}", declaration, entry.docs.join(" "))) {
320+ let intent = intents.iter().position(|name| *name == entry.name);
321+ if !matches(&format!("{} {}", declaration, entry.docs.join(" "))) && intent.is_none() {
186322 continue;
187323 }
188− if config_count == 0 {
189− if count > 0 {
190− out.push('\n');
191− }
192− out.push_str("Config vocabulary (definitions, builtins, operators, and types):\n");
193− }
194− out.push_str(&format!(" {}\n", declaration));
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);
195329 if let Some(doc) = entry.docs.first() {
196− out.push_str(&format!(" {}\n", truncate(doc, 110)));
330+ description.push_str(&format!(" {}\n", truncate(doc, 110)));
197331 }
198− config_count += 1;
332+ results.push((rank, entry.name.clone(), description));
199333 }
200− if count + config_count == 0 {
201− out.push_str("No matching commands or config names. Try `j commands` to list all names.\n");
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");
202350 } else {
203− out.push_str("\nUse `j help NAME` for details; `j commands QUERY` to narrow the list.\n");
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");
204356 }
205357 Ok(out)
206358}
@@ -219,14 +371,8 @@ pub fn command_help(src: &str, name: &str) -> Result<Option<String>, String> {
219371 Kind::Builtin => "Builtin declared in config",
220372 Kind::Type => "Type declared in config",
221373 };
222− let mut out = format!(
223− "{}\n\n{}\nSource: line {}\n",
224− entry.declaration(),
225− kind,
226− entry.line
227− );
374+ let mut out = String::new();
228375 if !entry.docs.is_empty() {
229− out.push('\n');
230376 for doc in entry.docs.iter().take(24) {
231377 out.push_str(doc);
232378 out.push('\n');
@@ -234,7 +380,9 @@ pub fn command_help(src: &str, name: &str) -> Result<Option<String>, String> {
234380 if entry.docs.len() > 24 {
235381 out.push_str("… See the source for the remaining comments.\n");
236382 }
383+ out.push('\n');
237384 }
385+ out.push_str(&format!("{}\n\n{}\nSource: line {}\n", entry.declaration(), kind, entry.line));
238386 out
239387 }))
240388}
src/jj.rs+288−72
@@ -45,6 +45,15 @@ fn block_on<F: Future>(f: F) -> F::Output {
4545
4646type OpenError = (u8, String);
4747
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+
4857// ----------------------------------------------------------------------
4958// locating and opening the repository (§7.1)
5059// ----------------------------------------------------------------------
@@ -69,6 +78,7 @@ fn toml_string(s: &str) -> String {
6978 '\n' => out.push_str("\\n"),
7079 '\r' => out.push_str("\\r"),
7180 '\t' => out.push_str("\\t"),
81+ c if c.is_ascii_control() => out.push_str(&format!("\\u{:04X}", c as u32)),
7282 c => out.push(c),
7383 }
7484 }
@@ -1445,6 +1455,17 @@ impl JjBackend {
14451455 ),
14461456 ));
14471457 }
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+ }
14481469 let workspace_name = workspace.workspace_name().to_owned();
14491470 let repo = block_on(workspace.repo_loader().load_at_head())
14501471 .map_err(|e| (2, format!("cannot load the repository at head: {}", e)))?;
@@ -1479,13 +1500,13 @@ impl JjBackend {
14791500 {
14801501 Ok(f) => f,
14811502 Err(e) => {
1482− crate::report!("j: cannot create {}: {}", lock_path.display(), e);
1503+ crate::terminal::report_error(format_args!("j: cannot create {}: {}", lock_path.display(), e));
14831504 std::process::exit(2);
14841505 }
14851506 };
14861507 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
14871508 if rc != 0 {
1488− crate::report!("j: another j is running in this repository");
1509+ crate::terminal::report_error(format_args!("j: another j is running in this repository"));
14891510 std::process::exit(2);
14901511 }
14911512 *self.inner.lock_guard.lock().unwrap() = Some(FileLock(file));
@@ -1748,10 +1769,10 @@ impl JjBackend {
17481769 current: &Value,
17491770 new: &Value,
17501771 text: &str,
1751− ) -> Result<(), Crash> {
1772+ ) -> Result<bool, Crash> {
17521773 // §1.2 step 8: unchanged, up to the order of snapshot entries (§7.3)
17531774 if crate::repo::same_repo(loaded, new)? {
1754− return Ok(());
1775+ return Ok(false);
17551776 }
17561777 *interp.old_repo.borrow_mut() = Some(loaded.clone());
17571778 crate::repo::validate_repo(interp, new)?;
@@ -1976,7 +1997,8 @@ impl JjBackend {
19761997 state.or_else(|_| block_on(self.wc_commit(&base)).map(|c| c.tree()).map_err(|e| Crash::new(e.1)))?
19771998 }
19781999 };
1979− block_on(self.checkout(&focus_commit, Some(&on_disk), Some(unpublished), Some(", and `j undo` goes back")))
2000+ block_on(self.checkout(&focus_commit, Some(&on_disk), Some(unpublished), Some(", and `j undo` goes back")))?;
2001+ Ok(true)
19802002 }
19812003
19822004 /// Check `commit` out: write its files to the working directory and
@@ -2176,7 +2198,8 @@ impl JjBackend {
21762198 }
21772199
21782200 fn user_signature(&self, cfg: &Config) -> Result<Signature, Crash> {
2179− let (name, email) = crate::config::eval_user_only(&mut Interp::dummy(), cfg)?;
2201+ let (name, email) = validated_identity(cfg)
2202+ .map_err(|(_, message)| Crash::new(message))?;
21802203 Ok(Signature {
21812204 name,
21822205 email,
@@ -2310,15 +2333,38 @@ impl JjBackend {
23102333 let _ = block_on(self.publish_tx(tx, "fetch"))?;
23112334 Ok(())
23122335 }
2313− pub fn cmd_push(&self, cfg: &mut Config, expr_text: &str) -> Result<(), OpenError> {
2336+ pub fn cmd_push(&self, cfg: &mut Config, expr_text: &str) -> Result<PushOutcome, OpenError> {
23142337 self.enter_workspace_root()?;
23152338 let origin = RemoteName::new("origin");
23162339 let base = self.head_repo()?;
23172340 let (value, vis, immutable) = self.eval_push_expr(cfg, expr_text, &base)?;
23182341 let records = parse_push_records(&value, &vis, &base)?;
2342+ if records.is_empty() {
2343+ return Ok(PushOutcome::default());
2344+ }
23192345 let deletes_wait = check_push_records(&records, &base, &vis, &immutable)?;
2320− let _ = self.push_to_origin(&base, origin, &records, deletes_wait, expr_text)?;
2321− Ok(())
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)
23222368 }
23232369
23242370 /// evaluate `EXPR` against the recorded repository, without snapshotting
@@ -2332,7 +2378,7 @@ impl JjBackend {
23322378 ) -> Result<(Value, Arc<VisibleRepo>, BTreeSet<String>), OpenError> {
23332379 let outer = Rc::new(cfg.global_names.clone());
23342380 let expr = crate::parse::parse_expr(expr_text, outer)
2335− .map_err(|p| (3, format!("line {}: {}", p.line, p.msg)))?;
2381+ .map_err(|p| (3, crate::diagnostics::format_source_error("expression", expr_text, p.line, &p.msg)))?;
23362382 let (mut interp, _loaded, current) = self.build_interp(cfg, expr_text, false)?;
23372383 let expr = crate::config::resolve_ids(&expr, &interp).map_err(|failures| {
23382384 let (prefix, candidates) = &failures[0];
@@ -2347,15 +2393,26 @@ impl JjBackend {
23472393 // evaluated it for this value to place the focus, so it is cached
23482394 let immutable = crate::repo::compute_immutable(&mut interp, &current)
23492395 .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+ };
23502407 let env = interp.global_env();
23512408 let expr_rc = Rc::new(expr);
23522409 let mut v = interp
23532410 .eval(&expr_rc, &env)
2354− .map_err(|c| (1, format!("crash: {}", c.msg)))?;
2411+ .map_err(&expression_error)?;
23552412 if matches!(v, Value::Fun(_)) {
23562413 v = interp
23572414 .apply(v, current)
2358− .map_err(|c| (1, format!("crash: {}", c.msg)))?;
2415+ .map_err(expression_error)?;
23592416 }
23602417 let _ = base;
23612418 let vis = self
@@ -2380,6 +2437,11 @@ impl JjBackend {
23802437 deletes_wait: bool,
23812438 description: &str,
23822439 ) -> 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+ }
23832445 let mut targets = jj_lib::git::GitPushRefTargets::default();
23842446 let mut deletes = jj_lib::git::GitPushRefTargets::default();
23852447 for (name, after) in records {
@@ -2398,16 +2460,6 @@ impl JjBackend {
23982460 .push((jj_lib::ref_name::RefNameBuf::from(name.clone()), Diff::new(before, after.clone())));
23992461 }
24002462 let mut tx = base.start_transaction();
2401− if records.is_empty() {
2402− // nothing to set or delete; push_refs would still run `git push
2403− // origin` without refspecs, which pushes whatever git's
2404− // push.default picks
2405− if !remote_has_url(git_backend(base.store())?, origin) {
2406− return Err((1, "no remote origin".to_string()));
2407− }
2408− let (_, repo) = block_on(self.publish_tx(tx, &truncate_chars(description, 200)))?;
2409− return Ok(repo);
2410− }
24112463 let subprocess_options = self.git_subprocess_options()?;
24122464 let mut callback = QuietGitCallback;
24132465 let mut push = |tx: &mut jj_lib::transaction::Transaction, targets| {
@@ -2485,14 +2537,31 @@ impl JjBackend {
24852537 Ok(repo)
24862538 }
24872539
2488− pub fn cmd_undo(&self, redo: bool) -> Result<(), OpenError> {
2540+ pub fn cmd_undo(&self, redo: bool) -> Result<String, OpenError> {
24892541 let base = self.head_repo()?;
24902542 let on_disk = self.check_wc_clean(&base)?;
24912543 let loader = self.repo_loader();
24922544 let mut cur = base.operation().clone();
24932545 let target_op_id: OperationId;
24942546 loop {
2495− match (op_marker(cur.metadata()), redo) {
2547+ let marker = op_marker(cur.metadata());
2548+ if let Some(m) = marker.as_ref().filter(|m| m.restores_view) {
2549+ // jj 0.45 names the operation whose view was restored.
2550+ // Repeating the same direction follows that target; reversing
2551+ // direction restores the marker's parent. The target may
2552+ // itself be an undo or redo, not just a normal operation.
2553+ if (m.kind == "redo") == redo {
2554+ cur = block_on(loader.load_operation(&m.id))
2555+ .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
2556+ continue;
2557+ }
2558+ let Some(parent) = cur.parent_ids().first().cloned() else {
2559+ return Err((1, if redo { "nothing to redo" } else { "nothing to undo" }.to_string()));
2560+ };
2561+ target_op_id = parent;
2562+ break;
2563+ }
2564+ match (marker, redo) {
24962565 (None, false) => {
24972566 let Some(parent) = cur.parent_ids().first().cloned() else {
24982567 return Err((1, "nothing to undo".to_string()));
@@ -2518,7 +2587,16 @@ impl JjBackend {
25182587 .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
25192588 }
25202589 (Some(m), true) if m.kind == "undo" => {
2521− target_op_id = m.id;
2590+ // An old j marker names the discarded normal operation,
2591+ // but its parent has the same view and retains any earlier
2592+ // undos. Modern redo markers must keep that navigation
2593+ // point so another redo can continue across the upgrade.
2594+ target_op_id = if m.authored_by_j {
2595+ cur.parent_ids().first().cloned()
2596+ .ok_or_else(|| (1, "nothing to redo".to_string()))?
2597+ } else {
2598+ m.id
2599+ };
25222600 break;
25232601 }
25242602 (Some(m), false) => {
@@ -2570,8 +2648,22 @@ impl JjBackend {
25702648 if !redo && op_created_repo(cur.metadata()) {
25712649 return Err((1, "nothing to undo".to_string()));
25722650 }
2573− let target_op = block_on(loader.load_operation(&target_op_id))
2651+ let mut target_op = block_on(loader.load_operation(&target_op_id))
25742652 .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);
25752667 let target_view = block_on(
25762668 loader
25772669 .op_store()
@@ -2595,11 +2687,13 @@ impl JjBackend {
25952687 },
25962688 ));
25972689 }
2598− let marker = if redo {
2599− format!("redo: {}", cur.id().hex())
2600− } else {
2601− format!("undo: {}", cur.id().hex())
2602− };
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+ );
26032697 let mut tx = base.start_transaction();
26042698 tx.repo_mut().set_view(target_view.clone());
26052699 let unpublished = block_on(tx.write(&marker))
@@ -2612,7 +2706,14 @@ impl JjBackend {
26122706 let back = if redo { ", and `j undo` goes back" } else { ", and `j redo` goes back" };
26132707 block_on(self.checkout(&wc_commit, Some(&on_disk), Some(unpublished), Some(back)))
26142708 .map_err(|c| (1, c.msg))?;
2615− Ok(())
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+ ))
26162717 }
26172718
26182719 /// Refuse, as `undo` and `redo` do before anything else (§7.7), when the
@@ -2626,7 +2727,21 @@ impl JjBackend {
26262727 /// what a checkout cut short wrote. Reserved commands never snapshot,
26272728 /// so nothing is recorded either way.
26282729 fn check_wc_clean(&self, base: &Arc<ReadonlyRepo>) -> Result<MergedTree, OpenError> {
2629− let dirty = (1, "working copy has changes not in @; run `j id` to record them or discard them".to_string());
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)
2735+ }
2736+
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> {
26302745 let wc_tree = block_on(self.wc_commit(base))?.tree();
26312746 let mut ws_guard = self.inner.workspace.lock().unwrap();
26322747 let root = ws_guard.workspace_root().to_owned();
@@ -2646,13 +2761,15 @@ impl JjBackend {
26462761 // drop the lock without saving, or the state would claim the
26472762 // directory was snapshotted and hide the changes from the next
26482763 // run (§7.7)
2649− return Err(dirty);
2764+ return Ok((scanned, false));
26502765 }
26512766 // saving the state only refreshes what it knows of each file, or
26522767 // takes in what the commit already has
2653− block_on(save_scan(locked_ws, &scanned, &wc_tree, base.operation().id()))
2654− .map_err(|e| (2, format!("cannot finish: {}", e)))?;
2655− Ok(scanned)
2768+ if refresh_clean_state {
2769+ block_on(save_scan(locked_ws, &scanned, &wc_tree, base.operation().id()))
2770+ .map_err(|e| (2, format!("cannot finish: {}", e)))?;
2771+ }
2772+ Ok((scanned, true))
26562773 }
26572774
26582775 /// The operation log for `ops` (§7.7), one line per operation, newest
@@ -2670,8 +2787,8 @@ impl JjBackend {
26702787 let mut tag = "";
26712788 match op_kind(op.metadata()) {
26722789 OpKind::Normal => {}
2673− OpKind::Undo { .. } => tag = "undo",
2674− OpKind::Redo { .. } => tag = "redo",
2790+ OpKind::Undo => tag = "undo",
2791+ OpKind::Redo => tag = "redo",
26752792 }
26762793 if desc.is_empty() {
26772794 desc = "(no description)".to_string();
@@ -3143,8 +3260,7 @@ async fn checkout_tree(tree: MergedTree, backend: &JjBackend) -> Result<MergedTr
31433260/// user settings for init/clone: a missing or malformed `user` is a
31443261/// configuration error (exit 3) (§7.9)
31453262fn user_settings_strict(cfg: &Config) -> Result<UserSettings, OpenError> {
3146− let (name, email) = crate::config::eval_user_only(&mut Interp::dummy(), cfg)
3147− .map_err(|c| (3, format!("config.j: {}", c.msg)))?;
3263+ let (name, email) = validated_identity(cfg)?;
31483264 let mut config = StackedConfig::with_defaults();
31493265 let text = format!(
31503266 "[user]\nname = {}\nemail = {}\n",
@@ -3158,6 +3274,39 @@ fn user_settings_strict(cfg: &Config) -> Result<UserSettings, OpenError> {
31583274 .map_err(|e| (3, format!("config.j: invalid user settings: {}", e)))
31593275}
31603276
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+
31613310// ----------------------------------------------------------------------
31623311// reserved-command helpers
31633312// ----------------------------------------------------------------------
@@ -3264,29 +3413,29 @@ fn git_default_branch(
32643413
32653414enum OpKind {
32663415 Normal,
3267− /// the operation this undo discarded the view of
3268− Undo { #[allow(dead_code)] undid: OperationId },
3269− /// the undo this redo reversed
3270− Redo { #[allow(dead_code)] redid: OperationId },
3416+ Undo,
3417+ Redo,
32713418}
32723419
32733420/// a marker parsed from an operation description (§7.7 kinds)
32743421struct OpMarker {
32753422 kind: &'static str,
32763423 id: OperationId,
3277− /// whether the description is j's own exact marker (`undo: <id>` /
3278− /// `redo: <id>`); only those are reliably loadable for the walk
3424+ /// jj 0.45 names the operation whose view was restored, rather than
3425+ /// the operation undone or the undo reversed.
3426+ restores_view: bool,
3427+ /// Whether a legacy marker is j's exact `undo: <id>` / `redo: <id>`
3428+ /// format, whose discarded-operation links the walk can follow.
32793429 authored_by_j: bool,
32803430}
32813431
3282−/// §7.7 kinds: `j` marks undo/redo operations as `undo: <opid>` /
3283−/// `redo: <opid>` in the description (0.45's Transaction cannot write
3284−/// metadata tags); jj's own `undo operation <id>` / `redo operation <id>`
3285−/// markers are treated the same way.
3432+/// §7.7 kinds: new records use jj 0.45's restored-view descriptions. Older
3433+/// j `undo: <id>` / `redo: <id>` and jj `undo operation <id>` /
3434+/// `redo operation <id>` descriptions remain readable too.
32863435fn op_kind(meta: &jj_lib::op_store::OperationMetadata) -> OpKind {
32873436 match op_marker(meta) {
3288− Some(OpMarker { kind: "undo", id, .. }) => OpKind::Undo { undid: id },
3289− Some(OpMarker { kind: "redo", id, .. }) => OpKind::Redo { redid: id },
3437+ Some(OpMarker { kind: "undo", .. }) => OpKind::Undo,
3438+ Some(OpMarker { kind: "redo", .. }) => OpKind::Redo,
32903439 _ => OpKind::Normal,
32913440 }
32923441}
@@ -3294,14 +3443,19 @@ fn op_kind(meta: &jj_lib::op_store::OperationMetadata) -> OpKind {
32943443fn op_marker(meta: &jj_lib::op_store::OperationMetadata) -> Option<OpMarker> {
32953444 let desc = meta.description.trim();
32963445 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+ }
32973451 if let Some(rest) = desc.strip_prefix(&format!("{}:", kind)) {
32983452 if let Some(id) = parse_op_id(rest.trim()) {
3299− return Some(OpMarker { kind, id, authored_by_j: true });
3453+ return Some(OpMarker { kind, id, restores_view: false, authored_by_j: true });
33003454 }
33013455 }
33023456 if let Some(rest) = desc.strip_prefix(&format!("{} operation", kind)) {
33033457 if let Some(id) = parse_op_id(rest.trim()) {
3304− return Some(OpMarker { kind, id, authored_by_j: false });
3458+ return Some(OpMarker { kind, id, restores_view: false, authored_by_j: false });
33053459 }
33063460 }
33073461 }
@@ -3341,6 +3495,42 @@ fn op_parent_id(
33413495 Ok(op.parent_ids().first().cloned())
33423496}
33433497
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(
3502+ 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)
3531+ }
3532+}
3533+
33443534/// parse the push-record list (§7.6): records of shape {id, name} set a
33453535/// bookmark, records of shape {delete} remove one; duplicates merge, the same
33463536/// name with different effects crashes (exit 1)
@@ -3407,6 +3597,11 @@ fn parse_push_records(
34073597 let rec = vis.commits.get(&change_id).ok_or_else(|| {
34083598 (1, format!("push: `@{}` is not a visible commit", change_id))
34093599 })?;
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+ }
34103605 out.push((name, Some(rec.commit.id().clone())));
34113606 }
34123607 None => {
@@ -3583,12 +3778,24 @@ fn check_push_records(
35833778 .map_err(|e| (2, format!("cannot list the commits to send: {}", e)))?;
35843779 for commit in &sent {
35853780 let change_id = commit.change_id().reverse_hex();
3586− for (_path, value) in commit.tree().entries() {
3781+ let mut unresolved_paths = Vec::new();
3782+ for (path, value) in commit.tree().entries() {
35873783 let value = value.map_err(|e| (2, format!("cannot read a tree: {}", e)))?;
35883784 if !value.is_resolved() {
3589− return Err((1, format!("push: commit `@{}` has unresolved files", change_id)));
3785+ unresolved_paths.push(path.as_internal_file_string().to_string());
35903786 }
35913787 }
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));
3794+ }
3795+ return Err((1, format!(
3796+ "push: commit `@{change_id}` has unresolved files: {paths}; run `j conflicts` to inspect conflicts, then `j by @{change_id}` to edit this commit. Resolve the listed files, run `j id` to record the resolution, and check `j conflicts` before pushing again"
3797+ )));
3798+ }
35923799 if commit.description().trim().is_empty() && commit.id() != store.root_commit_id() {
35933800 return Err((1, format!("push: commit `@{}` has an empty description", change_id)));
35943801 }
@@ -3618,23 +3825,11 @@ fn ancestors_of_change_ids(vis: &VisibleRepo, seeds: &[String]) -> BTreeSet<Stri
36183825
36193826/// git-check-ref-format --branch rules
36203827fn is_valid_git_branch_name(name: &str) -> bool {
3621− if name.is_empty()
3622− || name.starts_with('-')
3623− || name.starts_with('.')
3624− || name.ends_with('.')
3625− || name.ends_with('/')
3626− || name.ends_with(".lock")
3627− || name.contains("..")
3628− || name.contains("//")
3629− || name.contains("@{")
3630− || name == "@"
3631− {
3828+ if name.starts_with('-') {
36323829 return false;
36333830 }
3634− name.chars().all(|c| {
3635− !(c.is_ascii_control()
3636− || matches!(c, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\'))
3637− })
3831+ let full_name = format!("refs/heads/{name}");
3832+ gix::validate::reference::branch_name(full_name.as_str().into()).is_ok()
36383833}
36393834
36403835// ----------------------------------------------------------------------
@@ -3642,6 +3837,12 @@ fn is_valid_git_branch_name(name: &str) -> bool {
36423837// ----------------------------------------------------------------------
36433838
36443839impl 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+
36453846 fn visible_ids(&self) -> Vec<String> {
36463847 match self.inner.visible.lock().unwrap().as_ref() {
36473848 Some(v) => v.commits.keys().cloned().collect(),
@@ -4726,6 +4927,21 @@ mod tests {
47264927 assert!(cache.blobs.get(&id).is_none());
47274928 }
47284929
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+
47294945 #[test]
47304946 fn a_description_names_its_terms_whatever_their_labels_and_the_sides_that_cancel() {
47314947 // what a checkout writes for a symlink/file conflict of five terms,
src/lib.rs+3−2
@@ -5,14 +5,14 @@
55#[macro_export]
66macro_rules! report {
77 ($($arg:tt)*) => {{
8− use ::std::io::Write as _;
9− let _ = ::std::writeln!(::std::io::stderr(), $($arg)*);
8+ $crate::terminal::report(format_args!($($arg)*));
109 }};
1110}
1211
1312pub mod ast;
1413pub mod builtins;
1514pub mod config;
15+pub mod diagnostics;
1616pub mod domain;
1717pub mod eval;
1818pub mod lex;
@@ -22,6 +22,7 @@ pub mod render;
2222pub mod repo;
2323pub mod shape;
2424pub mod show;
25+pub mod terminal;
2526pub mod value;
2627
2728pub mod jj;
src/main.rs+280−84
@@ -1,8 +1,10 @@
11//! j — a functional-programming-centered CLI for jj repositories (§1).
22
33mod help;
4+mod setup;
45
56use j::config::{self, Config};
7+use j::diagnostics;
68use j::jj::JjBackend;
79use j::parse::parse_expr;
810use j::value::{Crash, Value};
@@ -13,20 +15,21 @@ use std::rc::Rc;
1315
1416const USAGE: &str = "usage: j EXPRESSION… (or echo EXPRESSION | j)
1517
18+Configure identity: j setup
1619Start a repository: j init or j clone URL
1720Inspect your work: j status or j tree
1821Discover commands: j help or j commands
1922Command details: j help NAME";
2023
2124fn err(status: u8, msg: impl std::fmt::Display) -> ExitCode {
22− j::report!("j: {}", msg);
25+ j::terminal::report_error(format_args!("j: {}", msg));
2326 ExitCode::from(status)
2427}
2528
2629/// Report a failure that ends the process, keeping the status as a number so
2730/// callers do not have to recover it from an `ExitCode`.
2831fn fail(status: u8, msg: impl std::fmt::Display) -> u8 {
29− j::report!("j: {}", msg);
32+ j::terminal::report_error(format_args!("j: {}", msg));
3033 status
3134}
3235
@@ -35,22 +38,22 @@ fn fail(status: u8, msg: impl std::fmt::Display) -> u8 {
3538/// rest is dropped and the run still succeeds. Any other write failure is an
3639/// error line. `print!` would panic on either.
3740fn print_output(s: &str) -> ExitCode {
38− use std::io::Write;
3941 let mut out = std::io::stdout().lock();
40− match out.write_all(s.as_bytes()).and_then(|()| out.flush()) {
42+ match j::terminal::write_output(&mut out, s) {
4143 Ok(()) => ExitCode::SUCCESS,
4244 Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
4345 Err(e) => err(1, format!("cannot write output: {}", e)),
4446 }
4547}
4648
47−fn crash_err(c: &Crash, expr_text: Option<&str>) -> ExitCode {
48− j::report!("j: crash: {}", c.msg);
49− match (&c.def, expr_text) {
50− (Some(d), Some(e)) => j::report!(" in {}, from {}", d, e),
51− (None, Some(e)) => j::report!(" from {}", e),
52− (Some(d), None) => j::report!(" in {}", d),
53− (None, None) => {}
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);
5457 }
5558 ExitCode::from(1)
5659}
@@ -73,43 +76,57 @@ fn load_config_file() -> Result<(Config, String), u8> {
7376 let path = config_path();
7477 let src = match std::fs::read_to_string(&path) {
7578 Ok(s) => s,
76− Err(_) => {
79+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
7780 // No user config yet: materialise the default there, editable by
7881 // the current user, so they have a starting point to customise.
79− if let Err(e) = write_default_config(&path) {
80− return Err(fail(3, format!("cannot create config at {}: {}", path, e)));
82+ match write_default_config(&path) {
83+ Ok(()) => j::report!("j: created an editable default config at {}", path),
84+ // Another invocation may have created it after our read.
85+ // Read that file below, without overwriting it.
86+ Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
87+ Err(e) => return Err(fail(3, format!("cannot create config at {}: {}", path, e))),
8188 }
82− j::report!("j: created an editable default config at {}", path);
8389 match std::fs::read_to_string(&path) {
8490 Ok(s) => s,
8591 Err(e) => return Err(fail(3, format!("cannot read config at {}: {}", path, e))),
8692 }
8793 }
94+ Err(e) => return Err(fail(3, format!("cannot read config at {}: {}", path, e))),
8895 };
8996 match config::load_config(&src) {
9097 Ok(c) => Ok((c, src)),
91− Err(e) => Err(fail(3, e.message())),
98+ Err(e) => {
99+ let message = match e {
100+ config::ConfigError::Parse(p) => {
101+ diagnostics::format_source_error(&path, &src, p.line, &p.msg)
102+ }
103+ config::ConfigError::Validation(message) => format!("{}: {}", path, message),
104+ };
105+ Err(fail(3, format!("{}\n Edit this file to repair your config; see `j help config`.", message)))
106+ }
92107 }
93108}
94109
95110/// Write the embedded default config to `path`, creating parent directories
96111/// and making the file readable and writable by the current user.
97112fn write_default_config(path: &str) -> std::io::Result<()> {
113+ use std::io::Write;
98114 if let Some(parent) = std::path::Path::new(path).parent() {
99115 std::fs::create_dir_all(parent)?;
100116 }
101− std::fs::write(path, DEFAULT_CONFIG)?;
117+ let mut options = std::fs::OpenOptions::new();
118+ options.write(true).create_new(true);
102119 // user-read+write (0o600 on unix): the file is meant to be edited by them
103120 #[cfg(unix)]
104121 {
105− use std::os::unix::fs::PermissionsExt;
106− std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
122+ use std::os::unix::fs::OpenOptionsExt;
123+ options.mode(0o600);
107124 }
108− Ok(())
125+ options.open(path)?.write_all(DEFAULT_CONFIG.as_bytes())
109126}
110127
111128const RESERVED: &[&str] = &[
112− "init", "clone", "remote", "fetch", "push", "undo", "redo", "ops", "help", "commands",
129+ "init", "clone", "remote", "fetch", "push", "undo", "redo", "ops", "help", "commands", "setup",
113130];
114131
115132/// Discovery reads the user's vocabulary without creating or evaluating it.
@@ -126,11 +143,13 @@ fn read_help_config() -> Result<(String, String), u8> {
126143 }
127144}
128145
129−fn run_help(text: &str) -> ExitCode {
130− let words: Vec<&str> = text.split_whitespace().collect();
131− match words.as_slice() {
146+fn run_help(words: &[&str]) -> ExitCode {
147+ match words {
132148 [_] => print_output(help::OVERVIEW),
133149 [_, name] => {
150+ if *name == "config" {
151+ return print_output(&format!("{}\nConfig: {}\n", help::CONFIG_HELP, config_path()));
152+ }
134153 if let Some(output) = help::reserved_help(name) {
135154 return print_output(&output);
136155 }
@@ -167,6 +186,32 @@ fn command_usage(usage: &str, command: &str) -> ExitCode {
167186 )
168187}
169188
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+}
214+
170215fn main() -> ExitCode {
171216 // evaluation builds deep continuation chains (trampolined, but the final
172217 // continuation still tears down by recursive Drop), so run the real work
@@ -211,14 +256,16 @@ fn run() -> ExitCode {
211256 // stdin is only read to report the "both" error — and reading it blindly
212257 // hangs whenever the process inherited a pipe nobody is writing to (a
213258 // shell loop, a CI step), so wait only briefly for input to appear.
214− // Bytes that are not UTF-8 are input too, and an error only once they
215− // are the expression.
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.
216262 let mut stdin_content = Vec::new();
217263 let stdin_has_data = {
218264 if std::io::stdin().is_terminal() {
219265 false
220266 } else if args.is_empty() || stdin_is_ready(50) {
221− match std::io::stdin().read_to_end(&mut stdin_content) {
267+ let limit = if args.is_empty() { u64::MAX } else { 1 };
268+ match std::io::stdin().take(limit).read_to_end(&mut stdin_content) {
222269 Ok(n) => n > 0,
223270 Err(_) => false,
224271 }
@@ -229,6 +276,16 @@ fn run() -> ExitCode {
229276 if stdin_has_data && !args.is_empty() {
230277 return err(2, "expression given both as arguments and on stdin");
231278 }
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+ }
232289 let text = if stdin_has_data {
233290 match String::from_utf8(stdin_content) {
234291 Ok(text) => text,
@@ -248,30 +305,41 @@ fn run() -> ExitCode {
248305 return ExitCode::from(2);
249306 }
250307 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+ };
251315 if matches!(first, "--help" | "-h") {
252− return run_help(trimmed);
316+ return run_help(&words);
253317 }
254318 if RESERVED.contains(&first) {
255− return run_reserved(first, trimmed);
319+ return run_reserved(first, trimmed, &words);
256320 }
257321
258322 run_expression(trimmed, true)
259323}
260324
261−fn run_reserved(cmd: &str, text: &str) -> ExitCode {
262− let words: Vec<&str> = text.split_whitespace().collect();
325+fn run_reserved(cmd: &str, text: &str, words: &[&str]) -> ExitCode {
263326 match cmd {
264− "help" => run_help(text),
327+ "help" => run_help(words),
265328 "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+ },
266333 "init" => {
267334 if words.len() != 1 {
268335 return command_usage("init", "init");
269336 }
270337 let (cfg, _) = or_exit(load_config_file());
271− match j::jj::cmd_init(&cfg) {
272− Ok(()) => ExitCode::SUCCESS,
273− Err(e) => err(e.0, e.1),
274− }
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+ )
275343 }
276344 "clone" => {
277345 if words.len() != 2 && words.len() != 3 {
@@ -292,31 +360,31 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
292360 }
293361 };
294362 let (cfg, _) = or_exit(load_config_file());
295− match j::jj::cmd_clone(&cfg, words[1], &dir) {
296− Ok(()) => ExitCode::SUCCESS,
297− Err(e) => err(e.0, e.1),
298− }
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+ )
299368 }
300369 "remote" => {
301370 if words.len() != 2 {
302371 return command_usage("remote URL", "remote");
303372 }
304373 let backend = or_exit(open_repo());
305− match backend.cmd_remote(words[1]) {
306− Ok(()) => ExitCode::SUCCESS,
307− Err(e) => err(e.0, e.1),
308− }
374+ command_result(
375+ j::terminal::with_progress("Updating remote", || backend.cmd_remote(words[1])),
376+ "Remote updated",
377+ )
309378 }
310379 "fetch" => {
311380 if words.len() != 1 {
312381 return command_usage("fetch", "fetch");
313382 }
314− let (cfg, _) = or_exit(load_config_file());
315− let backend = or_exit(open_repo_locked(&cfg));
316− match backend.cmd_fetch() {
317− Ok(()) => ExitCode::SUCCESS,
318− Err(e) => err(e.0, e.1),
319− }
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+ )
320388 }
321389 "push" => {
322390 let rest = text.trim_start_matches("push").trim();
@@ -326,9 +394,22 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
326394 let (mut cfg, _) = or_exit(load_config_file());
327395 let backend = or_exit(open_repo_locked(&cfg));
328396 // evaluate EXPR against the recorded repository (no snapshot)
329− match backend.cmd_push(&mut cfg, rest) {
330− Ok(()) => ExitCode::SUCCESS,
331− Err(e) => err(e.0, e.1),
397+ match j::terminal::with_progress("Pushing changes", || backend.cmd_push(&mut cfg, rest)) {
398+ Err((3, message)) if message.starts_with("config.j:") => {
399+ err(3, format!("{}: {}\n See `j help config` for configuration help.", config_path(), message.trim_start_matches("config.j: ")))
400+ }
401+ Ok(outcome) => {
402+ if outcome.published + outcome.deleted + outcome.unchanged == 0 {
403+ j::report!("j: No bookmarks selected; nothing published.\n First publication: j 'push (label \"feature\" here)'\n Inspect available commits with `j tree` and check your selector.");
404+ } else {
405+ j::terminal::success(&format!(
406+ "Push complete: {} published, {} deleted, {} unchanged bookmark(s)",
407+ outcome.published, outcome.deleted, outcome.unchanged,
408+ ));
409+ }
410+ ExitCode::SUCCESS
411+ }
412+ Err((code, message)) => err(code, message),
332413 }
333414 }
334415 "undo" => {
@@ -336,27 +417,25 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
336417 return command_usage("undo", "undo");
337418 }
338419 let backend = or_exit(open_repo_noconfig());
339− match backend.cmd_undo(false) {
340− Ok(()) => ExitCode::SUCCESS,
341− Err(e) => err(e.0, e.1),
342− }
420+ operation_result(
421+ j::terminal::with_progress("Undoing operation", || backend.cmd_undo(false)),
422+ )
343423 }
344424 "redo" => {
345425 if words.len() != 1 {
346426 return command_usage("redo", "redo");
347427 }
348428 let backend = or_exit(open_repo_noconfig());
349− match backend.cmd_undo(true) {
350− Ok(()) => ExitCode::SUCCESS,
351− Err(e) => err(e.0, e.1),
352− }
429+ operation_result(
430+ j::terminal::with_progress("Redoing operation", || backend.cmd_undo(true)),
431+ )
353432 }
354433 "ops" => {
355434 if words.len() != 1 {
356435 return command_usage("ops", "ops");
357436 }
358437 let backend = or_exit(open_repo_noconfig());
359− match backend.cmd_ops() {
438+ match j::terminal::with_progress("Loading operation log", || backend.cmd_ops()) {
360439 Ok(log) => print_output(&log),
361440 Err(e) => err(e.0, e.1),
362441 }
@@ -385,6 +464,13 @@ fn default_clone_dir(url: &str) -> Option<String> {
385464 }
386465}
387466
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+
388474fn or_exit<T>(r: Result<T, u8>) -> T {
389475 match r {
390476 Ok(v) => v,
@@ -429,15 +515,15 @@ fn run_expression(text: &str, snapshot: bool) -> ExitCode {
429515 let outer = Rc::new(cfg.global_names.clone());
430516 let mut expr = match parse_expr(text, outer) {
431517 Ok(e) => e,
432− Err(p) => return err(3, format!("line {}: {}", p.line, p.msg)),
518+ Err(p) => return err(3, diagnostics::format_source_error("expression", text, p.line, &p.msg)),
433519 };
434520
435521 // §1.2.4: build the current repository value (with snapshot)
436522 let (mut interp, loaded_repo, current_repo) =
437− match backend.build_interp(&mut cfg, text, snapshot) {
523+ match j::terminal::with_progress("Reading repository", || backend.build_interp(&mut cfg, text, snapshot)) {
438524 Ok(t) => t,
439525 Err((2, m)) => return err(2, m),
440− Err((3, m)) => return err(3, 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: "))),
441527 Err((_, m)) => return err(1, m),
442528 };
443529
@@ -448,9 +534,9 @@ fn run_expression(text: &str, snapshot: bool) -> ExitCode {
448534 Err(failures) => {
449535 let (prefix, candidates) = &failures[0];
450536 if candidates.is_empty() {
451− j::report!("j: crash: `@{}` matches no commit", prefix);
537+ j::terminal::report_error(format_args!("j: crash: `@{}` matches no commit", prefix));
452538 } else {
453− j::report!("j: crash: `@{}` is ambiguous", prefix);
539+ j::terminal::report_error(format_args!("j: crash: `@{}` is ambiguous", prefix));
454540 for c in candidates {
455541 j::report!(" {}", c);
456542 }
@@ -460,42 +546,139 @@ fn run_expression(text: &str, snapshot: bool) -> ExitCode {
460546 };
461547
462548 // §1.2.6: evaluate config definitions, then the expression
463− if let Err(c) = config::eval_config(&mut interp, &cfg) {
464− return err(3, format!("config.j: {}", c.msg));
549+ if let Err(c) = j::terminal::with_progress("Evaluating configuration", || {
550+ config::eval_config(&mut interp, &cfg)
551+ }) {
552+ return err(3, format!("{}: {}\n See `j help config` for configuration help.", config_path(), c.msg));
465553 }
466554 *interp.old_repo.borrow_mut() = Some(loaded_repo.clone());
467555 *interp.given_repo.borrow_mut() = Some(current_repo.clone());
468556 let env = interp.global_env();
469557 let expr_rc = Rc::new(expr);
470− let mut v = match interp.eval(&expr_rc, &env) {
558+ let v = match j::terminal::with_progress("Evaluating expression", || {
559+ let v = interp.eval(&expr_rc, &env)?;
560+ // §1.2.7: apply a function result to the repo, once.
561+ if matches!(v, Value::Fun(_)) {
562+ interp.apply(v, current_repo.clone())
563+ } else {
564+ Ok(v)
565+ }
566+ }) {
471567 Ok(v) => v,
472− Err(c) => return crash_err(&c, Some(text)),
568+ Err(c) => return crash_err(&c, text, &cfg),
473569 };
474570
475− // §1.2.7: apply a function result to the repo, once
476− if matches!(v, Value::Fun(_)) {
477− v = match interp.apply(v, current_repo.clone()) {
478− Ok(v) => v,
479− Err(c) => return crash_err(&c, Some(text)),
480− };
481− }
482−
483571 // §1.2.8: a Repo is persisted (unless unchanged)
484572 if is_repo_value(&v) {
485− match backend.persist(&mut interp, &cfg, &loaded_repo, &current_repo, &v, text) {
486− Ok(()) => ExitCode::SUCCESS,
487− Err(c) => crash_err(&c, Some(text)),
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),
488590 }
489591 } else {
490592 // §1.2.9: display
491593 let color = j::render::color_enabled("auto");
492− match j::render::display(&mut interp, &v, color) {
594+ match j::terminal::with_progress("Rendering output", || j::render::display(&mut interp, &v, color)) {
493595 Ok(s) => print_output(&s),
494− Err(c) => crash_err(&c, Some(text)),
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));
622+ }
623+ }
624+ }
625+ Ok(paths)
626+}
627+
628+fn report_conflicts(interp: &j::eval::Interp, before: &Value, after: &Value) {
629+ let summary = || -> Result<(), Crash> {
630+ let old = conflict_paths(interp, before)?;
631+ let new = conflict_paths(interp, after)?;
632+ let Value::Id(focus) = after.field("root")?.field("id")? else { return Ok(()) };
633+ let relevant: Vec<_> = new.iter()
634+ .filter(|(id, path)| id == focus.as_str() || !old.contains(&(id.clone(), path.clone())))
635+ .collect();
636+ if relevant.is_empty() {
637+ return Ok(());
638+ }
639+ j::report!("j: warning: {} unresolved file(s) remain:", relevant.len());
640+ for (id, path) in relevant.iter().take(10) {
641+ j::report!(" @{} {}", interp.unique_prefix(id), path.escape_debug());
495642 }
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);
496653 }
497654}
498655
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+
499682fn is_repo_value(v: &Value) -> bool {
500683 // §4.4: shape Repo and root's shape Commit
501684 let fields = match v {
@@ -523,3 +706,16 @@ fn is_repo_value(v: &Value) -> bool {
523706 .collect();
524707 root_fields == want_root
525708}
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/render.rs+268−74
@@ -33,33 +33,46 @@ impl Palette {
3333 self.wrap("2", s)
3434 }
3535 pub fn bold_bg(&self, s: &str) -> String {
36− self.wrap("1;48;5;236", s)
36+ self.wrap("1;97;48;5;17", s)
3737 }
3838 pub fn dim_italic(&self, s: &str) -> String {
3939 self.wrap("2;3", s)
4040 }
4141 pub fn cyan(&self, s: &str) -> String {
42− self.wrap("36", s)
42+ self.wrap("96", s)
4343 }
4444 pub fn red(&self, s: &str) -> String {
45− self.wrap("31", s)
45+ self.wrap("91", s)
4646 }
4747 pub fn blue(&self, s: &str) -> String {
48− self.wrap("34", s)
48+ self.wrap("94", s)
4949 }
5050 pub fn green(&self, s: &str) -> String {
51− self.wrap("32", s)
51+ self.wrap("92", s)
5252 }
5353 pub fn yellow(&self, s: &str) -> String {
54− self.wrap("33", s)
54+ self.wrap("93", s)
55+ }
56+ fn id(&self, s: &str) -> String {
57+ self.wrap("1;95", s)
58+ }
59+ fn label(&self, s: &str) -> String {
60+ self.wrap("1;92", s)
61+ }
62+ fn field(&self, s: &str) -> String {
63+ self.wrap("2;96", s)
64+ }
65+ fn rail(&self, lane: usize, s: &str) -> String {
66+ let codes = ["95", "96", "93", "92"];
67+ self.wrap(codes[lane.saturating_sub(1) % codes.len()], s)
5568 }
5669 pub fn grey(&self, bright: u8, s: &str) -> String {
5770 // grey scale: brighter when more recent
5871 self.wrap(&format!("38;5;{}", 245 + bright.min(10)), s)
5972 }
6073 pub fn accent(&self, level: u8, s: &str) -> String {
61− // size bar: one accent colour, brighter with size
62− let codes = ["38;5;240", "38;5;36", "38;5;32", "38;5;39", "38;5;87"];
74+ // Size remains encoded by the bar's height, with a vivid cool-to-warm ramp.
75+ let codes = ["96", "94", "95", "93", "1;93"];
6376 self.wrap(codes[level.min(4) as usize], s)
6477 }
6578 pub fn author(&self, name: &str, s: &str) -> String {
@@ -69,7 +82,7 @@ impl Palette {
6982 h = h.wrapping_mul(33).wrapping_add(b as u32);
7083 }
7184 let hue = (h % 6) as u8;
72− let code = ["31", "32", "33", "34", "35", "36"][hue as usize];
85+ let code = ["91", "92", "93", "94", "95", "96"][hue as usize];
7386 self.wrap(code, s)
7487 }
7588 /// colour with a raw ANSI code (used for meaning-coloured glyphs)
@@ -101,7 +114,8 @@ pub fn color_enabled(mode: &str) -> bool {
101114 match mode {
102115 "always" => true,
103116 "never" => false,
104− _ => crate::show::stdout_is_tty(),
117+ _ => std::env::var_os("TERM").map(|t| t != "dumb").unwrap_or(true)
118+ && crate::show::stdout_is_tty(),
105119 }
106120}
107121
@@ -157,11 +171,16 @@ fn field_set(v: &Value) -> Option<BTreeSet<String>> {
157171
158172fn shape_of_record(interp: &Interp, v: &Value) -> Option<String> {
159173 let fs = field_set(v)?;
160− for (name, _) in &interp.shapes.decls {
174+ // Only these named shapes have special display forms (§5.1). Searching
175+ // every declaration let a custom alias win in HashMap iteration order,
176+ // randomly turning Commit, Entry, etc. values into generic records.
177+ // Resolve each configured shape so aliases and custom field sets still
178+ // determine which records match.
179+ for name in ["Commit", "Entry", "Subtree", "Repo", "Frame", "Change"] {
161180 if let Some(s) = interp.shapes.shape_of(name) {
162181 if let ShapeKind::Record(want) = &s.kind {
163182 if want == &fs {
164− return Some(name.clone());
183+ return Some(name.to_string());
165184 }
166185 }
167186 }
@@ -178,6 +197,16 @@ pub fn is_commit(interp: &Interp, v: &Value) -> bool {
178197// ----------------------------------------------------------------------
179198
180199pub 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> {
181210 let pal = Palette { on: color };
182211 let mut out = String::new();
183212 let mut loaded = None;
@@ -185,7 +214,7 @@ pub fn display(interp: &mut Interp, v: &Value, color: bool) -> Result<String, Cr
185214 while let Some(block) = todo.pop() {
186215 match block {
187216 Block::Of(v, indent) => {
188− display_block(interp, &v, &pal, indent, &mut out, &mut loaded, &mut todo)?
217+ display_block(interp, &v, &pal, indent, &mut out, &mut loaded, &mut todo, term_w)?
189218 }
190219 Block::Items { xs, next, indent } => {
191220 if let Some(x) = xs.get(next).cloned() {
@@ -236,7 +265,7 @@ enum Block {
236265fn display_id(interp: &Interp, id: &str, pal: &Palette) -> String {
237266 let p = interp.unique_prefix(id);
238267 let n = p.len();
239− format!("{}{}", pal.bold(p.as_str()), pal.dim(&id[n..]))
268+ format!("{}{}", pal.id(p.as_str()), pal.dim(&id[n..]))
240269}
241270
242271fn display_line(
@@ -248,8 +277,8 @@ fn display_line(
248277 let v = &v.forced()?;
249278 match v {
250279 Value::Thunk(_) => unreachable!("forced never returns a thunk"),
251− Value::Int(n) => Ok(n.to_string()),
252− Value::Bool(b) => Ok(b.to_string()),
280+ Value::Int(n) => Ok(pal.yellow(&n.to_string())),
281+ Value::Bool(b) => Ok(if *b { pal.green("true") } else { pal.red("false") }),
253282 Value::Text(t) => Ok(first_line(t)),
254283 Value::Id(id) => Ok(display_id(interp, id, pal)),
255284 Value::Blob(b) => {
@@ -263,7 +292,7 @@ fn display_line(
263292 // a lambda renders as its source (§5.2), which may run over several
264293 // lines and hold comments; the line form keeps only the first
265294 Value::Fun(_) => Ok(first_line(&crate::show::show(interp, v)?)),
266− Value::Shape(s) => Ok(s.name.clone()),
295+ Value::Shape(s) => Ok(pal.cyan(&s.name)),
267296 Value::Record(_) => {
268297 if let Some(shape) = shape_of_record(interp, v) {
269298 match shape.as_str() {
@@ -335,14 +364,13 @@ fn list_line(_interp: &Interp, xs: &[Value], pal: &Palette) -> Result<String, Cr
335364 let parts: Vec<String> = xs
336365 .iter()
337366 .map(|x| match x {
338− Value::Int(n) => n.to_string(),
339− Value::Bool(b) => b.to_string(),
367+ Value::Int(n) => pal.yellow(&n.to_string()),
368+ Value::Bool(b) => if *b { pal.green("true") } else { pal.red("false") },
340369 _ => unreachable!(),
341370 })
342371 .collect();
343372 return Ok(parts.join(", "));
344373 }
345− let _ = pal;
346374 Ok(format!("{} items", xs.len()))
347375}
348376
@@ -355,9 +383,9 @@ fn entry_line(interp: &Interp, v: &Value, pal: &Palette) -> Result<String, Crash
355383 };
356384 let _ = interp;
357385 if unresolved {
358− Ok(format!("{} {} {}", pal.red("✖"), path, size))
386+ Ok(format!("{} {} {}", pal.red("✖"), pal.cyan(&path), pal.red(&size)))
359387 } else {
360− Ok(format!(" {} {}", path, size))
388+ Ok(format!(" {} {}", pal.cyan(&path), pal.yellow(&size)))
361389 }
362390}
363391
@@ -790,11 +818,18 @@ fn commit_line(
790818 .map(|l| l.as_text().map(|s| s.to_string()))
791819 .collect::<Result<_, _>>()?;
792820 let glyph = node_glyph(c, &id, standing)?;
793− let id_s = display_id(interp, &id, pal);
821+ let id_s = if glyph == "⊗" {
822+ pal.red(&display_id(interp, &id, &Palette { on: false }))
823+ } else if standing.immutable {
824+ pal.blue(&display_id(interp, &id, &Palette { on: false }))
825+ } else {
826+ display_id(interp, &id, pal)
827+ };
828+ let glyph = pal.code(node_ansi(&glyph), &glyph);
794829 let labels_s = if labels.is_empty() {
795830 String::new()
796831 } else {
797− pal.green(&labels.join(" "))
832+ pal.label(&labels.join(" "))
798833 };
799834 Ok(if labels_s.is_empty() {
800835 format!("{} {} {}", glyph, id_s, msg_first)
@@ -836,6 +871,7 @@ fn has_conflict(c: &Value) -> Result<bool, Crash> {
836871}
837872
838873/// `v` in block form, the blocks it holds left on `todo` (see `Block`)
874+#[allow(clippy::too_many_arguments)]
839875fn display_block(
840876 interp: &mut Interp,
841877 v: &Value,
@@ -844,6 +880,7 @@ fn display_block(
844880 out: &mut String,
845881 loaded: &mut Option<Loaded>,
846882 todo: &mut Vec<Block>,
883+ term_w: Option<usize>,
847884) -> Result<(), Crash> {
848885 let pad = " ".repeat(indent);
849886 let v = &v.forced()?;
@@ -874,7 +911,7 @@ fn display_block(
874911 }
875912 return Ok(());
876913 }
877− Value::List(xs) => return display_list_block(interp, xs, pal, indent, out, loaded, todo),
914+ Value::List(xs) => return display_list_block(interp, xs, pal, indent, out, loaded, todo, term_w),
878915 Value::Record(_) => {}
879916 }
880917 if let Some(shape) = shape_of_record(interp, v) {
@@ -896,7 +933,7 @@ fn display_block(
896933 ("children", v.field("children")?),
897934 ("context", Value::list(vec![])),
898935 ]);
899− let text = tree_render(interp, &opts, &repo, pal, false, tty_width())?;
936+ let text = tree_render(interp, &opts, &repo, pal, false, term_w)?;
900937 out.push_str(&indent_multiline(&text, indent));
901938 return Ok(());
902939 }
@@ -952,11 +989,37 @@ fn display_fields(
952989 todo: &mut Vec<Block>,
953990) -> Result<(), Crash> {
954991 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(_))));
955998 let from = after.map_or(Bound::Unbounded, Bound::Excluded);
956999 for (k, x) in m.range::<str, _>((from, Bound::Unbounded)) {
9571000 out.push_str(&pad);
958− out.push_str(&pal.dim(&pad_right(k, name_w)));
1001+ let label = if status && m.contains_key("unrecorded") && k == "changed" {
1002+ "in commit"
1003+ } else {
1004+ k.as_str()
1005+ };
1006+ out.push_str(&pal.field(&pad_right(label, name_w)));
9591007 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+ }
9601023 // lists print in block form under their key (§5.1)
9611024 if matches!(x, Value::List(xs) if !xs.is_empty()) {
9621025 out.push('\n');
@@ -1233,6 +1296,7 @@ fn top_of(interp: &Interp, _x: &Value) -> Result<Value, Crash> {
12331296}
12341297
12351298/// `list` in block form, the blocks it holds left on `todo` (see `Block`)
1299+#[allow(clippy::too_many_arguments)]
12361300fn display_list_block(
12371301 interp: &mut Interp,
12381302 list: &ListVal,
@@ -1241,6 +1305,7 @@ fn display_list_block(
12411305 out: &mut String,
12421306 loaded: &mut Option<Loaded>,
12431307 todo: &mut Vec<Block>,
1308+ term_w: Option<usize>,
12441309) -> Result<(), Crash> {
12451310 let xs: &[Value] = list;
12461311 let pad = " ".repeat(indent);
@@ -1274,7 +1339,7 @@ fn display_list_block(
12741339 // the glyph of its line form (§5.1)
12751340 let glyph = node_glyph(&c, &id, &loaded.line_standing(interp, &c)?)?;
12761341 let conflict = glyph == "⊗";
1277− let glyph = if conflict { pal.red(&glyph) } else { glyph };
1342+ let glyph = pal.code(node_ansi(&glyph), &glyph);
12781343 let msg = c.field("message")?.as_text()?.to_string();
12791344 let msg_first = msg.lines().next().unwrap_or("");
12801345 let labels: Vec<String> = c
@@ -1296,7 +1361,7 @@ fn display_list_block(
12961361 glyph,
12971362 id_s,
12981363 msg_first,
1299− pal.green(&labels.join(" "))
1364+ pal.label(&labels.join(" "))
13001365 ));
13011366 }
13021367 }
@@ -1355,20 +1420,22 @@ fn display_list_block(
13551420 _ => (String::new(), false),
13561421 };
13571422 out.push_str(&pad);
1358− let mark = if unresolved { "✖" } else { " " };
1423+ let mark = if unresolved { pal.red("✖") } else { " ".into() };
13591424 let size_s = if unresolved {
13601425 pal.red(&size)
13611426 } else {
1362− size
1427+ pal.yellow(&size)
13631428 };
13641429 // pad by display width, not by character count: `{:n$}`
13651430 // pads to n *chars*, so a wide (East Asian) path pushes
13661431 // the size column out of line
1367− out.push_str(&format!("{} {} {}\n", mark, pad_right(&path, width_max), size_s));
1432+ out.push_str(&format!(
1433+ "{} {} {}\n", mark, pal.cyan(&pad_right(&path, width_max)), size_s
1434+ ));
13681435 }
13691436 return Ok(());
13701437 }
1371− return display_table(interp, xs, pal, indent, out, loaded);
1438+ return display_table(interp, xs, pal, indent, out, loaded, term_w);
13721439 }
13731440 }
13741441 // list of other scalars
@@ -1393,17 +1460,36 @@ fn display_table(
13931460 indent: usize,
13941461 out: &mut String,
13951462 loaded: &mut Option<Loaded>,
1463+ term_w: Option<usize>,
13961464) -> Result<(), Crash> {
13971465 let pad = " ".repeat(indent);
13981466 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()];
13991474 // cells
14001475 let mut rows: Vec<Vec<String>> = Vec::new();
14011476 for x in xs {
14021477 let mut row = Vec::new();
1403− for f in &fields {
1478+ for (column, f) in fields.iter().enumerate() {
14041479 let cell = match x.field(f)? {
1405− Value::Bool(true) => "✓".to_string(),
1480+ Value::Bool(true) => pal.green("✓"),
14061481 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+ }
14071493 other => display_line(interp, &other, pal, loaded)?,
14081494 };
14091495 row.push(cell);
@@ -1414,7 +1500,7 @@ fn display_table(
14141500 let keep: Vec<usize> = (0..fields.len())
14151501 .filter(|&i| rows.iter().any(|r| !r[i].is_empty()))
14161502 .collect();
1417− let widths: Vec<usize> = keep
1503+ let mut widths: Vec<usize> = keep
14181504 .iter()
14191505 .map(|&i| {
14201506 rows.iter()
@@ -1424,6 +1510,40 @@ fn display_table(
14241510 .max(width(&fields[i]))
14251511 })
14261512 .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+ }
14271547 // header
14281548 out.push_str(&pad);
14291549 let header: Vec<String> = keep
@@ -1431,14 +1551,14 @@ fn display_table(
14311551 .zip(&widths)
14321552 .map(|(&i, w)| pad_right(&fields[i], *w))
14331553 .collect();
1434− out.push_str(&pal.dim(&header.join(" ")));
1554+ out.push_str(&pal.field(&header.join(" ")));
14351555 out.push('\n');
14361556 for r in &rows {
14371557 out.push_str(&pad);
14381558 let cells: Vec<String> = keep
14391559 .iter()
14401560 .zip(&widths)
1441− .map(|(&i, w)| pad_right(&r[i], *w))
1561+ .map(|(&i, w)| pad_right(&cut_to_width(&r[i], *w), *w))
14421562 .collect();
14431563 out.push_str(&cells.join(" "));
14441564 out.push('\n');
@@ -2325,7 +2445,7 @@ fn build_row_text(
23252445) -> RowText {
23262446 let c = n.commit();
23272447 let is_focus = c.map(|x| x.is_focus).unwrap_or(false);
2328− let gutter = if is_focus && with_focus { "▶ " } else { " " }.to_string();
2448+ let gutter = if is_focus && with_focus { pal.code("1;96", "▶ ") } else { " ".into() };
23292449 // id (blank on a run row, whose count `rail_row` draws: the part that
23302450 // overflows the rails area runs on into the id column)
23312451 let id = match n {
@@ -2339,8 +2459,10 @@ fn build_row_text(
23392459 pal.red(&format!("@{}", p))
23402460 } else if info.immutable {
23412461 pal.blue(&format!("@{}", p))
2462+ } else if info.is_focus {
2463+ pal.code("1;96", &format!("@{}", p))
23422464 } else {
2343− format!("@{}", p)
2465+ pal.id(&format!("@{}", p))
23442466 };
23452467 let plain = k + 1; // +1 for the @
23462468 let padded = if plain < id_w {
@@ -2430,6 +2552,7 @@ fn draw_rows(
24302552 term_w: Option<usize>,
24312553) -> Result<String, Crash> {
24322554 let rail_chars = 2 * lanes_n;
2555+ let overflow = placements.iter().any(|p| p.flattened);
24332556 // each row's rails, drawn once; a run row's go on past the rails area
24342557 // when its count does not fit
24352558 let mut rail_state = vec![None; lanes_n];
@@ -2462,7 +2585,7 @@ fn draw_rows(
24622585 for (i, n) in rows.iter().enumerate() {
24632586 texts.push(build_row_text(n, index.focus_adjacent[i], opts, pal, with_focus, prefix_len, id_w));
24642587 }
2465− // Columns 4–8 start at the same offset on every row (§Step 4). The size
2588+ // Columns 5–9 start at the same offset on every row (§Step 4). The size
24662589 // bar's column is there when any row draws a bar — ` ▅`, two columns —
24672590 // and blank on the rows that draw none, so a bar never shifts a message.
24682591 let bar_col = texts.iter().any(|t| !t.bar.is_empty());
@@ -2480,6 +2603,17 @@ fn draw_rows(
24802603 _ => None,
24812604 };
24822605 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+ }
24832617 line.push(' ');
24842618 line.push_str(&t.id);
24852619 if bar_col {
@@ -2561,12 +2695,14 @@ fn draw_rows(
25612695 line.push_str(&t.msg);
25622696 if label_col && !t.labels.is_empty() {
25632697 pad_to(&mut line, lab_off);
2564− line.push_str(&pal.green(&t.labels));
2698+ line.push_str(&pal.label(&t.labels));
25652699 }
25662700 if !metas[idx].is_empty() {
25672701 pad_to(&mut line, meta_off);
25682702 line.push_str(&metas[idx]);
25692703 }
2704+ // Remove layout padding, while preserving spaces in detail paths below.
2705+ while line.ends_with(' ') { line.pop(); }
25702706 // the whole focus row is highlighted with a background band across the
25712707 // full terminal width (§Colour; colour only — the ▶ gutter still marks
25722708 // the focus when colour is off)
@@ -2578,7 +2714,7 @@ fn draw_rows(
25782714 }
25792715 _ => line.clone(),
25802716 };
2581− lines.push(pal.bg_line("48;5;236", &padded));
2717+ lines.push(pal.bg_line("97;48;5;17", &padded));
25822718 } else {
25832719 lines.push(line);
25842720 }
@@ -2592,6 +2728,7 @@ fn draw_rows(
25922728 let mut dline = String::new();
25932729 dline.push_str(" ");
25942730 dline.push_str(&color_rails(&dchars, &dlane0, pal, None));
2731+ if overflow { dline.push_str(" "); }
25952732 dline.push(' ');
25962733 // the marks start two columns into the id column (§Step 4)
25972734 dline.push_str(" ");
@@ -2610,19 +2747,18 @@ fn draw_rows(
26102747 format!("{} {}", colored, p)
26112748 })
26122749 .collect();
2613− dline.push_str(&marks.join(" "));
2614− lines.push(dline);
2615− }
2616− }
2617− // no trailing whitespace on any row
2618− for l in lines.iter_mut() {
2619− while l.ends_with(' ') {
2620− l.pop();
2750+ let detail_lines = match term_w {
2751+ Some(w) => wrap_detail_items(&marks, w.saturating_sub(width(&dline)).max(1)),
2752+ None => vec![marks.join(" ")],
2753+ };
2754+ for text in detail_lines {
2755+ lines.push(format!("{}{}", dline, text));
2756+ }
26212757 }
26222758 }
26232759 // Legend (§Legend): explain the symbols that actually appear, faintly, on
26242760 // the right of the tree if it fits the terminal width, else at the bottom.
2625− append_legend(&mut lines, rows, opts, pal, term_w);
2761+ append_legend(&mut lines, rows, opts, pal, term_w, overflow);
26262762 let mut out = lines.join("\n");
26272763 out.push('\n');
26282764 Ok(out)
@@ -2635,10 +2771,11 @@ struct UsedSymbols {
26352771 run: bool, // a ╎ n run row
26362772 collapsed: bool, // a ⋯ n collapsed node
26372773 focus_gutter: bool, // ▶ present (a focus row exists)
2774+ overflow: bool, // separate » status column
26382775 detail_marks: Vec<char>, // + ~ − ✖ present in the focus's detail line
26392776}
26402777
2641−fn collect_used(rows: &[&Display], opts: &TreeOptions, icons: bool) -> UsedSymbols {
2778+fn collect_used(rows: &[&Display], opts: &TreeOptions, icons: bool, overflow: bool) -> UsedSymbols {
26422779 let mut present: BTreeSet<&'static str> = BTreeSet::new();
26432780 let mut run = false;
26442781 let mut collapsed = false;
@@ -2677,6 +2814,7 @@ fn collect_used(rows: &[&Display], opts: &TreeOptions, icons: bool) -> UsedSymbo
26772814 collapsed,
26782815 focus_gutter,
26792816 detail_marks: marks.into_iter().collect(),
2817+ overflow,
26802818 }
26812819}
26822820
@@ -2712,6 +2850,9 @@ fn legend_lines(used: &UsedSymbols) -> Vec<String> {
27122850 if used.focus_gutter {
27132851 out.push("▶ current commit".to_string());
27142852 }
2853+ if used.overflow {
2854+ out.push("» branch exceeds lane limit; increase lanes".to_string());
2855+ }
27152856 if used.run {
27162857 out.push("╎ n run of n commits".to_string());
27172858 }
@@ -2734,8 +2875,9 @@ fn append_legend(
27342875 opts: &TreeOptions,
27352876 pal: &Palette,
27362877 tty_width: Option<usize>,
2878+ overflow: bool,
27372879) {
2738− let used = collect_used(rows, opts, opts.icons);
2880+ let used = collect_used(rows, opts, opts.icons, overflow);
27392881 let entries = legend_lines(&used);
27402882 if entries.is_empty() {
27412883 return;
@@ -2845,8 +2987,6 @@ fn rail_row(
28452987 }
28462988 } else if state[i].is_some() {
28472989 '│'
2848− } else if pl.flattened && i == lanes_n - 1 {
2849− '»'
28502990 } else {
28512991 ' '
28522992 };
@@ -2897,8 +3037,9 @@ fn detail_rail_row(state: &[Option<bool>]) -> (Vec<char>, Vec<bool>) {
28973037}
28983038
28993039/// Colour the rails. Rail connectors are lane-coloured (lane 0 = immutable
2900−/// blue, others dim); the node glyph at `glyph` (its lane position and ANSI
2901−/// code) is meaning-coloured. A glyph two columns wide (`icons`) fills both
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
29023043/// characters of its lane, so the one after it is not drawn (§Glyphs).
29033044fn color_rails(
29043045 chars: &[char],
@@ -2922,17 +3063,72 @@ fn color_rails(
29223063 } else if lane0[i] {
29233064 s.push_str(&pal.blue(&t));
29243065 } else {
2925− s.push_str(&pal.dim(&t));
3066+ s.push_str(&pal.rail(i / 2, &t));
29263067 }
29273068 } else if lane0[i] {
29283069 s.push_str(&pal.blue(&t));
29293070 } else {
2930− s.push_str(&pal.dim(&t));
3071+ s.push_str(&pal.rail(i / 2, &t));
29313072 }
29323073 }
29333074 s
29343075}
29353076
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);
3095+ continue;
3096+ }
3097+ let cells = UnicodeWidthChar::width(c).unwrap_or(0);
3098+ if used > 0 && used + cells > limit {
3099+ if !active.is_empty() { line.push_str("\x1b[0m"); }
3100+ lines.push(line);
3101+ line = active.clone();
3102+ used = 0;
3103+ }
3104+ line.push(c);
3105+ used += cells;
3106+ }
3107+ if !active.is_empty() { line.push_str("\x1b[0m"); }
3108+ lines.push(line);
3109+ lines
3110+}
3111+
3112+/// Keep whole detail items together when possible; split only an item that
3113+/// cannot fit on an otherwise empty continuation row.
3114+fn wrap_detail_items(items: &[String], limit: usize) -> Vec<String> {
3115+ let mut lines = Vec::new();
3116+ let mut line = String::new();
3117+ for item in items {
3118+ if !line.is_empty() && width(&line) + 3 + width(item) <= limit {
3119+ line.push_str(" ");
3120+ line.push_str(item);
3121+ continue;
3122+ }
3123+ if !line.is_empty() { lines.push(std::mem::take(&mut line)); }
3124+ let mut parts = wrap_display_text(item, limit);
3125+ line = parts.pop().unwrap_or_default();
3126+ lines.extend(parts);
3127+ }
3128+ if !line.is_empty() { lines.push(line); }
3129+ lines
3130+}
3131+
29363132/// `s` cut to display width `w`, ending with `…` when anything is cut (and
29373133/// there is a column for it): the message on a terminal (§Step 4). Colour
29383134/// escapes are kept whole and not counted, and those past the cut still
@@ -3048,20 +3244,18 @@ fn glyph_for(info: &CommitInfo, icons: bool) -> &'static str {
30483244/// The ANSI colour code for a node glyph, by meaning (§Colour: glyphs carry
30493245/// meaning; colour only adds emphasis). Mirrors the precedence of `glyph_for`.
30503246fn glyph_ansi(info: &CommitInfo) -> &'static str {
3051− if info.id == ROOT_ID {
3052− "1" // root: bold
3053− } else if info.conflict {
3054− "31" // conflict: red
3055− } else if info.empty {
3056− "2" // empty: dim
3057− } else if info.immutable {
3058− "34" // immutable: blue
3059− } else if info.is_focus {
3060− "1;36" // focus: bold cyan
3061− } else if info.is_ancestor_of_focus {
3062− "32" // ancestor of focus: green
3063− } else {
3064− "37" // other commit: light grey
3247+ node_ansi(glyph_for(info, false))
3248+}
3249+
3250+fn node_ansi(glyph: &str) -> &'static str {
3251+ match glyph {
3252+ "⌂" => "1;95", // root: bold magenta
3253+ "⊗" => "91", // conflict: bright red
3254+ "◌" => "2", // empty: dim
3255+ "◆" => "94", // immutable: bright blue
3256+ "◉" => "1;96", // focus: bold cyan
3257+ "●" => "92", // ancestor of focus: bright green
3258+ _ => "97", // other commit: bright white
30653259 }
30663260}
30673261
src/repo.rs+32−3
@@ -382,7 +382,8 @@ pub fn same_repo(a: &Value, b: &Value) -> Result<bool, Crash> {
382382}
383383
384384// ----------------------------------------------------------------------
385−// validation (§7.5 steps 1–3 and 6), shared by `validate` and persistence
385+// validation (§7.5 steps 1–3, the root invariant of 4, and 6), shared by
386+// `validate` and persistence
386387// ----------------------------------------------------------------------
387388
388389#[derive(Debug)]
@@ -417,9 +418,29 @@ pub fn validate_repo(i: &mut Interp, new: &Value) -> Result<Validated, Crash> {
417418 "the focus must be a mutable commit; compose with new",
418419 ));
419420 }
421+ validate_root(old.as_ref(), new)?;
420422 Ok(Validated { immutable })
421423}
422424
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+
423444fn check_repo_shape(v: &Value) -> Result<(), Crash> {
424445 let fields = v
425446 .field_set()
@@ -460,6 +481,9 @@ fn validate_tree(tree: &Value, seen: &mut BTreeSet<String>) -> Result<(), Crash>
460481 id
461482 )));
462483 }
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()?;
463487 // a list loaded from a stored tree is a snapshot already: jj's tree
464488 // holds each name once, and lists a path as a file or as a directory.
465489 // Reading every commit's to check it made each persist read the files
@@ -475,13 +499,18 @@ fn validate_tree(tree: &Value, seen: &mut BTreeSet<String>) -> Result<(), Crash>
475499 Ok(())
476500}
477501
478−/// unique paths, none of them the root `./`, none both a file and a
479−/// directory (§7.5 step 1)
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).
480504fn validate_snapshot(files: &Value) -> Result<(), Crash> {
481505 let files = files.as_list()?;
482506 let mut paths = BTreeSet::new();
483507 for e in files {
484508 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+ }
485514 let p = p.as_list()?;
486515 let key: Vec<String> = p
487516 .iter()
src/setup.rsnew file+119
@@ -0,0 +1,119 @@
1+//! Explicit first-run configuration, independent of repository discovery.
2+
3+use j::ast::Item;
4+use j::config;
5+use j::eval::Interp;
6+use j::lex::{lex, Tok};
7+use j::parse::parse_config;
8+use j::show::show;
9+use j::value::Value;
10+use std::io::Write;
11+use std::path::Path;
12+use std::rc::Rc;
13+
14+const IDENTITY_EXAMPLE: &str = "user = { name = \"Ada Lovelace\", email = \"ada@example.org\" }";
15+
16+/// `args` contains only the arguments after `setup`. Existing files, including
17+/// invalid configs and symlinks, are never replaced. A new file is published
18+/// atomically so concurrent setup commands cannot overwrite one another.
19+pub fn run(path: &Path, default: &str, args: &[&str]) -> Result<String, (u8, String)> {
20+ if !matches!(args.len(), 0 | 2) {
21+ return Err((2, "usage: j setup [NAME EMAIL]; for example: j setup 'Ada Lovelace' ada@example.org; see `j help setup`".into()));
22+ }
23+ if path.symlink_metadata().is_ok() {
24+ return existing(path, args);
25+ }
26+ let source = match args {
27+ [name, email] => with_identity(default, name, email)?,
28+ [] => default.to_string(),
29+ _ => unreachable!(),
30+ };
31+ let parent = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or_else(|| Path::new("."));
32+ std::fs::create_dir_all(parent)
33+ .map_err(|error| (3, format!("cannot create config directory {}: {}", parent.display(), error)))?;
34+ let mut temporary = tempfile::NamedTempFile::new_in(parent)
35+ .map_err(|error| (3, format!("cannot prepare config at {}: {}", path.display(), error)))?;
36+ temporary.write_all(source.as_bytes())
37+ .map_err(|error| (3, format!("cannot write config at {}: {}", path.display(), error)))?;
38+ match temporary.persist_noclobber(path) {
39+ Ok(_) => {}
40+ Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => return existing(path, args),
41+ Err(error) => return Err((3, format!("cannot create config at {}: {}", path.display(), error.error))),
42+ }
43+ if args.is_empty() {
44+ Ok(format!(
45+ "Created the complete default config at {}.\n\nEdit the existing `user` definition in this file, replacing both example values with your identity:\n {}\nKeep the rest of the file: it defines your commands.\n\nThen run `j init` in your project directory, or `j clone URL`.\n",
46+ path.display(), IDENTITY_EXAMPLE,
47+ ))
48+ } else {
49+ Ok(format!(
50+ "Configuration ready at {}.\nIdentity: {}\n\nNext: run `j init` in your project directory, or `j clone URL`.\n",
51+ path.display(), identity_line(&source)?,
52+ ))
53+ }
54+}
55+
56+fn existing(path: &Path, args: &[&str]) -> Result<String, (u8, String)> {
57+ if !args.is_empty() {
58+ return Err((2, format!(
59+ "config already exists at {}; it was not changed. Edit its existing `user` definition to change your identity; see `j help config`.",
60+ path.display(),
61+ )));
62+ }
63+ let problem = match std::fs::read_to_string(path) {
64+ Ok(source) => match identity_line(&source) {
65+ Ok(identity) => return Ok(format!(
66+ "Configuration already exists at {} and is ready.\nIdentity: {}\n\nNext: run `j init` in your project directory, or `j clone URL`.\n",
67+ path.display(), identity,
68+ )),
69+ Err((_, message)) => message,
70+ },
71+ Err(error) => format!("cannot read this config: {}", error),
72+ };
73+ Ok(format!(
74+ "Configuration already exists at {}; it was not changed.\nNeeds attention: {}\n\nTo set your identity, edit the existing `user` definition:\n {}\nKeep the rest of the file and repair the problem above.\nSee `j help config`; then retry `j setup`.\n",
75+ path.display(), problem, IDENTITY_EXAMPLE,
76+ ))
77+}
78+
79+fn identity_line(source: &str) -> Result<String, (u8, String)> {
80+ let cfg = config::load_config(source).map_err(|error| (3, error.message()))?;
81+ j::jj::configured_identity(&cfg)
82+}
83+
84+fn with_identity(default: &str, name: &str, email: &str) -> Result<String, (u8, String)> {
85+ let items = parse_config(default, Rc::new(config::reserved_set()))
86+ .map_err(|error| (3, format!("cannot prepare default config: {}", error.msg)))?;
87+ let line = items.iter().find_map(|item| match item {
88+ Item::Definition(name, _, line) if name == "user" => Some(*line),
89+ _ => None,
90+ }).ok_or_else(|| (3, "default config has no `user` definition".into()))?;
91+ let tokens = lex(default).map_err(|error| (3, error.msg))?;
92+ let start = tokens.iter().position(|token| token.line == line && token.col == 1)
93+ .ok_or_else(|| (3, "cannot locate the default `user` definition".into()))?;
94+ let mut depth = 0usize;
95+ let mut end = tokens[start].end;
96+ for token in &tokens[start..] {
97+ if matches!(token.tok, Tok::Eof)
98+ || (token.line > line && token.col == 1 && depth == 0 && !matches!(token.tok, Tok::Newline))
99+ {
100+ break;
101+ }
102+ match token.tok {
103+ Tok::LParen | Tok::LBracket | Tok::LBrace => depth += 1,
104+ Tok::RParen | Tok::RBracket | Tok::RBrace => depth = depth.saturating_sub(1),
105+ Tok::Newline => continue,
106+ _ => {}
107+ }
108+ end = token.end;
109+ }
110+ let interp = Interp::dummy();
111+ let name = show(&interp, &Value::text(name)).map_err(|error| (3, error.msg))?;
112+ let email = show(&interp, &Value::text(email)).map_err(|error| (3, error.msg))?;
113+ let definition = format!("user = {{ name = {}, email = {} }}", name, email);
114+ let mut source = default.to_string();
115+ source.replace_range(tokens[start].start..end, &definition);
116+ // Reuse the same identity requirements as init/clone before writing a file.
117+ identity_line(&source)?;
118+ Ok(source)
119+}
src/show.rs+90−22
@@ -1,7 +1,7 @@
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, BlobVal, Crash, FunVal, ThunkVal, Value};
4+use crate::value::{BlobContent, BlobKind, BlobVal, Crash, FunVal, ThunkVal, Value};
55use num_bigint::BigInt;
66use unicode_width::UnicodeWidthChar;
77
@@ -67,7 +67,7 @@ fn render(interp: &Interp, v: &Value, out: &mut String) -> Result<(), Crash> {
6767 }
6868 out.push_str(" }");
6969 }
70− Value::Blob(b) => return render_blob(b, out),
70+ Value::Blob(b) => return render_blob(interp, b, out),
7171 Value::Shape(s) => out.push_str(&s.name),
7272 Value::Fun(f) => return render_fun(interp, f, out),
7373 }
@@ -84,24 +84,43 @@ fn render_int(n: &BigInt, out: &mut String) {
8484}
8585
8686#[inline(never)]
87−fn render_blob(b: &BlobVal, out: &mut String) -> Result<(), Crash> {
88− match &b.content {
89− BlobContent::Resolved(bytes) => match std::str::from_utf8(bytes) {
90− Ok(s) => {
91− out.push_str("blob ");
92− push_text_literal(s, out);
93− }
94− Err(_) => out.push_str("blob \"<binary>\""),
95− },
96− BlobContent::Lazy(_) | BlobContent::Conflict(_) => {
97− let bytes = b.bytes()?;
98− if b.is_unresolved() {
99− out.push_str("{- unresolved -} ");
100− }
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) {
101103 out.push_str("blob ");
102− push_text_literal(&String::from_utf8_lossy(&bytes), out);
104+ push_text_literal(text, out);
105+ return Ok(());
103106 }
104107 }
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);
105124 Ok(())
106125}
107126
@@ -436,6 +455,13 @@ pub fn difft(path: &Value, a: &Value, b: &Value) -> Result<Value, Crash> {
436455 .map(|c| c.as_text().map(|s| s.to_string()))
437456 .transpose()?
438457 .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+ }
439465 let a_bytes = match a {
440466 Value::Blob(bl) => bl.bytes()?,
441467 v => return Err(Crash::new(format!("difft: expected a Blob, got a {}", v.kind_name()))),
@@ -446,8 +472,14 @@ pub fn difft(path: &Value, a: &Value, b: &Value) -> Result<Value, Crash> {
446472 };
447473 let dir = tempfile::tempdir()
448474 .map_err(|e| Crash::new(format!("difft: cannot create temp dir: {}", e)))?;
449− let old = dir.path().join(format!("old-{}", last));
450− let new = dir.path().join(format!("new-{}", last));
475+ // Keep the filename unchanged: adding a prefix can exceed NAME_MAX for
476+ // a valid tracked path, and some languages are detected by exact name.
477+ let old_dir = dir.path().join("old");
478+ let new_dir = dir.path().join("new");
479+ std::fs::create_dir(&old_dir).map_err(|e| Crash::new(format!("difft: {}", e)))?;
480+ std::fs::create_dir(&new_dir).map_err(|e| Crash::new(format!("difft: {}", e)))?;
481+ let old = old_dir.join(name);
482+ let new = new_dir.join(name);
451483 std::fs::write(&old, &a_bytes).map_err(|e| Crash::new(format!("difft: {}", e)))?;
452484 std::fs::write(&new, &b_bytes).map_err(|e| Crash::new(format!("difft: {}", e)))?;
453485 let mut cmd = std::process::Command::new("difft");
@@ -464,12 +496,48 @@ pub fn difft(path: &Value, a: &Value, b: &Value) -> Result<Value, Crash> {
464496 cmd.env("DFT_WIDTH", w.to_string());
465497 }
466498 }
467− let out = cmd
468− .output()
469− .map_err(|_| Crash::new("difft: cannot execute `difft` (not on PATH)"))?;
499+ crate::terminal::external_stderr();
500+ let out = match cmd.output() {
501+ Ok(out) => out,
502+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
503+ return fallback_diff(comps, &a_bytes, &b_bytes);
504+ }
505+ Err(error) => return Err(Crash::new(format!("difft: cannot execute `difft`: {}", error))),
506+ };
470507 Ok(Value::text(String::from_utf8_lossy(&out.stdout).to_string()))
471508}
472509
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+
473541pub fn stdout_is_tty() -> bool {
474542 unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 }
475543}
src/terminal.rsnew file+276
@@ -0,0 +1,276 @@
1+//! Interactive presentation. Rendered values stay free of cursor controls;
2+//! motion belongs only to the CLI's final output and temporary status line.
3+
4+use std::fmt;
5+use std::io::{self, IsTerminal, Write};
6+use std::sync::atomic::{AtomicBool, Ordering};
7+use std::sync::mpsc::{self, Sender};
8+use std::sync::{Arc, Mutex, Weak};
9+use std::thread::{self, JoinHandle};
10+use std::time::{Duration, Instant};
11+
12+use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
13+
14+static ACTIVE_PROGRESS: Mutex<Option<Weak<ProgressWorker>>> = Mutex::new(None);
15+static EXTERNAL_STDERR: AtomicBool = AtomicBool::new(false);
16+
17+fn stop_progress() {
18+ let worker = ACTIVE_PROGRESS.lock().unwrap().as_ref().and_then(Weak::upgrade);
19+ if let Some(worker) = worker {
20+ worker.finish();
21+ }
22+}
23+
24+/// A subprocess may write an unterminated diagnostic. Clear our status before
25+/// it starts, and leave stderr alone for the remainder of this run.
26+pub fn external_stderr() {
27+ EXTERNAL_STDERR.store(true, Ordering::Relaxed);
28+ stop_progress();
29+}
30+
31+fn nonempty_env(name: &str) -> bool {
32+ std::env::var_os(name).is_some_and(|value| !value.is_empty())
33+}
34+
35+fn capable_terminal() -> bool {
36+ !nonempty_env("NO_COLOR") && std::env::var("TERM").as_deref() != Ok("dumb")
37+}
38+
39+fn interactive() -> bool {
40+ io::stdout().is_terminal()
41+ && io::stderr().is_terminal()
42+ && !nonempty_env("CI")
43+}
44+
45+fn motion_enabled() -> bool {
46+ interactive()
47+ && capable_terminal()
48+ && !matches!(
49+ std::env::var("J_ANIMATIONS")
50+ .unwrap_or_default()
51+ .to_ascii_lowercase()
52+ .as_str(),
53+ "never" | "off" | "false" | "0"
54+ )
55+}
56+
57+/// Write a diagnostic without turning a closed stderr into a panic (§1.4).
58+pub fn report(args: fmt::Arguments<'_>) {
59+ report_with_color(args, "1;96");
60+}
61+
62+pub fn report_error(args: fmt::Arguments<'_>) {
63+ report_with_color(args, "1;91");
64+}
65+
66+fn report_with_color(args: fmt::Arguments<'_>, code: &str) {
67+ stop_progress();
68+ let mut out = io::stderr().lock();
69+ if out.is_terminal() && capable_terminal() {
70+ let message = args.to_string();
71+ if let Some(rest) = message.strip_prefix("j: ") {
72+ let _ = writeln!(out, "\x1b[{code}mj:\x1b[0m {rest}");
73+ } else {
74+ let _ = writeln!(out, "\x1b[2m{message}\x1b[0m");
75+ }
76+ } else {
77+ let _ = writeln!(out, "{args}");
78+ }
79+}
80+
81+/// Short confirmation for commands that otherwise have no terminal output.
82+/// Pipes and unattended runs keep their existing, quiet behavior.
83+pub fn success(message: &str) {
84+ stop_progress();
85+ if interactive() {
86+ let mut out = io::stderr().lock();
87+ if capable_terminal() {
88+ let _ = writeln!(out, "\x1b[1;92m✓\x1b[0m \x1b[1;96mj:\x1b[0m {message}");
89+ } else {
90+ let _ = writeln!(out, "j: {message}");
91+ }
92+ }
93+}
94+
95+/// A delayed spinner, activity bar, and elapsed time. The bar is deliberately
96+/// indeterminate: elapsed time cannot tell us how much work remains.
97+/// Stopping wakes the worker immediately, so short operations have no extra
98+/// delay. Never hide or move the cursor
99+/// between rows: even an abrupt signal leaves the terminal usable.
100+struct Progress {
101+ worker: Option<Arc<ProgressWorker>>,
102+}
103+
104+struct ProgressWorker {
105+ stop: Sender<()>,
106+ thread: Mutex<Option<JoinHandle<()>>>,
107+}
108+
109+impl ProgressWorker {
110+ fn finish(&self) {
111+ let _ = self.stop.send(());
112+ if let Some(worker) = self.thread.lock().unwrap().take() {
113+ let _ = worker.join();
114+ }
115+ }
116+}
117+
118+impl Progress {
119+ fn start(label: &'static str) -> Self {
120+ if !motion_enabled() || EXTERNAL_STDERR.load(Ordering::Relaxed) {
121+ return Self { worker: None };
122+ }
123+ stop_progress();
124+ let (send, receive) = mpsc::channel();
125+ let started = Instant::now();
126+ let worker = thread::Builder::new().name("j-progress".into()).spawn(move || {
127+ if receive.recv_timeout(Duration::from_millis(140)) != Err(mpsc::RecvTimeoutError::Timeout) {
128+ return;
129+ }
130+ let colors = ["95", "96", "93", "92", "94"];
131+ let mut frame = 0;
132+ loop {
133+ // Fit each new frame to the current terminal width, including
134+ // after a resize. Leave one column spare at the edge.
135+ let width = stderr_width().saturating_sub(1);
136+ let line = progress_line(label, frame, started.elapsed(), width);
137+ {
138+ let mut out = io::stderr().lock();
139+ if write!(
140+ out,
141+ "\r\x1b[2K\x1b[1;{}m{}\x1b[0m",
142+ colors[frame % colors.len()],
143+ line
144+ )
145+ .and_then(|()| out.flush())
146+ .is_err()
147+ {
148+ break;
149+ }
150+ }
151+ frame += 1;
152+ if receive.recv_timeout(Duration::from_millis(75)) != Err(mpsc::RecvTimeoutError::Timeout) {
153+ break;
154+ }
155+ }
156+ let mut out = io::stderr().lock();
157+ let _ = write!(out, "\r\x1b[2K").and_then(|()| out.flush());
158+ });
159+ let worker = worker.ok().map(|worker| Arc::new(ProgressWorker {
160+ stop: send,
161+ thread: Mutex::new(Some(worker)),
162+ }));
163+ *ACTIVE_PROGRESS.lock().unwrap() = worker.as_ref().map(Arc::downgrade);
164+ Self { worker }
165+ }
166+}
167+
168+impl Drop for Progress {
169+ fn drop(&mut self) {
170+ if let Some(worker) = self.worker.take() {
171+ worker.finish();
172+ }
173+ }
174+}
175+
176+/// Finish the temporary line before the caller reports success or failure.
177+pub fn with_progress<T>(label: &'static str, operation: impl FnOnce() -> T) -> T {
178+ let _progress = Progress::start(label);
179+ operation()
180+}
181+
182+fn progress_line(label: &str, frame: usize, elapsed: Duration, width: usize) -> String {
183+ const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
184+ let mut line = format!("{} j · {}", FRAMES[frame % FRAMES.len()], label);
185+ let seconds = elapsed.as_secs();
186+ let time = if seconds < 60 {
187+ format!("{}.{}s", seconds, elapsed.subsec_millis() / 100)
188+ } else {
189+ format!("{}m {:02}s", seconds / 60, seconds % 60)
190+ };
191+ // Bounce a three-cell pulse across a ten-cell track. It never fills up
192+ // or suggests a completion percentage for work with no known total.
193+ let step = frame % 14;
194+ let start = step.min(14 - step);
195+ let bar: String = (0..10)
196+ .map(|cell| if (start..start + 3).contains(&cell) { '━' } else { '·' })
197+ .collect();
198+ let details = format!(" [{bar}] {time}");
199+ if line.width() + details.width() <= width {
200+ line.push_str(&details);
201+ } else if line.width() + 1 + time.width() <= width {
202+ // On small terminals, keep the operation's name ahead of decoration.
203+ line.push(' ');
204+ line.push_str(&time);
205+ }
206+ let mut used = 0;
207+ line.chars()
208+ .take_while(|c| {
209+ used += c.width().unwrap_or(0);
210+ used <= width
211+ })
212+ .collect()
213+}
214+
215+fn stderr_width() -> usize {
216+ // The two streams can be attached to different terminals. Measure the
217+ // one that receives the progress line, rather than the tree's stdout.
218+ unsafe {
219+ let mut size: libc::winsize = std::mem::zeroed();
220+ if libc::ioctl(libc::STDERR_FILENO, libc::TIOCGWINSZ, &mut size) == 0
221+ && size.ws_col > 0
222+ {
223+ size.ws_col as usize
224+ } else {
225+ 80
226+ }
227+ }
228+}
229+
230+/// Reveal colored, multiline terminal output in a few quick batches. Never
231+/// split a line (and therefore an ANSI style), alter its bytes, or animate
232+/// plain text. The total pacing is at most 96 ms, even for very large trees.
233+pub fn write_output(out: &mut impl Write, text: &str) -> io::Result<()> {
234+ if !motion_enabled() || !text.contains("\x1b[") {
235+ return out.write_all(text.as_bytes()).and_then(|()| out.flush());
236+ }
237+ let lines = text.bytes().filter(|&byte| byte == b'\n').count()
238+ + usize::from(!text.ends_with('\n'));
239+ let batch = lines.div_ceil(7).max(1);
240+ let mut start = 0;
241+ let mut end = 0;
242+ for (index, line) in text.split_inclusive('\n').enumerate() {
243+ end += line.len();
244+ if (index + 1) % batch == 0 && index + 1 < lines {
245+ out.write_all(text[start..end].as_bytes())?;
246+ out.flush()?;
247+ start = end;
248+ thread::sleep(Duration::from_millis(16));
249+ }
250+ }
251+ out.write_all(text[start..].as_bytes())?;
252+ out.flush()
253+}
254+
255+#[cfg(test)]
256+mod tests {
257+ use super::*;
258+
259+ #[test]
260+ fn progress_fits_terminal_cells_and_keeps_the_operation_visible() {
261+ let label = "Reading 仓库";
262+ for width in 0..80 {
263+ for frame in 0..20 {
264+ let line = progress_line(label, frame, Duration::from_millis(1234), width);
265+ assert!(line.width() <= width, "{width} columns: {line:?}");
266+ if width >= 18 {
267+ assert!(line.contains(label), "operation name lost: {line:?}");
268+ }
269+ }
270+ }
271+ let line = progress_line("Reading repository", 0, Duration::from_millis(1234), 80);
272+ assert!(line.contains("[━━━·······] 1.2s"), "{line:?}");
273+ assert!(progress_line("Reading repository", 0, Duration::from_secs(65), 80)
274+ .ends_with("1m 05s"));
275+ }
276+}
src/value.rs+314−14
@@ -3,7 +3,7 @@
33use crate::ast::{Expr, Pattern};
44use num_bigint::BigInt;
55use std::collections::{BTreeMap, BTreeSet, HashMap};
6−use std::rc::Rc;
6+use std::rc::{Rc, Weak};
77
88pub type RecordMap = BTreeMap<String, Value>;
99
@@ -764,10 +764,10 @@ pub struct Env {
764764 frame: Option<Rc<Frame>>,
765765}
766766
767−/// A large acyclic let can have one frame per binding. Release unique
768−/// frames iteratively, keeping the next parent alive while its child's
769−/// values drop, so closures capturing earlier frames cannot recurse down
770−/// that chain either (§4.1).
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).
771771impl Drop for Env {
772772 fn drop(&mut self) {
773773 let mut next = self.frame.take();
@@ -820,16 +820,20 @@ impl Env {
820820 /// extend with a recursive frame (for `let` blocks) binding `names`,
821821 /// whose values are pushed to the returned cell in the same order
822822 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();
823826 let cell = Rc::new(std::cell::RefCell::new(Vec::with_capacity(names.len())));
824− (
825− Env {
826− frame: Some(Rc::new(Frame {
827− kind: FrameKind::Rec(names, cell.clone()),
828− parent: self.clone(),
829− })),
830− },
831− cell,
832− )
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)
833837 }
834838
835839 pub fn lookup(&self, name: &str) -> Option<Value> {
@@ -903,6 +907,302 @@ impl Env {
903907 }
904908}
905909
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+
9061206// ----------------------------------------------------------------------
9071207// structural equality (§4.5); comparing functions is a crash
9081208// ----------------------------------------------------------------------
tests/base.rs+2−2
@@ -413,8 +413,8 @@ fn inspection_definitions() {
413413 // meta of minted crashes
414414 let m = crash(&mut i, &cfg, "meta @", repo.clone());
415415 assert!(m.contains("stored commit") || m.contains("meta"), "{}", m);
416− // review renders the two changed paths with difft, or crashes saying
417− // difft cannot run; "no changes" is for a focus that changes nothing
416+ // review renders changed paths with difft or the built-in fallback;
417+ // "no changes" is for a focus that changes nothing.
418418 let r = ev(&mut i, &cfg, "review", repo.clone());
419419 match r {
420420 Ok(v) => assert_ne!(v.as_text().unwrap(), "no changes\n"),
tests/builtins.rs+26
@@ -81,6 +81,32 @@ fn arithmetic() {
8181 check!(i, cfg, "3 >= 4", Value::Bool(false));
8282}
8383
84+#[test]
85+fn blob_hex_decodes_exact_bytes_and_file_type() {
86+ use j::value::BlobKind;
87+ let (mut i, cfg) = make_interp();
88+ for (name, kind) in [
89+ ("regular", BlobKind::Regular),
90+ ("executable", BlobKind::Executable),
91+ ("symlink", BlobKind::Symlink),
92+ ] {
93+ for (hex, bytes) in [("", vec![]), ("00ff80A5", vec![0, 255, 128, 165])] {
94+ let v = ok(&mut i, &cfg, &format!("blobHex \"{}\" \"{}\"", name, hex));
95+ let Value::Blob(b) = v else { panic!("expected Blob") };
96+ assert_eq!(b.kind, kind);
97+ assert_eq!(b.bytes().unwrap(), bytes);
98+ assert!(!b.is_unresolved());
99+ }
100+ }
101+ for hex in ["f", "xyz", "0g", "é", "00 ff"] {
102+ let msg = crash(&mut i, &cfg, &format!("blobHex \"regular\" \"{}\"", hex));
103+ assert!(msg.contains("invalid hexadecimal content"), "{}", msg);
104+ }
105+ assert!(crash(&mut i, &cfg, "blobHex \"directory\" \"\"").contains("kind must be"));
106+ assert!(crash(&mut i, &cfg, "blobHex 1 \"00\"").contains("expected Text"));
107+ assert!(crash(&mut i, &cfg, "blobHex \"regular\" 1").contains("expected Text"));
108+}
109+
84110#[test]
85111fn identity_and_const() {
86112 let (mut i, cfg) = make_interp();
tests/cli.rs+241−10
@@ -59,6 +59,15 @@ fn setup() -> Repo {
5959}
6060
6161impl Repo {
62+ fn operation_heads(&self) -> Vec<std::ffi::OsString> {
63+ let mut heads: Vec<_> = std::fs::read_dir(self.dir.join(".jj/repo/op_heads/heads"))
64+ .unwrap()
65+ .map(|entry| entry.unwrap().file_name())
66+ .collect();
67+ heads.sort();
68+ heads
69+ }
70+
6271 fn j(&self, args: &[&str]) -> Out {
6372 let out = Command::new(j_bin())
6473 .args(args)
@@ -190,6 +199,53 @@ fn both_stdin_and_args_rejected() {
190199 assert!(out.stderr.contains("both"), "{}", out.stderr);
191200}
192201
202+#[test]
203+fn arguments_do_not_wait_for_an_open_stdin_pipe_to_close() {
204+ use std::io::Write;
205+ use std::time::{Duration, Instant};
206+ let r = setup();
207+ for input in [Some(b'1'), Some(0xff), None] {
208+ let (reader, mut writer) = std::io::pipe().unwrap();
209+ // Supply input before spawning so the readiness check cannot race
210+ // the writer. Keep the writer open until the command has exited.
211+ if let Some(byte) = input {
212+ writer.write_all(&[byte]).unwrap();
213+ }
214+ let mut child = Command::new(j_bin())
215+ .arg(if input.is_some() { "describe \"must not run\"" } else { "1" })
216+ .current_dir(&r.dir)
217+ .env("XDG_CONFIG_HOME", &r.cfg)
218+ .env("NO_COLOR", "1")
219+ .stdin(reader)
220+ .stdout(Stdio::piped())
221+ .stderr(Stdio::piped())
222+ .spawn()
223+ .unwrap();
224+ let deadline = Instant::now() + Duration::from_secs(5);
225+ while child.try_wait().unwrap().is_none() {
226+ if Instant::now() >= deadline {
227+ child.kill().unwrap();
228+ let out = child.wait_with_output().unwrap();
229+ panic!("waited for stdin EOF with input {:?}: {}", input,
230+ String::from_utf8_lossy(&out.stderr));
231+ }
232+ std::thread::sleep(Duration::from_millis(10));
233+ }
234+ let out = child.wait_with_output().unwrap();
235+ drop(writer);
236+ let stderr = String::from_utf8_lossy(&out.stderr);
237+ if input.is_some() {
238+ assert_eq!(out.status.code(), Some(2), "{}", stderr);
239+ assert_eq!(stderr, "j: expression given both as arguments and on stdin\n");
240+ assert!(out.stdout.is_empty());
241+ assert_eq!(r.j(&["\\r -> show (focus r).message"]).ok().stdout, "\"\"\n");
242+ } else {
243+ assert_eq!(out.status.code(), Some(0), "{}", stderr);
244+ assert_eq!(out.stdout, b"1\n");
245+ }
246+ }
247+}
248+
193249#[test]
194250fn an_expression_that_is_not_utf8_is_a_usage_error() {
195251 // §1: an argument that was not UTF-8 (a path with a Latin-1 byte)
@@ -260,8 +316,8 @@ fn parse_error_is_exit_3() {
260316
261317#[test]
262318fn deep_nesting_is_a_parse_error() {
263− // §1.4: however deep the input, the process exits 3 with one `j:` line
264− // rather than aborting on a stack overflow in the parser
319+ // §1.4: however deep the input, the process exits 3 with a diagnostic
320+ // and bounded source excerpt rather than overflowing or dumping the input.
265321 let r = setup();
266322 let n = 100_000;
267323 // a left-associative chain too: the parser builds it in a loop, but its
@@ -274,7 +330,8 @@ fn deep_nesting_is_a_parse_error() {
274330 ] {
275331 let out = r.j_stdin(&src, &[]);
276332 assert_eq!(out.code, 3, "{}", out.stderr);
277− assert!(out.stderr.starts_with("j: ") && out.stderr.lines().count() == 1, "{}", out.stderr);
333+ assert!(out.stderr.starts_with("j: ") && out.stderr.lines().count() <= 2, "{}", out.stderr);
334+ assert!(out.stderr.len() < 1024, "diagnostic is not bounded: {} bytes", out.stderr.len());
278335 }
279336}
280337
@@ -416,9 +473,69 @@ fn validate_catches_what_persistence_refuses() {
416473 let r = setup();
417474 r.write("a.txt", "x\n");
418475 r.j(&["describe \"work\""]).ok();
419− // tree . validate . squash is an honest dry run
476+ let before = r.j(&["ops"]).ok().stdout.lines().count();
477+ let preview = r.j(&["tree . validate . squash"]);
478+ let persist = r.j(&["squash"]);
479+ assert_eq!(preview.code, 1, "{}", preview.stdout);
480+ assert_eq!(persist.code, 1, "{}", persist.stderr);
481+ assert_eq!(preview.stderr.lines().next(), persist.stderr.lines().next());
482+ assert!(preview.stderr.contains("the root commit cannot be changed"));
483+ assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), before);
484+ assert_eq!(r.read("a.txt"), "x\n");
485+
486+ // Once the parent is an ordinary commit, both preview and persistence
487+ // succeed, and the preview itself still records nothing.
488+ r.j(&["new"]).ok();
489+ r.write("b.txt", "y\n");
490+ r.j(&["describe \"second\""]).ok();
491+ let before = r.j(&["ops"]).ok().stdout.lines().count();
420492 let out = r.j(&["tree . validate . squash"]).ok();
421493 assert!(!out.stdout.is_empty());
494+ assert_eq!(r.j(&["ops"]).ok().stdout.lines().count(), before);
495+ r.j(&["squash"]).ok();
496+}
497+
498+#[test]
499+fn validate_and_persistence_reject_malformed_snapshot_contents() {
500+ let r = setup();
501+ r.write("kept", "original\n");
502+ r.j(&["describe \"before\""]).ok();
503+ let before = r.operation_heads();
504+ for entry in ["{ path = ./x, content = 1 }", "{ path = ./x }"] {
505+ let edit = format!("mapRoot (\\c -> c {{ files = [{}] }})", entry);
506+ let dry = r.j(&[&format!("show . validate . {}", edit)]);
507+ let persist = r.j(&[&edit]);
508+ assert_eq!(dry.code, 1, "validate accepted {}: {}", entry, dry.stdout);
509+ assert_eq!(persist.code, 1, "persistence accepted {}", entry);
510+ assert_eq!(dry.stderr.lines().next(), persist.stderr.lines().next());
511+ assert!(dry.stderr.contains("content"), "{}", dry.stderr);
512+ assert_eq!(r.operation_heads(), before);
513+ assert_eq!(r.read("kept"), "original\n");
514+ assert!(!r.dir.join("x").exists());
515+ }
516+}
517+
518+#[test]
519+fn validate_and_persistence_reject_nontext_commit_messages() {
520+ let r = setup();
521+ r.write("kept", "original\n");
522+ r.j(&["describe \"parent\""]).ok();
523+ r.j(&["new"]).ok();
524+ r.j(&["describe \"focus\""]).ok();
525+ let before = r.operation_heads();
526+ for target in ["here", "parents"] {
527+ for message in ["1", "[]", "true"] {
528+ let edit = format!("at {} (mapRoot (\\c -> c {{ message = {} }}))", target, message);
529+ let dry = r.j(&[&format!("show . validate . {}", edit)]);
530+ let persist = r.j(&[&edit]);
531+ assert_eq!(dry.code, 1, "validate accepted {}: {}", edit, dry.stdout);
532+ assert_eq!(persist.code, 1, "persistence accepted {}", edit);
533+ assert_eq!(dry.stderr.lines().next(), persist.stderr.lines().next());
534+ assert!(dry.stderr.contains("expected Text"), "{}", dry.stderr);
535+ assert_eq!(r.operation_heads(), before);
536+ assert_eq!(r.read("kept"), "original\n");
537+ }
538+ }
422539}
423540
424541#[test]
@@ -604,8 +721,8 @@ fn text_result_prints_raw() {
604721
605722#[test]
606723fn review_says_no_changes_only_when_nothing_changed() {
607− // `review` caught every crash of `diffs` with `or`, so without difft on
608− // PATH it printed "no changes" over a change it could not render
724+ // A missing renderer must still display changes; an installed renderer's
725+ // failures must remain visible instead of claiming "no changes".
609726 let r = setup();
610727 // a PATH holding nothing but what the test puts there (the config
611728 // directory is removed with the repository)
@@ -630,8 +747,9 @@ fn review_says_no_changes_only_when_nothing_changed() {
630747 assert_eq!(review(&bin).ok().stdout, "no changes\n");
631748 r.write("f", "hello\n");
632749 let out = review(&bin);
633− assert_eq!(out.code, 1, "stdout: {}", out.stdout);
634− assert!(out.stderr.contains("cannot execute `difft`"), "{}", out.stderr);
750+ assert_eq!(out.code, 0, "{}", out.stderr);
751+ assert_eq!(out.stdout, "--- a/f\n+++ b/f\n@@ -0,0 +1 @@\n+hello\n");
752+ assert!(out.stderr.is_empty(), "{}", out.stderr);
635753 use std::os::unix::fs::PermissionsExt;
636754 // a difft that runs and fails renders what it printed, nothing here,
637755 // and its complaint reaches the terminal instead of being dropped (§7.10)
@@ -649,7 +767,7 @@ fn review_says_no_changes_only_when_nothing_changed() {
649767 std::fs::write(&stub, "#!/bin/sh\nprintf '%s -> %s\\n' \"${1##*/}\" \"${2##*/}\"\n").unwrap();
650768 std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
651769 r.write("g", "world\n");
652− assert_eq!(review(&bin).ok().stdout, "old-f -> new-f\nold-g -> new-g\n");
770+ assert_eq!(review(&bin).ok().stdout, "f -> f\ng -> g\n");
653771}
654772
655773#[test]
@@ -3233,6 +3351,92 @@ fn init_refuses_inside_existing_repo() {
32333351 assert!(out.stderr.contains("already"), "{}", out.stderr);
32343352}
32353353
3354+#[test]
3355+fn show_files_roundtrips_binary_and_typed_blobs() {
3356+ let r = setup();
3357+ let bytes: Vec<u8> = (0..=255).collect();
3358+ std::fs::write(r.dir.join("binary"), &bytes).unwrap();
3359+ #[cfg(unix)]
3360+ {
3361+ use std::os::unix::fs::PermissionsExt;
3362+ r.write("executable", "#!/bin/sh\necho preserved\n");
3363+ std::fs::set_permissions(r.dir.join("executable"), std::fs::Permissions::from_mode(0o755)).unwrap();
3364+ std::os::unix::fs::symlink("binary", r.dir.join("link")).unwrap();
3365+ }
3366+
3367+ let before = r.operation_heads();
3368+ let shown = r.j(&["show . files"]).ok().stdout;
3369+ let equal = format!("\\r -> files r == ({})", shown);
3370+ assert_eq!(r.j(&[&equal]).ok().stdout.trim(), "true");
3371+ assert_eq!(r.operation_heads(), before);
3372+
3373+ // Check out an empty child, then reconstruct its files from the shown
3374+ // expression so the backend must write the decoded bytes and kinds.
3375+ r.j(&["mapRoot (\\c -> c { files = [] }) . new . describe \"source\""]).ok();
3376+ assert!(!r.dir.join("binary").exists());
3377+ #[cfg(unix)]
3378+ {
3379+ assert!(!r.dir.join("executable").exists());
3380+ assert!(r.dir.join("link").symlink_metadata().is_err());
3381+ }
3382+ let restore = format!("mapRoot (\\c -> c {{ files = {}, message = \"restored\" }})", shown);
3383+ r.j(&[&restore]).ok();
3384+ assert_eq!(std::fs::read(r.dir.join("binary")).unwrap(), bytes);
3385+ #[cfg(unix)]
3386+ {
3387+ use std::os::unix::fs::PermissionsExt;
3388+ assert_eq!(r.read("executable"), "#!/bin/sh\necho preserved\n");
3389+ assert_ne!(std::fs::metadata(r.dir.join("executable")).unwrap().permissions().mode() & 0o111, 0);
3390+ assert!(r.dir.join("link").symlink_metadata().unwrap().is_symlink());
3391+ assert_eq!(std::fs::read_link(r.dir.join("link")).unwrap(), PathBuf::from("binary"));
3392+ }
3393+ assert_eq!(r.j(&[&equal]).ok().stdout.trim(), "true");
3394+}
3395+
3396+#[test]
3397+fn invalid_utf8_config_is_preserved() {
3398+ let r = setup();
3399+ let cfg = r.cfg.join("j/config.j");
3400+ let original = b"-- Custom config with a Latin-1 comment: caf\xe9\n";
3401+ std::fs::write(&cfg, original).unwrap();
3402+ let before = r.operation_heads();
3403+
3404+ let out = r.j(&["describe \"must not run\""]);
3405+ assert_eq!(out.code, 3, "{}", out.stderr);
3406+ assert!(out.stderr.contains("cannot read config"), "{}", out.stderr);
3407+ assert!(!out.stderr.contains("created an editable default config"), "{}", out.stderr);
3408+ assert_eq!(std::fs::read(&cfg).unwrap(), original);
3409+ assert_eq!(r.operation_heads(), before);
3410+}
3411+
3412+#[test]
3413+fn user_identity_control_characters_do_not_panic() {
3414+ let temp = tempfile::tempdir().unwrap();
3415+ let r = Repo { dir: temp.path().join("repo"), cfg: temp.path().join("config") };
3416+ std::fs::create_dir(&r.dir).unwrap();
3417+ std::fs::create_dir_all(r.cfg.join("j")).unwrap();
3418+ // Git signatures exclude CR/LF; exercise every other ASCII control
3419+ // character, including those the old TOML builder failed to escape.
3420+ let controls: String = (0u8..=31).chain([127])
3421+ .filter(|c| !matches!(c, b'\r' | b'\n')).map(char::from).collect();
3422+ let name = format!("A{}\\\"éZ", controls);
3423+ let email = format!("e{}\\\"é@example.com", controls);
3424+ let name_literal = j::show::text_literal(&name);
3425+ let email_literal = j::show::text_literal(&email);
3426+ let config = include_str!("../config.j")
3427+ .replace("\"Your Name\"", &name_literal)
3428+ .replace("\"you@example.com\"", &email_literal);
3429+ std::fs::write(r.cfg.join("j/config.j"), config).unwrap();
3430+
3431+ // init uses strict identity loading; subsequent commands open an
3432+ // existing workspace. Both used to parse hand-built, invalid TOML.
3433+ r.j(&["init"]).ok();
3434+ assert_eq!(r.j(&["1"]).ok().stdout, "1\n");
3435+ r.j(&["describe \"identity preserved\""]).ok();
3436+ assert_eq!(r.j(&["show user.name"]).ok().stdout, format!("{}\n", name_literal));
3437+ assert_eq!(r.j(&["show user.email"]).ok().stdout, format!("{}\n", email_literal));
3438+}
3439+
32363440#[test]
32373441fn missing_config_is_created_editable() {
32383442 let dir = std::env::temp_dir().join(format!(
@@ -3259,7 +3463,7 @@ fn missing_config_is_created_editable() {
32593463 // the config now exists at the user path and is editable by the owner
32603464 let cfg = cfg_home.join("j/config.j");
32613465 let written = std::fs::read_to_string(&cfg).expect("config was created");
3262− assert!(written.contains("treeWith"), "default config content");
3466+ assert_eq!(written, include_str!("../config.j"));
32633467 #[cfg(unix)]
32643468 {
32653469 use std::os::unix::fs::PermissionsExt;
@@ -3268,6 +3472,33 @@ fn missing_config_is_created_editable() {
32683472 let _ = std::fs::remove_dir_all(&dir);
32693473}
32703474
3475+#[test]
3476+fn unreadable_config_is_not_replaced_with_defaults() {
3477+ let r = setup();
3478+ let path = r.cfg.join("j/config.j");
3479+ let mut original = std::fs::read(&path).unwrap();
3480+ original.extend_from_slice(b"\n-- non-UTF-8 comment: \xff\n");
3481+ std::fs::write(&path, &original).unwrap();
3482+ let out = r.j(&["1 + 1"]);
3483+ assert_eq!(out.code, 3, "{}", out.stderr);
3484+ assert!(out.stderr.starts_with("j: cannot read config at "), "{}", out.stderr);
3485+ assert!(out.stdout.is_empty());
3486+ assert_eq!(std::fs::read(&path).unwrap(), original);
3487+}
3488+
3489+#[test]
3490+fn missing_config_symlink_target_is_not_created() {
3491+ let r = setup();
3492+ let path = r.cfg.join("j/config.j");
3493+ let target = r.cfg.join("missing-config.j");
3494+ std::fs::remove_file(&path).unwrap();
3495+ std::os::unix::fs::symlink(&target, &path).unwrap();
3496+ let out = r.j(&["1 + 1"]);
3497+ assert_eq!(out.code, 3, "{}", out.stderr);
3498+ assert!(std::fs::symlink_metadata(&path).unwrap().file_type().is_symlink());
3499+ assert!(!target.exists());
3500+}
3501+
32713502// ----------------------------------------------------------------------
32723503// regressions
32733504// ----------------------------------------------------------------------
tests/difft.rsnew file+74
@@ -0,0 +1,74 @@
1+//! Difftastic receives unchanged basenames in isolated temporary directories.
2+#![cfg(unix)]
3+
4+use std::os::unix::fs::PermissionsExt;
5+use std::path::PathBuf;
6+use std::process::{Command, Output};
7+
8+#[test]
9+fn difft_preserves_basenames_and_cleans_up_temporary_files() {
10+ let temp = tempfile::tempdir().unwrap();
11+ let repo = temp.path().join("repo");
12+ let config = temp.path().join("config");
13+ let bin = temp.path().join("bin");
14+ std::fs::create_dir(&repo).unwrap();
15+ std::fs::create_dir_all(config.join("j")).unwrap();
16+ std::fs::create_dir(&bin).unwrap();
17+ std::fs::write(
18+ config.join("j/config.j"),
19+ include_str!("../config.j")
20+ .replace("Your Name", "Test User")
21+ .replace("you@example.com", "test@example.com"),
22+ ).unwrap();
23+ let stub = bin.join("difft");
24+ std::fs::write(&stub, "#!/bin/sh\nIFS= read -r old < \"$1\"\nIFS= read -r new < \"$2\"\nprintf '%s\\n' \"$1\" \"$2\" \"$old\" \"$new\"\n").unwrap();
25+ std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
26+ let run = |expression: &str| -> Output {
27+ Command::new(env!("CARGO_BIN_EXE_j"))
28+ .arg(expression)
29+ .current_dir(&repo)
30+ .env("XDG_CONFIG_HOME", &config)
31+ .env("PATH", &bin)
32+ .env("NO_COLOR", "1")
33+ .output()
34+ .unwrap()
35+ };
36+ let init = run("init");
37+ assert!(init.status.success(), "{}", String::from_utf8_lossy(&init.stderr));
38+
39+ let longest = format!("{}.rs", "a".repeat(252)); // NAME_MAX, including suffix
40+ for (path, basename) in [
41+ (format!("[{}]", j::show::text_literal(&longest)), longest.as_str()),
42+ ("[\"src\" \"main.rs\"]".to_string(), "main.rs"),
43+ ("[\"Makefile\"]".to_string(), "Makefile"),
44+ ("[\"file with spaces.rs\"]".to_string(), "file with spaces.rs"),
45+ ("[]".to_string(), "file"),
46+ ] {
47+ let result = run(&format!("difft {} (blob \"old\\n\") (blob \"new\\n\")", path));
48+ assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr));
49+ let output = String::from_utf8(result.stdout).unwrap();
50+ let lines: Vec<&str> = output.lines().collect();
51+ assert_eq!(lines.len(), 4, "{output}");
52+ assert_eq!(&lines[2..], &["old", "new"]);
53+ let old = PathBuf::from(lines[0]);
54+ let new = PathBuf::from(lines[1]);
55+ assert_eq!(old.file_name().unwrap(), basename);
56+ assert_eq!(new.file_name().unwrap(), basename);
57+ assert_eq!(old.parent().unwrap().file_name().unwrap(), "old");
58+ assert_eq!(new.parent().unwrap().file_name().unwrap(), "new");
59+ assert_ne!(old.parent(), new.parent());
60+ assert_eq!(old.parent().unwrap().parent(), new.parent().unwrap().parent());
61+ assert!(!old.parent().unwrap().parent().unwrap().exists(), "temporary directory leaked");
62+ }
63+
64+ // Paths are structural lists at run time. A malformed last component
65+ // must not let `join` put temporary blob contents outside their directory.
66+ let escaped = temp.path().join("escaped");
67+ for name in ["", ".", "..", "../escaped", escaped.to_str().unwrap()] {
68+ let expr = format!("difft [{}] (blob \"before\") (blob \"after\")", j::show::text_literal(name));
69+ let result = run(&expr);
70+ assert_eq!(result.status.code(), Some(1));
71+ assert!(String::from_utf8_lossy(&result.stderr).contains("last path component"));
72+ }
73+ assert!(!escaped.exists());
74+}
tests/display.rs+248−1
@@ -95,6 +95,16 @@ fn make_interp(be: MemBackend) -> (Interp, config::Config) {
9595}
9696
9797fn eval_and_display(interp: &mut Interp, cfg: &config::Config, src: &str, repo: Value) -> String {
98+ eval_and_display_width(interp, cfg, src, repo, None)
99+}
100+
101+fn eval_and_display_width(
102+ interp: &mut Interp,
103+ cfg: &config::Config,
104+ src: &str,
105+ repo: Value,
106+ terminal: Option<usize>,
107+) -> String {
98108 let outer = Rc::new(cfg.global_names.clone());
99109 let e = parse_expr(src, outer).unwrap();
100110 let e = config::resolve_ids(&e, interp).unwrap();
@@ -106,7 +116,70 @@ fn eval_and_display(interp: &mut Interp, cfg: &config::Config, src: &str, repo:
106116 } else {
107117 v
108118 };
109− j::render::display(interp, &v, false).unwrap()
119+ j::render::display_with_width(interp, &v, false, terminal).unwrap()
120+}
121+
122+#[test]
123+fn custom_aliases_do_not_displace_special_display_shapes() {
124+ let names = ["Commit", "Entry", "Subtree", "Repo", "Frame", "Change"];
125+ let mut source = CONFIG.to_string();
126+ // Deliberately choose an alias-first map order for every display shape.
127+ // Rendering is tested once against the normal config, not repeatedly
128+ // until randomized HashMap iteration happens to expose the old bug.
129+ let cfg = (0..128).find_map(|n| {
130+ for name in names {
131+ source.push_str(&format!("\nAlias{}{} = {}\n", name, n, name));
132+ }
133+ let cfg = config::load_config(&source).unwrap();
134+ let aliases_first = names.iter().all(|name| {
135+ let kind = cfg.shapes.shape_of(name).unwrap().kind;
136+ cfg.shapes.decls.keys().find(|candidate| {
137+ cfg.shapes.shape_of(candidate).is_some_and(|shape| shape.kind == kind)
138+ }).is_some_and(|first| first != name)
139+ });
140+ aliases_first.then_some(cfg)
141+ }).expect("could not construct alias-first declarations");
142+ let (repo, backend) = sample_repo();
143+ let mut aliased = Interp::new(Rc::new(backend), cfg.shapes.clone(), Env::empty());
144+ config::eval_config(&mut aliased, &cfg).unwrap();
145+ let (_, backend) = sample_repo();
146+ let (mut normal, normal_cfg) = make_interp(backend);
147+ for expression in [
148+ "focus",
149+ "head . files",
150+ "files",
151+ "\\r -> { root = r.root, children = r.children }",
152+ "id",
153+ "head . (.context)",
154+ "changeOf",
155+ "\\r -> { commit = r.root, entry = head r.root.files, repo = r, frame = head r.context, change = changeOf r }",
156+ ] {
157+ let expected = eval_and_display(&mut normal, &normal_cfg, expression, repo.clone());
158+ let actual = eval_and_display(&mut aliased, &cfg, expression, repo.clone());
159+ assert_eq!(actual, expected, "{}", expression);
160+ }
161+}
162+
163+#[test]
164+fn display_shapes_still_resolve_configured_aliases_and_field_sets() {
165+ let source = CONFIG.replace(
166+ "Entry = { path : Path, content : Blob }",
167+ "Entry = FileEntry\nFileEntry = { path : Path, content : Blob, note : Text }",
168+ );
169+ let cfg = config::load_config(&source).unwrap();
170+ let (repo, backend) = sample_repo();
171+ let mut interp = Interp::new(Rc::new(backend), cfg.shapes.clone(), Env::empty());
172+ config::eval_config(&mut interp, &cfg).unwrap();
173+ let out = eval_and_display(
174+ &mut interp, &cfg,
175+ "{ path = ./file, content = blob \"hello\", note = \"metadata\" }", repo.clone(),
176+ );
177+ assert_eq!(out, " file 5 B\n");
178+ // The original two-field record no longer has the configured Entry shape.
179+ let out = eval_and_display(
180+ &mut interp, &cfg, "{ path = ./file, content = blob \"hello\" }", repo,
181+ );
182+ assert!(out.starts_with("content ‹5 B›\n"), "{}", out);
110183}
111184
112185#[test]
@@ -190,6 +263,57 @@ fn text_of_blob() {
190263 assert_eq!(out, "fn lex() {}\n");
191264}
192265
266+#[test]
267+fn colour_preserves_raw_text_and_blob_contents() {
268+ let (_, be) = sample_repo();
269+ let (mut i, _) = make_interp(be);
270+ let raw = "true 42 @wqzt\n日本語\n\x1b[31mexisting style\x1b[0m\n";
271+ for value in [Value::text(raw), BlobVal::text_blob(raw)] {
272+ assert_eq!(j::render::display(&mut i, &value, true).unwrap(), raw);
273+ }
274+}
275+
276+#[test]
277+fn colour_preserves_structured_text_and_alignment() {
278+ fn plain(s: &str) -> String {
279+ let mut chars = s.chars();
280+ let mut out = String::new();
281+ while let Some(c) = chars.next() {
282+ if c == '\x1b' {
283+ for c in chars.by_ref() {
284+ if c == 'm' { break; }
285+ }
286+ } else {
287+ out.push(c);
288+ }
289+ }
290+ out
291+ }
292+
293+ let (repo, be) = sample_repo();
294+ let (mut i, _) = make_interp(be);
295+ *i.old_repo.borrow_mut() = Some(repo.clone());
296+ let rows = Value::list(vec![
297+ Value::record(&[("name", Value::text("日本語")), ("n", Value::int(42)), ("ok", Value::bool(true))]),
298+ Value::record(&[("name", Value::text("e\u{301}")), ("n", Value::int(-1)), ("ok", Value::bool(false))]),
299+ ]);
300+ for value in [
301+ rows.clone(),
302+ Value::record(&[("rows", rows), ("value", Value::int(7))]),
303+ repo.field("root").unwrap(),
304+ repo.field("root").unwrap().field("files").unwrap(),
305+ Value::list(vec![Value::int(1), Value::bool(true), Value::bool(false)]),
306+ ] {
307+ let styled = j::render::display(&mut i, &value, true).unwrap();
308+ let unstyled = j::render::display(&mut i, &value, false).unwrap();
309+ assert!(styled.contains('\x1b'));
310+ assert_eq!(plain(&styled), unstyled);
311+ for (styled_line, plain_line) in styled.lines().zip(unstyled.lines()) {
312+ assert_eq!(j::render::width(styled_line), j::render::width(plain_line));
313+ }
314+ }
315+}
316+
193317#[test]
194318fn dry_run_tree_after_squash() {
195319 let (repo, be) = sample_repo();
@@ -1001,3 +1125,126 @@ fn an_unreadable_blob_crashes_every_time() {
10011125 let src = "(\\repo -> show (contentAt [\"f.txt\"] (files repo)) or \"caught\")";
10021126 assert_eq!(try_eval_and_display(&mut i, &cfg, src, repo), Ok("caught\n".to_string()));
10031127}
1128+
1129+
1130+#[test]
1131+fn log_shows_label_names_and_copyable_unique_ids() {
1132+ let (repo, be) = sample_repo();
1133+ let (mut i, cfg) = make_interp(be);
1134+ let out = eval_and_display(&mut i, &cfg, "log", repo);
1135+ for expected in ["main", "feature", "@kpqx", "@wqzt", "@zzzz"] {
1136+ assert!(out.contains(expected), "missing {expected}: {out}");
1137+ }
1138+ assert!(!out.contains("items"), "{out}");
1139+ assert!(!out.contains("wqztbbbb"), "{out}");
1140+
1141+ // The backend may not yet know IDs created in an expression. The table
1142+ // itself must still distinguish shared prefixes (and duplicate rows).
1143+ let table = Value::list(vec![
1144+ Value::record(&[("id", Value::Id(Rc::new("abcd1111".into())))]),
1145+ Value::record(&[("id", Value::Id(Rc::new("abcd2222".into())))]),
1146+ Value::record(&[("id", Value::Id(Rc::new("abcd1111".into())))]),
1147+ ]);
1148+ let out = j::render::display(&mut i, &table, false).unwrap();
1149+ assert!(out.contains("@abcd1") && out.contains("@abcd2"), "{out}");
1150+}
1151+
1152+#[test]
1153+fn status_empty_states_are_readable_without_changing_values() {
1154+ let (repo, be) = sample_repo();
1155+ let (mut i, cfg) = make_interp(be);
1156+ let expression = "status . describe \"\" . new";
1157+ let out = eval_and_display(&mut i, &cfg, expression, repo.clone());
1158+ for expected in ["No changes", "No conflicts", "No labels", "No description", "All edits recorded"] {
1159+ assert!(out.contains(expected), "missing {expected}: {out}");
1160+ }
1161+ let literal = eval_and_display(&mut i, &cfg, &format!("show . ({expression})"), repo.clone());
1162+ assert!(!literal.contains("No changes"), "{literal}");
1163+ let generic = eval_and_display(&mut i, &cfg, "{ changed = [], message = \"\" }", repo);
1164+ assert!(generic.contains("0 items") && !generic.contains("No description"), "{generic}");
1165+}
1166+
1167+#[test]
1168+fn tables_fit_terminal_width_but_pipes_retain_full_messages() {
1169+ let (repo, be) = sample_repo();
1170+ let (mut i, cfg) = make_interp(be);
1171+ let message = "Make authentication easier for keyboard users across all screens";
1172+ let expression = format!("log . describe \"{message}\"");
1173+ let pipe = eval_and_display(&mut i, &cfg, &expression, repo.clone());
1174+ assert!(pipe.contains(message), "{pipe}");
1175+ let terminal = eval_and_display_width(&mut i, &cfg, &expression, repo.clone(), Some(48));
1176+ assert!(terminal.lines().all(|line| j::render::width(line) <= 48), "{terminal}");
1177+ assert!(terminal.contains('…') && terminal.contains("@wqzt"), "{terminal}");
1178+ assert!(terminal.contains("feature"), "{terminal}");
1179+
1180+ // The vertical fallback keeps all text when headers and IDs cannot fit
1181+ // side by side; reconstruct wrapped rows to check that no text was lost.
1182+ let narrow = eval_and_display_width(&mut i, &cfg, &expression, repo, Some(12));
1183+ assert!(narrow.lines().all(|line| j::render::width(line) <= 12), "{narrow}");
1184+ assert!(narrow.lines().collect::<String>().contains(message), "{narrow}");
1185+ assert!(narrow.contains("id: @wqzt"), "{narrow}");
1186+}
1187+
1188+#[test]
1189+fn narrow_tables_preserve_unicode_and_close_ansi_styles() {
1190+ let (_, be) = sample_repo();
1191+ let (mut i, _) = make_interp(be);
1192+ let value = Value::list(vec![Value::record(&[
1193+ ("description", Value::text("键盘支持e\u{301}完成")),
1194+ ("identifier", Value::Id(Rc::new("wqztbbbb".into()))),
1195+ ])]);
1196+ let plain = j::render::display_with_width(&mut i, &value, false, Some(12)).unwrap();
1197+ let styled = j::render::display_with_width(&mut i, &value, true, Some(12)).unwrap();
1198+ assert!(plain.lines().all(|line| j::render::width(line) <= 12), "{plain}");
1199+ assert!(plain.lines().collect::<String>().contains("键盘支持e\u{301}完成"), "{plain}");
1200+ let mut escape = false;
1201+ let unstyled: String = styled.chars().filter(|&c| {
1202+ if c == '\x1b' { escape = true; return false; }
1203+ if escape { if c == 'm' { escape = false; } return false; }
1204+ true
1205+ }).collect();
1206+ assert_eq!(unstyled, plain);
1207+}
1208+
1209+
1210+#[test]
1211+fn nested_tables_keep_parent_context_without_exhausting_narrow_width() {
1212+ let (_, be) = sample_repo();
1213+ let (mut i, _) = make_interp(be);
1214+ let heading = "a_long_parent_field_for_the_rows";
1215+ let message = "Keyboard navigation is available";
1216+ let rows = Value::list(vec![Value::record(&[
1217+ ("name", Value::text("preferences")),
1218+ ("message", Value::text(message)),
1219+ ])]);
1220+ let value = Value::record(&[(heading, rows)]);
1221+ let output = j::render::display_with_width(&mut i, &value, false, Some(12)).unwrap();
1222+ let mut lines = output.lines();
1223+ assert_eq!(lines.next().unwrap().trim_end(), heading, "{output}");
1224+ let contents: Vec<&str> = lines.collect();
1225+ assert!(contents.iter().all(|line| line.starts_with(" ")), "{output}");
1226+ assert!(contents.iter().all(|line| j::render::width(line) <= 12), "{output}");
1227+ let reconstructed: String = contents.iter().map(|line| &line[2..]).collect();
1228+ assert!(reconstructed.contains(&format!("message: {message}")), "{output}");
1229+ assert!(reconstructed.contains("name: preferences"), "{output}");
1230+
1231+ let pipe = j::render::display_with_width(&mut i, &value, false, None).unwrap();
1232+ assert!(pipe.contains(message), "{pipe}");
1233+ assert!(pipe.lines().skip(1).all(|line| line.starts_with(&" ".repeat(heading.len() + 2))), "{pipe}");
1234+}
1235+
1236+#[test]
1237+fn long_label_cells_truncate_on_terminals_and_remain_complete_in_pipes() {
1238+ let (_, be) = sample_repo();
1239+ let (mut i, _) = make_interp(be);
1240+ let label = "release/very-long-descriptive-bookmark-for-keyboard-navigation";
1241+ let value = Value::list(vec![Value::record(&[
1242+ ("id", Value::Id(Rc::new("abcd1111".into()))),
1243+ ("labels", Value::list(vec![Value::text(label)])),
1244+ ])]);
1245+ let tty = j::render::display_with_width(&mut i, &value, false, Some(24)).unwrap();
1246+ assert!(tty.lines().all(|line| j::render::width(line) <= 24), "{tty}");
1247+ assert!(tty.contains("@abcd") && tty.contains('…'), "{tty}");
1248+ let pipe = j::render::display_with_width(&mut i, &value, false, None).unwrap();
1249+ assert!(pipe.contains(label) && !pipe.contains('…'), "{pipe}");
1250+}
tests/env_lifetimes.rsnew file+215
@@ -0,0 +1,215 @@
1+//! Recursive bindings retain escaping closures, but not unreachable cycles.
2+
3+use j::config;
4+use j::domain::MemBackend;
5+use j::eval::Interp;
6+use j::parse::parse_expr;
7+use j::value::{Crash, Env, FunVal, Value};
8+use std::cell::RefCell;
9+use std::rc::{Rc, Weak};
10+
11+thread_local! {
12+ static OBSERVED: RefCell<Vec<Weak<FunVal>>> = const { RefCell::new(Vec::new()) };
13+ static PEAK_ALIVE: RefCell<usize> = const { RefCell::new(0) };
14+}
15+
16+fn observe(_: &mut Interp, args: &[Value]) -> Result<Value, Crash> {
17+ let Value::Fun(f) = &args[0] else { panic!("observe needs a function") };
18+ OBSERVED.with(|observed| {
19+ let mut observed = observed.borrow_mut();
20+ observed.push(Rc::downgrade(f));
21+ let alive = observed.iter().filter(|f| f.strong_count() != 0).count();
22+ PEAK_ALIVE.with(|peak| {
23+ let mut peak = peak.borrow_mut();
24+ *peak = (*peak).max(alive);
25+ });
26+ });
27+ Ok(args[0].clone())
28+}
29+
30+fn interpreter() -> (Interp, config::Config) {
31+ OBSERVED.with(|observed| observed.borrow_mut().clear());
32+ PEAK_ALIVE.with(|peak| *peak.borrow_mut() = 0);
33+ let cfg = config::load_config(include_str!("../config.j")).unwrap();
34+ let mut interp = Interp::new(Rc::new(MemBackend::new()), cfg.shapes.clone(), Env::empty());
35+ config::eval_config(&mut interp, &cfg).unwrap();
36+ interp.globals = interp.globals.extend(vec![("observe".into(), Value::Fun(Rc::new(FunVal::Builtin {
37+ name: "observe".into(), arity: 1, args: vec![], f: observe, pending: None,
38+ })))]);
39+ (interp, cfg)
40+}
41+
42+fn eval(interp: &mut Interp, cfg: &config::Config, src: &str) -> Value {
43+ let expr = parse_expr(src, Rc::new(cfg.global_names.clone())).unwrap();
44+ interp.eval(&Rc::new(expr), &interp.global_env()).unwrap()
45+}
46+
47+fn alive() -> usize {
48+ OBSERVED.with(|observed| observed.borrow().iter().filter(|f| f.strong_count() != 0).count())
49+}
50+
51+#[test]
52+fn unreachable_local_closures_and_their_payloads_are_reclaimed() {
53+ let (mut interp, cfg) = interpreter();
54+ let function = eval(&mut interp, &cfg, "let helper = observe (\\x -> x) in helper");
55+ assert_eq!(alive(), 1);
56+ drop(function);
57+ eval(&mut interp, &cfg, "0");
58+ assert_eq!(alive(), 0);
59+
60+ // A recursive helper captures the block's shared frame, including a
61+ // payload such as a large snapshot, until the dead cycle is collected.
62+ eval(&mut interp, &cfg,
63+ "let payload = range 0 10000; helper = observe (\\x -> \
64+ if x == 0 then payload else helper (x - 1)) in 0");
65+ assert_eq!(alive(), 0);
66+}
67+
68+#[test]
69+fn mutually_recursive_closures_escape_inside_shared_records_and_lists() {
70+ let (mut interp, cfg) = interpreter();
71+ let bundle = eval(&mut interp, &cfg,
72+ "let evenLocal = observe (\\n -> if n == 0 then true else \
73+ let scratch = observe (\\v -> v) in oddLocal (n - 1)); \
74+ oddLocal = observe (\\n -> if n == 0 then false else evenLocal (n - 1)) \
75+ in { callbacks = [evenLocal oddLocal evenLocal] }");
76+ eval(&mut interp, &cfg, "0");
77+ let callbacks = bundle.field("callbacks").unwrap();
78+ let function = callbacks.as_list().unwrap()[0].clone();
79+ drop(bundle);
80+ drop(callbacks);
81+ assert!(matches!(interp.apply(function.clone(), Value::int(2048)).unwrap(), Value::Bool(true)));
82+ assert_eq!(alive(), 2);
83+ drop(function);
84+ eval(&mut interp, &cfg, "0");
85+ assert_eq!(alive(), 0);
86+}
87+
88+#[test]
89+fn nested_captures_and_partially_applied_functions_keep_their_scopes() {
90+ let (mut interp, cfg) = interpreter();
91+ let function = eval(&mut interp, &cfg,
92+ "let maker = \\n -> let recurse = observe (\\m -> \
93+ if m == 0 then n else recurse (m - 1)) \
94+ in \\addend -> recurse 1000 + addend in maker 40");
95+ eval(&mut interp, &cfg, "0");
96+ assert!(matches!(interp.apply(function.clone(), Value::int(2)).unwrap(), Value::Int(n) if n == 42.into()));
97+ drop(function);
98+ eval(&mut interp, &cfg, "0");
99+ assert_eq!(alive(), 0);
100+
101+ let partial = eval(&mut interp, &cfg,
102+ "let helper = observe (\\a b -> a + b) in helper 40");
103+ eval(&mut interp, &cfg, "0");
104+ assert!(matches!(interp.apply(partial.clone(), Value::int(2)).unwrap(), Value::Int(n) if n == 42.into()));
105+ drop(partial);
106+ eval(&mut interp, &cfg, "0");
107+ assert_eq!(alive(), 0);
108+}
109+
110+#[test]
111+fn wrappers_and_list_views_preserve_all_owned_references() {
112+ let (mut interp, cfg) = interpreter();
113+ for expression in [
114+ "let helper = observe (\\x -> x); wrapper = helper . helper in wrapper",
115+ "let helper = observe (\\x -> x); wrapper = helper or helper in wrapper",
116+ "let helper = observe (\\x -> x); wrapper = const helper in wrapper 0",
117+ "let bundle = { callbacks = [(observe (\\n -> if n == 0 then 7 else \
118+ (head bundle.callbacks) (n - 1)))] } in head bundle.callbacks",
119+ ] {
120+ let function = eval(&mut interp, &cfg, expression);
121+ eval(&mut interp, &cfg, "0");
122+ interp.apply(function.clone(), Value::int(7)).unwrap();
123+ assert!(alive() > 0);
124+ drop(function);
125+ eval(&mut interp, &cfg, "0");
126+ assert_eq!(alive(), 0, "{expression}");
127+ }
128+
129+ let empty_view = eval(&mut interp, &cfg,
130+ "let helper = observe (\\x -> x); xs = [helper helper] in drop 2 xs");
131+ assert!(empty_view.as_list().unwrap().is_empty());
132+ eval(&mut interp, &cfg, "0");
133+ // An empty view still owns the two hidden entries in its backing Vec.
134+ assert_eq!(alive(), 1);
135+ drop(empty_view);
136+ eval(&mut interp, &cfg, "0");
137+ assert_eq!(alive(), 0);
138+}
139+
140+#[test]
141+fn long_running_tail_calls_collect_dead_local_bindings_during_evaluation() {
142+ let (mut interp, cfg) = interpreter();
143+ // Acyclic helpers use immutable frames and need no cycle collection.
144+ // Make each local helper recursive so this exercises collection while
145+ // the enclosing tail-recursive evaluation is still running.
146+ let result = eval(&mut interp, &cfg,
147+ "let loop = \\n -> if n == 0 then 0 else \
148+ let helper = observe (\\x -> if x == 0 then 0 else helper (x - 1)); payload = range 0 32 \
149+ in loop (n - 1) in loop 4000");
150+ assert!(matches!(result, Value::Int(n) if n == 0.into()));
151+ assert_eq!(alive(), 0);
152+ PEAK_ALIVE.with(|peak| assert!(*peak.borrow() < 1024, "dead closures accumulate: {}", peak.borrow()));
153+}
154+
155+#[test]
156+fn crashes_and_caught_crashes_release_abandoned_recursive_scopes() {
157+ let (mut interp, cfg) = interpreter();
158+ eval(&mut interp, &cfg,
159+ "(let helper = observe (\\x -> x) in crash \"discard\") or 42");
160+ assert_eq!(alive(), 0);
161+ let expr = parse_expr("let helper = observe (\\x -> x) in crash \"discard\"",
162+ Rc::new(cfg.global_names.clone())).unwrap();
163+ assert!(interp.eval(&Rc::new(expr), &interp.global_env()).is_err());
164+ assert_eq!(alive(), 0);
165+}
166+
167+#[test]
168+fn dropping_the_interpreter_reclaims_its_global_recursive_environment() {
169+ let (interp, _) = interpreter();
170+ let Value::Fun(f) = interp.globals.lookup("new").unwrap() else { panic!() };
171+ let weak = Rc::downgrade(&f);
172+ drop(f);
173+ drop(interp);
174+ assert_eq!(weak.strong_count(), 0);
175+}
176+
177+#[test]
178+fn returned_closures_outlive_the_interpreter_that_created_them() {
179+ let (mut interp, cfg) = interpreter();
180+ let function = eval(&mut interp, &cfg,
181+ "let recurse = observe (\\n -> if n == 0 then 42 else recurse (n - 1)) in recurse");
182+ drop(interp);
183+ assert_eq!(alive(), 1);
184+ let mut other = Interp::dummy();
185+ assert!(matches!(other.apply(function.clone(), Value::int(1000)).unwrap(), Value::Int(n) if n == 42.into()));
186+ drop(function);
187+ drop(other);
188+ assert_eq!(alive(), 0);
189+}
190+
191+#[test]
192+fn collecting_deep_recursive_parent_chains_uses_a_bounded_native_stack() {
193+ std::thread::Builder::new().stack_size(2 * 1024 * 1024).spawn(|| {
194+ use j::ast::{Expr, Pattern, Source};
195+ let interp = Interp::dummy();
196+ let mut env = Env::empty();
197+ let source = Source::new(Rc::from("\\x -> true"), 0, 10);
198+ let body = Rc::new(Expr::Bool(true));
199+ let mut observed = Vec::new();
200+ for _ in 0..10000 {
201+ let (next, cell) = env.extend_rec(Rc::from(vec!["recursive".to_owned()]));
202+ let function = Rc::new(FunVal::Closure {
203+ name: None, params: vec![Pattern::Var("x".to_owned())],
204+ applied: 0, applied_args: vec![], body: body.clone(),
205+ env: next.clone(), src: source.clone(), pending: None,
206+ });
207+ observed.push(Rc::downgrade(&function));
208+ cell.borrow_mut().push(Value::Fun(function));
209+ env = next;
210+ }
211+ drop(env);
212+ drop(interp);
213+ assert!(observed.iter().all(|f| f.strong_count() == 0));
214+ }).unwrap().join().unwrap();
215+}
tests/eval.rs+146−3
@@ -10,10 +10,14 @@ use std::rc::Rc;
1010const CONFIG: &str = include_str!("../config.j");
1111
1212fn make_interp() -> (Interp, config::Config) {
13− let cfg = config::load_config(CONFIG).expect("reference config must load");
13+ make_interp_from(CONFIG)
14+}
15+
16+fn make_interp_from(src: &str) -> (Interp, config::Config) {
17+ let cfg = config::load_config(src).expect("config must load");
1418 let backend = Rc::new(MemBackend::new());
1519 let mut interp = Interp::new(backend, cfg.shapes.clone(), Env::empty());
16− config::eval_config(&mut interp, &cfg).expect("reference config must evaluate");
20+ config::eval_config(&mut interp, &cfg).expect("config must evaluate");
1721 (interp, cfg)
1822}
1923
@@ -127,6 +131,143 @@ fn short_circuit_booleans() {
127131 assert!(crash(&mut i, &cfg, "false || 1").contains("Bool"));
128132}
129133
134+fn boolean_config(op: &str, signature: Option<&str>) -> String {
135+ CONFIG
136+ .lines()
137+ .filter_map(|line| {
138+ if line.starts_with(&format!("({})", op)) {
139+ signature.map(|ty| format!("({}) : {}", op, ty))
140+ } else {
141+ Some(line.to_string())
142+ }
143+ })
144+ .collect::<Vec<_>>()
145+ .join("\n")
146+}
147+
148+#[test]
149+fn short_circuit_operators_must_be_declared() {
150+ for op in ["&&", "||"] {
151+ let (mut i, cfg) = make_interp_from(&boolean_config(op, None));
152+ for expr in [
153+ format!("false {} true", op),
154+ format!("true {} false", op),
155+ format!("(crash \"lhs evaluated\") {} true", op),
156+ format!("({}) true false", op),
157+ format!("(true {}) false", op),
158+ format!("({} false) true", op),
159+ ] {
160+ assert_eq!(crash(&mut i, &cfg, &expr), format!("crash: unbound name `{}`", op));
161+ check!(i, cfg, &format!("({}) or 7", expr), Value::int(7));
162+ }
163+ }
164+}
165+
166+#[test]
167+fn short_circuit_contracts_check_only_evaluated_arguments() {
168+ for (op, skipped, reached) in [("&&", false, true), ("||", true, false)] {
169+ let (mut i, cfg) = make_interp_from(&boolean_config(op, Some("Text -> Bool -> Bool")));
170+ for lhs in [skipped, reached] {
171+ let expr = format!("{} {} crash \"rhs must stay unevaluated\"", lhs, op);
172+ assert_eq!(
173+ crash(&mut i, &cfg, &expr),
174+ format!("crash: contract: {} expected Text as argument 1, got Bool", op)
175+ );
176+ }
177+
178+ let (mut i, cfg) = make_interp_from(&boolean_config(op, Some("Bool -> Text -> Bool")));
179+ for expr in [
180+ format!("{} {} true", reached, op),
181+ format!("({} {}) true", reached, op),
182+ format!("({} true) {}", op, reached),
183+ // First-class applications stay strict, even when the first
184+ // argument would short-circuit in an infix expression.
185+ format!("({}) {} true", op, reached),
186+ format!("({}) {} true", op, skipped),
187+ ] {
188+ assert_eq!(
189+ crash(&mut i, &cfg, &expr),
190+ format!("crash: contract: {} expected Text as argument 2, got Bool", op)
191+ );
192+ }
193+ for expr in [
194+ format!("{} {} crash \"rhs must stay unevaluated\"", skipped, op),
195+ format!("({} crash \"rhs must stay unevaluated\") {}", op, skipped),
196+ format!("({} {}) true", skipped, op),
197+ ] {
198+ check!(i, cfg, &expr, Value::Bool(skipped));
199+ }
200+ // A declaration that allows every kind cannot remove the
201+ // operator's own requirement that its operands are booleans.
202+ let (mut i, cfg) = make_interp_from(&boolean_config(op, Some("a -> b -> c")));
203+ assert!(crash(&mut i, &cfg, &format!("1 {} true", op)).contains("expected Bool"));
204+ assert!(crash(&mut i, &cfg, &format!("{} {} 1", reached, op)).contains("expected Bool"));
205+ }
206+}
207+
208+#[test]
209+fn short_circuit_contracts_check_results_including_aliases() {
210+ for op in ["&&", "||"] {
211+ for signature in ["Bool -> Bool -> Text", "Switch"] {
212+ let src = format!(
213+ "{}\nSwitch = Bool -> Outcome\nOutcome = Bool -> Text\n",
214+ boolean_config(op, Some(signature))
215+ );
216+ let (mut i, cfg) = make_interp_from(&src);
217+ for lhs in [true, false] {
218+ for expr in [
219+ format!("{} {} true", lhs, op),
220+ format!("({} {}) true", lhs, op),
221+ format!("({} true) {}", op, lhs),
222+ format!("({}) {} true", op, lhs),
223+ ] {
224+ assert_eq!(
225+ crash(&mut i, &cfg, &expr),
226+ format!("crash: contract: {}: result expected Text, got Bool", op)
227+ );
228+ check!(i, cfg, &format!("({}) or 7", expr), Value::int(7));
229+ }
230+ }
231+ let skipped = op == "||";
232+ let expr = format!("{} {} crash \"rhs must stay unevaluated\"", skipped, op);
233+ assert_eq!(
234+ crash(&mut i, &cfg, &expr),
235+ format!("crash: contract: {}: result expected Text, got Bool", op)
236+ );
237+ check!(i, cfg, &format!("({}) or 9", expr), Value::int(9));
238+ }
239+ // A signature longer than the builtin's arity requires a
240+ // function result on both the skipped and the evaluated paths.
241+ let (mut i, cfg) = make_interp_from(&boolean_config(op, Some("Bool -> Bool -> Bool -> Bool")));
242+ for lhs in [true, false] {
243+ assert_eq!(
244+ crash(&mut i, &cfg, &format!("{} {} true", lhs, op)),
245+ format!("crash: contract: {} expected a function, got Bool", op)
246+ );
247+ }
248+ }
249+}
250+
251+#[test]
252+fn short_circuit_contract_crashes_name_the_executing_definition() {
253+ for op in ["&&", "||"] {
254+ let src = format!(
255+ "{}\nsigned : Bool -> Bool\nsigned = \\b -> b {} true\nplain = \\b -> b {} true\n",
256+ boolean_config(op, Some("Bool -> Bool -> Text")), op, op
257+ );
258+ let (mut i, cfg) = make_interp_from(&src);
259+ for lhs in [true, false] {
260+ for name in ["signed", "plain"] {
261+ let (msg, def) = crash_in(&mut i, &cfg, &format!("{} {}", name, lhs));
262+ assert_eq!(def.as_deref(), Some(name), "{}", msg);
263+ assert_eq!(msg, format!("contract: {}: result expected Text, got Bool", op));
264+ }
265+ let (_, def) = crash_in(&mut i, &cfg, &format!("{} {} true", lhs, op));
266+ assert_eq!(def, None);
267+ }
268+ }
269+}
270+
130271#[test]
131272fn if_requires_bool() {
132273 let (mut i, cfg) = make_interp();
@@ -719,7 +860,9 @@ fn crash_names_the_innermost_definition_executing() {
719860 ("sameAsInner 0", Some("inner")),
720861 ("sameAsInner \"x\"", Some("sameAsInner")),
721862 (prev.as_str(), Some("goto")),
722− (next.as_str(), Some("goto")),
863+ // next now handles an empty child list itself to suggest `j new`;
864+ // the direct goto and prev cases above still cover delegated errors.
865+ (next.as_str(), Some("next")),
723866 // a builtin is no definition: outside every definition, none
724867 ("head []", None),
725868 ("crash \"top\"", None),
tests/help.rs+121−5
@@ -35,6 +35,19 @@ impl HelpCli {
3535 std::fs::create_dir_all(self.config.path().join("j")).unwrap();
3636 std::fs::write(self.config.path().join("j/config.j"), source).unwrap();
3737 }
38+
39+ fn reference_config(&self) {
40+ self.write_config(&include_str!("../config.j")
41+ .replace("Your Name", "Help Test")
42+ .replace("you@example.com", "help@example.com"));
43+ }
44+
45+ fn operation_heads(&self) -> Vec<std::ffi::OsString> {
46+ let mut heads: Vec<_> = std::fs::read_dir(self.dir.path().join(".jj/repo/op_heads/heads"))
47+ .unwrap().map(|entry| entry.unwrap().file_name()).collect();
48+ heads.sort();
49+ heads
50+ }
3851}
3952
4053fn stdout(output: Output) -> String {
@@ -78,7 +91,7 @@ fn overview_and_reserved_topics_survive_broken_config() {
7891 cli.write_config("broken =\n");
7992 stdout(cli.run(&["help"]));
8093 for name in [
81− "init", "clone", "remote", "fetch", "push", "undo", "redo", "ops", "help", "commands",
94+ "init", "clone", "remote", "fetch", "push", "undo", "redo", "ops", "help", "commands", "setup",
8295 ] {
8396 assert!(stdout(cli.run(&["help", name])).contains(name));
8497 }
@@ -162,7 +175,7 @@ fn piped_help_and_expression_comments_preserve_input_rules() {
162175 stdout(cli.run(&["help", "push"]))
163176 );
164177
165− cli.write_config(include_str!("../config.j"));
178+ cli.reference_config();
166179 stdout(cli.run(&["init"]));
167180 assert_eq!(stdout(cli.run(&["\"--help\""])), "--help\n");
168181 assert_eq!(stdout(cli.run(&["(help) or 42"])), "42\n");
@@ -174,9 +187,9 @@ fn piped_help_and_expression_comments_preserve_input_rules() {
174187fn help_does_not_take_repository_lock_or_snapshot_changes() {
175188 use std::os::fd::AsRawFd;
176189 let cli = HelpCli::new();
177− cli.write_config(include_str!("../config.j"));
190+ cli.reference_config();
178191 stdout(cli.run(&["init"]));
179− let before = stdout(cli.run(&["ops"]));
192+ let before = cli.operation_heads();
180193 std::fs::write(cli.dir.path().join("work.txt"), "unsaved work\n").unwrap();
181194 let lock = std::fs::OpenOptions::new()
182195 .create(true)
@@ -192,13 +205,49 @@ fn help_does_not_take_repository_lock_or_snapshot_changes() {
192205 stdout(cli.run(&["help", "new"]));
193206 stdout(cli.run(&["commands", "Edit"]));
194207 drop(lock);
195− assert_eq!(stdout(cli.run(&["ops"])), before);
208+ assert_eq!(cli.operation_heads(), before);
196209 assert_eq!(
197210 std::fs::read_to_string(cli.dir.path().join("work.txt")).unwrap(),
198211 "unsaved work\n"
199212 );
200213}
201214
215+#[test]
216+fn separate_help_flags_never_execute_edits_or_take_the_repository_lock() {
217+ use std::os::fd::AsRawFd;
218+ let cli = HelpCli::new();
219+ // Help also works without a repository, creating no config.
220+ assert_eq!(stdout(cli.run(&["abandon", "--help"])), stdout(cli.run(&["help", "abandon"])));
221+ assert!(stdout(cli.run(&["clone", "--help"])).contains("Clone"));
222+ assert_eq!(std::fs::read_dir(cli.config.path()).unwrap().count(), 0);
223+
224+ cli.reference_config();
225+ stdout(cli.run(&["init"]));
226+ std::fs::write(cli.dir.path().join("draft.txt"), "valuable work\n").unwrap();
227+ stdout(cli.run(&["describe \"valuable work\""]));
228+ let before = cli.operation_heads();
229+ std::fs::write(cli.dir.path().join("draft.txt"), "unrecorded edits\n").unwrap();
230+ let lock = std::fs::OpenOptions::new().create(true).truncate(false).write(true)
231+ .open(cli.dir.path().join(".jj/j.lock")).unwrap();
232+ assert_eq!(unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }, 0);
233+ for command in ["abandon", "new", "squash", "describe"] {
234+ for flag in ["--help", "-h"] {
235+ assert_eq!(stdout(cli.run(&[command, flag])), stdout(cli.run(&["help", command])));
236+ }
237+ }
238+ assert_eq!(cli.operation_heads(), before);
239+ assert_eq!(std::fs::read_to_string(cli.dir.path().join("draft.txt")).unwrap(), "unrecorded edits\n");
240+}
241+
242+#[test]
243+fn config_help_is_available_when_configuration_is_broken() {
244+ let cli = HelpCli::new();
245+ cli.write_config("broken =\n");
246+ let help = stdout(cli.run(&["help", "config"]));
247+ assert!(help.contains("user ="));
248+ assert!(help.contains(cli.config.path().join("j/config.j").to_str().unwrap()));
249+}
250+
202251#[test]
203252fn help_read_failures_do_not_fall_back_to_reference_commands() {
204253 let cli = HelpCli::new();
@@ -208,3 +257,70 @@ fn help_read_failures_do_not_fall_back_to_reference_commands() {
208257 assert!(String::from_utf8_lossy(&output.stderr).contains("cannot read config"));
209258 stdout(cli.run(&["help", "clone"]));
210259}
260+
261+#[test]
262+fn default_discovery_is_short_and_full_catalog_remains_available() {
263+ let cli = HelpCli::new();
264+ let common = stdout(cli.run(&["commands"]));
265+ assert!(common.lines().count() <= 40, "{}", common);
266+ for command in ["j setup", "j save", "j 'preview squash'", "j commands all"] {
267+ assert!(common.contains(command), "{}", common);
268+ }
269+ assert!(!common.contains("(++) :"));
270+ assert!(!common.contains("Subtree ="));
271+ let all = stdout(cli.run(&["commands", "all"]));
272+ assert!(all.contains("(++) :"));
273+ assert!(all.contains("Subtree ="));
274+ assert!(all.contains("save : Edit"));
275+ assert_eq!(std::fs::read_dir(cli.config.path()).unwrap().count(), 0);
276+}
277+
278+#[test]
279+fn discovery_understands_beginner_tasks_without_inventing_commands() {
280+ let cli = HelpCli::new();
281+ for (query, expected) in [("save", "save : Edit"), ("record", "save : Edit"),
282+ ("commit", "describe : Text -> Edit"), ("branch", "new : Edit"),
283+ ("checkout", "goto : Revset -> Edit")] {
284+ let output = stdout(cli.run(&["commands", query]));
285+ let first = output.lines().find(|line| line.starts_with(" ")).unwrap();
286+ assert!(first.contains(expected), "{}", output);
287+ }
288+ let stash = stdout(cli.run(&["commands", "stash"]));
289+ assert!(stash.contains("no direct stash command"));
290+ assert!(stash.contains("j save") && stash.contains("j new"));
291+ cli.write_config("user = {}\ntree = 0\nimmutable = []\nlabelled = 0\n-- Custom shelving command.\nstash = 42\n");
292+ let stash = stdout(cli.run(&["commands", "stash"]));
293+ assert!(stash.contains("stash (definition; no signature)"));
294+ assert!(!stash.contains("no direct stash"));
295+ let record = stdout(cli.run(&["commands", "record"]));
296+ assert!(!record.contains("save :") && !record.contains("id :"));
297+}
298+
299+#[test]
300+fn command_help_leads_with_plain_explanations_and_setup_is_actionable() {
301+ let cli = HelpCli::new();
302+ let describe = stdout(cli.run(&["help", "describe"]));
303+ assert!(describe.starts_with("Set the message"), "{}", describe);
304+ assert!(describe.find("Example:").unwrap() < describe.find("Text -> Edit").unwrap());
305+ let config = stdout(cli.run(&["help", "config"]));
306+ assert!(config.contains("j setup 'Ada Lovelace' ada@example.org"));
307+ assert!(config.contains("not a complete config"));
308+ let clone = stdout(cli.run(&["help", "clone"]));
309+ assert!(clone.contains("cd project"));
310+ assert!(clone.contains("does not change your shell's directory"));
311+}
312+
313+#[test]
314+fn everyday_catalog_respects_custom_docs_and_missing_example_dependencies() {
315+ let cli = HelpCli::new();
316+ cli.write_config("user = {}\ntree = 0\nimmutable = []\nlabelled = 0\n-- Display my custom report.\npreview : Text -> Text\npreview = \\report -> report\n-- Open our custom dashboard.\nnew : Text -> Text\nnew = \\dashboard -> dashboard\n");
317+ let common = stdout(cli.run(&["commands"]));
318+ assert!(common.contains("Display my custom report."));
319+ assert!(common.contains("Open our custom dashboard."));
320+ assert!(common.contains("j help preview"));
321+ assert!(!common.contains("preview squash"));
322+ assert!(!common.contains("Start the next commit"));
323+ assert!(!common.contains("j save"));
324+ let all = stdout(cli.run(&["commands", "all"]));
325+ assert!(!all.contains("squash :"));
326+}
tests/interop.rs+173−10
@@ -92,6 +92,63 @@ impl Drop for T {
9292 }
9393}
9494
95+#[test]
96+fn sparse_working_copies_are_refused_without_changing_files_or_operations() {
97+ for partial in [false, true] {
98+ let Some(t) = setup() else { return };
99+ let (code, _, err) = t.j(&["init"]);
100+ assert_eq!(code, 0, "{}", err);
101+ std::fs::write(t.dir.join("a"), "original\n").unwrap();
102+ let (code, _, err) = t.j(&["describe \"base\""]);
103+ assert_eq!(code, 0, "{}", err);
104+ let args = if partial {
105+ vec!["sparse", "set", "--clear", "--add", "included"]
106+ } else {
107+ vec!["sparse", "set", "--clear"]
108+ };
109+ let (code, _, err) = t.jj(&args);
110+ assert_eq!(code, 0, "{}", err);
111+ assert!(!t.dir.join("a").exists());
112+ std::fs::write(t.dir.join("a"), "edited while excluded\n").unwrap();
113+ std::fs::write(t.dir.join("b"), "new while excluded\n").unwrap();
114+ let operations = || {
115+ let (code, out, err) = t.jj(&[
116+ "--ignore-working-copy", "op", "log", "--no-graph", "-T", "id ++ \"\\n\"",
117+ ]);
118+ assert_eq!(code, 0, "{}", err);
119+ out
120+ };
121+ let patterns = || {
122+ let (code, out, err) = t.jj(&["--ignore-working-copy", "sparse", "list"]);
123+ assert_eq!(code, 0, "{}", err);
124+ out
125+ };
126+ let before = operations();
127+ let sparse = patterns();
128+ for expr in ["status", "id", "describe \"must not run\"", "undo", "ops"] {
129+ let (code, _, err) = t.j(&[expr]);
130+ assert_eq!(code, 2, "{}: {}", expr, err);
131+ assert!(err.contains("sparse working copies are not supported"), "{}", err);
132+ assert!(err.contains("jj sparse set --clear --add ."), "{}", err);
133+ assert_eq!(std::fs::read_to_string(t.dir.join("a")).unwrap(), "edited while excluded\n");
134+ assert_eq!(std::fs::read_to_string(t.dir.join("b")).unwrap(), "new while excluded\n");
135+ }
136+ assert_eq!(operations(), before);
137+ assert_eq!(patterns(), sparse);
138+
139+ // Restoring full patterns explicitly makes j usable again. Files
140+ // created while excluded remain intact and can now be recorded.
141+ let (code, _, err) = t.jj(&["sparse", "set", "--clear", "--add", "."]);
142+ assert_eq!(code, 0, "{}", err);
143+ let (code, _, err) = t.j(&["id"]);
144+ assert_eq!(code, 0, "{}", err);
145+ let (code, files, err) = t.j(&["show . files"]);
146+ assert_eq!(code, 0, "{}", err);
147+ assert!(files.contains("edited while excluded"), "{}", files);
148+ assert!(files.contains("new while excluded"), "{}", files);
149+ }
150+}
151+
95152#[test]
96153fn jj_reads_js_commits() {
97154 let Some(t) = setup() else { return };
@@ -165,31 +222,137 @@ fn jj_undo_interop_marker() {
165222 assert!(ops.contains("undo"), "{}", ops);
166223}
167224
225+#[test]
226+fn j_and_jj_continue_each_others_undo_redo_chains() {
227+ let sequences: &[&[(&str, &str, &str)]] = &[
228+ &[
229+ ("jj", "undo", "two"), ("j", "redo", "three"),
230+ ("j", "undo", "two"), ("j", "undo", "one"),
231+ ("j", "redo", "two"), ("j", "redo", "three"),
232+ ],
233+ &[
234+ ("jj", "undo", "two"), ("jj", "undo", "one"),
235+ ("j", "undo", "zero"), ("j", "redo", "one"),
236+ ("j", "redo", "two"), ("j", "redo", "three"),
237+ ],
238+ &[
239+ ("jj", "undo", "two"), ("jj", "undo", "one"),
240+ ("jj", "redo", "two"), ("j", "redo", "three"),
241+ ("j", "undo", "two"), ("jj", "undo", "one"),
242+ ("jj", "redo", "two"), ("jj", "redo", "three"),
243+ ],
244+ &[
245+ ("j", "undo", "two"), ("j", "undo", "one"),
246+ ("jj", "redo", "two"), ("jj", "redo", "three"),
247+ ("jj", "undo", "two"), ("j", "undo", "one"),
248+ ("j", "redo", "two"), ("jj", "redo", "three"),
249+ ],
250+ &[
251+ ("jj", "undo", "two"), ("j", "redo", "three"),
252+ ("j", "undo", "two"), ("jj", "undo", "one"),
253+ ("j", "redo", "two"), ("jj", "redo", "three"),
254+ ],
255+ ];
256+ for sequence in sequences {
257+ let Some(t) = setup() else { return };
258+ for expr in ["init", "describe \"zero\"", "describe \"one\"", "describe \"two\"", "describe \"three\""] {
259+ let (code, _, err) = t.j(&[expr]);
260+ assert_eq!(code, 0, "{}: {}", expr, err);
261+ }
262+ for &(tool, command, expected) in *sequence {
263+ let (code, _, err) = if tool == "j" { t.j(&[command]) } else { t.jj(&[command]) };
264+ assert_eq!(code, 0, "{} {}: {}", tool, command, err);
265+ let (code, message, err) = t.j(&["\\r -> r.root.message"]);
266+ assert_eq!(code, 0, "{}", err);
267+ assert_eq!(message.trim(), expected, "after {} {} in {:?}", tool, command, sequence);
268+ }
269+ let (code, _, err) = t.j(&["redo"]);
270+ assert_eq!(code, 1, "{}", err);
271+ assert!(err.contains("nothing to redo"), "{}", err);
272+ let (code, ops, err) = t.j(&["ops"]);
273+ assert_eq!(code, 0, "{}", err);
274+ assert!(ops.lines().any(|line| line.ends_with("(undo)")), "{}", ops);
275+ assert!(ops.lines().any(|line| line.ends_with("(redo)")), "{}", ops);
276+ }
277+}
278+
279+#[test]
280+fn modern_undo_redo_continues_legacy_j_markers() {
281+ use jj_lib::config::{ConfigLayer, ConfigSource, StackedConfig};
282+ use jj_lib::default_backend_factories::{default_backend_factories, default_working_copy_factories};
283+ use jj_lib::object_id::ObjectId;
284+ use jj_lib::repo::ReadonlyRepo;
285+ use std::sync::Arc;
286+
287+ // Build operations with the exact descriptions older j versions wrote.
288+ // Only descriptions change in this history, so no checkout is needed.
289+ let restore = |repo: &Arc<ReadonlyRepo>, target: &jj_lib::operation::Operation, description: String| {
290+ let view = pollster::block_on(target.op_store().read_view(target.view_id())).unwrap();
291+ let mut tx = repo.start_transaction();
292+ tx.repo_mut().set_view(view);
293+ pollster::block_on(pollster::block_on(tx.write(description)).unwrap().publish()).unwrap()
294+ };
295+ for start_with_redo in [false, true] {
296+ let Some(t) = setup() else { return };
297+ for expr in ["init", "describe \"zero\"", "describe \"one\"", "describe \"two\"", "describe \"three\""] {
298+ let (code, _, err) = t.j(&[expr]);
299+ assert_eq!(code, 0, "{}", err);
300+ }
301+ let mut config = StackedConfig::with_defaults();
302+ config.add_layer(ConfigLayer::parse(ConfigSource::User, "[user]\nname = 'Test'\nemail = 't@t'\n").unwrap());
303+ let settings = jj_lib::settings::UserSettings::from_config(config).unwrap();
304+ let ws = jj_lib::workspace::Workspace::load(
305+ &settings, &t.dir, &default_backend_factories(), &default_working_copy_factories(),
306+ ).unwrap();
307+ let repo = pollster::block_on(ws.repo_loader().load_at_head()).unwrap();
308+ let three = repo.operation().clone();
309+ let two = pollster::block_on(three.parents()).unwrap().remove(0);
310+ let one = pollster::block_on(two.parents()).unwrap().remove(0);
311+ let repo = restore(&repo, &two, format!("undo: {}", three.id().hex()));
312+ let repo = restore(&repo, &one, format!("undo: {}", two.id().hex()));
313+ if start_with_redo {
314+ let undo_id = repo.operation().id().hex();
315+ restore(&repo, &two, format!("redo: {}", undo_id));
316+ let (code, _, err) = t.j(&["undo"]);
317+ assert_eq!(code, 0, "{}", err);
318+ }
319+ for expected in ["two", "three"] {
320+ let (code, _, err) = t.j(&["redo"]);
321+ assert_eq!(code, 0, "{}", err);
322+ let (code, message, err) = t.j(&["\\r -> r.root.message"]);
323+ assert_eq!(code, 0, "{}", err);
324+ assert_eq!(message.trim(), expected);
325+ }
326+ }
327+}
328+
168329#[test]
169330fn jj_reads_conflicts_written_by_j() {
170331 let Some(t) = setup() else { return };
171− t.j(&["init"]);
332+ let j_ok = |expr: &str| {
333+ let (code, _, err) = t.j(&[expr]);
334+ assert_eq!(code, 0, "{}: {}", expr, err);
335+ };
336+ j_ok("init");
172337 std::fs::write(t.dir.join("f.txt"), "base\n").unwrap();
173− t.j(&["describe \"base\""]);
338+ j_ok("describe \"base\"");
174339 // side 1
175− t.j(&["new"]);
340+ j_ok("new");
176341 std::fs::write(t.dir.join("f.txt"), "one\n").unwrap();
177− t.j(&["describe \"one\""]);
342+ j_ok("describe \"one\"");
178343 // side 2 from the parent
179− t.j(&["new . goto (kids (top id))"]);
344+ j_ok("new . prev");
180345 std::fs::write(t.dir.join("f.txt"), "two\n").unwrap();
181− t.j(&["describe \"two\""]);
346+ j_ok("describe \"two\"");
182347 // rebase two onto one: conflicts
183− let (code, _, err) = t.j(&["rebase (matching (\\c -> c.message == \"one\") all)"]);
184− assert_eq!(code, 0, "{}", err);
348+ j_ok("rebase (matching (\\c -> c.message == \"one\") all)");
185349 // jj sees the conflict
186350 let (code, log, _) = t.jj(&["log", "--no-pager"]);
187351 assert_eq!(code, 0);
188352 assert!(log.contains("conflict"), "{}", log);
189353 // resolve via jj's working copy and j records it
190354 std::fs::write(t.dir.join("f.txt"), "resolved\n").unwrap();
191− let (code, _, _) = t.j(&["id"]);
192− assert_eq!(code, 0);
355+ j_ok("id");
193356 let (code, log, _) = t.jj(&["log", "--no-pager"]);
194357 assert_eq!(code, 0);
195358 assert!(!log.contains("conflict"), "{}", log);
tests/onboarding.rsnew file+200
@@ -0,0 +1,200 @@
1+//! Follow the tutorial as a new user would, including the files that reach a
2+//! remote and the difference between a preview and a Repo-returning edit.
3+
4+use std::collections::BTreeMap;
5+use std::ffi::OsString;
6+use std::path::{Path, PathBuf};
7+use std::process::{Command, Output};
8+
9+const TUTORIAL: &str = include_str!("../docs/tutorial.md");
10+
11+struct Session {
12+ root: tempfile::TempDir,
13+ config: PathBuf,
14+}
15+
16+impl Session {
17+ fn new() -> Self {
18+ let root = tempfile::tempdir().unwrap();
19+ let config = root.path().join("config");
20+ std::fs::create_dir_all(config.join("j")).unwrap();
21+ let source = include_str!("../config.j")
22+ .replace("Your Name", "Tutorial User")
23+ .replace("you@example.com", "tutorial@example.org");
24+ std::fs::write(config.join("j/config.j"), source).unwrap();
25+ Self { root, config }
26+ }
27+
28+ fn command(&self, program: &str, cwd: &Path) -> Command {
29+ let mut command = Command::new(program);
30+ command.current_dir(cwd)
31+ .env("XDG_CONFIG_HOME", &self.config)
32+ .env("GIT_CONFIG_NOSYSTEM", "1")
33+ .env("GIT_CONFIG_GLOBAL", "/dev/null")
34+ .env("NO_COLOR", "1");
35+ command
36+ }
37+
38+ fn j(&self, cwd: &Path, expression: &str) -> String {
39+ let mut command = self.command(env!("CARGO_BIN_EXE_j"), cwd);
40+ command.arg(expression);
41+ output(&mut command)
42+ }
43+
44+ fn git(&self, cwd: &Path, args: &[&str]) -> String {
45+ let mut command = self.command("git", cwd);
46+ command.args(args);
47+ output(&mut command)
48+ }
49+
50+ fn shell(&self, cwd: &Path, script: &str) {
51+ // Run the actual fenced commands, preserving shell quoting and `cd`.
52+ // Only these two explicitly selected tutorial sections are executable.
53+ let binary_dir = Path::new(env!("CARGO_BIN_EXE_j")).parent().unwrap();
54+ let mut paths = vec![binary_dir.to_path_buf()];
55+ paths.extend(std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()));
56+ let mut command = self.command("sh", cwd);
57+ command.env("PATH", std::env::join_paths(paths).unwrap())
58+ .args(["-eu", "-c", script]);
59+ output(&mut command);
60+ }
61+
62+ fn clone_main(&self) -> (PathBuf, PathBuf) {
63+ let remote = self.root.path().join("origin.git");
64+ let seed = self.root.path().join("seed");
65+ std::fs::create_dir(&seed).unwrap();
66+ self.git(self.root.path(), &["init", "--bare", "--initial-branch=main", remote.to_str().unwrap()]);
67+ self.git(&seed, &["init", "--initial-branch=main"]);
68+ std::fs::write(seed.join("README.md"), "A project to try j with.\n").unwrap();
69+ self.git(&seed, &["add", "README.md"]);
70+ self.git(&seed, &["-c", "user.name=Tutorial User", "-c", "user.email=tutorial@example.org",
71+ "-c", "commit.gpgsign=false", "commit", "-m", "Initial main"]);
72+ self.git(&seed, &["push", remote.to_str().unwrap(), "main"]);
73+
74+ let repo = self.root.path().join("work");
75+ let mut clone = self.command(env!("CARGO_BIN_EXE_j"), self.root.path());
76+ clone.arg("clone").arg(&remote).arg(&repo);
77+ output(&mut clone);
78+ (repo, remote)
79+ }
80+}
81+
82+fn output(command: &mut Command) -> String {
83+ let Output { status, stdout, stderr } = command.output().unwrap();
84+ assert!(status.success(), "{command:?} failed ({status}):\n{}\n{}",
85+ String::from_utf8_lossy(&stdout), String::from_utf8_lossy(&stderr));
86+ String::from_utf8(stdout).unwrap()
87+}
88+
89+fn tutorial_blocks(heading: &str) -> Vec<String> {
90+ let section = TUTORIAL.split_once(heading).expect("tutorial section exists").1;
91+ let section = section.split("\n## ").next().unwrap();
92+ let mut blocks = Vec::new();
93+ let mut current = None::<String>;
94+ for line in section.lines() {
95+ if line.starts_with("```") {
96+ match current.take() {
97+ Some(block) => blocks.push(block),
98+ None => current = Some(String::new()),
99+ }
100+ } else if let Some(block) = &mut current {
101+ block.push_str(line);
102+ block.push('\n');
103+ }
104+ }
105+ assert!(current.is_none(), "unclosed tutorial code fence");
106+ blocks
107+}
108+
109+fn operation_heads(repo: &Path) -> Vec<OsString> {
110+ let mut heads: Vec<_> = std::fs::read_dir(repo.join(".jj/repo/op_heads/heads"))
111+ .unwrap().map(|entry| entry.unwrap().file_name()).collect();
112+ heads.sort();
113+ heads
114+}
115+
116+fn working_files(repo: &Path) -> BTreeMap<OsString, Vec<u8>> {
117+ std::fs::read_dir(repo).unwrap()
118+ .map(|entry| entry.unwrap())
119+ .filter(|entry| entry.file_type().unwrap().is_file())
120+ .map(|entry| (entry.file_name(), std::fs::read(entry.path()).unwrap()))
121+ .collect()
122+}
123+
124+#[test]
125+fn tutorial_first_changes_put_each_description_on_the_files_it_names() {
126+ let session = Session::new();
127+ let blocks = tutorial_blocks("## Part 1 — Ten minutes to a working repository");
128+ // Output examples share this section with shell examples. Select the
129+ // executable fences by their first command, never their sample IDs.
130+ let script = blocks.into_iter()
131+ // The alternative `clone URL` example needs a user's remote; it is
132+ // separate from the preceding two-change session exercised here.
133+ .take_while(|block| !block.trim_start().starts_with("j clone "))
134+ .filter(|block| {
135+ let first = block.lines().find(|line| !line.trim().is_empty()).unwrap_or("");
136+ first.starts_with("mkdir ") || first.starts_with("echo ") || first.starts_with("j ")
137+ }).collect::<Vec<_>>().join("\n");
138+ assert!(script.contains("main.rs") && script.contains("lib.rs"), "{script}");
139+ session.shell(session.root.path(), &script);
140+ let repo = session.root.path().join("play");
141+
142+ assert_eq!(session.j(&repo, "\\r -> r.root.message"), "add a library\n");
143+ assert_eq!(session.j(&repo, "changed"), "lib.rs\n");
144+ assert_eq!(session.j(&repo, "\\r -> (up r).root.message"), "start the project\n");
145+ assert_eq!(session.j(&repo, "changed . prev"), "main.rs\n");
146+ assert_eq!(session.j(&repo, "length . commits . top"), "3\n",
147+ "the first file must belong to the described commit, without an extra empty child");
148+}
149+
150+#[test]
151+fn tutorial_realistic_session_pushes_both_parts_of_the_parser_to_main_based_branch() {
152+ let session = Session::new();
153+ let (repo, remote) = session.clone_main();
154+ let blocks = tutorial_blocks("## Part 10 — A realistic session, start to finish");
155+ let script = blocks.first().expect("the complete session is the first fence");
156+ assert!(script.contains("parser.rs") && script.contains("push"), "{script}");
157+ session.shell(&repo, script);
158+
159+ assert_eq!(session.git(&remote, &["show", "parser:parser.rs"]), "fn parse() {}\n");
160+ assert_eq!(session.git(&remote, &["show", "parser:lib.rs"]), "mod parser;\n");
161+ assert_eq!(session.git(&remote, &["log", "-1", "--format=%s", "parser"]), "add the parser\n");
162+ assert_eq!(session.git(&remote, &["diff", "--name-only", "main..parser"]), "lib.rs\nparser.rs\n");
163+ assert_eq!(session.git(&remote, &["rev-parse", "parser^"]), session.git(&remote, &["rev-parse", "main"]));
164+}
165+
166+#[test]
167+fn preview_squash_keeps_recorded_history_and_working_edits_while_validate_persists() {
168+ let session = Session::new();
169+ let repo = session.root.path().join("preview");
170+ std::fs::create_dir(&repo).unwrap();
171+ session.j(&repo, "init");
172+ std::fs::write(repo.join("first.txt"), "first part\n").unwrap();
173+ session.j(&repo, "describe \"first\"");
174+ session.j(&repo, "new");
175+ std::fs::write(repo.join("second.txt"), "second part\n").unwrap();
176+ session.j(&repo, "describe \"second\"");
177+
178+ // On a clean working copy these are the actual recorded commit hashes,
179+ // descriptions, and IDs, rather than hashes of an unpublished snapshot.
180+ let commits = "\\r -> show (map (\\c -> { id = c.id, hash = (meta c.id).hash, message = c.message }) (commits (top r)))";
181+ let recorded = session.j(&repo, commits);
182+ let heads = operation_heads(&repo);
183+ std::fs::write(repo.join("pending.txt"), "an edit not yet recorded\n").unwrap();
184+ let files = working_files(&repo);
185+ let preview = session.j(&repo, "preview squash");
186+ assert!(preview.contains("first"), "{preview}");
187+ assert_eq!(operation_heads(&repo), heads, "preview must not publish an operation");
188+ assert_eq!(working_files(&repo), files, "preview must not checkout its result");
189+
190+ // Restoring the original disk contents lets the metadata inspection read
191+ // recorded commits directly again, without a transient dirty snapshot.
192+ std::fs::remove_file(repo.join("pending.txt")).unwrap();
193+ assert_eq!(session.j(&repo, commits), recorded, "preview must not rewrite recorded commits");
194+ assert_eq!(operation_heads(&repo), heads);
195+
196+ session.j(&repo, "validate . squash");
197+ assert_ne!(operation_heads(&repo), heads, "validate returns a Repo, so this command saves the squash");
198+ assert_ne!(session.j(&repo, commits), recorded);
199+ assert_eq!(session.j(&repo, "changed . prev"), "first.txt\nsecond.txt\n");
200+}
tests/onboarding_vocab.rsnew file+153
@@ -0,0 +1,153 @@
1+//! Newcomer vocabulary remains ordinary, pure config code.
2+
3+use j::config::{self, Config};
4+use j::domain::{MemBackend, MetaInfo, ROOT_ID};
5+use j::eval::Interp;
6+use j::parse::parse_expr;
7+use j::value::{value_eq, BlobVal, Crash, Env, Value};
8+use std::rc::Rc;
9+
10+const CONFIG: &str = include_str!("../config.j");
11+
12+fn commit(id: &str, message: &str, content: Option<&str>) -> Value {
13+ let files = content.map(|text| Value::record(&[
14+ ("path", Value::list(vec![Value::text("work.txt")])),
15+ ("content", BlobVal::text_blob(text)),
16+ ])).into_iter().collect();
17+ Value::record(&[
18+ ("id", Value::Id(Rc::new(id.to_string()))),
19+ ("message", Value::text(message)),
20+ ("labels", Value::list(vec![])),
21+ ("files", Value::list(files)),
22+ ])
23+}
24+
25+fn repo(content: &str, children: Vec<Value>) -> Value {
26+ Value::record(&[
27+ ("root", commit("kppppppp", "current work", Some(content))),
28+ ("children", Value::list(children)),
29+ ("context", Value::list(vec![Value::record(&[
30+ ("parent", commit(ROOT_ID, "", None)),
31+ ("left", Value::list(vec![])),
32+ ("right", Value::list(vec![])),
33+ ])])),
34+ ])
35+}
36+
37+fn subtree(id: &str, message: &str) -> Value {
38+ Value::record(&[
39+ ("root", commit(id, message, Some("recorded"))),
40+ ("children", Value::list(vec![])),
41+ ])
42+}
43+
44+fn interpreter(source: &str, loaded: &Value, given: &Value) -> (Interp, Config) {
45+ let cfg = config::load_config(source).unwrap();
46+ let mut backend = MemBackend::new();
47+ for id in [ROOT_ID, "kppppppp", "kqqqqqqq", "krrrrrrr"] {
48+ backend.metas.insert(id.to_string(), MetaInfo {
49+ hash: id.to_string(), author: "Test".into(), email: "test@example.com".into(), time: 1,
50+ });
51+ }
52+ let mut interp = Interp::new(Rc::new(backend), cfg.shapes.clone(), Env::empty());
53+ *interp.old_repo.borrow_mut() = Some(loaded.clone());
54+ *interp.given_repo.borrow_mut() = Some(given.clone());
55+ config::eval_config(&mut interp, &cfg).unwrap();
56+ (interp, cfg)
57+}
58+
59+fn evaluate(interp: &mut Interp, cfg: &Config, source: &str, repo: Value) -> Result<Value, Crash> {
60+ let expression = parse_expr(source, Rc::new(cfg.global_names.clone())).unwrap();
61+ let expression = config::resolve_ids(&expression, interp).unwrap();
62+ let value = interp.eval(&Rc::new(expression), &interp.global_env())?;
63+ if matches!(value, Value::Fun(_)) { interp.apply(value, repo) } else { Ok(value) }
64+}
65+
66+#[test]
67+fn save_keeps_the_snapshot_and_preview_validates_without_returning_a_repo() {
68+ let old = repo("recorded", vec![]);
69+ let given = repo("edited", vec![]);
70+ let (mut interp, cfg) = interpreter(CONFIG, &old, &given);
71+ let saved = evaluate(&mut interp, &cfg, "save", given.clone()).unwrap();
72+ assert!(value_eq(&saved, &given).unwrap());
73+ let preview = evaluate(&mut interp, &cfg, "preview (describe \"ready for review\")", given.clone()).unwrap();
74+ assert!(preview.as_text().unwrap().contains("ready for review"));
75+ assert_eq!(given.field("root").unwrap().field("message").unwrap().as_text().unwrap(), "current work");
76+ assert!(value_eq(interp.old_repo.borrow().as_ref().unwrap(), &old).unwrap());
77+ let error = evaluate(&mut interp, &cfg, "preview (describe \"change root\" . top)", given).err().unwrap();
78+ assert!(error.msg.contains("focus must be a mutable commit"), "{}", error.msg);
79+}
80+
81+#[test]
82+fn next_at_a_tip_recommends_creating_a_child() {
83+ let given = repo("recorded", vec![]);
84+ let (mut interp, cfg) = interpreter(CONFIG, &given, &given);
85+ let error = evaluate(&mut interp, &cfg, "next", given.clone()).err().unwrap();
86+ assert!(error.msg.contains("no children") && error.msg.contains("j new"), "{}", error.msg);
87+ // tip still stops at a leaf; recovery text must not change navigation.
88+ let tip = evaluate(&mut interp, &cfg, "tip", given.clone()).unwrap();
89+ assert!(value_eq(&tip, &given).unwrap());
90+}
91+
92+#[test]
93+fn ambiguous_navigation_lists_commit_messages_and_usable_ids() {
94+ let given = repo("recorded", vec![
95+ subtree("kqqqqqqq", "keyboard support\nprivate second line"),
96+ subtree("krrrrrrr", ""),
97+ ]);
98+ let (mut interp, cfg) = interpreter(CONFIG, &given, &given);
99+ let error = evaluate(&mut interp, &cfg, "next", given.clone()).err().unwrap();
100+ for expected in ["expected one revision, got 2", "@kqqqqqqq", "keyboard support", "@krrrrrrr", "(no description)", "j by @ID"] {
101+ assert!(error.msg.contains(expected), "missing {expected}: {}", error.msg);
102+ }
103+ assert!(!error.msg.contains("private second line"), "{}", error.msg);
104+ let selected = evaluate(&mut interp, &cfg, "by @kqqqqqqq", given).unwrap();
105+ assert!(matches!(selected.field("root").unwrap().field("id").unwrap(), Value::Id(id) if id.as_str() == "kqqqqqqq"));
106+}
107+
108+#[test]
109+fn missing_remote_navigation_explains_fetch_without_changing_empty_revsets() {
110+ let given = repo("recorded", vec![]);
111+ let (mut interp, cfg) = interpreter(CONFIG, &given, &given);
112+ for expression in ["trunk", "%main"] {
113+ assert!(evaluate(&mut interp, &cfg, expression, given.clone()).unwrap().as_list().unwrap().is_empty());
114+ }
115+ for expression in ["goto trunk", "goto %main", "goto (const [])"] {
116+ let error = evaluate(&mut interp, &cfg, expression, given.clone()).err().unwrap();
117+ for expected in ["expected one revision, got 0", "j tree", "j remote URL", "j fetch"] {
118+ assert!(error.msg.contains(expected), "{expression}: missing {expected}: {}", error.msg);
119+ }
120+ }
121+}
122+
123+#[test]
124+fn status_distinguishes_commit_changes_from_unrecorded_work() {
125+ let old = repo("recorded", vec![]);
126+ let given = repo("edited", vec![]);
127+ let (mut interp, cfg) = interpreter(CONFIG, &old, &given);
128+ let status = evaluate(&mut interp, &cfg, "status", given.clone()).unwrap();
129+ let paths = Value::list(vec![Value::list(vec![Value::text("work.txt")])]);
130+ assert!(value_eq(&status.field("changed").unwrap(), &paths).unwrap());
131+ assert!(value_eq(&status.field("unrecorded").unwrap(), &paths).unwrap());
132+
133+ let (mut interp, cfg) = interpreter(CONFIG, &given, &given);
134+ let recorded = evaluate(&mut interp, &cfg, "status", given).unwrap();
135+ assert!(value_eq(&recorded.field("changed").unwrap(), &paths).unwrap());
136+ assert!(recorded.field("unrecorded").unwrap().as_list().unwrap().is_empty());
137+}
138+
139+#[test]
140+fn older_custom_configs_can_omit_new_vocabulary() {
141+ let older = CONFIG
142+ .replace("unrecorded : Repo -> [Path]\n", "")
143+ .replace("save : Edit\nsave = id\n", "")
144+ .replace("preview : Edit -> Repo -> Text\npreview = \\edit -> tree . validate . edit\n", "")
145+ .replace(", unrecorded : [Path]", "")
146+ .replace("\n , unrecorded = unrecorded repo }", " }");
147+ let given = repo("recorded", vec![]);
148+ let (mut interp, cfg) = interpreter(&older, &given, &given);
149+ for name in ["save", "preview", "unrecorded"] { assert!(interp.globals.lookup(name).is_none(), "{name} unexpectedly available"); }
150+ let status = evaluate(&mut interp, &cfg, "status", given).unwrap();
151+ assert!(status.field("unrecorded").is_err());
152+ assert!(matches!(status, Value::Record(fields) if fields.len() == 5));
153+}
tests/remote.rs+462−10
@@ -29,6 +29,15 @@ fn uniq(tag: &str) -> PathBuf {
2929 ))
3030}
3131
32+fn operation_heads(dir: &std::path::Path) -> Vec<std::ffi::OsString> {
33+ let mut heads: Vec<_> = std::fs::read_dir(dir.join(".jj/repo/op_heads/heads"))
34+ .unwrap()
35+ .map(|entry| entry.unwrap().file_name())
36+ .collect();
37+ heads.sort();
38+ heads
39+}
40+
3241struct Env {
3342 dir: PathBuf,
3443 remote: PathBuf,
@@ -149,6 +158,315 @@ impl Drop for Env {
149158 }
150159}
151160
161+#[test]
162+fn dirty_push_warns_and_keeps_edits_available_for_recording() {
163+ let env = setup();
164+ let dest = env.dir.join("clone");
165+ env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
166+ env.j(&dest, &["describe \"work\""]).ok();
167+ std::fs::write(dest.join("a.txt"), "unrecorded\n").unwrap();
168+ // A status preview must not make a subsequent push silently appear to
169+ // include the previewed contents.
170+ env.j(&dest, &["status"]).ok();
171+ let state_path = dest.join(".jj/working_copy/tree_state");
172+ let state = std::fs::read(&state_path).unwrap();
173+ let out = env.j(&dest, &["push (label \"feature\" here)"]).ok();
174+ assert!(out.stderr.contains("working copy has unrecorded changes"), "{}", out.stderr);
175+ assert!(out.stderr.contains("push sends recorded content only"), "{}", out.stderr);
176+ assert!(out.stderr.contains("`j id`"), "{}", out.stderr);
177+ assert_eq!(git(&env.remote, &["show", "feature:a.txt"]), "one\n");
178+ assert_eq!(std::fs::read(&state_path).unwrap(), state, "push changed the saved working-copy state");
179+ assert_eq!(std::fs::read_to_string(dest.join("a.txt")).unwrap(), "unrecorded\n");
180+
181+ // Following the warning records and publishes the omitted edits.
182+ env.j(&dest, &["id"]).ok();
183+ let out = env.j(&dest, &["push (label \"feature\" here)"]).ok();
184+ assert!(!out.stderr.contains("unrecorded changes"), "{}", out.stderr);
185+ assert_eq!(git(&env.remote, &["show", "feature:a.txt"]), "unrecorded\n");
186+}
187+
188+#[test]
189+fn dirty_push_allows_unrelated_bookmarks_and_deletions_with_warning() {
190+ let env = setup();
191+ let dest = env.dir.join("clone");
192+ env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
193+ std::fs::write(dest.join("a.txt"), "unrecorded\n").unwrap();
194+ let out = env.j(&dest, &["push (label \"copy\" (labelled \"master\"))"]).ok();
195+ assert!(out.stderr.contains("push sends recorded content only"), "{}", out.stderr);
196+ assert_eq!(git(&env.remote, &["rev-parse", "copy"]), git(&env.remote, &["rev-parse", "master"]));
197+ let out = env.j(&dest, &["push (unlabel \"copy\")"]).ok();
198+ assert!(out.stderr.contains("push sends recorded content only"), "{}", out.stderr);
199+ assert_eq!(git(&env.remote, &["for-each-ref", "--format=%(refname)", "refs/heads/copy"]), "");
200+ assert_eq!(std::fs::read_to_string(dest.join("a.txt")).unwrap(), "unrecorded\n");
201+}
202+
203+#[test]
204+fn unrecorded_paths_distinguish_disk_edits_from_the_recorded_commit_change() {
205+ let env = setup();
206+ env.j(&env.dir, &["init"]).ok();
207+ std::fs::write(env.dir.join("modified.txt"), "before\n").unwrap();
208+ std::fs::write(env.dir.join("removed.txt"), "remove me\n").unwrap();
209+ env.j(&env.dir, &["describe \"baseline\""]).ok();
210+ env.j(&env.dir, &["new"]).ok();
211+ assert_eq!(env.j(&env.dir, &["unrecorded"]).ok().stdout.trim(), "none");
212+
213+ std::fs::write(env.dir.join("modified.txt"), "after\n").unwrap();
214+ std::fs::write(env.dir.join("added.txt"), "new file\n").unwrap();
215+ std::fs::remove_file(env.dir.join("removed.txt")).unwrap();
216+ let heads = operation_heads(&env.dir);
217+ let state_path = env.dir.join(".jj/working_copy/tree_state");
218+ let state = std::fs::read(&state_path).unwrap();
219+ let expected = "added.txt\nmodified.txt\nremoved.txt";
220+ for expression in ["unrecorded", "(.unrecorded) . status", "unrecorded . new", "unrecorded . prev"] {
221+ assert_eq!(env.j(&env.dir, &[expression]).ok().stdout.trim(), expected, "{}", expression);
222+ }
223+ assert_eq!(operation_heads(&env.dir), heads, "inspection recorded an operation");
224+ assert_eq!(std::fs::read(&state_path).unwrap(), state, "inspection saved the snapshot state");
225+ assert_eq!(std::fs::read_to_string(env.dir.join("modified.txt")).unwrap(), "after\n");
226+ assert_eq!(std::fs::read_to_string(env.dir.join("added.txt")).unwrap(), "new file\n");
227+ assert!(!env.dir.join("removed.txt").exists());
228+
229+ env.j(&env.dir, &["save"]).ok();
230+ assert_eq!(env.j(&env.dir, &["unrecorded"]).ok().stdout.trim(), "none");
231+ assert_eq!(env.j(&env.dir, &["(.unrecorded) . status"]).ok().stdout.trim(), "none");
232+ assert_eq!(env.j(&env.dir, &["changed"]).ok().stdout.trim(), expected);
233+}
234+
235+#[test]
236+fn unrecorded_paths_cover_the_snapshot_child_of_an_immutable_focus() {
237+ let env = setup();
238+ let dest = env.dir.join("clone");
239+ env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
240+ std::fs::write(dest.join("a.txt"), "recorded\n").unwrap();
241+ env.j(&dest, &["describe \"feature\""]).ok();
242+ env.j(&dest, &["push (label \"master\" here)"]).ok();
243+ let heads = operation_heads(&dest);
244+ std::fs::write(dest.join("a.txt"), "unrecorded\n").unwrap();
245+ assert_eq!(env.j(&dest, &["unrecorded"]).ok().stdout.trim(), "a.txt");
246+ assert_eq!(env.j(&dest, &["(.unrecorded) . status"]).ok().stdout.trim(), "a.txt");
247+ assert_eq!(operation_heads(&dest), heads);
248+ assert_eq!(git(&env.remote, &["show", "master:a.txt"]), "recorded\n");
249+ env.j(&dest, &["save"]).ok();
250+ assert_eq!(env.j(&dest, &["unrecorded"]).ok().stdout.trim(), "none");
251+ assert_eq!(env.j(&dest, &["changed"]).ok().stdout.trim(), "a.txt");
252+ assert_eq!(std::fs::read_to_string(dest.join("a.txt")).unwrap(), "unrecorded\n");
253+}
254+
255+#[test]
256+fn conflicted_push_names_files_and_gives_a_working_resolution_sequence() {
257+ let env = setup();
258+ let dest = env.dir.join("clone");
259+ env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
260+ std::fs::write(dest.join("a.txt"), "right\n").unwrap();
261+ env.j(&dest, &["describe \"right\""]).ok();
262+ let right = env.j(&dest, &["show . (.id) . focus"]).ok().stdout.trim().to_string();
263+ env.j(&dest, &["new . prev"]).ok();
264+ std::fs::write(dest.join("a.txt"), "left\n").unwrap();
265+ env.j(&dest, &["describe \"left\""]).ok();
266+ env.j(&dest, &[&format!("rebase (const [{}])", right)]).ok();
267+ let focus = env.j(&dest, &["show . (.id) . focus"]).ok().stdout.trim().to_string();
268+ let before = operation_heads(&dest);
269+ let out = env.j(&dest, &["push (label \"feature\" here)"]);
270+ assert_eq!(out.code, 1, "{}", out.stderr);
271+ for expected in ["has unresolved files: `a.txt`", "j conflicts", "Resolve the listed files", "j id"] {
272+ assert!(out.stderr.contains(expected), "{}", out.stderr);
273+ }
274+ assert!(out.stderr.contains(&format!("j by {}", focus)), "{}", out.stderr);
275+ assert_eq!(operation_heads(&dest), before);
276+ assert_eq!(git(&env.remote, &["for-each-ref", "--format=%(refname)", "refs/heads/feature"]), "");
277+
278+ env.j(&dest, &["conflicts"]).ok();
279+ env.j(&dest, &[&format!("by {}", focus)]).ok();
280+ std::fs::write(dest.join("a.txt"), "resolved\n").unwrap();
281+ env.j(&dest, &["id"]).ok();
282+ assert_eq!(env.j(&dest, &["conflicts"]).ok().stdout.trim(), "none");
283+ env.j(&dest, &["push (label \"feature\" here)"]).ok();
284+ assert_eq!(git(&env.remote, &["show", "feature:a.txt"]), "resolved\n");
285+}
286+
287+#[test]
288+fn placeholder_identity_refuses_setup_before_creating_repositories() {
289+ let env = setup();
290+ std::fs::write(env.cfg.join("j/config.j"), include_str!("../config.j")).unwrap();
291+ let out = env.j(&env.dir, &["init"]);
292+ assert_eq!(out.code, 3, "{}", out.stderr);
293+ assert!(out.stderr.contains("user identity is not configured"), "{}", out.stderr);
294+ assert!(out.stderr.contains(env.cfg.join("j/config.j").to_str().unwrap()), "{}", out.stderr);
295+ assert!(!env.dir.join(".jj").exists());
296+ assert!(!env.dir.join(".git").exists());
297+ let dest = env.dir.join("clone");
298+ let out = env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]);
299+ assert_eq!(out.code, 3, "{}", out.stderr);
300+ assert!(out.stderr.contains("user identity is not configured"), "{}", out.stderr);
301+ assert!(!dest.exists());
302+}
303+
304+#[test]
305+fn placeholder_identity_allows_inspection_but_never_records_edits() {
306+ let env = setup();
307+ env.j(&env.dir, &["init"]).ok();
308+ let reference = include_str!("../config.j");
309+ for invalid in [
310+ reference.replace("you@example.com", "test@example.com"),
311+ reference.replace("Your Name", "Test User"),
312+ reference.replace("Your Name", " ").replace("you@example.com", "test@example.com"),
313+ ] {
314+ std::fs::write(env.cfg.join("j/config.j"), invalid).unwrap();
315+ // An unchanged Repo and display-only expressions are safe even
316+ // when setup has not been completed.
317+ env.j(&env.dir, &["id"]).ok();
318+ std::fs::write(env.dir.join("draft.txt"), "my draft\n").unwrap();
319+ let before = operation_heads(&env.dir);
320+ env.j(&env.dir, &["status"]).ok();
321+ let out = env.j(&env.dir, &["describe \"draft\""]);
322+ assert_eq!(out.code, 3, "{}", out.stderr);
323+ assert!(out.stderr.contains("user identity is not configured"), "{}", out.stderr);
324+ assert_eq!(operation_heads(&env.dir), before);
325+ assert_eq!(std::fs::read_to_string(env.dir.join("draft.txt")).unwrap(), "my draft\n");
326+ std::fs::remove_file(env.dir.join("draft.txt")).unwrap();
327+ }
328+}
329+
330+#[test]
331+fn validated_preview_rejects_the_same_placeholder_identity_as_persistence() {
332+ let env = setup();
333+ env.j(&env.dir, &["init"]).ok();
334+ std::fs::write(env.dir.join("draft.txt"), "recorded draft\n").unwrap();
335+ env.j(&env.dir, &["describe \"draft\""]).ok();
336+ std::fs::write(env.cfg.join("j/config.j"), include_str!("../config.j")).unwrap();
337+ let before = operation_heads(&env.dir);
338+ let state_path = env.dir.join(".jj/working_copy/tree_state");
339+
340+ // Read-only inspection and an unchanged Repo require no authoring.
341+ // An ordinary preview remains useful while setting up an identity.
342+ for expression in ["validate", "tree", "tree . validate", "tree . new"] {
343+ env.j(&env.dir, &[expression]).ok();
344+ }
345+ let state = std::fs::read(&state_path).unwrap();
346+ let preview = env.j(&env.dir, &["tree . validate . new"]);
347+ assert_eq!(preview.code, 1, "{}", preview.stderr);
348+ assert!(preview.stderr.contains("user identity is not configured"), "{}", preview.stderr);
349+ let persisted = env.j(&env.dir, &["new"]);
350+ assert_eq!(persisted.code, 3, "{}", persisted.stderr);
351+ assert!(persisted.stderr.contains("user identity is not configured"), "{}", persisted.stderr);
352+ assert_eq!(operation_heads(&env.dir), before);
353+ assert_eq!(std::fs::read(&state_path).unwrap(), state);
354+ assert_eq!(std::fs::read_to_string(env.dir.join("draft.txt")).unwrap(), "recorded draft\n");
355+
356+ // A dirty snapshot is also a change that `id` would have to author.
357+ std::fs::write(env.dir.join("draft.txt"), "unrecorded draft\n").unwrap();
358+ let out = env.j(&env.dir, &["tree . validate"]);
359+ assert_eq!(out.code, 1, "{}", out.stderr);
360+ assert!(out.stderr.contains("user identity is not configured"), "{}", out.stderr);
361+ assert_eq!(operation_heads(&env.dir), before);
362+ assert_eq!(std::fs::read_to_string(env.dir.join("draft.txt")).unwrap(), "unrecorded draft\n");
363+}
364+
365+#[test]
366+fn dirty_undo_explains_recording_and_reverses_that_recording_first() {
367+ let env = setup();
368+ env.j(&env.dir, &["init"]).ok();
369+ env.j(&env.dir, &["describe \"original edit\""]).ok();
370+ let before = operation_heads(&env.dir);
371+ std::fs::write(env.dir.join("draft.txt"), "my draft\n").unwrap();
372+ let out = env.j(&env.dir, &["undo"]);
373+ assert_eq!(out.code, 1, "{}", out.stderr);
374+ assert!(out.stderr.contains("the next `j undo` undoes that recording"), "{}", out.stderr);
375+ assert!(out.stderr.contains("not the operation you originally wanted to undo"), "{}", out.stderr);
376+ assert_eq!(operation_heads(&env.dir), before);
377+ env.j(&env.dir, &["id"]).ok();
378+ env.j(&env.dir, &["undo"]).ok();
379+ assert!(!env.dir.join("draft.txt").exists());
380+ assert!(env.j(&env.dir, &["status"]).ok().stdout.contains("original edit"));
381+ env.j(&env.dir, &["undo"]).ok();
382+ assert!(!env.j(&env.dir, &["status"]).ok().stdout.contains("original edit"));
383+ env.j(&env.dir, &["redo"]).ok();
384+ env.j(&env.dir, &["redo"]).ok();
385+ assert_eq!(std::fs::read_to_string(env.dir.join("draft.txt")).unwrap(), "my draft\n");
386+}
387+
388+#[test]
389+fn push_expression_errors_offer_the_same_guidance_as_edits() {
390+ let env = setup();
391+ let dest = env.dir.join("clone");
392+ env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
393+ let before = operation_heads(&dest);
394+ let refs = git(&env.remote, &["for-each-ref", "--format=%(refname) %(objectname)"]);
395+ let typo = env.j(&dest, &["push labell"]);
396+ assert_eq!(typo.code, 1, "{}", typo.stderr);
397+ assert!(typo.stderr.contains("unbound name `labell`"), "{}", typo.stderr);
398+ assert!(typo.stderr.contains("Did you mean"), "{}", typo.stderr);
399+ assert!(typo.stderr.contains("`label`"), "{}", typo.stderr);
400+
401+ let incomplete = env.j(&dest, &["push (label"]);
402+ assert_eq!(incomplete.code, 3, "{}", incomplete.stderr);
403+ assert!(incomplete.stderr.contains("expression: line 1:"), "{}", incomplete.stderr);
404+ assert!(incomplete.stderr.contains("1 | (label"), "{}", incomplete.stderr);
405+
406+ let missing = env.j(&dest, &["push label"]);
407+ assert_eq!(missing.code, 1, "{}", missing.stderr);
408+ assert!(missing.stderr.contains("needs an argument of type Label"), "{}", missing.stderr);
409+ assert!(missing.stderr.contains("j help label"), "{}", missing.stderr);
410+ assert!(missing.stderr.contains("in label, from push label"), "{}", missing.stderr);
411+ assert_eq!(operation_heads(&dest), before);
412+ assert_eq!(git(&env.remote, &["for-each-ref", "--format=%(refname) %(objectname)"]), refs);
413+}
414+
415+#[test]
416+fn push_guidance_preserves_the_failing_definition_context() {
417+ let env = setup();
418+ env.j(&env.dir, &["init"]).ok();
419+ let path = env.cfg.join("j/config.j");
420+ let config = std::fs::read_to_string(&path).unwrap();
421+ std::fs::write(path, format!("{}\nlabell = \\repo -> crash \"unbound name `labell`\"\n", config)).unwrap();
422+ let out = env.j(&env.dir, &["push labell"]);
423+ assert_eq!(out.code, 1, "{}", out.stderr);
424+ assert!(out.stderr.contains("in labell, from push labell"), "{}", out.stderr);
425+ assert!(!out.stderr.contains("Did you mean"), "{}", out.stderr);
426+}
427+
428+#[test]
429+fn push_descriptions_cannot_impersonate_repository_creation() {
430+ let env = setup();
431+ let dest = env.dir.join("clone");
432+ env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
433+ env.j(&dest, &["describe \"work\""]).ok();
434+ let path = env.cfg.join("j/config.j");
435+ let config = std::fs::read_to_string(&path).unwrap();
436+ // Reserved command names remain valid names inside push expressions.
437+ for (definition, expression, changed) in [
438+ ("init = label \"feature\" here", "init", true),
439+ ("clone = \\name -> label name here", "clone \"feature\"", true),
440+ ("init = []", "init", false),
441+ ] {
442+ std::fs::write(&path, format!("{}\n{}\n", config, definition)).unwrap();
443+ let before = operation_heads(&dest);
444+ env.j(&dest, &[&format!("push {}", expression)]).ok();
445+ if !changed {
446+ assert_eq!(operation_heads(&dest), before, "empty selection created an operation");
447+ continue;
448+ }
449+ let ops = env.j(&dest, &["ops"]).ok().stdout;
450+ assert!(ops.lines().next().unwrap().ends_with(&format!("push {}", expression)), "{}", ops);
451+ if changed {
452+ assert_eq!(env.j(&dest, &["length . labelled \"feature\""]).ok().stdout.trim(), "1");
453+ }
454+ env.j(&dest, &["undo"]).ok();
455+ assert_eq!(env.j(&dest, &["length . labelled \"feature\""]).ok().stdout.trim(), "0");
456+ env.j(&dest, &["redo"]).ok();
457+ let count = if changed { "1" } else { "0" };
458+ assert_eq!(env.j(&dest, &["length . labelled \"feature\""]).ok().stdout.trim(), count);
459+ if changed {
460+ // Undo changes only the recorded label; the remote still has it.
461+ git(&env.remote, &["rev-parse", "--verify", "refs/heads/feature"]);
462+ env.j(&dest, &["push (unlabel \"feature\")"]).ok();
463+ }
464+ }
465+ // Moving back past the push also remains possible.
466+ env.j(&dest, &["undo"]).ok();
467+ env.j(&dest, &["undo"]).ok();
468+}
469+
152470#[test]
153471fn clone_sets_origin_and_labels() {
154472 let env = setup();
@@ -953,6 +1271,26 @@ fn push_rename() {
9531271 assert!(!branches.contains("old"), "{}", branches);
9541272}
9551273
1274+#[test]
1275+fn push_can_create_update_and_delete_the_at_bookmark() {
1276+ let env = setup();
1277+ let dest = env.dir.join("clone");
1278+ env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
1279+ assert_eq!(git(&dest, &["check-ref-format", "--branch", "@"]).trim(), "@");
1280+ env.j(&dest, &["describe \"first\""]).ok();
1281+ env.j(&dest, &["push (label \"@\" here)"]).ok();
1282+ let first = git(&env.remote, &["rev-parse", "refs/heads/@"]);
1283+ assert_eq!(env.j(&dest, &["length . labelled \"@\""]).ok().stdout.trim(), "1");
1284+
1285+ env.j(&dest, &["describe \"second\" . new"]).ok();
1286+ env.j(&dest, &["push (label \"@\" here)"]).ok();
1287+ assert_ne!(git(&env.remote, &["rev-parse", "refs/heads/@"]), first);
1288+ assert_eq!(git(&env.remote, &["log", "-1", "--format=%s", "refs/heads/@"]).trim(), "second");
1289+ env.j(&dest, &["push (unlabel \"@\")"]).ok();
1290+ assert!(git(&env.remote, &["for-each-ref", "--format=%(refname)", "refs/heads/@"]).is_empty());
1291+ assert_eq!(env.j(&dest, &["length . labelled \"@\""]).ok().stdout.trim(), "0");
1292+}
1293+
9561294#[test]
9571295fn push_refusals() {
9581296 let env = setup();
@@ -973,6 +1311,84 @@ fn push_refusals() {
9731311 assert_eq!(out.code, 0, "{}", out.stderr);
9741312}
9751313
1314+#[test]
1315+fn push_refuses_the_root_without_deleting_a_bookmark() {
1316+ let env = setup();
1317+ let dest = env.dir.join("clone");
1318+ env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
1319+ std::fs::write(dest.join("a.txt"), "work\n").unwrap();
1320+ env.j(&dest, &["describe \"work\""]).ok();
1321+ env.j(&dest, &["push (label \"feature\" here)"]).ok();
1322+ let refs = git(&env.remote, &["show-ref"]);
1323+ let ops = env.j(&dest, &["ops"]).ok().stdout.lines().count();
1324+ // The synthetic root has an all-zero Git id. Sending it as a target
1325+ // deleted feature, then failed to record the deletion locally.
1326+ for expr in [
1327+ "push (label \"feature\" (here . top))",
1328+ "push (\\r -> label \"another\" here r ++ label \"root\" (here . top) r)",
1329+ ] {
1330+ let out = env.j(&dest, &[expr]);
1331+ assert_eq!(out.code, 1, "{}: {}", expr, out.stderr);
1332+ assert!(out.stderr.contains("root commit cannot be pushed"), "{}", out.stderr);
1333+ assert_eq!(git(&env.remote, &["show-ref"]), refs, "{} changed the remote", expr);
1334+ assert_eq!(env.j(&dest, &["ops"]).ok().stdout.lines().count(), ops);
1335+ }
1336+ assert!(env.j(&dest, &["labelled \"feature\""]).ok().stdout.contains("work"));
1337+}
1338+
1339+#[test]
1340+fn push_checks_every_branch_name_before_sending_updates() {
1341+ let env = setup();
1342+ let dest = env.dir.join("clone");
1343+ env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
1344+ std::fs::write(dest.join("a.txt"), "work\n").unwrap();
1345+ env.j(&dest, &["describe \"work\""]).ok();
1346+ let refs = git(&env.remote, &["show-ref"]);
1347+ let ops = env.j(&dest, &["ops"]).ok().stdout.lines().count();
1348+ // HEAD was created remotely before its local export failed. Dot and
1349+ // .lock components also escaped the partial hand-written validation.
1350+ for name in ["HEAD", "topic/.hidden", "topic.lock/child", "-topic", "/topic", ""] {
1351+ let expr = format!(
1352+ "push (\\r -> label \"valid\" here r ++ label {} here r)",
1353+ j::show::text_literal(name)
1354+ );
1355+ let out = env.j(&dest, &[&expr]);
1356+ assert_eq!(out.code, 1, "{}: {}", name, out.stderr);
1357+ assert!(out.stderr.contains("not a valid git branch name"), "{}", out.stderr);
1358+ assert_eq!(git(&env.remote, &["show-ref"]), refs, "{} changed the remote", name);
1359+ assert_eq!(env.j(&dest, &["ops"]).ok().stdout.lines().count(), ops);
1360+ }
1361+ // Git permits @ as a branch and HEAD below another path component.
1362+ for name in ["@", "topic/HEAD"] {
1363+ env.j(&dest, &[&format!("push (label {} here)", j::show::text_literal(name))]).ok();
1364+ git(&env.remote, &["rev-parse", "--verify", &format!("refs/heads/{name}")]);
1365+ }
1366+}
1367+
1368+#[test]
1369+fn user_control_characters_survive_init_clone_and_open() {
1370+ let env = setup();
1371+ let name = "Test\u{1b}User\u{7f}Name";
1372+ let email = "test\u{1f}user@example.com";
1373+ let path = env.cfg.join("j/config.j");
1374+ let config = std::fs::read_to_string(&path).unwrap()
1375+ .replace("Test User", name)
1376+ .replace("test@example.com", email);
1377+ std::fs::write(path, config).unwrap();
1378+ // These are valid Text values, but writing their controls literally
1379+ // into the generated TOML used to panic before opening a repository.
1380+ let init = env.dir.join("init");
1381+ std::fs::create_dir(&init).unwrap();
1382+ env.j(&init, &["init"]).ok();
1383+ let clone = env.dir.join("clone");
1384+ env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), clone.to_str().unwrap()]).ok();
1385+ for dir in [&init, &clone] {
1386+ env.j(dir, &["describe \"work\""]).ok();
1387+ assert_eq!(env.j(dir, &["\\r -> (meta r.root.id).author"]).ok().stdout, format!("{name}\n"));
1388+ assert_eq!(env.j(dir, &["\\r -> (meta r.root.id).email"]).ok().stdout, format!("{email}\n"));
1389+ }
1390+}
1391+
9761392#[test]
9771393fn push_of_an_empty_list_pushes_nothing() {
9781394 // §7.6: an empty list of records sets and deletes no bookmark. It went to
@@ -989,29 +1405,37 @@ fn push_of_an_empty_list_pushes_nothing() {
9891405 git(&work, &["add", "l.txt"]);
9901406 git(&work, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qm", "local unpushed"]);
9911407 env.j(&work, &["init"]).ok();
992− env.j(&work, &["push []"]).ok();
1408+ let before_heads = operation_heads(&work);
1409+ let out = env.j(&work, &["push []"]).ok();
1410+ assert!(out.stderr.contains("No bookmarks selected; nothing published"), "{}", out.stderr);
1411+ assert!(out.stderr.contains("push (label \"feature\" here)"), "{}", out.stderr);
9931412 assert_eq!(master(), before, "push [] moved the remote's master");
994− // it records one operation (§7.7)
995− let ops = env.j(&work, &["ops"]).ok().stdout;
996− assert!(ops.lines().next().unwrap_or("").ends_with(" []"), "{}", ops);
1413+ assert_eq!(operation_heads(&work), before_heads, "push [] recorded an operation");
9971414
9981415 // a clone's git branch has no upstream
9991416 let dest = env.dir.join("clone");
10001417 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
10011418 env.j(&dest, &["describe \"x\""]).ok();
1002− env.j(&dest, &["push []"]).ok();
1003− env.j(&dest, &["push (label \"nope\" (labelled \"zzz\"))"]).ok();
1419+ let before_heads = operation_heads(&dest);
1420+ let unavailable_git = env.dir.join("git-must-not-be-run");
1421+ env.j_env(&dest, &["push []"], &[("J_GIT", &unavailable_git)]).ok();
1422+ let out = env.j(&dest, &["push (label \"nope\" (labelled \"zzz\"))"]).ok();
1423+ assert!(out.stderr.contains("No bookmarks selected; nothing published"), "{}", out.stderr);
1424+ assert_eq!(operation_heads(&dest), before_heads);
10041425 assert_eq!(master(), before);
10051426 let refs = git(&env.remote, &["for-each-ref", "--format=%(refname)"]);
10061427 assert_eq!(refs.trim(), "refs/heads/master");
10071428
1008− // without origin it still exits 1
1429+ // No selection needs no remote, network connection, or operation.
10091430 let plain = env.dir.join("plain");
10101431 std::fs::create_dir_all(&plain).unwrap();
10111432 env.j(&plain, &["init"]).ok();
1012− let out = env.j(&plain, &["push []"]);
1013− assert_eq!(out.code, 1);
1014− assert!(out.stderr.contains("no remote origin"), "{}", out.stderr);
1433+ let before_heads = operation_heads(&plain);
1434+ let out = env.j(&plain, &["push []"]).ok();
1435+ assert!(out.stderr.contains("No bookmarks selected; nothing published"), "{}", out.stderr);
1436+ let out = env.j(&plain, &["push relabel"]).ok();
1437+ assert!(out.stderr.contains("No bookmarks selected; nothing published"), "{}", out.stderr);
1438+ assert_eq!(operation_heads(&plain), before_heads);
10151439}
10161440
10171441#[test]
@@ -1685,6 +2109,34 @@ fn fetch_without_origin_is_exit_1() {
16852109 assert!(out.stderr.contains("no remote origin"), "{}", out.stderr);
16862110}
16872111
2112+#[test]
2113+fn fetch_does_not_read_or_create_config_even_when_it_rebases() {
2114+ for contents in [Some(&b"this is malformed ["[..]), Some(&b"\xff"[..]), None] {
2115+ let env = setup();
2116+ let (w, seed) = clone_above_s_and_t(&env);
2117+ let config_path = env.cfg.join("j/config.j");
2118+ let saved = std::fs::read(&config_path).unwrap();
2119+ if let Some(contents) = contents {
2120+ std::fs::write(&config_path, contents).unwrap();
2121+ } else {
2122+ std::fs::remove_dir_all(env.cfg.join("j")).unwrap();
2123+ }
2124+ force_push_dropping_t(&seed);
2125+ env.j(&w, &["fetch"]).ok();
2126+ if let Some(contents) = contents {
2127+ assert_eq!(std::fs::read(&config_path).unwrap(), contents);
2128+ } else {
2129+ assert!(!env.cfg.join("j").exists(), "fetch created the config directory");
2130+ }
2131+ // Rebase the recorded working-copy commit, leaving its checkout
2132+ // untouched until a persisting expression runs (§7.6).
2133+ assert_eq!(std::fs::read_to_string(w.join("t")).unwrap(), "t\n");
2134+ std::fs::create_dir_all(env.cfg.join("j")).unwrap();
2135+ std::fs::write(&config_path, saved).unwrap();
2136+ assert_eq!(env.j(&w, &[PARENT_MESSAGE]).ok().stdout.trim(), "s");
2137+ }
2138+}
2139+
16882140#[test]
16892141fn remote_command_sets_url() {
16902142 let env = setup();
tests/review_fallback.rsnew file+110
@@ -0,0 +1,110 @@
1+//! Review works with an isolated PATH and no optional diff renderer.
2+#![cfg(unix)]
3+
4+use std::os::unix::fs::PermissionsExt;
5+use std::path::PathBuf;
6+use std::process::{Command, Output};
7+
8+struct Fixture {
9+ _root: tempfile::TempDir,
10+ repo: PathBuf,
11+ config: PathBuf,
12+ bin: PathBuf,
13+}
14+
15+impl Fixture {
16+ fn new() -> Self {
17+ let root = tempfile::tempdir().unwrap();
18+ let repo = root.path().join("repo");
19+ let config = root.path().join("config");
20+ let bin = root.path().join("bin");
21+ std::fs::create_dir(&repo).unwrap();
22+ std::fs::create_dir(&bin).unwrap();
23+ std::fs::create_dir_all(config.join("j")).unwrap();
24+ std::fs::write(
25+ config.join("j/config.j"),
26+ include_str!("../config.j")
27+ .replace("Your Name", "Review Test")
28+ .replace("you@example.com", "review@example.com"),
29+ ).unwrap();
30+ let fixture = Self { _root: root, repo, config, bin };
31+ fixture.ok("init");
32+ fixture
33+ }
34+
35+ fn run(&self, expression: &str) -> Output {
36+ Command::new(env!("CARGO_BIN_EXE_j"))
37+ .arg(expression)
38+ .current_dir(&self.repo)
39+ .env("XDG_CONFIG_HOME", &self.config)
40+ .env("PATH", &self.bin)
41+ .env("NO_COLOR", "1")
42+ .output().unwrap()
43+ }
44+
45+ fn ok(&self, expression: &str) -> String {
46+ let out = self.run(expression);
47+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
48+ assert!(out.stderr.is_empty(), "{}", String::from_utf8_lossy(&out.stderr));
49+ String::from_utf8(out.stdout).unwrap()
50+ }
51+}
52+
53+#[test]
54+fn review_without_difft_identifies_added_deleted_and_modified_paths() {
55+ let fixture = Fixture::new();
56+ for (dir, content) in [("src", "before\n"), ("other", "gone\n")] {
57+ std::fs::create_dir(fixture.repo.join(dir)).unwrap();
58+ std::fs::write(fixture.repo.join(dir).join("file.txt"), content).unwrap();
59+ }
60+ fixture.ok("id");
61+ fixture.ok("new");
62+ std::fs::write(fixture.repo.join("src/file.txt"), "after").unwrap();
63+ std::fs::remove_file(fixture.repo.join("other/file.txt")).unwrap();
64+ std::fs::write(fixture.repo.join("added.txt"), "new\n").unwrap();
65+
66+ assert_eq!(fixture.ok("review"), concat!(
67+ "--- a/added.txt\n+++ b/added.txt\n@@ -0,0 +1 @@\n+new\n",
68+ "--- a/other/file.txt\n+++ b/other/file.txt\n@@ -1 +0,0 @@\n-gone\n",
69+ "--- a/src/file.txt\n+++ b/src/file.txt\n@@ -1 +1 @@\n-before\n+after\n",
70+ "\\ No newline at end of file\n",
71+ ));
72+}
73+
74+#[test]
75+fn fallback_reports_binary_changes_without_corrupting_their_bytes() {
76+ let fixture = Fixture::new();
77+ std::fs::write(fixture.repo.join("invalid.bin"), [0xff]).unwrap();
78+ std::fs::write(fixture.repo.join("nul.bin"), b"a\0").unwrap();
79+ assert_eq!(fixture.ok("review"), concat!(
80+ "Binary files a/invalid.bin and b/invalid.bin differ\n",
81+ "Binary files a/nul.bin and b/nul.bin differ\n",
82+ ));
83+ assert_eq!(std::fs::read(fixture.repo.join("invalid.bin")).unwrap(), [0xff]);
84+ assert_eq!(std::fs::read(fixture.repo.join("nul.bin")).unwrap(), b"a\0");
85+}
86+
87+#[test]
88+fn fallback_quotes_paths_and_omits_equal_content() {
89+ let fixture = Fixture::new();
90+ assert_eq!(fixture.ok("difft [\"dir name\" \"file.txt\"] (blob \"a\\n\") (blob \"b\\n\")"), concat!(
91+ "--- \"a/dir name/file.txt\"\n+++ \"b/dir name/file.txt\"\n",
92+ "@@ -1 +1 @@\n-a\n+b\n",
93+ ));
94+ assert!(fixture.ok("difft ./same (blob \"same\") (blob \"same\")").trim().is_empty());
95+}
96+
97+#[test]
98+fn an_unexecutable_renderer_reports_its_error_without_falling_back() {
99+ let fixture = Fixture::new();
100+ let executable = fixture.bin.join("difft");
101+ std::fs::write(&executable, "#!/bin/sh\nexit 0\n").unwrap();
102+ std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o644)).unwrap();
103+ std::fs::write(fixture.repo.join("file.txt"), "new\n").unwrap();
104+ let out = fixture.run("review");
105+ assert_eq!(out.status.code(), Some(1));
106+ assert!(out.stdout.is_empty());
107+ let error = String::from_utf8(out.stderr).unwrap();
108+ assert!(error.contains("cannot execute `difft`"), "{error}");
109+ assert!(error.contains("Permission denied"), "{error}");
110+}
tests/setup.rsnew file+144
@@ -0,0 +1,144 @@
1+//! First-run configuration must be complete, safe to repeat, and independent
2+//! of repository state.
3+
4+use std::path::PathBuf;
5+use std::process::{Command, Output, Stdio};
6+
7+struct SetupCli {
8+ dir: tempfile::TempDir,
9+ config: tempfile::TempDir,
10+}
11+
12+impl SetupCli {
13+ fn new() -> Self {
14+ Self { dir: tempfile::tempdir().unwrap(), config: tempfile::tempdir().unwrap() }
15+ }
16+
17+ fn path(&self) -> PathBuf { self.config.path().join("j/config.j") }
18+
19+ fn command(&self, args: &[&str]) -> Command {
20+ let mut command = Command::new(env!("CARGO_BIN_EXE_j"));
21+ command.args(args).current_dir(self.dir.path()).env("XDG_CONFIG_HOME", self.config.path())
22+ .env("NO_COLOR", "1").stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
23+ command
24+ }
25+
26+ fn run(&self, args: &[&str]) -> Output { self.command(args).output().unwrap() }
27+}
28+
29+fn success(output: Output) -> String {
30+ assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
31+ assert!(output.stderr.is_empty(), "{}", String::from_utf8_lossy(&output.stderr));
32+ String::from_utf8(output.stdout).unwrap()
33+}
34+
35+#[test]
36+fn setup_with_identity_creates_a_complete_usable_config_without_a_repo() {
37+ let cli = SetupCli::new();
38+ let output = success(cli.run(&["setup", "Ada Lovelace", "ada@example.org"]));
39+ assert!(output.contains("Configuration ready"));
40+ assert!(output.contains("j init"));
41+ assert!(!cli.dir.path().join(".jj").exists());
42+ let source = std::fs::read_to_string(cli.path()).unwrap();
43+ let cfg = j::config::load_config(&source).unwrap();
44+ assert!(cfg.defs.iter().any(|(name, _)| name == "new"));
45+ assert!(cfg.defs.iter().any(|(name, _)| name == "save"));
46+ assert_eq!(j::config::eval_user_only(&mut j::eval::Interp::dummy(), &cfg).unwrap(), ("Ada Lovelace".into(), "ada@example.org".into()));
47+ success(cli.run(&["init"]));
48+ success(cli.run(&["status"]));
49+
50+ #[cfg(unix)] {
51+ use std::os::unix::fs::PermissionsExt;
52+ assert_eq!(std::fs::metadata(cli.path()).unwrap().permissions().mode() & 0o777, 0o600);
53+ }
54+}
55+
56+#[test]
57+fn bare_setup_creates_the_entire_default_and_explains_the_identity_edit() {
58+ let cli = SetupCli::new();
59+ let output = success(cli.run(&["setup"]));
60+ assert!(output.contains("user = { name ="));
61+ assert!(output.contains("Keep the rest of the file"));
62+ assert_eq!(std::fs::read_to_string(cli.path()).unwrap(), include_str!("../config.j"));
63+ assert_eq!(std::fs::read_dir(cli.dir.path()).unwrap().count(), 0);
64+ let output = cli.run(&["init"]);
65+ assert_eq!(output.status.code(), Some(3));
66+ assert!(String::from_utf8_lossy(&output.stderr).contains("identity is not configured"));
67+}
68+
69+#[test]
70+fn identity_strings_round_trip_quotes_backslashes_unicode_and_controls() {
71+ let cli = SetupCli::new();
72+ let name = "Ада \"Lovelace\" \\ Test\nSecond line\t";
73+ let email = "ada+\"quote\"\\suffix@example.org";
74+ success(cli.run(&["setup", name, email]));
75+ let cfg = j::config::load_config(&std::fs::read_to_string(cli.path()).unwrap()).unwrap();
76+ assert_eq!(j::config::eval_user_only(&mut j::eval::Interp::dummy(), &cfg).unwrap(), (name.into(), email.into()));
77+}
78+
79+#[test]
80+fn existing_custom_or_broken_configs_are_never_replaced() {
81+ let cli = SetupCli::new();
82+ success(cli.run(&["setup", "Existing Person", "existing@example.org"]));
83+ let source = format!("{}\nmyCommand = \\repo -> repo\n", std::fs::read_to_string(cli.path()).unwrap());
84+ std::fs::write(cli.path(), &source).unwrap();
85+ let output = success(cli.run(&["setup"]));
86+ assert!(output.contains("already exists") && output.contains("ready"));
87+ assert!(output.contains("Existing Person"));
88+ let output = cli.run(&["setup", "Replacement", "replacement@example.org"]);
89+ assert_eq!(output.status.code(), Some(2));
90+ assert!(String::from_utf8_lossy(&output.stderr).contains("was not changed"));
91+ assert_eq!(std::fs::read_to_string(cli.path()).unwrap(), source);
92+
93+ std::fs::write(cli.path(), "broken =\n").unwrap();
94+ let output = success(cli.run(&["setup"]));
95+ assert!(output.contains("j help config"));
96+ assert!(output.contains("line 1:") && output.contains("unexpected"), "{}", output);
97+ assert_eq!(std::fs::read_to_string(cli.path()).unwrap(), "broken =\n");
98+}
99+
100+#[test]
101+fn usage_and_invalid_identities_do_not_create_files() {
102+ for args in [
103+ vec!["setup", "one"],
104+ vec!["setup", "one", "two", "three"],
105+ vec!["setup", "", "ada@example.org"],
106+ vec!["setup", "Ada", " "],
107+ vec!["setup", "Your Name", "you@example.com"],
108+ ] {
109+ let cli = SetupCli::new();
110+ let output = cli.run(&args);
111+ assert!(!output.status.success(), "{:?}", args);
112+ assert!(!cli.path().exists());
113+ assert_eq!(std::fs::read_dir(cli.config.path()).unwrap().count(), 0);
114+ }
115+}
116+
117+#[test]
118+fn concurrent_setup_publishes_one_complete_config_without_overwriting() {
119+ let cli = SetupCli::new();
120+ let first = cli.command(&["setup", "First Person", "first@example.org"]).spawn().unwrap();
121+ let second = cli.command(&["setup", "Second Person", "second@example.org"]).spawn().unwrap();
122+ let first = first.wait_with_output().unwrap();
123+ let second = second.wait_with_output().unwrap();
124+ assert_ne!(first.status.success(), second.status.success());
125+ let cfg = j::config::load_config(&std::fs::read_to_string(cli.path()).unwrap()).unwrap();
126+ let identity = j::config::eval_user_only(&mut j::eval::Interp::dummy(), &cfg).unwrap();
127+ assert!(identity == ("First Person".into(), "first@example.org".into())
128+ || identity == ("Second Person".into(), "second@example.org".into()));
129+ assert_eq!(std::fs::read_dir(cli.path().parent().unwrap()).unwrap().count(), 1);
130+}
131+
132+#[test]
133+#[cfg(unix)]
134+fn setup_does_not_follow_existing_symlinks_to_overwrite_files() {
135+ let cli = SetupCli::new();
136+ let target = cli.config.path().join("personal-config");
137+ std::fs::write(&target, "my private config").unwrap();
138+ std::fs::create_dir_all(cli.path().parent().unwrap()).unwrap();
139+ std::os::unix::fs::symlink(&target, cli.path()).unwrap();
140+ let output = cli.run(&["setup", "Ada", "ada@example.org"]);
141+ assert_eq!(output.status.code(), Some(2));
142+ assert_eq!(std::fs::read_to_string(&target).unwrap(), "my private config");
143+ assert!(std::fs::symlink_metadata(cli.path()).unwrap().file_type().is_symlink());
144+}
tests/show.rs+147−1
@@ -5,7 +5,7 @@ use j::domain::MemBackend;
55use j::eval::Interp;
66use j::parse::parse_expr;
77use j::show::{show, text_literal, unified_diff};
8−use j::value::{value_eq, BlobContent, BlobKind, BlobVal, ConflictSide, Env, Value};
8+use j::value::{value_eq, BlobContent, BlobKind, BlobVal, ConflictSide, Crash, Env, LazyBlob, Value};
99use std::alloc::{GlobalAlloc, Layout, System};
1010use std::cell::Cell;
1111use std::rc::Rc;
@@ -111,6 +111,89 @@ fn show_roundtrips_all_kinds() {
111111 roundtrip(&mut i, &cfg, &Value::Shape(Rc::new(s)));
112112}
113113
114+#[test]
115+fn show_preserves_resolved_blob_bytes_and_file_types() {
116+ let (mut i, cfg) = make_interp();
117+ for kind in [BlobKind::Regular, BlobKind::Executable, BlobKind::Symlink] {
118+ for bytes in [vec![], b"target\n".to_vec(), vec![0xff, 0x80], (0..=255).collect()] {
119+ let bytes = Rc::new(bytes);
120+ let eager = Value::Blob(Rc::new(BlobVal {
121+ kind: kind.clone(),
122+ content: BlobContent::Resolved(bytes.clone()),
123+ }));
124+ let lazy = Value::Blob(Rc::new(BlobVal {
125+ kind: kind.clone(),
126+ content: BlobContent::Lazy(Rc::new(LazyBlob::new(
127+ "stored-blob".into(), move || Ok(bytes),
128+ ))),
129+ }));
130+ assert_eq!(show(&i, &eager).unwrap(), show(&i, &lazy).unwrap());
131+ roundtrip(&mut i, &cfg, &eager);
132+ roundtrip(&mut i, &cfg, &lazy);
133+ // Snapshots exercise atom parentheses and wide rendering too.
134+ let snapshot = Value::list(vec![Value::record(&[
135+ ("content", lazy),
136+ ("path", Value::list(vec![Value::text("file")])),
137+ ])]);
138+ roundtrip(&mut i, &cfg, &snapshot);
139+ }
140+ }
141+ assert_eq!(show(&i, &BlobVal::text_blob("text")).unwrap(), "blob \"text\"");
142+ let binary = Value::Blob(Rc::new(BlobVal {
143+ kind: BlobKind::Regular,
144+ content: BlobContent::Resolved(Rc::new(vec![0xff, 0x80])),
145+ }));
146+ assert_eq!(show(&i, &binary).unwrap(), "blobHex \"regular\" \"ff80\"");
147+}
148+
149+#[test]
150+fn show_requires_a_lossless_constructor_in_older_configs() {
151+ let src = CONFIG.lines().filter(|line| !line.starts_with("blobHex "))
152+ .collect::<Vec<_>>().join("\n");
153+ let cfg = config::load_config(&src).unwrap();
154+ let mut i = Interp::new(Rc::new(MemBackend::new()), cfg.shapes.clone(), Env::empty());
155+ config::eval_config(&mut i, &cfg).unwrap();
156+ assert_eq!(show(&i, &BlobVal::text_blob("text")).unwrap(), "blob \"text\"");
157+ for (kind, bytes) in [
158+ (BlobKind::Regular, vec![0xff, 0x80]),
159+ (BlobKind::Executable, b"script".to_vec()),
160+ (BlobKind::Symlink, b"target".to_vec()),
161+ ] {
162+ let v = Value::Blob(Rc::new(BlobVal {
163+ kind,
164+ content: BlobContent::Resolved(Rc::new(bytes)),
165+ }));
166+ let error = show(&i, &v).unwrap_err();
167+ assert!(error.msg.contains("blobHex : Text -> Text -> Blob"), "{}", error.msg);
168+ }
169+}
170+
171+#[test]
172+fn show_propagates_blob_read_errors() {
173+ let (i, _) = make_interp();
174+ let v = Value::Blob(Rc::new(BlobVal {
175+ kind: BlobKind::Executable,
176+ content: BlobContent::Lazy(Rc::new(LazyBlob::new(
177+ "missing-blob".into(), || Err(Crash::new("store cannot read blob")),
178+ ))),
179+ }));
180+ assert_eq!(show(&i, &v).unwrap_err().msg, "store cannot read blob");
181+}
182+
183+#[test]
184+fn difft_rejects_path_components_that_are_not_basenames() {
185+ let mut invalid = vec!["", ".", "..", "../outside", "../../outside", "/outside", "nested/file", "./file", "file/"];
186+ if cfg!(windows) {
187+ invalid.extend(["..\\outside", "nested\\file", "C:\\outside", "C:outside", "\\outside", "file\\"]);
188+ }
189+ for name in invalid {
190+ let path = Value::list(vec![Value::text(name)]);
191+ let error = j::show::difft(&path, &BlobVal::text_blob("old"), &BlobVal::text_blob("new"))
192+ .unwrap_err();
193+ assert!(error.msg.contains("last path component must be a filename"), "{name:?}: {}", error.msg);
194+ }
195+}
196+
114197#[test]
115198fn show_record_field_order() {
116199 let (i, _cfg) = make_interp();
@@ -234,6 +317,69 @@ fn show_unresolved_blob() {
234317 assert!(s.starts_with("{- unresolved -} blob"), "{}", s);
235318}
236319
320+#[test]
321+fn show_older_configs_refuse_resolved_blobs_without_an_equal_literal() {
322+ let src = CONFIG.lines().filter(|line| !line.starts_with("blobHex "))
323+ .collect::<Vec<_>>().join("\n");
324+ let cfg = config::load_config(&src).unwrap();
325+ let mut i = Interp::new(Rc::new(MemBackend::new()), cfg.shapes.clone(), Env::empty());
326+ config::eval_config(&mut i, &cfg).unwrap();
327+ let catcher = parse_expr("\\x -> show x or \"caught\"", Rc::new(cfg.global_names.clone())).unwrap();
328+ let env = i.global_env();
329+ let catcher = i.eval(&Rc::new(catcher), &env).unwrap();
330+ for lazy in [false, true] {
331+ for (kind, bytes) in [
332+ (BlobKind::Regular, vec![0xff, 0xfe]),
333+ (BlobKind::Executable, b"echo hi\n".to_vec()),
334+ (BlobKind::Symlink, b"target".to_vec()),
335+ ] {
336+ let content = if lazy {
337+ BlobContent::Lazy(Rc::new(LazyBlob::new("stored".into(), move || Ok(Rc::new(bytes)))))
338+ } else {
339+ BlobContent::Resolved(Rc::new(bytes))
340+ };
341+ let blob = Value::Blob(Rc::new(BlobVal { kind, content }));
342+ // The failure propagates through nested snapshots and is caught
343+ // by the language, instead of yielding a damaged replacement.
344+ let snapshot = Value::list(vec![Value::record(&[
345+ ("content", blob),
346+ ("path", Value::list(vec![Value::text("file")])),
347+ ])]);
348+ let error = show(&i, &snapshot).unwrap_err();
349+ assert!(error.msg.contains("blobHex : Text -> Text -> Blob"), "{}", error.msg);
350+ let caught = i.apply(catcher.clone(), snapshot.clone()).unwrap();
351+ assert_eq!(caught.as_text().unwrap(), "caught");
352+ assert!(show(&i, &snapshot).is_err());
353+ }
354+ }
355+ // A stored regular UTF-8 blob still has exactly the same representation
356+ // as an eager one and round-trips, including embedded NUL bytes.
357+ let regular = Value::Blob(Rc::new(BlobVal {
358+ kind: BlobKind::Regular,
359+ content: BlobContent::Lazy(Rc::new(LazyBlob::new("text".into(), || {
360+ Ok(Rc::new(b"hello\0\n".to_vec()))
361+ }))),
362+ }));
363+ roundtrip(&mut i, &cfg, &regular);
364+}
365+
366+#[test]
367+fn show_keeps_the_unresolved_blob_fallback_for_binary_and_typed_conflicts() {
368+ let (i, _cfg) = make_interp();
369+ for kind in [BlobKind::Regular, BlobKind::Executable, BlobKind::Symlink] {
370+ let v = Value::Blob(Rc::new(BlobVal {
371+ kind: kind.clone(),
372+ content: BlobContent::Conflict(vec![Some(ConflictSide {
373+ kind,
374+ bytes: Rc::new(vec![0xff, 0xfe]),
375+ tree: None,
376+ })]),
377+ }));
378+ let s = show(&i, &v).unwrap();
379+ assert!(s.starts_with("{- unresolved -} blob"), "{}", s);
380+ }
381+}
382+
237383#[test]
238384fn show_line_breaking() {
239385 let (i, _cfg) = make_interp();
tests/showprop.rs+8−2
@@ -7,7 +7,7 @@ use j::domain::MemBackend;
77use j::eval::Interp;
88use j::parse::parse_expr;
99use j::show::show;
10−use j::value::{value_eq, BlobVal, Env, Value};
10+use j::value::{value_eq, BlobContent, BlobKind, BlobVal, Env, Value};
1111use proptest::prelude::*;
1212use std::rc::Rc;
1313
@@ -25,7 +25,13 @@ fn arb_value(depth: usize) -> BoxedStrategy<Value> {
2525 any::<i64>().prop_map(Value::int),
2626 "[a-zA-Z0-9 \n\t\"\\\\]{0,12}".prop_map(Value::text),
2727 any::<bool>().prop_map(Value::Bool),
28− Just(BlobVal::text_blob("blob content")),
28+ (
29+ prop::sample::select(vec![BlobKind::Regular, BlobKind::Executable, BlobKind::Symlink]),
30+ prop::collection::vec(any::<u8>(), 0..64),
31+ ).prop_map(|(kind, bytes)| Value::Blob(Rc::new(BlobVal {
32+ kind,
33+ content: BlobContent::Resolved(Rc::new(bytes)),
34+ }))),
2935 ];
3036 leaf.prop_recursive(depth as u32, 24, 6, |inner| {
3137 prop_oneof![
tests/terminal.rsnew file+621
@@ -0,0 +1,621 @@
1+//! Interactive presentation uses real terminal descriptors; pipes stay quiet,
2+//! and color opt-outs preserve useful plain confirmations (§1.4–1.5).
3+
4+#![cfg(unix)]
5+
6+use std::fs::File;
7+use std::io::{self, Read};
8+use std::os::fd::{FromRawFd, RawFd};
9+use std::os::unix::fs::PermissionsExt;
10+use std::path::PathBuf;
11+use std::process::{Command, Stdio};
12+use unicode_width::UnicodeWidthStr;
13+
14+const CLEAR_LINE: &str = "\r\x1b[2K";
15+// A bounded builtin fold keeps evaluation active for multiple progress frames
16+// without constructing a deep recursive value or touching the network.
17+const SLOW_SUM: &str = "foldl (+) 0 (range 0 500000)";
18+
19+struct Fixture {
20+ root: tempfile::TempDir,
21+ dir: PathBuf,
22+ cfg: PathBuf,
23+}
24+
25+impl Fixture {
26+ fn new() -> Self {
27+ let root = tempfile::tempdir().unwrap();
28+ let dir = root.path().join("work");
29+ let cfg = root.path().join("config");
30+ std::fs::create_dir(&dir).unwrap();
31+ std::fs::create_dir_all(cfg.join("j")).unwrap();
32+ let config = include_str!("../config.j")
33+ .replace("Your Name", "Terminal Test")
34+ .replace("you@example.com", "terminal@example.com");
35+ std::fs::write(cfg.join("j/config.j"), config).unwrap();
36+ Self { root, dir, cfg }
37+ }
38+
39+ fn command(&self, args: &[&str]) -> Command {
40+ let mut command = Command::new(env!("CARGO_BIN_EXE_j"));
41+ command
42+ .args(args)
43+ .current_dir(&self.dir)
44+ .env("XDG_CONFIG_HOME", &self.cfg)
45+ .env("TERM", "xterm-256color")
46+ .env_remove("NO_COLOR")
47+ .env_remove("CI")
48+ .env_remove("J_ANIMATIONS")
49+ .env_remove("J_GIT")
50+ .stdin(Stdio::null());
51+ command
52+ }
53+
54+ fn init(&self) {
55+ let out = self.command(&["init"]).output().unwrap();
56+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
57+ }
58+
59+ fn slow_clone(&self, fail: bool) -> Command {
60+ let remote = self.root.path().join("remote.git");
61+ let out = Command::new("git")
62+ .args(["init", "--bare", "-q"])
63+ .arg(&remote)
64+ .output()
65+ .unwrap();
66+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
67+ // Clone installs the remote during this run, so it uses the fetch
68+ // subprocess controlled by J_GIT. The fixture never contacts a network.
69+ let stub = self.root.path().join("slow-git");
70+ let failure = if fail {
71+ "echo 'terminal fixture failure' >&2; exit 1;"
72+ } else {
73+ ""
74+ };
75+ std::fs::write(&stub, format!(
76+ "#!/bin/sh\ncase \" $* \" in *\" fetch \"*) sleep 0.6; {failure} ;; esac\nexec git \"$@\"\n",
77+ )).unwrap();
78+ std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
79+ let mut command = self.command(&["clone", remote.to_str().unwrap(), "clone"]);
80+ command.env("J_GIT", stub);
81+ command
82+ }
83+}
84+
85+struct Out {
86+ code: i32,
87+ stdout: String,
88+ stderr: String,
89+}
90+
91+/// Return the master to capture output and the slave to install as stdout or
92+/// stderr. Disable newline translation so assertions see the bytes j wrote.
93+fn pty(columns: u16) -> (File, File) {
94+ let (mut master, mut slave): (RawFd, RawFd) = (-1, -1);
95+ let size = libc::winsize {
96+ ws_row: 24,
97+ ws_col: columns,
98+ ws_xpixel: 0,
99+ ws_ypixel: 0,
100+ };
101+ assert_eq!(
102+ unsafe {
103+ libc::openpty(
104+ &mut master,
105+ &mut slave,
106+ std::ptr::null_mut(),
107+ std::ptr::null(),
108+ &size,
109+ )
110+ },
111+ 0,
112+ "openpty: {}",
113+ io::Error::last_os_error()
114+ );
115+ let files = unsafe { (File::from_raw_fd(master), File::from_raw_fd(slave)) };
116+ for fd in [master, slave] {
117+ assert_ne!(unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) }, -1);
118+ }
119+ let mut settings = std::mem::MaybeUninit::<libc::termios>::uninit();
120+ assert_eq!(unsafe { libc::tcgetattr(slave, settings.as_mut_ptr()) }, 0);
121+ let mut settings = unsafe { settings.assume_init() };
122+ settings.c_oflag &= !libc::OPOST;
123+ assert_eq!(unsafe { libc::tcsetattr(slave, libc::TCSANOW, &settings) }, 0);
124+ files
125+}
126+
127+fn read_stream(mut reader: Box<dyn Read + Send>) -> String {
128+ let mut bytes = Vec::new();
129+ let mut buffer = [0; 4096];
130+ loop {
131+ match reader.read(&mut buffer) {
132+ Ok(0) => break,
133+ Ok(n) => bytes.extend_from_slice(&buffer[..n]),
134+ // Linux PTYs report EIO after their last slave has closed.
135+ Err(e) if e.raw_os_error() == Some(libc::EIO) => break,
136+ Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
137+ Err(e) => panic!("reading subprocess output: {e}"),
138+ }
139+ }
140+ String::from_utf8(bytes).unwrap()
141+}
142+
143+fn capture(command: Command, stdout_tty: bool, stderr_tty: bool) -> Out {
144+ capture_with_stderr_width(command, stdout_tty, stderr_tty, 80)
145+}
146+
147+fn capture_with_stderr_width(
148+ mut command: Command,
149+ stdout_tty: bool,
150+ stderr_tty: bool,
151+ columns: u16,
152+) -> Out {
153+ let stdout = stdout_tty.then(|| pty(80)).map(|(master, slave)| {
154+ command.stdout(slave);
155+ master
156+ });
157+ let stderr = stderr_tty.then(|| pty(columns)).map(|(master, slave)| {
158+ command.stderr(slave);
159+ master
160+ });
161+ if !stdout_tty {
162+ command.stdout(Stdio::piped());
163+ }
164+ if !stderr_tty {
165+ command.stderr(Stdio::piped());
166+ }
167+ let mut child = command.spawn().unwrap();
168+ drop(command); // Close the parent's slave descriptors so readers reach EOF.
169+ let stdout: Box<dyn Read + Send> = match stdout {
170+ Some(master) => Box::new(master),
171+ None => Box::new(child.stdout.take().unwrap()),
172+ };
173+ let stderr: Box<dyn Read + Send> = match stderr {
174+ Some(master) => Box::new(master),
175+ None => Box::new(child.stderr.take().unwrap()),
176+ };
177+ let stdout = std::thread::spawn(move || read_stream(stdout));
178+ let stderr = std::thread::spawn(move || read_stream(stderr));
179+ let started = std::time::Instant::now();
180+ let status = loop {
181+ if let Some(status) = child.try_wait().unwrap() {
182+ break status;
183+ }
184+ if started.elapsed() > std::time::Duration::from_secs(15) {
185+ let _ = child.kill();
186+ let _ = child.wait();
187+ panic!("terminal subprocess did not exit within 15 seconds");
188+ }
189+ std::thread::sleep(std::time::Duration::from_millis(10));
190+ };
191+ Out {
192+ code: status.code().unwrap_or(-1),
193+ stdout: stdout.join().unwrap(),
194+ stderr: stderr.join().unwrap(),
195+ }
196+}
197+
198+fn assert_no_motion(out: &Out) {
199+ for text in [&out.stdout, &out.stderr] {
200+ assert!(!text.contains('\r'), "unexpected terminal animation: {text:?}");
201+ assert!(!text.contains("\x1b[2K"), "unexpected line clearing: {text:?}");
202+ assert!(!text.contains("\x1b[?25"), "unexpected cursor control: {text:?}");
203+ }
204+}
205+
206+fn without_sgr(text: &str) -> String {
207+ let mut plain = String::new();
208+ let mut chars = text.chars();
209+ while let Some(c) = chars.next() {
210+ if c == '\x1b' {
211+ assert_eq!(chars.next(), Some('['), "unexpected escape: {text:?}");
212+ for parameter in chars.by_ref() {
213+ if parameter == 'm' {
214+ break;
215+ }
216+ assert!(parameter.is_ascii_digit() || parameter == ';');
217+ }
218+ } else {
219+ plain.push(c);
220+ }
221+ }
222+ plain
223+}
224+
225+fn assert_progress(out: &Out, label: &str) {
226+ let frames: Vec<_> = out.stderr.split(CLEAR_LINE)
227+ .filter(|frame| frame.contains(label))
228+ .map(without_sgr)
229+ .collect();
230+ assert!(frames.len() >= 2, "no repeated {label} frames: {:?}", out.stderr);
231+ let mut bars = std::collections::BTreeSet::new();
232+ for frame in &frames {
233+ let (_, details) = frame.split_once(" [")
234+ .unwrap_or_else(|| panic!("missing activity bar: {frame:?}"));
235+ let (bar, elapsed) = details.split_once("] ")
236+ .unwrap_or_else(|| panic!("missing elapsed time: {frame:?}"));
237+ assert!(bar.contains('━') && bar.contains('·'), "invalid activity bar: {bar:?}");
238+ assert!(elapsed.strip_suffix('s').and_then(|s| s.parse::<f64>().ok()).is_some(),
239+ "invalid elapsed time: {elapsed:?}");
240+ bars.insert(bar);
241+ }
242+ assert!(bars.len() >= 2, "activity bar did not animate: {frames:?}");
243+}
244+
245+#[test]
246+fn terminal_tree_is_colored_and_raw_text_is_unchanged() {
247+ let fixture = Fixture::new();
248+ fixture.init();
249+ let tree = capture(fixture.command(&["tree"]), true, true);
250+ assert_eq!(tree.code, 0, "{}", tree.stderr);
251+ assert!(tree.stdout.contains("\x1b["), "tree has no color: {:?}", tree.stdout);
252+
253+ let expr = "\"a rainbow\\nkeeps its bytes\\n\"";
254+ let plain = capture(fixture.command(&[expr]), false, false);
255+ let terminal = capture(fixture.command(&[expr]), true, true);
256+ assert_eq!(plain.code, 0, "{}", plain.stderr);
257+ assert_eq!(terminal.code, 0, "{}", terminal.stderr);
258+ assert_eq!(terminal.stdout, plain.stdout);
259+ assert_eq!(terminal.stdout, "a rainbow\nkeeps its bytes\n");
260+}
261+
262+#[test]
263+fn slow_expression_animates_and_clears_before_reporting_failure() {
264+ let fixture = Fixture::new();
265+ fixture.init();
266+ let expr = format!(
267+ "if {SLOW_SUM} == 124999750000 then crash \"expression fixture failure\" else \"\""
268+ );
269+ let out = capture(fixture.command(&[&expr]), true, true);
270+ assert_eq!(out.code, 1, "{}", out.stderr);
271+ assert!(out.stdout.is_empty(), "progress leaked into stdout: {:?}", out.stdout);
272+ assert_progress(&out, "Evaluating expression");
273+ let last_clear = out.stderr.rfind(CLEAR_LINE).expect("progress was never cleared");
274+ let diagnostic = without_sgr(&out.stderr[last_clear + CLEAR_LINE.len()..]);
275+ assert!(diagnostic.starts_with("j:"), "progress label was not cleared: {diagnostic:?}");
276+ assert!(diagnostic.contains("expression fixture failure"), "{diagnostic:?}");
277+ assert!(diagnostic.ends_with('\n'), "diagnostic did not end a line: {diagnostic:?}");
278+}
279+
280+#[test]
281+fn slow_implicit_application_preserves_output_and_respects_opt_outs() {
282+ let fixture = Fixture::new();
283+ fixture.init();
284+ // Evaluating this lambda is fast; applying it to the implicit repository
285+ // must remain inside the same progress phase.
286+ let expr = format!(
287+ "\\repo -> if {SLOW_SUM} == 124999750000 then \"computed\\nunchanged\\n\" else crash \"unexpected sum\""
288+ );
289+ let animated = capture(fixture.command(&[&expr]), true, true);
290+ assert_eq!(animated.code, 0, "{}", animated.stderr);
291+ assert_eq!(animated.stdout, "computed\nunchanged\n");
292+ assert_progress(&animated, "Evaluating expression");
293+ assert!(animated.stderr.ends_with(CLEAR_LINE), "progress was not cleared: {:?}", animated.stderr);
294+
295+ let mut command = fixture.command(&[&expr]);
296+ command.env("J_ANIMATIONS", "off");
297+ let opted_out = capture(command, true, true);
298+ let piped = capture(fixture.command(&[&expr]), false, false);
299+ for out in [opted_out, piped] {
300+ assert_eq!(out.code, 0, "{}", out.stderr);
301+ assert_eq!(out.stdout, animated.stdout);
302+ assert_no_motion(&out);
303+ }
304+}
305+
306+#[test]
307+fn slow_configuration_animates_and_clears_before_output() {
308+ let fixture = Fixture::new();
309+ fixture.init();
310+ let path = fixture.cfg.join("j/config.j");
311+ let mut config = std::fs::read_to_string(&path).unwrap();
312+ config.push_str(&format!("\nterminalWork = {SLOW_SUM}\n"));
313+ std::fs::write(path, config).unwrap();
314+ let out = capture(fixture.command(&["show terminalWork"]), true, true);
315+ assert_eq!(out.code, 0, "{}", out.stderr);
316+ assert_eq!(out.stdout, "124999750000\n");
317+ assert_progress(&out, "Evaluating configuration");
318+ assert!(out.stderr.ends_with(CLEAR_LINE), "progress was not cleared: {:?}", out.stderr);
319+}
320+
321+#[test]
322+fn slow_work_animates_and_clears_before_reporting_failure() {
323+ let fixture = Fixture::new();
324+ let mut command = fixture.slow_clone(true);
325+ // Empty opt-out variables are not opt-outs.
326+ command.env("NO_COLOR", "").env("CI", "");
327+ let out = capture(command, true, true);
328+ assert_eq!(out.code, 1, "{}", out.stderr);
329+ assert!(out.stdout.is_empty(), "spinner leaked into stdout: {:?}", out.stdout);
330+ let clear = "\r\x1b[2K";
331+ assert!(out.stderr.matches(clear).count() >= 3, "no repeated frames: {:?}", out.stderr);
332+ let diagnostic = out.stderr.find("j:").expect("missing diagnostic prefix");
333+ let last_clear = out.stderr.rfind(clear).expect("spinner was never cleared");
334+ assert!(last_clear < diagnostic, "animation continued over diagnostic: {:?}", out.stderr);
335+ let final_line = without_sgr(&out.stderr[last_clear + clear.len()..]);
336+ assert!(final_line.starts_with("j:"), "spinner label was not cleared: {final_line:?}");
337+ assert!(final_line.contains("terminal fixture failure"));
338+ assert!(out.stderr.ends_with('\n'), "diagnostic did not end a line: {:?}", out.stderr);
339+}
340+
341+#[test]
342+fn successful_slow_work_clears_before_its_confirmation() {
343+ let fixture = Fixture::new();
344+ let out = capture(fixture.slow_clone(false), true, true);
345+ assert_eq!(out.code, 0, "{}", out.stderr);
346+ assert!(out.stdout.is_empty(), "spinner leaked into stdout: {:?}", out.stdout);
347+ let clear = "\r\x1b[2K";
348+ assert!(out.stderr.matches(clear).count() >= 3, "no repeated frames: {:?}", out.stderr);
349+ let last_clear = out.stderr.rfind(clear).expect("spinner was never cleared");
350+ let final_line = without_sgr(&out.stderr[last_clear + clear.len()..]);
351+ assert!(final_line.starts_with("✓"), "spinner label was not cleared: {final_line:?}");
352+ assert!(final_line.contains("j:"), "missing success confirmation: {final_line:?}");
353+ assert!(fixture.dir.join("clone/.jj").is_dir());
354+}
355+
356+#[test]
357+fn progress_fits_a_narrow_stderr_terminal() {
358+ let fixture = Fixture::new();
359+ // stdout stays 80 columns wide: progress must measure its own stderr.
360+ let out = capture_with_stderr_width(fixture.slow_clone(false), true, true, 18);
361+ assert_eq!(out.code, 0, "{}", out.stderr);
362+ let segments: Vec<_> = out.stderr.split(CLEAR_LINE).skip(1).collect();
363+ let (confirmation, frames) = segments.split_last().expect("progress was never cleared");
364+ assert!(frames.len() >= 2, "no repeated frames: {:?}", out.stderr);
365+ for frame in frames {
366+ let plain = without_sgr(frame);
367+ assert!(!plain.contains('\n'), "progress used multiple rows: {plain:?}");
368+ assert!(plain.width() <= 17, "progress wrapped the 18-column terminal: {plain:?}");
369+ }
370+ assert!(without_sgr(confirmation).starts_with('✓'), "confirmation was not preserved: {confirmation:?}");
371+ assert!(out.stdout.is_empty(), "progress leaked into stdout: {:?}", out.stdout);
372+}
373+
374+#[test]
375+fn inherited_difft_diagnostic_is_not_erased_by_progress() {
376+ let fixture = Fixture::new();
377+ fixture.init();
378+ let bin = fixture.root.path().join("bin");
379+ std::fs::create_dir(&bin).unwrap();
380+ let stub = bin.join("difft");
381+ std::fs::write(
382+ &stub,
383+ "#!/bin/sh\nsleep 0.3\nprintf 'external difft diagnostic' >&2\nsleep 0.4\nexit 2\n",
384+ ).unwrap();
385+ std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
386+ let inherited_path = std::env::var_os("PATH").unwrap_or_default();
387+ let path = std::env::join_paths(
388+ std::iter::once(bin).chain(std::env::split_paths(&inherited_path)),
389+ ).unwrap();
390+ // Push evaluates its expression inside an active progress phase. A difft
391+ // that exits unsuccessfully still returns its stdout (§7.10), so deliberately
392+ // fail the expression afterwards, before any remote is consulted.
393+ let mut command = fixture.command(&[
394+ "push (if difft ./f (blob \"a\") (blob \"b\") == \"\" then crash \"push fixture failure\" else [])",
395+ ]);
396+ command.env("PATH", path);
397+ let out = capture(command, true, true);
398+ assert_eq!(out.code, 1, "{}", out.stderr);
399+ assert!(out.stdout.is_empty(), "unexpected stdout: {:?}", out.stdout);
400+ let diagnostic = out.stderr.find("external difft diagnostic")
401+ .expect("inherited difft diagnostic was lost");
402+ let after = &out.stderr[diagnostic..];
403+ assert!(!after.contains('\r'), "spinner overwrote external diagnostic: {after:?}");
404+ assert!(!after.contains("\x1b[2K"), "spinner cleared external diagnostic: {after:?}");
405+ assert!(after.contains("push fixture failure"), "push failure was lost: {after:?}");
406+}
407+
408+#[test]
409+fn terminal_motion_respects_environment_opt_outs() {
410+ let fixture = Fixture::new();
411+ for (key, value) in [
412+ ("NO_COLOR", "1"),
413+ ("TERM", "dumb"),
414+ ("CI", "1"),
415+ ("J_ANIMATIONS", "never"),
416+ ("J_ANIMATIONS", "0"),
417+ ("J_ANIMATIONS", "false"),
418+ ("J_ANIMATIONS", "off"),
419+ ] {
420+ let mut command = fixture.slow_clone(true);
421+ command.env(key, value);
422+ let out = capture(command, true, true);
423+ assert_eq!(out.code, 1, "{key}={value}: {}", out.stderr);
424+ assert!(out.stderr.contains("terminal fixture failure"), "{}", out.stderr);
425+ assert_no_motion(&out);
426+ if key == "NO_COLOR" || key == "TERM" {
427+ assert!(!out.stderr.contains('\x1b'), "{key}={value}: {:?}", out.stderr);
428+ } else if key == "J_ANIMATIONS" {
429+ assert!(out.stderr.contains("\x1b["), "motion opt-out also disabled color");
430+ }
431+ }
432+}
433+
434+#[test]
435+fn color_opt_outs_keep_plain_success_confirmations() {
436+ for (key, value) in [("NO_COLOR", "1"), ("TERM", "dumb")] {
437+ let fixture = Fixture::new();
438+ let mut command = fixture.slow_clone(false);
439+ command.env(key, value);
440+ let out = capture(command, true, true);
441+ assert_eq!(out.code, 0, "{key}={value}: {}", out.stderr);
442+ assert!(out.stdout.is_empty(), "unexpected stdout: {:?}", out.stdout);
443+ assert!(out.stderr.contains("j: Repository cloned"), "missing success confirmation: {:?}", out.stderr);
444+ assert!(!out.stderr.contains('\x1b'), "{key}={value}: {:?}", out.stderr);
445+ assert_no_motion(&out);
446+ assert!(fixture.dir.join("clone/.jj").is_dir());
447+ }
448+}
449+
450+#[test]
451+fn noninteractive_runs_keep_success_confirmations_quiet() {
452+ let fixture = Fixture::new();
453+ fixture.init();
454+ for (stdout_tty, stderr_tty) in [(false, true), (true, false), (false, false)] {
455+ let out = capture(fixture.command(&["id"]), stdout_tty, stderr_tty);
456+ assert_eq!(out.code, 0, "{}", out.stderr);
457+ assert!(out.stdout.is_empty(), "unexpected stdout: {:?}", out.stdout);
458+ assert!(out.stderr.is_empty(), "unexpected confirmation: {:?}", out.stderr);
459+ }
460+ let mut command = fixture.command(&["id"]);
461+ command.env("CI", "1");
462+ let out = capture(command, true, true);
463+ assert_eq!(out.code, 0, "{}", out.stderr);
464+ assert!(out.stdout.is_empty(), "unexpected stdout: {:?}", out.stdout);
465+ assert!(out.stderr.is_empty(), "unexpected CI confirmation: {:?}", out.stderr);
466+}
467+
468+#[test]
469+fn edit_confirmations_identify_changes_and_the_resulting_focus() {
470+ let fixture = Fixture::new();
471+ fixture.init();
472+ let confirmation = |expression: &str, expected: &str, description: &str| {
473+ let mut command = fixture.command(&[expression]);
474+ command.env("NO_COLOR", "1");
475+ let out = capture(command, true, true);
476+ assert_eq!(out.code, 0, "{}", out.stderr);
477+ assert!(out.stdout.is_empty(), "unexpected stdout: {:?}", out.stdout);
478+ assert_no_motion(&out);
479+ assert!(out.stderr.starts_with(&format!("j: {expected}; focus @")), "{}", out.stderr);
480+ assert!(out.stderr.ends_with(&format!(" {description}\n")), "{}", out.stderr);
481+ let focus = out.stderr.split("; focus ").nth(1).unwrap()
482+ .split_whitespace().next().unwrap().to_owned();
483+ assert!(focus.len() >= 5, "focus prefix is too short: {focus}");
484+ let full = fixture.command(&["\\r -> show r.root.id"]).output().unwrap();
485+ assert!(full.status.success(), "{}", String::from_utf8_lossy(&full.stderr));
486+ assert!(String::from_utf8(full.stdout).unwrap().starts_with(&focus));
487+ focus
488+ };
489+ let initial = confirmation("describe \"Fix login\"", "Saved changes", "Fix login");
490+ assert_eq!(confirmation("describe \"Fix login\"", "No changes", "Fix login"), initial);
491+ let next = confirmation("new", "Saved changes", "(no description)");
492+ assert_ne!(next, initial, "new did not report the resulting focus");
493+ std::fs::write(fixture.dir.join("file.txt"), "a working-copy change\n").unwrap();
494+ assert_eq!(confirmation("id", "Saved changes", "(no description)"), next);
495+ assert_eq!(confirmation("id", "No changes", "(no description)"), next);
496+}
497+
498+#[test]
499+fn conflicting_edits_report_paths_and_a_working_recovery_sequence() {
500+ let fixture = Fixture::new();
501+ fixture.init();
502+ let run = |expr: &str| {
503+ let out = fixture.command(&[expr]).output().unwrap();
504+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
505+ out
506+ };
507+ std::fs::write(fixture.dir.join("settings.txt"), "base\n").unwrap();
508+ run("describe \"base\"");
509+ run("new");
510+ std::fs::write(fixture.dir.join("settings.txt"), "left\n").unwrap();
511+ run("describe \"left\"");
512+ run("new . prev");
513+ std::fs::write(fixture.dir.join("settings.txt"), "right\n").unwrap();
514+ run("describe \"right\"");
515+
516+ let mut command = fixture.command(&["rebase siblings"]);
517+ command.env("NO_COLOR", "1");
518+ let out = capture(command, true, true);
519+ assert_eq!(out.code, 0, "{}", out.stderr);
520+ for expected in ["Saved changes", "1 unresolved file(s)", "settings.txt", "j conflicts", "j id"] {
521+ assert!(out.stderr.contains(expected), "missing {expected}: {}", out.stderr);
522+ }
523+ assert!(std::fs::read_to_string(fixture.dir.join("settings.txt")).unwrap().contains("<<<<<<<"));
524+ assert_no_motion(&out);
525+
526+ std::fs::write(fixture.dir.join("settings.txt"), "resolved\n").unwrap();
527+ let out = run("save");
528+ assert!(out.stderr.is_empty(), "{}", String::from_utf8_lossy(&out.stderr));
529+ assert_eq!(String::from_utf8(run("show . conflicts").stdout).unwrap().trim(), "[]");
530+}
531+
532+#[test]
533+fn empty_push_explains_that_nothing_was_published() {
534+ let fixture = Fixture::new();
535+ fixture.init();
536+ let mut command = fixture.command(&["push relabel"]);
537+ command.env("NO_COLOR", "1");
538+ let out = capture(command, true, true);
539+ assert_eq!(out.code, 0, "{}", out.stderr);
540+ assert!(out.stderr.contains("No bookmarks selected; nothing published"), "{}", out.stderr);
541+ assert!(out.stderr.contains("push (label \"feature\" here)"), "{}", out.stderr);
542+ assert!(!out.stderr.contains("Push complete"), "{}", out.stderr);
543+ assert_no_motion(&out);
544+}
545+
546+#[test]
547+fn push_confirmations_count_published_unchanged_and_deleted_bookmarks() {
548+ let fixture = Fixture::new();
549+ fixture.init();
550+ let remote = fixture.root.path().join("origin.git");
551+ let output = Command::new("git").args(["init", "--bare", "-q"])
552+ .arg(&remote).output().unwrap();
553+ assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
554+ for args in [vec!["describe \"feature\""], vec!["remote", remote.to_str().unwrap()]] {
555+ let output = fixture.command(&args).output().unwrap();
556+ assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
557+ }
558+ for (expression, counts) in [
559+ ("push (label \"feature\" here)", "1 published, 0 deleted, 0 unchanged"),
560+ ("push (label \"feature\" here)", "0 published, 0 deleted, 1 unchanged"),
561+ ("push (unlabel \"feature\")", "0 published, 1 deleted, 0 unchanged"),
562+ ] {
563+ let mut command = fixture.command(&[expression]);
564+ command.env("NO_COLOR", "1");
565+ let out = capture(command, true, true);
566+ assert_eq!(out.code, 0, "{}", out.stderr);
567+ assert!(out.stderr.contains(counts), "{}", out.stderr);
568+ assert_no_motion(&out);
569+ }
570+}
571+
572+#[test]
573+fn undo_redo_confirmations_name_the_affected_edit_and_restored_focus() {
574+ let fixture = Fixture::new();
575+ fixture.init();
576+ let focus_id = || {
577+ let out = fixture.command(&["\\r -> show r.root.id"]).output().unwrap();
578+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
579+ String::from_utf8(out.stdout).unwrap().trim().to_owned()
580+ };
581+ let original_id = focus_id();
582+ let expression = "describe \"Next task\" . new";
583+ let edit = fixture.command(&[expression]).output().unwrap();
584+ assert!(edit.status.success(), "{}", String::from_utf8_lossy(&edit.stderr));
585+ let next_id = focus_id();
586+ assert_ne!(original_id, next_id);
587+ for (command, action, id, title) in [
588+ ("undo", "Undid", &original_id, "(no description)"),
589+ ("redo", "Redid", &next_id, "Next task"),
590+ ("undo", "Undid", &original_id, "(no description)"),
591+ ("redo", "Redid", &next_id, "Next task"),
592+ ] {
593+ let mut command = fixture.command(&[command]);
594+ command.env("NO_COLOR", "1");
595+ let out = capture(command, true, true);
596+ assert_eq!(out.code, 0, "{}", out.stderr);
597+ assert!(out.stdout.is_empty(), "unexpected stdout: {:?}", out.stdout);
598+ assert_eq!(out.stderr, format!("j: {action} `{expression}`; restored focus {id} {title}\n"));
599+ assert_eq!(&focus_id(), id);
600+ assert_no_motion(&out);
601+ }
602+}
603+
604+#[test]
605+fn redirecting_either_output_stream_disables_motion() {
606+ let fixture = Fixture::new();
607+ for (stdout_tty, stderr_tty) in [(false, true), (true, false), (false, false)] {
608+ let out = capture(fixture.slow_clone(true), stdout_tty, stderr_tty);
609+ assert_eq!(out.code, 1, "{}", out.stderr);
610+ assert_no_motion(&out);
611+ assert!(out.stderr.contains("terminal fixture failure"), "{}", out.stderr);
612+ if !stderr_tty {
613+ assert!(!out.stderr.contains('\x1b'), "redirected diagnostic has ANSI: {:?}", out.stderr);
614+ }
615+ }
616+ fixture.init();
617+ let out = capture(fixture.command(&["tree"]), false, true);
618+ assert_eq!(out.code, 0, "{}", out.stderr);
619+ assert!(!out.stdout.contains('\x1b'), "redirected tree has ANSI: {:?}", out.stdout);
620+ assert_no_motion(&out);
621+}
tests/tree.rs+79−10
@@ -1124,7 +1124,7 @@ fn a_run_count_runs_on_into_the_id_column() {
11241124 let ctx = format!("lanes {}\n{}", lanes, text);
11251125 assert_eq!(text.lines().next(), Some(" ╎ 123"), "{}", ctx);
11261126 // the rest of the count fits the id column as it is
1127− assert_eq!(id_cols(&text), vec![2 + 2 * lanes + 1; WORKED_ROWS.len()], "{}", ctx);
1127+ assert_eq!(id_cols(&text), vec![2 + 2 * lanes + 1 + if lanes < 3 { 4 } else { 0 }; WORKED_ROWS.len()], "{}", ctx);
11281128 assert!(row_of(&text, "@aaaa").contains("@aaaa ▅ add parser"), "{}", ctx);
11291129 }
11301130 });
@@ -1165,7 +1165,7 @@ fn an_icons_glyph_fills_both_columns_of_its_lane() {
11651165 let plain = tree_text(&mut i, &cfg, &src(false), repo.clone());
11661166 let icons = tree_text(&mut i, &cfg, &src(true), repo);
11671167 assert_eq!(id_cols(&icons), id_cols(&plain), "lanes {}\n{}\n{}", lanes, plain, icons);
1168− assert_eq!(id_cols(&icons), vec![2 + 2 * lanes + 1; WORKED_ROWS.len()], "lanes {}\n{}", lanes, icons);
1168+ assert_eq!(id_cols(&icons), vec![2 + 2 * lanes + 1 + if lanes < 3 { 4 } else { 0 }; WORKED_ROWS.len()], "lanes {}\n{}", lanes, icons);
11691169 }
11701170}
11711171
@@ -1408,17 +1408,17 @@ fn a_flattened_last_child_of_the_trunk_head_ends_lane_0() {
14081408 let want = [
14091409 (1, shapes(&[
14101410 ("◆", "@khhh head main"),
1411− ("│", "@kxxx older"),
1412− ("│", "@kyyy newer"),
1413− ("»", "@kvvv on-older"),
1414− ("»", "@kwww on-newer"),
1411+ ("│", "» ○ @kxxx older"),
1412+ ("│", "» ○ @kyyy newer"),
1413+ ("", "» ○ @kvvv on-older"),
1414+ ("", "» ○ @kwww on-newer"),
14151415 ])),
14161416 (2, shapes(&[
14171417 ("◆", "@khhh head main"),
14181418 ("├─○", "@kxxx older"),
1419− ("│ │", "@kyyy newer"),
1419+ ("│ │", "» ○ @kyyy newer"),
14201420 (" ○", "@kvvv on-older"),
1421− (" »", "@kwww on-newer"),
1421+ ("", "» ○ @kwww on-newer"),
14221422 ])),
14231423 ];
14241424 for (lanes, want) in want {
@@ -1447,7 +1447,7 @@ fn the_worked_example_ends_lane_0_at_ptlm_on_fewer_lanes() {
14471447 // `wqzt`'s) and at lanes = 1, and lane 0 went on past it to the bottom.
14481448 // It ends on `ptlm`'s row, "and then empty", as at lanes = 3.
14491449 let want: [(usize, [&str; 9]); 2] = [
1450− (1, [" ╎", " ◆", " ◆", " │", "▶ │", " │", " │", " »", " »"]),
1450+ (1, [" ╎", " ◆", " ◆", " │", "▶ │", " │", " │", "", ""]),
14511451 (2, [" ╎ 14", " ◆─╮", " ◆ │", " │ ○", "▶ ├─⊗", " │ │", " │ │", " │", " ○"]),
14521452 ];
14531453 for (lanes, want) in want {
@@ -1513,7 +1513,7 @@ fn a_run_count_never_covers_a_rail() {
15131513 assert_eq!(lines[2], run, "lanes {}\n{}", lanes, text);
15141514 // the id column is where it is without the count
15151515 let id = col_of(row_of(&text, "@kggg"), "@kggg");
1516− assert_eq!(id, 2 + 2 * lanes + 1, "lanes {}\n{}", lanes, text);
1516+ assert_eq!(id, 2 + 2 * lanes + 1 + if lanes <= 3 { 4 } else { 0 }, "lanes {}\n{}", lanes, text);
15171517 }
15181518}
15191519
@@ -1694,3 +1694,72 @@ fn truncation_survives_mixed_scripts() {
16941694 }
16951695 }
16961696}
1697+
1698+
1699+#[test]
1700+fn overflow_preserves_state_even_when_every_rail_is_occupied() {
1701+ // At two lanes the worked example's empty ptlm overflows while the
1702+ // conflict's branch is still live. Neither state nor rails may vanish.
1703+ for icons in [false, true] {
1704+ let (repo, be) = worked_example();
1705+ let (mut i, cfg) = make_interp(be);
1706+ let text = tree_text(&mut i, &cfg, &format!("treeWith ({})", lanes_opts(2, icons)), repo);
1707+ let row = row_of(&text, "@ptlm");
1708+ assert!(row.contains(if icons { "» 🫙" } else { "» ◌" }), "{text}");
1709+ assert_eq!(upto_col(row, 6), " │ │ ", "{text}");
1710+ assert!(text.contains("» branch exceeds lane limit; increase lanes"), "{text}");
1711+ assert!(id_cols(&text).iter().all(|&column| column == 11), "{text}");
1712+ }
1713+ // At one lane the conflicting focus itself overflows; it must retain
1714+ // both the monochrome conflict glyph and the focus gutter.
1715+ let (repo, be) = worked_example();
1716+ let (mut i, cfg) = make_interp(be);
1717+ let text = tree_text(&mut i, &cfg, &format!("treeWith ({})", lanes_opts(1, false)), repo);
1718+ let row = row_of(&text, "@wqzt");
1719+ assert!(row.starts_with("▶ │ ") && row.contains("» ⊗"), "{text}");
1720+}
1721+
1722+#[test]
1723+fn terminal_detail_paths_wrap_without_losing_paths_or_live_rails() {
1724+ let root = commit(ROOT_ID, "", &[], vec![]);
1725+ let paths = ["src/123456789012 long_filename.rs", "src/第二个很长的文件名.rs"];
1726+ let focus = commit("kaaaaaaa", "focus", &[], paths.iter().map(|&p| (p, "x")).collect());
1727+ let child = commit("kbbbbbbb", "child", &[], paths.iter().map(|&p| (p, "x")).collect());
1728+ let repo = repo_of(root, vec![subtree(focus, vec![subtree(child, vec![])])], Some(0));
1729+ let (mut i, cfg) = make_interp(backend_with(vec![
1730+ meta(ROOT_ID, "R", 1), meta("kaaaaaaa", "A", 2), meta("kbbbbbbb", "A", 3),
1731+ ]));
1732+ let options = ONE_ROW_OPTS.replace("detail = 1", "detail = 2");
1733+ let rows = rows_on_terminal(&mut i, &cfg, &options, &repo, 32);
1734+ let start = rows.iter().position(|line| line.contains("@kaaa")).unwrap() + 1;
1735+ let end = rows.iter().position(|line| line.contains("@kbbb")).unwrap();
1736+ let detail = &rows[start..end];
1737+ assert!(detail.len() > 2, "{rows:?}");
1738+ assert!(detail.iter().all(|line| j::render::width(line) <= 32), "{rows:?}");
1739+ let prefix = upto_col(&detail[0], 13);
1740+ assert!(prefix.contains('│'), "{rows:?}");
1741+ assert!(detail.iter().all(|line| upto_col(line, 13) == prefix), "{rows:?}");
1742+ let contents: String = detail.iter().map(|line| between_cols(line, 13, usize::MAX)).collect();
1743+ for path in paths { assert!(contents.contains(path), "{contents}"); }
1744+ let pipe = tree_text(&mut i, &cfg, &format!("treeWith ({options})"), repo);
1745+ assert!(pipe.lines().any(|line| paths.iter().all(|path| line.contains(path))), "{pipe}");
1746+}
1747+
1748+
1749+#[test]
1750+fn default_four_lanes_explain_overflow_without_hiding_empty_state() {
1751+ let mut branch = subtree(commit("kzaaaaaa", "end", &[], vec![]), vec![]);
1752+ for depth in (0..6).rev() {
1753+ let letter = (b'a' + depth) as char;
1754+ branch = subtree(
1755+ commit(&format!("k{letter}aaaaaa"), &format!("level {depth}"), &[], vec![]),
1756+ vec![branch, subtree(commit(&format!("l{letter}aaaaaa"), "sibling", &[], vec![]), vec![])],
1757+ );
1758+ }
1759+ let repo = repo_of(commit(ROOT_ID, "", &[], vec![]), vec![branch], None);
1760+ let (mut i, cfg) = make_interp(MemBackend::new());
1761+ let text = tree_text(&mut i, &cfg, "tree", repo);
1762+ let row = row_of(&text, "@keaa");
1763+ assert!(row.starts_with(" │ │ │ │ » ◌"), "{text}");
1764+ assert!(text.contains("» branch exceeds lane limit; increase lanes"), "{text}");
1765+}
tests/ux_cli.rsnew file+105
@@ -0,0 +1,105 @@
1+//! User-facing invocation and diagnostic regressions use isolated repositories.
2+
3+use std::path::Path;
4+use std::process::{Command, Output};
5+
6+struct Fixture {
7+ root: tempfile::TempDir,
8+}
9+
10+impl Fixture {
11+ fn new() -> Self {
12+ let root = tempfile::tempdir().unwrap();
13+ std::fs::create_dir(root.path().join("repo")).unwrap();
14+ std::fs::create_dir_all(root.path().join("config/j")).unwrap();
15+ let source = include_str!("../config.j")
16+ .replace("Your Name", "UX Test")
17+ .replace("you@example.com", "ux@example.com");
18+ std::fs::write(root.path().join("config/j/config.j"), source).unwrap();
19+ Self { root }
20+ }
21+
22+ fn run_in(&self, cwd: &Path, args: &[&str]) -> Output {
23+ Command::new(env!("CARGO_BIN_EXE_j"))
24+ .args(args)
25+ .current_dir(cwd)
26+ .env("XDG_CONFIG_HOME", self.root.path().join("config"))
27+ .env("NO_COLOR", "1")
28+ .output().unwrap()
29+ }
30+
31+ fn run(&self, args: &[&str]) -> Output {
32+ self.run_in(&self.root.path().join("repo"), args)
33+ }
34+}
35+
36+fn success(output: Output) -> Output {
37+ assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
38+ output
39+}
40+
41+#[test]
42+fn shell_quoted_clone_and_remote_paths_keep_their_spaces() {
43+ let f = Fixture::new();
44+ let remote = f.root.path().join("source remote.git");
45+ success(Command::new("git").args(["init", "--bare", "-q"]).arg(&remote).output().unwrap());
46+ success(f.run_in(f.root.path(), &["clone", remote.to_str().unwrap(), "clone with spaces"]));
47+ let clone = f.root.path().join("clone with spaces");
48+ assert!(clone.join(".jj").is_dir());
49+ success(f.run_in(&clone, &["status"]));
50+
51+ let other = f.root.path().join("different remote.git");
52+ success(Command::new("git").args(["init", "--bare", "-q"]).arg(&other).output().unwrap());
53+ success(f.run_in(&clone, &["remote", other.to_str().unwrap()]));
54+ let url = success(Command::new("git").arg("-C").arg(&clone)
55+ .args(["config", "--get", "remote.origin.url"]).output().unwrap());
56+ assert_eq!(String::from_utf8(url.stdout).unwrap().trim(), other.to_str().unwrap());
57+ success(f.run_in(&clone, &["fetch"]));
58+}
59+
60+#[test]
61+fn expression_diagnostics_offer_usable_next_steps_without_editing() {
62+ let f = Fixture::new();
63+ success(f.run(&["init"]));
64+ let head_dir = f.root.path().join("repo/.jj/repo/op_heads/heads");
65+ let heads = || {
66+ let mut ids: Vec<_> = std::fs::read_dir(&head_dir).unwrap()
67+ .map(|e| e.unwrap().file_name()).collect();
68+ ids.sort();
69+ ids
70+ };
71+ let before = heads();
72+ for (args, expected) in [
73+ (vec!["describe", "hello"], "j 'describe \"hello\"'"),
74+ (vec!["neww"], "new"),
75+ (vec!["describe"], "j help describe"),
76+ (vec!["rebase"], "j help rebase"),
77+ ] {
78+ let output = f.run(&args);
79+ assert_eq!(output.status.code(), Some(1));
80+ assert!(output.stdout.is_empty());
81+ let error = String::from_utf8_lossy(&output.stderr);
82+ assert!(error.contains(expected), "{args:?}: {error}");
83+ }
84+ assert_eq!(heads(), before);
85+
86+ let output = f.run(&["describe (\n"]);
87+ assert_eq!(output.status.code(), Some(3));
88+ let error = String::from_utf8_lossy(&output.stderr);
89+ assert!(error.contains("expression"), "{error}");
90+ assert!(error.contains("line"), "{error}");
91+}
92+
93+#[test]
94+fn broken_configuration_names_the_exact_file_and_recovery_help() {
95+ let f = Fixture::new();
96+ let path = f.root.path().join("config/j/config.j");
97+ std::fs::write(&path, "broken =\n").unwrap();
98+ let output = f.run(&["status"]);
99+ assert_eq!(output.status.code(), Some(3));
100+ let error = String::from_utf8_lossy(&output.stderr);
101+ assert!(error.contains(path.to_str().unwrap()), "{error}");
102+ assert!(error.contains("broken ="), "{error}");
103+ assert!(error.contains("j help config"), "{error}");
104+ assert_eq!(std::fs::read_to_string(path).unwrap(), "broken =\n");
105+}
tests/validation.rs+172−4
@@ -1,9 +1,9 @@
1−//! Persistence validation tests (§7.5 steps 1–3, 6) and config validation (§6.2).
1+//! Persistence validation tests (§7.5 steps 1–4, 6) and config validation (§6.2).
22
33use j::config;
44use j::domain::{MemBackend, MetaInfo, ROOT_ID};
55use j::eval::Interp;
6−use j::value::{Crash, Env, ThunkVal, Value};
6+use j::value::{BlobContent, BlobKind, BlobVal, ConflictSide, Crash, Env, LazyBlob, ThunkVal, Value};
77use std::rc::Rc;
88
99const CONFIG: &str = include_str!("../config.j");
@@ -45,6 +45,10 @@ fn subtree(root: Value, kids: Vec<Value>) -> Value {
4545/// root -> a, focused on a
4646fn two_commit_repo(labels_a: &[&str]) -> Value {
4747 let root = commit(ROOT_ID, "", &[], vec![]);
48+ two_commit_repo_with_root(root, labels_a)
49+}
50+
51+fn two_commit_repo_with_root(root: Value, labels_a: &[&str]) -> Value {
4852 let a = commit("kaaaaaaa", "a", labels_a, vec![]);
4953 let frame = Value::record(&[
5054 ("left", Value::list(vec![])),
@@ -95,6 +99,62 @@ fn valid_repo_passes() {
9599 assert!(validate_with(&old, &old).is_ok());
96100}
97101
102+#[test]
103+fn every_commit_requires_a_text_message_without_reading_stored_files() {
104+ let repo = |bad_id: Option<&str>, message: Option<Value>| {
105+ let make = |id: &str| {
106+ let files = if id == ROOT_ID || id == "kaaaaaaa" {
107+ Value::list(vec![])
108+ } else {
109+ unreadable_tree(id, id)
110+ };
111+ let c = commit_holding(id, "", &[], files);
112+ if bad_id != Some(id) {
113+ return c;
114+ }
115+ let Value::Record(fields) = c else { unreachable!() };
116+ let mut fields = (*fields).clone();
117+ match &message {
118+ Some(v) => { fields.insert("message".to_string(), v.clone()); }
119+ None => { fields.remove("message"); }
120+ }
121+ Value::Record(Rc::new(fields))
122+ };
123+ // root -> parent -> [sibling, focus -> child], focused on focus.
124+ let frame = |parent: Value, left: Vec<Value>| {
125+ Value::record(&[
126+ ("parent", parent),
127+ ("left", Value::list(left)),
128+ ("right", Value::list(vec![])),
129+ ])
130+ };
131+ Value::record(&[
132+ ("root", make("kaaaaaaa")),
133+ ("children", Value::list(vec![subtree(make("kccccccc"), vec![])])),
134+ (
135+ "context",
136+ Value::list(vec![
137+ frame(make("kppppppp"), vec![subtree(make("ksssssss"), vec![])]),
138+ frame(make(ROOT_ID), vec![]),
139+ ]),
140+ ),
141+ ])
142+ };
143+ let old = repo(None, None);
144+ assert_eq!(validate_with(&old, &old), Ok(()));
145+ for id in ["kaaaaaaa", "kccccccc", "kppppppp", "ksssssss", ROOT_ID] {
146+ let bad = repo(Some(id), Some(Value::int(1)));
147+ assert_eq!(validate_with(&old, &bad), Err("expected Text, got a Int".into()), "{}", id);
148+ let missing = repo(Some(id), None);
149+ let want = if id == "kaaaaaaa" {
150+ "persistence: the root is not a Commit"
151+ } else {
152+ "record has no field `message`"
153+ };
154+ assert_eq!(validate_with(&old, &missing), Err(want.into()), "{}", id);
155+ }
156+}
157+
98158#[test]
99159fn duplicate_ids_rejected() {
100160 let old = two_commit_repo(&[]);
@@ -133,6 +193,57 @@ fn focus_with_files(files: Vec<Value>) -> Value {
133193 ])
134194}
135195
196+#[test]
197+fn snapshot_entries_require_blob_content() {
198+ let old = two_commit_repo(&[]);
199+ let path = Value::list(vec![Value::text("a")]);
200+ let missing = Value::record(&[("path", path.clone())]);
201+ let scalar = Value::record(&[("path", path), ("content", Value::int(1))]);
202+ for (entry, want) in [(missing, "no field `content`"), (scalar, "content is not a Blob")] {
203+ // Both the validate builtin and persistence call this validator;
204+ // previously only the later tree-writing step rejected the entry.
205+ let new = focus_with_files(vec![entry.clone()]);
206+ let message = validate_with(&old, &new).unwrap_err();
207+ assert!(message.contains(want), "{}", message);
208+
209+ // A deferred list without a trusted stored-tree id still needs the
210+ // same checks when it is materialized.
211+ let lazy = Value::record(&[
212+ ("children", old.field("children").unwrap()),
213+ ("context", old.field("context").unwrap()),
214+ (
215+ "root",
216+ commit_holding("kaaaaaaa", "a", &[], j::domain::lazy_files("kaaaaaaa", vec![entry])),
217+ ),
218+ ]);
219+ let message = validate_with(&old, &lazy).unwrap_err();
220+ assert!(message.contains(want), "{}", message);
221+ }
222+}
223+
224+#[test]
225+fn snapshot_validation_accepts_blobs_without_reading_their_bytes() {
226+ let old = two_commit_repo(&[]);
227+ let contents = [
228+ BlobContent::Resolved(Rc::new(vec![0xff, 0x00, 0x80])),
229+ BlobContent::Conflict(vec![
230+ Some(ConflictSide::regular(b"left\n")),
231+ Some(ConflictSide::regular(b"base\n")),
232+ None,
233+ ]),
234+ BlobContent::Lazy(Rc::new(LazyBlob::new("a".repeat(40), || {
235+ Err(Crash::new("validation read a lazy blob"))
236+ }))),
237+ ];
238+ let files = contents.into_iter().enumerate().map(|(n, content)| {
239+ Value::record(&[
240+ ("path", Value::list(vec![Value::text(format!("file{}", n))])),
241+ ("content", Value::Blob(Rc::new(BlobVal { kind: BlobKind::Regular, content }))),
242+ ])
243+ }).collect();
244+ assert_eq!(validate_with(&old, &focus_with_files(files)), Ok(()));
245+}
246+
136247fn entry_at(path: &[&str], content: &str) -> Value {
137248 Value::record(&[
138249 ("content", j::value::BlobVal::text_blob(content)),
@@ -632,8 +743,7 @@ fn snapshots_left_as_stored_are_not_read() {
632743}
633744
634745#[test]
635−fn root_must_stay_on_top() {
636− // covered implicitly by navigation invariants; validate shape only here
746+fn persistence_requires_a_repo() {
637747 let old = two_commit_repo(&[]);
638748 let not_repo = Value::record(&[("root", old.field("root").unwrap())]);
639749 let mut i = make_interp(backend_with(&[]));
@@ -642,6 +752,64 @@ fn root_must_stay_on_top() {
642752 assert!(e.msg.contains("not a Repo"), "{}", e.msg);
643753}
644754
755+#[test]
756+fn root_must_stay_on_top() {
757+ // With no remote labels the default immutable set is empty, but the
758+ // root still cannot be abandoned, replaced, or placed below a commit.
759+ let old = two_commit_repo(&[]);
760+ let a = old.field("root").unwrap();
761+ let root = old.field("context").unwrap().as_list().unwrap()[0]
762+ .field("parent").unwrap();
763+ for children in [vec![], vec![subtree(root, vec![])]] {
764+ let new = Value::record(&[
765+ ("children", Value::list(children)),
766+ ("context", Value::list(vec![])),
767+ ("root", a.clone()),
768+ ]);
769+ assert_eq!(
770+ validate_with(&old, &new).unwrap_err(),
771+ "persistence: the root commit must be the top",
772+ );
773+ }
774+ let replaced = two_commit_repo_with_root(commit("kbbbbbbb", "", &[], vec![]), &[]);
775+ assert_eq!(
776+ validate_with(&old, &replaced).unwrap_err(),
777+ "persistence: the root commit must be the top",
778+ );
779+}
780+
781+#[test]
782+fn root_message_and_files_cannot_change() {
783+ let old = two_commit_repo(&[]);
784+ for root in [
785+ commit(ROOT_ID, "edited", &[], vec![]),
786+ commit(ROOT_ID, "", &[], vec![entry("a", "squashed into root")]),
787+ ] {
788+ let new = two_commit_repo_with_root(root, &[]);
789+ assert_eq!(
790+ validate_with(&old, &new).unwrap_err(),
791+ "persistence: the root commit cannot be changed",
792+ );
793+ }
794+}
795+
796+#[test]
797+fn unchanged_synthetic_root_is_accepted_without_reading_its_tree() {
798+ let loaded = || two_commit_repo_with_root(
799+ commit_holding(ROOT_ID, "synthetic root", &[], unreadable_tree(ROOT_ID, "root-tree")),
800+ &[],
801+ );
802+ // Each load has its own thunk, as a snapshot rebuilding the repo does.
803+ assert_eq!(validate_with(&loaded(), &loaded()), Ok(()));
804+ let listed = |entries| two_commit_repo_with_root(
805+ commit(ROOT_ID, "synthetic root", &[], entries),
806+ &[],
807+ );
808+ let old = listed(vec![entry("a", "1"), entry("b", "2")]);
809+ let reordered = listed(vec![entry("b", "2"), entry("a", "1")]);
810+ assert_eq!(validate_with(&old, &reordered), Ok(()));
811+}
812+
645813// ------------------------------------------------------------------
646814// config validation (§6.2)
647815// ------------------------------------------------------------------