jj / How jj file annotate works

How jj file annotate works

jj file annotate prints a file one line at a time, each line labelled with the change that introduced it. It walks the file's history from one commit back towards the root, diffing each commit against its parents and handing matching lines down to the parent.

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. 7. Errors and edge cases
  8. 8. Example output

In short: jj resolves -r (default @) and checks that the path is a file or a conflict of files. FileAnnotator then walks the ancestors that modify the file, children before parents. At each commit it diffs the text against each parent's text line by line. Lines that match are handed down to the parent, and lines that match no parent are credited to that commit. The walk stops once no line is left to hand down. Each line is rendered with templates.file_annotate. Nothing is written: no transaction, no operation.

The command is cmd_file_annotate, and the work is done by FileAnnotator in jj-lib. The help text: "Annotates a revision line by line. Each line includes the source change that introduced the associated line" (source). There's no copy or rename tracking: the history of one path is followed, and upstream leaves a field for that to change (source).

1. Command-line surface

ArgumentTypeDefaultMeaning
PATH (positional)file pathrequiredThe file to annotate. It is a plain path, not a fileset, so exactly one file (source).
-r, --revision REVSETrevset@The commit to start from. It must resolve to exactly one commit, and it may be hidden (source).
-T, --template TEMPLATEtemplatetemplates.file_annotateHow to render each line. Every 0-argument method of AnnotationLine is a keyword (source).

FileAnnotateArgs

2. Configuration

templates.file_annotate supplies the template when -T is absent (source). The built-in default joins four fields with spaces: the change ID's shortest prefix (at least 8 characters), the author's email local part padded or cut to 8, the timestamp, and the line number padded to 4. Then comes ": " and the line (templates.toml):

join(" ",
  commit.change_id().shortest(8),
  pad_end(8, truncate_end(8, commit.author().email().local())),
  commit_timestamp(commit).local().format("%Y-%m-%d %H:%M:%S"),
  pad_start(4, line_number),
) ++ ": " ++ content

The timestamp comes from the commit_timestamp(commit) template alias, which defaults to the committer timestamp (source). The keywords are the five methods of AnnotationLine (commit_templater.rs, docs):

KeywordTypeValue
commitCommitThe commit the line is credited to.
contentByteStringThe line, including its newline.
line_numberInteger1-based line number in the file at -r.
original_line_numberInteger1-based line number in the credited commit's version of the file.
first_line_in_hunkBooleanFalse when the line just above is credited to the same commit.

revset-aliases apply when resolving -r. Nothing configures the history search itself: the CLI always passes all() as its domain, with a TODO about an option to limit it to recent commits (source).

3. End-to-end flow

jj file annotate: call flow cmd_file_annotate loads the workspace through the shared lifecycle, resolves -r (default @), parses the path and looks up its value in the commit's tree. An absent value or one that is not all files is an error. It picks the template from -T or templates.file_annotate and parses it. FileAnnotator::from_commit loads the file text, compute walks the ancestors that modify the file in the all() domain, and to_annotation returns one origin per line. render_file_annotation requests the pager and, for each line, loads the credited commit and formats an AnnotationLine. Shared lifecycle: load workspace and repo, snapshot the working copy (CLI page) resolve_single_rev(-r or @); parse_file_path; starting_commit.tree().path_value(path) a hidden commit is fine; the revset only has to name one commit absent → No such path to_file_merge() is None → Path exists but is not a regular file template: -T or templates.file_annotate parse_template with the commit template language, AnnotationLine as the context FileAnnotator::from_commit → compute(repo, all()) → to_annotation() jj-lib: walk heads ∪ (all() & ::start & files(path)), children first see the algorithm below render_file_annotation: request_pager; per line, get_commit_async(origin) AnnotationLine { commit, content, line_number, original_line_number, first_line_in_hunk } → template.format → stdout

4. Core architecture touchpoints

