jj / How jj evolog works

How jj evolog works

jj evolog shows the earlier commits a change has pointed to: every snapshot, describe, rebase or squash that replaced its commit. It reconstructs that history by walking the operation log, not the commit graph.

On this page
  1. 1. Command-line surface
  2. 2. Configuration
  3. 3. End-to-end flow
  4. 4. Core architecture touchpoints
  5. 5. What it reads, and snapshotting
  6. 6. Algorithm and invariants
  7. Walking the operation log
  8. Graph and list ordering
  9. The patch: comparing with the previous version
  10. 7. Errors and edge cases
  11. 8. Example output

In short: jj snapshots the working copy, resolves -r (default @) to commit IDs, and walks the operation log backwards from the current operation. Each operation records which commits it created or rewrote and from what. Whenever an operation has a record for a commit the walk is following, that commit is printed with the operation, and the walk follows its predecessors instead. It stops when nothing is left to follow, when the operation log runs out, or at an operation written by jj < 0.30, which recorded no predecessors. Entries are drawn as a graph (or a flat list), optionally with a diff against the previous version. No operation is recorded, apart from a snapshot's.

The command is cmd_evolog. The help text: "Show how a change has evolved over time … Lists the previous commits which a change has pointed to. The current commit of a change evolves when the change is updated, rebased, etc." (source). The walk itself lives in jj-lib, in walk_predecessors.

1. Command-line surface

FlagTypeDefaultMeaning
-r, --revisions (alias --revision)revsets, repeatable@"Follow changes from these revisions". All revsets are unioned and walked together (source, source).
-n, --limitnumbernone"Applied after revisions are reordered topologically, but before being reversed" (source). With --no-graph there is no reordering, so it cuts the walk order.
--reversedflagoffOldest first (source).
-G, --no-graphflagoffA flat list instead of a graph (source).
-T, --templatetemplatetemplates.evologRendered once per entry. The keywords are the methods of CommitEvolutionEntry, not Commit (source).
-p, --patchflagoff"Show patch compared to the previous version of this change". A previous version with other parents "will be temporarily rebased to the parents of the new version" (source).
--summary, --stat, --git, … (DiffFormatArgs)flagsnoneAny diff format also turns the diff on, with or without -p (source, source). The formats are on the jj diff page.

Global flags such as --at-op and --ignore-working-copy are on the CLI page. --at-op also moves where the walk starts, since it begins at the loaded operation.

2. Configuration

KeyDefaultEffect
templates.evologbuiltin_evolog_compactThe entry template when -T is absent (source, source). The built-in is builtin_log_compact(commit) plus a "-- operation <id> <description>" line when the entry has an operation (source).
templates.log_nodeas for jj logThe node symbol; there is no separate evolog setting yet (source, source).
ui.graph.stylecurvedThe graph's line style (source, source).
ui.log-word-wrapfalseWrap each entry to the width beside the graph (source, source).
ui.diff-formatter:color-wordsThe format -p uses when no other long format is given (source, source).
ui.pager, ui.paginateless -FRXK, autoOutput goes through the pager (source, source).

These are the same settings jj log uses for its graph. experimental.record-predecessors-in-commit does not affect evolog: the walk reads only the operations' records, never the commits' own predecessor field (source; see commits).

3. End-to-end flow

jj evolog: call flow After the shared lifecycle, cmd_evolog loads the workspace, which snapshots it, and resolves the revsets to start commit IDs. It sets up the diff renderer, graph style, content format and the two templates, and requests the pager. walk_predecessors walks the operation log from the current operation and yields entries of a commit and an optional operation. In graph mode the entries get edges to their predecessors, are grouped topologically, cut to the limit and optionally reversed; with --no-graph they are cut to the limit in walk order and optionally reversed. Each entry is rendered with the template and, if a diff was asked for, a diff against its predecessors. Shared lifecycle: parse args, load config (CLI page) workspace_helper snapshots the working copy parse_union_revsets → start IDs -r, default @ diff_renderer_for_log; graph style; content format; templates; pager templates.evolog for entries, templates.log_node for nodes walk_predecessors: walk the operation log back from the current operation yields CommitEvolutionEntry { commit, operation } graph (default) edges to predecessors → TopoGroupedGraph → limit → reverse_graph if --reversed --no-graph limit in walk order → reverse the list if --reversed per entry: template, then show_inter_diff if a diff was asked for predecessors rebased onto the entry's parents, then diffed

