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/issues/issues.go +32−13
| @@ -1,9 +1,16 @@ | |||
| 1 | 1 | // Package issues implements Vole's issue format: a tree of plain files on a | |
| 2 | 2 | // branch in the repository itself. | |
| 3 | 3 | // | |
| 4 | // issues/<ulid>/meta.json | ||
| 5 | // issues/<ulid>/description.md | ||
| 6 | // issues/<ulid>/comments/<ulid>.md | ||
| 4 | // refs/heads/vole | ||
| 5 | // issues/<ulid>/meta.json | ||
| 6 | // issues/<ulid>/description.md | ||
| 7 | // issues/<ulid>/comments/<ulid>.md | ||
| 8 | // requests/... reserved for change requests | ||
| 9 | // | ||
| 10 | // Issues and change requests share one branch because they are both human-authored | ||
| 11 | // and low-frequency, and because a single ref means one push, one fetch and one | ||
| 12 | // validation path. Anything machine-written (CI runs, statuses) must NOT go here: | ||
| 13 | // a high-frequency writer on this ref would collide with every human edit. | ||
| 7 | 14 | // | |
| 8 | 15 | // The format is the contract, not any particular tool. Someone with an editor and | |
| 9 | 16 | // a push can file a bug without installing Vole; `vole issue` is sugar over the | |
| @@ -28,10 +35,12 @@ import ( | |||
| 28 | 35 | ) | |
| 29 | 36 | ||
| 30 | 37 | const ( | |
| 31 | // Ref is the branch carrying issue data. | ||
| 32 | Ref = "refs/heads/issues" | ||
| 33 | // Dir is the top-level directory inside that branch. | ||
| 38 | // Ref is the branch carrying Vole metadata: issues, and later change requests. | ||
| 39 | Ref = "refs/heads/vole" | ||
| 40 | // Dir is the issues subtree inside that branch. | ||
| 34 | 41 | Dir = "issues" | |
| 42 | // RequestsDir is reserved for change requests. | ||
| 43 | RequestsDir = "requests" | ||
| 35 | 44 | ||
| 36 | 45 | metaName = "meta.json" | |
| 37 | 46 | descName = "description.md" | |
| @@ -285,33 +294,43 @@ func (e *ValidationError) Error() string { | |||
| 285 | 294 | return fmt.Sprintf("%d problems: %s", len(e.Problems), strings.Join(e.Problems, "; ")) | |
| 286 | 295 | } | |
| 287 | 296 | ||
| 288 | // Validate checks the issues branch at commit and reports every problem. | ||
| 297 | // Validate checks the metadata branch at commit and reports every problem. The | ||
| 298 | // top-level layout is closed: only issues/ and requests/ may appear, so a typo in | ||
| 299 | // a directory name is caught instead of silently ignored by the web. | ||
| 289 | 300 | func Validate(g *gitx.Repo, commit string) []string { | |
| 290 | 301 | if commit == "" || commit == gitx.ZeroSHA { | |
| 291 | 302 | return nil // deleting the branch is allowed | |
| 292 | 303 | } | |
| 293 | 304 | blobs, err := g.LsTreeR(commit) | |
| 294 | 305 | if err != nil { | |
| 295 | return []string{fmt.Sprintf("cannot read the issues tree: %v", err)} | ||
| 306 | return []string{fmt.Sprintf("cannot read the metadata tree: %v", err)} | ||
| 296 | 307 | } | |
| 297 | 308 | contents := map[string]string{} | |
| 309 | var problems []string | ||
| 298 | 310 | for _, e := range blobs { | |
| 299 | if !strings.HasPrefix(e.Name, Dir+"/") { | ||
| 311 | switch { | ||
| 312 | case strings.HasPrefix(e.Name, Dir+"/"): | ||
| 313 | case strings.HasPrefix(e.Name, RequestsDir+"/"): | ||
| 314 | // Reserved: change requests are validated by their own code when they | ||
| 315 | // land. Accepting the prefix now keeps the layout stable. | ||
| 316 | default: | ||
| 317 | problems = append(problems, fmt.Sprintf( | ||
| 318 | "%s: metadata may only contain %s/ and %s/", e.Name, Dir, RequestsDir)) | ||
| 300 | 319 | continue | |
| 301 | 320 | } | |
| 302 | 321 | data, err := g.BlobBySHA(e.SHA) | |
| 303 | 322 | if err != nil { | |
| 304 | return []string{fmt.Sprintf("cannot read %s: %v", e.Name, err)} | ||
| 323 | return append(problems, fmt.Sprintf("cannot read %s: %v", e.Name, err)) | ||
| 305 | 324 | } | |
| 306 | 325 | contents[e.Name] = string(data) | |
| 307 | 326 | } | |
| 308 | 327 | if _, err := Parse(contents); err != nil { | |
| 309 | 328 | if ve, ok := err.(*ValidationError); ok { | |
| 310 | return ve.Problems | ||
| 329 | return append(problems, ve.Problems...) | ||
| 311 | 330 | } | |
| 312 | return []string{err.Error()} | ||
| 331 | return append(problems, err.Error()) | ||
| 313 | 332 | } | |
| 314 | return nil | ||
| 333 | return problems | ||
| 315 | 334 | } | |
| 316 | 335 | ||
| 317 | 336 | // NewULID is exported for the CLI: a sortable id with a millisecond timestamp | |