Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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