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.

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 }