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