In short: operations are commits for repo state. Each one stores a view ID, parent operation IDs and metadata. A tiny separate store, the op heads, lists which operations are current. Two processes that run concurrently both succeed, leaving two op heads. The next command notices, merges the two views three-way against their common ancestor, and records a merge operation.
1. Operation
| Field | Type | Meaning |
|---|---|---|
view_id | ViewId | The view this operation produced. |
parents | Vec<OperationId> | Usually one. More than one means this operation merged concurrent operations. Only the root operation has none. |
metadata | OperationMetadata | Who, when, what (§2). |
commit_predecessors | Option<BTreeMap<CommitId, Vec<CommitId>>> | For each commit this operation created, the commits it was rewritten from ([] for brand-new commits). Every intermediate rewrite in one transaction is kept: X rewritten to Y, then rebased to Z, records {Y: [X], Z: [Y]}. None for operations written before jj 0.30. Commits that merely became visible, such as some Git imports, aren't listed. |
The root operation (make_root) has
no parents, the root view, empty metadata at timestamp 0, and an empty predecessor map. In the simple
op store, both the root operation and the root view have all-zero 64-byte IDs. They're never written
to disk, only synthesized on read. Every other operation ID is a BLAKE2b hash of the operation's
content, as covered on Every schema.
In jj-lib code, operations are handled through operation::Operation,
which pairs the ID and data with a handle to the op store. It adds parents(),
view(), and predecessors_for_commit(), which is what jj evolog walks
(source).
2. OperationMetadata
| Field | Type | Set from |
|---|---|---|
time | TimestampRange { start, end } | Transaction start, and the moment it's written. Both can be pinned with debug.operation-timestamp. |
description | String | The string the command passes to finish, e.g. fixed 3 commits, snapshot working copy, undo: restore to operation …. |
hostname, username | String | Settings, overridable with operation.hostname / operation.username. |
is_snapshot | bool | True for operations that only recorded the working copy. |
workspace_name | Option<WorkspaceNameBuf> | The workspace the command ran in. |
attributes | BTreeMap<String, String> | Free-form tags. The CLI always sets args to the shell-quoted command line. |
OperationMetadata ·
create_op_metadata ·
where args comes from
3. Two stores: operations and op heads
Operations and views live in the OpStore
(read_view/write_view, read_operation/write_operation,
resolve_operation_id_prefix, gc). Writing an operation doesn't make it current.
That's the job of the OpHeadsStore, which only holds the
set of head operation IDs:
-
update_op_heads(old_ids, new_id)adds the new head and removes the old ones. The simple implementation adds first, then removes: each head is an empty file named by the operation's hex ID in.jj/repo/op_heads/heads/. A crash in between leaves an extra head, never zero (source). -
get_op_heads()lists the directory. An empty result is reported asCorrupt repository: no head operation(source). -
lock()takesop_heads/heads/lock. The trait documents the lock as an optimization, not a correctness requirement: it stops two processes merging the same divergent heads at once (source).
So a transaction writes view → operation → index, then publishes by replacing its parent op heads with the new operation (see How a command runs). Readers that loaded the repo earlier keep a consistent snapshot, because nothing they reference is ever modified.
4. Concurrent operations
jj never blocks one command on another. It detects concurrency afterwards.
resolve_op_heads runs whenever a command loads the repo
at head:
- One head: load it. This is the common case.
- Several heads: take the op-heads lock and re-read, since another process may have just finished merging them.
- Drop heads that are ancestors of other heads. If only one remains, make it the sole head without creating a merge.
-
Otherwise, sort the remaining heads by end time and call the resolver. In the CLI, that prints
Concurrent modification detected, resolving automatically.and callsRepoLoader::merge_operations. - The resulting operation replaces all the old heads.
merge_operations starts a transaction at the first head, then folds in each further
operation with Transaction::merge_operation(base, other).
The base is the closest common ancestor. If
there are several, jj first merges them recursively and caches the result. Each step is a three-way
view merge, followed by rebasing descendants of
anything either side rewrote. The merge operation's description is
reconcile divergent operations from the CLI, or merge N operations by default.
5. Naming operations
Commands that take an operation (--at-op, jj op restore, …) accept a small
expression language (resolve_single_op):
| Syntax | Meaning |
|---|---|
@ | The operation the repo is loaded at. |
<hex prefix> | Any unambiguous prefix of an operation ID. Errors for no match, an ambiguous prefix, or invalid hex. |
X- | X's parent. Repeat for older ones (@--). Errors if X has no parent or several. |
X+ | X's child, found by walking down from the current heads. Errors if there isn't exactly one. |
6. Undo, redo, restore and revert
None of these delete anything. Each writes a new operation whose view is computed from older ones:
| Command | New view | Description recorded |
|---|---|---|
jj op restore X | X's view (see What gets restored). | restore to operation <X> |
jj op revert X |
The current view with X's change inverted. jj loads the repos at X and at X's parent, then does a three-way merge into the current repo with X as the base and X's parent as the "other" side. Fails for the root operation or a merge operation. | revert operation <X> |
jj undo |
Restores the current operation's parent. Repeating it walks further back: if the operation
being undone is itself an undo, jj targets what that undo restored, and it skips over
undo operations when picking the parent, so undo history never becomes a linked list. It warns
before undoing a git push or a workspace removal, and refuses root and merge
operations (suggesting jj op restore instead). It also refuses an operation
recorded in a different workspace unless you pass --allow-cross-workspace
(source).
|
undo: restore to operation <parent> |
jj redo | Undoes the most recent undo, like redo in a text editor. It errors with Nothing to redo unless the current operation is an undo or a redo. Like undo, it refuses an undo that was recorded in a different workspace unless you pass --allow-cross-workspace (source). | redo: restore to operation <…> |
cmd_undo ·
cmd_redo ·
cmd_op_restore ·
cmd_op_revert
undo finds the operation it restored by parsing its own description prefix,
undo: restore to operation . That's why the description is part of how undo works,
not just a label.
What gets restored
Restore, revert and undo all go through
view_with_desired_portions_restored,
which builds the new view from two sources, controlled by the experimental --what flag on
restore and revert. Both parts are restored by default, and jj undo always restores both:
repo: heads, local bookmarks and tags, working-copy commits, and the rest of the jj state.remote-tracking: remote bookmarks and tags. Leaving these out keeps jj's record of the remotes current, which matters if you want to push after undoing.
7. Garbage collection
Old operations stay forever unless collected. SimpleOpStore::gc(head_ids, keep_newer)
walks every operation reachable from the given heads, keeps them and their views, and deletes any
other operation or view file whose mtime is older than keep_newer. The time check
protects objects a concurrent process may have just written. jj util gc calls it with the current
operation as the only head. --expire=now sets keep_newer to now; it's the only
value accepted. It then runs the commit backend's own GC, which does real work only for the Git backend.
The command's help suggests running jj op abandon ..<old operation> first, so old
history becomes unreachable (source).