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.
forge/index/incremental_test.go +303
| @@ -0,0 +1,303 @@ | |||
| 1 | package index | ||
| 2 | |||
| 3 | // An incremental refresh is only correct if it leaves the rows it did not touch | ||
| 4 | // alone. These tests hold that: a new issue must not delete the repository's | ||
| 5 | // rows, and a comment or label edit must not consume a number. | ||
| 6 | |||
| 7 | import ( | ||
| 8 | "fmt" | ||
| 9 | "os" | ||
| 10 | "path/filepath" | ||
| 11 | "strings" | ||
| 12 | "testing" | ||
| 13 | "time" | ||
| 14 | |||
| 15 | "github.com/rodent-software/vole/core/git" | ||
| 16 | "github.com/rodent-software/vole/forge/repo" | ||
| 17 | "github.com/rodent-software/vole/metadata" | ||
| 18 | ) | ||
| 19 | |||
| 20 | func incrementalRepo(t *testing.T) (*Store, *repo.Meta, *git.Repo) { | ||
| 21 | t.Helper() | ||
| 22 | dir := filepath.Join(t.TempDir(), "r.git") | ||
| 23 | if err := git.InitBare(dir, "main"); err != nil { | ||
| 24 | t.Fatal(err) | ||
| 25 | } | ||
| 26 | g := git.Open(dir) | ||
| 27 | tree, err := g.MkTree(nil) | ||
| 28 | if err != nil { | ||
| 29 | t.Fatal(err) | ||
| 30 | } | ||
| 31 | seed, err := g.CommitTree(tree, "", "seed") | ||
| 32 | if err != nil { | ||
| 33 | t.Fatal(err) | ||
| 34 | } | ||
| 35 | if err := g.UpdateRef(metadata.Ref, seed); err != nil { | ||
| 36 | t.Fatal(err) | ||
| 37 | } | ||
| 38 | |||
| 39 | s, err := Open(filepath.Join(t.TempDir(), "index.db")) | ||
| 40 | if err != nil { | ||
| 41 | t.Fatal(err) | ||
| 42 | } | ||
| 43 | t.Cleanup(func() { s.Close() }) | ||
| 44 | if _, err := s.db.Exec(`INSERT INTO repos(id, name, created) VALUES('r1','demo',0)`); err != nil { | ||
| 45 | t.Fatal(err) | ||
| 46 | } | ||
| 47 | return s, &repo.Meta{ID: "r1", Name: "demo", Dir: dir}, g | ||
| 48 | } | ||
| 49 | |||
| 50 | func createIndexedIssue(t *testing.T, g *git.Repo, title string, at time.Time, labels, assignees []string) *metadata.Issue { | ||
| 51 | t.Helper() | ||
| 52 | iss, err := metadata.NewIssue(title, "body of "+title, "keenan", labels, assignees, at) | ||
| 53 | if err != nil { | ||
| 54 | t.Fatal(err) | ||
| 55 | } | ||
| 56 | if err := metadata.CreateIssue(g, iss, "keenan"); err != nil { | ||
| 57 | t.Fatal(err) | ||
| 58 | } | ||
| 59 | return iss | ||
| 60 | } | ||
| 61 | |||
| 62 | func watchedDeletes(t *testing.T, s *Store) { | ||
| 63 | t.Helper() | ||
| 64 | if _, err := s.db.Exec(`CREATE TABLE index_deletes(table_name TEXT NOT NULL)`); err != nil { | ||
| 65 | t.Fatal(err) | ||
| 66 | } | ||
| 67 | for _, table := range []string{"issues", "issue_labels", "issue_assignees", "entity_numbers"} { | ||
| 68 | trigger := `CREATE TRIGGER ` + table + `_deleted AFTER DELETE ON ` + table + | ||
| 69 | ` BEGIN INSERT INTO index_deletes VALUES('` + table + `'); END;` | ||
| 70 | if _, err := s.db.Exec(trigger); err != nil { | ||
| 71 | t.Fatal(err) | ||
| 72 | } | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | func deleteCount(t *testing.T, s *Store) int { | ||
| 77 | t.Helper() | ||
| 78 | var n int | ||
| 79 | if err := s.db.QueryRow(`SELECT COUNT(*) FROM index_deletes`).Scan(&n); err != nil { | ||
| 80 | t.Fatal(err) | ||
| 81 | } | ||
| 82 | return n | ||
| 83 | } | ||
| 84 | |||
| 85 | func TestEnsureAddsOneIssueWithoutDeletingTheIndex(t *testing.T) { | ||
| 86 | t.Parallel() | ||
| 87 | s, m, g := incrementalRepo(t) | ||
| 88 | now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) | ||
| 89 | first := createIndexedIssue(t, g, "First issue", now, []string{"bug"}, []string{"keenan"}) | ||
| 90 | second := createIndexedIssue(t, g, "Second issue", now.Add(time.Minute), nil, nil) | ||
| 91 | if err := s.Ensure(m); err != nil { | ||
| 92 | t.Fatal(err) | ||
| 93 | } | ||
| 94 | watchedDeletes(t, s) | ||
| 95 | |||
| 96 | third := createIndexedIssue(t, g, "Third issue", now.Add(2*time.Minute), []string{"cli"}, []string{"wilbur"}) | ||
| 97 | if err := s.Ensure(m); err != nil { | ||
| 98 | t.Fatal(err) | ||
| 99 | } | ||
| 100 | if n := deleteCount(t, s); n != 0 { | ||
| 101 | t.Fatalf("adding one issue deleted %d index rows, want 0", n) | ||
| 102 | } | ||
| 103 | |||
| 104 | rows, err := s.Issues(m.ID, Filter{}) | ||
| 105 | if err != nil { | ||
| 106 | t.Fatal(err) | ||
| 107 | } | ||
| 108 | if len(rows) != 3 { | ||
| 109 | t.Fatalf("issues = %d, want 3", len(rows)) | ||
| 110 | } | ||
| 111 | wantNumbers := map[string]int{first.ID: 1, second.ID: 2, third.ID: 3} | ||
| 112 | for _, row := range rows { | ||
| 113 | if row.Number != wantNumbers[row.ID] { | ||
| 114 | t.Fatalf("issue %s number = %d, want %d", row.ID, row.Number, wantNumbers[row.ID]) | ||
| 115 | } | ||
| 116 | } | ||
| 117 | for _, row := range rows { | ||
| 118 | if row.ID != third.ID { | ||
| 119 | continue | ||
| 120 | } | ||
| 121 | if len(row.Labels) != 1 || row.Labels[0] != "cli" { | ||
| 122 | t.Fatalf("labels = %v, want [cli]", row.Labels) | ||
| 123 | } | ||
| 124 | if len(row.Assignees) != 1 || row.Assignees[0] != "wilbur" { | ||
| 125 | t.Fatalf("assignees = %v, want [wilbur]", row.Assignees) | ||
| 126 | } | ||
| 127 | } | ||
| 128 | } | ||
| 129 | |||
| 130 | func TestEnsureUpdatesOneIssueWithoutAddingANumber(t *testing.T) { | ||
| 131 | t.Parallel() | ||
| 132 | s, m, g := incrementalRepo(t) | ||
| 133 | now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) | ||
| 134 | iss := createIndexedIssue(t, g, "Working issue", now, []string{"bug"}, nil) | ||
| 135 | other := createIndexedIssue(t, g, "Other issue", now.Add(time.Minute), nil, nil) | ||
| 136 | if err := s.Ensure(m); err != nil { | ||
| 137 | t.Fatal(err) | ||
| 138 | } | ||
| 139 | |||
| 140 | comment, err := metadata.NewIssueComment("A comment", "keenan", now.Add(2*time.Minute)) | ||
| 141 | if err != nil { | ||
| 142 | t.Fatal(err) | ||
| 143 | } | ||
| 144 | if err := metadata.AddIssueComment(g, iss.ID, comment, "keenan"); err != nil { | ||
| 145 | t.Fatal(err) | ||
| 146 | } | ||
| 147 | if _, err := metadata.UpdateIssue(g, iss.ID, func(iss *metadata.Issue) error { | ||
| 148 | iss.Labels = []string{"cli"} | ||
| 149 | return nil | ||
| 150 | }, "keenan"); err != nil { | ||
| 151 | t.Fatal(err) | ||
| 152 | } | ||
| 153 | if err := s.Ensure(m); err != nil { | ||
| 154 | t.Fatal(err) | ||
| 155 | } | ||
| 156 | |||
| 157 | rows, err := s.Issues(m.ID, Filter{}) | ||
| 158 | if err != nil { | ||
| 159 | t.Fatal(err) | ||
| 160 | } | ||
| 161 | if len(rows) != 2 { | ||
| 162 | t.Fatalf("issues = %d, want 2", len(rows)) | ||
| 163 | } | ||
| 164 | for _, row := range rows { | ||
| 165 | switch row.ID { | ||
| 166 | case iss.ID: | ||
| 167 | if row.Comments != 1 { | ||
| 168 | t.Fatalf("comment count = %d, want 1", row.Comments) | ||
| 169 | } | ||
| 170 | if len(row.Labels) != 1 || row.Labels[0] != "cli" { | ||
| 171 | t.Fatalf("labels = %v, want [cli]", row.Labels) | ||
| 172 | } | ||
| 173 | if row.Number != 1 { | ||
| 174 | t.Fatalf("number = %d, want 1", row.Number) | ||
| 175 | } | ||
| 176 | case other.ID: | ||
| 177 | if row.Number != 2 { | ||
| 178 | t.Fatalf("other number = %d, want 2", row.Number) | ||
| 179 | } | ||
| 180 | } | ||
| 181 | } | ||
| 182 | var numbered int | ||
| 183 | if err := s.db.QueryRow(`SELECT COUNT(*) FROM entity_numbers WHERE repo_id=?`, m.ID).Scan(&numbered); err != nil { | ||
| 184 | t.Fatal(err) | ||
| 185 | } | ||
| 186 | if numbered != 2 { | ||
| 187 | t.Fatalf("entity numbers = %d, want 2 (an edit must not consume one)", numbered) | ||
| 188 | } | ||
| 189 | } | ||
| 190 | |||
| 191 | func TestEnsureRebuildsWhenTheIndexIsMissing(t *testing.T) { | ||
| 192 | t.Parallel() | ||
| 193 | s, m, g := incrementalRepo(t) | ||
| 194 | iss := createIndexedIssue(t, g, "Keep me", time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC), nil, nil) | ||
| 195 | if err := s.Ensure(m); err != nil { | ||
| 196 | t.Fatal(err) | ||
| 197 | } | ||
| 198 | if _, err := s.db.Exec(` | ||
| 199 | DELETE FROM issues; | ||
| 200 | DELETE FROM issue_labels; | ||
| 201 | DELETE FROM issue_assignees; | ||
| 202 | DELETE FROM entity_numbers; | ||
| 203 | UPDATE repos SET issues_sha='';`); err != nil { | ||
| 204 | t.Fatal(err) | ||
| 205 | } | ||
| 206 | if err := s.Ensure(m); err != nil { | ||
| 207 | t.Fatal(err) | ||
| 208 | } | ||
| 209 | rows, err := s.Issues(m.ID, Filter{}) | ||
| 210 | if err != nil { | ||
| 211 | t.Fatal(err) | ||
| 212 | } | ||
| 213 | if len(rows) != 1 || rows[0].ID != iss.ID || rows[0].Number != 1 { | ||
| 214 | t.Fatalf("rebuilt rows = %+v, want the issue at #1", rows) | ||
| 215 | } | ||
| 216 | } | ||
| 217 | |||
| 218 | func TestEnsureKeepsANumberSpentInsideTheRange(t *testing.T) { | ||
| 219 | t.Parallel() | ||
| 220 | s, m, g := incrementalRepo(t) | ||
| 221 | now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) | ||
| 222 | if err := s.Ensure(m); err != nil { | ||
| 223 | t.Fatal(err) | ||
| 224 | } | ||
| 225 | // An entity created and deleted again between two refreshes still consumed a | ||
| 226 | // number. The next issue must not take it, or the index would disagree with | ||
| 227 | // the full history the branch can always reproduce. | ||
| 228 | gone := createIndexedIssue(t, g, "Deleted again", now, nil, nil) | ||
| 229 | if _, err := git.CommitFiles(g, metadata.Ref, func(files map[string][]byte) error { | ||
| 230 | delete(files, "issues/"+gone.ID+"/issue.md") | ||
| 231 | return nil | ||
| 232 | }, "delete the issue again", "keenan"); err != nil { | ||
| 233 | t.Fatal(err) | ||
| 234 | } | ||
| 235 | kept := createIndexedIssue(t, g, "Stays", now.Add(time.Minute), nil, nil) | ||
| 236 | if err := s.Ensure(m); err != nil { | ||
| 237 | t.Fatal(err) | ||
| 238 | } | ||
| 239 | |||
| 240 | rows, err := s.Issues(m.ID, Filter{}) | ||
| 241 | if err != nil { | ||
| 242 | t.Fatal(err) | ||
| 243 | } | ||
| 244 | if len(rows) != 1 || rows[0].ID != kept.ID || rows[0].Number != 2 { | ||
| 245 | t.Fatalf("rows = %+v, want %s at #2", rows, kept.ID) | ||
| 246 | } | ||
| 247 | var numbered int | ||
| 248 | if err := s.db.QueryRow(`SELECT COUNT(*) FROM entity_numbers WHERE repo_id=?`, m.ID).Scan(&numbered); err != nil { | ||
| 249 | t.Fatal(err) | ||
| 250 | } | ||
| 251 | if numbered != 2 { | ||
| 252 | t.Fatalf("entity numbers = %d, want 2 (the deleted creation keeps its number)", numbered) | ||
| 253 | } | ||
| 254 | } | ||
| 255 | |||
| 256 | // TestEnsureDerivesNewNumbersFromTheRangeOnly is the regression test for the | ||
| 257 | // cost a long branch used to add to every refresh: the numbering request names a | ||
| 258 | // commit range, not the whole tip. | ||
| 259 | // | ||
| 260 | // It is not parallel because it sets GIT_TRACE for the git subprocesses the | ||
| 261 | // fixture starts. | ||
| 262 | func TestEnsureDerivesNewNumbersFromTheRangeOnly(t *testing.T) { | ||
| 263 | s, m, g := incrementalRepo(t) | ||
| 264 | // History under a path the numbering does not care about. A full walk still | ||
| 265 | // has to traverse these commits; the range does not. | ||
| 266 | for i := 0; i < 10; i++ { | ||
| 267 | if _, err := git.CommitFiles(g, metadata.Ref, func(files map[string][]byte) error { | ||
| 268 | files[fmt.Sprintf("runs/old%d/1/record.md", i)] = []byte("record\n") | ||
| 269 | return nil | ||
| 270 | }, "old record", "keenan"); err != nil { | ||
| 271 | t.Fatal(err) | ||
| 272 | } | ||
| 273 | } | ||
| 274 | now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) | ||
| 275 | createIndexedIssue(t, g, "First", now, nil, nil) | ||
| 276 | if err := s.Ensure(m); err != nil { | ||
| 277 | t.Fatal(err) | ||
| 278 | } | ||
| 279 | |||
| 280 | trace := filepath.Join(t.TempDir(), "git.trace") | ||
| 281 | t.Setenv("GIT_TRACE", trace) | ||
| 282 | createIndexedIssue(t, g, "Second", now.Add(time.Minute), nil, nil) | ||
| 283 | if err := s.Ensure(m); err != nil { | ||
| 284 | t.Fatal(err) | ||
| 285 | } | ||
| 286 | data, err := os.ReadFile(trace) | ||
| 287 | if err != nil { | ||
| 288 | t.Fatal(err) | ||
| 289 | } | ||
| 290 | seen := 0 | ||
| 291 | for _, line := range strings.Split(string(data), "\n") { | ||
| 292 | if !strings.Contains(line, "--diff-filter=A") { | ||
| 293 | continue | ||
| 294 | } | ||
| 295 | seen++ | ||
| 296 | if !strings.Contains(line, "..") { | ||
| 297 | t.Fatalf("numbering walked the whole tip instead of the new range: %s", line) | ||
| 298 | } | ||
| 299 | } | ||
| 300 | if seen == 0 { | ||
| 301 | t.Fatal("no numbering git call was traced; the test did not observe the refresh") | ||
| 302 | } | ||
| 303 | } | ||