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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 1h ago 4b8c9e1d
history blame raw 15.0 KiB
1package 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
7import (
8 "fmt"
9 "os"
10 "path/filepath"
11 "strings"
12 "testing"
13 "time"
14
15 "rodent.software/vole/core/git"
16 "rodent.software/vole/forge/repo"
17 "rodent.software/vole/metadata"
18)
19
20func 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
50func 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
62func 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
76func 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
85func 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
130func 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
191func 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(`
199DELETE FROM issues;
200DELETE FROM issue_labels;
201DELETE FROM issue_assignees;
202DELETE FROM entity_numbers;
203UPDATE 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
218func 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.
262func 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}
304
305// TestEnsureDatesAnIssueFromItsNewestChange pins what the issues list sorts by.
306// The metadata branch has no updated field, so the date comes from the commits:
307// a comment, an edit, or an edit reverted inside the refreshed range all move it,
308// and a full rebuild derives the same value as the incremental refresh.
309func TestEnsureDatesAnIssueFromItsNewestChange(t *testing.T) {
310 s, m, g := incrementalRepo(t)
311 base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
312 // The commit dates are the fixture; the branch's own commits would otherwise
313 // all land in the same second and the test could not tell them apart.
314 at := func(offset time.Duration) {
315 t.Helper()
316 date := fmt.Sprintf("%d +0000", base.Add(offset).Unix())
317 t.Setenv("GIT_AUTHOR_DATE", date)
318 t.Setenv("GIT_COMMITTER_DATE", date)
319 }
320 rowOf := func(t *testing.T, id string) IssueRow {
321 t.Helper()
322 rows, err := s.Issues(m.ID, Filter{})
323 if err != nil {
324 t.Fatal(err)
325 }
326 for _, row := range rows {
327 if row.ID == id {
328 return row
329 }
330 }
331 t.Fatalf("no indexed row for %s", id)
332 return IssueRow{}
333 }
334
335 at(time.Hour)
336 first := createIndexedIssue(t, g, "First", base.Add(time.Hour), nil, nil)
337 at(2 * time.Hour)
338 second := createIndexedIssue(t, g, "Second", base.Add(2*time.Hour), nil, nil)
339 if err := s.Ensure(m); err != nil {
340 t.Fatal(err)
341 }
342 if got := rowOf(t, first.ID).Updated; !got.Equal(base.Add(time.Hour)) {
343 t.Fatalf("a fresh issue updated = %v, want its opening commit %v", got, base.Add(time.Hour))
344 }
345
346 // A comment is activity: the issue it is on becomes the most recently updated
347 // one, even though the other issue was filed later.
348 at(3 * time.Hour)
349 comment, err := metadata.NewIssueComment("A comment", "keenan", base.Add(3*time.Hour))
350 if err != nil {
351 t.Fatal(err)
352 }
353 if err := metadata.AddIssueComment(g, first.ID, comment, "keenan"); err != nil {
354 t.Fatal(err)
355 }
356 if err := s.Ensure(m); err != nil {
357 t.Fatal(err)
358 }
359 if got := rowOf(t, first.ID).Updated; !got.Equal(base.Add(3 * time.Hour)) {
360 t.Fatalf("updated after a comment = %v, want %v", got, base.Add(3*time.Hour))
361 }
362 if got := rowOf(t, second.ID).Updated; !got.Equal(base.Add(2 * time.Hour)) {
363 t.Fatalf("the untouched issue updated = %v, want %v", got, base.Add(2*time.Hour))
364 }
365 rows, err := s.Issues(m.ID, Filter{})
366 if err != nil {
367 t.Fatal(err)
368 }
369 if len(rows) != 2 || rows[0].ID != first.ID {
370 t.Fatalf("default order = %+v, want the commented issue first", rows)
371 }
372
373 // Two edits that cancel out leave the tree where it started. The tree diff
374 // sees nothing; the commits still happened, and the list has to say so.
375 at(4 * time.Hour)
376 if _, err := metadata.UpdateIssue(g, first.ID, func(iss *metadata.Issue) error {
377 iss.Labels = []string{"cli"}
378 return nil
379 }, "keenan"); err != nil {
380 t.Fatal(err)
381 }
382 at(5 * time.Hour)
383 if _, err := metadata.UpdateIssue(g, first.ID, func(iss *metadata.Issue) error {
384 iss.Labels = nil
385 return nil
386 }, "keenan"); err != nil {
387 t.Fatal(err)
388 }
389 if err := s.Ensure(m); err != nil {
390 t.Fatal(err)
391 }
392 incremental := rowOf(t, first.ID).Updated
393 if !incremental.Equal(base.Add(5 * time.Hour)) {
394 t.Fatalf("updated after an edit and its revert = %v, want %v", incremental, base.Add(5*time.Hour))
395 }
396
397 // A rebuild walks the whole branch. It must land on the same date, or the two
398 // ways of filling the index would disagree about the same repository.
399 if err := s.IndexRepo(m); err != nil {
400 t.Fatal(err)
401 }
402 if rebuilt := rowOf(t, first.ID).Updated; !rebuilt.Equal(incremental) {
403 t.Fatalf("rebuilt updated = %v, incremental = %v", rebuilt, incremental)
404 }
405}
406
407// TestEnsureDatesARequestFromItsNewestChange is the request half of the list's
408// activity sort. A comment or an approval moves the date; the incremental refresh
409// and a full rebuild derive the same one.
410func TestEnsureDatesARequestFromItsNewestChange(t *testing.T) {
411 s, m, g := incrementalRepo(t)
412 base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
413 at := func(offset time.Duration) {
414 t.Helper()
415 date := fmt.Sprintf("%d +0000", base.Add(offset).Unix())
416 t.Setenv("GIT_AUTHOR_DATE", date)
417 t.Setenv("GIT_COMMITTER_DATE", date)
418 }
419
420 at(time.Hour)
421 first := createIndexedRequest(t, g, "First change", base.Add(time.Hour), nil)
422 at(2 * time.Hour)
423 second := createIndexedRequest(t, g, "Second change", base.Add(2*time.Hour), nil)
424 if err := s.Ensure(m); err != nil {
425 t.Fatal(err)
426 }
427 rowOf := func(t *testing.T, id string) RequestRow {
428 t.Helper()
429 rows, err := s.Requests(m.ID, RequestFilter{})
430 if err != nil {
431 t.Fatal(err)
432 }
433 for _, row := range rows {
434 if row.ID == id {
435 return row
436 }
437 }
438 t.Fatalf("no indexed row for %s", id)
439 return RequestRow{}
440 }
441 if got := rowOf(t, first.ID).Updated; !got.Equal(base.Add(time.Hour)) {
442 t.Fatalf("a fresh request updated = %v, want its opening commit %v", got, base.Add(time.Hour))
443 }
444
445 // A comment moves the date, and the commented change takes the first row.
446 at(3 * time.Hour)
447 comment, err := metadata.NewRequestComment("A review note", "wilbur", base.Add(3*time.Hour))
448 if err != nil {
449 t.Fatal(err)
450 }
451 if err := metadata.AddRequestComment(g, first.ID, comment, "wilbur"); err != nil {
452 t.Fatal(err)
453 }
454 if err := s.Ensure(m); err != nil {
455 t.Fatal(err)
456 }
457 if got := rowOf(t, first.ID).Updated; !got.Equal(base.Add(3 * time.Hour)) {
458 t.Fatalf("updated after a comment = %v, want %v", got, base.Add(3*time.Hour))
459 }
460 if got := rowOf(t, second.ID).Updated; !got.Equal(base.Add(2 * time.Hour)) {
461 t.Fatalf("the untouched request updated = %v, want %v", got, base.Add(2*time.Hour))
462 }
463 rows, err := s.Requests(m.ID, RequestFilter{})
464 if err != nil {
465 t.Fatal(err)
466 }
467 if len(rows) != 2 || rows[0].ID != first.ID {
468 t.Fatalf("default order = %+v, want the commented change first", rows)
469 }
470
471 // An approval is activity too: it changes the request's directory.
472 at(4 * time.Hour)
473 if _, err := metadata.ApproveRequest(g, first.ID, "wilbur", "main", requestHead, base.Add(4*time.Hour), "wilbur"); err != nil {
474 t.Fatal(err)
475 }
476 if err := s.Ensure(m); err != nil {
477 t.Fatal(err)
478 }
479 incremental := rowOf(t, first.ID).Updated
480 if !incremental.Equal(base.Add(4 * time.Hour)) {
481 t.Fatalf("updated after an approval = %v, want %v", incremental, base.Add(4*time.Hour))
482 }
483
484 if err := s.IndexRepo(m); err != nil {
485 t.Fatal(err)
486 }
487 if rebuilt := rowOf(t, first.ID).Updated; !rebuilt.Equal(incremental) {
488 t.Fatalf("rebuilt updated = %v, incremental = %v", rebuilt, incremental)
489 }
490}
491