vole
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.
| 1 | # Issues |
| 2 | |
| 3 | Your issues belong to the repository, not to a vendor. They clone with the code, |
| 4 | keep working when a hosting service has an outage, and stay readable in any text |
| 5 | editor. |
| 6 | |
| 7 | An issue is a Markdown file stored on a special branch called `vole`. Every clone |
| 8 | of the repository carries the issues and their history, so there is no separate |
| 9 | issue database to back up. |
| 10 | |
| 11 | ## Where issues live |
| 12 | |
| 13 | ```text |
| 14 | issues/<ulid>/issue.md # the issue |
| 15 | issues/<ulid>/comments/<ulid>.md # a comment |
| 16 | ``` |
| 17 | |
| 18 | Here is a complete issue: |
| 19 | |
| 20 | ```markdown |
| 21 | --- |
| 22 | title: "Nothing renders on the docs page" |
| 23 | state: open |
| 24 | author: you |
| 25 | created: 2026-10-22T21:30:00Z |
| 26 | labels: |
| 27 | - bug |
| 28 | --- |
| 29 | Steps to reproduce... |
| 30 | ``` |
| 31 | |
| 32 | The top section, between the `---` lines, is YAML metadata. The rest is the |
| 33 | Markdown body. Titles and other free text are always quoted, so characters like |
| 34 | `#` or a leading `-` cannot change the meaning of the file. |
| 35 | |
| 36 | An issue id is a 26-character ULID. It uses uppercase letters and digits, but not |
| 37 | `I`, `L`, `O`, or `U`. A UUID will not work. |
| 38 | |
| 39 | ## Numbers |
| 40 | |
| 41 | Nobody types 26 characters, so every issue and change request also has a small |
| 42 | number: `#1`, `#42`. Issues and change requests share one sequence, oldest first, |
| 43 | the way one forge counts both in a single run. |
| 44 | |
| 45 | The number is **derived, not stored**. It is the position of the commit that |
| 46 | created the entity in the metadata branch's history. Nothing is added to the file, |
| 47 | no counter is kept, and there is nothing to migrate: an existing repository gets |
| 48 | its numbers the first time anyone asks. Two clones of the same history produce the |
| 49 | same numbers, and `vole admin index rebuild` reproduces them from git. |
| 50 | |
| 51 | The ULID is still the identity — it is the path, the front matter's neighbour in |
| 52 | `closes:`, the thing in the commit messages — so a number is a convenience, not a |
| 53 | reference you should quote in a script. The one way to make one wrong is to rewrite |
| 54 | the branch history: an admin redaction force-push renumbers everything after the |
| 55 | commit it rewrote. |
| 56 | |
| 57 | Every command that takes an id takes a number instead, with or without the `#`: |
| 58 | |
| 59 | ```sh |
| 60 | vole issue show 42 |
| 61 | vole issue close '#42' |
| 62 | vole change show 1 |
| 63 | ``` |
| 64 | |
| 65 | ## Writing an issue without the CLI |
| 66 | |
| 67 | ```sh |
| 68 | git switch --orphan vole |
| 69 | ID=01M5JR2JK29KKMAA1T9MDNTPBP |
| 70 | mkdir -p issues/$ID |
| 71 | cat > issues/$ID/issue.md <<'MD' |
| 72 | --- |
| 73 | title: "Nothing renders on the docs page" |
| 74 | state: open |
| 75 | author: you |
| 76 | created: 2026-10-22T21:30:00Z |
| 77 | labels: |
| 78 | - bug |
| 79 | --- |
| 80 | Steps to reproduce... |
| 81 | MD |
| 82 | git add -A |
| 83 | git commit -m "issue: docs page blank" |
| 84 | git push origin vole |
| 85 | ``` |
| 86 | |
| 87 | Vole checks the file when you push. The `author` field must be the account you push |
| 88 | as unless you hold `maintain`, which is the one place the browser is stricter than a |
| 89 | push: a contributor writes as itself. If someone else pushed first, the CLI puts your |
| 90 | change on top of theirs and tries again. |
| 91 | |
| 92 | ## From the browser |
| 93 | |
| 94 | When browser sign-in is enabled (see [Accounts and access](accounts-and-access.md#browser-sign-in)), |
| 95 | a signed-in contributor or maintainer can open an issue or comment from the |
| 96 | repository's issue page. The author is the account whose passkey opened the |
| 97 | session; the form cannot choose a different name. The change is written with the |
| 98 | same metadata writer the CLI uses, so it is the same file on the same branch. A |
| 99 | contributor can use this form or the CLI; pre-receive asks the same capabilities the |
| 100 | form does, so neither offers more than the other. |
| 101 | |
| 102 | The issue's author or a maintainer can correct its title with the pencil beside |
| 103 | the heading, or its description from the card's menu. Each opens its own field in |
| 104 | place, so saving does not leave the page or replace the discussion. The discussion, |
| 105 | labels, and assignees are left alone, and the edit is an ordinary metadata commit |
| 106 | like the one the CLI writes. |
| 107 | |
| 108 | ## Using the CLI |
| 109 | |
| 110 | ```sh |
| 111 | vole issue new --title "Nothing renders on the docs page" -m "Steps..." -l bug -a wilbur |
| 112 | vole issue comment -m "Also affects search" 01M5JR2JK29KKMAA1T9MDNTPBP |
| 113 | vole issue edit-comment 01M5JR2JK29KKMAA1T9MDNTPBP 01M5N1PRC4GJPZNEEG8PR8J4D6 -m "Corrected." |
| 114 | vole issue rm-comment 01M5JR2JK29KKMAA1T9MDNTPBP 01M5N1PRC4GJPZNEEG8PR8J4D6 |
| 115 | vole issue edit 01M5JR2JK29KKMAA1T9MDNTPBP --title "A better title" -l bug -l docs |
| 116 | vole issue assign 01M5JR2JK29KKMAA1T9MDNTPBP wilbur |
| 117 | vole issue unassign 01M5JR2JK29KKMAA1T9MDNTPBP wilbur |
| 118 | vole issue close 01M5JR2JK29KKMAA1T9MDNTPBP |
| 119 | vole issue reopen 01M5JR2JK29KKMAA1T9MDNTPBP |
| 120 | vole issue list --state open -l bug --assignee wilbur --format '%(number) %(title)' |
| 121 | vole issue show 01M5JR2JK29KKMAA1T9MDNTPBP # a prefix is enough |
| 122 | vole issue show 42 # the number is enough |
| 123 | ``` |
| 124 | |
| 125 | `--format` understands `%(number)`, `%(id)`, `%(shortid)`, `%(state)`, `%(author)`, |
| 126 | `%(title)`, `%(labels)`, `%(assignees)`, and `%(created)`. `%(shortid)` is a |
| 127 | prefix of the ULID, which is mostly timestamp; `%(number)` is the alias people |
| 128 | read and quote. |
| 129 | |
| 130 | The CLI commits and pushes for you. It uses the account your SSH key belongs to, |
| 131 | not the name in `git config`. If you want to commit without pushing, add |
| 132 | `--no-push`. |
| 133 | |
| 134 | You can also create an issue in a new directory with no remote configured: |
| 135 | |
| 136 | ```sh |
| 137 | git init bug-report |
| 138 | cd bug-report |
| 139 | vole issue new --title "the docs page is blank" \ |
| 140 | --remote ssh://git@forge.example.com/demo.git |
| 141 | ``` |
| 142 | |
| 143 | ## Rules |
| 144 | |
| 145 | - Issues and comments live under `issues/` and `changes/` on the `vole` branch. |
| 146 | Other paths are rejected. Releases have their own paths, written by Vole. CI |
| 147 | records are not stored on the branch; they live in `data/runs.db`. |
| 148 | - History on the `vole` branch must be linear. Merge commits are not allowed. |
| 149 | - Force-pushing `vole` requires repository admin and is recorded in |
| 150 | `data/audit.log`. It is meant for redaction, not normal work. |
| 151 | - Writing `#42` in a body links to that entity. Because one sequence holds both |
| 152 | kinds, the link goes through `/n/42`, and the server resolves it to the issue or |
| 153 | the change request that holds the number. `#<ulid>` links by id and is the form |
| 154 | that keeps working if history is ever rewritten. Commits that mention an id are |
| 155 | shown as backlinks on the issue page. |
| 156 | - Writing `@name` links to that account's profile. It is the same syntax a |
| 157 | workflow's `mentioned` filter matches, so the page and a job agree about who was |
| 158 | addressed. Code samples are skipped, and a name that is not an account stays |
| 159 | text. |
| 160 | - An assignee must be a valid account name: lowercase letters, digits, `-`, and |
| 161 | `_`. The name is stored as written because account names are never reused. A |
| 162 | value that is not an account name cannot be an assignee. |
| 163 | - The web page can filter by state, label, and assignee, and search titles and |
| 164 | bodies. |
| 165 | |
| 166 | ## Comments |
| 167 | |
| 168 | A comment is a file at `issues/<id>/comments/<ulid>.md` with `author`, |
| 169 | `created`, and optionally `reply_to` metadata. `reply_to` names any comment in |
| 170 | the same thread. The web renders one level of nesting by collecting a root's |
| 171 | descendants beneath it. |
| 172 | |
| 173 | Only the author of a comment can edit or delete it. Vole compares the author on |
| 174 | the old version of the file with the account behind the SSH key. An admin can |
| 175 | override this, for example to repair a thread after someone leaves, and the |
| 176 | override is recorded in `data/audit.log`. |
| 177 | |
| 178 | Deleting a comment replaces it with a tombstone ("Comment deleted") so replies |
| 179 | keep the thread they answer. The text remains in Git history, and a deleted |
| 180 | comment cannot be edited. Editing a comment does not add an "edited" field; the |
| 181 | commit carries the time. Deleting is not redaction. Redaction means an admin |
| 182 | force-push. |
| 183 | |
| 184 | The web comment menu offers Reply, Copy, Edit and Delete. Editing and deleting |
| 185 | are limited to the author or an admin, and deletion leaves a tombstone so replies |
| 186 | keep their thread. |
| 187 | |
| 188 | The issue itself works differently. Closing, relabeling, or assigning someone |
| 189 | else's issue is normal triage, not a claim of authorship. |
| 190 |