StructureRead / writeHowExplained on
Starting commitreadResolved from -r; its tree gives the path's value and the text to annotate.commits
Trees and file contentsreadThe path's value is read in the starting commit and in every parent the walk reaches (source).trees
ConflictsreadA conflicted file is materialized into text with markers before diffing (source).conflicts
Revset enginereadEvaluates the walk's revset and streams it as a graph with direct, indirect and missing edges (source).revsets
Commit indexreadSupplies ancestry, and the changed-path index when present, for the files(path) filter (source).index
Credited commitsreadLoaded once per output line to render commit (source).commits
ViewreadVisible heads, for resolving -r and for all().view
Working copyread, may snapshotSnapshotted by the shared lifecycle, so edits on disk show up in @.working-copy

5. What it reads, and snapshotting

  • No transaction, no operation of its own. The command only reads the repo it loaded (source).
  • Snapshot: workspace_helper snapshots the working copy first, like any command that loads the workspace. If files changed on disk, that snapshot records its own operation, so jj file annotate shows uncommitted edits as part of @.
  • History read: only commits that are ancestors of -r and change the file, plus the parents those edges point to. Commits that leave the file alone are filtered out by the revset engine, and their text is never read or diffed.
  • Hidden starting commits: work, since all() covers commits referenced in the expression (source). Upstream tests an abandoned commit via -r 'at_operation(@-, @)' (source).
  • jj undo has nothing to revert, except the snapshot if one was taken.

6. Algorithm and invariants

The state is a Source per pending commit: the file's text there, plus a sorted line_map of pairs (line in this commit, line in the starting file). There's also one origin per starting line (source). At the start, only the starting commit is pending and each line maps to itself (source).

  1. Pick the commits. The revset is the pending commits, unioned with domain & ::pending & files(path) (source). files() compares a merge against the merge of its parents, so a merge whose file is exactly what merging the parents gives, even an unresolved conflict, doesn't count as modifying it (source).
  2. Walk children before parents with stream_graph() (source). Its edges skip excluded commits: a parent that doesn't change the file is replaced by its nearest ancestors in the set (an indirect edge). A parent with no way back into the set gives a missing edge (source). Skipping is safe, because a skipped commit has the same text as the ancestor it's replaced by.
  3. At each commit, take its Source out of the map; commits with no pending lines are skipped. For each parent edge, load the parent's text if it isn't pending already. Run ContentDiff::by_line between the two. Every line of a Matching hunk that this commit still carries moves to the parent's line_map, merged with lines another child already handed down (source, diff). Parents are tried in order, so on a merge the first parent that has a line gets it.
  4. Drop or stop. A parent left with no lines is removed. If the edge was missing but the parent did get lines, those lines are marked Err(parent, line), meaning unresolved outside the domain, and the parent counts as an unresolved root (source).
  5. Credit the leftovers. Lines that matched no parent are credited Ok(this commit, line) (source).
  6. Stop as soon as every pending commit is an unresolved root. That usually means none are pending, so the walk ends at the oldest commit still contributing a line, not at the root (source).

Where the file first appears, the parent edge reaches outside the set, usually to the root commit. The parent's text there is empty, so nothing matches and the parent is dropped. The new lines are credited to the commit that added them. A line can only stay Err when a parent outside the set really has it. The CLI renders any such line as the root commit with original_line_number 1. Its comment names repositories that jj initialized shallowly and Git later unshallowed (source).

Conflicts. A conflicted file at any visited commit is materialized in the diff marker style before diffing, at line granularity, with identical changes on both sides accepted. Upstream marks this with "TODO: track line origins without materializing" (source). The marker lines and the +-prefixed diff lines exist in no parent, so they are credited to the conflicted commit; plain snapshot lines can still match a parent. A path that isn't a file in some commit (a directory, symlink, or deleted) reads as empty text there (source).

Rendering. Origins are 0-based in jj-lib (source) and shown 1-based. first_line_in_hunk compares the credited commit ID with the previous line's (source).

