A Neovim plugin for Gerrit Code Review. Browse changes, read the diff, leave inline comments, vote, and post the review without opening the web UI.
It talks to Gerrit over ssh on port 29418. There is no token to generate and
nothing to configure: if git push to your Gerrit works, so does this.
- Neovim 0.10 or newer
sshandgit- A Gerrit server you can reach over ssh (2.12 or newer, for
gerrit review --json) - Optional: telescope.nvim
for the change picker; without it
vim.ui.selectis used
With lazy.nvim:
{ 'SafaeOuajih/gerrit.nvim', cmd = 'Gerrit', opts = {} }Everything hangs off one command.
| Command | What it does |
|---|---|
:Gerrit |
pick from the open changes of the current project |
:Gerrit dashboard |
what is waiting for you, across every project |
:Gerrit status:merged owner:self |
pick from any Gerrit query |
:Gerrit mine |
your own open changes |
:Gerrit open 12345 |
the overview of one change |
:Gerrit diff 12345 |
its diff, ready to be commented on |
:Gerrit review [12345] |
compose and publish a review |
:Gerrit message 12345 lgtm |
post a one-line message |
:Gerrit drafts |
the comments you have not published yet |
:Gerrit watch [on|off] |
be told when a review starts waiting for you |
Where a change number is optional, it is taken from the current buffer when you are already looking at a change.
:Gerrit dashboard is the browser tab you kept open: the changes waiting on
your review, your own open changes, and what you have landed lately, in one
buffer.
Gerrit dashboard gerrit.example.com
Waiting for you (3)
4242 +2 foo: stop leaking the parser context Alice 1h ago
4118 -1 net: handle a truncated header Bo Lindqvist 1d ago
4301 docs: typo Chandra 5m ago
Your changes (1)
4300 baz: rework the retry loop Safae Ouajih 2h ago
Recently merged (1)
4290 build: bump the toolchain Safae Ouajih 4d ago
| Key | Action |
|---|---|
<CR> |
open the change |
d |
open its diff |
R |
review it |
gx |
open it in a browser |
r |
run every section again |
q |
close |
The sections are plain Gerrit queries, so they are yours to change:
dashboard = {
sections = {
{ title = 'Needs my vote', query = 'status:open reviewer:self NOT owner:self NOT label:Code-Review=2' },
{ title = 'Blocked', query = 'status:open owner:self label:Code-Review=-1' },
{ title = 'My topic', query = 'status:open topic:the-big-refactor' },
},
}Two things to know about them. They are sent exactly as written -- unlike
:Gerrit, they are neither scoped to the current project nor narrowed by
max_age, because a review waiting on you is waiting on you wherever you have
a shell open. And write NOT owner:self, not the -owner:self you would type
in the web UI: ssh hands the query over as plain arguments, and a word starting
with a dash is taken for a command line option long before Gerrit's parser sees
it.
:Gerrit watch polls the "waiting for you" query in the background and
notifies you about what has changed since the last look:
Alice is waiting for your review on 4242 foo: stop leaking the parser context
Nothing is announced twice. Which change was announced, and how recently it had
been touched, is remembered in a small JSON file under stdpath('state'), so
restarting Neovim does not replay your inbox at you; a reply on a change you
already knew about comes through as new activity on 4242, and several at once
arrive as a single notification rather than a stack of them.
Have it running from the start, and pick your own definition of "waiting":
watch = {
enabled = true,
query = 'status:open reviewer:self NOT owner:self',
interval = 300, -- seconds
}On Gerrit 3.3 and newer, attention:self is sharper than reviewer:self: it
is the same attention set the web UI's bold rows come from, so a change you
have already reviewed drops out of it until the ball is back in your court.
For a statusline:
require('gerrit').status() -- "Gerrit 3", or "" when nothing is waiting
require('gerrit').count() -- 3It is off by default: a plugin should not start talking to a server on a timer
just because it was installed. :checkhealth gerrit runs the watch query once
when it is on, since a query the server rejects would otherwise fail where
nobody can see it.
:Gerrit opens a picker; choosing a change shows its subject, owner, branch,
current votes, file list, commit message and the discussion so far.
| Key | Action |
|---|---|
<CR> |
open the diff |
R |
review this change |
gx |
open it in a browser |
r |
refresh |
q |
close |
The patch set is fetched with git fetch and shown as a unified diff. Every
line knows which file and line number it maps to on Gerrit's side, so a comment
lands where you put the cursor.
| Key | Action |
|---|---|
c |
comment on the cursor line, or on the visual selection |
X |
drop the draft comment under the cursor |
]c / [c |
jump between comments |
R |
review this change |
gf |
open the whole file at this line |
q |
close |
You do not have to be in the change's repository, or in any repository at all.
refs/changes/... lives on the Gerrit server rather than on your disk, so when
the change belongs to a project you have not cloned -- the usual case coming
out of the dashboard -- the patch set is fetched into a bare mirror under
mirror_dir instead, two commits deep and nothing more. Being in the project's
own checkout is still better, and is still preferred when it is what you have:
the objects are already there, and gf then opens the real, editable file
rather than the committed one.
Commenting on a diff --git header attaches the comment to the file as a
whole. Commenting on a removed line attaches it to the parent side, which is
what Gerrit does when you comment on the left-hand pane.
Comments already on the server are shown inline in grey; your unpublished drafts are shown in the warning colour with a marker in the sign column.
R opens a buffer holding the labels the change uses and room for a message:
# Change 4242 foo: stop leaking the parser context
# tools/thing main patch set 2
#
# 2 draft comments will be published with this review:
# src/foo.c:12 off by one here
# src/foo.c:40 why was this dropped?
#
# Vote by writing a value after a label; an empty value leaves it alone.
# Everything under the labels is the message. Lines starting with # are dropped.
# <C-s> publishes, q aborts.
Code-Review: +1
Verified:
Two nits inline, otherwise this looks right to me.
<C-s> (or :w) sends the message, the votes and every draft comment as a
single Gerrit review. q throws the buffer away and keeps the drafts.
Drafts survive quitting Neovim they are mirrored to a small JSON file under
stdpath('state') and are dropped only once the review is published.
Defaults, all optional:
require('gerrit').setup {
-- The server. Both are read from the git remote when left nil, which is
-- what makes the plugin work across several Gerrit servers unconfigured.
-- Set `host` to an ~/.ssh/config alias and leave `port` nil to let ssh
-- supply the port.
host = nil,
port = nil,
ssh_args = { '-o', 'BatchMode=yes' },
remote = nil, -- git remote to fetch patch sets from; nil tries gerrit, review, origin
project = nil, -- project name; nil reads it from the remote URL
query = 'status:open', -- the default query for `:Gerrit`
scope_to_project = true, -- narrow that query to the current project
max_age = '2w', -- and to the changes touched that recently; nil for all of them
limit = 100, -- most changes a query will return
labels = { 'Code-Review', 'Verified' }, -- offered when the change lists none
unresolved = true, -- publish comments as needing a reply
timeout = 30000, -- ms before an ssh or git call is abandoned
browser = nil, -- command for the browse key; nil uses vim.ui.open
draft_file = vim.fs.joinpath(vim.fn.stdpath 'state', 'gerrit-drafts.json'),
-- Where patch sets of projects you have not cloned are fetched to.
mirror_dir = vim.fs.joinpath(vim.fn.stdpath 'cache', 'gerrit.nvim'),
-- Sent as written: not scoped to the project, not narrowed by max_age.
dashboard = {
sections = {
{ title = 'Waiting for you', query = 'status:open reviewer:self NOT owner:self' },
{ title = 'Your changes', query = 'status:open owner:self' },
{ title = 'Recently merged', query = 'status:merged owner:self limit:10' },
},
},
watch = {
enabled = false, -- poll for reviews waiting on you, in the background
query = 'status:open reviewer:self NOT owner:self',
interval = 300, -- seconds; anything under 60 is rounded up to it
state_file = vim.fs.joinpath(vim.fn.stdpath 'state', 'gerrit-watch.json'),
},
keys = {
change = { diff = '<CR>', review = 'R', browse = 'gx', refresh = 'r', quit = 'q' },
diff = {
comment = 'c',
delete = 'X',
next = ']c',
prev = '[c',
review = 'R',
edit = 'gf',
quit = 'q',
},
review = { publish = '<C-s>', quit = 'q' },
dashboard = { open = '<CR>', diff = 'd', review = 'R', browse = 'gx', refresh = 'r', quit = 'q' },
},
}Set any key to false to leave it unmapped. The options that are lists --
ssh_args, labels, dashboard.sections -- replace the defaults rather than
merging with them, so three sections in means three sections out.
status:open on a project that has been running for years matches every change
anybody ever left hanging, and the server walks all of them before the first
row reaches us. :Gerrit and :Gerrit mine therefore ask only for the changes
somebody has touched in the last max_age, which shows up in the picker title:
status:open NOT age:2w
Any Gerrit age will do: 36h, 2w, 3mon, 1y. Set max_age = nil for the
old, exhaustive listing. A query you write yourself is never narrowed, so
:Gerrit status:open is the one-off way to see everything, and
:Gerrit open 12345 opens a change however long it has been sitting there.
Nothing needs to be configured per project. The ssh destination and the project
name come from the git remote of the repository the current buffer lives in, so
one install covers every Gerrit you work with. host is only needed when your
remote is an HTTPS URL, since an HTTPS remote says nothing about where ssh
should connect.
:checkhealth gerrit reports which server was worked out, from which remote,
and whether it answers. The three things it usually catches:
- the host does not resolve you are off the VPN
- ssh asks for a passphrase your key is not in the agent (
ssh-add -l) - no remote looks like Gerrit set
hostinsetup()
gerrit query --format=JSONlists and describes changesgit fetch <remote> refs/changes/NN/CHANGE/PATCHSETbrings the patch set localgit diff-tree --root -prenders itgerrit review --json <change>,<patchset>publishes the message, the votes and the inline comments as one GerritReviewInputon stdin
Sending the review as JSON on stdin is what makes inline comments possible over ssh, and it sidesteps having to quote a multi-line message onto a command line.
