on master help sign in
Improve CLI safety, onboarding, and workflow feedback (#9)

Co-authored-by: Montelot <montelot-dev@typeable.io>

commit3ef07529a79363ecf9efc17f868dc0170b7586af authoriko <mail@iko.soy> dateSun 11 Oct 2026, 04:09 (3 days ago) parent7c7e0b3
34 files changed+3677−426
unifiedsplit
CLAUDE.md+10−6 README.md+134−38 config.j+73−18 docs/base.md+110−36 docs/comparison.md+25−9 docs/language.md+43−15 docs/tutorial.md+102−36 specs/main.md+279−72 specs/tree.md+36−22 src/builtins.rs+28 src/config.rs+1−1 src/diagnostics.rs+381 src/domain.rs+6 src/help.rs+189−41 src/jj.rs+182−30 src/lib.rs+1 src/main.rs+194−34 src/render.rs+188−22 src/setup.rs+119 src/show.rs+38−3 src/terminal.rs+7−5 tests/base.rs+2−2 tests/cli.rs+9−7 tests/display.rs+134−1 tests/eval.rs+3−1 tests/help.rs+121−5 tests/onboarding.rs+200 tests/onboarding_vocab.rs+153 tests/remote.rs+299−10 tests/review_fallback.rs+110 tests/setup.rs+144 tests/terminal.rs+172−2 tests/tree.rs+79−10 tests/ux_cli.rs+105
CLAUDE.md+10−6
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
44
55## What this is
66
7−`j` is a CLI for Jujutsu (jj) repositories whose interface is a small, pure, dynamically typed functional language. A repository is a value (`Repo`, a zipper over the commit tree), a command is a function `Repo -> Repo`, and the whole user-facing vocabulary (`new`, `describe`, `squash`, `tree`, …) is written *in the language* in `config.j`. The Rust binary supplies only the language, the builtins `config.j` declares, ten reserved commands, and the bridge to jj's on-disk format via `jj-lib` (no `jj` binary needed at runtime).
7+`j` is a CLI for Jujutsu (jj) repositories whose interface is a small, pure, dynamically typed functional language. A repository is a value (`Repo`, a zipper over the commit tree), a command is a function `Repo -> Repo`, and the whole user-facing vocabulary (`new`, `describe`, `squash`, `tree`, …) is written *in the language* in `config.j`. The Rust binary supplies only the language, the builtins `config.j` declares, eleven reserved commands, and the bridge to jj's on-disk format via `jj-lib` (no `jj` binary needed at runtime).
88
99## Commands
1010
@@ -21,18 +21,17 @@ There is no devShell in `flake.nix`; `cargo`/`rustc` come from the system. The N
2121- `tests/cli.rs`, `tests/remote.rs`, `tests/interop.rs` run the built binary (`CARGO_BIN_EXE_j`) against real repos in temp dirs, each with its own `XDG_CONFIG_HOME`. They need `git` on `PATH`; `interop.rs` also uses the real `jj` binary and skips itself if it is absent.
2222- Everything else runs the interpreter over the in-memory `MemBackend` — no repository involved.
2323- `tests/laws.rs` and `tests/showprop.rs` are proptest suites; their `*.proptest-regressions` files are checked in on purpose.
24−- `difft` is only needed at runtime for `review`/`difft`. `J_GIT` overrides the `git` executable used for fetch.
24+- `difft` optionally enhances `review`/`difft`; an absent executable falls back to unified diffs with path headers (binary summaries for non-text content). Other launch errors still fail. `J_GIT` overrides the `git` executable used for fetch.
2525
2626## The spec is normative
2727
2828`specs/main.md` is the specification the implementation was written from, and the code cites it everywhere as `§N.M` (e.g. `§7.5` = persistence). When changing behaviour, find the section first, and keep code, spec, and tests agreeing. `specs/tree.md` is a newer, complete replacement for `§7.11` (the "rails" tree layout) and is what `render.rs` implements.
2929
30−Known drift to be aware of (not to copy):
31−- `§9` of the spec is a verbatim copy of `config.j`, but `config.j` is ahead of it (`touchedPaths`, the extra `treeWith` options, `treeCompact`, `treeData`). **`config.j` is the source of truth**; `docs/base.md` lags the same way.
30+`§9` of the spec is a verbatim copy of `config.j`; keep it synchronized when the reference vocabulary changes. **`config.j` is the source of truth** for runnable definitions, and `docs/base.md` documents the same vocabulary.
3231
3332## Architecture
3433
35−One run of `j EXPR` (`src/main.rs::run_expression`, mirroring spec §1.2): load + validate `config.j` → open the repo and take the lock → parse the expression → build the `Repo` value, snapshotting the working directory → resolve `@id` literals → evaluate the config's definitions, then the expression → if the result is a function, apply it to the repo **once** → if the final value is a `Repo` that differs from the one loaded, persist it; otherwise display it and persist nothing. Exit codes: 1 crash, 2 usage/no repo/locked, 3 config or parse error. All work happens on a thread with a 512 MB stack (`main`).
34+One run of `j EXPR` (`src/main.rs::run_expression`, mirroring spec §1.2): load + validate `config.j` → open the repo and take the lock → parse the expression → build the `Repo` value, snapshotting the working directory → resolve `@id` literals → evaluate the config's definitions, then the expression → if the result is a function, apply it to the repo **once** → if the final value is a `Repo`, persist only if it differs from the one loaded; display other values without persisting. Exit codes: 1 crash, 2 usage/no repo/locked, 3 config or parse error. All work happens on a thread with a 512 MB stack (`main`).
3635
3736Language pipeline, all in `src/`:
3837- `lex.rs` → `parse.rs` → `ast.rs`: the parser implements the two layout rules and needs the set of global names (`Config::global_names`) so expressions cannot shadow them.
@@ -43,14 +42,19 @@ Language pipeline, all in `src/`:
4342- `builtins.rs`: native implementations of the names `config.j` declares without defining.
4443- `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`.
4544
46−`help [NAME]` and `commands [QUERY]` are reserved discovery commands; `--help` and `-h` alias `help` only as the first word. They never open a repository or evaluate config definitions. General and reserved-command help work without config; the custom vocabulary reads config without writing it, using the embedded reference only when no file exists. Keep these paths usable outside a repository and when general help is needed to recover from a broken config.
45+`help [NAME]` and `commands [QUERY]` are reserved discovery commands. `--help` and `-h` open help as the first word or as separate argv entries after a command, before evaluation; comments within a single expression argument or stdin retain language semantics. Discovery never opens a repository or evaluates config definitions. General help, reserved-command help, and `help config` work without loading config; the custom vocabulary reads config without writing it, using the embedded reference only when no file exists. Keep these paths usable outside a repository and when help is needed to recover from a broken config. Literal reserved-command arguments retain separate shell argument boundaries; a whole command supplied as one string or stdin is whitespace-split.
46+
47+Identity validation rejects blank and shipped placeholder values at init, clone, and commit-authoring boundaries. Inspections remain usable without a configured identity. Push never records the working directory: it warns about unrecorded edits before sending recorded content. Undo/redo refuse dirty working copies and explain that `j id` adds an operation before the previously intended undo. Interactive success messages report saved/no-change status and focus, setup identity, or the affected undo/redo operation; plain terminals keep confirmations, while redirected output and CI remain quiet on success.
4748
4849Repository 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.
4951- `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.
5052- `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.
5153- `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.
5254- `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.
5355
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+
5458### `config.j` is code, and it is embedded
5559
5660`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+134−38
@@ -31,22 +31,47 @@ functions.
3131
3232```
3333j help -- a quick start, grouped commands, and examples
34−j commands -- browse the vocabulary in your config
34+j commands -- common tasks and how to do them
35+j commands all -- the full vocabulary in your config
3536j commands rebase -- search names, signatures, and descriptions
3637j help describe -- see a command's signature, documentation, and examples
3738j help push -- learn a reserved command's usage
39+j help config -- find your config and set your identity
3840```
3941
40−`j --help` and `j -h` also open help. Help and command discovery work outside
41−a repository and never change your files or configuration. The catalog includes
42+`j --help` and `j -h` also open help. A separate trailing help argument, such
43+as `j abandon --help`, opens that command's help without executing it.
44+Help and command discovery work outside a repository and never change your
45+files or configuration. The catalog includes
4246your own definitions, even ones without a signature; when no config exists, it
4347uses the embedded reference config. General help and help for reserved commands
4448are available even if your config is broken.
4549
46−To start working, run `j init` in a directory or `j clone URL`, then try
47−`j status` and `j tree`. Compose commands right to left:
50+Set up with your own name and email, then choose one starting point:
51+
52+```sh
53+j setup 'Ada Lovelace' ada@example.org
54+
55+# Start a new project
56+mkdir project && cd project
57+j init
58+```
59+
60+```sh
61+# Or join an existing project
62+j clone URL project
63+cd project
64+```
65+
66+`init` and `clone` leave you on a working-copy commit: the commit you are
67+editing now. Edit files, then run `j 'describe "first change"'` to save the
68+files and their description. Use `j save` to save files without changing the
69+description. Run `j new` **before** editing the next change. `j status` shows
70+both changes in the commit and edits that still need saving; `j tree` shows
71+where that commit sits in history. Compose commands right to left:
4872`j 'describe "first change" . new'` creates a commit, then describes it.
49−Preview an edit with `j 'tree . validate . squash'`.
73+Preview an edit with `j 'preview squash'`. Search familiar tasks with
74+`j commands commit`, `j commands checkout`, or `j commands branch`.
5075
5176---
5277
@@ -68,7 +93,7 @@ Preview an edit with `j 'tree . validate . squash'`.
6893### With Nix (recommended)
6994
7095The repository provides a flake with one default binary, `j`. Difftastic is a
71−runtime dependency and is wired in automatically.
96+runtime enhancement and is wired in automatically.
7297
7398```
7499nix build # builds ./result/bin/j
@@ -84,8 +109,9 @@ The reference `config.j` is installed alongside the binary
84109cargo build --release # produces target/release/j
85110```
86111
87−You'll want the reference `config.j` (see next section) and, for `review`/`difft`,
88−[difftastic](https://difftastic.wilfred.me.uk/) on your `PATH`.
112+Run `j setup` to create the reference `config.j` (see next section).
113+[Difftastic](https://difftastic.wilfred.me.uk/) on your `PATH` improves
114+`review`/`difft`; without it they use a unified diff.
89115
90116---
91117
@@ -114,8 +140,19 @@ colors follow the terminal for each stream; piped output stays plain unless
114140you explicitly request `treeWith`'s `color = "always"`. `NO_COLOR` takes
115141precedence even over that setting.
116142
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+
117149### User config
118150
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+
119156Expressions read `~/.config/j/config.j` (or `$XDG_CONFIG_HOME/j/config.j`).
120157An expression can only use a name this file declares or defines. The reference
121158[`config.j`](config.j) ships with the binary. Commands that execute against a
@@ -123,9 +160,19 @@ repository create an editable copy there when it is missing; help and discovery
123160use the embedded copy without writing it. Edit at least the identity:
124161
125162```
126−user = { name = "Your Name", email = "you@example.com" }
163+user = { name = "Ada Lovelace", email = "ada@example.org" }
127164```
128165
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+
129176The reference config defines the entire base vocabulary (see
130177[docs/base.md](docs/base.md)). Four definitions are read by `j` itself: `user`
131178(who commits), `tree` (how a `Repo` prints), `immutable` (which commits may
@@ -135,17 +182,19 @@ never be rewritten), and `labelled` (what a `%name` literal means).
135182
136183## How it works
137184
138−Every `j` run:
185+Every expression run:
139186
140−1. Snapshots the working directory into the working-copy commit.
141−2. Builds a `Repo` value focused on that commit.
187+1. Snapshots the working directory for evaluation.
188+2. Builds a `Repo` value focused on the working-copy commit.
1421893. Evaluates your expression.
1431904. If the result is a function, applies it to the `Repo`.
144−5. If the final value is a `Repo` that changed, persists it; otherwise prints
145− the value and changes nothing.
191+5. If the final value is a `Repo` that changed, persists it. An unchanged
192+ `Repo` saves nothing; other values print without saving.
146193
147194So most of what you write is an **edit** — a function `Repo -> Repo`. A result
148195that'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.
149198
150199```
151200j 'length . commits . top' -- a number: how many commits? prints, persists nothing
@@ -190,14 +239,19 @@ Opens a new commit *and* describes it, in one step.
190239j 'status'
191240```
192241
193−A summary of the focus: id, message, labels, changed paths, conflicted paths.
242+A summary of the focus: id, message, labels, changed paths, unrecorded edits,
243+and conflicted paths. `in commit` (the `changed` field) compares the commit
244+with its parent;
245+`unrecorded` compares the current files with their last saved state. After
246+`j save`, a commit can still have changed paths while all edits are recorded.
194247
195248```
196−changed 0 items
197−conflicts 0 items
198−id zrkmkkvrvtokumkuzrkmkkvrvtokumku
199−labels 0 items
200−message add a library
249+in commit No changes
250+conflicts No conflicts
251+id zrkmkkvrvtokumkuzrkmkkvrvtokumku
252+labels No labels
253+message add a library
254+unrecorded All edits recorded
201255```
202256
203257```
@@ -237,25 +291,33 @@ The same tree with ages and authors in the margin.
237291j 'log'
238292```
239293
240−A table of every commit, top first, with a `✓` on the focus.
294+A table of every commit, top first, with a `✓` on the focus, short ids, and
295+label names. Tables fit the terminal width, switching to fields on separate
296+lines on very narrow terminals. Redirected output preserves full cell text.
241297
242298```
243299j 'review'
244300```
245301
246−The focus's changes, rendered by difftastic as one page of diffs.
302+The focus's changes as one page of diffs, using difftastic when available
303+and a unified diff otherwise.
247304
248305### Move around
249306
307+Navigation checks out the selected commit's files so you can edit them.
308+
250309```
251310j 'prev' -- to the parent
252311j 'next' -- to the only child
253−j 'top' -- to the top of history
254312j 'tip' -- to the tip of the current line
255313j 'by @wqzt' -- to a specific commit, by id
256−j 'goto %main' -- to what the remote calls main
314+j 'new . goto trunk' -- start a change on top of the protected main line
257315```
258316
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+
259321### Undo and history of actions
260322
261323```
@@ -264,18 +326,25 @@ j redo -- reapply it
264326j ops -- the operation log, newest first
265327```
266328
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+
267336### A safe dry run
268337
269−Because a `Text` or `Repo`-returning-but-unchanged result persists nothing,
270−you can preview any edit by composing it with `tree` (or `validate`):
338+`preview` validates an edit and returns its resulting tree without saving:
271339
272340```
273−j 'tree . squash' -- show the tree as squash would leave it
274−j 'tree . validate . squash' -- the same, but crash if squash couldn't persist
341+j 'preview squash'
342+j 'preview (rebase trunk)'
275343```
276344
277−`validate` returns the repo unchanged if persisting would succeed, and crashes
278−with the exact error otherwise — an honest dry run.
345+This is shorthand for `tree . validate . EDIT`. `tree . EDIT` also previews,
346+but does not check whether the result can be saved. `validate . EDIT` alone
347+**saves** a changed repository; validation is not a dry-run mode.
279348
280349---
281350
@@ -377,6 +446,27 @@ j 'push (unlabel "old-feature")' -- delete a bookmark
377446j 'push relabel' -- after a rewrite, re-point the remote's bookmarks
378447```
379448
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+
380470---
381471
382472## Advanced examples
@@ -460,9 +550,9 @@ Rebase if you can; leave the repository alone if it crashes.
460550Anything added to `config.j` whose value is an `Edit` is a new command:
461551
462552```
463−-- in config.j: fold the whole stack into one commit called "release"
553+-- in config.j: squash into the parent and name the combined commit "release"
464554release : Edit
465−release = describe "release" . (contract everything)
555+release = describe "release" . prev . squash
466556```
467557
468558Then `j release` works like any built-in.
@@ -477,7 +567,8 @@ create or connect repositories, move refs, and walk the operation log.
477567| command | what it does |
478568|---|---|
479569| `j help [NAME]` | show a quick start or help for a reserved command or config name |
480−| `j commands [QUERY]` | list the current vocabulary, optionally searching names, signatures, and comments |
570+| `j commands [QUERY]` | show common tasks, search by task/name, or use `all` for the full vocabulary |
571+| `j setup [NAME EMAIL]` | create the full reference config, optionally with your identity; never overwrite an existing file |
481572| `j init` | create a colocated `.jj`/`.git` repository here (adopting an existing `.git`) |
482573| `j clone URL [DIR]` | clone `URL` into `DIR` (default: the repository's name, as `git clone` picks it) |
483574| `j remote URL` | set `origin`'s URL |
@@ -486,10 +577,15 @@ create or connect repositories, move refs, and walk the operation log.
486577| `j undo` / `j redo` | walk the operation log |
487578| `j ops` | print the operation log |
488579
489−`--help` and `-h` are aliases for `help` only as the first word. For example,
490−`j --help describe` opens help for `describe`; use `j help new` to learn about
491−`new`. There are no general command flags: `j 'new --help'` remains an expression
492−with a language comment. Everything else is an expression in the language.
580+`--help` and `-h` open help as the first word or as a separate argument after
581+the command: `j --help describe`, `j describe --help`, and `j describe -h`
582+all open help for `describe`. Inside a single expression argument or stdin,
583+language comments still apply: `j 'new --help'` executes `new`. There are no
584+other general command flags. Everything else is an expression in the language.
585+
586+Quote literal reserved-command arguments separately to keep spaces intact:
587+`j clone URL 'my project'` and `j remote '/path/to/shared repo.git'`. A whole
588+reserved command supplied as one string or on stdin is split on whitespace.
493589
494590---
495591
config.j+73−18
@@ -76,6 +76,10 @@ Drop = { delete : Label } -- delete the remote's bookmark
7676------------------------------------------------------------------------------
7777
7878(.) : (b -> c) -> (a -> b) -> a -> c -- composition, right to left
79+-- Return a value unchanged. As a repository command, `j id` records the
80+-- snapshotted working-copy edits in the focused commit. `j save` is the
81+-- descriptive alias in this reference config; inspections such as `j status`
82+-- and `j tree` do not record those edits.
7983id : a -> a
8084const : a -> b -> a
8185crash : Text -> a -- abort the program with a message
@@ -143,6 +147,11 @@ splitOn : Text -> Text -> [Text] -- splitOn "/" "a/b" = ["a" "b"]
143147-- file ./a would, crashes, naming the path (§7.3).
144148replay : Snapshot -> Change -> Snapshot
145149unresolved : Blob -> Bool
150+
151+-- Paths edited in the working copy since the last recorded repository state.
152+-- `j status` includes this list; `j save` records it in the focused commit.
153+-- This measures the working copy, even when composed after an in-memory edit.
154+unrecorded : Repo -> [Path]
146155touchedPaths : Change -> [Path] -- paths whose content differs, one pass;
147156 -- blobs compared by content id, bytes never read
148157blob : Text -> Blob -- a resolved regular file
@@ -160,8 +169,9 @@ validate : Repo -> Repo -- the repo unchanged, or the crash p
160169 -- focus). `tree . validate . rebase trunk` is an
161170 -- honest dry run.
162171diff : Blob -> Blob -> Text -- unified diff from the first to the second
163−difft : Path -> Blob -> Blob -> Text -- difftastic's rendering; needs difft on PATH.
164− -- Options via DFT_* environment variables.
172+difft : Path -> Blob -> Blob -> Text -- difftastic when difft is on PATH; otherwise
173+ -- unified diff with path headers and 3 context lines,
174+ -- or a binary-files summary. DFT_* configures difft.
165175treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text
166176 , lanes : Int, author : Bool, date : Bool, files : Bool }
167177 -> Repo -> Text -- the history as a tree; see the spec
@@ -249,6 +259,9 @@ restrict = \m -> filter (\e -> m e.path)
249259select : Fileset -> Snapshot -> Snapshot -> Snapshot -- m from a, the rest from b
250260select = \m a b -> restrict m a ++ restrict (neg m) b
251261
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`.
252265conflicted : Snapshot -> [Path]
253266conflicted = \s -> map (.path) (filter (\e -> unresolved e.content) s)
254267
@@ -333,6 +346,11 @@ minus = \a b repo -> let ys = b repo in filter (\i -> not (member i ys)) (a repo
333346matching : (Commit -> Bool) -> Revset -> Revset
334347matching = \p rs repo -> filter (\i -> p (commitAt i repo)) (rs repo)
335348
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.
336354conflicts : Revset
337355conflicts = matching (\c -> not (null (conflicted c.files))) all
338356
@@ -341,7 +359,10 @@ firstOf : [Revset] -> Revset
341359firstOf = \rss repo -> foldl (\acc rs -> if null acc then rs repo else acc) [] rss
342360
343361-- The main line: whichever of these names the remote has, first one wins.
344−-- Empty in a repository with no remote, in which case nothing is immutable.
362+-- Empty when no fetched remote label is named main, master, or trunk;
363+-- nothing is then immutable. Configure `j remote URL`, run `j fetch`, and
364+-- inspect `j tree` to see remote labels. A remote with another default label
365+-- needs this definition adjusted. Start work with j 'new . goto trunk'.
345366trunk : Revset
346367trunk = firstOf [%main %master %trunk]
347368
@@ -362,9 +383,15 @@ immutable = ancestorsOf trunk
362383goto : Revset -> Edit
363384goto = \rs repo ->
364385 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"
365390 in if length is == 1
366391 then by (head is) repo
367− else crash ("expected one revision, got " ++ show (length is))
392+ else if null is
393+ then crash "expected one revision, got 0; no matching revision. Run `j tree` to inspect available revisions. For a missing remote label such as %main or trunk, configure `j remote URL`, run `j fetch`, and check the label names in `j tree`."
394+ else crash ("expected one revision, got " ++ show (length is) ++ "\nCandidates:\n" ++ concat (map candidate (take 8 is)) ++ (if length is > 8 then " More candidates: run `j tree`.\n" else "") ++ "Choose a mutable commit with `j by @ID`, or start a child with `j 'new . by @ID'`; replace @ID with a candidate above.")
368395
369396commitAt : Id -> Repo -> Commit
370397commitAt = lookupCommit
@@ -372,8 +399,12 @@ commitAt = lookupCommit
372399prev : Edit -- the parent
373400prev = goto parents
374401
375−next : Edit -- the only child
376−next = goto kids
402+-- Move to the only child. At a tip, `j new` starts a child; when there are
403+-- several children, choose one of the candidates with `j by @ID`.
404+next : Edit
405+next = \repo -> if null (kids repo)
406+ then crash "next: this commit has no children; run `j new` to start a new child, or `j tree` to inspect the history"
407+ else goto kids repo
377408
378409tip : Edit -- end of the line of only-children
379410tip = \repo -> tip (next repo) or repo
@@ -399,6 +430,8 @@ eachChild = forEach kids
399430guard : (Repo -> Bool) -> Edit -- crash unless p holds
400431guard = \p repo -> if p repo then repo else crash "guard"
401432
433+-- Whether the focused commit has no unresolved files. This says nothing
434+-- about unrecorded working-copy edits; inspect those with `j unrecorded`.
402435clean : Repo -> Bool
403436clean = \repo -> null (conflicted repo.root.files)
404437
@@ -437,6 +470,21 @@ rewrite = \f repo ->
437470-- Commands
438471------------------------------------------------------------------------------
439472
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+
440488-- Set the message of the focused commit.
441489-- Example: j 'describe "fix login"'
442490describe : Text -> Edit
@@ -451,7 +499,7 @@ new = \repo -> addChild (newCommit repo.root.files) repo
451499-- new empty commit on that parent becomes the focus (the parent may be
452500-- immutable, and the focus never is). Refused for a commit the remote has a
453501-- name for: push something else under that name first, or forget the name.
454−-- Example: j abandon; preview first with j 'tree . abandon'.
502+-- Example: j abandon; validate a preview first with j 'preview abandon'.
455503abandon : Edit
456504abandon = \repo ->
457505 let c = repo.root
@@ -467,7 +515,7 @@ contract = \m repo -> let a = repo.root
467515 in at parents (rewrite (\q -> q { files = select m a.files q.files })) repo
468516
469517-- Fold the focus entirely into its parent; a new empty commit becomes the focus.
470−-- Example: j squash; preview first with j 'tree . validate . squash'.
518+-- Example: j squash; preview first with j 'preview squash'.
471519squash : Edit
472520squash = abandon . contract everything
473521
@@ -484,7 +532,7 @@ split = \m repo ->
484532 in by x.id (repo { root = a', children = [{ root = x, children = repo.children }] })
485533
486534-- Move the focused subtree onto another commit. Returns to the moved commit.
487−-- Example: j 'rebase trunk'; preview with j 'tree . validate . rebase trunk'.
535+-- Example: j 'rebase trunk'; preview with j 'preview (rebase trunk)'.
488536rebase : Revset -> Edit
489537rebase = \rs repo ->
490538 let d = (goto rs repo).root
@@ -555,13 +603,16 @@ contentAt = lookupContent
555603touched : Change -> [Path]
556604touched = touchedPaths
557605
558−-- Paths whose content differs between the focus and its parent.
606+-- Paths changed by the focused commit compared with its parent, including
607+-- the working-copy snapshot. These can remain nonempty after `j save`;
608+-- `j unrecorded` shows only working-copy edits not yet recorded.
559609-- Example: j changed
560610changed : Repo -> [Path]
561611changed = \repo -> touched (changeOf repo)
562612
563−-- What the focus changes, rendered by difftastic, one record per changed path.
564−-- Replace difft with (\p -> diff) to use the built-in unified diff instead.
613+-- What the focus changes, one record per changed path. Uses difftastic when
614+-- installed; a missing difft falls back to a unified diff with path headers
615+-- and 3 context lines, or a binary-files summary.
565616diffs : Repo -> [{ path : Path, diff : Text }]
566617diffs = \repo ->
567618 let ch = changeOf repo
@@ -569,23 +620,27 @@ diffs = \repo ->
569620
570621-- The same, as one page of text. A Text result prints raw, so `j review` is
571622-- readable in the terminal. A focus that changes nothing reads "no changes";
572−-- a difft that cannot be run (not on PATH) is a crash saying so, and
573−-- otherwise whatever difft prints, even nothing, is the page.
574−-- Example: j review (requires difftastic's difft on PATH).
623+-- missing difft uses the built-in unified diff (path headers, 3 context lines)
624+-- or a binary-files summary. Other execution errors remain visible; an
625+-- installed difft's output, even nothing, is the page.
626+-- Example: j review; difftastic is optional.
575627review : Repo -> Text
576628review = \repo ->
577629 let ds = diffs repo
578630 in if null ds then "no changes\n" else concat (map (\d -> d.diff) ds)
579631
580−-- A summary of the focus.
632+-- A summary of the focus. `changed` lists changes in the commit compared
633+-- with its parent; `unrecorded` lists working-copy edits not yet recorded.
634+-- Inspection does not record anything. Run `j save` to record those edits.
581635-- Example: j status
582−status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path] }
636+status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path], unrecorded : [Path] }
583637status = \repo ->
584638 { id = repo.root.id
585639 , message = repo.root.message
586640 , labels = repo.root.labels
587641 , changed = changed repo
588− , conflicts = conflicted repo.root.files }
642+ , conflicts = conflicted repo.root.files
643+ , unrecorded = unrecorded repo }
589644
590645-- A summary of every commit, top first, in preorder.
591646-- Example: j log
docs/base.md+110−36
@@ -47,6 +47,13 @@ user = { name = "Your Name", email = "you@example.com" }
4747
4848This is a plain value, not a function. Its signature is checked once, at load
4949(§4.13): if you write `user = 5`, `j` refuses to start (exit 3).
50+Creating a repository or saving changes also rejects empty fields and the
51+reference placeholders. Use your own name and email before `j init`,
52+`j clone`, or an edit that saves changes. `j help config` shows your actual
53+config path and an example, even if the config is broken.
54+For a new installation, `j setup 'Your actual name' you@your-domain.example`
55+creates the complete config with that identity. It never overwrites an
56+existing file.
5057
5158### 1.2 Paths, labels, entries, snapshots
5259
@@ -178,6 +185,34 @@ id 42 => 42
178185edit or id -- run edit, keep the repository if it crashes
179186```
180187
188+### `save` — record file edits
189+
190+```
191+save : Edit
192+```
193+
194+The identity edit, with a name for the everyday action: `j save` records the
195+working-directory snapshot without changing the commit's description or
196+focus. It creates no operation when nothing changed. `j id` does the same.
197+
198+### `preview` — validate an edit without saving
199+
200+```
201+preview : Edit -> Repo -> Text
202+```
203+
204+Apply the edit, validate its result, and render the resulting tree. Because
205+the final value is text, no edit or working-directory snapshot is recorded.
206+Validation errors report why the edit could not be saved.
207+
208+```
209+j 'preview squash'
210+j 'preview (rebase trunk)'
211+```
212+
213+This is the convenient form of `tree . validate . EDIT`. `validate . EDIT`
214+alone returns a repository and saves it.
215+
181216### `const` — constant function
182217
183218```
@@ -522,7 +557,7 @@ meta @wqzt => { hash = "74bee64e…", author = "Test User",
522557 email = "test@example.com", time = 1789457265 }
523558```
524559
525−### `validate` — dry-run persistence
560+### `validate` — check whether persistence would succeed
526561
527562```
528563validate : Repo -> Repo
@@ -530,7 +565,12 @@ validate : Repo -> Repo
530565
531566Returns the repo unchanged if persisting it would succeed; otherwise crashes
532567with exactly the error persistence would produce (shape, root, labels, immutability,
533−focus). `tree . validate . rebase trunk` is an honest dry run.
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.
534574
535575### `diff` and `difft` — rendering differences
536576
@@ -548,27 +588,34 @@ diff (blob "a\nb\n") (blob "a\nc\n")
548588```
549589
550590`difft p a b` renders the comparison with **difftastic**, treating the input
551−as the file `p` (its last component guides language detection). Needs `difft`
552−on `PATH` (the Nix package provides it); options via `DFT_*` environment
553−variables. Crashes naming the tool if absent. A `difft` that runs and fails
554−is not an error: what it printed, even nothing, is the result, and what it
555−says on stderr reaches the terminal.
591+as the file `p` (its last component guides language detection), using options
592+from `DFT_*` environment variables. The Nix package provides `difft`.
593+When the executable is missing, the builtin returns a unified diff with
594+`a/` and `b/` path headers and three lines of context; binary content receives
595+a difference summary. Other launch failures are crashes naming the tool and
596+OS error. A `difft` that runs and fails is not an error: what it printed,
597+even nothing, is the result, and what it says on stderr reaches the terminal.
556598
557599### `treeWith` — the history tree
558600
559601```
560−treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text }
602+treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text,
603+ lanes : Int, author : Bool, date : Bool, files : Bool }
561604 -> Repo -> Text
562605```
563606
564607Renders the history as a tree with an options record. The reference config
565−provides two ready-made settings, `tree` and `treeFull` (§9).
608+provides ready-made settings, including `tree` and `treeFull` (§10).
566609
567610```
568−treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
611+treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
569612 -- a full tree with ages and authors in the margin
570613```
571614
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+
572619### `extract` — every subvalue of a shape
573620
574621```
@@ -630,7 +677,9 @@ top = \repo -> top (up repo) or repo
630677```
631678
632679Moves the focus all the way to the top of history (where `context = []`). The
633−`or repo` is the base case: when `up` crashes at the top, stop.
680+`or repo` is the base case: when `up` crashes at the top, stop. Use it inside
681+an inspection such as `commits . top` or `tree . top`; `j top` alone cannot
682+save a focus on the protected root commit.
634683
635684```
636685top -- refocus at the very first commit
@@ -811,17 +860,14 @@ is `new . goto trunk`, never `goto trunk` alone.
811860
812861**`goto : Revset -> Edit`** — refocus on *the one commit* a revset names. This
813862is the only place a revset must have exactly one member; otherwise it crashes
814−with `expected one revision, got N`.
815−
816−```
817−goto = \rs repo ->
818− let is = rs repo
819− in if length is == 1 then by (head is) repo
820− else crash ("expected one revision, got " ++ show (length is))
821−```
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.
822867
823868```
824−goto %main -- refocus on main (crash if it names 0 or 2+ commits)
869+new . goto %main -- start a mutable child on main
870+tree . goto %main -- inspect main without moving the working copy
825871goto parents -- == prev
826872```
827873
@@ -830,10 +876,12 @@ The reference definition uses `lookupCommit`, which indexes the repository
830876once and retrieves commits without moving the focus. Its result is the same
831877as `(by i repo).root`.
832878
833−**`prev : Edit`** — to the parent (`goto parents`).
879+**`prev : Edit`** — to the parent (`goto parents`). Like every navigation edit,
880+it checks out the selected commit. A protected parent cannot remain focused;
881+inspect it with `tree . prev` or start a new child instead.
834882
835−**`next : Edit`** — to the only child (`goto kids`; crashes if the focus has
836−other than exactly one child).
883+**`next : Edit`** — to the only child. At a tip it suggests `j new`; at a fork
884+it lists candidates to choose with `by`.
837885
838886**`tip : Edit`** — the end of the line of only-children (`tip = \repo -> tip
839887(next repo) or repo`).
@@ -1017,6 +1065,9 @@ Crashes at the top.
10171065
10181066`j push EXPR` evaluates `EXPR` to a list of `Push` and `Drop` records. These
10191067functions 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.
10201071
10211072**`label : Label -> Revset -> Repo -> [Push]`** — label every commit of a
10221073revset.
@@ -1049,7 +1100,8 @@ j 'push relabel' -- after rewriting, re-point the remote's bookmarks
10491100
10501101## 10. Inspection
10511102
1052−These return printable values rather than a `Repo`.
1103+These return printable values rather than a `Repo`. They include current
1104+file edits in the displayed snapshot without recording those edits.
10531105
10541106**`focus : Repo -> Commit`** — the focused commit.
10551107
@@ -1090,45 +1142,64 @@ contentAt ./missing [] => blob ""
10901142**`changed : Repo -> [Path]`** — paths whose content differs between the focus
10911143and its parent.
10921144
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+
10931151**`diffs : Repo -> [{ path : Path, diff : Text }]`** — what the focus changes,
1094−rendered by difftastic, one record per changed path. (Replace `difft` with
1095−`(\p -> diff)` to use the built-in unified diff instead.)
1152+rendered by difftastic, one record per changed path, with unified diffs as
1153+the fallback when difftastic is missing.
10961154
10971155**`review : Repo -> Text`** — the same, as one page of text, or
10981156`"no changes\n"` when the focus changes nothing. A `Text` result prints raw,
1099−so `j review` is readable in the terminal. Like `diffs`, it crashes when
1100−`difft` cannot be run (not on `PATH`); otherwise whatever `difft` prints,
1101−even nothing, is the page, and what it says on stderr reaches the terminal.
1157+so `j review` is readable in the terminal even without difftastic. When
1158+`difft` runs, whatever it prints, even nothing, is the page, and what it says
1159+on stderr reaches the terminal. Other launch errors still fail the review.
11021160
11031161```
11041162j 'review' -- the focus's changes, as difftastic renders them
11051163```
11061164
1107−**`status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path] }`** — a summary of the focus.
1165+**`status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], unrecorded : [Path], conflicts : [Path] }`** — a summary of the focus and unsaved file edits.
11081166
11091167```
11101168j 'status'
11111169-- id @wqzt…
11121170-- message add the lexer
11131171-- labels [feature]
1114−-- changed [src/lexer.rs]
1115−-- conflicts []
1172+-- in commit [src/lexer.rs]
1173+-- conflicts No conflicts
1174+-- unrecorded All edits recorded
11161175```
11171176
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+
11181184**`log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]`**
11191185— a summary of every commit, top first, in preorder (`focus` marks the focused
1120−commit).
1186+commit). The display shows short ids and label names, fits columns to the
1187+terminal, and switches to fields on separate lines when necessary. Redirected
1188+output preserves full cell text.
11211189
11221190**`tree : Repo -> Text`** and **`treeFull : Repo -> Text`** — the history as a
1123−tree, built with `treeWith`. `tree` is compact (`detail = 1`, elided);
1191+tree, built with `treeWith`. `tree` shows every commit (`detail = 1`);
11241192`treeFull` shows ages and authors in the margin (`detail = 2`, `margin =
11251193true`). Edit these definitions to change what `j tree` shows.
11261194
11271195```
1128−tree = treeWith ({ detail = 1, margin = false, elide = true, icons = false, color = "auto" })
1129−treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
1196+tree = treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
1197+treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
11301198```
11311199
1200+Use `treeCompact` to fold uninteresting runs and distant subtrees, or
1201+`treeData` for extra date, files-changed, and author columns.
1202+
11321203```
11331204j 'tree'
11341205-- ⌂ zzzz
@@ -1228,6 +1299,7 @@ j 'tree'
12281299| `parents` | `Revset` | §7.3 |
12291300| `pick` | `Revset -> Edit` | §8 |
12301301| `prev` | `Edit` | §7.4 |
1302+| `preview` | `Edit -> Repo -> Text` | §2 |
12311303| `range` | `Int -> Int -> [Int]` | §4 |
12321304| `rebase` | `Revset -> Edit` | §8 |
12331305| `relabel` | `Repo -> [Push]` | §9 |
@@ -1239,6 +1311,7 @@ j 'tree'
12391311| `restrict` | `Fileset -> Snapshot -> Snapshot` | §7.2 |
12401312| `review` | `Repo -> Text` | §10 |
12411313| `rewrite` | `(Commit -> Commit) -> Edit` | §8 |
1314+| `save` | `Edit` | §2 |
12421315| `select` | `Fileset -> Snapshot -> Snapshot -> Snapshot` | §7.2 |
12431316| `show` | `a -> Text` | §3 |
12441317| `siblings` | `Revset` | §7.3 |
@@ -1262,6 +1335,7 @@ j 'tree'
12621335| `under` | `Path -> Fileset` | §7.2 |
12631336| `union` | `Revset -> Revset -> Revset` | §7.3 |
12641337| `unlabel` | `Label -> [Drop]` | §9 |
1338+| `unrecorded` | `Repo -> [Path]` | §10 |
12651339| `unresolved` | `Blob -> Bool` | §5 |
12661340| `up` | `Edit` | §6 |
12671341| `user` | `{ name : Text, email : Text }` | §1.1 |
docs/comparison.md+25−9
@@ -75,21 +75,27 @@ about: a commit keeps its identity (`@wqzt…`) across `describe`, `squash`,
7575`j push` takes an *expression* rather than flags: `j 'push (label "feature"
7676here)'` labels the focused commit, `j 'push relabel'` re-points the remote's
7777bookmarks after a rewrite.
78+Push sends recorded commits and warns before sending if files have
79+unrecorded edits. Run `j id`, then push again to include those edits.
7880
7981### 3.2 Recording work
8082
8183| Task | git | jj | j |
8284|---|---|---|---|
83−| Save current state | `git add … && git commit` | automatic (`jj st` shows it) | automatic; `j id` to record *only* the snapshot |
85+| Save current state | `git add … && git commit` | automatic (`jj st` shows it) | an edit records the snapshot; `j save` records *only* the snapshot |
8486| Set the message | `git commit -m "…"` | `jj describe -m "…"` or `jj describe` | `j 'describe "…"'` |
8587| Start the next change | (just keep editing) | `jj new` | `j new` |
8688| Amend the current commit | `git commit --amend` | `jj describe` + edit files in `@` | edit files + `j 'describe "…"'` |
8789| See what changed | `git diff` / `git show` | `jj diff` / `jj show` | `j review` (via difftastic) / `j status` |
8890
89−In git, "commit" is a deliberate act with a staging step. In jj and `j`, the
90−snapshot is continuous; `describe` sets a message and `new` opens the next
91−change. `j` has one deliberate-snapshot command, `j id`, for when you want to
92−record the working directory and nothing else.
91+In git, "commit" is a deliberate act with a staging step. jj snapshots the
92+working copy automatically. In `j`, expressions see current file edits,
93+but only a changed `Repo` result records them: `describe` saves a message
94+and `new` opens the next change. `j status` and other inspections save
95+nothing. Use `j save` (an alias for `id`) to record the working directory and
96+nothing else. Status displays the `changed` field as `in commit`, comparing
97+the current commit with its parent; `unrecorded` shows file edits made since
98+the last save.
9399
94100### 3.3 Looking around
95101
@@ -116,7 +122,7 @@ zipper with edits.
116122|---|---|---|---|
117123| To the parent | `git checkout HEAD~1` | `jj prev` *(≈, opens a new empty commit there)* | `j prev` |
118124| To the child | `git checkout <sha>` (manual) | `jj next` *(≈, opens a new empty commit there)* | `j next` |
119−| To a named commit | `git checkout <branch>` | `jj new <rev>` | `j 'goto %main'` |
125+| Start work on a named commit | `git checkout <branch>` | `jj new <rev>` | `j 'new . goto %main'` |
120126| To a specific change | `git checkout <sha>` | `jj new <change-id>` | `j 'by @wqzt'` |
121127| Back to your branch | `git checkout main` | `jj new main` | `j 'new . goto trunk'` |
122128
@@ -201,14 +207,16 @@ the remote's state explicit and impossible to mutate by accident.
201207| When do conflicts appear? | During merge/rebase, blocking it | Recorded *in* the commit, non-blocking | Recorded in the commit (unresolved `Blob`s) |
202208| Where do they live? | The working tree + index | In the commit itself | In the commit's `files`, as conflict blobs |
203209| How do you see them? | `git status` | `jj st`, `jj resolve` | `j conflicts`, `j status` |
204−| How do you resolve? | Edit files, `git add`, continue | Edit files; conflict is gone from `@` | Edit files, then any persisting expression (`j id`) |
210+| How do you resolve? | Edit files, `git add`, continue | Edit files; conflict is gone from `@` | Edit files, then `j save` and check `j status` |
205211
206212git stops the world on a conflict: a merge or rebase halts until you resolve
207213and `git add`. jj and `j` treat a conflict as *data* — a commit can simply
208214*contain* conflicted files, you can keep working, and you resolve whenever you
209215like. `j conflicts` lists the commits that have conflicts; checking one out
210−writes the markers to disk; editing the files and running `j id` records the
211−resolution.
216+writes the markers to disk; editing the files and running `j save` records
217+the resolution. Check `j status` for `No conflicts` and `All edits recorded`.
218+There is no separate continuation command. `j review` uses a unified diff
219+when difftastic is not installed.
212220
213221---
214222
@@ -227,6 +235,12 @@ a first-class, marked operation, so `undo`/`redo` are safe and precise. In
227235`j`, undoing a `push` restores the local labels (and leaves the remote
228236untouched).
229237
238+`j undo` and `j redo` require no unrecorded working-copy edits. Recording
239+edits with `j id` adds an operation, so the next undo removes that recording
240+before another undo reaches the preceding edit. A new recording also clears
241+pending redos. Interactive confirmations identify the affected operation and
242+restored focus; `j ops` shows the history.
243+
230244---
231245
232246## 9. The interface itself
@@ -296,6 +310,8 @@ j new
296310# …edit…
297311j 'describe "part two"'
298312j squash
313+j prev # squash leaves an empty child; return to the combined commit
314+j 'describe "complete feature"'
299315j 'rebase %main'
300316j 'push (label "feature" here)'
301317```
docs/language.md+43−15
@@ -3,8 +3,14 @@
33`j` is a small, pure, functional language. Every `j` command evaluates one
44expression in this language against the current repository. The language is
55the whole interface: there are no subcommands beyond a handful of reserved
6−words, and no general flags. As the first word, `--help` and `-h` are aliases
7−for `help`; use `j help` for a command guide or `j commands` to browse names.
6+words, and no general flags apart from help. `--help` and `-h` open help as
7+the first word or as a separate argument after a command: `j new --help`
8+shows help without executing `new`. Inside a single expression argument or
9+stdin, `--` still starts a language comment, so `j 'new --help'` executes
10+`new`. Use `j help` for a command guide, `j commands` for everyday tasks,
11+and `j commands all` for the complete vocabulary. `j setup NAME EMAIL`
12+creates a new configuration with your identity without replacing an existing
13+file.
814
915This document describes the language itself — its values, syntax, semantics,
1016and the builtins — with examples throughout. Every example shows an expression
@@ -18,16 +24,17 @@ and what it evaluates to, exactly as `j` prints it.
1824
1925## 1. The big picture
2026
21−A run of `j` looks like this:
27+An expression run of `j` looks like this:
2228
23291. Read the expression (from the command line or stdin).
24−2. Snapshot the working directory into the working-copy commit.
30+2. Snapshot the working directory for evaluation.
25313. Build a value `repo` describing the repository, focused on the
2632 working-copy commit.
27334. Evaluate the expression.
28345. If the result is a function, apply it to `repo`.
29356. If the final value is a `Repo` that differs from the one loaded, persist
30− the difference; otherwise print the value and change nothing.
36+ the difference. An unchanged `Repo` saves nothing; other values print
37+ without saving.
3138
3239So the most common thing you write is an **edit** — a function `Repo -> Repo`
3340(or a value that becomes one). `describe "wip"` is an edit. `new` is an edit.
@@ -554,6 +561,9 @@ crash "my message"
554561
555562Parse errors, configuration errors, and `Id`-resolution failures happen
556563*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.
557567
558568---
559569
@@ -768,7 +778,8 @@ prev -- to the parent (crashes at the top)
768778next -- to the only child (crashes if not exactly one)
769779up -- like prev, the raw zipper move
770780by @wqzt -- refocus on a specific commit
771−goto %main -- refocus on what the remote calls main
781+tree . goto %main -- inspect main without moving the working copy
782+new . goto %main -- start a mutable child on main
772783parents -- the focus's parents, as a Revset
773784kids -- its children
774785ancestors -- it and its ancestors
@@ -781,6 +792,7 @@ Edits — functions `Repo -> Repo`:
781792
782793```
783794new -- a new empty child of the focus
795+save -- record file edits without changing the description
784796describe "wip" -- set the focus's message
785797squash -- fold the focus into its parent
786798abandon -- drop the focus; children join the parent
@@ -795,7 +807,9 @@ And the inspections:
795807
796808```
797809tree -- the history as a tree (a Text)
798−status -- { id, message, labels, changed, conflicts }
810+status -- { id, message, labels, changed, conflicts, unrecorded }
811+unrecorded -- paths edited since the last recording
812+preview squash -- validate and show the resulting tree without saving
799813review -- the focus's changes as one page of diffs
800814files -- the focus's snapshot (its files)
801815commits . top -- every commit, preorder
@@ -820,10 +834,13 @@ j 'tree'
820834-- the second commit was a mistake; fold it into the first
821835j 'squash'
822836
823−-- rename the combined commit
837+-- squash leaves a fresh empty child; move back and rename the combined commit
838+j prev
824839j 'describe "add the front end"'
825840
826−-- undo the squash entirely
841+-- undo the rename, the focus move, then the squash
842+j undo
843+j undo
827844j undo
828845
829846-- move back to the first commit to fix something
@@ -852,17 +869,22 @@ diff (blob "a\nb\n") (blob "a\nc\n")
852869 => "@@ -1,2 +1,2 @@\n a\n-b\n+c\n"
853870```
854871
855−`difft p a b` renders the comparison with **difftastic** (which must be on
856−`PATH`; the Nix package provides it). `review` uses `difft` per changed path.
872+`difft p a b` renders the comparison with **difftastic** when it is on `PATH`
873+(the Nix package provides it). When it is absent, the builtin returns a
874+unified diff with path headers, or a summary for binary changes. `review`
875+uses `difft` per changed path; other errors starting the tool still fail.
857876
858877`treeWith options repo` renders the history tree with an options record:
859878
860879```
861−treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
880+treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
862881```
863882
864−The reference config gives two ready-made versions: `tree` (compact) and
865−`treeFull` (with ages and authors in the margin).
883+The reference config provides `tree`, `treeCompact` (folded runs and distant
884+subtrees), `treeFull` (ages and authors in the margin), and `treeData` (extra
885+date, files-changed, and author columns). Branches beyond `lanes` keep their
886+state marker beside `»`; increase `lanes` to show more rails. Long file paths
887+in detail rows wrap to fit the terminal.
866888
867889---
868890
@@ -941,5 +963,11 @@ the algebra they are.
941963**Blobs/repos:** `blob` `blobHex` `text` `unresolved` `replay` `by` `meta` `extract`
942964`diff` `difft` `treeWith` `validate`
943965
944−**Reserved commands** (not expressions): `init` `clone URL [DIR]` `remote URL`
966+**Reserved commands** (not expressions): `help [NAME]` `commands [QUERY]`
967+`setup [NAME EMAIL]` `init` `clone URL [DIR]` `remote URL`
945968`fetch` `push EXPR` `undo` `redo` `ops`.
969+
970+Literal reserved-command arguments preserve shell quoting boundaries, as in
971+`j clone URL 'my project'`. A whole reserved command passed as one string or
972+on stdin is split on whitespace. Expression arguments still use language
973+syntax: quote the whole expression, as in `j 'describe "my change"'`.
docs/tutorial.md+102−36
@@ -4,9 +4,17 @@ This is a guided first hour with `j`. It builds the mental model piece by
44piece — not just *what* to type, but *why it works* — so that by the end you
55can compose your own commands instead of memorizing them.
66
7−You'll want `j` installed (`nix build` or `cargo build --release`) and a
8−`config.j` with your identity (see the README's Configuration section). Every
9−command shown here is run in a real repository, so you can follow along.
7+You'll want `j` installed (`nix build` or `cargo build --release`). Set up
8+your configuration with your own name and email:
9+
10+```
11+j setup 'Ada Lovelace' ada@example.org
12+```
13+
14+This creates the full reference configuration without overwriting an existing
15+file. If you already have a config, `j help config` shows its location so you
16+can edit the `user` definition. The remaining commands run in a real
17+repository, so you can follow along.
1018
1119---
1220
@@ -20,11 +28,11 @@ j init
2028```
2129
2230`j init` makes a colocated `.jj`/`.git` repository and checks out a fresh
23−working-copy commit. Now write a file and start your first change:
31+working-copy commit: the commit you are currently editing. It already exists,
32+so write a file and describe that commit:
2433
2534```
2635echo 'fn main() {}' > main.rs
27−j 'new'
2836j 'describe "start the project"'
2937```
3038
@@ -36,35 +44,48 @@ j 'tree'
3644
3745```
3846 ⌂ zzzz
39− └─ ● pnyz ▂
40−▶ └─ ◌ psnq start the project
47+▶ └─ ◉ pnyz ▂ start the project
4148```
4249
4350Add a second change on top:
4451
4552```
46−echo 'lib' > lib.rs
4753j 'new'
54+echo 'lib' > lib.rs
4855j 'describe "add a library"'
4956j 'tree'
5057```
5158
5259```
5360 ⌂ zzzz
54− └─ ● pnyz ▂
55− └─ ● psnq ▂ start the project
56−▶ └─ ◌ trtq add a library
61+ └─ ● pnyz ▂ start the project
62+▶ └─ ◉ psnq ▂ add a library
5763```
5864
59−That's the whole basic loop: **edit files, `new`, `describe`.** There is no
60−`git add` step — the working directory is always already a commit, and `j`
61−snapshots it on every run.
65+That's the basic loop: **edit files, describe the current change, then `new`
66+before editing the next change.** `new` opens an empty change with the same
67+files as its parent; it does not move existing edits into that new change.
68+There is no `git add` step. `describe` saves both the current files and their
69+description; `j save` records file edits without changing the description.
6270
63−**What just happened, in the model:** each `j` run snapshots your working
64−directory into the working-copy commit, builds a `Repo` value focused there,
71+**What just happened, in the model:** each expression snapshots your working
72+directory for evaluation, builds a `Repo` value focused on that commit,
6573evaluates your expression, and — because `new` and `describe` are *edits*
6674(functions `Repo -> Repo`) — persists the resulting repository.
6775
76+`j status` has two useful lists: `in commit` compares the current commit with
77+its parent, while `unrecorded` lists file edits made since the last save.
78+After `describe` or `save`, `unrecorded` is empty; `in commit` still shows the
79+work in the commit. Inspecting status does not save anything.
80+
81+To join an existing project instead, clone it and enter its directory:
82+
83+```
84+j clone URL project
85+cd project
86+j status
87+```
88+
6889---
6990
7091## Part 2 — The one idea everything rests on
@@ -133,22 +154,28 @@ function produces, so don't wrap it.
133154Make a few commits to play with:
134155
135156```
136−echo a > a.txt; j 'new'; j 'describe "A"'
137−echo b > b.txt; j 'new'; j 'describe "B"'
138−echo c > c.txt; j 'new'; j 'describe "C"'
157+j new; echo a > a.txt; j 'describe "A"'
158+j new; echo b > b.txt; j 'describe "B"'
159+j new; echo c > c.txt; j 'describe "C"'
139160```
140161
141−Navigation is edits too:
162+Navigation changes the working-copy commit and checks out its files. Use
163+these commands to edit another mutable commit:
142164
143165```
144166j 'prev' -- to the parent
145167j 'next' -- to the only child
146−j 'top' -- to the top of history
147168j 'tip' -- to the end of the current line
148169j 'by @vqok' -- to a specific commit, by id (use a real id from j tree)
149−j 'goto %main' -- to what the remote calls main
150170```
151171
172+At a fork, choose an id from `j tree` with `j 'by @id'`; `next` only works
173+when there is one child. Main and the root commit are protected from editing.
174+Use `j 'new . goto trunk'` to start work on main, or
175+`j 'tree . goto trunk'` to inspect it without moving your working copy.
176+In a new local repository with no main bookmark, keep working from `j init`'s
177+commit instead.
178+
152179After `j 'prev'` from C, the tree shows the focus (▶ and ◉) on B:
153180
154181```
@@ -164,11 +191,16 @@ To see the focus's data, use the inspection functions:
164191```
165192j 'files' -- the focus's files
166193j 'changed' -- paths that differ from the parent
167−j 'status' -- id, message, labels, changed, conflicts
194+j 'status' -- focus, changes in the commit, unrecorded edits, conflicts
168195j 'log' -- a table of every commit, ✓ on the focus
169196j 'review' -- the focus's changes as diffs (difftastic)
170197```
171198
199+These inspections include current file edits without recording them. Use
200+`j save` to record file edits on their own (`id` is the same edit). `status` spells out empty states
201+such as `No changes` and `No conflicts`; `log` shows label names and short
202+ids and adapts to narrow terminals.
203+
172204### Composition order, made concrete
173205
174206```
@@ -194,11 +226,10 @@ operates on the repository the previous step produced.
194226
195227## Part 4 — Dry runs: seeing before doing
196228
197−Because inspections persist nothing, you can preview any edit by composing it
198−with `tree`:
229+Use `preview` to validate an edit and show its resulting tree without saving:
199230
200231```
201−j 'tree . squash'
232+j 'preview squash'
202233```
203234
204235From the A → B history, this *shows* the tree as `squash` would leave it (B
@@ -221,18 +252,21 @@ because `tree` returns a `Text`, not a `Repo`:
221252▶ └─ ◌ nknz B
222253```
223254
224−For a stronger guarantee, insert `validate`. `validate repo` returns the repo
225−unchanged if persisting would succeed, and crashes with the exact error
226−otherwise:
255+`preview` combines `tree` and `validate`. For an edit that takes arguments,
256+put that edit in parentheses:
227257
228258```
229−j 'tree . validate . rebase %main'
259+j 'preview (rebase %main)'
230260```
231261
232262This renders the post-rebase tree **and** crashes (without touching anything)
233263if that rebase couldn't actually happen — for instance, if `%main` doesn't
234264exist yet. That's an *honest* dry run.
235265
266+The longer form is `j 'tree . validate . rebase %main'`. `validate` alone
267+does not make an edit a dry run: `j 'validate . squash'` returns a repository
268+and saves it. The final inspection (`tree`) is what prevents persistence.
269+
236270---
237271
238272## Part 5 — `or`: the whole error-handling model
@@ -290,7 +324,7 @@ Two things make these safe to use freely:
290324
2913251. **The change id survives.** A commit keeps its `@wqzt…` id across
292326 `describe`, `squash`, `rebase`. You're never tracking a moving sha.
293−2. **`j undo` always works.** Every operation is recorded.
327+2. **Saved operations are undoable.** Use `j undo` and `j redo` to walk them.
294328
295329```
296330j undo -- revert the last thing
@@ -298,6 +332,28 @@ j redo -- reapply it
298332j ops -- see the operation log
299333```
300334
335+Undo and redo stop when files have unrecorded edits. Recording those edits
336+with `j id` adds an operation, so the next undo removes that recording;
337+another undo reaches the preceding edit. Recording also clears pending
338+redos. Read `j ops` to choose how far to go back. Successful interactive
339+undo/redo messages identify the affected operation and restored focus.
340+
341+### Resolve a conflict
342+
343+A successful rebase can leave conflicts in the working-copy commit. The edit
344+is saved; there is no separate continuation command. Resolve it in four steps:
345+
346+1. Run `j status` to find conflicted paths, and `j review` to inspect changes.
347+2. Edit those files, choosing the intended content and removing conflict markers.
348+3. Run `j save` to record the resolution.
349+4. Run `j status` again and check for `No conflicts` and `All edits recorded`.
350+
351+`j conflicts` lists conflicted commits throughout the repository. If a
352+conflict is on another mutable commit, use its id from `j tree` with
353+`j 'by @id'`, then follow the same steps. Run `j conflicts` again to check
354+the whole history. `review` uses difftastic when
355+installed and falls back to a unified diff when it is unavailable.
356+
301357### `split` and `contract`: moving changes by fileset
302358
303359A **fileset** is a function `Path -> Bool`. These two are inverses:
@@ -341,9 +397,12 @@ Most commands that take a "which commits" argument take a revset. `goto` is
341397the one place a revset must name *exactly one* commit:
342398
343399```
344−j 'goto %main' -- crash if %main names 0 or 2+ commits
400+j 'new . goto %main' -- select one main commit and start a mutable child
345401```
346402
403+A missing label reports how to fetch and inspect remote labels. An ambiguous
404+selector lists candidate ids and descriptions so you can choose with `by`.
405+
347406Revsets combine like sets:
348407
349408```
@@ -387,9 +446,9 @@ Everything `j` knows is defined in `config.j`. Any definition whose value is
387446an `Edit` is a new command. Add this to your `~/.config/j/config.j`:
388447
389448```
390−-- fold the whole stack into one commit called "release"
449+-- squash into the parent and name the combined commit "release"
391450release : Edit
392−release = describe "release" . (contract everything)
451+release = describe "release" . prev . squash
393452```
394453
395454Now `j release` works exactly like `squash` or `new`. The signature (`: Edit`)
@@ -432,8 +491,8 @@ echo 'fn parse() {}' > parser.rs
432491j 'describe "add the parser"'
433492
434493# second part
435−echo 'mod parser;' > lib.rs
436494j 'new'
495+echo 'mod parser;' > lib.rs
437496j 'describe "wire up the parser"'
438497
439498# look at it
@@ -442,17 +501,24 @@ j 'tree'
442501# the second commit was really part of the first; fold it in
443502j squash
444503
504+# squash leaves an empty child focused; move to the combined commit
505+j prev
506+
445507# rename the combined commit
446508j 'describe "add the parser"'
447509
448510# preview the rebase, then do it
449−j 'tree . validate . rebase %main'
511+j 'preview (rebase %main)'
450512j 'rebase %main'
451513
452514# share it
453515j 'push (label "parser" here)'
454516```
455517
518+Push sends recorded commits. If you edit files after the last saved edit,
519+push warns that those edits are omitted. Run `j save`, then push again to
520+include them. Looking at `j status` does not save the edits.
521+
456522And if any step goes wrong:
457523
458524```
specs/main.md+279−72
@@ -9,7 +9,7 @@ Nothing is implicit beyond the language itself. Every shape, builtin, and
99command is declared or defined in one user-owned file, `~/.config/j/config.j`,
1010written in the language. The binary provides the language (its syntax, its
1111operators and their fixities, its primitive kinds), the implementations of the
12−declared builtins, ten reserved words, and the bridge to jj repositories.
12+declared builtins, eleven reserved words, and the bridge to jj repositories.
1313`j` does not require the `jj` binary; it uses jj's on-disk formats through
1414jj-lib.
1515
@@ -46,10 +46,13 @@ echo EXPRESSION | j
4646 usage and getting-started guide to stderr and exit 2. The guide points to
4747 `j help`, `j commands`, and commands for starting or inspecting a repository.
4848 It does not read or create configuration or open a repository.
49−5. `--help` and `-h` are aliases for the reserved command `help` only when
50− they are the first whitespace-separated word (§1.1). There are no other
51− flags or trailing help flags: `j 'new --help'` is an expression whose
52− `--help` is a language comment, and `j '"--help"'` prints text.
49+5. `--help` and `-h` as the first word alias `help` (§1.1). A separate
50+ argument equal to either flag after the first argument requests help for
51+ the first word, before repository access or evaluation: `j abandon --help`
52+ is `j help abandon`. An initial `help`, `--help`, or `-h` followed by
53+ another help flag opens general help. Other flags are not supported.
54+ Language comments embedded within a single argument or stdin are unchanged:
55+ `j 'new --help'` executes `new`, and `j '"--help"'` prints text.
53566. The expression is text: an argument that is not valid UTF-8 is a usage
5457 error (exit 2) naming it, and so is an expression on standard input that
5558 is not.
@@ -58,13 +61,14 @@ echo EXPRESSION | j
5861
5962Before the expression is parsed, its text is trimmed of leading and trailing
6063whitespace and split on whitespace into words. If the first word is one of the
61−ten reserved words below, the whole text is a reserved command. A first word
64+eleven reserved words below, the whole text is a reserved command. A first word
6265of `--help` or `-h` is treated as `help`.
6366
6467| command | operation |
6568|---|---|
6669| `help [NAME]` | show the command guide, or help for a reserved command or config name; §1.1.1 |
67−| `commands [QUERY]` | list the vocabulary, optionally searching names, signatures, and comments; §1.1.1 |
70+| `commands [QUERY]` | show common tasks, search the vocabulary, or use `all` for the full catalog; §1.1.1 |
71+| `setup [NAME EMAIL]` | create the complete reference configuration, optionally setting identity; §6.1.1 |
6872| `init` | create a repository in the current directory; §7.8 |
6973| `clone URL [DIR]` | clone `URL` into `DIR` (default: the repository's name in `URL`); §7.8 |
7074| `remote URL` | set the URL of the remote `origin`, creating it if absent; §7.8 |
@@ -75,8 +79,12 @@ of `--help` or `-h` is treated as `help`.
7579| `ops` | print the operation log; §7.7 |
7680
7781For every reserved command except `push` and `commands`, the remaining words
78−are literal arguments (no quoting, no escaping); a wrong number is a usage
79−error (exit 2) and the language is not involved. `commands` takes the remaining
82+are literal arguments, and the language is not involved. When the first
83+shell argument is the command name, the remaining shell argument boundaries
84+are preserved, so `j clone '/path/to/source repo' 'my project'` accepts both
85+paths. A command supplied as one string or on stdin is split on whitespace;
86+it has no additional quote or escape syntax. A wrong argument count is a
87+usage error (exit 2). `commands` takes the remaining
8088trimmed text as its optional search query, including spaces. For `push`, the
8189remainder of the text is an expression, evaluated as in §1.2 steps 2–7 but
8290against the repository as recorded, without snapshotting the working directory;
@@ -96,11 +104,18 @@ for that reserved command or for a name in the current configuration, including
96104its signature when present and available documentation. `--help [NAME]` and
97105`-h [NAME]` are equivalent to `help [NAME]`. These successful requests exit 0.
98106An unknown name is a usage error (exit 2) with guidance to use `j commands`.
99−
100−`commands` lists the reserved commands and the vocabulary actually declared or
101−defined in the current configuration, including custom definitions without
102−signatures. `commands QUERY` searches the catalog case-insensitively by name,
103−signature, and documentation comments. The catalog does not present an
107+`j help config` is a built-in topic showing the configuration path, identity
108+setup, and repair instructions. It is available even with invalid configuration.
109+
110+`commands` shows a concise catalog of common tasks and their commands.
111+`commands all` lists the reserved commands and vocabulary actually declared
112+or defined in the current configuration, including custom definitions without
113+signatures. The concise catalog includes a bounded selection of custom
114+definitions and points to the full list. `commands QUERY` searches the catalog case-insensitively by name,
115+signature, documentation comments, and common task synonyms such as `commit`,
116+`checkout`, and `branch`, ranking direct matches and everyday commands first.
117+The `stash` query explains the save/new workflow when no custom `stash`
118+definition exists. The catalog does not present an
104119undeclared builtin as available merely because the binary implements it.
105120
106121General help and help for reserved commands do not depend on configuration and
@@ -131,7 +146,8 @@ resolves commit ids, snapshots the working directory, or records operations.
1311467. If `v` is a function, apply it to `r`, once, and let `v` be the result.
1321478. If `v` is a `Repo` (§4.4): if it equals the repository value *as loaded,
133148 before the snapshot of §7.4*, do nothing at all; otherwise persist it
134− (§7.5) and check out its focused commit's files (§7.4). Print nothing.
149+ (§7.5) and check out its focused commit's files (§7.4). Print nothing to stdout;
150+ interactive stderr reports the outcome and focus (§1.5).
135151 Exit 0.
1361529. Otherwise display `v` (§5.1) on stdout. Persist nothing. Exit 0.
137153
@@ -141,7 +157,7 @@ persist nothing, leave the working directory untouched, and exit 1.
141157Step 7 applies exactly once. If the result of applying a function to `r` is
142158again a function, that function is displayed, not applied again.
143159
144−Step 8's equality test means `j id` is a no-op when the working directory is
160+Step 8's equality test means `j save` (the reference alias for `id`) is a no-op when the working directory is
145161unchanged and records it when it is not: the snapshot makes `r` differ from
146162the loaded value, and that difference is what gets persisted. The test may be
147163implemented by comparing commit and tree hashes rather than values.
@@ -153,9 +169,11 @@ Two consequences worth knowing:
153169 what a rebase would produce without doing it. `validate` in the chain,
154170 `j tree . validate . rebase trunk`, additionally raises whatever
155171 persistence would refuse, so the preview is of something that could
156− actually be persisted.
157−- **Do, then look.** `j squash` prints nothing. `j squash && j tree` is the
158− idiom; there is no flag for it.
172+ actually be persisted. The reference `preview EDIT` is the convenient
173+ form of `tree . validate . EDIT`: `j 'preview (rebase trunk)'`. Validation
174+ alone returns a `Repo`, so `validate . EDIT` persists a changed result.
175+- **Do, then look.** `j squash` reports the resulting focus on an interactive
176+ terminal. `j squash && j tree` displays the whole tree; there is no flag for it.
159177
160178### 1.3 Interrupts
161179
@@ -215,6 +233,16 @@ or a nonempty `NO_COLOR`. Diagnostics use the stderr terminal; displayed
215233values use stdout. Error prefixes are bright red, informational prefixes
216234bright cyan, and interactive successful commands have a green checkmark.
217235Color 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.
218246
219247When both stdout and stderr are capable terminals, repository operations,
220248configuration evaluation, and expression evaluation (including implicit application)
@@ -235,8 +263,10 @@ most 96 ms of total pacing. Its bytes and row order are unchanged. Plain
235263text is written directly. `J_ANIMATIONS=never` (also `off`, `false`, or `0`,
236264case-insensitive) disables all motion while retaining colors. Motion is
237265also disabled by nonempty `NO_COLOR` or `CI`, `TERM=dumb`, or either output
238−stream being redirected. Interactive success messages are omitted in
239−these noninteractive modes; `J_ANIMATIONS` alone does not suppress them.
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.
240270Broken pipes and failed writes keep the behavior in §1.4.
241271
242272---
@@ -573,11 +603,12 @@ as in §4.13.
573603| `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 |
574604| `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) |
575605| `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 |
576−| `difft p a b` | the output of difftastic comparing `a` to `b` as the file `p`; §7.10. Crashes if `difft` is not on `PATH` |
606+| `difft p a b` | the output of difftastic comparing `a` to `b` as the file `p`, with a unified-diff fallback when the executable is absent; §7.10 |
607+| `unrecorded repo` | paths changed between the invocation's loaded and snapshotted focus files; §7.4. Returns `[]` without snapshot context, including in a push expression |
577608| `treeWith o r` | the history rendered as a tree with options record `o`; §7.11 |
578609| `extract S v` | every subvalue of `v` that is an `S`; §4.12 |
579610| `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) |
580−| `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). Persistence runs the same checks |
611+| `validate repo` | `repo` unchanged if persisting it would succeed; otherwise the crash persistence would raise (§7.5 steps 1–3, the root invariant of 4, and 6). A changed repository also checks the backend's author identity (§7.9). Persistence runs the same checks |
581612
582613### 4.10 Ids: existing and new
583614
@@ -741,6 +772,21 @@ display forms. Any other record is generic.
741772| list of other scalars | `a, b, c` | same, wrapped at the terminal width |
742773| any other list | `n items` | one block per item, separated by a blank line |
743774
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+
744790An empty list of any kind prints `none` (dim) in block form and `0 …` in
745791line form. Author and age in a `Commit` block are read from jj's commit
746792metadata by id;
@@ -764,12 +810,13 @@ Examples:
764810
765811```
766812$ j status
767−changed src/lexer.rs
768− src/parser.rs
769−conflicts src/lexer.rs
770−id wqzt·kpqxmnrvyxskptlmzzzzabcd
771−labels feature
772−message wip
813+in commit src/lexer.rs
814+ src/parser.rs
815+conflicts src/lexer.rs
816+id wqzt·kpqxmnrvyxskptlmzzzzabcd
817+labels feature
818+message wip
819+unrecorded All edits recorded
773820
774821$ j conflicts
775822⊗ wqzt wip feature
@@ -881,6 +928,14 @@ embedded reference `config.j` (§9) to that path as an editable starting point,
881928readable and writable by its owner, and reports its path. An existing file is
882929never overwritten, including when it cannot be read or contains invalid
883930UTF-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.
884939An existing user configuration is not updated when the binary changes. The
885940reference file is also distributed at `<prefix>/share/j/config.j`.
886941
@@ -890,6 +945,23 @@ names read it without evaluation. If it is missing they use the embedded
890945reference in memory without writing a file. The empty-invocation guide does
891946not read or write configuration either.
892947
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+
893965### 6.2 Validation
894966
895967Loading fails (exit 3) if any of these hold:
@@ -913,6 +985,13 @@ not declared is simply unbound, and using it is a runtime crash.
913985
914986Definitions may reference each other in any order.
915987
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+
916995### 6.3 Definitions the interpreter reads
917996
918997Four names in `config.j` are read by the interpreter itself, not only by
@@ -1148,6 +1227,14 @@ directory.
11481227
11491228Reserved commands never snapshot.
11501229
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+
11511238### 7.5 Persistence
11521239
11531240Given the loaded value `old` and the result `new`, one jj operation is
@@ -1287,6 +1374,13 @@ language: `fetch` and `push` are the only things that change them.
12871374 creating it if absent;
12881375 - `{ delete : Text }` — delete the bookmark of that name.
12891376
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+
12901384 Duplicates are merged; two records with the same `name` and different
12911385 effects are a crash. It is refused (exit 1, nothing pushed) if:
12921386 - a `name` is not a valid git branch name, an `id` is not a visible commit
@@ -1330,21 +1424,24 @@ language: `fetch` and `push` are the only things that change them.
13301424 bookmarks, and the refused push does not touch the label. After a
13311425 successful push the labels reflect the new positions. It records one
13321426 operation, even when it sends two git pushes. An empty list sends nothing
1333− to the remote.
1427+ to the remote, records no operation, and always reports that no updates
1428+ were selected, with guidance for selecting a first bookmark. A nonempty
1429+ successful push reports published, deleted, and unchanged bookmark counts
1430+ on an interactive terminal, including an entirely unchanged selection.
13341431 If the remote accepts some of the updates and rejects others, that
13351432 operation records the accepted ones, whose labels move, and `push` exits 1
13361433 naming the rejected bookmarks and any deletes it did not send.
13371434
13381435Only the remote named `origin` is supported in this version; `fetch` and
1339−`push` exit 1 with `j: no remote origin` when it is not configured.
1436+nonempty `push` exit 1 with `j: no remote origin` when it is not configured.
13401437Authentication is git's: the ssh agent for ssh URLs, git credential helpers
13411438for https, as jj-lib's git backend provides them; `j` adds no prompting.
13421439Bringing work up to date is a separate expression, e.g. `rebase trunk`.
13431440
13441441### 7.7 Operations, undo, redo, and the lock
13451442
1346−Every persisting `j` program, and each of `fetch` and `push`, records exactly
1347−one jj operation, described by the expression or command text. A push's
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
13481445description includes its `push ` prefix, so an expression named `init` or
13491446`clone` cannot be mistaken for the operation that created the repository.
13501447Crashes record nothing, except as §7.5 step 7 says.
@@ -1385,8 +1482,10 @@ history crosses from older markers into current ones.
13851482Both refuse, before doing anything, if the working directory holds a change
13861483the focused commit does not, as a snapshot would find it (§7.4; a stale
13871484working copy holds none where the directory holds what was last checked out
1388−or what the commit has): `j: working copy has changes not in @; run \`j id\`
1389−to record them or discard them`. Both record their operation and then check
1485+or what the commit has). The refusal explains that `j id` preserves those
1486+edits in a new operation, and the next undo reverses that recording before
1487+reaching the earlier operation; users should inspect `j ops` and can recover
1488+the recording with redo. Both record their operation and then check
13901489out the restored focus, as persistence does (§7.5 step 7): a checkout that
13911490fails leaves the operation recorded, and `redo` or `undo` goes back.
13921491Undoing a `push` restores the recorded labels and leaves the remote untouched.
@@ -1439,7 +1538,9 @@ that state, §7.2 applies.
14391538 it: the last component of `URL`'s path, a final `.git` component skipped,
14401539 minus `.git` (`repo` for `/srv/repo/.git`, `host:repo.git` and
14411540 `https://host/team/repo`); a `URL` that leaves no name, such as `/` or
1442− `..`, is a usage error asking for `DIR`. It requires `user`.
1541+ `..`, is a usage error asking for `DIR`. It requires `user`. Interactive
1542+ confirmation includes a shell-quoted command to enter the clone directory
1543+ and run `j status`; cloning cannot change the caller's working directory.
14431544- **`remote URL`** sets the URL of `origin`, creating the remote if it does not
14441545 exist. It does not fetch.
14451546
@@ -1470,7 +1571,16 @@ Commits are authored and committed as `user.name <user.email>` from
14701571`config.j` (§6.3). `j` reads no other configuration; jj-lib's settings object
14711572is constructed by `j` from `user` and fixed defaults. `init`, `clone`, and
14721573persistence exit 3 if `user` is missing or malformed, since that is a
1473−configuration error.
1574+configuration error. Authoring also refuses blank fields or the shipped
1575+`Your Name` / `you@example.com` placeholders (§6.2), with instructions to
1576+edit the actual config file. Read-only expressions and unchanged results
1577+remain available with placeholder identity. Successful interactive setup
1578+reports the configured identity; terminal control characters are escaped
1579+in confirmations without modifying the identity stored in commits.
1580+`validate` checks the author identity when its argument differs from the
1581+loaded repository, so validated previews report the same identity refusal
1582+before saving. That refusal is a catchable crash, like other `validate`
1583+failures. An unchanged `validate` and previews without `validate` remain usable.
14741584
14751585### 7.10 External diff rendering
14761586
@@ -1491,15 +1601,24 @@ program.
149116014. Return stdout as `Text`. A non-zero exit status is not an error (difftastic
14921602 uses it to signal "differences found" under some options), so what a
14931603 `difft` that fails printed, even nothing, is the result; a failure to
1494− execute the program is a crash naming it.
1604+ execute the program because it is absent uses the fallback below. Other
1605+ launch failures are crashes naming the tool and the OS error.
1606+
1607+If the executable is absent (`NotFound`), return a unified diff with three
1608+lines of context and `--- a/PATH` / `+++ b/PATH` headers using the complete
1609+path `p`. Paths containing whitespace or control characters are quoted.
1610+Content that is not UTF-8 or contains NUL receives a binary-difference summary.
1611+Equal bytes produce empty output. This fallback is usable without installing
1612+another program or changing configuration.
14951613
14961614All rendering options are difftastic's own, set through its `DFT_*`
14971615environment variables. `j` adds no options of its own. `diff` remains
1498−available as a dependency-free unified diff; the reference `config.j` uses
1499−`difft` for display, so a user without difftastic edits one line.
1616+available as a dependency-free unified diff without path headers; the
1617+reference `config.j` uses `difft` for display and receives the automatic
1618+fallback when difftastic is absent.
15001619
1501−Packaging: difftastic is a runtime dependency; on Nix, wrap `j` with `difft`
1502−on its `PATH`.
1620+Packaging: difftastic is an optional runtime enhancement; on Nix, wrap `j`
1621+with `difft` on its `PATH`.
15031622
15041623### 7.11 Tree rendering
15051624
@@ -1742,6 +1861,10 @@ Drop = { delete : Label } -- delete the remote's bookmark
17421861------------------------------------------------------------------------------
17431862
17441863(.) : (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.
17451868id : a -> a
17461869const : a -> b -> a
17471870crash : Text -> a -- abort the program with a message
@@ -1809,10 +1932,19 @@ splitOn : Text -> Text -> [Text] -- splitOn "/" "a/b" = ["a" "b"]
18091932-- file ./a would, crashes, naming the path (§7.3).
18101933replay : Snapshot -> Change -> Snapshot
18111934unresolved : 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
18121942blob : Text -> Blob -- a resolved regular file
18131943blobHex : Text -> Text -> Blob -- kind (regular/executable/symlink), hex bytes
18141944text : Blob -> Text -- its content (markers if unresolved)
18151945by : 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
18161948subtreeCommits : a -> [Commit] -- every commit of a tree in preorder, in one
18171949 -- pass: what `commits` (below) is
18181950meta : Id -> Meta -- git hash, author, email, committer time;
@@ -1822,9 +1954,11 @@ validate : Repo -> Repo -- the repo unchanged, or the crash p
18221954 -- focus). `tree . validate . rebase trunk` is an
18231955 -- honest dry run.
18241956diff : Blob -> Blob -> Text -- unified diff from the first to the second
1825−difft : Path -> Blob -> Blob -> Text -- difftastic's rendering; needs difft on PATH.
1826− -- Options via DFT_* environment variables.
1827−treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text }
1957+difft : Path -> Blob -> Blob -> Text -- difftastic when difft is on PATH; otherwise
1958+ -- unified diff with path headers and 3 context lines,
1959+ -- or a binary-files summary. DFT_* configures difft.
1960+treeWith : { detail : Int, margin : Bool, elide : Bool, icons : Bool, color : Text
1961+ , lanes : Int, author : Bool, date : Bool, files : Bool }
18281962 -> Repo -> Text -- the history as a tree; see the spec
18291963
18301964-- Every subvalue of a given shape, however deeply nested. A shape is a type
@@ -1910,6 +2044,9 @@ restrict = \m -> filter (\e -> m e.path)
19102044select : Fileset -> Snapshot -> Snapshot -> Snapshot -- m from a, the rest from b
19112045select = \m a b -> restrict m a ++ restrict (neg m) b
19122046
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`.
19132050conflicted : Snapshot -> [Path]
19142051conflicted = \s -> map (.path) (filter (\e -> unresolved e.content) s)
19152052
@@ -1994,6 +2131,11 @@ minus = \a b repo -> let ys = b repo in filter (\i -> not (member i ys)) (a repo
19942131matching : (Commit -> Bool) -> Revset -> Revset
19952132matching = \p rs repo -> filter (\i -> p (commitAt i repo)) (rs repo)
19962133
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.
19972139conflicts : Revset
19982140conflicts = matching (\c -> not (null (conflicted c.files))) all
19992141
@@ -2002,7 +2144,10 @@ firstOf : [Revset] -> Revset
20022144firstOf = \rss repo -> foldl (\acc rs -> if null acc then rs repo else acc) [] rss
20032145
20042146-- The main line: whichever of these names the remote has, first one wins.
2005−-- Empty in a repository with no remote, in which case nothing is immutable.
2147+-- Empty when no fetched remote label is named main, master, or trunk;
2148+-- nothing is then immutable. Configure `j remote URL`, run `j fetch`, and
2149+-- inspect `j tree` to see remote labels. A remote with another default label
2150+-- needs this definition adjusted. Start work with j 'new . goto trunk'.
20062151trunk : Revset
20072152trunk = firstOf [%main %master %trunk]
20082153
@@ -2018,21 +2163,33 @@ immutable = ancestorsOf trunk
20182163-- Focusing. `goto` is the only place a revset must have exactly one member.
20192164------------------------------------------------------------------------------
20202165
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.
20212168goto : Revset -> Edit
20222169goto = \rs repo ->
20232170 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"
20242175 in if length is == 1
20252176 then by (head is) repo
2026− else crash ("expected one revision, got " ++ show (length is))
2177+ else if null is
2178+ then crash "expected one revision, got 0; no matching revision. Run `j tree` to inspect available revisions. For a missing remote label such as %main or trunk, configure `j remote URL`, run `j fetch`, and check the label names in `j tree`."
2179+ else crash ("expected one revision, got " ++ show (length is) ++ "\nCandidates:\n" ++ concat (map candidate (take 8 is)) ++ (if length is > 8 then " More candidates: run `j tree`.\n" else "") ++ "Choose a mutable commit with `j by @ID`, or start a child with `j 'new . by @ID'`; replace @ID with a candidate above.")
20272180
20282181commitAt : Id -> Repo -> Commit
2029−commitAt = \i repo -> (by i repo).root
2182+commitAt = lookupCommit
20302183
20312184prev : Edit -- the parent
20322185prev = goto parents
20332186
2034−next : Edit -- the only child
2035−next = goto kids
2187+-- Move to the only child. At a tip, `j new` starts a child; when there are
2188+-- several children, choose one of the candidates with `j by @ID`.
2189+next : Edit
2190+next = \repo -> if null (kids repo)
2191+ then crash "next: this commit has no children; run `j new` to start a new child, or `j tree` to inspect the history"
2192+ else goto kids repo
20362193
20372194tip : Edit -- end of the line of only-children
20382195tip = \repo -> tip (next repo) or repo
@@ -2058,6 +2215,8 @@ eachChild = forEach kids
20582215guard : (Repo -> Bool) -> Edit -- crash unless p holds
20592216guard = \p repo -> if p repo then repo else crash "guard"
20602217
2218+-- Whether the focused commit has no unresolved files. This says nothing
2219+-- about unrecorded working-copy edits; inspect those with `j unrecorded`.
20612220clean : Repo -> Bool
20622221clean = \repo -> null (conflicted repo.root.files)
20632222
@@ -2096,11 +2255,28 @@ rewrite = \f repo ->
20962255-- Commands
20972256------------------------------------------------------------------------------
20982257
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+
20992273-- Set the message of the focused commit.
2274+-- Example: j 'describe "fix login"'
21002275describe : Text -> Edit
21012276describe = \m -> mapRoot (\c -> c { message = m })
21022277
21032278-- Start an empty child of the focus and move to it.
2279+-- Examples: j new; j 'describe "next change" . new'
21042280new : Edit
21052281new = \repo -> addChild (newCommit repo.root.files) repo
21062282
@@ -2108,6 +2284,7 @@ new = \repo -> addChild (newCommit repo.root.files) repo
21082284-- new empty commit on that parent becomes the focus (the parent may be
21092285-- immutable, and the focus never is). Refused for a commit the remote has a
21102286-- 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'.
21112288abandon : Edit
21122289abandon = \repo ->
21132290 let c = repo.root
@@ -2117,17 +2294,20 @@ abandon = \repo ->
21172294 else new (remove (eachChild (replayOnto p) repo))
21182295
21192296-- Push the files matching m from the focus down into its parent.
2297+-- Example: j 'contract (ext "rs")'
21202298contract : Fileset -> Edit
21212299contract = \m repo -> let a = repo.root
21222300 in at parents (rewrite (\q -> q { files = select m a.files q.files })) repo
21232301
21242302-- Fold the focus entirely into its parent; a new empty commit becomes the focus.
2303+-- Example: j squash; preview first with j 'preview squash'.
21252304squash : Edit
21262305squash = abandon . contract everything
21272306
21282307-- Push the files matching m from the focus up into a new child, which becomes
21292308-- the focus and inherits the old focus's children. Labels stay with the
21302309-- original commit, since they are the remote's.
2310+-- Example: j 'split (ext "rs")'; use (under ./src) to select a directory.
21312311split : Fileset -> Edit
21322312split = \m repo ->
21332313 let a = repo.root
@@ -2137,6 +2317,7 @@ split = \m repo ->
21372317 in by x.id (repo { root = a', children = [{ root = x, children = repo.children }] })
21382318
21392319-- Move the focused subtree onto another commit. Returns to the moved commit.
2320+-- Example: j 'rebase trunk'; preview with j 'preview (rebase trunk)'.
21402321rebase : Revset -> Edit
21412322rebase = \rs repo ->
21422323 let d = (goto rs repo).root
@@ -2147,6 +2328,7 @@ rebase = \rs repo ->
21472328 in by me (attach x.subtree (by d.id x.rest))
21482329
21492330-- Apply the focused commit's change onto another commit, as a new child there.
2331+-- Example: j 'pick %main'
21502332pick : Revset -> Edit
21512333pick = \rs repo ->
21522334 let ch = changeOf repo
@@ -2154,6 +2336,7 @@ pick = \rs repo ->
21542336 in at rs (addChild (newCommit (replay t.files ch))) repo
21552337
21562338-- A new child of the focus that undoes the change of the given commit.
2339+-- Example: j 'backout %feature'
21572340backout : Revset -> Edit
21582341backout = \rs repo ->
21592342 let ch = changeOf (goto rs repo)
@@ -2199,20 +2382,22 @@ entryAt = \p s -> filter (\e -> e.path == p) s
21992382
22002383-- The content at path p, or an empty file if absent.
22012384contentAt : Path -> Snapshot -> Blob
2202−contentAt = \p s -> (head (entryAt p s)).content or blob ""
2385+contentAt = lookupContent
22032386
22042387-- The paths a change touches.
22052388touched : Change -> [Path]
2206−touched = \ch ->
2207− let paths = map (.path) ch.to ++ filter (\p -> not (member p (map (.path) ch.to))) (map (.path) ch.from)
2208− in filter (\p -> entryAt p ch.to /= entryAt p ch.from) paths
2389+touched = touchedPaths
22092390
2210−-- Paths whose content differs between the focus and its parent.
2391+-- Paths changed by the focused commit compared with its parent, including
2392+-- the working-copy snapshot. These can remain nonempty after `j save`;
2393+-- `j unrecorded` shows only working-copy edits not yet recorded.
2394+-- Example: j changed
22112395changed : Repo -> [Path]
22122396changed = \repo -> touched (changeOf repo)
22132397
2214−-- What the focus changes, rendered by difftastic, one record per changed path.
2215−-- Replace difft with (\p -> diff) to use the built-in unified diff instead.
2398+-- What the focus changes, one record per changed path. Uses difftastic when
2399+-- installed; a missing difft falls back to a unified diff with path headers
2400+-- and 3 context lines, or a binary-files summary.
22162401diffs : Repo -> [{ path : Path, diff : Text }]
22172402diffs = \repo ->
22182403 let ch = changeOf repo
@@ -2220,23 +2405,30 @@ diffs = \repo ->
22202405
22212406-- The same, as one page of text. A Text result prints raw, so `j review` is
22222407-- readable in the terminal. A focus that changes nothing reads "no changes";
2223−-- a difft that cannot be run (not on PATH) is a crash saying so, and
2224−-- otherwise whatever difft prints, even nothing, is the page.
2408+-- missing difft uses the built-in unified diff (path headers, 3 context lines)
2409+-- or a binary-files summary. Other execution errors remain visible; an
2410+-- installed difft's output, even nothing, is the page.
2411+-- Example: j review; difftastic is optional.
22252412review : Repo -> Text
22262413review = \repo ->
22272414 let ds = diffs repo
22282415 in if null ds then "no changes\n" else concat (map (\d -> d.diff) ds)
22292416
2230−-- A summary of the focus.
2231−status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path] }
2417+-- A summary of the focus. `changed` lists changes in the commit compared
2418+-- with its parent; `unrecorded` lists working-copy edits not yet recorded.
2419+-- Inspection does not record anything. Run `j save` to record those edits.
2420+-- Example: j status
2421+status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path], unrecorded : [Path] }
22322422status = \repo ->
22332423 { id = repo.root.id
22342424 , message = repo.root.message
22352425 , labels = repo.root.labels
22362426 , changed = changed repo
2237− , conflicts = conflicted repo.root.files }
2427+ , conflicts = conflicted repo.root.files
2428+ , unrecorded = unrecorded repo }
22382429
22392430-- A summary of every commit, top first, in preorder.
2431+-- Example: j log
22402432log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]
22412433log = \repo ->
22422434 map (\c -> { id = c.id
@@ -2247,11 +2439,21 @@ log = \repo ->
22472439
22482440-- The history as a tree. Edit these records to change what `j tree` shows.
22492441-- (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.
22502445tree : Repo -> Text
2251−tree = treeWith ({ detail = 1, margin = false, elide = true, icons = false, color = "auto" })
2446+tree = treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
2447+
2448+treeCompact : Repo -> Text
2449+treeCompact = treeWith ({ detail = 1, margin = false, elide = true, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
22522450
22532451treeFull : Repo -> Text
2254−treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto" })
2452+treeFull = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
2453+
2454+-- The full tree plus the extra data columns: date, files changed, author.
2455+treeData : Repo -> Text
2456+treeData = treeWith ({ detail = 2, margin = true, elide = false, icons = false, color = "auto", lanes = 4, author = true, date = true, files = true })
22552457```
22562458
22572459---
@@ -2297,9 +2499,10 @@ here is out of scope (§12). Nothing here uses the `jj` binary.
22972499**Getting a repository**
22982500
22992501```
2300−$EDITOR ~/.config/j/config.j -- set `user` once
2502+j setup 'Your actual name' you@your-domain.example -- create the full config once
23012503j init -- new repository here, or adopt an existing .git
2302−j clone git@host:org/repo.git -- or an existing one
2504+j clone git@host:org/repo.git project -- or clone an existing one
2505+cd project -- after clone, enter its directory
23032506j remote git@host:org/repo.git -- attach a remote to an init'd repo
23042507```
23052508
@@ -2308,9 +2511,9 @@ j remote git@host:org/repo.git -- attach a remote to an init'd repo
23082511```
23092512j tree -- the history as a tree, focus marked
23102513j treeFull -- the same with sizes, ages, authors, changed paths
2311−j status -- id, message, labels, changed paths, conflicts of @
2514+j status -- focus, in-commit changes, unrecorded edits, conflicts
23122515j log -- every commit as a record
2313−j review -- difftastic rendering of @ against its parent
2516+j review -- difftastic or unified diff of @ against its parent
23142517j review . by @kpq -- the same for another commit, without moving
23152518j diffs -- the same, one record per path
23162519j here -- @'s id
@@ -2341,7 +2544,8 @@ j rebase trunk
23412544j guard clean . rebase trunk or id -- only if it comes out clean
23422545j pick @kpq
23432546j 'forEach (descendantsOf here) (describe "wip")'
2344−j id -- record the working directory, change nothing else
2547+j save -- record the working directory, change nothing else
2548+j 'preview squash' -- validate and show the result without saving
23452549j tree . squash -- dry run: show the result, persist nothing
23462550j tree . validate . squash -- the same, refusing what persistence would refuse
23472551j squash && j tree -- do, then look
@@ -2363,7 +2567,10 @@ j rebase trunk -- bring the stack up to date
23632567A rebase or fetch that conflicts leaves unresolved files in the affected
23642568commits. `j conflicts` lists them; `j by @…` moves there, which checks the
23652569files out with conflict markers; editing the files and running any persisting
2366−expression (`j id` at minimum) records the resolution.
2570+expression (`j save` or `j id` at minimum) records the resolution. Run
2571+`j status` to verify `No conflicts` and `All edits recorded` in the focus,
2572+and `j conflicts` to check the rest of the history. No continue command is
2573+needed.
23672574
23682575**Mistakes**
23692576
@@ -2378,7 +2585,7 @@ j redo
23782585Any definition added to `config.j` whose value is an `Edit` is a command:
23792586
23802587```haskell
2381−pr = \n -> describe n . squash
2588+pr = \n -> describe n . prev . squash
23822589```
23832590
23842591```
@@ -2395,5 +2602,5 @@ language (blobs are opaque); workspaces other than the default; sparse working
23952602copies (refused; §7.1); remotes other than `origin`; tags; submodules and LFS;
23962603ignore rules beyond `.gitignore`; repairing a repository in which one change
23972604id has two visible commits (refused; §7.2); general command flags beyond the
2398−first-word `--help` and `-h` aliases; static typing. None of
2605+`--help` and `-h` discovery arguments; static typing. None of
23992606these are precluded by the design.
specs/tree.md+36−22
@@ -137,10 +137,16 @@ always to the right of its source.
137137If rule 5 or a reservation finds no empty lane in `0 < ℓ < lanes`, the
138138subtree rooted at `n` is **flattened**: `n` and every descendant of it still
139139get their rows, in row order, with their id, message, labels and margin, but
140−they take no lane. Their rails area shows only the rails that pass through
141−and `»` in the last column. Nothing is hidden — the focus in particular is
142−always shown — only the shape beyond `lanes` is lost. Widen `lanes` (or
143−enable `elide`) to see it.
140+they take no lane. Their rails area preserves every rail that passes through.
141+If any rendered node is flattened, a separate four-column status area
142+is reserved between the rails and the id separator on every row. Flattened
143+rows show `»`; on a commit row this is followed by a space and its normal
144+node glyph (§Glyphs), padded to four display columns. Non-flattened rows
145+leave the area blank. Neither the overflow marker nor a node glyph replaces
146+a live rail. Nothing is hidden —
147+the focus, conflicts, and empty commits in particular are always identifiable
148+— only the shape beyond `lanes` is lost. Widen `lanes` (or enable `elide`) to
149+see it.
144150
145151#### Step 4 — drawing a row
146152
@@ -149,26 +155,28 @@ Columns, left to right, at fixed offsets:
1491551. **Gutter**, 2 characters: `▶ ` on the focus row, blank otherwise.
1501562. **Rails**, `2 · lanes` characters: lane `i` owns characters `2i` and
151157 `2i+1`.
152−3. One space.
153−4. **Id**: `@` followed by the shortest prefix unique among the commits of the
158+3. **Overflow status**, 4 display columns when any rendered node is flattened;
159+ absent otherwise (§Overflow).
160+4. One space.
161+5. **Id**: `@` followed by the shortest prefix unique among the commits of the
154162 display tree, minimum 4 — so the rendered id is itself a valid id literal
155163 (§3.1). Blank on a run row.
156−5. **Size bar** (`detail ≥ 1`, focus and its parent and children only; all
164+6. **Size bar** (`detail ≥ 1`, focus and its parent and children only; all
157165 commits at `detail = 2`): `▁ ▂ ▃ ▅ ▇`, as before. A row without a bar
158166 keeps the column blank; it is left out only when no row has a bar.
159−6. **Message**: first line; nothing if empty; followed by `⋯ n` on a
167+7. **Message**: first line; nothing if empty; followed by `⋯ n` on a
160168 collapsed node.
161−7. **Labels**: left-aligned column, present only if any rendered commit has
169+8. **Labels**: left-aligned column, present only if any rendered commit has
162170 a label; names separated by two spaces.
163−8. **Margin** (`margin = true`): age and author initials, as before, each
171+9. **Margin** (`margin = true`): age and author initials, as before, each
164172 right-aligned in its own column (a `9m` ends under a `13m`), so the margin
165173 ends at one column on every row.
166−9. **Data columns** (off unless enabled): `date` shows the commit's absolute
174+10. **Data columns** (off unless enabled): `date` shows the commit's absolute
167175 date `YYYY-MM-DD`; `files` shows the number of files changed as `n files`;
168176 `author` shows the full author name. They appear in that order, grey,
169177 immediately before the margin in the same right-aligned block.
170178
171−Columns 4–8 start at the same offset on every row. Labels start two columns
179+Columns 5–9 start at the same offset on every row. Labels start two columns
172180past the widest message, and the data columns and the margin start two
173181columns past the widest label (past the widest message when there is no
174182label column). Each data column, the age and the initials are as wide as
@@ -187,7 +195,6 @@ and the margin keep their columns; otherwise nothing is cut.
187195| `i` lies between the two ends of a horizontal segment on this row and holds a rail | `┼` |
188196| `i` lies between the two ends of a horizontal segment on this row | `─` |
189197| `i` holds a rail passing through this row | `│` |
190−| flattened rows only: `i = lanes − 1` | `»` |
191198| otherwise | space |
192199
193200Character `2i+1` is `─` if it lies inside a horizontal segment (from a fork
@@ -199,12 +206,16 @@ rail. The count is never cut: what does not fit the rails area runs on,
199206unbroken, through the space after it into the id column, which is widened if
200207it cannot hold the rest.
201208
202−**Detail line** (`detail = 2`, focus only): one extra row directly under the
203−focus. Its rails show `│` in every lane that holds a rail below the focus row
204−(including the focus's own lane if it has children); the text starts two
205−columns into the id column, under the focus's id, and lists the changed paths
206−with a mark each:
207−`+` added, `~` modified, `−` deleted, `✖` unresolved.
209+**Detail lines** (`detail = 2`, focus only): changed paths appear directly
210+under the focus, with a mark each: `+` added, `~` modified, `−` deleted,
211+`✖` unresolved. Every detail row shows `│` in every lane that holds a rail
212+below the focus row (including the focus's own lane if it has children), and
213+leaves any overflow status column blank. The text starts two columns into the
214+id column, under the focus's id, at the same offset on every continuation row.
215+When stdout is a terminal, paths wrap onto continuation rows within the width
216+available after this prefix. A path longer than that width may split across
217+rows; wrapping does not discard any path content. Redirected output keeps
218+the changed paths on a single detail row.
208219
209220#### Glyphs
210221
@@ -226,7 +237,8 @@ its parent's in that history — not of the stored commit with the same id, so
226237a dry run (§1.2) draws them as the edit would leave them.
227238An `icons = true` glyph is two columns wide, so it fills both characters of
228239its lane: character `2ℓ+1` of its row is not drawn.
229−Rails use `│ ├ ╰ ─ ┼ ╮ ┬ ╎ »`.
240+Rails use `│ ├ ╰ ─ ┼ ╮ ┬ ╎`; `»` belongs to the separate overflow status
241+column.
230242
231243#### Elision (`elide = true`)
232244
@@ -285,8 +297,10 @@ The legend covers the node glyphs present (§Glyphs), the focus gutter `▶`,
285297run `╎ n` and collapsed `⋯ n` rows when shown, and the detail-line marks
286298(`+` added, `~` modified, `−` deleted, `✖` unresolved) when a detail line is
287299present. A collapsed node shows `⋯ n` only when `n > 0`, so the `⋯` legend
288−entry appears only then. The rails characters are not itemised; they are
289−structural (§Step 4).
300+entry appears only then. When any rendered node is flattened, it also includes
301+`» branch exceeds lane limit; increase lanes`. Node glyphs in the overflow
302+status column contribute their normal legend entries. The rails characters
303+are not itemised; they are structural (§Step 4).
290304
291305The legend text is faint (dim). Placement: when stdout is a terminal and the
292306tree's widest line plus the legend fits the terminal width, the legend is set
src/builtins.rs+28
@@ -95,6 +95,7 @@ pub fn all_builtins() -> Vec<(String, Value)> {
9595 builtin("replay", 2, b_replay),
9696 builtin("unresolved", 1, b_unresolved),
9797 builtin("touchedPaths", 1, b_touched_paths),
98+ builtin("unrecorded", 1, b_unrecorded),
9899 builtin("subtreeCommits", 1, b_subtree_commits),
99100 builtin("blob", 1, b_blob),
100101 builtin("blobHex", 2, b_blob_hex),
@@ -497,6 +498,28 @@ fn b_touched_paths(_i: &mut Interp, args: &[Value]) -> BResult {
497498 Ok(Value::list(out))
498499}
499500
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+
500523/// every commit of a tree in preorder (§4.9): the value of the recursive
501524/// `\t -> t.root :: (concat (map subtreeCommits t.children) or [])`, which
502525/// the reference config's `commits` was. `::` and `concat` build new lists,
@@ -611,6 +634,11 @@ fn b_meta(i: &mut Interp, args: &[Value]) -> BResult {
611634
612635fn b_validate(i: &mut Interp, args: &[Value]) -> BResult {
613636 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+ }
614642 Ok(args[0].clone())
615643}
616644
src/config.rs+1−1
@@ -40,7 +40,7 @@ const RESERVED_BUILTINS: &[&str] = &[
4040 ">=", "show", "::", "map", "filter", "length", "null", "head", "tail", "last", "nth", "take",
4141 "drop", "member", "range", "foldl", "concat", "++", "startsWith", "endsWith", "splitOn",
4242 "replay", "unresolved", "blob", "blobHex", "text", "by", "meta", "validate", "diff", "difft", "treeWith",
43− "extract", "touchedPaths", "subtreeCommits", "lookupCommit", "lookupContent",
43+ "extract", "touchedPaths", "unrecorded", "subtreeCommits", "lookupCommit", "lookupContent",
4444];
4545
4646pub fn is_reserved_builtin(name: &str) -> bool {
src/diagnostics.rsnew file+381
@@ -0,0 +1,381 @@
1+//! Read-only guidance for common CLI mistakes. The original error remains the
2+//! authority: these helpers neither retry an expression nor change its meaning.
3+
4+use crate::ast::{Expr, TypeExpr};
5+use crate::config::Config;
6+use crate::parse::parse_expr;
7+use crate::shape::{compile_contract, type_name};
8+use crate::value::Crash;
9+use std::collections::BTreeSet;
10+use std::rc::Rc;
11+use unicode_width::UnicodeWidthChar;
12+
13+/// Add a location and the offending source line without inventing a column
14+/// that the parser does not currently retain.
15+pub fn format_source_error(label: &str, src: &str, line: usize, msg: &str) -> String {
16+ let mut output = format!("{}: line {}: {}", label, line, msg);
17+ if let Some(source_line) = line.checked_sub(1).and_then(|n| src.split('\n').nth(n)) {
18+ output.push_str(&format!("\n {} | {}", line, source_excerpt(source_line.trim_end_matches('\r'))));
19+ }
20+ output
21+}
22+
23+/// Leave room for an ellipsis, count wide characters, and render terminal
24+/// controls as visible escapes. Charge even zero-width characters one column
25+/// so a malformed line of combining marks cannot produce unlimited output.
26+fn source_excerpt(line: &str) -> String {
27+ const MAX_COLUMNS: usize = 160;
28+ let mut excerpt = String::new();
29+ let mut columns = 0;
30+ for character in line.chars() {
31+ let visible = if character.is_control() {
32+ character.escape_default().to_string()
33+ } else {
34+ character.to_string()
35+ };
36+ let width: usize = visible.chars().map(|c| c.width().unwrap_or(0).max(1)).sum();
37+ if columns + width > MAX_COLUMNS - 1 {
38+ excerpt.push('…');
39+ break;
40+ }
41+ excerpt.push_str(&visible);
42+ columns += width;
43+ }
44+ excerpt
45+}
46+
47+/// Suggest a next step only when the reported failure agrees with the user's
48+/// expression. In particular, an error inside a config definition must not be
49+/// misdiagnosed as a missing argument on the command line.
50+pub fn expression_hint(text: &str, cfg: &Config, crash: &Crash) -> Option<String> {
51+ expression_hint_for(text, cfg, crash, Invocation::Expression)
52+}
53+
54+/// Diagnose the expression following `push`, preserving that reserved command
55+/// in copyable examples so following a hint cannot turn a push into a local edit.
56+pub fn push_expression_hint(text: &str, cfg: &Config, crash: &Crash) -> Option<String> {
57+ expression_hint_for(text, cfg, crash, Invocation::Push)
58+}
59+
60+#[derive(Clone, Copy)]
61+enum Invocation {
62+ Expression,
63+ Push,
64+}
65+
66+fn expression_hint_for(text: &str, cfg: &Config, crash: &Crash, invocation: Invocation) -> Option<String> {
67+ let expr = parse_expr(text, Rc::new(cfg.global_names.clone())).ok()?;
68+ let names = vocabulary(cfg);
69+ if let Some(name) = crash.msg.strip_prefix("unbound name `").and_then(|s| s.strip_suffix('`')) {
70+ if crash.def.is_some() {
71+ return None;
72+ }
73+ let mut free = BTreeSet::new();
74+ expr.free_vars(&mut Vec::new(), &mut free);
75+ if !free.contains(name) {
76+ return None;
77+ }
78+ if let Some(hint) = text_argument_hint(&expr, cfg, &names, name, invocation) {
79+ return Some(hint);
80+ }
81+ let mut suggestions: Vec<_> = names
82+ .iter()
83+ .filter(|candidate| candidate.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_'))
84+ .filter_map(|candidate| {
85+ let limit = if name.chars().count() > 4 { 2 } else { 1 };
86+ let distance = edit_distance(name, candidate, limit)?;
87+ (distance > 0).then_some((distance, *candidate))
88+ })
89+ .collect();
90+ suggestions.sort_unstable();
91+ if let Some((distance, best)) = suggestions.first().copied() {
92+ let close: Vec<_> = suggestions
93+ .iter()
94+ .take_while(|(d, _)| *d == distance)
95+ .take(3)
96+ .map(|(_, n)| format!("`{}`", n))
97+ .collect();
98+ return Some(if close.len() == 1 {
99+ format!("Did you mean `{}`? See `{}`.", best, help_command(best))
100+ } else {
101+ format!("Did you mean {}? Use `j help NAME` for details.", close.join(" or "))
102+ });
103+ }
104+ return Some("Run `j commands` to browse the names available in your config.".into());
105+ }
106+
107+ let (name, args) = application(&expr)?;
108+ let signature = cfg.sigs.get(name)?;
109+ let contract = compile_contract(&cfg.shapes, signature);
110+ let parameter = contract.params.get(args.len())?;
111+ // A record supplied explicitly fails at an earlier argument. Requiring the
112+ // next argument to match confines this hint to the implicit repo argument.
113+ if !crash.msg.starts_with(&format!("contract: {} expected ", name))
114+ || !crash.msg.ends_with(&format!(" as argument {}, got record", args.len() + 1))
115+ || matches!(cfg.shapes.unfold(parameter), TypeExpr::Record(_))
116+ {
117+ return None;
118+ }
119+ let expected = type_name(parameter);
120+ Some(format!(
121+ "`{}` needs an argument of type {} before it can receive the repository. See `{}`.",
122+ name, expected, help_command(name)
123+ ))
124+}
125+
126+/// `global_names` also reserves every implemented builtin for shadowing checks.
127+/// Only names actually supplied by this config are useful typo suggestions.
128+fn vocabulary(cfg: &Config) -> BTreeSet<&str> {
129+ cfg.defs
130+ .iter()
131+ .map(|(name, _)| name.as_str())
132+ .chain(cfg.builtin_names.iter().map(String::as_str))
133+ .collect()
134+}
135+
136+fn unparen(mut expr: &Expr) -> &Expr {
137+ while let Expr::Paren(inner) = expr {
138+ expr = inner;
139+ }
140+ expr
141+}
142+
143+fn application(expr: &Expr) -> Option<(&str, Vec<&Expr>)> {
144+ let mut head = unparen(expr);
145+ let mut args = Vec::new();
146+ while let Expr::App(function, argument) = head {
147+ args.push(unparen(argument));
148+ head = unparen(function);
149+ }
150+ args.reverse();
151+ match head {
152+ Expr::Var(name) => Some((name, args)),
153+ _ => None,
154+ }
155+}
156+
157+fn text_argument_hint(
158+ expr: &Expr,
159+ cfg: &Config,
160+ names: &BTreeSet<&str>,
161+ missing: &str,
162+ invocation: Invocation,
163+) -> Option<String> {
164+ let (name, args) = application(expr)?;
165+ let contract = compile_contract(&cfg.shapes, cfg.sigs.get(name)?);
166+ let index = args.iter().position(|arg| matches!(arg, Expr::Var(n) if n == missing))?;
167+ let parameter = contract.params.get(index)?;
168+ if !matches!(cfg.shapes.unfold(parameter), TypeExpr::Con(n) if n == "Text") {
169+ return None;
170+ }
171+
172+ // For a single Text argument, shell-stripped words can be shown in a
173+ // complete, copyable example. Do not guess how to group multiple Text
174+ // parameters or how to rewrite a larger expression.
175+ let only_text_argument = index == 0
176+ && contract.params.iter().skip(1).all(|ty| matches!(ty.as_ref(), TypeExpr::Con(n) if n == "Repo"));
177+ let words: Option<Vec<_>> = args
178+ .iter()
179+ .map(|arg| match arg {
180+ Expr::Var(n) if !names.contains(n.as_str()) => Some(n.as_str()),
181+ _ => None,
182+ })
183+ .collect();
184+ let prefix = match invocation {
185+ Invocation::Expression => "",
186+ Invocation::Push => "push ",
187+ };
188+ match words.filter(|_| only_text_argument) {
189+ Some(words) => Some(format!(
190+ "If you meant text, preserve its double quotes by quoting the whole expression: j {}. See `{}`.",
191+ shell_quote(&format!("{}{} \"{}\"", prefix, name, words.join(" "))), help_command(name)
192+ )),
193+ None => Some(format!(
194+ "If you meant text, use double quotes for the text inside shell single quotes around the expression. See `{}`.",
195+ help_command(name)
196+ )),
197+ }
198+}
199+
200+fn shell_quote(text: &str) -> String {
201+ format!("'{}'", text.replace('\'', "'\\''"))
202+}
203+
204+fn help_command(name: &str) -> String {
205+ let argument = if name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
206+ name.to_string()
207+ } else {
208+ shell_quote(name)
209+ };
210+ format!("j help {}", argument)
211+}
212+
213+/// Bounded Damerau-Levenshtein distance catches adjacent transpositions as well
214+/// as inserted, removed, and replaced letters. Long identifiers are skipped so
215+/// error reporting cannot become expensive for generated input.
216+fn edit_distance(a: &str, b: &str, limit: usize) -> Option<usize> {
217+ let a: Vec<_> = a.chars().take(129).collect();
218+ let b: Vec<_> = b.chars().take(129).collect();
219+ if a.len() > 128 || b.len() > 128 || a.len().abs_diff(b.len()) > limit {
220+ return None;
221+ }
222+ let mut older = vec![0; b.len() + 1];
223+ let mut previous: Vec<_> = (0..=b.len()).collect();
224+ let mut current = vec![0; b.len() + 1];
225+ for (i, letter) in a.iter().enumerate() {
226+ current[0] = i + 1;
227+ for (k, other) in b.iter().enumerate() {
228+ current[k + 1] = (previous[k + 1] + 1)
229+ .min(current[k] + 1)
230+ .min(previous[k] + usize::from(letter != other));
231+ if i > 0 && k > 0 && *letter == b[k - 1] && a[i - 1] == *other {
232+ current[k + 1] = current[k + 1].min(older[k - 1] + 1);
233+ }
234+ }
235+ std::mem::swap(&mut older, &mut previous);
236+ std::mem::swap(&mut previous, &mut current);
237+ }
238+ (previous[b.len()] <= limit).then_some(previous[b.len()])
239+}
240+
241+#[cfg(test)]
242+mod tests {
243+ use super::*;
244+ use crate::config::{eval_config, load_config};
245+ use crate::eval::Interp;
246+ use crate::value::Value;
247+
248+ fn reference() -> Config {
249+ load_config(include_str!("../config.j")).unwrap()
250+ }
251+
252+ fn failure(cfg: &Config, text: &str) -> Crash {
253+ let mut interp = Interp::dummy();
254+ interp.shapes = cfg.shapes.clone();
255+ eval_config(&mut interp, cfg).unwrap();
256+ let expr = Rc::new(parse_expr(text, Rc::new(cfg.global_names.clone())).unwrap());
257+ let result = interp.eval(&expr, &interp.global_env()).and_then(|value| match value {
258+ Value::Fun(_) => interp.apply(value, Value::record(&[])),
259+ value => Ok(value),
260+ });
261+ result.expect_err("test expression must fail")
262+ }
263+
264+ #[test]
265+ fn suggests_real_commands_and_transposed_names() {
266+ let cfg = reference();
267+ for (expression, expected) in [("neww", "j help new"), ("desrcibe", "j help describe")] {
268+ let hint = expression_hint(expression, &cfg, &failure(&cfg, expression)).unwrap();
269+ assert!(hint.contains(expected), "{}", hint);
270+ }
271+ }
272+
273+ #[test]
274+ fn suggests_custom_definitions_but_not_undeclared_builtins() {
275+ let cfg = load_config("user = { name = \"A\", email = \"a@b\" }\ntree = \\r -> \"\"\nimmutable = \\r -> []\nlabelled = \\n r -> []\nship = \\r -> r\n").unwrap();
276+ let hint = expression_hint("shipp", &cfg, &failure(&cfg, "shipp")).unwrap();
277+ assert!(hint.contains("j help ship"));
278+ let hint = expression_hint("repla", &cfg, &failure(&cfg, "repla")).unwrap();
279+ assert!(!hint.contains("replay"));
280+ assert!(hint.contains("j commands"));
281+ }
282+
283+ #[test]
284+ fn explains_shell_quoting_only_for_text_parameters() {
285+ let cfg = reference();
286+ for expression in ["describe hello", "describe first change"] {
287+ let hint = expression_hint(expression, &cfg, &failure(&cfg, expression)).unwrap();
288+ assert!(hint.contains("j 'describe \""), "{}", hint);
289+ assert!(hint.contains("j help describe"));
290+ }
291+ let hint = expression_hint("rebase helloo", &cfg, &failure(&cfg, "rebase helloo")).unwrap();
292+ assert!(!hint.contains("quotes"));
293+ }
294+
295+ #[test]
296+ fn push_quoting_examples_preserve_the_reserved_command() {
297+ let cfg = reference();
298+ for (expression, corrected) in [
299+ ("unlabel feature", "unlabel \"feature\""),
300+ ("describe hello", "describe \"hello\""),
301+ ] {
302+ let crash = failure(&cfg, expression);
303+ let hint = push_expression_hint(expression, &cfg, &crash).unwrap();
304+ assert!(hint.contains(&format!("j 'push {}'", corrected)), "{}", hint);
305+ assert!(!hint.contains(&format!("j '{}'", corrected)), "{}", hint);
306+
307+ let ordinary = expression_hint(expression, &cfg, &crash).unwrap();
308+ assert!(ordinary.contains(&format!("j '{}'", corrected)), "{}", ordinary);
309+ assert!(!ordinary.contains("j 'push "), "{}", ordinary);
310+ }
311+ }
312+
313+ #[test]
314+ fn explains_implicit_repo_application_but_not_explicit_type_errors() {
315+ let cfg = reference();
316+ for expression in ["describe", "rebase", "split"] {
317+ let hint = expression_hint(expression, &cfg, &failure(&cfg, expression)).unwrap();
318+ assert!(hint.contains("before it can receive the repository"), "{}", hint);
319+ assert!(hint.contains(&format!("j help {}", expression)));
320+ }
321+ assert!(expression_hint("describe {}", &cfg, &failure(&cfg, "describe {}")).is_none());
322+ assert!(expression_hint("describe 42", &cfg, &failure(&cfg, "describe 42")).is_none());
323+ }
324+
325+ #[test]
326+ fn custom_signatures_drive_hints_after_partial_application() {
327+ let src = format!("{}\ncustom : Text -> Int -> Edit\ncustom = \\message count repo -> repo\n", include_str!("../config.j"));
328+ let cfg = load_config(&src).unwrap();
329+ let expression = "custom \"message\"";
330+ let hint = expression_hint(expression, &cfg, &failure(&cfg, expression)).unwrap();
331+ assert!(hint.contains("needs an argument of type Int"), "{}", hint);
332+ assert!(hint.contains("j help custom"));
333+ let expression = "custom \"message\" {}";
334+ assert!(expression_hint(expression, &cfg, &failure(&cfg, expression)).is_none());
335+ }
336+
337+ #[test]
338+ fn quote_examples_preserve_apostrophes_in_valid_identifiers() {
339+ let cfg = reference();
340+ let expression = "describe don't";
341+ let hint = expression_hint(expression, &cfg, &failure(&cfg, expression)).unwrap();
342+ assert!(hint.contains("j 'describe \"don'\\''t\"'"), "{}", hint);
343+ }
344+
345+ #[test]
346+ fn does_not_blame_the_cli_for_unbound_names_inside_definitions() {
347+ let cfg = reference();
348+ assert!(expression_hint("new", &cfg, &Crash::new("unbound name `neww`")).is_none());
349+ assert!(expression_hint("\\x -> x", &cfg, &Crash::new("unbound name `x`")).is_none());
350+ }
351+
352+ #[test]
353+ fn source_errors_name_the_file_and_preserve_line_numbers() {
354+ let output = format_source_error("/tmp/config.j", "first\nbroken =\n", 2, "unexpected newline");
355+ assert_eq!(output, "/tmp/config.j: line 2: unexpected newline\n 2 | broken =");
356+ assert_eq!(format_source_error("expression", "bad\n", 2, "unexpected end"), "expression: line 2: unexpected end\n 2 | ");
357+ assert_eq!(format_source_error("expression", "bad", 8, "unexpected end"), "expression: line 8: unexpected end");
358+ }
359+
360+ #[test]
361+ fn huge_unicode_source_lines_have_bounded_excerpts() {
362+ use unicode_width::UnicodeWidthStr;
363+
364+ for source in ["(".repeat(200_000), "界é🙂\u{301}".repeat(50_000), "\u{301}".repeat(200_000)] {
365+ let output = format_source_error("expression", &source, 1, "nested too deeply");
366+ let excerpt = output.split_once(" 1 | ").unwrap().1;
367+ assert!(excerpt.ends_with('…'));
368+ assert!(excerpt.width() <= 160, "excerpt exceeds display width budget");
369+ assert!(excerpt.chars().count() <= 160, "zero-width characters must also be bounded");
370+ assert!(output.len() < 700, "diagnostic must not repeat the entire huge line");
371+ }
372+ }
373+
374+ #[test]
375+ fn source_excerpts_escape_terminal_controls() {
376+ let output = format_source_error("expression", "\tfirst\u{1b}[31m\rsecond\u{7}", 1, "invalid input");
377+ let excerpt = output.split_once(" 1 | ").unwrap().1;
378+ assert_eq!(excerpt, "\\tfirst\\u{1b}[31m\\rsecond\\u{7}");
379+ assert!(!excerpt.chars().any(char::is_control));
380+ }
381+}
src/domain.rs+6
@@ -29,6 +29,12 @@ pub struct ImmutabilityInfo {
2929}
3030
3131pub trait Backend {
32+ /// Refuse an edit that cannot be authored with the backend's current
33+ /// configuration. In-memory repositories have no authoring requirement.
34+ fn validate_authoring(&self) -> Result<(), Crash> {
35+ Ok(())
36+ }
37+
3238 /// All visible change ids (full), for resolving `@prefix` literals.
3339 fn visible_ids(&self) -> Vec<String>;
3440 /// All visible ids beginning with `prefix`.
src/help.rs+189−41
@@ -13,19 +13,23 @@ Usage:
1313 j EXPRESSION… Evaluate an expression
1414 printf '%s\n' 'tree' | j Read an expression from stdin
1515 j help [NAME] Explain a command or config name
16− j commands [QUERY] Search names, signatures, and documentation
16+ j commands [QUERY] Find everyday commands by task
17+ j commands all Browse the complete vocabulary
18+ j help config Set your identity or repair configuration
1719
1820Start a repository:
21+ j setup 'Ada Lovelace' ada@example.org Create config with your identity
1922 j init Create a repository in the current directory
2023 j clone URL [DIR] Clone a repository
2124
2225Inspect (default config):
2326 j tree Show the history and current focus
2427 j status Show the focused commit and changed paths
25− j review Show changes with difftastic (difft on PATH)
28+ j review Show changes (unified diff if difft is absent)
2629 j log List commits
2730
2831Edit (default config):
32+ j save Record current file edits
2933 j 'describe "add a feature"' Set the focused commit's message
3034 j new Start an empty child and move to it
3135 j prev Move to the parent commit
@@ -34,7 +38,7 @@ Edit (default config):
3438 j 'rebase trunk' Move the focused subtree onto the main line
3539
3640Preview and recover:
37− j 'tree . squash' Preview a squash without saving the edit
41+ j 'preview squash' Check and preview a squash without saving
3842 j undo Undo the last operation
3943 j redo Reapply an undone operation
4044 j ops Show the operation log
@@ -45,6 +49,9 @@ Sync:
4549 j 'push (label "feature" here)' Publish the focus as a remote bookmark
4650 j push relabel Update existing remote bookmarks
4751
52+Push sends recorded commits. After editing files, run `j save` to record them
53+before pushing; `j status` and other inspections do not save those edits.
54+
4855Expressions compose right to left: `describe "wip" . new` runs new first.
4956A function result receives the current repository. A Repo result saves the
5057edit; other results print. Quote the whole expression with shell single
@@ -55,6 +62,34 @@ revsets, filesets, types, and custom names. Search with `j commands rebase`
5562or `j commands Edit`; use `j help squash` or `j help commands` for details.
5663"#;
5764
65+pub const CONFIG_HELP: &str = r#"Configuration and identity
66+
67+For a new installation, create the complete config with your identity:
68+
69+ j setup 'Ada Lovelace' ada@example.org
70+
71+Use your own name and email. Then run `j init` or `j clone URL`.
72+To create the complete default and edit identity yourself, run `j setup`.
73+Setup never replaces an existing config.
74+
75+If the file already exists, edit its existing `user` definition:
76+
77+ user = { name = "Ada Lovelace", email = "ada@example.org" }
78+
79+Keep the rest of the file: it defines your commands. The example above is
80+one definition, not a complete config. Set both fields before saving changes.
81+The shipped "Your Name" / "you@example.com" placeholders cannot author commits.
82+An existing configuration is never overwritten or upgraded automatically.
83+
84+If configuration is broken, repair the reported line in this file. General
85+help and reserved-command help remain available without loading config.
86+`j commands` checks the vocabulary without evaluating it or changing a repo.
87+
88+Configuration lives at $XDG_CONFIG_HOME/j/config.j when XDG_CONFIG_HOME is
89+set, otherwise at ~/.config/j/config.j. `j setup` creates an editable default;
90+help never creates or changes files.
91+"#;
92+
5893struct Reserved {
5994 name: &'static str,
6095 usage: &'static str,
@@ -67,13 +102,13 @@ const RESERVED: &[Reserved] = &[
67102 name: "clone",
68103 usage: "j clone URL [DIR]",
69104 summary: "Clone a repository.",
70− detail: "Clone URL into DIR, or into a directory named after the repository.\n\nExample:\n j clone https://example.com/team/project.git project\n",
105+ detail: "Clone URL into DIR, or into a directory named after the repository.\nFor first-time setup, run `j setup NAME EMAIL`. Shell-quote paths containing spaces.\nCloning does not change your shell's directory; enter the new folder afterward.\n\nExamples:\n j clone https://example.com/team/project.git project\n cd project\n j status\n\n j clone '/path/to/source repo' 'my project'\n cd 'my project'\n",
71106 },
72107 Reserved {
73108 name: "commands",
74109 usage: "j commands [QUERY]",
75− summary: "List and search the commands and config vocabulary.",
76− detail: "List CLI commands and the names declared or defined in config.j, including\ncustom definitions, builtins, operators, and types. QUERY matches names,\nsignatures, and source comments, ignoring case.\n\nThe config is parsed and validated without evaluating definitions or opening\na repository. No config file is created; when absent, defaults are shown.\n\nExamples:\n j commands\n j commands rebase\n j commands Edit\n j commands 'remote bookmark'\n j help squash\n",
110+ summary: "Find everyday commands by task, or browse the full vocabulary.",
111+ detail: "Without a query, show common tasks and a few custom definitions.\n`j commands all` lists every configured definition, builtin, operator, and type.\nSearch by task (save, record, commit, branch, checkout), name, signature, or\nsource comments. Search ignores case and puts direct matches and everyday\ncommands first. Use `j help NAME` for examples and types.\n\nThe config is parsed and validated without evaluating definitions or opening\na repository. No config file is created; when absent, defaults are shown.\n\nExamples:\n j commands\n j commands save\n j commands rebase\n j commands Edit\n j commands all\n",
77112 },
78113 Reserved {
79114 name: "fetch",
@@ -91,7 +126,7 @@ const RESERVED: &[Reserved] = &[
91126 name: "init",
92127 usage: "j init",
93128 summary: "Create a repository in the current directory.",
94− detail: "Create a colocated .jj/.git repository and a working-copy commit. An\nexisting .git repository is adopted. Set your identity in config.j's `user`\ndefinition before creating commits.\n\nExample:\n mkdir project\n cd project\n j init\n",
129+ detail: "Create a colocated .jj/.git repository and a working-copy commit. An\nexisting .git repository is adopted. On a new installation, first run\n`j setup NAME EMAIL`; use `j help config` to update an existing identity.\nThe shipped placeholder identity cannot author commits.\n\nExample:\n j setup 'Ada Lovelace' ada@example.org\n mkdir project\n cd project\n j init\n",
95130 },
96131 Reserved {
97132 name: "ops",
@@ -103,13 +138,13 @@ const RESERVED: &[Reserved] = &[
103138 name: "push",
104139 usage: "j push EXPRESSION",
105140 summary: "Publish or delete bookmarks on origin.",
106− detail: "Evaluate EXPRESSION to a list of Push and Drop records, then update origin.\nA function result receives the recorded repository; working-copy changes\nare not snapshotted by push. The default config provides label, unlabel,\nrename, and relabel helpers.\n\nExamples (default config):\n j 'push (label \"feature\" here)'\n j push relabel\n j 'push (unlabel \"feature\")'\n j 'push (rename \"old\" \"new\")'\n\nThe unlabel example deletes the remote bookmark.\n",
141+ detail: "Evaluate EXPRESSION to a list of Push and Drop records, then update origin.\nA function result receives the recorded repository; working-copy changes\nare not snapshotted by push. Unrecorded edits produce a warning before sending.\nRun `j id` to record them, then push again to include them; `j status` does not\nrecord edits. The default config provides label, unlabel, rename, and relabel.\n\nExamples (default config):\n j 'push (label \"feature\" here)'\n j push relabel\n j 'push (unlabel \"feature\")'\n j 'push (rename \"old\" \"new\")'\n\nThe unlabel example deletes the remote bookmark.\n",
107142 },
108143 Reserved {
109144 name: "redo",
110145 usage: "j redo",
111146 summary: "Reapply an undone operation.",
112− detail: "Reapply the operation removed by undo. Refuses if there is nothing to redo\nor if restoring the working copy would overwrite unsnapshotted changes.\n\nExample:\n j redo\n\nRelated: j help undo, j help ops\n",
147+ detail: "Reapply the operation removed by undo. Refuses if there is nothing to redo\nor if restoring the working copy would overwrite unsnapshotted changes.\nRecording new edits with `j id` creates an operation and clears pending redos.\nInspect `j ops` before choosing how to preserve or discard those edits.\n\nExample:\n j redo\n\nRelated: j help undo, j help ops\n",
113148 },
114149 Reserved {
115150 name: "remote",
@@ -117,11 +152,17 @@ const RESERVED: &[Reserved] = &[
117152 summary: "Set the origin remote URL.",
118153 detail: "Add origin or replace its URL. Fetch and push use this remote.\n\nExample:\n j remote https://example.com/team/project.git\n j fetch\n",
119154 },
155+ Reserved {
156+ name: "setup",
157+ usage: "j setup [NAME EMAIL]",
158+ summary: "Create your complete config before starting a repository.",
159+ detail: "With NAME and EMAIL, create the full default config with your identity.\nWithout arguments, create the default and print the exact definition to edit.\nAn existing config is never overwritten: edit its `user` definition directly.\nSetup does not create or open a repository.\n\nExamples:\n j setup 'Ada Lovelace' ada@example.org\n j init\n\n j setup\n j help config\n",
160+ },
120161 Reserved {
121162 name: "undo",
122163 usage: "j undo",
123164 summary: "Undo the last operation.",
124− detail: "Restore the repository state before the last operation. Repeat to walk\nback through history. Refuses if restoring the working copy would overwrite\nunsnapshotted changes. Undo does not reverse changes already sent to a remote.\n\nExample:\n j undo\n\nRelated: j help redo, j help ops\n",
165+ detail: "Restore the repository state before the last operation. Repeat to walk\nback through history. Refuses if restoring the working copy would overwrite\nunsnapshotted changes. `j id` preserves edits as a new operation: the next\nundo reverses that recording before reaching the earlier operation. Use\n`j ops` to inspect history; `j redo` can restore the undone recording.\nUndo does not reverse changes already sent to a remote.\n\nExample:\n j undo\n\nRelated: j help redo, j help ops\n",
125166 },
126167];
127168
@@ -164,43 +205,154 @@ impl Entry {
164205 }
165206}
166207
208+struct Task {
209+ group: &'static str,
210+ name: &'static str,
211+ usage: &'static str,
212+ summary: &'static str,
213+}
214+
215+const TASKS: &[Task] = &[
216+ Task { group: "Start", name: "setup", usage: "j setup NAME EMAIL", summary: "Set your name and email." },
217+ Task { group: "Start", name: "init", usage: "j init", summary: "Create a repository here." },
218+ Task { group: "Start", name: "clone", usage: "j clone URL [DIR]", summary: "Clone a repository, then cd into it." },
219+ Task { group: "Inspect", name: "status", usage: "j status", summary: "See your current change and files." },
220+ Task { group: "Inspect", name: "tree", usage: "j tree", summary: "See history and your current commit." },
221+ Task { group: "Inspect", name: "review", usage: "j review", summary: "Review changed file contents." },
222+ Task { group: "Work", name: "save", usage: "j save", summary: "Record current file edits." },
223+ Task { group: "Work", name: "id", usage: "j id", summary: "Record current file edits." },
224+ Task { group: "Work", name: "describe", usage: "j 'describe \"message\"'", summary: "Name the current commit." },
225+ Task { group: "Work", name: "new", usage: "j new", summary: "Start the next commit before editing." },
226+ Task { group: "Work", name: "preview", usage: "j 'preview squash'", summary: "Check an edit without saving it." },
227+ Task { group: "Work", name: "prev", usage: "j prev", summary: "Move to the parent commit." },
228+ Task { group: "Share", name: "remote", usage: "j remote URL", summary: "Set your remote repository." },
229+ Task { group: "Share", name: "fetch", usage: "j fetch", summary: "Get remote commits and bookmarks." },
230+ Task { group: "Share", name: "push", usage: "j help push", summary: "Learn how to publish a bookmark." },
231+ Task { group: "Recover", name: "undo", usage: "j undo", summary: "Undo the last operation." },
232+ Task { group: "Recover", name: "redo", usage: "j redo", summary: "Reapply an undone operation." },
233+ Task { group: "Recover", name: "ops", usage: "j ops", summary: "Inspect the operation history." },
234+];
235+
236+fn everyday_list(entries: &[Entry]) -> String {
237+ let mut out = String::from("Everyday commands:\n");
238+ let reference = config::load_config(include_str!("../config.j")).expect("embedded config is valid");
239+ let mut group = "";
240+ for task in TASKS {
241+ let entry = entries.iter().find(|entry| entry.name == task.name);
242+ let reserved = RESERVED.iter().any(|command| command.name == task.name);
243+ if !reserved && entry.is_none() {
244+ continue;
245+ }
246+ if task.name == "id" && entries.iter().any(|entry| entry.name == "save") {
247+ continue;
248+ }
249+ if task.group != group {
250+ out.push_str(&format!("\n{}:\n", task.group));
251+ group = task.group;
252+ }
253+ let custom_summary;
254+ let summary = match entry {
255+ Some(entry) if !entry.docs.is_empty() => entry.docs[0].as_str(),
256+ Some(entry) if entry.signature.is_none() && entry.docs.is_empty() => {
257+ custom_summary = format!("Custom definition; see `j help {}`.", entry.name);
258+ custom_summary.as_str()
259+ }
260+ _ => task.summary,
261+ };
262+ let changed_signature = entry.is_some_and(|entry| entry.signature.as_ref() != reference.sigs.get(task.name));
263+ let usage = if changed_signature || (task.name == "preview" && !entries.iter().any(|entry| entry.name == "squash")) {
264+ format!("j help {}", task.name)
265+ } else {
266+ task.usage.to_string()
267+ };
268+ out.push_str(&format!(" {:<29} {}\n", usage, truncate(summary, 47)));
269+ }
270+ // Surface a few additions without letting a large custom vocabulary turn
271+ // the starting guide into the complete catalog again.
272+ let custom: Vec<_> = entries.iter().filter(|entry| {
273+ matches!(entry.kind, Kind::Definition)
274+ && !reference.defs.iter().any(|(name, _)| name == &entry.name)
275+ }).collect();
276+ if !custom.is_empty() {
277+ out.push_str("\nYour custom definitions:\n");
278+ for entry in custom.iter().take(6) {
279+ let docs = entry.docs.first().map(String::as_str).unwrap_or("Use j help for details.");
280+ out.push_str(&format!(" {:<29} {}\n", entry.name, truncate(docs, 55)));
281+ }
282+ if custom.len() > 6 {
283+ out.push_str(" More custom definitions are listed by `j commands all`.\n");
284+ }
285+ }
286+ out.push_str("\nUse `j help NAME` for examples. Search by task: `j commands save`.\nUse `j commands all` for every definition, builtin, operator, and type.\n");
287+ out
288+}
289+
290+fn intent_names(query: &str) -> &'static [&'static str] {
291+ match query {
292+ "save" | "record" | "snapshot" | "save changes" | "record changes" => &["save", "id"],
293+ "commit" | "git commit" => &["describe", "new", "save", "id"],
294+ "branch" | "git branch" => &["new", "label", "push", "goto"],
295+ "checkout" | "switch" | "switch branch" | "git checkout" => &["goto", "prev", "next"],
296+ "diff" | "changes" | "git diff" => &["review", "status", "changed", "diff"],
297+ _ => &[],
298+ }
299+}
300+
167301pub fn command_list(src: &str, query: &str) -> Result<String, String> {
168302 let entries = entries(src)?;
169303 let query = query.trim().to_lowercase();
170− let matches = |text: &str| text.to_lowercase().contains(&query);
171− let mut out = String::new();
172− let mut count = 0;
304+ if query.is_empty() {
305+ return Ok(everyday_list(&entries));
306+ }
307+ let all = query == "all";
308+ let intents = intent_names(&query);
309+ let matches = |text: &str| all || text.to_lowercase().contains(&query);
310+ let mut results = Vec::new();
173311 for cmd in RESERVED {
174− if matches(&format!("{} {} {}", cmd.usage, cmd.summary, cmd.detail)) {
175− if count == 0 {
176− out.push_str("CLI commands:\n");
177− }
178− out.push_str(&format!(" {:<24} {}\n", cmd.usage, cmd.summary));
179− count += 1;
312+ let intent = intents.iter().position(|name| *name == cmd.name);
313+ if matches(&format!("{} {} {}", cmd.usage, cmd.summary, cmd.detail)) || intent.is_some() {
314+ let rank = if all || query == cmd.name { 0 } else { intent.map(|i| i + 1).unwrap_or(40) };
315+ results.push((rank, cmd.name.to_string(), format!(" {:<24} {}\n", cmd.usage, cmd.summary)));
180316 }
181317 }
182− let mut config_count = 0;
183− for entry in entries {
318+ for entry in &entries {
184319 let declaration = entry.declaration();
185− if !matches(&format!("{} {}", declaration, entry.docs.join(" "))) {
320+ let intent = intents.iter().position(|name| *name == entry.name);
321+ if !matches(&format!("{} {}", declaration, entry.docs.join(" "))) && intent.is_none() {
186322 continue;
187323 }
188− if config_count == 0 {
189− if count > 0 {
190− out.push('\n');
191− }
192− out.push_str("Config vocabulary (definitions, builtins, operators, and types):\n");
193− }
194− out.push_str(&format!(" {}\n", declaration));
324+ let everyday = TASKS.iter().any(|task| task.name == entry.name);
325+ let exact = query == entry.name.to_lowercase() && (!matches!(entry.kind, Kind::Type) || intents.is_empty());
326+ let rank = if all { 1 } else if exact { 0 }
327+ else { intent.map(|i| i + 1).unwrap_or(if everyday { 20 } else { 30 }) };
328+ let mut description = format!(" {}\n", declaration);
195329 if let Some(doc) = entry.docs.first() {
196− out.push_str(&format!(" {}\n", truncate(doc, 110)));
330+ description.push_str(&format!(" {}\n", truncate(doc, 110)));
197331 }
198− config_count += 1;
332+ results.push((rank, entry.name.clone(), description));
199333 }
200− if count + config_count == 0 {
201− out.push_str("No matching commands or config names. Try `j commands` to list all names.\n");
334+ results.sort_by(|a, b| (&a.0, a.1.to_lowercase(), &a.1).cmp(&(&b.0, b.1.to_lowercase(), &b.1)));
335+ let mut out = String::new();
336+ if matches!(query.as_str(), "stash" | "git stash") && !entries.iter().any(|entry| entry.name == "stash") {
337+ out.push_str("There is no direct stash command in this config.\n");
338+ let save = if entries.iter().any(|entry| entry.name == "save") { Some("save") }
339+ else if entries.iter().any(|entry| entry.name == "id") { Some("id") } else { None };
340+ if let Some(save) = save {
341+ out.push_str(&format!("`j {}` records file edits in the current commit.\n", save));
342+ }
343+ if entries.iter().any(|entry| entry.name == "new") {
344+ out.push_str("`j new` starts a child commit; the recorded work remains in its parent.\n");
345+ }
346+ out.push_str("\n");
347+ }
348+ if results.is_empty() {
349+ out.push_str("No matching commands or config names. Try `j commands` for common tasks or `j commands all` for every name.\n");
202350 } else {
203− out.push_str("\nUse `j help NAME` for details; `j commands QUERY` to narrow the list.\n");
351+ out.push_str(if all { "All commands and config vocabulary:\n" } else { "Matching commands and config names:\n" });
352+ for (_, _, description) in results {
353+ out.push_str(&description);
354+ }
355+ out.push_str("\nUse `j help NAME` for examples and details; `j commands QUERY` to search.\n");
204356 }
205357 Ok(out)
206358}
@@ -219,14 +371,8 @@ pub fn command_help(src: &str, name: &str) -> Result<Option<String>, String> {
219371 Kind::Builtin => "Builtin declared in config",
220372 Kind::Type => "Type declared in config",
221373 };
222− let mut out = format!(
223− "{}\n\n{}\nSource: line {}\n",
224− entry.declaration(),
225− kind,
226− entry.line
227− );
374+ let mut out = String::new();
228375 if !entry.docs.is_empty() {
229− out.push('\n');
230376 for doc in entry.docs.iter().take(24) {
231377 out.push_str(doc);
232378 out.push('\n');
@@ -234,7 +380,9 @@ pub fn command_help(src: &str, name: &str) -> Result<Option<String>, String> {
234380 if entry.docs.len() > 24 {
235381 out.push_str("… See the source for the remaining comments.\n");
236382 }
383+ out.push('\n');
237384 }
385+ out.push_str(&format!("{}\n\n{}\nSource: line {}\n", entry.declaration(), kind, entry.line));
238386 out
239387 }))
240388}
src/jj.rs+182−30
@@ -45,6 +45,15 @@ fn block_on<F: Future>(f: F) -> F::Output {
4545
4646type OpenError = (u8, String);
4747
48+/// The successful effects of a push, after duplicate requests are merged.
49+/// All zeroes means no bookmarks were selected and nothing was sent or saved.
50+#[derive(Clone, Debug, Default, PartialEq, Eq)]
51+pub struct PushOutcome {
52+ pub published: usize,
53+ pub deleted: usize,
54+ pub unchanged: usize,
55+}
56+
4857// ----------------------------------------------------------------------
4958// locating and opening the repository (§7.1)
5059// ----------------------------------------------------------------------
@@ -1760,10 +1769,10 @@ impl JjBackend {
17601769 current: &Value,
17611770 new: &Value,
17621771 text: &str,
1763− ) -> Result<(), Crash> {
1772+ ) -> Result<bool, Crash> {
17641773 // §1.2 step 8: unchanged, up to the order of snapshot entries (§7.3)
17651774 if crate::repo::same_repo(loaded, new)? {
1766− return Ok(());
1775+ return Ok(false);
17671776 }
17681777 *interp.old_repo.borrow_mut() = Some(loaded.clone());
17691778 crate::repo::validate_repo(interp, new)?;
@@ -1988,7 +1997,8 @@ impl JjBackend {
19881997 state.or_else(|_| block_on(self.wc_commit(&base)).map(|c| c.tree()).map_err(|e| Crash::new(e.1)))?
19891998 }
19901999 };
1991− block_on(self.checkout(&focus_commit, Some(&on_disk), Some(unpublished), Some(", and `j undo` goes back")))
2000+ block_on(self.checkout(&focus_commit, Some(&on_disk), Some(unpublished), Some(", and `j undo` goes back")))?;
2001+ Ok(true)
19922002 }
19932003
19942004 /// Check `commit` out: write its files to the working directory and
@@ -2188,7 +2198,8 @@ impl JjBackend {
21882198 }
21892199
21902200 fn user_signature(&self, cfg: &Config) -> Result<Signature, Crash> {
2191− let (name, email) = crate::config::eval_user_only(&mut Interp::dummy(), cfg)?;
2201+ let (name, email) = validated_identity(cfg)
2202+ .map_err(|(_, message)| Crash::new(message))?;
21922203 Ok(Signature {
21932204 name,
21942205 email,
@@ -2322,16 +2333,38 @@ impl JjBackend {
23222333 let _ = block_on(self.publish_tx(tx, "fetch"))?;
23232334 Ok(())
23242335 }
2325− pub fn cmd_push(&self, cfg: &mut Config, expr_text: &str) -> Result<(), OpenError> {
2336+ pub fn cmd_push(&self, cfg: &mut Config, expr_text: &str) -> Result<PushOutcome, OpenError> {
23262337 self.enter_workspace_root()?;
23272338 let origin = RemoteName::new("origin");
23282339 let base = self.head_repo()?;
23292340 let (value, vis, immutable) = self.eval_push_expr(cfg, expr_text, &base)?;
23302341 let records = parse_push_records(&value, &vis, &base)?;
2342+ if records.is_empty() {
2343+ return Ok(PushOutcome::default());
2344+ }
23312345 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+ }
23322365 let description = format!("push {}", expr_text);
23332366 let _ = self.push_to_origin(&base, origin, &records, deletes_wait, &description)?;
2334− Ok(())
2367+ Ok(outcome)
23352368 }
23362369
23372370 /// evaluate `EXPR` against the recorded repository, without snapshotting
@@ -2345,7 +2378,7 @@ impl JjBackend {
23452378 ) -> Result<(Value, Arc<VisibleRepo>, BTreeSet<String>), OpenError> {
23462379 let outer = Rc::new(cfg.global_names.clone());
23472380 let expr = crate::parse::parse_expr(expr_text, outer)
2348− .map_err(|p| (3, format!("line {}: {}", p.line, p.msg)))?;
2381+ .map_err(|p| (3, crate::diagnostics::format_source_error("expression", expr_text, p.line, &p.msg)))?;
23492382 let (mut interp, _loaded, current) = self.build_interp(cfg, expr_text, false)?;
23502383 let expr = crate::config::resolve_ids(&expr, &interp).map_err(|failures| {
23512384 let (prefix, candidates) = &failures[0];
@@ -2360,15 +2393,26 @@ impl JjBackend {
23602393 // evaluated it for this value to place the focus, so it is cached
23612394 let immutable = crate::repo::compute_immutable(&mut interp, &current)
23622395 .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+ };
23632407 let env = interp.global_env();
23642408 let expr_rc = Rc::new(expr);
23652409 let mut v = interp
23662410 .eval(&expr_rc, &env)
2367− .map_err(|c| (1, format!("crash: {}", c.msg)))?;
2411+ .map_err(&expression_error)?;
23682412 if matches!(v, Value::Fun(_)) {
23692413 v = interp
23702414 .apply(v, current)
2371− .map_err(|c| (1, format!("crash: {}", c.msg)))?;
2415+ .map_err(expression_error)?;
23722416 }
23732417 let _ = base;
23742418 let vis = self
@@ -2393,6 +2437,11 @@ impl JjBackend {
23932437 deletes_wait: bool,
23942438 description: &str,
23952439 ) -> 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+ }
23962445 let mut targets = jj_lib::git::GitPushRefTargets::default();
23972446 let mut deletes = jj_lib::git::GitPushRefTargets::default();
23982447 for (name, after) in records {
@@ -2411,16 +2460,6 @@ impl JjBackend {
24112460 .push((jj_lib::ref_name::RefNameBuf::from(name.clone()), Diff::new(before, after.clone())));
24122461 }
24132462 let mut tx = base.start_transaction();
2414− if records.is_empty() {
2415− // nothing to set or delete; push_refs would still run `git push
2416− // origin` without refspecs, which pushes whatever git's
2417− // push.default picks
2418− if !remote_has_url(git_backend(base.store())?, origin) {
2419− return Err((1, "no remote origin".to_string()));
2420− }
2421− let (_, repo) = block_on(self.publish_tx(tx, &truncate_chars(description, 200)))?;
2422− return Ok(repo);
2423− }
24242463 let subprocess_options = self.git_subprocess_options()?;
24252464 let mut callback = QuietGitCallback;
24262465 let mut push = |tx: &mut jj_lib::transaction::Transaction, targets| {
@@ -2498,7 +2537,7 @@ impl JjBackend {
24982537 Ok(repo)
24992538 }
25002539
2501− pub fn cmd_undo(&self, redo: bool) -> Result<(), OpenError> {
2540+ pub fn cmd_undo(&self, redo: bool) -> Result<String, OpenError> {
25022541 let base = self.head_repo()?;
25032542 let on_disk = self.check_wc_clean(&base)?;
25042543 let loader = self.repo_loader();
@@ -2621,6 +2660,10 @@ impl JjBackend {
26212660 .map_err(|e| (2, format!("cannot load an operation: {}", e)))?;
26222661 }
26232662 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);
26242667 let target_view = block_on(
26252668 loader
26262669 .op_store()
@@ -2663,7 +2706,14 @@ impl JjBackend {
26632706 let back = if redo { ", and `j undo` goes back" } else { ", and `j redo` goes back" };
26642707 block_on(self.checkout(&wc_commit, Some(&on_disk), Some(unpublished), Some(back)))
26652708 .map_err(|c| (1, c.msg))?;
2666− Ok(())
2709+ let focus_message = wc_commit.description().lines().next().unwrap_or("").trim();
2710+ Ok(format!(
2711+ "{} `{}`; restored focus @{} {}",
2712+ if redo { "Redid" } else { "Undid" },
2713+ affected_description,
2714+ wc_commit.change_id().reverse_hex(),
2715+ terminal_text(if focus_message.is_empty() { "(no description)" } else { focus_message }),
2716+ ))
26672717 }
26682718
26692719 /// Refuse, as `undo` and `redo` do before anything else (§7.7), when the
@@ -2677,7 +2727,21 @@ impl JjBackend {
26772727 /// what a checkout cut short wrote. Reserved commands never snapshot,
26782728 /// so nothing is recorded either way.
26792729 fn check_wc_clean(&self, base: &Arc<ReadonlyRepo>) -> Result<MergedTree, OpenError> {
2680− let dirty = (1, "working copy has changes not in @; run `j id` to record them or discard them".to_string());
2730+ let (scanned, clean) = self.inspect_working_copy(base, true)?;
2731+ if !clean {
2732+ return Err((1, "working copy has changes not in @; run `j id` to record them or discard them. Recording creates a new operation: the next `j undo` undoes that recording, not the operation you originally wanted to undo; a new recording also clears pending redos. Use `j ops` to inspect the history".to_string()));
2733+ }
2734+ Ok(scanned)
2735+ }
2736+
2737+ /// Compare a scan with the recorded focus without publishing a snapshot.
2738+ /// Push only inspects: it must leave the saved working-copy state alone
2739+ /// so a later `j id` still sees every unrecorded edit.
2740+ fn inspect_working_copy(
2741+ &self,
2742+ base: &Arc<ReadonlyRepo>,
2743+ refresh_clean_state: bool,
2744+ ) -> Result<(MergedTree, bool), OpenError> {
26812745 let wc_tree = block_on(self.wc_commit(base))?.tree();
26822746 let mut ws_guard = self.inner.workspace.lock().unwrap();
26832747 let root = ws_guard.workspace_root().to_owned();
@@ -2697,13 +2761,15 @@ impl JjBackend {
26972761 // drop the lock without saving, or the state would claim the
26982762 // directory was snapshotted and hide the changes from the next
26992763 // run (§7.7)
2700− return Err(dirty);
2764+ return Ok((scanned, false));
27012765 }
27022766 // saving the state only refreshes what it knows of each file, or
27032767 // takes in what the commit already has
2704− block_on(save_scan(locked_ws, &scanned, &wc_tree, base.operation().id()))
2705− .map_err(|e| (2, format!("cannot finish: {}", e)))?;
2706− Ok(scanned)
2768+ if refresh_clean_state {
2769+ block_on(save_scan(locked_ws, &scanned, &wc_tree, base.operation().id()))
2770+ .map_err(|e| (2, format!("cannot finish: {}", e)))?;
2771+ }
2772+ Ok((scanned, true))
27072773 }
27082774
27092775 /// The operation log for `ops` (§7.7), one line per operation, newest
@@ -3194,8 +3260,7 @@ async fn checkout_tree(tree: MergedTree, backend: &JjBackend) -> Result<MergedTr
31943260/// user settings for init/clone: a missing or malformed `user` is a
31953261/// configuration error (exit 3) (§7.9)
31963262fn user_settings_strict(cfg: &Config) -> Result<UserSettings, OpenError> {
3197− let (name, email) = crate::config::eval_user_only(&mut Interp::dummy(), cfg)
3198− .map_err(|c| (3, format!("config.j: {}", c.msg)))?;
3263+ let (name, email) = validated_identity(cfg)?;
31993264 let mut config = StackedConfig::with_defaults();
32003265 let text = format!(
32013266 "[user]\nname = {}\nemail = {}\n",
@@ -3209,6 +3274,39 @@ fn user_settings_strict(cfg: &Config) -> Result<UserSettings, OpenError> {
32093274 .map_err(|e| (3, format!("config.j: invalid user settings: {}", e)))
32103275}
32113276
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+
32123310// ----------------------------------------------------------------------
32133311// reserved-command helpers
32143312// ----------------------------------------------------------------------
@@ -3397,6 +3495,42 @@ fn op_parent_id(
33973495 Ok(op.parent_ids().first().cloned())
33983496}
33993497
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+
34003534/// parse the push-record list (§7.6): records of shape {id, name} set a
34013535/// bookmark, records of shape {delete} remove one; duplicates merge, the same
34023536/// name with different effects crashes (exit 1)
@@ -3644,12 +3778,24 @@ fn check_push_records(
36443778 .map_err(|e| (2, format!("cannot list the commits to send: {}", e)))?;
36453779 for commit in &sent {
36463780 let change_id = commit.change_id().reverse_hex();
3647− for (_path, value) in commit.tree().entries() {
3781+ let mut unresolved_paths = Vec::new();
3782+ for (path, value) in commit.tree().entries() {
36483783 let value = value.map_err(|e| (2, format!("cannot read a tree: {}", e)))?;
36493784 if !value.is_resolved() {
3650− return Err((1, format!("push: commit `@{}` has unresolved files", change_id)));
3785+ unresolved_paths.push(path.as_internal_file_string().to_string());
36513786 }
36523787 }
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+ }
36533799 if commit.description().trim().is_empty() && commit.id() != store.root_commit_id() {
36543800 return Err((1, format!("push: commit `@{}` has an empty description", change_id)));
36553801 }
@@ -3691,6 +3837,12 @@ fn is_valid_git_branch_name(name: &str) -> bool {
36913837// ----------------------------------------------------------------------
36923838
36933839impl 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+
36943846 fn visible_ids(&self) -> Vec<String> {
36953847 match self.inner.visible.lock().unwrap().as_ref() {
36963848 Some(v) => v.commits.keys().cloned().collect(),
src/lib.rs+1
@@ -12,6 +12,7 @@ macro_rules! report {
1212pub mod ast;
1313pub mod builtins;
1414pub mod config;
15+pub mod diagnostics;
1516pub mod domain;
1617pub mod eval;
1718pub mod lex;
src/main.rs+194−34
@@ -1,8 +1,10 @@
11//! j — a functional-programming-centered CLI for jj repositories (§1).
22
33mod help;
4+mod setup;
45
56use j::config::{self, Config};
7+use j::diagnostics;
68use j::jj::JjBackend;
79use j::parse::parse_expr;
810use j::value::{Crash, Value};
@@ -13,6 +15,7 @@ use std::rc::Rc;
1315
1416const USAGE: &str = "usage: j EXPRESSION… (or echo EXPRESSION | j)
1517
18+Configure identity: j setup
1619Start a repository: j init or j clone URL
1720Inspect your work: j status or j tree
1821Discover commands: j help or j commands
@@ -43,13 +46,14 @@ fn print_output(s: &str) -> ExitCode {
4346 }
4447}
4548
46−fn crash_err(c: &Crash, expr_text: Option<&str>) -> ExitCode {
49+fn crash_err(c: &Crash, expr_text: &str, cfg: &Config) -> ExitCode {
4750 j::terminal::report_error(format_args!("j: crash: {}", c.msg));
48− match (&c.def, expr_text) {
49− (Some(d), Some(e)) => j::report!(" in {}, from {}", d, e),
50− (None, Some(e)) => j::report!(" from {}", e),
51− (Some(d), None) => j::report!(" in {}", d),
52− (None, None) => {}
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);
5357 }
5458 ExitCode::from(1)
5559}
@@ -91,7 +95,15 @@ fn load_config_file() -> Result<(Config, String), u8> {
9195 };
9296 match config::load_config(&src) {
9397 Ok(c) => Ok((c, src)),
94− Err(e) => Err(fail(3, e.message())),
98+ Err(e) => {
99+ let message = match e {
100+ config::ConfigError::Parse(p) => {
101+ diagnostics::format_source_error(&path, &src, p.line, &p.msg)
102+ }
103+ config::ConfigError::Validation(message) => format!("{}: {}", path, message),
104+ };
105+ Err(fail(3, format!("{}\n Edit this file to repair your config; see `j help config`.", message)))
106+ }
95107 }
96108}
97109
@@ -114,7 +126,7 @@ fn write_default_config(path: &str) -> std::io::Result<()> {
114126}
115127
116128const RESERVED: &[&str] = &[
117− "init", "clone", "remote", "fetch", "push", "undo", "redo", "ops", "help", "commands",
129+ "init", "clone", "remote", "fetch", "push", "undo", "redo", "ops", "help", "commands", "setup",
118130];
119131
120132/// Discovery reads the user's vocabulary without creating or evaluating it.
@@ -131,11 +143,13 @@ fn read_help_config() -> Result<(String, String), u8> {
131143 }
132144}
133145
134−fn run_help(text: &str) -> ExitCode {
135− let words: Vec<&str> = text.split_whitespace().collect();
136− match words.as_slice() {
146+fn run_help(words: &[&str]) -> ExitCode {
147+ match words {
137148 [_] => print_output(help::OVERVIEW),
138149 [_, name] => {
150+ if *name == "config" {
151+ return print_output(&format!("{}\nConfig: {}\n", help::CONFIG_HELP, config_path()));
152+ }
139153 if let Some(output) = help::reserved_help(name) {
140154 return print_output(&output);
141155 }
@@ -182,6 +196,22 @@ fn command_result(result: Result<(), (u8, String)>, message: &str) -> ExitCode {
182196 }
183197}
184198
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+
185215fn main() -> ExitCode {
186216 // evaluation builds deep continuation chains (trampolined, but the final
187217 // continuation still tears down by recursive Drop), so run the real work
@@ -246,6 +276,16 @@ fn run() -> ExitCode {
246276 if stdin_has_data && !args.is_empty() {
247277 return err(2, "expression given both as arguments and on stdin");
248278 }
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+ }
249289 let text = if stdin_has_data {
250290 match String::from_utf8(stdin_content) {
251291 Ok(text) => text,
@@ -265,29 +305,40 @@ fn run() -> ExitCode {
265305 return ExitCode::from(2);
266306 }
267307 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+ };
268315 if matches!(first, "--help" | "-h") {
269− return run_help(trimmed);
316+ return run_help(&words);
270317 }
271318 if RESERVED.contains(&first) {
272− return run_reserved(first, trimmed);
319+ return run_reserved(first, trimmed, &words);
273320 }
274321
275322 run_expression(trimmed, true)
276323}
277324
278−fn run_reserved(cmd: &str, text: &str) -> ExitCode {
279− let words: Vec<&str> = text.split_whitespace().collect();
325+fn run_reserved(cmd: &str, text: &str, words: &[&str]) -> ExitCode {
280326 match cmd {
281− "help" => run_help(text),
327+ "help" => run_help(words),
282328 "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+ },
283333 "init" => {
284334 if words.len() != 1 {
285335 return command_usage("init", "init");
286336 }
287337 let (cfg, _) = or_exit(load_config_file());
338+ let identity = or_exit(setup_identity(&cfg));
288339 command_result(
289340 j::terminal::with_progress("Creating repository", || j::jj::cmd_init(&cfg)),
290− "Repository created",
341+ &format!("Repository created; identity {}", identity),
291342 )
292343 }
293344 "clone" => {
@@ -309,9 +360,10 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
309360 }
310361 };
311362 let (cfg, _) = or_exit(load_config_file());
363+ let identity = or_exit(setup_identity(&cfg));
312364 command_result(
313365 j::terminal::with_progress("Cloning repository", || j::jj::cmd_clone(&cfg, words[1], &dir)),
314− "Repository cloned",
366+ &format!("Repository cloned into {}; identity {}\n Next: cd {} && j status", dir, identity, shell_word(&dir)),
315367 )
316368 }
317369 "remote" => {
@@ -342,19 +394,31 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
342394 let (mut cfg, _) = or_exit(load_config_file());
343395 let backend = or_exit(open_repo_locked(&cfg));
344396 // evaluate EXPR against the recorded repository (no snapshot)
345− command_result(
346− j::terminal::with_progress("Pushing changes", || backend.cmd_push(&mut cfg, rest)),
347− "Push complete",
348− )
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),
413+ }
349414 }
350415 "undo" => {
351416 if words.len() != 1 {
352417 return command_usage("undo", "undo");
353418 }
354419 let backend = or_exit(open_repo_noconfig());
355− command_result(
420+ operation_result(
356421 j::terminal::with_progress("Undoing operation", || backend.cmd_undo(false)),
357− "Undo complete",
358422 )
359423 }
360424 "redo" => {
@@ -362,9 +426,8 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
362426 return command_usage("redo", "redo");
363427 }
364428 let backend = or_exit(open_repo_noconfig());
365− command_result(
429+ operation_result(
366430 j::terminal::with_progress("Redoing operation", || backend.cmd_undo(true)),
367− "Redo complete",
368431 )
369432 }
370433 "ops" => {
@@ -401,6 +464,13 @@ fn default_clone_dir(url: &str) -> Option<String> {
401464 }
402465}
403466
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+
404474fn or_exit<T>(r: Result<T, u8>) -> T {
405475 match r {
406476 Ok(v) => v,
@@ -445,7 +515,7 @@ fn run_expression(text: &str, snapshot: bool) -> ExitCode {
445515 let outer = Rc::new(cfg.global_names.clone());
446516 let mut expr = match parse_expr(text, outer) {
447517 Ok(e) => e,
448− Err(p) => return err(3, format!("line {}: {}", p.line, p.msg)),
518+ Err(p) => return err(3, diagnostics::format_source_error("expression", text, p.line, &p.msg)),
449519 };
450520
451521 // §1.2.4: build the current repository value (with snapshot)
@@ -453,7 +523,7 @@ fn run_expression(text: &str, snapshot: bool) -> ExitCode {
453523 match j::terminal::with_progress("Reading repository", || backend.build_interp(&mut cfg, text, snapshot)) {
454524 Ok(t) => t,
455525 Err((2, m)) => return err(2, m),
456− Err((3, m)) => return err(3, m),
526+ Err((3, m)) => return err(3, format!("{}: {}\n See `j help config` for configuration help.", config_path(), m.trim_start_matches("config.j: "))),
457527 Err((_, m)) => return err(1, m),
458528 };
459529
@@ -479,7 +549,7 @@ fn run_expression(text: &str, snapshot: bool) -> ExitCode {
479549 if let Err(c) = j::terminal::with_progress("Evaluating configuration", || {
480550 config::eval_config(&mut interp, &cfg)
481551 }) {
482− return err(3, format!("config.j: {}", c.msg));
552+ return err(3, format!("{}: {}\n See `j help config` for configuration help.", config_path(), c.msg));
483553 }
484554 *interp.old_repo.borrow_mut() = Some(loaded_repo.clone());
485555 *interp.given_repo.borrow_mut() = Some(current_repo.clone());
@@ -495,7 +565,7 @@ fn run_expression(text: &str, snapshot: bool) -> ExitCode {
495565 }
496566 }) {
497567 Ok(v) => v,
498− Err(c) => return crash_err(&c, Some(text)),
568+ Err(c) => return crash_err(&c, text, &cfg),
499569 };
500570
501571 // §1.2.8: a Repo is persisted (unless unchanged)
@@ -503,22 +573,112 @@ fn run_expression(text: &str, snapshot: bool) -> ExitCode {
503573 match j::terminal::with_progress("Saving changes", || {
504574 backend.persist(&mut interp, &cfg, &loaded_repo, &current_repo, &v, text)
505575 }) {
506− Ok(()) => {
507− j::terminal::success("Expression complete");
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+ }
508584 ExitCode::SUCCESS
509585 }
510− Err(c) => crash_err(&c, Some(text)),
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),
511590 }
512591 } else {
513592 // §1.2.9: display
514593 let color = j::render::color_enabled("auto");
515594 match j::terminal::with_progress("Rendering output", || j::render::display(&mut interp, &v, color)) {
516595 Ok(s) => print_output(&s),
517− Err(c) => crash_err(&c, Some(text)),
596+ Err(c) => crash_err(&c, text, &cfg),
597+ }
598+ }
599+}
600+
601+/// Report newly introduced conflicts and conflicts in the checked-out focus.
602+/// Stored, resolved trees are skipped without loading their entries or blobs.
603+fn conflict_paths(interp: &j::eval::Interp, repo: &Value) -> Result<BTreeSet<(String, String)>, Crash> {
604+ let mut paths = BTreeSet::new();
605+ for commit in j::repo::all_commits(repo)? {
606+ let Value::Id(id) = commit.field("id")? else { continue };
607+ let Value::Record(fields) = &commit else { continue };
608+ if let Some(Value::Thunk(files)) = fields.get("files") {
609+ // `new` can inherit an unchanged snapshot from another commit.
610+ // Its stored origin still answers for those files even when the
611+ // new commit has a different id.
612+ if files.origin().and_then(|origin| interp.backend.has_conflict(origin)) == Some(false) {
613+ continue;
614+ }
615+ }
616+ for entry in commit.field("files")?.as_list()? {
617+ if matches!(entry.field("content")?, Value::Blob(blob) if blob.is_unresolved()) {
618+ let path = entry.field("path")?.as_list()?.iter()
619+ .map(|part| part.as_text().map(str::to_owned))
620+ .collect::<Result<Vec<_>, _>>()?.join("/");
621+ paths.insert((id.to_string(), path));
622+ }
623+ }
624+ }
625+ Ok(paths)
626+}
627+
628+fn report_conflicts(interp: &j::eval::Interp, before: &Value, after: &Value) {
629+ let summary = || -> Result<(), Crash> {
630+ let old = conflict_paths(interp, before)?;
631+ let new = conflict_paths(interp, after)?;
632+ let Value::Id(focus) = after.field("root")?.field("id")? else { return Ok(()) };
633+ let relevant: Vec<_> = new.iter()
634+ .filter(|(id, path)| id == focus.as_str() || !old.contains(&(id.clone(), path.clone())))
635+ .collect();
636+ if relevant.is_empty() {
637+ return Ok(());
638+ }
639+ j::report!("j: warning: {} unresolved file(s) remain:", relevant.len());
640+ for (id, path) in relevant.iter().take(10) {
641+ j::report!(" @{} {}", interp.unique_prefix(id), path.escape_debug());
518642 }
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);
519653 }
520654}
521655
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+
522682fn is_repo_value(v: &Value) -> bool {
523683 // §4.4: shape Repo and root's shape Commit
524684 let fields = match v {
src/render.rs+188−22
@@ -197,6 +197,16 @@ pub fn is_commit(interp: &Interp, v: &Value) -> bool {
197197// ----------------------------------------------------------------------
198198
199199pub 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> {
200210 let pal = Palette { on: color };
201211 let mut out = String::new();
202212 let mut loaded = None;
@@ -204,7 +214,7 @@ pub fn display(interp: &mut Interp, v: &Value, color: bool) -> Result<String, Cr
204214 while let Some(block) = todo.pop() {
205215 match block {
206216 Block::Of(v, indent) => {
207− display_block(interp, &v, &pal, indent, &mut out, &mut loaded, &mut todo)?
217+ display_block(interp, &v, &pal, indent, &mut out, &mut loaded, &mut todo, term_w)?
208218 }
209219 Block::Items { xs, next, indent } => {
210220 if let Some(x) = xs.get(next).cloned() {
@@ -861,6 +871,7 @@ fn has_conflict(c: &Value) -> Result<bool, Crash> {
861871}
862872
863873/// `v` in block form, the blocks it holds left on `todo` (see `Block`)
874+#[allow(clippy::too_many_arguments)]
864875fn display_block(
865876 interp: &mut Interp,
866877 v: &Value,
@@ -869,6 +880,7 @@ fn display_block(
869880 out: &mut String,
870881 loaded: &mut Option<Loaded>,
871882 todo: &mut Vec<Block>,
883+ term_w: Option<usize>,
872884) -> Result<(), Crash> {
873885 let pad = " ".repeat(indent);
874886 let v = &v.forced()?;
@@ -899,7 +911,7 @@ fn display_block(
899911 }
900912 return Ok(());
901913 }
902− Value::List(xs) => return display_list_block(interp, xs, pal, indent, out, loaded, todo),
914+ Value::List(xs) => return display_list_block(interp, xs, pal, indent, out, loaded, todo, term_w),
903915 Value::Record(_) => {}
904916 }
905917 if let Some(shape) = shape_of_record(interp, v) {
@@ -921,7 +933,7 @@ fn display_block(
921933 ("children", v.field("children")?),
922934 ("context", Value::list(vec![])),
923935 ]);
924− let text = tree_render(interp, &opts, &repo, pal, false, tty_width())?;
936+ let text = tree_render(interp, &opts, &repo, pal, false, term_w)?;
925937 out.push_str(&indent_multiline(&text, indent));
926938 return Ok(());
927939 }
@@ -977,11 +989,37 @@ fn display_fields(
977989 todo: &mut Vec<Block>,
978990) -> Result<(), Crash> {
979991 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(_))));
980998 let from = after.map_or(Bound::Unbounded, Bound::Excluded);
981999 for (k, x) in m.range::<str, _>((from, Bound::Unbounded)) {
9821000 out.push_str(&pad);
983− out.push_str(&pal.field(&pad_right(k, name_w)));
1001+ let label = if status && m.contains_key("unrecorded") && k == "changed" {
1002+ "in commit"
1003+ } else {
1004+ k.as_str()
1005+ };
1006+ out.push_str(&pal.field(&pad_right(label, name_w)));
9841007 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+ }
9851023 // lists print in block form under their key (§5.1)
9861024 if matches!(x, Value::List(xs) if !xs.is_empty()) {
9871025 out.push('\n');
@@ -1258,6 +1296,7 @@ fn top_of(interp: &Interp, _x: &Value) -> Result<Value, Crash> {
12581296}
12591297
12601298/// `list` in block form, the blocks it holds left on `todo` (see `Block`)
1299+#[allow(clippy::too_many_arguments)]
12611300fn display_list_block(
12621301 interp: &mut Interp,
12631302 list: &ListVal,
@@ -1266,6 +1305,7 @@ fn display_list_block(
12661305 out: &mut String,
12671306 loaded: &mut Option<Loaded>,
12681307 todo: &mut Vec<Block>,
1308+ term_w: Option<usize>,
12691309) -> Result<(), Crash> {
12701310 let xs: &[Value] = list;
12711311 let pad = " ".repeat(indent);
@@ -1395,7 +1435,7 @@ fn display_list_block(
13951435 }
13961436 return Ok(());
13971437 }
1398− return display_table(interp, xs, pal, indent, out, loaded);
1438+ return display_table(interp, xs, pal, indent, out, loaded, term_w);
13991439 }
14001440 }
14011441 // list of other scalars
@@ -1420,17 +1460,36 @@ fn display_table(
14201460 indent: usize,
14211461 out: &mut String,
14221462 loaded: &mut Option<Loaded>,
1463+ term_w: Option<usize>,
14231464) -> Result<(), Crash> {
14241465 let pad = " ".repeat(indent);
14251466 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()];
14261474 // cells
14271475 let mut rows: Vec<Vec<String>> = Vec::new();
14281476 for x in xs {
14291477 let mut row = Vec::new();
1430− for f in &fields {
1478+ for (column, f) in fields.iter().enumerate() {
14311479 let cell = match x.field(f)? {
14321480 Value::Bool(true) => pal.green("✓"),
14331481 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+ }
14341493 other => display_line(interp, &other, pal, loaded)?,
14351494 };
14361495 row.push(cell);
@@ -1441,7 +1500,7 @@ fn display_table(
14411500 let keep: Vec<usize> = (0..fields.len())
14421501 .filter(|&i| rows.iter().any(|r| !r[i].is_empty()))
14431502 .collect();
1444− let widths: Vec<usize> = keep
1503+ let mut widths: Vec<usize> = keep
14451504 .iter()
14461505 .map(|&i| {
14471506 rows.iter()
@@ -1451,6 +1510,40 @@ fn display_table(
14511510 .max(width(&fields[i]))
14521511 })
14531512 .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+ }
14541547 // header
14551548 out.push_str(&pad);
14561549 let header: Vec<String> = keep
@@ -1465,7 +1558,7 @@ fn display_table(
14651558 let cells: Vec<String> = keep
14661559 .iter()
14671560 .zip(&widths)
1468− .map(|(&i, w)| pad_right(&r[i], *w))
1561+ .map(|(&i, w)| pad_right(&cut_to_width(&r[i], *w), *w))
14691562 .collect();
14701563 out.push_str(&cells.join(" "));
14711564 out.push('\n');
@@ -2459,6 +2552,7 @@ fn draw_rows(
24592552 term_w: Option<usize>,
24602553) -> Result<String, Crash> {
24612554 let rail_chars = 2 * lanes_n;
2555+ let overflow = placements.iter().any(|p| p.flattened);
24622556 // each row's rails, drawn once; a run row's go on past the rails area
24632557 // when its count does not fit
24642558 let mut rail_state = vec![None; lanes_n];
@@ -2491,7 +2585,7 @@ fn draw_rows(
24912585 for (i, n) in rows.iter().enumerate() {
24922586 texts.push(build_row_text(n, index.focus_adjacent[i], opts, pal, with_focus, prefix_len, id_w));
24932587 }
2494− // Columns 4–8 start at the same offset on every row (§Step 4). The size
2588+ // Columns 5–9 start at the same offset on every row (§Step 4). The size
24952589 // bar's column is there when any row draws a bar — ` ▅`, two columns —
24962590 // and blank on the rows that draw none, so a bar never shifts a message.
24972591 let bar_col = texts.iter().any(|t| !t.bar.is_empty());
@@ -2509,6 +2603,17 @@ fn draw_rows(
25092603 _ => None,
25102604 };
25112605 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+ }
25122617 line.push(' ');
25132618 line.push_str(&t.id);
25142619 if bar_col {
@@ -2596,6 +2701,8 @@ fn draw_rows(
25962701 pad_to(&mut line, meta_off);
25972702 line.push_str(&metas[idx]);
25982703 }
2704+ // Remove layout padding, while preserving spaces in detail paths below.
2705+ while line.ends_with(' ') { line.pop(); }
25992706 // the whole focus row is highlighted with a background band across the
26002707 // full terminal width (§Colour; colour only — the ▶ gutter still marks
26012708 // the focus when colour is off)
@@ -2621,6 +2728,7 @@ fn draw_rows(
26212728 let mut dline = String::new();
26222729 dline.push_str(" ");
26232730 dline.push_str(&color_rails(&dchars, &dlane0, pal, None));
2731+ if overflow { dline.push_str(" "); }
26242732 dline.push(' ');
26252733 // the marks start two columns into the id column (§Step 4)
26262734 dline.push_str(" ");
@@ -2639,19 +2747,18 @@ fn draw_rows(
26392747 format!("{} {}", colored, p)
26402748 })
26412749 .collect();
2642− dline.push_str(&marks.join(" "));
2643− lines.push(dline);
2644− }
2645− }
2646− // no trailing whitespace on any row
2647− for l in lines.iter_mut() {
2648− while l.ends_with(' ') {
2649− l.pop();
2750+ let detail_lines = match term_w {
2751+ Some(w) => wrap_detail_items(&marks, w.saturating_sub(width(&dline)).max(1)),
2752+ None => vec![marks.join(" ")],
2753+ };
2754+ for text in detail_lines {
2755+ lines.push(format!("{}{}", dline, text));
2756+ }
26502757 }
26512758 }
26522759 // Legend (§Legend): explain the symbols that actually appear, faintly, on
26532760 // the right of the tree if it fits the terminal width, else at the bottom.
2654− append_legend(&mut lines, rows, opts, pal, term_w);
2761+ append_legend(&mut lines, rows, opts, pal, term_w, overflow);
26552762 let mut out = lines.join("\n");
26562763 out.push('\n');
26572764 Ok(out)
@@ -2664,10 +2771,11 @@ struct UsedSymbols {
26642771 run: bool, // a ╎ n run row
26652772 collapsed: bool, // a ⋯ n collapsed node
26662773 focus_gutter: bool, // ▶ present (a focus row exists)
2774+ overflow: bool, // separate » status column
26672775 detail_marks: Vec<char>, // + ~ − ✖ present in the focus's detail line
26682776}
26692777
2670−fn collect_used(rows: &[&Display], opts: &TreeOptions, icons: bool) -> UsedSymbols {
2778+fn collect_used(rows: &[&Display], opts: &TreeOptions, icons: bool, overflow: bool) -> UsedSymbols {
26712779 let mut present: BTreeSet<&'static str> = BTreeSet::new();
26722780 let mut run = false;
26732781 let mut collapsed = false;
@@ -2706,6 +2814,7 @@ fn collect_used(rows: &[&Display], opts: &TreeOptions, icons: bool) -> UsedSymbo
27062814 collapsed,
27072815 focus_gutter,
27082816 detail_marks: marks.into_iter().collect(),
2817+ overflow,
27092818 }
27102819}
27112820
@@ -2741,6 +2850,9 @@ fn legend_lines(used: &UsedSymbols) -> Vec<String> {
27412850 if used.focus_gutter {
27422851 out.push("▶ current commit".to_string());
27432852 }
2853+ if used.overflow {
2854+ out.push("» branch exceeds lane limit; increase lanes".to_string());
2855+ }
27442856 if used.run {
27452857 out.push("╎ n run of n commits".to_string());
27462858 }
@@ -2763,8 +2875,9 @@ fn append_legend(
27632875 opts: &TreeOptions,
27642876 pal: &Palette,
27652877 tty_width: Option<usize>,
2878+ overflow: bool,
27662879) {
2767− let used = collect_used(rows, opts, opts.icons);
2880+ let used = collect_used(rows, opts, opts.icons, overflow);
27682881 let entries = legend_lines(&used);
27692882 if entries.is_empty() {
27702883 return;
@@ -2874,8 +2987,6 @@ fn rail_row(
28742987 }
28752988 } else if state[i].is_some() {
28762989 '│'
2877− } else if pl.flattened && i == lanes_n - 1 {
2878− '»'
28792990 } else {
28802991 ' '
28812992 };
@@ -2963,6 +3074,61 @@ fn color_rails(
29633074 s
29643075}
29653076
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+
29663132/// `s` cut to display width `w`, ending with `…` when anything is cut (and
29673133/// there is a column for it): the message on a terminal (§Step 4). Colour
29683134/// escapes are kept whole and not counted, and those past the cut still
src/setup.rsnew file+119
@@ -0,0 +1,119 @@
1+//! Explicit first-run configuration, independent of repository discovery.
2+
3+use j::ast::Item;
4+use j::config;
5+use j::eval::Interp;
6+use j::lex::{lex, Tok};
7+use j::parse::parse_config;
8+use j::show::show;
9+use j::value::Value;
10+use std::io::Write;
11+use std::path::Path;
12+use std::rc::Rc;
13+
14+const IDENTITY_EXAMPLE: &str = "user = { name = \"Ada Lovelace\", email = \"ada@example.org\" }";
15+
16+/// `args` contains only the arguments after `setup`. Existing files, including
17+/// invalid configs and symlinks, are never replaced. A new file is published
18+/// atomically so concurrent setup commands cannot overwrite one another.
19+pub fn run(path: &Path, default: &str, args: &[&str]) -> Result<String, (u8, String)> {
20+ if !matches!(args.len(), 0 | 2) {
21+ return Err((2, "usage: j setup [NAME EMAIL]; for example: j setup 'Ada Lovelace' ada@example.org; see `j help setup`".into()));
22+ }
23+ if path.symlink_metadata().is_ok() {
24+ return existing(path, args);
25+ }
26+ let source = match args {
27+ [name, email] => with_identity(default, name, email)?,
28+ [] => default.to_string(),
29+ _ => unreachable!(),
30+ };
31+ let parent = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or_else(|| Path::new("."));
32+ std::fs::create_dir_all(parent)
33+ .map_err(|error| (3, format!("cannot create config directory {}: {}", parent.display(), error)))?;
34+ let mut temporary = tempfile::NamedTempFile::new_in(parent)
35+ .map_err(|error| (3, format!("cannot prepare config at {}: {}", path.display(), error)))?;
36+ temporary.write_all(source.as_bytes())
37+ .map_err(|error| (3, format!("cannot write config at {}: {}", path.display(), error)))?;
38+ match temporary.persist_noclobber(path) {
39+ Ok(_) => {}
40+ Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => return existing(path, args),
41+ Err(error) => return Err((3, format!("cannot create config at {}: {}", path.display(), error.error))),
42+ }
43+ if args.is_empty() {
44+ Ok(format!(
45+ "Created the complete default config at {}.\n\nEdit the existing `user` definition in this file, replacing both example values with your identity:\n {}\nKeep the rest of the file: it defines your commands.\n\nThen run `j init` in your project directory, or `j clone URL`.\n",
46+ path.display(), IDENTITY_EXAMPLE,
47+ ))
48+ } else {
49+ Ok(format!(
50+ "Configuration ready at {}.\nIdentity: {}\n\nNext: run `j init` in your project directory, or `j clone URL`.\n",
51+ path.display(), identity_line(&source)?,
52+ ))
53+ }
54+}
55+
56+fn existing(path: &Path, args: &[&str]) -> Result<String, (u8, String)> {
57+ if !args.is_empty() {
58+ return Err((2, format!(
59+ "config already exists at {}; it was not changed. Edit its existing `user` definition to change your identity; see `j help config`.",
60+ path.display(),
61+ )));
62+ }
63+ let problem = match std::fs::read_to_string(path) {
64+ Ok(source) => match identity_line(&source) {
65+ Ok(identity) => return Ok(format!(
66+ "Configuration already exists at {} and is ready.\nIdentity: {}\n\nNext: run `j init` in your project directory, or `j clone URL`.\n",
67+ path.display(), identity,
68+ )),
69+ Err((_, message)) => message,
70+ },
71+ Err(error) => format!("cannot read this config: {}", error),
72+ };
73+ Ok(format!(
74+ "Configuration already exists at {}; it was not changed.\nNeeds attention: {}\n\nTo set your identity, edit the existing `user` definition:\n {}\nKeep the rest of the file and repair the problem above.\nSee `j help config`; then retry `j setup`.\n",
75+ path.display(), problem, IDENTITY_EXAMPLE,
76+ ))
77+}
78+
79+fn identity_line(source: &str) -> Result<String, (u8, String)> {
80+ let cfg = config::load_config(source).map_err(|error| (3, error.message()))?;
81+ j::jj::configured_identity(&cfg)
82+}
83+
84+fn with_identity(default: &str, name: &str, email: &str) -> Result<String, (u8, String)> {
85+ let items = parse_config(default, Rc::new(config::reserved_set()))
86+ .map_err(|error| (3, format!("cannot prepare default config: {}", error.msg)))?;
87+ let line = items.iter().find_map(|item| match item {
88+ Item::Definition(name, _, line) if name == "user" => Some(*line),
89+ _ => None,
90+ }).ok_or_else(|| (3, "default config has no `user` definition".into()))?;
91+ let tokens = lex(default).map_err(|error| (3, error.msg))?;
92+ let start = tokens.iter().position(|token| token.line == line && token.col == 1)
93+ .ok_or_else(|| (3, "cannot locate the default `user` definition".into()))?;
94+ let mut depth = 0usize;
95+ let mut end = tokens[start].end;
96+ for token in &tokens[start..] {
97+ if matches!(token.tok, Tok::Eof)
98+ || (token.line > line && token.col == 1 && depth == 0 && !matches!(token.tok, Tok::Newline))
99+ {
100+ break;
101+ }
102+ match token.tok {
103+ Tok::LParen | Tok::LBracket | Tok::LBrace => depth += 1,
104+ Tok::RParen | Tok::RBracket | Tok::RBrace => depth = depth.saturating_sub(1),
105+ Tok::Newline => continue,
106+ _ => {}
107+ }
108+ end = token.end;
109+ }
110+ let interp = Interp::dummy();
111+ let name = show(&interp, &Value::text(name)).map_err(|error| (3, error.msg))?;
112+ let email = show(&interp, &Value::text(email)).map_err(|error| (3, error.msg))?;
113+ let definition = format!("user = {{ name = {}, email = {} }}", name, email);
114+ let mut source = default.to_string();
115+ source.replace_range(tokens[start].start..end, &definition);
116+ // Reuse the same identity requirements as init/clone before writing a file.
117+ identity_line(&source)?;
118+ Ok(source)
119+}
src/show.rs+38−3
@@ -497,12 +497,47 @@ pub fn difft(path: &Value, a: &Value, b: &Value) -> Result<Value, Crash> {
497497 }
498498 }
499499 crate::terminal::external_stderr();
500− let out = cmd
501− .output()
502− .map_err(|_| Crash::new("difft: cannot execute `difft` (not on PATH)"))?;
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+ };
503507 Ok(Value::text(String::from_utf8_lossy(&out.stdout).to_string()))
504508}
505509
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+
506541pub fn stdout_is_tty() -> bool {
507542 unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 }
508543}
src/terminal.rs+7−5
@@ -39,12 +39,12 @@ fn capable_terminal() -> bool {
3939fn interactive() -> bool {
4040 io::stdout().is_terminal()
4141 && io::stderr().is_terminal()
42− && capable_terminal()
4342 && !nonempty_env("CI")
4443}
4544
4645fn motion_enabled() -> bool {
4746 interactive()
47+ && capable_terminal()
4848 && !matches!(
4949 std::env::var("J_ANIMATIONS")
5050 .unwrap_or_default()
@@ -83,10 +83,12 @@ fn report_with_color(args: fmt::Arguments<'_>, code: &str) {
8383pub fn success(message: &str) {
8484 stop_progress();
8585 if interactive() {
86− let _ = writeln!(
87− io::stderr().lock(),
88− "\x1b[1;92m✓\x1b[0m \x1b[1;96mj:\x1b[0m {message}"
89− );
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+ }
9092 }
9193}
9294
tests/base.rs+2−2
@@ -413,8 +413,8 @@ fn inspection_definitions() {
413413 // meta of minted crashes
414414 let m = crash(&mut i, &cfg, "meta @", repo.clone());
415415 assert!(m.contains("stored commit") || m.contains("meta"), "{}", m);
416− // review renders the two changed paths with difft, or crashes saying
417− // difft cannot run; "no changes" is for a focus that changes nothing
416+ // review renders changed paths with difft or the built-in fallback;
417+ // "no changes" is for a focus that changes nothing.
418418 let r = ev(&mut i, &cfg, "review", repo.clone());
419419 match r {
420420 Ok(v) => assert_ne!(v.as_text().unwrap(), "no changes\n"),
tests/cli.rs+9−7
@@ -316,8 +316,8 @@ fn parse_error_is_exit_3() {
316316
317317#[test]
318318fn deep_nesting_is_a_parse_error() {
319− // §1.4: however deep the input, the process exits 3 with one `j:` line
320− // rather than aborting on a stack overflow in the parser
319+ // §1.4: however deep the input, the process exits 3 with a diagnostic
320+ // and bounded source excerpt rather than overflowing or dumping the input.
321321 let r = setup();
322322 let n = 100_000;
323323 // a left-associative chain too: the parser builds it in a loop, but its
@@ -330,7 +330,8 @@ fn deep_nesting_is_a_parse_error() {
330330 ] {
331331 let out = r.j_stdin(&src, &[]);
332332 assert_eq!(out.code, 3, "{}", out.stderr);
333− assert!(out.stderr.starts_with("j: ") && out.stderr.lines().count() == 1, "{}", out.stderr);
333+ assert!(out.stderr.starts_with("j: ") && out.stderr.lines().count() <= 2, "{}", out.stderr);
334+ assert!(out.stderr.len() < 1024, "diagnostic is not bounded: {} bytes", out.stderr.len());
334335 }
335336}
336337
@@ -720,8 +721,8 @@ fn text_result_prints_raw() {
720721
721722#[test]
722723fn review_says_no_changes_only_when_nothing_changed() {
723− // `review` caught every crash of `diffs` with `or`, so without difft on
724− // PATH it printed "no changes" over a change it could not render
724+ // A missing renderer must still display changes; an installed renderer's
725+ // failures must remain visible instead of claiming "no changes".
725726 let r = setup();
726727 // a PATH holding nothing but what the test puts there (the config
727728 // directory is removed with the repository)
@@ -746,8 +747,9 @@ fn review_says_no_changes_only_when_nothing_changed() {
746747 assert_eq!(review(&bin).ok().stdout, "no changes\n");
747748 r.write("f", "hello\n");
748749 let out = review(&bin);
749− assert_eq!(out.code, 1, "stdout: {}", out.stdout);
750− assert!(out.stderr.contains("cannot execute `difft`"), "{}", out.stderr);
750+ assert_eq!(out.code, 0, "{}", out.stderr);
751+ assert_eq!(out.stdout, "--- a/f\n+++ b/f\n@@ -0,0 +1 @@\n+hello\n");
752+ assert!(out.stderr.is_empty(), "{}", out.stderr);
751753 use std::os::unix::fs::PermissionsExt;
752754 // a difft that runs and fails renders what it printed, nothing here,
753755 // and its complaint reaches the terminal instead of being dropped (§7.10)
tests/display.rs+134−1
@@ -95,6 +95,16 @@ fn make_interp(be: MemBackend) -> (Interp, config::Config) {
9595}
9696
9797fn eval_and_display(interp: &mut Interp, cfg: &config::Config, src: &str, repo: Value) -> String {
98+ eval_and_display_width(interp, cfg, src, repo, None)
99+}
100+
101+fn eval_and_display_width(
102+ interp: &mut Interp,
103+ cfg: &config::Config,
104+ src: &str,
105+ repo: Value,
106+ terminal: Option<usize>,
107+) -> String {
98108 let outer = Rc::new(cfg.global_names.clone());
99109 let e = parse_expr(src, outer).unwrap();
100110 let e = config::resolve_ids(&e, interp).unwrap();
@@ -106,7 +116,7 @@ fn eval_and_display(interp: &mut Interp, cfg: &config::Config, src: &str, repo:
106116 } else {
107117 v
108118 };
109− j::render::display(interp, &v, false).unwrap()
119+ j::render::display_with_width(interp, &v, false, terminal).unwrap()
110120}
111121
112122#[test]
@@ -1115,3 +1125,126 @@ fn an_unreadable_blob_crashes_every_time() {
11151125 let src = "(\\repo -> show (contentAt [\"f.txt\"] (files repo)) or \"caught\")";
11161126 assert_eq!(try_eval_and_display(&mut i, &cfg, src, repo), Ok("caught\n".to_string()));
11171127}
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/eval.rs+3−1
@@ -860,7 +860,9 @@ fn crash_names_the_innermost_definition_executing() {
860860 ("sameAsInner 0", Some("inner")),
861861 ("sameAsInner \"x\"", Some("sameAsInner")),
862862 (prev.as_str(), Some("goto")),
863− (next.as_str(), Some("goto")),
863+ // next now handles an empty child list itself to suggest `j new`;
864+ // the direct goto and prev cases above still cover delegated errors.
865+ (next.as_str(), Some("next")),
864866 // a builtin is no definition: outside every definition, none
865867 ("head []", None),
866868 ("crash \"top\"", None),
tests/help.rs+121−5
@@ -35,6 +35,19 @@ impl HelpCli {
3535 std::fs::create_dir_all(self.config.path().join("j")).unwrap();
3636 std::fs::write(self.config.path().join("j/config.j"), source).unwrap();
3737 }
38+
39+ fn reference_config(&self) {
40+ self.write_config(&include_str!("../config.j")
41+ .replace("Your Name", "Help Test")
42+ .replace("you@example.com", "help@example.com"));
43+ }
44+
45+ fn operation_heads(&self) -> Vec<std::ffi::OsString> {
46+ let mut heads: Vec<_> = std::fs::read_dir(self.dir.path().join(".jj/repo/op_heads/heads"))
47+ .unwrap().map(|entry| entry.unwrap().file_name()).collect();
48+ heads.sort();
49+ heads
50+ }
3851}
3952
4053fn stdout(output: Output) -> String {
@@ -78,7 +91,7 @@ fn overview_and_reserved_topics_survive_broken_config() {
7891 cli.write_config("broken =\n");
7992 stdout(cli.run(&["help"]));
8093 for name in [
81− "init", "clone", "remote", "fetch", "push", "undo", "redo", "ops", "help", "commands",
94+ "init", "clone", "remote", "fetch", "push", "undo", "redo", "ops", "help", "commands", "setup",
8295 ] {
8396 assert!(stdout(cli.run(&["help", name])).contains(name));
8497 }
@@ -162,7 +175,7 @@ fn piped_help_and_expression_comments_preserve_input_rules() {
162175 stdout(cli.run(&["help", "push"]))
163176 );
164177
165− cli.write_config(include_str!("../config.j"));
178+ cli.reference_config();
166179 stdout(cli.run(&["init"]));
167180 assert_eq!(stdout(cli.run(&["\"--help\""])), "--help\n");
168181 assert_eq!(stdout(cli.run(&["(help) or 42"])), "42\n");
@@ -174,9 +187,9 @@ fn piped_help_and_expression_comments_preserve_input_rules() {
174187fn help_does_not_take_repository_lock_or_snapshot_changes() {
175188 use std::os::fd::AsRawFd;
176189 let cli = HelpCli::new();
177− cli.write_config(include_str!("../config.j"));
190+ cli.reference_config();
178191 stdout(cli.run(&["init"]));
179− let before = stdout(cli.run(&["ops"]));
192+ let before = cli.operation_heads();
180193 std::fs::write(cli.dir.path().join("work.txt"), "unsaved work\n").unwrap();
181194 let lock = std::fs::OpenOptions::new()
182195 .create(true)
@@ -192,13 +205,49 @@ fn help_does_not_take_repository_lock_or_snapshot_changes() {
192205 stdout(cli.run(&["help", "new"]));
193206 stdout(cli.run(&["commands", "Edit"]));
194207 drop(lock);
195− assert_eq!(stdout(cli.run(&["ops"])), before);
208+ assert_eq!(cli.operation_heads(), before);
196209 assert_eq!(
197210 std::fs::read_to_string(cli.dir.path().join("work.txt")).unwrap(),
198211 "unsaved work\n"
199212 );
200213}
201214
215+#[test]
216+fn separate_help_flags_never_execute_edits_or_take_the_repository_lock() {
217+ use std::os::fd::AsRawFd;
218+ let cli = HelpCli::new();
219+ // Help also works without a repository, creating no config.
220+ assert_eq!(stdout(cli.run(&["abandon", "--help"])), stdout(cli.run(&["help", "abandon"])));
221+ assert!(stdout(cli.run(&["clone", "--help"])).contains("Clone"));
222+ assert_eq!(std::fs::read_dir(cli.config.path()).unwrap().count(), 0);
223+
224+ cli.reference_config();
225+ stdout(cli.run(&["init"]));
226+ std::fs::write(cli.dir.path().join("draft.txt"), "valuable work\n").unwrap();
227+ stdout(cli.run(&["describe \"valuable work\""]));
228+ let before = cli.operation_heads();
229+ std::fs::write(cli.dir.path().join("draft.txt"), "unrecorded edits\n").unwrap();
230+ let lock = std::fs::OpenOptions::new().create(true).truncate(false).write(true)
231+ .open(cli.dir.path().join(".jj/j.lock")).unwrap();
232+ assert_eq!(unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }, 0);
233+ for command in ["abandon", "new", "squash", "describe"] {
234+ for flag in ["--help", "-h"] {
235+ assert_eq!(stdout(cli.run(&[command, flag])), stdout(cli.run(&["help", command])));
236+ }
237+ }
238+ assert_eq!(cli.operation_heads(), before);
239+ assert_eq!(std::fs::read_to_string(cli.dir.path().join("draft.txt")).unwrap(), "unrecorded edits\n");
240+}
241+
242+#[test]
243+fn config_help_is_available_when_configuration_is_broken() {
244+ let cli = HelpCli::new();
245+ cli.write_config("broken =\n");
246+ let help = stdout(cli.run(&["help", "config"]));
247+ assert!(help.contains("user ="));
248+ assert!(help.contains(cli.config.path().join("j/config.j").to_str().unwrap()));
249+}
250+
202251#[test]
203252fn help_read_failures_do_not_fall_back_to_reference_commands() {
204253 let cli = HelpCli::new();
@@ -208,3 +257,70 @@ fn help_read_failures_do_not_fall_back_to_reference_commands() {
208257 assert!(String::from_utf8_lossy(&output.stderr).contains("cannot read config"));
209258 stdout(cli.run(&["help", "clone"]));
210259}
260+
261+#[test]
262+fn default_discovery_is_short_and_full_catalog_remains_available() {
263+ let cli = HelpCli::new();
264+ let common = stdout(cli.run(&["commands"]));
265+ assert!(common.lines().count() <= 40, "{}", common);
266+ for command in ["j setup", "j save", "j 'preview squash'", "j commands all"] {
267+ assert!(common.contains(command), "{}", common);
268+ }
269+ assert!(!common.contains("(++) :"));
270+ assert!(!common.contains("Subtree ="));
271+ let all = stdout(cli.run(&["commands", "all"]));
272+ assert!(all.contains("(++) :"));
273+ assert!(all.contains("Subtree ="));
274+ assert!(all.contains("save : Edit"));
275+ assert_eq!(std::fs::read_dir(cli.config.path()).unwrap().count(), 0);
276+}
277+
278+#[test]
279+fn discovery_understands_beginner_tasks_without_inventing_commands() {
280+ let cli = HelpCli::new();
281+ for (query, expected) in [("save", "save : Edit"), ("record", "save : Edit"),
282+ ("commit", "describe : Text -> Edit"), ("branch", "new : Edit"),
283+ ("checkout", "goto : Revset -> Edit")] {
284+ let output = stdout(cli.run(&["commands", query]));
285+ let first = output.lines().find(|line| line.starts_with(" ")).unwrap();
286+ assert!(first.contains(expected), "{}", output);
287+ }
288+ let stash = stdout(cli.run(&["commands", "stash"]));
289+ assert!(stash.contains("no direct stash command"));
290+ assert!(stash.contains("j save") && stash.contains("j new"));
291+ cli.write_config("user = {}\ntree = 0\nimmutable = []\nlabelled = 0\n-- Custom shelving command.\nstash = 42\n");
292+ let stash = stdout(cli.run(&["commands", "stash"]));
293+ assert!(stash.contains("stash (definition; no signature)"));
294+ assert!(!stash.contains("no direct stash"));
295+ let record = stdout(cli.run(&["commands", "record"]));
296+ assert!(!record.contains("save :") && !record.contains("id :"));
297+}
298+
299+#[test]
300+fn command_help_leads_with_plain_explanations_and_setup_is_actionable() {
301+ let cli = HelpCli::new();
302+ let describe = stdout(cli.run(&["help", "describe"]));
303+ assert!(describe.starts_with("Set the message"), "{}", describe);
304+ assert!(describe.find("Example:").unwrap() < describe.find("Text -> Edit").unwrap());
305+ let config = stdout(cli.run(&["help", "config"]));
306+ assert!(config.contains("j setup 'Ada Lovelace' ada@example.org"));
307+ assert!(config.contains("not a complete config"));
308+ let clone = stdout(cli.run(&["help", "clone"]));
309+ assert!(clone.contains("cd project"));
310+ assert!(clone.contains("does not change your shell's directory"));
311+}
312+
313+#[test]
314+fn everyday_catalog_respects_custom_docs_and_missing_example_dependencies() {
315+ let cli = HelpCli::new();
316+ cli.write_config("user = {}\ntree = 0\nimmutable = []\nlabelled = 0\n-- Display my custom report.\npreview : Text -> Text\npreview = \\report -> report\n-- Open our custom dashboard.\nnew : Text -> Text\nnew = \\dashboard -> dashboard\n");
317+ let common = stdout(cli.run(&["commands"]));
318+ assert!(common.contains("Display my custom report."));
319+ assert!(common.contains("Open our custom dashboard."));
320+ assert!(common.contains("j help preview"));
321+ assert!(!common.contains("preview squash"));
322+ assert!(!common.contains("Start the next commit"));
323+ assert!(!common.contains("j save"));
324+ let all = stdout(cli.run(&["commands", "all"]));
325+ assert!(!all.contains("squash :"));
326+}
tests/onboarding.rsnew file+200
@@ -0,0 +1,200 @@
1+//! Follow the tutorial as a new user would, including the files that reach a
2+//! remote and the difference between a preview and a Repo-returning edit.
3+
4+use std::collections::BTreeMap;
5+use std::ffi::OsString;
6+use std::path::{Path, PathBuf};
7+use std::process::{Command, Output};
8+
9+const TUTORIAL: &str = include_str!("../docs/tutorial.md");
10+
11+struct Session {
12+ root: tempfile::TempDir,
13+ config: PathBuf,
14+}
15+
16+impl Session {
17+ fn new() -> Self {
18+ let root = tempfile::tempdir().unwrap();
19+ let config = root.path().join("config");
20+ std::fs::create_dir_all(config.join("j")).unwrap();
21+ let source = include_str!("../config.j")
22+ .replace("Your Name", "Tutorial User")
23+ .replace("you@example.com", "tutorial@example.org");
24+ std::fs::write(config.join("j/config.j"), source).unwrap();
25+ Self { root, config }
26+ }
27+
28+ fn command(&self, program: &str, cwd: &Path) -> Command {
29+ let mut command = Command::new(program);
30+ command.current_dir(cwd)
31+ .env("XDG_CONFIG_HOME", &self.config)
32+ .env("GIT_CONFIG_NOSYSTEM", "1")
33+ .env("GIT_CONFIG_GLOBAL", "/dev/null")
34+ .env("NO_COLOR", "1");
35+ command
36+ }
37+
38+ fn j(&self, cwd: &Path, expression: &str) -> String {
39+ let mut command = self.command(env!("CARGO_BIN_EXE_j"), cwd);
40+ command.arg(expression);
41+ output(&mut command)
42+ }
43+
44+ fn git(&self, cwd: &Path, args: &[&str]) -> String {
45+ let mut command = self.command("git", cwd);
46+ command.args(args);
47+ output(&mut command)
48+ }
49+
50+ fn shell(&self, cwd: &Path, script: &str) {
51+ // Run the actual fenced commands, preserving shell quoting and `cd`.
52+ // Only these two explicitly selected tutorial sections are executable.
53+ let binary_dir = Path::new(env!("CARGO_BIN_EXE_j")).parent().unwrap();
54+ let mut paths = vec![binary_dir.to_path_buf()];
55+ paths.extend(std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()));
56+ let mut command = self.command("sh", cwd);
57+ command.env("PATH", std::env::join_paths(paths).unwrap())
58+ .args(["-eu", "-c", script]);
59+ output(&mut command);
60+ }
61+
62+ fn clone_main(&self) -> (PathBuf, PathBuf) {
63+ let remote = self.root.path().join("origin.git");
64+ let seed = self.root.path().join("seed");
65+ std::fs::create_dir(&seed).unwrap();
66+ self.git(self.root.path(), &["init", "--bare", "--initial-branch=main", remote.to_str().unwrap()]);
67+ self.git(&seed, &["init", "--initial-branch=main"]);
68+ std::fs::write(seed.join("README.md"), "A project to try j with.\n").unwrap();
69+ self.git(&seed, &["add", "README.md"]);
70+ self.git(&seed, &["-c", "user.name=Tutorial User", "-c", "user.email=tutorial@example.org",
71+ "-c", "commit.gpgsign=false", "commit", "-m", "Initial main"]);
72+ self.git(&seed, &["push", remote.to_str().unwrap(), "main"]);
73+
74+ let repo = self.root.path().join("work");
75+ let mut clone = self.command(env!("CARGO_BIN_EXE_j"), self.root.path());
76+ clone.arg("clone").arg(&remote).arg(&repo);
77+ output(&mut clone);
78+ (repo, remote)
79+ }
80+}
81+
82+fn output(command: &mut Command) -> String {
83+ let Output { status, stdout, stderr } = command.output().unwrap();
84+ assert!(status.success(), "{command:?} failed ({status}):\n{}\n{}",
85+ String::from_utf8_lossy(&stdout), String::from_utf8_lossy(&stderr));
86+ String::from_utf8(stdout).unwrap()
87+}
88+
89+fn tutorial_blocks(heading: &str) -> Vec<String> {
90+ let section = TUTORIAL.split_once(heading).expect("tutorial section exists").1;
91+ let section = section.split("\n## ").next().unwrap();
92+ let mut blocks = Vec::new();
93+ let mut current = None::<String>;
94+ for line in section.lines() {
95+ if line.starts_with("```") {
96+ match current.take() {
97+ Some(block) => blocks.push(block),
98+ None => current = Some(String::new()),
99+ }
100+ } else if let Some(block) = &mut current {
101+ block.push_str(line);
102+ block.push('\n');
103+ }
104+ }
105+ assert!(current.is_none(), "unclosed tutorial code fence");
106+ blocks
107+}
108+
109+fn operation_heads(repo: &Path) -> Vec<OsString> {
110+ let mut heads: Vec<_> = std::fs::read_dir(repo.join(".jj/repo/op_heads/heads"))
111+ .unwrap().map(|entry| entry.unwrap().file_name()).collect();
112+ heads.sort();
113+ heads
114+}
115+
116+fn working_files(repo: &Path) -> BTreeMap<OsString, Vec<u8>> {
117+ std::fs::read_dir(repo).unwrap()
118+ .map(|entry| entry.unwrap())
119+ .filter(|entry| entry.file_type().unwrap().is_file())
120+ .map(|entry| (entry.file_name(), std::fs::read(entry.path()).unwrap()))
121+ .collect()
122+}
123+
124+#[test]
125+fn tutorial_first_changes_put_each_description_on_the_files_it_names() {
126+ let session = Session::new();
127+ let blocks = tutorial_blocks("## Part 1 — Ten minutes to a working repository");
128+ // Output examples share this section with shell examples. Select the
129+ // executable fences by their first command, never their sample IDs.
130+ let script = blocks.into_iter()
131+ // The alternative `clone URL` example needs a user's remote; it is
132+ // separate from the preceding two-change session exercised here.
133+ .take_while(|block| !block.trim_start().starts_with("j clone "))
134+ .filter(|block| {
135+ let first = block.lines().find(|line| !line.trim().is_empty()).unwrap_or("");
136+ first.starts_with("mkdir ") || first.starts_with("echo ") || first.starts_with("j ")
137+ }).collect::<Vec<_>>().join("\n");
138+ assert!(script.contains("main.rs") && script.contains("lib.rs"), "{script}");
139+ session.shell(session.root.path(), &script);
140+ let repo = session.root.path().join("play");
141+
142+ assert_eq!(session.j(&repo, "\\r -> r.root.message"), "add a library\n");
143+ assert_eq!(session.j(&repo, "changed"), "lib.rs\n");
144+ assert_eq!(session.j(&repo, "\\r -> (up r).root.message"), "start the project\n");
145+ assert_eq!(session.j(&repo, "changed . prev"), "main.rs\n");
146+ assert_eq!(session.j(&repo, "length . commits . top"), "3\n",
147+ "the first file must belong to the described commit, without an extra empty child");
148+}
149+
150+#[test]
151+fn tutorial_realistic_session_pushes_both_parts_of_the_parser_to_main_based_branch() {
152+ let session = Session::new();
153+ let (repo, remote) = session.clone_main();
154+ let blocks = tutorial_blocks("## Part 10 — A realistic session, start to finish");
155+ let script = blocks.first().expect("the complete session is the first fence");
156+ assert!(script.contains("parser.rs") && script.contains("push"), "{script}");
157+ session.shell(&repo, script);
158+
159+ assert_eq!(session.git(&remote, &["show", "parser:parser.rs"]), "fn parse() {}\n");
160+ assert_eq!(session.git(&remote, &["show", "parser:lib.rs"]), "mod parser;\n");
161+ assert_eq!(session.git(&remote, &["log", "-1", "--format=%s", "parser"]), "add the parser\n");
162+ assert_eq!(session.git(&remote, &["diff", "--name-only", "main..parser"]), "lib.rs\nparser.rs\n");
163+ assert_eq!(session.git(&remote, &["rev-parse", "parser^"]), session.git(&remote, &["rev-parse", "main"]));
164+}
165+
166+#[test]
167+fn preview_squash_keeps_recorded_history_and_working_edits_while_validate_persists() {
168+ let session = Session::new();
169+ let repo = session.root.path().join("preview");
170+ std::fs::create_dir(&repo).unwrap();
171+ session.j(&repo, "init");
172+ std::fs::write(repo.join("first.txt"), "first part\n").unwrap();
173+ session.j(&repo, "describe \"first\"");
174+ session.j(&repo, "new");
175+ std::fs::write(repo.join("second.txt"), "second part\n").unwrap();
176+ session.j(&repo, "describe \"second\"");
177+
178+ // On a clean working copy these are the actual recorded commit hashes,
179+ // descriptions, and IDs, rather than hashes of an unpublished snapshot.
180+ let commits = "\\r -> show (map (\\c -> { id = c.id, hash = (meta c.id).hash, message = c.message }) (commits (top r)))";
181+ let recorded = session.j(&repo, commits);
182+ let heads = operation_heads(&repo);
183+ std::fs::write(repo.join("pending.txt"), "an edit not yet recorded\n").unwrap();
184+ let files = working_files(&repo);
185+ let preview = session.j(&repo, "preview squash");
186+ assert!(preview.contains("first"), "{preview}");
187+ assert_eq!(operation_heads(&repo), heads, "preview must not publish an operation");
188+ assert_eq!(working_files(&repo), files, "preview must not checkout its result");
189+
190+ // Restoring the original disk contents lets the metadata inspection read
191+ // recorded commits directly again, without a transient dirty snapshot.
192+ std::fs::remove_file(repo.join("pending.txt")).unwrap();
193+ assert_eq!(session.j(&repo, commits), recorded, "preview must not rewrite recorded commits");
194+ assert_eq!(operation_heads(&repo), heads);
195+
196+ session.j(&repo, "validate . squash");
197+ assert_ne!(operation_heads(&repo), heads, "validate returns a Repo, so this command saves the squash");
198+ assert_ne!(session.j(&repo, commits), recorded);
199+ assert_eq!(session.j(&repo, "changed . prev"), "first.txt\nsecond.txt\n");
200+}
tests/onboarding_vocab.rsnew file+153
@@ -0,0 +1,153 @@
1+//! Newcomer vocabulary remains ordinary, pure config code.
2+
3+use j::config::{self, Config};
4+use j::domain::{MemBackend, MetaInfo, ROOT_ID};
5+use j::eval::Interp;
6+use j::parse::parse_expr;
7+use j::value::{value_eq, BlobVal, Crash, Env, Value};
8+use std::rc::Rc;
9+
10+const CONFIG: &str = include_str!("../config.j");
11+
12+fn commit(id: &str, message: &str, content: Option<&str>) -> Value {
13+ let files = content.map(|text| Value::record(&[
14+ ("path", Value::list(vec![Value::text("work.txt")])),
15+ ("content", BlobVal::text_blob(text)),
16+ ])).into_iter().collect();
17+ Value::record(&[
18+ ("id", Value::Id(Rc::new(id.to_string()))),
19+ ("message", Value::text(message)),
20+ ("labels", Value::list(vec![])),
21+ ("files", Value::list(files)),
22+ ])
23+}
24+
25+fn repo(content: &str, children: Vec<Value>) -> Value {
26+ Value::record(&[
27+ ("root", commit("kppppppp", "current work", Some(content))),
28+ ("children", Value::list(children)),
29+ ("context", Value::list(vec![Value::record(&[
30+ ("parent", commit(ROOT_ID, "", None)),
31+ ("left", Value::list(vec![])),
32+ ("right", Value::list(vec![])),
33+ ])])),
34+ ])
35+}
36+
37+fn subtree(id: &str, message: &str) -> Value {
38+ Value::record(&[
39+ ("root", commit(id, message, Some("recorded"))),
40+ ("children", Value::list(vec![])),
41+ ])
42+}
43+
44+fn interpreter(source: &str, loaded: &Value, given: &Value) -> (Interp, Config) {
45+ let cfg = config::load_config(source).unwrap();
46+ let mut backend = MemBackend::new();
47+ for id in [ROOT_ID, "kppppppp", "kqqqqqqq", "krrrrrrr"] {
48+ backend.metas.insert(id.to_string(), MetaInfo {
49+ hash: id.to_string(), author: "Test".into(), email: "test@example.com".into(), time: 1,
50+ });
51+ }
52+ let mut interp = Interp::new(Rc::new(backend), cfg.shapes.clone(), Env::empty());
53+ *interp.old_repo.borrow_mut() = Some(loaded.clone());
54+ *interp.given_repo.borrow_mut() = Some(given.clone());
55+ config::eval_config(&mut interp, &cfg).unwrap();
56+ (interp, cfg)
57+}
58+
59+fn evaluate(interp: &mut Interp, cfg: &Config, source: &str, repo: Value) -> Result<Value, Crash> {
60+ let expression = parse_expr(source, Rc::new(cfg.global_names.clone())).unwrap();
61+ let expression = config::resolve_ids(&expression, interp).unwrap();
62+ let value = interp.eval(&Rc::new(expression), &interp.global_env())?;
63+ if matches!(value, Value::Fun(_)) { interp.apply(value, repo) } else { Ok(value) }
64+}
65+
66+#[test]
67+fn save_keeps_the_snapshot_and_preview_validates_without_returning_a_repo() {
68+ let old = repo("recorded", vec![]);
69+ let given = repo("edited", vec![]);
70+ let (mut interp, cfg) = interpreter(CONFIG, &old, &given);
71+ let saved = evaluate(&mut interp, &cfg, "save", given.clone()).unwrap();
72+ assert!(value_eq(&saved, &given).unwrap());
73+ let preview = evaluate(&mut interp, &cfg, "preview (describe \"ready for review\")", given.clone()).unwrap();
74+ assert!(preview.as_text().unwrap().contains("ready for review"));
75+ assert_eq!(given.field("root").unwrap().field("message").unwrap().as_text().unwrap(), "current work");
76+ assert!(value_eq(interp.old_repo.borrow().as_ref().unwrap(), &old).unwrap());
77+ let error = evaluate(&mut interp, &cfg, "preview (describe \"change root\" . top)", given).err().unwrap();
78+ assert!(error.msg.contains("focus must be a mutable commit"), "{}", error.msg);
79+}
80+
81+#[test]
82+fn next_at_a_tip_recommends_creating_a_child() {
83+ let given = repo("recorded", vec![]);
84+ let (mut interp, cfg) = interpreter(CONFIG, &given, &given);
85+ let error = evaluate(&mut interp, &cfg, "next", given.clone()).err().unwrap();
86+ assert!(error.msg.contains("no children") && error.msg.contains("j new"), "{}", error.msg);
87+ // tip still stops at a leaf; recovery text must not change navigation.
88+ let tip = evaluate(&mut interp, &cfg, "tip", given.clone()).unwrap();
89+ assert!(value_eq(&tip, &given).unwrap());
90+}
91+
92+#[test]
93+fn ambiguous_navigation_lists_commit_messages_and_usable_ids() {
94+ let given = repo("recorded", vec![
95+ subtree("kqqqqqqq", "keyboard support\nprivate second line"),
96+ subtree("krrrrrrr", ""),
97+ ]);
98+ let (mut interp, cfg) = interpreter(CONFIG, &given, &given);
99+ let error = evaluate(&mut interp, &cfg, "next", given.clone()).err().unwrap();
100+ for expected in ["expected one revision, got 2", "@kqqqqqqq", "keyboard support", "@krrrrrrr", "(no description)", "j by @ID"] {
101+ assert!(error.msg.contains(expected), "missing {expected}: {}", error.msg);
102+ }
103+ assert!(!error.msg.contains("private second line"), "{}", error.msg);
104+ let selected = evaluate(&mut interp, &cfg, "by @kqqqqqqq", given).unwrap();
105+ assert!(matches!(selected.field("root").unwrap().field("id").unwrap(), Value::Id(id) if id.as_str() == "kqqqqqqq"));
106+}
107+
108+#[test]
109+fn missing_remote_navigation_explains_fetch_without_changing_empty_revsets() {
110+ let given = repo("recorded", vec![]);
111+ let (mut interp, cfg) = interpreter(CONFIG, &given, &given);
112+ for expression in ["trunk", "%main"] {
113+ assert!(evaluate(&mut interp, &cfg, expression, given.clone()).unwrap().as_list().unwrap().is_empty());
114+ }
115+ for expression in ["goto trunk", "goto %main", "goto (const [])"] {
116+ let error = evaluate(&mut interp, &cfg, expression, given.clone()).err().unwrap();
117+ for expected in ["expected one revision, got 0", "j tree", "j remote URL", "j fetch"] {
118+ assert!(error.msg.contains(expected), "{expression}: missing {expected}: {}", error.msg);
119+ }
120+ }
121+}
122+
123+#[test]
124+fn status_distinguishes_commit_changes_from_unrecorded_work() {
125+ let old = repo("recorded", vec![]);
126+ let given = repo("edited", vec![]);
127+ let (mut interp, cfg) = interpreter(CONFIG, &old, &given);
128+ let status = evaluate(&mut interp, &cfg, "status", given.clone()).unwrap();
129+ let paths = Value::list(vec![Value::list(vec![Value::text("work.txt")])]);
130+ assert!(value_eq(&status.field("changed").unwrap(), &paths).unwrap());
131+ assert!(value_eq(&status.field("unrecorded").unwrap(), &paths).unwrap());
132+
133+ let (mut interp, cfg) = interpreter(CONFIG, &given, &given);
134+ let recorded = evaluate(&mut interp, &cfg, "status", given).unwrap();
135+ assert!(value_eq(&recorded.field("changed").unwrap(), &paths).unwrap());
136+ assert!(recorded.field("unrecorded").unwrap().as_list().unwrap().is_empty());
137+}
138+
139+#[test]
140+fn older_custom_configs_can_omit_new_vocabulary() {
141+ let older = CONFIG
142+ .replace("unrecorded : Repo -> [Path]\n", "")
143+ .replace("save : Edit\nsave = id\n", "")
144+ .replace("preview : Edit -> Repo -> Text\npreview = \\edit -> tree . validate . edit\n", "")
145+ .replace(", unrecorded : [Path]", "")
146+ .replace("\n , unrecorded = unrecorded repo }", " }");
147+ let given = repo("recorded", vec![]);
148+ let (mut interp, cfg) = interpreter(&older, &given, &given);
149+ for name in ["save", "preview", "unrecorded"] { assert!(interp.globals.lookup(name).is_none(), "{name} unexpectedly available"); }
150+ let status = evaluate(&mut interp, &cfg, "status", given).unwrap();
151+ assert!(status.field("unrecorded").is_err());
152+ assert!(matches!(status, Value::Record(fields) if fields.len() == 5));
153+}
tests/remote.rs+299−10
@@ -29,6 +29,15 @@ fn uniq(tag: &str) -> PathBuf {
2929 ))
3030}
3131
32+fn operation_heads(dir: &std::path::Path) -> Vec<std::ffi::OsString> {
33+ let mut heads: Vec<_> = std::fs::read_dir(dir.join(".jj/repo/op_heads/heads"))
34+ .unwrap()
35+ .map(|entry| entry.unwrap().file_name())
36+ .collect();
37+ heads.sort();
38+ heads
39+}
40+
3241struct Env {
3342 dir: PathBuf,
3443 remote: PathBuf,
@@ -149,6 +158,273 @@ impl Drop for Env {
149158 }
150159}
151160
161+#[test]
162+fn dirty_push_warns_and_keeps_edits_available_for_recording() {
163+ let env = setup();
164+ let dest = env.dir.join("clone");
165+ env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
166+ env.j(&dest, &["describe \"work\""]).ok();
167+ std::fs::write(dest.join("a.txt"), "unrecorded\n").unwrap();
168+ // A status preview must not make a subsequent push silently appear to
169+ // include the previewed contents.
170+ env.j(&dest, &["status"]).ok();
171+ let state_path = dest.join(".jj/working_copy/tree_state");
172+ let state = std::fs::read(&state_path).unwrap();
173+ let out = env.j(&dest, &["push (label \"feature\" here)"]).ok();
174+ assert!(out.stderr.contains("working copy has unrecorded changes"), "{}", out.stderr);
175+ assert!(out.stderr.contains("push sends recorded content only"), "{}", out.stderr);
176+ assert!(out.stderr.contains("`j id`"), "{}", out.stderr);
177+ assert_eq!(git(&env.remote, &["show", "feature:a.txt"]), "one\n");
178+ assert_eq!(std::fs::read(&state_path).unwrap(), state, "push changed the saved working-copy state");
179+ assert_eq!(std::fs::read_to_string(dest.join("a.txt")).unwrap(), "unrecorded\n");
180+
181+ // Following the warning records and publishes the omitted edits.
182+ env.j(&dest, &["id"]).ok();
183+ let out = env.j(&dest, &["push (label \"feature\" here)"]).ok();
184+ assert!(!out.stderr.contains("unrecorded changes"), "{}", out.stderr);
185+ assert_eq!(git(&env.remote, &["show", "feature:a.txt"]), "unrecorded\n");
186+}
187+
188+#[test]
189+fn dirty_push_allows_unrelated_bookmarks_and_deletions_with_warning() {
190+ let env = setup();
191+ let dest = env.dir.join("clone");
192+ env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
193+ std::fs::write(dest.join("a.txt"), "unrecorded\n").unwrap();
194+ let out = env.j(&dest, &["push (label \"copy\" (labelled \"master\"))"]).ok();
195+ assert!(out.stderr.contains("push sends recorded content only"), "{}", out.stderr);
196+ assert_eq!(git(&env.remote, &["rev-parse", "copy"]), git(&env.remote, &["rev-parse", "master"]));
197+ let out = env.j(&dest, &["push (unlabel \"copy\")"]).ok();
198+ assert!(out.stderr.contains("push sends recorded content only"), "{}", out.stderr);
199+ assert_eq!(git(&env.remote, &["for-each-ref", "--format=%(refname)", "refs/heads/copy"]), "");
200+ assert_eq!(std::fs::read_to_string(dest.join("a.txt")).unwrap(), "unrecorded\n");
201+}
202+
203+#[test]
204+fn unrecorded_paths_distinguish_disk_edits_from_the_recorded_commit_change() {
205+ let env = setup();
206+ env.j(&env.dir, &["init"]).ok();
207+ std::fs::write(env.dir.join("modified.txt"), "before\n").unwrap();
208+ std::fs::write(env.dir.join("removed.txt"), "remove me\n").unwrap();
209+ env.j(&env.dir, &["describe \"baseline\""]).ok();
210+ env.j(&env.dir, &["new"]).ok();
211+ assert_eq!(env.j(&env.dir, &["unrecorded"]).ok().stdout.trim(), "none");
212+
213+ std::fs::write(env.dir.join("modified.txt"), "after\n").unwrap();
214+ std::fs::write(env.dir.join("added.txt"), "new file\n").unwrap();
215+ std::fs::remove_file(env.dir.join("removed.txt")).unwrap();
216+ let heads = operation_heads(&env.dir);
217+ let state_path = env.dir.join(".jj/working_copy/tree_state");
218+ let state = std::fs::read(&state_path).unwrap();
219+ let expected = "added.txt\nmodified.txt\nremoved.txt";
220+ for expression in ["unrecorded", "(.unrecorded) . status", "unrecorded . new", "unrecorded . prev"] {
221+ assert_eq!(env.j(&env.dir, &[expression]).ok().stdout.trim(), expected, "{}", expression);
222+ }
223+ assert_eq!(operation_heads(&env.dir), heads, "inspection recorded an operation");
224+ assert_eq!(std::fs::read(&state_path).unwrap(), state, "inspection saved the snapshot state");
225+ assert_eq!(std::fs::read_to_string(env.dir.join("modified.txt")).unwrap(), "after\n");
226+ assert_eq!(std::fs::read_to_string(env.dir.join("added.txt")).unwrap(), "new file\n");
227+ assert!(!env.dir.join("removed.txt").exists());
228+
229+ env.j(&env.dir, &["save"]).ok();
230+ assert_eq!(env.j(&env.dir, &["unrecorded"]).ok().stdout.trim(), "none");
231+ assert_eq!(env.j(&env.dir, &["(.unrecorded) . status"]).ok().stdout.trim(), "none");
232+ assert_eq!(env.j(&env.dir, &["changed"]).ok().stdout.trim(), expected);
233+}
234+
235+#[test]
236+fn unrecorded_paths_cover_the_snapshot_child_of_an_immutable_focus() {
237+ let env = setup();
238+ let dest = env.dir.join("clone");
239+ env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
240+ std::fs::write(dest.join("a.txt"), "recorded\n").unwrap();
241+ env.j(&dest, &["describe \"feature\""]).ok();
242+ env.j(&dest, &["push (label \"master\" here)"]).ok();
243+ let heads = operation_heads(&dest);
244+ std::fs::write(dest.join("a.txt"), "unrecorded\n").unwrap();
245+ assert_eq!(env.j(&dest, &["unrecorded"]).ok().stdout.trim(), "a.txt");
246+ assert_eq!(env.j(&dest, &["(.unrecorded) . status"]).ok().stdout.trim(), "a.txt");
247+ assert_eq!(operation_heads(&dest), heads);
248+ assert_eq!(git(&env.remote, &["show", "master:a.txt"]), "recorded\n");
249+ env.j(&dest, &["save"]).ok();
250+ assert_eq!(env.j(&dest, &["unrecorded"]).ok().stdout.trim(), "none");
251+ assert_eq!(env.j(&dest, &["changed"]).ok().stdout.trim(), "a.txt");
252+ assert_eq!(std::fs::read_to_string(dest.join("a.txt")).unwrap(), "unrecorded\n");
253+}
254+
255+#[test]
256+fn conflicted_push_names_files_and_gives_a_working_resolution_sequence() {
257+ let env = setup();
258+ let dest = env.dir.join("clone");
259+ env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
260+ std::fs::write(dest.join("a.txt"), "right\n").unwrap();
261+ env.j(&dest, &["describe \"right\""]).ok();
262+ let right = env.j(&dest, &["show . (.id) . focus"]).ok().stdout.trim().to_string();
263+ env.j(&dest, &["new . prev"]).ok();
264+ std::fs::write(dest.join("a.txt"), "left\n").unwrap();
265+ env.j(&dest, &["describe \"left\""]).ok();
266+ env.j(&dest, &[&format!("rebase (const [{}])", right)]).ok();
267+ let focus = env.j(&dest, &["show . (.id) . focus"]).ok().stdout.trim().to_string();
268+ let before = operation_heads(&dest);
269+ let out = env.j(&dest, &["push (label \"feature\" here)"]);
270+ assert_eq!(out.code, 1, "{}", out.stderr);
271+ for expected in ["has unresolved files: `a.txt`", "j conflicts", "Resolve the listed files", "j id"] {
272+ assert!(out.stderr.contains(expected), "{}", out.stderr);
273+ }
274+ assert!(out.stderr.contains(&format!("j by {}", focus)), "{}", out.stderr);
275+ assert_eq!(operation_heads(&dest), before);
276+ assert_eq!(git(&env.remote, &["for-each-ref", "--format=%(refname)", "refs/heads/feature"]), "");
277+
278+ env.j(&dest, &["conflicts"]).ok();
279+ env.j(&dest, &[&format!("by {}", focus)]).ok();
280+ std::fs::write(dest.join("a.txt"), "resolved\n").unwrap();
281+ env.j(&dest, &["id"]).ok();
282+ assert_eq!(env.j(&dest, &["conflicts"]).ok().stdout.trim(), "none");
283+ env.j(&dest, &["push (label \"feature\" here)"]).ok();
284+ assert_eq!(git(&env.remote, &["show", "feature:a.txt"]), "resolved\n");
285+}
286+
287+#[test]
288+fn placeholder_identity_refuses_setup_before_creating_repositories() {
289+ let env = setup();
290+ std::fs::write(env.cfg.join("j/config.j"), include_str!("../config.j")).unwrap();
291+ let out = env.j(&env.dir, &["init"]);
292+ assert_eq!(out.code, 3, "{}", out.stderr);
293+ assert!(out.stderr.contains("user identity is not configured"), "{}", out.stderr);
294+ assert!(out.stderr.contains(env.cfg.join("j/config.j").to_str().unwrap()), "{}", out.stderr);
295+ assert!(!env.dir.join(".jj").exists());
296+ assert!(!env.dir.join(".git").exists());
297+ let dest = env.dir.join("clone");
298+ let out = env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]);
299+ assert_eq!(out.code, 3, "{}", out.stderr);
300+ assert!(out.stderr.contains("user identity is not configured"), "{}", out.stderr);
301+ assert!(!dest.exists());
302+}
303+
304+#[test]
305+fn placeholder_identity_allows_inspection_but_never_records_edits() {
306+ let env = setup();
307+ env.j(&env.dir, &["init"]).ok();
308+ let reference = include_str!("../config.j");
309+ for invalid in [
310+ reference.replace("you@example.com", "test@example.com"),
311+ reference.replace("Your Name", "Test User"),
312+ reference.replace("Your Name", " ").replace("you@example.com", "test@example.com"),
313+ ] {
314+ std::fs::write(env.cfg.join("j/config.j"), invalid).unwrap();
315+ // An unchanged Repo and display-only expressions are safe even
316+ // when setup has not been completed.
317+ env.j(&env.dir, &["id"]).ok();
318+ std::fs::write(env.dir.join("draft.txt"), "my draft\n").unwrap();
319+ let before = operation_heads(&env.dir);
320+ env.j(&env.dir, &["status"]).ok();
321+ let out = env.j(&env.dir, &["describe \"draft\""]);
322+ assert_eq!(out.code, 3, "{}", out.stderr);
323+ assert!(out.stderr.contains("user identity is not configured"), "{}", out.stderr);
324+ assert_eq!(operation_heads(&env.dir), before);
325+ assert_eq!(std::fs::read_to_string(env.dir.join("draft.txt")).unwrap(), "my draft\n");
326+ std::fs::remove_file(env.dir.join("draft.txt")).unwrap();
327+ }
328+}
329+
330+#[test]
331+fn validated_preview_rejects_the_same_placeholder_identity_as_persistence() {
332+ let env = setup();
333+ env.j(&env.dir, &["init"]).ok();
334+ std::fs::write(env.dir.join("draft.txt"), "recorded draft\n").unwrap();
335+ env.j(&env.dir, &["describe \"draft\""]).ok();
336+ std::fs::write(env.cfg.join("j/config.j"), include_str!("../config.j")).unwrap();
337+ let before = operation_heads(&env.dir);
338+ let state_path = env.dir.join(".jj/working_copy/tree_state");
339+
340+ // Read-only inspection and an unchanged Repo require no authoring.
341+ // An ordinary preview remains useful while setting up an identity.
342+ for expression in ["validate", "tree", "tree . validate", "tree . new"] {
343+ env.j(&env.dir, &[expression]).ok();
344+ }
345+ let state = std::fs::read(&state_path).unwrap();
346+ let preview = env.j(&env.dir, &["tree . validate . new"]);
347+ assert_eq!(preview.code, 1, "{}", preview.stderr);
348+ assert!(preview.stderr.contains("user identity is not configured"), "{}", preview.stderr);
349+ let persisted = env.j(&env.dir, &["new"]);
350+ assert_eq!(persisted.code, 3, "{}", persisted.stderr);
351+ assert!(persisted.stderr.contains("user identity is not configured"), "{}", persisted.stderr);
352+ assert_eq!(operation_heads(&env.dir), before);
353+ assert_eq!(std::fs::read(&state_path).unwrap(), state);
354+ assert_eq!(std::fs::read_to_string(env.dir.join("draft.txt")).unwrap(), "recorded draft\n");
355+
356+ // A dirty snapshot is also a change that `id` would have to author.
357+ std::fs::write(env.dir.join("draft.txt"), "unrecorded draft\n").unwrap();
358+ let out = env.j(&env.dir, &["tree . validate"]);
359+ assert_eq!(out.code, 1, "{}", out.stderr);
360+ assert!(out.stderr.contains("user identity is not configured"), "{}", out.stderr);
361+ assert_eq!(operation_heads(&env.dir), before);
362+ assert_eq!(std::fs::read_to_string(env.dir.join("draft.txt")).unwrap(), "unrecorded draft\n");
363+}
364+
365+#[test]
366+fn dirty_undo_explains_recording_and_reverses_that_recording_first() {
367+ let env = setup();
368+ env.j(&env.dir, &["init"]).ok();
369+ env.j(&env.dir, &["describe \"original edit\""]).ok();
370+ let before = operation_heads(&env.dir);
371+ std::fs::write(env.dir.join("draft.txt"), "my draft\n").unwrap();
372+ let out = env.j(&env.dir, &["undo"]);
373+ assert_eq!(out.code, 1, "{}", out.stderr);
374+ assert!(out.stderr.contains("the next `j undo` undoes that recording"), "{}", out.stderr);
375+ assert!(out.stderr.contains("not the operation you originally wanted to undo"), "{}", out.stderr);
376+ assert_eq!(operation_heads(&env.dir), before);
377+ env.j(&env.dir, &["id"]).ok();
378+ env.j(&env.dir, &["undo"]).ok();
379+ assert!(!env.dir.join("draft.txt").exists());
380+ assert!(env.j(&env.dir, &["status"]).ok().stdout.contains("original edit"));
381+ env.j(&env.dir, &["undo"]).ok();
382+ assert!(!env.j(&env.dir, &["status"]).ok().stdout.contains("original edit"));
383+ env.j(&env.dir, &["redo"]).ok();
384+ env.j(&env.dir, &["redo"]).ok();
385+ assert_eq!(std::fs::read_to_string(env.dir.join("draft.txt")).unwrap(), "my draft\n");
386+}
387+
388+#[test]
389+fn push_expression_errors_offer_the_same_guidance_as_edits() {
390+ let env = setup();
391+ let dest = env.dir.join("clone");
392+ env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
393+ let before = operation_heads(&dest);
394+ let refs = git(&env.remote, &["for-each-ref", "--format=%(refname) %(objectname)"]);
395+ let typo = env.j(&dest, &["push labell"]);
396+ assert_eq!(typo.code, 1, "{}", typo.stderr);
397+ assert!(typo.stderr.contains("unbound name `labell`"), "{}", typo.stderr);
398+ assert!(typo.stderr.contains("Did you mean"), "{}", typo.stderr);
399+ assert!(typo.stderr.contains("`label`"), "{}", typo.stderr);
400+
401+ let incomplete = env.j(&dest, &["push (label"]);
402+ assert_eq!(incomplete.code, 3, "{}", incomplete.stderr);
403+ assert!(incomplete.stderr.contains("expression: line 1:"), "{}", incomplete.stderr);
404+ assert!(incomplete.stderr.contains("1 | (label"), "{}", incomplete.stderr);
405+
406+ let missing = env.j(&dest, &["push label"]);
407+ assert_eq!(missing.code, 1, "{}", missing.stderr);
408+ assert!(missing.stderr.contains("needs an argument of type Label"), "{}", missing.stderr);
409+ assert!(missing.stderr.contains("j help label"), "{}", missing.stderr);
410+ assert!(missing.stderr.contains("in label, from push label"), "{}", missing.stderr);
411+ assert_eq!(operation_heads(&dest), before);
412+ assert_eq!(git(&env.remote, &["for-each-ref", "--format=%(refname) %(objectname)"]), refs);
413+}
414+
415+#[test]
416+fn push_guidance_preserves_the_failing_definition_context() {
417+ let env = setup();
418+ env.j(&env.dir, &["init"]).ok();
419+ let path = env.cfg.join("j/config.j");
420+ let config = std::fs::read_to_string(&path).unwrap();
421+ std::fs::write(path, format!("{}\nlabell = \\repo -> crash \"unbound name `labell`\"\n", config)).unwrap();
422+ let out = env.j(&env.dir, &["push labell"]);
423+ assert_eq!(out.code, 1, "{}", out.stderr);
424+ assert!(out.stderr.contains("in labell, from push labell"), "{}", out.stderr);
425+ assert!(!out.stderr.contains("Did you mean"), "{}", out.stderr);
426+}
427+
152428#[test]
153429fn push_descriptions_cannot_impersonate_repository_creation() {
154430 let env = setup();
@@ -164,7 +440,12 @@ fn push_descriptions_cannot_impersonate_repository_creation() {
164440 ("init = []", "init", false),
165441 ] {
166442 std::fs::write(&path, format!("{}\n{}\n", config, definition)).unwrap();
443+ let before = operation_heads(&dest);
167444 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+ }
168449 let ops = env.j(&dest, &["ops"]).ok().stdout;
169450 assert!(ops.lines().next().unwrap().ends_with(&format!("push {}", expression)), "{}", ops);
170451 if changed {
@@ -1124,29 +1405,37 @@ fn push_of_an_empty_list_pushes_nothing() {
11241405 git(&work, &["add", "l.txt"]);
11251406 git(&work, &["-c", "user.email=t@t", "-c", "user.name=T", "commit", "-qm", "local unpushed"]);
11261407 env.j(&work, &["init"]).ok();
1127− env.j(&work, &["push []"]).ok();
1408+ let before_heads = operation_heads(&work);
1409+ let out = env.j(&work, &["push []"]).ok();
1410+ assert!(out.stderr.contains("No bookmarks selected; nothing published"), "{}", out.stderr);
1411+ assert!(out.stderr.contains("push (label \"feature\" here)"), "{}", out.stderr);
11281412 assert_eq!(master(), before, "push [] moved the remote's master");
1129− // it records one operation (§7.7)
1130− let ops = env.j(&work, &["ops"]).ok().stdout;
1131− assert!(ops.lines().next().unwrap_or("").ends_with(" []"), "{}", ops);
1413+ assert_eq!(operation_heads(&work), before_heads, "push [] recorded an operation");
11321414
11331415 // a clone's git branch has no upstream
11341416 let dest = env.dir.join("clone");
11351417 env.j(&env.dir, &["clone", env.remote.to_str().unwrap(), dest.to_str().unwrap()]).ok();
11361418 env.j(&dest, &["describe \"x\""]).ok();
1137− env.j(&dest, &["push []"]).ok();
1138− env.j(&dest, &["push (label \"nope\" (labelled \"zzz\"))"]).ok();
1419+ let before_heads = operation_heads(&dest);
1420+ let unavailable_git = env.dir.join("git-must-not-be-run");
1421+ env.j_env(&dest, &["push []"], &[("J_GIT", &unavailable_git)]).ok();
1422+ let out = env.j(&dest, &["push (label \"nope\" (labelled \"zzz\"))"]).ok();
1423+ assert!(out.stderr.contains("No bookmarks selected; nothing published"), "{}", out.stderr);
1424+ assert_eq!(operation_heads(&dest), before_heads);
11391425 assert_eq!(master(), before);
11401426 let refs = git(&env.remote, &["for-each-ref", "--format=%(refname)"]);
11411427 assert_eq!(refs.trim(), "refs/heads/master");
11421428
1143− // without origin it still exits 1
1429+ // No selection needs no remote, network connection, or operation.
11441430 let plain = env.dir.join("plain");
11451431 std::fs::create_dir_all(&plain).unwrap();
11461432 env.j(&plain, &["init"]).ok();
1147− let out = env.j(&plain, &["push []"]);
1148− assert_eq!(out.code, 1);
1149− assert!(out.stderr.contains("no remote origin"), "{}", out.stderr);
1433+ let before_heads = operation_heads(&plain);
1434+ let out = env.j(&plain, &["push []"]).ok();
1435+ assert!(out.stderr.contains("No bookmarks selected; nothing published"), "{}", out.stderr);
1436+ let out = env.j(&plain, &["push relabel"]).ok();
1437+ assert!(out.stderr.contains("No bookmarks selected; nothing published"), "{}", out.stderr);
1438+ assert_eq!(operation_heads(&plain), before_heads);
11501439}
11511440
11521441#[test]
tests/review_fallback.rsnew file+110
@@ -0,0 +1,110 @@
1+//! Review works with an isolated PATH and no optional diff renderer.
2+#![cfg(unix)]
3+
4+use std::os::unix::fs::PermissionsExt;
5+use std::path::PathBuf;
6+use std::process::{Command, Output};
7+
8+struct Fixture {
9+ _root: tempfile::TempDir,
10+ repo: PathBuf,
11+ config: PathBuf,
12+ bin: PathBuf,
13+}
14+
15+impl Fixture {
16+ fn new() -> Self {
17+ let root = tempfile::tempdir().unwrap();
18+ let repo = root.path().join("repo");
19+ let config = root.path().join("config");
20+ let bin = root.path().join("bin");
21+ std::fs::create_dir(&repo).unwrap();
22+ std::fs::create_dir(&bin).unwrap();
23+ std::fs::create_dir_all(config.join("j")).unwrap();
24+ std::fs::write(
25+ config.join("j/config.j"),
26+ include_str!("../config.j")
27+ .replace("Your Name", "Review Test")
28+ .replace("you@example.com", "review@example.com"),
29+ ).unwrap();
30+ let fixture = Self { _root: root, repo, config, bin };
31+ fixture.ok("init");
32+ fixture
33+ }
34+
35+ fn run(&self, expression: &str) -> Output {
36+ Command::new(env!("CARGO_BIN_EXE_j"))
37+ .arg(expression)
38+ .current_dir(&self.repo)
39+ .env("XDG_CONFIG_HOME", &self.config)
40+ .env("PATH", &self.bin)
41+ .env("NO_COLOR", "1")
42+ .output().unwrap()
43+ }
44+
45+ fn ok(&self, expression: &str) -> String {
46+ let out = self.run(expression);
47+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
48+ assert!(out.stderr.is_empty(), "{}", String::from_utf8_lossy(&out.stderr));
49+ String::from_utf8(out.stdout).unwrap()
50+ }
51+}
52+
53+#[test]
54+fn review_without_difft_identifies_added_deleted_and_modified_paths() {
55+ let fixture = Fixture::new();
56+ for (dir, content) in [("src", "before\n"), ("other", "gone\n")] {
57+ std::fs::create_dir(fixture.repo.join(dir)).unwrap();
58+ std::fs::write(fixture.repo.join(dir).join("file.txt"), content).unwrap();
59+ }
60+ fixture.ok("id");
61+ fixture.ok("new");
62+ std::fs::write(fixture.repo.join("src/file.txt"), "after").unwrap();
63+ std::fs::remove_file(fixture.repo.join("other/file.txt")).unwrap();
64+ std::fs::write(fixture.repo.join("added.txt"), "new\n").unwrap();
65+
66+ assert_eq!(fixture.ok("review"), concat!(
67+ "--- a/added.txt\n+++ b/added.txt\n@@ -0,0 +1 @@\n+new\n",
68+ "--- a/other/file.txt\n+++ b/other/file.txt\n@@ -1 +0,0 @@\n-gone\n",
69+ "--- a/src/file.txt\n+++ b/src/file.txt\n@@ -1 +1 @@\n-before\n+after\n",
70+ "\\ No newline at end of file\n",
71+ ));
72+}
73+
74+#[test]
75+fn fallback_reports_binary_changes_without_corrupting_their_bytes() {
76+ let fixture = Fixture::new();
77+ std::fs::write(fixture.repo.join("invalid.bin"), [0xff]).unwrap();
78+ std::fs::write(fixture.repo.join("nul.bin"), b"a\0").unwrap();
79+ assert_eq!(fixture.ok("review"), concat!(
80+ "Binary files a/invalid.bin and b/invalid.bin differ\n",
81+ "Binary files a/nul.bin and b/nul.bin differ\n",
82+ ));
83+ assert_eq!(std::fs::read(fixture.repo.join("invalid.bin")).unwrap(), [0xff]);
84+ assert_eq!(std::fs::read(fixture.repo.join("nul.bin")).unwrap(), b"a\0");
85+}
86+
87+#[test]
88+fn fallback_quotes_paths_and_omits_equal_content() {
89+ let fixture = Fixture::new();
90+ assert_eq!(fixture.ok("difft [\"dir name\" \"file.txt\"] (blob \"a\\n\") (blob \"b\\n\")"), concat!(
91+ "--- \"a/dir name/file.txt\"\n+++ \"b/dir name/file.txt\"\n",
92+ "@@ -1 +1 @@\n-a\n+b\n",
93+ ));
94+ assert!(fixture.ok("difft ./same (blob \"same\") (blob \"same\")").trim().is_empty());
95+}
96+
97+#[test]
98+fn an_unexecutable_renderer_reports_its_error_without_falling_back() {
99+ let fixture = Fixture::new();
100+ let executable = fixture.bin.join("difft");
101+ std::fs::write(&executable, "#!/bin/sh\nexit 0\n").unwrap();
102+ std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o644)).unwrap();
103+ std::fs::write(fixture.repo.join("file.txt"), "new\n").unwrap();
104+ let out = fixture.run("review");
105+ assert_eq!(out.status.code(), Some(1));
106+ assert!(out.stdout.is_empty());
107+ let error = String::from_utf8(out.stderr).unwrap();
108+ assert!(error.contains("cannot execute `difft`"), "{error}");
109+ assert!(error.contains("Permission denied"), "{error}");
110+}
tests/setup.rsnew file+144
@@ -0,0 +1,144 @@
1+//! First-run configuration must be complete, safe to repeat, and independent
2+//! of repository state.
3+
4+use std::path::PathBuf;
5+use std::process::{Command, Output, Stdio};
6+
7+struct SetupCli {
8+ dir: tempfile::TempDir,
9+ config: tempfile::TempDir,
10+}
11+
12+impl SetupCli {
13+ fn new() -> Self {
14+ Self { dir: tempfile::tempdir().unwrap(), config: tempfile::tempdir().unwrap() }
15+ }
16+
17+ fn path(&self) -> PathBuf { self.config.path().join("j/config.j") }
18+
19+ fn command(&self, args: &[&str]) -> Command {
20+ let mut command = Command::new(env!("CARGO_BIN_EXE_j"));
21+ command.args(args).current_dir(self.dir.path()).env("XDG_CONFIG_HOME", self.config.path())
22+ .env("NO_COLOR", "1").stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
23+ command
24+ }
25+
26+ fn run(&self, args: &[&str]) -> Output { self.command(args).output().unwrap() }
27+}
28+
29+fn success(output: Output) -> String {
30+ assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
31+ assert!(output.stderr.is_empty(), "{}", String::from_utf8_lossy(&output.stderr));
32+ String::from_utf8(output.stdout).unwrap()
33+}
34+
35+#[test]
36+fn setup_with_identity_creates_a_complete_usable_config_without_a_repo() {
37+ let cli = SetupCli::new();
38+ let output = success(cli.run(&["setup", "Ada Lovelace", "ada@example.org"]));
39+ assert!(output.contains("Configuration ready"));
40+ assert!(output.contains("j init"));
41+ assert!(!cli.dir.path().join(".jj").exists());
42+ let source = std::fs::read_to_string(cli.path()).unwrap();
43+ let cfg = j::config::load_config(&source).unwrap();
44+ assert!(cfg.defs.iter().any(|(name, _)| name == "new"));
45+ assert!(cfg.defs.iter().any(|(name, _)| name == "save"));
46+ assert_eq!(j::config::eval_user_only(&mut j::eval::Interp::dummy(), &cfg).unwrap(), ("Ada Lovelace".into(), "ada@example.org".into()));
47+ success(cli.run(&["init"]));
48+ success(cli.run(&["status"]));
49+
50+ #[cfg(unix)] {
51+ use std::os::unix::fs::PermissionsExt;
52+ assert_eq!(std::fs::metadata(cli.path()).unwrap().permissions().mode() & 0o777, 0o600);
53+ }
54+}
55+
56+#[test]
57+fn bare_setup_creates_the_entire_default_and_explains_the_identity_edit() {
58+ let cli = SetupCli::new();
59+ let output = success(cli.run(&["setup"]));
60+ assert!(output.contains("user = { name ="));
61+ assert!(output.contains("Keep the rest of the file"));
62+ assert_eq!(std::fs::read_to_string(cli.path()).unwrap(), include_str!("../config.j"));
63+ assert_eq!(std::fs::read_dir(cli.dir.path()).unwrap().count(), 0);
64+ let output = cli.run(&["init"]);
65+ assert_eq!(output.status.code(), Some(3));
66+ assert!(String::from_utf8_lossy(&output.stderr).contains("identity is not configured"));
67+}
68+
69+#[test]
70+fn identity_strings_round_trip_quotes_backslashes_unicode_and_controls() {
71+ let cli = SetupCli::new();
72+ let name = "Ада \"Lovelace\" \\ Test\nSecond line\t";
73+ let email = "ada+\"quote\"\\suffix@example.org";
74+ success(cli.run(&["setup", name, email]));
75+ let cfg = j::config::load_config(&std::fs::read_to_string(cli.path()).unwrap()).unwrap();
76+ assert_eq!(j::config::eval_user_only(&mut j::eval::Interp::dummy(), &cfg).unwrap(), (name.into(), email.into()));
77+}
78+
79+#[test]
80+fn existing_custom_or_broken_configs_are_never_replaced() {
81+ let cli = SetupCli::new();
82+ success(cli.run(&["setup", "Existing Person", "existing@example.org"]));
83+ let source = format!("{}\nmyCommand = \\repo -> repo\n", std::fs::read_to_string(cli.path()).unwrap());
84+ std::fs::write(cli.path(), &source).unwrap();
85+ let output = success(cli.run(&["setup"]));
86+ assert!(output.contains("already exists") && output.contains("ready"));
87+ assert!(output.contains("Existing Person"));
88+ let output = cli.run(&["setup", "Replacement", "replacement@example.org"]);
89+ assert_eq!(output.status.code(), Some(2));
90+ assert!(String::from_utf8_lossy(&output.stderr).contains("was not changed"));
91+ assert_eq!(std::fs::read_to_string(cli.path()).unwrap(), source);
92+
93+ std::fs::write(cli.path(), "broken =\n").unwrap();
94+ let output = success(cli.run(&["setup"]));
95+ assert!(output.contains("j help config"));
96+ assert!(output.contains("line 1:") && output.contains("unexpected"), "{}", output);
97+ assert_eq!(std::fs::read_to_string(cli.path()).unwrap(), "broken =\n");
98+}
99+
100+#[test]
101+fn usage_and_invalid_identities_do_not_create_files() {
102+ for args in [
103+ vec!["setup", "one"],
104+ vec!["setup", "one", "two", "three"],
105+ vec!["setup", "", "ada@example.org"],
106+ vec!["setup", "Ada", " "],
107+ vec!["setup", "Your Name", "you@example.com"],
108+ ] {
109+ let cli = SetupCli::new();
110+ let output = cli.run(&args);
111+ assert!(!output.status.success(), "{:?}", args);
112+ assert!(!cli.path().exists());
113+ assert_eq!(std::fs::read_dir(cli.config.path()).unwrap().count(), 0);
114+ }
115+}
116+
117+#[test]
118+fn concurrent_setup_publishes_one_complete_config_without_overwriting() {
119+ let cli = SetupCli::new();
120+ let first = cli.command(&["setup", "First Person", "first@example.org"]).spawn().unwrap();
121+ let second = cli.command(&["setup", "Second Person", "second@example.org"]).spawn().unwrap();
122+ let first = first.wait_with_output().unwrap();
123+ let second = second.wait_with_output().unwrap();
124+ assert_ne!(first.status.success(), second.status.success());
125+ let cfg = j::config::load_config(&std::fs::read_to_string(cli.path()).unwrap()).unwrap();
126+ let identity = j::config::eval_user_only(&mut j::eval::Interp::dummy(), &cfg).unwrap();
127+ assert!(identity == ("First Person".into(), "first@example.org".into())
128+ || identity == ("Second Person".into(), "second@example.org".into()));
129+ assert_eq!(std::fs::read_dir(cli.path().parent().unwrap()).unwrap().count(), 1);
130+}
131+
132+#[test]
133+#[cfg(unix)]
134+fn setup_does_not_follow_existing_symlinks_to_overwrite_files() {
135+ let cli = SetupCli::new();
136+ let target = cli.config.path().join("personal-config");
137+ std::fs::write(&target, "my private config").unwrap();
138+ std::fs::create_dir_all(cli.path().parent().unwrap()).unwrap();
139+ std::os::unix::fs::symlink(&target, cli.path()).unwrap();
140+ let output = cli.run(&["setup", "Ada", "ada@example.org"]);
141+ assert_eq!(output.status.code(), Some(2));
142+ assert_eq!(std::fs::read_to_string(&target).unwrap(), "my private config");
143+ assert!(std::fs::symlink_metadata(cli.path()).unwrap().file_type().is_symlink());
144+}
tests/terminal.rs+172−2
@@ -1,5 +1,5 @@
1−//! Interactive presentation uses real terminal descriptors; pipes and opt-outs
2−//! must keep the CLI suitable for scripts (§1.4).
1+//! Interactive presentation uses real terminal descriptors; pipes stay quiet,
2+//! and color opt-outs preserve useful plain confirmations (§1.4–1.5).
33
44#![cfg(unix)]
55
@@ -431,6 +431,176 @@ fn terminal_motion_respects_environment_opt_outs() {
431431 }
432432}
433433
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+
434604#[test]
435605fn redirecting_either_output_stream_disables_motion() {
436606 let fixture = Fixture::new();
tests/tree.rs+79−10
@@ -1124,7 +1124,7 @@ fn a_run_count_runs_on_into_the_id_column() {
11241124 let ctx = format!("lanes {}\n{}", lanes, text);
11251125 assert_eq!(text.lines().next(), Some(" ╎ 123"), "{}", ctx);
11261126 // the rest of the count fits the id column as it is
1127− assert_eq!(id_cols(&text), vec![2 + 2 * lanes + 1; WORKED_ROWS.len()], "{}", ctx);
1127+ assert_eq!(id_cols(&text), vec![2 + 2 * lanes + 1 + if lanes < 3 { 4 } else { 0 }; WORKED_ROWS.len()], "{}", ctx);
11281128 assert!(row_of(&text, "@aaaa").contains("@aaaa ▅ add parser"), "{}", ctx);
11291129 }
11301130 });
@@ -1165,7 +1165,7 @@ fn an_icons_glyph_fills_both_columns_of_its_lane() {
11651165 let plain = tree_text(&mut i, &cfg, &src(false), repo.clone());
11661166 let icons = tree_text(&mut i, &cfg, &src(true), repo);
11671167 assert_eq!(id_cols(&icons), id_cols(&plain), "lanes {}\n{}\n{}", lanes, plain, icons);
1168− assert_eq!(id_cols(&icons), vec![2 + 2 * lanes + 1; WORKED_ROWS.len()], "lanes {}\n{}", lanes, icons);
1168+ assert_eq!(id_cols(&icons), vec![2 + 2 * lanes + 1 + if lanes < 3 { 4 } else { 0 }; WORKED_ROWS.len()], "lanes {}\n{}", lanes, icons);
11691169 }
11701170}
11711171
@@ -1408,17 +1408,17 @@ fn a_flattened_last_child_of_the_trunk_head_ends_lane_0() {
14081408 let want = [
14091409 (1, shapes(&[
14101410 ("◆", "@khhh head main"),
1411− ("│", "@kxxx older"),
1412− ("│", "@kyyy newer"),
1413− ("»", "@kvvv on-older"),
1414− ("»", "@kwww on-newer"),
1411+ ("│", "» ○ @kxxx older"),
1412+ ("│", "» ○ @kyyy newer"),
1413+ ("", "» ○ @kvvv on-older"),
1414+ ("", "» ○ @kwww on-newer"),
14151415 ])),
14161416 (2, shapes(&[
14171417 ("◆", "@khhh head main"),
14181418 ("├─○", "@kxxx older"),
1419− ("│ │", "@kyyy newer"),
1419+ ("│ │", "» ○ @kyyy newer"),
14201420 (" ○", "@kvvv on-older"),
1421− (" »", "@kwww on-newer"),
1421+ ("", "» ○ @kwww on-newer"),
14221422 ])),
14231423 ];
14241424 for (lanes, want) in want {
@@ -1447,7 +1447,7 @@ fn the_worked_example_ends_lane_0_at_ptlm_on_fewer_lanes() {
14471447 // `wqzt`'s) and at lanes = 1, and lane 0 went on past it to the bottom.
14481448 // It ends on `ptlm`'s row, "and then empty", as at lanes = 3.
14491449 let want: [(usize, [&str; 9]); 2] = [
1450− (1, [" ╎", " ◆", " ◆", " │", "▶ │", " │", " │", " »", " »"]),
1450+ (1, [" ╎", " ◆", " ◆", " │", "▶ │", " │", " │", "", ""]),
14511451 (2, [" ╎ 14", " ◆─╮", " ◆ │", " │ ○", "▶ ├─⊗", " │ │", " │ │", " │", " ○"]),
14521452 ];
14531453 for (lanes, want) in want {
@@ -1513,7 +1513,7 @@ fn a_run_count_never_covers_a_rail() {
15131513 assert_eq!(lines[2], run, "lanes {}\n{}", lanes, text);
15141514 // the id column is where it is without the count
15151515 let id = col_of(row_of(&text, "@kggg"), "@kggg");
1516− assert_eq!(id, 2 + 2 * lanes + 1, "lanes {}\n{}", lanes, text);
1516+ assert_eq!(id, 2 + 2 * lanes + 1 + if lanes <= 3 { 4 } else { 0 }, "lanes {}\n{}", lanes, text);
15171517 }
15181518}
15191519
@@ -1694,3 +1694,72 @@ fn truncation_survives_mixed_scripts() {
16941694 }
16951695 }
16961696}
1697+
1698+
1699+#[test]
1700+fn overflow_preserves_state_even_when_every_rail_is_occupied() {
1701+ // At two lanes the worked example's empty ptlm overflows while the
1702+ // conflict's branch is still live. Neither state nor rails may vanish.
1703+ for icons in [false, true] {
1704+ let (repo, be) = worked_example();
1705+ let (mut i, cfg) = make_interp(be);
1706+ let text = tree_text(&mut i, &cfg, &format!("treeWith ({})", lanes_opts(2, icons)), repo);
1707+ let row = row_of(&text, "@ptlm");
1708+ assert!(row.contains(if icons { "» 🫙" } else { "» ◌" }), "{text}");
1709+ assert_eq!(upto_col(row, 6), " │ │ ", "{text}");
1710+ assert!(text.contains("» branch exceeds lane limit; increase lanes"), "{text}");
1711+ assert!(id_cols(&text).iter().all(|&column| column == 11), "{text}");
1712+ }
1713+ // At one lane the conflicting focus itself overflows; it must retain
1714+ // both the monochrome conflict glyph and the focus gutter.
1715+ let (repo, be) = worked_example();
1716+ let (mut i, cfg) = make_interp(be);
1717+ let text = tree_text(&mut i, &cfg, &format!("treeWith ({})", lanes_opts(1, false)), repo);
1718+ let row = row_of(&text, "@wqzt");
1719+ assert!(row.starts_with("▶ │ ") && row.contains("» ⊗"), "{text}");
1720+}
1721+
1722+#[test]
1723+fn terminal_detail_paths_wrap_without_losing_paths_or_live_rails() {
1724+ let root = commit(ROOT_ID, "", &[], vec![]);
1725+ let paths = ["src/123456789012 long_filename.rs", "src/第二个很长的文件名.rs"];
1726+ let focus = commit("kaaaaaaa", "focus", &[], paths.iter().map(|&p| (p, "x")).collect());
1727+ let child = commit("kbbbbbbb", "child", &[], paths.iter().map(|&p| (p, "x")).collect());
1728+ let repo = repo_of(root, vec![subtree(focus, vec![subtree(child, vec![])])], Some(0));
1729+ let (mut i, cfg) = make_interp(backend_with(vec![
1730+ meta(ROOT_ID, "R", 1), meta("kaaaaaaa", "A", 2), meta("kbbbbbbb", "A", 3),
1731+ ]));
1732+ let options = ONE_ROW_OPTS.replace("detail = 1", "detail = 2");
1733+ let rows = rows_on_terminal(&mut i, &cfg, &options, &repo, 32);
1734+ let start = rows.iter().position(|line| line.contains("@kaaa")).unwrap() + 1;
1735+ let end = rows.iter().position(|line| line.contains("@kbbb")).unwrap();
1736+ let detail = &rows[start..end];
1737+ assert!(detail.len() > 2, "{rows:?}");
1738+ assert!(detail.iter().all(|line| j::render::width(line) <= 32), "{rows:?}");
1739+ let prefix = upto_col(&detail[0], 13);
1740+ assert!(prefix.contains('│'), "{rows:?}");
1741+ assert!(detail.iter().all(|line| upto_col(line, 13) == prefix), "{rows:?}");
1742+ let contents: String = detail.iter().map(|line| between_cols(line, 13, usize::MAX)).collect();
1743+ for path in paths { assert!(contents.contains(path), "{contents}"); }
1744+ let pipe = tree_text(&mut i, &cfg, &format!("treeWith ({options})"), repo);
1745+ assert!(pipe.lines().any(|line| paths.iter().all(|path| line.contains(path))), "{pipe}");
1746+}
1747+
1748+
1749+#[test]
1750+fn default_four_lanes_explain_overflow_without_hiding_empty_state() {
1751+ let mut branch = subtree(commit("kzaaaaaa", "end", &[], vec![]), vec![]);
1752+ for depth in (0..6).rev() {
1753+ let letter = (b'a' + depth) as char;
1754+ branch = subtree(
1755+ commit(&format!("k{letter}aaaaaa"), &format!("level {depth}"), &[], vec![]),
1756+ vec![branch, subtree(commit(&format!("l{letter}aaaaaa"), "sibling", &[], vec![]), vec![])],
1757+ );
1758+ }
1759+ let repo = repo_of(commit(ROOT_ID, "", &[], vec![]), vec![branch], None);
1760+ let (mut i, cfg) = make_interp(MemBackend::new());
1761+ let text = tree_text(&mut i, &cfg, "tree", repo);
1762+ let row = row_of(&text, "@keaa");
1763+ assert!(row.starts_with(" │ │ │ │ » ◌"), "{text}");
1764+ assert!(text.contains("» branch exceeds lane limit; increase lanes"), "{text}");
1765+}
tests/ux_cli.rsnew file+105
@@ -0,0 +1,105 @@
1+//! User-facing invocation and diagnostic regressions use isolated repositories.
2+
3+use std::path::Path;
4+use std::process::{Command, Output};
5+
6+struct Fixture {
7+ root: tempfile::TempDir,
8+}
9+
10+impl Fixture {
11+ fn new() -> Self {
12+ let root = tempfile::tempdir().unwrap();
13+ std::fs::create_dir(root.path().join("repo")).unwrap();
14+ std::fs::create_dir_all(root.path().join("config/j")).unwrap();
15+ let source = include_str!("../config.j")
16+ .replace("Your Name", "UX Test")
17+ .replace("you@example.com", "ux@example.com");
18+ std::fs::write(root.path().join("config/j/config.j"), source).unwrap();
19+ Self { root }
20+ }
21+
22+ fn run_in(&self, cwd: &Path, args: &[&str]) -> Output {
23+ Command::new(env!("CARGO_BIN_EXE_j"))
24+ .args(args)
25+ .current_dir(cwd)
26+ .env("XDG_CONFIG_HOME", self.root.path().join("config"))
27+ .env("NO_COLOR", "1")
28+ .output().unwrap()
29+ }
30+
31+ fn run(&self, args: &[&str]) -> Output {
32+ self.run_in(&self.root.path().join("repo"), args)
33+ }
34+}
35+
36+fn success(output: Output) -> Output {
37+ assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
38+ output
39+}
40+
41+#[test]
42+fn shell_quoted_clone_and_remote_paths_keep_their_spaces() {
43+ let f = Fixture::new();
44+ let remote = f.root.path().join("source remote.git");
45+ success(Command::new("git").args(["init", "--bare", "-q"]).arg(&remote).output().unwrap());
46+ success(f.run_in(f.root.path(), &["clone", remote.to_str().unwrap(), "clone with spaces"]));
47+ let clone = f.root.path().join("clone with spaces");
48+ assert!(clone.join(".jj").is_dir());
49+ success(f.run_in(&clone, &["status"]));
50+
51+ let other = f.root.path().join("different remote.git");
52+ success(Command::new("git").args(["init", "--bare", "-q"]).arg(&other).output().unwrap());
53+ success(f.run_in(&clone, &["remote", other.to_str().unwrap()]));
54+ let url = success(Command::new("git").arg("-C").arg(&clone)
55+ .args(["config", "--get", "remote.origin.url"]).output().unwrap());
56+ assert_eq!(String::from_utf8(url.stdout).unwrap().trim(), other.to_str().unwrap());
57+ success(f.run_in(&clone, &["fetch"]));
58+}
59+
60+#[test]
61+fn expression_diagnostics_offer_usable_next_steps_without_editing() {
62+ let f = Fixture::new();
63+ success(f.run(&["init"]));
64+ let head_dir = f.root.path().join("repo/.jj/repo/op_heads/heads");
65+ let heads = || {
66+ let mut ids: Vec<_> = std::fs::read_dir(&head_dir).unwrap()
67+ .map(|e| e.unwrap().file_name()).collect();
68+ ids.sort();
69+ ids
70+ };
71+ let before = heads();
72+ for (args, expected) in [
73+ (vec!["describe", "hello"], "j 'describe \"hello\"'"),
74+ (vec!["neww"], "new"),
75+ (vec!["describe"], "j help describe"),
76+ (vec!["rebase"], "j help rebase"),
77+ ] {
78+ let output = f.run(&args);
79+ assert_eq!(output.status.code(), Some(1));
80+ assert!(output.stdout.is_empty());
81+ let error = String::from_utf8_lossy(&output.stderr);
82+ assert!(error.contains(expected), "{args:?}: {error}");
83+ }
84+ assert_eq!(heads(), before);
85+
86+ let output = f.run(&["describe (\n"]);
87+ assert_eq!(output.status.code(), Some(3));
88+ let error = String::from_utf8_lossy(&output.stderr);
89+ assert!(error.contains("expression"), "{error}");
90+ assert!(error.contains("line"), "{error}");
91+}
92+
93+#[test]
94+fn broken_configuration_names_the_exact_file_and_recovery_help() {
95+ let f = Fixture::new();
96+ let path = f.root.path().join("config/j/config.j");
97+ std::fs::write(&path, "broken =\n").unwrap();
98+ let output = f.run(&["status"]);
99+ assert_eq!(output.status.code(), Some(3));
100+ let error = String::from_utf8_lossy(&output.stderr);
101+ assert!(error.contains(path.to_str().unwrap()), "{error}");
102+ assert!(error.contains("broken ="), "{error}");
103+ assert!(error.contains("j help config"), "{error}");
104+ assert_eq!(std::fs::read_to_string(path).unwrap(), "broken =\n");
105+}