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
| Argument | Type | Default | Meaning |
|---|---|---|---|
PATH (positional) | file path | required | The file to annotate. It is a plain path, not a fileset, so exactly one file (source). |
-r, --revision REVSET | revset | @ | The commit to start from. It must resolve to exactly one commit, and it may be hidden (source). |
-T, --template TEMPLATE | template | templates.file_annotate | How to render each line. Every 0-argument method of AnnotationLine is a keyword (source). |
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):
| Keyword | Type | Value |
|---|---|---|
commit | Commit | The commit the line is credited to. |
content | ByteString | The line, including its newline. |
line_number | Integer | 1-based line number in the file at -r. |
original_line_number | Integer | 1-based line number in the credited commit's version of the file. |
first_line_in_hunk | Boolean | False 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
4. Core architecture touchpoints
| Structure | Read / write | How | Explained on |
|---|---|---|---|
| Starting commit | read | Resolved from -r; its tree gives the path's value and the text to annotate. | commits |
| Trees and file contents | read | The path's value is read in the starting commit and in every parent the walk reaches (source). | trees |
| Conflicts | read | A conflicted file is materialized into text with markers before diffing (source). | conflicts |
| Revset engine | read | Evaluates the walk's revset and streams it as a graph with direct, indirect and missing edges (source). | revsets |
| Commit index | read | Supplies ancestry, and the changed-path index when present, for the files(path) filter (source). | index |
| Credited commits | read | Loaded once per output line to render commit (source). | commits |
View | read | Visible heads, for resolving -r and for all(). | view |
| Working copy | read, may snapshot | Snapshotted 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_helpersnapshots the working copy first, like any command that loads the workspace. If files changed on disk, that snapshot records its own operation, sojj file annotateshows uncommitted edits as part of@. - History read: only commits that are ancestors of
-rand 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 undohas 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).
-
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). -
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. -
At each commit, take its
Sourceout 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. RunContentDiff::by_linebetween the two. Every line of aMatchinghunk that this commit still carries moves to the parent'sline_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. -
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). -
Credit the leftovers. Lines that matched no parent are credited
Ok(this commit, line)(source). - 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).
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
| Message | When | Source |
|---|---|---|
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!