on c7c3ac0 help sign in
44 files changed+741−6854
since splitsnapshots
unifiedsplit
CLAUDE.md+6−11 README.md+38−159 config.j+19−75 docs/base.md+42−133 docs/comparison.md+9−25 docs/language.md+19−65 docs/tutorial.md+36−102 specs/main.md+121−408 specs/tree.md+34−59 src/builtins.rs−44 src/config.rs+2−2 src/diagnostics.rs−381 src/domain.rs−6 src/eval.rs+45−78 src/help.rs+41−189 src/jj.rs+72−288 src/lib.rs+2−3 src/main.rs+84−280 src/render.rs+74−268 src/repo.rs+3−32 src/setup.rs−119 src/show.rs+22−90 src/terminal.rs−276 src/value.rs+14−314 tests/base.rs+2−2 tests/builtins.rs−26 tests/cli.rs+10−241 tests/difft.rs−74 tests/display.rs+1−248 tests/env_lifetimes.rs−215 tests/eval.rs+3−146 tests/help.rs+5−121 tests/interop.rs+10−173 tests/onboarding.rs−200 tests/onboarding_vocab.rs−153 tests/remote.rs+10−462 tests/review_fallback.rs−110 tests/setup.rs−144 tests/show.rs+1−147 tests/showprop.rs+2−8 tests/terminal.rs−621 tests/tree.rs+10−79 tests/ux_cli.rs−105 tests/validation.rs+4−172
CLAUDE.md+6−11
@@ -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, eleven 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, ten 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,40 +21,35 @@ 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` 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.
24+- `difft` is only needed at runtime for `review`/`difft`. `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−`§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.
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.
3132
3233## Architecture
3334
34−One run of `j EXPR` (`src/main.rs::run_expression`, mirroring spec §1.2): load + validate `config.j` → open the repo and take the lock → parse the expression → build the `Repo` value, snapshotting the working directory → resolve `@id` literals → evaluate the config's definitions, then the expression → if the result is a function, apply it to the repo **once** → if the final value is a `Repo`, persist only if it differs from the one loaded; display other values without persisting. Exit codes: 1 crash, 2 usage/no repo/locked, 3 config or parse error. All work happens on a thread with a 512 MB stack (`main`).
35+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`).
3536
3637Language pipeline, all in `src/`:
3738- `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.
3839- `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.
3940- `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` 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.
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.
4846
4947Repository 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.
5148- `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.
5249- `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.
5350- `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.
5451- `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.
5552
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−
5853### `config.j` is code, and it is embedded
5954
6055`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+38−159
@@ -31,47 +31,22 @@ functions.
3131
3232```
3333j help -- a quick start, grouped commands, and examples
34−j commands -- common tasks and how to do them
35−j commands all -- the full vocabulary in your config
34+j commands -- browse the vocabulary in your config
3635j commands rebase -- search names, signatures, and descriptions
3736j help describe -- see a command's signature, documentation, and examples
3837j help push -- learn a reserved command's usage
39−j help config -- find your config and set your identity
4038```
4139
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
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
4642your own definitions, even ones without a signature; when no config exists, it
4743uses the embedded reference config. General help and help for reserved commands
4844are available even if your config is broken.
4945
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:
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:
7248`j 'describe "first change" . new'` creates a commit, then describes it.
73−Preview an edit with `j 'preview squash'`. Search familiar tasks with
74−`j commands commit`, `j commands checkout`, or `j commands branch`.
49+Preview an edit with `j 'tree . validate . squash'`.
7550
7651---
7752
@@ -93,7 +68,7 @@ Preview an edit with `j 'preview squash'`. Search familiar tasks with
9368### With Nix (recommended)
9469
9570The repository provides a flake with one default binary, `j`. Difftastic is a
96−runtime enhancement and is wired in automatically.
71+runtime dependency and is wired in automatically.
9772
9873```
9974nix build # builds ./result/bin/j
@@ -109,50 +84,13 @@ The reference `config.j` is installed alongside the binary
10984cargo build --release # produces target/release/j
11085```
11186
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.
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`.
11589
11690---
11791
11892## Configuration
11993
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−
15694Expressions read `~/.config/j/config.j` (or `$XDG_CONFIG_HOME/j/config.j`).
15795An expression can only use a name this file declares or defines. The reference
15896[`config.j`](config.j) ships with the binary. Commands that execute against a
@@ -160,19 +98,9 @@ repository create an editable copy there when it is missing; help and discovery
16098use the embedded copy without writing it. Edit at least the identity:
16199
162100```
163−user = { name = "Ada Lovelace", email = "ada@example.org" }
101+user = { name = "Your Name", email = "you@example.com" }
164102```
165103
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−
176104The reference config defines the entire base vocabulary (see
177105[docs/base.md](docs/base.md)). Four definitions are read by `j` itself: `user`
178106(who commits), `tree` (how a `Repo` prints), `immutable` (which commits may
@@ -182,19 +110,17 @@ never be rewritten), and `labelled` (what a `%name` literal means).
182110
183111## How it works
184112
185−Every expression run:
113+Every `j` run:
186114
187−1. Snapshots the working directory for evaluation.
188−2. Builds a `Repo` value focused on the working-copy commit.
115+1. Snapshots the working directory into the working-copy commit.
116+2. Builds a `Repo` value focused on that commit.
1891173. Evaluates your expression.
1901184. If the result is a function, applies it to the `Repo`.
191−5. If the final value is a `Repo` that changed, persists it. An unchanged
192− `Repo` saves nothing; other values print without saving.
119+5. If the final value is a `Repo` that changed, persists it; otherwise prints
120+ the value and changes nothing.
193121
194122So most of what you write is an **edit** — a function `Repo -> Repo`. A result
195123that'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.
198124
199125```
200126j 'length . commits . top' -- a number: how many commits? prints, persists nothing
@@ -239,19 +165,14 @@ Opens a new commit *and* describes it, in one step.
239165j 'status'
240166```
241167
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.
168+A summary of the focus: id, message, labels, changed paths, conflicted paths.
247169
248170```
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
171+changed 0 items
172+conflicts 0 items
173+id zrkmkkvrvtokumkuzrkmkkvrvtokumku
174+labels 0 items
175+message add a library
255176```
256177
257178```
@@ -291,33 +212,25 @@ The same tree with ages and authors in the margin.
291212j 'log'
292213```
293214
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.
215+A table of every commit, top first, with a `✓` on the focus.
297216
298217```
299218j 'review'
300219```
301220
302−The focus's changes as one page of diffs, using difftastic when available
303−and a unified diff otherwise.
221+The focus's changes, rendered by difftastic as one page of diffs.
304222
305223### Move around
306224
307−Navigation checks out the selected commit's files so you can edit them.
308−
309225```
310226j 'prev' -- to the parent
311227j 'next' -- to the only child
228+j 'top' -- to the top of history
312229j 'tip' -- to the tip of the current line
313230j 'by @wqzt' -- to a specific commit, by id
314−j 'new . goto trunk' -- start a change on top of the protected main line
231+j 'goto %main' -- to what the remote calls main
315232```
316233
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−
321234### Undo and history of actions
322235
323236```
@@ -326,25 +239,18 @@ j redo -- reapply it
326239j ops -- the operation log, newest first
327240```
328241
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−
336242### A safe dry run
337243
338−`preview` validates an edit and returns its resulting tree without saving:
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`):
339246
340247```
341−j 'preview squash'
342−j 'preview (rebase trunk)'
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
343250```
344251
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.
252+`validate` returns the repo unchanged if persisting would succeed, and crashes
253+with the exact error otherwise — an honest dry run.
348254
349255---
350256
@@ -446,27 +352,6 @@ j 'push (unlabel "old-feature")' -- delete a bookmark
446352j 'push relabel' -- after a rewrite, re-point the remote's bookmarks
447353```
448354
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−
470355---
471356
472357## Advanced examples
@@ -550,9 +435,9 @@ Rebase if you can; leave the repository alone if it crashes.
550435Anything added to `config.j` whose value is an `Edit` is a new command:
551436
552437```
553−-- in config.j: squash into the parent and name the combined commit "release"
438+-- in config.j: fold the whole stack into one commit called "release"
554439release : Edit
555−release = describe "release" . prev . squash
440+release = describe "release" . (contract everything)
556441```
557442
558443Then `j release` works like any built-in.
@@ -567,8 +452,7 @@ create or connect repositories, move refs, and walk the operation log.
567452| command | what it does |
568453|---|---|
569454| `j help [NAME]` | show a quick start or help for a reserved command or config name |
570−| `j commands [QUERY]` | show common tasks, search by task/name, or use `all` for the full vocabulary |
571−| `j setup [NAME EMAIL]` | create the full reference config, optionally with your identity; never overwrite an existing file |
455+| `j commands [QUERY]` | list the current vocabulary, optionally searching names, signatures, and comments |
572456| `j init` | create a colocated `.jj`/`.git` repository here (adopting an existing `.git`) |
573457| `j clone URL [DIR]` | clone `URL` into `DIR` (default: the repository's name, as `git clone` picks it) |
574458| `j remote URL` | set `origin`'s URL |
@@ -577,15 +461,10 @@ create or connect repositories, move refs, and walk the operation log.
577461| `j undo` / `j redo` | walk the operation log |
578462| `j ops` | print the operation log |
579463
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.
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.
589468
590469---
591470
config.j+19−75
@@ -76,10 +76,6 @@ Drop = { delete : Label } -- delete the remote's bookmark
7676------------------------------------------------------------------------------
7777
7878(.) : (b -> c) -> (a -> b) -> a -> c -- composition, right to left
79−-- Return a value unchanged. As a repository command, `j id` records the
80−-- snapshotted working-copy edits in the focused commit. `j save` is the
81−-- descriptive alias in this reference config; inspections such as `j status`
82−-- and `j tree` do not record those edits.
8379id : a -> a
8480const : a -> b -> a
8581crash : Text -> a -- abort the program with a message
@@ -147,15 +143,9 @@ splitOn : Text -> Text -> [Text] -- splitOn "/" "a/b" = ["a" "b"]
147143-- file ./a would, crashes, naming the path (§7.3).
148144replay : Snapshot -> Change -> Snapshot
149145unresolved : 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]
155146touchedPaths : Change -> [Path] -- paths whose content differs, one pass;
156147 -- blobs compared by content id, bytes never read
157148blob : Text -> Blob -- a resolved regular file
158−blobHex : Text -> Text -> Blob -- kind (regular/executable/symlink), hex bytes
159149text : Blob -> Text -- its content (markers if unresolved)
160150by : Id -> Repo -> Repo -- focus the commit with this id; crash if absent
161151lookupCommit : Id -> Repo -> Commit -- indexed commit lookup without moving the focus
@@ -165,13 +155,12 @@ subtreeCommits : a -> [Commit] -- every commit of a tree in preorder
165155meta : Id -> Meta -- git hash, author, email, committer time;
166156 -- crash for a commit not yet persisted
167157validate : Repo -> Repo -- the repo unchanged, or the crash persisting
168− -- it would produce (shape, root, labels, immutability,
158+ -- it would produce (shape, labels, immutability,
169159 -- focus). `tree . validate . rebase trunk` is an
170160 -- honest dry run.
171161diff : Blob -> Blob -> Text -- unified diff from the first to the second
172−difft : Path -> Blob -> Blob -> Text -- difftastic when difft is on PATH; otherwise
173− -- unified diff with path headers and 3 context lines,
174− -- or a binary-files summary. DFT_* configures difft.
162+difft : Path -> Blob -> Blob -> Text -- difftastic's rendering; needs difft on PATH.
163+ -- Options via DFT_* environment variables.
175164treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text
176165 , lanes : Int, author : Bool, date : Bool, files : Bool }
177166 -> Repo -> Text -- the history as a tree; see the spec
@@ -259,9 +248,6 @@ restrict = \m -> filter (\e -> m e.path)
259248select : Fileset -> Snapshot -> Snapshot -> Snapshot -- m from a, the rest from b
260249select = \m a b -> restrict m a ++ restrict (neg m) b
261250
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`.
265251conflicted : Snapshot -> [Path]
266252conflicted = \s -> map (.path) (filter (\e -> unresolved e.content) s)
267253
@@ -346,11 +332,6 @@ minus = \a b repo -> let ys = b repo in filter (\i -> not (member i ys)) (a repo
346332matching : (Commit -> Bool) -> Revset -> Revset
347333matching = \p rs repo -> filter (\i -> p (commitAt i repo)) (rs repo)
348334
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.
354335conflicts : Revset
355336conflicts = matching (\c -> not (null (conflicted c.files))) all
356337
@@ -359,10 +340,7 @@ firstOf : [Revset] -> Revset
359340firstOf = \rss repo -> foldl (\acc rs -> if null acc then rs repo else acc) [] rss
360341
361342-- The main line: whichever of these names the remote has, first one wins.
362−-- Empty when no fetched remote label is named main, master, or trunk;
363−-- nothing is then immutable. Configure `j remote URL`, run `j fetch`, and
364−-- inspect `j tree` to see remote labels. A remote with another default label
365−-- needs this definition adjusted. Start work with j 'new . goto trunk'.
343+-- Empty in a repository with no remote, in which case nothing is immutable.
366344trunk : Revset
367345trunk = firstOf [%main %master %trunk]
368346
@@ -383,15 +361,9 @@ immutable = ancestorsOf trunk
383361goto : Revset -> Edit
384362goto = \rs repo ->
385363 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"
390364 in if length is == 1
391365 then by (head is) repo
392− else if null is
393− then crash "expected one revision, got 0; no matching revision. Run `j tree` to inspect available revisions. For a missing remote label such as %main or trunk, configure `j remote URL`, run `j fetch`, and check the label names in `j tree`."
394− else crash ("expected one revision, got " ++ show (length is) ++ "\nCandidates:\n" ++ concat (map candidate (take 8 is)) ++ (if length is > 8 then " More candidates: run `j tree`.\n" else "") ++ "Choose a mutable commit with `j by @ID`, or start a child with `j 'new . by @ID'`; replace @ID with a candidate above.")
366+ else crash ("expected one revision, got " ++ show (length is))
395367
396368commitAt : Id -> Repo -> Commit
397369commitAt = lookupCommit
@@ -399,12 +371,8 @@ commitAt = lookupCommit
399371prev : Edit -- the parent
400372prev = goto parents
401373
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
374+next : Edit -- the only child
375+next = goto kids
408376
409377tip : Edit -- end of the line of only-children
410378tip = \repo -> tip (next repo) or repo
@@ -430,8 +398,6 @@ eachChild = forEach kids
430398guard : (Repo -> Bool) -> Edit -- crash unless p holds
431399guard = \p repo -> if p repo then repo else crash "guard"
432400
433−-- Whether the focused commit has no unresolved files. This says nothing
434−-- about unrecorded working-copy edits; inspect those with `j unrecorded`.
435401clean : Repo -> Bool
436402clean = \repo -> null (conflicted repo.root.files)
437403
@@ -470,21 +436,6 @@ rewrite = \f repo ->
470436-- Commands
471437------------------------------------------------------------------------------
472438
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−
488439-- Set the message of the focused commit.
489440-- Example: j 'describe "fix login"'
490441describe : Text -> Edit
@@ -499,7 +450,7 @@ new = \repo -> addChild (newCommit repo.root.files) repo
499450-- new empty commit on that parent becomes the focus (the parent may be
500451-- immutable, and the focus never is). Refused for a commit the remote has a
501452-- name for: push something else under that name first, or forget the name.
502−-- Example: j abandon; validate a preview first with j 'preview abandon'.
453+-- Example: j abandon; preview first with j 'tree . abandon'.
503454abandon : Edit
504455abandon = \repo ->
505456 let c = repo.root
@@ -515,7 +466,7 @@ contract = \m repo -> let a = repo.root
515466 in at parents (rewrite (\q -> q { files = select m a.files q.files })) repo
516467
517468-- Fold the focus entirely into its parent; a new empty commit becomes the focus.
518−-- Example: j squash; preview first with j 'preview squash'.
469+-- Example: j squash; preview first with j 'tree . validate . squash'.
519470squash : Edit
520471squash = abandon . contract everything
521472
@@ -532,7 +483,7 @@ split = \m repo ->
532483 in by x.id (repo { root = a', children = [{ root = x, children = repo.children }] })
533484
534485-- Move the focused subtree onto another commit. Returns to the moved commit.
535−-- Example: j 'rebase trunk'; preview with j 'preview (rebase trunk)'.
486+-- Example: j 'rebase trunk'; preview with j 'tree . validate . rebase trunk'.
536487rebase : Revset -> Edit
537488rebase = \rs repo ->
538489 let d = (goto rs repo).root
@@ -603,16 +554,13 @@ contentAt = lookupContent
603554touched : Change -> [Path]
604555touched = touchedPaths
605556
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.
557+-- Paths whose content differs between the focus and its parent.
609558-- Example: j changed
610559changed : Repo -> [Path]
611560changed = \repo -> touched (changeOf repo)
612561
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.
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.
616564diffs : Repo -> [{ path : Path, diff : Text }]
617565diffs = \repo ->
618566 let ch = changeOf repo
@@ -620,27 +568,23 @@ diffs = \repo ->
620568
621569-- The same, as one page of text. A Text result prints raw, so `j review` is
622570-- readable in the terminal. A focus that changes nothing reads "no changes";
623−-- missing difft uses the built-in unified diff (path headers, 3 context lines)
624−-- or a binary-files summary. Other execution errors remain visible; an
625−-- installed difft's output, even nothing, is the page.
626−-- Example: j review; difftastic is optional.
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).
627574review : Repo -> Text
628575review = \repo ->
629576 let ds = diffs repo
630577 in if null ds then "no changes\n" else concat (map (\d -> d.diff) ds)
631578
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.
579+-- A summary of the focus.
635580-- Example: j status
636−status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path], unrecorded : [Path] }
581+status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path] }
637582status = \repo ->
638583 { id = repo.root.id
639584 , message = repo.root.message
640585 , labels = repo.root.labels
641586 , changed = changed repo
642− , conflicts = conflicted repo.root.files
643− , unrecorded = unrecorded repo }
587+ , conflicts = conflicted repo.root.files }
644588
645589-- A summary of every commit, top first, in preorder.
646590-- Example: j log
docs/base.md+42−133
@@ -47,13 +47,6 @@ user = { name = "Your Name", email = "you@example.com" }
4747
4848This is a plain value, not a function. Its signature is checked once, at load
4949(§4.13): if you write `user = 5`, `j` refuses to start (exit 3).
50−Creating a repository or saving changes also rejects empty fields and the
51−reference placeholders. Use your own name and email before `j init`,
52−`j clone`, or an edit that saves changes. `j help config` shows your actual
53−config path and an example, even if the config is broken.
54−For a new installation, `j setup 'Your actual name' you@your-domain.example`
55−creates the complete config with that identity. It never overwrites an
56−existing file.
5750
5851### 1.2 Paths, labels, entries, snapshots
5952
@@ -185,34 +178,6 @@ id 42 => 42
185178edit or id -- run edit, keep the repository if it crashes
186179```
187180
188−### `save` — record file edits
189−
190−```
191−save : Edit
192−```
193−
194−The identity edit, with a name for the everyday action: `j save` records the
195−working-directory snapshot without changing the commit's description or
196−focus. It creates no operation when nothing changed. `j id` does the same.
197−
198−### `preview` — validate an edit without saving
199−
200−```
201−preview : Edit -> Repo -> Text
202−```
203−
204−Apply the edit, validate its result, and render the resulting tree. Because
205−the final value is text, no edit or working-directory snapshot is recorded.
206−Validation errors report why the edit could not be saved.
207−
208−```
209−j 'preview squash'
210−j 'preview (rebase trunk)'
211−```
212−
213−This is the convenient form of `tree . validate . EDIT`. `validate . EDIT`
214−alone returns a repository and saves it.
215−
216181### `const` — constant function
217182
218183```
@@ -291,9 +256,7 @@ not : Bool -> Bool
291256```
292257
293258`&&` and `||` short-circuit (the right operand is not evaluated if the left
294−decides the result). Operands must be `Bool`. Infix use requires their
295−declarations in `config.j` and checks the contracts of evaluated operands
296−and the result; a skipped right operand is not checked.
259+decides the result). Operands must be `Bool`.
297260
298261```
2992621 == 1 && 2 == 2 => true
@@ -338,17 +301,11 @@ Defined on `Int` only; crash on anything else. Non-associative (cannot chain:
338301show : a -> Text
339302```
340303
341−When it succeeds, `show` renders a value in the language's literal syntax.
342−Except for closures and unresolved blobs, the text parses and evaluates back
343−to an equal value. A top-level `Text` prints raw, so `j show …` is how you inspect values.
304+Renders any value in the language's literal syntax, such that (except for
305+closures and unresolved blobs) the text parses and evaluates back to an equal
306+value. A top-level `Text` prints raw, so `j show …` is how you inspect values.
344307See §5.2 of the spec for the exact rendering.
345308
346−Regular UTF-8 blobs render as `blob "…"`; binary blobs, executables, and
347−symlinks render as `blobHex "kind" "…"`, preserving bytes and file type with
348−lowercase hexadecimal content. An older custom config without
349−`blobHex : Text -> Text -> Blob` must add that declaration before showing
350−these values; otherwise `show` crashes with instructions to do so.
351−
352309```
353310show 42 => "42"
354311show "hi" => "\"hi\""
@@ -484,12 +441,11 @@ unresolved ((head (replay [({ path = ./f, content = blob "onto" })]
484441Two laws to internalise it: `replay b { from = b, to = x } = x` and
485442`replay x { from = b, to = b } = x`.
486443
487−### `unresolved`, `blob`, `blobHex`, `text` — blob primitives
444+### `unresolved`, `blob`, `text` — blob primitives
488445
489446```
490447unresolved : Blob -> Bool
491448blob : Text -> Blob
492−blobHex : Text -> Text -> Blob
493449text : Blob -> Text
494450```
495451
@@ -497,16 +453,9 @@ text : Blob -> Text
497453blob's content (its conflict-marker rendering if unresolved). `unresolved b`
498454is `true` iff the blob is a conflict.
499455
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−
506456```
507457blob "content" => blob "content"
508458text (blob "content") => "content"
509−text (blobHex "regular" "6869") => "hi"
510459unresolved (blob "x") => false
511460```
512461
@@ -557,20 +506,15 @@ meta @wqzt => { hash = "74bee64e…", author = "Test User",
557506 email = "test@example.com", time = 1789457265 }
558507```
559508
560−### `validate` — check whether persistence would succeed
509+### `validate` — dry-run persistence
561510
562511```
563512validate : Repo -> Repo
564513```
565514
566515Returns the repo unchanged if persisting it would succeed; otherwise crashes
567−with exactly the error persistence would produce (shape, root, labels, immutability,
568−focus). A changed repository also checks that the author identity is configured;
569−an unchanged repository can still be inspected with the default identity.
570−`tree . validate . rebase trunk` is an honest dry run.
571−`validate . rebase trunk` alone saves the result: `validate` returns the same
572−repository it was given, not the repository from before the edit. Prefer
573−`preview (rebase trunk)` for the complete safe-preview expression.
516+with exactly the error persistence would produce (shape, labels, immutability,
517+focus). `tree . validate . rebase trunk` is an honest dry run.
574518
575519### `diff` and `difft` — rendering differences
576520
@@ -588,34 +532,27 @@ diff (blob "a\nb\n") (blob "a\nc\n")
588532```
589533
590534`difft p a b` renders the comparison with **difftastic**, treating the input
591−as the file `p` (its last component guides language detection), using options
592−from `DFT_*` environment variables. The Nix package provides `difft`.
593−When the executable is missing, the builtin returns a unified diff with
594−`a/` and `b/` path headers and three lines of context; binary content receives
595−a difference summary. Other launch failures are crashes naming the tool and
596−OS error. A `difft` that runs and fails is not an error: what it printed,
597−even nothing, is the result, and what it says on stderr reaches the terminal.
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.
598540
599541### `treeWith` — the history tree
600542
601543```
602−treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text,
603− lanes : Int, author : Bool, date : Bool, files : Bool }
544+treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text }
604545 -> Repo -> Text
605546```
606547
607548Renders the history as a tree with an options record. The reference config
608−provides ready-made settings, including `tree` and `treeFull` (§10).
549+provides two ready-made settings, `tree` and `treeFull` (§9).
609550
610551```
611−treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
552+treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
612553 -- a full tree with ages and authors in the margin
613554```
614555
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−
619556### `extract` — every subvalue of a shape
620557
621558```
@@ -677,9 +614,7 @@ top = \repo -> top (up repo) or repo
677614```
678615
679616Moves the focus all the way to the top of history (where `context = []`). The
680−`or repo` is the base case: when `up` crashes at the top, stop. Use it inside
681−an inspection such as `commits . top` or `tree . top`; `j top` alone cannot
682−save a focus on the protected root commit.
617+`or repo` is the base case: when `up` crashes at the top, stop.
683618
684619```
685620top -- refocus at the very first commit
@@ -860,14 +795,17 @@ is `new . goto trunk`, never `goto trunk` alone.
860795
861796**`goto : Revset -> Edit`** — refocus on *the one commit* a revset names. This
862797is the only place a revset must have exactly one member; otherwise it crashes
863−with `expected one revision, got N` and recovery guidance. An empty selector
864−points to `j tree` and remote/fetch setup; an ambiguous selector shows up to
865−eight candidate ids and descriptions, with commands to select a mutable commit
866−or create a child. Candidate ids are complete copyable id literals.
798+with `expected one revision, got N`.
867799
868800```
869−new . goto %main -- start a mutable child on main
870−tree . goto %main -- inspect main without moving the working copy
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)
871809goto parents -- == prev
872810```
873811
@@ -876,12 +814,10 @@ The reference definition uses `lookupCommit`, which indexes the repository
876814once and retrieves commits without moving the focus. Its result is the same
877815as `(by i repo).root`.
878816
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.
817+**`prev : Edit`** — to the parent (`goto parents`).
882818
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`.
819+**`next : Edit`** — to the only child (`goto kids`; crashes if the focus has
820+other than exactly one child).
885821
886822**`tip : Edit`** — the end of the line of only-children (`tip = \repo -> tip
887823(next repo) or repo`).
@@ -1065,9 +1001,6 @@ Crashes at the top.
10651001
10661002`j push EXPR` evaluates `EXPR` to a list of `Push` and `Drop` records. These
10671003functions 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.
10711004
10721005**`label : Label -> Revset -> Repo -> [Push]`** — label every commit of a
10731006revset.
@@ -1100,8 +1033,7 @@ j 'push relabel' -- after rewriting, re-point the remote's bookmarks
11001033
11011034## 10. Inspection
11021035
1103−These return printable values rather than a `Repo`. They include current
1104−file edits in the displayed snapshot without recording those edits.
1036+These return printable values rather than a `Repo`.
11051037
11061038**`focus : Repo -> Commit`** — the focused commit.
11071039
@@ -1142,64 +1074,45 @@ contentAt ./missing [] => blob ""
11421074**`changed : Repo -> [Path]`** — paths whose content differs between the focus
11431075and its parent.
11441076
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−
11511077**`diffs : Repo -> [{ path : Path, diff : Text }]`** — what the focus changes,
1152−rendered by difftastic, one record per changed path, with unified diffs as
1153−the fallback when difftastic is missing.
1078+rendered by difftastic, one record per changed path. (Replace `difft` with
1079+`(\p -> diff)` to use the built-in unified diff instead.)
11541080
11551081**`review : Repo -> Text`** — the same, as one page of text, or
11561082`"no changes\n"` when the focus changes nothing. A `Text` result prints raw,
1157−so `j review` is readable in the terminal even without difftastic. When
1158−`difft` runs, whatever it prints, even nothing, is the page, and what it says
1159−on stderr reaches the terminal. Other launch errors still fail the review.
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.
11601086
11611087```
11621088j 'review' -- the focus's changes, as difftastic renders them
11631089```
11641090
1165−**`status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], unrecorded : [Path], conflicts : [Path] }`** — a summary of the focus and unsaved file edits.
1091+**`status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path] }`** — a summary of the focus.
11661092
11671093```
11681094j 'status'
11691095-- id @wqzt…
11701096-- message add the lexer
11711097-- labels [feature]
1172−-- in commit [src/lexer.rs]
1173−-- conflicts No conflicts
1174−-- unrecorded All edits recorded
1098+-- changed [src/lexer.rs]
1099+-- conflicts []
11751100```
11761101
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−
11841102**`log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]`**
11851103— a summary of every commit, top first, in preorder (`focus` marks the focused
1186−commit). The display shows short ids and label names, fits columns to the
1187−terminal, and switches to fields on separate lines when necessary. Redirected
1188−output preserves full cell text.
1104+commit).
11891105
11901106**`tree : Repo -> Text`** and **`treeFull : Repo -> Text`** — the history as a
1191−tree, built with `treeWith`. `tree` shows every commit (`detail = 1`);
1107+tree, built with `treeWith`. `tree` is compact (`detail = 1`, elided);
11921108`treeFull` shows ages and authors in the margin (`detail = 2`, `margin =
11931109true`). Edit these definitions to change what `j tree` shows.
11941110
11951111```
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 })
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" })
11981114```
11991115
1200−Use `treeCompact` to fold uninteresting runs and distant subtrees, or
1201−`treeData` for extra date, files-changed, and author columns.
1202−
12031116```
12041117j 'tree'
12051118-- ⌂ zzzz
@@ -1229,7 +1142,6 @@ j 'tree'
12291142| `attach` | `Subtree -> Edit` | §7.1 |
12301143| `backout` | `Revset -> Edit` | §8 |
12311144| `blob` | `Text -> Blob` | §5 |
1232−| `blobHex` | `Text -> Text -> Blob` | §5 |
12331145| `both` | `Fileset -> Fileset -> Fileset` | §7.2 |
12341146| `by` | `Id -> Repo -> Repo` | §5 |
12351147| `changeOf` | `Repo -> Change` | §7.2 |
@@ -1299,7 +1211,6 @@ j 'tree'
12991211| `parents` | `Revset` | §7.3 |
13001212| `pick` | `Revset -> Edit` | §8 |
13011213| `prev` | `Edit` | §7.4 |
1302−| `preview` | `Edit -> Repo -> Text` | §2 |
13031214| `range` | `Int -> Int -> [Int]` | §4 |
13041215| `rebase` | `Revset -> Edit` | §8 |
13051216| `relabel` | `Repo -> [Push]` | §9 |
@@ -1311,7 +1222,6 @@ j 'tree'
13111222| `restrict` | `Fileset -> Snapshot -> Snapshot` | §7.2 |
13121223| `review` | `Repo -> Text` | §10 |
13131224| `rewrite` | `(Commit -> Commit) -> Edit` | §8 |
1314−| `save` | `Edit` | §2 |
13151225| `select` | `Fileset -> Snapshot -> Snapshot -> Snapshot` | §7.2 |
13161226| `show` | `a -> Text` | §3 |
13171227| `siblings` | `Revset` | §7.3 |
@@ -1335,7 +1245,6 @@ j 'tree'
13351245| `under` | `Path -> Fileset` | §7.2 |
13361246| `union` | `Revset -> Revset -> Revset` | §7.3 |
13371247| `unlabel` | `Label -> [Drop]` | §9 |
1338−| `unrecorded` | `Repo -> [Path]` | §10 |
13391248| `unresolved` | `Blob -> Bool` | §5 |
13401249| `up` | `Edit` | §6 |
13411250| `user` | `{ name : Text, email : Text }` | §1.1 |
docs/comparison.md+9−25
@@ -75,27 +75,21 @@ about: a commit keeps its identity (`@wqzt…`) across `describe`, `squash`,
7575`j push` takes an *expression* rather than flags: `j 'push (label "feature"
7676here)'` labels the focused commit, `j 'push relabel'` re-points the remote's
7777bookmarks after a rewrite.
78−Push sends recorded commits and warns before sending if files have
79−unrecorded edits. Run `j id`, then push again to include those edits.
8078
8179### 3.2 Recording work
8280
8381| Task | git | jj | j |
8482|---|---|---|---|
85−| Save current state | `git add … && git commit` | automatic (`jj st` shows it) | an edit records the snapshot; `j save` records *only* the snapshot |
83+| Save current state | `git add … && git commit` | automatic (`jj st` shows it) | automatic; `j id` to record *only* the snapshot |
8684| Set the message | `git commit -m "…"` | `jj describe -m "…"` or `jj describe` | `j 'describe "…"'` |
8785| Start the next change | (just keep editing) | `jj new` | `j new` |
8886| Amend the current commit | `git commit --amend` | `jj describe` + edit files in `@` | edit files + `j 'describe "…"'` |
8987| See what changed | `git diff` / `git show` | `jj diff` / `jj show` | `j review` (via difftastic) / `j status` |
9088
91−In git, "commit" is a deliberate act with a staging step. jj snapshots the
92−working copy automatically. In `j`, expressions see current file edits,
93−but only a changed `Repo` result records them: `describe` saves a message
94−and `new` opens the next change. `j status` and other inspections save
95−nothing. Use `j save` (an alias for `id`) to record the working directory and
96−nothing else. Status displays the `changed` field as `in commit`, comparing
97−the current commit with its parent; `unrecorded` shows file edits made since
98−the last save.
89+In git, "commit" is a deliberate act with a staging step. In jj and `j`, the
90+snapshot is continuous; `describe` sets a message and `new` opens the next
91+change. `j` has one deliberate-snapshot command, `j id`, for when you want to
92+record the working directory and nothing else.
9993
10094### 3.3 Looking around
10195
@@ -122,7 +116,7 @@ zipper with edits.
122116|---|---|---|---|
123117| To the parent | `git checkout HEAD~1` | `jj prev` *(≈, opens a new empty commit there)* | `j prev` |
124118| To the child | `git checkout <sha>` (manual) | `jj next` *(≈, opens a new empty commit there)* | `j next` |
125−| Start work on a named commit | `git checkout <branch>` | `jj new <rev>` | `j 'new . goto %main'` |
119+| To a named commit | `git checkout <branch>` | `jj new <rev>` | `j 'goto %main'` |
126120| To a specific change | `git checkout <sha>` | `jj new <change-id>` | `j 'by @wqzt'` |
127121| Back to your branch | `git checkout main` | `jj new main` | `j 'new . goto trunk'` |
128122
@@ -207,16 +201,14 @@ the remote's state explicit and impossible to mutate by accident.
207201| When do conflicts appear? | During merge/rebase, blocking it | Recorded *in* the commit, non-blocking | Recorded in the commit (unresolved `Blob`s) |
208202| Where do they live? | The working tree + index | In the commit itself | In the commit's `files`, as conflict blobs |
209203| How do you see them? | `git status` | `jj st`, `jj resolve` | `j conflicts`, `j status` |
210−| How do you resolve? | Edit files, `git add`, continue | Edit files; conflict is gone from `@` | Edit files, then `j save` and check `j status` |
204+| How do you resolve? | Edit files, `git add`, continue | Edit files; conflict is gone from `@` | Edit files, then any persisting expression (`j id`) |
211205
212206git stops the world on a conflict: a merge or rebase halts until you resolve
213207and `git add`. jj and `j` treat a conflict as *data* — a commit can simply
214208*contain* conflicted files, you can keep working, and you resolve whenever you
215209like. `j conflicts` lists the commits that have conflicts; checking one out
216−writes the markers to disk; editing the files and running `j save` records
217−the resolution. Check `j status` for `No conflicts` and `All edits recorded`.
218−There is no separate continuation command. `j review` uses a unified diff
219−when difftastic is not installed.
210+writes the markers to disk; editing the files and running `j id` records the
211+resolution.
220212
221213---
222214
@@ -235,12 +227,6 @@ a first-class, marked operation, so `undo`/`redo` are safe and precise. In
235227`j`, undoing a `push` restores the local labels (and leaves the remote
236228untouched).
237229
238−`j undo` and `j redo` require no unrecorded working-copy edits. Recording
239−edits with `j id` adds an operation, so the next undo removes that recording
240−before another undo reaches the preceding edit. A new recording also clears
241−pending redos. Interactive confirmations identify the affected operation and
242−restored focus; `j ops` shows the history.
243−
244230---
245231
246232## 9. The interface itself
@@ -310,8 +296,6 @@ j new
310296# …edit…
311297j 'describe "part two"'
312298j squash
313−j prev # squash leaves an empty child; return to the combined commit
314−j 'describe "complete feature"'
315299j 'rebase %main'
316300j 'push (label "feature" here)'
317301```
docs/language.md+19−65
@@ -3,14 +3,8 @@
33`j` is a small, pure, functional language. Every `j` command evaluates one
44expression in this language against the current repository. The language is
55the whole interface: there are no subcommands beyond a handful of reserved
6−words, and no general flags apart from help. `--help` and `-h` open help as
7−the first word or as a separate argument after a command: `j new --help`
8−shows help without executing `new`. Inside a single expression argument or
9−stdin, `--` still starts a language comment, so `j 'new --help'` executes
10−`new`. Use `j help` for a command guide, `j commands` for everyday tasks,
11−and `j commands all` for the complete vocabulary. `j setup NAME EMAIL`
12−creates a new configuration with your identity without replacing an existing
13−file.
6+words, and no 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.
148
159This document describes the language itself — its values, syntax, semantics,
1610and the builtins — with examples throughout. Every example shows an expression
@@ -24,17 +18,16 @@ and what it evaluates to, exactly as `j` prints it.
2418
2519## 1. The big picture
2620
27−An expression run of `j` looks like this:
21+A run of `j` looks like this:
2822
29231. Read the expression (from the command line or stdin).
30−2. Snapshot the working directory for evaluation.
24+2. Snapshot the working directory into the working-copy commit.
31253. Build a value `repo` describing the repository, focused on the
3226 working-copy commit.
33274. Evaluate the expression.
34285. If the result is a function, apply it to `repo`.
35296. If the final value is a `Repo` that differs from the one loaded, persist
36− the difference. An unchanged `Repo` saves nothing; other values print
37− without saving.
30+ the difference; otherwise print the value and change nothing.
3831
3932So the most common thing you write is an **edit** — a function `Repo -> Repo`
4033(or a value that becomes one). `describe "wip"` is an edit. `new` is an edit.
@@ -234,8 +227,7 @@ distinct id each time it is evaluated (see §7).
234227
235228A `Blob` is the content of one file, either resolved or unresolved (a
236229conflict). You rarely write them directly; you get them from a commit's
237−`files`. It carries the file kind as well as its bytes. `blob "…"` builds a
238−resolved regular file from text; `text` reads it back:
230+`files`. `blob "…"` builds a resolved one, `text` reads it back:
239231
240232```
241233text (blob "file content")
@@ -561,9 +553,6 @@ crash "my message"
561553
562554Parse errors, configuration errors, and `Id`-resolution failures happen
563555*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.
567556
568557---
569558
@@ -647,13 +636,6 @@ unresolved (blob "x") => false
647636`unresolved b` is `true` iff the blob is a conflict (produced by a clashing
648637`replay`). `text` on an unresolved blob yields its conflict-marker rendering.
649638
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−
657639### Shapes and `extract`
658640
659641A type name in an expression is a `Shape`. `extract S v` returns every
@@ -685,10 +667,6 @@ A function that has a **signature** (a builtin, or a definition with one in
685667parameter type as it is supplied, and the final result against the result
686668type.
687669
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−
692670The check is one level deep — it never inspects inside a list or a record's
693671field values (that would make every call cost the size of the value).
694672
@@ -718,17 +696,11 @@ unchecked except through the functions they call.
718696
719697## 12. `show`: rendering values as text
720698
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
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
723701back to an equal value. Since `show` returns a `Text`, and a top-level `Text`
724702prints raw, `j show …` is how you inspect values.
725703
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−
732704```
733705show 42 => "42"
734706show "hi" => "\"hi\""
@@ -778,8 +750,7 @@ prev -- to the parent (crashes at the top)
778750next -- to the only child (crashes if not exactly one)
779751up -- like prev, the raw zipper move
780752by @wqzt -- refocus on a specific commit
781−tree . goto %main -- inspect main without moving the working copy
782−new . goto %main -- start a mutable child on main
753+goto %main -- refocus on what the remote calls main
783754parents -- the focus's parents, as a Revset
784755kids -- its children
785756ancestors -- it and its ancestors
@@ -792,7 +763,6 @@ Edits — functions `Repo -> Repo`:
792763
793764```
794765new -- a new empty child of the focus
795−save -- record file edits without changing the description
796766describe "wip" -- set the focus's message
797767squash -- fold the focus into its parent
798768abandon -- drop the focus; children join the parent
@@ -807,9 +777,7 @@ And the inspections:
807777
808778```
809779tree -- the history as a tree (a Text)
810−status -- { id, message, labels, changed, conflicts, unrecorded }
811−unrecorded -- paths edited since the last recording
812−preview squash -- validate and show the resulting tree without saving
780+status -- { id, message, labels, changed, conflicts }
813781review -- the focus's changes as one page of diffs
814782files -- the focus's snapshot (its files)
815783commits . top -- every commit, preorder
@@ -834,13 +802,10 @@ j 'tree'
834802-- the second commit was a mistake; fold it into the first
835803j 'squash'
836804
837−-- squash leaves a fresh empty child; move back and rename the combined commit
838−j prev
805+-- rename the combined commit
839806j 'describe "add the front end"'
840807
841−-- undo the rename, the focus move, then the squash
842−j undo
843−j undo
808+-- undo the squash entirely
844809j undo
845810
846811-- move back to the first commit to fix something
@@ -869,22 +834,17 @@ diff (blob "a\nb\n") (blob "a\nc\n")
869834 => "@@ -1,2 +1,2 @@\n a\n-b\n+c\n"
870835```
871836
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.
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.
876839
877840`treeWith options repo` renders the history tree with an options record:
878841
879842```
880−treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
843+treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
881844```
882845
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.
846+The reference config gives two ready-made versions: `tree` (compact) and
847+`treeFull` (with ages and authors in the margin).
888848
889849---
890850
@@ -960,14 +920,8 @@ the algebra they are.
960920
961921**Text:** `splitOn` `startsWith` `endsWith` `concat` `++`
962922
963−**Blobs/repos:** `blob` `blobHex` `text` `unresolved` `replay` `by` `meta` `extract`
923+**Blobs/repos:** `blob` `text` `unresolved` `replay` `by` `meta` `extract`
964924`diff` `difft` `treeWith` `validate`
965925
966−**Reserved commands** (not expressions): `help [NAME]` `commands [QUERY]`
967−`setup [NAME EMAIL]` `init` `clone URL [DIR]` `remote URL`
926+**Reserved commands** (not expressions): `init` `clone URL [DIR]` `remote URL`
968927`fetch` `push EXPR` `undo` `redo` `ops`.
969−
970−Literal reserved-command arguments preserve shell quoting boundaries, as in
971−`j clone URL 'my project'`. A whole reserved command passed as one string or
972−on stdin is split on whitespace. Expression arguments still use language
973−syntax: quote the whole expression, as in `j 'describe "my change"'`.
docs/tutorial.md+36−102
@@ -4,17 +4,9 @@ This is a guided first hour with `j`. It builds the mental model piece by
44piece — not just *what* to type, but *why it works* — so that by the end you
55can compose your own commands instead of memorizing them.
66
7−You'll want `j` installed (`nix build` or `cargo build --release`). Set up
8−your configuration with your own name and email:
9−
10−```
11−j setup 'Ada Lovelace' ada@example.org
12−```
13−
14−This creates the full reference configuration without overwriting an existing
15−file. If you already have a config, `j help config` shows its location so you
16−can edit the `user` definition. The remaining commands run in a real
17−repository, so you can follow along.
7+You'll want `j` installed (`nix build` or `cargo build --release`) and a
8+`config.j` with your identity (see the README's Configuration section). Every
9+command shown here is run in a real repository, so you can follow along.
1810
1911---
2012
@@ -28,11 +20,11 @@ j init
2820```
2921
3022`j init` makes a colocated `.jj`/`.git` repository and checks out a fresh
31−working-copy commit: the commit you are currently editing. It already exists,
32−so write a file and describe that commit:
23+working-copy commit. Now write a file and start your first change:
3324
3425```
3526echo 'fn main() {}' > main.rs
27+j 'new'
3628j 'describe "start the project"'
3729```
3830
@@ -44,48 +36,35 @@ j 'tree'
4436
4537```
4638 ⌂ zzzz
47−▶ └─ ◉ pnyz ▂ start the project
39+ └─ ● pnyz ▂
40+▶ └─ ◌ psnq start the project
4841```
4942
5043Add a second change on top:
5144
5245```
53−j 'new'
5446echo 'lib' > lib.rs
47+j 'new'
5548j 'describe "add a library"'
5649j 'tree'
5750```
5851
5952```
6053 ⌂ zzzz
61− └─ ● pnyz ▂ start the project
62−▶ └─ ◉ psnq ▂ add a library
54+ └─ ● pnyz ▂
55+ └─ ● psnq ▂ start the project
56+▶ └─ ◌ trtq add a library
6357```
6458
65−That's the basic loop: **edit files, describe the current change, then `new`
66−before editing the next change.** `new` opens an empty change with the same
67−files as its parent; it does not move existing edits into that new change.
68−There is no `git add` step. `describe` saves both the current files and their
69−description; `j save` records file edits without changing the description.
59+That's the whole basic loop: **edit files, `new`, `describe`.** There is no
60+`git add` step — the working directory is always already a commit, and `j`
61+snapshots it on every run.
7062
71−**What just happened, in the model:** each expression snapshots your working
72−directory for evaluation, builds a `Repo` value focused on that commit,
63+**What just happened, in the model:** each `j` run snapshots your working
64+directory into the working-copy commit, builds a `Repo` value focused there,
7365evaluates your expression, and — because `new` and `describe` are *edits*
7466(functions `Repo -> Repo`) — persists the resulting repository.
7567
76−`j status` has two useful lists: `in commit` compares the current commit with
77−its parent, while `unrecorded` lists file edits made since the last save.
78−After `describe` or `save`, `unrecorded` is empty; `in commit` still shows the
79−work in the commit. Inspecting status does not save anything.
80−
81−To join an existing project instead, clone it and enter its directory:
82−
83−```
84−j clone URL project
85−cd project
86−j status
87−```
88−
8968---
9069
9170## Part 2 — The one idea everything rests on
@@ -154,28 +133,22 @@ function produces, so don't wrap it.
154133Make a few commits to play with:
155134
156135```
157−j new; echo a > a.txt; j 'describe "A"'
158−j new; echo b > b.txt; j 'describe "B"'
159−j new; echo c > c.txt; j 'describe "C"'
136+echo a > a.txt; j 'new'; j 'describe "A"'
137+echo b > b.txt; j 'new'; j 'describe "B"'
138+echo c > c.txt; j 'new'; j 'describe "C"'
160139```
161140
162−Navigation changes the working-copy commit and checks out its files. Use
163−these commands to edit another mutable commit:
141+Navigation is edits too:
164142
165143```
166144j 'prev' -- to the parent
167145j 'next' -- to the only child
146+j 'top' -- to the top of history
168147j 'tip' -- to the end of the current line
169148j 'by @vqok' -- to a specific commit, by id (use a real id from j tree)
149+j 'goto %main' -- to what the remote calls main
170150```
171151
172−At a fork, choose an id from `j tree` with `j 'by @id'`; `next` only works
173−when there is one child. Main and the root commit are protected from editing.
174−Use `j 'new . goto trunk'` to start work on main, or
175−`j 'tree . goto trunk'` to inspect it without moving your working copy.
176−In a new local repository with no main bookmark, keep working from `j init`'s
177−commit instead.
178−
179152After `j 'prev'` from C, the tree shows the focus (▶ and ◉) on B:
180153
181154```
@@ -191,16 +164,11 @@ To see the focus's data, use the inspection functions:
191164```
192165j 'files' -- the focus's files
193166j 'changed' -- paths that differ from the parent
194−j 'status' -- focus, changes in the commit, unrecorded edits, conflicts
167+j 'status' -- id, message, labels, changed, conflicts
195168j 'log' -- a table of every commit, ✓ on the focus
196169j 'review' -- the focus's changes as diffs (difftastic)
197170```
198171
199−These inspections include current file edits without recording them. Use
200−`j save` to record file edits on their own (`id` is the same edit). `status` spells out empty states
201−such as `No changes` and `No conflicts`; `log` shows label names and short
202−ids and adapts to narrow terminals.
203−
204172### Composition order, made concrete
205173
206174```
@@ -226,10 +194,11 @@ operates on the repository the previous step produced.
226194
227195## Part 4 — Dry runs: seeing before doing
228196
229−Use `preview` to validate an edit and show its resulting tree without saving:
197+Because inspections persist nothing, you can preview any edit by composing it
198+with `tree`:
230199
231200```
232−j 'preview squash'
201+j 'tree . squash'
233202```
234203
235204From the A → B history, this *shows* the tree as `squash` would leave it (B
@@ -252,21 +221,18 @@ because `tree` returns a `Text`, not a `Repo`:
252221▶ └─ ◌ nknz B
253222```
254223
255−`preview` combines `tree` and `validate`. For an edit that takes arguments,
256−put that edit in parentheses:
224+For a stronger guarantee, insert `validate`. `validate repo` returns the repo
225+unchanged if persisting would succeed, and crashes with the exact error
226+otherwise:
257227
258228```
259−j 'preview (rebase %main)'
229+j 'tree . validate . rebase %main'
260230```
261231
262232This renders the post-rebase tree **and** crashes (without touching anything)
263233if that rebase couldn't actually happen — for instance, if `%main` doesn't
264234exist yet. That's an *honest* dry run.
265235
266−The longer form is `j 'tree . validate . rebase %main'`. `validate` alone
267−does not make an edit a dry run: `j 'validate . squash'` returns a repository
268−and saves it. The final inspection (`tree`) is what prevents persistence.
269−
270236---
271237
272238## Part 5 — `or`: the whole error-handling model
@@ -324,7 +290,7 @@ Two things make these safe to use freely:
324290
3252911. **The change id survives.** A commit keeps its `@wqzt…` id across
326292 `describe`, `squash`, `rebase`. You're never tracking a moving sha.
327−2. **Saved operations are undoable.** Use `j undo` and `j redo` to walk them.
293+2. **`j undo` always works.** Every operation is recorded.
328294
329295```
330296j undo -- revert the last thing
@@ -332,28 +298,6 @@ j redo -- reapply it
332298j ops -- see the operation log
333299```
334300
335−Undo and redo stop when files have unrecorded edits. Recording those edits
336−with `j id` adds an operation, so the next undo removes that recording;
337−another undo reaches the preceding edit. Recording also clears pending
338−redos. Read `j ops` to choose how far to go back. Successful interactive
339−undo/redo messages identify the affected operation and restored focus.
340−
341−### Resolve a conflict
342−
343−A successful rebase can leave conflicts in the working-copy commit. The edit
344−is saved; there is no separate continuation command. Resolve it in four steps:
345−
346−1. Run `j status` to find conflicted paths, and `j review` to inspect changes.
347−2. Edit those files, choosing the intended content and removing conflict markers.
348−3. Run `j save` to record the resolution.
349−4. Run `j status` again and check for `No conflicts` and `All edits recorded`.
350−
351−`j conflicts` lists conflicted commits throughout the repository. If a
352−conflict is on another mutable commit, use its id from `j tree` with
353−`j 'by @id'`, then follow the same steps. Run `j conflicts` again to check
354−the whole history. `review` uses difftastic when
355−installed and falls back to a unified diff when it is unavailable.
356−
357301### `split` and `contract`: moving changes by fileset
358302
359303A **fileset** is a function `Path -> Bool`. These two are inverses:
@@ -397,12 +341,9 @@ Most commands that take a "which commits" argument take a revset. `goto` is
397341the one place a revset must name *exactly one* commit:
398342
399343```
400−j 'new . goto %main' -- select one main commit and start a mutable child
344+j 'goto %main' -- crash if %main names 0 or 2+ commits
401345```
402346
403−A missing label reports how to fetch and inspect remote labels. An ambiguous
404−selector lists candidate ids and descriptions so you can choose with `by`.
405−
406347Revsets combine like sets:
407348
408349```
@@ -446,9 +387,9 @@ Everything `j` knows is defined in `config.j`. Any definition whose value is
446387an `Edit` is a new command. Add this to your `~/.config/j/config.j`:
447388
448389```
449−-- squash into the parent and name the combined commit "release"
390+-- fold the whole stack into one commit called "release"
450391release : Edit
451−release = describe "release" . prev . squash
392+release = describe "release" . (contract everything)
452393```
453394
454395Now `j release` works exactly like `squash` or `new`. The signature (`: Edit`)
@@ -491,8 +432,8 @@ echo 'fn parse() {}' > parser.rs
491432j 'describe "add the parser"'
492433
493434# second part
494−j 'new'
495435echo 'mod parser;' > lib.rs
436+j 'new'
496437j 'describe "wire up the parser"'
497438
498439# look at it
@@ -501,24 +442,17 @@ j 'tree'
501442# the second commit was really part of the first; fold it in
502443j squash
503444
504−# squash leaves an empty child focused; move to the combined commit
505−j prev
506−
507445# rename the combined commit
508446j 'describe "add the parser"'
509447
510448# preview the rebase, then do it
511−j 'preview (rebase %main)'
449+j 'tree . validate . rebase %main'
512450j 'rebase %main'
513451
514452# share it
515453j 'push (label "parser" here)'
516454```
517455
518−Push sends recorded commits. If you edit files after the last saved edit,
519−push warns that those edits are omitted. Run `j save`, then push again to
520−include them. Looking at `j status` does not save the edits.
521−
522456And if any step goes wrong:
523457
524458```
specs/main.md+121−408
@@ -9,7 +9,7 @@ Nothing is implicit beyond the language itself. Every shape, builtin, and
99command is declared or defined in one user-owned file, `~/.config/j/config.j`,
1010written in the language. The binary provides the language (its syntax, its
1111operators and their fixities, its primitive kinds), the implementations of the
12−declared builtins, eleven reserved words, and the bridge to jj repositories.
12+declared builtins, ten 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,19 +40,15 @@ echo EXPRESSION | j
4040 `::`, `or`, `.`, and the literals `%name`, `@id`, `./a/b` are all safe
4141 unquoted.
42423. If arguments are present and rule 1 also applies, exit 2 with
43− `j: expression given both as arguments and on stdin`. One byte is
44− sufficient to reject the invocation; the pipe need not reach EOF.
43+ `j: expression given both as arguments and on stdin`.
45444. If neither is present, or the input is only whitespace, print a concise
4645 usage and getting-started guide to stderr and exit 2. The guide points to
4746 `j help`, `j commands`, and commands for starting or inspecting a repository.
4847 It does not read or create configuration or open a repository.
49−5. `--help` and `-h` as the first word alias `help` (§1.1). A separate
50− argument equal to either flag after the first argument requests help for
51− the first word, before repository access or evaluation: `j abandon --help`
52− is `j help abandon`. An initial `help`, `--help`, or `-h` followed by
53− another help flag opens general help. Other flags are not supported.
54− Language comments embedded within a single argument or stdin are unchanged:
55− `j 'new --help'` executes `new`, and `j '"--help"'` prints text.
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.
56526. The expression is text: an argument that is not valid UTF-8 is a usage
5753 error (exit 2) naming it, and so is an expression on standard input that
5854 is not.
@@ -61,14 +57,13 @@ echo EXPRESSION | j
6157
6258Before the expression is parsed, its text is trimmed of leading and trailing
6359whitespace and split on whitespace into words. If the first word is one of the
64−eleven reserved words below, the whole text is a reserved command. A first word
60+ten reserved words below, the whole text is a reserved command. A first word
6561of `--help` or `-h` is treated as `help`.
6662
6763| command | operation |
6864|---|---|
6965| `help [NAME]` | show the command guide, or help for a reserved command or config name; §1.1.1 |
70−| `commands [QUERY]` | show common tasks, search the vocabulary, or use `all` for the full catalog; §1.1.1 |
71−| `setup [NAME EMAIL]` | create the complete reference configuration, optionally setting identity; §6.1.1 |
66+| `commands [QUERY]` | list the vocabulary, optionally searching names, signatures, and comments; §1.1.1 |
7267| `init` | create a repository in the current directory; §7.8 |
7368| `clone URL [DIR]` | clone `URL` into `DIR` (default: the repository's name in `URL`); §7.8 |
7469| `remote URL` | set the URL of the remote `origin`, creating it if absent; §7.8 |
@@ -79,12 +74,8 @@ of `--help` or `-h` is treated as `help`.
7974| `ops` | print the operation log; §7.7 |
8075
8176For every reserved command except `push` and `commands`, the remaining words
82−are literal arguments, and the language is not involved. When the first
83−shell argument is the command name, the remaining shell argument boundaries
84−are preserved, so `j clone '/path/to/source repo' 'my project'` accepts both
85−paths. A command supplied as one string or on stdin is split on whitespace;
86−it has no additional quote or escape syntax. A wrong argument count is a
87−usage error (exit 2). `commands` takes the remaining
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
8879trimmed text as its optional search query, including spaces. For `push`, the
8980remainder of the text is an expression, evaluated as in §1.2 steps 2–7 but
9081against the repository as recorded, without snapshotting the working directory;
@@ -104,18 +95,11 @@ for that reserved command or for a name in the current configuration, including
10495its signature when present and available documentation. `--help [NAME]` and
10596`-h [NAME]` are equivalent to `help [NAME]`. These successful requests exit 0.
10697An unknown name is a usage error (exit 2) with guidance to use `j commands`.
107−`j help config` is a built-in topic showing the configuration path, identity
108−setup, and repair instructions. It is available even with invalid configuration.
109−
110−`commands` shows a concise catalog of common tasks and their commands.
111−`commands all` lists the reserved commands and vocabulary actually declared
112−or defined in the current configuration, including custom definitions without
113−signatures. The concise catalog includes a bounded selection of custom
114−definitions and points to the full list. `commands QUERY` searches the catalog case-insensitively by name,
115−signature, documentation comments, and common task synonyms such as `commit`,
116−`checkout`, and `branch`, ranking direct matches and everyday commands first.
117−The `stash` query explains the save/new workflow when no custom `stash`
118−definition exists. The catalog does not present an
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
119103undeclared builtin as available merely because the binary implements it.
120104
121105General help and help for reserved commands do not depend on configuration and
@@ -146,8 +130,7 @@ resolves commit ids, snapshots the working directory, or records operations.
1461307. If `v` is a function, apply it to `r`, once, and let `v` be the result.
1471318. If `v` is a `Repo` (§4.4): if it equals the repository value *as loaded,
148132 before the snapshot of §7.4*, do nothing at all; otherwise persist it
149− (§7.5) and check out its focused commit's files (§7.4). Print nothing to stdout;
150− interactive stderr reports the outcome and focus (§1.5).
133+ (§7.5) and check out its focused commit's files (§7.4). Print nothing.
151134 Exit 0.
1521359. Otherwise display `v` (§5.1) on stdout. Persist nothing. Exit 0.
153136
@@ -157,7 +140,7 @@ persist nothing, leave the working directory untouched, and exit 1.
157140Step 7 applies exactly once. If the result of applying a function to `r` is
158141again a function, that function is displayed, not applied again.
159142
160−Step 8's equality test means `j save` (the reference alias for `id`) is a no-op when the working directory is
143+Step 8's equality test means `j id` is a no-op when the working directory is
161144unchanged and records it when it is not: the snapshot makes `r` differ from
162145the loaded value, and that difference is what gets persisted. The test may be
163146implemented by comparing commit and tree hashes rather than values.
@@ -169,11 +152,9 @@ Two consequences worth knowing:
169152 what a rebase would produce without doing it. `validate` in the chain,
170153 `j tree . validate . rebase trunk`, additionally raises whatever
171154 persistence would refuse, so the preview is of something that could
172− actually be persisted. The reference `preview EDIT` is the convenient
173− form of `tree . validate . EDIT`: `j 'preview (rebase trunk)'`. Validation
174− alone returns a `Repo`, so `validate . EDIT` persists a changed result.
175−- **Do, then look.** `j squash` reports the resulting focus on an interactive
176− terminal. `j squash && j tree` displays the whole tree; there is no flag for it.
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.
177158
178159### 1.3 Interrupts
179160
@@ -226,49 +207,6 @@ is in `goto`. A builtin is not a definition, so a crash in one is in
226207whichever definition applied it, and with none executing the line is only
227208` from <expression>`.
228209
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−
272210---
273211
274212## 2. Values
@@ -528,9 +466,7 @@ negation. Ordering operators are defined on `Int` only and crash otherwise.
528466
529467`if`, `or`, `&&`, and `||` do not evaluate all operands. `&&` and `||`
530468short-circuit. The interpreter recognises `&&` and `||` by name; `or` is a
531−keyword. Like other builtins, `&&` and `||` must be declared. Their contracts
532−check each evaluated operand and the final result; a skipped operand is
533−neither evaluated nor checked.
469+keyword.
534470
535471`a or b` evaluates `a`. If that crashes, the crash is discarded and `b` is
536472evaluated and returned. Otherwise, if the value of `a` is a function, `b` is
@@ -596,19 +532,17 @@ as in §4.13.
596532| `replay onto ch` | replay the change `ch` (`{ from, to }`) onto the snapshot `onto`; §7.3 |
597533| `unresolved b` | `true` iff the blob is a conflict |
598534| `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 |
600535| `text b` | the content of a blob as text; for an unresolved blob, its conflict-marker rendering |
601536| `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 |
602537| `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 |
603538| `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 |
604539| `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) |
605540| `diff a b` | a unified diff from the text of `a` to the text of `b`, split into lines after each `\n` (not at a lone `\r`), no `---`/`+++` header lines, three lines of context, each hunk headed `@@ -s,n +s,n @@` with the first line and the number of lines it shows of each side, as `diff -u` writes it, a line that has no trailing newline followed by `\ No newline at end of file`; `""` if equal; crashes if either is not UTF-8 |
606−| `difft p a b` | the output of difftastic comparing `a` to `b` as the file `p`, with a unified-diff fallback when the executable is absent; §7.10 |
607−| `unrecorded repo` | paths changed between the invocation's loaded and snapshotted focus files; §7.4. Returns `[]` without snapshot context, including in a push expression |
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` |
608542| `treeWith o r` | the history rendered as a tree with options record `o`; §7.11 |
609543| `extract S v` | every subvalue of `v` that is an `S`; §4.12 |
610544| `meta i` | `{ hash, author, email, time }` for the visible commit with change id `i`: its current git hash, author name and email, committer time. Crashes for an id with no stored commit (one minted in this program) |
611−| `validate repo` | `repo` unchanged if persisting it would succeed; otherwise the crash persistence would raise (§7.5 steps 1–3, the root invariant of 4, and 6). A changed repository also checks the backend's author identity (§7.9). Persistence runs the same checks |
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 |
612546
613547### 4.10 Ids: existing and new
614548
@@ -697,11 +631,6 @@ signature is a crash, not a reason to try the other side. A signature on
697631a definition that is not a function (`user`, `conflicts`) is checked once,
698632against the value, when the definition is evaluated at load.
699633
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−
705634The check is one level deep and never inspects inside a list or a record's
706635field values:
707636
@@ -745,9 +674,7 @@ indented under their key or bullet. Colour follows §7.11's rules and is used
745674only when stdout is a terminal; nothing is conveyed by colour alone.
746675
747676Shapes are recognised by their exact field set, as in §4.12: `Commit`, `Entry`,
748−`Subtree`, `Repo`, `Frame`, `Change`, checked in that order using their
749−configured definitions. Additional type aliases do not displace these
750−display forms. Any other record is generic.
677+`Subtree`, `Repo`, `Frame`, `Change`. Any other record is generic.
751678
752679| kind | line form | block form |
753680|---|---|---|
@@ -772,21 +699,6 @@ display forms. Any other record is generic.
772699| list of other scalars | `a, b, c` | same, wrapped at the terminal width |
773700| any other list | `n items` | one block per item, separated by a blank line |
774701
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−
790702An empty list of any kind prints `none` (dim) in block form and `0 …` in
791703line form. Author and age in a `Commit` block are read from jj's commit
792704metadata by id;
@@ -810,13 +722,12 @@ Examples:
810722
811723```
812724$ j status
813−in commit src/lexer.rs
814− src/parser.rs
815−conflicts src/lexer.rs
816−id wqzt·kpqxmnrvyxskptlmzzzzabcd
817−labels feature
818−message wip
819−unrecorded All edits recorded
725+changed src/lexer.rs
726+ src/parser.rs
727+conflicts src/lexer.rs
728+id wqzt·kpqxmnrvyxskptlmzzzzabcd
729+labels feature
730+message wip
820731
821732$ j conflicts
822733⊗ wqzt wip feature
@@ -859,19 +770,14 @@ j: crash: expected one revision, got 0
859770
860771### 5.2 `show`
861772
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
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
864775bound (§3.4: a list 40,000 deep, a record about 20,000), `show v` parses and
865776evaluates to a value equal to `v` under the same `config.j`. It never uses
866777colour and never truncates. A value holding a part the store cannot supply (a
867778`files` list or a file's content, §7.2) crashes `show` as it crashes any other
868779use: `show` writes no stand-in for the part.
869780
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−
875781| kind | rendering |
876782|---|---|
877783| `Int` | decimal |
@@ -880,8 +786,7 @@ modified automatically.
880786| list | `[v1 v2 …]` |
881787| record | `{ a = v, b = w }`, fields in ascending lexicographic order |
882788| `Id` | `@` followed by the full id, e.g. `@wqztkpqxmnrvyxskptlmzzzzabcd` |
883−| resolved regular `Blob` with valid UTF-8 content | `blob "…"` |
884−| other resolved `Blob` | `blobHex "kind" "…"`, preserving file kind and bytes, with lowercase hexadecimal content |
789+| resolved `Blob` | `blob "…"` |
885790| unresolved `Blob` | `{- unresolved -} blob "…"` with the conflict-marker rendering |
886791| builtin | its declared name, operators parenthesised: `map`, `(++)` |
887792| selector | the selector itself: `.id` |
@@ -919,23 +824,12 @@ Since `show` returns a `Text` and a top-level `Text` displays raw,
919824### 6.1 Location and loading
920825
921826`j` reads `$XDG_CONFIG_HOME/j/config.j`, defaulting to `~/.config/j/config.j`.
922−Expression evaluation and `push` load the configuration; `init` and
827+Expression evaluation, `push`, and `fetch` load the configuration; `init` and
923828`clone` need `user`. When there is no repository yet (`init`, `clone`), only
924829`user` is evaluated; `Id` literals are not resolved and no other definition
925−is evaluated. `remote`, `fetch`, `undo`, `redo`, and `ops` do not load configuration.
830+is evaluated. `remote`, `undo`, `redo`, and `ops` do not load configuration.
926831When a command needs configuration and none exists, the binary writes its
927−embedded reference `config.j` (§9) to that path as an editable starting point,
928−readable and writable by its owner, and reports its path. An existing file is
929−never overwritten, including when it cannot be read or contains invalid
930−UTF-8; such errors exit 3.
931−Configuration diagnostics identify the actual file; parse errors include the
932−line number and a bounded source excerpt, with a pointer to `j help config`.
933−Excerpts escape terminal controls and are capped at 160 display columns and
934−characters, with an ellipsis when truncated. Expression parse errors, including
935−push expressions, likewise identify their source line. Runtime diagnostics may
936−add typo suggestions from the actual vocabulary, quoting examples for Text
937−arguments, or missing-argument guidance derived from the command signature;
938−they never retry an expression or change its meaning or exit code.
832+embedded reference `config.j` (§9) to that path as an editable starting point.
939833An existing user configuration is not updated when the binary changes. The
940834reference file is also distributed at `<prefix>/share/j/config.j`.
941835
@@ -945,23 +839,6 @@ names read it without evaluation. If it is missing they use the embedded
945839reference in memory without writing a file. The empty-invocation guide does
946840not read or write configuration either.
947841
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−
965842### 6.2 Validation
966843
967844Loading fails (exit 3) if any of these hold:
@@ -985,13 +862,6 @@ not declared is simply unbound, and using it is a runtime crash.
985862
986863Definitions may reference each other in any order.
987864
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−
995865### 6.3 Definitions the interpreter reads
996866
997867Four names in `config.j` are read by the interpreter itself, not only by
@@ -1020,10 +890,6 @@ but nothing here requires the `jj` binary to be installed.
1020890Walk up from the current directory to the first directory containing `.jj`.
1021891Only the default workspace is supported; if the working directory belongs to
1022892another 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`.
1027893
1028894### 7.2 Building the `Repo` value
1029895
@@ -1127,11 +993,10 @@ some other value in its place.
1127993 `./a` to `./a/b` replayed onto `./a/b`; the deletion of `./a/z` replayed
1128994 onto a snapshot where `./a` is a file gives that file), and it may crash
1129995 where jj carries a conflict that one of the three held at `./a`.
1130−- `unresolved`, `blob`, `blobHex`, and `text` are as in §4.9. `blob` produces
1131− a regular file from text. `blobHex` constructs a regular file, executable,
1132− or symlink from its kind and hexadecimal bytes, so `show` can preserve both
1133− content and file type. `text` of a symlink is its target; `text` of a
1134− non-UTF-8 blob crashes. Persisting a symlink with a non-UTF-8 target crashes.
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.
11351000
11361001### 7.4 Working-directory invariant
11371002
@@ -1227,14 +1092,6 @@ directory.
12271092
12281093Reserved commands never snapshot.
12291094
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−
12381095### 7.5 Persistence
12391096
12401097Given the loaded value `old` and the result `new`, one jj operation is
@@ -1242,14 +1099,11 @@ recorded, described by the expression text (truncated to 200 characters).
12421099Within it:
12431100
124411011. **Validate shape.** `new` has the shape of a `Repo`; every `id` occurs
1245− exactly once; every commit's `message` is a `Text`; every snapshot entry
1246− has a `path` list of texts and a `content` that is a `Blob`; every snapshot
1247− has unique paths, none of them the root `./` and none both a file and a
1248− directory (§7.3); every path
1249− component of the focus, of every other commit that is not in `old`, and
1250− of every path any other commit holds that it did not hold in `old`
1251− (§7.3), is a name a checkout can create: not empty, `.`, `..`, `.git` or
1252− `.jj`, without `/`
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 `/`
12531107 or NUL, and a name the workspace's filesystem can hold. A name the working
12541108 directory holds (§7.4) is one. A name longer than 255 bytes, the limit of
12551109 ext4, is tried on the filesystem, since exFAT, NTFS and APFS count 255
@@ -1374,18 +1228,10 @@ language: `fetch` and `push` are the only things that change them.
13741228 creating it if absent;
13751229 - `{ delete : Text }` — delete the bookmark of that name.
13761230
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−
13841231 Duplicates are merged; two records with the same `name` and different
13851232 effects are a crash. It is refused (exit 1, nothing pushed) if:
1386− - a `name` is not a valid git branch name, an `id` is not a visible commit
1387− or names the synthetic root (which has no Git commit), or a delete names
1388− a bookmark the remote does not have;
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;
13891235 - any commit that would be sent has an unresolved file or an empty
13901236 description (the commits that would be sent are the records' `id`s and
13911237 their ancestors that no bookmark on `origin` reaches as of the last
@@ -1424,68 +1270,47 @@ language: `fetch` and `push` are the only things that change them.
14241270 bookmarks, and the refused push does not touch the label. After a
14251271 successful push the labels reflect the new positions. It records one
14261272 operation, even when it sends two git pushes. An empty list sends nothing
1427− to the remote, records no operation, and always reports that no updates
1428− were selected, with guidance for selecting a first bookmark. A nonempty
1429− successful push reports published, deleted, and unchanged bookmark counts
1430− on an interactive terminal, including an entirely unchanged selection.
1273+ to the remote.
14311274 If the remote accepts some of the updates and rejects others, that
14321275 operation records the accepted ones, whose labels move, and `push` exits 1
14331276 naming the rejected bookmarks and any deletes it did not send.
14341277
14351278Only the remote named `origin` is supported in this version; `fetch` and
1436−nonempty `push` exit 1 with `j: no remote origin` when it is not configured.
1279+`push` exit 1 with `j: no remote origin` when it is not configured.
14371280Authentication is git's: the ssh agent for ssh URLs, git credential helpers
14381281for https, as jj-lib's git backend provides them; `j` adds no prompting.
14391282Bringing work up to date is a separate expression, e.g. `rebase trunk`.
14401283
14411284### 7.7 Operations, undo, redo, and the lock
14421285
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.
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`.
14811308
14821309Both refuse, before doing anything, if the working directory holds a change
14831310the focused commit does not, as a snapshot would find it (§7.4; a stale
14841311working copy holds none where the directory holds what was last checked out
1485−or what the commit has). The refusal explains that `j id` preserves those
1486−edits in a new operation, and the next undo reverses that recording before
1487−reaching the earlier operation; users should inspect `j ops` and can recover
1488−the recording with redo. Both record their operation and then check
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
14891314out the restored focus, as persistence does (§7.5 step 7): a checkout that
14901315fails leaves the operation recorded, and `redo` or `undo` goes back.
14911316Undoing a `push` restores the recorded labels and leaves the remote untouched.
@@ -1538,9 +1363,7 @@ that state, §7.2 applies.
15381363 it: the last component of `URL`'s path, a final `.git` component skipped,
15391364 minus `.git` (`repo` for `/srv/repo/.git`, `host:repo.git` and
15401365 `https://host/team/repo`); a `URL` that leaves no name, such as `/` or
1541− `..`, is a usage error asking for `DIR`. It requires `user`. Interactive
1542− confirmation includes a shell-quoted command to enter the clone directory
1543− and run `j status`; cloning cannot change the caller's working directory.
1366+ `..`, is a usage error asking for `DIR`. It requires `user`.
15441367- **`remote URL`** sets the URL of `origin`, creating the remote if it does not
15451368 exist. It does not fetch.
15461369
@@ -1571,27 +1394,16 @@ Commits are authored and committed as `user.name <user.email>` from
15711394`config.j` (§6.3). `j` reads no other configuration; jj-lib's settings object
15721395is constructed by `j` from `user` and fixed defaults. `init`, `clone`, and
15731396persistence exit 3 if `user` is missing or malformed, since that is a
1574−configuration error. Authoring also refuses blank fields or the shipped
1575−`Your Name` / `you@example.com` placeholders (§6.2), with instructions to
1576−edit the actual config file. Read-only expressions and unchanged results
1577−remain available with placeholder identity. Successful interactive setup
1578−reports the configured identity; terminal control characters are escaped
1579−in confirmations without modifying the identity stored in commits.
1580−`validate` checks the author identity when its argument differs from the
1581−loaded repository, so validated previews report the same identity refusal
1582−before saving. That refusal is a catchable crash, like other `validate`
1583−failures. An unchanged `validate` and previews without `validate` remain usable.
1397+configuration error.
15841398
15851399### 7.10 External diff rendering
15861400
15871401`difft p a b` runs difftastic. It is the only builtin that executes another
15881402program.
15891403
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`.
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.
159514072. Run `difft OLD NEW` with the process's stdin closed and its environment
15961408 inherited unchanged, except: if `DFT_COLOR` is unset and `j`'s stdout is a
15971409 terminal, set `DFT_COLOR=always`; if `DFT_WIDTH` is unset and `j`'s stdout
@@ -1601,24 +1413,15 @@ program.
160114134. Return stdout as `Text`. A non-zero exit status is not an error (difftastic
16021414 uses it to signal "differences found" under some options), so what a
16031415 `difft` that fails printed, even nothing, is the result; a failure to
1604− execute the program because it is absent uses the fallback below. Other
1605− launch failures are crashes naming the tool and the OS error.
1606−
1607−If the executable is absent (`NotFound`), return a unified diff with three
1608−lines of context and `--- a/PATH` / `+++ b/PATH` headers using the complete
1609−path `p`. Paths containing whitespace or control characters are quoted.
1610−Content that is not UTF-8 or contains NUL receives a binary-difference summary.
1611−Equal bytes produce empty output. This fallback is usable without installing
1612−another program or changing configuration.
1416+ execute the program is a crash naming it.
16131417
16141418All rendering options are difftastic's own, set through its `DFT_*`
16151419environment variables. `j` adds no options of its own. `diff` remains
1616−available as a dependency-free unified diff without path headers; the
1617−reference `config.j` uses `difft` for display and receives the automatic
1618−fallback when difftastic is absent.
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.
16191422
1620−Packaging: difftastic is an optional runtime enhancement; on Nix, wrap `j`
1621−with `difft` on its `PATH`.
1423+Packaging: difftastic is a runtime dependency; on Nix, wrap `j` with `difft`
1424+on its `PATH`.
16221425
16231426### 7.11 Tree rendering
16241427
@@ -1642,8 +1445,7 @@ run — have no metadata: their margin cells are blank, their size bar is
16421445computed from the value, and they sort last among their siblings. The same
16431446holds for a `Commit` block in display (§5.1).
16441447
1645−Missing fields crash. `color = "auto"` means colour iff stdout is a terminal
1646−and `TERM` is not `dumb`.
1448+Missing fields crash. `color = "auto"` means colour iff stdout is a terminal.
16471449If the environment variable `NO_COLOR` is set and non-empty, colour is never
16481450used regardless of `color`. `config.j` fixes the defaults by defining `tree`.
16491451
@@ -1861,10 +1663,6 @@ Drop = { delete : Label } -- delete the remote's bookmark
18611663------------------------------------------------------------------------------
18621664
18631665(.) : (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.
18681666id : a -> a
18691667const : a -> b -> a
18701668crash : Text -> a -- abort the program with a message
@@ -1932,33 +1730,21 @@ splitOn : Text -> Text -> [Text] -- splitOn "/" "a/b" = ["a" "b"]
19321730-- file ./a would, crashes, naming the path (§7.3).
19331731replay : Snapshot -> Change -> Snapshot
19341732unresolved : 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
19421733blob : Text -> Blob -- a resolved regular file
1943−blobHex : Text -> Text -> Blob -- kind (regular/executable/symlink), hex bytes
19441734text : Blob -> Text -- its content (markers if unresolved)
19451735by : 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
19481736subtreeCommits : a -> [Commit] -- every commit of a tree in preorder, in one
19491737 -- pass: what `commits` (below) is
19501738meta : Id -> Meta -- git hash, author, email, committer time;
19511739 -- crash for a commit not yet persisted
19521740validate : Repo -> Repo -- the repo unchanged, or the crash persisting
1953− -- it would produce (shape, root, labels, immutability,
1741+ -- it would produce (shape, labels, immutability,
19541742 -- focus). `tree . validate . rebase trunk` is an
19551743 -- honest dry run.
19561744diff : Blob -> Blob -> Text -- unified diff from the first to the second
1957−difft : Path -> Blob -> Blob -> Text -- difftastic when difft is on PATH; otherwise
1958− -- unified diff with path headers and 3 context lines,
1959− -- or a binary-files summary. DFT_* configures difft.
1960−treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text
1961− , lanes : Int, author : Bool, date : Bool, files : Bool }
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 }
19621748 -> Repo -> Text -- the history as a tree; see the spec
19631749
19641750-- Every subvalue of a given shape, however deeply nested. A shape is a type
@@ -2044,9 +1830,6 @@ restrict = \m -> filter (\e -> m e.path)
20441830select : Fileset -> Snapshot -> Snapshot -> Snapshot -- m from a, the rest from b
20451831select = \m a b -> restrict m a ++ restrict (neg m) b
20461832
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`.
20501833conflicted : Snapshot -> [Path]
20511834conflicted = \s -> map (.path) (filter (\e -> unresolved e.content) s)
20521835
@@ -2131,11 +1914,6 @@ minus = \a b repo -> let ys = b repo in filter (\i -> not (member i ys)) (a repo
21311914matching : (Commit -> Bool) -> Revset -> Revset
21321915matching = \p rs repo -> filter (\i -> p (commitAt i repo)) (rs repo)
21331916
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.
21391917conflicts : Revset
21401918conflicts = matching (\c -> not (null (conflicted c.files))) all
21411919
@@ -2144,10 +1922,7 @@ firstOf : [Revset] -> Revset
21441922firstOf = \rss repo -> foldl (\acc rs -> if null acc then rs repo else acc) [] rss
21451923
21461924-- The main line: whichever of these names the remote has, first one wins.
2147−-- Empty when no fetched remote label is named main, master, or trunk;
2148−-- nothing is then immutable. Configure `j remote URL`, run `j fetch`, and
2149−-- inspect `j tree` to see remote labels. A remote with another default label
2150−-- needs this definition adjusted. Start work with j 'new . goto trunk'.
1925+-- Empty in a repository with no remote, in which case nothing is immutable.
21511926trunk : Revset
21521927trunk = firstOf [%main %master %trunk]
21531928
@@ -2163,33 +1938,21 @@ immutable = ancestorsOf trunk
21631938-- Focusing. `goto` is the only place a revset must have exactly one member.
21641939------------------------------------------------------------------------------
21651940
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.
21681941goto : Revset -> Edit
21691942goto = \rs repo ->
21701943 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"
21751944 in if length is == 1
21761945 then by (head is) repo
2177− else if null is
2178− then crash "expected one revision, got 0; no matching revision. Run `j tree` to inspect available revisions. For a missing remote label such as %main or trunk, configure `j remote URL`, run `j fetch`, and check the label names in `j tree`."
2179− else crash ("expected one revision, got " ++ show (length is) ++ "\nCandidates:\n" ++ concat (map candidate (take 8 is)) ++ (if length is > 8 then " More candidates: run `j tree`.\n" else "") ++ "Choose a mutable commit with `j by @ID`, or start a child with `j 'new . by @ID'`; replace @ID with a candidate above.")
1946+ else crash ("expected one revision, got " ++ show (length is))
21801947
21811948commitAt : Id -> Repo -> Commit
2182−commitAt = lookupCommit
1949+commitAt = \i repo -> (by i repo).root
21831950
21841951prev : Edit -- the parent
21851952prev = goto parents
21861953
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
1954+next : Edit -- the only child
1955+next = goto kids
21931956
21941957tip : Edit -- end of the line of only-children
21951958tip = \repo -> tip (next repo) or repo
@@ -2215,8 +1978,6 @@ eachChild = forEach kids
22151978guard : (Repo -> Bool) -> Edit -- crash unless p holds
22161979guard = \p repo -> if p repo then repo else crash "guard"
22171980
2218−-- Whether the focused commit has no unresolved files. This says nothing
2219−-- about unrecorded working-copy edits; inspect those with `j unrecorded`.
22201981clean : Repo -> Bool
22211982clean = \repo -> null (conflicted repo.root.files)
22221983
@@ -2255,28 +2016,11 @@ rewrite = \f repo ->
22552016-- Commands
22562017------------------------------------------------------------------------------
22572018
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−
22732019-- Set the message of the focused commit.
2274−-- Example: j 'describe "fix login"'
22752020describe : Text -> Edit
22762021describe = \m -> mapRoot (\c -> c { message = m })
22772022
22782023-- Start an empty child of the focus and move to it.
2279−-- Examples: j new; j 'describe "next change" . new'
22802024new : Edit
22812025new = \repo -> addChild (newCommit repo.root.files) repo
22822026
@@ -2284,7 +2028,6 @@ new = \repo -> addChild (newCommit repo.root.files) repo
22842028-- new empty commit on that parent becomes the focus (the parent may be
22852029-- immutable, and the focus never is). Refused for a commit the remote has a
22862030-- 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'.
22882031abandon : Edit
22892032abandon = \repo ->
22902033 let c = repo.root
@@ -2294,20 +2037,17 @@ abandon = \repo ->
22942037 else new (remove (eachChild (replayOnto p) repo))
22952038
22962039-- Push the files matching m from the focus down into its parent.
2297−-- Example: j 'contract (ext "rs")'
22982040contract : Fileset -> Edit
22992041contract = \m repo -> let a = repo.root
23002042 in at parents (rewrite (\q -> q { files = select m a.files q.files })) repo
23012043
23022044-- Fold the focus entirely into its parent; a new empty commit becomes the focus.
2303−-- Example: j squash; preview first with j 'preview squash'.
23042045squash : Edit
23052046squash = abandon . contract everything
23062047
23072048-- Push the files matching m from the focus up into a new child, which becomes
23082049-- the focus and inherits the old focus's children. Labels stay with the
23092050-- original commit, since they are the remote's.
2310−-- Example: j 'split (ext "rs")'; use (under ./src) to select a directory.
23112051split : Fileset -> Edit
23122052split = \m repo ->
23132053 let a = repo.root
@@ -2317,7 +2057,6 @@ split = \m repo ->
23172057 in by x.id (repo { root = a', children = [{ root = x, children = repo.children }] })
23182058
23192059-- Move the focused subtree onto another commit. Returns to the moved commit.
2320−-- Example: j 'rebase trunk'; preview with j 'preview (rebase trunk)'.
23212060rebase : Revset -> Edit
23222061rebase = \rs repo ->
23232062 let d = (goto rs repo).root
@@ -2328,7 +2067,6 @@ rebase = \rs repo ->
23282067 in by me (attach x.subtree (by d.id x.rest))
23292068
23302069-- Apply the focused commit's change onto another commit, as a new child there.
2331−-- Example: j 'pick %main'
23322070pick : Revset -> Edit
23332071pick = \rs repo ->
23342072 let ch = changeOf repo
@@ -2336,7 +2074,6 @@ pick = \rs repo ->
23362074 in at rs (addChild (newCommit (replay t.files ch))) repo
23372075
23382076-- A new child of the focus that undoes the change of the given commit.
2339−-- Example: j 'backout %feature'
23402077backout : Revset -> Edit
23412078backout = \rs repo ->
23422079 let ch = changeOf (goto rs repo)
@@ -2382,22 +2119,20 @@ entryAt = \p s -> filter (\e -> e.path == p) s
23822119
23832120-- The content at path p, or an empty file if absent.
23842121contentAt : Path -> Snapshot -> Blob
2385−contentAt = lookupContent
2122+contentAt = \p s -> (head (entryAt p s)).content or blob ""
23862123
23872124-- The paths a change touches.
23882125touched : Change -> [Path]
2389−touched = touchedPaths
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
23902129
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
2130+-- Paths whose content differs between the focus and its parent.
23952131changed : Repo -> [Path]
23962132changed = \repo -> touched (changeOf repo)
23972133
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.
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.
24012136diffs : Repo -> [{ path : Path, diff : Text }]
24022137diffs = \repo ->
24032138 let ch = changeOf repo
@@ -2405,30 +2140,23 @@ diffs = \repo ->
24052140
24062141-- The same, as one page of text. A Text result prints raw, so `j review` is
24072142-- readable in the terminal. A focus that changes nothing reads "no changes";
2408−-- missing difft uses the built-in unified diff (path headers, 3 context lines)
2409−-- or a binary-files summary. Other execution errors remain visible; an
2410−-- installed difft's output, even nothing, is the page.
2411−-- Example: j review; difftastic is optional.
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.
24122145review : Repo -> Text
24132146review = \repo ->
24142147 let ds = diffs repo
24152148 in if null ds then "no changes\n" else concat (map (\d -> d.diff) ds)
24162149
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] }
2150+-- A summary of the focus.
2151+status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path] }
24222152status = \repo ->
24232153 { id = repo.root.id
24242154 , message = repo.root.message
24252155 , labels = repo.root.labels
24262156 , changed = changed repo
2427− , conflicts = conflicted repo.root.files
2428− , unrecorded = unrecorded repo }
2157+ , conflicts = conflicted repo.root.files }
24292158
24302159-- A summary of every commit, top first, in preorder.
2431−-- Example: j log
24322160log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]
24332161log = \repo ->
24342162 map (\c -> { id = c.id
@@ -2439,21 +2167,11 @@ log = \repo ->
24392167
24402168-- The history as a tree. Edit these records to change what `j tree` shows.
24412169-- (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.
24452170tree : Repo -> Text
2446−tree = treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
2447−
2448−treeCompact : Repo -> Text
2449−treeCompact = treeWith ({ detail = 1, margin = false, elide = true, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
2171+tree = treeWith ({ detail = 1, margin = false, elide = true, icons = false, color = "auto" })
24502172
24512173treeFull : Repo -> Text
2452−treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
2453−
2454−-- The full tree plus the extra data columns: date, files changed, author.
2455−treeData : Repo -> Text
2456−treeData = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = true, date = true, files = true })
2174+treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
24572175```
24582176
24592177---
@@ -2461,9 +2179,9 @@ treeData = treeWith ({ detail = 2, margin = true, elide = false, icons = false,
24612179## 10. Testing
24622180
24632181The interpreter must be testable without a jj repository. Provide an in-memory
2464−implementation of the domain builtins (`replay`, `unresolved`, `blob`, `blobHex`,
2182+implementation of the domain builtins (`replay`, `unresolved`, `blob`,
24652183`text`, `by`, `extract`, `meta`, `difft` as a stub, and the minting
2466−of `@`) in which a resolved blob carries bytes and a file kind, a
2184+of `@`) in which a blob is text, a
24672185conflict is a record of the three sides, and `replay` resolves a path exactly
24682186when at most one side changed it or both made the same change, path by path
24692187even where a file meets a directory, which jj merges as one value (§7.3).
@@ -2499,10 +2217,9 @@ here is out of scope (§12). Nothing here uses the `jj` binary.
24992217**Getting a repository**
25002218
25012219```
2502−j setup 'Your actual name' you@your-domain.example -- create the full config once
2220+$EDITOR ~/.config/j/config.j -- set `user` once
25032221j init -- new repository here, or adopt an existing .git
2504−j clone git@host:org/repo.git project -- or clone an existing one
2505−cd project -- after clone, enter its directory
2222+j clone git@host:org/repo.git -- or an existing one
25062223j remote git@host:org/repo.git -- attach a remote to an init'd repo
25072224```
25082225
@@ -2511,9 +2228,9 @@ j remote git@host:org/repo.git -- attach a remote to an init'd repo
25112228```
25122229j tree -- the history as a tree, focus marked
25132230j treeFull -- the same with sizes, ages, authors, changed paths
2514−j status -- focus, in-commit changes, unrecorded edits, conflicts
2231+j status -- id, message, labels, changed paths, conflicts of @
25152232j log -- every commit as a record
2516−j review -- difftastic or unified diff of @ against its parent
2233+j review -- difftastic rendering of @ against its parent
25172234j review . by @kpq -- the same for another commit, without moving
25182235j diffs -- the same, one record per path
25192236j here -- @'s id
@@ -2544,8 +2261,7 @@ j rebase trunk
25442261j guard clean . rebase trunk or id -- only if it comes out clean
25452262j pick @kpq
25462263j 'forEach (descendantsOf here) (describe "wip")'
2547−j save -- record the working directory, change nothing else
2548−j 'preview squash' -- validate and show the result without saving
2264+j id -- record the working directory, change nothing else
25492265j tree . squash -- dry run: show the result, persist nothing
25502266j tree . validate . squash -- the same, refusing what persistence would refuse
25512267j squash && j tree -- do, then look
@@ -2567,10 +2283,7 @@ j rebase trunk -- bring the stack up to date
25672283A rebase or fetch that conflicts leaves unresolved files in the affected
25682284commits. `j conflicts` lists them; `j by @…` moves there, which checks the
25692285files out with conflict markers; editing the files and running any persisting
2570−expression (`j save` or `j id` at minimum) records the resolution. Run
2571−`j status` to verify `No conflicts` and `All edits recorded` in the focus,
2572−and `j conflicts` to check the rest of the history. No continue command is
2573−needed.
2286+expression (`j id` at minimum) records the resolution.
25742287
25752288**Mistakes**
25762289
@@ -2585,7 +2298,7 @@ j redo
25852298Any definition added to `config.j` whose value is an `Edit` is a command:
25862299
25872300```haskell
2588−pr = \n -> describe n . prev . squash
2301+pr = \n -> describe n . squash
25892302```
25902303
25912304```
@@ -2598,9 +2311,9 @@ j 'pr "fix login"' && j 'push (label "fix-login" here)'
25982311
25992312Second parents of merge commits (merges are placed under their first parent
26002313and are read-only); hunk-level `split`; conflict resolution inside the
2601−language (blobs are opaque); workspaces other than the default; sparse working
2602−copies (refused; §7.1); remotes other than `origin`; tags; submodules and LFS;
2603−ignore rules beyond `.gitignore`; repairing a repository in which one change
2604−id has two visible commits (refused; §7.2); general command flags beyond the
2605−`--help` and `-h` discovery arguments; static typing. None of
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
26062319these are precluded by the design.
specs/tree.md+34−59
@@ -137,16 +137,10 @@ 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 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.
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.
150144
151145#### Step 4 — drawing a row
152146
@@ -155,28 +149,26 @@ Columns, left to right, at fixed offsets:
1551491. **Gutter**, 2 characters: `▶ ` on the focus row, blank otherwise.
1561502. **Rails**, `2 · lanes` characters: lane `i` owns characters `2i` and
157151 `2i+1`.
158−3. **Overflow status**, 4 display columns when any rendered node is flattened;
159− absent otherwise (§Overflow).
160−4. One space.
161−5. **Id**: `@` followed by the shortest prefix unique among the commits of the
152+3. One space.
153+4. **Id**: `@` followed by the shortest prefix unique among the commits of the
162154 display tree, minimum 4 — so the rendered id is itself a valid id literal
163155 (§3.1). Blank on a run row.
164−6. **Size bar** (`detail ≥ 1`, focus and its parent and children only; all
156+5. **Size bar** (`detail ≥ 1`, focus and its parent and children only; all
165157 commits at `detail = 2`): `▁ ▂ ▃ ▅ ▇`, as before. A row without a bar
166158 keeps the column blank; it is left out only when no row has a bar.
167−7. **Message**: first line; nothing if empty; followed by `⋯ n` on a
159+6. **Message**: first line; nothing if empty; followed by `⋯ n` on a
168160 collapsed node.
169−8. **Labels**: left-aligned column, present only if any rendered commit has
161+7. **Labels**: left-aligned column, present only if any rendered commit has
170162 a label; names separated by two spaces.
171−9. **Margin** (`margin = true`): age and author initials, as before, each
163+8. **Margin** (`margin = true`): age and author initials, as before, each
172164 right-aligned in its own column (a `9m` ends under a `13m`), so the margin
173165 ends at one column on every row.
174−10. **Data columns** (off unless enabled): `date` shows the commit's absolute
166+9. **Data columns** (off unless enabled): `date` shows the commit's absolute
175167 date `YYYY-MM-DD`; `files` shows the number of files changed as `n files`;
176168 `author` shows the full author name. They appear in that order, grey,
177169 immediately before the margin in the same right-aligned block.
178170
179−Columns 5–9 start at the same offset on every row. Labels start two columns
171+Columns 4–8 start at the same offset on every row. Labels start two columns
180172past the widest message, and the data columns and the margin start two
181173columns past the widest label (past the widest message when there is no
182174label column). Each data column, the age and the initials are as wide as
@@ -195,6 +187,7 @@ and the margin keep their columns; otherwise nothing is cut.
195187| `i` lies between the two ends of a horizontal segment on this row and holds a rail | `┼` |
196188| `i` lies between the two ends of a horizontal segment on this row | `─` |
197189| `i` holds a rail passing through this row | `│` |
190+| flattened rows only: `i = lanes − 1` | `»` |
198191| otherwise | space |
199192
200193Character `2i+1` is `─` if it lies inside a horizontal segment (from a fork
@@ -206,16 +199,12 @@ rail. The count is never cut: what does not fit the rails area runs on,
206199unbroken, through the space after it into the id column, which is widened if
207200it cannot hold the rest.
208201
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.
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.
219208
220209#### Glyphs
221210
@@ -237,8 +226,7 @@ its parent's in that history — not of the stored commit with the same id, so
237226a dry run (§1.2) draws them as the edit would leave them.
238227An `icons = true` glyph is two columns wide, so it fills both characters of
239228its lane: character `2ℓ+1` of its row is not drawn.
240−Rails use `│ ├ ╰ ─ ┼ ╮ ┬ ╎`; `»` belongs to the separate overflow status
241−column.
229+Rails use `│ ├ ╰ ─ ┼ ╮ ┬ ╎ »`.
242230
243231#### Elision (`elide = true`)
244232
@@ -262,29 +250,18 @@ drawn with `⌂`.
262250
263251#### Colour
264252
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.
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.
288265
289266#### Legend
290267
@@ -297,10 +274,8 @@ The legend covers the node glyphs present (§Glyphs), the focus gutter `▶`,
297274run `╎ n` and collapsed `⋯ n` rows when shown, and the detail-line marks
298275(`+` added, `~` modified, `−` deleted, `✖` unresolved) when a detail line is
299276present. A collapsed node shows `⋯ n` only when `n > 0`, so the `⋯` legend
300−entry appears only then. When any rendered node is flattened, it also includes
301−`» branch exceeds lane limit; increase lanes`. Node glyphs in the overflow
302−status column contribute their normal legend entries. The rails characters
303−are not itemised; they are structural (§Step 4).
277+entry appears only then. The rails characters are not itemised; they are
278+structural (§Step 4).
304279
305280The legend text is faint (dim). Placement: when stdout is a terminal and the
306281tree's widest line plus the legend fits the terminal width, the legend is set
src/builtins.rs−44
@@ -95,10 +95,8 @@ 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),
9998 builtin("subtreeCommits", 1, b_subtree_commits),
10099 builtin("blob", 1, b_blob),
101− builtin("blobHex", 2, b_blob_hex),
102100 builtin("text", 1, b_text),
103101 builtin("by", 2, b_by),
104102 builtin("lookupCommit", 2, b_lookup_commit),
@@ -498,28 +496,6 @@ fn b_touched_paths(_i: &mut Interp, args: &[Value]) -> BResult {
498496 Ok(Value::list(out))
499497}
500498
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−
523499/// every commit of a tree in preorder (§4.9): the value of the recursive
524500/// `\t -> t.root :: (concat (map subtreeCommits t.children) or [])`, which
525501/// the reference config's `commits` was. `::` and `concat` build new lists,
@@ -553,21 +529,6 @@ fn b_blob(_i: &mut Interp, args: &[Value]) -> BResult {
553529 })))
554530}
555531
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−
571532fn b_text(_i: &mut Interp, args: &[Value]) -> BResult {
572533 match &args[0] {
573534 Value::Blob(b) => {
@@ -634,11 +595,6 @@ fn b_meta(i: &mut Interp, args: &[Value]) -> BResult {
634595
635596fn b_validate(i: &mut Interp, args: &[Value]) -> BResult {
636597 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− }
642598 Ok(args[0].clone())
643599}
644600
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", "blobHex", "text", "by", "meta", "validate", "diff", "difft", "treeWith",
43− "extract", "touchedPaths", "unrecorded", "subtreeCommits", "lookupCommit", "lookupContent",
42+ "replay", "unresolved", "blob", "text", "by", "meta", "validate", "diff", "difft", "treeWith",
43+ "extract", "touchedPaths", "subtreeCommits", "lookupCommit", "lookupContent",
4444];
4545
4646pub fn is_reserved_builtin(name: &str) -> bool {
src/diagnostics.rsdeleted−381
@@ -1,381 +0,0 @@
1−//! Read-only guidance for common CLI mistakes. The original error remains the
2−//! authority: these helpers neither retry an expression nor change its meaning.
3−
4−use crate::ast::{Expr, TypeExpr};
5−use crate::config::Config;
6−use crate::parse::parse_expr;
7−use crate::shape::{compile_contract, type_name};
8−use crate::value::Crash;
9−use std::collections::BTreeSet;
10−use std::rc::Rc;
11−use unicode_width::UnicodeWidthChar;
12−
13−/// Add a location and the offending source line without inventing a column
14−/// that the parser does not currently retain.
15−pub fn format_source_error(label: &str, src: &str, line: usize, msg: &str) -> String {
16− let mut output = format!("{}: line {}: {}", label, line, msg);
17− if let Some(source_line) = line.checked_sub(1).and_then(|n| src.split('\n').nth(n)) {
18− output.push_str(&format!("\n {} | {}", line, source_excerpt(source_line.trim_end_matches('\r'))));
19− }
20− output
21−}
22−
23−/// Leave room for an ellipsis, count wide characters, and render terminal
24−/// controls as visible escapes. Charge even zero-width characters one column
25−/// so a malformed line of combining marks cannot produce unlimited output.
26−fn source_excerpt(line: &str) -> String {
27− const MAX_COLUMNS: usize = 160;
28− let mut excerpt = String::new();
29− let mut columns = 0;
30− for character in line.chars() {
31− let visible = if character.is_control() {
32− character.escape_default().to_string()
33− } else {
34− character.to_string()
35− };
36− let width: usize = visible.chars().map(|c| c.width().unwrap_or(0).max(1)).sum();
37− if columns + width > MAX_COLUMNS - 1 {
38− excerpt.push('…');
39− break;
40− }
41− excerpt.push_str(&visible);
42− columns += width;
43− }
44− excerpt
45−}
46−
47−/// Suggest a next step only when the reported failure agrees with the user's
48−/// expression. In particular, an error inside a config definition must not be
49−/// misdiagnosed as a missing argument on the command line.
50−pub fn expression_hint(text: &str, cfg: &Config, crash: &Crash) -> Option<String> {
51− expression_hint_for(text, cfg, crash, Invocation::Expression)
52−}
53−
54−/// Diagnose the expression following `push`, preserving that reserved command
55−/// in copyable examples so following a hint cannot turn a push into a local edit.
56−pub fn push_expression_hint(text: &str, cfg: &Config, crash: &Crash) -> Option<String> {
57− expression_hint_for(text, cfg, crash, Invocation::Push)
58−}
59−
60−#[derive(Clone, Copy)]
61−enum Invocation {
62− Expression,
63− Push,
64−}
65−
66−fn expression_hint_for(text: &str, cfg: &Config, crash: &Crash, invocation: Invocation) -> Option<String> {
67− let expr = parse_expr(text, Rc::new(cfg.global_names.clone())).ok()?;
68− let names = vocabulary(cfg);
69− if let Some(name) = crash.msg.strip_prefix("unbound name `").and_then(|s| s.strip_suffix('`')) {
70− if crash.def.is_some() {
71− return None;
72− }
73− let mut free = BTreeSet::new();
74− expr.free_vars(&mut Vec::new(), &mut free);
75− if !free.contains(name) {
76− return None;
77− }
78− if let Some(hint) = text_argument_hint(&expr, cfg, &names, name, invocation) {
79− return Some(hint);
80− }
81− let mut suggestions: Vec<_> = names
82− .iter()
83− .filter(|candidate| candidate.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_'))
84− .filter_map(|candidate| {
85− let limit = if name.chars().count() > 4 { 2 } else { 1 };
86− let distance = edit_distance(name, candidate, limit)?;
87− (distance > 0).then_some((distance, *candidate))
88− })
89− .collect();
90− suggestions.sort_unstable();
91− if let Some((distance, best)) = suggestions.first().copied() {
92− let close: Vec<_> = suggestions
93− .iter()
94− .take_while(|(d, _)| *d == distance)
95− .take(3)
96− .map(|(_, n)| format!("`{}`", n))
97− .collect();
98− return Some(if close.len() == 1 {
99− format!("Did you mean `{}`? See `{}`.", best, help_command(best))
100− } else {
101− format!("Did you mean {}? Use `j help NAME` for details.", close.join(" or "))
102− });
103− }
104− return Some("Run `j commands` to browse the names available in your config.".into());
105− }
106−
107− let (name, args) = application(&expr)?;
108− let signature = cfg.sigs.get(name)?;
109− let contract = compile_contract(&cfg.shapes, signature);
110− let parameter = contract.params.get(args.len())?;
111− // A record supplied explicitly fails at an earlier argument. Requiring the
112− // next argument to match confines this hint to the implicit repo argument.
113− if !crash.msg.starts_with(&format!("contract: {} expected ", name))
114− || !crash.msg.ends_with(&format!(" as argument {}, got record", args.len() + 1))
115− || matches!(cfg.shapes.unfold(parameter), TypeExpr::Record(_))
116− {
117− return None;
118− }
119− let expected = type_name(parameter);
120− Some(format!(
121− "`{}` needs an argument of type {} before it can receive the repository. See `{}`.",
122− name, expected, help_command(name)
123− ))
124−}
125−
126−/// `global_names` also reserves every implemented builtin for shadowing checks.
127−/// Only names actually supplied by this config are useful typo suggestions.
128−fn vocabulary(cfg: &Config) -> BTreeSet<&str> {
129− cfg.defs
130− .iter()
131− .map(|(name, _)| name.as_str())
132− .chain(cfg.builtin_names.iter().map(String::as_str))
133− .collect()
134−}
135−
136−fn unparen(mut expr: &Expr) -> &Expr {
137− while let Expr::Paren(inner) = expr {
138− expr = inner;
139− }
140− expr
141−}
142−
143−fn application(expr: &Expr) -> Option<(&str, Vec<&Expr>)> {
144− let mut head = unparen(expr);
145− let mut args = Vec::new();
146− while let Expr::App(function, argument) = head {
147− args.push(unparen(argument));
148− head = unparen(function);
149− }
150− args.reverse();
151− match head {
152− Expr::Var(name) => Some((name, args)),
153− _ => None,
154− }
155−}
156−
157−fn text_argument_hint(
158− expr: &Expr,
159− cfg: &Config,
160− names: &BTreeSet<&str>,
161− missing: &str,
162− invocation: Invocation,
163−) -> Option<String> {
164− let (name, args) = application(expr)?;
165− let contract = compile_contract(&cfg.shapes, cfg.sigs.get(name)?);
166− let index = args.iter().position(|arg| matches!(arg, Expr::Var(n) if n == missing))?;
167− let parameter = contract.params.get(index)?;
168− if !matches!(cfg.shapes.unfold(parameter), TypeExpr::Con(n) if n == "Text") {
169− return None;
170− }
171−
172− // For a single Text argument, shell-stripped words can be shown in a
173− // complete, copyable example. Do not guess how to group multiple Text
174− // parameters or how to rewrite a larger expression.
175− let only_text_argument = index == 0
176− && contract.params.iter().skip(1).all(|ty| matches!(ty.as_ref(), TypeExpr::Con(n) if n == "Repo"));
177− let words: Option<Vec<_>> = args
178− .iter()
179− .map(|arg| match arg {
180− Expr::Var(n) if !names.contains(n.as_str()) => Some(n.as_str()),
181− _ => None,
182− })
183− .collect();
184− let prefix = match invocation {
185− Invocation::Expression => "",
186− Invocation::Push => "push ",
187− };
188− match words.filter(|_| only_text_argument) {
189− Some(words) => Some(format!(
190− "If you meant text, preserve its double quotes by quoting the whole expression: j {}. See `{}`.",
191− shell_quote(&format!("{}{} \"{}\"", prefix, name, words.join(" "))), help_command(name)
192− )),
193− None => Some(format!(
194− "If you meant text, use double quotes for the text inside shell single quotes around the expression. See `{}`.",
195− help_command(name)
196− )),
197− }
198−}
199−
200−fn shell_quote(text: &str) -> String {
201− format!("'{}'", text.replace('\'', "'\\''"))
202−}
203−
204−fn help_command(name: &str) -> String {
205− let argument = if name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
206− name.to_string()
207− } else {
208− shell_quote(name)
209− };
210− format!("j help {}", argument)
211−}
212−
213−/// Bounded Damerau-Levenshtein distance catches adjacent transpositions as well
214−/// as inserted, removed, and replaced letters. Long identifiers are skipped so
215−/// error reporting cannot become expensive for generated input.
216−fn edit_distance(a: &str, b: &str, limit: usize) -> Option<usize> {
217− let a: Vec<_> = a.chars().take(129).collect();
218− let b: Vec<_> = b.chars().take(129).collect();
219− if a.len() > 128 || b.len() > 128 || a.len().abs_diff(b.len()) > limit {
220− return None;
221− }
222− let mut older = vec![0; b.len() + 1];
223− let mut previous: Vec<_> = (0..=b.len()).collect();
224− let mut current = vec![0; b.len() + 1];
225− for (i, letter) in a.iter().enumerate() {
226− current[0] = i + 1;
227− for (k, other) in b.iter().enumerate() {
228− current[k + 1] = (previous[k + 1] + 1)
229− .min(current[k] + 1)
230− .min(previous[k] + usize::from(letter != other));
231− if i > 0 && k > 0 && *letter == b[k - 1] && a[i - 1] == *other {
232− current[k + 1] = current[k + 1].min(older[k - 1] + 1);
233− }
234− }
235− std::mem::swap(&mut older, &mut previous);
236− std::mem::swap(&mut previous, &mut current);
237− }
238− (previous[b.len()] <= limit).then_some(previous[b.len()])
239−}
240−
241−#[cfg(test)]
242−mod tests {
243− use super::*;
244− use crate::config::{eval_config, load_config};
245− use crate::eval::Interp;
246− use crate::value::Value;
247−
248− fn reference() -> Config {
249− load_config(include_str!("../config.j")).unwrap()
250− }
251−
252− fn failure(cfg: &Config, text: &str) -> Crash {
253− let mut interp = Interp::dummy();
254− interp.shapes = cfg.shapes.clone();
255− eval_config(&mut interp, cfg).unwrap();
256− let expr = Rc::new(parse_expr(text, Rc::new(cfg.global_names.clone())).unwrap());
257− let result = interp.eval(&expr, &interp.global_env()).and_then(|value| match value {
258− Value::Fun(_) => interp.apply(value, Value::record(&[])),
259− value => Ok(value),
260− });
261− result.expect_err("test expression must fail")
262− }
263−
264− #[test]
265− fn suggests_real_commands_and_transposed_names() {
266− let cfg = reference();
267− for (expression, expected) in [("neww", "j help new"), ("desrcibe", "j help describe")] {
268− let hint = expression_hint(expression, &cfg, &failure(&cfg, expression)).unwrap();
269− assert!(hint.contains(expected), "{}", hint);
270− }
271− }
272−
273− #[test]
274− fn suggests_custom_definitions_but_not_undeclared_builtins() {
275− let cfg = load_config("user = { name = \"A\", email = \"a@b\" }\ntree = \\r -> \"\"\nimmutable = \\r -> []\nlabelled = \\n r -> []\nship = \\r -> r\n").unwrap();
276− let hint = expression_hint("shipp", &cfg, &failure(&cfg, "shipp")).unwrap();
277− assert!(hint.contains("j help ship"));
278− let hint = expression_hint("repla", &cfg, &failure(&cfg, "repla")).unwrap();
279− assert!(!hint.contains("replay"));
280− assert!(hint.contains("j commands"));
281− }
282−
283− #[test]
284− fn explains_shell_quoting_only_for_text_parameters() {
285− let cfg = reference();
286− for expression in ["describe hello", "describe first change"] {
287− let hint = expression_hint(expression, &cfg, &failure(&cfg, expression)).unwrap();
288− assert!(hint.contains("j 'describe \""), "{}", hint);
289− assert!(hint.contains("j help describe"));
290− }
291− let hint = expression_hint("rebase helloo", &cfg, &failure(&cfg, "rebase helloo")).unwrap();
292− assert!(!hint.contains("quotes"));
293− }
294−
295− #[test]
296− fn push_quoting_examples_preserve_the_reserved_command() {
297− let cfg = reference();
298− for (expression, corrected) in [
299− ("unlabel feature", "unlabel \"feature\""),
300− ("describe hello", "describe \"hello\""),
301− ] {
302− let crash = failure(&cfg, expression);
303− let hint = push_expression_hint(expression, &cfg, &crash).unwrap();
304− assert!(hint.contains(&format!("j 'push {}'", corrected)), "{}", hint);
305− assert!(!hint.contains(&format!("j '{}'", corrected)), "{}", hint);
306−
307− let ordinary = expression_hint(expression, &cfg, &crash).unwrap();
308− assert!(ordinary.contains(&format!("j '{}'", corrected)), "{}", ordinary);
309− assert!(!ordinary.contains("j 'push "), "{}", ordinary);
310− }
311− }
312−
313− #[test]
314− fn explains_implicit_repo_application_but_not_explicit_type_errors() {
315− let cfg = reference();
316− for expression in ["describe", "rebase", "split"] {
317− let hint = expression_hint(expression, &cfg, &failure(&cfg, expression)).unwrap();
318− assert!(hint.contains("before it can receive the repository"), "{}", hint);
319− assert!(hint.contains(&format!("j help {}", expression)));
320− }
321− assert!(expression_hint("describe {}", &cfg, &failure(&cfg, "describe {}")).is_none());
322− assert!(expression_hint("describe 42", &cfg, &failure(&cfg, "describe 42")).is_none());
323− }
324−
325− #[test]
326− fn custom_signatures_drive_hints_after_partial_application() {
327− let src = format!("{}\ncustom : Text -> Int -> Edit\ncustom = \\message count repo -> repo\n", include_str!("../config.j"));
328− let cfg = load_config(&src).unwrap();
329− let expression = "custom \"message\"";
330− let hint = expression_hint(expression, &cfg, &failure(&cfg, expression)).unwrap();
331− assert!(hint.contains("needs an argument of type Int"), "{}", hint);
332− assert!(hint.contains("j help custom"));
333− let expression = "custom \"message\" {}";
334− assert!(expression_hint(expression, &cfg, &failure(&cfg, expression)).is_none());
335− }
336−
337− #[test]
338− fn quote_examples_preserve_apostrophes_in_valid_identifiers() {
339− let cfg = reference();
340− let expression = "describe don't";
341− let hint = expression_hint(expression, &cfg, &failure(&cfg, expression)).unwrap();
342− assert!(hint.contains("j 'describe \"don'\\''t\"'"), "{}", hint);
343− }
344−
345− #[test]
346− fn does_not_blame_the_cli_for_unbound_names_inside_definitions() {
347− let cfg = reference();
348− assert!(expression_hint("new", &cfg, &Crash::new("unbound name `neww`")).is_none());
349− assert!(expression_hint("\\x -> x", &cfg, &Crash::new("unbound name `x`")).is_none());
350− }
351−
352− #[test]
353− fn source_errors_name_the_file_and_preserve_line_numbers() {
354− let output = format_source_error("/tmp/config.j", "first\nbroken =\n", 2, "unexpected newline");
355− assert_eq!(output, "/tmp/config.j: line 2: unexpected newline\n 2 | broken =");
356− assert_eq!(format_source_error("expression", "bad\n", 2, "unexpected end"), "expression: line 2: unexpected end\n 2 | ");
357− assert_eq!(format_source_error("expression", "bad", 8, "unexpected end"), "expression: line 8: unexpected end");
358− }
359−
360− #[test]
361− fn huge_unicode_source_lines_have_bounded_excerpts() {
362− use unicode_width::UnicodeWidthStr;
363−
364− for source in ["(".repeat(200_000), "界é🙂\u{301}".repeat(50_000), "\u{301}".repeat(200_000)] {
365− let output = format_source_error("expression", &source, 1, "nested too deeply");
366− let excerpt = output.split_once(" 1 | ").unwrap().1;
367− assert!(excerpt.ends_with('…'));
368− assert!(excerpt.width() <= 160, "excerpt exceeds display width budget");
369− assert!(excerpt.chars().count() <= 160, "zero-width characters must also be bounded");
370− assert!(output.len() < 700, "diagnostic must not repeat the entire huge line");
371− }
372− }
373−
374− #[test]
375− fn source_excerpts_escape_terminal_controls() {
376− let output = format_source_error("expression", "\tfirst\u{1b}[31m\rsecond\u{7}", 1, "invalid input");
377− let excerpt = output.split_once(" 1 | ").unwrap().1;
378− assert_eq!(excerpt, "\\tfirst\\u{1b}[31m\\rsecond\\u{7}");
379− assert!(!excerpt.chars().any(char::is_control));
380− }
381−}
src/domain.rs−6
@@ -29,12 +29,6 @@ pub struct ImmutabilityInfo {
2929}
3030
3131pub trait Backend {
32− /// Refuse an edit that cannot be authored with the backend's current
33− /// configuration. In-memory repositories have no authoring requirement.
34− fn validate_authoring(&self) -> Result<(), Crash> {
35− Ok(())
36− }
37−
3832 /// All visible change ids (full), for resolving `@prefix` literals.
3933 fn visible_ids(&self) -> Vec<String>;
4034 /// All visible ids beginning with `prefix`.
src/eval.rs+45−78
@@ -47,19 +47,6 @@ 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−
6350/// length of the common leading run of two ids (ids are ASCII, so bytes)
6451fn common_prefix_len(a: &str, b: &str) -> usize {
6552 a.bytes()
@@ -117,14 +104,11 @@ pub(crate) enum Cont {
117104 env: Env,
118105 cont: Rc<Cont>,
119106 },
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− },
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>),
128112 /// `or` after both evaluated as functions: build the lifted function
129113 OrBoth(Value, Rc<Cont>),
130114 /// a shared continuation (used when both `or` outcomes continue the same way)
@@ -274,7 +258,9 @@ fn parent_rc(k: &Cont) -> Option<&Rc<Cont>> {
274258 Cont::Fun(_, k) => Some(k),
275259 Cont::If(_, _, _, k) => Some(k),
276260 Cont::LetRest { cont, .. } => Some(cont),
277− Cont::ShortCircuit { cont, .. } => Some(cont),
261+ Cont::And(_, _, k) => Some(k),
262+ Cont::Or2(_, _, k) => Some(k),
263+ Cont::ExpectBool(_, k) => Some(k),
278264 Cont::OrBoth(_, k) => Some(k),
279265 Cont::Shared(k) => Some(k),
280266 Cont::ApplyTo(_, k) => Some(k),
@@ -623,14 +609,10 @@ impl Interp {
623609 }
624610
625611 fn run(&mut self, st: State) -> EResult {
626− let result = match self.trampoline(st) {
612+ match self.trampoline(st) {
627613 Ok(v) => Ok(v),
628614 Err(c) => Err(self.decorate(c)),
629− };
630− // Keep a returned closure alive as a root while reclaiming local
631− // recursive environments abandoned by the completed evaluation.
632− crate::value::collect_cycles();
633− result
615+ }
634616 }
635617
636618 fn trampoline(&mut self, st: State) -> EResult {
@@ -794,26 +776,29 @@ impl Interp {
794776 Cont::Arg(x.clone(), env, Rc::new(k)),
795777 )),
796778 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+ }
797793 let fv = match env.lookup(op) {
798794 Some(v) => v,
799795 None => return Run::Crash(Crash::new(format!("unbound name `{}`", op))),
800796 };
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 ||.
797+ // eval a, apply the operator to it, then apply the result to b
813798 Run::Step(State::Eval(
814799 a.clone(),
815− env,
816− Cont::Fun(fv, Rc::new(next)),
800+ env.clone(),
801+ Cont::Fun(fv, Rc::new(Cont::Arg(b.clone(), env, Rc::new(k)))),
817802 ))
818803 }
819804 Expr::Or(a, b) => {
@@ -928,42 +913,24 @@ impl Interp {
928913 ))
929914 }
930915 }
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− }
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+ },
967934 Cont::OrBoth(a, k) => {
968935 if matches!(v, Value::Fun(_)) {
969936 Run::Step(State::Ret(Value::Fun(Rc::new(FunVal::OrFun(a, v))), (*k).clone()))
src/help.rs+41−189
@@ -13,23 +13,19 @@ 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] Find everyday commands by task
17− j commands all Browse the complete vocabulary
18− j help config Set your identity or repair configuration
16+ j commands [QUERY] Search names, signatures, and documentation
1917
2018Start a repository:
21− j setup 'Ada Lovelace' ada@example.org Create config with your identity
2219 j init Create a repository in the current directory
2320 j clone URL [DIR] Clone a repository
2421
2522Inspect (default config):
2623 j tree Show the history and current focus
2724 j status Show the focused commit and changed paths
28− j review Show changes (unified diff if difft is absent)
25+ j review Show changes with difftastic (difft on PATH)
2926 j log List commits
3027
3128Edit (default config):
32− j save Record current file edits
3329 j 'describe "add a feature"' Set the focused commit's message
3430 j new Start an empty child and move to it
3531 j prev Move to the parent commit
@@ -38,7 +34,7 @@ Edit (default config):
3834 j 'rebase trunk' Move the focused subtree onto the main line
3935
4036Preview and recover:
41− j 'preview squash' Check and preview a squash without saving
37+ j 'tree . squash' Preview a squash without saving the edit
4238 j undo Undo the last operation
4339 j redo Reapply an undone operation
4440 j ops Show the operation log
@@ -49,9 +45,6 @@ Sync:
4945 j 'push (label "feature" here)' Publish the focus as a remote bookmark
5046 j push relabel Update existing remote bookmarks
5147
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−
5548Expressions compose right to left: `describe "wip" . new` runs new first.
5649A function result receives the current repository. A Repo result saves the
5750edit; other results print. Quote the whole expression with shell single
@@ -62,34 +55,6 @@ revsets, filesets, types, and custom names. Search with `j commands rebase`
6255or `j commands Edit`; use `j help squash` or `j help commands` for details.
6356"#;
6457
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−
9358struct Reserved {
9459 name: &'static str,
9560 usage: &'static str,
@@ -102,13 +67,13 @@ const RESERVED: &[Reserved] = &[
10267 name: "clone",
10368 usage: "j clone URL [DIR]",
10469 summary: "Clone a repository.",
105− detail: "Clone URL into DIR, or into a directory named after the repository.\nFor first-time setup, run `j setup NAME EMAIL`. Shell-quote paths containing spaces.\nCloning does not change your shell's directory; enter the new folder afterward.\n\nExamples:\n j clone https://example.com/team/project.git project\n cd project\n j status\n\n j clone '/path/to/source repo' 'my project'\n cd 'my project'\n",
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",
10671 },
10772 Reserved {
10873 name: "commands",
10974 usage: "j commands [QUERY]",
110− summary: "Find everyday commands by task, or browse the full vocabulary.",
111− detail: "Without a query, show common tasks and a few custom definitions.\n`j commands all` lists every configured definition, builtin, operator, and type.\nSearch by task (save, record, commit, branch, checkout), name, signature, or\nsource comments. Search ignores case and puts direct matches and everyday\ncommands first. Use `j help NAME` for examples and types.\n\nThe config is parsed and validated without evaluating definitions or opening\na repository. No config file is created; when absent, defaults are shown.\n\nExamples:\n j commands\n j commands save\n j commands rebase\n j commands Edit\n j commands all\n",
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",
11277 },
11378 Reserved {
11479 name: "fetch",
@@ -126,7 +91,7 @@ const RESERVED: &[Reserved] = &[
12691 name: "init",
12792 usage: "j init",
12893 summary: "Create a repository in the current directory.",
129− detail: "Create a colocated .jj/.git repository and a working-copy commit. An\nexisting .git repository is adopted. On a new installation, first run\n`j setup NAME EMAIL`; use `j help config` to update an existing identity.\nThe shipped placeholder identity cannot author commits.\n\nExample:\n j setup 'Ada Lovelace' ada@example.org\n mkdir project\n cd project\n j init\n",
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",
13095 },
13196 Reserved {
13297 name: "ops",
@@ -138,13 +103,13 @@ const RESERVED: &[Reserved] = &[
138103 name: "push",
139104 usage: "j push EXPRESSION",
140105 summary: "Publish or delete bookmarks on origin.",
141− detail: "Evaluate EXPRESSION to a list of Push and Drop records, then update origin.\nA function result receives the recorded repository; working-copy changes\nare not snapshotted by push. Unrecorded edits produce a warning before sending.\nRun `j id` to record them, then push again to include them; `j status` does not\nrecord edits. The default config provides label, unlabel, rename, and relabel.\n\nExamples (default config):\n j 'push (label \"feature\" here)'\n j push relabel\n j 'push (unlabel \"feature\")'\n j 'push (rename \"old\" \"new\")'\n\nThe unlabel example deletes the remote bookmark.\n",
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",
142107 },
143108 Reserved {
144109 name: "redo",
145110 usage: "j redo",
146111 summary: "Reapply an undone operation.",
147− detail: "Reapply the operation removed by undo. Refuses if there is nothing to redo\nor if restoring the working copy would overwrite unsnapshotted changes.\nRecording new edits with `j id` creates an operation and clears pending redos.\nInspect `j ops` before choosing how to preserve or discard those edits.\n\nExample:\n j redo\n\nRelated: j help undo, j help ops\n",
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",
148113 },
149114 Reserved {
150115 name: "remote",
@@ -152,17 +117,11 @@ const RESERVED: &[Reserved] = &[
152117 summary: "Set the origin remote URL.",
153118 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",
154119 },
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− },
161120 Reserved {
162121 name: "undo",
163122 usage: "j undo",
164123 summary: "Undo the last operation.",
165− detail: "Restore the repository state before the last operation. Repeat to walk\nback through history. Refuses if restoring the working copy would overwrite\nunsnapshotted changes. `j id` preserves edits as a new operation: the next\nundo reverses that recording before reaching the earlier operation. Use\n`j ops` to inspect history; `j redo` can restore the undone recording.\nUndo does not reverse changes already sent to a remote.\n\nExample:\n j undo\n\nRelated: j help redo, j help ops\n",
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",
166125 },
167126];
168127
@@ -205,154 +164,43 @@ impl Entry {
205164 }
206165}
207166
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−
301167pub fn command_list(src: &str, query: &str) -> Result<String, String> {
302168 let entries = entries(src)?;
303169 let query = query.trim().to_lowercase();
304− if query.is_empty() {
305− return Ok(everyday_list(&entries));
306− }
307− let all = query == "all";
308− let intents = intent_names(&query);
309− let matches = |text: &str| all || text.to_lowercase().contains(&query);
310− let mut results = Vec::new();
170+ let matches = |text: &str| text.to_lowercase().contains(&query);
171+ let mut out = String::new();
172+ let mut count = 0;
311173 for cmd in RESERVED {
312− let intent = intents.iter().position(|name| *name == cmd.name);
313− if matches(&format!("{} {} {}", cmd.usage, cmd.summary, cmd.detail)) || intent.is_some() {
314− let rank = if all || query == cmd.name { 0 } else { intent.map(|i| i + 1).unwrap_or(40) };
315− results.push((rank, cmd.name.to_string(), format!(" {:<24} {}\n", cmd.usage, cmd.summary)));
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;
316180 }
317181 }
318− for entry in &entries {
182+ let mut config_count = 0;
183+ for entry in entries {
319184 let declaration = entry.declaration();
320− let intent = intents.iter().position(|name| *name == entry.name);
321− if !matches(&format!("{} {}", declaration, entry.docs.join(" "))) && intent.is_none() {
185+ if !matches(&format!("{} {}", declaration, entry.docs.join(" "))) {
322186 continue;
323187 }
324− let everyday = TASKS.iter().any(|task| task.name == entry.name);
325− let exact = query == entry.name.to_lowercase() && (!matches!(entry.kind, Kind::Type) || intents.is_empty());
326− let rank = if all { 1 } else if exact { 0 }
327− else { intent.map(|i| i + 1).unwrap_or(if everyday { 20 } else { 30 }) };
328− let mut description = format!(" {}\n", declaration);
329− if let Some(doc) = entry.docs.first() {
330− description.push_str(&format!(" {}\n", truncate(doc, 110)));
331− }
332− results.push((rank, entry.name.clone(), description));
333− }
334− results.sort_by(|a, b| (&a.0, a.1.to_lowercase(), &a.1).cmp(&(&b.0, b.1.to_lowercase(), &b.1)));
335− let mut out = String::new();
336− if matches!(query.as_str(), "stash" | "git stash") && !entries.iter().any(|entry| entry.name == "stash") {
337− out.push_str("There is no direct stash command in this config.\n");
338− let save = if entries.iter().any(|entry| entry.name == "save") { Some("save") }
339− else if entries.iter().any(|entry| entry.name == "id") { Some("id") } else { None };
340− if let Some(save) = save {
341− out.push_str(&format!("`j {}` records file edits in the current commit.\n", save));
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");
342193 }
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");
194+ out.push_str(&format!(" {}\n", declaration));
195+ if let Some(doc) = entry.docs.first() {
196+ out.push_str(&format!(" {}\n", truncate(doc, 110)));
345197 }
346− out.push_str("\n");
198+ config_count += 1;
347199 }
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");
200+ if count + config_count == 0 {
201+ out.push_str("No matching commands or config names. Try `j commands` to list all names.\n");
350202 } else {
351− out.push_str(if all { "All commands and config vocabulary:\n" } else { "Matching commands and config names:\n" });
352− for (_, _, description) in results {
353− out.push_str(&description);
354− }
355− out.push_str("\nUse `j help NAME` for examples and details; `j commands QUERY` to search.\n");
203+ out.push_str("\nUse `j help NAME` for details; `j commands QUERY` to narrow the list.\n");
356204 }
357205 Ok(out)
358206}
@@ -371,8 +219,14 @@ pub fn command_help(src: &str, name: &str) -> Result<Option<String>, String> {
371219 Kind::Builtin => "Builtin declared in config",
372220 Kind::Type => "Type declared in config",
373221 };
374− let mut out = String::new();
222+ let mut out = format!(
223+ "{}\n\n{}\nSource: line {}\n",
224+ entry.declaration(),
225+ kind,
226+ entry.line
227+ );
375228 if !entry.docs.is_empty() {
229+ out.push('\n');
376230 for doc in entry.docs.iter().take(24) {
377231 out.push_str(doc);
378232 out.push('\n');
@@ -380,9 +234,7 @@ pub fn command_help(src: &str, name: &str) -> Result<Option<String>, String> {
380234 if entry.docs.len() > 24 {
381235 out.push_str("… See the source for the remaining comments.\n");
382236 }
383− out.push('\n');
384237 }
385− out.push_str(&format!("{}\n\n{}\nSource: line {}\n", entry.declaration(), kind, entry.line));
386238 out
387239 }))
388240}
src/jj.rs+72−288
@@ -45,15 +45,6 @@ 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−
5748// ----------------------------------------------------------------------
5849// locating and opening the repository (§7.1)
5950// ----------------------------------------------------------------------
@@ -78,7 +69,6 @@ fn toml_string(s: &str) -> String {
7869 '\n' => out.push_str("\\n"),
7970 '\r' => out.push_str("\\r"),
8071 '\t' => out.push_str("\\t"),
81− c if c.is_ascii_control() => out.push_str(&format!("\\u{:04X}", c as u32)),
8272 c => out.push(c),
8373 }
8474 }
@@ -1455,17 +1445,6 @@ impl JjBackend {
14551445 ),
14561446 ));
14571447 }
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− }
14691448 let workspace_name = workspace.workspace_name().to_owned();
14701449 let repo = block_on(workspace.repo_loader().load_at_head())
14711450 .map_err(|e| (2, format!("cannot load the repository at head: {}", e)))?;
@@ -1500,13 +1479,13 @@ impl JjBackend {
15001479 {
15011480 Ok(f) => f,
15021481 Err(e) => {
1503− crate::terminal::report_error(format_args!("j: cannot create {}: {}", lock_path.display(), e));
1482+ crate::report!("j: cannot create {}: {}", lock_path.display(), e);
15041483 std::process::exit(2);
15051484 }
15061485 };
15071486 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
15081487 if rc != 0 {
1509− crate::terminal::report_error(format_args!("j: another j is running in this repository"));
1488+ crate::report!("j: another j is running in this repository");
15101489 std::process::exit(2);
15111490 }
15121491 *self.inner.lock_guard.lock().unwrap() = Some(FileLock(file));
@@ -1769,10 +1748,10 @@ impl JjBackend {
17691748 current: &Value,
17701749 new: &Value,
17711750 text: &str,
1772− ) -> Result<bool, Crash> {
1751+ ) -> Result<(), Crash> {
17731752 // §1.2 step 8: unchanged, up to the order of snapshot entries (§7.3)
17741753 if crate::repo::same_repo(loaded, new)? {
1775− return Ok(false);
1754+ return Ok(());
17761755 }
17771756 *interp.old_repo.borrow_mut() = Some(loaded.clone());
17781757 crate::repo::validate_repo(interp, new)?;
@@ -1997,8 +1976,7 @@ impl JjBackend {
19971976 state.or_else(|_| block_on(self.wc_commit(&base)).map(|c| c.tree()).map_err(|e| Crash::new(e.1)))?
19981977 }
19991978 };
2000− block_on(self.checkout(&focus_commit, Some(&on_disk), Some(unpublished), Some(", and `j undo` goes back")))?;
2001− Ok(true)
1979+ block_on(self.checkout(&focus_commit, Some(&on_disk), Some(unpublished), Some(", and `j undo` goes back")))
20021980 }
20031981
20041982 /// Check `commit` out: write its files to the working directory and
@@ -2198,8 +2176,7 @@ impl JjBackend {
21982176 }
21992177
22002178 fn user_signature(&self, cfg: &Config) -> Result<Signature, Crash> {
2201− let (name, email) = validated_identity(cfg)
2202− .map_err(|(_, message)| Crash::new(message))?;
2179+ let (name, email) = crate::config::eval_user_only(&mut Interp::dummy(), cfg)?;
22032180 Ok(Signature {
22042181 name,
22052182 email,
@@ -2333,38 +2310,15 @@ impl JjBackend {
23332310 let _ = block_on(self.publish_tx(tx, "fetch"))?;
23342311 Ok(())
23352312 }
2336− pub fn cmd_push(&self, cfg: &mut Config, expr_text: &str) -> Result<PushOutcome, OpenError> {
2313+ pub fn cmd_push(&self, cfg: &mut Config, expr_text: &str) -> Result<(), OpenError> {
23372314 self.enter_workspace_root()?;
23382315 let origin = RemoteName::new("origin");
23392316 let base = self.head_repo()?;
23402317 let (value, vis, immutable) = self.eval_push_expr(cfg, expr_text, &base)?;
23412318 let records = parse_push_records(&value, &vis, &base)?;
2342− if records.is_empty() {
2343− return Ok(PushOutcome::default());
2344− }
23452319 let deletes_wait = check_push_records(&records, &base, &vis, &immutable)?;
2346− let mut outcome = PushOutcome::default();
2347− for (name, target) in &records {
2348− let before = base.view()
2349− .get_remote_bookmark(RefName::new(name).to_remote_symbol(origin))
2350− .target.as_resolved().cloned().unwrap_or(None);
2351− if &before == target {
2352− outcome.unchanged += 1;
2353− } else if target.is_none() {
2354− outcome.deleted += 1;
2355− } else {
2356− outcome.published += 1;
2357− }
2358− }
2359− let (_, clean) = self.inspect_working_copy(&base, false)?;
2360− if !clean {
2361− crate::terminal::report(format_args!(
2362− "j: warning: working copy has unrecorded changes; push sends recorded content only, so these edits will not be sent. Run `j id` to record them, then push again to include them."
2363− ));
2364− }
2365− let description = format!("push {}", expr_text);
2366− let _ = self.push_to_origin(&base, origin, &records, deletes_wait, &description)?;
2367− Ok(outcome)
2320+ let _ = self.push_to_origin(&base, origin, &records, deletes_wait, expr_text)?;
2321+ Ok(())
23682322 }
23692323
23702324 /// evaluate `EXPR` against the recorded repository, without snapshotting
@@ -2378,7 +2332,7 @@ impl JjBackend {
23782332 ) -> Result<(Value, Arc<VisibleRepo>, BTreeSet<String>), OpenError> {
23792333 let outer = Rc::new(cfg.global_names.clone());
23802334 let expr = crate::parse::parse_expr(expr_text, outer)
2381− .map_err(|p| (3, crate::diagnostics::format_source_error("expression", expr_text, p.line, &p.msg)))?;
2335+ .map_err(|p| (3, format!("line {}: {}", p.line, p.msg)))?;
23822336 let (mut interp, _loaded, current) = self.build_interp(cfg, expr_text, false)?;
23832337 let expr = crate::config::resolve_ids(&expr, &interp).map_err(|failures| {
23842338 let (prefix, candidates) = &failures[0];
@@ -2393,26 +2347,15 @@ impl JjBackend {
23932347 // evaluated it for this value to place the focus, so it is cached
23942348 let immutable = crate::repo::compute_immutable(&mut interp, &current)
23952349 .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− };
24072350 let env = interp.global_env();
24082351 let expr_rc = Rc::new(expr);
24092352 let mut v = interp
24102353 .eval(&expr_rc, &env)
2411− .map_err(&expression_error)?;
2354+ .map_err(|c| (1, format!("crash: {}", c.msg)))?;
24122355 if matches!(v, Value::Fun(_)) {
24132356 v = interp
24142357 .apply(v, current)
2415− .map_err(expression_error)?;
2358+ .map_err(|c| (1, format!("crash: {}", c.msg)))?;
24162359 }
24172360 let _ = base;
24182361 let vis = self
@@ -2437,11 +2380,6 @@ impl JjBackend {
24372380 deletes_wait: bool,
24382381 description: &str,
24392382 ) -> 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− }
24452383 let mut targets = jj_lib::git::GitPushRefTargets::default();
24462384 let mut deletes = jj_lib::git::GitPushRefTargets::default();
24472385 for (name, after) in records {
@@ -2460,6 +2398,16 @@ impl JjBackend {
24602398 .push((jj_lib::ref_name::RefNameBuf::from(name.clone()), Diff::new(before, after.clone())));
24612399 }
24622400 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+ }
24632411 let subprocess_options = self.git_subprocess_options()?;
24642412 let mut callback = QuietGitCallback;
24652413 let mut push = |tx: &mut jj_lib::transaction::Transaction, targets| {
@@ -2537,31 +2485,14 @@ impl JjBackend {
25372485 Ok(repo)
25382486 }
25392487
2540− pub fn cmd_undo(&self, redo: bool) -> Result<String, OpenError> {
2488+ pub fn cmd_undo(&self, redo: bool) -> Result<(), OpenError> {
25412489 let base = self.head_repo()?;
25422490 let on_disk = self.check_wc_clean(&base)?;
25432491 let loader = self.repo_loader();
25442492 let mut cur = base.operation().clone();
25452493 let target_op_id: OperationId;
25462494 loop {
2547− let marker = op_marker(cur.metadata());
2548− if let Some(m) = marker.as_ref().filter(|m| m.restores_view) {
2549− // jj 0.45 names the operation whose view was restored.
2550− // Repeating the same direction follows that target; reversing
2551− // direction restores the marker's parent. The target may
2552− // itself be an undo or redo, not just a normal operation.
2553− if (m.kind == "redo") == redo {
2554− cur = block_on(loader.load_operation(&m.id))
2555− .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
2556− continue;
2557− }
2558− let Some(parent) = cur.parent_ids().first().cloned() else {
2559− return Err((1, if redo { "nothing to redo" } else { "nothing to undo" }.to_string()));
2560− };
2561− target_op_id = parent;
2562− break;
2563− }
2564− match (marker, redo) {
2495+ match (op_marker(cur.metadata()), redo) {
25652496 (None, false) => {
25662497 let Some(parent) = cur.parent_ids().first().cloned() else {
25672498 return Err((1, "nothing to undo".to_string()));
@@ -2587,16 +2518,7 @@ impl JjBackend {
25872518 .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
25882519 }
25892520 (Some(m), true) if m.kind == "undo" => {
2590− // An old j marker names the discarded normal operation,
2591− // but its parent has the same view and retains any earlier
2592− // undos. Modern redo markers must keep that navigation
2593− // point so another redo can continue across the upgrade.
2594− target_op_id = if m.authored_by_j {
2595− cur.parent_ids().first().cloned()
2596− .ok_or_else(|| (1, "nothing to redo".to_string()))?
2597− } else {
2598− m.id
2599− };
2521+ target_op_id = m.id;
26002522 break;
26012523 }
26022524 (Some(m), false) => {
@@ -2648,22 +2570,8 @@ impl JjBackend {
26482570 if !redo && op_created_repo(cur.metadata()) {
26492571 return Err((1, "nothing to undo".to_string()));
26502572 }
2651− let mut target_op = block_on(loader.load_operation(&target_op_id))
2573+ let target_op = block_on(loader.load_operation(&target_op_id))
26522574 .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);
26672575 let target_view = block_on(
26682576 loader
26692577 .op_store()
@@ -2687,13 +2595,11 @@ impl JjBackend {
26872595 },
26882596 ));
26892597 }
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− );
2598+ let marker = if redo {
2599+ format!("redo: {}", cur.id().hex())
2600+ } else {
2601+ format!("undo: {}", cur.id().hex())
2602+ };
26972603 let mut tx = base.start_transaction();
26982604 tx.repo_mut().set_view(target_view.clone());
26992605 let unpublished = block_on(tx.write(&marker))
@@ -2706,14 +2612,7 @@ impl JjBackend {
27062612 let back = if redo { ", and `j undo` goes back" } else { ", and `j redo` goes back" };
27072613 block_on(self.checkout(&wc_commit, Some(&on_disk), Some(unpublished), Some(back)))
27082614 .map_err(|c| (1, c.msg))?;
2709− let focus_message = wc_commit.description().lines().next().unwrap_or("").trim();
2710− Ok(format!(
2711− "{} `{}`; restored focus @{} {}",
2712− if redo { "Redid" } else { "Undid" },
2713− affected_description,
2714− wc_commit.change_id().reverse_hex(),
2715− terminal_text(if focus_message.is_empty() { "(no description)" } else { focus_message }),
2716− ))
2615+ Ok(())
27172616 }
27182617
27192618 /// Refuse, as `undo` and `redo` do before anything else (§7.7), when the
@@ -2727,21 +2626,7 @@ impl JjBackend {
27272626 /// what a checkout cut short wrote. Reserved commands never snapshot,
27282627 /// so nothing is recorded either way.
27292628 fn check_wc_clean(&self, base: &Arc<ReadonlyRepo>) -> Result<MergedTree, OpenError> {
2730− let (scanned, clean) = self.inspect_working_copy(base, true)?;
2731− if !clean {
2732− return Err((1, "working copy has changes not in @; run `j id` to record them or discard them. Recording creates a new operation: the next `j undo` undoes that recording, not the operation you originally wanted to undo; a new recording also clears pending redos. Use `j ops` to inspect the history".to_string()));
2733− }
2734− Ok(scanned)
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> {
2629+ let dirty = (1, "working copy has changes not in @; run `j id` to record them or discard them".to_string());
27452630 let wc_tree = block_on(self.wc_commit(base))?.tree();
27462631 let mut ws_guard = self.inner.workspace.lock().unwrap();
27472632 let root = ws_guard.workspace_root().to_owned();
@@ -2761,15 +2646,13 @@ impl JjBackend {
27612646 // drop the lock without saving, or the state would claim the
27622647 // directory was snapshotted and hide the changes from the next
27632648 // run (§7.7)
2764− return Ok((scanned, false));
2649+ return Err(dirty);
27652650 }
27662651 // saving the state only refreshes what it knows of each file, or
27672652 // takes in what the commit already has
2768− if refresh_clean_state {
2769− block_on(save_scan(locked_ws, &scanned, &wc_tree, base.operation().id()))
2770− .map_err(|e| (2, format!("cannot finish: {}", e)))?;
2771− }
2772− Ok((scanned, true))
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)
27732656 }
27742657
27752658 /// The operation log for `ops` (§7.7), one line per operation, newest
@@ -2787,8 +2670,8 @@ impl JjBackend {
27872670 let mut tag = "";
27882671 match op_kind(op.metadata()) {
27892672 OpKind::Normal => {}
2790− OpKind::Undo => tag = "undo",
2791− OpKind::Redo => tag = "redo",
2673+ OpKind::Undo { .. } => tag = "undo",
2674+ OpKind::Redo { .. } => tag = "redo",
27922675 }
27932676 if desc.is_empty() {
27942677 desc = "(no description)".to_string();
@@ -3260,7 +3143,8 @@ async fn checkout_tree(tree: MergedTree, backend: &JjBackend) -> Result<MergedTr
32603143/// user settings for init/clone: a missing or malformed `user` is a
32613144/// configuration error (exit 3) (§7.9)
32623145fn user_settings_strict(cfg: &Config) -> Result<UserSettings, OpenError> {
3263− let (name, email) = validated_identity(cfg)?;
3146+ let (name, email) = crate::config::eval_user_only(&mut Interp::dummy(), cfg)
3147+ .map_err(|c| (3, format!("config.j: {}", c.msg)))?;
32643148 let mut config = StackedConfig::with_defaults();
32653149 let text = format!(
32663150 "[user]\nname = {}\nemail = {}\n",
@@ -3274,39 +3158,6 @@ fn user_settings_strict(cfg: &Config) -> Result<UserSettings, OpenError> {
32743158 .map_err(|e| (3, format!("config.j: invalid user settings: {}", e)))
32753159}
32763160
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−
33103161// ----------------------------------------------------------------------
33113162// reserved-command helpers
33123163// ----------------------------------------------------------------------
@@ -3413,29 +3264,29 @@ fn git_default_branch(
34133264
34143265enum OpKind {
34153266 Normal,
3416− Undo,
3417− Redo,
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 },
34183271}
34193272
34203273/// a marker parsed from an operation description (§7.7 kinds)
34213274struct OpMarker {
34223275 kind: &'static str,
34233276 id: OperationId,
3424− /// jj 0.45 names the operation whose view was restored, rather than
3425− /// the operation undone or the undo reversed.
3426− restores_view: bool,
3427− /// Whether a legacy marker is j's exact `undo: <id>` / `redo: <id>`
3428− /// format, whose discarded-operation links the walk can follow.
3277+ /// whether the description is j's own exact marker (`undo: <id>` /
3278+ /// `redo: <id>`); only those are reliably loadable for the walk
34293279 authored_by_j: bool,
34303280}
34313281
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.
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.
34353286fn op_kind(meta: &jj_lib::op_store::OperationMetadata) -> OpKind {
34363287 match op_marker(meta) {
3437− Some(OpMarker { kind: "undo", .. }) => OpKind::Undo,
3438− Some(OpMarker { kind: "redo", .. }) => OpKind::Redo,
3288+ Some(OpMarker { kind: "undo", id, .. }) => OpKind::Undo { undid: id },
3289+ Some(OpMarker { kind: "redo", id, .. }) => OpKind::Redo { redid: id },
34393290 _ => OpKind::Normal,
34403291 }
34413292}
@@ -3443,19 +3294,14 @@ fn op_kind(meta: &jj_lib::op_store::OperationMetadata) -> OpKind {
34433294fn op_marker(meta: &jj_lib::op_store::OperationMetadata) -> Option<OpMarker> {
34443295 let desc = meta.description.trim();
34453296 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− }
34513297 if let Some(rest) = desc.strip_prefix(&format!("{}:", kind)) {
34523298 if let Some(id) = parse_op_id(rest.trim()) {
3453− return Some(OpMarker { kind, id, restores_view: false, authored_by_j: true });
3299+ return Some(OpMarker { kind, id, authored_by_j: true });
34543300 }
34553301 }
34563302 if let Some(rest) = desc.strip_prefix(&format!("{} operation", kind)) {
34573303 if let Some(id) = parse_op_id(rest.trim()) {
3458− return Some(OpMarker { kind, id, restores_view: false, authored_by_j: false });
3304+ return Some(OpMarker { kind, id, authored_by_j: false });
34593305 }
34603306 }
34613307 }
@@ -3495,42 +3341,6 @@ fn op_parent_id(
34953341 Ok(op.parent_ids().first().cloned())
34963342}
34973343
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−
35343344/// parse the push-record list (§7.6): records of shape {id, name} set a
35353345/// bookmark, records of shape {delete} remove one; duplicates merge, the same
35363346/// name with different effects crashes (exit 1)
@@ -3597,11 +3407,6 @@ fn parse_push_records(
35973407 let rec = vis.commits.get(&change_id).ok_or_else(|| {
35983408 (1, format!("push: `@{}` is not a visible commit", change_id))
35993409 })?;
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− }
36053410 out.push((name, Some(rec.commit.id().clone())));
36063411 }
36073412 None => {
@@ -3778,24 +3583,12 @@ fn check_push_records(
37783583 .map_err(|e| (2, format!("cannot list the commits to send: {}", e)))?;
37793584 for commit in &sent {
37803585 let change_id = commit.change_id().reverse_hex();
3781− let mut unresolved_paths = Vec::new();
3782− for (path, value) in commit.tree().entries() {
3586+ for (_path, value) in commit.tree().entries() {
37833587 let value = value.map_err(|e| (2, format!("cannot read a tree: {}", e)))?;
37843588 if !value.is_resolved() {
3785− unresolved_paths.push(path.as_internal_file_string().to_string());
3589+ return Err((1, format!("push: commit `@{}` has unresolved files", change_id)));
37863590 }
37873591 }
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− }
37993592 if commit.description().trim().is_empty() && commit.id() != store.root_commit_id() {
38003593 return Err((1, format!("push: commit `@{}` has an empty description", change_id)));
38013594 }
@@ -3825,11 +3618,23 @@ fn ancestors_of_change_ids(vis: &VisibleRepo, seeds: &[String]) -> BTreeSet<Stri
38253618
38263619/// git-check-ref-format --branch rules
38273620fn is_valid_git_branch_name(name: &str) -> bool {
3828− if name.starts_with('-') {
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+ {
38293632 return false;
38303633 }
3831− let full_name = format!("refs/heads/{name}");
3832− gix::validate::reference::branch_name(full_name.as_str().into()).is_ok()
3634+ name.chars().all(|c| {
3635+ !(c.is_ascii_control()
3636+ || matches!(c, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\'))
3637+ })
38333638}
38343639
38353640// ----------------------------------------------------------------------
@@ -3837,12 +3642,6 @@ fn is_valid_git_branch_name(name: &str) -> bool {
38373642// ----------------------------------------------------------------------
38383643
38393644impl 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−
38463645 fn visible_ids(&self) -> Vec<String> {
38473646 match self.inner.visible.lock().unwrap().as_ref() {
38483647 Some(v) => v.commits.keys().cloned().collect(),
@@ -4927,21 +4726,6 @@ mod tests {
49274726 assert!(cache.blobs.get(&id).is_none());
49284727 }
49294728
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−
49454729 #[test]
49464730 fn a_description_names_its_terms_whatever_their_labels_and_the_sides_that_cancel() {
49474731 // what a checkout writes for a symlink/file conflict of five terms,
src/lib.rs+2−3
@@ -5,14 +5,14 @@
55#[macro_export]
66macro_rules! report {
77 ($($arg:tt)*) => {{
8− $crate::terminal::report(format_args!($($arg)*));
8+ use ::std::io::Write as _;
9+ let _ = ::std::writeln!(::std::io::stderr(), $($arg)*);
910 }};
1011}
1112
1213pub mod ast;
1314pub mod builtins;
1415pub mod config;
15−pub mod diagnostics;
1616pub mod domain;
1717pub mod eval;
1818pub mod lex;
@@ -22,7 +22,6 @@ pub mod render;
2222pub mod repo;
2323pub mod shape;
2424pub mod show;
25−pub mod terminal;
2625pub mod value;
2726
2827pub mod jj;
src/main.rs+84−280
@@ -1,10 +1,8 @@
11//! j — a functional-programming-centered CLI for jj repositories (§1).
22
33mod help;
4−mod setup;
54
65use j::config::{self, Config};
7−use j::diagnostics;
86use j::jj::JjBackend;
97use j::parse::parse_expr;
108use j::value::{Crash, Value};
@@ -15,21 +13,20 @@ use std::rc::Rc;
1513
1614const USAGE: &str = "usage: j EXPRESSION… (or echo EXPRESSION | j)
1715
18−Configure identity: j setup
1916Start a repository: j init or j clone URL
2017Inspect your work: j status or j tree
2118Discover commands: j help or j commands
2219Command details: j help NAME";
2320
2421fn err(status: u8, msg: impl std::fmt::Display) -> ExitCode {
25− j::terminal::report_error(format_args!("j: {}", msg));
22+ j::report!("j: {}", msg);
2623 ExitCode::from(status)
2724}
2825
2926/// Report a failure that ends the process, keeping the status as a number so
3027/// callers do not have to recover it from an `ExitCode`.
3128fn fail(status: u8, msg: impl std::fmt::Display) -> u8 {
32− j::terminal::report_error(format_args!("j: {}", msg));
29+ j::report!("j: {}", msg);
3330 status
3431}
3532
@@ -38,22 +35,22 @@ fn fail(status: u8, msg: impl std::fmt::Display) -> u8 {
3835/// rest is dropped and the run still succeeds. Any other write failure is an
3936/// error line. `print!` would panic on either.
4037fn print_output(s: &str) -> ExitCode {
38+ use std::io::Write;
4139 let mut out = std::io::stdout().lock();
42− match j::terminal::write_output(&mut out, s) {
40+ match out.write_all(s.as_bytes()).and_then(|()| out.flush()) {
4341 Ok(()) => ExitCode::SUCCESS,
4442 Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
4543 Err(e) => err(1, format!("cannot write output: {}", e)),
4644 }
4745}
4846
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);
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) => {}
5754 }
5855 ExitCode::from(1)
5956}
@@ -76,57 +73,43 @@ fn load_config_file() -> Result<(Config, String), u8> {
7673 let path = config_path();
7774 let src = match std::fs::read_to_string(&path) {
7875 Ok(s) => s,
79− Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
76+ Err(_) => {
8077 // No user config yet: materialise the default there, editable by
8178 // the current user, so they have a starting point to customise.
82− match write_default_config(&path) {
83− Ok(()) => j::report!("j: created an editable default config at {}", path),
84− // Another invocation may have created it after our read.
85− // Read that file below, without overwriting it.
86− Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
87− Err(e) => return Err(fail(3, format!("cannot create config at {}: {}", path, e))),
79+ if let Err(e) = write_default_config(&path) {
80+ return Err(fail(3, format!("cannot create config at {}: {}", path, e)));
8881 }
82+ j::report!("j: created an editable default config at {}", path);
8983 match std::fs::read_to_string(&path) {
9084 Ok(s) => s,
9185 Err(e) => return Err(fail(3, format!("cannot read config at {}: {}", path, e))),
9286 }
9387 }
94− Err(e) => return Err(fail(3, format!("cannot read config at {}: {}", path, e))),
9588 };
9689 match config::load_config(&src) {
9790 Ok(c) => Ok((c, src)),
98− Err(e) => {
99− let message = match e {
100− config::ConfigError::Parse(p) => {
101− diagnostics::format_source_error(&path, &src, p.line, &p.msg)
102− }
103− config::ConfigError::Validation(message) => format!("{}: {}", path, message),
104− };
105− Err(fail(3, format!("{}\n Edit this file to repair your config; see `j help config`.", message)))
106− }
91+ Err(e) => Err(fail(3, e.message())),
10792 }
10893}
10994
11095/// Write the embedded default config to `path`, creating parent directories
11196/// and making the file readable and writable by the current user.
11297fn write_default_config(path: &str) -> std::io::Result<()> {
113− use std::io::Write;
11498 if let Some(parent) = std::path::Path::new(path).parent() {
11599 std::fs::create_dir_all(parent)?;
116100 }
117− let mut options = std::fs::OpenOptions::new();
118− options.write(true).create_new(true);
101+ std::fs::write(path, DEFAULT_CONFIG)?;
119102 // user-read+write (0o600 on unix): the file is meant to be edited by them
120103 #[cfg(unix)]
121104 {
122− use std::os::unix::fs::OpenOptionsExt;
123− options.mode(0o600);
105+ use std::os::unix::fs::PermissionsExt;
106+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
124107 }
125− options.open(path)?.write_all(DEFAULT_CONFIG.as_bytes())
108+ Ok(())
126109}
127110
128111const RESERVED: &[&str] = &[
129− "init", "clone", "remote", "fetch", "push", "undo", "redo", "ops", "help", "commands", "setup",
112+ "init", "clone", "remote", "fetch", "push", "undo", "redo", "ops", "help", "commands",
130113];
131114
132115/// Discovery reads the user's vocabulary without creating or evaluating it.
@@ -143,13 +126,11 @@ fn read_help_config() -> Result<(String, String), u8> {
143126 }
144127}
145128
146−fn run_help(words: &[&str]) -> ExitCode {
147− match words {
129+fn run_help(text: &str) -> ExitCode {
130+ let words: Vec<&str> = text.split_whitespace().collect();
131+ match words.as_slice() {
148132 [_] => print_output(help::OVERVIEW),
149133 [_, name] => {
150− if *name == "config" {
151− return print_output(&format!("{}\nConfig: {}\n", help::CONFIG_HELP, config_path()));
152− }
153134 if let Some(output) = help::reserved_help(name) {
154135 return print_output(&output);
155136 }
@@ -186,32 +167,6 @@ fn command_usage(usage: &str, command: &str) -> ExitCode {
186167 )
187168}
188169
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−
215170fn main() -> ExitCode {
216171 // evaluation builds deep continuation chains (trampolined, but the final
217172 // continuation still tears down by recursive Drop), so run the real work
@@ -256,16 +211,14 @@ fn run() -> ExitCode {
256211 // stdin is only read to report the "both" error — and reading it blindly
257212 // hangs whenever the process inherited a pipe nobody is writing to (a
258213 // shell loop, a CI step), so wait only briefly for input to appear.
259− // One byte already proves the "both" error: do not wait for the pipe's
260− // writer to close it. Bytes that are not UTF-8 are input too, and an
261− // error only once they are the expression.
214+ // Bytes that are not UTF-8 are input too, and an error only once they
215+ // are the expression.
262216 let mut stdin_content = Vec::new();
263217 let stdin_has_data = {
264218 if std::io::stdin().is_terminal() {
265219 false
266220 } else if args.is_empty() || stdin_is_ready(50) {
267− let limit = if args.is_empty() { u64::MAX } else { 1 };
268− match std::io::stdin().take(limit).read_to_end(&mut stdin_content) {
221+ match std::io::stdin().read_to_end(&mut stdin_content) {
269222 Ok(n) => n > 0,
270223 Err(_) => false,
271224 }
@@ -276,16 +229,6 @@ fn run() -> ExitCode {
276229 if stdin_has_data && !args.is_empty() {
277230 return err(2, "expression given both as arguments and on stdin");
278231 }
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− }
289232 let text = if stdin_has_data {
290233 match String::from_utf8(stdin_content) {
291234 Ok(text) => text,
@@ -305,41 +248,30 @@ fn run() -> ExitCode {
305248 return ExitCode::from(2);
306249 }
307250 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− };
315251 if matches!(first, "--help" | "-h") {
316− return run_help(&words);
252+ return run_help(trimmed);
317253 }
318254 if RESERVED.contains(&first) {
319− return run_reserved(first, trimmed, &words);
255+ return run_reserved(first, trimmed);
320256 }
321257
322258 run_expression(trimmed, true)
323259}
324260
325−fn run_reserved(cmd: &str, text: &str, words: &[&str]) -> ExitCode {
261+fn run_reserved(cmd: &str, text: &str) -> ExitCode {
262+ let words: Vec<&str> = text.split_whitespace().collect();
326263 match cmd {
327− "help" => run_help(words),
264+ "help" => run_help(text),
328265 "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− },
333266 "init" => {
334267 if words.len() != 1 {
335268 return command_usage("init", "init");
336269 }
337270 let (cfg, _) = or_exit(load_config_file());
338− let identity = or_exit(setup_identity(&cfg));
339− command_result(
340− j::terminal::with_progress("Creating repository", || j::jj::cmd_init(&cfg)),
341− &format!("Repository created; identity {}", identity),
342− )
271+ match j::jj::cmd_init(&cfg) {
272+ Ok(()) => ExitCode::SUCCESS,
273+ Err(e) => err(e.0, e.1),
274+ }
343275 }
344276 "clone" => {
345277 if words.len() != 2 && words.len() != 3 {
@@ -360,31 +292,31 @@ fn run_reserved(cmd: &str, text: &str, words: &[&str]) -> ExitCode {
360292 }
361293 };
362294 let (cfg, _) = or_exit(load_config_file());
363− let identity = or_exit(setup_identity(&cfg));
364− command_result(
365− j::terminal::with_progress("Cloning repository", || j::jj::cmd_clone(&cfg, words[1], &dir)),
366− &format!("Repository cloned into {}; identity {}\n Next: cd {} && j status", dir, identity, shell_word(&dir)),
367− )
295+ match j::jj::cmd_clone(&cfg, words[1], &dir) {
296+ Ok(()) => ExitCode::SUCCESS,
297+ Err(e) => err(e.0, e.1),
298+ }
368299 }
369300 "remote" => {
370301 if words.len() != 2 {
371302 return command_usage("remote URL", "remote");
372303 }
373304 let backend = or_exit(open_repo());
374− command_result(
375− j::terminal::with_progress("Updating remote", || backend.cmd_remote(words[1])),
376− "Remote updated",
377− )
305+ match backend.cmd_remote(words[1]) {
306+ Ok(()) => ExitCode::SUCCESS,
307+ Err(e) => err(e.0, e.1),
308+ }
378309 }
379310 "fetch" => {
380311 if words.len() != 1 {
381312 return command_usage("fetch", "fetch");
382313 }
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− )
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+ }
388320 }
389321 "push" => {
390322 let rest = text.trim_start_matches("push").trim();
@@ -394,22 +326,9 @@ fn run_reserved(cmd: &str, text: &str, words: &[&str]) -> ExitCode {
394326 let (mut cfg, _) = or_exit(load_config_file());
395327 let backend = or_exit(open_repo_locked(&cfg));
396328 // evaluate EXPR against the recorded repository (no snapshot)
397− match j::terminal::with_progress("Pushing changes", || backend.cmd_push(&mut cfg, rest)) {
398− Err((3, message)) if message.starts_with("config.j:") => {
399− err(3, format!("{}: {}\n See `j help config` for configuration help.", config_path(), message.trim_start_matches("config.j: ")))
400− }
401− Ok(outcome) => {
402− if outcome.published + outcome.deleted + outcome.unchanged == 0 {
403− j::report!("j: No bookmarks selected; nothing published.\n First publication: j 'push (label \"feature\" here)'\n Inspect available commits with `j tree` and check your selector.");
404− } else {
405− j::terminal::success(&format!(
406− "Push complete: {} published, {} deleted, {} unchanged bookmark(s)",
407− outcome.published, outcome.deleted, outcome.unchanged,
408− ));
409− }
410− ExitCode::SUCCESS
411− }
412− Err((code, message)) => err(code, message),
329+ match backend.cmd_push(&mut cfg, rest) {
330+ Ok(()) => ExitCode::SUCCESS,
331+ Err(e) => err(e.0, e.1),
413332 }
414333 }
415334 "undo" => {
@@ -417,25 +336,27 @@ fn run_reserved(cmd: &str, text: &str, words: &[&str]) -> ExitCode {
417336 return command_usage("undo", "undo");
418337 }
419338 let backend = or_exit(open_repo_noconfig());
420− operation_result(
421− j::terminal::with_progress("Undoing operation", || backend.cmd_undo(false)),
422− )
339+ match backend.cmd_undo(false) {
340+ Ok(()) => ExitCode::SUCCESS,
341+ Err(e) => err(e.0, e.1),
342+ }
423343 }
424344 "redo" => {
425345 if words.len() != 1 {
426346 return command_usage("redo", "redo");
427347 }
428348 let backend = or_exit(open_repo_noconfig());
429− operation_result(
430− j::terminal::with_progress("Redoing operation", || backend.cmd_undo(true)),
431− )
349+ match backend.cmd_undo(true) {
350+ Ok(()) => ExitCode::SUCCESS,
351+ Err(e) => err(e.0, e.1),
352+ }
432353 }
433354 "ops" => {
434355 if words.len() != 1 {
435356 return command_usage("ops", "ops");
436357 }
437358 let backend = or_exit(open_repo_noconfig());
438− match j::terminal::with_progress("Loading operation log", || backend.cmd_ops()) {
359+ match backend.cmd_ops() {
439360 Ok(log) => print_output(&log),
440361 Err(e) => err(e.0, e.1),
441362 }
@@ -464,13 +385,6 @@ fn default_clone_dir(url: &str) -> Option<String> {
464385 }
465386}
466387
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−
474388fn or_exit<T>(r: Result<T, u8>) -> T {
475389 match r {
476390 Ok(v) => v,
@@ -515,15 +429,15 @@ fn run_expression(text: &str, snapshot: bool) -> ExitCode {
515429 let outer = Rc::new(cfg.global_names.clone());
516430 let mut expr = match parse_expr(text, outer) {
517431 Ok(e) => e,
518− Err(p) => return err(3, diagnostics::format_source_error("expression", text, p.line, &p.msg)),
432+ Err(p) => return err(3, format!("line {}: {}", p.line, p.msg)),
519433 };
520434
521435 // §1.2.4: build the current repository value (with snapshot)
522436 let (mut interp, loaded_repo, current_repo) =
523− match j::terminal::with_progress("Reading repository", || backend.build_interp(&mut cfg, text, snapshot)) {
437+ match backend.build_interp(&mut cfg, text, snapshot) {
524438 Ok(t) => t,
525439 Err((2, m)) => return err(2, m),
526− Err((3, m)) => return err(3, format!("{}: {}\n See `j help config` for configuration help.", config_path(), m.trim_start_matches("config.j: "))),
440+ Err((3, m)) => return err(3, m),
527441 Err((_, m)) => return err(1, m),
528442 };
529443
@@ -534,9 +448,9 @@ fn run_expression(text: &str, snapshot: bool) -> ExitCode {
534448 Err(failures) => {
535449 let (prefix, candidates) = &failures[0];
536450 if candidates.is_empty() {
537− j::terminal::report_error(format_args!("j: crash: `@{}` matches no commit", prefix));
451+ j::report!("j: crash: `@{}` matches no commit", prefix);
538452 } else {
539− j::terminal::report_error(format_args!("j: crash: `@{}` is ambiguous", prefix));
453+ j::report!("j: crash: `@{}` is ambiguous", prefix);
540454 for c in candidates {
541455 j::report!(" {}", c);
542456 }
@@ -546,139 +460,42 @@ fn run_expression(text: &str, snapshot: bool) -> ExitCode {
546460 };
547461
548462 // §1.2.6: evaluate config definitions, then the expression
549− if let Err(c) = j::terminal::with_progress("Evaluating configuration", || {
550− config::eval_config(&mut interp, &cfg)
551− }) {
552− return err(3, format!("{}: {}\n See `j help config` for configuration help.", config_path(), c.msg));
463+ if let Err(c) = config::eval_config(&mut interp, &cfg) {
464+ return err(3, format!("config.j: {}", c.msg));
553465 }
554466 *interp.old_repo.borrow_mut() = Some(loaded_repo.clone());
555467 *interp.given_repo.borrow_mut() = Some(current_repo.clone());
556468 let env = interp.global_env();
557469 let expr_rc = Rc::new(expr);
558− let v = match j::terminal::with_progress("Evaluating expression", || {
559− let v = interp.eval(&expr_rc, &env)?;
560− // §1.2.7: apply a function result to the repo, once.
561− if matches!(v, Value::Fun(_)) {
562− interp.apply(v, current_repo.clone())
563− } else {
564− Ok(v)
565− }
566− }) {
470+ let mut v = match interp.eval(&expr_rc, &env) {
567471 Ok(v) => v,
568− Err(c) => return crash_err(&c, text, &cfg),
472+ Err(c) => return crash_err(&c, Some(text)),
569473 };
570474
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+
571483 // §1.2.8: a Repo is persisted (unless unchanged)
572484 if is_repo_value(&v) {
573− match j::terminal::with_progress("Saving changes", || {
574− backend.persist(&mut interp, &cfg, &loaded_repo, &current_repo, &v, text)
575− }) {
576− Ok(changed) => {
577− if std::io::stdout().is_terminal() && std::io::stderr().is_terminal() {
578− let outcome = if changed { "Saved changes" } else { "No changes" };
579− j::terminal::success(&format!("{}; {}", outcome, focus_summary(&v)));
580− }
581− if changed {
582− report_conflicts(&interp, &loaded_repo, &v);
583− }
584− ExitCode::SUCCESS
585− }
586− Err(c) if c.msg.starts_with("config.j: user identity") => {
587− err(3, format!("{}: {}\n Edit `user` in this file; see `j help config`.", config_path(), c.msg.trim_start_matches("config.j: ")))
588− }
589− Err(c) => crash_err(&c, text, &cfg),
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)),
590488 }
591489 } else {
592490 // §1.2.9: display
593491 let color = j::render::color_enabled("auto");
594− match j::terminal::with_progress("Rendering output", || j::render::display(&mut interp, &v, color)) {
492+ match j::render::display(&mut interp, &v, color) {
595493 Ok(s) => print_output(&s),
596− Err(c) => crash_err(&c, text, &cfg),
597− }
598− }
599−}
600−
601−/// Report newly introduced conflicts and conflicts in the checked-out focus.
602−/// Stored, resolved trees are skipped without loading their entries or blobs.
603−fn conflict_paths(interp: &j::eval::Interp, repo: &Value) -> Result<BTreeSet<(String, String)>, Crash> {
604− let mut paths = BTreeSet::new();
605− for commit in j::repo::all_commits(repo)? {
606− let Value::Id(id) = commit.field("id")? else { continue };
607− let Value::Record(fields) = &commit else { continue };
608− if let Some(Value::Thunk(files)) = fields.get("files") {
609− // `new` can inherit an unchanged snapshot from another commit.
610− // Its stored origin still answers for those files even when the
611− // new commit has a different id.
612− if files.origin().and_then(|origin| interp.backend.has_conflict(origin)) == Some(false) {
613− continue;
614− }
615− }
616− for entry in commit.field("files")?.as_list()? {
617− if matches!(entry.field("content")?, Value::Blob(blob) if blob.is_unresolved()) {
618− let path = entry.field("path")?.as_list()?.iter()
619− .map(|part| part.as_text().map(str::to_owned))
620− .collect::<Result<Vec<_>, _>>()?.join("/");
621− paths.insert((id.to_string(), path));
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());
494+ Err(c) => crash_err(&c, Some(text)),
642495 }
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);
653496 }
654497}
655498
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−
682499fn is_repo_value(v: &Value) -> bool {
683500 // §4.4: shape Repo and root's shape Commit
684501 let fields = match v {
@@ -706,16 +523,3 @@ fn is_repo_value(v: &Value) -> bool {
706523 .collect();
707524 root_fields == want_root
708525}
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+74−268
@@ -33,46 +33,33 @@ impl Palette {
3333 self.wrap("2", s)
3434 }
3535 pub fn bold_bg(&self, s: &str) -> String {
36− self.wrap("1;97;48;5;17", s)
36+ self.wrap("1;48;5;236", 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("96", s)
42+ self.wrap("36", s)
4343 }
4444 pub fn red(&self, s: &str) -> String {
45− self.wrap("91", s)
45+ self.wrap("31", s)
4646 }
4747 pub fn blue(&self, s: &str) -> String {
48− self.wrap("94", s)
48+ self.wrap("34", s)
4949 }
5050 pub fn green(&self, s: &str) -> String {
51− self.wrap("92", s)
51+ self.wrap("32", s)
5252 }
5353 pub fn yellow(&self, s: &str) -> String {
54− self.wrap("93", s)
55− }
56− fn id(&self, s: &str) -> String {
57− self.wrap("1;95", s)
58− }
59− fn label(&self, s: &str) -> String {
60− self.wrap("1;92", s)
61− }
62− fn field(&self, s: &str) -> String {
63− self.wrap("2;96", s)
64− }
65− fn rail(&self, lane: usize, s: &str) -> String {
66− let codes = ["95", "96", "93", "92"];
67− self.wrap(codes[lane.saturating_sub(1) % codes.len()], s)
54+ self.wrap("33", s)
6855 }
6956 pub fn grey(&self, bright: u8, s: &str) -> String {
7057 // grey scale: brighter when more recent
7158 self.wrap(&format!("38;5;{}", 245 + bright.min(10)), s)
7259 }
7360 pub fn accent(&self, level: u8, s: &str) -> String {
74− // Size remains encoded by the bar's height, with a vivid cool-to-warm ramp.
75− let codes = ["96", "94", "95", "93", "1;93"];
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"];
7663 self.wrap(codes[level.min(4) as usize], s)
7764 }
7865 pub fn author(&self, name: &str, s: &str) -> String {
@@ -82,7 +69,7 @@ impl Palette {
8269 h = h.wrapping_mul(33).wrapping_add(b as u32);
8370 }
8471 let hue = (h % 6) as u8;
85− let code = ["91", "92", "93", "94", "95", "96"][hue as usize];
72+ let code = ["31", "32", "33", "34", "35", "36"][hue as usize];
8673 self.wrap(code, s)
8774 }
8875 /// colour with a raw ANSI code (used for meaning-coloured glyphs)
@@ -114,8 +101,7 @@ pub fn color_enabled(mode: &str) -> bool {
114101 match mode {
115102 "always" => true,
116103 "never" => false,
117− _ => std::env::var_os("TERM").map(|t| t != "dumb").unwrap_or(true)
118− && crate::show::stdout_is_tty(),
104+ _ => crate::show::stdout_is_tty(),
119105 }
120106}
121107
@@ -171,16 +157,11 @@ fn field_set(v: &Value) -> Option<BTreeSet<String>> {
171157
172158fn shape_of_record(interp: &Interp, v: &Value) -> Option<String> {
173159 let fs = field_set(v)?;
174− // Only these named shapes have special display forms (§5.1). Searching
175− // every declaration let a custom alias win in HashMap iteration order,
176− // randomly turning Commit, Entry, etc. values into generic records.
177− // Resolve each configured shape so aliases and custom field sets still
178− // determine which records match.
179− for name in ["Commit", "Entry", "Subtree", "Repo", "Frame", "Change"] {
160+ for (name, _) in &interp.shapes.decls {
180161 if let Some(s) = interp.shapes.shape_of(name) {
181162 if let ShapeKind::Record(want) = &s.kind {
182163 if want == &fs {
183− return Some(name.to_string());
164+ return Some(name.clone());
184165 }
185166 }
186167 }
@@ -197,16 +178,6 @@ pub fn is_commit(interp: &Interp, v: &Value) -> bool {
197178// ----------------------------------------------------------------------
198179
199180pub 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> {
210181 let pal = Palette { on: color };
211182 let mut out = String::new();
212183 let mut loaded = None;
@@ -214,7 +185,7 @@ pub fn display_with_width(
214185 while let Some(block) = todo.pop() {
215186 match block {
216187 Block::Of(v, indent) => {
217− display_block(interp, &v, &pal, indent, &mut out, &mut loaded, &mut todo, term_w)?
188+ display_block(interp, &v, &pal, indent, &mut out, &mut loaded, &mut todo)?
218189 }
219190 Block::Items { xs, next, indent } => {
220191 if let Some(x) = xs.get(next).cloned() {
@@ -265,7 +236,7 @@ enum Block {
265236fn display_id(interp: &Interp, id: &str, pal: &Palette) -> String {
266237 let p = interp.unique_prefix(id);
267238 let n = p.len();
268− format!("{}{}", pal.id(p.as_str()), pal.dim(&id[n..]))
239+ format!("{}{}", pal.bold(p.as_str()), pal.dim(&id[n..]))
269240}
270241
271242fn display_line(
@@ -277,8 +248,8 @@ fn display_line(
277248 let v = &v.forced()?;
278249 match v {
279250 Value::Thunk(_) => unreachable!("forced never returns a thunk"),
280− Value::Int(n) => Ok(pal.yellow(&n.to_string())),
281− Value::Bool(b) => Ok(if *b { pal.green("true") } else { pal.red("false") }),
251+ Value::Int(n) => Ok(n.to_string()),
252+ Value::Bool(b) => Ok(b.to_string()),
282253 Value::Text(t) => Ok(first_line(t)),
283254 Value::Id(id) => Ok(display_id(interp, id, pal)),
284255 Value::Blob(b) => {
@@ -292,7 +263,7 @@ fn display_line(
292263 // a lambda renders as its source (§5.2), which may run over several
293264 // lines and hold comments; the line form keeps only the first
294265 Value::Fun(_) => Ok(first_line(&crate::show::show(interp, v)?)),
295− Value::Shape(s) => Ok(pal.cyan(&s.name)),
266+ Value::Shape(s) => Ok(s.name.clone()),
296267 Value::Record(_) => {
297268 if let Some(shape) = shape_of_record(interp, v) {
298269 match shape.as_str() {
@@ -364,13 +335,14 @@ fn list_line(_interp: &Interp, xs: &[Value], pal: &Palette) -> Result<String, Cr
364335 let parts: Vec<String> = xs
365336 .iter()
366337 .map(|x| match x {
367− Value::Int(n) => pal.yellow(&n.to_string()),
368− Value::Bool(b) => if *b { pal.green("true") } else { pal.red("false") },
338+ Value::Int(n) => n.to_string(),
339+ Value::Bool(b) => b.to_string(),
369340 _ => unreachable!(),
370341 })
371342 .collect();
372343 return Ok(parts.join(", "));
373344 }
345+ let _ = pal;
374346 Ok(format!("{} items", xs.len()))
375347}
376348
@@ -383,9 +355,9 @@ fn entry_line(interp: &Interp, v: &Value, pal: &Palette) -> Result<String, Crash
383355 };
384356 let _ = interp;
385357 if unresolved {
386− Ok(format!("{} {} {}", pal.red("✖"), pal.cyan(&path), pal.red(&size)))
358+ Ok(format!("{} {} {}", pal.red("✖"), path, size))
387359 } else {
388− Ok(format!(" {} {}", pal.cyan(&path), pal.yellow(&size)))
360+ Ok(format!(" {} {}", path, size))
389361 }
390362}
391363
@@ -818,18 +790,11 @@ fn commit_line(
818790 .map(|l| l.as_text().map(|s| s.to_string()))
819791 .collect::<Result<_, _>>()?;
820792 let glyph = node_glyph(c, &id, standing)?;
821− let id_s = if glyph == "⊗" {
822− pal.red(&display_id(interp, &id, &Palette { on: false }))
823− } else if standing.immutable {
824− pal.blue(&display_id(interp, &id, &Palette { on: false }))
825− } else {
826− display_id(interp, &id, pal)
827− };
828− let glyph = pal.code(node_ansi(&glyph), &glyph);
793+ let id_s = display_id(interp, &id, pal);
829794 let labels_s = if labels.is_empty() {
830795 String::new()
831796 } else {
832− pal.label(&labels.join(" "))
797+ pal.green(&labels.join(" "))
833798 };
834799 Ok(if labels_s.is_empty() {
835800 format!("{} {} {}", glyph, id_s, msg_first)
@@ -871,7 +836,6 @@ fn has_conflict(c: &Value) -> Result<bool, Crash> {
871836}
872837
873838/// `v` in block form, the blocks it holds left on `todo` (see `Block`)
874−#[allow(clippy::too_many_arguments)]
875839fn display_block(
876840 interp: &mut Interp,
877841 v: &Value,
@@ -880,7 +844,6 @@ fn display_block(
880844 out: &mut String,
881845 loaded: &mut Option<Loaded>,
882846 todo: &mut Vec<Block>,
883− term_w: Option<usize>,
884847) -> Result<(), Crash> {
885848 let pad = " ".repeat(indent);
886849 let v = &v.forced()?;
@@ -911,7 +874,7 @@ fn display_block(
911874 }
912875 return Ok(());
913876 }
914− Value::List(xs) => return display_list_block(interp, xs, pal, indent, out, loaded, todo, term_w),
877+ Value::List(xs) => return display_list_block(interp, xs, pal, indent, out, loaded, todo),
915878 Value::Record(_) => {}
916879 }
917880 if let Some(shape) = shape_of_record(interp, v) {
@@ -933,7 +896,7 @@ fn display_block(
933896 ("children", v.field("children")?),
934897 ("context", Value::list(vec![])),
935898 ]);
936− let text = tree_render(interp, &opts, &repo, pal, false, term_w)?;
899+ let text = tree_render(interp, &opts, &repo, pal, false, tty_width())?;
937900 out.push_str(&indent_multiline(&text, indent));
938901 return Ok(());
939902 }
@@ -989,37 +952,11 @@ fn display_fields(
989952 todo: &mut Vec<Block>,
990953) -> Result<(), Crash> {
991954 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(_))));
998955 let from = after.map_or(Bound::Unbounded, Bound::Excluded);
999956 for (k, x) in m.range::<str, _>((from, Bound::Unbounded)) {
1000957 out.push_str(&pad);
1001− let label = if status && m.contains_key("unrecorded") && k == "changed" {
1002− "in commit"
1003− } else {
1004− k.as_str()
1005− };
1006− out.push_str(&pal.field(&pad_right(label, name_w)));
958+ out.push_str(&pal.dim(&pad_right(k, name_w)));
1007959 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− }
1023960 // lists print in block form under their key (§5.1)
1024961 if matches!(x, Value::List(xs) if !xs.is_empty()) {
1025962 out.push('\n');
@@ -1296,7 +1233,6 @@ fn top_of(interp: &Interp, _x: &Value) -> Result<Value, Crash> {
12961233}
12971234
12981235/// `list` in block form, the blocks it holds left on `todo` (see `Block`)
1299−#[allow(clippy::too_many_arguments)]
13001236fn display_list_block(
13011237 interp: &mut Interp,
13021238 list: &ListVal,
@@ -1305,7 +1241,6 @@ fn display_list_block(
13051241 out: &mut String,
13061242 loaded: &mut Option<Loaded>,
13071243 todo: &mut Vec<Block>,
1308− term_w: Option<usize>,
13091244) -> Result<(), Crash> {
13101245 let xs: &[Value] = list;
13111246 let pad = " ".repeat(indent);
@@ -1339,7 +1274,7 @@ fn display_list_block(
13391274 // the glyph of its line form (§5.1)
13401275 let glyph = node_glyph(&c, &id, &loaded.line_standing(interp, &c)?)?;
13411276 let conflict = glyph == "⊗";
1342− let glyph = pal.code(node_ansi(&glyph), &glyph);
1277+ let glyph = if conflict { pal.red(&glyph) } else { glyph };
13431278 let msg = c.field("message")?.as_text()?.to_string();
13441279 let msg_first = msg.lines().next().unwrap_or("");
13451280 let labels: Vec<String> = c
@@ -1361,7 +1296,7 @@ fn display_list_block(
13611296 glyph,
13621297 id_s,
13631298 msg_first,
1364− pal.label(&labels.join(" "))
1299+ pal.green(&labels.join(" "))
13651300 ));
13661301 }
13671302 }
@@ -1420,22 +1355,20 @@ fn display_list_block(
14201355 _ => (String::new(), false),
14211356 };
14221357 out.push_str(&pad);
1423− let mark = if unresolved { pal.red("✖") } else { " ".into() };
1358+ let mark = if unresolved { "✖" } else { " " };
14241359 let size_s = if unresolved {
14251360 pal.red(&size)
14261361 } else {
1427− pal.yellow(&size)
1362+ size
14281363 };
14291364 // pad by display width, not by character count: `{:n$}`
14301365 // pads to n *chars*, so a wide (East Asian) path pushes
14311366 // the size column out of line
1432− out.push_str(&format!(
1433− "{} {} {}\n", mark, pal.cyan(&pad_right(&path, width_max)), size_s
1434− ));
1367+ out.push_str(&format!("{} {} {}\n", mark, pad_right(&path, width_max), size_s));
14351368 }
14361369 return Ok(());
14371370 }
1438− return display_table(interp, xs, pal, indent, out, loaded, term_w);
1371+ return display_table(interp, xs, pal, indent, out, loaded);
14391372 }
14401373 }
14411374 // list of other scalars
@@ -1460,36 +1393,17 @@ fn display_table(
14601393 indent: usize,
14611394 out: &mut String,
14621395 loaded: &mut Option<Loaded>,
1463− term_w: Option<usize>,
14641396) -> Result<(), Crash> {
14651397 let pad = " ".repeat(indent);
14661398 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()];
14741399 // cells
14751400 let mut rows: Vec<Vec<String>> = Vec::new();
14761401 for x in xs {
14771402 let mut row = Vec::new();
1478− for (column, f) in fields.iter().enumerate() {
1403+ for f in &fields {
14791404 let cell = match x.field(f)? {
1480− Value::Bool(true) => pal.green("✓"),
1405+ Value::Bool(true) => "✓".to_string(),
14811406 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− }
14931407 other => display_line(interp, &other, pal, loaded)?,
14941408 };
14951409 row.push(cell);
@@ -1500,7 +1414,7 @@ fn display_table(
15001414 let keep: Vec<usize> = (0..fields.len())
15011415 .filter(|&i| rows.iter().any(|r| !r[i].is_empty()))
15021416 .collect();
1503− let mut widths: Vec<usize> = keep
1417+ let widths: Vec<usize> = keep
15041418 .iter()
15051419 .map(|&i| {
15061420 rows.iter()
@@ -1510,40 +1424,6 @@ fn display_table(
15101424 .max(width(&fields[i]))
15111425 })
15121426 .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− }
15471427 // header
15481428 out.push_str(&pad);
15491429 let header: Vec<String> = keep
@@ -1551,14 +1431,14 @@ fn display_table(
15511431 .zip(&widths)
15521432 .map(|(&i, w)| pad_right(&fields[i], *w))
15531433 .collect();
1554− out.push_str(&pal.field(&header.join(" ")));
1434+ out.push_str(&pal.dim(&header.join(" ")));
15551435 out.push('\n');
15561436 for r in &rows {
15571437 out.push_str(&pad);
15581438 let cells: Vec<String> = keep
15591439 .iter()
15601440 .zip(&widths)
1561− .map(|(&i, w)| pad_right(&cut_to_width(&r[i], *w), *w))
1441+ .map(|(&i, w)| pad_right(&r[i], *w))
15621442 .collect();
15631443 out.push_str(&cells.join(" "));
15641444 out.push('\n');
@@ -2445,7 +2325,7 @@ fn build_row_text(
24452325) -> RowText {
24462326 let c = n.commit();
24472327 let is_focus = c.map(|x| x.is_focus).unwrap_or(false);
2448− let gutter = if is_focus && with_focus { pal.code("1;96", "▶ ") } else { " ".into() };
2328+ let gutter = if is_focus && with_focus { "▶ " } else { " " }.to_string();
24492329 // id (blank on a run row, whose count `rail_row` draws: the part that
24502330 // overflows the rails area runs on into the id column)
24512331 let id = match n {
@@ -2459,10 +2339,8 @@ fn build_row_text(
24592339 pal.red(&format!("@{}", p))
24602340 } else if info.immutable {
24612341 pal.blue(&format!("@{}", p))
2462− } else if info.is_focus {
2463− pal.code("1;96", &format!("@{}", p))
24642342 } else {
2465− pal.id(&format!("@{}", p))
2343+ format!("@{}", p)
24662344 };
24672345 let plain = k + 1; // +1 for the @
24682346 let padded = if plain < id_w {
@@ -2552,7 +2430,6 @@ fn draw_rows(
25522430 term_w: Option<usize>,
25532431) -> Result<String, Crash> {
25542432 let rail_chars = 2 * lanes_n;
2555− let overflow = placements.iter().any(|p| p.flattened);
25562433 // each row's rails, drawn once; a run row's go on past the rails area
25572434 // when its count does not fit
25582435 let mut rail_state = vec![None; lanes_n];
@@ -2585,7 +2462,7 @@ fn draw_rows(
25852462 for (i, n) in rows.iter().enumerate() {
25862463 texts.push(build_row_text(n, index.focus_adjacent[i], opts, pal, with_focus, prefix_len, id_w));
25872464 }
2588− // Columns 5–9 start at the same offset on every row (§Step 4). The size
2465+ // Columns 4–8 start at the same offset on every row (§Step 4). The size
25892466 // bar's column is there when any row draws a bar — ` ▅`, two columns —
25902467 // and blank on the rows that draw none, so a bar never shifts a message.
25912468 let bar_col = texts.iter().any(|t| !t.bar.is_empty());
@@ -2603,17 +2480,6 @@ fn draw_rows(
26032480 _ => None,
26042481 };
26052482 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− }
26172483 line.push(' ');
26182484 line.push_str(&t.id);
26192485 if bar_col {
@@ -2695,14 +2561,12 @@ fn draw_rows(
26952561 line.push_str(&t.msg);
26962562 if label_col && !t.labels.is_empty() {
26972563 pad_to(&mut line, lab_off);
2698− line.push_str(&pal.label(&t.labels));
2564+ line.push_str(&pal.green(&t.labels));
26992565 }
27002566 if !metas[idx].is_empty() {
27012567 pad_to(&mut line, meta_off);
27022568 line.push_str(&metas[idx]);
27032569 }
2704− // Remove layout padding, while preserving spaces in detail paths below.
2705− while line.ends_with(' ') { line.pop(); }
27062570 // the whole focus row is highlighted with a background band across the
27072571 // full terminal width (§Colour; colour only — the ▶ gutter still marks
27082572 // the focus when colour is off)
@@ -2714,7 +2578,7 @@ fn draw_rows(
27142578 }
27152579 _ => line.clone(),
27162580 };
2717− lines.push(pal.bg_line("97;48;5;17", &padded));
2581+ lines.push(pal.bg_line("48;5;236", &padded));
27182582 } else {
27192583 lines.push(line);
27202584 }
@@ -2728,7 +2592,6 @@ fn draw_rows(
27282592 let mut dline = String::new();
27292593 dline.push_str(" ");
27302594 dline.push_str(&color_rails(&dchars, &dlane0, pal, None));
2731− if overflow { dline.push_str(" "); }
27322595 dline.push(' ');
27332596 // the marks start two columns into the id column (§Step 4)
27342597 dline.push_str(" ");
@@ -2747,18 +2610,19 @@ fn draw_rows(
27472610 format!("{} {}", colored, p)
27482611 })
27492612 .collect();
2750− let detail_lines = match term_w {
2751− Some(w) => wrap_detail_items(&marks, w.saturating_sub(width(&dline)).max(1)),
2752− None => vec![marks.join(" ")],
2753− };
2754− for text in detail_lines {
2755− lines.push(format!("{}{}", dline, text));
2756− }
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();
27572621 }
27582622 }
27592623 // Legend (§Legend): explain the symbols that actually appear, faintly, on
27602624 // the right of the tree if it fits the terminal width, else at the bottom.
2761− append_legend(&mut lines, rows, opts, pal, term_w, overflow);
2625+ append_legend(&mut lines, rows, opts, pal, term_w);
27622626 let mut out = lines.join("\n");
27632627 out.push('\n');
27642628 Ok(out)
@@ -2771,11 +2635,10 @@ struct UsedSymbols {
27712635 run: bool, // a ╎ n run row
27722636 collapsed: bool, // a ⋯ n collapsed node
27732637 focus_gutter: bool, // ▶ present (a focus row exists)
2774− overflow: bool, // separate » status column
27752638 detail_marks: Vec<char>, // + ~ − ✖ present in the focus's detail line
27762639}
27772640
2778−fn collect_used(rows: &[&Display], opts: &TreeOptions, icons: bool, overflow: bool) -> UsedSymbols {
2641+fn collect_used(rows: &[&Display], opts: &TreeOptions, icons: bool) -> UsedSymbols {
27792642 let mut present: BTreeSet<&'static str> = BTreeSet::new();
27802643 let mut run = false;
27812644 let mut collapsed = false;
@@ -2814,7 +2677,6 @@ fn collect_used(rows: &[&Display], opts: &TreeOptions, icons: bool, overflow: bo
28142677 collapsed,
28152678 focus_gutter,
28162679 detail_marks: marks.into_iter().collect(),
2817− overflow,
28182680 }
28192681}
28202682
@@ -2850,9 +2712,6 @@ fn legend_lines(used: &UsedSymbols) -> Vec<String> {
28502712 if used.focus_gutter {
28512713 out.push("▶ current commit".to_string());
28522714 }
2853− if used.overflow {
2854− out.push("» branch exceeds lane limit; increase lanes".to_string());
2855− }
28562715 if used.run {
28572716 out.push("╎ n run of n commits".to_string());
28582717 }
@@ -2875,9 +2734,8 @@ fn append_legend(
28752734 opts: &TreeOptions,
28762735 pal: &Palette,
28772736 tty_width: Option<usize>,
2878− overflow: bool,
28792737) {
2880− let used = collect_used(rows, opts, opts.icons, overflow);
2738+ let used = collect_used(rows, opts, opts.icons);
28812739 let entries = legend_lines(&used);
28822740 if entries.is_empty() {
28832741 return;
@@ -2987,6 +2845,8 @@ fn rail_row(
29872845 }
29882846 } else if state[i].is_some() {
29892847 '│'
2848+ } else if pl.flattened && i == lanes_n - 1 {
2849+ '»'
29902850 } else {
29912851 ' '
29922852 };
@@ -3037,9 +2897,8 @@ fn detail_rail_row(state: &[Option<bool>]) -> (Vec<char>, Vec<bool>) {
30372897}
30382898
30392899/// Colour the rails. Rail connectors are lane-coloured (lane 0 = immutable
3040−/// bright blue, others rotating accent hues); the node glyph at `glyph`
3041−/// (its lane position and ANSI code) is meaning-coloured. A glyph two
3042−/// columns wide (`icons`) fills both
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
30432902/// characters of its lane, so the one after it is not drawn (§Glyphs).
30442903fn color_rails(
30452904 chars: &[char],
@@ -3063,72 +2922,17 @@ fn color_rails(
30632922 } else if lane0[i] {
30642923 s.push_str(&pal.blue(&t));
30652924 } else {
3066− s.push_str(&pal.rail(i / 2, &t));
2925+ s.push_str(&pal.dim(&t));
30672926 }
30682927 } else if lane0[i] {
30692928 s.push_str(&pal.blue(&t));
30702929 } else {
3071− s.push_str(&pal.rail(i / 2, &t));
2930+ s.push_str(&pal.dim(&t));
30722931 }
30732932 }
30742933 s
30752934}
30762935
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−
31322936/// `s` cut to display width `w`, ending with `…` when anything is cut (and
31332937/// there is a column for it): the message on a terminal (§Step 4). Colour
31342938/// escapes are kept whole and not counted, and those past the cut still
@@ -3244,18 +3048,20 @@ fn glyph_for(info: &CommitInfo, icons: bool) -> &'static str {
32443048/// The ANSI colour code for a node glyph, by meaning (§Colour: glyphs carry
32453049/// meaning; colour only adds emphasis). Mirrors the precedence of `glyph_for`.
32463050fn glyph_ansi(info: &CommitInfo) -> &'static str {
3247− node_ansi(glyph_for(info, false))
3248−}
3249−
3250−fn node_ansi(glyph: &str) -> &'static str {
3251− match glyph {
3252− "⌂" => "1;95", // root: bold magenta
3253− "⊗" => "91", // conflict: bright red
3254− "◌" => "2", // empty: dim
3255− "◆" => "94", // immutable: bright blue
3256− "◉" => "1;96", // focus: bold cyan
3257− "●" => "92", // ancestor of focus: bright green
3258− _ => "97", // other commit: bright white
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
32593065 }
32603066}
32613067
src/repo.rs+3−32
@@ -382,8 +382,7 @@ pub fn same_repo(a: &Value, b: &Value) -> Result<bool, Crash> {
382382}
383383
384384// ----------------------------------------------------------------------
385−// validation (§7.5 steps 1–3, the root invariant of 4, and 6), shared by
386−// `validate` and persistence
385+// validation (§7.5 steps 1–3 and 6), shared by `validate` and persistence
387386// ----------------------------------------------------------------------
388387
389388#[derive(Debug)]
@@ -418,29 +417,9 @@ pub fn validate_repo(i: &mut Interp, new: &Value) -> Result<Validated, Crash> {
418417 "the focus must be a mutable commit; compose with new",
419418 ));
420419 }
421− validate_root(old.as_ref(), new)?;
422420 Ok(Validated { immutable })
423421}
424422
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−
444423fn check_repo_shape(v: &Value) -> Result<(), Crash> {
445424 let fields = v
446425 .field_set()
@@ -481,9 +460,6 @@ fn validate_tree(tree: &Value, seen: &mut BTreeSet<String>) -> Result<(), Crash>
481460 id
482461 )));
483462 }
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()?;
487463 // a list loaded from a stored tree is a snapshot already: jj's tree
488464 // holds each name once, and lists a path as a file or as a directory.
489465 // Reading every commit's to check it made each persist read the files
@@ -499,18 +475,13 @@ fn validate_tree(tree: &Value, seen: &mut BTreeSet<String>) -> Result<(), Crash>
499475 Ok(())
500476}
501477
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).
478+/// unique paths, none of them the root `./`, none both a file and a
479+/// directory (§7.5 step 1)
504480fn validate_snapshot(files: &Value) -> Result<(), Crash> {
505481 let files = files.as_list()?;
506482 let mut paths = BTreeSet::new();
507483 for e in files {
508484 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− }
514485 let p = p.as_list()?;
515486 let key: Vec<String> = p
516487 .iter()
src/setup.rsdeleted−119
@@ -1,119 +0,0 @@
1−//! Explicit first-run configuration, independent of repository discovery.
2−
3−use j::ast::Item;
4−use j::config;
5−use j::eval::Interp;
6−use j::lex::{lex, Tok};
7−use j::parse::parse_config;
8−use j::show::show;
9−use j::value::Value;
10−use std::io::Write;
11−use std::path::Path;
12−use std::rc::Rc;
13−
14−const IDENTITY_EXAMPLE: &str = "user = { name = \"Ada Lovelace\", email = \"ada@example.org\" }";
15−
16−/// `args` contains only the arguments after `setup`. Existing files, including
17−/// invalid configs and symlinks, are never replaced. A new file is published
18−/// atomically so concurrent setup commands cannot overwrite one another.
19−pub fn run(path: &Path, default: &str, args: &[&str]) -> Result<String, (u8, String)> {
20− if !matches!(args.len(), 0 | 2) {
21− return Err((2, "usage: j setup [NAME EMAIL]; for example: j setup 'Ada Lovelace' ada@example.org; see `j help setup`".into()));
22− }
23− if path.symlink_metadata().is_ok() {
24− return existing(path, args);
25− }
26− let source = match args {
27− [name, email] => with_identity(default, name, email)?,
28− [] => default.to_string(),
29− _ => unreachable!(),
30− };
31− let parent = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or_else(|| Path::new("."));
32− std::fs::create_dir_all(parent)
33− .map_err(|error| (3, format!("cannot create config directory {}: {}", parent.display(), error)))?;
34− let mut temporary = tempfile::NamedTempFile::new_in(parent)
35− .map_err(|error| (3, format!("cannot prepare config at {}: {}", path.display(), error)))?;
36− temporary.write_all(source.as_bytes())
37− .map_err(|error| (3, format!("cannot write config at {}: {}", path.display(), error)))?;
38− match temporary.persist_noclobber(path) {
39− Ok(_) => {}
40− Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => return existing(path, args),
41− Err(error) => return Err((3, format!("cannot create config at {}: {}", path.display(), error.error))),
42− }
43− if args.is_empty() {
44− Ok(format!(
45− "Created the complete default config at {}.\n\nEdit the existing `user` definition in this file, replacing both example values with your identity:\n {}\nKeep the rest of the file: it defines your commands.\n\nThen run `j init` in your project directory, or `j clone URL`.\n",
46− path.display(), IDENTITY_EXAMPLE,
47− ))
48− } else {
49− Ok(format!(
50− "Configuration ready at {}.\nIdentity: {}\n\nNext: run `j init` in your project directory, or `j clone URL`.\n",
51− path.display(), identity_line(&source)?,
52− ))
53− }
54−}
55−
56−fn existing(path: &Path, args: &[&str]) -> Result<String, (u8, String)> {
57− if !args.is_empty() {
58− return Err((2, format!(
59− "config already exists at {}; it was not changed. Edit its existing `user` definition to change your identity; see `j help config`.",
60− path.display(),
61− )));
62− }
63− let problem = match std::fs::read_to_string(path) {
64− Ok(source) => match identity_line(&source) {
65− Ok(identity) => return Ok(format!(
66− "Configuration already exists at {} and is ready.\nIdentity: {}\n\nNext: run `j init` in your project directory, or `j clone URL`.\n",
67− path.display(), identity,
68− )),
69− Err((_, message)) => message,
70− },
71− Err(error) => format!("cannot read this config: {}", error),
72− };
73− Ok(format!(
74− "Configuration already exists at {}; it was not changed.\nNeeds attention: {}\n\nTo set your identity, edit the existing `user` definition:\n {}\nKeep the rest of the file and repair the problem above.\nSee `j help config`; then retry `j setup`.\n",
75− path.display(), problem, IDENTITY_EXAMPLE,
76− ))
77−}
78−
79−fn identity_line(source: &str) -> Result<String, (u8, String)> {
80− let cfg = config::load_config(source).map_err(|error| (3, error.message()))?;
81− j::jj::configured_identity(&cfg)
82−}
83−
84−fn with_identity(default: &str, name: &str, email: &str) -> Result<String, (u8, String)> {
85− let items = parse_config(default, Rc::new(config::reserved_set()))
86− .map_err(|error| (3, format!("cannot prepare default config: {}", error.msg)))?;
87− let line = items.iter().find_map(|item| match item {
88− Item::Definition(name, _, line) if name == "user" => Some(*line),
89− _ => None,
90− }).ok_or_else(|| (3, "default config has no `user` definition".into()))?;
91− let tokens = lex(default).map_err(|error| (3, error.msg))?;
92− let start = tokens.iter().position(|token| token.line == line && token.col == 1)
93− .ok_or_else(|| (3, "cannot locate the default `user` definition".into()))?;
94− let mut depth = 0usize;
95− let mut end = tokens[start].end;
96− for token in &tokens[start..] {
97− if matches!(token.tok, Tok::Eof)
98− || (token.line > line && token.col == 1 && depth == 0 && !matches!(token.tok, Tok::Newline))
99− {
100− break;
101− }
102− match token.tok {
103− Tok::LParen | Tok::LBracket | Tok::LBrace => depth += 1,
104− Tok::RParen | Tok::RBracket | Tok::RBrace => depth = depth.saturating_sub(1),
105− Tok::Newline => continue,
106− _ => {}
107− }
108− end = token.end;
109− }
110− let interp = Interp::dummy();
111− let name = show(&interp, &Value::text(name)).map_err(|error| (3, error.msg))?;
112− let email = show(&interp, &Value::text(email)).map_err(|error| (3, error.msg))?;
113− let definition = format!("user = {{ name = {}, email = {} }}", name, email);
114− let mut source = default.to_string();
115− source.replace_range(tokens[start].start..end, &definition);
116− // Reuse the same identity requirements as init/clone before writing a file.
117− identity_line(&source)?;
118− Ok(source)
119−}
src/show.rs+22−90
@@ -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, BlobKind, BlobVal, Crash, FunVal, ThunkVal, Value};
4+use crate::value::{BlobContent, 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(interp, b, out),
70+ Value::Blob(b) => return render_blob(b, out),
7171 Value::Shape(s) => out.push_str(&s.name),
7272 Value::Fun(f) => return render_fun(interp, f, out),
7373 }
@@ -84,43 +84,24 @@ fn render_int(n: &BigInt, out: &mut String) {
8484}
8585
8686#[inline(never)]
87−fn render_blob(interp: &Interp, b: &BlobVal, out: &mut String) -> Result<(), Crash> {
88− // Unresolved blobs render as marker text, explicitly outside the
89− // round-trip guarantee. Resolved blobs must preserve bytes and kind.
90− if b.is_unresolved() {
91− let bytes = b.bytes()?;
92− out.push_str("{- unresolved -} blob ");
93− push_text_literal(&String::from_utf8_lossy(&bytes), out);
94− return Ok(());
95− }
96− let bytes = match &b.content {
97− BlobContent::Resolved(bytes) => bytes.clone(),
98− BlobContent::Lazy(lazy) => lazy.force()?,
99− BlobContent::Conflict(_) => unreachable!("handled above"),
100− };
101− if b.kind == BlobKind::Regular {
102− if let Ok(text) = std::str::from_utf8(&bytes) {
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+ }
103101 out.push_str("blob ");
104− push_text_literal(text, out);
105− return Ok(());
102+ push_text_literal(&String::from_utf8_lossy(&bytes), out);
106103 }
107104 }
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);
124105 Ok(())
125106}
126107
@@ -455,13 +436,6 @@ pub fn difft(path: &Value, a: &Value, b: &Value) -> Result<Value, Crash> {
455436 .map(|c| c.as_text().map(|s| s.to_string()))
456437 .transpose()?
457438 .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− }
465439 let a_bytes = match a {
466440 Value::Blob(bl) => bl.bytes()?,
467441 v => return Err(Crash::new(format!("difft: expected a Blob, got a {}", v.kind_name()))),
@@ -472,14 +446,8 @@ pub fn difft(path: &Value, a: &Value, b: &Value) -> Result<Value, Crash> {
472446 };
473447 let dir = tempfile::tempdir()
474448 .map_err(|e| Crash::new(format!("difft: cannot create temp dir: {}", e)))?;
475− // Keep the filename unchanged: adding a prefix can exceed NAME_MAX for
476− // a valid tracked path, and some languages are detected by exact name.
477− let old_dir = dir.path().join("old");
478− let new_dir = dir.path().join("new");
479− std::fs::create_dir(&old_dir).map_err(|e| Crash::new(format!("difft: {}", e)))?;
480− std::fs::create_dir(&new_dir).map_err(|e| Crash::new(format!("difft: {}", e)))?;
481− let old = old_dir.join(name);
482− let new = new_dir.join(name);
449+ let old = dir.path().join(format!("old-{}", last));
450+ let new = dir.path().join(format!("new-{}", last));
483451 std::fs::write(&old, &a_bytes).map_err(|e| Crash::new(format!("difft: {}", e)))?;
484452 std::fs::write(&new, &b_bytes).map_err(|e| Crash::new(format!("difft: {}", e)))?;
485453 let mut cmd = std::process::Command::new("difft");
@@ -496,48 +464,12 @@ pub fn difft(path: &Value, a: &Value, b: &Value) -> Result<Value, Crash> {
496464 cmd.env("DFT_WIDTH", w.to_string());
497465 }
498466 }
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− };
467+ let out = cmd
468+ .output()
469+ .map_err(|_| Crash::new("difft: cannot execute `difft` (not on PATH)"))?;
507470 Ok(Value::text(String::from_utf8_lossy(&out.stdout).to_string()))
508471}
509472
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−
541473pub fn stdout_is_tty() -> bool {
542474 unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 }
543475}
src/terminal.rsdeleted−276
@@ -1,276 +0,0 @@
1−//! Interactive presentation. Rendered values stay free of cursor controls;
2−//! motion belongs only to the CLI's final output and temporary status line.
3−
4−use std::fmt;
5−use std::io::{self, IsTerminal, Write};
6−use std::sync::atomic::{AtomicBool, Ordering};
7−use std::sync::mpsc::{self, Sender};
8−use std::sync::{Arc, Mutex, Weak};
9−use std::thread::{self, JoinHandle};
10−use std::time::{Duration, Instant};
11−
12−use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
13−
14−static ACTIVE_PROGRESS: Mutex<Option<Weak<ProgressWorker>>> = Mutex::new(None);
15−static EXTERNAL_STDERR: AtomicBool = AtomicBool::new(false);
16−
17−fn stop_progress() {
18− let worker = ACTIVE_PROGRESS.lock().unwrap().as_ref().and_then(Weak::upgrade);
19− if let Some(worker) = worker {
20− worker.finish();
21− }
22−}
23−
24−/// A subprocess may write an unterminated diagnostic. Clear our status before
25−/// it starts, and leave stderr alone for the remainder of this run.
26−pub fn external_stderr() {
27− EXTERNAL_STDERR.store(true, Ordering::Relaxed);
28− stop_progress();
29−}
30−
31−fn nonempty_env(name: &str) -> bool {
32− std::env::var_os(name).is_some_and(|value| !value.is_empty())
33−}
34−
35−fn capable_terminal() -> bool {
36− !nonempty_env("NO_COLOR") && std::env::var("TERM").as_deref() != Ok("dumb")
37−}
38−
39−fn interactive() -> bool {
40− io::stdout().is_terminal()
41− && io::stderr().is_terminal()
42− && !nonempty_env("CI")
43−}
44−
45−fn motion_enabled() -> bool {
46− interactive()
47− && capable_terminal()
48− && !matches!(
49− std::env::var("J_ANIMATIONS")
50− .unwrap_or_default()
51− .to_ascii_lowercase()
52− .as_str(),
53− "never" | "off" | "false" | "0"
54− )
55−}
56−
57−/// Write a diagnostic without turning a closed stderr into a panic (§1.4).
58−pub fn report(args: fmt::Arguments<'_>) {
59− report_with_color(args, "1;96");
60−}
61−
62−pub fn report_error(args: fmt::Arguments<'_>) {
63− report_with_color(args, "1;91");
64−}
65−
66−fn report_with_color(args: fmt::Arguments<'_>, code: &str) {
67− stop_progress();
68− let mut out = io::stderr().lock();
69− if out.is_terminal() && capable_terminal() {
70− let message = args.to_string();
71− if let Some(rest) = message.strip_prefix("j: ") {
72− let _ = writeln!(out, "\x1b[{code}mj:\x1b[0m {rest}");
73− } else {
74− let _ = writeln!(out, "\x1b[2m{message}\x1b[0m");
75− }
76− } else {
77− let _ = writeln!(out, "{args}");
78− }
79−}
80−
81−/// Short confirmation for commands that otherwise have no terminal output.
82−/// Pipes and unattended runs keep their existing, quiet behavior.
83−pub fn success(message: &str) {
84− stop_progress();
85− if interactive() {
86− let mut out = io::stderr().lock();
87− if capable_terminal() {
88− let _ = writeln!(out, "\x1b[1;92m✓\x1b[0m \x1b[1;96mj:\x1b[0m {message}");
89− } else {
90− let _ = writeln!(out, "j: {message}");
91− }
92− }
93−}
94−
95−/// A delayed spinner, activity bar, and elapsed time. The bar is deliberately
96−/// indeterminate: elapsed time cannot tell us how much work remains.
97−/// Stopping wakes the worker immediately, so short operations have no extra
98−/// delay. Never hide or move the cursor
99−/// between rows: even an abrupt signal leaves the terminal usable.
100−struct Progress {
101− worker: Option<Arc<ProgressWorker>>,
102−}
103−
104−struct ProgressWorker {
105− stop: Sender<()>,
106− thread: Mutex<Option<JoinHandle<()>>>,
107−}
108−
109−impl ProgressWorker {
110− fn finish(&self) {
111− let _ = self.stop.send(());
112− if let Some(worker) = self.thread.lock().unwrap().take() {
113− let _ = worker.join();
114− }
115− }
116−}
117−
118−impl Progress {
119− fn start(label: &'static str) -> Self {
120− if !motion_enabled() || EXTERNAL_STDERR.load(Ordering::Relaxed) {
121− return Self { worker: None };
122− }
123− stop_progress();
124− let (send, receive) = mpsc::channel();
125− let started = Instant::now();
126− let worker = thread::Builder::new().name("j-progress".into()).spawn(move || {
127− if receive.recv_timeout(Duration::from_millis(140)) != Err(mpsc::RecvTimeoutError::Timeout) {
128− return;
129− }
130− let colors = ["95", "96", "93", "92", "94"];
131− let mut frame = 0;
132− loop {
133− // Fit each new frame to the current terminal width, including
134− // after a resize. Leave one column spare at the edge.
135− let width = stderr_width().saturating_sub(1);
136− let line = progress_line(label, frame, started.elapsed(), width);
137− {
138− let mut out = io::stderr().lock();
139− if write!(
140− out,
141− "\r\x1b[2K\x1b[1;{}m{}\x1b[0m",
142− colors[frame % colors.len()],
143− line
144− )
145− .and_then(|()| out.flush())
146− .is_err()
147− {
148− break;
149− }
150− }
151− frame += 1;
152− if receive.recv_timeout(Duration::from_millis(75)) != Err(mpsc::RecvTimeoutError::Timeout) {
153− break;
154− }
155− }
156− let mut out = io::stderr().lock();
157− let _ = write!(out, "\r\x1b[2K").and_then(|()| out.flush());
158− });
159− let worker = worker.ok().map(|worker| Arc::new(ProgressWorker {
160− stop: send,
161− thread: Mutex::new(Some(worker)),
162− }));
163− *ACTIVE_PROGRESS.lock().unwrap() = worker.as_ref().map(Arc::downgrade);
164− Self { worker }
165− }
166−}
167−
168−impl Drop for Progress {
169− fn drop(&mut self) {
170− if let Some(worker) = self.worker.take() {
171− worker.finish();
172− }
173− }
174−}
175−
176−/// Finish the temporary line before the caller reports success or failure.
177−pub fn with_progress<T>(label: &'static str, operation: impl FnOnce() -> T) -> T {
178− let _progress = Progress::start(label);
179− operation()
180−}
181−
182−fn progress_line(label: &str, frame: usize, elapsed: Duration, width: usize) -> String {
183− const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
184− let mut line = format!("{} j · {}", FRAMES[frame % FRAMES.len()], label);
185− let seconds = elapsed.as_secs();
186− let time = if seconds < 60 {
187− format!("{}.{}s", seconds, elapsed.subsec_millis() / 100)
188− } else {
189− format!("{}m {:02}s", seconds / 60, seconds % 60)
190− };
191− // Bounce a three-cell pulse across a ten-cell track. It never fills up
192− // or suggests a completion percentage for work with no known total.
193− let step = frame % 14;
194− let start = step.min(14 - step);
195− let bar: String = (0..10)
196− .map(|cell| if (start..start + 3).contains(&cell) { '━' } else { '·' })
197− .collect();
198− let details = format!(" [{bar}] {time}");
199− if line.width() + details.width() <= width {
200− line.push_str(&details);
201− } else if line.width() + 1 + time.width() <= width {
202− // On small terminals, keep the operation's name ahead of decoration.
203− line.push(' ');
204− line.push_str(&time);
205− }
206− let mut used = 0;
207− line.chars()
208− .take_while(|c| {
209− used += c.width().unwrap_or(0);
210− used <= width
211− })
212− .collect()
213−}
214−
215−fn stderr_width() -> usize {
216− // The two streams can be attached to different terminals. Measure the
217− // one that receives the progress line, rather than the tree's stdout.
218− unsafe {
219− let mut size: libc::winsize = std::mem::zeroed();
220− if libc::ioctl(libc::STDERR_FILENO, libc::TIOCGWINSZ, &mut size) == 0
221− && size.ws_col > 0
222− {
223− size.ws_col as usize
224− } else {
225− 80
226− }
227− }
228−}
229−
230−/// Reveal colored, multiline terminal output in a few quick batches. Never
231−/// split a line (and therefore an ANSI style), alter its bytes, or animate
232−/// plain text. The total pacing is at most 96 ms, even for very large trees.
233−pub fn write_output(out: &mut impl Write, text: &str) -> io::Result<()> {
234− if !motion_enabled() || !text.contains("\x1b[") {
235− return out.write_all(text.as_bytes()).and_then(|()| out.flush());
236− }
237− let lines = text.bytes().filter(|&byte| byte == b'\n').count()
238− + usize::from(!text.ends_with('\n'));
239− let batch = lines.div_ceil(7).max(1);
240− let mut start = 0;
241− let mut end = 0;
242− for (index, line) in text.split_inclusive('\n').enumerate() {
243− end += line.len();
244− if (index + 1) % batch == 0 && index + 1 < lines {
245− out.write_all(text[start..end].as_bytes())?;
246− out.flush()?;
247− start = end;
248− thread::sleep(Duration::from_millis(16));
249− }
250− }
251− out.write_all(text[start..].as_bytes())?;
252− out.flush()
253−}
254−
255−#[cfg(test)]
256−mod tests {
257− use super::*;
258−
259− #[test]
260− fn progress_fits_terminal_cells_and_keeps_the_operation_visible() {
261− let label = "Reading 仓库";
262− for width in 0..80 {
263− for frame in 0..20 {
264− let line = progress_line(label, frame, Duration::from_millis(1234), width);
265− assert!(line.width() <= width, "{width} columns: {line:?}");
266− if width >= 18 {
267− assert!(line.contains(label), "operation name lost: {line:?}");
268− }
269− }
270− }
271− let line = progress_line("Reading repository", 0, Duration::from_millis(1234), 80);
272− assert!(line.contains("[━━━·······] 1.2s"), "{line:?}");
273− assert!(progress_line("Reading repository", 0, Duration::from_secs(65), 80)
274− .ends_with("1m 05s"));
275− }
276−}
src/value.rs+14−314
@@ -3,7 +3,7 @@
33use crate::ast::{Expr, Pattern};
44use num_bigint::BigInt;
55use std::collections::{BTreeMap, BTreeSet, HashMap};
6−use std::rc::{Rc, Weak};
6+use std::rc::Rc;
77
88pub type RecordMap = BTreeMap<String, Value>;
99
@@ -764,10 +764,10 @@ pub struct Env {
764764 frame: Option<Rc<Frame>>,
765765}
766766
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).
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).
771771impl Drop for Env {
772772 fn drop(&mut self) {
773773 let mut next = self.frame.take();
@@ -820,20 +820,16 @@ 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();
826823 let cell = Rc::new(std::cell::RefCell::new(Vec::with_capacity(names.len())));
827− let frame = Rc::new(Frame {
828− kind: FrameKind::Rec(names, cell.clone()),
829− parent: self.clone(),
830− });
831− RECURSIVE_FRAMES.with(|registry| {
832− let mut registry = registry.borrow_mut();
833− registry.frames.push(Rc::downgrade(&frame));
834− registry.allocations += 1;
835− });
836− (Env { frame: Some(frame) }, cell)
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+ )
837833 }
838834
839835 pub fn lookup(&self, name: &str) -> Option<Value> {
@@ -907,302 +903,6 @@ impl Env {
907903 }
908904}
909905
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−
1206906// ----------------------------------------------------------------------
1207907// structural equality (§4.5); comparing functions is a crash
1208908// ----------------------------------------------------------------------
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 changed paths with difft or the built-in fallback;
417− // "no changes" is for a focus that changes nothing.
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
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,32 +81,6 @@ fn arithmetic() {
8181 check!(i, cfg, "3 >= 4", Value::Bool(false));
8282}
8383
84−#[test]
85−fn blob_hex_decodes_exact_bytes_and_file_type() {
86− use j::value::BlobKind;
87− let (mut i, cfg) = make_interp();
88− for (name, kind) in [
89− ("regular", BlobKind::Regular),
90− ("executable", BlobKind::Executable),
91− ("symlink", BlobKind::Symlink),
92− ] {
93− for (hex, bytes) in [("", vec![]), ("00ff80A5", vec![0, 255, 128, 165])] {
94− let v = ok(&mut i, &cfg, &format!("blobHex \"{}\" \"{}\"", name, hex));
95− let Value::Blob(b) = v else { panic!("expected Blob") };
96− assert_eq!(b.kind, kind);
97− assert_eq!(b.bytes().unwrap(), bytes);
98− assert!(!b.is_unresolved());
99− }
100− }
101− for hex in ["f", "xyz", "0g", "é", "00 ff"] {
102− let msg = crash(&mut i, &cfg, &format!("blobHex \"regular\" \"{}\"", hex));
103− assert!(msg.contains("invalid hexadecimal content"), "{}", msg);
104− }
105− assert!(crash(&mut i, &cfg, "blobHex \"directory\" \"\"").contains("kind must be"));
106− assert!(crash(&mut i, &cfg, "blobHex 1 \"00\"").contains("expected Text"));
107− assert!(crash(&mut i, &cfg, "blobHex \"regular\" 1").contains("expected Text"));
108−}
109−
11084#[test]
11185fn identity_and_const() {
11286 let (mut i, cfg) = make_interp();
tests/cli.rs+10−241
@@ -59,15 +59,6 @@ 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−
7162 fn j(&self, args: &[&str]) -> Out {
7263 let out = Command::new(j_bin())
7364 .args(args)
@@ -199,53 +190,6 @@ fn both_stdin_and_args_rejected() {
199190 assert!(out.stderr.contains("both"), "{}", out.stderr);
200191}
201192
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−
249193#[test]
250194fn an_expression_that_is_not_utf8_is_a_usage_error() {
251195 // §1: an argument that was not UTF-8 (a path with a Latin-1 byte)
@@ -316,8 +260,8 @@ fn parse_error_is_exit_3() {
316260
317261#[test]
318262fn deep_nesting_is_a_parse_error() {
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.
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
321265 let r = setup();
322266 let n = 100_000;
323267 // a left-associative chain too: the parser builds it in a loop, but its
@@ -330,8 +274,7 @@ fn deep_nesting_is_a_parse_error() {
330274 ] {
331275 let out = r.j_stdin(&src, &[]);
332276 assert_eq!(out.code, 3, "{}", 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());
277+ assert!(out.stderr.starts_with("j: ") && out.stderr.lines().count() == 1, "{}", out.stderr);
335278 }
336279}
337280
@@ -473,69 +416,9 @@ fn validate_catches_what_persistence_refuses() {
473416 let r = setup();
474417 r.write("a.txt", "x\n");
475418 r.j(&["describe \"work\""]).ok();
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();
419+ // tree . validate . squash is an honest dry run
492420 let out = r.j(&["tree . validate . squash"]).ok();
493421 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− }
539422}
540423
541424#[test]
@@ -721,8 +604,8 @@ fn text_result_prints_raw() {
721604
722605#[test]
723606fn review_says_no_changes_only_when_nothing_changed() {
724− // A missing renderer must still display changes; an installed renderer's
725− // failures must remain visible instead of claiming "no changes".
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
726609 let r = setup();
727610 // a PATH holding nothing but what the test puts there (the config
728611 // directory is removed with the repository)
@@ -747,9 +630,8 @@ fn review_says_no_changes_only_when_nothing_changed() {
747630 assert_eq!(review(&bin).ok().stdout, "no changes\n");
748631 r.write("f", "hello\n");
749632 let out = review(&bin);
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);
633+ assert_eq!(out.code, 1, "stdout: {}", out.stdout);
634+ assert!(out.stderr.contains("cannot execute `difft`"), "{}", out.stderr);
753635 use std::os::unix::fs::PermissionsExt;
754636 // a difft that runs and fails renders what it printed, nothing here,
755637 // and its complaint reaches the terminal instead of being dropped (§7.10)
@@ -767,7 +649,7 @@ fn review_says_no_changes_only_when_nothing_changed() {
767649 std::fs::write(&stub, "#!/bin/sh\nprintf '%s -> %s\\n' \"${1##*/}\" \"${2##*/}\"\n").unwrap();
768650 std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
769651 r.write("g", "world\n");
770− assert_eq!(review(&bin).ok().stdout, "f -> f\ng -> g\n");
652+ assert_eq!(review(&bin).ok().stdout, "old-f -> new-f\nold-g -> new-g\n");
771653}
772654
773655#[test]
@@ -3351,92 +3233,6 @@ fn init_refuses_inside_existing_repo() {
33513233 assert!(out.stderr.contains("already"), "{}", out.stderr);
33523234}
33533235
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−
34403236#[test]
34413237fn missing_config_is_created_editable() {
34423238 let dir = std::env::temp_dir().join(format!(
@@ -3463,7 +3259,7 @@ fn missing_config_is_created_editable() {
34633259 // the config now exists at the user path and is editable by the owner
34643260 let cfg = cfg_home.join("j/config.j");
34653261 let written = std::fs::read_to_string(&cfg).expect("config was created");
3466− assert_eq!(written, include_str!("../config.j"));
3262+ assert!(written.contains("treeWith"), "default config content");
34673263 #[cfg(unix)]
34683264 {
34693265 use std::os::unix::fs::PermissionsExt;
@@ -3472,33 +3268,6 @@ fn missing_config_is_created_editable() {
34723268 let _ = std::fs::remove_dir_all(&dir);
34733269}
34743270
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−
35023271// ----------------------------------------------------------------------
35033272// regressions
35043273// ----------------------------------------------------------------------
tests/difft.rsdeleted−74
@@ -1,74 +0,0 @@
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+1−248
@@ -95,16 +95,6 @@ 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 {
10898 let outer = Rc::new(cfg.global_names.clone());
10999 let e = parse_expr(src, outer).unwrap();
110100 let e = config::resolve_ids(&e, interp).unwrap();
@@ -116,70 +106,7 @@ fn eval_and_display_width(
116106 } else {
117107 v
118108 };
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);
109+ j::render::display(interp, &v, false).unwrap()
183110}
184111
185112#[test]
@@ -263,57 +190,6 @@ fn text_of_blob() {
263190 assert_eq!(out, "fn lex() {}\n");
264191}
265192
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−
317193#[test]
318194fn dry_run_tree_after_squash() {
319195 let (repo, be) = sample_repo();
@@ -1125,126 +1001,3 @@ fn an_unreadable_blob_crashes_every_time() {
11251001 let src = "(\\repo -> show (contentAt [\"f.txt\"] (files repo)) or \"caught\")";
11261002 assert_eq!(try_eval_and_display(&mut i, &cfg, src, repo), Ok("caught\n".to_string()));
11271003}
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.rsdeleted−215
@@ -1,215 +0,0 @@
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+3−146
@@ -10,14 +10,10 @@ use std::rc::Rc;
1010const CONFIG: &str = include_str!("../config.j");
1111
1212fn make_interp() -> (Interp, config::Config) {
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");
13+ let cfg = config::load_config(CONFIG).expect("reference config must load");
1814 let backend = Rc::new(MemBackend::new());
1915 let mut interp = Interp::new(backend, cfg.shapes.clone(), Env::empty());
20− config::eval_config(&mut interp, &cfg).expect("config must evaluate");
16+ config::eval_config(&mut interp, &cfg).expect("reference config must evaluate");
2117 (interp, cfg)
2218}
2319
@@ -131,143 +127,6 @@ fn short_circuit_booleans() {
131127 assert!(crash(&mut i, &cfg, "false || 1").contains("Bool"));
132128}
133129
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−
271130#[test]
272131fn if_requires_bool() {
273132 let (mut i, cfg) = make_interp();
@@ -860,9 +719,7 @@ fn crash_names_the_innermost_definition_executing() {
860719 ("sameAsInner 0", Some("inner")),
861720 ("sameAsInner \"x\"", Some("sameAsInner")),
862721 (prev.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")),
722+ (next.as_str(), Some("goto")),
866723 // a builtin is no definition: outside every definition, none
867724 ("head []", None),
868725 ("crash \"top\"", None),
tests/help.rs+5−121
@@ -35,19 +35,6 @@ 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− }
5138}
5239
5340fn stdout(output: Output) -> String {
@@ -91,7 +78,7 @@ fn overview_and_reserved_topics_survive_broken_config() {
9178 cli.write_config("broken =\n");
9279 stdout(cli.run(&["help"]));
9380 for name in [
94− "init", "clone", "remote", "fetch", "push", "undo", "redo", "ops", "help", "commands", "setup",
81+ "init", "clone", "remote", "fetch", "push", "undo", "redo", "ops", "help", "commands",
9582 ] {
9683 assert!(stdout(cli.run(&["help", name])).contains(name));
9784 }
@@ -175,7 +162,7 @@ fn piped_help_and_expression_comments_preserve_input_rules() {
175162 stdout(cli.run(&["help", "push"]))
176163 );
177164
178− cli.reference_config();
165+ cli.write_config(include_str!("../config.j"));
179166 stdout(cli.run(&["init"]));
180167 assert_eq!(stdout(cli.run(&["\"--help\""])), "--help\n");
181168 assert_eq!(stdout(cli.run(&["(help) or 42"])), "42\n");
@@ -187,9 +174,9 @@ fn piped_help_and_expression_comments_preserve_input_rules() {
187174fn help_does_not_take_repository_lock_or_snapshot_changes() {
188175 use std::os::fd::AsRawFd;
189176 let cli = HelpCli::new();
190− cli.reference_config();
177+ cli.write_config(include_str!("../config.j"));
191178 stdout(cli.run(&["init"]));
192− let before = cli.operation_heads();
179+ let before = stdout(cli.run(&["ops"]));
193180 std::fs::write(cli.dir.path().join("work.txt"), "unsaved work\n").unwrap();
194181 let lock = std::fs::OpenOptions::new()
195182 .create(true)
@@ -205,49 +192,13 @@ fn help_does_not_take_repository_lock_or_snapshot_changes() {
205192 stdout(cli.run(&["help", "new"]));
206193 stdout(cli.run(&["commands", "Edit"]));
207194 drop(lock);
208− assert_eq!(cli.operation_heads(), before);
195+ assert_eq!(stdout(cli.run(&["ops"])), before);
209196 assert_eq!(
210197 std::fs::read_to_string(cli.dir.path().join("work.txt")).unwrap(),
211198 "unsaved work\n"
212199 );
213200}
214201
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−
251202#[test]
252203fn help_read_failures_do_not_fall_back_to_reference_commands() {
253204 let cli = HelpCli::new();
@@ -257,70 +208,3 @@ fn help_read_failures_do_not_fall_back_to_reference_commands() {
257208 assert!(String::from_utf8_lossy(&output.stderr).contains("cannot read config"));
258209 stdout(cli.run(&["help", "clone"]));
259210}
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+10−173
@@ -92,63 +92,6 @@ 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−
15295#[test]
15396fn jj_reads_js_commits() {
15497 let Some(t) = setup() else { return };
@@ -222,137 +165,31 @@ fn jj_undo_interop_marker() {
222165 assert!(ops.contains("undo"), "{}", ops);
223166}
224167
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−
329168#[test]
330169fn jj_reads_conflicts_written_by_j() {
331170 let Some(t) = setup() else { return };
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");
171+ t.j(&["init"]);
337172 std::fs::write(t.dir.join("f.txt"), "base\n").unwrap();
338− j_ok("describe \"base\"");
173+ t.j(&["describe \"base\""]);
339174 // side 1
340− j_ok("new");
175+ t.j(&["new"]);
341176 std::fs::write(t.dir.join("f.txt"), "one\n").unwrap();
342− j_ok("describe \"one\"");
177+ t.j(&["describe \"one\""]);
343178 // side 2 from the parent
344− j_ok("new . prev");
179+ t.j(&["new . goto (kids (top id))"]);
345180 std::fs::write(t.dir.join("f.txt"), "two\n").unwrap();
346− j_ok("describe \"two\"");
181+ t.j(&["describe \"two\""]);
347182 // rebase two onto one: conflicts
348− j_ok("rebase (matching (\\c -> c.message == \"one\") all)");
183+ let (code, _, err) = t.j(&["rebase (matching (\\c -> c.message == \"one\") all)"]);
184+ assert_eq!(code, 0, "{}", err);
349185 // jj sees the conflict
350186 let (code, log, _) = t.jj(&["log", "--no-pager"]);
351187 assert_eq!(code, 0);
352188 assert!(log.contains("conflict"), "{}", log);
353189 // resolve via jj's working copy and j records it
354190 std::fs::write(t.dir.join("f.txt"), "resolved\n").unwrap();
355− j_ok("id");
191+ let (code, _, _) = t.j(&["id"]);
192+ assert_eq!(code, 0);
356193 let (code, log, _) = t.jj(&["log", "--no-pager"]);
357194 assert_eq!(code, 0);
358195 assert!(!log.contains("conflict"), "{}", log);
tests/onboarding.rsdeleted−200
@@ -1,200 +0,0 @@
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.rsdeleted−153
@@ -1,153 +0,0 @@
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+10−462
@@ -29,15 +29,6 @@ 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−
4132struct Env {
4233 dir: PathBuf,
4334 remote: PathBuf,
@@ -158,315 +149,6 @@ impl Drop for Env {
158149 }
159150}
160151
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−
470152#[test]
471153fn clone_sets_origin_and_labels() {
472154 let env = setup();
@@ -1271,26 +953,6 @@ fn push_rename() {
1271953 assert!(!branches.contains("old"), "{}", branches);
1272954}
1273955
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−
1294956#[test]
1295957fn push_refusals() {
1296958 let env = setup();
@@ -1311,84 +973,6 @@ fn push_refusals() {
1311973 assert_eq!(out.code, 0, "{}", out.stderr);
1312974}
1313975
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−
1392976#[test]
1393977fn push_of_an_empty_list_pushes_nothing() {
1394978 // §7.6: an empty list of records sets and deletes no bookmark. It went to
@@ -1405,37 +989,29 @@ fn push_of_an_empty_list_pushes_nothing() {
1405989 git(&work, &["add", "l.txt"]);
1406990 git(&work, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qm", "local unpushed"]);
1407991 env.j(&work, &["init"]).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);
992+ env.j(&work, &["push []"]).ok();
1412993 assert_eq!(master(), before, "push [] moved the remote's master");
1413− assert_eq!(operation_heads(&work), before_heads, "push [] recorded an operation");
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);
1414997
1415998 // a clone's git branch has no upstream
1416999 let dest = env.dir.join("clone");
14171000 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
14181001 env.j(&dest, &["describe \"x\""]).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);
1002+ env.j(&dest, &["push []"]).ok();
1003+ env.j(&dest, &["push (label \"nope\" (labelled \"zzz\"))"]).ok();
14251004 assert_eq!(master(), before);
14261005 let refs = git(&env.remote, &["for-each-ref", "--format=%(refname)"]);
14271006 assert_eq!(refs.trim(), "refs/heads/master");
14281007
1429− // No selection needs no remote, network connection, or operation.
1008+ // without origin it still exits 1
14301009 let plain = env.dir.join("plain");
14311010 std::fs::create_dir_all(&plain).unwrap();
14321011 env.j(&plain, &["init"]).ok();
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);
1012+ let out = env.j(&plain, &["push []"]);
1013+ assert_eq!(out.code, 1);
1014+ assert!(out.stderr.contains("no remote origin"), "{}", out.stderr);
14391015}
14401016
14411017#[test]
@@ -2109,34 +1685,6 @@ fn fetch_without_origin_is_exit_1() {
21091685 assert!(out.stderr.contains("no remote origin"), "{}", out.stderr);
21101686}
21111687
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−
21401688#[test]
21411689fn remote_command_sets_url() {
21421690 let env = setup();
tests/review_fallback.rsdeleted−110
@@ -1,110 +0,0 @@
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.rsdeleted−144
@@ -1,144 +0,0 @@
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+1−147
@@ -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, Crash, Env, LazyBlob, Value};
8+use j::value::{value_eq, BlobContent, BlobKind, BlobVal, ConflictSide, Env, Value};
99use std::alloc::{GlobalAlloc, Layout, System};
1010use std::cell::Cell;
1111use std::rc::Rc;
@@ -111,89 +111,6 @@ 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−
197114#[test]
198115fn show_record_field_order() {
199116 let (i, _cfg) = make_interp();
@@ -317,69 +234,6 @@ fn show_unresolved_blob() {
317234 assert!(s.starts_with("{- unresolved -} blob"), "{}", s);
318235}
319236
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−
383237#[test]
384238fn show_line_breaking() {
385239 let (i, _cfg) = make_interp();
tests/showprop.rs+2−8
@@ -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, BlobContent, BlobKind, BlobVal, Env, Value};
10+use j::value::{value_eq, BlobVal, Env, Value};
1111use proptest::prelude::*;
1212use std::rc::Rc;
1313
@@ -25,13 +25,7 @@ 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− (
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− }))),
28+ Just(BlobVal::text_blob("blob content")),
3529 ];
3630 leaf.prop_recursive(depth as u32, 24, 6, |inner| {
3731 prop_oneof![
tests/terminal.rsdeleted−621
@@ -1,621 +0,0 @@
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+10−79
@@ -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 + if lanes < 3 { 4 } else { 0 }; WORKED_ROWS.len()], "{}", ctx);
1127+ assert_eq!(id_cols(&text), vec![2 + 2 * lanes + 1; 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 + if lanes < 3 { 4 } else { 0 }; WORKED_ROWS.len()], "lanes {}\n{}", lanes, icons);
1168+ assert_eq!(id_cols(&icons), vec![2 + 2 * lanes + 1; 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 + if lanes <= 3 { 4 } else { 0 }, "lanes {}\n{}", lanes, text);
1516+ assert_eq!(id, 2 + 2 * lanes + 1, "lanes {}\n{}", lanes, text);
15171517 }
15181518}
15191519
@@ -1694,72 +1694,3 @@ 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.rsdeleted−105
@@ -1,105 +0,0 @@
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+4−172
@@ -1,9 +1,9 @@
1−//! Persistence validation tests (§7.5 steps 1–4, 6) and config validation (§6.2).
1+//! Persistence validation tests (§7.5 steps 1–3, 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::{BlobContent, BlobKind, BlobVal, ConflictSide, Crash, Env, LazyBlob, ThunkVal, Value};
6+use j::value::{Crash, Env, ThunkVal, Value};
77use std::rc::Rc;
88
99const CONFIG: &str = include_str!("../config.j");
@@ -45,10 +45,6 @@ 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 {
5248 let a = commit("kaaaaaaa", "a", labels_a, vec![]);
5349 let frame = Value::record(&[
5450 ("left", Value::list(vec![])),
@@ -99,62 +95,6 @@ fn valid_repo_passes() {
9995 assert!(validate_with(&old, &old).is_ok());
10096}
10197
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−
15898#[test]
15999fn duplicate_ids_rejected() {
160100 let old = two_commit_repo(&[]);
@@ -193,57 +133,6 @@ fn focus_with_files(files: Vec<Value>) -> Value {
193133 ])
194134}
195135
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−
247136fn entry_at(path: &[&str], content: &str) -> Value {
248137 Value::record(&[
249138 ("content", j::value::BlobVal::text_blob(content)),
@@ -743,7 +632,8 @@ fn snapshots_left_as_stored_are_not_read() {
743632}
744633
745634#[test]
746−fn persistence_requires_a_repo() {
635+fn root_must_stay_on_top() {
636+ // covered implicitly by navigation invariants; validate shape only here
747637 let old = two_commit_repo(&[]);
748638 let not_repo = Value::record(&[("root", old.field("root").unwrap())]);
749639 let mut i = make_interp(backend_with(&[]));
@@ -752,64 +642,6 @@ fn persistence_requires_a_repo() {
752642 assert!(e.msg.contains("not a Repo"), "{}", e.msg);
753643}
754644
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−
813645// ------------------------------------------------------------------
814646// config validation (§6.2)
815647// ------------------------------------------------------------------