Skip to content

Latest commit

 

History

History
538 lines (396 loc) · 16.2 KB

tutorial.md

File metadata and controls

538 lines (396 loc) · 16.2 KB

A tutorial on j

This is a guided first hour with j. It builds the mental model piece by piece — not just what to type, but why it works — so that by the end you can compose your own commands instead of memorizing them.

You'll want j installed (nix build or cargo build --release). Set up your configuration with your own name and email:

j setup 'Ada Lovelace' ada@example.org

This creates the full reference configuration without overwriting an existing file. If you already have a config, j help config shows its location so you can edit the user definition. The remaining commands run in a real repository, so you can follow along.


Part 1 — Ten minutes to a working repository

Make an empty directory and create a repository:

mkdir play && cd play
j init

j init makes a colocated .jj/.git repository and checks out a fresh working-copy commit: the commit you are currently editing. It already exists, so write a file and describe that commit:

echo 'fn main() {}' > main.rs
j 'describe "start the project"'

Look at what you have:

j 'tree'
   ⌂ zzzz
▶ └─  ◉ pnyz ▂  start the project

Add a second change on top:

j 'new'
echo 'lib' > lib.rs
j 'describe "add a library"'
j 'tree'
   ⌂ zzzz
  └─  ● pnyz ▂  start the project
▶    └─  ◉ psnq ▂  add a library

That's the basic loop: edit files, describe the current change, then new before editing the next change. new opens an empty change with the same files as its parent; it does not move existing edits into that new change. There is no git add step. describe saves both the current files and their description; j save records file edits without changing the description.

What just happened, in the model: each expression snapshots your working directory for evaluation, builds a Repo value focused on that commit, evaluates your expression, and — because new and describe are edits (functions Repo -> Repo) — persists the resulting repository.

j status has two useful lists: in commit compares the current commit with its parent, while unrecorded lists file edits made since the last save. After describe or save, unrecorded is empty; in commit still shows the work in the commit. Inspecting status does not save anything.

To join an existing project instead, clone it and enter its directory:

j clone URL project
cd project
j status

Part 2 — The one idea everything rests on

Here is the single most important thing to understand about j:

If your expression evaluates to a function, that function is applied to the repository. There is no repo variable — the repository is the implicit argument.

new, describe, prev, squash are all functions Repo -> Repo (called edits). When you run j squash, you're not invoking a subcommand; you're evaluating the name squash to a function, which j then applies to your repository.

This is why composition works. . is ordinary function composition, (f . g) x = f (g x) — right to left:

j 'describe "wip" . new'

reads as "first new, then describe," because (describe "wip" . new) repo = describe "wip" (new repo). Run it and you've opened a new commit and set its message, in one expression, as one recorded operation.

And it's why inspections are just functions too. length . commits . top composes three functions into one Repo -> Int:

j 'length . commits . top'
    => 5
  • top moves the focus to the top of history,
  • commits lists every commit of the (sub)tree,
  • length counts them.

Since the result is an Int (not a Repo), j prints it and persists nothing. That's the rule: a Repo result persists; anything else prints.

Two outcomes. A run either persists (the result is a Repo that differs from the loaded one) or prints (any other value). Edits persist; inspections print.

A subtlety that trips people up

j 'show (length . commits . top)' does not print 5. It prints the function:

(.) ((length)) (((.) ((commits)) ((top))))

Why? show (...) applies show to the function value length . commits . top, producing a Text without ever applying it to the repository. To get the count, you want the composition itself applied: length . commits . top (with no show). The result is a function Repo -> Int, j applies it, and prints 5. show is for rendering a value; here you want the value the function produces, so don't wrap it.


Part 3 — Reading and moving around

Make a few commits to play with:

j new; echo a > a.txt; j 'describe "A"'
j new; echo b > b.txt; j 'describe "B"'
j new; echo c > c.txt; j 'describe "C"'

Navigation changes the working-copy commit and checks out its files. Use these commands to edit another mutable commit:

j 'prev'          -- to the parent
j 'next'          -- to the only child
j 'tip'           -- to the end of the current line
j 'by @vqok'      -- to a specific commit, by id (use a real id from j tree)

At a fork, choose an id from j tree with j 'by @id'; next only works when there is one child. Main and the root commit are protected from editing. Use j 'new . goto trunk' to start work on main, or j 'tree . goto trunk' to inspect it without moving your working copy. In a new local repository with no main bookmark, keep working from j init's commit instead.