4. Core architecture touchpoints

StructureRead / writeHowExplained on
Operation logreadWalked from the current operation in reverse topological order, loading parents lazily by end time (source). Each operation's commit_predecessors map is the only source of history (source).operations
Op storereadThe map is stored per operation: commit_predecessors and the stores_commit_predecessors flag (source).protobufs
CommitsreadEvery entry's commit is loaded by ID; most are hidden, which is why they print as xyz/1, xyz/2 and "(hidden)".commit index
Revset enginereadOnly to resolve -r to start IDs.revsets
Treesread, with a diffPredecessor trees are rebased onto the entry's parents and diffed (source).trees
Working copyread (snapshot)The snapshot may record a snapshot working copy operation, which then shows as the newest entry for @.working copy

5. What it reads, and snapshotting

  • Snapshot: yes, through workspace_helper (source). With --ignore-working-copy, edits since the last snapshot are not in the history yet.
  • Where the history comes from: each transaction writes the map of commits it created or rewrote to their predecessors into its operation (source). Rewrites fill it through set_predecessors; a CommitBuilder made for a rewrite starts with the old commit as its predecessor (source, source, source). A new commit maps to an empty list.
  • What the map leaves out: "Existing commits (including some commits imported from Git) aren't tracked even if they became visible at this operation" (source). The map is kept even when a commit is rewritten several times in one transaction, as {Y: [X], Z: [Y]}.
  • Output: everything goes to stdout through the pager. No transaction is started, so no operation is recorded beyond the snapshot's.

6. Algorithm and invariants

Walking the operation log

The walk keeps a list of commit IDs still to visit, starting with the -r commits, and a queue of entries ready to yield (source). While the list is not empty and the queue is, it takes the next operation (source):

  • For every listed ID that this operation has a record for, visit_op replaces the ID by its predecessors and marks it to be emitted. A newly created commit is replaced by nothing, so its line of history ends there. IDs without a record stay for older operations (source).
  • Each marked commit is queued with this operation. If one operation marked several, they are sorted newest first through the operation's own map, so {Y: [X], Z: [Y]} gives Z then Y; an ID reached twice in one operation is emitted once (source).
  • When the operation log runs out, or reaches an operation that "was written by jj < 0.30" and so has no map, every remaining ID is queued with no operation and the walk ends. Concurrent branches of the operation log beyond that point are not looked at: "Operation history should be mostly linear" (source, source).

The walk itself yields only commit IDs paired with their operations. walk_predecessors then loads each commit from the store, keeping up to the store's concurrency limit of loads in flight at once while still yielding entries in walk order (source).

How walk_predecessors follows a squashed change Upstream's reverse-with-graph test. A change was described a, b and c, two new commits d and e were made on c, then d and e were squashed into c as c+d+e. Starting from c+d+e and walking operations newest first: the squash records c+d+e from c, d and e, so c+d+e is emitted and c, d and e are followed. The new-commit operations record e and d with no predecessors, so each is emitted and dropped. The describe operations then lead from c to b to a to the original empty commit, which the add-workspace operation created, leaving nothing to follow. operation (newest first) its record emitted still to visit squash commits into c c+d+e ← c, d, e c+d+e c, d, e new empty commit (e) e ← nothing e c, d new empty commit (d) d ← nothing d c describe commit (b → c) c ← b c b describe commit (a → b) b ← a b a describe commit (empty → a) a ← empty a empty add workspace 'default' empty ← nothing empty nothing: stop graph: c+d+e has edges to c, d, e; d and e are leaves, c continues down to the empty commit

Graph and list ordering

