jj / The operation log

The operation log

Every change jj makes to a repo is recorded as an operation: an immutable record that points at the resulting view and at the operation(s) it came after. Together they form a DAG, the operation log. Undo, --at-op, and safe concurrent use of one repo by several processes all fall out of that structure.

On this page
  1. 1. Operation
  2. 2. OperationMetadata
  3. 3. Two stores: operations and op heads
  4. 4. Concurrent operations
  5. 5. Naming operations
  6. 6. Undo, redo, restore and revert
  7. What gets restored
  8. 7. Garbage collection

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

FieldTypeMeaning
view_idViewIdThe view this operation produced.
parentsVec<OperationId>Usually one. More than one means this operation merged concurrent operations. Only the root operation has none.
metadataOperationMetadataWho, when, what (§2).
commit_predecessorsOption<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.

op_store::Operation

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

FieldTypeSet from
timeTimestampRange { start, end }Transaction start, and the moment it's written. Both can be pinned with debug.operation-timestamp.
descriptionStringThe string the command passes to finish, e.g. fixed 3 commits, snapshot working copy, undo: restore to operation ….
hostname, usernameStringSettings, overridable with operation.hostname / operation.username.
is_snapshotboolTrue for operations that only recorded the working copy.
workspace_nameOption<WorkspaceNameBuf>The workspace the command ran in.
attributesBTreeMap<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 as Corrupt repository: no head operation (source).
  • lock() takes op_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

Two concurrent operations merged by the next command Operation O is the head. Process 1 writes operation A with parent O, and process 2 concurrently writes operation B, also with parent O. Both publish, so the op heads store lists A and B. The next command finds two heads, removes any that are ancestors of others, sorts the rest by end time, and merges them: it starts from A, merges B's view against the common ancestor O, rebases descendants, and writes merge operation M with parents A and B, described "reconcile divergent operations". M then replaces A and B as the single op head. O A process 1 B process 2 M "reconcile divergent operations" parents: A, B arrows point parent → child op heads before: {A, B} op heads after: {M} base for B's changes: O

jj never blocks one command on another. It detects concurrency afterwards. resolve_op_heads runs whenever a command loads the repo at head:

  1. One head: load it. This is the common case.
  2. Several heads: take the op-heads lock and re-read, since another process may have just finished merging them.
  3. Drop heads that are ancestors of other heads. If only one remains, make it the sole head without creating a merge.
  4. Otherwise, sort the remaining heads by end time and call the resolver. In the CLI, that prints Concurrent modification detected, resolving automatically. and calls RepoLoader::merge_operations.
  5. 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):

SyntaxMeaning
@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:

CommandNew viewDescription recorded
jj op restore XX'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 redoUndoes 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).