After j 'prev' from C, the tree shows the focus (▶ and ◉) on B:

   ⌂ zzzz
  └─  ● qmwp ▂
     └─  ● nnvs ▂  A
▶       └─  ◉ vqok ▂  B
           └─  ◌ lttm  C

To see the focus's data, use the inspection functions:

j 'files'         -- the focus's files
j 'changed'       -- paths that differ from the parent
j 'status'        -- focus, changes in the commit, unrecorded edits, conflicts
j 'log'           -- a table of every commit, ✓ on the focus
j 'review'        -- the focus's changes as diffs (difftastic)

These inspections include current file edits without recording them. Use j save to record file edits on their own (id is the same edit). status spells out empty states such as No changes and No conflicts; log shows label names and short ids and adapts to narrow terminals.

Composition order, made concrete

j 'describe "edited two back" . prev'

prev runs first (right-to-left), then describe. From C, this moves to B and renames B. The focus ends on B:

   ⌂ zzzz
  └─  ● qmwp ▂
     └─  ● nnvs ▂  A
▶       └─  ◉ vqok ▂  edited two back
           └─  ◌ lttm  C

If you meant "rename C," that's just j 'describe "edited two back"' — no prev. The lesson: read . chains from right to left, and remember each step operates on the repository the previous step produced.


Part 4 — Dry runs: seeing before doing

Use preview to validate an edit and show its resulting tree without saving:

j 'preview squash'

From the A → B history, this shows the tree as squash would leave it (B folded into A, its message gone):

   ⌂ zzzz
  └─  ● krly ▂
     └─  ● xspk ▂  A
▶       └─  ◌ zkyl

But run j 'tree' right after and B is still there — nothing persisted, because tree returns a Text, not a Repo:

   ⌂ zzzz
  └─  ● krly ▂
     └─  ● xspk ▂  A
▶       └─  ◌ nknz  B

preview combines tree and validate. For an edit that takes arguments, put that edit in parentheses:

j 'preview (rebase %main)'

This renders the post-rebase tree and crashes (without touching anything) if that rebase couldn't actually happen — for instance, if %main doesn't exist yet. That's an honest dry run.

The longer form is j 'tree . validate . rebase %main'. validate alone does not make an edit a dry run: j 'validate . squash' returns a repository and saves it. The final inspection (tree) is what prevents persistence.


Part 5 — or: the whole error-handling model

There are no exceptions to declare and no Maybe. A failing operation crashes, and or catches it:

head [] or 42         => 42
edit or id            -- run edit; if it crashes, run id (do nothing)

id is the identity edit, so edit or id means "try the edit, keep the repository if it fails."

The reason this works for edits is lifting. A function value can't crash until it's applied, so squash or id would catch nothing if or simply returned the function. Instead, when both sides are functions, or builds a new one pointwise: (f or g) x = f x or g x. So:

j 'squash or id'

actually runs squash on your repository, and only if that crashes does it fall back to id. Try it on the A → B history: B gets folded into A, no crash, so id never runs.

A practical use: "rebase onto main if there is a main, otherwise leave things alone":

j 'rebase %main or id'

Part 6 — Editing history for real

Now that you can navigate, dry-run, and recover, the editing commands are just edits with clear semantics:

j 'describe "…"'          -- set the focus's message
j squash                  -- fold the focus into its parent
j abandon                 -- drop the focus; children join the parent
j 'split (ext "rs")'      -- push the .rs files up into a new child commit
j 'contract everything'   -- push all of the focus's files into the parent
j 'rebase %main'          -- move the focused subtree onto main
j 'pick @wqzt'            -- copy @wqzt's change here as a new commit
j 'backout @wqzt'         -- a new commit undoing @wqzt's change

Two things make these safe to use freely:

  1. The change id survives. A commit keeps its @wqzt… id across describe, squash, rebase. You're never tracking a moving sha.
  2. Saved operations are undoable. Use j undo and j redo to walk them.
j undo            -- revert the last thing
j redo            -- reapply it
j ops             -- see the operation log

Undo and redo stop when files have unrecorded edits. Recording those edits with j id adds an operation, so the next undo removes that recording; another undo reaches the preceding edit. Recording also clears pending redos. Read j ops to choose how far to go back. Successful interactive undo/redo messages identify the affected operation and restored focus.

Resolve a conflict

A successful rebase can leave conflicts in the working-copy commit. The edit is saved; there is no separate continuation command. Resolve it in four steps:

  1. Run j status to find conflicted paths, and j review to inspect changes.
  2. Edit those files, choosing the intended content and removing conflict markers.
  3. Run j save to record the resolution.
  4. Run j status again and check for No conflicts and All edits recorded.

