Skip to content
FiraenixPublic

About

Review code on your disk, not on a forge — a terminal reviewer for Jujutsu (jj) stacks with structural diffs, rebase-proof inline comments, and a CLI that turns the review into a coding agent's task list

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

rv

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.

a review in rv: structural diff, inline comment, live status bar

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-a and rv feature-b are 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 in Config.toml, or use it stock with zero setup
  • No forge, no network, no accounts — it never leaves your machine

What rv is not

  • 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 rv does makes an outbound connection.
  • No authentication. There is no account, no token, no config file to hold one.
  • No model calls. rv never talks to an LLM. The handoff is a file you give to whatever agent you already use.
  • No history mutation. rv opens 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. rv deliberately ignores your jj config — your revset-aliases, your ui.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.

Requirements

  • A jj repository colocated with git — one with a top-level .git/. This is the default for jj git init as of jj 0.44. rv needs .git/info/exclude to 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-lib 0.44) — to build from source. The prebuilt binaries below need none of it.
  • difftastic (difft) on PATH, optional. With it you get structural, language-aware diffs. Without it rv silently falls back to a line diff and labels the pane fallback so you always know which one you are reading.

Install

# 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/rv

Prebuilt 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.

From source

cargo build --release        # ./target/release/rv
cargo install --path rv      # or put it on your PATH

rv 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.

Usage

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

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 toolchain

The 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.

Example

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.

The reviewer

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.

Keybindings

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)

Several reviews at once

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.

Inline comments

the comment browser: every finding, one Enter from its code

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.

The .review/ directory

.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.

The agent loop

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 up

The 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.
EOF

Every 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.

RV_ASCII

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 rv

As with RV_NO_DIFFT, presence is the switch: RV_ASCII=0 turns the glyphs off like any other value.

RV_NO_DIFFT

Set RV_NO_DIFFT=1 to skip difftastic entirely and use the built-in line diff:

RV_NO_DIFFT=1 rv

Use 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.

Current limits

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 derive outdated when their code moves on — but a reply does not move a comment to awaiting-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/N and 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 reanchor to sweep them after you rewrite history.
  • Must be run from the workspace root. rv does not walk up the directory tree looking for .jj/. From a subdirectory it fails with no jj workspace at <dir>. Pass --repo <root> if you need to run it from elsewhere.
  • rv reviews 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 status will do) to snapshot, then run rv.
  • A workspace with no top-level .git/ is not protected. The exclude mechanism is .git/info/exclude and nothing else, and rv creates that path unconditionally rather than checking whether a git repository is really there. So in any workspace root without a sibling .git/, rv invents one containing a single info/exclude file 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 the git.colocate = false case: it covers repositories where colocation was turned off with jj git colocation disable, and every secondary workspace created by jj workspace add, which has its own .jj/ and no .git/ of its own. rv does not currently detect or warn about any of them. After your first rv run in a new workspace, check that jj status does not list .review/.
  • Not a TTY, not a review. A bare rv needs a terminal; piped or redirected it prints could not start the terminal and exits 1. Use rv render or rv status in scripts.

Layout

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.

Development

nix develop          # cargo, jj and difftastic at the versions CI uses

cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --check

The 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.

Licence

MIT or Apache-2.0, at your option. See LICENSE-MIT and LICENSE-APACHE.

About

Review code on your disk, not on a forge — a terminal reviewer for Jujutsu (jj) stacks with structural diffs, rebase-proof inline comments, and a CLI that turns the review into a coding agent's task list

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages