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/server/integration_test.go +235−11

@@ -17,6 +17,7 @@ import (
17 17 "os/exec"
18 18 "path/filepath"
19 19 "strings"
20 "sync"
20 21 "testing"
21 22
22 23 "golang.org/x/crypto/ssh"
@@ -137,6 +138,49 @@ func keypair(t *testing.T, path string) (pubLine, privPath string) {
137 138 return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPub))), path
138 139 }
139 140
141 // addWriter creates a second user with write access and returns their key path.
142 func (f *fixture) addWriter(t *testing.T) string {
143 t.Helper()
144 pub, priv := keypair(t, filepath.Join(f.cfg.DataDir, "id_writer"))
145 if err := f.srv.Identity.Create("wilbur"); err != nil {
146 t.Fatal(err)
147 }
148 if _, err := f.srv.Identity.AddKey("wilbur", pub, "test"); err != nil {
149 t.Fatal(err)
150 }
151 m := mustRepo(t, f)
152 a, err := m.ACL()
153 if err != nil {
154 t.Fatal(err)
155 }
156 if err := a.SetEntry("wilbur", "write"); err != nil {
157 t.Fatal(err)
158 }
159 if err := m.SetACL(a, "test"); err != nil {
160 t.Fatal(err)
161 }
162 return priv
163 }
164
165 // gitWithKey is f.git with a specific identity.
166 func (f *fixture) gitWithKey(key, dir string, args ...string) (string, string, error) {
167 f.t.Helper()
168 cmd := exec.Command("git", args...)
169 cmd.Dir = dir
170 cmd.Env = append(os.Environ(),
171 "GIT_TERMINAL_PROMPT=0",
172 "GIT_CONFIG_GLOBAL=/dev/null",
173 "GIT_CONFIG_SYSTEM=/dev/null",
174 "GIT_SSH_COMMAND=ssh -i "+key+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
175 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com",
176 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com",
177 )
178 var out, errb bytes.Buffer
179 cmd.Stdout, cmd.Stderr = &out, &errb
180 err := cmd.Run()
181 return out.String(), errb.String(), err
182 }
183
140 184 func (f *fixture) sshURL(name string) string {
141 185 return "ssh://git@" + f.sshAddr + "/" + name + ".git"
142 186 }
@@ -420,7 +464,7 @@ const testIssueID = "01M5JR2JK29KKMAA1T9MDNTPBP"
420 464 func (f *fixture) issuesRepo(t *testing.T, files map[string]string) string {
421 465 t.Helper()
422 466 dir := t.TempDir()
423 if _, errs, err := runGit(t, dir, "init", "-q", "-b", "issues"); err != nil {
467 if _, errs, err := runGit(t, dir, "init", "-q", "-b", "vole"); err != nil {
424 468 t.Fatalf("init: %v: %s", err, errs)
425 469 }
426 470 for name, content := range files {
@@ -457,7 +501,7 @@ func TestIssuePushedThenRendered(t *testing.T) {
457 501
458 502 desc := "Steps:\n\n1. push an issue\n2. read it back\n\n**bold claim** and `code`\n"
459 503 work := f.issuesRepo(t, issueFiles(testIssueID, "Push-based issues work", desc))
460 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/issues"); err != nil {
504 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
461 505 t.Fatalf("push issues: %v\n%s", err, errs)
462 506 }
463 507
@@ -495,7 +539,7 @@ func TestIssueMarkdownIsSanitised(t *testing.T) {
495 539 f.publish(f.repo)
496 540 desc := "hello\n\n<script>alert('xss')</script>\n\n[click](javascript:alert(1))\n"
497 541 work := f.issuesRepo(t, issueFiles(testIssueID, "XSS attempt", desc))
498 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/issues"); err != nil {
542 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
499 543 t.Fatalf("push: %s", errs)
500 544 }
501 545 code, body := f.get("/r/" + f.repo + "/issues/" + testIssueID)
@@ -517,11 +561,11 @@ func TestInvalidIssuePushRejected(t *testing.T) {
517 561 f.publish(f.repo)
518 562
519 563 good := f.issuesRepo(t, issueFiles(testIssueID, "fine", "body\n"))
520 if _, errs, err := f.git(good, "push", f.sshURL(f.repo), "HEAD:refs/heads/issues"); err != nil {
564 if _, errs, err := f.git(good, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
521 565 t.Fatalf("good push: %s", errs)
522 566 }
523 567 before, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"),
524 "rev-parse", "--verify", "--quiet", "refs/heads/issues")
568 "rev-parse", "--verify", "--quiet", "refs/heads/vole")
525 569
526 570 // Same history, but with a title-less issue added.
527 571 bad := issueFiles("01M5JR2JK29KKMAA1T9MDNTPBZ", "", "no title\n")
@@ -540,7 +584,7 @@ func TestInvalidIssuePushRejected(t *testing.T) {
540 584 if _, errs, err := runGit(t, good, "commit", "-qm", "bad issue"); err != nil {
541 585 t.Fatalf("commit: %s", errs)
542 586 }
543 _, errs, err := f.git(good, "push", f.sshURL(f.repo), "HEAD:refs/heads/issues")
587 _, errs, err := f.git(good, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole")
544 588 if err == nil {
545 589 t.Fatal("push with a title-less issue was accepted")
546 590 }
@@ -548,7 +592,7 @@ func TestInvalidIssuePushRejected(t *testing.T) {
548 592 t.Fatalf("rejection did not explain why:\n%s", errs)
549 593 }
550 594 after, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"),
551 "rev-parse", "--verify", "--quiet", "refs/heads/issues")
595 "rev-parse", "--verify", "--quiet", "refs/heads/vole")
552 596 if strings.TrimSpace(before) != strings.TrimSpace(after) {
553 597 t.Fatalf("issues branch moved on a rejected push: %s -> %s", before, after)
554 598 }
@@ -560,7 +604,7 @@ func TestIssuesSurviveIndexRebuild(t *testing.T) {
560 604 f := newFixture(t)
561 605 f.publish(f.repo)
562 606 work := f.issuesRepo(t, issueFiles(testIssueID, "persisted", "body\n"))
563 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/issues"); err != nil {
607 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
564 608 t.Fatalf("push: %s", errs)
565 609 }
566 610 if err := f.srv.Index.EnsureIssues(mustRepo(t, f)); err != nil {
@@ -684,7 +728,7 @@ func TestIssueCLIEndToEnd(t *testing.T) {
684 728 func (f *fixture) serverIssues(t *testing.T) string {
685 729 t.Helper()
686 730 out, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"),
687 "rev-parse", "--verify", "--quiet", "refs/heads/issues")
731 "rev-parse", "--verify", "--quiet", "refs/heads/vole")
688 732 return strings.TrimSpace(out)
689 733 }
690 734
@@ -704,7 +748,7 @@ func TestIssueFiltersAndSearch(t *testing.T) {
704 748 add("01M5JR2JK29KKMAA1T9MDNTPB3", "Slow parser", "open", "throughput", `["bug","perf"]`)
705 749
706 750 work := f.issuesRepo(t, files)
707 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/issues"); err != nil {
751 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
708 752 t.Fatalf("push: %s", errs)
709 753 }
710 754
@@ -746,7 +790,7 @@ func TestCrossReferencesLinksIssues(t *testing.T) {
746 790 files[k] = v
747 791 }
748 792 work := f.issuesRepo(t, files)
749 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/issues"); err != nil {
793 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
750 794 t.Fatalf("push issues: %s", errs)
751 795 }
752 796
@@ -791,6 +835,186 @@ func min(a, b int) int {
791 835 return b
792 836 }
793 837
838 // TestMetadataRejectsMergeCommits: metadata history is linear by policy, so a
839 // merge node is refused rather than quietly accepted.
840 func TestMetadataRejectsMergeCommits(t *testing.T) {
841 f := newFixture(t)
842 f.publish(f.repo)
843
844 work := f.issuesRepo(t, issueFiles(testIssueID, "first", "body\n"))
845 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
846 t.Fatalf("initial push: %s", errs)
847 }
848
849 // Two diverging commits, then a merge.
850 git := func(args ...string) {
851 t.Helper()
852 if _, errs, err := runGit(t, work, args...); err != nil {
853 t.Fatalf("%v: %v: %s", args, err, errs)
854 }
855 }
856 git("checkout", "-q", "-b", "side")
857 writeIssueFile(t, work, "01M5JR2JK29KKMAA1T9MDNTPE1", "side")
858 git("add", "-A")
859 git("commit", "-qm", "side issue")
860 git("checkout", "-q", "vole")
861 writeIssueFile(t, work, "01M5JR2JK29KKMAA1T9MDNTPE2", "main")
862 git("add", "-A")
863 git("commit", "-qm", "main issue")
864 git("merge", "-q", "--no-ff", "-m", "merge side", "side")
865
866 _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole")
867 if err == nil {
868 t.Fatal("a merge commit was accepted on the metadata branch")
869 }
870 if !strings.Contains(errs, "linear history") {
871 t.Fatalf("rejection did not explain the rule:\n%s", errs)
872 }
873 }
874
875 // TestMetadataForcePushIsAdminOnly: rewriting discussion content is a redaction
876 // tool, not a normal operation.
877 func TestMetadataForcePushIsAdminOnly(t *testing.T) {
878 f := newFixture(t)
879 f.publish(f.repo)
880 writerKey := f.addWriter(t)
881
882 work := f.issuesRepo(t, issueFiles(testIssueID, "first", "body\n"))
883 if _, errs, err := f.gitWithKey(writerKey, work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
884 t.Fatalf("writer initial push: %s", errs)
885 }
886
887 // Amend in place: a new object for the same parent, which is a rewrite. Adding
888 // and resetting would leave the tip unchanged and the push a no-op that never
889 // reaches the hook.
890 if _, errs, err := runGit(t, work, "commit", "-q", "--amend", "-m", "rewritten"); err != nil {
891 t.Fatalf("amend: %v: %s", err, errs)
892 }
893
894 _, errs, err := f.gitWithKey(writerKey, work, "push", "--force", f.sshURL(f.repo), "HEAD:refs/heads/vole")
895 if err == nil {
896 t.Fatal("a non-admin force-pushed the metadata branch")
897 }
898 if !strings.Contains(errs, "requires admin") {
899 t.Fatalf("rejection did not name the rule:\n%s", errs)
900 }
901
902 // The admin may, and it is recorded.
903 if _, errs, err := f.git(work, "push", "--force", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
904 t.Fatalf("admin force-push refused: %s", errs)
905 }
906 audit, err := os.ReadFile(f.cfg.AuditLog())
907 if err != nil {
908 t.Fatal(err)
909 }
910 if !strings.Contains(string(audit), "force-push refs/heads/vole") {
911 t.Fatalf("force-push not audited:\n%s", audit)
912 }
913 }
914
915 // TestMetadataRejectsUnknownTopLevel: the layout is closed, so a typo is caught
916 // rather than silently ignored by the web.
917 func TestMetadataRejectsUnknownTopLevel(t *testing.T) {
918 f := newFixture(t)
919 f.publish(f.repo)
920 work := t.TempDir()
921 if _, errs, err := runGit(t, work, "init", "-q", "-b", "vole"); err != nil {
922 t.Fatalf("init: %v: %s", err, errs)
923 }
924 if err := os.MkdirAll(filepath.Join(work, "notes"), 0o755); err != nil {
925 t.Fatal(err)
926 }
927 if err := os.WriteFile(filepath.Join(work, "notes", "x.md"), []byte("hi\n"), 0o644); err != nil {
928 t.Fatal(err)
929 }
930 if _, errs, err := runGit(t, work, "add", "-A"); err != nil {
931 t.Fatalf("add: %v: %s", err, errs)
932 }
933 if _, errs, err := runGit(t, work, "commit", "-qm", "notes"); err != nil {
934 t.Fatalf("commit: %v: %s", err, errs)
935 }
936 _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole")
937 if err == nil {
938 t.Fatal("an unknown top-level directory was accepted")
939 }
940 if !strings.Contains(errs, "may only contain") {
941 t.Fatalf("rejection did not explain the layout:\n%s", errs)
942 }
943 }
944
945 // TestConcurrentCLIWritesAllLand: two people filing issues at once must both
946 // succeed. The CLI re-applies on a lost race instead of failing.
947 func TestConcurrentCLIWritesAllLand(t *testing.T) {
948 f := newFixture(t)
949 f.publish(f.repo)
950 if _, errs, err := f.git(workRepo(t, map[string]string{"readme.md": "hi\n"}),
951 "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
952 t.Fatalf("push main: %s", errs)
953 }
954
955 const n = 6
956 clones := make([]string, n)
957 for i := range clones {
958 dir := filepath.Join(t.TempDir(), fmt.Sprintf("clone%d", i))
959 if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), dir); err != nil {
960 t.Fatalf("clone: %s", errs)
961 }
962 clones[i] = dir
963 }
964
965 var wg sync.WaitGroup
966 errs := make([]string, n)
967 for i := 0; i < n; i++ {
968 wg.Add(1)
969 go func(i int) {
970 defer wg.Done()
971 cmd := exec.Command(binPath, "issue", "new", "--title", fmt.Sprintf("concurrent %d", i))
972 cmd.Dir = clones[i]
973 cmd.Env = append(os.Environ(),
974 "GIT_SSH_COMMAND=ssh -i "+f.keyPath+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
975 "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
976 "USER=keenan", "GIT_AUTHOR_NAME=keenan", "GIT_AUTHOR_EMAIL=k@e")
977 var out bytes.Buffer
978 cmd.Stdout, cmd.Stderr = &out, &out
979 if err := cmd.Run(); err != nil {
980 errs[i] = out.String()
981 }
982 }(i)
983 }
984 wg.Wait()
985 for i, e := range errs {
986 if e != "" {
987 t.Fatalf("writer %d failed:\n%s", i, e)
988 }
989 }
990
991 if err := f.srv.Index.EnsureIssues(mustRepo(t, f)); err != nil {
992 t.Fatal(err)
993 }
994 rows, err := f.srv.Index.Issues(repoID(t, f), index.Filter{})
995 if err != nil {
996 t.Fatal(err)
997 }
998 if len(rows) != n {
999 t.Fatalf("%d issues landed, want %d (a lost race dropped a write)", len(rows), n)
1000 }
1001 }
1002
1003 func writeIssueFile(t *testing.T, dir, id, title string) {
1004 t.Helper()
1005 p := filepath.Join(dir, "issues", id)
1006 if err := os.MkdirAll(p, 0o755); err != nil {
1007 t.Fatal(err)
1008 }
1009 meta := fmt.Sprintf(`{"title":%q,"state":"open","author":"keenan","created":"2026-10-22T21:00:00Z"}`, title)
1010 if err := os.WriteFile(filepath.Join(p, "meta.json"), []byte(meta), 0o644); err != nil {
1011 t.Fatal(err)
1012 }
1013 if err := os.WriteFile(filepath.Join(p, "description.md"), []byte("body\n"), 0o644); err != nil {
1014 t.Fatal(err)
1015 }
1016 }
1017
794 1018 func mustRepo(t *testing.T, f *fixture) *repo.Meta {
795 1019 t.Helper()
796 1020 m, err := f.srv.Repos.Open(f.repo)