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 | } | |