vole
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.
Phase 3: change requests, with approvals bound to the commit they reviewed
1eeca4e67a65d71217d51b1c41632330225b351a · Keenan Nemetz · 17d ago · parent 92b37587
Phase 3: change requests, with approvals bound to the commit they reviewed Review is now a git artifact. A request names a base branch and one head commit; an approval names that same commit, so a branch that moves after approval is not approved any more. Because `git push` is the merge, the gate lives in pre-receive and there is no authenticated write surface to build. requests/<ulid>/meta.json title, author, base, head, state requests/<ulid>/description.md requests/<ulid>/approvals/<user>.json base + head + created Two push shapes are accepted for a protected branch: the new tip is the approved head, or the new tip is a merge whose parents are the branch tip and the approved head. "The approved head is an ancestor of the new tip" is refused on purpose — accepting it would let anyone append unapproved commits and land them together with an approved one. Admins keep their bypass; `vole acl approvals <repo> <n>` sets the count, and 0 restores admin-only. Three integrity holes found while building it, each now covered by a test: - An approval file names a principal, but nothing tied that name to the pusher, so any writer could commit approvals/<someone-else>.json. The hook now requires a newly added approval to be named after the SSH identity. That check initially skipped the push that *creates* the branch, where there is no old commit to diff against — which let whoever created the branch seed any approval they liked. Fixed to inspect the whole tree on a first push. - Approvals were bound to the head but not the branch, so rewriting a request's base repointed an approval at somewhere the approver never looked. - Self-approval was refused at the CLI but not in validation, so a hand-written tree could carry one. Also relaxes the CLI to accept options after arguments, as git does. Refusing `vole request approve <id> --as keenan` is a local dialect, not git's behaviour, and `git commit -m msg path` is the shape people already type. Exit 129, no bundling and `--`/`--end-of-options` are unchanged. Verified end to end on a real instance with two keys: a writer's direct push to a protected main is refused with a message naming the exact commit; the same push still fails with a request open but unapproved; self-approval fails; an admin approval lets it through; main advances; the web shows the approval as current and the request as already merged; the audit log records the merge with request id and approver. Known rough edge: a merged request stays open until someone closes it. Marking it merged automatically needs the server to write metadata during a push, which the design deliberately does not do. Phase 4 CI is the natural place to close it.
docs/roadmap.md +40−13
| @@ -178,19 +178,43 @@ named and the branch unmoved; deleting the index loses no issues. | |||
| 178 | 178 | ||
| 179 | 179 | ### Phase 3 — Change requests | |
| 180 | 180 | ||
| 181 | - CR entity on `refs/vole/crs` referencing base branch + head branch + head SHA | ||
| 182 | - Diff view (three-dot against merge base), file tree, per-file collapse for large diffs | ||
| 183 | - Line-anchored review comments that survive force-pushes via `(path, blob-sha, line)` anchoring | ||
| 184 | - Review states: comment / approve / request changes, on whichever surface the | ||
| 185 | open question above resolves to | ||
| 186 | - Merge as a CAS update of the base ref from the reviewed head SHA; reject stale merges | ||
| 187 | rather than clobbering | ||
| 188 | - Protected branches: required approvals, required status checks (wired in Phase 4) | ||
| 189 | - Conflict detection surfaced before merge is attempted | ||
| 190 | |||
| 191 | **Exit criteria:** a full CR flow on a real repo with review comments that stay attached | ||
| 192 | across a force-push; a merge raced by a concurrent push is rejected, not applied; a | ||
| 193 | protected branch cannot be pushed by a non-owner even with a valid token. | ||
| 181 | ✅ **Done.** A change request is a directory on `refs/heads/vole`: | ||
| 182 | |||
| 183 | ``` | ||
| 184 | requests/<ulid>/meta.json title, author, base branch, head commit, state | ||
| 185 | requests/<ulid>/description.md | ||
| 186 | requests/<ulid>/approvals/<principal>.json base + head + created | ||
| 187 | ``` | ||
| 188 | |||
| 189 | - **Approvals are bound to one commit and one branch.** `git push` is the merge, so the | ||
| 190 | gate is `pre-receive` and there is no write surface to authenticate. An approval names | ||
| 191 | the exact head it reviewed; push a new head and the approvals stop matching. | ||
| 192 | - **Two push shapes are accepted** for a protected branch: the new tip *is* the approved | ||
| 193 | head, or the new tip is a merge whose parents are the branch tip and the approved head | ||
| 194 | (either order). "Approved head is an ancestor of the new tip" is deliberately refused — | ||
| 195 | it would let anyone append unapproved commits and land them together. | ||
| 196 | - **Admins bypass; everyone else needs the approvals.** `vole acl approvals <repo> <n>` | ||
| 197 | sets the count; `0` restores the old admin-only behaviour. | ||
| 198 | - **An approval can only be written by the person it names.** Otherwise a writer could | ||
| 199 | commit `approvals/<someone-else>.json` and approve on their behalf; the hook rejects any | ||
| 200 | newly added approval whose name is not the pushing principal. Self-approval is refused | ||
| 201 | too, at the CLI and again in validation. | ||
| 202 | - **View**: list and detail pages, current versus stale approvals, and the file list the | ||
| 203 | request would change. | ||
| 204 | |||
| 205 | Deferred to later phases: | ||
| 206 | |||
| 207 | - Line-anchored review comments anchored on `(path, blob-sha, line)` so they survive a | ||
| 208 | rewrite | ||
| 209 | - A rendered, per-file collapsible diff (the detail page lists changed paths only) | ||
| 210 | - Required status checks (Phase 4 wires these into the same gate) | ||
| 211 | - Conflict detection surfaced before the push is attempted | ||
| 212 | |||
| 213 | **Exit criteria:** met, except review comments and rendered diffs. Verified end to end: a | ||
| 214 | writer cannot land on a protected branch without approvals, an amended head invalidates a | ||
| 215 | prior approval, an approval for `main` cannot be repointed at another branch, a | ||
| 216 | self-approval or impersonated approval is refused, an admin bypass works, and each merge | ||
| 217 | is written to the audit log with the request id and approvers. | ||
| 194 | 218 | ||
| 195 | 219 | ### Phase 4 — CI/CD | |
| 196 | 220 | ||
| @@ -329,6 +353,9 @@ Filled in as phases land. Append; don't rewrite. | |||
| 329 | 353 | | 2026-10-22 | CLI follows gitcli(7): `--long`, `-o`, options first, `--`, exit 129 | A tool that speaks git's dialect composes with git and with muscle memory | | |
| 330 | 354 | | 2026-10-22 | Issues and requests share `refs/heads/vole`; linear history, no merges | One ref, one push, atomic cross-cutting edits; entities are separate files so a re-apply never conflicts | | |
| 331 | 355 | | 2026-10-22 | Force-push to metadata is admin-only and audited | Redaction is a real need, but it must never be silent | | |
| 356 | | 2026-10-22 | Approvals name a commit and a branch; the merge is a push | A branch-moving head makes "approve once" meaningless, and a push needs no new write surface | | ||
| 357 | | 2026-10-22 | Only the named principal may add an approval file | A filename is not authentication; the hook compares it to the SSH identity | | ||
| 358 | | 2026-10-22 | CLI accepts options after arguments, as git does | Refusing them is a local dialect; `git commit -m x path` is the shape people type | | ||
| 332 | 359 | ||
| 333 | 360 | ## Open questions | |
| 334 | 361 | ||