How lines are handed down from commit2 to commit1 to initial From upstream's test_annotate_with_template. commit2 has six lines. Diffing it against commit1 matches lines 1 to 3, which move to commit1; lines 4 to 6 match nothing and are credited to commit2. Diffing commit1 against initial matches only "initial 2", commit1's line 1, which is initial's line 2; commit1's lines 2 and 3 are credited to commit1. initial's parent is the root, which has no text, so "initial 2" is credited to initial, and the walk ends. kkmpptxz commit2 (start) rlvkpnrz commit1 qpvuntsm initial 1 initial 2 2 new text from new co… 3 that splits into mul… 4 new text from new co… 5 also continuing on a… 6 and a third! 1 initial 2 2 new text from new co… 3 that splits into mul… 1 initial 1 2 initial 2 3 initial 3 Highlighted: credited to that commit. Arrows: Matching hunks, handed to the parent. Root has empty text, so initial keeps "initial 2"; nothing is left pending and the walk stops.

Invariants: every line of the file at -r gets exactly one origin. A line is handed down only through an exact line match, so its credited commit has it with the same bytes. The output always has as many lines as the file.

7. Errors and edge cases

MessageWhenSource
Error: No such path: <path>The path isn't in the commit's tree.annotate.rs
Error: Path exists but is not a regular file: <path>A directory, symlink or submodule, or a conflict where any term isn't a file (to_file_merge).annotate.rs
Revset `…` resolved to more than one revision (or to none)-r isn't exactly one commit.revset_util.rs
Template parse error-T, or the configured template, doesn't parse against AnnotationLine.annotate.rs
clap: missing <PATH>No path given.annotate.rs

Upstream tests the first two messages on a missing file, a directory and a symlink (test_annotate_non_file).

8. Example output

A linear history, with the default template. file.txt got line1 in qpvuntsm (author foo@example.org), and the working-copy commit appended a line (test_annotate_linear):

qpvuntsm foo      2001-02-03 08:05:08    1: line1
kkmpptxz test.use 2001-02-03 08:05:10    2: new text from new commit

A merge whose conflict was resolved by keeping both lines. Each line goes to the side that added it (test_annotate_merge):

qpvuntsm test.use 2001-02-03 08:05:08    1: line1
zsuskuln test.use 2001-02-03 08:05:11    2: new text from new commit 1
royxmykx test.use 2001-02-03 08:05:13    3: new text from new commit 2

The same merge left conflicted, annotated from an empty commit on top of it (test_annotate_conflicted). Neither the merge nor the empty commit modifies the file, but the empty commit is where the walk starts, so the materialized markers are credited to it (yostqsxw). line1 and commit2's line match a parent and keep their origins:

qpvuntsm test.use 2001-02-03 08:05:08    1: line1
yostqsxw test.use 2001-02-03 08:05:15    2: <<<<<<< conflict 1 of 1
yostqsxw test.use 2001-02-03 08:05:15    3: %%%%%%% diff from: qpvuntsm a5daff01 "initial"
yostqsxw test.use 2001-02-03 08:05:15    4: \\\\\\\        to: zsuskuln 30cd4478 "commit1"
yostqsxw test.use 2001-02-03 08:05:15    5: +new text from new commit 1
yostqsxw test.use 2001-02-03 08:05:15    6: +++++++ royxmykx ad312256 "commit2"
royxmykx test.use 2001-02-03 08:05:13    7: new text from new commit 2
yostqsxw test.use 2001-02-03 08:05:15    8: >>>>>>> conflict 1 of 1 ends

A custom template that prints a header per hunk and the line number in the credited commit, for the history drawn above (test_annotate_with_template). The template is if(first_line_in_hunk, "\n" ++ separate("\n", …) ++ "\n") ++ pad_start(4, original_line_number) ++ " ->" ++ pad_start(4, line_number) ++ ": " ++ content:

qpvuntsm initial
2001-02-03 08:05:08 Test User <test.user@example.com>
   2 ->   1: initial 2

rlvkpnrz commit1
2001-02-03 08:05:09 Test User <test.user@example.com>
   2 ->   2: new text from new commit 1
   3 ->   3: that splits into multiple lines

kkmpptxz commit2
2001-02-03 08:05:10 Test User <test.user@example.com>
   4 ->   4: new text from new commit 2
   5 ->   5: also continuing on a second line
   6 ->   6: and a third!