Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Change requests
Review should not depend on a web session or a vendor. In Vole, a change request is a normal branch and a normal push. A request opened from a branch follows that branch: every push moves the request’s head, so an approval always names the exact commit it reviewed and a moved branch starts the review over.
Files behind a request
changes/<ulid>/change.md # title, state, author, base, head, closes
changes/<ulid>/comments/<ulid>.md # discussion
changes/<ulid>/approvals/<account-name>.md # one approval per account
Opening and landing a request
A request starts from a branch you may push. Which branches those are depends on your role:
- A
maintainoradminaccount may push shared branches, so it can propose any ordinary branch in the repository. - A
contributeaccount may propose only a branch in its own namespace,refs/heads/contrib/<name>/.
The Open change request form on the repository’s change-request page lists
exactly the branches your account may propose; pre-receive checks the branch the
request names the same way. A contribute account cannot name an arbitrary
commit or another account’s branch. The namespace is the account name, which is
fixed at creation. CI for a branch under refs/heads/contrib/ is held until a
maintainer allows the exact tip from the repository page; a shared branch runs CI
when it is pushed.
The change’s author or a maintainer can correct its title with the pencil beside the heading, or its description from the card’s menu. Each opens its own field in place, so saving does not leave the page or replace the review. The prose is not what an approval names — the base and head are — so the review and its approvals stay as they are, and a closed or merged record can be corrected too.
A maintainer can open a request from a shared branch with the CLI:
# The author pushes a branch and opens a request for the issue it fixes.
git push origin feature:refs/heads/feature
vole change new --title "Retry on timeout" --base main --head feature \
--closes 01M5JR2JK29KKMAA1T9MDNTPBP -m "Rationale."
# An approver reviews that commit and approves it.
vole change approve <id> --as <your-name>
# The author lands it. Vole checks that the approval still matches the new tip.
git push origin feature:refs/heads/main
The request records the branch it follows in head_ref: and the commit currently
under review in head:. A branch is resolved on the remote, not in the local
clone: if the clone is behind, the request records the remote tip and says so on
stderr, because that is the commit the forge will review and land. A branch that
is only local is recorded too, with a note that it is not on the remote yet. A
request whose head is already in its base — the same commit, or an ancestor — is
refused: it would review nothing. The request page, the request list and
vole change show show both. After every accepted push, Vole moves head: to the new tip of the
followed branch, so a fixup does not need a second command. Approvals and checks
still name a commit: the approvals that named the old tip stop counting, and the new
tip needs its own CI result. An approval is never carried silently to a commit nobody
reviewed — the approver’s form names the base and head it displayed, and the writer
refuses if a push or a retarget moved either one in between.
A request opened from a bare commit id records no branch and stays pinned to that
commit. vole change update <id> --head <branch> makes an open request follow a
branch (or a different one); --head <commit> pins it to one commit. The request
page’s base/head picker does the same. Neither is needed after a normal push: the
request already follows the branch it was opened from. A branch that is deleted
leaves the last tip it saw in head: and the page says the branch is gone.
Other commands:
vole change list [--state open|closed]
vole change show <id>
vole change update <id> [--base <branch>] [--head <rev>] [--closes <issue>]... [--no-closes]
vole change close <id>
vole change reopen <id>
vole change show <id> also tells you whether the branch still merges, when the
needed commits are available locally. If they are not, it says so rather than
guessing.
Stacked requests
A request may target another feature branch instead of main. Push the lower
branch first, then open the upper request with --base <lower-branch> --head <upper-branch>. Its diff is only the upper change, not everything the lower branch
adds as well.
When the lower request lands, retarget the upper one:
vole change update <id> --base main
The request page’s target picker changes the base the same way. A new base changes the diff, and every approval named the old base, so the upper request needs a fresh review against the new base. Changing the base does not change the branch the request follows; pick a different head branch too only if the upper branch itself moved.
A landing moves the base branch without a push, and Vole still moves the open requests that follow that branch onto the landed commit — the same rule a push to the branch applies. So a lower request follows its branch when an upper request lands into it, with no separate command.
Linking the issues it closes
A change request names the issues it fixes in its own metadata, not in its
description: change.md carries a closes list, the request page shows those
issues, and landing the request closes them in the same commit that marks it
merged. A [#42](/vole/n/42), #<ulid> or Fixes [#42](/vole/n/42) in the description is only prose — it
does not link anything and closes nothing.
Set the list when the request is opened, or later while it is still open:
vole change new --title "Retry on timeout" --base main --head feature \
--closes 01M5JR2JK29KKMAA1T9MDNTPBP,01M5N1PRC4GJPZNEEG8PR8J4D6
vole change new --title "Retry on timeout" --base main --head feature \
--closes 42,43 # the same list by number
vole change update <id> --closes 42 # replace the list
vole change update <id> --no-closes # clear it
The flag takes a full ULID or the [#42](/vole/n/42) number, and 42 is resolved to the ULID
before the link is written, so the file always holds ids. The issue has to exist
when the link is written, so a typo fails while the author is still looking at the
command, and a number that names a change request is refused: a request cannot
close a request. The browser’s Open change request form takes the same list in
its Closes field.
A number is shared with issues, so [#42](/vole/n/42) may name either kind and the server
resolves it when the link is rendered. closes: is not where that resolution
lives: it holds ULIDs, because that is the reference that survives a rewritten
history.
A repository can require a linked issue for every new change request. The checkbox is in Settings → Access, and the same rule is available to an instance admin from the terminal:
vole admin acl require-issues enable demo
The request page marks Closes as required, the browser refuses an unlinked request, and the push gate refuses one added through the CLI or a hand-written metadata commit. Existing requests are unaffected: turning the setting on does not invalidate history, and turning it off allows new requests with no link again.
Only an open request may be relinked: a merged request’s list records what the
merge did, and a closed one is not going to land. The request page’s Issues
section lists the linked issues in the same rows as the overview’s recent issues.
Its search box finds issues by title, #number, or ULID; picking a result links
it, and the unlink icon removes one. Both controls use the same closes list as
the CLI, and only the request’s author or a maintainer may change it. Landing is
what closes an issue; linking one does not, and a request that is closed without
landing leaves its issues open.
How approvals work
A protected branch only accepts an update that carries the reviewed head. When nothing else has landed, the update is the head itself. When the branch has moved, Vole builds the merge, tests that, and lands the commit it tested.
The approval names the base branch and the head, not the commit the base currently points at. A base move therefore retests the combined result but does not invalidate the approval: nobody has to rebase or re-approve just because something else landed first.
Adding a commit to the branch after approval still does not carry the old approval. The request follows its branch, so the push moves its head and the approval names a commit that is no longer the head.
“Merge, then push” is accepted only when the pushed merge commit has the tree a
clean merge of the branch tip and the reviewed head would produce, and the combined
result has passed. A merge commit with extra content, or with conflicts resolved by
hand, is refused: it is content nobody reviewed under that approval. The Merge
button and vole change merge are the supported path.
The combined result
For a landing that needs a merge commit, Vole builds it and runs the repository’s workflows against that tree. A combined result is tested when a head is pushed, and again when the base moves if the request is under review or already waiting to merge. The server looks at open requests on a short interval, so a burst of work-in-progress pushes settles on the head that is actually there before a run is queued.
The combined result uses the workflow files at the merge, so a workflow change on the base applies to the landing. It is a normal run for the base branch, but it does not deploy the repository’s site.
A landing lands that same commit, so the push that moves the branch does not run those jobs a second time: the tree, the workflow and the job already ran for this branch, and the run list shows the run that tested it once. A job that deploys the site is the exception — a combined result never deploys — so that one still runs on the branch, because publishing the site is work the combined result did not do. A combined result derived from a contributor branch is still untrusted code: it runs only after that exact head was allowed, and it uses the contributor’s cache namespace.
The request page shows one status:
- Passed with the latest main — Merge lands immediately.
- Testing the merge with the latest main — Merge becomes Merge when ready and lands as soon as the run passes.
- The combined result failed — the runs name the job. Pushing a fix starts a new review, because the head changes.
- Conflicts with the latest main — resolve them on the branch; the server does not invent a resolution.
Merge when ready survives the base moving. It stops when the head changes, the
request closes, the actor loses merge permission, or the request conflicts. Every
landing re-checks the approvals, the actor’s permission, and the combined result at
the moment the branch moves. vole change merge does the same thing from the
terminal, and vole change show prints the combined result and whether it is ready.
An approval can also be recorded from the request page in the browser when the signed-in account has maintain access. The approver is the session’s account name, not a name from the page. The page’s Approve form carries the base and head it displayed; if a push moved the branch, or the request was retargeted, before the form was submitted, the approval is refused and the approver sees the new target instead of approving it unseen.
An approval is stored at approvals/<account-name>.md. Only the account named in
that path can create, change, or remove it. change.md records the author’s
account name in author; that is the only author field and it is what blocks
self-approval. An admin can repair an
approval, and the override is recorded in data/audit.log.
An approval counts only while its author still has the standing to give it: the
account must be active and must currently hold maintain or admin on the
repository. Retiring an account or reducing its role therefore withdraws its
approvals without editing any file — the file stays on the branch, but the gate
stops counting it, and the page shows it as no longer counts rather than as a
review the landing will accept. The page and the gate use the same rule, so a
request page cannot promise a merge that the push then refuses.
The request page has three tabs. Conversation holds the description,
discussion, approvals and checks. Files is the review surface: a tree of the
changed files beside the selected file’s diff, with line comments and their
composer in place. Runs lists the workflow results recorded for the head
commit, with each run’s duration and attempts, and links to the run page, step
logs and full log. vole change show prints the same runs as one line each, with the
26-character id, so a failed check can be opened with vole run show <id> and read
with vole run logs <id>. It also lists the combined result’s runs when the landing
needs one, because that commit can fail separately from the head.
Together they show:
- the changes compared with the branch it would merge into,
- CI checks for the head commit,
- the combined result that would land, and whether it has passed,
- how many approvals are required and who approved which commit,
- whether the branch still merges cleanly.
Vole checks the last item with Git itself, without touching a working copy. It is shown for information, not enforced. Vole leaves it out when it cannot answer — for example, if the base branch no longer exists.
A merged request keeps showing the change it landed. The mark records
merged_base, the tip the base pointed at before it moved, because afterwards the
head is in the base and a diff of the two is empty. That is what the Files tab
diffs when the request is already merged.
The change list
The change list shows one status per row, so the queue can be read without opening every change:
- Waiting on CI approval — a contributor branch at this commit has not been allowed by a maintainer yet.
- Waiting on CI jobs — checks for the commit are queued or running.
- CI failed — a check failed; pushing a fix starts a new review.
- CI could not run — a runner could not start a job for this commit, so nothing was tested. The commit is not what failed: rerun the job once the machine can build, and the check takes the new result.
- Waiting for review — fewer current approvals than the base branch requires.
- Merge conflict — the branch no longer merges into its base cleanly.
- Ready to merge — the required approvals are in (or none are required) and the commit’s checks passed.
- Merged, Closed — the change landed, or was closed without landing.
The list also shows when each change was last updated: the newest commit that touched its directory, which covers an edit, a comment, and an approval. It sorts by that, so the change that moved most recently is first.
Review comments
vole change comment <id> -m "Does this hold for the empty repository case?"
vole change comment <id> -m "Good catch, amended." # prints the comment id
vole change comment <id> --reply-to <comment-id> -m "A follow-up."
vole change edit-comment <id> <comment-id> -m "Rephrased."
vole change rm-comment <id> <comment-id>
A comment can reply to any comment with --reply-to. The web shows one level of
nesting by collecting a root’s descendants under it. Each comment has a context menu with
Reply, Copy, Edit and Delete. Reply targets the one composer at the foot of the
discussion; Cancel reply clears the target without clearing the draft. Replies
can be folded under their root, while the root comment stays visible. Editing and
deleting are limited to the author or an admin. Deleting a comment leaves a
tombstone so its replies keep the thread they answer.
Only the author can edit or delete a comment. An admin can override that, and the
override is recorded in data/audit.log. Comments appear in the Conversation tab
with an anchor for each one (#c-<ulid>); a line comment also appears beneath its
line in the Files tab (#inline-c-<ulid>). A line comment in the conversation
carries a collapsed excerpt of the lines around it, read from the exact blob the
comment named, so a stale comment still shows the code that was reviewed.
You can attach a comment to a line on either side of the diff:
vole change comment <id> -m "this drops the check" \
--path internal/auth.go --line 88
vole change comment <id> -m "and this was the old line" \
--path internal/auth.go --line 88 --side old
The comment remembers the version of the file it was written about, not just the
line number. If the file changes, the web page marks the comment as out of date.
The CLI needs the commits in your clone; if they are missing, it tells you to run
git fetch instead of guessing.
Branch rules
vole admin acl protect add demo refs/heads/main # protect a branch
vole admin acl set-approvals demo 2 # require two approvals
vole admin acl set-approvals demo 0 # back to admin-only
vole admin acl checks enable demo # also require passing CI checks
Admins can push a protected branch directly. Every merge through review is
recorded in data/audit.log with the request id and approvers. For the runner
allowlist behind required checks, see
Runners.