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.

One metadata branch: refs/heads/vole, linear history, audited rewrites

92b37587e6e8b4e05f95dfc1e555ba36eaaa424d · Keenan Nemetz · 17d ago · parent f9568748

One metadata branch: refs/heads/vole, linear history, audited rewrites

Issues and change requests now share a single branch. Both are human-authored and
low-frequency, and one ref means one push, one fetch and one validation path
instead of per-branch special cases.

  refs/heads/vole
    issues/<ulid>/...
    requests/...        reserved for change requests

Rules, enforced in pre-receive rather than documented and hoped for:

- The tree layout is closed: only issues/ and requests/ may appear, so a typo in
  a directory name is rejected instead of being silently ignored by the web.
- History must be linear. Merge commits on the metadata branch are refused,
  because a merge node says nothing and makes `git log` harder to read than the
  change it describes. Rebasing is what happens instead.
- Force-push requires repo admin and writes a line to the audit log. Rewriting
  discussion history is sometimes necessary (someone pastes a secret) but must
  never be silent, so it is allowed rather than forbidden and recorded.

The CLI no longer fails on a lost race. Because every entity is its own file,
re-applying a change on the new tip cannot conflict, so it re-fetches, redoes the
mutation and retries with jittered backoff. Two things this turned up that a
single-writer test could never have found:

- A lost race has more than one shape. Besides "non-fast-forward" and "fetch
  first", a push can arrive with a stale old-oid ("incorrect old value
  provided") or lose the creation of the branch itself ("reference already
  exists"). All four are retryable; hook rejections are not.
- Retries without jitter keep writers in lockstep: six concurrent writers failed
  five rounds in a row with a deterministic backoff, and converged immediately
  once the sleep was randomised.

Verified by hand: the CLI writes to the vole branch, `git log vole` is a flat
list, and a merge push is refused with both problems reported at once — the merge
node and a malformed issue id in the same tree.

internal/hook/hook.go +56−2

@@ -6,8 +6,10 @@ import (
6 6 "bufio"
7 7 "fmt"
8 8 "io"
9 "os"
9 10 "path/filepath"
10 11 "strings"
12 "time"
11 13
12 14 "github.com/rodent-software/vole/internal/acl"
13 15 "github.com/rodent-software/vole/internal/gitx"
@@ -19,8 +21,11 @@ type Config struct {
19 21 RepoDir string
20 22 Actor string
21 23 MaxBlobBytes int64
22 Out io.Writer
23 In io.Reader
24 // AuditPath receives lines about privileged metadata operations. Empty means
25 // no audit sink, which is only acceptable for tests.
26 AuditPath string
27 Out io.Writer
28 In io.Reader
24 29 }
25 30
26 31 type change struct {
@@ -77,6 +82,35 @@ func PreReceive(cfg Config) int {
77 82 "%s: only refs/heads/* and refs/tags/* may be pushed", c.ref))
78 83 continue
79 84 }
85 // The metadata branch is append-only in shape: linear history, no merge
86 // commits. A merge node in metadata says nothing and makes the log harder
87 // to read than the change it describes.
88 if c.ref == issues.Ref {
89 if merges, err := g.CountMerges(c.old, c.new); err != nil {
90 problems = append(problems, fmt.Sprintf("%s: cannot check history shape: %v", c.ref, err))
91 } else if merges > 0 {
92 problems = append(problems, fmt.Sprintf(
93 "%s: metadata must have linear history (%d merge commit(s) would be added); rebase instead of merging", c.ref, merges))
94 }
95 }
96
97 // A rewrite of the metadata branch is allowed, because discussion content
98 // sometimes has to be redacted, but only by an admin and never silently.
99 if c.old != "" && c.old != gitx.ZeroSHA && c.new != gitx.ZeroSHA {
100 ff, err := g.IsAncestor(c.old, c.new)
101 if err != nil {
102 problems = append(problems, fmt.Sprintf("%s: cannot check fast-forward: %v", c.ref, err))
103 } else if !ff {
104 if !role.AtLeast(acl.Admin) {
105 problems = append(problems, fmt.Sprintf(
106 "%s: rewriting history requires admin access; %s is %s", c.ref, actor, role))
107 } else {
108 auditLine(cfg.AuditPath, fmt.Sprintf("force-push %s %s -> %s by %s in %s",
109 c.ref, short(c.old), short(c.new), actor, m.Name))
110 }
111 }
112 }
113
80 114 if a.IsProtected(c.ref) && !role.AtLeast(acl.Admin) {
81 115 problems = append(problems, fmt.Sprintf(
82 116 "%s is protected: %s may not push it directly (role: %s). Open a change request instead.",
@@ -148,6 +182,26 @@ func readChanges(r io.Reader) ([]change, error) {
148 182 return out, sc.Err()
149 183 }
150 184
185 // auditLine is best-effort: failing to write the log must not change policy.
186 func auditLine(path, line string) {
187 if path == "" {
188 return
189 }
190 f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
191 if err != nil {
192 return
193 }
194 fmt.Fprintf(f, "%s\t%s\n", time.Now().UTC().Format(time.RFC3339), line)
195 f.Close()
196 }
197
198 func short(sha string) string {
199 if len(sha) > 8 {
200 return sha[:8]
201 }
202 return sha
203 }
204
151 205 func reject(w io.Writer, format string, args ...any) {
152 206 fmt.Fprintf(w, "vole: "+format+"\n", args...)
153 207 }