Review code on your disk, not on a forge. A terminal reviewer for Jujutsu stacks — structural diffs, inline comments, and a CLI that makes the same review a coding agent's task list.
rv reads a range of jj changes out of your local repository, shows you the
diff — difftastic's language-aware
structural diff, syntax-highlighted, with full-file context — and lets you
attach comments to individual lines. Those comments live in .review/,
anchored to content rather than line numbers, so they survive rebases and
amends.
The same CLI is a coding agent's whole interface to the review: the agent
reads it with rv comments --json, fixes the code, answers with rv reply,
and ticks work off with rv resolve — no file parsing anywhere, and
rv status --check is the poll loop. You review in one pane; the agent
works through your findings in another; rv refreshes itself as the
commits land.
It is a review tool for the work sitting on your disk right now — before it becomes a pull request, or instead of ever becoming one.
- Structural diffs — difftastic under the hood, with an honest line-diff fallback that names itself in the status bar
- Comments that survive history rewrites — content-anchored, with a three-tier re-anchoring cascade before a comment is ever marked outdated
- A review per branch —
rv feature-aandrv feature-bare independent sessions; jump between them and each is where you left it - Auto-refresh — the reviewer watches jj's operation log and the files under review, and follows commits, rebases, checkouts and plain edits as they happen — comments moving with the code they are about
- An agent loop with no glue code — JSON out, replies and resolutions in, one exit code to poll
- Configurable — remap any key in
keybindings.toml, set your session defaults inConfig.toml, or use it stock with zero setup - No forge, no network, no accounts — it never leaves your machine
- No forge. It does not know what GitHub, GitLab, Gerrit or Forgejo are. It never reads or writes a pull request, a merge request or a change list.
- No network. Nothing
rvdoes makes an outbound connection. - No authentication. There is no account, no token, no config file to hold one.
- No model calls.
rvnever talks to an LLM. The handoff is a file you give to whatever agent you already use. - No history mutation.
rvopens the repository read-only. It never starts a transaction and never writes inside.jj/. The only things it writes are.review/and one line appended to.git/info/exclude. - No jj-config coupling.
rvdeliberately ignores your jj config — yourrevset-aliases, yourui.diff.tool, all of it. It behaves the same on a fresh jj install as on a heavily customised one. Its own configuration lives in~/.config/rv/and every bit of it is optional.
- A jj repository colocated with git — one with a top-level
.git/. This is the default forjj git initas of jj 0.44.rvneeds.git/info/excludeto keep.review/out of the change you are reviewing; see Current limits for what happens without it. - Rust 1.89 or newer, edition 2024 (both forced by
jj-lib0.44) — to build from source. The prebuilt binaries below need none of it. - difftastic (
difft) onPATH, optional. With it you get structural, language-aware diffs. Without itrvsilently falls back to a line diff and labels the panefallbackso you always know which one you are reading.
# macOS and Linux
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/Firaenix/rv/releases/latest/download/rv-installer.sh | sh
# Windows
powershell -c "irm https://github.com/Firaenix/rv/releases/latest/download/rv-installer.ps1 | iex"
# Homebrew
brew install Firaenix/tap/rvPrebuilt binaries are published for macOS (Apple Silicon and Intel), Linux (x86-64 and arm64) and Windows (x86-64). Each release also carries the archives and their checksums directly, if you would rather not pipe a script into a shell.
cargo build --release # ./target/release/rv
cargo install --path rv # or put it on your PATHrv is not on crates.io: the name belongs to an unrelated project there, so
cargo install rv installs something else. Install from this repository, or
use one of the binaries above.
Run rv from the workspace root — the directory holding .jj/. See
Current limits.
| Command | What it reviews |
|---|---|
rv |
trunk()..@ — everything on your stack that trunk does not have |
rv <bookmark> |
trunk()..<bookmark>. The target may be a bookmark, a change id or a commit id |
rv --from <rev> --to <rev> |
An explicit range, e.g. rv --from main --to my-feature |
rv comment <file> --line <n> [--side left] -m <text> |
Adds a comment exactly as the TUI would — anchor and id handled. -m - reads the body from stdin. The reviewer agent's way in |
rv comments [--json] [--state open] |
Lists the comments — id, state, body, reply, anchor and excerpt. The agent's read channel |
rv reply <id> -m <text> |
Stores an answer on the comment (-m - for stdin). A second reply replaces the first; state is untouched |
rv resolve <id> / rv abandon <id> |
Settles a comment, recording who (--by agent is the default; the TUI's R and c a record user). Re-applying reopens it |
rv diff [<file>] --json |
The changes in rv's own side-aware coordinates — the numbers rv comment --line accepts |
rv render [--out <path>] |
Prints the review as markdown, a view nothing reads back; --out writes it to a file |
rv status |
Prints the range, its changes, its changed files and its comment counts |
rv status --json |
The same report as JSON, for scripting |
rv status --check |
Exit 1 while any comment is open, 0 otherwise, nothing printed — the worker's poll and a CI gate |
rv flag <file> --line <n> [--side left] -m <reason> |
Points the reviewer at a line with a reason — attention, not feedback. Never blocks --check. The agent's way to say "start here" |
rv flags [--json] [--open] |
Lists the flags — id, state, anchor, reason |
rv ack <id> / rv unflag <id> |
Marks a flag looked at, or removes it |
rv review <file> [--change <id>] / rv unreview … |
Ticks a file off as reviewed — the range's diff of it, or one change's — or clears the tick |
rv reviewed [--json] |
Lists the ticked files, and which have changed since |
rv completions <shell> |
Prints the completion script for bash, zsh, fish, elvish or powershell on stdout. The nix and Homebrew packages install it for you |
rv --repo <path> … |
Reviews the workspace at <path> instead of the current directory |
rv --no-difft … |
Diffs with the in-process engine instead of difftastic: line-based rather than structural, with context lines. What a reviewer with no difft on PATH sees |
nix run . # run rv without installing anything
nix build # ./result/bin/rv, with difftastic wrapped onto its PATH
nix develop # a shell with cargo, jj, difftastic and the rust toolchainThe packaged binary carries its own difftastic, so structural diffs work even
where difft is not installed, and its shell completions are installed beside
it — nothing to source.
A cargo install carries neither, so completions are a command there:
rv completions zsh > "${fpath[1]}/_rv"
rv completions bash > /etc/bash_completion.d/rv
rv completions fish > ~/.config/fish/completions/rv.fish--to overrides the positional target when both are given. That is also the
escape hatch for the one name collision: a bookmark literally called render or
status has to be passed as rv --to status, since subcommands share the first
positional slot.
rv's idea of trunk() is its own built-in one — the latest of
main/master/trunk on origin/upstream, falling back to the repository
root. It is not read from your config, so in a repository with no remotes the
default range is your entire history.
Exit codes: 0 on success (including --help and --version), 1 when
rv itself fails — an unreadable workspace, an unresolvable revision, an empty
range, no terminal — and 2 for a command line clap rejects, such as an
unknown flag. Failures print the whole error chain as a sentence rather than a
backtrace.
Real output from rv reviewing its own repository, with the middle of each list
elided at the … markers:
$ rv status
revset trunk()..@
base 0000000000000000000000000000000000000000
head 2b9795343e3de90321d23e92e13c8efb6a30e613
changes (17)
ntoxqqukosxynuuymkrsllvymmsnsmxp 2b9795343e3de90321d23e92e13c8efb6a30e613 (no description set)
uspywkpvwqtypzsqrwmptwpymlvvrtok fbad2ee3477848ec4350606e4961374f8bd24bb5 docs(rv): README and milestone-1 dogfood verification
mspsnktlqkkmpyyqunnolnwqnnuvwkuv c960123dbb78d6cec5c61e0406fe66ce0301efad fix(rv): widen comment ids and display the anchored line number
…
nowwnlnmvkwonnvtrspxrrxnprsupkvs 38c8c68bf0bd9f88aa93cacce4dbd9512a670df0 docs: rv design spec, milestone-1 implementation plan, and handoff
files (28)
added Cargo.lock
added Cargo.toml
added README.md
…
added rv/tests/cli.rs
comments 0 open, 0 awaiting verification, 0 resolved, 0 abandoned, 0 outdated
Every file reads added there only because this repository's whole history is
one stack with no merge base — trunk() degrades to the root commit when there
are no remotes. In a normal repository you will see modified, removed and
renamed too.
Change ids are shown the way jj log shows them (the reverse-hex z–k
alphabet), so you can paste one straight back into a jj command.
A bare rv opens the terminal UI: a sidebar and a diff pane over a status
line, with your comments drawn in the diff itself, under the lines they are
about.
┌──────────────────────┬──────────────────────────────────────────────────────┐
│ Files (27) │ ▸ rv-core/src/anchor.rs — difftastic (Rust) │
│ + Cargo.lock │ 47 + let start = index.satu… │
│ + rv-core/src/an… │ 48 + let end = (index + 5)… │
│ ~ rv/src/ui.rs │ ╭─ 8d985355 · open ────────────────╮ │
│ │ │ lines.len() - 1 underflows │ │
│ │ │ reply: fixed with unwrap_or(0) │ │
│ │ ╰──────────────────────────────────╯ │
├──────────────────────┴──────────────────────────────────────────────────────┤
│ ↓↑ move ←→ focus [/] file c comment g goto v view ? help q quit │
└─────────────────────────────────────────────────────────────────────────────┘
The sidebar marks each file by how it changed: + added, - removed, ~
modified, -> renamed. Tab switches it from that file list to a list of every
comment in the review, and Enter on one of those rows opens the code it is
about. The diff pane's title says where its lines came from — difftastic (Rust), fallback, or binary — so a degraded diff is never mistaken for a
structural one. Diffs are computed lazily, for the selected file only, and
cached, so stepping back to a file does not re-run difftastic.
The pane the next keystroke lands in is marked with a ▸ on its title and a
bold border, never with colour — the chrome's colours already mean something
else: a blue border is a comment, a green background an addition, a red one
a removal.
The code's own colours are your terminal's, not rv's. Syntax highlighting
emits only the 16 indexed ANSI colours, which every scheme redefines for itself,
so rv shows your Solarized or your Gruvbox rather than a palette it picked —
which is why there is no theme setting and no need for one. The chrome and the
code never collide because they use different channels: a diff's green and red
are backgrounds, and a syntax colour is always a foreground.
Every key below is the default, and almost all of them can be remapped in
~/.config/rv/keybindings.toml — a patch over the defaults, where
command = "key" moves a command onto a new key and frees its old one:
[leaders]
goto = "g" # which key opens each leader's menu
[keys] # direct keys, everywhere
cursor_next_row = ["j", "Down"]
app_quit = "Q"
[keys.files] # direct keys in one pane only
diff_toggle_full_context = "f"
[goto.diff] # a leader's children, scoped to one pane
cursor_last_row = "e"rv keymap opens the file in $EDITOR, seeding it with the fully-commented
defaults on first run — which is also where the command vocabulary lives —
and validates it the moment the editor exits; rv keymap --show prints the
effective map, defaults and patch merged. A user bind that lands on a key a
default already holds simply takes it, and the review says so when it opens;
only a config that contradicts itself refuses to load. Ctrl+C,
Shift+arrows and the ? popup's own keys are not remappable.
Session defaults — the display preferences you would otherwise re-toggle at
the top of every review — live in ~/.config/rv/Config.toml (rv config
opens it, seeded and validated the same way), every field optional:
tree = true # open the file list as a tree
sort = "added" # natural | added | removed
full_context = false # open on changes-only
grouped = true # removals before additions in each hunk
tint = false # no proportion tint on sidebar rows
counts = false # no +n -n column
wrap_commit_subjects = true # wrap commit rows instead of clipping them
sidebar_hidden = true # open with the sidebar put away
split = 40 # sidebar share of the width, in percent
ascii = true # ASCII status-bar separators (RV_ASCII as a setting)
engine = "fallback" # auto | fallback — --no-difft as a default
auto_refresh = false # stop refreshing when the repo or a reviewed file moves (on by default)Every reviewed head gets its own directory under .review/reviews/, so
reviews never clobber each other: rv feature-a and rv feature-b are two
independent sessions — comments, scope and export each — and jumping back to
either finds it exactly where it was left. A bare rv is the one ambient
working-copy review that follows @ wherever you take it. rv reviews
lists everything stored, with each review's range and comment counts.
While the TUI is open, rv watches jj's operation log and the working copy of the files under review (a handful of stat calls every two seconds) and refreshes itself when either moves — an agent's commit landing, a rebase, a file saved in an editor — and again the moment the terminal regains focus, so what you come back to is what is there now. It never interrupts a comment being typed or a confirmation being answered.
Browsing
Movement is the arrows (and the mouse); in the diff ←/→ step a column
cursor along the selected line a character at a time and h/l step it a word
at a time, clicking a character places it there, and the line scrolls sideways
to keep the cursor on screen. Uppercase acts, lowercase moves: every key that
writes to the review or leaves the reviewer is a shifted letter — C comment,
D delete, R resolve, A acknowledge a flag, F flag, X tick reviewed,
E edit — so a mutation is always a deliberate chord and a lowercase letter
is always safe to press. The lowercase letters are leaders, each opening a
small menu of what the next key does: Space is the contextual menu — it
shows whichever actions suit the mode you are in; m jumps to a mode;
g goes somewhere; c is the comment menu (the same verbs as the
capitals, for the reviewer who prefers a menu); and v is the full, stable
list of view toggles. ? shows the leaders and what is special where you
are; ? again unrolls the whole map, drawn from the keymap as your config
left it.
| Key | Action |
|---|---|
↓ |
Next row, file or comment — whichever the focused pane is listing |
↑ |
The previous one |
← |
One character left along the diff line, scrolling the view to follow; elsewhere out / up a level: a comment stack hands the focus back to the diff, and in the file or commits list it climbs one level of the tree you drilled into. It never leaves the diff — Tab does that |
→ |
One character right along the diff line, scrolling the view to follow; elsewhere into / open: in the file or commits list it drills into the directory or change under the cursor, opens the file under it (moving the focus to the diff), and in the Comments tab jumps to the comment's code |
PgDn |
Move the cursor a screenful forward in the focused pane |
PgUp |
A screenful back |
Home |
Jump the cursor to the first row of the focused pane |
End |
Jump it to the last row |
Shift+← |
Scroll the focused pane's text sideways toward the start of the line |
Shift+→ |
The other way, to read the tail of lines wider than the pane; a trackpad's sideways flick does the same |
] |
Next file, from whichever pane the cursor is in |
[ |
Previous file, likewise |
Enter |
To the diff for the highlighted item: opens the file under the sidebar cursor (moving the focus to the diff), steps into the selected diff line's comment stack — or onto the flag the cursor is resting on — or from the Comments tab jumps to the comment's code. It no longer fires on a directory or change row — → drills into those |
Tab |
From the diff, to the sidebar on whatever tab it was left on; from the sidebar, round the four lists: Files → Commits → Comments → Flags → Files. The diff is not a stop on that walk — Enter, → on a file or m d go there — so holding Tab cannot strand you between the flag browser and the diff. A file row under a change shows that change's diff of it |
s |
Fold a comment box away, or a directory in the file list — again to unfold |
f |
On the diff: toggle full-file context (also v f from anywhere) |
i |
In the Commits list: put the change details away, or bring them back (also v i from anywhere) |
E |
Open the selected file at the cursor's line in $EDITOR, and come back to the review when it exits. Unset $EDITOR is reported in the status line rather than guessed at |
+ |
Widen the sidebar |
_ |
Narrow it |
Esc |
Leave the comment stack or the flag, or back out of a zoomed directory |
? |
What the keys do here: a contextual tip in the corner above the bar. ? again unrolls the whole keymap; Esc or q closes either |
q |
Quit |
Ctrl+C |
Quit from anywhere, including out of a half-typed comment |
C |
Comment on the highlighted line: type, Enter saves, Esc discards. Every key that writes to the review is a shifted letter — C D R A F X E — so a mutation is always a deliberate chord, and a lowercase letter only ever moves, toggles a view, or opens a menu |
D |
Delete the comment or flag under the cursor, after a y/n confirmation |
R |
Resolve the comment under the cursor — again to reopen |
X |
Tick the file off as reviewed — again to untick. From the file list it ticks the range's diff of the file, from a row under a change it ticks that change's diff of it, and on a change row it ticks every file the change touched. Ticking folds the file's comments and flags away; the tick shows as ✓ on the row and in the diff's title, ≈ once the file has changed under it |
F |
(Diff) Flag the highlighted line: type why it deserves a look, Enter saves. A flag is attention, not feedback — it never blocks rv status --check |
A |
(Diff) Acknowledge the line's flags — again to reopen them. On a flag (FLAG in the bar, reached by clicking it or Enter on its row), just that one |
/ |
Find text in the diff: type, Enter jumps to the first match after the cursor, Esc cancels. Case-blind unless the query has a capital; every match is underlined |
n |
The next match of the last / query, wrapping round the file |
N |
The previous one |
h |
(Diff) Move the column cursor to the previous word of the highlighted line |
l |
(Diff) The next word — the word under the cursor is what *, g d and g r look up |
* |
(Diff) Search for the word under the column cursor and jump to its next occurrence — / without retyping what is already on screen |
Space t |
(Files/Commits) Switch the list between a flat list and a tree |
Space o |
(Files/Commits) Cycle the list's order: by path, additions, deletions |
Space # |
(Files/Commits) Show or hide the +n -n counts |
Space c |
(Files/Commits) Tint the names by their change |
Space g |
(Diff) Group the diff by side instead of interleaving |
Space b |
(Diff) Cycle before | diffed | after |
Space f |
(Diff) Toggle full-file context |
Space d |
(on a comment) Delete it, after a y/n confirmation |
Space r |
(on a comment) Resolve it — again to reopen |
Space a |
(on a comment) Abandon it — again to reopen |
m f |
Go to the Files mode: the sidebar's file list |
m c |
Go to the Commits mode: the list of changes |
m o |
Go to the Comments mode: the review's comment browser |
m F |
Go to the Flags mode: every flag in the review under the file it points into, open ones first by file and line. Enter jumps to the code, A acknowledges, s folds, d deletes. A file or change row in the Files/Commits lists carries ⚑ while a flag under it is still open |
m d |
Go to the Diff mode: the focus lands on the diff |
g ↓ |
Next hunk: the first line of the next run of changes, skipping the context between them |
g ↑ |
The previous hunk |
g n |
Next symbol in scope — every changed file, or one change's files from the Commits tab |
g N |
The previous symbol |
g / |
Find a symbol by name: type, Enter jumps to the best match, Esc cancels |
g f |
Next flag in the review, wrapping round |
g F |
The previous flag |
g d |
Go to the definition of the symbol under the column cursor — the next one on from here where the review defines it more than once. The symbol is the one tree-sitter found at that spot, so on self.write() it is write |
g r |
List every reference to the symbol under the column cursor, across every file in scope. The list is what tree-sitter found — real uses, each tagged with what it is (a call, a type) — so a name inside a comment or a string is not in it. Each row is a kind, a file, a line number and the line itself; ↑/↓ choose (wrapping), Home/End jump to either end, Enter goes there, Esc leaves the review where it was. The cursor opens on the first reference past the one you are reading. For a file rv has no grammar for the panel says Lines naming, and those rows are a plain whole-word text match |
c c |
Comment on the highlighted line (the menu's spelling of C) |
c d |
Delete a comment, after a y/n confirmation (D) |
c r |
Resolve a comment — press it again to reopen (R) |
c a |
Abandon a comment, dropping it without fixing it — again to reopen |
v f |
Toggle full-file context. Default on: the pane shows the whole file with the changed lines highlighted. Off restores the difftastic-only view of just the changes |
v g |
Group the diff: each hunk's removed lines before its added ones, the way a unified diff prints — instead of difftastic's interleaving of the two sides |
v b |
Cycle the diff between showing both sides, the before side alone (context and removals), and the after side alone (context and additions) |
v t |
Switch the file list between a flat list and a tree |
v o |
Cycle the file list's order: by path, by additions, by deletions |
v c |
Tint the sidebar names by their change — each name runs green through a light seam to red, split where its additions end — or turn the tint off |
v # |
Show or hide the sidebar's +n -n counts |
v z |
Hide the sidebar, or bring it back — the ‹ in the bottom-left corner does the same by pointer |
v < |
Narrow the sidebar |
v > |
Widen it |
v i |
Put the change details away, or bring them back. They show whenever a change is highlighted in the Commits tab: both ids, the whole description, and every file it touched |
v r |
Refresh: re-resolve the range against the repository as it now stands — after a push, a rebase, or a bookmark move |
In the diff, ↓ and ↑ move by row rather than by diff line, so a comment
box is something the cursor walks through rather than over — see Inline
comments. v <, v > and the fold state are preferences of
the running session: nothing about how you arranged your screen is written to
.review/.
J and K walk hunks — a run of added or removed lines with unchanged
context either side — so a long file with three edits in it is three presses
rather than a scroll. Neither wraps: at the last hunk J says so in the status
line and leaves the cursor where it is, because a jump back to the top would
look exactly like a jump that did nothing.
Which comment d and s act on follows the cursor: the box you are on inside a
stack, the comment the browser is showing on the Comments tab, and otherwise
the selected diff line's — the newest of them for d, all of them together for
s. The Files tab selects a file rather than a comment, so d there
deletes nothing and says so, while s still folds the line the diff is on.
Typing a comment
| Key | Action |
|---|---|
| any character | Append to the comment |
Backspace |
Delete the last character |
Enter |
Save; the status line reports path:line |
Esc |
Discard |
Comments are single-line for now. Saving one writes it through to
comments.json immediately, so a comment survives the process being killed
the instant after Enter.
A comment anchors to the side of the diff its line belongs to: a removed line
anchors to the base revision, added and context lines to the head. The line
number shown in the pane is always the one the anchor stores. If the highlighted
line has no number on that side, rv refuses to save rather than anchoring it
somewhere approximate, and says so in the status line.
A saved comment is drawn beneath the line it is anchored to, in a blue
bordered box indented to the diff's gutter, titled with the comment's id and its
state. Several comments on one line stack under it, oldest first. The boxes are
part of the diff pane: ↓ and ↑ move the cursor by row, so they walk
through a box rather than over it, and the pane scrolls with them. A comment
longer than the pane is tall is therefore read the way any other content is, by
scrolling. While the cursor is inside a box, the line the box belongs to stays
the selected one — so c there comments on the code the box is about.
A box sticks to the code it is about, not to a line number. When the file
changes under a comment — lines added above it, the commented line itself
rewritten — the box is drawn under the line as it now stands, Enter in the
Comments tab jumps there, and the browser names the new line. The heading says
how sure that placement is: nothing, when the line is exactly where it was;
· moved in grey when the same text was found elsewhere; · weak anchor in
yellow when the line's own text has changed and the box was placed by the lines
around it, or — when even those are gone — left at its old number. The stored
comment keeps the line it was written at; the placement is worked out afresh
every time the review opens or refreshes.
Enter steps the cursor into the stack under the selected line, where j and
k move between the boxes rather than between the lines; Esc or ← steps
back out. The selected box is drawn brighter and bold, so D and s visibly
have a target. The bar says STACK while you are there.
A flag is the same kind of thing: click one, or press Enter with the row
cursor on it, and the bar says FLAG — j/k walk the line's flags, the
selected one is drawn bright, and D, A and s act on it alone.
A reply — stored by a coding agent with rv reply — renders inside the same
box, beneath the comment body, prefixed reply: and dimmed. It is part of the same conversation, and
dimming it is what tells your own words from the answer to them at a glance.
s folds a box down to a single row and unfolds it again. Folding is a view
preference of this session only. It is never written to .review/, so nothing
another reviewer — or an LLM reading the export — sees depends on how you
arranged your screen; reopening rv gives you every box back. A comment that is
no longer open starts folded, and greyed, since it is not one asking for an
answer.
Deleting a comment is permanent. d asks first — the status line reads
delete comment at <path>:<line>? (y/n) — and only a lowercase y goes
through; every other key keeps the comment. What y removes is the comment's
entry in comments.json, with nothing that undoes it.
REVIEW-FEEDBACK.md is a view, produced only on request — the TUI's e
or rv render --out — and read back by nothing. Saving, settling and replying
never touch it; comments.json is what says which comments exist right now.
.review/
├── session.toml the range under review and every change in it
├── comments.json the authority on which comments exist — each entry
│ carries the lines around its comment, as they were
└── REVIEW-FEEDBACK.md a rendered view of the above, only ever written on request
REVIEW-FEEDBACK.md is a view: rebuilt from comments.json when you ask for
it, never read back. comments.json is the file that decides what exists.
.review/ is kept out of version control automatically. On its first run in
a repository, rv appends /.review/ to .git/info/exclude — never to
.gitignore, which is shared and would affect every clone. This is correctness,
not tidiness: an untracked-but-visible .review/ would be snapshotted by jj into
the very change you are reviewing, on every single jj command. After running
rv, both git status and jj status should stay silent about it. Every write
under .review/ is atomic (write to a temp file in the same directory, fsync,
rename), so a reader never sees half a file. One side effect of that scheme: the
files rv writes end up mode 0600, including .git/info/exclude, which it
rewrites rather than owns. More restrictive than the 0644 git leaves, never
less, but worth knowing if something else on your machine reads that file.
Deleting .review/ throws away your comments and nothing else; the next rv
run recreates it.
This is what the tool is for, and it is CLI end to end.
The worker's loop:
rv status --check # is there work? (exit 1 = open comments)
rv comments --json --state open # what exactly?
# …fix the code…
rv reply 6ce52206 -m "Widened to 8 hex; prop_store pins the width."
rv resolve 6ce52206
rv status --json # open down, resolved upThe reviewer's loop:
rv status --json # scope: changes, files
rv diff --json # what changed, in rv's own coordinates
# …read the head files as needed…
rv comment rv-core/src/store.rs --line 238 -m - <<'EOF'
`content_hash` is computed from the untrimmed line, so re-indenting breaks
every anchor — hash the trimmed text.
EOFEvery command either succeeds or exits 1 with a reason on stderr; nothing in
the loop parses a document. The -m - form reads the body from stdin — the
git commit -F - convention — so a finding full of backticks and quotes never
meets the shell. Two skills in this repository teach the loop to agents:
rv-reviewer and rv-worker under .claude/skills/.
rv render still produces the markdown for human reading, and a reply an agent
wrote into a pre-CLI export is rescued into the store once, the first time this
version loads the review.
The status bar's powerline arrows and the sidebar's folder and file icons are nerd-font glyphs. They need a patched font, rv cannot detect one, and a font without the patch shows tofu — so one switch turns both off:
RV_ASCII=1 rvAs with RV_NO_DIFFT, presence is the switch: RV_ASCII=0 turns the glyphs
off like any other value.
Set RV_NO_DIFFT=1 to skip difftastic entirely and use the built-in line diff:
RV_NO_DIFFT=1 rvUse it when difftastic is missing, when it is slow on a pathological file, or
when you want to check whether a confusing diff is difftastic's alignment or
your code. The diff pane title changes from difftastic (Rust) to fallback so
the two are never confused. rv also falls back on its own, silently and with
the same label, if difft is absent or returns something unexpected.
This is 1.0.0 — the first release. rv reviews its own development stack, and
has been doing so for the whole of its own history. Known and deliberate gaps:
- Line-scoped comments only. One line, one comment, one line of text. No multi-line selections, no multi-line comment bodies, no symbol- or block-scoped comments.
- No verification flow. Comments resolve (
r,rv resolve), abandon (a,rv abandon) and deriveoutdatedwhen their code moves on — but a reply does not move a comment toawaiting-verification, and nothing automated verifies a fix. The human's pass in the TUI is the verification. - No node-scoped anchors. Symbols index for
n/Nand the/picker, but a comment still anchors to a line, not a syntax node. - No re-anchoring command. Anchors carry a content hash and can survive an
edit, but there is no
rv reanchorto sweep them after you rewrite history. - Must be run from the workspace root.
rvdoes not walk up the directory tree looking for.jj/. From a subdirectory it fails withno jj workspace at <dir>. Pass--repo <root>if you need to run it from elsewhere. rvreviews the last snapshot of@, not your files on disk. Because it is strictly read-only it never snapshots the working copy, so edits you made since the last jj command are invisible to it — a file you just created will not appear in the review at all. Run any jj command (jj statuswill do) to snapshot, then runrv.- A workspace with no top-level
.git/is not protected. The exclude mechanism is.git/info/excludeand nothing else, andrvcreates that path unconditionally rather than checking whether a git repository is really there. So in any workspace root without a sibling.git/,rvinvents one containing a singleinfo/excludefile that jj never reads, reports success, and leaves.review/visible — where jj then snapshots it straight into the change you are reviewing. This is not only thegit.colocate = falsecase: it covers repositories where colocation was turned off withjj git colocation disable, and every secondary workspace created byjj workspace add, which has its own.jj/and no.git/of its own.rvdoes not currently detect or warn about any of them. After your firstrvrun in a new workspace, check thatjj statusdoes not list.review/. - Not a TTY, not a review. A bare
rvneeds a terminal; piped or redirected it printscould not start the terminaland exits 1. Userv renderorrv statusin scripts.
| Crate | Contents |
|---|---|
rv-core |
Everything terminal-free: jj-lib access, diff production, anchoring, .review/ I/O, markdown render and reply parsing |
rv |
The CLI and the ratatui TUI |
rv-core must never depend on ratatui, crossterm or tui-textarea, and
jj-lib is imported in exactly one file, rv-core/src/vcs.rs. The first rule is
what makes anchoring testable without a terminal; the second bounds the blast
radius of a jj-lib upgrade to one module.
nix develop # cargo, jj and difftastic at the versions CI uses
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --checkThe suite spawns a real jj workspace per fixture and shells out to difft, so
both must be on PATH — nix develop is the shortest way to have exactly the
versions CI runs. CI runs those three commands inside that same shell, so a
green run locally means a green run there.
MIT or Apache-2.0, at your option. See LICENSE-MIT and LICENSE-APACHE.

