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
| Flag | Type | Default | Meaning |
|---|---|---|---|
-r, --revisions (alias --revision) | revsets, repeatable | @ | "Follow changes from these revisions". All revsets are unioned and walked together (source, source). |
-n, --limit | number | none | "Applied after revisions are reordered topologically, but before being reversed" (source). With --no-graph there is no reordering, so it cuts the walk order. |
--reversed | flag | off | Oldest first (source). |
-G, --no-graph | flag | off | A flat list instead of a graph (source). |
-T, --template | template | templates.evolog | Rendered once per entry. The keywords are the methods of CommitEvolutionEntry, not Commit (source). |
-p, --patch | flag | off | "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) | flags | none | Any 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
| Key | Default | Effect |
|---|---|---|
templates.evolog | builtin_evolog_compact | The 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_node | as for jj log | The node symbol; there is no separate evolog setting yet (source, source). |
ui.graph.style | curved | The graph's line style (source, source). |
ui.log-word-wrap | false | Wrap each entry to the width beside the graph (source, source). |
ui.diff-formatter | :color-words | The format -p uses when no other long format is given (source, source). |
ui.pager, ui.paginate | less -FRXK, auto | Output 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
4. Core architecture touchpoints
| Structure | Read / write | How | Explained on |
|---|---|---|---|
| Operation log | read | Walked 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 store | read | The map is stored per operation: commit_predecessors and the stores_commit_predecessors flag (source). | protobufs |
| Commits | read | Every 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 engine | read | Only to resolve -r to start IDs. | revsets |
| Trees | read, with a diff | Predecessor trees are rebased onto the entry's parents and diffed (source). | trees |
| Working copy | read (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; aCommitBuildermade 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_opreplaces 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]}givesZthenY; 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).
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_parentof 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 behaviour | When | Source |
|---|---|---|
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" line | The 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" line | The 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 error | The 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