Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Issues
Your issues belong to the repository, not to a vendor. They clone with the code, keep working when a hosting service has an outage, and stay readable in any text editor.
An issue is a Markdown file stored on a special branch called vole. Every clone
of the repository carries the issues and their history, so there is no separate
issue database to back up.
Where issues live
issues/<ulid>/issue.md # the issue
issues/<ulid>/comments/<ulid>.md # a comment
Here is a complete issue:
---
title: "Nothing renders on the docs page"
state: open
author: you
created: 2026-10-22T21:30:00Z
labels:
- bug
---
Steps to reproduce...
The top section, between the --- lines, is YAML metadata. The rest is the
Markdown body. Titles and other free text are always quoted, so characters like
# or a leading - cannot change the meaning of the file.
An issue id is a 26-character ULID. It uses uppercase letters and digits, but not
I, L, O, or U. A UUID will not work.
Numbers
Nobody types 26 characters, so every issue and change request also has a small
number: [#1](/r/vole/n/1), [#42](/r/vole/n/42). Issues and change requests share one sequence, oldest first,
the way one forge counts both in a single run.
The number is derived, not stored. It is the position of the commit that
created the entity in the metadata branch’s history. Nothing is added to the file,
no counter is kept, and there is nothing to migrate: an existing repository gets
its numbers the first time anyone asks. Two clones of the same history produce the
same numbers, and vole admin index rebuild reproduces them from git.
The ULID is still the identity — it is the path, the front matter’s neighbour in
closes:, the thing in the commit messages — so a number is a convenience, not a
reference you should quote in a script. The one way to make one wrong is to rewrite
the branch history: an admin redaction force-push renumbers everything after the
commit it rewrote.
Every command that takes an id takes a number instead, with or without the #:
vole issue show 42
vole issue close '#42'
vole change show 1
Writing an issue without the CLI
git switch --orphan vole
ID=01M5JR2JK29KKMAA1T9MDNTPBP
mkdir -p issues/$ID
cat > issues/$ID/issue.md <<'MD'
---
title: "Nothing renders on the docs page"
state: open
author: you
created: 2026-10-22T21:30:00Z
labels:
- bug
---
Steps to reproduce...
MD
git add -A
git commit -m "issue: docs page blank"
git push origin vole
Vole checks the file when you push. The author field must be the account you push
as unless you hold maintain, which is the one place the browser is stricter than a
push: a contributor writes as itself. If someone else pushed first, the CLI puts your
change on top of theirs and tries again.
From the browser
When browser sign-in is enabled (see Accounts and access), a signed-in contributor or maintainer can open an issue or comment from the repository’s issue page. The author is the account whose passkey opened the session; the form cannot choose a different name. The change is written with the same metadata writer the CLI uses, so it is the same file on the same branch. A contributor can use this form or the CLI; pre-receive asks the same capabilities the form does, so neither offers more than the other.
The issue’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 discussion. The discussion, labels, and assignees are left alone, and the edit is an ordinary metadata commit like the one the CLI writes.
Using the CLI
vole issue new --title "Nothing renders on the docs page" -m "Steps..." -l bug -a wilbur
vole issue comment -m "Also affects search" 01M5JR2JK29KKMAA1T9MDNTPBP
vole issue edit-comment 01M5JR2JK29KKMAA1T9MDNTPBP 01M5N1PRC4GJPZNEEG8PR8J4D6 -m "Corrected."
vole issue rm-comment 01M5JR2JK29KKMAA1T9MDNTPBP 01M5N1PRC4GJPZNEEG8PR8J4D6
vole issue edit 01M5JR2JK29KKMAA1T9MDNTPBP --title "A better title" -l bug -l docs
vole issue assign 01M5JR2JK29KKMAA1T9MDNTPBP wilbur
vole issue unassign 01M5JR2JK29KKMAA1T9MDNTPBP wilbur
vole issue close 01M5JR2JK29KKMAA1T9MDNTPBP
vole issue reopen 01M5JR2JK29KKMAA1T9MDNTPBP
vole issue list --state open -l bug --assignee wilbur --format '%(number) %(title)'
vole issue show 01M5JR2JK29KKMAA1T9MDNTPBP # a prefix is enough
vole issue show 42 # the number is enough
--format understands %(number), %(id), %(shortid), %(state), %(author),
%(title), %(labels), %(assignees), and %(created). %(shortid) is a
prefix of the ULID, which is mostly timestamp; %(number) is the alias people
read and quote.
The CLI commits and pushes for you. It uses the account your SSH key belongs to,
not the name in git config. If you want to commit without pushing, add
--no-push.
You can also create an issue in a new directory with no remote configured:
git init bug-report
cd bug-report
vole issue new --title "the docs page is blank" \
--remote ssh://git@forge.example.com/demo.git
Rules
- Issues and comments live under
issues/andchanges/on thevolebranch. Other paths are rejected. Releases have their own paths, written by Vole. CI records are not stored on the branch; they live indata/runs.db. - History on the
volebranch must be linear. Merge commits are not allowed. - Force-pushing
volerequires repository admin and is recorded indata/audit.log. It is meant for redaction, not normal work. - Writing
[#42](/r/vole/n/42)in a body links to that entity. Because one sequence holds both kinds, the link goes through/n/42, and the server resolves it to the issue or the change request that holds the number.#<ulid>links by id and is the form that keeps working if history is ever rewritten. Commits that mention an id are shown as backlinks on the issue page. - Writing
@namelinks to that account’s profile. It is the same syntax a workflow’smentionedfilter matches, so the page and a job agree about who was addressed. Code samples are skipped, and a name that is not an account stays text. - An assignee must be a valid account name: lowercase letters, digits,
-, and_. The name is stored as written because account names are never reused. A value that is not an account name cannot be an assignee. - The web page can filter by state, label, and assignee, and search titles and bodies.
Comments
A comment is a file at issues/<id>/comments/<ulid>.md with author,
created, and optionally reply_to metadata. reply_to names any comment in
the same thread. The web renders one level of nesting by collecting a root’s
descendants beneath it.
Only the author of a comment can edit or delete it. Vole compares the author on
the old version of the file with the account behind the SSH key. An admin can
override this, for example to repair a thread after someone leaves, and the
override is recorded in data/audit.log.
Deleting a comment replaces it with a tombstone (“Comment deleted”) so replies keep the thread they answer. The text remains in Git history, and a deleted comment cannot be edited. Editing a comment does not add an “edited” field; the commit carries the time. Deleting is not redaction. Redaction means an admin force-push.
The web comment menu offers Reply, Copy, Edit and Delete. Editing and deleting are limited to the author or an admin, and deletion leaves a tombstone so replies keep their thread.
The issue itself works differently. Closing, relabeling, or assigning someone else’s issue is normal triage, not a claim of authorship.