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/gitx/gitx.go +37
| @@ -338,6 +338,43 @@ func (r *Repo) Log(rev string, n int) ([]Commit, error) { | |||
| 338 | 338 | return parseCommits(out), nil | |
| 339 | 339 | } | |
| 340 | 340 | ||
| 341 | // IsAncestor reports whether ancestor is reachable from descendant. Used to tell | ||
| 342 | // a fast-forward push from a rewrite. | ||
| 343 | func (r *Repo) IsAncestor(ancestor, descendant string) (bool, error) { | ||
| 344 | if ancestor == "" || ancestor == ZeroSHA { | ||
| 345 | return true, nil // a new branch cannot rewrite anything | ||
| 346 | } | ||
| 347 | _, _, err := r.run(nil, "merge-base", "--is-ancestor", ancestor, descendant) | ||
| 348 | if err == nil { | ||
| 349 | return true, nil | ||
| 350 | } | ||
| 351 | if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 { | ||
| 352 | return false, nil | ||
| 353 | } | ||
| 354 | return false, fmt.Errorf("merge-base --is-ancestor %s %s: %w", ancestor, descendant, err) | ||
| 355 | } | ||
| 356 | |||
| 357 | // CountMerges counts merge commits in old..new, or in all of new when old is the | ||
| 358 | // zero SHA. | ||
| 359 | func (r *Repo) CountMerges(old, new string) (int, error) { | ||
| 360 | args := []string{"rev-list", "--count", "--merges", new} | ||
| 361 | if old != "" && old != ZeroSHA { | ||
| 362 | args = []string{"rev-list", "--count", "--merges", old + ".." + new} | ||
| 363 | } | ||
| 364 | out, errs, err := r.run(nil, args...) | ||
| 365 | if err != nil { | ||
| 366 | if strings.Contains(errs, "bad revision") || strings.Contains(errs, "unknown revision") { | ||
| 367 | return 0, nil // history we cannot read is not a merge we can prove | ||
| 368 | } | ||
| 369 | return 0, fmt.Errorf("rev-list --merges: %w: %s", err, errs) | ||
| 370 | } | ||
| 371 | n, err := strconv.Atoi(strings.TrimSpace(string(out))) | ||
| 372 | if err != nil { | ||
| 373 | return 0, nil | ||
| 374 | } | ||
| 375 | return n, nil | ||
| 376 | } | ||
| 377 | |||
| 341 | 378 | // LogGrep returns commits whose message mentions pattern. Used for issue | |
| 342 | 379 | // backlinks, so the search happens in git rather than in a second index. | |
| 343 | 380 | func (r *Repo) LogGrep(rev, pattern string, n int) ([]Commit, error) { | |