j conflicts lists conflicted commits throughout the repository. If a conflict is on another mutable commit, use its id from j tree with j 'by @id', then follow the same steps. Run j conflicts again to check the whole history. review uses difftastic when installed and falls back to a unified diff when it is unavailable.

split and contract: moving changes by fileset

A fileset is a function Path -> Bool. These two are inverses:

  • split m pushes the files matching m up into a new child commit.
  • contract m pushes the files matching m down into the parent.

Suppose one commit touches a.rs and b.j, and you want the Rust change on its own:

j 'split (ext "rs")'

The .rs change becomes a separate child commit (now the focus); the .j change stays in the original. squash is defined in terms of these: squash = abandon . contract everything.

Filesets compose: under ./src, ext "rs", both, either, neg, everything. So split (both (under ./src) (ext "rs")) splits off only the Rust files under src/.


Part 7 — Revsets: naming sets of commits

A revset is a function Repo -> [Id] — a set of commits computed from the repository. You've been using them: parents, kids, %main.

here        -- the focused commit
parents     -- its parents
kids        -- its children
ancestors   -- it and everything above
descendants -- it and everything below
all         -- every visible commit
trunk       -- the main line (%main, %master, or %trunk)

Most commands that take a "which commits" argument take a revset. goto is the one place a revset must name exactly one commit:

j 'new . goto %main'  -- select one main commit and start a mutable child

A missing label reports how to fetch and inspect remote labels. An ambiguous selector lists candidate ids and descriptions so you can choose with by.

Revsets combine like sets:

j 'matching (\c -> c.message == "wip") all'

matching p rs keeps the commits of rs satisfying predicate p — here, every commit whose message is wip. The result is a list of commits, so it prints (persisting nothing).


Part 8 — Batch operations with at and forEach

at rs e runs edit e at the commit revset rs names, then returns the focus to where it was (by id, so e may move things):

j 'at parents (describe "parent edit")'

This renamed the parent while leaving you focused where you were — the tree shows the parent with its new message and the focus marker still on your original commit.

forEach rs e runs e at every commit of rs:

j 'forEach descendants (describe "wip")'

Every commit below the focus is now described wip. forEach is not special — it's an ordinary function Revset -> Edit -> Edit defined in config.j, and you could have written it yourself.


Part 9 — Making it yours: writing a command

Everything j knows is defined in config.j. Any definition whose value is an Edit is a new command. Add this to your ~/.config/j/config.j:

-- squash into the parent and name the combined commit "release"
release : Edit
release = describe "release" . prev . squash

Now j release works exactly like squash or new. The signature (: Edit) is optional but recommended — it makes j check the definition's arguments and result at each call.

A revset example — "commits ahead of trunk":

ahead : Revset
ahead = minus descendants trunk

Then j 'length . ahead' prints how many commits are on your branch but not the main line.

This is the payoff of the whole design: the vocabulary is not a fixed list of subcommands. It's functions, in a file you can read and extend. squash, tree, status, rebase are definitions, not builtins.


Part 10 — A realistic session, start to finish

Putting it all together. Start a feature on top of main, commit in two parts, tidy the history, and push.

This session assumes you have a remote with a main bookmark (so trunk resolves to something) — for example after j clone, or j remote URL followed by j fetch. goto trunk crashes if there's no main line yet; new . goto trunk is how you start on top of it (trunk is immutable, so you always open a new commit rather than focusing it directly).

# start on the main line
j 'new . goto trunk'

# first part
echo 'fn parse() {}' > parser.rs
j 'describe "add the parser"'

# second part
j 'new'
echo 'mod parser;' > lib.rs
j 'describe "wire up the parser"'

# look at it
j 'tree'

# the second commit was really part of the first; fold it in
j squash

# squash leaves an empty child focused; move to the combined commit
j prev

# rename the combined commit
j 'describe "add the parser"'

# preview the rebase, then do it
j 'preview (rebase %main)'
j 'rebase %main'

# share it
j 'push (label "parser" here)'

Push sends recorded commits. If you edit files after the last saved edit, push warns that those edits are omitted. Run j save, then push again to include them. Looking at j status does not save the edits.

And if any step goes wrong:

j undo

Where next

  • docs/language.md — the language in full: values, syntax, semantics, contracts, show.
  • docs/base.md — every builtin and config.j definition, with examples and an alphabetical index.
  • docs/comparison.md — how j maps onto git and jj, if you're coming from either.
  • config.j — the reference vocabulary, to read and imitate.