Reviewing

otisk's review model is one commit per change. You amend it rather than stacking fix-ups on top, and each push of it is kept as a revision, so nothing you pushed is ever lost. Concepts explains why it is shaped that way and how it differs from a pull request; this page is how to use it.

A change

From inside a clone:

otisk change hook                    # new commits get a Change-Id trailer

echo "a first idea" > idea.md
git add idea.md && git commit -m "Write down an idea"
otisk change push                    # git push origin HEAD:refs/for/main
otisk: change 1 revision 1 -- Write down an idea
To ssh://git.otisk.dev/yuritomanek/notes.git
 * [new reference]   HEAD -> refs/changes/1/1

Revise it by amending, and push again:

echo "a better idea" > idea.md
git add idea.md && git commit --amend --no-edit
otisk change push                    # revision 2 of the same change

otisk change list                    # open changes
otisk change diff 1                  # what changed between revisions 1 and 2
otisk change land 1                  # fast-forward main to revision 2

The Change-Id trailer is what makes an amended commit the same change, so keep it when you amend. Landing only ever fast-forwards: if main has moved on, rebase onto it and push a new revision first.

Several at once

A push may carry several commits, and each one becomes its own change, stacked in order:

git commit -m "Add a lexer"        # on top of the change above
otisk change push                  # both commits, a change each
otisk: change 1: no new changes
otisk: change 2 revision 1 -- Add a lexer

The commits are reported bottom-up: the one already pushed has nothing new, and the one on top became change 2.

Amend any of them and push again, and each change gains a revision; nothing new means nothing recorded. Land them from the bottom up — a change whose parent has not landed is refused. If any commit in the push is unusable, for example because it has no Change-Id, the whole push is refused and nothing is recorded, naming the commit at fault.

Comments

Comments go on a change, or on a line of one of its revisions:

otisk change comment 1 --file idea.md --line 1 -m "is this the best idea?"
otisk change comment 1 -m "otherwise this looks fine"     # about the change
otisk change reply 1 1 -m "probably not, but it is mine"  # thread 1
otisk change comments 1
t1  open r2 idea.md:1
    yuritomanek  2026-09-18 10:02
      is this the best idea?
    yuritomanek  2026-09-18 10:05
      probably not, but it is mine

A comment names the revision and line it was written about, and stays there when you push a new revision, so it always points at what was actually read. Mark one dealt with, or bring it back:

otisk change resolve 1 1
otisk change comments 1 --all     # resolved threads too
otisk change reopen 1 1

otisk change show 1 counts the threads still open, and otisk change list marks changes that have any, so nothing is landed with a question outstanding unless you mean it.

Comments live in the repository, as a commit per comment on refs/changes/<number>/meta. They are stored, replicated, and restored exactly as your code is, and you can read them with git alone:

git fetch origin refs/changes/1/meta:refs/otisk/meta && git log refs/otisk/meta

All of it in one place

Everything above is also a terminal interface:

otisk ui          # in a clone; or otisk ui --repo yuritomanek/notes
┌─ 1 yuritomanek/notes — open changes ─────────┐┌─ 3 revisions ────────────────────────────────┐
│> 1  open  r2  main  (1)  Write down an idea  ││  change 1 (open) → main                      │
│                                              ││  Change-Id: I3f2a9c7e1b4d8a60f5c2e9b17d4a8c3…│
│                                              ││> r1  b71d4e05c3a9  Write down an idea        │
│                                              ││  r2  8f1c0b2a9d44  Write down an idea        │
│                                              ││                                              │
└──────────────────────────────────────────────┘└──────────────────────────────────────────────┘
┌─ 2 local work — origin/main..HEAD ───────────┐┌─ 4 comments ─────────────────────────────────┐
│> new change  Say why it is worth writing down││> t1  open r2 idea.md:1                       │
│                                              ││      yuritomanek  say why, not what          │
│                                              ││                                              │
│                                              ││                                              │
└──────────────────────────────────────────────┘└──────────────────────────────────────────────┘

Four panels: the repository's changes and the commits you have not pushed yet on the left, the selected change's revisions and conversation on the right. Moving in the change list changes what the right-hand panels show, so reading a change never costs you sight of the others. tab moves to the next panel and 14 straight to one — the number each panel wears. The line below names the panel the keys are in and what it offers, and ? shows every panel's keys. Each panel scrolls when it holds more than fits, and says on its border how much is out of sight.

local work is what git log <remote>/<branch>..HEAD holds, which is what the panel is titled — the branch being the one the selected change targets, or the repository's default, and fetched first so that landing something elsewhere takes it off the list. Each commit is labelled with what pushing it would do: start a new change, add a revision to change N, or nothing, because it is already the latest revision, has landed, or has no Change-Id. p pushes them for review, as one series, and what the host answers is what you then see.

In changes, a lists every change, l lands, x abandons, and c comments. In revisions, d shows the selected revision's diff and D compares the last two — both in your pager, through git, returning when you quit it. In comments, R replies and s resolves or reopens. Colour says what a change's status is, and which threads are still open.

It does nothing the commands cannot: each keystroke is one of them, over one reused ssh connection, so a refusal reads exactly as it does in the shell.