In graph mode each entry gets a direct edge to each of its predecessor IDs (source), and the stream goes through TopoGroupedGraph, as in jj log. Upstream's comment gives the reason: the first predecessor is usually the previous version of the change and the others were squashed into it, and grouping shows "squashed commits in reverse chronological order" and "before the squash destination" (source). The limit is taken from the grouped stream; --reversed then collects it and runs reverse_graph (source). So --limit=3 --reversed shows the three newest entries, oldest first (test).

With --no-graph the walk order is used as is: limit, then reverse the list (source, test). Entries are printed as the walk yields them, so a long history starts printing before the walk reaches the end.

The patch: comparing with the previous version

diff_renderer_for_log returns a renderer only if -p or a diff format flag was given (source). For each entry, show_inter_diff compares its predecessors with its commit (source), the same routine jj interdiff uses:

  • The description is diffed as a pseudo-file, JJ-COMMIT-DESCRIPTION. With several predecessors their descriptions are merged into a conflict, which is why a squash shows conflict markers in that file (source, test).
  • The old tree is rebase_to_dest_parent of the predecessors onto the entry's parents. A single predecessor with the same parents is used as is; otherwise each predecessor's changes are merged onto the new parents' tree (source). A pure rebase therefore shows no diff, even one that produced a conflict; the later version that resolves it shows the resolution (test).
  • An entry with no predecessors (a new commit, or one cut off by the walk) is diffed against its own parents and an empty description.
  • Copy records are not tracked in this diff (source).

The template language has the same comparison as inter_diff() on a CommitEvolutionEntry, next to commit, operation and predecessors (source).

Invariants: the history comes only from the operation log, so it is exactly as long as the operation log that still holds it. Each emitted commit is printed with the operation that created or rewrote it, except entries cut off by the end of the walk, which have no operation. No operation is recorded, apart from a snapshot's.

7. Errors and edge cases

Message or behaviourWhenSource
Predecessors cycle detected around commit <id>Within one operation the map loops, so the commits it marked cannot be sorted.evolution.rs, evolution.rs
The oldest entry has no "operation" lineThe walk ran out: jj op abandon removed the older operations, or it reached operations from before jj 0.30. Its diff is then against its parents.test, jj op abandon
A single entry, with no "operation" lineThe commit has no record in any operation, for example one imported from Git. The whole operation log is walked first.op_store.rs, evolution.rs
error: a value is required for '--template <TEMPLATE>', with a hint listing template aliases-T without a value.test
A revset or template error-r or -T does not parse or resolve.evolog.rs
A working-copy errorThe snapshot fails, or the working copy is stale.working copy

8. Example output

From upstream's test_evolog_with_or_without_diff: a new commit was edited, rebased onto the root (which conflicted), and edited again to resolve it. The change ID stays rlvkpnrz; the older commits are hidden and get offsets:

$ jj evolog
@  rlvkpnrz test.user@example.com 2001-02-03 08:05:10 33c10ace
│  my description
│  -- operation b78459d9d126 snapshot working copy
×  rlvkpnrz/1 test.user@example.com 2001-02-03 08:05:09 cdf175ef (hidden) (conflict)
│  my description
│  -- operation b2a0c4f0aa5d rebase commit 51e08f95160c897080d035d330aead3ee6ed5588
○  rlvkpnrz/2 test.user@example.com 2001-02-03 08:05:09 51e08f95 (hidden)
│  my description
│  -- operation 8c72466647e6 snapshot working copy
○  rlvkpnrz/3 test.user@example.com 2001-02-03 08:05:08 b955b72e (hidden)
   (empty) my description
   -- operation 390d0e261e11 new empty commit

From test_evolog_abandoned_op, after jj op abandon ..@- removed every operation but the last. The second entry has no operation and its summary is against its parent, the empty root:

$ jj evolog --summary
@  qpvuntsm test.user@example.com 2001-02-03 08:05:09 e1869e5d
│  file2
│  -- operation 942cfd389e43 describe commit 32cabcfa05c604a36074d74ae59964e4e5eb18e9
○  qpvuntsm/1 test.user@example.com 2001-02-03 08:05:09 32cabcfa (hidden)
   file1
   A file1
   A file2