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.
Refresh the issue index from what changed
34a404b394dd9abcce0a67e32e99342c69595413 · Keenan Nemetz · 10d ago · parent fdf2340f
Refresh the issue index from what changed A page read after a write used to reload every issue and renumber the repository from the whole branch history. Ensure now applies the difference between the indexed commit and the tip: it reloads only changed issues, adds only the numbers the range created, and leaves every other row alone. A rewritten branch or a missing index still falls back to the full rebuild. The detail page and the browser write handlers now read one issue instead of the whole issue tree. Issue writes no longer re-validate the whole metadata tree after a write the writer already validated; the pre-receive hook still owns that check for pushes.
metadata/load_issue_test.go +86
| @@ -0,0 +1,86 @@ | |||
| 1 | package metadata | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "strings" | ||
| 5 | "testing" | ||
| 6 | "time" | ||
| 7 | |||
| 8 | "github.com/rodent-software/vole/core/git" | ||
| 9 | ) | ||
| 10 | |||
| 11 | func TestLoadIssueAtReadsOnlyTheTarget(t *testing.T) { | ||
| 12 | t.Parallel() | ||
| 13 | root := t.TempDir() | ||
| 14 | if err := git.InitBare(root, "main"); err != nil { | ||
| 15 | t.Fatal(err) | ||
| 16 | } | ||
| 17 | g := git.Open(root) | ||
| 18 | tree, err := g.MkTree(nil) | ||
| 19 | if err != nil { | ||
| 20 | t.Fatal(err) | ||
| 21 | } | ||
| 22 | seed, err := g.CommitTree(tree, "", "seed") | ||
| 23 | if err != nil { | ||
| 24 | t.Fatal(err) | ||
| 25 | } | ||
| 26 | if err := g.UpdateRef(Ref, seed); err != nil { | ||
| 27 | t.Fatal(err) | ||
| 28 | } | ||
| 29 | |||
| 30 | now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) | ||
| 31 | target, err := NewIssue("Target", "target body", "keenan", []string{"bug"}, nil, now) | ||
| 32 | if err != nil { | ||
| 33 | t.Fatal(err) | ||
| 34 | } | ||
| 35 | if err := CreateIssue(g, target, "keenan"); err != nil { | ||
| 36 | t.Fatal(err) | ||
| 37 | } | ||
| 38 | other, err := NewIssue("Other", "other body", "keenan", nil, nil, now.Add(time.Minute)) | ||
| 39 | if err != nil { | ||
| 40 | t.Fatal(err) | ||
| 41 | } | ||
| 42 | if err := CreateIssue(g, other, "keenan"); err != nil { | ||
| 43 | t.Fatal(err) | ||
| 44 | } | ||
| 45 | c, err := NewIssueComment("One comment", "wilbur", now.Add(2*time.Minute)) | ||
| 46 | if err != nil { | ||
| 47 | t.Fatal(err) | ||
| 48 | } | ||
| 49 | if err := AddIssueComment(g, target.ID, c, "wilbur"); err != nil { | ||
| 50 | t.Fatal(err) | ||
| 51 | } | ||
| 52 | |||
| 53 | got, found, err := LoadIssueAt(g, Ref, target.ID) | ||
| 54 | if err != nil { | ||
| 55 | t.Fatal(err) | ||
| 56 | } | ||
| 57 | if !found { | ||
| 58 | t.Fatal("target issue was not found") | ||
| 59 | } | ||
| 60 | if got.ID != target.ID || got.Title != "Target" || got.Description != "target body\n" { | ||
| 61 | t.Fatalf("loaded %+v, want the target issue", got) | ||
| 62 | } | ||
| 63 | if len(got.Comments) != 1 || strings.TrimSpace(got.Comments[0].Body) != "One comment" { | ||
| 64 | t.Fatalf("comments = %+v, want the one comment", got.Comments) | ||
| 65 | } | ||
| 66 | if got.ID == other.ID { | ||
| 67 | t.Fatal("LoadIssueAt returned the wrong issue") | ||
| 68 | } | ||
| 69 | |||
| 70 | _, found, err = LoadIssueAt(g, Ref, "01M5JR2JK29KKMAA1T9MDNTPB0") | ||
| 71 | if err != nil { | ||
| 72 | t.Fatal(err) | ||
| 73 | } | ||
| 74 | if found { | ||
| 75 | t.Fatal("a missing issue was reported as found") | ||
| 76 | } | ||
| 77 | // An id is used as a path segment, so anything that is not a ULID is simply | ||
| 78 | // not an issue rather than a chance to read outside issues/. | ||
| 79 | _, found, err = LoadIssueAt(g, Ref, "../../etc/passwd") | ||
| 80 | if err != nil { | ||
| 81 | t.Fatal(err) | ||
| 82 | } | ||
| 83 | if found { | ||
| 84 | t.Fatal("a non-ULID was read as an issue") | ||
| 85 | } | ||
| 86 | } | ||